feat(language-service): add quick info for inline templates in ivy (#39060)

Adds implementation for `getQuickInfoAtPosition` to the Ivy Language
Service, which now returns `ts.QuickInfo` for inline templates.

PR Close #39060
This commit is contained in:
Andrew Scott 2020-09-28 11:26:07 -07:00 committed by Joey Perrott
parent 4fe673d518
commit 904adb72d2
12 changed files with 892 additions and 26 deletions

View File

@ -15,6 +15,8 @@ ts_library(
"//packages/compiler-cli/src/ngtsc/shims",
"//packages/compiler-cli/src/ngtsc/typecheck",
"//packages/compiler-cli/src/ngtsc/typecheck/api",
# TODO(atscott): Pull functions/variables common to VE and Ivy into a new package
"//packages/language-service",
"@npm//typescript",
],
)

View File

@ -10,6 +10,8 @@ import {AbsoluteSourceSpan, ParseSourceSpan} from '@angular/compiler';
import * as e from '@angular/compiler/src/expression_parser/ast'; // e for expression AST
import * as t from '@angular/compiler/src/render3/r3_ast'; // t for template AST
import {isTemplateNode, isTemplateNodeWithKeyAndValue} from './utils';
/**
* Return the template AST node or expression AST node that most accurately
* represents the node at the specified cursor `position`.
@ -165,24 +167,6 @@ class ExpressionVisitor extends e.RecursiveAstVisitor {
}
}
export function isTemplateNode(node: t.Node|e.AST): node is t.Node {
// Template node implements the Node interface so we cannot use instanceof.
return node.sourceSpan instanceof ParseSourceSpan;
}
interface NodeWithKeyAndValue extends t.Node {
keySpan: ParseSourceSpan;
valueSpan?: ParseSourceSpan;
}
export function isTemplateNodeWithKeyAndValue(node: t.Node|e.AST): node is NodeWithKeyAndValue {
return isTemplateNode(node) && node.hasOwnProperty('keySpan');
}
export function isExpressionNode(node: t.Node|e.AST): node is e.AST {
return node instanceof e.AST;
}
function getSpanIncludingEndTag(ast: t.Node) {
const result = {
start: ast.sourceSpan.start.offset,

View File

@ -16,6 +16,8 @@ import {TypeCheckShimGenerator} from '@angular/compiler-cli/src/ngtsc/typecheck'
import {OptimizeFor, TypeCheckingProgramStrategy} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
import * as ts from 'typescript/lib/tsserverlibrary';
import {QuickInfoBuilder} from './quick_info';
export class LanguageService {
private options: CompilerOptions;
private lastKnownProgram: ts.Program|null = null;
@ -45,6 +47,12 @@ export class LanguageService {
throw new Error('Ivy LS currently does not support external template');
}
getQuickInfoAtPosition(fileName: string, position: number): ts.QuickInfo|undefined {
const program = this.strategy.getProgram();
const compiler = this.createCompiler(program);
return new QuickInfoBuilder(this.tsLS, compiler).get(fileName, position);
}
private createCompiler(program: ts.Program): NgCompiler {
return new NgCompiler(
this.adapter,

View File

@ -0,0 +1,237 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {AST, BindingPipe, ImplicitReceiver, MethodCall, TmplAstBoundAttribute, TmplAstNode, TmplAstTextAttribute} from '@angular/compiler';
import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core';
import {DirectiveSymbol, DomBindingSymbol, ElementSymbol, ExpressionSymbol, InputBindingSymbol, OutputBindingSymbol, ReferenceSymbol, ShimLocation, Symbol, SymbolKind, VariableSymbol} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
import * as ts from 'typescript';
import {createQuickInfo, SYMBOL_PUNC, SYMBOL_SPACE, SYMBOL_TEXT} from '../src/hover';
import {findNodeAtPosition} from './hybrid_visitor';
import {filterAliasImports, getDirectiveMatches, getDirectiveMatchesForAttribute, getTemplateInfoAtPosition, getTextSpanOfNode} from './utils';
/**
* The type of Angular directive. Used for QuickInfo in template.
*/
export enum QuickInfoKind {
COMPONENT = 'component',
DIRECTIVE = 'directive',
EVENT = 'event',
REFERENCE = 'reference',
ELEMENT = 'element',
VARIABLE = 'variable',
PIPE = 'pipe',
PROPERTY = 'property',
METHOD = 'method',
TEMPLATE = 'template',
}
export class QuickInfoBuilder {
private readonly typeChecker = this.compiler.getNextProgram().getTypeChecker();
constructor(private readonly tsLS: ts.LanguageService, private readonly compiler: NgCompiler) {}
get(fileName: string, position: number): ts.QuickInfo|undefined {
const templateInfo = getTemplateInfoAtPosition(fileName, position, this.compiler);
if (templateInfo === undefined) {
return undefined;
}
const {template, component} = templateInfo;
const node = findNodeAtPosition(template, position);
if (node === undefined) {
return undefined;
}
const symbol = this.compiler.getTemplateTypeChecker().getSymbolOfNode(node, component);
if (symbol === null) {
return isDollarAny(node) ? createDollarAnyQuickInfo(node) : undefined;
}
return this.getQuickInfoForSymbol(symbol, node);
}
private getQuickInfoForSymbol(symbol: Symbol, node: TmplAstNode|AST): ts.QuickInfo|undefined {
switch (symbol.kind) {
case SymbolKind.Input:
case SymbolKind.Output:
return this.getQuickInfoForBindingSymbol(symbol, node);
case SymbolKind.Template:
return createNgTemplateQuickInfo(node);
case SymbolKind.Element:
return this.getQuickInfoForElementSymbol(symbol);
case SymbolKind.Variable:
return this.getQuickInfoForVariableSymbol(symbol, node);
case SymbolKind.Reference:
return this.getQuickInfoForReferenceSymbol(symbol, node);
case SymbolKind.DomBinding:
return this.getQuickInfoForDomBinding(node, symbol);
case SymbolKind.Directive:
return this.getQuickInfoAtShimLocation(symbol.shimLocation, node);
case SymbolKind.Expression:
return node instanceof BindingPipe ?
this.getQuickInfoForPipeSymbol(symbol, node) :
this.getQuickInfoAtShimLocation(symbol.shimLocation, node);
}
}
private getQuickInfoForBindingSymbol(
symbol: InputBindingSymbol|OutputBindingSymbol, node: TmplAstNode|AST): ts.QuickInfo
|undefined {
if (symbol.bindings.length === 0) {
return undefined;
}
const kind = symbol.kind === SymbolKind.Input ? QuickInfoKind.PROPERTY : QuickInfoKind.EVENT;
const quickInfo = this.getQuickInfoAtShimLocation(symbol.bindings[0].shimLocation, node);
return quickInfo === undefined ? undefined : updateQuickInfoKind(quickInfo, kind);
}
private getQuickInfoForElementSymbol(symbol: ElementSymbol): ts.QuickInfo {
const {templateNode} = symbol;
const matches = getDirectiveMatches(symbol.directives, templateNode.name);
if (matches.size > 0) {
return this.getQuickInfoForDirectiveSymbol(matches.values().next().value, templateNode);
}
return createQuickInfo(
templateNode.name, QuickInfoKind.ELEMENT, getTextSpanOfNode(templateNode),
undefined /* containerName */, this.typeChecker.typeToString(symbol.tsType));
}
private getQuickInfoForVariableSymbol(symbol: VariableSymbol, node: TmplAstNode|AST):
ts.QuickInfo {
const documentation = this.getDocumentationFromTypeDefAtLocation(symbol.shimLocation);
return createQuickInfo(
symbol.declaration.name, QuickInfoKind.VARIABLE, getTextSpanOfNode(node),
undefined /* containerName */, this.typeChecker.typeToString(symbol.tsType), documentation);
}
private getQuickInfoForReferenceSymbol(symbol: ReferenceSymbol, node: TmplAstNode|AST):
ts.QuickInfo {
const documentation = this.getDocumentationFromTypeDefAtLocation(symbol.shimLocation);
return createQuickInfo(
symbol.declaration.name, QuickInfoKind.REFERENCE, getTextSpanOfNode(node),
undefined /* containerName */, this.typeChecker.typeToString(symbol.tsType), documentation);
}
private getQuickInfoForPipeSymbol(symbol: ExpressionSymbol, node: TmplAstNode|AST): ts.QuickInfo
|undefined {
const quickInfo = this.getQuickInfoAtShimLocation(symbol.shimLocation, node);
return quickInfo === undefined ? undefined : updateQuickInfoKind(quickInfo, QuickInfoKind.PIPE);
}
private getQuickInfoForDomBinding(node: TmplAstNode|AST, symbol: DomBindingSymbol) {
if (!(node instanceof TmplAstTextAttribute) && !(node instanceof TmplAstBoundAttribute)) {
return undefined;
}
const directives = getDirectiveMatchesForAttribute(
node.name, symbol.host.templateNode, symbol.host.directives);
if (directives.size === 0) {
return undefined;
}
return this.getQuickInfoForDirectiveSymbol(directives.values().next().value, node);
}
private getQuickInfoForDirectiveSymbol(dir: DirectiveSymbol, node: TmplAstNode|AST):
ts.QuickInfo {
const kind = dir.isComponent ? QuickInfoKind.COMPONENT : QuickInfoKind.DIRECTIVE;
const documentation = this.getDocumentationFromTypeDefAtLocation(dir.shimLocation);
return createQuickInfo(
this.typeChecker.typeToString(dir.tsType), kind, getTextSpanOfNode(node),
undefined /* containerName */, undefined, documentation);
}
private getDocumentationFromTypeDefAtLocation(shimLocation: ShimLocation):
ts.SymbolDisplayPart[]|undefined {
const typeDefs = this.tsLS.getTypeDefinitionAtPosition(
shimLocation.shimPath, shimLocation.positionInShimFile);
if (typeDefs === undefined || typeDefs.length === 0) {
return undefined;
}
return this.tsLS.getQuickInfoAtPosition(typeDefs[0].fileName, typeDefs[0].textSpan.start)
?.documentation;
}
private getQuickInfoAtShimLocation(location: ShimLocation, node: TmplAstNode|AST): ts.QuickInfo
|undefined {
const quickInfo =
this.tsLS.getQuickInfoAtPosition(location.shimPath, location.positionInShimFile);
if (quickInfo === undefined || quickInfo.displayParts === undefined) {
return quickInfo;
}
quickInfo.displayParts = filterAliasImports(quickInfo.displayParts);
const textSpan = getTextSpanOfNode(node);
return {...quickInfo, textSpan};
}
}
function updateQuickInfoKind(quickInfo: ts.QuickInfo, kind: QuickInfoKind): ts.QuickInfo {
if (quickInfo.displayParts === undefined) {
return quickInfo;
}
const startsWithKind = quickInfo.displayParts.length >= 3 &&
displayPartsEqual(quickInfo.displayParts[0], {text: '(', kind: SYMBOL_PUNC}) &&
quickInfo.displayParts[1].kind === SYMBOL_TEXT &&
displayPartsEqual(quickInfo.displayParts[2], {text: ')', kind: SYMBOL_PUNC});
if (startsWithKind) {
quickInfo.displayParts[1].text = kind;
} else {
quickInfo.displayParts = [
{text: '(', kind: SYMBOL_PUNC},
{text: kind, kind: SYMBOL_TEXT},
{text: ')', kind: SYMBOL_PUNC},
{text: ' ', kind: SYMBOL_SPACE},
...quickInfo.displayParts,
];
}
return quickInfo;
}
function displayPartsEqual(a: {text: string, kind: string}, b: {text: string, kind: string}) {
return a.text === b.text && a.kind === b.kind;
}
function isDollarAny(node: TmplAstNode|AST): node is MethodCall {
return node instanceof MethodCall && node.receiver instanceof ImplicitReceiver &&
node.name === '$any' && node.args.length === 1;
}
function createDollarAnyQuickInfo(node: MethodCall): ts.QuickInfo {
return createQuickInfo(
'$any',
QuickInfoKind.METHOD,
getTextSpanOfNode(node),
/** containerName */ undefined,
'any',
[{
kind: SYMBOL_TEXT,
text: 'function to cast an expression to the `any` type',
}],
);
}
// TODO(atscott): Create special `ts.QuickInfo` for `ng-template` and `ng-container` as well.
function createNgTemplateQuickInfo(node: TmplAstNode|AST): ts.QuickInfo {
return createQuickInfo(
'ng-template',
QuickInfoKind.TEMPLATE,
getTextSpanOfNode(node),
/** containerName */ undefined,
/** type */ undefined,
[{
kind: SYMBOL_TEXT,
text:
'The `<ng-template>` is an Angular element for rendering HTML. It is never displayed directly.',
}],
);
}

