mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
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:
parent
a8aab64809
commit
95b3f37d4a
19 changed files with 443 additions and 20 deletions
|
|
@ -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)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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"/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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. ');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)!;
|
||||
|
|
|
|||
Loading…
Reference in a new issue