regex-partial-match 1.2.0 → 1.3.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.
Files changed (67) hide show
  1. package/README.md +146 -8
  2. package/lib/{extend.d.ts → extend/index.d.ts} +1 -1
  3. package/lib/{extend.js → extend/index.js} +1 -1
  4. package/lib/partialMatchRegExp/atomSyntax.d.ts +4 -0
  5. package/lib/partialMatchRegExp/atomSyntax.js +4 -0
  6. package/lib/partialMatchRegExp/backreferenceExpansion.d.ts +10 -0
  7. package/lib/partialMatchRegExp/backreferenceExpansion.js +1 -0
  8. package/lib/partialMatchRegExp/compilePartial/asNativeAtom.d.ts +2 -0
  9. package/lib/partialMatchRegExp/compilePartial/asNativeAtom.js +10 -0
  10. package/lib/partialMatchRegExp/compilePartial/asOptionalAtom.d.ts +1 -0
  11. package/lib/partialMatchRegExp/compilePartial/asOptionalAtom.js +4 -0
  12. package/lib/partialMatchRegExp/compilePartial/asPreScanPart.d.ts +2 -0
  13. package/lib/partialMatchRegExp/compilePartial/asPreScanPart.js +8 -0
  14. package/lib/partialMatchRegExp/compilePartial/caseFoldFlags.d.ts +1 -0
  15. package/lib/partialMatchRegExp/compilePartial/caseFoldFlags.js +3 -0
  16. package/lib/partialMatchRegExp/compilePartial/compiled.d.ts +29 -0
  17. package/lib/partialMatchRegExp/compilePartial/compiled.js +27 -0
  18. package/lib/partialMatchRegExp/compilePartial/flagsAtBackreference.d.ts +2 -0
  19. package/lib/partialMatchRegExp/compilePartial/flagsAtBackreference.js +4 -0
  20. package/lib/partialMatchRegExp/compilePartial/index.d.ts +2 -0
  21. package/lib/partialMatchRegExp/compilePartial/index.js +79 -0
  22. package/lib/partialMatchRegExp/compilePartial/longestBakedPrefixEndingInput.d.ts +1 -0
  23. package/lib/partialMatchRegExp/compilePartial/longestBakedPrefixEndingInput.js +50 -0
  24. package/lib/partialMatchRegExp/compilePartial/reclassifyLegacyEscapes.d.ts +2 -0
  25. package/lib/partialMatchRegExp/compilePartial/reclassifyLegacyEscapes.js +17 -0
  26. package/lib/partialMatchRegExp/compilePartial/renderParts.d.ts +2 -0
  27. package/lib/partialMatchRegExp/compilePartial/renderParts.js +9 -0
  28. package/lib/partialMatchRegExp/compilePartial/resolvedFromScan.d.ts +2 -0
  29. package/lib/partialMatchRegExp/compilePartial/resolvedFromScan.js +10 -0
  30. package/lib/partialMatchRegExp/compilePartial/spliceOriginalSource.d.ts +2 -0
  31. package/lib/partialMatchRegExp/compilePartial/spliceOriginalSource.js +12 -0
  32. package/lib/partialMatchRegExp/compilePartial/startsWithUnderFlags.d.ts +1 -0
  33. package/lib/partialMatchRegExp/compilePartial/startsWithUnderFlags.js +7 -0
  34. package/lib/partialMatchRegExp/compilePartial/toStatic.d.ts +3 -0
  35. package/lib/partialMatchRegExp/compilePartial/toStatic.js +4 -0
  36. package/lib/partialMatchRegExp/groupName.d.ts +2 -0
  37. package/lib/partialMatchRegExp/groupName.js +8 -0
  38. package/lib/partialMatchRegExp/index.d.ts +3 -0
  39. package/lib/partialMatchRegExp/index.js +3 -0
  40. package/lib/partialMatchRegExp/isComplete/index.d.ts +38 -0
  41. package/lib/partialMatchRegExp/isComplete/index.js +41 -0
  42. package/lib/partialMatchRegExp/isComplete/isMatchComplete.d.ts +3 -0
  43. package/lib/partialMatchRegExp/isComplete/isMatchComplete.js +14 -0
  44. package/lib/partialMatchRegExp/isComplete/partRole.d.ts +3 -0
  45. package/lib/partialMatchRegExp/isComplete/partRole.js +28 -0
  46. package/lib/partialMatchRegExp/isComplete/truncationProbe.d.ts +8 -0
  47. package/lib/partialMatchRegExp/isComplete/truncationProbe.js +105 -0
  48. package/lib/partialMatchRegExp/isComplete/truncationProbeCache.d.ts +4 -0
  49. package/lib/partialMatchRegExp/isComplete/truncationProbeCache.js +1 -0
  50. package/lib/partialMatchRegExp/legacyEscape.d.ts +2 -0
  51. package/lib/partialMatchRegExp/legacyEscape.js +18 -0
  52. package/lib/partialMatchRegExp/part.d.ts +24 -0
  53. package/lib/partialMatchRegExp/part.js +2 -0
  54. package/lib/partialMatchRegExp/partialMatchInternals.d.ts +2 -0
  55. package/lib/partialMatchRegExp/partialMatchInternals.js +2 -0
  56. package/lib/{partialMatchRegExp.d.ts → partialMatchRegExp/partialMatchRegExp.d.ts} +6 -3
  57. package/lib/{partialMatchRegExp.js → partialMatchRegExp/partialMatchRegExp.js} +23 -35
  58. package/lib/partialMatchRegExp/preferLongerCaptures.d.ts +1 -0
  59. package/lib/partialMatchRegExp/preferLongerCaptures.js +30 -0
  60. package/lib/partialMatchRegExp/regexFeatures.d.ts +6 -0
  61. package/lib/partialMatchRegExp/regexFeatures.js +47 -0
  62. package/lib/partialMatchRegExp/walk.d.ts +8 -0
  63. package/lib/{compilePartial.js → partialMatchRegExp/walk.js} +131 -162
  64. package/package.json +23 -13
  65. package/lib/compilePartial.d.ts +0 -26
  66. /package/lib/{escapeAtom.d.ts → partialMatchRegExp/escapeAtom.d.ts} +0 -0
  67. /package/lib/{escapeAtom.js → partialMatchRegExp/escapeAtom.js} +0 -0
