feat(language-service): Add getTypeDefinitionAtPosition (go to type definition) (#39145)
This commit adds the implementation for providing "go to type definition" functionality in the Ivy Language Service. PR Close #39145
This commit is contained in:
parent
bf717b1a31
commit
a84976fdfc
|
@ -6,43 +6,43 @@
|
|||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
|
||||
import {AST, TmplAstNode} from '@angular/compiler';
|
||||
import {AST, TmplAstNode, TmplAstTextAttribute} 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';
|
||||
import {flatMap, getDirectiveMatchesForAttribute, getDirectiveMatchesForElementTag, getTemplateInfoAtPosition, getTextSpanOfNode, isDollarEvent, TemplateInfo, toTextSpan} from './utils';
|
||||
|
||||
interface DefinitionMeta {
|
||||
node: AST|TmplAstNode;
|
||||
symbol: Symbol;
|
||||
}
|
||||
|
||||
interface HasShimLocation {
|
||||
shimLocation: ShimLocation;
|
||||
}
|
||||
|
||||
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;
|
||||
return;
|
||||
}
|
||||
const {template, component} = templateInfo;
|
||||
|
||||
const node = findNodeAtPosition(template, position);
|
||||
const definitionMeta = this.getDefinitionMetaAtPosition(templateInfo, 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)) {
|
||||
if (definitionMeta === undefined || isDollarEvent(definitionMeta.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)};
|
||||
const definitions = this.getDefinitionsForSymbol(definitionMeta.symbol, definitionMeta.node);
|
||||
return {definitions, textSpan: getTextSpanOfNode(definitionMeta.node)};
|
||||
}
|
||||
|
||||
private getDefinitionsForSymbol(symbol: Symbol, node: TmplAstNode|AST):
|
||||
|
@ -65,7 +65,7 @@ export class DefinitionBuilder {
|
|||
return [];
|
||||
case SymbolKind.Input:
|
||||
case SymbolKind.Output:
|
||||
return this.getDefinitionsForSymbols(symbol.bindings);
|
||||
return this.getDefinitionsForSymbols(...symbol.bindings);
|
||||
case SymbolKind.Variable:
|
||||
case SymbolKind.Reference: {
|
||||
const definitions: ts.DefinitionInfo[] = [];
|
||||
|
@ -81,27 +81,86 @@ export class DefinitionBuilder {
|
|||
});
|
||||
}
|
||||
if (symbol.kind === SymbolKind.Variable) {
|
||||
definitions.push(...this.getDefinitionInfos(symbol.shimLocation));
|
||||
definitions.push(...this.getDefinitionsForSymbols(symbol));
|
||||
}
|
||||
return definitions;
|
||||
}
|
||||
case SymbolKind.Expression: {
|
||||
const {shimLocation} = symbol;
|
||||
return this.getDefinitionInfos(shimLocation);
|
||||
return this.getDefinitionsForSymbols(symbol);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getDefinitionsForSymbols(symbols: {shimLocation: ShimLocation}[]) {
|
||||
const definitions: ts.DefinitionInfo[] = [];
|
||||
for (const {shimLocation} of symbols) {
|
||||
definitions.push(...this.getDefinitionInfos(shimLocation));
|
||||
}
|
||||
return definitions;
|
||||
private getDefinitionsForSymbols(...symbols: HasShimLocation[]): ts.DefinitionInfo[] {
|
||||
return flatMap(symbols, ({shimLocation}) => {
|
||||
const {shimPath, positionInShimFile} = shimLocation;
|
||||
return this.tsLS.getDefinitionAtPosition(shimPath, positionInShimFile) ?? [];
|
||||
});
|
||||
}
|
||||
|
||||
private getDefinitionInfos({shimPath, positionInShimFile}: ShimLocation):
|
||||
readonly ts.DefinitionInfo[] {
|
||||
return this.tsLS.getDefinitionAtPosition(shimPath, positionInShimFile) ?? [];
|
||||
getTypeDefinitionsAtPosition(fileName: string, position: number):
|
||||
readonly ts.DefinitionInfo[]|undefined {
|
||||
const templateInfo = getTemplateInfoAtPosition(fileName, position, this.compiler);
|
||||
if (templateInfo === undefined) {
|
||||
return;
|
||||
}
|
||||
const definitionMeta = this.getDefinitionMetaAtPosition(templateInfo, position);
|
||||
if (definitionMeta === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const {symbol, node} = definitionMeta;
|
||||
switch (symbol.kind) {
|
||||
case SymbolKind.Template: {
|
||||
const matches = getDirectiveMatchesForElementTag(symbol.templateNode, symbol.directives);
|
||||
return this.getTypeDefinitionsForSymbols(...matches);
|
||||
}
|
||||
case SymbolKind.Element: {
|
||||
const matches = getDirectiveMatchesForAttribute(
|
||||
symbol.templateNode.name, symbol.templateNode, symbol.directives);
|
||||
// If one of the directive matches is a component, we should not include the native element
|
||||
// in the results because it is replaced by the component.
|
||||
return Array.from(matches).some(dir => dir.isComponent) ?
|
||||
this.getTypeDefinitionsForSymbols(...matches) :
|
||||
this.getTypeDefinitionsForSymbols(...matches, symbol);
|
||||
}
|
||||
case SymbolKind.DomBinding: {
|
||||
if (!(node instanceof TmplAstTextAttribute)) {
|
||||
return [];
|
||||
}
|
||||
const dirs = getDirectiveMatchesForAttribute(
|
||||
node.name, symbol.host.templateNode, symbol.host.directives);
|
||||
return this.getTypeDefinitionsForSymbols(...dirs);
|
||||
}
|
||||
case SymbolKind.Output:
|
||||
case SymbolKind.Input:
|
||||
return this.getTypeDefinitionsForSymbols(...symbol.bindings);
|
||||
case SymbolKind.Reference:
|
||||
case SymbolKind.Directive:
|
||||
case SymbolKind.Expression:
|
||||
case SymbolKind.Variable:
|
||||
return this.getTypeDefinitionsForSymbols(symbol);
|
||||
}
|
||||
}
|
||||
|
||||
private getTypeDefinitionsForSymbols(...symbols: HasShimLocation[]): ts.DefinitionInfo[] {
|
||||
return flatMap(symbols, ({shimLocation}) => {
|
||||
const {shimPath, positionInShimFile} = shimLocation;
|
||||
return this.tsLS.getTypeDefinitionAtPosition(shimPath, positionInShimFile) ?? [];
|
||||
});
|
||||
}
|
||||
|
||||
private getDefinitionMetaAtPosition({template, component}: TemplateInfo, position: number):
|
||||
DefinitionMeta|undefined {
|
||||
const node = findNodeAtPosition(template, position);
|
||||
if (node === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const symbol = this.compiler.getTemplateTypeChecker().getSymbolOfNode(node, component);
|
||||
if (symbol === null) {
|
||||
return;
|
||||
}
|
||||
return {node, symbol};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -60,6 +60,14 @@ export class LanguageService {
|
|||
return new DefinitionBuilder(this.tsLS, compiler).getDefinitionAndBoundSpan(fileName, position);
|
||||
}
|
||||
|
||||
getTypeDefinitionAtPosition(fileName: string, position: number):
|
||||
readonly ts.DefinitionInfo[]|undefined {
|
||||
const program = this.strategy.getProgram();
|
||||
const compiler = this.createCompiler(program, fileName);
|
||||
return new DefinitionBuilder(this.tsLS, compiler)
|
||||
.getTypeDefinitionsAtPosition(fileName, position);
|
||||
}
|
||||
|
||||
getQuickInfoAtPosition(fileName: string, position: number): ts.QuickInfo|undefined {
|
||||
const program = this.strategy.getProgram();
|
||||
const compiler = this.createCompiler(program, fileName);
|
||||
|
|
|
@ -13,7 +13,7 @@ import * as ts from 'typescript';
|
|||
import {createQuickInfo, SYMBOL_PUNC, SYMBOL_SPACE, SYMBOL_TEXT} from '../common/quick_info';
|
||||
|
||||
import {findNodeAtPosition} from './hybrid_visitor';
|
||||
import {filterAliasImports, getDirectiveMatches, getDirectiveMatchesForAttribute, getTemplateInfoAtPosition, getTextSpanOfNode} from './utils';
|
||||
import {filterAliasImports, getDirectiveMatchesForAttribute, getDirectiveMatchesForElementTag, getTemplateInfoAtPosition, getTextSpanOfNode} from './utils';
|
||||
|
||||
/**
|
||||
* The type of Angular directive. Used for QuickInfo in template.
|
||||
|
@ -94,7 +94,7 @@ export class QuickInfoBuilder {
|
|||
|
||||
private getQuickInfoForElementSymbol(symbol: ElementSymbol): ts.QuickInfo {
|
||||
const {templateNode} = symbol;
|
||||
const matches = getDirectiveMatches(symbol.directives, templateNode.name);
|
||||
const matches = getDirectiveMatchesForElementTag(templateNode, symbol.directives);
|
||||
if (matches.size > 0) {
|
||||
return this.getQuickInfoForDirectiveSymbol(matches.values().next().value, templateNode);
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import * as ts from 'typescript/lib/tsserverlibrary';
|
|||
import {LanguageService} from '../language_service';
|
||||
|
||||
import {APP_COMPONENT, setup} from './mock_host';
|
||||
import {humanizeDefinitionInfo} from './test_utils';
|
||||
|
||||
describe('definitions', () => {
|
||||
const {project, service, tsLS} = setup();
|
||||
|
@ -404,17 +405,6 @@ describe('definitions', () => {
|
|||
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,
|
||||
};
|
||||
return definitions!.map(d => humanizeDefinitionInfo(d, service));
|
||||
}
|
||||
});
|
||||
|
|
|
@ -110,7 +110,7 @@ interface OverwriteResult {
|
|||
text: string;
|
||||
}
|
||||
|
||||
class MockService {
|
||||
export class MockService {
|
||||
private readonly overwritten = new Set<ts.server.NormalizedPath>();
|
||||
|
||||
constructor(
|
||||
|
|
|
@ -28,6 +28,14 @@ describe('quick info', () => {
|
|||
expectedDisplayString: '(element) button: HTMLButtonElement'
|
||||
});
|
||||
});
|
||||
|
||||
it('should work for directives which match native element tags', () => {
|
||||
expectQuickInfo({
|
||||
templateOverride: `<butt¦on compound custom-button></button>`,
|
||||
expectedSpanText: '<button compound custom-button></button>',
|
||||
expectedDisplayString: '(directive) AppModule.CompoundCustomButtonDirective'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('templates', () => {
|
||||
|
@ -357,8 +365,7 @@ describe('quick info', () => {
|
|||
expect(documentation).toBe('This is the title of the `AppComponent` Component.');
|
||||
});
|
||||
|
||||
// TODO(atscott): Enable once #39065 is merged
|
||||
xit('works with external template', () => {
|
||||
it('works with external template', () => {
|
||||
const {position, text} = service.overwrite(TEST_TEMPLATE, '<butt¦on></button>');
|
||||
const quickInfo = ngLS.getQuickInfoAtPosition(TEST_TEMPLATE, position);
|
||||
expect(quickInfo).toBeTruthy();
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* @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 {MockService} from './mock_host';
|
||||
|
||||
export interface HumanizedDefinitionInfo {
|
||||
fileName: string;
|
||||
textSpan: string;
|
||||
contextSpan: string|undefined;
|
||||
}
|
||||
|
||||
export function humanizeDefinitionInfo(
|
||||
def: ts.DefinitionInfo, service: MockService): HumanizedDefinitionInfo {
|
||||
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,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,314 @@
|
|||
/**
|
||||
* @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 {LanguageService} from '../language_service';
|
||||
|
||||
import {APP_COMPONENT, setup} from './mock_host';
|
||||
import {HumanizedDefinitionInfo, humanizeDefinitionInfo} from './test_utils';
|
||||
|
||||
describe('type definitions', () => {
|
||||
const {project, service, tsLS} = setup();
|
||||
const ngLS = new LanguageService(project, tsLS);
|
||||
|
||||
beforeEach(() => {
|
||||
service.reset();
|
||||
});
|
||||
|
||||
describe('elements', () => {
|
||||
it('should work for native elements', () => {
|
||||
const defs = getTypeDefinitionsAndAssertBoundSpan({
|
||||
templateOverride: `<butt¦on></button>`,
|
||||
});
|
||||
expect(defs.length).toEqual(2);
|
||||
expect(defs[0].fileName).toContain('lib.dom.d.ts');
|
||||
expect(defs[0].contextSpan).toContain('interface HTMLButtonElement extends HTMLElement');
|
||||
expect(defs[1].contextSpan).toContain('declare var HTMLButtonElement');
|
||||
});
|
||||
|
||||
it('should return directives which match the element tag', () => {
|
||||
const defs = getTypeDefinitionsAndAssertBoundSpan({
|
||||
templateOverride: `<butt¦on compound custom-button></button>`,
|
||||
});
|
||||
expect(defs.length).toEqual(3);
|
||||
expect(defs[0].contextSpan).toContain('export class CompoundCustomButtonDirective');
|
||||
expect(defs[1].contextSpan).toContain('interface HTMLButtonElement extends HTMLElement');
|
||||
expect(defs[2].contextSpan).toContain('declare var HTMLButtonElement');
|
||||
});
|
||||
});
|
||||
|
||||
describe('templates', () => {
|
||||
it('should return no definitions for ng-templates', () => {
|
||||
const {position} =
|
||||
service.overwriteInlineTemplate(APP_COMPONENT, `<ng-templ¦ate></ng-template>`);
|
||||
const defs = ngLS.getTypeDefinitionAtPosition(APP_COMPONENT, position);
|
||||
expect(defs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('directives', () => {
|
||||
it('should work for directives', () => {
|
||||
const definitions = getTypeDefinitionsAndAssertBoundSpan({
|
||||
templateOverride: `<div string-model¦></div>`,
|
||||
});
|
||||
expect(definitions.length).toEqual(1);
|
||||
expect(definitions[0].fileName).toContain('parsing-cases.ts');
|
||||
expect(definitions[0].textSpan).toEqual('StringModel');
|
||||
expect(definitions[0].contextSpan).toContain('@Directive');
|
||||
});
|
||||
|
||||
it('should work for components', () => {
|
||||
const definitions = getTypeDefinitionsAndAssertBoundSpan({
|
||||
templateOverride: `<t¦est-comp></test-comp>`,
|
||||
});
|
||||
expect(definitions.length).toEqual(1);
|
||||
expect(definitions[0].textSpan).toEqual('TestComponent');
|
||||
expect(definitions[0].contextSpan).toContain('@Component');
|
||||
});
|
||||
|
||||
it('should work for structural directives', () => {
|
||||
const definitions = getTypeDefinitionsAndAssertBoundSpan({
|
||||
templateOverride: `<div *¦ngFor="let item of heroes"></div>`,
|
||||
});
|
||||
expect(definitions.length).toEqual(1);
|
||||
expect(definitions[0].fileName).toContain('ng_for_of.d.ts');
|
||||
expect(definitions[0].textSpan).toEqual('NgForOf');
|
||||
expect(definitions[0].contextSpan)
|
||||
.toContain(
|
||||
'export declare class NgForOf<T, U extends NgIterable<T> = NgIterable<T>> implements DoCheck');
|
||||
});
|
||||
|
||||
it('should work for directives with compound selectors', () => {
|
||||
let defs = getTypeDefinitionsAndAssertBoundSpan({
|
||||
templateOverride: `<button com¦pound custom-button></button>`,
|
||||
});
|
||||
expect(defs.length).toEqual(1);
|
||||
expect(defs[0].contextSpan).toContain('export class CompoundCustomButtonDirective');
|
||||
defs = getTypeDefinitionsAndAssertBoundSpan({
|
||||
templateOverride: `<button compound cu¦stom-button></button>`,
|
||||
});
|
||||
expect(defs.length).toEqual(1);
|
||||
expect(defs[0].contextSpan).toContain('export class CompoundCustomButtonDirective');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bindings', () => {
|
||||
describe('inputs', () => {
|
||||
it('should return something for input providers with non-primitive types', () => {
|
||||
const defs = getTypeDefinitionsAndAssertBoundSpan({
|
||||
templateOverride: `<button compound custom-button [config¦]="{}"></button>`,
|
||||
});
|
||||
expect(defs.length).toEqual(1);
|
||||
expect(defs[0].textSpan).toEqual('{color?: string}');
|
||||
});
|
||||
|
||||
it('should work for structural directive inputs ngForTrackBy', () => {
|
||||
const definitions = getTypeDefinitionsAndAssertBoundSpan({
|
||||
templateOverride: `<div *ngFor="let item of heroes; tr¦ackBy: test;"></div>`,
|
||||
});
|
||||
expect(definitions!.length).toEqual(1);
|
||||
|
||||
const [def] = definitions;
|
||||
expect(def.textSpan).toEqual('TrackByFunction');
|
||||
expect(def.contextSpan).toContain('export interface TrackByFunction<T>');
|
||||
});
|
||||
|
||||
it('should work for structural directive inputs ngForOf', () => {
|
||||
const definitions = getTypeDefinitionsAndAssertBoundSpan({
|
||||
templateOverride: `<div *ngFor="let item o¦f heroes"></div>`,
|
||||
});
|
||||
expectAllArrayDefinitions(definitions);
|
||||
});
|
||||
|
||||
it('should return nothing for two-way binding providers', () => {
|
||||
const definitions = getTypeDefinitionsAndAssertBoundSpan({
|
||||
templateOverride: `<test-comp string-model [(mo¦del)]="title"></test-comp>`,
|
||||
});
|
||||
// TODO(atscott): This should actually return EventEmitter type but we only match the input
|
||||
// at the moment.
|
||||
expect(definitions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('outputs', () => {
|
||||
it('should work for event providers', () => {
|
||||
const definitions = getTypeDefinitionsAndAssertBoundSpan({
|
||||
templateOverride: `<test-comp (te¦st)="myClick($event)"></test-comp>`,
|
||||
});
|
||||
expect(definitions!.length).toEqual(2);
|
||||
|
||||
const [def, xyz] = definitions;
|
||||
expect(def.textSpan).toEqual('EventEmitter');
|
||||
expect(def.contextSpan).toContain('export interface EventEmitter<T> extends Subject<T>');
|
||||
expect(xyz.textSpan).toEqual('EventEmitter');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('references', () => {
|
||||
it('should work for element references', () => {
|
||||
const defs = getTypeDefinitionsAndAssertBoundSpan({
|
||||
templateOverride: `<div #chart></div>{{char¦t}}`,
|
||||
});
|
||||
expect(defs.length).toEqual(2);
|
||||
expect(defs[0].contextSpan).toContain('interface HTMLDivElement extends HTMLElement');
|
||||
expect(defs[1].contextSpan).toContain('declare var HTMLDivElement');
|
||||
});
|
||||
|
||||
it('should work for directive references', () => {
|
||||
const defs = getTypeDefinitionsAndAssertBoundSpan({
|
||||
templateOverride: `<div #mod¦el="stringModel" string-model></div>`,
|
||||
});
|
||||
expect(defs.length).toEqual(1);
|
||||
expect(defs[0].contextSpan).toContain('@Directive');
|
||||
expect(defs[0].contextSpan).toContain('export class StringModel');
|
||||
});
|
||||
});
|
||||
|
||||
describe('variables', () => {
|
||||
it('should work for array members', () => {
|
||||
const definitions = getTypeDefinitionsAndAssertBoundSpan({
|
||||
templateOverride: `<div *ngFor="let hero of heroes">{{her¦o}}</div>`,
|
||||
});
|
||||
expect(definitions!.length).toEqual(1);
|
||||
|
||||
expect(definitions[0].textSpan).toEqual('Hero');
|
||||
expect(definitions[0].contextSpan).toContain('export interface Hero');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pipes', () => {
|
||||
it('should work for pipes', () => {
|
||||
const templateOverride = `<p>The hero's birthday is {{birthday | da¦te: "MM/dd/yy"}}</p>`;
|
||||
const definitions = getTypeDefinitionsAndAssertBoundSpan({
|
||||
templateOverride,
|
||||
});
|
||||
expect(definitions!.length).toEqual(1);
|
||||
|
||||
const [def] = definitions;
|
||||
expect(def.textSpan).toEqual('transform');
|
||||
expect(def.contextSpan).toContain('transform(value: Date');
|
||||
});
|
||||
});
|
||||
|
||||
describe('expressions', () => {
|
||||
it('should return nothing for primitives', () => {
|
||||
const definitions = getTypeDefinitionsAndAssertBoundSpan({
|
||||
templateOverride: `<div>{{ tit¦le }}</div>`,
|
||||
});
|
||||
expect(definitions!.length).toEqual(0);
|
||||
});
|
||||
|
||||
// TODO(atscott): Investigate why this returns nothing in the test environment. This actually
|
||||
// works in the extension.
|
||||
xit('should work for functions on primitives', () => {
|
||||
const definitions = getTypeDefinitionsAndAssertBoundSpan({
|
||||
templateOverride: `<div>{{ title.toLower¦case() }}</div>`,
|
||||
});
|
||||
expect(definitions!.length).toEqual(1);
|
||||
expect(definitions[0].textSpan).toEqual('toLowerCase');
|
||||
expect(definitions[0].fileName).toContain('lib.es5.d.ts');
|
||||
});
|
||||
|
||||
it('should work for accessed property reads', () => {
|
||||
const definitions = getTypeDefinitionsAndAssertBoundSpan({
|
||||
templateOverride: `<div>{{heroes[0].addre¦ss}}</div>`,
|
||||
});
|
||||
expect(definitions!.length).toEqual(1);
|
||||
|
||||
const [def] = definitions;
|
||||
expect(def.textSpan).toEqual('Address');
|
||||
expect(def.contextSpan).toContain('export interface Address');
|
||||
});
|
||||
|
||||
it('should work for $event', () => {
|
||||
const definitions = getTypeDefinitionsAndAssertBoundSpan({
|
||||
templateOverride: `<button (click)="title=$ev¦ent"></button>`,
|
||||
});
|
||||
expect(definitions!.length).toEqual(2);
|
||||
|
||||
const [def1, def2] = definitions;
|
||||
expect(def1.textSpan).toEqual('MouseEvent');
|
||||
expect(def1.contextSpan).toContain(`interface MouseEvent extends UIEvent`);
|
||||
expect(def2.textSpan).toEqual('MouseEvent');
|
||||
expect(def2.contextSpan).toContain(`declare var MouseEvent:`);
|
||||
});
|
||||
|
||||
it('should work for method calls', () => {
|
||||
const definitions = getTypeDefinitionsAndAssertBoundSpan({
|
||||
templateOverride: `<div (click)="setT¦itle('title')"></div>`,
|
||||
});
|
||||
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 = getTypeDefinitionsAndAssertBoundSpan({
|
||||
templateOverride: `<div (click)="hero.add¦ress = undefined"></div>`,
|
||||
});
|
||||
expect(definitions!.length).toEqual(1);
|
||||
|
||||
const [def] = definitions;
|
||||
expect(def.textSpan).toEqual('Address');
|
||||
expect(def.contextSpan).toContain('export interface Address');
|
||||
});
|
||||
|
||||
it('should work for variables in structural directives', () => {
|
||||
const definitions = getTypeDefinitionsAndAssertBoundSpan({
|
||||
templateOverride: `<div *ngFor="let item of heroes as her¦oes2; trackBy: test;"></div>`,
|
||||
});
|
||||
expectAllArrayDefinitions(definitions);
|
||||
});
|
||||
|
||||
it('should work for uses of members in structural directives', () => {
|
||||
const definitions = getTypeDefinitionsAndAssertBoundSpan({
|
||||
templateOverride: `<div *ngFor="let item of heroes as heroes2">{{her¦oes2}}</div>`,
|
||||
});
|
||||
expectAllArrayDefinitions(definitions);
|
||||
});
|
||||
|
||||
it('should work for members in structural directives', () => {
|
||||
const definitions = getTypeDefinitionsAndAssertBoundSpan({
|
||||
templateOverride: `<div *ngFor="let item of her¦oes; trackBy: test;"></div>`,
|
||||
});
|
||||
expectAllArrayDefinitions(definitions);
|
||||
});
|
||||
|
||||
it('should return nothing for the $any() cast function', () => {
|
||||
const {position} =
|
||||
service.overwriteInlineTemplate(APP_COMPONENT, `<div>{{$an¦y(title)}}</div>`);
|
||||
const definitionAndBoundSpan = ngLS.getTypeDefinitionAtPosition(APP_COMPONENT, position);
|
||||
expect(definitionAndBoundSpan).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
function getTypeDefinitionsAndAssertBoundSpan({templateOverride}: {templateOverride: string}):
|
||||
HumanizedDefinitionInfo[] {
|
||||
const {position} = service.overwriteInlineTemplate(APP_COMPONENT, templateOverride);
|
||||
const defs = ngLS.getTypeDefinitionAtPosition(APP_COMPONENT, position);
|
||||
expect(defs).toBeTruthy();
|
||||
return defs!.map(d => humanizeDefinitionInfo(d, service));
|
||||
}
|
||||
|
||||
function expectAllArrayDefinitions(definitions: HumanizedDefinitionInfo[]) {
|
||||
expect(definitions!.length).toBeGreaterThan(0);
|
||||
const actualTextSpans = new Set(definitions.map(d => d.textSpan));
|
||||
expect(actualTextSpans).toEqual(new Set(['Array']));
|
||||
const possibleFileNames = [
|
||||
'lib.es5.d.ts', 'lib.es2015.core.d.ts', 'lib.es2015.iterable.d.ts',
|
||||
'lib.es2015.symbol.wellknown.d.ts', 'lib.es2016.array.include.d.ts'
|
||||
];
|
||||
for (const def of definitions) {
|
||||
const fileName = def.fileName.split('/').slice(-1)[0];
|
||||
expect(possibleFileNames)
|
||||
.toContain(fileName, `Expected ${fileName} to be one of: ${possibleFileNames}`);
|
||||
}
|
||||
}
|
||||
});
|
|
@ -24,10 +24,6 @@ export function create(info: ts.server.PluginCreateInfo): ts.LanguageService {
|
|||
return diagnostics;
|
||||
}
|
||||
|
||||
function getTypeDefinitionAtPosition(fileName: string, position: number) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getQuickInfoAtPosition(fileName: string, position: number): ts.QuickInfo|undefined {
|
||||
if (angularOnly) {
|
||||
return ngLS.getQuickInfoAtPosition(fileName, position);
|
||||
|
@ -38,6 +34,17 @@ export function create(info: ts.server.PluginCreateInfo): ts.LanguageService {
|
|||
}
|
||||
}
|
||||
|
||||
function getTypeDefinitionAtPosition(
|
||||
fileName: string, position: number): readonly ts.DefinitionInfo[]|undefined {
|
||||
if (angularOnly) {
|
||||
return ngLS.getTypeDefinitionAtPosition(fileName, position);
|
||||
} else {
|
||||
// If TS could answer the query, then return that result. Otherwise, return from Angular LS.
|
||||
return tsLS.getTypeDefinitionAtPosition(fileName, position) ??
|
||||
ngLS.getTypeDefinitionAtPosition(fileName, position);
|
||||
}
|
||||
}
|
||||
|
||||
function getDefinitionAndBoundSpan(
|
||||
fileName: string, position: number): ts.DefinitionInfoAndBoundSpan|undefined {
|
||||
if (angularOnly) {
|
||||
|
|
|
@ -14,28 +14,6 @@ import * as ts from 'typescript';
|
|||
|
||||
import {ALIAS_NAME, SYMBOL_PUNC} from '../common/quick_info';
|
||||
|
||||
/**
|
||||
* 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<DirectiveSymbol> {
|
||||
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);
|
||||
|
@ -162,45 +140,108 @@ function getInlineTemplateInfoAtPosition(
|
|||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Given an attribute node, converts it to string form.
|
||||
*/
|
||||
export function getDirectiveMatchesForAttribute(
|
||||
attribute: string, hostNode: t.Template|t.Element,
|
||||
directives: DirectiveSymbol[]): Set<DirectiveSymbol> {
|
||||
const attributes: Array<t.TextAttribute|t.BoundAttribute> =
|
||||
[...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));
|
||||
function toAttributeString(attribute: t.TextAttribute|t.BoundAttribute): string {
|
||||
return `[${attribute.name}=${attribute.valueSpan?.toString() ?? ''}]`;
|
||||
}
|
||||
|
||||
const hostNodeName = hostNode instanceof t.Template ? hostNode.tagName : hostNode.name;
|
||||
const directivesWithAttribute = getDirectiveMatches(directives, hostNodeName + attrs.join(''));
|
||||
const directivesWithoutAttribute =
|
||||
getDirectiveMatches(directives, hostNodeName + attrsOmit.join(''));
|
||||
function getNodeName(node: t.Template|t.Element): string {
|
||||
return node instanceof t.Template ? node.tagName : node.name;
|
||||
}
|
||||
|
||||
const result = new Set<DirectiveSymbol>();
|
||||
for (const dir of directivesWithAttribute) {
|
||||
if (!directivesWithoutAttribute.has(dir)) {
|
||||
/**
|
||||
* Given a template or element node, returns all attributes on the node.
|
||||
*/
|
||||
function getAttributes(node: t.Template|t.Element): Array<t.TextAttribute|t.BoundAttribute> {
|
||||
const attributes: Array<t.TextAttribute|t.BoundAttribute> = [...node.attributes, ...node.inputs];
|
||||
if (node instanceof t.Template) {
|
||||
attributes.push(...node.templateAttrs);
|
||||
}
|
||||
return attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given two `Set`s, returns all items in the `left` which do not appear in the `right`.
|
||||
*/
|
||||
function difference<T>(left: Set<T>, right: Set<T>): Set<T> {
|
||||
const result = new Set<T>();
|
||||
for (const dir of left) {
|
||||
if (!right.has(dir)) {
|
||||
result.add(dir);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an element or template, determines which directives match because the tag is present. For
|
||||
* example, if a directive selector is `div[myAttr]`, this would match div elements but would not if
|
||||
* the selector were just `[myAttr]`. We find which directives are applied because of this tag by
|
||||
* elimination: compare the directive matches with the tag present against the directive matches
|
||||
* without it. The difference would be the directives which match because the tag is present.
|
||||
*
|
||||
* @param element The element or template node that the attribute/tag is part of.
|
||||
* @param directives The list of directives to match against.
|
||||
* @returns The list of directives matching the tag name via the strategy described above.
|
||||
*/
|
||||
// TODO(atscott): Add unit tests for this and the one for attributes
|
||||
export function getDirectiveMatchesForElementTag(
|
||||
element: t.Template|t.Element, directives: DirectiveSymbol[]): Set<DirectiveSymbol> {
|
||||
const attributes = getAttributes(element);
|
||||
const allAttrs = attributes.map(toAttributeString);
|
||||
const allDirectiveMatches =
|
||||
getDirectiveMatchesForSelector(directives, getNodeName(element) + allAttrs.join(''));
|
||||
const matchesWithoutElement = getDirectiveMatchesForSelector(directives, allAttrs.join(''));
|
||||
return difference(allDirectiveMatches, matchesWithoutElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an attribute name, determines which directives match because the attribute is present. 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 name The name of the attribute
|
||||
* @param hostNode The node which the attribute appears on
|
||||
* @param directives The list of directives to match against.
|
||||
* @returns The list of directives matching the tag name via the strategy described above.
|
||||
*/
|
||||
export function getDirectiveMatchesForAttribute(
|
||||
name: string, hostNode: t.Template|t.Element,
|
||||
directives: DirectiveSymbol[]): Set<DirectiveSymbol> {
|
||||
const attributes = getAttributes(hostNode);
|
||||
const allAttrs = attributes.map(toAttributeString);
|
||||
const allDirectiveMatches =
|
||||
getDirectiveMatchesForSelector(directives, getNodeName(hostNode) + allAttrs.join(''));
|
||||
const attrsExcludingName = attributes.filter(a => a.name !== name).map(toAttributeString);
|
||||
const matchesWithoutAttr =
|
||||
getDirectiveMatchesForSelector(directives, attrsExcludingName.join(''));
|
||||
return difference(allDirectiveMatches, matchesWithoutAttr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a list of directives and a text to use as a selector, returns the directives which match
|
||||
* for the selector.
|
||||
*/
|
||||
function getDirectiveMatchesForSelector(
|
||||
directives: DirectiveSymbol[], selector: string): Set<DirectiveSymbol> {
|
||||
const selectors = CssSelector.parse(selector);
|
||||
if (selectors.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 selectors.some(selector => matcher.match(selector, null));
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new `ts.SymbolDisplayPart` array which has the alias imports from the tcb filtered
|
||||
* out, i.e. `i0.NgForOf`.
|
||||
|
@ -231,3 +272,15 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new array formed by applying a given callback function to each element of the array,
|
||||
* and then flattening the result by one level.
|
||||
*/
|
||||
export function flatMap<T, R>(items: T[]|readonly T[], f: (item: T) => R[] | readonly R[]): R[] {
|
||||
const results: R[] = [];
|
||||
for (const x of items) {
|
||||
results.push(...f(x));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
|
|
@ -8,10 +8,15 @@
|
|||
|
||||
import {Component} from '@angular/core';
|
||||
|
||||
export interface Address {
|
||||
streetName: string;
|
||||
}
|
||||
|
||||
/** The most heroic being. */
|
||||
export interface Hero {
|
||||
id: number;
|
||||
name: string;
|
||||
address?: Address;
|
||||
}
|
||||
|
||||
@Component({
|
||||
|
|
|
@ -24,6 +24,7 @@ import * as ParsingCases from './parsing-cases';
|
|||
ParsingCases.TestComponent,
|
||||
ParsingCases.TestPipe,
|
||||
ParsingCases.WithContextDirective,
|
||||
ParsingCases.CompoundCustomButtonDirective,
|
||||
]
|
||||
})
|
||||
export class AppModule {
|
||||
|
|
|
@ -12,6 +12,7 @@ import {Hero} from './app.component';
|
|||
|
||||
@Directive({
|
||||
selector: '[string-model]',
|
||||
exportAs: 'stringModel',
|
||||
})
|
||||
export class StringModel {
|
||||
@Input() model: string = 'model';
|
||||
|
@ -69,6 +70,11 @@ export class WithContextDirective {
|
|||
}
|
||||
}
|
||||
|
||||
@Directive({selector: 'button[custom-button][compound]'})
|
||||
export class CompoundCustomButtonDirective {
|
||||
@Input() config?: {color?: string};
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'prefixPipe',
|
||||
})
|
||||
|
|
Loading…
Reference in New Issue