Skip to content

Commit 562ba24

Browse files
committed
feat: add support for ES2025 duplicate named capturing groups
BREAKING CHANGE: add support for ES2025 duplicate named capturing groups
1 parent a98196e commit 562ba24

19 files changed

+3144
-254
lines changed

src/ast.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,7 @@ export interface Backreference extends NodeBase {
432432
type: "Backreference"
433433
parent: Alternative | Quantifier
434434
ref: number | string
435-
resolved: CapturingGroup
435+
resolved: CapturingGroup[]
436436
}
437437

438438
/**

src/ecma-versions.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ export type EcmaVersion =
1010
| 2022
1111
| 2023
1212
| 2024
13-
export const latestEcmaVersion = 2024
13+
| 2025
14+
export const latestEcmaVersion = 2025

src/group-specifiers.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/**
2+
* Holds information for all GroupSpecifiers included in the pattern.
3+
*/
4+
export interface GroupSpecifiers {
5+
/**
6+
* @returns true if there are no GroupSpecifiers included in the pattern.
7+
*/
8+
isEmpty: () => boolean
9+
clear: () => void
10+
/**
11+
* Called when visiting the Alternative.
12+
* For ES2025, manage nesting with new Alternative scopes.
13+
*/
14+
enterAlternative: () => void
15+
/**
16+
* Called when leaving the Alternative.
17+
*/
18+
leaveAlternative: () => void
19+
/**
20+
* Checks whether the given group name is within the pattern.
21+
*/
22+
hasInPattern: (name: string) => boolean
23+
/**
24+
* Checks whether the given group name is within the current scope.
25+
*/
26+
hasInScope: (name: string) => boolean
27+
/**
28+
* Adds the given group name to the current scope.
29+
*/
30+
addToScope: (name: string) => void
31+
}
32+
33+
export class GroupSpecifiersAsES2018 implements GroupSpecifiers {
34+
private groupName = new Set<string>()
35+
36+
public clear(): void {
37+
this.groupName.clear()
38+
}
39+
40+
public isEmpty(): boolean {
41+
return !this.groupName.size
42+
}
43+
44+
public hasInPattern(name: string): boolean {
45+
return this.groupName.has(name)
46+
}
47+
48+
public hasInScope(name: string): boolean {
49+
return this.hasInPattern(name)
50+
}
51+
52+
public addToScope(name: string): void {
53+
this.groupName.add(name)
54+
}
55+
56+
// eslint-disable-next-line class-methods-use-this
57+
public enterAlternative(): void {
58+
// Prior to ES2025, it does not manage alternative scopes.
59+
}
60+
61+
// eslint-disable-next-line class-methods-use-this
62+
public leaveAlternative(): void {
63+
// Prior to ES2025, it does not manage alternative scopes.
64+
}
65+
}
66+
67+
export class GroupSpecifiersAsES2025 implements GroupSpecifiers {
68+
private groupNamesInAlternative = new Set<string>()
69+
private upperGroupNamesStack: Set<string>[] = []
70+
71+
private groupNamesInPattern = new Set<string>()
72+
73+
public clear(): void {
74+
this.groupNamesInAlternative.clear()
75+
this.upperGroupNamesStack.length = 0
76+
this.groupNamesInPattern.clear()
77+
}
78+
79+
public isEmpty(): boolean {
80+
return !this.groupNamesInPattern.size
81+
}
82+
83+
public enterAlternative(): void {
84+
this.upperGroupNamesStack.push(this.groupNamesInAlternative)
85+
this.groupNamesInAlternative = new Set(this.groupNamesInAlternative)
86+
}
87+
88+
public leaveAlternative(): void {
89+
this.groupNamesInAlternative = this.upperGroupNamesStack.pop()!
90+
}
91+
92+
public hasInPattern(name: string): boolean {
93+
return this.groupNamesInPattern.has(name)
94+
}
95+
96+
public hasInScope(name: string): boolean {
97+
return this.groupNamesInAlternative.has(name)
98+
}
99+
100+
public addToScope(name: string): void {
101+
this.groupNamesInAlternative.add(name)
102+
this.groupNamesInPattern.add(name)
103+
}
104+
}

src/parser.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ type AppendableNode =
3636

3737
const DUMMY_PATTERN: Pattern = {} as Pattern
3838
const DUMMY_FLAGS: Flags = {} as Flags
39-
const DUMMY_CAPTURING_GROUP: CapturingGroup = {} as CapturingGroup
4039

