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

Issue:
`regexp/prefer-d`: Add an option for whole `\d`s
**Description**
I think that the range `0-9` can be more readable than `\d` in some situations. E.g. `[0-9a-f]` vs `[\da-f]`.

I think we should an option that makes it so that only whole character classes that are equal to `\d` or `\D` are reported. So `[0-9]` will be reported but `[0-9a-f]` won't.

What do you think @ota-meshi?

Pull request description:
Add `insideCharacterClass` option to `prefer-d`
This fixes #341.

The new option adds 3 modes for character class elements:

- `ignore`: This is the don't-care option.
- `d` *(default)*: Replace all with `\d`. This is the current behaviour.
- `range`: Replace all with `0-9`.

Patch:
diff --git a/docs/rules/prefer-d.md b/docs/rules/prefer-d.md
index e66f7e7..6067404 100644
--- a/docs/rules/prefer-d.md
+++ b/docs/rules/prefer-d.md
@@ -34,7 +34,79 @@ var foo = /[^0-9]/;
 
 ## :wrench: Options
 
-Nothing.
+```json5
+{
+  "regexp/prefer-d": [
+    "error",
+    {
+        "insideCharacterClass": "d"
+    }
+  ]
+}
+```
+
+### `insideCharacterClass`
+
+This option control how character class element equivalent to `\d` will be treated.
+
+*Note:* This option does not affect character classes equivalent to `\d`. E.g. `[\d]`, `[0-9]`, and `[0123456789]` are unaffected.
+
+- `insideCharacterClass: "d"` (_default_)
+
+  Character class element equivalent to `\d` will be reported and replaced with `\d`.
+
+  <eslint-code-block fix>
+
+  ```js
+  /* eslint regexp/prefer-d: ["error", { insideCharacterClass: "d" }] */
+
+  /* ✓ GOOD */
+  var foo = /[\da-z]/;
+
+  /* ✗ BAD */
+  var foo = /[0-9a-z]/;
+  var foo = /[0-9]/;
+  ```
+
+  </eslint-code-block>
+
+- `insideCharacterClass: "range"`
+
+  Character class element equivalent to `\d` will be reported and replaced with the range `0-9`.
+
+  <eslint-code-block fix>
+
+  ```js
+  /* eslint regexp/prefer-d: ["error", { insideCharacterClass: "range" }] */
+
+  /* ✓ GOOD */
+  var foo = /[0-9a-z]/;
+
+  /* ✗ BAD */
+  var foo = /[\da-z]/;
+  var foo = /[0-9]/;
+  ```
+
+  </eslint-code-block>
+
+- `insideCharacterClass: "ignore"`
+
+  Character class element will not be reported.
+
+  <eslint-code-block fix>
+
+  ```js
+  /* eslint regexp/prefer-d: ["error", { insideCharacterClass: "ignore" }] */
+
+  /* ✓ GOOD */
+  var foo = /[\da-z]/;
+  var foo = /[0-9a-z]/;
+
+  /* ✗ BAD */
+  var foo = /[0-9]/;
+  ```
+
+  </eslint-code-block>
 
 ## :rocket: Version
 
diff --git a/lib/rules/prefer-d.ts b/lib/rules/prefer-d.ts
index 53b3473..cdc6565 100644
--- a/lib/rules/prefer-d.ts
+++ b/lib/rules/prefer-d.ts
@@ -8,6 +8,27 @@ import {
 } from "../utils"
 import { Chars, toCharSet } from "regexp-ast-analysis"
 import { mention } from "../utils/mention"
