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
@@ -23,12 +23,12 @@ import { createUnplugin } from 'unplugin'
23
23
  import { parseSync } from 'oxc-parser'
24
24
  import { ResolverFactory } from 'oxc-resolver'
25
25
  import MagicString from 'magic-string'
26
- import { evaluateExpr, evaluateCombinatorArray, evaluateParserFactory, evaluateStaticValue, evaluateWordFactory, evaluateWhenFactory, evaluateRefDeclaration, applyDefineStatement, referencesAny, setReducerResolver, setBuilderImportResolver, propName, type Scope, type ScopeEntry } from './evaluator.ts'
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
29
  import { compileRuleMap } from '../table/compile-rule-map.ts'
30
30
  import { compileLinkableTable } from '../compiler/compile-linkable-table.ts'
31
- import { createReducerResolver } from './reducer-resolver.ts'
31
+ import { createReducerResolver, topLevelIsStable } from './reducer-resolver.ts'
32
32
  import { findFreeIdentifiers } from './free-identifiers.ts'
33
33
  import { supercompileRuleMapReplacement } from './entry-supercompiler.ts'
34
34
  import {
@@ -37,7 +37,9 @@ import {
37
37
  } from '../compiler/degradation.ts'
38
38
  import type { HostMode } from '../cst/host-mode.ts'
39
39
  import { COMPOSED_PIECES } from '../compiler/linker.ts'
40
- import { evalRuleMapIR, serializeRuleMap } from '../compiler/ir-serialize.ts'
40
+ import { serializeCombinator, serializeRuleMap } from '../compiler/ir-serialize.ts'
41
+ import { evalCombinatorIR, evalRulesIR } from './ir-eval.ts'
42
+ import { linkRules, RULES_RECIPE, type LinkOptions, type RulesRecipe } from '../combinators/parser.ts'
41
43
  import { PARSEMAN_VERSION } from '../version.ts'
42
44
  import { grammarReflectionSource, type GrammarReflection } from '../cst/reflection.ts'
43
45
  import { createHash } from 'node:crypto'
@@ -341,14 +343,44 @@ function topLevelInit(body: AnyNode[], name: string): AnyNode | null {
341
343
  return null
342
344
  }
343
345
 
344
- /** `Symbol.for('parseman.composedPieces')` ? */
345
- function isComposedPiecesSymbol(n: AnyNode | undefined): boolean {
346
+ /** `Symbol.for('<key>')` ? */
347
+ function isSymbolFor(n: AnyNode | undefined, key: string): boolean {
346
348
  if (!n || n.type !== 'CallExpression') return false
347
349
  const callee = n.callee as AnyNode | undefined
348
350
  const obj = (callee?.object as { name?: string } | undefined)?.name
349
351
  const prop = (callee?.property as { name?: string } | undefined)?.name
350
352
  const arg0 = (n.arguments as AnyNode[] | undefined)?.[0] as { value?: unknown } | undefined
351
- return obj === 'Symbol' && prop === 'for' && arg0?.value === 'parseman.composedPieces'
353
+ return obj === 'Symbol' && prop === 'for' && arg0?.value === key
354
+ }
355
+ const isComposedPiecesSymbol = (n: AnyNode | undefined): boolean => isSymbolFor(n, 'parseman.composedPieces')
356
+
357
+ /**
358
+ * The key a compiled terminal carries its combinator IR under — see `withCombinatorIR`.
359
+ * EMITTED SOURCE TEXT, like the composed-pieces key: the runtime never reads it, only a
360
+ * downstream macro build does, by finding it in the artifact's AST.
361
+ */
362
+ const COMBINATOR_IR_KEY = 'parseman.combinatorIR'
363
+
364
+ /** A compiled top-level const initializer's carried combinator IR, or null.
365
+ * The shape is exactly what `withCombinatorIR` emits:
366
+ * `Object.defineProperty(<parser>, Symbol.for('parseman.combinatorIR'), { value: '<ir>', … })`. */
367
+ function carriedCombinatorIR(init: AnyNode): string | null {
368
+ let n = init
369
+ while (n.type === 'ParenthesizedExpression') n = n.expression as AnyNode
370
+ if (n.type !== 'CallExpression') return null
371
+ const callee = n.callee as AnyNode
372
+ const args = n.arguments as AnyNode[]
373
+ if ((callee.object as { name?: string } | undefined)?.name !== 'Object'
374
+ || (callee.property as { name?: string } | undefined)?.name !== 'defineProperty'
375
+ || !isSymbolFor(args[1], COMBINATOR_IR_KEY)) return null
376
+ for (const p of (args[2]?.properties as AnyNode[] | undefined) ?? []) {
377
+ const v = p.value as { type?: string; value?: unknown; expressions?: unknown[]; quasis?: Array<{ value: { cooked?: string | null } }> } | undefined
378
+ if (propName(p as never) !== 'value') continue
379
+ if (v?.type === 'Literal' && typeof v.value === 'string') return v.value
380
+ // A bundler may requote it as a template (esbuild does when it holds both quote kinds).
381
+ if (v?.type === 'TemplateLiteral' && v.expressions?.length === 0) return v.quasis?.[0]?.value.cooked ?? null
382
+ }
383
+ return null
352
384
  }
353
385
 
354
386
  /** Walk an initializer subtree for the `Object.defineProperty(_,
@@ -367,9 +399,10 @@ function findCarriedPiecesLiteral(root: AnyNode): { start: number; end: number }
367
399
  const args = node.arguments as AnyNode[] | undefined
368
400
  // Table lowering: `tableRules(program, { [Symbol.for('…composedPieces')]:
369
401
  // <literal>, … })`. The metadata object becomes the returned map's
370
- // prototype, so the symbol remains readable without becoming an own key.
402
+ // prototype, so the symbol remains readable without becoming an own key. The
403
+ // callee's NAME is not checked: a bundler renames a clashing import
404
+ // (`tableRules2`), and the computed key below is what identifies the literal.
371
405
  if ((callee as { type?: string; name?: string } | undefined)?.type === 'Identifier'
372
- && (callee as { name?: string }).name === 'tableRules'
373
406
  && (args?.[1] as AnyNode | undefined)?.type === 'ObjectExpression') {
374
407
  for (const p of ((args![1] as AnyNode).properties as AnyNode[] | undefined) ?? []) {
375
408
  if ((p as { computed?: boolean }).computed && isComposedPiecesSymbol(p.key as AnyNode) && p.value) {
@@ -555,16 +588,17 @@ function transformMacroImpl(
555
588
  specifiers: Array<{ type: string; local: string; start: number; end: number }>
556
589
  }> = []
557
590
 
591
+ // A macro import is a parseman-alias import carrying `with { type: 'macro' }`
592
+ // (oxc exposes this as ImportDeclaration.attributes).
593
+ const isMacroImport = (s: ImportDeclaration): boolean => s.attributes.some(a => {
594
+ const key = a.key.type === 'Identifier' ? a.key.name : String(a.key.value)
595
+ return key === 'type' && a.value.value === 'macro'
596
+ })
558
597
  for (const stmt of body) {
559
598
  if (stmt.type !== 'ImportDeclaration') continue
560
599
  const s = stmt as ImportDeclaration
561
600
 
562
- // A macro import is a parseman-alias import carrying `with { type: 'macro' }`
563
- // (oxc exposes this as ImportDeclaration.attributes).
564
- const isMacro = moduleAliases.has(s.source.value) && s.attributes.some(a => {
565
- const key = a.key.type === 'Identifier' ? a.key.name : String(a.key.value)
566
- return key === 'type' && a.value.value === 'macro'
567
- })
601
+ const isMacro = moduleAliases.has(s.source.value) && isMacroImport(s)
568
602
 
569
603
  if (!isMacro) {
570
604
  const specifiers: Array<{ type: string; local: string; start: number; end: number }> = []
@@ -670,8 +704,265 @@ function transformMacroImpl(
670
704
  setReducerResolver(reducerResolver, code)
671
705
  /* A direct builder's free name is rescuable when THIS module imported it — the
672
706
  * artifact carries `{ source, imported }` and a downstream compose() re-emits the
673
- * import (see the re-lower pass below). Module-private consts stay refusals. */
674
- setBuilderImportResolver(name => importBindings.get(name) ?? null)
707
+ * import (see the re-lower pass below). Module-private consts stay refusals.
708
+ * While a terminal of ANOTHER module is evaluated, its own imports answer instead
709
+ * (`moduleConstValue`), so its builder's helpers are re-emitted here. */
710
+ let builderImports = (name: string): { source: string; imported: string } | null => importBindings.get(name) ?? null
711
+ setBuilderImportResolver(name => builderImports(name))
712
+ const ownRoot = nearestPackageRoot(id)
713
+ let ownDir = path.dirname(id)
714
+ try { ownDir = fs.realpathSync(ownDir) } catch { /* a virtual module: keep its path */ }
715
+ /** `spec`, imported by `from`, as THIS module must write it; null when it can't: a
716
+ * relative import of another package names a file this one has no stable path to. */
717
+ const rebaseSpecifier = (from: string, spec: string): string | null => {
718
+ if (!spec.startsWith('.')) return spec
719
+ const target = path.resolve(path.dirname(from), spec)
720
+ if (ownRoot === null || nearestPackageRoot(target) !== ownRoot) return null
721
+ const rel = path.relative(ownDir, target).split(path.sep).join('/')
722
+ return rel.startsWith('../') ? rel : `./${rel}`
723
+ }
724
+
725
+ /*
726
+ * IMPORTED VALUES — a terminal, a skip set, an options object or a boundary string
727
+ * declared in ANOTHER module and used where the macro evaluates a grammar: a rule
728
+ * body, a combinator's arguments, a `rules({ trivia, scanSkip })` option.
729
+ *
730
+ * Before this, an imported name evaluated to `null`. In a rule body that made the
731
+ * factory "not statically evaluable"; in an option it was worse — the option became
732
+ * `undefined` and the build succeeded without it. So every grammar family re-declared
733
+ * its own copy of each shared terminal, because only a whole `rules()` map could
734
+ * cross a module boundary (as `compose()` pieces).
735
+ *
736
+ * Resolution reads the exporting module, and never runs it:
737
+ * - a relative import resolves to its SOURCE (`resolvePrivateSourceModule`, the
738
+ * same rule imported factories use), whose declaration is evaluated like a local;
739
+ * - a package import resolves to its PUBLISHED ENTRY, a compiled artifact, where a
740
+ * terminal survives only as a compiled parser. The macro therefore carries each
741
+ * such terminal's combinator IR on the exported value (`withCombinatorIR`), the
742
+ * way a grammar carries its composed pieces, and this reads it back. A plain
743
+ * value (a string, an options object, an array of terminals) is left verbatim by
744
+ * the macro and is evaluated as written.
745
+ *
746
+ * Values are rebuilt per transform, and within one transform two grammars returning
747
+ * the same value is safe: `rules()` stamps its options onto a rule's value, so it
748
+ * gives each grammar after the first an unstamped copy (`ownRule`, parser.ts).
749
+ *
750
+ * A binding that exists but cannot be resolved is recorded in `unresolvedImports`,
751
+ * and every place that fails for it fails the BUILD naming it (`failOnUnresolved`).
752
+ */
753
+ const unresolvedImports = new Set<string>()
754
+ type ValueModule = {
755
+ file: string
756
+ src: string
757
+ body: AnyNode[]
758
+ consts: Map<string, AnyNode>
759
+ imports: Map<string, { source: string; imported: string }>
760
+ opaqueImports: Set<string>
761
+ scope?: ModuleScope
762
+ }
763
+ const valueModules = new Map<string, ValueModule | null>()
764
+ const OPAQUE_IMPORT = 'is a default or namespace import, which the macro does not resolve — import the binding by name'
765
+ const opaqueImportsOf = (moduleBody: AnyNode[]): Set<string> => {
766
+ const out = new Set<string>()
767
+ for (const st of moduleBody) {
768
+ if (st.type !== 'ImportDeclaration') continue
769
+ for (const sp of (st as unknown as ImportDeclaration).specifiers) {
770
+ if (sp.type !== 'ImportSpecifier') out.add(sp.local.name)
771
+ }
772
+ }
773
+ return out
774
+ }
775
+ const valueModule = (file: string): ValueModule | null => {
776
+ if (valueModules.has(file)) return valueModules.get(file)!
777
+ const mod = parseModuleCached(file)
778
+ let out: ValueModule | null = null
779
+ if (mod) {
780
+ const consts = new Map<string, AnyNode>()
781
+ // A `let`/`var` counts when nothing reassigns it: a bundled entry declares its
782
+ // exports `var dq = …; export { dq }`.
783
+ let stable: ((name: string) => boolean) | undefined
784
+ for (const st of mod.body as Statement[]) {
785
+ const vd = unwrapVd(st)
786
+ if (!vd) continue
787
+ const isConst = (vd as unknown as { kind?: string }).kind === 'const'
788
+ for (const d of vd.declarations) {
789
+ const idn = d.id as unknown as { type?: string; name?: string }
790
+ if (idn.type !== 'Identifier' || !idn.name || !d.init) continue
791
+ if (isConst || (stable ??= topLevelIsStable(mod.body, mod.src.length))(idn.name)) consts.set(idn.name, d.init as unknown as AnyNode)
792
+ }
793
+ }
794
+ out = {
795
+ file,
796
+ src: mod.src,
797
+ body: mod.body as AnyNode[],
798
+ consts,
799
+ imports: extractImportBindings(mod.body as AnyNode[]),
800
+ opaqueImports: opaqueImportsOf(mod.body as AnyNode[]),
801
+ }
802
+ }
803
+ valueModules.set(file, out)
804
+ return out
805
+ }
806
+ const resolveValueFile = (from: string, specifier: string): string | null => {
807
+ const source = resolvePrivateSourceModule(from, specifier)
808
+ if (source) return source
809
+ try { return getCompiledResolver().resolveFileSync(from, specifier).path ?? null } catch { return null }
810
+ }
811
+ const evaluatingConsts = new Set<string>()
812
+ /** Rewrite each carried builder import in `combi`'s graph (written in `from`) to THIS
813
+ * module's spelling; the first that can't be is returned, for the refusal. */
814
+ const rebaseCarriedBuilderImports = (combi: unknown, from: string): { local: string; source: string } | null => {
815
+ const seen = new Set<unknown>()
816
+ const walk = (v: unknown): { local: string; source: string } | null => {
817
+ if (v === null || typeof v !== 'object' || seen.has(v)) return null
818
+ seen.add(v)
819
+ const def = (v as { _def?: { buildImports?: ReadonlyArray<{ local: string; source: string; imported: string }> } })._def
820
+ if (def?.buildImports) {
821
+ const out = []
822
+ for (const bi of def.buildImports) {
823
+ const source = rebaseSpecifier(from, bi.source)
824
+ if (source === null) return bi
825
+ out.push({ ...bi, source })
826
+ }
827
+ def.buildImports = out
828
+ }
829
+ for (const child of Object.values(v as Record<string, unknown>)) {
830
+ for (const c of Array.isArray(child) ? child : [child]) {
831
+ const stuck = walk(c)
832
+ if (stuck) return stuck
833
+ }
834
+ }
835
+ return null
836
+ }
837
+ return walk(combi)
838
+ }
839
+ /** Evaluate one top-level `const` of a foreign module, as the macro would locally. */
840
+ const moduleConstValue = (m: ValueModule, name: string): FreeNameResolution => {
841
+ const key = `${m.file}\0${name}`
842
+ if (evaluatingConsts.has(key)) return { unresolved: `is part of a declaration cycle in ${m.file}` }
843
+ evaluatingConsts.add(key)
844
+ const outerBuilderImports = builderImports
845
+ try {
846
+ const init = m.consts.get(name)! as unknown as Expression
847
+ const ir = carriedCombinatorIR(init as unknown as AnyNode)
848
+ if (ir !== null) {
849
+ let combi: Combinator<unknown> | null = null
850
+ let why = ''
851
+ try { combi = evalCombinatorIR(ir) } catch (e) { why = `: ${(e as Error).message}` }
852
+ if (!combi) return { unresolved: `carries combinator IR that does not evaluate (${m.file})${why}` }
853
+ const stuck = rebaseCarriedBuilderImports(combi, m.file)
854
+ return stuck
855
+ ? { unresolved: `has a node() builder reading \`${stuck.local}\` from '${stuck.source}' inside ${m.file}, which this module cannot import — import it there by package name` }
856
+ : { value: { combi, mfSrcs: [] } satisfies ScopeEntry }
857
+ }
858
+ const scope = moduleValueScope(m)
859
+ // Offsets inside this module's reducers must be answered against ITS scope tree.
860
+ reducerResolver.register(m.file)
861
+ // A builder's free names are THIS module's imports, re-spelled for the consumer.
862
+ builderImports = name => {
863
+ const b = m.imports.get(name)
864
+ const source = b ? rebaseSpecifier(m.file, b.source) : null
865
+ return b && source !== null ? { source, imported: b.imported } : null
866
+ }
867
+ const mfs: string[] = []
868
+ const combi = evaluateExpr(init, scope as unknown as Scope, m.src, mfs)
869
+ if (combi) return { value: { combi, mfSrcs: mfs } satisfies ScopeEntry }
870
+ const factory = evaluateWordFactory(init, scope as unknown as Scope, m.src) ?? evaluateWhenFactory(init, scope as unknown as Scope, m.src)
871
+ if (factory) return { value: factory }
872
+ const value = evaluateStaticValue(init, scope as unknown as Scope, m.src)
873
+ if (value !== null && value !== undefined || isStaticNullishExpression(init)) return { value }
874
+ return {
875
+ unresolved: /\btableRules\b/.test(m.src.slice(init.start, init.end))
876
+ ? `is a compiled parser in ${m.file} that carries no combinator IR — rebuild that module with parseman v${PARSEMAN_VERSION}`
877
+ : `is not a statically-known combinator or value (${m.file})`,
878
+ }
879
+ } finally {
880
+ evaluatingConsts.delete(key)
881
+ builderImports = outerBuilderImports
882
+ }
883
+ }
884
+ const moduleValueScope = (m: ValueModule): ModuleScope =>
885
+ m.scope ??= new ModuleScope(name => {
886
+ if (m.consts.has(name)) return moduleConstValue(m, name)
887
+ const binding = m.imports.get(name)
888
+ if (binding) return importedValue(m.file, binding)
889
+ return m.opaqueImports.has(name) ? { unresolved: OPAQUE_IMPORT } : null
890
+ }, unresolvedImports)
891
+ /** The value of export `name` of module `m`, following `export { x } from '…'`. */
892
+ const exportedValue = (m: ValueModule, name: string, depth: number): FreeNameResolution => {
893
+ for (const st of m.body) {
894
+ if (st.type !== 'ExportNamedDeclaration' || !(st as { source?: unknown }).source) continue
895
+ for (const sp of (st.specifiers as AnyNode[] | undefined) ?? []) {
896
+ if ((sp.exported as { name?: string }).name !== name) continue
897
+ const source = (st.source as { value: string }).value
898
+ const file = depth < 16 ? resolveValueFile(m.file, source) : null
899
+ const next = file ? valueModule(file) : null
900
+ if (!next) return { unresolved: `is re-exported from '${source}', which does not resolve from ${m.file}` }
901
+ return exportedValue(next, (sp.local as { name: string }).name, depth + 1)
902
+ }
903
+ }
904
+ const local = exportLocalName(m.body, name)
905
+ if (local === null) {
906
+ // A barrel: `export * from '…'`. ponytail: the first star that exports `name`
907
+ // wins; a name two stars both export is ambiguous in ES, which this does not flag.
908
+ for (const st of m.body) {
909
+ if (st.type !== 'ExportAllDeclaration' || (st as { exported?: unknown }).exported) continue
910
+ const file = depth < 16 ? resolveValueFile(m.file, (st.source as { value: string }).value) : null
911
+ const next = file ? valueModule(file) : null
912
+ if (!next) continue
913
+ const r = exportedValue(next, name, depth + 1)
914
+ if (r !== null && 'unresolved' in r && r.unresolved.startsWith('is not exported by ')) continue
915
+ return r
916
+ }
917
+ return { unresolved: `is not exported by ${m.file}` }
918
+ }
919
+ const scope = moduleValueScope(m)
920
+ if (!scope.has(local)) {
921
+ scope.get(local) // records WHY, for the error
922
+ return { unresolved: `could not be resolved in ${m.file}` }
923
+ }
924
+ return { value: scope.get(local) }
925
+ }
926
+ const importedValue = (from: string, binding: { source: string; imported: string }): FreeNameResolution => {
927
+ const file = resolveValueFile(from, binding.source)
928
+ const m = file ? valueModule(file) : null
929
+ if (!m) return { unresolved: `is imported from '${binding.source}', which does not resolve from ${from}` }
930
+ return exportedValue(m, binding.imported, 0)
931
+ }
932
+ /** Declarations of THIS module that no grammar construct references, kept unevaluated
933
+ * until a grammar reads the name. Ordinary code may read any import, and resolving a
934
+ * module for a value nothing compiles is pure build cost — or a build failure. */
935
+ const deferredLocals = new Map<string, Expression>()
936
+ /** Their results, once read: each grammar's factory reads through its own forked scope. */
937
+ const deferredResults = new Map<string, FreeNameResolution>()
938
+ const mainOpaqueImports = opaqueImportsOf(body as unknown as AnyNode[])
939
+ const resolveMainFree = (name: string): FreeNameResolution => {
940
+ const done = deferredResults.get(name)
941
+ if (done !== undefined) return done
942
+ const deferred = deferredLocals.get(name)
943
+ if (deferred !== undefined) {
944
+ deferredLocals.delete(name)
945
+ // Its own misses are reported through the local's name, not beside it.
946
+ const outer = [...unresolvedImports]
947
+ unresolvedImports.clear()
948
+ const value = evaluateStaticValue(deferred, scope, code)
949
+ const why = [...unresolvedImports]
950
+ unresolvedImports.clear()
951
+ for (const n of outer) unresolvedImports.add(n)
952
+ const result: FreeNameResolution = value !== null && value !== undefined || isStaticNullishExpression(deferred)
953
+ ? { value }
954
+ : why.length > 0 ? { unresolved: `could not be evaluated at build time, because: ${why.join('; ')}` } : null
955
+ deferredResults.set(name, result)
956
+ return result
957
+ }
958
+ const binding = importBindings.get(name)
959
+ if (!binding) return mainOpaqueImports.has(name) ? { unresolved: OPAQUE_IMPORT } : null
960
+ const resolved = importedValue(id, binding)
961
+ // Its value is now inside the compiled grammar; the import goes too unless
962
+ // something left verbatim still reads it (the same rule as an inlined factory).
963
+ if (resolved !== null && 'value' in resolved) usedImportedFactories.add(name)
964
+ return resolved
965
+ }
675
966
 
676
967
  const topLevelFunction = (moduleBody: AnyNode[], name: string): AnyNode | null => {
677
968
  for (const st of moduleBody) {
@@ -685,19 +976,21 @@ function transformMacroImpl(
685
976
  }
686
977
  return null
687
978
  }
688
- const isParsemanMacroImport = (stmt: AnyNode): boolean => {
689
- if (stmt.type !== 'ImportDeclaration') return false
690
- const s = stmt as unknown as ImportDeclaration
691
- return moduleAliases.has(s.source.value) && s.attributes.some(a => {
692
- const key = a.key.type === 'Identifier' ? a.key.name : String(a.key.value)
693
- return key === 'type' && a.value.value === 'macro'
694
- })
695
- }
696
- const sourceScopeUntil = (moduleBody: AnyNode[], source: string, until: number): Scope | null => {
697
- const out: Scope = new Map()
979
+ const sourceScopeUntil = (file: string, moduleBody: AnyNode[], source: string, until: number): Scope | null => {
980
+ // The module's own declarations are still evaluated EAGERLY, in order, up to the
981
+ // factory (a factory module the macro cannot fully account for still falls back).
982
+ // Its imports are what changed: they resolve on first read, like this module's.
983
+ const imports = extractImportBindings(moduleBody)
984
+ const opaque = opaqueImportsOf(moduleBody)
985
+ const out = new ModuleScope(name => {
986
+ const binding = imports.get(name)
987
+ if (binding) return importedValue(file, binding)
988
+ return opaque.has(name) ? { unresolved: OPAQUE_IMPORT } : null
989
+ }, unresolvedImports) as unknown as Scope
698
990
  for (const stmt of moduleBody as Statement[]) {
699
991
  if (stmt.type === 'ImportDeclaration') {
700
- if (!isParsemanMacroImport(stmt as unknown as AnyNode)) return null
992
+ // A module that imports parseman WITHOUT the macro attribute is not macro code.
993
+ if (moduleAliases.has((stmt as unknown as ImportDeclaration).source.value) && !isMacroImport(stmt as unknown as ImportDeclaration)) return null
701
994
  continue
702
995
  }
703
996
  if (stmt.type === 'FunctionDeclaration') continue
@@ -747,6 +1040,11 @@ function transformMacroImpl(
747
1040
  }
748
1041
  return out
749
1042
  }
1043
+ /** Imported factories whose module could not be accounted for because of an
1044
+ * unresolved import — reported when a `rules()` call actually uses one. */
1045
+ const factoryFailures = new Map<string, string[]>()
1046
+ /** Imports the macro inlined — a factory, or an imported terminal/value — and so
1047
+ * may remove once nothing verbatim reads them. */
750
1048
  const usedImportedFactories = new Set<string>()
751
1049
  const markUsedImportedFactories = (names: readonly string[] | undefined): void => {
752
1050
  for (const name of names ?? []) usedImportedFactories.add(name)
@@ -759,8 +1057,14 @@ function transformMacroImpl(
759
1057
  const localFor = exportLocalName(mod.body as AnyNode[], binding.imported)
760
1058
  const init = localFor ? topLevelInit(mod.body as AnyNode[], localFor) ?? topLevelFunction(mod.body as AnyNode[], localFor) : null
761
1059
  if (!init || (init.type !== 'ArrowFunctionExpression' && init.type !== 'FunctionExpression' && init.type !== 'FunctionDeclaration')) continue
762
- const factoryScope = sourceScopeUntil(mod.body as AnyNode[], mod.src, init.start)
763
- if (!factoryScope) continue
1060
+ unresolvedImports.clear()
1061
+ const factoryScope = sourceScopeUntil(file, mod.body as AnyNode[], mod.src, init.start)
1062
+ if (!factoryScope) {
1063
+ // Not silent: `rules(<this factory>)` refuses the build naming the binding.
1064
+ if (unresolvedImports.size > 0) factoryFailures.set(localName, [...unresolvedImports])
1065
+ unresolvedImports.clear()
1066
+ continue
1067
+ }
764
1068
  factoryDecls.set(localName, {
765
1069
  fn: init as unknown as Expression,
766
1070
  declaredAt: -1,
@@ -778,8 +1082,9 @@ function transformMacroImpl(
778
1082
  // --- Pass 2: evaluate declarations in source order ---
779
1083
  // Scope stores enriched ScopeEntry objects so evaluateParserFactory can
780
1084
  // replay mfSrcs when outer-scope combinators are referenced inside factories.
781
- const scope: Scope = new Map<string, ScopeEntry>()
782
- const replacements: Array<{ start: number; end: number; replacement: string }> = []
1085
+ const scope = new ModuleScope(resolveMainFree, unresolvedImports) as unknown as Scope
1086
+ /** `carry`: a compiled terminal that may need its combinator IR — see `withCombinatorIR`. */
1087
+ const replacements: Array<{ start: number; end: number; replacement: string; carry?: { name: string; combi: Combinator<unknown>; exported: boolean } }> = []
783
1088
  const warnings: string[] = []
784
1089
  // Collect degradations instead of printing them, so they arrive on the SAME channel
785
1090
  // as every other macro warning (the bundler's `this.warn`) with a `file:line` anchor.
@@ -839,6 +1144,15 @@ function transformMacroImpl(
839
1144
  for (const bi of def?.buildImports ?? []) {
840
1145
  let m = pendingBuilderImports.get(bi.source)
841
1146
  if (!m) { m = new Map(); pendingBuilderImports.set(bi.source, m) }
1147
+ const prev = m.get(bi.local)
1148
+ // Two builders reading one local name as different exports cannot share an import.
1149
+ if (prev !== undefined && prev !== bi.imported) {
1150
+ throw new Error(
1151
+ `${id} — parseman will not emit this module: inlined builders read \`${bi.local}\` as both `
1152
+ + `\`${prev}\` and \`${bi.imported}\` from ${JSON.stringify(bi.source)}. The builder source names `
1153
+ + `\`${bi.local}\` verbatim, so one import cannot bind both; import them under different names.`,
1154
+ )
1155
+ }
842
1156
  m.set(bi.local, bi.imported)
843
1157
  }
844
1158
  // An ANONYMOUS lazy is inline recursion — follow the thunk. A NAMED lazy is a
@@ -885,6 +1199,58 @@ function transformMacroImpl(
885
1199
  warnings.push(`${id}:${lineOf(pos)} — ${msg} (running via the interpreter; add the plugin or simplify the declaration to compile it)`)
886
1200
  }
887
1201
 
1202
+ /**
1203
+ * REFUSE THE BUILD over something the macro could not evaluate, naming each binding
1204
+ * it could not resolve (`unresolvedImports`, filled by the scopes as they miss).
1205
+ *
1206
+ * A warning is not enough here. For a `rules()` option the "fallback" was to build the
1207
+ * grammar WITHOUT the option — `scanSkip` gone, every string and comment inside a
1208
+ * `scanTo`/`balanced` region live again — with the build green and no interpreter
1209
+ * fallback to count. And a terminal imported from a published package is a compiled
1210
+ * parser at runtime, so there is no interpreter fallback for it to take either.
1211
+ */
1212
+ const unevaluable = (pos: number, what: string): never => {
1213
+ const names = [...unresolvedImports]
1214
+ unresolvedImports.clear()
1215
+ throw new Error(
1216
+ `${id}:${lineOf(pos)} — ${what} can't be evaluated at build time`
1217
+ + (names.length > 0
1218
+ ? `; unresolved binding(s):\n${names.map(n => ` - ${n}`).join('\n')}`
1219
+ : ' (it is not a static combinator/value expression)')
1220
+ + `\n parseman will not emit this module rather than drop it silently.`,
1221
+ )
1222
+ }
1223
+ /** A `rules()` option: absent/nullish → `undefined`; otherwise evaluated or the build fails. */
1224
+ const requiredOption = <T>(label: string, name: string, value: Expression | undefined, evaluate: (v: Expression) => T | null): T | undefined => {
1225
+ if (value === undefined || isStaticNullishExpression(value)) return undefined
1226
+ unresolvedImports.clear()
1227
+ const out = evaluate(value)
1228
+ if (out === null) {
1229
+ // A const bound to `null`/`undefined` means no option, exactly as the literal does.
1230
+ const id = (value.type === 'Identifier' ? value.name : null)
1231
+ if (id !== null && scope.has(id) && scope.get(id) == null) return undefined
1232
+ return unevaluable(value.start, `${label}: rules({ ${name} })`)
1233
+ }
1234
+ unresolvedImports.clear()
1235
+ return out
1236
+ }
1237
+ /** Options the macro reads by NAME, so a spread — or a computed key it cannot prove
1238
+ * names no option — would hide one from it. */
1239
+ const RULES_OPTIONS = new Set(['trivia', 'scanSkip', 'hostMode', 'trackLines'])
1240
+ const assertPlainOptions = (optExpr: AnyNode | undefined, label: string): void => {
1241
+ for (const p of (optExpr?.type === 'ObjectExpression' ? optExpr.properties as AnyNode[] : [])) {
1242
+ if (propName(p as never) !== null) continue
1243
+ const key = p.type === 'Property' ? evaluateStaticValue(p.key as unknown as Expression, scope, code) : null
1244
+ if (typeof key !== 'string' || RULES_OPTIONS.has(key)) {
1245
+ unevaluable(p.start, `${label}: an options ${p.type === 'Property' ? 'computed key' : 'spread'}`)
1246
+ }
1247
+ }
1248
+ }
1249
+ const triviaOption = (optExpr: AnyNode | undefined, label: string): Combinator<unknown> | undefined => {
1250
+ assertPlainOptions(optExpr, label)
1251
+ return requiredOption(label, 'trivia', optionProp(optExpr, 'trivia'), v => evaluateExpr(v, scope, code, []))
1252
+ }
1253
+
888
1254
  /**
889
1255
  * Compile a `rules(factory)` call into ONE shared replacement expression
890
1256
  * for the whole call — see compileRuleMap() in codegen.ts for why this is
@@ -923,11 +1289,23 @@ function transformMacroImpl(
923
1289
  const importedFactory = namedFactory?.imported === true && factoryArg === namedFactory.fn
924
1290
  ? (factoryArgRaw as unknown as { name?: string }).name
925
1291
  : undefined
1292
+ const failedFactory = factoryArgRaw.type === 'Identifier'
1293
+ ? factoryFailures.get((factoryArgRaw as unknown as { name: string }).name)
1294
+ : undefined
1295
+ if (failedFactory) {
1296
+ for (const n of failedFactory) unresolvedImports.add(n)
1297
+ unevaluable(init.start, `${label}: the imported rules() factory \`${(factoryArgRaw as unknown as { name: string }).name}\``)
1298
+ }
926
1299
 
927
1300
  // Grammar-level options object — evaluate `trivia` / `scanSkip` so the compiled
928
1301
  // map seeds them as the ambient defaults (build-time mirror of rules() tagging
929
1302
  // grammarTrivia / grammarScanSkip at runtime).
930
1303
  const optionsArg = (optionsFirst ? arg0 : arg1) as AnyNode | undefined
1304
+ // `optionProp` reads an object LITERAL: a trailing `rules(factory, OPTS)` would hide
1305
+ // every option from it and ship the grammar without them, as `compose()` would.
1306
+ if (optionsArg !== undefined && optionsArg.type !== 'ObjectExpression' && !isStaticNullishExpression(optionsArg as unknown as Expression)) {
1307
+ unevaluable(optionsArg.start as number, `${label}: rules() options`)
1308
+ }
931
1309
  const optionValue = (name: string): Expression | undefined => optionProp(optionsArg, name)
932
1310
 
933
1311
  // Read and VALIDATE hostMode before evaluating the factory, so a mode the macro
@@ -973,14 +1351,18 @@ function transformMacroImpl(
973
1351
  *
974
1352
  * Evaluated BEFORE the factory because they are now inputs to it.
975
1353
  */
976
- const triviaValue = optionValue('trivia')
977
- const gTrivia = triviaValue ? evaluateExpr(triviaValue, scope, code, []) : undefined
978
- const scanSkipValue = optionValue('scanSkip')
979
- const gScanSkip = scanSkipValue
980
- ? (evaluateCombinatorArray(scanSkipValue, scope, code) ?? undefined)
981
- : undefined
1354
+ const gTrivia = triviaOption(optionsArg, label)
1355
+ const scanSkipArray = requiredOption(label, 'scanSkip', optionValue('scanSkip'), v => {
1356
+ const units = evaluateCombinatorArray(v, scope, code)
1357
+ if (units) return units
1358
+ // An empty array means no skip units, whether written `[]` or named by a const.
1359
+ const value = evaluateStaticValue(v, scope, code)
1360
+ return Array.isArray(value) && value.length === 0 ? [] : null
1361
+ })
1362
+ const gScanSkip = scanSkipArray?.length ? scanSkipArray : undefined
982
1363
 
983
1364
  const why: { reason?: string } = {}
1365
+ unresolvedImports.clear()
984
1366
  const ruleMap = evaluateParserFactory(factoryArg, factoryScope, factoryCode, [], why, {
985
1367
  ...(gTrivia ? { trivia: gTrivia as Combinator<unknown> } : {}),
986
1368
  ...(gScanSkip ? { scanSkip: gScanSkip } : {}),
@@ -988,6 +1370,7 @@ function transformMacroImpl(
988
1370
  ...(gTrackLines === true ? { trackLines: true } : {}),
989
1371
  })
990
1372
  if (!ruleMap) {
1373
+ if (unresolvedImports.size > 0) unevaluable(init.start, `${label}: rules(...) factory`)
991
1374
  warn(init.start, why.reason === undefined
992
1375
  ? `${label}: rules(...) factory isn't statically evaluable`
993
1376
  : `${label}: rules(...) factory isn't statically evaluable — ${why.reason}`)
@@ -1018,6 +1401,8 @@ function transformMacroImpl(
1018
1401
  // inlined" leaves the author with a ~5x silent perf regression and no lead.
1019
1402
  const refusals: string[] = []
1020
1403
  const compiled = compileRuleMap([...evaluated.ruleMap], { ...(evaluated.trivia ? { trivia: evaluated.trivia } : {}), ...(evaluated.scanSkip ? { scanSkip: evaluated.scanSkip } : {}), ...(evaluated.trackLines ? { trackLines: true } : {}), recovery, coverage: grammarCoverage, refusals })
1404
+ // An imported terminal's inlined builder reads ITS module's helpers; import them here.
1405
+ if (compiled !== null) collectBuilderImports([...evaluated.ruleMap])
1021
1406
  // A table replacement names `tableRules`, which nothing in the consumer's
1022
1407
  // module binds. That reference is the whole reason the artifact is small (the
1023
1408
  // driver is SHARED, not inlined per grammar), so the import is owned here.
@@ -1196,34 +1581,58 @@ function transformMacroImpl(
1196
1581
  // spread by following that module's own import of `binding` to its compiled file
1197
1582
  // and recursing, then eval the literal with the resolved ancestors stubbed in so
1198
1583
  // the spreads splice their pieces. `seen` guards against an import cycle.
1199
- const resolveModulePieces = (module: ResolvedGrammarModule, exportName: string, seen: Set<string>): RawItem[] | null => {
1584
+ //
1585
+ // A grammar whose literal IS found is BUILD-COMPILED: a runtime `compose()` cannot
1586
+ // link it, so failing to resolve it is not a reason to fall back to runtime.
1587
+ // `unresolved.why` then says what could not be followed, and the caller fails the
1588
+ // build with it. `local` names a top-level binding of the module rather than an
1589
+ // export — how a bundler leaves an ancestor it inlined.
1590
+ const resolveModulePieces = (
1591
+ module: ResolvedGrammarModule,
1592
+ exportName: string,
1593
+ seen: Set<string>,
1594
+ unresolved: { why?: string },
1595
+ local = false,
1596
+ ): RawItem[] | null => {
1200
1597
  const { file } = module
1201
1598
  const mod = module.source
1202
1599
  ? lowerPrivateSourceModule(file, moduleAliases, warnUnloweredRegex, recovery)
1203
1600
  : parseModuleCached(file)
1204
1601
  if (!mod) return null
1205
- const localFor = exportLocalName(mod.body as AnyNode[], exportName)
1602
+ const localFor = local ? exportName : exportLocalName(mod.body as AnyNode[], exportName)
1206
1603
  const initNode = localFor ? topLevelInit(mod.body as AnyNode[], localFor) : null
1207
1604
  const literalRange = initNode ? findCarriedPiecesLiteral(initNode) : null
1208
1605
  if (!literalRange) return null
1209
1606
  const literal = mod.src.slice(literalRange.start, literalRange.end)
1607
+ const fail = (why: string): null => { unresolved.why ??= `${exportName} in ${file}: ${why}`; return null }
1210
1608
 
1211
1609
  const imports = extractImportBindings(mod.body as AnyNode[])
1212
1610
  const stubNames: string[] = []
1213
1611
  const stubVals: unknown[] = []
1214
- const spreadRe = /\.\.\.\s*\(?\s*([A-Za-z_$][\w$]*)\s*\[\s*Symbol\s*\.\s*for\s*\(\s*['"]parseman\.composedPieces['"]/g
1612
+ // A bundler may reprint the key with an annotation inside the brackets — esbuild
1613
+ // writes `syn[/* @__PURE__ */ Symbol.for("parseman.composedPieces")]` — so block
1614
+ // comments before `Symbol` are allowed. Missing one leaves the spread unresolved.
1615
+ const spreadRe = /\.\.\.\s*\(?\s*([A-Za-z_$][\w$]*)\s*\[\s*(?:\/\*[^]*?\*\/\s*)*Symbol\s*\.\s*for\s*\(\s*['"]parseman\.composedPieces['"]/g
1215
1616
  const done = new Set<string>()
1216
1617
  for (let m: RegExpExecArray | null; (m = spreadRe.exec(literal)); ) {
1217
1618
  const local = m[1]!
1218
1619
  if (done.has(local)) continue
1219
1620
  done.add(local)
1220
1621
  const b = imports.get(local)
1221
- let subPieces: RawItem[] = []
1622
+ let subPieces: RawItem[] | null = []
1222
1623
  if (b) {
1223
1624
  const subModule = resolveGrammarModule(file, b.source)
1224
- if (subModule && !seen.has(subModule.file)) {
1225
- subPieces = resolveModulePieces(subModule, b.imported, new Set(seen).add(subModule.file)) ?? []
1625
+ if (subModule === null) return fail(`its carried pieces spread \`${local}\` from '${b.source}', a module that could not be found`)
1626
+ if (!seen.has(subModule.file)) {
1627
+ subPieces = resolveModulePieces(subModule, b.imported, new Set(seen).add(subModule.file), unresolved)
1628
+ if (subPieces === null) return fail(`its carried pieces spread \`${local}\` from '${b.source}', which could not be resolved`)
1226
1629
  }
1630
+ } else if (topLevelInit(mod.body as AnyNode[], local) !== null && !seen.has(`${file}#${local}`)) {
1631
+ // A bundled module: the ancestor was inlined as a top-level binding.
1632
+ subPieces = resolveModulePieces(module, local, new Set(seen).add(`${file}#${local}`), unresolved, true)
1633
+ if (subPieces === null) return fail(`its carried pieces spread the inlined binding \`${local}\`, which carries none`)
1634
+ } else {
1635
+ return fail(`its carried pieces spread \`${local}\`, which is neither an import nor a top-level binding of the module`)
1227
1636
  }
1228
1637
  stubNames.push(local)
1229
1638
  // The KEY comes from the linker, which owns it. Spelling `Symbol.for('…')`
@@ -1245,49 +1654,41 @@ function transformMacroImpl(
1245
1654
  // eslint-disable-next-line no-new-func
1246
1655
  const list = new Function(...stubNames, `return (${literal})`)(...stubVals) as RawItem[]
1247
1656
  return list
1248
- } catch { return null }
1657
+ } catch (e) { return fail(`its carried pieces literal did not evaluate (${(e as Error).message})`) }
1249
1658
  }
1250
1659
 
1660
+ // Imported build-compiled grammars whose carried pieces could not be resolved, by
1661
+ // local name, with the reason. `compileComposeCall` fails the build on these.
1662
+ const unresolvedCompiled = new Map<string, string>()
1663
+
1251
1664
  const importedPieces = (localName: string): RawItem[] | null => {
1252
1665
  if (importedPiecesCache.has(localName)) return importedPiecesCache.get(localName)!
1253
1666
  let result: RawItem[] | null = null
1254
1667
  const binding = importBindings.get(localName)
1255
1668
  if (binding) {
1256
1669
  const module = resolveGrammarModule(id, binding.source)
1257
- result = module ? resolveModulePieces(module, binding.imported, new Set([module.file])) : null
1670
+ const unresolved: { why?: string } = {}
1671
+ result = module ? resolveModulePieces(module, binding.imported, new Set([module.file]), unresolved) : null
1672
+ if (result === null && unresolved.why !== undefined) unresolvedCompiled.set(localName, unresolved.why)
1258
1673
  }
1259
1674
  importedPiecesCache.set(localName, result)
1260
1675
  return result
1261
1676
  }
1262
1677
 
1263
1678
  /**
1264
- * MERGE a carried list to ONE rule map — the table lowering's entire composition
1265
- * mechanism, and the reason it needs no linker.
1266
- *
1267
- * Source composition is a TEXTUAL splice: each piece is lowered on its own to
1268
- * namespaced `_r_<Name>` functions, and `fusedBody()` picks a winner per name and
1269
- * substitutes the `@FS:` dispatch placeholders with that winner's first-set
1270
- * condition. A table has no text to splice and no placeholders to resolve, so the
1271
- * merge moves one level UP, onto the combinators: evaluate each piece's IR back to
1272
- * a rule map, let a later piece's name override an earlier one — which is exactly
1273
- * what `compose()` means — and hand the single merged map to
1274
- * `compileRuleMap`, which encodes ONCE.
1275
- *
1276
- * This is what makes table composition the easy kind: no relocation of code
1277
- * offsets, no merging of const / fn / class / expected / dispatch pools between
1278
- * two already-encoded programs. `encodeTableProgram` points `enc.winners` at the
1279
- * merged map (`table/encode.ts:1383`), so a base piece's internal `g.Atom`
1280
- * resolves BY NAME to the override (`:1036`) rather than through a thunk that
1281
- * closes over the base. Open recursion — much of the point of compose — therefore
1282
- * survives the merge, and the result is the table the merged grammar would have
1283
- * produced had it been written as a single `rules()` call.
1679
+ * Evaluate a carried list back to combinators, one entry per piece: its rule map
1680
+ * and the `rules()` recipe it was built from. `compose()` links the recipes into
1681
+ * one namespace (`mergedCarriedRules`); `composeLeaf()` merges the maps by name.
1682
+ * Either way the merged grammar is encoded ONCE by `compileRuleMap` — no relocation
1683
+ * of code offsets and no merging of const / fn / expected pools between two
1684
+ * already-encoded programs.
1284
1685
  *
1285
1686
  * `null` means some piece cannot contribute combinators: a FULL BAKED piece (the
1286
1687
  * un-serializable fallback at `localCarried`) carries lowered SOURCE and nothing
1287
1688
  * else. That shape is codegen-only, so the caller falls back to fused source
1288
1689
  * rather than refusing the grammar.
1289
1690
  */
1290
- type CarriedRuleMap = { ns: string; rules: Array<[string, Combinator<unknown>]> }
1691
+ type CarriedRuleMap = { ns: string; rules: Array<[string, Combinator<unknown>]>; recipes: readonly RulesRecipe[] }
1291
1692
  const carriedRuleMaps = (
1292
1693
  items: CarriedItem[],
1293
1694
  ): { pieces: CarriedRuleMap[]; trackLines: boolean } | null => {
@@ -1300,7 +1701,8 @@ function transformMacroImpl(
1300
1701
  // merged encode resolves `trackLines` from `_meta` alone and silently drops line
1301
1702
  // tracking for a grammar that asked for it.
1302
1703
  if (item.trackLines === true) trackLines = true
1303
- pieces.push({ ns: item.ns, rules: evalRuleMapIR(item.ir) })
1704
+ const map = evalRulesIR(item.ir)
1705
+ pieces.push({ ns: item.ns, rules: Object.entries(map), recipes: (map as Record<symbol, readonly RulesRecipe[]>)[RULES_RECIPE]! })
1304
1706
  return true
1305
1707
  }
1306
1708
  for (const item of items) {
@@ -1345,24 +1747,39 @@ function transformMacroImpl(
1345
1747
  return [...winners]
1346
1748
  }
1347
1749
 
1750
+ /**
1751
+ * LINK a carried list into the composed map, exactly as runtime `compose()` does:
1752
+ * every piece's carried `rules()` recipe runs again against ONE namespace
1753
+ * (`linkRules`), a later piece's rule winning by name, and the composing trivia and
1754
+ * host mode stamped on every rule. A `g.X` in one piece is then the SAME slot as the
1755
+ * `X` another piece defines, so everything the encoder derives from the graph —
1756
+ * first sets, nullability, expected sets — sees the composed grammar. Merging
1757
+ * per-piece maps by name instead left each piece's cross-piece `g.X` an unresolved
1758
+ * thunk: parsing still bound it by name, but a failure derived as the bare rule name
1759
+ * (or, through a nullable guess, a token the parse never required), so the macro
1760
+ * table reported different `expected` sets than the interpreter and `compile()` of
1761
+ * the same composition.
1762
+ */
1348
1763
  const mergedCarriedRules = (
1349
1764
  items: CarriedItem[],
1765
+ link: LinkOptions,
1350
1766
  ): { rules: Array<[string, Combinator<unknown>]>; trackLines: boolean } | null => {
1351
1767
  const carriedMaps = carriedRuleMaps(items)
1352
1768
  if (carriedMaps === null) return null
1353
- return { rules: mergeRuleMaps(carriedMaps.pieces.map(p => p.rules)), trackLines: carriedMaps.trackLines }
1769
+ const linked = linkRules(carriedMaps.pieces.flatMap(p => p.recipes), link)
1770
+ // A name some piece only REFERENCES and none defines stays an undefined slot in
1771
+ // the namespace. It is not a rule, so it is not handed to the encoder as one.
1772
+ const rules = Object.entries(linked).filter(([, rule]) => {
1773
+ if (rule._def.tag !== 'lazy') return true
1774
+ try { rule._def.thunk(); return true } catch { return false }
1775
+ })
1776
+ return { rules, trackLines: carriedMaps.trackLines }
1354
1777
  }
1355
1778
 
1356
1779
  /**
1357
- * COMPOSING-WINS, as an OVERRIDE rather than a gap-fill.
1358
- *
1359
- * `compileRuleMap`'s `applyAmbient` only fills a rule that carries no trivia of
1360
- * its own — correct for `composeLeaf`, whose pieces may legitimately disagree, and
1361
- * WRONG for `compose`, where the composing grammar's trivia governs every fused rule
1362
- * INCLUDING the inherited ones. Gap-filling leaves a base rule that declared its own
1363
- * `rules({ trivia }, …)` still skipping the base's trivia after a delta re-declared
1364
- * it, so `compose([css, less])` silently parses the inherited rules under css's
1365
- * whitespace — the multi-level composing-wins contract, inverted.
1780
+ * COMPOSING-WINS for `composeLeaf()`'s carried pieces, as an OVERRIDE rather than
1781
+ * a gap-fill: `compileRuleMap`'s `applyAmbient` only fills a rule that carries no
1782
+ * trivia of its own. (`compose()` gets the same override from `linkRules`.)
1366
1783
  *
1367
1784
  * Safe to mutate: every rule here came from `evalRuleMapIR`, which constructs FRESH
1368
1785
  * combinators per piece per compose, so this cannot leak into another compose of the
@@ -1456,9 +1873,7 @@ function transformMacroImpl(
1456
1873
  const a0 = rulesArgs[0] as AnyNode | undefined
1457
1874
  const a1 = rulesArgs[1] as AnyNode | undefined
1458
1875
  const optExpr = (a0?.type === 'ObjectExpression' ? a0 : a1?.type === 'ObjectExpression' ? a1 : undefined) as AnyNode | undefined
1459
- const triviaValue = optionProp(optExpr, 'trivia')
1460
- if (!triviaValue) return undefined
1461
- return (evaluateExpr(triviaValue, scope, code, []) as Combinator<unknown> | null) ?? undefined
1876
+ return triviaOption(optExpr, 'compose() element')
1462
1877
  }
1463
1878
  const composingTrivia = (elements: ReadonlyArray<Expression | null>): Combinator<unknown> | undefined => {
1464
1879
  for (let i = elements.length - 1; i >= 0; i--) {
@@ -1497,6 +1912,10 @@ function transformMacroImpl(
1497
1912
  // because the artifact genuinely WAS 'ast'. Same vacuous-classification shape this
1498
1913
  // change exists to remove, one call site over.
1499
1914
  const cOptions = (init as unknown as { arguments: Expression[] }).arguments[1] as AnyNode | undefined
1915
+ // `optionProp` reads an object LITERAL: any other options expression, or a spread
1916
+ // inside one, would hide `hostMode` from it and ship the default.
1917
+ if (cOptions !== undefined && cOptions.type !== 'ObjectExpression') unevaluable(cOptions.start, 'compose() options')
1918
+ assertPlainOptions(cOptions, 'compose()')
1500
1919
  const cHostModeValue = optionProp(cOptions, 'hostMode')
1501
1920
  const cHostMode = cHostModeValue?.type === 'Literal'
1502
1921
  ? (cHostModeValue as unknown as { value?: unknown }).value
@@ -1512,7 +1931,25 @@ function transformMacroImpl(
1512
1931
  const importedFactories: string[] = []
1513
1932
  for (let i = 0; i < elements.length; i++) {
1514
1933
  const r = argPieces(elements[i]!, `compose${init.start}_${i}`, composing)
1515
- if (!r) { warn(init.start, `compose(): argument ${i} isn't a build-resolvable grammar; falling back to runtime`); return null }
1934
+ if (!r) {
1935
+ // A build-compiled argument cannot fall back: runtime `compose()` links live
1936
+ // `rules()` grammars only and would throw at import. Fail here instead.
1937
+ const el = elements[i]!
1938
+ const name = el.type === 'Identifier' ? (el as unknown as { name: string }).name : undefined
1939
+ const why = name === undefined ? undefined : unresolvedCompiled.get(name)
1940
+ if (why !== undefined) {
1941
+ throw new Error(
1942
+ `${id}:${lineOf(init.start)} — compose(): argument ${i} (\`${name}\` from '${importBindings.get(name!)?.source}') `
1943
+ + `is a build-compiled grammar whose carried pieces could not be resolved: ${why}. `
1944
+ + 'A runtime compose() cannot link a build-compiled grammar, so this would throw at import. '
1945
+ + 'The usual cause is a bundler rewriting the upstream module (inlining or renaming an ancestor '
1946
+ + 'grammar it composes, or reprinting its carried-pieces literal); keep upstream grammar packages '
1947
+ + 'external to the bundle.',
1948
+ )
1949
+ }
1950
+ warn(init.start, `compose(): argument ${i} isn't a build-resolvable grammar; falling back to runtime`)
1951
+ return null
1952
+ }
1516
1953
  carried.push(...r.carried)
1517
1954
  importedFactories.push(...(r.importedFactories ?? []))
1518
1955
  }
@@ -1523,9 +1960,11 @@ function transformMacroImpl(
1523
1960
  // does — one encode, one `tableRules(…)` expression, no linker. `carried` is
1524
1961
  // unchanged either way: it is the re-lowerable IR list, not a lowering artifact,
1525
1962
  // so a downstream re-compose behaves identically whichever engine emitted here.
1526
- const merged = mergedCarriedRules(carried)
1963
+ const merged = mergedCarriedRules(carried, {
1964
+ trivia: composing,
1965
+ ...(cHostMode ? { hostMode: cHostMode as HostMode } : {}),
1966
+ })
1527
1967
  if (merged !== null) {
1528
- if (composing) applyComposingTrivia(merged.rules, composing)
1529
1968
  // Harvest the direct-builder import provenance carried on the merged graph so
1530
1969
  // the re-lower pass at the end of this transform re-emits those imports.
1531
1970
  collectBuilderImports(merged.rules)
@@ -1809,10 +2248,8 @@ function transformMacroImpl(
1809
2248
  // ── Simple binding: const name = <expr> ──────────────────────────
1810
2249
  const varName = (d.id as unknown as { name: string }).name
1811
2250
  if (!referencesAny(init, allNames, scope)) {
1812
- const staticValue = evaluateStaticValue(init, scope, code)
1813
- if (staticValue !== null && staticValue !== undefined || isStaticNullishExpression(init)) {
1814
- ;(scope as Map<string, unknown>).set(varName, staticValue)
1815
- }
2251
+ // Evaluated only if a grammar reads it — see `deferredLocals`.
2252
+ deferredLocals.set(varName, init)
1816
2253
  continue
1817
2254
  }
1818
2255
 
@@ -1991,6 +2428,7 @@ function transformMacroImpl(
1991
2428
  }
1992
2429
 
1993
2430
  const mapFnSources: string[] = []
2431
+ unresolvedImports.clear()
1994
2432
  const parser = evaluateExpr(init, scope, code, mapFnSources)
1995
2433
  if (parser === null) {
1996
2434
  const wordFactory = evaluateWordFactory(init, scope, code)
@@ -2034,6 +2472,7 @@ function transformMacroImpl(
2034
2472
  if (exportPrefix) exportedFactories.push({ name: varName, pos: init.start })
2035
2473
  continue
2036
2474
  }
2475
+ if (unresolvedImports.size > 0) unevaluable(init.start, `"${varName}"`)
2037
2476
  warn(init.start, `"${varName}" references a parseman macro import but isn't a statically-evaluable combinator`)
2038
2477
  continue
2039
2478
  }
@@ -2055,6 +2494,7 @@ function transformMacroImpl(
2055
2494
  start: init.start,
2056
2495
  end: init.end,
2057
2496
  replacement: compiled.inlineExpression,
2497
+ carry: { name: varName, combi: parser, exported: exportPrefix !== '' },
2058
2498
  })
2059
2499
 
2060
2500
  // Store enriched scope entry so factories can replay mfSrcs
@@ -2165,40 +2605,78 @@ function transformMacroImpl(
2165
2605
  )
2166
2606
  }
2167
2607
 
2608
+ /**
2609
+ * Carry a compiled terminal's combinator IR on its value, when another module can
2610
+ * reach it: it is exported, or something left verbatim (an exported skip array, an
2611
+ * options object) still names it.
2612
+ *
2613
+ * Lowering turns `export const blockComment = regex(…)` into a compiled PARSER, and a
2614
+ * parser is not something the macro can evaluate. So without this, a package could
2615
+ * share a grammar map (whose pieces it carries for `compose()`) but not one terminal,
2616
+ * and every grammar that wanted the same string-or-comment skipper declared its own.
2617
+ * The IR rides on the value exactly like composed pieces do — non-enumerable, under a
2618
+ * `Symbol.for` key — and `carriedCombinatorIR` reads it back out of the artifact.
2619
+ * The parser function itself is unchanged, so no parse runs differently.
2620
+ */
2621
+ const withCombinatorIR = <R extends (typeof replacements)[number]>(r: R): R => {
2622
+ if (!r.carry || (!r.carry.exported && !referencedNames().live.has(r.carry.name))) return r
2623
+ let ir: string | null = null
2624
+ // An IR the serializer cannot produce leaves the terminal uncarried; a module that
2625
+ // imports it then fails ITS build naming it (see `moduleConstValue`).
2626
+ try { ir = serializeCombinator(r.carry.combi) } catch { /* uncarried */ }
2627
+ return ir === null ? r : {
2628
+ ...r,
2629
+ replacement: `/* @__PURE__ */ Object.defineProperty(${r.replacement}, Symbol.for('${COMBINATOR_IR_KEY}'), { value: ${JSON.stringify(ir)}, enumerable: false })`,
2630
+ }
2631
+ }
2168
2632
  const ms = new MagicString(code)
2169
2633
  const replacementRanges = runtimeComposeFallback
2170
2634
  ? []
2171
2635
  : replacements.map(({ start, end }) => ({ start, end }))
2172
2636
  const isInsideReplacement = (start: number, end: number): boolean =>
2173
2637
  replacementRanges.some(r => start >= r.start && end <= r.end)
2174
- const importedBindingStillReferenced = (local: string): boolean => {
2175
- const walk = (node: unknown, parent?: AnyNode, parentKey?: string): boolean => {
2176
- if (!node || typeof node !== 'object') return false
2638
+ /** Every name the EMITTED module still reads outside the replaced ranges — computed
2639
+ * once (it used to be one whole-module walk per name asked about). A declaration's
2640
+ * own binding name is not a read. `live` leaves out reads inside a local `rules()`
2641
+ * factory, whose text survives lowering only as macro-only dead code, and inside the
2642
+ * `.define()` statements lowering removes. */
2643
+ let referenced: { all: Set<string>; live: Set<string> } | null = null
2644
+ const referencedNames = (): { all: Set<string>; live: Set<string> } => {
2645
+ if (referenced !== null) return referenced
2646
+ const all = new Set<string>()
2647
+ const live = new Set<string>()
2648
+ // Also the `x.define(…)` statements lowering strips.
2649
+ const deadRanges = [...[...factoryDecls.values()].filter(f => !f.imported).map(f => f.fn), ...(anyUnresolved ? [] : defineRemovals)]
2650
+ const walk = (node: unknown, inFactory: boolean, parent?: AnyNode, parentKey?: string): void => {
2651
+ if (!node || typeof node !== 'object') return
2177
2652
  const rec = node as AnyNode
2178
- if (rec.type === 'ImportDeclaration') return false
2179
- if (typeof rec.start === 'number' && typeof rec.end === 'number' && isInsideReplacement(rec.start, rec.end)) return false
2180
- if ((rec.type === 'Identifier' || rec.type === 'BindingIdentifier') && (rec as { name?: string }).name === local) {
2181
- if (
2182
- parentKey === 'key' &&
2183
- (parent?.type === 'ObjectProperty' || parent?.type === 'Property' || parent?.type === 'PropertyDefinition') &&
2184
- (parent as { computed?: boolean }).computed !== true
2185
- ) return false
2186
- if (parentKey === 'property' && parent?.type === 'StaticMemberExpression') return false
2187
- if (parentKey === 'property' && parent?.type === 'MemberExpression' && (parent as { computed?: boolean }).computed !== true) return false
2188
- return true
2653
+ if (rec.type === 'ImportDeclaration') return
2654
+ if (typeof rec.start === 'number' && typeof rec.end === 'number') {
2655
+ if (isInsideReplacement(rec.start, rec.end)) return
2656
+ inFactory ||= deadRanges.some(f => rec.start >= f.start && rec.end <= f.end)
2657
+ }
2658
+ if (rec.type === 'Identifier' || rec.type === 'BindingIdentifier') {
2659
+ const isKey = parentKey === 'key'
2660
+ && (parent?.type === 'ObjectProperty' || parent?.type === 'Property' || parent?.type === 'PropertyDefinition')
2661
+ && (parent as { computed?: boolean }).computed !== true
2662
+ const isMember = parentKey === 'property'
2663
+ && (parent?.type === 'StaticMemberExpression' || (parent?.type === 'MemberExpression' && (parent as { computed?: boolean }).computed !== true))
2664
+ const isDeclared = parentKey === 'id' && parent?.type === 'VariableDeclarator'
2665
+ if (!isKey && !isMember && !isDeclared) {
2666
+ all.add(rec.name as string)
2667
+ if (!inFactory) live.add(rec.name as string)
2668
+ }
2669
+ return
2189
2670
  }
2190
2671
  for (const key of Object.keys(rec)) {
2191
2672
  if (key === 'type' || key === 'start' || key === 'end') continue
2192
2673
  const value = (rec as Record<string, unknown>)[key]
2193
- if (Array.isArray(value)) {
2194
- if (value.some(child => walk(child, rec, key))) return true
2195
- } else if (walk(value, rec, key)) {
2196
- return true
2197
- }
2674
+ if (Array.isArray(value)) for (const child of value) walk(child, inFactory, rec, key)
2675
+ else walk(value, inFactory, rec, key)
2198
2676
  }
2199
- return false
2200
2677
  }
2201
- return (body as unknown[]).some(stmt => walk(stmt))
2678
+ for (const stmt of body as unknown[]) walk(stmt, false)
2679
+ return referenced = { all, live }
2202
2680
  }
2203
2681
 
2204
2682
  for (const imp of ordinaryImports) {
@@ -2208,7 +2686,7 @@ function transformMacroImpl(
2208
2686
  imp.specifiers.every(spec =>
2209
2687
  spec.type === 'ImportSpecifier' &&
2210
2688
  usedImportedFactories.has(spec.local) &&
2211
- !importedBindingStillReferenced(spec.local),
2689
+ !referencedNames().all.has(spec.local),
2212
2690
  )
2213
2691
  ) {
2214
2692
  ms.remove(imp.start, imp.end)
@@ -2239,7 +2717,7 @@ function transformMacroImpl(
2239
2717
  // Runtime compose consumes combinator objects. A partially lowered module mixes
2240
2718
  // compiled parser functions with those objects (for example `trivia(ws)` after
2241
2719
  // `ws` was lowered), so an unresolved compose makes the whole module runtime.
2242
- const applied = runtimeComposeFallback ? [] : replacements
2720
+ const applied = runtimeComposeFallback ? [] : replacements.map(r => withCombinatorIR(r))
2243
2721
  // NO MODULE HOIST. It existed to deduplicate declarations across the fused IIFEs the
2244
2722
  // source lowering emitted — `_pfFail` and friends, one copy per variant. A table
2245
2723
  // replacement is a `tableRules(...)` call over a data literal: there are no emitted