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

Issue:
Suggest `i` flag if it simplifies the pattern
If a pattern can be simplified by adding the `i` flag (e.g. `/[a-fA-F]/` -> `/[a-f]/i`), it might be a good idea to suggest that to the user.

The conditions for this rule to add the `i` flag (and subsequently simplify the pattern) are:

1. The `i` flag isn't present.
2. All characters, character classes, and character sets match the same character(s) regardless of the `i` flag (aka. the flag doesn't change the meaning of the pattern.)
3. There is at least one character class that can be simplified because of the added `i` flag. E.g. a character range can be removed, or a character class can be replaced by a character or character set, or similar. 


Pull request:
Add `regexp/use-ignore-case` rule
This resolves #201, a somewhat long-standing issue.

Other changes:

- I moved the case-invariance logic from `no-useless-flags` into its own file, so `use-ignore-case` can use it.
- I fixed a bug with `fixReplaceFlags`. The new `includePattern` parameter added in #338 was ignored for regex literals. I noticed this big because `use-ignore-case`'s fix didn't work.

Patch:
diff --git a/README.md b/README.md
index fe71aae..ea7660b 100644
--- a/README.md
+++ b/README.md
@@ -159,6 +159,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
 | [regexp/prefer-regexp-test](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-regexp-test.html) | enforce that `RegExp#test` is used instead of `String#match` and `RegExp#exec` | :wrench: |
 | [regexp/require-unicode-regexp](https://ota-meshi.github.io/eslint-plugin-regexp/rules/require-unicode-regexp.html) | enforce the use of the `u` flag | :wrench: |
 | [regexp/sort-alternatives](https://ota-meshi.github.io/eslint-plugin-regexp/rules/sort-alternatives.html) | sort alternatives if order doesn't matter | :wrench: |
+| [regexp/use-ignore-case](https://ota-meshi.github.io/eslint-plugin-regexp/rules/use-ignore-case.html) | use the `i` flag if it simplifies the pattern | :wrench: |
 
 ### Stylistic Issues
 
diff --git a/docs/rules/README.md b/docs/rules/README.md
index 8be8267..d9bcf37 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -68,6 +68,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
 | [regexp/prefer-regexp-test](./prefer-regexp-test.md) | enforce that `RegExp#test` is used instead of `String#match` and `RegExp#exec` | :wrench: |
 | [regexp/require-unicode-regexp](./require-unicode-regexp.md) | enforce the use of the `u` flag | :wrench: |
 | [regexp/sort-alternatives](./sort-alternatives.md) | sort alternatives if order doesn't matter | :wrench: |
+| [regexp/use-ignore-case](./use-ignore-case.md) | use the `i` flag if it simplifies the pattern | :wrench: |
 
 ### Stylistic Issues
 
diff --git a/docs/rules/no-unused-capturing-group.md b/docs/rules/no-unused-capturing-group.md
index 589fef0..e322e79 100644
--- a/docs/rules/no-unused-capturing-group.md
+++ b/docs/rules/no-unused-capturing-group.md
@@ -14,7 +14,7 @@ since: "v0.6.0"
 
 ## :book: Rule Details
 
-This rule aims to optimize regular expressions by replacing unused capturing groups with non-capturing groups.
+This rule reports unused capturing groups.
 
 <eslint-code-block fix>
 
@@ -51,6 +51,26 @@ var index = '2000-12-31'.search(/(\d{4})-(\d{2})-(\d{2})/) // 0
 
 </eslint-code-block>
 
+### Why no unused capturing groups?
+
+Many people use capturing groups instead of non-capturing groups for convenience. `()` is simpler and feels more natural than the cryptic-looking `(?:)`.
+
+However, using capturing groups in place of non-capturing groups also has negative consequences.
+
+#### Performance
+
+Capturing groups are slower than non-capturing groups.
+
+This is by design. Capturing groups (as the name suggests) captured their matched text. The regex engine has to do extra work every time a capturing group is matched compared to a non-capturing group.
+
+#### Code smell
+
+A capturing group is intended to store its matched text so it can later be used, e.g. in text replacements.
+
+That makes a capturing group quite similar to a variable in that its value (the captured text) is stored (by the regex engine) and can be accessed afterward (by the developer). However, if the captured text is not used, then the capturing group will essentially be an unused variable. This makes the regex harder to understand because other developers will have to constantly ask themselves: "Is this a capturing group because the captured text will be used later on in the code, or because `()` is faster to type?"
+
+Using capturing groups only if the captured text is used makes their usage unambiguous and easier for others to understand.
+
 ## :wrench: Options
 
 ```json
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/docs/rules/use-ignore-case.md b/docs/rules/use-ignore-case.md
new file mode 100644
index 0000000..b4c7f36
--- /dev/null
+++ b/docs/rules/use-ignore-case.md
@@ -0,0 +1,41 @@
+---
+pageClass: "rule-details"
+sidebarDepth: 0
+title: "regexp/use-ignore-case"
+description: "use the `i` flag if it simplifies the pattern"
+---
+# regexp/use-ignore-case
+
+> use the `i` flag if it simplifies the pattern
+
+- :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 regular expressions that can be simplified by adding the `i` flag.
+
+<eslint-code-block fix>
+
+```js
+/* eslint regexp/use-ignore-case: "error" */
+
+/* ✓ GOOD */
+var foo = /\w\d+a/;
+var foo = /\b0x[a-fA-F0-9]+\b/;
+
+/* ✗ BAD */
+var foo = /[a-zA-Z]/;
+var foo = /\b0[xX][a-fA-F0-9]+\b/;
+```
+
+</eslint-code-block>
+
+## :wrench: Options
+
+Nothing.
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/lib/rules/use-ignore-case.ts)
+- [Test source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/tests/lib/rules/use-ignore-case.ts)
diff --git a/lib/rules/no-useless-flag.ts b/lib/rules/no-useless-flag.ts
index 060c496..3af2834 100644
--- a/lib/rules/no-useless-flag.ts
+++ b/lib/rules/no-useless-flag.ts
@@ -22,7 +22,7 @@ import {
 import { createTypeTracker } from "../utils/type-tracker"
 import type { RuleListener } from "../types"
 import type { Rule } from "eslint"
-import { toCharSet } from "regexp-ast-analysis"
+import { isCaseVariant } from "../utils/regexp-ast/case-variation"
 
 type CodePathStack = {
     codePathId: string
@@ -200,50 +200,9 @@ function createUselessIgnoreCaseFlagVisitor(context: Rule.RuleContext) {
                 return {}
             }
 
-            const flagsNoI = { ...flags, ignoreCase: false }
-
-            let unnecessary = true
             return {
-                onAssertionEnter(aNode) {
-                    if (unnecessary) {
-                        if (aNode.kind === "word" && flags.unicode) {
-                            // \b is defined similarly to \w.
-                            // same reason as for \w
-                            unnecessary = false
-                        }
-                    }
-                },
-                onCharacterEnter(cNode) {
-                    if (unnecessary) {
-                        // all characters only accept themselves except if they
-                        // are case sensitive
-                        if (toCharSet(cNode, flags).size > 1) {
-                            unnecessary = false
-                        }
-                    }
-                },
-                onCharacterSetEnter(cNode) {
-                    if (unnecessary) {
-                        if (cNode.kind === "word" && flags.unicode) {
-                            // \w is defined as [0-9A-Za-z_] and this character
-                            // class is case invariant in UTF16 (non-Unicode)
-                            // mode. However, Unicode mode changes however
-                            // ignore case works and this causes `/\w/u` and
-                            // `/\w/iu` to accept different characters,
-                            unnecessary = false
-                        }
-                        if (cNode.kind === "property") {
-                            const caseInsensitive = toCharSet(cNode, flags)
-                            const caseSensitive = toCharSet(cNode, flagsNoI)
-
-                            if (!caseInsensitive.equals(caseSensitive)) {
-                                unnecessary = false
-                            }
-                        }
-                    }
-                },
-                onPatternLeave() {
-                    if (unnecessary) {
+                onPatternLeave(pattern) {
+                    if (!isCaseVariant(pattern, flags, false)) {
                         context.report({
                             node: regexpNode,
                             loc: getFlagLocation("i"),
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),
+                            })
+                        }
                     }
                 },
             }
diff --git a/lib/rules/use-ignore-case.ts b/lib/rules/use-ignore-case.ts
new file mode 100644
index 0000000..3d57763
--- /dev/null
+++ b/lib/rules/use-ignore-case.ts
@@ -0,0 +1,258 @@
+import type { CharSet } from "refa"
+import { Chars, toCharSet } from "regexp-ast-analysis"
+import type { CharacterClass, CharacterClassElement } from "regexpp/ast"
+import type { RegExpVisitor } from "regexpp/visitor"
+import type { RegExpContext } from "../utils"
+import { createRule, defineRegexpVisitor } from "../utils"
+import {
+    getIgnoreCaseFlags,
+    isCaseVariant,
+} from "../utils/regexp-ast/case-variation"
+import { mention } from "../utils/mention"
+import type {
+    PatternSource,
+    PatternRange,
+} from "../utils/ast-utils/pattern-source"
+import type { Rule } from "eslint"
+import { UsageOfPattern } from "../utils/get-usage-of-pattern"
+
+const ELEMENT_ORDER: Record<CharacterClassElement["type"], number> = {
+    Character: 1,
+    CharacterClassRange: 2,
+    CharacterSet: 3,
+}
+
+/**
+ * Finds all character class elements that do not contribute to the whole.
+ */
+function findUseless(
+    elements: readonly CharacterClassElement[],
+    getCharSet: (e: CharacterClassElement) => CharSet,
+    other: CharSet,
+): Set<CharacterClassElement> {
+    const cache = new Map<CharacterClassElement, CharSet>()
+
+    /** A cached version of `getCharSet` */
+    function get(e: CharacterClassElement): CharSet {
+        let cached = cache.get(e)
+        if (cached === undefined) {
+            cached = getCharSet(e)
+            cache.set(e, cached)
+        }
+        return cached
+    }
+
+    // When searching for useless elements, we want to first
+    // search for useless characters, then useless ranges, and
+    // finally useless sets.
+
+    const sortedElements = [...elements]
+        .reverse()
+        .sort((a, b) => ELEMENT_ORDER[a.type] - ELEMENT_ORDER[b.type])
+
+    const useless = new Set<CharacterClassElement>()
+
+    for (const e of sortedElements) {
+        const cs = get(e)
+        if (cs.isSubsetOf(other)) {
+            useless.add(e)
+            continue
+        }
+
+        // the total of all other elements
+        const otherElements = elements.filter((o) => o !== e && !useless.has(o))
+        const total = other.union(...otherElements.map(get))
+        if (cs.isSubsetOf(total)) {
+            useless.add(e)
+            continue
+        }
+    }
+
+    return useless
+}
+
+/** Returns all elements not in the given set */
+function without<T>(iter: Iterable<T>, set: ReadonlySet<T>): T[] {
+    const result: T[] = []
+    for (const item of iter) {
+        if (!set.has(item)) {
+            result.push(item)
+        }
+    }
+    return result
+}
+
+/**
+ * Removes all the given ranges from the given pattern.
+ *
+ * This assumes that all ranges are disjoint
+ */
+function removeAll(
+    fixer: Rule.RuleFixer,
+    patternSource: PatternSource,
+    ranges: readonly PatternRange[],
+) {
+    const sorted = [...ranges].sort((a, b) => b.start - a.start)
+    let pattern = patternSource.value
+
+    for (const { start, end } of sorted) {
+        pattern = pattern.slice(0, start) + pattern.slice(end)
+    }
+
+    const range = patternSource.getReplaceRange({
+        start: 0,
+        end: patternSource.value.length,
+    })
+    if (range) {
+        return range.replace(fixer, pattern)
+    }
+    return null
+}
+
+export default createRule("use-ignore-case", {
+    meta: {
+        docs: {
+            description: "use the `i` flag if it simplifies the pattern",
+            category: "Best Practices",
+            // TODO Switch to recommended in the major version.
+            // recommended: true,
+            recommended: false,
+        },
+        fixable: "code",
+        schema: [],
+        messages: {
+            unexpected:
+                "The character class(es) {{ classes }} can be simplified using the `i` flag.",
+        },
+        type: "suggestion",
+    },
+    create(context) {
+        /**
+         * Create visitor
+         */
+        function createVisitor(
+            regexpContext: RegExpContext,
+        ): RegExpVisitor.Handlers {
+            const {
+                node,
+                flags,
+                ownsFlags,
+                flagsString,
+                patternAst,
+                patternSource,
+                getUsageOfPattern,
+                getFlagsLocation,
+                fixReplaceFlags,
+            } = regexpContext
+
+            if (!ownsFlags || flagsString === null) {
+                // It's not (statically) fixable
+                return {}
+            }
+            if (flags.ignoreCase) {
+                // We can't suggest the i flag if it's already used
+                return {}
+            }
+            if (getUsageOfPattern() === UsageOfPattern.partial) {
+                // Adding flags to partial patterns isn't a good idea
+                return {}
+            }
+            if (isCaseVariant(patternAst, flags)) {
+                // We can't add the i flag
+                return {}
+            }
+
+            const uselessElements: CharacterClassElement[] = []
+            const ccs: CharacterClass[] = []
+
+            return {
+                onCharacterClassEnter(ccNode) {
+                    const invariantElement = ccNode.elements.filter(
+                        (e) => !isCaseVariant(e, flags),
+                    )
+                    if (invariantElement.length === ccNode.elements.length) {
+                        // all elements are case invariant
+                        return
+                    }
+
+                    const invariant = Chars.empty(flags).union(
+                        ...invariantElement.map((e) => toCharSet(e, flags)),
+                    )
+
+                    let variantElements = without(
+                        ccNode.elements,
+                        new Set(invariantElement),
+                    )
+
+                    // find all elements that are useless even without
+                    // the i flag
+                    const alwaysUseless = findUseless(
+                        variantElements,
+                        (e) => toCharSet(e, flags),
+                        invariant,
+                    )
+
+                    // remove useless elements
+                    variantElements = without(variantElements, alwaysUseless)
+
+                    // find useless elements
+                    const iFlags = getIgnoreCaseFlags(flags)
+                    const useless = findUseless(
+                        variantElements,
+                        (e) => toCharSet(e, iFlags),
+                        invariant,
+                    )
+
+                    uselessElements.push(...useless)
+                    ccs.push(ccNode)
+                },
+
+                onPatternLeave() {
+                    if (uselessElements.length === 0) {
+                        return
+                    }
+
+                    context.report({
+                        node,
+                        loc: getFlagsLocation(),
+                        messageId: "unexpected",
+                        data: {
+                            classes: ccs.map((cc) => mention(cc)).join(", "),
+                        },
+                        fix(fixer) {
+                            const patternFix = removeAll(
+                                fixer,
+                                patternSource,
+                                uselessElements,
+                            )
+                            if (!patternFix) {
+                                return null
+                            }
+
+                            const flagsFix = fixReplaceFlags(
+                                `${flagsString}i`,
+                                false,
+                            )(fixer)
+                            if (!flagsFix) {
+                                return null
+                            }
+
+                            const fix = [patternFix]
+                            if (Array.isArray(flagsFix)) {
+                                fix.push(...flagsFix)
+                            } else {
+                                fix.push(flagsFix)
+                            }
+
+                            return fix
+                        },
+                    })
+                },
+            }
+        }
+
+        return defineRegexpVisitor(context, {
+            createVisitor,
+        })
+    },
+})
diff --git a/lib/utils/index.ts b/lib/utils/index.ts
index 0c8675c..6177b34 100644
--- a/lib/utils/index.ts
+++ b/lib/utils/index.ts
@@ -878,21 +878,6 @@ function fixReplaceFlags(
     includePattern: boolean,
 ) {
     return (fixer: Rule.RuleFixer): Rule.Fix[] | Rule.Fix | null => {
-        let patternFix = null
-        if (includePattern) {
-            if (!patternSource) {
-                return null
-            }
-            const patternRange = patternSource.getReplaceRange({
-                start: 0,
-                end: patternSource.value.length,
-            })
-            if (patternRange == null) {
-                return null
-            }
-            patternFix = patternRange.replace(fixer, patternSource.value)
-        }
-
         let newFlags
         if (typeof replacement === "string") {
             newFlags = replacement
@@ -908,10 +893,7 @@ function fixReplaceFlags(
             return null
         }
 
-        if (isRegexpLiteral(regexpNode)) {
-            // fixes that change the pattern generally assume that flags don't
-            // change, so we have to create conflicts.
-
+        if (includePattern && isRegexpLiteral(regexpNode)) {
             return fixer.replaceText(
                 regexpNode,
                 `/${regexpNode.regex.pattern}/${newFlags}`,
@@ -920,7 +902,12 @@ function fixReplaceFlags(
 
         // eslint-disable-next-line one-var -- x
         let flagsFix
-        if (flagsNode) {
+        if (isRegexpLiteral(regexpNode)) {
+            flagsFix = fixer.replaceTextRange(
+                getFlagsRange(regexpNode),
+                newFlags,
+            )
+        } else if (flagsNode) {
             const range = getFlagsRange(flagsNode)
             if (range == null) {
                 return null
@@ -938,9 +925,24 @@ function fixReplaceFlags(
             )
         }
 
-        if (!patternFix) {
+        if (!includePattern) {
             return flagsFix
         }
+
+        // pattern fix
+
+        if (!patternSource) {
+            return null
+        }
+        const patternRange = patternSource.getReplaceRange({
+            start: 0,
+            end: patternSource.value.length,
+        })
+        if (patternRange == null) {
+            return null
+        }
+        const patternFix = patternRange.replace(fixer, patternSource.value)
+
         return [patternFix, flagsFix]
     }
 }
diff --git a/lib/utils/regexp-ast/case-variation.ts b/lib/utils/regexp-ast/case-variation.ts
new file mode 100644
index 0000000..8ede96f
--- /dev/null
+++ b/lib/utils/regexp-ast/case-variation.ts
@@ -0,0 +1,153 @@
+import type { ReadonlyFlags } from "regexp-ast-analysis"
+import {
+    toCache,
+    hasSomeDescendant,
+    toCharSet,
+    isEmptyBackreference,
+} from "regexp-ast-analysis"
+import type {
+    Alternative,
+    Character,
+    CharacterClassElement,
+    CharacterClassRange,
+    CharacterSet,
+    Element,
+    Pattern,
+} from "regexpp/ast"
+
+const ignoreCaseFlagsCache = new WeakMap<ReadonlyFlags, ReadonlyFlags>()
+const caseSensitiveFlagsCache = new WeakMap<ReadonlyFlags, ReadonlyFlags>()
+
+/**
+ * Returns flags equivalent to the given flags but with the `i` flag set.
+ */
+export function getIgnoreCaseFlags(flags: ReadonlyFlags): ReadonlyFlags {
+    if (flags.ignoreCase) {
+        return flags
+    }
+
+    let cached = ignoreCaseFlagsCache.get(flags)
+    if (cached === undefined) {
+        cached = toCache({ ...flags, ignoreCase: true })
+        ignoreCaseFlagsCache.set(flags, cached)
+    }
+    return cached
+}
+
+/**
+ * Returns flags equivalent to the given flags but without the `i` flag set.
+ */
+export function getCaseSensitiveFlags(flags: ReadonlyFlags): ReadonlyFlags {
+    if (flags.ignoreCase === false) {
+        return flags
+    }
+
+    let cached = caseSensitiveFlagsCache.get(flags)
+    if (cached === undefined) {
+        cached = toCache({ ...flags, ignoreCase: false })
+        caseSensitiveFlagsCache.set(flags, cached)
+    }
+    return cached
+}
+
+/**
+ * Returns whether the given element **will not** behave the same with or
+ * without the `i` flag.
+ *
+ * @param wholeCharacterClass Whether character classes will be checked as a
+ * whole or as a list of character class elements.
+ *
+ * If `false`, then the character class is case-variant if any of its elements
+ * is case-variant.
+ *
+ * Examples:
+ * - `wholeCharacterClass: true`: `isCaseVariant(/[a-zA-Z]/) -> false`
+ * - `wholeCharacterClass: false`: `isCaseVariant(/[a-zA-Z]/) -> true`
+ */
+export function isCaseVariant(
+    element: Element | CharacterClassElement | Alternative | Pattern,
+    flags: ReadonlyFlags,
+    wholeCharacterClass = true,
+): boolean {
+    const { unicode = false } = flags
+
+    const iSet = getIgnoreCaseFlags(flags)
+    const iUnset = getCaseSensitiveFlags(flags)
+
+    /** Whether the given character class element is case variant */
+    function ccElementIsCaseVariant(
+        e: Character | CharacterClassRange | CharacterSet,
+    ): boolean {
+        switch (e.type) {
+            case "Character":
+                // case-variant characters will accept > 1 characters
+                return toCharSet(e, iSet).size !== 1
+
+            case "CharacterClassRange":
+                return !toCharSet(e, iSet).equals(toCharSet(e, iUnset))
+
+            case "CharacterSet":
+                switch (e.kind) {
+                    case "word":
+                        // \w which is case-variant in Unicode mode
+                        return unicode
+                    case "property":
+                        // just check for equality
+                        return !toCharSet(e, iSet).equals(toCharSet(e, iUnset))
+                    default:
+                        // all other character sets are case-invariant
+                        return false
+                }
+
+            default:
+                throw new Error(`Unknown type: ${e}`)
+        }
+    }
+
+    return hasSomeDescendant(
+        element,
+        (d) => {
+            switch (d.type) {
+                case "Assertion":
+                    // \b and \B are defined in terms of \w which is
+                    // case-variant in Unicode mode
+                    return unicode && d.kind === "word"
+
+                case "Backreference":
+                    // we need to check whether the associated capturing group
+                    // is case variant
+                    if (hasSomeDescendant(element, d.resolved)) {
+                        // the capturing group is part of the root element, so
+                        // we don't need to make an extra check
+                        return false
+                    }
+
+                    return (
+                        !isEmptyBackreference(d) &&
+                        isCaseVariant(d.resolved, flags)
+                    )
+
+                case "Character":
+                case "CharacterClassRange":
+                case "CharacterSet":
+                    return ccElementIsCaseVariant(d)
+
+                case "CharacterClass":
+                    if (!wholeCharacterClass) {
+                        return d.elements.some(ccElementIsCaseVariant)
+                    }
+                    // just check for equality
+                    return !toCharSet(d, iSet).equals(toCharSet(d, iUnset))
+
+                default:
+                    return false
+            }
+        },
+        (d) => {
+            // don't go into character classes and ranges
+            return (
+                d.type !== "CharacterClass" && d.type !== "CharacterClassRange"
+            )
+        },
+    )
+}
diff --git a/lib/utils/rules.ts b/lib/utils/rules.ts
index 51d2daa..e07622b 100644
--- a/lib/utils/rules.ts
+++ b/lib/utils/rules.ts
@@ -72,6 +72,7 @@ import sortCharacterClassElements from "../rules/sort-character-class-elements"
 import sortFlags from "../rules/sort-flags"
 import strict from "../rules/strict"
 import unicodeEscape from "../rules/unicode-escape"
+import useIgnoreCase from "../rules/use-ignore-case"
 
 export const rules = [
     confusingQuantifier,
@@ -147,4 +148,5 @@ export const rules = [
     sortFlags,
     strict,
     unicodeEscape,
+    useIgnoreCase,
 ] as RuleModule[]


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."],
+        },
     ],
 })
diff --git a/tests/lib/rules/use-ignore-case.ts b/tests/lib/rules/use-ignore-case.ts
new file mode 100644
index 0000000..8b01b3c
--- /dev/null
+++ b/tests/lib/rules/use-ignore-case.ts
@@ -0,0 +1,45 @@
+import { RuleTester } from "eslint"
+import rule from "../../../lib/rules/use-ignore-case"
+
+const tester = new RuleTester({
+    parserOptions: {
+        ecmaVersion: 2020,
+        sourceType: "module",
+    },
+})
+
+tester.run("use-ignore-case", rule as any, {
+    valid: [
+        String.raw`/regexp/`,
+        String.raw`/[aA]/i`,
+        String.raw`/[aA]a/`,
+        String.raw`/[aAb]/`,
+        String.raw`/[aaaa]/`,
+
+        // partial pattern
+        String.raw`/[a-zA-Z]/.source`,
+    ],
+    invalid: [
+        {
+            code: String.raw`/[a-zA-Z]/`,
+            output: String.raw`/[a-z]/i`,
+            errors: [
+                "The character class(es) '[a-zA-Z]' can be simplified using the `i` flag.",
+            ],
+        },
+        {
+            code: String.raw`/\b0[xX][a-fA-F0-9]+\b/`,
+            output: String.raw`/\b0[x][a-f0-9]+\b/i`,
+            errors: [
+                "The character class(es) '[xX]', '[a-fA-F0-9]' can be simplified using the `i` flag.",
+            ],
+        },
+        {
+            code: String.raw`RegExp("[a-zA-Z]")`,
+            output: String.raw`RegExp("[a-z]", "i")`,
+            errors: [
+                "The character class(es) '[a-zA-Z]' can be simplified using the `i` flag.",
+            ],
+        },
+    ],
+})