From 3975dd90a6264e2554bba551ee84df142d9e50f7 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Wed, 30 Sep 2020 08:47:36 -0700 Subject: [PATCH] feat(language-service): Add getDefinitionAndBoundSpan (go to definition) (#39101) This commit adds the implementation for providing "go to definition" functionality in the Ivy Language Service. PR Close #39101 --- packages/language-service/ivy/definitions.ts | 107 +++++ .../language-service/ivy/language_service.ts | 8 + .../ivy/test/definitions_spec.ts | 420 ++++++++++++++++++ packages/language-service/ivy/ts_plugin.ts | 12 + packages/language-service/ivy/utils.ts | 5 + 5 files changed, 552 insertions(+) create mode 100644 packages/language-service/ivy/definitions.ts create mode 100644 packages/language-service/ivy/test/definitions_spec.ts diff --git a/packages/language-service/ivy/definitions.ts b/packages/language-service/ivy/definitions.ts new file mode 100644 index 0000000000..edd736a617 --- /dev/null +++ b/packages/language-service/ivy/definitions.ts @@ -0,0 +1,107 @@ +/** + * @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, TmplAstNode} from '@angular/compiler'; +import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core'; +import {ShimLocation, Symbol, SymbolKind} from '@angular/compiler-cli/src/ngtsc/typecheck/api'; +import * as ts from 'typescript'; + +import {findNodeAtPosition} from './hybrid_visitor'; +import {getTemplateInfoAtPosition, getTextSpanOfNode, isDollarEvent, toTextSpan} from './utils'; + +export class DefinitionBuilder { + constructor(private readonly tsLS: ts.LanguageService, private readonly compiler: NgCompiler) {} + + // TODO(atscott): getTypeDefinitionAtPosition + + getDefinitionAndBoundSpan(fileName: string, position: number): ts.DefinitionInfoAndBoundSpan + |undefined { + const templateInfo = getTemplateInfoAtPosition(fileName, position, this.compiler); + if (templateInfo === undefined) { + return undefined; + } + const {template, component} = templateInfo; + + const node = findNodeAtPosition(template, position); + // The `$event` of event handlers would point to the $event parameter in the shim file, as in + // `_outputHelper(_t3["x"]).subscribe(function ($event): any { $event }) ;` + // If we wanted to return something for this, it would be more appropriate for something like + // `getTypeDefinition`. + if (node === undefined || isDollarEvent(node)) { + return undefined; + } + + const symbol = this.compiler.getTemplateTypeChecker().getSymbolOfNode(node, component); + if (symbol === null) { + return undefined; + } + + const definitions = this.getDefinitionsForSymbol(symbol, node); + return {definitions, textSpan: getTextSpanOfNode(node)}; + } + + private getDefinitionsForSymbol(symbol: Symbol, node: TmplAstNode|AST): + readonly ts.DefinitionInfo[]|undefined { + switch (symbol.kind) { + case SymbolKind.Directive: + case SymbolKind.Element: + case SymbolKind.Template: + case SymbolKind.DomBinding: + // `Template` and `Element` types should not return anything because their "definitions" are + // the template locations themselves. Instead, `getTypeDefinitionAtPosition` should return + // the directive class / native element interface. `Directive` would have similar reasoning, + // though the `TemplateTypeChecker` only returns it as a list on `DomBinding`, `Element`, or + // `Template` so it's really only here for switch case completeness (it wouldn't ever appear + // here). + // + // `DomBinding` also does not return anything because the value assignment is internal to + // the TCB. Again, `getTypeDefinitionAtPosition` could return a possible directive the + // attribute binds to or the property in the native interface. + return []; + case SymbolKind.Input: + case SymbolKind.Output: + return this.getDefinitionsForSymbols(symbol.bindings); + case SymbolKind.Variable: + case SymbolKind.Reference: { + const definitions: ts.DefinitionInfo[] = []; + if (symbol.declaration !== node) { + definitions.push({ + name: symbol.declaration.name, + containerName: '', + containerKind: ts.ScriptElementKind.unknown, + kind: ts.ScriptElementKind.variableElement, + textSpan: getTextSpanOfNode(symbol.declaration), + contextSpan: toTextSpan(symbol.declaration.sourceSpan), + fileName: symbol.declaration.sourceSpan.start.file.url, + }); + } + if (symbol.kind === SymbolKind.Variable) { + definitions.push(...this.getDefinitionInfos(symbol.shimLocation)); + } + return definitions; + } + case SymbolKind.Expression: { + const {shimLocation} = symbol; + return this.getDefinitionInfos(shimLocation); + } + } + } + + private getDefinitionsForSymbols(symbols: {shimLocation: ShimLocation}[]) { + const definitions: ts.DefinitionInfo[] = []; + for (const {shimLocation} of symbols) { + definitions.push(...this.getDefinitionInfos(shimLocation)); + } + return definitions; + } + + private getDefinitionInfos({shimPath, positionInShimFile}: ShimLocation): + readonly ts.DefinitionInfo[] { + return this.tsLS.getDefinitionAtPosition(shimPath, positionInShimFile) ?? []; + } +} diff --git a/packages/language-service/ivy/language_service.ts b/packages/language-service/ivy/language_service.ts index a3a828b331..ba7ceb7c2d 100644 --- a/packages/language-service/ivy/language_service.ts +++ b/packages/language-service/ivy/language_service.ts @@ -16,6 +16,7 @@ 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 {DefinitionBuilder} from './definitions'; import {QuickInfoBuilder} from './quick_info'; export class LanguageService { @@ -47,6 +48,13 @@ export class LanguageService { throw new Error('Ivy LS currently does not support external template'); } + getDefinitionAndBoundSpan(fileName: string, position: number): ts.DefinitionInfoAndBoundSpan + |undefined { + const program = this.strategy.getProgram(); + const compiler = this.createCompiler(program); + return new DefinitionBuilder(this.tsLS, compiler).getDefinitionAndBoundSpan(fileName, position); + } + getQuickInfoAtPosition(fileName: string, position: number): ts.QuickInfo|undefined { const program = this.strategy.getProgram(); const compiler = this.createCompiler(program); diff --git a/packages/language-service/ivy/test/definitions_spec.ts b/packages/language-service/ivy/test/definitions_spec.ts new file mode 100644 index 0000000000..7dc8afe88c --- /dev/null +++ b/packages/language-service/ivy/test/definitions_spec.ts @@ -0,0 +1,420 @@ +/** + * @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} from './mock_host'; + +describe('definitions', () => { + const {project, service, tsLS} = setup(); + const ngLS = new LanguageService(project, tsLS); + + beforeEach(() => { + service.reset(); + }); + + describe('elements', () => { + it('should return nothing for native elements', () => { + const {position} = service.overwriteInlineTemplate(APP_COMPONENT, ``); + const definitionAndBoundSpan = ngLS.getDefinitionAndBoundSpan(APP_COMPONENT, position); + // The "definition" is this location itself so we should return nothing. + // getTypeDefinitionAtPosition would return the HTMLButtonElement interface. + expect(definitionAndBoundSpan!.definitions).toEqual([]); + }); + }); + + describe('templates', () => { + it('should return no definitions for ng-templates', () => { + const {position} = + service.overwriteInlineTemplate(APP_COMPONENT, ``); + const definitionAndBoundSpan = ngLS.getDefinitionAndBoundSpan(APP_COMPONENT, position); + expect(definitionAndBoundSpan!.definitions).toEqual([]); + }); + }); + + describe('directives', () => { + it('should work for directives', () => { + const definitions = getDefinitionsAndAssertBoundSpan({ + templateOverride: `
`, + expectedSpanText: 'string-model', + }); + expect(definitions).toEqual([]); + }); + + it('should work for components', () => { + const templateOverride = ` + +
some stuff in the middle
+ `; + const definitions = getDefinitionsAndAssertBoundSpan({ + templateOverride, + expectedSpanText: templateOverride.replace('¦', '').trim(), + }); + expect(definitions).toEqual([]); + }); + + it('should not return anything for structural directives where the key does not map to a binding', + () => { + const definitions = getDefinitionsAndAssertBoundSpan({ + templateOverride: `
`, + expectedSpanText: 'ngFor', + }); + expect(definitions).toEqual([]); + }); + + it('should return binding for structural directive where key maps to a binding', () => { + const definitions = getDefinitionsAndAssertBoundSpan({ + templateOverride: `
`, + expectedSpanText: 'ngIf', + }); + expect(definitions!.length).toEqual(1); + + const [def] = definitions; + expect(def.textSpan).toEqual('ngIf'); + expect(def.contextSpan).toEqual('set ngIf(condition: T);'); + }); + + it('should work for directives with compound selectors', () => { + const definitions = getDefinitionsAndAssertBoundSpan({ + templateOverride: `{{item}}`, + expectedSpanText: 'ngFor', + }); + expect(definitions).toEqual([]); + }); + }); + + describe('bindings', () => { + describe('inputs', () => { + it('should work for input providers', () => { + const definitions = getDefinitionsAndAssertBoundSpan({ + templateOverride: ``, + expectedSpanText: 'tcName', + }); + expect(definitions!.length).toEqual(1); + + const [def] = definitions; + expect(def.textSpan).toEqual('name'); + expect(def.contextSpan).toEqual(`@Input('tcName') name = 'test';`); + }); + + it('should work for structural directive inputs ngForTrackBy', () => { + const definitions = getDefinitionsAndAssertBoundSpan({ + templateOverride: `
`, + expectedSpanText: 'trackBy', + }); + expect(definitions!.length).toEqual(2); + + const [setterDef, getterDef] = definitions; + expect(setterDef.fileName).toContain('ng_for_of.d.ts'); + expect(setterDef.textSpan).toEqual('ngForTrackBy'); + expect(setterDef.contextSpan).toEqual('set ngForTrackBy(fn: TrackByFunction);'); + expect(getterDef.textSpan).toEqual('ngForTrackBy'); + expect(getterDef.contextSpan).toEqual('get ngForTrackBy(): TrackByFunction;'); + }); + + it('should work for structural directive inputs ngForOf', () => { + const definitions = getDefinitionsAndAssertBoundSpan({ + templateOverride: `
`, + expectedSpanText: 'of', + }); + expect(definitions!.length).toEqual(1); + + const [def] = definitions; + expect(def.textSpan).toEqual('ngForOf'); + expect(def.contextSpan) + .toEqual('set ngForOf(ngForOf: U & NgIterable | undefined | null);'); + }); + + it('should work for two-way binding providers', () => { + const definitions = getDefinitionsAndAssertBoundSpan({ + templateOverride: ``, + expectedSpanText: 'model', + }); + // TODO(atscott): This should really return 2 definitions, 1 for the input and 1 for the + // output. + // The TemplateTypeChecker also only returns the first match in the TCB for a given + // sourceSpan so even if we also requested the TmplAstBoundEvent, we'd still get back the + // symbol for the + // @Input because the input appears first in the TCB and they have the same sourceSpan. + expect(definitions!.length).toEqual(1); + + const [def] = definitions; + expect(def.textSpan).toEqual('model'); + expect(def.contextSpan).toEqual(`@Input() model: string = 'model';`); + }); + }); + + describe('outputs', () => { + it('should work for event providers', () => { + const definitions = getDefinitionsAndAssertBoundSpan({ + templateOverride: ``, + expectedSpanText: '(test)="myClick($event)"', + }); + expect(definitions!.length).toEqual(1); + + const [def] = definitions; + expect(def.textSpan).toEqual('testEvent'); + expect(def.contextSpan).toEqual('@Output(\'test\') testEvent = new EventEmitter();'); + }); + + it('should return nothing for $event from EventEmitter', () => { + const {position} = service.overwriteInlineTemplate( + APP_COMPONENT, `
`); + const definitionAndBoundSpan = ngLS.getDefinitionAndBoundSpan(APP_COMPONENT, position); + expect(definitionAndBoundSpan).toBeUndefined(); + }); + }); + }); + + describe('references', () => { + it('should work for element reference declarations', () => { + const definitions = getDefinitionsAndAssertBoundSpan({ + templateOverride: `
{{chart}}`, + expectedSpanText: '#chart', + }); + // We're already at the definition, so nothing is returned + expect(definitions).toEqual([]); + }); + + it('should work for element reference uses', () => { + const definitions = getDefinitionsAndAssertBoundSpan({ + templateOverride: `
{{char¦t}}`, + expectedSpanText: 'chart', + }); + expect(definitions!.length).toEqual(1); + + const [varDef] = definitions; + expect(varDef.textSpan).toEqual('#chart'); + }); + }); + + describe('variables', () => { + it('should work for array members', () => { + const definitions = getDefinitionsAndAssertBoundSpan({ + templateOverride: `
{{her¦o}}
`, + expectedSpanText: 'hero', + }); + expect(definitions!.length).toEqual(2); + + const [templateDeclarationDef, contextDef] = definitions; + expect(templateDeclarationDef.textSpan).toEqual('hero'); + // `$implicit` is from the `NgForOfContext`: + // https://github.com/angular/angular/blob/89c5255b8ca59eed27ede9e1fad69857ab0c6f4f/packages/common/src/directives/ng_for_of.ts#L15 + expect(contextDef.textSpan).toEqual('$implicit'); + expect(contextDef.contextSpan).toContain('$implicit: T;'); + }); + }); + + describe('pipes', () => { + it('should work for pipes', () => { + const templateOverride = `

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

`; + const definitions = getDefinitionsAndAssertBoundSpan({ + templateOverride, + expectedSpanText: 'date', + }); + expect(definitions!.length).toEqual(1); + + const [def] = definitions; + expect(def.textSpan).toEqual('transform'); + expect(def.contextSpan).toContain('transform(value: Date | string | number, '); + }); + }); + + describe('expressions', () => { + it('should find members in a text interpolation', () => { + const definitions = getDefinitionsAndAssertBoundSpan({ + templateOverride: `
{{ tit¦le }}
`, + expectedSpanText: 'title', + }); + expect(definitions!.length).toEqual(1); + + const [def] = definitions; + expect(def.textSpan).toEqual('title'); + expect(def.contextSpan).toEqual(`title = 'Tour of Heroes';`); + }); + + it('should work for accessed property reads', () => { + const definitions = getDefinitionsAndAssertBoundSpan({ + templateOverride: `
{{title.len¦gth}}
`, + expectedSpanText: 'length', + }); + expect(definitions!.length).toEqual(1); + + const [def] = definitions; + expect(def.textSpan).toEqual('length'); + expect(def.contextSpan).toEqual('readonly length: number;'); + }); + + it('should find members in an attribute interpolation', () => { + const definitions = getDefinitionsAndAssertBoundSpan({ + templateOverride: `
`, + expectedSpanText: 'title', + }); + expect(definitions!.length).toEqual(1); + + const [def] = definitions; + expect(def.textSpan).toEqual('title'); + expect(def.contextSpan).toEqual(`title = 'Tour of Heroes';`); + }); + + it('should find members of input binding', () => { + const definitions = getDefinitionsAndAssertBoundSpan({ + templateOverride: ``, + expectedSpanText: 'title', + }); + expect(definitions!.length).toEqual(1); + + const [def] = definitions; + expect(def.textSpan).toEqual('title'); + expect(def.contextSpan).toEqual(`title = 'Tour of Heroes';`); + }); + + it('should find members of event binding', () => { + const definitions = getDefinitionsAndAssertBoundSpan({ + templateOverride: ``, + expectedSpanText: 'title', + }); + expect(definitions!.length).toEqual(1); + + const [def] = definitions; + expect(def.textSpan).toEqual('title'); + expect(def.contextSpan).toEqual(`title = 'Tour of Heroes';`); + }); + + it('should work for method calls', () => { + const definitions = getDefinitionsAndAssertBoundSpan({ + templateOverride: `
`, + expectedSpanText: 'setTitle', + }); + expect(definitions!.length).toEqual(1); + + const [def] = definitions; + expect(def.textSpan).toEqual('setTitle'); + expect(def.contextSpan).toContain('setTitle(newTitle: string)'); + }); + + it('should work for accessed properties in writes', () => { + const definitions = getDefinitionsAndAssertBoundSpan({ + templateOverride: `
`, + expectedSpanText: 'id', + }); + expect(definitions!.length).toEqual(1); + + const [def] = definitions; + expect(def.textSpan).toEqual('id'); + expect(def.contextSpan).toEqual('id: number;'); + }); + + it('should work for method call arguments', () => { + const definitions = getDefinitionsAndAssertBoundSpan({ + templateOverride: `
`, + expectedSpanText: 'name', + }); + expect(definitions!.length).toEqual(1); + + const [def] = definitions; + expect(def.textSpan).toEqual('name'); + expect(def.contextSpan).toEqual('name: string;'); + }); + + it('should find members of two-way binding', () => { + const definitions = getDefinitionsAndAssertBoundSpan({ + templateOverride: ``, + expectedSpanText: 'title', + }); + expect(definitions!.length).toEqual(1); + + const [def] = definitions; + expect(def.textSpan).toEqual('title'); + expect(def.contextSpan).toEqual(`title = 'Tour of Heroes';`); + }); + + it('should find members in a structural directive', () => { + const definitions = getDefinitionsAndAssertBoundSpan({ + templateOverride: `
`, + expectedSpanText: 'anyValue', + }); + expect(definitions!.length).toEqual(1); + + const [def] = definitions; + expect(def.textSpan).toEqual('anyValue'); + expect(def.contextSpan).toEqual('anyValue: any;'); + }); + + it('should work for variables in structural directives', () => { + const definitions = getDefinitionsAndAssertBoundSpan({ + templateOverride: `
`, + expectedSpanText: 'heroes2', + }); + expect(definitions!.length).toEqual(1); + + const [def] = definitions; + expect(def.textSpan).toEqual('ngForOf'); + expect(def.contextSpan).toEqual('ngForOf: U;'); + }); + + it('should work for uses of members in structural directives', () => { + const definitions = getDefinitionsAndAssertBoundSpan({ + templateOverride: `
{{her¦oes2}}
`, + expectedSpanText: 'heroes2', + }); + expect(definitions!.length).toEqual(2); + + const [def, contextDef] = definitions; + expect(def.textSpan).toEqual('heroes2'); + expect(def.contextSpan).toEqual('of heroes as heroes2'); + expect(contextDef.textSpan).toEqual('ngForOf'); + expect(contextDef.contextSpan).toEqual('ngForOf: U;'); + }); + + it('should work for members in structural directives', () => { + const definitions = getDefinitionsAndAssertBoundSpan({ + templateOverride: `
`, + expectedSpanText: 'heroes', + }); + expect(definitions!.length).toEqual(1); + + const [def] = definitions; + expect(def.textSpan).toEqual('heroes'); + expect(def.contextSpan).toEqual('heroes: Hero[] = [this.hero];'); + }); + + it('should return nothing for the $any() cast function', () => { + const {position} = + service.overwriteInlineTemplate(APP_COMPONENT, `
{{$an¦y(title)}}
`); + const definitionAndBoundSpan = ngLS.getDefinitionAndBoundSpan(APP_COMPONENT, position); + expect(definitionAndBoundSpan).toBeUndefined(); + }); + }); + + function getDefinitionsAndAssertBoundSpan( + {templateOverride, expectedSpanText}: {templateOverride: string, expectedSpanText: string}): + Array<{textSpan: string, contextSpan: string | undefined, fileName: string}> { + const {position, text} = service.overwriteInlineTemplate(APP_COMPONENT, templateOverride); + const definitionAndBoundSpan = ngLS.getDefinitionAndBoundSpan(APP_COMPONENT, position); + expect(definitionAndBoundSpan).toBeTruthy(); + const {textSpan, definitions} = definitionAndBoundSpan!; + expect(text.substring(textSpan.start, textSpan.start + textSpan.length)) + .toEqual(expectedSpanText); + expect(definitions).toBeTruthy(); + return definitions!.map(d => humanizeDefinitionInfo(d)); + } + + function humanizeDefinitionInfo(def: ts.DefinitionInfo) { + const snapshot = service.getScriptInfo(def.fileName).getSnapshot(); + return { + fileName: def.fileName, + textSpan: snapshot.getText(def.textSpan.start, def.textSpan.start + def.textSpan.length), + contextSpan: def.contextSpan ? + snapshot.getText(def.contextSpan.start, def.contextSpan.start + def.contextSpan.length) : + undefined, + }; + } +}); diff --git a/packages/language-service/ivy/ts_plugin.ts b/packages/language-service/ivy/ts_plugin.ts index 67941a7796..8a238431ea 100644 --- a/packages/language-service/ivy/ts_plugin.ts +++ b/packages/language-service/ivy/ts_plugin.ts @@ -38,10 +38,22 @@ export function create(info: ts.server.PluginCreateInfo): ts.LanguageService { } } + function getDefinitionAndBoundSpan( + fileName: string, position: number): ts.DefinitionInfoAndBoundSpan|undefined { + if (angularOnly) { + return ngLS.getDefinitionAndBoundSpan(fileName, position); + } else { + // If TS could answer the query, then return that result. Otherwise, return from Angular LS. + return tsLS.getDefinitionAndBoundSpan(fileName, position) ?? + ngLS.getDefinitionAndBoundSpan(fileName, position); + } + } + return { ...tsLS, getSemanticDiagnostics, getTypeDefinitionAtPosition, getQuickInfoAtPosition, + getDefinitionAndBoundSpan, }; } diff --git a/packages/language-service/ivy/utils.ts b/packages/language-service/ivy/utils.ts index a900eb970c..b7c9bdc0ef 100644 --- a/packages/language-service/ivy/utils.ts +++ b/packages/language-service/ivy/utils.ts @@ -226,3 +226,8 @@ export function filterAliasImports(displayParts: ts.SymbolDisplayPart[]): ts.Sym return !aliasNameFollowedByDot && !dotPrecededByAlias; }); } + +export function isDollarEvent(n: t.Node|e.AST): n is e.PropertyRead { + return n instanceof e.PropertyRead && n.name === '$event' && + n.receiver instanceof e.ImplicitReceiver; +}