Repository: ota-meshi/eslint-plugin-regexp
Pull request #480 (2022-11-19 11:46:09)

Improve `prefer-lookaround` rule to report when there are leading/trailing assertions
close  #478


Patch:
diff --git a/lib/rules/prefer-lookaround.ts b/lib/rules/prefer-lookaround.ts
index 87cfba2..8d58ea3 100644
--- a/lib/rules/prefer-lookaround.ts
+++ b/lib/rules/prefer-lookaround.ts
@@ -1,5 +1,10 @@
 import type { RegExpVisitor } from "regexpp/visitor"
-import type { CapturingGroup, Element } from "regexpp/ast"
+import type {
+    CapturingGroup,
+    Element,
+    LookaroundAssertion,
+    Pattern,
+} from "regexpp/ast"
 import type { RegExpContext } from "../utils"
 import { createRule, defineRegexpVisitor } from "../utils"
 import { createTypeTracker } from "../utils/type-tracker"
@@ -11,7 +16,10 @@ import {
     extractExpressionReferences,
     isKnownMethodCall,
 } from "../utils/ast-utils"
-import type { PatternReplaceRange } from "../utils/ast-utils/pattern-source"
+import type {
+    PatternRange,
+    PatternReplaceRange,
+} from "../utils/ast-utils/pattern-source"
 import type { Expression, Literal } from "estree"
 import type { Rule } from "eslint"
 import { mention } from "../utils/mention"
@@ -172,6 +180,184 @@ function getSideEffectsWhenReplacingCapturingGroup(
     }
 }
 
