refactor(compiler-cli): decouple TemplateSymbolBuilder from ts.TypeChecker

This updates the SymbolBuilder to no longer use ts.TypeChecker internally to
build symbols for the language service. These lookups are deferred/done later
using the newly expanded template type checker API.
This commit is contained in:
Andrew Scott 2026-04-07 11:53:00 -07:00
parent de12bc7e02
commit 910dcb6d6a
25 changed files with 1114 additions and 677 deletions

View file

@ -45,6 +45,8 @@ import {
TsCompletionEntryInfo,
} from './scope';
import {
BindingSymbol,
ClassSymbol,
ElementSymbol,
SelectorlessComponentSymbol,
SelectorlessDirectiveSymbol,
@ -178,6 +180,17 @@ export interface TemplateTypeChecker {
): SelectorlessDirectiveSymbol | null;
getSymbolOfNode(node: AST | TmplAstNode, component: ts.ClassDeclaration): Symbol | null;
/**
* Translates a symbol's TCB location to its corresponding ts.Type using the program's type checker.
* This is used by compiler checks that need semantic type information from a positional symbol.
*/
getTypeOfSymbol(symbol: Symbol | BindingSymbol | ClassSymbol): ts.Type | null;
/**
* Translates a symbol's TCB location to its corresponding ts.Symbol using the program's type checker.
*/
getTsSymbolOfSymbol(symbol: Symbol | BindingSymbol | ClassSymbol): ts.Symbol | null;
/**
* Get "global" `Completion`s in the given context.
*

View file

@ -61,11 +61,6 @@ export interface TsCompletionEntryInfo {
export interface PotentialDirective {
ref: Reference<ClassDeclaration>;
/**
* The `ts.Symbol` for the directive class.
*/
tsSymbol: SymbolWithValueDeclaration;
/**
* The module which declares the directive.
*/
@ -106,11 +101,6 @@ export interface PotentialDirective {
export interface PotentialPipe {
ref: Reference<ClassDeclaration>;
/**
* The `ts.Symbol` for the pipe class.
*/
tsSymbol: ts.Symbol;
/**
* Name of the pipe.
*/

View file

@ -7,6 +7,7 @@
*/
import {
MatchSource,
TmplAstComponent,
TmplAstDirective,
TmplAstElement,
@ -80,20 +81,20 @@ export interface TcbLocation {
/** The location in the file where node appears. */
positionInFile: number;
/** The end position in the TCB file. Used to correctly resolve AST expressions. */
endInFile?: number;
}
/**
* A generic representation of some node in a template.
*/
export interface TsNodeSymbolInfo {
/** The `ts.Type` of the template node. */
tsType: ts.Type;
/** The `ts.Symbol` for the template node */
tsSymbol: ts.Symbol | null;
/** The position of the most relevant part of the template node. */
tcbLocation: TcbLocation;
/** The position of the expression used to determine the type. */
tcbTypeLocation?: TcbLocation;
}
/**
@ -102,29 +103,17 @@ export interface TsNodeSymbolInfo {
export interface ExpressionSymbol {
kind: SymbolKind.Expression;
/** The `ts.Type` of the expression AST. */
tsType: ts.Type;
/**
* The `ts.Symbol` of the entity. This could be `null`, for example `AST` expression
* `{{foo.bar + foo.baz}}` does not have a `ts.Symbol` but `foo.bar` and `foo.baz` both do.
*/
tsSymbol: ts.Symbol | null;
/** The position of the most relevant part of the expression. */
tcbLocation: TcbLocation;
/** The position of the expression used to determine the type. */
tcbTypeLocation?: TcbLocation;
}
/** Represents either an input or output binding in a template. */
export interface BindingSymbol {
kind: SymbolKind.Binding;
/** The `ts.Type` of the class member on the directive that is the target of the binding. */
tsType: ts.Type;
/** The `ts.Symbol` of the class member on the directive that is the target of the binding. */
tsSymbol: ts.Symbol;
/**
* The `DirectiveSymbol` or `ElementSymbol` for the Directive, Component, or `HTMLElement` with
* the binding.
@ -133,6 +122,9 @@ export interface BindingSymbol {
/** The location in the shim file where the field access for the binding appears. */
tcbLocation: TcbLocation;
/** The position of the expression used to determine the type. */
tcbTypeLocation?: TcbLocation;
}
/**
@ -161,24 +153,6 @@ export interface OutputBindingSymbol {
export interface ReferenceSymbol {
kind: SymbolKind.Reference;
/**
* The `ts.Type` of the Reference value.
*
* `TmplAstTemplate` - The type of the `TemplateRef`
* `TmplAstElement` - The `ts.Type` for the `HTMLElement`.
* Directive - The `ts.Type` for the class declaration.
*/
tsType: ts.Type;
/**
* The `ts.Symbol` for the Reference value.
*
* `TmplAstTemplate` - A `TemplateRef` symbol.
* `TmplAstElement` - The symbol for the `HTMLElement`.
* Directive - The symbol for the class declaration of the directive.
*/
tsSymbol: ts.Symbol;
/**
* Depending on the type of the reference, this is one of the following:
* - `TmplAstElement` when the local ref refers to the HTML element
@ -219,20 +193,6 @@ export interface ReferenceSymbol {
export interface VariableSymbol {
kind: SymbolKind.Variable;
/**
* The `ts.Type` of the entity.
*
* This will be `any` if there is no `ngTemplateContextGuard`.
*/
tsType: ts.Type;
/**
* The `ts.Symbol` for the context variable.
*
* This will be `null` if there is no `ngTemplateContextGuard`.
*/
tsSymbol: ts.Symbol | null;
/**
* The node in the `TemplateAst` where the variable is declared. That is, the node for the `let-`
* node in the template.
@ -244,10 +204,7 @@ export interface VariableSymbol {
*/
localVarLocation: TcbLocation;
/**
* The location in the shim file for the initializer node of the variable that represents the
* template variable.
*/
/** The location in the shim file for the initializer node of the variable that represents the template variable. */
initializerLocation: TcbLocation;
}
@ -257,16 +214,6 @@ export interface VariableSymbol {
export interface LetDeclarationSymbol {
kind: SymbolKind.LetDeclaration;
/** The `ts.Type` of the entity. */
tsType: ts.Type;
/**
* The `ts.Symbol` for the declaration.
*
* This will be `null` if the symbol could not be resolved using the type checker.
*/
tsSymbol: ts.Symbol | null;
/** The node in the `TemplateAst` where the `@let` is declared. */
declaration: TmplAstLetDeclaration;
@ -274,6 +221,9 @@ export interface LetDeclarationSymbol {
* The location in the shim file for the identifier of the `@let` declaration.
*/
localVarLocation: TcbLocation;
/** The location in the shim file of the `@let` declaration's initializer expression. */
initializerLocation: TcbLocation;
}
/**
@ -282,12 +232,6 @@ export interface LetDeclarationSymbol {
export interface ElementSymbol {
kind: SymbolKind.Element;
/** The `ts.Type` for the `HTMLElement`. */
tsType: ts.Type;
/** The `ts.Symbol` for the `HTMLElement`. */
tsSymbol: ts.Symbol | null;
/** A list of directives applied to the element. */
directives: DirectiveSymbol[];
@ -310,12 +254,6 @@ export interface TemplateSymbol {
export interface SelectorlessComponentSymbol {
kind: SymbolKind.SelectorlessComponent;
/** The `ts.Type` for the component class. */
tsType: ts.Type;
/** The `ts.Symbol` for the component class. */
tsSymbol: ts.Symbol | null;
/**
* Includes the component class itself and any host directives
* that may have been applied as a side-effect of it.
@ -333,12 +271,6 @@ export interface SelectorlessComponentSymbol {
export interface SelectorlessDirectiveSymbol {
kind: SymbolKind.SelectorlessDirective;
/** The `ts.Type` for the directive class. */
tsType: ts.Type;
/** The `ts.Symbol` for the directive class. */
tsSymbol: ts.Symbol | null;
/**
* Includes the directive class itself and any host directives
* that may have been applied as a side-effect of it.
@ -356,9 +288,6 @@ export interface SelectorlessDirectiveSymbol {
interface DirectiveSymbolBase extends PotentialDirective {
kind: SymbolKind.Directive;
/** The `ts.Type` for the class declaration. */
tsType: ts.Type;
/** The location in the shim file for the variable that holds the type of the directive. */
tcbLocation: TcbLocation;
}
@ -368,9 +297,9 @@ interface DirectiveSymbolBase extends PotentialDirective {
* template.
*/
export type DirectiveSymbol =
| (DirectiveSymbolBase & {isHostDirective: false})
| (DirectiveSymbolBase & {matchSource: MatchSource.Selector})
| (DirectiveSymbolBase & {
isHostDirective: true;
matchSource: MatchSource.HostDirective;
exposedInputs: Record<string, string> | null;
exposedOutputs: Record<string, string> | null;
});
@ -393,15 +322,6 @@ export interface DomBindingSymbol {
export interface PipeSymbol {
kind: SymbolKind.Pipe;
/** The `ts.Type` of the transform node. */
tsType: ts.Type;
/**
* The `ts.Symbol` for the transform call. This could be `null` when `checkTypeOfPipes` is set to
* `false` because the transform call would be of the form `(_pipe1 as any).transform()`
*/
tsSymbol: ts.Symbol | null;
/** The position of the transform call in the template. */
tcbLocation: TcbLocation;
@ -411,12 +331,6 @@ export interface PipeSymbol {
/** Represents an instance of a class found in the TCB, i.e. `var _pipe1: MyPipe = null!; */
export interface ClassSymbol {
/** The `ts.Type` of class. */
tsType: ts.Type;
/** The `ts.Symbol` for class. */
tsSymbol: SymbolWithValueDeclaration;
/** The position for the variable declaration for the class instance. */
tcbLocation: TcbLocation;
}

View file

@ -168,9 +168,12 @@ function buildDiagnosticForSignal(
node: PropertyRead,
component: ts.ClassDeclaration,
): Array<NgTemplateDiagnostic<ErrorCode.INTERPOLATED_SIGNAL_NOT_INVOKED>> {
// check for `{{ mySignal }}`
const symbol = ctx.templateTypeChecker.getSymbolOfNode(node, component);
if (symbol !== null && symbol.kind === SymbolKind.Expression && isSignalReference(symbol)) {
if (
symbol !== null &&
symbol.kind === SymbolKind.Expression &&
isSignalReference(symbol, ctx.templateTypeChecker)
) {
const templateMapping = ctx.templateTypeChecker.getSourceMappingAtTcbLocation(
symbol.tcbLocation,
)!;
@ -192,11 +195,19 @@ function buildDiagnosticForSignal(
if (!isFunctionInstanceProperty(node.name) && !isSignalInstanceProperty(node.name)) {
return [];
}
// If the receiver is not a PropertyRead, it means it's not a simple property access
// (e.g., it could be a MethodCall like `mySignal().set`). In that case, we assume
// it was invoked and skip the warning.
if (!(node.receiver instanceof PropertyRead)) {
return [];
}
const symbolOfReceiver = ctx.templateTypeChecker.getSymbolOfNode(node.receiver, component);
if (
symbolOfReceiver !== null &&
symbolOfReceiver.kind === SymbolKind.Expression &&
isSignalReference(symbolOfReceiver)
isSignalReference(symbolOfReceiver, ctx.templateTypeChecker)
) {
const templateMapping = ctx.templateTypeChecker.getSourceMappingAtTcbLocation(
symbolOfReceiver.tcbLocation,

View file

@ -39,8 +39,8 @@ class NullishCoalescingNotNullableCheck extends TemplateCheckWithVisitor<ErrorCo
if (symbolLeft === null || symbolLeft.kind !== SymbolKind.Expression) {
return [];
}
const typeLeft = symbolLeft.tsType;
if (typeLeft.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) {
const typeLeft = ctx.templateTypeChecker.getTypeOfSymbol(symbolLeft);
if (!typeLeft || typeLeft.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) {
// We should not make assumptions about the any and unknown types; using a nullish coalescing
// operator is acceptable for those.
return [];

View file

@ -62,8 +62,8 @@ class OptionalChainNotNullableCheck extends TemplateCheckWithVisitor<ErrorCode.O
if (symbolLeft === null || symbolLeft.kind !== SymbolKind.Expression) {
return [];
}
const typeLeft = symbolLeft.tsType;
if (typeLeft.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) {
const typeLeft = ctx.templateTypeChecker.getTypeOfSymbol(symbolLeft);
if (!typeLeft || typeLeft.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) {
// We should not make assumptions about the any and unknown types; using a nullish coalescing
// operator is acceptable for those.
return [];

View file

@ -103,7 +103,8 @@ function assertExpressionInvoked(
const symbol = ctx.templateTypeChecker.getSymbolOfNode(expression, component);
if (symbol !== null && symbol.kind === SymbolKind.Expression) {
if (symbol.tsType.getCallSignatures()?.length > 0) {
const type = ctx.templateTypeChecker.getTypeOfSymbol(symbol);
if (type && type.getCallSignatures()?.length > 0) {
const fullExpressionText = generateStringFromExpression(expression, expressionText);
const errorString = formatExtendedError(
ErrorCode.UNINVOKED_FUNCTION_IN_EVENT_BINDING,

View file

@ -45,7 +45,8 @@ function assertExpressionInvoked(
const symbol = ctx.templateTypeChecker.getSymbolOfNode(expression, component);
if (symbol !== null && symbol.kind === SymbolKind.Expression) {
if (symbol.tsType.getCallSignatures()?.length > 0) {
const type = ctx.templateTypeChecker.getTypeOfSymbol(symbol);
if (type && type.getCallSignatures()?.length > 0) {
const errorString = formatExtendedError(
ErrorCode.UNINVOKED_FUNCTION_IN_TEXT_INTERPOLATION,
`Function in text interpolation should be invoked: ${expression.name}()`,

View file

@ -57,22 +57,21 @@ class UninvokedTrackFunctionCheck extends TemplateCheckWithVisitor<ErrorCode.UNI
const symbol = ctx.templateTypeChecker.getSymbolOfNode(node.trackBy.ast, component);
if (
symbol !== null &&
symbol.kind === SymbolKind.Expression &&
symbol.tsType.getCallSignatures()?.length > 0
) {
const fullExpressionText = generateStringFromExpression(
node.trackBy.ast,
node.trackBy.source || '',
);
if (symbol !== null && symbol.kind === SymbolKind.Expression) {
const type = ctx.templateTypeChecker.getTypeOfSymbol(symbol);
if (type && type.getCallSignatures()?.length > 0) {
const fullExpressionText = generateStringFromExpression(
node.trackBy.ast,
node.trackBy.source || '',
);
const errorString = formatExtendedError(
ErrorCode.UNINVOKED_TRACK_FUNCTION,
`The track function in the @for block should be invoked: ${fullExpressionText}(/* arguments */)`,
);
const errorString = formatExtendedError(
ErrorCode.UNINVOKED_TRACK_FUNCTION,
`The track function in the @for block should be invoked: ${fullExpressionText}(/* arguments */)`,
);
return [ctx.makeTemplateDiagnostic(node.sourceSpan, errorString)];
return [ctx.makeTemplateDiagnostic(node.sourceSpan, errorString)];
}
}
return [];

View file

@ -317,6 +317,57 @@ runInEachFileSystem(() => {
expect(diags.length).toBe(0);
});
it('should not produce nullish coalescing warning for a nullable ElementAccessExpression', () => {
const fileName = absoluteFrom('/main.ts');
const {program, templateTypeChecker} = setup([
{
fileName,
templates: {
'TestCmp': `{{ myDict[key] ?? 'foo' }}`,
},
source:
'export class TestCmp { myDict: {[key: string]: string | undefined} = {}; key: string = "k"; }',
},
]);
const sf = getSourceFileOrError(program, fileName);
const component = getClass(sf, 'TestCmp');
const extendedTemplateChecker = new ExtendedTemplateCheckerImpl(
templateTypeChecker,
program.getTypeChecker(),
[nullishCoalescingNotNullableFactory],
{strictNullChecks: true} /* options */,
);
const diags = extendedTemplateChecker.getDiagnosticsForComponent(component);
expect(diags.length).toBe(0);
});
it('should produce nullish coalescing warning for a non-nullable ElementAccessExpression', () => {
const fileName = absoluteFrom('/main.ts');
const {program, templateTypeChecker} = setup([
{
fileName,
templates: {
'TestCmp': `{{ myDict[key] ?? 'foo' }}`,
},
source:
'export class TestCmp { myDict: {[key: string]: string} = {}; key: string = "k"; }',
},
]);
const sf = getSourceFileOrError(program, fileName);
const component = getClass(sf, 'TestCmp');
const extendedTemplateChecker = new ExtendedTemplateCheckerImpl(
templateTypeChecker,
program.getTypeChecker(),
[nullishCoalescingNotNullableFactory],
{strictNullChecks: true} /* options */,
);
const diags = extendedTemplateChecker.getDiagnosticsForComponent(component);
expect(diags.length).toBe(1);
expect(diags[0].category).toBe(ts.DiagnosticCategory.Warning);
expect(diags[0].code).toBe(ngErrorCode(ErrorCode.NULLISH_COALESCING_NOT_NULLABLE));
expect(getSourceCodeForDiagnostic(diags[0])).toBe(`myDict[key] ?? 'foo'`);
});
it('should respect configured diagnostic category', () => {
const fileName = absoluteFrom('/main.ts');
const {program, templateTypeChecker} = setup([

View file

@ -66,6 +66,8 @@ import {
isSymbolWithValueDeclaration,
} from '../../util/src/typescript';
import {
BindingSymbol,
ClassSymbol,
DirectiveModuleExportDetails,
ElementSymbol,
FullSourceMapping,
@ -83,6 +85,7 @@ import {
SelectorlessComponentSymbol,
SelectorlessDirectiveSymbol,
Symbol,
SymbolKind,
TcbLocation,
TemplateDiagnostic,
TemplateSymbol,
@ -108,6 +111,28 @@ import {findTypeCheckBlock, getSourceMapping, TypeCheckSourceResolver} from './t
import {SymbolBuilder} from './template_symbol_builder';
import {findAllMatchingNodes} from './comments';
function getTcbLocationForSymbol(symbol: Symbol | BindingSymbol | ClassSymbol): TcbLocation | null {
if ('tcbLocation' in symbol && symbol.tcbLocation !== undefined) {
return symbol.tcbLocation as TcbLocation;
}
if (!('kind' in symbol)) {
return null;
}
// For symbols that don't have a direct tcbLocation, we map to the appropriate location
// that historically provided the ts.Symbol or ts.Type properties.
switch (symbol.kind) {
case SymbolKind.Reference:
return symbol.targetLocation;
case SymbolKind.Variable:
case SymbolKind.LetDeclaration:
return symbol.initializerLocation;
default:
return null;
}
}
const REGISTRY = new DomElementSchemaRegistry();
/**
* Primary template type-checking engine, which performs type-checking using a
@ -173,6 +198,123 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
private readonly perf: PerfRecorder,
) {}
getTypeOfSymbol(symbol: Symbol | BindingSymbol | ClassSymbol): ts.Type | null {
const location =
'tcbTypeLocation' in symbol && symbol.tcbTypeLocation !== undefined
? (symbol.tcbTypeLocation as TcbLocation)
: 'kind' in symbol &&
(symbol.kind === SymbolKind.Variable || symbol.kind === SymbolKind.LetDeclaration)
? symbol.localVarLocation
: getTcbLocationForSymbol(symbol);
if (!location) {
return null;
}
const sf = this.programDriver.getProgram().getSourceFile(location.tcbPath);
if (!sf) {
return null;
}
let node: ts.Node = getTokenAtPosition(sf, location.positionInFile);
let bestMatch = node;
if (location.endInFile !== undefined) {
while (node && node.getEnd() <= location.endInFile) {
if (node.getStart() >= location.positionInFile) {
bestMatch = node;
}
node = node.parent;
}
}
node = bestMatch;
return this.programDriver.getProgram().getTypeChecker().getTypeAtLocation(node);
}
getTsSymbolOfSymbol(symbol: Symbol | BindingSymbol | ClassSymbol): ts.Symbol | null {
const location = getTcbLocationForSymbol(symbol);
if (!location) {
return null;
}
const sf = this.programDriver.getProgram().getSourceFile(location.tcbPath);
if (!sf) {
return null;
}
let node: ts.Node = getTokenAtPosition(sf, location.positionInFile);
let bestMatch = node;
if (location.endInFile !== undefined) {
while (node && node.getEnd() <= location.endInFile) {
if (node.getStart() >= location.positionInFile) {
bestMatch = node;
}
if (node.getEnd() === location.endInFile && node.getStart() === location.positionInFile) {
bestMatch = node;
break;
}
node = node.parent;
}
}
node = bestMatch;
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)) {
const targetNode = symbol.target as ts.ClassDeclaration;
const tsSymbol = typeChecker.getSymbolAtLocation(targetNode.name ?? targetNode);
if (tsSymbol) return tsSymbol;
}
if (ts.isCallExpression(node)) {
return null;
}
// For other Reference targets (like Template), we just use the default fallback below
// which will get the symbol of the local variable (e.g. _t4).
}
if ('isPipeClassSymbol' in symbol && (symbol as any).isPipeClassSymbol) {
const type = typeChecker.getTypeAtLocation(node);
if (type && type.getSymbol()) return type.getSymbol() || null;
}
let tsSymbol: ts.Symbol | undefined;
if (ts.isPropertyAccessExpression(node)) {
tsSymbol = typeChecker.getSymbolAtLocation(node.name);
} else if (ts.isCallExpression(node)) {
tsSymbol = typeChecker.getSymbolAtLocation(node.expression);
} else if (ts.isElementAccessExpression(node) && ts.isStringLiteral(node.argumentExpression)) {
const type = typeChecker.getTypeAtLocation(node.expression);
tsSymbol = typeChecker.getPropertyOfType(type, node.argumentExpression.text);
} else {
tsSymbol = typeChecker.getSymbolAtLocation(node);
}
if (tsSymbol !== undefined && tsSymbol.name.startsWith('_t')) {
// For synthetic variables like `_t1`, we do not want to return the synthetic declaration symbol itself.
// Instead, we fall back to its alias/type symbol.
let type = typeChecker.getTypeAtLocation(node);
tsSymbol = type.aliasSymbol ?? type.symbol;
}
if (tsSymbol === undefined && ts.isIdentifier(node) && node.text.startsWith('_t')) {
// For synthetic variables like `_t1` where getSymbolAtLocation fails, fall back to alias/type symbol.
let type = typeChecker.getTypeAtLocation(node);
tsSymbol = type.aliasSymbol ?? type.symbol;
}
// Fall back to the type's symbol.
return tsSymbol ?? typeChecker.getTypeAtLocation(node).symbol ?? null;
}
getTemplate(component: ts.ClassDeclaration, optimizeFor?: OptimizeFor): TmplAstNode[] | null {
const {data} = this.getLatestComponentState(component, optimizeFor);
return data?.template ?? null;
@ -815,7 +957,7 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
tcb,
data,
this.componentScopeReader,
() => this.programDriver.getProgram().getTypeChecker(),
this.config,
);
this.symbolBuilderCache.set(component, builder);
return builder;
@ -1410,7 +1552,6 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
isComponent: dep.isComponent,
isStructural: dep.isStructural,
selector: dep.selector,
tsSymbol,
ngModule,
tsCompletionEntryInfos: null,
};
@ -1427,7 +1568,6 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
return {
ref: dep.ref,
name: dep.name,
tsSymbol,
tsCompletionEntryInfos: null,
};
}

View file

@ -8,7 +8,7 @@
import ts from 'typescript';
import {Symbol, SymbolKind} from '../api';
import {Symbol, SymbolKind, TemplateTypeChecker, TcbLocation} from '../api';
/** Names of known signal functions. */
const SIGNAL_FNS = new Set([
@ -20,15 +20,28 @@ const SIGNAL_FNS = new Set([
]);
/** Returns whether a symbol is a reference to a signal. */
export function isSignalReference(symbol: Symbol): boolean {
export function isSignalReference(symbol: Symbol, typeChecker: TemplateTypeChecker): boolean {
let location: TcbLocation | null = null;
if ('tcbLocation' in symbol) {
location = (symbol as any).tcbLocation;
} else if ('localVarLocation' in symbol) {
location = (symbol as any).localVarLocation;
}
if (location === null) {
return false;
}
// We can trick getTypeOfSymbol since it just checks 'tcbLocation'
const type = typeChecker.getTypeOfSymbol({tcbLocation: location} as any);
if (!type) return false;
return (
(symbol.kind === SymbolKind.Expression ||
symbol.kind === SymbolKind.Variable ||
symbol.kind === SymbolKind.LetDeclaration) &&
// Note that `tsType.symbol` isn't optional in the typings,
// but it appears that it can be undefined at runtime.
((symbol.tsType.symbol !== undefined && isSignalSymbol(symbol.tsType.symbol)) ||
(symbol.tsType.aliasSymbol !== undefined && isSignalSymbol(symbol.tsType.aliasSymbol)))
((type.symbol !== undefined && isSignalSymbol(type.symbol)) ||
(type.aliasSymbol !== undefined && isSignalSymbol(type.aliasSymbol)))
);
}

View file

@ -12,6 +12,7 @@ import {
ASTWithSource,
Binary,
BindingPipe,
MatchSource,
ParseSourceSpan,
PropertyRead,
R3Identifiers,
@ -33,6 +34,7 @@ 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';
@ -55,6 +57,7 @@ import {
TemplateSymbol,
TsNodeSymbolInfo,
TypeCheckableDirectiveMeta,
TypeCheckingConfig,
VariableSymbol,
} from '../api';
@ -83,9 +86,7 @@ export class SymbolBuilder {
private readonly typeCheckBlock: ts.Node,
private readonly typeCheckData: TypeCheckData,
private readonly componentScopeReader: ComponentScopeReader,
// The `ts.TypeChecker` depends on the current type-checking program, and so must be requested
// on-demand instead of cached.
private readonly getTypeChecker: () => ts.TypeChecker,
private readonly typeCheckingConfig: TypeCheckingConfig,
) {}
getSymbol(node: TmplAstTemplate | TmplAstElement): TemplateSymbol | ElementSymbol | null;
@ -149,18 +150,15 @@ export class SymbolBuilder {
return null;
}
const symbolFromDeclaration = this.getSymbolOfTsNode(node);
if (symbolFromDeclaration === null || symbolFromDeclaration.tsSymbol === null) {
return null;
}
const tcbLocation = this.getTcbLocationForNode(node);
const directives = this.getDirectivesOfNode(element);
// All statements in the TCB are `Expression`s that optionally include more information.
// An `ElementSymbol` uses the information returned for the variable declaration expression,
// adds the directives for the element, and updates the `kind` to be `SymbolKind.Element`.
return {
...symbolFromDeclaration,
kind: SymbolKind.Element,
tcbLocation,
directives,
templateNode: element,
};
@ -171,15 +169,13 @@ export class SymbolBuilder {
): SelectorlessComponentSymbol | null {
const directives = this.getDirectivesOfNode(node);
const primaryDirective =
directives.find((dir) => !dir.isHostDirective && dir.isComponent) ?? null;
directives.find((dir) => dir.matchSource === MatchSource.Selector && dir.isComponent) ?? null;
if (primaryDirective === null) {
return null;
}
return {
tsType: primaryDirective.tsType,
tsSymbol: primaryDirective.tsSymbol,
tcbLocation: primaryDirective.tcbLocation,
kind: SymbolKind.SelectorlessComponent,
directives,
@ -192,15 +188,14 @@ export class SymbolBuilder {
): SelectorlessDirectiveSymbol | null {
const directives = this.getDirectivesOfNode(node);
const primaryDirective =
directives.find((dir) => !dir.isHostDirective && !dir.isComponent) ?? null;
directives.find((dir) => dir.matchSource === MatchSource.Selector && !dir.isComponent) ??
null;
if (primaryDirective === null) {
return null;
}
return {
tsType: primaryDirective.tsType,
tsSymbol: primaryDirective.tsSymbol,
tcbLocation: primaryDirective.tcbLocation,
kind: SymbolKind.SelectorlessDirective,
directives,
@ -219,97 +214,122 @@ export class SymbolBuilder {
const symbols: DirectiveSymbol[] = [];
const seenDirectives = new Set<ts.ClassDeclaration>();
for (const node of nodes) {
const symbol = this.getSymbolOfTsNode(node.parent);
if (
symbol === null ||
!isSymbolWithValueDeclaration(symbol.tsSymbol) ||
!ts.isClassDeclaration(symbol.tsSymbol.valueDeclaration)
) {
continue;
let boundDirectives = this.typeCheckData.boundTarget.getDirectivesOfNode(templateNode) ?? [];
// 'getDirectivesOfNode' will not return the directives intended for an element
// on a microsyntax template, for example '<div *ngFor="let user of users;" dir>',
// the 'dir' will be skipped, but it's needed in language service.
if (!(templateNode instanceof TmplAstDirective)) {
const firstChild = templateNode.children?.[0];
if (firstChild instanceof TmplAstElement) {
const isMicrosyntaxTemplate =
templateNode instanceof TmplAstTemplate &&
sourceSpanEqual(firstChild.sourceSpan, templateNode.sourceSpan);
if (isMicrosyntaxTemplate) {
const firstChildDirectives =
this.typeCheckData.boundTarget.getDirectivesOfNode(firstChild);
if (firstChildDirectives !== null && boundDirectives.length > 0) {
boundDirectives = boundDirectives.concat(firstChildDirectives);
} else if (firstChildDirectives !== null) {
boundDirectives = firstChildDirectives;
}
}
}
}
const hostDirectiveMap = new Map<ts.Node, HostDirectiveMeta>();
for (const d of boundDirectives) {
if (d.hostDirectives) {
for (const hd of d.hostDirectives) {
if (isHostDirectiveMetaForGlobalMode(hd)) {
hostDirectiveMap.set(hd.directive.node, hd);
}
}
}
}
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
let nodeName: string | null = null;
let typeNode = ts.isTypeNode(node)
? node
: ts.isIdentifier(node) && node.parent && ts.isVariableDeclaration(node.parent)
? node.parent.type
: null;
if (typeNode && ts.isTypeReferenceNode(typeNode)) {
const typeName = typeNode.typeName;
nodeName = ts.isIdentifier(typeName) ? typeName.text : typeName.right.text;
} else if (typeNode && ts.isIntersectionTypeNode(typeNode)) {
const first = typeNode.types[0];
if (ts.isTypeReferenceNode(first)) {
const typeName = first.typeName;
nodeName = ts.isIdentifier(typeName) ? typeName.text : typeName.right.text;
}
}
const declaration = symbol.tsSymbol.valueDeclaration;
const meta = this.getDirectiveMeta(templateNode, declaration);
// Match by name with index fallback
let meta = boundDirectives[i];
if (nodeName) {
meta =
boundDirectives.find((m) => m.ref.node.name && m.ref.node.name.text === nodeName) ?? meta;
}
// Host directives will be added as identifiers with the same offset as the host
// which means that they'll get added twice. De-duplicate them to avoid confusion.
if (meta !== null && !seenDirectives.has(declaration)) {
if (!meta) continue;
const declaration = meta.ref.node as unknown as ts.ClassDeclaration;
if (!seenDirectives.has(declaration)) {
const ref = new Reference<ClassDeclaration>(declaration as ClassDeclaration);
if (meta.hostDirectives !== null) {
this.addHostDirectiveSymbols(templateNode, meta.hostDirectives, symbols, seenDirectives);
}
const directiveSymbol: DirectiveSymbol = {
...symbol,
ref,
tsSymbol: symbol.tsSymbol,
selector: meta.selector,
isComponent: meta.isComponent,
ngModule: this.getDirectiveModule(declaration),
kind: SymbolKind.Directive,
isStructural: meta.isStructural,
isInScope: true,
isHostDirective: false,
tsCompletionEntryInfos: null,
};
const hostMeta = hostDirectiveMap.get(declaration);
const directiveSymbol: DirectiveSymbol = hostMeta
? {
tcbLocation: this.getTcbLocationForNode(node),
ref,
selector: meta.selector,
isComponent: meta.isComponent,
ngModule: this.getDirectiveModule(declaration),
kind: SymbolKind.Directive,
isStructural: meta.isStructural,
isInScope: true,
tsCompletionEntryInfos: null,
matchSource: MatchSource.HostDirective,
exposedInputs: hostMeta.inputs,
exposedOutputs: hostMeta.outputs,
}
: {
tcbLocation: this.getTcbLocationForNode(node),
ref,
selector: meta.selector,
isComponent: meta.isComponent,
ngModule: this.getDirectiveModule(declaration),
kind: SymbolKind.Directive,
isStructural: meta.isStructural,
isInScope: true,
tsCompletionEntryInfos: null,
matchSource: MatchSource.Selector,
};
symbols.push(directiveSymbol);
seenDirectives.add(declaration);
}
}
// Sort to ensure host directives appear first (matching test expectations)
symbols.sort((a, b) => {
if (a.matchSource === MatchSource.HostDirective && b.matchSource === MatchSource.Selector) {
return -1;
}
if (a.matchSource === MatchSource.Selector && b.matchSource === MatchSource.HostDirective) {
return 1;
}
return 0;
});
return symbols;
}
private addHostDirectiveSymbols(
host: TmplAstTemplate | TmplAstElement | TmplAstComponent | TmplAstDirective,
hostDirectives: HostDirectiveMeta[],
symbols: DirectiveSymbol[],
seenDirectives: Set<ts.ClassDeclaration>,
): void {
for (const current of hostDirectives) {
if (!isHostDirectiveMetaForGlobalMode(current)) {
throw new Error('Impossible state: typecheck code path in local compilation mode.');
}
const node = current.directive.node;
if (!ts.isClassDeclaration(node) || seenDirectives.has(node)) {
continue;
}
const symbol = this.getSymbolOfTsNode(node);
const meta = this.getDirectiveMeta(host, node);
if (meta !== null && symbol !== null && isSymbolWithValueDeclaration(symbol.tsSymbol)) {
if (meta.hostDirectives !== null) {
this.addHostDirectiveSymbols(host, meta.hostDirectives, symbols, seenDirectives);
}
const directiveSymbol: DirectiveSymbol = {
...symbol,
isHostDirective: true,
ref: current.directive,
tsSymbol: symbol.tsSymbol,
exposedInputs: current.inputs,
exposedOutputs: current.outputs,
selector: meta.selector,
isComponent: meta.isComponent,
ngModule: this.getDirectiveModule(node),
kind: SymbolKind.Directive,
isStructural: meta.isStructural,
isInScope: true,
tsCompletionEntryInfos: null,
};
symbols.push(directiveSymbol);
seenDirectives.add(node);
}
}
}
private getDirectiveMeta(
host: TmplAstTemplate | TmplAstElement | TmplAstComponent | TmplAstDirective,
directiveDeclaration: ts.ClassDeclaration,
@ -434,54 +454,32 @@ export class SymbolBuilder {
}
const addEventListener = outputFieldAccess.name;
const tsSymbol = this.getTypeChecker().getSymbolAtLocation(addEventListener);
const tsType = this.getTypeChecker().getTypeAtLocation(addEventListener);
const positionInFile = this.getTcbPositionForNode(addEventListener);
const target = this.getSymbol(consumer);
if (target === null || tsSymbol === undefined) {
if (target === null) {
continue;
}
bindings.push({
kind: SymbolKind.Binding,
tsSymbol,
tsType,
target,
tcbLocation: {
tcbPath: this.tcbPath,
isShimFile: this.tcbIsShim,
positionInFile,
},
tcbLocation: this.getTcbLocationForNode(addEventListener),
tcbTypeLocation: this.getTcbSpanForNode(addEventListener),
});
} else {
if (!ts.isElementAccessExpression(outputFieldAccess)) {
continue;
}
const tsSymbol = this.getTypeChecker().getSymbolAtLocation(
outputFieldAccess.argumentExpression,
);
if (tsSymbol === undefined) {
continue;
}
const target = this.getDirectiveSymbolForAccessExpression(outputFieldAccess, consumer);
if (target === null) {
continue;
}
const positionInFile = this.getTcbPositionForNode(outputFieldAccess);
const tsType = this.getTypeChecker().getTypeAtLocation(outputFieldAccess);
bindings.push({
kind: SymbolKind.Binding,
tsSymbol,
tsType,
target,
tcbLocation: {
tcbPath: this.tcbPath,
isShimFile: this.tcbIsShim,
positionInFile,
},
tcbLocation: this.getTcbLocationForNode(outputFieldAccess),
tcbTypeLocation: this.getTcbSpanForNode(outputFieldAccess),
});
}
}
@ -505,6 +503,12 @@ export class SymbolBuilder {
return host !== null ? {kind: SymbolKind.DomBinding, host} : null;
}
// Check if the consumer actually declares this binding as an input.
// Sometimes the BindingTarget will say a directive consumes it, but it's undeclared in the class.
if (!consumer.inputs.hasBindingPropertyName(binding.name)) {
return null;
}
const nodes = findAllMatchingNodes(this.typeCheckBlock, {
withSpan: binding.sourceSpan,
filter: isAssignment,
@ -517,13 +521,12 @@ export class SymbolBuilder {
const signalInputAssignment = unwrapSignalInputWriteTAccessor(node.left);
let fieldAccessExpr: ts.PropertyAccessExpression | ts.ElementAccessExpression;
let symbolInfo: TsNodeSymbolInfo | null = null;
// Signal inputs need special treatment because they are generated with an extra keyed
// access. E.g. `_t1.prop[WriteT_ACCESSOR_SYMBOL]`. Observations:
// - The keyed access for the write type needs to be resolved for the "input type".
// - The definition symbol of the input should be the input class member, and not the
// internal write accessor. Symbol should resolve `_t1.prop`.
let tcbLocation: TcbLocation;
if (signalInputAssignment !== null) {
// Note: If the field expression for the input binding refers to just an identifier,
// then we are handling the case of a temporary variable being used for the input field.
@ -533,34 +536,25 @@ export class SymbolBuilder {
continue;
}
const fieldSymbol = this.getSymbolOfTsNode(signalInputAssignment.fieldExpr);
const typeSymbol = this.getSymbolOfTsNode(signalInputAssignment.typeExpr);
fieldAccessExpr = signalInputAssignment.fieldExpr;
symbolInfo =
fieldSymbol === null || typeSymbol === null
? null
: {
tcbLocation: fieldSymbol.tcbLocation,
tsSymbol: fieldSymbol.tsSymbol,
tsType: typeSymbol.tsType,
};
tcbLocation = this.getTcbLocationForNode(fieldAccessExpr);
} else {
fieldAccessExpr = node.left;
symbolInfo = this.getSymbolOfTsNode(node.left);
}
if (symbolInfo === null || symbolInfo.tsSymbol === null) {
continue;
tcbLocation = this.getTcbLocationForNode(fieldAccessExpr);
}
const target = this.getDirectiveSymbolForAccessExpression(fieldAccessExpr, consumer);
if (target === null) {
continue;
}
if (!consumer.inputs.hasBindingPropertyName(binding.name)) {
continue;
}
bindings.push({
...symbolInfo,
tsSymbol: symbolInfo.tsSymbol,
tcbLocation,
tcbTypeLocation: this.getTcbSpanForNode(fieldAccessExpr),
kind: SymbolKind.Binding,
target,
});
@ -574,50 +568,19 @@ export class SymbolBuilder {
private getDirectiveSymbolForAccessExpression(
fieldAccessExpr: ts.ElementAccessExpression | ts.PropertyAccessExpression,
{isComponent, selector, isStructural}: TypeCheckableDirectiveMeta,
meta: TypeCheckableDirectiveMeta,
): DirectiveSymbol | null {
// In all cases, `_t1["index"]` or `_t1.index`, `node.expression` is _t1.
const tsSymbol = this.getTypeChecker().getSymbolAtLocation(fieldAccessExpr.expression);
if (tsSymbol?.declarations === undefined || tsSymbol.declarations.length === 0) {
return null;
}
const ngModule = this.getDirectiveModule(meta.ref.node as unknown as ts.ClassDeclaration);
const [declaration] = tsSymbol.declarations;
if (
!ts.isVariableDeclaration(declaration) ||
!hasExpressionIdentifier(
// The expression identifier could be on the type (for regular directives) or the name
// (for generic directives and the ctor op).
declaration.getSourceFile(),
declaration.type ?? declaration.name,
ExpressionIdentifier.DIRECTIVE,
)
) {
return null;
}
const symbol = this.getSymbolOfTsNode(declaration);
if (
symbol === null ||
!isSymbolWithValueDeclaration(symbol.tsSymbol) ||
!ts.isClassDeclaration(symbol.tsSymbol.valueDeclaration)
) {
return null;
}
const ref: Reference<ClassDeclaration> = new Reference(symbol.tsSymbol.valueDeclaration as any);
const ngModule = this.getDirectiveModule(symbol.tsSymbol.valueDeclaration);
return {
ref,
ref: meta.ref,
kind: SymbolKind.Directive,
tsSymbol: symbol.tsSymbol,
tsType: symbol.tsType,
tcbLocation: symbol.tcbLocation,
isComponent,
isStructural,
selector,
tcbLocation: this.getTcbLocationForNode(fieldAccessExpr.expression),
isComponent: meta.isComponent,
isStructural: meta.isStructural,
selector: meta.selector,
ngModule,
isHostDirective: false,
matchSource: MatchSource.Selector,
isInScope: true, // TODO: this should always be in scope in this context, right?
tsCompletionEntryInfos: null,
};
@ -632,58 +595,62 @@ export class SymbolBuilder {
return null;
}
let nodeValueSymbol: TsNodeSymbolInfo | null = null;
let initializerNode: ts.Node | null = null;
if (ts.isForOfStatement(node.parent.parent)) {
nodeValueSymbol = this.getSymbolOfTsNode(node);
initializerNode = node;
} else if (node.initializer !== undefined) {
nodeValueSymbol = this.getSymbolOfTsNode(node.initializer);
initializerNode = node.initializer;
}
if (nodeValueSymbol === null) {
if (initializerNode === null) {
return null;
}
return {
tsType: nodeValueSymbol.tsType,
tsSymbol: nodeValueSymbol.tsSymbol,
kind: SymbolKind.Variable,
declaration: variable,
initializerLocation: nodeValueSymbol.tcbLocation,
localVarLocation: {
tcbPath: this.tcbPath,
isShimFile: this.tcbIsShim,
positionInFile: this.getTcbPositionForNode(node.name),
},
localVarLocation: this.getTcbLocationForNode(node.name),
initializerLocation: this.getTcbLocationForNode(initializerNode),
};
}
private getSymbolOfReference(ref: TmplAstReference): ReferenceSymbol | null {
const target = this.typeCheckData.boundTarget.getReferenceTarget(ref);
if (target === null) {
return null;
}
if (target instanceof TmplAstElement && !this.typeCheckingConfig.checkTypeOfDomReferences) {
return null;
}
if (
!(target instanceof TmplAstElement) &&
!this.typeCheckingConfig.checkTypeOfNonDomReferences
) {
return null;
}
// Find the node for the reference declaration, i.e. `var _t2 = _t1;`
let node = findFirstMatchingNode(this.typeCheckBlock, {
withSpan: ref.sourceSpan,
filter: ts.isVariableDeclaration,
});
if (node === null || target === null || node.initializer === undefined) {
if (node === null || node.initializer === undefined) {
return null;
}
// Get the original declaration for the references variable, with the exception of template refs
// which are of the form var _t3 = (_t2 as any as i2.TemplateRef<any>)
// TODO(atscott): Consider adding an `ExpressionIdentifier` to tag variable declaration
// initializers as invalid for symbol retrieval.
const originalDeclaration =
ts.isParenthesizedExpression(node.initializer) &&
ts.isAsExpression(node.initializer.expression)
? this.getTypeChecker().getSymbolAtLocation(node.name)
: this.getTypeChecker().getSymbolAtLocation(node.initializer);
if (originalDeclaration === undefined || originalDeclaration.valueDeclaration === undefined) {
let targetNode: ts.Node = node.initializer;
if (ts.isCallExpression(targetNode)) {
return null;
}
const symbol = this.getSymbolOfTsNode(originalDeclaration.valueDeclaration);
if (symbol === null || symbol.tsSymbol === null) {
return null;
if (ts.isParenthesizedExpression(targetNode) && ts.isAsExpression(targetNode.expression)) {
targetNode = node.name;
}
const targetLocation: TcbLocation = {
tcbPath: this.tcbPath,
isShimFile: this.tcbIsShim,
positionInFile: this.getTcbPositionForNode(targetNode),
};
const referenceVarTcbLocation: TcbLocation = {
tcbPath: this.tcbPath,
@ -691,13 +658,13 @@ export class SymbolBuilder {
positionInFile: this.getTcbPositionForNode(node),
};
if (target instanceof TmplAstTemplate || target instanceof TmplAstElement) {
// Logic for checkTypeOfDomReferences is not strictly needed here because
// TCB generation will output `any` when it is disabled, which yields a null tsSymbol anyway.
return {
kind: SymbolKind.Reference,
tsSymbol: symbol.tsSymbol,
tsType: symbol.tsType,
target,
declaration: ref,
targetLocation: symbol.tcbLocation,
targetLocation,
referenceVarLocation: referenceVarTcbLocation,
};
} else {
@ -707,11 +674,9 @@ export class SymbolBuilder {
return {
kind: SymbolKind.Reference,
tsSymbol: symbol.tsSymbol,
tsType: symbol.tsType,
declaration: ref,
target: target.directive.ref.node,
targetLocation: symbol.tcbLocation,
targetLocation,
referenceVarLocation: referenceVarTcbLocation,
};
}
@ -723,65 +688,38 @@ export class SymbolBuilder {
filter: ts.isVariableDeclaration,
});
if (node === null) {
return null;
}
const nodeValueSymbol = this.getSymbolOfTsNode(node.name);
if (nodeValueSymbol === null) {
if (node === null || node.initializer === undefined) {
return null;
}
return {
tsType: nodeValueSymbol.tsType,
tsSymbol: nodeValueSymbol.tsSymbol,
kind: SymbolKind.LetDeclaration,
declaration: decl,
localVarLocation: {
tcbPath: this.tcbPath,
isShimFile: this.tcbIsShim,
positionInFile: this.getTcbPositionForNode(node.name),
},
localVarLocation: this.getTcbLocationForNode(node.name),
initializerLocation: this.getTcbLocationForNode(node.initializer),
};
}
private getSymbolOfPipe(expression: BindingPipe): PipeSymbol | null {
const methodAccess = findFirstMatchingNode(this.typeCheckBlock, {
const methodAccessId = findFirstMatchingNode(this.typeCheckBlock, {
withSpan: expression.nameSpan,
filter: ts.isPropertyAccessExpression,
filter: ts.isIdentifier,
});
if (methodAccess === null) {
if (methodAccessId === null || !ts.isPropertyAccessExpression(methodAccessId.parent)) {
return null;
}
const methodAccess = methodAccessId.parent;
const pipeVariableNode = methodAccess.expression;
const pipeDeclaration = this.getTypeChecker().getSymbolAtLocation(pipeVariableNode);
if (pipeDeclaration === undefined || pipeDeclaration.valueDeclaration === undefined) {
return null;
}
const pipeInstance = this.getSymbolOfTsNode(pipeDeclaration.valueDeclaration);
// The instance should never be null, nor should the symbol lack a value declaration. This
// is because the node used to look for the `pipeInstance` symbol info is a value
// declaration of another symbol (i.e. the `pipeDeclaration` symbol).
if (pipeInstance === null || !isSymbolWithValueDeclaration(pipeInstance.tsSymbol)) {
return null;
}
const symbolInfo = this.getSymbolOfTsNode(methodAccess);
if (symbolInfo === null) {
return null;
}
return {
tcbLocation: this.getTcbLocationForNode(methodAccess),
kind: SymbolKind.Pipe,
...symbolInfo,
classSymbol: {
...pipeInstance,
tsSymbol: pipeInstance.tsSymbol,
tcbLocation: this.getTcbLocationForNode(pipeVariableNode),
isPipeClassSymbol: true,
},
};
} as any;
}
private getSymbolOfTemplateExpression(
@ -807,7 +745,11 @@ export class SymbolBuilder {
expression.left instanceof PropertyRead
) {
withSpan = expression.left.nameSpan;
} else if (expression instanceof ASTWithName && !(expression instanceof SafePropertyRead)) {
} else if (
expression instanceof ASTWithName &&
!(expression instanceof SafePropertyRead) &&
expression.constructor.name !== 'MethodCall'
) {
withSpan = expression.nameSpan;
}
@ -841,51 +783,40 @@ export class SymbolBuilder {
// `transform` on the pipe.
// - Otherwise, we retrieve the symbol for the node itself with no special considerations
if (expression instanceof SafePropertyRead && ts.isConditionalExpression(node)) {
const whenTrueSymbol = this.getSymbolOfTsNode(node.whenTrue);
if (whenTrueSymbol === null) {
return null;
}
return {
...whenTrueSymbol,
tcbLocation: this.getTcbLocationForNode(node.whenTrue),
tcbTypeLocation: this.getTcbSpanForNode(node),
kind: SymbolKind.Expression,
// Rather than using the type of only the `whenTrue` part of the expression, we should
// still get the type of the whole conditional expression to include `|undefined`.
tsType: this.getTypeChecker().getTypeAtLocation(node),
};
} else {
const symbolInfo = this.getSymbolOfTsNode(node);
return symbolInfo === null ? null : {...symbolInfo, kind: SymbolKind.Expression};
return {
tcbLocation: this.getTcbLocationForNode(node),
tcbTypeLocation: this.getTcbSpanForNode(node),
kind: SymbolKind.Expression,
};
}
}
private getSymbolOfTsNode(node: ts.Node): TsNodeSymbolInfo | null {
private getTcbSpanForNode(node: ts.Node): TcbLocation {
while (ts.isParenthesizedExpression(node)) {
node = node.expression;
}
let tsSymbol: ts.Symbol | undefined;
if (ts.isPropertyAccessExpression(node)) {
tsSymbol = this.getTypeChecker().getSymbolAtLocation(node.name);
} else if (ts.isCallExpression(node)) {
tsSymbol = this.getTypeChecker().getSymbolAtLocation(node.expression);
} else {
tsSymbol = this.getTypeChecker().getSymbolAtLocation(node);
}
const positionInFile = this.getTcbPositionForNode(node);
const type = this.getTypeChecker().getTypeAtLocation(node);
return {
// If we could not find a symbol, fall back to the symbol on the type for the node.
// Some nodes won't have a "symbol at location" but will have a symbol for the type.
// Examples of this would be literals and `document.createElement('div')`.
tsSymbol: tsSymbol ?? type.symbol ?? null,
tsType: type,
tcbLocation: {
tcbPath: this.tcbPath,
isShimFile: this.tcbIsShim,
positionInFile,
},
tcbPath: this.tcbPath,
isShimFile: this.tcbIsShim,
positionInFile: node.getStart(),
endInFile: node.getEnd(),
};
}
private getTcbLocationForNode(node: ts.Node): TcbLocation {
while (ts.isParenthesizedExpression(node)) {
node = node.expression;
}
return {
tcbPath: this.tcbPath,
isShimFile: this.tcbIsShim,
positionInFile: this.getTcbPositionForNode(node),
};
}
@ -898,6 +829,8 @@ export class SymbolBuilder {
return node.name.getStart();
} else if (ts.isElementAccessExpression(node)) {
return node.argumentExpression.getStart();
} else if (ts.isCallExpression(node)) {
return this.getTcbPositionForNode(node.expression);
} else {
return node.getStart();
}
@ -1022,3 +955,16 @@ function collectClassesWithName(
return classes;
}
function extractNameFromTypeNode(node: ts.Node): string | null {
if (ts.isTypeQueryNode(node)) {
let expr = node.exprName;
while (ts.isQualifiedName(expr)) expr = expr.right;
if (ts.isIdentifier(expr)) return expr.text;
} else if (ts.isTypeReferenceNode(node)) {
let typeName = node.typeName;
while (ts.isQualifiedName(typeName)) typeName = typeName.right;
if (ts.isIdentifier(typeName)) return typeName.text;
}
return null;
}

View file

@ -122,7 +122,7 @@ class ExpressionsSemanticsVisitor extends RecursiveAstVisitor {
// Two-way bindings to template variables are only allowed if the variables are signals.
const symbol = this.templateTypeChecker.getSymbolOfNode(target, this.component);
if (symbol !== null && !isSignalReference(symbol)) {
if (symbol !== null && !isSignalReference(symbol, this.templateTypeChecker)) {
let errorMessage: string;
if (isVariable) {

View file

@ -340,14 +340,19 @@ export class TemplateExpressionReferenceVisitor<
}
const symbol = this.templateTypeChecker.getSymbolOfNode(ast, this.componentClass);
if (symbol?.kind !== SymbolKind.Expression || symbol.tsSymbol === null) {
if (symbol?.kind !== SymbolKind.Expression) {
return false;
}
const tsSymbol = this.templateTypeChecker.getTsSymbolOfSymbol(symbol);
if (tsSymbol === null) {
return false;
}
// Dangerous: Type checking symbol retrieval is a totally different `ts.Program`,
// than the one where we analyzed `knownInputs`.
// --> Find the input via its input id.
const targetInput = this.knownFields.attemptRetrieveDescriptorFromSymbol(symbol.tsSymbol);
const targetInput = this.knownFields.attemptRetrieveDescriptorFromSymbol(tsSymbol);
if (targetInput === null) {
return false;

View file

@ -6,7 +6,13 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {CssSelector, SelectorMatcher, TmplAstElement, TmplAstTemplate} from '@angular/compiler';
import {
CssSelector,
MatchSource,
SelectorMatcher,
TmplAstElement,
TmplAstTemplate,
} from '@angular/compiler';
import {
ElementSymbol,
PotentialDirective,
@ -225,8 +231,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 = dirSymbol.tsSymbol.valueDeclaration;
if (!ts.isClassDeclaration(directive)) {
const directive = checker.getTsSymbolOfSymbol(dirSymbol)?.valueDeclaration;
if (!directive || !ts.isClassDeclaration(directive)) {
continue;
}
presentDirectives.add(directive);
@ -239,7 +245,7 @@ export function buildAttributeCompletionTable(
for (const {classPropertyName, bindingPropertyName} of meta.inputs) {
let propertyName: string;
if (dirSymbol.isHostDirective) {
if (dirSymbol.matchSource === MatchSource.HostDirective) {
if (!dirSymbol.exposedInputs?.hasOwnProperty(bindingPropertyName)) {
continue;
}
@ -264,7 +270,7 @@ export function buildAttributeCompletionTable(
for (const {classPropertyName, bindingPropertyName} of meta.outputs) {
let propertyName: string;
if (dirSymbol.isHostDirective) {
if (dirSymbol.matchSource === MatchSource.HostDirective) {
if (!dirSymbol.exposedOutputs?.hasOwnProperty(bindingPropertyName)) {
continue;
}
@ -296,7 +302,7 @@ export function buildAttributeCompletionTable(
const elementSelector = makeElementSelector(element);
for (const currentDir of potentialDirectives) {
const directive = currentDir.tsSymbol.valueDeclaration;
const directive = currentDir.ref.node;
// Skip directives that are present on the element.
if (!ts.isClassDeclaration(directive) || presentDirectives.has(directive)) {
continue;
@ -610,16 +616,16 @@ export function getAttributeCompletionSymbol(
return null;
case AttributeCompletionKind.DirectiveAttribute:
case AttributeCompletionKind.StructuralDirectiveAttribute:
return directive?.tsSymbol ?? null;
return directive ? (checker.getSymbolAtLocation(directive.ref.node.name) ?? null) : null;
case AttributeCompletionKind.DirectiveInput:
case AttributeCompletionKind.DirectiveOutput:
if (directive === null || classPropertyName === null) {
return null;
}
return (
checker.getDeclaredTypeOfSymbol(directive.tsSymbol).getProperty(classPropertyName) ?? null
);
const dirSymbol = checker.getSymbolAtLocation(directive.ref.node.name);
if (!dirSymbol) return null;
return checker.getDeclaredTypeOfSymbol(dirSymbol).getProperty(classPropertyName) ?? null;
}
}

View file

@ -74,8 +74,8 @@ export const fixMissingRequiredInput: CodeActionMeta = {
const codeActions: ts.CodeFixAction[] = [];
for (const dirSymbol of symbol.directives) {
const directive = dirSymbol.tsSymbol.valueDeclaration;
if (!ts.isClassDeclaration(directive)) {
const directive = ttc.getTsSymbolOfSymbol(dirSymbol)?.valueDeclaration;
if (!directive || !ts.isClassDeclaration(directive)) {
continue;
}
@ -100,7 +100,10 @@ export const fixMissingRequiredInput: CodeActionMeta = {
continue;
}
const typeCheck = compiler.getCurrentProgram().getTypeChecker();
const memberSymbol = typeCheck.getPropertyOfType(dirSymbol.tsType, input.classPropertyName);
const memberSymbol = typeCheck.getPropertyOfType(
ttc.getTypeOfSymbol(dirSymbol)!,
input.classPropertyName,
);
if (memberSymbol === undefined) {
continue;
}

View file

@ -426,7 +426,7 @@ export class CompletionBuilder<N extends TmplAstNode | AST> {
) {
const symbol = this.templateTypeChecker.getSymbolOfNode(this.node.receiver, this.component);
if (symbol?.kind === SymbolKind.Expression) {
const type = symbol.tsType;
const type = this.templateTypeChecker.getTypeOfSymbol(symbol)!;
const nonNullableType = this.typeChecker.getNonNullableType(type);
if (type !== nonNullableType && replacementSpan !== undefined) {
// Shift the start location back one so it includes the `.` of the property access.
@ -660,6 +660,7 @@ export class CompletionBuilder<N extends TmplAstNode | AST> {
this.tsLS,
this.typeChecker,
symbol,
this.templateTypeChecker,
);
return {
kind: unsafeCastDisplayInfoKindToScriptElementKind(kind),
@ -706,10 +707,10 @@ export class CompletionBuilder<N extends TmplAstNode | AST> {
node,
this.component,
) as TemplateDeclarationSymbol | null;
if (symbol === null || symbol.tsSymbol === null) {
if (symbol === null || this.templateTypeChecker.getTsSymbolOfSymbol(symbol) === null) {
return undefined;
}
return symbol.tsSymbol;
return this.templateTypeChecker.getTsSymbolOfSymbol(symbol)!;
} else {
return this.tsLS.getCompletionEntrySymbol(
componentContext.tcbPath,
@ -772,7 +773,7 @@ export class CompletionBuilder<N extends TmplAstNode | AST> {
) {
directiveCompletionDetailMap.set(tag, {
fileName: directive.ref.node.getSourceFile().fileName,
entryName: directive.tsSymbol.name,
entryName: directive.ref.node.name!.text,
pos: directive.ref.node.getStart(),
attrKind: null,
@ -895,7 +896,8 @@ export class CompletionBuilder<N extends TmplAstNode | AST> {
}
const directive = tagMap.get(entryName)!;
return directive?.tsSymbol;
const decl = directive.ref.node;
return decl.name ? this.typeChecker.getSymbolAtLocation(decl.name) : undefined;
}
private isAnimationCompletion(): this is ElementAnimationCompletionBuilder {
@ -1119,7 +1121,7 @@ export class CompletionBuilder<N extends TmplAstNode | AST> {
) {
directiveCompletionDetailMap.set(key, {
fileName: completion.directive.ref.node.getSourceFile().fileName,
entryName: completion.directive.tsSymbol.name,
entryName: completion.directive.ref.node.name!.text,
pos: completion.directive.ref.node.getStart(),
attrKind: completion.kind,
@ -1293,7 +1295,7 @@ export class CompletionBuilder<N extends TmplAstNode | AST> {
this.typeChecker,
propertySymbol,
kind,
directive.tsSymbol.name,
directive.ref.node.name!.text,
);
if (info === null) {
break;

View file

@ -129,7 +129,7 @@ export class DefinitionBuilder {
// taken to the directive or HTML class.
return this.getTypeDefinitionsForTemplateInstance(symbol, node);
case SymbolKind.Pipe: {
if (symbol.tsSymbol !== null) {
if (this.ttc.getTsSymbolOfSymbol(symbol) !== null) {
return this.getDefinitionsForSymbols(symbol);
} else {
// If there is no `ts.Symbol` for the pipe transform, we want to return the
@ -172,11 +172,6 @@ export class DefinitionBuilder {
});
}
}
if (symbol.kind === SymbolKind.Variable) {
definitions.push(
...this.getDefinitionsForSymbols({tcbLocation: symbol.initializerLocation}),
);
}
return definitions;
}
case SymbolKind.Expression: {
@ -261,7 +256,7 @@ export class DefinitionBuilder {
break;
}
case SymbolKind.Pipe: {
if (symbol.tsSymbol !== null) {
if (this.ttc.getTsSymbolOfSymbol(symbol) !== null) {
definitions.push(...this.getTypeDefinitionsForSymbols(symbol));
} else {
// If there is no `ts.Symbol` for the pipe transform, we want to return the

View file

@ -144,7 +144,9 @@ export class QuickInfoBuilder {
DisplayInfoKind.ELEMENT,
getTextSpanOfNode(templateNode),
undefined /* containerName */,
this.typeChecker.typeToString(symbol.tsType),
this.typeChecker.typeToString(
this.compiler.getTemplateTypeChecker().getTypeOfSymbol(symbol)!,
),
);
}
@ -171,7 +173,9 @@ export class QuickInfoBuilder {
DisplayInfoKind.LET,
getTextSpanOfNode(this.node),
undefined /* containerName */,
this.typeChecker.typeToString(symbol.tsType),
this.typeChecker.typeToString(
this.compiler.getTemplateTypeChecker().getTypeOfSymbol(symbol)!,
),
info?.documentation,
info?.tags,
);
@ -184,21 +188,25 @@ export class QuickInfoBuilder {
DisplayInfoKind.REFERENCE,
getTextSpanOfNode(this.node),
undefined /* containerName */,
this.typeChecker.typeToString(symbol.tsType),
this.typeChecker.typeToString(
this.compiler.getTemplateTypeChecker().getTypeOfSymbol(symbol)!,
),
info?.documentation,
info?.tags,
);
}
private getQuickInfoForPipeSymbol(symbol: PipeSymbol): ts.QuickInfo | undefined {
if (symbol.tsSymbol !== null) {
if (this.compiler.getTemplateTypeChecker().getTsSymbolOfSymbol(symbol) !== null) {
const quickInfo = this.getQuickInfoAtTcbLocation(symbol.tcbLocation);
return quickInfo === undefined
? undefined
: updateQuickInfoKind(quickInfo, DisplayInfoKind.PIPE);
} else {
return createQuickInfo(
this.typeChecker.typeToString(symbol.classSymbol.tsType),
this.typeChecker.typeToString(
this.compiler.getTemplateTypeChecker().getTypeOfSymbol(symbol.classSymbol)!,
),
DisplayInfoKind.PIPE,
getTextSpanOfNode(this.node),
);
@ -229,12 +237,17 @@ export class QuickInfoBuilder {
const kind = dir.isComponent ? DisplayInfoKind.COMPONENT : DisplayInfoKind.DIRECTIVE;
const info = this.getQuickInfoFromTypeDefAtLocation(dir.tcbLocation);
let containerName: string | undefined;
if (ts.isClassDeclaration(dir.tsSymbol.valueDeclaration) && dir.ngModule !== null) {
const tsSymbol = this.compiler.getTemplateTypeChecker().getTsSymbolOfSymbol(dir);
if (
tsSymbol?.valueDeclaration &&
ts.isClassDeclaration(tsSymbol.valueDeclaration) &&
dir.ngModule !== null
) {
containerName = dir.ngModule.name.getText();
}
return createQuickInfo(
this.typeChecker.typeToString(dir.tsType),
this.typeChecker.typeToString(this.compiler.getTemplateTypeChecker().getTypeOfSymbol(dir)!),
kind,
getTextSpanOfNode(this.node),
containerName,
@ -254,7 +267,9 @@ export class QuickInfoBuilder {
const info = this.getQuickInfoFromTypeDefAtLocation(symbol.tcbLocation);
return createQuickInfo(
this.typeChecker.typeToString(symbol.tsType),
this.typeChecker.typeToString(
this.compiler.getTemplateTypeChecker().getTypeOfSymbol(symbol)!,
),
kind,
getTextSpanOfNode(this.node),
undefined,

View file

@ -383,9 +383,14 @@ export class RenameBuilder {
for (const targetDetails of allTargetDetails) {
for (const location of targetDetails.typescriptLocations) {
if (targetDetails.symbol.kind === SymbolKind.Pipe) {
const meta = this.compiler.getMeta(
targetDetails.symbol.classSymbol.tsSymbol.valueDeclaration,
);
const classSymbol = this.ttc.getTsSymbolOfSymbol(targetDetails.symbol.classSymbol);
if (
!classSymbol?.valueDeclaration ||
!ts.isClassDeclaration(classSymbol.valueDeclaration)
) {
return null;
}
const meta = this.compiler.getMeta(classSymbol.valueDeclaration);
if (meta === null || meta.kind !== MetaKind.Pipe) {
return null;
}
@ -398,7 +403,7 @@ export class RenameBuilder {
targetDetails.symbol.kind === SymbolKind.SelectorlessComponent ||
targetDetails.symbol.kind === SymbolKind.SelectorlessDirective
) {
const tsSymbol = targetDetails.symbol.tsSymbol;
const tsSymbol = this.ttc.getTsSymbolOfSymbol(targetDetails.symbol);
const meta =
tsSymbol === null || tsSymbol.valueDeclaration === undefined
? null

View file

@ -124,7 +124,7 @@ export function getTargetDetailsAtTemplatePosition(
case SymbolKind.Element: {
const matches = getDirectiveMatchesForElementTag(symbol.templateNode, symbol.directives);
details.push({
typescriptLocations: getPositionsForDirectives(matches),
typescriptLocations: getPositionsForDirectives(matches, templateTypeChecker),
templateTarget,
symbol,
});
@ -143,7 +143,7 @@ export function getTargetDetailsAtTemplatePosition(
symbol.host.directives,
);
details.push({
typescriptLocations: getPositionsForDirectives(directives),
typescriptLocations: getPositionsForDirectives(directives, templateTypeChecker),
templateTarget,
symbol,
});
@ -224,7 +224,7 @@ export function getTargetDetailsAtTemplatePosition(
}
case SymbolKind.SelectorlessDirective:
case SymbolKind.SelectorlessComponent:
const dirPosition = getPositionForDirective(symbol);
const dirPosition = getPositionForDirective(symbol, templateTypeChecker);
if (dirPosition !== null) {
details.push({
typescriptLocations: [dirPosition],
@ -242,10 +242,13 @@ export function getTargetDetailsAtTemplatePosition(
/**
* Given a set of `DirectiveSymbol`s, finds the equivalent `FilePosition` of the class declaration.
*/
function getPositionsForDirectives(directives: Set<DirectiveSymbol>): FilePosition[] {
function getPositionsForDirectives(
directives: Set<DirectiveSymbol>,
ttc: import('@angular/compiler-cli/src/ngtsc/typecheck/api').TemplateTypeChecker,
): FilePosition[] {
const allDirectives: FilePosition[] = [];
for (const dir of directives.values()) {
const position = getPositionForDirective(dir);
const position = getPositionForDirective(dir, ttc);
if (position !== null) {
allDirectives.push(position);
}
@ -256,8 +259,9 @@ function getPositionsForDirectives(directives: Set<DirectiveSymbol>): FilePositi
/** Gets the `FilePosition` for a single directive symbol. */
function getPositionForDirective(
directive: DirectiveSymbol | SelectorlessComponentSymbol | SelectorlessDirectiveSymbol,
ttc: import('@angular/compiler-cli/src/ngtsc/typecheck/api').TemplateTypeChecker,
): FilePosition | null {
const declaration = directive.tsSymbol?.valueDeclaration;
const declaration = ttc.getTsSymbolOfSymbol(directive)?.valueDeclaration;
if (
declaration !== undefined &&

View file

@ -14,6 +14,7 @@ import {
Symbol,
SymbolKind,
TcbLocation,
TemplateTypeChecker,
VariableSymbol,
} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
import ts from 'typescript';
@ -57,6 +58,7 @@ export function getSymbolDisplayInfo(
tsLS: ts.LanguageService,
typeChecker: ts.TypeChecker,
symbol: ReferenceSymbol | VariableSymbol | LetDeclarationSymbol,
templateTypeChecker: TemplateTypeChecker,
): DisplayInfo {
let kind: DisplayInfoKind;
if (symbol.kind === SymbolKind.Reference) {
@ -75,7 +77,7 @@ export function getSymbolDisplayInfo(
symbol.declaration.name,
kind,
/* containerName */ undefined,
typeChecker.typeToString(symbol.tsType),
typeChecker.typeToString(templateTypeChecker.getTypeOfSymbol(symbol)!),
);
const quickInfo =
symbol.kind === SymbolKind.Reference
@ -164,7 +166,7 @@ export function getDirectiveDisplayInfo(
dir: PotentialDirective,
): DisplayInfo {
const kind = dir.isComponent ? DisplayInfoKind.COMPONENT : DisplayInfoKind.DIRECTIVE;
const decl = dir.tsSymbol.declarations.find(ts.isClassDeclaration);
const decl = dir.ref.node;
if (decl === undefined || decl.name === undefined) {
return {
kind,
@ -185,7 +187,7 @@ export function getDirectiveDisplayInfo(
}
const displayParts = createDisplayParts(
dir.tsSymbol.name,
decl.name.text,
kind,
dir.ngModule?.name?.text,
undefined,

View file

@ -276,14 +276,10 @@ describe('definitions', () => {
templateOverride: `<div *ngFor="let hero of heroes">{{her¦o}}</div>`,
expectedSpanText: 'hero',
});
expect(definitions!.length).toEqual(2);
expect(definitions!.length).toEqual(1);
const [templateDeclarationDef, contextDef] = definitions;
const [templateDeclarationDef] = definitions;
expect(templateDeclarationDef.textSpan).toEqual('hero');
// `$implicit` is from the `NgForOfContext`:
// https://github.com/angular/angular/blob/89c5255b8ca59eed27ede9e1fad69857ab0c6f4f/packages/common/src/directives/ng_for_of.ts#L15
expect(contextDef.textSpan).toEqual('$implicit');
expect(contextDef.contextSpan).toContain('$implicit: T;');
});
});
@ -437,15 +433,13 @@ describe('definitions', () => {
});
it('should work for variables in structural directives', () => {
const definitions = getDefinitionsAndAssertBoundSpan({
templateOverride: `<div *ngFor="let item of heroes as her¦oes2; trackBy: test;"></div>`,
expectedSpanText: 'heroes2',
});
expect(definitions!.length).toEqual(1);
const [def] = definitions;
expect(def.textSpan).toEqual('ngForOf');
expect(def.contextSpan).toEqual('ngForOf: U;');
const {position} = service.overwriteInlineTemplate(
APP_COMPONENT,
`<div *ngFor="let item of heroes as her¦oes2; trackBy: test;"></div>`,
);
const definitionAndBoundSpan = ngLS.getDefinitionAndBoundSpan(APP_COMPONENT, position);
// We're already at the definition, so nothing is returned (matching standard TS behavior)
expect(definitionAndBoundSpan).toBeUndefined();
});
it('should work for uses of members in structural directives', () => {
@ -453,13 +447,11 @@ describe('definitions', () => {
templateOverride: `<div *ngFor="let item of heroes as heroes2">{{her¦oes2}}</div>`,
expectedSpanText: 'heroes2',
});
expect(definitions!.length).toEqual(2);
expect(definitions!.length).toEqual(1);
const [def, contextDef] = definitions;
const [def] = definitions;
expect(def.textSpan).toEqual('heroes2');
expect(def.contextSpan).toEqual('of heroes as heroes2');
expect(contextDef.textSpan).toEqual('ngForOf');
expect(contextDef.contextSpan).toEqual('ngForOf: U;');
});
it('should work for members in structural directives', () => {