fix(language-service): Support to resolve the re-export component. (#62585)

In the context of TypeScript (TS), a re-exported symbol is considered a distinct symbol.
This means that a developer can choose to import either the re-exported symbol or
the original symbol. However, in the context of Angular, the re-exported symbol
is treated as the same component because it uses the same selector.

This pull request will utilize the most recent re-export component file to
resolve the module specifier.

PR Close #62585
This commit is contained in:
ivanwonder 2025-07-09 21:55:21 +08:00 committed by Jessica Janiuk
parent 6a6cb01bb3
commit eeeaadc7e9
5 changed files with 372 additions and 49 deletions

View file

@ -145,6 +145,14 @@ export enum PotentialImportMode {
ForceDirect,
}
export interface PotentialDirectiveModuleSpecifierResolver {
resolve(toImport: Reference<ClassDeclaration>, importOn: ts.Node | null): string | undefined;
export interface DirectiveModuleExportDetails {
moduleSpecifier: string;
exportName: string;
}
export interface PotentialDirectiveModuleSpecifierResolver {
resolve(
toImport: Reference<ClassDeclaration>,
importOn: ts.Node | null,
): DirectiveModuleExportDetails | null;
}

View file

@ -26,7 +26,7 @@ import {
WrappedNodeExpr,
} from '@angular/compiler';
import {isDirectiveDeclaration} from './ts_util';
import {isDirectiveDeclaration, isSymbolAliasOf} from './ts_util';
import ts from 'typescript';
@ -66,6 +66,7 @@ import {
isSymbolWithValueDeclaration,
} from '../../util/src/typescript';
import {
DirectiveModuleExportDetails,
ElementSymbol,
FullSourceMapping,
GetPotentialAngularMetaOptions,
@ -1053,11 +1054,15 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
const cachedCompletionEntryInfos =
resultingDirectives.get(directiveDecl.ref.node)?.tsCompletionEntryInfos ?? [];
cachedCompletionEntryInfos.push({
tsCompletionEntryData: data,
tsCompletionEntrySymbolFileName: symbolFileName,
tsCompletionEntrySymbolName: symbolName,
});
appendOrReplaceTsEntryInfo(
cachedCompletionEntryInfos,
{
tsCompletionEntryData: data,
tsCompletionEntrySymbolFileName: symbolFileName,
tsCompletionEntrySymbolName: symbolName,
},
this.programDriver.getProgram(),
);
if (resultingDirectives.has(directiveDecl.ref.node)) {
const directiveInfo = resultingDirectives.get(directiveDecl.ref.node)!;
@ -1283,37 +1288,37 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
*/
let highestImportPriority = -1;
const collectImports = (emit: PotentialImport | null, moduleSpecifier: string | undefined) => {
const collectImports = (
emit: PotentialImport | null,
moduleSpecifierDetail: DirectiveModuleExportDetails | null,
) => {
if (emit === null) {
return;
}
imports.push({
...emit,
moduleSpecifier: moduleSpecifier ?? emit.moduleSpecifier,
moduleSpecifier: moduleSpecifierDetail?.moduleSpecifier ?? emit.moduleSpecifier,
symbolName: moduleSpecifierDetail?.exportName ?? emit.symbolName,
});
if (moduleSpecifier !== undefined && highestImportPriority === -1) {
if (moduleSpecifierDetail !== null && highestImportPriority === -1) {
highestImportPriority = imports.length - 1;
}
};
if (meta.isStandalone || importMode === PotentialImportMode.ForceDirect) {
const emitted = this.emit(PotentialImportKind.Standalone, toImport, inContext);
const moduleSpecifier = potentialDirectiveModuleSpecifierResolver?.resolve(
toImport,
inContext,
);
collectImports(emitted, moduleSpecifier);
const moduleSpecifierDetail =
potentialDirectiveModuleSpecifierResolver?.resolve(toImport, inContext) ?? null;
collectImports(emitted, moduleSpecifierDetail);
}
const exportingNgModules = this.ngModuleIndex.getNgModulesExporting(meta.ref.node);
if (exportingNgModules !== null) {
for (const exporter of exportingNgModules) {
const emittedRef = this.emit(PotentialImportKind.NgModule, exporter, inContext);
const moduleSpecifier = potentialDirectiveModuleSpecifierResolver?.resolve(
exporter,
inContext,
);
collectImports(emittedRef, moduleSpecifier);
const moduleSpecifierDetail =
potentialDirectiveModuleSpecifierResolver?.resolve(exporter, inContext) ?? null;
collectImports(emittedRef, moduleSpecifierDetail);
}
}
@ -1787,3 +1792,110 @@ type TsDeprecatedDiagnostics = Required<Pick<ts.DiagnosticWithLocation, 'reports
function isDeprecatedDiagnostics(diag: ts.DiagnosticWithLocation): diag is TsDeprecatedDiagnostics {
return diag.reportsDeprecated !== undefined;
}
/**
* Append the ts completion entry into the array only when the new entry's directive
* doesn't exist in the array.
*
* If the new entry's directive already exists, and the entry's symbol is the alias of
* the existing entry, the new entry will replace the existing entry.
*
*/
function appendOrReplaceTsEntryInfo(
tsEntryInfos: TsCompletionEntryInfo[],
newTsEntryInfo: TsCompletionEntryInfo,
program: ts.Program,
) {
const typeChecker = program.getTypeChecker();
const newTsEntryInfoSymbol = getSymbolFromTsEntryInfo(newTsEntryInfo, program);
if (newTsEntryInfoSymbol === null) {
return;
}
// Find the index of the first entry that has a matching type.
const matchedEntryIndex = tsEntryInfos.findIndex((currentTsEntryInfo) => {
const currentTsEntrySymbol = getSymbolFromTsEntryInfo(currentTsEntryInfo, program);
if (currentTsEntrySymbol === null) {
return false;
}
return isSymbolTypeMatch(currentTsEntrySymbol, newTsEntryInfoSymbol, typeChecker);
});
if (matchedEntryIndex === -1) {
// No entry with a matching type was found, so append the new entry.
tsEntryInfos.push(newTsEntryInfo);
return;
}
// An entry with a matching type was found at matchedEntryIndex.
const matchedEntry = tsEntryInfos[matchedEntryIndex];
const matchedEntrySymbol = getSymbolFromTsEntryInfo(matchedEntry, program);
if (matchedEntrySymbol === null) {
// Should not happen based on the findIndex condition, but check defensively.
return;
}
// Check if the `matchedEntrySymbol` is an alias of the `newTsEntryInfoSymbol`.
if (isSymbolAliasOf(matchedEntrySymbol, newTsEntryInfoSymbol, typeChecker)) {
// The first type-matching entry is an alias, so replace it.
tsEntryInfos[matchedEntryIndex] = newTsEntryInfo;
return;
}
// The new entry's symbol is an alias of the existing entry's symbol.
// In this case, we prefer to keep the existing entry that was found first
// and do not replace it.
return;
}
function getSymbolFromTsEntryInfo(
tsInfo: TsCompletionEntryInfo,
program: ts.Program,
): ts.Symbol | null {
const typeChecker = program.getTypeChecker();
const sf = program.getSourceFile(tsInfo.tsCompletionEntrySymbolFileName);
if (sf === undefined) {
return null;
}
const sfSymbol = typeChecker.getSymbolAtLocation(sf);
if (sfSymbol === undefined) {
return null;
}
return (
typeChecker.tryGetMemberInModuleExports(tsInfo.tsCompletionEntrySymbolName, sfSymbol) ?? null
);
}
function getFirstTypeDeclarationOfSymbol(
symbol: ts.Symbol,
typeChecker: ts.TypeChecker,
): ts.Declaration | undefined {
const type = typeChecker.getTypeOfSymbol(symbol);
return type.getSymbol()?.declarations?.[0];
}
/**
* Check if the two symbols come from the same type node. For example:
*
* The `NewBarComponent`'s type node is the `BarComponent`.
*
* ```
* // a.ts
* export class BarComponent
*
* // b.ts
* import {BarComponent} from "./a"
* const NewBarComponent = BarComponent;
* export {NewBarComponent}
* ```
*/
function isSymbolTypeMatch(
first: ts.Symbol,
last: ts.Symbol,
typeChecker: ts.TypeChecker,
): boolean {
const firstTypeNode = getFirstTypeDeclarationOfSymbol(first, typeChecker);
const lastTypeNode = getFirstTypeDeclarationOfSymbol(last, typeChecker);
return firstTypeNode === lastTypeNode && firstTypeNode !== undefined;
}

View file

@ -216,3 +216,54 @@ export function isDirectiveDeclaration(node: ts.Node): node is ts.TypeNode | ts.
hasExpressionIdentifier(sourceFile, node, ExpressionIdentifier.DIRECTIVE)
);
}
/**
* Check if the lastSymbol is an alias of the firstSymbol. For example:
*
* The NewBarComponent is an alias of BarComponent.
*
* But the NotAliasBarComponent is not an alias of BarComponent, because
* the NotAliasBarComponent is a new variable.
*
* This should work for most cases.
*
* https://github.com/microsoft/TypeScript/blob/9e20e032effad965567d4a1e1c30d5433b0a3332/src/compiler/checker.ts#L3638-L3652
*
* ```
* // a.ts
* export class BarComponent {};
* // b.ts
* export {BarComponent as NewBarComponent} from "./a";
* // c.ts
* import {BarComponent} from "./a"
* const NotAliasBarComponent = BarComponent;
* export {NotAliasBarComponent};
* ```
*/
export function isSymbolAliasOf(
firstSymbol: ts.Symbol,
lastSymbol: ts.Symbol,
typeChecker: ts.TypeChecker,
): boolean {
let currentSymbol: ts.Symbol | undefined = lastSymbol;
const seenSymbol: Set<ts.Symbol> = new Set();
while (
firstSymbol !== currentSymbol &&
currentSymbol !== undefined &&
currentSymbol.flags & ts.SymbolFlags.Alias
) {
if (seenSymbol.has(currentSymbol)) {
break;
}
seenSymbol.add(currentSymbol);
currentSymbol = typeChecker.getImmediateAliasedSymbol(currentSymbol);
if (currentSymbol === firstSymbol) {
return true;
}
}
return false;
}

View file

@ -7,6 +7,7 @@
*/
import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core';
import {
DirectiveModuleExportDetails,
PotentialDirective,
PotentialDirectiveModuleSpecifierResolver,
PotentialImportMode,
@ -821,19 +822,30 @@ class PotentialDirectiveModuleSpecifierResolverImpl
private readonly includeCompletionsForModuleExports: boolean | undefined,
) {}
resolve(toImport: Reference<ClassDeclaration>, importOn: ts.Node | null): string | undefined {
resolve(
toImport: Reference<ClassDeclaration>,
importOn: ts.Node | null,
): DirectiveModuleExportDetails | null {
if (toImport.node.getSourceFile().fileName === importOn?.getSourceFile().fileName) {
return undefined;
return null;
}
const moduleSpecifier = getModuleSpecifierIfExists(this.compiler, importOn, toImport.node);
const tsEntry = this.getMatchTsEntry(toImport);
const moduleSpecifier = getModuleSpecifierIfExists(
this.compiler,
importOn,
toImport.node,
tsEntry?.tsCompletionEntrySymbolName,
);
if (moduleSpecifier !== null) {
return moduleSpecifier;
return {
moduleSpecifier,
exportName: tsEntry?.tsCompletionEntrySymbolName ?? toImport.node.name.getText(),
};
}
return getModuleSpecifierFromImportStatement(
this.directive.tsCompletionEntryInfos,
toImport,
tsEntry,
importOn,
this.templateTypeChecker,
this.component,
@ -841,6 +853,18 @@ class PotentialDirectiveModuleSpecifierResolverImpl
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/;
@ -854,28 +878,27 @@ const importRegex = /\bimport\b[\s\S]*?\bfrom\b\s*(['"`])(.*?)\1/;
* like `import { FooComponent } from '@foo'`. The `@foo` will be returned by the function.
*/
function getModuleSpecifierFromImportStatement(
tsCompletionEntryInfos: TsCompletionEntryInfo[] | null,
toImport: Reference<ClassDeclaration>,
tsCompletionEntryInfo: TsCompletionEntryInfo | null,
importOn: ts.Node | null,
templateTypeChecker: TemplateTypeChecker,
component: ts.ClassDeclaration,
tsLS: ts.LanguageService,
includeCompletionsForModuleExports: boolean | undefined,
): string | undefined {
const tsCompletionEntryInfo = findTsCompletionEntryInfoForImport(
tsCompletionEntryInfos,
toImport,
);
): DirectiveModuleExportDetails | null {
const program = tsLS.getProgram();
if (program === undefined) {
return null;
}
if (tsCompletionEntryInfo === undefined) {
return undefined;
if (tsCompletionEntryInfo === null) {
return null;
}
const tsEntryName = tsCompletionEntryInfo.tsCompletionEntrySymbolName;
const globalContext = templateTypeChecker.getGlobalTsContext(component);
if (globalContext === null) {
return undefined;
return null;
}
const completionListDetail = tsLS.getCompletionEntryDetails(
@ -892,7 +915,7 @@ function getModuleSpecifierFromImportStatement(
const actions = completionListDetail?.codeActions;
if (actions === undefined) {
return undefined;
return null;
}
const tcbDir = path.posix.dirname(globalContext.tcbPath);
@ -919,25 +942,41 @@ function getModuleSpecifierFromImportStatement(
moduleSpecifier = `./${moduleSpecifier}`;
}
}
return moduleSpecifier;
return {moduleSpecifier, exportName: tsEntryName};
}
}
}
}
return undefined;
return null;
}
function findTsCompletionEntryInfoForImport(
tsCompletionEntryInfos: TsCompletionEntryInfo[] | null,
toImport: Reference<ClassDeclaration>,
): TsCompletionEntryInfo | undefined {
const toImportSymbolName = toImport.node.name?.text;
const toImportSymbolFileName = toImport.node.getSourceFile().fileName;
program: ts.Program,
): TsCompletionEntryInfo | null {
const typeChecker = program.getTypeChecker();
return tsCompletionEntryInfos?.find(
(entry) =>
entry.tsCompletionEntrySymbolName === toImportSymbolName &&
entry.tsCompletionEntrySymbolFileName === toImportSymbolFileName,
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
);
}
@ -972,6 +1011,7 @@ function getModuleSpecifierIfExists(
compiler: NgCompiler,
importOn: ts.Node | null,
toImport: ClassDeclaration,
exportName: string | undefined,
): string | null {
if (importOn === null) {
return null;
@ -991,14 +1031,20 @@ function getModuleSpecifierIfExists(
}
const toImportSymbolFromModule = typeChecker.tryGetMemberInModuleExports(
toImport.name.getText(),
exportName ?? toImport.name.getText(),
importSymbol,
);
if (toImportSymbolFromModule === undefined) {
continue;
}
const symbolType = typeChecker.getTypeOfSymbol(toImportSymbolFromModule);
/**
* Make sure these are the same node.
*/
if (toImportSymbolFromModule?.declarations?.[0] === toImport) {
if (symbolType.getSymbol()?.declarations?.[0] === toImport) {
return getStringLiteralText(importDecl.moduleSpecifier) ?? null;
}
}

View file

@ -1013,6 +1013,62 @@ describe('code fixes', () => {
]);
});
it('for a re-export symbol from the tsconfig path', () => {
const standaloneFiles = {
'src/foo.ts': `
import {Component} from '@angular/core';
@Component({
selector: 'foo',
template: '<bar></bar>',
standalone: true
})
export class FooComponent {}
`,
'component/share/bar.ts': `
import {Component} from '@angular/core';
@Component({
selector: 'bar',
template: '<div>bar</div>',
standalone: true
})
export class BarComponent {}
`,
'component/share/re_export.ts': `
import {BarComponent as NewBarComponent1} from "./bar"
export {NewBarComponent1}
`,
'component/share/public_api.ts': `
export {NewBarComponent1 as NewBarComponent2} from "./re_export"
`,
'component/share/index.ts': `
export {NewBarComponent2 as NewBarComponent3} from "./public_api"
`,
};
const project = createModuleAndProjectWithDeclarations(env, 'test', {}, {}, standaloneFiles, {
paths: {'@app/*': ['./component/share/*.ts']},
});
const diags = project.getDiagnosticsForFile('src/foo.ts');
const fixFile = project.openFile('src/foo.ts');
fixFile.moveCursorToText('<¦bar>');
const codeActions = project.getCodeFixesAtPosition(
'src/foo.ts',
fixFile.cursor,
fixFile.cursor,
[diags[0].code],
);
const actionChanges = allChangesForCodeActions(fixFile.contents, codeActions);
actionChangesMatch(
actionChanges,
`Import NewBarComponent3 from '@app/index' on FooComponent`,
[
[``, `import { NewBarComponent3 } from "@app/index";`],
[``, `, imports: [NewBarComponent3]`],
],
);
});
it('for a reusable path from the tsconfig', () => {
const standaloneFiles = {
'src/foo.ts': `
@ -1061,6 +1117,56 @@ describe('code fixes', () => {
]);
});
it('for a reusable path with name export from the tsconfig', () => {
const standaloneFiles = {
'src/foo.ts': `
import {Component} from '@angular/core';
import {BazComponent} from '@app/bar';
@Component({
selector: 'foo',
template: '<bar></bar><baz/>',
imports: [BazComponent]
})
export class FooComponent {}
`,
'component/share/bar.ts': `
import {Component} from '@angular/core';
@Component({
selector: 'bar',
template: '<div>bar</div>',
})
class BarComponent {}
@Component({
selector: 'baz',
template: '<div>baz</div>',
})
class BazComponent {}
export {BarComponent, BazComponent};
`,
};
const project = createModuleAndProjectWithDeclarations(env, 'test', {}, {}, standaloneFiles, {
paths: {'@app/*': ['./component/share/*.ts']},
});
const diags = project.getDiagnosticsForFile('src/foo.ts');
const fixFile = project.openFile('src/foo.ts');
fixFile.moveCursorToText('<¦bar>');
const codeActions = project.getCodeFixesAtPosition(
'src/foo.ts',
fixFile.cursor,
fixFile.cursor,
[diags[0].code],
);
const actionChanges = allChangesForCodeActions(fixFile.contents, codeActions);
actionChangesMatch(actionChanges, `Import BarComponent from '@app/bar' on FooComponent`, [
[`{BazComponent}`, `{ BazComponent, BarComponent }`],
[`imports: [BazComponent]`, `imports: [BazComponent, BarComponent]`],
]);
});
it('for module specifier existing in the file', () => {
const standaloneFiles = {
'src/foo.ts': `