prefer-switch.js 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. 'use strict';
  2. const {hasSideEffect} = require('@eslint-community/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. }
  136. case 'IfStatement': {
  137. return !node.alternate
  138. || shouldInsertBreakStatement(node.consequent)
  139. || shouldInsertBreakStatement(node.alternate);
  140. }
  141. case 'BlockStatement': {
  142. const lastNode = getBlockStatementLastNode(node);
  143. return !lastNode || shouldInsertBreakStatement(lastNode);
  144. }
  145. default: {
  146. return true;
  147. }
  148. }
  149. }
  150. function fix({discriminant, ifStatements}, sourceCode, options) {
  151. const discriminantText = sourceCode.getText(discriminant);
  152. return function * (fixer) {
  153. const firstStatement = ifStatements[0].statement;
  154. const indent = getIndentString(firstStatement, sourceCode);
  155. yield fixer.insertTextBefore(firstStatement, `switch (${discriminantText}) {`);
  156. const lastStatement = ifStatements[ifStatements.length - 1].statement;
  157. if (lastStatement.alternate) {
  158. const {alternate} = lastStatement;
  159. yield fixer.insertTextBefore(alternate, `\n${indent}default: `);
  160. /*
  161. Technically, we should insert braces for the following case,
  162. but who writes like this? And using `let`/`const` is invalid.
  163. ```js
  164. if (foo === 1) {}
  165. else if (foo === 2) {}
  166. else if (foo === 3) {}
  167. else var a = 1;
  168. ```
  169. */
  170. } else {
  171. switch (options.emptyDefaultCase) {
  172. case 'no-default-comment': {
  173. yield fixer.insertTextAfter(firstStatement, `\n${indent}// No default`);
  174. break;
  175. }
  176. case 'do-nothing-comment': {
  177. yield fixer.insertTextAfter(firstStatement, `\n${indent}default:\n${indent}// Do nothing`);
  178. break;
  179. }
  180. // No default
  181. }
  182. }
  183. yield fixer.insertTextAfter(firstStatement, `\n${indent}}`);
  184. for (const {statement, compareExpressions} of ifStatements) {
  185. const {consequent, alternate, range} = statement;
  186. const headRange = [range[0], consequent.range[0]];
  187. if (alternate) {
  188. const [, start] = consequent.range;
  189. const [end] = alternate.range;
  190. yield fixer.replaceTextRange([start, end], '');
  191. }
  192. yield fixer.replaceTextRange(headRange, '');
  193. for (const {left, right} of compareExpressions) {
  194. const node = isSame(left, discriminant) ? right : left;
  195. const text = sourceCode.getText(node);
  196. yield fixer.insertTextBefore(consequent, `\n${indent}case ${text}: `);
  197. }
  198. if (shouldInsertBreakStatement(consequent)) {
  199. yield * insertBreakStatement(consequent, fixer, sourceCode, indent);
  200. yield * insertBracesIfNotBlockStatement(consequent, fixer, indent);
  201. }
  202. }
  203. };
  204. }
  205. /** @param {import('eslint').Rule.RuleContext} context */
  206. const create = context => {
  207. const options = {
  208. minimumCases: 3,
  209. emptyDefaultCase: 'no-default-comment',
  210. insertBreakInDefaultCase: false,
  211. ...context.options[0],
  212. };
  213. const sourceCode = context.getSourceCode();
  214. const ifStatements = new Set();
  215. const breakStatements = [];
  216. const checked = new Set();
  217. return {
  218. 'IfStatement'(node) {
  219. ifStatements.add(node);
  220. },
  221. 'BreakStatement:not([label])'(node) {
  222. breakStatements.push(node);
  223. },
  224. * 'Program:exit'() {
  225. for (const node of ifStatements) {
  226. if (checked.has(node)) {
  227. continue;
  228. }
  229. const {discriminant, ifStatements} = getStatements(node);
  230. if (!discriminant || ifStatements.length < options.minimumCases) {
  231. continue;
  232. }
  233. for (const {statement} of ifStatements) {
  234. checked.add(statement);
  235. }
  236. const problem = {
  237. loc: {
  238. start: node.loc.start,
  239. end: node.consequent.loc.start,
  240. },
  241. messageId: MESSAGE_ID,
  242. };
  243. if (
  244. !hasSideEffect(discriminant, sourceCode)
  245. && !ifStatements.some(({statement}) => hasBreakInside(breakStatements, statement))
  246. ) {
  247. problem.fix = fix({discriminant, ifStatements}, sourceCode, options);
  248. }
  249. yield problem;
  250. }
  251. },
  252. };
  253. };
  254. const schema = [
  255. {
  256. type: 'object',
  257. additionalProperties: false,
  258. properties: {
  259. minimumCases: {
  260. type: 'integer',
  261. minimum: 2,
  262. default: 3,
  263. },
  264. emptyDefaultCase: {
  265. enum: [
  266. 'no-default-comment',
  267. 'do-nothing-comment',
  268. 'no-default-case',
  269. ],
  270. default: 'no-default-comment',
  271. },
  272. },
  273. },
  274. ];
  275. /** @type {import('eslint').Rule.RuleModule} */
  276. module.exports = {
  277. create,
  278. meta: {
  279. type: 'suggestion',
  280. docs: {
  281. description: 'Prefer `switch` over multiple `else-if`.',
  282. },
  283. fixable: 'code',
  284. schema,
  285. messages,
  286. },
  287. };