|
| 1 | +// @ignoreDep typescript |
| 2 | +import * as ts from 'typescript'; |
| 3 | + |
| 4 | +import { collectDeepNodes } from './ast_helpers'; |
| 5 | +import { RemoveNodeOperation, TransformOperation } from './interfaces'; |
| 6 | + |
| 7 | + |
| 8 | +interface RemovedSymbol { |
| 9 | + symbol: ts.Symbol; |
| 10 | + importDecl: ts.ImportDeclaration; |
| 11 | + importSpec: ts.ImportSpecifier; |
| 12 | + singleImport: boolean; |
| 13 | + removed: ts.Identifier[]; |
| 14 | + all: ts.Identifier[]; |
| 15 | +} |
| 16 | + |
| 17 | +// Remove imports for which all identifiers have been removed. |
| 18 | +// Needs type checker, and works even if it's not the first transformer. |
| 19 | +// Works by removing imports for symbols whose identifiers have all been removed. |
| 20 | +// Doesn't use the `symbol.declarations` because that previous transforms might have removed nodes |
| 21 | +// but the type checker doesn't know. |
| 22 | +// See https://github.com/Microsoft/TypeScript/issues/17552 for more information. |
| 23 | +export function elideImports( |
| 24 | + sourceFile: ts.SourceFile, |
| 25 | + removedNodes: ts.Node[], |
| 26 | + getTypeChecker: () => ts.TypeChecker, |
| 27 | +): TransformOperation[] { |
| 28 | + const ops: TransformOperation[] = []; |
| 29 | + |
| 30 | + if (removedNodes.length === 0) { |
| 31 | + return []; |
| 32 | + } |
| 33 | + |
| 34 | + // Get all children identifiers inside the removed nodes. |
| 35 | + const removedIdentifiers = removedNodes |
| 36 | + .map((node) => collectDeepNodes<ts.Identifier>(node, ts.SyntaxKind.Identifier)) |
| 37 | + .reduce((prev, curr) => prev.concat(curr), []) |
| 38 | + // Also add the top level nodes themselves if they are identifiers. |
| 39 | + .concat(removedNodes.filter((node) => |
| 40 | + node.kind === ts.SyntaxKind.Identifier) as ts.Identifier[]); |
| 41 | + |
| 42 | + if (removedIdentifiers.length === 0) { |
| 43 | + return []; |
| 44 | + } |
| 45 | + |
| 46 | + // Get all imports in the source file. |
| 47 | + const allImports = collectDeepNodes<ts.ImportDeclaration>( |
| 48 | + sourceFile, ts.SyntaxKind.ImportDeclaration); |
| 49 | + |
| 50 | + if (allImports.length === 0) { |
| 51 | + return []; |
| 52 | + } |
| 53 | + |
| 54 | + const removedSymbolMap: Map<string, RemovedSymbol> = new Map(); |
| 55 | + const typeChecker = getTypeChecker(); |
| 56 | + |
| 57 | + // Find all imports that use a removed identifier and add them to the map. |
| 58 | + allImports |
| 59 | + .filter((node: ts.ImportDeclaration) => { |
| 60 | + // TODO: try to support removing `import * as X from 'XYZ'`. |
| 61 | + // Filter out import statements that are either `import 'XYZ'` or `import * as X from 'XYZ'`. |
| 62 | + const clause = node.importClause as ts.ImportClause; |
| 63 | + if (!clause || clause.name || !clause.namedBindings) { |
| 64 | + return false; |
| 65 | + } |
| 66 | + return clause.namedBindings.kind == ts.SyntaxKind.NamedImports; |
| 67 | + }) |
| 68 | + .forEach((importDecl: ts.ImportDeclaration) => { |
| 69 | + const importClause = importDecl.importClause as ts.ImportClause; |
| 70 | + const namedImports = importClause.namedBindings as ts.NamedImports; |
| 71 | + |
| 72 | + namedImports.elements.forEach((importSpec: ts.ImportSpecifier) => { |
| 73 | + const importId = importSpec.name; |
| 74 | + const symbol = typeChecker.getSymbolAtLocation(importId); |
| 75 | + |
| 76 | + const removedNodesForImportId = removedIdentifiers.filter((id) => |
| 77 | + id.text === importId.text && typeChecker.getSymbolAtLocation(id) === symbol); |
| 78 | + |
| 79 | + if (removedNodesForImportId.length > 0) { |
| 80 | + removedSymbolMap.set(importId.text, { |
| 81 | + symbol, |
| 82 | + importDecl, |
| 83 | + importSpec, |
| 84 | + singleImport: namedImports.elements.length === 1, |
| 85 | + removed: removedNodesForImportId, |
| 86 | + all: [] |
| 87 | + }); |
| 88 | + } |
| 89 | + }); |
| 90 | + }); |
| 91 | + |
| 92 | + |
| 93 | + if (removedSymbolMap.size === 0) { |
| 94 | + return []; |
| 95 | + } |
| 96 | + |
| 97 | + // Find all identifiers in the source file that have a removed symbol, and add them to the map. |
| 98 | + collectDeepNodes<ts.Identifier>(sourceFile, ts.SyntaxKind.Identifier) |
| 99 | + .forEach((id) => { |
| 100 | + if (removedSymbolMap.has(id.text)) { |
| 101 | + const symbol = removedSymbolMap.get(id.text); |
| 102 | + if (typeChecker.getSymbolAtLocation(id) === symbol.symbol) { |
| 103 | + symbol.all.push(id); |
| 104 | + } |
| 105 | + } |
| 106 | + }); |
| 107 | + |
| 108 | + Array.from(removedSymbolMap.values()) |
| 109 | + .filter((symbol) => { |
| 110 | + // If the number of removed imports plus one (the import specifier) is equal to the total |
| 111 | + // number of identifiers for that symbol, it's safe to remove the import. |
| 112 | + return symbol.removed.length + 1 === symbol.all.length; |
| 113 | + }) |
| 114 | + .forEach((symbol) => { |
| 115 | + // Remove the whole declaration if it's a single import. |
| 116 | + const nodeToRemove = symbol.singleImport ? symbol.importSpec : symbol.importDecl; |
| 117 | + ops.push(new RemoveNodeOperation(sourceFile, nodeToRemove)); |
| 118 | + }); |
| 119 | + |
| 120 | + return ops; |
| 121 | +} |
0 commit comments