diff --git a/packages/schematics/angular/utility/ast-utils.ts b/packages/schematics/angular/utility/ast-utils.ts index 5bd165c803..9d25bc8f3a 100644 --- a/packages/schematics/angular/utility/ast-utils.ts +++ b/packages/schematics/angular/utility/ast-utils.ts @@ -665,3 +665,52 @@ export function addRouteDeclarationToModule( return new InsertChange(fileToAdd, insertPos, route); } + +/** Asserts if the specified node is a named declaration (e.g. class, interface). */ +function isNamedNode( + node: ts.Node & { name?: ts.Node }, +): node is ts.Node & { name: ts.Identifier } { + return !!node.name && ts.isIdentifier(node.name); +} + +/** + * Determines if a SourceFile has a top-level declaration whose name matches a specific symbol. + * Can be used to avoid conflicts when inserting new imports into a file. + * @param sourceFile File in which to search. + * @param symbolName Name of the symbol to search for. + * @param skipModule Path of the module that the symbol may have been imported from. Used to + * avoid false positives where the same symbol we're looking for may have been imported. + */ +export function hasTopLevelIdentifier( + sourceFile: ts.SourceFile, + symbolName: string, + skipModule: string | null = null, +): boolean { + for (const node of sourceFile.statements) { + if (isNamedNode(node) && node.name.text === symbolName) { + return true; + } + + if ( + ts.isVariableStatement(node) && + node.declarationList.declarations.some((decl) => { + return isNamedNode(decl) && decl.name.text === symbolName; + }) + ) { + return true; + } + + if ( + ts.isImportDeclaration(node) && + ts.isStringLiteralLike(node.moduleSpecifier) && + node.moduleSpecifier.text !== skipModule && + node.importClause?.namedBindings && + ts.isNamedImports(node.importClause.namedBindings) && + node.importClause.namedBindings.elements.some((el) => el.name.text === symbolName) + ) { + return true; + } + } + + return false; +} diff --git a/packages/schematics/angular/utility/ast-utils_spec.ts b/packages/schematics/angular/utility/ast-utils_spec.ts index 779e7fd0f7..6b8accde1f 100644 --- a/packages/schematics/angular/utility/ast-utils_spec.ts +++ b/packages/schematics/angular/utility/ast-utils_spec.ts @@ -18,6 +18,7 @@ import { addRouteDeclarationToModule, addSymbolToNgModuleMetadata, findNodes, + hasTopLevelIdentifier, insertAfterLastOccurrence, insertImport, } from './ast-utils'; @@ -776,4 +777,74 @@ describe('ast utils', () => { expect(result).toBe(fileContent); }); }); + + describe('hasTopLevelIdentifier', () => { + const filePath = './src/foo.ts'; + + it('should find top-level class declaration with a specific name', () => { + const fileContent = `class FooClass {}`; + const source = getTsSource(filePath, fileContent); + + expect(hasTopLevelIdentifier(source, 'FooClass')).toBe(true); + expect(hasTopLevelIdentifier(source, 'Foo')).toBe(false); + }); + + it('should find top-level interface declaration with a specific name', () => { + const fileContent = `interface FooInterface {}`; + const source = getTsSource(filePath, fileContent); + + expect(hasTopLevelIdentifier(source, 'FooInterface')).toBe(true); + expect(hasTopLevelIdentifier(source, 'Foo')).toBe(false); + }); + + it('should find top-level variable declaration with a specific name', () => { + const fileContent = ` + const singleVar = 1; + + const fooVar = 1, barVar = 2; + `; + const source = getTsSource(filePath, fileContent); + + expect(hasTopLevelIdentifier(source, 'singleVar')).toBe(true); + expect(hasTopLevelIdentifier(source, 'fooVar')).toBe(true); + expect(hasTopLevelIdentifier(source, 'barVar')).toBe(true); + expect(hasTopLevelIdentifier(source, 'bar')).toBe(false); + }); + + it('should find top-level imports with a specific name', () => { + const fileContent = ` + import { FooInterface } from '@foo/interfaces'; + + class FooClass implements FooInterface {} + `; + const source = getTsSource(filePath, fileContent); + + expect(hasTopLevelIdentifier(source, 'FooInterface')).toBe(true); + expect(hasTopLevelIdentifier(source, 'Foo')).toBe(false); + }); + + it('should find top-level aliased imports with a specific name', () => { + const fileContent = ` + import { FooInterface as AliasedFooInterface } from '@foo/interfaces'; + + class FooClass implements AliasedFooInterface {} + `; + const source = getTsSource(filePath, fileContent); + + expect(hasTopLevelIdentifier(source, 'AliasedFooInterface')).toBe(true); + expect(hasTopLevelIdentifier(source, 'FooInterface')).toBe(false); + expect(hasTopLevelIdentifier(source, 'Foo')).toBe(false); + }); + + it('should be able to skip imports from a certain module', () => { + const fileContent = ` + import { FooInterface } from '@foo/interfaces'; + + class FooClass implements FooInterface {} + `; + const source = getTsSource(filePath, fileContent); + + expect(hasTopLevelIdentifier(source, 'FooInterface', '@foo/interfaces')).toBe(false); + }); + }); });