feat(language-service): support multiple symbol definitions (#34782)

In Angular, symbol can have multiple definitions (e.g. a two-way
binding). This commit adds support for for multiple definitions for a
queried location in a template.

PR Close #34782
This commit is contained in:
Ayaz Hafiz 2020-01-14 18:54:40 -08:00 committed by Andrew Kushnir
parent 48f8ca5483
commit 1ea04ffc05
5 changed files with 199 additions and 154 deletions

View File

@ -9,9 +9,9 @@
import * as path from 'path'; import * as path from 'path';
import * as ts from 'typescript'; // used as value and is provided at runtime import * as ts from 'typescript'; // used as value and is provided at runtime
import {AstResult} from './common'; import {AstResult} from './common';
import {locateSymbol} from './locate_symbol'; import {locateSymbols} from './locate_symbol';
import {getPropertyAssignmentFromValue, isClassDecoratorProperty} from './template'; import {getPropertyAssignmentFromValue, isClassDecoratorProperty} from './template';
import {Span, TemplateSource} from './types'; import {Span} from './types';
import {findTightestNode} from './utils'; import {findTightestNode} from './utils';
/** /**
@ -34,30 +34,38 @@ function ngSpanToTsTextSpan(span: Span): ts.TextSpan {
*/ */
export function getDefinitionAndBoundSpan( export function getDefinitionAndBoundSpan(
info: AstResult, position: number): ts.DefinitionInfoAndBoundSpan|undefined { info: AstResult, position: number): ts.DefinitionInfoAndBoundSpan|undefined {
const symbolInfo = locateSymbol(info, position); const symbols = locateSymbols(info, position);
if (!symbolInfo) { if (!symbols.length) {
return; return;
} }
const textSpan = ngSpanToTsTextSpan(symbolInfo.span);
const textSpan = ngSpanToTsTextSpan(symbols[0].span);
const definitions: ts.DefinitionInfo[] = [];
for (const symbolInfo of symbols) {
const {symbol} = symbolInfo; const {symbol} = symbolInfo;
const {container, definition: locations} = symbol;
if (!locations || !locations.length) {
// symbol.definition is really the locations of the symbol. There could be // symbol.definition is really the locations of the symbol. There could be
// more than one. No meaningful info could be provided without any location. // more than one. No meaningful info could be provided without any location.
return {textSpan}; const {kind, name, container, definition: locations} = symbol;
if (!locations || !locations.length) {
continue;
} }
const containerKind = container ? container.kind : ts.ScriptElementKind.unknown;
const containerKind =
container ? container.kind as ts.ScriptElementKind : ts.ScriptElementKind.unknown;
const containerName = container ? container.name : ''; const containerName = container ? container.name : '';
const definitions = locations.map((location) => { definitions.push(...locations.map((location) => {
return { return {
kind: symbol.kind as ts.ScriptElementKind, kind: kind as ts.ScriptElementKind,
name: symbol.name, name,
containerKind: containerKind as ts.ScriptElementKind, containerKind,
containerName: containerName, containerName,
textSpan: ngSpanToTsTextSpan(location.span), textSpan: ngSpanToTsTextSpan(location.span),
fileName: location.fileName, fileName: location.fileName,
}; };
}); }));
}
return { return {
definitions, textSpan, definitions, textSpan,
}; };

View File

@ -148,8 +148,14 @@ export function getExpressionSymbol(
}, },
visitPropertyWrite(ast) { visitPropertyWrite(ast) {
const receiverType = getType(ast.receiver); const receiverType = getType(ast.receiver);
const {start} = ast.span;
symbol = receiverType && receiverType.members().get(ast.name); symbol = receiverType && receiverType.members().get(ast.name);
span = ast.span; // A PropertyWrite span includes both the LHS (name) and the RHS (value) of the write. In this
// visit, only the name is relevant.
// prop=$event
// ^^^^ name
// ^^^^^^ value; visited separately as a nested AST
span = {start, end: start + ast.name.length};
}, },
visitQuote(ast) {}, visitQuote(ast) {},
visitSafeMethodCall(ast) { visitSafeMethodCall(ast) {

View File

@ -10,7 +10,7 @@ import {CompileSummaryKind, StaticSymbol} from '@angular/compiler';
import * as ts from 'typescript'; import * as ts from 'typescript';
import {AstResult} from './common'; import {AstResult} from './common';
import {locateSymbol} from './locate_symbol'; import {locateSymbols} from './locate_symbol';
import * as ng from './types'; import * as ng from './types';
import {TypeScriptServiceHost} from './typescript_host'; import {TypeScriptServiceHost} from './typescript_host';
import {findTightestNode} from './utils'; import {findTightestNode} from './utils';
@ -32,10 +32,11 @@ const SYMBOL_INTERFACE = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.inter
*/ */
export function getHover(info: AstResult, position: number, host: Readonly<TypeScriptServiceHost>): export function getHover(info: AstResult, position: number, host: Readonly<TypeScriptServiceHost>):
ts.QuickInfo|undefined { ts.QuickInfo|undefined {
const symbolInfo = locateSymbol(info, position); const symbolInfo = locateSymbols(info, position)[0];
if (!symbolInfo) { if (!symbolInfo) {
return; return;
} }
const {symbol, span, compileTypeSummary} = symbolInfo; const {symbol, span, compileTypeSummary} = symbolInfo;
const textSpan = {start: span.start, length: span.end - span.start}; const textSpan = {start: span.start, length: span.end - span.start};

View File

@ -12,7 +12,7 @@ import {AstResult} from './common';
import {getExpressionScope} from './expression_diagnostics'; import {getExpressionScope} from './expression_diagnostics';
import {getExpressionSymbol} from './expressions'; import {getExpressionSymbol} from './expressions';
import {Definition, DirectiveKind, Span, Symbol} from './types'; import {Definition, DirectiveKind, Span, Symbol} from './types';
import {diagnosticInfoFromTemplateInfo, findTemplateAstAt, getPathToNodeAtPosition, inSpan, offsetSpan, spanOf} from './utils'; import {diagnosticInfoFromTemplateInfo, findTemplateAstAt, getPathToNodeAtPosition, inSpan, isNarrower, offsetSpan, spanOf} from './utils';
export interface SymbolInfo { export interface SymbolInfo {
symbol: Symbol; symbol: Symbol;
@ -21,18 +21,41 @@ export interface SymbolInfo {
} }
/** /**
* Traverse the template AST and locate the Symbol at the specified `position`. * Traverses a template AST and locates symbol(s) at a specified position.
* @param info Ast and Template Source * @param info template AST information set
* @param position location to look for * @param position location to locate symbols at
*/ */
export function locateSymbol(info: AstResult, position: number): SymbolInfo|undefined { export function locateSymbols(info: AstResult, position: number): SymbolInfo[] {
const templatePosition = position - info.template.span.start; const templatePosition = position - info.template.span.start;
// TODO: update `findTemplateAstAt` to use absolute positions.
const path = findTemplateAstAt(info.templateAst, templatePosition); const path = findTemplateAstAt(info.templateAst, templatePosition);
if (!path.tail) return [];
const narrowest = spanOf(path.tail);
const toVisit: TemplateAst[] = [];
for (let node: TemplateAst|undefined = path.tail;
node && isNarrower(spanOf(node.sourceSpan), narrowest); node = path.parentOf(node)) {
toVisit.push(node);
}
return toVisit.map(ast => locateSymbol(ast, path, info))
.filter((sym): sym is SymbolInfo => sym !== undefined);
}
/**
* Visits a template node and locates the symbol in that node at a path position.
* @param ast template AST node to visit
* @param path non-empty set of narrowing AST nodes at a position
* @param info template AST information set
*/
function locateSymbol(ast: TemplateAst, path: TemplateAstPath, info: AstResult): SymbolInfo|
undefined {
const templatePosition = path.position;
const position = templatePosition + info.template.span.start;
let compileTypeSummary: CompileTypeSummary|undefined = undefined; let compileTypeSummary: CompileTypeSummary|undefined = undefined;
if (path.tail) { let symbol: Symbol|undefined;
let symbol: Symbol|undefined = undefined; let span: Span|undefined;
let span: Span|undefined = undefined; const attributeValueSymbol = (ast: AST): boolean => {
const attributeValueSymbol = (ast: AST, inEvent: boolean = false): boolean => {
const attribute = findAttribute(info, position); const attribute = findAttribute(info, position);
if (attribute) { if (attribute) {
if (inSpan(templatePosition, spanOf(attribute.valueSpan))) { if (inSpan(templatePosition, spanOf(attribute.valueSpan))) {
@ -51,7 +74,7 @@ export function locateSymbol(info: AstResult, position: number): SymbolInfo|unde
} }
return false; return false;
}; };
path.tail.visit( ast.visit(
{ {
visitNgContent(ast) {}, visitNgContent(ast) {},
visitEmbeddedTemplate(ast) {}, visitEmbeddedTemplate(ast) {},
@ -80,7 +103,7 @@ export function locateSymbol(info: AstResult, position: number): SymbolInfo|unde
}, },
visitVariable(ast) {}, visitVariable(ast) {},
visitEvent(ast) { visitEvent(ast) {
if (!attributeValueSymbol(ast.handler, /* inEvent */ true)) { if (!attributeValueSymbol(ast.handler)) {
symbol = findOutputBinding(info, path, ast); symbol = findOutputBinding(info, path, ast);
symbol = symbol && new OverrideKindSymbol(symbol, DirectiveKind.EVENT); symbol = symbol && new OverrideKindSymbol(symbol, DirectiveKind.EVENT);
span = spanOf(ast); span = spanOf(ast);
@ -137,7 +160,6 @@ export function locateSymbol(info: AstResult, position: number): SymbolInfo|unde
if (symbol && span) { if (symbol && span) {
return {symbol, span: offsetSpan(span, info.template.span.start), compileTypeSummary}; return {symbol, span: offsetSpan(span, info.template.span.start), compileTypeSummary};
} }
}
} }
function findAttribute(info: AstResult, position: number): Attribute|undefined { function findAttribute(info: AstResult, position: number): Attribute|undefined {

View File

@ -14,6 +14,7 @@ import {TypeScriptServiceHost} from '../src/typescript_host';
import {MockTypescriptHost} from './test_utils'; import {MockTypescriptHost} from './test_utils';
const TEST_TEMPLATE = '/app/test.ng'; const TEST_TEMPLATE = '/app/test.ng';
const PARSING_CASES = '/app/parsing-cases.ts';
describe('definitions', () => { describe('definitions', () => {
const mockHost = new MockTypescriptHost(['/app/main.ts']); const mockHost = new MockTypescriptHost(['/app/main.ts']);
@ -49,28 +50,29 @@ describe('definitions', () => {
}); });
it('should be able to find a field in a attribute reference', () => { it('should be able to find a field in a attribute reference', () => {
const fileName = mockHost.addCode(` mockHost.override(TEST_TEMPLATE, `<input [(ngModel)]="«title»">`);
@Component({
template: '<input [(ngModel)]="«name»">'
})
export class MyComponent {
«ᐱnameᐱ: string;»
}`);
const marker = mockHost.getReferenceMarkerFor(fileName, 'name'); const marker = mockHost.getReferenceMarkerFor(TEST_TEMPLATE, 'title');
const result = ngService.getDefinitionAndBoundSpan(fileName, marker.start); const result = ngService.getDefinitionAndBoundSpan(TEST_TEMPLATE, marker.start);
expect(result).toBeDefined(); expect(result).toBeDefined();
const {textSpan, definitions} = result !; const {textSpan, definitions} = result !;
expect(textSpan).toEqual(marker); expect(textSpan).toEqual(marker);
expect(definitions).toBeDefined(); expect(definitions).toBeDefined();
expect(definitions !.length).toBe(1);
const def = definitions ![0];
expect(def.fileName).toBe(fileName); // There are exactly two, indentical definitions here, corresponding to the "name" on the
expect(def.name).toBe('name'); // property and event bindings of the two-way binding. The two-way binding is effectively
// syntactic sugar for `[ngModel]="name" (ngModel)="name=$event"`.
expect(definitions !.length).toBe(2);
for (const def of definitions !) {
expect(def.fileName).toBe(PARSING_CASES);
expect(def.name).toBe('title');
expect(def.kind).toBe('property'); expect(def.kind).toBe('property');
expect(def.textSpan).toEqual(mockHost.getDefinitionMarkerFor(fileName, 'name'));
const fileContent = mockHost.readFile(def.fileName);
expect(fileContent !.substring(def.textSpan.start, def.textSpan.start + def.textSpan.length))
.toEqual(`title = 'Some title';`);
}
}); });
it('should be able to find a method from a call', () => { it('should be able to find a method from a call', () => {
@ -304,18 +306,24 @@ describe('definitions', () => {
const boundedText = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'my'); const boundedText = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'my');
expect(textSpan).toEqual(boundedText); expect(textSpan).toEqual(boundedText);
// There should be exactly 1 definition
expect(definitions).toBeDefined(); expect(definitions).toBeDefined();
expect(definitions !.length).toBe(1); expect(definitions !.length).toBe(2);
const def = definitions ![0]; const [def1, def2] = definitions !;
const refFileName = '/app/parsing-cases.ts'; const refFileName = '/app/parsing-cases.ts';
expect(def.fileName).toBe(refFileName); expect(def1.fileName).toBe(refFileName);
expect(def.name).toBe('model'); expect(def1.name).toBe('model');
expect(def.kind).toBe('property'); expect(def1.kind).toBe('property');
const content = mockHost.readFile(refFileName) !; let content = mockHost.readFile(refFileName) !;
expect(content.substring(def.textSpan.start, def.textSpan.start + def.textSpan.length)) expect(content.substring(def1.textSpan.start, def1.textSpan.start + def1.textSpan.length))
.toEqual(`@Input() model: string = 'model';`); .toEqual(`@Input() model: string = 'model';`);
expect(def2.fileName).toBe(refFileName);
expect(def2.name).toBe('modelChange');
expect(def2.kind).toBe('event');
content = mockHost.readFile(refFileName) !;
expect(content.substring(def2.textSpan.start, def2.textSpan.start + def2.textSpan.length))
.toEqual(`@Output() modelChange: EventEmitter<string> = new EventEmitter();`);
}); });
it('should be able to find a template from a url', () => { it('should be able to find a template from a url', () => {