Skip to content

Commit 7c052bb

Browse files
dylhunnatscott
authored andcommitted
feat(language-service): Support autocompletion for blocks (#52121)
This commit introduces basic autocompletion support for the new block keywords. After typing `@`, the language service suggests the various block names. PR Close #52121
1 parent 4da08dc commit 7c052bb

File tree

4 files changed

+101
-16
lines changed

4 files changed

+101
-16
lines changed

packages/language-service/src/attribute_completions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,7 @@ function buildSnippet(insertSnippet: true|undefined, text: string): string|undef
405405
* This sort priority is based on the ASCII table. Other than `space`, the `!` is the first
406406
* printable character in the ASCII ordering.
407407
*/
408-
enum AsciiSortPriority {
408+
export enum AsciiSortPriority {
409409
First = '!',
410410
Second = '"',
411411
}

packages/language-service/src/completions.ts

Lines changed: 80 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@
99
import {AST, ASTWithSource, BindingPipe, BindingType, Call, EmptyExpr, ImplicitReceiver, LiteralPrimitive, ParsedEventType, ParseSourceSpan, PropertyRead, PropertyWrite, SafePropertyRead, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstElement, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstText, TmplAstTextAttribute, TmplAstVariable} from '@angular/compiler';
1010
import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core';
1111
import {CompletionKind, PotentialDirective, SymbolKind, TemplateDeclarationSymbol} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
12-
import {BoundEvent, TextAttribute} from '@angular/compiler/src/render3/r3_ast';
12+
import {BoundEvent, DeferredBlock, TextAttribute, UnknownBlock} from '@angular/compiler/src/render3/r3_ast';
1313
import ts from 'typescript';
1414

15-
import {addAttributeCompletionEntries, AttributeCompletionKind, buildAnimationCompletionEntries, buildAttributeCompletionTable, getAttributeCompletionSymbol} from './attribute_completions';
15+
import {addAttributeCompletionEntries, AsciiSortPriority, AttributeCompletionKind, buildAnimationCompletionEntries, buildAttributeCompletionTable, getAttributeCompletionSymbol} from './attribute_completions';
1616
import {DisplayInfo, DisplayInfoKind, getDirectiveDisplayInfo, getSymbolDisplayInfo, getTsSymbolDisplayInfo, unsafeCastDisplayInfoKindToScriptElementKind} from './display_parts';
1717
import {TargetContext, TargetNodeKind, TemplateTarget} from './template_target';
1818
import {filterAliasImports, isBoundEventWithSyntheticHandler, isWithin} from './utils';
@@ -29,6 +29,8 @@ type LiteralCompletionBuilder = CompletionBuilder<LiteralPrimitive|TextAttribute
2929

3030
type ElementAnimationCompletionBuilder = CompletionBuilder<TmplAstBoundAttribute|TmplAstBoundEvent>;
3131

32+
type BlockCompletionBuilder = CompletionBuilder<UnknownBlock>;
33+
3234
export enum CompletionNodeContext {
3335
None,
3436
ElementTag,
@@ -40,6 +42,16 @@ export enum CompletionNodeContext {
4042

4143
const ANIMATION_PHASES = ['start', 'done'];
4244

45+
function buildBlockSnippet(insertSnippet: boolean, text: string, withParens: boolean): string {
46+
if (!insertSnippet) {
47+
return text;
48+
}
49+
if (withParens) {
50+
return `${text} ($1) {$2}`;
51+
}
52+
return `${text} {$1}`;
53+
}
54+
4355
/**
4456
* Performs autocompletion operations on a given node in the template.
4557
*
@@ -83,11 +95,62 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
8395
return this.getPipeCompletions();
8496
} else if (this.isLiteralCompletion()) {
8597
return this.getLiteralCompletions(options);
98+
} else if (this.isBlockCompletion()) {
99+
return this.getBlockCompletions(options);
86100
} else {
87101
return undefined;
88102
}
89103
}
90104

105+
private isBlockCompletion(): this is BlockCompletionBuilder {
106+
return this.node instanceof UnknownBlock;
107+
}
108+
109+
private getBlockCompletions(
110+
this: BlockCompletionBuilder, options: ts.GetCompletionsAtPositionOptions|undefined):
111+
ts.WithMetadata<ts.CompletionInfo>|undefined {
112+
const blocksWithParens = ['if', 'else if', 'for', 'switch', 'case', 'defer'];
113+
const blocksWithoutParens = ['else', 'empty', 'placeholder', 'error', 'loading', 'default'];
114+
115+
// Determine whether to provide a snippet, which includes parens and curly braces.
116+
// If the block has any expressions or a body, don't provide a snippet as the completion.
117+
// TODO: We can be smarter about this, e.g. include `default` in `switch` if it is missing.
118+
const incompleteBlockHasExpressionsOrBody =
119+
this.node.sourceSpan.toString().substring(1 + this.node.name.length).trim().length > 0;
120+
const useSnippet = (options?.includeCompletionsWithSnippetText ?? false) &&
121+
!incompleteBlockHasExpressionsOrBody;
122+
123+
// Generate the list of completions, one for each block.
124+
// TODO: Exclude connected blocks (e.g. `else` when the preceding block isn't `if` or `else
125+
// if`).
126+
const partialCompletionEntryWholeBlock = {
127+
kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.BLOCK),
128+
replacementSpan: {
129+
start: this.node.sourceSpan.start.offset + 1,
130+
length: this.node.name.length,
131+
}
132+
};
133+
const completionEntries: ts.CompletionEntry[] = [
134+
...blocksWithParens, ...blocksWithoutParens
135+
].map(name => ({
136+
name,
137+
sortText: `${AsciiSortPriority.First}${name}`,
138+
insertText: buildBlockSnippet(useSnippet, name, blocksWithParens.includes(name)),
139+
isSnippet: useSnippet || undefined,
140+
...partialCompletionEntryWholeBlock,
141+
}));
142+
143+
// Return the completions.
144+
const completionInfo: ts.CompletionInfo = {
145+
flags: ts.CompletionInfoFlags.IsContinuation,
146+
isMemberCompletion: false,
147+
isGlobalCompletion: false,
148+
isNewIdentifierLocation: false,
149+
entries: completionEntries,
150+
};
151+
return completionInfo;
152+
}
153+
91154
private isLiteralCompletion(): this is LiteralCompletionBuilder {
92155
return this.node instanceof LiteralPrimitive ||
93156
(this.node instanceof TextAttribute &&
@@ -118,7 +181,8 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
118181
if (this.node instanceof LiteralPrimitive) {
119182
if (typeof this.node.value === 'string' && this.node.value.length > 0) {
120183
replacementSpan = {
121-
// The sourceSpan of `LiteralPrimitive` includes the open quote and the completion entries
184+
// The sourceSpan of `LiteralPrimitive` includes the open quote and the completion
185+
// entries
122186
// don't, so skip the open quote here.
123187
start: this.node.sourceSpan.start + 1,
124188
length: this.node.value.length,
@@ -366,8 +430,8 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
366430
return {
367431
entries,
368432
// Although this completion is "global" in the sense of an Angular expression (there is no
369-
// explicit receiver), it is not "global" in a TypeScript sense since Angular expressions have
370-
// the component as an implicit receiver.
433+
// explicit receiver), it is not "global" in a TypeScript sense since Angular expressions
434+
// have the component as an implicit receiver.
371435
isGlobalCompletion: false,
372436
isMemberCompletion: true,
373437
isNewIdentifierLocation: false,
@@ -392,8 +456,8 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
392456

393457
if (templateContext.has(entryName)) {
394458
const entry = templateContext.get(entryName)!;
395-
// Entries that reference a symbol in the template context refer either to local references or
396-
// variables.
459+
// Entries that reference a symbol in the template context refer either to local references
460+
// or variables.
397461
const symbol = this.templateTypeChecker.getSymbolOfNode(entry.node, this.component) as
398462
TemplateDeclarationSymbol |
399463
null;
@@ -449,8 +513,8 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
449513
private isElementTagCompletion(): this is CompletionBuilder<TmplAstElement|TmplAstText> {
450514
if (this.node instanceof TmplAstText) {
451515
const positionInTextNode = this.position - this.node.sourceSpan.start.offset;
452-
// We only provide element completions in a text node when there is an open tag immediately to
453-
// the left of the position.
516+
// We only provide element completions in a text node when there is an open tag immediately
517+
// to the left of the position.
454518
return this.node.value.substring(0, positionInTextNode).endsWith('<');
455519
} else if (this.node instanceof TmplAstElement) {
456520
return this.nodeContext === CompletionNodeContext.ElementTag;
@@ -479,8 +543,8 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
479543
const replacementSpan: ts.TextSpan = {start, length};
480544

481545
let potentialTags = Array.from(templateTypeChecker.getPotentialElementTags(this.component));
482-
// Don't provide non-Angular tags (directive === null) because we expect other extensions (i.e.
483-
// Emmet) to provide those for HTML files.
546+
// Don't provide non-Angular tags (directive === null) because we expect other extensions
547+
// (i.e. Emmet) to provide those for HTML files.
484548
potentialTags = potentialTags.filter(([_, directive]) => directive !== null);
485549
const entries: ts.CompletionEntry[] = potentialTags.map(([tag, directive]) => ({
486550
kind: tagCompletionKind(directive),
@@ -648,8 +712,9 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
648712
}
649713

650714
if (this.node instanceof TmplAstTextAttribute && this.node.keySpan !== undefined) {
651-
// The `sourceSpan` only includes `ngFor` and the `valueSpan` is always empty even if there
652-
// is something there because we split this up into the desugared AST, `ngFor ngForOf=""`.
715+
// The `sourceSpan` only includes `ngFor` and the `valueSpan` is always empty even if
716+
// there is something there because we split this up into the desugared AST, `ngFor
717+
// ngForOf=""`.
653718
const nodeStart = this.node.keySpan.start.getContext(1, 1);
654719
if (nodeStart?.before[0] === '*') {
655720
const nodeEnd = this.node.keySpan.end.getContext(1, 1);
@@ -768,8 +833,8 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
768833
case AttributeCompletionKind.DomAttribute:
769834
case AttributeCompletionKind.DomProperty:
770835
// TODO(alxhub): ideally we would show the same documentation as quick info here. However,
771-
// since these bindings don't exist in the TCB, there is no straightforward way to retrieve
772-
// a `ts.Symbol` for the field in the TS DOM definition.
836+
// since these bindings don't exist in the TCB, there is no straightforward way to
837+
// retrieve a `ts.Symbol` for the field in the TS DOM definition.
773838
displayParts = [];
774839
break;
775840
case AttributeCompletionKind.DirectiveAttribute:

packages/language-service/src/display_parts.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export const SYMBOL_TEXT = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.tex
2424
*/
2525
export enum DisplayInfoKind {
2626
ATTRIBUTE = 'attribute',
27+
BLOCK = 'block',
2728
COMPONENT = 'component',
2829
DIRECTIVE = 'directive',
2930
EVENT = 'event',

packages/language-service/test/completions_spec.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,25 @@ describe('completions', () => {
281281
});
282282
});
283283

284+
describe('for blocks', () => {
285+
it('at top level', () => {
286+
const {templateFile} = setup(`@`, ``);
287+
templateFile.moveCursorToText('@¦');
288+
const completions = templateFile.getCompletionsAtPosition();
289+
expectContain(
290+
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.BLOCK), ['if']);
291+
});
292+
293+
it('inside if', () => {
294+
const {templateFile} = setup(`@if (1) { @s }`, ``);
295+
templateFile.moveCursorToText('@s¦');
296+
const completions = templateFile.getCompletionsAtPosition();
297+
expectContain(
298+
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.BLOCK),
299+
['switch']);
300+
});
301+
});
302+
284303
describe('in an expression scope', () => {
285304
it('should return completions in a property access expression', () => {
286305
const {templateFile} = setup(`{{name.f}}`, `name!: {first: string; last: string;};`);

0 commit comments

Comments
 (0)