prefer-switch.js 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. 'use strict';
  2. const {hasSideEffect} = require('eslint-utils');
  3. const isSameReference = require('./utils/is-same-reference.js');
  4. const getIndentString = require('./utils/get-indent-string.js');
  5. const MESSAGE_ID = 'prefer-switch';
  6. const messages = {
  7. [MESSAGE_ID]: 'Use `switch` instead of multiple `else-if`.',
  8. };
  9. const isSame = (nodeA, nodeB) => nodeA === nodeB || isSameReference(nodeA, nodeB);
  10. function getEqualityComparisons(node) {
  11. const nodes = [node];
  12. const compareExpressions = [];
  13. while (nodes.length > 0) {
  14. node = nodes.pop();
  15. if (node.type === 'LogicalExpression' && node.operator === '||') {
  16. nodes.push(node.right, node.left);
  17. continue;
  18. }
  19. if (node.type !== 'BinaryExpression' || node.operator !== '===') {
  20. return [];
  21. }
  22. compareExpressions.push(node);
  23. }
  24. return compareExpressions;
  25. }
  26. function getCommonReferences(expressions, candidates) {
  27. for (const {left, right} of expressions) {
  28. candidates = candidates.filter(node => isSame(node, left) || isSame(node, right));
  29. if (candidates.length === 0) {
  30. break;
  31. }
  32. }
  33. return candidates;
  34. }
  35. function getStatements(statement) {
  36. let discriminantCandidates;
  37. const ifStatements = [];
  38. for (; statement && statement.type === 'IfStatement'; statement = statement.alternate) {
  39. const {test} = statement;
  40. const compareExpressions = getEqualityComparisons(test);
  41. if (compareExpressions.length === 0) {
  42. break;
  43. }
  44. if (!discriminantCandidates) {
  45. const [{left, right}] = compareExpressions;
  46. discriminantCandidates = [left, right];
  47. }
  48. const candidates = getCommonReferences(
  49. compareExpressions,
  50. discriminantCandidates,
  51. );
  52. if (candidates.length === 0) {
  53. break;
  54. }
  55. discriminantCandidates = candidates;
  56. ifStatements.push({
  57. statement,
  58. compareExpressions,
  59. });
  60. }
  61. return {
  62. ifStatements,
  63. discriminant: discriminantCandidates && discriminantCandidates[0],
  64. };
  65. }
  66. const breakAbleNodeTypes = new Set([
  67. 'WhileStatement',
  68. 'DoWhileStatement',
  69. 'ForStatement',
  70. 'ForOfStatement',
  71. 'ForInStatement',
  72. 'SwitchStatement',
  73. ]);
  74. const getBreakTarget = node => {
  75. for (;node.parent; node = node.parent) {
  76. if (breakAbleNodeTypes.has(node.type)) {
  77. return node;
  78. }
  79. }
  80. };
  81. const isNodeInsideNode = (inner, outer) =>
  82. inner.range[0] >= outer.range[0] && inner.range[1] <= outer.range[1];
  83. function hasBreakInside(breakStatements, node) {
  84. for (const breakStatement of breakStatements) {
  85. if (!isNodeInsideNode(breakStatement, node)) {
  86. continue;
  87. }
  88. const breakTarget = getBreakTarget(breakStatement);
  89. if (!breakTarget) {
  90. return true;
  91. }
  92. if (isNodeInsideNode(node, breakTarget)) {
  93. return true;
  94. }
  95. }
  96. return false;
  97. }
  98. function * insertBracesIfNotBlockStatement(node, fixer, indent) {
  99. if (!node || node.type === 'BlockStatement') {
  100. return;
  101. }
  102. yield fixer.insertTextBefore(node, `{\n${indent}`);
  103. yield fixer.insertTextAfter(node, `\n${indent}}`);
  104. }
  105. function * insertBreakStatement(node, fixer, sourceCode, indent) {
  106. if (node.type === 'BlockStatement') {
  107. const lastToken = sourceCode.getLastToken(node);
  108. yield fixer.insertTextBefore(lastToken, `\n${indent}break;\n${indent}`);
  109. } else {
  110. yield fixer.insertTextAfter(node, `\n${indent}break;`);
  111. }
  112. }
  113. function getBlockStatementLastNode(blockStatement) {
  114. const {body} = blockStatement;
  115. for (let index = body.length - 1; index >= 0; index--) {
  116. const node = body[index];
  117. if (node.type === 'FunctionDeclaration' || node.type === 'EmptyStatement') {
  118. continue;
  119. }
  120. if (node.type === 'BlockStatement') {
  121. const last = getBlockStatementLastNode(node);
  122. if (last) {
  123. return last;
  124. }
  125. continue;
  126. }
  127. return node;
  128. }
  129. }
  130. function shouldInsertBreakStatement(node) {
  131. switch (node.type) {
  132. case 'ReturnStatement':
  133. case 'ThrowStatement':
  134. return false;
  135. case 'IfStatement':
  136. return !node.alternate
  137. || shouldInsertBreakStatement(node.consequent)
  138. || shouldInsertBreakStatement(node.alternate);
  139. case 'BlockStatement': {
  140. const lastNode = getBlockStatementLastNode(node);
  141. return !lastNode || shouldInsertBreakStatement(lastNode);
  142. }
  143. default:
  144. return true;
  145. }
  146. }
  147. function fix({discriminant, ifStatements}, sourceCode, options) {
  148. const discriminantText = sourceCode.getText(discriminant);
  149. return function * (fixer) {
  150. const firstStatement = ifStatements[0].statement;
  151. const indent = getIndentString(firstStatement, sourceCode);
  152. yield fixer.insertTextBefore(firstStatement, `switch (${discriminantText}) {`);
  153. const lastStatement = ifStatements[ifStatements.length - 1].statement;
  154. if (lastStatement.alternate) {
  155. const {alternate} = lastStatement;
  156. yield fixer.insertTextBefore(alternate, `\n${indent}default: `);
  157. /*
  158. Technically, we should insert braces for the following case,
  159. but who writes like this? And using `let`/`const` is invalid.
  160. ```js
  161. if (foo === 1) {}
  162. else if (foo === 2) {}
  163. else if (foo === 3) {}
  164. else var a = 1;
  165. ```
  166. */
  167. } else {
  168. switch (options.emptyDefaultCase) {
  169. case 'no-default-comment':
  170. yield fixer.insertTextAfter(firstStatement, `\n${indent}// No default`);
  171. break;
  172. case 'do-nothing-comment': {
  173. yield fixer.insertTextAfter(firstStatement, `\n${indent}default:\n${indent}// Do nothing`);
  174. break;
  175. }
  176. // No default
  177. }
  178. }
  179. yield fixer.insertTextAfter(firstStatement, `\n${indent}}`);
  180. for (const {statement, compareExpressions} of ifStatements) {
  181. const {consequent, alternate, range} = statement;
  182. const headRange = [range[0], consequent.range[0]];
  183. if (alternate) {
  184. const [, start] = consequent.range;
  185. const [end] = alternate.range;
  186. yield fixer.replaceTextRange([start, end], '');
  187. }
  188. yield fixer.replaceTextRange(headRange, '');
  189. for (const {left, right} of compareExpressions) {
  190. const node = isSame(left, discriminant) ? right : left;
  191. const text = sourceCode.getText(node);
  192. yield fixer.insertTextBefore(consequent, `\n${indent}case ${text}: `);
  193. }
  194. if (shouldInsertBreakStatement(consequent)) {
  195. yield * insertBreakStatement(consequent, fixer, sourceCode, indent);
  196. yield * insertBracesIfNotBlockStatement(consequent, fixer, indent);
  197. }
  198. }
  199. };
  200. }
  201. /** @param {import('eslint').Rule.RuleContext} context */
  202. const create = context => {
  203. const options = {
  204. minimumCases: 3,
  205. emptyDefaultCase: 'no-default-comment',
  206. insertBreakInDefaultCase: false,
  207. ...context.options[0],
  208. };
  209. const sourceCode = context.getSourceCode();
  210. const ifStatements = new Set();
  211. const breakStatements = [];
  212. const checked = new Set();
  213. return {
  214. 'IfStatement'(node) {
  215. ifStatements.add(node);
  216. },
  217. 'BreakStatement:not([label])'(node) {
  218. breakStatements.push(node);
  219. },
  220. * 'Program:exit'() {
  221. for (const node of ifStatements) {
  222. if (checked.has(node)) {
  223. continue;
  224. }
  225. const {discriminant, ifStatements} = getStatements(node);
  226. if (!discriminant || ifStatements.length < options.minimumCases) {
  227. continue;
  228. }
  229. for (const {statement} of ifStatements) {
  230. checked.add(statement);
  231. }
  232. const problem = {
  233. loc: {
  234. start: node.loc.start,
  235. end: node.consequent.loc.start,
  236. },
  237. messageId: MESSAGE_ID,
  238. };
  239. if (
  240. !hasSideEffect(discriminant, sourceCode)
  241. && !ifStatements.some(({statement}) => hasBreakInside(breakStatements, statement))
  242. ) {
  243. problem.fix = fix({discriminant, ifStatements}, sourceCode, options);
  244. }
  245. yield problem;
  246. }
  247. },
  248. };
  249. };
  250. const schema = [
  251. {
  252. type: 'object',
  253. additionalProperties: false,
  254. properties: {
  255. minimumCases: {
  256. type: 'integer',
  257. minimum: 2,
  258. default: 3,
  259. },
  260. emptyDefaultCase: {
  261. enum: [
  262. 'no-default-comment',
  263. 'do-nothing-comment',
  264. 'no-default-case',
  265. ],
  266. default: 'no-default-comment',
  267. },
  268. },
  269. },
  270. ];
  271. /** @type {import('eslint').Rule.RuleModule} */
  272. module.exports = {
  273. create,
  274. meta: {
  275. type: 'suggestion',
  276. docs: {
  277. description: 'Prefer `switch` over multiple `else-if`.',
  278. },
  279. fixable: 'code',
  280. schema,
  281. messages,
  282. },
  283. };