Repository: eslint-functional/eslint-plugin-functional
Pull request #828 (2024-05-25 12:04:01)

feat(prefer-immutable-types): allow for changing suggestion messages

graphite-app[bot]: ## Your org has enabled the Graphite merge queue for merging into main

Add the label “Status: Merge Queue” to the PR and Graphite will automatically add it to the merge queue when it’s ready to merge. Or use the label “Status: Hotfix” to add to the merge queue as a hot fix.

You must have a Graphite account and log in to Graphite in order to use the merge queue. Sign up using [this link](https://app.graphite.dev/invite/github/eslint-functional?ref=merge-queue-instructions-comment&prId=5129934229).
RebeccaStevens: > [!WARNING]
> <b>This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack <a href="https://app.graphite.dev/github/pr/eslint-functional/eslint-plugin-functional/828?utm_source=stack-comment-downstack-mergeability-warning" >on Graphite</a>.</b>
> <a href="https://graphite.dev/docs/merge-pull-requests">Learn more</a>

* **#828** <a href="https://app.graphite.dev/github/pr/eslint-functional/eslint-plugin-functional/828?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈
* **#810** <a href="https://app.graphite.dev/github/pr/eslint-functional/eslint-plugin-functional/810?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>: 1 other dependent PR ([#826](https://github.com/eslint-functional/eslint-plugin-functional/pull/826) <a href="https://app.graphite.dev/github/pr/eslint-functional/eslint-plugin-functional/826?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>)
* **#804** <a href="https://app.graphite.dev/github/pr/eslint-functional/eslint-plugin-functional/804?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>: 1 other dependent PR ([#807](https://github.com/eslint-functional/eslint-plugin-functional/pull/807) <a href="https://app.graphite.dev/github/pr/eslint-functional/eslint-plugin-functional/807?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>)
* **#811** <a href="https://app.graphite.dev/github/pr/eslint-functional/eslint-plugin-functional/811?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#803** <a href="https://app.graphite.dev/github/pr/eslint-functional/eslint-plugin-functional/803?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#812** <a href="https://app.graphite.dev/github/pr/eslint-functional/eslint-plugin-functional/812?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#825** <a href="https://app.graphite.dev/github/pr/eslint-functional/eslint-plugin-functional/825?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#809** <a href="https://app.graphite.dev/github/pr/eslint-functional/eslint-plugin-functional/809?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* `main`

This stack of pull requests is managed by Graphite. <a href="https://stacking.dev/?utm_source=stack-comment">Learn more about stacking.</a>
<h2></h2>

 Join @RebeccaStevens and the rest of your teammates on <a href="https://graphite.dev?utm-source=stack-comment"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="11px" height="11px"/> <b>Graphite</b></a>
  <!-- Current dependencies on/for this PR: -->
graphite-app[bot]: ### Merge activity

* **Jun 19, 6:50 AM EDT**: The merge label 'Status: Merge Queue' was detected. This PR will be added to the [Graphite merge queue](https://app.graphite.dev/merges?org=eslint-functional&repo=eslint-plugin-functional) once it meets the requirements.


Patch:
diff --git a/docs/rules/prefer-immutable-types.md b/docs/rules/prefer-immutable-types.md
index 202946f..6e8bdb7 100644
--- a/docs/rules/prefer-immutable-types.md
+++ b/docs/rules/prefer-immutable-types.md
@@ -240,9 +240,15 @@ type Options = {
   };
 
   suggestions?: {
-    ReadonlyShallow?: Array<Array<{ pattern: string; replace: string }>>;
-    ReadonlyDeep?: Array<Array<{ pattern: string; replace: string }>>;
-    Immutable?: Array<Array<{ pattern: string; replace: string }>>;
+    ReadonlyShallow?: Array<
+      Array<{ pattern: string; replace: string; message?: string }>
+    >;
+    ReadonlyDeep?: Array<
+      Array<{ pattern: string; replace: string; message?: string }>
+    >;
+    Immutable?: Array<
+      Array<{ pattern: string; replace: string; message?: string }>
+    >;
   };
 };
 ```
@@ -262,14 +268,19 @@ const defaults = {
           pattern:
             "^([_$a-zA-Z\\xA0-\\uFFFF][_$a-zA-Z0-9\\xA0-\\uFFFF]*\\[\\])$",
           replace: "readonly $1",
+          message: "Prepend with readonly.",
         },
         {
           pattern: "^(Array|Map|Set)<(.+)>$",
           replace: "Readonly$1<$2>",
+          message: "Use Readonly$1 instead of $1.",
         },
+      ],
+      [
         {
           pattern: "^(.+)$",
           replace: "Readonly<$1>",
+          message: "Surround with Readonly.",
         },
       ],
     ],
diff --git a/src/rules/prefer-immutable-types.ts b/src/rules/prefer-immutable-types.ts
index 4ca41f2..e7ab569 100644
--- a/src/rules/prefer-immutable-types.ts
+++ b/src/rules/prefer-immutable-types.ts
@@ -69,6 +69,8 @@ type FixerConfigRaw = {
   replace: string;
 };
 
+type SuggestionsConfigRaw = Array<FixerConfigRaw & { message?: string }>;
+
 type FixerConfigRawMap = Partial<
   Record<
     "ReadonlyShallow" | "ReadonlyDeep" | "Immutable",
@@ -79,7 +81,7 @@ type FixerConfigRawMap = Partial<
 type SuggestionConfigRawMap = Partial<
   Record<
     "ReadonlyShallow" | "ReadonlyDeep" | "Immutable",
-    FixerConfigRaw[][] | undefined
+    SuggestionsConfigRaw[] | undefined
   >
 >;
 
@@ -88,7 +90,7 @@ type FixerConfig = {
   replace: string;
 };
 
-type SuggestionsConfig = FixerConfig[];
+type SuggestionsConfig = Array<FixerConfig & { message?: string }>;
 
 /**
  * The options this rule can take.
@@ -205,6 +207,7 @@ const suggestionsSchema: JSONSchema4 = {
       properties: {
         pattern: { type: "string" },
         replace: { type: "string" },
+        message: { type: "string" },
       },
       additionalProperties: false,
     },
@@ -275,14 +278,19 @@ const defaultOptions: Options = [
             pattern:
               "^([_$a-zA-Z\\xA0-\\uFFFF][_$a-zA-Z0-9\\xA0-\\uFFFF]*\\[\\])$",
             replace: "readonly $1",
+            message: "Prepend with readonly.",
           },
           {
             pattern: "^(Array|Map|Set)<(.+)>$",
             replace: "Readonly$1<$2>",
+            message: "Use Readonly$1 instead of $1.",
           },
+        ],
+        [
           {
             pattern: "^(.+)$",
             replace: "Readonly<$1>",
+            message: "Surround with Readonly.",
           },
         ],
       ],
@@ -303,6 +311,8 @@ const errorMessages = {
   propertyImmutability:
     'Property should have an immutability of at least "{{ expected }}" (actual: "{{ actual }}").',
   propertyModifier: "Property should have a readonly modifier.",
+  propertyModifierSuggestion: "Add readonly modifier.",
+  userDefined: "{{ message }}",
 } as const;
 
 /**
@@ -331,7 +341,7 @@ type Descriptor = RuleResult<
 
 type AllFixers = {
   fix: ReportFixFunction | null;
-  suggestionFixers: ReportFixFunction[] | null;
+  suggestionFixers: Array<{ fix: ReportFixFunction; message: string }> | null;
 };
 
 /**
@@ -383,14 +393,27 @@ function getConfiguredSuggestionFixers(
   suggestionsConfigs: SuggestionsConfig[],
 ) {
   return suggestionsConfigs
-    .map((configs): NonNullable<Descriptor["fix"]> | null => {
-      const config = configs.find((c) => c.pattern.test(text));
-      if (config === undefined) {
-        return null;
-      }
-      return (fixer) =>
-        fixer.replaceText(node, text.replace(config.pattern, config.replace));
-    })
+    .map(
+      (
+        configs,
+      ): { fix: NonNullable<Descriptor["fix"]>; message: string } | null => {
+        const config = configs.find((c) => c.pattern.test(text));
+        if (config === undefined) {
+          return null;
+        }
+        return {
+          fix: (fixer) =>
+            fixer.replaceText(
+              node,
+              text.replace(config.pattern, config.replace),
+            ),
+          message:
+            config.message === undefined
+              ? `Replace with: ${text.replace(config.pattern, config.replace)}`
+              : text.replace(config.pattern, config.message),
+        };
+      },
+    )
     .filter(isDefined);
 }
 
@@ -504,17 +527,16 @@ function getParameterTypeViolations(
 
       const parameterProperty = isTSParameterProperty(param);
       if (parameterProperty && !param.readonly) {
-        const messageId = "propertyModifier";
         const fix: NonNullable<Descriptor["fix"]> | null = (fixer) =>
           fixer.insertTextBefore(param.parameter, "readonly ");
 
         return {
           node: param,
-          messageId,
+          messageId: "propertyModifier",
           fix: fixerConfigs === false ? null : fix,
           suggest: [
             {
-              messageId,
+              messageId: "propertyModifierSuggestion",
               fix,
             },
           ],
@@ -564,21 +586,20 @@ function getParameterTypeViolations(
               suggestionsConfigs,
             );
 
-      const messageId = "parameter";
-      const data = {
-        actual: Immutability[immutability],
-        expected: Immutability[enforcement],
-      };
-
       return {
         node: actualParam,
-        messageId,
-        data,
+        messageId: "parameter",
+        data: {
+          actual: Immutability[immutability],
+          expected: Immutability[enforcement],
+        },
         fix,
         suggest:
-          suggestionFixers?.map((fix) => ({
-            messageId,
-            data,
+          suggestionFixers?.map(({ fix, message }) => ({
+            messageId: "userDefined",
+            data: {
+              message,
+            },
             fix,
           })) ?? null,
       };
@@ -658,22 +679,21 @@ function getReturnTypeViolations(
       suggestionsConfigs,
     );
 
-    const messageId = "returnType";
-    const data = {
-      actual: Immutability[immutability],
-      expected: Immutability[enforcement],
-    };
-
     return [
       {
         node: node.returnType,
-        messageId,
-        data,
+        messageId: "returnType",
+        data: {
+          actual: Immutability[immutability],
+          expected: Immutability[enforcement],
+        },
         fix,
         suggest:
-          suggestionFixers?.map((fix) => ({
-            messageId,
-            data,
+          suggestionFixers?.map(({ fix, message }) => ({
+            messageId: "userDefined",
+            data: {
+              message,
+            },
             fix,
           })) ?? null,
       },
@@ -713,22 +733,21 @@ function getReturnTypeViolations(
           suggestionsConfigs,
         );
 
-  const messageId = "returnType";
-  const data = {
-    actual: Immutability[immutability],
-    expected: Immutability[enforcement],
-  };
-
   return [
     {
       node: hasID(node) && node.id !== null ? node.id : node,
-      messageId,
-      data,
+      messageId: "returnType",
+      data: {
+        actual: Immutability[immutability],
+        expected: Immutability[enforcement],
+      },
       fix,
       suggest:
-        suggestionFixers?.map((fix) => ({
-          messageId,
-          data,
+        suggestionFixers?.map(({ fix, message }) => ({
+          messageId: "userDefined",
+          data: {
+            message,
+          },
           fix,
         })) ?? null,
     },
@@ -807,17 +826,16 @@ function checkVariable(
     const fix: NonNullable<Descriptor["fix"]> | null = (fixer) =>
       fixer.insertTextBefore(node.key, "readonly ");
 
-    const messageId = "propertyModifier";
     return {
       context,
       descriptors: [
         {
           node,
-          messageId,
+          messageId: "propertyModifier",
           fix: rawFixerConfig === undefined ? null : fix,
           suggest: [
             {
-              messageId,
+              messageId: "propertyModifierSuggestion",
               fix,
             },
           ],
@@ -912,9 +930,11 @@ function checkVariable(
           data,
           fix,
           suggest:
-            suggestionFixers?.map((fix) => ({
-              messageId,
-              data,
+            suggestionFixers?.map(({ fix, message }) => ({
+              messageId: "userDefined",
+              data: {
+                message,
+              },
               fix,
             })) ?? null,
         };


Test patch:
diff --git a/tests/rules/prefer-immutable-types/ts/parameters/invalid.ts b/tests/rules/prefer-immutable-types/ts/parameters/invalid.ts
index a5377dd..275caeb 100644
--- a/tests/rules/prefer-immutable-types/ts/parameters/invalid.ts
+++ b/tests/rules/prefer-immutable-types/ts/parameters/invalid.ts
@@ -46,7 +46,8 @@ const tests: Array<
         column: 14,
         suggestions: [
           {
-            messageId: "parameter",
+            messageId: "userDefined",
+            data: { message: "Surround with Readonly." },
             output:
               "function foo(arg1: Readonly<{ foo: string }>, arg2: { foo: number }) {}",
           },
@@ -59,7 +60,8 @@ const tests: Array<
         column: 37,
         suggestions: [
           {
-            messageId: "parameter",
+            messageId: "userDefined",
+            data: { message: "Surround with Readonly." },
             output:
               "function foo(arg1: { foo: string }, arg2: Readonly<{ foo: number }>) {}",
           },
@@ -179,7 +181,7 @@ const tests: Array<
         column: 5,
         suggestions: [
           {
-            messageId: "propertyModifier",
+            messageId: "propertyModifierSuggestion",
             output: dedent`
               class Klass {
                 constructor (
@@ -199,7 +201,7 @@ const tests: Array<
         column: 5,
         suggestions: [
           {
-            messageId: "propertyModifier",
+            messageId: "propertyModifierSuggestion",
             output: dedent`
               class Klass {
                 constructor (
@@ -219,7 +221,7 @@ const tests: Array<
         column: 5,
         suggestions: [
           {
-            messageId: "propertyModifier",
+            messageId: "propertyModifierSuggestion",
             output: dedent`
               class Klass {
                 constructor (
@@ -245,7 +247,8 @@ const tests: Array<
         column: 46,
         suggestions: [
           {
-            messageId: "parameter",
+            messageId: "userDefined",
+            data: { message: "Surround with Readonly." },
             output:
               "function foo(arg0: { foo: string | number }, arg1: Readonly<{ foo: string | number }>): arg0 is { foo: number } {}",
           },
@@ -295,7 +298,8 @@ const tests: Array<
         column: 14,
         suggestions: [
           {
-            messageId: "parameter",
+            messageId: "userDefined",
+            data: { message: "Replace with: ReadonlyDeep<{ foo: string }>" },
             output: "function foo(arg1: ReadonlyDeep<{ foo: string }>) {}",
           },
         ],
@@ -329,7 +333,10 @@ const tests: Array<
         column: 14,
         suggestions: [
           {
-            messageId: "parameter",
+            messageId: "userDefined",
+            data: {
+              message: "Replace with: ReadonlyDeep<{ foo: { bar: string } }>",
+            },
             output:
               "function foo(arg1: ReadonlyDeep<{ foo: { bar: string } }>) {}",
           },
@@ -357,7 +364,10 @@ const tests: Array<
         column: 14,
         suggestions: [
           {
-            messageId: "parameter",
+            messageId: "userDefined",
+            data: {
+              message: "Use ReadonlyArray instead of Array.",
+            },
             output: dedent`
               function foo(arg: ReadonlyArray<string>) {}
               function foo(arg: string[]) {}
@@ -369,6 +379,22 @@ const tests: Array<
               function foo(arg: ReadonlyMap<string, string>) {}
             `,
           },
+          {
+            messageId: "userDefined",
+            data: {
+              message: "Surround with Readonly.",
+            },
+            output: dedent`
+              function foo(arg: Readonly<Array<string>>) {}
+              function foo(arg: string[]) {}
+              function foo(arg: Set<string>) {}
+              function foo(arg: Map<string, string>) {}
+              function foo(arg: ReadonlyArray<string>) {}
+              function foo(arg: readonly string[]) {}
+              function foo(arg: ReadonlySet<string>) {}
+              function foo(arg: ReadonlyMap<string, string>) {}
+            `,
+          },
         ],
       },
       {
@@ -378,7 +404,10 @@ const tests: Array<
         column: 14,
         suggestions: [
           {
-            messageId: "parameter",
+            messageId: "userDefined",
+            data: {
+              message: "Prepend with readonly.",
+            },
             output: dedent`
               function foo(arg: Array<string>) {}
               function foo(arg: readonly string[]) {}
@@ -390,6 +419,22 @@ const tests: Array<
               function foo(arg: ReadonlyMap<string, string>) {}
             `,
           },
+          {
+            messageId: "userDefined",
+            data: {
+              message: "Surround with Readonly.",
+            },
+            output: dedent`
+              function foo(arg: Array<string>) {}
+              function foo(arg: Readonly<string[]>) {}
+              function foo(arg: Set<string>) {}
+              function foo(arg: Map<string, string>) {}
+              function foo(arg: ReadonlyArray<string>) {}
+              function foo(arg: readonly string[]) {}
+              function foo(arg: ReadonlySet<string>) {}
+              function foo(arg: ReadonlyMap<string, string>) {}
+            `,
+          },
         ],
       },
       {
@@ -399,7 +444,10 @@ const tests: Array<
         column: 14,
         suggestions: [
           {
-            messageId: "parameter",
+            messageId: "userDefined",
+            data: {
+              message: "Use ReadonlySet instead of Set.",
+            },
             output: dedent`
               function foo(arg: Array<string>) {}
               function foo(arg: string[]) {}
@@ -411,6 +459,22 @@ const tests: Array<
               function foo(arg: ReadonlyMap<string, string>) {}
             `,
           },
+          {
+            messageId: "userDefined",
+            data: {
+              message: "Surround with Readonly.",
+            },
+            output: dedent`
+              function foo(arg: Array<string>) {}
+              function foo(arg: string[]) {}
+              function foo(arg: Readonly<Set<string>>) {}
+              function foo(arg: Map<string, string>) {}
+              function foo(arg: ReadonlyArray<string>) {}
+              function foo(arg: readonly string[]) {}
+              function foo(arg: ReadonlySet<string>) {}
+              function foo(arg: ReadonlyMap<string, string>) {}
+            `,
+          },
         ],
       },
       {
@@ -420,7 +484,10 @@ const tests: Array<
         column: 14,
         suggestions: [
           {
-            messageId: "parameter",
+            messageId: "userDefined",
+            data: {
+              message: "Use ReadonlyMap instead of Map.",
+            },
             output: dedent`
               function foo(arg: Array<string>) {}
               function foo(arg: string[]) {}
@@ -432,6 +499,22 @@ const tests: Array<
               function foo(arg: ReadonlyMap<string, string>) {}
             `,
           },
+          {
+            messageId: "userDefined",
+            data: {
+              message: "Surround with Readonly.",
+            },
+            output: dedent`
+              function foo(arg: Array<string>) {}
+              function foo(arg: string[]) {}
+              function foo(arg: Set<string>) {}
+              function foo(arg: Readonly<Map<string, string>>) {}
+              function foo(arg: ReadonlyArray<string>) {}
+              function foo(arg: readonly string[]) {}
+              function foo(arg: ReadonlySet<string>) {}
+              function foo(arg: ReadonlyMap<string, string>) {}
+            `,
+          },
         ],
       },
     ],
diff --git a/tests/rules/prefer-immutable-types/ts/return-types/invalid.ts b/tests/rules/prefer-immutable-types/ts/return-types/invalid.ts
index 238528b..1147105 100644
--- a/tests/rules/prefer-immutable-types/ts/return-types/invalid.ts
+++ b/tests/rules/prefer-immutable-types/ts/return-types/invalid.ts
@@ -68,7 +68,10 @@ const tests: Array<
         column: 26,
         suggestions: [
           {
-            messageId: "returnType",
+            messageId: "userDefined",
+            data: {
+              message: "Surround with Readonly.",
+            },
             output: dedent`
               function foo(arg: number): Readonly<{ foo: string }>;
               function foo(arg: string): Readonly<{ foo: number }>;
@@ -85,7 +88,10 @@ const tests: Array<
         column: 27,
         suggestions: [
           {
-            messageId: "returnType",
+            messageId: "userDefined",
+            data: {
+              message: "Surround with Readonly.",
+            },
             output: dedent`
               function foo(arg: number): { foo: string };
               function foo(arg: string): Readonly<{ foo: number }>;
diff --git a/tests/rules/prefer-immutable-types/ts/variables/invalid.ts b/tests/rules/prefer-immutable-types/ts/variables/invalid.ts
index c724f0d..44ef78f 100644
--- a/tests/rules/prefer-immutable-types/ts/variables/invalid.ts
+++ b/tests/rules/prefer-immutable-types/ts/variables/invalid.ts
@@ -65,7 +65,10 @@ const tests: Array<
         column: 7,
         suggestions: [
           {
-            messageId: "variable",
+            messageId: "userDefined",
+            data: {
+              message: "Surround with Readonly.",
+            },
             output: dedent`
               const foo: Readonly<{ foo: string }> = {} as any,
                     bar: Readonly<{ foo: number }> = {} as any;
@@ -168,7 +171,7 @@ const tests: Array<
         column: 3,
         suggestions: [
           {
-            messageId: "propertyModifier",
+            messageId: "propertyModifierSuggestion",
             output: dedent`
               class Klass {
                 readonly foo: number;
@@ -187,7 +190,7 @@ const tests: Array<
         column: 3,
         suggestions: [
           {
-            messageId: "propertyModifier",
+            messageId: "propertyModifierSuggestion",
             output: dedent`
               class Klass {
                 foo: number;
@@ -206,7 +209,7 @@ const tests: Array<
         column: 3,
         suggestions: [
           {
-            messageId: "propertyModifier",
+            messageId: "propertyModifierSuggestion",
             output: dedent`
               class Klass {
                 foo: number;
@@ -225,7 +228,7 @@ const tests: Array<
         column: 3,
         suggestions: [
           {
-            messageId: "propertyModifier",
+            messageId: "propertyModifierSuggestion",
             output: dedent`
               class Klass {
                 foo: number;