parseman 0.56.0 → 0.57.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 (75) hide show
  1. package/dist/analysis/diagnostics.js +379 -72
  2. package/dist/analysis/diagnostics.js.map +3 -3
  3. package/dist/cli/index.js +379 -72
  4. package/dist/cli/index.js.map +3 -3
  5. package/dist/combinators/node.d.ts +1 -1
  6. package/dist/compiler/build-arity.d.ts +1 -1
  7. package/dist/compiler/build-arity.d.ts.map +1 -1
  8. package/dist/cst/trivia-kinds.d.ts +9 -1
  9. package/dist/cst/trivia-kinds.d.ts.map +1 -1
  10. package/dist/index.js +446 -75
  11. package/dist/index.js.map +3 -3
  12. package/dist/language-service/index.js +1 -0
  13. package/dist/language-service/index.js.map +1 -1
  14. package/dist/parse-context.d.ts.map +1 -1
  15. package/dist/plugin/entry-supercompiler.d.ts +1 -1
  16. package/dist/plugin/entry-supercompiler.d.ts.map +1 -1
  17. package/dist/plugin/index.d.ts +27 -1
  18. package/dist/plugin/index.d.ts.map +1 -1
  19. package/dist/plugin/index.js +930 -157
  20. package/dist/plugin/index.js.map +3 -3
  21. package/dist/plugin/ir-eval.d.ts +2 -0
  22. package/dist/plugin/ir-eval.d.ts.map +1 -1
  23. package/dist/regex/classes.d.ts.map +1 -1
  24. package/dist/run/index.js +1 -0
  25. package/dist/run/index.js.map +1 -1
  26. package/dist/table/assemble.d.ts +9 -0
  27. package/dist/table/assemble.d.ts.map +1 -1
  28. package/dist/table/child-slots.d.ts +4 -6
  29. package/dist/table/child-slots.d.ts.map +1 -1
  30. package/dist/table/compile-rule-map.d.ts +14 -2
  31. package/dist/table/compile-rule-map.d.ts.map +1 -1
  32. package/dist/table/emit-assembly.d.ts +17 -2
  33. package/dist/table/emit-assembly.d.ts.map +1 -1
  34. package/dist/table/emit.d.ts +5 -1
  35. package/dist/table/emit.d.ts.map +1 -1
  36. package/dist/table/encode.d.ts.map +1 -1
  37. package/dist/table/exec-baseline.d.ts.map +1 -1
  38. package/dist/table/exec.d.ts.map +1 -1
  39. package/dist/table/index.js +442 -70
  40. package/dist/table/index.js.map +3 -3
  41. package/dist/table/ops.d.ts +9 -6
  42. package/dist/table/ops.d.ts.map +1 -1
  43. package/dist/table/program.d.ts +13 -0
  44. package/dist/table/program.d.ts.map +1 -1
  45. package/dist/table/select-hot.d.ts +6 -0
  46. package/dist/table/select-hot.d.ts.map +1 -0
  47. package/dist/table/trivia-gap.d.ts +31 -0
  48. package/dist/table/trivia-gap.d.ts.map +1 -0
  49. package/dist/types.d.ts +17 -0
  50. package/dist/types.d.ts.map +1 -1
  51. package/dist/version.d.ts +1 -1
  52. package/package.json +4 -1
  53. package/src/combinators/node.ts +6 -13
  54. package/src/combinators/trivia-skip.ts +44 -1
  55. package/src/compiler/build-arity.ts +3 -3
  56. package/src/cst/trivia-kinds.ts +12 -1
  57. package/src/parse-context.ts +1 -0
  58. package/src/plugin/entry-supercompiler.ts +8 -9
  59. package/src/plugin/index.ts +138 -13
  60. package/src/plugin/ir-eval.ts +7 -1
  61. package/src/regex/classes.ts +3 -0
  62. package/src/table/assemble.ts +122 -24
  63. package/src/table/child-slots.ts +4 -6
  64. package/src/table/compile-rule-map.ts +55 -15
  65. package/src/table/emit-assembly.ts +139 -26
  66. package/src/table/emit.ts +8 -7
  67. package/src/table/encode.ts +69 -21
  68. package/src/table/exec-baseline.ts +1 -3
  69. package/src/table/exec.ts +7 -6
  70. package/src/table/ops.ts +9 -6
  71. package/src/table/program.ts +24 -0
  72. package/src/table/select-hot.ts +70 -0
  73. package/src/table/trivia-gap.ts +109 -0
  74. package/src/types.ts +18 -0
  75. 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'
@@ -38,7 +38,9 @@ import {
38
38
  import type { HostMode } from '../cst/host-mode.ts'
39
39
  import { COMPOSED_PIECES } 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'
@@ -87,10 +89,59 @@ export type ParsecraftPluginOptions = {
87
89
  /** Emit grammar-coverage hooks in macro output. Off by default, so ordinary
88
90
  * macro output remains byte-identical. */
89
91
  grammarCoverage?: boolean
92
+ /**
93
+ * OPTIONAL TUNING for selective assemblies. **Off by default.**
94
+ *
95
+ * A grammar gets generated code only for its hottest sites, within a byte budget.
96
+ * Without this option the build ranks sites by a static estimate. With it, the
97
+ * build parses `files` from rule `entry` on the closure engine (nothing is
98
+ * generated to measure) and ranks sites by measured calls per byte instead. It
99
+ * changes which sites are generated, never what a parse returns.
100
+ */
101
+ assemblyProfile?: AssemblyProfile
102
+ /**
103
+ * Generated assembly bytes each module may spend, as a multiple of its own source
104
+ * length. **Default `0.25`.** `0` ships no assembly. The right number is
105
+ * per grammar: a larger one parses faster in steady state and costs import and
106
+ * first-parse time, so measure both, and keep the artifact within 5× its source
107
+ * (docs/design/runtime-and-size-contract.md, rules 4 and 6).
108
+ */
109
+ assemblyBudget?: number
110
+ }
111
+
112
+ /** A corpus a build parses to rank selective-assembly sites — see `assemblyProfile`. */
113
+ export type AssemblyProfile = {
114
+ /** The rule each corpus file is parsed from, e.g. `'Stylesheet'`. A grammar
115
+ * without this rule keeps the static ranking. */
116
+ readonly entry: string
117
+ /** Paths of representative inputs. */
118
+ readonly files: readonly string[]
119
+ }
120
+
121
+ /** Corpus file contents, re-read when a file's mtime moves, so a watch build that
122
+ * re-lowers after a corpus edit ranks by the edited corpus. */
123
+ const corpusCache = new Map<string, { readonly mtimeMs: number; readonly text: string }>()
124
+ function corpusTexts(profile: AssemblyProfile): readonly string[] {
125
+ return profile.files.map(file => {
126
+ const mtimeMs = fs.statSync(file).mtimeMs
127
+ const hit = corpusCache.get(file)
128
+ if (hit !== undefined && hit.mtimeMs === mtimeMs) return hit.text
129
+ const text = fs.readFileSync(file, 'utf8')
130
+ corpusCache.set(file, { mtimeMs, text })
131
+ return text
132
+ })
90
133
  }
91
134
 
92
135
  const PARSEMAN_MODULE = 'parseman'
93
136
 
137
+ /**
138
+ * The default `assemblyBudget`: generated assembly bytes a module may spend, as a
139
+ * multiple of its own source length. A quarter of the source keeps every jess
140
+ * grammar far under the 5x ceiling (contract rule 4); the 0.57.0 CHANGELOG has the
141
+ * measured curve.
142
+ */
143
+ const DEFAULT_ASSEMBLY_BUDGET = 0.25
144
+
94
145
  /**
95
146
  * The REASON a lowering refused, appended to the warning that reports it.
96
147
  *
@@ -280,11 +331,14 @@ export default createUnplugin((opts: ParsecraftPluginOptions = {}) => ({
280
331
  return /\.[jt]sx?$/.test(id) && !id.includes('node_modules')
281
332
  },
282
333
 
283
- transform(this: { warn?: (msg: string) => void }, code: string, id: string) {
334
+ transform(this: { warn?: (msg: string) => void; addWatchFile?: (file: string) => void }, code: string, id: string) {
284
335
  if (!code.includes('parseman')) return null
285
336
  if (!code.includes('macro')) return null
337
+ // A corpus edit changes which sites a profiled build generates, so a watch
338
+ // build re-lowers when one does.
339
+ for (const file of opts.assemblyProfile?.files ?? []) this?.addWatchFile?.(file)
286
340
  const moduleAliases = new Set([PARSEMAN_MODULE, ...(opts.moduleAliases ?? [])])
287
- const result = transformMacro(code, id, moduleAliases, opts.warnUnloweredRegex === true, opts.recovery === true, opts.grammarCoverage === true)
341
+ const result = transformMacro(code, id, moduleAliases, opts.warnUnloweredRegex === true, opts.recovery === true, opts.grammarCoverage === true, opts.assemblyProfile, opts.assemblyBudget)
288
342
  if (result?.warnings.length) {
289
343
  for (const w of result.warnings) {
290
344
  if (typeof this?.warn === 'function') this.warn(`[parseman] ${w}`)
@@ -544,10 +598,12 @@ export function transformMacro(
544
598
  warnUnloweredRegex = false,
545
599
  recovery = false,
546
600
  grammarCoverage = false,
601
+ assemblyProfile?: AssemblyProfile,
602
+ assemblyBudget = DEFAULT_ASSEMBLY_BUDGET,
547
603
  ): TransformMacroResult | null {
548
604
  const depth = degradationCaptureDepth()
549
605
  try {
550
- return transformMacroImpl(code, id, moduleAliases, warnUnloweredRegex, recovery, grammarCoverage)
606
+ return transformMacroImpl(code, id, moduleAliases, warnUnloweredRegex, recovery, grammarCoverage, assemblyProfile, assemblyBudget)
551
607
  } finally {
552
608
  setReducerResolver(null)
553
609
  setBuilderImportResolver(null)
@@ -564,6 +620,8 @@ function transformMacroImpl(
564
620
  warnUnloweredRegex: boolean,
565
621
  recovery: boolean,
566
622
  grammarCoverage: boolean,
623
+ assemblyProfile: AssemblyProfile | undefined,
624
+ assemblyBudgetShare: number,
567
625
  ): TransformMacroResult | null {
568
626
  let result: ReturnType<typeof parseSync>
569
627
  try {
@@ -1984,9 +2042,10 @@ function transformMacroImpl(
1984
2042
  carried,
1985
2043
  reflection: compiled.reflection,
1986
2044
  })
2045
+ const select = selectAssembly(init.start, 'compose()', compiled, !declaresLeaf())
1987
2046
  return {
1988
- replacement: compiled.replacementWithMetadata(reflectionMetadata),
1989
- exportedReplacement: compiled.replacementWithMetadata(exportedMetadata),
2047
+ replacement: assembledReplacement(compiled, reflectionMetadata, select),
2048
+ exportedReplacement: assembledReplacement(compiled, exportedMetadata, select),
1990
2049
  carried,
1991
2050
  ...(composing ? { trivia: composing } : {}),
1992
2051
  ...(importedFactories.length ? { importedFactories } : {}),
@@ -2006,6 +2065,74 @@ function transformMacroImpl(
2006
2065
  return null
2007
2066
  }
2008
2067
 
2068
+ /**
2069
+ * SELECTIVE ASSEMBLY, BUDGETED PER MODULE. A fused grammar's default AST/no-lines
2070
+ * table carries generated code for its hottest sites only (`select-hot.ts`); the
2071
+ * closure engine runs the rest. The site bodies of every fused grammar in this
2072
+ * module together may not exceed `assemblyBudget` times the module's own source.
2073
+ *
2074
+ * A module that declares a `composeLeaf` has named its terminal parser, so its
2075
+ * `compose()` results are composable PIECES: another build re-lowers them and
2076
+ * never parses through their table, so they spend nothing. Without a
2077
+ * `composeLeaf`, a `compose()` result is the module's parser.
2078
+ */
2079
+ let assemblyBudget = Math.floor(code.length * assemblyBudgetShare)
2080
+ let leafDeclared: boolean | undefined
2081
+ const declaresLeaf = (): boolean => leafDeclared ??= (body as Statement[]).some(stmt => {
2082
+ const vd = unwrapVd(stmt)
2083
+ return vd !== null && vd.declarations.some(d => d.init != null && isComposeLeafCall(d.init as Expression))
2084
+ })
2085
+ const selectAssembly = (
2086
+ pos: number,
2087
+ label: string,
2088
+ compiled: CompiledRuleMapTable,
2089
+ terminal: boolean,
2090
+ ): ReadonlySet<number> => {
2091
+ // A coverage build instruments the table; its counters are not the shipping
2092
+ // parser, so it carries no assembly.
2093
+ if (!terminal || grammarCoverage) return new Set()
2094
+ const { sites, bytes, refusal } = compiled.selectAssembly(assemblyBudget, profileOf(compiled))
2095
+ // The artifact still parses, on the closure engine, but a build that would
2096
+ // have generated code says which construct stopped it rather than fall back
2097
+ // silently. Not `warn()`: every declaration still lowers, so the module stays
2098
+ // fully resolved.
2099
+ if (refusal !== undefined) {
2100
+ warnings.push(`${id}:${lineOf(pos)} — ${label}: its precompiled assembly cannot emit ${refusal}; the artifact runs on the closure engine`)
2101
+ }
2102
+ assemblyBudget -= bytes
2103
+ return sites
2104
+ }
2105
+ /** Measured site calls over the optional corpus, or `undefined` for the static
2106
+ * ranking. A file whose parse throws still contributes the calls it made. */
2107
+ const profileOf = (compiled: CompiledRuleMapTable): Int32Array | undefined => {
2108
+ // Only the default AST/no-lines table carries an assembly, so only it is profiled.
2109
+ if (assemblyProfile === undefined || compiled.hostMode !== 'ast' || compiled.prog.lines === 1
2110
+ || !(assemblyProfile.entry in compiled.prog.rules)) return undefined
2111
+ let parsed = 0
2112
+ // A builder re-lowered from carried IR is inert at build time; for COUNTING
2113
+ // only, a plain node stands in for it, so the corpus can run past it.
2114
+ const fns = compiled.prog.fns.map(fn => {
2115
+ const type = (fn as { [INERT_BUILDER]?: string } | null)?.[INERT_BUILDER]
2116
+ return type === undefined ? fn
2117
+ : (children: unknown, _fields: unknown, span: unknown) => ({ type, span, children })
2118
+ })
2119
+ const counts = countSiteVisits(compiled.prog, rules => {
2120
+ const entry = rules[assemblyProfile.entry]!
2121
+ for (const text of corpusTexts(assemblyProfile)) {
2122
+ try { if (run(entry, text).ok) parsed++ } catch { /* the calls it made still count */ }
2123
+ }
2124
+ }, fns)
2125
+ if (parsed > 0) return counts
2126
+ // Not `warn()`: nothing fell back to the interpreter — the build is only untuned.
2127
+ warnings.push(`${id} — assemblyProfile: no corpus file parsed from rule "${assemblyProfile.entry}"; using the static ranking`)
2128
+ return undefined
2129
+ }
2130
+ /** The fused table's replacement, with its selective default assembly if any. */
2131
+ const assembledReplacement = (compiled: CompiledRuleMapTable, metadata: string, select: ReadonlySet<number>): string =>
2132
+ select.size === 0
2133
+ ? compiled.replacementWithMetadata(metadata)
2134
+ : supercompileRuleMapReplacement(compiled.prog, compiled.replacementWithMetadata(metadata, { select }), select)
2135
+
2009
2136
  /**
2010
2137
  * Compile a terminal composition. Imported/base pieces still travel as normal
2011
2138
  * re-lowerable IR, but the final local rules map is compiled directly in this
@@ -2136,12 +2263,10 @@ function transformMacroImpl(
2136
2263
  if (compiled !== null) {
2137
2264
  usedTableRuntime = true
2138
2265
  return {
2139
- replacement: supercompileRuleMapReplacement(
2140
- compiled.prog,
2141
- compiled.replacementWithMetadata(
2142
- staticTableMetadataSource({ reflection: compiled.reflection, leaf: true }),
2143
- { precompileDefault: true },
2144
- ),
2266
+ replacement: assembledReplacement(
2267
+ compiled,
2268
+ staticTableMetadataSource({ reflection: compiled.reflection, leaf: true }),
2269
+ selectAssembly(init.start, 'composeLeaf()', compiled, true),
2145
2270
  ),
2146
2271
  ...(importedFactories.length ? { importedFactories } : {}),
2147
2272
  }
@@ -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
@@ -64,11 +64,12 @@
64
64
  * the three-way identity sweep gates this against it.
65
65
  */
66
66
  import { rebuildLineScan } from './line-scan.ts'
67
- import { scanRefs } from './program.ts'
67
+ import { assemblyExtraIps } from './program.ts'
68
68
  import type { Combinator, FieldMap, FirstSet, ParseContext, ParseResult, ParserDef } from '../types.ts'
69
69
  import { balanced, scanTo } from '../combinators/scanTo.ts'
70
70
  import { buildFieldMap } from '../compiler/fields.ts'
71
71
  import { FIELD_LEADING_TRIVIA, FIELD_TRAILING_TRIVIA, LEAD, triviaFieldCapture } from '../combinators/field-trivia.ts'
72
+ import { skipDeferredTrivia } from './trivia-gap.ts'
72
73
  import { asciiFoldKey } from '../combinators/dispatch.ts'
73
74
  import { projectChild, unwrapChild } from '../combinators/node.ts'
74
75
  import { cstOutputHost } from '../compiler/build-arity.ts'
@@ -122,7 +123,7 @@ import { lead, rawEntry, spanLines } from './run-support.ts'
122
123
  * property read per terminal miss — the same price codegen pays.
123
124
  */
124
125
  import {
125
- classHas, decodeClassSpec, expandCompact, resolveTable,
126
+ classHas, decodeClassSpec, expandCompact, ownTableProgram, resolveTable,
126
127
  validateDispatchSpec,
127
128
  type CompactProgram, type ResolvedClass, type ResolvedTable,
128
129
  type SubtreeRef, type TableProgram, type TableRule,
@@ -477,6 +478,15 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
477
478
  // Value/CST/failure/cursor effects stay in those consumers.
478
479
  const scalarRecognizers: Array<ScalarRecognizer | undefined> = []
479
480
  const rawScalarSpecs = new Set<number>()
481
+ /**
482
+ * The class of an `optional`/`not`/`peek` child's first-character gate
483
+ * (`encode.ts` `lookaheadGate`), tested before the wrapper takes its mark.
484
+ * `null` where there is none, or where a probe or tolerant parse forwards
485
+ * through the gate (`OP_GATE` below).
486
+ */
487
+ function lookaheadClass(child: number): ResolvedClass | null {
488
+ return code[child] === OP_GATE && !cfg.tolerant && !cfg.probe ? cc[code[child + 1]!]! : null
489
+ }
480
490
  function scalarFor(child: number): ScalarRecognizer | undefined {
481
491
  const spec = code[child + 1]!
482
492
  let recognize = scalarRecognizers[spec]
@@ -487,9 +497,7 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
487
497
  return recognize
488
498
  }
489
499
 
490
- const labelExtraIps: number[] = []
491
- for (const s of prog.scans ?? []) for (const r of scanRefs(s)) labelExtraIps.push(r[0])
492
- for (const set of prog.scanSkip ?? []) for (const r of set) labelExtraIps.push(r[0])
500
+ const labelExtraIps = assemblyExtraIps(prog)
493
501
  const closureLabels = computeSiteLabels(
494
502
  code, [...Object.values(prog.rules), ...labelExtraIps], hostCst,
495
503
  )
@@ -504,6 +512,23 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
504
512
  */
505
513
  let SCAN: FastTriviaScanner | null = null
506
514
 
515
+ /**
516
+ * A SELECTIVE build-time assembly: emitted bodies for its selected sites, this
517
+ * engine for the rest. `link` hands out its exported sites, wrapped so the
518
+ * callee sees this engine's installed scanner — the one slot both engines keep
519
+ * separately (the end cell `EC` is already shared).
520
+ */
521
+ let HYBRID: EmittedAssembly | undefined
522
+ function hybridPiece(piece: Piece): Piece {
523
+ const setScan = HYBRID!.scan!
524
+ return (input, pos, ctx) => {
525
+ const saved = setScan(SCAN)
526
+ const value = piece(input, pos, ctx)
527
+ setScan(saved)
528
+ return value
529
+ }
530
+ }
531
+
507
532
  const memo = new Map<number, Piece>()
508
533
  const reached = new Set<number>()
509
534
 
@@ -906,9 +931,7 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
906
931
  // Appended rather than folded into the test above so the non-capturing path
907
932
  // keeps the exact branch it had.
908
933
  if (s !== null) return skipTriviaScanned(s, input, cur, ctx)
909
- if (needsDeferredTriviaCommit(ctx)) {
910
- return commitTriviaScan(scanTriviaCompact(input, cur, ctx))
911
- }
934
+ if (needsDeferredTriviaCommit(ctx)) return skipDeferredTrivia(input, cur, ctx)
912
935
  return advanceTrivia(input, cur, ctx)
913
936
  }
914
937
 
@@ -1159,7 +1182,9 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
1159
1182
  if (next !== undefined) {
1160
1183
  return next(input, pos, ctx, bits, need, mRaw, mTl, mLv, mFl, mEr, mLog, mRoot, acc, prev, best)
1161
1184
  }
1162
- if (best === pos) acc = through(total, prev, acc)
1185
+ // No arm entered (`prev` 0, `acc` unset): the catch-up would rebuild
1186
+ // `choiceFx`, the arms' flatMap, set by set. It IS the answer.
1187
+ if (best === pos && prev !== 0) acc = through(total, prev, acc)
1163
1188
  ctx._fe = pos; ctx._fx = acc ?? choiceFx
1164
1189
  return FAIL
1165
1190
  }
