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
@@ -1,27 +1,22 @@
1
1
  /**
2
- * The linker: compose independently-carried grammar pieces into ONE runnable rule map.
2
+ * The linker: compose grammar pieces into ONE rule map.
3
3
  *
4
- * Composition is a RULE-MAP MERGE plus a single encode. Each piece carries its
5
- * combinator graph as IR; `compose()` evaluates every piece's IR back to a rule map,
6
- * lets a later piece's name override an earlier one, and encodes the merged map ONCE.
7
- * `enc.winners` binds every by-name reference against that merged map, so overriding a
8
- * rule reroutes EVERY call to it — including calls inside a base piece's own rules
9
- * (open recursion) — with no shared scope and no relocation. Later piece wins per
10
- * rule name.
4
+ * At RUNTIME, composition is linking and nothing else. Every piece's `rules()`
5
+ * factory runs again against one shared namespace (`linkRules`), a later piece's
6
+ * name winning, so an override reroutes every call to it — a base piece's own calls
7
+ * included (open recursion) — and the result is the interpreter grammar one `rules()`
8
+ * over all the pieces would build. No table, no source text, no `eval`, and no piece
9
+ * is mutated (docs/design/runtime-and-size-contract.md, rules 1 and 2).
11
10
  *
12
- * This replaced a textual splice. The source lowering compiled each piece to namespaced
13
- * `_r_<Name>` function sources and concatenated them into one `new Function` scope,
14
- * patching `@FS:` first-set placeholders per winner. That is why the linker needed
15
- * `'unsafe-eval'`; the merge is now data, so it does not.
11
+ * At BUILD time the macro plugin composes instead: it re-lowers each piece's carried
12
+ * IR and encodes the merged map once into a static table.
16
13
  */
17
- import { ruleDependencies, childrenOf } from '../analysis/gating.ts'
14
+ import { ruleDependencies } from '../analysis/gating.ts'
15
+ import { borrowedRuleMessage, borrowedRuleReference } from './borrowed-rules.ts'
18
16
  import { FUSED_HOST_MODE, FUSED_HOST_ELIDED, type HostMode } from '../cst/host-mode.ts'
19
- import { evalRuleMapIR, serializeRuleMap } from './ir-serialize.ts'
20
17
  import { compileLinkableTable, type LinkableTable } from './compile-linkable-table.ts'
21
- import { compileRuleMapRunnable } from '../table/compile-rule-map.ts'
22
- import { tableRules } from '../table/assemble.ts'
23
18
  import { GRAMMAR_REFLECTION } from '../cst/reflection.ts'
24
- import { PARSEMAN_VERSION } from '../version.ts'
19
+ import { linkRules, RULE_ORDER, RULES_RECIPE, type LinkOptions, type RulesRecipe } from '../combinators/parser.ts'
25
20
  import type { BuildHost, Combinator, CstCollapsePredicate, ParseContext, ParseResult } from '../types.ts'
26
21
  import type { Runnable } from '../functional/run.ts'
27
22
 
@@ -52,6 +47,10 @@ export function linkable(
52
47
  { ...(trivia ? { trivia } : {}), ...(hostMode ? { hostMode } : {}) },
53
48
  )
54
49
  if (!piece) throw new Error('linkable(): this grammar cannot be compiled to a linkable artifact (contains a runtime-only parser fallback)')
50
+ // A runtime artifact of a `rules()` grammar keeps its recipe, so a runtime
51
+ // `compose()` links it like the grammar itself.
52
+ const recipes = (rulesMap as Record<symbol, unknown>)[RULES_RECIPE]
53
+ if (Array.isArray(recipes)) Object.defineProperty(piece, RULES_RECIPE, { value: recipes, enumerable: false })
55
54
  return piece
56
55
  }
57
56
 
@@ -167,188 +166,6 @@ export type FusedRule = (
167
166
  ) => ParseResult<unknown> & { readonly value?: unknown }
168
167
 
169
168
 
