no-array-for-each.js 12 KB

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