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

Issue:
Use expression character class instead of lookaround
**Motivation**
Due to the lack of set operation for character classes, I wrote regexes like `(?!\s)[\w\P{ASCII}]` in the past ([another example](https://github.com/ota-meshi/eslint-plugin-regexp/issues/574#issuecomment-1711508301)). They can be transformed to use set operations instead. Using set operation not only makes the intent clearer, it's also more performant. I did some performance test a while back, and `(?!\s)[\w\P{ASCII}]` was about 4x slower than `[\w...whatever ranges are equivalent]`.

**Description**
Combine single-character lookarounds with character classes in regexes with the `v` flag. E.g. `(?=[x])[y]` => `[y&&x]` and `(?![x])[y]` => `[y--x]`. Regexes without the `v` should not be affected.

If the element after the lookaround is a character set, then the transformation should still be applied. E.g. `(?!\s)\P{ASCII}` => `[\P{ASCII}--\s]`.

**Examples**

```js
// good
/(?!\s)[\w\x80-\uFFFF]/;
/(?!\s)[\w\P{ASCII}]/u;

// bad
bad  = /(?!\s)[\w\P{ASCII}]/v;
good = /[[\w\P{ASCII}]--\s]/v;
```


Pull request description:
Add `regexp/prefer-set-operation` rule
Resolves #581.

The implementation of the rule is fairly simple right now. It just goes through all elements of an alternative and tries to find the pattern `(?=a)b` or `a(?<=b)`. So it won't attempt to do clever things like assertions reordering (e.g. `(?!a)(?=bcd)e` == `(?=bcd)(?!a)e` == `(?=bcd)[e--a]`) and partial application (e.g. `(?!a|bcd)e` == `(?!bcd)(?!a)e` == `(?!bcd)[e--a]`). I think it should be okay. Adjacent assertions are rare in practice, and partial application has the issue that it might make the intent of the author less clear.

Patch:
diff --git a/.changeset/pre.json b/.changeset/pre.json
index f9f6b7e..f863854 100644
--- a/.changeset/pre.json
+++ b/.changeset/pre.json
@@ -14,6 +14,7 @@
     "early-guests-hug",
     "friendly-walls-reply",
     "gold-baboons-clap",
+    "green-peaches-explode",
     "heavy-rings-reply",
     "hungry-spoons-mix",
     "khaki-colts-tell",
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/CHANGELOG.md b/CHANGELOG.md
index 62de131..3c0efce 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
 # eslint-plugin-regexp
 
+## 2.0.0-next.8
+
+### Minor Changes
+
+-   Add support for v flag to `regexp/sort-character-class-elements` rule ([#588](https://github.com/ota-meshi/eslint-plugin-regexp/pull/588))
+
 ## 2.0.0-next.7
 
 ### Minor Changes
diff --git a/README.md b/README.md
index 561ec72..2d8398e 100644
--- a/README.md
+++ b/README.md
@@ -165,6 +165,7 @@ The `plugin:regexp/all` config enables all rules. It's meant for testing, not fo
 | [prefer-range](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-range.html)                                                   | enforce using character class range                                                        | ✅  |    | 🔧 |    |
 | [prefer-regexp-exec](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-regexp-exec.html)                                       | enforce that `RegExp#exec` is used instead of `String#match` if no global flag is provided |    |    |    |    |
 | [prefer-regexp-test](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-regexp-test.html)                                       | enforce that `RegExp#test` is used instead of `String#match` and `RegExp#exec`             |    |    | 🔧 |    |
+| [prefer-set-operation](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-set-operation.html)                                   | prefer character class set operations instead of lookarounds                               | ✅  |    | 🔧 |    |
 | [require-unicode-regexp](https://ota-meshi.github.io/eslint-plugin-regexp/rules/require-unicode-regexp.html)                               | enforce the use of the `u` flag                                                            |    |    | 🔧 |    |
 | [require-unicode-sets-regexp](https://ota-meshi.github.io/eslint-plugin-regexp/rules/require-unicode-sets-regexp.html)                     | enforce the use of the `v` flag                                                            |    |    | 🔧 |    |
 | [sort-alternatives](https://ota-meshi.github.io/eslint-plugin-regexp/rules/sort-alternatives.html)                                         | sort alternatives if order doesn't matter                                                  |    |    | 🔧 |    |
diff --git a/docs/rules/index.md b/docs/rules/index.md
index 31e251a..dc8f59f 100644
--- a/docs/rules/index.md
+++ b/docs/rules/index.md
@@ -72,6 +72,7 @@ sidebarDepth: 0
 | [prefer-range](prefer-range.md)                                                   | enforce using character class range                                                        | ✅  |    | 🔧 |    |
 | [prefer-regexp-exec](prefer-regexp-exec.md)                                       | enforce that `RegExp#exec` is used instead of `String#match` if no global flag is provided |    |    |    |    |
 | [prefer-regexp-test](prefer-regexp-test.md)                                       | enforce that `RegExp#test` is used instead of `String#match` and `RegExp#exec`             |    |    | 🔧 |    |
+| [prefer-set-operation](prefer-set-operation.md)                                   | prefer character class set operations instead of lookarounds                               | ✅  |    | 🔧 |    |
 | [require-unicode-regexp](require-unicode-regexp.md)                               | enforce the use of the `u` flag                                                            |    |    | 🔧 |    |
 | [require-unicode-sets-regexp](require-unicode-sets-regexp.md)                     | enforce the use of the `v` flag                                                            |    |    | 🔧 |    |
 | [sort-alternatives](sort-alternatives.md)                                         | sort alternatives if order doesn't matter                                                  |    |    | 🔧 |    |
diff --git a/docs/rules/prefer-set-operation.md b/docs/rules/prefer-set-operation.md
new file mode 100644
index 0000000..d8e65b1
--- /dev/null
+++ b/docs/rules/prefer-set-operation.md
@@ -0,0 +1,48 @@
+---
+pageClass: "rule-details"
+sidebarDepth: 0
+title: "regexp/prefer-set-operation"
+description: "prefer character class set operations instead of lookarounds"
+---
+# regexp/prefer-set-operation
+
+💼 This rule is enabled in the ✅ `plugin:regexp/recommended` config.
+
+🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
+
+<!-- end auto-generated rule header -->
+
+> prefer character class set operations instead of lookarounds
+
+## :book: Rule Details
+
+Regular expressions with the `v` flag have access to character class set operations (e.g. `/[\s&&\p{ASCII}]/v`, `/[\w--\d]/v`). These are more readable and performant than using lookarounds to achieve the same effect. For example, `/(?!\d)\w/v` is the same as `/[\w--\d]/v`.
+
+<eslint-code-block fix>
+
+```js
+/* eslint regexp/prefer-set-operation: "error" */
+
+/* ✓ GOOD */
+var foo = /(?!\d)\w/  // requires the v flag
+var foo = /(?!\d)\w/u // requires the v flag
+
+/* ✗ BAD */
+var foo = /(?!\d)\w/v
+var foo = /(?!\s)[\w\P{ASCII}]/v
+```
+
+</eslint-code-block>
+
+## :wrench: Options
+
+Nothing.
+
+## :rocket: Version
+
+:exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/lib/rules/prefer-set-operation.ts)
+- [Test source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/tests/lib/rules/prefer-set-operation.ts)
diff --git a/lib/configs/recommended.ts b/lib/configs/recommended.ts
index 3d0979e..d112209 100644
--- a/lib/configs/recommended.ts
+++ b/lib/configs/recommended.ts
@@ -60,6 +60,7 @@ export const rules = {
     "regexp/prefer-predefined-assertion": "error",
     "regexp/prefer-question-quantifier": "error",
     "regexp/prefer-range": "error",
+    "regexp/prefer-set-operation": "error",
     "regexp/prefer-star-quantifier": "error",
     "regexp/prefer-unicode-codepoint-escapes": "error",
     "regexp/prefer-w": "error",
diff --git a/lib/rules/prefer-set-operation.ts b/lib/rules/prefer-set-operation.ts
new file mode 100644
index 0000000..86cdf00
--- /dev/null
+++ b/lib/rules/prefer-set-operation.ts
@@ -0,0 +1,146 @@
+import type { RegExpVisitor } from "@eslint-community/regexpp/visitor"
+import type {
+    Alternative,
+    Character,
+    CharacterClass,
+    CharacterSet,
+    ExpressionCharacterClass,
+    LookaroundAssertion,
+    Node,
+} from "@eslint-community/regexpp/ast"
+import type { RegExpContext } from "../utils"
+import { createRule, defineRegexpVisitor } from "../utils"
+import { hasStrings } from "regexp-ast-analysis"
+
+type CharElement =
+    | Character
+    | CharacterSet
+    | CharacterClass
+    | ExpressionCharacterClass
+
+function isCharElement(node: Node): node is CharElement {
+    return (
+        node.type === "Character" ||
+        node.type === "CharacterSet" ||
+        node.type === "CharacterClass" ||
+        node.type === "ExpressionCharacterClass"
+    )
+}
+
+type CharLookaround = LookaroundAssertion & {
+    alternatives: [Alternative & { elements: [CharElement] }]
+}
+
+function isCharLookaround(node: Node): node is CharLookaround {
+    return (
+        node.type === "Assertion" &&
+        (node.kind === "lookahead" || node.kind === "lookbehind") &&
+        node.alternatives.length === 1 &&
+        node.alternatives[0].elements.length === 1 &&
+        isCharElement(node.alternatives[0].elements[0])
+    )
+}
+
+function escapeRaw(raw: string): string {
+    if (/^[&\-^]$/u.test(raw)) {
+        return `\\${raw}`
+    }
+    return raw
+}
+
+export default createRule("prefer-set-operation", {
+    meta: {
+        docs: {
+            description:
+                "prefer character class set operations instead of lookarounds",
+            category: "Best Practices",
+            recommended: true,
+        },
+        fixable: "code",
+        schema: [],
+        messages: {
+            unexpected:
+                "This lookaround can be combined with '{{char}}' using a set operation.",
+        },
+        type: "suggestion",
+    },
+    create(context) {
+        function createVisitor(
+            regexpContext: RegExpContext,
+        ): RegExpVisitor.Handlers {
+            const { node, flags, getRegexpLocation, fixReplaceNode } =
+                regexpContext
+
+            if (!flags.unicodeSets) {
+                // set operations are exclusive to the v flag.
+                return {}
+            }
+
+            function tryApply(
+                element: CharElement,
+                assertion: CharLookaround,
+                parent: Alternative,
+            ): void {
+                const assertElement = assertion.alternatives[0].elements[0]
+                if (hasStrings(assertElement, flags)) {
+                    return
+                }
+
+                context.report({
+                    node,
+                    loc: getRegexpLocation(assertion),
+                    messageId: "unexpected",
+                    data: {
+                        char: element.raw,
+                    },
+                    fix: fixReplaceNode(parent, () => {
+                        const op = assertion.negate ? "--" : "&&"
+                        const left = escapeRaw(element.raw)
+                        const right = escapeRaw(assertElement.raw)
+                        const replacement = `[${left}${op}${right}]`
+
+                        return parent.elements
+                            .map((e) => {
+                                if (e === assertion) {
+                                    return ""
+                                } else if (e === element) {
+                                    return replacement
+                                }
+                                return e.raw
+                            })
+                            .join("")
+                    }),
+                })
+            }
+
+            return {
+                onAlternativeEnter(alternative) {
+                    const { elements } = alternative
+                    for (let i = 1; i < elements.length; i++) {
+                        const a = elements[i - 1]
+                        const b = elements[i]
+
+                        if (
+                            isCharElement(a) &&
+                            isCharLookaround(b) &&
+                            b.kind === "lookbehind"
+                        ) {
+                            tryApply(a, b, alternative)
+                        }
+                        if (
+                            isCharLookaround(a) &&
+                            a.kind === "lookahead" &&
+                            isCharElement(b)
+                        ) {
+                            tryApply(b, a, alternative)
+                        }
+                    }
+                },
+            }
+        }
+
+        return defineRegexpVisitor(context, {
+            createVisitor,
+        })
+    },
+})
diff --git a/lib/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/package.json b/package.json
index 133c91a..849f1b4 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
     "name": "eslint-plugin-regexp",
-    "version": "2.0.0-next.7",
+    "version": "2.0.0-next.8",
     "description": "ESLint plugin for finding RegExp mistakes and RegExp style guide violations.",
     "engines": {
         "node": "^18 || >=20"


Test patch:
diff --git a/tests/lib/rules/prefer-set-operation.ts b/tests/lib/rules/prefer-set-operation.ts
new file mode 100644
index 0000000..f88503f
--- /dev/null
+++ b/tests/lib/rules/prefer-set-operation.ts
@@ -0,0 +1,42 @@
+import { RuleTester } from "eslint"
+import rule from "../../../lib/rules/prefer-set-operation"
+
+const tester = new RuleTester({
+    parserOptions: {
+        ecmaVersion: "latest",
+        sourceType: "module",
+    },
+})
+
+tester.run("prefer-set-operation", rule as any, {
+    valid: [
+        String.raw`/a\b/`,
+        String.raw`/a\b/u`,
+        String.raw`/a\b/v`,
+        String.raw`/(?!a)\w/`,
+        String.raw`/(?!a)\w/u`,
+    ],
+    invalid: [
+        {
+            code: String.raw`/(?!a)\w/v`,
+            output: String.raw`/[\w--a]/v`,
+            errors: [
+                "This lookaround can be combined with '\\w' using a set operation.",
+            ],
+        },
+        {
+            code: String.raw`/\w(?<=\d)/v`,
+            output: String.raw`/[\w&&\d]/v`,
+            errors: [
+                "This lookaround can be combined with '\\w' using a set operation.",
+            ],
+        },
+        {
+            code: String.raw`/(?!-)&/v`,
+            output: String.raw`/[\&--\-]/v`,
+            errors: [
+                "This lookaround can be combined with '&' using a set operation.",
+            ],
+        },
+    ],
+})