@@ -1228,7 +1253,9 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
1228
1253
  if (next !== undefined) {
1229
1254
  return next(input, pos, ctx, c, need, mRaw, mTl, mLv, mFl, mEr, mLog, mRoot, acc, prev, best)
1230
1255
  }
1231
- if (best === pos) acc = through(total, prev, acc)
1256
+ // No arm entered (`prev` 0, `acc` unset): the catch-up would rebuild
1257
+ // `choiceFx`, the arms' flatMap, set by set. It IS the answer.
1258
+ if (best === pos && prev !== 0) acc = through(total, prev, acc)
1232
1259
  ctx._fe = pos; ctx._fx = acc ?? choiceFx
1233
1260
  return FAIL
1234
1261
  }
@@ -1339,6 +1366,13 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
1339
1366
  if (done !== undefined) return done
1340
1367
  const flight = inFlight.get(ip)
1341
1368
  if (flight !== undefined) return flight.fwd
1369
+ const selected = HYBRID?.byIp[ip]
1370
+ if (selected !== undefined) {
1371
+ const piece = hybridPiece(selected as Piece)
1372
+ memo.set(ip, piece)
1373
+ reached.add(ip)
1374
+ return piece
1375
+ }
1342
1376
 
1343
1377
  let target: Piece | undefined
