Repository: ota-meshi/eslint-plugin-regexp
Pull request #621 (2023-09-26 18:21:43)

Add suggestions for `regexp/no-empty-alternative`
I added suggestions for `no-empty-alternative` that replace the empty alternative with a quantifier. This will hopefully make the rule easier to use.


Patch:
diff --git a/.changeset/beige-suns-clap.md b/.changeset/beige-suns-clap.md
new file mode 100644
index 0000000..e914aac
--- /dev/null
+++ b/.changeset/beige-suns-clap.md
@@ -0,0 +1,5 @@
+---
+"eslint-plugin-regexp": minor
+---
+
+Add support for `v` flag to `regexp/optimal-quantifier-concatenation`
diff --git a/.changeset/clean-kids-mate.md b/.changeset/clean-kids-mate.md
new file mode 100644
index 0000000..93c55c2
--- /dev/null
+++ b/.changeset/clean-kids-mate.md
@@ -0,0 +1,5 @@
+---
+"eslint-plugin-regexp": minor
+---
+
+Add support for `v` flag to `regexp/prefer-character-class`
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/itchy-trains-exist.md b/.changeset/itchy-trains-exist.md
new file mode 100644
index 0000000..c686fb6
--- /dev/null
+++ b/.changeset/itchy-trains-exist.md
@@ -0,0 +1,5 @@
+---
+"eslint-plugin-regexp": minor
+---
+
+Add support for `v` flag to `regexp/no-misleading-capturing-group`
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/.changeset/swift-guests-trade.md b/.changeset/swift-guests-trade.md
new file mode 100644
index 0000000..91c5c85
--- /dev/null
+++ b/.changeset/swift-guests-trade.md
@@ -0,0 +1,5 @@
+---
+"eslint-plugin-regexp": minor
+---
+
+Add suggestions for `regexp/no-empty-alternative`
diff --git a/README.md b/README.md
index 561ec72..1066544 100644
--- a/README.md
+++ b/README.md
@@ -112,7 +112,7 @@ The `plugin:regexp/all` config enables all rules. It's meant for testing, not fo
 | [no-contradiction-with-assertion](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-contradiction-with-assertion.html)           | disallow elements that contradict assertions                                            | ✅  |    |    | 💡 |
 | [no-control-character](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-control-character.html)                                 | disallow control characters                                                             |    |    |    | 💡 |
 | [no-dupe-disjunctions](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-dupe-disjunctions.html)                                 | disallow duplicate disjunctions                                                         | ✅  |    |    | 💡 |
-| [no-empty-alternative](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-empty-alternative.html)                                 | disallow alternatives without elements                                                  |    | ✅  |    |    |
+| [no-empty-alternative](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-empty-alternative.html)                                 | disallow alternatives without elements                                                  |    | ✅  |    | 💡 |
 | [no-empty-capturing-group](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-empty-capturing-group.html)                         | disallow capturing group that captures empty.                                           | ✅  |    |    |    |
 | [no-empty-character-class](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-empty-character-class.html)                         | disallow character classes that match no characters                                     | ✅  |    |    |    |
 | [no-empty-group](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-empty-group.html)                                             | disallow empty group                                                                    | ✅  |    |    |    |
@@ -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..dda84d2 100644
--- a/docs/rules/index.md
+++ b/docs/rules/index.md
@@ -19,7 +19,7 @@ sidebarDepth: 0
 | [no-contradiction-with-assertion](no-contradiction-with-assertion.md)           | disallow elements that contradict assertions                                            | ✅  |    |    | 💡 |
 | [no-control-character](no-control-character.md)                                 | disallow control characters                                                             |    |    |    | 💡 |
 | [no-dupe-disjunctions](no-dupe-disjunctions.md)                                 | disallow duplicate disjunctions                                                         | ✅  |    |    | 💡 |
-| [no-empty-alternative](no-empty-alternative.md)                                 | disallow alternatives without elements                                                  |    | ✅  |    |    |
+| [no-empty-alternative](no-empty-alternative.md)                                 | disallow alternatives without elements                                                  |    | ✅  |    | 💡 |
 | [no-empty-capturing-group](no-empty-capturing-group.md)                         | disallow capturing group that captures empty.                                           | ✅  |    |    |    |
 | [no-empty-character-class](no-empty-character-class.md)                         | disallow character classes that match no characters                                     | ✅  |    |    |    |
 | [no-empty-group](no-empty-group.md)                                             | disallow empty group                                                                    | ✅  |    |    |    |