+/** Checks whether the given element is a capturing group of length 1 or greater. */
+function isCapturingGroupAndNotZeroLength(
+    element: Element,
+): element is CapturingGroup {
+    return element.type === "CapturingGroup" && !isZeroLength(element)
+}
+
+type ParsedStartPattern = {
+    // A list of zero-length elements placed before the start capturing group.
+    // e.g.
+    // /^(foo)bar/ -> ^
+    // /\b(foo)bar/ -> \b
+    // /(?:^|\b)(foo)bar/ -> (?:^|\b)
+    // /(?<=f)(oo)bar/ -> (?<=f)
+    // /(foo)bar/ -> null
+    leadingElements: Element[]
+    // Capturing group used to replace the starting string.
+    capturingGroup: CapturingGroup
+    // The pattern used when replacing lookbehind assertions.
+    replacedAssertion: string
+    range: PatternRange
+}
+type ParsedEndPattern = {
+    // Capturing group used to replace the ending string.
+    capturingGroup: CapturingGroup
+    // A list of zero-length elements placed after the end capturing group.
+    // e.g.
+    // /foo(bar)$/ -> $
+    // /foo(bar)\b/ -> \b
+    // /foo(bar)(?:\b|$)/ -> (?:\b|$)
+    // /foo(ba)(?=r)/ -> (?=r)
+    // /foo(bar)/ -> null
+    trailingElements: Element[]
+    // The pattern used when replacing lookahead assertions.
+    replacedAssertion: string
+    range: PatternRange
+}
+type ParsedElements = {
+    // All elements
+    elements: readonly Element[]
+    start: ParsedStartPattern | null
+    end: ParsedEndPattern | null
+}
+
+/**
+ * Parse the elements of the pattern.
+ */
+function parsePatternElements(node: Pattern): ParsedElements | null {
+    if (node.alternatives.length > 1) {
+        return null
+    }
+    const elements = node.alternatives[0].elements
+    const leadingElements: Element[] = []
+    let start: ParsedStartPattern | null = null
+
+    for (const element of elements) {
+        if (isZeroLength(element)) {
+            leadingElements.push(element)
+            continue
+        }
+        if (isCapturingGroupAndNotZeroLength(element)) {
+            const capturingGroup = element
+            start = {
+                leadingElements,
+                capturingGroup,
+                replacedAssertion: startElementsToLookbehindAssertionText(
+                    leadingElements,
+                    capturingGroup,
+                ),
+                range: {
+                    start: (leadingElements[0] || capturingGroup).start,
+                    end: capturingGroup.end,
+                },
+            }
+        }
+        break
+    }
+
+    let end: ParsedEndPattern | null = null
+    const trailingElements: Element[] = []
+    for (const element of [...elements].reverse()) {
+        if (isZeroLength(element)) {
+            trailingElements.unshift(element)
+            continue
+        }
+
+        if (isCapturingGroupAndNotZeroLength(element)) {
+            const capturingGroup = element
+            end = {
+                capturingGroup,
+                trailingElements,
+                replacedAssertion: endElementsToLookaheadAssertionText(
+                    capturingGroup,
+                    trailingElements,
+                ),
+                range: {
+                    start: capturingGroup.start,
+                    end: (
+                        trailingElements[trailingElements.length - 1] ||
+                        capturingGroup
+                    ).end,
+                },
+            }
+        }
+        break
+    }
+    if (!start && !end) {
+        // No capturing groups.
+        return null
+    }
+    if (start && end && start.capturingGroup === end.capturingGroup) {
+        // There is only one capturing group.
+        return null
+    }
+
+    return {
+        elements,
+        start,
+        end,
+    }
+}
+
+/** Convert end capturing group to lookahead assertion text. */
+function endElementsToLookaheadAssertionText(
+    capturingGroup: CapturingGroup,
+    trailingElements: Element[],
+): string {
+    const groupPattern = capturingGroup.alternatives.map((a) => a.raw).join("|")
+
+    const trailing = leadingTrailingElementsToLookaroundAssertionPatternText(
+        trailingElements,
+        "lookahead",
+    )
+    if (trailing && capturingGroup.alternatives.length !== 1) {
+        return `(?=(?:${groupPattern})${trailing})`
+    }
+    return `(?=${groupPattern}${trailing})`
+}
+
+/** Convert start capturing group to lookbehind assertion text. */
+function startElementsToLookbehindAssertionText(
+    leadingElements: Element[],
+    capturingGroup: CapturingGroup,
+): string {
+    const leading = leadingTrailingElementsToLookaroundAssertionPatternText(
+        leadingElements,
+        "lookbehind",
+    )
+    const groupPattern = capturingGroup.alternatives.map((a) => a.raw).join("|")
+    if (leading && capturingGroup.alternatives.length !== 1) {
+        return `(?<=${leading}(?:${groupPattern}))`
+    }
+    return `(?<=${leading}${groupPattern})`
+}
+
+/** Convert leading/trailing elements to lookaround assertion pattern text. */
+function leadingTrailingElementsToLookaroundAssertionPatternText(
+    leadingTrailingElements: Element[],
+    lookaroundAssertionKind: LookaroundAssertion["kind"],
+): string {
+    if (
+        leadingTrailingElements.length === 1 &&
+        leadingTrailingElements[0].type === "Assertion"
+    ) {
+        const assertion = leadingTrailingElements[0]
+        if (
+            assertion.kind === lookaroundAssertionKind &&
+            !assertion.negate &&
+            assertion.alternatives.length === 1
+        ) {
+            // If the leading/trailing assertion is simple (single alternative, and positive) lookaround assertion, unwrap the parens.
+            return assertion.alternatives[0].raw
+        }
+    }
+
+    return leadingTrailingElements.map((e) => e.raw).join("")
+}
+
 /**
  * Parse option
  */
