Repository: ota-meshi/eslint-plugin-regexp (ts)

Issue:
Add `prefer-named-replacement` rule
As per [this comment](https://github.com/ota-meshi/eslint-plugin-regexp/pull/339#discussion_r717477491).

> `"str".replace(/(?<foo>foo)/, '$<foo>')` > `"str".replace(/(?<foo>foo)/, '$1')`

Pull request description:
Add prefer-named-replacement rule
close #344

Patch:
diff --git a/README.md b/README.md
index ea7660b..24f580b 100644
--- a/README.md
+++ b/README.md
@@ -175,6 +175,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
 | [regexp/prefer-lookaround](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-lookaround.html) | prefer lookarounds over capturing group that do not replace | :wrench: |
 | [regexp/prefer-named-backreference](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-named-backreference.html) | enforce using named backreferences | :wrench: |
 | [regexp/prefer-named-capture-group](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-named-capture-group.html) | enforce using named capture groups |  |
+| [regexp/prefer-named-replacement](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-named-replacement.html) | enforce using named replacement | :wrench: |
 | [regexp/prefer-plus-quantifier](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-plus-quantifier.html) | enforce using `+` quantifier | :star::wrench: |
 | [regexp/prefer-question-quantifier](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-question-quantifier.html) | enforce using `?` quantifier | :star::wrench: |
 | [regexp/prefer-star-quantifier](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-star-quantifier.html) | enforce using `*` quantifier | :star::wrench: |
diff --git a/docs/rules/README.md b/docs/rules/README.md
index d9bcf37..f993088 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -84,6 +84,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
 | [regexp/prefer-lookaround](./prefer-lookaround.md) | prefer lookarounds over capturing group that do not replace | :wrench: |
 | [regexp/prefer-named-backreference](./prefer-named-backreference.md) | enforce using named backreferences | :wrench: |
 | [regexp/prefer-named-capture-group](./prefer-named-capture-group.md) | enforce using named capture groups |  |
+| [regexp/prefer-named-replacement](./prefer-named-replacement.md) | enforce using named replacement | :wrench: |
 | [regexp/prefer-plus-quantifier](./prefer-plus-quantifier.md) | enforce using `+` quantifier | :star::wrench: |
 | [regexp/prefer-question-quantifier](./prefer-question-quantifier.md) | enforce using `?` quantifier | :star::wrench: |
 | [regexp/prefer-star-quantifier](./prefer-star-quantifier.md) | enforce using `*` quantifier | :star::wrench: |
diff --git a/docs/rules/prefer-named-backreference.md b/docs/rules/prefer-named-backreference.md
index f989d00..8960956 100644
--- a/docs/rules/prefer-named-backreference.md
+++ b/docs/rules/prefer-named-backreference.md
@@ -34,6 +34,14 @@ var foo = /(?<foo>a)\1/
 
 Nothing.
 
+## :couple: Related rules
+
+- [regexp/prefer-named-capture-group]
+- [regexp/prefer-named-replacement]
+
+[regexp/prefer-named-capture-group]: ./prefer-named-capture-group.md
+[regexp/prefer-named-replacement]: ./prefer-named-replacement.md
+
 ## :rocket: Version
 
 This rule was introduced in eslint-plugin-regexp v0.9.0
diff --git a/docs/rules/prefer-named-capture-group.md b/docs/rules/prefer-named-capture-group.md
index 59fd174..f59a369 100644
--- a/docs/rules/prefer-named-capture-group.md
+++ b/docs/rules/prefer-named-capture-group.md
@@ -34,6 +34,14 @@ var foo = /\b(foo)+\b/;
 
 Nothing.
 
+## :couple: Related rules
+
+- [regexp/prefer-named-backreference]
+- [regexp/prefer-named-replacement]
+
+[regexp/prefer-named-backreference]: ./prefer-named-backreference.md
+[regexp/prefer-named-replacement]: ./prefer-named-replacement.md
+
 ## :books: Further reading
 
 - [prefer-named-capture-group]
diff --git a/docs/rules/prefer-named-replacement.md b/docs/rules/prefer-named-replacement.md
new file mode 100644
index 0000000..8d59b52
--- /dev/null
+++ b/docs/rules/prefer-named-replacement.md
@@ -0,0 +1,57 @@
+---
+pageClass: "rule-details"
+sidebarDepth: 0
+title: "regexp/prefer-named-replacement"
+description: "enforce using named replacement"
+---
+# regexp/prefer-named-replacement
+
+> enforce using named replacement
+
+- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
+- :wrench: The `--fix` option on the [command line](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems) can automatically fix some of the problems reported by this rule.
+
+## :book: Rule Details
+
+This rule reports and fixes `$n` parameter in replacement string that do not use the name of their referenced capturing group.
+
+<eslint-code-block fix>
+
+```js
+/* eslint regexp/prefer-named-replacement: "error" */
+
+/* ✓ GOOD */
+"abc".replace(/a(?<foo>b)c/, '$<foo>');
+"abc".replace(/a(b)c/, '$1');
+
+/* ✗ BAD */
+"abc".replace(/a(?<foo>b)c/, '$1');
+```
+
+</eslint-code-block>
+
+## :wrench: Options
+
+```json
+{
+  "regexp/prefer-named-replacement": ["error", {
+      "strictTypes": true
+  }]
+}
+```
+
+- `strictTypes` ... If `true`, strictly check the type of object to determine if the string instance was used in `replace()` and `replaceAll()`. Default is `true`.  
+  This option is always on when using TypeScript.
+
+## :couple: Related rules
+
+- [regexp/prefer-named-backreference]
+- [regexp/prefer-named-capture-group]
+
+[regexp/prefer-named-backreference]: ./prefer-named-backreference.md
+[regexp/prefer-named-capture-group]: ./prefer-named-capture-group.md
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/lib/rules/prefer-named-replacement.ts)
+- [Test source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/tests/lib/rules/prefer-named-replacement.ts)
diff --git a/lib/rules/prefer-named-replacement.ts b/lib/rules/prefer-named-replacement.ts
new file mode 100644
index 0000000..2e0e43f
--- /dev/null
+++ b/lib/rules/prefer-named-replacement.ts
@@ -0,0 +1,81 @@
+import type { RegExpVisitor } from "regexpp/visitor"
+import type { RegExpContext } from "../utils"
+import { createRule, defineRegexpVisitor } from "../utils"
+
+export default createRule("prefer-named-replacement", {
+    meta: {
+        docs: {
+            description: "enforce using named replacement",
+            category: "Stylistic Issues",
+            recommended: false,
+        },
+        fixable: "code",
+        schema: [
+            {
+                type: "object",
+                properties: {
+                    strictTypes: { type: "boolean" },
+                },
+                additionalProperties: false,
+            },
+        ],
+        messages: {
+            unexpected: "Unexpected indexed reference in replacement string.",
+        },
+        type: "suggestion", // "problem",
+    },
+    create(context) {
+        const strictTypes = context.options[0]?.strictTypes ?? true
+        const sourceCode = context.getSourceCode()
+
+        /**
+         * Create visitor
+         */
+        function createVisitor(
+            regexpContext: RegExpContext,
+        ): RegExpVisitor.Handlers {
+            const {
+                node,
+                getAllCapturingGroups,
+                getCapturingGroupReferences,
+            } = regexpContext
+
+            const capturingGroups = getAllCapturingGroups()
+            if (!capturingGroups.length) {
+                return {}
+            }
+
+            for (const ref of getCapturingGroupReferences({ strictTypes })) {
+                if (
+                    ref.type === "ReplacementRef" &&
+                    ref.kind === "index" &&
+                    ref.range
+                ) {
+                    const cgNode = capturingGroups[ref.ref - 1]
+                    if (cgNode && cgNode.name) {
+                        context.report({
+                            node,
+                            loc: {
+                                start: sourceCode.getLocFromIndex(ref.range[0]),
+                                end: sourceCode.getLocFromIndex(ref.range[1]),
+                            },
+                            messageId: "unexpected",
+                            fix(fixer) {
+                                return fixer.replaceTextRange(
+                                    ref.range!,
+                                    `$<${cgNode.name}>`,
+                                )
+                            },
+                        })
+                    }
+                }
+            }
+
+            return {}
+        }
+
+        return defineRegexpVisitor(context, {
+            createVisitor,
+        })
+    },
+})
diff --git a/lib/utils/extract-capturing-group-references.ts b/lib/utils/extract-capturing-group-references.ts
index c5f6c54..29066fe 100644
--- a/lib/utils/extract-capturing-group-references.ts
+++ b/lib/utils/extract-capturing-group-references.ts
@@ -131,9 +131,9 @@ export type CapturingGroupReference =
 
 type ExtractCapturingGroupReferencesContext = {
     flags: ReadonlyFlags
-    typeTracer: TypeTracker
     countOfCapturingGroup: number
     context: Rule.RuleContext
+    isString: (node: Expression) => boolean
 }
 
 /**
@@ -145,12 +145,17 @@ export function* extractCapturingGroupReferences(
     typeTracer: TypeTracker,
     countOfCapturingGroup: number,
     context: Rule.RuleContext,
+    options: {
+        strictTypes: boolean
+    },
 ): Iterable<CapturingGroupReference> {
-    const ctx = {
+    const ctx: ExtractCapturingGroupReferencesContext = {
         flags,
-        typeTracer,
         countOfCapturingGroup,
         context,
+        isString: options.strictTypes
+            ? (n) => typeTracer.isString(n)
+            : (n) => typeTracer.maybeString(n),
     }
     for (const ref of extractExpressionReferences(node, context)) {
         if (ref.type === "argument") {
@@ -187,7 +192,7 @@ function* iterateForArgument(
     if (callExpression.arguments[0] !== argument) {
         return
     }
-    if (!ctx.typeTracer.isString(callExpression.callee.object)) {
+    if (!ctx.isString(callExpression.callee.object)) {
         yield {
             type: "UnknownUsage",
             node: argument,
diff --git a/lib/utils/index.ts b/lib/utils/index.ts
index a529f85..34f58d2 100644
--- a/lib/utils/index.ts
+++ b/lib/utils/index.ts
@@ -91,7 +91,9 @@ type RegExpContextBase = {
     /**
      * Returns the capturing group references
      */
