Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add no-array-for-each rule #1017

Merged
merged 43 commits into from
Jan 19, 2021
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
ea0b64c
Add `no-array-for-each`
fisker Jan 11, 2021
2a638a3
Merge branch 'master' into no-array-for-each
fisker Jan 14, 2021
aa5786e
Refactor
fisker Jan 14, 2021
298e2e1
Tests
fisker Jan 14, 2021
59c70a7
Tests
fisker Jan 14, 2021
5c18e07
Tests
fisker Jan 14, 2021
507863f
fix
fisker Jan 14, 2021
4782010
Update snapshot
fisker Jan 14, 2021
b17181b
Fix
fisker Jan 14, 2021
f1e723b
Rename
fisker Jan 14, 2021
b57da17
Style
fisker Jan 14, 2021
f871353
Support check `this`
fisker Jan 18, 2021
3bc64aa
Support check `function.id` and `arguments`
fisker Jan 18, 2021
d9f6141
Fix wrongly fix
fisker Jan 18, 2021
6b0b592
More tests
fisker Jan 18, 2021
109ab2b
Merge branch 'master' into no-array-for-each
fisker Jan 18, 2021
a747d93
Fix logic
fisker Jan 18, 2021
4f374e5
Style
fisker Jan 18, 2021
dcafc69
Style
fisker Jan 18, 2021
1a7e6d4
More tests
fisker Jan 18, 2021
3f2c9ef
Clean
fisker Jan 18, 2021
bfa8ea0
Simplify array parentheses check
fisker Jan 18, 2021
a9c1713
Fix ASI problem
fisker Jan 18, 2021
71cd4eb
Fix parameter check
fisker Jan 18, 2021
2bcc256
Add `return` in `switch`
fisker Jan 18, 2021
8dac1cd
Fix parenthesized callback
fisker Jan 18, 2021
2599ae5
Keep `semi` for arrow functions
fisker Jan 18, 2021
edd49ba
Ignore unreachable
fisker Jan 18, 2021
80cde64
Fix style
fisker Jan 18, 2021
0917e76
Fix `arguments` check
fisker Jan 18, 2021
c33b426
Test possible conflicts
fisker Jan 18, 2021
078e93b
Extend fix range
fisker Jan 18, 2021
f035aca
Add docs
fisker Jan 18, 2021
4941a08
Fix `lint`
fisker Jan 18, 2021
e32cb9f
Rename a function
fisker Jan 18, 2021
34e5a58
Improve `=>` token search, fix crash on `typescript` parser
fisker Jan 18, 2021
6a91f93
Fix code style
fisker Jan 18, 2021
bc47538
One more test
fisker Jan 18, 2021
d8447b6
Merge branch 'master' into no-array-for-each
fisker Jan 19, 2021
de64278
Update no-array-for-each.js
fisker Jan 19, 2021
1f1dbb8
Rename file
fisker Jan 19, 2021
6982ad4
Update no-array-for-each.md
sindresorhus Jan 19, 2021
c767fe0
Update no-array-for-each.js
sindresorhus Jan 19, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/rules/no-array-for-each.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Prefer `for…of` over `Array#forEach(…)`.

<!-- More detailed description. Remove this comment. -->

This rule is partly fixable.

## Fail

```js
const foo = 'unicorn';
```

## Pass

