parseman 0.61.2 → 0.61.4

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.
@@ -1,6 +1,25 @@
1
- /** The sites to emit: highest weight per byte first, while they fit in `budget` bytes. */
1
+ /**
2
+ * The sites to emit: highest weight per byte first, stopping at the first that
3
+ * does not fit in `budget` bytes. Stopping rather than skipping is what makes a
4
+ * larger budget a superset: a skip lets a budget that just admits a big site lose
5
+ * the small ones a smaller budget took after skipping it. Insertion order is rank.
6
+ */
2
7
  export declare function selectHotSites(code: readonly number[], entries: readonly number[], siteBytes: ReadonlyMap<number, number>, budget: number,
3
8
  /** Measured calls per site (`countSiteVisits` over a corpus). Replaces the
4
9
  * static estimate when given. */
5
10
  profile?: ArrayLike<number>): Set<number>;
11
+ /**
12
+ * The largest prefix of the ranking whose real source fits `ceiling`. `siteBytes`
13
+ * leaves out what sites add around their bodies, so the estimate can overshoot:
14
+ * each overshoot drops the last site and measures again. Walking down the one
15
+ * order is what keeps a selection a prefix of any larger budget's: that budget has
16
+ * every candidate this one has, so it can never settle on fewer. `undefined` when
17
+ * not even the first site fits.
18
+ */
19
+ export declare function fitHotSites(code: readonly number[], entries: readonly number[], siteBytes: ReadonlyMap<number, number>,
20
+ /** What any selection costs before its first site body. */
21
+ fixed: number, ceiling: number, sourceBytes: (sites: ReadonlySet<number>) => number, profile?: ArrayLike<number>): {
22
+ readonly sites: ReadonlySet<number>;
23
+ readonly bytes: number;
24
+ } | undefined;
6
25
  //# sourceMappingURL=select-hot.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"select-hot.d.ts","sourceRoot":"","sources":["../../src/table/select-hot.ts"],"names":[],"mappings":"AAuBA,0FAA0F;AAC1F,wBAAgB,cAAc,CAC5B,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,SAAS,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,EACtC,MAAM,EAAE,MAAM;AACd;iCACiC;AACjC,OAAO,CAAC,EAAE,SAAS,CAAC,MAAM,CAAC,GAC1B,GAAG,CAAC,MAAM,CAAC,CAiBb"}
1
+ {"version":3,"file":"select-hot.d.ts","sourceRoot":"","sources":["../../src/table/select-hot.ts"],"names":[],"mappings":"AAuBA;;;;;GAKG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,SAAS,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,EACtC,MAAM,EAAE,MAAM;AACd;iCACiC;AACjC,OAAO,CAAC,EAAE,SAAS,CAAC,MAAM,CAAC,GAC1B,GAAG,CAAC,MAAM,CAAC,CAgBb;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CACzB,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,SAAS,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC;AACtC,2DAA2D;AAC3D,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,EACf,WAAW,EAAE,CAAC,KAAK,EAAE,WAAW,CAAC,MAAM,CAAC,KAAK,MAAM,EACnD,OAAO,CAAC,EAAE,SAAS,CAAC,MAAM,CAAC,GAC1B;IAAE,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAQ7E"}
package/dist/version.d.ts CHANGED
@@ -13,5 +13,5 @@
13
13
  * Kept in sync with `package.json` by `test/unit/version-sync.test.ts`, which fails
14
14
  * the build if this constant drifts from the published version.
15
15
  */
16
- export declare const PARSEMAN_VERSION = "0.61.2";
16
+ export declare const PARSEMAN_VERSION = "0.61.4";
17
17
  //# sourceMappingURL=version.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parseman",
