mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
feat(language-service): provide snippets for attribute (#43590)
Support snippet completions for dom attribute whose value is empty. For Example `<div (my¦) />`, the `insertText` will return `(myOutput)="$0"`. Fixes https://github.com/angular/vscode-ng-language-service/issues/521 PR Close #43590
This commit is contained in:
@@ -370,6 +370,10 @@ export function buildAttributeCompletionTable(
|
||||
return table;
|
||||
}
|
||||
|
||||
function buildSnippet(insertSnippet: true|undefined, text: string): string|undefined {
|
||||
return insertSnippet ? `${text}="$1"` : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an `AttributeCompletion`, add any available completions to a `ts.CompletionEntry` array of
|
||||
* results.
|
||||
@@ -381,10 +385,15 @@ export function buildAttributeCompletionTable(
|
||||
* 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).
|
||||
*
|
||||
* If the `insertSnippet` is `true`, the completion entries should includes the property or event
|
||||
* binding sugar in some case. For Example `<div (my¦) />`, the `replacementSpan` is `(my)`, and the
|
||||
* `insertText` is `(myOutput)="$0"`.
|
||||
*/
|
||||
export function addAttributeCompletionEntries(
|
||||
entries: ts.CompletionEntry[], completion: AttributeCompletion, isAttributeContext: boolean,
|
||||
isElementContext: boolean, replacementSpan: ts.TextSpan|undefined): void {
|
||||
isElementContext: boolean, replacementSpan: ts.TextSpan|undefined,
|
||||
insertSnippet: true|undefined): void {
|
||||
switch (completion.kind) {
|
||||
case AttributeCompletionKind.DirectiveAttribute: {
|
||||
entries.push({
|
||||
@@ -403,17 +412,21 @@ export function addAttributeCompletionEntries(
|
||||
entries.push({
|
||||
kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.DIRECTIVE),
|
||||
name: prefix + completion.attribute,
|
||||
insertText: buildSnippet(insertSnippet, prefix + completion.attribute),
|
||||
isSnippet: insertSnippet,
|
||||
sortText: prefix + completion.attribute,
|
||||
replacementSpan,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case AttributeCompletionKind.DirectiveInput: {
|
||||
if (isAttributeContext) {
|
||||
if (isAttributeContext || insertSnippet) {
|
||||
// Offer a completion of a property binding.
|
||||
entries.push({
|
||||
kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY),
|
||||
name: `[${completion.propertyName}]`,
|
||||
insertText: buildSnippet(insertSnippet, `[${completion.propertyName}]`),
|
||||
isSnippet: insertSnippet,
|
||||
sortText: completion.propertyName,
|
||||
replacementSpan,
|
||||
});
|
||||
@@ -422,6 +435,8 @@ export function addAttributeCompletionEntries(
|
||||
entries.push({
|
||||
kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY),
|
||||
name: `[(${completion.propertyName})]`,
|
||||
insertText: buildSnippet(insertSnippet, `[(${completion.propertyName})]`),
|
||||
isSnippet: insertSnippet,
|
||||
// This completion should sort after the property binding.
|
||||
sortText: completion.propertyName + '_1',
|
||||
replacementSpan,
|
||||
@@ -431,6 +446,8 @@ export function addAttributeCompletionEntries(
|
||||
entries.push({
|
||||
kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.ATTRIBUTE),
|
||||
name: completion.propertyName,
|
||||
insertText: buildSnippet(insertSnippet, completion.propertyName),
|
||||
isSnippet: insertSnippet,
|
||||
// This completion should sort after both property binding options (one-way and two-way).
|
||||
sortText: completion.propertyName + '_2',
|
||||
replacementSpan,
|
||||
@@ -439,6 +456,8 @@ export function addAttributeCompletionEntries(
|
||||
entries.push({
|
||||
kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY),
|
||||
name: completion.propertyName,
|
||||
insertText: buildSnippet(insertSnippet, completion.propertyName),
|
||||
isSnippet: insertSnippet,
|
||||
sortText: completion.propertyName,
|
||||
replacementSpan,
|
||||
});
|
||||
@@ -446,10 +465,12 @@ export function addAttributeCompletionEntries(
|
||||
break;
|
||||
}
|
||||
case AttributeCompletionKind.DirectiveOutput: {
|
||||
if (isAttributeContext) {
|
||||
if (isAttributeContext || insertSnippet) {
|
||||
entries.push({
|
||||
kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.EVENT),
|
||||
name: `(${completion.eventName})`,
|
||||
insertText: buildSnippet(insertSnippet, `(${completion.eventName})`),
|
||||
isSnippet: insertSnippet,
|
||||
sortText: completion.eventName,
|
||||
replacementSpan,
|
||||
});
|
||||
@@ -457,6 +478,8 @@ export function addAttributeCompletionEntries(
|
||||
entries.push({
|
||||
kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.EVENT),
|
||||
name: completion.eventName,
|
||||
insertText: buildSnippet(insertSnippet, completion.eventName),
|
||||
isSnippet: insertSnippet,
|
||||
sortText: completion.eventName,
|
||||
replacementSpan,
|
||||
});
|
||||
@@ -464,11 +487,13 @@ export function addAttributeCompletionEntries(
|
||||
break;
|
||||
}
|
||||
case AttributeCompletionKind.DomAttribute: {
|
||||
if (isAttributeContext && completion.isAlsoProperty) {
|
||||
if ((isAttributeContext || insertSnippet) && completion.isAlsoProperty) {
|
||||
// Offer a completion of a property binding to the DOM property.
|
||||
entries.push({
|
||||
kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY),
|
||||
name: `[${completion.attribute}]`,
|
||||
insertText: buildSnippet(insertSnippet, `[${completion.attribute}]`),
|
||||
isSnippet: insertSnippet,
|
||||
// In the case of DOM attributes, the property binding should sort after the attribute
|
||||
// binding.
|
||||
sortText: completion.attribute + '_1',
|
||||
@@ -482,6 +507,8 @@ export function addAttributeCompletionEntries(
|
||||
entries.push({
|
||||
kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY),
|
||||
name: completion.property,
|
||||
insertText: buildSnippet(insertSnippet, completion.property),
|
||||
isSnippet: insertSnippet,
|
||||
sortText: completion.property,
|
||||
replacementSpan,
|
||||
});
|
||||
@@ -492,6 +519,8 @@ export function addAttributeCompletionEntries(
|
||||
entries.push({
|
||||
kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.EVENT),
|
||||
name: `(${completion.eventName})`,
|
||||
insertText: buildSnippet(insertSnippet, `(${completion.eventName})`),
|
||||
isSnippet: insertSnippet,
|
||||
sortText: completion.eventName,
|
||||
replacementSpan,
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
|
||||
import {AST, BindingPipe, Call, EmptyExpr, ImplicitReceiver, LiteralPrimitive, ParseSourceSpan, PropertyRead, PropertyWrite, SafePropertyRead, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstElement, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstText, TmplAstTextAttribute, TmplAstVariable} from '@angular/compiler';
|
||||
import {AST, ASTWithSource, BindingPipe, Call, EmptyExpr, ImplicitReceiver, LiteralPrimitive, ParseSourceSpan, PropertyRead, PropertyWrite, SafePropertyRead, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstElement, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstText, TmplAstTextAttribute, TmplAstVariable} from '@angular/compiler';
|
||||
import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core';
|
||||
import {CompletionKind, DirectiveInScope, SymbolKind, TemplateDeclarationSymbol} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
|
||||
import {BoundEvent, TextAttribute} from '@angular/compiler/src/render3/r3_ast';
|
||||
@@ -15,7 +15,7 @@ import ts from 'typescript';
|
||||
import {addAttributeCompletionEntries, AttributeCompletionKind, buildAttributeCompletionTable, getAttributeCompletionSymbol} from './attribute_completions';
|
||||
import {DisplayInfo, DisplayInfoKind, getDirectiveDisplayInfo, getSymbolDisplayInfo, getTsSymbolDisplayInfo, unsafeCastDisplayInfoKindToScriptElementKind} from './display_parts';
|
||||
import {TargetContext, TargetNodeKind, TemplateTarget} from './template_target';
|
||||
import {filterAliasImports} from './utils';
|
||||
import {filterAliasImports, isBoundEventWithSyntheticHandler} from './utils';
|
||||
|
||||
type PropertyExpressionCompletionBuilder =
|
||||
CompletionBuilder<PropertyRead|PropertyWrite|EmptyExpr|SafePropertyRead|TmplAstBoundEvent>;
|
||||
@@ -70,7 +70,7 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
|
||||
} else if (this.isElementTagCompletion()) {
|
||||
return this.getElementTagCompletion();
|
||||
} else if (this.isElementAttributeCompletion()) {
|
||||
return this.getElementAttributeCompletions();
|
||||
return this.getElementAttributeCompletions(options);
|
||||
} else if (this.isPipeCompletion()) {
|
||||
return this.getPipeCompletions();
|
||||
} else if (this.isLiteralCompletion()) {
|
||||
@@ -538,8 +538,10 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
|
||||
this.node instanceof TmplAstTextAttribute || this.node instanceof TmplAstBoundEvent);
|
||||
}
|
||||
|
||||
private getElementAttributeCompletions(this: ElementAttributeCompletionBuilder):
|
||||
ts.WithMetadata<ts.CompletionInfo>|undefined {
|
||||
private getElementAttributeCompletions(
|
||||
this: ElementAttributeCompletionBuilder,
|
||||
options: ts.GetCompletionsAtPositionOptions|
|
||||
undefined): ts.WithMetadata<ts.CompletionInfo>|undefined {
|
||||
let element: TmplAstElement|TmplAstTemplate;
|
||||
if (this.node instanceof TmplAstElement) {
|
||||
element = this.node;
|
||||
@@ -558,6 +560,45 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
|
||||
replacementSpan = makeReplacementSpanFromParseSourceSpan(this.node.keySpan);
|
||||
}
|
||||
|
||||
let insertSnippet: true|undefined;
|
||||
if (options?.includeCompletionsWithSnippetText && options.includeCompletionsWithInsertText) {
|
||||
if (this.node instanceof TmplAstBoundEvent && isBoundEventWithSyntheticHandler(this.node)) {
|
||||
replacementSpan = makeReplacementSpanFromParseSourceSpan(this.node.sourceSpan);
|
||||
insertSnippet = true;
|
||||
}
|
||||
|
||||
const isBoundAttributeValueEmpty = this.node instanceof TmplAstBoundAttribute &&
|
||||
(this.node.valueSpan === undefined ||
|
||||
(this.node.value instanceof ASTWithSource && this.node.value.ast instanceof EmptyExpr));
|
||||
if (isBoundAttributeValueEmpty) {
|
||||
replacementSpan = makeReplacementSpanFromParseSourceSpan(this.node.sourceSpan);
|
||||
insertSnippet = true;
|
||||
}
|
||||
|
||||
if (this.node instanceof TmplAstTextAttribute && this.node.keySpan !== undefined) {
|
||||
// The `sourceSpan` only includes `ngFor` and the `valueSpan` is always empty even if there
|
||||
// is something there because we split this up into the desugared AST, `ngFor ngForOf=""`.
|
||||
const nodeStart = this.node.keySpan.start.getContext(1, 1);
|
||||
if (nodeStart?.before[0] === '*') {
|
||||
const nodeEnd = this.node.keySpan.end.getContext(1, 1);
|
||||
if (nodeEnd?.after[0] !== '=') {
|
||||
// *ngFor -> *ngFor="¦"
|
||||
insertSnippet = true;
|
||||
}
|
||||
} else {
|
||||
if (this.node.value === '') {
|
||||
replacementSpan = makeReplacementSpanFromParseSourceSpan(this.node.sourceSpan);
|
||||
insertSnippet = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.node instanceof TmplAstElement) {
|
||||
// <div ¦ />
|
||||
insertSnippet = true;
|
||||
}
|
||||
}
|
||||
|
||||
const attrTable = buildAttributeCompletionTable(
|
||||
this.component, element, this.compiler.getTemplateTypeChecker());
|
||||
|
||||
@@ -607,8 +648,10 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
|
||||
// Is the completion for an element (not an <ng-template>)?
|
||||
const isElementContext =
|
||||
this.node instanceof TmplAstElement || this.nodeParent instanceof TmplAstElement;
|
||||
|
||||
addAttributeCompletionEntries(
|
||||
entries, completion, isAttributeContext, isElementContext, replacementSpan);
|
||||
entries, completion, isAttributeContext, isElementContext, replacementSpan,
|
||||
insertSnippet);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {ParseSpan, 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
|
||||
|
||||
import {isTemplateNodeWithKeyAndValue, isWithin, isWithinKeyValue} from './utils';
|
||||
import {isBoundEventWithSyntheticHandler, isTemplateNodeWithKeyAndValue, isWithin, isWithinKeyValue} from './utils';
|
||||
|
||||
/**
|
||||
* Contextual information for a target position within the template.
|
||||
@@ -375,22 +375,10 @@ class TemplateTargetVisitor implements t.Visitor {
|
||||
}
|
||||
|
||||
visitBoundEvent(event: t.BoundEvent) {
|
||||
// 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 (!isBoundEventWithSyntheticHandler(event)) {
|
||||
const visitor = new ExpressionVisitor(this.position);
|
||||
visitor.visit(event.handler, this.path);
|
||||
}
|
||||
if (handler instanceof e.LiteralPrimitive && handler.value === 'ERROR') {
|
||||
return;
|
||||
}
|
||||
|
||||
const visitor = new ExpressionVisitor(this.position);
|
||||
visitor.visit(event.handler, this.path);
|
||||
}
|
||||
|
||||
visitText(text: t.Text) {
|
||||
|
||||
@@ -48,6 +48,18 @@ const DIR_WITH_OUTPUT = {
|
||||
`
|
||||
};
|
||||
|
||||
const CUSTOM_BUTTON = {
|
||||
'Button': `
|
||||
@Directive({
|
||||
selector: 'button[mat-button]',
|
||||
inputs: ['color']
|
||||
})
|
||||
export class Button {
|
||||
color!: any;
|
||||
}
|
||||
`
|
||||
};
|
||||
|
||||
const DIR_WITH_TWO_WAY_BINDING = {
|
||||
'Dir': `
|
||||
@Directive({
|
||||
@@ -875,6 +887,219 @@ describe('completions', () => {
|
||||
expectReplacementText(completions, templateFile.contents, 'title');
|
||||
});
|
||||
});
|
||||
|
||||
describe('insert snippet text', () => {
|
||||
it('should be able to complete for an empty attribute', () => {
|
||||
const {templateFile} = setup(`<input dir >`, '', DIR_WITH_OUTPUT);
|
||||
templateFile.moveCursorToText('¦>');
|
||||
|
||||
const completions = templateFile.getCompletionsAtPosition({
|
||||
includeCompletionsWithSnippetText: true,
|
||||
includeCompletionsWithInsertText: true,
|
||||
});
|
||||
expectContainInsertTextWithSnippet(
|
||||
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.EVENT),
|
||||
['(myOutput)="$1"']);
|
||||
});
|
||||
|
||||
it('should be able to complete for a partial attribute', () => {
|
||||
const {templateFile} = setup(`<input dir my>`, '', DIR_WITH_OUTPUT);
|
||||
templateFile.moveCursorToText('¦>');
|
||||
|
||||
const completions = templateFile.getCompletionsAtPosition({
|
||||
includeCompletionsWithSnippetText: true,
|
||||
includeCompletionsWithInsertText: true,
|
||||
});
|
||||
expectContainInsertTextWithSnippet(
|
||||
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.EVENT),
|
||||
['(myOutput)="$1"']);
|
||||
expectReplacementText(completions, templateFile.contents, 'my');
|
||||
});
|
||||
|
||||
it('should be able to complete for the event binding with the value is empty', () => {
|
||||
const {templateFile} = setup(`<input dir ()="">`, '', DIR_WITH_OUTPUT);
|
||||
templateFile.moveCursorToText('(¦)="">');
|
||||
|
||||
const completions = templateFile.getCompletionsAtPosition({
|
||||
includeCompletionsWithSnippetText: true,
|
||||
includeCompletionsWithInsertText: true,
|
||||
});
|
||||
expectContainInsertTextWithSnippet(
|
||||
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.EVENT),
|
||||
['(myOutput)="$1"']);
|
||||
expectReplacementText(completions, templateFile.contents, '()=""');
|
||||
});
|
||||
|
||||
it('should be able to complete for the event binding', () => {
|
||||
const {templateFile} = setup(`<input dir ()>`, '', DIR_WITH_OUTPUT);
|
||||
templateFile.moveCursorToText('(¦)>');
|
||||
|
||||
const completions = templateFile.getCompletionsAtPosition({
|
||||
includeCompletionsWithSnippetText: true,
|
||||
includeCompletionsWithInsertText: true,
|
||||
});
|
||||
expectContainInsertTextWithSnippet(
|
||||
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.EVENT),
|
||||
['(myOutput)="$1"']);
|
||||
expectReplacementText(completions, templateFile.contents, '()');
|
||||
});
|
||||
|
||||
it('should be able to complete for the dom event binding', () => {
|
||||
const {templateFile} = setup(`<input dir (cli)>`, '', DIR_WITH_OUTPUT);
|
||||
templateFile.moveCursorToText('(cli¦)>');
|
||||
|
||||
const completions = templateFile.getCompletionsAtPosition({
|
||||
includeCompletionsWithSnippetText: true,
|
||||
includeCompletionsWithInsertText: true,
|
||||
});
|
||||
expectContainInsertTextWithSnippet(
|
||||
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.EVENT),
|
||||
['(click)="$1"']);
|
||||
expectReplacementText(completions, templateFile.contents, '(cli)');
|
||||
});
|
||||
|
||||
it('should be able to complete for the property binding with the value is empty', () => {
|
||||
const {templateFile} = setup(`<input [my]="">`, '', DIR_WITH_SELECTED_INPUT);
|
||||
templateFile.moveCursorToText('[my¦]=""');
|
||||
|
||||
const completions = templateFile.getCompletionsAtPosition({
|
||||
includeCompletionsWithSnippetText: true,
|
||||
includeCompletionsWithInsertText: true,
|
||||
});
|
||||
expectContainInsertTextWithSnippet(
|
||||
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY),
|
||||
['[myInput]="$1"']);
|
||||
expectReplacementText(completions, templateFile.contents, '[my]=""');
|
||||
});
|
||||
|
||||
it('should be able to complete for the property binding', () => {
|
||||
const {templateFile} = setup(`<input [my]>`, '', DIR_WITH_SELECTED_INPUT);
|
||||
templateFile.moveCursorToText('[my¦]');
|
||||
|
||||
const completions = templateFile.getCompletionsAtPosition({
|
||||
includeCompletionsWithSnippetText: true,
|
||||
includeCompletionsWithInsertText: true,
|
||||
});
|
||||
expectContainInsertTextWithSnippet(
|
||||
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY),
|
||||
['[myInput]="$1"']);
|
||||
expectReplacementText(completions, templateFile.contents, '[my]');
|
||||
});
|
||||
|
||||
it('should be able to complete for the dom property binding', () => {
|
||||
const {templateFile} = setup(`<input [val]>`, '', DIR_WITH_SELECTED_INPUT);
|
||||
templateFile.moveCursorToText('[val¦]');
|
||||
|
||||
const completions = templateFile.getCompletionsAtPosition({
|
||||
includeCompletionsWithSnippetText: true,
|
||||
includeCompletionsWithInsertText: true,
|
||||
});
|
||||
expectContainInsertTextWithSnippet(
|
||||
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY),
|
||||
['[value]="$1"']);
|
||||
expectReplacementText(completions, templateFile.contents, '[val]');
|
||||
});
|
||||
|
||||
it('should be able to complete for the two way binding with the value is empty', () => {
|
||||
const {templateFile} = setup(`<h1 dir [(mod)]=""></h1>`, ``, DIR_WITH_TWO_WAY_BINDING);
|
||||
templateFile.moveCursorToText('[(mod¦)]=""');
|
||||
|
||||
const completions = templateFile.getCompletionsAtPosition({
|
||||
includeCompletionsWithSnippetText: true,
|
||||
includeCompletionsWithInsertText: true,
|
||||
});
|
||||
expectContainInsertTextWithSnippet(
|
||||
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY),
|
||||
['[(model)]="$1"']);
|
||||
expectReplacementText(completions, templateFile.contents, '[(mod)]=""');
|
||||
});
|
||||
|
||||
it('should be able to complete for the two way binding via', () => {
|
||||
const {templateFile} = setup(`<h1 dir [(mod)]></h1>`, ``, DIR_WITH_TWO_WAY_BINDING);
|
||||
templateFile.moveCursorToText('[(mod¦)]');
|
||||
|
||||
const completions = templateFile.getCompletionsAtPosition({
|
||||
includeCompletionsWithSnippetText: true,
|
||||
includeCompletionsWithInsertText: true,
|
||||
});
|
||||
expectContainInsertTextWithSnippet(
|
||||
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY),
|
||||
['[(model)]="$1"']);
|
||||
expectReplacementText(completions, templateFile.contents, '[(mod)]');
|
||||
});
|
||||
|
||||
it('should be able to complete for the structural directive with the value is empty', () => {
|
||||
const {templateFile} = setup(`<input dir *ngFor="">`, '', NG_FOR_DIR);
|
||||
templateFile.moveCursorToText('ngFor¦="">');
|
||||
|
||||
const completions = templateFile.getCompletionsAtPosition({
|
||||
includeCompletionsWithSnippetText: true,
|
||||
includeCompletionsWithInsertText: true,
|
||||
});
|
||||
// Now here is using the `=` to check the value of the structural directive. If the
|
||||
// sourceSpan/valueSpan made more sense, it should behave like this `ngFor¦="" -> ngFor="¦"`,
|
||||
// and enable comments below.
|
||||
//
|
||||
// expectContainInsertTextWithSnippet(
|
||||
// completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.DIRECTIVE),
|
||||
// ['ngFor="$1"']);
|
||||
// expectReplacementText(completions, templateFile.contents, 'ngFor=""');
|
||||
expectContain(
|
||||
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.DIRECTIVE),
|
||||
['ngFor']);
|
||||
expect(completions?.entries[0]).toBeDefined();
|
||||
expect(completions?.entries[0].isSnippet).toBeUndefined();
|
||||
expectReplacementText(completions, templateFile.contents, 'ngFor');
|
||||
});
|
||||
|
||||
it('should be able to complete for the structural directive', () => {
|
||||
const {templateFile} = setup(`<input dir *ngFor>`, '', NG_FOR_DIR);
|
||||
templateFile.moveCursorToText('ngFor¦>');
|
||||
|
||||
const completions = templateFile.getCompletionsAtPosition({
|
||||
includeCompletionsWithSnippetText: true,
|
||||
includeCompletionsWithInsertText: true,
|
||||
});
|
||||
expectContainInsertTextWithSnippet(
|
||||
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.DIRECTIVE),
|
||||
['ngFor="$1"']);
|
||||
expectReplacementText(completions, templateFile.contents, 'ngFor');
|
||||
});
|
||||
|
||||
it('should not be included in the completion for an attribute with a value', () => {
|
||||
const {templateFile} = setup(`<input dir myInput="1">`, '', DIR_WITH_SELECTED_INPUT);
|
||||
templateFile.moveCursorToText('myInput¦="1">');
|
||||
|
||||
const completions = templateFile.getCompletionsAtPosition({
|
||||
includeCompletionsWithSnippetText: true,
|
||||
includeCompletionsWithInsertText: true,
|
||||
});
|
||||
expectContain(
|
||||
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.ATTRIBUTE),
|
||||
['myInput']);
|
||||
expectDoesNotContainInsertTextWithSnippet(
|
||||
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.ATTRIBUTE),
|
||||
['myInput="$1"']);
|
||||
expectReplacementText(completions, templateFile.contents, 'myInput');
|
||||
});
|
||||
|
||||
it('should not be included in the completion for a directive attribute without input', () => {
|
||||
const {templateFile} = setup(`<button mat-></button>`, '', CUSTOM_BUTTON);
|
||||
templateFile.moveCursorToText('mat-¦>');
|
||||
|
||||
const completions = templateFile.getCompletionsAtPosition({
|
||||
includeCompletionsWithSnippetText: true,
|
||||
includeCompletionsWithInsertText: true,
|
||||
});
|
||||
expectContain(
|
||||
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.DIRECTIVE),
|
||||
['mat-button']);
|
||||
expectDoesNotContainInsertTextWithSnippet(
|
||||
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.DIRECTIVE),
|
||||
['mat-button="$1"']);
|
||||
expectReplacementText(completions, templateFile.contents, 'mat-');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function expectContainInsertText(
|
||||
@@ -886,6 +1111,26 @@ function expectContainInsertText(
|
||||
}
|
||||
}
|
||||
|
||||
function expectContainInsertTextWithSnippet(
|
||||
completions: ts.CompletionInfo|undefined, kind: ts.ScriptElementKind|DisplayInfoKind,
|
||||
insertTexts: string[]) {
|
||||
expect(completions).toBeDefined();
|
||||
for (const insertText of insertTexts) {
|
||||
expect(completions!.entries)
|
||||
.toContain(jasmine.objectContaining({insertText, kind, isSnippet: true} as any));
|
||||
}
|
||||
}
|
||||
|
||||
function expectDoesNotContainInsertTextWithSnippet(
|
||||
completions: ts.CompletionInfo|undefined, kind: ts.ScriptElementKind|DisplayInfoKind,
|
||||
insertTexts: string[]) {
|
||||
expect(completions).toBeDefined();
|
||||
for (const insertText of insertTexts) {
|
||||
expect(completions!.entries)
|
||||
.not.toContain(jasmine.objectContaining({insertText, kind, isSnippet: true} as any));
|
||||
}
|
||||
}
|
||||
|
||||
function expectContain(
|
||||
completions: ts.CompletionInfo|undefined, kind: ts.ScriptElementKind|DisplayInfoKind,
|
||||
names: string[]) {
|
||||
|
||||
@@ -374,3 +374,20 @@ export function getTemplateLocationFromShimLocation(
|
||||
}
|
||||
return {templateUrl, span};
|
||||
}
|
||||
|
||||
export function isBoundEventWithSyntheticHandler(event: t.BoundEvent): boolean {
|
||||
// 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 true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user