diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/ops/switch_block.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/ops/switch_block.ts index a0b4583304b..83d2d365017 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/ops/switch_block.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/ops/switch_block.ts @@ -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)), ); diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/diagnostics_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/diagnostics_spec.ts index 37efc8fffa6..84041077ff4 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/diagnostics_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/diagnostics_spec.ts @@ -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); diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts index c4cacb5544c..db617bd969d 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts @@ -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', () => { diff --git a/packages/compiler/src/combined_visitor.ts b/packages/compiler/src/combined_visitor.ts index a198880a04b..e18876634e4 100644 --- a/packages/compiler/src/combined_visitor.ts +++ b/packages/compiler/src/combined_visitor.ts @@ -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); diff --git a/packages/compiler/src/compiler.ts b/packages/compiler/src/compiler.ts index 3ed0ce28680..1d478d08338 100644 --- a/packages/compiler/src/compiler.ts +++ b/packages/compiler/src/compiler.ts @@ -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, diff --git a/packages/compiler/src/ml_parser/lexer.ts b/packages/compiler/src/ml_parser/lexer.ts index a68cd57383b..3c8b04ab61b 100644 --- a/packages/compiler/src/ml_parser/lexer.ts +++ b/packages/compiler/src/ml_parser/lexer.ts @@ -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(); diff --git a/packages/compiler/src/render3/r3_ast.ts b/packages/compiler/src/render3/r3_ast.ts index e62a21dcb0e..d570bb13950 100644 --- a/packages/compiler/src/render3/r3_ast.ts +++ b/packages/compiler/src/render3/r3_ast.ts @@ -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(visitor: Visitor): Result { + return visitor.visitSwitchExhaustiveCheck(this); + } +} + export class ForLoopBlock extends BlockNode implements Node { constructor( public item: Variable, @@ -725,6 +741,7 @@ export interface Visitor { 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 { 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); diff --git a/packages/compiler/src/render3/r3_control_flow.ts b/packages/compiler/src/render3/r3_control_flow.ts index 1a2c7dc7cb1..78fe0434da8 100644 --- a/packages/compiler/src/render3/r3_control_flow.ts +++ b/packages/compiler/src/render3/r3_control_flow.ts @@ -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'), diff --git a/packages/compiler/src/render3/view/t2_binder.ts b/packages/compiler/src/render3/view/t2_binder.ts index 901d890a591..549ef8a1488 100644 --- a/packages/compiler/src/render3/view/t2_binder.ts +++ b/packages/compiler/src/render3/view/t2_binder.ts @@ -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 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); diff --git a/packages/compiler/test/ml_parser/html_parser_spec.ts b/packages/compiler/test/ml_parser/html_parser_spec.ts index f90012e5dfe..47b956aea40 100644 --- a/packages/compiler/test/ml_parser/html_parser_spec.ts +++ b/packages/compiler/test/ml_parser/html_parser_spec.ts @@ -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('@defer {hello}', 'TestComp'))).toEqual([ [html.Element, 'img', 0], diff --git a/packages/compiler/test/ml_parser/lexer_spec.ts b/packages/compiler/test/ml_parser/lexer_spec.ts index 91ebdd3c1b1..9d7bbd1c5c7 100644 --- a/packages/compiler/test/ml_parser/lexer_spec.ts +++ b/packages/compiler/test/ml_parser/lexer_spec.ts @@ -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'], diff --git a/packages/compiler/test/render3/r3_ast_spans_spec.ts b/packages/compiler/test/render3/r3_ast_spans_spec.ts index 95de11b248e..7b9d1b10eaf 100644 --- a/packages/compiler/test/render3/r3_ast_spans_spec.ts +++ b/packages/compiler/test/render3/r3_ast_spans_spec.ts @@ -137,6 +137,7 @@ class R3AstSourceSpans implements t.Visitor { 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 { 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', () => { diff --git a/packages/compiler/test/render3/r3_template_transform_spec.ts b/packages/compiler/test/render3/r3_template_transform_spec.ts index eb9455b3810..6198983fce3 100644 --- a/packages/compiler/test/render3/r3_template_transform_spec.ts +++ b/packages/compiler/test/render3/r3_template_transform_spec.ts @@ -99,6 +99,7 @@ class R3AstHumanizer implements t.Visitor { 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 { 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"/); + }); }); }); diff --git a/packages/compiler/test/render3/util/expression.ts b/packages/compiler/test/render3/util/expression.ts index e9eb337574f..522f151c111 100644 --- a/packages/compiler/test/render3/util/expression.ts +++ b/packages/compiler/test/render3/util/expression.ts @@ -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); diff --git a/packages/core/schematics/utils/template_ast_visitor.ts b/packages/core/schematics/utils/template_ast_visitor.ts index 517b6903061..5259b8c7baa 100644 --- a/packages/core/schematics/utils/template_ast_visitor.ts +++ b/packages/core/schematics/utils/template_ast_visitor.ts @@ -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. diff --git a/packages/core/test/acceptance/control_flow_switch_spec.ts b/packages/core/test/acceptance/control_flow_switch_spec.ts index aecaeb916b2..d619e3c683a 100644 --- a/packages/core/test/acceptance/control_flow_switch_spec.ts +++ b/packages/core/test/acceptance/control_flow_switch_spec.ts @@ -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. '); + }); }); diff --git a/packages/language-service/src/semantic_tokens.ts b/packages/language-service/src/semantic_tokens.ts index 22ba73c13c9..d590682f5ce 100644 --- a/packages/language-service/src/semantic_tokens.ts +++ b/packages/language-service/src/semantic_tokens.ts @@ -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); diff --git a/packages/language-service/src/template_target.ts b/packages/language-service/src/template_target.ts index 15e34d4ef30..1fb327e8fa0 100644 --- a/packages/language-service/src/template_target.ts +++ b/packages/language-service/src/template_target.ts @@ -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); diff --git a/packages/language-service/test/legacy/template_target_spec.ts b/packages/language-service/test/legacy/template_target_spec.ts index e6be3e8ca51..8a2419557f9 100644 --- a/packages/language-service/test/legacy/template_target_spec.ts +++ b/packages/language-service/test/legacy/template_target_spec.ts @@ -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)!;