diff --git a/packages/language-service/ivy/BUILD.bazel b/packages/language-service/ivy/BUILD.bazel index e56ebed70b..f512182eb6 100644 --- a/packages/language-service/ivy/BUILD.bazel +++ b/packages/language-service/ivy/BUILD.bazel @@ -15,6 +15,8 @@ ts_library( "//packages/compiler-cli/src/ngtsc/shims", "//packages/compiler-cli/src/ngtsc/typecheck", "//packages/compiler-cli/src/ngtsc/typecheck/api", + # TODO(atscott): Pull functions/variables common to VE and Ivy into a new package + "//packages/language-service", "@npm//typescript", ], ) diff --git a/packages/language-service/ivy/hybrid_visitor.ts b/packages/language-service/ivy/hybrid_visitor.ts index 2cc7933d92..b68d3e782a 100644 --- a/packages/language-service/ivy/hybrid_visitor.ts +++ b/packages/language-service/ivy/hybrid_visitor.ts @@ -10,6 +10,8 @@ import {AbsoluteSourceSpan, ParseSourceSpan} from '@angular/compiler'; import * as e from '@angular/compiler/src/expression_parser/ast'; // e for expression AST import * as t from '@angular/compiler/src/render3/r3_ast'; // t for template AST +import {isTemplateNode, isTemplateNodeWithKeyAndValue} from './utils'; + /** * Return the template AST node or expression AST node that most accurately * represents the node at the specified cursor `position`. @@ -165,24 +167,6 @@ class ExpressionVisitor extends e.RecursiveAstVisitor { } } -export function isTemplateNode(node: t.Node|e.AST): node is t.Node { - // Template node implements the Node interface so we cannot use instanceof. - return node.sourceSpan instanceof ParseSourceSpan; -} - -interface NodeWithKeyAndValue extends t.Node { - keySpan: ParseSourceSpan; - valueSpan?: ParseSourceSpan; -} - -export function isTemplateNodeWithKeyAndValue(node: t.Node|e.AST): node is NodeWithKeyAndValue { - return isTemplateNode(node) && node.hasOwnProperty('keySpan'); -} - -export function isExpressionNode(node: t.Node|e.AST): node is e.AST { - return node instanceof e.AST; -} - function getSpanIncludingEndTag(ast: t.Node) { const result = { start: ast.sourceSpan.start.offset, diff --git a/packages/language-service/ivy/language_service.ts b/packages/language-service/ivy/language_service.ts index 88b7edfd03..a3a828b331 100644 --- a/packages/language-service/ivy/language_service.ts +++ b/packages/language-service/ivy/language_service.ts @@ -16,6 +16,8 @@ import {TypeCheckShimGenerator} from '@angular/compiler-cli/src/ngtsc/typecheck' import {OptimizeFor, TypeCheckingProgramStrategy} from '@angular/compiler-cli/src/ngtsc/typecheck/api'; import * as ts from 'typescript/lib/tsserverlibrary'; +import {QuickInfoBuilder} from './quick_info'; + export class LanguageService { private options: CompilerOptions; private lastKnownProgram: ts.Program|null = null; @@ -45,6 +47,12 @@ export class LanguageService { throw new Error('Ivy LS currently does not support external template'); } + getQuickInfoAtPosition(fileName: string, position: number): ts.QuickInfo|undefined { + const program = this.strategy.getProgram(); + const compiler = this.createCompiler(program); + return new QuickInfoBuilder(this.tsLS, compiler).get(fileName, position); + } + private createCompiler(program: ts.Program): NgCompiler { return new NgCompiler( this.adapter, diff --git a/packages/language-service/ivy/quick_info.ts b/packages/language-service/ivy/quick_info.ts new file mode 100644 index 0000000000..76ba3b0d40 --- /dev/null +++ b/packages/language-service/ivy/quick_info.ts @@ -0,0 +1,237 @@ +/** + * @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, BindingPipe, ImplicitReceiver, MethodCall, TmplAstBoundAttribute, TmplAstNode, TmplAstTextAttribute} from '@angular/compiler'; +import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core'; +import {DirectiveSymbol, DomBindingSymbol, ElementSymbol, ExpressionSymbol, InputBindingSymbol, OutputBindingSymbol, ReferenceSymbol, ShimLocation, Symbol, SymbolKind, VariableSymbol} from '@angular/compiler-cli/src/ngtsc/typecheck/api'; +import * as ts from 'typescript'; + +import {createQuickInfo, SYMBOL_PUNC, SYMBOL_SPACE, SYMBOL_TEXT} from '../src/hover'; + +import {findNodeAtPosition} from './hybrid_visitor'; +import {filterAliasImports, getDirectiveMatches, getDirectiveMatchesForAttribute, getTemplateInfoAtPosition, getTextSpanOfNode} from './utils'; + +/** + * The type of Angular directive. Used for QuickInfo in template. + */ +export enum QuickInfoKind { + COMPONENT = 'component', + DIRECTIVE = 'directive', + EVENT = 'event', + REFERENCE = 'reference', + ELEMENT = 'element', + VARIABLE = 'variable', + PIPE = 'pipe', + PROPERTY = 'property', + METHOD = 'method', + TEMPLATE = 'template', +} + +export class QuickInfoBuilder { + private readonly typeChecker = this.compiler.getNextProgram().getTypeChecker(); + constructor(private readonly tsLS: ts.LanguageService, private readonly compiler: NgCompiler) {} + + get(fileName: string, position: number): ts.QuickInfo|undefined { + const templateInfo = getTemplateInfoAtPosition(fileName, position, this.compiler); + if (templateInfo === undefined) { + return undefined; + } + const {template, component} = templateInfo; + + const node = findNodeAtPosition(template, position); + if (node === undefined) { + return undefined; + } + + const symbol = this.compiler.getTemplateTypeChecker().getSymbolOfNode(node, component); + if (symbol === null) { + return isDollarAny(node) ? createDollarAnyQuickInfo(node) : undefined; + } + + return this.getQuickInfoForSymbol(symbol, node); + } + + private getQuickInfoForSymbol(symbol: Symbol, node: TmplAstNode|AST): ts.QuickInfo|undefined { + switch (symbol.kind) { + case SymbolKind.Input: + case SymbolKind.Output: + return this.getQuickInfoForBindingSymbol(symbol, node); + case SymbolKind.Template: + return createNgTemplateQuickInfo(node); + case SymbolKind.Element: + return this.getQuickInfoForElementSymbol(symbol); + case SymbolKind.Variable: + return this.getQuickInfoForVariableSymbol(symbol, node); + case SymbolKind.Reference: + return this.getQuickInfoForReferenceSymbol(symbol, node); + case SymbolKind.DomBinding: + return this.getQuickInfoForDomBinding(node, symbol); + case SymbolKind.Directive: + return this.getQuickInfoAtShimLocation(symbol.shimLocation, node); + case SymbolKind.Expression: + return node instanceof BindingPipe ? + this.getQuickInfoForPipeSymbol(symbol, node) : + this.getQuickInfoAtShimLocation(symbol.shimLocation, node); + } + } + + private getQuickInfoForBindingSymbol( + symbol: InputBindingSymbol|OutputBindingSymbol, node: TmplAstNode|AST): ts.QuickInfo + |undefined { + if (symbol.bindings.length === 0) { + return undefined; + } + + const kind = symbol.kind === SymbolKind.Input ? QuickInfoKind.PROPERTY : QuickInfoKind.EVENT; + + const quickInfo = this.getQuickInfoAtShimLocation(symbol.bindings[0].shimLocation, node); + return quickInfo === undefined ? undefined : updateQuickInfoKind(quickInfo, kind); + } + + private getQuickInfoForElementSymbol(symbol: ElementSymbol): ts.QuickInfo { + const {templateNode} = symbol; + const matches = getDirectiveMatches(symbol.directives, templateNode.name); + if (matches.size > 0) { + return this.getQuickInfoForDirectiveSymbol(matches.values().next().value, templateNode); + } + + return createQuickInfo( + templateNode.name, QuickInfoKind.ELEMENT, getTextSpanOfNode(templateNode), + undefined /* containerName */, this.typeChecker.typeToString(symbol.tsType)); + } + + private getQuickInfoForVariableSymbol(symbol: VariableSymbol, node: TmplAstNode|AST): + ts.QuickInfo { + const documentation = this.getDocumentationFromTypeDefAtLocation(symbol.shimLocation); + return createQuickInfo( + symbol.declaration.name, QuickInfoKind.VARIABLE, getTextSpanOfNode(node), + undefined /* containerName */, this.typeChecker.typeToString(symbol.tsType), documentation); + } + + private getQuickInfoForReferenceSymbol(symbol: ReferenceSymbol, node: TmplAstNode|AST): + ts.QuickInfo { + const documentation = this.getDocumentationFromTypeDefAtLocation(symbol.shimLocation); + return createQuickInfo( + symbol.declaration.name, QuickInfoKind.REFERENCE, getTextSpanOfNode(node), + undefined /* containerName */, this.typeChecker.typeToString(symbol.tsType), documentation); + } + + private getQuickInfoForPipeSymbol(symbol: ExpressionSymbol, node: TmplAstNode|AST): ts.QuickInfo + |undefined { + const quickInfo = this.getQuickInfoAtShimLocation(symbol.shimLocation, node); + return quickInfo === undefined ? undefined : updateQuickInfoKind(quickInfo, QuickInfoKind.PIPE); + } + + private getQuickInfoForDomBinding(node: TmplAstNode|AST, symbol: DomBindingSymbol) { + if (!(node instanceof TmplAstTextAttribute) && !(node instanceof TmplAstBoundAttribute)) { + return undefined; + } + const directives = getDirectiveMatchesForAttribute( + node.name, symbol.host.templateNode, symbol.host.directives); + if (directives.size === 0) { + return undefined; + } + + return this.getQuickInfoForDirectiveSymbol(directives.values().next().value, node); + } + + private getQuickInfoForDirectiveSymbol(dir: DirectiveSymbol, node: TmplAstNode|AST): + ts.QuickInfo { + const kind = dir.isComponent ? QuickInfoKind.COMPONENT : QuickInfoKind.DIRECTIVE; + const documentation = this.getDocumentationFromTypeDefAtLocation(dir.shimLocation); + return createQuickInfo( + this.typeChecker.typeToString(dir.tsType), kind, getTextSpanOfNode(node), + undefined /* containerName */, undefined, documentation); + } + + private getDocumentationFromTypeDefAtLocation(shimLocation: ShimLocation): + ts.SymbolDisplayPart[]|undefined { + const typeDefs = this.tsLS.getTypeDefinitionAtPosition( + shimLocation.shimPath, shimLocation.positionInShimFile); + if (typeDefs === undefined || typeDefs.length === 0) { + return undefined; + } + return this.tsLS.getQuickInfoAtPosition(typeDefs[0].fileName, typeDefs[0].textSpan.start) + ?.documentation; + } + + private getQuickInfoAtShimLocation(location: ShimLocation, node: TmplAstNode|AST): ts.QuickInfo + |undefined { + const quickInfo = + this.tsLS.getQuickInfoAtPosition(location.shimPath, location.positionInShimFile); + if (quickInfo === undefined || quickInfo.displayParts === undefined) { + return quickInfo; + } + + quickInfo.displayParts = filterAliasImports(quickInfo.displayParts); + + const textSpan = getTextSpanOfNode(node); + return {...quickInfo, textSpan}; + } +} + +function updateQuickInfoKind(quickInfo: ts.QuickInfo, kind: QuickInfoKind): ts.QuickInfo { + if (quickInfo.displayParts === undefined) { + return quickInfo; + } + + const startsWithKind = quickInfo.displayParts.length >= 3 && + displayPartsEqual(quickInfo.displayParts[0], {text: '(', kind: SYMBOL_PUNC}) && + quickInfo.displayParts[1].kind === SYMBOL_TEXT && + displayPartsEqual(quickInfo.displayParts[2], {text: ')', kind: SYMBOL_PUNC}); + if (startsWithKind) { + quickInfo.displayParts[1].text = kind; + } else { + quickInfo.displayParts = [ + {text: '(', kind: SYMBOL_PUNC}, + {text: kind, kind: SYMBOL_TEXT}, + {text: ')', kind: SYMBOL_PUNC}, + {text: ' ', kind: SYMBOL_SPACE}, + ...quickInfo.displayParts, + ]; + } + return quickInfo; +} + +function displayPartsEqual(a: {text: string, kind: string}, b: {text: string, kind: string}) { + return a.text === b.text && a.kind === b.kind; +} + +function isDollarAny(node: TmplAstNode|AST): node is MethodCall { + return node instanceof MethodCall && node.receiver instanceof ImplicitReceiver && + node.name === '$any' && node.args.length === 1; +} + +function createDollarAnyQuickInfo(node: MethodCall): ts.QuickInfo { + return createQuickInfo( + '$any', + QuickInfoKind.METHOD, + getTextSpanOfNode(node), + /** containerName */ undefined, + 'any', + [{ + kind: SYMBOL_TEXT, + text: 'function to cast an expression to the `any` type', + }], + ); +} + +// TODO(atscott): Create special `ts.QuickInfo` for `ng-template` and `ng-container` as well. +function createNgTemplateQuickInfo(node: TmplAstNode|AST): ts.QuickInfo { + return createQuickInfo( + 'ng-template', + QuickInfoKind.TEMPLATE, + getTextSpanOfNode(node), + /** containerName */ undefined, + /** type */ undefined, + [{ + kind: SYMBOL_TEXT, + text: + 'The `` is an Angular element for rendering HTML. It is never displayed directly.', + }], + ); +} diff --git a/packages/language-service/ivy/test/hybrid_visitor_spec.ts b/packages/language-service/ivy/test/hybrid_visitor_spec.ts index ec452c9696..9d29535356 100644 --- a/packages/language-service/ivy/test/hybrid_visitor_spec.ts +++ b/packages/language-service/ivy/test/hybrid_visitor_spec.ts @@ -10,7 +10,8 @@ import {ParseError, parseTemplate} from '@angular/compiler'; import * as e from '@angular/compiler/src/expression_parser/ast'; // e for expression AST import * as t from '@angular/compiler/src/render3/r3_ast'; // t for template AST -import {findNodeAtPosition, isExpressionNode, isTemplateNode} from '../hybrid_visitor'; +import {findNodeAtPosition} from '../hybrid_visitor'; +import {isExpressionNode, isTemplateNode} from '../utils'; interface ParseResult { nodes: t.Node[]; diff --git a/packages/language-service/ivy/test/language_service_spec.ts b/packages/language-service/ivy/test/language_service_spec.ts index 99cc6e531c..590d986e8d 100644 --- a/packages/language-service/ivy/test/language_service_spec.ts +++ b/packages/language-service/ivy/test/language_service_spec.ts @@ -17,7 +17,7 @@ describe('parseNgCompilerOptions', () => { const options = parseNgCompilerOptions(project); expect(options).toEqual(jasmine.objectContaining({ enableIvy: true, // default for ivy is true - fullTemplateTypeCheck: true, + strictTemplates: true, strictInjectionParameters: true, })); }); diff --git a/packages/language-service/ivy/test/quick_info_spec.ts b/packages/language-service/ivy/test/quick_info_spec.ts new file mode 100644 index 0000000000..ac262b78cd --- /dev/null +++ b/packages/language-service/ivy/test/quick_info_spec.ts @@ -0,0 +1,393 @@ +/** + * @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 * as ts from 'typescript/lib/tsserverlibrary'; + +import {LanguageService} from '../language_service'; + +import {APP_COMPONENT, setup, TEST_TEMPLATE} from './mock_host'; + +describe('quick info', () => { + const {project, service, tsLS} = setup(); + const ngLS = new LanguageService(project, tsLS); + + beforeEach(() => { + service.reset(); + }); + + describe('elements', () => { + it('should work for native elements', () => { + expectQuickInfo({ + templateOverride: ``, + expectedSpanText: '', + expectedDisplayString: '(element) button: HTMLButtonElement' + }); + }); + }); + + describe('templates', () => { + it('should return undefined for ng-templates', () => { + const {documentation} = expectQuickInfo({ + templateOverride: ``, + expectedSpanText: '', + expectedDisplayString: '(template) ng-template' + }); + expect(toText(documentation)) + .toContain('The `` is an Angular element for rendering HTML.'); + }); + }); + + describe('directives', () => { + it('should work for directives', () => { + expectQuickInfo({ + templateOverride: `
`, + expectedSpanText: 'string-model', + // TODO(atscott): Find a way to include the module + // expectedDisplayParts: '(directive) AppModule.StringModel' + expectedDisplayString: '(directive) StringModel' + }); + }); + + it('should work for components', () => { + const {documentation} = expectQuickInfo({ + templateOverride: ``, + expectedSpanText: '', + // TODO(atscott): Find a way to include the module + // expectedDisplayParts: '(component) AppModule.TestComponent' + expectedDisplayString: '(component) TestComponent' + }); + expect(toText(documentation)).toBe('This Component provides the `test-comp` selector.'); + }); + + it('should work for structural directives', () => { + const {documentation} = expectQuickInfo({ + templateOverride: `
`, + expectedSpanText: 'ngFor', + expectedDisplayString: '(directive) NgForOf>' + }); + expect(toText(documentation)) + .toContain('A [structural directive](guide/structural-directives) that renders'); + }); + + it('should work for directives with compound selectors, some of which are bindings', () => { + expectQuickInfo({ + templateOverride: `{{item}}`, + expectedSpanText: 'ngFor', + expectedDisplayString: '(directive) NgForOf>' + }); + }); + + it('should work for data-let- syntax', () => { + expectQuickInfo({ + templateOverride: + `{{item}}`, + expectedSpanText: 'hero', + expectedDisplayString: '(variable) hero: Hero' + }); + }); + }); + + describe('bindings', () => { + describe('inputs', () => { + it('should work for input providers', () => { + expectQuickInfo({ + templateOverride: ``, + expectedSpanText: 'tcName', + expectedDisplayString: '(property) TestComponent.name: string' + }); + }); + + it('should work for bind- syntax', () => { + expectQuickInfo({ + templateOverride: ``, + expectedSpanText: 'tcName', + expectedDisplayString: '(property) TestComponent.name: string' + }); + expectQuickInfo({ + templateOverride: ``, + expectedSpanText: 'tcName', + expectedDisplayString: '(property) TestComponent.name: string' + }); + }); + + it('should work for structural directive inputs ngForTrackBy', () => { + expectQuickInfo({ + templateOverride: `
`, + expectedSpanText: 'trackBy', + expectedDisplayString: + '(property) NgForOf.ngForTrackBy: TrackByFunction' + }); + }); + + it('should work for structural directive inputs ngForOf', () => { + expectQuickInfo({ + templateOverride: `
`, + expectedSpanText: 'of', + expectedDisplayString: + '(property) NgForOf.ngForOf: Hero[] | (Hero[] & Iterable) | null | undefined' + }); + }); + + it('should work for two-way binding providers', () => { + expectQuickInfo({ + templateOverride: ``, + expectedSpanText: 'model', + expectedDisplayString: '(property) StringModel.model: string' + }); + }); + }); + + describe('outputs', () => { + it('should work for event providers', () => { + expectQuickInfo({ + templateOverride: ``, + expectedSpanText: '(test)="myClick($event)"', + expectedDisplayString: '(event) TestComponent.testEvent: EventEmitter' + }); + }); + + it('should work for on- syntax binding', () => { + expectQuickInfo({ + templateOverride: ``, + expectedSpanText: 'on-test="myClick($event)"', + expectedDisplayString: '(event) TestComponent.testEvent: EventEmitter' + }); + expectQuickInfo({ + templateOverride: ``, + expectedSpanText: 'data-on-test="myClick($event)"', + expectedDisplayString: '(event) TestComponent.testEvent: EventEmitter' + }); + }); + + it('should work for $event from EventEmitter', () => { + expectQuickInfo({ + templateOverride: `
`, + expectedSpanText: '$event', + expectedDisplayString: '(parameter) $event: string' + }); + }); + + it('should work for $event from native element', () => { + expectQuickInfo({ + templateOverride: `
`, + expectedSpanText: '$event', + expectedDisplayString: '(parameter) $event: MouseEvent' + }); + }); + }); + }); + + describe('references', () => { + it('should work for element reference declarations', () => { + const {documentation} = expectQuickInfo({ + templateOverride: `
`, + expectedSpanText: '#chart', + expectedDisplayString: '(reference) chart: HTMLDivElement' + }); + expect(toText(documentation)) + .toEqual( + 'Provides special properties (beyond the regular HTMLElement ' + + 'interface it also has available to it by inheritance) for manipulating
elements.'); + }); + + it('should work for ref- syntax', () => { + expectQuickInfo({ + templateOverride: `
`, + expectedSpanText: 'ref-chart', + expectedDisplayString: '(reference) chart: HTMLDivElement' + }); + expectQuickInfo({ + templateOverride: `
`, + expectedSpanText: 'data-ref-chart', + expectedDisplayString: '(reference) chart: HTMLDivElement' + }); + }); + }); + + describe('variables', () => { + it('should work for array members', () => { + const {documentation} = expectQuickInfo({ + templateOverride: `
{{her¦o}}
`, + expectedSpanText: 'hero', + expectedDisplayString: '(variable) hero: Hero' + }); + expect(toText(documentation)).toEqual('The most heroic being.'); + }); + + it('should work for ReadonlyArray members (#36191)', () => { + expectQuickInfo({ + templateOverride: `
{{her¦o}}
`, + expectedSpanText: 'hero', + expectedDisplayString: '(variable) hero: Readonly' + }); + }); + + it('should work for const array members (#36191)', () => { + expectQuickInfo({ + templateOverride: `
{{na¦me}}
`, + expectedSpanText: 'name', + expectedDisplayString: '(variable) name: { readonly name: "name"; }' + }); + }); + }); + + describe('pipes', () => { + it('should work for pipes', () => { + const templateOverride = `

The hero's birthday is {{birthday | da¦te: "MM/dd/yy"}}

`; + expectQuickInfo({ + templateOverride, + expectedSpanText: 'date', + expectedDisplayString: + '(pipe) DatePipe.transform(value: string | number | Date, format?: string | undefined, timezone?: ' + + 'string | undefined, locale?: string | undefined): string | null (+2 overloads)' + }); + }); + }); + + describe('expressions', () => { + it('should find members in a text interpolation', () => { + expectQuickInfo({ + templateOverride: `
{{ tit¦le }}
`, + expectedSpanText: 'title', + expectedDisplayString: '(property) AppComponent.title: string' + }); + }); + + it('should work for accessed property reads', () => { + expectQuickInfo({ + templateOverride: `
{{title.len¦gth}}
`, + expectedSpanText: 'length', + expectedDisplayString: '(property) String.length: number' + }); + }); + + it('should find members in an attribute interpolation', () => { + expectQuickInfo({ + templateOverride: `
`, + expectedSpanText: 'title', + expectedDisplayString: '(property) AppComponent.title: string' + }); + }); + + it('should find members of input binding', () => { + expectQuickInfo({ + templateOverride: ``, + expectedSpanText: 'title', + expectedDisplayString: '(property) AppComponent.title: string' + }); + }); + + it('should find input binding on text attribute', () => { + expectQuickInfo({ + templateOverride: ``, + expectedSpanText: 'tcName="title"', + expectedDisplayString: '(property) TestComponent.name: string' + }); + }); + + it('should find members of event binding', () => { + expectQuickInfo({ + templateOverride: ``, + expectedSpanText: 'title', + expectedDisplayString: '(property) AppComponent.title: string' + }); + }); + + it('should work for method calls', () => { + expectQuickInfo({ + templateOverride: `
`, + expectedSpanText: 'setTitle', + expectedDisplayString: '(method) AppComponent.setTitle(newTitle: string): void' + }); + }); + + it('should work for accessed properties in writes', () => { + expectQuickInfo({ + templateOverride: `
`, + expectedSpanText: 'id', + expectedDisplayString: '(property) Hero.id: number' + }); + }); + + it('should work for method call arguments', () => { + expectQuickInfo({ + templateOverride: `
`, + expectedSpanText: 'name', + expectedDisplayString: '(property) Hero.name: string' + }); + }); + + it('should find members of two-way binding', () => { + expectQuickInfo({ + templateOverride: ``, + expectedSpanText: 'title', + expectedDisplayString: '(property) AppComponent.title: string' + }); + }); + + it('should find members in a structural directive', () => { + expectQuickInfo({ + templateOverride: `
`, + expectedSpanText: 'anyValue', + expectedDisplayString: '(property) AppComponent.anyValue: any' + }); + }); + + it('should work for members in structural directives', () => { + expectQuickInfo({ + templateOverride: `
`, + expectedSpanText: 'heroes', + expectedDisplayString: '(property) AppComponent.heroes: Hero[]' + }); + }); + + it('should work for the $any() cast function', () => { + expectQuickInfo({ + templateOverride: `
{{$an¦y(title)}}
`, + expectedSpanText: '$any', + expectedDisplayString: '(method) $any: any' + }); + }); + + it('should provide documentation', () => { + const {position} = service.overwriteInlineTemplate(APP_COMPONENT, `
{{¦title}}
`); + const quickInfo = ngLS.getQuickInfoAtPosition(APP_COMPONENT, position); + const documentation = toText(quickInfo!.documentation); + expect(documentation).toBe('This is the title of the `AppComponent` Component.'); + }); + + // TODO(atscott): Enable once #39065 is merged + xit('works with external template', () => { + const {position, text} = service.overwrite(TEST_TEMPLATE, ''); + const quickInfo = ngLS.getQuickInfoAtPosition(TEST_TEMPLATE, position); + expect(quickInfo).toBeTruthy(); + const {textSpan, displayParts} = quickInfo!; + expect(text.substring(textSpan.start, textSpan.start + textSpan.length)) + .toEqual(''); + expect(toText(displayParts)).toEqual('(element) button: HTMLButtonElement'); + }); + }); + + function expectQuickInfo( + {templateOverride, expectedSpanText, expectedDisplayString}: + {templateOverride: string, expectedSpanText: string, expectedDisplayString: string}): + ts.QuickInfo { + const {position, text} = service.overwriteInlineTemplate(APP_COMPONENT, templateOverride); + const quickInfo = ngLS.getQuickInfoAtPosition(APP_COMPONENT, position); + expect(quickInfo).toBeTruthy(); + const {textSpan, displayParts} = quickInfo!; + expect(text.substring(textSpan.start, textSpan.start + textSpan.length)) + .toEqual(expectedSpanText); + expect(toText(displayParts)).toEqual(expectedDisplayString); + return quickInfo!; + } +}); + +function toText(displayParts?: ts.SymbolDisplayPart[]): string { + return (displayParts || []).map(p => p.text).join(''); +} diff --git a/packages/language-service/ivy/ts_plugin.ts b/packages/language-service/ivy/ts_plugin.ts index a575542b38..67941a7796 100644 --- a/packages/language-service/ivy/ts_plugin.ts +++ b/packages/language-service/ivy/ts_plugin.ts @@ -28,9 +28,20 @@ export function create(info: ts.server.PluginCreateInfo): ts.LanguageService { return undefined; } + function getQuickInfoAtPosition(fileName: string, position: number): ts.QuickInfo|undefined { + if (angularOnly) { + return ngLS.getQuickInfoAtPosition(fileName, position); + } else { + // If TS could answer the query, then return that result. Otherwise, return from Angular LS. + return tsLS.getQuickInfoAtPosition(fileName, position) ?? + ngLS.getQuickInfoAtPosition(fileName, position); + } + } + return { ...tsLS, getSemanticDiagnostics, getTypeDefinitionAtPosition, + getQuickInfoAtPosition, }; } diff --git a/packages/language-service/ivy/utils.ts b/packages/language-service/ivy/utils.ts new file mode 100644 index 0000000000..a2ad10423d --- /dev/null +++ b/packages/language-service/ivy/utils.ts @@ -0,0 +1,228 @@ +/** + * @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 {AbsoluteSourceSpan, CssSelector, ParseSourceSpan, SelectorMatcher} from '@angular/compiler'; +import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core'; +import {DirectiveSymbol} from '@angular/compiler-cli/src/ngtsc/typecheck/api'; +import * as e from '@angular/compiler/src/expression_parser/ast'; // e for expression AST +import * as t from '@angular/compiler/src/render3/r3_ast'; // t for template AST +import * as ts from 'typescript'; + +import {ALIAS_NAME, SYMBOL_PUNC} from '../src/hover'; + +/** + * Given a list of directives and a text to use as a selector, returns the directives which match + * for the selector. + */ +export function getDirectiveMatches( + directives: DirectiveSymbol[], selector: string): Set { + const selectorToMatch = CssSelector.parse(selector); + if (selectorToMatch.length === 0) { + return new Set(); + } + return new Set(directives.filter((dir: DirectiveSymbol) => { + if (dir.selector === null) { + return false; + } + + const matcher = new SelectorMatcher(); + matcher.addSelectables(CssSelector.parse(dir.selector)); + + return matcher.match(selectorToMatch[0], null); + })); +} + +export function getTextSpanOfNode(node: t.Node|e.AST): ts.TextSpan { + if (isTemplateNodeWithKeyAndValue(node)) { + return toTextSpan(node.keySpan); + } else if ( + node instanceof e.PropertyWrite || node instanceof e.MethodCall || + node instanceof e.BindingPipe || node instanceof e.PropertyRead) { + // The `name` part of a `PropertyWrite`, `MethodCall`, and `BindingPipe` does not + // have its own AST so there is no way to retrieve a `Symbol` for just the `name` via a specific + // node. + return toTextSpan(node.nameSpan); + } else { + return toTextSpan(node.sourceSpan); + } +} + +export function toTextSpan(span: AbsoluteSourceSpan|ParseSourceSpan): ts.TextSpan { + let start: number, end: number; + if (span instanceof AbsoluteSourceSpan) { + start = span.start; + end = span.end; + } else { + start = span.start.offset; + end = span.end.offset; + } + return {start, length: end - start}; +} + +interface NodeWithKeyAndValue extends t.Node { + keySpan: ParseSourceSpan; + valueSpan?: ParseSourceSpan; +} + +export function isTemplateNodeWithKeyAndValue(node: t.Node|e.AST): node is NodeWithKeyAndValue { + return isTemplateNode(node) && node.hasOwnProperty('keySpan'); +} + +export function isTemplateNode(node: t.Node|e.AST): node is t.Node { + // Template node implements the Node interface so we cannot use instanceof. + return node.sourceSpan instanceof ParseSourceSpan; +} + +export function isExpressionNode(node: t.Node|e.AST): node is e.AST { + return node instanceof e.AST; +} + +export interface TemplateInfo { + template: t.Node[]; + component: ts.ClassDeclaration; +} + +/** + * Retrieves the `ts.ClassDeclaration` at a location along with its template nodes. + */ +export function getTemplateInfoAtPosition( + fileName: string, position: number, compiler: NgCompiler): TemplateInfo|undefined { + if (fileName.endsWith('.ts')) { + return getInlineTemplateInfoAtPosition(fileName, position, compiler); + } else { + return getFirstComponentForTemplateFile(fileName, compiler); + } +} + + +/** + * First, attempt to sort component declarations by file name. + * If the files are the same, sort by start location of the declaration. + */ +function tsDeclarationSortComparator(a: ts.Declaration, b: ts.Declaration): number { + const aFile = a.getSourceFile().fileName; + const bFile = b.getSourceFile().fileName; + if (aFile < bFile) { + return -1; + } else if (aFile > bFile) { + return 1; + } else { + return b.getFullStart() - a.getFullStart(); + } +} + +function getFirstComponentForTemplateFile(fileName: string, compiler: NgCompiler): TemplateInfo| + undefined { + const templateTypeChecker = compiler.getTemplateTypeChecker(); + const components = compiler.getComponentsWithTemplateFile(fileName); + const sortedComponents = Array.from(components).sort(tsDeclarationSortComparator); + for (const component of sortedComponents) { + if (!ts.isClassDeclaration(component)) { + continue; + } + const template = templateTypeChecker.getTemplate(component); + if (template === null) { + continue; + } + return {template, component}; + } + + return undefined; +} + +/** + * Retrieves the `ts.ClassDeclaration` at a location along with its template nodes. + */ +function getInlineTemplateInfoAtPosition( + fileName: string, position: number, compiler: NgCompiler): TemplateInfo|undefined { + const sourceFile = compiler.getNextProgram().getSourceFile(fileName); + if (!sourceFile) { + return undefined; + } + + // We only support top level statements / class declarations + for (const statement of sourceFile.statements) { + if (!ts.isClassDeclaration(statement) || position < statement.pos || position > statement.end) { + continue; + } + + const template = compiler.getTemplateTypeChecker().getTemplate(statement); + if (template === null) { + return undefined; + } + + return {template, component: statement}; + } + + return undefined; +} + +/** + * Given an attribute name and the element or template the attribute appears on, determines which + * directives match because the attribute is present. That is, we find which directives are applied + * because of this attribute by elimination: compare the directive matches with the attribute + * present against the directive matches without it. The difference would be the directives which + * match because the attribute is present. + * + * @param attribute The attribute name to use for directive matching. + * @param hostNode The element or template node that the attribute is on. + * @param directives The list of directives to match against. + * @returns The list of directives matching the attribute via the strategy described above. + */ +export function getDirectiveMatchesForAttribute( + attribute: string, hostNode: t.Template|t.Element, + directives: DirectiveSymbol[]): Set { + const attributes: Array = + [...hostNode.attributes, ...hostNode.inputs]; + if (hostNode instanceof t.Template) { + attributes.push(...hostNode.templateAttrs); + } + function toAttributeString(a: t.TextAttribute|t.BoundAttribute) { + return `[${a.name}=${a.valueSpan?.toString() ?? ''}]`; + } + const attrs = attributes.map(toAttributeString); + const attrsOmit = attributes.map(a => a.name === attribute ? '' : toAttributeString(a)); + + const hostNodeName = hostNode instanceof t.Template ? hostNode.tagName : hostNode.name; + const directivesWithAttribute = getDirectiveMatches(directives, hostNodeName + attrs.join('')); + const directivesWithoutAttribute = + getDirectiveMatches(directives, hostNodeName + attrsOmit.join('')); + + const result = new Set(); + for (const dir of directivesWithAttribute) { + if (!directivesWithoutAttribute.has(dir)) { + result.add(dir); + } + } + return result; +} + +/** + * Returns a new `ts.SymbolDisplayPart` array which has the alias imports from the tcb filtered + * out, i.e. `i0.NgForOf`. + */ +export function filterAliasImports(displayParts: ts.SymbolDisplayPart[]): ts.SymbolDisplayPart[] { + const tcbAliasImportRegex = /i\d+/; + function isImportAlias(part: {kind: string, text: string}) { + return part.kind === ALIAS_NAME && tcbAliasImportRegex.test(part.text); + } + function isDotPunctuation(part: {kind: string, text: string}) { + return part.kind === SYMBOL_PUNC && part.text === '.'; + } + + return displayParts.filter((part, i) => { + const previousPart = displayParts[i - 1]; + const nextPart = displayParts[i + 1]; + + const aliasNameFollowedByDot = + isImportAlias(part) && nextPart !== undefined && isDotPunctuation(nextPart); + const dotPrecededByAlias = + isDotPunctuation(part) && previousPart !== undefined && isImportAlias(previousPart); + + return !aliasNameFollowedByDot && !dotPrecededByAlias; + }); +} diff --git a/packages/language-service/src/hover.ts b/packages/language-service/src/hover.ts index 8940d2db09..781d06d2b9 100644 --- a/packages/language-service/src/hover.ts +++ b/packages/language-service/src/hover.ts @@ -13,10 +13,11 @@ import * as ng from './types'; import {inSpan} from './utils'; // Reverse mappings of enum would generate strings -const SYMBOL_SPACE = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.space]; -const SYMBOL_PUNC = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.punctuation]; -const SYMBOL_TEXT = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.text]; -const SYMBOL_INTERFACE = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.interfaceName]; +export const SYMBOL_SPACE = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.space]; +export const SYMBOL_PUNC = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.punctuation]; +export const SYMBOL_TEXT = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.text]; +export const SYMBOL_INTERFACE = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.interfaceName]; +export const ALIAS_NAME = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.aliasName]; /** * Traverse the template AST and look for the symbol located at `position`, then @@ -80,7 +81,7 @@ export function getTsHover( * @param type user-friendly name of the type * @param documentation docstring or comment */ -function createQuickInfo( +export function createQuickInfo( name: string, kind: string, textSpan: ts.TextSpan, containerName?: string, type?: string, documentation?: ts.SymbolDisplayPart[]): ts.QuickInfo { const containerDisplayParts = containerName ? diff --git a/packages/language-service/test/BUILD.bazel b/packages/language-service/test/BUILD.bazel index 553002ddfe..36bd6f7771 100644 --- a/packages/language-service/test/BUILD.bazel +++ b/packages/language-service/test/BUILD.bazel @@ -13,6 +13,7 @@ ts_library( srcs = [ "test_utils.ts", ], + visibility = ["//packages/language-service:__subpackages__"], deps = [ "//packages/compiler", "//packages/compiler-cli/test:test_utils", diff --git a/packages/language-service/test/project/tsconfig.json b/packages/language-service/test/project/tsconfig.json index d2a130c87a..be758449b5 100644 --- a/packages/language-service/test/project/tsconfig.json +++ b/packages/language-service/test/project/tsconfig.json @@ -12,7 +12,7 @@ } }, "angularCompilerOptions": { - "fullTemplateTypeCheck": true, + "strictTemplates": true, "strictInjectionParameters": true } }