+import type {
+    CharacterClassElement,
+    CharacterClassRange,
+    EscapeCharacterSet,
+} from "regexpp/ast"
+
+/**
+ * Returns whether the given character class element is equivalent to `\d`.
+ */
+function isDigits(
+    element: CharacterClassElement,
+): element is EscapeCharacterSet | CharacterClassRange {
+    return (
+        (element.type === "CharacterSet" &&
+            element.kind === "digit" &&
+            !element.negate) ||
+        (element.type === "CharacterClassRange" &&
+            element.min.value === CP_DIGIT_ZERO &&
+            element.max.value === CP_DIGIT_NINE)
+    )
+}
 
 export default createRule("prefer-d", {
     meta: {
@@ -17,14 +38,28 @@ export default createRule("prefer-d", {
             recommended: true,
         },
         fixable: "code",
-        schema: [],
+        schema: [
+            {
+                type: "object",
+                properties: {
+                    insideCharacterClass: {
+                        type: "string",
+                        enum: ["ignore", "range", "d"],
+                    },
+                },
+                additionalProperties: false,
+            },
+        ],
         messages: {
             unexpected:
                 "Unexpected {{type}} {{expr}}. Use '{{instead}}' instead.",
         },
-        type: "suggestion", // "problem",
+        type: "suggestion",
     },
     create(context) {
+        const insideCharacterClass: "ignore" | "range" | "d" =
+            context.options[0]?.insideCharacterClass ?? "d"
+
         /**
          * Create visitor
          */
@@ -34,8 +69,6 @@ export default createRule("prefer-d", {
             getRegexpLocation,
             fixReplaceNode,
         }: RegExpContext): RegExpVisitor.Handlers {
-            let reportedCharacterClass = false
-
             return {
                 onCharacterClassEnter(ccNode) {
                     const charSet = toCharSet(ccNode, flags)
@@ -48,8 +81,6 @@ export default createRule("prefer-d", {
                     }
 
                     if (predefined) {
-                        reportedCharacterClass = true
-
                         context.report({
                             node,
                             loc: getRegexpLocation(ccNode),
@@ -61,33 +92,34 @@ export default createRule("prefer-d", {
                             },
                             fix: fixReplaceNode(ccNode, predefined),
                         })
+                        return
                     }
-                },
-                onCharacterClassLeave() {
-                    reportedCharacterClass = false
-                },
-                onCharacterClassRangeEnter(ccrNode) {
-                    if (reportedCharacterClass) {
+
+                    if (insideCharacterClass === "ignore") {
                         return
                     }
 
-                    if (
-                        ccrNode.min.value === CP_DIGIT_ZERO &&
-                        ccrNode.max.value === CP_DIGIT_NINE
-                    ) {
-                        const instead = "\\d"
+                    const expected =
+                        insideCharacterClass === "d" ? "\\d" : "0-9"
 
-                        context.report({
-                            node,
-                            loc: getRegexpLocation(ccrNode),
-                            messageId: "unexpected",
-                            data: {
-                                type: "character class range",
-                                expr: mention(ccrNode),
-                                instead,
-                            },
-                            fix: fixReplaceNode(ccrNode, instead),
-                        })
+                    // check the elements in this character class
+                    for (const e of ccNode.elements) {
+                        if (isDigits(e) && e.raw !== expected) {
+                            context.report({
+                                node,
+                                loc: getRegexpLocation(e),
+                                messageId: "unexpected",
+                                data: {
+                                    type:
+                                        e.type === "CharacterSet"
+                                            ? "character set"
+                                            : "character class range",
+                                    expr: mention(e),
+                                    instead: expected,
+                                },
+                                fix: fixReplaceNode(e, expected),
+                            })
+                        }
                     }
                 },
             }


Test patch:
diff --git a/tests/lib/rules/prefer-d.ts b/tests/lib/rules/prefer-d.ts
index 90be162..bdae490 100644
--- a/tests/lib/rules/prefer-d.ts
+++ b/tests/lib/rules/prefer-d.ts
@@ -9,7 +9,26 @@ const tester = new RuleTester({
 })
 
 tester.run("prefer-d", rule as any, {
-    valid: ["/\\d/", "/[1-9]/"],
+    valid: [
+        String.raw`/\d/`,
+        String.raw`/[1-9]/`,
+        {
+            code: String.raw`/[0-9a-z]/`,
+            options: [{ insideCharacterClass: "ignore" }],
+        },
+        {
+            code: String.raw`/[\da-z]/`,
+            options: [{ insideCharacterClass: "ignore" }],
+        },
+        {
+            code: String.raw`/[0-9a-z]/`,
+            options: [{ insideCharacterClass: "range" }],
+        },
+        {
+            code: String.raw`/[\da-z]/`,
+            options: [{ insideCharacterClass: "d" }],
+        },
+    ],
     invalid: [
         {
             code: "/[0-9]/",
@@ -66,5 +85,19 @@ tester.run("prefer-d", rule as any, {
             output: null,
             errors: ["Unexpected character class '[0-9]'. Use '\\d' instead."],
         },
+        {
+            code: String.raw`/[0-9a-z]/`,
+            output: String.raw`/[\da-z]/`,
+            options: [{ insideCharacterClass: "d" }],
+            errors: [
+                "Unexpected character class range '0-9'. Use '\\d' instead.",
+            ],
+        },
+        {
+            code: String.raw`/[\da-z]/`,
+            output: String.raw`/[0-9a-z]/`,
+            options: [{ insideCharacterClass: "range" }],
+            errors: ["Unexpected character set '\\d'. Use '0-9' instead."],
+        },
     ],
 })