3
- "version": "0.61.2",
3
+ "version": "0.61.4",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "engines": {
6
6
  "node": "^20.19.0 || >=22.13.0"
@@ -17,6 +17,7 @@ import type { Combinator } from '../types.ts'
17
17
  import type { DispatchArm } from '../combinators/dispatch.ts'
18
18
  import { ref } from '../combinators/ref.ts'
19
19
  import { rules, RULES_RECIPE, type PriorRules, type RulesOptions, type RulesRecipe } from '../combinators/parser.ts'
20
+ import { parseSync } from 'oxc-parser'
20
21
  import * as parseman from '../index.ts'
21
22
  import { confirmedBuildParamUnused } from '../compiler/build-arity.ts'
22
23
  import { directBuilderBindings } from './direct-builder-static.ts'
@@ -72,7 +73,7 @@ function stripTsFromSource(node: Node, code: string): string {
72
73
  walk(node)
73
74
  const start = (node as { start: number }).start
74
75
  const end = (node as { end: number }).end
75
- if (cuts.length === 0) return code.slice(start, end)
76
+ if (cuts.length === 0) return stripComments(code.slice(start, end))
76
77
  cuts.sort((a, b) => a[0] - b[0])
77
78
  let out = ''
78
79
  let cur = start
@@ -81,7 +82,46 @@ function stripTsFromSource(node: Node, code: string): string {
81
82
  out += code.slice(cur, s)
82
83
  cur = e
83
84
  }
84
- return out + code.slice(cur, end)
85
+ return stripComments(out + code.slice(cur, end))
86
+ }
87
+
88
+ /** The four characters ECMAScript treats as ending a line. */
89
+ const LINE_TERMINATOR = /[\n\r\u2028\u2029]/
90
+
91
+ /**
92
+ * A reducer's source without its comments, and without the whitespace before them.
93
+ * A reducer ships as a string in the carried IR, where a bundler cannot strip it, and
94
+ * a comment must not change what a module ships. A comment still separates tokens and,
95
+ * if it crossed or followed a line end, still ends a line: automatic semicolon
96
+ * insertion and `return` depend on that line end.
97
+ */
98
+ function stripComments(text: string): string {
99
+ if (!text.includes('/*') && !text.includes('//')) return text
100
+ const wrapped = `(${text})`
101
+ let out = ''
102
+ let cur = 0
103
+ let lineEnd = false
104
+ const comments = parseSync('reducer.ts', wrapped).comments
105
+ comments.forEach((c, i) => {
106
+ const before = text.slice(cur, c.start - 1)
107
+ const kept = before.trimEnd()
108
+ out += kept
109
+ lineEnd ||= LINE_TERMINATOR.test(before.slice(kept.length)) || LINE_TERMINATOR.test(c.value)
110
+ cur = c.end - 1
111
+ const after = /^\s*/.exec(text.slice(cur))![0]
112
+ const endsLine = LINE_TERMINATOR.test(after)
113
+ if (comments[i + 1]?.start === c.end + after.length) {
114
+ lineEnd ||= endsLine
115
+ cur += after.length
116
+ return
117
+ }
118
+ if (out !== '' && cur + after.length < text.length && !endsLine) {
119
+ if (lineEnd) out += '\n'
120
+ else if (after === '') out += ' '
121
+ }
122
+ lineEnd = false
123
+ })
124
+ return out + text.slice(cur)
85
125
  }
86
126
 
87
127
  /**
@@ -21,6 +21,7 @@
21
21
  import * as fs from 'node:fs'
22
22
  import * as path from 'node:path'
23
23
  import { createUnplugin } from 'unplugin'
24
+ import type { UnpluginBuildContext } from 'unplugin'
24
25
  import { parseSync } from 'oxc-parser'
25
26
  import { ResolverFactory } from 'oxc-resolver'
26
27
  import MagicString from 'magic-string'
@@ -170,24 +171,42 @@ export type ParsecraftPluginOptions = {
170
171
  */
171
172
  assemblyProfile?: AssemblyProfile
172
173
  /**
173
- * Generated assembly bytes each module may spend, as a multiple of its own source
174
- * length. **Default `0.25`.** `0` ships no assembly. The right number is
174
+ * Generated assembly bytes each module may spend, as a multiple of its own code,
175
+ * comments and whitespace aside. **Default `0.5`.** `0` ships no assembly. The right number is
175
176
  * per grammar: a larger one parses faster in steady state and costs import and
176
177
  * first-parse time, so measure both, and keep the artifact within 5× its source
177
- * (docs/design/runtime-and-size-contract.md, rules 4 and 6).
178
+ * (docs/design/runtime-and-size-contract.md, rules 4 and 6). A larger budget never
179
+ * loses a site, with one limit: a module with two AST grammars spends it in order, so
180
+ * a larger budget can buy the first a big site and leave the second fewer.
178
181
  */
179
182
  assemblyBudget?: number
180
183
  }
