UNPKG

10.7 kBJavaScriptView Raw
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"use strict";
8
9const { CALL, ReferenceTracker } = require("eslint-utils");
10const {
11 isCommaToken,
12 isOpeningParenToken,
13 isClosingParenToken,
14 isParenthesised
15} = require("./utils/ast-utils");
16
17const 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 */
24function 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 */
33function 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 */
43function 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 */
52function 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 */
65function 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 */
88function 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 */
107function 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 */
134function 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 */
161function 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 */
179function 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
243module.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};