diff --git a/.changeset/spotty-phones-deliver.md b/.changeset/spotty-phones-deliver.md
new file mode 100644
index 000000000..e068beb48
--- /dev/null
+++ b/.changeset/spotty-phones-deliver.md
@@ -0,0 +1,5 @@
+---
+"eslint-plugin-regexp": major
+---
+
+Add `regexp/prefer-set-operation` rule
diff --git a/README.md b/README.md
index 561ec723e..2d8398eb3 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 31e251aac..dc8f59f83 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 000000000..d8e65b19d
--- /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).
+
+
+
+> 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`.
+
+
+
+```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
+```
+
+
+
+## :wrench: Options
+
+Nothing.
+
+## :rocket: Version
+
+:exclamation: ***This rule has not been released yet.***
+
+## :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 3d0979ee0..d11220902 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 000000000..86cdf0053
--- /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 0d44409c9..e69b79c44 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/tests/lib/rules/prefer-set-operation.ts b/tests/lib/rules/prefer-set-operation.ts
new file mode 100644
index 000000000..f88503f76
--- /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.",
+ ],
+ },
+ ],
+})