no-array-for-each.js 13 KB

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