parseman 0.56.0 → 0.58.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 (106) hide show
  1. package/dist/analysis/diagnostics.js +2232 -1088
  2. package/dist/analysis/diagnostics.js.map +3 -3
  3. package/dist/cli/index.js +2247 -1103
  4. package/dist/cli/index.js.map +3 -3
  5. package/dist/combinators/dispatch.d.ts.map +1 -1
  6. package/dist/combinators/node.d.ts +1 -1
  7. package/dist/compiler/build-arity.d.ts +1 -1
  8. package/dist/compiler/build-arity.d.ts.map +1 -1
  9. package/dist/compiler/token-alphabet.d.ts +11 -1
  10. package/dist/compiler/token-alphabet.d.ts.map +1 -1
  11. package/dist/compiler/token-capability.js +69 -16
  12. package/dist/compiler/token-capability.js.map +2 -2
  13. package/dist/cst/capture-buffer.d.ts +6 -0
  14. package/dist/cst/capture-buffer.d.ts.map +1 -1
  15. package/dist/cst/trivia-kinds.d.ts +9 -1
  16. package/dist/cst/trivia-kinds.d.ts.map +1 -1
  17. package/dist/index.js +1446 -236
  18. package/dist/index.js.map +3 -3
  19. package/dist/language-service/index.js +1 -0
  20. package/dist/language-service/index.js.map +1 -1
  21. package/dist/parse-context.d.ts.map +1 -1
  22. package/dist/plugin/entry-supercompiler.d.ts +1 -1
  23. package/dist/plugin/entry-supercompiler.d.ts.map +1 -1
  24. package/dist/plugin/index.d.ts +51 -1
  25. package/dist/plugin/index.d.ts.map +1 -1
  26. package/dist/plugin/index.js +2126 -380
  27. package/dist/plugin/index.js.map +3 -3
  28. package/dist/plugin/ir-eval.d.ts +2 -0
  29. package/dist/plugin/ir-eval.d.ts.map +1 -1
  30. package/dist/regex/classes.d.ts.map +1 -1
  31. package/dist/run/index.js +1 -0
  32. package/dist/run/index.js.map +1 -1
  33. package/dist/table/assemble.d.ts +9 -0
  34. package/dist/table/assemble.d.ts.map +1 -1
  35. package/dist/table/child-slots.d.ts +4 -6
  36. package/dist/table/child-slots.d.ts.map +1 -1
  37. package/dist/table/compile-rule-map.d.ts +14 -2
  38. package/dist/table/compile-rule-map.d.ts.map +1 -1
  39. package/dist/table/emit-assembly.d.ts +17 -2
  40. package/dist/table/emit-assembly.d.ts.map +1 -1
  41. package/dist/table/emit.d.ts +5 -1
  42. package/dist/table/emit.d.ts.map +1 -1
  43. package/dist/table/encode.d.ts.map +1 -1
  44. package/dist/table/exec-baseline.d.ts.map +1 -1
  45. package/dist/table/exec.d.ts.map +1 -1
  46. package/dist/table/factor-choice.d.ts +42 -0
  47. package/dist/table/factor-choice.d.ts.map +1 -0
  48. package/dist/table/fail-clean.d.ts +3 -0
  49. package/dist/table/fail-clean.d.ts.map +1 -0
  50. package/dist/table/hold-gap.d.ts +8 -0
  51. package/dist/table/hold-gap.d.ts.map +1 -0
  52. package/dist/table/index.js +1798 -316
  53. package/dist/table/index.js.map +3 -3
  54. package/dist/table/lex-program.d.ts +7 -3
  55. package/dist/table/lex-program.d.ts.map +1 -1
  56. package/dist/table/ops.d.ts +17 -6
  57. package/dist/table/ops.d.ts.map +1 -1
  58. package/dist/table/program.d.ts +13 -0
  59. package/dist/table/program.d.ts.map +1 -1
  60. package/dist/table/run-support.d.ts +19 -0
  61. package/dist/table/run-support.d.ts.map +1 -1
  62. package/dist/table/scalar-terminal.d.ts +2 -0
  63. package/dist/table/scalar-terminal.d.ts.map +1 -1
  64. package/dist/table/select-hot.d.ts +6 -0
  65. package/dist/table/select-hot.d.ts.map +1 -0
  66. package/dist/table/site-labels.d.ts +66 -0
  67. package/dist/table/site-labels.d.ts.map +1 -1
  68. package/dist/table/trivia-gap.d.ts +31 -0
  69. package/dist/table/trivia-gap.d.ts.map +1 -0
  70. package/dist/types.d.ts +17 -0
  71. package/dist/types.d.ts.map +1 -1
  72. package/dist/version.d.ts +1 -1
  73. package/package.json +4 -1
  74. package/src/combinators/dispatch.ts +12 -3
  75. package/src/combinators/node.ts +6 -13
  76. package/src/combinators/trivia-skip.ts +44 -1
  77. package/src/compiler/build-arity.ts +3 -3
  78. package/src/compiler/token-alphabet.ts +57 -17
  79. package/src/cst/capture-buffer.ts +25 -0
  80. package/src/cst/trivia-kinds.ts +12 -1
  81. package/src/parse-context.ts +1 -0
  82. package/src/plugin/entry-supercompiler.ts +8 -9
  83. package/src/plugin/index.ts +362 -42
  84. package/src/plugin/ir-eval.ts +7 -1
  85. package/src/regex/classes.ts +3 -0
  86. package/src/table/assemble.ts +371 -83
  87. package/src/table/child-slots.ts +6 -8
  88. package/src/table/compile-rule-map.ts +64 -15
  89. package/src/table/emit-assembly.ts +244 -42
  90. package/src/table/emit.ts +11 -8
  91. package/src/table/encode.ts +149 -35
  92. package/src/table/exec-baseline.ts +1 -3
  93. package/src/table/exec.ts +34 -14
  94. package/src/table/factor-choice.ts +173 -0
  95. package/src/table/fail-clean.ts +130 -0
  96. package/src/table/hold-gap.ts +68 -0
  97. package/src/table/lex-program.ts +197 -43
  98. package/src/table/ops.ts +18 -7
  99. package/src/table/program.ts +24 -0
  100. package/src/table/run-support.ts +35 -0
  101. package/src/table/scalar-terminal.ts +8 -0
  102. package/src/table/select-hot.ts +70 -0
  103. package/src/table/site-labels.ts +135 -4
  104. package/src/table/trivia-gap.ts +109 -0
  105. package/src/types.ts +18 -0
  106. package/src/version.ts +1 -1
@@ -26,7 +26,7 @@ import MagicString from 'magic-string'
26
26
  import { evaluateExpr, evaluateCombinatorArray, evaluateParserFactory, evaluateStaticValue, evaluateWordFactory, evaluateWhenFactory, evaluateRefDeclaration, applyDefineStatement, referencesAny, setReducerResolver, setBuilderImportResolver, propName, ModuleScope, type FreeNameResolution, type Scope, type ScopeEntry } from './evaluator.ts'
27
27
  import { classifyRuleMap } from '../analysis/commitment.ts'
28
28
  import { compile } from '../table/compile.ts'
29
- import { compileRuleMap } from '../table/compile-rule-map.ts'
29
+ import { compileRuleMap, type CompiledRuleMapTable } from '../table/compile-rule-map.ts'
30
30
  import { compileLinkableTable } from '../compiler/compile-linkable-table.ts'
31
31
  import { createReducerResolver, topLevelIsStable } from './reducer-resolver.ts'
32
32
  import { findFreeIdentifiers } from './free-identifiers.ts'
@@ -36,9 +36,11 @@ import {
36
36
  resolveDegradationLevel, recordDegradation, degradationCaptureDepth, unwindDegradationCapture,
37
37
  } from '../compiler/degradation.ts'
38
38
  import type { HostMode } from '../cst/host-mode.ts'
39
- import { COMPOSED_PIECES } from '../compiler/linker.ts'
39
+ import { COMPOSED_PIECES, type IRPiece } from '../compiler/linker.ts'
40
40
  import { serializeCombinator, serializeRuleMap } from '../compiler/ir-serialize.ts'
41
- import { evalCombinatorIR, evalRulesIR } from './ir-eval.ts'
41
+ import { evalCombinatorIR, evalRulesIR, INERT_BUILDER } from './ir-eval.ts'
42
+ import { countSiteVisits } from '../table/assemble.ts'
43
+ import { run } from '../functional/run.ts'
42
44
  import { linkRules, RULES_RECIPE, type LinkOptions, type RulesRecipe } from '../combinators/parser.ts'
43
45
  import { PARSEMAN_VERSION } from '../version.ts'
44
46
  import { grammarReflectionSource, type GrammarReflection } from '../cst/reflection.ts'
@@ -63,6 +65,48 @@ import type {
63
65
  */
64
66
  const TABLE_RUNTIME_SPECIFIER = 'parseman/table'
65
67
 
68
+ /**
69
+ * BUILD-ONLY CARRIED PIECES.
70
+ *
71
+ * Nothing at runtime reads a grammar's carried IR: `compose()` links live `rules()`
72
+ * maps, and every runtime reader treats an IR piece as opaque
73
+ * (docs/design/runtime-and-size-contract.md, rules 1 and 2). Only a downstream macro
74
+ * build reads it. Under a rollup-family bundler the macro therefore leaves the IR out
75
+ * of the artifact and writes each chunk's carried lists beside it, as
76
+ * `<chunk>.parseman.json`; `resolveModulePieces` reads that file before it looks for
77
+ * an inline literal. Inline, the IR is about a quarter of a composed grammar's
78
+ * artifact, and the runtime spread that forwards an ancestor's pieces keeps that
79
+ * ancestor's module in the import graph of every parse.
80
+ *
81
+ * An item is an IR piece, or a reference to an export of a package module, resolved
82
+ * from the artifact's own location.
83
+ */
84
+ export type CarriedSidecarItem = IRPiece | { from: string; name: string }
85
+ /** One module's carried lists, by export name. */
86
+ export type CarriedSink = Map<string, CarriedSidecarItem[]>
87
+ export const CARRIED_SIDECAR_SUFFIX = '.parseman.json'
88
+ type CarriedSidecar = { parseman: string; exports: Record<string, CarriedSidecarItem[]> }
89
+
90
+ const _sidecarCache = new Map<string, { mtimeMs: number; sidecar: CarriedSidecar | null }>()
91
+ /** The carried-pieces sidecar written beside a compiled artifact, or null. */
92
+ function readCarriedSidecar(artifact: string): CarriedSidecar | null {
93
+ const file = artifact + CARRIED_SIDECAR_SUFFIX
94
+ let mtimeMs: number
95
+ try { mtimeMs = fs.statSync(file).mtimeMs } catch { return null }
96
+ const hit = _sidecarCache.get(file)
97
+ if (hit && hit.mtimeMs === mtimeMs) return hit.sidecar
98
+ let sidecar: CarriedSidecar | null = null
99
+ try {
100
+ // A file without an `exports` object is not a sidecar this plugin wrote: treat
101
+ // it as absent, so the inline literal is looked for and a missing one reports.
102
+ const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf8'))
103
+ const exports = typeof parsed === 'object' && parsed !== null ? (parsed as { exports?: unknown }).exports : undefined
104
+ if (typeof exports === 'object' && exports !== null) sidecar = parsed as CarriedSidecar
105
+ } catch { /* unreadable: no sidecar */ }
106
+ _sidecarCache.set(file, { mtimeMs, sidecar })
107
+ return sidecar
108
+ }
109
+
66
110
  export type ParsecraftPluginOptions = {
67
111
  /** Extra module specifiers to treat as parseman re-exports */
68
112
  moduleAliases?: string[]
@@ -87,10 +131,59 @@ export type ParsecraftPluginOptions = {
87
131
  /** Emit grammar-coverage hooks in macro output. Off by default, so ordinary
88
132
  * macro output remains byte-identical. */
89
133
  grammarCoverage?: boolean
134
+ /**
135
+ * OPTIONAL TUNING for selective assemblies. **Off by default.**
136
+ *
137
+ * A grammar gets generated code only for its hottest sites, within a byte budget.
138
+ * Without this option the build ranks sites by a static estimate. With it, the
139
+ * build parses `files` from rule `entry` on the closure engine (nothing is
140
+ * generated to measure) and ranks sites by measured calls per byte instead. It
141
+ * changes which sites are generated, never what a parse returns.
142
+ */
143
+ assemblyProfile?: AssemblyProfile
144
+ /**
145
+ * Generated assembly bytes each module may spend, as a multiple of its own source
146
+ * length. **Default `0.25`.** `0` ships no assembly. The right number is
147
+ * per grammar: a larger one parses faster in steady state and costs import and
148
+ * first-parse time, so measure both, and keep the artifact within 5× its source
149
+ * (docs/design/runtime-and-size-contract.md, rules 4 and 6).
150
+ */
151
+ assemblyBudget?: number
152
+ }
153
+
154
+ /** A corpus a build parses to rank selective-assembly sites — see `assemblyProfile`. */
155
+ export type AssemblyProfile = {
156
+ /** The rule each corpus file is parsed from, e.g. `'Stylesheet'`. A grammar
157
+ * without this rule keeps the static ranking. */
158
+ readonly entry: string
159
+ /** Paths of representative inputs. */
160
+ readonly files: readonly string[]
161
+ }
162
+
163
+ /** Corpus file contents, re-read when a file's mtime moves, so a watch build that
164
+ * re-lowers after a corpus edit ranks by the edited corpus. */
165
+ const corpusCache = new Map<string, { readonly mtimeMs: number; readonly text: string }>()
166
+ function corpusTexts(profile: AssemblyProfile): readonly string[] {
167
+ return profile.files.map(file => {
168
+ const mtimeMs = fs.statSync(file).mtimeMs
169
+ const hit = corpusCache.get(file)
170
+ if (hit !== undefined && hit.mtimeMs === mtimeMs) return hit.text
171
+ const text = fs.readFileSync(file, 'utf8')
172
+ corpusCache.set(file, { mtimeMs, text })
173
+ return text
174
+ })
90
175
  }
91
176
 
92
177
  const PARSEMAN_MODULE = 'parseman'
93
178
 
179
+ /**
180
+ * The default `assemblyBudget`: generated assembly bytes a module may spend, as a
181
+ * multiple of its own source length. A quarter of the source keeps every jess
182
+ * grammar far under the 5x ceiling (contract rule 4); the 0.57.0 CHANGELOG has the
183
+ * measured curve.
184
+ */
185
+ const DEFAULT_ASSEMBLY_BUDGET = 0.25
186
+
94
187
  /**
95
188
  * The REASON a lowering refused, appended to the warning that reports it.
96
189
  *
@@ -269,38 +362,142 @@ function parseModuleCached(filePath: string): ParsedModule | null {
269
362
  return parsed
270
363
  }
271
364
 
272
- export default createUnplugin((opts: ParsecraftPluginOptions = {}) => ({
273
- name: 'parseman',
274
- // Run BEFORE the bundler's TS/JS transform — otherwise esbuild (Vite) strips
275
- // the `with { type: 'macro' }` import attribute before we ever see it, and the
276
- // macro silently never fires.
277
- enforce: 'pre' as const,
278
-
279
- transformInclude(id: string) {
280
- return /\.[jt]sx?$/.test(id) && !id.includes('node_modules')
281
- },
282
-
283
- transform(this: { warn?: (msg: string) => void }, code: string, id: string) {
284
- if (!code.includes('parseman')) return null
285
- if (!code.includes('macro')) return null
286
- const moduleAliases = new Set([PARSEMAN_MODULE, ...(opts.moduleAliases ?? [])])
287
- const result = transformMacro(code, id, moduleAliases, opts.warnUnloweredRegex === true, opts.recovery === true, opts.grammarCoverage === true)
288
- if (result?.warnings.length) {
289
- for (const w of result.warnings) {
290
- if (typeof this?.warn === 'function') this.warn(`[parseman] ${w}`)
291
- else console.warn(`[parseman] ${w}`)
292
- }
365
+ /** The rollup-family hook that writes each chunk's carried pieces beside it. */
366
+ type SidecarBundle = Record<string, { type: string; fileName: string; exports?: string[]; moduleIds?: string[]; facadeModuleId?: string | null }>
367
+ type SidecarEmitter = {
368
+ emitFile: (file: { type: 'asset'; fileName: string; source: string }) => unknown
369
+ getModuleInfo: (id: string) => { code: string | null } | null
370
+ resolve: (source: string, importer?: string) => Promise<{ id: string; external?: boolean | string } | null>
371
+ }
372
+
373
+ /**
374
+ * Where a module's export `exported` comes from when the module re-exports it,
375
+ * possibly renamed: `export { base as renamed } from './grammar'`, or an imported
376
+ * binding exported under another name. Null when the module declares it itself.
377
+ */
378
+ function reexportSource(body: AnyNode[], exported: string): { source: string; imported: string } | null {
379
+ for (const st of body) {
380
+ if (st.type !== 'ExportNamedDeclaration') continue
381
+ for (const sp of (st.specifiers as AnyNode[] | undefined) ?? []) {
382
+ const exp = sp.exported as { name?: string; value?: string } | undefined
383
+ if ((exp?.name ?? exp?.value) !== exported) continue
384
+ const local = sp.local as { name?: string; value?: string } | undefined
385
+ const name = local?.name ?? local?.value
386
+ if (name === undefined) return null
387
+ const from = (st.source as { value?: string } | null | undefined)?.value
388
+ return from !== undefined ? { source: from, imported: name } : extractImportBindings(body).get(name) ?? null
293
389
  }
294
- if (!result) return null
295
- return { code: result.code, map: result.map }
296
- },
297
- }))
390
+ }
391
+ return null
392
+ }
393
+
394
+ /**
395
+ * The carried list behind an entry chunk's export, followed from the entry module
396
+ * through its re-exports to the module that recorded it, under the name that module
397
+ * exports it as. A chunk's export names are its entry module's, so an entry that
398
+ * renames a grammar (`export { base as renamed } from './grammar'`) still finds the
399
+ * pieces recorded as `base`.
400
+ */
401
+ async function followCarried(this: SidecarEmitter, sinks: Map<string, CarriedSink>, moduleId: string, name: string): Promise<CarriedSidecarItem[] | undefined> {
402
+ for (let hop = 0; hop < 64; hop++) {
403
+ const own = sinks.get(moduleId)?.get(name)
404
+ if (own !== undefined) return own
405
+ const code = this.getModuleInfo(moduleId)?.code
406
+ if (!code) return undefined
407
+ const parsed = parseSync(moduleId, code)
408
+ if (parsed.errors.length > 0) return undefined
409
+ const next = reexportSource(parsed.program.body as unknown as AnyNode[], name)
410
+ if (next === null) return undefined
411
+ const resolved = await this.resolve(next.source, moduleId)
412
+ if (resolved === null || resolved.external) return undefined
413
+ moduleId = resolved.id
414
+ name = next.imported
415
+ }
416
+ return undefined
417
+ }
418
+
419
+ /**
420
+ * Write `<chunk>.parseman.json` for every chunk that exports a grammar with carried
421
+ * pieces. An entry chunk's export is followed from its entry module to the module
422
+ * that recorded it (`followCarried`). Any other export is matched by name to the
423
+ * module that recorded it, preferring a module of the chunk itself; a name two
424
+ * modules of the bundle both record, and neither of them in the chunk, is ambiguous
425
+ * and is not written — a downstream compose of it then fails its build naming the
426
+ * artifact.
427
+ */
428
+ async function emitCarriedSidecars(this: SidecarEmitter, sinks: Map<string, CarriedSink>, bundle: SidecarBundle): Promise<void> {
429
+ const recorders = new Map<string, string[]>()
430
+ for (const [moduleId, sink] of sinks) for (const name of sink.keys()) (recorders.get(name) ?? recorders.set(name, []).get(name)!).push(moduleId)
431
+ for (const chunk of Object.values(bundle)) {
432
+ if (chunk.type !== 'chunk') continue
433
+ const exports: Record<string, CarriedSidecarItem[]> = {}
434
+ for (const name of chunk.exports ?? []) {
435
+ const followed = chunk.facadeModuleId ? await followCarried.call(this, sinks, chunk.facadeModuleId, name) : undefined
436
+ if (followed !== undefined) { exports[name] = followed; continue }
437
+ const ids = recorders.get(name)
438
+ if (ids === undefined) continue
439
+ const own = ids.filter(m => chunk.moduleIds?.includes(m))
440
+ const moduleId = own.length === 1 ? own[0] : own.length === 0 && ids.length === 1 ? ids[0] : undefined
441
+ if (moduleId !== undefined) exports[name] = sinks.get(moduleId)!.get(name)!
442
+ }
443
+ if (Object.keys(exports).length === 0) continue
444
+ const sidecar: CarriedSidecar = { parseman: PARSEMAN_VERSION, exports }
445
+ this.emitFile({ type: 'asset', fileName: chunk.fileName + CARRIED_SIDECAR_SUFFIX, source: JSON.stringify(sidecar) })
446
+ }
447
+ }
448
+
449
+ const SIDECAR_FRAMEWORKS = new Set(['rollup', 'rolldown', 'vite'])
450
+
451
+ export default createUnplugin((opts: ParsecraftPluginOptions = {}, meta) => {
452
+ // Module id → its carried lists, under a bundler that writes them beside the chunk.
453
+ // Any other bundler gets them inline, as before.
454
+ const sinks = SIDECAR_FRAMEWORKS.has(meta.framework) ? new Map<string, CarriedSink>() : undefined
455
+ const generateBundle = sinks === undefined ? undefined : {
456
+ generateBundle(this: SidecarEmitter, _options: unknown, bundle: SidecarBundle) { return emitCarriedSidecars.call(this, sinks, bundle) },
457
+ }
458
+ return {
459
+ name: 'parseman',
460
+ ...(generateBundle === undefined ? {} : { rollup: generateBundle, rolldown: generateBundle, vite: generateBundle }),
461
+ // Run BEFORE the bundler's TS/JS transform — otherwise esbuild (Vite) strips
462
+ // the `with { type: 'macro' }` import attribute before we ever see it, and the
463
+ // macro silently never fires.
464
+ enforce: 'pre' as const,
465
+
466
+ transformInclude(id: string) {
467
+ return /\.[jt]sx?$/.test(id) && !id.includes('node_modules')
468
+ },
469
+
470
+ transform(this: { warn?: (msg: string) => void; addWatchFile?: (file: string) => void }, code: string, id: string) {
471
+ if (!code.includes('parseman')) return null
472
+ if (!code.includes('macro')) return null
473
+ // A corpus edit changes which sites a profiled build generates, so a watch
474
+ // build re-lowers when one does.
475
+ for (const file of opts.assemblyProfile?.files ?? []) this?.addWatchFile?.(file)
476
+ const moduleAliases = new Set([PARSEMAN_MODULE, ...(opts.moduleAliases ?? [])])
477
+ const sink: CarriedSink | undefined = sinks && new Map()
478
+ const result = transformMacro(code, id, moduleAliases, opts.warnUnloweredRegex === true, opts.recovery === true, opts.grammarCoverage === true, opts.assemblyProfile, opts.assemblyBudget, sink)
479
+ if (sinks && sink) {
480
+ if (sink.size > 0) sinks.set(id, sink)
481
+ else sinks.delete(id)
482
+ }
483
+ if (result?.warnings.length) {
484
+ for (const w of result.warnings) {
485
+ if (typeof this?.warn === 'function') this.warn(`[parseman] ${w}`)
486
+ else console.warn(`[parseman] ${w}`)
487
+ }
488
+ }
489
+ if (!result) return null
490
+ return { code: result.code, map: result.map }
491
+ },
492
+ }
493
+ })
298
494
 
