From e42250f13950ef20a8acbb5871dc19c20800ddad Mon Sep 17 00:00:00 2001 From: Alex Rickabaugh Date: Wed, 18 Nov 2020 17:30:52 -0800 Subject: [PATCH] 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 --- .../src/ngtsc/typecheck/api/checker.ts | 7 ++ .../src/ngtsc/typecheck/api/scope.ts | 6 + .../src/ngtsc/typecheck/api/symbols.ts | 3 - .../src/ngtsc/typecheck/src/checker.ts | 61 ++++++++- packages/language-service/ivy/completions.ts | 118 ++++++++++++++++-- .../language-service/ivy/display_parts.ts | 27 +++- .../language-service/ivy/language_service.ts | 25 +++- .../ivy/test/completions_spec.ts | 72 ++++++++++- 8 files changed, 293 insertions(+), 26 deletions(-) diff --git a/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts b/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts index 4603bd1739..f87d6662ca 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts @@ -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; } /** diff --git a/packages/compiler-cli/src/ngtsc/typecheck/api/scope.ts b/packages/compiler-cli/src/ngtsc/typecheck/api/scope.ts index 4395c0934b..54763b77a3 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/api/scope.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/api/scope.ts @@ -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. @@ -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. */ diff --git a/packages/compiler-cli/src/ngtsc/typecheck/api/symbols.ts b/packages/compiler-cli/src/ngtsc/typecheck/api/symbols.ts index f6b3cf92c6..0640ac103b 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/api/symbols.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/api/symbols.ts @@ -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; } /** diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts index 0a032a995c..22acdfa999 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts @@ -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'; @@ -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 @@ -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(); + /** + * 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>(); + private isComplete = false; constructor( @@ -500,6 +513,36 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker { return data.pipes; } + getPotentialElementTags(component: ts.ClassDeclaration): Map { + if (this.elementTagCache.has(component)) { + return this.elementTagCache.get(component)!; + } + + const tagMap = new Map(); + + 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)!; @@ -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; @@ -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; diff --git a/packages/language-service/ivy/completions.ts b/packages/language-service/ivy/completions.ts index a06493bd2f..f8aaa3c305 100644 --- a/packages/language-service/ivy/completions.ts +++ b/packages/language-service/ivy/completions.ts @@ -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; + +export enum CompletionNodeContext { + None, + ElementTag, + ElementAttributeKey, + ElementAttributeValue, +} + /** * Performs autocompletion operations on a given node in the template. * @@ -37,6 +44,7 @@ export class CompletionBuilder { 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) {} @@ -47,6 +55,8 @@ export class CompletionBuilder { undefined): ts.WithMetadata|undefined { if (this.isPropertyExpressionCompletion()) { return this.getPropertyExpressionCompletion(options); + } else if (this.isElementTagCompletion()) { + return this.getElementTagCompletion(); } else { return undefined; } @@ -60,6 +70,8 @@ export class CompletionBuilder { 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; } @@ -71,6 +83,8 @@ export class CompletionBuilder { getCompletionEntrySymbol(name: string): ts.Symbol|undefined { if (this.isPropertyExpressionCompletion()) { return this.getPropertyExpressionCompletionSymbol(name); + } else if (this.isElementTagCompletion()) { + return this.getElementTagCompletionSymbol(name); } else { return undefined; } @@ -268,7 +282,7 @@ export class CompletionBuilder { } const {kind, displayParts, documentation} = - getDisplayInfo(this.tsLS, this.typeChecker, symbol); + getSymbolDisplayInfo(this.tsLS, this.typeChecker, symbol); return { kind: unsafeCastDisplayInfoKindToScriptElementKind(kind), name: entryName, @@ -311,6 +325,84 @@ export class CompletionBuilder { /* source */ undefined); } } + + private isElementTagCompletion(): this is CompletionBuilder { + return this.node instanceof TmplAstElement && + this.nodeContext === CompletionNodeContext.ElementTag; + } + + private getElementTagCompletion(this: CompletionBuilder): + ts.WithMetadata|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, 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, 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): + // ts.WithMetadata {} } /** @@ -323,8 +415,8 @@ export class CompletionBuilder { */ 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| @@ -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); +} diff --git a/packages/language-service/ivy/display_parts.ts b/packages/language-service/ivy/display_parts.ts index 0844fe6a81..528f17e07a 100644 --- a/packages/language-service/ivy/display_parts.ts +++ b/packages/language-service/ivy/display_parts.ts @@ -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'; @@ -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; @@ -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, + }; +} \ No newline at end of file diff --git a/packages/language-service/ivy/language_service.ts b/packages/language-service/ivy/language_service.ts index 6e01c8ea04..20a0c735a0 100644 --- a/packages/language-service/ivy/language_service.ts +++ b/packages/language-service/ivy/language_service.ts @@ -15,11 +15,11 @@ import * as ts from 'typescript/lib/tsserverlibrary'; import {LanguageServiceAdapter, LSParseConfigHost} from './adapters'; import {CompilerFactory} from './compiler_factory'; -import {CompletionBuilder} from './completions'; +import {CompletionBuilder, CompletionNodeContext} from './completions'; import {DefinitionBuilder} from './definitions'; import {QuickInfoBuilder} from './quick_info'; import {ReferenceBuilder} from './references'; -import {getTargetAtPosition} from './template_target'; +import {getTargetAtPosition, TargetNode, TargetNodeKind} from './template_target'; import {getTemplateInfoAtPosition, isTypeScriptFile} from './utils'; export class LanguageService { @@ -133,7 +133,8 @@ export class LanguageService { } return new CompletionBuilder( this.tsLS, compiler, templateInfo.component, positionDetails.nodeInContext.node, - positionDetails.parent, positionDetails.template); + nodeContextFromTarget(positionDetails.nodeInContext), positionDetails.parent, + positionDetails.template); } getCompletionsAtPosition( @@ -161,12 +162,13 @@ export class LanguageService { 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); if (builder === null) { return undefined; } - const result = builder.getCompletionEntrySymbol(name); + const result = builder.getCompletionEntrySymbol(entryName); this.compilerFactory.registerLastKnownProgram(); return result; } @@ -255,3 +257,16 @@ function getOrCreateTypeCheckScriptInfo( } 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; + } +} diff --git a/packages/language-service/ivy/test/completions_spec.ts b/packages/language-service/ivy/test/completions_spec.ts index b318f581eb..4edeed79b8 100644 --- a/packages/language-service/ivy/test/completions_spec.ts +++ b/packages/language-service/ivy/test/completions_spec.ts @@ -10,7 +10,7 @@ import {TmplAstNode} from '@angular/compiler'; import {absoluteFrom, AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system'; import {initMockFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing'; import * as ts from 'typescript'; -import {DisplayInfoKind} from '../display_parts'; +import {DisplayInfoKind, unsafeCastDisplayInfoKindToScriptElementKind} from '../display_parts'; import {LanguageService} from '../language_service'; import {LanguageServiceTestEnvironment} from './env'; @@ -198,6 +198,61 @@ describe('completions', () => { }); }); }); + + describe('element tag scope', () => { + it('should return DOM completions', () => { + const {ngLS, fileName, cursor} = setup(``, ''); + 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(``, '', 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(``, '', 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( @@ -223,7 +278,9 @@ function toText(displayParts?: ts.SymbolDisplayPart[]): string { 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, fileName: AbsoluteFsPath, AppCmp: ts.ClassDeclaration, @@ -233,11 +290,16 @@ function setup(templateWithCursor: string, classContents: string): { } { const codePath = absoluteFrom('/test.ts'); const templatePath = absoluteFrom('/test.html'); + + const decls = ['AppCmp', ...Object.keys(otherDirectives)]; + + const otherDirectiveClassDecls = Object.values(otherDirectives).join('\n\n'); + const env = LanguageServiceTestEnvironment.setup([ { name: codePath, contents: ` - import {Component, NgModule} from '@angular/core'; + import {Component, Directive, NgModule} from '@angular/core'; @Component({ templateUrl: './test.html', @@ -247,8 +309,10 @@ function setup(templateWithCursor: string, classContents: string): { ${classContents} } + ${otherDirectiveClassDecls} + @NgModule({ - declarations: [AppCmp], + declarations: [${decls.join(', ')}], }) export class AppModule {} `,