/** * @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, EmptyExpr, ImplicitReceiver, LiteralPrimitive, MethodCall, ParseSourceSpan, PropertyRead, PropertyWrite, SafeMethodCall, SafePropertyRead, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstElement, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstText, TmplAstTextAttribute, TmplAstVariable} from '@angular/compiler'; import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core'; 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 {addAttributeCompletionEntries, AttributeCompletionKind, buildAttributeCompletionTable, getAttributeCompletionSymbol} from './attribute_completions'; import {DisplayInfo, DisplayInfoKind, getDirectiveDisplayInfo, getSymbolDisplayInfo, getTsSymbolDisplayInfo, unsafeCastDisplayInfoKindToScriptElementKind} from './display_parts'; import {TargetContext, TargetNodeKind, TemplateTarget} from './template_target'; import {filterAliasImports} from './utils'; type PropertyExpressionCompletionBuilder = CompletionBuilder; type ElementAttributeCompletionBuilder = CompletionBuilder; type PipeCompletionBuilder = CompletionBuilder; export enum CompletionNodeContext { None, ElementTag, ElementAttributeKey, ElementAttributeValue, EventValue, TwoWayBinding, } /** * Performs autocompletion operations on a given node in the template. * * This class acts as a closure around all of the context required to perform the 3 autocompletion * operations (completions, get details, and get symbol) at a specific node. * * The generic `N` type for the template node is narrowed internally for certain operations, as the * compiler operations required to implement completion may be different for different node types. * * @param N type of the template node in question, narrowed accordingly. */ export class CompletionBuilder { private readonly typeChecker = this.compiler.getCurrentProgram().getTypeChecker(); private readonly templateTypeChecker = this.compiler.getTemplateTypeChecker(); private readonly nodeParent = this.targetDetails.parent; private readonly nodeContext = nodeContextFromTarget(this.targetDetails.context); private readonly template = this.targetDetails.template; private readonly position = this.targetDetails.position; constructor( private readonly tsLS: ts.LanguageService, private readonly compiler: NgCompiler, private readonly component: ts.ClassDeclaration, private readonly node: N, private readonly targetDetails: TemplateTarget) {} /** * Analogue for `ts.LanguageService.getCompletionsAtPosition`. */ getCompletionsAtPosition(options: ts.GetCompletionsAtPositionOptions| undefined): ts.WithMetadata|undefined { if (this.isPropertyExpressionCompletion()) { return this.getPropertyExpressionCompletion(options); } else if (this.isElementTagCompletion()) { return this.getElementTagCompletion(); } else if (this.isElementAttributeCompletion()) { return this.getElementAttributeCompletions(); } else if (this.isPipeCompletion()) { return this.getPipeCompletions(); } else { return undefined; } } /** * Analogue for `ts.LanguageService.getCompletionEntryDetails`. */ getCompletionEntryDetails( entryName: string, formatOptions: ts.FormatCodeOptions|ts.FormatCodeSettings|undefined, 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 if (this.isElementAttributeCompletion()) { return this.getElementAttributeCompletionDetails(entryName); } } /** * Analogue for `ts.LanguageService.getCompletionEntrySymbol`. */ getCompletionEntrySymbol(name: string): ts.Symbol|undefined { if (this.isPropertyExpressionCompletion()) { return this.getPropertyExpressionCompletionSymbol(name); } else if (this.isElementTagCompletion()) { return this.getElementTagCompletionSymbol(name); } else if (this.isElementAttributeCompletion()) { return this.getElementAttributeCompletionSymbol(name); } else { return undefined; } } /** * Determine if the current node is the completion of a property expression, and narrow the type * of `this.node` if so. * * This narrowing gives access to additional methods related to completion of property * expressions. */ private isPropertyExpressionCompletion(this: CompletionBuilder): this is PropertyExpressionCompletionBuilder { return this.node instanceof PropertyRead || this.node instanceof MethodCall || this.node instanceof SafePropertyRead || this.node instanceof SafeMethodCall || this.node instanceof PropertyWrite || this.node instanceof EmptyExpr || // BoundEvent nodes only count as property completions if in an EventValue context. (this.node instanceof BoundEvent && this.nodeContext === CompletionNodeContext.EventValue); } /** * Get completions for property expressions. */ private getPropertyExpressionCompletion( this: PropertyExpressionCompletionBuilder, options: ts.GetCompletionsAtPositionOptions| undefined): ts.WithMetadata|undefined { if (this.node instanceof EmptyExpr || this.node instanceof BoundEvent || this.node.receiver instanceof ImplicitReceiver) { return this.getGlobalPropertyExpressionCompletion(options); } else { const location = this.compiler.getTemplateTypeChecker().getExpressionCompletionLocation( this.node, this.component); if (location === null) { return undefined; } const tsResults = this.tsLS.getCompletionsAtPosition( location.shimPath, location.positionInShimFile, options); if (tsResults === undefined) { return undefined; } const replacementSpan = makeReplacementSpanFromAst(this.node); let ngResults: ts.CompletionEntry[] = []; for (const result of tsResults.entries) { ngResults.push({ ...result, replacementSpan, }); } return { ...tsResults, entries: ngResults, }; } } /** * Get the details of a specific completion for a property expression. */ private getPropertyExpressionCompletionDetails( this: PropertyExpressionCompletionBuilder, entryName: string, formatOptions: ts.FormatCodeOptions|ts.FormatCodeSettings|undefined, preferences: ts.UserPreferences|undefined): ts.CompletionEntryDetails|undefined { let details: ts.CompletionEntryDetails|undefined = undefined; if (this.node instanceof EmptyExpr || this.node instanceof BoundEvent || this.node.receiver instanceof ImplicitReceiver) { details = this.getGlobalPropertyExpressionCompletionDetails(entryName, formatOptions, preferences); } else { const location = this.compiler.getTemplateTypeChecker().getExpressionCompletionLocation( this.node, this.component); if (location === null) { return undefined; } details = this.tsLS.getCompletionEntryDetails( location.shimPath, location.positionInShimFile, entryName, formatOptions, /* source */ undefined, preferences); } if (details !== undefined) { details.displayParts = filterAliasImports(details.displayParts); } return details; } /** * Get the `ts.Symbol` for a specific completion for a property expression. */ private getPropertyExpressionCompletionSymbol( this: PropertyExpressionCompletionBuilder, name: string): ts.Symbol|undefined { if (this.node instanceof EmptyExpr || this.node instanceof LiteralPrimitive || this.node instanceof BoundEvent || this.node.receiver instanceof ImplicitReceiver) { return this.getGlobalPropertyExpressionCompletionSymbol(name); } else { const location = this.compiler.getTemplateTypeChecker().getExpressionCompletionLocation( this.node, this.component); if (location === null) { return undefined; } return this.tsLS.getCompletionEntrySymbol( location.shimPath, location.positionInShimFile, name, /* source */ undefined); } } /** * Get completions for a property expression in a global context (e.g. `{{y|}}`). */ private getGlobalPropertyExpressionCompletion( this: PropertyExpressionCompletionBuilder, options: ts.GetCompletionsAtPositionOptions| undefined): ts.WithMetadata|undefined { const completions = this.templateTypeChecker.getGlobalCompletions(this.template, this.component, this.node); if (completions === null) { return undefined; } const {componentContext, templateContext, nodeContext: astContext} = completions; const replacementSpan = makeReplacementSpanFromAst(this.node); // Merge TS completion results with results from the template scope. let entries: ts.CompletionEntry[] = []; const componentCompletions = this.tsLS.getCompletionsAtPosition( componentContext.shimPath, componentContext.positionInShimFile, options); if (componentCompletions !== undefined) { for (const tsCompletion of componentCompletions.entries) { // Skip completions that are shadowed by a template entity definition. if (templateContext.has(tsCompletion.name)) { continue; } entries.push({ ...tsCompletion, // Substitute the TS completion's `replacementSpan` (which uses offsets within the TCB) // with the `replacementSpan` within the template source. replacementSpan, }); } } // Merge TS completion results with results from the ast context. if (astContext !== null) { const nodeCompletions = this.tsLS.getCompletionsAtPosition( astContext.shimPath, astContext.positionInShimFile, options); if (nodeCompletions !== undefined) { for (const tsCompletion of nodeCompletions.entries) { if (this.isValidNodeContextCompletion(tsCompletion)) { entries.push({ ...tsCompletion, // Substitute the TS completion's `replacementSpan` (which uses offsets within the // TCB) with the `replacementSpan` within the template source. replacementSpan, }); } } } } for (const [name, entity] of templateContext) { entries.push({ name, sortText: name, replacementSpan, kindModifiers: ts.ScriptElementKindModifier.none, kind: unsafeCastDisplayInfoKindToScriptElementKind( entity.kind === CompletionKind.Reference ? DisplayInfoKind.REFERENCE : DisplayInfoKind.VARIABLE), }); } return { entries, // Although this completion is "global" in the sense of an Angular expression (there is no // explicit receiver), it is not "global" in a TypeScript sense since Angular expressions have // the component as an implicit receiver. isGlobalCompletion: false, isMemberCompletion: true, isNewIdentifierLocation: false, }; } /** * Get the details of a specific completion for a property expression in a global context (e.g. * `{{y|}}`). */ private getGlobalPropertyExpressionCompletionDetails( this: PropertyExpressionCompletionBuilder, entryName: string, formatOptions: ts.FormatCodeOptions|ts.FormatCodeSettings|undefined, preferences: ts.UserPreferences|undefined): ts.CompletionEntryDetails|undefined { const completions = this.templateTypeChecker.getGlobalCompletions(this.template, this.component, this.node); if (completions === null) { return undefined; } const {componentContext, templateContext} = completions; if (templateContext.has(entryName)) { const entry = templateContext.get(entryName)!; // Entries that reference a symbol in the template context refer either to local references or // variables. const symbol = this.templateTypeChecker.getSymbolOfNode(entry.node, this.component) as TemplateDeclarationSymbol | null; if (symbol === null) { return undefined; } const {kind, displayParts, documentation} = getSymbolDisplayInfo(this.tsLS, this.typeChecker, symbol); return { kind: unsafeCastDisplayInfoKindToScriptElementKind(kind), name: entryName, kindModifiers: ts.ScriptElementKindModifier.none, displayParts, documentation, }; } else { return this.tsLS.getCompletionEntryDetails( componentContext.shimPath, componentContext.positionInShimFile, entryName, formatOptions, /* source */ undefined, preferences); } } /** * Get the `ts.Symbol` of a specific completion for a property expression in a global context * (e.g. * `{{y|}}`). */ private getGlobalPropertyExpressionCompletionSymbol( this: PropertyExpressionCompletionBuilder, entryName: string): ts.Symbol|undefined { const completions = this.templateTypeChecker.getGlobalCompletions(this.template, this.component, this.node); if (completions === null) { return undefined; } const {componentContext, templateContext} = completions; if (templateContext.has(entryName)) { const node: TmplAstReference|TmplAstVariable = templateContext.get(entryName)!.node; const symbol = this.templateTypeChecker.getSymbolOfNode(node, this.component) as TemplateDeclarationSymbol | null; if (symbol === null || symbol.tsSymbol === null) { return undefined; } return symbol.tsSymbol; } else { return this.tsLS.getCompletionEntrySymbol( componentContext.shimPath, componentContext.positionInShimFile, entryName, /* source */ undefined); } } private isElementTagCompletion(): this is CompletionBuilder { if (this.node instanceof TmplAstText) { const positionInTextNode = this.position - this.node.sourceSpan.start.offset; // We only provide element completions in a text node when there is an open tag immediately to // the left of the position. return this.node.value.substring(0, positionInTextNode).endsWith('<'); } else if (this.node instanceof TmplAstElement) { return this.nodeContext === CompletionNodeContext.ElementTag; } return false; } private getElementTagCompletion(this: CompletionBuilder): ts.WithMetadata|undefined { const templateTypeChecker = this.compiler.getTemplateTypeChecker(); let start: number; let length: number; if (this.node instanceof TmplAstElement) { // The replacementSpan is the tag name. start = this.node.sourceSpan.start.offset + 1; // account for leading '<' length = this.node.name.length; } else { const positionInTextNode = this.position - this.node.sourceSpan.start.offset; const textToLeftOfPosition = this.node.value.substring(0, positionInTextNode); start = this.node.sourceSpan.start.offset + textToLeftOfPosition.lastIndexOf('<') + 1; // We only autocomplete immediately after the < so we don't replace any existing text length = 0; } const replacementSpan: ts.TextSpan = {start, length}; let potentialTags = Array.from(templateTypeChecker.getPotentialElementTags(this.component)); // Don't provide non-Angular tags (directive === null) because we expect other extensions (i.e. // Emmet) to provide those for HTML files. potentialTags = potentialTags.filter(([_, directive]) => directive !== null); const entries: ts.CompletionEntry[] = potentialTags.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 isElementAttributeCompletion(): this is ElementAttributeCompletionBuilder { return (this.nodeContext === CompletionNodeContext.ElementAttributeKey || this.nodeContext === CompletionNodeContext.TwoWayBinding) && (this.node instanceof TmplAstElement || this.node instanceof TmplAstBoundAttribute || this.node instanceof TmplAstTextAttribute || this.node instanceof TmplAstBoundEvent); } private getElementAttributeCompletions(this: ElementAttributeCompletionBuilder): ts.WithMetadata|undefined { let element: TmplAstElement|TmplAstTemplate; if (this.node instanceof TmplAstElement) { element = this.node; } else if ( this.nodeParent instanceof TmplAstElement || this.nodeParent instanceof TmplAstTemplate) { element = this.nodeParent; } else { // Nothing to do without an element to process. return undefined; } let replacementSpan: ts.TextSpan|undefined = undefined; if ((this.node instanceof TmplAstBoundAttribute || this.node instanceof TmplAstBoundEvent || this.node instanceof TmplAstTextAttribute) && this.node.keySpan !== undefined) { replacementSpan = makeReplacementSpanFromParseSourceSpan(this.node.keySpan); } const attrTable = buildAttributeCompletionTable( this.component, element, this.compiler.getTemplateTypeChecker()); let entries: ts.CompletionEntry[] = []; for (const completion of attrTable.values()) { // First, filter out completions that don't make sense for the current node. For example, if // the user is completing on a property binding `[foo|]`, don't offer output event // completions. switch (completion.kind) { case AttributeCompletionKind.DomAttribute: case AttributeCompletionKind.DomProperty: if (this.node instanceof TmplAstBoundEvent) { continue; } break; case AttributeCompletionKind.DirectiveInput: if (this.node instanceof TmplAstBoundEvent) { continue; } if (!completion.twoWayBindingSupported && this.nodeContext === CompletionNodeContext.TwoWayBinding) { continue; } break; case AttributeCompletionKind.DirectiveOutput: if (this.node instanceof TmplAstBoundAttribute) { continue; } break; case AttributeCompletionKind.DirectiveAttribute: if (this.node instanceof TmplAstBoundAttribute || this.node instanceof TmplAstBoundEvent) { continue; } break; } // Is the completion in an attribute context (instead of a property context)? const isAttributeContext = (this.node instanceof TmplAstElement || this.node instanceof TmplAstTextAttribute); // Is the completion for an element (not an )? const isElementContext = this.node instanceof TmplAstElement || this.nodeParent instanceof TmplAstElement; addAttributeCompletionEntries( entries, completion, isAttributeContext, isElementContext, replacementSpan); } return { entries, isGlobalCompletion: false, isMemberCompletion: false, isNewIdentifierLocation: true, }; } private getElementAttributeCompletionDetails( this: ElementAttributeCompletionBuilder, entryName: string): ts.CompletionEntryDetails |undefined { // `entryName` here may be `foo` or `[foo]`, depending on which suggested completion the user // chose. Strip off any binding syntax to get the real attribute name. const {name, kind} = stripBindingSugar(entryName); let element: TmplAstElement|TmplAstTemplate; if (this.node instanceof TmplAstElement || this.node instanceof TmplAstTemplate) { element = this.node; } else if ( this.nodeParent instanceof TmplAstElement || this.nodeParent instanceof TmplAstTemplate) { element = this.nodeParent; } else { // Nothing to do without an element to process. return undefined; } const attrTable = buildAttributeCompletionTable( this.component, element, this.compiler.getTemplateTypeChecker()); if (!attrTable.has(name)) { return undefined; } const completion = attrTable.get(name)!; let displayParts: ts.SymbolDisplayPart[]; let documentation: ts.SymbolDisplayPart[]|undefined = undefined; let info: DisplayInfo|null; switch (completion.kind) { case AttributeCompletionKind.DomAttribute: case AttributeCompletionKind.DomProperty: // TODO(alxhub): ideally we would show the same documentation as quick info here. However, // since these bindings don't exist in the TCB, there is no straightforward way to retrieve // a `ts.Symbol` for the field in the TS DOM definition. displayParts = []; break; case AttributeCompletionKind.DirectiveAttribute: info = getDirectiveDisplayInfo(this.tsLS, completion.directive); displayParts = info.displayParts; documentation = info.documentation; break; case AttributeCompletionKind.DirectiveInput: case AttributeCompletionKind.DirectiveOutput: const propertySymbol = getAttributeCompletionSymbol(completion, this.typeChecker); if (propertySymbol === null) { return undefined; } info = getTsSymbolDisplayInfo( this.tsLS, this.typeChecker, propertySymbol, completion.kind === AttributeCompletionKind.DirectiveInput ? DisplayInfoKind.PROPERTY : DisplayInfoKind.EVENT, completion.directive.tsSymbol.name); if (info === null) { return undefined; } displayParts = info.displayParts; documentation = info.documentation; } return { name: entryName, kind: unsafeCastDisplayInfoKindToScriptElementKind(kind), kindModifiers: ts.ScriptElementKindModifier.none, displayParts: [], documentation, }; } private getElementAttributeCompletionSymbol( this: ElementAttributeCompletionBuilder, attribute: string): ts.Symbol|undefined { const {name} = stripBindingSugar(attribute); let element: TmplAstElement|TmplAstTemplate; if (this.node instanceof TmplAstElement || this.node instanceof TmplAstTemplate) { element = this.node; } else if ( this.nodeParent instanceof TmplAstElement || this.nodeParent instanceof TmplAstTemplate) { element = this.nodeParent; } else { // Nothing to do without an element to process. return undefined; } const attrTable = buildAttributeCompletionTable( this.component, element, this.compiler.getTemplateTypeChecker()); if (!attrTable.has(name)) { return undefined; } const completion = attrTable.get(name)!; return getAttributeCompletionSymbol(completion, this.typeChecker) ?? undefined; } private isPipeCompletion(): this is PipeCompletionBuilder { return this.node instanceof BindingPipe; } private getPipeCompletions(this: PipeCompletionBuilder): ts.WithMetadata|undefined { const pipes = this.templateTypeChecker.getPipesInScope(this.component); if (pipes === null) { return undefined; } const replacementSpan = makeReplacementSpanFromAst(this.node); const entries: ts.CompletionEntry[] = pipes.map(pipe => ({ name: pipe.name, sortText: pipe.name, kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PIPE), replacementSpan, })); return { entries, isGlobalCompletion: false, isMemberCompletion: false, isNewIdentifierLocation: false, }; } /** * From the AST node of the cursor position, include completion of string literals, number * literals, `true`, `false`, `null`, and `undefined`. */ private isValidNodeContextCompletion(completion: ts.CompletionEntry): boolean { if (completion.kind === ts.ScriptElementKind.string) { // 'string' kind includes both string literals and number literals return true; } if (completion.kind === ts.ScriptElementKind.keyword) { return completion.name === 'true' || completion.name === 'false' || completion.name === 'null'; } if (completion.kind === ts.ScriptElementKind.variableElement) { return completion.name === 'undefined'; } return false; } } function makeReplacementSpanFromParseSourceSpan(span: ParseSourceSpan): ts.TextSpan { return { start: span.start.offset, length: span.end.offset - span.start.offset, }; } function makeReplacementSpanFromAst(node: PropertyRead|PropertyWrite|MethodCall|SafePropertyRead| SafeMethodCall|BindingPipe|EmptyExpr|LiteralPrimitive| BoundEvent): ts.TextSpan|undefined { if ((node instanceof EmptyExpr || node instanceof LiteralPrimitive || node instanceof BoundEvent)) { // empty nodes do not replace any existing text return undefined; } return { start: 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); } const BINDING_SUGAR = /[\[\(\)\]]/g; function stripBindingSugar(binding: string): {name: string, kind: DisplayInfoKind} { const name = binding.replace(BINDING_SUGAR, ''); if (binding.startsWith('[')) { return {name, kind: DisplayInfoKind.PROPERTY}; } else if (binding.startsWith('(')) { return {name, kind: DisplayInfoKind.EVENT}; } else { return {name, kind: DisplayInfoKind.ATTRIBUTE}; } } function nodeContextFromTarget(target: TargetContext): CompletionNodeContext { switch (target.kind) { case TargetNodeKind.ElementInTagContext: return CompletionNodeContext.ElementTag; case TargetNodeKind.ElementInBodyContext: // Completions in element bodies are for new attributes. return CompletionNodeContext.ElementAttributeKey; case TargetNodeKind.TwoWayBindingContext: return CompletionNodeContext.TwoWayBinding; case TargetNodeKind.AttributeInKeyContext: return CompletionNodeContext.ElementAttributeKey; case TargetNodeKind.AttributeInValueContext: if (target.node instanceof TmplAstBoundEvent) { return CompletionNodeContext.EventValue; } else { return CompletionNodeContext.None; } default: // No special context is available. return CompletionNodeContext.None; } }