170
- /**
171
- * Fuse carried pieces into a runnable rule map — the TABLE equivalent of the
172
- * textual splice this file used to perform.
173
- *
174
- * `fusedBody()` concatenated namespaced `_r_<Name>` function sources, picked a winning
175
- * function per name, and patched `@FS:` dispatch placeholders with the winner's
176
- * first-set condition — roughly 200 lines whose entire job was to make separately
177
- * lowered SOURCE agree about names. A table has no text to splice, so the merge moves
178
- * up one level onto the combinators: merge the rule maps (later piece wins), then
179
- * `encodeTable` ONCE over the merged map. `enc.winners` binds every by-name reference,
180
- * including a base piece's internal `g.Atom` that an override replaced, so open
181
- * recursion across pieces resolves without relocating a single encoded offset.
182
- *
183
- * One encode, no pools to merge, and the result is the table the merged grammar would
184
- * have produced had it been written as a single `rules()` call.
185
- */
186
- function fuseCarried(
187
- carried: ReadonlyArray<LinkableTable | IRPiece>,
188
- trivia?: Combinator<unknown>,
189
- hostMode?: HostMode,
190
- ): Record<string, FusedRule> {
191
- const maps: Array<Array<[string, Combinator<unknown>]>> = []
192
- for (const p of carried) {
193
- // ARTIFACT VERSION LOCK. `fusedBody` enforced this and would have taken it with it:
194
- // artifacts are version-locked and there is no cross-version read path, so an
195
- // UNSTAMPED piece and a MISMATCHED one are both refused — a stale artifact that
196
- // merely happens to still encode is exactly what this stops.
197
- if (!isIRPiece(p)) {
198
- if (typeof p.v !== 'string') {
199
- throw new Error(
200
- `parseman: artifact "${p.ns}" is UNSTAMPED (compiled before the version-lock invariant). `
201
- + `Recompile the grammar with parseman ${PARSEMAN_VERSION}; parseman does not fuse unversioned or cross-version artifacts.`,
202
- )
203
- }
204
- if (p.v !== PARSEMAN_VERSION) {
205
- throw new Error(
206
- `parseman: artifact "${p.ns}" was compiled with parseman ${p.v}, but is being fused with parseman ${PARSEMAN_VERSION}. `
207
- + `Compiled grammar artifacts are version-locked — recompile the grammar with parseman ${PARSEMAN_VERSION}; parseman does not fuse across versions.`,
208
- )
209
- }
210
- }
211
- const rules = ruleMapOfCarried(p)
212
- if (rules === undefined) {
213
- throw new Error(`compose: carried piece "${p.ns}" has no re-lowerable IR and cannot be fused`)
214
- }
215
- maps.push(rules)
216
- }
217
- const merged = mergeCarriedRuleMaps(maps)
218
- materializeDirectBuilders(merged)
219
- // COMPOSING-WINS is an OVERRIDE, not a gap-fill: the composing grammar's trivia
220
- // governs every fused rule INCLUDING inherited ones. `applyAmbient` inside
221
- // `compileRuleMap` only fills a rule that declares none, which would leave an
222
- // inherited rule still skipping its own base's whitespace after a delta re-declared
223
- // it. Safe to mutate — `evalRuleMapIR` builds fresh combinators per fuse.
224
- if (trivia) {
225
- for (const [, rule] of merged) {
226
- if (rule._meta.isTrivia) continue
227
- ;(rule._meta as { grammarTrivia?: Combinator<unknown> }).grammarTrivia = trivia
228
- }
229
- }
230
- // RUNNABLE, not printable. `compose()` returns a parser; it never emits source, so
231
- // requiring a captured source per author callback would refuse every grammar built at
232
- // runtime — which have live callbacks by construction.
233
- const refusals: string[] = []
234
- const compiled = compileRuleMapRunnable(merged, {
235
- ...(trivia ? { trivia } : {}),
236
- ...(hostMode ? { hostMode } : {}),
237
- refusals,
238
- })
239
- if (compiled === null) {
240
- throw new Error(`compose: the merged grammar could not be encoded to a table${refusals.length ? ` — ${refusals.join('; ')}` : ''}`)
241
- }
242
- // `tableRules`, NOT `exec.ts`'s same-named `tableRules`. Both return
243
- // `Record<string, TableRule>`, so binding the interpreter here type-checked and ran
244
- // correctly — it was just the SLOW engine, on the one path (compose/fuse) that never
245
- // goes through `table/index.ts` and so never saw the `tableRules as tableRules`
246
- // re-export. Import the assembler by its own name so the binding cannot go stale again.
247
- const map = tableRules(compiled.prog) as unknown as Record<string, FusedRule>
248
- // The host-mode stamp went on the fused closure before; a table carries nothing until
249
- // it is stamped, and an UNSTAMPED map reads as `{ ast, false }` so every driver
250
- // compatibility check passes vacuously. Stamped on the rule functions too, because
251
- // `run(map.Rule, …)` is handed the rule and never sees the map.
252
- for (const k of Object.keys(map)) {
253
- Object.defineProperty(map[k]!, FUSED_HOST_MODE, { value: compiled.hostMode, enumerable: false })
254
- Object.defineProperty(map[k]!, FUSED_HOST_ELIDED, { value: compiled.hostBranchElided, enumerable: false })
255
- }
256
- Object.defineProperty(map, FUSED_HOST_MODE, { value: compiled.hostMode, enumerable: false })
257
- Object.defineProperty(map, FUSED_HOST_ELIDED, { value: compiled.hostBranchElided, enumerable: false })
258
- // GRAMMAR REFLECTION, which `fusedBody` merged across pieces and stamped. A visitor
259
- // built over a composed grammar reads it to know the node types; unstamped, it reads
260
- // as an empty grammar and every visitor silently matches nothing.
261
- Object.defineProperty(map, GRAMMAR_REFLECTION, { value: compiled.reflection, enumerable: false })
262
- return map
263
- }
264
-
265
- /**
266
- * Re-attach a DIRECT node builder that came back from IR as an inert sentinel.
267
- *
268
- * `evalRuleMapIR` deliberately refuses to evaluate a captured `buildSrc`: raw IR
269
- * interpretation must not run arbitrary source, so `_nd` installs a thrower and keeps
270
- * the text. Only a COMPILER consumer may materialize it — and the runtime fuse is one.
271
- * The source lowering did exactly this, less visibly: it inlined `buildSrc` into the
272
- * fused body and `new Function` evaluated it. The table parks callbacks in a pool
273
- * instead of inlining them, so the same materialization has to be explicit or the
274
- * sentinel reaches the pool and throws on the first parse through that node.
275
- *
276
- * Scoped to the runtime fuse. The MACRO never comes here: its encoder captures
277
- * `buildSrc` and PRINTS it, so it needs no live function at all.
278
- */
279
- function materializeDirectBuilders(ruleMap: ReadonlyArray<readonly [string, Combinator<unknown>]>): void {
280
- const seen = new Set<Combinator<unknown>>()
281
- const visit = (p: Combinator<unknown>): void => {
282
- if (seen.has(p)) return
283
- seen.add(p)
284
- const d = p._def as { tag: string; type?: string; build?: unknown; buildSrc?: string; buildImports?: ReadonlyArray<{ local: string; source: string; imported: string }> }
285
- if (d.tag === 'node' && typeof d.buildSrc === 'string') {
286
- // FAIL CLOSED for the runtime fuse: a builder whose free names are module
287
- // imports can be re-bound by the MACRO plugin (it re-emits the imports into the
288
- // generated module), but an in-process `(0, eval)` here has no import to give it,
289
- // so the builder would throw ReferenceError at parse time — wrong output deferred.
290
- // Refuse now, with a message that points at the build-time path. The macro never
291
- // reaches this function (it prints `buildSrc`, see the doc above).
292
- if (d.buildImports !== undefined && d.buildImports.length > 0) {
293
- throw new Error(
294
- `IR direct node builder for ${d.type ?? '<anonymous>'} references module import(s) `
295
- + `${d.buildImports.map(bi => bi.local).join(', ')} that a runtime compose() cannot supply; `
296
- + `compose at build time via the parseman macro plugin, which re-emits the imports.`,
297
- )
298
- }
299
- try {
300
- // eslint-disable-next-line no-eval
301
- const fn = (0, eval)(`(${d.buildSrc})`) as unknown
302
- if (typeof fn === 'function') d.build = fn
303
- } catch { /* leave the sentinel: it throws with its own message on reach */ }
304
- }
305
- if (d.tag === 'lazy') {
306
- let resolved: Combinator<unknown> | undefined
307
- try { resolved = (d as unknown as { thunk: () => Combinator<unknown> }).thunk() } catch { return }
308
- if (resolved) visit(resolved)
309
- return
310
- }
311
- for (const child of childrenOf(p._def)) visit(child)
312
- }
313
- for (const [, rule] of ruleMap) visit(rule)
314
- }
315
-
316
- /** The combinator map behind a carried piece: IR is evaluated back, a table piece
317
- * uses the IR it always carries. `undefined` only for a piece with neither. */
318
- function ruleMapOfCarried(p: LinkableTable | IRPiece): Array<[string, Combinator<unknown>]> | undefined {
319
- if (isIRPiece(p)) return evalRuleMapIR(p.ir)
320
- // IR FIRST: re-evaluating it yields FRESH combinators, so seeding composing trivia
321
- // onto their `_meta` cannot leak back into the artifact this piece came from. The live
322
- // map is the fallback for a runtime-built grammar that has no serializable IR.
323
- if (p.ir !== null) return evalRuleMapIR(p.ir)
324
- return p.ruleMap.length > 0 ? p.ruleMap.map(([k, v]) => [k, v] as [string, Combinator<unknown>]) : undefined
325
- }
326
-
327
- /** Fold ordered rule maps into the composed map: a later piece's name WINS.
328
- *
329
- * A REFERENCE IS NOT A DEFINITION. A `rules(g => …)` cache also holds every `g.X` that
330
- * was merely ACCESSED, as an unresolvable lazy. Merged in order, such an entry lands
331
- * last and SHADOWS the piece that actually defines the name — the encoder then finds a
332
- * hole where the winner should be and refuses the whole grammar. The reference is not
333
- * lost: it stays inside the referring piece's rule bodies, where `enc.winners` binds it
334
- * by name to whichever piece supplies the definition.
335
- */
336
- function mergeCarriedRuleMaps(
337
- maps: ReadonlyArray<ReadonlyArray<readonly [string, Combinator<unknown>]>>,
338
- ): Array<[string, Combinator<unknown>]> {
339
- const winners = new Map<string, Combinator<unknown>>()
340
- for (const map of maps) {
341
- for (const [name, rule] of map) {
342
- if (rule._def.tag === 'lazy') {
343
- try { rule._def.thunk() } catch { continue }
344
- }
345
- winners.set(name, rule)
346
- }
347
- }
348
- return [...winners]
349
- }
350
-
351
-
352
169
  export { FUSED_HOST_MODE, FUSED_HOST_ELIDED } from '../cst/host-mode.ts'
353
170
 
354
171
  /** The host mode a fused/composed rule map was built for. Defaults to 'ast'. */