181
184
 
182
185
  /** A corpus a build parses to rank selective-assembly sites — see `assemblyProfile`. */
183
186
  export type AssemblyProfile = {
184
- /** The rule each corpus file is parsed from, e.g. `'Stylesheet'`. A grammar
185
- * without this rule keeps the static ranking. */
187
+ /** The rule each corpus file is parsed from, e.g. `'Stylesheet'`. A grammar without
188
+ * this rule keeps the static ranking. A build fails when grammars were profiled and
189
+ * none has the rule. A direct `transformMacro` caller gets `assemblyProfile:
190
+ * 'no-entry'` on the result instead, and has to read it. */
186
191
  readonly entry: string
187
192
  /** Paths of representative inputs. At least one must have content, and each that
188
193
  * does has to parse in full without recovery, or the build fails. A file with only
189
- * whitespace counts as empty. */
194
+ * whitespace counts as empty. A callback the corpus doesn't reach is never run, so
195
+ * nothing checks it. */
190
196
  readonly files: readonly string[]
197
+ /**
198
+ * The values of names a grammar's reducers and gates import, for the corpus run.
199
+ * Their source runs at build time with no module around it, so a grammar whose
200
+ * callbacks read imported helpers passes those helpers here. See
201
+ * `docs/guide/macro-mode.md` for what this does and doesn't cover.
202
+ *
203
+ * It's one flat namespace, so two modules can't bind one name to different helpers.
204
+ * A name that's already a global fails the build. A helper named like a global and
205
+ * left out of `scope` silently uses the global. A direct `node()` builder doesn't
206
+ * run in the corpus run: a plain node stands in for it, so a helper only a builder
207
+ * reads is neither needed here nor checked.
208
+ */
209
+ readonly scope?: Readonly<Record<string, unknown>>
191
210
  }
192
211
 
193
212
  /** Corpus file contents, re-read when a file's mtime moves, so a watch build that
@@ -208,11 +227,26 @@ const PARSEMAN_MODULE = 'parseman'
208
227
 
209
228
  /**
210
229
  * The default `assemblyBudget`: generated assembly bytes a module may spend, as a
211
- * multiple of its own source length. A quarter of the source keeps every jess
212
- * grammar far under the 5x ceiling (contract rule 4); the 0.57.0 CHANGELOG has the
213
- * measured curve.
230
+ * multiple of its own code (`codeBytes`). Half keeps every jess grammar far under the
231
+ * 5x ceiling (contract rule 4). A grammar that wants a different trade passes its own.
214
232
  */
215
- const DEFAULT_ASSEMBLY_BUDGET = 0.25
233
+ const DEFAULT_ASSEMBLY_BUDGET = 0.5
234
+
235
+ /**
236
+ * A module's code in bytes, without its comments and whitespace. It is what a budget
237
+ * is a share of, so editing a comment or reflowing a line cannot move a site in or
238
+ * out of the assembly, and with it the module's size and parse speed.
239
+ */
240
+ function codeBytes(code: string, comments: readonly { start: number; end: number }[], end = code.length): number {
241
+ let bytes = 0
242
+ let at = 0
243
+ for (const c of comments) {
244
+ if (c.start >= end) break
245
+ bytes += code.slice(at, c.start).replace(/\s+/g, '').length
246
+ at = c.end
247
+ }
248
+ return bytes + code.slice(at, end).replace(/\s+/g, '').length
249
+ }
216
250
 