```js
const foo = '🦄';
```
1 change: 1 addition & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ module.exports = {
'unicorn/new-for-builtins': 'error',
'unicorn/no-abusive-eslint-disable': 'error',
'unicorn/no-array-callback-reference': 'error',
'unicorn/no-array-for-each': 'error',
'unicorn/no-array-reduce': 'error',
'unicorn/no-console-spaces': 'error',
'unicorn/no-for-loop': 'error',
Expand Down
2 changes: 2 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ Configure it in `package.json`.
"unicorn/new-for-builtins": "error",
"unicorn/no-abusive-eslint-disable": "error",
"unicorn/no-array-callback-reference": "error",
"unicorn/no-array-for-each": "error",
"unicorn/no-array-reduce": "error",
"unicorn/no-console-spaces": "error",
"unicorn/no-for-loop": "error",
Expand Down Expand Up @@ -125,6 +126,7 @@ Configure it in `package.json`.
- [new-for-builtins](docs/rules/new-for-builtins.md) - Enforce the use of `new` for all builtins, except `String`, `Number`, `Boolean`, `Symbol` and `BigInt`. *(partly fixable)*
- [no-abusive-eslint-disable](docs/rules/no-abusive-eslint-disable.md) - Enforce specifying rules to disable in `eslint-disable` comments.
- [no-array-callback-reference](docs/rules/no-array-callback-reference.md) - Prevent passing a function reference directly to iterator methods.
- [no-array-for-each](docs/rules/no-array-for-each.md) - Prefer `for…of` over `Array#forEach(…)`. *(partly fixable)*
- [no-array-reduce](docs/rules/no-array-reduce.md) - Disallow `Array#reduce()` and `Array#reduceRight()`.
- [no-console-spaces](docs/rules/no-console-spaces.md) - Do not use leading/trailing space between `console.log` parameters. *(fixable)*
- [no-for-loop](docs/rules/no-for-loop.md) - Do not use a `for` loop that can be replaced with a `for-of` loop. *(partly fixable)*
Expand Down
284 changes: 284 additions & 0 deletions rules/no-array-for-each.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
'use strict';
const {isParenthesized, isArrowToken, isCommaToken, isSemicolonToken} = require('eslint-utils');
const getDocumentationUrl = require('./utils/get-documentation-url');
const methodSelector = require('./utils/method-selector');
const needsSemicolon = require('./utils/needs-semicolon');
const shouldAddParenthesesToMemberExpressionObject = require('./utils/should-add-parentheses-to-member-expression-object');
const shouldAddParenthesesToExpressionStatementExpression = require('./utils/should-add-parentheses-to-expression-statement-expression');

const MESSAGE_ID = 'no-array-for-each';
const messages = {
[MESSAGE_ID]: 'Do not use `Array#forEach(…)`.'
};

const arrayForEachCallSelector = methodSelector({
name: 'forEach',
includeOptional: true
});

const continueAbleNodeTypes = new Set([
'WhileStatement',
'DoWhileStatement',
'ForStatement',
'ForOfStatement',
'ForInStatement'
]);

function isReturnStatementInContinueAbleNodes(returnStatement, callbackFunction) {
for (let node = returnStatement; node && node !== callbackFunction; node = node.parent) {
if (continueAbleNodeTypes.has(node.type)) {
return true;
}
}

return false;
}

function getFixFunction(callExpression, sourceCode, functionReturnStatements) {
const [callback] = callExpression.arguments;
const parameters = callback.params;
const array = callExpression.callee.object;
const returnStatements = functionReturnStatements.get(callback);

const getForOfLoopHeadText = () => {
const parametersText = parameters.map(parameter => sourceCode.getText(parameter));
const useEntries = parameters.length === 2;

let text = 'for (const ';
text += useEntries ? `[${parametersText.join(', ')}]` : parametersText[0];

text += ' of ';

let arrayText = sourceCode.getText(array);
if (
isParenthesized(callExpression, sourceCode) ||
(useEntries && shouldAddParenthesesToMemberExpressionObject(array, sourceCode))
) {
arrayText = `(${arrayText})`;
}

text += arrayText;

if (useEntries) {
text += '.entries()';
}

text += ') ';

return text;
};

const getForOfLoopHeadRange = () => {
const [start] = callExpression.range;
let end;
if (callback.body.type === 'BlockStatement') {
end = callback.body.range[0];
} else {
const arrowToken = sourceCode.getFirstToken(callback, isArrowToken);
end = arrowToken.range[1];
}

return [start, end];
};

function * replaceReturnStatement(returnStatement, fixer) {
const returnToken = sourceCode.getFirstToken(returnStatement);

/* istanbul ignore next: `ReturnStatement` firstToken should be `return` */
if (returnToken.value !== 'return') {
throw new Error(`Unexpected token ${returnToken.value}.`);
}

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 = '(';
textAfter = ')';
}

const shouldAddSemicolonBefore = needsSemicolon(previousToken, sourceCode, shouldAddParentheses ? '(' : nextToken.value);
if (shouldAddSemicolonBefore) {
textBefore = `;${textBefore}`;
}

if (textBefore) {
yield fixer.insertTextBefore(nextToken, textBefore);
}

if (textAfter) {
yield fixer.insertTextAfter(returnStatement.argument, textAfter);
}

// If `returnStatement` has no semi
const lastToken = sourceCode.getLastToken(returnStatement);
yield fixer.insertTextAfter(
returnStatement,
`${isSemicolonToken(lastToken) ? '' : ';'} continue;`
);
}

const shouldRemoveExpressionStatementLastToken = (token) => {
if (!isSemicolonToken(token)) {
return false;
}

if (callback.body.type === 'BlockStatement') {
return true;
}

const nextToken = sourceCode.getTokenAfter(token);
if (nextToken && needsSemicolon(token, sourceCode, nextToken.value)) {
return false;
}

return true;
};

return function * (fixer) {
yield fixer.replaceTextRange(getForOfLoopHeadRange(), getForOfLoopHeadText());

// Remove call expression trailing comma
const [penultimateToken, lastToken] = sourceCode.getLastTokens(callExpression, 2);
if (isCommaToken(penultimateToken)) {
yield fixer.remove(penultimateToken);
}

yield fixer.remove(lastToken);

for (const returnStatement of returnStatements) {
yield * replaceReturnStatement(returnStatement, fixer);
}

const expressionStatementLastToken = sourceCode.getLastToken(callExpression.parent);
if (shouldRemoveExpressionStatementLastToken(expressionStatementLastToken)) {
yield fixer.remove(expressionStatementLastToken, fixer);
}
};
}

