parseman 0.61.6 → 0.61.8

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/dist/version.d.ts CHANGED
@@ -13,5 +13,5 @@
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 declare const PARSEMAN_VERSION = "0.61.6";
16
+ export declare const PARSEMAN_VERSION = "0.61.8";
17
17
  //# sourceMappingURL=version.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parseman",
3
- "version": "0.61.6",
3
+ "version": "0.61.8",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "engines": {
6
6
  "node": "^20.19.0 || >=22.13.0"
@@ -1,7 +1,7 @@
1
1
  import type { Combinator, ParseContext, ParseResult, ParserMeta, ParseError } from '../types.ts'
2
2
  import { captureError } from '../recovery/scan.ts'
3
3
  import { annotateSpanFromLineContext } from '../line-index.ts'
4
- import { matchesEmpty, isZeroWidthAssertion } from './first-set.ts'
4
+ import { any, matchesEmpty, isZeroWidthAssertion } from './first-set.ts'
5
5
 
6
6
  export type { ParseError }
7
7
  export { assertionFailureExpected, directTerminalFailureExpected } from './expected.ts'
@@ -133,7 +133,8 @@ function derive(c: Combinator<unknown>, seen: Set<Combinator<unknown>>): string[
133
133
  export function expect<T>(combinator: Combinator<T>, label?: string): Combinator<T | ParseError> {
134
134
  const expected = label !== undefined ? [label] : deriveExpected(combinator)
135
135
  const meta: ParserMeta = {
136
- firstSet: combinator._meta.firstSet,
136
+ // It runs on any character: on one its child can't start, it reports the error.
137
+ firstSet: any(),
137
138
  canMatchNewline: combinator._meta.canMatchNewline,
138
139
  isTrivia: false,
139
140
  }
@@ -202,7 +202,8 @@ function matchesEmptyBody(
202
202
  case 'many':
203
203
  case 'optional':
204
204
  case 'not':
205
- case 'peek': return true // zero repetitions / absent / lookahead
205
+ case 'peek':
206
+ case 'expect': return true // zero repetitions / absent / lookahead / recovered
206
207
  // Default `sepBy` is `(item (sep item)*)?` — it MATCHES THE EMPTY STRING.
207
208
  // Any `min >= 1` requires that many ITEMS, so it is nullable only when the
208
209
  // item is. (Keying this off `min === 1` reported every `{ min: 2 }` list as
@@ -220,7 +221,6 @@ function matchesEmptyBody(
220
221
  case 'trivia':
221
222
  case 'token':
222
223
  case 'leaf':
223
- case 'expect':
224
224
  case 'withCtx':
225
225
  case 'node':
226
226
  case 'grammar':
@@ -382,6 +382,8 @@ export function sequenceFirstSet(parsers: readonly Combinator<unknown>[]): First
382
382
  * repeat item's, a gap's and a node's gate read a pad through its non-empty
383
383
  * matches.
384
384
  *
385
+ * `follow: true` asks where a resync sentinel stops, which reads through `expect`.
386
+ *
385
387
  * `activeTrivia` is the live trivia, `null` for a scope known to have none (where
386
388
  * a reference installs its rule's trivia, `triviaThroughRef`), and `undefined`
387
389
  * where it is not known.
@@ -396,6 +398,7 @@ export function firstSetOf(
396
398
  resolve?: RefResolver,
397
399
  activeTrivia?: Combinator<unknown> | null,
398
400
  consuming = true,
401
+ follow = false,
399
402
  ): FirstSet {
400
403
  // `seen` is the current recursion path, not a global visited set. A completed
401
404
  // shared child must be analysed again for a sibling; only a back-edge on this
@@ -403,7 +406,7 @@ export function firstSetOf(
403
406
  if (seen.has(p)) return any() // cycle → any (safe over-approximation)
404
407
  seen.add(p)
405
408
  try {
406
- return firstSetBody(p, seen, resolve, activeTrivia, consuming)
409
+ return firstSetBody(p, seen, resolve, activeTrivia, consuming, follow)
407
410
  } finally {
408
411
  seen.delete(p)
409
412
  }
@@ -415,8 +418,9 @@ function firstSetBody(
415
418
  resolve: RefResolver | undefined,
416
419
  activeTrivia: Combinator<unknown> | null | undefined,
417
420
  consuming: boolean,
421
+ follow: boolean,
418
422
  ): FirstSet {
419
- const fs = (c: Combinator<unknown>): FirstSet => firstSetOf(c, seen, resolve, activeTrivia, consuming)
423
+ const fs = (c: Combinator<unknown>): FirstSet => firstSetOf(c, seen, resolve, activeTrivia, consuming, follow)
420
424
  const empties = (c: Combinator<unknown>): boolean => matchesEmpty(c, new Set(), resolve)
421
425
  const d = p._def as ParserDef
422
426
  switch (d.tag) {
@@ -437,7 +441,7 @@ function firstSetBody(
437
441
  // install; the interpreter's own callers (`sequence.ts` follow sentinels)
438
442
  // read a reference as 0.58.0 did (#223).
439
443
  const through = activeTrivia === null && resolve !== undefined
440
- return firstSetOf(target, seen, resolve, through ? triviaThroughRef(p, target, null) : activeTrivia, consuming)
444
+ return firstSetOf(target, seen, resolve, through ? triviaThroughRef(p, target, null) : activeTrivia, consuming, follow)
441
445
  }
442
446
  case 'choice': {
443
447
  let out: FirstSet = empty()
@@ -457,6 +461,9 @@ function firstSetBody(
457
461
  // original cursor.
458
462
  return sequenceSet(d.parsers, fs, empties, activeTrivia, consuming)
459
463
  }
464
+ // Runs on any character: on one its child can't start, it reports the error. A
465
+ // resync sentinel still stops where the child could start.
466
+ case 'expect': return follow ? fs(d.parser) : any()
460
467
  case 'peek': {
461
468
  // Deep-resolve the body: a `ref()` reads `any()` at CONSTRUCTION, so the
462
469
  // shallow `_meta.firstSet` baked into the assertion would lose the gate.
@@ -472,19 +479,19 @@ function firstSetBody(
472
479
  case 'leaf':
473
480
  case 'node':
474
481
  case 'sepBy':
475
- case 'expect':
476
482
  // A transaction accepts exactly what its inner accepts. Its own `_meta` is
477
483
  // the inner's construction-time set, which reads `any` when the inner opens
478
484
  // with a rule reference — resolve through it like any other wrapper.
479
485
  case 'attempt': return fs(d.parser)
480
486
  // A token runs its body with trivia cleared (`token.ts`).
481
- case 'token': return firstSetOf(d.parser, seen, resolve, activeTrivia === undefined ? undefined : null, consuming)
487
+ case 'token': return firstSetOf(d.parser, seen, resolve, activeTrivia === undefined ? undefined : null, consuming, follow)
482
488
  case 'grammar': return firstSetOf(
483
489
  d.parser,
484
490
  seen,
485
491
  resolve,
486
492
  d.clearTrivia ? null : (d.triviaParser ?? activeTrivia),
487
493
  consuming,
494
+ follow,
488
495
  )
489
496
  // A run starts with its first line's prefix; a nullable prefix starts with anything.
490
497
  case 'lines': {
@@ -108,7 +108,7 @@ export function sequence<T extends [Combinator<unknown>, ...Combinator<unknown>[
108
108
  if (tolerant) {
109
109
  followSentinels ??= parsers.map((_, i) => firstSetSentinel(
110
110
  parsers.slice(i + 1).reduce<ReturnType<typeof firstSetOf>>(
111
- (set, parser) => union(set, firstSetOf(parser, new Set(), undefined, undefined, false)), { kind: 'empty' },
111
+ (set, parser) => union(set, firstSetOf(parser, new Set(), undefined, undefined, false, true)), { kind: 'empty' },
112
112
  ),
113
113
  ))
114
114
  }
@@ -2844,8 +2844,46 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
2844
2844
  }
2845
2845
  if (hasAdj) {
2846
2846
  const first = linkFirst()
2847
+ // What a held gap needs undone if the next term recovers at zero width.
2848
+ // Written after the lookahead returns and read before the next term runs,
2849
+ // so nothing can re-enter this sequence in between.
2850
+ let heldFrom = 0, heldTl = 0, heldScanTl = 0, heldLog = 0, heldScanLog = 0, heldRoot = 0, heldScanRoot = 0
2847
2851
  const runnerAt = (i: number): TermRunner => {
2848
2852
  const kidIp = code[base + i]!
2853
+ if (i > 1 && holdsGap(code, k, base, n, i - 1, NULLABLE) && rowNullable(code, k, kidIp, NULLABLE)) {
2854
+ const kid = link(kidIp)
2855
+ return (input, cur, ctx) => {
2856
+ const from = heldFrom, tl = heldTl, scanTl = heldScanTl, log = heldLog
2857
+ const scanLog = heldScanLog, root = heldRoot, scanRoot = heldScanRoot
2858
+ const next = step(kid, input, cur, ctx)
2859
+ if (next !== cur || from === cur) return next
2860
+ rollbackScannedTriviaAt(ctx, tl, scanTl, log, scanLog, root, scanRoot)
2861
+ return from
2862
+ }
2863
+ }
2864
+ if (holdsGap(code, k, base, n, i, NULLABLE) && rowNullable(code, k, code[base + i + 1]!, NULLABLE)) {
2865
+ const kid = link(kidIp)
2866
+ return (input, cur, ctx) => {
2867
+ if (ctx.trivia === undefined) {
2868
+ const v = kid(input, cur, ctx)
2869
+ if (v === FAIL) return -1
2870
+ TERMV = v
2871
+ heldFrom = cur
2872
+ return EC.e
2873
+ }
2874
+ const tl = cstTlLen(ctx), log = ctx._triviaLog?.length ?? 0, root = ctx._rootTriviaLog?.length ?? 0
2875
+ const scanEnd = skipTrivia(input, cur, ctx)
2876
+ const scanTl = cstTlLen(ctx), scanLog = ctx._triviaLog?.length ?? 0, scanRoot = ctx._rootTriviaLog?.length ?? 0
2877
+ const v = kid(input, scanEnd, ctx)
2878
+ if (v === FAIL) return -1
2879
+ TERMV = v
2880
+ heldFrom = cur
2881
+ heldTl = tl; heldScanTl = scanTl
2882
+ heldLog = log; heldScanLog = scanLog
2883
+ heldRoot = root; heldScanRoot = scanRoot
2884
+ return scanEnd
2885
+ }
2886
+ }
2849
2887
  if (holdsGap(code, k, base, n, i, NULLABLE)) {
2850
2888
  const kid = link(kidIp)
2851
2889
  return (input, cur, ctx) => {
@@ -467,16 +467,40 @@ ${scanned}
467
467
  }`
468
468
  }
469
469
 
470
+ const TRIVIA_LOG_LENGTH = 'ctx._cstBuf!==undefined?(ctx._cstBuf.tl!==undefined?ctx._cstBuf.tl.length:0):(ctx._cstTriviaLog!==undefined?ctx._cstTriviaLog.length:0)'
471
+
470
472
  /**
471
473
  * A lookahead term that HOLDS its gap (`hold-gap.ts`): the gap is scanned and
472
474
  * kept, the lookahead runs at its end, and the cursor stays there for the next
473
- * term, which cannot match empty.
475
+ * term, which cannot match empty unless it `recovers`. Then the marks stay in
476
+ * scope for `emitHeldGapUndo`, which follows the next term.
474
477
  */
475
- function emitHoldTerm(callee: string, dst: string, t: string, l: SiteLabel, skip: string): string {
478
+ function emitHoldTerm(callee: string, dst: string, t: string, l: SiteLabel, skip: string, recovers: boolean): string {
476
479
  const fast = `const ${t}v=${callee}(input,cur,ctx)
477
480
  if(${t}v===FAIL)return FAIL
478
481
  ${dst}=${t}v
479
482
  cur=EC.e`
483
+ if (recovers) {
484
+ const marked = `let ${t}c=cur,${t}s=cur,${t}tl=0,${t}stl=0,${t}lg=0,${t}slg=0,${t}rt=0,${t}srt=0`
485
+ if (l.tri === TRI_NONE) return `${marked}\n${fast}`
486
+ const held = `${t}tl=${TRIVIA_LOG_LENGTH}
487
+ ${t}lg=ctx._triviaLog!==undefined?ctx._triviaLog.length:0
488
+ ${t}rt=ctx._rootTriviaLog!==undefined?ctx._rootTriviaLog.length:0
489
+ ${t}s=${skip}(input,cur,ctx)
490
+ ${t}stl=${TRIVIA_LOG_LENGTH}
491
+ ${t}slg=ctx._triviaLog!==undefined?ctx._triviaLog.length:0
492
+ ${t}srt=ctx._rootTriviaLog!==undefined?ctx._rootTriviaLog.length:0
493
+ const ${t}v=${callee}(input,${t}s,ctx)
494
+ if(${t}v===FAIL)return FAIL
495
+ ${dst}=${t}v
496
+ cur=${t}s`
497
+ return l.tri !== TRI_UNKNOWN ? `${marked}\n${held}` : `${marked}
498
+ if(ctx.trivia===undefined){
499
+ ${fast}
500
+ }else{
501
+ ${held}
502
+ }`
503
+ }
480
504
  if (l.tri === TRI_NONE) return fast
481
505
  const held = `const ${t}s=${skip}(input,cur,ctx)
482
506
  const ${t}v=${callee}(input,${t}s,ctx)
@@ -491,6 +515,11 @@ ${held}
491
515
  }`
492
516
  }
493
517
 
518
+ /** The next term recovered at zero width, so the gap held for it was never consumed. */
519
+ function emitHeldGapUndo(t: string): string {
520
+ return `if(cur===${t}s&&${t}s!==${t}c){rollbackScannedTriviaAt(ctx,${t}tl,${t}stl,${t}lg,${t}slg,${t}rt,${t}srt);cur=${t}c}`
521
+ }
522
+
494
523
  /**
495
524
  * The repetition's LEAD-TRIVIA skip, as a BLOCK.
496
525
  *
@@ -2019,6 +2048,7 @@ return v
2019
2048
  }
2020
2049
  parts.push('let cur=EC.e')
2021
2050
  const names: string[] = ['v0']
2051
+ let heldUndo: string | undefined
2022
2052
  for (let i = 1; i < n; i++) {
2023
2053
  const vn = `v${i}`
2024
2054
  parts.push(`let ${vn}`)
@@ -2038,9 +2068,16 @@ return v
2038
2068
  // The callee expression hands it over AFTER the scan statement, right
2039
2069
  // before the call, so a field inside the trivia grammar cannot take it.
2040
2070
  const callee = leadingTriviaFieldAt(code, k, childIp) ? `(LEAD.at=cur,${kids[i]!})` : kids[i]!
2041
- parts.push(holdsGap(code, k, base, n, i, nullable)
2042
- ? emitHoldTerm(callee, vn, tmp(), L, skipFor(L))
2043
- : emitTerm(callee, vn, tmp(), L, skipFor(L), L.tri >= 0 ? triviaMiss(L.tri) : undefined))
2071
+ if (holdsGap(code, k, base, n, i, nullable)) {
2072
+ const t = tmp()
2073
+ const recovers = rowNullable(code, k, code[base + i + 1]!, nullable)
2074
+ parts.push(emitHoldTerm(callee, vn, t, L, skipFor(L), recovers))
2075
+ heldUndo = recovers ? t : undefined
2076
+ } else {
2077
+ parts.push(emitTerm(callee, vn, tmp(), L, skipFor(L), L.tri >= 0 ? triviaMiss(L.tri) : undefined))
2078
+ if (heldUndo !== undefined) parts.push(emitHeldGapUndo(heldUndo))
2079
+ heldUndo = undefined
2080
+ }
2044
2081
  }
2045
2082
  names.push(vn)
2046
2083
  }
@@ -751,7 +751,7 @@ class Encoder {
751
751
  for (let j = i + 1; j < parsers.length; j++) {
752
752
  // As the interpreter's `followSentinels` derive them (`sequence.ts`): with no
753
753
  // trivia, whatever scope the sequence runs in.
754
- fs = union(fs, firstSetOf(parsers[j]!, new Set(), rr, undefined, false))
754
+ fs = union(fs, firstSetOf(parsers[j]!, new Set(), rr, undefined, false, true))
755
755
  }
756
756
  return this.charClass(fs)
757
757
  }
@@ -120,11 +120,15 @@ export function failCleanAnalysis(code: ArrayLike<number>): FailClean {
120
120
  case OP_CONT:
121
121
  out = clean(code[ip + 1]!) && neverFails(code[ip + 3]!)
122
122
  break
123
- // An arm's failure and the fallback's are committed; only the selector's is
124
- // not, and without a fallback an unknown key fails after the selector wrote.
125
- case OP_DISPATCH:
126
- out = code[ip + 3]! >= 0 && clean(code[ip + 1]!)
123
+ // An arm's failure and a committing fallback's are committed; only the
124
+ // selector's is not. An uncommitted fallback's failure leaves the selector's
125
+ // capture, unless the fallback reads it through `routed()`, which rolls that
126
+ // capture back first. With no fallback, the dispatch fails after the selector wrote.
127
+ case OP_DISPATCH: {
128
+ const flags = code[ip + 4]!
129
+ out = code[ip + 3]! >= 0 && ((flags & 2) === 0 || (flags & 1) === 1) && clean(code[ip + 1]!)
127
130
  break
131
+ }
128
132
  default: {
129
133
  const c = passChild(code, ip)
130
134
  out = c >= 0 && clean(c)
@@ -13,6 +13,10 @@
13
13
  *
14
14
  * Not before an adjacency assertion or a leading-trivia field, which read the
15
15
  * cursor from before the gap.
16
+ *
17
+ * A next term that can match empty only by recovering (`expect`) holds the gap
18
+ * too, so valid text still scans it once. If it does recover, the gap is
19
+ * unrecorded and the cursor goes back, where only invalid text pays for it.
16
20
  */
17
21
  import {
18
22
  OP_ADJ, OP_ATTEMPT, OP_CHOICE, OP_COV, OP_EXPECT, OP_FIELD, OP_GATE, OP_LABEL, OP_LEAF, OP_LIT, OP_LIT_CI,
@@ -24,16 +28,21 @@ import { leadingTriviaFieldAt } from './field-spec.ts'
24
28
  import { regexCanMatchEmpty } from '../regex/first-set.ts'
25
29
 
26
30
  const WRAPPERS = new Set([OP_NODE, OP_XFORM, OP_LEAF, OP_SOURCE_LEAF, OP_TOKEN, OP_SCOPE, OP_SCOPE_CAP,
27
- OP_SCOPE_PLAIN, OP_FIELD, OP_LABEL, OP_EXPECT, OP_WITHCTX, OP_RULE, OP_ATTEMPT, OP_COV, OP_GAPGATE])
31
+ OP_SCOPE_PLAIN, OP_FIELD, OP_LABEL, OP_WITHCTX, OP_RULE, OP_ATTEMPT, OP_COV, OP_GAPGATE])
28
32
 
29
33
  /**
30
34
  * Can the row at `ip` match empty? `true` wherever it cannot tell. A first-set
31
35
  * gate row wraps only a child that cannot (`encode.ts` gates nothing nullable).
36
+ * An `expect` recovers at zero width whatever its child needs; with `recovers`
37
+ * false, this asks what the row can match empty without recovering.
32
38
  */
33
- export function rowNullable(code: ArrayLike<number>, k: readonly unknown[], ip: number, memo: Map<number, boolean>): boolean {
34
- const hit = memo.get(ip)
39
+ export function rowNullable(
40
+ code: ArrayLike<number>, k: readonly unknown[], ip: number, memo: Map<number, boolean>, recovers = true,
41
+ ): boolean {
42
+ const key = recovers ? ip : ~ip
43
+ const hit = memo.get(key)
35
44
  if (hit !== undefined) return hit
36
- memo.set(ip, true) // a cycle is answered conservatively
45
+ memo.set(key, true) // a cycle is answered conservatively
37
46
  const op = code[ip]!
38
47
  const kids: number[] = []
39
48
  let out = true
@@ -42,20 +51,20 @@ export function rowNullable(code: ArrayLike<number>, k: readonly unknown[], ip:
42
51
  else if (op === OP_RX) { const re = k[code[ip + 1]!]; out = !(re instanceof RegExp) || regexCanMatchEmpty(re.source) }
43
52
  else if (op === OP_SEQ || op === OP_SEQV || op === OP_SEQX) {
44
53
  childSlots(code, ip, kids)
45
- out = kids.every(c => rowNullable(code, k, c, memo))
54
+ out = kids.every(c => rowNullable(code, k, c, memo, recovers))
46
55
  } else if (op === OP_CHOICE) {
47
56
  childSlots(code, ip, kids)
48
- out = kids.length === 0 || kids.some(c => rowNullable(code, k, c, memo))
57
+ out = kids.length === 0 || kids.some(c => rowNullable(code, k, c, memo, recovers))
49
58
  } else if (op === OP_REP || op === OP_REPV) {
50
- out = code[ip + 2] === 0 || rowNullable(code, k, code[ip + 1]!, memo)
59
+ out = code[ip + 2] === 0 || rowNullable(code, k, code[ip + 1]!, memo, recovers)
51
60
  } else if (op === OP_SEED) {
52
- out = rowNullable(code, k, code[ip + 1]!, memo)
61
+ out = rowNullable(code, k, code[ip + 1]!, memo, recovers)
53
62
  } else if (op === OP_CONT) {
54
- out = rowNullable(code, k, code[ip + 1]!, memo) && rowNullable(code, k, code[ip + 3]!, memo)
55
- } else if (WRAPPERS.has(op)) {
56
- if (childSlots(code, ip, kids) && kids.length === 1) out = rowNullable(code, k, kids[0]!, memo)
63
+ out = rowNullable(code, k, code[ip + 1]!, memo, recovers) && rowNullable(code, k, code[ip + 3]!, memo, recovers)
64
+ } else if (WRAPPERS.has(op) || (op === OP_EXPECT && !recovers)) {
65
+ if (childSlots(code, ip, kids) && kids.length === 1) out = rowNullable(code, k, kids[0]!, memo, recovers)
57
66
  }
58
- memo.set(ip, out)
67
+ memo.set(key, out)
59
68
  return out
60
69
  }
61
70
 
@@ -68,5 +77,5 @@ export function holdsGap(
68
77
  if (code[term] !== OP_NOT && code[term] !== OP_PEEK) return false
69
78
  const next = code[base + i + 1]!
70
79
  if (code[next] === OP_ADJ || leadingTriviaFieldAt(code, k, next)) return false
71
- return !rowNullable(code, k, next, memo)
80
+ return !rowNullable(code, k, next, memo, false)
72
81
  }
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.6'
16
+ export const PARSEMAN_VERSION = '0.61.8'