UNPKG

chrome-devtools-frontend

Version:
218 lines (194 loc) • 9.42 kB
// 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); }, }); } } }, }; }, });