Repository: ota-meshi/eslint-plugin-regexp
Pull request #624 (2023-09-26 19:18:24)

Add suggestions for `regexp/no-lazy-ends`
I added 2 suggestions to every report:

1. The first will transform the regex into what it is. E.g. `/a+?/` -> `/a/`, because these 2 regexes are equivalent.
2. The second will make the quantifier greedy. E.g. `/a+?/` -> `/a+/`. I thought that this might be useful.

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

Latest commit: d471cdf38f54a492eaed0f8c35101dd375d4f492

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/no-lazy-ends-suggest?filename=.changeset/rare-spiders-drop.md&value=---%0A%22eslint-plugin-regexp%22%3A%20patch%0A---%0A%0AAdd%20suggestions%20for%20%60regexp%2Fno-lazy-ends%60%0A)


RunDevelopment: Okay, how should I fix this? Our tests seem to require that all messages end with a dot, but my suggestion messages end with a sentence in parentheses, so it ends with `.)`.

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/rare-spiders-drop.md b/.changeset/rare-spiders-drop.md
new file mode 100644
index 0000000..1abe352
--- /dev/null
+++ b/.changeset/rare-spiders-drop.md
@@ -0,0 +1,5 @@
+---
+"eslint-plugin-regexp": minor
+---
+
+Add suggestions for `regexp/no-lazy-ends`
diff --git a/.changeset/silly-bees-wave.md b/.changeset/silly-bees-wave.md
new file mode 100644
index 0000000..683aba2
--- /dev/null
+++ b/.changeset/silly-bees-wave.md
@@ -0,0 +1,5 @@
+---
+"eslint-plugin-regexp": minor
+---
+
+Add suggestions for `regexp/optimal-lookaround-quantifier`
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/.changeset/twenty-snails-chew.md b/.changeset/twenty-snails-chew.md
new file mode 100644
index 0000000..c1a4969
--- /dev/null
+++ b/.changeset/twenty-snails-chew.md
@@ -0,0 +1,5 @@
+---
+"eslint-plugin-regexp": minor
+---
+
+Added suggestions for `regexp/no-escape-backspace`
diff --git a/README.md b/README.md
index 561ec72..bf9689a 100644
--- a/README.md
+++ b/README.md
@@ -112,14 +112,14 @@ 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                                                                    | ✅  |    |    |    |
 | [no-empty-lookarounds-assertion](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-empty-lookarounds-assertion.html)             | disallow empty lookahead assertion or empty lookbehind assertion                        | ✅  |    |    |    |
-| [no-escape-backspace](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-escape-backspace.html)                                   | disallow escape backspace (`[\b]`)                                                      | ✅  |    |    |    |
+| [no-escape-backspace](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-escape-backspace.html)                                   | disallow escape backspace (`[\b]`)                                                      | ✅  |    |    | 💡 |
 | [no-invalid-regexp](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-invalid-regexp.html)                                       | disallow invalid regular expression strings in `RegExp` constructors                    | ✅  |    |    |    |
-| [no-lazy-ends](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-lazy-ends.html)                                                 | disallow lazy quantifiers at the end of an expression                                   |    | ✅  |    |    |
+| [no-lazy-ends](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-lazy-ends.html)                                                 | disallow lazy quantifiers at the end of an expression                                   |    | ✅  |    | 💡 |
 | [no-misleading-capturing-group](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-misleading-capturing-group.html)               | disallow capturing groups that do not behave as one would expect                        | ✅  |    |    | 💡 |
 | [no-misleading-unicode-character](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-misleading-unicode-character.html)           | disallow multi-code-point characters in character classes and quantifiers               | ✅  |    | 🔧 | 💡 |
 | [no-missing-g-flag](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-missing-g-flag.html)                                       | disallow missing `g` flag in patterns used in `String#matchAll` and `String#replaceAll` | ✅  |    | 🔧 |    |
