Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(eslint-plugin): add switch-exhaustiveness-check rule #972

Merged
merged 25 commits into from Feb 3, 2020
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
c774e87
feat(eslint-plugin): add switch-exhaustiveness-check rule
drets Sep 12, 2019
185d0ec
feat(eslint-plugin): add return type (minor)
drets Sep 13, 2019
8b48f7a
feat(eslint-plugin): add more tests
drets Sep 13, 2019
65832fc
feat(eslint-plugin): improve fixer logic
drets Sep 13, 2019
3fdd117
Merge branch 'master' into master
drets Sep 23, 2019
7f68282
feat(eslint-plugin): use Set for case types
cust0dian Jan 22, 2020
89f44e9
feat(eslint-plugin): use union format when displaying missing cases
cust0dian Jan 22, 2020
0dccf0e
feat(eslint-plugin): add more tests
cust0dian Jan 22, 2020
e4a9538
feat(eslint-plugin): remove fixer
cust0dian Jan 23, 2020
92a68d6
feat(esling-plugin): bring back fixer for suggestions
cust0dian Jan 23, 2020
0c2c832
Merge remote-tracking branch 'upstream/master' into switch-exhaustive…
cust0dian Jan 23, 2020
5c0ad61
feat(eslint-plugin): better handle missing symbol cases
cust0dian Jan 23, 2020
1f34107
Merge pull request #1 from cust0dian/switch-exhaustiveness-check
drets Jan 24, 2020
87c4c80
feat(eslint-plugin): fix lint and test errors
cust0dian Jan 24, 2020
d7f6928
feat(eslint-plugin): fix spelling errors
cust0dian Jan 24, 2020
2591e71
Merge remote-tracking branch 'upstream/master'
cust0dian Jan 24, 2020
a66e431
feat(eslint-plugin): split up primitive literals test
cust0dian Jan 30, 2020
cb05877
feat(eslint-plugin): improve fixer to handle the case
cust0dian Jan 30, 2020
a031253
feat(eslint-plugin): extend test cases to check format of missing cases
cust0dian Jan 30, 2020
fc7a8cd
feat(eslint-plugin): cleanup trailing whitespace
cust0dian Jan 30, 2020
dfbd62c
Merge remote-tracking branch 'upstream/master'
cust0dian Jan 30, 2020
c569ded
feat(eslint-plugin): fix postion of the rule in README table
cust0dian Jan 30, 2020
c9b8d7b
remove fixer flag from meta
bradzacher Feb 3, 2020
0b3317f
update docs
bradzacher Feb 3, 2020
6611091
Merge branch 'master' into master
bradzacher Feb 3, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/eslint-plugin/README.md
Expand Up @@ -143,6 +143,7 @@ Pro Tip: For larger codebases you may want to consider splitting our linting int
| [`@typescript-eslint/prefer-readonly`](./docs/rules/prefer-readonly.md) | Requires that private members are marked as `readonly` if they're never modified outside of the constructor | | :wrench: | :thought_balloon: |
| [`@typescript-eslint/prefer-regexp-exec`](./docs/rules/prefer-regexp-exec.md) | Enforce that `RegExp#exec` is used instead of `String#match` if no global flag is provided | :heavy_check_mark: | | :thought_balloon: |
| [`@typescript-eslint/prefer-string-starts-ends-with`](./docs/rules/prefer-string-starts-ends-with.md) | Enforce the use of `String#startsWith` and `String#endsWith` instead of other equivalent methods of checking substrings | :heavy_check_mark: | :wrench: | :thought_balloon: |
| [`@typescript-eslint/switch-exhaustiveness-check`](./docs/rules/switch-exhaustiveness-check.md) | Exhaustiveness checking in switch with union type | | :wrench: | :thought_balloon: |
| [`@typescript-eslint/promise-function-async`](./docs/rules/promise-function-async.md) | Requires any function or method that returns a Promise to be marked async | | | :thought_balloon: |
| [`@typescript-eslint/require-array-sort-compare`](./docs/rules/require-array-sort-compare.md) | Requires `Array#sort` calls to always provide a `compareFunction` | | | :thought_balloon: |
| [`@typescript-eslint/restrict-plus-operands`](./docs/rules/restrict-plus-operands.md) | When adding two variables, operands must both be of type number or of type string | | | :thought_balloon: |
Expand Down
103 changes: 103 additions & 0 deletions packages/eslint-plugin/docs/rules/switch-exhaustiveness-check.md
@@ -0,0 +1,103 @@
# Exhaustiveness checking in switch with union type (`switch-exhaustiveness-check`)

