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:
Andrew Scott 2020-10-02 13:54:18 -07:00 committed by atscott
parent bf717b1a31
commit a84976fdfc
13 changed files with 579 additions and 102 deletions

View File

@ -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};
}
}

View File

@ -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);

View File

@ -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);
}

View File

@ -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));
}
});

View File

@ -110,7 +110,7 @@ interface OverwriteResult {
text: string;
}
class MockService {
export class MockService {
private readonly overwritten = new Set<ts.server.NormalizedPath>();
constructor(

View File

@ -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();

View File

@ -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,
};
}

View File

@ -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}`);
}
}
});

View File

@ -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) {

View File

@ -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;
}

View File

@ -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({

View File

@ -24,6 +24,7 @@ import * as ParsingCases from './parsing-cases';
ParsingCases.TestComponent,
ParsingCases.TestPipe,
ParsingCases.WithContextDirective,
ParsingCases.CompoundCustomButtonDirective,
]
})
export class AppModule {

View File

@ -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',
})