parseman 0.51.2 → 0.53.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.
Files changed (98) hide show
  1. package/dist/analysis/commitment.d.ts.map +1 -1
  2. package/dist/analysis/diagnostics.js +411 -28
  3. package/dist/analysis/diagnostics.js.map +3 -3
  4. package/dist/cli/index.js +6162 -6372
  5. package/dist/cli/index.js.map +3 -3
  6. package/dist/combinators/attempt.d.ts +11 -1
  7. package/dist/combinators/attempt.d.ts.map +1 -1
  8. package/dist/combinators/map.d.ts.map +1 -1
  9. package/dist/combinators/node.d.ts +6 -2
  10. package/dist/combinators/node.d.ts.map +1 -1
  11. package/dist/combinators/parser.d.ts +34 -0
  12. package/dist/combinators/parser.d.ts.map +1 -1
  13. package/dist/combinators/ref.d.ts +0 -13
  14. package/dist/combinators/ref.d.ts.map +1 -1
  15. package/dist/compiler/borrowed-rules.d.ts +30 -0
  16. package/dist/compiler/borrowed-rules.d.ts.map +1 -0
  17. package/dist/compiler/compile-linkable-table.d.ts +1 -1
  18. package/dist/compiler/grammar-coverage-ids.d.ts.map +1 -1
  19. package/dist/compiler/ir-serialize.d.ts +10 -4
  20. package/dist/compiler/ir-serialize.d.ts.map +1 -1
  21. package/dist/compiler/linker.d.ts +48 -91
  22. package/dist/compiler/linker.d.ts.map +1 -1
  23. package/dist/compiler/token-capability.js +4 -0
  24. package/dist/compiler/token-capability.js.map +1 -1
  25. package/dist/coverage.d.ts +1 -1
  26. package/dist/coverage.d.ts.map +1 -1
  27. package/dist/cst/reflection.d.ts.map +1 -1
  28. package/dist/cst/trivia-kinds.d.ts.map +1 -1
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +470 -725
  32. package/dist/index.js.map +3 -3
  33. package/dist/language-service/index.js.map +1 -1
  34. package/dist/plugin/evaluator.d.ts +38 -2
  35. package/dist/plugin/evaluator.d.ts.map +1 -1
  36. package/dist/plugin/index.d.ts.map +1 -1
  37. package/dist/plugin/index.js +1127 -544
  38. package/dist/plugin/index.js.map +3 -3
  39. package/dist/plugin/ir-eval.d.ts +21 -0
  40. package/dist/plugin/ir-eval.d.ts.map +1 -0
  41. package/dist/plugin/reducer-resolver.d.ts +6 -0
  42. package/dist/plugin/reducer-resolver.d.ts.map +1 -1
  43. package/dist/spec/index.js +555 -6086
  44. package/dist/spec/index.js.map +3 -3
  45. package/dist/spec/railroad-lib.d.ts +7 -2
  46. package/dist/spec/railroad-lib.d.ts.map +1 -1
  47. package/dist/spec/railroad.d.ts.map +1 -1
  48. package/dist/table/assemble.d.ts.map +1 -1
  49. package/dist/table/compile-rule-map.d.ts +11 -4
  50. package/dist/table/compile-rule-map.d.ts.map +1 -1
  51. package/dist/table/compile.d.ts +16 -0
  52. package/dist/table/compile.d.ts.map +1 -1
  53. package/dist/table/emit-assembly.d.ts.map +1 -1
  54. package/dist/table/encode-baseline.d.ts.map +1 -1
  55. package/dist/table/exec.d.ts.map +1 -1
  56. package/dist/table/index.js +272 -102
  57. package/dist/table/index.js.map +2 -2
  58. package/dist/table/ops.d.ts +7 -5
  59. package/dist/table/ops.d.ts.map +1 -1
  60. package/dist/table/site-labels.d.ts.map +1 -1
  61. package/dist/types.d.ts +8 -1
  62. package/dist/types.d.ts.map +1 -1
  63. package/dist/version.d.ts +1 -1
  64. package/package.json +1 -1
  65. package/src/analysis/commitment.ts +3 -1
  66. package/src/analysis/rebuild.ts +1 -1
  67. package/src/combinators/attempt.ts +26 -3
  68. package/src/combinators/first-set.ts +5 -1
  69. package/src/combinators/map.ts +5 -1
  70. package/src/combinators/node.ts +12 -3
  71. package/src/combinators/parser.ts +133 -67
  72. package/src/combinators/ref.ts +10 -1
  73. package/src/compiler/borrowed-rules.ts +88 -0
  74. package/src/compiler/compile-linkable-table.ts +1 -1
  75. package/src/compiler/grammar-coverage-ids.ts +35 -6
  76. package/src/compiler/ir-serialize.ts +13 -119
  77. package/src/compiler/linker.ts +190 -666
  78. package/src/coverage.ts +7 -5
  79. package/src/cst/reflection.ts +6 -1
  80. package/src/cst/trivia-kinds.ts +6 -0
  81. package/src/index.ts +1 -0
  82. package/src/plugin/evaluator.ts +114 -14
  83. package/src/plugin/index.ts +588 -110
  84. package/src/plugin/ir-eval.ts +165 -0
  85. package/src/plugin/reducer-resolver.ts +20 -3
  86. package/src/spec/railroad-lib.ts +18 -8
  87. package/src/spec/railroad.ts +3 -4
  88. package/src/table/assemble.ts +22 -8
  89. package/src/table/compile-rule-map.ts +18 -8
  90. package/src/table/compile.ts +61 -0
  91. package/src/table/emit-assembly.ts +8 -3
  92. package/src/table/encode-baseline.ts +5 -1
  93. package/src/table/encode.ts +13 -13
  94. package/src/table/exec.ts +6 -2
  95. package/src/table/ops.ts +7 -5
  96. package/src/table/site-labels.ts +3 -1
  97. package/src/types.ts +8 -1
  98. package/src/version.ts +1 -1
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Where the interpreter and a compiled table would bind one reference differently.
3
+ *
4
+ * A rule reference (`g.X`) is a slot object. The interpreter follows the slot it
5
+ * holds. The table encoder binds a reference to the map's rule of the same NAME
6
+ * (`encode.ts`, `winners`). Within one linked map the two are the same rule. They
7
+ * differ only for a rule object BORROWED from another grammar (`Entry: base.Entry`):
8
+ * its graph holds `base`'s own slots, so the interpreter keeps `base`'s `Atom` while
9
+ * the encoder binds this map's `Atom`. Both parse, and they accept different input.
10
+ *
11
+ * ponytail: refuses the pattern instead of rebinding borrowed graphs, which is the
12
+ * planned follow-up that lets both engines agree.
13
+ */
14
+ import type { Combinator } from '../types.ts'
15
+ import { childrenOf } from '../analysis/gating.ts'
16
+ import { winnerWrapsReference } from './token-alphabet.ts'
17
+
18
+ type Comb = Combinator<unknown>
19
+
20
+ const nameOf = (c: Comb): string | undefined => (c as unknown as { _ruleName?: string })._ruleName
21
+
22
+ /** Follow a slot to the combinator it resolves to, or `undefined` for a hole. */
23
+ function deref(c: Comb): Comb | undefined {
24
+ const seen = new Set<Comb>()
25
+ let current: Comb = c
26
+ while (current._def.tag === 'lazy' && !seen.has(current)) {
27
+ seen.add(current)
28
+ try { current = current._def.thunk() } catch { return undefined }
29
+ }
30
+ return current
31
+ }
32
+
33
+ /**
34
+ * The first rule of `map` whose graph reaches another grammar's slot for a rule
35
+ * `map` also defines — the rule, and the name it references — or `undefined`.
36
+ */
37
+ export function borrowedRuleReference(
38
+ map: Readonly<Record<string, Comb>>,
39
+ ): { rule: string; reference: string } | undefined {
40
+ const seen = new Set<Comb>()
41
+ for (const [rule, self] of Object.entries(map)) {
42
+ let found: string | undefined
43
+ const visit = (c: Comb): void => {
44
+ if (found !== undefined) return
45
+ if (c._def.tag === 'lazy') {
46
+ // The root's own slot leads to this rule's body; any other named slot is a
47
+ // reference, judged before it is ever marked seen (another root's own slot
48
+ // is that root's body, which its own walk covers).
49
+ if (!winnerWrapsReference(self, c)) {
50
+ const name = nameOf(c)
51
+ // OWN rules only: an inherited name (`toString`) is not a rule of the map.
52
+ const winner = name !== undefined && Object.hasOwn(map, name) ? map[name] : undefined
53
+ if (winner !== undefined) {
54
+ // This map's own reference, or another slot to the very same rule.
55
+ if (winnerWrapsReference(winner, c) || deref(c) === deref(winner)) return
56
+ found = name
57
+ return
58
+ }
59
+ }
60
+ // The root's body, or a rule this map does not define: both engines follow
61
+ // the slot, so its body matters only for what IT references. ONE step: the
62
+ // slot may resolve to another slot (an alias, `Entry: g.Atom`), and that one is
63
+ // a reference to judge too, not a hop to skip.
64
+ if (seen.has(c)) return
65
+ seen.add(c)
66
+ let body: Comb
67
+ try { body = c._def.thunk() } catch { return }
68
+ visit(body)
69
+ return
70
+ }
71
+ if (seen.has(c)) return
72
+ seen.add(c)
73
+ for (const child of childrenOf(c._def)) visit(child)
74
+ }
75
+ visit(self)
76
+ if (found !== undefined) return { rule, reference: found }
77
+ }
78
+ return undefined
79
+ }
80
+
81
+ /** The refusal, worded for the API that refused. */
82
+ export function borrowedRuleMessage(api: string, hit: { rule: string; reference: string }): string {
83
+ return `${api}: rule "${hit.rule}" is a rule object borrowed from another grammar, and its graph references `
84
+ + `"${hit.reference}", which this grammar also defines. The interpreter would keep the other grammar's `
85
+ + `"${hit.reference}" while a compiled table binds this grammar's, so the two would accept different input. `
86
+ + `Compose the grammar that defines "${hit.rule}" instead of borrowing its rule object, so its `
87
+ + `references resolve to this grammar's rules.`
88
+ }
@@ -31,7 +31,7 @@ import { PARSEMAN_VERSION } from '../version.ts'
31
31
  * composable half, and it is NOT new machinery: the plugin
