Skip to content

Commit 12b46da

Browse files
fiskersindresorhus
andauthored
Add prefer-array-find rule (#735)
Co-authored-by: Sindre Sorhus <[email protected]>
1 parent 16f6ef3 commit 12b46da

File tree

6 files changed

+1138
-1
lines changed

6 files changed

+1138
-1
lines changed

docs/rules/prefer-array-find.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Prefer `.find(…)` over the first element from `.filter(…)`
2+
3+
[`Array#find()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/find) breaks the loop as soon as it finds a match and doesn't create a new array.
4+
5+
This rule is fixable unless default values are used in declaration or assignment.
6+
7+
## Fail
8+
9+
```js
10+
const item = array.filter(x => x === '🦄')[0];
11+
```
12+
13+
```js
14+
const item = array.filter(x => x === '🦄').shift();
15+
```
16+
17+
```js
18+
const [item] = array.filter(x => x === '🦄');
19+
```
20+
21+
```js
22+
[item] = array.filter(x => x === '🦄');
23+
```
24+
25+
## Pass
26+
27+
```js
28+
const item = array.find(x => x === '🦄');
29+
```
30+
31+
```js
32+
item = array.find(x => x === '🦄');
33+
```

index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ module.exports = {
4848
'unicorn/no-zero-fractions': 'error',
4949
'unicorn/number-literal-case': 'error',
5050
'unicorn/prefer-add-event-listener': 'error',
51+
'unicorn/prefer-array-find': 'error',
5152
'unicorn/prefer-dataset': 'error',
5253
'unicorn/prefer-event-key': 'error',
5354
'unicorn/prefer-flat-map': 'error',

readme.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ Configure it in `package.json`.
6464
"unicorn/no-zero-fractions": "error",
6565
"unicorn/number-literal-case": "error",
6666
"unicorn/prefer-add-event-listener": "error",
67+
"unicorn/prefer-array-find": "error",
6768
"unicorn/prefer-dataset": "error",
6869
"unicorn/prefer-event-key": "error",
6970
"unicorn/prefer-flat-map": "error",
@@ -124,6 +125,7 @@ Configure it in `package.json`.
124125
- [no-zero-fractions](docs/rules/no-zero-fractions.md) - Disallow number literals with zero fractions or dangling dots. *(fixable)*
125126
- [number-literal-case](docs/rules/number-literal-case.md) - Enforce proper case for numeric literals. *(fixable)*
126127
- [prefer-add-event-listener](docs/rules/prefer-add-event-listener.md) - Prefer `.addEventListener()` and `.removeEventListener()` over `on`-functions. *(partly fixable)*
128+
- [prefer-array-find](docs/rules/prefer-array-find.md) - Prefer `.find(…)` over the first element from `.filter(…)`. *(partly fixable)*
127129
- [prefer-dataset](docs/rules/prefer-dataset.md) - Prefer using `.dataset` on DOM elements over `.setAttribute(…)`. *(fixable)*
128130
- [prefer-event-key](docs/rules/prefer-event-key.md) - Prefer `KeyboardEvent#key` over `KeyboardEvent#keyCode`. *(partly fixable)*
129131
- [prefer-flat-map](docs/rules/prefer-flat-map.md) - Prefer `.flatMap(…)` over `.map(…).flat()`. *(fixable)*

rules/prefer-array-find.js

Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
'use strict';
2+
const {isParenthesized, findVariable} = require('eslint-utils');
3+
const getDocumentationUrl = require('./utils/get-documentation-url');
4+
const methodSelector = require('./utils/method-selector');
5+
const getVariableIdentifiers = require('./utils/get-variable-identifiers');
6+
7+
const ERROR_ZERO_INDEX = 'error-zero-index';
8+
const ERROR_SHIFT = 'error-shift';
9+
const ERROR_DESTRUCTURING_DECLARATION = 'error-destructuring-declaration';
10+
const ERROR_DESTRUCTURING_ASSIGNMENT = 'error-destructuring-assignment';
11+
const ERROR_DECLARATION = 'error-variable';
12+
13+
const SUGGESTION_NULLISH_COALESCING_OPERATOR = 'suggest-nullish-coalescing-operator';
14+
const SUGGESTION_LOGICAL_OR_OPERATOR = 'suggest-logical-or-operator';
15+
16+
const filterMethodSelectorOptions = {
17+
name: 'filter',
18+
min: 1,
19+
max: 2
20+
};
21+
22+
const filterVariableSelector = [
23+
'VariableDeclaration',
24+
// Exclude `export const foo = [];`
25+
`:not(${
26+
[
27+
'ExportNamedDeclaration',
28+
'>',
29+
'VariableDeclaration.declaration'
30+
].join('')
31+
})`,
32+
'>',
33+
'VariableDeclarator.declarations',
34+
'[id.type="Identifier"]',
35+
methodSelector({
36+
...filterMethodSelectorOptions,
37+
property: 'init'
38+
})
39+
].join('');
40+
41+
const zeroIndexSelector = [
42+
'MemberExpression',
43+
'[computed=true]',
44+
'[property.type="Literal"]',
45+
'[property.raw="0"]',
46+
methodSelector({
47+
...filterMethodSelectorOptions,
48+
property: 'object'
49+
})
50+
].join('');
51+
52+
const shiftSelector = [
53+
methodSelector({
54+
name: 'shift',
55+
length: 0
56+
}),
57+
methodSelector({
58+
...filterMethodSelectorOptions,
59+
property: 'callee.object'
60+
})
61+
].join('');
62+
63+
const destructuringDeclaratorSelector = [
64+
'VariableDeclarator',
65+
'[id.type="ArrayPattern"]',
66+
'[id.elements.length=1]',
67+
'[id.elements.0.type!="RestElement"]',
68+
methodSelector({
69+
...filterMethodSelectorOptions,
70+
property: 'init'
71+
})
72+
].join('');
73+
74+
const destructuringAssignmentSelector = [
75+
'AssignmentExpression',
76+
'[left.type="ArrayPattern"]',
77+
'[left.elements.length=1]',
78+
'[left.elements.0.type!="RestElement"]',
79+
methodSelector({
80+
...filterMethodSelectorOptions,
81+
property: 'right'
82+
})
83+
].join('');
84+
85+
// Need add `()` to the `AssignmentExpression`
86+
// - `ObjectExpression`: `[{foo}] = array.filter(bar)` fix to `{foo} = array.find(bar)`
87+
// - `ObjectPattern`: `[{foo = baz}] = array.filter(bar)`
88+
const assignmentNeedParenthesize = (node, source) => {
89+
const isAssign = node.type === 'AssignmentExpression';
90+
91+
if (!isAssign || isParenthesized(node, source)) {
92+
return false;
93+
}
94+
95+
const {left} = getDestructuringLeftAndRight(node);
96+
const [element] = left.elements;
97+
const {type} = element.type === 'AssignmentPattern' ? element.left : element;
98+
return type === 'ObjectExpression' || type === 'ObjectPattern';
99+
};
100+
101+
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Operator_Precedence#Table
102+
const hasLowerPrecedence = (node, operator) => (
103+
(node.type === 'LogicalExpression' && (
104+
node.operator === operator ||
105+
// https://tc39.es/proposal-nullish-coalescing/ says
106+
// `??` has lower precedence than `||`
107+
// But MDN says
108+
// `??` has higher precedence than `||`
109+
(operator === '||' && node.operator === '??') ||
110+
(operator === '??' && (node.operator === '||' || node.operator === '&&'))
111+
)) ||
112+
node.type === 'ConditionalExpression' ||
113+
// Lower than `assignment`, should already parenthesized
114+
/* istanbul ignore next */
115+
node.type === 'AssignmentExpression' ||
116+
node.type === 'YieldExpression' ||
117+
node.type === 'SequenceExpression'
118+
);
119+
120+
const getDestructuringLeftAndRight = node => {
121+
/* istanbul ignore next */
122+
if (!node) {
123+
return {};
124+
}
125+
126+
if (node.type === 'AssignmentExpression') {
127+
return node;
128+
}
129+
130+
if (node.type === 'VariableDeclarator') {
131+
return {left: node.id, right: node.init};
132+
}
133+
134+
return {};
135+
};
136+
137+
const fixDestructuring = (node, source, fixer) => {
138+
const {left} = getDestructuringLeftAndRight(node);
139+
const [element] = left.elements;
140+
141+
const leftText = source.getText(element.type === 'AssignmentPattern' ? element.left : element);
142+
const fixes = [fixer.replaceText(left, leftText)];
143+
144+
// `AssignmentExpression` always starts with `[` or `(`, so we don't need check ASI
145+
if (assignmentNeedParenthesize(node, source)) {
146+
fixes.push(fixer.insertTextBefore(node, '('));
147+
fixes.push(fixer.insertTextAfter(node, ')'));
148+
}
149+
150+
return fixes;
151+
};
152+
153+
const hasDefaultValue = node => getDestructuringLeftAndRight(node).left.elements[0].type === 'AssignmentPattern';
154+
155+
const fixDestructuringDefaultValue = (node, source, fixer, operator) => {
156+
const {left, right} = getDestructuringLeftAndRight(node);
157+
const [element] = left.elements;
158+
const defaultValue = element.right;
159+
let defaultValueText = source.getText(defaultValue);
160+
161+
if (isParenthesized(defaultValue, source) || hasLowerPrecedence(defaultValue, operator)) {
162+
defaultValueText = `(${defaultValueText})`;
163+
}
164+
165+
return fixer.insertTextAfter(right, ` ${operator} ${defaultValueText}`);
166+
};
167+
168+
const fixDestructuringAndReplaceFilter = (source, node) => {
169+
const {property} = getDestructuringLeftAndRight(node).right.callee;
170+
171+
let suggest;
172+
let fix;
173+
174+
if (hasDefaultValue(node)) {
175+
suggest = [
176+
{operator: '??', messageId: SUGGESTION_NULLISH_COALESCING_OPERATOR},
177+
{operator: '||', messageId: SUGGESTION_LOGICAL_OR_OPERATOR}
178+
].map(({messageId, operator}) => ({
179+
messageId,
180+
fix: fixer => [
181+
fixer.replaceText(property, 'find'),
182+
fixDestructuringDefaultValue(node, source, fixer, operator),
183+
...fixDestructuring(node, source, fixer)
184+
]
185+
}));
186+
} else {
187+
fix = fixer => [
188+
fixer.replaceText(property, 'find'),
189+
...fixDestructuring(node, source, fixer)
190+
];
191+
}
192+
193+
return {fix, suggest};
194+
};
195+
196+
const isAccessingZeroIndex = node =>
197+
node.parent &&
198+
node.parent.type === 'MemberExpression' &&
199+
node.parent.computed === true &&
200+
node.parent.object === node &&
201+
node.parent.property &&
202+
node.parent.property.type === 'Literal' &&
203+
node.parent.property.raw === '0';
204+
205+
const isDestructuringFirstElement = node => {
206+
const {left, right} = getDestructuringLeftAndRight(node.parent);
207+
return left &&
208+
right &&
209+
right === node &&
210+
left.type === 'ArrayPattern' &&
211+
left.elements &&
212+
left.elements.length === 1 &&
213+
left.elements[0].type !== 'RestElement';
214+
};
215+
216+
const create = context => {
217+
const source = context.getSourceCode();
218+
219+
return {
220+
[zeroIndexSelector](node) {
221+
context.report({
222+
node: node.object.callee.property,
223+
messageId: ERROR_ZERO_INDEX,
224+
fix: fixer => [
225+
fixer.replaceText(node.object.callee.property, 'find'),
226+
fixer.removeRange([node.object.range[1], node.range[1]])
227+
]
228+
});
229+
},
230+
[shiftSelector](node) {
231+
context.report({
232+
node: node.callee.object.callee.property,
233+
messageId: ERROR_SHIFT,
234+
fix: fixer => [
235+
fixer.replaceText(node.callee.object.callee.property, 'find'),
236+
fixer.removeRange([node.callee.object.range[1], node.range[1]])
237+
]
238+
});
239+
},
240+
[destructuringDeclaratorSelector](node) {
241+
context.report({
242+
node: node.init.callee.property,
243+
messageId: ERROR_DESTRUCTURING_DECLARATION,
244+
...fixDestructuringAndReplaceFilter(source, node)
245+
});
246+
},
247+
[destructuringAssignmentSelector](node) {
248+
context.report({
249+
node: node.right.callee.property,
250+
messageId: ERROR_DESTRUCTURING_ASSIGNMENT,
251+
...fixDestructuringAndReplaceFilter(source, node)
252+
});
253+
},
254+
[filterVariableSelector](node) {
255+
const variable = findVariable(context.getScope(), node.id);
256+
const identifiers = getVariableIdentifiers(variable).filter(identifier => identifier !== node.id);
257+
258+
if (identifiers.length === 0) {
259+
return;
260+
}
261+
262+
const zeroIndexNodes = [];
263+
const destructuringNodes = [];
264+
for (const identifier of identifiers) {
265+
if (isAccessingZeroIndex(identifier)) {
266+
zeroIndexNodes.push(identifier.parent);
267+
} else if (isDestructuringFirstElement(identifier)) {
268+
destructuringNodes.push(identifier.parent);
269+
} else {
270+
return;
271+
}
272+
}
273+
274+
const problem = {
275+
node: node.init.callee.property,
276+
messageId: ERROR_DECLARATION
277+
};
278+
279+
// `const [foo = bar] = baz` is not fixable
280+
if (!destructuringNodes.some(node => hasDefaultValue(node))) {
281+
problem.fix = fixer => {
282+
const fixes = [
283+
fixer.replaceText(node.init.callee.property, 'find')
284+
];
285+
286+
for (const node of zeroIndexNodes) {
287+
fixes.push(fixer.removeRange([node.object.range[1], node.range[1]]));
288+
}
289+
290+
for (const node of destructuringNodes) {
291+
fixes.push(...fixDestructuring(node, source, fixer));
292+
}
293+
294+
return fixes;
295+
};
296+
}
297+
298+
context.report(problem);
299+
}
300+
};
301+
};
302+
303+
module.exports = {
304+
create,
305+
meta: {
306+
type: 'suggestion',
307+
docs: {
308+
url: getDocumentationUrl(__filename)
309+
},
310+
fixable: 'code',
311+
messages: {
312+
[ERROR_DECLARATION]: 'Prefer `.find(…)` over `.filter(…)`.',
313+
[ERROR_ZERO_INDEX]: 'Prefer `.find(…)` over `.filter(…)[0]`.',
314+
[ERROR_SHIFT]: 'Prefer `.find(…)` over `.filter(…).shift()`.',
315+
[ERROR_DESTRUCTURING_DECLARATION]: 'Prefer `.find(…)` over destructuring `.filter(…)`.',
316+
// Same message as `ERROR_DESTRUCTURING_DECLARATION`, but different case
317+
[ERROR_DESTRUCTURING_ASSIGNMENT]: 'Prefer `.find(…)` over destructuring `.filter(…)`.',
318+
[SUGGESTION_NULLISH_COALESCING_OPERATOR]: 'Replace `.filter(…)` with `.find(…) ?? …`.',
319+
[SUGGESTION_LOGICAL_OR_OPERATOR]: 'Replace `.filter(…)` with `.find(…) || …`.'
320+
}
321+
}
322+
};

rules/utils/method-selector.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ module.exports = options => {
2525
];
2626

2727
if (name) {
28-
selector.push(`[callee.property.name="${name}"]`);
28+
selector.push(`[${prefix}callee.property.name="${name}"]`);
2929
}
3030

3131
if (Array.isArray(names) && names.length !== 0) {

0 commit comments

Comments
 (0)