217
251
  /**
218
252
  * The REASON a lowering refused, appended to the warning that reports it.
@@ -541,6 +575,9 @@ export default createUnplugin((opts: ParsecraftPluginOptions = {}, meta) => {
541
575
  // Module id → its carried lists, under a bundler that writes them beside the chunk.
542
576
  // Any other bundler gets them inline, as before.
543
577
  const sinks = SIDECAR_FRAMEWORKS.has(meta.framework) ? new Map<string, CarriedSink>() : undefined
578
+ // Module id → whether its grammars ran the corpus. A module without the entry rule is
579
+ // fine on its own, because one plugin sees many modules, so only the build can tell.
580
+ const profiled = new Map<string, NonNullable<TransformMacroResult['assemblyProfile']>>()
544
581
  // `order: 'post'`: the sidecar records a digest of each chunk's code, so it is
545
582
  // taken after every other plugin's `generateBundle` has rewritten that code (Vite
546
583
  // rewrites a dynamic `import()` through its preload helper there).
@@ -562,10 +599,19 @@ export default createUnplugin((opts: ParsecraftPluginOptions = {}, meta) => {
562
599
  return /\.[jt]sx?$/.test(id) && !id.includes('node_modules')
563
600
  },
564
601
 
602
+ buildEnd(this: UnpluginBuildContext & { getModuleIds?: () => Iterable<string> }, error?: unknown) {
603
+ if (error) return
604
+ // esbuild, webpack and rspack can't list a build's modules, so there every module seen counts.
605
+ const ids = [...this.getModuleIds?.() ?? profiled.keys()].filter(id => profiled.has(id))
606
+ if (ids.length === 0 || ids.some(id => profiled.get(id) === 'ran')) return
607
+ throw new Error(`assemblyProfile: no grammar has rule "${opts.assemblyProfile?.entry}", so the corpus never ran (${ids.join(', ')})`)
608
+ },
609
+
565
610
  transform(this: { warn?: (msg: string) => void; addWatchFile?: (file: string) => void }, code: string, id: string) {
566
611
  // What this module recorded on an earlier build of a watch session is stale
567
612
  // from here: it records again below, or it no longer lowers anything.
568
613
  sinks?.delete(id)
614
+ profiled.delete(id)
569
615
  if (!code.includes('parseman')) return null
570
616
  if (!code.includes('macro')) return null
571
617
  // A corpus edit changes which sites a profiled build generates, so a watch
@@ -575,6 +621,7 @@ export default createUnplugin((opts: ParsecraftPluginOptions = {}, meta) => {
575
621
  const sink: CarriedSink | undefined = sinks && new Map()
576
622
  const result = transformMacro(code, id, moduleAliases, opts.warnUnloweredRegex === true, opts.recovery === true, opts.grammarCoverage === true, opts.assemblyProfile, opts.assemblyBudget, sink)
577
623
  if (sinks && sink && sink.size > 0) sinks.set(id, sink)
624
+ if (result?.assemblyProfile !== undefined) profiled.set(id, result.assemblyProfile)
578
625
  if (result?.warnings.length) {
579
626
  for (const w of result.warnings) {
580
627
  if (typeof this?.warn === 'function') this.warn(`[parseman] ${w}`)
@@ -769,6 +816,11 @@ export type TransformMacroResult = {
769
816
  map: ReturnType<MagicString['generateMap']>
770
817
  /** Diagnostics for macro-referencing shapes that fell back to the interpreter. */
771
818
  warnings: string[]
819
+ /** With an `assemblyProfile`: `'ran'` when a grammar here parsed the corpus,
820
+ * `'no-entry'` when the grammars that could have don't have its `entry` rule. */
821
+ assemblyProfile?: 'ran' | 'no-entry'
822
+ /** The sites the module's `assemblyBudget` bought, by program offset, in rank order. */
823
+ assemblySites: number[]
772
824
  }
773
825
 