package/README.md CHANGED
@@ -36,6 +36,39 @@ partial.test("hello world"); // true - full match
36
36
  partial.test("goodbye"); // false - cannot match
37
37
  ```
38
38
 
39
+ ### Telling a prefix from a complete match
40
+
41
+ `test()` and `exec()` answer "could this match?", which is `true` for a prefix and for a complete match alike. [`isComplete()`](#iscompletepartial-partialmatchregexp-match-regexpexecarray-boolean) separates the two, testing the original pattern instead doesn't [^1], giving the three states progressive validation actually needs:
42
+
43
+ ```javascript
44
+ import PartialMatchRegExp, { isComplete } from "regex-partial-match";
45
+
46
+ const partial = new PartialMatchRegExp(/^\d{4}-\d{2}-\d{2}/);
47
+
48
+ function state(input) {
49
+ const match = partial.exec(input);
50
+
51
+ if (match === null) return "invalid";
52
+ return isComplete(partial, match) ? "complete" : "incomplete";
53
+ }
54
+
55
+ state("20xx"); // 'invalid' - reject
56
+ state("2024"); // 'incomplete' - no error, keep typing
57
+ state("2024-06"); // 'incomplete' - no error, keep typing
58
+ state("2024-06-15"); // 'complete' - accept, enable submit
59
+ ```
60
+
61
+ [^1]:
62
+ Testing the original, untransformed pattern looks like it should answer this — "did the input fully satisfy the original pattern?" — but it asks a different question: whether the original matches *at all* here, not whether *this* match got there by truncation. The two agree almost always, but a truncation branch firing inside a zero-width assertion can make both return an identical result by different paths. See [Why the question can't be answered from the outside](#why-the-question-cant-be-answered-from-the-outside) for the case where they diverge.
63
+
64
+ ### A note on Tree-Shaking
65
+
66
+ If your environment doesn't tree-shake (e.g. Deno, or unbundled Node) and you have no use for `isComplete`, import `PartialMatchRegExp` from its own subpath instead of the default entry, to avoid pulling in `isComplete`'s code:
67
+
68
+ ```javascript
69
+ import PartialMatchRegExp from "regex-partial-match/partialMatchRegExp";
70
+ ```
71
+
39
72
  ### Extending RegExp.prototype
40
73
 
41
74
  ```javascript
@@ -48,7 +81,7 @@ partial.test("hel"); // true
48
81
 
49
82
  ## ⚙️ How It Works
50
83
 
