You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
299 lines
10 KiB
299 lines
10 KiB
/** |
|
* @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 }); |
|
} |
|
} |
|
} |
|
}; |
|
} |
|
};
|
|
|