feat(compiler): Exhaustive checks for switch blocks

`@switch` blocks can now enable exhaustive typechecking by adding `@default(never);` at the end of a `@switch` block.
This commit is contained in:
Matthieu Riegler 2026-01-20 03:20:26 +01:00 committed by Matthew Beck (Berry)
parent a8aab64809
commit 95b3f37d4a
19 changed files with 443 additions and 20 deletions

View file

@ -8,11 +8,11 @@
import {TmplAstSwitchBlock, TmplAstSwitchBlockCaseGroup} from '@angular/compiler';
import ts from 'typescript';
import {markIgnoreDiagnostics} from '../comments';
import {TcbOp} from './base';
import type {Scope} from './scope';
import type {Context} from './context';
import {tcbExpression} from './expression';
import {markIgnoreDiagnostics} from '../comments';
import type {Scope} from './scope';
/**
* A `TcbOp` which renders a `switch` block as a TypeScript `switch` statement.
@ -56,6 +56,28 @@ export class TcbSwitchOp extends TcbOp {
});
});
if (this.block.exhaustiveCheck) {
const switchValue = tcbExpression(this.block.expression, this.tcb, this.scope);
clauses.push(
ts.factory.createDefaultClause([
ts.factory.createVariableStatement(
undefined,
ts.factory.createVariableDeclarationList(
[
ts.factory.createVariableDeclaration(
ts.factory.createUniqueName('tcbExhaustive'),
undefined,
ts.factory.createKeywordTypeNode(ts.SyntaxKind.NeverKeyword),
switchValue,
),
],
ts.NodeFlags.Const,
),
),
]),
);
}
this.scope.addStatement(
ts.factory.createSwitchStatement(switchExpression, ts.factory.createCaseBlock(clauses)),
);

View file

@ -1372,6 +1372,65 @@ class TestComponent {
});
});
describe('switch exhaustiveness', () => {
it('should report an error when a case is missing in a switch block', () => {
const messages = diagnose(
`
@switch (value) {
@case ('a') {}
@default never;
}
`,
`
export class TestComponent {
value: 'a' | 'b';
}
`,
);
expect(messages).toEqual([
`TestComponent.html(2, 20): Type '"b"' is not assignable to type 'never'.`,
]);
});
it('should not report an error when all cases are handled', () => {
const messages = diagnose(
`
@switch (value) {
@case ('a') {}
@case ('b') {}
@default never;
}
`,
`
export class TestComponent {
value: 'a' | 'b';
}
`,
);
expect(messages).toEqual([]);
});
it('should not report an error when a default case is provided', () => {
const messages = diagnose(
`
@switch (value) {
@case ('a') {}
@default {}
}
`,
`
export class TestComponent {
value: 'a' | 'b';
}
`,
);
expect(messages).toEqual([]);
});
});
// https://github.com/angular/angular/issues/43970
describe('template parse failures', () => {
afterEach(resetParseTemplateAsSourceFileForTest);

View file

@ -12,7 +12,7 @@ import {absoluteFrom, getSourceFileOrError} from '../../file_system';
import {initMockFileSystem} from '../../file_system/testing';
import {Reference} from '../../imports';
import {OptimizeFor, TypeCheckingConfig} from '../api';
import {ALL_ENABLED_CONFIG, setup, tcb, TestDeclaration, TestDirective} from '../testing';
import {ALL_ENABLED_CONFIG, diagnose, setup, tcb, TestDeclaration, TestDirective} from '../testing';
describe('type check blocks', () => {
beforeEach(() => initMockFileSystem('Native'));
@ -2141,6 +2141,89 @@ describe('type check blocks', () => {
'switch (((this).expr)) { ' + 'case 1: break; ' + 'case 2: break; ' + 'default: break; }',
);
});
it('should generate a switch block with exhaustiveness checking', () => {
const TEMPLATE = `
@switch (expr) {
@case (1) {
{{one()}}
}
@case (2) {
{{two()}}
}
@default never;
}
`;
expect(tcb(TEMPLATE)).toContain(
'switch (((this).expr)) { ' +
'case 1: "" + ((this).one()); break; ' +
'case 2: "" + ((this).two()); break; ' +
'default: const tcbExhaustive_1: never = ((this).expr);',
);
});
it('should not report unused locals for exhaustiveness check variable', () => {
const TEMPLATE = `
@switch (expr) {
@case (1) {}
@default never;
}
`;
const SOURCE = `
export class TestComponent {
expr!: 1|2;
}
`;
expect(diagnose(TEMPLATE, SOURCE, undefined, [], undefined, {noUnusedLocals: true})).toEqual([
`TestComponent.html(2, 18): Type '2' is not assignable to type 'never'.`,
]);
});
it('should not generate exhaustiveness checking when there is a consecutive default case', () => {
const TEMPLATE = `
@switch (expr) {
@case (1) {
{{one()}}
}
@case (2)
@default {
{{default()}}
}
}
`;
expect(tcb(TEMPLATE)).toContain(
'switch (((this).expr)) { ' +
'case 1: "" + ((this).one()); break; ' +
'case 2: ' +
'default: "" + ((this).default()); break; }',
);
});
it('should generate the right TCB if default is not the last case', () => {
const TEMPLATE = `
@switch (expr) {
@case (1) {
{{one()}}
}
@default
@case (3) {
{{default()}}
}
@case (2) {
{{two()}}
}
}
`;
expect(tcb(TEMPLATE)).toContain(
'switch (((this).expr)) { ' +
'case 1: "" + ((this).one()); break; ' +
'default: ' +
'case 3: "" + ((this).default()); break; ' +
'case 2: "" + ((this).two()); break; }',
);
});
});
describe('for loop blocks', () => {

View file

@ -101,6 +101,8 @@ export class CombinedRecursiveAstVisitor extends RecursiveAstVisitor implements
this.visitAllTemplateNodes(block.children);
}
visitSwitchExhaustiveCheck(block: t.SwitchExhaustiveCheck): void {}
visitForLoopBlock(block: t.ForLoopBlock): void {
block.item.visit(this);
this.visitAllTemplateNodes(block.contextVariables);

View file

@ -169,6 +169,7 @@ export {
SwitchBlock as TmplAstSwitchBlock,
SwitchBlockCase as TmplAstSwitchBlockCase,
SwitchBlockCaseGroup as TmplAstSwitchBlockCaseGroup,
SwitchExhaustiveCheck as TmplAstSwitchExhaustiveCheck,
Template as TmplAstTemplate,
Text as TmplAstText,
TextAttribute as TmplAstTextAttribute,

View file

@ -299,6 +299,14 @@ class _Tokenizer {
this._beginToken(TokenType.BLOCK_OPEN_START, start);
const startToken = this._endToken([this._getBlockName()]);
if (startToken.parts[0] === 'default never' && this._attemptCharCode(chars.$SEMICOLON)) {
this._beginToken(TokenType.BLOCK_OPEN_END);
this._endToken([]);
this._beginToken(TokenType.BLOCK_CLOSE);
this._endToken([]);
return;
}
if (this._cursor.peek() === chars.$LPAREN) {
// Advance past the opening paren.
this._cursor.advance();

View file

@ -410,6 +410,7 @@ export class SwitchBlock extends BlockNode implements Node {
* aren't meant to be processed in any other way.
*/
public unknownBlocks: UnknownBlock[],
public exhaustiveCheck: SwitchExhaustiveCheck | null,
sourceSpan: ParseSourceSpan,
startSourceSpan: ParseSourceSpan,
endSourceSpan: ParseSourceSpan | null,
@ -457,6 +458,21 @@ export class SwitchBlockCaseGroup extends BlockNode implements Node {
}
}
export class SwitchExhaustiveCheck extends BlockNode implements Node {
constructor(
sourceSpan: ParseSourceSpan,
startSourceSpan: ParseSourceSpan,
endSourceSpan: ParseSourceSpan | null,
nameSpan: ParseSourceSpan,
) {
super(nameSpan, sourceSpan, startSourceSpan, endSourceSpan);
}
visit<Result>(visitor: Visitor<Result>): Result {
return visitor.visitSwitchExhaustiveCheck(this);
}
}
export class ForLoopBlock extends BlockNode implements Node {
constructor(
public item: Variable,
@ -725,6 +741,7 @@ export interface Visitor<Result = any> {
visitSwitchBlock(block: SwitchBlock): Result;
visitSwitchBlockCase(block: SwitchBlockCase): Result;
visitSwitchBlockCaseGroup(block: SwitchBlockCaseGroup): Result;
visitSwitchExhaustiveCheck(block: SwitchExhaustiveCheck): Result;
visitForLoopBlock(block: ForLoopBlock): Result;
visitForLoopBlockEmpty(block: ForLoopBlockEmpty): Result;
visitIfBlock(block: IfBlock): Result;
@ -773,6 +790,7 @@ export class RecursiveVisitor implements Visitor<void> {
visitAll(this, block.cases);
visitAll(this, block.children);
}
visitSwitchExhaustiveCheck(block: SwitchExhaustiveCheck): void {}
visitForLoopBlock(block: ForLoopBlock): void {
const blockItems = [block.item, ...block.contextVariables, ...block.children];
block.empty && blockItems.push(block.empty);

View file

@ -232,6 +232,7 @@ export function createSwitchBlock(
const unknownBlocks: t.UnknownBlock[] = [];
let collectedCases: t.SwitchBlockCase[] = [];
let firstCaseStart: ParseSourceSpan | null = null;
let exhaustiveCheck: t.SwitchExhaustiveCheck | null = null;
// Here we assume that all the blocks are valid given that we validated them above.
for (const node of ast.children) {
@ -239,16 +240,59 @@ export function createSwitchBlock(
continue;
}
if ((node.name !== 'case' || node.parameters.length === 0) && node.name !== 'default') {
if (
(node.name !== 'case' || node.parameters.length === 0) &&
node.name !== 'default' &&
node.name !== 'default never'
) {
unknownBlocks.push(new t.UnknownBlock(node.name, node.sourceSpan, node.nameSpan));
continue;
}
if (exhaustiveCheck !== null) {
errors.push(
new ParseError(
node.sourceSpan,
'@default block with "never" parameter must be the last case in a switch',
),
);
}
const isCase = node.name === 'case';
let expression: AST | null = null;
if (isCase) {
expression = parseBlockParameterToBinding(node.parameters[0], bindingParser);
} else if (node.name === 'default never') {
if (
node.children.length > 0 ||
(node.endSourceSpan !== null &&
node.endSourceSpan.start.offset !== node.endSourceSpan.end.offset)
) {
errors.push(
new ParseError(
node.sourceSpan,
'@default block with "never" parameter cannot have a body',
),
);
}
if (collectedCases.length > 0) {
errors.push(
new ParseError(
node.sourceSpan,
'A @case block with no body cannot be followed by a @default block with "never" parameter',
),
);
}
exhaustiveCheck = new t.SwitchExhaustiveCheck(
node.sourceSpan,
node.startSourceSpan,
node.endSourceSpan,
node.nameSpan,
);
continue;
}
const switchCase = new t.SwitchBlockCase(
@ -300,6 +344,7 @@ export function createSwitchBlock(
primaryExpression,
groups,
unknownBlocks,
exhaustiveCheck,
ast.sourceSpan,
ast.startSourceSpan,
ast.endSourceSpan,
@ -570,14 +615,24 @@ function validateSwitchBlock(ast: html.Block): ParseError[] {
continue;
}
if (!(node instanceof html.Block) || (node.name !== 'case' && node.name !== 'default')) {
if (
!(node instanceof html.Block) ||
(node.name !== 'case' && node.name !== 'default' && node.name !== 'default never')
) {
errors.push(
new ParseError(node.sourceSpan, '@switch block can only contain @case and @default blocks'),
);
continue;
}
if (node.name === 'default') {
if (node.name === 'default never') {
if (hasDefault) {
errors.push(
new ParseError(node.startSourceSpan, '@switch block can only have one @default block'),
);
}
hasDefault = true;
} else if (node.name === 'default') {
if (hasDefault) {
errors.push(
new ParseError(node.startSourceSpan, '@switch block can only have one @default block'),

View file

@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {CssSelector, SelectorlessMatcher, SelectorMatcher} from '../../directive_matching';
import {
AST,
BindingPipe,
@ -13,7 +14,6 @@ import {
PropertyRead,
SafePropertyRead,
} from '../../expression_parser/ast';
import {CssSelector, SelectorlessMatcher, SelectorMatcher} from '../../directive_matching';
import {
BoundAttribute,
BoundEvent,
@ -42,6 +42,7 @@ import {
SwitchBlock,
SwitchBlockCase,
SwitchBlockCaseGroup,
SwitchExhaustiveCheck,
Template,
Text,
TextAttribute,
@ -51,6 +52,7 @@ import {
Visitor,
} from '../r3_ast';
import {CombinedRecursiveAstVisitor} from '../../combined_visitor';
import {
BoundTarget,
DirectiveMeta,
@ -63,7 +65,6 @@ import {
} from './t2_api';
import {parseTemplate} from './template';
import {createCssSelectorFromNode} from './util';
import {CombinedRecursiveAstVisitor} from '../../combined_visitor';
/**
* Computes a difference between full list (first argument) and
@ -397,6 +398,8 @@ class Scope implements Visitor {
this.ingestScopedNode(block);
}
visitSwitchExhaustiveCheck(block: SwitchExhaustiveCheck) {}
visitForLoopBlock(block: ForLoopBlock) {
this.ingestScopedNode(block);
block.empty?.visit(this);
@ -589,6 +592,8 @@ class DirectiveBinder<DirectiveT extends DirectiveMeta> implements Visitor {
block.children.forEach((node) => node.visit(this));
}
visitSwitchExhaustiveCheck(block: SwitchExhaustiveCheck) {}
visitForLoopBlock(block: ForLoopBlock) {
block.item.visit(this);
block.contextVariables.forEach((v) => v.visit(this));
@ -947,6 +952,10 @@ class TemplateBinder extends CombinedRecursiveAstVisitor {
this.ingestScopedNode(block);
}
override visitSwitchExhaustiveCheck(block: SwitchExhaustiveCheck) {
// There are no bindings/references in the exhaustive check block.
}
override visitForLoopBlock(block: ForLoopBlock) {
block.expression.visit(this);
this.ingestScopedNode(block);

View file

@ -1088,6 +1088,21 @@ describe('HtmlParser', () => {
]);
});
it('should parse exhaustive default checks in a switch block', () => {
expect(
humanizeDom(
parser.parse(`@switch (expr) {@case ('foo') {} @default never;}`, `TestComp`),
),
).toEqual([
[html.Block, 'switch', 0],
[html.BlockParameter, 'expr'],
[html.Block, 'case', 1],
[html.BlockParameter, `'foo'`],
[html.Text, ' ', 1, [' ']],
[html.Block, 'default never', 1],
]);
});
it('should close void elements used right before a block', () => {
expect(humanizeDom(parser.parse('<img>@defer {hello}', 'TestComp'))).toEqual([
[html.Element, 'img', 0],

View file

@ -3366,6 +3366,24 @@ describe('HtmlLexer', () => {
expect(tokenizeAndHumanizeParts('@if(){hello}')).toEqual(expected);
});
it('should parse @default never;', () => {
expect(tokenizeAndHumanizeParts('@default never;')).toEqual([
[TokenType.BLOCK_OPEN_START, 'default never'],
[TokenType.BLOCK_OPEN_END],
[TokenType.BLOCK_CLOSE],
[TokenType.EOF],
]);
});
it('should parse @default never ;', () => {
expect(tokenizeAndHumanizeParts('@default never ;')).toEqual([
[TokenType.BLOCK_OPEN_START, 'default never'],
[TokenType.BLOCK_OPEN_END],
[TokenType.BLOCK_CLOSE],
[TokenType.EOF],
]);
});
it('should parse a block with parameters', () => {
expect(tokenizeAndHumanizeParts('@for (item of items; track item.id) {hello}')).toEqual([
[TokenType.BLOCK_OPEN_START, 'for'],

View file

@ -137,6 +137,7 @@ class R3AstSourceSpans implements t.Visitor<void> {
humanizeSpan(block.endSourceSpan),
]);
this.visitAll([block.groups]);
block.exhaustiveCheck?.visit(this);
}
visitSwitchBlockCase(block: t.SwitchBlockCase): void {
@ -156,6 +157,14 @@ class R3AstSourceSpans implements t.Visitor<void> {
this.visitAll([block.cases, block.children]);
}
visitSwitchExhaustiveCheck(block: t.SwitchExhaustiveCheck): void {
this.result.push([
'SwitchExhaustiveCheck',
humanizeSpan(block.sourceSpan),
humanizeSpan(block.startSourceSpan),
]);
}
visitForLoopBlock(block: t.ForLoopBlock): void {
this.result.push([
'ForLoopBlock',
@ -878,6 +887,23 @@ describe('R3 AST source spans', () => {
['Text', 'No case matched'],
]);
});
it('is correct for switch blocks with exhaustive checking', () => {
const html = `@switch (cond.kind) {` + `@case (x()) {X case}` + `@default never;` + `}`;
expectFromHtml(html).toEqual([
[
'SwitchBlock',
'@switch (cond.kind) {@case (x()) {X case}@default never;}',
'@switch (cond.kind) {',
'}',
],
['SwitchBlockCaseGroup', '@case (x()) {X case}', '@case (x()) {'],
['SwitchBlockCase', '@case (x()) {X case}', '@case (x()) {'],
['Text', 'X case'],
['SwitchExhaustiveCheck', '@default never;', '@default never;'],
]);
});
});
describe('for loop blocks', () => {

View file

@ -99,6 +99,7 @@ class R3AstHumanizer implements t.Visitor<void> {
visitSwitchBlock(block: t.SwitchBlock): void {
this.result.push(['SwitchBlock', unparse(block.expression)]);
this.visitAll([block.groups]);
block.exhaustiveCheck?.visit(this);
}
visitSwitchBlockCase(block: t.SwitchBlockCase): void {
@ -113,6 +114,10 @@ class R3AstHumanizer implements t.Visitor<void> {
this.visitAll([block.cases, block.children]);
}
visitSwitchExhaustiveCheck(block: t.SwitchExhaustiveCheck): void {
this.result.push(['SwitchExhaustiveCheck']);
}
visitForLoopBlock(block: t.ForLoopBlock): void {
const result: any[] = ['ForLoopBlock', unparse(block.expression), unparse(block.trackBy)];
this.result.push(result);
@ -1685,6 +1690,14 @@ describe('R3 template transform', () => {
]);
});
it('should parse a switch block with a default never case', () => {
expectFromHtml(`
@switch (cond.kind) {
@default never;
}
`).toEqual([['SwitchBlock', 'cond.kind'], ['SwitchExhaustiveCheck']]);
});
// This is a special case for `switch` blocks, because `preserveWhitespaces` will cause
// some text nodes with whitespace to be preserve in the primary block.
it('should parse a switch block when preserveWhitespaces is enabled', () => {
@ -1974,6 +1987,50 @@ describe('R3 template transform', () => {
`),
).toThrowError(/@default block cannot have parameters/);
});
it('should report if in a @switch block a @default never block has a body', () => {
expect(() =>
parse(`
@switch (cond) {
@default never {nope}
}
`),
).toThrowError(/@default block with "never" parameter cannot have a body/);
});
it('should report if a switch fallthrough case is followed by a @default never block', () => {
expect(() =>
parse(`
@switch (cond) {
@case (foo)
@default never;
}
`),
).toThrowError(
/A @case block with no body cannot be followed by a @default block with "never" parameter/,
);
});
it('should throw if @default never is not the last case in a switch block', () => {
expect(() =>
parse(`
@switch (cond) {
@default never;
@case (foo) {foo}
}
`),
).toThrowError(/@default block with "never" parameter must be the last case in a switch/);
});
it('should throw if a semicolon is missing after @default never', () => {
expect(() =>
parse(`
@switch (cond) {
@default never
}
`),
).toThrowError(/Incomplete block "default never"/);
});
});
});

View file

@ -219,6 +219,8 @@ class ExpressionSourceHumanizer extends e.RecursiveAstVisitor implements t.Visit
t.visitAll(this, block.children);
}
visitSwitchExhaustiveCheck(block: t.SwitchExhaustiveCheck) {}
visitForLoopBlock(block: t.ForLoopBlock) {
block.item.visit(this);
t.visitAll(this, block.contextVariables);

View file

@ -10,32 +10,33 @@ import type {
TmplAstBoundAttribute,
TmplAstBoundEvent,
TmplAstBoundText,
TmplAstComponent,
TmplAstContent,
TmplAstDeferredBlock,
TmplAstDeferredBlockError,
TmplAstDeferredBlockLoading,
TmplAstDeferredBlockPlaceholder,
TmplAstDeferredTrigger,
TmplAstDirective,
TmplAstElement,
TmplAstIfBlockBranch,
TmplAstForLoopBlock,
TmplAstForLoopBlockEmpty,
TmplAstIcu,
TmplAstIfBlock,
TmplAstIfBlockBranch,
TmplAstLetDeclaration,
TmplAstNode,
TmplAstRecursiveVisitor,
TmplAstReference,
TmplAstSwitchBlock,
TmplAstSwitchBlockCase,
TmplAstSwitchBlockCaseGroup,
TmplAstSwitchExhaustiveCheck,
TmplAstTemplate,
TmplAstText,
TmplAstTextAttribute,
TmplAstVariable,
TmplAstUnknownBlock,
TmplAstLetDeclaration,
TmplAstComponent,
TmplAstDirective,
TmplAstVariable,
} from '@angular/compiler';
/**
@ -87,6 +88,7 @@ export class TemplateAstVisitor implements TmplAstRecursiveVisitor {
visitLetDeclaration(decl: TmplAstLetDeclaration): void {}
visitComponent(component: TmplAstComponent): void {}
visitDirective(directive: TmplAstDirective): void {}
visitSwitchExhaustiveCheck(block: TmplAstSwitchExhaustiveCheck): void {}
/**
* Visits all the provided nodes in order using this Visitor's visit methods.

View file

@ -372,4 +372,32 @@ describe('control flow - switch', () => {
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toBe(' default ');
});
it('should support exhaustive switch checking', () => {
@Component({
template: `
Between here
@switch (case) {
@case (0) {
case 0
}
@case (1) {
case 1
}
@default never;
}
and there.
`,
})
class TestComponent {
case: 0 | 1 = 2 as 1; // Intentionally incorrect to test exhaustive checking at runtime
}
const fixture = TestBed.createComponent(TestComponent);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toBe(' Between here and there. ');
fixture.componentInstance.case = 0;
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toBe(' Between here case 0 and there. ');
});
});

View file

@ -7,36 +7,37 @@
*/
import {
TmplAstElement,
TmplAstNode,
TmplAstTemplate,
TmplAstVisitor,
ParseSourceSpan,
TmplAstBoundAttribute,
TmplAstBoundEvent,
TmplAstBoundText,
TmplAstComponent,
TmplAstContent,
TmplAstDeferredBlock,
TmplAstDeferredBlockError,
TmplAstDeferredBlockLoading,
TmplAstDeferredBlockPlaceholder,
TmplAstDeferredTrigger,
TmplAstDirective,
TmplAstElement,
TmplAstForLoopBlock,
TmplAstForLoopBlockEmpty,
TmplAstIcu,
TmplAstIfBlock,
TmplAstIfBlockBranch,
TmplAstLetDeclaration,
TmplAstNode,
TmplAstReference,
TmplAstSwitchBlock,
TmplAstSwitchBlockCase,
TmplAstSwitchBlockCaseGroup,
TmplAstSwitchExhaustiveCheck,
TmplAstTemplate,
TmplAstText,
TmplAstTextAttribute,
TmplAstUnknownBlock,
TmplAstVariable,
TmplAstComponent,
TmplAstDirective,
ParseSourceSpan,
TmplAstVisitor,
} from '@angular/compiler';
import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core';
import {PotentialDirective} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
@ -180,6 +181,8 @@ class ClassificationVisitor implements TmplAstVisitor {
this.visitAll(block.children);
}
visitSwitchExhaustiveCheck(block: TmplAstSwitchExhaustiveCheck) {}
visitForLoopBlock(block: TmplAstForLoopBlock) {
this.visitAll(block.children);
this.visit(block.empty);

View file

@ -43,6 +43,7 @@ import {
TmplAstSwitchBlock,
TmplAstSwitchBlockCase,
TmplAstSwitchBlockCaseGroup,
TmplAstSwitchExhaustiveCheck,
TmplAstTemplate,
TmplAstText,
TmplAstTextAttribute,
@ -662,6 +663,9 @@ class TemplateTargetVisitor implements TmplAstVisitor {
this.visitBinding(block.expression);
this.visitAll(block.groups);
this.visitAll(block.unknownBlocks);
if (block.exhaustiveCheck) {
this.visit(block.exhaustiveCheck);
}
}
visitSwitchBlockCase(block: TmplAstSwitchBlockCase) {
@ -673,6 +677,8 @@ class TemplateTargetVisitor implements TmplAstVisitor {
this.visitAll(block.children);
}
visitSwitchExhaustiveCheck(block: TmplAstSwitchExhaustiveCheck) {}
visitForLoopBlock(block: TmplAstForLoopBlock) {
this.visit(block.item);
this.visitAll(block.contextVariables);

View file

@ -39,6 +39,7 @@ import {
SafePropertyRead,
TmplAstSwitchBlock as SwitchBlock,
TmplAstSwitchBlockCase as SwitchBlockCase,
TmplAstSwitchExhaustiveCheck as SwitchExhaustiveCheck,
TmplAstTemplate as Template,
TmplAstTextAttribute as TextAttribute,
TmplAstTimerDeferredTrigger as TimerDeferredTrigger,
@ -1060,6 +1061,14 @@ describe('blocks', () => {
expect(node).toBeInstanceOf(SwitchBlockCase);
});
it('should visit exhautive default block on switch', () => {
const {nodes, position} = parse(`@switch (foo) { @d¦efault never; }`);
const {context} = getTargetAtPosition(nodes, position)!;
const {node} = context as SingleNodeTarget;
expect(isTemplateNode(node!)).toBe(true);
expect(node).toBeInstanceOf(SwitchExhaustiveCheck);
});
it('should visit if block main branch', () => {
const {nodes, position} = parse(`@i¦f (title) { }`);
const {context} = getTargetAtPosition(nodes, position)!;