parseman 0.55.0 → 0.55.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.
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.55.0";
16
+ export declare const PARSEMAN_VERSION = "0.55.2";
17
17
  //# sourceMappingURL=version.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parseman",
3
- "version": "0.55.0",
3
+ "version": "0.55.2",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "engines": {
6
6
  "node": "^20.19.0 || >=22.13.0"
@@ -441,24 +441,41 @@ export function hasDirectBuildDef(p: Combinator<unknown>, seen: Set<Combinator<u
441
441
  }
442
442
  }
443
443
 
444
- /** True when `p` can report a committed failure through emitFallible's failure channel. */
444
+ /**
445
+ * True when `p` can report a committed failure through emitFallible's failure
446
+ * channel: a `dispatch`, or a shape this analysis cannot see into, is reachable
447
+ * along `commitFailureSuccessors`' edges.
448
+ *
449
+ * `memo` carries exact answers between calls over one graph and one `resolve`;
450
+ * a caller asking at many sites passes the same map to every call, so the graph
451
+ * is walked once rather than once per site.
452
+ */
445
453
  export function mayCommitFailure(
446
454
  p: Combinator<unknown>,
447
- seen: Set<Combinator<unknown>> = new Set(),
448
455
  resolve?: RefResolver,
456
+ memo: Map<Combinator<unknown>, boolean> = new Map(),
449
457
  ): boolean {
450
- if (seen.has(p)) return false
451
- seen.add(p)
458
+ return reachesMarked(p, node => commitFailureSuccessors(node, resolve), memo)
459
+ }
460
+
461
+ const NO_EDGES: readonly Combinator<unknown>[] = []
462
+
463
+ /** `mayCommitFailure`'s edges out of `p`, or `true` when `p` itself can commit. */
464
+ function commitFailureSuccessors(
465
+ p: Combinator<unknown>,
466
+ resolve: RefResolver | undefined,
467
+ ): readonly Combinator<unknown>[] | true {
452
468
  const d = p._def
453
469
  switch (d.tag) {
454
470
  case 'dispatch':
471
+ case 'unknown':
455
472
  return true
456
473
  case 'choice':
457
474
  case 'sequence':
458
- return d.parsers.some(x => mayCommitFailure(x, seen, resolve))
475
+ return d.parsers
459
476
  // A contained transaction reports every failure uncommitted.
460
477
  case 'attempt':
461
- return d.contain !== true && mayCommitFailure(d.parser, seen, resolve)
478
+ return d.contain === true ? NO_EDGES : [d.parser]
462
479
  case 'many':
463
480
  case 'oneOrMore':
464
481
  case 'optional':
@@ -467,32 +484,102 @@ export function mayCommitFailure(
467
484
  case 'field':
468
485
  case 'grammar':
469
486
  case 'node':
470
- return mayCommitFailure(d.parser, seen, resolve)
471
- case 'sepBy':
472
- return mayCommitFailure(d.parser, seen, resolve) || mayCommitFailure(d.separator, seen, resolve)
473
487
  case 'token':
474
488
  case 'leaf':
475
- return mayCommitFailure(d.parser, seen, resolve)
476
489
  case 'withCtx':
477
- return mayCommitFailure(d.parser, seen, resolve)
490
+ return [d.parser]
491
+ case 'sepBy':
492
+ return [d.parser, d.separator]
478
493
  case 'scanTo':
479
- return mayCommitFailure(d.sentinel, seen, resolve)
480
- || d.skip.some(x => mayCommitFailure(x, seen, resolve))
481
- || (d.recoverAt !== undefined && mayCommitFailure(d.recoverAt, seen, resolve))
482
- || (d.stopAt !== undefined && mayCommitFailure(d.stopAt, seen, resolve))
494
+ return [
495
+ d.sentinel,
496
+ ...d.skip,
497
+ ...(d.recoverAt === undefined ? NO_EDGES : [d.recoverAt]),
498
+ ...(d.stopAt === undefined ? NO_EDGES : [d.stopAt]),
499
+ ]
483
500
  case 'lazy': {
484
- try { return mayCommitFailure(d.thunk(), seen, resolve) }
501
+ try { return [d.thunk()] }
485
502
  catch {
486
503
  const name = (p as unknown as { _ruleName?: string })._ruleName
487
504
  const target = name === undefined ? undefined : resolve?.(name)
488
- return target === undefined ? true : mayCommitFailure(target, seen, resolve)
505
+ return target === undefined ? true : [target]
489
506
  }
490
507
  }
491
- case 'unknown':
492
- return true
493
508
  default:
494
- return false
509
+ return NO_EDGES
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Whether a MARKED node — one `successors` answers `true` for — is reachable
515
+ * from `root`.
516
+ *
517
+ * Reachability over a cyclic graph, decided with Tarjan's strongly connected
518
+ * components so that every node the walk enters leaves with its exact answer in
519
+ * `memo`: the members of one component reach each other, so they share one
520
+ * answer, settled when the walk returns to the component's first node. A DFS
521
+ * that instead cuts a cycle at its own ancestor path learns nothing it can keep
522
+ * about a node whose walk hit the cut; one that keeps a visited set per call
523
+ * keeps nothing past the call. Shared across calls, `memo` makes every node
524
+ * entered once however many roots are asked about.
525
+ *
526
+ * `memo` must only ever be used with one `successors`: its entries are answers
527
+ * about that graph.
528
+ */
529
+ export function reachesMarked<T>(
530
+ root: T,
531
+ successors: (node: T) => readonly T[] | true,
532
+ memo: Map<T, boolean>,
533
+ ): boolean {
534
+ const known = memo.get(root)
535
+ if (known !== undefined) return known
536
+ const order = new Map<T, number>()
537
+ const low: number[] = []
538
+ const found: boolean[] = []
539
+ const stack: T[] = []
540
+ const visit = (node: T): number => {
541
+ const at = low.length
542
+ order.set(node, at)
543
+ low.push(at)
544
+ found.push(false)
545
+ stack.push(node)
546
+ const next = successors(node)
547
+ if (next === true) found[at] = true
548
+ else {
549
+ for (const child of next) {
550
+ // Settled: a node that reaches a marked node is answered, and so is
551
+ // every node still above it on the stack, since each reaches it. The
552
+ // children left unentered stay unmemoized for a later walk to settle.
553
+ if (found[at]) break
554
+ const hit = memo.get(child)
555
+ if (hit !== undefined) {
556
+ if (hit) found[at] = true
557
+ continue
558
+ }
559
+ const seen = order.get(child)
560
+ if (seen !== undefined) {
561
+ // Unmemoized but entered: still on the stack, so in this node's
562
+ // component. Its answer reaches the component's first node by the
563
+ // tree edges, not through here.
564
+ if (seen < low[at]!) low[at] = seen
565
+ continue
566
+ }
567
+ const k = visit(child)
568
+ if (low[k]! < low[at]!) low[at] = low[k]!
569
+ if (found[k]) found[at] = true
570
+ }
571
+ }
572
+ if (low[at] === at) {
573
+ const answer = found[at]!
574
+ let member: T
575
+ do {
576
+ member = stack.pop()!
577
+ memo.set(member, answer)
578
+ } while (member !== node)
579
+ }
580
+ return at
495
581
  }
582
+ return found[visit(root)]!
496
583
  }
497
584
 
498
585
  /** A leaf-composed imported piece may carry Parseman's own structural balanced
@@ -1,7 +1,7 @@
1
1
  import type { AutoNotCheck, Combinator, FirstSet, ParserDef } from '../types.ts'
2
2
  import { classifyFinalChoice, firstSetOf, intersects, matchesEmpty, union, type RefResolver } from '../combinators/first-set.ts'
3
3
  import { childrenOf } from '../analysis/gating.ts'
4
- import { capturesLeaf, mayCommitFailure, mayLeavePartialCapture } from '../analysis/commitment.ts'
4
+ import { capturesLeaf, mayCommitFailure, mayLeavePartialCapture, reachesMarked } from '../analysis/commitment.ts'
5
5
  import { getCoreLiteralValue } from '../combinators/choice.ts'
6
6
  import { deriveExpected } from '../combinators/expect.ts'
7
7
  import { assertionFailureExpected, directTerminalFailureExpected } from '../combinators/expected.ts'
@@ -147,21 +147,20 @@ class Encoder {
147
147
  private nonCommittingChoiceSites = new Set<number>()
148
148
  private failureRollbackCleanSites = new Set<number>()
149
149
  /**
150
- * `hasSideWriter` memo for `failureNeedsRollback`, shared across every call
151
- * for the lifetime of this `Encoder`. A cached entry is written only when
152
- * `complete` is true, which means the answer is a path-independent fact
153
- * about that combinator node — it does not depend on which root's traversal
154
- * asked, only on the node's own reachable subgraph. Scoping the memo per call
155
- * (as a local inside `failureNeedsRollback`) was correct but discarded that
156
- * fact after every arm, so a grammar with many ordered-choice arms / optional
157
- * / attempt sites re-walked the SAME shared rule graph from scratch for each
158
- * site — O(sites × graph size) instead of O(graph size). Measured on the
159
- * scss-parser grammar (composed CSS-base superset, the deepest/largest of the
160
- * four grammars): 47-50s for one `compileRuleMap` with the per-call memo,
161
- * versus the whole macro transform completing in under a second once this
162
- * memo is hoisted here.
150
+ * `reachesSideWriter`'s answers, shared by every `failureNeedsRollback` call
151
+ * for the lifetime of this `Encoder`. Every entry is exact — it depends only on
152
+ * the node's own reachable subgraph, never on which root's walk asked — so each
153
+ * combinator is walked once per encode however many choice arms, optionals and
154
+ * attempt sites reach it.
163
155
  */
164
156
  private sideWriterMemo = new Map<Combinator<unknown>, boolean>()
157
+ /**
158
+ * `mayCommitFailure`'s answers, shared by every choice site for the lifetime
159
+ * of this `Encoder` — exact for the graph and the `winners` resolver it is
160
+ * encoding, so the rule graph is walked once per encode rather than once per
161
+ * choice site.
162
+ */
163
+ private commitFailureMemo = new Map<Combinator<unknown>, boolean>()
165
164
  labels: readonly string[] | undefined = undefined
166
165
  /** Trivia table in scope at the row being encoded — codegen's `ctx.activeTrivia`. */
167
166
  activeTrivia: Combinator<unknown> | undefined = undefined
@@ -653,8 +652,7 @@ class Encoder {
653
652
  * writer census covers the side sinks that proof deliberately does not model:
654
653
  * fields, recovered errors, and interpreted scanners. Strict assemblies do
655
654
  * not run repeat recovery, but these rows can still publish through a caller's
656
- * context. Cycles are safe to stop: revisiting the same combinator cannot
657
- * discover a writer the first traversal did not already reach.
655
+ * context.
658
656
  */
659
657
  private failureNeedsRollback(
660
658
  p: Combinator<unknown>,
@@ -663,63 +661,42 @@ class Encoder {
663
661
  const refName = (p as unknown as { _ruleName?: string })._ruleName
664
662
  const winner = refName === undefined ? undefined : this.winners?.[refName]
665
663
  const root = winner !== undefined && !winnerWrapsReference(winner, p) ? winner : p
666
- const sideWriterMemo = this.sideWriterMemo
667
- // A single mutable `seen` set threaded through the whole call, rather than
668
- // copied at every recursion level: this DFS visits children strictly
669
- // sequentially, so marking `current` in progress before recursing and
670
- // clearing it once its subtree is fully explored reproduces the exact same
671
- // ancestor-path membership an immutable copy-per-level would have computed,
672
- // without the O(depth) allocation on every node.
673
- const seen = new Set<Combinator<unknown>>()
674
- const hasSideWriter = (current: Combinator<unknown>): { found: boolean; complete: boolean } => {
675
- const cached = sideWriterMemo.get(current)
676
- if (cached !== undefined) return { found: cached, complete: true }
677
- if (seen.has(current)) return { found: false, complete: false }
678
- seen.add(current)
679
- const def = current._def
680
- let result: { found: boolean; complete: boolean }
681
- switch (def.tag) {
682
- case 'field':
683
- case 'expect':
684
- case 'recover':
685
- case 'scanTo':
686
- case 'unknown':
687
- result = { found: true, complete: true }
688
- break
689
- case 'lazy': {
690
- const name = (current as unknown as { _ruleName?: string })._ruleName
691
- const resolvedWinner = name === undefined ? undefined : this.winners?.[name]
692
- if (resolvedWinner !== undefined && !winnerWrapsReference(resolvedWinner, current)) {
693
- result = hasSideWriter(resolvedWinner)
694
- break
695
- }
696
- try { result = hasSideWriter(def.thunk()) }
697
- catch { result = { found: true, complete: true } }
698
- break
699
- }
700
- default: {
701
- let complete = true
702
- result = { found: false, complete: true }
703
- for (const child of childrenOf(def)) {
704
- const childResult = hasSideWriter(child)
705
- if (childResult.found) {
706
- result = { found: true, complete: true }
707
- break
708
- }
709
- complete &&= childResult.complete
710
- }
711
- if (!result.found) result = { found: false, complete }
712
- }
713
- }
714
- seen.delete(current)
715
- if (result.found || result.complete) sideWriterMemo.set(current, result.found)
716
- return result
717
- }
718
664
  return mayLeavePartialCapture(root, new Set(), true)
719
- || hasSideWriter(root).found
665
+ || this.reachesSideWriter(root)
720
666
  || ((autoNot?.length ?? 0) > 0 && capturesLeaf(root))
721
667
  }
722
668
 
669
+ /**
670
+ * Whether a field, recovered error, interpreted scanner or unresolvable shape
671
+ * is reachable from `root` — the writer census of `failureNeedsRollback`,
672
+ * entering each combinator once per `Encoder` (see `reachesMarked`).
673
+ */
674
+ private reachesSideWriter(root: Combinator<unknown>): boolean {
675
+ return reachesMarked(root, node => this.sideWriterSuccessors(node), this.sideWriterMemo)
676
+ }
677
+
678
+ /** The census's edges out of `node`, or `true` when `node` is itself a writer. */
679
+ private sideWriterSuccessors(node: Combinator<unknown>): readonly Combinator<unknown>[] | true {
680
+ const def = node._def
681
+ switch (def.tag) {
682
+ case 'field':
683
+ case 'expect':
684
+ case 'recover':
685
+ case 'scanTo':
686
+ case 'unknown':
687
+ return true
688
+ case 'lazy': {
689
+ const name = (node as unknown as { _ruleName?: string })._ruleName
690
+ const winner = name === undefined ? undefined : this.winners?.[name]
691
+ if (winner !== undefined && !winnerWrapsReference(winner, node)) return [winner]
692
+ try { return [def.thunk()] }
693
+ catch { return true }
694
+ }
695
+ default:
696
+ return childrenOf(def)
697
+ }
698
+ }
699
+
723
700
  /** A first set becomes a char class string, or −1 for `any`. */
724
701
  private charClass(fs: FirstSet): number {
725
702
  if (fs.kind !== 'ranges' || fs.ranges.length === 0) return -1
@@ -1244,7 +1221,7 @@ class Encoder {
1244
1221
  this.code[head + 4 + kids.length + i] = this.expected(deriveExpected(arms[i]!))
1245
1222
  }
1246
1223
  this.choiceRollbackMasks.set(head, rollbackMask)
1247
- if (arms.every(arm => !mayCommitFailure(arm, new Set(), rr))) {
1224
+ if (arms.every(arm => !mayCommitFailure(arm, rr, this.commitFailureMemo))) {
1248
1225
  this.nonCommittingChoiceSites.add(head)
1249
1226
  }
1250
1227
  return head
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.55.0'
16
+ export const PARSEMAN_VERSION = '0.55.2'