Repository: ota-meshi/eslint-plugin-regexp
Pull request #617 (2023-09-26 11:05:07)

Add support for `v` flag to `regexp/use-ignore-case`
Related to #545.

Changes:
- The rule (and `isCaseVariant`) now supports the `v` flag.
- I added support for including string alternatives in the analysis.
- Fixed `removeAll`. That function did now work correctly because `pattern = pattern.slice(0, start) + pattern.slice(end)` is only correct for a `{ start, end }` pair.
- The rule will now add the `i` in the correct place. E.g. `u` -> `iu`.
- I added a `cachedFn` util function and used it in 3 places. The function itself is very simple.

changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: 704914796686aefbe69f6b869f749cc4ec0ef4c3

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/ota-meshi/eslint-plugin-regexp/new/v-flag-use-ignore-case?filename=.changeset/cyan-rats-attend.md&value=---%0A%22eslint-plugin-regexp%22%3A%20patch%0A---%0A%0AAdd%20support%20for%20%60v%60%20flag%20to%20%60regexp%2Fuse-ignore-case%60%0A)



Patch:
diff --git a/.changeset/cyan-rats-attend.md b/.changeset/cyan-rats-attend.md
new file mode 100644
index 0000000..a1beff6
--- /dev/null
+++ b/.changeset/cyan-rats-attend.md
@@ -0,0 +1,5 @@
+---
+"eslint-plugin-regexp": minor
+---
+
+Add support for `v` flag to `regexp/use-ignore-case`
diff --git a/.changeset/spotty-phones-deliver.md b/.changeset/spotty-phones-deliver.md
new file mode 100644
index 0000000..e068beb
--- /dev/null
+++ b/.changeset/spotty-phones-deliver.md
@@ -0,0 +1,5 @@
+---
+"eslint-plugin-regexp": major
+---
+
+Add `regexp/prefer-set-operation` rule
diff --git a/README.md b/README.md
index 561ec72..2d8398e 100644
--- a/README.md
+++ b/README.md
@@ -165,6 +165,7 @@ The `plugin:regexp/all` config enables all rules. It's meant for testing, not fo
 | [prefer-range](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-range.html)                                                   | enforce using character class range                                                        | ✅  |    | 🔧 |    |
 | [prefer-regexp-exec](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-regexp-exec.html)                                       | enforce that `RegExp#exec` is used instead of `String#match` if no global flag is provided |    |    |    |    |
 | [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`             |    |    | 🔧 |    |
+| [prefer-set-operation](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-set-operation.html)                                   | prefer character class set operations instead of lookarounds                               | ✅  |    | 🔧 |    |
 | [require-unicode-regexp](https://ota-meshi.github.io/eslint-plugin-regexp/rules/require-unicode-regexp.html)                               | enforce the use of the `u` flag                                                            |    |    | 🔧 |    |
 | [require-unicode-sets-regexp](https://ota-meshi.github.io/eslint-plugin-regexp/rules/require-unicode-sets-regexp.html)                     | enforce the use of the `v` flag                                                            |    |    | 🔧 |    |
 | [sort-alternatives](https://ota-meshi.github.io/eslint-plugin-regexp/rules/sort-alternatives.html)                                         | sort alternatives if order doesn't matter                                                  |    |    | 🔧 |    |
diff --git a/docs/rules/index.md b/docs/rules/index.md
index 31e251a..dc8f59f 100644
--- a/docs/rules/index.md
+++ b/docs/rules/index.md
@@ -72,6 +72,7 @@ sidebarDepth: 0
 | [prefer-range](prefer-range.md)                                                   | enforce using character class range                                                        | ✅  |    | 🔧 |    |
 | [prefer-regexp-exec](prefer-regexp-exec.md)                                       | enforce that `RegExp#exec` is used instead of `String#match` if no global flag is provided |    |    |    |    |
 | [prefer-regexp-test](prefer-regexp-test.md)                                       | enforce that `RegExp#test` is used instead of `String#match` and `RegExp#exec`             |    |    | 🔧 |    |
+| [prefer-set-operation](prefer-set-operation.md)                                   | prefer character class set operations instead of lookarounds                               | ✅  |    | 🔧 |    |
 | [require-unicode-regexp](require-unicode-regexp.md)                               | enforce the use of the `u` flag                                                            |    |    | 🔧 |    |
 | [require-unicode-sets-regexp](require-unicode-sets-regexp.md)                     | enforce the use of the `v` flag                                                            |    |    | 🔧 |    |
 | [sort-alternatives](sort-alternatives.md)                                         | sort alternatives if order doesn't matter                                                  |    |    | 🔧 |    |
diff --git a/docs/rules/prefer-set-operation.md b/docs/rules/prefer-set-operation.md
new file mode 100644
index 0000000..d8e65b1
--- /dev/null
+++ b/docs/rules/prefer-set-operation.md
@@ -0,0 +1,48 @@
+---
+pageClass: "rule-details"
+sidebarDepth: 0
+title: "regexp/prefer-set-operation"
+description: "prefer character class set operations instead of lookarounds"
+---
+# regexp/prefer-set-operation
+
+💼 This rule is enabled in the ✅ `plugin:regexp/recommended` config.
+
+🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
+
+<!-- end auto-generated rule header -->
+
+> prefer character class set operations instead of lookarounds
+
+## :book: Rule Details
+
+Regular expressions with the `v` flag have access to character class set operations (e.g. `/[\s&&\p{ASCII}]/v`, `/[\w--\d]/v`). These are more readable and performant than using lookarounds to achieve the same effect. For example, `/(?!\d)\w/v` is the same as `/[\w--\d]/v`.
+
+<eslint-code-block fix>
+
+```js
+/* eslint regexp/prefer-set-operation: "error" */
+
+/* ✓ GOOD */
+var foo = /(?!\d)\w/  // requires the v flag
+var foo = /(?!\d)\w/u // requires the v flag
+
+/* ✗ BAD */
+var foo = /(?!\d)\w/v
+var foo = /(?!\s)[\w\P{ASCII}]/v
+```
+
+</eslint-code-block>
+
+## :wrench: Options
+
+Nothing.
+
+## :rocket: Version
+
+:exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/lib/rules/prefer-set-operation.ts)
+- [Test source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/tests/lib/rules/prefer-set-operation.ts)
diff --git a/lib/configs/recommended.ts b/lib/configs/recommended.ts
index 3d0979e..d112209 100644
--- a/lib/configs/recommended.ts
+++ b/lib/configs/recommended.ts
@@ -60,6 +60,7 @@ export const rules = {
     "regexp/prefer-predefined-assertion": "error",
     "regexp/prefer-question-quantifier": "error",
     "regexp/prefer-range": "error",
+    "regexp/prefer-set-operation": "error",
     "regexp/prefer-star-quantifier": "error",
     "regexp/prefer-unicode-codepoint-escapes": "error",
     "regexp/prefer-w": "error",
diff --git a/lib/rules/prefer-set-operation.ts b/lib/rules/prefer-set-operation.ts
new file mode 100644
index 0000000..86cdf00
--- /dev/null
+++ b/lib/rules/prefer-set-operation.ts
@@ -0,0 +1,146 @@
+import type { RegExpVisitor } from "@eslint-community/regexpp/visitor"
+import type {
+    Alternative,
+    Character,
+    CharacterClass,
+    CharacterSet,
+    ExpressionCharacterClass,
+    LookaroundAssertion,
+    Node,
+} from "@eslint-community/regexpp/ast"
+import type { RegExpContext } from "../utils"
+import { createRule, defineRegexpVisitor } from "../utils"
+import { hasStrings } from "regexp-ast-analysis"
+
+type CharElement =
+    | Character
+    | CharacterSet
+    | CharacterClass
+    | ExpressionCharacterClass
+
+function isCharElement(node: Node): node is CharElement {
+    return (
+        node.type === "Character" ||
+        node.type === "CharacterSet" ||
+        node.type === "CharacterClass" ||
+        node.type === "ExpressionCharacterClass"
+    )
+}
+
+type CharLookaround = LookaroundAssertion & {
+    alternatives: [Alternative & { elements: [CharElement] }]
+}
+
+function isCharLookaround(node: Node): node is CharLookaround {
+    return (
+        node.type === "Assertion" &&
+        (node.kind === "lookahead" || node.kind === "lookbehind") &&
+        node.alternatives.length === 1 &&
+        node.alternatives[0].elements.length === 1 &&
+        isCharElement(node.alternatives[0].elements[0])
+    )
+}
+
+function escapeRaw(raw: string): string {
+    if (/^[&\-^]$/u.test(raw)) {
+        return `\\${raw}`
+    }
+    return raw
+}
+
+export default createRule("prefer-set-operation", {
+    meta: {
+        docs: {
+            description:
+                "prefer character class set operations instead of lookarounds",
+            category: "Best Practices",
+            recommended: true,
+        },
+        fixable: "code",
+        schema: [],
+        messages: {
+            unexpected:
+                "This lookaround can be combined with '{{char}}' using a set operation.",
+        },
+        type: "suggestion",
+    },
+    create(context) {
+        function createVisitor(
+            regexpContext: RegExpContext,
+        ): RegExpVisitor.Handlers {
+            const { node, flags, getRegexpLocation, fixReplaceNode } =
+                regexpContext
+
+            if (!flags.unicodeSets) {
+                // set operations are exclusive to the v flag.
+                return {}
+            }
+
+            function tryApply(
+                element: CharElement,
+                assertion: CharLookaround,
+                parent: Alternative,
+            ): void {
+                const assertElement = assertion.alternatives[0].elements[0]
+                if (hasStrings(assertElement, flags)) {
+                    return
+                }
+
+                context.report({
+                    node,
+                    loc: getRegexpLocation(assertion),
+                    messageId: "unexpected",
+                    data: {
+                        char: element.raw,
+                    },
+                    fix: fixReplaceNode(parent, () => {
+                        const op = assertion.negate ? "--" : "&&"
+                        const left = escapeRaw(element.raw)
+                        const right = escapeRaw(assertElement.raw)
+                        const replacement = `[${left}${op}${right}]`
+
+                        return parent.elements
+                            .map((e) => {
+                                if (e === assertion) {
+                                    return ""
+                                } else if (e === element) {
+                                    return replacement
+                                }
+                                return e.raw
+                            })
+                            .join("")
+                    }),
+                })
+            }
+
+            return {
+                onAlternativeEnter(alternative) {
+                    const { elements } = alternative
+                    for (let i = 1; i < elements.length; i++) {
+                        const a = elements[i - 1]
+                        const b = elements[i]
+
+                        if (
+                            isCharElement(a) &&
+                            isCharLookaround(b) &&
+                            b.kind === "lookbehind"
+                        ) {
+                            tryApply(a, b, alternative)
+                        }
+                        if (
+                            isCharLookaround(a) &&
+                            a.kind === "lookahead" &&
+                            isCharElement(b)
+                        ) {
+                            tryApply(b, a, alternative)
+                        }
+                    }
+                },
+            }
+        }
+
+        return defineRegexpVisitor(context, {
+            createVisitor,
+        })
+    },
+})
diff --git a/lib/rules/use-ignore-case.ts b/lib/rules/use-ignore-case.ts
index 5375845..f04c4c6 100644
--- a/lib/rules/use-ignore-case.ts
+++ b/lib/rules/use-ignore-case.ts
@@ -1,8 +1,10 @@
-import type { CharSet } from "refa"
-import { Chars, toCharSet } from "regexp-ast-analysis"
+import { CharSet, JS } from "refa"
+import { Chars, toUnicodeSet } from "regexp-ast-analysis"
 import type {
     CharacterClass,
     CharacterClassElement,
+    Node,
+    StringAlternative,
 } from "@eslint-community/regexpp/ast"
 import type { RegExpVisitor } from "@eslint-community/regexpp/visitor"
 import type { RegExpContext } from "../utils"
@@ -18,34 +20,29 @@ import type {
 } from "../utils/ast-utils/pattern-source"
 import type { Rule } from "eslint"
 import { UsageOfPattern } from "../utils/get-usage-of-pattern"
+import { cachedFn } from "../utils/util"
 
-// FIXME: TS Error
-// @ts-expect-error -- FIXME
-const ELEMENT_ORDER: Record<CharacterClassElement["type"], number> = {
+type FlatClassElement = CharacterClassElement | StringAlternative
+
+const ELEMENT_ORDER: Record<FlatClassElement["type"], number> = {
     Character: 1,
     CharacterClassRange: 2,
     CharacterSet: 3,
+    CharacterClass: 4,
+    ExpressionCharacterClass: 5,
+    ClassStringDisjunction: 6,
+    StringAlternative: 7,
 }
 
 /**
  * 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
-    }
+    elements: readonly FlatClassElement[],
+    getChars: (e: FlatClassElement) => JS.UnicodeSet,
+    other: JS.UnicodeSet,
+): Set<FlatClassElement> {
+    const get = cachedFn(getChars)
 
     // When searching for useless elements, we want to first
     // search for useless characters, then useless ranges, and
@@ -55,7 +52,7 @@ function findUseless(
         .reverse()
         .sort((a, b) => ELEMENT_ORDER[a.type] - ELEMENT_ORDER[b.type])
 
-    const useless = new Set<CharacterClassElement>()
+    const useless = new Set<FlatClassElement>()
 
     for (const e of sortedElements) {
         const cs = get(e)
@@ -88,20 +85,51 @@ function without<T>(iter: Iterable<T>, set: ReadonlySet<T>): T[] {
 }
 
 /**
- * Removes all the given ranges from the given pattern.
- *
- * This assumes that all ranges are disjoint
+ * Removes all the given nodes from the given pattern.
  */
 function removeAll(
     fixer: Rule.RuleFixer,
     patternSource: PatternSource,
-    ranges: readonly PatternRange[],
+    nodes: readonly Node[],
 ) {
-    const sorted = [...ranges].sort((a, b) => b.start - a.start)
-    let pattern = patternSource.value
+    // we abuse CharSet to merge adjacent and overlapping ranges
+    const charSet = CharSet.empty(Number.MAX_SAFE_INTEGER).union(
+        nodes.map((n) => {
+            let min = n.start
+            let max = n.end - 1
+
+            if (n.type === "StringAlternative") {
+                const parent = n.parent
+                if (
+                    parent.alternatives.length === 1 ||
+                    parent.alternatives.every((a) => nodes.includes(a))
+                ) {
+                    // we have to remove the whole disjunction
+                    min = parent.start
+                    max = parent.end - 1
+                } else {
+                    const isFirst = parent.alternatives.at(0) === n
+                    if (isFirst) {
+                        max++
+                    } else {
+                        min--
+                    }
+                }
+            }
+
+            return { min, max }
+        }),
+    )
+    const sorted = charSet.ranges.map(
+        ({ min, max }): PatternRange => ({ start: min, end: max + 1 }),
+    )
 
+    let pattern = patternSource.value
+    let removed = 0
     for (const { start, end } of sorted) {
-        pattern = pattern.slice(0, start) + pattern.slice(end)
+        pattern =
+            pattern.slice(0, start - removed) + pattern.slice(end - removed)
+        removed += end - start
     }
 
     const range = patternSource.getReplaceRange({
@@ -114,6 +142,23 @@ function removeAll(
     return null
 }
 
+/**
+ * Adds the `i` flag to the given flags string.
+ */
+function getIgnoreCaseFlagsString(flags: string): string {
+    if (flags.includes("i")) {
+        return flags
+    }
+
+    // keep flags sorted
+    for (let i = 0; i < flags.length; i++) {
+        if (flags[i] > "i") {
+            return `${flags.slice(0, i)}i${flags.slice(i)}`
+        }
+    }
+    return `${flags}i`
+}
+
 export default createRule("use-ignore-case", {
     meta: {
         docs: {
@@ -162,27 +207,34 @@ export default createRule("use-ignore-case", {
                 return {}
             }
 
-            const uselessElements: CharacterClassElement[] = []
+            const uselessElements: FlatClassElement[] = []
             const ccs: CharacterClass[] = []
 
             return {
                 onCharacterClassEnter(ccNode) {
-                    const invariantElement = ccNode.elements.filter(
+                    const elements = ccNode.elements.flatMap(
+                        (e: CharacterClassElement): FlatClassElement[] => {
+                            if (e.type === "ClassStringDisjunction") {
+                                return e.alternatives
+                            }
+                            return [e]
+                        },
+                    )
+                    const invariantElement = elements.filter(
                         (e) => !isCaseVariant(e, flags),
                     )
-                    if (invariantElement.length === ccNode.elements.length) {
+                    if (invariantElement.length === elements.length) {
                         // all elements are case invariant
                         return
                     }
 
-                    const invariant = Chars.empty(flags).union(
-                        // FIXME: TS Error
-                        // @ts-expect-error -- FIXME
-                        ...invariantElement.map((e) => toCharSet(e, flags)),
+                    const empty = JS.UnicodeSet.empty(Chars.maxChar(flags))
+                    const invariant = empty.union(
+                        ...invariantElement.map((e) => toUnicodeSet(e, flags)),
                     )
 
                     let variantElements = without(
-                        ccNode.elements,
+                        elements,
                         new Set(invariantElement),
                     )
 
@@ -190,9 +242,7 @@ export default createRule("use-ignore-case", {
                     // the i flag
                     const alwaysUseless = findUseless(
                         variantElements,
-                        // FIXME: TS Error
-                        // @ts-expect-error -- FIXME
-                        (e) => toCharSet(e, flags),
+                        (e) => toUnicodeSet(e, flags),
                         invariant,
                     )
 
@@ -203,9 +253,7 @@ export default createRule("use-ignore-case", {
                     const iFlags = getIgnoreCaseFlags(flags)
                     const useless = findUseless(
                         variantElements,
-                        // FIXME: TS Error
-                        // @ts-expect-error -- FIXME
-                        (e) => toCharSet(e, iFlags),
+                        (e) => toUnicodeSet(e, iFlags),
                         invariant,
                     )
 
@@ -236,7 +284,7 @@ export default createRule("use-ignore-case", {
                             }
 
                             const flagsFix = fixReplaceFlags(
-                                `${flagsString}i`,
+                                getIgnoreCaseFlagsString(flagsString),
                                 false,
                             )(fixer)
                             if (!flagsFix) {
diff --git a/lib/utils/regexp-ast/case-variation.ts b/lib/utils/regexp-ast/case-variation.ts
index 3075d72..c58500f 100644
--- a/lib/utils/regexp-ast/case-variation.ts
+++ b/lib/utils/regexp-ast/case-variation.ts
@@ -8,49 +8,37 @@ import {
 } from "regexp-ast-analysis"
 import type {
     Alternative,
-    Character,
+    CharacterClass,
     CharacterClassElement,
-    CharacterClassRange,
     CharacterSet,
     Element,
+    ExpressionCharacterClass,
     Pattern,
+    StringAlternative,
 } from "@eslint-community/regexpp/ast"
-import { assertNever } from "../util"
-
-const ignoreCaseFlagsCache = new WeakMap<ReadonlyFlags, ReadonlyFlags>()
-const caseSensitiveFlagsCache = new WeakMap<ReadonlyFlags, ReadonlyFlags>()
+import { assertNever, cachedFn } from "../util"
 
 /**
  * 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
-}
+export const getIgnoreCaseFlags = cachedFn(
+    (flags: ReadonlyFlags): ReadonlyFlags => {
+        return flags.ignoreCase
+            ? flags
+            : toCache({ ...flags, ignoreCase: true })
+    },
+)
 
 /**
  * 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
-}
+export const getCaseSensitiveFlags = cachedFn(
+    (flags: ReadonlyFlags): ReadonlyFlags => {
+        return flags.ignoreCase === false
+            ? flags
+            : toCache({ ...flags, ignoreCase: false })
+    },
+)
 
 /**
  * Returns whether the given element **will not** behave the same with or
@@ -67,18 +55,28 @@ export function getCaseSensitiveFlags(flags: ReadonlyFlags): ReadonlyFlags {
  * - `wholeCharacterClass: false`: `isCaseVariant(/[a-zA-Z]/) -> true`
  */
 export function isCaseVariant(
-    element: Element | CharacterClassElement | Alternative | Pattern,
+    element:
+        | Element
+        | CharacterClassElement
+        | StringAlternative
+        | Alternative
+        | Pattern,
     flags: ReadonlyFlags,
     wholeCharacterClass = true,
 ): boolean {
-    const { unicode = false } = flags
+    const unicodeLike = Boolean(flags.unicode || flags.unicodeSets)
 
     const iSet = getIgnoreCaseFlags(flags)
     const iUnset = getCaseSensitiveFlags(flags)
 
     /** Whether the given character class element is case variant */
     function ccElementIsCaseVariant(
-        e: Character | CharacterClassRange | CharacterSet,
+        e:
+            | CharacterClassElement
+            | CharacterSet
+            | CharacterClass
+            | StringAlternative
+            | ExpressionCharacterClass["expression"],
     ): boolean {
         switch (e.type) {
             case "Character":
@@ -92,7 +90,7 @@ export function isCaseVariant(
                 switch (e.kind) {
                     case "word":
                         // \w which is case-variant in Unicode mode
-                        return unicode
+                        return unicodeLike
                     case "property":
                         // just check for equality
                         return !toUnicodeSet(e, iSet).equals(
@@ -103,6 +101,30 @@ export function isCaseVariant(
                         return false
                 }
 
+            case "CharacterClass":
+                if (!wholeCharacterClass) {
+                    return e.elements.some(ccElementIsCaseVariant)
+                }
+                // just check for equality
+                return !toUnicodeSet(e, iSet).equals(toUnicodeSet(e, iUnset))
+
+            case "ExpressionCharacterClass":
+                return ccElementIsCaseVariant(e.expression)
+
+            case "ClassIntersection":
+            case "ClassSubtraction":
+                return !toUnicodeSet(e, iSet).equals(toUnicodeSet(e, iUnset))
+
+            case "ClassStringDisjunction":
+                if (!wholeCharacterClass) {
+                    return e.alternatives.some(ccElementIsCaseVariant)
+                }
+                // just check for equality
+                return !toUnicodeSet(e, iSet).equals(toUnicodeSet(e, iUnset))
+
+            case "StringAlternative":
+                return e.elements.some(ccElementIsCaseVariant)
+
             default:
                 return assertNever(e)
         }
@@ -115,7 +137,7 @@ export function isCaseVariant(
                 case "Assertion":
                     // \b and \B are defined in terms of \w which is
                     // case-variant in Unicode mode
-                    return unicode && d.kind === "word"
+                    return unicodeLike && d.kind === "word"
 
                 case "Backreference":
                     // we need to check whether the associated capturing group
@@ -134,18 +156,13 @@ export function isCaseVariant(
                 case "Character":
                 case "CharacterClassRange":
                 case "CharacterSet":
-                    return ccElementIsCaseVariant(d)
-
                 case "CharacterClass":
-                    if (!wholeCharacterClass) {
-                        // FIXME: TS Error
-                        // @ts-expect-error -- FIXME
-                        return d.elements.some(ccElementIsCaseVariant)
-                    }
-                    // just check for equality
-                    return !toUnicodeSet(d, iSet).equals(
-                        toUnicodeSet(d, iUnset),
-                    )
+                case "ExpressionCharacterClass":
+                case "ClassIntersection":
+                case "ClassSubtraction":
+                case "ClassStringDisjunction":
+                case "StringAlternative":
+                    return ccElementIsCaseVariant(d)
 
                 default:
                     return false
@@ -154,7 +171,10 @@ export function isCaseVariant(
         (d) => {
             // don't go into character classes and ranges
             return (
-                d.type !== "CharacterClass" && d.type !== "CharacterClassRange"
+                d.type !== "CharacterClass" &&
+                d.type !== "CharacterClassRange" &&
+                d.type !== "ExpressionCharacterClass" &&
+                d.type !== "ClassStringDisjunction"
             )
         },
     )
diff --git a/lib/utils/rules.ts b/lib/utils/rules.ts
index 0d44409..e69b79c 100644
--- a/lib/utils/rules.ts
+++ b/lib/utils/rules.ts
@@ -67,6 +67,7 @@ import preferRange from "../rules/prefer-range"
 import preferRegexpExec from "../rules/prefer-regexp-exec"
 import preferRegexpTest from "../rules/prefer-regexp-test"
 import preferResultArrayGroups from "../rules/prefer-result-array-groups"
+import preferSetOperation from "../rules/prefer-set-operation"
 import preferStarQuantifier from "../rules/prefer-star-quantifier"
 import preferT from "../rules/prefer-t"
 import preferUnicodeCodepointEscapes from "../rules/prefer-unicode-codepoint-escapes"
@@ -149,6 +150,7 @@ export const rules = [
     preferRegexpExec,
     preferRegexpTest,
     preferResultArrayGroups,
+    preferSetOperation,
     preferStarQuantifier,
     preferT,
     preferUnicodeCodepointEscapes,
diff --git a/lib/utils/util.ts b/lib/utils/util.ts
index db6c66d..9a27cbd 100644
--- a/lib/utils/util.ts
+++ b/lib/utils/util.ts
@@ -4,3 +4,22 @@
 export function assertNever(value: never): never {
     throw new Error(`Invalid value: ${value}`)
 }
+
+/**
+ * Returns a cached version of the given function. A `WeakMap` is used internally.
+ *
+ * For the cached function to behave correctly, the given function must be pure.
+ */
+export function cachedFn<K extends object, R>(
+    fn: (key: K) => R,
+): (key: K) => R {
+    const cache = new WeakMap<K, R>()
+    return (key) => {
+        let cached = cache.get(key)
+        if (cached === undefined) {
+            cached = fn(key)
+            cache.set(key, cached)
+        }
+        return cached
+    }
+}


Test patch:
diff --git a/tests/lib/rules/prefer-set-operation.ts b/tests/lib/rules/prefer-set-operation.ts
new file mode 100644
index 0000000..f88503f
--- /dev/null
+++ b/tests/lib/rules/prefer-set-operation.ts
@@ -0,0 +1,42 @@
+import { RuleTester } from "eslint"
+import rule from "../../../lib/rules/prefer-set-operation"
+
+const tester = new RuleTester({
+    parserOptions: {
+        ecmaVersion: "latest",
+        sourceType: "module",
+    },
+})
+
+tester.run("prefer-set-operation", rule as any, {
+    valid: [
+        String.raw`/a\b/`,
+        String.raw`/a\b/u`,
+        String.raw`/a\b/v`,
+        String.raw`/(?!a)\w/`,
+        String.raw`/(?!a)\w/u`,
+    ],
+    invalid: [
+        {
+            code: String.raw`/(?!a)\w/v`,
+            output: String.raw`/[\w--a]/v`,
+            errors: [
+                "This lookaround can be combined with '\\w' using a set operation.",
+            ],
+        },
+        {
+            code: String.raw`/\w(?<=\d)/v`,
+            output: String.raw`/[\w&&\d]/v`,
+            errors: [
+                "This lookaround can be combined with '\\w' using a set operation.",
+            ],
+        },
+        {
+            code: String.raw`/(?!-)&/v`,
+            output: String.raw`/[\&--\-]/v`,
+            errors: [
+                "This lookaround can be combined with '&' using a set operation.",
+            ],
+        },
+    ],
+})
diff --git a/tests/lib/rules/use-ignore-case.ts b/tests/lib/rules/use-ignore-case.ts
index 8b01b3c..54e9421 100644
--- a/tests/lib/rules/use-ignore-case.ts
+++ b/tests/lib/rules/use-ignore-case.ts
@@ -3,7 +3,7 @@ import rule from "../../../lib/rules/use-ignore-case"
 
 const tester = new RuleTester({
     parserOptions: {
-        ecmaVersion: 2020,
+        ecmaVersion: "latest",
         sourceType: "module",
     },
 })
@@ -16,6 +16,22 @@ tester.run("use-ignore-case", rule as any, {
         String.raw`/[aAb]/`,
         String.raw`/[aaaa]/`,
 
+        String.raw`/regexp/u`,
+        String.raw`/[aA]/iu`,
+        String.raw`/[aA]a/u`,
+        String.raw`/[aAb]/u`,
+        String.raw`/[aaaa]/u`,
+        String.raw`/\b[aA]/u`,
+        String.raw`/[a-zA-Z]/u`,
+
+        String.raw`/regexp/v`,
+        String.raw`/[aA]/iv`,
+        String.raw`/[aA]a/v`,
+        String.raw`/[aAb]/v`,
+        String.raw`/[aaaa]/v`,
+        String.raw`/\b[aA]/v`,
+        String.raw`/[a-zA-Z]/v`,
+
         // partial pattern
         String.raw`/[a-zA-Z]/.source`,
     ],
@@ -27,6 +43,27 @@ tester.run("use-ignore-case", rule as any, {
                 "The character class(es) '[a-zA-Z]' can be simplified using the `i` flag.",
             ],
         },
+        {
+            code: String.raw`/[aA][aA][aA][aA][aA]/`,
+            output: String.raw`/[a][a][a][a][a]/i`,
+            errors: [
+                "The character class(es) '[aA]', '[aA]', '[aA]', '[aA]', '[aA]' can be simplified using the `i` flag.",
+            ],
+        },
+        {
+            code: String.raw`/[aA]/u`,
+            output: String.raw`/[a]/iu`,
+            errors: [
+                "The character class(es) '[aA]' can be simplified using the `i` flag.",
+            ],
+        },
+        {
+            code: String.raw`/[aA]/v`,
+            output: String.raw`/[a]/iv`,
+            errors: [
+                "The character class(es) '[aA]' 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`,
@@ -41,5 +78,12 @@ tester.run("use-ignore-case", rule as any, {
                 "The character class(es) '[a-zA-Z]' can be simplified using the `i` flag.",
             ],
         },
+        {
+            code: String.raw`/[\q{a|A}]/v`,
+            output: String.raw`/[\q{a}]/iv`,
+            errors: [
+                "The character class(es) '[\\q{a|A}]' can be simplified using the `i` flag.",
+            ],
+        },
     ],
 })