parseman 0.61.0 → 0.61.2

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.
@@ -0,0 +1,326 @@
1
+ /**
2
+ * THE BACKTRACKING PROBE, for the two table engines.
3
+ *
4
+ * This module counts the rewinds visible among the terminals a parse matched, without
5
+ * touching either engine: it rewrites a COPY of a program so that every terminal row
6
+ * reports what it consumed, and runs that copy on the closure engine — or, in tests
7
+ * and benches, on the emitted engine from a build-time module
8
+ * (`bench/emitted-assembly.ts`); nothing here builds code from source. A program that
9
+ * is not rewritten carries no probe row, so an ordinary parse pays nothing for this — no branch, no field, no
10
+ * assembly key. That is the whole reason it is a program rewrite and not a `RunCfg`
11
+ * bit: a bit would still be a second assembly every engine has to know about.
12
+ *
13
+ * WHAT IS COUNTED, per parse (`BacktrackCounts`):
14
+ *
15
+ * - rewinds: a terminal matched starting BELOW the end of the previous matched
16
+ * terminal — the parse went backwards, because a failed arm, an
17
+ * `attempt()`, an `optional()` or a choice restored the position;
18
+ * - bytesReread: of those terminals' bytes, the ones below the furthest byte
19
+ * a matched terminal already reached;
20
+ * - lookaheads / lookaheadBytes: `not()`/`peek()` evaluations, and the bytes
21
+ * terminals matched inside them;
22
+ * - regexLookarounds / regexLookaroundBytes: runs of a regex terminal whose
23
+ * pattern holds a lookaround, and, for a run that matched, the fewest bytes past
24
+ * its match that the same match still needs when the input is cut short. That is
25
+ * a lower bound on what it read: `/a(?!bbbb)/` on `abbbc` reads past its match to
26
+ * decide and counts 0, and every failed run counts 0.
27
+ *
28
+ * A terminal is a row that reads input itself: `LIT`, `RX`, `LIT_CI`, `LEX_BODY`,
29
+ * `LEX_PROGRAM`, `SCAN`, `LIVE` and their line-tracking twins. Trivia between terms
30
+ * is not a terminal, and a lexical body or a scan is one read. `routed()` reads
31
+ * nothing: the `dispatch()` selector consumed the token.
32
+ *
33
+ * WHAT A ZERO DOES NOT MEAN. Zero rewinds says no matched terminal started behind the
34
+ * one before it. It does not say the parse wasted no work. Not counted:
35
+ *
36
+ * - failed terminal reads. Only a match reports. On 2,000 `a`s,
37
+ * `many(choice(regex(/[a-z]+;/), regex(/[a-z]/)))` scans about 2M characters in
38
+ * regexes that fail, and every count is 0 on both engines;
39
+ * - trivia re-scans: the same gap skipped again after a position is restored;
40
+ * - the choice predecide pretest, which reads an arm's leading terminal before the
41
+ * arm runs;
42
+ * - table `token()` bodies. A `token()` the table lowers to one `LEX_BODY` or
43
+ * `LEX_PROGRAM` row is one read, and its retries are hidden inside that row. On the
44
+ * same input, `many(token(choice(sequence(regex(/[a-z]+/), literal(';')),
45
+ * regex(/[a-z]/))))` reports 2,000 rewinds on the interpreter and 0 on the table,
46
+ * which does the same retries. A `token()` that does not lower still reports its
47
+ * terminals.
48
+ *
49
+ * HOW, row by row. A terminal `T` in a parent's slot becomes `XFORM(report, T)`,
50
+ * which hands the recorder `T`'s span and returns `T`'s value unchanged. A
51
+ * lookahead needs to know it is one, so `not(c)` becomes
52
+ *
53
+ * ARMGATE(enter, XFORM(exit, NOT(XFORM(exit, c))))
54
+ *
55
+ * whose `exit` runs once whether `c` matches or not, and `peek(c)` becomes `NOT`
56
+ * of that. A regex with a lookaround becomes `ARMGATE(attempt, XFORM(match, RX))`.
57
+ * No gadget is a sequence: a sequence skips trivia between its terms, which would
58
+ * move where the rewritten row ends.
59
+ *
60
+ * A scan reads its stop and skip subtrees as part of one read, and those may be
61
+ * rows the grammar shares. The rewrite gives the scan pool its own copy of them,
62
+ * left as it was, so the scan reports once, as the terminal it is.
63
+ *
64
+ * Every row there is one both engines already run. A rewritten parse must return
65
+ * what the plain one returns; `backtrackProbe` checks that on every parse it measures.
66
+ */
67
+ import {
68
+ OP_ARMGATE, OP_GREEDY, OP_LEX_BODY, OP_LEX_PROGRAM, OP_LIT, OP_LIT_CI, OP_LIT_CI_TRACK,
69
+ OP_LIT_TRACK, OP_LIVE, OP_NOT, OP_PEEK, OP_RX, OP_RX_TRACK, OP_SCAN, OP_XFORM,
70
+ } from './ops.ts'
71
+ import { childSlotOffsets, childSlots } from './child-slots.ts'
72
+ import {
73
+ assemblyExtraIps, expandCompact, type CompactProgram, type SubtreeRef, type TableProgram,
74
+ } from './program.ts'
75
+ import type { RunCfg } from './assemble.ts'
76
+
77
+ export type BacktrackCounts = {
78
+ rewinds: number
79
+ bytesReread: number
80
+ lookaheads: number
81
+ lookaheadBytes: number
82
+ regexLookarounds: number
83
+ regexLookaroundBytes: number
84
+ }
85
+
86
+ const LOOKAROUND = /(?<!\\)\(\?<?[=!]/
87
+
88
+ /** Does this pattern read input it does not consume? */
89
+ export function hasLookaround(re: RegExp): boolean {
90
+ return LOOKAROUND.test(re.source)
91
+ }
92
+
93
+ /**
94
+ * How far past `end` a regex match at `start` depended on the input: the shortest
95
+ * extension of the input past `end` under which the match still ends at `end`.
96
+ * A lower bound for a negative lookaround, which is decided by what does NOT
97
+ * follow and so can hold on a shorter input than it read.
98
+ */
99
+ function lookaroundExtent(re: RegExp, input: string, start: number, end: number): number {
100
+ const probe = new RegExp(re.source, re.flags.includes('y') ? re.flags : `${re.flags}y`)
101
+ const same = (n: number): boolean => {
102
+ probe.lastIndex = start
103
+ return probe.test(input.slice(0, end + n)) && probe.lastIndex === end
104
+ }
105
+ let lo = 0
106
+ let hi = input.length - end
107
+ while (lo < hi) {
108
+ const mid = (lo + hi) >>> 1
109
+ if (same(mid)) hi = mid
110
+ else lo = mid + 1
111
+ }
112
+ return lo
113
+ }
114
+
115
+ /**
116
+ * The counters one probed parse writes. Shared by both table engines and the
117
+ * interpreter probe, so all three count by the same rule.
118
+ */
119
+ export class BacktrackRecorder {
120
+ input = ''
121
+ private counts: BacktrackCounts = zeroCounts()
122
+ private look = 0
123
+ private opaque = 0
124
+ private last = 0
125
+ private furthest = 0
126
+
127
+ begin(input: string): void {
128
+ this.input = input
129
+ this.counts = zeroCounts()
130
+ this.look = 0
131
+ this.opaque = 0
132
+ this.last = 0
133
+ this.furthest = 0
134
+ }
135
+
136
+ /** The counts for the parse `begin` opened. Throws if a region was left open. */
137
+ end(): BacktrackCounts {
138
+ if (this.look !== 0 || this.opaque !== 0) {
139
+ throw new Error(`backtrack probe: unbalanced region (lookahead ${this.look}, opaque ${this.opaque})`)
140
+ }
141
+ return this.counts
142
+ }
143
+
144
+ consume(start: number, end: number): void {
145
+ if (this.opaque > 0 || end <= start) return
146
+ const c = this.counts
147
+ if (this.look > 0) { c.lookaheadBytes += end - start; return }
148
+ if (start < this.last) c.rewinds++
149
+ if (start < this.furthest) c.bytesReread += Math.min(end, this.furthest) - start
150
+ this.last = end
151
+ if (end > this.furthest) this.furthest = end
152
+ }
153
+
154
+ enterLookahead(): void {
155
+ if (this.opaque === 0) this.counts.lookaheads++
156
+ this.look++
157
+ }
158
+
159
+ exitLookahead(): void { this.look-- }
160
+
161
+ regexAttempt(): void {
162
+ if (this.opaque === 0) this.counts.regexLookarounds++
163
+ }
164
+
165
+ regexMatch(re: RegExp, start: number, end: number): void {
166
+ if (this.opaque === 0) this.counts.regexLookaroundBytes += lookaroundExtent(re, this.input, start, end)
167
+ this.consume(start, end)
168
+ }
169
+
170
+ /** A read whose inner steps are not the grammar's (the interpreter's scans). */
171
+ enterOpaque(): void { this.opaque++ }
172
+ exitOpaque(): void { this.opaque-- }
173
+ }
174
+
175
+ export function zeroCounts(): BacktrackCounts {
176
+ return { rewinds: 0, bytesReread: 0, lookaheads: 0, lookaheadBytes: 0, regexLookarounds: 0, regexLookaroundBytes: 0 }
177
+ }
178
+
179
+ type Span = { readonly start: number; readonly end: number }
180
+
181
+ /** Every row start reachable from `roots`, ascending, closed by `code.length`. */
182
+ function rowStarts(code: ArrayLike<number>, roots: Iterable<number>): number[] {
183
+ const seen = new Set<number>()
184
+ const stack = [...roots]
185
+ const kids: number[] = []
186
+ while (stack.length > 0) {
187
+ const ip = stack.pop()!
188
+ if (seen.has(ip)) continue
189
+ seen.add(ip)
190
+ kids.length = 0
191
+ childSlots(code, ip, kids)
192
+ for (const c of kids) stack.push(c)
193
+ }
194
+ return [...seen, code.length].sort((a, b) => a - b)
195
+ }
196
+
197
+ /** A copy of `source` whose terminals and lookaheads report to `rec`. */
198
+ export function probeProgram(source: TableProgram | CompactProgram, rec: BacktrackRecorder): TableProgram {
199
+ const prog = expandCompact(source)
200
+ const code = [...prog.code]
201
+ const fns: unknown[] = [...prog.fns]
202
+ const fx: (readonly string[])[] = [...prog.fx, []]
203
+ const noFx = fx.length - 1
204
+ const row = (...words: number[]): number => {
205
+ const ip = code.length
206
+ code.push(...words)
207
+ return ip
208
+ }
209
+ const fn = (f: unknown): number => fns.push(f) - 1
210
+ const slots: number[] = []
211
+
212
+ // A scan's subtrees run as part of ONE read, and may be rows the grammar also
213
+ // reaches. They get their own uninstrumented copy, so the rewrite below never
214
+ // reaches them through a row it shares with the grammar.
215
+ const extraIps = assemblyExtraIps(prog)
216
+ const starts = rowStarts(prog.code, [...Object.values(prog.rules), ...extraIps])
217
+ const copies = new Map<number, number>()
218
+ function copyOf(ip: number): number {
219
+ const hit = copies.get(ip)
220
+ if (hit !== undefined) return hit
221
+ slots.length = 0
222
+ childSlotOffsets(prog.code, ip, slots)
223
+ if (slots.length === 0) { copies.set(ip, ip); return ip }
224
+ // Rows are laid out end to end, so a row runs to the next row that starts.
225
+ const next = starts[starts.findIndex(s => s > ip)]!
226
+ const at = code.length
227
+ copies.set(ip, at)
228
+ for (let i = ip; i < next; i++) code.push(prog.code[i]!)
229
+ const own = slots.map(s => s - ip)
230
+ for (const o of own) code[at + o] = copyOf(prog.code[ip + o]!)
231
+ return at
232
+ }
233
+ const ref = (r: SubtreeRef): SubtreeRef => [copyOf(r[0]), r[1]]
234
+ const scans = prog.scans?.map(s => ({
235
+ ...s,
236
+ skip: s.skip.map(ref),
237
+ ...(s.sentinel === undefined ? {} : { sentinel: ref(s.sentinel) }),
238
+ ...(s.recoverAt === undefined ? {} : { recoverAt: ref(s.recoverAt) }),
239
+ ...(s.stopAt === undefined ? {} : { stopAt: ref(s.stopAt) }),
240
+ ...(s.prefix === undefined ? {} : { prefix: ref(s.prefix) }),
241
+ ...(s.body === undefined ? {} : { body: ref(s.body) }),
242
+ ...(s.first === undefined ? {} : { first: ref(s.first) }),
243
+ ...(s.lazy === undefined ? {} : { lazy: ref(s.lazy) }),
244
+ }))
245
+ const scanSkip = prog.scanSkip?.map(set => set.map(ref))
246
+
247
+ const report = fn((v: unknown, span: Span) => { rec.consume(span.start, span.end); return v })
248
+ const lookIn = fn(() => { rec.enterLookahead(); return true })
249
+ const lookOut = fn((v: unknown) => { rec.exitLookahead(); return v })
250
+ const regexTry = fn(() => { rec.regexAttempt(); return true })
251
+
252
+ // `exit` runs once on each outcome: inside the NOT when the child matched (the
253
+ // NOT then fails), outside it when the NOT holds — including when its lead
254
+ // gate holds without running the child.
255
+ const lookahead = (child: number, fxi: number): number => row(OP_ARMGATE, lookIn,
256
+ row(OP_XFORM, lookOut, row(OP_NOT, row(OP_XFORM, lookOut, replacement(child) ?? child), fxi)), noFx)
257
+
258
+ const replaced = new Map<number, number>()
259
+ function replacement(c: number): number | undefined {
260
+ const hit = replaced.get(c)
261
+ if (hit !== undefined) return hit
262
+ let r: number | undefined
263
+ switch (code[c]) {
264
+ case OP_LIT: case OP_LIT_TRACK: case OP_LIT_CI: case OP_LIT_CI_TRACK:
265
+ case OP_LEX_BODY: case OP_LEX_PROGRAM: case OP_SCAN: case OP_LIVE:
266
+ r = row(OP_XFORM, report, c)
267
+ break
268
+ case OP_RX: case OP_RX_TRACK: {
269
+ const re = prog.k[code[c + 1]!] as RegExp
270
+ r = hasLookaround(re)
271
+ ? row(OP_ARMGATE, regexTry,
272
+ row(OP_XFORM, fn((v: unknown, span: Span) => { rec.regexMatch(re, span.start, span.end); return v }), c), noFx)
273
+ : row(OP_XFORM, report, c)
274
+ break
275
+ }
276
+ case OP_NOT:
277
+ r = lookahead(code[c + 1]!, code[c + 2]!)
278
+ break
279
+ case OP_PEEK:
280
+ r = row(OP_NOT, lookahead(code[c + 1]!, code[c + 2]!), code[c + 2]!)
281
+ break
282
+ }
283
+ if (r !== undefined) replaced.set(c, r)
284
+ return r
285
+ }
286
+
287
+ const rules: Record<string, number> = {}
288
+ const stack: number[] = []
289
+ for (const [name, ip] of Object.entries(prog.rules)) {
290
+ rules[name] = replacement(ip) ?? ip
291
+ stack.push(ip)
292
+ }
293
+ const seen = new Set<number>()
294
+ while (stack.length > 0) {
295
+ const ip = stack.pop()!
296
+ if (seen.has(ip)) continue
297
+ seen.add(ip)
298
+ slots.length = 0
299
+ if (!childSlotOffsets(code, ip, slots)) throw new Error(`backtrack probe: unknown opcode ${String(code[ip])} at ${ip}`)
300
+ for (let i = 0; i < slots.length; i++) {
301
+ const s = slots[i]!
302
+ const c = code[s]!
303
+ stack.push(c)
304
+ // A keyword classifier re-runs the literal arm its super arm already read;
305
+ // that is classification, not a failed alternative, so only the super arm
306
+ // reports. Its arms are linked, never decoded, so leaving them is exact.
307
+ if (code[ip] === OP_GREEDY && i > 0) continue
308
+ const r = replacement(c)
309
+ if (r !== undefined) code[s] = r
310
+ }
311
+ }
312
+
313
+ // A fresh object, not a spread: a spread would carry the source program's
314
+ // compiler-only caches, which describe its rows, not these.
315
+ const out = Object.fromEntries(Object.entries(prog)) as unknown as TableProgram
316
+ return {
317
+ ...out, code, fns, fx, rules, asm: [],
318
+ ...(scans === undefined ? {} : { scans }),
319
+ ...(scanSkip === undefined ? {} : { scanSkip }),
320
+ }
321
+ }
322
+
323
+ /** The option set a probe runs a program under: a plain parse of its host mode. */
324
+ export function probeCfg(prog: TableProgram): RunCfg {
325
+ return { hostCst: prog.hostMode === 'cst', trackLines: prog.lines === 1, tolerant: false, coverage: false, probe: false }
326
+ }
@@ -33,7 +33,23 @@ import {
33
33
  * report), `site-labels.ts` ignores it (an unwalked site resolves to `TOP` via
34
34
  * `labelAt`, and the enclosing assembly is unemittable anyway).
35
35
  */
36
+ /**
37
+ * The child instructions of the row at `ip`, read through `childSlotOffsets` below:
38
+ * one edge table, two views of it.
39
+ */
36
40
  export function childSlots(code: ArrayLike<number>, ip: number, out: number[]): boolean {
41
+ const from = out.length
42
+ if (!childSlotOffsets(code, ip, out)) return false
43
+ for (let i = from; i < out.length; i++) out[i] = code[out[i]!]!
44
+ return true
45
+ }
46
+
47
+ /**
48
+ * The OPERAND OFFSETS that hold child instructions — the edge table itself. A caller
49
+ * that rewrites a program's edges (`backtrack-probe.ts`) needs where an edge is
50
+ * stored, not only where it points.
51
+ */
52
+ export function childSlotOffsets(code: ArrayLike<number>, ip: number, out: number[]): boolean {
37
53
  switch (code[ip]) {
38
54
  // ZERO-WIDTH / TERMINAL ROWS — no successor. `OP_GUARD` and `OP_ADJ` are both
39
55
  // childless tests. `OP_LIVE` holds a hand-written combinator in `prog.fns`; its
@@ -48,67 +64,67 @@ export function childSlots(code: ArrayLike<number>, ip: number, out: number[]):
48
64
  // `opHistogram` throw on every table the coverage build produces.
49
65
  case OP_RULE: case OP_OPT: case OP_NOT: case OP_PEEK: case OP_EXPECT:
50
66
  case OP_TOKEN: case OP_ATTEMPT: case OP_LABEL: case OP_COV: case OP_REJECT: case OP_GAPGATE:
51
- out.push(code[ip + 1]!)
67
+ out.push(ip + 1)
52
68
  return true
53
69
 
54
70
  // Single child at `ip + 2`.
55
71
  case OP_GATE: case OP_SCOPE: case OP_SCOPE_CAP: case OP_SCOPE_PLAIN: case OP_WITHCTX: case OP_XFORM:
56
72
  case OP_NODE: case OP_NODE_TRACK: case OP_FIELD: case OP_LEAF: case OP_SOURCE_LEAF: case OP_ARMGATE: case OP_RGATE: case OP_RGATE2:
57
- out.push(code[ip + 2]!)
73
+ out.push(ip + 2)
58
74
  return true
59
75
 
60
76
  // `STRICT strict tolerant` — both encodings are reachable; an assembly keeps one.
61
77
  case OP_STRICT:
62
- out.push(code[ip + 1]!, code[ip + 2]!)
78
+ out.push(ip + 1, ip + 2)
63
79
  return true
64
80
 
65
81
  // `ROUTED fallback` — the operand is an offset or −1. The routed token itself is
66
82
  // data the enclosing `dispatch()` already consumed, so the only edge is the
67
83
  // fallback.
68
84
  case OP_ROUTED:
69
- if (code[ip + 1]! >= 0) out.push(code[ip + 1]!)
85
+ if (code[ip + 1]! >= 0) out.push(ip + 1)
70
86
  return true
71
87
 
72
88
  // `DISPATCH sel d other otherRouted n a1 … an` — the SELECTOR is a child on
73
89
  // exactly the same footing as the arms: it runs inside whatever node and scope
74
90
  // this site sits in.
75
91
  case OP_DISPATCH: {
76
- out.push(code[ip + 1]!)
77
- if (code[ip + 3]! >= 0) out.push(code[ip + 3]!)
92
+ out.push(ip + 1)
93
+ if (code[ip + 3]! >= 0) out.push(ip + 3)
78
94
  const n = code[ip + 5]!
79
- for (let i = 0; i < n; i++) out.push(code[ip + 6 + i]!)
95
+ for (let i = 0; i < n; i++) out.push(ip + 6 + i)
80
96
  return true
81
97
  }
82
98
 
83
99
  case OP_SEQ: case OP_SEQV: {
84
100
  const n = code[ip + 1]!
85
- for (let i = 0; i < n; i++) out.push(code[ip + 2 + i]!)
101
+ for (let i = 0; i < n; i++) out.push(ip + 2 + i)
86
102
  return true
87
103
  }
88
104
 
89
105
  case OP_SEQX: {
90
106
  const n = code[ip + 2]!
91
- for (let i = 0; i < n; i++) out.push(code[ip + 3 + i]!)
107
+ for (let i = 0; i < n; i++) out.push(ip + 3 + i)
92
108
  return true
93
109
  }
94
110
 
95
111
  case OP_CHOICE: {
96
112
  const n = code[ip + 2]!
97
- for (let i = 0; i < n; i++) out.push(code[ip + 4 + i]!)
113
+ for (let i = 0; i < n; i++) out.push(ip + 4 + i)
98
114
  return true
99
115
  }
100
116
 
101
117
  // Arms are stride-2 from `ip + 4` — the gate word sits beside each arm offset.
102
118
  case OP_GREEDY: {
103
- out.push(code[ip + 1]!)
119
+ out.push(ip + 1)
104
120
  const n = code[ip + 2]!
105
- for (let i = 0; i < n; i++) out.push(code[ip + 4 + 2 * i]!)
121
+ for (let i = 0; i < n; i++) out.push(ip + 4 + 2 * i)
106
122
  return true
107
123
  }
108
124
 
109
125
  case OP_REP: case OP_REPV:
110
- out.push(code[ip + 1]!)
111
- if (code[ip + 4]! >= 0) out.push(code[ip + 4]!)
126
+ out.push(ip + 1)
127
+ if (code[ip + 4]! >= 0) out.push(ip + 4)
112
128
  return true
113
129
 
114
130
  default:
package/src/version.ts CHANGED
@@ -13,4 +13,4 @@
13
13
  * Kept in sync with `package.json` by `test/unit/version-sync.test.ts`, which fails
14
14
  * the build if this constant drifts from the published version.
15
15
  */
16
- export const PARSEMAN_VERSION = '0.61.0'
16
+ export const PARSEMAN_VERSION = '0.61.2'