Skip to content

Commit

Permalink
feat(language-service): autocompletion of element tags (#40032)
Browse files Browse the repository at this point in the history
This commit expands the autocompletion capabilities of the language service
to include element tag names. It presents both DOM elements from the Angular
DOM schema as well as any components (or directives with element selectors)
that are in scope within the template as options for completion.

PR Close #40032
  • Loading branch information
alxhub committed Dec 14, 2020
1 parent ccaf48d commit e42250f
Show file tree
Hide file tree
Showing 8 changed files with 293 additions and 26 deletions.
7 changes: 7 additions & 0 deletions packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,13 @@ export interface TemplateTypeChecker {
* Get basic metadata on the pipes which are in scope for the given component.
*/
getPipesInScope(component: ts.ClassDeclaration): PipeInScope[]|null;

/**
* Retrieve a `Map` of potential template element tags, to either the `DirectiveInScope` that
* declares them (if the tag is from a directive/component), or `null` if the tag originates from
* the DOM schema.
*/
getPotentialElementTags(component: ts.ClassDeclaration): Map<string, DirectiveInScope|null>;
}

/**
Expand Down
6 changes: 6 additions & 0 deletions packages/compiler-cli/src/ngtsc/typecheck/api/scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import * as ts from 'typescript';
import {ClassDeclaration} from '../../reflection';

/**
* Metadata on a directive which is available in the scope of a template.
Expand All @@ -17,6 +18,11 @@ export interface DirectiveInScope {
*/
tsSymbol: ts.Symbol;

/**
* The module which declares the directive.
*/
ngModule: ClassDeclaration|null;

/**
* The selector for the directive or component.
*/
Expand Down
3 changes: 0 additions & 3 deletions packages/compiler-cli/src/ngtsc/typecheck/api/symbols.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,9 +261,6 @@ export interface DirectiveSymbol extends DirectiveInScope {

/** The location in the shim file for the variable that holds the type of the directive. */
shimLocation: ShimLocation;

/** The `NgModule` that this directive is declared in or `null` if it could not be determined. */
ngModule: ClassDeclaration|null;
}

/**
Expand Down
61 changes: 56 additions & 5 deletions packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@
* found in the LICENSE file at https://angular.io/license
*/

import {AST, MethodCall, ParseError, parseTemplate, PropertyRead, SafeMethodCall, SafePropertyRead, TmplAstNode, TmplAstTemplate} from '@angular/compiler';
import {AST, CssSelector, DomElementSchemaRegistry, MethodCall, ParseError, parseTemplate, PropertyRead, SafeMethodCall, SafePropertyRead, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstVariable} from '@angular/compiler';
import * as ts from 'typescript';

import {absoluteFrom, absoluteFromSourceFile, AbsoluteFsPath, getSourceFileOrError} from '../../file_system';
import {ReferenceEmitter} from '../../imports';
import {IncrementalBuild} from '../../incremental/api';
import {isNamedClassDeclaration, ReflectionHost} from '../../reflection';
import {ClassDeclaration, isNamedClassDeclaration, ReflectionHost} from '../../reflection';
import {ComponentScopeReader} from '../../scope';
import {isShim} from '../../shims';
import {getSourceFileOrNull} from '../../util/src/typescript';
Expand All @@ -26,6 +26,8 @@ import {TemplateSourceManager} from './source';
import {findTypeCheckBlock, getTemplateMapping, TemplateSourceResolver} from './tcb_util';
import {SymbolBuilder} from './template_symbol_builder';


const REGISTRY = new DomElementSchemaRegistry();
/**
* Primary template type-checking engine, which performs type-checking using a
* `TypeCheckingProgramStrategy` for type-checking program maintenance, and the
Expand Down Expand Up @@ -54,13 +56,24 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
/**
* Stores directives and pipes that are in scope for each component.
*
* Unlike the other caches, the scope of a component is not affected by its template, so this
* Unlike other caches, the scope of a component is not affected by its template, so this
* cache does not need to be invalidate if the template is overridden. It will be destroyed when
* the `ts.Program` changes and the `TemplateTypeCheckerImpl` as a whole is destroyed and
* replaced.
*/
private scopeCache = new Map<ts.ClassDeclaration, ScopeData>();

/**
* Stores potential element tags for each component (a union of DOM tags as well as directive
* tags).
*
* Unlike other caches, the scope of a component is not affected by its template, so this
* cache does not need to be invalidate if the template is overridden. It will be destroyed when
* the `ts.Program` changes and the `TemplateTypeCheckerImpl` as a whole is destroyed and
* replaced.
*/
private elementTagCache = new Map<ts.ClassDeclaration, Map<string, DirectiveInScope|null>>();

private isComplete = false;

constructor(
Expand Down Expand Up @@ -500,6 +513,36 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
return data.pipes;
}

getPotentialElementTags(component: ts.ClassDeclaration): Map<string, DirectiveInScope|null> {
if (this.elementTagCache.has(component)) {
return this.elementTagCache.get(component)!;
}

const tagMap = new Map<string, DirectiveInScope|null>();

for (const tag of REGISTRY.allKnownElementNames()) {
tagMap.set(tag, null);
}

const scope = this.getScopeData(component);
if (scope !== null) {
for (const directive of scope.directives) {
for (const selector of CssSelector.parse(directive.selector)) {
if (selector.element === null || tagMap.has(selector.element)) {
// Skip this directive if it doesn't match an element tag, or if another directive has
// already been included with the same element name.
continue;
}

tagMap.set(selector.element, directive);
}
}
}

this.elementTagCache.set(component, tagMap);
return tagMap;
}

private getScopeData(component: ts.ClassDeclaration): ScopeData|null {
if (this.scopeCache.has(component)) {
return this.scopeCache.get(component)!;
Expand All @@ -521,7 +564,7 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
};

const typeChecker = this.typeCheckingStrategy.getProgram().getTypeChecker();
for (const dir of scope.exported.directives) {
for (const dir of scope.compilation.directives) {
if (dir.selector === null) {
// Skip this directive, it can't be added to a template anyway.
continue;
Expand All @@ -530,14 +573,22 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
if (tsSymbol === undefined) {
continue;
}

let ngModule: ClassDeclaration|null = null;
const moduleScopeOfDir = this.componentScopeReader.getScopeForComponent(dir.ref.node);
if (moduleScopeOfDir !== null) {
ngModule = moduleScopeOfDir.ngModule;
}

data.directives.push({
isComponent: dir.isComponent,
selector: dir.selector,
tsSymbol,
ngModule,
});
}

for (const pipe of scope.exported.pipes) {
for (const pipe of scope.compilation.pipes) {
const tsSymbol = typeChecker.getSymbolAtLocation(pipe.ref.node.name);
if (tsSymbol === undefined) {
continue;
Expand Down
118 changes: 111 additions & 7 deletions packages/language-service/ivy/completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,26 @@
* found in the LICENSE file at https://angular.io/license
*/

import {AST, EmptyExpr, ImplicitReceiver, LiteralPrimitive, MethodCall, PropertyRead, PropertyWrite, SafeMethodCall, SafePropertyRead, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstVariable} from '@angular/compiler';
import {AST, EmptyExpr, ImplicitReceiver, LiteralPrimitive, MethodCall, PropertyRead, PropertyWrite, SafeMethodCall, SafePropertyRead, TmplAstBoundEvent, TmplAstElement, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstVariable} from '@angular/compiler';
import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core';
import {CompletionKind, TemplateDeclarationSymbol} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
import {BoundEvent} from '@angular/compiler/src/render3/r3_ast';
import {CompletionKind, DirectiveInScope, TemplateDeclarationSymbol} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
import * as ts from 'typescript';

import {DisplayInfoKind, getDisplayInfo, unsafeCastDisplayInfoKindToScriptElementKind} from './display_parts';
import {DisplayInfoKind, getDirectiveDisplayInfo, getSymbolDisplayInfo, unsafeCastDisplayInfoKindToScriptElementKind} from './display_parts';
import {filterAliasImports} from './utils';

type PropertyExpressionCompletionBuilder =
CompletionBuilder<PropertyRead|PropertyWrite|MethodCall|EmptyExpr|SafePropertyRead|
SafeMethodCall>;


export enum CompletionNodeContext {
None,
ElementTag,
ElementAttributeKey,
ElementAttributeValue,
}

/**
* Performs autocompletion operations on a given node in the template.
*
Expand All @@ -37,6 +44,7 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
constructor(
private readonly tsLS: ts.LanguageService, private readonly compiler: NgCompiler,
private readonly component: ts.ClassDeclaration, private readonly node: N,
private readonly nodeContext: CompletionNodeContext,
private readonly nodeParent: TmplAstNode|AST|null,
private readonly template: TmplAstTemplate|null) {}

Expand All @@ -47,6 +55,8 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
undefined): ts.WithMetadata<ts.CompletionInfo>|undefined {
if (this.isPropertyExpressionCompletion()) {
return this.getPropertyExpressionCompletion(options);
} else if (this.isElementTagCompletion()) {
return this.getElementTagCompletion();
} else {
return undefined;
}
Expand All @@ -60,6 +70,8 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
preferences: ts.UserPreferences|undefined): ts.CompletionEntryDetails|undefined {
if (this.isPropertyExpressionCompletion()) {
return this.getPropertyExpressionCompletionDetails(entryName, formatOptions, preferences);
} else if (this.isElementTagCompletion()) {
return this.getElementTagCompletionDetails(entryName);
} else {
return undefined;
}
Expand All @@ -71,6 +83,8 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
getCompletionEntrySymbol(name: string): ts.Symbol|undefined {
if (this.isPropertyExpressionCompletion()) {
return this.getPropertyExpressionCompletionSymbol(name);
} else if (this.isElementTagCompletion()) {
return this.getElementTagCompletionSymbol(name);
} else {
return undefined;
}
Expand Down Expand Up @@ -268,7 +282,7 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
}

const {kind, displayParts, documentation} =
getDisplayInfo(this.tsLS, this.typeChecker, symbol);
getSymbolDisplayInfo(this.tsLS, this.typeChecker, symbol);
return {
kind: unsafeCastDisplayInfoKindToScriptElementKind(kind),
name: entryName,
Expand Down Expand Up @@ -311,6 +325,84 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
/* source */ undefined);
}
}

private isElementTagCompletion(): this is CompletionBuilder<TmplAstElement> {
return this.node instanceof TmplAstElement &&
this.nodeContext === CompletionNodeContext.ElementTag;
}

private getElementTagCompletion(this: CompletionBuilder<TmplAstElement>):
ts.WithMetadata<ts.CompletionInfo>|undefined {
const templateTypeChecker = this.compiler.getTemplateTypeChecker();

// The replacementSpan is the tag name.
const replacementSpan: ts.TextSpan = {
start: this.node.sourceSpan.start.offset + 1, // account for leading '<'
length: this.node.name.length,
};

const entries: ts.CompletionEntry[] =
Array.from(templateTypeChecker.getPotentialElementTags(this.component))
.map(([tag, directive]) => ({
kind: tagCompletionKind(directive),
name: tag,
sortText: tag,
replacementSpan,
}));

return {
entries,
isGlobalCompletion: false,
isMemberCompletion: false,
isNewIdentifierLocation: false,
};
}

private getElementTagCompletionDetails(
this: CompletionBuilder<TmplAstElement>, entryName: string): ts.CompletionEntryDetails
|undefined {
const templateTypeChecker = this.compiler.getTemplateTypeChecker();

const tagMap = templateTypeChecker.getPotentialElementTags(this.component);
if (!tagMap.has(entryName)) {
return undefined;
}

const directive = tagMap.get(entryName)!;
let displayParts: ts.SymbolDisplayPart[];
let documentation: ts.SymbolDisplayPart[]|undefined = undefined;
if (directive === null) {
displayParts = [];
} else {
const displayInfo = getDirectiveDisplayInfo(this.tsLS, directive);
displayParts = displayInfo.displayParts;
documentation = displayInfo.documentation;
}

return {
kind: tagCompletionKind(directive),
name: entryName,
kindModifiers: ts.ScriptElementKindModifier.none,
displayParts,
documentation,
};
}

private getElementTagCompletionSymbol(this: CompletionBuilder<TmplAstElement>, entryName: string):
ts.Symbol|undefined {
const templateTypeChecker = this.compiler.getTemplateTypeChecker();

const tagMap = templateTypeChecker.getPotentialElementTags(this.component);
if (!tagMap.has(entryName)) {
return undefined;
}

const directive = tagMap.get(entryName)!;
return directive?.tsSymbol;
}

// private getElementAttributeCompletions(this: CompletionBuilder<TmplAstElement>):
// ts.WithMetadata<ts.CompletionInfo> {}
}

/**
Expand All @@ -323,8 +415,8 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
*/
function isBrokenEmptyBoundEventExpression(
node: TmplAstNode|AST, parent: TmplAstNode|AST|null): node is LiteralPrimitive {
return node instanceof LiteralPrimitive && parent !== null && parent instanceof BoundEvent &&
node.value === 'ERROR';
return node instanceof LiteralPrimitive && parent !== null &&
parent instanceof TmplAstBoundEvent && node.value === 'ERROR';
}

function makeReplacementSpan(node: PropertyRead|PropertyWrite|MethodCall|SafePropertyRead|
Expand All @@ -334,3 +426,15 @@ function makeReplacementSpan(node: PropertyRead|PropertyWrite|MethodCall|SafePro
length: node.nameSpan.end - node.nameSpan.start,
};
}

function tagCompletionKind(directive: DirectiveInScope|null): ts.ScriptElementKind {
let kind: DisplayInfoKind;
if (directive === null) {
kind = DisplayInfoKind.ELEMENT;
} else if (directive.isComponent) {
kind = DisplayInfoKind.COMPONENT;
} else {
kind = DisplayInfoKind.DIRECTIVE;
}
return unsafeCastDisplayInfoKindToScriptElementKind(kind);
}
27 changes: 25 additions & 2 deletions packages/language-service/ivy/display_parts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

import {ReferenceSymbol, ShimLocation, Symbol, SymbolKind, VariableSymbol} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
import {DirectiveInScope, ReferenceSymbol, ShimLocation, Symbol, SymbolKind, VariableSymbol} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
import * as ts from 'typescript';


Expand Down Expand Up @@ -40,7 +40,7 @@ export interface DisplayInfo {
documentation: ts.SymbolDisplayPart[]|undefined;
}

export function getDisplayInfo(
export function getSymbolDisplayInfo(
tsLS: ts.LanguageService, typeChecker: ts.TypeChecker,
symbol: ReferenceSymbol|VariableSymbol): DisplayInfo {
let kind: DisplayInfoKind;
Expand Down Expand Up @@ -126,3 +126,26 @@ function getDocumentationFromTypeDefAtLocation(
return tsLS.getQuickInfoAtPosition(typeDefs[0].fileName, typeDefs[0].textSpan.start)
?.documentation;
}

export function getDirectiveDisplayInfo(
tsLS: ts.LanguageService, dir: DirectiveInScope): DisplayInfo {
const kind = dir.isComponent ? DisplayInfoKind.COMPONENT : DisplayInfoKind.DIRECTIVE;
const decl = dir.tsSymbol.declarations.find(ts.isClassDeclaration);
if (decl === undefined || decl.name === undefined) {
return {kind, displayParts: [], documentation: []};
}

const res = tsLS.getQuickInfoAtPosition(decl.getSourceFile().fileName, decl.name.getStart());
if (res === undefined) {
return {kind, displayParts: [], documentation: []};
}

const displayParts =
createDisplayParts(dir.tsSymbol.name, kind, dir.ngModule?.name?.text, undefined);

return {
kind,
displayParts,
documentation: res.documentation,
};
}

0 comments on commit e42250f

Please sign in to comment.