View File

@ -10,7 +10,8 @@ import {ParseError, parseTemplate} from '@angular/compiler';
import * as e from '@angular/compiler/src/expression_parser/ast'; // e for expression AST
import * as t from '@angular/compiler/src/render3/r3_ast'; // t for template AST
import {findNodeAtPosition, isExpressionNode, isTemplateNode} from '../hybrid_visitor';
import {findNodeAtPosition} from '../hybrid_visitor';
import {isExpressionNode, isTemplateNode} from '../utils';
interface ParseResult {
nodes: t.Node[];

View File

@ -17,7 +17,7 @@ describe('parseNgCompilerOptions', () => {
const options = parseNgCompilerOptions(project);
expect(options).toEqual(jasmine.objectContaining({
enableIvy: true, // default for ivy is true
fullTemplateTypeCheck: true,
strictTemplates: true,
strictInjectionParameters: true,
}));
});

View File

@ -0,0 +1,393 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import * as ts from 'typescript/lib/tsserverlibrary';
import {LanguageService} from '../language_service';
import {APP_COMPONENT, setup, TEST_TEMPLATE} from './mock_host';
describe('quick info', () => {
const {project, service, tsLS} = setup();
const ngLS = new LanguageService(project, tsLS);
beforeEach(() => {
service.reset();
});
describe('elements', () => {
it('should work for native elements', () => {
expectQuickInfo({
templateOverride: `<butt¦on></button>`,
expectedSpanText: '<button></button>',
expectedDisplayString: '(element) button: HTMLButtonElement'
});
});
});
describe('templates', () => {
it('should return undefined for ng-templates', () => {
const {documentation} = expectQuickInfo({
templateOverride: `<ng-templ¦ate></ng-template>`,
expectedSpanText: '<ng-template></ng-template>',
expectedDisplayString: '(template) ng-template'
});
expect(toText(documentation))
.toContain('The `<ng-template>` is an Angular element for rendering HTML.');
});
});
describe('directives', () => {
it('should work for directives', () => {
expectQuickInfo({
templateOverride: `<div string-model¦></div>`,
expectedSpanText: 'string-model',
// TODO(atscott): Find a way to include the module
// expectedDisplayParts: '(directive) AppModule.StringModel'
expectedDisplayString: '(directive) StringModel'
});
});
it('should work for components', () => {
const {documentation} = expectQuickInfo({
templateOverride: `<t¦est-comp></test-comp>`,
expectedSpanText: '<test-comp></test-comp>',
// TODO(atscott): Find a way to include the module
// expectedDisplayParts: '(component) AppModule.TestComponent'
expectedDisplayString: '(component) TestComponent'
});
expect(toText(documentation)).toBe('This Component provides the `test-comp` selector.');
});
it('should work for structural directives', () => {
const {documentation} = expectQuickInfo({
templateOverride: `<div *¦ngFor="let item of heroes"></div>`,
expectedSpanText: 'ngFor',
expectedDisplayString: '(directive) NgForOf<Hero, Array<Hero>>'
});
expect(toText(documentation))
.toContain('A [structural directive](guide/structural-directives) that renders');
});
it('should work for directives with compound selectors, some of which are bindings', () => {
expectQuickInfo({
templateOverride: `<ng-template ngF¦or let-hero [ngForOf]="heroes">{{item}}</ng-template>`,
expectedSpanText: 'ngFor',
expectedDisplayString: '(directive) NgForOf<Hero, Array<Hero>>'
});
});
it('should work for data-let- syntax', () => {
expectQuickInfo({
templateOverride:
`<ng-template ngFor data-let-he¦ro [ngForOf]="heroes">{{item}}</ng-template>`,
expectedSpanText: 'hero',
expectedDisplayString: '(variable) hero: Hero'
});
});
});
describe('bindings', () => {
describe('inputs', () => {
it('should work for input providers', () => {
expectQuickInfo({
templateOverride: `<test-comp [tcN¦ame]="name"></test-comp>`,
expectedSpanText: 'tcName',
expectedDisplayString: '(property) TestComponent.name: string'
});
});
it('should work for bind- syntax', () => {
expectQuickInfo({
templateOverride: `<test-comp bind-tcN¦ame="name"></test-comp>`,
expectedSpanText: 'tcName',
expectedDisplayString: '(property) TestComponent.name: string'
});
expectQuickInfo({
templateOverride: `<test-comp data-bind-tcN¦ame="name"></test-comp>`,
expectedSpanText: 'tcName',
expectedDisplayString: '(property) TestComponent.name: string'
});
});
it('should work for structural directive inputs ngForTrackBy', () => {
expectQuickInfo({
templateOverride: `<div *ngFor="let item of heroes; tr¦ackBy: test;"></div>`,
expectedSpanText: 'trackBy',
expectedDisplayString:
'(property) NgForOf<Hero, Hero[]>.ngForTrackBy: TrackByFunction<Hero>'
});
});
it('should work for structural directive inputs ngForOf', () => {
expectQuickInfo({
templateOverride: `<div *ngFor="let item o¦f heroes; trackBy: test;"></div>`,
expectedSpanText: 'of',
expectedDisplayString:
'(property) NgForOf<Hero, Hero[]>.ngForOf: Hero[] | (Hero[] & Iterable<Hero>) | null | undefined'
});
});
it('should work for two-way binding providers', () => {
expectQuickInfo({
templateOverride: `<test-comp string-model [(mo¦del)]="title"></test-comp>`,
expectedSpanText: 'model',
expectedDisplayString: '(property) StringModel.model: string'
});
});
});
describe('outputs', () => {
it('should work for event providers', () => {
expectQuickInfo({
templateOverride: `<test-comp (te¦st)="myClick($event)"></test-comp>`,
expectedSpanText: '(test)="myClick($event)"',
expectedDisplayString: '(event) TestComponent.testEvent: EventEmitter<any>'
});
});
it('should work for on- syntax binding', () => {
expectQuickInfo({
templateOverride: `<test-comp on-te¦st="myClick($event)"></test-comp>`,
expectedSpanText: 'on-test="myClick($event)"',
expectedDisplayString: '(event) TestComponent.testEvent: EventEmitter<any>'
});
expectQuickInfo({
templateOverride: `<test-comp data-on-te¦st="myClick($event)"></test-comp>`,
expectedSpanText: 'data-on-test="myClick($event)"',
expectedDisplayString: '(event) TestComponent.testEvent: EventEmitter<any>'
});
});
it('should work for $event from EventEmitter', () => {
expectQuickInfo({
templateOverride: `<div string-model (modelChange)="myClick($e¦vent)"></div>`,
expectedSpanText: '$event',
expectedDisplayString: '(parameter) $event: string'
});
});
it('should work for $event from native element', () => {
expectQuickInfo({
templateOverride: `<div (click)="myClick($e¦vent)"></div>`,
expectedSpanText: '$event',
expectedDisplayString: '(parameter) $event: MouseEvent'
});
});
});
});
describe('references', () => {
it('should work for element reference declarations', () => {
const {documentation} = expectQuickInfo({
templateOverride: `<div #¦chart></div>`,
expectedSpanText: '#chart',
expectedDisplayString: '(reference) chart: HTMLDivElement'
});
expect(toText(documentation))
.toEqual(
'Provides special properties (beyond the regular HTMLElement ' +
'interface it also has available to it by inheritance) for manipulating <div> elements.');
});
it('should work for ref- syntax', () => {
expectQuickInfo({
templateOverride: `<div ref-ch¦art></div>`,
expectedSpanText: 'ref-chart',
expectedDisplayString: '(reference) chart: HTMLDivElement'
});
expectQuickInfo({
templateOverride: `<div data-ref-ch¦art></div>`,
expectedSpanText: 'data-ref-chart',
expectedDisplayString: '(reference) chart: HTMLDivElement'
});
});
});
describe('variables', () => {
it('should work for array members', () => {
const {documentation} = expectQuickInfo({
templateOverride: `<div *ngFor="let hero of heroes">{{her¦o}}</div>`,
expectedSpanText: 'hero',
expectedDisplayString: '(variable) hero: Hero'
});
expect(toText(documentation)).toEqual('The most heroic being.');
});
it('should work for ReadonlyArray members (#36191)', () => {
expectQuickInfo({
templateOverride: `<div *ngFor="let hero of readonlyHeroes">{{her¦o}}</div>`,
expectedSpanText: 'hero',
expectedDisplayString: '(variable) hero: Readonly<Hero>'
});
});
it('should work for const array members (#36191)', () => {
expectQuickInfo({
templateOverride: `<div *ngFor="let name of constNames">{{na¦me}}</div>`,
expectedSpanText: 'name',
expectedDisplayString: '(variable) name: { readonly name: "name"; }'
});
});
});
describe('pipes', () => {
it('should work for pipes', () => {
const templateOverride = `<p>The hero's birthday is {{birthday | da¦te: "MM/dd/yy"}}</p>`;
expectQuickInfo({
templateOverride,
expectedSpanText: 'date',
expectedDisplayString:
'(pipe) DatePipe.transform(value: string | number | Date, format?: string | undefined, timezone?: ' +
'string | undefined, locale?: string | undefined): string | null (+2 overloads)'
});
});
});
describe('expressions', () => {
it('should find members in a text interpolation', () => {
expectQuickInfo({
templateOverride: `<div>{{ tit¦le }}</div>`,
expectedSpanText: 'title',
expectedDisplayString: '(property) AppComponent.title: string'
});
});
it('should work for accessed property reads', () => {
expectQuickInfo({
templateOverride: `<div>{{title.len¦gth}}</div>`,
expectedSpanText: 'length',
expectedDisplayString: '(property) String.length: number'
});
});
it('should find members in an attribute interpolation', () => {
expectQuickInfo({
templateOverride: `<div string-model model="{{tit¦le}}"></div>`,
expectedSpanText: 'title',
expectedDisplayString: '(property) AppComponent.title: string'
});
});
it('should find members of input binding', () => {
expectQuickInfo({
templateOverride: `<test-comp [tcName]="ti¦tle"></test-comp>`,
expectedSpanText: 'title',
expectedDisplayString: '(property) AppComponent.title: string'
});
});
it('should find input binding on text attribute', () => {
expectQuickInfo({
templateOverride: `<test-comp tcN¦ame="title"></test-comp>`,
expectedSpanText: 'tcName="title"',
expectedDisplayString: '(property) TestComponent.name: string'
});
});
it('should find members of event binding', () => {
expectQuickInfo({
templateOverride: `<test-comp (test)="ti¦tle=$event"></test-comp>`,
expectedSpanText: 'title',
expectedDisplayString: '(property) AppComponent.title: string'
});
});
it('should work for method calls', () => {
expectQuickInfo({
templateOverride: `<div (click)="setT¦itle('title')"></div>`,
expectedSpanText: 'setTitle',
expectedDisplayString: '(method) AppComponent.setTitle(newTitle: string): void'
});
});
it('should work for accessed properties in writes', () => {
expectQuickInfo({
templateOverride: `<div (click)="hero.i¦d = 2"></div>`,
expectedSpanText: 'id',
expectedDisplayString: '(property) Hero.id: number'
});
});
it('should work for method call arguments', () => {
expectQuickInfo({
templateOverride: `<div (click)="setTitle(hero.nam¦e)"></div>`,
expectedSpanText: 'name',
expectedDisplayString: '(property) Hero.name: string'
});
});
it('should find members of two-way binding', () => {
expectQuickInfo({
templateOverride: `<input [(ngModel)]="ti¦tle" />`,
expectedSpanText: 'title',
expectedDisplayString: '(property) AppComponent.title: string'
});
});
it('should find members in a structural directive', () => {
expectQuickInfo({
templateOverride: `<div *ngIf="anyV¦alue"></div>`,
expectedSpanText: 'anyValue',
expectedDisplayString: '(property) AppComponent.anyValue: any'
});
});
it('should work for members in structural directives', () => {
expectQuickInfo({
templateOverride: `<div *ngFor="let item of her¦oes; trackBy: test;"></div>`,
expectedSpanText: 'heroes',
expectedDisplayString: '(property) AppComponent.heroes: Hero[]'
});
});
it('should work for the $any() cast function', () => {
expectQuickInfo({
templateOverride: `<div>{{$an¦y(title)}}</div>`,
expectedSpanText: '$any',
expectedDisplayString: '(method) $any: any'
});
});
it('should provide documentation', () => {
const {position} = service.overwriteInlineTemplate(APP_COMPONENT, `<div>{{¦title}}</div>`);
const quickInfo = ngLS.getQuickInfoAtPosition(APP_COMPONENT, position);
const documentation = toText(quickInfo!.documentation);
expect(documentation).toBe('This is the title of the `AppComponent` Component.');
});
// TODO(atscott): Enable once #39065 is merged
xit('works with external template', () => {
const {position, text} = service.overwrite(TEST_TEMPLATE, '<butt¦on></button>');
const quickInfo = ngLS.getQuickInfoAtPosition(TEST_TEMPLATE, position);
expect(quickInfo).toBeTruthy();
const {textSpan, displayParts} = quickInfo!;
expect(text.substring(textSpan.start, textSpan.start + textSpan.length))
.toEqual('<button></button>');
expect(toText(displayParts)).toEqual('(element) button: HTMLButtonElement');
});
});
function expectQuickInfo(
{templateOverride, expectedSpanText, expectedDisplayString}:
{templateOverride: string, expectedSpanText: string, expectedDisplayString: string}):
ts.QuickInfo {
const {position, text} = service.overwriteInlineTemplate(APP_COMPONENT, templateOverride);
const quickInfo = ngLS.getQuickInfoAtPosition(APP_COMPONENT, position);
expect(quickInfo).toBeTruthy();
const {textSpan, displayParts} = quickInfo!;
expect(text.substring(textSpan.start, textSpan.start + textSpan.length))
.toEqual(expectedSpanText);
expect(toText(displayParts)).toEqual(expectedDisplayString);
return quickInfo!;
}
});
function toText(displayParts?: ts.SymbolDisplayPart[]): string {
return (displayParts || []).map(p => p.text).join('');
}

View File

@ -28,9 +28,20 @@ export function create(info: ts.server.PluginCreateInfo): ts.LanguageService {
return undefined;
}
function getQuickInfoAtPosition(fileName: string, position: number): ts.QuickInfo|undefined {
if (angularOnly) {
return ngLS.getQuickInfoAtPosition(fileName, position);
} else {
// If TS could answer the query, then return that result. Otherwise, return from Angular LS.
return tsLS.getQuickInfoAtPosition(fileName, position) ??
ngLS.getQuickInfoAtPosition(fileName, position);
}
}
return {
...tsLS,
getSemanticDiagnostics,
getTypeDefinitionAtPosition,
getQuickInfoAtPosition,
};
}

