Skip to content

Commit 799fab3

Browse files
authored
Add support for ES2025 duplicate named capturing groups (#752)
* Add support for ES2025 * fix * Create odd-snakes-compare.md * update * rename function * update deps & ci script * update runs-on * revert runs-on * test * update typescript * test * fix * test * test * test * test * revert * use disallowAutomaticSingleRunInference * use disallowAutomaticSingleRunInference
1 parent ccf9673 commit 799fab3

15 files changed

+536
-280
lines changed

.changeset/odd-snakes-compare.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"eslint-plugin-regexp": minor
3+
---
4+
5+
Add support for ES2025 duplicate named capturing groups

.github/workflows/NodeCI.yml

+3-6
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,10 @@ jobs:
3030
uses: actions/setup-node@v4
3131
with:
3232
node-version: ${{ matrix.node }}
33-
- name: Install ESLint ${{ matrix.eslint }}
34-
# We use --legacy-peer-deps because we get a lot of dependency warnings when installing eslint v9
35-
run: npm i -D eslint@${{ matrix.eslint }} --legacy-peer-deps
3633
- name: Install Packages
37-
# run: npm ci
38-
# We use `npm i` because there is an error regarding dependencies when installing eslint v9.
39-
run: npm i -f
34+
run: npm ci
35+
- name: Install ESLint ${{ matrix.eslint }}
36+
run: npm i -D eslint@${{ matrix.eslint }}
4037
- name: Build
4138
run: npm run build
4239
- name: Test

lib/rules/no-useless-backreference.ts

+73-13
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ import type { RegExpContext } from "../utils"
1515
import { createRule, defineRegexpVisitor } from "../utils"
1616
import { mention } from "../utils/mention"
1717

18+
type MessageId =
19+
| "nested"
20+
| "disjunctive"
21+
| "intoNegativeLookaround"
22+
| "forward"
23+
| "backward"
24+
| "empty"
25+
1826
/**
1927
* Returns whether the list of ancestors from `from` to `to` contains a negated
2028
* lookaround.
@@ -35,16 +43,67 @@ function hasNegatedLookaroundInBetween(
3543
return false
3644
}
3745

46+
/**
47+
* Returns the problem information specifying the reason why the backreference is
48+
* useless.
49+
*/
50+
function getUselessProblem(
51+
backRef: Backreference,
52+
flags: ReadonlyFlags,
53+
): { messageId: MessageId; group: CapturingGroup; otherGroups: string } | null {
54+
const groups = [backRef.resolved].flat()
55+
56+
const problems: { messageId: MessageId; group: CapturingGroup }[] = []
57+
for (const group of groups) {
58+
const messageId = getUselessMessageId(backRef, group, flags)
59+
if (!messageId) {
60+
return null
61+
}
62+
problems.push({ messageId, group })
63+
}
64+
if (problems.length === 0) {
65+
return null
66+
}
67+
68+
let problemsToReport
69+
70+
// Gets problems that appear in the same disjunction.
71+
const problemsInSameDisjunction = problems.filter(
72+
(problem) => problem.messageId !== "disjunctive",
73+
)
74+
75+
if (problemsInSameDisjunction.length) {
76+
// Only report problems that appear in the same disjunction.
77+
problemsToReport = problemsInSameDisjunction
78+
} else {
79+
// If all groups appear in different disjunctions, report it.
80+
problemsToReport = problems
81+
}
82+
83+
const [{ messageId, group }, ...other] = problemsToReport
84+
let otherGroups = ""
85+
86+
if (other.length === 1) {
87+
otherGroups = " and another group"
88+
} else if (other.length > 1) {
89+
otherGroups = ` and other ${other.length} groups`
90+
}
91+
return {
92+
messageId,
93+
group,
94+
otherGroups,
95+
}
96+
}
97+
3898
/**
3999
* Returns the message id specifying the reason why the backreference is
40100
* useless.
41101
*/
42102
function getUselessMessageId(
43103
backRef: Backreference,
104+
group: CapturingGroup,
44105
flags: ReadonlyFlags,
45-
): string | null {
46-
const group = backRef.resolved
47-
106+
): MessageId | null {
48107
const closestAncestor = getClosestAncestor(backRef, group)
49108

50109
if (closestAncestor === group) {
@@ -93,16 +152,16 @@ export default createRule("no-useless-backreference", {
93152
},
94153
schema: [],
95154
messages: {
96-
nested: "Backreference {{ bref }} will be ignored. It references group {{ group }} from within that group.",
155+
nested: "Backreference {{ bref }} will be ignored. It references group {{ group }}{{ otherGroups }} from within that group.",
97156
forward:
98-
"Backreference {{ bref }} will be ignored. It references group {{ group }} which appears later in the pattern.",
157+
"Backreference {{ bref }} will be ignored. It references group {{ group }}{{ otherGroups }} which appears later in the pattern.",
99158
backward:
100-
"Backreference {{ bref }} will be ignored. It references group {{ group }} which appears before in the same lookbehind.",
159+
"Backreference {{ bref }} will be ignored. It references group {{ group }}{{ otherGroups }} which appears before in the same lookbehind.",
101160
disjunctive:
102-
"Backreference {{ bref }} will be ignored. It references group {{ group }} which is in another alternative.",
161+
"Backreference {{ bref }} will be ignored. It references group {{ group }}{{ otherGroups }} which is in another alternative.",
103162
intoNegativeLookaround:
104-
"Backreference {{ bref }} will be ignored. It references group {{ group }} which is in a negative lookaround.",
105-
empty: "Backreference {{ bref }} will be ignored. It references group {{ group }} which always captures zero characters.",
163+
"Backreference {{ bref }} will be ignored. It references group {{ group }}{{ otherGroups }} which is in a negative lookaround.",
164+
empty: "Backreference {{ bref }} will be ignored. It references group {{ group }}{{ otherGroups }} which always captures zero characters.",
106165
},
107166
type: "suggestion", // "problem",
108167
},
@@ -114,16 +173,17 @@ export default createRule("no-useless-backreference", {
114173
}: RegExpContext): RegExpVisitor.Handlers {
115174
return {
116175
onBackreferenceEnter(backRef) {
117-
const messageId = getUselessMessageId(backRef, flags)
176+
const problem = getUselessProblem(backRef, flags)
118177

119-
if (messageId) {
178+
if (problem) {
120179
context.report({
121180
node,
122181
loc: getRegexpLocation(backRef),
123-
messageId,
182+
messageId: problem.messageId,
124183
data: {
125184
bref: mention(backRef),
126-
group: mention(backRef.resolved),
185+
group: mention(problem.group),
186+
otherGroups: problem.otherGroups,
127187
},
128188
})
129189
}

lib/rules/prefer-named-backreference.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@ export default createRule("prefer-named-backreference", {
2424
}: RegExpContext): RegExpVisitor.Handlers {
2525
return {
2626
onBackreferenceEnter(bNode) {
27-
if (bNode.resolved.name && !bNode.raw.startsWith("\\k<")) {
27+
if (
28+
!bNode.ambiguous &&
29+
bNode.resolved.name &&
30+
!bNode.raw.startsWith("\\k<")
31+
) {
2832
context.report({
2933
node,
3034
loc: getRegexpLocation(bNode),

lib/utils/regexp-ast/case-variation.ts

+29-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type {
22
Alternative,
3+
Backreference,
4+
CapturingGroup,
35
CharacterClass,
46
CharacterClassElement,
57
CharacterSet,
@@ -15,6 +17,7 @@ import {
1517
toCharSet,
1618
isEmptyBackreference,
1719
toUnicodeSet,
20+
getClosestAncestor,
1821
} from "regexp-ast-analysis"
1922
import { assertNever, cachedFn } from "../util"
2023

@@ -139,19 +142,28 @@ export function isCaseVariant(
139142
// case-variant in Unicode mode
140143
return unicodeLike && d.kind === "word"
141144

142-
case "Backreference":
145+
case "Backreference": {
143146
// we need to check whether the associated capturing group
144147
// is case variant
145-
if (hasSomeDescendant(element, d.resolved)) {
148+
149+
const outside = getReferencedGroupsFromBackreference(
150+
d,
151+
).filter(
152+
(resolved) => !hasSomeDescendant(element, resolved),
153+
)
154+
if (outside.length === 0) {
146155
// the capturing group is part of the root element, so
147156
// we don't need to make an extra check
148157
return false
149158
}
150159

151160
return (
152161
!isEmptyBackreference(d, flags) &&
153-
isCaseVariant(d.resolved, flags)
162+
outside.some((resolved) =>
163+
isCaseVariant(resolved, flags),
164+
)
154165
)
166+
}
155167

156168
case "Character":
157169
case "CharacterClassRange":
@@ -179,3 +191,17 @@ export function isCaseVariant(
179191
},
180192
)
181193
}
194+
195+
/**
196+
* Returns the actually referenced capturing group from the given backreference.
197+
*/
198+
function getReferencedGroupsFromBackreference(
199+
backRef: Backreference,
200+
): CapturingGroup[] {
201+
return [backRef.resolved].flat().filter((group) => {
202+
const closestAncestor = getClosestAncestor(backRef, group)
203+
return (
204+
closestAncestor !== group && closestAncestor.type === "Alternative"
205+
)
206+
})
207+
}

0 commit comments

Comments
 (0)