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

Issue:
Add `regexp/prefer-result-array-groups` rule
From [this comment](https://github.com/ota-meshi/eslint-plugin-regexp/pull/339#discussion_r719249323).

> `prefer-regexp-result-array-groups` is a rule that inherits some named capturing group checks from `no-unused-capturing-group`. 
> This rule prefers named access over array index access when using named capturing group in regex. Perhaps the process is similar to `prefer-named-replacement`.
> When adding `prefer-regexp-result-array-groups`, exclude the same check from `no-unused-capturing-group`.
> 
> The example code that `prefer-regexp-result-array-groups` warns is:
> 
> ```js
> const reFoo = /(?<foo>foo)/
> let resArr
> while (resArr = reFoo.exec(str)) {
>   const foo = resArr[1]
>   //                ^^^ Use `resArr.groups.foo` instead.
>   //                Currently it is warned by no-unused-capturing-group, but change it to not warn.
> }
> ```
> 
> The sample code that the new rule does not warn is:
> 
> ```js
> const reFoo = /(?<foo>foo)/
> let resArr
> while (resArr = reFoo.exec(str)) {
>   const foo = resArr.groups.foo // correct
> }
> ```
> 
> ```js
> const reFoo = /(foo)/
> let resArr
> while (resArr = reFoo.exec(str)) {
>   const foo = resArr[1] // Not using named capturing group in regex.
> }
> ```
> 
> ```js
> const reFoo = /(foo)/
> let resArr
> while (resArr = reFoo.exec(str)) {
>   const match = resArr[0] // The capturing group was not used. It's warned by no-unused-capturing-group rule.
> }
> ```
> 
> The `prefer-regexp-result-array-groups` rule checks not only the `exec` but also the return values of `match` and `matchAll`.

Pull request:
Add prefer-result-array-groups rule
close #347 

Patch:
diff --git a/README.md b/README.md
index 24f580b..fa34837 100644
--- a/README.md
+++ b/README.md
@@ -178,6 +178,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
 | [regexp/prefer-named-replacement](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-named-replacement.html) | enforce using named replacement | :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: |
+| [regexp/prefer-result-array-groups](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-result-array-groups.html) | enforce using result array `groups` | :wrench: |
 | [regexp/prefer-star-quantifier](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-star-quantifier.html) | enforce using `*` quantifier | :star::wrench: |
 | [regexp/prefer-unicode-codepoint-escapes](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-unicode-codepoint-escapes.html) | enforce use of unicode codepoint escapes | :star::wrench: |
 | [regexp/prefer-w](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-w.html) | enforce using `\w` | :star::wrench: |
diff --git a/docs/rules/README.md b/docs/rules/README.md
index f993088..bf50b78 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -87,6 +87,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
 | [regexp/prefer-named-replacement](./prefer-named-replacement.md) | enforce using named replacement | :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: |
+| [regexp/prefer-result-array-groups](./prefer-result-array-groups.md) | enforce using result array `groups` | :wrench: |
 | [regexp/prefer-star-quantifier](./prefer-star-quantifier.md) | enforce using `*` quantifier | :star::wrench: |
 | [regexp/prefer-unicode-codepoint-escapes](./prefer-unicode-codepoint-escapes.md) | enforce use of unicode codepoint escapes | :star::wrench: |
 | [regexp/prefer-w](./prefer-w.md) | enforce using `\w` | :star::wrench: |
diff --git a/docs/rules/prefer-named-backreference.md b/docs/rules/prefer-named-backreference.md
index 8960956..0bb7141 100644
--- a/docs/rules/prefer-named-backreference.md
+++ b/docs/rules/prefer-named-backreference.md
@@ -38,9 +38,11 @@ Nothing.
 
 - [regexp/prefer-named-capture-group]
 - [regexp/prefer-named-replacement]
+- [regexp/prefer-result-array-groups]
 
 [regexp/prefer-named-capture-group]: ./prefer-named-capture-group.md
 [regexp/prefer-named-replacement]: ./prefer-named-replacement.md
+[regexp/prefer-result-array-groups]: ./prefer-result-array-groups.md
 
 ## :rocket: Version
 
diff --git a/docs/rules/prefer-named-capture-group.md b/docs/rules/prefer-named-capture-group.md
index f59a369..e5e8684 100644
--- a/docs/rules/prefer-named-capture-group.md
+++ b/docs/rules/prefer-named-capture-group.md
@@ -38,9 +38,11 @@ Nothing.
 
 - [regexp/prefer-named-backreference]
 - [regexp/prefer-named-replacement]
+- [regexp/prefer-result-array-groups]
 
 [regexp/prefer-named-backreference]: ./prefer-named-backreference.md
 [regexp/prefer-named-replacement]: ./prefer-named-replacement.md
+[regexp/prefer-result-array-groups]: ./prefer-result-array-groups.md
 
 ## :books: Further reading
 
diff --git a/docs/rules/prefer-named-replacement.md b/docs/rules/prefer-named-replacement.md
index 8d59b52..d431fbc 100644
--- a/docs/rules/prefer-named-replacement.md
+++ b/docs/rules/prefer-named-replacement.md
@@ -47,9 +47,11 @@ This rule reports and fixes `$n` parameter in replacement string that do not use
 
 - [regexp/prefer-named-backreference]
 - [regexp/prefer-named-capture-group]
+- [regexp/prefer-result-array-groups]
 
 [regexp/prefer-named-backreference]: ./prefer-named-backreference.md
 [regexp/prefer-named-capture-group]: ./prefer-named-capture-group.md
+[regexp/prefer-result-array-groups]: ./prefer-result-array-groups.md
 
 ## :mag: Implementation
 
diff --git a/docs/rules/prefer-result-array-groups.md b/docs/rules/prefer-result-array-groups.md
new file mode 100644
index 0000000..c5ea551
--- /dev/null
+++ b/docs/rules/prefer-result-array-groups.md
@@ -0,0 +1,63 @@
+---
+pageClass: "rule-details"
+sidebarDepth: 0
+title: "regexp/prefer-result-array-groups"
+description: "enforce using result array `groups`"
+---
+# regexp/prefer-result-array-groups
+
+> enforce using result array `groups`
+
+- :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 and fixes regexp result arrays where named capturing groups are accessed by index instead of using [`groups`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Groups_and_Ranges#using_named_groups).
+
+<eslint-code-block fix>
+
+```js
+/* eslint regexp/prefer-result-array-groups: "error" */
+
+const regex = /(?<foo>a)(b)c/g
+let match
+while (match = regex.exec(str)) {
+    /* ✓ GOOD */
+    var p1 = match.groups.foo
+    var p2 = match[2]
+
+    /* ✗ BAD */
+    var p1 = match[1]
+}
+```
+
+</eslint-code-block>
+
+## :wrench: Options
+
+```json
+{
+  "regexp/prefer-result-array-groups": ["error", {
+    "strictTypes": true
+  }]
+}
+```
+
+- `strictTypes` ... If `true`, strictly check the type of object to determine if the string instance was used in `match()` and `matchAll()`. Default is `true`.  
+  This option is always on when using TypeScript.
+
+## :couple: Related rules
+
+- [regexp/prefer-named-backreference]
+- [regexp/prefer-named-capture-group]
+- [regexp/prefer-named-replacement]
+
+[regexp/prefer-named-backreference]: ./prefer-named-backreference.md
+[regexp/prefer-named-capture-group]: ./prefer-named-capture-group.md
+[regexp/prefer-named-replacement]: ./prefer-named-replacement.md
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/lib/rules/prefer-result-array-groups.ts)
+- [Test source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/tests/lib/rules/prefer-result-array-groups.ts)
diff --git a/lib/rules/prefer-result-array-groups.ts b/lib/rules/prefer-result-array-groups.ts
new file mode 100644
index 0000000..1f4876a
--- /dev/null
+++ b/lib/rules/prefer-result-array-groups.ts
@@ -0,0 +1,169 @@
+import type { RegExpVisitor } from "regexpp/visitor"
+import { isOpeningBracketToken } from "eslint-utils"
+import type { RegExpContext } from "../utils"
+import { createRule, defineRegexpVisitor } from "../utils"
+import {
+    getTypeScriptTools,
+    isAny,
+    isClassOrInterface,
+} from "../utils/ts-utils"
+import type { Expression, Super } from "estree"
+
+export default createRule("prefer-result-array-groups", {
+    meta: {
+        docs: {
+            description: "enforce using result array `groups`",
+            category: "Stylistic Issues",
+            recommended: false,
+        },
+        fixable: "code",
+        schema: [
+            {
+                type: "object",
+                properties: {
+                    strictTypes: { type: "boolean" },
+                },
+                additionalProperties: false,
+            },
+        ],
+        messages: {
+            unexpected:
+                "Unexpected indexed access for the named capturing group '{{ name }}' from regexp result array.",
+        },
+        type: "suggestion",
+    },
+    create(context) {
+        const strictTypes = context.options[0]?.strictTypes ?? true
+        const sourceCode = context.getSourceCode()
+
+        /**
+         * Create visitor
+         */
+        function createVisitor(
+            regexpContext: RegExpContext,
+        ): RegExpVisitor.Handlers {
+            const {
+                getAllCapturingGroups,
+                getCapturingGroupReferences,
+            } = regexpContext
+
+            const capturingGroups = getAllCapturingGroups()
+            if (!capturingGroups.length) {
+                return {}
+            }
+
+            for (const ref of getCapturingGroupReferences({ strictTypes })) {
+                if (
+                    ref.type === "ArrayRef" &&
+                    ref.kind === "index" &&
+                    ref.ref != null
+                ) {
+                    const cgNode = capturingGroups[ref.ref - 1]
+                    if (cgNode && cgNode.name) {
+                        const memberNode =
+                            ref.prop.type === "member" ? ref.prop.node : null
+                        context.report({
+                            node: ref.prop.node,
+                            messageId: "unexpected",
+                            data: {
+                                name: cgNode.name,
+                            },
+                            fix:
+                                memberNode && memberNode.computed
+                                    ? (fixer) => {
+                                          const tokens = sourceCode.getTokensBetween(
+                                              memberNode.object,
+                                              memberNode.property,
+                                          )
+                                          let openingBracket = tokens.pop()
+                                          while (
+                                              openingBracket &&
+                                              !isOpeningBracketToken(
+                                                  openingBracket,
+                                              )
+                                          ) {
+                                              openingBracket = tokens.pop()
+                                          }
+                                          if (!openingBracket) {
+                                              // unknown ast
+                                              return null
+                                          }
+
+                                          const kind = getRegExpArrayTypeKind(
+                                              memberNode.object,
+                                          )
+                                          if (kind === "unknown") {
+                                              // Using TypeScript but I can't identify the type or it's not a RegExpXArray type.
+                                              return null
+                                          }
+                                          const needNonNull =
+                                              kind === "RegExpXArray"
+
+                                          return fixer.replaceTextRange(
+                                              [
+                                                  openingBracket.range![0],
+                                                  memberNode.range![1],
+                                              ],
+                                              `${
+                                                  memberNode.optional ? "" : "."
+                                              }groups${
+                                                  needNonNull ? "!" : ""
+                                              }.${cgNode.name}`,
+                                          )
+                                      }
+                                    : null,
+                        })
+                    }
+                }
+            }
+
+            return {}
+        }
+
+        return defineRegexpVisitor(context, {
+            createVisitor,
+        })
+
+        type RegExpArrayTypeKind =
+            | "RegExpXArray" // RegExpMatchArray or RegExpExecArray
+            | "any"
+            | "unknown" // It's cannot autofix
+
+        /** Gets the type kind of the given node. */
+        function getRegExpArrayTypeKind(
+            node: Expression | Super,
+        ): RegExpArrayTypeKind | null {
+            const {
+                tsNodeMap,
+                checker,
+                usedTS,
+                hasFullTypeInformation,
+            } = getTypeScriptTools(context)
+            if (!usedTS) {
+                // Not using TypeScript.
+                return null
+            }
+            if (!hasFullTypeInformation) {
+                // The user has not given the type information to ESLint. So we don't know if this can be autofix.
+                return "unknown"
+            }
+            const tsNode = tsNodeMap.get(node)
+            const tsType = (tsNode && checker.getTypeAtLocation(tsNode)) || null
+            if (!tsType) {
+                // The node type cannot be determined.
+                return "unknown"
+            }
+
+            if (isClassOrInterface(tsType)) {
+                const name = tsType.symbol.escapedName
+                return name === "RegExpMatchArray" || name === "RegExpExecArray"
+                    ? "RegExpXArray"
+                    : "unknown"
+            }
+            if (isAny(tsType)) {
+                return "any"
+            }
+            return "unknown"
+        }
+    },
+})
diff --git a/lib/utils/rules.ts b/lib/utils/rules.ts
index fb15707..c5c2348 100644
--- a/lib/utils/rules.ts
+++ b/lib/utils/rules.ts
@@ -63,6 +63,7 @@ import preferQuestionQuantifier from "../rules/prefer-question-quantifier"
 import preferRange from "../rules/prefer-range"
 import preferRegexpExec from "../rules/prefer-regexp-exec"
 import preferRegexpTest from "../rules/prefer-regexp-test"
+import preferResultArrayGroups from "../rules/prefer-result-array-groups"
 import preferStarQuantifier from "../rules/prefer-star-quantifier"
 import preferT from "../rules/prefer-t"
 import preferUnicodeCodepointEscapes from "../rules/prefer-unicode-codepoint-escapes"
@@ -140,6 +141,7 @@ export const rules = [
     preferRange,
     preferRegexpExec,
     preferRegexpTest,
+    preferResultArrayGroups,
     preferStarQuantifier,
     preferT,
     preferUnicodeCodepointEscapes,
diff --git a/lib/utils/ts-utils/index.ts b/lib/utils/ts-utils/index.ts
new file mode 100644
index 0000000..8d47125
--- /dev/null
+++ b/lib/utils/ts-utils/index.ts
@@ -0,0 +1,159 @@
+import type { Rule } from "eslint"
+import type * as TS from "typescript"
+// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- ignore
+type TypeScript = typeof import("typescript")
+
+/**
+ * Get TypeScript tools
+ */
+export function getTypeScriptTools(
+    context: Rule.RuleContext,
+): {
+    tsNodeMap: ReadonlyMap<unknown, TS.Node>
+    checker: TS.TypeChecker
+    usedTS: boolean
+    hasFullTypeInformation: boolean
+} {
+    const ts = getTypeScript()
+    const tsNodeMap: ReadonlyMap<unknown, TS.Node> =
+        context.parserServices.esTreeNodeToTSNodeMap
+    const checker: TS.TypeChecker =
+        context.parserServices.program &&
+        context.parserServices.program.getTypeChecker()
+    const usedTS = Boolean(ts && tsNodeMap && checker)
+    const hasFullTypeInformation =
+        usedTS && context.parserServices.hasFullTypeInformation !== false
+
+    return {
+        tsNodeMap,
+        checker,
+        usedTS,
+        hasFullTypeInformation,
+    }
+}
+
+let cacheTypeScript: TypeScript | undefined
+/**
+ * Get TypeScript tools
+ */
+export function getTypeScript(): TypeScript | undefined {
+    try {
+        // eslint-disable-next-line @typescript-eslint/no-require-imports -- ignore
+        return (cacheTypeScript ??= require("typescript"))
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ignore
+    } catch (e: any) {
+        if (e.code === "MODULE_NOT_FOUND") {
+            return undefined
+        }
+        throw e
+    }
+}
+
+/**
+ * Check if a given type is an array-like type or not.
+ */
+export function isArrayLikeObject(tsType: TS.Type): boolean {
+    const ts = getTypeScript()!
+    return (
+        isObject(tsType) &&
+        (tsType.objectFlags &
+            (ts.ObjectFlags.ArrayLiteral |
+                ts.ObjectFlags.EvolvingArray |
+                ts.ObjectFlags.Tuple)) !==
+            0
+    )
+}
+
+/**
+ * Check if a given type is an interface type or not.
+ */
+export function isClassOrInterface(
+    tsType: TS.Type,
+): tsType is TS.InterfaceType {
+    const ts = getTypeScript()!
+    return (
+        isObject(tsType) &&
+        (tsType.objectFlags & ts.ObjectFlags.ClassOrInterface) !== 0
+    )
+}
+
+/**
+ * Check if a given type is an object type or not.
+ */
+export function isObject(tsType: TS.Type): tsType is TS.ObjectType {
+    const ts = getTypeScript()!
+    return (tsType.flags & ts.TypeFlags.Object) !== 0
+}
+
+/**
+ * Check if a given type is a reference type or not.
+ */
+export function isReferenceObject(tsType: TS.Type): tsType is TS.TypeReference {
+    const ts = getTypeScript()!
+    return (
+        isObject(tsType) &&
+        (tsType.objectFlags & ts.ObjectFlags.Reference) !== 0
+    )
+}
+
+/**
+ * Check if a given type is a union-or-intersection type or not.
+ */
+export function isUnionOrIntersection(
+    tsType: TS.Type,
+): tsType is TS.UnionOrIntersectionType {
+    const ts = getTypeScript()!
+    return (tsType.flags & ts.TypeFlags.UnionOrIntersection) !== 0
+}
+/**
+ * Check if a given type is a type-parameter type or not.
+ */
+export function isTypeParameter(
+    tsType: TS.Type,
+): tsType is TS.UnionOrIntersectionType {
+    const ts = getTypeScript()!
+    return (tsType.flags & ts.TypeFlags.TypeParameter) !== 0
+}
+
+/**
+ * Check if a given type is an any type or not.
+ */
+export function isAny(tsType: TS.Type): boolean {
+    const ts = getTypeScript()!
+    return (tsType.flags & ts.TypeFlags.Any) !== 0
+}
+/**
+ * Check if a given type is an unknown type or not.
+ */
+export function isUnknown(tsType: TS.Type): boolean {
+    const ts = getTypeScript()!
+    return (tsType.flags & ts.TypeFlags.Unknown) !== 0
+}
+/**
+ * Check if a given type is an string-like type or not.
+ */
+export function isStringLine(tsType: TS.Type): boolean {
+    const ts = getTypeScript()!
+    return (tsType.flags & ts.TypeFlags.StringLike) !== 0
+}
+/**
+ * Check if a given type is an number-like type or not.
+ */
+export function isNumberLike(tsType: TS.Type): boolean {
+    const ts = getTypeScript()!
+    return (tsType.flags & ts.TypeFlags.NumberLike) !== 0
+}
+/**
+ * Check if a given type is an boolean-like type or not.
+ */
+export function isBooleanLike(tsType: TS.Type): boolean {
+    const ts = getTypeScript()!
+    return (tsType.flags & ts.TypeFlags.BooleanLike) !== 0
+}
+/**
+ * Check if a given type is an bigint-like type or not.
+ */
+export function isBigIntLike(tsType: TS.Type): boolean {
+    const ts = getTypeScript()!
+    return (tsType.flags & ts.TypeFlags.BigIntLike) !== 0
+}
diff --git a/lib/utils/type-tracker/index.ts b/lib/utils/type-tracker/index.ts
index 19f2dae..359271f 100644
--- a/lib/utils/type-tracker/index.ts
+++ b/lib/utils/type-tracker/index.ts
@@ -30,19 +30,24 @@ import { getJSDoc, parseTypeText } from "./jsdoc"
 import type { JSDocTypeNode } from "./jsdoc/jsdoctypeparser-ast"
 import { TypeIterable, UNKNOWN_ITERABLE } from "./type-data/iterable"
 import { getParent } from "../ast-utils"
-
-// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- ignore
-const ts: typeof import("typescript") = (() => {
-    try {
-        // eslint-disable-next-line @typescript-eslint/no-require-imports -- ignore
-        return require("typescript")
-    } catch (e) {
-        if (e.code === "MODULE_NOT_FOUND") {
-            return undefined
-        }
-        throw e
-    }
-})()
+import {
+    getTypeScript,
+    getTypeScriptTools,
+    isAny,
+    isArrayLikeObject,
+    isBigIntLike,
+    isBooleanLike,
+    isClassOrInterface,
+    isNumberLike,
+    isObject,
+    isReferenceObject,
+    isStringLine,
+    isTypeParameter,
+    isUnionOrIntersection,
+    isUnknown,
+} from "../ts-utils"
+
+const ts = getTypeScript()!
 
 export type TypeTracker = {
     isString: (node: ES.Expression) => boolean
@@ -63,12 +68,7 @@ export function createTypeTracker(context: Rule.RuleContext): TypeTracker {
         return cache
     }
 
-    const tsNodeMap: ReadonlyMap<unknown, TS.Node> =
-        context.parserServices.esTreeNodeToTSNodeMap
-    const checker: TS.TypeChecker =
-        context.parserServices.program &&
-        context.parserServices.program.getTypeChecker()
-    const availableTS = Boolean(ts && tsNodeMap && checker)
+    const { tsNodeMap, checker, usedTS } = getTypeScriptTools(context)
 
     const cacheTypeInfo = new WeakMap<ES.Expression, TypeInfo | null>()
 
@@ -95,7 +95,7 @@ export function createTypeTracker(context: Rule.RuleContext): TypeTracker {
         if (isString(node)) {
             return true
         }
-        if (availableTS) {
+        if (usedTS) {
             return false
         }
         return getType(node) == null
@@ -171,7 +171,7 @@ export function createTypeTracker(context: Rule.RuleContext): TypeTracker {
             return STRING
         }
 
-        if (availableTS) {
+        if (usedTS) {
             return getTypeByTs(node)
         }
 
@@ -505,7 +505,7 @@ export function createTypeTracker(context: Rule.RuleContext): TypeTracker {
             }
         }
 
-        return availableTS ? getTypeByTs(node) : null
+        return usedTS ? getTypeByTs(node) : null
     }
 
     /**
@@ -525,22 +525,19 @@ export function createTypeTracker(context: Rule.RuleContext): TypeTracker {
         /* eslint-enable complexity -- X( */
         tsType: TS.Type,
     ): TypeInfo | null {
-        if ((tsType.flags & ts.TypeFlags.StringLike) !== 0) {
+        if (isStringLine(tsType)) {
             return STRING
         }
-        if ((tsType.flags & ts.TypeFlags.NumberLike) !== 0) {
+        if (isNumberLike(tsType)) {
             return NUMBER
         }
-        if ((tsType.flags & ts.TypeFlags.BooleanLike) !== 0) {
+        if (isBooleanLike(tsType)) {
             return BOOLEAN
         }
-        if ((tsType.flags & ts.TypeFlags.BigIntLike) !== 0) {
+        if (isBigIntLike(tsType)) {
             return BIGINT
         }
-        if (
-            (tsType.flags & ts.TypeFlags.Any) !== 0 ||
-            (tsType.flags & ts.TypeFlags.Unknown) !== 0
-        ) {
+        if (isAny(tsType) || isUnknown(tsType)) {
             return null
         }
         if (isArrayLikeObject(tsType)) {
@@ -550,7 +547,7 @@ export function createTypeTracker(context: Rule.RuleContext): TypeTracker {
         if (isReferenceObject(tsType) && tsType.target !== tsType) {
             return getTypeFromTsType(tsType.target)
         }
-        if ((tsType.flags & ts.TypeFlags.TypeParameter) !== 0) {
+        if (isTypeParameter(tsType)) {
             const constraintType = getConstraintType(tsType)
             if (constraintType) {
                 return getTypeFromTsType(constraintType)
@@ -596,56 +593,6 @@ export function createTypeTracker(context: Rule.RuleContext): TypeTracker {
     }
 }
 
-/**
- * Check if a given type is an array-like type or not.
- */
-function isArrayLikeObject(tsType: TS.Type) {
-    return (
-        isObject(tsType) &&
-        (tsType.objectFlags &
-            (ts.ObjectFlags.ArrayLiteral |
-                ts.ObjectFlags.EvolvingArray |
-                ts.ObjectFlags.Tuple)) !==
-            0
-    )
-}
-
-/**
- * Check if a given type is an interface type or not.
- */
-function isClassOrInterface(tsType: TS.Type): tsType is TS.InterfaceType {
-    return (
-        isObject(tsType) &&
-        (tsType.objectFlags & ts.ObjectFlags.ClassOrInterface) !== 0
-    )
-}
-
-/**
- * Check if a given type is an object type or not.
- */
-function isObject(tsType: TS.Type): tsType is TS.ObjectType {
-    return (tsType.flags & ts.TypeFlags.Object) !== 0
-}
-
-/**
- * Check if a given type is a reference type or not.
- */
-function isReferenceObject(tsType: TS.Type): tsType is TS.TypeReference {
-    return (
-        isObject(tsType) &&
-        (tsType.objectFlags & ts.ObjectFlags.Reference) !== 0
-    )
-}
-
-/**
- * Check if a given type is a union-or-intersection type or not.
- */
-function isUnionOrIntersection(
-    tsType: TS.Type,
-): tsType is TS.UnionOrIntersectionType {
-    return (tsType.flags & ts.TypeFlags.UnionOrIntersection) !== 0
-}
-
 /** Get type from jsdoc type text */
 function typeTextToTypeInfo(typeText?: string): TypeInfo | null {
     if (typeText == null) {
diff --git a/typings/eslint-utils/index.d.ts b/typings/eslint-utils/index.d.ts
index 1587b2e..469b432 100644
--- a/typings/eslint-utils/index.d.ts
+++ b/typings/eslint-utils/index.d.ts
@@ -37,6 +37,9 @@ export function isCommentToken(
 export function isOpeningParenToken(
     token: eslint.AST.Token | ESTree.Comment,
 ): boolean
+export function isOpeningBracketToken(
+    token: eslint.AST.Token | ESTree.Comment,
+): boolean
 export function hasSideEffect(
     node: ESTree.Node,
     sourceCode: SourceCode,


Test patch:
diff --git a/tests/lib/rules/prefer-result-array-groups.ts b/tests/lib/rules/prefer-result-array-groups.ts
new file mode 100644
index 0000000..12a92fa
--- /dev/null
+++ b/tests/lib/rules/prefer-result-array-groups.ts
@@ -0,0 +1,413 @@
+import { RuleTester } from "eslint"
+import path from "path"
+import rule from "../../../lib/rules/prefer-result-array-groups"
+
+const tester = new RuleTester({
+    parserOptions: {
+        ecmaVersion: 2020,
+        sourceType: "module",
+    },
+})
+
+tester.run("prefer-result-array-groups", rule as any, {
+    valid: [
+        `
+        const regex = /regexp/
+        let match
+        while (match = regex.exec(foo)) {
+            const match = match[0]
+            // ...
+        }
+        `,
+        `
+        const regex = /a(b)c/
+        let match
+        while (match = regex.exec(foo)) {
+            const p1 = match[1]
+            // ...
+        }
+        `,
+        `
+        const regex = /a(b)c/
+        let match
+        while (match = regex.exec(foo)) {
+            const p1 = match?.[1]
+            // ...
+        }
+        `,
+        `
+        const regex = /a(b)c/
+        let match
+        while (match = regex.exec(foo)) {
+            const p1 = match.unknown
+            // ...
+        }
+        `,
+        `
+        const regex = /a(?<foo>b)c/
+        let match
+        while (match = regex.exec(foo)) {
+            const p1 = match.unknown
+            // ...
+        }
+        `,
+        // String.prototype.match()
+        `
+        const arr = "str".match(/regexp/)
+        const p1 = arr[1]
+        `,
+        `
+        const arr = "str".match(/a(b)c/)
+        const p1 = arr[1]
+        `,
+        `
+        const arr = "str".match(/a(?<foo>b)c/)
+        const p1 = arr.groups.foo
+        `,
+        `
+        const arr = "str".match(/a(?<foo>b)c/)
+        const p1 = arr.unknown
+        `,
+        `
+        const arr = unknown.match(/a(?<foo>b)c/)
+        const p1 = arr[1]
+        `,
+        `
+        const arr = "str".match(/a(?<foo>b)c/g)
+        const p1 = arr[1]
+        `,
+        // String.prototype.matchAll()
+        `
+        const matches = "str".matchAll(/a(?<foo>b)c/);
+        for (const match of matches) {
+            const p1 = match.groups.foo
+            // ..
+        }
+        `,
+        `
+        const matches = "str".matchAll(/a(b)c/);
+        for (const match of matches) {
+            const p1 = match[1]
+            // ..
+        }
+        `,
+        `
+        const matches = unknown.matchAll(/a(?<foo>b)c/);
+        for (const match of matches) {
+            const p1 = match.groups.foo
+            // ..
+        }
+        `,
+    ],
+    invalid: [
+        {
+            code: `
+            const regex = /a(?<foo>b)c/
+            let match
+            while (match = regex.exec(foo)) {
+                const p1 = match[1]
+                // ...
+            }
+            `,
+            output: `
+            const regex = /a(?<foo>b)c/
+            let match
+            while (match = regex.exec(foo)) {
+                const p1 = match.groups.foo
+                // ...
+            }
+            `,
+            errors: [
+                {
+                    message:
+                        "Unexpected indexed access for the named capturing group 'foo' from regexp result array.",
+                    line: 5,
+                    column: 28,
+                },
+            ],
+        },
+        {
+            code: `
+            const regex = /a(?<foo>b)c/
+            let match
+            while (match = regex.exec(foo)) {
+                const p1 = match?.[1]
+                // ...
+            }
+            `,
+            output: `
+            const regex = /a(?<foo>b)c/
+            let match
+            while (match = regex.exec(foo)) {
+                const p1 = match?.groups.foo
+                // ...
+            }
+            `,
+            errors: [
+                {
+                    message:
+                        "Unexpected indexed access for the named capturing group 'foo' from regexp result array.",
+                    line: 5,
+                    column: 28,
+                },
+            ],
+        },
+        {
+            code: `
+            const regex = /a(?<foo>b)c/
+            let match
+            while (match = regex.exec(foo)) {
+                const p1 = match?.[(1)]
+                // ...
+            }
+            `,
+            output: `
+            const regex = /a(?<foo>b)c/
+            let match
+            while (match = regex.exec(foo)) {
+                const p1 = match?.groups.foo
+                // ...
+            }
+            `,
+            errors: [
+                {
+                    message:
+                        "Unexpected indexed access for the named capturing group 'foo' from regexp result array.",
+                    line: 5,
+                    column: 28,
+                },
+            ],
+        },
+        {
+            code: `
+            const regex = /(a)(?<foo>b)c/
+            let match
+            while (match = regex.exec(foo)) {
+                const p1 = match[1]
+                const p2 = match[2]
+                // ...
+            }
+            `,
+            output: `
+            const regex = /(a)(?<foo>b)c/
+            let match
+            while (match = regex.exec(foo)) {
+                const p1 = match[1]
+                const p2 = match.groups.foo
+                // ...
+            }
+            `,
+            errors: [
+                {
+                    message:
+                        "Unexpected indexed access for the named capturing group 'foo' from regexp result array.",
+                    line: 6,
+                    column: 28,
+                },
+            ],
+        },
+        {
+            code: `
+            const regex = /(?<foo>a)(?<bar>b)c/
+            let match
+            while (match = regex.exec(foo)) {
+                const [,p1,p2] = match
+                // ...
+            }
+            `,
+            output: null,
+            errors: [
+                {
+                    message:
+                        "Unexpected indexed access for the named capturing group 'foo' from regexp result array.",
+                    line: 5,
+                    column: 25,
+                },
+                {
+                    message:
+                        "Unexpected indexed access for the named capturing group 'bar' from regexp result array.",
+                    line: 5,
+                    column: 28,
+                },
+            ],
+        },
+        // String.prototype.match()
+        {
+            code: `
+            const arr = "str".match(/a(?<foo>b)c/)
+            const p1 = arr[1]
+            `,
+            output: `
+            const arr = "str".match(/a(?<foo>b)c/)
+            const p1 = arr.groups.foo
+            `,
+            errors: [
+                {
+                    message:
+                        "Unexpected indexed access for the named capturing group 'foo' from regexp result array.",
+                    line: 3,
+                    column: 24,
+                },
+            ],
+        },
+        {
+            code: `
+            const arr = unknown.match(/a(?<foo>b)c/)
+            const p1 = arr[1]
+            `,
+            output: `
+            const arr = unknown.match(/a(?<foo>b)c/)
+            const p1 = arr.groups.foo
+            `,
+            options: [{ strictTypes: false }],
+            errors: [
+                {
+                    message:
+                        "Unexpected indexed access for the named capturing group 'foo' from regexp result array.",
+                    line: 3,
+                    column: 24,
+                },
+            ],
+        },
+        // String.prototype.matchAll()
+        {
+            code: `
+            const matches = "str".matchAll(/a(?<foo>b)c/);
+            for (const match of matches) {
+                const p1 = match[1]
+                // ..
+            }
+            `,
+            output: `
+            const matches = "str".matchAll(/a(?<foo>b)c/);
+            for (const match of matches) {
+                const p1 = match.groups.foo
+                // ..
+            }
+            `,
+            errors: [
+                {
+                    message:
+                        "Unexpected indexed access for the named capturing group 'foo' from regexp result array.",
+                    line: 4,
+                    column: 28,
+                },
+            ],
+        },
+        {
+            code: `
+            const matches = unknown.matchAll(/a(?<foo>b)c/);
+            for (const match of matches) {
+                const p1 = match[1]
+                // ..
+            }
+            `,
+            output: `
+            const matches = unknown.matchAll(/a(?<foo>b)c/);
+            for (const match of matches) {
+                const p1 = match.groups.foo
+                // ..
+            }
+            `,
+            options: [{ strictTypes: false }],
+            errors: [
+                {
+                    message:
+                        "Unexpected indexed access for the named capturing group 'foo' from regexp result array.",
+                    line: 4,
+                    column: 28,
+                },
+            ],
+        },
+        // with TypeScript
+        {
+            filename: path.join(__dirname, "prefer-result-array-groups.ts"),
+            code: `
+            const regex = /a(?<foo>b)c/
+            let match
+            while (match = regex.exec(foo)) {
+                const p1 = match[1]
+                // ...
+            }
+            `,
+            output: `
+            const regex = /a(?<foo>b)c/
+            let match
+            while (match = regex.exec(foo)) {
+                const p1 = match.groups!.foo
+                // ...
+            }
+            `,
+            parser: require.resolve("@typescript-eslint/parser"),
+            parserOptions: {
+                project: require.resolve("../../../tsconfig.json"),
+            },
+            errors: [
+                "Unexpected indexed access for the named capturing group 'foo' from regexp result array.",
+            ],
+        },
+        {
+            // If don't give type information.
+            code: `
+            const regex = /a(?<foo>b)c/
+            let match
+            while (match = regex.exec(foo)) {
+                const p1 = match[1]
+                // ...
+            }
+            `,
+            output: null,
+            parser: require.resolve("@typescript-eslint/parser"),
+            errors: [
+                "Unexpected indexed access for the named capturing group 'foo' from regexp result array.",
+            ],
+        },
+        {
+            // Not using RegExpExecArray
+            filename: path.join(__dirname, "prefer-result-array-groups.ts"),
+            code: `
+            const regex = /a(?<foo>b)c/
+            let match: any[] | null
+            while (match = regex.exec(foo)) {
+                const p1 = match[1]
+                // ...
+            }
+            `,
+            output: null,
+            parser: require.resolve("@typescript-eslint/parser"),
+            parserOptions: {
+                project: require.resolve("../../../tsconfig.json"),
+            },
+            errors: [
+                "Unexpected indexed access for the named capturing group 'foo' from regexp result array.",
+            ],
+        },
+        {
+            // Using `any` type.
+            filename: path.join(__dirname, "prefer-result-array-groups.ts"),
+            code: `
+            const regex = /a(?<foo>b)c/
+            let match: any
+            while (match = regex.exec(foo)) {
+                const p1 = match[1]
+                // ...
+            }
+            `,
+            output: `
+            const regex = /a(?<foo>b)c/
+            let match: any
+            while (match = regex.exec(foo)) {
+                const p1 = match.groups.foo
+                // ...
+            }
+            `,
+            parser: require.resolve("@typescript-eslint/parser"),
+            parserOptions: {
+                project: require.resolve("../../../tsconfig.json"),
+            },
+            errors: [
+                "Unexpected indexed access for the named capturing group 'foo' from regexp result array.",
+            ],
+        },
+    ],
+})