parseman 0.55.4 → 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 (125) hide show
  1. package/dist/analysis/choice-cost.d.ts.map +1 -1
  2. package/dist/analysis/commitment.d.ts.map +1 -1
  3. package/dist/analysis/diagnostics.js +3745 -3180
  4. package/dist/analysis/diagnostics.js.map +3 -3
  5. package/dist/analysis/duplication.d.ts.map +1 -1
  6. package/dist/analysis/gating.d.ts.map +1 -1
  7. package/dist/cli/index.js +2182 -1617
  8. package/dist/cli/index.js.map +3 -3
  9. package/dist/combinators/attempt.d.ts.map +1 -1
  10. package/dist/combinators/choice.d.ts.map +1 -1
  11. package/dist/combinators/dispatch.d.ts.map +1 -1
  12. package/dist/combinators/expect.d.ts.map +1 -1
  13. package/dist/combinators/first-set.d.ts.map +1 -1
  14. package/dist/combinators/lines.d.ts +79 -0
  15. package/dist/combinators/lines.d.ts.map +1 -0
  16. package/dist/combinators/node.d.ts +1 -1
  17. package/dist/combinators/trivia-skip.d.ts +1 -0
  18. package/dist/combinators/trivia-skip.d.ts.map +1 -1
  19. package/dist/compiler/build-arity.d.ts +1 -1
  20. package/dist/compiler/build-arity.d.ts.map +1 -1
  21. package/dist/compiler/fields.d.ts.map +1 -1
  22. package/dist/compiler/grammar-coverage-ids.d.ts.map +1 -1
  23. package/dist/compiler/ir-serialize.d.ts.map +1 -1
  24. package/dist/compiler/token-alphabet.d.ts.map +1 -1
  25. package/dist/compiler/token-capability.js +25 -0
  26. package/dist/compiler/token-capability.js.map +1 -1
  27. package/dist/coverage.d.ts.map +1 -1
  28. package/dist/cst/reflection.d.ts.map +1 -1
  29. package/dist/cst/trivia-kinds.d.ts +9 -1
  30. package/dist/cst/trivia-kinds.d.ts.map +1 -1
  31. package/dist/index.d.ts +2 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +2348 -1697
  34. package/dist/index.js.map +3 -3
  35. package/dist/language-service/index.js +3 -0
  36. package/dist/language-service/index.js.map +1 -1
  37. package/dist/parse-context.d.ts.map +1 -1
  38. package/dist/plugin/entry-supercompiler.d.ts +1 -1
  39. package/dist/plugin/entry-supercompiler.d.ts.map +1 -1
  40. package/dist/plugin/evaluator.d.ts.map +1 -1
  41. package/dist/plugin/index.d.ts +27 -1
  42. package/dist/plugin/index.d.ts.map +1 -1
  43. package/dist/plugin/index.js +2360 -1314
  44. package/dist/plugin/index.js.map +3 -3
  45. package/dist/plugin/ir-eval.d.ts +2 -4
  46. package/dist/plugin/ir-eval.d.ts.map +1 -1
  47. package/dist/regex/classes.d.ts.map +1 -1
  48. package/dist/run/index.js +3 -0
  49. package/dist/run/index.js.map +1 -1
  50. package/dist/spec/index.js +15 -0
  51. package/dist/spec/index.js.map +1 -1
  52. package/dist/spec/model.d.ts.map +1 -1
  53. package/dist/table/assemble.d.ts +9 -65
  54. package/dist/table/assemble.d.ts.map +1 -1
  55. package/dist/table/child-slots.d.ts +4 -6
  56. package/dist/table/child-slots.d.ts.map +1 -1
  57. package/dist/table/compile-rule-map.d.ts +14 -2
  58. package/dist/table/compile-rule-map.d.ts.map +1 -1
  59. package/dist/table/emit-assembly.d.ts +17 -2
  60. package/dist/table/emit-assembly.d.ts.map +1 -1
  61. package/dist/table/emit.d.ts +5 -1
  62. package/dist/table/emit.d.ts.map +1 -1
  63. package/dist/table/encode.d.ts.map +1 -1
  64. package/dist/table/exec-baseline.d.ts.map +1 -1
  65. package/dist/table/exec.d.ts.map +1 -1
  66. package/dist/table/index.js +3884 -3262
  67. package/dist/table/index.js.map +3 -3
  68. package/dist/table/line-scan.d.ts +9 -0
  69. package/dist/table/line-scan.d.ts.map +1 -0
  70. package/dist/table/ops.d.ts +9 -6
  71. package/dist/table/ops.d.ts.map +1 -1
  72. package/dist/table/program.d.ts +24 -2
  73. package/dist/table/program.d.ts.map +1 -1
  74. package/dist/table/select-hot.d.ts +6 -0
  75. package/dist/table/select-hot.d.ts.map +1 -0
  76. package/dist/table/trivia-gap.d.ts +31 -0
  77. package/dist/table/trivia-gap.d.ts.map +1 -0
  78. package/dist/types.d.ts +38 -0
  79. package/dist/types.d.ts.map +1 -1
  80. package/dist/version.d.ts +1 -1
  81. package/package.json +4 -1
  82. package/src/analysis/choice-cost.ts +1 -0
  83. package/src/analysis/commitment.ts +13 -0
  84. package/src/analysis/duplication.ts +3 -0
  85. package/src/analysis/gating.ts +5 -0
  86. package/src/analysis/rebuild.ts +2 -2
  87. package/src/combinators/attempt.ts +2 -0
  88. package/src/combinators/choice.ts +3 -0
  89. package/src/combinators/dispatch.ts +3 -0
  90. package/src/combinators/expect.ts +5 -0
  91. package/src/combinators/first-set.ts +9 -1
  92. package/src/combinators/lines.ts +244 -0
  93. package/src/combinators/node.ts +6 -13
  94. package/src/combinators/trivia-skip.ts +48 -3
  95. package/src/compiler/build-arity.ts +3 -3
  96. package/src/compiler/fields.ts +3 -0
  97. package/src/compiler/grammar-coverage-ids.ts +1 -0
  98. package/src/compiler/ir-serialize.ts +10 -0
  99. package/src/compiler/token-alphabet.ts +3 -1
  100. package/src/coverage.ts +6 -0
  101. package/src/cst/reflection.ts +3 -0
  102. package/src/cst/trivia-kinds.ts +12 -1
  103. package/src/index.ts +2 -0
  104. package/src/parse-context.ts +3 -0
  105. package/src/plugin/entry-supercompiler.ts +8 -9
  106. package/src/plugin/evaluator.ts +4 -0
  107. package/src/plugin/index.ts +138 -13
  108. package/src/plugin/ir-eval.ts +10 -1
  109. package/src/regex/classes.ts +3 -0
  110. package/src/spec/model.ts +15 -0
  111. package/src/table/assemble.ts +125 -33
  112. package/src/table/child-slots.ts +4 -6
  113. package/src/table/compile-rule-map.ts +55 -15
  114. package/src/table/emit-assembly.ts +139 -26
  115. package/src/table/emit.ts +13 -11
  116. package/src/table/encode.ts +87 -27
  117. package/src/table/exec-baseline.ts +1 -3
  118. package/src/table/exec.ts +10 -6
  119. package/src/table/line-scan.ts +19 -0
  120. package/src/table/ops.ts +9 -6
  121. package/src/table/program.ts +41 -2
  122. package/src/table/select-hot.ts +70 -0
  123. package/src/table/trivia-gap.ts +109 -0
  124. package/src/types.ts +32 -0
  125. 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
  }
