Skip to content

Commit

Permalink
Add prefer-structured-clone rule (#2329)
Browse files Browse the repository at this point in the history
  • Loading branch information
fisker authored May 7, 2024
1 parent dbb98be commit 497519e
Show file tree
Hide file tree
Showing 7 changed files with 590 additions and 0 deletions.
1 change: 1 addition & 0 deletions configs/recommended.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
59 changes: 59 additions & 0 deletions docs/rules/prefer-structured-clone.md
Original file line number Diff line number Diff line change
@@ -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).

<!-- end auto-generated rule header -->
<!-- Do not manually modify this header. Run: `npm run fix:eslint-docs` -->

[`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
```
1 change: 1 addition & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. || | 💡 |
Expand Down
153 changes: 153 additions & 0 deletions rules/prefer-structured-clone.js
Original file line number Diff line number Diff line change
@@ -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,
},
};
75 changes: 75 additions & 0 deletions test/prefer-structured-clone.mjs
Original file line number Diff line number Diff line change
@@ -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']}],
},
],
});
Loading

0 comments on commit 497519e

Please sign in to comment.