1344
1378
  const fwd: Piece = (input, pos, ctx) => target!(input, pos, ctx)
@@ -1354,7 +1388,7 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
1354
1388
  && code[ip + 1]! >= 0
1355
1389
  && closureLabels.at(ip).tri === code[ip + 1]!
1356
1390
  ? link(code[ip + 2]!)
1357
- : lower(ip)
1391
+ : countedPiece(lower(ip), ip)
1358
1392
 
1359
1393
  inFlight.delete(ip)
1360
1394
  holder.set(piece)
@@ -1996,7 +2030,9 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
1996
2030
  }
1997
2031
  const child = link(code[ip + 1]!)
1998
2032
  const xf = fx[code[ip + 2]!] as string[]
2033
+ const gate = lookaheadClass(code[ip + 1]!)
1999
2034
  return (input, pos, ctx) => {
2035
+ if (gate !== null && !classHas(gate, lead(input, pos))) { EC.e = pos; return null }
2000
2036
  const need = markCst(ctx)
2001
2037
  const mRaw = MRAW
2002
2038
  const mTl = MTL
@@ -2018,7 +2054,9 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
2018
2054
  case OP_PEEK: {
2019
2055
  const child = link(code[ip + 1]!)
2020
2056
  const xf = fx[code[ip + 2]!] as string[]
2057
+ const gate = lookaheadClass(code[ip + 1]!)
2021
2058
  return (input, pos, ctx) => {
2059
+ if (gate !== null && !classHas(gate, lead(input, pos))) { ctx._fe = pos; ctx._fx = xf; return FAIL }
2022
2060
  const need = markCst(ctx)
2023
2061
  const mRaw = MRAW
2024
2062
  const mTl = MTL
@@ -2038,7 +2076,9 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
2038
2076
 
2039
2077
  case OP_OPT: {
2040
2078
  const child = link(code[ip + 1]!)
2079
+ const gate = lookaheadClass(code[ip + 1]!)
2041
2080
  return (input, pos, ctx) => {
2081
+ if (gate !== null && !classHas(gate, lead(input, pos))) { EC.e = pos; return null }
2042
2082
  const need = markCst(ctx)
2043
2083
  const mRaw = MRAW
2044
2084
  const mTl = MTL
@@ -2915,6 +2955,7 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
2915
2955
  if (committed(ctx)) { if (acc !== undefined) ctx._fx = acc; return FAIL }
2916
2956
  if (need) rollbackTriviaAt(ctx, mRaw, mTl, mLv, mFl, mEr, mLog, mRoot)
2917
2957
  }
2958
+ if (prev === 0) { ctx._fe = pos; ctx._fx = choiceFx; return FAIL }
2918
2959
  if (best === pos && prev < 1) acc = accSet(e0, acc)
2919
2960
  if (best === pos && prev < 2) acc = accSet(e1, acc)
2920
2961
  if (best === pos && n === 3 && prev < 3) acc = accSet(e2, acc)
@@ -3134,9 +3175,10 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
3134
3175
  return (input, pos, ctx) => {
3135
3176
  if (!pred(ctx.state)) {
3136
3177
  // A `continue` in the interpreter (choice.ts:150), not a failure —
3137
- // so no cut survives to the enclosing choice.
3178
+ // so no cut survives to the enclosing choice, and no failure position
3179
+ // ranks it on the ordered path. A dispatched choice reads `_fx` alone.
3138
3180
  ctx._fc = false
3139
- ctx._fe = pos
3181
+ ctx._fe = -1
3140
3182
  ctx._fx = xf
3141
3183
  return FAIL
3142
3184
  }
@@ -3663,9 +3705,7 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
3663
3705
  if (v === FAIL) return FAIL
3664
3706
  const end = EC.e
3665
3707
  const span = tracked ? spanLines(ctx, pos, end) : { start: pos, end }
3666
- const st = readsState && ctx.state !== undefined
3667
- ? Object.assign({}, ctx.state as Record<string, unknown>)
3668
- : undefined
3708
+ const st = readsState ? ctx.state : undefined
3669
3709
 
3670
3710
  let nd: unknown
3671
3711
  if (unwrap && kids.length === 1) {
@@ -3685,12 +3725,12 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
3685
3725
  nd = kids[0]
3686
3726
  } else if (proj >= 0) {
3687
3727
  nd = hostCst && host !== undefined
3688
- ? host(type, kids, fieldMap, span, rawKids, tlog, readsState ? st : ctx.state !== undefined ? Object.assign({}, ctx.state as Record<string, unknown>) : undefined, tags)
3728
+ ? host(type, kids, fieldMap, span, rawKids, tlog, ctx.state, tags)
3689
3729
  : projectChild(kids, proj, type)
3690
3730
  } else if (build !== undefined) {
3691
3731
  if (hostCst && host !== undefined) {
3692
3732
  // A direct builder is bypassed under a CST host.
3693
- nd = host(type, kids, fieldMap, span, rawKids, tlog, readsState ? st : ctx.state !== undefined ? Object.assign({}, ctx.state as Record<string, unknown>) : undefined, tags)
3733
+ nd = host(type, kids, fieldMap, span, rawKids, tlog, ctx.state, tags)
3694
3734
  } else {
3695
3735
  nd = build(kids, fieldMap, span, rawKids, captureWide ? tlog : EMPTY_TL, st)
3696
3736
  }
@@ -3777,9 +3817,7 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
3777
3817
  // The scan pool and the scan-skip sets are built FROM subtrees, so their
3778
3818
  // sites need emitted names too or `subtreeComb` would have to fall back to
3779
3819
  // a closure for them and the two engines would both be live in one parse.
3780
- const extraIps: number[] = []
3781
- for (const s of prog.scans ?? []) for (const r of scanRefs(s)) extraIps.push(r[0])
3782
- for (const set of prog.scanSkip ?? []) for (const r of set) extraIps.push(r[0])
3820
+ const extraIps = assemblyExtraIps(prog)
3783
3821
  const roots = [...Object.values(prog.rules), ...extraIps]
3784
3822
  for (const ip of reachableSites(code, roots)) {
3785
3823
  if (code[ip] === OP_DISPATCH) {
@@ -3852,9 +3890,10 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
3852
3890
  REC ? prog.cc.map((_, i) => sentinelFor(i)) : EMPTY_SENTS,
3853
3891
  matchesAt, recoverScan, orSentinel, captureError,
3854
3892
  scalarRecognizers, commitTriviaScan, scanTriviaCompact, t.lex, adjacencyHolds, t.lexPrograms,
3855
- LEAD, triviaFieldCapture,
3893
+ LEAD, triviaFieldCapture, skipDeferredTrivia,
3856
3894
  )
3857
3895
  emitReached = new Set(pre.reached)
3896
+ if (emitted.ext !== undefined) HYBRID = emitted
3858
3897
  }
3859
3898
  /**
3860
3899
  * ONCE A PROGRAM CARRIES ASSEMBLIES, THE CONSTRUCTOR IS OFF FOR IT — for
@@ -3934,7 +3973,7 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
3934
3973
  REC ? prog.cc.map((_, i) => sentinelFor(i)) : EMPTY_SENTS,
3935
3974
  matchesAt, recoverScan, orSentinel, captureError,
3936
3975
  scalarRecognizers, commitTriviaScan, scanTriviaCompact, t.lex, adjacencyHolds, t.lexPrograms,
3937
- LEAD, triviaFieldCapture,
3976
+ LEAD, triviaFieldCapture, skipDeferredTrivia,
3938
3977
  )
3939
3978
  emitReached = em.reached
3940
3979
  } catch (e) {
@@ -3947,7 +3986,7 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
3947
3986
  /** One piece for a site, from whichever engine this assembly is running. */
3948
3987
  function pieceAt(ip: number): Piece {
3949
3988
  const em = emitted
3950
- if (em !== undefined) {
3989
+ if (em !== undefined && HYBRID === undefined) {
3951
3990
  const p = em.byIp[ip]
3952
3991
  if (p === undefined) throw new Error(`table emitter: no emitted body for site ${ip}`)
3953
3992
  return p as Piece
@@ -3982,6 +4021,28 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
3982
4021
 
3983
4022
  /* ── link the rules ──────────────────────────────────────────────────────── */
3984
4023
 
4024
+ if (HYBRID !== undefined) {
4025
+ const em = HYBRID
4026
+ em.bind!(
4027
+ s => { const saved = SCAN; SCAN = s as FastTriviaScanner | null; return saved },
4028
+ em.ext!.map(ip => link(ip)),
4029
+ )
4030
+ const pieces: Record<string, Piece> = {}
4031
+ for (const [name, entryIp] of Object.entries(prog.rules)) {
4032
+ pieces[name] = (em.pieces[name] as Piece | undefined) ?? link(entryIp)
4033
+ }
4034
+ for (const ip of emitReached!) reached.add(ip)
4035
+ return {
4036
+ pieces,
4037
+ end: () => EC.e,
4038
+ begin: ctx => { begin(ctx); em.begin(ctx) },
4039
+ finish: () => { em.finish(); finish() },
4040
+ scanSkip,
4041
+ reached,
4042
+ emitRefusal: undefined,
4043
+ }
4044
+ }
4045
+
3985
4046
  if (emitted !== undefined) {
3986
4047
  const em = emitted
3987
4048
  return {
@@ -4034,6 +4095,43 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
4034
4095
  return { pieces, end: () => EC.e, begin, finish, scanSkip, reached, emitRefusal }
4035
4096
  }
4036
4097
 
4098
+ /**
4099
+ * BUILD-TIME SITE PROFILING (`countSiteVisits`). Set only while that call runs;
4100
+ * `link` reads it when it LOWERS a site, never per parse, so an ordinary assembly
4101
+ * carries no counter and no branch.
4102
+ */
4103
+ let SITE_COUNTS: Int32Array | undefined
4104
+
4105
+ function countedPiece(piece: Piece, ip: number): Piece {
4106
+ const counts = SITE_COUNTS
4107
+ if (counts === undefined) return piece
4108
+ return (input, pos, ctx) => {
4109
+ counts[ip]!++
4110
+ return piece(input, pos, ctx)
4111
+ }
4112
+ }
4113
+
4114
+ /**
4115
+ * How many times each table site runs while `parse` drives the program — the
4116
+ * profile an optional corpus gives selective assembly (`select-hot.ts`). Runs the
4117
+ * CLOSURE engine, so it constructs no code; `counts[ip]` is the number of calls
4118
+ * of the piece lowered for site `ip` (an alias site counts at its target).
4119
+ */
4120
+ export function countSiteVisits(
4121
+ prog: TableProgram,
4122
+ parse: (rules: Record<string, TableRule>) => void,
4123
+ /** A replacement callback pool (same indices), e.g. stand-ins for inert builders. */
4124
+ fns: readonly unknown[] = prog.fns,
4125
+ ): Int32Array {
4126
+ const counts = new Int32Array(prog.code.length)
4127
+ // A fresh program, not a spread: a spread keeps the source's cached resolution,
4128
+ // which holds the ORIGINAL callback pool.
4129
+ const profiled = ownTableProgram({ ...prog, fns, asm: [] })
4130
+ SITE_COUNTS = counts
4131
+ try { parse(tableRules(profiled)) } finally { SITE_COUNTS = undefined }
4132
+ return counts
4133
+ }
4134
+
4037
4135
  /**
4038
4136
  * Assemblies for one resolved table, one per option set, built on demand.
4039
4137
  *
@@ -19,12 +19,10 @@ import {
19
19
  * only against it happening WITHIN that file.
20
20
  *
21
21
  * The subset was never load-bearing. The five opcodes it omitted — COV, GREEDY,
22
- * REJECT, ARMGATE, WITHCTX — are exactly the five `emit-assembly.ts` has no case
23
- * for, so any program containing one throws `Unemittable` from the default arm and
24
- * the whole assembly falls back to closures. Walking into their subtrees can
25
- * therefore only affect a result that is about to be discarded. (Verified: none of
26
- * the five is so much as imported by `emit-assembly.ts`. Its one consumer of these
27
- * edges, the `fd`/`er` sink census, is computed before the lowering that throws.)
22
+ * REJECT, ARMGATE, WITHCTX — were exactly the five `emit-assembly.ts` had no case
23
+ * for. ARMGATE and WITHCTX are lowered there now, so their subtrees are walked like
24
+ * any other; COV, GREEDY and REJECT still throw `Unemittable` from the default arm,
25
+ * and the whole assembly falls back to closures.
28
26
  *
29
27
  * `code` is `ArrayLike<number>` because the two callers hold different
30
28
  * representations of the same stream — `TableProgram.code` is a `readonly number[]`,