@@ -2,6 +2,7 @@
2
2
  * The macro plugin's IR evaluator: turns a composed grammar's carried IR back
3
3
  * into combinators so the build can re-lower it. Build time only — see below.
4
4
  */
5
+ import { columns, lines } from '../combinators/lines.ts'
5
6
  import type { Combinator } from '../types.ts'
6
7
  import { rules } from '../combinators/parser.ts'
7
8
  import { ref } from '../combinators/ref.ts'
@@ -26,6 +27,9 @@ import { adjacent, notAdjacent } from '../combinators/adjacency.ts'
26
27
 
27
28
  type Comb = Combinator<unknown>
28
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
+
29
33
  /** Reconstruct a rule map from serialized IR (the inverse of `serializeRuleMap`) —
30
34
  * evaluate the combinator-construction expression with every constructor in scope.
31
35
  *
@@ -87,7 +91,10 @@ function evalIR(ir: string, rulesImpl: unknown): unknown {
87
91
  // carries the callback source. The compiler is the only consumer that may
88
92
  // materialize `buildSrc`; raw IR interpretation deliberately rejects direct
89
93
  // builders rather than evaluating arbitrary captured source at runtime.
90
- 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)
91
98
  ;(n._def as { buildSrc?: string; buildStaticError?: readonly string[] }).buildSrc = src
92
99
  if (staticError !== undefined) (n._def as { buildStaticError?: readonly string[] }).buildStaticError = staticError
93
100
  // Analysis-only resolved reducer signature (see `buildAnalysisSrc`). Without it a
@@ -155,11 +162,13 @@ function evalIR(ir: string, rulesImpl: unknown): unknown {
155
162
  'rules', 'ref', 'regex', 'literal', 'keywords', 'sequence', 'choice', 'dispatch', 'when', 'startsWith', 'endsWith', 'matches', 'otherwise', 'routed', 'attempt',
156
163
  'many', 'oneOrMore', 'optional', 'sepBy', 'keepSeparator', 'not', 'peek', 'node', 'parser',
157
164
  'scanTo', 'balanced', 'token', 'leaf', 'transform', 'trivia', 'classifiedTrivia', 'label', 'field', 'expect', 'adjacent', 'notAdjacent', '_tf', '_lf', '_nd', '_gch', '_wc',
165
+ 'lines', 'columns',
158
166
  `return (${ir})`,
159
167
  )
160
168
  return fn(
161
169
  rulesImpl, ref, regex, literal, keywords, sequence, choice, dispatch, when, startsWith, endsWith, matches, otherwise, routed, attempt,
162
170
  many, oneOrMore, optional, sepBy, keepSeparator, not, peek, node, parser,
163
171
  scanTo, balanced, token, leaf, transform, trivia, classifiedTrivia, label, field, expectC, adjacent, notAdjacent, _tf, _lf, _nd, _gch, _wc,
172
+ lines, columns,
164
173
  )
165
174
  }
@@ -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
package/src/spec/model.ts CHANGED
@@ -477,6 +477,21 @@ class Builder {
477
477
  return this.walk(def.parser)
478
478
  case 'scanTo':
479
479
  return { kind: 'annotation', text: '…' }
480
+ // A run of lines: each line opens with the prefix, the first with `first`
481
+ // when given, and the body reads the lines' contents.
482
+ case 'lines':
483
+ return {
484
+ kind: 'seq',
485
+ items: [
486
+ { kind: 'annotation', text: 'each line begins with' },
487
+ this.walk(def.prefix),
488
+ ...(def.first === undefined ? [] : [{ kind: 'annotation', text: 'the first with' } as SpecNode, this.walk(def.first)]),
489
+ ...(def.lazy === undefined ? [] : [{ kind: 'annotation', text: 'or continues where' } as SpecNode, this.walk(def.lazy)]),
490
+ this.walk(def.body),
491
+ ],
492
+ }
493
+ case 'columns':
494
+ return { kind: 'annotation', text: `${def.n} columns` }
480
495
 
481
496
  // Trivia — elided by default; included as an annotation when asked.
482
497
  case 'trivia':
@@ -63,10 +63,13 @@
63
63
  * Semantics are `exec.ts`'s, case for case. That file remains the reference and
64
64
  * the three-way identity sweep gates this against it.
65
65
  */