@@ -157,7 +157,7 @@ The `plugin:regexp/all` config enables all rules. It's meant for testing, not fo
 | [no-useless-range](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-useless-range.html)                                           | disallow unnecessary character ranges                                                      | ✅  |    | 🔧 |    |
 | [no-useless-two-nums-quantifier](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-useless-two-nums-quantifier.html)               | disallow unnecessary `{n,m}` quantifier                                                    | ✅  |    | 🔧 |    |
 | [no-zero-quantifier](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-zero-quantifier.html)                                       | disallow quantifiers with a maximum of zero                                                | ✅  |    |    | 💡 |
-| [optimal-lookaround-quantifier](https://ota-meshi.github.io/eslint-plugin-regexp/rules/optimal-lookaround-quantifier.html)                 | disallow the alternatives of lookarounds that end with a non-constant quantifier           |    | ✅  |    |    |
+| [optimal-lookaround-quantifier](https://ota-meshi.github.io/eslint-plugin-regexp/rules/optimal-lookaround-quantifier.html)                 | disallow the alternatives of lookarounds that end with a non-constant quantifier           |    | ✅  |    | 💡 |
 | [optimal-quantifier-concatenation](https://ota-meshi.github.io/eslint-plugin-regexp/rules/optimal-quantifier-concatenation.html)           | require optimal quantifiers for concatenated quantifiers                                   | ✅  |    | 🔧 |    |
 | [prefer-escape-replacement-dollar-char](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-escape-replacement-dollar-char.html) | enforces escape of replacement `$` character (`$$`).                                       |    |    |    |    |
 | [prefer-predefined-assertion](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-predefined-assertion.html)                     | prefer predefined assertion over equivalent lookarounds                                    | ✅  |    | 🔧 |    |
@@ -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..11ed9bc 100644
--- a/docs/rules/index.md
+++ b/docs/rules/index.md
@@ -19,14 +19,14 @@ 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                                                                    | ✅  |    |    |    |
 | [no-empty-lookarounds-assertion](no-empty-lookarounds-assertion.md)             | disallow empty lookahead assertion or empty lookbehind assertion                        | ✅  |    |    |    |
-| [no-escape-backspace](no-escape-backspace.md)                                   | disallow escape backspace (`[\b]`)                                                      | ✅  |    |    |    |
+| [no-escape-backspace](no-escape-backspace.md)                                   | disallow escape backspace (`[\b]`)                                                      | ✅  |    |    | 💡 |
 | [no-invalid-regexp](no-invalid-regexp.md)                                       | disallow invalid regular expression strings in `RegExp` constructors                    | ✅  |    |    |    |
-| [no-lazy-ends](no-lazy-ends.md)                                                 | disallow lazy quantifiers at the end of an expression                                   |    | ✅  |    |    |
+| [no-lazy-ends](no-lazy-ends.md)                                                 | disallow lazy quantifiers at the end of an expression                                   |    | ✅  |    | 💡 |
 | [no-misleading-capturing-group](no-misleading-capturing-group.md)               | disallow capturing groups that do not behave as one would expect                        | ✅  |    |    | 💡 |
 | [no-misleading-unicode-character](no-misleading-unicode-character.md)           | disallow multi-code-point characters in character classes and quantifiers               | ✅  |    | 🔧 | 💡 |
 | [no-missing-g-flag](no-missing-g-flag.md)                                       | disallow missing `g` flag in patterns used in `String#matchAll` and `String#replaceAll` | ✅  |    | 🔧 |    |
@@ -64,7 +64,7 @@ sidebarDepth: 0
 | [no-useless-range](no-useless-range.md)                                           | disallow unnecessary character ranges                                                      | ✅  |    | 🔧 |    |
 | [no-useless-two-nums-quantifier](no-useless-two-nums-quantifier.md)               | disallow unnecessary `{n,m}` quantifier                                                    | ✅  |    | 🔧 |    |
 | [no-zero-quantifier](no-zero-quantifier.md)                                       | disallow quantifiers with a maximum of zero                                                | ✅  |    |    | 💡 |
-| [optimal-lookaround-quantifier](optimal-lookaround-quantifier.md)                 | disallow the alternatives of lookarounds that end with a non-constant quantifier           |    | ✅  |    |    |
+| [optimal-lookaround-quantifier](optimal-lookaround-quantifier.md)                 | disallow the alternatives of lookarounds that end with a non-constant quantifier           |    | ✅  |    | 💡 |
 | [optimal-quantifier-concatenation](optimal-quantifier-concatenation.md)           | require optimal quantifiers for concatenated quantifiers                                   | ✅  |    | 🔧 |    |
 | [prefer-escape-replacement-dollar-char](prefer-escape-replacement-dollar-char.md) | enforces escape of replacement `$` character (`$$`).                                       |    |    |    |    |
 | [prefer-predefined-assertion](prefer-predefined-assertion.md)                     | prefer predefined assertion over equivalent lookarounds                                    | ✅  |    | 🔧 |    |
@@ -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/no-escape-backspace.md b/docs/rules/no-escape-backspace.md
index e78b022..63c5d4a 100644
--- a/docs/rules/no-escape-backspace.md
+++ b/docs/rules/no-escape-backspace.md
@@ -9,6 +9,8 @@ since: "v0.1.0"
 
 💼 This rule is enabled 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 escape backspace (`[\b]`)
diff --git a/docs/rules/no-lazy-ends.md b/docs/rules/no-lazy-ends.md
index 993a354..7cd630b 100644
--- a/docs/rules/no-lazy-ends.md
+++ b/docs/rules/no-lazy-ends.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 lazy quantifiers at the end of an expression
diff --git a/docs/rules/optimal-lookaround-quantifier.md b/docs/rules/optimal-lookaround-quantifier.md
index 83eada8..3359133 100644
--- a/docs/rules/optimal-lookaround-quantifier.md
+++ b/docs/rules/optimal-lookaround-quantifier.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 the alternatives of lookarounds that end with a non-constant quantifier
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-escape-backspace.ts b/lib/rules/no-escape-backspace.ts
index 88ed8a7..160a3f0 100644
--- a/lib/rules/no-escape-backspace.ts
+++ b/lib/rules/no-escape-backspace.ts
@@ -10,8 +10,10 @@ export default createRule("no-escape-backspace", {
             recommended: true,
         },
         schema: [],
+        hasSuggestions: true,
         messages: {
             unexpected: "Unexpected '[\\b]'. Use '\\u0008' instead.",
+            suggest: "Use '\\u0008'.",
         },
         type: "suggestion", // "problem",
     },
@@ -19,6 +21,7 @@ export default createRule("no-escape-backspace", {
         function createVisitor({
             node,
             getRegexpLocation,
+            fixReplaceNode,
         }: RegExpContext): RegExpVisitor.Handlers {
             return {
                 onCharacterEnter(cNode) {
@@ -27,6 +30,12 @@ export default createRule("no-escape-backspace", {
                             node,
                             loc: getRegexpLocation(cNode),
                             messageId: "unexpected",
+                            suggest: [
+                                {
+                                    messageId: "suggest",
+                                    fix: fixReplaceNode(cNode, "\\u0008"),
+                                },
+                            ],
                         })
                     }
                 },
diff --git a/lib/rules/no-lazy-ends.ts b/lib/rules/no-lazy-ends.ts
index 2145ec2..7f3cb60 100644
--- a/lib/rules/no-lazy-ends.ts
+++ b/lib/rules/no-lazy-ends.ts
@@ -1,6 +1,7 @@
 import type { RegExpVisitor } from "@eslint-community/regexpp/visitor"
 import type { Alternative, Quantifier } from "@eslint-community/regexpp/ast"
 import type { RegExpContext } from "../utils"
+import type { Rule } from "eslint"
 import { createRule, defineRegexpVisitor } from "../utils"
 import { UsageOfPattern } from "../utils/get-usage-of-pattern"
 
@@ -57,6 +58,7 @@ export default createRule("no-lazy-ends", {
                 additionalProperties: false,
             },
         ],
+        hasSuggestions: true,
         messages: {
             uselessElement:
                 "The quantifier and the quantified element can be removed because the quantifier is lazy and has a minimum of 0.",
@@ -64,6 +66,15 @@ export default createRule("no-lazy-ends", {
                 "The quantifier can be removed because the quantifier is lazy and has a minimum of 1.",
             uselessRange:
                 "The quantifier can be replaced with '{{{min}}}' because the quantifier is lazy and has a minimum of {{min}}.",
+
+            suggestMakeGreedy:
+                "Make the quantifier greedy. (This changes the behavior of the regex.)",
+            suggestRemoveElement:
+                "Remove the quantified element. (This does not changes the behavior of the regex.)",
+            suggestRemoveQuantifier:
+                "Remove the quantifier. (This does not changes the behavior of the regex.)",
+            suggestRange:
+                "Replace the quantifier with '{{{min}}}'. (This does not changes the behavior of the regex.)",
         },
         type: "problem",
     },
@@ -74,6 +85,7 @@ export default createRule("no-lazy-ends", {
             node,
             getRegexpLocation,
             getUsageOfPattern,
+            fixReplaceNode,
         }: RegExpContext): RegExpVisitor.Handlers {
             if (ignorePartial) {
                 const usageOfPattern = getUsageOfPattern()
@@ -88,17 +100,48 @@ export default createRule("no-lazy-ends", {
                     for (const lazy of extractLazyEndQuantifiers(
                         pNode.alternatives,
                     )) {
+                        const makeGreedy: Rule.SuggestionReportDescriptor = {
+                            messageId: "suggestMakeGreedy",
+                            fix: fixReplaceNode(lazy, lazy.raw.slice(0, -1)),
+                        }
+
                         if (lazy.min === 0) {
+                            // we have to replace the quantifier with (?:) to
+                            // avoid creating invalid patterns. E.g. /a??/ -> /(?:)/
+                            const replacement =
+                                pNode.alternatives.length === 1 &&
+                                pNode.alternatives[0].elements.length === 1 &&
+                                pNode.alternatives[0].elements[0] === lazy
+                                    ? "(?:)"
+                                    : ""
+
                             context.report({
                                 node,
                                 loc: getRegexpLocation(lazy),
                                 messageId: "uselessElement",
+                                suggest: [
+                                    {
+                                        messageId: "suggestRemoveElement",
+                                        fix: fixReplaceNode(lazy, replacement),
+                                    },
+                                    makeGreedy,
+                                ],
                             })
                         } else if (lazy.min === 1) {
                             context.report({
                                 node,
                                 loc: getRegexpLocation(lazy),
                                 messageId: "uselessQuantifier",
+                                suggest: [
+                                    {
+                                        messageId: "suggestRemoveQuantifier",
+                                        fix: fixReplaceNode(
+                                            lazy,
+                                            lazy.element.raw,
+                                        ),
+                                    },
+                                    makeGreedy,
+                                ],
                             })
                         } else {
                             context.report({
@@ -108,6 +151,19 @@ export default createRule("no-lazy-ends", {
                                 data: {
                                     min: String(lazy.min),
                                 },
+                                suggest: [
+                                    {
+                                        messageId: "suggestRange",
+                                        data: {
+                                            min: String(lazy.min),
+                                        },
+                                        fix: fixReplaceNode(
+                                            lazy,
+                                            `${lazy.element.raw}{${lazy.min}}`,
+                                        ),
+                                    },
+                                    makeGreedy,
+                                ],
                             })
                         }
                     }
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-lookaround-quantifier.ts b/lib/rules/optimal-lookaround-quantifier.ts
index cdb5ac8..a761308 100644
--- a/lib/rules/optimal-lookaround-quantifier.ts
+++ b/lib/rules/optimal-lookaround-quantifier.ts
@@ -66,10 +66,13 @@ export default createRule("optimal-lookaround-quantifier", {
             default: "warn",
         },
         schema: [],
+        hasSuggestions: true,
         messages: {
             remove: "The quantified expression {{expr}} at the {{endOrStart}} of the expression tree should only be matched a constant number of times. The expression can be removed without affecting the lookaround.",
             replacedWith:
                 "The quantified expression {{expr}} at the {{endOrStart}} of the expression tree should only be matched a constant number of times. The expression can be replaced with {{replacer}} without affecting the lookaround.",
+            suggestRemove: "Remove the expression.",
+            suggestReplace: "Replace the expression with {{replacer}}.",
         },
         type: "problem",
     },
@@ -77,6 +80,7 @@ export default createRule("optimal-lookaround-quantifier", {
         function createVisitor({
             node,
             getRegexpLocation,
+            fixReplaceNode,
         }: RegExpContext): RegExpVisitor.Handlers {
             return {
                 onAssertionEnter(aNode) {
@@ -108,6 +112,25 @@ export default createRule("optimal-lookaround-quantifier", {
                                     endOrStart,
                                     replacer,
                                 },
+                                suggest: [
+                                    {
+                                        messageId:
+                                            q.min === 0
+                                                ? "suggestRemove"
+                                                : "suggestReplace",
+                                        data: {
+                                            replacer,
+                                        },
+                                        fix: fixReplaceNode(q, () => {
+                                            if (q.min === 0) {
+                                                return ""
+                                            } else if (q.min === 1) {
+                                                return q.element.raw
+                                            }
+                                            return `${q.element.raw}{${q.min}}`
+                                        }),
+                                    },
+                                ],
                             })
                         }
                     }
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-escape-backspace.ts b/tests/lib/rules/no-escape-backspace.ts
index 18d52d5..fe261fa 100644
--- a/tests/lib/rules/no-escape-backspace.ts
+++ b/tests/lib/rules/no-escape-backspace.ts
@@ -18,6 +18,7 @@ tester.run("no-escape-backspace", rule as any, {
                     message: "Unexpected '[\\b]'. Use '\\u0008' instead.",
                     column: 3,
                     endColumn: 5,
+                    suggestions: [{ output: "/[\\u0008]/" }],
                 },
             ],
         },
diff --git a/tests/lib/rules/no-lazy-ends.ts b/tests/lib/rules/no-lazy-ends.ts
index 3a6eadb..2c5f528 100644
--- a/tests/lib/rules/no-lazy-ends.ts
+++ b/tests/lib/rules/no-lazy-ends.ts
@@ -38,6 +38,10 @@ tester.run("no-lazy-ends", rule as any, {
                         "The quantifier and the quantified element can be removed because the quantifier is lazy and has a minimum of 0.",
                     line: 1,
                     column: 2,
+                    suggestions: [
+                        { output: "/(?:)/.test(str)" },
+                        { output: "/a?/.test(str)" },
+                    ],
                 },
             ],
         },
@@ -49,6 +53,10 @@ tester.run("no-lazy-ends", rule as any, {
                         "The quantifier and the quantified element can be removed because the quantifier is lazy and has a minimum of 0.",
                     line: 1,
                     column: 2,
+                    suggestions: [
+                        { output: "/(?:)/.test(str)" },
+                        { output: "/a*/.test(str)" },
+                    ],
                 },
             ],
         },
@@ -60,6 +68,10 @@ tester.run("no-lazy-ends", rule as any, {
                         "The quantifier can be removed because the quantifier is lazy and has a minimum of 1.",
                     line: 1,
                     column: 2,
+                    suggestions: [
+                        { output: "/a/.test(str)" },
+                        { output: "/a+/.test(str)" },
+                    ],
                 },
             ],
         },
@@ -71,6 +83,10 @@ tester.run("no-lazy-ends", rule as any, {
                         "The quantifier can be replaced with '{3}' because the quantifier is lazy and has a minimum of 3.",
                     line: 1,
                     column: 2,
+                    suggestions: [
+                        { output: "/a{3}/.test(str)" },
+                        { output: "/a{3,7}/.test(str)" },
+                    ],
                 },
             ],
         },
@@ -82,6 +98,10 @@ tester.run("no-lazy-ends", rule as any, {
                         "The quantifier can be replaced with '{3}' because the quantifier is lazy and has a minimum of 3.",
                     line: 1,
                     column: 2,
+                    suggestions: [
+                        { output: "/a{3}/.test(str)" },
+                        { output: "/a{3,}/.test(str)" },
+                    ],
                 },
             ],
         },
@@ -94,6 +114,10 @@ tester.run("no-lazy-ends", rule as any, {
                         "The quantifier can be removed because the quantifier is lazy and has a minimum of 1.",
                     line: 1,
                     column: 9,
+                    suggestions: [
+                        { output: `/(?:a|b(c))/.test(str)` },
+                        { output: `/(?:a|b(c+))/.test(str)` },
+                    ],
                 },
             ],
         },
@@ -105,6 +129,10 @@ tester.run("no-lazy-ends", rule as any, {
                         "The quantifier can be removed because the quantifier is lazy and has a minimum of 1.",
                     line: 1,
                     column: 9,
+                    suggestions: [
+                        { output: `/a(?:c|ab)?/.test(str)` },
+                        { output: `/a(?:c|ab+)?/.test(str)` },
+                    ],
                 },
             ],
         },
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-lookaround-quantifier.ts b/tests/lib/rules/optimal-lookaround-quantifier.ts
index 0d9e6d8..d3fd173 100644
--- a/tests/lib/rules/optimal-lookaround-quantifier.ts
+++ b/tests/lib/rules/optimal-lookaround-quantifier.ts
@@ -17,8 +17,8 @@ tester.run("optimal-lookaround-quantifier", rule as any, {
                 {
                     message:
                         "The quantified expression 'a*' at the end of the expression tree should only be matched a constant number of times. The expression can be removed without affecting the lookaround.",
-                    line: 1,
                     column: 6,
+                    suggestions: [{ output: `/(?=b)/` }],
                 },
             ],
         },
@@ -28,8 +28,8 @@ tester.run("optimal-lookaround-quantifier", rule as any, {
                 {
                     message:
                         "The quantified expression 'c*' at the end of the expression tree should only be matched a constant number of times. The expression can be removed without affecting the lookaround.",
-                    line: 1,
                     column: 14,
+                    suggestions: [{ output: `/(?=(?:a|b|ab))/` }],
                 },
             ],
         },
@@ -39,8 +39,8 @@ tester.run("optimal-lookaround-quantifier", rule as any, {
                 {
                     message:
                         "The quantified expression 'c+' at the end of the expression tree should only be matched a constant number of times. The expression can be replaced with 'c' (no quantifier) without affecting the lookaround.",
-                    line: 1,
                     column: 14,
+                    suggestions: [{ output: `/(?=(?:a|b|abc))/` }],
                 },
             ],
         },
@@ -50,8 +50,8 @@ tester.run("optimal-lookaround-quantifier", rule as any, {
                 {
                     message:
                         "The quantified expression 'c{4,9}' at the end of the expression tree should only be matched a constant number of times. The expression can be replaced with 'c{4}' without affecting the lookaround.",
-                    line: 1,
                     column: 14,
+                    suggestions: [{ output: `/(?=(?:a|b|abc{4}))/` }],
                 },
             ],
         },
@@ -61,8 +61,8 @@ tester.run("optimal-lookaround-quantifier", rule as any, {
                 {
                     message:
                         "The quantified expression '[a-c]*' at the start of the expression tree should only be matched a constant number of times. The expression can be removed without affecting the lookaround.",
-                    line: 1,
                     column: 6,
+                    suggestions: [{ output: `/(?<=)/` }],
                 },
             ],
         },
@@ -72,8 +72,8 @@ tester.run("optimal-lookaround-quantifier", rule as any, {
                 {
                     message:
                         "The quantified expression '(?:d|c)*' at the start of the expression tree should only be matched a constant number of times. The expression can be removed without affecting the lookaround.",
-                    line: 1,
                     column: 6,
+                    suggestions: [{ output: `/(?<=ab)/` }],
                 },
             ],
         },
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.",
+            ],
+        },
     ],
 })
diff --git a/tests/lib/utils/rules.ts b/tests/lib/utils/rules.ts
index ccef506..b88da1a 100644
--- a/tests/lib/utils/rules.ts
+++ b/tests/lib/utils/rules.ts
@@ -63,9 +63,7 @@ describe("Check if the strict of all rules is correct", () => {
                     it(messageId, () => {
                         const message = rule.meta.messages[messageId]
                         assert.ok(
-                            message.endsWith(".") ||
-                                message.endsWith("?") ||
-                                message.endsWith("}}"),
+                            /(?:[.?]|\}\})\)?$/u.test(message),
                             "Doesn't end with a dot.",
                         )
                     })