View File

@ -0,0 +1,228 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {AbsoluteSourceSpan, CssSelector, ParseSourceSpan, SelectorMatcher} from '@angular/compiler';
import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core';
import {DirectiveSymbol} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
import * as e from '@angular/compiler/src/expression_parser/ast'; // e for expression AST
import * as t from '@angular/compiler/src/render3/r3_ast'; // t for template AST
import * as ts from 'typescript';
import {ALIAS_NAME, SYMBOL_PUNC} from '../src/hover';
/**
* Given a list of directives and a text to use as a selector, returns the directives which match
* for the selector.
*/
export function getDirectiveMatches(
directives: DirectiveSymbol[], selector: string): Set<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);
} else if (
node instanceof e.PropertyWrite || node instanceof e.MethodCall ||
node instanceof e.BindingPipe || node instanceof e.PropertyRead) {
// The `name` part of a `PropertyWrite`, `MethodCall`, and `BindingPipe` does not
// have its own AST so there is no way to retrieve a `Symbol` for just the `name` via a specific
// node.
return toTextSpan(node.nameSpan);
} else {
return toTextSpan(node.sourceSpan);
}
}
export function toTextSpan(span: AbsoluteSourceSpan|ParseSourceSpan): ts.TextSpan {
let start: number, end: number;
if (span instanceof AbsoluteSourceSpan) {
start = span.start;
end = span.end;
} else {
start = span.start.offset;
end = span.end.offset;
}
return {start, length: end - start};
}
interface NodeWithKeyAndValue extends t.Node {
keySpan: ParseSourceSpan;
valueSpan?: ParseSourceSpan;
}
export function isTemplateNodeWithKeyAndValue(node: t.Node|e.AST): node is NodeWithKeyAndValue {
return isTemplateNode(node) && node.hasOwnProperty('keySpan');
}
export function isTemplateNode(node: t.Node|e.AST): node is t.Node {
// Template node implements the Node interface so we cannot use instanceof.
return node.sourceSpan instanceof ParseSourceSpan;
}
export function isExpressionNode(node: t.Node|e.AST): node is e.AST {
return node instanceof e.AST;
}
export interface TemplateInfo {
template: t.Node[];
component: ts.ClassDeclaration;
}
/**
* Retrieves the `ts.ClassDeclaration` at a location along with its template nodes.
*/
export function getTemplateInfoAtPosition(
fileName: string, position: number, compiler: NgCompiler): TemplateInfo|undefined {
if (fileName.endsWith('.ts')) {
return getInlineTemplateInfoAtPosition(fileName, position, compiler);
} else {
return getFirstComponentForTemplateFile(fileName, compiler);
}
}
/**
* First, attempt to sort component declarations by file name.
* If the files are the same, sort by start location of the declaration.
*/
function tsDeclarationSortComparator(a: ts.Declaration, b: ts.Declaration): number {
const aFile = a.getSourceFile().fileName;
const bFile = b.getSourceFile().fileName;
if (aFile < bFile) {
return -1;
} else if (aFile > bFile) {
return 1;
} else {
return b.getFullStart() - a.getFullStart();
}
}
function getFirstComponentForTemplateFile(fileName: string, compiler: NgCompiler): TemplateInfo|
undefined {
const templateTypeChecker = compiler.getTemplateTypeChecker();
const components = compiler.getComponentsWithTemplateFile(fileName);
const sortedComponents = Array.from(components).sort(tsDeclarationSortComparator);
for (const component of sortedComponents) {
if (!ts.isClassDeclaration(component)) {
continue;
}
const template = templateTypeChecker.getTemplate(component);
if (template === null) {
continue;
}
return {template, component};
}
return undefined;
}
/**
* Retrieves the `ts.ClassDeclaration` at a location along with its template nodes.
*/
function getInlineTemplateInfoAtPosition(
fileName: string, position: number, compiler: NgCompiler): TemplateInfo|undefined {
const sourceFile = compiler.getNextProgram().getSourceFile(fileName);
if (!sourceFile) {
return undefined;
}
// We only support top level statements / class declarations
for (const statement of sourceFile.statements) {
if (!ts.isClassDeclaration(statement) || position < statement.pos || position > statement.end) {
continue;
}
const template = compiler.getTemplateTypeChecker().getTemplate(statement);
if (template === null) {
return undefined;
}
return {template, component: statement};
}
return undefined;
}
/**
* Given an attribute name and the element or template the attribute appears on, determines which
* directives match because the attribute is present. That is, we find which directives are applied
* because of this attribute by elimination: compare the directive matches with the attribute
* present against the directive matches without it. The difference would be the directives which
* match because the attribute is present.
*
* @param attribute The attribute name to use for directive matching.
* @param hostNode The element or template node that the attribute is on.
* @param directives The list of directives to match against.
* @returns The list of directives matching the attribute via the strategy described above.
*/
export function getDirectiveMatchesForAttribute(
attribute: string, hostNode: t.Template|t.Element,
directives: DirectiveSymbol[]): Set<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));
const hostNodeName = hostNode instanceof t.Template ? hostNode.tagName : hostNode.name;
const directivesWithAttribute = getDirectiveMatches(directives, hostNodeName + attrs.join(''));
const directivesWithoutAttribute =
getDirectiveMatches(directives, hostNodeName + attrsOmit.join(''));
const result = new Set<DirectiveSymbol>();
for (const dir of directivesWithAttribute) {
if (!directivesWithoutAttribute.has(dir)) {
result.add(dir);
}
}
return result;
}
/**
* Returns a new `ts.SymbolDisplayPart` array which has the alias imports from the tcb filtered
* out, i.e. `i0.NgForOf`.
*/
export function filterAliasImports(displayParts: ts.SymbolDisplayPart[]): ts.SymbolDisplayPart[] {
const tcbAliasImportRegex = /i\d+/;
function isImportAlias(part: {kind: string, text: string}) {
return part.kind === ALIAS_NAME && tcbAliasImportRegex.test(part.text);
}
function isDotPunctuation(part: {kind: string, text: string}) {
return part.kind === SYMBOL_PUNC && part.text === '.';
}
return displayParts.filter((part, i) => {
const previousPart = displayParts[i - 1];
const nextPart = displayParts[i + 1];
const aliasNameFollowedByDot =
isImportAlias(part) && nextPart !== undefined && isDotPunctuation(nextPart);
const dotPrecededByAlias =
isDotPunctuation(part) && previousPart !== undefined && isImportAlias(previousPart);
return !aliasNameFollowedByDot && !dotPrecededByAlias;
});
}

