299 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			299 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| /**
 | |
|  * @fileoverview Prefers object spread property over Object.assign
 | |
|  * @author Sharmila Jesupaul
 | |
|  */
 | |
| 
 | |
| "use strict";
 | |
| 
 | |
| const { CALL, ReferenceTracker } = require("@eslint-community/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/latest/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.sourceCode;
 | |
| 
 | |
|         return {
 | |
|             Program(node) {
 | |
|                 const scope = sourceCode.getScope(node);
 | |
|                 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: refNode } of tracker.iterateGlobalReferences(trackMap)) {
 | |
|                     if (
 | |
|                         refNode.arguments.length >= 1 &&
 | |
|                         refNode.arguments[0].type === "ObjectExpression" &&
 | |
|                         !hasArraySpread(refNode) &&
 | |
|                         !(
 | |
|                             refNode.arguments.length > 1 &&
 | |
|                             hasArgumentsWithAccessors(refNode)
 | |
|                         )
 | |
|                     ) {
 | |
|                         const messageId = refNode.arguments.length === 1
 | |
|                             ? "useLiteralMessage"
 | |
|                             : "useSpreadMessage";
 | |
|                         const fix = defineFixer(refNode, sourceCode);
 | |
| 
 | |
|                         context.report({ node: refNode, messageId, fix });
 | |
|                     }
 | |
|                 }
 | |
|             }
 | |
|         };
 | |
|     }
 | |
| };
 |