no-array-for-each.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  1. 'use strict';
  2. const {
  3. isParenthesized,
  4. isCommaToken,
  5. isSemicolonToken,
  6. isClosingParenToken,
  7. findVariable,
  8. hasSideEffect,
  9. } = require('eslint-utils');
  10. const {methodCallSelector, referenceIdentifierSelector} = require('./selectors/index.js');
  11. const {extendFixRange} = require('./fix/index.js');
  12. const needsSemicolon = require('./utils/needs-semicolon.js');
  13. const shouldAddParenthesesToExpressionStatementExpression = require('./utils/should-add-parentheses-to-expression-statement-expression.js');
  14. const shouldAddParenthesesToMemberExpressionObject = require('./utils/should-add-parentheses-to-member-expression-object.js');
  15. const {getParentheses, getParenthesizedRange} = require('./utils/parentheses.js');
  16. const isFunctionSelfUsedInside = require('./utils/is-function-self-used-inside.js');
  17. const {isNodeMatches} = require('./utils/is-node-matches.js');
  18. const assertToken = require('./utils/assert-token.js');
  19. const {fixSpaceAroundKeyword, removeParentheses} = require('./fix/index.js');
  20. const {isArrowFunctionBody} = require('./ast/index.js');
  21. const MESSAGE_ID_ERROR = 'no-array-for-each/error';
  22. const MESSAGE_ID_SUGGESTION = 'no-array-for-each/suggestion';
  23. const messages = {
  24. [MESSAGE_ID_ERROR]: 'Use `for…of` instead of `.forEach(…)`.',
  25. [MESSAGE_ID_SUGGESTION]: 'Switch to `for…of`.',
  26. };
  27. const forEachMethodCallSelector = methodCallSelector({
  28. method: 'forEach',
  29. includeOptionalCall: true,
  30. includeOptionalMember: true,
  31. });
  32. const continueAbleNodeTypes = new Set([
  33. 'WhileStatement',
  34. 'DoWhileStatement',
  35. 'ForStatement',
  36. 'ForOfStatement',
  37. 'ForInStatement',
  38. ]);
  39. const stripChainExpression = node =>
  40. (node.parent.type === 'ChainExpression' && node.parent.expression === node)
  41. ? node.parent
  42. : node;
  43. function isReturnStatementInContinueAbleNodes(returnStatement, callbackFunction) {
  44. for (let node = returnStatement; node && node !== callbackFunction; node = node.parent) {
  45. if (continueAbleNodeTypes.has(node.type)) {
  46. return true;
  47. }
  48. }
  49. return false;
  50. }
  51. function shouldSwitchReturnStatementToBlockStatement(returnStatement) {
  52. const {parent} = returnStatement;
  53. switch (parent.type) {
  54. case 'IfStatement':
  55. return parent.consequent === returnStatement || parent.alternate === returnStatement;
  56. // These parent's body need switch to `BlockStatement` too, but since they are "continueAble", won't fix
  57. // case 'ForStatement':
  58. // case 'ForInStatement':
  59. // case 'ForOfStatement':
  60. // case 'WhileStatement':
  61. // case 'DoWhileStatement':
  62. case 'WithStatement':
  63. return parent.body === returnStatement;
  64. default:
  65. return false;
  66. }
  67. }
  68. function getFixFunction(callExpression, functionInfo, context) {
  69. const sourceCode = context.getSourceCode();
  70. const [callback] = callExpression.arguments;
  71. const parameters = callback.params;
  72. const iterableObject = callExpression.callee.object;
  73. const {returnStatements} = functionInfo.get(callback);
  74. const isOptionalObject = callExpression.callee.optional;
  75. const ancestor = stripChainExpression(callExpression).parent;
  76. const objectText = sourceCode.getText(iterableObject);
  77. const getForOfLoopHeadText = () => {
  78. const [elementText, indexText] = parameters.map(parameter => sourceCode.getText(parameter));
  79. const shouldUseEntries = parameters.length === 2;
  80. let text = 'for (';
  81. text += isFunctionParameterVariableReassigned(callback, context) ? 'let' : 'const';
  82. text += ' ';
  83. text += shouldUseEntries ? `[${indexText}, ${elementText}]` : elementText;
  84. text += ' of ';
  85. const shouldAddParenthesesToObject
  86. = isParenthesized(iterableObject, sourceCode)
  87. || (
  88. // `1?.forEach()` -> `(1).entries()`
  89. isOptionalObject
  90. && shouldUseEntries
  91. && shouldAddParenthesesToMemberExpressionObject(iterableObject, sourceCode)
  92. );
  93. text += shouldAddParenthesesToObject ? `(${objectText})` : objectText;
  94. if (shouldUseEntries) {
  95. text += '.entries()';
  96. }
  97. text += ') ';
  98. return text;
  99. };
  100. const getForOfLoopHeadRange = () => {
  101. const [start] = callExpression.range;
  102. const [end] = getParenthesizedRange(callback.body, sourceCode);
  103. return [start, end];
  104. };
  105. function * replaceReturnStatement(returnStatement, fixer) {
  106. const returnToken = sourceCode.getFirstToken(returnStatement);
  107. assertToken(returnToken, {
  108. expected: 'return',
  109. ruleId: 'no-array-for-each',
  110. });
  111. if (!returnStatement.argument) {
  112. yield fixer.replaceText(returnToken, 'continue');
  113. return;
  114. }
  115. // Remove `return`
  116. yield fixer.remove(returnToken);
  117. const previousToken = sourceCode.getTokenBefore(returnToken);
  118. const nextToken = sourceCode.getTokenAfter(returnToken);
  119. let textBefore = '';
  120. let textAfter = '';
  121. const shouldAddParentheses
  122. = !isParenthesized(returnStatement.argument, sourceCode)
  123. && shouldAddParenthesesToExpressionStatementExpression(returnStatement.argument);
  124. if (shouldAddParentheses) {
  125. textBefore = `(${textBefore}`;
  126. textAfter = `${textAfter})`;
  127. }
  128. const insertBraces = shouldSwitchReturnStatementToBlockStatement(returnStatement);
  129. if (insertBraces) {
  130. textBefore = `{ ${textBefore}`;
  131. } else if (needsSemicolon(previousToken, sourceCode, shouldAddParentheses ? '(' : nextToken.value)) {
  132. textBefore = `;${textBefore}`;
  133. }
  134. if (textBefore) {
  135. yield fixer.insertTextBefore(nextToken, textBefore);
  136. }
  137. if (textAfter) {
  138. yield fixer.insertTextAfter(returnStatement.argument, textAfter);
  139. }
  140. const returnStatementHasSemicolon = isSemicolonToken(sourceCode.getLastToken(returnStatement));
  141. if (!returnStatementHasSemicolon) {
  142. yield fixer.insertTextAfter(returnStatement, ';');
  143. }
  144. yield fixer.insertTextAfter(returnStatement, ' continue;');
  145. if (insertBraces) {
  146. yield fixer.insertTextAfter(returnStatement, ' }');
  147. }
  148. }
  149. const shouldRemoveExpressionStatementLastToken = token => {
  150. if (!isSemicolonToken(token)) {
  151. return false;
  152. }
  153. if (callback.body.type !== 'BlockStatement') {
  154. return false;
  155. }
  156. return true;
  157. };
  158. function * removeCallbackParentheses(fixer) {
  159. // Opening parenthesis tokens already included in `getForOfLoopHeadRange`
  160. const closingParenthesisTokens = getParentheses(callback, sourceCode)
  161. .filter(token => isClosingParenToken(token));
  162. for (const closingParenthesisToken of closingParenthesisTokens) {
  163. yield fixer.remove(closingParenthesisToken);
  164. }
  165. }
  166. return function * (fixer) {
  167. // `(( foo.forEach(bar => bar) ))`
  168. yield * removeParentheses(callExpression, fixer, sourceCode);
  169. // Replace these with `for (const … of …) `
  170. // foo.forEach(bar => bar)
  171. // ^^^^^^^^^^^^^^^^^^^^^^
  172. // foo.forEach(bar => (bar))
  173. // ^^^^^^^^^^^^^^^^^^^^^^
  174. // foo.forEach(bar => {})
  175. // ^^^^^^^^^^^^^^^^^^^^^^
  176. // foo.forEach(function(bar) {})
  177. // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  178. yield fixer.replaceTextRange(getForOfLoopHeadRange(), getForOfLoopHeadText());
  179. // Parenthesized callback function
  180. // foo.forEach( ((bar => {})) )
  181. // ^^
  182. yield * removeCallbackParentheses(fixer);
  183. const [
  184. penultimateToken,
  185. lastToken,
  186. ] = sourceCode.getLastTokens(callExpression, 2);
  187. // The possible trailing comma token of `Array#forEach()` CallExpression
  188. // foo.forEach(bar => {},)
  189. // ^
  190. if (isCommaToken(penultimateToken)) {
  191. yield fixer.remove(penultimateToken);
  192. }
  193. // The closing parenthesis token of `Array#forEach()` CallExpression
  194. // foo.forEach(bar => {})
  195. // ^
  196. yield fixer.remove(lastToken);
  197. for (const returnStatement of returnStatements) {
  198. yield * replaceReturnStatement(returnStatement, fixer);
  199. }
  200. if (ancestor.type === 'ExpressionStatement') {
  201. const expressionStatementLastToken = sourceCode.getLastToken(ancestor);
  202. // Remove semicolon if it's not needed anymore
  203. // foo.forEach(bar => {});
  204. // ^
  205. if (shouldRemoveExpressionStatementLastToken(expressionStatementLastToken)) {
  206. yield fixer.remove(expressionStatementLastToken, fixer);
  207. }
  208. } else if (ancestor.type === 'ArrowFunctionExpression') {
  209. yield fixer.insertTextBefore(callExpression, '{ ');
  210. yield fixer.insertTextAfter(callExpression, ' }');
  211. }
  212. yield * fixSpaceAroundKeyword(fixer, callExpression.parent, sourceCode);
  213. if (isOptionalObject) {
  214. yield fixer.insertTextBefore(callExpression, `if (${objectText}) `);
  215. }
  216. // Prevent possible variable conflicts
  217. yield * extendFixRange(fixer, callExpression.parent.range);
  218. };
  219. }
  220. const isChildScope = (child, parent) => {
  221. for (let scope = child; scope; scope = scope.upper) {
  222. if (scope === parent) {
  223. return true;
  224. }
  225. }
  226. return false;
  227. };
  228. function isFunctionParametersSafeToFix(callbackFunction, {context, scope, callExpression, allIdentifiers}) {
  229. const variables = context.getDeclaredVariables(callbackFunction);
  230. for (const variable of variables) {
  231. if (variable.defs.length !== 1) {
  232. return false;
  233. }
  234. const [definition] = variable.defs;
  235. if (definition.type !== 'Parameter') {
  236. continue;
  237. }
  238. const variableName = definition.name.name;
  239. const [callExpressionStart, callExpressionEnd] = callExpression.range;
  240. for (const identifier of allIdentifiers) {
  241. const {name, range: [start, end]} = identifier;
  242. if (
  243. name !== variableName
  244. || start < callExpressionStart
  245. || end > callExpressionEnd
  246. ) {
  247. continue;
  248. }
  249. const variable = findVariable(scope, identifier);
  250. if (!variable || variable.scope === scope || isChildScope(scope, variable.scope)) {
  251. return false;
  252. }
  253. }
  254. }
  255. return true;
  256. }
  257. // TODO[@fisker]: Improve `./utils/is-left-hand-side.js` with similar logic
  258. function isAssignmentLeftHandSide(node) {
  259. const {parent} = node;
  260. switch (parent.type) {
  261. case 'AssignmentExpression':
  262. case 'ForInStatement':
  263. case 'ForOfStatement':
  264. return parent.left === node;
  265. case 'UpdateExpression':
  266. return parent.argument === node;
  267. case 'Property':
  268. return parent.value === node && isAssignmentLeftHandSide(parent);
  269. case 'AssignmentPattern':
  270. return parent.left === node && isAssignmentLeftHandSide(parent);
  271. case 'ArrayPattern':
  272. return parent.elements.includes(node) && isAssignmentLeftHandSide(parent);
  273. case 'ObjectPattern':
  274. return parent.properties.includes(node) && isAssignmentLeftHandSide(parent);
  275. default:
  276. return false;
  277. }
  278. }
  279. function isFunctionParameterVariableReassigned(callbackFunction, context) {
  280. return context.getDeclaredVariables(callbackFunction)
  281. .filter(variable => variable.defs[0].type === 'Parameter')
  282. .some(variable =>
  283. variable.references.some(reference => isAssignmentLeftHandSide(reference.identifier)),
  284. );
  285. }
  286. function isFixable(callExpression, {scope, functionInfo, allIdentifiers, context}) {
  287. // Check `CallExpression`
  288. if (callExpression.optional || callExpression.arguments.length !== 1) {
  289. return false;
  290. }
  291. // Check ancestors, we only fix `ExpressionStatement`
  292. const callOrChainExpression = stripChainExpression(callExpression);
  293. if (
  294. callOrChainExpression.parent.type !== 'ExpressionStatement'
  295. && !isArrowFunctionBody(callOrChainExpression)
  296. ) {
  297. return false;
  298. }
  299. // Check `CallExpression.arguments[0]`;
  300. const [callback] = callExpression.arguments;
  301. if (
  302. // Leave non-function type to `no-array-callback-reference` rule
  303. (callback.type !== 'FunctionExpression' && callback.type !== 'ArrowFunctionExpression')
  304. || callback.async
  305. || callback.generator
  306. ) {
  307. return false;
  308. }
  309. // Check `callback.params`
  310. const parameters = callback.params;
  311. if (
  312. !(parameters.length === 1 || parameters.length === 2)
  313. // `array.forEach((element = defaultValue) => {})`
  314. || (parameters.length === 1 && parameters[0].type === 'AssignmentPattern')
  315. || parameters.some(({type, typeAnnotation}) => type === 'RestElement' || typeAnnotation)
  316. || !isFunctionParametersSafeToFix(callback, {scope, callExpression, allIdentifiers, context})
  317. ) {
  318. return false;
  319. }
  320. // Check `ReturnStatement`s in `callback`
  321. const {returnStatements, scope: callbackScope} = functionInfo.get(callback);
  322. if (returnStatements.some(returnStatement => isReturnStatementInContinueAbleNodes(returnStatement, callback))) {
  323. return false;
  324. }
  325. if (isFunctionSelfUsedInside(callback, callbackScope)) {
  326. return false;
  327. }
  328. return true;
  329. }
  330. const ignoredObjects = [
  331. 'React.Children',
  332. 'Children',
  333. 'R',
  334. // https://www.npmjs.com/package/p-iteration
  335. 'pIteration',
  336. ];
  337. /** @param {import('eslint').Rule.RuleContext} context */
  338. const create = context => {
  339. const functionStack = [];
  340. const callExpressions = [];
  341. const allIdentifiers = [];
  342. const functionInfo = new Map();
  343. const sourceCode = context.getSourceCode();
  344. return {
  345. ':function'(node) {
  346. functionStack.push(node);
  347. functionInfo.set(node, {
  348. returnStatements: [],
  349. scope: context.getScope(),
  350. });
  351. },
  352. ':function:exit'() {
  353. functionStack.pop();
  354. },
  355. [referenceIdentifierSelector()](node) {
  356. allIdentifiers.push(node);
  357. },
  358. ':function ReturnStatement'(node) {
  359. const currentFunction = functionStack[functionStack.length - 1];
  360. const {returnStatements} = functionInfo.get(currentFunction);
  361. returnStatements.push(node);
  362. },
  363. [forEachMethodCallSelector](node) {
  364. if (isNodeMatches(node.callee.object, ignoredObjects)) {
  365. return;
  366. }
  367. callExpressions.push({
  368. node,
  369. scope: context.getScope(),
  370. });
  371. },
  372. * 'Program:exit'() {
  373. for (const {node, scope} of callExpressions) {
  374. const iterable = node.callee;
  375. const problem = {
  376. node: iterable.property,
  377. messageId: MESSAGE_ID_ERROR,
  378. };
  379. if (!isFixable(node, {scope, allIdentifiers, functionInfo, context})) {
  380. yield problem;
  381. continue;
  382. }
  383. const shouldUseSuggestion = iterable.optional && hasSideEffect(iterable, sourceCode);
  384. const fix = getFixFunction(node, functionInfo, context);
  385. if (shouldUseSuggestion) {
  386. problem.suggest = [
  387. {
  388. messageId: MESSAGE_ID_SUGGESTION,
  389. fix,
  390. },
  391. ];
  392. } else {
  393. problem.fix = fix;
  394. }
  395. yield problem;
  396. }
  397. },
  398. };
  399. };
  400. /** @type {import('eslint').Rule.RuleModule} */
  401. module.exports = {
  402. create,
  403. meta: {
  404. type: 'suggestion',
  405. docs: {
  406. description: 'Prefer `for…of` over the `forEach` method.',
  407. },
  408. fixable: 'code',
  409. hasSuggestions: true,
  410. messages,
  411. },
  412. };