parseman 0.50.3 → 0.50.5

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.50.3";
16
+ export declare const PARSEMAN_VERSION = "0.50.5";
17
17
  //# sourceMappingURL=version.d.ts.map
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "parseman",
3
- "version": "0.50.3",
3
+ "version": "0.50.5",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "engines": {
6
6
  "node": "^20.19.0 || >=22.13.0"
7
7
  },
8
- "description": "Parser combinators that compile to optimized JavaScript \u2014 use as a library or as a build-time macro",
8
+ "description": "Parser combinators that compile to optimized JavaScript — use as a library or as a build-time macro",
9
9
  "type": "module",
10
10
  "sideEffects": false,
11
11
  "author": "Matthew Dean <matthew-dean@users.noreply.github.com>",
@@ -63,11 +63,31 @@ export function classifyFinalChoice(
63
63
  return { exclusive, firstSets, nullable }
64
64
  }
65
65
 
66
- /** The `resolve`-supplied target for an unresolvable NAMED lazy, if any. */
67
- function resolveNamedRef(p: Combinator<unknown>, resolve: RefResolver | undefined): Combinator<unknown> | undefined {
66
+ /** The compose WINNER a named lazy ref resolves to — the same row the encoder's
67
+ * `lazy` case calls via `winners`/`scopedRef`. Winner-FIRST resolution is what makes
68
+ * first-set / nullability analysis AGREE with emission: a base piece's thunk still
69
+ * closes over its own binding, so a compose OVERRIDE (and any undefined cross-piece
70
+ * ref) would otherwise be invisible to the gate — the reducer reroutes, the recognizer
71
+ * does not. `undefined` → no winner; fall back to the thunk. Monolithic: the winner IS
72
+ * the thunk's target, a no-op. A winner that recurses back to `p` (recursive or
73
+ * self-alias rule) is caught by the shared `seen` guard → `any`, a sound over-approx. */
74
+ function winnerForRef(p: Combinator<unknown>, resolve: RefResolver | undefined): Combinator<unknown> | undefined {
68
75
  if (resolve === undefined) return undefined
69
76
  const name = (p as unknown as { _ruleName?: string })._ruleName
70
- return name === undefined ? undefined : resolve(name)
77
+ const w = name === undefined ? undefined : resolve(name)
78
+ // Skip a winner that merely wraps THIS reference (a transparent rules()/parser()
79
+ // alias): resolving to it is a self-hop, not an override — matches the encoder's
80
+ // own `winnerWrapsReference` guard so first-set analysis agrees with emission.
81
+ if (w === undefined || w === p) return undefined
82
+ let cur = w
83
+ for (const seen = new Set<Combinator<unknown>>(); !seen.has(cur); ) {
84
+ if (cur === p) return undefined
85
+ seen.add(cur)
86
+ const cd = cur._def as ParserDef
87
+ if (cd.tag !== 'grammar' && cd.tag !== 'trivia') return w
88
+ cur = cd.parser
89
+ }
90
+ return w
71
91
  }
72
92
 
73
93
  export function union(a: FirstSet, b: FirstSet): FirstSet {
@@ -190,9 +210,12 @@ function matchesEmptyBody(
190
210
  case 'node':
191
211
  case 'grammar':
192
212
  case 'recover': return me(d.parser)
193
- case 'lazy':
213
+ case 'lazy': {
214
+ const w = winnerForRef(p, resolve)
215
+ if (w !== undefined) return me(w)
194
216
  try { return me(d.thunk()) }
195
- catch { const t = resolveNamedRef(p, resolve); return t ? me(t) : true }
217
+ catch { return true }
218
+ }
196
219
  default: return true // scanTo / guard / unknown → assume nullable (safe)
197
220
  }
198
221
  }
@@ -338,9 +361,12 @@ function firstSetBody(
338
361
  case 'regex':
339
362
  case 'keywords': return p._meta.firstSet // terminals: no refs, cached set is exact
340
363
  case 'dispatch': return fs(d.selector)
341
- case 'lazy':
364
+ case 'lazy': {
365
+ const w = winnerForRef(p, resolve)
366
+ if (w !== undefined) return fs(w)
342
367
  try { return fs(d.thunk()) }
343
- catch { const t = resolveNamedRef(p, resolve); return t ? fs(t) : any() }
368
+ catch { return any() }
369
+ }
344
370
  case 'choice': {
345
371
  let out: FirstSet = empty()
346
372
  for (const arm of d.parsers) out = union(out, fs(arm))
@@ -321,6 +321,33 @@ class Serializer {
321
321
  return c._def.tag === 'lazy' && ruleNameOf(c) !== undefined ? (lazyTarget(c) ?? c) : c
322
322
  }
323
323
 
324
+ /**
325
+ * The child a rule-entry grammar scope should serialize as its body, when that
326
+ * scope merely WRAPS the rule's own named `lazy` proxy.
327
+ *
328
+ * `rules({ trackLines: true }, …)` wraps every rule VALUE — which is that rule's
329
+ * own named lazy proxy — in a `grammarParser({ trackLines: true })` scope
330
+ * (`parser.ts:229-242`). `body()` unwraps a BARE lazy entry to its defined target;
331
+ * a lazy wrapped one level deep by that scope must be unwrapped INSIDE the scope
332
+ * too. Left wrapped, the inner proxy serializes as a `g[name]` SELF-reference and
333
+ * the rule's real body is dropped, so it re-lowers to `Name: parser(opts, g.Name)`
334
+ * — a bodyless self-cycle the table encoder rejects with `alias cycle — lazy (rule
335
+ * 'Name') encodes to its own recursion trampoline`. This is why the four
336
+ * `*PositionsGrammar` exports fell back to a runtime `compose()` under trackLines.
337
+ *
338
+ * STRICT: only a scope that IS a rule entry (in `localRuleNames`) AND wraps ITS
339
+ * OWN name unwraps. A scope wrapping another rule's ref (a genuine cross-rule
340
+ * alias, `Name: parser(opts, g.Other)`) or a real body is untouched, so the
341
+ * non-trackLines and non-self-wrap output stays byte-identical.
342
+ */
343
+ private scopeSelfBody(c: Comb): Comb | undefined {
344
+ const name = this.localRuleNames.get(c)
345
+ if (name === undefined || c._def.tag !== 'grammar') return undefined
346
+ const inner = c._def.parser
347
+ if (inner._def.tag !== 'lazy' || ruleNameOf(inner) !== name) return undefined
348
+ return lazyTarget(inner) ?? undefined
349
+ }
350
+
324
351
  run(): string {
325
352
  for (const [, c] of this.ruleMap) this.analyze(this.body(c), new Set())
326
353
  for (const c of this.scanSkip) { this.assertNoRuleRef(c, new Set()); this.analyze(c, new Set()) }
@@ -415,7 +442,19 @@ class Serializer {
415
442
  return
416
443
  }
417
444
  active.add(c)
418
- for (const child of childrenOf(def)) this.analyze(child, active)
445
+ // When a rule-entry scope wraps its own named lazy, substitute the lazy's
446
+ // unwrapped body for `def.parser` — but keep the scope's OTHER children (its
447
+ // `triviaParser`) in the analysis, exactly as `childrenOf('grammar')` returns
448
+ // both. Dropping the trivia parser here would leave a recursive/shared trivia
449
+ // unanalysed, so its const/self-ref hoisting would be missing when `expr` emits
450
+ // it via `kid(def.triviaParser)`. selfBody is non-undefined only for `'grammar'`.
451
+ const selfBody = def.tag === 'grammar' ? this.scopeSelfBody(c) : undefined
452
+ const kids = selfBody === undefined
453
+ ? childrenOf(def)
454
+ : def.tag === 'grammar' && def.triviaParser
455
+ ? [selfBody, def.triviaParser]
456
+ : [selfBody]
457
+ for (const child of kids) this.analyze(child, active)
419
458
  active.delete(c)
420
459
  }
421
460
 
@@ -602,7 +641,7 @@ class Serializer {
602
641
  : spec
603
642
  ? `classifiedTrivia({ ${spec.arms.map(arm => `${JSON.stringify(arm.label)}: ${kid(arm.parser)}`).join(', ')} })`
604
643
  : def.triviaParser ? kid(def.triviaParser) : 'undefined'
605
- return `parser({ trivia: ${trivia}${def.captureTrivia ? ', captureTrivia: true' : ''}${def.rootCapture ? `, rootCapture: ${JSON.stringify(def.rootCapture)}` : ''}${def.trackLines ? ', trackLines: true' : ''} }, ${kid(def.parser)})`
644
+ return `parser({ trivia: ${trivia}${def.captureTrivia ? ', captureTrivia: true' : ''}${def.rootCapture ? `, rootCapture: ${JSON.stringify(def.rootCapture)}` : ''}${def.trackLines ? ', trackLines: true' : ''} }, ${kid(this.scopeSelfBody(c) ?? def.parser)})`
606
645
  }
607
646
  case 'withCtx': {
608
647
  // A `withCtx` round-trips through `_wc`, which rebuilds it AND re-attaches
@@ -1404,7 +1404,15 @@ class Encoder {
1404
1404
  // names tokens the run would have skipped — `['"@"', '"(", '"x"']` where
1405
1405
  // running the body reports `['"x"']` alone.
1406
1406
  if (matchesEmpty(d.parser)) return body
1407
- const fs = d.parser._meta.firstSet
1407
+ // A node whose BODY is a bare cross-rule ref (`node('X', g.Y, …)`) caches its
1408
+ // first set from the ref's base binding. Under a compose OVERRIDE the winner
1409
+ // for `Y` may be WIDER, so the cached set gates out input the merged rule
1410
+ // accepts (the reducer reroutes via `winners`, but this recognizer gate did
1411
+ // not). Resolve the ref's gate through `winners`, exactly as the `lazy` case
1412
+ // resolves the call. Non-ref bodies keep the cached set (byte-identical).
1413
+ const fs = d.parser._def.tag === 'lazy'
1414
+ ? firstSetOf(d.parser, new Set(), this.refResolver())
1415
+ : d.parser._meta.firstSet
1408
1416
  if (fs.kind === 'any') return body
1409
1417
  const cls = this.charClass(fs)
1410
1418
  if (cls < 0) return body
@@ -1594,7 +1602,11 @@ class Encoder {
1594
1602
  // run itself would have skipped past. Running the inner instead reported a
1595
1603
  // strictly smaller set, so omitting the guard was an observable divergence,
1596
1604
  // not a slower equivalent.
1597
- const fs = d.parser._meta.firstSet
1605
+ // Same override-aware resolution as the node gate: a bare-ref inner resolves
1606
+ // its gate through `winners` so a widened winner is not gated out.
1607
+ const fs = d.parser._def.tag === 'lazy'
1608
+ ? firstSetOf(d.parser, new Set(), this.refResolver())
1609
+ : d.parser._meta.firstSet
1598
1610
  if (fs.kind === 'any' || matchesEmpty(d.parser)) return inner
1599
1611
  const cls = this.charClass(fs)
1600
1612
  if (cls < 0) return inner
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.50.3'
16
+ export const PARSEMAN_VERSION = '0.50.5'