51
- The library transforms a regular expression by wrapping each [atomic element](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions#atoms) in a [non-capturing group](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Non-capturing_group) with a [disjunction](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Disjunction) to a true-end-of-input sentinel (`$(?![\s\S])`[^1]):
84
+ The library transforms a regular expression by wrapping each [atomic element](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions#atoms) in a [non-capturing group](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Non-capturing_group) with a [disjunction](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Disjunction) to a true-end-of-input sentinel (`$(?![\s\S])`[^2]):
52
85
 
53
86
  ```javascript
54
87
  /abc/ → /(?:a|$(?![\s\S]))(?:b|$(?![\s\S]))(?:c|$(?![\s\S]))/
@@ -56,15 +89,17 @@ The library transforms a regular expression by wrapping each [atomic element](ht
56
89
 
57
90
  This allows the pattern to match prefixes of the original pattern, enabling validation of incomplete input.
58
91
 
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.
92
+ Since the library accepts only valid regular expressions [^3], this enables the algorithm to make lots of unguarded assumptions about the source of the expression.
60
93
 
61
94
  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.
62
95
 
63
96
  - `/\b\B/` - impossible to match both a word boundary and a non-word boundary
64
97
  - `/$^/` - end cannot come before start
65
98
  - `x{2}?` - lazy quantifiers are mutually exclusive to fixed-length assertions
99
+ - `/[]/` - an empty character class matches nothing
100
+ - `/(?=b)(?=c)/` - the next character cannot be two different characters
66
101
 
67
- Such combinations have not been tested.
102
+ Contradictory patterns are not comprehensively supported and may partially match inappropriately.
68
103
 
69
104
  > [!NOTE]
70
105
  > See [Partial Match Parity](/docs/partial-match-parity.md) for full details on how the library compares to reference implementations
@@ -113,6 +148,8 @@ The library is compiled to **ES2015** (ECMAScript 6). Certain regular expression
113
148
  - [**`v` (unicodeSets) flag**](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/unicodeSets) - ES2024+
114
149
  - [**Modifiers**](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Modifier) (`(?ims:...)`, `(?-ims:...)`, `(?i-ms:...)`) - ES2025+
115
150
 
151
+ Each of these applies only when the *original* pattern uses the feature — everything else, including construction, `exec()` and `test()`, holds to the ES2015 floor. [`isComplete()`](#iscompletepartial-partialmatchregexp-match-regexpexecarray-boolean) is the one exception: it always requires **ES2018+**, regardless of the pattern, since its internal probe uses named capturing groups.
152
+
116
153
  ## ⚠️ Caveats
117
154
 
118
155
  ### [`.test()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/test) behaviour and non-matching results from [`.exec()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/exec) and [`.match()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/match)
@@ -163,6 +200,22 @@ e.g.
163
200
  "a".match(/(?:x|$(?![\s\S]))/); // ['', index: 1, input: "a", groups: undefined];
164
201
  ```
165
202
 
203
+ > [!TIP]
204
+ > [`isComplete()`](#iscompletepartial-partialmatchregexp-match-regexpexecarray-boolean) answers this without a length check, and covers more than one: it reports `false` for the empty end-of-input match, since it exists only because the input ran out, and equally for a non-empty prefix like `"hello"` against `/hello world/`, which a length check would wave through.
205
+ >
206
+ > It describes a match, so ask it from `exec()` rather than `test()`:
207
+ >
208
+ > ```js
209
+ > import PartialMatchRegExp, { isComplete } from "regex-partial-match";
210
+ >
211
+ > const partial = new PartialMatchRegExp(/x/);
212
+ > const match = partial.exec("a"); // ['', index: 1, input: "a", groups: undefined]
213
+ >
214
+ > isComplete(partial, match); // false - the match depended on the input running out
215
+ > ```
216
+ >
217
+ > `false` is "not yet", not "never": for an unanchored `/x/`, `"a"` really is a viable prefix of `"ax"`. It is only when validating that which came before that it should be read as "no match".
218
+
166
219
  > [!NOTE]
167
220
  > A more ergonomic `test()` / `exec()` output [was explored](https://github.com/TomStrepsil/regex-partial-match/pull/51), but proved a complex problem space.
168
221
 
@@ -173,8 +226,11 @@ e.g.
173
226
  The following cases remain atomic (full native value or exactly at true end of input, no mid-value partial matching):
174
227
 
175
228
  - **Backreferences inside lookbehinds and negative lookarounds.** These are verbatim contexts — the value a lookbehind or negative lookahead requires must be fully present or fully absent, so there's no partial-prefix position to expand into.
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.
177
229
  - **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.
230
+ - **A forward reference, outside a lookbehind** — `\1` written before group 1 opens, or referencing it while it's still open (a self-reference inside the group's own body, e.g. `\1` in `/^(\1a)$/`). Its value can't come from the capture scan, which resolves it on a path it could never have taken, so it's left to the engine — which, per ECMAScript, always resolves it to empty there (it can't have participated yet, even on a later iteration of an enclosing quantifier). This costs nothing in practice: there's no real value being withheld. (Inside a lookbehind — already covered above — matching runs right-to-left, so a reference written first can still follow its own group's capture; that's exactly why the whole body stays atomic regardless of `forward`.)
231
+ - **A `\k<name>` referencing a name declared more than once**, which ECMAScript permits only across disjoint alternatives. This one is stricter than the rest — see [docs/backreferences.md](./docs/backreferences.md#duplicate-named-groups) for why, and for the workaround.
232
+
233
+ The case-folding a backreference's expansion agrees against tracks a locally-scoped `(?i:...)`/`(?-i:...)` [modifier](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Modifier) around that specific backreference, rather than only the pattern's own flags. This holds for a backreference the local scope makes *more* case-insensitive than the pattern is, or *less*. V8 versions before [the fix](https://issues.chromium.org/issues/447583670) released in Node.js 24.12 can still mishandle the locally-disabled case when the surrounding pattern has `i`; Chromium/Electron support depends on their bundled V8 version.
178
234
 
179
235
  #### Prefix-ambiguous top-level alternation
180
236
 
@@ -197,16 +253,17 @@ partial.test("abca"); // false — but "abca" is a valid prefix of "abcabc" via
197
253
 
198
254
  See [docs/backreferences.md](./docs/backreferences.md) for why this happens (the internal capture scan resolving the wrong alternative first).
199
255
 
200
- [^1]:
256
+ [^2]:
201
257
  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.
202
258
 
203
259
  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.
204
260
 
205
261
  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.
206
262
 
207
- [^2]:
263
+ [^3]:
208
264
  To remain lightweight, no runtime type validation is applied, so non-TypeScript consumers will be reliant on underlying errors thrown if used incorrectly.
209
265
 
266
+
210
267
  ### Positive Lookbehinds
211
268
 
212
269
  Whilst forming a match, a positive lookbehind must match in entirety, for the pattern to match. This is inherent in the concept of non-matching groups, since they are not match-worthy themselves, but just qualify matching atoms.
@@ -345,7 +402,7 @@ Useful for parsing log files, network streams, or any chunked data where records
345
402
 
346
403
  Extends `RegExp`. An instance behaves like a normal `RegExp` — `instanceof RegExp` is `true`, and `.test()`, `.exec()`, `.match()`, `.matchAll()`, `.replace()`, etc. all work as expected — but also matches any input string that is a valid prefix of the original pattern, in addition to full matches.
347
404
 
348
- Available via the default entry point of the package.
405
+ Available via the default entry point of the package, or via `regex-partial-match/partialMatchRegExp` — see [A note on Tree-Shaking](#a-note-on-tree-shaking).
349
406
 
350
407
  **Parameters:**
351
408
 
@@ -364,6 +421,87 @@ When using `import 'regex-partial-match/extend'`, this method is added to `RegEx
364
421
 
365
422
  - A new `PartialMatchRegExp` that matches partial strings, created from the `RegExp` instance the method was called on.
366
423
 
424
+ ### `isComplete(partial: PartialMatchRegExp, match: RegExpExecArray): boolean`
425
+
426
+ Reports whether a match `partial` produced is a **match** of the original pattern, or merely a **prefix** of it. `exec()` alone cannot say: it returns the same shape of array for `"h"`, `"hello"` and `"hello world"` against `/hello world/`.
427
+
428
+ Available as a named export of the default entry point: `import { isComplete } from 'regex-partial-match'`. Its probe requires ES2018+, so in an environment that doesn't tree-shake and has no use for it, import `PartialMatchRegExp` from `regex-partial-match/partialMatchRegExp` instead of the default entry to avoid bundling `isComplete`'s code; see [A note on Tree-Shaking](#a-note-on-tree-shaking).
429
+
430
+ **Parameters:**
431
+
432
+ - `partial` - The `PartialMatchRegExp` instance that produced `match`
433
+ - `match` - A match returned by `partial.exec()`
434
+
435
+ **Returns:**
436
+
437
+ - `true` when every atom matched literally, so the path the match took is one the original pattern could have taken itself.
438
+ - `false` when the match depended on the input running out — it took one of the `|$(?![\s\S])` branches described in [How It Works](#how-it-works). More input is needed, and the match's captures are **provisional**: a truncated path can capture values that no complete match ever produces.
439
+
440
+ ```javascript
441
+ import PartialMatchRegExp, { isComplete } from "regex-partial-match";
442
+
443
+ const partial = new PartialMatchRegExp(/hello world/);
444
+ const prefix = partial.exec("hello");
445
+
446
+ prefix[0]; // 'hello'
447
+ isComplete(partial, prefix); // false — a prefix, not a match
448
+
449
+ const complete = partial.exec("hello world");
450
+
451
+ complete[0]; // 'hello world'
452
+ isComplete(partial, complete); // true
453
+ ```
454
+
455
+ > [!NOTE]
456
+ > Complete is not the same as *final*. A greedy pattern like `/hello \w+/` matches `"hello world"` completely, and would still match more of `"hello worldly"`.
457
+
458
+ #### Why the question can't be answered from the outside
459
+
460
+ Neither of the obvious workarounds answers it:
461
+
462
+ - **Checking whether the match ends at the end of input.** True for every prefix, but also true for the common case of a complete match of input as it is typed — `/^\d{4}/` against `"2024"` ends at end of input and is complete.
463
+ - **Re-running the original pattern.** That asks whether the original matches *at all* at that position, not whether *this result* was arrived at by truncation. Where a truncation branch fires inside a zero-width assertion the two diverge, and the original can return an identical array by a different path:
464
+
465
+ ```javascript
466
+ const pattern = /a(?=(?:b(?:x|(c))d|b))/;
467
+ const partial = new PartialMatchRegExp(pattern);
468
+
469
+ partial.exec("ab"); // ['a', undefined] — truncated inside the assertion
470
+ pattern.exec("ab"); // ['a', undefined] — identical, and complete
471
+ pattern.exec("abcd"); // ['a', 'c'] — what more input actually produces
472
+ ```
473
+
474
+ Over `"ab"` the transformed pattern satisfies the atoms after `b` through *their* truncation branches — zero-width, so the match's own end never moves — before ever reaching the `(c)` group. Group 1 is left `undefined` where more input would define it.
475
+
476
+ The information only exists during matching. `isComplete()` recovers it by re-running the compiled pattern, [sticky](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/sticky) at `match.index`, with an empty named group in front of each truncation branch; an empty group is zero-width and always succeeds, so the twin walks the identical path, and any marker that comes back defined is a truncation branch the match actually took.
477
+
478
+ Recognising a truncation branch, a raw lookaround, or a group open back out of the rendered pattern is one classification, shared by every pass that needs it, rather than re-derived independently wherever it's needed.
479
+
480
+ The twin is built lazily, on first use, and never escapes the library — the array, `groups`, numbering and `d`-flag indices you hold are the ones `exec()` produced. It is cached once per instance for a pattern without [backreferences](#backreferences); for one with them the pattern is re-expanded per input, so the twin belongs to the expansion behind one particular match and a later match builds its own.
481
+
482
+ **Cost:** one anchored `exec` per call, plus the twin's construction on the first call that needs it. `exec()` and `test()` are untouched: the expansion a backreference match is answered from is held on the match itself, under a private symbol, so it costs a single field write and is collected with the match. Repeatedly asking about the *same* match is cheap; asking once per match on a backreference pattern pays for a new twin each time.
483
+
484
+ > [!CAUTION]
485
+ > `isComplete()` itself always requires ES2018+, regardless of the pattern: its truncation probe is built from named capturing groups internally, even for a pattern as plain as `/^abc/`. See [Browser Compatibility](#browser-compatibility) — every other method holds to the ES2015 floor stated there.
486
+
487
+ #### What it cannot see
488
+
489
+ `isComplete()` is necessary, but not sufficient, for a scanner whose output must be the same however the input is chunked. It cannot see a capture still growing *inside* an assertion: [`RepeatMatcher`](https://tc39.es/ecma262/#sec-runtime-semantics-repeatmatcher-abstract-operation) discards a repetition once it matches empty, so a truncation sentinel placed after a greedy repetition never gets a chance to fire there.
490
+
491
+ ```javascript
492
+ const partial = new PartialMatchRegExp(/a(?=(b+))/);
493
+ const match = partial.exec("ab");
494
+
495
+ match[1]; // 'b'
496
+ isComplete(partial, match); // true
497
+
498
+ /a(?=(b+))/.exec("abbX")[1]; // 'bb' — the same capture, over more input
499
+ ```
500
+
501
+ This isn't a defect: every atom in the `"ab"` match matched literally, so `isComplete()` answers correctly by its own definition. It just doesn't mean *cannot change with more input* — the same distinction the [note above](#iscompletepartial-partialmatchregexp-match-regexpexecarray-boolean) draws for `/hello \w+/`, one level further in.
502
+
503
+ A chunk-invariant scanner needs a second, independent check: whether any capture's end coincides with the end of the buffer, computable from [`d`-flag](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/hasIndices) indices without this library's help. Defer a match when either check says to.
504
+
367
505
  ### `PartialMatchRegExp.prototype.features: ReadonlySet<RegexFeature>`
368
506
 
369
507
  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.
@@ -416,7 +554,7 @@ partial.features.has("backreference"); // true
416
554
  | `controlLetterEscape` | `\cX` | |
417
555
  | `hexEscapeSequence` | `\xXX` | |
418
556
  | `unicodeEscapeSequence` | `\uXXXX`, `\u{...}` | |
419
- | `otherEscape` | Any other `\X`, e.g. `\.`, `\0` | |
557
+ | `otherEscape` | Any other `\X`, e.g. `\.` | `\0` alone is tagged `otherEscape` only under `u`/`v`; otherwise `backreference`, [like any other digit escape](docs/backreferences.md) |
420
558
 
421
559
  Three things worth knowing about how these tags line up with the grammar:
422
560
 
@@ -1,4 +1,4 @@
1
- import PartialMatchRegExp from "./partialMatchRegExp.ts";
1
+ import PartialMatchRegExp from "../partialMatchRegExp/partialMatchRegExp.ts";
2
2
  declare global {
3
3
  interface RegExp {
4
4
  /**
@@ -1,4 +1,4 @@
1
- import PartialMatchRegExp from "./partialMatchRegExp.js";
1
+ import PartialMatchRegExp from "../partialMatchRegExp/partialMatchRegExp.js";
2
2
  RegExp.prototype.toPartialMatchRegex = function () {
3
3
  return new PartialMatchRegExp(this);
4
4
  };
@@ -0,0 +1,4 @@
1
+ export declare const DISJUNCTION_TO_END_OF_INPUT = "|$(?![\\s\\S]))";
2
+ export declare const OPTIONAL_ATOM_OPENING = "(?:";
3
+ export declare const NAMED_GROUP_OPENING = "(?<";
4
+ export declare const ANY_CAPTURED_TEXT = "(?:[\\s\\S]*?)";
@@ -0,0 +1,4 @@
1
+ export const DISJUNCTION_TO_END_OF_INPUT = "|$(?![\\s\\S]))";
2
+ export const OPTIONAL_ATOM_OPENING = "(?:";
3
+ export const NAMED_GROUP_OPENING = "(?<";
4
+ export const ANY_CAPTURED_TEXT = "(?:[\\s\\S]*?)";
@@ -0,0 +1,10 @@
1
+ import type { Part } from "./part.ts";
2
+ import type { TruncationProbe } from "./isComplete/truncationProbe.ts";
3
+ export interface BackreferenceExpansion {
4
+ parts: Part[];
5
+ probe: TruncationProbe | undefined;
6
+ }
7
+ export declare const backreferenceExpansion: unique symbol;
8
+ export interface ExpandedMatch extends RegExpExecArray {
9
+ [backreferenceExpansion]?: BackreferenceExpansion;
10
+ }
@@ -0,0 +1 @@
1
+ export const backreferenceExpansion = Symbol("backreferenceExpansion");
@@ -0,0 +1,2 @@
1
+ import { type Backreference } from "../part.ts";
2
+ export default function asNativeAtom(backref: Backreference): string;
@@ -0,0 +1,10 @@
1
+ import { isNumericBackreference } from "../part.js";
2
+ import asOptionalAtom from "./asOptionalAtom.js";
3
+ function backrefToken(backref) {
4
+ return isNumericBackreference(backref)
5
+ ? "\\" + String(backref.ref)
6
+ : "\\k<" + backref.ref + ">";
7
+ }
8
+ export default function asNativeAtom(backref) {
9
+ return asOptionalAtom(backrefToken(backref));
10
+ }
@@ -0,0 +1 @@
1
+ export default function asOptionalAtom(text: string): string;
@@ -0,0 +1,4 @@
1
+ import { DISJUNCTION_TO_END_OF_INPUT } from "../atomSyntax.js";
2
+ export default function asOptionalAtom(text) {
3
+ return "(?:" + text + DISJUNCTION_TO_END_OF_INPUT;
4
+ }
@@ -0,0 +1,2 @@
1
+ import { type Part } from "../part.ts";
2
+ export default function asPreScanPart(part: Part): string;
@@ -0,0 +1,8 @@
1
+ import { ANY_CAPTURED_TEXT } from "../atomSyntax.js";
2
+ import { isBackreference } from "../part.js";
3
+ import asNativeAtom from "./asNativeAtom.js";
4
+ export default function asPreScanPart(part) {
5
+ if (!isBackreference(part))
6
+ return part;
7
+ return part.forward ? asNativeAtom(part) : ANY_CAPTURED_TEXT;
8
+ }
@@ -0,0 +1 @@
1
+ export default function caseFoldFlags(flags: string): string;
@@ -0,0 +1,3 @@
1
+ export default function caseFoldFlags(flags) {
2
+ return flags.replace(/[^iuv]/g, "");
3
+ }
@@ -0,0 +1,29 @@
1
+ import type { Part, RawLookaroundInfo } from "../part.ts";
2
+ import { type RegexFeature } from "../regexFeatures.ts";
3
+ export interface DynamicPath {
4
+ originalCaptureScan: RegExp;
5
+ preScan: RegExp;
6
+ expand: (capture: RegExpExecArray) => Part[];
7
+ expansionFitsCaptures: (expandedFrom: RegExpExecArray, match: RegExpExecArray, input: string) => boolean;
8
+ }
9
+ declare abstract class Compiled {
10
+ readonly rawLookarounds: readonly RawLookaroundInfo[];
11
+ readonly namedGroupOpenings: readonly string[];
12
+ private readonly _featureMask;
13
+ private _features?;
14
+ constructor(rawLookarounds: readonly RawLookaroundInfo[], namedGroupOpenings: readonly string[], _featureMask: number);
15
+ get features(): ReadonlySet<RegexFeature>;
16
+ }
17
+ export declare class CompiledStatic extends Compiled {
18
+ readonly regex: RegExp;
19
+ readonly parts: string[];
20
+ readonly kind = "static";
21
+ constructor(regex: RegExp, parts: string[], rawLookarounds: readonly RawLookaroundInfo[], namedGroupOpenings: readonly string[], featureMask: number);
22
+ }
23
+ export declare class CompiledDynamic extends Compiled {
24
+ readonly dynamic: DynamicPath;
25
+ readonly kind = "dynamic";
26
+ constructor(dynamic: DynamicPath, rawLookarounds: readonly RawLookaroundInfo[], namedGroupOpenings: readonly string[], featureMask: number);
27
+ }
28
+ export type CompiledPartial = CompiledStatic | CompiledDynamic;
29
+ export {};
@@ -0,0 +1,27 @@
1
+ import { featureSet } from "../regexFeatures.js";
2
+ class Compiled {
3
+ constructor(rawLookarounds, namedGroupOpenings, _featureMask) {
4
+ this.rawLookarounds = rawLookarounds;
5
+ this.namedGroupOpenings = namedGroupOpenings;
6
+ this._featureMask = _featureMask;
7
+ }
8
+ get features() {
9
+ var _a;
10
+ return ((_a = this._features) !== null && _a !== void 0 ? _a : (this._features = featureSet(this._featureMask)));
11
+ }
12
+ }
13
+ export class CompiledStatic extends Compiled {
14
+ constructor(regex, parts, rawLookarounds, namedGroupOpenings, featureMask) {
15
+ super(rawLookarounds, namedGroupOpenings, featureMask);
16
+ this.regex = regex;
17
+ this.parts = parts;
18
+ this.kind = "static";
19
+ }
20
+ }
21
+ export class CompiledDynamic extends Compiled {
22
+ constructor(dynamic, rawLookarounds, namedGroupOpenings, featureMask) {
23
+ super(rawLookarounds, namedGroupOpenings, featureMask);
24
+ this.dynamic = dynamic;
25
+ this.kind = "dynamic";
26
+ }
27
+ }
@@ -0,0 +1,2 @@
1
+ import type { Backreference } from "../part.ts";
2
+ export default function flagsAtBackreference(backref: Backreference, flags: string): string;
@@ -0,0 +1,4 @@
1
+ export default function flagsAtBackreference(backref, flags) {
2
+ const unicodeMode = flags.includes("v") ? "v" : flags.includes("u") ? "u" : "";
3
+ return (backref.caseInsensitive ? "i" : "") + unicodeMode;
4
+ }
@@ -0,0 +1,2 @@
1
+ import { type CompiledPartial } from "./compiled.ts";
2
+ export default function compilePartial(regex: RegExp): CompiledPartial;
@@ -0,0 +1,79 @@
1
+ import escapeAtom from "../escapeAtom.js";
2
+ import { walk } from "../walk.js";
3
+ import { OPTIONAL_ATOM_OPENING } from "../atomSyntax.js";
4
+ import { isBackreference } from "../part.js";
5
+ import { hasFeature } from "../regexFeatures.js";
6
+ import asOptionalAtom from "./asOptionalAtom.js";
7
+ import asPreScanPart from "./asPreScanPart.js";
8
+ import resolvedFromScan from "./resolvedFromScan.js";
9
+ import startsWithUnderFlags from "./startsWithUnderFlags.js";
10
+ import longestBakedPrefixEndingInput from "./longestBakedPrefixEndingInput.js";
11
+ import flagsAtBackreference from "./flagsAtBackreference.js";
12
+ import reclassifyLegacyEscapes from "./reclassifyLegacyEscapes.js";
13
+ import spliceOriginalSource from "./spliceOriginalSource.js";
14
+ import toStatic from "./toStatic.js";
15
+ import { CompiledDynamic } from "./compiled.js";
16
+ const MAYBE_HAS_BACKREFERENCE_REGEX = /\\[0-9]|\\k</;
17
+ const NEVER = "(?!)";
18
+ const GROUP_CLOSING = ")";
19
+ const ALTERNATION = "|";
20
+ const ONLY_AT_END_OF_INPUT = asOptionalAtom(NEVER);
21
+ export default function compilePartial(regex) {
22
+ const flags = regex.flags;
23
+ let walked = walk(regex, true);
24
+ if (hasFeature(walked.featureMask, "namedBackreference") &&
25
+ !hasFeature(walked.featureMask, "namedGroup")) {
26
+ walked = walk(regex, false);
27
+ }
28
+ const { parts, groupCount, featureMask, rawLookarounds, namedGroupOpenings } = walked;
29
+ if (!MAYBE_HAS_BACKREFERENCE_REGEX.test(regex.source)) {
30
+ return toStatic(parts, flags, rawLookarounds, namedGroupOpenings, featureMask);
31
+ }
32
+ const isUnicode = regex.unicode || regex.unicodeSets;
33
+ const sanitisedParts = isUnicode
34
+ ? parts
35
+ : reclassifyLegacyEscapes(parts, regex.source, groupCount);
36
+ const backreferences = sanitisedParts.filter(isBackreference);
37
+ if (backreferences.length === 0) {
38
+ return toStatic(sanitisedParts, flags, rawLookarounds, namedGroupOpenings, featureMask);
39
+ }
40
+ return new CompiledDynamic({
41
+ originalCaptureScan: new RegExp(spliceOriginalSource(regex.source, backreferences), flags),
42
+ preScan: new RegExp(sanitisedParts.map(asPreScanPart).join(""), flags),
43
+ expansionFitsCaptures: (expandedFrom, match, input) => {
44
+ var _a;
45
+ for (const backref of backreferences) {
46
+ const baked = resolvedFromScan(backref, expandedFrom);
47
+ if (baked === undefined)
48
+ continue;
49
+ const resolved = (_a = resolvedFromScan(backref, match)) !== null && _a !== void 0 ? _a : "";
50
+ const backrefFlags = flagsAtBackreference(backref, flags);
51
+ const consumed = longestBakedPrefixEndingInput(baked, input, backrefFlags);
52
+ if (!startsWithUnderFlags(resolved, consumed, backrefFlags))
53
+ return false;
54
+ }
55
+ return true;
56
+ },
57
+ expand: (capture) => {
58
+ const expanded = [];
59
+ for (const part of sanitisedParts) {
60
+ if (!isBackreference(part)) {
61
+ expanded.push(part);
62
+ continue;
63
+ }
64
+ const captured = resolvedFromScan(part, capture);
65
+ if (captured === undefined || captured === "") {
66
+ expanded.push(part);
67
+ continue;
68
+ }
69
+ const atoms = isUnicode ? Array.from(captured) : captured.split("");
70
+ expanded.push(OPTIONAL_ATOM_OPENING, part, ALTERNATION);
71
+ for (const atom of atoms) {
72
+ expanded.push(asOptionalAtom(escapeAtom(atom)));
73
+ }
74
+ expanded.push(ONLY_AT_END_OF_INPUT, GROUP_CLOSING);
75
+ }
76
+ return expanded;
77
+ }
78
+ }, rawLookarounds, namedGroupOpenings, featureMask);
79
+ }
@@ -0,0 +1 @@
1
+ export default function longestBakedPrefixEndingInput(baked: string, input: string, flags: string): string;
@@ -0,0 +1,50 @@
1
+ import escapeAtom from "../escapeAtom.js";
2
+ import caseFoldFlags from "./caseFoldFlags.js";
3
+ const SENTINEL = null;
4
+ function foldMatchersFor(atoms, flags) {
5
+ const matchers = Object.create(null);
6
+ if (!flags.includes("i"))
7
+ return matchers;
8
+ const foldFlags = caseFoldFlags(flags);
9
+ for (const atom of atoms) {
10
+ if (!(atom in matchers)) {
11
+ matchers[atom] = new RegExp("^" + escapeAtom(atom), foldFlags);
12
+ }
13
+ }
14
+ return matchers;
15
+ }
16
+ function atomsAgree(a, b, foldMatchers) {
17
+ var _a, _b;
18
+ if (a === SENTINEL || b === SENTINEL)
19
+ return false;
20
+ if (a === b)
21
+ return true;
22
+ return (_b = (_a = foldMatchers[a]) === null || _a === void 0 ? void 0 : _a.test(b)) !== null && _b !== void 0 ? _b : false;
23
+ }
24
+ export default function longestBakedPrefixEndingInput(baked, input, flags) {
25
+ if (baked === "")
26
+ return "";
27
+ const isUnicode = flags.includes("u") || flags.includes("v");
28
+ const bakedAtoms = isUnicode ? Array.from(baked) : baked.split("");
29
+ const inputAtoms = isUnicode ? Array.from(input) : input.split("");
30
+ const foldMatchers = foldMatchersFor(bakedAtoms, flags);
31
+ const sentinelIndex = bakedAtoms.length;
32
+ const totalLength = bakedAtoms.length + 1 + inputAtoms.length;
33
+ const atIndex = (index) => index < sentinelIndex
34
+ ? bakedAtoms[index]
35
+ : index === sentinelIndex
36
+ ? SENTINEL
37
+ : inputAtoms[index - sentinelIndex - 1];
38
+ const longestBorder = [0];
39
+ for (let index = 1; index < totalLength; index++) {
40
+ let matched = longestBorder[index - 1];
41
+ while (matched > 0 &&
42
+ !atomsAgree(atIndex(matched), atIndex(index), foldMatchers)) {
43
+ matched = longestBorder[matched - 1];
44
+ }
45
+ if (atomsAgree(atIndex(matched), atIndex(index), foldMatchers))
46
+ matched++;
47
+ longestBorder.push(matched);
48
+ }
49
+ return bakedAtoms.slice(0, longestBorder[totalLength - 1]).join("");
50
+ }
@@ -0,0 +1,2 @@
1
+ import { type Part } from "../part.ts";
2
+ export default function reclassifyLegacyEscapes(parts: Part[], source: string, groupCount: number): Part[];
@@ -0,0 +1,17 @@
1
+ import { legacyEscapeAtoms } from "../legacyEscape.js";
2
+ import { isNumericBackreference } from "../part.js";
3
+ import asOptionalAtom from "./asOptionalAtom.js";
4
+ export default function reclassifyLegacyEscapes(parts, source, groupCount) {
5
+ const reclassified = [];
6
+ for (const part of parts) {
7
+ if (isNumericBackreference(part) && (part.ref < 1 || part.ref > groupCount)) {
8
+ for (const atom of legacyEscapeAtoms(source.slice(part.start + 1, part.end))) {
9
+ reclassified.push(asOptionalAtom(atom));
10
+ }
11
+ }
12
+ else {
13
+ reclassified.push(part);
14
+ }
15
+ }
16
+ return reclassified;
17
+ }
@@ -0,0 +1,2 @@
1
+ import { type Part } from "../part.ts";
2
+ export default function renderParts(parts: readonly Part[]): string;
@@ -0,0 +1,9 @@
1
+ import { isBackreference } from "../part.js";
2
+ import asNativeAtom from "./asNativeAtom.js";
3
+ export default function renderParts(parts) {
4
+ let rendered = "";
5
+ for (const part of parts) {
6
+ rendered += isBackreference(part) ? asNativeAtom(part) : part;
7
+ }
8
+ return rendered;
9
+ }
@@ -0,0 +1,2 @@
1
+ import { type Backreference } from "../part.ts";
2
+ export default function resolvedFromScan(backref: Backreference, capture: RegExpExecArray): string | undefined;
@@ -0,0 +1,10 @@
1
+ import { decodeGroupName } from "../groupName.js";
2
+ import { isNumericBackreference } from "../part.js";
3
+ export default function resolvedFromScan(backref, capture) {
4
+ var _a;
5
+ if (backref.forward)
6
+ return undefined;
7
+ return isNumericBackreference(backref)
8
+ ? capture[backref.ref]
9
+ : (_a = capture.groups) === null || _a === void 0 ? void 0 : _a[decodeGroupName(backref.ref)];
10
+ }
@@ -0,0 +1,2 @@
1
+ import type { Backreference } from "../part.ts";
2
+ export default function spliceOriginalSource(source: string, backrefs: Backreference[]): string;
@@ -0,0 +1,12 @@
1
+ import { ANY_CAPTURED_TEXT } from "../atomSyntax.js";
2
+ export default function spliceOriginalSource(source, backrefs) {
3
+ let result = "";
4
+ let cursor = 0;
5
+ for (const { start, end, forward } of backrefs) {
6
+ if (forward)
7
+ continue;
8
+ result += source.slice(cursor, start) + ANY_CAPTURED_TEXT;
9
+ cursor = end;
10
+ }
11
+ return result + source.slice(cursor);
12
+ }
@@ -0,0 +1 @@
1
+ export default function startsWithUnderFlags(text: string, prefix: string, flags: string): boolean;
@@ -0,0 +1,7 @@
1
+ import escapeAtom from "../escapeAtom.js";
2
+ import caseFoldFlags from "./caseFoldFlags.js";
3
+ export default function startsWithUnderFlags(text, prefix, flags) {
4
+ if (prefix === "" || !flags.includes("i"))
5
+ return text.startsWith(prefix);
6
+ return new RegExp("^" + escapeAtom(prefix), caseFoldFlags(flags)).test(text);
7
+ }
@@ -0,0 +1,3 @@
1
+ import type { RawLookaroundInfo } from "../part.ts";
2
+ import { CompiledStatic } from "./compiled.ts";
3
+ export default function toStatic(parts: string[], flags: string, rawLookarounds: readonly RawLookaroundInfo[], namedGroupOpenings: readonly string[], featureMask: number): CompiledStatic;