Skip to content

Commit 6b341b6

Browse files
authored
Add prefer-iterator-to-array-at-end rule (#3041)
1 parent 47356fa commit 6b341b6

7 files changed

Lines changed: 710 additions & 0 deletions
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# prefer-iterator-to-array-at-end
2+
3+
📝 Prefer moving `.toArray()` to the end of iterator helper chains.
4+
5+
💼 This rule is enabled in the following [configs](https://github.com/sindresorhus/eslint-plugin-unicorn#recommended-config): ✅ `recommended`, ☑️ `unopinionated`.
6+
7+
💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).
8+
9+
<!-- end auto-generated rule header -->
10+
11+
Prefer moving `Iterator#toArray()` to the end of iterator helper chains.
12+
13+
Iterator helpers are lazy. Calling `toArray()` before helper methods like `map()` or `filter()` creates a temporary array and makes the rest of the chain use `Array` methods instead of lazy `Iterator` methods.
14+
15+
## Examples
16+
17+
```js
18+
//
19+
const result = iterator.toArray().map(fn);
20+
21+
//
22+
const result = iterator.map(fn).toArray();
23+
```
24+
25+
```js
26+
//
27+
const result = iterator.toArray().filter(fn);
28+
29+
//
30+
const result = iterator.filter(fn).toArray();
31+
```
32+
33+
Cases are reported as suggestions instead of autofixes because moving `toArray()` changes when callbacks run: `Array` methods run after the iterator has been exhausted, while `Iterator` helpers run lazily as the result is consumed. `Array` callbacks also receive an extra `array` argument that `Iterator` callbacks do not.
34+
35+
`flatMap()` has an additional difference: `Array#flatMap()` accepts non-iterable callback results, while `Iterator#flatMap()` requires iterable results.
36+
37+
```js
38+
//
39+
const result = iterator.toArray().flatMap(fn);
40+
41+
//
42+
const result = iterator.flatMap(fn).toArray();
43+
```
44+
45+
This rule only handles direct lazy helper equivalents. It intentionally does not convert `Array#slice()` to `Iterator#take()` or `Iterator#drop()`.

readme.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ export default [
173173
| [prefer-import-meta-properties](docs/rules/prefer-import-meta-properties.md) | Prefer `import.meta.{dirname,filename}` over legacy techniques for getting file paths. | | 🔧 | |
174174
| [prefer-includes](docs/rules/prefer-includes.md) | Prefer `.includes()` over `.indexOf()`, `.lastIndexOf()`, and `Array#some()` when checking for existence or non-existence. | ✅ ☑️ | 🔧 | 💡 |
175175
| [prefer-iterator-concat](docs/rules/prefer-iterator-concat.md) | Prefer `Iterator.concat(…)` over temporary spread arrays. | | 🔧 | 💡 |
176+
| [prefer-iterator-to-array-at-end](docs/rules/prefer-iterator-to-array-at-end.md) | Prefer moving `.toArray()` to the end of iterator helper chains. | ✅ ☑️ | | 💡 |
176177
| [prefer-keyboard-event-key](docs/rules/prefer-keyboard-event-key.md) | Prefer `KeyboardEvent#key` over deprecated keyboard event properties. | ✅ ☑️ | 🔧 | 💡 |
177178
| [prefer-logical-operator-over-ternary](docs/rules/prefer-logical-operator-over-ternary.md) | Prefer using a logical operator over a ternary. | ✅ ☑️ | | 💡 |
178179
| [prefer-math-abs](docs/rules/prefer-math-abs.md) | Prefer `Math.abs()` over manual absolute value expressions and symmetric range checks. | ✅ ☑️ | 🔧 | |

rules/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ export {default as 'prefer-https'} from './prefer-https.js';
114114
export {default as 'prefer-import-meta-properties'} from './prefer-import-meta-properties.js';
115115
export {default as 'prefer-includes'} from './prefer-includes.js';
116116
export {default as 'prefer-iterator-concat'} from './prefer-iterator-concat.js';
117+
export {default as 'prefer-iterator-to-array-at-end'} from './prefer-iterator-to-array-at-end.js';
117118
export {default as 'prefer-keyboard-event-key'} from './prefer-keyboard-event-key.js';
118119
export {default as 'prefer-logical-operator-over-ternary'} from './prefer-logical-operator-over-ternary.js';
119120
export {default as 'prefer-math-abs'} from './prefer-math-abs.js';
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import {
2+
getCallExpressionArgumentsText,
3+
getCallExpressionTokens,
4+
getParenthesizedText,
5+
} from './utils/index.js';
6+
import {isMethodCall} from './ast/index.js';
7+
8+
const MESSAGE_ID = 'prefer-iterator-to-array-at-end';
9+
10+
const methods = [
11+
'filter',
12+
'flatMap',
13+
'map',
14+
];
15+
16+
const messages = {
17+
[MESSAGE_ID]: 'Move `.toArray()` after `.{{method}}(…)`.',
18+
};
19+
20+
const isToArrayCall = node => isMethodCall(node, {
21+
method: 'toArray',
22+
argumentsLength: 0,
23+
optionalCall: false,
24+
optionalMember: false,
25+
});
26+
27+
const hasArrayParameter = node => (
28+
node.type === 'ArrowFunctionExpression'
29+
|| node.type === 'FunctionExpression'
30+
) && (
31+
node.params.length > 2
32+
|| node.params.some(parameter => parameter.type === 'RestElement')
33+
);
34+
35+
const getReplacementText = (toArrayCall, methodCall, context, openingParenthesisToken) => {
36+
const {sourceCode} = context;
37+
const iteratorText = getParenthesizedText(toArrayCall.callee.object, context);
38+
const method = methodCall.callee.property.name;
39+
const argumentsText = getCallExpressionArgumentsText(context, methodCall);
40+
const [, methodNameEnd] = sourceCode.getRange(methodCall.callee.property);
41+
const [openingParenthesisStart] = sourceCode.getRange(openingParenthesisToken);
42+
const textBetweenMethodAndArguments = sourceCode.text.slice(methodNameEnd, openingParenthesisStart);
43+
44+
return `${iteratorText}.${method}${textBetweenMethodAndArguments}(${argumentsText}).toArray()`;
45+
};
46+
47+
const getFix = (toArrayCall, methodCall, context) => {
48+
const {sourceCode} = context;
49+
const {
50+
openingParenthesisToken,
51+
closingParenthesisToken,
52+
} = getCallExpressionTokens(methodCall, context);
53+
const [, methodNameEnd] = sourceCode.getRange(methodCall.callee.property);
54+
const [argumentsEnd] = sourceCode.getRange(closingParenthesisToken);
55+
56+
if (sourceCode.getCommentsInside(methodCall).some(comment => {
57+
const [start, end] = sourceCode.getRange(comment);
58+
59+
return start < methodNameEnd || end > argumentsEnd;
60+
})) {
61+
return;
62+
}
63+
64+
return fixer => fixer.replaceText(methodCall, getReplacementText(toArrayCall, methodCall, context, openingParenthesisToken));
65+
};
66+
67+
/** @param {import('eslint').Rule.RuleContext} context */
68+
const create = context => {
69+
context.on('CallExpression', node => {
70+
if (
71+
!isMethodCall(node, {
72+
methods,
73+
argumentsLength: 1,
74+
optionalCall: false,
75+
optionalMember: false,
76+
})
77+
|| !isToArrayCall(node.callee.object)
78+
|| hasArrayParameter(node.arguments[0])
79+
) {
80+
return;
81+
}
82+
83+
const method = node.callee.property.name;
84+
const toArrayCall = node.callee.object;
85+
const fix = getFix(toArrayCall, node, context);
86+
const problem = {
87+
node: toArrayCall.callee.property,
88+
messageId: MESSAGE_ID,
89+
data: {method},
90+
};
91+
92+
if (!fix) {
93+
return problem;
94+
}
95+
96+
return {
97+
...problem,
98+
suggest: [
99+
{
100+
messageId: MESSAGE_ID,
101+
data: {method},
102+
fix,
103+
},
104+
],
105+
};
106+
});
107+
};
108+
109+
/** @type {import('eslint').Rule.RuleModule} */
110+
const config = {
111+
create,
112+
meta: {
113+
type: 'suggestion',
114+
docs: {
115+
description: 'Prefer moving `.toArray()` to the end of iterator helper chains.',
116+
recommended: 'unopinionated',
117+
},
118+
hasSuggestions: true,
119+
messages,
120+
},
121+
};
122+
123+
export default config;
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import outdent from 'outdent';
2+
import {getTester, parsers} from './utils/test.js';
3+
4+
const {test} = getTester(import.meta);
5+
6+
test.snapshot({
7+
valid: [
8+
// Already at the end.
9+
'iterator.map(fn).toArray()',
10+
'iterator.filter(fn).toArray()',
11+
'iterator.flatMap(fn).toArray()',
12+
13+
// Not `.toArray()`.
14+
'iterator.map(fn)',
15+
'iterator.filter(fn)',
16+
'array.toSorted().map(fn)',
17+
18+
// `.toArray()` with arguments.
19+
'iterator.toArray(true).map(fn)',
20+
'iterator.toArray(true).filter(fn)',
21+
'iterator.toArray(true).flatMap(fn)',
22+
23+
// Optional chaining.
24+
'iterator?.toArray().map(fn)',
25+
'iterator.toArray?.().map(fn)',
26+
'iterator.toArray()?.map(fn)',
27+
'iterator.toArray().map?.(fn)',
28+
29+
// Computed properties.
30+
'iterator["toArray"]().map(fn)',
31+
'iterator.toArray()["map"](fn)',
32+
33+
// Extra arguments.
34+
'iterator.toArray().map(fn, thisArgument)',
35+
'iterator.toArray().filter(fn, thisArgument)',
36+
'iterator.toArray().flatMap(fn, thisArgument)',
37+
'iterator.toArray().map(...arguments_)',
38+
39+
// Array callbacks receive the 3rd `array` argument, Iterator callbacks do not.
40+
'iterator.toArray().map((value, index, array) => array.length)',
41+
'iterator.toArray().filter(function (value, index, array) { return array.length; })',
42+
'iterator.toArray().flatMap((value, index, array) => array)',
43+
'iterator.toArray().map((...values) => values.length)',
44+
45+
// Other methods are handled by other rules or intentionally ignored.
46+
'iterator.toArray().every(fn)',
47+
'iterator.toArray().find(fn)',
48+
'iterator.toArray().forEach(fn)',
49+
'iterator.toArray().reduce(fn, init)',
50+
'iterator.toArray().some(fn)',
51+
'iterator.toArray().slice(1)',
52+
'iterator.toArray().sort()',
53+
'iterator.toArray().at(0)',
54+
'iterator.toArray().length',
55+
],
56+
invalid: [
57+
'iterator.toArray().map(fn)',
58+
'iterator.toArray().filter(fn)',
59+
'iterator.toArray().flatMap(fn)',
60+
'iterator.toArray().filter(function (value) { return value; })',
61+
62+
// Inline callbacks.
63+
'iterator.toArray().map(value => value * 2)',
64+
'iterator.toArray().filter((value, index) => index > 0)',
65+
'iterator.toArray().flatMap(value => [value])',
66+
'iterator.toArray().flatMap(value => value)',
67+
68+
// Parenthesized.
69+
'(iterator.toArray()).map(fn)',
70+
'((iterator.toArray())).filter(fn)',
71+
'((iterator).toArray()).flatMap(fn)',
72+
73+
// Complex receivers.
74+
'getIterator().toArray().map(fn)',
75+
'(condition ? iterator : otherIterator).toArray().filter(fn)',
76+
77+
// Chained methods are handled one helper at a time.
78+
'iterator.toArray().map(mapper).filter(predicate)',
79+
80+
// Comments inside the helper call arguments are preserved.
81+
'iterator.toArray().map(/* comment */ fn)',
82+
83+
// Comments inside `.toArray()` are reported without a fix.
84+
'iterator.toArray(/* comment */).map(fn)',
85+
86+
// Comments between `.toArray()` and the helper method are reported without a fix.
87+
'iterator.toArray() /* comment */ .map(value => value)',
88+
89+
// Comments before helper arguments are preserved.
90+
'iterator.toArray().map /* comment */ (value => value)',
91+
92+
// Multiline.
93+
outdent`
94+
const result = iterator
95+
.toArray()
96+
.map(value => value * 2);
97+
`,
98+
99+
// TypeScript.
100+
{
101+
code: '(iterator as Iterator<number>).toArray().map(value => value * 2)',
102+
languageOptions: {
103+
parser: parsers.typescript,
104+
},
105+
},
106+
{
107+
code: 'iterator.toArray().map<string>(value => value)',
108+
languageOptions: {
109+
parser: parsers.typescript,
110+
},
111+
},
112+
{
113+
code: 'iterator!.toArray().filter(Boolean)',
114+
languageOptions: {
115+
parser: parsers.typescript,
116+
},
117+
},
118+
],
119+
});

0 commit comments

Comments
 (0)