Skip to content

Commit d65e980

Browse files
Improved no-useless-non-capturing-group rule (#154)
* Imporved `no-useless-non-capturing-group` rule * Improved docs
1 parent 024c6d7 commit d65e980

File tree

4 files changed

+269
-111
lines changed

4 files changed

+269
-111
lines changed

docs/rules/no-useless-non-capturing-group.md

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ since: "v0.4.0"
1313

1414
## :book: Rule Details
1515

16-
This rule reports unnecessary Non-capturing group
16+
This rule reports unnecessary non-capturing group
1717

1818
<eslint-code-block fix>
1919

@@ -22,18 +22,50 @@ This rule reports unnecessary Non-capturing group
2222

2323
/* ✓ GOOD */
2424
var foo = /(?:abcd)?/
25-
var foo = /(?:ab|cd)/
25+
var foo = /a(?:ab|cd)/
2626

2727
/* ✗ BAD */
28+
var foo = /(?:ab|cd)/
2829
var foo = /(?:abcd)/
2930
var foo = /(?:[a-d])/
31+
var foo = /(?:[a-d])|e/
32+
var foo = /(?:a|(?:b|c)|d)/
3033
```
3134

3235
</eslint-code-block>
3336

3437
## :wrench: Options
3538

36-
Nothing.
39+
```json5
40+
{
41+
"regexp/no-useless-non-capturing-group": ["error", {
42+
"allowTop": true
43+
}]
44+
}
45+
```
46+
47+
- `"allowTop"`:
48+
Whether a top-level non-capturing group is allowed. Defaults to `false`.
49+
50+
Sometimes it's useful to wrap a whole pattern into a non-capturing group (e.g. when the pattern is used as a building block to construct more complex patterns). Use this option to allow top-level non-capturing groups.
51+
52+
<eslint-code-block fix>
53+
54+
```js
55+
/* eslint regexp/no-useless-non-capturing-group: ["error", { allowTop: true }] */
56+
57+
/* ✓ GOOD */
58+
var foo = /(?:abcd)/
59+
var foo = /(?:ab|cd)/
60+
var foo = /(?:abcd)/
61+
var foo = /(?:[a-d])/
62+
63+
/* ✗ BAD */
64+
var foo = /(?:[a-d])|e/
65+
var foo = /(?:a|(?:b|c)|d)/
66+
```
67+
68+
</eslint-code-block>
3769

3870
## :rocket: Version
3971

lib/rules/no-useless-non-capturing-group.ts

Lines changed: 64 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,27 @@
1+
import type { Group } from "regexpp/ast"
12
import type { RegExpVisitor } from "regexpp/visitor"
23
import type { RegExpContext } from "../utils"
34
import { canUnwrapped, createRule, defineRegexpVisitor } from "../utils"
45

6+
/**
7+
* Returns whether the given group is the top-level group of its pattern.
8+
*
9+
* A pattern with a top-level groups is of the form `/(?:...)/flags`.
10+
*/
11+
function isTopLevel(group: Group): boolean {
12+
const parent = group.parent
13+
if (parent.type === "Alternative" && parent.elements.length === 1) {
14+
const parentParent = parent.parent
15+
if (
16+
parentParent.type === "Pattern" &&
17+
parentParent.alternatives.length === 1
18+
) {
19+
return true
20+
}
21+
}
22+
return false
23+
}
24+
525
export default createRule("no-useless-non-capturing-group", {
626
meta: {
727
docs: {
@@ -11,62 +31,73 @@ export default createRule("no-useless-non-capturing-group", {
1131
recommended: false,
1232
},
1333
fixable: "code",
14-
schema: [],
34+
schema: [
35+
{
36+
type: "object",
37+
properties: {
38+
allowTop: { type: "boolean" },
39+
},
40+
additionalProperties: false,
41+
},
42+
],
1543
messages: {
1644
unexpected: "Unexpected quantifier Non-capturing group.",
1745
},
1846
type: "suggestion", // "problem",
1947
},
2048
create(context) {
49+
const allowTop = context.options[0]?.allowTop ?? false
50+
2151
/**
2252
* Create visitor
2353
*/
2454
function createVisitor({
2555
node,
2656
getRegexpLocation,
27-
getRegexpRange,
57+
fixReplaceNode,
2858
}: RegExpContext): RegExpVisitor.Handlers {
2959
return {
3060
onGroupEnter(gNode) {
31-
if (gNode.alternatives.length !== 1) {
32-
// Useful when using disjunctions.
33-
// e.g. /(?:a|b)/
34-
return
35-
}
36-
const alt = gNode.alternatives[0]
37-
if (alt.elements.length === 0) {
38-
// Ignore empty groups. You can check with another rule.
39-
// e.g. /(?:)/
40-
return
41-
}
42-
const parent = gNode.parent
43-
if (
44-
parent.type === "Quantifier" &&
45-
(alt.elements.length > 1 ||
46-
alt.elements[0].type === "Quantifier")
47-
) {
48-
// e.g. /(?:ab)?/
61+
if (allowTop && isTopLevel(gNode)) {
4962
return
5063
}
51-
if (!canUnwrapped(gNode, alt.raw)) {
52-
return
64+
65+
if (gNode.alternatives.length === 1) {
66+
const alt = gNode.alternatives[0]
67+
if (alt.elements.length === 0) {
68+
// Ignore empty groups. You can check with another rule.
69+
// e.g. /(?:)/
70+
return
71+
}
72+
const parent = gNode.parent
73+
if (
74+
parent.type === "Quantifier" &&
75+
(alt.elements.length > 1 ||
76+
alt.elements[0].type === "Quantifier")
77+
) {
78+
// e.g. /(?:ab)?/
79+
return
80+
}
81+
if (!canUnwrapped(gNode, alt.raw)) {
82+
return
83+
}
84+
} else {
85+
// the group might still be useless
86+
// e.g. /a(?:b|(?:c|d)|e)/
87+
const parent = gNode.parent
88+
if (parent.type !== "Alternative") {
89+
return
90+
}
91+
if (parent.elements.length !== 1) {
92+
return
93+
}
5394
}
5495

5596
context.report({
5697
node,
5798
loc: getRegexpLocation(gNode),
5899
messageId: "unexpected",
59-
fix(fixer) {
60-
const groupRange = getRegexpRange(gNode)
61-
const altRange = getRegexpRange(alt)
62-
if (!groupRange || !altRange) {
63-
return null
64-
}
65-
return [
66-
fixer.removeRange([groupRange[0], altRange[0]]),
67-
fixer.removeRange([altRange[1], groupRange[1]]),
68-
]
69-
},
100+
fix: fixReplaceNode(gNode, gNode.raw.slice(3, -1)),
70101
})
71102
},
72103
}

lib/utils/index.ts

Lines changed: 93 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type * as ESTree from "estree"
22
import type { RuleListener, RuleModule, PartialRuleModule } from "../types"
33
import type { RegExpVisitor } from "regexpp/visitor"
4-
import type { Alternative, Element, Node, Quantifier } from "regexpp/ast"
4+
import type { Element, Node, Quantifier } from "regexpp/ast"
55
import { RegExpParser, visitRegExpAST } from "regexpp"
66
import {
77
CALL,
@@ -752,90 +752,111 @@ export function quantToString(quant: Readonly<Quant>): string {
752752

753753
/* eslint-disable complexity -- X( */
754754
/**
755-
* Check the siblings to see if the regex doesn't change when unwrapped.
755+
* Returns whether the concatenation of the two string might create new escape
756+
* sequences or elements.
756757
*/
757-
export function canUnwrapped(
758+
function mightCreateNewElement(
758759
/* eslint-enable complexity -- X( */
759-
node: Element,
760-
text: string,
760+
before: string,
761+
after: string,
761762
): boolean {
762-
const parent = node.parent
763-
let target: Element, alternative: Alternative
764-
if (parent.type === "Quantifier") {
765-
alternative = parent.parent
766-
target = parent
767-
} else if (parent.type === "Alternative") {
768-
alternative = parent
769-
target = node
770-
} else {
763+
// control
764+
// \cA
765+
if (before.endsWith("\\c") && /^[a-z]/i.test(after)) {
771766
return true
772767
}
773-
const index = alternative.elements.indexOf(target)
774-
if (index === 0) {
768+
769+
// hexadecimal
770+
// \xFF \uFFFF
771+
if (
772+
/(?:^|[^\\])(?:\\{2})*\\(?:x[\dA-Fa-f]?|u[\dA-Fa-f]{0,3})$/.test(
773+
before,
774+
) &&
775+
/^[\da-f]/i.test(after)
776+
) {
775777
return true
776778
}
777-
if (/^\d+$/u.test(text)) {
778-
let prevIndex = index - 1
779-
let prev = alternative.elements[prevIndex]
780-
if (prev.type === "Backreference") {
781-
// e.g. /()\1[0]/ -> /()\10/
782-
return false
783-
}
784779

785-
while (
786-
prev.type === "Character" &&
787-
/^\d+$/u.test(prev.raw) &&
788-
prevIndex > 0
789-
) {
790-
prevIndex--
791-
prev = alternative.elements[prevIndex]
792-
}
793-
if (prev.type === "Character" && prev.raw === "{") {
794-
// e.g. /a{[0]}/ -> /a{0}/
795-
return false
796-
}
780+
// unicode
781+
// \u{FFFF}
782+
if (
783+
(/(?:^|[^\\])(?:\\{2})*\\u$/.test(before) &&
784+
/^\{[\da-f]*(?:\}[\s\S]*)?$/i.test(after)) ||
785+
(/(?:^|[^\\])(?:\\{2})*\\u\{[\da-f]*$/.test(before) &&
786+
/^(?:[\da-f]+\}?|\})/i.test(after))
787+
) {
788+
return true
797789
}
798-
if (/^[0-7]+$/u.test(text)) {
799-
const prev = alternative.elements[index - 1]
800-
if (prev.type === "Character" && /^\\[0-7]+$/u.test(prev.raw)) {
801-
// e.g. /\0[1]/ -> /\01/
802-
return false
803-
}
790+
791+
// octal
792+
// \077 \123
793+
if (
794+
(/(?:^|[^\\])(?:\\{2})*\\0[0-7]?$/.test(before) &&
795+
/^[0-7]/.test(after)) ||
796+
(/(?:^|[^\\])(?:\\{2})*\\[1-7]$/.test(before) && /^[0-7]/.test(after))
797+
) {
798+
return true
804799
}
805-
if (/^[\da-f]+$/iu.test(text)) {
806-
let prevIndex = index - 1
807-
let prev = alternative.elements[prevIndex]
808-
while (
809-
prev.type === "Character" &&
810-
/^[\da-f]+$/iu.test(prev.raw) &&
811-
prevIndex > 0
812-
) {
813-
prevIndex--
814-
prev = alternative.elements[prevIndex]
815-
}
816-
if (
817-
prev.type === "Character" &&
818-
(prev.raw === "\\x" || prev.raw === "\\u")
819-
) {
820-
// e.g. /\xF[F]/ -> /\xFF/
821-
// e.g. /\uF[F]FF/ -> /\xFFFF/
822-
return false
823-
}
800+
801+
// backreference
802+
// \12 \k<foo>
803+
if (
804+
(/(?:^|[^\\])(?:\\{2})*\\[1-9]\d*$/.test(before) &&
805+
/^\d/.test(after)) ||
806+
(/(?:^|[^\\])(?:\\{2})*\\k$/.test(before) && after.startsWith("<")) ||
807+
/(?:^|[^\\])(?:\\{2})*\\k<[^<>]*$/.test(before)
808+
) {
809+
return true
824810
}
825-
if (/^[a-z]+$/iu.test(text)) {
826-
if (index > 1) {
827-
const prev = alternative.elements[index - 1]
828-
if (prev.type === "Character" && prev.raw === "c") {
829-
const prev2 = alternative.elements[index - 2]
830-
if (prev2.type === "Character" && prev2.raw === "\\") {
831-
// e.g. /\c[M]/ -> /\cM/
832-
return false
833-
}
834-
}
835-
}
811+
812+
// property
813+
// \p{L} \P{L}
814+
if (
815+
(/(?:^|[^\\])(?:\\{2})*\\p$/i.test(before) &&
816+
/^\{[\w=]*(?:\}[\s\S]*)?$/.test(after)) ||
817+
(/(?:^|[^\\])(?:\\{2})*\\p\{[\w=]*$/i.test(before) &&
818+
/^[\w=]+(?:\}[\s\S]*)?$|^\}/.test(after))
819+
) {
820+
return true
836821
}
837822

838-
return true
823+
// quantifier
824+
// {1} {2,} {2,3}
825+
if (
826+
(/(?:^|[^\\])(?:\\{2})*\{\d*$/.test(before) && /^[\d,}]/.test(after)) ||
827+
(/(?:^|[^\\])(?:\\{2})*\{\d+,$/.test(before) &&
828+
/^(?:\d+(?:\}|$)|\})/.test(after)) ||
829+
(/(?:^|[^\\])(?:\\{2})*\{\d+,\d*$/.test(before) &&
830+
after.startsWith("}"))
831+
) {
832+
return true
833+
}
834+
835+
return false
836+
}
837+
838+
/**
839+
* Check the siblings to see if the regex doesn't change when unwrapped.
840+
*/
841+
export function canUnwrapped(node: Element, text: string): boolean {
842+
let textBefore, textAfter
843+
844+
const parent = node.parent
845+
if (parent.type === "Alternative") {
846+
textBefore = parent.raw.slice(0, node.start - parent.start)
847+
textAfter = parent.raw.slice(node.end - parent.start)
848+
} else if (parent.type === "Quantifier") {
849+
const alt = parent.parent
850+
textBefore = alt.raw.slice(0, node.start - alt.start)
851+
textAfter = alt.raw.slice(node.end - alt.start)
852+
} else {
853+
return true
854+
}
855+
856+
return (
857+
!mightCreateNewElement(textBefore, text) &&
858+
!mightCreateNewElement(text, textAfter)
859+
)
839860
}
840861

841862
/**

0 commit comments

Comments
 (0)