4140
function isClassSetOperand(
4241
node: UnicodeSetsCharacterClassElement,
@@ -148,12 +147,12 @@ class RegExpParserState {
148147

149148
for (const reference of this._backreferences) {
150149
const ref = reference.ref
151-
const group =
152-
typeof ref === "number"
153-
? this._capturingGroups[ref - 1]
154-
: this._capturingGroups.find((g) => g.name === ref)!
155-
reference.resolved = group
156-
group.references.push(reference)
150+
for (const group of typeof ref === "number"
151+
? [this._capturingGroups[ref - 1]]
152+
: this._capturingGroups.filter((g) => g.name === ref)) {
153+
reference.resolved.push(group)
154+
group.references.push(reference)
155+
}
157156
}
158157
}
159158

@@ -480,7 +479,7 @@ class RegExpParserState {
480479
end,
481480
raw: this.source.slice(start, end),
482481
ref,
483-
resolved: DUMMY_CAPTURING_GROUP,
482+
resolved: [],
484483
}
485484
parent.elements.push(node)
486485
this._backreferences.push(node)
@@ -747,14 +746,15 @@ export namespace RegExpParser {
747746
strict?: boolean
748747

749748
/**
750-
* ECMAScript version. Default is `2024`.
749+
* ECMAScript version. Default is `2025`.
751750
* - `2015` added `u` and `y` flags.
752751
* - `2018` added `s` flag, Named Capturing Group, Lookbehind Assertion,
753752
* and Unicode Property Escape.
754753
* - `2019`, `2020`, and `2021` added more valid Unicode Property Escapes.
755754
* - `2022` added `d` flag.
756755
* - `2023` added more valid Unicode Property Escapes.
757756
* - `2024` added `v` flag.
757+
* - `2025` added duplicate named capturing groups.
758758
*/
759759
ecmaVersion?: EcmaVersion
760760
}

src/validator.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import type { EcmaVersion } from "./ecma-versions"
22
import { latestEcmaVersion } from "./ecma-versions"
3+
import type { GroupSpecifiers } from "./group-specifiers"
4+
import {
5+
GroupSpecifiersAsES2018,
6+
GroupSpecifiersAsES2025,
7+
} from "./group-specifiers"
38
import { Reader } from "./reader"
49
import { newRegExpSyntaxError } from "./regexp-syntax-error"
510
import {
@@ -231,14 +236,15 @@ export namespace RegExpValidator {
231236
strict?: boolean
232237

233238
/**
234-
* ECMAScript version. Default is `2024`.
239+
* ECMAScript version. Default is `2025`.
235240
* - `2015` added `u` and `y` flags.
236241
* - `2018` added `s` flag, Named Capturing Group, Lookbehind Assertion,
237242
* and Unicode Property Escape.
238243
* - `2019`, `2020`, and `2021` added more valid Unicode Property Escapes.
239244
* - `2022` added `d` flag.
240245
* - `2023` added more valid Unicode Property Escapes.
241246
* - `2024` added `v` flag.
247+
* - `2025` added duplicate named capturing groups.
242248
*/
243249
ecmaVersion?: EcmaVersion
244250

@@ -631,7 +637,7 @@ export class RegExpValidator {
631637

632638
private _numCapturingParens = 0
633639

634-
private _groupNames = new Set<string>()
640+
private _groupSpecifiers: GroupSpecifiers
635641

636642
private _backreferenceNames = new Set<string>()
637643

@@ -643,6 +649,10 @@ export class RegExpValidator {
643649
*/
644650
public constructor(options?: RegExpValidator.Options) {
645651
this._options = options ?? {}
652+
this._groupSpecifiers =
653+
this.ecmaVersion >= 2025
654+
? new GroupSpecifiersAsES2025()
655+
: new GroupSpecifiersAsES2018()
646656
}
647657

648658
/**
@@ -763,7 +773,7 @@ export class RegExpValidator {
763773
if (
764774
!this._nFlag &&
765775
this.ecmaVersion >= 2018 &&
766-
this._groupNames.size > 0
776+
!this._groupSpecifiers.isEmpty()
767777
) {
768778
this._nFlag = true
769779
this.rewind(start)
@@ -1301,7 +1311,7 @@ export class RegExpValidator {
13011311
private consumePattern(): void {
13021312
const start = this.index
13031313
this._numCapturingParens = this.countCapturingParens()
1304-
this._groupNames.clear()
1314+
this._groupSpecifiers.clear()
13051315
this._backreferenceNames.clear()
13061316

13071317
this.onPatternEnter(start)
@@ -1322,7 +1332,7 @@ export class RegExpValidator {
13221332
this.raise(`Unexpected character '${c}'`)
13231333
}
13241334
for (const name of this._backreferenceNames) {
1325-
if (!this._groupNames.has(name)) {
1335+
if (!this._groupSpecifiers.hasInPattern(name)) {
13261336
this.raise("Invalid named capture referenced")
13271337
}
13281338
}
@@ -1380,7 +1390,9 @@ export class RegExpValidator {
13801390

13811391
this.onDisjunctionEnter(start)
13821392
do {
1393+
this._groupSpecifiers.enterAlternative()
13831394
this.consumeAlternative(i++)
1395+
this._groupSpecifiers.leaveAlternative()
13841396
} while (this.eat(VERTICAL_LINE))
13851397

13861398
if (this.consumeQuantifier(true)) {
@@ -1846,8 +1858,8 @@ export class RegExpValidator {
18461858
private consumeGroupSpecifier(): boolean {
18471859
if (this.eat(QUESTION_MARK)) {
18481860
if (this.eatGroupName()) {
1849-
if (!this._groupNames.has(this._lastStrValue)) {
1850-
this._groupNames.add(this._lastStrValue)
1861+
if (!this._groupSpecifiers.hasInScope(this._lastStrValue)) {
1862+
this._groupSpecifiers.addToScope(this._lastStrValue)
18511863
return true
18521864
}
18531865
this.raise("Duplicate capture group name")

test/fixtures/parser/literal/basic-valid-2015-u.json

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1690,7 +1690,9 @@
16901690
"end": 6,
16911691
"raw": "\\1",
16921692
"ref": 1,
1693-
"resolved": "♻️../0"
1693+
"resolved": [
1694+
"♻️../0"
1695+
]
16941696
}
16951697
]
16961698
}
@@ -1741,7 +1743,9 @@
17411743
"end": 3,
17421744
"raw": "\\1",
17431745
"ref": 1,
1744-
"resolved": "♻️../1"
1746+
"resolved": [
1747+
"♻️../1"
1748+
]
17451749
},
17461750
{
17471751
"type": "CapturingGroup",
@@ -2104,7 +2108,9 @@
21042108
"end": 34,
21052109
"raw": "\\10",
21062110
"ref": 10,
2107-
"resolved": "♻️../9"
2111+
"resolved": [
2112+
"♻️../9"
2113+
]
21082114
}
21092115
]
21102116
}
@@ -2465,7 +2471,9 @@
24652471
"end": 37,
24662472
"raw": "\\11",
24672473
"ref": 11,
2468-
"resolved": "♻️../10"
2474+
"resolved": [
2475+
"♻️../10"
2476+
]
24692477
}
24702478
]
24712479
}

test/fixtures/parser/literal/basic-valid-2015.json

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3800,7 +3800,9 @@
38003800
"end": 6,
38013801
"raw": "\\1",
38023802
"ref": 1,
3803-
"resolved": "♻️../0"
3803+
"resolved": [
3804+
"♻️../0"
3805+
]
38043806
}
38053807
]
38063808
}
@@ -3851,7 +3853,9 @@
38513853
"end": 3,
38523854
"raw": "\\1",
38533855
"ref": 1,
3854-
"resolved": "♻️../1"
3856+
"resolved": [
3857+
"♻️../1"
3858+
]
38553859
},
38563860
{
38573861
"type": "CapturingGroup",
@@ -4444,7 +4448,9 @@
44444448
"end": 34,
44454449
"raw": "\\10",
44464450
"ref": 10,
4447-
"resolved": "♻️../9"
4451+
"resolved": [
4452+
"♻️../9"
4453+
]
44484454
}
44494455
]
44504456
}
@@ -5135,7 +5141,9 @@
51355141
"end": 37,
51365142
"raw": "\\11",
51375143
"ref": 11,
5138-
"resolved": "♻️../10"
5144+
"resolved": [
5145+
"♻️../10"
5146+
]
51395147
}
51405148
]
51415149
}

0 commit comments

Comments
 (0)