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

Issue:
New rule: `no-useless-lookaround-assertions`
**Motivation**
<!-- A clear and concise description of the problem the new rule is supposed to solve. -->

We can define unnecessary lookaround assertions within lookaround assertions, so I would like a rule to report this and fix it automatically.

```js
'JavaScript'.replace(/Java(?=Scrip(?=t))/u, 'Type');
'JavaScript'.replace(/(?<=(?<=J)ava)Script/u, '');
```

**Description**
<!-- A clear and concise description of the new rule. -->

New rule report unnecessary lookaround assertions within lookaround assertions.


**Examples**

<!-- Add a few examples of regexes that the rule does and does not report. -->

```js
/* ✗ BAD */
'JavaScript'.replace(/Java(?=Scrip(?=t))/u, 'Type');
'JavaScript'.replace(/(?<=(?<=J)ava)Script/u, '');

/* ✓ GOOD */
'JavaScript'.replace(/Java(?=Script)/u, 'Type');
'JavaScript'.replace(/(?<=Java)Script/u, '');
```



Pull request description:
Add `regexp/no-extra-lookaround-assertions` rule
close #481 

Patch:
diff --git a/README.md b/README.md
index 008537a..552fbef 100644
--- a/README.md
+++ b/README.md
@@ -138,6 +138,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
 | [regexp/control-character-escape](https://ota-meshi.github.io/eslint-plugin-regexp/rules/control-character-escape.html) | enforce consistent escaping of control characters | :star::wrench: |
 | [regexp/negation](https://ota-meshi.github.io/eslint-plugin-regexp/rules/negation.html) | enforce use of escapes on negation | :star::wrench: |
 | [regexp/no-dupe-characters-character-class](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-dupe-characters-character-class.html) | disallow duplicate characters in the RegExp character class | :star::wrench: |
+| [regexp/no-extra-lookaround-assertions](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-extra-lookaround-assertions.html) | disallow unnecessary nested lookaround assertions | :wrench: |
 | [regexp/no-invisible-character](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-invisible-character.html) | disallow invisible raw character | :star::wrench: |
 | [regexp/no-legacy-features](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-legacy-features.html) | disallow legacy RegExp features | :star: |
 | [regexp/no-non-standard-flag](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-non-standard-flag.html) | disallow non-standard flags | :star: |
diff --git a/docs/rules/index.md b/docs/rules/index.md
index 321ec49..8c133c4 100644
--- a/docs/rules/index.md
+++ b/docs/rules/index.md
@@ -43,6 +43,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
 | [regexp/control-character-escape](./control-character-escape.md) | enforce consistent escaping of control characters | :star::wrench: |
 | [regexp/negation](./negation.md) | enforce use of escapes on negation | :star::wrench: |
 | [regexp/no-dupe-characters-character-class](./no-dupe-characters-character-class.md) | disallow duplicate characters in the RegExp character class | :star::wrench: |
+| [regexp/no-extra-lookaround-assertions](./no-extra-lookaround-assertions.md) | disallow unnecessary nested lookaround assertions | :wrench: |
 | [regexp/no-invisible-character](./no-invisible-character.md) | disallow invisible raw character | :star::wrench: |
 | [regexp/no-legacy-features](./no-legacy-features.md) | disallow legacy RegExp features | :star: |
 | [regexp/no-non-standard-flag](./no-non-standard-flag.md) | disallow non-standard flags | :star: |
diff --git a/docs/rules/no-extra-lookaround-assertions.md b/docs/rules/no-extra-lookaround-assertions.md
new file mode 100644
index 0000000..2300a6b
--- /dev/null
+++ b/docs/rules/no-extra-lookaround-assertions.md
@@ -0,0 +1,61 @@
+---
+pageClass: "rule-details"
+sidebarDepth: 0
+title: "regexp/no-extra-lookaround-assertions"
+description: "disallow unnecessary nested lookaround assertions"
+---
+# regexp/no-extra-lookaround-assertions
+
+> disallow unnecessary nested lookaround assertions
+
+- :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
+
+The last positive lookahead assertion within a lookahead assertion is the same without lookahead assertions.
+Also, The first positive lookbehind assertion within a lookbehind assertion is the same without lookbehind assertions.
+They can be inlined or converted to group.
+
+```js
+/a(?=b(?=c))/u; /* -> */ /a(?=bc)/u;
+/a(?=b(?=c|C))/u; /* -> */ /a(?=b(?:c|C))/u;
+
+/(?<=(?<=a)b)c/u; /* -> */ /(?<=ab)c/u;
+/(?<=(?<=a|A)b)c/u; /* -> */ /(?<=(?:a|A)b)c/u;
+```
+
+This rule aims to report and fix these unnecessary lookaround assertions.
+
+<eslint-code-block fix>
+
+```js
+/* eslint regexp/no-extra-lookaround-assertions: "error" */
+
+/* ✓ GOOD */
+var ts = 'JavaScript'.replace(/Java(?=Script)/u, 'Type');
+var java = 'JavaScript'.replace(/(?<=Java)Script/u, '');
+var re1 = /a(?=bc)/u;
+var re2 = /a(?=b(?:c|C))/u;
+var re3 = /(?<=ab)c/u;
+var re4 = /(?<=(?:a|A)b)c/u;
+
+/* ✗ BAD */
+var ts = 'JavaScript'.replace(/Java(?=Scrip(?=t))/u, 'Type');
+var java = 'JavaScript'.replace(/(?<=(?<=J)ava)Script/u, '');
+var re1 = /a(?=b(?=c))/u;
+var re2 = /a(?=b(?=c|C))/u;
+var re3 = /(?<=(?<=a)b)c/u;
+var re4 = /(?<=(?<=a|A)b)c/u;
+```
+
+</eslint-code-block>
+
+## :wrench: Options
+
+Nothing.
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/lib/rules/no-extra-lookaround-assertions.ts)
+- [Test source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/tests/lib/rules/no-extra-lookaround-assertions.ts)
diff --git a/lib/rules/no-extra-lookaround-assertions.ts b/lib/rules/no-extra-lookaround-assertions.ts
new file mode 100644
index 0000000..2f18e42
--- /dev/null
+++ b/lib/rules/no-extra-lookaround-assertions.ts
@@ -0,0 +1,119 @@
+import type { LookaroundAssertion } from "regexpp/ast"
+import type { RegExpVisitor } from "regexpp/visitor"
+import type { RegExpContext } from "../utils"
+import { createRule, defineRegexpVisitor } from "../utils"
+
+export default createRule("no-extra-lookaround-assertions", {
+    meta: {
+        docs: {
+            description: "disallow unnecessary nested lookaround assertions",
+            category: "Best Practices",
+            // TODO Switch to recommended in the major version.
+            // recommended: true,
+            recommended: false,
+        },
+        fixable: "code",
+        schema: [],
+        messages: {
+            canBeInlined:
+                "This {{kind}} assertion is useless and can be inlined.",
+            canBeConvertedIntoGroup:
+                "This {{kind}} assertion is useless and can be converted into a group.",
+        },
+        type: "suggestion",
+    },
+    create(context) {
+        /**
+         * Create visitor
+         */
+        function createVisitor(
+            regexpContext: RegExpContext,
+        ): RegExpVisitor.Handlers {
+            return {
+                onAssertionEnter(aNode) {
+                    if (
+                        aNode.kind === "lookahead" ||
+                        aNode.kind === "lookbehind"
+                    ) {
+                        verify(regexpContext, aNode)
+                    }
+                },
+            }
+        }
+
+        /** Verify for lookaround assertion */
+        function verify(
+            regexpContext: RegExpContext,
+            assertion: LookaroundAssertion,
+        ) {
+            for (const alternative of assertion.alternatives) {
+                const nested = at(
+                    alternative.elements,
+                    assertion.kind === "lookahead"
+                        ? // The last positive lookahead assertion within
+                          // a lookahead assertion is the same without the assertion.
+                          -1
+                        : // The first positive lookbehind assertion within
+                          // a lookbehind assertion is the same without the assertion.
+                          0,
+                )
+                if (
+                    nested?.type === "Assertion" &&
+                    nested.kind === assertion.kind &&
+                    !nested.negate
+                ) {
+                    reportLookaroundAssertion(regexpContext, nested)
+                }
+            }
+        }
+
+        /** Report */
+        function reportLookaroundAssertion(
+            { node, getRegexpLocation, fixReplaceNode }: RegExpContext,
+            assertion: LookaroundAssertion,
+        ) {
+            let messageId, replaceText
+            if (assertion.alternatives.length === 1) {
+                messageId = "canBeInlined"
+                // unwrap `(?=` and `)`, `(?<=` and `)`
+                replaceText = assertion.alternatives[0].raw
+            } else {
+                messageId = "canBeConvertedIntoGroup"
+                // replace `?=` with `?:`, or `?<=` with `?:`
+                replaceText = `(?:${assertion.alternatives
+                    .map((alt) => alt.raw)
+                    .join("|")})`
+            }
+
+            context.report({
+                node,
+                loc: getRegexpLocation(assertion),
+                messageId,
+                data: {
+                    kind: assertion.kind,
+                },
+                fix: fixReplaceNode(assertion, replaceText),
+            })
+        }
+
+        return defineRegexpVisitor(context, {
+            createVisitor,
+        })
+    },
+})
+
+// TODO After dropping support for Node < v16.6.0 we can use native `.at()`.
+/**
+ * `.at()` polyfill
+ * see https://github.com/tc39/proposal-relative-indexing-method#polyfill
+ */
+function at<T>(array: T[], n: number) {
+    // ToInteger() abstract op
+    let num = Math.trunc(n) || 0
+    // Allow negative indexing from the end
+    if (num < 0) num += array.length
+    // OOB access is guaranteed to return undefined
+    if (num < 0 || num >= array.length) return undefined
+    // Otherwise, this is just normal property access
+    return array[num]
+}
diff --git a/lib/utils/rules.ts b/lib/utils/rules.ts
index 43d6576..86ae883 100644
--- a/lib/utils/rules.ts
+++ b/lib/utils/rules.ts
@@ -16,6 +16,7 @@ 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"
+import noExtraLookaroundAssertions from "../rules/no-extra-lookaround-assertions"
 import noInvalidRegexp from "../rules/no-invalid-regexp"
 import noInvisibleCharacter from "../rules/no-invisible-character"
 import noLazyEnds from "../rules/no-lazy-ends"
@@ -95,6 +96,7 @@ export const rules = [
     noEmptyGroup,
     noEmptyLookaroundsAssertion,
     noEscapeBackspace,
+    noExtraLookaroundAssertions,
     noInvalidRegexp,
     noInvisibleCharacter,
     noLazyEnds,


Test patch:
diff --git a/tests/lib/rules/no-extra-lookaround-assertions.ts b/tests/lib/rules/no-extra-lookaround-assertions.ts
new file mode 100644
index 0000000..0bb380f
--- /dev/null
+++ b/tests/lib/rules/no-extra-lookaround-assertions.ts
@@ -0,0 +1,129 @@
+import { RuleTester } from "eslint"
+import rule from "../../../lib/rules/no-extra-lookaround-assertions"
+
+const tester = new RuleTester({
+    parserOptions: {
+        ecmaVersion: 2020,
+        sourceType: "module",
+    },
+})
+
+tester.run("no-extra-lookaround-assertions", rule as any, {
+    valid: [
+        `console.log('JavaScript'.replace(/Java(?=Script)/u, 'Type'))`,
+        `console.log('JavaScript'.replace(/(?<=Java)Script/u, ''))`,
+    ],
+    invalid: [
+        {
+            code: `console.log('JavaScript'.replace(/Java(?=Scrip(?=t))/u, 'Type'))`,
+            output: `console.log('JavaScript'.replace(/Java(?=Script)/u, 'Type'))`,
+            errors: [
+                {
+                    message:
+                        "This lookahead assertion is useless and can be inlined.",
+                    column: 47,
+                },
+            ],
+        },
+        {
+            code: `console.log('JavaScript'.replace(/(?<=(?<=J)ava)Script/u, ''))`,
+            output: `console.log('JavaScript'.replace(/(?<=Java)Script/u, ''))`,
+            errors: [
+                {
+                    message:
+                        "This lookbehind assertion is useless and can be inlined.",
+                    column: 39,
+                },
+            ],
+        },
+        // Within negate
+        {
+            code: `console.log('JavaScript Java JavaRuntime'.replace(/Java(?!Scrip(?=t))/gu, 'Python'))`,
+            output: `console.log('JavaScript Java JavaRuntime'.replace(/Java(?!Script)/gu, 'Python'))`,
+            errors: [
+                {
+                    message:
+                        "This lookahead assertion is useless and can be inlined.",
+                    column: 64,
+                },
+            ],
+        },
+        {
+            code: `console.log('JavaScript TypeScript ActionScript'.replace(/(?<!(?<=J)ava)Script/gu, 'ScriptCompiler'))`,
+            output: `console.log('JavaScript TypeScript ActionScript'.replace(/(?<!Java)Script/gu, 'ScriptCompiler'))`,
+            errors: [
+                {
+                    message:
+                        "This lookbehind assertion is useless and can be inlined.",
+                    column: 63,
+                },
+            ],
+        },
+        // Multiple alternatives
+        {
+            code: `console.log('JavaScriptChecker JavaScriptLinter'.replace(/Java(?=Script(?=Checker|Linter))/gu, 'Type'))`,
+            output: `console.log('JavaScriptChecker JavaScriptLinter'.replace(/Java(?=Script(?:Checker|Linter))/gu, 'Type'))`,
+            errors: [
+                {
+                    message:
+                        "This lookahead assertion is useless and can be converted into a group.",
+                    column: 72,
+                },
+            ],
+        },
+        {
+            code: `console.log('JavaScriptChecker JavaScriptLinter'.replace(/Java(?=Script(?=(?:Check|Lint)er))/gu, 'Type'))`,
+            output: `console.log('JavaScriptChecker JavaScriptLinter'.replace(/Java(?=Script(?:Check|Lint)er)/gu, 'Type'))`,
+            errors: [
+                {
+                    message:
+                        "This lookahead assertion is useless and can be inlined.",
+                    column: 72,
+                },
+            ],
+        },
+        {
+            code: `console.log('ESLint JSLint TSLint'.replace(/(?<=(?<=J|T)S)Lint/gu, '-Runtime'))`,
+            output: `console.log('ESLint JSLint TSLint'.replace(/(?<=(?:J|T)S)Lint/gu, '-Runtime'))`,
+            errors: [
+                {
+                    message:
+                        "This lookbehind assertion is useless and can be converted into a group.",
+                    column: 49,
+                },
+            ],
+        },
+        {
+            code: `console.log('JavaScriptChecker JavaScriptLinter'.replace(/Java(?=Script(?=Checker)|Script(?=Linter))/gu, 'Type'))`,
+            output: `console.log('JavaScriptChecker JavaScriptLinter'.replace(/Java(?=ScriptChecker|ScriptLinter)/gu, 'Type'))`,
+            errors: [
+                {
+                    message:
+                        "This lookahead assertion is useless and can be inlined.",
+                    column: 72,
+                },
+                {
+                    message:
+                        "This lookahead assertion is useless and can be inlined.",
+                    column: 90,
+                },
+            ],
+        },
+        {
+            code: `console.log('ESLint JSLint TSLint'.replace(/(?<=(?<=J)S|(?<=T)S)Lint/gu, '-Runtime'))`,
+            output: `console.log('ESLint JSLint TSLint'.replace(/(?<=JS|TS)Lint/gu, '-Runtime'))`,
+            errors: [
+                {
+                    message:
+                        "This lookbehind assertion is useless and can be inlined.",
+                    column: 49,
+                },
+                {
+                    message:
+                        "This lookbehind assertion is useless and can be inlined.",
+                    column: 57,
+                },
+            ],
+        },
+    ],
+})