View File

@ -13,10 +13,11 @@ import * as ng from './types';
import {inSpan} from './utils';
// Reverse mappings of enum would generate strings
const SYMBOL_SPACE = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.space];
const SYMBOL_PUNC = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.punctuation];
const SYMBOL_TEXT = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.text];
const SYMBOL_INTERFACE = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.interfaceName];
export const SYMBOL_SPACE = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.space];
export const SYMBOL_PUNC = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.punctuation];
export const SYMBOL_TEXT = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.text];
export const SYMBOL_INTERFACE = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.interfaceName];
export const ALIAS_NAME = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.aliasName];
/**
* Traverse the template AST and look for the symbol located at `position`, then
@ -80,7 +81,7 @@ export function getTsHover(
* @param type user-friendly name of the type
* @param documentation docstring or comment
*/
function createQuickInfo(
export function createQuickInfo(
name: string, kind: string, textSpan: ts.TextSpan, containerName?: string, type?: string,
documentation?: ts.SymbolDisplayPart[]): ts.QuickInfo {
const containerDisplayParts = containerName ?

View File

@ -13,6 +13,7 @@ ts_library(
srcs = [
"test_utils.ts",
],
visibility = ["//packages/language-service:__subpackages__"],
deps = [
"//packages/compiler",
"//packages/compiler-cli/test:test_utils",

View File

@ -12,7 +12,7 @@
}
},
"angularCompilerOptions": {
"fullTemplateTypeCheck": true,
"strictTemplates": true,
"strictInjectionParameters": true
}
}