From 69957f72e240e516fe65146c314014fadc43dd1f Mon Sep 17 00:00:00 2001 From: ivanwonder Date: Wed, 22 Sep 2021 09:04:25 +0800 Subject: [PATCH] feat(language-service): provide snippets for attribute (#43590) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support snippet completions for dom attribute whose value is empty. For Example `
`, the `insertText` will return `(myOutput)="$0"`. Fixes https://github.com/angular/vscode-ng-language-service/issues/521 PR Close #43590 --- .../ivy/attribute_completions.ts | 37 ++- packages/language-service/ivy/completions.ts | 55 +++- .../language-service/ivy/template_target.ts | 20 +- .../ivy/test/completions_spec.ts | 245 ++++++++++++++++++ packages/language-service/ivy/utils.ts | 17 ++ 5 files changed, 348 insertions(+), 26 deletions(-) diff --git a/packages/language-service/ivy/attribute_completions.ts b/packages/language-service/ivy/attribute_completions.ts index a6f469b55ac..631d6854eaf 100644 --- a/packages/language-service/ivy/attribute_completions.ts +++ b/packages/language-service/ivy/attribute_completions.ts @@ -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 `
`, 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, }); diff --git a/packages/language-service/ivy/completions.ts b/packages/language-service/ivy/completions.ts index 333fcb07205..6bf6cee9bfd 100644 --- a/packages/language-service/ivy/completions.ts +++ b/packages/language-service/ivy/completions.ts @@ -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; @@ -70,7 +70,7 @@ export class CompletionBuilder { } 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 { this.node instanceof TmplAstTextAttribute || this.node instanceof TmplAstBoundEvent); } - private getElementAttributeCompletions(this: ElementAttributeCompletionBuilder): - ts.WithMetadata|undefined { + private getElementAttributeCompletions( + this: ElementAttributeCompletionBuilder, + options: ts.GetCompletionsAtPositionOptions| + undefined): ts.WithMetadata|undefined { let element: TmplAstElement|TmplAstTemplate; if (this.node instanceof TmplAstElement) { element = this.node; @@ -558,6 +560,45 @@ export class CompletionBuilder { 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) { + //
+ insertSnippet = true; + } + } + const attrTable = buildAttributeCompletionTable( this.component, element, this.compiler.getTemplateTypeChecker()); @@ -607,8 +648,10 @@ export class CompletionBuilder { // Is the completion for an element (not an )? const isElementContext = this.node instanceof TmplAstElement || this.nodeParent instanceof TmplAstElement; + addAttributeCompletionEntries( - entries, completion, isAttributeContext, isElementContext, replacementSpan); + entries, completion, isAttributeContext, isElementContext, replacementSpan, + insertSnippet); } return { diff --git a/packages/language-service/ivy/template_target.ts b/packages/language-service/ivy/template_target.ts index 12847cc1d2f..d5e3f556417 100644 --- a/packages/language-service/ivy/template_target.ts +++ b/packages/language-service/ivy/template_target.ts @@ -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) { diff --git a/packages/language-service/ivy/test/completions_spec.ts b/packages/language-service/ivy/test/completions_spec.ts index 41870024bba..cf5128ad27c 100644 --- a/packages/language-service/ivy/test/completions_spec.ts +++ b/packages/language-service/ivy/test/completions_spec.ts @@ -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(``, '', 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(``, '', 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(``, '', 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(``, '', 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(``, '', 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(``, '', 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(``, '', 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(``, '', 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(`

`, ``, 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(`

`, ``, 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(``, '', 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(``, '', 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(``, '', 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(``, '', 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[]) { diff --git a/packages/language-service/ivy/utils.ts b/packages/language-service/ivy/utils.ts index 3e44d1ae92c..2da2af621c5 100644 --- a/packages/language-service/ivy/utils.ts +++ b/packages/language-service/ivy/utils.ts @@ -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; +}