diff --git a/conf/eslint-recommended.js b/conf/eslint-recommended.js index 0acee234a56b..611159e35c4b 100755 --- a/conf/eslint-recommended.js +++ b/conf/eslint-recommended.js @@ -229,6 +229,7 @@ module.exports = { "prefer-const": "off", "prefer-destructuring": "off", "prefer-numeric-literals": "off", + "prefer-object-spread": "off", "prefer-promise-reject-errors": "off", "prefer-reflect": "off", "prefer-rest-params": "off", diff --git a/docs/rules/prefer-object-spread.md b/docs/rules/prefer-object-spread.md new file mode 100644 index 000000000000..6a2fdff661a9 --- /dev/null +++ b/docs/rules/prefer-object-spread.md @@ -0,0 +1,50 @@ +# Prefer use of an object spread over `Object.assign` (prefer-object-spread) + +When Object.assign is called using an object literal the first argument, this rule requires using the object spread syntax instead. + +**Please note:** This rule can only be used when using an `ecmaVersion` of 2018 or higher, 9 or higher, or when using an `ecmaVersion` of 2015-2017 or 5-8 with the `experimentalObjectRestSpread` parser option enabled. + +## Rule Details + +The following patterns are considered errors: + +```js + +Object.assign({}, foo) + +Object.assign({}, {foo: 'bar'}) + +Object.assign({ foo: 'bar'}, baz) + +Object.assign({ foo: 'bar' }, Object.assign({ bar: 'foo' })) + +Object.assign({}, { foo, bar, baz }) + +Object.assign({}, { ...baz }) + +``` + +The following patterns are not errors: + +```js + +Object.assign(...foo); + +// Any Object.assign call without an object literal as the first argument +Object.assign(foo, { bar: baz }); + +Object.assign(foo, Object.assign({ bar: 'foo' })); + +Object.assign(foo, { bar, baz }) + +Object.assign(foo, { ...baz }); + +// Object.assign with a single argument that is an object literal +Object.assign({}); + +Object.assign({ foo: bar }); +``` + +## When Not To Use It + +When you don't care about syntactic sugar added by the object spread property. diff --git a/lib/rules/prefer-object-spread.js b/lib/rules/prefer-object-spread.js new file mode 100644 index 000000000000..5dceea15b056 --- /dev/null +++ b/lib/rules/prefer-object-spread.js @@ -0,0 +1,143 @@ +/** + * @fileoverview Prefers object spread property over Object.assign + * @author Sharmila Jesupaul + * See LICENSE file in root directory for full license. + */ + +"use strict"; + +// Helpers +/** + * Helper that returns the last element in an array + * @param {array} arr - array that you are searching in + * @returns {string} - Returns the last element in an array of string arguments + */ +function tail(arr) { + return arr[arr.length - 1]; +} + +/** + * Helper that strips the curlie braces from a stringified object, returning only the contents + * @param {string} objectString - Source code of an object literal + * @returns {string} - Returns the object with the curly braces stripped + */ +function stripCurlies(objectString) { + return objectString.slice(1, -1); +} + +/** + * Helper that checks if the node is an Object.assign call + * @param {ASTNode} node - The node that the rule warns on + * @returns {boolean} - Returns true if the node is an Object.assign call + */ +function isObjectAssign(node) { + return ( + node.callee && + node.callee.type === "MemberExpression" && + node.callee.object.name === "Object" && + node.callee.property.name === "assign" + ); +} + +/** + * Autofixer that parses arguments that are passed into the Object.assign call, and returns a formatted object spread. + * @param {array} args - The node that the rule warns on, i.e. the Object.assign call + * @param {string} sourceCode - sourceCode of the Object.assign call + * @returns {array} formatted args - replaces the Object.assign with a spread object. + */ +function parseArgs(args, sourceCode) { + const mapped = args.map((arg, i) => { + + // If the the argument is an empty object + if (arg.type === "ObjectExpression" && arg.properties.length === 0) { + return ""; + } + + // if the argument is another object.assign call run this function for all of it's arguments + if (isObjectAssign(arg)) { + return parseArgs(arg.arguments, sourceCode); + } + + const next = args[i + 1] || {}; + + // if the argument is an object with properties + if (arg.type === "ObjectExpression") { + const trimmedObject = stripCurlies(sourceCode.getText(arg)).trim(); + + return next.range && next.range[0] ? `${trimmedObject}, ` : trimmedObject; + } + + return next.range && next.range[0] + ? `...${sourceCode.getText(arg)}, ` + : `...${sourceCode.getText(arg)}`; + }); + + return [].concat.apply([], mapped); +} + +/** + * 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 autofixSpread(node, sourceCode) { + return fixer => { + const args = node.arguments; + + const processedArgs = parseArgs(args, sourceCode).join(""); + + const lastArg = tail(args); + const firstArg = args[0]; + + const funcEnd = sourceCode.text + .slice(lastArg.range[1], node.range[1]) + .split(")")[0]; + const funcStart = tail( + sourceCode.text.slice(node.range[0], firstArg.range[0]).split("(") + ); + + return fixer.replaceText( + node, + `{${funcStart}${processedArgs}${funcEnd}}` + ); + }; +} + +module.exports = { + meta: { + docs: { + description: + "disallow using Object.assign with an object literal as the first argument and prefer the use of object spread instead.", + category: "ECMAScript 6", + recommended: false, + url: "https://eslint.org/docs/rules/prefer-object-spread" + }, + schema: [], + fixable: "code" + }, + + create: function rule(context) { + return { + CallExpression: node => { + const message = "Use an object spread instead of `Object.assign()` eg: `{ ...foo }`"; + const sourceCode = context.getSourceCode(); + const hasSpreadElement = node.arguments.length && + node.arguments.some(x => x.type === "SpreadElement"); + + if ( + node.arguments.length > 1 && + node.arguments[0].type === "ObjectExpression" && + isObjectAssign && + !hasSpreadElement + ) { + context.report({ + node, + message, + fix: autofixSpread(node, sourceCode) + }); + } + } + }; + } +}; diff --git a/tests/lib/rules/prefer-object-spread.js b/tests/lib/rules/prefer-object-spread.js new file mode 100644 index 000000000000..d58ffe784b3a --- /dev/null +++ b/tests/lib/rules/prefer-object-spread.js @@ -0,0 +1,195 @@ +/** + * @fileoverview Prefers object spread property over Object.assign + * @author Sharmila Jesupaul + * See LICENSE file in root directory for full license. + */ + +"use strict"; + +const rule = require("../../../lib/rules/prefer-object-spread"); + +const RuleTester = require("../../../lib/testers/rule-tester"); + +const parserOptions = { + ecmaVersion: 6, + ecmaFeatures: { + experimentalObjectRestSpread: true + } +}; + +const defaultErrorMessage = + "Use an object spread instead of `Object.assign()` eg: `{ ...foo }`"; +const ruleTester = new RuleTester(); + +ruleTester.run("prefer-object-spread", rule, { + valid: [ + { + code: "const bar = { ...foo }", + parserOptions + }, + { + code: "Object.assign(...foo)", + parserOptions + }, + { + code: "Object.assign(foo, { bar: baz })", + parserOptions + } + ], + + invalid: [ + { + code: "Object.assign({}, foo)", + output: "{...foo}", + errors: [ + { + message: defaultErrorMessage, + type: "CallExpression" + } + ] + }, + { + code: "Object.assign({}, { foo: 'bar' })", + output: "{foo: 'bar'}", + errors: [ + { + message: defaultErrorMessage, + type: "CallExpression" + } + ] + }, + { + code: "Object.assign({}, baz, { foo: 'bar' })", + output: "{...baz, foo: 'bar'}", + errors: [ + { + message: defaultErrorMessage, + type: "CallExpression" + } + ] + }, + { + code: "Object.assign({}, { foo: 'bar', baz: 'foo' })", + output: "{foo: 'bar', baz: 'foo'}", + errors: [ + { + message: defaultErrorMessage, + type: "CallExpression" + } + ] + }, + { + code: "Object.assign({ foo: 'bar' }, baz)", + output: "{foo: 'bar', ...baz}", + errors: [ + { + message: defaultErrorMessage, + type: "CallExpression" + } + ] + }, + + // Many args + { + code: "Object.assign({ foo: 'bar' }, cats, dogs, trees, birds)", + output: "{foo: 'bar', ...cats, ...dogs, ...trees, ...birds}", + parserOptions, + errors: [ + { + message: defaultErrorMessage, + type: "CallExpression" + } + ] + }, + + // Nested Object.assign calls + { + code: + "Object.assign({ foo: 'bar' }, Object.assign({ bar: 'foo' }, baz))", + output: "{foo: 'bar', bar: 'foo', ...baz}", + parserOptions, + errors: [ + { + message: defaultErrorMessage, + type: "CallExpression" + }, + { + message: defaultErrorMessage, + type: "CallExpression" + } + ] + }, + { + code: + "Object.assign({ foo: 'bar' }, Object.assign({ bar: 'foo' }, Object.assign({}, { superNested: 'butwhy' })))", + output: "{foo: 'bar', bar: 'foo', superNested: 'butwhy'}", + parserOptions, + errors: [ + { + message: defaultErrorMessage, + type: "CallExpression" + }, + { + message: defaultErrorMessage, + type: "CallExpression" + }, + { + message: defaultErrorMessage, + type: "CallExpression" + } + ] + }, + + // Mix spread in argument + { + code: "Object.assign({ foo: 'bar', ...bar }, baz)", + output: "{foo: 'bar', ...bar, ...baz}", + parserOptions, + errors: [ + { + message: defaultErrorMessage, + type: "CallExpression" + } + ] + }, + + // Object shorthand + { + code: "Object.assign({}, { foo, bar, baz })", + output: "{foo, bar, baz}", + parserOptions, + errors: [ + { + message: defaultErrorMessage, + type: "CallExpression" + } + ] + }, + + // Objects with computed properties + { + code: "Object.assign({}, { [bar]: 'foo' })", + output: "{[bar]: 'foo'}", + parserOptions, + errors: [ + { + message: defaultErrorMessage, + type: "CallExpression" + } + ] + }, + + // Objects with spread properties + { + code: "Object.assign({ ...bar }, { ...baz })", + output: "{...bar, ...baz}", + parserOptions, + errors: [ + { + message: defaultErrorMessage, + type: "CallExpression" + } + ] + } + ] +});