From cf2e8b99a82b039844b6b91041ba64a4829ab0e3 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Thu, 27 Aug 2020 13:35:34 -0700 Subject: [PATCH] feat(compiler-cli): Add ability to get `Symbol` of `Template`s and `Element`s in component template (#38618) Adds support to the `TemplateTypeChecker` for retrieving a `Symbol` for `TmplAstTemplate` and `TmplAstElement` nodes in a component template. PR Close #38618 --- .../src/ngtsc/typecheck/src/comments.ts | 56 +++++- .../typecheck/src/template_symbol_builder.ts | 71 ++++++- ...ecker__get_symbol_of_template_node_spec.ts | 174 +++++++++++++++++- 3 files changed, 283 insertions(+), 18 deletions(-) diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/comments.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/comments.ts index 3255b2dae2..bf74b313e9 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/comments.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/comments.ts @@ -88,14 +88,7 @@ export interface FindOptions { 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 { +function getSpanFromOptions(opts: FindOptions) { let withSpan: {start: number, end: number}|null = null; if (opts.withSpan !== undefined) { if (opts.withSpan instanceof AbsoluteSourceSpan) { @@ -104,6 +97,18 @@ export function findFirstMatchingNode(tcb: ts.Node, opts: Fin withSpan = {start: opts.withSpan.start.offset, end: opts.withSpan.end.offset}; } } + return withSpan; +} + +/** + * 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 { + const withSpan = getSpanFromOptions(opts); const sf = tcb.getSourceFile(); const visitor = makeRecursiveVisitor(node => { if (!opts.filter(node)) { @@ -120,6 +125,41 @@ export function findFirstMatchingNode(tcb: ts.Node, opts: Fin return tcb.forEachChild(visitor) ?? null; } +/** + * Given a `ts.Node` with source span comments, finds the first node whose source span comment + * matches the given `sourceSpan`. Additionally, the `filter` function allows matching only + * `ts.Nodes` of a given type, which provides the ability to select only matches of a given type + * when there may be more than one. + * + * Returns `null` when no `ts.Node` matches the given conditions. + */ +export function findAllMatchingNodes(tcb: ts.Node, opts: FindOptions): T[] { + const withSpan = getSpanFromOptions(opts); + const results: T[] = []; + const stack: ts.Node[] = [tcb]; + const sf = tcb.getSourceFile(); + + while (stack.length > 0) { + const node = stack.pop()!; + + if (!opts.filter(node)) { + stack.push(...node.getChildren()); + continue; + } + if (withSpan !== null) { + const comment = readSpanComment(node, sf); + if (comment === null || withSpan.start !== comment.start || withSpan.end !== comment.end) { + stack.push(...node.getChildren()); + continue; + } + } + + results.push(node); + } + + return results; +} + export function hasExpressionIdentifier( sourceFile: ts.SourceFile, node: ts.Node, identifier: ExpressionIdentifier): boolean { return ts.forEachTrailingCommentRange(sourceFile.text, node.getEnd(), (pos, end, kind) => { 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 index 37bca8f8bc..4312eaa2ab 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts @@ -11,12 +11,13 @@ 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 {DirectiveSymbol, ElementSymbol, ExpressionSymbol, InputBindingSymbol, OutputBindingSymbol, Symbol, SymbolKind, TemplateSymbol, TsNodeSymbolInfo} from '../api'; -import {ExpressionIdentifier, findFirstMatchingNode, hasExpressionIdentifier} from './comments'; +import {ExpressionIdentifier, findAllMatchingNodes, 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. @@ -26,6 +27,8 @@ export class SymbolBuilder { private readonly typeChecker: ts.TypeChecker, private readonly shimPath: AbsoluteFsPath, private readonly typeCheckBlock: ts.Node, private readonly templateData: TemplateData) {} + getSymbol(node: TmplAstTemplate|TmplAstElement): TemplateSymbol|ElementSymbol|null; + getSymbol(node: AST|TmplAstNode): Symbol|null; getSymbol(node: AST|TmplAstNode): Symbol|null { if (node instanceof TmplAstBoundAttribute) { // TODO(atscott): input and output bindings only return the first directive match but should @@ -33,10 +36,65 @@ export class SymbolBuilder { return this.getSymbolOfInputBinding(node); } else if (node instanceof TmplAstBoundEvent) { return this.getSymbolOfBoundEvent(node); + } else if (node instanceof TmplAstElement) { + return this.getSymbolOfElement(node); + } else if (node instanceof TmplAstTemplate) { + return this.getSymbolOfAstTemplate(node); } return null; } + private getSymbolOfAstTemplate(template: TmplAstTemplate): TemplateSymbol|null { + const directives = this.getDirectivesOfNode(template); + return {kind: SymbolKind.Template, directives}; + } + + private getSymbolOfElement(element: TmplAstElement): ElementSymbol|null { + const elementSourceSpan = element.startSourceSpan ?? element.sourceSpan; + + const node = findFirstMatchingNode( + this.typeCheckBlock, {withSpan: elementSourceSpan, filter: ts.isVariableDeclaration}); + if (node === null) { + return null; + } + + const symbolFromDeclaration = this.getSymbolOfVariableDeclaration(node); + if (symbolFromDeclaration === null || symbolFromDeclaration.tsSymbol === null) { + return null; + } + + 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, + directives, + }; + } + + private getDirectivesOfNode(element: TmplAstElement|TmplAstTemplate): DirectiveSymbol[] { + const elementSourceSpan = element.startSourceSpan ?? element.sourceSpan; + const tcbSourceFile = this.typeCheckBlock.getSourceFile(); + const isDirectiveDeclaration = (node: ts.Node): node is ts.TypeNode => ts.isTypeNode(node) && + hasExpressionIdentifier(tcbSourceFile, node, ExpressionIdentifier.DIRECTIVE); + + const nodes = findAllMatchingNodes( + this.typeCheckBlock, {withSpan: elementSourceSpan, filter: isDirectiveDeclaration}); + return nodes + .map(node => { + const symbol = this.getSymbolOfTsNode(node); + if (symbol === null || symbol.tsSymbol === null) { + return null; + } + const directiveSymbol: + DirectiveSymbol = {...symbol, tsSymbol: symbol.tsSymbol, kind: SymbolKind.Directive}; + return directiveSymbol; + }) + .filter((d): d is DirectiveSymbol => d !== null); + } + private getSymbolOfBoundEvent(eventBinding: TmplAstBoundEvent): OutputBindingSymbol|null { // Outputs are a `ts.CallExpression` that look like one of the two: // * _outputHelper(_t1["outputField"]).subscribe(handler); @@ -110,10 +168,9 @@ export class SymbolBuilder { } const consumer = this.templateData.boundTarget.getConsumerOfBinding(attributeBinding); - let target: ElementSymbol|DirectiveSymbol|null; + let target: ElementSymbol|TemplateSymbol|DirectiveSymbol|null; if (consumer instanceof TmplAstTemplate || consumer instanceof TmplAstElement) { - // TODO(atscott): handle bindings to elements and templates - target = null; + target = this.getSymbol(consumer); } else { target = this.getDirectiveSymbolForAccessExpression(node.left); } @@ -154,15 +211,15 @@ export class SymbolBuilder { } const symbol = this.getSymbolOfVariableDeclaration(declaration); - if (symbol === null || symbol.tsSymbol === null || symbol.tsType === null) { + if (symbol === null || symbol.tsSymbol === null) { return null; } return { - ...symbol, kind: SymbolKind.Directive, tsSymbol: symbol.tsSymbol, tsType: symbol.tsType, + shimLocation: symbol.shimLocation, }; } 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 index def71f70ba..5e5efbf0f4 100644 --- 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 @@ -6,17 +6,69 @@ * found in the LICENSE file at https://angular.io/license */ -import {TmplAstBoundAttribute, TmplAstElement, TmplAstTemplate} from '@angular/compiler'; +import {TmplAstBoundAttribute, TmplAstElement, TmplAstNode, 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 {ClassDeclaration} from '../../reflection'; +import {DirectiveSymbol, ElementSymbol, InputBindingSymbol, OutputBindingSymbol, Symbol, SymbolKind, TemplateSymbol, TemplateTypeChecker, TypeCheckingConfig} from '../api'; import {getClass, ngForDeclaration, ngForTypeCheckTarget, setup as baseTestSetup, TypeCheckingTarget} from './test_utils'; runInEachFileSystem(() => { - describe('TemplateTypeChecker.getSymbolOfNodeInComponentTemplate', () => { + describe('TemplateTypeChecker.getSymbolOfNode', () => { + describe('templates', () => { + describe('ng-templates', () => { + let templateTypeChecker: TemplateTypeChecker; + let cmp: ClassDeclaration; + let templateNode: TmplAstTemplate; + let program: ts.Program; + + beforeEach(() => { + const fileName = absoluteFrom('/main.ts'); + const dirFile = absoluteFrom('/dir.ts'); + const templateString = ` + +
+
`; + const testValues = setup([ + { + fileName, + templates: {'Cmp': templateString}, + source: ` + export class Cmp { }`, + declarations: [{ + name: 'TestDir', + selector: '[dir]', + file: dirFile, + type: 'directive', + exportAs: ['dir'], + }] + }, + { + fileName: dirFile, + source: `export class TestDir {}`, + templates: {}, + } + ]); + templateTypeChecker = testValues.templateTypeChecker; + program = testValues.program; + const sf = getSourceFileOrError(testValues.program, fileName); + cmp = getClass(sf, 'Cmp'); + templateNode = getAstTemplates(templateTypeChecker, cmp)[0]; + }); + + it('should get symbol for the template itself', () => { + const symbol = templateTypeChecker.getSymbolOfNode(templateNode, cmp)!; + assertTemplateSymbol(symbol); + expect(symbol.directives.length).toBe(1); + assertDirectiveSymbol(symbol.directives[0]); + expect(symbol.directives[0].tsSymbol.getName()).toBe('TestDir'); + }); + }); + }); + describe('input bindings', () => { it('can retrieve a symbol for an input binding', () => { const fileName = absoluteFrom('/main.ts'); @@ -473,9 +525,117 @@ runInEachFileSystem(() => { expect(symbol).toBeNull(); }); }); + + describe('for elements', () => { + it('for elements that are components with no inputs', () => { + const fileName = absoluteFrom('/main.ts'); + const dirFile = absoluteFrom('/dir.ts'); + const {program, templateTypeChecker} = setup( + [ + { + fileName, + templates: {'Cmp': ``}, + declarations: [ + { + name: 'ChildComponent', + selector: 'child-component', + file: dirFile, + type: 'directive', + }, + ] + }, + { + fileName: dirFile, + source: ` + export class ChildComponent {} + `, + templates: {'ChildComponent': ''}, + } + ], + ); + const sf = getSourceFileOrError(program, fileName); + const cmp = getClass(sf, 'Cmp'); + + const nodes = templateTypeChecker.getTemplate(cmp)!; + + const symbol = templateTypeChecker.getSymbolOfNode(nodes[0] as TmplAstElement, cmp)!; + assertElementSymbol(symbol); + expect(symbol.directives.length).toBe(1); + assertDirectiveSymbol(symbol.directives[0]); + expect(program.getTypeChecker().typeToString(symbol.directives[0].tsType)) + .toEqual('ChildComponent'); + }); + + it('element with directive matches', () => { + 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', + }, + { + name: 'TestDir2', + selector: '[dir2]', + file: dirFile, + type: 'directive', + }, + { + name: 'TestDirAllDivs', + selector: 'div', + file: dirFile, + type: 'directive', + }, + ] + }, + { + fileName: dirFile, + source: ` + export class TestDir {} + export class TestDir2 {} + export class TestDirAllDivs {} + `, + templates: {}, + } + ], + ); + const sf = getSourceFileOrError(program, fileName); + const cmp = getClass(sf, 'Cmp'); + + const nodes = templateTypeChecker.getTemplate(cmp)!; + + const symbol = templateTypeChecker.getSymbolOfNode(nodes[0] as TmplAstElement, cmp)!; + assertElementSymbol(symbol); + expect(symbol.directives.length).toBe(3); + const expectedDirectives = ['TestDir', 'TestDir2', 'TestDirAllDivs'].sort(); + const actualDirectives = + symbol.directives.map(dir => program.getTypeChecker().typeToString(dir.tsType)).sort(); + expect(actualDirectives).toEqual(expectedDirectives); + }); + }); }); }); +function onlyAstTemplates(nodes: TmplAstNode[]): TmplAstTemplate[] { + return nodes.filter((n): n is TmplAstTemplate => n instanceof TmplAstTemplate); +} + +function getAstTemplates( + templateTypeChecker: TemplateTypeChecker, cmp: ts.ClassDeclaration&{name: ts.Identifier}) { + return onlyAstTemplates(templateTypeChecker.getTemplate(cmp)!); +} + +function assertDirectiveSymbol(tSymbol: Symbol): asserts tSymbol is DirectiveSymbol { + expect(tSymbol.kind).toEqual(SymbolKind.Directive); +} + function assertInputBindingSymbol(tSymbol: Symbol): asserts tSymbol is InputBindingSymbol { expect(tSymbol.kind).toEqual(SymbolKind.Input); } @@ -484,6 +644,14 @@ function assertOutputBindingSymbol(tSymbol: Symbol): asserts tSymbol is OutputBi expect(tSymbol.kind).toEqual(SymbolKind.Output); } +function assertTemplateSymbol(tSymbol: Symbol): asserts tSymbol is TemplateSymbol { + expect(tSymbol.kind).toEqual(SymbolKind.Template); +} + +function assertElementSymbol(tSymbol: Symbol): asserts tSymbol is ElementSymbol { + expect(tSymbol.kind).toEqual(SymbolKind.Element); +} + export function setup(targets: TypeCheckingTarget[], config?: Partial) { return baseTestSetup( targets, {inlining: false, config: {...config, enableTemplateTypeChecker: true}});