regex-partial-match 0.4.0 → 1.0.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 +138 -70
- package/lib/compilePartial.d.ts +13 -0
- package/lib/compilePartial.js +256 -0
- package/lib/escapeAtom.d.ts +1 -0
- package/lib/escapeAtom.js +5 -0
- package/lib/extend.d.ts +3 -4
- package/lib/extend.js +2 -2
- package/lib/partialMatchRegExp.d.ts +32 -0
- package/lib/partialMatchRegExp.js +113 -0
- package/package.json +13 -7
- package/lib/index.d.ts +0 -31
- package/lib/index.js +0 -199
package/README.md
CHANGED
|
@@ -2,31 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
A zero-dependency regular expression transform for partial matching, enabling validation of incomplete input strings against regex patterns.
|
|
4
4
|
|
|
5
|
-
## Problem statement
|
|
5
|
+
## 🧩 Problem statement
|
|
6
6
|
|
|
7
7
|
Unlike C/C++ (via [PCRE/PCRE2](https://www.pcre.org/original/doc/html/pcrepartial.html), [RE2](https://github.com/google/re2?tab=readme-ov-file#matching-interface), [Boost.Regex](https://www.boost.org/doc/libs/1_34_1/libs/regex/doc/partial_matches.html)), Python ([via third party regex module](https://pypi.org/project/regex/#:~:text=Added%20partial%20matches)) or Java (via [`hitEnd`](https://docs.oracle.com/javase/8/docs/api/java/util/regex/Matcher.html#hitEnd--)), Javascript has no canonical / innate partial-matching for regular expressions.
|
|
8
8
|
|
|
9
|
-
## Overview
|
|
9
|
+
## 📖 Overview
|
|
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
13
|
**Based on an algorithm created by [Lucas Trzesniewski](https://github.com/ltrzesniewski)**, re-created for NPM via ISC license, with permission.
|
|
14
14
|
|
|
15
|
-
## Installation
|
|
15
|
+
## 📦 Installation
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
18
|
npm install regex-partial-match
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
## Usage
|
|
21
|
+
## 🚀 Usage
|
|
22
22
|
|
|
23
23
|
### Basic Usage
|
|
24
24
|
|
|
25
25
|
```javascript
|
|
26
|
-
import
|
|
26
|
+
import PartialMatchRegExp from "regex-partial-match";
|
|
27
27
|
|
|
28
|
-
const pattern =
|
|
29
|
-
const partial =
|
|
28
|
+
const pattern = /^hello world/;
|
|
29
|
+
const partial = new PartialMatchRegExp(pattern);
|
|
30
30
|
|
|
31
31
|
partial.test("h"); // true - could match
|
|
32
32
|
partial.test("hello"); // true - could match
|
|
@@ -39,22 +39,26 @@ partial.test("goodbye"); // false - cannot match
|
|
|
39
39
|
```javascript
|
|
40
40
|
import "regex-partial-match/extend";
|
|
41
41
|
|
|
42
|
-
const partial =
|
|
42
|
+
const partial = /^hello world/.toPartialMatchRegex();
|
|
43
43
|
|
|
44
44
|
partial.test("hel"); // true
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
## How It Works
|
|
47
|
+
## ⚙️ How It Works
|
|
48
48
|
|
|
49
|
-
The library transforms a regular expression by wrapping each atomic element in a non-capturing group with
|
|
49
|
+
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]):
|
|
50
50
|
|
|
51
51
|
```javascript
|
|
52
|
-
/abc/ → /(?:a|$)(?:b|$)(?:c|$)/
|
|
52
|
+
/abc/ → /(?:a|$(?![\s\S]))(?:b|$(?![\s\S]))(?:c|$(?![\s\S]))/
|
|
53
53
|
```
|
|
54
54
|
|
|
55
55
|
This allows the pattern to match prefixes of the original pattern, enabling validation of incomplete input.
|
|
56
56
|
|
|
57
|
-
|
|
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
|
+
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.
|
|
58
62
|
|
|
59
63
|
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.
|
|
60
64
|
|
|
@@ -64,9 +68,10 @@ The library has been stress-tested with various regular expression features in i
|
|
|
64
68
|
|
|
65
69
|
Such combinations have not been tested.
|
|
66
70
|
|
|
67
|
-
[
|
|
71
|
+
> [!NOTE]
|
|
72
|
+
> See [Partial Match Parity](/docs/partial-match-parity.md) for full details on how the library compares to reference implementations
|
|
68
73
|
|
|
69
|
-
## Supported Features
|
|
74
|
+
## ✅ Supported Features
|
|
70
75
|
|
|
71
76
|
- 🔤 [Literal characters](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Literal_character)
|
|
72
77
|
- 🔣 [Character escapes](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Character_escape) (`\n`, `\t`, `\x61`, `\u0061`, `\u{1F600}`)
|
|
@@ -77,6 +82,7 @@ Such combinations have not been tested.
|
|
|
77
82
|
- 🔢 [Quantifiers](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Quantifier) (`*`, `+`, `?`, `{n}`, `{n,}`, `{n,m}`)
|
|
78
83
|
- 🔀 [Disjunction](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Disjunction) (`a|b`)
|
|
79
84
|
- 👥 [Groups](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Capturing_group) (capturing and non-capturing) (`(?:abc)`, `(abc)`, `(?<named>abc)`)
|
|
85
|
+
- 🔙 [Backreferences](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Backreference) (`\1`, `\k<name>`) (See [caveats](#backreferences) for known limitations)
|
|
80
86
|
- 👉 [Lookahead assertions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Lookahead_assertion) (`(?=...)`, `(?!...)`)
|
|
81
87
|
- 👈 [Lookbehind assertions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Lookbehind_assertion) (`(?<=...)`, `(?<!...)`)
|
|
82
88
|
- ⚓ [Input Boundaries](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Input_boundary_assertion) (`^`, `$`)
|
|
@@ -84,18 +90,16 @@ Such combinations have not been tested.
|
|
|
84
90
|
- 🏴 [Flags](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/flags): `g`, `i`, `m`, `s`, `u`, `d`, `y` (See [caveats](#sticky-flag-y) for `y`)
|
|
85
91
|
- 🎚️ [Modifiers](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Modifier) (`(?ims:...)`, `(?-ims:...)`, `(?im-s:...)`)
|
|
86
92
|
|
|
87
|
-
## Unsupported Features
|
|
93
|
+
## 🚫 Unsupported Features
|
|
88
94
|
|
|
89
95
|
The following regex features are **not currently supported**:
|
|
90
96
|
|
|
91
|
-
- ⚠️ [Backreferences](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Backreference) (`\1`, `\k<name>`) - Can be included, but can't partially match. See [caveats](#caveats).
|
|
92
97
|
- ⚠️ [Character class substrings](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Character_class#matching_strings) (`\q{abc}`) - When used independently, rather than to modify, can be included, but can't partially match. See [caveats](#caveats).
|
|
93
98
|
|
|
94
|
-
## Browser Compatibility
|
|
99
|
+
## 🌐 Browser Compatibility
|
|
95
100
|
|
|
96
|
-
The library is compiled to **
|
|
101
|
+
The library is compiled to **ES2015** (ECMAScript 6). Certain regular expression features naturally require newer environments:
|
|
97
102
|
|
|
98
|
-
- [**Unicode escapes**](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Unicode_character_class_escape) (`\u{...}`) - ES2015+
|
|
99
103
|
- [**Unicode property escapes**](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Unicode_character_class_escape) (`\p{...}`, `\P{...}`) - ES2018+
|
|
100
104
|
- [**Lookbehind assertions**](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Lookbehind_assertion) (`(?<=...)`, `(?<!...)`) - ES2018+
|
|
101
105
|
- [**Named capturing groups**](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Named_capturing_group) (`(?<name>...)`) - ES2018+
|
|
@@ -104,43 +108,80 @@ The library is compiled to **ES5** for broad compatibility with older browsers a
|
|
|
104
108
|
- [**`v` (unicodeSets) flag**](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/unicodeSets) - ES2024+
|
|
105
109
|
- [**Modifiers**](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Modifier) (`(?ims:...)`, `(?-ims:...)`, `(?i-ms:...)`) - ES2025+
|
|
106
110
|
|
|
107
|
-
## Caveats
|
|
111
|
+
## ⚠️ Caveats
|
|
108
112
|
|
|
109
113
|
### [`.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)
|
|
110
114
|
|
|
111
|
-
|
|
115
|
+
For unanchored patterns (no `^` and not using the `y` flag), the library produces an expression that always matches an empty string at the true end of the input — see [How It Works](#how-it-works). Feasibly, this is the start of a new partial match.
|
|
112
116
|
|
|
113
117
|
Hence:
|
|
114
118
|
|
|
115
119
|
```js
|
|
116
120
|
/x/.test("a") === false; /* untransformed regex */
|
|
117
|
-
/(?:x|$)/.test("a") === true; /*
|
|
121
|
+
/(?:x|$(?![\s\S]))/.test("a") === true; /* new PartialMatchRegExp(/x/), internally */
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
To mitigate, a start anchor (`^`) can prevent the engine from scanning forward to match the empty-string fallback at the end of the input:
|
|
125
|
+
|
|
126
|
+
```js
|
|
127
|
+
/* new PartialMatchRegExp(/^x/) matches as if it were /^(?:x|$(?![\s\S]))/ */
|
|
128
|
+
/^(?:x|$(?![\s\S]))/.test("") === true;
|
|
129
|
+
/^(?:x|$(?![\s\S]))/.test("x") === true;
|
|
130
|
+
/^(?:x|$(?![\s\S]))/.test("a") === false;
|
|
118
131
|
```
|
|
119
132
|
|
|
120
|
-
|
|
133
|
+
> [!CAUTION]
|
|
134
|
+
> In [multiline mode](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/multiline), `^` still matches at the start of the string and immediately after each `\n`, so the transformed regex can attempt the empty-string fallback at the start of any line — but, since the fallback requires strict end-of-input, it only succeeds if that line start is *also* genuinely where the input ends:
|
|
135
|
+
>
|
|
136
|
+
> ```js
|
|
137
|
+
> /^(?:x|$(?![\s\S]))/m.test("x") === true;
|
|
138
|
+
> /^(?:x|$(?![\s\S]))/m.test("a\n") === true; /* '^' matches after '\n', and input truly ends there */
|
|
139
|
+
> /^(?:x|$(?![\s\S]))/m.test("a\nb") === false; /* '^' matches after '\n', but "b" remains — not genuine end-of-input */
|
|
140
|
+
> ```
|
|
141
|
+
|
|
142
|
+
The [`y` flag](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/sticky) prevents matching ahead from the [`lastIndex`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/lastIndex) (defaulting to `0` for a new `RegExp`):
|
|
121
143
|
|
|
122
144
|
```js
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
/^(?:x|$)/.test("a") === false;
|
|
145
|
+
/(?:x|$(?![\s\S]))/y.test("x") === true;
|
|
146
|
+
/(?:x|$(?![\s\S]))/y.test("a") === false;
|
|
126
147
|
```
|
|
127
148
|
|
|
128
|
-
|
|
149
|
+
> [!CAUTION]
|
|
150
|
+
> See [caveats](#sticky-flag-y) re: resetting `lastIndex` when incrementally matching
|
|
151
|
+
|
|
152
|
+
On this basis, `.test()` should be used with caution, and a match of an empty string at the true end of the input should instead be considered "no match", if validating that which came before.
|
|
129
153
|
|
|
130
|
-
|
|
154
|
+
e.g.
|
|
131
155
|
|
|
132
156
|
```js
|
|
133
|
-
/(?:x|$)/.exec("a"); // ['', index: 1, input: "a", groups: undefined];
|
|
134
|
-
"a".match(/(?:x|$)/); // ['', index: 1, input: "a", groups: undefined];
|
|
157
|
+
/(?:x|$(?![\s\S]))/.exec("a"); // ['', index: 1, input: "a", groups: undefined];
|
|
158
|
+
"a".match(/(?:x|$(?![\s\S]))/); // ['', index: 1, input: "a", groups: undefined];
|
|
135
159
|
```
|
|
136
160
|
|
|
137
|
-
|
|
161
|
+
> [!NOTE]
|
|
162
|
+
> A more ergonomic `test()` / `exec()` output [was explored](https://github.com/TomStrepsil/regex-partial-match/pull/51), but proved a complex problem space.
|
|
138
163
|
|
|
139
164
|
### Backreferences
|
|
140
165
|
|
|
141
|
-
|
|
166
|
+
`PartialMatchRegExp` supports partial matching of backreferences (`\1`, `\k<name>`) — see [Patterns with backreferences](#patterns-with-backreferences) above and [docs/backreferences.md](./docs/backreferences.md) for the algorithm. A backreference is inherently atomic — `\1` must match the complete captured text or fail — but the library resolves what each group captured from a partial input and expands the backreference into per-character partial form so matching can still proceed character-by-character in the common case.
|
|
167
|
+
|
|
168
|
+
The following cases remain atomic (full native value or exactly at true end of input, no mid-value partial matching):
|
|
169
|
+
|
|
170
|
+
- **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.
|
|
171
|
+
- **`\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
|
+
- **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
|
+
|
|
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.
|
|
142
175
|
|
|
143
|
-
|
|
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.
|
|
178
|
+
|
|
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.
|
|
180
|
+
|
|
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.
|
|
182
|
+
|
|
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.
|
|
144
185
|
|
|
145
186
|
### Positive Lookbehinds
|
|
146
187
|
|
|
@@ -160,27 +201,34 @@ In [unicode-aware mode](https://developer.mozilla.org/en-US/docs/Web/JavaScript/
|
|
|
160
201
|
|
|
161
202
|
### [Sticky](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/sticky) Flag (`y`)
|
|
162
203
|
|
|
163
|
-
The
|
|
164
|
-
|
|
165
|
-
**Example:**
|
|
204
|
+
The sticky flag is fully supported for its intended use case: **scanning within a single fixed string**. Partial matches are found only at `lastIndex`; the engine does not scan forward, and `lastIndex` advances on success or resets to `0` on failure — exactly as native sticky regexes behave.
|
|
166
205
|
|
|
167
206
|
```javascript
|
|
168
|
-
|
|
169
|
-
const partial = createPartialMatchRegex(pattern);
|
|
207
|
+
import PartialMatchRegExp from "regex-partial-match";
|
|
170
208
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
partial.
|
|
174
|
-
//
|
|
209
|
+
const partial = new PartialMatchRegExp(/hello/y);
|
|
210
|
+
|
|
211
|
+
partial.lastIndex = 2;
|
|
212
|
+
partial.test("xyhello"); // true — partial match at position 2
|
|
213
|
+
partial.test("xyworld"); // false — no match at position 2, no forward scan
|
|
214
|
+
partial.lastIndex = 2;
|
|
215
|
+
partial.test("xyhel"); // true — partial prefix "hel" at position 2
|
|
175
216
|
```
|
|
176
217
|
|
|
177
|
-
**
|
|
218
|
+
**Limitation — progressive input validation:** Because a successful match advances `lastIndex`, testing a sequence of growing strings against the same instance does not work as expected:
|
|
219
|
+
|
|
220
|
+
```javascript
|
|
221
|
+
const partial = new PartialMatchRegExp(/hello/y);
|
|
178
222
|
|
|
179
|
-
|
|
223
|
+
partial.test("h"); // true, lastIndex → 1
|
|
224
|
+
partial.test("he"); // false — sticky requires a match at position 1 of "he",
|
|
225
|
+
// but "e" is not a valid start of the pattern
|
|
226
|
+
partial.test("hel"); // true (lastIndex was reset to 0 by the previous failure)
|
|
227
|
+
```
|
|
180
228
|
|
|
181
|
-
|
|
229
|
+
There is no way to distinguish "scanning forward in the same string" from "testing a new, longer string", so this cannot be fixed in code. For progressive input validation, use a regex **without** the `y` flag and always test against the full input so far.
|
|
182
230
|
|
|
183
|
-
The
|
|
231
|
+
The `gy` flag combination is also fully supported: `exec()`/`test()` behave as sticky, while `match()`, `matchAll()`, `replace()`, and `replaceAll()` iterate via `exec()` as global — matching [the language specification](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/sticky).
|
|
184
232
|
|
|
185
233
|
### "String properties"
|
|
186
234
|
|
|
@@ -190,13 +238,27 @@ Hence, `[\p{RGI_Emoji_Flag_Sequence}]` will match `🇺🇳` as a whole, but not
|
|
|
190
238
|
|
|
191
239
|
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.
|
|
192
240
|
|
|
193
|
-
|
|
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
|
+
## 💡 Examples
|
|
194
254
|
|
|
195
255
|
### Form Validation
|
|
196
256
|
|
|
197
257
|
```javascript
|
|
258
|
+
import PartialMatchRegExp from "regex-partial-match";
|
|
259
|
+
|
|
198
260
|
const emailPattern = /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i;
|
|
199
|
-
const partial =
|
|
261
|
+
const partial = new PartialMatchRegExp(emailPattern);
|
|
200
262
|
|
|
201
263
|
function validateEmail(input) {
|
|
202
264
|
return partial.test(input) ? "valid" : "invalid";
|
|
@@ -212,8 +274,10 @@ validateEmail("@@invalid"); // 'invalid' - cannot match
|
|
|
212
274
|
### Autocomplete
|
|
213
275
|
|
|
214
276
|
```javascript
|
|
277
|
+
import PartialMatchRegExp from "regex-partial-match";
|
|
278
|
+
|
|
215
279
|
const commandPattern = /^(help|quit|save|load)/;
|
|
216
|
-
const partial =
|
|
280
|
+
const partial = new PartialMatchRegExp(commandPattern);
|
|
217
281
|
|
|
218
282
|
function getSuggestions(input) {
|
|
219
283
|
return partial.test(input) ? "valid prefix" : "no suggestions";
|
|
@@ -228,9 +292,11 @@ getSuggestions("xyz"); // 'no suggestions'
|
|
|
228
292
|
### Stream Processing
|
|
229
293
|
|
|
230
294
|
```javascript
|
|
295
|
+
import PartialMatchRegExp from "regex-partial-match";
|
|
296
|
+
|
|
231
297
|
// Process streaming data with pattern matching at chunk boundaries
|
|
232
298
|
const pattern = /\{"[^"]+":"[^"]+"\}/; // Match JSON objects
|
|
233
|
-
const partial =
|
|
299
|
+
const partial = new PartialMatchRegExp(pattern);
|
|
234
300
|
let buffer = "";
|
|
235
301
|
|
|
236
302
|
function processChunk(chunk) {
|
|
@@ -261,49 +327,51 @@ processChunk("invalid{"); // [] - discarded, buffer: ''
|
|
|
261
327
|
|
|
262
328
|
Useful for parsing log files, network streams, or any chunked data where records may be split across boundaries.
|
|
263
329
|
|
|
264
|
-
## API
|
|
330
|
+
## 🔌 API
|
|
265
331
|
|
|
266
|
-
### `
|
|
332
|
+
### `new PartialMatchRegExp(pattern: RegExp | string, flags?: string)`
|
|
267
333
|
|
|
268
|
-
|
|
334
|
+
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.
|
|
269
335
|
|
|
270
336
|
Available via the default entry point of the package.
|
|
271
337
|
|
|
272
338
|
**Parameters:**
|
|
273
339
|
|
|
274
|
-
- `
|
|
340
|
+
- `pattern` - A `RegExp` instance, or a pattern source string (as accepted by the `RegExp` constructor)
|
|
341
|
+
- `flags` - A flags string, used only when `pattern` is a string (as accepted by the `RegExp` constructor)
|
|
275
342
|
|
|
276
343
|
**Returns:**
|
|
277
344
|
|
|
278
|
-
- A
|
|
345
|
+
- A `PartialMatchRegExp` instance that matches partial strings of the original pattern
|
|
279
346
|
|
|
280
|
-
### `RegExp.prototype.toPartialMatchRegex():
|
|
347
|
+
### `RegExp.prototype.toPartialMatchRegex(): PartialMatchRegExp`
|
|
281
348
|
|
|
282
349
|
When using `import 'regex-partial-match/extend'`, this method is added to `RegExp.prototype`.
|
|
283
350
|
|
|
284
351
|
**Returns:**
|
|
285
352
|
|
|
286
|
-
- A new `
|
|
353
|
+
- A new `PartialMatchRegExp` that matches partial strings, created from the `RegExp` instance the method was called on.
|
|
287
354
|
|
|
288
|
-
## License
|
|
355
|
+
## 📜 License
|
|
289
356
|
|
|
290
357
|
ISC License - see [LICENSE](./LICENSE) file for details.
|
|
291
358
|
|
|
292
|
-
## Credits
|
|
359
|
+
## 🙌 Credits
|
|
293
360
|
|
|
294
361
|
Algorithm created by [Lucas Trzesniewski](https://github.com/ltrzesniewski).
|
|
295
362
|
|
|
296
|
-
## Contributing
|
|
363
|
+
## 🤝 Contributing
|
|
297
364
|
|
|
298
365
|
Contributions are welcome! Please open an issue or pull request on [GitHub](https://github.com/TomStrepsil/regex-partial-match).
|
|
299
366
|
|
|
300
|
-
## Related projects
|
|
301
|
-
|
|
302
|
-
| Project
|
|
303
|
-
|
|
|
304
|
-
| [`incr-regex-package`](https://www.npmjs.com/package/incr-regex-package)
|
|
305
|
-
| [`dfa`](https://github.com/foliojs/dfa)
|
|
306
|
-
| [`refa`](https://github.com/RunDevelopment/refa)
|
|
307
|
-
| [`@eslint-community/regexpp`](https://github.com/eslint-community/regexpp)
|
|
308
|
-
| [`Regex+`](https://www.npmjs.com/package/regex)
|
|
309
|
-
| [`Awesome Regex`](https://github.com/slevithan/awesome-regex)
|
|
367
|
+
## 🔗 Related projects
|
|
368
|
+
|
|
369
|
+
| Project | Description |
|
|
370
|
+
| ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
371
|
+
| [`incr-regex-package`](https://www.npmjs.com/package/incr-regex-package) | Incremental regex matcher |
|
|
372
|
+
| [`dfa`](https://github.com/foliojs/dfa) | Compiles a regular expression like syntax to fast deterministic finite automata, which could be used to partial match? |
|
|
373
|
+
| [`refa`](https://github.com/RunDevelopment/refa) | Can [convert regular expressions to an Abstract Syntax Tree](https://rundevelopment.github.io/refa/docs/latest/classes/JS.Parser.html), which might afford partial-match capability? |
|
|
374
|
+
| [`@eslint-community/regexpp`](https://github.com/eslint-community/regexpp) | A regular expression parser for ECMAScript with AST generation and visitor implementation |
|
|
375
|
+
| [`Regex+`](https://www.npmjs.com/package/regex) | template literal, transforming native regular expressions |
|
|
376
|
+
| [`Awesome Regex`](https://github.com/slevithan/awesome-regex) | Curated list of tools, tutorials, libraries, and other resources, covering all major regex flavours |
|
|
377
|
+
| [`replace-content-transformer`](https://github.com/TomStrepsil/replace-content-transformer) | A toolkit for stream content replacement, underpinned by `regex-partial-match` |
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface DynamicPath {
|
|
2
|
+
originalCaptureScan: RegExp;
|
|
3
|
+
preScan: RegExp;
|
|
4
|
+
expand: (capture: RegExpExecArray) => string;
|
|
5
|
+
}
|
|
6
|
+
export type CompiledPartial = {
|
|
7
|
+
kind: "static";
|
|
8
|
+
regex: RegExp;
|
|
9
|
+
} | {
|
|
10
|
+
kind: "dynamic";
|
|
11
|
+
dynamic: DynamicPath;
|
|
12
|
+
};
|
|
13
|
+
export declare const compilePartial: (regex: RegExp) => CompiledPartial;
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import escapeAtom from "./escapeAtom.js";
|
|
2
|
+
const OCCURRENCES_REGEX = /\{\d+,?\d*\}/y;
|
|
3
|
+
const NOT_NUMBERS_REGEX = /\D/g;
|
|
4
|
+
const ALTERNATION_TO_END_OF_INPUT = "|$(?![\\s\\S]))";
|
|
5
|
+
const MAYBE_HAS_BACKREFERENCE = /\\[1-9]|\\k</;
|
|
6
|
+
const isBackreference = (part) => typeof part !== "string";
|
|
7
|
+
const isNumericBackreference = (part) => isBackreference(part) && typeof part.ref === "number";
|
|
8
|
+
function walk(regex) {
|
|
9
|
+
const source = regex.source;
|
|
10
|
+
const isUnicode = regex.unicode || regex.unicodeSets;
|
|
11
|
+
let i = 0;
|
|
12
|
+
let groupCount = 0;
|
|
13
|
+
function extractSlice(length) {
|
|
14
|
+
return source.slice(i, (i += length));
|
|
15
|
+
}
|
|
16
|
+
function process() {
|
|
17
|
+
var _a;
|
|
18
|
+
const result = [];
|
|
19
|
+
function appendOptional(length) {
|
|
20
|
+
result.push("(?:" + extractSlice(length) + ALTERNATION_TO_END_OF_INPUT);
|
|
21
|
+
}
|
|
22
|
+
function appendRaw(length) {
|
|
23
|
+
result.push(extractSlice(length));
|
|
24
|
+
}
|
|
25
|
+
function appendRawGroup(prefixLength) {
|
|
26
|
+
const start = i;
|
|
27
|
+
i += prefixLength;
|
|
28
|
+
process();
|
|
29
|
+
result.push(source.slice(start, i));
|
|
30
|
+
}
|
|
31
|
+
while (i < source.length) {
|
|
32
|
+
switch (source[i]) {
|
|
33
|
+
case "\\":
|
|
34
|
+
switch (source[i + 1]) {
|
|
35
|
+
case "c":
|
|
36
|
+
appendOptional(3);
|
|
37
|
+
break;
|
|
38
|
+
case "k": {
|
|
39
|
+
const referenceEnd = source[i + 2] === "<" ? source.indexOf(">", i) : -1;
|
|
40
|
+
if (referenceEnd === -1) {
|
|
41
|
+
appendOptional(2);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
const start = i;
|
|
45
|
+
const ref = source.slice(i + 3, referenceEnd);
|
|
46
|
+
i = referenceEnd + 1;
|
|
47
|
+
result.push({ ref, start, end: i });
|
|
48
|
+
}
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
case "u":
|
|
52
|
+
if (isUnicode && source[i + 2] === "{") {
|
|
53
|
+
appendOptional(source.indexOf("}", i) - i + 1);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
appendOptional(6);
|
|
57
|
+
}
|
|
58
|
+
break;
|
|
59
|
+
case "p":
|
|
60
|
+
case "P":
|
|
61
|
+
if (isUnicode) {
|
|
62
|
+
appendOptional(source.indexOf("}", i) - i + 1);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
appendOptional(2);
|
|
66
|
+
}
|
|
67
|
+
break;
|
|
68
|
+
case "x":
|
|
69
|
+
appendOptional(4);
|
|
70
|
+
break;
|
|
71
|
+
case "1":
|
|
72
|
+
case "2":
|
|
73
|
+
case "3":
|
|
74
|
+
case "4":
|
|
75
|
+
case "5":
|
|
76
|
+
case "6":
|
|
77
|
+
case "7":
|
|
78
|
+
case "8":
|
|
79
|
+
case "9": {
|
|
80
|
+
NOT_NUMBERS_REGEX.lastIndex = i + 1;
|
|
81
|
+
const nextNonDigit = NOT_NUMBERS_REGEX.exec(source);
|
|
82
|
+
const start = i;
|
|
83
|
+
const end = nextNonDigit ? nextNonDigit.index : source.length;
|
|
84
|
+
const ref = Number(source.slice(start + 1, end));
|
|
85
|
+
i = end;
|
|
86
|
+
result.push({ ref, start, end });
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
default:
|
|
90
|
+
appendOptional(2);
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
break;
|
|
94
|
+
case "[": {
|
|
95
|
+
let depth = 1, j = i + 1;
|
|
96
|
+
while (depth) {
|
|
97
|
+
switch (source[j]) {
|
|
98
|
+
case "\\":
|
|
99
|
+
j += 2;
|
|
100
|
+
continue;
|
|
101
|
+
case "[":
|
|
102
|
+
if (regex.unicodeSets)
|
|
103
|
+
depth++;
|
|
104
|
+
break;
|
|
105
|
+
case "]":
|
|
106
|
+
depth--;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
j++;
|
|
110
|
+
}
|
|
111
|
+
appendOptional(j - i);
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
case "|":
|
|
115
|
+
case "^":
|
|
116
|
+
case "*":
|
|
117
|
+
case "+":
|
|
118
|
+
case "?":
|
|
119
|
+
case "$":
|
|
120
|
+
appendRaw(1);
|
|
121
|
+
break;
|
|
122
|
+
case "{": {
|
|
123
|
+
OCCURRENCES_REGEX.lastIndex = i;
|
|
124
|
+
const regExpExecArray = OCCURRENCES_REGEX.exec(source);
|
|
125
|
+
if (regExpExecArray) {
|
|
126
|
+
appendRaw(regExpExecArray[0].length);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
appendOptional(1);
|
|
130
|
+
}
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
case "(":
|
|
134
|
+
if (source[i + 1] == "?") {
|
|
135
|
+
switch (source[i + 2]) {
|
|
136
|
+
case ":":
|
|
137
|
+
result.push("(?:");
|
|
138
|
+
i += 3;
|
|
139
|
+
result.push(...process(), ALTERNATION_TO_END_OF_INPUT);
|
|
140
|
+
break;
|
|
141
|
+
case "=":
|
|
142
|
+
result.push("(?=");
|
|
143
|
+
i += 3;
|
|
144
|
+
result.push(...process(), ")");
|
|
145
|
+
break;
|
|
146
|
+
case "-":
|
|
147
|
+
case "i":
|
|
148
|
+
case "s":
|
|
149
|
+
case "m": {
|
|
150
|
+
const flagsStart = i + 2, colonIndex = source.indexOf(":", flagsStart);
|
|
151
|
+
result.push("(?" + source.slice(flagsStart, colonIndex) + ":");
|
|
152
|
+
i = colonIndex + 1;
|
|
153
|
+
result.push(...process(), ")");
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
case "!":
|
|
157
|
+
appendRawGroup(3);
|
|
158
|
+
break;
|
|
159
|
+
case "<":
|
|
160
|
+
switch (source[i + 3]) {
|
|
161
|
+
case "=":
|
|
162
|
+
case "!":
|
|
163
|
+
appendRawGroup(4);
|
|
164
|
+
break;
|
|
165
|
+
default:
|
|
166
|
+
++groupCount;
|
|
167
|
+
appendRaw(source.indexOf(">", i) - i + 1);
|
|
168
|
+
result.push(...process(), ALTERNATION_TO_END_OF_INPUT);
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
++groupCount;
|
|
176
|
+
appendRaw(1);
|
|
177
|
+
result.push(...process(), ALTERNATION_TO_END_OF_INPUT);
|
|
178
|
+
}
|
|
179
|
+
break;
|
|
180
|
+
case ")":
|
|
181
|
+
++i;
|
|
182
|
+
return result;
|
|
183
|
+
default:
|
|
184
|
+
appendOptional(isUnicode && ((_a = source.codePointAt(i)) !== null && _a !== void 0 ? _a : 0) > 0xffff ? 2 : 1);
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return result;
|
|
189
|
+
}
|
|
190
|
+
return { parts: process(), groupCount };
|
|
191
|
+
}
|
|
192
|
+
function render(parts, renderBackref) {
|
|
193
|
+
return parts
|
|
194
|
+
.map((part) => (isBackreference(part) ? renderBackref(part) : part))
|
|
195
|
+
.join("");
|
|
196
|
+
}
|
|
197
|
+
function backrefToken(backref) {
|
|
198
|
+
return isNumericBackreference(backref)
|
|
199
|
+
? "\\" + String(backref.ref)
|
|
200
|
+
: "\\k<" + backref.ref + ">";
|
|
201
|
+
}
|
|
202
|
+
function reclassifyOctalEscapes(parts, source, groupCount) {
|
|
203
|
+
return parts.map((part) => isNumericBackreference(part) && part.ref > groupCount
|
|
204
|
+
? "(?:" + source.slice(part.start, part.end) + ALTERNATION_TO_END_OF_INPUT
|
|
205
|
+
: part);
|
|
206
|
+
}
|
|
207
|
+
function spliceOriginalSource(source, backrefs) {
|
|
208
|
+
let result = "";
|
|
209
|
+
let cursor = 0;
|
|
210
|
+
for (const { start, end } of backrefs) {
|
|
211
|
+
result += source.slice(cursor, start) + "(?:[\\s\\S]*?)";
|
|
212
|
+
cursor = end;
|
|
213
|
+
}
|
|
214
|
+
return result + source.slice(cursor);
|
|
215
|
+
}
|
|
216
|
+
function expandCaptured(value, isUnicode) {
|
|
217
|
+
return (isUnicode ? Array.from(value) : value.split(""))
|
|
218
|
+
.map((atom) => "(?:" + escapeAtom(atom) + ALTERNATION_TO_END_OF_INPUT)
|
|
219
|
+
.join("");
|
|
220
|
+
}
|
|
221
|
+
export const compilePartial = (regex) => {
|
|
222
|
+
const { parts, groupCount } = walk(regex);
|
|
223
|
+
if (!MAYBE_HAS_BACKREFERENCE.test(regex.source)) {
|
|
224
|
+
return {
|
|
225
|
+
kind: "static",
|
|
226
|
+
regex: new RegExp(render(parts, backrefToken), regex.flags)
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
const isUnicode = regex.unicode || regex.unicodeSets;
|
|
230
|
+
const sanitizedParts = isUnicode
|
|
231
|
+
? parts
|
|
232
|
+
: reclassifyOctalEscapes(parts, regex.source, groupCount);
|
|
233
|
+
const backreferences = sanitizedParts.filter(isBackreference);
|
|
234
|
+
if (backreferences.length === 0) {
|
|
235
|
+
return {
|
|
236
|
+
kind: "static",
|
|
237
|
+
regex: new RegExp(render(sanitizedParts, backrefToken), regex.flags)
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
kind: "dynamic",
|
|
242
|
+
dynamic: {
|
|
243
|
+
originalCaptureScan: new RegExp(spliceOriginalSource(regex.source, backreferences), regex.flags),
|
|
244
|
+
preScan: new RegExp(render(parts, () => "(?:[\\s\\S]*?)"), regex.flags),
|
|
245
|
+
expand: (capture) => render(parts, (backref) => {
|
|
246
|
+
var _a;
|
|
247
|
+
const captured = isNumericBackreference(backref)
|
|
248
|
+
? capture[backref.ref]
|
|
249
|
+
: (_a = capture.groups) === null || _a === void 0 ? void 0 : _a[backref.ref];
|
|
250
|
+
return captured === undefined
|
|
251
|
+
? "(?:" + backrefToken(backref) + ALTERNATION_TO_END_OF_INPUT
|
|
252
|
+
: expandCaptured(captured, isUnicode);
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function escapeAtom(atom: string): string;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export default function escapeAtom(atom) {
|
|
2
|
+
var _a, _b;
|
|
3
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- support for RegExp.escape is not yet widespread, so fallback to manual escape
|
|
4
|
+
return (_b = (_a = RegExp.escape) === null || _a === void 0 ? void 0 : _a.call(RegExp, atom)) !== null && _b !== void 0 ? _b : atom.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
5
|
+
}
|
package/lib/extend.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import PartialMatchRegExp from "./partialMatchRegExp.ts";
|
|
1
2
|
declare global {
|
|
2
3
|
interface RegExp {
|
|
3
4
|
/**
|
|
@@ -7,7 +8,7 @@ declare global {
|
|
|
7
8
|
* with an alternation to end-of-input (`$`), allowing the pattern to match prefixes
|
|
8
9
|
* of the original pattern. This enables validation of incomplete input strings.
|
|
9
10
|
*
|
|
10
|
-
* @returns A new
|
|
11
|
+
* @returns A new PartialMatchRegExp that matches partial strings of the original pattern
|
|
11
12
|
*
|
|
12
13
|
* @example
|
|
13
14
|
* ```typescript
|
|
@@ -23,13 +24,11 @@ declare global {
|
|
|
23
24
|
*
|
|
24
25
|
* @remarks
|
|
25
26
|
* - The transformed regex will always match an empty string at the end of input
|
|
26
|
-
* - Backreferences cannot be partially matched as they are atomic
|
|
27
27
|
* - Use with a start anchor (`^`) to prevent false positives from empty string matches
|
|
28
28
|
* - The `y` (sticky) flag may not behave as expected in partial matching scenarios
|
|
29
29
|
*
|
|
30
30
|
* @see {@link https://github.com/TomStrepsil/regex-partial-match#readme | Documentation}
|
|
31
31
|
*/
|
|
32
|
-
toPartialMatchRegex():
|
|
32
|
+
toPartialMatchRegex(): PartialMatchRegExp;
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
-
export {};
|
package/lib/extend.js
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `RegExp` subclass that supports partial (prefix) matching.
|
|
3
|
+
*
|
|
4
|
+
* An instance behaves like a normal `RegExp` but also matches any input string
|
|
5
|
+
* that is a valid prefix of the original pattern — i.e. strings that could still
|
|
6
|
+
* lead to a full match with more input. This enables validation of incomplete
|
|
7
|
+
* input strings.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* const pattern = /^hello world/;
|
|
12
|
+
* const partial = new PartialMatchRegExp(pattern);
|
|
13
|
+
*
|
|
14
|
+
* partial.test('h'); // true - could match
|
|
15
|
+
* partial.test('hello'); // true - could match
|
|
16
|
+
* partial.test('hello world'); // true - full match
|
|
17
|
+
* partial.test('goodbye'); // false - cannot match
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* @remarks
|
|
21
|
+
* - The transformed pattern always matches an empty string at the end of input;
|
|
22
|
+
* use a start anchor (`^`) to prevent false positives from empty string matches
|
|
23
|
+
* - The `y` (sticky) flag may not behave as expected in partial matching scenarios
|
|
24
|
+
*
|
|
25
|
+
* @see {@link https://github.com/TomStrepsil/regex-partial-match#readme | Documentation}
|
|
26
|
+
*/
|
|
27
|
+
declare class PartialMatchRegExp extends RegExp {
|
|
28
|
+
#private;
|
|
29
|
+
constructor(pattern: RegExp | string, flags?: string);
|
|
30
|
+
exec(input: string): RegExpExecArray | null;
|
|
31
|
+
}
|
|
32
|
+
export default PartialMatchRegExp;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
|
|
2
|
+
if (kind === "m") throw new TypeError("Private method is not writable");
|
|
3
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
|
|
4
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
|
|
5
|
+
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
|
|
6
|
+
};
|
|
7
|
+
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
|
|
8
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
|
|
9
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
|
|
10
|
+
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
11
|
+
};
|
|
12
|
+
var _PartialMatchRegExp_static, _PartialMatchRegExp_dynamic;
|
|
13
|
+
import { compilePartial } from "./compilePartial.js";
|
|
14
|
+
/**
|
|
15
|
+
* A `RegExp` subclass that supports partial (prefix) matching.
|
|
16
|
+
*
|
|
17
|
+
* An instance behaves like a normal `RegExp` but also matches any input string
|
|
18
|
+
* that is a valid prefix of the original pattern — i.e. strings that could still
|
|
19
|
+
* lead to a full match with more input. This enables validation of incomplete
|
|
20
|
+
* input strings.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```typescript
|
|
24
|
+
* const pattern = /^hello world/;
|
|
25
|
+
* const partial = new PartialMatchRegExp(pattern);
|
|
26
|
+
*
|
|
27
|
+
* partial.test('h'); // true - could match
|
|
28
|
+
* partial.test('hello'); // true - could match
|
|
29
|
+
* partial.test('hello world'); // true - full match
|
|
30
|
+
* partial.test('goodbye'); // false - cannot match
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* @remarks
|
|
34
|
+
* - The transformed pattern always matches an empty string at the end of input;
|
|
35
|
+
* use a start anchor (`^`) to prevent false positives from empty string matches
|
|
36
|
+
* - The `y` (sticky) flag may not behave as expected in partial matching scenarios
|
|
37
|
+
*
|
|
38
|
+
* @see {@link https://github.com/TomStrepsil/regex-partial-match#readme | Documentation}
|
|
39
|
+
*/
|
|
40
|
+
class PartialMatchRegExp extends RegExp {
|
|
41
|
+
constructor(pattern, flags) {
|
|
42
|
+
var _a, _b;
|
|
43
|
+
super(pattern, flags);
|
|
44
|
+
_PartialMatchRegExp_static.set(this, void 0);
|
|
45
|
+
_PartialMatchRegExp_dynamic.set(this, void 0);
|
|
46
|
+
const compiled = compilePartial(this);
|
|
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
|
+
? [compiled.dynamic, null]
|
|
49
|
+
: [null, compiled.regex];
|
|
50
|
+
}
|
|
51
|
+
exec(input) {
|
|
52
|
+
if (!__classPrivateFieldGet(this, _PartialMatchRegExp_dynamic, "f")) {
|
|
53
|
+
const partial = __classPrivateFieldGet(this, _PartialMatchRegExp_static, "f"); // eslint-disable-line @typescript-eslint/no-non-null-assertion -- #static is set whenever #dynamic is null
|
|
54
|
+
partial.lastIndex = this.lastIndex;
|
|
55
|
+
const match = partial.exec(input);
|
|
56
|
+
this.lastIndex = partial.lastIndex;
|
|
57
|
+
return match;
|
|
58
|
+
}
|
|
59
|
+
const start = this.lastIndex;
|
|
60
|
+
const originalMatch = super.exec(input);
|
|
61
|
+
if (originalMatch)
|
|
62
|
+
return originalMatch;
|
|
63
|
+
const { originalCaptureScan, preScan, expand } = __classPrivateFieldGet(this, _PartialMatchRegExp_dynamic, "f");
|
|
64
|
+
originalCaptureScan.lastIndex = start;
|
|
65
|
+
let capture = originalCaptureScan.exec(input);
|
|
66
|
+
if (capture === null) {
|
|
67
|
+
preScan.lastIndex = start;
|
|
68
|
+
capture = preScan.exec(input);
|
|
69
|
+
}
|
|
70
|
+
if (capture === null)
|
|
71
|
+
return null;
|
|
72
|
+
const expanded = new RegExp(expand(capture), this.flags);
|
|
73
|
+
expanded.lastIndex = start;
|
|
74
|
+
const match = expanded.exec(input);
|
|
75
|
+
if (match === null)
|
|
76
|
+
return null;
|
|
77
|
+
preferLongerCaptures(match, capture);
|
|
78
|
+
this.lastIndex = expanded.lastIndex;
|
|
79
|
+
return match;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
_PartialMatchRegExp_static = new WeakMap(), _PartialMatchRegExp_dynamic = new WeakMap();
|
|
83
|
+
function pickLonger(scanned, matched) {
|
|
84
|
+
return scanned !== undefined &&
|
|
85
|
+
matched !== undefined &&
|
|
86
|
+
scanned.length > matched.length
|
|
87
|
+
? scanned
|
|
88
|
+
: undefined;
|
|
89
|
+
}
|
|
90
|
+
function preferLongerCaptures(match, scanned) {
|
|
91
|
+
var _a, _b, _c, _d;
|
|
92
|
+
for (let index = 1; index < match.length; index++) {
|
|
93
|
+
const longer = pickLonger(scanned[index], match[index]);
|
|
94
|
+
if (longer === undefined)
|
|
95
|
+
continue;
|
|
96
|
+
match[index] = longer;
|
|
97
|
+
if (match.indices && ((_a = scanned.indices) === null || _a === void 0 ? void 0 : _a[index])) {
|
|
98
|
+
match.indices[index] = scanned.indices[index];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (match.groups && scanned.groups) {
|
|
102
|
+
for (const name of Object.keys(match.groups)) {
|
|
103
|
+
const longer = pickLonger(scanned.groups[name], match.groups[name]);
|
|
104
|
+
if (longer === undefined)
|
|
105
|
+
continue;
|
|
106
|
+
match.groups[name] = longer;
|
|
107
|
+
if ((_b = match.indices) === null || _b === void 0 ? void 0 : _b.groups) {
|
|
108
|
+
match.indices.groups[name] = (_d = (_c = scanned.indices) === null || _c === void 0 ? void 0 : _c.groups) === null || _d === void 0 ? void 0 : _d[name];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export default PartialMatchRegExp;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "regex-partial-match",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "A regular expression transform for partial matching",
|
|
5
5
|
"devEngines": {
|
|
6
6
|
"runtime": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"packageManager": {
|
|
11
11
|
"name": "npm",
|
|
12
|
-
"version": "
|
|
12
|
+
"version": "11.17.0+sha512.3eeaf18997b11070d313849268b23766b9db0068997dec9471073170fe43fa17f2b4d0337bf0f52330ee2274e7f5754b21b01052742e48f5c9c74d8b1e32ef43"
|
|
13
13
|
}
|
|
14
14
|
},
|
|
15
15
|
"keywords": [
|
|
@@ -33,12 +33,12 @@
|
|
|
33
33
|
}
|
|
34
34
|
],
|
|
35
35
|
"type": "module",
|
|
36
|
-
"main": "lib/
|
|
36
|
+
"main": "lib/partialMatchRegExp.js",
|
|
37
37
|
"exports": {
|
|
38
38
|
".": {
|
|
39
|
-
"types": "./lib/
|
|
40
|
-
"import": "./lib/
|
|
41
|
-
"default": "./lib/
|
|
39
|
+
"types": "./lib/partialMatchRegExp.d.ts",
|
|
40
|
+
"import": "./lib/partialMatchRegExp.js",
|
|
41
|
+
"default": "./lib/partialMatchRegExp.js"
|
|
42
42
|
},
|
|
43
43
|
"./extend": {
|
|
44
44
|
"types": "./lib/extend.d.ts",
|
|
@@ -53,6 +53,9 @@
|
|
|
53
53
|
"type": "git",
|
|
54
54
|
"url": "git+https://github.com/TomStrepsil/regex-partial-match.git"
|
|
55
55
|
},
|
|
56
|
+
"workspaces": [
|
|
57
|
+
"benchmarking"
|
|
58
|
+
],
|
|
56
59
|
"sideEffects": false,
|
|
57
60
|
"scripts": {
|
|
58
61
|
"lint": "eslint .",
|
|
@@ -74,13 +77,16 @@
|
|
|
74
77
|
"registry": "https://registry.npmjs.org"
|
|
75
78
|
},
|
|
76
79
|
"devDependencies": {
|
|
80
|
+
"@typescript/lib-es2018": "file:types/lib-es2018",
|
|
81
|
+
"@typescript/lib-es2022": "file:types/lib-es2022",
|
|
77
82
|
"@eslint/js": "^9.17.0",
|
|
78
83
|
"@eslint/markdown": "^7.5.1",
|
|
84
|
+
"@types/node": "^25.9.3",
|
|
79
85
|
"eslint": "^9.17.0",
|
|
80
86
|
"jiti": "^2.6.1",
|
|
81
87
|
"lint-staged": "^15.2.11",
|
|
82
88
|
"simple-git-hooks": "^2.12.1",
|
|
83
|
-
"typescript": "^
|
|
89
|
+
"typescript": "^6.0.3",
|
|
84
90
|
"typescript-eslint": "^8.18.1",
|
|
85
91
|
"vitest": "^4.0.15"
|
|
86
92
|
}
|
package/lib/index.d.ts
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
declare const createPartialMatchRegex: (regex: RegExp) => RegExp;
|
|
2
|
-
/**
|
|
3
|
-
* Transforms a regular expression to support partial matching.
|
|
4
|
-
*
|
|
5
|
-
* This function wraps each atomic element of the regex pattern in a non-capturing group
|
|
6
|
-
* with an alternation to end-of-input (`$`), allowing the pattern to match prefixes
|
|
7
|
-
* of the original pattern. This enables validation of incomplete input strings.
|
|
8
|
-
*
|
|
9
|
-
* @param regex - The regular expression to transform for partial matching
|
|
10
|
-
* @returns A new RegExp that matches partial strings of the original pattern
|
|
11
|
-
*
|
|
12
|
-
* @example
|
|
13
|
-
* ```typescript
|
|
14
|
-
* const pattern = /hello world/;
|
|
15
|
-
* const partial = createPartialMatchRegex(pattern);
|
|
16
|
-
*
|
|
17
|
-
* partial.test('h'); // true - could match
|
|
18
|
-
* partial.test('hello'); // true - could match
|
|
19
|
-
* partial.test('hello world'); // true - full match
|
|
20
|
-
* partial.test('goodbye'); // false - cannot match
|
|
21
|
-
* ```
|
|
22
|
-
*
|
|
23
|
-
* @remarks
|
|
24
|
-
* - The transformed regex will always match an empty string at the end of input
|
|
25
|
-
* - Backreferences cannot be partially matched as they are atomic
|
|
26
|
-
* - Use with a start anchor (`^`) to prevent false positives from empty string matches
|
|
27
|
-
* - The `y` (sticky) flag may not behave as expected in partial matching scenarios
|
|
28
|
-
*
|
|
29
|
-
* @see {@link https://github.com/TomStrepsil/regex-partial-match#readme | Documentation}
|
|
30
|
-
*/
|
|
31
|
-
export default createPartialMatchRegex;
|
package/lib/index.js
DELETED
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
var occurrencesRegex = /\{\d+,?\d*\}/g;
|
|
2
|
-
var notNumbersRegex = /\D/g;
|
|
3
|
-
var createPartialMatchRegex = function (regex) {
|
|
4
|
-
var source = regex.source;
|
|
5
|
-
var isUnicode = regex.unicode || regex.unicodeSets;
|
|
6
|
-
var i = 0;
|
|
7
|
-
function extractSubstring(length) {
|
|
8
|
-
return source.substring(i, (i += length));
|
|
9
|
-
}
|
|
10
|
-
function process() {
|
|
11
|
-
var result = "";
|
|
12
|
-
function appendOptional(length) {
|
|
13
|
-
result += "(?:" + extractSubstring(length) + "|$)";
|
|
14
|
-
}
|
|
15
|
-
function appendRaw(length) {
|
|
16
|
-
result += extractSubstring(length);
|
|
17
|
-
}
|
|
18
|
-
while (i < source.length) {
|
|
19
|
-
switch (source[i]) {
|
|
20
|
-
case "\\":
|
|
21
|
-
switch (source[i + 1]) {
|
|
22
|
-
case "c":
|
|
23
|
-
appendOptional(3);
|
|
24
|
-
break;
|
|
25
|
-
case "k":
|
|
26
|
-
appendOptional(source.indexOf(">", i) - i + 1);
|
|
27
|
-
break;
|
|
28
|
-
case "u":
|
|
29
|
-
if (isUnicode && source[i + 2] === "{") {
|
|
30
|
-
appendOptional(source.indexOf("}", i) - i + 1);
|
|
31
|
-
}
|
|
32
|
-
else {
|
|
33
|
-
appendOptional(6);
|
|
34
|
-
}
|
|
35
|
-
break;
|
|
36
|
-
case "p":
|
|
37
|
-
case "P":
|
|
38
|
-
if (isUnicode) {
|
|
39
|
-
appendOptional(source.indexOf("}", i) - i + 1);
|
|
40
|
-
}
|
|
41
|
-
else {
|
|
42
|
-
appendOptional(2);
|
|
43
|
-
}
|
|
44
|
-
break;
|
|
45
|
-
case "x":
|
|
46
|
-
appendOptional(4);
|
|
47
|
-
break;
|
|
48
|
-
case "1":
|
|
49
|
-
case "2":
|
|
50
|
-
case "3":
|
|
51
|
-
case "4":
|
|
52
|
-
case "5":
|
|
53
|
-
case "6":
|
|
54
|
-
case "7":
|
|
55
|
-
case "8":
|
|
56
|
-
case "9":
|
|
57
|
-
notNumbersRegex.lastIndex = i + 1;
|
|
58
|
-
var nextNonDigit = notNumbersRegex.exec(source);
|
|
59
|
-
appendOptional((nextNonDigit ? nextNonDigit.index : source.length) - i);
|
|
60
|
-
break;
|
|
61
|
-
default:
|
|
62
|
-
appendOptional(2);
|
|
63
|
-
break;
|
|
64
|
-
}
|
|
65
|
-
break;
|
|
66
|
-
case "[": {
|
|
67
|
-
var depth = 1, escaped = false, j = i + 1;
|
|
68
|
-
while (depth) {
|
|
69
|
-
switch (source[j++]) {
|
|
70
|
-
case "\\":
|
|
71
|
-
escaped = !escaped;
|
|
72
|
-
continue;
|
|
73
|
-
case "[":
|
|
74
|
-
if (!escaped)
|
|
75
|
-
depth++;
|
|
76
|
-
break;
|
|
77
|
-
case "]":
|
|
78
|
-
if (!escaped)
|
|
79
|
-
depth--;
|
|
80
|
-
break;
|
|
81
|
-
}
|
|
82
|
-
escaped = false;
|
|
83
|
-
}
|
|
84
|
-
appendOptional(j - i);
|
|
85
|
-
break;
|
|
86
|
-
}
|
|
87
|
-
case "|":
|
|
88
|
-
case "^":
|
|
89
|
-
case "*":
|
|
90
|
-
case "+":
|
|
91
|
-
case "?":
|
|
92
|
-
case "$":
|
|
93
|
-
appendRaw(1);
|
|
94
|
-
break;
|
|
95
|
-
case "{": {
|
|
96
|
-
occurrencesRegex.lastIndex = i;
|
|
97
|
-
var regExpExecArray = occurrencesRegex.exec(source);
|
|
98
|
-
if (regExpExecArray) {
|
|
99
|
-
appendRaw(regExpExecArray[0].length);
|
|
100
|
-
}
|
|
101
|
-
else {
|
|
102
|
-
appendOptional(1);
|
|
103
|
-
}
|
|
104
|
-
break;
|
|
105
|
-
}
|
|
106
|
-
case "(":
|
|
107
|
-
if (source[i + 1] == "?") {
|
|
108
|
-
switch (source[i + 2]) {
|
|
109
|
-
case ":":
|
|
110
|
-
result += "(?:";
|
|
111
|
-
i += 3;
|
|
112
|
-
result += process() + "|$)";
|
|
113
|
-
break;
|
|
114
|
-
case "=":
|
|
115
|
-
result += "(?=";
|
|
116
|
-
i += 3;
|
|
117
|
-
result += process() + ")";
|
|
118
|
-
break;
|
|
119
|
-
case "-":
|
|
120
|
-
case "i":
|
|
121
|
-
case "s":
|
|
122
|
-
case "m":
|
|
123
|
-
var temp = i + 2, temp2 = source.indexOf(":", temp);
|
|
124
|
-
result += "(?" + source.substring(temp, temp2) + ":";
|
|
125
|
-
i = temp2 + 1;
|
|
126
|
-
result += process() + ")";
|
|
127
|
-
break;
|
|
128
|
-
case "!": {
|
|
129
|
-
var temp_1 = i;
|
|
130
|
-
i += 3;
|
|
131
|
-
process();
|
|
132
|
-
result += source.substring(temp_1, i);
|
|
133
|
-
break;
|
|
134
|
-
}
|
|
135
|
-
case "<":
|
|
136
|
-
switch (source[i + 3]) {
|
|
137
|
-
case "=":
|
|
138
|
-
case "!": {
|
|
139
|
-
var temp_2 = i;
|
|
140
|
-
i += 4;
|
|
141
|
-
process();
|
|
142
|
-
result += source.substring(temp_2, i);
|
|
143
|
-
break;
|
|
144
|
-
}
|
|
145
|
-
default:
|
|
146
|
-
appendRaw(source.indexOf(">", i) - i + 1);
|
|
147
|
-
result += process() + "|$)";
|
|
148
|
-
break;
|
|
149
|
-
}
|
|
150
|
-
break;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
else {
|
|
154
|
-
appendRaw(1);
|
|
155
|
-
result += process() + "|$)";
|
|
156
|
-
}
|
|
157
|
-
break;
|
|
158
|
-
case ")":
|
|
159
|
-
++i;
|
|
160
|
-
return result;
|
|
161
|
-
default:
|
|
162
|
-
appendOptional(1);
|
|
163
|
-
break;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
return result;
|
|
167
|
-
}
|
|
168
|
-
return new RegExp(process(), regex.flags);
|
|
169
|
-
};
|
|
170
|
-
/**
|
|
171
|
-
* Transforms a regular expression to support partial matching.
|
|
172
|
-
*
|
|
173
|
-
* This function wraps each atomic element of the regex pattern in a non-capturing group
|
|
174
|
-
* with an alternation to end-of-input (`$`), allowing the pattern to match prefixes
|
|
175
|
-
* of the original pattern. This enables validation of incomplete input strings.
|
|
176
|
-
*
|
|
177
|
-
* @param regex - The regular expression to transform for partial matching
|
|
178
|
-
* @returns A new RegExp that matches partial strings of the original pattern
|
|
179
|
-
*
|
|
180
|
-
* @example
|
|
181
|
-
* ```typescript
|
|
182
|
-
* const pattern = /hello world/;
|
|
183
|
-
* const partial = createPartialMatchRegex(pattern);
|
|
184
|
-
*
|
|
185
|
-
* partial.test('h'); // true - could match
|
|
186
|
-
* partial.test('hello'); // true - could match
|
|
187
|
-
* partial.test('hello world'); // true - full match
|
|
188
|
-
* partial.test('goodbye'); // false - cannot match
|
|
189
|
-
* ```
|
|
190
|
-
*
|
|
191
|
-
* @remarks
|
|
192
|
-
* - The transformed regex will always match an empty string at the end of input
|
|
193
|
-
* - Backreferences cannot be partially matched as they are atomic
|
|
194
|
-
* - Use with a start anchor (`^`) to prevent false positives from empty string matches
|
|
195
|
-
* - The `y` (sticky) flag may not behave as expected in partial matching scenarios
|
|
196
|
-
*
|
|
197
|
-
* @see {@link https://github.com/TomStrepsil/regex-partial-match#readme | Documentation}
|
|
198
|
-
*/
|
|
199
|
-
export default createPartialMatchRegex;
|