32
32
  * already carries pieces as IR (`plugin/index.ts:1772`) and
33
33
  * the linker already re-lowers them (`linker.ts:640`,
34
- * `compileLinkable(evalRuleMapIR(p.ir), …)`).
34
+ * `compileLinkable(evalRuleMapIR(p.ir), …)` at build time).
35
35
  *
36
36
  * ── WHY THAT MAKES COMPOSITION THE EASY KIND ─────────────────────────────────
37
37
  * The open question was whether table-to-table composition has to merge two
@@ -60,7 +60,10 @@ function children(def: ParserDef, winners?: Record<string, Combinator<unknown>>)
60
60
  return []
61
61
  }
62
62
  const name = (resolved as Combinator<unknown> & { _ruleName?: string })._ruleName
63
- return name && winners?.[name] ? [winners[name]!] : [resolved]
63
+ const winner = name ? winners?.[name] : undefined
64
+ // A slot that IS its rule's winner (a runtime composition's) descends into
65
+ // its own body; any other reference reroutes to the winner.
66
+ return winner !== undefined && winner._def !== def ? [winner] : [resolved]
64
67
  }
65
68
  default: return []
66
69
  }
@@ -92,18 +95,41 @@ export function buildGrammarPlan(entry: Combinator<unknown> | readonly Combinato
92
95
  }
