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:
parent
c0ab43f3c8
commit
66378ed0ef
|
@ -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",
|
||||
|
|
|
@ -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]];
|
||||
}
|
||||
}
|
|
@ -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,26 +411,183 @@ 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|
|
||||
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};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue