parseman 0.52.0 → 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 (62) hide show
  1. package/dist/analysis/diagnostics.js +358 -8
  2. package/dist/analysis/diagnostics.js.map +3 -3
  3. package/dist/cli/index.js +5929 -6172
  4. package/dist/cli/index.js.map +3 -3
  5. package/dist/combinators/parser.d.ts +34 -0
  6. package/dist/combinators/parser.d.ts.map +1 -1
  7. package/dist/combinators/ref.d.ts +0 -13
  8. package/dist/combinators/ref.d.ts.map +1 -1
  9. package/dist/compiler/borrowed-rules.d.ts +30 -0
  10. package/dist/compiler/borrowed-rules.d.ts.map +1 -0
  11. package/dist/compiler/compile-linkable-table.d.ts +1 -1
  12. package/dist/compiler/grammar-coverage-ids.d.ts.map +1 -1
  13. package/dist/compiler/ir-serialize.d.ts +10 -4
  14. package/dist/compiler/ir-serialize.d.ts.map +1 -1
  15. package/dist/compiler/linker.d.ts +48 -91
  16. package/dist/compiler/linker.d.ts.map +1 -1
  17. package/dist/coverage.d.ts +1 -1
  18. package/dist/coverage.d.ts.map +1 -1
  19. package/dist/cst/reflection.d.ts.map +1 -1
  20. package/dist/index.js +416 -704
  21. package/dist/index.js.map +3 -3
  22. package/dist/language-service/index.js.map +1 -1
  23. package/dist/plugin/evaluator.d.ts +38 -2
  24. package/dist/plugin/evaluator.d.ts.map +1 -1
  25. package/dist/plugin/index.d.ts.map +1 -1
  26. package/dist/plugin/index.js +1073 -523
  27. package/dist/plugin/index.js.map +3 -3
  28. package/dist/plugin/ir-eval.d.ts +21 -0
  29. package/dist/plugin/ir-eval.d.ts.map +1 -0
  30. package/dist/plugin/reducer-resolver.d.ts +6 -0
  31. package/dist/plugin/reducer-resolver.d.ts.map +1 -1
  32. package/dist/spec/index.js +555 -6109
  33. package/dist/spec/index.js.map +3 -3
  34. package/dist/spec/railroad-lib.d.ts +7 -2
  35. package/dist/spec/railroad-lib.d.ts.map +1 -1
  36. package/dist/spec/railroad.d.ts.map +1 -1
  37. package/dist/table/compile-rule-map.d.ts +11 -4
  38. package/dist/table/compile-rule-map.d.ts.map +1 -1
  39. package/dist/table/compile.d.ts +16 -0
  40. package/dist/table/compile.d.ts.map +1 -1
  41. package/dist/table/index.js +235 -87
  42. package/dist/table/index.js.map +2 -2
  43. package/dist/version.d.ts +1 -1
  44. package/package.json +1 -1
  45. package/src/combinators/parser.ts +133 -67
  46. package/src/combinators/ref.ts +10 -1
  47. package/src/compiler/borrowed-rules.ts +88 -0
  48. package/src/compiler/compile-linkable-table.ts +1 -1
  49. package/src/compiler/grammar-coverage-ids.ts +35 -6
  50. package/src/compiler/ir-serialize.ts +12 -118
  51. package/src/compiler/linker.ts +190 -666
  52. package/src/coverage.ts +6 -4
  53. package/src/cst/reflection.ts +6 -1
  54. package/src/plugin/evaluator.ts +113 -13
  55. package/src/plugin/index.ts +588 -110
  56. package/src/plugin/ir-eval.ts +165 -0
  57. package/src/plugin/reducer-resolver.ts +20 -3
  58. package/src/spec/railroad-lib.ts +18 -8
  59. package/src/spec/railroad.ts +3 -4
  60. package/src/table/compile-rule-map.ts +18 -8
  61. package/src/table/compile.ts +61 -0
  62. package/src/version.ts +1 -1
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.52.0";
16
+ export declare const PARSEMAN_VERSION = "0.53.0";
17
17
  //# sourceMappingURL=version.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parseman",
