Visitar URL original
feat(language-service): add support for @boundary blocks (#70463) · angular/angular@f4a5650 · GitHub
Skip to content

Commit f4a5650

Browse files
alxhubcrisbeto
authored andcommitted
feat(language-service): add support for @boundary blocks (#70463)
Add support for the new `@boundary` and `@error` control flow blocks in the Angular Language Service. This includes: - Updating outlining spans to handle boundary blocks correctly. - Adding classification visitor methods for semantic tokens. - Adding template target visitor methods for navigation and hover support. - Updating the TextMate grammar to recognize `@boundary` and the `when` clause. PR Close #70463
1 parent f7597cb commit f4a5650

8 files changed

Lines changed: 155 additions & 5 deletions

File tree

‎packages/language-service/src/completions.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,15 @@ export class CompletionBuilder<N extends TmplAstNode | AST> {
233233
options: ts.GetCompletionsAtPositionOptions | undefined,
234234
): ts.WithMetadata<ts.CompletionInfo> | undefined {
235235
const blocksWithParens = ['if', 'else if', 'for', 'switch', 'case', 'defer'];
236-
const blocksWithoutParens = ['else', 'empty', 'placeholder', 'error', 'loading', 'default'];
236+
const blocksWithoutParens = [
237+
'else',
238+
'empty',
239+
'placeholder',
240+
'error',
241+
'loading',
242+
'default',
243+
'boundary',
244+
];
237245

238246
// Determine whether to provide a snippet, which includes parens and curly braces.
239247
// If the block has any expressions or a body, don't provide a snippet as the completion.

‎packages/language-service/src/document_symbols.ts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
ASTWithSource,
1212
TmplAstBoundAttribute,
1313
TmplAstBoundDeferredTrigger,
14+
TmplAstBoundaryBlock,
15+
TmplAstBoundaryErrorBlock,
1416
TmplAstComponent,
1517
TmplAstContent,
1618
TmplAstDeferredBlock,
@@ -524,6 +526,57 @@ class TemplateSymbolVisitor extends TmplAstRecursiveVisitor {
524526
tmplAstVisitAll(this, block.children);
525527
this.popChildren();
526528
}
529+
override visitBoundaryBlock(block: TmplAstBoundaryBlock): void {
530+
const symbol: TemplateDocumentSymbol = {
531+
text: '@boundary',
532+
kind: ts.ScriptElementKind.functionElement,
533+
lspKind: AngularSymbolKind.Event, // @boundary → Event ⚡
534+
spans: [toTextSpan(block.mainBlockSpan)],
535+
nameSpan: toTextSpan(block.startSourceSpan),
536+
};
537+
this.addSymbol(symbol);
538+
this.pushChildren(symbol);
539+
tmplAstVisitAll(this, block.children);
540+
tmplAstVisitAll(this, block.errorBlocks);
541+
this.popChildren();
542+
}
543+
544+
override visitBoundaryErrorBlock(block: TmplAstBoundaryErrorBlock): void {
545+
const triggers: string[] = [];
546+
if (block.expression) {
547+
const exprText = getExpressionText(block.expression);
548+
triggers.push(`when ${exprText}`);
549+
}
550+
const name = triggers.length > 0 ? `@error (${triggers.join('; ')})` : '@error';
551+
552+
const symbol: TemplateDocumentSymbol = {
553+
text: name,
554+
kind: ts.ScriptElementKind.functionElement,
555+
lspKind: AngularSymbolKind.Event, // @error → Event ⚡
556+
spans: [toTextSpan(block.sourceSpan)],
557+
nameSpan: toTextSpan(block.startSourceSpan),
558+
};
559+
this.addSymbol(symbol);
560+
this.pushChildren(symbol);
561+
562+
for (const contextVar of block.contextVariables) {
563+
this.processedVariables.add(contextVar);
564+
// Show if explicitly aliased (name differs from value) OR if showImplicitForVariables is enabled
565+
const isExplicitlyAliased = contextVar.name !== contextVar.value;
566+
if (isExplicitlyAliased || this.options.showImplicitForVariables) {
567+
const contextSymbol: TemplateDocumentSymbol = {
568+
text: `let ${contextVar.name}`,
569+
kind: ts.ScriptElementKind.localVariableElement,
570+
spans: [toTextSpan(contextVar.keySpan)],
571+
nameSpan: toTextSpan(contextVar.keySpan),
572+
};
573+
this.addSymbol(contextSymbol);
574+
}
575+
}
576+
577+
tmplAstVisitAll(this, block.children);
578+
this.popChildren();
579+
}
527580

528581
// Elements and components
529582
override visitElement(element: TmplAstElement): void {

‎packages/language-service/src/outlining_spans.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
TmplAstNode,
1717
TmplAstRecursiveVisitor,
1818
TmplAstSwitchBlockCase,
19+
TmplAstBoundaryBlock,
1920
tmplAstVisitAll,
2021
} from '@angular/compiler';
2122
import {isExternalResource, isNamedClassDeclaration, NgCompiler} from '@angular/compiler-cli';
@@ -68,7 +69,11 @@ class BlockVisitor extends TmplAstRecursiveVisitor {
6869
// The source span of for loops and deferred blocks contain all parts (ForLoopBlockEmpty,
6970
// DeferredBlockLoading, etc.). The folding range should only include the main block span for
7071
// these.
71-
if (block instanceof TmplAstForLoopBlock || block instanceof TmplAstDeferredBlock) {
72+
if (
73+
block instanceof TmplAstForLoopBlock ||
74+
block instanceof TmplAstDeferredBlock ||
75+
block instanceof TmplAstBoundaryBlock
76+
) {
7277
mainBlockSpan = block.mainBlockSpan;
7378
}
7479
return {

‎packages/language-service/src/semantic_tokens.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ import {
3939
TmplAstUnknownBlock,
4040
TmplAstVariable,
4141
TmplAstVisitor,
42+
TmplAstBoundaryBlock,
43+
TmplAstBoundaryErrorBlock,
4244
} from '@angular/compiler';
4345
import {NgCompiler, PotentialDirective} from '@angular/compiler-cli';
4446

@@ -205,6 +207,15 @@ class ClassificationVisitor implements TmplAstVisitor {
205207
this.visitAll(block.children);
206208
}
207209

210+
visitBoundaryBlock(block: TmplAstBoundaryBlock) {
211+
this.visitAll(block.children);
212+
this.visitAll(block.errorBlocks);
213+
}
214+
215+
visitBoundaryErrorBlock(block: TmplAstBoundaryErrorBlock) {
216+
this.visitAll(block.children);
217+
}
218+
208219
visitTemplate(template: TmplAstTemplate) {
209220
this.visitAll(template.children);
210221
}

‎packages/language-service/src/template_target.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import {
1818
RecursiveAstVisitor,
1919
SafeCall,
2020
ThisReceiver,
21+
TmplAstBoundaryBlock,
22+
TmplAstBoundaryErrorBlock,
2123
TmplAstBoundAttribute,
2224
TmplAstBoundDeferredTrigger,
2325
TmplAstBoundEvent,
@@ -54,7 +56,6 @@ import {
5456
tmplAstVisitAll,
5557
TmplAstVisitor,
5658
} from '@angular/compiler';
57-
import {NgCompiler} from '@angular/compiler-cli';
5859
import {findFirstMatchingNode} from '@angular/compiler-cli/private/hybrid_analysis';
5960
import tss from 'typescript';
6061

@@ -63,7 +64,6 @@ import {
6364
isTemplateNodeWithKeyAndValue,
6465
isWithin,
6566
isWithinKeyValue,
66-
TypeCheckInfo,
6767
} from './utils';
6868

6969
/**
@@ -703,6 +703,17 @@ class TemplateTargetVisitor implements TmplAstVisitor {
703703
this.visitAll(block.children);
704704
}
705705

706+
visitBoundaryBlock(block: TmplAstBoundaryBlock) {
707+
this.visitAll(block.children);
708+
this.visitAll(block.errorBlocks);
709+
}
710+
711+
visitBoundaryErrorBlock(block: TmplAstBoundaryErrorBlock) {
712+
this.visitAll(block.contextVariables);
713+
block.expression && this.visitBinding(block.expression);
714+
this.visitAll(block.children);
715+
}
716+
706717
visitUnknownBlock(block: TmplAstUnknownBlock) {}
707718

708719
visitLetDeclaration(decl: TmplAstLetDeclaration) {

‎packages/language-service/test/get_outlining_spans_spec.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,35 @@ describe('get outlining spans', () => {
203203
expect(getTrimmedSpanText(result[0].textSpan, files['app.ts'])).toEqual('{{item}}');
204204
expect(getTrimmedSpanText(result[1].textSpan, files['app.ts'])).toEqual('empty list');
205205
});
206+
207+
it('should have outlining spans for boundary and error blocks', () => {
208+
const files = {
209+
'app.ts': `
210+
import {Component} from '@angular/core';
211+
212+
@Component({
213+
template: \`
214+
@boundary {
215+
boundary body
216+
} @error (let err) {
217+
error body
218+
}
219+
\`,
220+
standalone: false,
221+
})
222+
export class AppCmp {
223+
}`,
224+
};
225+
const env = LanguageServiceTestEnv.setup();
226+
const project = createModuleAndProjectWithDeclarations(env, 'test', files);
227+
project.expectNoSourceDiagnostics();
228+
229+
const appFile = project.openFile('app.ts');
230+
const result = appFile.getOutliningSpans();
231+
expect(result.length).toEqual(2);
232+
expect(getTrimmedSpanText(result[0].textSpan, files['app.ts'])).toEqual('boundary body');
233+
expect(getTrimmedSpanText(result[1].textSpan, files['app.ts'])).toEqual('error body');
234+
});
206235
});
207236

208237
function getTrimmedSpanText(span: ts.TextSpan, contents: string) {

‎packages/language-service/test/quick_info_spec.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -848,6 +848,22 @@ describe('quick info', () => {
848848
});
849849
});
850850

851+
it('boundary error block context variables', () => {
852+
expectQuickInfo({
853+
templateOverride: `@boundary { } @error (let er¦r) { }`,
854+
expectedSpanText: 'err',
855+
expectedDisplayString: '(variable) err: Error',
856+
});
857+
});
858+
859+
it('boundary error block retry context variable', () => {
860+
expectQuickInfo({
861+
templateOverride: `@boundary { } @error (let err, rt¦ry = $reset) { }`,
862+
expectedSpanText: 'rtry',
863+
expectedDisplayString: '(variable) rtry: () => void',
864+
});
865+
});
866+
851867
it('if block alias variable narrowed', () => {
852868
expectQuickInfo({
853869
templateOverride: `@if (signalValue; as al¦iasName) {}`,

‎vscode-ng-language-service/syntaxes/template-blocks.json‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"name": "keyword.control.block.transition.ng"
1313
},
1414
"block": {
15-
"begin": "(@)(if|else if|else|defer|placeholder|loading|error|switch|for|empty)(?:\\s*)",
15+
"begin": "(@)(if|else if|else|defer|placeholder|loading|error|boundary|switch|for|empty)(?:\\s*)",
1616
"beginCaptures": {
1717
"1": {
1818
"patterns": [
@@ -108,6 +108,9 @@
108108
{
109109
"include": "#blockExpressionTrackClause"
110110
},
111+
{
112+
"include": "#blockExpressionWhenClause"
113+
},
111114
{
112115
"include": "expression.ng"
113116
}
@@ -119,6 +122,20 @@
119122
}
120123
}
121124
},
125+
"blockExpressionWhenClause": {
126+
"begin": "\\bwhen\\b",
127+
"beginCaptures": {
128+
"0": {
129+
"name": "keyword.control.when.ng"
130+
}
131+
},
132+
"end": "(?=[$)])|(?<=;)",
133+
"patterns": [
134+
{
135+
"include": "expression.ng"
136+
}
137+
]
138+
},
122139
"blockExpressionOfClause": {
123140
"begin": "([_$[:alpha:]][_$[:alnum:]]*)\\s+(of)\\b",
124141
"beginCaptures": {

0 commit comments

Comments
 (0)