299
495
  // ---------------------------------------------------------------------------
300
496
  // Reading carried pieces back out of a COMPILED grammar module
301
497
  // ---------------------------------------------------------------------------
302
- // The macro embeds a grammar's linkable pieces inside its exported const's
303
- // initializer (see `withCarriedPieces`). To compose an imported grammar we parse
498
+ // Under a rollup-family bundler the pieces are in the sidecar beside the module
499
+ // (`readCarriedSidecar`). Otherwise the macro embeds them inside its exported const's
500
+ // initializer (see `withCarriedPieces`), and to compose an imported grammar we parse
304
501
  // its compiled module, find that const, and pull the pieces literal back out —
305
502
  // these three pure helpers do exactly that (no runtime, no source recompile).
306
503
 
@@ -544,10 +741,13 @@ export function transformMacro(
544
741
  warnUnloweredRegex = false,
545
742
  recovery = false,
546
743
  grammarCoverage = false,
744
+ assemblyProfile?: AssemblyProfile,
745
+ assemblyBudget = DEFAULT_ASSEMBLY_BUDGET,
746
+ carriedSink?: CarriedSink,
547
747
  ): TransformMacroResult | null {
548
748
  const depth = degradationCaptureDepth()
549
749
  try {
550
- return transformMacroImpl(code, id, moduleAliases, warnUnloweredRegex, recovery, grammarCoverage)
750
+ return transformMacroImpl(code, id, moduleAliases, warnUnloweredRegex, recovery, grammarCoverage, assemblyProfile, assemblyBudget, carriedSink)
551
751
  } finally {
552
752
  setReducerResolver(null)
553
753
  setBuilderImportResolver(null)
@@ -564,6 +764,9 @@ function transformMacroImpl(
564
764
  warnUnloweredRegex: boolean,
565
765
  recovery: boolean,
566
766
  grammarCoverage: boolean,
767
+ assemblyProfile: AssemblyProfile | undefined,
768
+ assemblyBudgetShare: number,
769
+ carriedSink: CarriedSink | undefined,
567
770
  ): TransformMacroResult | null {
568
771
  let result: ReturnType<typeof parseSync>
569
772
  try {
@@ -1595,6 +1798,23 @@ function transformMacroImpl(
1595
1798
  local = false,
1596
1799
  ): RawItem[] | null => {
1597
1800
  const { file } = module
1801
+ const fail = (why: string): null => { unresolved.why ??= `${exportName} in ${file}: ${why}`; return null }
1802
+ // A build-only sidecar wins over an inline literal: an artifact that has one
1803
+ // carries no literal (see `CarriedSidecarItem`).
1804
+ const sidecarItems = module.source || local ? undefined : readCarriedSidecar(file)?.exports[exportName]
1805
+ if (sidecarItems !== undefined) {
1806
+ const list: RawItem[] = []
1807
+ for (const item of sidecarItems) {
1808
+ if ('ir' in item) { list.push(item); continue }
1809
+ const sub = resolveGrammarModule(file, item.from)
1810
+ if (sub === null) return fail(`its carried pieces name \`${item.name}\` from '${item.from}', a module that could not be found`)
1811
+ if (seen.has(sub.file)) continue
1812
+ const pieces = resolveModulePieces(sub, item.name, new Set(seen).add(sub.file), unresolved)
1813
+ if (pieces === null) return fail(`its carried pieces name \`${item.name}\` from '${item.from}', which could not be resolved`)
1814
+ list.push(...pieces)
1815
+ }
1816
+ return list
1817
+ }
1598
1818
  const mod = module.source
1599
1819
  ? lowerPrivateSourceModule(file, moduleAliases, warnUnloweredRegex, recovery)
1600
1820
  : parseModuleCached(file)
@@ -1602,9 +1822,15 @@ function transformMacroImpl(
1602
1822
  const localFor = local ? exportName : exportLocalName(mod.body as AnyNode[], exportName)
1603
1823
  const initNode = localFor ? topLevelInit(mod.body as AnyNode[], localFor) : null
1604
1824
  const literalRange = initNode ? findCarriedPiecesLiteral(initNode) : null
1605
- if (!literalRange) return null
1825
+ if (!literalRange) {
1826
+ // A compiled grammar with neither a literal nor a sidecar entry was built to be
1827
+ // composed or it was not, and a runtime compose() cannot link it either way.
1828
+ return initNode !== null && !module.source && mod.src.slice(initNode.start, initNode.end).includes('tableRules(')
1829
+ ? fail(`is a compiled grammar with no carried pieces — no inline literal, and no entry in ${path.basename(file)}${CARRIED_SIDECAR_SUFFIX}. `
1830
+ + 'Rebuild it with this parseman, and keep that file beside the artifact')
1831
+ : null
1832
+ }
1606
1833
  const literal = mod.src.slice(literalRange.start, literalRange.end)
1607
- const fail = (why: string): null => { unresolved.why ??= `${exportName} in ${file}: ${why}`; return null }
1608
1834
 
1609
1835
  const imports = extractImportBindings(mod.body as AnyNode[])
1610
1836
  const stubNames: string[] = []
@@ -1675,6 +1901,32 @@ function transformMacroImpl(
1675
1901
  return result
1676
1902
  }
1677
1903
 
1904
+ /**
1905
+ * Record an exported grammar's carried list in the build-only sink rather than
1906
+ * emitting it into the runtime artifact. `false` when there is no sink, or when an
1907
+ * entry cannot be written as data; the caller then emits it inline, as before.
1908
+ *
1909
+ * A package import stays a reference, resolved from the artifact's location just as
1910
+ * the import itself is, so a descendant never re-serializes its ancestor. A relative
1911
+ * import names a module the bundler folds into this chunk, which has no artifact of
1912
+ * its own to point at, so its pieces are written out here.
1913
+ */
1914
+ const sinkCarried = (name: string, list: CarriedItem[]): boolean => {
1915
+ if (carriedSink === undefined) return false
1916
+ const out: CarriedSidecarItem[] = []
1917
+ for (const it of list) {
1918
+ if (!isSpread(it)) { out.push(it); continue }
1919
+ const b = importBindings.get(it.__spreadLocal)
1920
+ if (b === undefined) return false
1921
+ if (!b.source.startsWith('.') && !path.isAbsolute(b.source)) { out.push({ from: b.source, name: b.imported }); continue }
1922
+ const pieces = importedPieces(it.__spreadLocal)
1923
+ if (pieces === null) return false
1924
+ out.push(...pieces)
1925
+ }
1926
+ carriedSink.set(name, out)
1927
+ return true
1928
+ }
1929
+
1678
1930
  /**
1679
1931
  * Evaluate a carried list back to combinators, one entry per piece: its rule map
1680
1932
  * and the `rules()` recipe it was built from. `compose()` links the recipes into
@@ -1984,9 +2236,10 @@ function transformMacroImpl(
1984
2236
  carried,
1985
2237
  reflection: compiled.reflection,
1986
2238
  })
2239
+ const select = selectAssembly(init.start, 'compose()', compiled, !declaresLeaf())
1987
2240
  return {
1988
- replacement: compiled.replacementWithMetadata(reflectionMetadata),
1989
- exportedReplacement: compiled.replacementWithMetadata(exportedMetadata),
2241
+ replacement: assembledReplacement(compiled, reflectionMetadata, select),
2242
+ exportedReplacement: assembledReplacement(compiled, exportedMetadata, select),
1990
2243
  carried,
1991
2244
  ...(composing ? { trivia: composing } : {}),
1992
2245
  ...(importedFactories.length ? { importedFactories } : {}),
@@ -2006,6 +2259,74 @@ function transformMacroImpl(
2006
2259
  return null
2007
2260
  }
2008
2261
 
2262
+ /**
2263
+ * SELECTIVE ASSEMBLY, BUDGETED PER MODULE. A fused grammar's default AST/no-lines
2264
+ * table carries generated code for its hottest sites only (`select-hot.ts`); the
2265
+ * closure engine runs the rest. The site bodies of every fused grammar in this
2266
+ * module together may not exceed `assemblyBudget` times the module's own source.
2267
+ *
2268
+ * A module that declares a `composeLeaf` has named its terminal parser, so its
2269
+ * `compose()` results are composable PIECES: another build re-lowers them and
2270
+ * never parses through their table, so they spend nothing. Without a
2271
+ * `composeLeaf`, a `compose()` result is the module's parser.
2272
+ */
2273
+ let assemblyBudget = Math.floor(code.length * assemblyBudgetShare)
2274
+ let leafDeclared: boolean | undefined
2275
+ const declaresLeaf = (): boolean => leafDeclared ??= (body as Statement[]).some(stmt => {
2276
+ const vd = unwrapVd(stmt)
2277
+ return vd !== null && vd.declarations.some(d => d.init != null && isComposeLeafCall(d.init as Expression))
2278
+ })
2279
+ const selectAssembly = (
2280
+ pos: number,
2281
+ label: string,
2282
+ compiled: CompiledRuleMapTable,
2283
+ terminal: boolean,
2284
+ ): ReadonlySet<number> => {
2285
+ // A coverage build instruments the table; its counters are not the shipping
2286
+ // parser, so it carries no assembly.
2287
+ if (!terminal || grammarCoverage) return new Set()
2288
+ const { sites, bytes, refusal } = compiled.selectAssembly(assemblyBudget, profileOf(compiled))
2289
+ // The artifact still parses, on the closure engine, but a build that would
2290
+ // have generated code says which construct stopped it rather than fall back
2291
+ // silently. Not `warn()`: every declaration still lowers, so the module stays
2292
+ // fully resolved.
2293
+ if (refusal !== undefined) {
2294
+ warnings.push(`${id}:${lineOf(pos)} — ${label}: its precompiled assembly cannot emit ${refusal}; the artifact runs on the closure engine`)
2295
+ }
2296
+ assemblyBudget -= bytes
2297
+ return sites
2298
+ }
2299
+ /** Measured site calls over the optional corpus, or `undefined` for the static
2300
+ * ranking. A file whose parse throws still contributes the calls it made. */
2301
+ const profileOf = (compiled: CompiledRuleMapTable): Int32Array | undefined => {
2302
+ // Only the default AST/no-lines table carries an assembly, so only it is profiled.
2303
+ if (assemblyProfile === undefined || compiled.hostMode !== 'ast' || compiled.prog.lines === 1
2304
+ || !(assemblyProfile.entry in compiled.prog.rules)) return undefined
2305
+ let parsed = 0
2306
+ // A builder re-lowered from carried IR is inert at build time; for COUNTING
2307
+ // only, a plain node stands in for it, so the corpus can run past it.
2308
+ const fns = compiled.prog.fns.map(fn => {
2309
+ const type = (fn as { [INERT_BUILDER]?: string } | null)?.[INERT_BUILDER]
2310
+ return type === undefined ? fn
2311
+ : (children: unknown, _fields: unknown, span: unknown) => ({ type, span, children })
2312
+ })
2313
+ const counts = countSiteVisits(compiled.prog, rules => {
2314
+ const entry = rules[assemblyProfile.entry]!
2315
+ for (const text of corpusTexts(assemblyProfile)) {
2316
+ try { if (run(entry, text).ok) parsed++ } catch { /* the calls it made still count */ }
2317
+ }
2318
+ }, fns)
2319
+ if (parsed > 0) return counts
2320
+ // Not `warn()`: nothing fell back to the interpreter — the build is only untuned.
2321
+ warnings.push(`${id} — assemblyProfile: no corpus file parsed from rule "${assemblyProfile.entry}"; using the static ranking`)
2322
+ return undefined
2323
+ }
2324
+ /** The fused table's replacement, with its selective default assembly if any. */
2325
+ const assembledReplacement = (compiled: CompiledRuleMapTable, metadata: string, select: ReadonlySet<number>): string =>
2326
+ select.size === 0
2327
+ ? compiled.replacementWithMetadata(metadata)
2328
+ : supercompileRuleMapReplacement(compiled.prog, compiled.replacementWithMetadata(metadata, { select }), select)
2329
+
2009
2330
  /**
2010
2331
  * Compile a terminal composition. Imported/base pieces still travel as normal
2011
2332
  * re-lowerable IR, but the final local rules map is compiled directly in this
@@ -2136,12 +2457,10 @@ function transformMacroImpl(
2136
2457
  if (compiled !== null) {
2137
2458
  usedTableRuntime = true
2138
2459
  return {
2139
- replacement: supercompileRuleMapReplacement(
2140
- compiled.prog,
2141
- compiled.replacementWithMetadata(
2142
- staticTableMetadataSource({ reflection: compiled.reflection, leaf: true }),
2143
- { precompileDefault: true },
2144
- ),
2460
+ replacement: assembledReplacement(
2461
+ compiled,
2462
+ staticTableMetadataSource({ reflection: compiled.reflection, leaf: true }),
2463
+ selectAssembly(init.start, 'composeLeaf()', compiled, true),
2145
2464
  ),
2146
2465
  ...(importedFactories.length ? { importedFactories } : {}),
2147
2466
  }
@@ -2368,6 +2687,7 @@ function transformMacroImpl(
2368
2687
  else warn(init.start, `${varName}: exported grammar could not be serialized to IR, so it carries no `
2369
2688
  + 'composable pieces — a downstream compose() of this grammar will fall back to runtime. '
2370
2689
  + 'Re-run with PARSEMAN_IR_DEBUG=1 to print the combinator that blocked serialization.')
2690
+ if (carriedMetadata !== undefined && sinkCarried(varName, carriedMetadata)) carriedMetadata = undefined
2371
2691
  }
2372
2692
  const replacement = compiledRules.replacement !== null
2373
2693
  ? compiledRules.replacementWithMetadata!(staticTableMetadataSource({
@@ -2403,7 +2723,7 @@ function transformMacroImpl(
2403
2723
  // grammar via `import { <name> }` (re-composition, no source) and re-lower it
2404
2724
  // under ITS composing trivia.
2405
2725
  localComposedCarried.set(varName, fused.carried)
2406
- const replacement = exportPrefix ? fused.exportedReplacement : fused.replacement
2726
+ const replacement = exportPrefix && !sinkCarried(varName, fused.carried) ? fused.exportedReplacement : fused.replacement
2407
2727
  // Coverage definitions already ride in the table program's `cv` pool;
2408
2728
  // `tableRules` exposes that same pool through the metadata prototype.
2409
2729
  replacements.push({ start: init.start, end: init.end, replacement })
@@ -27,6 +27,9 @@ import { adjacent, notAdjacent } from '../combinators/adjacency.ts'
27
27
 
28
28
  type Comb = Combinator<unknown>
29
29
 
30
+ /** Marks a carried direct builder that cannot run at build time; its value is the node type. */
31
+ export const INERT_BUILDER: unique symbol = Symbol('parseman.inertBuilder')
32
+
30
33
  /** Reconstruct a rule map from serialized IR (the inverse of `serializeRuleMap`) —
31
34
  * evaluate the combinator-construction expression with every constructor in scope.
32
35
  *
@@ -88,7 +91,10 @@ function evalIR(ir: string, rulesImpl: unknown): unknown {
88
91
  // carries the callback source. The compiler is the only consumer that may
89
92
  // materialize `buildSrc`; raw IR interpretation deliberately rejects direct
90
93
  // builders rather than evaluating arbitrary captured source at runtime.
91
- const n = node(type, child as never, (() => { throw new Error('IR node build requires static re-lowering') }) as never, opts as never)
94
+ const inert = (() => { throw new Error('IR node build requires static re-lowering') }) as (() => never) & { [INERT_BUILDER]?: string }
95
+ // Named, so a build-time profile run can stand a plain node in for it.
96
+ inert[INERT_BUILDER] = type
97
+ const n = node(type, child as never, inert as never, opts as never)
92
98
  ;(n._def as { buildSrc?: string; buildStaticError?: readonly string[] }).buildSrc = src
93
99
  if (staticError !== undefined) (n._def as { buildStaticError?: readonly string[] }).buildStaticError = staticError
94
100
  // Analysis-only resolved reducer signature (see `buildAnalysisSrc`). Without it a
@@ -63,6 +63,9 @@ export function literalCodePoints(frag: string): number[] | null {
63
63
  if (ch === '\\') {
64
64
  const e = frag[i + 1]
65
65
  if (e === undefined) return null
66
+ // Any other word-character escape is a class (`\d`), an assertion (`\b`), a
67
+ // backreference or a hex, unicode or control escape, never the character itself.
68
+ if (!(e in CLASS_ESCAPES) && /\w/.test(e)) return null
66
69
  out.push(e in CLASS_ESCAPES ? CLASS_ESCAPES[e]! : e.codePointAt(0)!)
67
70
  i += 2
68
71
  continue