From c4556db9f57e8898d0059739ea397d6a67325398 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Thu, 27 Aug 2020 11:36:17 -0700 Subject: [PATCH] feat(compiler-cli): `TemplateTypeChecker` operation to get `Symbol` from a template node (#38618) Specifically, this commit adds support for retrieving a `Symbol` from a `TmplAstBoundEvent` or `TmplAstBoundAttribute`. Other template nodes will be supported in following commits. PR Close #38618 --- .../src/ngtsc/typecheck/api/checker.ts | 13 +- .../src/ngtsc/typecheck/src/checker.ts | 35 +- .../src/ngtsc/typecheck/src/comments.ts | 64 ++- .../src/ngtsc/typecheck/src/diagnostics.ts | 2 +- .../typecheck/src/template_symbol_builder.ts | 215 ++++++++ .../ngtsc/typecheck/src/type_check_block.ts | 4 +- .../src/ngtsc/typecheck/test/test_utils.ts | 10 + ...ecker__get_symbol_of_template_node_spec.ts | 490 ++++++++++++++++++ .../src/ngtsc/util/src/typescript.ts | 5 + 9 files changed, 827 insertions(+), 11 deletions(-) create mode 100644 packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts create mode 100644 packages/compiler-cli/src/ngtsc/typecheck/test/type_checker__get_symbol_of_template_node_spec.ts diff --git a/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts b/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts index 3eac4dfebd..843466a2e4 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts @@ -6,9 +6,11 @@ * found in the LICENSE file at https://angular.io/license */ -import {ParseError, TmplAstNode} from '@angular/compiler'; +import {AST, ParseError, TmplAstNode,} from '@angular/compiler'; import * as ts from 'typescript'; +import {Symbol} from './symbols'; + /** * Interface to the Angular Template Type Checker to extract diagnostics and intelligence from the * compiler's understanding of component templates. @@ -77,6 +79,15 @@ export interface TemplateTypeChecker { * This method always runs in `OptimizeFor.SingleFile` mode. */ getTypeCheckBlock(component: ts.ClassDeclaration): ts.Node|null; + + /** + * Retrieves a `Symbol` for the node in a component's template. + * + * This method can return `null` if a valid `Symbol` cannot be determined for the node. + * + * @see Symbol + */ + getSymbolOfNode(node: AST|TmplAstNode, component: ts.ClassDeclaration): Symbol|null; } /** diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts index db8ef3487a..5ef92a8a60 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {ParseError, parseTemplate, TmplAstNode} from '@angular/compiler'; +import {AST, ParseError, parseTemplate, TmplAstNode} from '@angular/compiler'; import * as ts from 'typescript'; import {absoluteFromSourceFile, AbsoluteFsPath, getSourceFileOrError} from '../../file_system'; @@ -15,12 +15,13 @@ import {IncrementalBuild} from '../../incremental/api'; import {ReflectionHost} from '../../reflection'; import {isShim} from '../../shims'; import {getSourceFileOrNull} from '../../util/src/typescript'; -import {OptimizeFor, ProgramTypeCheckAdapter, TemplateId, TemplateTypeChecker, TypeCheckingConfig, TypeCheckingProgramStrategy, UpdateMode} from '../api'; +import {OptimizeFor, ProgramTypeCheckAdapter, Symbol, TemplateId, TemplateTypeChecker, TypeCheckingConfig, TypeCheckingProgramStrategy, UpdateMode} from '../api'; import {TemplateDiagnostic} from '../diagnostics'; -import {InliningMode, ShimTypeCheckingData, TypeCheckContextImpl, TypeCheckingHost} from './context'; +import {InliningMode, ShimTypeCheckingData, TemplateData, TypeCheckContextImpl, TypeCheckingHost} from './context'; import {findTypeCheckBlock, shouldReportDiagnostic, TemplateSourceResolver, translateDiagnostic} from './diagnostics'; import {TemplateSourceManager} from './source'; +import {SymbolBuilder} from './template_symbol_builder'; /** * Primary template type-checking engine, which performs type-checking using a @@ -50,6 +51,14 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker { } getTemplate(component: ts.ClassDeclaration): TmplAstNode[]|null { + const templateData = this.getTemplateData(component); + if (templateData === null) { + return null; + } + return templateData.template; + } + + private getTemplateData(component: ts.ClassDeclaration): TemplateData|null { this.ensureShimForComponent(component); const sf = component.getSourceFile(); @@ -59,7 +68,7 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker { const fileRecord = this.getFileData(sfPath); if (!fileRecord.shimData.has(shimPath)) { - return []; + return null; } const templateId = fileRecord.sourceManager.getTemplateId(component); @@ -69,7 +78,7 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker { return null; } - return shimRecord.templates.get(templateId)!.template; + return shimRecord.templates.get(templateId)!; } overrideComponentTemplate(component: ts.ClassDeclaration, template: string): @@ -349,6 +358,22 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker { } return this.state.get(path)!; } + + getSymbolOfNode(node: AST|TmplAstNode, component: ts.ClassDeclaration): Symbol|null { + const tcb = this.getTypeCheckBlock(component); + if (tcb === null) { + return null; + } + + const typeChecker = this.typeCheckingStrategy.getProgram().getTypeChecker(); + const shimPath = this.typeCheckingStrategy.shimPathForComponent(component); + const data = this.getTemplateData(component); + if (data === null) { + return null; + } + + return new SymbolBuilder(typeChecker, shimPath, tcb, data).getSymbol(node); + } } function convertDiagnostic( diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/comments.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/comments.ts index 65bc50a7a0..3255b2dae2 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/comments.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/comments.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {AbsoluteSourceSpan} from '@angular/compiler'; +import {AbsoluteSourceSpan, ParseSourceSpan} from '@angular/compiler'; import * as ts from 'typescript'; const parseSpanComment = /^(\d+),(\d+)$/; @@ -17,7 +17,8 @@ const parseSpanComment = /^(\d+),(\d+)$/; * * Will return `null` if no trailing comments on the node match the expected form of a source span. */ -export function readSpanComment(sourceFile: ts.SourceFile, node: ts.Node): AbsoluteSourceSpan|null { +export function readSpanComment( + node: ts.Node, sourceFile: ts.SourceFile = node.getSourceFile()): AbsoluteSourceSpan|null { return ts.forEachTrailingCommentRange(sourceFile.text, node.getEnd(), (pos, end, kind) => { if (kind !== ts.SyntaxKind.MultiLineCommentTrivia) { return null; @@ -51,7 +52,7 @@ export function addExpressionIdentifier(node: ts.Node, identifier: ExpressionIde /* hasTrailingNewLine */ false); } -export const IGNORE_MARKER = `${CommentTriviaType.DIAGNOSTIC}:ignore`; +const IGNORE_MARKER = `${CommentTriviaType.DIAGNOSTIC}:ignore`; /** * Tag the `ts.Node` with an indication that any errors arising from the evaluation of the node @@ -72,3 +73,60 @@ export function hasIgnoreMarker(node: ts.Node, sourceFile: ts.SourceFile): boole return commentText === IGNORE_MARKER; }) === true; } + +function makeRecursiveVisitor(visitor: (node: ts.Node) => T | null): + (node: ts.Node) => T | undefined { + function recursiveVisitor(node: ts.Node): T|undefined { + const res = visitor(node); + return res !== null ? res : node.forEachChild(recursiveVisitor); + } + return recursiveVisitor; +} + +export interface FindOptions { + filter: (node: ts.Node) => node is T; + withSpan?: AbsoluteSourceSpan|ParseSourceSpan; +} + +/** + * Given a `ts.Node` with finds the first node whose matching the criteria specified + * by the `FindOptions`. + * + * Returns `null` when no `ts.Node` matches the given conditions. + */ +export function findFirstMatchingNode(tcb: ts.Node, opts: FindOptions): T| + null { + let withSpan: {start: number, end: number}|null = null; + if (opts.withSpan !== undefined) { + if (opts.withSpan instanceof AbsoluteSourceSpan) { + withSpan = opts.withSpan; + } else { + withSpan = {start: opts.withSpan.start.offset, end: opts.withSpan.end.offset}; + } + } + const sf = tcb.getSourceFile(); + const visitor = makeRecursiveVisitor(node => { + if (!opts.filter(node)) { + return null; + } + if (withSpan !== null) { + const comment = readSpanComment(node, sf); + if (comment === null || withSpan.start !== comment.start || withSpan.end !== comment.end) { + return null; + } + } + return node; + }); + return tcb.forEachChild(visitor) ?? null; +} + +export function hasExpressionIdentifier( + sourceFile: ts.SourceFile, node: ts.Node, identifier: ExpressionIdentifier): boolean { + return ts.forEachTrailingCommentRange(sourceFile.text, node.getEnd(), (pos, end, kind) => { + if (kind !== ts.SyntaxKind.MultiLineCommentTrivia) { + return false; + } + const commentText = sourceFile.text.substring(pos + 2, end - 2); + return commentText === `${CommentTriviaType.EXPRESSION_TYPE_IDENTIFIER}:${identifier}`; + }) || false; +} diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/diagnostics.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/diagnostics.ts index f80155bf21..ff8be74968 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/diagnostics.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/diagnostics.ts @@ -162,7 +162,7 @@ function findSourceLocation(node: ts.Node, sourceFile: ts.SourceFile): SourceLoc return null; } - const span = readSpanComment(sourceFile, node); + const span = readSpanComment(node, sourceFile); if (span !== null) { // Once the positional information has been extracted, search further up the TCB to extract // the unique id that is attached with the TCB's function declaration. diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts new file mode 100644 index 0000000000..37bca8f8bc --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts @@ -0,0 +1,215 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {AST, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstElement, TmplAstNode, TmplAstTemplate} from '@angular/compiler'; +import * as ts from 'typescript'; + +import {AbsoluteFsPath} from '../../file_system'; +import {isAssignment} from '../../util/src/typescript'; +import {DirectiveSymbol, ElementSymbol, ExpressionSymbol, InputBindingSymbol, OutputBindingSymbol, ReferenceSymbol, Symbol, SymbolKind, TsNodeSymbolInfo, VariableSymbol} from '../api'; + +import {ExpressionIdentifier, findFirstMatchingNode, hasExpressionIdentifier} from './comments'; +import {TemplateData} from './context'; +import {TcbDirectiveOutputsOp} from './type_check_block'; + +/** + * A class which extracts information from a type check block. + * This class is essentially used as just a closure around the constructor parameters. + */ +export class SymbolBuilder { + constructor( + private readonly typeChecker: ts.TypeChecker, private readonly shimPath: AbsoluteFsPath, + private readonly typeCheckBlock: ts.Node, private readonly templateData: TemplateData) {} + + getSymbol(node: AST|TmplAstNode): Symbol|null { + if (node instanceof TmplAstBoundAttribute) { + // TODO(atscott): input and output bindings only return the first directive match but should + // return a list of bindings for all of them. + return this.getSymbolOfInputBinding(node); + } else if (node instanceof TmplAstBoundEvent) { + return this.getSymbolOfBoundEvent(node); + } + return null; + } + + private getSymbolOfBoundEvent(eventBinding: TmplAstBoundEvent): OutputBindingSymbol|null { + // Outputs are a `ts.CallExpression` that look like one of the two: + // * _outputHelper(_t1["outputField"]).subscribe(handler); + // * _t1.addEventListener(handler); + const node = findFirstMatchingNode( + this.typeCheckBlock, {withSpan: eventBinding.sourceSpan, filter: ts.isCallExpression}); + if (node === null) { + return null; + } + + const consumer = this.templateData.boundTarget.getConsumerOfBinding(eventBinding); + if (consumer instanceof TmplAstTemplate || consumer instanceof TmplAstElement) { + // Bindings to element or template events produce `addEventListener` which + // we cannot get the field for. + return null; + } + const outputFieldAccess = TcbDirectiveOutputsOp.decodeOutputCallExpression(node); + if (outputFieldAccess === null) { + return null; + } + + const tsSymbol = this.typeChecker.getSymbolAtLocation(outputFieldAccess.argumentExpression); + if (tsSymbol === undefined) { + return null; + } + + + const target = this.getDirectiveSymbolForAccessExpression(outputFieldAccess); + if (target === null) { + return null; + } + + const positionInShimFile = outputFieldAccess.argumentExpression.getStart(); + const tsType = this.typeChecker.getTypeAtLocation(node); + return { + kind: SymbolKind.Output, + bindings: [{ + kind: SymbolKind.Binding, + tsSymbol, + tsType, + target, + shimLocation: {shimPath: this.shimPath, positionInShimFile}, + }], + }; + } + + private getSymbolOfInputBinding(attributeBinding: TmplAstBoundAttribute): InputBindingSymbol + |null { + const node = findFirstMatchingNode( + this.typeCheckBlock, {withSpan: attributeBinding.sourceSpan, filter: isAssignment}); + if (node === null) { + return null; + } + + let tsSymbol: ts.Symbol|undefined; + let positionInShimFile: number|null = null; + let tsType: ts.Type; + if (ts.isElementAccessExpression(node.left)) { + tsSymbol = this.typeChecker.getSymbolAtLocation(node.left.argumentExpression); + positionInShimFile = node.left.argumentExpression.getStart(); + tsType = this.typeChecker.getTypeAtLocation(node.left.argumentExpression); + } else if (ts.isPropertyAccessExpression(node.left)) { + tsSymbol = this.typeChecker.getSymbolAtLocation(node.left.name); + positionInShimFile = node.left.name.getStart(); + tsType = this.typeChecker.getTypeAtLocation(node.left.name); + } else { + return null; + } + if (tsSymbol === undefined || positionInShimFile === null) { + return null; + } + + const consumer = this.templateData.boundTarget.getConsumerOfBinding(attributeBinding); + let target: ElementSymbol|DirectiveSymbol|null; + if (consumer instanceof TmplAstTemplate || consumer instanceof TmplAstElement) { + // TODO(atscott): handle bindings to elements and templates + target = null; + } else { + target = this.getDirectiveSymbolForAccessExpression(node.left); + } + + if (target === null) { + return null; + } + + return { + kind: SymbolKind.Input, + bindings: [{ + kind: SymbolKind.Binding, + tsSymbol, + tsType, + target, + shimLocation: {shimPath: this.shimPath, positionInShimFile}, + }], + }; + } + + private getDirectiveSymbolForAccessExpression(node: ts.ElementAccessExpression| + ts.PropertyAccessExpression): DirectiveSymbol|null { + // In either case, `_t1["index"]` or `_t1.index`, `node.expression` is _t1. + // The retrieved symbol for _t1 will be the variable declaration. + const tsSymbol = this.typeChecker.getSymbolAtLocation(node.expression); + if (tsSymbol === undefined || tsSymbol.declarations.length === 0) { + return null; + } + + 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.getSymbolOfVariableDeclaration(declaration); + if (symbol === null || symbol.tsSymbol === null || symbol.tsType === null) { + return null; + } + + return { + ...symbol, + kind: SymbolKind.Directive, + tsSymbol: symbol.tsSymbol, + tsType: symbol.tsType, + }; + } + + private getSymbolOfTsNode(node: ts.Node): TsNodeSymbolInfo|null { + while (ts.isParenthesizedExpression(node)) { + node = node.expression; + } + + let tsSymbol: ts.Symbol|undefined; + let positionInShimFile: number; + if (ts.isPropertyAccessExpression(node)) { + tsSymbol = this.typeChecker.getSymbolAtLocation(node.name); + positionInShimFile = node.name.getStart(); + } else { + tsSymbol = this.typeChecker.getSymbolAtLocation(node); + positionInShimFile = node.getStart(); + } + + const type = this.typeChecker.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. + // One example of this would be literals. + tsSymbol: tsSymbol ?? type.symbol ?? null, + tsType: type, + shimLocation: {shimPath: this.shimPath, positionInShimFile}, + }; + } + + private getSymbolOfVariableDeclaration(declaration: ts.VariableDeclaration): TsNodeSymbolInfo + |null { + // Instead of returning the Symbol for the temporary variable, we want to get the `ts.Symbol` + // for: + // - The type reference for `var _t2: MyDir = xyz` (prioritize/trust the declared type) + // - The initializer for `var _t2 = _t1.index`. + if (declaration.type && ts.isTypeReferenceNode(declaration.type)) { + return this.getSymbolOfTsNode(declaration.type.typeName); + } + if (declaration.initializer === undefined) { + return null; + } + + const symbol = this.getSymbolOfTsNode(declaration.initializer); + if (symbol === null) { + return null; + } + + return symbol; + } +} diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts index 4229892fa7..bb2ba89098 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts @@ -376,8 +376,8 @@ class TcbDirectiveTypeOp extends TcbOp { const id = this.tcb.allocateId(); const type = this.tcb.env.referenceType(this.dir.ref); - addParseSpanInfo(type, this.node.startSourceSpan || this.node.sourceSpan); addExpressionIdentifier(type, ExpressionIdentifier.DIRECTIVE); + addParseSpanInfo(type, this.node.startSourceSpan || this.node.sourceSpan); this.scope.addStatement(tsDeclareVariable(id, type)); return id; } @@ -477,6 +477,8 @@ class TcbDirectiveCtorOp extends TcbOp { execute(): ts.Identifier { const id = this.tcb.allocateId(); + addExpressionIdentifier(id, ExpressionIdentifier.DIRECTIVE); + addParseSpanInfo(id, this.node.startSourceSpan || this.node.sourceSpan); const genericInputs = new Map(); diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts index 88bb3be900..253e5b8193 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts @@ -153,6 +153,16 @@ export function ngForDts(): TestFile { }; } +export function ngForTypeCheckTarget(): TypeCheckingTarget { + const dts = ngForDts(); + return { + ...dts, + fileName: dts.name, + source: dts.contents, + templates: {}, + }; +} + export const ALL_ENABLED_CONFIG: TypeCheckingConfig = { applyTemplateContextGuards: true, checkQueries: false, diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/type_checker__get_symbol_of_template_node_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/type_checker__get_symbol_of_template_node_spec.ts new file mode 100644 index 0000000000..def71f70ba --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/type_checker__get_symbol_of_template_node_spec.ts @@ -0,0 +1,490 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {TmplAstBoundAttribute, TmplAstElement, TmplAstTemplate} from '@angular/compiler'; +import * as ts from 'typescript'; + +import {absoluteFrom, getSourceFileOrError} from '../../file_system'; +import {runInEachFileSystem} from '../../file_system/testing'; +import {InputBindingSymbol, OutputBindingSymbol, Symbol, SymbolKind, TypeCheckingConfig} from '../api'; + +import {getClass, ngForDeclaration, ngForTypeCheckTarget, setup as baseTestSetup, TypeCheckingTarget} from './test_utils'; + +runInEachFileSystem(() => { + describe('TemplateTypeChecker.getSymbolOfNodeInComponentTemplate', () => { + describe('input bindings', () => { + it('can retrieve a symbol for an input binding', () => { + const fileName = absoluteFrom('/main.ts'); + const dirFile = absoluteFrom('/dir.ts'); + const templateString = + `
`; + const {program, templateTypeChecker} = setup([ + { + fileName, + templates: {'Cmp': templateString}, + declarations: [{ + name: 'TestDir', + selector: '[dir]', + file: dirFile, + type: 'directive', + inputs: {inputA: 'inputA', inputB: 'inputBRenamed'}, + }] + }, + { + fileName: dirFile, + source: `export class TestDir {inputA!: string; inputB!: string}`, + templates: {}, + } + ]); + const sf = getSourceFileOrError(program, fileName); + const cmp = getClass(sf, 'Cmp'); + + const nodes = templateTypeChecker.getTemplate(cmp)!; + + const inputAbinding = (nodes[0] as TmplAstElement).inputs[0]; + const aSymbol = templateTypeChecker.getSymbolOfNode(inputAbinding, cmp)!; + assertInputBindingSymbol(aSymbol); + expect((aSymbol.bindings[0].tsSymbol!.declarations[0] as ts.PropertyDeclaration) + .name.getText()) + .toEqual('inputA'); + + const inputBbinding = (nodes[0] as TmplAstElement).inputs[1]; + const bSymbol = templateTypeChecker.getSymbolOfNode(inputBbinding, cmp)!; + // TODO(atscott): The BoundTarget is not assigning renamed properties correctly + // assertInputBindingSymbol(bSymbol); + // expect((bSymbol.bindings[0].tsSymbol!.declarations[0] as ts.PropertyDeclaration) + // .name.getText()) + // .toEqual('inputB'); + }); + + it('does not retrieve a symbol for an input when undeclared', () => { + const fileName = absoluteFrom('/main.ts'); + const dirFile = absoluteFrom('/dir.ts'); + const templateString = `
`; + const {program, templateTypeChecker} = setup([ + { + fileName, + templates: {'Cmp': templateString}, + declarations: [{ + name: 'TestDir', + selector: '[dir]', + file: dirFile, + type: 'directive', + inputs: {inputA: 'inputA'}, + }] + }, + { + fileName: dirFile, + source: `export class TestDir {}`, + templates: {}, + } + ]); + const sf = getSourceFileOrError(program, fileName); + const cmp = getClass(sf, 'Cmp'); + + const nodes = templateTypeChecker.getTemplate(cmp)!; + + const inputAbinding = (nodes[0] as TmplAstElement).inputs[0]; + const aSymbol = templateTypeChecker.getSymbolOfNode(inputAbinding, cmp)!; + expect(aSymbol).toBeNull(); + }); + + it('can retrieve a symbol for an input of structural directive', () => { + const fileName = absoluteFrom('/main.ts'); + const templateString = `
`; + const {program, templateTypeChecker} = setup([ + {fileName, templates: {'Cmp': templateString}, declarations: [ngForDeclaration()]}, + ngForTypeCheckTarget(), + ]); + const sf = getSourceFileOrError(program, fileName); + const cmp = getClass(sf, 'Cmp'); + + const nodes = templateTypeChecker.getTemplate(cmp)!; + + const ngForOfBinding = + (nodes[0] as TmplAstTemplate).templateAttrs.find(a => a.name === 'ngForOf')! as + TmplAstBoundAttribute; + const symbol = templateTypeChecker.getSymbolOfNode(ngForOfBinding, cmp)!; + assertInputBindingSymbol(symbol); + expect( + (symbol.bindings[0].tsSymbol!.declarations[0] as ts.PropertyDeclaration).name.getText()) + .toEqual('ngForOf'); + }); + + it('returns empty list when there is no directive registered for the binding', () => { + const fileName = absoluteFrom('/main.ts'); + const templateString = `
`; + const {program, templateTypeChecker} = setup([ + {fileName, templates: {'Cmp': templateString}, declarations: []}, + ]); + const sf = getSourceFileOrError(program, fileName); + const cmp = getClass(sf, 'Cmp'); + + const nodes = templateTypeChecker.getTemplate(cmp)!; + const binding = (nodes[0] as TmplAstElement).inputs[0]; + + const symbol = templateTypeChecker.getSymbolOfNode(binding, cmp); + expect(symbol).toBeNull(); + }); + + it('returns empty list when directive members do not match the input', () => { + const fileName = absoluteFrom('/main.ts'); + const dirFile = absoluteFrom('/dir.ts'); + const templateString = `
`; + const {program, templateTypeChecker} = setup([ + { + fileName, + templates: {'Cmp': templateString}, + declarations: [{ + name: 'TestDir', + selector: '[dir]', + file: dirFile, + type: 'directive', + inputs: {}, + }] + }, + { + fileName: dirFile, + source: `export class TestDir {}`, + templates: {}, + } + ]); + const sf = getSourceFileOrError(program, fileName); + const cmp = getClass(sf, 'Cmp'); + + const nodes = templateTypeChecker.getTemplate(cmp)!; + + const inputAbinding = (nodes[0] as TmplAstElement).inputs[0]; + const symbol = templateTypeChecker.getSymbolOfNode(inputAbinding, cmp); + expect(symbol).toBeNull(); + }); + + it('can match binding when there are two directives', () => { + const fileName = absoluteFrom('/main.ts'); + const dirFile = absoluteFrom('/dir.ts'); + const templateString = `
`; + const {program, templateTypeChecker} = setup([ + { + fileName, + templates: {'Cmp': templateString}, + declarations: [ + { + name: 'TestDir', + selector: '[dir]', + file: dirFile, + type: 'directive', + inputs: {inputA: 'inputA'}, + }, + { + name: 'OtherDir', + selector: '[otherDir]', + file: dirFile, + type: 'directive', + inputs: {}, + } + ] + }, + { + fileName: dirFile, + source: ` + export class TestDir {inputA!: string;} + export class OtherDir {} + `, + templates: {}, + } + ]); + const sf = getSourceFileOrError(program, fileName); + const cmp = getClass(sf, 'Cmp'); + + const nodes = templateTypeChecker.getTemplate(cmp)!; + + const inputAbinding = (nodes[0] as TmplAstElement).inputs[0]; + const symbol = templateTypeChecker.getSymbolOfNode(inputAbinding, cmp)!; + assertInputBindingSymbol(symbol); + expect( + (symbol.bindings[0].tsSymbol!.declarations[0] as ts.PropertyDeclaration).name.getText()) + .toEqual('inputA'); + expect((symbol.bindings[0].tsSymbol!.declarations[0] as ts.PropertyDeclaration) + .parent.name?.text) + .toEqual('TestDir'); + }); + + it('returns the first field match when directive maps same input to two fields', () => { + const fileName = absoluteFrom('/main.ts'); + const dirFile = absoluteFrom('/dir.ts'); + const {program, templateTypeChecker} = setup([ + { + fileName, + templates: {'Cmp': `
`}, + declarations: [ + { + name: 'TestDir', + selector: '[dir]', + file: dirFile, + type: 'directive', + inputs: {inputA: 'inputA', otherInputA: 'inputA'}, + }, + ] + }, + { + fileName: dirFile, + source: ` + export class TestDir {inputA!: string; otherInputA!: string;} + `, + templates: {}, + } + ]); + const sf = getSourceFileOrError(program, fileName); + const cmp = getClass(sf, 'Cmp'); + + const nodes = templateTypeChecker.getTemplate(cmp)!; + + const inputAbinding = (nodes[0] as TmplAstElement).inputs[0]; + const symbol = templateTypeChecker.getSymbolOfNode(inputAbinding, cmp)!; + assertInputBindingSymbol(symbol); + expect( + (symbol.bindings[0].tsSymbol!.declarations[0] as ts.PropertyDeclaration).name.getText()) + .toEqual('otherInputA'); + expect((symbol.bindings[0].tsSymbol!.declarations[0] as ts.PropertyDeclaration) + .parent.name?.text) + .toEqual('TestDir'); + }); + + it('returns the first directive match when two directives have the same input', () => { + const fileName = absoluteFrom('/main.ts'); + const dirFile = absoluteFrom('/dir.ts'); + const templateString = `
`; + const {program, templateTypeChecker} = setup([ + { + fileName, + templates: {'Cmp': templateString}, + declarations: [ + { + name: 'TestDir', + selector: '[dir]', + file: dirFile, + type: 'directive', + inputs: {inputA: 'inputA'}, + }, + { + name: 'OtherDir', + selector: '[otherDir]', + file: dirFile, + type: 'directive', + inputs: {otherDirInputA: 'inputA'}, + } + ] + }, + { + fileName: dirFile, + source: ` + export class TestDir {inputA!: string;} + export class OtherDir {otherDirInputA!: string;} + `, + templates: {}, + } + ]); + const sf = getSourceFileOrError(program, fileName); + const cmp = getClass(sf, 'Cmp'); + + const nodes = templateTypeChecker.getTemplate(cmp)!; + + const inputAbinding = (nodes[0] as TmplAstElement).inputs[0]; + const symbol = templateTypeChecker.getSymbolOfNode(inputAbinding, cmp)!; + assertInputBindingSymbol(symbol); + expect( + (symbol.bindings[0].tsSymbol!.declarations[0] as ts.PropertyDeclaration).name.getText()) + .toEqual('inputA'); + expect((symbol.bindings[0].tsSymbol!.declarations[0] as ts.PropertyDeclaration) + .parent.name?.text) + .toEqual('TestDir'); + }); + }); + + describe('output bindings', () => { + it('should find symbol for output binding', () => { + const fileName = absoluteFrom('/main.ts'); + const dirFile = absoluteFrom('/dir.ts'); + const templateString = + `
`; + const {program, templateTypeChecker} = setup([ + { + fileName, + templates: {'Cmp': templateString}, + declarations: [ + { + name: 'TestDir', + selector: '[dir]', + file: dirFile, + type: 'directive', + outputs: {outputA: 'outputA', outputB: 'renamedOutputB'}, + }, + ] + }, + { + fileName: dirFile, + source: ` + export class TestDir {outputA!: EventEmitter; outputB!: EventEmitter} + `, + templates: {}, + } + ]); + const sf = getSourceFileOrError(program, fileName); + const cmp = getClass(sf, 'Cmp'); + + const nodes = templateTypeChecker.getTemplate(cmp)!; + + const outputABinding = (nodes[0] as TmplAstElement).outputs[0]; + const aSymbol = templateTypeChecker.getSymbolOfNode(outputABinding, cmp)!; + assertOutputBindingSymbol(aSymbol); + expect((aSymbol.bindings[0].tsSymbol!.declarations[0] as ts.PropertyDeclaration) + .name.getText()) + .toEqual('outputA'); + + const outputBBinding = (nodes[0] as TmplAstElement).outputs[1]; + const bSymbol = templateTypeChecker.getSymbolOfNode(outputBBinding, cmp)!; + // TODO(atscott): The BoundTarget is not assigning renamed properties correctly + // assertOutputBindingSymbol(bSymbol); + // expect((bSymbol.bindings[0].tsSymbol!.declarations[0] as ts.PropertyDeclaration) + // .name.getText()) + // .toEqual('outputB'); + }); + + it('should find symbol for output binding when there are multiple directives', () => { + const fileName = absoluteFrom('/main.ts'); + const dirFile = absoluteFrom('/dir.ts'); + const {program, templateTypeChecker} = setup([ + { + fileName, + templates: {'Cmp': `
`}, + declarations: [ + { + name: 'TestDir', + selector: '[dir]', + file: dirFile, + type: 'directive', + outputs: {outputA: 'outputA'}, + }, + { + name: 'OtherDir', + selector: '[otherdir]', + file: dirFile, + type: 'directive', + outputs: {unusedOutput: 'unusedOutput'}, + }, + ] + }, + { + fileName: dirFile, + source: ` + export class TestDir {outputA!: EventEmitter;} + export class OtherDir {unusedOutput!: EventEmitter;} + `, + templates: {}, + } + ]); + const sf = getSourceFileOrError(program, fileName); + const cmp = getClass(sf, 'Cmp'); + + const nodes = templateTypeChecker.getTemplate(cmp)!; + + const outputABinding = (nodes[0] as TmplAstElement).outputs[0]; + const symbol = templateTypeChecker.getSymbolOfNode(outputABinding, cmp)!; + assertOutputBindingSymbol(symbol); + expect( + (symbol.bindings[0].tsSymbol!.declarations[0] as ts.PropertyDeclaration).name.getText()) + .toEqual('outputA'); + expect((symbol.bindings[0].tsSymbol!.declarations[0] as ts.PropertyDeclaration) + .parent.name?.text) + .toEqual('TestDir'); + }); + + it('returns empty list when binding does not match any directive output', () => { + const fileName = absoluteFrom('/main.ts'); + const dirFile = absoluteFrom('/dir.ts'); + const {program, templateTypeChecker} = setup([ + { + fileName, + templates: {'Cmp': `
`}, + declarations: [ + { + name: 'TestDir', + selector: '[dir]', + file: dirFile, + type: 'directive', + outputs: {outputA: 'outputA'}, + }, + ] + }, + { + fileName: dirFile, + source: `export class TestDir {outputA!: EventEmitter;}`, + templates: {}, + } + ]); + const sf = getSourceFileOrError(program, fileName); + const cmp = getClass(sf, 'Cmp'); + + const nodes = templateTypeChecker.getTemplate(cmp)!; + + const outputABinding = (nodes[0] as TmplAstElement).outputs[0]; + const symbol = templateTypeChecker.getSymbolOfNode(outputABinding, cmp); + expect(symbol).toBeNull(); + }); + + it('returns empty list when checkTypeOfOutputEvents is false', () => { + const fileName = absoluteFrom('/main.ts'); + const dirFile = absoluteFrom('/dir.ts'); + const {program, templateTypeChecker} = setup( + [ + { + fileName, + templates: {'Cmp': `
`}, + declarations: [ + { + name: 'TestDir', + selector: '[dir]', + file: dirFile, + type: 'directive', + outputs: {outputA: 'outputA'}, + }, + ] + }, + { + fileName: dirFile, + source: `export class TestDir {outputA!: EventEmitter;}`, + templates: {}, + } + ], + {checkTypeOfOutputEvents: false}); + const sf = getSourceFileOrError(program, fileName); + const cmp = getClass(sf, 'Cmp'); + + const nodes = templateTypeChecker.getTemplate(cmp)!; + + const outputABinding = (nodes[0] as TmplAstElement).outputs[0]; + const symbol = templateTypeChecker.getSymbolOfNode(outputABinding, cmp); + // TODO(atscott): should type checker still generate the subscription in this case? + expect(symbol).toBeNull(); + }); + }); + }); +}); + +function assertInputBindingSymbol(tSymbol: Symbol): asserts tSymbol is InputBindingSymbol { + expect(tSymbol.kind).toEqual(SymbolKind.Input); +} + +function assertOutputBindingSymbol(tSymbol: Symbol): asserts tSymbol is OutputBindingSymbol { + expect(tSymbol.kind).toEqual(SymbolKind.Output); +} + +export function setup(targets: TypeCheckingTarget[], config?: Partial) { + return baseTestSetup( + targets, {inlining: false, config: {...config, enableTemplateTypeChecker: true}}); +} diff --git a/packages/compiler-cli/src/ngtsc/util/src/typescript.ts b/packages/compiler-cli/src/ngtsc/util/src/typescript.ts index 13a90d3a3e..346c47773f 100644 --- a/packages/compiler-cli/src/ngtsc/util/src/typescript.ts +++ b/packages/compiler-cli/src/ngtsc/util/src/typescript.ts @@ -139,6 +139,11 @@ export function resolveModuleName( } } +/** Returns true if the node is an assignment expression. */ +export function isAssignment(node: ts.Node): node is ts.BinaryExpression { + return ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken; +} + /** * Asserts that the keys `K` form a subset of the keys of `T`. */