343 lines
7.8 KiB
JavaScript
343 lines
7.8 KiB
JavaScript
|
'use strict';
|
||
|
const {hasSideEffect} = require('@eslint-community/eslint-utils');
|
||
|
const isSameReference = require('./utils/is-same-reference.js');
|
||
|
const getIndentString = require('./utils/get-indent-string.js');
|
||
|
|
||
|
const MESSAGE_ID = 'prefer-switch';
|
||
|
const messages = {
|
||
|
[MESSAGE_ID]: 'Use `switch` instead of multiple `else-if`.',
|
||
|
};
|
||
|
|
||
|
const isSame = (nodeA, nodeB) => nodeA === nodeB || isSameReference(nodeA, nodeB);
|
||
|
|
||
|
function getEqualityComparisons(node) {
|
||
|
const nodes = [node];
|
||
|
const compareExpressions = [];
|
||
|
while (nodes.length > 0) {
|
||
|
node = nodes.pop();
|
||
|
|
||
|
if (node.type === 'LogicalExpression' && node.operator === '||') {
|
||
|
nodes.push(node.right, node.left);
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
if (node.type !== 'BinaryExpression' || node.operator !== '===') {
|
||
|
return [];
|
||
|
}
|
||
|
|
||
|
compareExpressions.push(node);
|
||
|
}
|
||
|
|
||
|
return compareExpressions;
|
||
|
}
|
||
|
|
||
|
function getCommonReferences(expressions, candidates) {
|
||
|
for (const {left, right} of expressions) {
|
||
|
candidates = candidates.filter(node => isSame(node, left) || isSame(node, right));
|
||
|
|
||
|
if (candidates.length === 0) {
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return candidates;
|
||
|
}
|
||
|
|
||
|
function getStatements(statement) {
|
||
|
let discriminantCandidates;
|
||
|
const ifStatements = [];
|
||
|
for (; statement && statement.type === 'IfStatement'; statement = statement.alternate) {
|
||
|
const {test} = statement;
|
||
|
const compareExpressions = getEqualityComparisons(test);
|
||
|
|
||
|
if (compareExpressions.length === 0) {
|
||
|
break;
|
||
|
}
|
||
|
|
||
|
if (!discriminantCandidates) {
|
||
|
const [{left, right}] = compareExpressions;
|
||
|
discriminantCandidates = [left, right];
|
||
|
}
|
||
|
|
||
|
const candidates = getCommonReferences(
|
||
|
compareExpressions,
|
||
|
discriminantCandidates,
|
||
|
);
|
||
|
|
||
|
if (candidates.length === 0) {
|
||
|
break;
|
||
|
}
|
||
|
|
||
|
discriminantCandidates = candidates;
|
||
|
|
||
|
ifStatements.push({
|
||
|
statement,
|
||
|
compareExpressions,
|
||
|
});
|
||
|
}
|
||
|
|
||
|
return {
|
||
|
ifStatements,
|
||
|
discriminant: discriminantCandidates && discriminantCandidates[0],
|
||
|
};
|
||
|
}
|
||
|
|
||
|
const breakAbleNodeTypes = new Set([
|
||
|
'WhileStatement',
|
||
|
'DoWhileStatement',
|
||
|
'ForStatement',
|
||
|
'ForOfStatement',
|
||
|
'ForInStatement',
|
||
|
'SwitchStatement',
|
||
|
]);
|
||
|
const getBreakTarget = node => {
|
||
|
for (;node.parent; node = node.parent) {
|
||
|
if (breakAbleNodeTypes.has(node.type)) {
|
||
|
return node;
|
||
|
}
|
||
|
}
|
||
|
};
|
||
|
|
||
|
const isNodeInsideNode = (inner, outer) =>
|
||
|
inner.range[0] >= outer.range[0] && inner.range[1] <= outer.range[1];
|
||
|
function hasBreakInside(breakStatements, node) {
|
||
|
for (const breakStatement of breakStatements) {
|
||
|
if (!isNodeInsideNode(breakStatement, node)) {
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
const breakTarget = getBreakTarget(breakStatement);
|
||
|
|
||
|
if (!breakTarget) {
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
if (isNodeInsideNode(node, breakTarget)) {
|
||
|
return true;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
function * insertBracesIfNotBlockStatement(node, fixer, indent) {
|
||
|
if (!node || node.type === 'BlockStatement') {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
yield fixer.insertTextBefore(node, `{\n${indent}`);
|
||
|
yield fixer.insertTextAfter(node, `\n${indent}}`);
|
||
|
}
|
||
|
|
||
|
function * insertBreakStatement(node, fixer, sourceCode, indent) {
|
||
|
if (node.type === 'BlockStatement') {
|
||
|
const lastToken = sourceCode.getLastToken(node);
|
||
|
yield fixer.insertTextBefore(lastToken, `\n${indent}break;\n${indent}`);
|
||
|
} else {
|
||
|
yield fixer.insertTextAfter(node, `\n${indent}break;`);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function getBlockStatementLastNode(blockStatement) {
|
||
|
const {body} = blockStatement;
|
||
|
for (let index = body.length - 1; index >= 0; index--) {
|
||
|
const node = body[index];
|
||
|
if (node.type === 'FunctionDeclaration' || node.type === 'EmptyStatement') {
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
if (node.type === 'BlockStatement') {
|
||
|
const last = getBlockStatementLastNode(node);
|
||
|
if (last) {
|
||
|
return last;
|
||
|
}
|
||
|
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
return node;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function shouldInsertBreakStatement(node) {
|
||
|
switch (node.type) {
|
||
|
case 'ReturnStatement':
|
||
|
case 'ThrowStatement': {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
case 'IfStatement': {
|
||
|
return !node.alternate
|
||
|
|| shouldInsertBreakStatement(node.consequent)
|
||
|
|| shouldInsertBreakStatement(node.alternate);
|
||
|
}
|
||
|
|
||
|
case 'BlockStatement': {
|
||
|
const lastNode = getBlockStatementLastNode(node);
|
||
|
return !lastNode || shouldInsertBreakStatement(lastNode);
|
||
|
}
|
||
|
|
||
|
default: {
|
||
|
return true;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function fix({discriminant, ifStatements}, sourceCode, options) {
|
||
|
const discriminantText = sourceCode.getText(discriminant);
|
||
|
|
||
|
return function * (fixer) {
|
||
|
const firstStatement = ifStatements[0].statement;
|
||
|
const indent = getIndentString(firstStatement, sourceCode);
|
||
|
yield fixer.insertTextBefore(firstStatement, `switch (${discriminantText}) {`);
|
||
|
|
||
|
const lastStatement = ifStatements[ifStatements.length - 1].statement;
|
||
|
if (lastStatement.alternate) {
|
||
|
const {alternate} = lastStatement;
|
||
|
yield fixer.insertTextBefore(alternate, `\n${indent}default: `);
|
||
|
/*
|
||
|
Technically, we should insert braces for the following case,
|
||
|
but who writes like this? And using `let`/`const` is invalid.
|
||
|
|
||
|
```js
|
||
|
if (foo === 1) {}
|
||
|
else if (foo === 2) {}
|
||
|
else if (foo === 3) {}
|
||
|
else var a = 1;
|
||
|
```
|
||
|
*/
|
||
|
} else {
|
||
|
switch (options.emptyDefaultCase) {
|
||
|
case 'no-default-comment': {
|
||
|
yield fixer.insertTextAfter(firstStatement, `\n${indent}// No default`);
|
||
|
break;
|
||
|
}
|
||
|
|
||
|
case 'do-nothing-comment': {
|
||
|
yield fixer.insertTextAfter(firstStatement, `\n${indent}default:\n${indent}// Do nothing`);
|
||
|
break;
|
||
|
}
|
||
|
// No default
|
||
|
}
|
||
|
}
|
||
|
|
||
|
yield fixer.insertTextAfter(firstStatement, `\n${indent}}`);
|
||
|
|
||
|
for (const {statement, compareExpressions} of ifStatements) {
|
||
|
const {consequent, alternate, range} = statement;
|
||
|
const headRange = [range[0], consequent.range[0]];
|
||
|
|
||
|
if (alternate) {
|
||
|
const [, start] = consequent.range;
|
||
|
const [end] = alternate.range;
|
||
|
yield fixer.replaceTextRange([start, end], '');
|
||
|
}
|
||
|
|
||
|
yield fixer.replaceTextRange(headRange, '');
|
||
|
for (const {left, right} of compareExpressions) {
|
||
|
const node = isSame(left, discriminant) ? right : left;
|
||
|
const text = sourceCode.getText(node);
|
||
|
yield fixer.insertTextBefore(consequent, `\n${indent}case ${text}: `);
|
||
|
}
|
||
|
|
||
|
if (shouldInsertBreakStatement(consequent)) {
|
||
|
yield * insertBreakStatement(consequent, fixer, sourceCode, indent);
|
||
|
yield * insertBracesIfNotBlockStatement(consequent, fixer, indent);
|
||
|
}
|
||
|
}
|
||
|
};
|
||
|
}
|
||
|
|
||
|
/** @param {import('eslint').Rule.RuleContext} context */
|
||
|
const create = context => {
|
||
|
const options = {
|
||
|
minimumCases: 3,
|
||
|
emptyDefaultCase: 'no-default-comment',
|
||
|
insertBreakInDefaultCase: false,
|
||
|
...context.options[0],
|
||
|
};
|
||
|
const sourceCode = context.getSourceCode();
|
||
|
const ifStatements = new Set();
|
||
|
const breakStatements = [];
|
||
|
const checked = new Set();
|
||
|
|
||
|
return {
|
||
|
'IfStatement'(node) {
|
||
|
ifStatements.add(node);
|
||
|
},
|
||
|
'BreakStatement:not([label])'(node) {
|
||
|
breakStatements.push(node);
|
||
|
},
|
||
|
* 'Program:exit'() {
|
||
|
for (const node of ifStatements) {
|
||
|
if (checked.has(node)) {
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
const {discriminant, ifStatements} = getStatements(node);
|
||
|
|
||
|
if (!discriminant || ifStatements.length < options.minimumCases) {
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
for (const {statement} of ifStatements) {
|
||
|
checked.add(statement);
|
||
|
}
|
||
|
|
||
|
const problem = {
|
||
|
loc: {
|
||
|
start: node.loc.start,
|
||
|
end: node.consequent.loc.start,
|
||
|
},
|
||
|
messageId: MESSAGE_ID,
|
||
|
};
|
||
|
|
||
|
if (
|
||
|
!hasSideEffect(discriminant, sourceCode)
|
||
|
&& !ifStatements.some(({statement}) => hasBreakInside(breakStatements, statement))
|
||
|
) {
|
||
|
problem.fix = fix({discriminant, ifStatements}, sourceCode, options);
|
||
|
}
|
||
|
|
||
|
yield problem;
|
||
|
}
|
||
|
},
|
||
|
};
|
||
|
};
|
||
|
|
||
|
const schema = [
|
||
|
{
|
||
|
type: 'object',
|
||
|
additionalProperties: false,
|
||
|
properties: {
|
||
|
minimumCases: {
|
||
|
type: 'integer',
|
||
|
minimum: 2,
|
||
|
default: 3,
|
||
|
},
|
||
|
emptyDefaultCase: {
|
||
|
enum: [
|
||
|
'no-default-comment',
|
||
|
'do-nothing-comment',
|
||
|
'no-default-case',
|
||
|
],
|
||
|
default: 'no-default-comment',
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
];
|
||
|
|
||
|
/** @type {import('eslint').Rule.RuleModule} */
|
||
|
module.exports = {
|
||
|
create,
|
||
|
meta: {
|
||
|
type: 'suggestion',
|
||
|
docs: {
|
||
|
description: 'Prefer `switch` over multiple `else-if`.',
|
||
|
},
|
||
|
fixable: 'code',
|
||
|
schema,
|
||
|
messages,
|
||
|
},
|
||
|
};
|