function isFixable(callExpression, sourceCode, functionReturnStatements) {
// Check `CallExpression`
if (
callExpression.optional ||
isParenthesized(callExpression, sourceCode) ||
callExpression.arguments.length !== 1
) {
return false;
}

// Check `CallExpression.parent`
if (callExpression.parent.type !== 'ExpressionStatement') {
return false;
}

// Check `CallExpression.callee`
if (callExpression.callee.optional) {
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) ||
parameters.some(parameter => parameter.type !== 'Identifier')
) {
return false;
}

// TODO: check parameters conflicts
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not really sure how to check this,

Should not fix

foo.forEach(foo => bar());

->

for (const foo of foo) bar();
a[foo].forEach(foo => bar());

->

for (const foo of a[foo]) bar();

Should fix

a((foo) => foo).forEach(foo => bar());

->

for (const foo of a((foo) => foo)) bar();

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Figured a way to detect this, but not very efficient.

Copy link
Collaborator Author

@fisker fisker Jan 18, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A similar logic maybe possible to auto fix destructuring parameters, but it's much complicated, I'm going to try in a seperate PR.


// Check `ReturnStatement`s in `callback`
const returnStatements = functionReturnStatements.get(callback);
if (returnStatements.some(returnStatement => isReturnStatementInContinueAbleNodes(returnStatement, callback))) {
return false;
}

// Check `callback` self
if (callback.type === 'FunctionExpression') {
// TODO: check `.id` `arguments` `this` of `FunctionExpression`
}

return true;
}

const create = context => {
const functionStacks = [];
const functionReturnStatements = new Map();
const callExpressions = [];

const sourceCode = context.getSourceCode();

return {
':function'(node) {
functionStacks.push(node);
functionReturnStatements.set(node, []);
},
':function:exit'() {
functionStacks.pop();
},
ReturnStatement(node) {
const currentFunction = functionStacks[functionStacks.length - 1];
// `globalReturn `
/* istanbul ignore next: ESLint deprecated `ecmaFeatures`, can't test */
if (!currentFunction) {
return;
}

const returnStatements = functionReturnStatements.get(currentFunction);
returnStatements.push(node);
},
[arrayForEachCallSelector](node) {
callExpressions.push(node);
},
'Program:exit'() {
for (const callExpression of callExpressions) {
const problem = {
node: callExpression.callee.property,
messageId: MESSAGE_ID
};

if (isFixable(callExpression, sourceCode, functionReturnStatements)) {
problem.fix = getFixFunction(callExpression, sourceCode, functionReturnStatements);
}

context.report(problem);
}
}
};
};

module.exports = {
create,
meta: {
type: 'suggestion',
docs: {
url: getDocumentationUrl(__filename)
},
fixable: 'code',
messages
}
};
8 changes: 6 additions & 2 deletions rules/utils/method-selector.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ module.exports = options => {
object,
min,
max,
property = ''
property = '',
includeOptional = false
} = {
min: 0,
max: Number.POSITIVE_INFINITY,
Expand All @@ -20,10 +21,13 @@ module.exports = options => {
const selector = [
`[${prefix}type="CallExpression"]`,
`[${prefix}callee.type="MemberExpression"]`,
`[${prefix}callee.computed=false]`,
`[${prefix}callee.property.type="Identifier"]`
];

if (!includeOptional) {
selector.push(`[${prefix}callee.computed=false]`);
}

if (name) {
selector.push(`[${prefix}callee.property.name="${name}"]`);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
'use strict';

/**
Check if parentheses should to be added to a `node` when it's used as an `expression` of `ExpressionStatement`.

@param {Node} node - The AST node to check.
@param {SourceCode} sourceCode - The source code object.
@returns {boolean}
*/
function shouldAddParenthesesToExpressionStatementExpression(node) {
switch (node.type) {
case 'ObjectExpression':
return true;
case 'AssignmentExpression':
return node.left.type === 'ObjectPattern' || node.left.type === 'ArrayPattern';
default:
return false;
}
}

module.exports = shouldAddParenthesesToExpressionStatementExpression;