774
826
  /**
@@ -1751,7 +1803,9 @@ function transformMacroImpl(
1751
1803
 
1752
1804
  // Stable, reproducible per-artifact namespace: hash of module id + a label
1753
1805
  // (binding name / arg position) — never a counter, so rebuilds are byte-stable
1754
- // and two artifacts never collide when fused.
1806
+ // and two artifacts never collide when fused. A call is labelled by where it sits
1807
+ // in the module's code (`codeBytes`), so a comment above it leaves the name alone.
1808
+ const callLabel = (fn: string, start: number): string => `${fn}${codeBytes(code, result.comments, start)}`
1755
1809
  const nsFor = (label: string): string =>
1756
1810
  `_${createHash('sha1').update(`${id}#${label}`).digest('hex').slice(0, 8)}_`
1757
1811
 
@@ -2255,7 +2309,7 @@ function transformMacroImpl(
2255
2309
  const carried: CarriedItem[] = [] // re-lowerable; also SERIALIZED onto the value
2256
2310
  const importedFactories: string[] = []
2257
2311
  for (let i = 0; i < elements.length; i++) {
2258
- const r = argPieces(elements[i]!, `compose${init.start}_${i}`, composing)
2312
+ const r = argPieces(elements[i]!, `${callLabel('compose', init.start)}_${i}`, composing)
2259
2313
  if (!r) {
2260
2314
  // A build-compiled argument cannot fall back: runtime `compose()` links live
2261
2315
  // `rules()` grammars only and would throw at import. Fail here instead.
@@ -2344,14 +2398,15 @@ function transformMacroImpl(
2344
2398
  * SELECTIVE ASSEMBLY, BUDGETED PER MODULE. A fused grammar's default AST/no-lines
2345
2399
  * table carries generated code for its hottest sites only (`select-hot.ts`); the
2346
2400
  * closure engine runs the rest. The site bodies of every fused grammar in this
2347
- * module together may not exceed `assemblyBudget` times the module's own source.
2401
+ * module together may not exceed `assemblyBudget` times the module's own code (`codeBytes`).
2348
2402
  *
2349
2403
  * A module that declares a `composeLeaf` has named its terminal parser, so its
2350
2404
  * `compose()` results are composable PIECES: another build re-lowers them and
2351
2405
  * never parses through their table, so they spend nothing. Without a
2352
2406
  * `composeLeaf`, a `compose()` result is the module's parser.
2353
2407
  */
2354
- let assemblyBudget = Math.floor(code.length * assemblyBudgetShare)
2408
+ let assemblyBudget = Math.floor(codeBytes(code, result.comments) * assemblyBudgetShare)
2409
+ const assemblySites: number[] = []
2355
2410
  let leafDeclared: boolean | undefined
2356
2411
  const declaresLeaf = (): boolean => leafDeclared ??= (body as Statement[]).some(stmt => {
2357
2412
  const vd = unwrapVd(stmt)
@@ -2375,15 +2430,21 @@ function transformMacroImpl(
2375
2430
  warnings.push(`${id}:${lineOf(pos)} — ${label}: its precompiled assembly cannot emit ${refusal}; the artifact runs on the closure engine`)
2376
2431
  }
2377
2432
  assemblyBudget -= bytes
2433
+ assemblySites.push(...sites)
2378
2434
  return sites
2379
2435
  }
2380
2436
  /** Measured site calls over the optional corpus, or `undefined` for the static
2381
2437
  * ranking. A corpus that cannot run fails the build: a static ranking standing in
2382
2438
  * for the measured one configured is a slower parser that nothing reports. */
2439
+ let profileOutcome: TransformMacroResult['assemblyProfile']
2383
2440
  const profileOf = (compiled: CompiledRuleMapTable): Int32Array | undefined => {
2384
2441
  // Only the default AST/no-lines table carries an assembly, so only it is profiled.
2385
- if (assemblyProfile === undefined || compiled.hostMode !== 'ast' || compiled.prog.lines === 1
2386
- || !(assemblyProfile.entry in compiled.prog.rules)) return undefined
2442
+ if (assemblyProfile === undefined || compiled.hostMode !== 'ast' || compiled.prog.lines === 1) return undefined
2443
+ if (!(assemblyProfile.entry in compiled.prog.rules)) {
2444
+ profileOutcome ??= 'no-entry'
2445
+ return undefined
2446
+ }
2447
+ profileOutcome = 'ran'
2387
2448
  const refuse = (why: string): never => { throw new Error(`${id} — assemblyProfile: ${why}`) }
2388
2449
  if (corpusTexts(assemblyProfile).every(text => text.trim() === '')) refuse('`files` has no non-empty file')
2389
2450
  let thrower: unknown
@@ -2398,24 +2459,38 @@ function transformMacroImpl(
2398
2459
  },
2399
2460
  })