Union type may have a lot of parts. It's easy to forget to consider all cases in switch. This rule reminds which parts are missing. If domain of the problem requires to have only a partial switch, developer may _explicitly_ add a default clause.

Examples of **incorrect** code for this rule:

```ts
type Day =
| 'Monday'
| 'Tuesday'
| 'Wednesday'
| 'Thursday'
| 'Friday'
| 'Saturday'
| 'Sunday';

const day = 'Monday' as Day;
let result = 0;

switch (day) {
case 'Monday': {
result = 1;
break;
}
}
```

Examples of **correct** code for this rule:

```ts
type Day =
| 'Monday'
| 'Tuesday'
| 'Wednesday'
| 'Thursday'
| 'Friday'
| 'Saturday'
| 'Sunday';

const day = 'Monday' as Day;
let result = 0;

switch (day) {
case 'Monday': {
result = 1;
break;
}
case 'Tuesday': {
result = 2;
break;
}
case 'Wednesday': {
result = 3;
break;
}
case 'Thursday': {
result = 4;
break;
}
case 'Friday': {
result = 5;
break;
}
case 'Saturday': {
result = 6;
break;
}
case 'Sunday': {
result = 7;
break;
}
}
```

or

```ts
type Day =
| 'Monday'
| 'Tuesday'
| 'Wednesday'
| 'Thursday'
| 'Friday'
| 'Saturday'
| 'Sunday';

const day = 'Monday' as Day;
let result = 0;

switch (day) {
case 'Monday': {
result = 1;
break;
}
default: {
result = 42;
}
}
```

## When Not To Use It