@@ -230,10 +416,8 @@ export default createRule("prefer-lookaround", {
             regexpContext: RegExpContext,
         ): RegExpVisitor.Handlers {
             const { regexpNode, patternAst } = regexpContext
-            if (
-                patternAst.alternatives.length > 1 ||
-                patternAst.alternatives[0].elements.length < 2
-            ) {
+            const parsedElements = parsePatternElements(patternAst)
+            if (!parsedElements) {
                 return {}
             }
             const replaceReferenceList: ReplaceReferences[] = []
@@ -297,6 +481,7 @@ export default createRule("prefer-lookaround", {
             }
             return createVerifyVisitor(
                 regexpContext,
+                parsedElements,
                 new ReplaceReferencesList(replaceReferenceList),
             )
         }
@@ -408,6 +593,7 @@ export default createRule("prefer-lookaround", {
          */
         function createVerifyVisitor(
             regexpContext: RegExpContext,
+            parsedElements: ParsedElements,
             replaceReferenceList: ReplaceReferencesList,
         ): RegExpVisitor.Handlers {
             type RefState = {
@@ -457,43 +643,29 @@ export default createRule("prefer-lookaround", {
                         }
                     }
                 },
-                onPatternLeave(pNode) {
+                onPatternLeave() {
                     // 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])
+                        startRefState.capturingGroups[0] ===
+                            parsedElements.start?.capturingGroup
                     ) {
-                        const capturingGroup = startRefState.capturingGroups[0]
-                        reportStart = {
-                            capturingGroup,
-                            expr: `(?<=${capturingGroup.alternatives
-                                .map((a) => a.raw)
-                                .join("|")})`,
-                        }
+                        reportStart = parsedElements.start
                     }
                     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])
+                            parsedElements.end?.capturingGroup
                     ) {
-                        const capturingGroup = endRefState.capturingGroups[0]
-                        reportEnd = {
-                            capturingGroup,
-                            expr: `(?=${capturingGroup.alternatives
-                                .map((a) => a.raw)
-                                .join("|")})`,
-                        }
+                        reportEnd = parsedElements.end
                     }
                     const sideEffects =
                         getSideEffectsWhenReplacingCapturingGroup(
-                            alt.elements,
+                            parsedElements.elements,
                             reportStart?.capturingGroup,
                             reportEnd?.capturingGroup,
                             regexpContext,
@@ -530,12 +702,14 @@ export default createRule("prefer-lookaround", {
                         for (const report of [reportStart, reportEnd]) {
                             context.report({
                                 loc: regexpContext.getRegexpLocation(
-                                    report.capturingGroup,
+                                    report.range,
                                 ),
                                 messageId: "preferLookarounds",
                                 data: {
-                                    expr1: mention(reportStart.expr),
-                                    expr2: mention(reportEnd.expr),
+                                    expr1: mention(
+                                        reportStart.replacedAssertion,
+                                    ),
+                                    expr2: mention(reportEnd.replacedAssertion),
                                 },
                                 fix,
                             })
@@ -559,12 +733,12 @@ export default createRule("prefer-lookaround", {
                         )
                         context.report({
                             loc: regexpContext.getRegexpLocation(
-                                reportStart.capturingGroup,
+                                reportStart.range,
                             ),
                             messageId: "prefer",
                             data: {
                                 kind: "lookbehind assertion",
-                                expr: mention(reportStart.expr),
+                                expr: mention(reportStart.replacedAssertion),
                             },
                             fix,
                         })
@@ -595,12 +769,12 @@ export default createRule("prefer-lookaround", {
                         )
                         context.report({
                             loc: regexpContext.getRegexpLocation(
-                                reportEnd.capturingGroup,
+                                reportEnd.range,
                             ),
                             messageId: "prefer",
                             data: {
                                 kind: "lookahead assertion",
-                                expr: mention(reportEnd.expr),
+                                expr: mention(reportEnd.replacedAssertion),
                             },
                             fix,
                         })
@@ -614,10 +788,7 @@ export default createRule("prefer-lookaround", {
          */
         function buildFixer(
             regexpContext: RegExpContext,
-            replaceCapturingGroups: {
-                capturingGroup: CapturingGroup
-                expr: string
-            }[],
+            replaceCapturingGroups: (ParsedStartPattern | ParsedEndPattern)[],
             replaceReferenceList: ReplaceReferencesList,
             getRemoveRanges: (
                 replaceReference: ReplaceReferences,
@@ -638,17 +809,17 @@ export default createRule("prefer-lookaround", {
             }
             const replaces: {
                 replaceRange: PatternReplaceRange
-                expr: string
+                replacedAssertion: string
             }[] = []
-            for (const { capturingGroup, expr } of replaceCapturingGroups) {
+            for (const { range, replacedAssertion } of replaceCapturingGroups) {
                 const replaceRange =
-                    regexpContext.patternSource.getReplaceRange(capturingGroup)
+                    regexpContext.patternSource.getReplaceRange(range)
                 if (!replaceRange) {
                     return null
                 }
                 replaces.push({
                     replaceRange,
-                    expr,
+                    replacedAssertion,
                 })
             }
 
@@ -660,10 +831,11 @@ export default createRule("prefer-lookaround", {
                         fix: () => fixer.removeRange(removeRange),
                     })
                 }
-                for (const { replaceRange, expr } of replaces) {
+                for (const { replaceRange, replacedAssertion } of replaces) {
                     list.push({
                         offset: replaceRange.range[0],
-                        fix: () => replaceRange.replace(fixer, expr),
+                        fix: () =>
+                            replaceRange.replace(fixer, replacedAssertion),
                     })
                 }
                 return list


Test patch:
diff --git a/tests/lib/rules/prefer-lookaround.ts b/tests/lib/rules/prefer-lookaround.ts
index 155e969..740fbf1 100644
--- a/tests/lib/rules/prefer-lookaround.ts
+++ b/tests/lib/rules/prefer-lookaround.ts
@@ -199,6 +199,9 @@ tester.run("prefer-lookaround", rule as any, {
         "aaaaaa".replace(/(?<=a)aa(?:)/g, "bb")
         // 'abbbba'
         `,
+        // cannot replace assertions
+        `var str = 'JavaScriptCode'.replace(/Java(Script)(?:Code)/g, 'Type$1')`,
+        `var str = 'JavaScriptCode'.replace(/(?:Java)(Script)Code/g, '$1Element')`,
     ],
     invalid: [
         {
@@ -437,5 +440,365 @@ tester.run("prefer-lookaround", rule as any, {
                 "This capturing group can be replaced with a lookahead assertion ('(?=(a))').",
             ],
         },
+        {
+            code: `var str = 'JavaScript'.replace(/Java(Script)$/g, 'Type$1')`,
+            output: `var str = 'JavaScript'.replace(/Java(?=Script$)/g, 'Type')`,
+            errors: [
+                {
+                    message:
+                        "This capturing group can be replaced with a lookahead assertion ('(?=Script$)').",
+                    column: 37,
+                    endColumn: 46,
+                },
+            ],
+        },
+        {
+            code: String.raw`var str = 'JavaScript'.replace(/Java(Script)\b/g, 'Type$1')`,
+            output: String.raw`var str = 'JavaScript'.replace(/Java(?=Script\b)/g, 'Type')`,
+            errors: [
+                {
+                    message:
+                        "This capturing group can be replaced with a lookahead assertion ('(?=Script\\b)').",
+                    column: 37,
+                    endColumn: 47,
+                },
+            ],
+        },
+        {
+            code: String.raw`var str = 'JavaScript'.replace(/Java(Script)(?:\b|$)/g, 'Type$1')`,
+            output: String.raw`var str = 'JavaScript'.replace(/Java(?=Script(?:\b|$))/g, 'Type')`,
+            errors: [
+                {
+                    message:
+                        "This capturing group can be replaced with a lookahead assertion ('(?=Script(?:\\b|$))').",
+                    column: 37,
+                    endColumn: 53,
+                },
+            ],
+        },
+        {
+            code: `var str = 'JavaScript'.replace(/Java(Scrip)(?=t)/g, 'Type$1')`,
+            output: `var str = 'JavaScript'.replace(/Java(?=Script)/g, 'Type')`,
+            errors: [
+                {
+                    message:
+                        "This capturing group can be replaced with a lookahead assertion ('(?=Script)').",
+                    column: 37,
+                    endColumn: 49,
+                },
+            ],
+        },
+        {
+            code: `var str = 'JavaScriptLinter'.replace(/Java(Script)(?=Linter|Checker)/g, 'Type$1')`,
+            output: `var str = 'JavaScriptLinter'.replace(/Java(?=Script(?=Linter|Checker))/g, 'Type')`,
+            errors: [
+                {
+                    message:
+                        "This capturing group can be replaced with a lookahead assertion ('(?=Script(?=Linter|Checker))').",
+                    column: 43,
+                    endColumn: 69,
+                },
+            ],
+        },
+        {
+            code: `var str = 'Java'.replace(/^(J)ava/g, '$1Query')`,
+            output: `var str = 'Java'.replace(/(?<=^J)ava/g, 'Query')`,
+            errors: [
+                {
+                    message:
+                        "This capturing group can be replaced with a lookbehind assertion ('(?<=^J)').",
+                    column: 27,
+                    endColumn: 31,
+                },
+            ],
+        },
+        {
+            code: String.raw`var str = 'Java'.replace(/\b(J)ava/g, '$1Query')`,
+            output: String.raw`var str = 'Java'.replace(/(?<=\bJ)ava/g, 'Query')`,
+            errors: [
+                {
+                    message:
+                        "This capturing group can be replaced with a lookbehind assertion ('(?<=\\bJ)').",
+                    column: 27,
+                    endColumn: 32,
+                },
+            ],
+        },
+        {
+            code: String.raw`var str = 'Java'.replace(/(?:^|\b)(J)ava/g, '$1Query')`,
+            output: String.raw`var str = 'Java'.replace(/(?<=(?:^|\b)J)ava/g, 'Query')`,
+            errors: [
+                {
+                    message:
+                        "This capturing group can be replaced with a lookbehind assertion ('(?<=(?:^|\\b)J)').",
+                    column: 27,
+                    endColumn: 38,
+                },
+            ],
+        },
+        {
+            code: `var str = 'JavaScriptCode'.replace(/(?<=Java)(Script)Code/g, '$1Linter')`,
+            output: `var str = 'JavaScriptCode'.replace(/(?<=JavaScript)Code/g, 'Linter')`,
+            errors: [
+                {
+                    message:
+                        "This capturing group can be replaced with a lookbehind assertion ('(?<=JavaScript)').",
+                    column: 37,
+                    endColumn: 54,
+                },
+            ],
+        },
+        {
+            code: `var str = 'JavaScriptCode'.replace(/(?<=Java|Type)(Script)Code/g, '$1Linter')`,
+            output: `var str = 'JavaScriptCode'.replace(/(?<=(?<=Java|Type)Script)Code/g, 'Linter')`,
+            errors: [
+                {
+                    message:
+                        "This capturing group can be replaced with a lookbehind assertion ('(?<=(?<=Java|Type)Script)').",
+                    column: 37,
+                    endColumn: 59,
+                },
+            ],
+        },
+        {
+            code: `
+            const str = 'JavaScript'.replace(/^(Java)(Script)$/, '$1 and Type$2');
+            `,
+            output: `
+            const str = 'JavaScript'.replace(/(?<=^Java)(?=Script$)/, ' and Type');
+            `,
+            errors: [
+                {
+                    message:
+                        "These capturing groups can be replaced with lookaround assertions ('(?<=^Java)' and '(?=Script$)').",
+                    column: 47,
+                    endColumn: 54,
+                },
+                {
+                    message:
+                        "These capturing groups can be replaced with lookaround assertions ('(?<=^Java)' and '(?=Script$)').",
+                    column: 54,
+                    endColumn: 63,
+                },
+            ],
+        },
+        {
+            code: String.raw`
+            const str = 'JavaScript'.replace(/\b(Java)(Script)\b/, '$1 and Type$2');
+            `,
+            output: String.raw`
+            const str = 'JavaScript'.replace(/(?<=\bJava)(?=Script\b)/, ' and Type');
+            `,
+            errors: [
+                {
+                    message:
+                        "These capturing groups can be replaced with lookaround assertions ('(?<=\\bJava)' and '(?=Script\\b)').",
+                    column: 47,
+                    endColumn: 55,
+                },
+                {
+                    message:
+                        "These capturing groups can be replaced with lookaround assertions ('(?<=\\bJava)' and '(?=Script\\b)').",
+                    column: 55,
+                    endColumn: 65,
+                },
+            ],
+        },
+        {
+            code: String.raw`
+            const str = 'JavaScript'.replace(/(?:^|\b)(Java)(Script)(?:\b|$)/, '$1 and Type$2');
+            `,
+            output: String.raw`
+            const str = 'JavaScript'.replace(/(?<=(?:^|\b)Java)(?=Script(?:\b|$))/, ' and Type');
+            `,
+            errors: [
+                {
+                    message:
+                        "These capturing groups can be replaced with lookaround assertions ('(?<=(?:^|\\b)Java)' and '(?=Script(?:\\b|$))').",
+                    column: 47,
+                    endColumn: 61,
+                },
+                {
+                    message:
+                        "These capturing groups can be replaced with lookaround assertions ('(?<=(?:^|\\b)Java)' and '(?=Script(?:\\b|$))').",
+                    column: 61,
+                    endColumn: 77,
+                },
+            ],
+        },
+        {
+            code: `
+            const str = 'JavaScript'.replace(/^(J)(ava)(Script)$/, '$1Query, and Java$3');
+            `,
+            output: `
+            const str = 'JavaScript'.replace(/(?<=^J)(ava)(?=Script$)/, 'Query, and Java');
+            `,
+            errors: [
+                {
+                    message:
+                        "These capturing groups can be replaced with lookaround assertions ('(?<=^J)' and '(?=Script$)').",
+                    column: 47,
+                    endColumn: 51,
+                },
+                {
+                    message:
+                        "These capturing groups can be replaced with lookaround assertions ('(?<=^J)' and '(?=Script$)').",
+                    column: 56,
+                    endColumn: 65,
+                },
+            ],
+        },
+        {
+            code: String.raw`
+            const str = 'JavaScript'.replace(/\b(J)(ava)(Script)\b/, '$1Query, and Java$3');
+            `,
+            output: String.raw`
+            const str = 'JavaScript'.replace(/(?<=\bJ)(ava)(?=Script\b)/, 'Query, and Java');
+            `,
+            errors: [
+                {
+                    message:
+                        "These capturing groups can be replaced with lookaround assertions ('(?<=\\bJ)' and '(?=Script\\b)').",
+                    column: 47,
+                    endColumn: 52,
+                },
+                {
+                    message:
+                        "These capturing groups can be replaced with lookaround assertions ('(?<=\\bJ)' and '(?=Script\\b)').",
+                    column: 57,
+                    endColumn: 67,
+                },
+            ],
+        },
+        {
+            code: `var str = 'JavaScript'.replace(/Java(S)(c)(r)(i)(p)(t)/g, 'Type$1$2$3$4$5$6')`,
+            output: `var str = 'JavaScript'.replace(/Java(S)(c)(r)(i)(p)(?=t)/g, 'Type$1$2$3$4$5')`,
+            errors: [
+                "This capturing group can be replaced with a lookahead assertion ('(?=t)').",
+            ],
+        },
+        {
+            code: `var str = 'JavaScript'.replace(/Java(S)(c)(r)(i)(p)(?=t)/g, 'Type$1$2$3$4$5')`,
+            output: `var str = 'JavaScript'.replace(/Java(S)(c)(r)(i)(?=pt)/g, 'Type$1$2$3$4')`,
+            errors: [
+                "This capturing group can be replaced with a lookahead assertion ('(?=pt)').",
+            ],
+        },
+        {
+            code: `var str = 'JavaScript'.replace(/Java(S)(c)(r)(i)(?=pt)/g, 'Type$1$2$3$4')`,
+            output: `var str = 'JavaScript'.replace(/Java(S)(c)(r)(?=ipt)/g, 'Type$1$2$3')`,
+            errors: [
+                "This capturing group can be replaced with a lookahead assertion ('(?=ipt)').",
+            ],
+        },
+        {
+            code: `var str = 'JavaScript'.replace(/(?<=J)(a)(v)(a)Script/g, '$1$2$3Runtime')`,
+            output: null,
+            errors: [
+                "This capturing group can be replaced with a lookbehind assertion ('(?<=Ja)').",
+            ],
+        },
+        // Multiple alternatives
+        {
+            code: `
+            var str1 = 'ESLint ESLint'.replace(/ES(Lint|Tree)$/g, 'TypeScriptES$1');
+            var str2 = 'ESTree ESTree'.replace(/ES(Lint|Tree)$/g, 'TypeScriptES$1');
+            console.log(str1, str2)
+            `,
+            output: `
+            var str1 = 'ESLint ESLint'.replace(/ES(?=(?:Lint|Tree)$)/g, 'TypeScriptES');
+            var str2 = 'ESTree ESTree'.replace(/ES(?=(?:Lint|Tree)$)/g, 'TypeScriptES');
+            console.log(str1, str2)
+            `,
+            errors: [
+                "This capturing group can be replaced with a lookahead assertion ('(?=(?:Lint|Tree)$)').",
+                "This capturing group can be replaced with a lookahead assertion ('(?=(?:Lint|Tree)$)').",
+            ],
+        },
+        {
+            code: `
+            var str1 = 'ESLint ESLint'.replace(/^(E|J)S/g, '$1Script');
+            var str2 = 'JSLint JSLint'.replace(/^(E|J)S/g, '$1Script');
+            console.log(str1, str2)
+            `,
+            output: `
+            var str1 = 'ESLint ESLint'.replace(/(?<=^(?:E|J))S/g, 'Script');
+            var str2 = 'JSLint JSLint'.replace(/(?<=^(?:E|J))S/g, 'Script');
+            console.log(str1, str2)
+            `,
+            errors: [
+                "This capturing group can be replaced with a lookbehind assertion ('(?<=^(?:E|J))').",
+                "This capturing group can be replaced with a lookbehind assertion ('(?<=^(?:E|J))').",
+            ],
+        },
+        // Zero-length elements that are complex, or not commonly used.
+        {
+            code: String.raw`var str = 'foobarbaz'.replace(/foo(bar)(?:^|$|a{0}\b|(?=x)|(?<=y))/, 'test$1')`,
+            output: String.raw`var str = 'foobarbaz'.replace(/foo(?=bar(?:^|$|a{0}\b|(?=x)|(?<=y)))/, 'test')`,
+            errors: [
+                "This capturing group can be replaced with a lookahead assertion ('(?=bar(?:^|$|a{0}\\b|(?=x)|(?<=y)))').",
+            ],
+        },
+        {
+            code: String.raw`var str = 'foobarbaz'.replace(/foo(bar)(?<=z\w+)/, 'test$1')`,
+            output: String.raw`var str = 'foobarbaz'.replace(/foo(?=bar(?<=z\w+))/, 'test')`,
+            errors: [
+                "This capturing group can be replaced with a lookahead assertion ('(?=bar(?<=z\\w+))').",
+            ],
+        },
+        {
+            code: `var str = 'JavaScript'.replace(/Java(Script)^/g, 'Type$1')`,
+            output: `var str = 'JavaScript'.replace(/Java(?=Script^)/g, 'Type')`,
+            errors: [
+                "This capturing group can be replaced with a lookahead assertion ('(?=Script^)').",
+            ],
+        },
+        {
+            code: `var str = 'Java'.replace(/$(J)ava/g, '$1Query')`,
+            output: `var str = 'Java'.replace(/(?<=$J)ava/g, 'Query')`,
+            errors: [
+                "This capturing group can be replaced with a lookbehind assertion ('(?<=$J)').",
+            ],
+        },
+        {
+            code: String.raw`
+            const str = 'JavaScript'.replace(/^\b(J)(ava)(Script)\b$/, '$1Query, and Java$3');
+            `,
+            output: String.raw`
+            const str = 'JavaScript'.replace(/(?<=^\bJ)(ava)(?=Script\b$)/, 'Query, and Java');
+            `,
+            errors: [
+                "These capturing groups can be replaced with lookaround assertions ('(?<=^\\bJ)' and '(?=Script\\b$)').",
+                "These capturing groups can be replaced with lookaround assertions ('(?<=^\\bJ)' and '(?=Script\\b$)').",
+            ],
+        },
+        {
+            code: String.raw`var str = 'foobar'.replace(/foo(bar)(\b|$)/, 'bar$1')`,
+            output: String.raw`var str = 'foobar'.replace(/foo(?=bar(\b|$))/, 'bar')`,
+            errors: [
+                "This capturing group can be replaced with a lookahead assertion ('(?=bar(\\b|$))').",
+            ],
+        },
+        {
+            code: String.raw`var str = 'foobar'.replace(/foo(bar)(\b|$)/, '$2$1')`,
+            output: null,
+            errors: [
+                "This capturing group can be replaced with a lookahead assertion ('(?=bar(\\b|$))').",
+            ],
+        },
+        {
+            code: `var str = 'JavaScript'.replace(/Java(Scrip)((?=t))/g, 'Type$1')`,
+            output: `var str = 'JavaScript'.replace(/Java(?=Scrip((?=t)))/g, 'Type')`,
+            errors: [
+                "This capturing group can be replaced with a lookahead assertion ('(?=Scrip((?=t)))').",
+            ],
+        },
+        {
+            code: `var str = 'JavaScriptCode'.replace(/((?<=Java))(Script)Code/g, '$2Linter')`,
+            output: `var str = 'JavaScriptCode'.replace(/(?<=((?<=Java))Script)Code/g, 'Linter')`,
+            errors: [
+                "This capturing group can be replaced with a lookbehind assertion ('(?<=((?<=Java))Script)').",
+            ],
+        },
     ],
 })