|
| 1 | +import { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'; |
| 2 | +import * as ts from 'typescript'; |
| 3 | +import { |
| 4 | + createRule, |
| 5 | + getParserServices, |
| 6 | + getConstrainedTypeAtLocation, |
| 7 | +} from '../util'; |
| 8 | +import { isTypeFlagSet, unionTypeParts } from 'tsutils'; |
| 9 | +import { isClosingBraceToken, isOpeningBraceToken } from 'eslint-utils'; |
| 10 | + |
| 11 | +export default createRule({ |
| 12 | + name: 'switch-exhaustiveness-check', |
| 13 | + meta: { |
| 14 | + type: 'suggestion', |
| 15 | + docs: { |
| 16 | + description: 'Exhaustiveness checking in switch with union type', |
| 17 | + category: 'Best Practices', |
| 18 | + recommended: false, |
| 19 | + requiresTypeChecking: true, |
| 20 | + }, |
| 21 | + schema: [], |
| 22 | + messages: { |
| 23 | + switchIsNotExhaustive: |
| 24 | + 'Switch is not exhaustive. Cases not matched: {{missingBranches}}', |
| 25 | + addMissingCases: 'Add branches for missing cases', |
| 26 | + }, |
| 27 | + }, |
| 28 | + defaultOptions: [], |
| 29 | + create(context) { |
| 30 | + const sourceCode = context.getSourceCode(); |
| 31 | + const service = getParserServices(context); |
| 32 | + const checker = service.program.getTypeChecker(); |
| 33 | + |
| 34 | + function getNodeType(node: TSESTree.Node): ts.Type { |
| 35 | + const tsNode = service.esTreeNodeToTSNodeMap.get(node); |
| 36 | + return getConstrainedTypeAtLocation(checker, tsNode); |
| 37 | + } |
| 38 | + |
| 39 | + function fixSwitch( |
| 40 | + fixer: TSESLint.RuleFixer, |
| 41 | + node: TSESTree.SwitchStatement, |
| 42 | + missingBranchTypes: Array<ts.Type>, |
| 43 | + ): TSESLint.RuleFix | null { |
| 44 | + const lastCase = |
| 45 | + node.cases.length > 0 ? node.cases[node.cases.length - 1] : null; |
| 46 | + const caseIndent = lastCase |
| 47 | + ? ' '.repeat(lastCase.loc.start.column) |
| 48 | + : // if there are no cases, use indentation of the switch statement |
| 49 | + // and leave it to user to format it correctly |
| 50 | + ' '.repeat(node.loc.start.column); |
| 51 | + |
| 52 | + const missingCases = []; |
| 53 | + for (const missingBranchType of missingBranchTypes) { |
| 54 | + // While running this rule on checker.ts of TypeScript project |
| 55 | + // the fix introduced a compiler error due to: |
| 56 | + // |
| 57 | + // type __String = (string & { |
| 58 | + // __escapedIdentifier: void; |
| 59 | + // }) | (void & { |
| 60 | + // __escapedIdentifier: void; |
| 61 | + // }) | InternalSymbolName; |
| 62 | + // |
| 63 | + // The following check fixes it. |
| 64 | + if (missingBranchType.isIntersection()) { |
| 65 | + continue; |
| 66 | + } |
| 67 | + |
| 68 | + const caseTest = checker.typeToString(missingBranchType); |
| 69 | + const errorMessage = `Not implemented yet: ${caseTest} case`; |
| 70 | + |
| 71 | + missingCases.push( |
| 72 | + `case ${caseTest}: { throw new Error('${errorMessage}') }`, |
| 73 | + ); |
| 74 | + } |
| 75 | + |
| 76 | + const fixString = missingCases |
| 77 | + .map(code => `${caseIndent}${code}`) |
| 78 | + .join('\n'); |
| 79 | + |
| 80 | + if (lastCase) { |
| 81 | + return fixer.insertTextAfter(lastCase, `\n${fixString}`); |
| 82 | + } |
| 83 | + |
| 84 | + // there were no existing cases |
| 85 | + const openingBrace = sourceCode.getTokenAfter( |
| 86 | + node.discriminant, |
| 87 | + isOpeningBraceToken, |
| 88 | + )!; |
| 89 | + const closingBrace = sourceCode.getTokenAfter( |
| 90 | + node.discriminant, |
| 91 | + isClosingBraceToken, |
| 92 | + )!; |
| 93 | + |
| 94 | + return fixer.replaceTextRange( |
| 95 | + [openingBrace.range[0], closingBrace.range[1]], |
| 96 | + ['{', fixString, `${caseIndent}}`].join('\n'), |
| 97 | + ); |
| 98 | + } |
| 99 | + |
| 100 | + function checkSwitchExhaustive(node: TSESTree.SwitchStatement): void { |
| 101 | + const discriminantType = getNodeType(node.discriminant); |
| 102 | + |
| 103 | + if (discriminantType.isUnion()) { |
| 104 | + const unionTypes = unionTypeParts(discriminantType); |
| 105 | + const caseTypes: Set<ts.Type> = new Set(); |
| 106 | + for (const switchCase of node.cases) { |
| 107 | + if (switchCase.test === null) { |
| 108 | + // Switch has 'default' branch - do nothing. |
| 109 | + return; |
| 110 | + } |
| 111 | + |
| 112 | + caseTypes.add(getNodeType(switchCase.test)); |
| 113 | + } |
| 114 | + |
| 115 | + const missingBranchTypes = unionTypes.filter( |
| 116 | + unionType => !caseTypes.has(unionType), |
| 117 | + ); |
| 118 | + |
| 119 | + if (missingBranchTypes.length === 0) { |
| 120 | + // All cases matched - do nothing. |
| 121 | + return; |
| 122 | + } |
| 123 | + |
| 124 | + context.report({ |
| 125 | + node: node.discriminant, |
| 126 | + messageId: 'switchIsNotExhaustive', |
| 127 | + data: { |
| 128 | + missingBranches: missingBranchTypes |
| 129 | + .map(missingType => |
| 130 | + isTypeFlagSet(missingType, ts.TypeFlags.ESSymbolLike) |
| 131 | + ? `typeof ${missingType.symbol.escapedName}` |
| 132 | + : checker.typeToString(missingType), |
| 133 | + ) |
| 134 | + .join(' | '), |
| 135 | + }, |
| 136 | + suggest: [ |
| 137 | + { |
| 138 | + messageId: 'addMissingCases', |
| 139 | + fix(fixer): TSESLint.RuleFix | null { |
| 140 | + return fixSwitch(fixer, node, missingBranchTypes); |
| 141 | + }, |
| 142 | + }, |
| 143 | + ], |
| 144 | + }); |
| 145 | + } |
| 146 | + } |
| 147 | + |
| 148 | + return { |
| 149 | + SwitchStatement: checkSwitchExhaustive, |
| 150 | + }; |
| 151 | + }, |
| 152 | +}); |
0 commit comments