If program doesn't have union types with many parts. Downside of this rule is the need for type information, so it's slower than regular rules.
1 change: 1 addition & 0 deletions packages/eslint-plugin/src/configs/all.json
Expand Up @@ -93,6 +93,7 @@
"space-before-function-paren": "off",
"@typescript-eslint/space-before-function-paren": "error",
"@typescript-eslint/strict-boolean-expressions": "error",
"@typescript-eslint/switch-exhaustiveness-check": "error",
"@typescript-eslint/triple-slash-reference": "error",
"@typescript-eslint/type-annotation-spacing": "error",
"@typescript-eslint/typedef": "error",
Expand Down
2 changes: 2 additions & 0 deletions packages/eslint-plugin/src/rules/index.ts
Expand Up @@ -78,6 +78,7 @@ import returnAwait from './return-await';
import semi from './semi';
import spaceBeforeFunctionParen from './space-before-function-paren';
import strictBooleanExpressions from './strict-boolean-expressions';
import switchExhaustivenessCheck from './switch-exhaustiveness-check';
import tripleSlashReference from './triple-slash-reference';
import typeAnnotationSpacing from './type-annotation-spacing';
import typedef from './typedef';
Expand Down Expand Up @@ -165,6 +166,7 @@ export default {
semi: semi,
'space-before-function-paren': spaceBeforeFunctionParen,
'strict-boolean-expressions': strictBooleanExpressions,
'switch-exhaustiveness-check': switchExhaustivenessCheck,
'triple-slash-reference': tripleSlashReference,
'type-annotation-spacing': typeAnnotationSpacing,
typedef: typedef,
Expand Down
153 changes: 153 additions & 0 deletions packages/eslint-plugin/src/rules/switch-exhaustiveness-check.ts
@@ -0,0 +1,153 @@
import { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils';
import * as ts from 'typescript';
import {
createRule,
getParserServices,
getConstrainedTypeAtLocation,
} from '../util';
import { isTypeFlagSet, unionTypeParts } from 'tsutils';
import { isClosingBraceToken, isOpeningBraceToken } from 'eslint-utils';

export default createRule({
name: 'switch-exhaustiveness-check',
meta: {
type: 'suggestion',
fixable: 'code',
docs: {
description: 'Exhaustiveness checking in switch with union type',
category: 'Best Practices',
recommended: false,
requiresTypeChecking: true,
},
schema: [],
messages: {
switchIsNotExhaustive:
'Switch is not exhaustive. Cases not matched: {{missingBranches}}',
addMissingCases: 'Add branches for missing cases',
},
},
defaultOptions: [],
create(context) {
const sourceCode = context.getSourceCode();
const service = getParserServices(context);
const checker = service.program.getTypeChecker();

function getNodeType(node: TSESTree.Node): ts.Type {
const tsNode = service.esTreeNodeToTSNodeMap.get(node);
return getConstrainedTypeAtLocation(checker, tsNode);
}

function fixSwitch(
fixer: TSESLint.RuleFixer,
node: TSESTree.SwitchStatement,
missingBranchTypes: Array<ts.Type>,
): TSESLint.RuleFix | null {
const lastCase =
node.cases.length > 0 ? node.cases[node.cases.length - 1] : null;
const caseIndent = lastCase
? ' '.repeat(lastCase.loc.start.column)
: // if there are no cases, use indentation of the switch statement
// and leave it to user to format it correctly
' '.repeat(node.loc.start.column);

const missingCases = [];
for (const missingBranchType of missingBranchTypes) {
// While running this rule on checker.ts of TypeScript project
// the fix introduced a compiler error due to:
//
// type __String = (string & {
// __escapedIdentifier: void;
// }) | (void & {
// __escapedIdentifier: void;
// }) | InternalSymbolName;
//
// The following check fixes it.
if (missingBranchType.isIntersection()) {
continue;
}

const caseTest = checker.typeToString(missingBranchType);
const errorMessage = `Not implemented yet: ${caseTest} case`;

missingCases.push(
`case ${caseTest}: { throw new Error('${errorMessage}') }`,
);
}

const fixString = missingCases
.map(code => `${caseIndent}${code}`)
.join('\n');

if (lastCase) {
return fixer.insertTextAfter(lastCase, `\n${fixString}`);
}

// there were no existing cases
const openingBrace = sourceCode.getTokenAfter(
node.discriminant,
isOpeningBraceToken,
)!;
const closingBrace = sourceCode.getTokenAfter(
node.discriminant,
isClosingBraceToken,
)!;

return fixer.replaceTextRange(
[openingBrace.range[0], closingBrace.range[1]],
['{', fixString, `${caseIndent}}`].join('\n'),
);
}

function checkSwitchExhaustive(node: TSESTree.SwitchStatement): void {
const discriminantType = getNodeType(node.discriminant);

if (discriminantType.isUnion()) {
const unionTypes = unionTypeParts(discriminantType);
const caseTypes: Set<ts.Type> = new Set();
for (const switchCase of node.cases) {
if (switchCase.test === null) {
// Switch has 'default' branch - do nothing.
return;
}

caseTypes.add(getNodeType(switchCase.test));
}

const missingBranchTypes = unionTypes.filter(
unionType => !caseTypes.has(unionType),
);
bradzacher marked this conversation as resolved.
Show resolved Hide resolved

if (missingBranchTypes.length === 0) {
// All cases matched - do nothing.
return;
}

context.report({
node: node.discriminant,
messageId: 'switchIsNotExhaustive',
data: {
missingBranches: missingBranchTypes
.map(missingType =>
isTypeFlagSet(missingType, ts.TypeFlags.ESSymbolLike)
? `typeof ${missingType.symbol.escapedName}`
: checker.typeToString(missingType),
)
.join(' | '),
},
suggest: [
{
messageId: 'addMissingCases',
fix(fixer): TSESLint.RuleFix | null {
return fixSwitch(fixer, node, missingBranchTypes);
},
},
],
});
}
}

return {
SwitchStatement: checkSwitchExhaustive,
};
},
});