93
96
  }
94
97
  }
98
+ /** The body behind a slot that is itself its rule's winner — how a runtime
99
+ * composition holds every rule. Transparent: its body owns the rule. */
100
+ const selfWinnerBody = (parser: Combinator<unknown>): Combinator<unknown> | undefined => {
101
+ if (parser._def.tag !== 'lazy' || winners === undefined) return undefined
102
+ const name = winnerNames.get(parser)
103
+ if (name === undefined || winners[name] !== parser) return undefined
104
+ try { return parser._def.thunk() } catch { return undefined }
105
+ }
106
+ // A body that SEVERAL winners' slots resolve to (`X: shared, Y: shared`) can carry
107
+ // only one name, so there each slot owns its own rule and the body owns none.
108
+ const slotsPerBody = new Map<Combinator<unknown>, number>()
109
+ if (winners) {
110
+ for (const winner of Object.values(winners)) {
111
+ const body = selfWinnerBody(winner)
112
+ if (body !== undefined) slotsPerBody.set(body, (slotsPerBody.get(body) ?? 0) + 1)
113
+ }
114
+ }
115
+ const sharedBody = (parser: Combinator<unknown>): boolean => (slotsPerBody.get(parser) ?? 0) > 1
116
+ const ownRule = (parser: Combinator<unknown>, rule: string): void => {
117
+ const id = `rule:${rule}`
118
+ definitions.set(id, { id, kind: 'rule' })
119
+ rules.set(parser, id)
120
+ }
95
121
  const seen = new Set<Combinator<unknown>>()
