feat(language-service): autocompletion of element tags (#40032)

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
This commit is contained in:
Alex Rickabaugh 2020-11-18 17:30:52 -08:00
parent ccaf48de8f
commit e42250f139
8 changed files with 293 additions and 26 deletions

View File

@ -142,6 +142,13 @@ export interface TemplateTypeChecker {
* Get basic metadata on the pipes which are in scope for the given component. * Get basic metadata on the pipes which are in scope for the given component.
*/ */
getPipesInScope(component: ts.ClassDeclaration): PipeInScope[]|null; 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>;
} }
/** /**

View File

@ -7,6 +7,7 @@
*/ */
import * as ts from 'typescript'; import * as ts from 'typescript';
import {ClassDeclaration} from '../../reflection';
/** /**
* Metadata on a directive which is available in the scope of a template. * Metadata on a directive which is available in the scope of a template.
@ -17,6 +18,11 @@ export interface DirectiveInScope {
*/ */
tsSymbol: ts.Symbol; tsSymbol: ts.Symbol;
/**
* The module which declares the directive.
*/
ngModule: ClassDeclaration|null;
/** /**
* The selector for the directive or component. * The selector for the directive or component.
*/ */

View File

@ -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. */ /** The location in the shim file for the variable that holds the type of the directive. */
shimLocation: ShimLocation; shimLocation: ShimLocation;
/** The `NgModule` that this directive is declared in or `null` if it could not be determined. */
ngModule: ClassDeclaration|null;
} }
/** /**

View File

@ -6,13 +6,13 @@
* found in the LICENSE file at https://angular.io/license * 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 * as ts from 'typescript';
import {absoluteFrom, absoluteFromSourceFile, AbsoluteFsPath, getSourceFileOrError} from '../../file_system'; import {absoluteFrom, absoluteFromSourceFile, AbsoluteFsPath, getSourceFileOrError} from '../../file_system';
import {ReferenceEmitter} from '../../imports'; import {ReferenceEmitter} from '../../imports';
import {IncrementalBuild} from '../../incremental/api'; import {IncrementalBuild} from '../../incremental/api';
import {isNamedClassDeclaration, ReflectionHost} from '../../reflection'; import {ClassDeclaration, isNamedClassDeclaration, ReflectionHost} from '../../reflection';
import {ComponentScopeReader} from '../../scope'; import {ComponentScopeReader} from '../../scope';
import {isShim} from '../../shims'; import {isShim} from '../../shims';
import {getSourceFileOrNull} from '../../util/src/typescript'; import {getSourceFileOrNull} from '../../util/src/typescript';
@ -26,6 +26,8 @@ import {TemplateSourceManager} from './source';
import {findTypeCheckBlock, getTemplateMapping, TemplateSourceResolver} from './tcb_util'; import {findTypeCheckBlock, getTemplateMapping, TemplateSourceResolver} from './tcb_util';
import {SymbolBuilder} from './template_symbol_builder'; import {SymbolBuilder} from './template_symbol_builder';
const REGISTRY = new DomElementSchemaRegistry();
/** /**
* Primary template type-checking engine, which performs type-checking using a * Primary template type-checking engine, which performs type-checking using a
* `TypeCheckingProgramStrategy` for type-checking program maintenance, and the * `TypeCheckingProgramStrategy` for type-checking program maintenance, and the
@ -54,13 +56,24 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
/** /**
* Stores directives and pipes that are in scope for each component. * 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 * 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 * the `ts.Program` changes and the `TemplateTypeCheckerImpl` as a whole is destroyed and
* replaced. * replaced.
*/ */
private scopeCache = new Map<ts.ClassDeclaration, ScopeData>(); 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; private isComplete = false;
constructor( constructor(
@ -500,6 +513,36 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
return data.pipes; 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 { private getScopeData(component: ts.ClassDeclaration): ScopeData|null {
if (this.scopeCache.has(component)) { if (this.scopeCache.has(component)) {
return this.scopeCache.get(component)!; return this.scopeCache.get(component)!;
@ -521,7 +564,7 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
}; };
const typeChecker = this.typeCheckingStrategy.getProgram().getTypeChecker(); const typeChecker = this.typeCheckingStrategy.getProgram().getTypeChecker();
for (const dir of scope.exported.directives) { for (const dir of scope.compilation.directives) {
if (dir.selector === null) { if (dir.selector === null) {
// Skip this directive, it can't be added to a template anyway. // Skip this directive, it can't be added to a template anyway.
continue; continue;
@ -530,14 +573,22 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
if (tsSymbol === undefined) { if (tsSymbol === undefined) {
continue; continue;
} }
let ngModule: ClassDeclaration|null = null;
const moduleScopeOfDir = this.componentScopeReader.getScopeForComponent(dir.ref.node);
if (moduleScopeOfDir !== null) {
ngModule = moduleScopeOfDir.ngModule;
}
data.directives.push({ data.directives.push({
isComponent: dir.isComponent, isComponent: dir.isComponent,
selector: dir.selector, selector: dir.selector,
tsSymbol, tsSymbol,
ngModule,
}); });
} }
for (const pipe of scope.exported.pipes) { for (const pipe of scope.compilation.pipes) {
const tsSymbol = typeChecker.getSymbolAtLocation(pipe.ref.node.name); const tsSymbol = typeChecker.getSymbolAtLocation(pipe.ref.node.name);
if (tsSymbol === undefined) { if (tsSymbol === undefined) {
continue; continue;

View File

@ -6,19 +6,26 @@
* found in the LICENSE file at https://angular.io/license * 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 {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core';
import {CompletionKind, TemplateDeclarationSymbol} from '@angular/compiler-cli/src/ngtsc/typecheck/api'; import {CompletionKind, DirectiveInScope, TemplateDeclarationSymbol} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
import {BoundEvent} from '@angular/compiler/src/render3/r3_ast';
import * as ts from 'typescript'; import * as ts from 'typescript';
import {DisplayInfoKind, getDisplayInfo, unsafeCastDisplayInfoKindToScriptElementKind} from './display_parts'; import {DisplayInfoKind, getDirectiveDisplayInfo, getSymbolDisplayInfo, unsafeCastDisplayInfoKindToScriptElementKind} from './display_parts';
import {filterAliasImports} from './utils'; import {filterAliasImports} from './utils';
type PropertyExpressionCompletionBuilder = type PropertyExpressionCompletionBuilder =
CompletionBuilder<PropertyRead|PropertyWrite|MethodCall|EmptyExpr|SafePropertyRead| CompletionBuilder<PropertyRead|PropertyWrite|MethodCall|EmptyExpr|SafePropertyRead|
SafeMethodCall>; SafeMethodCall>;
export enum CompletionNodeContext {
None,
ElementTag,
ElementAttributeKey,
ElementAttributeValue,
}
/** /**
* Performs autocompletion operations on a given node in the template. * Performs autocompletion operations on a given node in the template.
* *
@ -37,6 +44,7 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
constructor( constructor(
private readonly tsLS: ts.LanguageService, private readonly compiler: NgCompiler, private readonly tsLS: ts.LanguageService, private readonly compiler: NgCompiler,
private readonly component: ts.ClassDeclaration, private readonly node: N, private readonly component: ts.ClassDeclaration, private readonly node: N,
private readonly nodeContext: CompletionNodeContext,
private readonly nodeParent: TmplAstNode|AST|null, private readonly nodeParent: TmplAstNode|AST|null,
private readonly template: TmplAstTemplate|null) {} private readonly template: TmplAstTemplate|null) {}
@ -47,6 +55,8 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
undefined): ts.WithMetadata<ts.CompletionInfo>|undefined { undefined): ts.WithMetadata<ts.CompletionInfo>|undefined {
if (this.isPropertyExpressionCompletion()) { if (this.isPropertyExpressionCompletion()) {
return this.getPropertyExpressionCompletion(options); return this.getPropertyExpressionCompletion(options);
} else if (this.isElementTagCompletion()) {
return this.getElementTagCompletion();
} else { } else {
return undefined; return undefined;
} }
@ -60,6 +70,8 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
preferences: ts.UserPreferences|undefined): ts.CompletionEntryDetails|undefined { preferences: ts.UserPreferences|undefined): ts.CompletionEntryDetails|undefined {
if (this.isPropertyExpressionCompletion()) { if (this.isPropertyExpressionCompletion()) {
return this.getPropertyExpressionCompletionDetails(entryName, formatOptions, preferences); return this.getPropertyExpressionCompletionDetails(entryName, formatOptions, preferences);
} else if (this.isElementTagCompletion()) {
return this.getElementTagCompletionDetails(entryName);
} else { } else {
return undefined; return undefined;
} }
@ -71,6 +83,8 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
getCompletionEntrySymbol(name: string): ts.Symbol|undefined { getCompletionEntrySymbol(name: string): ts.Symbol|undefined {
if (this.isPropertyExpressionCompletion()) { if (this.isPropertyExpressionCompletion()) {
return this.getPropertyExpressionCompletionSymbol(name); return this.getPropertyExpressionCompletionSymbol(name);
} else if (this.isElementTagCompletion()) {
return this.getElementTagCompletionSymbol(name);
} else { } else {
return undefined; return undefined;
} }
@ -268,7 +282,7 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
} }
const {kind, displayParts, documentation} = const {kind, displayParts, documentation} =
getDisplayInfo(this.tsLS, this.typeChecker, symbol); getSymbolDisplayInfo(this.tsLS, this.typeChecker, symbol);
return { return {
kind: unsafeCastDisplayInfoKindToScriptElementKind(kind), kind: unsafeCastDisplayInfoKindToScriptElementKind(kind),
name: entryName, name: entryName,
@ -311,6 +325,84 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
/* source */ undefined); /* 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> {}
} }
/** /**
@ -323,8 +415,8 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
*/ */
function isBrokenEmptyBoundEventExpression( function isBrokenEmptyBoundEventExpression(
node: TmplAstNode|AST, parent: TmplAstNode|AST|null): node is LiteralPrimitive { node: TmplAstNode|AST, parent: TmplAstNode|AST|null): node is LiteralPrimitive {
return node instanceof LiteralPrimitive && parent !== null && parent instanceof BoundEvent && return node instanceof LiteralPrimitive && parent !== null &&
node.value === 'ERROR'; parent instanceof TmplAstBoundEvent && node.value === 'ERROR';
} }
function makeReplacementSpan(node: PropertyRead|PropertyWrite|MethodCall|SafePropertyRead| function makeReplacementSpan(node: PropertyRead|PropertyWrite|MethodCall|SafePropertyRead|
@ -334,3 +426,15 @@ function makeReplacementSpan(node: PropertyRead|PropertyWrite|MethodCall|SafePro
length: node.nameSpan.end - node.nameSpan.start, 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);
}

View File

@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license * 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'; import * as ts from 'typescript';
@ -40,7 +40,7 @@ export interface DisplayInfo {
documentation: ts.SymbolDisplayPart[]|undefined; documentation: ts.SymbolDisplayPart[]|undefined;
} }
export function getDisplayInfo( export function getSymbolDisplayInfo(
tsLS: ts.LanguageService, typeChecker: ts.TypeChecker, tsLS: ts.LanguageService, typeChecker: ts.TypeChecker,
symbol: ReferenceSymbol|VariableSymbol): DisplayInfo { symbol: ReferenceSymbol|VariableSymbol): DisplayInfo {
let kind: DisplayInfoKind; let kind: DisplayInfoKind;
@ -126,3 +126,26 @@ function getDocumentationFromTypeDefAtLocation(
return tsLS.getQuickInfoAtPosition(typeDefs[0].fileName, typeDefs[0].textSpan.start) return tsLS.getQuickInfoAtPosition(typeDefs[0].fileName, typeDefs[0].textSpan.start)
?.documentation; ?.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,
};
}

View File

@ -15,11 +15,11 @@ import * as ts from 'typescript/lib/tsserverlibrary';
import {LanguageServiceAdapter, LSParseConfigHost} from './adapters'; import {LanguageServiceAdapter, LSParseConfigHost} from './adapters';
import {CompilerFactory} from './compiler_factory'; import {CompilerFactory} from './compiler_factory';
import {CompletionBuilder} from './completions'; import {CompletionBuilder, CompletionNodeContext} from './completions';
import {DefinitionBuilder} from './definitions'; import {DefinitionBuilder} from './definitions';
import {QuickInfoBuilder} from './quick_info'; import {QuickInfoBuilder} from './quick_info';
import {ReferenceBuilder} from './references'; import {ReferenceBuilder} from './references';
import {getTargetAtPosition} from './template_target'; import {getTargetAtPosition, TargetNode, TargetNodeKind} from './template_target';
import {getTemplateInfoAtPosition, isTypeScriptFile} from './utils'; import {getTemplateInfoAtPosition, isTypeScriptFile} from './utils';
export class LanguageService { export class LanguageService {
@ -133,7 +133,8 @@ export class LanguageService {
} }
return new CompletionBuilder( return new CompletionBuilder(
this.tsLS, compiler, templateInfo.component, positionDetails.nodeInContext.node, this.tsLS, compiler, templateInfo.component, positionDetails.nodeInContext.node,
positionDetails.parent, positionDetails.template); nodeContextFromTarget(positionDetails.nodeInContext), positionDetails.parent,
positionDetails.template);
} }
getCompletionsAtPosition( getCompletionsAtPosition(
@ -161,12 +162,13 @@ export class LanguageService {
return result; return result;
} }
getCompletionEntrySymbol(fileName: string, position: number, name: string): ts.Symbol|undefined { getCompletionEntrySymbol(fileName: string, position: number, entryName: string): ts.Symbol
|undefined {
const builder = this.getCompletionBuilder(fileName, position); const builder = this.getCompletionBuilder(fileName, position);
if (builder === null) { if (builder === null) {
return undefined; return undefined;
} }
const result = builder.getCompletionEntrySymbol(name); const result = builder.getCompletionEntrySymbol(entryName);
this.compilerFactory.registerLastKnownProgram(); this.compilerFactory.registerLastKnownProgram();
return result; return result;
} }
@ -255,3 +257,16 @@ function getOrCreateTypeCheckScriptInfo(
} }
return scriptInfo; return scriptInfo;
} }
function nodeContextFromTarget(target: TargetNode): CompletionNodeContext {
switch (target.kind) {
case TargetNodeKind.ElementInTagContext:
return CompletionNodeContext.ElementTag;
case TargetNodeKind.ElementInBodyContext:
// Completions in element bodies are for new attributes.
return CompletionNodeContext.ElementAttributeKey;
default:
// No special context is available.
return CompletionNodeContext.None;
}
}

View File

@ -10,7 +10,7 @@ import {TmplAstNode} from '@angular/compiler';
import {absoluteFrom, AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system'; import {absoluteFrom, AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system';
import {initMockFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing'; import {initMockFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing';
import * as ts from 'typescript'; import * as ts from 'typescript';
import {DisplayInfoKind} from '../display_parts'; import {DisplayInfoKind, unsafeCastDisplayInfoKindToScriptElementKind} from '../display_parts';
import {LanguageService} from '../language_service'; import {LanguageService} from '../language_service';
import {LanguageServiceTestEnvironment} from './env'; import {LanguageServiceTestEnvironment} from './env';
@ -198,6 +198,61 @@ describe('completions', () => {
}); });
}); });
}); });
describe('element tag scope', () => {
it('should return DOM completions', () => {
const {ngLS, fileName, cursor} = setup(`<div¦>`, '');
const completions = ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined);
expectContain(
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.ELEMENT),
['div', 'span']);
});
it('should return directive completions', () => {
const OTHER_DIR = {
'OtherDir': `
/** This is another directive. */
@Directive({selector: 'other-dir'})
export class OtherDir {}
`,
};
const {ngLS, fileName, cursor} = setup(`<div¦>`, '', OTHER_DIR);
const completions = ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined);
expectContain(
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.DIRECTIVE),
['other-dir']);
const details =
ngLS.getCompletionEntryDetails(fileName, cursor, 'other-dir', undefined, undefined)!;
expect(details).toBeDefined();
expect(ts.displayPartsToString(details.displayParts))
.toEqual('(directive) AppModule.OtherDir');
expect(ts.displayPartsToString(details.documentation!)).toEqual('This is another directive.');
});
it('should return component completions', () => {
const OTHER_CMP = {
'OtherCmp': `
/** This is another component. */
@Component({selector: 'other-cmp', template: 'unimportant'})
export class OtherCmp {}
`,
};
const {ngLS, fileName, cursor} = setup(`<div¦>`, '', OTHER_CMP);
const completions = ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined);
expectContain(
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.COMPONENT),
['other-cmp']);
const details =
ngLS.getCompletionEntryDetails(fileName, cursor, 'other-cmp', undefined, undefined)!;
expect(details).toBeDefined();
expect(ts.displayPartsToString(details.displayParts))
.toEqual('(component) AppModule.OtherCmp');
expect(ts.displayPartsToString(details.documentation!)).toEqual('This is another component.');
});
});
}); });
function expectContain( function expectContain(
@ -223,7 +278,9 @@ function toText(displayParts?: ts.SymbolDisplayPart[]): string {
return (displayParts ?? []).map(p => p.text).join(''); return (displayParts ?? []).map(p => p.text).join('');
} }
function setup(templateWithCursor: string, classContents: string): { function setup(
templateWithCursor: string, classContents: string,
otherDirectives: {[name: string]: string} = {}): {
env: LanguageServiceTestEnvironment, env: LanguageServiceTestEnvironment,
fileName: AbsoluteFsPath, fileName: AbsoluteFsPath,
AppCmp: ts.ClassDeclaration, AppCmp: ts.ClassDeclaration,
@ -233,11 +290,16 @@ function setup(templateWithCursor: string, classContents: string): {
} { } {
const codePath = absoluteFrom('/test.ts'); const codePath = absoluteFrom('/test.ts');
const templatePath = absoluteFrom('/test.html'); const templatePath = absoluteFrom('/test.html');
const decls = ['AppCmp', ...Object.keys(otherDirectives)];
const otherDirectiveClassDecls = Object.values(otherDirectives).join('\n\n');
const env = LanguageServiceTestEnvironment.setup([ const env = LanguageServiceTestEnvironment.setup([
{ {
name: codePath, name: codePath,
contents: ` contents: `
import {Component, NgModule} from '@angular/core'; import {Component, Directive, NgModule} from '@angular/core';
@Component({ @Component({
templateUrl: './test.html', templateUrl: './test.html',
@ -247,8 +309,10 @@ function setup(templateWithCursor: string, classContents: string): {
${classContents} ${classContents}
} }
${otherDirectiveClassDecls}
@NgModule({ @NgModule({
declarations: [AppCmp], declarations: [${decls.join(', ')}],
}) })
export class AppModule {} export class AppModule {}
`, `,