3
- "version": "0.52.0",
3
+ "version": "0.53.0",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "engines": {
6
6
  "node": "^20.19.0 || >=22.13.0"
@@ -37,6 +37,31 @@ function isNamedRuleRefForAnotherRule(r: Combinator<unknown>, key: string): bool
37
37
  return name !== undefined && name !== key
38
38
  }
39
39
 
40
+ /** Every rule value a finished `rules()` call holds. See `ownRule`. */
41
+ const claimedRules = new WeakSet<object>()
42
+
43
+ /**
44
+ * `rules()` stamps its options (`trivia`, `scanSkip`, `hostMode`, `trackLines`) and the
45
+ * rule name onto the rule object itself. So a value ANOTHER grammar already holds — one
46
+ * terminal returned by two factories, or a rule of grammar A returned by grammar B —
47
+ * must not be stamped in place: the second grammar would overwrite the first's options,
48
+ * or inherit them wherever it omits one, and the two would parse different input than
49
+ * either declared. This grammar takes its own shallow copy instead, with none of those
50
+ * stamps. (A rule ref's `parse` still reads the meta it closes over — that is the other
51
+ * grammar's rule carrying its own trivia, exactly as a `g.X` reference to it would.)
52
+ */
53
+ function ownRule(p: Combinator<unknown>, copies: Map<object, Combinator<unknown>>): Combinator<unknown> {
54
+ // A trivia rule is never stamped, and its identity is what ties it to `{ trivia }`.
55
+ if (!claimedRules.has(p) || p._meta.isTrivia) return p
56
+ let own = copies.get(p)
57
+ if (own === undefined) {
58
+ const { grammarTrivia: _t, grammarScanSkip: _s, grammarHostMode: _h, grammarTrackLines: _l, ...meta } = p._meta
59
+ // One copy per grammar, so a value under two keys stays one object here too.
60
+ copies.set(p, own = { ...p, _meta: meta })
61
+ }
62
+ return own
63
+ }
64
+
40
65
  /**
41
66
  * Define named grammar rules without forward declarations.
42
67
  *
@@ -140,7 +165,52 @@ export function rules<T extends Record<string, Combinator<unknown>>>(
140
165
  ): RulesResult<T> {
141
166
  const factory = (typeof a === 'function' ? a : b) as (self: any) => T
142
167
  const options = (typeof a === 'function' ? b : a) as RulesOptions | undefined
143
- const cache: Partial<T> = {}
168
+ const recipe: RulesRecipe = { factory, options }
169
+ const map = linkRules([recipe])
170
+ Object.defineProperty(map, RULES_RECIPE, { value: [recipe], enumerable: false })
171
+ return map as RulesResult<T>
172
+ }
173
+
174
+ /**
175
+ * The recipe a `rules()` result was built from — its factory and options —
176
+ * non-enumerable, in piece order. Runtime `compose()` links grammars by running
177
+ * these again against ONE shared namespace (`linkRules`), which is what makes a
178
+ * composition the same graph as one `rules()` written over every piece. A
179
+ * composed result carries the concatenated list, so it composes again.
180
+ */
181
+ export const RULES_RECIPE = Symbol.for('parseman.rulesRecipe')
182
+ export type RulesRecipe = {
183
+ readonly factory: (self: Record<string, Combinator<unknown>>) => Record<string, Combinator<unknown>>
184
+ readonly options: RulesOptions | undefined
185
+ }
186
+
187
+ /**
188
+ * How a composition overrides its pieces' own options: the composing grammar's
189
+ * trivia governs every rule (composing-wins), and an explicit host mode wins
190
+ * over each piece's.
191
+ */
192
+ export type LinkOptions = {
193
+ readonly trivia?: Combinator<unknown> | undefined
194
+ readonly hostMode?: HostMode | undefined
195
+ }
196
+
197
+ /**
198
+ * Build ONE rule map from one or more recipes, run in order against a single
199
+ * shared namespace, a later recipe's definition winning per rule name.
200
+ *
201
+ * `rules()` is the one-recipe case. Runtime `compose()` passes every piece's
202
+ * recipe plus `link`: each `g.X` in every factory then resolves to the one
203
+ * composed slot for X, so an override reroutes a base piece's own calls (open
204
+ * recursion) and every choice computes its first-set dispatch against the
205
+ * winners. The graph is built fresh, so no shared rule of any piece is touched;
206
+ * under `link` a winner is always held in a fresh slot, so the stamps below never
207
+ * land on a combinator a factory returned from module scope either.
208
+ */
209
+ export function linkRules(
210
+ recipes: readonly RulesRecipe[],
211
+ link?: LinkOptions,
212
+ ): Record<string, Combinator<unknown>> {
213
+ const cache: Record<string, Combinator<unknown>> = {}
144
214
 
145
215
  // Proxy: accessing any property creates a ref() placeholder on first touch.
146
216
  const proxy = new Proxy(cache, {
@@ -161,13 +231,22 @@ export function rules<T extends Record<string, Combinator<unknown>>>(
161
231
 
162
232
  // Evaluate all rule definitions. JavaScript evaluates object-literal values
163
233
  // left-to-right, so any `g.ruleName` access inside triggers placeholder creation
164
- // before the rule's own parser is built — enabling forward references.
165
- const definitions = factory(proxy)
234
+ // before the rule's own parser is built — enabling forward references. A later
235
+ // recipe's definition of a name WINS; the name keeps its first position.
236
+ const winners = new Map<string, { parser: Combinator<unknown>; options: RulesOptions | undefined }>()
237
+ for (const recipe of recipes) {
238
+ const definitions = recipe.factory(proxy)
239
+ for (const key of Object.keys(definitions)) {
240
+ winners.set(key, { parser: definitions[key]!, options: recipe.options })
241
+ }
242
+ }
243
+ const keys = [...winners.keys()]
166
244
 
167
245
  // Fill each ref with its actual definition, or store directly if never accessed via proxy.
168
- for (const key of Object.keys(definitions)) {
169
- const placeholder = (cache as Record<string, Combinator<unknown>>)[key]
170
- const parser = (definitions as Record<string, Combinator<unknown>>)[key]!
246
+ const copies = new Map<object, Combinator<unknown>>()
247
+ for (const [key, { parser: defined }] of winners) {
248
+ const placeholder = cache[key]
249
+ const parser = ownRule(defined, copies)
171
250
  if (placeholder === parser) {
172
251
  throw new Error(`rules(): rule "${key}" cannot be a direct alias to itself`)
173
252
  }
@@ -177,76 +256,63 @@ export function rules<T extends Record<string, Combinator<unknown>>>(
177
256
  // Propagate actual first-set so later choices wrapping this ref get correct dispatch.
178
257
  placeholder._meta.firstSet = parser._meta.firstSet
179
258
  placeholder._meta.canMatchNewline = parser._meta.canMatchNewline
180
- } else if (isNamedRuleRefForAnotherRule(parser, key)) {
259
+ } else if (isNamedRuleRefForAnotherRule(parser, key) || link !== undefined) {
260
+ if (!isNamedRuleRefForAnotherRule(parser, key)) tagRule(parser, key)
181
261
  const alias = ref()
182
262
  tagRule(alias, key)
183
263
  alias.define(parser)
184
264
  alias._meta.firstSet = parser._meta.firstSet
185
265
  alias._meta.canMatchNewline = parser._meta.canMatchNewline
186
- ;(cache as Record<string, Combinator<unknown>>)[key] = alias
266
+ cache[key] = alias
187
267
  } else {
188
268
  tagRule(parser, key)
189
- ;(cache as Record<string, Combinator<unknown>>)[key] = parser
190
- }
191
- }
192
-
193
- // Declare the grammar-level ambient trivia on every rule, so parsing ANY rule
194
- // as an entry installs it (run()/parse() read this), and the macro can seed the
195
- // compiled map. `parser({trivia})` / `noTrivia` still override it locally.
196
- // `!= null`: a `trivia: null` grammar clears trivia — same as omitting it at the
197
- // grammar level — so store nothing (and never write null, which has no `_meta`).
198
- if (options?.trivia != null) {
199
- for (const key of Object.keys(definitions)) {
200
- const rule = (cache as Record<string, Combinator<unknown>>)[key]
201
- // Skip trivia rules (e.g. the grammar's `rw`, returned so the driver can
202
- // reach it as `g.rw`): a trivia rule must never carry ambient trivia, or it
203
- // would recursively skip trivia within itself. Mirrors the codegen guard.
204
- if (rule && !rule._meta.isTrivia) (rule._meta as { grammarTrivia?: Combinator<unknown> }).grammarTrivia = options.trivia
205
- }
206
- }
207
-
208
- // Grammar-level ambient scan-skip, mirroring the trivia stamp above: every
209
- // non-trivia rule carries it so any parse entry installs `ctx.scanSkip` and the
210
- // compiled map can seed it. `!= null` so `scanSkip: null` clears (stores nothing).
211
- if (options?.scanSkip != null) {
212
- for (const key of Object.keys(definitions)) {
213
- const rule = (cache as Record<string, Combinator<unknown>>)[key]
214
- if (rule && !rule._meta.isTrivia) (rule._meta as { grammarScanSkip?: Combinator<unknown>[] }).grammarScanSkip = options.scanSkip
269
+ cache[key] = parser
215
270
  }
216
271
  }
217
272
 
218
- // Grammar-level host mode, mirroring the two stamps above. Only `'cst'` is recorded:
219
- // `'ast'` is the default everywhere, so stamping it would put a field on every rule of
220
- // every grammar to say "unchanged". Trivia rules are skipped for the same reason as
221
- // above — they build no nodes, so the mode is meaningless on them.
222
- if (options?.hostMode === 'cst') {
223
- for (const key of Object.keys(definitions)) {
224
- const rule = (cache as Record<string, Combinator<unknown>>)[key]
225
- if (rule && !rule._meta.isTrivia) (rule._meta as { grammarHostMode?: HostMode }).grammarHostMode = 'cst'
226
- }
227
- }
228
-
229
- if (options?.trackLines === true) {
230
- for (const key of Object.keys(definitions)) {
231
- const rule = (cache as Record<string, Combinator<unknown>>)[key]
232
- if (rule && !rule._meta.isTrivia) (rule._meta as { grammarTrackLines?: true }).grammarTrackLines = true
233
- }
234
- for (const key of Object.keys(definitions)) {
235
- const rule = (cache as Record<string, Combinator<unknown>>)[key]
236
- if (rule && !rule._meta.isTrivia && rule._def.tag !== 'grammar') {
237
- const wrapped = grammarParser({ trackLines: true }, rule)
238
- tagRule(wrapped, key)
239
- ;(wrapped._meta as { grammarTrackLines?: true }).grammarTrackLines = true
240
- ;(cache as Record<string, Combinator<unknown>>)[key] = wrapped
273
+ // Grammar-level stamps, per rule. Each touches only its own rule — a wrap
274
+ // replaces that rule's entry, and references hold the placeholder — so one pass
275
+ // is the same as a pass per stamp. Trivia rules (e.g. the grammar's `rw`,
276
+ // returned so the driver can reach it as `g.rw`) take none: a trivia rule must
277
+ // never carry ambient trivia, or it would recursively skip trivia within itself.
278
+ for (const key of keys) {
279
+ const options = winners.get(key)!.options
280
+ let rule = cache[key]!
281
+ if (!rule._meta.isTrivia) {
282
+ const meta = rule._meta as {
283
+ grammarTrivia?: Combinator<unknown>
284
+ grammarScanSkip?: Combinator<unknown>[]
285
+ grammarHostMode?: HostMode
286
+ grammarTrackLines?: true
287
+ }
288
+ // Ambient trivia, installed at the parse entry (run()/parse() read it) and
289
+ // seeded into the compiled map; `parser({trivia})` / `noTrivia` override it
290
+ // locally. `!= null`: `trivia: null` clears — store nothing. Under `link`
291
+ // the COMPOSING trivia governs every rule, inherited ones included.
292
+ const trivia = link !== undefined ? link.trivia : options?.trivia
293
+ if (trivia != null) meta.grammarTrivia = trivia
294
+ // Ambient scan-skip, per piece (never composing-wins: opaque units are
295
+ // dialect-specific), so any entry installs `ctx.scanSkip`.
296
+ if (options?.scanSkip != null) meta.grammarScanSkip = options.scanSkip
297
+ // Host mode: only `'cst'` is recorded — `'ast'` is the default everywhere.
298
+ if ((link?.hostMode ?? options?.hostMode) === 'cst') meta.grammarHostMode = 'cst'
299
+ if (options?.trackLines === true) {
300
+ meta.grammarTrackLines = true
301
+ if (rule._def.tag !== 'grammar') {
302
+ rule = grammarParser({ trackLines: true }, rule)
303
+ tagRule(rule, key)
304
+ ;(rule._meta as { grammarTrackLines?: true }).grammarTrackLines = true
305
+ cache[key] = rule
306
+ }
241
307
  }
242
308
  }
243
- }
244
-
245
- // Dead-value analysis: mark container aggregates that only feed a node()'s
246
- // capture so the interpreter (and, via the same flag, the compiled output) skips
247
- // building them. Each rule is its own root — refs are boundaries (see value-usage).
248
- for (const key of Object.keys(definitions)) {
249
- markUnusedValues((cache as Record<string, Combinator<unknown>>)[key]!)
309
+ // Dead-value analysis: mark container aggregates that only feed a node()'s
310
+ // capture so the interpreter (and, via the same flag, the compiled output) skips
311
+ // building them. Each rule is its own root — refs are boundaries (see value-usage).
312
+ markUnusedValues(rule)
313
+ // Claimed only after the define loop, so one value under two keys of THIS grammar
314
+ // stays one object. See `ownRule`.
315
+ claimedRules.add(winners.get(key)!.parser).add(rule)
250
316
  }
251
317
 
252
318
  // Record the factory's DECLARATION order (the returned object's key order).
@@ -256,11 +322,11 @@ export function rules<T extends Record<string, Combinator<unknown>>>(
256
322
  // Non-enumerable, so Object.keys / spread / for-in over the grammar are
257
323
  // unaffected and every existing consumer sees exactly the rules it did before.
258
324
  Object.defineProperty(cache, RULE_ORDER, {
259
- value: Object.keys(definitions),
325
+ value: keys,
260
326
  enumerable: false,
261
327
  configurable: true,
262
328
  })
263
- attachGrammarReflection(cache, collectGrammarReflection(Object.keys(definitions).map(key => [key, (cache as Record<string, Combinator<unknown>>)[key]!])))
329
+ attachGrammarReflection(cache, collectGrammarReflection(keys.map(key => [key, cache[key]!])))
264
330
 
265
- return cache as RulesResult<T>
331
+ return cache
266
332
  }
@@ -26,6 +26,15 @@ function withRuleTrivia<T>(
26
26
  *
27
27
  * Unlike lazy(() => x), you use the ref directly — no wrapping at each call site.
28
28
  */
29
+ /*
30
+ * What `thunk()` throws for an undefined ref. Grammar construction probes thunks
31
+ * constantly — a `rules()` factory runs before any of its refs is defined, and
32
+ * first-set, nullability and expected-set analysis all try a thunk and catch — so
33
+ * one shared error spares a stack capture per probe. Parsing through an undefined
34
+ * ref still throws a fresh error below, with the stack that locates it.
35
+ */
36
+ const UNDEFINED_REF = new Error('ref<T>() used before .define() was called')
37
+
29
38
  export function ref<T>(): Combinator<T> & { define(p: Combinator<T>): void } {
30
39
  let resolved: Combinator<T> | null = null
31
40
  let resolvedScalar: ScalarParser | null = null
@@ -44,7 +53,7 @@ export function ref<T>(): Combinator<T> & { define(p: Combinator<T>): void } {
44
53
  _def: {
45
54
  tag: 'lazy' as const,
46
55
  thunk: () => {
47
- if (!resolved) throw new Error('ref<T>() used before .define() was called')
56
+ if (!resolved) throw UNDEFINED_REF
48
57
  return resolved as Combinator<unknown>
49
58
  },
50
59
  },
@@ -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