From 4fc45a9b38bdcc38da07236a98116f9fc9e13636 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Wed, 26 Aug 2026 21:57:24 +0000 Subject: [PATCH] refactor(compiler-cli): index bound directive inputs and outputs in templates Update the template indexer to discover and record bound directive inputs (property bindings, static text attributes) and outputs (event bindings). This associates template binding identifiers with their target directive or component class declarations, enabling indexers and language tooling to properly resolve and cross-reference bound directive inputs and outputs. --- .../annotations/component/src/handler.ts | 7 + .../compiler-cli/src/ngtsc/indexer/src/api.ts | 21 ++- .../src/ngtsc/indexer/src/template.ts | 58 +++++++- .../src/ngtsc/indexer/test/template_spec.ts | 130 ++++++++++++++++++ .../src/ngtsc/indexer/test/util.ts | 20 ++- 5 files changed, 227 insertions(+), 9 deletions(-) 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 7c09ea421ea..d849aa5f3fe 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts @@ -1187,6 +1187,13 @@ export class ComponentDecoratorHandler implements DecoratorHandler< getReferenceTarget(node) { return boundTemplate.getReferenceTarget(node); }, + getConsumerOfBinding(binding) { + const consumer = boundTemplate.getConsumerOfBinding(binding); + if (consumer && 'ref' in consumer && consumer.ref) { + return {ref: {node: consumer.ref.node}}; + } + return null; + }, getExpressionTarget(ast) { return boundTemplate.getExpressionTarget(ast); }, diff --git a/packages/compiler-cli/src/ngtsc/indexer/src/api.ts b/packages/compiler-cli/src/ngtsc/indexer/src/api.ts index 70b1ab26658..fe3e866838d 100644 --- a/packages/compiler-cli/src/ngtsc/indexer/src/api.ts +++ b/packages/compiler-cli/src/ngtsc/indexer/src/api.ts @@ -8,6 +8,8 @@ import { AST, + TmplAstBoundAttribute, + TmplAstBoundEvent, TmplAstComponent, TmplAstDirective, TmplAstElement, @@ -15,6 +17,7 @@ import { TmplAstNode, TmplAstReference, TmplAstTemplate, + TmplAstTextAttribute, TmplAstVariable, } from '@angular/compiler'; import {DeclarationNode} from '../../reflection'; @@ -33,6 +36,8 @@ export enum IdentifierKind { LetDeclaration, Component, Directive, + Input, + Output, } /** @@ -138,11 +143,19 @@ export interface VariableIdentifier extends TemplateIdentifier { kind: IdentifierKind.Variable; } -/** Describes a `@let` declaration in a template. */ +/** Describes an `@let` declaration in a template. */ export interface LetDeclarationIdentifier extends TemplateIdentifier { kind: IdentifierKind.LetDeclaration; } +/** Describes a bound attribute or event in a template targeting an Angular input/output. */ +export interface BoundAttributeIdentifier extends TemplateIdentifier { + kind: IdentifierKind.Input | IdentifierKind.Output; + target: { + node: T; + } | null; +} + /** * Identifiers recorded at the top level of the template, without any context about the HTML nodes * they were discovered in. @@ -156,7 +169,8 @@ export type TopLevelIdentifier = | MethodIdentifier | LetDeclarationIdentifier | ComponentNodeIdentifier - | DirectiveNodeIdentifier; + | DirectiveNodeIdentifier + | BoundAttributeIdentifier; /** Identifiers that can bring in directives to the template. */ export type DirectiveHostIdentifier = @@ -207,6 +221,9 @@ export interface AbstractBoundTemplate { directive: {ref: {node: T}}; } | null; + getConsumerOfBinding?( + binding: TmplAstBoundAttribute | TmplAstBoundEvent | TmplAstTextAttribute, + ): {ref: {node: T}} | TmplAstElement | TmplAstTemplate | null; getExpressionTarget(ast: AST): TmplAstReference | TmplAstVariable | TmplAstLetDeclaration | null; getUsedDirectives(): Array<{ref: {node: T}; isComponent: boolean}>; getTemplateAst(): TmplAstNode[] | undefined; diff --git a/packages/compiler-cli/src/ngtsc/indexer/src/template.ts b/packages/compiler-cli/src/ngtsc/indexer/src/template.ts index c0820f20a06..f402aad5178 100644 --- a/packages/compiler-cli/src/ngtsc/indexer/src/template.ts +++ b/packages/compiler-cli/src/ngtsc/indexer/src/template.ts @@ -14,6 +14,7 @@ import { PropertyRead, ThisReceiver, TmplAstBoundAttribute, + TmplAstBoundEvent, TmplAstComponent, TmplAstDirective, TmplAstElement, @@ -21,6 +22,7 @@ import { TmplAstNode, TmplAstReference, TmplAstTemplate, + TmplAstTextAttribute, TmplAstVariable, tmplAstVisitAll, } from '@angular/compiler'; @@ -31,6 +33,7 @@ import { AbsoluteSourceSpan, AbstractBoundTemplate, AttributeIdentifier, + BoundAttributeIdentifier, ComponentNodeIdentifier, DirectiveHostIdentifier, DirectiveNodeIdentifier, @@ -48,9 +51,7 @@ import { type ExpressionIdentifier = PropertyIdentifier | MethodIdentifier; type TmplTarget = TmplAstReference | TmplAstVariable | TmplAstLetDeclaration; type TargetIdentifier = - | ReferenceIdentifier - | VariableIdentifier - | LetDeclarationIdentifier; + ReferenceIdentifier | VariableIdentifier | LetDeclarationIdentifier; type TargetIdentifierMap = Map>; /** @@ -148,6 +149,10 @@ class TemplateVisitor extends CombinedRecursiveAstVisitor { } override visitBoundAttribute(attribute: TmplAstBoundAttribute): void { + const identifier = this.bindingToIdentifier(attribute, IdentifierKind.Input); + if (identifier !== null) { + this.identifiers.add(identifier); + } const previous = this.currentAstWithSource; this.currentAstWithSource = { source: attribute.valueSpan?.toString() || null, @@ -157,6 +162,53 @@ class TemplateVisitor extends CombinedRecursiveAstVisitor { this.currentAstWithSource = previous; } + override visitBoundEvent(event: TmplAstBoundEvent): void { + const identifier = this.bindingToIdentifier(event, IdentifierKind.Output); + if (identifier !== null) { + this.identifiers.add(identifier); + } + super.visitBoundEvent(event); + } + + override visitTextAttribute(attribute: TmplAstTextAttribute): void { + const identifier = this.bindingToIdentifier(attribute, IdentifierKind.Input); + if (identifier !== null) { + this.identifiers.add(identifier); + } + super.visitTextAttribute(attribute); + } + + private bindingToIdentifier( + node: TmplAstBoundAttribute | TmplAstBoundEvent | TmplAstTextAttribute, + kind: IdentifierKind.Input | IdentifierKind.Output, + ): BoundAttributeIdentifier | null { + if (!this.boundTemplate.getConsumerOfBinding) { + return null; + } + const consumer = this.boundTemplate.getConsumerOfBinding(node); + if (!consumer || consumer instanceof TmplAstElement || consumer instanceof TmplAstTemplate) { + return null; + } + + const keySpan = node.keySpan ?? (node instanceof TmplAstTextAttribute ? node.sourceSpan : null); + if (!keySpan) { + return null; + } + + const span = new AbsoluteSourceSpan( + keySpan.start.offset, + keySpan.start.offset + node.name.length, + ); + return { + name: node.name, + span, + kind, + target: { + node: consumer.ref.node, + }, + }; + } + /** Creates an identifier for a template element or template node. */ private directiveHostToIdentifier( node: TmplAstElement | TmplAstTemplate | TmplAstComponent | TmplAstDirective, 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 4fc4c5aa781..05d682f5711 100644 --- a/packages/compiler-cli/src/ngtsc/indexer/test/template_spec.ts +++ b/packages/compiler-cli/src/ngtsc/indexer/test/template_spec.ts @@ -1196,5 +1196,135 @@ runInEachFileSystem(() => { }, ]); }); + + it('should discover bound attribute inputs on components', () => { + const comp = ` + export class MyComp { + myInput: string; + } + `; + const compDecl = util.getComponentDeclaration(comp, 'MyComp'); + const template = ''; + const boundTemplate = util.getBoundTemplate(template, {}, [ + { + selector: 'my-comp', + declaration: compDecl, + inputs: {myInput: 'myInput'}, + }, + ]); + const refs = Array.from(getTemplateIdentifiers(boundTemplate)); + + expect(refs).toContain({ + name: 'myInput', + kind: IdentifierKind.Input, + span: new AbsoluteSourceSpan(10, 17), + target: { + node: compDecl, + }, + }); + }); + + it('should discover bound event outputs on components', () => { + const comp = ` + export class MyComp { + myOutput: any; + } + `; + const compDecl = util.getComponentDeclaration(comp, 'MyComp'); + const template = ''; + const boundTemplate = util.getBoundTemplate(template, {}, [ + { + selector: 'my-comp', + declaration: compDecl, + outputs: {myOutput: 'myOutput'}, + }, + ]); + const refs = Array.from(getTemplateIdentifiers(boundTemplate)); + + expect(refs).toContain({ + name: 'myOutput', + kind: IdentifierKind.Output, + span: new AbsoluteSourceSpan(10, 18), + target: { + node: compDecl, + }, + }); + }); + + it('should discover static text attribute inputs on components', () => { + const comp = ` + export class MyComp { + myInput: string; + } + `; + const compDecl = util.getComponentDeclaration(comp, 'MyComp'); + const template = ''; + const boundTemplate = util.getBoundTemplate(template, {}, [ + { + selector: 'my-comp', + declaration: compDecl, + inputs: {myInput: 'myInput'}, + }, + ]); + const refs = Array.from(getTemplateIdentifiers(boundTemplate)); + + expect(refs).toContain({ + name: 'myInput', + kind: IdentifierKind.Input, + span: new AbsoluteSourceSpan(9, 16), + target: { + node: compDecl, + }, + }); + }); + + it('should discover bound attribute inputs and event outputs on directives applied to elements', () => { + const dir = ` + export class MyDir { + dirInput: string; + dirOutput: any; + } + `; + const dirDecl = util.getComponentDeclaration(dir, 'MyDir'); + const template = '
'; + const boundTemplate = util.getBoundTemplate(template, {}, [ + { + selector: '[myDir]', + declaration: dirDecl, + inputs: {dirInput: 'dirInput'}, + outputs: {dirOutput: 'dirOutput'}, + }, + ]); + const refs = Array.from(getTemplateIdentifiers(boundTemplate)); + + expect(refs).toContain({ + name: 'dirInput', + kind: IdentifierKind.Input, + span: new AbsoluteSourceSpan(12, 20), + target: { + node: dirDecl, + }, + }); + expect(refs).toContain({ + name: 'dirOutput', + kind: IdentifierKind.Output, + span: new AbsoluteSourceSpan(29, 38), + target: { + node: dirDecl, + }, + }); + }); + + it('should not discover input or output identifiers for native element bindings and events', () => { + const template = + ''; + const boundTemplate = util.getBoundTemplate(template); + const refs = Array.from(getTemplateIdentifiers(boundTemplate)); + + const inputOrOutputRefs = refs.filter( + (ref) => ref.kind === IdentifierKind.Input || ref.kind === IdentifierKind.Output, + ); + expect(inputOrOutputRefs).toEqual([]); + }); }); }); diff --git a/packages/compiler-cli/src/ngtsc/indexer/test/util.ts b/packages/compiler-cli/src/ngtsc/indexer/test/util.ts index a3d0f320a3a..d7f98cea161 100644 --- a/packages/compiler-cli/src/ngtsc/indexer/test/util.ts +++ b/packages/compiler-cli/src/ngtsc/indexer/test/util.ts @@ -57,15 +57,20 @@ export function getComponentDeclaration(componentStr: string, className: string) export function getBoundTemplate( template: string, options: ParseTemplateOptions = {}, - components: Array<{selector: string | null; declaration: ClassDeclaration}> = [], + components: Array<{ + selector: string | null; + declaration: ClassDeclaration; + inputs?: Record; + outputs?: Record; + }> = [], ): AbstractBoundTemplate { - const componentsMeta = components.map(({selector, declaration}) => ({ + const componentsMeta = components.map(({selector, declaration, inputs = {}, outputs = {}}) => ({ ref: new Reference(declaration), selector, name: declaration.name.getText(), isComponent: true, - inputs: ClassPropertyMapping.fromMappedObject({}), - outputs: ClassPropertyMapping.fromMappedObject({}), + inputs: ClassPropertyMapping.fromMappedObject(inputs), + outputs: ClassPropertyMapping.fromMappedObject(outputs), exportAs: null, isStructural: false, animationTriggerNames: null, @@ -106,6 +111,13 @@ export function getBoundTemplate( getReferenceTarget(node) { return boundTemplate.getReferenceTarget(node); }, + getConsumerOfBinding(binding) { + const consumer = boundTemplate.getConsumerOfBinding(binding); + if (consumer && 'ref' in consumer && consumer.ref) { + return {ref: {node: consumer.ref.node}}; + } + return null; + }, getExpressionTarget(ast) { return boundTemplate.getExpressionTarget(ast); },