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
This commit is contained in:
Alex Rickabaugh 2020-12-03 18:08:00 -08:00
parent c0ab43f3c8
commit 66378ed0ef
8 changed files with 993 additions and 35 deletions

View File

@ -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",

View File

@ -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<string, AttributeCompletion> {
const table = new Map<string, AttributeCompletion>();
// 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<ts.ClassDeclaration>();
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 `<element attr|>` will generate two results: `attribute` and
* `[attribute]` - either a static attribute can be generated, or a property binding. However,
* `<element [attr|]>` 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]];
}
}

View File

@ -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<PropertyRead|PropertyWrite|MethodCall|EmptyExpr|SafePropertyRead|
SafeMethodCall>;
SafeMethodCall|TmplAstBoundEvent>;
type ElementAttributeCompletionBuilder =
CompletionBuilder<TmplAstElement|TmplAstBoundAttribute|TmplAstTextAttribute|TmplAstBoundEvent>;
export enum CompletionNodeContext {
@ -24,6 +29,7 @@ export enum CompletionNodeContext {
ElementTag,
ElementAttributeKey,
ElementAttributeValue,
EventValue,
}
/**
@ -57,6 +63,8 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
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<N extends TmplAstNode|AST> {
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<N extends TmplAstNode|AST> {
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<N extends TmplAstNode|AST> {
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<N extends TmplAstNode|AST> {
this: PropertyExpressionCompletionBuilder,
options: ts.GetCompletionsAtPositionOptions|
undefined): ts.WithMetadata<ts.CompletionInfo>|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<N extends TmplAstNode|AST> {
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<N extends TmplAstNode|AST> {
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<N extends TmplAstNode|AST> {
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<N extends TmplAstNode|AST> {
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,25 +411,182 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
return directive?.tsSymbol;
}
// private getElementAttributeCompletions(this: CompletionBuilder<TmplAstElement>):
// ts.WithMetadata<ts.CompletionInfo> {}
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<ts.CompletionInfo>|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|
function makeReplacementSpanFromAst(node: PropertyRead|PropertyWrite|MethodCall|SafePropertyRead|
SafeMethodCall): ts.TextSpan {
return {
start: 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};
}
}

View File

@ -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',
@ -149,3 +150,28 @@ export function getDirectiveDisplayInfo(
documentation: res.documentation,
};
}
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,
};
}

View File

@ -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;

View File

@ -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);
}

View File

@ -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(`<input ¦>`, '');
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(`<input val¦>`, '');
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(`<input [val¦]>`, '');
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(`<input dir ¦>`, '', 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(`<input dir my¦>`, '', 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(`<input dir [my¦]>`, '', 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(`<input ¦>`, '', 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(`<input my¦>`, '', 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(`<input [my¦]>`, '', 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(`<input dir ¦>`, '', 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(`<input dir (my¦)>`, '', 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,
};
}

View File

@ -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