2400
2461
  })
2401
- return countSiteVisits(compiled.prog, rules => {
2402
- const entry = rules[assemblyProfile.entry]!
2403
- corpusTexts(assemblyProfile).forEach((text, i) => {
2404
- const file = assemblyProfile.files[i]!
2405
- if (text.trim() === '') return
2406
- let result
2407
- try { result = run(entry, text) } catch (e) {
2408
- const source = String(thrower).replace(/\s+/g, ' ').slice(0, 200)
2409
- throw new Error(`${id} — assemblyProfile: ${thrower === undefined ? 'the parse' : `gate or reducer \`${source}\``} threw on ${file}: ${String(e)}`, { cause: e })
2410
- }
2411
- if (!result.ok || result.unconsumedFrom !== null) {
2412
- refuse(`${file} does not parse from rule "${assemblyProfile.entry}": it stops at offset ${result.ok ? result.unconsumedFrom : result.span.end}`)
2413
- }
2414
- if (result.errors.length > 0) {
2415
- refuse(`${file} parses only by recovering from ${result.errors.length} error(s), first at offset ${result.errors[0]!.span.start}`)
2416
- }
2417
- })
2418
- }, fns)
2462
+ // ponytail: callback source is evaluated in global scope, so the corpus run sees
2463
+ // `scope` as globals for this one synchronous call, and nothing can interleave with
2464
+ // it. Async work a callback starts that outlives the call no longer sees them;
2465
+ // rebind each callback instead if a helper ever needs that.
2466
+ const scope = assemblyProfile.scope ?? {}
2467
+ const names = Object.keys(scope)
2468
+ for (const name of names) {
2469
+ if (name in globalThis) refuse(`\`scope\` name "${name}" is already a global; a callback couldn't tell which one it reads`)
2470
+ }
2471
+ try {
2472
+ for (const name of names) Object.defineProperty(globalThis, name, { value: scope[name], configurable: true })
2473
+ return countSiteVisits(compiled.prog, rules => {
2474
+ const entry = rules[assemblyProfile.entry]!
2475
+ corpusTexts(assemblyProfile).forEach((text, i) => {
2476
+ const file = assemblyProfile.files[i]!
2477
+ if (text.trim() === '') return
2478
+ let result
2479
+ try { result = run(entry, text) } catch (e) {
2480
+ const source = String(thrower).replace(/\s+/g, ' ').slice(0, 200)
2481
+ throw new Error(`${id} — assemblyProfile: ${thrower === undefined ? 'the parse' : `gate or reducer \`${source}\``} threw on ${file}: ${String(e)}`, { cause: e })
2482
+ }
2483
+ if (!result.ok || result.unconsumedFrom !== null) {
2484
+ refuse(`${file} does not parse from rule "${assemblyProfile.entry}": it stops at offset ${result.ok ? result.unconsumedFrom : result.span.end}`)
2485
+ }
2486
+ if (result.errors.length > 0) {
2487
+ refuse(`${file} parses only by recovering from ${result.errors.length} error(s), first at offset ${result.errors[0]!.span.start}`)
2488
+ }
2489
+ })
2490
+ }, fns)
2491
+ } finally {
2492
+ for (const name of names) Reflect.deleteProperty(globalThis, name)
2493
+ }
2419
2494
  }
2420
2495
  /** The fused table's replacement, with its selective default assembly if any. */
2421
2496
  const assembledReplacement = (compiled: CompiledRuleMapTable, metadata: string, select: ReadonlySet<number>): string =>
@@ -2445,7 +2520,7 @@ function transformMacroImpl(
2445
2520
  let localRules: ReadonlyMap<string, Combinator<unknown>> | null = null
2446
2521
  const importedFactories: string[] = []
2447
2522
  if (isRulesCall(localArg)) {
2448
- const evaluated = evaluateRulesFactory(localArg, `composeLeaf${init.start}`)
2523
+ const evaluated = evaluateRulesFactory(localArg, callLabel('composeLeaf', init.start))
2449
2524
  localRules = evaluated?.ruleMap ?? null
2450
2525
  if (evaluated?.importedFactory) importedFactories.push(evaluated.importedFactory)
2451
2526
  } else if (localArg.type === 'Identifier') {
@@ -2472,7 +2547,7 @@ function transformMacroImpl(
2472
2547
  const composing = composingTrivia(elements)
2473
2548
  const carried: CarriedItem[] = []
2474
2549
  for (let i = 0; i < elements.length - 1; i++) {
2475
- const r = argPieces(elements[i]!, `composeLeaf${init.start}_${i}`, composing)
2550
+ const r = argPieces(elements[i]!, `${callLabel('composeLeaf', init.start)}_${i}`, composing)
2476
2551
  if (!r) {
2477
2552
  warn(init.start, `composeLeaf(): argument ${i} isn't a build-resolvable recognition grammar`)
2478
2553
  return null
@@ -3294,8 +3369,10 @@ function transformMacroImpl(
3294
3369
  }
3295
3370
 
3296
3371
  return {
3372
+ assemblySites: applied.length > 0 ? assemblySites : [],
3297
3373
  code: ms.toString(),
3298
3374
  map: ms.generateMap({ hires: true }),
3299
3375
  warnings,
3376
+ ...(profileOutcome === undefined ? {} : { assemblyProfile: profileOutcome }),
3300
3377
  }
3301
3378
  }
@@ -5,7 +5,7 @@ import { collectGrammarReflection, GRAMMAR_REFLECTION, type GrammarReflection }
5
5
  import { encodeTableProgram, type TableSettings } from './encode.ts'
6
6
  import { defaultAssemblyCfgs, emitTableExpression } from './emit.ts'
7
7
  import { emitAssemblySource, Unemittable } from './emit-assembly.ts'
8
- import { selectHotSites } from './select-hot.ts'
8
+ import { fitHotSites } from './select-hot.ts'
9
9
 
10
10
  /** The most the entry supercompiler may grow an assembly (`entry-supercompiler.ts`, its 5% source cap). */
11
11
  const SUPERCOMPILE_GROWTH = 1.05
@@ -372,29 +372,16 @@ export function compileRuleMap(
372
372
  // The estimate (prelude plus site bodies) leaves out what selected sites add
373
373
  // around their bodies — shared helpers and the `_px` wrappers of the closure
374
374
  // sites they call — and the supercompiler may grow the source by up to 5%. So
375
- // the selection is judged by the source it actually emits, scaled for that 5%,
376
- // and narrowed until that fits.
375
+ // the selection is judged by the source it actually emits, scaled for that 5%.
377
376
  const ceiling = budget / SUPERCOMPILE_GROWTH
378
377
  // A budget that cannot cover the prelude buys nothing, by design: no refusal.
379
378
  if (ceiling <= fixed) return none
380
- // Narrowed until it fits, with no attempt limit: the target shrinks by at
381
- // least 1% a round, so the selection either fits or empties. An empty one
382
- // says why — a build that asked for an assembly and silently shipped none is
383
- // a regression no test sees (the artifact still parses, on the closures).
384
- let target = ceiling
385
- for (;;) {
386
- const sites = selectHotSites(artifact.code, Object.values(artifact.rules), siteBytes, target - fixed, profile)
387
- if (sites.size === 0) {
388
- return {
389
- ...none,
390
- refusal: `a site within its ${Math.floor(ceiling)} B selection ceiling (the prelude alone is ${fixed} B)`,
391
- }
392
- }
393
- const bytes = emitAssemblySource(table, artifact, cfg, roots, true, sites, true).source.length
394
- if (bytes <= ceiling) return { sites, bytes: Math.ceil(bytes * SUPERCOMPILE_GROWTH) }
395
- // Scale by the overshoot, and by at least 1%: near the ceiling a site is
396
- // the unit, and the ratio alone can stall a few bytes over it.
397
- target *= Math.min(ceiling / bytes, 0.99)
379
+ const fit = fitHotSites(artifact.code, Object.values(artifact.rules), siteBytes, fixed, ceiling,
380
+ sites => emitAssemblySource(table, artifact, cfg, roots, true, sites, true).source.length, profile)
381
+ if (fit !== undefined) return { sites: fit.sites, bytes: Math.ceil(fit.bytes * SUPERCOMPILE_GROWTH) }
382
+ return {
383
+ ...none,
384
+ refusal: `a site within its ${Math.floor(ceiling)} B selection ceiling (the prelude alone is ${fixed} B)`,
398
385
  }
399
386
  }
400
387
 
@@ -21,7 +21,12 @@ const LOOP = 8
21
21
  const ROUNDS = 60
22
22
  const CAP = 1e12
23
23
 
24
- /** The sites to emit: highest weight per byte first, while they fit in `budget` bytes. */
24
+ /**
25
+ * The sites to emit: highest weight per byte first, stopping at the first that
26
+ * does not fit in `budget` bytes. Stopping rather than skipping is what makes a
27
+ * larger budget a superset: a skip lets a budget that just admits a big site lose
28
+ * the small ones a smaller budget took after skipping it. Insertion order is rank.
29
+ */
25
30
  export function selectHotSites(
26
31
  code: readonly number[],
27
32
  entries: readonly number[],
@@ -41,14 +46,40 @@ export function selectHotSites(
41
46
  let spent = 0
42
47
  for (const ip of ranked) {
43
48
  if (w(ip) === 0) break
44
- const bytes = siteBytes.get(ip)!
45
- if (spent + bytes > budget) continue
46
- spent += bytes
49
+ spent += siteBytes.get(ip)!
50
+ if (spent > budget) break
47
51
  selected.add(ip)
48
52
  }
49
53
  return selected
50
54
  }
51
55
 
56
+ /**
57
+ * The largest prefix of the ranking whose real source fits `ceiling`. `siteBytes`
58
+ * leaves out what sites add around their bodies, so the estimate can overshoot:
59
+ * each overshoot drops the last site and measures again. Walking down the one
60
+ * order is what keeps a selection a prefix of any larger budget's: that budget has
61
+ * every candidate this one has, so it can never settle on fewer. `undefined` when
62
+ * not even the first site fits.
63
+ */
64
+ export function fitHotSites(
65
+ code: readonly number[],
66
+ entries: readonly number[],
67
+ siteBytes: ReadonlyMap<number, number>,
68
+ /** What any selection costs before its first site body. */
69
+ fixed: number,
70
+ ceiling: number,
71
+ sourceBytes: (sites: ReadonlySet<number>) => number,
72
+ profile?: ArrayLike<number>,
73
+ ): { readonly sites: ReadonlySet<number>; readonly bytes: number } | undefined {
74
+ const ranked = [...selectHotSites(code, entries, siteBytes, ceiling - fixed, profile)]
75
+ for (; ranked.length > 0; ranked.pop()) {
76
+ const sites = new Set(ranked)
77
+ const bytes = sourceBytes(sites)
78
+ if (bytes <= ceiling) return { sites, bytes }
79
+ }
80
+ return undefined
81
+ }
82
+
52
83
  function staticWeights(code: readonly number[], entries: readonly number[]): Map<number, number> {
53
84
  let weight = new Map<number, number>()
54
85
  const kids: number[] = []
package/src/version.ts CHANGED
@@ -13,4 +13,4 @@
13
13
  * Kept in sync with `package.json` by `test/unit/version-sync.test.ts`, which fails
14
14
  * the build if this constant drifts from the published version.
15
15
  */
16
- export const PARSEMAN_VERSION = '0.61.2'
16
+ export const PARSEMAN_VERSION = '0.61.4'