Skip to content

Commit d1b30e0

Browse files
committed
feat: add support for ES2025 duplicate named capturing groups
1 parent a98196e commit d1b30e0

9 files changed

+2615
-82
lines changed

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: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -747,14 +747,15 @@ export namespace RegExpParser {
747747
strict?: boolean
748748

749749
/**
750-
* ECMAScript version. Default is `2024`.
750+
* ECMAScript version. Default is `2025`.
751751
* - `2015` added `u` and `y` flags.
752752
* - `2018` added `s` flag, Named Capturing Group, Lookbehind Assertion,
753753
* and Unicode Property Escape.
754754
* - `2019`, `2020`, and `2021` added more valid Unicode Property Escapes.
755755
* - `2022` added `d` flag.
756756
* - `2023` added more valid Unicode Property Escapes.
757757
* - `2024` added `v` flag.
758+
* - `2025` added duplicate named capturing groups.
758759
*/
759760
ecmaVersion?: EcmaVersion
760761
}

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")
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"options": {
3+
"strict": false,
4+
"ecmaVersion": 2024
5+
},
6+
"patterns": {
7+
"/(?<year>[0-9]{4})-[0-9]{2}|[0-9]{2}-(?<year>[0-9]{4})/": {
8+
"error": {
9+
"message": "Invalid regular expression: /(?<year>[0-9]{4})-[0-9]{2}|[0-9]{2}-(?<year>[0-9]{4})/: Duplicate capture group name",
10+
"index": 45
11+
}
12+
}
13+
}
14+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"options": {
3+
"strict": false,
4+
"ecmaVersion": 2025
5+
},
6+
"patterns": {
7+
"/(?<year>[0-9]{4})-(?<year>[0-9]{2})/": {
8+
"error": {
9+
"message": "Invalid regular expression: /(?<year>[0-9]{4})-(?<year>[0-9]{2})/: Duplicate capture group name",
10+
"index": 27
11+
}
12+
}
13+
}
14+
}

0 commit comments

Comments
 (0)