From 63ac1bd2fdbf6d982274363aa7a84066529bdc82 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Mon, 23 Mar 2026 06:56:14 +0100 Subject: [PATCH] refactor(compiler-cli): decouple host element creation logic from TypeScript Reworks the logic for constructing a `HostElement` node so that we can reuse it without depending on TypeScript APIs. --- .../compiler-cli/private/hybrid_analysis.ts | 8 + .../annotations/component/src/handler.ts | 19 +- .../annotations/directive/src/handler.ts | 18 +- .../ngtsc/annotations/directive/src/shared.ts | 114 +++++- .../src/ngtsc/typecheck/src/host_bindings.ts | 344 +++++++++--------- .../ngtsc/host_bindings_type_check_spec.ts | 8 +- 6 files changed, 302 insertions(+), 209 deletions(-) diff --git a/packages/compiler-cli/private/hybrid_analysis.ts b/packages/compiler-cli/private/hybrid_analysis.ts index d8ef033fbc4..cdc25d9c2d1 100644 --- a/packages/compiler-cli/private/hybrid_analysis.ts +++ b/packages/compiler-cli/private/hybrid_analysis.ts @@ -28,3 +28,11 @@ export type {ReferenceEmitter} from '../src/ngtsc/imports'; export type {ReflectionHost, ClassDeclaration} from '../src/ngtsc/reflection'; export {ClassPropertyMapping} from '../src/ngtsc/metadata/src/property_mapping'; export type {TypeCheckSourceResolver} from '../src/ngtsc/typecheck/src/tcb_util'; +export { + createHostElement, + type SourceNode, + type StaticSourceNode, + type HostObjectLiteralBinding, + type HostListenerDecorator, + type HostBindingDecorator, +} from '../src/ngtsc/typecheck/src/host_bindings'; 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 b2fcc66045c..98d844df6f7 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts @@ -135,6 +135,7 @@ import { compileNgFactoryDefField, compileResults, createForwardRefResolver, + createSourceSpan, extractClassDebugInfo, extractClassMetadata, extractSchemas, @@ -231,10 +232,12 @@ const isUsedPipe = (decl: AnyUsedType): decl is UsedPipe => /** * `DecoratorHandler` which handles the `@Component` annotation. */ -export class ComponentDecoratorHandler - implements - DecoratorHandler -{ +export class ComponentDecoratorHandler implements DecoratorHandler< + Decorator, + ComponentAnalysisData, + ComponentSymbol, + ComponentResolutionData +> { constructor( private reflector: ReflectionHost, private evaluator: PartialEvaluator, @@ -1167,10 +1170,10 @@ export class ComponentDecoratorHandler ? createHostElement( 'component', meta.meta.selector, - node, - meta.hostBindingNodes.literal, - meta.hostBindingNodes.bindingDecorators, - meta.hostBindingNodes.listenerDecorators, + createSourceSpan(node.name), + meta.hostBindingNodes.hostObjectLiteralBindings, + meta.hostBindingNodes.hostBindingDecorators, + meta.hostBindingNodes.hostListenerDecorators, ) : null; const hostBindingsContext: HostBindingsContext | null = diff --git a/packages/compiler-cli/src/ngtsc/annotations/directive/src/handler.ts b/packages/compiler-cli/src/ngtsc/annotations/directive/src/handler.ts index 875308dadc0..aa8fae7f8d0 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/directive/src/handler.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/directive/src/handler.ts @@ -63,6 +63,7 @@ import { compileInputTransformFields, compileNgFactoryDefField, compileResults, + createSourceSpan, extractClassMetadata, findAngularDecorator, getDirectiveDiagnostics, @@ -132,9 +133,12 @@ export interface DirectiveHandlerData { resources: DirectiveResources; } -export class DirectiveDecoratorHandler - implements DecoratorHandler -{ +export class DirectiveDecoratorHandler implements DecoratorHandler< + Decorator | null, + DirectiveHandlerData, + DirectiveSymbol, + unknown +> { constructor( private reflector: ReflectionHost, private evaluator: PartialEvaluator, @@ -360,10 +364,10 @@ export class DirectiveDecoratorHandler const hostElement = createHostElement( 'directive', meta.meta.selector, - node, - meta.hostBindingNodes.literal, - meta.hostBindingNodes.bindingDecorators, - meta.hostBindingNodes.listenerDecorators, + createSourceSpan(node.name), + meta.hostBindingNodes.hostObjectLiteralBindings, + meta.hostBindingNodes.hostBindingDecorators, + meta.hostBindingNodes.hostListenerDecorators, ); if (hostElement !== null && scope.directivesOnHost !== null) { diff --git a/packages/compiler-cli/src/ngtsc/annotations/directive/src/shared.ts b/packages/compiler-cli/src/ngtsc/annotations/directive/src/shared.ts index dcb3b72180b..f4ebec08315 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/directive/src/shared.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/directive/src/shared.ts @@ -56,7 +56,6 @@ import { ForeignFunctionResolver, PartialEvaluator, ResolvedValue, - traceDynamicValue, } from '../../../partial_evaluator'; import { AmbientImport, @@ -93,6 +92,13 @@ import {tryParseSignalInputMapping} from './input_function'; import {tryParseSignalModelMapping} from './model_function'; import {tryParseInitializerBasedOutput} from './output_function'; import {tryParseSignalQueryFromInitializer} from './query_functions'; +import { + HostObjectLiteralBinding, + HostListenerDecorator, + HostBindingDecorator, + SourceNode, + StaticSourceNode, +} from '../../../typecheck/src/host_bindings'; const EMPTY_OBJECT: {[key: string]: string} = {}; @@ -105,9 +111,10 @@ export const queryDecoratorNames: QueryDecoratorName[] = [ ]; export interface HostBindingNodes { - literal: ts.ObjectLiteralExpression | null; - bindingDecorators: Set; - listenerDecorators: Set; + hostObjectLiteralBindings: HostObjectLiteralBinding[]; + hostBindingDecorators: HostBindingDecorator[]; + hostListenerDecorators: HostListenerDecorator[]; + rawNodes: ts.Node[]; } const QUERY_TYPES = new Set(queryDecoratorNames); @@ -290,9 +297,10 @@ export function extractDirectiveMetadata( } const hostBindingNodes: HostBindingNodes = { - literal: null, - bindingDecorators: new Set(), - listenerDecorators: new Set(), + hostObjectLiteralBindings: [], + hostBindingDecorators: [], + hostListenerDecorators: [], + rawNodes: [], }; const host = extractHostBindings( @@ -600,7 +608,17 @@ function extractHostBindings( const hostExpression = metadata.get('host')!; bindings = evaluateHostExpressionBindings(hostExpression, evaluator); if (ts.isObjectLiteralExpression(hostExpression)) { - hostBindingNodes.literal = hostExpression; + hostBindingNodes.rawNodes.push(hostExpression); + + for (const prop of hostExpression.properties) { + if (ts.isPropertyAssignment(prop)) { + hostBindingNodes.hostObjectLiteralBindings.push({ + key: sourceNodeFromTs(prop.name), + value: sourceNodeFromTs(prop.initializer), + sourceSpan: createSourceSpan(prop), + }); + } + } } } else { bindings = parseHostBindings({}); @@ -645,7 +663,23 @@ function extractHostBindings( } if (ts.isDecorator(decorator.node)) { - hostBindingNodes.bindingDecorators.add(decorator.node); + const member = decorator.node.parent; + + hostBindingNodes.rawNodes.push(decorator.node.expression); + + // TODO(crisbeto): this doesn't cover getters which is likely hiding some errors. + if (member && ts.isPropertyDeclaration(member) && member.name) { + const memberName = sourceNodeFromTs(member.name); + + if (memberName.kind === 'string' || memberName.kind === 'identifier') { + hostBindingNodes.hostBindingDecorators.push({ + memberName, + memberSpan: createSourceSpan(member), + arguments: decorator.args === null ? [] : decorator.args.map(sourceNodeFromTs), + decoratorSpan: createSourceSpan(decorator.node), + }); + } + } } // Since this is a decorator, we know that the value is a class member. Always access it @@ -710,7 +744,33 @@ function extractHostBindings( } if (ts.isDecorator(decorator.node)) { - hostBindingNodes.listenerDecorators.add(decorator.node); + const member = decorator.node.parent; + hostBindingNodes.rawNodes.push(decorator.node.expression); + + if (member && ts.isMethodDeclaration(member) && member.name) { + const memberName = sourceNodeFromTs(member.name); + + if (memberName.kind === 'string' || memberName.kind === 'identifier') { + let eventName: SourceNode | null = null; + let args: SourceNode[] | undefined; + + if (decorator.args !== null && decorator.args.length > 0) { + eventName = sourceNodeFromTs(decorator.args[0]); + args = + decorator.args.length > 1 && ts.isArrayLiteralExpression(decorator.args[1]) + ? decorator.args[1].elements.map(sourceNodeFromTs) + : []; + } + + hostBindingNodes.hostListenerDecorators.push({ + eventName, + memberName, + memberSpan: createSourceSpan(member), + arguments: args ?? [], + decoratorSpan: createSourceSpan(decorator.node), + }); + } + } } bindings.listeners[eventName] = `${member.name}(${args.join(',')})`; @@ -720,6 +780,28 @@ function extractHostBindings( return bindings; } +function sourceNodeFromTs(node: ts.Node): SourceNode { + const sourceSpan = createSourceSpan(node); + + if (ts.isStringLiteralLike(node) || ts.isIdentifier(node)) { + // Offset by one on both sides to skip over the quotes. + if (ts.isStringLiteralLike(node)) { + sourceSpan.fullStart = sourceSpan.fullStart.moveBy(1); + sourceSpan.start = sourceSpan.start.moveBy(1); + sourceSpan.end = sourceSpan.end.moveBy(-1); + } + + return { + kind: ts.isIdentifier(node) ? 'identifier' : 'string', + sourceSpan, + source: node.getText(), + text: node.text, + }; + } + + return {kind: 'unspecified', sourceSpan}; +} + function extractQueriesFromDecorator( queryData: ts.Expression, reflector: ReflectionHost, @@ -2143,16 +2225,8 @@ function toR3InputMetadata(mapping: InputMapping): R3InputMetadata { export function extractHostBindingResources(nodes: HostBindingNodes): ReadonlySet { const result = new Set(); - if (nodes.literal !== null) { - result.add({path: null, node: nodes.literal}); - } - - for (const current of nodes.bindingDecorators) { - result.add({path: null, node: current.expression}); - } - - for (const current of nodes.listenerDecorators) { - result.add({path: null, node: current.expression}); + for (const node of nodes.rawNodes) { + result.add({path: null, node}); } return result; diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/host_bindings.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/host_bindings.ts index f2e85520e4c..1453002190a 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/host_bindings.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/host_bindings.ts @@ -31,8 +31,6 @@ import { ImplicitReceiver, } from '@angular/compiler'; import ts from 'typescript'; -import {createSourceSpan} from '../../annotations/common'; -import {ClassDeclaration} from '../../reflection'; /** * Comment attached to an AST node that serves as a guard to distinguish nodes @@ -40,61 +38,89 @@ import {ClassDeclaration} from '../../reflection'; */ const GUARD_COMMENT_TEXT = 'hostBindingsBlockGuard'; -/** Node that represent a static name of a member. */ -type StaticName = ts.Identifier | ts.StringLiteralLike; +/** Represents information extracted from the source AST. */ +export type SourceNode = + | StaticSourceNode + | { + kind: 'unspecified'; + sourceSpan: ParseSourceSpan; + }; -/** Property assignment node with a static name and initializer. */ -type StaticPropertyAssignment = ts.PropertyAssignment & { - name: StaticName; - initializer: ts.StringLiteralLike; -}; +/** A `SourceNode` which represents a static expression. */ +export interface StaticSourceNode { + kind: 'string' | 'identifier'; + /** Raw source code of the node (e.g. strings include the quotes). */ + source: string; + /** Actual text of the node (e.g. value inside the quotes in strings). */ + text: string; + /** Location information about the node. */ + sourceSpan: ParseSourceSpan; +} + +/** A single binding inside the `host` object of a directive. */ +export interface HostObjectLiteralBinding { + /** Node representing the key of the binding. */ + key: SourceNode; + /** Node representing the value of the binding. */ + value: SourceNode; + /** Location information about the entire binding. */ + sourceSpan: ParseSourceSpan; +} + +/** A single binding declared by a `@HostListener` decorator on a class member. */ +export interface HostListenerDecorator { + /** Node declaring the name of the event (e.g. first argument of `@HostListener`). */ + eventName: SourceNode | null; + /** Node representing the name of the member that was decorated. */ + memberName: StaticSourceNode; + /** Location information about the member that the decorator is set on. */ + memberSpan: ParseSourceSpan; + /** Arguments passed to the event. */ + arguments: SourceNode[]; + /** Location information about the decorator. */ + decoratorSpan: ParseSourceSpan; +} + +/** A single binding declared by the `@HostBinding` decorator on a class member. */ +export interface HostBindingDecorator { + /** Node representing the name of the member that was decorated. */ + memberName: StaticSourceNode; + /** Location information about the member that the decorator is set on. */ + memberSpan: ParseSourceSpan; + /** Arguments passed into the decorator */ + arguments: SourceNode[]; + /** Location information about the decorator. */ + decoratorSpan: ParseSourceSpan; +} /** * Creates an AST node that represents the host element of a directive. * Can return null if there are no valid bindings to be checked. - * @param type Whether the host element is for a directive or a component. - * @param selector Selector of the directive. - * @param sourceNode Class declaration for the directive. - * @param literal `host` object literal from the decorator. - * @param bindingDecorators `HostBinding` decorators discovered on the node. - * @param listenerDecorators `HostListener` decorators discovered on the node. + * @param meta Metadata used to construct the host element. */ export function createHostElement( type: 'component' | 'directive', selector: string | null, - sourceNode: ClassDeclaration, - literal: ts.ObjectLiteralExpression | null, - bindingDecorators: Iterable, - listenerDecorators: Iterable, + nameSpan: ParseSourceSpan, + hostObjectLiteralBindings: HostObjectLiteralBinding[], + hostBindingDecorators: HostBindingDecorator[], + hostListenerDecorators: HostListenerDecorator[], ): TmplAstHostElement | null { const bindings: TmplAstBoundAttribute[] = []; const listeners: TmplAstBoundEvent[] = []; let parser: BindingParser | null = null; - if (literal !== null) { - for (const prop of literal.properties) { - // We only support type checking of static bindings. - if ( - ts.isPropertyAssignment(prop) && - ts.isStringLiteralLike(prop.initializer) && - isStaticName(prop.name) - ) { - parser ??= makeBindingParser(); - createNodeFromHostLiteralProperty( - prop as StaticPropertyAssignment, - parser, - bindings, - listeners, - ); - } - } + for (const binding of hostObjectLiteralBindings) { + // We only support type checking of static bindings. + parser ??= makeBindingParser(); + createNodeFromHostLiteralProperty(binding, parser, bindings, listeners); } - for (const decorator of bindingDecorators) { + for (const decorator of hostBindingDecorators) { createNodeFromBindingDecorator(decorator, bindings); } - for (const decorator of listenerDecorators) { + for (const decorator of hostListenerDecorators) { parser ??= makeBindingParser(); createNodeFromListenerDecorator(decorator, parser, listeners); } @@ -122,7 +148,7 @@ export function createHostElement( tagNames.push(`ng-${type}`); } - return new TmplAstHostElement(tagNames, bindings, listeners, createSourceSpan(sourceNode.name)); + return new TmplAstHostElement(tagNames, bindings, listeners, nameSpan); } /** @@ -173,7 +199,7 @@ export function isHostBindingsBlockGuard(node: ts.Node): boolean { * @param listeners Array tracking the event listeners of the host element. */ function createNodeFromHostLiteralProperty( - property: StaticPropertyAssignment, + binding: HostObjectLiteralBinding, parser: BindingParser, bindings: TmplAstBoundAttribute[], listeners: TmplAstBoundEvent[], @@ -181,17 +207,25 @@ function createNodeFromHostLiteralProperty( // TODO(crisbeto): surface parsing errors here, because currently they just get ignored. // They'll still get reported when the handler tries to parse the bindings, but here we // can highlight the nodes more accurately. - const {name, initializer} = property; + const {key, value, sourceSpan} = binding; - if (name.text.startsWith('[') && name.text.endsWith(']')) { - const {attrName, type} = inferBoundAttribute(name.text.slice(1, -1)); - const valueSpan = createStaticExpressionSpan(initializer); - const ast = parser.parseBinding(initializer.text, true, valueSpan, valueSpan.start.offset); + if (key.kind !== 'string' || value.kind !== 'string') { + return; + } + + if (key.text.startsWith('[') && key.text.endsWith(']')) { + const {attrName, type} = inferBoundAttribute(key.text.slice(1, -1)); + const ast = parser.parseBinding( + value.text, + true, + value.sourceSpan, + value.sourceSpan.start.offset, + ); if (ast.errors.length > 0) { return; // See TODO above. } - fixupSpans(ast, initializer); + fixupSpans(ast, value); bindings.push( new TmplAstBoundAttribute( attrName, @@ -199,31 +233,31 @@ function createNodeFromHostLiteralProperty( 0, ast, null, - createSourceSpan(property), - createStaticExpressionSpan(name), - valueSpan, + sourceSpan, + key.sourceSpan, + value.sourceSpan, undefined, ), ); - } else if (name.text.startsWith('(') && name.text.endsWith(')')) { + } else if (key.text.startsWith('(') && key.text.endsWith(')')) { const events: ParsedEvent[] = []; parser.parseEvent( - name.text.slice(1, -1), - initializer.text, + key.text.slice(1, -1), + value.text, false, - createSourceSpan(property), - createStaticExpressionSpan(initializer), + sourceSpan, + value.sourceSpan, [], events, - createStaticExpressionSpan(name), + key.sourceSpan, ); if (events.length === 0 || events[0].handler.errors.length > 0) { return; // See TODO above. } - fixupSpans(events[0].handler, initializer); + fixupSpans(events[0].handler, value); listeners.push(TmplAstBoundEvent.fromParsedEvent(events[0])); } } @@ -234,34 +268,23 @@ function createNodeFromHostLiteralProperty( * @param bindings Array tracking the bound attributes of the host element. */ function createNodeFromBindingDecorator( - decorator: ts.Decorator, + decorator: HostBindingDecorator, bindings: TmplAstBoundAttribute[], ): void { - // We only support decorators that are being called. - if (!ts.isCallExpression(decorator.expression)) { - return; - } - - const args = decorator.expression.arguments; - const property = decorator.parent; - let nameNode: StaticName | null = null; - let propertyName: StaticName | null = null; - - if (property && ts.isPropertyDeclaration(property) && isStaticName(property.name)) { - propertyName = property.name; - } + const args = decorator.arguments; + let nameNode: SourceNode; // The first parameter is optional. If omitted, the name // of the class member is used as the property. if (args.length === 0) { - nameNode = propertyName; - } else if (ts.isStringLiteralLike(args[0])) { + nameNode = decorator.memberName; + } else if (args[0].kind === 'string') { nameNode = args[0]; } else { return; } - if (nameNode === null || propertyName === null) { + if (nameNode.kind !== 'string' && nameNode.kind !== 'identifier') { return; } @@ -272,17 +295,21 @@ function createNodeFromBindingDecorator( // manually here. Note that we use a dummy span with -1/-1 as offsets, because it isn't // used for type checking and constructing it accurately would take some effort. const span = new ParseSpan(-1, -1); - const propertyStart = property.getStart(); + const propertyStart = decorator.memberSpan.start.offset; const receiver = new ThisReceiver(span, new AbsoluteSourceSpan(propertyStart, propertyStart)); - const nameSpan = new AbsoluteSourceSpan(propertyName.getStart(), propertyName.getEnd()); - const read = ts.isIdentifier(propertyName) - ? new PropertyRead(span, nameSpan, nameSpan, receiver, propertyName.text) - : new KeyedRead( - span, - nameSpan, - receiver, - new LiteralPrimitive(span, nameSpan, propertyName.text), - ); + const nameSpan = new AbsoluteSourceSpan( + nameNode.sourceSpan.start.offset, + nameNode.sourceSpan.end.offset, + ); + const read = + decorator.memberName.kind === 'string' + ? new KeyedRead( + span, + nameSpan, + receiver, + new LiteralPrimitive(span, nameSpan, decorator.memberName.text), + ) + : new PropertyRead(span, nameSpan, nameSpan, receiver, decorator.memberName.text); const {attrName, type} = inferBoundAttribute(nameNode.text); bindings.push( @@ -292,9 +319,9 @@ function createNodeFromBindingDecorator( 0, read, null, - createSourceSpan(decorator), - createStaticExpressionSpan(nameNode), - createSourceSpan(decorator), + decorator.decoratorSpan, + nameNode.sourceSpan, + decorator.decoratorSpan, undefined, ), ); @@ -307,25 +334,11 @@ function createNodeFromBindingDecorator( * @param bindings Array tracking the bound events of the host element. */ function createNodeFromListenerDecorator( - decorator: ts.Decorator, + decorator: HostListenerDecorator, parser: BindingParser, listeners: TmplAstBoundEvent[], ): void { - // We only support decorators that are being called with at least one argument. - if (!ts.isCallExpression(decorator.expression) || decorator.expression.arguments.length === 0) { - return; - } - - const args = decorator.expression.arguments; - const method = decorator.parent; - - // Only handle decorators that are statically analyzable. - if ( - !method || - !ts.isMethodDeclaration(method) || - !isStaticName(method.name) || - !ts.isStringLiteralLike(args[0]) - ) { + if (decorator.eventName === null || decorator.eventName.kind !== 'string') { return; } @@ -335,53 +348,61 @@ function createNodeFromListenerDecorator( // something like `(foo)="handleFoo()"` in the AST. Instead we construct the expressions // manually here. Note that we use a dummy span with -1/-1 as offsets, because it isn't // used for type checking and constructing it accurately would take some effort. - const span = new ParseSpan(-1, -1); + const dummySpan = new ParseSpan(-1, -1); const argNodes: AST[] = []; - const methodStart = method.getStart(); - const methodReceiver = new ThisReceiver(span, new AbsoluteSourceSpan(methodStart, methodStart)); - const nameSpan = new AbsoluteSourceSpan(method.name.getStart(), method.name.getEnd()); - const receiver = ts.isIdentifier(method.name) - ? new PropertyRead(span, nameSpan, nameSpan, methodReceiver, method.name.text) - : new KeyedRead( - span, - nameSpan, - methodReceiver, - new LiteralPrimitive(span, nameSpan, method.name.text), - ); + const methodStart = decorator.memberSpan.start.offset; + const methodReceiver = new ThisReceiver( + dummySpan, + new AbsoluteSourceSpan(methodStart, methodStart), + ); + const nameSpan = new AbsoluteSourceSpan( + decorator.memberName.sourceSpan.start.offset, + decorator.memberName.sourceSpan.end.offset, + ); + const receiver = + decorator.memberName.kind === 'string' + ? new KeyedRead( + dummySpan, + nameSpan, + methodReceiver, + new LiteralPrimitive(dummySpan, nameSpan, decorator.memberName.text), + ) + : new PropertyRead(dummySpan, nameSpan, nameSpan, methodReceiver, decorator.memberName.text); - if (args.length > 1 && ts.isArrayLiteralExpression(args[1])) { - for (const expr of args[1].elements) { - // If the parameter is a static string, parse it using the binding parser since it can be any - // expression, otherwise treat it as `any` so the rest of the parameters can be checked. - if (ts.isStringLiteralLike(expr)) { - const span = createStaticExpressionSpan(expr); - const ast = parser.parseBinding(expr.text, true, span, span.start.offset); - fixupSpans(ast, expr); - argNodes.push(ast); - } else { - // Represents `$any(0)`. We need to construct it manually in order to set the right spans. - const expressionSpan = new AbsoluteSourceSpan(expr.getStart(), expr.getEnd()); - const anyRead = new PropertyRead( - span, - expressionSpan, - expressionSpan, - new ImplicitReceiver(span, expressionSpan), - '$any', - ); - const anyCall = new Call( - span, - expressionSpan, - anyRead, - [new LiteralPrimitive(span, expressionSpan, 0)], - expressionSpan, - ); - argNodes.push(anyCall); - } + for (const arg of decorator.arguments) { + // If the parameter is a static string, parse it using the binding parser since it can be any + // expression, otherwise treat it as `any` so the rest of the parameters can be checked. + if (arg.kind === 'string') { + const span = arg.sourceSpan; + const ast = parser.parseBinding(arg.text, true, span, span.start.offset); + fixupSpans(ast, arg); + argNodes.push(ast); + } else { + // Represents `$any(0)`. We need to construct it manually in order to set the right spans. + const expressionSpan = new AbsoluteSourceSpan( + arg.sourceSpan.start.offset, + arg.sourceSpan.end.offset, + ); + const anyRead = new PropertyRead( + dummySpan, + expressionSpan, + expressionSpan, + new ImplicitReceiver(dummySpan, expressionSpan), + '$any', + ); + const anyCall = new Call( + dummySpan, + expressionSpan, + anyRead, + [new LiteralPrimitive(dummySpan, expressionSpan, 0)], + expressionSpan, + ); + argNodes.push(anyCall); } } - const callNode = new Call(span, nameSpan, receiver, argNodes, span); - const eventNameNode = args[0]; + const callNode = new Call(dummySpan, nameSpan, receiver, argNodes, dummySpan); + const eventNameNode = decorator.eventName; let type: ParsedEventType; let eventName: string; let phase: string | null; @@ -408,9 +429,9 @@ function createNodeFromListenerDecorator( callNode, target, phase, - createSourceSpan(decorator), - createSourceSpan(decorator), - createStaticExpressionSpan(eventNameNode), + decorator.decoratorSpan, + decorator.decoratorSpan, + eventNameNode.sourceSpan, ), ); } @@ -452,31 +473,12 @@ function inferBoundAttribute(name: string): {attrName: string; type: BindingType return {attrName, type}; } -/** Checks whether the specified node is a static name node. */ -function isStaticName(node: ts.Node): node is StaticName { - return ts.isIdentifier(node) || ts.isStringLiteralLike(node); -} - -/** Creates a `ParseSourceSpan` pointing to a static expression AST node's source. */ -function createStaticExpressionSpan(node: ts.StringLiteralLike | ts.Identifier): ParseSourceSpan { - const span = createSourceSpan(node); - - // Offset by one on both sides to skip over the quotes. - if (ts.isStringLiteralLike(node)) { - span.fullStart = span.fullStart.moveBy(1); - span.start = span.start.moveBy(1); - span.end = span.end.moveBy(-1); - } - - return span; -} - /** * Adjusts the spans of a parsed AST so that they're appropriate for a host bindings context. * @param ast The parsed AST that may need to be adjusted. * @param initializer TypeScript node from which the source of the AST was extracted. */ -function fixupSpans(ast: AST, initializer: ts.StringLiteralLike): void { +function fixupSpans(ast: AST, node: StaticSourceNode): void { // When parsing the initializer as a property/event binding, we use `.text` which excludes escaped // quotes and is generally what we want, because preserving them would result in a parser error, // however it has the downside in that the spans of the expressions following the escaped @@ -501,11 +503,13 @@ function fixupSpans(ast: AST, initializer: ts.StringLiteralLike): void { // 4. Constructing some sort of string like ``, // passing it through the HTML parser and extracting the first attribute from it - wasn't explored // much, but likely has the same issues as approach #3. - const escapeIndex = initializer.getText().indexOf('\\', 1); + const escapeIndex = node.source.indexOf('\\', 1); if (escapeIndex > -1) { - const newSpan = new ParseSpan(0, initializer.getWidth()); - const newSourceSpan = new AbsoluteSourceSpan(initializer.getStart(), initializer.getEnd()); + const start = node.sourceSpan.start.offset; + const end = node.sourceSpan.end.offset; + const newSpan = new ParseSpan(0, end - start); + const newSourceSpan = new AbsoluteSourceSpan(start, end); ast.visit(new ReplaceSpanVisitor(escapeIndex, newSpan, newSourceSpan)); } } diff --git a/packages/compiler-cli/test/ngtsc/host_bindings_type_check_spec.ts b/packages/compiler-cli/test/ngtsc/host_bindings_type_check_spec.ts index d1aa2a8797a..002497b8c2d 100644 --- a/packages/compiler-cli/test/ngtsc/host_bindings_type_check_spec.ts +++ b/packages/compiler-cli/test/ngtsc/host_bindings_type_check_spec.ts @@ -579,7 +579,7 @@ runInEachFileSystem(() => { expect(getDiagnosticSourceCode(diags[1])).toBe('handleClick'); }); - it('should report diagnostic on the entire initializer of property binding if node contains escaped string', () => { + it('should report diagnostic on the entire expression of property binding if node contains escaped string', () => { env.write( 'test.ts', ` @@ -599,10 +599,10 @@ runInEachFileSystem(() => { const diags = env.driveDiagnostics(); expect(diags.length).toBe(1); expect(diags[0].messageText).toBe(`Property 'doesNotExist' does not exist on type 'Dir'.`); - expect(getDiagnosticSourceCode(diags[0])).toBe(`'prefix + \\'123\\' + doesNotExist'`); + expect(getDiagnosticSourceCode(diags[0])).toBe(`prefix + \\'123\\' + doesNotExist`); }); - it('should report diagnostic on the entire initializer of event binding if node contains escaped string', () => { + it('should report diagnostic on the entire expression of event binding if node contains escaped string', () => { env.write( 'test.ts', ` @@ -624,7 +624,7 @@ runInEachFileSystem(() => { expect(diags[0].messageText).toBe( `Argument of type 'string' is not assignable to parameter of type 'number'.`, ); - expect(getDiagnosticSourceCode(diags[0])).toBe(`'handleClick(\\'foo\\')'`); + expect(getDiagnosticSourceCode(diags[0])).toBe(`handleClick(\\'foo\\')`); }); it('should preserve diagnostic location of nodes that occur before escaped string', () => {