From 74c1716cefa3268528011b2a459c41ca3f75845c Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Wed, 2 Sep 2026 20:22:13 +0000 Subject: [PATCH] refactor(compiler-cli): index pipes in template expressions Update the template indexer to discover and record pipes used in template expressions. This associates template pipe identifiers with their target pipe class declarations, enabling indexers and language tooling to properly resolve and cross-reference pipes. --- .../annotations/component/src/handler.ts | 5 +++ .../compiler-cli/src/ngtsc/indexer/src/api.ts | 13 +++++- .../src/ngtsc/indexer/src/template.ts | 37 ++++++++++++++++ .../src/ngtsc/indexer/test/template_spec.ts | 44 +++++++++++++++++++ .../src/ngtsc/indexer/test/util.ts | 8 ++++ 5 files changed, 106 insertions(+), 1 deletion(-) diff --git a/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts b/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts index be30ec0566d..b1c873d9ab6 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts @@ -1160,6 +1160,7 @@ export class ComponentDecoratorHandler implements DecoratorHandler< if (analysis.isPoisoned && !this.usePoisonedData) { return null; } + const typeCheckScope = this.typeCheckScopeRegistry.getTypeCheckScope(new Reference(node)); const scope = this.scopeReader.getScopeForComponent(node); const selector = analysis.meta.selector; let matcher: DirectiveMatcher | null = null; @@ -1210,6 +1211,10 @@ export class ComponentDecoratorHandler implements DecoratorHandler< getTemplateAst() { return boundTemplate.target.template; }, + getPipe(name) { + const pipe = typeCheckScope.pipes.get(name); + return pipe ? {ref: {node: pipe.ref.node}} : null; + }, }; context.addComponent({ diff --git a/packages/compiler-cli/src/ngtsc/indexer/src/api.ts b/packages/compiler-cli/src/ngtsc/indexer/src/api.ts index fe3e866838d..ef7cade1f33 100644 --- a/packages/compiler-cli/src/ngtsc/indexer/src/api.ts +++ b/packages/compiler-cli/src/ngtsc/indexer/src/api.ts @@ -38,6 +38,7 @@ export enum IdentifierKind { Directive, Input, Output, + Pipe, } /** @@ -156,6 +157,14 @@ export interface BoundAttributeIdentifier extends TemplateI } | null; } +/** Describes a pipe used in a template expression. */ +export interface PipeIdentifier extends TemplateIdentifier { + kind: IdentifierKind.Pipe; + target: { + node: T; + } | null; +} + /** * Identifiers recorded at the top level of the template, without any context about the HTML nodes * they were discovered in. @@ -170,7 +179,8 @@ export type TopLevelIdentifier = | LetDeclarationIdentifier | ComponentNodeIdentifier | DirectiveNodeIdentifier - | BoundAttributeIdentifier; + | BoundAttributeIdentifier + | PipeIdentifier; /** Identifiers that can bring in directives to the template. */ export type DirectiveHostIdentifier = @@ -227,6 +237,7 @@ export interface AbstractBoundTemplate { getExpressionTarget(ast: AST): TmplAstReference | TmplAstVariable | TmplAstLetDeclaration | null; getUsedDirectives(): Array<{ref: {node: T}; isComponent: boolean}>; getTemplateAst(): TmplAstNode[] | undefined; + getPipe(name: string): {ref: {node: T}} | null; } /** diff --git a/packages/compiler-cli/src/ngtsc/indexer/src/template.ts b/packages/compiler-cli/src/ngtsc/indexer/src/template.ts index f402aad5178..0dbcb9cc4c3 100644 --- a/packages/compiler-cli/src/ngtsc/indexer/src/template.ts +++ b/packages/compiler-cli/src/ngtsc/indexer/src/template.ts @@ -8,6 +8,7 @@ import { AST, ASTWithSource, + BindingPipe, CombinedRecursiveAstVisitor, ImplicitReceiver, ParseSourceSpan, @@ -41,6 +42,7 @@ import { IdentifierKind, LetDeclarationIdentifier, MethodIdentifier, + PipeIdentifier, PropertyIdentifier, ReferenceIdentifier, TemplateNodeIdentifier, @@ -148,6 +150,41 @@ class TemplateVisitor extends CombinedRecursiveAstVisitor { super.visitPropertyRead(ast, null); } + override visitPipe(ast: BindingPipe): void { + this.visitPipeIdentifier(ast); + super.visitPipe(ast, null); + } + + private visitPipeIdentifier(ast: BindingPipe): void { + if (this.currentAstWithSource === null || this.currentAstWithSource.source === null) { + return; + } + + const {absoluteOffset, source: expressionStr} = this.currentAstWithSource; + const identifierStart = ast.nameSpan.start - absoluteOffset; + + if (!expressionStr.startsWith(ast.name, identifierStart)) { + this.errors.push( + new Error( + `Impossible state: "${ast.name}" not found in "${expressionStr}" at location ${identifierStart}`, + ), + ); + return; + } + + const absoluteStart = absoluteOffset + identifierStart; + const span = new AbsoluteSourceSpan(absoluteStart, absoluteStart + ast.name.length); + const target = this.boundTemplate.getPipe(ast.name); + const identifier: PipeIdentifier = { + name: ast.name, + span, + kind: IdentifierKind.Pipe, + target: target ? {node: target.ref.node} : null, + }; + + this.identifiers.add(identifier); + } + override visitBoundAttribute(attribute: TmplAstBoundAttribute): void { const identifier = this.bindingToIdentifier(attribute, IdentifierKind.Input); if (identifier !== null) { diff --git a/packages/compiler-cli/src/ngtsc/indexer/test/template_spec.ts b/packages/compiler-cli/src/ngtsc/indexer/test/template_spec.ts index 05d682f5711..e9f5ed4a30f 100644 --- a/packages/compiler-cli/src/ngtsc/indexer/test/template_spec.ts +++ b/packages/compiler-cli/src/ngtsc/indexer/test/template_spec.ts @@ -1326,5 +1326,49 @@ runInEachFileSystem(() => { ); expect(inputOrOutputRefs).toEqual([]); }); + + it('should discover pipes in template expressions', () => { + const pipe = ` + export class MyPipe { + transform(v: any) { return v; } + } + `; + const pipeDecl = util.getComponentDeclaration(pipe, 'MyPipe'); + const template = '{{ foo | myPipe }}'; + const boundTemplate = util.getBoundTemplate( + template, + {}, + [], + [ + { + name: 'myPipe', + declaration: pipeDecl, + }, + ], + ); + const refs = Array.from(getTemplateIdentifiers(boundTemplate)); + + expect(refs).toContain({ + name: 'myPipe', + kind: IdentifierKind.Pipe, + span: new AbsoluteSourceSpan(9, 15), + target: { + node: pipeDecl, + }, + }); + }); + + it('should handle unresolvable pipes gracefully', () => { + const template = '{{ foo | unknownPipe }}'; + const boundTemplate = util.getBoundTemplate(template); + const refs = Array.from(getTemplateIdentifiers(boundTemplate)); + + expect(refs).toContain({ + name: 'unknownPipe', + kind: IdentifierKind.Pipe, + span: new AbsoluteSourceSpan(9, 20), + target: null, + }); + }); }); }); diff --git a/packages/compiler-cli/src/ngtsc/indexer/test/util.ts b/packages/compiler-cli/src/ngtsc/indexer/test/util.ts index d7f98cea161..9dfcf78880d 100644 --- a/packages/compiler-cli/src/ngtsc/indexer/test/util.ts +++ b/packages/compiler-cli/src/ngtsc/indexer/test/util.ts @@ -63,6 +63,10 @@ export function getBoundTemplate( inputs?: Record; outputs?: Record; }> = [], + pipes: Array<{ + name: string; + declaration: ClassDeclaration; + }> = [], ): AbstractBoundTemplate { const componentsMeta = components.map(({selector, declaration, inputs = {}, outputs = {}}) => ({ ref: new Reference(declaration), @@ -130,6 +134,10 @@ export function getBoundTemplate( getTemplateAst() { return boundTemplate.target.template; }, + getPipe(name) { + const pipe = pipes.find((p) => p.name === name); + return pipe ? {ref: {node: pipe.declaration}} : null; + }, }; return abstractBoundTemplate; }