-    getCapturingGroupReferences: () => CapturingGroupReference[]
+    getCapturingGroupReferences: (options?: {
+        strictTypes?: boolean // default true
+    }) => CapturingGroupReference[]
 
     /**
      * Returns a list of all capturing groups in the order of their numbers.
@@ -610,7 +612,10 @@ function buildRegExpContextBase({
     const sourceCode = context.getSourceCode()
 
     let cacheUsageOfPattern: UsageOfPattern | null = null
-    let cacheCapturingGroupReference: CapturingGroupReference[] | null = null
+    const cacheCapturingGroupReferenceMap = new Map<
+        boolean /* strictTypes */,
+        CapturingGroupReference[]
+    >()
     let cacheAllCapturingGroups: CapturingGroup[] | null = null
     return {
         getRegexpLocation: (range, offsets) => {
@@ -643,21 +648,33 @@ function buildRegExpContextBase({
         },
         getUsageOfPattern: () =>
             (cacheUsageOfPattern ??= getUsageOfPattern(regexpNode, context)),
-        getCapturingGroupReferences: () => {
+        getCapturingGroupReferences: (options?: {
+            strictTypes?: boolean // default true
+        }) => {
+            const strictTypes = Boolean(options?.strictTypes ?? true)
+            const cacheCapturingGroupReference = cacheCapturingGroupReferenceMap.get(
+                strictTypes,
+            )
             if (cacheCapturingGroupReference) {
                 return cacheCapturingGroupReference
             }
             const countOfCapturingGroup = getAllCapturingGroupsWithCache()
                 .length
-            return (cacheCapturingGroupReference = [
+            const capturingGroupReferences = [
                 ...extractCapturingGroupReferences(
                     regexpNode,
                     flags,
                     createTypeTracker(context),
                     countOfCapturingGroup,
                     context,
+                    { strictTypes },
                 ),
-            ])
+            ]
+            cacheCapturingGroupReferenceMap.set(
+                strictTypes,
+                capturingGroupReferences,
+            )
+            return capturingGroupReferences
         },
         getAllCapturingGroups: getAllCapturingGroupsWithCache,
 
diff --git a/lib/utils/rules.ts b/lib/utils/rules.ts
index e07622b..fb15707 100644
--- a/lib/utils/rules.ts
+++ b/lib/utils/rules.ts
@@ -55,6 +55,7 @@ import preferEscapeReplacementDollarChar from "../rules/prefer-escape-replacemen
 import preferLookaround from "../rules/prefer-lookaround"
 import preferNamedBackreference from "../rules/prefer-named-backreference"
 import preferNamedCaptureGroup from "../rules/prefer-named-capture-group"
+import preferNamedReplacement from "../rules/prefer-named-replacement"
 import preferPlusQuantifier from "../rules/prefer-plus-quantifier"
 import preferPredefinedAssertion from "../rules/prefer-predefined-assertion"
 import preferQuantifier from "../rules/prefer-quantifier"
@@ -131,6 +132,7 @@ export const rules = [
     preferLookaround,
     preferNamedBackreference,
     preferNamedCaptureGroup,
+    preferNamedReplacement,
     preferPlusQuantifier,
     preferPredefinedAssertion,
     preferQuantifier,


Test patch:
diff --git a/tests/lib/rules/prefer-named-replacement.ts b/tests/lib/rules/prefer-named-replacement.ts
new file mode 100644
index 0000000..7cc5f2d
--- /dev/null
+++ b/tests/lib/rules/prefer-named-replacement.ts
@@ -0,0 +1,74 @@
+import { RuleTester } from "eslint"
+import rule from "../../../lib/rules/prefer-named-replacement"
+
+const tester = new RuleTester({
+    parserOptions: {
+        ecmaVersion: 2020,
+        sourceType: "module",
+    },
+})
+
+tester.run("prefer-named-replacement", rule as any, {
+    valid: [
+        `"str".replace(/regexp/, "foo")`,
+        `"str".replace(/a(b)c/, "_$1_")`,
+        `"str".replaceAll(/a(b)c/, "_$1_")`,
+        `"str".replace(/a(?<foo>b)c/, "_$<foo>_")`,
+        `"str".replaceAll(/a(?<foo>b)c/, "_$<foo>_")`,
+        `"str".replace(/a(?<foo>b)c/, "_$0_")`,
+        `"str".replace(/(a)(?<foo>b)c/, "_$1_")`,
+        `"str".replace(/a(b)c/, "_$2_")`,
+        `unknown.replace(/a(?<foo>b)c/, "_$1_")`,
+        `unknown.replaceAll(/a(?<foo>b)c/, "_$1_")`,
+    ],
+    invalid: [
+        {
+            code: `"str".replace(/a(?<foo>b)c/, "_$1_")`,
+            output: `"str".replace(/a(?<foo>b)c/, "_$<foo>_")`,
+            errors: [
+                {
+                    message:
+                        "Unexpected indexed reference in replacement string.",
+                    line: 1,
+                    column: 32,
+                },
+            ],
+        },
+        {
+            code: `"str".replaceAll(/a(?<foo>b)c/, "_$1_")`,
+            output: `"str".replaceAll(/a(?<foo>b)c/, "_$<foo>_")`,
+            errors: [
+                {
+                    message:
+                        "Unexpected indexed reference in replacement string.",
+                    line: 1,
+                    column: 35,
+                },
+            ],
+        },
+        {
+            code: `"str".replace(/(a)(?<foo>b)c/, "_$1$2_")`,
+            output: `"str".replace(/(a)(?<foo>b)c/, "_$1$<foo>_")`,
+            errors: [
+                {
+                    message:
+                        "Unexpected indexed reference in replacement string.",
+                    line: 1,
+                    column: 36,
+                },
+            ],
+        },
+        {
+            code: `unknown.replace(/a(?<foo>b)c/, "_$1_")`,
+            output: `unknown.replace(/a(?<foo>b)c/, "_$<foo>_")`,
+            options: [{ strictTypes: false }],
+            errors: ["Unexpected indexed reference in replacement string."],
+        },
+        {
+            code: `unknown.replaceAll(/a(?<foo>b)c/, "_$1_")`,
+            output: `unknown.replaceAll(/a(?<foo>b)c/, "_$<foo>_")`,
+            options: [{ strictTypes: false }],
+            errors: ["Unexpected indexed reference in replacement string."],
+        },
+    ],
+})