Skip to content

Commit 3da3428

Browse files
authored
[FEATURE] Export discovered regex features during source walk (#70)
* Add features export * Correct placement of backreferences & prefix-ambiguous top-level alternation in docs * Remove confusing "multi-engine consistency" comparison from parity tables
1 parent ca9886c commit 3da3428

7 files changed

Lines changed: 567 additions & 76 deletions

File tree

README.md

Lines changed: 92 additions & 23 deletions
Large diffs are not rendered by default.

docs/CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111

1212
- Benchmark for construction cost
13+
- `features: ReadonlySet<RegexFeature>` field of the created `PartialMatchRegExp`, indicating the discovered features found during walk of the regex
14+
15+
### Fixed
16+
17+
- Moved documentation of caveat regarding prefix-ambiguous top-level alternation to its proper location alongside backreferences, since it only applies when they exist
18+
19+
### Removed
20+
21+
- Removed confusing "multi-engine consistency" comparison that differentiates the testing style of RE2 reference implementation from the parity documentation
1322

1423
## [1.0.0] - 2026-07-22
1524

docs/partial-match-parity.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ RE2 **explicitly excludes backreferences by design**. From `re2.h`: _"backrefere
6363
| First-match (NFA) vs longest-match (POSIX) semantics | ✅ ECMAScript / NFA first-match — `(a\|aa)\1` on `"aaaa"` yields `m[1]="a"`, not `"aa"` |
6464
| Capturing groups across anchoring modes | ✅ Covered — see groups tests |
6565
| Backreferences | ✅ Partial matching supported (RE2 excludes them entirely by design) |
66-
| Multi-engine consistency | N/A — JS has a single engine per runtime |
6766

6867
## ☕ OpenJDK (`java.util.regex``RegExTest.java`)
6968

@@ -106,4 +105,3 @@ The JDK **does** support backreferences, and `RegExTest.java` includes `backRefT
106105
| hitEnd() / prefix-only match ||| ✅ (`\=ps` / `\=ph` subject modifiers) || ✅ (non-empty exec result) |
107106
| requireEnd() ||||| ⚠️ Not exposed |
108107
| Backreference partial matching | ❌ unsupported dialect | ❌ excluded by design | ⚠️ Not a focus in testdata | ⚠️ full match only (`backRefTest`) ||
109-
| Multi-engine consistency ||||| N/A |

src/compilePartial.ts

Lines changed: 137 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import escapeAtom from "./escapeAtom.ts";
22

33
const OCCURRENCES_REGEX = /\{\d+,?\d*\}/y;
44
const NOT_NUMBERS_REGEX = /\D/g;
5-
const ALTERNATION_TO_END_OF_INPUT = "|$(?![\\s\\S]))";
5+
const DISJUNCTION_TO_END_OF_INPUT = "|$(?![\\s\\S]))";
66
const MAYBE_HAS_BACKREFERENCE = /\\[1-9]|\\k</;
77

88
interface NumericBackreference {
@@ -26,12 +26,46 @@ const isBackreference = (part: Part): part is Backreference =>
2626
const isNumericBackreference = (part: Part): part is NumericBackreference =>
2727
isBackreference(part) && typeof part.ref === "number";
2828

29-
function walk(regex: RegExp): { parts: Part[]; groupCount: number } {
29+
export type RegexFeature =
30+
| "patternCharacter"
31+
| "startAnchor"
32+
| "endAnchor"
33+
| "wordBoundary"
34+
| "nonWordBoundary"
35+
| "lookahead"
36+
| "negativeLookahead"
37+
| "lookbehind"
38+
| "negativeLookbehind"
39+
| "backreference"
40+
| "namedBackreference"
41+
| "namedGroup"
42+
| "capturingGroup"
43+
| "nonCapturingGroup"
44+
| "modifierGroup"
45+
| "modifierGroupWithRemoval"
46+
| "characterClass"
47+
| "nestedCharacterClass"
48+
| "classIntersection"
49+
| "classSubtraction"
50+
| "disjunction"
51+
| "quantifier"
52+
| "unicodePropertyEscape"
53+
| "characterClassEscape"
54+
| "controlEscape"
55+
| "controlLetterEscape"
56+
| "hexEscapeSequence"
57+
| "unicodeEscapeSequence"
58+
| "otherEscape";
59+
60+
function walk(
61+
regex: RegExp
62+
): { parts: Part[]; groupCount: number; features: Set<RegexFeature> } {
3063
const source = regex.source;
3164
const isUnicode = regex.unicode || regex.unicodeSets;
3265

3366
let i = 0;
3467
let groupCount = 0;
68+
const features = new Set<RegexFeature>();
3569

3670
function extractSlice(length: number): string {
3771
return source.slice(i, (i += length));
@@ -41,7 +75,7 @@ function walk(regex: RegExp): { parts: Part[]; groupCount: number } {
4175
const result: Part[] = [];
4276

4377
function appendOptional(length: number) {
44-
result.push("(?:" + extractSlice(length) + ALTERNATION_TO_END_OF_INPUT);
78+
result.push("(?:" + extractSlice(length) + DISJUNCTION_TO_END_OF_INPUT);
4579
}
4680

4781
function appendRaw(length: number) {
@@ -60,6 +94,7 @@ function walk(regex: RegExp): { parts: Part[]; groupCount: number } {
6094
case "\\":
6195
switch (source[i + 1]) {
6296
case "c":
97+
features.add("controlLetterEscape");
6398
appendOptional(3);
6499
break;
65100
case "k": {
@@ -68,6 +103,7 @@ function walk(regex: RegExp): { parts: Part[]; groupCount: number } {
68103
if (referenceEnd === -1) {
69104
appendOptional(2);
70105
} else {
106+
features.add("namedBackreference");
71107
const start = i;
72108
const ref = source.slice(i + 3, referenceEnd);
73109
i = referenceEnd + 1;
@@ -76,6 +112,7 @@ function walk(regex: RegExp): { parts: Part[]; groupCount: number } {
76112
break;
77113
}
78114
case "u":
115+
features.add("unicodeEscapeSequence");
79116
if (isUnicode && source[i + 2] === "{") {
80117
appendOptional(source.indexOf("}", i) - i + 1);
81118
} else {
@@ -85,14 +122,32 @@ function walk(regex: RegExp): { parts: Part[]; groupCount: number } {
85122
case "p":
86123
case "P":
87124
if (isUnicode) {
125+
features.add("unicodePropertyEscape");
88126
appendOptional(source.indexOf("}", i) - i + 1);
89127
} else {
90128
appendOptional(2);
91129
}
92130
break;
93131
case "x":
132+
features.add("hexEscapeSequence");
94133
appendOptional(4);
95134
break;
135+
case "b":
136+
features.add("wordBoundary");
137+
appendOptional(2);
138+
break;
139+
case "B":
140+
features.add("nonWordBoundary");
141+
appendOptional(2);
142+
break;
143+
case "f":
144+
case "n":
145+
case "r":
146+
case "t":
147+
case "v":
148+
features.add("controlEscape");
149+
appendOptional(2);
150+
break;
96151
case "1":
97152
case "2":
98153
case "3":
@@ -102,6 +157,7 @@ function walk(regex: RegExp): { parts: Part[]; groupCount: number } {
102157
case "7":
103158
case "8":
104159
case "9": {
160+
features.add("backreference");
105161
NOT_NUMBERS_REGEX.lastIndex = i + 1;
106162
const nextNonDigit = NOT_NUMBERS_REGEX.exec(source);
107163
const start = i;
@@ -111,43 +167,82 @@ function walk(regex: RegExp): { parts: Part[]; groupCount: number } {
111167
result.push({ ref, start, end });
112168
break;
113169
}
170+
case "d":
171+
case "D":
172+
case "w":
173+
case "W":
174+
case "s":
175+
case "S":
176+
features.add("characterClassEscape");
177+
appendOptional(2);
178+
break;
114179
default:
180+
features.add("otherEscape");
115181
appendOptional(2);
116182
break;
117183
}
118184
break;
119185
case "[": {
186+
features.add("characterClass");
120187
let depth = 1,
121-
j = i + 1;
188+
j = i + 1,
189+
previousSetOperatorCharacter: string | undefined;
122190
while (depth) {
123-
switch (source[j]) {
191+
const character = source[j];
192+
switch (character) {
124193
case "\\":
125194
j += 2;
195+
previousSetOperatorCharacter = undefined;
126196
continue;
127197
case "[":
128-
if (regex.unicodeSets) depth++;
198+
if (regex.unicodeSets) {
199+
features.add("nestedCharacterClass");
200+
depth++;
201+
}
129202
break;
130203
case "]":
131204
depth--;
132205
break;
206+
case "&":
207+
if (regex.unicodeSets && previousSetOperatorCharacter === "&") {
208+
features.add("classIntersection");
209+
}
210+
break;
211+
case "-":
212+
if (regex.unicodeSets && previousSetOperatorCharacter === "-") {
213+
features.add("classSubtraction");
214+
}
215+
break;
133216
}
217+
previousSetOperatorCharacter = character;
134218
j++;
135219
}
136220
appendOptional(j - i);
137221
break;
138222
}
139-
case "|":
140223
case "^":
224+
features.add("startAnchor");
225+
appendRaw(1);
226+
break;
227+
case "$":
228+
features.add("endAnchor");
229+
appendRaw(1);
230+
break;
231+
case "|":
232+
features.add("disjunction");
233+
appendRaw(1);
234+
break;
141235
case "*":
142236
case "+":
143237
case "?":
144-
case "$":
238+
features.add("quantifier");
145239
appendRaw(1);
146240
break;
147241
case "{": {
148242
OCCURRENCES_REGEX.lastIndex = i;
149243
const regExpExecArray = OCCURRENCES_REGEX.exec(source);
150244
if (regExpExecArray) {
245+
features.add("quantifier");
151246
appendRaw(regExpExecArray[0].length);
152247
} else {
153248
appendOptional(1);
@@ -158,11 +253,13 @@ function walk(regex: RegExp): { parts: Part[]; groupCount: number } {
158253
if (source[i + 1] == "?") {
159254
switch (source[i + 2]) {
160255
case ":":
256+
features.add("nonCapturingGroup");
161257
result.push("(?:");
162258
i += 3;
163-
result.push(...process(), ALTERNATION_TO_END_OF_INPUT);
259+
result.push(...process(), DISJUNCTION_TO_END_OF_INPUT);
164260
break;
165261
case "=":
262+
features.add("lookahead");
166263
result.push("(?=");
167264
i += 3;
168265
result.push(...process(), ")");
@@ -173,38 +270,53 @@ function walk(regex: RegExp): { parts: Part[]; groupCount: number } {
173270
case "m": {
174271
const flagsStart = i + 2,
175272
colonIndex = source.indexOf(":", flagsStart);
176-
result.push("(?" + source.slice(flagsStart, colonIndex) + ":");
273+
const modifiers = source.slice(flagsStart, colonIndex);
274+
features.add(
275+
modifiers.includes("-")
276+
? "modifierGroupWithRemoval"
277+
: "modifierGroup"
278+
);
279+
result.push("(?" + modifiers + ":");
177280
i = colonIndex + 1;
178281
result.push(...process(), ")");
179282
break;
180283
}
181284
case "!":
285+
features.add("negativeLookahead");
182286
appendRawGroup(3);
183287
break;
184288
case "<":
185289
switch (source[i + 3]) {
186290
case "=":
291+
features.add("lookbehind");
292+
appendRawGroup(4);
293+
break;
187294
case "!":
295+
features.add("negativeLookbehind");
188296
appendRawGroup(4);
189297
break;
190298
default:
299+
features.add("namedGroup");
300+
features.add("capturingGroup");
191301
++groupCount;
192302
appendRaw(source.indexOf(">", i) - i + 1);
193-
result.push(...process(), ALTERNATION_TO_END_OF_INPUT);
303+
result.push(...process(), DISJUNCTION_TO_END_OF_INPUT);
194304
break;
195305
}
196306
break;
197307
}
198308
} else {
309+
features.add("capturingGroup");
199310
++groupCount;
200311
appendRaw(1);
201-
result.push(...process(), ALTERNATION_TO_END_OF_INPUT);
312+
result.push(...process(), DISJUNCTION_TO_END_OF_INPUT );
202313
}
203314
break;
204315
case ")":
205316
++i;
206317
return result;
207318
default:
319+
features.add("patternCharacter");
208320
appendOptional(
209321
isUnicode && (source.codePointAt(i) ?? 0) > 0xffff ? 2 : 1
210322
);
@@ -214,7 +326,7 @@ function walk(regex: RegExp): { parts: Part[]; groupCount: number } {
214326
return result;
215327
}
216328

217-
return { parts: process(), groupCount };
329+
return { parts: process(), groupCount, features };
218330
}
219331

220332
function render(
@@ -239,7 +351,7 @@ function reclassifyOctalEscapes(
239351
): Part[] {
240352
return parts.map((part) =>
241353
isNumericBackreference(part) && part.ref > groupCount
242-
? "(?:" + source.slice(part.start, part.end) + ALTERNATION_TO_END_OF_INPUT
354+
? "(?:" + source.slice(part.start, part.end) + DISJUNCTION_TO_END_OF_INPUT
243355
: part
244356
);
245357
}
@@ -259,7 +371,7 @@ function spliceOriginalSource(
259371

260372
function expandCaptured(value: string, isUnicode: boolean): string {
261373
return (isUnicode ? Array.from(value) : value.split(""))
262-
.map((atom) => "(?:" + escapeAtom(atom) + ALTERNATION_TO_END_OF_INPUT)
374+
.map((atom) => "(?:" + escapeAtom(atom) + DISJUNCTION_TO_END_OF_INPUT)
263375
.join("");
264376
}
265377

@@ -269,17 +381,19 @@ export interface DynamicPath {
269381
expand: (capture: RegExpExecArray) => string;
270382
}
271383

272-
export type CompiledPartial =
384+
export type CompiledPartial = (
273385
| { kind: "static"; regex: RegExp }
274-
| { kind: "dynamic"; dynamic: DynamicPath };
386+
| { kind: "dynamic"; dynamic: DynamicPath }
387+
) & { features: Set<RegexFeature> };
275388

276389
export const compilePartial = (regex: RegExp): CompiledPartial => {
277-
const { parts, groupCount } = walk(regex);
390+
const { parts, groupCount, features } = walk(regex);
278391

279392
if (!MAYBE_HAS_BACKREFERENCE.test(regex.source)) {
280393
return {
281394
kind: "static",
282-
regex: new RegExp(render(parts, backrefToken), regex.flags)
395+
regex: new RegExp(render(parts, backrefToken), regex.flags),
396+
features
283397
};
284398
}
285399

@@ -291,12 +405,14 @@ export const compilePartial = (regex: RegExp): CompiledPartial => {
291405
if (backreferences.length === 0) {
292406
return {
293407
kind: "static",
294-
regex: new RegExp(render(sanitizedParts, backrefToken), regex.flags)
408+
regex: new RegExp(render(sanitizedParts, backrefToken), regex.flags),
409+
features
295410
};
296411
}
297412

298413
return {
299414
kind: "dynamic",
415+
features,
300416
dynamic: {
301417
originalCaptureScan: new RegExp(
302418
spliceOriginalSource(regex.source, backreferences),
@@ -312,7 +428,7 @@ export const compilePartial = (regex: RegExp): CompiledPartial => {
312428
? capture[backref.ref]
313429
: capture.groups?.[backref.ref];
314430
return captured === undefined
315-
? "(?:" + backrefToken(backref) + ALTERNATION_TO_END_OF_INPUT
431+
? "(?:" + backrefToken(backref) + DISJUNCTION_TO_END_OF_INPUT
316432
: expandCaptured(captured, isUnicode);
317433
})
318434
}

0 commit comments

Comments
 (0)