467 lines
13 KiB
JavaScript
467 lines
13 KiB
JavaScript
|
'use strict';
|
||
|
const {
|
||
|
isParenthesized,
|
||
|
isCommaToken,
|
||
|
isSemicolonToken,
|
||
|
isClosingParenToken,
|
||
|
findVariable,
|
||
|
hasSideEffect,
|
||
|
} = require('@eslint-community/eslint-utils');
|
||
|
const {methodCallSelector, referenceIdentifierSelector} = require('./selectors/index.js');
|
||
|
const {extendFixRange} = require('./fix/index.js');
|
||
|
const needsSemicolon = require('./utils/needs-semicolon.js');
|
||
|
const shouldAddParenthesesToExpressionStatementExpression = require('./utils/should-add-parentheses-to-expression-statement-expression.js');
|
||
|
const shouldAddParenthesesToMemberExpressionObject = require('./utils/should-add-parentheses-to-member-expression-object.js');
|
||
|
const {getParentheses, getParenthesizedRange} = require('./utils/parentheses.js');
|
||
|
const isFunctionSelfUsedInside = require('./utils/is-function-self-used-inside.js');
|
||
|
const {isNodeMatches} = require('./utils/is-node-matches.js');
|
||
|
const assertToken = require('./utils/assert-token.js');
|
||
|
const {fixSpaceAroundKeyword, removeParentheses} = require('./fix/index.js');
|
||
|
const {isArrowFunctionBody} = require('./ast/index.js');
|
||
|
|
||
|
const MESSAGE_ID_ERROR = 'no-array-for-each/error';
|
||
|
const MESSAGE_ID_SUGGESTION = 'no-array-for-each/suggestion';
|
||
|
const messages = {
|
||
|
[MESSAGE_ID_ERROR]: 'Use `for…of` instead of `.forEach(…)`.',
|
||
|
[MESSAGE_ID_SUGGESTION]: 'Switch to `for…of`.',
|
||
|
};
|
||
|
|
||
|
const forEachMethodCallSelector = methodCallSelector({
|
||
|
method: 'forEach',
|
||
|
includeOptionalCall: true,
|
||
|
includeOptionalMember: true,
|
||
|
});
|
||
|
|
||
|
const continueAbleNodeTypes = new Set([
|
||
|
'WhileStatement',
|
||
|
'DoWhileStatement',
|
||
|
'ForStatement',
|
||
|
'ForOfStatement',
|
||
|
'ForInStatement',
|
||
|
]);
|
||
|
|
||
|
const stripChainExpression = node =>
|
||
|
(node.parent.type === 'ChainExpression' && node.parent.expression === node)
|
||
|
? node.parent
|
||
|
: node;
|
||
|
|
||
|
function isReturnStatementInContinueAbleNodes(returnStatement, callbackFunction) {
|
||
|
for (let node = returnStatement; node && node !== callbackFunction; node = node.parent) {
|
||
|
if (continueAbleNodeTypes.has(node.type)) {
|
||
|
return true;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
function shouldSwitchReturnStatementToBlockStatement(returnStatement) {
|
||
|
const {parent} = returnStatement;
|
||
|
|
||
|
switch (parent.type) {
|
||
|
case 'IfStatement': {
|
||
|
return parent.consequent === returnStatement || parent.alternate === returnStatement;
|
||
|
}
|
||
|
|
||
|
// These parent's body need switch to `BlockStatement` too, but since they are "continueAble", won't fix
|
||
|
// case 'ForStatement':
|
||
|
// case 'ForInStatement':
|
||
|
// case 'ForOfStatement':
|
||
|
// case 'WhileStatement':
|
||
|
// case 'DoWhileStatement':
|
||
|
case 'WithStatement': {
|
||
|
return parent.body === returnStatement;
|
||
|
}
|
||
|
|
||
|
default: {
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function getFixFunction(callExpression, functionInfo, context) {
|
||
|
const sourceCode = context.getSourceCode();
|
||
|
const [callback] = callExpression.arguments;
|
||
|
const parameters = callback.params;
|
||
|
const iterableObject = callExpression.callee.object;
|
||
|
const {returnStatements} = functionInfo.get(callback);
|
||
|
const isOptionalObject = callExpression.callee.optional;
|
||
|
const ancestor = stripChainExpression(callExpression).parent;
|
||
|
const objectText = sourceCode.getText(iterableObject);
|
||
|
|
||
|
const getForOfLoopHeadText = () => {
|
||
|
const [elementText, indexText] = parameters.map(parameter => sourceCode.getText(parameter));
|
||
|
const shouldUseEntries = parameters.length === 2;
|
||
|
|
||
|
let text = 'for (';
|
||
|
text += isFunctionParameterVariableReassigned(callback, context) ? 'let' : 'const';
|
||
|
text += ' ';
|
||
|
text += shouldUseEntries ? `[${indexText}, ${elementText}]` : elementText;
|
||
|
text += ' of ';
|
||
|
|
||
|
const shouldAddParenthesesToObject
|
||
|
= isParenthesized(iterableObject, sourceCode)
|
||
|
|| (
|
||
|
// `1?.forEach()` -> `(1).entries()`
|
||
|
isOptionalObject
|
||
|
&& shouldUseEntries
|
||
|
&& shouldAddParenthesesToMemberExpressionObject(iterableObject, sourceCode)
|
||
|
);
|
||
|
|
||
|
text += shouldAddParenthesesToObject ? `(${objectText})` : objectText;
|
||
|
|
||
|
if (shouldUseEntries) {
|
||
|
text += '.entries()';
|
||
|
}
|
||
|
|
||
|
text += ') ';
|
||
|
|
||
|
return text;
|
||
|
};
|
||
|
|
||
|
const getForOfLoopHeadRange = () => {
|
||
|
const [start] = callExpression.range;
|
||
|
const [end] = getParenthesizedRange(callback.body, sourceCode);
|
||
|
return [start, end];
|
||
|
};
|
||
|
|
||
|
function * replaceReturnStatement(returnStatement, fixer) {
|
||
|
const returnToken = sourceCode.getFirstToken(returnStatement);
|
||
|
assertToken(returnToken, {
|
||
|
expected: 'return',
|
||
|
ruleId: 'no-array-for-each',
|
||
|
});
|
||
|
|
||
|
if (!returnStatement.argument) {
|
||
|
yield fixer.replaceText(returnToken, 'continue');
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Remove `return`
|
||
|
yield fixer.remove(returnToken);
|
||
|
|
||
|
const previousToken = sourceCode.getTokenBefore(returnToken);
|
||
|
const nextToken = sourceCode.getTokenAfter(returnToken);
|
||
|
let textBefore = '';
|
||
|
let textAfter = '';
|
||
|
const shouldAddParentheses
|
||
|
= !isParenthesized(returnStatement.argument, sourceCode)
|
||
|
&& shouldAddParenthesesToExpressionStatementExpression(returnStatement.argument);
|
||
|
if (shouldAddParentheses) {
|
||
|
textBefore = `(${textBefore}`;
|
||
|
textAfter = `${textAfter})`;
|
||
|
}
|
||
|
|
||
|
const insertBraces = shouldSwitchReturnStatementToBlockStatement(returnStatement);
|
||
|
if (insertBraces) {
|
||
|
textBefore = `{ ${textBefore}`;
|
||
|
} else if (needsSemicolon(previousToken, sourceCode, shouldAddParentheses ? '(' : nextToken.value)) {
|
||
|
textBefore = `;${textBefore}`;
|
||
|
}
|
||
|
|
||
|
if (textBefore) {
|
||
|
yield fixer.insertTextBefore(nextToken, textBefore);
|
||
|
}
|
||
|
|
||
|
if (textAfter) {
|
||
|
yield fixer.insertTextAfter(returnStatement.argument, textAfter);
|
||
|
}
|
||
|
|
||
|
const returnStatementHasSemicolon = isSemicolonToken(sourceCode.getLastToken(returnStatement));
|
||
|
if (!returnStatementHasSemicolon) {
|
||
|
yield fixer.insertTextAfter(returnStatement, ';');
|
||
|
}
|
||
|
|
||
|
yield fixer.insertTextAfter(returnStatement, ' continue;');
|
||
|
|
||
|
if (insertBraces) {
|
||
|
yield fixer.insertTextAfter(returnStatement, ' }');
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const shouldRemoveExpressionStatementLastToken = token => {
|
||
|
if (!isSemicolonToken(token)) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
if (callback.body.type !== 'BlockStatement') {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
return true;
|
||
|
};
|
||
|
|
||
|
function * removeCallbackParentheses(fixer) {
|
||
|
// Opening parenthesis tokens already included in `getForOfLoopHeadRange`
|
||
|
const closingParenthesisTokens = getParentheses(callback, sourceCode)
|
||
|
.filter(token => isClosingParenToken(token));
|
||
|
|
||
|
for (const closingParenthesisToken of closingParenthesisTokens) {
|
||
|
yield fixer.remove(closingParenthesisToken);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return function * (fixer) {
|
||
|
// `(( foo.forEach(bar => bar) ))`
|
||
|
yield * removeParentheses(callExpression, fixer, sourceCode);
|
||
|
|
||
|
// Replace these with `for (const … of …) `
|
||
|
// foo.forEach(bar => bar)
|
||
|
// ^^^^^^^^^^^^^^^^^^^^^^
|
||
|
// foo.forEach(bar => (bar))
|
||
|
// ^^^^^^^^^^^^^^^^^^^^^^
|
||
|
// foo.forEach(bar => {})
|
||
|
// ^^^^^^^^^^^^^^^^^^^^^^
|
||
|
// foo.forEach(function(bar) {})
|
||
|
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||
|
yield fixer.replaceTextRange(getForOfLoopHeadRange(), getForOfLoopHeadText());
|
||
|
|
||
|
// Parenthesized callback function
|
||
|
// foo.forEach( ((bar => {})) )
|
||
|
// ^^
|
||
|
yield * removeCallbackParentheses(fixer);
|
||
|
|
||
|
const [
|
||
|
penultimateToken,
|
||
|
lastToken,
|
||
|
] = sourceCode.getLastTokens(callExpression, 2);
|
||
|
|
||
|
// The possible trailing comma token of `Array#forEach()` CallExpression
|
||
|
// foo.forEach(bar => {},)
|
||
|
// ^
|
||
|
if (isCommaToken(penultimateToken)) {
|
||
|
yield fixer.remove(penultimateToken);
|
||
|
}
|
||
|
|
||
|
// The closing parenthesis token of `Array#forEach()` CallExpression
|
||
|
// foo.forEach(bar => {})
|
||
|
// ^
|
||
|
yield fixer.remove(lastToken);
|
||
|
|
||
|
for (const returnStatement of returnStatements) {
|
||
|
yield * replaceReturnStatement(returnStatement, fixer);
|
||
|
}
|
||
|
|
||
|
if (ancestor.type === 'ExpressionStatement') {
|
||
|
const expressionStatementLastToken = sourceCode.getLastToken(ancestor);
|
||
|
// Remove semicolon if it's not needed anymore
|
||
|
// foo.forEach(bar => {});
|
||
|
// ^
|
||
|
if (shouldRemoveExpressionStatementLastToken(expressionStatementLastToken)) {
|
||
|
yield fixer.remove(expressionStatementLastToken, fixer);
|
||
|
}
|
||
|
} else if (ancestor.type === 'ArrowFunctionExpression') {
|
||
|
yield fixer.insertTextBefore(callExpression, '{ ');
|
||
|
yield fixer.insertTextAfter(callExpression, ' }');
|
||
|
}
|
||
|
|
||
|
yield * fixSpaceAroundKeyword(fixer, callExpression.parent, sourceCode);
|
||
|
|
||
|
if (isOptionalObject) {
|
||
|
yield fixer.insertTextBefore(callExpression, `if (${objectText}) `);
|
||
|
}
|
||
|
|
||
|
// Prevent possible variable conflicts
|
||
|
yield * extendFixRange(fixer, callExpression.parent.range);
|
||
|
};
|
||
|
}
|
||
|
|
||
|
const isChildScope = (child, parent) => {
|
||
|
for (let scope = child; scope; scope = scope.upper) {
|
||
|
if (scope === parent) {
|
||
|
return true;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return false;
|
||
|
};
|
||
|
|
||
|
function isFunctionParametersSafeToFix(callbackFunction, {context, scope, callExpression, allIdentifiers}) {
|
||
|
const variables = context.getDeclaredVariables(callbackFunction);
|
||
|
|
||
|
for (const variable of variables) {
|
||
|
if (variable.defs.length !== 1) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
const [definition] = variable.defs;
|
||
|
if (definition.type !== 'Parameter') {
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
const variableName = definition.name.name;
|
||
|
const [callExpressionStart, callExpressionEnd] = callExpression.range;
|
||
|
for (const identifier of allIdentifiers) {
|
||
|
const {name, range: [start, end]} = identifier;
|
||
|
if (
|
||
|
name !== variableName
|
||
|
|| start < callExpressionStart
|
||
|
|| end > callExpressionEnd
|
||
|
) {
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
const variable = findVariable(scope, identifier);
|
||
|
if (!variable || variable.scope === scope || isChildScope(scope, variable.scope)) {
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
function isFunctionParameterVariableReassigned(callbackFunction, context) {
|
||
|
return context.getDeclaredVariables(callbackFunction)
|
||
|
.filter(variable => variable.defs[0].type === 'Parameter')
|
||
|
.some(variable =>
|
||
|
variable.references.some(reference => !reference.init && reference.isWrite()),
|
||
|
);
|
||
|
}
|
||
|
|
||
|
function isFixable(callExpression, {scope, functionInfo, allIdentifiers, context}) {
|
||
|
// Check `CallExpression`
|
||
|
if (callExpression.optional || callExpression.arguments.length !== 1) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
// Check ancestors, we only fix `ExpressionStatement`
|
||
|
const callOrChainExpression = stripChainExpression(callExpression);
|
||
|
if (
|
||
|
callOrChainExpression.parent.type !== 'ExpressionStatement'
|
||
|
&& !isArrowFunctionBody(callOrChainExpression)
|
||
|
) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
// Check `CallExpression.arguments[0]`;
|
||
|
const [callback] = callExpression.arguments;
|
||
|
if (
|
||
|
// Leave non-function type to `no-array-callback-reference` rule
|
||
|
(callback.type !== 'FunctionExpression' && callback.type !== 'ArrowFunctionExpression')
|
||
|
|| callback.async
|
||
|
|| callback.generator
|
||
|
) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
// Check `callback.params`
|
||
|
const parameters = callback.params;
|
||
|
if (
|
||
|
!(parameters.length === 1 || parameters.length === 2)
|
||
|
// `array.forEach((element = defaultValue) => {})`
|
||
|
|| (parameters.length === 1 && parameters[0].type === 'AssignmentPattern')
|
||
|
// https://github.com/sindresorhus/eslint-plugin-unicorn/issues/1814
|
||
|
|| (parameters.length === 2 && parameters[1].type !== 'Identifier')
|
||
|
|| parameters.some(({type, typeAnnotation}) => type === 'RestElement' || typeAnnotation)
|
||
|
|| !isFunctionParametersSafeToFix(callback, {scope, callExpression, allIdentifiers, context})
|
||
|
) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
// Check `ReturnStatement`s in `callback`
|
||
|
const {returnStatements, scope: callbackScope} = functionInfo.get(callback);
|
||
|
if (returnStatements.some(returnStatement => isReturnStatementInContinueAbleNodes(returnStatement, callback))) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
if (isFunctionSelfUsedInside(callback, callbackScope)) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
const ignoredObjects = [
|
||
|
'React.Children',
|
||
|
'Children',
|
||
|
'R',
|
||
|
// https://www.npmjs.com/package/p-iteration
|
||
|
'pIteration',
|
||
|
];
|
||
|
|
||
|
/** @param {import('eslint').Rule.RuleContext} context */
|
||
|
const create = context => {
|
||
|
const functionStack = [];
|
||
|
const callExpressions = [];
|
||
|
const allIdentifiers = [];
|
||
|
const functionInfo = new Map();
|
||
|
const sourceCode = context.getSourceCode();
|
||
|
|
||
|
return {
|
||
|
':function'(node) {
|
||
|
functionStack.push(node);
|
||
|
functionInfo.set(node, {
|
||
|
returnStatements: [],
|
||
|
scope: context.getScope(),
|
||
|
});
|
||
|
},
|
||
|
':function:exit'() {
|
||
|
functionStack.pop();
|
||
|
},
|
||
|
[referenceIdentifierSelector()](node) {
|
||
|
allIdentifiers.push(node);
|
||
|
},
|
||
|
':function ReturnStatement'(node) {
|
||
|
const currentFunction = functionStack[functionStack.length - 1];
|
||
|
const {returnStatements} = functionInfo.get(currentFunction);
|
||
|
returnStatements.push(node);
|
||
|
},
|
||
|
[forEachMethodCallSelector](node) {
|
||
|
if (isNodeMatches(node.callee.object, ignoredObjects)) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
callExpressions.push({
|
||
|
node,
|
||
|
scope: context.getScope(),
|
||
|
});
|
||
|
},
|
||
|
* 'Program:exit'() {
|
||
|
for (const {node, scope} of callExpressions) {
|
||
|
const iterable = node.callee;
|
||
|
|
||
|
const problem = {
|
||
|
node: iterable.property,
|
||
|
messageId: MESSAGE_ID_ERROR,
|
||
|
};
|
||
|
|
||
|
if (!isFixable(node, {scope, allIdentifiers, functionInfo, context})) {
|
||
|
yield problem;
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
const shouldUseSuggestion = iterable.optional && hasSideEffect(iterable, sourceCode);
|
||
|
const fix = getFixFunction(node, functionInfo, context);
|
||
|
|
||
|
if (shouldUseSuggestion) {
|
||
|
problem.suggest = [
|
||
|
{
|
||
|
messageId: MESSAGE_ID_SUGGESTION,
|
||
|
fix,
|
||
|
},
|
||
|
];
|
||
|
} else {
|
||
|
problem.fix = fix;
|
||
|
}
|
||
|
|
||
|
yield problem;
|
||
|
}
|
||
|
},
|
||
|
};
|
||
|
};
|
||
|
|
||
|
/** @type {import('eslint').Rule.RuleModule} */
|
||
|
module.exports = {
|
||
|
create,
|
||
|
meta: {
|
||
|
type: 'suggestion',
|
||
|
docs: {
|
||
|
description: 'Prefer `for…of` over the `forEach` method.',
|
||
|
},
|
||
|
fixable: 'code',
|
||
|
hasSuggestions: true,
|
||
|
messages,
|
||
|
},
|
||
|
};
|