Skip to content

Commit cda72bd

Browse files
fiskersindresorhus
andauthored
prefer-spread: Prefer ... over Array#concat() (#1029)
Co-authored-by: Sindre Sorhus <[email protected]>
1 parent 8d32574 commit cda72bd

14 files changed

+701
-64
lines changed

docs/rules/prefer-spread.md

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,25 @@
1-
# Prefer the spread operator over `Array.from()`
1+
# Prefer the spread operator over `Array.from()` and `Array#concat()`
22

3-
Enforces the use of the spread operator over `Array.from()`. This rule adds on to the built-in [prefer-spread](https://eslint.org/docs/rules/prefer-spread) rule, which only flags uses of `.apply()`. Does not enforce for `TypedArray.from()`;
4-
5-
This rule is fixable.
3+
Enforces the use of the spread operator over `Array.from()` and `Array#concat()`. This rule adds on to the built-in [prefer-spread](https://eslint.org/docs/rules/prefer-spread) rule, which only flags uses of `.apply()`. Does not enforce for `TypedArray.from()`;
64

5+
This rule is partly fixable.
76

87
## Fail
98

109
```js
11-
Array.from(set).map(() => {});
10+
Array.from(set).map(element => foo(element));
1211
```
1312

13+
```js
14+
const array = array1.concat(array2);
15+
```
1416

1517
## Pass
1618

1719
```js
18-
[...set].map(() => {});
20+
[...set].map(element => foo(element));
21+
```
22+
23+
```js
24+
const array = [...array1, ...array2];
1925
```

readme.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ Configure it in `package.json`.
173173
- [prefer-reflect-apply](docs/rules/prefer-reflect-apply.md) - Prefer `Reflect.apply()` over `Function#apply()`. *(fixable)*
174174
- [prefer-regexp-test](docs/rules/prefer-regexp-test.md) - Prefer `RegExp#test()` over `String#match()` and `RegExp#exec()`. *(fixable)*
175175
- [prefer-set-has](docs/rules/prefer-set-has.md) - Prefer `Set#has()` over `Array#includes()` when checking for existence or non-existence. *(fixable)*
176-
- [prefer-spread](docs/rules/prefer-spread.md) - Prefer the spread operator over `Array.from()`. *(fixable)*
176+
- [prefer-spread](docs/rules/prefer-spread.md) - Prefer the spread operator over `Array.from()` and `Array#concat()`. *(partly fixable)*
177177
- [prefer-string-replace-all](docs/rules/prefer-string-replace-all.md) - Prefer `String#replaceAll()` over regex searches with the global flag. *(fixable)*
178178
- [prefer-string-slice](docs/rules/prefer-string-slice.md) - Prefer `String#slice()` over `String#substr()` and `String#substring()`. *(partly fixable)*
179179
- [prefer-string-starts-ends-with](docs/rules/prefer-string-starts-ends-with.md) - Prefer `String#startsWith()` & `String#endsWith()` over `RegExp#test()`. *(fixable)*

rules/no-array-push-push.js

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
'use strict';
2-
const {hasSideEffect, isCommaToken, isOpeningParenToken, isSemicolonToken} = require('eslint-utils');
2+
const {hasSideEffect, isCommaToken, isSemicolonToken} = require('eslint-utils');
33
const getDocumentationUrl = require('./utils/get-documentation-url');
44
const methodSelector = require('./utils/method-selector');
5+
const getCallExpressionArgumentsText = require('./utils/get-call-expression-arguments-text');
56

67
const ERROR = 'error';
78
const SUGGESTION = 'suggestion';
@@ -20,16 +21,6 @@ const arrayPushExpressionStatement = [
2021

2122
const selector = `${arrayPushExpressionStatement} + ${arrayPushExpressionStatement}`;
2223

23-
const getCallExpressionArgumentsText = (node, sourceCode) => {
24-
const openingParenthesisToken = sourceCode.getTokenAfter(node.callee, isOpeningParenToken);
25-
const closingParenthesisToken = sourceCode.getLastToken(node);
26-
27-
return sourceCode.text.slice(
28-
openingParenthesisToken.range[1],
29-
closingParenthesisToken.range[0]
30-
);
31-
};
32-
3324
function getFirstExpression(node, sourceCode) {
3425
const {parent} = node;
3526
const visitorKeys = sourceCode.visitorKeys[parent.type] || Object.keys(parent);

rules/no-unused-properties.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ const create = context => {
129129
continue;
130130
}
131131

132-
const nextPath = path.concat(key);
132+
const nextPath = [...path, key];
133133

134134
const nextReferences = references
135135
.map(reference => {

rules/prefer-spread.js

Lines changed: 136 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
11
'use strict';
2+
const {getStaticValue, isCommaToken} = require('eslint-utils');
23
const getDocumentationUrl = require('./utils/get-documentation-url');
34
const methodSelector = require('./utils/method-selector');
45
const needsSemicolon = require('./utils/needs-semicolon');
6+
const getParentheses = require('./utils/get-parentheses');
7+
const getCallExpressionArgumentsText = require('./utils/get-call-expression-arguments-text');
58

6-
const MESSAGE_ID = 'prefer-spread';
9+
const ERROR_ARRAY_FROM = 'array-from';
10+
const ERROR_ARRAY_CONCAT = 'array-concat';
11+
const SUGGESTION_CONCAT_ARGUMENT_IS_SPREADABLE = 'argument-is-spreadable';
12+
const SUGGESTION_CONCAT_ARGUMENT_IS_NOT_SPREADABLE = 'argument-is-not-spreadable';
713
const messages = {
8-
[MESSAGE_ID]: 'Prefer the spread operator over `Array.from()`.'
14+
[ERROR_ARRAY_FROM]: 'Prefer the spread operator over `Array.from(…)`.',
15+
[ERROR_ARRAY_CONCAT]: 'Prefer the spread operator over `Array#concat(…)`.',
16+
[SUGGESTION_CONCAT_ARGUMENT_IS_SPREADABLE]: 'Argument of `Array#concat(…)` is an `array`.',
17+
[SUGGESTION_CONCAT_ARGUMENT_IS_NOT_SPREADABLE]: 'Argument of `Array#concat(…)` is not an `array`.'
918
};
1019

11-
const selector = [
20+
const arrayFromCallSelector = [
1221
methodSelector({
1322
object: 'Array',
1423
name: 'from',
@@ -19,15 +28,88 @@ const selector = [
1928
'[arguments.0.type!="ObjectExpression"]'
2029
].join('');
2130

31+
const arrayConcatCallSelector = [
32+
methodSelector({
33+
name: 'concat'
34+
}),
35+
`:not(${
36+
[
37+
'Literal',
38+
'TemplateLiteral'
39+
].map(type => `[callee.object.type="${type}"]`).join(', ')
40+
})`
41+
].join('');
42+
43+
const isArrayLiteral = node => node.type === 'ArrayExpression';
44+
45+
function fixConcat(node, sourceCode, isSpreadable) {
46+
const array = node.callee.object;
47+
const [item] = node.arguments;
48+
49+
const getRangeAfterArray = () => {
50+
const [, start] = getParenthesizedArrayRange();
51+
const [, end] = node.range;
52+
53+
return [start, end];
54+
};
55+
56+
const getParenthesizedArrayRange = () => {
57+
const [firstToken = array, lastToken = array] = getParentheses(array, sourceCode);
58+
59+
const [start] = firstToken.range;
60+
const [, end] = lastToken.range;
61+
return [start, end];
62+
};
63+
64+
const getFixedText = () => {
65+
if (isArrayLiteral(item)) {
66+
return sourceCode.getText(item, -1, -1);
67+
}
68+
69+
const text = getCallExpressionArgumentsText(node, sourceCode);
70+
return isSpreadable ? `...${text}` : text;
71+
};
72+
73+
return function * (fixer) {
74+
// Fixed code always starts with `[` or `(`
75+
if (needsSemicolon(sourceCode.getTokenBefore(node), sourceCode, '[')) {
76+
yield fixer.insertTextBefore(node, ';');
77+
}
78+
79+
const rangeAfterArray = getRangeAfterArray();
80+
let text = getFixedText();
81+
82+
if (isArrayLiteral(array)) {
83+
const [penultimateToken, closingBracketToken] = sourceCode.getLastTokens(array, 2);
84+
85+
if (array.elements.length > 0) {
86+
text = ` ${text}`;
87+
88+
if (!isCommaToken(penultimateToken)) {
89+
text = `,${text}`;
90+
}
91+
}
92+
93+
yield fixer.insertTextBefore(closingBracketToken, text);
94+
} else {
95+
yield fixer.insertTextBefore(node, '[...');
96+
yield fixer.insertTextAfterRange(getParenthesizedArrayRange(), `, ${text}`);
97+
yield fixer.insertTextAfter(node, ']');
98+
}
99+
100+
yield fixer.replaceTextRange(rangeAfterArray, '');
101+
};
102+
}
103+
22104
const create = context => {
23105
const sourceCode = context.getSourceCode();
24106
const getSource = node => sourceCode.getText(node);
25107

26108
return {
27-
[selector](node) {
109+
[arrayFromCallSelector](node) {
28110
context.report({
29111
node,
30-
messageId: MESSAGE_ID,
112+
messageId: ERROR_ARRAY_FROM,
31113
fix: fixer => {
32114
const [arrayLikeArgument, mapFn, thisArgument] = node.arguments.map(node => getSource(node));
33115
let replacement = `${
@@ -42,6 +124,55 @@ const create = context => {
42124
return fixer.replaceText(node, replacement);
43125
}
44126
});
127+
},
128+
[arrayConcatCallSelector](node) {
129+
const scope = context.getScope();
130+
const staticResult = getStaticValue(node.callee.object, scope);
131+
132+
if (staticResult && !Array.isArray(staticResult.value)) {
133+
return;
134+
}
135+
136+
const problem = {
137+
node: node.callee.property,
138+
messageId: ERROR_ARRAY_CONCAT
139+
};
140+
141+
const [item] = node.arguments;
142+
if (node.arguments.length !== 1 || item.type === 'SpreadElement') {
143+
context.report(problem);
144+
return;
145+
}
146+
147+
let isItemArray;
148+
if (isArrayLiteral(item)) {
149+
isItemArray = true;
150+
} else {
151+
const result = getStaticValue(item, scope);
152+
153+
if (result) {
154+
isItemArray = Array.isArray(result.value);
155+
}
156+
}
157+
158+
if (isItemArray === true) {
159+
problem.fix = fixConcat(node, sourceCode, /* isSpreadable */ true);
160+
} else if (isItemArray === false) {
161+
problem.fix = fixConcat(node, sourceCode, /* isSpreadable */ false);
162+
} else {
163+
problem.suggest = [
164+
{
165+
messageId: SUGGESTION_CONCAT_ARGUMENT_IS_SPREADABLE,
166+
fix: fixConcat(node, sourceCode, /* isSpreadable */ true)
167+
},
168+
{
169+
messageId: SUGGESTION_CONCAT_ARGUMENT_IS_NOT_SPREADABLE,
170+
fix: fixConcat(node, sourceCode, /* isSpreadable */ false)
171+
}
172+
];
173+
}
174+
175+
context.report(problem);
45176
}
46177
};
47178
};

rules/prevent-abbreviations.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -569,7 +569,7 @@ const create = context => {
569569
scope: variable.scope,
570570
defs: variable.defs,
571571
identifiers: variable.identifiers,
572-
references: variable.references.concat(outerClassVariable.references)
572+
references: [...variable.references, ...outerClassVariable.references]
573573
};
574574

575575
// Call the common checker with the newly forged normalized class variable
@@ -640,7 +640,10 @@ const create = context => {
640640
return;
641641
}
642642

643-
const scopes = variable.references.map(reference => reference.from).concat(variable.scope);
643+
const scopes = [
644+
...variable.references.map(reference => reference.from),
645+
variable.scope
646+
];
644647
variableReplacements.samples = variableReplacements.samples.map(
645648
name => avoidCapture(name, scopes, ecmaVersion, isSafeName)
646649
);
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
'use strict';
2+
const {isOpeningParenToken} = require('eslint-utils');
3+
4+
/**
5+
Get the text of the arguments list of `CallExpression`.
6+
7+
@param {Node} node - The `CallExpression` node.
8+
@param {SourceCode} sourceCode - The source code object.
9+
@returns {string}
10+
*/
11+
const getCallExpressionArgumentsText = (node, sourceCode) => {
12+
const openingParenthesisToken = sourceCode.getTokenAfter(node.callee, isOpeningParenToken);
13+
const closingParenthesisToken = sourceCode.getLastToken(node);
14+
15+
return sourceCode.text.slice(
16+
openingParenthesisToken.range[1],
17+
closingParenthesisToken.range[0]
18+
);
19+
};
20+
21+
module.exports = getCallExpressionArgumentsText;

rules/utils/get-parentheses.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
'use strict';
2+
const {isOpeningParenToken, isClosingParenToken} = require('eslint-utils');
3+
const getParenthesizedTimes = require('./get-parenthesized-times');
4+
5+
/*
6+
Get the first opening parenthesis token and the last closing parenthesis token of a parenthesized node.
7+
8+
@param {Node} node - The node to be checked.
9+
@param {SourceCode} sourceCode - The source code object.
10+
@returns {Token[]}
11+
*/
12+
function getParentheses(node, sourceCode) {
13+
const parenthesizedTimes = getParenthesizedTimes(node, sourceCode);
14+
15+
if (parenthesizedTimes > 0) {
16+
return [
17+
sourceCode.getTokenBefore(node, {skip: parenthesizedTimes - 1, filter: isOpeningParenToken}),
18+
sourceCode.getTokenAfter(node, {skip: parenthesizedTimes - 1, filter: isClosingParenToken})
19+
];
20+
}
21+
22+
return [];
23+
}
24+
25+
module.exports = getParentheses;

rules/utils/get-references.js

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
'use strict';
2-
const {uniq} = require('lodash');
2+
const {uniq, flatten} = require('lodash');
33

4-
const getReferences = scope => uniq(
5-
scope.references.concat(
6-
...scope.childScopes.map(scope => getReferences(scope))
7-
)
8-
);
4+
const getReferences = scope => uniq([
5+
...scope.references,
6+
...flatten(scope.childScopes.map(scope => getReferences(scope)))
7+
]);
98

109
module.exports = getReferences;

0 commit comments

Comments
 (0)