1 | /**
|
2 | * @fileoverview Prefers object spread property over Object.assign
|
3 | * @author Sharmila Jesupaul
|
4 | * See LICENSE file in root directory for full license.
|
5 | */
|
6 |
|
7 | ;
|
8 |
|
9 | const { CALL, ReferenceTracker } = require("eslint-utils");
|
10 | const {
|
11 | isCommaToken,
|
12 | isOpeningParenToken,
|
13 | isClosingParenToken,
|
14 | isParenthesised
|
15 | } = require("./utils/ast-utils");
|
16 |
|
17 | const ANY_SPACE = /\s/u;
|
18 |
|
19 | /**
|
20 | * Helper that checks if the Object.assign call has array spread
|
21 | * @param {ASTNode} node The node that the rule warns on
|
22 | * @returns {boolean} - Returns true if the Object.assign call has array spread
|
23 | */
|
24 | function hasArraySpread(node) {
|
25 | return node.arguments.some(arg => arg.type === "SpreadElement");
|
26 | }
|
27 |
|
28 | /**
|
29 | * Determines whether the given node is an accessor property (getter/setter).
|
30 | * @param {ASTNode} node Node to check.
|
31 | * @returns {boolean} `true` if the node is a getter or a setter.
|
32 | */
|
33 | function isAccessorProperty(node) {
|
34 | return node.type === "Property" &&
|
35 | (node.kind === "get" || node.kind === "set");
|
36 | }
|
37 |
|
38 | /**
|
39 | * Determines whether the given object expression node has accessor properties (getters/setters).
|
40 | * @param {ASTNode} node `ObjectExpression` node to check.
|
41 | * @returns {boolean} `true` if the node has at least one getter/setter.
|
42 | */
|
43 | function hasAccessors(node) {
|
44 | return node.properties.some(isAccessorProperty);
|
45 | }
|
46 |
|
47 | /**
|
48 | * Determines whether the given call expression node has object expression arguments with accessor properties (getters/setters).
|
49 | * @param {ASTNode} node `CallExpression` node to check.
|
50 | * @returns {boolean} `true` if the node has at least one argument that is an object expression with at least one getter/setter.
|
51 | */
|
52 | function hasArgumentsWithAccessors(node) {
|
53 | return node.arguments
|
54 | .filter(arg => arg.type === "ObjectExpression")
|
55 | .some(hasAccessors);
|
56 | }
|
57 |
|
58 | /**
|
59 | * Helper that checks if the node needs parentheses to be valid JS.
|
60 | * The default is to wrap the node in parentheses to avoid parsing errors.
|
61 | * @param {ASTNode} node The node that the rule warns on
|
62 | * @param {Object} sourceCode in context sourcecode object
|
63 | * @returns {boolean} - Returns true if the node needs parentheses
|
64 | */
|
65 | function needsParens(node, sourceCode) {
|
66 | const parent = node.parent;
|
67 |
|
68 | switch (parent.type) {
|
69 | case "VariableDeclarator":
|
70 | case "ArrayExpression":
|
71 | case "ReturnStatement":
|
72 | case "CallExpression":
|
73 | case "Property":
|
74 | return false;
|
75 | case "AssignmentExpression":
|
76 | return parent.left === node && !isParenthesised(sourceCode, node);
|
77 | default:
|
78 | return !isParenthesised(sourceCode, node);
|
79 | }
|
80 | }
|
81 |
|
82 | /**
|
83 | * Determines if an argument needs parentheses. The default is to not add parens.
|
84 | * @param {ASTNode} node The node to be checked.
|
85 | * @param {Object} sourceCode in context sourcecode object
|
86 | * @returns {boolean} True if the node needs parentheses
|
87 | */
|
88 | function argNeedsParens(node, sourceCode) {
|
89 | switch (node.type) {
|
90 | case "AssignmentExpression":
|
91 | case "ArrowFunctionExpression":
|
92 | case "ConditionalExpression":
|
93 | return !isParenthesised(sourceCode, node);
|
94 | default:
|
95 | return false;
|
96 | }
|
97 | }
|
98 |
|
99 | /**
|
100 | * Get the parenthesis tokens of a given ObjectExpression node.
|
101 | * This includes the braces of the object literal and enclosing parentheses.
|
102 | * @param {ASTNode} node The node to get.
|
103 | * @param {Token} leftArgumentListParen The opening paren token of the argument list.
|
104 | * @param {SourceCode} sourceCode The source code object to get tokens.
|
105 | * @returns {Token[]} The parenthesis tokens of the node. This is sorted by the location.
|
106 | */
|
107 | function getParenTokens(node, leftArgumentListParen, sourceCode) {
|
108 | const parens = [sourceCode.getFirstToken(node), sourceCode.getLastToken(node)];
|
109 | let leftNext = sourceCode.getTokenBefore(node);
|
110 | let rightNext = sourceCode.getTokenAfter(node);
|
111 |
|
112 | // Note: don't include the parens of the argument list.
|
113 | while (
|
114 | leftNext &&
|
115 | rightNext &&
|
116 | leftNext.range[0] > leftArgumentListParen.range[0] &&
|
117 | isOpeningParenToken(leftNext) &&
|
118 | isClosingParenToken(rightNext)
|
119 | ) {
|
120 | parens.push(leftNext, rightNext);
|
121 | leftNext = sourceCode.getTokenBefore(leftNext);
|
122 | rightNext = sourceCode.getTokenAfter(rightNext);
|
123 | }
|
124 |
|
125 | return parens.sort((a, b) => a.range[0] - b.range[0]);
|
126 | }
|
127 |
|
128 | /**
|
129 | * Get the range of a given token and around whitespaces.
|
130 | * @param {Token} token The token to get range.
|
131 | * @param {SourceCode} sourceCode The source code object to get tokens.
|
132 | * @returns {number} The end of the range of the token and around whitespaces.
|
133 | */
|
134 | function getStartWithSpaces(token, sourceCode) {
|
135 | const text = sourceCode.text;
|
136 | let start = token.range[0];
|
137 |
|
138 | // If the previous token is a line comment then skip this step to avoid commenting this token out.
|
139 | {
|
140 | const prevToken = sourceCode.getTokenBefore(token, { includeComments: true });
|
141 |
|
142 | if (prevToken && prevToken.type === "Line") {
|
143 | return start;
|
144 | }
|
145 | }
|
146 |
|
147 | // Detect spaces before the token.
|
148 | while (ANY_SPACE.test(text[start - 1] || "")) {
|
149 | start -= 1;
|
150 | }
|
151 |
|
152 | return start;
|
153 | }
|
154 |
|
155 | /**
|
156 | * Get the range of a given token and around whitespaces.
|
157 | * @param {Token} token The token to get range.
|
158 | * @param {SourceCode} sourceCode The source code object to get tokens.
|
159 | * @returns {number} The start of the range of the token and around whitespaces.
|
160 | */
|
161 | function getEndWithSpaces(token, sourceCode) {
|
162 | const text = sourceCode.text;
|
163 | let end = token.range[1];
|
164 |
|
165 | // Detect spaces after the token.
|
166 | while (ANY_SPACE.test(text[end] || "")) {
|
167 | end += 1;
|
168 | }
|
169 |
|
170 | return end;
|
171 | }
|
172 |
|
173 | /**
|
174 | * Autofixes the Object.assign call to use an object spread instead.
|
175 | * @param {ASTNode|null} node The node that the rule warns on, i.e. the Object.assign call
|
176 | * @param {string} sourceCode sourceCode of the Object.assign call
|
177 | * @returns {Function} autofixer - replaces the Object.assign with a spread object.
|
178 | */
|
179 | function defineFixer(node, sourceCode) {
|
180 | return function *(fixer) {
|
181 | const leftParen = sourceCode.getTokenAfter(node.callee, isOpeningParenToken);
|
182 | const rightParen = sourceCode.getLastToken(node);
|
183 |
|
184 | // Remove everything before the opening paren: callee `Object.assign`, type arguments, and whitespace between the callee and the paren.
|
185 | yield fixer.removeRange([node.range[0], leftParen.range[0]]);
|
186 |
|
187 | // Replace the parens of argument list to braces.
|
188 | if (needsParens(node, sourceCode)) {
|
189 | yield fixer.replaceText(leftParen, "({");
|
190 | yield fixer.replaceText(rightParen, "})");
|
191 | } else {
|
192 | yield fixer.replaceText(leftParen, "{");
|
193 | yield fixer.replaceText(rightParen, "}");
|
194 | }
|
195 |
|
196 | // Process arguments.
|
197 | for (const argNode of node.arguments) {
|
198 | const innerParens = getParenTokens(argNode, leftParen, sourceCode);
|
199 | const left = innerParens.shift();
|
200 | const right = innerParens.pop();
|
201 |
|
202 | if (argNode.type === "ObjectExpression") {
|
203 | const maybeTrailingComma = sourceCode.getLastToken(argNode, 1);
|
204 | const maybeArgumentComma = sourceCode.getTokenAfter(right);
|
205 |
|
206 | /*
|
207 | * Make bare this object literal.
|
208 | * And remove spaces inside of the braces for better formatting.
|
209 | */
|
210 | for (const innerParen of innerParens) {
|
211 | yield fixer.remove(innerParen);
|
212 | }
|
213 | const leftRange = [left.range[0], getEndWithSpaces(left, sourceCode)];
|
214 | const rightRange = [
|
215 | Math.max(getStartWithSpaces(right, sourceCode), leftRange[1]), // Ensure ranges don't overlap
|
216 | right.range[1]
|
217 | ];
|
218 |
|
219 | yield fixer.removeRange(leftRange);
|
220 | yield fixer.removeRange(rightRange);
|
221 |
|
222 | // Remove the comma of this argument if it's duplication.
|
223 | if (
|
224 | (argNode.properties.length === 0 || isCommaToken(maybeTrailingComma)) &&
|
225 | isCommaToken(maybeArgumentComma)
|
226 | ) {
|
227 | yield fixer.remove(maybeArgumentComma);
|
228 | }
|
229 | } else {
|
230 |
|
231 | // Make spread.
|
232 | if (argNeedsParens(argNode, sourceCode)) {
|
233 | yield fixer.insertTextBefore(left, "...(");
|
234 | yield fixer.insertTextAfter(right, ")");
|
235 | } else {
|
236 | yield fixer.insertTextBefore(left, "...");
|
237 | }
|
238 | }
|
239 | }
|
240 | };
|
241 | }
|
242 |
|
243 | module.exports = {
|
244 | meta: {
|
245 | type: "suggestion",
|
246 |
|
247 | docs: {
|
248 | description:
|
249 | "disallow using Object.assign with an object literal as the first argument and prefer the use of object spread instead.",
|
250 | category: "Stylistic Issues",
|
251 | recommended: false,
|
252 | url: "https://eslint.org/docs/rules/prefer-object-spread"
|
253 | },
|
254 |
|
255 | schema: [],
|
256 | fixable: "code",
|
257 |
|
258 | messages: {
|
259 | useSpreadMessage: "Use an object spread instead of `Object.assign` eg: `{ ...foo }`.",
|
260 | useLiteralMessage: "Use an object literal instead of `Object.assign`. eg: `{ foo: bar }`."
|
261 | }
|
262 | },
|
263 |
|
264 | create(context) {
|
265 | const sourceCode = context.getSourceCode();
|
266 |
|
267 | return {
|
268 | Program() {
|
269 | const scope = context.getScope();
|
270 | const tracker = new ReferenceTracker(scope);
|
271 | const trackMap = {
|
272 | Object: {
|
273 | assign: { [CALL]: true }
|
274 | }
|
275 | };
|
276 |
|
277 | // Iterate all calls of `Object.assign` (only of the global variable `Object`).
|
278 | for (const { node } of tracker.iterateGlobalReferences(trackMap)) {
|
279 | if (
|
280 | node.arguments.length >= 1 &&
|
281 | node.arguments[0].type === "ObjectExpression" &&
|
282 | !hasArraySpread(node) &&
|
283 | !(
|
284 | node.arguments.length > 1 &&
|
285 | hasArgumentsWithAccessors(node)
|
286 | )
|
287 | ) {
|
288 | const messageId = node.arguments.length === 1
|
289 | ? "useLiteralMessage"
|
290 | : "useSpreadMessage";
|
291 | const fix = defineFixer(node, sourceCode);
|
292 |
|
293 | context.report({ node, messageId, fix });
|
294 | }
|
295 | }
|
296 | }
|
297 | };
|
298 | }
|
299 | };
|