@untangle-code/search 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/NOTICE +90 -0
- package/README.md +433 -2
- package/THIRD_PARTY_LICENSES.md +29 -0
- package/dist/benchmark-integrity.d.ts +12 -0
- package/dist/benchmark-integrity.d.ts.map +1 -0
- package/dist/benchmark-integrity.js +10 -0
- package/dist/benchmark-integrity.js.map +1 -0
- package/dist/bm25.d.ts +46 -0
- package/dist/bm25.d.ts.map +1 -0
- package/dist/bm25.js +105 -0
- package/dist/bm25.js.map +1 -0
- package/dist/deadline.d.ts +4 -0
- package/dist/deadline.d.ts.map +1 -0
- package/dist/deadline.js +54 -0
- package/dist/deadline.js.map +1 -0
- package/dist/dedupe.d.ts +48 -0
- package/dist/dedupe.d.ts.map +1 -0
- package/dist/dedupe.js +58 -0
- package/dist/dedupe.js.map +1 -0
- package/dist/diversify.d.ts +45 -0
- package/dist/diversify.d.ts.map +1 -0
- package/dist/diversify.js +30 -0
- package/dist/diversify.js.map +1 -0
- package/dist/engine.d.ts +157 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +1498 -0
- package/dist/engine.js.map +1 -0
- package/dist/enrichment.d.ts +16 -0
- package/dist/enrichment.d.ts.map +1 -0
- package/dist/enrichment.js +92 -0
- package/dist/enrichment.js.map +1 -0
- package/dist/filter.d.ts +86 -0
- package/dist/filter.d.ts.map +1 -0
- package/dist/filter.js +166 -0
- package/dist/filter.js.map +1 -0
- package/dist/findability.d.ts +144 -0
- package/dist/findability.d.ts.map +1 -0
- package/dist/findability.js +393 -0
- package/dist/findability.js.map +1 -0
- package/dist/format.d.ts +28 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +172 -0
- package/dist/format.js.map +1 -0
- package/dist/fusion.d.ts +47 -0
- package/dist/fusion.d.ts.map +1 -0
- package/dist/fusion.js +84 -0
- package/dist/fusion.js.map +1 -0
- package/dist/glob.d.ts +50 -0
- package/dist/glob.d.ts.map +1 -0
- package/dist/glob.js +144 -0
- package/dist/glob.js.map +1 -0
- package/dist/grep.d.ts +76 -0
- package/dist/grep.d.ts.map +1 -0
- package/dist/grep.js +475 -0
- package/dist/grep.js.map +1 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +63 -0
- package/dist/index.js.map +1 -0
- package/dist/ledger.d.ts +45 -0
- package/dist/ledger.d.ts.map +1 -0
- package/dist/ledger.js +39 -0
- package/dist/ledger.js.map +1 -0
- package/dist/payload.d.ts +108 -0
- package/dist/payload.d.ts.map +1 -0
- package/dist/payload.js +263 -0
- package/dist/payload.js.map +1 -0
- package/dist/port.d.ts +112 -0
- package/dist/port.d.ts.map +1 -0
- package/dist/port.js +2 -0
- package/dist/port.js.map +1 -0
- package/dist/product-complexity.d.ts +89 -0
- package/dist/product-complexity.d.ts.map +1 -0
- package/dist/product-complexity.js +1367 -0
- package/dist/product-complexity.js.map +1 -0
- package/dist/regex-safety.d.ts +75 -0
- package/dist/regex-safety.d.ts.map +1 -0
- package/dist/regex-safety.js +902 -0
- package/dist/regex-safety.js.map +1 -0
- package/dist/relevance-prerequisites.d.ts +21 -0
- package/dist/relevance-prerequisites.d.ts.map +1 -0
- package/dist/relevance-prerequisites.js +47 -0
- package/dist/relevance-prerequisites.js.map +1 -0
- package/dist/relevance.d.ts +107 -0
- package/dist/relevance.d.ts.map +1 -0
- package/dist/relevance.js +199 -0
- package/dist/relevance.js.map +1 -0
- package/dist/retrieval.d.ts +46 -0
- package/dist/retrieval.d.ts.map +1 -0
- package/dist/retrieval.js +182 -0
- package/dist/retrieval.js.map +1 -0
- package/dist/scoring.d.ts +102 -0
- package/dist/scoring.d.ts.map +1 -0
- package/dist/scoring.js +175 -0
- package/dist/scoring.js.map +1 -0
- package/dist/sort.d.ts +21 -0
- package/dist/sort.d.ts.map +1 -0
- package/dist/sort.js +53 -0
- package/dist/sort.js.map +1 -0
- package/dist/symbol-match.d.ts +21 -0
- package/dist/symbol-match.d.ts.map +1 -0
- package/dist/symbol-match.js +72 -0
- package/dist/symbol-match.js.map +1 -0
- package/dist/tokenize.d.ts +41 -0
- package/dist/tokenize.d.ts.map +1 -0
- package/dist/tokenize.js +202 -0
- package/dist/tokenize.js.map +1 -0
- package/dist/types.d.ts +173 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +42 -0
- package/dist/types.js.map +1 -0
- package/package.json +61 -3
- package/src/benchmark-integrity.ts +25 -0
- package/src/bm25.ts +135 -0
- package/src/deadline.ts +59 -0
- package/src/dedupe.ts +99 -0
- package/src/diversify.ts +71 -0
- package/src/engine.ts +1957 -0
- package/src/enrichment.ts +115 -0
- package/src/filter.ts +267 -0
- package/src/findability.ts +580 -0
- package/src/format.ts +228 -0
- package/src/fusion.ts +121 -0
- package/src/glob.ts +151 -0
- package/src/grep.ts +618 -0
- package/src/index.ts +83 -0
- package/src/ledger.ts +72 -0
- package/src/payload.ts +382 -0
- package/src/port.ts +133 -0
- package/src/product-complexity.ts +2168 -0
- package/src/regex-safety.ts +1002 -0
- package/src/relevance-prerequisites.ts +83 -0
- package/src/relevance.ts +328 -0
- package/src/retrieval.ts +299 -0
- package/src/scoring.ts +321 -0
- package/src/sort.ts +86 -0
- package/src/symbol-match.ts +87 -0
- package/src/tokenize.ts +213 -0
- package/src/types.ts +206 -0
|
@@ -0,0 +1,1002 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Erkennung von Suchmustern, deren Auswertung exponentiell werden kann.
|
|
3
|
+
*
|
|
4
|
+
* WARUM ES DIESE PRUEFUNG UEBERHAUPT GIBT: Die Auswertung eines regulaeren
|
|
5
|
+
* Ausdrucks laeuft in JavaScript als EIN synchroner Aufruf. Solange er laeuft,
|
|
6
|
+
* ist die Ereignisschleife blockiert — ein Timer feuert nicht, ein
|
|
7
|
+
* `AbortSignal` kann nicht geprueft werden, ein Budget „durchsuchte Chunks"
|
|
8
|
+
* wird nicht erreicht. Gemessen: ein einziger Chunk mit einer 31 Zeichen
|
|
9
|
+
* langen Zeile und dem Muster `(a+)+$` hat den Aufruf ueber 88 Sekunden
|
|
10
|
+
* festgehalten, obwohl `maxScannedChunks` 1 war und das Abbruchsignal nach
|
|
11
|
+
* 200 ms gesetzt wurde. Ein gefaehrliches Muster muss deshalb VOR dem ersten
|
|
12
|
+
* Speicherzugriff abgelehnt werden; nachtraeglich abbrechen laesst es sich
|
|
13
|
+
* nicht.
|
|
14
|
+
*
|
|
15
|
+
* WARUM NICHT „VERSCHACHTELTER QUANTOR" ALS KRITERIUM: Die erste Fassung
|
|
16
|
+
* dieser Pruefung suchte eine Wiederholung ueber einer Gruppe, die selbst eine
|
|
17
|
+
* unbegrenzte Wiederholung enthaelt (`(a+)+`). Das ist ein SYNTAKTISCHER
|
|
18
|
+
* Stellvertreter fuer die eigentliche Eigenschaft und trifft sie in beide
|
|
19
|
+
* Richtungen daneben:
|
|
20
|
+
*
|
|
21
|
+
* - `(a|a)+$` und `(\s|\t)+$` enthalten keinen verschachtelten Quantor und
|
|
22
|
+
* liefen trotzdem nie zurueck (gemessen: nach 30 s per SIGALRM getoetet).
|
|
23
|
+
* `(\s|\t)+$` ist dabei ein alltaegliches Muster — Suche nach
|
|
24
|
+
* Zeilenendleerraum.
|
|
25
|
+
* - `(a+b)+` und `(\w+\.)+` wurden abgelehnt, obwohl ihre Auswertung
|
|
26
|
+
* hoechstens polynomiell ist.
|
|
27
|
+
*
|
|
28
|
+
* Die tatsaechliche Ursache exponentieller Laufzeit ist MEHRDEUTIGKEIT unter
|
|
29
|
+
* einer unbegrenzten Wiederholung: `R+` wird exponentiell, wenn ein Wort auf
|
|
30
|
+
* mehr als eine Art durch `R` bzw. durch `R·R` zerlegt werden kann. Genau das
|
|
31
|
+
* prueft dieses Modul — an einem geparsten Baum, nicht an der Zeichenkette.
|
|
32
|
+
* Fuer jede unbegrenzte Wiederholung `R{n,}` gilt der Rumpf `R` als
|
|
33
|
+
* gefaehrlich, wenn
|
|
34
|
+
*
|
|
35
|
+
* (1) `R` das leere Wort matchen kann (`(a*)*`, `(a?)+`),
|
|
36
|
+
* (2) `R` eine Alternative enthaelt, deren Zweige ein GEMEINSAMES Wort
|
|
37
|
+
* matchen koennen (`(a|a)`, `(\s|\t)`),
|
|
38
|
+
* (3a) `R` am Ende unbegrenzt Zeichen aufsaugen kann, die `R` auch beginnen
|
|
39
|
+
* koennen (`a+`, `\w+\s?`), oder
|
|
40
|
+
* (3b) `R` am Anfang unbegrenzt Zeichen aufsaugen kann, die `R` auch beenden
|
|
41
|
+
* koennen (`\w+a`).
|
|
42
|
+
*
|
|
43
|
+
* `(a+b)+` und `(\w+\.)+` fallen durch keine dieser Regeln: der abschliessende
|
|
44
|
+
* Pflichtteil (`b`, `.`) ist ein Trennzeichen, das die Zerlegung eindeutig
|
|
45
|
+
* macht. Sie werden jetzt ausgefuehrt.
|
|
46
|
+
*
|
|
47
|
+
* BEKANNTE GRENZE, ausdruecklich benannt: Das bleibt eine ENTSCHEIDBARE
|
|
48
|
+
* Naeherung eines unentscheidbaren Problems. Sie ist bewusst auf die sichere
|
|
49
|
+
* Seite gebaut — im Zweifel wird abgelehnt (Rueckwaertsreferenzen unter einer
|
|
50
|
+
* Wiederholung, `\p{…}` als „irgendein Zeichen") — aber sie ist kein Beweis.
|
|
51
|
+
* Ein Muster, das sie passiert, kann im schlimmsten Fall noch immer
|
|
52
|
+
* polynomiell teuer sein (etwa `\w+\d+` mit quadratischem Aufwand); nur die
|
|
53
|
+
* exponentielle Familie ist abgedeckt.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Zeichenmengen
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
/** Groesster Unicode-Codepoint. */
|
|
61
|
+
const MAX_CODE_POINT = 0x10ffff;
|
|
62
|
+
|
|
63
|
+
/** Eine Zeichenmenge als sortierte, disjunkte, geschlossene Intervalle. */
|
|
64
|
+
interface CharSet {
|
|
65
|
+
ranges: readonly (readonly [number, number])[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const EMPTY_SET: CharSet = { ranges: [] };
|
|
69
|
+
const UNIVERSE: CharSet = { ranges: [[0, MAX_CODE_POINT]] };
|
|
70
|
+
|
|
71
|
+
function setOf(...ranges: (readonly [number, number])[]): CharSet {
|
|
72
|
+
return normalizeSet({ ranges });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function singleton(codePoint: number): CharSet {
|
|
76
|
+
return { ranges: [[codePoint, codePoint]] };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normalizeSet(set: CharSet): CharSet {
|
|
80
|
+
const sorted = [...set.ranges]
|
|
81
|
+
.filter(([low, high]) => low <= high)
|
|
82
|
+
.sort((left, right) => left[0] - right[0] || left[1] - right[1]);
|
|
83
|
+
const merged: [number, number][] = [];
|
|
84
|
+
for (const [low, high] of sorted) {
|
|
85
|
+
const last = merged[merged.length - 1];
|
|
86
|
+
if (last !== undefined && low <= last[1] + 1) {
|
|
87
|
+
last[1] = Math.max(last[1], high);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
merged.push([low, high]);
|
|
91
|
+
}
|
|
92
|
+
return { ranges: merged };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function unionSets(left: CharSet, right: CharSet): CharSet {
|
|
96
|
+
return normalizeSet({ ranges: [...left.ranges, ...right.ranges] });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function complementSet(set: CharSet): CharSet {
|
|
100
|
+
const normalized = normalizeSet(set);
|
|
101
|
+
const ranges: [number, number][] = [];
|
|
102
|
+
let next = 0;
|
|
103
|
+
for (const [low, high] of normalized.ranges) {
|
|
104
|
+
if (low > next) {
|
|
105
|
+
ranges.push([next, low - 1]);
|
|
106
|
+
}
|
|
107
|
+
next = Math.max(next, high + 1);
|
|
108
|
+
}
|
|
109
|
+
if (next <= MAX_CODE_POINT) {
|
|
110
|
+
ranges.push([next, MAX_CODE_POINT]);
|
|
111
|
+
}
|
|
112
|
+
return { ranges };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function setsIntersect(left: CharSet, right: CharSet): boolean {
|
|
116
|
+
let leftIndex = 0;
|
|
117
|
+
let rightIndex = 0;
|
|
118
|
+
while (leftIndex < left.ranges.length && rightIndex < right.ranges.length) {
|
|
119
|
+
const a = left.ranges[leftIndex] as readonly [number, number];
|
|
120
|
+
const b = right.ranges[rightIndex] as readonly [number, number];
|
|
121
|
+
if (Math.max(a[0], b[0]) <= Math.min(a[1], b[1])) {
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
if (a[1] < b[1]) {
|
|
125
|
+
leftIndex += 1;
|
|
126
|
+
} else {
|
|
127
|
+
rightIndex += 1;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isEmptySet(set: CharSet): boolean {
|
|
134
|
+
return set.ranges.length === 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* ASCII-Faltung fuer `i`-Suchen. Nur ASCII: eine unvollstaendige Faltung macht
|
|
139
|
+
* die Menge KLEINER als die Wirklichkeit, und eine zu kleine Menge koennte
|
|
140
|
+
* eine Ueberschneidung uebersehen. Deshalb wird bei `i` zusaetzlich jedes
|
|
141
|
+
* Intervall, das Buchstaben beruehrt, auf beide Schreibweisen erweitert — im
|
|
142
|
+
* Zweifel zu gross, also auf der ablehnenden Seite.
|
|
143
|
+
*/
|
|
144
|
+
function foldCase(set: CharSet): CharSet {
|
|
145
|
+
const upperA = 0x41;
|
|
146
|
+
const upperZ = 0x5a;
|
|
147
|
+
const lowerA = 0x61;
|
|
148
|
+
const lowerZ = 0x7a;
|
|
149
|
+
const extra: [number, number][] = [];
|
|
150
|
+
for (const [low, high] of set.ranges) {
|
|
151
|
+
const upperLow = Math.max(low, upperA);
|
|
152
|
+
const upperHigh = Math.min(high, upperZ);
|
|
153
|
+
if (upperLow <= upperHigh) {
|
|
154
|
+
extra.push([upperLow + 0x20, upperHigh + 0x20]);
|
|
155
|
+
}
|
|
156
|
+
const lowerLow = Math.max(low, lowerA);
|
|
157
|
+
const lowerHigh = Math.min(high, lowerZ);
|
|
158
|
+
if (lowerLow <= lowerHigh) {
|
|
159
|
+
extra.push([lowerLow - 0x20, lowerHigh - 0x20]);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return extra.length === 0 ? set : unionSets(set, { ranges: extra });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const DIGIT_SET = setOf([0x30, 0x39]);
|
|
166
|
+
const WORD_SET = setOf([0x30, 0x39], [0x41, 0x5a], [0x5f, 0x5f], [0x61, 0x7a]);
|
|
167
|
+
const SPACE_SET = setOf(
|
|
168
|
+
[0x09, 0x0d],
|
|
169
|
+
[0x20, 0x20],
|
|
170
|
+
[0xa0, 0xa0],
|
|
171
|
+
[0x1680, 0x1680],
|
|
172
|
+
[0x2000, 0x200a],
|
|
173
|
+
[0x2028, 0x2029],
|
|
174
|
+
[0x202f, 0x202f],
|
|
175
|
+
[0x205f, 0x205f],
|
|
176
|
+
[0x3000, 0x3000],
|
|
177
|
+
[0xfeff, 0xfeff],
|
|
178
|
+
);
|
|
179
|
+
/** `.` ohne `s`-Flag: alles ausser den Zeilentrennern. */
|
|
180
|
+
const DOT_SET = complementSet(setOf([0x0a, 0x0a], [0x0d, 0x0d], [0x2028, 0x2029]));
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// Baum
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
type RegexNode =
|
|
187
|
+
/** Ein zeichenverbrauchendes Atom. */
|
|
188
|
+
| { kind: "chars"; set: CharSet }
|
|
189
|
+
/** Nullbreite ohne eigene Zeichen: `^`, `$`, `\b`, Lookarounds. */
|
|
190
|
+
| { kind: "zeroWidth" }
|
|
191
|
+
/** Nicht analysierbar: Rueckwaertsreferenz (Laenge unbekannt). */
|
|
192
|
+
| { kind: "opaque"; text: string }
|
|
193
|
+
| { kind: "concat"; parts: RegexNode[] }
|
|
194
|
+
| { kind: "alt"; branches: RegexNode[] }
|
|
195
|
+
| { kind: "repeat"; body: RegexNode; min: number; max: number; text: string }
|
|
196
|
+
| { kind: "group"; body: RegexNode; text: string };
|
|
197
|
+
|
|
198
|
+
class PatternParseError extends Error {}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Rekursiv absteigender Parser fuer die Teilmenge der JavaScript-Syntax, die
|
|
202
|
+
* fuer die Mehrdeutigkeitsfrage zaehlt. Was er nicht versteht, wird zu
|
|
203
|
+
* `opaque` oder `chars(UNIVERSE)` — beides faellt in der Auswertung auf die
|
|
204
|
+
* ablehnende Seite. Ein Muster, das er gar nicht parsen kann, wird NICHT
|
|
205
|
+
* abgelehnt: dann ist es ohnehin syntaktisch fehlerhaft und `new RegExp`
|
|
206
|
+
* meldet den echten Fehler.
|
|
207
|
+
*/
|
|
208
|
+
class PatternParser {
|
|
209
|
+
private index = 0;
|
|
210
|
+
|
|
211
|
+
public constructor(
|
|
212
|
+
private readonly pattern: string,
|
|
213
|
+
private readonly caseInsensitive: boolean,
|
|
214
|
+
) {}
|
|
215
|
+
|
|
216
|
+
public parse(): RegexNode {
|
|
217
|
+
const node = this.parseAlternation();
|
|
218
|
+
if (this.index < this.pattern.length) {
|
|
219
|
+
throw new PatternParseError(`unexpected "${this.pattern[this.index] ?? ""}"`);
|
|
220
|
+
}
|
|
221
|
+
return node;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private parseAlternation(): RegexNode {
|
|
225
|
+
const branches: RegexNode[] = [this.parseConcat()];
|
|
226
|
+
while (this.peek() === "|") {
|
|
227
|
+
this.index += 1;
|
|
228
|
+
branches.push(this.parseConcat());
|
|
229
|
+
}
|
|
230
|
+
return branches.length === 1 ? (branches[0] as RegexNode) : { kind: "alt", branches };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private parseConcat(): RegexNode {
|
|
234
|
+
const parts: RegexNode[] = [];
|
|
235
|
+
for (;;) {
|
|
236
|
+
const char = this.peek();
|
|
237
|
+
if (char === undefined || char === "|" || char === ")") {
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
parts.push(this.parseQuantified());
|
|
241
|
+
}
|
|
242
|
+
if (parts.length === 0) {
|
|
243
|
+
return { kind: "zeroWidth" };
|
|
244
|
+
}
|
|
245
|
+
return parts.length === 1 ? (parts[0] as RegexNode) : { kind: "concat", parts };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private parseQuantified(): RegexNode {
|
|
249
|
+
const start = this.index;
|
|
250
|
+
const atom = this.parseAtom();
|
|
251
|
+
const quantifier = this.readQuantifier();
|
|
252
|
+
if (quantifier === undefined) {
|
|
253
|
+
return atom;
|
|
254
|
+
}
|
|
255
|
+
// Faules `?` bzw. `+` (nur ES2025) aendert die Suchreihenfolge, nicht den
|
|
256
|
+
// schlimmsten Fall — es wird uebergangen.
|
|
257
|
+
if (this.peek() === "?" || this.peek() === "+") {
|
|
258
|
+
this.index += 1;
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
kind: "repeat",
|
|
262
|
+
body: atom,
|
|
263
|
+
min: quantifier.min,
|
|
264
|
+
max: quantifier.max,
|
|
265
|
+
text: this.pattern.slice(start, this.index),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private parseAtom(): RegexNode {
|
|
270
|
+
const start = this.index;
|
|
271
|
+
const char = this.peek();
|
|
272
|
+
if (char === undefined) {
|
|
273
|
+
throw new PatternParseError("unexpected end of pattern");
|
|
274
|
+
}
|
|
275
|
+
if (char === "(") {
|
|
276
|
+
return this.parseGroup(start);
|
|
277
|
+
}
|
|
278
|
+
if (char === "[") {
|
|
279
|
+
return { kind: "chars", set: this.parseCharacterClass() };
|
|
280
|
+
}
|
|
281
|
+
if (char === "\\") {
|
|
282
|
+
return this.parseEscape();
|
|
283
|
+
}
|
|
284
|
+
if (char === "^" || char === "$") {
|
|
285
|
+
this.index += 1;
|
|
286
|
+
return { kind: "zeroWidth" };
|
|
287
|
+
}
|
|
288
|
+
if (char === ".") {
|
|
289
|
+
this.index += 1;
|
|
290
|
+
return { kind: "chars", set: this.applyCase(DOT_SET) };
|
|
291
|
+
}
|
|
292
|
+
if (char === "*" || char === "+" || char === "?") {
|
|
293
|
+
throw new PatternParseError(`quantifier "${char}" without a preceding atom`);
|
|
294
|
+
}
|
|
295
|
+
const codePoint = this.pattern.codePointAt(this.index);
|
|
296
|
+
if (codePoint === undefined) {
|
|
297
|
+
throw new PatternParseError("unexpected end of pattern");
|
|
298
|
+
}
|
|
299
|
+
this.index += String.fromCodePoint(codePoint).length;
|
|
300
|
+
return { kind: "chars", set: this.applyCase(singleton(codePoint)) };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private parseGroup(start: number): RegexNode {
|
|
304
|
+
this.index += 1; // "("
|
|
305
|
+
let isLookaround = false;
|
|
306
|
+
if (this.peek() === "?") {
|
|
307
|
+
const next = this.pattern[this.index + 1];
|
|
308
|
+
if (next === ":") {
|
|
309
|
+
this.index += 2;
|
|
310
|
+
} else if (next === "=" || next === "!") {
|
|
311
|
+
this.index += 2;
|
|
312
|
+
isLookaround = true;
|
|
313
|
+
} else if (
|
|
314
|
+
next === "<" &&
|
|
315
|
+
(this.pattern[this.index + 2] === "=" || this.pattern[this.index + 2] === "!")
|
|
316
|
+
) {
|
|
317
|
+
this.index += 3;
|
|
318
|
+
isLookaround = true;
|
|
319
|
+
} else if (next === "<") {
|
|
320
|
+
const close = this.pattern.indexOf(">", this.index + 2);
|
|
321
|
+
if (close === -1) {
|
|
322
|
+
throw new PatternParseError("unterminated group name");
|
|
323
|
+
}
|
|
324
|
+
this.index = close + 1;
|
|
325
|
+
} else {
|
|
326
|
+
throw new PatternParseError(`unsupported group prefix "(?${next ?? ""}"`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
const body = this.parseAlternation();
|
|
330
|
+
if (this.peek() !== ")") {
|
|
331
|
+
throw new PatternParseError("unbalanced parenthesis");
|
|
332
|
+
}
|
|
333
|
+
this.index += 1;
|
|
334
|
+
if (isLookaround) {
|
|
335
|
+
// Nullbreite: ein Lookaround verbraucht kein Zeichen und traegt deshalb
|
|
336
|
+
// weder zur Zerlegung noch zur Mehrdeutigkeit von `R·R` bei. Ihn
|
|
337
|
+
// pauschal abzulehnen wuerde alltaegliche Muster wie `(?:(?!foo)\w)+`
|
|
338
|
+
// treffen, die nachweislich eindeutig sind.
|
|
339
|
+
return { kind: "zeroWidth" };
|
|
340
|
+
}
|
|
341
|
+
return { kind: "group", body, text: this.pattern.slice(start, this.index) };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private parseEscape(): RegexNode {
|
|
345
|
+
this.index += 1; // "\\"
|
|
346
|
+
const char = this.peek();
|
|
347
|
+
if (char === undefined) {
|
|
348
|
+
throw new PatternParseError("pattern ends with a backslash");
|
|
349
|
+
}
|
|
350
|
+
this.index += 1;
|
|
351
|
+
switch (char) {
|
|
352
|
+
case "d":
|
|
353
|
+
return { kind: "chars", set: DIGIT_SET };
|
|
354
|
+
case "D":
|
|
355
|
+
return { kind: "chars", set: complementSet(DIGIT_SET) };
|
|
356
|
+
case "w":
|
|
357
|
+
return { kind: "chars", set: this.applyCase(WORD_SET) };
|
|
358
|
+
case "W":
|
|
359
|
+
return { kind: "chars", set: complementSet(WORD_SET) };
|
|
360
|
+
case "s":
|
|
361
|
+
return { kind: "chars", set: SPACE_SET };
|
|
362
|
+
case "S":
|
|
363
|
+
return { kind: "chars", set: complementSet(SPACE_SET) };
|
|
364
|
+
case "b":
|
|
365
|
+
case "B":
|
|
366
|
+
return { kind: "zeroWidth" };
|
|
367
|
+
case "t":
|
|
368
|
+
return { kind: "chars", set: singleton(0x09) };
|
|
369
|
+
case "n":
|
|
370
|
+
return { kind: "chars", set: singleton(0x0a) };
|
|
371
|
+
case "r":
|
|
372
|
+
return { kind: "chars", set: singleton(0x0d) };
|
|
373
|
+
case "f":
|
|
374
|
+
return { kind: "chars", set: singleton(0x0c) };
|
|
375
|
+
case "v":
|
|
376
|
+
return { kind: "chars", set: singleton(0x0b) };
|
|
377
|
+
case "0":
|
|
378
|
+
return { kind: "chars", set: singleton(0x00) };
|
|
379
|
+
case "p":
|
|
380
|
+
case "P": {
|
|
381
|
+
// `\p{…}` ist EIN Zeichen, dessen Menge hier nicht aufgeloest wird:
|
|
382
|
+
// als „irgendein Zeichen" gelesen ueberschneidet es sich mit allem und
|
|
383
|
+
// faellt damit auf die ablehnende Seite.
|
|
384
|
+
this.skipBracedArgument();
|
|
385
|
+
return { kind: "chars", set: UNIVERSE };
|
|
386
|
+
}
|
|
387
|
+
case "k": {
|
|
388
|
+
this.skipAngledArgument();
|
|
389
|
+
return { kind: "opaque", text: "\\k<…>" };
|
|
390
|
+
}
|
|
391
|
+
case "u":
|
|
392
|
+
case "x": {
|
|
393
|
+
const codePoint = this.readHexEscape(char);
|
|
394
|
+
return { kind: "chars", set: this.applyCase(singleton(codePoint)) };
|
|
395
|
+
}
|
|
396
|
+
case "c": {
|
|
397
|
+
this.index += 1;
|
|
398
|
+
return { kind: "chars", set: UNIVERSE };
|
|
399
|
+
}
|
|
400
|
+
default: {
|
|
401
|
+
if (char >= "1" && char <= "9") {
|
|
402
|
+
while (/[0-9]/u.test(this.peek() ?? "")) {
|
|
403
|
+
this.index += 1;
|
|
404
|
+
}
|
|
405
|
+
return { kind: "opaque", text: `\\${char}` };
|
|
406
|
+
}
|
|
407
|
+
return { kind: "chars", set: this.applyCase(singleton(char.codePointAt(0) as number)) };
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private readHexEscape(kind: string): number {
|
|
413
|
+
if (kind === "u" && this.peek() === "{") {
|
|
414
|
+
const close = this.pattern.indexOf("}", this.index);
|
|
415
|
+
if (close === -1) {
|
|
416
|
+
throw new PatternParseError("unterminated \\u{…}");
|
|
417
|
+
}
|
|
418
|
+
const value = Number.parseInt(this.pattern.slice(this.index + 1, close), 16);
|
|
419
|
+
this.index = close + 1;
|
|
420
|
+
return Number.isNaN(value) ? 0 : value;
|
|
421
|
+
}
|
|
422
|
+
const width = kind === "u" ? 4 : 2;
|
|
423
|
+
const text = this.pattern.slice(this.index, this.index + width);
|
|
424
|
+
if (text.length < width || !/^[0-9a-fA-F]+$/u.test(text)) {
|
|
425
|
+
throw new PatternParseError(`malformed \\${kind} escape`);
|
|
426
|
+
}
|
|
427
|
+
this.index += width;
|
|
428
|
+
return Number.parseInt(text, 16);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private skipBracedArgument(): void {
|
|
432
|
+
if (this.peek() !== "{") {
|
|
433
|
+
throw new PatternParseError("\\p must be followed by {…}");
|
|
434
|
+
}
|
|
435
|
+
const close = this.pattern.indexOf("}", this.index);
|
|
436
|
+
if (close === -1) {
|
|
437
|
+
throw new PatternParseError("unterminated \\p{…}");
|
|
438
|
+
}
|
|
439
|
+
this.index = close + 1;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
private skipAngledArgument(): void {
|
|
443
|
+
if (this.peek() !== "<") {
|
|
444
|
+
throw new PatternParseError("\\k must be followed by <…>");
|
|
445
|
+
}
|
|
446
|
+
const close = this.pattern.indexOf(">", this.index);
|
|
447
|
+
if (close === -1) {
|
|
448
|
+
throw new PatternParseError("unterminated \\k<…>");
|
|
449
|
+
}
|
|
450
|
+
this.index = close + 1;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
private parseCharacterClass(): CharSet {
|
|
454
|
+
this.index += 1; // "["
|
|
455
|
+
let negated = false;
|
|
456
|
+
if (this.peek() === "^") {
|
|
457
|
+
negated = true;
|
|
458
|
+
this.index += 1;
|
|
459
|
+
}
|
|
460
|
+
let set: CharSet = EMPTY_SET;
|
|
461
|
+
let pendingLow: number | undefined;
|
|
462
|
+
let closed = false;
|
|
463
|
+
while (this.index < this.pattern.length) {
|
|
464
|
+
if (this.peek() === "]") {
|
|
465
|
+
this.index += 1;
|
|
466
|
+
closed = true;
|
|
467
|
+
break;
|
|
468
|
+
}
|
|
469
|
+
const item = this.readClassItem();
|
|
470
|
+
if (item.set !== undefined) {
|
|
471
|
+
// Eine Klassen-Kurzform (`\d`) kann kein Bereichsende sein.
|
|
472
|
+
if (pendingLow !== undefined) {
|
|
473
|
+
set = unionSets(set, singleton(pendingLow));
|
|
474
|
+
pendingLow = undefined;
|
|
475
|
+
}
|
|
476
|
+
set = unionSets(set, item.set);
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
const codePoint = item.codePoint as number;
|
|
480
|
+
if (pendingLow !== undefined) {
|
|
481
|
+
set = unionSets(set, setOf([pendingLow, codePoint]));
|
|
482
|
+
pendingLow = undefined;
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
if (this.peek() === "-" && this.pattern[this.index + 1] !== "]") {
|
|
486
|
+
this.index += 1;
|
|
487
|
+
pendingLow = codePoint;
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
set = unionSets(set, singleton(codePoint));
|
|
491
|
+
}
|
|
492
|
+
if (!closed) {
|
|
493
|
+
throw new PatternParseError("unterminated character class");
|
|
494
|
+
}
|
|
495
|
+
if (pendingLow !== undefined) {
|
|
496
|
+
set = unionSets(set, singleton(pendingLow));
|
|
497
|
+
}
|
|
498
|
+
const resolved = negated ? complementSet(set) : set;
|
|
499
|
+
return this.applyCase(resolved);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private readClassItem(): { set?: CharSet; codePoint?: number } {
|
|
503
|
+
if (this.peek() !== "\\") {
|
|
504
|
+
const codePoint = this.pattern.codePointAt(this.index) as number;
|
|
505
|
+
this.index += String.fromCodePoint(codePoint).length;
|
|
506
|
+
return { codePoint };
|
|
507
|
+
}
|
|
508
|
+
const node = this.parseEscape();
|
|
509
|
+
if (node.kind === "chars") {
|
|
510
|
+
if (node.set.ranges.length === 1) {
|
|
511
|
+
const [low, high] = node.set.ranges[0] as readonly [number, number];
|
|
512
|
+
if (low === high) {
|
|
513
|
+
return { codePoint: low };
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return { set: node.set };
|
|
517
|
+
}
|
|
518
|
+
// `\b` heisst in einer Klasse Backspace; alles Uebrige (Rueckwaerts-
|
|
519
|
+
// referenzen gibt es hier nicht) wird als „irgendein Zeichen" gelesen.
|
|
520
|
+
return { set: UNIVERSE };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
private readQuantifier(): { min: number; max: number } | undefined {
|
|
524
|
+
const char = this.peek();
|
|
525
|
+
if (char === "*") {
|
|
526
|
+
this.index += 1;
|
|
527
|
+
return { min: 0, max: Number.POSITIVE_INFINITY };
|
|
528
|
+
}
|
|
529
|
+
if (char === "+") {
|
|
530
|
+
this.index += 1;
|
|
531
|
+
return { min: 1, max: Number.POSITIVE_INFINITY };
|
|
532
|
+
}
|
|
533
|
+
if (char === "?") {
|
|
534
|
+
this.index += 1;
|
|
535
|
+
return { min: 0, max: 1 };
|
|
536
|
+
}
|
|
537
|
+
if (char !== "{") {
|
|
538
|
+
return undefined;
|
|
539
|
+
}
|
|
540
|
+
const close = this.pattern.indexOf("}", this.index + 1);
|
|
541
|
+
if (close === -1) {
|
|
542
|
+
return undefined; // `{` als Literal
|
|
543
|
+
}
|
|
544
|
+
const body = this.pattern.slice(this.index + 1, close);
|
|
545
|
+
const match = /^(\d+)(,(\d*)?)?$/u.exec(body);
|
|
546
|
+
if (match === null) {
|
|
547
|
+
return undefined; // `{` als Literal
|
|
548
|
+
}
|
|
549
|
+
const min = Number(match[1]);
|
|
550
|
+
const hasComma = match[2] !== undefined;
|
|
551
|
+
const maxText = match[3];
|
|
552
|
+
const max =
|
|
553
|
+
hasComma && (maxText === undefined || maxText === "")
|
|
554
|
+
? Number.POSITIVE_INFINITY
|
|
555
|
+
: hasComma
|
|
556
|
+
? Number(maxText)
|
|
557
|
+
: min;
|
|
558
|
+
this.index = close + 1;
|
|
559
|
+
return { min, max };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private applyCase(set: CharSet): CharSet {
|
|
563
|
+
return this.caseInsensitive ? foldCase(set) : set;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
private peek(): string | undefined {
|
|
567
|
+
return this.pattern[this.index];
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ---------------------------------------------------------------------------
|
|
572
|
+
// Auswertung
|
|
573
|
+
// ---------------------------------------------------------------------------
|
|
574
|
+
|
|
575
|
+
/** Kann der Knoten das leere Wort matchen? */
|
|
576
|
+
function isNullable(node: RegexNode): boolean {
|
|
577
|
+
switch (node.kind) {
|
|
578
|
+
case "chars":
|
|
579
|
+
return false;
|
|
580
|
+
case "zeroWidth":
|
|
581
|
+
return true;
|
|
582
|
+
case "opaque":
|
|
583
|
+
return true; // eine Rueckwaertsreferenz kann leer sein
|
|
584
|
+
case "concat":
|
|
585
|
+
return node.parts.every(isNullable);
|
|
586
|
+
case "alt":
|
|
587
|
+
return node.branches.some(isNullable);
|
|
588
|
+
case "repeat":
|
|
589
|
+
return node.min === 0 || isNullable(node.body);
|
|
590
|
+
case "group":
|
|
591
|
+
return isNullable(node.body);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** Alle Zeichen, die der Knoten ueberhaupt verbrauchen kann (Ueberschaetzung). */
|
|
596
|
+
function consumableSet(node: RegexNode): CharSet {
|
|
597
|
+
switch (node.kind) {
|
|
598
|
+
case "chars":
|
|
599
|
+
return node.set;
|
|
600
|
+
case "zeroWidth":
|
|
601
|
+
return EMPTY_SET;
|
|
602
|
+
case "opaque":
|
|
603
|
+
return UNIVERSE;
|
|
604
|
+
case "concat":
|
|
605
|
+
return node.parts.reduce<CharSet>(
|
|
606
|
+
(acc, part) => unionSets(acc, consumableSet(part)),
|
|
607
|
+
EMPTY_SET,
|
|
608
|
+
);
|
|
609
|
+
case "alt":
|
|
610
|
+
return node.branches.reduce<CharSet>(
|
|
611
|
+
(acc, branch) => unionSets(acc, consumableSet(branch)),
|
|
612
|
+
EMPTY_SET,
|
|
613
|
+
);
|
|
614
|
+
case "repeat":
|
|
615
|
+
return node.max === 0 ? EMPTY_SET : consumableSet(node.body);
|
|
616
|
+
case "group":
|
|
617
|
+
return consumableSet(node.body);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Zeichen, mit denen ein Treffer des Knotens beginnen kann. */
|
|
622
|
+
function firstSet(node: RegexNode): CharSet {
|
|
623
|
+
return edgeSet(node, "first");
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** Zeichen, mit denen ein Treffer des Knotens enden kann. */
|
|
627
|
+
function lastSet(node: RegexNode): CharSet {
|
|
628
|
+
return edgeSet(node, "last");
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function edgeSet(node: RegexNode, side: "first" | "last"): CharSet {
|
|
632
|
+
switch (node.kind) {
|
|
633
|
+
case "chars":
|
|
634
|
+
return node.set;
|
|
635
|
+
case "zeroWidth":
|
|
636
|
+
return EMPTY_SET;
|
|
637
|
+
case "opaque":
|
|
638
|
+
return UNIVERSE;
|
|
639
|
+
case "alt":
|
|
640
|
+
return node.branches.reduce<CharSet>(
|
|
641
|
+
(acc, branch) => unionSets(acc, edgeSet(branch, side)),
|
|
642
|
+
EMPTY_SET,
|
|
643
|
+
);
|
|
644
|
+
case "group":
|
|
645
|
+
return edgeSet(node.body, side);
|
|
646
|
+
case "repeat":
|
|
647
|
+
return node.max === 0 ? EMPTY_SET : edgeSet(node.body, side);
|
|
648
|
+
case "concat": {
|
|
649
|
+
const order = side === "first" ? node.parts : [...node.parts].reverse();
|
|
650
|
+
let acc: CharSet = EMPTY_SET;
|
|
651
|
+
for (const part of order) {
|
|
652
|
+
acc = unionSets(acc, edgeSet(part, side));
|
|
653
|
+
if (!isNullable(part)) {
|
|
654
|
+
break;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return acc;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Zeichen, die der Knoten an seinem Rand UNBEGRENZT aufsaugen kann.
|
|
664
|
+
*
|
|
665
|
+
* Das ist die Menge, die eine Zerlegung von `R·R` mehrdeutig macht: kann `R`
|
|
666
|
+
* am Ende beliebig viele Zeichen schlucken, die `R` auch beginnen koennen,
|
|
667
|
+
* dann gibt es zu einem Wort mehr als eine Aufteilung — und genau daraus
|
|
668
|
+
* entsteht das exponentielle Zurueckziehen.
|
|
669
|
+
*/
|
|
670
|
+
function unboundedEdgeSet(node: RegexNode, side: "first" | "last"): CharSet {
|
|
671
|
+
switch (node.kind) {
|
|
672
|
+
case "chars":
|
|
673
|
+
case "zeroWidth":
|
|
674
|
+
case "opaque":
|
|
675
|
+
return EMPTY_SET;
|
|
676
|
+
case "alt":
|
|
677
|
+
return node.branches.reduce<CharSet>(
|
|
678
|
+
(acc, branch) => unionSets(acc, unboundedEdgeSet(branch, side)),
|
|
679
|
+
EMPTY_SET,
|
|
680
|
+
);
|
|
681
|
+
case "group":
|
|
682
|
+
return unboundedEdgeSet(node.body, side);
|
|
683
|
+
case "repeat":
|
|
684
|
+
return node.max === Number.POSITIVE_INFINITY
|
|
685
|
+
? unionSets(consumableSet(node.body), unboundedEdgeSet(node.body, side))
|
|
686
|
+
: unboundedEdgeSet(node.body, side);
|
|
687
|
+
case "concat": {
|
|
688
|
+
const order = side === "first" ? node.parts : [...node.parts].reverse();
|
|
689
|
+
let acc: CharSet = EMPTY_SET;
|
|
690
|
+
for (const part of order) {
|
|
691
|
+
acc = unionSets(acc, unboundedEdgeSet(part, side));
|
|
692
|
+
if (!isNullable(part)) {
|
|
693
|
+
break;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
return acc;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** Feste Laenge eines Knotens in Zeichen, oder `undefined` bei Variabilitaet. */
|
|
702
|
+
function fixedLength(node: RegexNode): number | undefined {
|
|
703
|
+
switch (node.kind) {
|
|
704
|
+
case "chars":
|
|
705
|
+
return 1;
|
|
706
|
+
case "zeroWidth":
|
|
707
|
+
return 0;
|
|
708
|
+
case "opaque":
|
|
709
|
+
return undefined;
|
|
710
|
+
case "concat": {
|
|
711
|
+
let total = 0;
|
|
712
|
+
for (const part of node.parts) {
|
|
713
|
+
const length = fixedLength(part);
|
|
714
|
+
if (length === undefined) {
|
|
715
|
+
return undefined;
|
|
716
|
+
}
|
|
717
|
+
total += length;
|
|
718
|
+
}
|
|
719
|
+
return total;
|
|
720
|
+
}
|
|
721
|
+
case "alt": {
|
|
722
|
+
let common: number | undefined;
|
|
723
|
+
for (const branch of node.branches) {
|
|
724
|
+
const length = fixedLength(branch);
|
|
725
|
+
if (length === undefined || (common !== undefined && common !== length)) {
|
|
726
|
+
return undefined;
|
|
727
|
+
}
|
|
728
|
+
common = length;
|
|
729
|
+
}
|
|
730
|
+
return common;
|
|
731
|
+
}
|
|
732
|
+
case "repeat":
|
|
733
|
+
if (node.min !== node.max) {
|
|
734
|
+
return undefined;
|
|
735
|
+
}
|
|
736
|
+
{
|
|
737
|
+
const length = fixedLength(node.body);
|
|
738
|
+
return length === undefined ? undefined : length * node.min;
|
|
739
|
+
}
|
|
740
|
+
case "group":
|
|
741
|
+
return fixedLength(node.body);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Die Zeichenmenge an Position `offset` eines Knotens FESTER Laenge.
|
|
747
|
+
* Nur fuer solche Knoten definiert; sonst `UNIVERSE` (ablehnende Seite).
|
|
748
|
+
*/
|
|
749
|
+
function setAtPosition(node: RegexNode, offset: number): CharSet {
|
|
750
|
+
switch (node.kind) {
|
|
751
|
+
case "chars":
|
|
752
|
+
return offset === 0 ? node.set : EMPTY_SET;
|
|
753
|
+
case "zeroWidth":
|
|
754
|
+
return EMPTY_SET;
|
|
755
|
+
case "opaque":
|
|
756
|
+
return UNIVERSE;
|
|
757
|
+
case "group":
|
|
758
|
+
return setAtPosition(node.body, offset);
|
|
759
|
+
case "alt":
|
|
760
|
+
return node.branches.reduce<CharSet>(
|
|
761
|
+
(acc, branch) => unionSets(acc, setAtPosition(branch, offset)),
|
|
762
|
+
EMPTY_SET,
|
|
763
|
+
);
|
|
764
|
+
case "concat": {
|
|
765
|
+
let consumed = 0;
|
|
766
|
+
for (const part of node.parts) {
|
|
767
|
+
const length = fixedLength(part);
|
|
768
|
+
if (length === undefined) {
|
|
769
|
+
return UNIVERSE;
|
|
770
|
+
}
|
|
771
|
+
if (offset < consumed + length) {
|
|
772
|
+
return setAtPosition(part, offset - consumed);
|
|
773
|
+
}
|
|
774
|
+
consumed += length;
|
|
775
|
+
}
|
|
776
|
+
return EMPTY_SET;
|
|
777
|
+
}
|
|
778
|
+
case "repeat": {
|
|
779
|
+
const bodyLength = fixedLength(node.body);
|
|
780
|
+
if (bodyLength === undefined || bodyLength === 0) {
|
|
781
|
+
return UNIVERSE;
|
|
782
|
+
}
|
|
783
|
+
return setAtPosition(node.body, offset % bodyLength);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Koennen zwei Alternativzweige unter einer Wiederholung eine mehrdeutige
|
|
790
|
+
* Zerlegung erzeugen?
|
|
791
|
+
*
|
|
792
|
+
* GLEICHE FESTE LAENGE ist exakt entscheidbar: ein gemeinsames Wort gibt es
|
|
793
|
+
* genau dann, wenn sich die Zeichenmengen an JEDER Position ueberschneiden.
|
|
794
|
+
* `(a|b)` und `(ab|ac)` fallen damit durch — zu Recht.
|
|
795
|
+
*
|
|
796
|
+
* VERSCHIEDENE FESTE LAENGE ist der Fall, an dem eine reine „gemeinsames
|
|
797
|
+
* Wort"-Pruefung zu kurz greift: `(x|xx)+` hat keine zwei Zweige mit
|
|
798
|
+
* gemeinsamem Wort und ist trotzdem exponentiell, weil der kuerzere Zweig ein
|
|
799
|
+
* PRAEFIX des laengeren ist und `n` Zeichen sich damit auf Fibonacci-viele
|
|
800
|
+
* Arten zerlegen lassen. Geprueft wird deshalb das Praefix — das ist der
|
|
801
|
+
* Ansatz von Sardinas/Patterson in seiner ersten Runde. Der Preis ist eine
|
|
802
|
+
* bewusste Ueber-Sperrung: `(a|ab)+` wird abgelehnt, obwohl es eindeutig ist.
|
|
803
|
+
*
|
|
804
|
+
* VARIABLE LAENGE laesst sich hier nicht mehr entscheiden; dann entscheiden
|
|
805
|
+
* die Anfangszeichen, wieder auf der ablehnenden Seite.
|
|
806
|
+
*/
|
|
807
|
+
function branchesCanShareWord(left: RegexNode, right: RegexNode): boolean {
|
|
808
|
+
if (isNullable(left) && isNullable(right)) {
|
|
809
|
+
return true;
|
|
810
|
+
}
|
|
811
|
+
const leftLength = fixedLength(left);
|
|
812
|
+
const rightLength = fixedLength(right);
|
|
813
|
+
if (leftLength !== undefined && rightLength !== undefined) {
|
|
814
|
+
const shorter = leftLength <= rightLength ? left : right;
|
|
815
|
+
const longer = leftLength <= rightLength ? right : left;
|
|
816
|
+
const overlap = Math.min(leftLength, rightLength);
|
|
817
|
+
for (let offset = 0; offset < overlap; offset += 1) {
|
|
818
|
+
if (!setsIntersect(setAtPosition(shorter, offset), setAtPosition(longer, offset))) {
|
|
819
|
+
return false;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
return true;
|
|
823
|
+
}
|
|
824
|
+
return setsIntersect(firstSet(left), firstSet(right));
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** Sammelt alle Alternativknoten unterhalb (und einschliesslich) `node`. */
|
|
828
|
+
function collectAlternations(node: RegexNode, into: RegexNode[]): void {
|
|
829
|
+
switch (node.kind) {
|
|
830
|
+
case "alt":
|
|
831
|
+
into.push(node);
|
|
832
|
+
node.branches.forEach((branch) => collectAlternations(branch, into));
|
|
833
|
+
return;
|
|
834
|
+
case "concat":
|
|
835
|
+
node.parts.forEach((part) => collectAlternations(part, into));
|
|
836
|
+
return;
|
|
837
|
+
case "group":
|
|
838
|
+
case "repeat":
|
|
839
|
+
collectAlternations(node.body, into);
|
|
840
|
+
return;
|
|
841
|
+
default:
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** Enthaelt der Teilbaum eine Rueckwaertsreferenz? */
|
|
847
|
+
function containsBackreference(node: RegexNode): boolean {
|
|
848
|
+
switch (node.kind) {
|
|
849
|
+
case "opaque":
|
|
850
|
+
return true;
|
|
851
|
+
case "concat":
|
|
852
|
+
return node.parts.some(containsBackreference);
|
|
853
|
+
case "alt":
|
|
854
|
+
return node.branches.some(containsBackreference);
|
|
855
|
+
case "group":
|
|
856
|
+
case "repeat":
|
|
857
|
+
return containsBackreference(node.body);
|
|
858
|
+
default:
|
|
859
|
+
return false;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function describe(node: RegexNode): string {
|
|
864
|
+
if (node.kind === "group" || node.kind === "repeat") {
|
|
865
|
+
return node.text;
|
|
866
|
+
}
|
|
867
|
+
return "the repeated subexpression";
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* Begruendung, warum der Rumpf einer unbegrenzten Wiederholung mehrdeutig ist
|
|
872
|
+
* — oder `undefined`, wenn er es nachweislich nicht ist.
|
|
873
|
+
*/
|
|
874
|
+
function ambiguityReason(body: RegexNode, repeatText: string): string | undefined {
|
|
875
|
+
if (isNullable(body)) {
|
|
876
|
+
return `the repeated subexpression in "${repeatText}" can match the empty string, so a word has unboundedly many decompositions`;
|
|
877
|
+
}
|
|
878
|
+
if (containsBackreference(body)) {
|
|
879
|
+
return `the repeated subexpression in "${repeatText}" contains a backreference, whose matched length is unknown; ambiguity cannot be ruled out`;
|
|
880
|
+
}
|
|
881
|
+
const alternations: RegexNode[] = [];
|
|
882
|
+
collectAlternations(body, alternations);
|
|
883
|
+
for (const alternation of alternations) {
|
|
884
|
+
if (alternation.kind !== "alt") {
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
for (let left = 0; left < alternation.branches.length; left += 1) {
|
|
888
|
+
for (let right = left + 1; right < alternation.branches.length; right += 1) {
|
|
889
|
+
const a = alternation.branches[left] as RegexNode;
|
|
890
|
+
const b = alternation.branches[right] as RegexNode;
|
|
891
|
+
if (branchesCanShareWord(a, b)) {
|
|
892
|
+
return `two alternatives inside "${repeatText}" can match the same text, so the repetition can split a word in more than one way`;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
const tail = unboundedEdgeSet(body, "last");
|
|
898
|
+
if (!isEmptySet(tail) && setsIntersect(tail, firstSet(body))) {
|
|
899
|
+
return `"${repeatText}" can absorb characters at the end of one repetition that could equally start the next one, so a word has more than one decomposition`;
|
|
900
|
+
}
|
|
901
|
+
const head = unboundedEdgeSet(body, "first");
|
|
902
|
+
if (!isEmptySet(head) && setsIntersect(head, lastSet(body))) {
|
|
903
|
+
return `"${repeatText}" can absorb characters at the start of one repetition that could equally end the previous one, so a word has more than one decomposition`;
|
|
904
|
+
}
|
|
905
|
+
return undefined;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/** Durchsucht den Baum nach einer gefaehrlichen Wiederholung. */
|
|
909
|
+
function findAmbiguousRepetition(node: RegexNode): string | undefined {
|
|
910
|
+
switch (node.kind) {
|
|
911
|
+
case "repeat": {
|
|
912
|
+
// `max > 1` genau wie zuvor: schon `(a+){2}` verdoppelt den Aufwand je
|
|
913
|
+
// Schachtelungsebene, und `{2,}` ist ohnehin unbegrenzt.
|
|
914
|
+
if (node.max > 1) {
|
|
915
|
+
const reason = ambiguityReason(node.body, describe(node));
|
|
916
|
+
if (reason !== undefined) {
|
|
917
|
+
return reason;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
return findAmbiguousRepetition(node.body);
|
|
921
|
+
}
|
|
922
|
+
case "group":
|
|
923
|
+
return findAmbiguousRepetition(node.body);
|
|
924
|
+
case "concat": {
|
|
925
|
+
for (const part of node.parts) {
|
|
926
|
+
const reason = findAmbiguousRepetition(part);
|
|
927
|
+
if (reason !== undefined) {
|
|
928
|
+
return reason;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
return undefined;
|
|
932
|
+
}
|
|
933
|
+
case "alt": {
|
|
934
|
+
for (const branch of node.branches) {
|
|
935
|
+
const reason = findAmbiguousRepetition(branch);
|
|
936
|
+
if (reason !== undefined) {
|
|
937
|
+
return reason;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
return undefined;
|
|
941
|
+
}
|
|
942
|
+
default:
|
|
943
|
+
return undefined;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/** Optionen der Sicherheitspruefung. */
|
|
948
|
+
export interface GrepPatternSafetyOptions {
|
|
949
|
+
/** True, wenn das Muster mit dem `i`-Flag laufen soll. */
|
|
950
|
+
caseInsensitive?: boolean;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Beschreibt, warum ein Muster als unsicher gilt — oder `undefined`, wenn die
|
|
955
|
+
* Pruefung keine exponentielle Mehrdeutigkeit findet.
|
|
956
|
+
*
|
|
957
|
+
* Ein Muster, das der Parser nicht versteht, gilt hier als sicher: es ist dann
|
|
958
|
+
* syntaktisch fehlerhaft, und `new RegExp` meldet den zutreffenden Fehler,
|
|
959
|
+
* statt dass diese Pruefung eine falsche Begruendung erfindet (E4).
|
|
960
|
+
*/
|
|
961
|
+
export function findUnsafeGrepPattern(
|
|
962
|
+
pattern: string,
|
|
963
|
+
options: GrepPatternSafetyOptions = {},
|
|
964
|
+
): string | undefined {
|
|
965
|
+
const caseInsensitive = options.caseInsensitive === true;
|
|
966
|
+
let tree: RegexNode;
|
|
967
|
+
try {
|
|
968
|
+
tree = new PatternParser(pattern, caseInsensitive).parse();
|
|
969
|
+
} catch {
|
|
970
|
+
// WICHTIG: „mein Parser hat es nicht verstanden" heisst NICHT „das Muster
|
|
971
|
+
// ist harmlos". Ohne diese Unterscheidung waere jede Luecke im Parser eine
|
|
972
|
+
// stille Luecke in der Sicherheitspruefung — das Muster liefe ungeprueft.
|
|
973
|
+
// Also wird gegenueber der Sprache selbst geprueft: haelt `RegExp` das
|
|
974
|
+
// Muster fuer gueltig, ist es GUELTIG UND UNANALYSIERBAR und wird
|
|
975
|
+
// abgelehnt. Haelt `RegExp` es fuer fehlerhaft, ist der Syntaxfehler die
|
|
976
|
+
// zutreffende Meldung, und diese Pruefung erfindet keine zweite (E4).
|
|
977
|
+
try {
|
|
978
|
+
new RegExp(pattern, caseInsensitive ? "ui" : "u");
|
|
979
|
+
} catch {
|
|
980
|
+
return undefined;
|
|
981
|
+
}
|
|
982
|
+
return `the pattern uses a construct this safety check cannot analyse, so exponential backtracking cannot be ruled out`;
|
|
983
|
+
}
|
|
984
|
+
return findAmbiguousRepetition(tree);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* Wirft, wenn das Muster exponentielles Zurueckziehen ausloesen kann.
|
|
989
|
+
*
|
|
990
|
+
* Siehe den Modulkopf fuer das Kriterium und seine ausdrueckliche Grenze.
|
|
991
|
+
*/
|
|
992
|
+
export function assertSafeGrepPattern(
|
|
993
|
+
pattern: string,
|
|
994
|
+
options: GrepPatternSafetyOptions = {},
|
|
995
|
+
): void {
|
|
996
|
+
const unsafe = findUnsafeGrepPattern(pattern, options);
|
|
997
|
+
if (unsafe !== undefined) {
|
|
998
|
+
throw new Error(
|
|
999
|
+
`grep pattern is rejected as unsafe: ${unsafe}. Such a repetition can take exponential time to evaluate, and that evaluation cannot be interrupted — not by maxScannedChunks and not by an abort signal. Rewrite the repeated part so that each repetition has exactly one possible decomposition (for example by ending it with a character the repetition itself cannot match).`,
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
}
|