mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
refactor(compiler-cli): add the ability to remove imports from the import manager (#57110)
Extends the `ImportManager` to allow for imports to be removed. This will be useful in automated migrations. PR Close #57110
This commit is contained in:
parent
be45346f2d
commit
2158b8dafb
3 changed files with 337 additions and 24 deletions
|
|
@ -75,6 +75,13 @@ export class ImportManager
|
|||
}
|
||||
> = new Map();
|
||||
|
||||
/**
|
||||
* Keeps track of imports marked for removal. The root-level key is the file from which the
|
||||
* import should be removed, the inner map key is the name of the module from which the symbol
|
||||
* is being imported. The value of the inner map is a set of symbol names that should be removed.
|
||||
* Note! the inner map tracks the original names of the imported symbols, not their local aliases.
|
||||
*/
|
||||
private removedImports: Map<ts.SourceFile, Map<ModuleName, Set<string>>> = new Map();
|
||||
private nextUniqueIndex = 0;
|
||||
private config: ImportManagerConfig;
|
||||
|
||||
|
|
@ -142,6 +149,14 @@ export class ImportManager
|
|||
);
|
||||
}
|
||||
|
||||
// Remove the newly-added import from the set of removed imports.
|
||||
if (request.exportSymbolName !== null && !request.asTypeReference) {
|
||||
this.removedImports
|
||||
.get(request.requestedFile)
|
||||
?.get(request.exportModuleSpecifier as ModuleName)
|
||||
?.delete(request.exportSymbolName);
|
||||
}
|
||||
|
||||
// Attempt to re-use previous identical import requests.
|
||||
const previousGeneratedImportRef = attemptToReuseGeneratedImports(
|
||||
this.reuseGeneratedImportsTracker,
|
||||
|
|
@ -157,6 +172,33 @@ export class ImportManager
|
|||
return createImportReference(!!request.asTypeReference, resultImportRef);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks all imported symbols with a specific name for removal.
|
||||
* Call `addImport` to undo this operation.
|
||||
* @param requestedFile File from which to remove the imports.
|
||||
* @param exportSymbolName Declared name of the symbol being removed.
|
||||
* @param moduleSpecifier Module from which the symbol is being imported.
|
||||
*/
|
||||
removeImport(
|
||||
requestedFile: ts.SourceFile,
|
||||
exportSymbolName: string,
|
||||
moduleSpecifier: string,
|
||||
): void {
|
||||
let moduleMap = this.removedImports.get(requestedFile);
|
||||
if (!moduleMap) {
|
||||
moduleMap = new Map();
|
||||
this.removedImports.set(requestedFile, moduleMap);
|
||||
}
|
||||
|
||||
let removedSymbols = moduleMap.get(moduleSpecifier as ModuleName);
|
||||
if (!removedSymbols) {
|
||||
removedSymbols = new Set();
|
||||
moduleMap.set(moduleSpecifier as ModuleName, removedSymbols);
|
||||
}
|
||||
|
||||
removedSymbols.add(exportSymbolName);
|
||||
}
|
||||
|
||||
private _generateNewImport(
|
||||
request: ImportRequest<ts.SourceFile>,
|
||||
): ts.Identifier | [ts.Identifier, ts.Identifier] {
|
||||
|
|
@ -255,10 +297,13 @@ export class ImportManager
|
|||
updatedImports: Map<ts.NamedImports, ts.NamedImports>;
|
||||
newImports: Map<string, ts.ImportDeclaration[]>;
|
||||
reusedOriginalAliasDeclarations: Set<AliasImportDeclaration>;
|
||||
deletedImports: Set<ts.ImportDeclaration>;
|
||||
} {
|
||||
const affectedFiles = new Set<string>();
|
||||
const updatedImportsResult = new Map<ts.NamedImports, ts.NamedImports>();
|
||||
const newImportsResult = new Map<string, ts.ImportDeclaration[]>();
|
||||
const deletedImports = new Set<ts.ImportDeclaration>();
|
||||
const importDeclarationsPerFile = new Map<ts.SourceFile, ts.ImportDeclaration[]>();
|
||||
|
||||
const addNewImport = (fileName: string, importDecl: ts.ImportDeclaration) => {
|
||||
affectedFiles.add(fileName);
|
||||
|
|
@ -271,10 +316,11 @@ export class ImportManager
|
|||
|
||||
// Collect original source file imports that need to be updated.
|
||||
this.reuseSourceFileImportsTracker.updatedImports.forEach((expressions, importDecl) => {
|
||||
const sourceFile = importDecl.getSourceFile();
|
||||
const namedBindings = importDecl.importClause!.namedBindings as ts.NamedImports;
|
||||
const newNamedBindings = ts.factory.updateNamedImports(
|
||||
namedBindings,
|
||||
namedBindings.elements.concat(
|
||||
const moduleName = (importDecl.moduleSpecifier as ts.StringLiteral).text as ModuleName;
|
||||
const newElements = namedBindings.elements
|
||||
.concat(
|
||||
expressions.map(({propertyName, fileUniqueAlias}) =>
|
||||
ts.factory.createImportSpecifier(
|
||||
false,
|
||||
|
|
@ -282,11 +328,60 @@ export class ImportManager
|
|||
fileUniqueAlias ?? propertyName,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
)
|
||||
.filter((specifier) => this._canAddSpecifier(sourceFile, moduleName, specifier));
|
||||
|
||||
affectedFiles.add(importDecl.getSourceFile().fileName);
|
||||
updatedImportsResult.set(namedBindings, newNamedBindings);
|
||||
affectedFiles.add(sourceFile.fileName);
|
||||
|
||||
if (newElements.length === 0) {
|
||||
deletedImports.add(importDecl);
|
||||
} else {
|
||||
updatedImportsResult.set(
|
||||
namedBindings,
|
||||
ts.factory.updateNamedImports(namedBindings, newElements),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
this.removedImports.forEach((removeMap, sourceFile) => {
|
||||
if (removeMap.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let allImports = importDeclarationsPerFile.get(sourceFile);
|
||||
|
||||
if (!allImports) {
|
||||
allImports = sourceFile.statements.filter(ts.isImportDeclaration);
|
||||
importDeclarationsPerFile.set(sourceFile, allImports);
|
||||
}
|
||||
|
||||
for (const node of allImports) {
|
||||
if (
|
||||
!node.importClause?.namedBindings ||
|
||||
!ts.isNamedImports(node.importClause.namedBindings) ||
|
||||
this.reuseSourceFileImportsTracker.updatedImports.has(node) ||
|
||||
deletedImports.has(node)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const namedBindings = node.importClause.namedBindings;
|
||||
const moduleName = (node.moduleSpecifier as ts.StringLiteral).text as ModuleName;
|
||||
const newImports = namedBindings.elements.filter((specifier) =>
|
||||
this._canAddSpecifier(sourceFile, moduleName, specifier),
|
||||
);
|
||||
|
||||
if (newImports.length === 0) {
|
||||
affectedFiles.add(sourceFile.fileName);
|
||||
deletedImports.add(node);
|
||||
} else if (newImports.length !== namedBindings.elements.length) {
|
||||
affectedFiles.add(sourceFile.fileName);
|
||||
updatedImportsResult.set(
|
||||
namedBindings,
|
||||
ts.factory.updateNamedImports(namedBindings, newImports),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Collect all new imports to be added. Named imports, namespace imports or side-effects.
|
||||
|
|
@ -324,17 +419,23 @@ export class ImportManager
|
|||
});
|
||||
|
||||
namedImports.forEach((specifiers, moduleName) => {
|
||||
const newImport = ts.factory.createImportDeclaration(
|
||||
undefined,
|
||||
ts.factory.createImportClause(
|
||||
false,
|
||||
undefined,
|
||||
ts.factory.createNamedImports(specifiers),
|
||||
),
|
||||
ts.factory.createStringLiteral(moduleName, useSingleQuotes),
|
||||
const filteredSpecifiers = specifiers.filter((specifier) =>
|
||||
this._canAddSpecifier(sourceFile, moduleName, specifier),
|
||||
);
|
||||
|
||||
addNewImport(fileName, newImport);
|
||||
if (filteredSpecifiers.length > 0) {
|
||||
const newImport = ts.factory.createImportDeclaration(
|
||||
undefined,
|
||||
ts.factory.createImportClause(
|
||||
false,
|
||||
undefined,
|
||||
ts.factory.createNamedImports(filteredSpecifiers),
|
||||
),
|
||||
ts.factory.createStringLiteral(moduleName, useSingleQuotes),
|
||||
);
|
||||
|
||||
addNewImport(fileName, newImport);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -343,6 +444,7 @@ export class ImportManager
|
|||
newImports: newImportsResult,
|
||||
updatedImports: updatedImportsResult,
|
||||
reusedOriginalAliasDeclarations: this.reuseSourceFileImportsTracker.reusedAliasDeclarations,
|
||||
deletedImports,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -387,6 +489,17 @@ export class ImportManager
|
|||
}
|
||||
return this.newImports.get(file)!;
|
||||
}
|
||||
|
||||
private _canAddSpecifier(
|
||||
sourceFile: ts.SourceFile,
|
||||
moduleSpecifier: ModuleName,
|
||||
specifier: ts.ImportSpecifier,
|
||||
): boolean {
|
||||
return !this.removedImports
|
||||
.get(sourceFile)
|
||||
?.get(moduleSpecifier)
|
||||
?.has((specifier.propertyName || specifier.name).text);
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates an import reference based on the given identifier, or nested access. */
|
||||
|
|
|
|||
|
|
@ -18,14 +18,20 @@ import type {ImportManager} from './import_manager';
|
|||
* - The transform updates existing imports with new symbols to be added.
|
||||
* - The transform adds new necessary imports.
|
||||
* - The transform inserts additional optional statements after imports.
|
||||
* - The transform deletes any nodes that are marked for deletion by the manager.
|
||||
*/
|
||||
export function createTsTransformForImportManager(
|
||||
manager: ImportManager,
|
||||
extraStatementsForFiles?: Map<string, ts.Statement[]>,
|
||||
): ts.TransformerFactory<ts.SourceFile> {
|
||||
return (ctx) => {
|
||||
const {affectedFiles, newImports, updatedImports, reusedOriginalAliasDeclarations} =
|
||||
manager.finalize();
|
||||
const {
|
||||
affectedFiles,
|
||||
newImports,
|
||||
updatedImports,
|
||||
reusedOriginalAliasDeclarations,
|
||||
deletedImports,
|
||||
} = manager.finalize();
|
||||
|
||||
// If we re-used existing source file alias declarations, mark those as referenced so TypeScript
|
||||
// doesn't drop these thinking they are unused.
|
||||
|
|
@ -45,12 +51,16 @@ export function createTsTransformForImportManager(
|
|||
}
|
||||
}
|
||||
|
||||
const visitStatement: ts.Visitor<ts.Node> = (node) => {
|
||||
if (
|
||||
!ts.isImportDeclaration(node) ||
|
||||
node.importClause === undefined ||
|
||||
!ts.isImportClause(node.importClause)
|
||||
) {
|
||||
const visitStatement: ts.Visitor<ts.Node, ts.Node | undefined> = (node) => {
|
||||
if (!ts.isImportDeclaration(node)) {
|
||||
return node;
|
||||
}
|
||||
|
||||
if (deletedImports.has(node)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (node.importClause === undefined || !ts.isImportClause(node.importClause)) {
|
||||
return node;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -891,6 +891,196 @@ describe('import manager', () => {
|
|||
`),
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove a pre-existing import from a declaration', () => {
|
||||
const {testFile, emit} = createTestProgram(`
|
||||
import { input, output, model } from '@angular/core';
|
||||
input();
|
||||
output();
|
||||
model();
|
||||
`);
|
||||
const manager = new ImportManager();
|
||||
|
||||
manager.removeImport(testFile, 'output', '@angular/core');
|
||||
const res = emit(manager, []);
|
||||
|
||||
expect(res).toBe(
|
||||
omitLeadingWhitespace(`
|
||||
import { input, model } from '@angular/core';
|
||||
input();
|
||||
output();
|
||||
model();
|
||||
`),
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove the entire declaration if all pre-existing imports are removed', () => {
|
||||
const {testFile, emit} = createTestProgram(`
|
||||
import { input, output } from '@angular/core';
|
||||
input();
|
||||
output();
|
||||
`);
|
||||
const manager = new ImportManager();
|
||||
|
||||
manager.removeImport(testFile, 'input', '@angular/core');
|
||||
manager.removeImport(testFile, 'output', '@angular/core');
|
||||
|
||||
expect(emit(manager, [])).toBe(
|
||||
omitLeadingWhitespace(`
|
||||
input();
|
||||
output();
|
||||
export {};
|
||||
`),
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove a pre-existing aliased import', () => {
|
||||
const {testFile, emit} = createTestProgram(`
|
||||
import { input, output as foo } from '@angular/core';
|
||||
input();
|
||||
foo();
|
||||
`);
|
||||
const manager = new ImportManager();
|
||||
|
||||
manager.removeImport(testFile, 'output', '@angular/core');
|
||||
|
||||
expect(emit(manager, [])).toBe(
|
||||
omitLeadingWhitespace(`
|
||||
import { input } from '@angular/core';
|
||||
input();
|
||||
foo();
|
||||
`),
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove all pre-existing instances of a specific import', () => {
|
||||
const {testFile, emit} = createTestProgram(`
|
||||
import { input, input as foo } from '@angular/core';
|
||||
import { input as bar } from '@angular/core';
|
||||
input();
|
||||
foo();
|
||||
bar();
|
||||
`);
|
||||
const manager = new ImportManager();
|
||||
|
||||
manager.removeImport(testFile, 'input', '@angular/core');
|
||||
|
||||
expect(emit(manager, [])).toBe(
|
||||
omitLeadingWhitespace(`
|
||||
input();
|
||||
foo();
|
||||
bar();
|
||||
export {};
|
||||
`),
|
||||
);
|
||||
});
|
||||
|
||||
it('should be able to remove from an import that is being modified', () => {
|
||||
const {testFile, emit} = createTestProgram(`
|
||||
import { input } from '@angular/core';
|
||||
input();
|
||||
`);
|
||||
const manager = new ImportManager();
|
||||
|
||||
const ref = manager.addImport({
|
||||
exportModuleSpecifier: '@angular/core',
|
||||
exportSymbolName: 'foo',
|
||||
requestedFile: testFile,
|
||||
});
|
||||
|
||||
manager.removeImport(testFile, 'input', '@angular/core');
|
||||
const res = emit(manager, [ts.factory.createExpressionStatement(ref)]);
|
||||
|
||||
expect(res).toBe(
|
||||
omitLeadingWhitespace(`
|
||||
import { foo } from '@angular/core';
|
||||
foo;
|
||||
input();
|
||||
`),
|
||||
);
|
||||
});
|
||||
|
||||
it('should be able to remove a symbol from a newly-created import declaration', () => {
|
||||
const {testFile, emit} = createTestProgram('');
|
||||
const manager = new ImportManager();
|
||||
|
||||
const inputRef = manager.addImport({
|
||||
exportModuleSpecifier: '@angular/core',
|
||||
exportSymbolName: 'input',
|
||||
requestedFile: testFile,
|
||||
});
|
||||
|
||||
const outputRef = manager.addImport({
|
||||
exportModuleSpecifier: '@angular/core',
|
||||
exportSymbolName: 'output',
|
||||
requestedFile: testFile,
|
||||
});
|
||||
|
||||
manager.removeImport(testFile, 'input', '@angular/core');
|
||||
|
||||
const res = emit(manager, [
|
||||
ts.factory.createExpressionStatement(inputRef),
|
||||
ts.factory.createExpressionStatement(outputRef),
|
||||
]);
|
||||
|
||||
expect(res).toBe(
|
||||
omitLeadingWhitespace(`
|
||||
import { output } from "@angular/core";
|
||||
input;
|
||||
output;
|
||||
`),
|
||||
);
|
||||
});
|
||||
|
||||
it('should add a symbol if addImport is called after removeImport', () => {
|
||||
const {testFile, emit} = createTestProgram('');
|
||||
const manager = new ImportManager();
|
||||
|
||||
manager.addImport({
|
||||
exportModuleSpecifier: '@angular/core',
|
||||
exportSymbolName: 'input',
|
||||
requestedFile: testFile,
|
||||
});
|
||||
|
||||
manager.removeImport(testFile, 'input', '@angular/core');
|
||||
|
||||
const ref = manager.addImport({
|
||||
exportModuleSpecifier: '@angular/core',
|
||||
exportSymbolName: 'input',
|
||||
requestedFile: testFile,
|
||||
});
|
||||
|
||||
const res = emit(manager, [ts.factory.createExpressionStatement(ref)]);
|
||||
|
||||
expect(res).toBe(
|
||||
omitLeadingWhitespace(`
|
||||
import { input } from "@angular/core";
|
||||
input;
|
||||
`),
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove a newly-added aliased import', () => {
|
||||
const {testFile, emit} = createTestProgram('');
|
||||
const manager = new ImportManager();
|
||||
|
||||
const inputRef = manager.addImport({
|
||||
exportModuleSpecifier: '@angular/core',
|
||||
exportSymbolName: 'input',
|
||||
unsafeAliasOverride: 'foo',
|
||||
requestedFile: testFile,
|
||||
});
|
||||
|
||||
manager.removeImport(testFile, 'input', '@angular/core');
|
||||
|
||||
const res = emit(manager, [ts.factory.createExpressionStatement(inputRef)]);
|
||||
|
||||
expect(res).toBe(
|
||||
omitLeadingWhitespace(`
|
||||
foo;
|
||||
`),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function createTestProgram(text: string): {
|
||||
|
|
|
|||
Loading…
Reference in a new issue