@@ -362,28 +179,13 @@ export function fusedHostElidedOf(registry: object): boolean {
362
179
  return (registry as Record<symbol, unknown>)[FUSED_HOST_ELIDED] === true
363
180
  }
364
181
 
365
- /**
366
- * Compose grammars/artifacts into a runnable parser map — the ONLY public
367
- * composition entry point. `compose([base, ext, …])`: later entries override
368
- * earlier ones by rule name, and because fusion re-binds every reference in one
369
- * shared scope, an override reroutes the base's OWN calls too (open recursion).
370
- *
371
- * Each entry may be a **grammar** (a `rules()` result — a map of combinators,
372
- * linkable-ified here) OR an already-compiled **linkable artifact** (what the
373
- * macro emits and a package ships). So a package needs no opt-in wrapper to be
374
- * composable — `compose([importedGrammar, myRules])` just works.
375
- *
376
- * The macro compiles `compose([...])` to STATIC fused source (no `new Function`).
377
- * Called at runtime (no macro, like `compile()`) it fuses via `new Function`.
378
- */
379
- /** A composed parser carries its flattened source pieces (non-enumerable) so it
380
- * can be composed AGAIN — `compose([lessGrammar, delta])` where `lessGrammar` is
381
- * itself a `compose([...])` result. */
182
+ /** The carried pieces a BUILD-compiled (`compose()` under the macro) grammar
183
+ * holds: its pieces as serialized IR, re-lowered by the macro plugin at build
184
+ * time. A runtime composition carries recipes instead (`RULES_RECIPE`). */
382
185
  export const COMPOSED_PIECES = Symbol.for('parseman.composedPieces')
383
186
 