66
+ import { rebuildLineScan } from './line-scan.ts'
67
+ import { assemblyExtraIps } from './program.ts'
66
68
  import type { Combinator, FieldMap, FirstSet, ParseContext, ParseResult, ParserDef } from '../types.ts'
67
69
  import { balanced, scanTo } from '../combinators/scanTo.ts'
68
70
  import { buildFieldMap } from '../compiler/fields.ts'
69
71
  import { FIELD_LEADING_TRIVIA, FIELD_TRAILING_TRIVIA, LEAD, triviaFieldCapture } from '../combinators/field-trivia.ts'
72
+ import { skipDeferredTrivia } from './trivia-gap.ts'
70
73
  import { asciiFoldKey } from '../combinators/dispatch.ts'
71
74
  import { projectChild, unwrapChild } from '../combinators/node.ts'
72
75
  import { cstOutputHost } from '../compiler/build-arity.ts'
@@ -120,7 +123,7 @@ import { lead, rawEntry, spanLines } from './run-support.ts'
120
123
  * property read per terminal miss — the same price codegen pays.
121
124
  */
122
125
  import {
123
- classHas, decodeClassSpec, expandCompact, resolveTable,
126
+ classHas, decodeClassSpec, expandCompact, ownTableProgram, resolveTable,
124
127
  validateDispatchSpec,
125
128
  type CompactProgram, type ResolvedClass, type ResolvedTable,
126
129
  type SubtreeRef, type TableProgram, type TableRule,
@@ -475,6 +478,15 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
475
478
  // Value/CST/failure/cursor effects stay in those consumers.
476
479
  const scalarRecognizers: Array<ScalarRecognizer | undefined> = []
477
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
+ }
478
490
  function scalarFor(child: number): ScalarRecognizer | undefined {
479
491
  const spec = code[child + 1]!
480
492
  let recognize = scalarRecognizers[spec]
@@ -485,14 +497,7 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
485
497
  return recognize
486
498
  }
