blob: 214f950c64212e97ef7c17c24643482f68a78668 [file] [log] [blame]
/**
* @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");
}
/**
* 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 incldues 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 the callee `Object.assign`
yield fixer.remove(node.callee);
// 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, "...");
}
}
}
};
}
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.",
category: "Stylistic Issues",
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)
) {
const messageId = node.arguments.length === 1
? "useLiteralMessage"
: "useSpreadMessage";
const fix = defineFixer(node, sourceCode);
context.report({ node, messageId, fix });
}
}
}
};
}
};