96
122
  const visit = (parser: Combinator<unknown>, path: string): void => {
97
123
  if (seen.has(parser)) return
98
124
  seen.add(parser)
99
125
  const rule = winnerNames.get(parser) ?? (parser as Combinator<unknown> & { _ruleName?: string })._ruleName
126
+ const body = selfWinnerBody(parser)
100
127
  // `rules()` references are tagged too for linker naming, but only the final
101
128
  // rule definition owns execution coverage; otherwise ref + target double-hit.
102
- if (rule && (winnerNames.has(parser) || parser._def.tag !== 'lazy')) {
103
- const id = `rule:${rule}`
104
- definitions.set(id, { id, kind: 'rule' })
105
- rules.set(parser, id)
129
+ if (rule && body === undefined && !sharedBody(parser) && (winnerNames.has(parser) || parser._def.tag !== 'lazy')) {
130
+ ownRule(parser, rule)
106
131
  }
132
+ if (rule && body !== undefined && sharedBody(body)) ownRule(parser, rule)
107
133
  if (parser._def.tag === 'choice') {
108
134
  const ids = parser._def.parsers.map((_, index) => `choice:${path}/arm:${index}`)
109
135
  choices.set(parser, ids)
@@ -128,7 +154,10 @@ export function buildGrammarPlan(entry: Combinator<unknown> | readonly Combinato
128
154
  // caller's winner map is then the only durable public identity for each
129
155
  // root; using the generic `entry` path would merge separate roots' choice
130
156
  // IDs and omit their rule definitions.
131
- visit(root, winnerNames.get(root) ?? (root as Combinator<unknown> & { _ruleName?: string })._ruleName ?? 'entry')
157
+ const name = winnerNames.get(root) ?? (root as Combinator<unknown> & { _ruleName?: string })._ruleName ?? 'entry'
158
+ const body = selfWinnerBody(root)
159
+ if (body !== undefined && sharedBody(body) && !seen.has(root)) { seen.add(root); ownRule(root, name) }
160
+ visit(body ?? root, name)
132
161
  }
133
162
  return { definitions: [...definitions.values()].sort((a, b) => a.id.localeCompare(b.id)), choices, dispatches, attempts, labels, rules }
134
163
  }
@@ -33,7 +33,7 @@ import { peek } from '../combinators/peek.ts'
33
33
  import { node } from '../combinators/node.ts'
34
34
  import { parser } from '../combinators/grammar.ts'
35
35
  import { scanTo, balanced, type BalancedAmbient } from '../combinators/scanTo.ts'
36
- import { token, leaf, sourceLeaf } from '../combinators/token.ts'
36
+ import { token, leaf } from '../combinators/token.ts'
37
37
  import { classifiedTrivia, transform, trivia, label, field } from '../combinators/map.ts'
38
38
  import { analyzeLabeledTrivia } from '../cst/trivia-kinds.ts'
39
39
  import { expect as expectC } from '../combinators/expect.ts'
@@ -157,123 +157,17 @@ export function serializeRuleMap(
157
157
  }
158
158
  }
159
159
 
160
- /** Reconstruct a rule map from serialized IR (the inverse of `serializeRuleMap`) —
161
- * evaluate the combinator-construction expression with every constructor in scope.
162
- * Used at fuse time (runtime linker + build-time plugin) to re-lower carried IR. */
163
- export function evalRuleMapIR(ir: string): Array<[string, Comb]> {
164
- // `_tf`/`_nd` reconstruct a transform/node AND restore its captured callback
165
- // source (`_def.fnSrc`/`buildSrc`) so re-lowering inlines it statically. The live
166
- // fn is only needed for interpreted mode; a self-contained transform source is
167
- // eval'd, a node build (which may reference imported AST classes) is left to its
168
- // source only. Macro-only validation metadata is carried as plain data: this
169
- // runtime module never parses callback source or imports a compiler frontend.
170
- const _tf = (child: Comb, src: string, recognitionOnly = false): Comb => {
171
- let fn: (...a: unknown[]) => unknown
172
- // eslint-disable-next-line no-eval
173
- try { fn = (0, eval)(`(${src})`) } catch { fn = () => { throw new Error('IR transform fn not materialized') } }
174
- const t = transform(child as never, fn as never)
175
- ;(t._def as { fnSrc?: string }).fnSrc = src
176
- if (recognitionOnly) (t._def as { recognitionOnly?: boolean }).recognitionOnly = true
177
- return t as Comb
178
- }
179
- const _lf = (child: Comb, src: string, source = false): Comb => {
180
- let fn: (...a: unknown[]) => unknown
181
- // eslint-disable-next-line no-eval
182
- try { fn = (0, eval)(`(${src})`) } catch { fn = () => { throw new Error('IR leaf fn not materialized') } }
183
- const l = source ? sourceLeaf(child as never, fn as never) : leaf(child as never, fn as never)
184
- ;(l._def as { fnSrc?: string }).fnSrc = src
185
- return l as Comb
186
- }
187
- const _nd = (type: string, child: Comb, src: string, opts?: unknown, staticError?: readonly string[], sigSrc?: string, buildImports?: ReadonlyArray<{ local: string; source: string; imported: string }>, rawUnused?: true): Comb => {
188
- if (staticError !== undefined && staticError.length > 0) {
189
- // Fail closed: a builder with an un-rescuable binding is NOT fused. The
190
- // plugin catches this and leaves the runtime compose() in place rather than
191
- // shipping a fused table that would ReferenceError on import. Import-rescued
192
- // free names never reach here — they ride in `buildImports`, not `staticError`.
193
- throw new Error(`IR direct node builder for ${type} must be macro-static and self-contained; unsupported binding(s): ${staticError.join(', ')}`)
194
- }
195
- // A serialized direct builder needs an inert sentinel as well as buildSrc.
196
- // `node(..., undefined)` is structural, so re-lowering a composed artifact
197
- // silently routes it through ctx.build/default CST even though the IR still
198
- // carries the callback source. The compiler is the only consumer that may
199
- // materialize `buildSrc`; raw IR interpretation deliberately rejects direct
200
- // builders rather than evaluating arbitrary captured source at runtime.
201
- const n = node(type, child as never, (() => { throw new Error('IR node build requires static re-lowering') }) as never, opts as never)
202
- ;(n._def as { buildSrc?: string; buildStaticError?: readonly string[] }).buildSrc = src
203
- if (staticError !== undefined) (n._def as { buildStaticError?: readonly string[] }).buildStaticError = staticError
204
- // Analysis-only resolved reducer signature (see `buildAnalysisSrc`). Without it a
205
- // re-lowered composed artifact silently re-acquires the fail-open capture cost the
206
- // authoring module had already resolved away.
207
- if (sigSrc !== undefined) (n._def as { buildSigSrc?: string }).buildSigSrc = sigSrc
208
- if (rawUnused === true) (n._def as { buildRawUnused?: true }).buildRawUnused = true
209
- // Re-attach the direct-builder import provenance so the plugin's re-lower pass
210
- // can re-emit the imports into the consuming module. Plain data — this runtime
211
- // module never resolves or emits imports itself.
212
- if (buildImports !== undefined) (n._def as { buildImports?: ReadonlyArray<{ local: string; source: string; imported: string }> }).buildImports = buildImports
213
- return n as Comb
214
- }
215
- // `_gch` rebuilds a GATED choice AND restores its `_def.gateSrcs` (parallel to the
216
- // arms) — the gate mirror of `_tf`/`_nd`. Each item is either a plain arm or a
217
- // `[gateSource, arm]` tuple: the source is eval'd to the live predicate (interpreted
218
- // mode) and recorded as the arm's gateSrcs entry so re-lowering INLINES the gate
219
- // statically (keeping the artifact fusible via `emitFusedSource`). A missing/failed
220
- // source falls back to a throwing predicate — the same contract as `_tf`.
221
- const _gch = (items: Array<Comb | [string, Comb]>): Comb => {
222
- const gateSrcs: (string | null)[] = []
223
- const arms = items.map(it => {
224
- if (Array.isArray(it)) {
225
- const [src, comb] = it
226
- gateSrcs.push(src)
227
- let gate: (s: unknown) => boolean
228
- // eslint-disable-next-line no-eval
229
- try { gate = (0, eval)(`(${src})`) } catch { gate = () => { throw new Error('IR gate fn not materialized') } }
230
- return { gate, combinator: comb }
231
- }
232
- gateSrcs.push(null)
233
- return it
234
- })
235
- const c = (choice as (...a: unknown[]) => Comb)(...arms)
236
- // `choice` is the authority on gate alignment; `gateSrcs` must line up 1:1 with
237
- // the constructed `_def.gates`. They always do on the normal path (both from the
238
- // same `items.map`), but assert it so a future change in `choice` that coalesces
239
- // or drops arms can't silently ship a mis-aligned gate-source array.
240
- const gates = (c._def as { gates?: unknown[] }).gates
241
- if (gates && gates.length !== gateSrcs.length) {
242
- throw new Error(`_gch: gateSrcs length ${gateSrcs.length} != choice gates length ${gates.length}`)
243
- }
244
- ;(c._def as { gateSrcs?: (string | null)[] }).gateSrcs = gateSrcs
245
- return c
246
- }
247
- // `_wc` rebuilds a `withCtx` AND restores its `_def.extraSrc` (the source of the
248
- // `extra`/state value) — the withCtx mirror of `_tf`/`_nd`/`_gch`. The source is
249
- // eval'd to the live `extra` value (interpreted mode) and recorded on the def so
250
- // re-lowering INLINES the state getter statically (`() => (extraSrc)`), keeping the
251
- // artifact fusible via `emitFusedSource`. A plain `withCtx(value, inner)` would
252
- // leave `extraSrc` unset → codegen emits a source-less runtime closure (a non-static
253
- // callback) → `emitFusedSource` fails and a downstream `compose()` silently falls
254
- // back to a runtime fuse. A missing/failed source falls back to `undefined` state —
255
- // the same best-effort contract as `_tf`.
256
- const _wc = (src: string, inner: Comb): Comb => {
257
- let extra: unknown
258
- // eslint-disable-next-line no-eval
259
- try { extra = (0, eval)(`(${src})`) } catch { extra = undefined }
260
- const w = (withCtx as (e: unknown, c: Comb) => Comb)(extra, inner)
261
- ;(w._def as { extraSrc?: string }).extraSrc = src
262
- return w
263
- }
264
- // eslint-disable-next-line no-new-func
265
- const fn = new Function(
266
- 'rules', 'ref', 'regex', 'literal', 'keywords', 'sequence', 'choice', 'dispatch', 'when', 'startsWith', 'endsWith', 'matches', 'otherwise', 'routed', 'attempt',
267
- 'many', 'oneOrMore', 'optional', 'sepBy', 'keepSeparator', 'not', 'peek', 'node', 'parser',
268
- 'scanTo', 'balanced', 'token', 'leaf', 'transform', 'trivia', 'classifiedTrivia', 'label', 'field', 'expect', 'adjacent', 'notAdjacent', '_tf', '_lf', '_nd', '_gch', '_wc',
269
- `return (${ir})`,
270
- )
271
- const map = fn(
272
- rules, ref, regex, literal, keywords, sequence, choice, dispatch, when, startsWith, endsWith, matches, otherwise, routed, attempt,
273
- many, oneOrMore, optional, sepBy, keepSeparator, not, peek, node, parser,
274
- scanTo, balanced, token, leaf, transform, trivia, classifiedTrivia, label, field, expectC, adjacent, notAdjacent, _tf, _lf, _nd, _gch, _wc,
275
- ) as Record<string, Comb>
276
- return Object.entries(map)
160
+ /**
161
+ * ONE plain combinator as IR — what a compiled module carries on an exported terminal
162
+ * so another module can import it into a grammar (the macro evaluates grammars, and a
163
+ * compiled parser function is not a combinator it can evaluate).
164
+ *
165
+ * No new format: it is `serializeRuleMap`'s `scanSkip` channel over an empty map, which
166
+ * already serializes rule-free terminals OUTSIDE any factory — exactly this shape. Its
167
+ * inverse, `evalCombinatorIR`, evaluates source and so lives in `src/plugin/ir-eval.ts`.
168
+ */
169
+ export function serializeCombinator(c: Comb): string | null {
170
+ return serializeRuleMap([], [c])
277
171
  }
278
172
 
279
173
  /** A recursive combinator currently being emitted, and the local `ref()` var that
@@ -574,7 +468,7 @@ class Serializer {
574
468
  ? `oneOrMore(${kid(def.parser)})`
575
469
  : `many(${kid(def.parser)}${repeatOpts(def.min, def.max)})`
576
470
  case 'optional': return `optional(${kid(def.parser)})`
577
- case 'attempt': return `attempt(${kid(def.parser)})`
471
+ case 'attempt': return `attempt(${kid(def.parser)}${def.contain === true ? ', { contain: true }' : ''})`
578
472
  // `keepSeparators` is an opt-in expressed at the SEPARATOR, not in the options
579
473
  // bag, so it must round-trip as `keepSeparator(sep)` — serializing it as an
580
474
  // option would reconstruct a grammar whose call site no longer states its own