From 497519e799ac116bce58ce1c41f9f701061848e1 Mon Sep 17 00:00:00 2001 From: fisker Cheung Date: Tue, 7 May 2024 16:09:58 +0800 Subject: [PATCH] Add `prefer-structured-clone` rule (#2329) --- configs/recommended.js | 1 + docs/rules/prefer-structured-clone.md | 59 ++++ readme.md | 1 + rules/prefer-structured-clone.js | 153 +++++++++ test/prefer-structured-clone.mjs | 75 +++++ test/snapshots/prefer-structured-clone.mjs.md | 301 ++++++++++++++++++ .../prefer-structured-clone.mjs.snap | Bin 0 -> 974 bytes 7 files changed, 590 insertions(+) create mode 100644 docs/rules/prefer-structured-clone.md create mode 100644 rules/prefer-structured-clone.js create mode 100644 test/prefer-structured-clone.mjs create mode 100644 test/snapshots/prefer-structured-clone.mjs.md create mode 100644 test/snapshots/prefer-structured-clone.mjs.snap diff --git a/configs/recommended.js b/configs/recommended.js index 2da3c5e960..b6e543f10a 100644 --- a/configs/recommended.js +++ b/configs/recommended.js @@ -101,6 +101,7 @@ module.exports = { 'unicorn/prefer-string-slice': 'error', 'unicorn/prefer-string-starts-ends-with': 'error', 'unicorn/prefer-string-trim-start-end': 'error', + 'unicorn/prefer-structured-clone': 'error', 'unicorn/prefer-switch': 'error', 'unicorn/prefer-ternary': 'error', 'unicorn/prefer-top-level-await': 'error', diff --git a/docs/rules/prefer-structured-clone.md b/docs/rules/prefer-structured-clone.md new file mode 100644 index 0000000000..100bbab1a1 --- /dev/null +++ b/docs/rules/prefer-structured-clone.md @@ -0,0 +1,59 @@ +# Prefer using `structuredClone` to create a deep clone + +πŸ’Ό This rule is enabled in the βœ… `recommended` [config](https://github.com/sindresorhus/eslint-plugin-unicorn#preset-configs-eslintconfigjs). + +πŸ’‘ This rule is manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions). + + + + +[`structuredClone`](https://developer.mozilla.org/en-US/docs/Web/API/structuredClone) is the modern way to create a deep clone of a value. + +## Fail + +```js +const clone = JSON.parse(JSON.stringify(foo)); +``` + +```js +const clone = _.cloneDeep(foo); +``` + +## Pass + +```js +const clone = structuredClone(foo); +``` + +## Options + +Type: `object` + +### functions + +Type: `string[]` + +You can also check custom functions that creates a deep clone. + +`_.cloneDeep()` and `lodash.cloneDeep()` are always checked. + +Example: + +```js +{ + 'unicorn/prefer-structured-clone': [ + 'error', + { + functions: [ + 'cloneDeep', + 'utils.clone' + ] + } + ] +} +``` + +```js +// eslint unicorn/prefer-structured-clone: ["error", {"functions": ["utils.clone"]}] +const clone = utils.clone(foo); // Fails +``` diff --git a/readme.md b/readme.md index 607b27f92e..9cd69cf9a6 100644 --- a/readme.md +++ b/readme.md @@ -209,6 +209,7 @@ If you don't use the preset, ensure you use the same `env` and `parserOptions` c | [prefer-string-slice](docs/rules/prefer-string-slice.md) | Prefer `String#slice()` over `String#substr()` and `String#substring()`. | βœ… | πŸ”§ | | | [prefer-string-starts-ends-with](docs/rules/prefer-string-starts-ends-with.md) | Prefer `String#startsWith()` & `String#endsWith()` over `RegExp#test()`. | βœ… | πŸ”§ | πŸ’‘ | | [prefer-string-trim-start-end](docs/rules/prefer-string-trim-start-end.md) | Prefer `String#trimStart()` / `String#trimEnd()` over `String#trimLeft()` / `String#trimRight()`. | βœ… | πŸ”§ | | +| [prefer-structured-clone](docs/rules/prefer-structured-clone.md) | Prefer using `structuredClone` to create a deep clone. | βœ… | | πŸ’‘ | | [prefer-switch](docs/rules/prefer-switch.md) | Prefer `switch` over multiple `else-if`. | βœ… | πŸ”§ | | | [prefer-ternary](docs/rules/prefer-ternary.md) | Prefer ternary expressions over simple `if-else` statements. | βœ… | πŸ”§ | | | [prefer-top-level-await](docs/rules/prefer-top-level-await.md) | Prefer top-level await over top-level promises and async function calls. | βœ… | | πŸ’‘ | diff --git a/rules/prefer-structured-clone.js b/rules/prefer-structured-clone.js new file mode 100644 index 0000000000..896e510b64 --- /dev/null +++ b/rules/prefer-structured-clone.js @@ -0,0 +1,153 @@ +'use strict'; +const { + isCommaToken, + isOpeningParenToken, +} = require('@eslint-community/eslint-utils'); +const {isCallExpression, isMethodCall} = require('./ast/index.js'); +const {removeParentheses} = require('./fix/index.js'); +const {isNodeMatchesNameOrPath} = require('./utils/index.js'); + +const MESSAGE_ID_ERROR = 'prefer-structured-clone/error'; +const MESSAGE_ID_SUGGESTION = 'prefer-structured-clone/suggestion'; +const messages = { + [MESSAGE_ID_ERROR]: 'Prefer `structuredClone(…)` over `{{description}}` to create a deep clone.', + [MESSAGE_ID_SUGGESTION]: 'Switch to `structuredClone(…)`.', +}; + +const lodashCloneDeepFunctions = [ + '_.cloneDeep', + 'lodash.cloneDeep', +]; + +/** @param {import('eslint').Rule.RuleContext} context */ +const create = context => { + const {functions: configFunctions} = { + functions: [], + ...context.options[0], + }; + const functions = [...configFunctions, ...lodashCloneDeepFunctions]; + + // `JSON.parse(JSON.stringify(…))` + context.on('CallExpression', callExpression => { + if (!( + // `JSON.stringify()` + isMethodCall(callExpression, { + object: 'JSON', + method: 'parse', + argumentsLength: 1, + optionalCall: false, + optionalMember: false, + }) + // `JSON.parse()` + && isMethodCall(callExpression.arguments[0], { + object: 'JSON', + method: 'stringify', + argumentsLength: 1, + optionalCall: false, + optionalMember: false, + }) + )) { + return; + } + + const jsonParse = callExpression; + const jsonStringify = callExpression.arguments[0]; + + return { + node: jsonParse, + loc: { + start: jsonParse.loc.start, + end: jsonStringify.callee.loc.end, + }, + messageId: MESSAGE_ID_ERROR, + data: { + description: 'JSON.parse(JSON.stringify(…))', + }, + suggest: [ + { + messageId: MESSAGE_ID_SUGGESTION, + * fix(fixer) { + yield fixer.replaceText(jsonParse.callee, 'structuredClone'); + + const {sourceCode} = context; + + yield fixer.remove(jsonStringify.callee); + yield * removeParentheses(jsonStringify.callee, fixer, sourceCode); + + const openingParenthesisToken = sourceCode.getTokenAfter(jsonStringify.callee, isOpeningParenToken); + yield fixer.remove(openingParenthesisToken); + + const [ + penultimateToken, + closingParenthesisToken, + ] = sourceCode.getLastTokens(jsonStringify, 2); + + if (isCommaToken(penultimateToken)) { + yield fixer.remove(penultimateToken); + } + + yield fixer.remove(closingParenthesisToken); + }, + }, + ], + }; + }); + + // `_.cloneDeep(foo)` + context.on('CallExpression', callExpression => { + if (!isCallExpression(callExpression, { + argumentsLength: 1, + optional: false, + })) { + return; + } + + const {callee} = callExpression; + const matchedFunction = functions.find(nameOrPath => isNodeMatchesNameOrPath(callee, nameOrPath)); + + if (!matchedFunction) { + return; + } + + return { + node: callee, + messageId: MESSAGE_ID_ERROR, + data: { + description: `${matchedFunction.trim()}(…)`, + }, + suggest: [ + { + messageId: MESSAGE_ID_SUGGESTION, + fix: fixer => fixer.replaceText(callee, 'structuredClone'), + }, + ], + }; + }); +}; + +const schema = [ + { + type: 'object', + additionalProperties: false, + properties: { + functions: { + type: 'array', + uniqueItems: true, + }, + }, + }, +]; + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + create, + meta: { + type: 'suggestion', + docs: { + description: 'Prefer using `structuredClone` to create a deep clone.', + }, + hasSuggestions: true, + schema, + messages, + }, +}; diff --git a/test/prefer-structured-clone.mjs b/test/prefer-structured-clone.mjs new file mode 100644 index 0000000000..3543e6abe5 --- /dev/null +++ b/test/prefer-structured-clone.mjs @@ -0,0 +1,75 @@ +import outdent from 'outdent'; +import {getTester} from './utils/test.mjs'; + +const {test} = getTester(import.meta); + +// `JSON.parse(JSON.stringify(…))` +test.snapshot({ + valid: [ + 'structuredClone(foo)', + 'JSON.parse(new JSON.stringify(foo))', + 'new JSON.parse(JSON.stringify(foo))', + 'JSON.parse(JSON.stringify())', + 'JSON.parse(JSON.stringify(...foo))', + 'JSON.parse(JSON.stringify(foo, extraArgument))', + 'JSON.parse(...JSON.stringify(foo))', + 'JSON.parse(JSON.stringify(foo), extraArgument)', + 'JSON.parse(JSON.stringify?.(foo))', + 'JSON.parse(JSON?.stringify(foo))', + 'JSON.parse?.(JSON.stringify(foo))', + 'JSON?.parse(JSON.stringify(foo))', + 'JSON.parse(JSON.not_stringify(foo))', + 'JSON.parse(not_JSON.stringify(foo))', + 'JSON.not_parse(JSON.stringify(foo))', + 'not_JSON.parse(JSON.stringify(foo))', + 'JSON.stringify(JSON.parse(foo))', + // Not checking + 'JSON.parse(JSON.stringify(foo, undefined, 2))', + ], + invalid: [ + 'JSON.parse(JSON.stringify(foo))', + 'JSON.parse(JSON.stringify(foo),)', + 'JSON.parse(JSON.stringify(foo,))', + 'JSON.parse(JSON.stringify(foo,),)', + 'JSON.parse( ((JSON.stringify)) (foo))', + '(( JSON.parse)) (JSON.stringify(foo))', + 'JSON.parse(JSON.stringify( ((foo)) ))', + outdent` + function foo() { + return JSON + .parse( + JSON. + stringify( + bar, + ), + ); + } + `, + ], +}); + +// Custom functions +test.snapshot({ + valid: [ + 'new _.cloneDeep(foo)', + 'notMatchedFunction(foo)', + '_.cloneDeep()', + '_.cloneDeep(...foo)', + '_.cloneDeep(foo, extraArgument)', + '_.cloneDeep?.(foo)', + '_?.cloneDeep(foo)', + ], + invalid: [ + '_.cloneDeep(foo)', + 'lodash.cloneDeep(foo)', + 'lodash.cloneDeep(foo,)', + { + code: 'myCustomDeepCloneFunction(foo,)', + options: [{functions: ['myCustomDeepCloneFunction']}], + }, + { + code: 'my.cloneDeep(foo,)', + options: [{functions: ['my.cloneDeep']}], + }, + ], +}); diff --git a/test/snapshots/prefer-structured-clone.mjs.md b/test/snapshots/prefer-structured-clone.mjs.md new file mode 100644 index 0000000000..dfe6bee09c --- /dev/null +++ b/test/snapshots/prefer-structured-clone.mjs.md @@ -0,0 +1,301 @@ +# Snapshot report for `test/prefer-structured-clone.mjs` + +The actual snapshot is saved in `prefer-structured-clone.mjs.snap`. + +Generated by [AVA](https://avajs.dev). + +## invalid(1): JSON.parse(JSON.stringify(foo)) + +> Input + + `␊ + 1 | JSON.parse(JSON.stringify(foo))␊ + ` + +> Error 1/1 + + `␊ + > 1 | JSON.parse(JSON.stringify(foo))␊ + | ^^^^^^^^^^^^^^^^^^^^^^^^^ Prefer \`structuredClone(…)\` over \`JSON.parse(JSON.stringify(…))\` to create a deep clone.␊ + ␊ + --------------------------------------------------------------------------------␊ + Suggestion 1/1: Switch to \`structuredClone(…)\`.␊ + 1 | structuredClone(foo)␊ + ` + +## invalid(2): JSON.parse(JSON.stringify(foo),) + +> Input + + `␊ + 1 | JSON.parse(JSON.stringify(foo),)␊ + ` + +> Error 1/1 + + `␊ + > 1 | JSON.parse(JSON.stringify(foo),)␊ + | ^^^^^^^^^^^^^^^^^^^^^^^^^ Prefer \`structuredClone(…)\` over \`JSON.parse(JSON.stringify(…))\` to create a deep clone.␊ + ␊ + --------------------------------------------------------------------------------␊ + Suggestion 1/1: Switch to \`structuredClone(…)\`.␊ + 1 | structuredClone(foo,)␊ + ` + +## invalid(3): JSON.parse(JSON.stringify(foo,)) + +> Input + + `␊ + 1 | JSON.parse(JSON.stringify(foo,))␊ + ` + +> Error 1/1 + + `␊ + > 1 | JSON.parse(JSON.stringify(foo,))␊ + | ^^^^^^^^^^^^^^^^^^^^^^^^^ Prefer \`structuredClone(…)\` over \`JSON.parse(JSON.stringify(…))\` to create a deep clone.␊ + ␊ + --------------------------------------------------------------------------------␊ + Suggestion 1/1: Switch to \`structuredClone(…)\`.␊ + 1 | structuredClone(foo)␊ + ` + +## invalid(4): JSON.parse(JSON.stringify(foo,),) + +> Input + + `␊ + 1 | JSON.parse(JSON.stringify(foo,),)␊ + ` + +> Error 1/1 + + `␊ + > 1 | JSON.parse(JSON.stringify(foo,),)␊ + | ^^^^^^^^^^^^^^^^^^^^^^^^^ Prefer \`structuredClone(…)\` over \`JSON.parse(JSON.stringify(…))\` to create a deep clone.␊ + ␊ + --------------------------------------------------------------------------------␊ + Suggestion 1/1: Switch to \`structuredClone(…)\`.␊ + 1 | structuredClone(foo,)␊ + ` + +## invalid(5): JSON.parse( ((JSON.stringify)) (foo)) + +> Input + + `␊ + 1 | JSON.parse( ((JSON.stringify)) (foo))␊ + ` + +> Error 1/1 + + `␊ + > 1 | JSON.parse( ((JSON.stringify)) (foo))␊ + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Prefer \`structuredClone(…)\` over \`JSON.parse(JSON.stringify(…))\` to create a deep clone.␊ + ␊ + --------------------------------------------------------------------------------␊ + Suggestion 1/1: Switch to \`structuredClone(…)\`.␊ + 1 | structuredClone( foo)␊ + ` + +## invalid(6): (( JSON.parse)) (JSON.stringify(foo)) + +> Input + + `␊ + 1 | (( JSON.parse)) (JSON.stringify(foo))␊ + ` + +> Error 1/1 + + `␊ + > 1 | (( JSON.parse)) (JSON.stringify(foo))␊ + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Prefer \`structuredClone(…)\` over \`JSON.parse(JSON.stringify(…))\` to create a deep clone.␊ + ␊ + --------------------------------------------------------------------------------␊ + Suggestion 1/1: Switch to \`structuredClone(…)\`.␊ + 1 | (( structuredClone)) (foo)␊ + ` + +## invalid(7): JSON.parse(JSON.stringify( ((foo)) )) + +> Input + + `␊ + 1 | JSON.parse(JSON.stringify( ((foo)) ))␊ + ` + +> Error 1/1 + + `␊ + > 1 | JSON.parse(JSON.stringify( ((foo)) ))␊ + | ^^^^^^^^^^^^^^^^^^^^^^^^^ Prefer \`structuredClone(…)\` over \`JSON.parse(JSON.stringify(…))\` to create a deep clone.␊ + ␊ + --------------------------------------------------------------------------------␊ + Suggestion 1/1: Switch to \`structuredClone(…)\`.␊ + 1 | structuredClone( ((foo)) )␊ + ` + +## invalid(8): function foo() { return JSON .parse( JSON. stringify( bar, ), ); } + +> Input + + `␊ + 1 | function foo() {␊ + 2 | return JSON␊ + 3 | .parse(␊ + 4 | JSON.␊ + 5 | stringify(␊ + 6 | bar,␊ + 7 | ),␊ + 8 | );␊ + 9 | }␊ + ` + +> Error 1/1 + + `␊ + 1 | function foo() {␊ + > 2 | return JSON␊ + | ^^^^␊ + > 3 | .parse(␊ + | ^^^^^^^^^␊ + > 4 | JSON.␊ + | ^^^^^^^^^␊ + > 5 | stringify(␊ + | ^^^^^^^^^^^^^^ Prefer \`structuredClone(…)\` over \`JSON.parse(JSON.stringify(…))\` to create a deep clone.␊ + 6 | bar,␊ + 7 | ),␊ + 8 | );␊ + 9 | }␊ + ␊ + --------------------------------------------------------------------------------␊ + Suggestion 1/1: Switch to \`structuredClone(…)\`.␊ + 1 | function foo() {␊ + 2 | return structuredClone(␊ + 3 | ␊ + 4 | bar␊ + 5 | ,␊ + 6 | );␊ + 7 | }␊ + ` + +## invalid(1): _.cloneDeep(foo) + +> Input + + `␊ + 1 | _.cloneDeep(foo)␊ + ` + +> Error 1/1 + + `␊ + > 1 | _.cloneDeep(foo)␊ + | ^^^^^^^^^^^ Prefer \`structuredClone(…)\` over \`_.cloneDeep(…)\` to create a deep clone.␊ + ␊ + --------------------------------------------------------------------------------␊ + Suggestion 1/1: Switch to \`structuredClone(…)\`.␊ + 1 | structuredClone(foo)␊ + ` + +## invalid(2): lodash.cloneDeep(foo) + +> Input + + `␊ + 1 | lodash.cloneDeep(foo)␊ + ` + +> Error 1/1 + + `␊ + > 1 | lodash.cloneDeep(foo)␊ + | ^^^^^^^^^^^^^^^^ Prefer \`structuredClone(…)\` over \`lodash.cloneDeep(…)\` to create a deep clone.␊ + ␊ + --------------------------------------------------------------------------------␊ + Suggestion 1/1: Switch to \`structuredClone(…)\`.␊ + 1 | structuredClone(foo)␊ + ` + +## invalid(3): lodash.cloneDeep(foo,) + +> Input + + `␊ + 1 | lodash.cloneDeep(foo,)␊ + ` + +> Error 1/1 + + `␊ + > 1 | lodash.cloneDeep(foo,)␊ + | ^^^^^^^^^^^^^^^^ Prefer \`structuredClone(…)\` over \`lodash.cloneDeep(…)\` to create a deep clone.␊ + ␊ + --------------------------------------------------------------------------------␊ + Suggestion 1/1: Switch to \`structuredClone(…)\`.␊ + 1 | structuredClone(foo,)␊ + ` + +## invalid(4): myCustomDeepCloneFunction(foo,) + +> Input + + `␊ + 1 | myCustomDeepCloneFunction(foo,)␊ + ` + +> Options + + `␊ + [␊ + {␊ + "functions": [␊ + "myCustomDeepCloneFunction"␊ + ]␊ + }␊ + ]␊ + ` + +> Error 1/1 + + `␊ + > 1 | myCustomDeepCloneFunction(foo,)␊ + | ^^^^^^^^^^^^^^^^^^^^^^^^^ Prefer \`structuredClone(…)\` over \`myCustomDeepCloneFunction(…)\` to create a deep clone.␊ + ␊ + --------------------------------------------------------------------------------␊ + Suggestion 1/1: Switch to \`structuredClone(…)\`.␊ + 1 | structuredClone(foo,)␊ + ` + +## invalid(5): my.cloneDeep(foo,) + +> Input + + `␊ + 1 | my.cloneDeep(foo,)␊ + ` + +> Options + + `␊ + [␊ + {␊ + "functions": [␊ + "my.cloneDeep"␊ + ]␊ + }␊ + ]␊ + ` + +> Error 1/1 + + `␊ + > 1 | my.cloneDeep(foo,)␊ + | ^^^^^^^^^^^^ Prefer \`structuredClone(…)\` over \`my.cloneDeep(…)\` to create a deep clone.␊ + ␊ + --------------------------------------------------------------------------------␊ + Suggestion 1/1: Switch to \`structuredClone(…)\`.␊ + 1 | structuredClone(foo,)␊ + ` diff --git a/test/snapshots/prefer-structured-clone.mjs.snap b/test/snapshots/prefer-structured-clone.mjs.snap new file mode 100644 index 0000000000000000000000000000000000000000..5e3bfab094c6fa4d6ff162b8d22a3a5f485f9450 GIT binary patch literal 974 zcmV;<12OzTRzVUpsQ2rV(O{{_N6TBDlgP0z^)2ny~SW(m%^@2gMFf4UkIr$XH(}z zdyT=4<**CFeV5_BDam~r?~}!QB_v;E$%7Je0LCDy?+u3jLXy7N7*_!Yi~_bf3P>@S zp@JJM1c1~WFjB$_u7jdP-0eG8?n{ra1Sw-&CFqP2c0(n|8O&C~4HAQ*#Ptw{Otv6u zsYL;lp}%3G)F3v-6~rMUh@I@>5H&b05d6F_a*$hC6)c8~gU=apTjLe7tmi$M>U}glcO#4N; z1O!kWc8lZ1Ab^$NVbNa~0;mOj>~~ga2R9JFQy9PnPl^TRR9l!Uh?d;sE!h`h5r)mz z9%?3b!DtmVc|~*$F+!(k`Xo& zyKIvBJW75