487
499
 
488
- const labelExtraIps: number[] = []
489
- for (const s of prog.scans ?? []) {
490
- for (const r of s.skip) labelExtraIps.push(r[0])
491
- if (s.sentinel !== undefined) labelExtraIps.push(s.sentinel[0])
492
- if (s.recoverAt !== undefined) labelExtraIps.push(s.recoverAt[0])
493
- if (s.stopAt !== undefined) labelExtraIps.push(s.stopAt[0])
494
- }
495
- for (const set of prog.scanSkip ?? []) for (const r of set) labelExtraIps.push(r[0])
500
+ const labelExtraIps = assemblyExtraIps(prog)
496
501
  const closureLabels = computeSiteLabels(
497
502
  code, [...Object.values(prog.rules), ...labelExtraIps], hostCst,
498
503
  )
@@ -507,6 +512,23 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
507
512
  */
508
513
  let SCAN: FastTriviaScanner | null = null
509
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
+
510
532
  const memo = new Map<number, Piece>()
511
533
  const reached = new Set<number>()
512
534
 
@@ -909,9 +931,7 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
909
931
  // Appended rather than folded into the test above so the non-capturing path
910
932
  // keeps the exact branch it had.
911
933
  if (s !== null) return skipTriviaScanned(s, input, cur, ctx)
912
- if (needsDeferredTriviaCommit(ctx)) {
913
- return commitTriviaScan(scanTriviaCompact(input, cur, ctx))
914
- }
934
+ if (needsDeferredTriviaCommit(ctx)) return skipDeferredTrivia(input, cur, ctx)
915
935
  return advanceTrivia(input, cur, ctx)
916
936
  }
917
937
 
@@ -1162,7 +1182,9 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
1162
1182
  if (next !== undefined) {
1163
1183
  return next(input, pos, ctx, bits, need, mRaw, mTl, mLv, mFl, mEr, mLog, mRoot, acc, prev, best)
1164
1184
  }
1165
- 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)
1166
1188
  ctx._fe = pos; ctx._fx = acc ?? choiceFx
1167
1189
  return FAIL
1168
1190
  }
@@ -1231,7 +1253,9 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
1231
1253
  if (next !== undefined) {
1232
1254
  return next(input, pos, ctx, c, need, mRaw, mTl, mLv, mFl, mEr, mLog, mRoot, acc, prev, best)
1233
1255
  }
1234
- 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)
1235
1259
  ctx._fe = pos; ctx._fx = acc ?? choiceFx
1236
1260
  return FAIL
1237
1261
  }
@@ -1342,6 +1366,13 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
1342
1366
  if (done !== undefined) return done
1343
1367
  const flight = inFlight.get(ip)
1344
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
+ }
1345
1376
 
1346
1377
  let target: Piece | undefined
1347
1378
  const fwd: Piece = (input, pos, ctx) => target!(input, pos, ctx)
@@ -1357,7 +1388,7 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
1357
1388
  && code[ip + 1]! >= 0
1358
1389
  && closureLabels.at(ip).tri === code[ip + 1]!
1359
1390
  ? link(code[ip + 2]!)
1360
- : lower(ip)
1391
+ : countedPiece(lower(ip), ip)
1361
1392
 
1362
1393
  inFlight.delete(ip)
1363
1394
  holder.set(piece)
@@ -1999,7 +2030,9 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
1999
2030
  }
2000
2031
  const child = link(code[ip + 1]!)
2001
2032
  const xf = fx[code[ip + 2]!] as string[]
2033
+ const gate = lookaheadClass(code[ip + 1]!)
2002
2034
  return (input, pos, ctx) => {
2035
+ if (gate !== null && !classHas(gate, lead(input, pos))) { EC.e = pos; return null }
2003
2036
  const need = markCst(ctx)
2004
2037
  const mRaw = MRAW
2005
2038
  const mTl = MTL
@@ -2021,7 +2054,9 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
2021
2054
  case OP_PEEK: {
2022
2055
  const child = link(code[ip + 1]!)
2023
2056
  const xf = fx[code[ip + 2]!] as string[]
2057
+ const gate = lookaheadClass(code[ip + 1]!)
2024
2058
  return (input, pos, ctx) => {
2059
+ if (gate !== null && !classHas(gate, lead(input, pos))) { ctx._fe = pos; ctx._fx = xf; return FAIL }
2025
2060
  const need = markCst(ctx)
2026
2061
  const mRaw = MRAW
2027
2062
  const mTl = MTL
@@ -2041,7 +2076,9 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
2041
2076
 
2042
2077
  case OP_OPT: {
2043
2078
  const child = link(code[ip + 1]!)
2079
+ const gate = lookaheadClass(code[ip + 1]!)
2044
2080
  return (input, pos, ctx) => {
2081
+ if (gate !== null && !classHas(gate, lead(input, pos))) { EC.e = pos; return null }
2045
2082
  const need = markCst(ctx)
2046
2083
  const mRaw = MRAW
2047
2084
  const mTl = MTL
@@ -2918,6 +2955,7 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
2918
2955
  if (committed(ctx)) { if (acc !== undefined) ctx._fx = acc; return FAIL }
2919
2956
  if (need) rollbackTriviaAt(ctx, mRaw, mTl, mLv, mFl, mEr, mLog, mRoot)
2920
2957
  }
2958
+ if (prev === 0) { ctx._fe = pos; ctx._fx = choiceFx; return FAIL }
2921
2959
  if (best === pos && prev < 1) acc = accSet(e0, acc)
2922
2960
  if (best === pos && prev < 2) acc = accSet(e1, acc)
2923
2961
  if (best === pos && n === 3 && prev < 3) acc = accSet(e2, acc)
@@ -3137,9 +3175,10 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
3137
3175
  return (input, pos, ctx) => {
3138
3176
  if (!pred(ctx.state)) {
3139
3177
  // A `continue` in the interpreter (choice.ts:150), not a failure —
3140
- // 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.
3141
3180
  ctx._fc = false
3142
- ctx._fe = pos
3181
+ ctx._fe = -1
3143
3182
  ctx._fx = xf
3144
3183
  return FAIL
3145
3184
  }
@@ -3666,9 +3705,7 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
3666
3705
  if (v === FAIL) return FAIL
3667
3706
  const end = EC.e
3668
3707
  const span = tracked ? spanLines(ctx, pos, end) : { start: pos, end }
3669
- const st = readsState && ctx.state !== undefined
3670
- ? Object.assign({}, ctx.state as Record<string, unknown>)
3671
- : undefined
3708
+ const st = readsState ? ctx.state : undefined
3672
3709
 
3673
3710
  let nd: unknown
3674
3711
  if (unwrap && kids.length === 1) {
@@ -3688,12 +3725,12 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
3688
3725
  nd = kids[0]
3689
3726
  } else if (proj >= 0) {
3690
3727
  nd = hostCst && host !== undefined
3691
- ? 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)
3692
3729
  : projectChild(kids, proj, type)
3693
3730
  } else if (build !== undefined) {
3694
3731
  if (hostCst && host !== undefined) {
3695
3732
  // A direct builder is bypassed under a CST host.
3696
- 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)
3697
3734
  } else {
3698
3735
  nd = build(kids, fieldMap, span, rawKids, captureWide ? tlog : EMPTY_TL, st)
3699
3736
  }
@@ -3780,14 +3817,7 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
3780
3817
  // The scan pool and the scan-skip sets are built FROM subtrees, so their
3781
3818
  // sites need emitted names too or `subtreeComb` would have to fall back to
3782
3819
  // a closure for them and the two engines would both be live in one parse.
3783
- const extraIps: number[] = []
3784
- for (const s of prog.scans ?? []) {
3785
- for (const r of s.skip) extraIps.push(r[0])
3786
- if (s.sentinel !== undefined) extraIps.push(s.sentinel[0])
3787
- if (s.recoverAt !== undefined) extraIps.push(s.recoverAt[0])
3788
- if (s.stopAt !== undefined) extraIps.push(s.stopAt[0])
3789
- }
3790
- for (const set of prog.scanSkip ?? []) for (const r of set) extraIps.push(r[0])
3820
+ const extraIps = assemblyExtraIps(prog)
3791
3821
  const roots = [...Object.values(prog.rules), ...extraIps]
3792
3822
  for (const ip of reachableSites(code, roots)) {
3793
3823
  if (code[ip] === OP_DISPATCH) {
@@ -3860,9 +3890,10 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
3860
3890
  REC ? prog.cc.map((_, i) => sentinelFor(i)) : EMPTY_SENTS,
3861
3891
  matchesAt, recoverScan, orSentinel, captureError,
3862
3892
  scalarRecognizers, commitTriviaScan, scanTriviaCompact, t.lex, adjacencyHolds, t.lexPrograms,
3863
- LEAD, triviaFieldCapture,
3893
+ LEAD, triviaFieldCapture, skipDeferredTrivia,
3864
3894
  )
3865
3895
  emitReached = new Set(pre.reached)
3896
+ if (emitted.ext !== undefined) HYBRID = emitted
3866
3897
  }
3867
3898
  /**
3868
3899
  * ONCE A PROGRAM CARRIES ASSEMBLIES, THE CONSTRUCTOR IS OFF FOR IT — for
@@ -3942,7 +3973,7 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
3942
3973
  REC ? prog.cc.map((_, i) => sentinelFor(i)) : EMPTY_SENTS,
3943
3974
  matchesAt, recoverScan, orSentinel, captureError,
3944
3975
  scalarRecognizers, commitTriviaScan, scanTriviaCompact, t.lex, adjacencyHolds, t.lexPrograms,
3945
- LEAD, triviaFieldCapture,
3976
+ LEAD, triviaFieldCapture, skipDeferredTrivia,
3946
3977
  )
3947
3978
  emitReached = em.reached
3948
3979
  } catch (e) {
@@ -3955,7 +3986,7 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
3955
3986
  /** One piece for a site, from whichever engine this assembly is running. */
3956
3987
  function pieceAt(ip: number): Piece {
3957
3988
  const em = emitted
3958
- if (em !== undefined) {
3989
+ if (em !== undefined && HYBRID === undefined) {
3959
3990
  const p = em.byIp[ip]
3960
3991
  if (p === undefined) throw new Error(`table emitter: no emitted body for site ${ip}`)
3961
3992
  return p as Piece
@@ -3964,6 +3995,8 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
3964
3995
  }
3965
3996
 
3966
3997
  for (const s of prog.scans ?? []) {
3998
+ const rebuilt = rebuildLineScan(s, subtreeComb)
3999
+ if (rebuilt !== undefined) { scansArr.push(rebuilt); continue }
3967
4000
  const skip = s.skip.map(r => subtreeComb(r))
3968
4001
  const raw = (s.flags & 1) !== 0
3969
4002
  if (s.kind === 1) {
@@ -3988,6 +4021,28 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
3988
4021
 
3989
4022
  /* ── link the rules ──────────────────────────────────────────────────────── */
3990
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
+
3991
4046
  if (emitted !== undefined) {
3992
4047
  const em = emitted
3993
4048
  return {
@@ -4040,6 +4095,43 @@ export function assemble(t: ResolvedTable, prog: TableProgram, cfg: RunCfg): Ass
4040
4095
  return { pieces, end: () => EC.e, begin, finish, scanSkip, reached, emitRefusal }
4041
4096
  }
4042
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
+
4043
4135
  /**
4044
4136
  * Assemblies for one resolved table, one per option set, built on demand.
4045
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[]`,