Skip to content

Commit

Permalink
feat(immutable-data): add new option ignoreNonConstDeclarations
Browse files Browse the repository at this point in the history
fix #691
  • Loading branch information
RebeccaStevens committed Jul 21, 2023
1 parent 180e191 commit ecde24a
Show file tree
Hide file tree
Showing 7 changed files with 227 additions and 11 deletions.
7 changes: 7 additions & 0 deletions docs/rules/immutable-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ This rule accepts an options object of the following type:
type Options = {
ignoreClasses: boolean | "fieldsOnly";
ignoreImmediateMutation: boolean;
ignoreNonConstDeclarations: boolean;
ignoreIdentifierPattern?: string[] | string;
ignoreAccessorPattern?: string[] | string;
};
Expand All @@ -72,6 +73,7 @@ type Options = {
type Options = {
ignoreClasses: false;
ignoreImmediateMutation: true;
ignoreNonConstDeclarations: false;
};
```

Expand All @@ -97,6 +99,11 @@ const original = ["foo", "bar", "baz"];
const sorted = [...original].sort((a, b) => a.localeCompare(b)); // This is OK with ignoreImmediateMutation.
```

### `ignoreNonConstDeclarations`

If true, this rule will ignore any mutations that happen on non-const variables.
This allow for more easily using mutable data by simply using the `let` keyword instead of `const`.

### `ignoreClasses`

Ignore mutations inside classes.
Expand Down
116 changes: 106 additions & 10 deletions src/rules/immutable-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import {
} from "@typescript-eslint/utils/json-schema";
import { type RuleContext } from "@typescript-eslint/utils/ts-eslint";
import { deepmerge } from "deepmerge-ts";
import { isNodeFlagSet } from "ts-api-utils";
import type ts from "typescript";

import typescript from "#eslint-plugin-functional/conditional-imports/typescript";
import {
type IgnoreAccessorPatternOption,
type IgnoreIdentifierPatternOption,
Expand Down Expand Up @@ -52,6 +55,7 @@ type Options = [
IgnoreClassesOption &
IgnoreIdentifierPatternOption & {
ignoreImmediateMutation: boolean;
ignoreNonConstDeclarations: boolean;
},
];

Expand All @@ -69,6 +73,9 @@ const schema: JSONSchema4[] = [
ignoreImmediateMutation: {
type: "boolean",
},
ignoreNonConstDeclarations: {
type: "boolean",
},
} satisfies JSONSchema4ObjectSchema["properties"],
),
additionalProperties: false,
Expand All @@ -82,6 +89,7 @@ const defaultOptions: Options = [
{
ignoreClasses: false,
ignoreImmediateMutation: true,
ignoreNonConstDeclarations: false,
},
];

Expand Down Expand Up @@ -158,6 +166,39 @@ const objectConstructorMutatorFunctions = new Set([
"setPrototypeOf",
]);

/**
* Is the given identifier defined by a mutable variable (let or var)?
*/
function isDefinedByMutableVaraible(
node: TSESTree.Identifier,
context: Readonly<RuleContext<keyof typeof errorMessages, Options>>,
) {
if (typescript === undefined) {
return true;
}

const tsNode = context.parserServices?.esTreeNodeToTSNodeMap.get(node);
const variableDeclaration =
tsNode !== undefined &&
"flowNode" in tsNode &&
typeof tsNode.flowNode === "object" &&
tsNode.flowNode !== null &&
"node" in tsNode.flowNode &&
typeof tsNode.flowNode.node === "object" &&
tsNode.flowNode.node !== null &&
typescript.isVariableDeclaration(tsNode.flowNode.node as ts.Node)
? (tsNode.flowNode.node as ts.VariableDeclaration)
: undefined;

const variableDeclarationList = variableDeclaration?.parent;

return (
variableDeclarationList === undefined ||
!typescript.isVariableDeclarationList(variableDeclarationList) ||
!isNodeFlagSet(variableDeclarationList, typescript.NodeFlags.Const)
);
}

/**
* Check if the given assignment expression violates this rule.
*/
Expand All @@ -167,8 +208,12 @@ function checkAssignmentExpression(
options: Readonly<Options>,
): RuleResult<keyof typeof errorMessages, Options> {
const [optionsObject] = options;
const { ignoreIdentifierPattern, ignoreAccessorPattern, ignoreClasses } =
optionsObject;
const {
ignoreIdentifierPattern,
ignoreAccessorPattern,
ignoreNonConstDeclarations,
ignoreClasses,
} = optionsObject;

if (
!isMemberExpression(node.left) ||
Expand All @@ -186,6 +231,17 @@ function checkAssignmentExpression(
};
}

if (
ignoreNonConstDeclarations &&
isIdentifier(node.left.object) &&
isDefinedByMutableVaraible(node.left.object, context)
) {
return {
context,
descriptors: [],
};
}

return {
context,
descriptors:
Expand All @@ -203,8 +259,12 @@ function checkUnaryExpression(
options: Readonly<Options>,
): RuleResult<keyof typeof errorMessages, Options> {
const [optionsObject] = options;
const { ignoreIdentifierPattern, ignoreAccessorPattern, ignoreClasses } =
optionsObject;
const {
ignoreIdentifierPattern,
ignoreAccessorPattern,
ignoreNonConstDeclarations,
ignoreClasses,
} = optionsObject;

if (
!isMemberExpression(node.argument) ||
Expand All @@ -222,6 +282,17 @@ function checkUnaryExpression(
};
}

if (
ignoreNonConstDeclarations &&
isIdentifier(node.argument.object) &&
isDefinedByMutableVaraible(node.argument.object, context)
) {
return {
context,
descriptors: [],
};
}

return {
context,
descriptors:
Expand All @@ -238,8 +309,12 @@ function checkUpdateExpression(
options: Readonly<Options>,
): RuleResult<keyof typeof errorMessages, Options> {
const [optionsObject] = options;
const { ignoreIdentifierPattern, ignoreAccessorPattern, ignoreClasses } =
optionsObject;
const {
ignoreIdentifierPattern,
ignoreAccessorPattern,
ignoreNonConstDeclarations,
ignoreClasses,
} = optionsObject;

if (
!isMemberExpression(node.argument) ||
Expand All @@ -257,6 +332,17 @@ function checkUpdateExpression(
};
}

if (
ignoreNonConstDeclarations &&
isIdentifier(node.argument.object) &&
isDefinedByMutableVaraible(node.argument.object, context)
) {
return {
context,
descriptors: [],
};
}

return {
context,
descriptors: [{ node, messageId: "generic" }],
Expand Down Expand Up @@ -306,8 +392,12 @@ function checkCallExpression(
options: Readonly<Options>,
): RuleResult<keyof typeof errorMessages, Options> {
const [optionsObject] = options;
const { ignoreIdentifierPattern, ignoreAccessorPattern, ignoreClasses } =
optionsObject;
const {
ignoreIdentifierPattern,
ignoreAccessorPattern,
ignoreNonConstDeclarations,
ignoreClasses,
} = optionsObject;

// Not potential object mutation?
if (
Expand All @@ -334,7 +424,10 @@ function checkCallExpression(
arrayMutatorMethods.has(node.callee.property.name) &&
(!ignoreImmediateMutation ||
!isInChainCallAndFollowsNew(node.callee, context)) &&
isArrayType(getTypeOfNode(node.callee.object, context))
isArrayType(getTypeOfNode(node.callee.object, context)) &&
(!ignoreNonConstDeclarations ||
!isIdentifier(node.callee.object) ||
!isDefinedByMutableVaraible(node.callee.object, context))
) {
return {
context,
Expand All @@ -355,7 +448,10 @@ function checkCallExpression(
ignoreIdentifierPattern,
ignoreAccessorPattern,
) &&
isObjectConstructorType(getTypeOfNode(node.callee.object, context))
isObjectConstructorType(getTypeOfNode(node.callee.object, context)) &&
(!ignoreNonConstDeclarations ||
!isIdentifier(node.callee.object) ||
!isDefinedByMutableVaraible(node.callee.object, context))
) {
return {
context,
Expand Down
2 changes: 1 addition & 1 deletion src/utils/rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ export function getESTreeNode<
Context extends Readonly<RuleContext<string, BaseOptions>>,
>(node: TSNode, context: Context): TSESTree.Node | null {
const parserServices = getParserServices(context, true);
return parserServices.tsNodeToESTreeNodeMap.get(node);
return parserServices.tsNodeToESTreeNodeMap.get(node) ?? null;
}

/**
Expand Down
46 changes: 46 additions & 0 deletions tests/rules/immutable-data/ts/array/invalid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,52 @@ const tests: Array<
},
],
},
// ignoreNonConstDeclarations.
{
code: dedent`
const foo = [0, 1];
foo[0] += 1;
`,
optionsSet: [[{ ignoreNonConstDeclarations: true }]],
errors: [
{
messageId: "generic",
type: AST_NODE_TYPES.AssignmentExpression,
line: 2,
column: 1,
},
],
},
{
code: dedent`
const foo = [0, 1];
foo[1]++;
`,
optionsSet: [[{ ignoreNonConstDeclarations: true }]],
errors: [
{
messageId: "generic",
type: AST_NODE_TYPES.UpdateExpression,
line: 2,
column: 1,
},
],
},
{
code: dedent`
const foo = [0, 1];
foo.pop();
`,
optionsSet: [[{ ignoreNonConstDeclarations: true }]],
errors: [
{
messageId: "array",
type: AST_NODE_TYPES.CallExpression,
line: 2,
column: 1,
},
],
},
];

export default tests;
19 changes: 19 additions & 0 deletions tests/rules/immutable-data/ts/array/valid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,25 @@ const tests: Array<ValidTestCaseSet<OptionsOf<typeof rule>>> = [
`,
optionsSet: [[]],
},
// ignoreNonConstDeclarations.
{
code: dedent`
var mutableVar = [0, 1];
mutableVar[0] += 1;
mutableVar[1]++;
mutableVar.pop();
`,
optionsSet: [[{ ignoreNonConstDeclarations: true }]],
},
{
code: dedent`
let mutableVar = [0, 1];
mutableVar[0] += 1;
mutableVar[1]++;
mutableVar.pop();
`,
optionsSet: [[{ ignoreNonConstDeclarations: true }]],
},
];

export default tests;
31 changes: 31 additions & 0 deletions tests/rules/immutable-data/ts/object/invalid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,37 @@ const tests: Array<
},
],
},
// ignoreNonConstDeclarations.
{
code: dedent`
const mutableVar = { a: 1 };
mutableVar.a++;
`,
optionsSet: [[{ ignoreNonConstDeclarations: true }]],
errors: [
{
messageId: "generic",
type: AST_NODE_TYPES.UpdateExpression,
line: 2,
column: 1,
},
],
},
{
code: dedent`
const mutableVar = { a: 1 };
mutableVar.a = 0;
`,
optionsSet: [[{ ignoreNonConstDeclarations: true }]],
errors: [
{
messageId: "generic",
type: AST_NODE_TYPES.AssignmentExpression,
line: 2,
column: 1,
},
],
},
];

export default tests;
17 changes: 17 additions & 0 deletions tests/rules/immutable-data/ts/object/valid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,23 @@ const tests: Array<ValidTestCaseSet<OptionsOf<typeof rule>>> = [
[{ ignoreAccessorPattern: ["**.mutable*.**"] }],
],
},
// ignoreNonConstDeclarations.
{
code: dedent`
var mutableVar = { a: 1 };
mutableVar.a = 0;
mutableVar.a++;
`,
optionsSet: [[{ ignoreNonConstDeclarations: true }]],
},
{
code: dedent`
let mutableVar = { a: 1 };
mutableVar.a = 0;
mutableVar.a++;
`,
optionsSet: [[{ ignoreNonConstDeclarations: true }]],
},
// Allow initialization of class members in constructor
{
code: dedent`
Expand Down

0 comments on commit ecde24a

Please sign in to comment.