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 {} `,