refactor(compiler-cli): decouple SymbolBuilder from BoundTarget and minimize adapter surface

Decouple `SymbolBuilder` from the full `BoundTarget` interface by introducing a purpose-built `SymbolBoundTarget` interface containing only the 4 methods required for symbol resolution. This eliminates the need for the large, pass-through `BoundTargetAdapter` and further isolates `SymbolBuilder` from compiler-internal implementation details.

Also minimize `TypeCheckableDirectiveMetaAdapter` by redefining `SymbolDirectiveMeta` to not extend `DirectiveMeta`, exposing only the properties actually used by `SymbolBuilder`.

Removed dead code `getDirectiveMeta` in `template_symbol_builder.ts` which was unused.

These changes improve maintainability and ensure a cleaner architecture by strictly defining the boundaries of what `SymbolBuilder` needs from the rest of the system.
By limiting the required inputs to only what's necessary for the implementation, we make it easier to re-use
the implementation between different compiler architectures
This commit is contained in:
Andrew Scott 2026-04-07 13:10:45 -07:00
parent 057cc6d09d
commit d4c8a9a887
13 changed files with 361 additions and 143 deletions

View file

@ -55,11 +55,21 @@ export interface TsCompletionEntryInfo {
tsCompletionEntryData?: ts.CompletionEntryData;
}
/**
* A reference to a symbol in a source file, without holding heavy AST nodes.
*/
export interface SymbolReference {
filePath: string;
position: number;
name: string;
moduleSpecifier?: string;
}
/**
* Metadata on a directive which is available in a template.
*/
export interface PotentialDirective {
ref: Reference<ClassDeclaration>;
ref: SymbolReference;
/**
* The module which declares the directive.
@ -99,7 +109,7 @@ export interface PotentialDirective {
* Metadata for a pipe which is available in a template.
*/
export interface PotentialPipe {
ref: Reference<ClassDeclaration>;
ref: SymbolReference;
/**
* Name of the pipe.

View file

@ -333,4 +333,7 @@ export interface PipeSymbol {
export interface ClassSymbol {
/** The position for the variable declaration for the class instance. */
tcbLocation: TcbLocation;
/** Whether this class symbol represents a pipe. */
isPipeClassSymbol?: boolean;
}

View file

@ -8,19 +8,26 @@
import {
AST,
BoundTarget,
CssSelector,
DomElementSchemaRegistry,
ExternalExpr,
LiteralPrimitive,
ParseSourceSpan,
PropertyRead,
ReferenceTarget,
SafePropertyRead,
ScopedNode,
Target,
TemplateEntity,
TmplAstBoundAttribute,
TmplAstBoundEvent,
TmplAstComponent,
TmplAstDirective,
TmplAstElement,
TmplAstHostElement,
TmplAstNode,
TmplAstReference,
TmplAstTemplate,
TmplAstTextAttribute,
WrappedNodeExpr,
@ -86,6 +93,7 @@ import {
SelectorlessDirectiveSymbol,
Symbol,
SymbolKind,
SymbolReference,
TcbLocation,
TemplateDiagnostic,
TemplateSymbol,
@ -108,9 +116,109 @@ import {shouldReportDiagnostic, translateDiagnostic} from './diagnostics';
import {TypeCheckShimGenerator} from './shim';
import {DirectiveSourceManager} from './source';
import {findTypeCheckBlock, getSourceMapping, TypeCheckSourceResolver} from './tcb_util';
import {SymbolBuilder} from './template_symbol_builder';
import {SymbolBuilder, SymbolDirectiveMeta, SymbolBoundTarget} from './template_symbol_builder';
import {findAllMatchingNodes} from './comments';
export class TypeCheckableDirectiveMetaAdapter implements SymbolDirectiveMeta {
constructor(
private meta: TypeCheckableDirectiveMeta,
private componentScopeReader: ComponentScopeReader,
) {}
getSymbolReference(): SymbolReference {
return {
filePath: this.meta.ref.node.getSourceFile().fileName,
position: this.meta.ref.node.name.getStart(),
name: this.meta.ref.node.name.text,
moduleSpecifier: this.meta.ref.bestGuessOwningModule?.specifier,
};
}
getNgModule(): ClassDeclaration | null {
if (ts.isClassDeclaration(this.meta.ref.node)) {
const scope = this.componentScopeReader.getScopeForComponent(this.meta.ref.node);
if (scope === null || scope.kind !== ComponentScopeKind.NgModule) {
return null;
}
return scope.ngModule;
}
return null;
}
getReferenceTargetNode(): ts.ClassDeclaration | null {
return ts.isClassDeclaration(this.meta.ref.node) ? this.meta.ref.node : null;
}
get selector() {
return this.meta.selector;
}
get isComponent() {
return this.meta.isComponent;
}
get inputs() {
return this.meta.inputs;
}
get outputs() {
return this.meta.outputs;
}
get isStructural() {
return this.meta.isStructural;
}
get hostDirectives() {
return this.meta.hostDirectives;
}
get matchSource() {
return this.meta.matchSource;
}
}
export class BoundTargetAdapter implements SymbolBoundTarget {
constructor(
private delegate: BoundTarget<TypeCheckableDirectiveMeta>,
private componentScopeReader: ComponentScopeReader,
) {}
getDirectivesOfNode(node: TmplAstNode): SymbolDirectiveMeta[] | null {
const dirs = this.delegate.getDirectivesOfNode(node as TmplAstElement | TmplAstTemplate);
return dirs
? dirs.map((d) => new TypeCheckableDirectiveMetaAdapter(d, this.componentScopeReader))
: null;
}
getReferenceTarget(ref: TmplAstReference): ReferenceTarget<SymbolDirectiveMeta> | null {
const target = this.delegate.getReferenceTarget(ref);
if (target === null) return null;
if ('directive' in target) {
return {
directive: new TypeCheckableDirectiveMetaAdapter(
target.directive,
this.componentScopeReader,
),
node: target.node,
};
}
return target;
}
getConsumerOfBinding(
binding: TmplAstBoundAttribute | TmplAstBoundEvent | TmplAstTextAttribute,
): SymbolDirectiveMeta | TmplAstElement | TmplAstTemplate | null {
const consumer = this.delegate.getConsumerOfBinding(binding);
if (consumer === null) return null;
if (typeof consumer === 'object' && 'ref' in consumer) {
return new TypeCheckableDirectiveMetaAdapter(
consumer as TypeCheckableDirectiveMeta,
this.componentScopeReader,
);
}
return consumer;
}
getExpressionTarget(expr: AST) {
return this.delegate.getExpressionTarget(expr);
}
}
function getTcbLocationForSymbol(symbol: Symbol | BindingSymbol | ClassSymbol): TcbLocation | null {
if ('tcbLocation' in symbol && symbol.tcbLocation !== undefined) {
return symbol.tcbLocation as TcbLocation;
@ -255,21 +363,8 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
const typeChecker = this.programDriver.getProgram().getTypeChecker();
if (
'kind' in symbol &&
(symbol.kind === SymbolKind.Directive ||
symbol.kind === SymbolKind.SelectorlessDirective ||
symbol.kind === SymbolKind.SelectorlessComponent)
) {
const refNode = (symbol as any).ref?.node;
if (refNode) {
const tsSymbol = typeChecker.getSymbolAtLocation(refNode.name ?? refNode);
if (tsSymbol) return tsSymbol;
}
}
if ('kind' in symbol && symbol.kind === SymbolKind.Reference) {
if ((symbol.target as any).kind && ts.isClassDeclaration(symbol.target as ts.Node)) {
if (ts.isClassDeclaration(symbol.target as ts.Node)) {
const targetNode = symbol.target as ts.ClassDeclaration;
const tsSymbol = typeChecker.getSymbolAtLocation(targetNode.name ?? targetNode);
if (tsSymbol) return tsSymbol;
@ -281,7 +376,7 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
// which will get the symbol of the local variable (e.g. _t4).
}
if ('isPipeClassSymbol' in symbol && (symbol as any).isPipeClassSymbol) {
if ('isPipeClassSymbol' in symbol && symbol.isPipeClassSymbol) {
const type = typeChecker.getTypeAtLocation(node);
if (type && type.getSymbol()) return type.getSymbol() || null;
}
@ -955,8 +1050,7 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
tcbPath,
tcbIsShim,
tcb,
data,
this.componentScopeReader,
new BoundTargetAdapter(data.boundTarget, this.componentScopeReader),
this.config,
);
this.symbolBuilderCache.set(component, builder);
@ -971,6 +1065,14 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
return engine.getGlobalTsContext();
}
private getRefKey(ref: Reference<ClassDeclaration> | SymbolReference): string {
if ('filePath' in ref) {
return `${ref.filePath}#${ref.position}`;
} else {
return `${ref.node.getSourceFile().fileName}#${ref.node.name!.getStart()}`;
}
}
getPotentialTemplateDirectives(
component: ts.ClassDeclaration,
tsLs: ts.LanguageService,
@ -983,14 +1085,15 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
return [];
}
const resultingDirectives = new Map<ClassDeclaration<DeclarationNode>, PotentialDirective>();
const resultingDirectives = new Map<string, PotentialDirective>();
const directivesInScope = this.getTemplateDirectiveInScope(component);
const directiveInGlobal = this.getElementsInGlobal(component, tsLs, options);
for (const directive of [...directivesInScope, ...directiveInGlobal]) {
if (resultingDirectives.has(directive.ref.node)) {
const key = this.getRefKey(directive.ref);
if (resultingDirectives.has(key)) {
continue;
}
resultingDirectives.set(directive.ref.node, directive);
resultingDirectives.set(key, directive);
}
return Array.from(resultingDirectives.values());
}
@ -1005,20 +1108,20 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
// Very similar to the above `getPotentialTemplateDirectives`, but on pipes.
const typeChecker = this.programDriver.getProgram().getTypeChecker();
const resultingPipes = new Map<ClassDeclaration<DeclarationNode>, PotentialPipe>();
const resultingPipes = new Map<string, PotentialPipe>();
if (scope !== null) {
const inScopePipes = this.getScopeData(component, scope)?.pipes ?? [];
for (const p of inScopePipes) {
resultingPipes.set(p.ref.node, p);
resultingPipes.set(this.getRefKey(p.ref), p);
}
}
for (const pipeClass of this.localMetaReader.getKnown(MetaKind.Pipe)) {
const pipeMeta = this.metaReader.getPipeMetadata(new Reference(pipeClass));
if (pipeMeta === null) continue;
if (resultingPipes.has(pipeClass)) continue;
if (resultingPipes.has(this.getRefKey(new Reference(pipeClass)))) continue;
const withScope = this.scopeDataOfPipeMeta(typeChecker, pipeMeta);
if (withScope === null) continue;
resultingPipes.set(pipeClass, {...withScope, isInScope: false});
resultingPipes.set(this.getRefKey(withScope.ref), {...withScope, isInScope: false});
}
return Array.from(resultingPipes.values());
}
@ -1045,7 +1148,7 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
}
getTemplateDirectiveInScope(component: ts.ClassDeclaration): PotentialDirective[] {
const resultingDirectives = new Map<ClassDeclaration<DeclarationNode>, PotentialDirective>();
const resultingDirectives = new Map<string, PotentialDirective>();
const scope = this.getComponentScope(component);
@ -1058,7 +1161,7 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
const inScopeDirectives = this.getScopeData(component, scope)?.directives ?? [];
// First, all in scope directives can be used.
for (const d of inScopeDirectives) {
resultingDirectives.set(d.ref.node, d);
resultingDirectives.set(this.getRefKey(d.ref), d);
}
}
@ -1073,12 +1176,14 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
if (directiveClass.getSourceFile().fileName !== currentComponentFileName) {
continue;
}
const directiveMeta = this.metaReader.getDirectiveMetadata(new Reference(directiveClass));
const ref = new Reference(directiveClass);
const directiveMeta = this.metaReader.getDirectiveMetadata(ref);
if (directiveMeta === null) continue;
if (resultingDirectives.has(directiveClass)) continue;
const key = this.getRefKey(ref);
if (resultingDirectives.has(key)) continue;
const withScope = this.scopeDataOfDirectiveMeta(typeChecker, directiveMeta);
if (withScope === null) continue;
resultingDirectives.set(directiveClass, {...withScope, isInScope: false});
resultingDirectives.set(key, {...withScope, isInScope: false});
}
return Array.from(resultingDirectives.values());
@ -1548,7 +1653,12 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
}
return {
ref: dep.ref,
ref: {
filePath: dep.ref.node.getSourceFile().fileName,
position: dep.ref.node.name!.getStart(),
name: dep.ref.node.name!.text,
moduleSpecifier: dep.ref.bestGuessOwningModule?.specifier,
},
isComponent: dep.isComponent,
isStructural: dep.isStructural,
selector: dep.selector,
@ -1561,12 +1671,16 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
typeChecker: ts.TypeChecker,
dep: PipeMeta,
): Omit<PotentialPipe, 'isInScope'> | null {
const tsSymbol = typeChecker.getSymbolAtLocation(dep.ref.node.name);
if (tsSymbol === undefined) {
if (!dep.ref.node.name) {
return null;
}
return {
ref: dep.ref,
ref: {
filePath: dep.ref.node.getSourceFile().fileName,
position: dep.ref.node.name!.getStart(),
name: dep.ref.node.name!.text,
moduleSpecifier: dep.ref.bestGuessOwningModule?.specifier,
},
name: dep.name,
tsCompletionEntryInfos: null,
};

View file

@ -8,6 +8,7 @@
import {
BoundTarget,
DirectiveMeta,
ParseError,
ParseSourceFile,
R3TargetBinder,
@ -80,7 +81,7 @@ export interface ShimTypeCheckingData {
/**
* Data tracked for each class processed by the type-checking system.
*/
export interface TypeCheckData {
export interface TypeCheckData<D extends DirectiveMeta = TypeCheckableDirectiveMeta> {
/**
* Template nodes for which the TCB was generated.
*/
@ -90,7 +91,7 @@ export interface TypeCheckData {
* `BoundTarget` which was used to generate the TCB, and contains bindings for the associated
* template nodes.
*/
boundTarget: BoundTarget<TypeCheckableDirectiveMeta>;
boundTarget: BoundTarget<D>;
/**
* Errors found while parsing the template, which have been converted to diagnostics.

View file

@ -12,11 +12,15 @@ import {
ASTWithSource,
Binary,
BindingPipe,
BoundTarget,
ClassPropertyMapping,
MatchSource,
ParseSourceSpan,
PropertyRead,
R3Identifiers,
ReferenceTarget,
SafePropertyRead,
TemplateEntity,
TmplAstBoundAttribute,
TmplAstBoundEvent,
TmplAstComponent,
@ -32,12 +36,10 @@ import {
import ts from 'typescript';
import {AbsoluteFsPath} from '../../file_system';
import {Reference} from '../../imports';
import {HostDirectiveMeta, isHostDirectiveMetaForGlobalMode} from '../../metadata';
import {ClassDeclaration} from '../../reflection';
import {ComponentScopeKind, ComponentScopeReader} from '../../scope';
import {isAssignment, isSymbolWithValueDeclaration} from '../../util/src/typescript';
import {isAssignment} from '../../util/src/typescript';
import {
BindingSymbol,
DirectiveSymbol,
@ -51,16 +53,14 @@ import {
ReferenceSymbol,
SelectorlessComponentSymbol,
SelectorlessDirectiveSymbol,
SymbolReference,
Symbol,
SymbolKind,
TcbLocation,
TemplateSymbol,
TsNodeSymbolInfo,
TypeCheckableDirectiveMeta,
TypeCheckingConfig,
VariableSymbol,
} from '../api';
import {
ExpressionIdentifier,
findAllMatchingNodes,
@ -68,10 +68,31 @@ import {
hasExpressionIdentifier,
readDirectiveIdFromComment,
} from './comments';
import {TypeCheckData} from './context';
import {isAccessExpression, isDirectiveDeclaration} from './ts_util';
import {MaybeSourceFileWithOriginalFile, NgOriginalFile} from '../../program_driver';
export interface SymbolDirectiveMeta {
getSymbolReference(): SymbolReference;
getNgModule(): ClassDeclaration | null;
getReferenceTargetNode(): ts.ClassDeclaration | null;
matchSource: MatchSource;
isComponent: boolean;
selector: string | null;
isStructural: boolean;
inputs: ClassPropertyMapping;
outputs: ClassPropertyMapping;
hostDirectives?: HostDirectiveMeta[] | null;
}
export interface SymbolBoundTarget {
getDirectivesOfNode(node: TmplAstNode): SymbolDirectiveMeta[] | null;
getConsumerOfBinding(
binding: TmplAstBoundAttribute | TmplAstBoundEvent | TmplAstTextAttribute,
): SymbolDirectiveMeta | TmplAstElement | TmplAstTemplate | null;
getReferenceTarget(ref: TmplAstReference): ReferenceTarget<SymbolDirectiveMeta> | null;
getExpressionTarget(expr: AST): TemplateEntity | null;
}
/**
* Generates and caches `Symbol`s for various template structures for a given component.
*
@ -85,8 +106,7 @@ export class SymbolBuilder {
private readonly tcbPath: AbsoluteFsPath,
private readonly tcbIsShim: boolean,
private readonly typeCheckBlock: ts.Node,
private readonly typeCheckData: TypeCheckData,
private readonly componentScopeReader: ComponentScopeReader,
private readonly boundTarget: SymbolBoundTarget,
private readonly typeCheckingConfig: TypeCheckingConfig,
) {}
@ -208,8 +228,7 @@ export class SymbolBuilder {
templateNode: TmplAstElement | TmplAstTemplate | TmplAstComponent | TmplAstDirective,
): DirectiveSymbol[] {
const elementSourceSpan = templateNode.startSourceSpan ?? templateNode.sourceSpan;
const boundDirectives = this.typeCheckData.boundTarget.getDirectivesOfNode(templateNode) ?? [];
const boundDirectives = this.boundTarget.getDirectivesOfNode(templateNode) ?? [];
let symbols = this.getDirectiveSymbolsForDirectives(boundDirectives, elementSourceSpan);
// 'getDirectivesOfNode' will not return the directives intended for an element
@ -224,8 +243,7 @@ export class SymbolBuilder {
templateNode instanceof TmplAstTemplate &&
sourceSpanEqual(firstChild.sourceSpan, templateNode.sourceSpan);
if (isMicrosyntaxTemplate) {
const firstChildDirectives =
this.typeCheckData.boundTarget.getDirectivesOfNode(firstChild);
const firstChildDirectives = this.boundTarget.getDirectivesOfNode(firstChild);
if (firstChildDirectives !== null) {
const childSymbols = this.getDirectiveSymbolsForDirectives(
firstChildDirectives,
@ -233,7 +251,11 @@ export class SymbolBuilder {
);
// Merge symbols, avoiding duplicates
for (const symbol of childSymbols) {
if (!symbols.some((s) => s.ref.node === symbol.ref.node)) {
if (
!symbols.some(
(s) => s.ref.name === symbol.ref.name && s.ref.filePath === symbol.ref.filePath,
)
) {
symbols.push(symbol);
}
}
@ -246,7 +268,7 @@ export class SymbolBuilder {
}
private getDirectiveSymbolsForDirectives(
boundDirectives: TypeCheckableDirectiveMeta[],
boundDirectives: SymbolDirectiveMeta[],
span: ParseSourceSpan,
): DirectiveSymbol[] {
const nodes = findAllMatchingNodes(this.typeCheckBlock, {
@ -254,19 +276,20 @@ export class SymbolBuilder {
filter: isDirectiveDeclaration,
});
const hostDirectiveMap = new Map<ts.Node, HostDirectiveMeta>();
const hostDirectiveMap = new Map<string, HostDirectiveMeta>();
for (const d of boundDirectives) {
if (d.hostDirectives) {
for (const hd of d.hostDirectives) {
if (isHostDirectiveMetaForGlobalMode(hd)) {
hostDirectiveMap.set(hd.directive.node, hd);
const key = `${hd.directive.node.getSourceFile().fileName}#${hd.directive.node.name.text}`;
hostDirectiveMap.set(key, hd);
}
}
}
}
const symbols: DirectiveSymbol[] = [];
const seenDirectives = new Set<ts.ClassDeclaration>();
const seenDirectives = new Set<string>();
const sf = this.typeCheckBlock.getSourceFile();
for (const node of nodes) {
@ -275,19 +298,20 @@ export class SymbolBuilder {
const meta = boundDirectives[id];
if (!meta) continue;
const declaration = meta.ref.node as unknown as ts.ClassDeclaration;
const ref = meta.getSymbolReference();
const refKey = `${ref.filePath}#${ref.name}`;
if (!seenDirectives.has(declaration)) {
const ref = new Reference<ClassDeclaration>(declaration as ClassDeclaration);
const hostMeta = hostDirectiveMap.get(declaration);
if (!seenDirectives.has(refKey)) {
const ref = meta.getSymbolReference();
const key = `${ref.filePath}#${ref.name}`;
const hostMeta = hostDirectiveMap.get(key) || null;
const directiveSymbol: DirectiveSymbol = hostMeta
? {
tcbLocation: this.getTcbLocationForNode(node),
ref,
selector: meta.selector,
isComponent: meta.isComponent,
ngModule: this.getDirectiveModule(declaration),
ngModule: meta.getNgModule(),
kind: SymbolKind.Directive,
isStructural: meta.isStructural,
isInScope: true,
@ -301,7 +325,7 @@ export class SymbolBuilder {
ref,
selector: meta.selector,
isComponent: meta.isComponent,
ngModule: this.getDirectiveModule(declaration),
ngModule: meta.getNgModule(),
kind: SymbolKind.Directive,
isStructural: meta.isStructural,
isInScope: true,
@ -310,23 +334,15 @@ export class SymbolBuilder {
};
symbols.push(directiveSymbol);
seenDirectives.add(declaration);
seenDirectives.add(refKey);
}
}
return symbols;
}
private getDirectiveModule(declaration: ts.ClassDeclaration): ClassDeclaration | null {
const scope = this.componentScopeReader.getScopeForComponent(declaration as ClassDeclaration);
if (scope === null || scope.kind !== ComponentScopeKind.NgModule) {
return null;
}
return scope.ngModule;
}
private getSymbolOfBoundEvent(eventBinding: TmplAstBoundEvent): OutputBindingSymbol | null {
const consumer = this.typeCheckData.boundTarget.getConsumerOfBinding(eventBinding);
const consumer = this.boundTarget.getConsumerOfBinding(eventBinding);
if (consumer === null) {
return null;
}
@ -415,7 +431,7 @@ export class SymbolBuilder {
private getSymbolOfInputBinding(
binding: TmplAstBoundAttribute | TmplAstTextAttribute,
): InputBindingSymbol | DomBindingSymbol | null {
const consumer = this.typeCheckData.boundTarget.getConsumerOfBinding(binding);
const consumer = this.boundTarget.getConsumerOfBinding(binding);
if (consumer === null) {
return null;
}
@ -490,18 +506,16 @@ export class SymbolBuilder {
private getDirectiveSymbolForAccessExpression(
fieldAccessExpr: ts.ElementAccessExpression | ts.PropertyAccessExpression,
meta: TypeCheckableDirectiveMeta,
meta: SymbolDirectiveMeta,
): DirectiveSymbol | null {
const ngModule = this.getDirectiveModule(meta.ref.node as unknown as ts.ClassDeclaration);
return {
ref: meta.ref,
ref: meta.getSymbolReference(),
kind: SymbolKind.Directive,
tcbLocation: this.getTcbLocationForNode(fieldAccessExpr.expression),
isComponent: meta.isComponent,
isStructural: meta.isStructural,
selector: meta.selector,
ngModule,
ngModule: meta.getNgModule(),
matchSource: MatchSource.Selector,
isInScope: true, // TODO: this should always be in scope in this context, right?
tsCompletionEntryInfos: null,
@ -537,7 +551,7 @@ export class SymbolBuilder {
}
private getSymbolOfReference(ref: TmplAstReference): ReferenceSymbol | null {
const target = this.typeCheckData.boundTarget.getReferenceTarget(ref);
const target = this.boundTarget.getReferenceTarget(ref);
if (target === null) {
return null;
}
@ -590,14 +604,15 @@ export class SymbolBuilder {
referenceVarLocation: referenceVarTcbLocation,
};
} else {
if (!ts.isClassDeclaration(target.directive.ref.node)) {
const targetNode = target.directive.getReferenceTargetNode();
if (targetNode === null) {
return null;
}
return {
kind: SymbolKind.Reference,
declaration: ref,
target: target.directive.ref.node,
target: targetNode,
targetLocation,
referenceVarLocation: referenceVarTcbLocation,
};
@ -641,7 +656,7 @@ export class SymbolBuilder {
tcbLocation: this.getTcbLocationForNode(pipeVariableNode),
isPipeClassSymbol: true,
},
} as any;
};
}
private getSymbolOfTemplateExpression(
@ -651,9 +666,14 @@ export class SymbolBuilder {
expression = expression.ast;
}
const expressionTarget = this.typeCheckData.boundTarget.getExpressionTarget(expression);
const expressionTarget = this.boundTarget.getExpressionTarget(expression);
if (expressionTarget !== null) {
return this.getSymbol(expressionTarget);
return this.getSymbol(expressionTarget) as
| VariableSymbol
| ReferenceSymbol
| ExpressionSymbol
| LetDeclarationSymbol
| null;
}
let withSpan = expression.sourceSpan;

View file

@ -2359,7 +2359,7 @@ runInEachFileSystem(() => {
const nodes = templateTypeChecker.getTemplate(cmp)!;
const symbol = templateTypeChecker.getSymbolOfNode(nodes[0] as TmplAstComponent, cmp)!;
assertSelectorlessComponentSymbol(symbol);
expect(symbol.directives.map((d) => d.ref.node.name.text)).toEqual(['Dep']);
expect(symbol.directives.map((d) => d.ref.name)).toEqual(['Dep']);
});
it('should get symbol for a selector attribute when there are multiple directives', () => {
@ -2397,7 +2397,7 @@ runInEachFileSystem(() => {
expect(symbol).toBeTruthy();
assertDomBindingSymbol(symbol!);
assertElementSymbol(symbol!.host);
expect(symbol!.host.directives.map((d) => d.ref.node.name.text)).toContain('MatListItem');
expect(symbol!.host.directives.map((d) => d.ref.name)).toContain('MatListItem');
});
it('should get symbol of a selectorless directive', () => {
const fileName = absoluteFrom('/main.ts');
@ -2418,7 +2418,7 @@ runInEachFileSystem(() => {
const element = nodes[0] as TmplAstElement;
const symbol = templateTypeChecker.getSymbolOfNode(element.directives[0], cmp)!;
assertSelectorlessDirectiveSymbol(symbol);
expect(symbol.directives.map((d) => d.ref.node.name.text)).toEqual(['Dep']);
expect(symbol.directives.map((d) => d.ref.name)).toEqual(['Dep']);
});
it('should get symbol on a node that has both selectorless components and directives', () => {
@ -2444,10 +2444,10 @@ runInEachFileSystem(() => {
const directiveSymbol = templateTypeChecker.getSymbolOfNode(component.directives[0], cmp)!;
assertSelectorlessComponentSymbol(componentSymbol);
expect(componentSymbol.directives.map((d) => d.ref.node.name.text)).toEqual(['DepComp']);
expect(componentSymbol.directives.map((d) => d.ref.name)).toEqual(['DepComp']);
assertSelectorlessDirectiveSymbol(directiveSymbol);
expect(directiveSymbol.directives.map((d) => d.ref.node.name.text)).toEqual(['DepDir']);
expect(directiveSymbol.directives.map((d) => d.ref.name)).toEqual(['DepDir']);
});
it('should get symbol of selectorless directives with host directives', () => {
@ -2502,16 +2502,13 @@ runInEachFileSystem(() => {
const directiveSymbol = templateTypeChecker.getSymbolOfNode(component.directives[0], cmp)!;
assertSelectorlessComponentSymbol(componentSymbol);
expect(componentSymbol.directives.map((d) => d.ref.node.name.text)).toEqual([
expect(componentSymbol.directives.map((d) => d.ref.name)).toEqual([
'DepCompHost',
'DepComp',
]);
assertSelectorlessDirectiveSymbol(directiveSymbol);
expect(directiveSymbol.directives.map((d) => d.ref.node.name.text)).toEqual([
'DepDirHost',
'DepDir',
]);
expect(directiveSymbol.directives.map((d) => d.ref.name)).toEqual(['DepDirHost', 'DepDir']);
});
it('should get symbol of a selectorless component input', () => {
@ -3055,7 +3052,7 @@ runInEachFileSystem(() => {
const symbol = templateTypeChecker.getSymbolOfNode(element, cmp)!;
assertElementSymbol(symbol);
const actual = symbol.directives.map((d) => ({
name: d.ref.node.name.text,
name: d.ref.name,
matchSource: d.matchSource,
}));
actual.sort((a, b) => a.name.localeCompare(b.name));

View file

@ -117,6 +117,7 @@ export function pruneNgModules(
tracker,
typeChecker,
templateTypeChecker,
tsProgram,
declarationImportRemapper,
);
@ -271,6 +272,7 @@ function replaceInComponentImportsArray(
tracker: ChangeTracker,
typeChecker: ts.TypeChecker,
templateTypeChecker: TemplateTypeChecker,
program: ts.Program,
importRemapper?: DeclarationImportsRemapper,
) {
for (const [array, toReplace] of componentImportArrays.getEntries()) {
@ -282,7 +284,7 @@ function replaceInComponentImportsArray(
const replacements = new UniqueItemTracker<ts.Node, Reference<NamedClassDeclaration>>();
const usedImports = new Set(
findTemplateDependencies(closestClass, templateTypeChecker).map((ref) => ref.node),
findTemplateDependencies(closestClass, templateTypeChecker, program).map((ref) => ref.node),
);
const nodesToRemove = new Set<ts.Node>();

View file

@ -121,6 +121,7 @@ export function toStandaloneBootstrap(
allDeclarations,
tracker,
templateTypeChecker,
program.getTsProgram(),
declarationImportRemapper,
);
}

View file

@ -20,7 +20,6 @@ import {ChangesByFile, ChangeTracker, ImportRemapper} from '../../utils/change_t
import {getAngularDecorators, NgDecorator} from '../../utils/ng_decorators';
import {getImportSpecifier} from '../../utils/typescript/imports';
import {closestNode} from '../../utils/typescript/nodes';
import {isReferenceToImport} from '../../utils/typescript/symbol';
import {
findClassDeclaration,
@ -88,6 +87,7 @@ export function toStandalone(
declarations,
tracker,
templateTypeChecker,
program.getTsProgram(),
declarationImportRemapper,
);
}
@ -119,6 +119,7 @@ export function convertNgModuleDeclarationToStandalone(
allDeclarations: Set<ts.ClassDeclaration>,
tracker: ChangeTracker,
typeChecker: TemplateTypeChecker,
program: ts.Program,
importRemapper?: DeclarationImportsRemapper,
): void {
const directiveMeta = typeChecker.getDirectiveMetadata(decl);
@ -132,6 +133,7 @@ export function convertNgModuleDeclarationToStandalone(
allDeclarations,
tracker,
typeChecker,
program,
importRemapper,
);
@ -175,9 +177,10 @@ function getComponentImportExpressions(
allDeclarations: Set<ts.ClassDeclaration>,
tracker: ChangeTracker,
typeChecker: TemplateTypeChecker,
program: ts.Program,
importRemapper?: DeclarationImportsRemapper,
): ts.Expression[] {
const templateDependencies = findTemplateDependencies(decl, typeChecker);
const templateDependencies = findTemplateDependencies(decl, typeChecker, program);
const usedDependenciesInMigration = new Set(
templateDependencies.filter((dep) => allDeclarations.has(dep.node)),
);
@ -656,6 +659,7 @@ export function findTestObjectsToMigrate(sourceFile: ts.SourceFile, typeChecker:
export function findTemplateDependencies(
decl: ts.ClassDeclaration,
typeChecker: TemplateTypeChecker,
program: ts.Program,
): Reference<NamedClassDeclaration>[] {
const results: Reference<NamedClassDeclaration>[] = [];
const usedDirectives = typeChecker.getUsedDirectives(decl);
@ -663,9 +667,7 @@ export function findTemplateDependencies(
if (usedDirectives !== null) {
for (const dir of usedDirectives) {
if (ts.isClassDeclaration(dir.ref.node)) {
results.push(dir.ref as Reference<NamedClassDeclaration>);
}
results.push(dir.ref as Reference<NamedClassDeclaration>);
}
}
@ -673,11 +675,17 @@ export function findTemplateDependencies(
const potentialPipes = typeChecker.getPotentialPipes(decl);
for (const pipe of potentialPipes) {
if (
ts.isClassDeclaration(pipe.ref.node) &&
usedPipes.some((current) => pipe.name === current)
) {
results.push(pipe.ref as Reference<NamedClassDeclaration>);
const sourceFile = program.getSourceFile(pipe.ref.filePath);
const node = sourceFile ? findTightestNode(sourceFile, pipe.ref.position) : null;
const classDecl = node ? closestNode(node, ts.isClassDeclaration) : null;
if (classDecl && usedPipes.some((current) => pipe.name === current)) {
const owningModule = pipe.ref.moduleSpecifier
? {
specifier: pipe.ref.moduleSpecifier,
resolutionContext: decl.getSourceFile().fileName,
}
: null;
results.push(new Reference(classDecl as NamedClassDeclaration, owningModule));
}
}
}
@ -947,3 +955,10 @@ function isStandaloneDeclaration(
templateTypeChecker.getDirectiveMetadata(node) || templateTypeChecker.getPipeMetadata(node);
return metadata != null && metadata.isStandalone;
}
function findTightestNode(node: ts.Node, position: number): ts.Node | undefined {
if (position < node.getStart() || position > node.getEnd()) {
return undefined;
}
return node.forEachChild((c) => findTightestNode(c, position)) ?? node;
}

View file

@ -24,6 +24,7 @@ import ts from 'typescript';
import {DisplayInfoKind, unsafeCastDisplayInfoKindToScriptElementKind} from './utils/display_parts';
import {makeElementSelector} from './utils';
import {getClassDeclarationFromSymbolReference} from './utils/ts_utils';
/**
* Differentiates different kinds of `AttributeCompletion`s.
@ -231,7 +232,8 @@ export function buildAttributeCompletionTable(
// An `ElementSymbol` was available. This means inputs and outputs for directives on the
// element can be added to the completion table.
for (const dirSymbol of symbol.directives) {
const directive = checker.getTsSymbolOfSymbol(dirSymbol)?.valueDeclaration;
const directive = getClassDeclarationFromSymbolReference(ls, dirSymbol.ref);
if (!directive || !ts.isClassDeclaration(directive)) {
continue;
}
@ -302,9 +304,10 @@ export function buildAttributeCompletionTable(
const elementSelector = makeElementSelector(element);
for (const currentDir of potentialDirectives) {
const directive = currentDir.ref.node;
const directive = getClassDeclarationFromSymbolReference(ls, currentDir.ref);
// Skip directives that are present on the element.
if (!ts.isClassDeclaration(directive) || presentDirectives.has(directive)) {
if (!directive || !ts.isClassDeclaration(directive) || presentDirectives.has(directive)) {
continue;
}
@ -603,11 +606,23 @@ export function addAttributeCompletionEntries(
}
}
function getDirectiveSymbol(
directive: PotentialDirective,
checker: ts.TypeChecker,
ls?: ts.LanguageService,
): ts.Symbol | null {
if (!ls) return null;
const classDecl = getClassDeclarationFromSymbolReference(ls, directive.ref);
if (!classDecl || !classDecl.name) return null;
return checker.getSymbolAtLocation(classDecl.name) ?? null;
}
export function getAttributeCompletionSymbol(
attrKind: AttributeCompletionKind,
directive: PotentialDirective | null,
classPropertyName: string | null,
checker: ts.TypeChecker,
ls?: ts.LanguageService,
): ts.Symbol | null {
switch (attrKind) {
case AttributeCompletionKind.DomAttribute:
@ -616,14 +631,14 @@ export function getAttributeCompletionSymbol(
return null;
case AttributeCompletionKind.DirectiveAttribute:
case AttributeCompletionKind.StructuralDirectiveAttribute:
return directive ? (checker.getSymbolAtLocation(directive.ref.node.name) ?? null) : null;
return directive ? getDirectiveSymbol(directive, checker, ls) : null;
case AttributeCompletionKind.DirectiveInput:
case AttributeCompletionKind.DirectiveOutput:
if (directive === null || classPropertyName === null) {
return null;
}
const dirSymbol = checker.getSymbolAtLocation(directive.ref.node.name);
const dirSymbol = getDirectiveSymbol(directive, checker, ls);
if (!dirSymbol) return null;
return checker.getDeclaredTypeOfSymbol(dirSymbol).getProperty(classPropertyName) ?? null;
}

View file

@ -65,6 +65,7 @@ import {
findTightestNode,
getCodeActionToImportTheDirectiveDeclaration,
standaloneTraitOrNgModule,
getClassDeclarationFromSymbolReference,
} from './utils/ts_utils';
import {filterAliasImports, isBoundEventWithSyntheticHandler, isWithin} from './utils';
@ -772,9 +773,9 @@ export class CompletionBuilder<N extends TmplAstNode | AST> {
directive.tsCompletionEntryInfos.length > 0
) {
directiveCompletionDetailMap.set(tag, {
fileName: directive.ref.node.getSourceFile().fileName,
entryName: directive.ref.node.name!.text,
pos: directive.ref.node.getStart(),
fileName: directive.ref.filePath,
entryName: directive.ref.name,
pos: directive.ref.position,
attrKind: null,
// The Angular LS only supports displaying one directive at a time when
@ -896,7 +897,9 @@ export class CompletionBuilder<N extends TmplAstNode | AST> {
}
const directive = tagMap.get(entryName)!;
const decl = directive.ref.node;
const decl = getClassDeclarationFromSymbolReference(this.tsLS, directive.ref);
if (!decl || !ts.isClassDeclaration(decl)) return undefined;
return decl.name ? this.typeChecker.getSymbolAtLocation(decl.name) : undefined;
}
@ -1120,9 +1123,9 @@ export class CompletionBuilder<N extends TmplAstNode | AST> {
completion.directive.tsCompletionEntryInfos.length > 0
) {
directiveCompletionDetailMap.set(key, {
fileName: completion.directive.ref.node.getSourceFile().fileName,
entryName: completion.directive.ref.node.name!.text,
pos: completion.directive.ref.node.getStart(),
fileName: completion.directive.ref.filePath,
entryName: completion.directive.ref.name,
pos: completion.directive.ref.position,
attrKind: completion.kind,
// The Angular LS only supports displaying one directive at a time when
@ -1235,6 +1238,7 @@ export class CompletionBuilder<N extends TmplAstNode | AST> {
name,
directive,
templateTypeChecker,
this.tsLS,
);
}
}
@ -1276,6 +1280,7 @@ export class CompletionBuilder<N extends TmplAstNode | AST> {
directive,
classPropertyName,
this.typeChecker,
this.tsLS,
);
if (propertySymbol === null) {
break;
@ -1295,7 +1300,7 @@ export class CompletionBuilder<N extends TmplAstNode | AST> {
this.typeChecker,
propertySymbol,
kind,
directive.ref.node.name!.text,
directive.ref.name,
);
if (info === null) {
break;
@ -1376,6 +1381,7 @@ export class CompletionBuilder<N extends TmplAstNode | AST> {
'directive' in completion ? completion.directive : null,
'classPropertyName' in completion ? completion.classPropertyName : null,
this.typeChecker,
this.tsLS,
) ?? undefined
);
}
@ -1536,12 +1542,14 @@ function getClassPropertyNameFromDirective(
attrName: string,
directive: PotentialDirective | null,
templateTypeChecker: TemplateTypeChecker,
ls?: ts.LanguageService,
): string | null {
if (directive === null || attrKind === null) {
return null;
}
const dirNode = directive.ref.node;
if (!ts.isClassDeclaration(dirNode)) {
const dirNode = ls ? getClassDeclarationFromSymbolReference(ls, directive.ref) : null;
if (!dirNode || !ts.isClassDeclaration(dirNode)) {
return null;
}
const meta = templateTypeChecker.getDirectiveMetadata(dirNode);

View file

@ -166,17 +166,11 @@ export function getDirectiveDisplayInfo(
dir: PotentialDirective,
): DisplayInfo {
const kind = dir.isComponent ? DisplayInfoKind.COMPONENT : DisplayInfoKind.DIRECTIVE;
const decl = dir.ref.node;
if (decl === undefined || decl.name === undefined) {
return {
kind,
displayParts: [],
documentation: [],
tags: undefined,
};
}
const filePath = dir.ref.filePath;
const position = dir.ref.position;
const name = dir.ref.name;
const res = tsLS.getQuickInfoAtPosition(decl.getSourceFile().fileName, decl.name.getStart());
const res = tsLS.getQuickInfoAtPosition(filePath, position);
if (res === undefined) {
return {
kind,
@ -186,12 +180,7 @@ export function getDirectiveDisplayInfo(
};
}
const displayParts = createDisplayParts(
decl.name.text,
kind,
dir.ngModule?.name?.text,
undefined,
);
const displayParts = createDisplayParts(name, kind, dir.ngModule?.name?.text, undefined);
return {
kind,

View file

@ -14,6 +14,7 @@ import {
PotentialPipe,
TemplateTypeChecker,
TsCompletionEntryInfo,
SymbolReference,
} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
import ts from 'typescript';
import {guessIndentationInSingleLine} from './format';
@ -35,6 +36,7 @@ export function findTightestNode(node: ts.Node, position: number): ts.Node | und
export interface FindOptions<T extends ts.Node> {
filter: (node: ts.Node) => node is T;
position?: number;
}
/**
@ -64,6 +66,11 @@ export function findFirstMatchingNode<T extends 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;
@ -74,6 +81,28 @@ export function findFirstMatchingNode<T extends ts.Node>(
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)) {
@ -674,15 +703,31 @@ export function getCodeActionToImportTheDirectiveDeclaration(
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(
directive.ref,
ref,
importOn,
PotentialImportMode.Normal,
potentialDirectiveModuleSpecifierResolver,
);
const declarationName = directive.ref.node.name.getText();
const declarationName = directive.ref.name;
for (const potentialImport of potentialImports) {
const fileImportChanges: ts.TextChange[] = [];
@ -810,9 +855,7 @@ function getStringLiteralText(moduleSpecifier: ts.Expression): string | undefine
* The developer should export the `FooComponent` in the `AppModule`.
*
*/
class PotentialDirectiveModuleSpecifierResolverImpl
implements PotentialDirectiveModuleSpecifierResolver
{
class PotentialDirectiveModuleSpecifierResolverImpl implements PotentialDirectiveModuleSpecifierResolver {
constructor(
private readonly compiler: NgCompiler,
private readonly directive: PotentialDirective | PotentialPipe,