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}; 300