/
githubmirror
/
angular
Обзор
Документация
Войти
/
githubmirror
/
angular
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
packages/language-service/src/utils/ts_utils.ts
1 097 строк
35 KB
Andrew Scott
refactor: remove all deep imports in language service
13 апр 2026, 11:16
13 апр 2026, 11:16
1ca9d28
Код
Авторство
О чём код?
/** * @license * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ import {guessIndentationInSingleLine} from './format'; import * as path from 'path'; import { ClassDeclaration, DirectiveModuleExportDetails, NgCompiler, PotentialDirective, PotentialDirectiveModuleSpecifierResolver, PotentialImportMode, PotentialPipe, Reference, SymbolReference, TemplateTypeChecker, TsCompletionEntryInfo, } from '@angular/compiler-cli'; import ts from 'typescript'; /** * Return the node that most tightly encompasses the specified `position`. * @param node The starting node to start the top-down search. * @param position The target position within the `node`. */ export function findTightestNode(node: ts.Node, position: number): ts.Node | undefined { if (node.getStart() <= position && position < node.getEnd()) { return node.forEachChild((c) => findTightestNode(c, position)) ?? node; } return undefined; } export interface FindOptions<T extends ts.Node> { filter: (node: ts.Node) => node is T; position?: number; } /** * Finds TypeScript nodes descending from the provided root which match the given filter. */ export function findAllMatchingNodes<T extends ts.Node>(root: ts.Node, opts: FindOptions<T>): T[] { const matches: T[] = []; const explore = (currNode: ts.Node) => { if (opts.filter(currNode)) { matches.push(currNode); } currNode.forEachChild((descendent) => explore(descendent)); }; explore(root); return matches; } /** * Finds TypeScript nodes descending from the provided root which match the given filter. */ export function findFirstMatchingNode<T extends ts.Node>( root: ts.Node, opts: FindOptions<T>, ): T | null { let match: T | null = null; const explore = (currNode: ts.Node) => { if (match !== null) { return; } if (opts.position !== undefined) { if (currNode.getStart() > opts.position || opts.position >= currNode.getEnd()) { return; } } if (opts.filter(currNode)) { match = currNode; return; } currNode.forEachChild((descendent) => explore(descendent)); }; explore(root); return match; } /** * Resolves a ClassDeclaration from a SymbolReference. */ export function getClassDeclarationFromSymbolReference( ls: ts.LanguageService, ref: SymbolReference, ): ts.ClassDeclaration | null { const program = ls.getProgram(); if (!program) { return null; } const sf = program.getSourceFile(ref.filePath); if (!sf) { return null; } return findFirstMatchingNode(sf, { position: ref.position, filter: (node): node is ts.ClassDeclaration => ts.isClassDeclaration(node) && node.name?.getStart() === ref.position, }); } export function getParentClassDeclaration(startNode: ts.Node): ts.ClassDeclaration | undefined { while (startNode) { if (ts.isClassDeclaration(startNode)) { return startNode; } startNode = startNode.parent; } return undefined; } /** * Returns a property assignment from the assignment value if the property name * matches the specified `key`, or `null` if there is no match. */ export function getPropertyAssignmentFromValue( value: ts.Node, key: string, ): ts.PropertyAssignment | null { const propAssignment = value.parent; if ( !propAssignment || !ts.isPropertyAssignment(propAssignment) || propAssignment.name.getText() !== key ) { return null; } return propAssignment; } /** * Given a decorator property assignment, return the ClassDeclaration node that corresponds to the * directive class the property applies to. * If the property assignment is not on a class decorator, no declaration is returned. * * For example, * * @Component({ * template: '<div></div>' * ^^^^^^^^^^^^^^^^^^^^^^^---- property assignment * }) * class AppComponent {} * ^---- class declaration node * * @param propAsgnNode property assignment */ export function getClassDeclFromDecoratorProp( propAsgnNode: ts.PropertyAssignment, ): ts.ClassDeclaration | undefined { if (!propAsgnNode.parent || !ts.isObjectLiteralExpression(propAsgnNode.parent)) { return; } const objLitExprNode = propAsgnNode.parent; if (!objLitExprNode.parent || !ts.isCallExpression(objLitExprNode.parent)) { return; } const callExprNode = objLitExprNode.parent; if (!callExprNode.parent || !ts.isDecorator(callExprNode.parent)) { return; } const decorator = callExprNode.parent; if (!decorator.parent || !ts.isClassDeclaration(decorator.parent)) { return; } const classDeclNode = decorator.parent; return classDeclNode; } /** * Collects all member methods, including those from base classes. */ export function collectMemberMethods( clazz: ts.ClassDeclaration, typeChecker: ts.TypeChecker, ): ts.MethodDeclaration[] { const members: ts.MethodDeclaration[] = []; const apparentProps = typeChecker.getTypeAtLocation(clazz).getApparentProperties(); for (const prop of apparentProps) { if (prop.valueDeclaration && ts.isMethodDeclaration(prop.valueDeclaration)) { members.push(prop.valueDeclaration); } } return members; } /** * Given an existing array literal expression, update it by pushing a new expression. */ export function addElementToArrayLiteral( arr: ts.ArrayLiteralExpression, elem: ts.Expression, ): ts.ArrayLiteralExpression { return ts.factory.updateArrayLiteralExpression(arr, [...arr.elements, elem]); } /** * Given an ObjectLiteralExpression node, extract and return the PropertyAssignment corresponding to * the given key. `null` if no such key exists. */ export function objectPropertyAssignmentForKey( obj: ts.ObjectLiteralExpression, key: string, ): ts.PropertyAssignment | null { const matchingProperty = obj.properties.filter( (a) => a.name !== undefined && ts.isIdentifier(a.name) && a.name.escapedText === key, )[0]; return matchingProperty && ts.isPropertyAssignment(matchingProperty) ? matchingProperty : null; } /** * Given an ObjectLiteralExpression node, create or update the specified key, using the provided * callback to generate the new value (possibly based on an old value), and return the `ts.PropertyAssignment` * for the key. */ export function updateObjectValueForKey( obj: ts.ObjectLiteralExpression, key: string, newValueFn: (oldValue?: ts.Expression) => ts.Expression, ): ts.PropertyAssignment { const existingProp = objectPropertyAssignmentForKey(obj, key); return ts.factory.createPropertyAssignment( ts.factory.createIdentifier(key), newValueFn(existingProp?.initializer), ); } /** * Create a new ArrayLiteralExpression, or accept an existing one. * Ensure the array contains the provided identifier. * Returns the array, either updated or newly created. * If no update is needed, returns `null`. */ export function ensureArrayWithIdentifier( identifierText: string, expression: ts.Expression, arr?: ts.ArrayLiteralExpression, ): ts.ArrayLiteralExpression | null { if (arr === undefined) { return ts.factory.createArrayLiteralExpression([expression]); } if (arr.elements.find((v) => ts.isIdentifier(v) && v.text === identifierText)) { return null; } return ts.factory.updateArrayLiteralExpression(arr, [...arr.elements, expression]); } /** * Determine whether this an import of the given `propertyName` from a particular module * specifier already exists. If so, return the local name for that import, which might be an * alias. */ export function hasImport( importDeclarations: ts.ImportDeclaration[], propName: string, moduleSpecifier: string, ): string | null { return ( importDeclarations .filter( (declaration) => getStringLiteralText(declaration.moduleSpecifier) === moduleSpecifier, ) .map((declaration) => importHas(declaration, propName)) .find((prop) => prop !== null) ?? null ); } function nameInExportScope(importSpecifier: ts.ImportSpecifier): string { return importSpecifier.propertyName?.text ?? importSpecifier.name.text; } /** * Determine whether this import declaration already contains an import of the given * `propertyName`, and if so, the name it can be referred to with in the local scope. */ function importHas(importDecl: ts.ImportDeclaration, propName: string): string | null { const importClauseName = importDecl.importClause?.name?.getText(); if (propName === 'default' && importClauseName !== undefined) { return importClauseName; } const bindings = importDecl.importClause?.namedBindings; if (bindings === undefined) { return null; } // First, we handle the case of explicit named imports. if (ts.isNamedImports(bindings)) { // Find any import specifier whose property name in the *export* scope equals the expected // name. const specifier = bindings.elements.find( (importSpecifier) => propName == nameInExportScope(importSpecifier), ); // Return the name of the property in the *local* scope. if (specifier === undefined) { return null; } return specifier.name.text; } // The other case is a namespace import. return `${bindings.name.text}.${propName}`; } /** * Given an unqualified name, determine whether an existing import is already using this name in * the current scope. * TODO: It would be better to check if *any* symbol uses this name in the current scope. */ function importCollisionExists(importDeclaration: ts.ImportDeclaration[], name: string): boolean { const bindings = importDeclaration.map((declaration) => declaration.importClause?.namedBindings); const namedBindings: ts.NamedImports[] = bindings.filter( (binding) => binding !== undefined && ts.isNamedImports(binding), ) as ts.NamedImports[]; const specifiers = namedBindings.flatMap((b) => b.elements); return specifiers.some((s) => s.name.text === name); } /** * Generator function that yields an infinite sequence of alternative aliases for a given symbol * name. */ function* suggestAlternativeSymbolNames(name: string): Iterator<string> { for (let i = 1; true; i++) { yield `${name}_${i}`; // The _n suffix is the same style as TS generated aliases } } /** * Transform the given import name into an alias that does not collide with any other import * symbol. */ export function nonCollidingImportName( importDeclarations: ts.ImportDeclaration[], name: string, ): string { const possibleNames = suggestAlternativeSymbolNames(name); while (importCollisionExists(importDeclarations, name)) { name = possibleNames.next().value; } return name; } /** * If the provided trait is standalone, just return it. Otherwise, returns the owning ngModule. */ export function standaloneTraitOrNgModule( checker: TemplateTypeChecker, trait: ts.ClassDeclaration, ): ts.ClassDeclaration | null { const componentDecorator = checker.getPrimaryAngularDecorator(trait); if (componentDecorator == null) { return null; } const owningNgModule = checker.getOwningNgModule(trait); const isMarkedStandalone = isStandaloneDecorator(componentDecorator); if (owningNgModule === null && !isMarkedStandalone) { // TODO(dylhunn): This is a "moduleless component." We should probably suggest the user add // `standalone: true`. return null; } return owningNgModule ?? trait; } /** * Updates the imports on a TypeScript file, by ensuring the provided import is present. * Returns the text changes, as well as the name with which the imported symbol can be referred to. * * When the component is exported by default, the `symbolName` is `default`, and the `declarationName` * should be used as the import name. */ export function updateImportsForTypescriptFile( file: ts.SourceFile, symbolName: string, declarationName: string, moduleSpecifier: string, ): [ts.TextChange[], string] { // The trait might already be imported, possibly under a different name. If so, determine the // local name of the imported trait. const allImports = findAllMatchingNodes(file, {filter: ts.isImportDeclaration}); const existingImportName: string | null = hasImport(allImports, symbolName, moduleSpecifier); if (existingImportName !== null) { return [[], existingImportName]; } // If the trait has not already been imported, we need to insert the new import. const existingImportDeclaration = allImports.find( (decl) => getStringLiteralText(decl.moduleSpecifier) === moduleSpecifier, ); const importName = nonCollidingImportName( allImports, symbolName === 'default' ? declarationName : symbolName, ); if (existingImportDeclaration !== undefined) { // Update an existing import declaration. const importClause = existingImportDeclaration.importClause; if (importClause === undefined) { return [[], '']; } let span = {start: importClause.getStart(), length: importClause.getWidth()}; const updatedBindings = updateImport(existingImportDeclaration, symbolName, importName); if (updatedBindings === undefined) { return [[], '']; } const importString = printNode(updatedBindings, file); return [[{span, newText: importString}], importName]; } // Find the last import in the file. let lastImport: ts.ImportDeclaration | null = null; file.forEachChild((child) => { if (ts.isImportDeclaration(child)) lastImport = child; }); // Generate a new import declaration, and insert it after the last import declaration, only // looking at root nodes in the AST. If no import exists, place it at the start of the file. let span: ts.TextSpan = {start: 0, length: 0}; if ((lastImport as any) !== null) { // TODO: Why does the compiler insist this is null? span.start = lastImport!.getStart() + lastImport!.getWidth(); } const newImportDeclaration = generateImport(symbolName, importName, moduleSpecifier); const importString = '\n' + printNode(newImportDeclaration, file); return [[{span, newText: importString}], importName]; } /** * Updates a given Angular trait, such as an NgModule or standalone Component, by adding * `importName` to the list of imports on the decorator arguments. */ export function updateImportsForAngularTrait( checker: TemplateTypeChecker, trait: ts.ClassDeclaration, importName: string, forwardRefName: string | null, ): ts.TextChange[] { // Get the object with arguments passed into the primary Angular decorator for this trait. const decorator = checker.getPrimaryAngularDecorator(trait); if (decorator === null) { return []; } const decoratorProps = findFirstMatchingNode(decorator, {filter: ts.isObjectLiteralExpression}); if (decoratorProps === null) { return []; } /** * The assumption here is that there is a `template` or `templateUrl` in the decorator. */ if (decoratorProps.properties.length === 0) { return []; } const lastProp = decoratorProps.properties[decoratorProps.properties.length - 1]; const trailRange = ts.getTrailingCommentRanges(decoratorProps.getSourceFile().text, lastProp.end); const lastTrailRange = trailRange !== undefined && trailRange.length > 0 ? trailRange[trailRange.length - 1] : undefined; const lastTrailRangePos = lastTrailRange?.end ?? lastProp.end; const oldImports = decoratorProps.properties.find((prop) => prop.name?.getText() === 'imports'); let updateRequired = true; // Update the trait's imports. const newDecoratorProps = updateObjectValueForKey( decoratorProps, 'imports', (oldValue?: ts.Expression) => { if (oldValue && !ts.isArrayLiteralExpression(oldValue)) { return oldValue; } const identifier = ts.factory.createIdentifier(importName); const expression = forwardRefName ? ts.factory.createCallExpression(ts.factory.createIdentifier(forwardRefName), undefined, [ ts.factory.createArrowFunction( undefined, undefined, [], undefined, undefined, identifier, ), ]) : identifier; const newArr = ensureArrayWithIdentifier(importName, expression, oldValue); updateRequired = newArr !== null; return newArr!; }, ); if (!updateRequired) { return []; } const indentationNumber = guessIndentationInSingleLine(lastProp, lastProp.getSourceFile()); const indentationString = indentationNumber !== undefined ? ' '.repeat(indentationNumber) : ''; let indentationPrefix = ',\n' + indentationString; /** * If the last trail range is a single line comment, the `,` should be placed in the next line and the * `imports` arrays should be placed in the next line of `,`. * * For example: * * { * template: "" // this is a comment * } * * => * * { * template: "" // this is an comment * , * imports: [] * } */ if (lastTrailRange?.kind === ts.SyntaxKind.SingleLineCommentTrivia) { indentationPrefix = '\n' + indentationString + ',\n' + indentationString; } return [ { /** * If the `imports` exists in the object, replace the old `imports` array with the new `imports` array. * If the `imports` doesn't exist in the object, append the `imports` array after the last property of the object. * * There is a weird usage, but it's acceptable. For example: * * { * template: ``, // This is a comment for the template * _____________^ // The new `imports` array is appended here before the `,` * } * * => * * { * template: ``, * imports: [], // This is a comment for the template * } * */ span: { start: oldImports !== undefined ? oldImports.getStart() : lastTrailRangePos, length: oldImports !== undefined ? oldImports.getEnd() - oldImports.getStart() : 0, }, newText: (oldImports !== undefined ? '' : indentationPrefix) + printNode(newDecoratorProps, trait.getSourceFile()), }, ]; } /** * Return whether a given Angular decorator specifies `standalone: true`. */ export function isStandaloneDecorator(decorator: ts.Decorator): boolean | null { const decoratorProps = findFirstMatchingNode(decorator, {filter: ts.isObjectLiteralExpression}); if (decoratorProps === null) { return null; } for (const property of decoratorProps.properties) { if (!ts.isPropertyAssignment(property)) { continue; } // TODO(dylhunn): What if this is a dynamically evaluated expression? if (property.name.getText() === 'standalone' && property.initializer.getText() === 'false') { return false; } } return true; } /** * Generate a new import. Follows the format: * ```ts * import {exportedSpecifierName as localName} from 'rawModuleSpecifier'; * ``` * * If the component is exported by default, follows the format: * * ```ts * import exportedSpecifierName from 'rawModuleSpecifier'; * ``` * * If `exportedSpecifierName` is null, or is equal to `name`, then the qualified import alias will * be omitted. */ export function generateImport( localName: string, exportedSpecifierName: string | null, rawModuleSpecifier: string, ): ts.ImportDeclaration { let propName: ts.Identifier | undefined; if (exportedSpecifierName !== null && exportedSpecifierName !== localName) { propName = ts.factory.createIdentifier(exportedSpecifierName); } const name = ts.factory.createIdentifier(localName); const moduleSpec = ts.factory.createStringLiteral(rawModuleSpecifier); let importClauseName: ts.Identifier | undefined; let importBindings: ts.NamedImportBindings | undefined; if (localName === 'default' && exportedSpecifierName !== null) { importClauseName = ts.factory.createIdentifier(exportedSpecifierName); } else { importBindings = ts.factory.createNamedImports([ ts.factory.createImportSpecifier(false, propName, name), ]); } return ts.factory.createImportDeclaration( undefined, ts.factory.createImportClause(undefined, importClauseName, importBindings), moduleSpec, undefined, ); } /** * Update an existing named import with a new member. * If `exportedSpecifierName` is null, or is equal to `name`, then the qualified import alias will * be omitted. * If the `localName` is `default` and `exportedSpecifierName` is not null, the `exportedSpecifierName` * is used as the default import name. */ export function updateImport( importDeclaration: ts.ImportDeclaration, localName: string, exportedSpecifierName: string | null, ): ts.ImportClause | undefined { const importClause = importDeclaration.importClause; if (importClause === undefined) { return undefined; } const bindings = importClause.namedBindings; if (bindings !== undefined && ts.isNamespaceImport(bindings)) { // This should be impossible. If a namespace import is present, the symbol was already // considered imported above. console.error(`Unexpected namespace import ${importDeclaration.getText()}`); return undefined; } if (localName === 'default' && exportedSpecifierName !== null) { const importClauseName = ts.factory.createIdentifier(exportedSpecifierName); return ts.factory.updateImportClause( importClause, false, importClauseName, importClause.namedBindings, ); } let propertyName: ts.Identifier | undefined; if (exportedSpecifierName !== null && exportedSpecifierName !== localName) { propertyName = ts.factory.createIdentifier(exportedSpecifierName); } const name = ts.factory.createIdentifier(localName); const newImport = ts.factory.createImportSpecifier(false, propertyName, name); let namedImport: ts.NamedImports; if (bindings === undefined) { namedImport = ts.factory.createNamedImports([newImport]); } else { namedImport = ts.factory.updateNamedImports(bindings, [...bindings.elements, newImport]); } return ts.factory.updateImportClause(importClause, false, importClause.name, namedImport); } let printer: ts.Printer | null = null; /** * Get a ts.Printer for printing AST nodes, reusing the previous Printer if already created. */ function getOrCreatePrinter(): ts.Printer { if (printer === null) { printer = ts.createPrinter(); } return printer; } /** * Print a given TypeScript node into a string. Used to serialize entirely synthetic generated AST, * which will not have `.text` or `.fullText` set. */ export function printNode(node: ts.Node, sourceFile: ts.SourceFile): string { return getOrCreatePrinter().printNode(ts.EmitHint.Unspecified, node, sourceFile); } /** * Get the code actions to tell the vscode how to import the directive into the standalone component or ng module. */ export function getCodeActionToImportTheDirectiveDeclaration( compiler: NgCompiler, component: ts.ClassDeclaration, importOn: ts.ClassDeclaration, directive: PotentialDirective | PotentialPipe, tsLs: ts.LanguageService, includeCompletionsForModuleExports?: boolean, ): ts.CodeAction[] | undefined { const codeActions: ts.CodeAction[] = []; const potentialDirectiveModuleSpecifierResolver = new PotentialDirectiveModuleSpecifierResolverImpl( compiler, directive, compiler.getTemplateTypeChecker(), component, tsLs, includeCompletionsForModuleExports, ); let ref: Reference<ClassDeclaration> | null = null; const node = getClassDeclarationFromSymbolReference(tsLs, directive.ref); if (node && node.name) { const owningModule = directive.ref.moduleSpecifier ? { specifier: directive.ref.moduleSpecifier, resolutionContext: directive.ref.filePath, } : null; ref = new Reference(node as unknown as ClassDeclaration, owningModule); } if (ref === null) { return undefined; } const potentialImports = compiler .getTemplateTypeChecker() .getPotentialImportsFor( ref, importOn, PotentialImportMode.Normal, potentialDirectiveModuleSpecifierResolver, ); const declarationName = directive.ref.name; for (const potentialImport of potentialImports) { const fileImportChanges: ts.TextChange[] = []; let importName: string; let forwardRefName: string | null = null; if (potentialImport.moduleSpecifier) { const [importChanges, generatedImportName] = updateImportsForTypescriptFile( importOn.getSourceFile(), potentialImport.symbolName, declarationName, potentialImport.moduleSpecifier, ); importName = generatedImportName; fileImportChanges.push(...importChanges); } else { if (potentialImport.isForwardReference) { // Note that we pass the `importOn` file twice since we know that the potential import // is within the same file, because it doesn't have a `moduleSpecifier`. const [forwardRefImports, generatedForwardRefName] = updateImportsForTypescriptFile( importOn.getSourceFile(), 'forwardRef', declarationName, '@angular/core', ); fileImportChanges.push(...forwardRefImports); forwardRefName = generatedForwardRefName; } importName = potentialImport.symbolName; } // Always update the trait import, although the TS import might already be present. const traitImportChanges = updateImportsForAngularTrait( compiler.getTemplateTypeChecker(), importOn, importName, forwardRefName, ); if (traitImportChanges.length === 0) continue; let description = `Import ${importName}`; if (potentialImport.moduleSpecifier !== undefined) { description += ` from '${potentialImport.moduleSpecifier}' on ${importOn.name!.text}`; } codeActions.push({ description, changes: [ { fileName: importOn.getSourceFile().fileName, textChanges: [...fileImportChanges, ...traitImportChanges], }, ], }); } return codeActions; } function getStringLiteralText(moduleSpecifier: ts.Expression): string | undefined { if (ts.isStringLiteral(moduleSpecifier)) { return moduleSpecifier.text; } return undefined; } /** * Try to compute the module specifier after applying the paths from tsconfig or package.json export rules. * * Try to reuse the module specifier if the import in the `importOn` can export the `toImport` node. * * If a module specifier cannot be resolved, return undefined, and the Angular compiler will apply its own rules. * * There is one exception here. For example * * ```ts * // app.ts * @Component({ * selector: 'app-foo', * template: '', * }) * export class FooComponent {} * * @Component({ * selector: 'app-bar', * template: '', * }) * * export class BarComponent {} * * ``` * * ```ts * // app.module.ts * import {BarComponent} from "./app"; * @NgModule({ * declarations: [BarComponent], * exports: [BarComponent], * imports: [] * }) * export class AppModule {} * ``` * * ```ts * // test.ts * // The `@/app.module` resolves to `./app.module.ts`. * // The `@/app` resolves to `./app`. * // It's configured in the `paths` in the tsconfig. * import {AppModule} from "@/app.module"; * * @Component({ * selector: 'app-baz', * template: '<app-bar/><app-foo/>', * imports: [AppModule], * }) * export class BazComponent {} * ``` * * When providing the code action for the `app-foo` in the `test.ts` file, * the module specifier is `./app.ts`, not `@/app`. Because the TCB has imported * the `./app.ts` for the `app-bar`, the tsLs will reuse the import statement * in the TCB, no module specifier can be extracted, and the `@/app.module` doesn't * export the `FooComponent`. So the module specifier will be generated by * the Angular compiler. * * The developer should export the `FooComponent` in the `AppModule`. * */ class PotentialDirectiveModuleSpecifierResolverImpl implements PotentialDirectiveModuleSpecifierResolver { constructor( private readonly compiler: NgCompiler, private readonly directive: PotentialDirective | PotentialPipe, private readonly templateTypeChecker: TemplateTypeChecker, private readonly component: ts.ClassDeclaration, private readonly tsLS: ts.LanguageService, private readonly includeCompletionsForModuleExports: boolean | undefined, ) {} resolve( toImport: Reference<ClassDeclaration>, importOn: ts.Node | null, ): DirectiveModuleExportDetails | null { if (toImport.node.getSourceFile().fileName === importOn?.getSourceFile().fileName) { return null; } const tsEntry = this.getMatchTsEntry(toImport); const moduleSpecifier = getModuleSpecifierIfExists( this.compiler, importOn, toImport.node, tsEntry?.tsCompletionEntrySymbolName, ); if (moduleSpecifier !== null) { return { moduleSpecifier, exportName: tsEntry?.tsCompletionEntrySymbolName ?? toImport.node.name.getText(), }; } return getModuleSpecifierFromImportStatement( tsEntry, importOn, this.templateTypeChecker, this.component, this.tsLS, this.includeCompletionsForModuleExports, ); } private getMatchTsEntry(toImport: Reference<ClassDeclaration>): TsCompletionEntryInfo | null { const program = this.tsLS.getProgram(); if (program === undefined) { return null; } return findTsCompletionEntryInfoForImport( this.directive.tsCompletionEntryInfos, toImport, program, ); } } const importRegex = /\bimport\b[\s\S]*?\bfrom\b\s*(['"`])(.*?)\1/; /** * Get the module specifier from the code actions returned by the `ls.getCompletionEntryDetails`. * * If the directive needs to update the import statement, the code action will include the text * like `i0.FooComponent`. * * If the directive needs to import a new external module, the code action will include the text * like `import { FooComponent } from '@foo'`. The `@foo` will be returned by the function. */ function getModuleSpecifierFromImportStatement( tsCompletionEntryInfo: TsCompletionEntryInfo | null, importOn: ts.Node | null, templateTypeChecker: TemplateTypeChecker, component: ts.ClassDeclaration, tsLS: ts.LanguageService, includeCompletionsForModuleExports: boolean | undefined, ): DirectiveModuleExportDetails | null { const program = tsLS.getProgram(); if (program === undefined) { return null; } if (tsCompletionEntryInfo === null) { return null; } const tsEntryName = tsCompletionEntryInfo.tsCompletionEntrySymbolName; const globalContext = templateTypeChecker.getGlobalTsContext(component); if (globalContext === null) { return null; } const completionListDetail = tsLS.getCompletionEntryDetails( globalContext.tcbPath, globalContext.positionInFile, tsEntryName, {}, undefined, { includeCompletionsForModuleExports, }, tsCompletionEntryInfo.tsCompletionEntryData, ); const actions = completionListDetail?.codeActions; if (actions === undefined) { return null; } const tcbDir = path.posix.dirname(globalContext.tcbPath); const importOnDir = importOn ? path.posix.dirname(importOn.getSourceFile().fileName) : undefined; for (const action of actions) { for (const changes of action.changes) { for (const textChange of changes.textChanges) { const match = importRegex.exec(textChange.newText); if (match !== null) { let moduleSpecifier = match[2]; /** * The TCB path may differ from the ng module path. If the module specifier is a relative path, * it must be relative to the NG module path. */ if ( moduleSpecifier.startsWith('.') && tcbDir !== importOnDir && importOnDir !== undefined ) { const moduleSpecifierFullPath = path.posix.resolve(tcbDir, moduleSpecifier); moduleSpecifier = path.posix.relative(importOnDir, moduleSpecifierFullPath); if (!moduleSpecifier.startsWith('.')) { moduleSpecifier = `./${moduleSpecifier}`; } } return {moduleSpecifier, exportName: tsEntryName}; } } } } return null; } function findTsCompletionEntryInfoForImport( tsCompletionEntryInfos: TsCompletionEntryInfo[] | null, toImport: Reference<ClassDeclaration>, program: ts.Program, ): TsCompletionEntryInfo | null { const typeChecker = program.getTypeChecker(); return ( tsCompletionEntryInfos?.find((tsEntry) => { const sf = program.getSourceFile(tsEntry.tsCompletionEntrySymbolFileName); if (sf === undefined) { return false; } const sfSymbol = typeChecker.getSymbolAtLocation(sf); if (sfSymbol === undefined) { return false; } const tsEntrySymbol = typeChecker.tryGetMemberInModuleExports( tsEntry.tsCompletionEntrySymbolName, sfSymbol, ); if (tsEntrySymbol === undefined) { return false; } const tsEntryType = typeChecker.getTypeOfSymbol(tsEntrySymbol); return tsEntryType.getSymbol()?.declarations?.[0] === toImport.node; }) ?? null ); } function moduleSpecifierPointsToSymbol( tsChecker: ts.TypeChecker, moduleSpecifier: ts.Expression, ): ts.Symbol | null { const specifierSymbol = tsChecker.getSymbolAtLocation(moduleSpecifier); if (specifierSymbol === undefined) { console.error(`Undefined symbol for module specifier ${moduleSpecifier.getText()}`); return null; } const symbolDeclarations = specifierSymbol.declarations; if (symbolDeclarations === undefined || symbolDeclarations.length === 0) { console.error(`Unknown symbol declarations for module specifier ${moduleSpecifier.getText()}`); return null; } if (symbolDeclarations.length > 0) { const sf = symbolDeclarations[0].getSourceFile(); return tsChecker.getSymbolAtLocation(sf) ?? null; } return null; } /** * Retrieve all imports from the `importOn` and check if any import can export the node specified by `toImport`. * If a matching import is found, attempt to reuse it. */ function getModuleSpecifierIfExists( compiler: NgCompiler, importOn: ts.Node | null, toImport: ClassDeclaration, exportName: string | undefined, ): string | null { if (importOn === null) { return null; } const allImports = findAllMatchingNodes(importOn.getSourceFile(), { filter: ts.isImportDeclaration, }); const typeChecker = compiler.getCurrentProgram().getTypeChecker(); for (const importDecl of allImports) { const importSymbol = moduleSpecifierPointsToSymbol(typeChecker, importDecl.moduleSpecifier); if (importSymbol === null) { continue; } const toImportSymbolFromModule = typeChecker.tryGetMemberInModuleExports( exportName ?? toImport.name.getText(), importSymbol, ); if (toImportSymbolFromModule === undefined) { continue; } const symbolType = typeChecker.getTypeOfSymbol(toImportSymbolFromModule); /** * Make sure these are the same node. */ if (symbolType.getSymbol()?.declarations?.[0] === toImport) { return getStringLiteralText(importDecl.moduleSpecifier) ?? null; } } return null; }