regex-partial-match 1.0.0 β†’ 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,6 +10,8 @@ Unlike C/C++ (via [PCRE/PCRE2](https://www.pcre.org/original/doc/html/pcrepartia
10
10
 
11
11
  This library transforms regular expressions to best-effort support **partial matching**, allowing you to test if an incomplete string could potentially match the full pattern. This is particularly useful for real-time input validation, autocomplete systems, progressive form validation, stream chunk matching, etc.
12
12
 
13
+ As a side effect of the parse this requires, each `PartialMatchRegExp` also exposes a [`features`](#partialmatchregexpprototypefeatures-readonlysetregexfeature) set naming the syntactic constructs its pattern uses β€” useful for consumers that need to reason about a pattern without writing their own regex parser. For many features, a simple search in the [source](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/source) would be insufficient.
14
+
13
15
  **Based on an algorithm created by [Lucas Trzesniewski](https://github.com/ltrzesniewski)**, re-created for NPM via ISC license, with permission.
14
16
 
15
17
  ## πŸ“¦ Installation
@@ -54,10 +56,6 @@ The library transforms a regular expression by wrapping each [atomic element](ht
54
56
 
55
57
  This allows the pattern to match prefixes of the original pattern, enabling validation of incomplete input.
56
58
 
57
- ### Patterns with backreferences
58
-
59
- Backreferences cannot be handled by the `|$(?![\s\S])` transform alone because they are atomic β€” `\1` must match the entire captured string or fail, and its length is only known at runtime. `PartialMatchRegExp` first tries a full match natively, as a short-circuit β€” if the input already satisfies the whole pattern, there's nothing further to do. Otherwise it runs a "capture scan": a variant of the pattern with each backreference swapped for a lazy `(?:[\s\S]*?)` wildcard, so the group it depends on can still capture against a partial input β€” matching anything, or nothing at all, without needing to already know the backreference's value. Whatever that scan captures (or leaves undefined, if the group hasn't been reached yet) is then used to build a fresh partial-matching regex for this specific input, expanding the backreference character-by-character from the captured value with the same per-atom transform as the rest of the pattern. See [docs/backreferences.md](./docs/backreferences.md) for the full algorithm, including the prefer-longer post-processing that preserves correct captures for groups inside quantifiers.
60
-
61
59
  Since the library accepts only valid regular expressions [^2], this enables the algorithm to make lots of unguarded assumptions about the source of the expression.
62
60
 
63
61
  The library has been stress-tested with various regular expression features in isolation, and some in likely combination, but obviously it's an unbounded test space, and syntactically valid regular expressions nevertheless support contradictory patterns e.g.
@@ -71,6 +69,13 @@ Such combinations have not been tested.
71
69
  > [!NOTE]
72
70
  > See [Partial Match Parity](/docs/partial-match-parity.md) for full details on how the library compares to reference implementations
73
71
 
72
+ ### Patterns with backreferences
73
+
74
+ Backreferences cannot be handled by the `|$(?![\s\S])` transform alone because they are atomic β€” `\1` must match the entire captured string or fail, and its length is only known at runtime. `PartialMatchRegExp` first tries a full match natively, as a short-circuit β€” if the input already satisfies the whole pattern, there's nothing further to do. Otherwise it runs a "capture scan": a variant of the pattern with each backreference swapped for a lazy `(?:[\s\S]*?)` wildcard, so the group it depends on can still capture against a partial input β€” matching anything, or nothing at all, without needing to already know the backreference's value.
75
+
76
+ Whatever that scan captures (or leaves `undefined`, if the group hasn't been reached yet) is then used to build a fresh partial-matching regex for this specific input, expanding the backreference character-by-character from the captured value with the same per-atom transform as the rest of the pattern. See [docs/backreferences.md](./docs/backreferences.md) for the full algorithm, including the prefer-longer post-processing that preserves correct captures for groups inside quantifiers.
77
+
78
+
74
79
  ## βœ… Supported Features
75
80
 
76
81
  - πŸ”€ [Literal characters](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Literal_character)
@@ -171,17 +176,36 @@ The following cases remain atomic (full native value or exactly at true end of i
171
176
  - **`\k<name>` with no named capturing groups in the pattern.** [Annex B](https://tc39.es/ecma262/#sec-regular-expressions-patterns) tolerates this as the literal characters `k<a>`, but it's still treated as if it were a named backreference and matched atomically β€” `"k"` and `"k<"` will not partially match. A `\k` not immediately followed by a well-formed `<name>` reference is treated as the literal `k` and partially matches as usual.
172
177
  - **A backreference whose captured value can't be determined from a partial input.** This only affects the backreference site itself; it's strictly better than rejecting the input outright, and never accepts anything unsound.
173
178
 
174
- See also the [prefix-ambiguous top-level alternation](#prefix-ambiguous-top-level-alternation) caveat below β€” a related edge case where the internal capture scan resolves the wrong branch first.
179
+ #### Prefix-ambiguous top-level alternation
180
+
181
+ When a pattern uses top-level alternation where one branch is a strict prefix of another (e.g. `^(ab)\1|^(abc)\2`), the internal capture scan may select the shorter branch β€” because it uses `(?:[\s\S]*?)` which accepts zero characters β€” causing the final partial regex to fail for inputs that are valid prefixes of the longer branch. In such cases `exec` returns `null` even though the input is a valid partial match:
182
+
183
+ ```javascript
184
+ const partial = new PartialMatchRegExp(/^(ab)\1|^(abc)\2/);
185
+
186
+ partial.test("abca"); // false β€” but "abca" is a valid prefix of "abcabc" via the second branch
187
+ ```
188
+
189
+ > [!TIP]
190
+ > If alternate branches share a prefix, list the longer one first. The capture scan tries branches in order and stops at the first that accepts the partial input, so putting the longer branch first ensures it's the one selected:
191
+ >
192
+ > ```javascript
193
+ > const partial = new PartialMatchRegExp(/^(abc)\2|^(ab)\1/);
194
+ >
195
+ > partial.test("abca"); // true
196
+ > ```
197
+
198
+ See [docs/backreferences.md](./docs/backreferences.md) for why this happens (the internal capture scan resolving the wrong alternative first).
175
199
 
176
- [^1]:
177
- A bare `$` alone isn't sufficient here: under the `m` (multiline) flag β€” including one turned on locally via a `(?m:...)` [modifier](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Modifier) β€” `$` also matches immediately before *any* line terminator, not just the true end of input. That would let a `"\n"` the source pattern never allowed for be silently accepted as if the input had simply run out, e.g. `new PartialMatchRegExp(/^foobar/m)` would wrongly accept `"foo\nbaz"`. Appending `(?![\s\S])` narrows the disjunction down to strict end-of-input, regardless of multiline state.
200
+ [^1]:
201
+ A bare `$` alone isn't sufficient here: under the `m` (multiline) flag β€” including one turned on locally via a `(?m:...)` [modifier](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Modifier) β€” `$` also matches immediately before *any* line terminator, not just the true end of input. That would let a `"\n"` the source pattern never allowed for be silently accepted as if the input had simply run out, e.g. `new PartialMatchRegExp(/^foobar/m)` would wrongly accept `"foo\nbaz"`. Appending `(?![\s\S])` narrows the disjunction down to strict end-of-input, regardless of multiline state.
178
202
 
179
- See [chromium issue 536420076](https://issues.chromium.org/u/2/issues/536420076) for the underlying V8 bug that requires `$` to precede `(?![\s\S])` rather than using the lookahead alone.
203
+ See [chromium issue 536420076](https://issues.chromium.org/u/2/issues/536420076) for the underlying V8 bug that requires `$` to precede `(?![\s\S])` rather than using the lookahead alone.
180
204
 
181
- A shorter option, `(?-m:$)` β€” disabling multiline locally so `$` means strict end-of-input on its own β€” also sidesteps the bug and saves a few bytes per atom. However, modifier groups are new enough that support isn't universal, and feature-detecting them would add a fallback branch our test suite can't exercise honestly, since every engine that can realistically be tested against already supports them.
205
+ A shorter option, `(?-m:$)` β€” disabling multiline locally so `$` means strict end-of-input on its own β€” also sidesteps the bug and saves a few bytes per atom. However, modifier groups are new enough that support isn't universal, and feature-detecting them would add a fallback branch the test suite can't exercise honestly, since every engine that can realistically be tested against already supports them.
182
206
 
183
- [^2]:
184
- To remain lightweight, no runtime type validation is applied, so non-TypeScript consumers will be reliant on underlying errors thrown if used incorrectly.
207
+ [^2]:
208
+ To remain lightweight, no runtime type validation is applied, so non-TypeScript consumers will be reliant on underlying errors thrown if used incorrectly.
185
209
 
186
210
  ### Positive Lookbehinds
187
211
 
@@ -238,18 +262,6 @@ Hence, `[\p{RGI_Emoji_Flag_Sequence}]` will match `πŸ‡ΊπŸ‡³` as a whole, but not
238
262
 
239
263
  In [`v` mode](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/unicodeSets) expressions, where `[\q{abc}]` syntax is used in isolation (rather than its canonical use-case as a subtraction/intersection of another character class), this will also only match entirely or not at all. i.e. `abc` can match, but not partially.
240
264
 
241
- ### Prefix-ambiguous top-level alternation
242
-
243
- When a pattern uses top-level alternation where one branch is a strict prefix of another (e.g. `^(ab)\1|^(abc)\2`), the internal capture scan may select the shorter branch β€” because it uses `(?:[\s\S]*?)` which accepts zero characters β€” causing the final partial regex to fail for inputs that are valid prefixes of the longer branch. In such cases `exec` returns `null` even though the input is a valid partial match:
244
-
245
- ```javascript
246
- const partial = new PartialMatchRegExp(/^(ab)\1|^(abc)\2/);
247
-
248
- partial.test("abca"); // false β€” but "abca" is a valid prefix of "abcabc" via the second branch
249
- ```
250
-
251
- See [docs/backreferences.md](./docs/backreferences.md) for why this happens (the internal capture scan resolving the wrong alternative first).
252
-
253
265
  ## πŸ’‘ Examples
254
266
 
255
267
  ### Form Validation
@@ -352,6 +364,63 @@ When using `import 'regex-partial-match/extend'`, this method is added to `RegEx
352
364
 
353
365
  - A new `PartialMatchRegExp` that matches partial strings, created from the `RegExp` instance the method was called on.
354
366
 
367
+ ### `PartialMatchRegExp.prototype.features: ReadonlySet<RegexFeature>`
368
+
369
+ Building the partial-match regex requires walking the entire source pattern once. As a side effect of that same walk, each instance records which syntactic constructs its pattern actually uses, exposed as a `features` set β€” no separate scan of the source is performed to produce it.
370
+
371
+ This is useful for consumers building on top of `PartialMatchRegExp` who need to reason about which constructs a *specific* pattern uses, without writing their own regex parser to find out. Two concrete cases:
372
+
373
+ - **Flagging patterns likely to hit one of the [caveats](#caveats) documented above.** For example, a pattern combining `backreference` with `lookbehind`, `negativeLookahead`, or `negativeLookbehind` is a candidate for the [atomic-backreference caveat](#backreferences); one combining `backreference` with `disjunction` is a candidate for the [prefix-ambiguous top-level alternation caveat](#prefix-ambiguous-top-level-alternation). A consumer accepting user-supplied patterns can surface a warning instead of letting the edge case surprise someone later.
374
+ - **Restricting which constructs a product surface allows.** e.g. a system that only wants to accept "simple" patterns (no lookaround, no backreferences) from untrusted input can check `features` against an allow-list and reject the rest, without needing to hand-roll that check against the raw pattern source.
375
+
376
+ ```javascript
377
+ import PartialMatchRegExp from "regex-partial-match";
378
+
379
+ const partial = new PartialMatchRegExp(/^[a-z]+(?<domain>\.[a-z]+)\1/);
380
+
381
+ partial.features; // Set { "startAnchor", "characterClass", "quantifier", "namedGroup", "capturingGroup", "backreference" }
382
+ partial.features.has("backreference"); // true
383
+ ```
384
+
385
+ `RegexFeature` is a string union, exported alongside `PartialMatchRegExp`:
386
+
387
+ | Feature | Matches | Notes |
388
+ | --------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------- |
389
+ | `patternCharacter` | An ordinary literal character | |
390
+ | `startAnchor` | Top-level `^` | |
391
+ | `endAnchor` | Top-level `$` | |
392
+ | `wordBoundary` | Top-level `\b` | |
393
+ | `nonWordBoundary` | Top-level `\B` | |
394
+ | `lookahead` | `(?=...)` | |
395
+ | `negativeLookahead` | `(?!...)` | |
396
+ | `lookbehind` | `(?<=...)` | |
397
+ | `negativeLookbehind` | `(?<!...)` | |
398
+ | `backreference` | `\1` | |
399
+ | `namedBackreference` | `\k<name>` | |
400
+ | `namedGroup` | `(?<name>...)` | Always accompanied by `capturingGroup` β€” see below |
401
+ | `capturingGroup` | `(...)`, including named groups | |
402
+ | `nonCapturingGroup` | `(?:...)` | |
403
+ | `modifierGroup` | `(?ims:...)` | |
404
+ | `modifierGroupWithRemoval` | `(?ims-ims:...)` | Mutually exclusive with `modifierGroup` |
405
+ | `characterClass` | `[...]` | |
406
+ | `nestedCharacterClass` | `[...[...]...]` | `v` flag only |
407
+ | `classIntersection` | `&&` inside a character class | `v` flag only |
408
+ | `classSubtraction` | `--` inside a character class | `v` flag only |
409
+ | `disjunction` | `\|` | |
410
+ | `quantifier` | `*`, `+`, `?`, `{n}`, `{n,}`, `{n,m}` | |
411
+ | `unicodePropertyEscape` | `\p{...}`, `\P{...}` | `u`/`v` flag only β€” otherwise tagged `otherEscape` |
412
+ | `characterClassEscape` | `\d`, `\D`, `\w`, `\W`, `\s`, `\S` | |
413
+ | `controlEscape` | `\f`, `\n`, `\r`, `\t`, `\v` | |
414
+ | `controlLetterEscape` | `\cX` | |
415
+ | `hexEscapeSequence` | `\xXX` | |
416
+ | `unicodeEscapeSequence` | `\uXXXX`, `\u{...}` | |
417
+ | `otherEscape` | Any other `\X`, e.g. `\.`, `\0` | |
418
+
419
+ Two things worth knowing about how these tags line up with the grammar:
420
+
421
+ - **One ECMA-262 production can map to several tags.** `Assertion` alone covers `^`, `$`, `\b`, `\B`, and all four lookarounds β€” `features` splits it by whichever discriminant is easiest to read off during the walk (`^` vs `$`, `=` vs `!` after `(?<`, etc.), since that information is free at the point each construct is recognized.
422
+ - **A named capturing group always carries both `namedGroup` and `capturingGroup`.** The grammar treats a capturing group with a name and one without as the same production (`( GroupSpecifier? Disjunction )`), not two, so both tags are added together.
423
+
355
424
  ## πŸ“œ License
356
425
 
357
426
  ISC License - see [LICENSE](./LICENSE) file for details.
@@ -1,13 +1,16 @@
1
+ export type RegexFeature = "patternCharacter" | "startAnchor" | "endAnchor" | "wordBoundary" | "nonWordBoundary" | "lookahead" | "negativeLookahead" | "lookbehind" | "negativeLookbehind" | "backreference" | "namedBackreference" | "namedGroup" | "capturingGroup" | "nonCapturingGroup" | "modifierGroup" | "modifierGroupWithRemoval" | "characterClass" | "nestedCharacterClass" | "classIntersection" | "classSubtraction" | "disjunction" | "quantifier" | "unicodePropertyEscape" | "characterClassEscape" | "controlEscape" | "controlLetterEscape" | "hexEscapeSequence" | "unicodeEscapeSequence" | "otherEscape";
1
2
  export interface DynamicPath {
2
3
  originalCaptureScan: RegExp;
3
4
  preScan: RegExp;
4
5
  expand: (capture: RegExpExecArray) => string;
5
6
  }
6
- export type CompiledPartial = {
7
+ export type CompiledPartial = ({
7
8
  kind: "static";
8
9
  regex: RegExp;
9
10
  } | {
10
11
  kind: "dynamic";
11
12
  dynamic: DynamicPath;
13
+ }) & {
14
+ features: Set<RegexFeature>;
12
15
  };
13
16
  export declare const compilePartial: (regex: RegExp) => CompiledPartial;
@@ -1,7 +1,7 @@
1
1
  import escapeAtom from "./escapeAtom.js";
2
2
  const OCCURRENCES_REGEX = /\{\d+,?\d*\}/y;
3
3
  const NOT_NUMBERS_REGEX = /\D/g;
4
- const ALTERNATION_TO_END_OF_INPUT = "|$(?![\\s\\S]))";
4
+ const DISJUNCTION_TO_END_OF_INPUT = "|$(?![\\s\\S]))";
5
5
  const MAYBE_HAS_BACKREFERENCE = /\\[1-9]|\\k</;
6
6
  const isBackreference = (part) => typeof part !== "string";
7
7
  const isNumericBackreference = (part) => isBackreference(part) && typeof part.ref === "number";
@@ -10,6 +10,7 @@ function walk(regex) {
10
10
  const isUnicode = regex.unicode || regex.unicodeSets;
11
11
  let i = 0;
12
12
  let groupCount = 0;
13
+ const features = new Set();
13
14
  function extractSlice(length) {
14
15
  return source.slice(i, (i += length));
15
16
  }
@@ -17,7 +18,7 @@ function walk(regex) {
17
18
  var _a;
18
19
  const result = [];
19
20
  function appendOptional(length) {
20
- result.push("(?:" + extractSlice(length) + ALTERNATION_TO_END_OF_INPUT);
21
+ result.push("(?:" + extractSlice(length) + DISJUNCTION_TO_END_OF_INPUT);
21
22
  }
22
23
  function appendRaw(length) {
23
24
  result.push(extractSlice(length));
@@ -33,6 +34,7 @@ function walk(regex) {
33
34
  case "\\":
34
35
  switch (source[i + 1]) {
35
36
  case "c":
37
+ features.add("controlLetterEscape");
36
38
  appendOptional(3);
37
39
  break;
38
40
  case "k": {
@@ -41,6 +43,7 @@ function walk(regex) {
41
43
  appendOptional(2);
42
44
  }
43
45
  else {
46
+ features.add("namedBackreference");
44
47
  const start = i;
45
48
  const ref = source.slice(i + 3, referenceEnd);
46
49
  i = referenceEnd + 1;
@@ -49,6 +52,7 @@ function walk(regex) {
49
52
  break;
50
53
  }
51
54
  case "u":
55
+ features.add("unicodeEscapeSequence");
52
56
  if (isUnicode && source[i + 2] === "{") {
53
57
  appendOptional(source.indexOf("}", i) - i + 1);
54
58
  }
@@ -59,6 +63,7 @@ function walk(regex) {
59
63
  case "p":
60
64
  case "P":
61
65
  if (isUnicode) {
66
+ features.add("unicodePropertyEscape");
62
67
  appendOptional(source.indexOf("}", i) - i + 1);
63
68
  }
64
69
  else {
@@ -66,8 +71,25 @@ function walk(regex) {
66
71
  }
67
72
  break;
68
73
  case "x":
74
+ features.add("hexEscapeSequence");
69
75
  appendOptional(4);
70
76
  break;
77
+ case "b":
78
+ features.add("wordBoundary");
79
+ appendOptional(2);
80
+ break;
81
+ case "B":
82
+ features.add("nonWordBoundary");
83
+ appendOptional(2);
84
+ break;
85
+ case "f":
86
+ case "n":
87
+ case "r":
88
+ case "t":
89
+ case "v":
90
+ features.add("controlEscape");
91
+ appendOptional(2);
92
+ break;
71
93
  case "1":
72
94
  case "2":
73
95
  case "3":
@@ -77,6 +99,7 @@ function walk(regex) {
77
99
  case "7":
78
100
  case "8":
79
101
  case "9": {
102
+ features.add("backreference");
80
103
  NOT_NUMBERS_REGEX.lastIndex = i + 1;
81
104
  const nextNonDigit = NOT_NUMBERS_REGEX.exec(source);
82
105
  const start = i;
@@ -86,43 +109,80 @@ function walk(regex) {
86
109
  result.push({ ref, start, end });
87
110
  break;
88
111
  }
112
+ case "d":
113
+ case "D":
114
+ case "w":
115
+ case "W":
116
+ case "s":
117
+ case "S":
118
+ features.add("characterClassEscape");
119
+ appendOptional(2);
120
+ break;
89
121
  default:
122
+ features.add("otherEscape");
90
123
  appendOptional(2);
91
124
  break;
92
125
  }
93
126
  break;
94
127
  case "[": {
95
- let depth = 1, j = i + 1;
128
+ features.add("characterClass");
129
+ let depth = 1, j = i + 1, previousSetOperatorCharacter;
96
130
  while (depth) {
97
- switch (source[j]) {
131
+ const character = source[j];
132
+ switch (character) {
98
133
  case "\\":
99
134
  j += 2;
135
+ previousSetOperatorCharacter = undefined;
100
136
  continue;
101
137
  case "[":
102
- if (regex.unicodeSets)
138
+ if (regex.unicodeSets) {
139
+ features.add("nestedCharacterClass");
103
140
  depth++;
141
+ }
104
142
  break;
105
143
  case "]":
106
144
  depth--;
107
145
  break;
146
+ case "&":
147
+ if (regex.unicodeSets && previousSetOperatorCharacter === "&") {
148
+ features.add("classIntersection");
149
+ }
150
+ break;
151
+ case "-":
152
+ if (regex.unicodeSets && previousSetOperatorCharacter === "-") {
153
+ features.add("classSubtraction");
154
+ }
155
+ break;
108
156
  }
157
+ previousSetOperatorCharacter = character;
109
158
  j++;
110
159
  }
111
160
  appendOptional(j - i);
112
161
  break;
113
162
  }
114
- case "|":
115
163
  case "^":
164
+ features.add("startAnchor");
165
+ appendRaw(1);
166
+ break;
167
+ case "$":
168
+ features.add("endAnchor");
169
+ appendRaw(1);
170
+ break;
171
+ case "|":
172
+ features.add("disjunction");
173
+ appendRaw(1);
174
+ break;
116
175
  case "*":
117
176
  case "+":
118
177
  case "?":
119
- case "$":
178
+ features.add("quantifier");
120
179
  appendRaw(1);
121
180
  break;
122
181
  case "{": {
123
182
  OCCURRENCES_REGEX.lastIndex = i;
124
183
  const regExpExecArray = OCCURRENCES_REGEX.exec(source);
125
184
  if (regExpExecArray) {
185
+ features.add("quantifier");
126
186
  appendRaw(regExpExecArray[0].length);
127
187
  }
128
188
  else {
@@ -134,11 +194,13 @@ function walk(regex) {
134
194
  if (source[i + 1] == "?") {
135
195
  switch (source[i + 2]) {
136
196
  case ":":
197
+ features.add("nonCapturingGroup");
137
198
  result.push("(?:");
138
199
  i += 3;
139
- result.push(...process(), ALTERNATION_TO_END_OF_INPUT);
200
+ result.push(...process(), DISJUNCTION_TO_END_OF_INPUT);
140
201
  break;
141
202
  case "=":
203
+ features.add("lookahead");
142
204
  result.push("(?=");
143
205
  i += 3;
144
206
  result.push(...process(), ")");
@@ -148,46 +210,59 @@ function walk(regex) {
148
210
  case "s":
149
211
  case "m": {
150
212
  const flagsStart = i + 2, colonIndex = source.indexOf(":", flagsStart);
151
- result.push("(?" + source.slice(flagsStart, colonIndex) + ":");
213
+ const modifiers = source.slice(flagsStart, colonIndex);
214
+ features.add(modifiers.includes("-")
215
+ ? "modifierGroupWithRemoval"
216
+ : "modifierGroup");
217
+ result.push("(?" + modifiers + ":");
152
218
  i = colonIndex + 1;
153
219
  result.push(...process(), ")");
154
220
  break;
155
221
  }
156
222
  case "!":
223
+ features.add("negativeLookahead");
157
224
  appendRawGroup(3);
158
225
  break;
159
226
  case "<":
160
227
  switch (source[i + 3]) {
161
228
  case "=":
229
+ features.add("lookbehind");
230
+ appendRawGroup(4);
231
+ break;
162
232
  case "!":
233
+ features.add("negativeLookbehind");
163
234
  appendRawGroup(4);
164
235
  break;
165
236
  default:
237
+ features.add("namedGroup");
238
+ features.add("capturingGroup");
166
239
  ++groupCount;
167
240
  appendRaw(source.indexOf(">", i) - i + 1);
168
- result.push(...process(), ALTERNATION_TO_END_OF_INPUT);
241
+ result.push(...process(), DISJUNCTION_TO_END_OF_INPUT);
169
242
  break;
170
243
  }
171
244
  break;
172
245
  }
173
246
  }
174
247
  else {
248
+ features.add("capturingGroup");
175
249
  ++groupCount;
176
250
  appendRaw(1);
177
- result.push(...process(), ALTERNATION_TO_END_OF_INPUT);
251
+ result.push(...process(), DISJUNCTION_TO_END_OF_INPUT);
178
252
  }
179
253
  break;
180
254
  case ")":
181
255
  ++i;
182
256
  return result;
183
257
  default:
258
+ features.add("patternCharacter");
184
259
  appendOptional(isUnicode && ((_a = source.codePointAt(i)) !== null && _a !== void 0 ? _a : 0) > 0xffff ? 2 : 1);
185
260
  break;
186
261
  }
187
262
  }
188
263
  return result;
189
264
  }
190
- return { parts: process(), groupCount };
265
+ return { parts: process(), groupCount, features };
191
266
  }
192
267
  function render(parts, renderBackref) {
193
268
  return parts
@@ -201,7 +276,7 @@ function backrefToken(backref) {
201
276
  }
202
277
  function reclassifyOctalEscapes(parts, source, groupCount) {
203
278
  return parts.map((part) => isNumericBackreference(part) && part.ref > groupCount
204
- ? "(?:" + source.slice(part.start, part.end) + ALTERNATION_TO_END_OF_INPUT
279
+ ? "(?:" + source.slice(part.start, part.end) + DISJUNCTION_TO_END_OF_INPUT
205
280
  : part);
206
281
  }
207
282
  function spliceOriginalSource(source, backrefs) {
@@ -215,15 +290,16 @@ function spliceOriginalSource(source, backrefs) {
215
290
  }
216
291
  function expandCaptured(value, isUnicode) {
217
292
  return (isUnicode ? Array.from(value) : value.split(""))
218
- .map((atom) => "(?:" + escapeAtom(atom) + ALTERNATION_TO_END_OF_INPUT)
293
+ .map((atom) => "(?:" + escapeAtom(atom) + DISJUNCTION_TO_END_OF_INPUT)
219
294
  .join("");
220
295
  }
221
296
  export const compilePartial = (regex) => {
222
- const { parts, groupCount } = walk(regex);
297
+ const { parts, groupCount, features } = walk(regex);
223
298
  if (!MAYBE_HAS_BACKREFERENCE.test(regex.source)) {
224
299
  return {
225
300
  kind: "static",
226
- regex: new RegExp(render(parts, backrefToken), regex.flags)
301
+ regex: new RegExp(render(parts, backrefToken), regex.flags),
302
+ features
227
303
  };
228
304
  }
229
305
  const isUnicode = regex.unicode || regex.unicodeSets;
@@ -234,11 +310,13 @@ export const compilePartial = (regex) => {
234
310
  if (backreferences.length === 0) {
235
311
  return {
236
312
  kind: "static",
237
- regex: new RegExp(render(sanitizedParts, backrefToken), regex.flags)
313
+ regex: new RegExp(render(sanitizedParts, backrefToken), regex.flags),
314
+ features
238
315
  };
239
316
  }
240
317
  return {
241
318
  kind: "dynamic",
319
+ features,
242
320
  dynamic: {
243
321
  originalCaptureScan: new RegExp(spliceOriginalSource(regex.source, backreferences), regex.flags),
244
322
  preScan: new RegExp(render(parts, () => "(?:[\\s\\S]*?)"), regex.flags),
@@ -248,7 +326,7 @@ export const compilePartial = (regex) => {
248
326
  ? capture[backref.ref]
249
327
  : (_a = capture.groups) === null || _a === void 0 ? void 0 : _a[backref.ref];
250
328
  return captured === undefined
251
- ? "(?:" + backrefToken(backref) + ALTERNATION_TO_END_OF_INPUT
329
+ ? "(?:" + backrefToken(backref) + DISJUNCTION_TO_END_OF_INPUT
252
330
  : expandCaptured(captured, isUnicode);
253
331
  })
254
332
  }
@@ -1,3 +1,5 @@
1
+ import { type RegexFeature } from "./compilePartial.ts";
2
+ export type { RegexFeature };
1
3
  /**
2
4
  * A `RegExp` subclass that supports partial (prefix) matching.
3
5
  *
@@ -26,6 +28,7 @@
26
28
  */
27
29
  declare class PartialMatchRegExp extends RegExp {
28
30
  #private;
31
+ readonly features: ReadonlySet<RegexFeature>;
29
32
  constructor(pattern: RegExp | string, flags?: string);
30
33
  exec(input: string): RegExpExecArray | null;
31
34
  }
@@ -47,6 +47,7 @@ class PartialMatchRegExp extends RegExp {
47
47
  _a = this, _b = this, [({ set value(_c) { __classPrivateFieldSet(_a, _PartialMatchRegExp_dynamic, _c, "f"); } }).value, ({ set value(_c) { __classPrivateFieldSet(_b, _PartialMatchRegExp_static, _c, "f"); } }).value] = compiled.kind === "dynamic"
48
48
  ? [compiled.dynamic, null]
49
49
  : [null, compiled.regex];
50
+ this.features = compiled.features;
50
51
  }
51
52
  exec(input) {
52
53
  if (!__classPrivateFieldGet(this, _PartialMatchRegExp_dynamic, "f")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "regex-partial-match",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "A regular expression transform for partial matching",
5
5
  "devEngines": {
6
6
  "runtime": {