/** * @fileoverview Prefers object spread property over Object.assign * @author Sharmila Jesupaul * See LICENSE file in root directory for full license. */ "use strict"; const { CALL, ReferenceTracker } = require("eslint-utils"); const { isCommaToken, isOpeningParenToken, isClosingParenToken, isParenthesised } = require("./utils/ast-utils"); const ANY_SPACE = /\s/u; /** * Helper that checks if the Object.assign call has array spread * @param {ASTNode} node The node that the rule warns on * @returns {boolean} - Returns true if the Object.assign call has array spread */ function hasArraySpread(node) { return node.arguments.some(arg => arg.type === "SpreadElement"); } /** * Determines whether the given node is an accessor property (getter/setter). * @param {ASTNode} node Node to check. * @returns {boolean} `true` if the node is a getter or a setter. */ function isAccessorProperty(node) { return node.type === "Property" && (node.kind === "get" || node.kind === "set"); } /** * Determines whether the given object expression node has accessor properties (getters/setters). * @param {ASTNode} node `ObjectExpression` node to check. * @returns {boolean} `true` if the node has at least one getter/setter. */ function hasAccessors(node) { return node.properties.some(isAccessorProperty); } /** * Determines whether the given call expression node has object expression arguments with accessor properties (getters/setters). * @param {ASTNode} node `CallExpression` node to check. * @returns {boolean} `true` if the node has at least one argument that is an object expression with at least one getter/setter. */ function hasArgumentsWithAccessors(node) { return node.arguments .filter(arg => arg.type === "ObjectExpression") .some(hasAccessors); } /** * Helper that checks if the node needs parentheses to be valid JS. * The default is to wrap the node in parentheses to avoid parsing errors. * @param {ASTNode} node The node that the rule warns on * @param {Object} sourceCode in context sourcecode object * @returns {boolean} - Returns true if the node needs parentheses */ function needsParens(node, sourceCode) { const parent = node.parent; switch (parent.type) { case "VariableDeclarator": case "ArrayExpression": case "ReturnStatement": case "CallExpression": case "Property": return false; case "AssignmentExpression": return parent.left === node && !isParenthesised(sourceCode, node); default: return !isParenthesised(sourceCode, node); } } /** * Determines if an argument needs parentheses. The default is to not add parens. * @param {ASTNode} node The node to be checked. * @param {Object} sourceCode in context sourcecode object * @returns {boolean} True if the node needs parentheses */ function argNeedsParens(node, sourceCode) { switch (node.type) { case "AssignmentExpression": case "ArrowFunctionExpression": case "ConditionalExpression": return !isParenthesised(sourceCode, node); default: return false; } } /** * Get the parenthesis tokens of a given ObjectExpression node. * This includes the braces of the object literal and enclosing parentheses. * @param {ASTNode} node The node to get. * @param {Token} leftArgumentListParen The opening paren token of the argument list. * @param {SourceCode} sourceCode The source code object to get tokens. * @returns {Token[]} The parenthesis tokens of the node. This is sorted by the location. */ function getParenTokens(node, leftArgumentListParen, sourceCode) { const parens = [sourceCode.getFirstToken(node), sourceCode.getLastToken(node)]; let leftNext = sourceCode.getTokenBefore(node); let rightNext = sourceCode.getTokenAfter(node); // Note: don't include the parens of the argument list. while ( leftNext && rightNext && leftNext.range[0] > leftArgumentListParen.range[0] && isOpeningParenToken(leftNext) && isClosingParenToken(rightNext) ) { parens.push(leftNext, rightNext); leftNext = sourceCode.getTokenBefore(leftNext); rightNext = sourceCode.getTokenAfter(rightNext); } return parens.sort((a, b) => a.range[0] - b.range[0]); } /** * Get the range of a given token and around whitespaces. * @param {Token} token The token to get range. * @param {SourceCode} sourceCode The source code object to get tokens. * @returns {number} The end of the range of the token and around whitespaces. */ function getStartWithSpaces(token, sourceCode) { const text = sourceCode.text; let start = token.range[0]; // If the previous token is a line comment then skip this step to avoid commenting this token out. { const prevToken = sourceCode.getTokenBefore(token, { includeComments: true }); if (prevToken && prevToken.type === "Line") { return start; } } // Detect spaces before the token. while (ANY_SPACE.test(text[start - 1] || "")) { start -= 1; } return start; } /** * Get the range of a given token and around whitespaces. * @param {Token} token The token to get range. * @param {SourceCode} sourceCode The source code object to get tokens. * @returns {number} The start of the range of the token and around whitespaces. */ function getEndWithSpaces(token, sourceCode) { const text = sourceCode.text; let end = token.range[1]; // Detect spaces after the token. while (ANY_SPACE.test(text[end] || "")) { end += 1; } return end; } /** * Autofixes the Object.assign call to use an object spread instead. * @param {ASTNode|null} node The node that the rule warns on, i.e. the Object.assign call * @param {string} sourceCode sourceCode of the Object.assign call * @returns {Function} autofixer - replaces the Object.assign with a spread object. */ function defineFixer(node, sourceCode) { return function *(fixer) { const leftParen = sourceCode.getTokenAfter(node.callee, isOpeningParenToken); const rightParen = sourceCode.getLastToken(node); // Remove everything before the opening paren: callee `Object.assign`, type arguments, and whitespace between the callee and the paren. yield fixer.removeRange([node.range[0], leftParen.range[0]]); // Replace the parens of argument list to braces. if (needsParens(node, sourceCode)) { yield fixer.replaceText(leftParen, "({"); yield fixer.replaceText(rightParen, "})"); } else { yield fixer.replaceText(leftParen, "{"); yield fixer.replaceText(rightParen, "}"); } // Process arguments. for (const argNode of node.arguments) { const innerParens = getParenTokens(argNode, leftParen, sourceCode); const left = innerParens.shift(); const right = innerParens.pop(); if (argNode.type === "ObjectExpression") { const maybeTrailingComma = sourceCode.getLastToken(argNode, 1); const maybeArgumentComma = sourceCode.getTokenAfter(right); /* * Make bare this object literal. * And remove spaces inside of the braces for better formatting. */ for (const innerParen of innerParens) { yield fixer.remove(innerParen); } const leftRange = [left.range[0], getEndWithSpaces(left, sourceCode)]; const rightRange = [ Math.max(getStartWithSpaces(right, sourceCode), leftRange[1]), // Ensure ranges don't overlap right.range[1] ]; yield fixer.removeRange(leftRange); yield fixer.removeRange(rightRange); // Remove the comma of this argument if it's duplication. if ( (argNode.properties.length === 0 || isCommaToken(maybeTrailingComma)) && isCommaToken(maybeArgumentComma) ) { yield fixer.remove(maybeArgumentComma); } } else { // Make spread. if (argNeedsParens(argNode, sourceCode)) { yield fixer.insertTextBefore(left, "...("); yield fixer.insertTextAfter(right, ")"); } else { yield fixer.insertTextBefore(left, "..."); } } } }; } /** @type {import('../shared/types').Rule} */ module.exports = { meta: { type: "suggestion", docs: { description: "Disallow using Object.assign with an object literal as the first argument and prefer the use of object spread instead.", recommended: false, url: "https://eslint.org/docs/rules/prefer-object-spread" }, schema: [], fixable: "code", messages: { useSpreadMessage: "Use an object spread instead of `Object.assign` eg: `{ ...foo }`.", useLiteralMessage: "Use an object literal instead of `Object.assign`. eg: `{ foo: bar }`." } }, create(context) { const sourceCode = context.getSourceCode(); return { Program() { const scope = context.getScope(); const tracker = new ReferenceTracker(scope); const trackMap = { Object: { assign: { [CALL]: true } } }; // Iterate all calls of `Object.assign` (only of the global variable `Object`). for (const { node } of tracker.iterateGlobalReferences(trackMap)) { if ( node.arguments.length >= 1 && node.arguments[0].type === "ObjectExpression" && !hasArraySpread(node) && !( node.arguments.length > 1 && hasArgumentsWithAccessors(node) ) ) { const messageId = node.arguments.length === 1 ? "useLiteralMessage" : "useSpreadMessage"; const fix = defineFixer(node, sourceCode); context.report({ node, messageId, fix }); } } } }; } };