Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add prefer-structured-clone rule #2329

Merged
merged 9 commits into from
May 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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