chrome-devtools-frontend
Version:
Chrome DevTools UI
218 lines (194 loc) • 9.42 kB
text/typescript
// Copyright 2022 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import type {TSESLint, TSESTree} from '@typescript-eslint/utils';
import {createRule} from './utils/ruleCreator.ts';
type ImportDeclaration = TSESTree.ImportDeclaration;
type ImportSpecifier = TSESTree.ImportSpecifier;
type RuleFixer = TSESLint.RuleFixer;
type RuleContext = TSESLint.RuleContext<'inlineTypeImport'|'convertTypeImport', []>;
export default createRule({
name: 'inline-type-imports',
meta: {
type: 'problem',
docs: {
description: 'Inline type imports.',
category: 'Possible Errors',
},
fixable: 'code',
messages: {
inlineTypeImport: 'Type imports must be imported in the same import statement as values, using the type keyword',
convertTypeImport: 'Type imports must use the type modifier on each item, not on the overall import statement',
},
schema: [], // no options
},
defaultOptions: [],
create: function(context: RuleContext) {
// Stores any type imports (import type {} from ...).
// The key is the literal import path ("../foo.js");
const typeImports = new Map<string, ImportDeclaration>();
// Stores any value imports (import {} from ...).
// The key is the literal import path ("../foo.js");
const valueImports = new Map<string, ImportDeclaration>();
// Takes the node that represents an import ("Foo", "Foo as Bar") and
// return the literal text.
function getTextForImportSpecifier(specifier: ImportSpecifier): string {
// => import {Foo as Bar} from 'foo';
// Foo = imported name
// Bar = local name
const localName = specifier.local.name;
const importedName =
specifier.imported.type === 'Identifier' ? specifier.imported.name : specifier.imported.value;
// => import Foo from 'foo;
if (localName === importedName) {
// No `X as Y`, so just use either name.
return localName;
}
return `${importedName} as ${localName}`;
}
function mergeImports(fixer: RuleFixer, typeImportNode: ImportDeclaration, valueImportNode: ImportDeclaration) {
// Get all the references from the type import node that we need to add to the value import node.
const typeImportSpecifiers =
typeImportNode.specifiers.filter((spec): spec is ImportSpecifier => spec.type === 'ImportSpecifier')
.map(spec => {
return getTextForImportSpecifier(spec);
});
// Find the last value specifier, which we will then insert the type imports to.
const lastValueSpecifier = valueImportNode.specifiers[valueImportNode.specifiers.length - 1];
// Remember that we don't need to concern ourselves with indentation: in
// PRESUBMIT clang-format runs _after_ ESLint, so we can let Clang tidy
// up any rough edges.
const textToImport = ', ' + typeImportSpecifiers.map(spec => `type ${spec}`).join(', ');
return [
// Remove the type import
fixer.remove(typeImportNode),
// Add the type imports to the existing import
fixer.insertTextAfter(lastValueSpecifier, textToImport),
];
}
function extractTypeImportKeyword(fixer: RuleFixer, valueImportNode: ImportDeclaration) {
if (!valueImportNode.range) {
return [];
}
const importStart = valueImportNode.range[0];
// Find the 'type' keyword after 'import'. It should be the first token after 'import'.
const sourceCode = context.sourceCode ?? context.getSourceCode();
const importToken = sourceCode.getFirstToken(valueImportNode);
const typeToken = importToken ? sourceCode.getTokenAfter(importToken) : null;
const fixes: TSESLint.RuleFix[] = [];
if (typeToken && typeToken.value === 'type' && typeToken.range) {
// Remove the " type" text after "import".
fixes.push(fixer.removeRange(typeToken.range));
// Potentially remove the space after 'type' if it exists
const spaceAfterType = sourceCode.getTokenAfter(typeToken);
if (spaceAfterType?.range && spaceAfterType.range[0] === typeToken.range[1]) {
fixes.push(fixer.removeRange([typeToken.range[1], spaceAfterType.range[0]]));
}
}
// We need to remove the "type" text before each specifier.
const removeTypeFromSpecifiersFixers =
valueImportNode.specifiers.filter(spec => spec.type === 'ImportSpecifier')
.map(spec => {
if (spec.importKind === 'type') {
const typeKeywordToken = sourceCode.getFirstToken(spec);
if (typeKeywordToken && typeKeywordToken.value === 'type' && typeKeywordToken.range) {
const spaceAfter = sourceCode.getTokenAfter(typeKeywordToken);
// Remove 'type' and the space after it
const endRange = (spaceAfter?.range) ? spaceAfter.range[0] : typeKeywordToken.range[1];
return fixer.removeRange([typeKeywordToken.range[0], endRange]);
}
}
return null; // Return null for specifiers without 'type' or if token not found
})
.filter((fix): fix is TSESLint.RuleFix => fix !== null); // Filter out nulls
// Insert ' type' after 'import'
fixes.push(fixer.insertTextAfterRange([importStart, importStart + 6], ' type')); // 6 = length of "import"
return [
...fixes,
...removeTypeFromSpecifiersFixers,
];
}
return {
ImportDeclaration(node) {
// Note that we only care about named imports: import {} from 'foo.js'.
// This is because:
// 1: if we have `import type * as SDK from '../` that means we know we
// aren't using `SDK` for any values, otherwise we wouldn't have the
// `type` modifier.
// 2: similarly, `import type Foo from './foo'` follows (1). We also
// don't use this pattern in DevTools, but even if we did we don't have
// to worry about it.
// 3: Any side-effect imports (import './foo.js') are irrelevant.
if (!node.specifiers || node.specifiers.length < 1) {
// => import './foo.js';
return;
}
if (node.specifiers[0].type === 'ImportDefaultSpecifier') {
// => import Foo from './foo.js';
return;
}
if (node.specifiers[0].type === 'ImportNamespaceSpecifier') {
// => import * as Foo from './foo.js';
return;
}
// Store the import
const importFilePath = node.source.value;
if (node.importKind === 'type') {
typeImports.set(importFilePath, node);
} else if (node.importKind === 'value') {
// Check if all specifiers are type imports even if the declaration kind is 'value'
const allSpecifiersAreTypes =
node.specifiers.every(s => s.type === 'ImportSpecifier' && s.importKind === 'type');
if (allSpecifiersAreTypes) {
// Treat `import {type A} from '...'` as a type import for merging purposes initially.
// It will be handled later in Program:exit if no value import exists.
typeImports.set(importFilePath, node);
} else {
valueImports.set(importFilePath, node);
}
}
},
'Program:exit'() {
// Loop over the type imports and see if there are any matching value
// imports.
// Looping this way means if there are any value imports without a
// matching type import, we leave them alone.
for (const [typeImportFilePath, typeImportNode] of typeImports) {
const valueImportNodeForFilePath = valueImports.get(typeImportFilePath);
// Check if the typeImportNode was originally `import {type A}` which we stored earlier
const typeImportIsSpecifierOnly = typeImportNode.importKind === 'value' &&
typeImportNode.specifiers.every(s => s.type === 'ImportSpecifier' && s.importKind === 'type');
if (valueImportNodeForFilePath) {
// If we've got here, we have two imports for the same file-path, one
// for types (either `import type {A}` or `import {type A}`),
// and one for values, so let's merge them.
context.report({
node: typeImportNode,
messageId: 'inlineTypeImport',
fix(fixer) {
return mergeImports(
fixer,
typeImportNode,
valueImportNodeForFilePath,
);
},
});
// Since we merged, remove this from typeImports map so it's not processed below
typeImports.delete(typeImportFilePath);
continue;
} else if (typeImportIsSpecifierOnly) {
// If there's no corresponding value import, and this was an `import {type A}` style,
// convert it to `import type {A}`.
context.report({
node: typeImportNode,
messageId: 'convertTypeImport',
fix(fixer) {
return extractTypeImportKeyword(fixer, typeImportNode);
},
});
}
}
},
};
},
});