@ultimat3/testing 30.6.0 → 30.7.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.
@@ -1,216 +0,0 @@
1
- // Strings a declared `matches(/…/)` pattern accepts, built by walking the pattern rather than
2
- // guessing shapes. The common subset only — literals, classes, `.`, groups, alternation,
3
- // quantifiers, anchors — and an honest `[]` for anything past it (lookaround, backreference, `\b`,
4
- // `\p{…}`): a pattern this cannot read leaves the column's other candidates to the solver.
5
-
6
- import { fnv1a } from '@ultimat3/core';
7
- import { seededRandom } from './determinism';
8
-
9
- type Node =
10
- | { readonly kind: 'chars'; readonly chars: readonly string[] }
11
- | { readonly kind: 'alt'; readonly branches: readonly (readonly Piece[])[] }
12
- | { readonly kind: 'empty' };
13
-
14
- interface Piece {
15
- readonly node: Node;
16
- readonly min: number;
17
- readonly max: number;
18
- }
19
-
20
- /** How far past its floor an unbounded quantifier draws: enough to vary, short enough to store. */
21
- const UNBOUNDED_SPAN = 6;
22
-
23
- /** The universe a class or `.` picks from: printable ASCII, so every sample is plain text. */
24
- const PRINTABLE = Array.from({ length: 95 }, (_, i) => String.fromCharCode(32 + i));
25
- const DIGITS = PRINTABLE.filter((c) => c >= '0' && c <= '9');
26
- const WORD = PRINTABLE.filter((c) => /\w/.test(c));
27
- const SPACE = [' ', '\t'];
28
-
29
- const SHORTHAND: Readonly<Record<string, readonly string[]>> = {
30
- d: DIGITS,
31
- D: PRINTABLE.filter((c) => !DIGITS.includes(c)),
32
- w: WORD,
33
- W: PRINTABLE.filter((c) => !WORD.includes(c)),
34
- s: SPACE,
35
- S: PRINTABLE.filter((c) => c !== ' '),
36
- };
37
-
38
- const CONTROL: Readonly<Record<string, string>> = { t: '\t', n: '\n', r: '\r', f: '\f', v: '\v' };
39
-
40
- class Parser {
41
- private at = 0;
42
- /** The first construct past the subset; set once, and it ends the walk. */
43
- private refused: string | undefined;
44
- constructor(private readonly source: string) {}
45
-
46
- /** The tree, or `undefined` when any part of the pattern is past the subset. */
47
- parse(): Node | undefined {
48
- const node = this.alternation();
49
- if (this.refused === undefined && this.at < this.source.length) this.refuse('a stray ")"');
50
- return this.refused === undefined ? node : undefined;
51
- }
52
-
53
- /** Records the refusal and moves to the end, so every loop above stops on its next look. */
54
- private refuse(construct: string): readonly string[] {
55
- this.refused ??= construct;
56
- this.at = this.source.length;
57
- return [];
58
- }
59
-
60
- private peek(): string | undefined {
61
- return this.source[this.at];
62
- }
63
-
64
- private alternation(): Node {
65
- const branches: Piece[][] = [this.sequence()];
66
- while (this.peek() === '|') {
67
- this.at += 1;
68
- branches.push(this.sequence());
69
- }
70
- return { kind: 'alt', branches };
71
- }
72
-
73
- private sequence(): Piece[] {
74
- const pieces: Piece[] = [];
75
- for (let c = this.peek(); c !== undefined && c !== '|' && c !== ')'; c = this.peek()) {
76
- const node = this.atom();
77
- const [min, max] = this.quantifier();
78
- pieces.push({ node, min, max });
79
- }
80
- return pieces;
81
- }
82
-
83
- private quantifier(): readonly [number, number] {
84
- const c = this.peek();
85
- const bounds = /^\{(\d+)(,(\d*))?\}/.exec(this.source.slice(this.at));
86
- let range: readonly [number, number] | undefined;
87
- if (c === '*') range = [0, UNBOUNDED_SPAN];
88
- else if (c === '+') range = [1, 1 + UNBOUNDED_SPAN];
89
- else if (c === '?') range = [0, 1];
90
- else if (bounds !== null) {
91
- const low = Number(bounds[1]);
92
- const high =
93
- bounds[2] === undefined ? low : bounds[3] === '' ? low + UNBOUNDED_SPAN : Number(bounds[3]);
94
- range = [low, high];
95
- }
96
- if (range === undefined) return [1, 1];
97
- this.at += bounds !== null && c === '{' ? bounds[0].length : 1;
98
- // A lazy quantifier matches the same strings; only the engine's preference differs.
99
- if (this.peek() === '?') this.at += 1;
100
- return range;
101
- }
102
-
103
- private atom(): Node {
104
- const c = this.peek() as string;
105
- this.at += 1;
106
- if (c === '^' || c === '$') return { kind: 'empty' };
107
- if (c === '.') return { kind: 'chars', chars: PRINTABLE };
108
- if (c === '[') return { kind: 'chars', chars: this.charClass() };
109
- if (c === '(') return this.group();
110
- if (c === '\\') return { kind: 'chars', chars: this.escape(false) };
111
- if (c === '*' || c === '+' || c === '?' || c === '{')
112
- return { kind: 'chars', chars: this.refuse(`a bare "${c}"`) };
113
- return { kind: 'chars', chars: [c] };
114
- }
115
-
116
- private group(): Node {
117
- if (this.peek() === '?') {
118
- const head = this.source.slice(this.at, this.at + 3);
119
- if (head.startsWith('?:')) this.at += 2;
120
- else if (/^\?<[A-Za-z_$]/.test(head) && this.source.includes('>', this.at)) {
121
- this.at = this.source.indexOf('>', this.at) + 1;
122
- } else return { kind: 'chars', chars: this.refuse(`the lookaround "(${head}"`) };
123
- }
124
- const inner = this.alternation();
125
- if (this.peek() !== ')') return { kind: 'chars', chars: this.refuse('an unclosed group') };
126
- this.at += 1;
127
- return inner;
128
- }
129
-
130
- private escape(inClass: boolean): readonly string[] {
131
- const c = this.peek();
132
- if (c === undefined) return this.refuse('a trailing backslash');
133
- this.at += 1;
134
- const shorthand = Object.hasOwn(SHORTHAND, c) ? SHORTHAND[c] : undefined;
135
- if (shorthand !== undefined) return shorthand;
136
- const control = Object.hasOwn(CONTROL, c) ? CONTROL[c] : undefined;
137
- if (control !== undefined) return [control];
138
- const hex = c === 'x' ? 2 : c === 'u' ? 4 : 0;
139
- if (hex > 0) {
140
- const digits = this.source.slice(this.at, this.at + hex);
141
- if (!/^[0-9a-fA-F]+$/.test(digits) || digits.length !== hex) return this.refuse(`\\${c}`);
142
- this.at += hex;
143
- return [String.fromCharCode(Number.parseInt(digits, 16))];
144
- }
145
- // `\b`, `\B`, a backreference, `\p{…}`, `\k<…>`, `\0`: a position or a reference, not a char.
146
- if (/[A-Za-z0-9]/.test(c)) return this.refuse(`\\${c}${inClass ? ' in a class' : ''}`);
147
- return [c];
148
- }
149
-
150
- /** The members of `[…]`, drawn from printable ASCII; a negated class is its complement there. */
151
- private charClass(): readonly string[] {
152
- const negated = this.peek() === '^';
153
- if (negated) this.at += 1;
154
- const members = new Set<string>();
155
- let first = true;
156
- for (let c = this.peek(); c !== ']' || first; c = this.peek()) {
157
- if (c === undefined) return this.refuse('an unclosed class');
158
- first = false;
159
- const low = this.classMember();
160
- if (low.length === 1 && this.peek() === '-' && this.source[this.at + 1] !== ']') {
161
- this.at += 1;
162
- const high = this.classMember();
163
- if (high.length !== 1) return this.refuse('a range ending in a shorthand');
164
- const [from, to] = [(low[0] as string).charCodeAt(0), (high[0] as string).charCodeAt(0)];
165
- // Past printable ASCII the range's own first member stands in, so `[À-ÿ]` still yields one.
166
- const inside = PRINTABLE.filter((ch) => ch.charCodeAt(0) >= from && ch.charCodeAt(0) <= to);
167
- for (const ch of inside.length > 0 ? inside : [String.fromCharCode(from)]) members.add(ch);
168
- } else {
169
- for (const ch of low) members.add(ch);
170
- }
171
- }
172
- this.at += 1;
173
- return negated ? PRINTABLE.filter((ch) => !members.has(ch)) : [...members];
174
- }
175
-
176
- private classMember(): readonly string[] {
177
- const c = this.peek() as string;
178
- this.at += 1;
179
- return c === '\\' ? this.escape(true) : [c];
180
- }
181
- }
182
-
183
- const draw = (node: Node, random: () => number): string => {
184
- const pick = <T>(items: readonly T[]): T | undefined =>
185
- items[Math.floor(random() * items.length)];
186
- if (node.kind === 'empty') return '';
187
- if (node.kind === 'chars') return pick(node.chars) ?? '';
188
- const branch = pick(node.branches) ?? [];
189
- return branch
190
- .map((piece) => {
191
- const count = piece.min + Math.floor(random() * (piece.max - piece.min + 1));
192
- return Array.from({ length: count }, () => draw(piece.node, random)).join('');
193
- })
194
- .join('');
195
- };
196
-
197
- /** Tries per sample: a draw that crosses an anchor (`a^b`) or a flag is retried, never returned. */
198
- const ATTEMPTS = 8;
199
-
200
- /**
201
- * Up to `count` distinct strings `pattern.test` accepts, the same ones for the same `seed` on any
202
- * machine. Empty when the pattern is past the subset, or when no draw lands inside it.
203
- */
204
- export function patternSamples(pattern: RegExp, seed: string, count = 4): readonly string[] {
205
- const tree = new Parser(pattern.source).parse();
206
- if (tree === undefined) return [];
207
- const random = seededRandom(fnv1a(`${pattern.source}/${pattern.flags}:${seed}`) || 1);
208
- // `lastIndex` would make `test` stateful under `g`/`y`; a copy without them judges each draw.
209
- const judge = new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ''));
210
- const out: string[] = [];
211
- for (let attempt = 0; attempt < count * ATTEMPTS && out.length < count; attempt += 1) {
212
- const sample = draw(tree, random);
213
- if (judge.test(sample) && !out.includes(sample)) out.push(sample);
214
- }
215
- return out;
216
- }