@@ -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/no-empty-alternative.md b/docs/rules/no-empty-alternative.md
index f355e36..eb4b10c 100644
--- a/docs/rules/no-empty-alternative.md
+++ b/docs/rules/no-empty-alternative.md
@@ -9,6 +9,8 @@ since: "v0.8.0"
 
 ⚠️ This rule _warns_ in the ✅ `plugin:regexp/recommended` config.
 
+💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/developer-guide/working-with-rules#providing-suggestions).
+
 <!-- end auto-generated rule header -->
 
 > disallow alternatives without elements
@@ -19,6 +21,9 @@ While (re-)writing long regular expressions, it can happen that one forgets to
 remove the `|` character of a former alternative. This rule tries to point out
 these potential mistakes by reporting all empty alternatives.
 
+If the empty alternative is supposed to match the empty string, then use a
+quantifier instead. For example, `a|` should be written as `a?`.
+
 <eslint-code-block>
 
 ```js
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/no-empty-alternative.ts b/lib/rules/no-empty-alternative.ts
index 008b56f..67c794f 100644
--- a/lib/rules/no-empty-alternative.ts
+++ b/lib/rules/no-empty-alternative.ts
@@ -1,5 +1,6 @@
 import type { RegExpVisitor } from "@eslint-community/regexpp/visitor"
 import type {
+    Alternative,
     CapturingGroup,
     Group,
     Pattern,
@@ -7,6 +8,49 @@ import type {
 import type { RegExpContext } from "../utils"
 import { createRule, defineRegexpVisitor } from "../utils"
 
+/**
+ * Returns the source before and after the alternatives of the given capturing group.
+ */
+function getCapturingGroupOuterSource(node: CapturingGroup): [string, string] {
+    const first = node.alternatives[0]
+    const last = node.alternatives[node.alternatives.length - 1]
+
+    const innerStart = first.start - node.start
+    const innerEnd = last.end - node.start
+    return [node.raw.slice(0, innerStart), node.raw.slice(innerEnd)]
+}
+
+function getFixedNode(
+    regexpNode: CapturingGroup | Group | Pattern,
+    alt: Alternative,
+): string | null {
+    let quant
+    if (regexpNode.alternatives.at(0) === alt) {
+        quant = "??"
+    } else if (regexpNode.alternatives.at(-1) === alt) {
+        quant = "?"
+    } else {
+        return null
+    }
+
+    const innerAlternatives = regexpNode.alternatives
+        .filter((a) => a !== alt)
+        .map((a) => a.raw)
+        .join("|")
+
+    let replacement = `(?:${innerAlternatives})${quant}`
+    if (regexpNode.type === "CapturingGroup") {
+        const [before, after] = getCapturingGroupOuterSource(regexpNode)
+        replacement = `${before}${replacement}${after}`
+    } else if (regexpNode.parent?.type === "Quantifier") {
+        // if the parent is a quantifier, then we need to wrap the replacement
+        // in a non-capturing group
+        replacement = `(?:${replacement})`
+    }
+
+    return replacement
+}
+
 export default createRule("no-empty-alternative", {
     meta: {
         docs: {
@@ -16,8 +60,10 @@ export default createRule("no-empty-alternative", {
             default: "warn",
         },
         schema: [],
+        hasSuggestions: true,
         messages: {
-            empty: "No empty alternatives. Use quantifiers instead.",
+            empty: "This empty alternative might be a mistake. If not, use a quantifier instead.",
+            suggest: "Use a quantifier instead.",
         },
         type: "problem",
     },
@@ -25,6 +71,7 @@ export default createRule("no-empty-alternative", {
         function createVisitor({
             node,
             getRegexpLocation,
+            fixReplaceNode,
         }: RegExpContext): RegExpVisitor.Handlers {
             function verifyAlternatives(
                 regexpNode: CapturingGroup | Group | Pattern,
@@ -34,12 +81,12 @@ export default createRule("no-empty-alternative", {
                     // the parser and one alternative is already handled by other rules.
                     for (let i = 0; i < regexpNode.alternatives.length; i++) {
                         const alt = regexpNode.alternatives[i]
-                        const last = i === regexpNode.alternatives.length - 1
+                        const isLast = i === regexpNode.alternatives.length - 1
                         if (alt.elements.length === 0) {
                             // Since empty alternative have a width of 0, it's hard to underline their location.
                             // So we will report the location of the `|` that causes the empty alternative.
                             const index = alt.start
-                            const loc = last
+                            const loc = isLast
                                 ? getRegexpLocation({
                                       start: index - 1,
                                       end: index,
@@ -49,10 +96,23 @@ export default createRule("no-empty-alternative", {
                                       end: index + 1,
                                   })
 
+                            const fixed = getFixedNode(regexpNode, alt)
+
                             context.report({
                                 node,
                                 loc,
                                 messageId: "empty",
+                                suggest: fixed
+                                    ? [
+                                          {
+                                              messageId: "suggest",
+                                              fix: fixReplaceNode(
+                                                  regexpNode,
+                                                  fixed,
+                                              ),
+                                          },
+                                      ]
+                                    : undefined,
                             })
                             // don't report the same node multiple times
                             return
diff --git a/lib/rules/no-misleading-capturing-group.ts b/lib/rules/no-misleading-capturing-group.ts
index 7cd9a07..ef2b03d 100644
--- a/lib/rules/no-misleading-capturing-group.ts
+++ b/lib/rules/no-misleading-capturing-group.ts
@@ -18,8 +18,8 @@ import {
     isEmpty,
     hasSomeDescendant,
     Chars,
-    toCharSet,
     followPaths,
+    toUnicodeSet,
 } from "regexp-ast-analysis"
 import { canSimplifyQuantifier } from "../utils/regexp-ast/simplify-quantifier"
 import { fixSimplifyQuantifier } from "../utils/fix-simplify-quantifier"
@@ -161,10 +161,29 @@ function uncachedGetSingleRepeatedChar(
         case "Character":
         case "CharacterClass":
         case "CharacterSet":
-        case "ExpressionCharacterClass":
-            // FIXME: TS Error
-            // @ts-expect-error -- FIXME
-            return toCharSet(element, flags)
+        case "ExpressionCharacterClass": {
+            const set = toUnicodeSet(element, flags)
+            if (set.accept.isEmpty) {
+                return set.chars
+            }
+
+            // this is pretty much the same as a list of alternatives, so we do the
+            // same thing as for Groups and Alternatives.
+            return set.wordSets
+                .map((wordSet): CharSet => {
+                    let total: CharSet | undefined = undefined
+                    for (const c of wordSet) {
+                        if (total === undefined) {
+                            total = c
+                        } else {
+                            total = total.intersect(c)
+                        }
+                        if (total.isEmpty) return total
+                    }
+                    return total ?? Chars.empty(flags)
+                })
+                .reduce((a, b) => a.union(b))
+        }
 
         case "CapturingGroup":
         case "Group":
diff --git a/lib/rules/no-useless-character-class.ts b/lib/rules/no-useless-character-class.ts
index 30c8374..af67957 100644
--- a/lib/rules/no-useless-character-class.ts
+++ b/lib/rules/no-useless-character-class.ts
@@ -7,17 +7,13 @@ import type {
     ExpressionCharacterClass,
     UnicodeSetsCharacterClass,
 } from "@eslint-community/regexpp/ast"
+import { RESERVED_DOUBLE_PUNCTUATOR_CHARS } from "../utils/unicode-set"
 
 const ESCAPES_OUTSIDE_CHARACTER_CLASS = new Set("$()*+./?[{|")
 const ESCAPES_OUTSIDE_CHARACTER_CLASS_WITH_U = new Set([
     ...ESCAPES_OUTSIDE_CHARACTER_CLASS,
     "}",
 ])
-// A single character set of ClassSetReservedDoublePunctuator.
-// && !! ## $$ %% ** ++ ,, .. :: ;; << == >> ?? @@ ^^ `` ~~ are ClassSetReservedDoublePunctuator
-const REGEX_CLASS_SET_RESERVED_DOUBLE_PUNCTUATOR = new Set(
-    "!#$%&*+,.:;<=>?@^`~",
-)
 
 export default createRule("no-useless-character-class", {
     meta: {
@@ -217,9 +213,7 @@ export default createRule("no-useless-character-class", {
 
                             // Avoid [A&&[&]] => [A&&&]
                             if (
-                                REGEX_CLASS_SET_RESERVED_DOUBLE_PUNCTUATOR.has(
-                                    char,
-                                ) &&
+                                RESERVED_DOUBLE_PUNCTUATOR_CHARS.has(char) &&
                                 // The previous character is the same
                                 pattern[ccNode.start - 1] === char
                             ) {
@@ -263,9 +257,7 @@ export default createRule("no-useless-character-class", {
 
                             // Avoid [A[&]&B] => [A&&B]
                             return (
-                                REGEX_CLASS_SET_RESERVED_DOUBLE_PUNCTUATOR.has(
-                                    char,
-                                ) &&
+                                RESERVED_DOUBLE_PUNCTUATOR_CHARS.has(char) &&
                                 // The next character is the same
                                 pattern[ccNode.end] === char
                             )
diff --git a/lib/rules/no-useless-escape.ts b/lib/rules/no-useless-escape.ts
index b47a217..64d2fc4 100644
--- a/lib/rules/no-useless-escape.ts
+++ b/lib/rules/no-useless-escape.ts
@@ -25,20 +25,8 @@ import {
     CP_PIPE,
     CP_MINUS,
     canUnwrapped,
-    CP_HASH,
-    CP_PERCENT,
-    CP_BAN,
-    CP_AMP,
-    CP_COMMA,
-    CP_COLON,
-    CP_SEMI,
-    CP_LT,
-    CP_EQ,
-    CP_GT,
-    CP_AT,
-    CP_TILDE,
-    CP_BACKTICK,
 } from "../utils"
+import { RESERVED_DOUBLE_PUNCTUATOR_CP } from "../utils/unicode-set"
 
 const REGEX_CHAR_CLASS_ESCAPES = new Set([
     CP_BACK_SLASH, // \\
@@ -80,29 +68,6 @@ const POTENTIAL_ESCAPE_SEQUENCE_FOR_CHAR_CLASS = new Set([
     ...POTENTIAL_ESCAPE_SEQUENCE,
     "q",
 ])
-// A single character set of ClassSetReservedDoublePunctuator.
-// && !! ## $$ %% ** ++ ,, .. :: ;; << == >> ?? @@ ^^ `` ~~ are ClassSetReservedDoublePunctuator
-const REGEX_CLASS_SET_RESERVED_DOUBLE_PUNCTUATOR = new Set([
-    CP_BAN, // !
-    CP_HASH, // #
-    CP_DOLLAR, // $
-    CP_PERCENT, // %
-    CP_AMP, // &
-    CP_STAR, // *
-    CP_PLUS, // +
-    CP_COMMA, // ,
-    CP_DOT, // .
-    CP_COLON, // :
-    CP_SEMI, // ;
-    CP_LT, // <
-    CP_EQ, // =
-    CP_GT, // >
-    CP_QUESTION, // ?
-    CP_AT, // @
-    CP_CARET, // ^
-    CP_BACKTICK, // `
-    CP_TILDE, // ~
-])
 
 export default createRule("no-useless-escape", {
     meta: {
@@ -186,7 +151,7 @@ export default createRule("no-useless-escape", {
                                 }
                                 if (flags.unicodeSets) {
                                     if (
-                                        REGEX_CLASS_SET_RESERVED_DOUBLE_PUNCTUATOR.has(
+                                        RESERVED_DOUBLE_PUNCTUATOR_CP.has(
                                             cNode.value,
                                         )
                                     ) {
diff --git a/lib/rules/optimal-quantifier-concatenation.ts b/lib/rules/optimal-quantifier-concatenation.ts
index 0288b8b..a1b8b6d 100644
--- a/lib/rules/optimal-quantifier-concatenation.ts
+++ b/lib/rules/optimal-quantifier-concatenation.ts
@@ -18,8 +18,8 @@ import type { Ancestor, ReadonlyFlags } from "regexp-ast-analysis"
 import {
     Chars,
     hasSomeDescendant,
-    toCharSet,
     getConsumedChars,
+    toUnicodeSet,
 } from "regexp-ast-analysis"
 import { getParser } from "../utils/regexp-ast"
 import type { CharSet } from "refa"
@@ -76,13 +76,13 @@ function getSingleConsumedChar(
         case "Character":
         case "CharacterSet":
         case "CharacterClass":
-        case "ExpressionCharacterClass":
+        case "ExpressionCharacterClass": {
+            const set = toUnicodeSet(element, flags)
             return {
-                // FIXME: TS Error
-                // @ts-expect-error -- FIXME
-                char: toCharSet(element, flags),
-                complete: true,
+                char: set.chars,
+                complete: set.accept.isEmpty,
             }
+        }
 
         case "Group":
         case "CapturingGroup": {
diff --git a/lib/rules/prefer-character-class.ts b/lib/rules/prefer-character-class.ts
index 89ccf54..957a027 100644
--- a/lib/rules/prefer-character-class.ts
+++ b/lib/rules/prefer-character-class.ts
@@ -6,7 +6,7 @@ import type {
     CharacterClass,
     CharacterClassElement,
     CharacterSet,
-    Element,
+    ExpressionCharacterClass,
     Group,
     LookaroundAssertion,
     Node,
@@ -17,12 +17,13 @@ import { createRule, defineRegexpVisitor } from "../utils"
 import type { CharSet } from "refa"
 import type { FirstConsumedChar, ReadonlyFlags } from "regexp-ast-analysis"
 import {
-    toCharSet,
     getFirstConsumedChar,
     getMatchingDirection,
+    toUnicodeSet,
 } from "regexp-ast-analysis"
 import type { Position, SourceLocation } from "estree"
 import { assertNever } from "../utils/util"
+import { RESERVED_DOUBLE_PUNCTUATOR_CHARS } from "../utils/unicode-set"
 
 /**
  * Find the first index of an element that satisfies the given condition.
@@ -59,7 +60,12 @@ type RawAlternative = RawCharAlternative | RawNonCharAlternative
 interface RawCharAlternative {
     readonly isCharacter: true
     readonly alternative: Alternative
-    readonly element: Character | CharacterSet | CharacterClass
+    readonly char: CharSet
+    readonly element:
+        | Character
+        | CharacterSet
+        | CharacterClass
+        | ExpressionCharacterClass
 }
 interface RawNonCharAlternative {
     readonly isCharacter: false
@@ -88,52 +94,54 @@ function elementsToCharacterClass(elements: CharElementArray): string {
     // Its ONLY job is to generate a valid character class from the given elements.
     // Optimizations can be done by another rule.
 
-    let result = "["
+    const parts: string[] = []
 
-    elements.forEach((e, i) => {
+    elements.forEach((e) => {
         switch (e.type) {
             case "Character":
                 if (e.raw === "-") {
-                    if (i === 0 || i === elements.length - 1) {
-                        result += "-"
-                    } else {
-                        result += "\\-"
-                    }
-                } else if (e.raw === "^") {
-                    if (i === 0) {
-                        result += "\\^"
-                    } else {
-                        result += "^"
-                    }
+                    parts.push("\\-")
                 } else if (e.raw === "]") {
-                    result += "\\]"
+                    parts.push("\\]")
                 } else {
-                    result += e.raw
+                    parts.push(e.raw)
                 }
                 break
 
             case "CharacterClassRange":
-                if (e.min.raw === "^" && i === 0) {
-                    result += `\\^-${e.max.raw}`
-                } else {
-                    result += `${e.min.raw}-${e.max.raw}`
-                }
-                break
-
             case "CharacterSet":
-                result += e.raw
+            case "CharacterClass":
+            case "ClassStringDisjunction":
+            case "ExpressionCharacterClass":
+                parts.push(e.raw)
                 break
 
             default:
-                // FIXME: TS Error
-                // @ts-expect-error -- FIXME
                 throw assertNever(e)
         }
     })
 
-    result += "]"
+    if (parts.length > 0 && parts[0].startsWith("^")) {
+        parts[0] = `\\${parts[0]}`
+    }
+
+    // escape double punctuators for v flag
+    for (let i = 1; i < parts.length; i++) {
+        const prev = parts[i - 1]
+        const curr = parts[i]
+
+        const pChar = prev.slice(-1)
+        const cChar = curr[0]
+        if (
+            RESERVED_DOUBLE_PUNCTUATOR_CHARS.has(cChar) &&
+            cChar === pChar &&
+            !prev.endsWith(`\\${pChar}`)
+        ) {
+            parts[i - 1] = `${prev.slice(0, -1)}\\${pChar}`
+        }
+    }
 
-    return result
+    return `[${parts.join("")}]`
 }
 
 /**
@@ -144,21 +152,23 @@ function categorizeRawAlts(
     alternatives: readonly Alternative[],
     flags: ReadonlyFlags,
 ): RawAlternative[] {
-    return alternatives.map<RawAlternative>((alternative) => {
+    return alternatives.map((alternative): RawAlternative => {
         if (alternative.elements.length === 1) {
             const element = alternative.elements[0]
             if (
                 element.type === "Character" ||
                 element.type === "CharacterClass" ||
-                element.type === "CharacterSet"
+                element.type === "CharacterSet" ||
+                element.type === "ExpressionCharacterClass"
             ) {
-                return {
-                    isCharacter: true,
-                    alternative,
-                    element,
-                    // FIXME: TS Error
-                    // @ts-expect-error -- FIXME
-                    char: toCharSet(element, flags),
+                const set = toUnicodeSet(element, flags)
+                if (set.accept.isEmpty) {
+                    return {
+                        isCharacter: true,
+                        alternative,
+                        char: set.chars,
+                        element,
+                    }
                 }
             }
         }
@@ -189,23 +199,36 @@ function containsCharacterClass(alts: readonly RawAlternative[]): boolean {
  *
  * The returned array may be empty.
  */
-function toCharacterClassElement(element: Element): CharElementArray | null {
-    if (element.type === "CharacterSet") {
-        // normal dot is not possible (it technically is but it's complicated)
-        if (element.kind === "any") {
-            return null
-        }
-        return [element]
-    } else if (element.type === "CharacterClass") {
-        if (element.negate) {
-            // we can't (easily) combine negated character classes
-            return null
-        }
-        return element.elements
-    } else if (element.type === "Character") {
-        return [element]
+function toCharacterClassElement(
+    element: RawCharAlternative["element"],
+): CharElementArray | null {
+    switch (element.type) {
+        case "Character":
+            return [element]
+
+        case "CharacterSet":
+            if (element.kind === "any") {
+                // normal dot is not possible (it technically is but it's complicated)
+                return null
+            }
+            return [element]
+
+        case "CharacterClass":
+            if (element.negate) {
+                if (element.unicodeSets) {
+                    return [element]
+                }
+                // we can't (easily) combine negated character classes without the v flag
+                return null
+            }
+            return element.elements
+
+        case "ExpressionCharacterClass":
+            return [element]
+
+        default:
+            return assertNever(element)
     }
-    return null
 }
 
 /**
@@ -215,16 +238,14 @@ function parseRawAlts(
     alternatives: readonly RawAlternative[],
     flags: ReadonlyFlags,
 ): ParsedAlternative[] {
-    return alternatives.map<ParsedAlternative>((a) => {
+    return alternatives.map((a): ParsedAlternative => {
         if (a.isCharacter) {
             const elements = toCharacterClassElement(a.element)
             if (elements) {
                 return {
                     isCharacter: true,
                     elements,
-                    // FIXME: TS Error
-                    // @ts-expect-error -- FIXME
-                    char: toCharSet(a.element, flags),
+                    char: a.char,
                     raw: a.alternative.raw,
                 }
             }
@@ -349,21 +370,14 @@ function findNonDisjointAlt(
 /**
  * Returns where the given alternative can accept any character.
  */
-function totalIsAll(
-    alternatives: readonly RawAlternative[],
-    { flags }: RegExpContext,
-): boolean {
+function totalIsAll(alternatives: readonly RawAlternative[]): boolean {
     let total: CharSet | undefined = undefined
     for (const a of alternatives) {
         if (a.isCharacter) {
             if (total === undefined) {
-                // FIXME: TS Error
-                // @ts-expect-error -- FIXME
-                total = toCharSet(a.element, flags)
+                total = a.char
             } else {
-                // FIXME: TS Error
-                // @ts-expect-error -- FIXME
-                total = total.union(toCharSet(a.element, flags))
+                total = total.union(a.char)
             }
         }
     }
@@ -506,10 +520,7 @@ export default createRule("prefer-character-class", {
                     return
                 }
 
-                if (
-                    alts.every((a) => a.isCharacter) &&
-                    totalIsAll(alts, regexpContext)
-                ) {
+                if (alts.every((a) => a.isCharacter) && totalIsAll(alts)) {
                     // This is the special case where:
                     // 1) all alternatives are characters,
                     // 2) there are at least 2 alternatives, and
@@ -538,7 +549,7 @@ export default createRule("prefer-character-class", {
                 if (
                     characterAltsCount >= minCharacterAlternatives ||
                     containsCharacterClass(alts) ||
-                    totalIsAll(alts, regexpContext) ||
+                    totalIsAll(alts) ||
                     findNonDisjointAlt(parsedAlts)
                 ) {
                     optimizeCharacterAlts(parsedAlts)
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/require-unicode-sets-regexp.ts b/lib/rules/require-unicode-sets-regexp.ts
index d8e33d1..07a1398 100644
--- a/lib/rules/require-unicode-sets-regexp.ts
+++ b/lib/rules/require-unicode-sets-regexp.ts
@@ -3,29 +3,7 @@ import type { RegExpContext } from "../utils"
 import { createRule, defineRegexpVisitor } from "../utils"
 import { RegExpParser, visitRegExpAST } from "@eslint-community/regexpp"
 import { toUnicodeSet } from "regexp-ast-analysis"
-
-const CLASS_SET_RESERVED_DOUBLE_PUNCTUATORS = [
-    "&&",
-    "!!",
-    "##",
-    "$$",
-    "%%",
-    "**",
-    "++",
-    ",,",
-    "..",
-    "::",
-    ";;",
-    "<<",
-    "==",
-    ">>",
-    "??",
-    "@@",
-    "^^",
-    "``",
-    "~~",
-    "--",
-]
+import { RESERVED_DOUBLE_PUNCTUATOR_PATTERN } from "../utils/unicode-set"
 
 /**
  * Returns whether the regex would keep its behavior if the v flag were to be
@@ -48,11 +26,7 @@ function isCompatible(regexpContext: RegExpContext): boolean {
                 if (!us.equals(vus)) {
                     throw INCOMPATIBLE
                 }
-                if (
-                    CLASS_SET_RESERVED_DOUBLE_PUNCTUATORS.some((punctuator) =>
-                        node.raw.includes(punctuator),
-                    )
-                ) {
+                if (RESERVED_DOUBLE_PUNCTUATOR_PATTERN.test(node.raw)) {
                     throw INCOMPATIBLE
                 }
             },
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/unicode-set.ts b/lib/utils/unicode-set.ts
new file mode 100644
index 0000000..cc11851
--- /dev/null
+++ b/lib/utils/unicode-set.ts
@@ -0,0 +1,18 @@
+/**
+ * A single character set of ClassSetReservedDoublePunctuator.
+ *
+ * `&& !! ## $$ %% ** ++ ,, .. :: ;; << == >> ?? @@ ^^ `` ~~ --` are ClassSetReservedDoublePunctuator
+ */
+export const RESERVED_DOUBLE_PUNCTUATOR_CHARS: ReadonlySet<string> = new Set(
+    "&!#$%*+,.:;<=>?@^`~-",
+)
+
+/**
+ * Same as {@link RESERVED_DOUBLE_PUNCTUATOR_CHARS} but as code points.
+ */
+export const RESERVED_DOUBLE_PUNCTUATOR_CP: ReadonlySet<number> = new Set(
+    [...RESERVED_DOUBLE_PUNCTUATOR_CHARS].map((c) => c.codePointAt(0)!),
+)
+
+export const RESERVED_DOUBLE_PUNCTUATOR_PATTERN =
+    /&&|!!|##|\$\$|%%|\*\*|\+\+|,,|\.\.|::|;;|<<|==|>>|\?\?|@@|\^\^|``|~~|--/u
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/no-empty-alternative.ts b/tests/lib/rules/no-empty-alternative.ts
index b839702..010d8e7 100644
--- a/tests/lib/rules/no-empty-alternative.ts
+++ b/tests/lib/rules/no-empty-alternative.ts
@@ -15,9 +15,9 @@ tester.run("no-empty-alternative", rule as any, {
             code: `/|||||/`,
             errors: [
                 {
-                    message: "No empty alternatives. Use quantifiers instead.",
-                    line: 1,
+                    messageId: "empty",
                     column: 2,
+                    suggestions: [{ output: `/(?:||||)??/` }],
                 },
             ],
         },
@@ -25,9 +25,9 @@ tester.run("no-empty-alternative", rule as any, {
             code: `/(a+|b+|)/`,
             errors: [
                 {
-                    message: "No empty alternatives. Use quantifiers instead.",
-                    line: 1,
+                    messageId: "empty",
                     column: 8,
+                    suggestions: [{ output: `/((?:a+|b+)?)/` }],
                 },
             ],
         },
@@ -35,9 +35,36 @@ tester.run("no-empty-alternative", rule as any, {
             code: String.raw`/(?:\|\|||\|)/`,
             errors: [
                 {
-                    message: "No empty alternatives. Use quantifiers instead.",
-                    line: 1,
+                    messageId: "empty",
                     column: 10,
+                    suggestions: [],
+                },
+            ],
+        },
+        {
+            code: String.raw`/(?<name>a|b|)/`,
+            errors: [
+                {
+                    messageId: "empty",
+                    suggestions: [{ output: String.raw`/(?<name>(?:a|b)?)/` }],
+                },
+            ],
+        },
+        {
+            code: String.raw`/(?:a|b|)f/`,
+            errors: [
+                {
+                    messageId: "empty",
+                    suggestions: [{ output: String.raw`/(?:a|b)?f/` }],
+                },
+            ],
+        },
+        {
+            code: String.raw`/(?:a|b|)+f/`,
+            errors: [
+                {
+                    messageId: "empty",
+                    suggestions: [{ output: String.raw`/(?:(?:a|b)?)+f/` }],
                 },
             ],
         },
diff --git a/tests/lib/rules/no-misleading-capturing-group.ts b/tests/lib/rules/no-misleading-capturing-group.ts
index 20fe17c..35a1f77 100644
--- a/tests/lib/rules/no-misleading-capturing-group.ts
+++ b/tests/lib/rules/no-misleading-capturing-group.ts
@@ -3,7 +3,7 @@ import rule from "../../../lib/rules/no-misleading-capturing-group"
 
 const tester = new RuleTester({
     parserOptions: {
-        ecmaVersion: 2020,
+        ecmaVersion: "latest",
         sourceType: "module",
     },
 })
@@ -68,5 +68,11 @@ tester.run("no-misleading-capturing-group", rule as any, {
                 "The quantifier '~?' can exchange characters (~) with '[\\s\\S]+'. This makes the capturing group misleading, because the quantifier will capture fewer characters than its pattern suggests.",
             ],
         },
+        {
+            code: String.raw`/^([a\q{abc}]*).+/v`,
+            errors: [
+                "The quantifier '[a\\q{abc}]*' can exchange characters (a) with '.+'. This makes the capturing group misleading, because the quantifier will capture fewer characters than its pattern suggests.",
+            ],
+        },
     ],
 })
diff --git a/tests/lib/rules/optimal-quantifier-concatenation.ts b/tests/lib/rules/optimal-quantifier-concatenation.ts
index e6a181d..aa75171 100644
--- a/tests/lib/rules/optimal-quantifier-concatenation.ts
+++ b/tests/lib/rules/optimal-quantifier-concatenation.ts
@@ -3,7 +3,7 @@ import rule from "../../../lib/rules/optimal-quantifier-concatenation"
 
 const tester = new RuleTester({
     parserOptions: {
-        ecmaVersion: 2020,
+        ecmaVersion: "latest",
         sourceType: "module",
     },
 })
@@ -291,5 +291,26 @@ tester.run("optimal-quantifier-concatenation", rule as any, {
                 "'\\d*' can be removed because it is already included by '\\d+'. This cannot be fixed automatically because it involves a capturing group.",
             ],
         },
+        {
+            code: String.raw`/\d+(\d*)/v`,
+            output: null,
+            errors: [
+                "'\\d*' can be removed because it is already included by '\\d+'. This cannot be fixed automatically because it involves a capturing group.",
+            ],
+        },
+        {
+            code: String.raw`/a+[a\q{}]+/v`,
+            output: String.raw`/a+[a\q{}]/v`,
+            errors: [
+                "'[a\\q{}]+' can be replaced with '[a\\q{}]' because of 'a+'.",
+            ],
+        },
+        {
+            code: String.raw`/[ab]*[\q{a|bb}]+/v`,
+            output: String.raw`/[ab]*[\q{a|bb}]/v`,
+            errors: [
+                "'[\\q{a|bb}]+' can be replaced with '[\\q{a|bb}]' because of '[ab]*'.",
+            ],
+        },
     ],
 })
diff --git a/tests/lib/rules/prefer-character-class.ts b/tests/lib/rules/prefer-character-class.ts
index 7bd33e9..fe54d02 100644
--- a/tests/lib/rules/prefer-character-class.ts
+++ b/tests/lib/rules/prefer-character-class.ts
@@ -3,7 +3,7 @@ import rule from "../../../lib/rules/prefer-character-class"
 
 const tester = new RuleTester({
     parserOptions: {
-        ecmaVersion: 2020,
+        ecmaVersion: "latest",
         sourceType: "module",
     },
 })
@@ -133,7 +133,7 @@ tester.run("prefer-character-class", rule as any, {
 
         { code: String.raw`/a|b|c/`, output: String.raw`/[abc]/`, errors: 1 },
         { code: String.raw`/]|a|b/`, output: String.raw`/[\]ab]/`, errors: 1 },
-        { code: String.raw`/-|a|c/`, output: String.raw`/[-ac]/`, errors: 1 },
+        { code: String.raw`/-|a|c/`, output: String.raw`/[\-ac]/`, errors: 1 },
         { code: String.raw`/a|-|c/`, output: String.raw`/[a\-c]/`, errors: 1 },
         {
             code: String.raw`/a|[-]|c/`,
@@ -271,6 +271,22 @@ tester.run("prefer-character-class", rule as any, {
             errors: 1,
         },
 
+        {
+            code: String.raw`/1|2|3|[\w--\d]/v`,
+            output: String.raw`/[123[\w--\d]]/v`,
+            errors: 1,
+        },
+        {
+            code: String.raw`/1|&|&|[\w--\d]/v`,
+            output: String.raw`/[1\&&[\w--\d]]/v`,
+            errors: 1,
+        },
+        {
+            code: String.raw`/1|~|~|[\w--\d]|[\q{abc}]/v`,
+            output: String.raw`/[1\~~[\w--\d]]|[\q{abc}]/v`,
+            errors: 1,
+        },
+
         // only report affected alternatives
         {
             code: String.raw`/foo|bar|a|b|c|baz/`,
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.",
+            ],
+        },
     ],
 })