From f56ece4fdcd6c1a9c07fa54d8502881a4f16e2d3 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Thu, 27 Aug 2020 13:44:38 -0700 Subject: [PATCH] feat(compiler-cli): Add ability to get `Symbol` of AST expression in component template (#38618) Adds support to the `TemplateTypeChecker` to get a `Symbol` of an AST expression in a component template. Not all expressions will have `ts.Symbol`s (e.g. there is no `ts.Symbol` associated with the expression `a + b`, but there are for both the a and b nodes individually). PR Close #38618 --- .../typecheck/src/template_symbol_builder.ts | 53 ++- ...ecker__get_symbol_of_template_node_spec.ts | 425 +++++++++++++++++- 2 files changed, 475 insertions(+), 3 deletions(-) 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 4312eaa2ab..a6a123523f 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 @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {AST, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstElement, TmplAstNode, TmplAstTemplate} from '@angular/compiler'; +import {AbsoluteSourceSpan, AST, ASTWithSource, BindingPipe, SafeMethodCall, SafePropertyRead, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstElement, TmplAstNode, TmplAstTemplate} from '@angular/compiler'; import * as ts from 'typescript'; import {AbsoluteFsPath} from '../../file_system'; @@ -40,7 +40,10 @@ export class SymbolBuilder { return this.getSymbolOfElement(node); } else if (node instanceof TmplAstTemplate) { return this.getSymbolOfAstTemplate(node); + } else if (node instanceof AST) { + return this.getSymbolOfTemplateExpression(node); } + // TODO(atscott): TmplAstContent, TmplAstIcu return null; } @@ -223,6 +226,54 @@ export class SymbolBuilder { }; } + private getSymbolOfTemplateExpression(expression: AST): ExpressionSymbol|null { + if (expression instanceof ASTWithSource) { + expression = expression.ast; + } + + let node = findFirstMatchingNode( + this.typeCheckBlock, + {withSpan: expression.sourceSpan, filter: (n: ts.Node): n is ts.Node => true}); + if (node === null) { + return null; + } + + while (ts.isParenthesizedExpression(node)) { + node = node.expression; + } + + // - If we have safe property read ("a?.b") we want to get the Symbol for b, the `whenTrue` + // expression. + // - If our expression is a pipe binding ("a | test:b:c"), we want the Symbol for the + // `transform` on the pipe. + // - Otherwise, we retrieve the symbol for the node itself with no special considerations + if ((expression instanceof SafePropertyRead || expression instanceof SafeMethodCall) && + ts.isConditionalExpression(node)) { + const whenTrueSymbol = + (expression instanceof SafeMethodCall && ts.isCallExpression(node.whenTrue)) ? + this.getSymbolOfTsNode(node.whenTrue.expression) : + this.getSymbolOfTsNode(node.whenTrue); + if (whenTrueSymbol === null) { + return null; + } + + return { + ...whenTrueSymbol, + kind: SymbolKind.Expression, + // Rather than using the type of only the `whenTrue` part of the expression, we should + // still get the type of the whole conditional expression to include `|undefined`. + tsType: this.typeChecker.getTypeAtLocation(node) + }; + } else if (expression instanceof BindingPipe && ts.isCallExpression(node)) { + // TODO(atscott): Create a PipeSymbol to include symbol for the Pipe class + const symbolInfo = this.getSymbolOfTsNode(node.expression); + return symbolInfo === null ? null : {...symbolInfo, kind: SymbolKind.Expression}; + } else { + const symbolInfo = this.getSymbolOfTsNode(node); + return symbolInfo === null ? null : {...symbolInfo, kind: SymbolKind.Expression}; + } + } + private getSymbolOfTsNode(node: ts.Node): TsNodeSymbolInfo|null { while (ts.isParenthesizedExpression(node)) { node = node.expression; 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 5e5efbf0f4..52e77a3dcd 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,18 +6,38 @@ * found in the LICENSE file at https://angular.io/license */ -import {TmplAstBoundAttribute, TmplAstElement, TmplAstNode, TmplAstTemplate} from '@angular/compiler'; +import {ASTWithSource, Binary, BindingPipe, Conditional, Interpolation, PropertyRead, TmplAstBoundAttribute, TmplAstBoundText, TmplAstElement, TmplAstNode, TmplAstTemplate} from '@angular/compiler'; import * as ts from 'typescript'; import {absoluteFrom, getSourceFileOrError} from '../../file_system'; import {runInEachFileSystem} from '../../file_system/testing'; import {ClassDeclaration} from '../../reflection'; -import {DirectiveSymbol, ElementSymbol, InputBindingSymbol, OutputBindingSymbol, Symbol, SymbolKind, TemplateSymbol, TemplateTypeChecker, TypeCheckingConfig} from '../api'; +import {DirectiveSymbol, ElementSymbol, ExpressionSymbol, InputBindingSymbol, OutputBindingSymbol, Symbol, SymbolKind, TemplateSymbol, TemplateTypeChecker, TypeCheckingConfig} from '../api'; import {getClass, ngForDeclaration, ngForTypeCheckTarget, setup as baseTestSetup, TypeCheckingTarget} from './test_utils'; runInEachFileSystem(() => { describe('TemplateTypeChecker.getSymbolOfNode', () => { + it('should not get a symbol for regular attributes', () => { + const fileName = absoluteFrom('/main.ts'); + const templateString = `
`; + const {templateTypeChecker, program} = setup( + [ + { + fileName, + templates: {'Cmp': templateString}, + source: `export class Cmp {}`, + }, + ], + ); + const sf = getSourceFileOrError(program, fileName); + const cmp = getClass(sf, 'Cmp'); + const {attributes} = getAstElements(templateTypeChecker, cmp)[0]; + + const symbol = templateTypeChecker.getSymbolOfNode(attributes[0], cmp); + expect(symbol).toBeNull(); + }); + describe('templates', () => { describe('ng-templates', () => { let templateTypeChecker: TemplateTypeChecker; @@ -67,6 +87,394 @@ runInEachFileSystem(() => { expect(symbol.directives[0].tsSymbol.getName()).toBe('TestDir'); }); }); + + describe('structural directives', () => { + let templateTypeChecker: TemplateTypeChecker; + let cmp: ClassDeclaration; + let templateNode: TmplAstTemplate; + let program: ts.Program; + + beforeEach(() => { + const fileName = absoluteFrom('/main.ts'); + const templateString = ` +
+ {{user.name}} {{user.streetNumber}} +
+
`; + const testValues = setup([ + { + fileName, + templates: {'Cmp': templateString}, + source: ` + export interface User { + name: string; + streetNumber: number; + } + export class Cmp { users: User[]; } + `, + declarations: [ngForDeclaration()], + }, + ngForTypeCheckTarget(), + ]); + templateTypeChecker = testValues.templateTypeChecker; + program = testValues.program; + const sf = getSourceFileOrError(testValues.program, fileName); + cmp = getClass(sf, 'Cmp'); + templateNode = getAstTemplates(templateTypeChecker, cmp)[0]; + }); + + it('should retrieve a symbol for an expression inside structural binding', () => { + const ngForOfBinding = + templateNode.templateAttrs.find(a => a.name === 'ngForOf')! as TmplAstBoundAttribute; + const symbol = templateTypeChecker.getSymbolOfNode(ngForOfBinding.value, cmp)!; + assertExpressionSymbol(symbol); + expect(program.getTypeChecker().symbolToString(symbol.tsSymbol!)).toEqual('users'); + expect(program.getTypeChecker().typeToString(symbol.tsType)).toEqual('Array'); + }); + + it('should retrieve a symbol for property reads of implicit variable inside structural binding', + () => { + const boundText = + (templateNode.children[0] as TmplAstElement).children[0] as TmplAstBoundText; + const interpolation = (boundText.value as ASTWithSource).ast as Interpolation; + const namePropRead = interpolation.expressions[0] as PropertyRead; + const streetNumberPropRead = interpolation.expressions[1] as PropertyRead; + + const nameSymbol = templateTypeChecker.getSymbolOfNode(namePropRead, cmp)!; + assertExpressionSymbol(nameSymbol); + expect(program.getTypeChecker().symbolToString(nameSymbol.tsSymbol!)).toEqual('name'); + expect(program.getTypeChecker().typeToString(nameSymbol.tsType)).toEqual('string'); + + const streetSymbol = templateTypeChecker.getSymbolOfNode(streetNumberPropRead, cmp)!; + assertExpressionSymbol(streetSymbol); + expect(program.getTypeChecker().symbolToString(streetSymbol.tsSymbol!)) + .toEqual('streetNumber'); + expect(program.getTypeChecker().typeToString(streetSymbol.tsType)).toEqual('number'); + }); + }); + }); + + describe('for expressions', () => { + it('should get a symbol for a component property used in an input binding', () => { + const fileName = absoluteFrom('/main.ts'); + const templateString = `
`; + const {templateTypeChecker, program} = setup([ + { + fileName, + templates: {'Cmp': templateString}, + source: `export class Cmp {helloWorld?: boolean;}`, + }, + ]); + const sf = getSourceFileOrError(program, fileName); + const cmp = getClass(sf, 'Cmp'); + const nodes = getAstElements(templateTypeChecker, cmp); + + const symbol = templateTypeChecker.getSymbolOfNode(nodes[0].inputs[0].value, cmp)!; + assertExpressionSymbol(symbol); + expect(program.getTypeChecker().symbolToString(symbol.tsSymbol!)).toEqual('helloWorld'); + expect(program.getTypeChecker().typeToString(symbol.tsType)) + .toEqual('false | true | undefined'); + }); + + it('should get a symbol for properties several levels deep', () => { + const fileName = absoluteFrom('/main.ts'); + const templateString = `
`; + const {templateTypeChecker, program} = setup([ + { + fileName, + templates: {'Cmp': templateString}, + source: ` + interface Address { + street: string; + } + + interface Person { + address: Address; + } + export class Cmp {person?: Person;} + `, + }, + ]); + const sf = getSourceFileOrError(program, fileName); + const cmp = getClass(sf, 'Cmp'); + const nodes = getAstElements(templateTypeChecker, cmp); + + const inputNode = nodes[0].inputs[0].value as ASTWithSource; + + const symbol = templateTypeChecker.getSymbolOfNode(inputNode, cmp)!; + assertExpressionSymbol(symbol); + expect(program.getTypeChecker().symbolToString(symbol.tsSymbol!)).toEqual('street'); + expect((symbol.tsSymbol!.declarations[0] as ts.PropertyDeclaration).parent.name!.getText()) + .toEqual('Address'); + expect(program.getTypeChecker().typeToString(symbol.tsType)).toEqual('string'); + + const personSymbol = templateTypeChecker.getSymbolOfNode( + ((inputNode.ast as PropertyRead).receiver as PropertyRead).receiver, cmp)!; + assertExpressionSymbol(personSymbol); + expect(program.getTypeChecker().symbolToString(personSymbol.tsSymbol!)).toEqual('person'); + expect(program.getTypeChecker().typeToString(personSymbol.tsType)) + .toEqual('Person | undefined'); + }); + + describe('should get symbols for conditionals', () => { + let templateTypeChecker: TemplateTypeChecker; + let cmp: ClassDeclaration; + let program: ts.Program; + let templateString: string; + + beforeEach(() => { + const fileName = absoluteFrom('/main.ts'); + templateString = ` +
+
+
+ `; + const testValues = setup( + [ + { + fileName, + templates: {'Cmp': templateString}, + source: ` + interface Address { + street: string; + } + + interface Person { + address: Address; + speak(): string; + } + export class Cmp {person?: Person; noPersonError = 'no person'} + `, + }, + ], + ); + templateTypeChecker = testValues.templateTypeChecker; + program = testValues.program; + const sf = getSourceFileOrError(program, fileName); + cmp = getClass(sf, 'Cmp'); + }); + + it('safe property reads', () => { + const nodes = getAstElements(templateTypeChecker, cmp); + const safePropertyRead = nodes[0].inputs[0].value as ASTWithSource; + const propReadSymbol = templateTypeChecker.getSymbolOfNode(safePropertyRead, cmp)!; + assertExpressionSymbol(propReadSymbol); + expect(program.getTypeChecker().symbolToString(propReadSymbol.tsSymbol!)) + .toEqual('street'); + expect((propReadSymbol.tsSymbol!.declarations[0] as ts.PropertyDeclaration) + .parent.name!.getText()) + .toEqual('Address'); + expect(program.getTypeChecker().typeToString(propReadSymbol.tsType)) + .toEqual('string | undefined'); + }); + + it('safe method calls', () => { + const nodes = getAstElements(templateTypeChecker, cmp); + const safeMethodCall = nodes[2].inputs[0].value as ASTWithSource; + const methodCallSymbol = templateTypeChecker.getSymbolOfNode(safeMethodCall, cmp)!; + assertExpressionSymbol(methodCallSymbol); + expect(program.getTypeChecker().symbolToString(methodCallSymbol.tsSymbol!)) + .toEqual('speak'); + expect((methodCallSymbol.tsSymbol!.declarations[0] as ts.PropertyDeclaration) + .parent.name!.getText()) + .toEqual('Person'); + expect(program.getTypeChecker().typeToString(methodCallSymbol.tsType)) + .toEqual('string | undefined'); + }); + + it('ternary expressions', () => { + const nodes = getAstElements(templateTypeChecker, cmp); + + const ternary = (nodes[1].inputs[0].value as ASTWithSource).ast as Conditional; + const ternarySymbol = templateTypeChecker.getSymbolOfNode(ternary, cmp)!; + assertExpressionSymbol(ternarySymbol); + expect(ternarySymbol.tsSymbol).toBeNull(); + expect(program.getTypeChecker().typeToString(ternarySymbol.tsType)) + .toEqual('string | Address'); + const addrSymbol = templateTypeChecker.getSymbolOfNode(ternary.trueExp, cmp)!; + assertExpressionSymbol(addrSymbol); + expect(program.getTypeChecker().symbolToString(addrSymbol.tsSymbol!)).toEqual('address'); + expect(program.getTypeChecker().typeToString(addrSymbol.tsType)).toEqual('Address'); + + const noPersonSymbol = templateTypeChecker.getSymbolOfNode(ternary.falseExp, cmp)!; + assertExpressionSymbol(noPersonSymbol); + expect(program.getTypeChecker().symbolToString(noPersonSymbol.tsSymbol!)) + .toEqual('noPersonError'); + expect(program.getTypeChecker().typeToString(noPersonSymbol.tsType)).toEqual('string'); + }); + }); + + it('should get a symbol for function on a component used in an input binding', () => { + const fileName = absoluteFrom('/main.ts'); + const templateString = `
`; + const {templateTypeChecker, program} = setup([ + { + fileName, + templates: {'Cmp': templateString}, + source: ` + export class Cmp { + helloWorld() { return ''; } + }`, + }, + ]); + const sf = getSourceFileOrError(program, fileName); + const cmp = getClass(sf, 'Cmp'); + const nodes = getAstElements(templateTypeChecker, cmp); + + const symbol = templateTypeChecker.getSymbolOfNode(nodes[0].inputs[0].value, cmp)!; + assertExpressionSymbol(symbol); + expect(program.getTypeChecker().symbolToString(symbol.tsSymbol!)).toEqual('helloWorld'); + expect(program.getTypeChecker().typeToString(symbol.tsType)).toEqual('() => string'); + }); + + it('should get a symbol for binary expressions', () => { + const fileName = absoluteFrom('/main.ts'); + const templateString = `
`; + const {templateTypeChecker, program} = setup([ + { + fileName, + templates: {'Cmp': templateString}, + source: ` + export class Cmp { + a!: string; + b!: number; + }`, + }, + ]); + const sf = getSourceFileOrError(program, fileName); + const cmp = getClass(sf, 'Cmp'); + const nodes = getAstElements(templateTypeChecker, cmp); + + const valueAssignment = nodes[0].inputs[0].value as ASTWithSource; + const wholeExprSymbol = templateTypeChecker.getSymbolOfNode(valueAssignment, cmp)!; + assertExpressionSymbol(wholeExprSymbol); + expect(wholeExprSymbol.tsSymbol).toBeNull(); + expect(program.getTypeChecker().typeToString(wholeExprSymbol.tsType)).toEqual('string'); + + const aSymbol = + templateTypeChecker.getSymbolOfNode((valueAssignment.ast as Binary).left, cmp)!; + assertExpressionSymbol(aSymbol); + expect(program.getTypeChecker().symbolToString(aSymbol.tsSymbol!)).toBe('a'); + expect(program.getTypeChecker().typeToString(aSymbol.tsType)).toEqual('string'); + + const bSymbol = + templateTypeChecker.getSymbolOfNode((valueAssignment.ast as Binary).right, cmp)!; + assertExpressionSymbol(bSymbol); + expect(program.getTypeChecker().symbolToString(bSymbol.tsSymbol!)).toBe('b'); + expect(program.getTypeChecker().typeToString(bSymbol.tsType)).toEqual('number'); + }); + + describe('literals', () => { + let templateTypeChecker: TemplateTypeChecker; + let cmp: ClassDeclaration; + let interpolation: Interpolation; + let program: ts.Program; + + beforeEach(() => { + const fileName = absoluteFrom('/main.ts'); + const templateString = ` + {{ [1, 2, 3] }} + {{ { hello: "world" } }}`; + const testValues = setup([ + { + fileName, + templates: {'Cmp': templateString}, + source: `export class Cmp {}`, + }, + ]); + templateTypeChecker = testValues.templateTypeChecker; + program = testValues.program; + const sf = getSourceFileOrError(testValues.program, fileName); + cmp = getClass(sf, 'Cmp'); + interpolation = ((templateTypeChecker.getTemplate(cmp)![0] as TmplAstBoundText).value as + ASTWithSource) + .ast as Interpolation; + }); + + it('literal array', () => { + const literalArray = interpolation.expressions[0]; + const symbol = templateTypeChecker.getSymbolOfNode(literalArray, cmp)!; + assertExpressionSymbol(symbol); + expect(program.getTypeChecker().symbolToString(symbol.tsSymbol!)).toEqual('Array'); + expect(program.getTypeChecker().typeToString(symbol.tsType)).toEqual('Array'); + }); + + it('literal map', () => { + const literalMap = interpolation.expressions[1]; + const symbol = templateTypeChecker.getSymbolOfNode(literalMap, cmp)!; + assertExpressionSymbol(symbol); + expect(program.getTypeChecker().symbolToString(symbol.tsSymbol!)).toEqual('__object'); + expect(program.getTypeChecker().typeToString(symbol.tsType)) + .toEqual('{ hello: string; }'); + }); + }); + + + describe('pipes', () => { + let templateTypeChecker: TemplateTypeChecker; + let cmp: ClassDeclaration; + let binding: BindingPipe; + let program: ts.Program; + + beforeEach(() => { + const fileName = absoluteFrom('/main.ts'); + const templateString = `
`; + const testValues = setup([ + { + fileName, + templates: {'Cmp': templateString}, + source: ` + export class Cmp { a: string; b: number; c: boolean } + export class TestPipe { + transform(value: string, repeat: number, commaSeparate: boolean): string[] { + } + } + `, + declarations: [{ + type: 'pipe', + name: 'TestPipe', + pipeName: 'test', + }], + }, + ]); + program = testValues.program; + templateTypeChecker = testValues.templateTypeChecker; + const sf = getSourceFileOrError(testValues.program, fileName); + cmp = getClass(sf, 'Cmp'); + binding = + (getAstElements(templateTypeChecker, cmp)[0].inputs[0].value as ASTWithSource).ast as + BindingPipe; + }); + + it('should get symbol for pipe', () => { + const pipeSymbol = templateTypeChecker.getSymbolOfNode(binding, cmp)!; + assertExpressionSymbol(pipeSymbol); + expect(program.getTypeChecker().symbolToString(pipeSymbol.tsSymbol!)) + .toEqual('transform'); + expect( + (pipeSymbol.tsSymbol!.declarations[0].parent as ts.ClassDeclaration).name!.getText()) + .toEqual('TestPipe'); + expect(program.getTypeChecker().typeToString(pipeSymbol.tsType)) + .toEqual('(value: string, repeat: number, commaSeparate: boolean) => string[]'); + }); + + it('should get symbols for pipe expression and args', () => { + const aSymbol = templateTypeChecker.getSymbolOfNode(binding.exp, cmp)!; + assertExpressionSymbol(aSymbol); + expect(program.getTypeChecker().symbolToString(aSymbol.tsSymbol!)).toEqual('a'); + expect(program.getTypeChecker().typeToString(aSymbol.tsType)).toEqual('string'); + + const bSymbol = templateTypeChecker.getSymbolOfNode(binding.args[0], cmp)!; + assertExpressionSymbol(bSymbol); + expect(program.getTypeChecker().symbolToString(bSymbol.tsSymbol!)).toEqual('b'); + expect(program.getTypeChecker().typeToString(bSymbol.tsType)).toEqual('number'); + + const cSymbol = templateTypeChecker.getSymbolOfNode(binding.args[1], cmp)!; + assertExpressionSymbol(cSymbol); + expect(program.getTypeChecker().symbolToString(cSymbol.tsSymbol!)).toEqual('c'); + expect(program.getTypeChecker().typeToString(cSymbol.tsType)).toEqual('boolean'); + }); + }); }); describe('input bindings', () => { @@ -627,6 +1035,15 @@ function onlyAstTemplates(nodes: TmplAstNode[]): TmplAstTemplate[] { return nodes.filter((n): n is TmplAstTemplate => n instanceof TmplAstTemplate); } +function onlyAstElements(nodes: TmplAstNode[]): TmplAstElement[] { + return nodes.filter((n): n is TmplAstElement => n instanceof TmplAstElement); +} + +function getAstElements( + templateTypeChecker: TemplateTypeChecker, cmp: ts.ClassDeclaration&{name: ts.Identifier}) { + return onlyAstElements(templateTypeChecker.getTemplate(cmp)!); +} + function getAstTemplates( templateTypeChecker: TemplateTypeChecker, cmp: ts.ClassDeclaration&{name: ts.Identifier}) { return onlyAstTemplates(templateTypeChecker.getTemplate(cmp)!); @@ -648,6 +1065,10 @@ function assertTemplateSymbol(tSymbol: Symbol): asserts tSymbol is TemplateSymbo expect(tSymbol.kind).toEqual(SymbolKind.Template); } +function assertExpressionSymbol(tSymbol: Symbol): asserts tSymbol is ExpressionSymbol { + expect(tSymbol.kind).toEqual(SymbolKind.Expression); +} + function assertElementSymbol(tSymbol: Symbol): asserts tSymbol is ElementSymbol { expect(tSymbol.kind).toEqual(SymbolKind.Element); }