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:
ivanwonder
2021-09-22 09:04:25 +08:00
committed by Dylan Hunn
parent c14085e434
commit 69957f72e2
5 changed files with 348 additions and 26 deletions
@@ -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,
});
+49 -6
View File
@@ -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[]) {
+17
View File
@@ -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;
}