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.
- package/dist/analysis/diagnostics.js +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/index.js +26 -17
- package/dist/index.js.map +2 -2
- package/dist/plugin/evaluator.d.ts.map +1 -1
- package/dist/plugin/index.d.ts +29 -6
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +141 -60
- package/dist/plugin/index.js.map +3 -3
- package/dist/table/compile-rule-map.d.ts.map +1 -1
- package/dist/table/index.js +26 -17
- package/dist/table/index.js.map +1 -1
- package/dist/table/select-hot.d.ts +20 -1
- package/dist/table/select-hot.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
- package/src/plugin/evaluator.ts +42 -2
- package/src/plugin/index.ts +113 -36
- package/src/table/compile-rule-map.ts +8 -21
- package/src/table/select-hot.ts +35 -4
- package/src/version.ts +1 -1
|
@@ -1,6 +1,25 @@
|
|
|
1
|
-
/**
|
|
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
|
|
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.
|
|
16
|
+
export declare const PARSEMAN_VERSION = "0.61.4";
|
|
17
17
|
//# sourceMappingURL=version.d.ts.map
|
package/package.json
CHANGED
package/src/plugin/evaluator.ts
CHANGED
|
@@ -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
|
/**
|
package/src/plugin/index.ts
CHANGED
|
@@ -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
|
|
174
|
-
*
|
|
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
|
-
*
|
|
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
|
|
212
|
-
*
|
|
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.
|
|
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]!,
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
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,
|
|
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]!,
|
|
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 {
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
|
package/src/table/select-hot.ts
CHANGED
|
@@ -21,7 +21,12 @@ const LOOP = 8
|
|
|
21
21
|
const ROUNDS = 60
|
|
22
22
|
const CAP = 1e12
|
|
23
23
|
|
|
24
|
-
/**
|
|
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
|
-
|
|
45
|
-
if (spent
|
|
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