From 66378ed0efc4fe344c343232a69d2142fdf622d5 Mon Sep 17 00:00:00 2001 From: Alex Rickabaugh Date: Thu, 3 Dec 2020 18:08:00 -0800 Subject: [PATCH] feat(language-service): complete attributes on elements (#40032) This commit adds attribute completion to the Language Service. It completes from 3 sources: 1. inputs/outputs of directives currently present on the element 2. inputs/outputs/attributes of directives in scope for the element, that would become present if the input/output/attribute was added 3. DOM properties and attributes We distinguish between completion of a property binding (`[foo|]`) and a completion in an attribute context (`foo|`). For the latter, bindings to the attribute are offered, as well as a property binding which adds the square bracket notation. To determine hypothetical matches (directives which would become present if a binding is added), directives in scope are scanned and matched against a hypothetical version of the element which has the attribute. PR Close #40032 --- packages/language-service/ivy/BUILD.bazel | 1 + .../ivy/attribute_completions.ts | 471 ++++++++++++++++++ packages/language-service/ivy/completions.ts | 240 +++++++-- .../language-service/ivy/display_parts.ts | 28 +- .../language-service/ivy/language_service.ts | 10 +- .../language-service/ivy/template_target.ts | 51 +- .../ivy/test/completions_spec.ts | 220 +++++++- packages/language-service/ivy/utils.ts | 7 + 8 files changed, 993 insertions(+), 35 deletions(-) create mode 100644 packages/language-service/ivy/attribute_completions.ts diff --git a/packages/language-service/ivy/BUILD.bazel b/packages/language-service/ivy/BUILD.bazel index ce37cd7631..044974cee2 100644 --- a/packages/language-service/ivy/BUILD.bazel +++ b/packages/language-service/ivy/BUILD.bazel @@ -11,6 +11,7 @@ ts_library( "//packages/compiler-cli/src/ngtsc/core", "//packages/compiler-cli/src/ngtsc/core:api", "//packages/compiler-cli/src/ngtsc/file_system", + "//packages/compiler-cli/src/ngtsc/imports", "//packages/compiler-cli/src/ngtsc/incremental", "//packages/compiler-cli/src/ngtsc/metadata", "//packages/compiler-cli/src/ngtsc/reflection", diff --git a/packages/language-service/ivy/attribute_completions.ts b/packages/language-service/ivy/attribute_completions.ts new file mode 100644 index 0000000000..3cb46fe789 --- /dev/null +++ b/packages/language-service/ivy/attribute_completions.ts @@ -0,0 +1,471 @@ +/** + * @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 {CssSelector, SelectorMatcher, TmplAstElement} from '@angular/compiler'; +import {DirectiveInScope, TemplateTypeChecker} from '@angular/compiler-cli/src/ngtsc/typecheck/api'; +import * as ts from 'typescript'; + +import {DisplayInfoKind, unsafeCastDisplayInfoKindToScriptElementKind} from './display_parts'; +import {makeElementSelector} from './utils'; + +/** + * Differentiates different kinds of `AttributeCompletion`s. + */ +export enum AttributeCompletionKind { + /** + * Completion of an attribute from the HTML schema. + * + * Attributes often have a corresponding DOM property of the same name. + */ + DomAttribute, + + /** + * Completion of a property from the DOM schema. + * + * `DomProperty` completions are generated only for properties which don't share their name with + * an HTML attribute. + */ + DomProperty, + + /** + * Completion of an attribute that results in a new directive being matched on an element. + */ + DirectiveAttribute, + + /** + * Completion of an input from a directive which is either present on the element, or becomes + * present after the addition of this attribute. + */ + DirectiveInput, + + /** + * Completion of an output from a directive which is either present on the element, or becomes + * present after the addition of this attribute. + */ + DirectiveOutput, +} + +/** + * Completion of an attribute from the DOM schema. + */ +export interface DomAttributeCompletion { + kind: AttributeCompletionKind.DomAttribute; + + /** + * Name of the HTML attribute (not to be confused with the corresponding DOM property name). + */ + attribute: string; + + /** + * Whether this attribute is also a DOM property. + */ + isAlsoProperty: boolean; +} + +/** + * Completion of a DOM property of an element that's distinct from an HTML attribute. + */ +export interface DomPropertyCompletion { + kind: AttributeCompletionKind.DomProperty; + + /** + * Name of the DOM property + */ + property: string; +} + +/** + * Completion of an attribute which results in a new directive being matched on an element. + */ +export interface DirectiveAttributeCompletion { + kind: AttributeCompletionKind.DirectiveAttribute; + + /** + * Name of the attribute whose addition causes this directive to match the element. + */ + attribute: string; + + /** + * The directive whose selector gave rise to this completion. + */ + directive: DirectiveInScope; +} + +/** + * Completion of an input of a directive which may either be present on the element, or become + * present when a binding to this input is added. + */ +export interface DirectiveInputCompletion { + kind: AttributeCompletionKind.DirectiveInput; + + /** + * The public property name of the input (the name which would be used in any binding to that + * input). + */ + propertyName: string; + + /** + * The directive which has this input. + */ + directive: DirectiveInScope; + + /** + * The field name on the directive class which corresponds to this input. + * + * Currently, in the case where a single property name corresponds to multiple input fields, only + * the first such field is represented here. In the future multiple results may be warranted. + */ + classPropertyName: string; + + /** + * Whether this input can be used with two-way binding (that is, whether a corresponding change + * output exists on the directive). + */ + twoWayBindingSupported: boolean; +} + +export interface DirectiveOutputCompletion { + kind: AttributeCompletionKind.DirectiveOutput; + + /** + * The public event name of the output (the name which would be used in any binding to that + * output). + */ + eventName: string; + + /** + *The directive which has this output. + */ + directive: DirectiveInScope; + + /** + * The field name on the directive class which corresponds to this output. + */ + classPropertyName: string; +} + +/** + * Any named attribute which is available for completion on a given element. + * + * Disambiguated by the `kind` property into various types of completions. + */ +export type AttributeCompletion = DomAttributeCompletion|DomPropertyCompletion| + DirectiveAttributeCompletion|DirectiveInputCompletion|DirectiveOutputCompletion; + +/** + * Given an element and its context, produce a `Map` of all possible attribute completions. + * + * 3 kinds of attributes are considered for completion, from highest to lowest priority: + * + * 1. Inputs/outputs of directives present on the element already. + * 2. Inputs/outputs of directives that are not present on the element, but which would become + * present if such a binding is added. + * 3. Attributes from the DOM schema for the element. + * + * The priority of these options determines which completions are added to the `Map`. If a directive + * input shares the same name as a DOM attribute, the `Map` will reflect the directive input + * completion, not the DOM completion for that name. + */ +export function buildAttributeCompletionTable( + component: ts.ClassDeclaration, element: TmplAstElement, + checker: TemplateTypeChecker): Map { + const table = new Map(); + + // Use the `ElementSymbol` to iterate over directives present on the element, and their + // inputs/outputs. These have the highest priority of completion results. + const symbol = checker.getSymbolOfNode(element, component); + const presentDirectives = new Set(); + if (symbol !== null) { + // An `ElementSymbol` was available. This means inputs and outputs for directives on the + // element can be added to the completion table. + for (const dirSymbol of symbol.directives) { + const directive = dirSymbol.tsSymbol.valueDeclaration; + if (!ts.isClassDeclaration(directive)) { + continue; + } + presentDirectives.add(directive); + + const meta = checker.getDirectiveMetadata(directive); + if (meta === null) { + continue; + } + + for (const [propertyName, classPropertyName] of meta.inputs) { + if (table.has(propertyName)) { + continue; + } + + table.set(propertyName, { + kind: AttributeCompletionKind.DirectiveInput, + propertyName, + directive: dirSymbol, + classPropertyName, + twoWayBindingSupported: meta.outputs.hasBindingPropertyName(propertyName + 'Change'), + }); + } + + for (const [propertyName, classPropertyName] of meta.outputs) { + if (table.has(propertyName)) { + continue; + } + + table.set(propertyName, { + kind: AttributeCompletionKind.DirectiveOutput, + eventName: propertyName, + directive: dirSymbol, + classPropertyName, + }); + } + } + } + + // Next, explore hypothetical directives and determine if the addition of any single attributes + // can cause the directive to match the element. + const directivesInScope = checker.getDirectivesInScope(component); + if (directivesInScope !== null) { + const elementSelector = makeElementSelector(element); + + for (const dirInScope of directivesInScope) { + const directive = dirInScope.tsSymbol.valueDeclaration; + // Skip directives that are present on the element. + if (!ts.isClassDeclaration(directive) || presentDirectives.has(directive)) { + continue; + } + + const meta = checker.getDirectiveMetadata(directive); + if (meta === null || meta.selector === null) { + continue; + } + + const selectors = CssSelector.parse(meta.selector); + const matcher = new SelectorMatcher(); + matcher.addSelectables(selectors); + + for (const selector of selectors) { + for (const [attrName, attrValue] of selectorAttributes(selector)) { + if (attrValue !== '') { + // This attribute selector requires a value, which is not supported in completion. + continue; + } + + if (table.has(attrName)) { + // Skip this attribute as there's already a binding for it. + continue; + } + + // Check whether adding this attribute would cause the directive to start matching. + const newElementSelector = elementSelector + `[${attrName}]`; + if (!matcher.match(CssSelector.parse(newElementSelector)[0], null)) { + // Nope, move on with our lives. + continue; + } + + // Adding this attribute causes a new directive to be matched. Decide how to categorize + // it based on the directive's inputs and outputs. + if (meta.inputs.hasBindingPropertyName(attrName)) { + // This attribute corresponds to an input binding. + table.set(attrName, { + kind: AttributeCompletionKind.DirectiveInput, + directive: dirInScope, + propertyName: attrName, + classPropertyName: + meta.inputs.getByBindingPropertyName(attrName)![0].classPropertyName, + twoWayBindingSupported: meta.outputs.hasBindingPropertyName(attrName + 'Change'), + }); + } else if (meta.outputs.hasBindingPropertyName(attrName)) { + // This attribute corresponds to an output binding. + table.set(attrName, { + kind: AttributeCompletionKind.DirectiveOutput, + directive: dirInScope, + eventName: attrName, + classPropertyName: + meta.outputs.getByBindingPropertyName(attrName)![0].classPropertyName, + }); + } else { + // This attribute causes a new directive to be matched, but does not also correspond to + // an input or output binding. + table.set(attrName, { + kind: AttributeCompletionKind.DirectiveAttribute, + attribute: attrName, + directive: dirInScope, + }); + } + } + } + } + } + + // Finally, add any DOM attributes not already covered by inputs. + for (const {attribute, property} of checker.getPotentialDomBindings(element.name)) { + const isAlsoProperty = attribute === property; + if (!table.has(attribute)) { + table.set(attribute, { + kind: AttributeCompletionKind.DomAttribute, + attribute, + isAlsoProperty, + }); + } + if (!isAlsoProperty && !table.has(property)) { + table.set(property, { + kind: AttributeCompletionKind.DomProperty, + property, + }); + } + } + + return table; +} + +/** + * Given an `AttributeCompletion`, add any available completions to a `ts.CompletionEntry` array of + * results. + * + * The kind of completions generated depends on whether the current context is an attribute context + * or not. For example, completing on `` will generate two results: `attribute` and + * `[attribute]` - either a static attribute can be generated, or a property binding. However, + * `` is not an attribute context, and so only the property completion `attribute` + * is generated. Note that this completion does not have the `[]` property binding sugar as its + * implicitly present in a property binding context (we're already completing within an `[attr|]` + * expression). + */ +export function addAttributeCompletionEntries( + entries: ts.CompletionEntry[], completion: AttributeCompletion, isAttributeContext: boolean, + replacementSpan: ts.TextSpan|undefined): void { + switch (completion.kind) { + case AttributeCompletionKind.DirectiveAttribute: { + entries.push({ + kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.DIRECTIVE), + name: completion.attribute, + sortText: completion.attribute, + replacementSpan, + }); + break; + } + case AttributeCompletionKind.DirectiveInput: { + if (isAttributeContext) { + // Offer a completion of a property binding. + entries.push({ + kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY), + name: `[${completion.propertyName}]`, + sortText: completion.propertyName, + replacementSpan, + }); + // If the directive supports banana-in-a-box for this input, offer that as well. + if (completion.twoWayBindingSupported) { + entries.push({ + kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY), + name: `[(${completion.propertyName})]`, + // This completion should sort after the property binding. + sortText: completion.propertyName + '_1', + replacementSpan, + }); + } + // Offer a completion of the input binding as an attribute. + entries.push({ + kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.ATTRIBUTE), + name: completion.propertyName, + // This completion should sort after both property binding options (one-way and two-way). + sortText: completion.propertyName + '_2', + replacementSpan, + }); + } else { + entries.push({ + kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY), + name: completion.propertyName, + sortText: completion.propertyName, + replacementSpan, + }); + } + break; + } + case AttributeCompletionKind.DirectiveOutput: { + if (isAttributeContext) { + entries.push({ + kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.EVENT), + name: `(${completion.eventName})`, + sortText: completion.eventName, + replacementSpan, + }); + } else { + entries.push({ + kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.EVENT), + name: completion.eventName, + sortText: completion.eventName, + replacementSpan, + }); + } + break; + } + case AttributeCompletionKind.DomAttribute: { + if (isAttributeContext) { + // Offer a completion of an attribute binding. + entries.push({ + kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.ATTRIBUTE), + name: completion.attribute, + sortText: completion.attribute, + replacementSpan, + }); + if (completion.isAlsoProperty) { + // Offer a completion of a property binding to the DOM property. + entries.push({ + kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY), + name: `[${completion.attribute}]`, + // In the case of DOM attributes, the property binding should sort after the attribute + // binding. + sortText: completion.attribute + '_1', + replacementSpan, + }); + } + } else if (completion.isAlsoProperty) { + entries.push({ + kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY), + name: completion.attribute, + sortText: completion.attribute, + replacementSpan, + }); + } + break; + } + case AttributeCompletionKind.DomProperty: { + if (!isAttributeContext) { + entries.push({ + kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY), + name: completion.property, + sortText: completion.property, + replacementSpan, + }); + } + } + } +} + +export function getAttributeCompletionSymbol( + completion: AttributeCompletion, checker: ts.TypeChecker): ts.Symbol|null { + switch (completion.kind) { + case AttributeCompletionKind.DomAttribute: + case AttributeCompletionKind.DomProperty: + return null; + case AttributeCompletionKind.DirectiveAttribute: + return completion.directive.tsSymbol; + case AttributeCompletionKind.DirectiveInput: + case AttributeCompletionKind.DirectiveOutput: + return checker.getDeclaredTypeOfSymbol(completion.directive.tsSymbol) + .getProperty(completion.classPropertyName) ?? + null; + } +} + +/** + * Iterates over `CssSelector` attributes, which are internally represented in a zipped array style + * which is not conducive to straightforward iteration. + */ +function* selectorAttributes(selector: CssSelector): Iterable<[string, string]> { + for (let i = 0; i < selector.attrs.length; i += 2) { + yield [selector.attrs[0], selector.attrs[1]]; + } +} diff --git a/packages/language-service/ivy/completions.ts b/packages/language-service/ivy/completions.ts index f8aaa3c305..216baa904d 100644 --- a/packages/language-service/ivy/completions.ts +++ b/packages/language-service/ivy/completions.ts @@ -6,17 +6,22 @@ * found in the LICENSE file at https://angular.io/license */ -import {AST, EmptyExpr, ImplicitReceiver, LiteralPrimitive, MethodCall, PropertyRead, PropertyWrite, SafeMethodCall, SafePropertyRead, TmplAstBoundEvent, TmplAstElement, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstVariable} from '@angular/compiler'; +import {AST, EmptyExpr, ImplicitReceiver, LiteralPrimitive, MethodCall, ParseSourceSpan, PropertyRead, PropertyWrite, SafeMethodCall, SafePropertyRead, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstElement, TmplAstNode, TmplAstReference, TmplAstTemplate, 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 {DisplayInfoKind, getDirectiveDisplayInfo, getSymbolDisplayInfo, unsafeCastDisplayInfoKindToScriptElementKind} from './display_parts'; +import {addAttributeCompletionEntries, AttributeCompletionKind, buildAttributeCompletionTable, getAttributeCompletionSymbol} from './attribute_completions'; +import {DisplayInfo, DisplayInfoKind, getDirectiveDisplayInfo, getSymbolDisplayInfo, getTsSymbolDisplayInfo, unsafeCastDisplayInfoKindToScriptElementKind} from './display_parts'; import {filterAliasImports} from './utils'; type PropertyExpressionCompletionBuilder = CompletionBuilder; + SafeMethodCall|TmplAstBoundEvent>; + +type ElementAttributeCompletionBuilder = + CompletionBuilder; export enum CompletionNodeContext { @@ -24,6 +29,7 @@ export enum CompletionNodeContext { ElementTag, ElementAttributeKey, ElementAttributeValue, + EventValue, } /** @@ -57,6 +63,8 @@ export class CompletionBuilder { return this.getPropertyExpressionCompletion(options); } else if (this.isElementTagCompletion()) { return this.getElementTagCompletion(); + } else if (this.isElementAttributeCompletion()) { + return this.getElementAttributeCompletions(); } else { return undefined; } @@ -72,8 +80,8 @@ export class CompletionBuilder { return this.getPropertyExpressionCompletionDetails(entryName, formatOptions, preferences); } else if (this.isElementTagCompletion()) { return this.getElementTagCompletionDetails(entryName); - } else { - return undefined; + } else if (this.isElementAttributeCompletion()) { + return this.getElementAttributeCompletionDetails(entryName); } } @@ -85,6 +93,8 @@ export class CompletionBuilder { return this.getPropertyExpressionCompletionSymbol(name); } else if (this.isElementTagCompletion()) { return this.getElementTagCompletionSymbol(name); + } else if (this.isElementAttributeCompletion()) { + return this.getElementAttributeCompletionSymbol(name); } else { return undefined; } @@ -102,7 +112,8 @@ export class CompletionBuilder { 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 || - isBrokenEmptyBoundEventExpression(this.node, this.nodeParent); + // BoundEvent nodes only count as property completions if in an EventValue context. + (this.node instanceof BoundEvent && this.nodeContext === CompletionNodeContext.EventValue); } /** @@ -112,8 +123,7 @@ export class CompletionBuilder { this: PropertyExpressionCompletionBuilder, options: ts.GetCompletionsAtPositionOptions| undefined): ts.WithMetadata|undefined { - if (this.node instanceof EmptyExpr || - isBrokenEmptyBoundEventExpression(this.node, this.nodeParent) || + if (this.node instanceof EmptyExpr || this.node instanceof BoundEvent || this.node.receiver instanceof ImplicitReceiver) { return this.getGlobalPropertyExpressionCompletion(options); } else { @@ -128,7 +138,7 @@ export class CompletionBuilder { return undefined; } - const replacementSpan = makeReplacementSpan(this.node); + const replacementSpan = makeReplacementSpanFromAst(this.node); let ngResults: ts.CompletionEntry[] = []; for (const result of tsResults.entries) { @@ -152,8 +162,7 @@ export class CompletionBuilder { formatOptions: ts.FormatCodeOptions|ts.FormatCodeSettings|undefined, preferences: ts.UserPreferences|undefined): ts.CompletionEntryDetails|undefined { let details: ts.CompletionEntryDetails|undefined = undefined; - if (this.node instanceof EmptyExpr || - isBrokenEmptyBoundEventExpression(this.node, this.nodeParent) || + if (this.node instanceof EmptyExpr || this.node instanceof BoundEvent || this.node.receiver instanceof ImplicitReceiver) { details = this.getGlobalPropertyExpressionCompletionDetails(entryName, formatOptions, preferences); @@ -179,7 +188,7 @@ export class CompletionBuilder { private getPropertyExpressionCompletionSymbol( this: PropertyExpressionCompletionBuilder, name: string): ts.Symbol|undefined { if (this.node instanceof EmptyExpr || this.node instanceof LiteralPrimitive || - this.node.receiver instanceof ImplicitReceiver) { + this.node instanceof BoundEvent || this.node.receiver instanceof ImplicitReceiver) { return this.getGlobalPropertyExpressionCompletionSymbol(name); } else { const location = this.compiler.getTemplateTypeChecker().getExpressionCompletionLocation( @@ -209,8 +218,9 @@ export class CompletionBuilder { let replacementSpan: ts.TextSpan|undefined = undefined; // Non-empty nodes get replaced with the completion. - if (!(this.node instanceof EmptyExpr || this.node instanceof LiteralPrimitive)) { - replacementSpan = makeReplacementSpan(this.node); + if (!(this.node instanceof EmptyExpr || this.node instanceof LiteralPrimitive || + this.node instanceof BoundEvent)) { + replacementSpan = makeReplacementSpanFromAst(this.node); } // Merge TS completion results with results from the template scope. @@ -401,26 +411,183 @@ export class CompletionBuilder { return directive?.tsSymbol; } - // private getElementAttributeCompletions(this: CompletionBuilder): - // ts.WithMetadata {} + private isElementAttributeCompletion(): this is ElementAttributeCompletionBuilder { + return this.nodeContext === CompletionNodeContext.ElementAttributeKey && + (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; + if (this.node instanceof TmplAstElement) { + element = this.node; + } else if (this.nodeParent instanceof TmplAstElement) { + 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; + } + 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); + addAttributeCompletionEntries(entries, completion, isAttributeContext, 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; + if (this.node instanceof TmplAstElement) { + element = this.node; + } else if (this.nodeParent instanceof TmplAstElement) { + 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, kind} = stripBindingSugar(attribute); + + let element: TmplAstElement; + if (this.node instanceof TmplAstElement) { + element = this.node; + } else if (this.nodeParent instanceof TmplAstElement) { + 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; + } } -/** - * Checks whether the given `node` is (most likely) a synthetic node created by the template parser - * for an empty event binding `(event)=""`. - * - * When parsing such an expression, a synthetic `LiteralPrimitive` node is generated for the - * `BoundEvent`'s handler with the literal text value 'ERROR'. Detecting this case is crucial to - * supporting completions within empty event bindings. - */ -function isBrokenEmptyBoundEventExpression( - node: TmplAstNode|AST, parent: TmplAstNode|AST|null): node is LiteralPrimitive { - return node instanceof LiteralPrimitive && parent !== null && - parent instanceof TmplAstBoundEvent && node.value === 'ERROR'; +function makeReplacementSpanFromParseSourceSpan(span: ParseSourceSpan): ts.TextSpan { + return { + start: span.start.offset, + length: span.end.offset - span.start.offset, + }; } -function makeReplacementSpan(node: PropertyRead|PropertyWrite|MethodCall|SafePropertyRead| - SafeMethodCall): ts.TextSpan { +function makeReplacementSpanFromAst(node: PropertyRead|PropertyWrite|MethodCall|SafePropertyRead| + SafeMethodCall): ts.TextSpan { return { start: node.nameSpan.start, length: node.nameSpan.end - node.nameSpan.start, @@ -438,3 +605,16 @@ function tagCompletionKind(directive: DirectiveInScope|null): ts.ScriptElementKi } 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}; + } +} diff --git a/packages/language-service/ivy/display_parts.ts b/packages/language-service/ivy/display_parts.ts index 528f17e07a..fefda80e9c 100644 --- a/packages/language-service/ivy/display_parts.ts +++ b/packages/language-service/ivy/display_parts.ts @@ -22,6 +22,7 @@ export const SYMBOL_TEXT = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.tex * Label for various kinds of Angular entities for TS display info. */ export enum DisplayInfoKind { + ATTRIBUTE = 'attribute', COMPONENT = 'component', DIRECTIVE = 'directive', EVENT = 'event', @@ -148,4 +149,29 @@ export function getDirectiveDisplayInfo( displayParts, documentation: res.documentation, }; -} \ No newline at end of file +} + +export function getTsSymbolDisplayInfo( + tsLS: ts.LanguageService, checker: ts.TypeChecker, symbol: ts.Symbol, kind: DisplayInfoKind, + ownerName: string|null): DisplayInfo|null { + const decl = symbol.valueDeclaration; + if (decl === undefined || (!ts.isPropertyDeclaration(decl) && !ts.isMethodDeclaration(decl)) || + !ts.isIdentifier(decl.name)) { + return null; + } + const res = tsLS.getQuickInfoAtPosition(decl.getSourceFile().fileName, decl.name.getStart()); + if (res === undefined) { + return {kind, displayParts: [], documentation: []}; + } + + const type = checker.getDeclaredTypeOfSymbol(symbol); + const typeString = checker.typeToString(type); + + const displayParts = createDisplayParts(symbol.name, kind, ownerName ?? undefined, typeString); + + return { + kind, + displayParts, + documentation: res.documentation, + }; +} diff --git a/packages/language-service/ivy/language_service.ts b/packages/language-service/ivy/language_service.ts index 20a0c735a0..2a6903bf65 100644 --- a/packages/language-service/ivy/language_service.ts +++ b/packages/language-service/ivy/language_service.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {AST, TmplAstNode} from '@angular/compiler'; +import {AST, TmplAstBoundEvent, TmplAstNode} from '@angular/compiler'; import {CompilerOptions, ConfigurationHost, readConfiguration} from '@angular/compiler-cli'; import {absoluteFromSourceFile, AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system'; import {TypeCheckShimGenerator} from '@angular/compiler-cli/src/ngtsc/typecheck'; @@ -265,6 +265,14 @@ function nodeContextFromTarget(target: TargetNode): CompletionNodeContext { case TargetNodeKind.ElementInBodyContext: // Completions in element bodies are for new attributes. return CompletionNodeContext.ElementAttributeKey; + 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; diff --git a/packages/language-service/ivy/template_target.ts b/packages/language-service/ivy/template_target.ts index 4209cd00c7..85860c5b0f 100644 --- a/packages/language-service/ivy/template_target.ts +++ b/packages/language-service/ivy/template_target.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ +import {TmplAstBoundEvent} from '@angular/compiler'; import * as e from '@angular/compiler/src/expression_parser/ast'; // e for expression AST import * as t from '@angular/compiler/src/render3/r3_ast'; // t for template AST @@ -45,7 +46,8 @@ export interface TemplateTarget { * as well as a body, and a given position definitively points to one or the other. `TargetNode` * captures the node itself, as well as this additional contextual disambiguation. */ -export type TargetNode = RawExpression|RawTemplateNode|ElementInBodyContext|ElementInTagContext; +export type TargetNode = RawExpression|RawTemplateNode|ElementInBodyContext|ElementInTagContext| + AttributeInKeyContext|AttributeInValueContext; /** * Differentiates the various kinds of `TargetNode`s. @@ -55,6 +57,8 @@ export enum TargetNodeKind { RawTemplateNode, ElementInTagContext, ElementInBodyContext, + AttributeInKeyContext, + AttributeInValueContext, } /** @@ -91,6 +95,16 @@ export interface ElementInBodyContext { node: t.Element|t.Template; } +export interface AttributeInKeyContext { + kind: TargetNodeKind.AttributeInKeyContext; + node: t.TextAttribute|t.BoundAttribute|t.BoundEvent; +} + +export interface AttributeInValueContext { + kind: TargetNodeKind.AttributeInValueContext; + node: t.TextAttribute|t.BoundAttribute|t.BoundEvent; +} + /** * Return the template AST node or expression AST node that most accurately * represents the node at the specified cursor `position`. @@ -106,7 +120,10 @@ export function getTargetAtPosition(template: t.Node[], position: number): Templ const candidate = path[path.length - 1]; if (isTemplateNodeWithKeyAndValue(candidate)) { - const {keySpan, valueSpan} = candidate; + let {keySpan, valueSpan} = candidate; + if (valueSpan === undefined && candidate instanceof TmplAstBoundEvent) { + valueSpan = candidate.handlerSpan; + } const isWithinKeyValue = isWithin(position, keySpan) || (valueSpan && isWithin(position, valueSpan)); if (!isWithinKeyValue) { @@ -157,6 +174,21 @@ export function getTargetAtPosition(template: t.Node[], position: number): Templ node: candidate, }; } + } else if ( + (candidate instanceof t.BoundAttribute || candidate instanceof t.BoundEvent || + candidate instanceof t.TextAttribute) && + candidate.keySpan !== undefined) { + if (isWithin(position, candidate.keySpan)) { + nodeInContext = { + kind: TargetNodeKind.AttributeInKeyContext, + node: candidate, + }; + } else { + nodeInContext = { + kind: TargetNodeKind.AttributeInValueContext, + node: candidate, + }; + } } else { nodeInContext = { kind: TargetNodeKind.RawTemplateNode, @@ -264,6 +296,21 @@ class TemplateTargetVisitor implements t.Visitor { this.path.pop(); // remove bound event from the AST path return; } + + // An event binding with no value (e.g. `(event|)`) parses to a `BoundEvent` with a + // `LiteralPrimitive` handler with value `'ERROR'`, as opposed to a property binding with no + // value which has an `EmptyExpr` as its value. This is a synthetic node created by the binding + // parser, and is not suitable to use for Language Service analysis. Skip it. + // + // TODO(alxhub): modify the parser to generate an `EmptyExpr` instead. + let handler: e.AST = event.handler; + if (handler instanceof e.ASTWithSource) { + handler = handler.ast; + } + if (handler instanceof e.LiteralPrimitive && handler.value === 'ERROR') { + return; + } + const visitor = new ExpressionVisitor(this.position); visitor.visit(event.handler, this.path); } diff --git a/packages/language-service/ivy/test/completions_spec.ts b/packages/language-service/ivy/test/completions_spec.ts index 4edeed79b8..b2ff0754d7 100644 --- a/packages/language-service/ivy/test/completions_spec.ts +++ b/packages/language-service/ivy/test/completions_spec.ts @@ -15,6 +15,42 @@ import {LanguageService} from '../language_service'; import {LanguageServiceTestEnvironment} from './env'; +const DIR_WITH_INPUT = { + 'Dir': ` + @Directive({ + selector: '[dir]', + inputs: ['myInput'] + }) + export class Dir { + myInput!: string; + } + ` +}; + +const DIR_WITH_OUTPUT = { + 'Dir': ` + @Directive({ + selector: '[dir]', + outputs: ['myOutput'] + }) + export class Dir { + myInput!: any; + } + ` +}; + +const DIR_WITH_SELECTED_INPUT = { + 'Dir': ` + @Directive({ + selector: '[myInput]', + inputs: ['myInput'] + }) + export class Dir { + myInput!: string; + } + ` +}; + describe('completions', () => { beforeEach(() => { initMockFileSystem('Native'); @@ -252,6 +288,162 @@ describe('completions', () => { .toEqual('(component) AppModule.OtherCmp'); expect(ts.displayPartsToString(details.documentation!)).toEqual('This is another component.'); }); + + describe('element attribute scope', () => { + describe('dom completions', () => { + it('should return completions for a new element attribute', () => { + const {ngLS, fileName, cursor} = setup(``, ''); + + const completions = + ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined); + expectContain( + completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.ATTRIBUTE), + ['value']); + expectContain( + completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY), + ['[value]']); + }); + + it('should return completions for a partial attribute', () => { + const {ngLS, fileName, cursor, text} = setup(``, ''); + + const completions = + ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined); + expectContain( + completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.ATTRIBUTE), + ['value']); + expectContain( + completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY), + ['[value]']); + expectReplacementText(completions, text, 'val'); + }); + + it('should return completions for a partial property binding', () => { + const {ngLS, fileName, cursor, text} = setup(``, ''); + + const completions = + ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined); + expectDoesNotContain( + completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.ATTRIBUTE), + ['value']); + expectDoesNotContain( + completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY), + ['[value]']); + expectContain( + completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY), + ['value']); + expectReplacementText(completions, text, 'val'); + }); + }); + + describe('directive present', () => { + it('should return directive input completions for a new attribute', () => { + const {ngLS, fileName, cursor, text} = setup(``, '', DIR_WITH_INPUT); + + const completions = + ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined); + expectContain( + completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY), + ['[myInput]']); + expectContain( + completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.ATTRIBUTE), + ['myInput']); + }); + + it('should return directive input completions for a partial attribute', () => { + const {ngLS, fileName, cursor, text} = setup(``, '', DIR_WITH_INPUT); + + const completions = + ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined); + expectContain( + completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY), + ['[myInput]']); + expectContain( + completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.ATTRIBUTE), + ['myInput']); + }); + + it('should return input completions for a partial property binding', () => { + const {ngLS, fileName, cursor, text} = setup(``, '', DIR_WITH_INPUT); + + const completions = + ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined); + expectContain( + completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY), + ['myInput']); + }); + }); + + describe('directive not present', () => { + it('should return input completions for a new attribute', () => { + const {ngLS, fileName, cursor, text} = setup(``, '', DIR_WITH_SELECTED_INPUT); + + const completions = + ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined); + // This context should generate two completions: + // * `[myInput]` as a property + // * `myInput` as an attribute + expectContain( + completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY), + ['[myInput]']); + expectContain( + completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.ATTRIBUTE), + ['myInput']); + }); + }); + + it('should return input completions for a partial attribute', () => { + const {ngLS, fileName, cursor, text} = setup(``, '', DIR_WITH_SELECTED_INPUT); + + const completions = + ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined); + // This context should generate two completions: + // * `[myInput]` as a property + // * `myInput` as an attribute + expectContain( + completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY), + ['[myInput]']); + expectContain( + completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.ATTRIBUTE), + ['myInput']); + expectReplacementText(completions, text, 'my'); + }); + + it('should return input completions for a partial property binding', () => { + const {ngLS, fileName, cursor, text} = setup(``, '', DIR_WITH_SELECTED_INPUT); + + const completions = + ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined); + // This context should generate two completions: + // * `[myInput]` as a property + // * `myInput` as an attribute + expectContain( + completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY), + ['myInput']); + expectReplacementText(completions, text, 'my'); + }); + + it('should return output completions for an empty binding', () => { + const {ngLS, fileName, cursor, text} = setup(``, '', DIR_WITH_OUTPUT); + + const completions = + ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined); + expectContain( + completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.EVENT), + ['(myOutput)']); + }); + + it('should return output completions for a partial event binding', () => { + const {ngLS, fileName, cursor, text} = setup(``, '', DIR_WITH_OUTPUT); + + const completions = + ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined); + expectContain( + completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.EVENT), + ['myOutput']); + expectReplacementText(completions, text, 'my'); + }); + }); }); }); @@ -274,6 +466,29 @@ function expectAll( expect(completions!.entries.length).toEqual(Object.keys(contains).length); } +function expectDoesNotContain( + completions: ts.CompletionInfo|undefined, kind: ts.ScriptElementKind|DisplayInfoKind, + names: string[]) { + expect(completions).toBeDefined(); + for (const name of names) { + expect(completions!.entries).not.toContain(jasmine.objectContaining({name, kind} as any)); + } +} + +function expectReplacementText( + completions: ts.CompletionInfo|undefined, text: string, replacementText: string) { + if (completions === undefined) { + return; + } + + for (const entry of completions.entries) { + expect(entry.replacementSpan).toBeDefined(); + const completionReplaces = + text.substr(entry.replacementSpan!.start, entry.replacementSpan!.length); + expect(completionReplaces).toBe(replacementText); + } +} + function toText(displayParts?: ts.SymbolDisplayPart[]): string { return (displayParts ?? []).map(p => p.text).join(''); } @@ -287,6 +502,7 @@ function setup( ngLS: LanguageService, cursor: number, nodes: TmplAstNode[], + text: string, } { const codePath = absoluteFrom('/test.ts'); const templatePath = absoluteFrom('/test.html'); @@ -323,13 +539,15 @@ function setup( contents: 'Placeholder template', } ]); - const {nodes, cursor} = env.overrideTemplateWithCursor(codePath, 'AppCmp', templateWithCursor); + const {nodes, cursor, text} = + env.overrideTemplateWithCursor(codePath, 'AppCmp', templateWithCursor); return { env, fileName: templatePath, AppCmp: env.getClass(codePath, 'AppCmp'), ngLS: env.ngLS, nodes, + text, cursor, }; } diff --git a/packages/language-service/ivy/utils.ts b/packages/language-service/ivy/utils.ts index 2aa5d04290..28327990a3 100644 --- a/packages/language-service/ivy/utils.ts +++ b/packages/language-service/ivy/utils.ts @@ -210,6 +210,13 @@ export function getDirectiveMatchesForElementTag( return difference(allDirectiveMatches, matchesWithoutElement); } + +export function makeElementSelector(element: t.Element|t.Template): string { + const attributes = getAttributes(element); + const allAttrs = attributes.map(toAttributeString); + return getNodeName(element) + allAttrs.join(''); +} + /** * Given an attribute name, determines which directives match because the attribute is present. We * find which directives are applied because of this attribute by elimination: compare the directive