384
- /** The carried pieces a `compose()`/`composeLeaf()` result holds, or `undefined` when
385
- * the value is not a composed grammar. This is what makes a fused grammar analysable:
386
- * the pieces are re-lowerable IR even though the fused map itself is only functions. */
187
+ /** The carried pieces a build-compiled composed grammar holds, or `undefined`
188
+ * when the value is not one. */
387
189
  export function composedPiecesOf(
388
190
  grammar: Record<string, unknown>,
389
191
  ): ReadonlyArray<LinkableTable | IRPiece> | undefined {
@@ -392,26 +194,22 @@ export function composedPiecesOf(
392
194
  }
393
195
 
394
196
  /**
395
- * A terminal fused grammar may be used to run a parser, but not as an input to
197
+ * A terminal composed grammar may be used to run a parser, but not as an input to
396
198
  * another composition. Macro `composeLeaf()` uses this for a local semantic
397
199
  * reduction over imported recognition-only IR: the local reductions stay in
398
200
  * their lexical module and therefore never become carried IR.
399
201
  */
400
202
  const LEAF_COMPOSED = Symbol.for('parseman.leafComposed')
401
203
 
402
- /** The composing (outermost) trivia a runtime `compose()` applied — stored so a
403
- * LATER `compose([thisResult, …])` that declares no trivia of its own still re-lowers
404
- * these rules under the SAME trivia (composing-wins survives re-composition). The
405
- * carried IR pieces hold no trivia of their own, so it must be remembered separately. */
204
+ /** The composing trivia a runtime `compose()` applied, so a LATER composition
205
+ * that declares none of its own keeps it (composing-wins survives re-composition). */
406
206
  const COMPOSED_TRIVIA = Symbol.for('parseman.composedTrivia')
407
207
 
408
- /** Final winner map for semantic-coverage tooling. It exists only when every
409
- * carried compose piece is re-lowerable IR; opaque precompiled artifacts have no
410
- * combinator graph to inspect and therefore deliberately expose no fake map. */
411
- const COMPOSED_COVERAGE_RULES = Symbol.for('parseman.composedCoverageRules')
208
+ /** Marks a runtime composition: a lazily linked interpreter map over recipes. */
209
+ const LINKED = Symbol.for('parseman.linkedComposition')
412
210
 
413
- /** The compact IR form a grammar carries instead of its lowered rule source: the
414
- * combinator-construction expression, re-lowered here at fuse time. */
211
+ /** The compact IR form a build-compiled grammar carries: the combinator-construction
212
+ * expression, re-lowered by the macro plugin at build time. */
415
213
  export type IRPiece = { ns: string; ir: string; trackLines?: true }
416
214
 
417
215
  function isIRPiece(p: unknown): p is IRPiece {
@@ -441,6 +239,20 @@ export function once<T>(fn: () => T): () => T {
441
239
  }
442
240
  }
443
241
 
242
+ /**
243
+ * The live combinator map behind a carried piece, or `undefined` when it has none.
244
+ *
245
+ * NEVER FROM IR. Carried IR is JavaScript source — reducers, gates and builders
246
+ * included — and rebuilding it means `eval`, which runs nowhere at runtime outside
247
+ * `compile()` (docs/design/runtime-and-size-contract.md, rule 1). The macro plugin
248
+ * re-lowers IR at build time, in the bundler. At runtime an IR-only piece is opaque,
249
+ * and every caller reports it as such.
250
+ */
251
+ function ruleMapOfCarried(p: LinkableTable | IRPiece): Array<[string, Combinator<unknown>]> | undefined {
252
+ if (isIRPiece(p)) return undefined
253
+ return p.ruleMap.length > 0 ? p.ruleMap.map(([k, v]) => [k, v] as [string, Combinator<unknown>]) : undefined
254
+ }
255
+
444
256
  /** The re-lowerable carried pieces' rule maps, in compose order — the input to the
445
257
  * gating analysis (`diagnoseGrammar`). An opaque precompiled artifact contributes no
446
258
  * combinator graph, so it is skipped: a hole it would have bound stays unresolved
@@ -453,7 +265,7 @@ export function carriedRuleMaps(carried: ReadonlyArray<LinkableTable | IRPiece>)
453
265
  return carriedRuleMapsDetailed(carried).maps
454
266
  }
455
267
 
456
- /** `carriedRuleMaps` plus the pieces it could NOT re-lower, named by namespace and
268
+ /** `carriedRuleMaps` plus the pieces it could NOT recover, named by namespace and
457
269
  * rule count, so a caller can report exactly how much of the grammar went unseen. */
458
270
  export function carriedRuleMapsDetailed(
459
271
  carried: ReadonlyArray<LinkableTable | IRPiece>,
@@ -461,31 +273,18 @@ export function carriedRuleMapsDetailed(
461
273
  const maps: Array<Array<[string, Combinator<unknown>]>> = []
462
274
  const opaque: Array<{ ns: string; ruleNames: string[] }> = []
463
275
  for (const p of carried) {
464
- // A table artifact is recoverable far more often than a source one was: it carries
465
- // IR, and failing that the live combinators. Ask for the map before declaring the
466
- // piece opaque, or a perfectly analysable grammar is reported as unseen.
467
276
  const rules = ruleMapOfCarried(p)
468
277
  if (rules !== undefined) { maps.push(rules); continue }
469
- // NAMED, not anonymous. `keys` is the artifact's rule-name list; the field this
470
- // used to read (`ruleFns`) belonged to the source lowering, and reading a missing
471
- // one degrades every opaque piece to `<artifact _lkN_>` — "reported, but uselessly",
472
- // which is the exact failure this reporting exists to prevent.
473
278
  opaque.push({ ns: p.ns, ruleNames: isIRPiece(p) ? [] : [...p.keys] })
474
279
  }
475
280
  return { maps, opaque }
476
281
  }
477
282
 
478
283
  /**
479
- * Recover the override-winner COMBINATOR map behind a `compose()` result, plus the
480
- * pieces that could not be recovered.
481
- *
482
- * A fused map holds rule functions, so any consumer that walks a combinator graph
483
- * (gating analysis, the spec/EBNF/railroad model) cannot read it directly. The graph
484
- * is not lost, though — `compose()` retains re-lowerable IR — so this is the single
485
- * shared recovery both consumers use. Sharing it is the point: two copies of this
486
- * logic is how one walker gets fixed and the other silently keeps failing.
487
- *
488
- * Returns `undefined` when `grammar` is not a composed result.
284
+ * Recover the override-winner COMBINATOR map behind a BUILD-compiled composed
285
+ * grammar, plus the pieces that could not be recovered. A runtime composition is
286
+ * already a combinator map, so this returns `undefined` for it, exactly as for a
287
+ * plain `rules()` map: walk it directly.
489
288
  */
490
289
  export function recoverComposedRules(
491
290
  grammar: Record<string, unknown>,
@@ -504,464 +303,189 @@ export function recoverComposedRules(
504
303
  return { rules, opaque }
505
304
  }
506
305
 
507
- function coverageRulesOf(carried: Array<LinkableTable | IRPiece>): Record<string, Combinator<unknown>> | undefined {
508
- const winners: Record<string, Combinator<unknown>> = {}
509
- for (const piece of carried) {
510
- if (!isIRPiece(piece)) return undefined
511
- const map = evalRuleMapIR(piece.ir)
512
- for (const [name, rule] of map) {
513
- // An accessed-but-undefined `g.Name` is an external reference, never a
514
- // rule definition. Match compose's IR filtering rule exactly.
515
- if (rule._def.tag === 'lazy') {
516
- try { rule._def.thunk() } catch { continue }
517
- }
518
- winners[name] = rule
519
- }
520
- }
521
- return winners
522
- }
523
-
524
- /** Return the final override-winner combinator map carried by runtime
525
- * `compose()`, or `undefined` when a precompiled opaque artifact participated.
526
- * This is intentionally internal: callers must not treat it as a parser API. */
306
+ /** The final override-winner combinator map of a runtime composition, or
307
+ * `undefined` for anything else. INTERNAL: coverage tooling only. */
527
308
  export function composedCoverageRules(grammar: Record<string, unknown>): Record<string, Combinator<unknown>> | undefined {
528
- return (grammar as Record<symbol, unknown>)[COMPOSED_COVERAGE_RULES] as Record<string, Combinator<unknown>> | undefined
309
+ const link = (grammar as Record<symbol, unknown>)[LINKED] as (() => Record<string, Combinator<unknown>>) | undefined
310
+ return link?.()
311
+ }
312
+
313
+ /** Each runtime composition's rules, mapped to the linked map they belong to. */
314
+ const LINKED_WINNERS = new WeakMap<Combinator<unknown>, Record<string, Combinator<unknown>>>()
315
+
316
+ /** The linked winner map a runtime composition's rule belongs to, or `undefined`.
317
+ * Lets a coverage run over ONE composed rule name its choices exactly as
318
+ * `composedGrammarCoverageDefinitions` does. INTERNAL: coverage tooling only. */
319
+ export function linkedWinnersOf(rule: Combinator<unknown>): Record<string, Combinator<unknown>> | undefined {
320
+ return LINKED_WINNERS.get(rule)
321
+ }
322
+
323
+ /** The recipes one `compose()` item contributes, in order. */
324
+ function recipesOf(item: LinkableTable | Record<string, unknown>): readonly RulesRecipe[] {
325
+ const recipes = (item as Record<symbol, unknown>)[RULES_RECIPE]
326
+ if (Array.isArray(recipes)) return recipes as RulesRecipe[]
327
+ const what = isLinkableTable(item) ? `the table artifact "${item.ns}"`
328
+ : composedPiecesOf(item as Record<string, unknown>) !== undefined ? 'a build-compiled compose() result'
329
+ : 'a value that is not a rules() grammar'
330
+ throw new Error(
331
+ `compose: ${what} has no live rules to link. At runtime compose() links interpreter grammars — `
332
+ + 'rules() maps and runtime compose() results — and never evaluates carried IR or source text. '
333
+ + 'Compose compiled grammars at build time (the parseman macro does), or compose the interpreter builds of both.',
334
+ )
529
335
  }
530
336
 
531
- /** Flatten one `compose()` item to its pieces: a prior composed result → its
532
- * carried list; an artifact → itself; a grammar (`rules()` map) → linkable-ified. */
533
- function nextComposeNs(used: Set<string>): string {
534
- let ns: string
535
- do { ns = `_lk${_nsCounter++}_` } while (used.has(ns))
536
- used.add(ns)
537
- return ns
337
+ /** The composed grammar's ambient trivia = the LAST item that declares one: a
338
+ * `rules({ trivia }, …)` map's own, or a runtime composition's composing trivia.
339
+ * Outermost wins over every linked rule, inherited ones included; `parser` /
340
+ * `noTrivia` still override locally. */
341
+ function composingTriviaOf(items: Array<LinkableTable | Record<string, unknown>>): Combinator<unknown> | undefined {
342
+ for (let i = items.length - 1; i >= 0; i--) {
343
+ const item = items[i] as Record<symbol, unknown>
344
+ if (item[LINKED] !== undefined) {
345
+ const stamped = item[COMPOSED_TRIVIA] as Combinator<unknown> | undefined
346
+ if (stamped) return stamped
347
+ continue
348
+ }
349
+ const recipes = item[RULES_RECIPE] as readonly RulesRecipe[] | undefined
350
+ const trivia = recipes?.[recipes.length - 1]?.options?.trivia
351
+ if (trivia != null) return trivia
352
+ }
353
+ return undefined
538
354
  }
539
355
 
540
- /** Flatten one `compose()` item to its RE-LOWERABLE carried items — the form stored
541
- * on the composed result so it can be composed AGAIN under a NEW composing trivia.
542
- * A grammar (`rules()` map) is carried as compact IR (`{ns, ir}`), NOT baked source,
543
- * so a later `compose([thisResult, delta])` re-lowers it with the delta's trivia
544
- * (multi-level composing-wins). A prior composed result contributes its OWN carried
545
- * items (already IR); a pre-compiled artifact has no source, so it stays baked. */
546
- function itemCarried(
547
- item: LinkableTable | Record<string, unknown>,
548
- used: Set<string>,
549
- trivia?: Combinator<unknown>,
550
- // Only reaches the non-serializable fallback below, where the grammar is baked
551
- // immediately instead of carried as re-lowerable IR.
552
- hostMode?: HostMode,
553
- ): Array<LinkableTable | IRPiece> {
554
- const carried = (item as Record<symbol, unknown>)[COMPOSED_PIECES]
555
- // A prior composed result (runtime or macro-compiled): its carried list is already
556
- // re-lowerable (IR pieces, plus any pre-compiled artifacts). Pass it through so THIS
557
- // compose re-lowers it under its own composing trivia. Reserve its namespaces so a
558
- // sibling grammar map can't collide with them.
559
- if (Array.isArray(carried)) {
560
- const items = carried as Array<LinkableTable | IRPiece>
561
- for (const p of items) used.add(p.ns)
562
- return items
563
- }
564
- // A pre-compiled artifact (`linkable()`): a table piece. It ALWAYS carries its IR,
565
- // which is what makes table-to-table composition a rule-map merge rather than a
566
- // relocation of two encoded programs — so unlike a source artifact it stays
567
- // re-lowerable under a new composing trivia.
568
- if (isLinkableTable(item)) {
569
- used.add(item.ns)
570
- return [item]
356
+ /** The rule names a linked composition defines, in first-declaration order —
357
+ * known WITHOUT linking, from each item's own declaration order. */
358
+ function declaredNamesOf(items: Array<LinkableTable | Record<string, unknown>>): string[] {
359
+ const names = new Set<string>()
360
+ for (const item of items) {
361
+ const linked = (item as Record<symbol, unknown>)[LINKED] !== undefined
362
+ // A `linkable()` artifact names its rules in `keys`; its own fields are not rules.
363
+ const order = linked ? Object.keys(item)
364
+ : isLinkableTable(item) ? item.keys
365
+ : (item as Record<string, unknown>)[RULE_ORDER] as readonly string[] | undefined
366
+ for (const name of order ?? Object.keys(item)) names.add(name)
571
367
  }
572
- // A grammar (`rules()` map): carry it as compact IR so a later compose re-lowers it
573
- // under ITS trivia. Unserializable → bake now with this compose's trivia (can't
574
- // re-lower later; acceptable fallback, mirrors the macro's full-pieces fallback).
575
- const map = item as Record<string, Combinator<unknown>>
576
- const ns = nextComposeNs(used)
577
- // Drop EXTERNAL entries first (same filter as compileLinkable): a `rules()` cache
578
- // also holds every ACCESSED-but-undefined `g.X` as an unresolved-lazy entry. Left in,
579
- // serializeRuleMap would emit `X: g["X"]` — a self-referential rule that shadows the
580
- // sibling artifact defining X and recurses forever. They resolve by name at fuse time.
581
- const entries = Object.entries(map).filter(([, val]) => {
582
- const d = val._def
583
- if (d.tag !== 'lazy') return true
584
- try { d.thunk(); return true } catch { return false }
585
- })
586
- // Carry this grammar's ambient `scanSkip` (per-piece — opaque units are
587
- // dialect-specific, NOT composing-wins) into the IR so a re-lower stamps
588
- // `grammarScanSkip` back on. `linkable()`'s fallback reads it off `_meta` directly.
589
- const scanSkip = entries
590
- .map(([, val]) => (val._meta as { grammarScanSkip?: Combinator<unknown>[] }).grammarScanSkip)
591
- .find(Boolean)
592
- const ir = serializeRuleMap(entries, scanSkip)
593
- return ir ? [{ ns, ir }] : [linkable(map, ns, trivia, hostMode)]
368
+ return [...names]
594
369
  }
595
370
 
596
- /** The composed grammar's ambient trivia = the LAST composed item that declares a
597
- * grammar-level trivia (via `rules({ trivia }, …)`, which tags `grammarTrivia` on its
598
- * rules). Outermost wins: the composing grammar's trivia applies to every fused rule,
599
- * including those inherited from a base — so e.g. an SCSS `rw` (which extends Less's)
600
- * governs the inherited Less/CSS rules too. `parser`/`noTrivia` still override locally. */
601
- function composingTriviaOf(items: Array<LinkableTable | Record<string, unknown>>): Combinator<unknown> | undefined {
602
- for (let i = items.length - 1; i >= 0; i--) {
603
- const item = items[i] as Record<string, unknown> | undefined
604
- if (!item || isLinkableTable(item)) continue
605
- // A COMPOSED item states its trivia on the stamp rather than on its values (its
606
- // rules live in carried IR, which does not carry `_meta`). Skipping such an item
607
- // entirely — as this did — loses the trivia of every grammar that reached this
608
- // compose through a PRIOR compose.
609
- const stamped = (item as Record<symbol, unknown>)[COMPOSED_TRIVIA] as Combinator<unknown> | undefined
610
- if (stamped) return stamped
611
- if ((item as Record<symbol, unknown>)[COMPOSED_PIECES]) continue
612
- for (const v of Object.values(item)) {
613
- const t = (v as Combinator<unknown> | undefined)?._meta?.grammarTrivia
614
- if (t) return t
371
+ /** Link recipes into one interpreter map, refusing a name nobody defines. */
372
+ function linkComposition(
373
+ recipes: readonly RulesRecipe[],
374
+ link: LinkOptions,
375
+ ): Record<string, Combinator<unknown>> {
376
+ const map = linkRules(recipes, link)
377
+ const missing: string[] = []
378
+ for (const [name, rule] of Object.entries(map)) {
379
+ if (rule._def.tag !== 'lazy') continue
380
+ try { rule._def.thunk() } catch { missing.push(name) }
381
+ }
382
+ if (missing.length > 0) {
383
+ const holes = new Set(missing)
384
+ for (const [name, deps] of ruleDependencies(Object.entries(map).filter(([n]) => !holes.has(n)))) {
385
+ for (const dep of deps) if (holes.has(dep)) throw new Error(`compose: rule "${name}" references missing rule "${dep}"`)
615
386
  }
387
+ throw new Error(`compose: rule(s) ${missing.map(n => `"${n}"`).join(', ')} are referenced but defined by no composed grammar`)
616
388
  }
617
- return undefined
389
+ // A borrowed rule object keeps its own grammar's bindings here but would be rebound
390
+ // by name in `compile()` of this composition: refuse it rather than let the two
391
+ // engines accept different input.
392
+ const borrowed = borrowedRuleReference(map)
393
+ if (borrowed !== undefined) throw new Error(borrowedRuleMessage('compose', borrowed))
394
+ for (const rule of Object.values(map)) LINKED_WINNERS.set(rule, map)
395
+ // The linked map is itself a composition: it composes again, and says so.
396
+ Object.defineProperty(map, RULES_RECIPE, { value: recipes, enumerable: false })
397
+ Object.defineProperty(map, LINKED, { value: () => map, enumerable: false })
398
+ if (link.trivia) Object.defineProperty(map, COMPOSED_TRIVIA, { value: link.trivia, enumerable: false })
399
+ return map
618
400
  }
619
401
 
402
+ /**
403
+ * Compose grammars into ONE parser map — the only public composition entry point.
404
+ * `compose([base, ext, …])`: a later entry's rule WINS by name, and because every
405
+ * piece is linked against one namespace, an override reroutes the base's OWN calls
406
+ * too (open recursion).
407
+ *
408
+ * UNDER THE MACRO, `compose([...])` is lowered at BUILD time to a static table.
409
+ *
410
+ * AT RUNTIME it only links (docs/design/runtime-and-size-contract.md, rule 2):
411
+ * every piece's `rules()` factory runs again against one shared namespace, so the
412
+ * result is exactly the interpreter grammar one `rules()` over all the pieces would
413
+ * build. It builds no table, evaluates no source and mutates no piece. Linking is
414
+ * LAZY — the first read of a rule links the whole map once — so `compose()` itself
415
+ * costs a walk of the items' declared names. For speed, `compile()` a rule of it;
416
+ * `compile()` specialises when the environment allows and falls back under CSP.
417
+ */
620
418
  export function compose(
621
419
  items: Array<LinkableTable | Record<string, unknown>>,
622
420
  /**
623
- * Compile-time host mode for the fused artifact, same meaning as
624
- * `compile(g, { hostMode })`. Omit (or `'ast'`) for the eval driver — the fused rules
625
- * build through the grammar's own `build` callbacks and carry no positioned-CST
626
- * branch. Pass `'cst'` to fuse a SECOND artifact from the same pieces for the linter /
627
- * IDE / language-service driver. Two compilations of one grammar, decided here, rather
628
- * than one artifact deciding per node on every parse.
421
+ * Host mode for the composed grammar, same meaning as `compile(g, { hostMode })`.
422
+ * The interpreter routes by host at parse time; this records `'cst'` on every rule
423
+ * so `run()` can refuse a mismatched host instead of building the wrong tree.
629
424
  */
630
425
  opts?: { hostMode?: HostMode },
631
- ): Record<string, FusedRule> {
426
+ ): Record<string, Runnable> {
632
427
  if (items.some(item => (item as Record<symbol, unknown>)[LEAF_COMPOSED] === true)) {
633
428
  throw new Error('compose: a composeLeaf() result is terminal and cannot be composed again')
634
429
  }
635
- const used = new Set<string>()
636
- // The composed grammar's ambient trivia comes from the composing grammar itself —
637
- // whatever the last piece declared via rules({ trivia }, …). No separate option:
638
- // the trivia rides with the grammar that declared it.
639
- const trivia = composingTriviaOf(items)
640
- // Carried items are RE-LOWERABLE (IR); materialize them ONCE with this compose's
641
- // trivia for the now-fuse, but STORE the un-materialized carried list so a later
642
- // compose can re-lower it under a different trivia (multi-level composing-wins).
643
- const carried = items.flatMap(item => itemCarried(item, used, trivia, opts?.hostMode))
644
- const map = fuseCarried(carried, trivia, opts?.hostMode)
645
- Object.defineProperty(map, COMPOSED_PIECES, { value: carried, enumerable: false })
646
- if (trivia) Object.defineProperty(map, COMPOSED_TRIVIA, { value: trivia, enumerable: false })
647
- const coverageRules = coverageRulesOf(carried)
648
- if (coverageRules) Object.defineProperty(map, COMPOSED_COVERAGE_RULES, { value: coverageRules, enumerable: false })
649
- return map
430
+ return linkedMap(items, opts, false)
650
431
  }
651
432
 
652
- /**
653
- * Compose a terminal grammar. This is for a leaf parser that overlays local
654
- * semantic reductions on reusable recognition rules.
655
- *
656
- * Under the macro this lowers to STATIC fused source (functions), exactly like
657
- * `compose()`. It is still macro-only as a *compiled* artifact: without macro
658
- * lowering there is no safe way to keep lexical builders out of carried IR, so it
659
- * never falls back to runtime CODEGEN composition.
660
- *
661
- * Called at runtime (no macro) it returns the INTERPRETED fuse of the same items —
662
- * a combinator map, not a map of compiled functions (`fuseInterpreted`), fused lazily
663
- * per rule name.
664
- *
665
- * THE RETURN TYPE IS `Runnable`, NOT `FusedRule`, BECAUSE BOTH PATHS ARE REAL. A macro
666
- * build yields fused functions; an un-macro'd call yields combinators. `Runnable` is
667
- * already the library's name for "either of those" — it is what `run()` and
668
- * `parseDoc()` take — so the declared type is TRUE on both paths and a caller needs no
669
- * narrowing to use the result. This used to declare `Record<string, FusedRule>` and
670
- * launder the runtime path through an `as unknown as`, which let a caller hold a
671
- * combinator map while the type promised compiled functions.
672
- *
673
- * Do NOT "fix" this by deleting the runtime path. It is load-bearing: the `bench/jess`
674
- * harness family and two differential-gate legs (`emit-identity-one`,
675
- * `scan-shape-oracle-one`) import un-macro'd grammar modules and depend on this lazy
676
- * interpreted fuse, one dialect per process. Whether an un-macro'd `composeLeaf()`
677
- * should exist at all is a separate, open owner question — but while the gates depend
678
- * on it, it exists, and the type says so.
679
- */
680
- export function composeLeaf(
433
+ function linkedMap(
681
434
  items: Array<LinkableTable | Record<string, unknown>>,
435
+ opts: { hostMode?: HostMode } | undefined,
436
+ leaf: boolean,
682
437
  ): Record<string, Runnable> {
683
- const pieces = items.flatMap(interpretedPieces)
684
- let fused: Record<string, Combinator<unknown>> | undefined
438
+ const recipes = items.flatMap(recipesOf)
439
+ const trivia = composingTriviaOf(items)
440
+ const names = declaredNamesOf(items)
441
+ let linked: Record<string, Combinator<unknown>> | undefined
442
+ const link = (): Record<string, Combinator<unknown>> =>
443
+ (linked ??= linkComposition(recipes, { trivia, ...(opts?.hostMode === undefined ? {} : { hostMode: opts.hostMode }) }))
444
+ // One accessor per rule, installed once here and never per parse: the first read
445
+ // links the whole map, every later read is a cached lookup. (Invariant allowlist:
446
+ // INV-1:src/compiler/linker.ts:linkedMap.)
685
447
  const map: Record<string, unknown> = {}
686
- // LAZY on purpose. A grammar module typically builds SEVERAL leaf grammars over
687
- // one shared recognition piece (`cssGrammar`, `cssLineGrammar`, `cssCstGrammar`,
688
- // …). An interpreted fuse binds that shared piece IN PLACE, so only one of them
689
- // can exist at a time — fusing all of them at import would make merely importing
690
- // the module throw. Fusing on first ACCESS means the grammar you actually use
691
- // works, and reaching for a second, conflicting one fails loudly at that point.
692
- // (`trackLines`/`hostMode` are compile-time distinctions; the interpreter decides
693
- // both per parse, so those variants are the same interpreted grammar anyway.)
694
- for (const name of ruleNamesOf(pieces)) {
695
- Object.defineProperty(map, name, {
696
- enumerable: true,
697
- configurable: true,
698
- get: () => (fused ??= fusePieces(pieces))[name],
699
- })
448
+ for (const name of names) {
449
+ Object.defineProperty(map, name, { enumerable: true, configurable: true, get: () => link()[name] })
700
450
  }
701
- Object.defineProperty(map, LEAF_COMPOSED, { value: true, enumerable: false })
702
- Object.defineProperty(map, INTERPRETED_PIECES, {
703
- value: pieces.filter(p => p.plain).map(p => p.entries),
704
- enumerable: false,
705
- })
451
+ Object.defineProperty(map, RULES_RECIPE, { value: recipes, enumerable: false })
452
+ Object.defineProperty(map, RULE_ORDER, { value: names, enumerable: false })
453
+ Object.defineProperty(map, LINKED, { value: link, enumerable: false })
454
+ Object.defineProperty(map, GRAMMAR_REFLECTION, { enumerable: false, configurable: true, get: () => link()[GRAMMAR_REFLECTION as never] })
455
+ if (trivia) Object.defineProperty(map, COMPOSED_TRIVIA, { value: trivia, enumerable: false })
456
+ if (leaf) Object.defineProperty(map, LEAF_COMPOSED, { value: true, enumerable: false })
706
457
  return map as Record<string, Runnable>
707
458
  }
708
459
 
709
- /* ── Interpreted fuse ─────────────────────────────────────────────────────────
710
- *
711
- * `compose()` fuses by CODEGEN: every piece is compiled to `_r_<Name>` functions
712
- * dropped into one scope, so a reference resolves by NAME and an override reroutes
713
- * the base piece's own calls (open recursion). None of that exists interpreted —
714
- * the interpreter runs the combinator graph, and a cross-piece reference is an
715
- * ordinary `ref()` placeholder that nobody ever `.define()`d. That is why a
716
- * composed grammar could not be run interpreted at all, and why every diagnostic
717
- * that must NOT reach codegen (profiling, gating analysis, coverage) had to be
718
- * hand-fused in throwaway scripts.
719
- *
720
- * The interpreted fuse binds those placeholders directly, with the SAME semantics
721
- * the compiled fuse gets from name resolution:
722
- * - later piece wins per rule name (matching the compiled merge);
723
- * - an override REPOINTS the slot every call site already holds, so a base
724
- * piece's internal calls reroute too (open recursion);
725
- * - the composing grammar's trivia governs every fused rule (`composingTriviaOf`);
726
- * - a referenced-but-undefined rule is a fuse-time error, not a parse-time one.
727
- *
728
- * It is MUTATING by construction: a hole is a shared object, and binding it is the
729
- * only way its call sites can see the answer. `repointRef` therefore records what
730
- * it changed and refuses a CONFLICTING second bind, so two different fusions over
731
- * one shared piece fail loudly instead of silently rewriting each other's parser.
732
- * ───────────────────────────────────────────────────────────────────────────── */
733
-
734
460
  /**
735
- * A rule slot produced by `ref()` — what `rules()` stores for every rule that is
736
- * referenced by name, and for every `g.X` hole a piece leaves for another piece to
737
- * fill. `parse`/`_def.thunk` are OWN properties of that object, which is what lets
738
- * the interpreted fuse repoint it in place.
461
+ * Compose a TERMINAL grammar: a leaf parser that overlays local semantic reductions
462
+ * on reusable recognition rules. Under the macro it lowers to a static table exactly
463
+ * like `compose()` and must: there is no runtime codegen fallback.
464
+ *
465
+ * At runtime it is the same lazy link as `compose()`, marked terminal. The bench and
466
+ * differential harnesses import un-macro'd grammar modules and run this path.
739
467
  */
740
- type RefSlot = Combinator<unknown> & {
741
- _def: { tag: 'lazy'; thunk: () => Combinator<unknown> }
742
- define(p: Combinator<unknown>): void
743
- parse(input: string, pos: number, ctx: ParseContext): ParseResult<unknown>
744
- }
745
-
746
- function isRefSlot(c: Combinator<unknown>): c is RefSlot {
747
- return c._def.tag === 'lazy' && typeof (c as unknown as { define?: unknown }).define === 'function'
468
+ export function composeLeaf(
469
+ items: Array<LinkableTable | Record<string, unknown>>,
470
+ ): Record<string, Runnable> {
471
+ return linkedMap(items, undefined, true)
748
472
  }
749
473
 
750
- /** What a slot resolved to BEFORE any interpreted fuse touched it (`null` = it was
751
- * an unbound cross-piece hole). Recorded on first repoint so a LATER fuse computes
752
- * its winner from the grammar as authored, never from another fusion's binding. */
753
- const FUSE_ORIGINAL = Symbol.for('parseman.interpretedFuseOriginal')
754
- /** What an interpreted fuse repointed this slot at. */
755
- const FUSE_TARGET = Symbol.for('parseman.interpretedFuseTarget')
756
- /** The source rule maps behind a `fuseInterpreted()` result, so it can be fused
757
- * again — the interpreted mirror of `COMPOSED_PIECES`. */
758
- const INTERPRETED_PIECES = Symbol.for('parseman.interpretedPieces')
759
-
760
- /** Whether `map` is an interpreted fuse (a combinator map) rather than a compiled
761
- * `compose()` result (a map of fused functions). INTERNAL — not re-exported from
762
- * `src/index.ts`. A consumer never has to ask this question: what it holds is
763
- * whatever the macro built. Diagnostics that fuse interpreted on purpose do. */
474
+ /** Whether `map` is a runtime composition (a combinator map) rather than a
475
+ * build-compiled one (a table). INTERNAL — diagnostics only. */
764
476
  export function isInterpretedFuse(map: object): boolean {
765
- return Array.isArray((map as Record<symbol, unknown>)[INTERPRETED_PIECES])
766
- }
767
-
768
- type NamedEntries = ReadonlyArray<[string, Combinator<unknown>]>
769
- /** `plain` = the item was an authored `rules()` map. Only plain maps declare the
770
- * composing trivia, mirroring `composingTriviaOf`, which skips artifacts and prior
771
- * composed results for exactly the same reason: their trivia was already applied. */
772
- type FusePiece = { entries: NamedEntries; plain: boolean }
773
-
774
- /** The rule a map entry DEFINES, or `undefined` when it is an external reference
775
- * (an accessed-but-undefined `g.X`). Same local-vs-external test `compileLinkable`
776
- * and `itemCarried` apply, so the two fuses agree on what a piece contributes. */
777
- function definitionOf(entry: Combinator<unknown>): Combinator<unknown> | undefined {
778
- if (isRefSlot(entry)) {
779
- const original = (entry as unknown as Record<symbol, unknown>)[FUSE_ORIGINAL]
780
- if (original !== undefined) return (original as Combinator<unknown> | null) ?? undefined
781
- try { return entry._def.thunk() } catch { return undefined }
782
- }
783
- if (entry._def.tag === 'lazy') {
784
- try { (entry._def as { thunk: () => Combinator<unknown> }).thunk() } catch { return undefined }
785
- }
786
- return entry
787
- }
788
-
789
- /** Point `slot` at `target`, updating every call site that holds it. Mirrors
790
- * `ref().define()`'s metadata propagation; refuses to overwrite a binding a
791
- * DIFFERENT fusion already made (see the mutation note above). */
792
- function repointRef(slot: RefSlot, target: Combinator<unknown>, name: string): void {
793
- const tagged = slot as unknown as Record<symbol, unknown>
794
- const bound = tagged[FUSE_TARGET] as Combinator<unknown> | undefined
795
- const original = FUSE_ORIGINAL in tagged
796
- ? tagged[FUSE_ORIGINAL] as Combinator<unknown> | null
797
- : (() => { try { return slot._def.thunk() } catch { return null } })()
798
- if ((bound ?? original) === target) return
799
- if (bound !== undefined) {
800
- throw new Error(
801
- `fuseInterpreted: rule "${name}" is already bound by a DIFFERENT interpreted fusion of the same grammar piece. `
802
- + `An interpreted fuse binds the shared placeholder objects in place, so two fusions cannot share a piece — `
803
- + `build a fresh instance of the piece (call its rules() factory again, or import the module under a distinct specifier) for the second fusion.`,
804
- )
805
- }
806
- if (!(FUSE_ORIGINAL in tagged)) Object.defineProperty(slot, FUSE_ORIGINAL, { value: original, enumerable: false })
807
- Object.defineProperty(slot, FUSE_TARGET, { value: target, enumerable: false })
808
- slot._def.thunk = () => target
809
- slot.parse = (input, pos, ctx) => target.parse(input, pos, ctx)
810
- const meta = slot._meta
811
- meta.firstSet = target._meta.firstSet
812
- meta.canMatchNewline = target._meta.canMatchNewline
813
- meta.isTrivia = target._meta.isTrivia
814
- if (target._meta.triviaKindLabels !== undefined) meta.triviaKindLabels = target._meta.triviaKindLabels
815
- else (meta as { triviaKindLabels: readonly string[] | undefined }).triviaKindLabels = undefined
816
- if (target._meta.disjoint !== undefined) meta.disjoint = target._meta.disjoint
817
- else (meta as { disjoint: boolean | undefined }).disjoint = undefined
818
- }
819
-
820
- /** Flatten one `fuseInterpreted()` item to the rule maps it contributes, in order. */
821
- function interpretedPieces(item: LinkableTable | Record<string, unknown>): FusePiece[] {
822
- const fused = (item as Record<symbol, unknown>)[INTERPRETED_PIECES]
823
- if (Array.isArray(fused)) return (fused as NamedEntries[]).map(entries => ({ entries, plain: false }))
824
- const carried = composedPiecesOf(item as Record<string, unknown>)
825
- if (carried !== undefined) {
826
- // A compiled `compose()` result. Its carried IR re-lowers to combinators, but a
827
- // piece that arrived already COMPILED has no combinator graph at all — fusing
828
- // around it would silently drop its rules, which is the one failure mode a
829
- // diagnostic must never have.
830
- const { maps, opaque } = carriedRuleMapsDetailed(carried)
831
- if (opaque.length > 0) {
832
- throw new Error(
833
- `fuseInterpreted: cannot interpret a composed grammar containing precompiled artifact(s) `
834
- + `${opaque.map(o => `"${o.ns}" (${o.ruleNames.length} rules)`).join(', ')} — they carry compiled functions, not a combinator graph. `
835
- + `Pass the source grammars (the same items you passed to compose()) instead.`,
836
- )
837
- }
838
- return maps.map(entries => ({ entries, plain: false }))
839
- }
840
- if (isLinkableTable(item)) {
841
- // A table piece DOES carry its IR, so this is recoverable rather than fatal: hydrate
842
- // the combinator graph back out of it and interpret that.
843
- const rules = ruleMapOfCarried(item)
844
- if (rules === undefined) {
845
- throw new Error('fuseInterpreted: a precompiled linkable artifact with no carried IR has no combinator graph to interpret; pass the source grammar (a rules() map) instead')
846
- }
847
- return [{ entries: rules, plain: false }]
848
- }
849
- return [{ entries: Object.entries(item as Record<string, Combinator<unknown>>), plain: true }]
477
+ return (map as Record<symbol, unknown>)[LINKED] !== undefined
850
478
  }
851
479
 
852
480
  /**
853
- * Materialize a composition as a RUNNABLE INTERPRETED rule map — the interpreted
854
- * counterpart of `compose()`, with identical fuse semantics (later piece wins,
855
- * override reroutes the base's own calls, composing trivia governs every rule).
856
- * No codegen, no `new Function`, no macro build step: the result is a plain map of
857
- * combinators that `run()` / `parseDoc()` accept exactly like a fused map.
858
- *
859
- * This is what diagnostics and profiling run against — they must stay in
860
- * interpreted mode, and before this they could not see a composed grammar at all.
861
- *
862
- * Items are the SAME items `compose()`/`composeLeaf()` take: `rules()` maps
863
- * (the intended input), a prior `fuseInterpreted()` result, or a runtime
864
- * `compose()` result (re-lowered from its carried IR — note that carried IR cannot
865
- * materialize direct `node()` builders, so prefer the source maps). A precompiled
866
- * `linkable()` artifact is rejected: it has no combinator graph.
867
- *
868
- * MUTATION: binding a cross-piece hole rewrites the shared placeholder object every
869
- * call site already holds — that IS how an override reaches a base piece's own
870
- * calls. A second, DIFFERENT fusion over the same piece objects therefore throws
871
- * rather than silently rewriting the first one's parser.
481
+ * The linked interpreter map for a composition, as plain combinators — what
482
+ * diagnostics and profiling walk. The same link `compose()` makes, forced now.
483
+ * INTERNAL: not re-exported from `src/index.ts`.
872
484
  */
873
485
  export function fuseInterpreted(
874
486
  items: Array<LinkableTable | Record<string, unknown>>,
875
487
  opts?: { hostMode?: HostMode },
876
488
  ): Record<string, Combinator<unknown>> {
877
- return fusePieces(items.flatMap(interpretedPieces), opts)
878
- }
879
-
880
- /** The rule names a fusion of these pieces defines, in winner order — computable
881
- * WITHOUT binding anything, which is what lets `composeLeaf()` expose its key set
882
- * before it fuses. */
883
- function ruleNamesOf(pieces: FusePiece[]): string[] {
884
- const names = new Set<string>()
885
- for (const piece of pieces) {
886
- for (const [name, value] of piece.entries) if (definitionOf(value) !== undefined) names.add(name)
887
- }
888
- return [...names]
889
- }
890
-
891
- function fusePieces(
892
- pieces: FusePiece[],
893
- opts?: { hostMode?: HostMode },
894
- ): Record<string, Combinator<unknown>> {
895
- // Composing-wins trivia, read exactly as `composingTriviaOf` reads it for compose():
896
- // the LAST authored grammar that declared `rules({ trivia }, …)`.
897
- let trivia: Combinator<unknown> | undefined
898
- for (let i = pieces.length - 1; i >= 0 && trivia === undefined; i--) {
899
- if (!pieces[i]!.plain) continue
900
- for (const [, rule] of pieces[i]!.entries) {
901
- const t = rule._meta.grammarTrivia
902
- if (t) { trivia = t; break }
903
- }
904
- }
905
-
906
- // Winner per rule name — later piece wins, matching `fuseRules`. The winner is the
907
- // DEFINITION, never the slot holding it: a slot can be repointed, and a chain
908
- // through one would make an override of X reroute into itself.
909
- const winner = new Map<string, Combinator<unknown>>()
910
- const entry = new Map<string, Combinator<unknown>>()
911
- for (const piece of pieces) {
912
- for (const [name, value] of piece.entries) {
913
- const def = definitionOf(value)
914
- if (def === undefined) continue
915
- winner.set(name, def)
916
- entry.set(name, value)
917
- }
918
- }
919
-
920
- // Bind every hole (and repoint every overridden slot) before anything runs.
921
- const missing = new Set<string>()
922
- for (const piece of pieces) {
923
- for (const [name, value] of piece.entries) {
924
- if (!isRefSlot(value)) continue
925
- const target = winner.get(name)
926
- if (target === undefined) { missing.add(name); continue }
927
- repointRef(value, target, name)
928
- }
929
- }
930
- if (missing.size > 0) throw new Error(missingRuleMessage(pieces, missing))
931
-
932
- const out: Record<string, Combinator<unknown>> = {}
933
- for (const [name, value] of entry) {
934
- out[name] = value
935
- const meta = value._meta as {
936
- isTrivia: boolean
937
- grammarTrivia?: Combinator<unknown>
938
- grammarHostMode?: HostMode
939
- }
940
- // A trivia rule must never carry the ambient trivia (it would recursively skip
941
- // trivia within itself) — the same guard `compileLinkable` applies per rule.
942
- if (trivia !== undefined && !meta.isTrivia) meta.grammarTrivia = trivia
943
- // Host mode is PER PIECE, exactly as `compileLinkable` resolves it: an explicit
944
- // option wins, otherwise the owning piece's own `rules({ hostMode })` stamp.
945
- if (opts?.hostMode !== undefined && !meta.isTrivia) {
946
- if (opts.hostMode === 'cst') meta.grammarHostMode = 'cst'
947
- else (meta as { grammarHostMode: HostMode | undefined }).grammarHostMode = undefined
948
- }
949
- }
950
- Object.defineProperty(out, INTERPRETED_PIECES, {
951
- value: pieces.filter(p => p.plain).map(p => p.entries),
952
- enumerable: false,
953
- })
954
- return out
955
- }
956
-
957
- /** Name-closure failure, reported the way the compiled fuse reports it: which rule
958
- * referenced the missing name. Computed only on the error path. */
959
- function missingRuleMessage(pieces: FusePiece[], missing: Set<string>): string {
960
- for (const piece of pieces) {
961
- const deps = ruleDependencies(piece.entries.filter(([, v]) => definitionOf(v) !== undefined))
962
- for (const [name, ds] of deps) {
963
- for (const d of ds) if (missing.has(d)) return `fuseInterpreted: rule "${name}" references missing rule "${d}"`
964
- }
965
- }
966
- return `fuseInterpreted: missing rule(s) ${[...missing].map(n => `"${n}"`).join(', ')}`
489
+ const map = linkedMap(items, opts, false)
490
+ return (map as unknown as Record<symbol, () => Record<string, Combinator<unknown>>>)[LINKED]!()
967
491
  }