Repository: ota-meshi/eslint-plugin-regexp
Pull request #329 (2021-09-18 03:50:23)

Add `regexp/no-empty-character-class` rule
Related to #325

RunDevelopment: I was wondering whether it might be useful to [evaluate](https://rundevelopment.github.io/regexp-ast-analysis/docs/latest/modules.html#matchesnocharacters) the character class? 

This does depart from what ESLint does but it might be useful I think. I imagine this rule as an opposite to `match-any`.
ota-meshi: That's good idea. Do you think we need to change the rule name?
RunDevelopment: You mean into e.g. `match-none`? I don't think so. While the rule does deviate from ESLint's implementation a little, the intent is still the same. We just check more thoroughly.
ota-meshi: Thank you for your opinion. I have updated this rule.
ota-meshi: Thank you for the review! I have changed this PR.

Patch:
diff --git a/README.md b/README.md
index 09f06f0..92214cf 100644
--- a/README.md
+++ b/README.md
@@ -108,6 +108,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
 | [regexp/no-dupe-disjunctions](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-dupe-disjunctions.html) | disallow duplicate disjunctions | :star: |
 | [regexp/no-empty-alternative](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-empty-alternative.html) | disallow alternatives without elements | :star: |
 | [regexp/no-empty-capturing-group](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-empty-capturing-group.html) | disallow capturing group that captures empty. | :star: |
+| [regexp/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 |  |
 | [regexp/no-empty-group](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-empty-group.html) | disallow empty group | :star: |
 | [regexp/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 | :star: |
 | [regexp/no-escape-backspace](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-escape-backspace.html) | disallow escape backspace (`[\b]`) | :star: |
@@ -167,6 +168,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
 | [regexp/no-useless-non-capturing-group](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-useless-non-capturing-group.html) | disallow unnecessary Non-capturing group | :star::wrench: |
 | [regexp/prefer-character-class](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-character-class.html) | enforce using character class | :star::wrench: |
 | [regexp/prefer-d](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-d.html) | enforce using `\d` | :star::wrench: |
+| [regexp/prefer-lookaround](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-lookaround.html) | prefer lookarounds over capturing group that do not replace | :wrench: |
 | [regexp/prefer-named-backreference](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-named-backreference.html) | enforce using named backreferences | :wrench: |
 | [regexp/prefer-plus-quantifier](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-plus-quantifier.html) | enforce using `+` quantifier | :star::wrench: |
 | [regexp/prefer-question-quantifier](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-question-quantifier.html) | enforce using `?` quantifier | :star::wrench: |
diff --git a/docs/rules/README.md b/docs/rules/README.md
index 64d1897..2f1937f 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -17,6 +17,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
 | [regexp/no-dupe-disjunctions](./no-dupe-disjunctions.md) | disallow duplicate disjunctions | :star: |
 | [regexp/no-empty-alternative](./no-empty-alternative.md) | disallow alternatives without elements | :star: |
 | [regexp/no-empty-capturing-group](./no-empty-capturing-group.md) | disallow capturing group that captures empty. | :star: |
+| [regexp/no-empty-character-class](./no-empty-character-class.md) | disallow character classes that match no characters |  |
 | [regexp/no-empty-group](./no-empty-group.md) | disallow empty group | :star: |
 | [regexp/no-empty-lookarounds-assertion](./no-empty-lookarounds-assertion.md) | disallow empty lookahead assertion or empty lookbehind assertion | :star: |
 | [regexp/no-escape-backspace](./no-escape-backspace.md) | disallow escape backspace (`[\b]`) | :star: |
@@ -76,6 +77,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
 | [regexp/no-useless-non-capturing-group](./no-useless-non-capturing-group.md) | disallow unnecessary Non-capturing group | :star::wrench: |
 | [regexp/prefer-character-class](./prefer-character-class.md) | enforce using character class | :star::wrench: |
 | [regexp/prefer-d](./prefer-d.md) | enforce using `\d` | :star::wrench: |
+| [regexp/prefer-lookaround](./prefer-lookaround.md) | prefer lookarounds over capturing group that do not replace | :wrench: |
 | [regexp/prefer-named-backreference](./prefer-named-backreference.md) | enforce using named backreferences | :wrench: |
 | [regexp/prefer-plus-quantifier](./prefer-plus-quantifier.md) | enforce using `+` quantifier | :star::wrench: |
 | [regexp/prefer-question-quantifier](./prefer-question-quantifier.md) | enforce using `?` quantifier | :star::wrench: |
diff --git a/docs/rules/no-empty-character-class.md b/docs/rules/no-empty-character-class.md
new file mode 100644
index 0000000..417985c
--- /dev/null
+++ b/docs/rules/no-empty-character-class.md
@@ -0,0 +1,52 @@
+---
+pageClass: "rule-details"
+sidebarDepth: 0
+title: "regexp/no-empty-character-class"
+description: "disallow character classes that match no characters"
+---
+# regexp/no-empty-character-class
+
+> disallow character classes that match no characters
+
+- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
+
+## :book: Rule Details
+
+This rule reports character classes that cannot match any characters.
+
+Character classes that cannot match any characters are either empty or negated character classes of elements that contain all characters.
+
+The reports for this rule include reports for the ESLint core [no-empty-character-class] rule. That is, if you use this rule, you can turn off the ESLint core [no-empty-character-class] rule.
+
+<eslint-code-block>
+
+```js
+/* eslint regexp/no-empty-character-class: "error" */
+
+/* ✓ GOOD */
+var foo = /abc[d]/;
+var foo = /abc[a-z]/;
+var foo = /[^]/;
+var foo = /[\s\S]/;
+
+/* ✗ BAD */
+var foo = /abc[]/;
+var foo = /[^\s\S]/;
+```
+
+</eslint-code-block>
+
+## :wrench: Options
+
+Nothing.
+
+## :books: Further reading
+
+- [no-empty-character-class]
+
+[no-empty-character-class]: https://eslint.org/docs/rules/no-empty-character-class
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/lib/rules/no-empty-character-class.ts)
+- [Test source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/tests/lib/rules/no-empty-character-class.ts)
diff --git a/docs/rules/prefer-lookaround.md b/docs/rules/prefer-lookaround.md
new file mode 100644
index 0000000..304135c
--- /dev/null
+++ b/docs/rules/prefer-lookaround.md
@@ -0,0 +1,48 @@
+---
+pageClass: "rule-details"
+sidebarDepth: 0
+title: "regexp/prefer-lookaround"
+description: "prefer lookarounds over capturing group that do not replace"
+---
+# regexp/prefer-lookaround
+
+> prefer lookarounds over capturing group that do not replace
+
+- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
+- :wrench: The `--fix` option on the [command line](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems) can automatically fix some of the problems reported by this rule.
+
+## :book: Rule Details
+
+This rule reports string replacement using capturing groups that can be replaced with lookaround assertions.
+
+<eslint-code-block fix>
+
+```js
+/* eslint regexp/prefer-lookaround: "error" */
+
+/* ✓ GOOD */
+var str = 'JavaScript'.replace(/Java(?=Script)/g, 'Type')
+
+/* ✗ BAD */
+var str = 'JavaScript'.replace(/Java(Script)/g, 'Type$1')
+```
+
+</eslint-code-block>
+
+## :wrench: Options
+
+```json
+{
+  "regexp/prefer-lookaround": ["error", {
+      "strictTypes": true
+  }]
+}
+```
+
+- `strictTypes` ... If `true`, strictly check the type of object to determine if the regex instance was used in `replace()` and `replaceAll()`. Default is `true`.  
+  This option is always on when using TypeScript.
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/lib/rules/prefer-lookaround.ts)
+- [Test source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/tests/lib/rules/prefer-lookaround.ts)
diff --git a/lib/configs/recommended.ts b/lib/configs/recommended.ts
index ab19b6d..ba8c0df 100644
--- a/lib/configs/recommended.ts
+++ b/lib/configs/recommended.ts
@@ -10,6 +10,8 @@ export = {
         // The ESLint rule will report fewer cases than our rule
         "no-invalid-regexp": "off",
         "no-useless-backreference": "off",
+        // TODO Switch in the major version.
+        // "no-empty-character-class": "off",
 
         // eslint-plugin-regexp rules
         "regexp/confusing-quantifier": "warn",
diff --git a/lib/rules/no-empty-character-class.ts b/lib/rules/no-empty-character-class.ts
new file mode 100644
index 0000000..d4216a8
--- /dev/null
+++ b/lib/rules/no-empty-character-class.ts
@@ -0,0 +1,51 @@
+import { matchesNoCharacters } from "regexp-ast-analysis"
+import type { RegExpVisitor } from "regexpp/visitor"
+import type { RegExpContext } from "../utils"
+import { createRule, defineRegexpVisitor } from "../utils"
+
+export default createRule("no-empty-character-class", {
+    meta: {
+        docs: {
+            description: "disallow character classes that match no characters",
+            category: "Possible Errors",
+            // TODO Switch to recommended in the major version.
+            // recommended: true,
+            recommended: false,
+        },
+        schema: [],
+        messages: {
+            empty:
+                "This character class matches no characters because it is empty.",
+            cannotMatchAny: "This character class cannot match any characters.",
+        },
+        type: "suggestion", // "problem",
+    },
+    create(context) {
+        /**
+         * Create visitor
+         */
+        function createVisitor(
+            regexpContext: RegExpContext,
+        ): RegExpVisitor.Handlers {
+            const { node, getRegexpLocation, flags } = regexpContext
+
+            return {
+                onCharacterClassEnter(ccNode) {
+                    if (matchesNoCharacters(ccNode, flags)) {
+                        context.report({
+                            node,
+                            loc: getRegexpLocation(ccNode),
+                            messageId: ccNode.elements.length
+                                ? "cannotMatchAny"
+                                : "empty",
+                        })
+                    }
+                },
+            }
+        }
+
+        return defineRegexpVisitor(context, {
+            createVisitor,
+        })
+    },
+})
diff --git a/lib/rules/prefer-lookaround.ts b/lib/rules/prefer-lookaround.ts
new file mode 100644
index 0000000..3c9f762
--- /dev/null
+++ b/lib/rules/prefer-lookaround.ts
@@ -0,0 +1,675 @@
+import type { RegExpVisitor } from "regexpp/visitor"
+import type { CapturingGroup, Element } from "regexpp/ast"
+import type { RegExpContext } from "../utils"
+import { createRule, defineRegexpVisitor } from "../utils"
+import { createTypeTracker } from "../utils/type-tracker"
+import type { KnownMethodCall, ReferenceElement } from "../utils/ast-utils"
+import {
+    getParent,
+    parseReplacements,
+    getStaticValue,
+    extractExpressionReferences,
+    isKnownMethodCall,
+} from "../utils/ast-utils"
+import type { PatternReplaceRange } from "../utils/ast-utils/pattern-source"
+import type { Expression, Literal } from "estree"
+import type { Rule } from "eslint"
+import { mention } from "../utils/mention"
+import {
+    getFirstConsumedCharPlusAfter,
+    getPossiblyConsumedChar,
+} from "../utils/regexp-ast"
+import {
+    getLengthRange,
+    isZeroLength,
+    FirstConsumedChars,
+} from "regexp-ast-analysis"
+import type { CharSet } from "refa"
+
+type ReplaceReference = { ref: string | number; range?: [number, number] }
+type ReplaceReferences = {
+    // Reference at the starting position.
+    // e.g.
+    // '$1str$2' -> { ref: 1, range: [0,2] }
+    // '$<foo>str$<bar>' -> { ref: 'foo', range: [0,6] }
+    // 'str$1' -> null
+    // 'str$1str$2' -> null
+    startRef: ReplaceReference | null
+    // Reference at the ending position.
+    // e.g.
+    // '$1str$2' -> { ref: 2, range: [5,7] }
+    // '$<foo>str$<bar>' -> { ref: 'bar', range: [9,15] }
+    // '$1str' -> null
+    // '$1str$2str' -> null
+    endRef: ReplaceReference | null
+    // All references including the above.
+    allRefs: ReplaceReference[]
+}
+
+/**
+ * Holds all replacement reference data.
+ *
+ * If the same RegExp instance is used for replacement in 2 places, the number of data in `list` is 2.
+ */
+class ReplaceReferencesList {
+    private readonly list: ReplaceReferences[]
+
+    /** Reference name at the starting position. */
+    public readonly startRefName?: string | number
+
+    /** Reference name at the ending position. */
+    public readonly endRefName?: string | number
+
+    /** All reference names except at the starting position. */
+    public readonly otherThanStartRefNames: Set<string | number>
+
+    /** All reference names except at the starting position. */
+    public readonly otherThanEndRefNames: Set<string | number>
+
+    public constructor(list: ReplaceReferences[]) {
+        this.list = list
+        this.startRefName = list[0].startRef?.ref
+        this.endRefName = list[0].endRef?.ref
+
+        const otherThanStartRefNames = new Set<string | number>()
+        const otherThanEndRefNames = new Set<string | number>()
+        for (const { startRef, endRef, allRefs } of this.list) {
+            for (const ref of allRefs) {
+                if (ref !== startRef) {
+                    otherThanStartRefNames.add(ref.ref)
+                }
+                if (ref !== endRef) {
+                    otherThanEndRefNames.add(ref.ref)
+                }
+            }
+        }
+        this.otherThanStartRefNames = otherThanStartRefNames
+        this.otherThanEndRefNames = otherThanEndRefNames
+    }
+
+    public *[Symbol.iterator](): Iterator<ReplaceReferences> {
+        yield* this.list
+    }
+}
+
+const enum SideEffect {
+    startRef,
+    endRef,
+}
+
+/**
+ * Gets the type of side effect when replacing the capture group for the given element.
+ *
+ * There are no side effects if the following conditions are met:
+ *
+ * - Some elements other than the start capturing group have disjoints to the start capturing group.
+ * - The last element and the start consume character have disjoint.
+ */
+function getSideEffectsWhenReplacingCapturingGroup(
+    elements: readonly Element[],
+    start: CapturingGroup | undefined,
+    end: CapturingGroup | undefined,
+    { flags }: RegExpContext,
+): Set<SideEffect> {
+    const result = new Set<SideEffect>()
+
+    if (start) {
+        const { char } = getPossiblyConsumedChar(start, flags)
+        if (!hasDisjoint(char, elements.slice(1))) {
+            result.add(SideEffect.startRef)
+        } else {
+            const last = elements[elements.length - 1]
+            const lastChar = FirstConsumedChars.toLook(
+                getFirstConsumedCharPlusAfter(last, "rtl", flags),
+            )
+            if (!lastChar.char.isDisjointWith(char)) {
+                result.add(SideEffect.startRef)
+            }
+        }
+    }
+
+    if (end && flags.global) {
+        const first = elements[0]
+        if (first) {
+            const { char } = getPossiblyConsumedChar(end, flags)
+
+            const firstChar = FirstConsumedChars.toLook(
+                getFirstConsumedCharPlusAfter(first, "ltr", flags),
+            )
+            if (!firstChar.char.isDisjointWith(char)) {
+                result.add(SideEffect.endRef)
+            }
+        }
+    }
+
+    return result
+
+    /** Checks whether the given target element has disjoint in elements.  */
+    function hasDisjoint(target: CharSet, targetElements: Element[]) {
+        for (const element of targetElements) {
+            if (isConstantLength(element)) {
+                const elementChars = getPossiblyConsumedChar(element, flags)
+                if (elementChars.char.isEmpty) {
+                    continue
+                }
+                if (elementChars.char.isDisjointWith(target)) {
+                    return true
+                }
+            } else {
+                const elementLook = FirstConsumedChars.toLook(
+                    getFirstConsumedCharPlusAfter(element, "ltr", flags),
+                )
+                return elementLook.char.isDisjointWith(target)
+            }
+        }
+        return false
+    }
+
+    /** Checks whether the given element is constant length. */
+    function isConstantLength(target: Element): boolean {
+        const range = getLengthRange(target)
+        return Boolean(range && range.min === range.max)
+    }
+}
+
+/**
+ * Parse option
+ */
+function parseOption(
+    userOption:
+        | {
+              strictTypes?: boolean
+          }
+        | undefined,
+) {
+    let strictTypes = true
+    if (userOption) {
+        if (userOption.strictTypes != null) {
+            strictTypes = userOption.strictTypes
+        }
+    }
+
+    return {
+        strictTypes,
+    }
+}
+
+export default createRule("prefer-lookaround", {
+    meta: {
+        docs: {
+            description:
+                "prefer lookarounds over capturing group that do not replace",
+            category: "Stylistic Issues",
+            recommended: false,
+        },
+        fixable: "code",
+        schema: [
+            {
+                type: "object",
+                properties: {
+                    strictTypes: { type: "boolean" },
+                },
+                additionalProperties: false,
+            },
+        ],
+        messages: {
+            preferLookarounds:
+                "These capturing groups can be replaced with lookaround assertions ({{expr1}} and {{expr2}}).",
+            prefer:
+                "This capturing group can be replaced with a {{kind}} ({{expr}}).",
+        },
+        type: "suggestion",
+    },
+    create(context) {
+        const { strictTypes } = parseOption(context.options[0])
+        const typeTracer = createTypeTracker(context)
+
+        /**
+         * Create visitor
+         */
+        function createVisitor(
+            regexpContext: RegExpContext,
+        ): RegExpVisitor.Handlers {
+            const { regexpNode, patternAst } = regexpContext
+            if (
+                patternAst.alternatives.length > 1 ||
+                patternAst.alternatives[0].elements.length < 2
+            ) {
+                return {}
+            }
+            const replaceReferenceList: ReplaceReferences[] = []
+            for (const ref of extractExpressionReferences(
+                regexpNode,
+                context,
+            )) {
+                if (ref.type === "argument") {
+                    if (
+                        !isKnownMethodCall(ref.callExpression, {
+                            replace: 2,
+                            replaceAll: 2,
+                        })
+                    ) {
+                        // Calls other than replace.
+                        return {}
+                    }
+                    const replaceReference = getReplaceReferenceFromCallExpression(
+                        ref.callExpression,
+                    )
+                    if (!replaceReference) {
+                        // Unknown call or replacement where lookarounds cannot be used.
+                        return {}
+                    }
+                    replaceReferenceList.push(replaceReference)
+                } else if (ref.type === "member") {
+                    const parent = getParent(ref.memberExpression)
+                    if (
+                        parent?.type === "CallExpression" &&
+                        isKnownMethodCall(parent, {
+                            test: 1,
+                        }) &&
+                        !regexpContext.flags.global
+                    ) {
+                        // Using it in the `test` method has no effect on rewriting the regex.
+                        continue
+                    }
+                    // Cannot trace.
+                    return {}
+                } else {
+                    // Cannot trace.
+                    return {}
+                }
+            }
+            if (!replaceReferenceList.length) {
+                // No pattern is used.
+                return {}
+            }
+            const replaceReference = replaceReferenceList[0]
+            if (
+                replaceReferenceList.some(
+                    (target) =>
+                        target.startRef?.ref !==
+                            replaceReference.startRef?.ref ||
+                        target.endRef?.ref !== replaceReference.endRef?.ref,
+                )
+            ) {
+                // Lookaround cannot be used as it is used in various replacements.
+                return {}
+            }
+            return createVerifyVisitor(
+                regexpContext,
+                new ReplaceReferencesList(replaceReferenceList),
+            )
+        }
+
+        /**
+         * Get the replace reference info from given call expression
+         */
+        function getReplaceReferenceFromCallExpression(
+            node: KnownMethodCall,
+        ): ReplaceReferences | null {
+            if (
+                strictTypes
+                    ? !typeTracer.isString(node.callee.object)
+                    : !typeTracer.maybeString(node.callee.object)
+            ) {
+                // The callee object is not a string.
+                return null
+            }
+
+            const replacementNode = node.arguments[1]
+            if (replacementNode.type === "Literal") {
+                return getReplaceReferenceFromLiteralReplacementArgument(
+                    replacementNode,
+                )
+            }
+            return getReplaceReferenceFromNonLiteralReplacementArgument(
+                replacementNode,
+            )
+        }
+
+        /**
+         * Get the replace reference info from given literal replacement argument
+         */
+        function getReplaceReferenceFromLiteralReplacementArgument(
+            node: Literal,
+        ): ReplaceReferences | null {
+            if (typeof node.value !== "string") {
+                return null
+            }
+            const replacements = parseReplacements(context, node)
+
+            let startRef: ReplaceReference | null = null
+            let endRef: ReplaceReference | null = null
+            const start = replacements[0]
+            if (start?.type === "ReferenceElement") {
+                startRef = start
+            }
+            const end = replacements[replacements.length - 1]
+            if (end?.type === "ReferenceElement") {
+                endRef = end
+            }
+            if (!startRef && !endRef) {
+                // Not using a capturing group at start or end.
+                return null
+            }
+            return {
+                startRef,
+                endRef,
+                allRefs: replacements.filter(
+                    (e): e is ReferenceElement => e.type === "ReferenceElement",
+                ),
+            }
+        }
+
+        /**
+         * Get the replace reference info from given non-literal replacement argument
+         */
+        function getReplaceReferenceFromNonLiteralReplacementArgument(
+            node: Expression,
+        ): ReplaceReferences | null {
+            const evaluated = getStaticValue(context, node)
+            if (!evaluated || typeof evaluated.value !== "string") {
+                // The replacement string cannot be determined.
+                return null
+            }
+            const refRegex = /\$([1-9]\d*|<([^>]+)>)/gu
+
+            const allRefs: ReplaceReference[] = []
+            let startRef: ReplaceReference | null = null
+            let endRef: ReplaceReference | null = null
+            let re
+            while ((re = refRegex.exec(evaluated.value))) {
+                const ref = { ref: re[2] ? re[2] : Number(re[1]) }
+                if (re.index === 0) {
+                    startRef = ref
+                }
+                if (refRegex.lastIndex === evaluated.value.length) {
+                    endRef = ref
+                }
+                allRefs.push(ref)
+            }
+            if (!startRef && !endRef) {
+                // Not using a capturing group at start or end.
+                return null
+            }
+            return {
+                startRef,
+                endRef,
+                allRefs,
+            }
+        }
+
+        /**
+         * Create visitor for verify capturing groups
+         */
+        function createVerifyVisitor(
+            regexpContext: RegExpContext,
+            replaceReferenceList: ReplaceReferencesList,
+        ): RegExpVisitor.Handlers {
+            type RefState = {
+                capturingGroups: CapturingGroup[]
+                isUseOther?: boolean
+                capturingNum: number
+            }
+            const startRefState: RefState = {
+                capturingGroups: [],
+                capturingNum: -1,
+            }
+            const endRefState: RefState = {
+                capturingGroups: [],
+                capturingNum: -1,
+            }
+
+            let refNum = 0
+            return {
+                onCapturingGroupEnter(cgNode) {
+                    refNum++
+                    processForState(
+                        replaceReferenceList.startRefName,
+                        replaceReferenceList.otherThanStartRefNames,
+                        startRefState,
+                    )
+                    processForState(
+                        replaceReferenceList.endRefName,
+                        replaceReferenceList.otherThanEndRefNames,
+                        endRefState,
+                    )
+
+                    /** Process state */
+                    function processForState(
+                        refName: string | number | undefined,
+                        otherThanRefNames: Set<string | number>,
+                        state: RefState,
+                    ) {
+                        if (refName === refNum || refName === cgNode.name) {
+                            state.capturingGroups.push(cgNode)
+                            state.capturingNum = refNum
+                            // Flags the capturing group referenced in `refName` if it is also referenced elsewhere.
+                            state.isUseOther ||= Boolean(
+                                otherThanRefNames.has(refNum) ||
+                                    (cgNode.name &&
+                                        otherThanRefNames.has(cgNode.name)),
+                            )
+                        }
+                    }
+                },
+                onPatternLeave(pNode) {
+                    // verify
+                    const alt = pNode.alternatives[0]
+                    let reportStart = null
+                    if (
+                        !startRefState.isUseOther &&
+                        startRefState.capturingGroups.length === 1 && // It will not be referenced from more than one, but check it just in case.
+                        startRefState.capturingGroups[0] === alt.elements[0] &&
+                        !isZeroLength(startRefState.capturingGroups[0])
+                    ) {
+                        const capturingGroup = startRefState.capturingGroups[0]
+                        reportStart = {
+                            capturingGroup,
+                            expr: `(?<=${capturingGroup.alternatives
+                                .map((a) => a.raw)
+                                .join("|")})`,
+                        }
+                    }
+                    let reportEnd = null
+                    if (
+                        !endRefState.isUseOther &&
+                        endRefState.capturingGroups.length === 1 && // It will not be referenced from more than one, but check it just in case.
+                        endRefState.capturingGroups[0] ===
+                            alt.elements[alt.elements.length - 1] &&
+                        !isZeroLength(endRefState.capturingGroups[0])
+                    ) {
+                        const capturingGroup = endRefState.capturingGroups[0]
+                        reportEnd = {
+                            capturingGroup,
+                            expr: `(?=${capturingGroup.alternatives
+                                .map((a) => a.raw)
+                                .join("|")})`,
+                        }
+                    }
+                    const sideEffects = getSideEffectsWhenReplacingCapturingGroup(
+                        alt.elements,
+                        reportStart?.capturingGroup,
+                        reportEnd?.capturingGroup,
+                        regexpContext,
+                    )
+                    if (sideEffects.has(SideEffect.startRef)) {
+                        reportStart = null
+                    }
+                    if (sideEffects.has(SideEffect.endRef)) {
+                        reportEnd = null
+                    }
+
+                    if (reportStart && reportEnd) {
+                        const fix = buildFixer(
+                            regexpContext,
+                            [reportStart, reportEnd],
+                            replaceReferenceList,
+                            (target) => {
+                                if (
+                                    target.allRefs.some(
+                                        (ref) =>
+                                            ref !== target.startRef &&
+                                            ref !== target.endRef,
+                                    )
+                                ) {
+                                    // If the capturing group is used for something other than the replacement refs, it cannot be fixed.
+                                    return null
+                                }
+                                return [
+                                    target.startRef?.range,
+                                    target.endRef?.range,
+                                ]
+                            },
+                        )
+                        for (const report of [reportStart, reportEnd]) {
+                            context.report({
+                                loc: regexpContext.getRegexpLocation(
+                                    report.capturingGroup,
+                                ),
+                                messageId: "preferLookarounds",
+                                data: {
+                                    expr1: mention(reportStart.expr),
+                                    expr2: mention(reportEnd.expr),
+                                },
+                                fix,
+                            })
+                        }
+                    } else if (reportStart) {
+                        const fix = buildFixer(
+                            regexpContext,
+                            [reportStart],
+                            replaceReferenceList,
+                            (target) => {
+                                if (
+                                    target.allRefs.some(
+                                        (ref) => ref !== target.startRef,
+                                    )
+                                ) {
+                                    // If the capturing group is used for something other than the replacement refs, it cannot be fixed.
+                                    return null
+                                }
+                                return [target.startRef?.range]
+                            },
+                        )
+                        context.report({
+                            loc: regexpContext.getRegexpLocation(
+                                reportStart.capturingGroup,
+                            ),
+                            messageId: "prefer",
+                            data: {
+                                kind: "lookbehind assertion",
+                                expr: mention(reportStart.expr),
+                            },
+                            fix,
+                        })
+                    } else if (reportEnd) {
+                        const fix = buildFixer(
+                            regexpContext,
+                            [reportEnd],
+                            replaceReferenceList,
+                            (target) => {
+                                if (
+                                    target.allRefs.some((ref) => {
+                                        if (
+                                            ref === target.endRef ||
+                                            typeof ref.ref !== "number"
+                                        ) {
+                                            return false
+                                        }
+                                        return (
+                                            endRefState.capturingNum <= ref.ref
+                                        )
+                                    })
+                                ) {
+                                    // If the capturing group with a large num is used, it cannot be fixed.
+                                    return null
+                                }
+                                return [target.endRef?.range]
+                            },
+                        )
+                        context.report({
+                            loc: regexpContext.getRegexpLocation(
+                                reportEnd.capturingGroup,
+                            ),
+                            messageId: "prefer",
+                            data: {
+                                kind: "lookahead assertion",
+                                expr: mention(reportEnd.expr),
+                            },
+                            fix,
+                        })
+                    }
+                },
+            }
+        }
+
+        /**
+         * Build fixer function
+         */
+        function buildFixer(
+            regexpContext: RegExpContext,
+            replaceCapturingGroups: {
+                capturingGroup: CapturingGroup
+                expr: string
+            }[],
+            replaceReferenceList: ReplaceReferencesList,
+            getRemoveRanges: (
+                replaceReference: ReplaceReferences,
+            ) => Iterable<[number, number] | undefined> | null,
+        ): ((fixer: Rule.RuleFixer) => Rule.Fix[]) | null {
+            const removeRanges: [number, number][] = []
+            for (const replaceReference of replaceReferenceList) {
+                const targetRemoveRanges = getRemoveRanges(replaceReference)
+                if (!targetRemoveRanges) {
+                    return null
+                }
+                for (const range of targetRemoveRanges) {
+                    if (!range) {
+                        return null
+                    }
+                    removeRanges.push(range)
+                }
+            }
+            const replaces: {
+                replaceRange: PatternReplaceRange
+                expr: string
+            }[] = []
+            for (const { capturingGroup, expr } of replaceCapturingGroups) {
+                const replaceRange = regexpContext.patternSource.getReplaceRange(
+                    capturingGroup,
+                )
+                if (!replaceRange) {
+                    return null
+                }
+                replaces.push({
+                    replaceRange,
+                    expr,
+                })
+            }
+
+            return (fixer) => {
+                const list: { offset: number; fix: () => Rule.Fix }[] = []
+                for (const removeRange of removeRanges) {
+                    list.push({
+                        offset: removeRange[0],
+                        fix: () => fixer.removeRange(removeRange),
+                    })
+                }
+                for (const { replaceRange, expr } of replaces) {
+                    list.push({
+                        offset: replaceRange.range[0],
+                        fix: () => replaceRange.replace(fixer, expr),
+                    })
+                }
+                return list
+                    .sort((a, b) => a.offset - b.offset)
+                    .map((item) => item.fix())
+            }
+        }
+
+        return defineRegexpVisitor(context, {
+            createVisitor,
+        })
+    },
+})
diff --git a/lib/utils/regexp-ast/alternative-prefix.ts b/lib/utils/regexp-ast/alternative-prefix.ts
index 053dbea..0a0b28d 100644
--- a/lib/utils/regexp-ast/alternative-prefix.ts
+++ b/lib/utils/regexp-ast/alternative-prefix.ts
@@ -1,13 +1,8 @@
 import type { CharSet } from "refa"
-import type {
-    FirstConsumedChar,
-    MatchingDirection,
-    ReadonlyFlags,
-} from "regexp-ast-analysis"
+import type { MatchingDirection, ReadonlyFlags } from "regexp-ast-analysis"
 import {
     getFirstCharAfter,
     toCharSet,
-    getFirstConsumedChar,
     getFirstConsumedCharAfter,
     FirstConsumedChars,
     isZeroLength,
@@ -21,6 +16,7 @@ import type {
     Group,
     Quantifier,
 } from "regexpp/ast"
+import { getFirstConsumedCharPlusAfter } from "./common"
 
 const ltrCache = new WeakMap<Alternative, readonly CharSet[]>()
 const rtlCache = new WeakMap<Alternative, readonly CharSet[]>()
@@ -239,30 +235,3 @@ function getQuantifierPrefix(
     chars.push(look.char)
     return { chars, complete: false }
 }
-
-/**
- * This operations is equal to:
- *
- * ```
- * concat(
- *     getFirstConsumedChar(element, direction, flags),
- *     getFirstConsumedCharAfter(element, direction, flags),
- * )
- * ```
- */
-function getFirstConsumedCharPlusAfter(
-    element: Element | Alternative,
-    direction: MatchingDirection,
-    flags: ReadonlyFlags,
-): FirstConsumedChar {
-    const consumed = getFirstConsumedChar(element, direction, flags)
-
-    if (!consumed.empty) {
-        return consumed
-    }
-
-    return FirstConsumedChars.concat(
-        [consumed, getFirstConsumedCharAfter(element, direction, flags)],
-        flags,
-    )
-}
diff --git a/lib/utils/regexp-ast/common.ts b/lib/utils/regexp-ast/common.ts
index 4585ee9..2598c66 100644
--- a/lib/utils/regexp-ast/common.ts
+++ b/lib/utils/regexp-ast/common.ts
@@ -1,2 +1,39 @@
-import type { Node } from "regexpp/ast"
+import type {
+    FirstConsumedChar,
+    MatchingDirection,
+    ReadonlyFlags,
+} from "regexp-ast-analysis"
+import {
+    FirstConsumedChars,
+    getFirstConsumedChar,
+    getFirstConsumedCharAfter,
+} from "regexp-ast-analysis"
+import type { Alternative, Element, Node } from "regexpp/ast"
 export type ShortCircuit = (aNode: Node, bNode: Node) => boolean | null
+
+/**
+ * This operations is equal to:
+ *
+ * ```
+ * concat(
+ *     getFirstConsumedChar(element, direction, flags),
+ *     getFirstConsumedCharAfter(element, direction, flags),
+ * )
+ * ```
+ */
+export function getFirstConsumedCharPlusAfter(
+    element: Element | Alternative,
+    direction: MatchingDirection,
+    flags: ReadonlyFlags,
+): FirstConsumedChar {
+    const consumed = getFirstConsumedChar(element, direction, flags)
+
+    if (!consumed.empty) {
+        return consumed
+    }
+
+    return FirstConsumedChars.concat(
+        [consumed, getFirstConsumedCharAfter(element, direction, flags)],
+        flags,
+    )
+}
diff --git a/lib/utils/regexp-ast/index.ts b/lib/utils/regexp-ast/index.ts
index c156bdd..66be9bd 100644
--- a/lib/utils/regexp-ast/index.ts
+++ b/lib/utils/regexp-ast/index.ts
@@ -11,7 +11,7 @@ import {
     isEmptyBackreference,
     toCharSet,
 } from "regexp-ast-analysis"
-export { ShortCircuit } from "./common"
+export { ShortCircuit, getFirstConsumedCharPlusAfter } from "./common"
 export * from "./is-covered"
 export * from "./is-equals"
 
diff --git a/lib/utils/rules.ts b/lib/utils/rules.ts
index f332a51..b0458a9 100644
--- a/lib/utils/rules.ts
+++ b/lib/utils/rules.ts
@@ -11,6 +11,7 @@ import noDupeCharactersCharacterClass from "../rules/no-dupe-characters-characte
 import noDupeDisjunctions from "../rules/no-dupe-disjunctions"
 import noEmptyAlternative from "../rules/no-empty-alternative"
 import noEmptyCapturingGroup from "../rules/no-empty-capturing-group"
+import noEmptyCharacterClass from "../rules/no-empty-character-class"
 import noEmptyGroup from "../rules/no-empty-group"
 import noEmptyLookaroundsAssertion from "../rules/no-empty-lookarounds-assertion"
 import noEscapeBackspace from "../rules/no-escape-backspace"
@@ -49,6 +50,7 @@ import orderInCharacterClass from "../rules/order-in-character-class"
 import preferCharacterClass from "../rules/prefer-character-class"
 import preferD from "../rules/prefer-d"
 import preferEscapeReplacementDollarChar from "../rules/prefer-escape-replacement-dollar-char"
+import preferLookaround from "../rules/prefer-lookaround"
 import preferNamedBackreference from "../rules/prefer-named-backreference"
 import preferPlusQuantifier from "../rules/prefer-plus-quantifier"
 import preferPredefinedAssertion from "../rules/prefer-predefined-assertion"
@@ -80,6 +82,7 @@ export const rules = [
     noDupeDisjunctions,
     noEmptyAlternative,
     noEmptyCapturingGroup,
+    noEmptyCharacterClass,
     noEmptyGroup,
     noEmptyLookaroundsAssertion,
     noEscapeBackspace,
@@ -118,6 +121,7 @@ export const rules = [
     preferCharacterClass,
     preferD,
     preferEscapeReplacementDollarChar,
+    preferLookaround,
     preferNamedBackreference,
     preferPlusQuantifier,
     preferPredefinedAssertion,
diff --git a/tools/update-rulesets.ts b/tools/update-rulesets.ts
index 6114701..82d245c 100644
--- a/tools/update-rulesets.ts
+++ b/tools/update-rulesets.ts
@@ -24,6 +24,8 @@ export = {
         // The ESLint rule will report fewer cases than our rule
         "no-invalid-regexp": "off",
         "no-useless-backreference": "off",
+        // TODO Switch in the major version.
+        // "no-empty-character-class": "off",
 
         // eslint-plugin-regexp rules
         ${rules


Test patch:
diff --git a/tests/lib/rules/no-empty-character-class.ts b/tests/lib/rules/no-empty-character-class.ts
new file mode 100644
index 0000000..8b5e229
--- /dev/null
+++ b/tests/lib/rules/no-empty-character-class.ts
@@ -0,0 +1,79 @@
+import { RuleTester } from "eslint"
+import rule from "../../../lib/rules/no-empty-character-class"
+
+const tester = new RuleTester({
+    parserOptions: {
+        ecmaVersion: 2020,
+        sourceType: "module",
+    },
+})
+
+tester.run("no-empty-character-class", rule as any, {
+    valid: [
+        `/[a]/`,
+        `/[a-z]/`,
+        `/[a]?/`,
+        `/[a]*/`,
+        `/[[]/`,
+        String.raw`/\[]/`,
+        `/[^]/`,
+        `/[()]/`,
+        `/[ ]/`,
+        String.raw`/[\s\S]/`,
+        String.raw`/[\da-zA-Z_\W]/`,
+    ],
+    invalid: [
+        {
+            code: `/[]/`,
+            errors: [
+                {
+                    message:
+                        "This character class matches no characters because it is empty.",
+                    line: 1,
+                    column: 2,
+                },
+            ],
+        },
+        {
+            code: `/abc[]/`,
+            errors: [
+                {
+                    message:
+                        "This character class matches no characters because it is empty.",
+                    line: 1,
+                    column: 5,
+                },
+            ],
+        },
+        {
+            code: `/([])/`,
+            errors: [
+                {
+                    message:
+                        "This character class matches no characters because it is empty.",
+                    line: 1,
+                    column: 3,
+                },
+            ],
+        },
+        {
+            code: `new RegExp("[]");`,
+            errors: [
+                {
+                    message:
+                        "This character class matches no characters because it is empty.",
+                    line: 1,
+                    column: 13,
+                },
+            ],
+        },
+        {
+            code: String.raw`/[^\s\S]/`,
+            errors: ["This character class cannot match any characters."],
+        },
+        {
+            code: String.raw`/[^\da-zA-Z_\W]/`,
+            errors: ["This character class cannot match any characters."],
+        },
+    ],
+})
diff --git a/tests/lib/rules/prefer-lookaround.ts b/tests/lib/rules/prefer-lookaround.ts
new file mode 100644
index 0000000..155e969
--- /dev/null
+++ b/tests/lib/rules/prefer-lookaround.ts
@@ -0,0 +1,441 @@
+import { RuleTester } from "eslint"
+import rule from "../../../lib/rules/prefer-lookaround"
+
+const tester = new RuleTester({
+    parserOptions: {
+        ecmaVersion: 2020,
+        sourceType: "module",
+    },
+})
+
+tester.run("prefer-lookaround", rule as any, {
+    valid: [
+        `const str = 'I love unicorn! I hate unicorn?'.replace(/(?<=love )unicorn(?=!)/, '🦄');`,
+        `const str = 'A JavaScript linter written in JavaScript.'.replaceAll(/Java(?=Script)/g, 'Type');`,
+        `const str = 'A JavaScript formatter written in JavaScript.'.replace(/(?<=Java)Script/g, '');`,
+        `'JavaScript.'.replace(/(Java)(Script)/, '$1, $1$2, and Type$2')`,
+        `'JavaScript.'.replace(/Java(Script)/, 'Java$1 and Type$1')`,
+        `'JavaScript.'.replace(/Java(?<script>Script)/, 'Java$<script> and Type$1')`,
+        `'JavaScript.'.replace(/(JavaScript)/, '$1')`,
+        `'JavaScript.'.replace(/Java(Script)/, '$1$1')`,
+        `'JavaScript.'.replace(/Java(Script)/, '$1foo$1')`,
+        {
+            code: `
+            const regex = /(Java)(Script)/g
+            str.replace(regex, '$1$2');
+            str2.replace(regex, '$1 and Java$2');
+            str3.replace(regex, '$1 and $2');
+            `,
+            options: [{ strictTypes: true }],
+        },
+        {
+            code: `
+            const regex = /(Java)(Script)/g
+            str.replace(regex, '$1$2');
+            str2.replace(regex, '$1 and Java$2');
+            str3.replace(regex, '$1 and $2');
+            useUnknown(regex)
+            `,
+            options: [{ strictTypes: false }],
+        },
+        {
+            code: `
+            const regex = /(Java)(Script)/g
+            str.replace(regex, '$1$2');
+            str2.replace(regex, '$1 and Java$2');
+            str3.replace(regex, '$1 and $2');
+            regex.test(foo)
+            `,
+            options: [{ strictTypes: false }],
+        },
+        {
+            code: `
+            const regex = /(Java)(Script)/g
+            str.replace(regex, '$1');
+            str2.replace(regex, '$1 and Java$2');
+            str3.replace(regex, '$1 and $2');
+            `,
+            options: [{ strictTypes: false }],
+        },
+        {
+            code: `
+            const regex = /(Java)(Script)/g
+            str.replace(regex, unknown);
+            str2.replace(regex, '$1 and Java$2');
+            str3.replace(regex, '$1 and $2');
+            `,
+            options: [{ strictTypes: false }],
+        },
+        {
+            code: `
+            const regex = /(Java)(Script)/g
+            str.replace(regex, 42);
+            str2.replace(regex, '$1 and Java$2');
+            str3.replace(regex, '$1 and $2');
+            `,
+            options: [{ strictTypes: false }],
+        },
+        `
+        "aa-a".replace(/a(\\b|$)/g, 'b$1')
+        "aaa".replace(/(^)a/, '$1b')
+        `,
+        `
+        "aaaaaa".replace(/(a)a(a)/g, "$1b$2")
+        // 'abaaba'
+        "aaaaaa".replace(/(?<=a)a(?=a)/g, "b")
+        // 'abbbba'
+        `,
+        `
+        "aaaaaa".replace(/(^a+)a(?=a)/, "$1b")
+        // 'aaaaba'
+        "aaaaaa".replace(/(?<=^a+)a(?=a)/, "b")
+        // 'abaaaa'
+        `,
+        `
+        "aaaaaa".replace(/(a)a/g, "$1b")
+        // 'ababab'
+        "aaaaaa".replace(/(?<=a)a/g, "b")
+        // 'abbbbb'
+        `,
+        `
+        "aaaaaa".replace(/(a)\\w/g, "$1b")
+        // 'ababab'
+        "aaaaaa".replace(/(?<=a)\\w/g, "b")
+        // 'abbbbb'
+        `,
+        `
+        "aaaaaa".replace(/(\\w)a/g, "$1b")
+        // 'ababab'
+        "aaaaaa".replace(/(?<=\\w)a/g, "b")
+        // 'abbbbb'
+        `,
+        `
+        "aaaaaa".replace(/(aa)a/g, "$1b")
+        // 'aabaab'
+        "aaaaaa".replace(/(?<=aa)a/g, "b")
+        // 'aabbbb'
+        `,
+        `
+        "aaaaaa".replace(/(a)aa/g, "$1bb")
+        // 'abbabb'
+        "aaaaaa".replace(/(?<=a)aa/g, "bb")
+        // 'abbbba'
+        `,
+        `
+        "aaaaaa".replace(/(a)a*/g, "$1b")
+        // 'ab'
+        "aaaaaa".replace(/(?<=a)a*/g, "b")
+        // 'abb'
+        `,
+        `
+        "aaaaaa".replace(/(a*)a/g, "$1b")
+        // 'aaaaab'
+        "aaaaaa".replace(/(?<=a*)a/g, "b")
+        // 'bbbbbb'
+        `,
+        `
+        "aaaaaa".replace(/(^a+)a/, "$1b")
+        // 'aaaaab'
+        "aaaaaa".replace(/(?<=^a+)a/, "b")
+        // 'abaaaa'
+        `,
+        `
+        "aaaaaa".replace(/a(a+)/g, "b$1")
+        // 'baaaaa'
+        "aaaaaa".replace(/a(?=a+)/g, "b")
+        // 'bbbbba'
+        `,
+        `
+        "ababababa".replace(/(a)b(a)/g, "$1c$2")
+        // 'acabacaba'
+        "ababababa".replace(/(?<=a)b(?=a)/g, "c")
+        // 'acacacaca'
+        `,
+        `
+        "aaaaaaaaa".replace(/(a{0,3})a/g, "$1b")
+        // 'aaabaaabb'
+        "aaaaaaaaa".replace(/(?<=a{0,3})a/g, "b")
+        // 'bbbbbbbbb'
+        `,
+        `
+        "aaaaaaaaa".replace(/(a{0,3})aaaaa/g, "$1b")
+        // 'aaaba'
+        "aaaaaaaaa".replace(/(?<=a{0,3})aaaaa/g, "b")
+        // 'baaaa'
+        `,
+        `
+        "aaaab".replace(/(a+)a+b/, "$1c")
+        // 'aaac'
+        "aaaab".replace(/(?<=a+)a+b/, "c")
+        // 'ac'
+        `,
+        `
+        "abaaaba".replace(/ab(a+)/g, "c$1")
+        // 'caaaba'
+        "abaaaba".replace(/ab(?=a+)/g, "c")
+        // 'caaca'
+        `,
+        `
+        "ababababa".replace(/(a)ba/g, "$1c")
+        // 'acbacba'
+        "ababababa".replace(/(?<=a)ba/g, "c")
+        // 'acccc'
+        `,
+        `
+        "aJavaJava".replace(/(a)Java/g, '$1foo')
+        // 'afooJava'
+        "aJavaJava".replace(/(?<=a)Java/g, 'foo')
+        // 'afoofoo'
+        `,
+        `
+        "aaaaaa".replace(/(a)a(?:)a/g, "$1bb")
+        // 'abbabb'
+        "aaaaaa".replace(/(?<=a)a(?:)a/g, "bb")
+        // 'abbbba'
+        `,
+        `
+        "aaaaaa".replace(/(a)aa(?:)/g, "$1bb")
+        // 'abbabb'
+        "aaaaaa".replace(/(?<=a)aa(?:)/g, "bb")
+        // 'abbbba'
+        `,
+    ],
+    invalid: [
+        {
+            code: `
+            const str = 'I love unicorn! I hate unicorn?'.replace(/(?<before>love )unicorn(?<after>!)/, '$<before>🦄$<after>');
+            `,
+            output: `
+            const str = 'I love unicorn! I hate unicorn?'.replace(/(?<=love )unicorn(?=!)/, '🦄');
+            `,
+            errors: [
+                {
+                    message:
+                        "These capturing groups can be replaced with lookaround assertions ('(?<=love )' and '(?=!)').",
+                    column: 68,
+                    endColumn: 84,
+                },
+                {
+                    message:
+                        "These capturing groups can be replaced with lookaround assertions ('(?<=love )' and '(?=!)').",
+                    column: 91,
+                    endColumn: 102,
+                },
+            ],
+        },
+        {
+            code: `
+            const str = 'I love unicorn! I hate unicorn?'.replace(/(love )unicorn(!)/, '$1🦄$2');
+            `,
+            output: `
+            const str = 'I love unicorn! I hate unicorn?'.replace(/(?<=love )unicorn(?=!)/, '🦄');
+            `,
+            errors: [
+                {
+                    message:
+                        "These capturing groups can be replaced with lookaround assertions ('(?<=love )' and '(?=!)').",
+                    column: 68,
+                    endColumn: 75,
+                },
+                {
+                    message:
+                        "These capturing groups can be replaced with lookaround assertions ('(?<=love )' and '(?=!)').",
+                    column: 82,
+                    endColumn: 85,
+                },
+            ],
+        },
+        {
+            code: `
+            const str = 'A JavaScript linter written in JavaScript.'.replaceAll(/Java(Script)/g, 'Type$1');
+            `,
+            output: `
+            const str = 'A JavaScript linter written in JavaScript.'.replaceAll(/Java(?=Script)/g, 'Type');
+            `,
+            errors: [
+                {
+                    message:
+                        "This capturing group can be replaced with a lookahead assertion ('(?=Script)').",
+                    column: 86,
+                    endColumn: 94,
+                },
+            ],
+        },
+        {
+            code: `
+            const str = 'A JavaScript formatter written in JavaScript.'.replace(/(Java)Script/g, '$1');
+            `,
+            output: `
+            const str = 'A JavaScript formatter written in JavaScript.'.replace(/(?<=Java)Script/g, '');
+            `,
+            errors: [
+                {
+                    message:
+                        "This capturing group can be replaced with a lookbehind assertion ('(?<=Java)').",
+                    column: 82,
+                    endColumn: 88,
+                },
+            ],
+        },
+        {
+            code: `
+            const str = 'JavaScript'.replace(/Java(Script)/g, '$1');
+            `,
+            output: `
+            const str = 'JavaScript'.replace(/Java(?=Script)/g, '');
+            `,
+            errors: [
+                "This capturing group can be replaced with a lookahead assertion ('(?=Script)').",
+            ],
+        },
+        {
+            code: `
+            const str = 'JavaScript.'.replace(/(Java)(Script)/, '$1 and Type$2');
+            `,
+            output: `
+            const str = 'JavaScript.'.replace(/(?<=Java)(?=Script)/, ' and Type');
+            `,
+            errors: [
+                "These capturing groups can be replaced with lookaround assertions ('(?<=Java)' and '(?=Script)').",
+                "These capturing groups can be replaced with lookaround assertions ('(?<=Java)' and '(?=Script)').",
+            ],
+        },
+        {
+            code: `
+            const replacement = '$<before>🦄$<after>'
+            const str = 'I love unicorn! I hate unicorn?'.replace(/(?<before>love )unicorn(?<after>!)/, replacement);
+            `,
+            output: null,
+            errors: [
+                "These capturing groups can be replaced with lookaround assertions ('(?<=love )' and '(?=!)').",
+                "These capturing groups can be replaced with lookaround assertions ('(?<=love )' and '(?=!)').",
+            ],
+        },
+        {
+            code: `
+            const replacement = '$1🦄$2'
+            const str = 'I love unicorn! I hate unicorn?'.replace(/(love )unicorn(!)/, replacement);
+            `,
+            output: null,
+            errors: [
+                "These capturing groups can be replaced with lookaround assertions ('(?<=love )' and '(?=!)').",
+                "These capturing groups can be replaced with lookaround assertions ('(?<=love )' and '(?=!)').",
+            ],
+        },
+        {
+            code: `
+            const str = 'JavaScript.'.replace(/(J)(ava)(Script)/, '$1Query, and Java$3');
+            `,
+            output: `
+            const str = 'JavaScript.'.replace(/(?<=J)(ava)(?=Script)/, 'Query, and Java');
+            `,
+            errors: [
+                "These capturing groups can be replaced with lookaround assertions ('(?<=J)' and '(?=Script)').",
+                "These capturing groups can be replaced with lookaround assertions ('(?<=J)' and '(?=Script)').",
+            ],
+        },
+        {
+            code: `
+            const str = 'JavaScript.'.replace(/(J)(ava)(Script)/, '$1Query, $2, and Java$3');
+            `,
+            output: null,
+            errors: [
+                "These capturing groups can be replaced with lookaround assertions ('(?<=J)' and '(?=Script)').",
+                "These capturing groups can be replaced with lookaround assertions ('(?<=J)' and '(?=Script)').",
+            ],
+        },
+        {
+            code: `
+            const regex = /(Java)(Script)/g
+            str.replace(regex, '$1$2');
+            str2.replace(regex, '$1 and Java$2');
+            str3.replace(regex, '$1 and $2');
+            `,
+            output: `
+            const regex = /(?<=Java)(?=Script)/g
+            str.replace(regex, '');
+            str2.replace(regex, ' and Java');
+            str3.replace(regex, ' and ');
+            `,
+            options: [{ strictTypes: false }],
+            errors: [
+                "These capturing groups can be replaced with lookaround assertions ('(?<=Java)' and '(?=Script)').",
+                "These capturing groups can be replaced with lookaround assertions ('(?<=Java)' and '(?=Script)').",
+            ],
+        },
+        {
+            code: `
+            const regex = /(Java)(Script)/
+            str.replace(regex, '$1$2');
+            str2.replace(regex, '$1 and Java$2');
+            str3.replace(regex, '$1 and $2');
+            regex.test(str);
+            `,
+            output: `
+            const regex = /(?<=Java)(?=Script)/
+            str.replace(regex, '');
+            str2.replace(regex, ' and Java');
+            str3.replace(regex, ' and ');
+            regex.test(str);
+            `,
+            options: [{ strictTypes: false }],
+            errors: [
+                "These capturing groups can be replaced with lookaround assertions ('(?<=Java)' and '(?=Script)').",
+                "These capturing groups can be replaced with lookaround assertions ('(?<=Java)' and '(?=Script)').",
+            ],
+        },
+        {
+            code: `
+            const regex = /(a)b(c)/
+            "abc".replace(regex, '$1ccount');
+            "abc".replace(regex, '$1$2$2ount');
+            `,
+            output: null,
+            errors: [
+                "This capturing group can be replaced with a lookbehind assertion ('(?<=a)').",
+            ],
+        },
+        {
+            code: `
+            const regex = /(a)b(c)/
+            "abc".replace(regex, 'dynami$2');
+            "abc".replace(regex, 'dyn$1mi$2');
+            `,
+            output: `
+            const regex = /(a)b(?=c)/
+            "abc".replace(regex, 'dynami');
+            "abc".replace(regex, 'dyn$1mi');
+            `,
+            errors: [
+                "This capturing group can be replaced with a lookahead assertion ('(?=c)').",
+            ],
+        },
+        {
+            code: `
+            "aabcaabbcc".replace(/(a+)b+c/g, "$1_")
+            // 'aa_aa_c'
+            `,
+            output: `
+            "aabcaabbcc".replace(/(?<=a+)b+c/g, "_")
+            // 'aa_aa_c'
+            `,
+            errors: [
+                "This capturing group can be replaced with a lookbehind assertion ('(?<=a+)').",
+            ],
+        },
+        {
+            code: `"aaaaaa".replace(/(^a+)a(a)/, "$1b$2")`,
+            output: `"aaaaaa".replace(/(^a+)a(?=a)/, "$1b")`,
+            errors: [
+                "This capturing group can be replaced with a lookahead assertion ('(?=a)').",
+            ],
+        },
+        {
+            code: `"aaaaaa".replace(/(^a+)a((a))/, "$1b$3$2")`,
+            output: null,
+            errors: [
+                "This capturing group can be replaced with a lookahead assertion ('(?=(a))').",
+            ],
+        },
+    ],
+})