jz 0.0.0 → 0.1.1

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/src/compile.js ADDED
@@ -0,0 +1,1211 @@
1
+ /**
2
+ * Compile prepared AST to WASM module (S-expression arrays for watr).
3
+ *
4
+ * # Stage contract
5
+ * IN: prepared AST (from prepare) + `ctx.func.list` with raw bodies.
6
+ * OUT: WAT IR `['module', ...sections]` ready for watrCompile/watrPrint.
7
+ * FLOW: orchestrator only. Calls analyze passes per function, then emit(body) via
8
+ * src/emit.js's dispatch, then optimizeFunc (src/optimize.js) per function,
9
+ * finally assembles module sections in canonical order.
10
+ *
11
+ * # Core abstraction
12
+ * Emitter table (ctx.core.emit) maps AST ops → WASM IR generators. Base operators defined
13
+ * in `emitter` export (src/emit.js); on reset, ctx.core.emit starts as a flat copy of emitter
14
+ * and modules add/override entries directly. No prototype chain.
15
+ * emit(node) dispatches: numbers → i32/f64.const, strings → local.get, arrays → ctx.core.emit[op].
16
+ *
17
+ * # Type system
18
+ * Every emitted node carries .type ('i32' | 'f64').
19
+ * Operators preserve i32 when both operands are i32.
20
+ * Division/power always produce f64. Bitwise/comparisons always produce i32.
21
+ * Variables are typed by pre-analysis: if any assignment is f64, local is f64.
22
+ *
23
+ * Per-function state on ctx: locals (Map name→type), stack (loop labels), uniq (counter), sig.
24
+ *
25
+ * @module compile
26
+ */
27
+
28
+ import { parse as parseWat } from 'watr'
29
+ import { ctx, err, inc, resolveIncludes, PTR } from './ctx.js'
30
+ import {
31
+ T, VAL, analyzeValTypes, analyzeIntCertain, analyzeLocals,
32
+ analyzePtrUnboxable, typedElemAux, invalidateLocalsCache,
33
+ analyzeBoxedCaptures, updateRep,
34
+ } from './analyze.js'
35
+ import { optimizeFunc, hoistConstantPool, specializeMkptr, specializePtrBase, sortStrPoolByFreq, treeshake } from './optimize.js'
36
+ import { emit, emitter, emitFlat, emitBody } from './emit.js'
37
+ import {
38
+ typed, asF64, asI32, asPtrOffset, asParamType, toI32, asI64, fromI64,
39
+ NULL_NAN, UNDEF_NAN, NULL_WAT, UNDEF_WAT, NULL_IR, UNDEF_IR, nullExpr, undefExpr,
40
+ MAX_CLOSURE_ARITY, MEM_OPS, WASM_OPS, SPREAD_MUTATORS, BOXED_MUTATORS,
41
+ mkPtrIR, ptrOffsetIR, ptrTypeIR, extractF64Bits, appendStaticSlots,
42
+ isLit, litVal, isNullishLit, isPureIR, isPostfix, emitNum,
43
+ temp, tempI32, tempI64, f64rem, toNumF64, truthyIR, toBoolFromEmitted,
44
+ keyValType, usesDynProps, needsDynShadow,
45
+ isGlobal, isConst, boxedAddr, readVar, writeVar, isNullish,
46
+ slotAddr, elemLoad, elemStore, arrayLoop, allocPtr,
47
+ multiCount, loopTop, flat, reconstructArgsWithSpreads,
48
+ valKindToPtr,
49
+ } from './ir.js'
50
+ import plan from './plan.js'
51
+
52
+ const timePhase = (profiler, name, fn) => profiler ? profiler.time(name, fn) : fn()
53
+
54
+ // Per-compile func name set + map live on ctx.func.names / ctx.func.map,
55
+ // populated at compile() entry. Both reset by ctx.js reset() and re-filled here.
56
+
57
+ // NaN-box high-bits mask: used by the static-prefix-strip pass below to
58
+ // identify pointer slots in the data segment. Kept local (ir.js owns the
59
+ // runtime packing via mkPtrIR).
60
+ const NAN_PREFIX_BITS = 0x7FF8n
61
+
62
+ // Low-level IR helpers previously lived here. Pure ones moved to src/ir.js;
63
+ // emit-calling ones (toBool, emitTypeofCmp, emitDecl, materializeMulti,
64
+ // buildArrayWithSpreads) moved to src/emit.js.
65
+
66
+ // AST-analysis primitives (staticObjectProps, paramReps lattice helpers,
67
+ // inferArg* cross-call inference, collectProgramFacts) moved to src/analyze.js.
68
+
69
+ /**
70
+ * Boundary-wrap predicate: exports whose body-driven result OR any param narrowed
71
+ * away from the JS-visible f64 ABI need a wrapper that re-/un-boxes at the JS↔WASM
72
+ * edge so the inner func can keep its raw type while exports preserve Number /
73
+ * pointer semantics for JS callers.
74
+ *
75
+ * Numeric param narrowing on exports IS enabled when all internal call sites pass
76
+ * i32 — the wrapper does `i32.trunc_sat_f64_s` at the boundary (matches JS i32
77
+ * coercion `n | 0` semantics for integer-shaped values; a JS caller passing a
78
+ * fractional Number gets the same truncation it would get from `arr[n]`).
79
+ */
80
+ const isBoundaryWrapped = (func) => {
81
+ if (!func.exported || func.raw || func.sig.results.length !== 1) return false
82
+ if (func.sig.results[0] !== 'f64' || func.sig.ptrKind != null) return true
83
+ return func.sig.params.some(p => p.type !== 'f64' || p.ptrKind != null)
84
+ }
85
+
86
+ /**
87
+ * Tail-call rewrite: walks tail positions of an emitted IR tree and replaces
88
+ * direct `(call $name args...)` ops with `(return_call $name args...)`.
89
+ *
90
+ * Tail positions, recursively from the IR root:
91
+ * - the root itself (function's terminal value-producing expression)
92
+ * - both arms of `(if (result T) cond (then ...) (else ...))`
93
+ * - last instruction of `(block (result T) ...)`
94
+ *
95
+ * Only fires when caller and callee result types match — if they didn't match,
96
+ * `asParamType`/`asPtrOffset` would have wrapped the call in a conversion op,
97
+ * pushing the `call` away from the tail position. We don't recurse into
98
+ * arithmetic / select / loop ops: their results aren't standalone-tail control
99
+ * transfers.
100
+ *
101
+ * Mirrors the existing `'return'` op handler in emit.js (which already does
102
+ * TCO when the return statement is explicit). This pass closes the gap for
103
+ * expression-bodied arrows like `(n, acc) => n <= 0 ? acc : sum(n-1, acc+n)`
104
+ * — the AST has no `return` keyword so the emit-time handler never fires.
105
+ */
106
+ const tcoTailRewrite = (ir, resultType) => {
107
+ if (ctx.transform.noTailCall || ctx.func.inTry) return ir
108
+ if (!Array.isArray(ir)) return ir
109
+ const op = ir[0]
110
+ if (op === 'call' && typeof ir[1] === 'string') {
111
+ // IR call name is `$name`; func.map keys are bare `name`.
112
+ const calleeName = ir[1].startsWith('$') ? ir[1].slice(1) : ir[1]
113
+ const callee = ctx.func.map.get(calleeName)
114
+ if (!callee || callee.raw) return ir
115
+ const calleeRT = callee.sig?.results?.[0] ?? 'f64'
116
+ if (calleeRT !== resultType) return ir
117
+ return typed(['return_call', ...ir.slice(1)], resultType)
118
+ }
119
+ if (op === 'if' && Array.isArray(ir[1]) && ir[1][0] === 'result') {
120
+ let changed = false
121
+ const newIr = ir.slice()
122
+ for (let i = 3; i < newIr.length; i++) {
123
+ const arm = newIr[i]
124
+ if (Array.isArray(arm) && (arm[0] === 'then' || arm[0] === 'else') && arm.length > 1) {
125
+ const last = arm[arm.length - 1]
126
+ const rewritten = tcoTailRewrite(last, resultType)
127
+ if (rewritten !== last) {
128
+ newIr[i] = [...arm.slice(0, -1), rewritten]
129
+ changed = true
130
+ }
131
+ }
132
+ }
133
+ return changed ? typed(newIr, ir.type) : ir
134
+ }
135
+ if (op === 'block' && ir.length > 1) {
136
+ const last = ir[ir.length - 1]
137
+ const rewritten = tcoTailRewrite(last, resultType)
138
+ if (rewritten !== last) return typed([...ir.slice(0, -1), rewritten], ir.type)
139
+ }
140
+ return ir
141
+ }
142
+
143
+ // === Module compilation ===
144
+
145
+ const cloneRepMap = map => map ? new Map([...map].map(([k, v]) => [k, { ...v }])) : null
146
+
147
+ function enterFunc(func) {
148
+ ctx.func.stack = []
149
+ ctx.func.uniq = 0
150
+ ctx.func.current = func.sig
151
+ ctx.func.body = func.body
152
+ ctx.func.directClosures = null
153
+ ctx.func.localProps = null
154
+ }
155
+
156
+ function analyzeFuncForEmit(func, programFacts) {
157
+ const { paramReps } = programFacts
158
+ if (func.raw) return null
159
+
160
+ const { name, body, sig } = func
161
+ enterFunc(func)
162
+
163
+ const block = Array.isArray(body) && body[0] === '{}' && body[1]?.[0] !== ':'
164
+ ctx.func.boxed = new Map()
165
+ ctx.func.repByLocal = null
166
+ ctx.types.typedElem = ctx.scope.globalTypedElem ? new Map(ctx.scope.globalTypedElem) : null
167
+
168
+ const _reps = paramReps.get(name)
169
+ if (_reps) {
170
+ for (const [k, r] of _reps) {
171
+ if (k >= sig.params.length) continue
172
+ const pname = sig.params[k].name
173
+ if (r.typedCtor) {
174
+ if (!ctx.types.typedElem) ctx.types.typedElem = new Map()
175
+ if (!ctx.types.typedElem.has(pname)) ctx.types.typedElem.set(pname, r.typedCtor)
176
+ updateRep(pname, { val: VAL.TYPED })
177
+ }
178
+ if (r.val && !ctx.func.repByLocal?.get(pname)?.val) updateRep(pname, { val: r.val })
179
+ if (r.arrayElemSchema != null) updateRep(pname, { arrayElemSchema: r.arrayElemSchema })
180
+ if (r.arrayElemValType != null) updateRep(pname, { arrayElemValType: r.arrayElemValType })
181
+ if (r.intConst != null) updateRep(pname, { intConst: r.intConst })
182
+ }
183
+ }
184
+ // Drop any earlier-cached analyzeLocals for this body — narrowSignatures called
185
+ // it before our pre-seed, when params still had no inferred VAL.TYPED, so the
186
+ // cached widths reflect the pre-narrow state. Re-walk now with reps in place.
187
+ invalidateLocalsCache(body)
188
+ ctx.func.locals = block ? analyzeLocals(body) : new Map()
189
+ if (block) {
190
+ analyzeValTypes(body)
191
+ analyzeIntCertain(body)
192
+ analyzeBoxedCaptures(body)
193
+ // Lower provably-monomorphic pointer locals to i32 offset storage.
194
+ const unbox = analyzePtrUnboxable(body, ctx.func.locals, ctx.func.boxed)
195
+ if (unbox.size > 0) {
196
+ for (const [n, kind] of unbox) {
197
+ ctx.func.locals.set(n, 'i32')
198
+ const fields = { ptrKind: kind }
199
+ if (kind === VAL.TYPED) {
200
+ const aux = typedElemAux(ctx.types.typedElem?.get(n))
201
+ if (aux != null) fields.ptrAux = aux
202
+ }
203
+ updateRep(n, fields)
204
+ }
205
+ }
206
+ }
207
+ // Pointer-ABI params (from narrowing loop above): params already have type='i32' and
208
+ // ptrKind set. Register them in ctx.func.repByLocal so readVar tags local.gets correctly.
209
+ // Boxed capture still works: the boxed-init path (below) uses a ptrKind-tagged local.get
210
+ // so asF64 reboxes to NaN-form before f64.store to the cell.
211
+ for (const p of sig.params) {
212
+ if (p.ptrKind == null) continue
213
+ const fields = { ptrKind: p.ptrKind }
214
+ if (p.ptrAux != null) fields.ptrAux = p.ptrAux
215
+ updateRep(p.name, fields)
216
+ }
217
+
218
+ return {
219
+ block,
220
+ locals: new Map(ctx.func.locals),
221
+ boxed: new Map(ctx.func.boxed),
222
+ typedElem: ctx.types.typedElem ? new Map(ctx.types.typedElem) : null,
223
+ repByLocal: cloneRepMap(ctx.func.repByLocal),
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Phase: emit one user function to WAT IR.
229
+ *
230
+ * Reads precomputed `funcFacts` and the narrowed `func.sig`; applies scoped
231
+ * schema param bindings during emission so they cannot leak between functions.
232
+ */
233
+ function emitFunc(func, funcFacts, programFacts) {
234
+ const { paramReps } = programFacts
235
+
236
+ // Raw WAT functions (e.g., _alloc, _reset from memory module)
237
+ if (func.raw) return parseWat(func.raw)
238
+
239
+ const { name, body, exported, sig } = func
240
+ const multi = sig.results.length > 1
241
+ const _reps = paramReps.get(name)
242
+
243
+ enterFunc(func)
244
+ const block = funcFacts.block
245
+ ctx.func.locals = new Map(funcFacts.locals)
246
+ ctx.func.boxed = new Map(funcFacts.boxed)
247
+ ctx.func.repByLocal = cloneRepMap(funcFacts.repByLocal)
248
+ ctx.types.typedElem = funcFacts.typedElem ? new Map(funcFacts.typedElem) : null
249
+
250
+ // D: Apply call-site param facts (only if body analysis didn't already set them).
251
+ // Schema bindings additionally write into ctx.schema.vars so prop-access dispatch
252
+ // hits the slot map. ctx.schema.vars is saved/restored so bindings don't leak.
253
+ const schemaVarsPrev = new Map(ctx.schema.vars)
254
+ if (_reps) {
255
+ for (const [k, r] of _reps) {
256
+ if (k >= sig.params.length) continue
257
+ const pname = sig.params[k].name
258
+ if (r.val && !ctx.func.repByLocal?.get(pname)?.val) updateRep(pname, { val: r.val })
259
+ if (r.typedCtor) {
260
+ if (!ctx.types.typedElem) ctx.types.typedElem = new Map()
261
+ if (!ctx.types.typedElem.has(pname)) ctx.types.typedElem.set(pname, r.typedCtor)
262
+ if (!ctx.func.repByLocal?.get(pname)?.val) updateRep(pname, { val: VAL.TYPED })
263
+ }
264
+ if (r.schemaId != null && !exported && !ctx.schema.vars.has(pname)) {
265
+ ctx.schema.vars.set(pname, r.schemaId)
266
+ updateRep(pname, { schemaId: r.schemaId })
267
+ }
268
+ }
269
+ }
270
+
271
+ const fn = ['func', `$${name}`]
272
+ // Boundary-wrapped exports defer the export attribute to a synthesized
273
+ // wrapper ($${name}$exp) that reboxes the narrowed result back to f64.
274
+ if (exported && !isBoundaryWrapped(func)) fn.push(['export', `"${name}"`])
275
+ fn.push(...sig.params.map(p => ['param', `$${p.name}`, p.type]))
276
+ fn.push(...sig.results.map(t => ['result', t]))
277
+
278
+ // Default params: missing JS args become canonical NaN (0x7FF8000000000000) in WASM f64 params.
279
+ // Check for canonical NaN specifically — NaN-boxed pointers are also NaN but have non-zero payload.
280
+ const defaults = func.defaults || {}
281
+ const defaultInits = []
282
+ for (const [pname, defVal] of Object.entries(defaults)) {
283
+ const p = sig.params.find(p => p.name === pname)
284
+ const t = p?.type || 'f64'
285
+ defaultInits.push(
286
+ ['if', isNullish(typed(['local.get', `$${pname}`], 'f64')),
287
+ ['then', ['local.set', `$${pname}`, t === 'f64' ? asF64(emit(defVal)) : asI32(emit(defVal))]]])
288
+ }
289
+
290
+ // Box params that are mutably captured: allocate cell, copy param value
291
+ const boxedParamInits = []
292
+ for (const p of sig.params) {
293
+ if (ctx.func.boxed.has(p.name)) {
294
+ const cell = ctx.func.boxed.get(p.name)
295
+ ctx.func.locals.set(cell, 'i32')
296
+ const lget = typed(['local.get', `$${p.name}`], p.type)
297
+ if (p.ptrKind != null) lget.ptrKind = p.ptrKind
298
+ boxedParamInits.push(
299
+ ['local.set', `$${cell}`, ['call', '$__alloc', ['i32.const', 8]]],
300
+ ['f64.store', ['local.get', `$${cell}`], asF64(lget)])
301
+ }
302
+ }
303
+
304
+ if (block) {
305
+ const stmts = emitBody(body)
306
+ for (const [l, t] of ctx.func.locals) fn.push(['local', `$${l}`, t])
307
+ // I: Skip trailing fallback when last statement is return (unreachable code)
308
+ const lastStmt = stmts.at(-1)
309
+ const endsWithReturn = lastStmt && (lastStmt[0] === 'return' || lastStmt[0] === 'return_call')
310
+ fn.push(...defaultInits, ...boxedParamInits, ...stmts, ...(endsWithReturn ? [] : sig.results.map(t => [`${t}.const`, 0])))
311
+ } else if (multi && body[0] === '[') {
312
+ const values = body.slice(1).map(e => asF64(emit(e)))
313
+ for (const [l, t] of ctx.func.locals) fn.push(['local', `$${l}`, t])
314
+ fn.push(...boxedParamInits, ...values)
315
+ } else {
316
+ const ir = emit(body)
317
+ for (const [l, t] of ctx.func.locals) fn.push(['local', `$${l}`, t])
318
+ const finalIR = sig.ptrKind != null ? asPtrOffset(ir, sig.ptrKind) : asParamType(ir, sig.results[0])
319
+ fn.push(...defaultInits, ...boxedParamInits, tcoTailRewrite(finalIR, sig.results[0]))
320
+ }
321
+
322
+ // Restore schema.vars so param bindings don't leak to next function.
323
+ ctx.schema.vars = schemaVarsPrev
324
+ return fn
325
+ }
326
+
327
+ /**
328
+ * Phase: synthesize JS-boundary wrappers for narrowed exports.
329
+ *
330
+ * For each `isBoundaryWrapped(func)`, emit a sibling `$${name}$exp` that:
331
+ * - holds the (export "name") attribute (JS sees the wrapper)
332
+ * - takes f64 params always (JS calling convention via host.js wrap)
333
+ * - converts each narrowed param at the call: f64 → i32 (truncate-sat) for
334
+ * numeric narrowed, f64 → i32-offset (`i32.wrap_i64 + i64.reinterpret_f64`)
335
+ * for pointer narrowed
336
+ * - forwards args to the inner $${name}
337
+ * - reboxes the narrowed result back to f64 so JS sees Number / NaN-boxed ptr
338
+ *
339
+ * Param convert cases (each narrowed inner-param):
340
+ * - p.type = 'i32', no ptrKind → i32.trunc_sat_f64_s(local.get $p)
341
+ * - p.type = 'i32', ptrKind set → i32.wrap_i64(i64.reinterpret_f64(local.get $p))
342
+ *
343
+ * Result rebox cases:
344
+ * - sig.ptrKind != null → mkPtrIR(ptrKind, ptrAux ?? 0, callIR)
345
+ * - sig.results[0] = i32 → f64.convert_i32_s(callIR)
346
+ * - sig.results[0] = f64 → callIR (some params narrowed but result stayed f64)
347
+ */
348
+ function synthesizeBoundaryWrappers() {
349
+ const wrappers = []
350
+ for (const func of ctx.func.list) {
351
+ if (!isBoundaryWrapped(func)) continue
352
+ const { name, sig } = func
353
+ const wrapNode = ['func', `$${name}$exp`, ['export', `"${name}"`]]
354
+ // External ABI: every param is f64 (JS Number / NaN-boxed ptr).
355
+ for (const p of sig.params) wrapNode.push(['param', `$${p.name}`, 'f64'])
356
+ wrapNode.push(['result', 'f64'])
357
+ const args = sig.params.map(p => {
358
+ const get = ['local.get', `$${p.name}`]
359
+ if (p.type === 'f64') return get
360
+ if (p.ptrKind != null) {
361
+ // NaN-boxed f64 → raw i32 offset
362
+ return ['i32.wrap_i64', ['i64.reinterpret_f64', get]]
363
+ }
364
+ // Numeric i32 — JS Number → i32 truncation (matches `n | 0` for integers).
365
+ return ['i32.trunc_sat_f64_s', get]
366
+ })
367
+ const callIR = ['call', `$${name}`, ...args]
368
+ let body
369
+ if (sig.ptrKind != null) {
370
+ const ptrType = valKindToPtr(sig.ptrKind)
371
+ body = mkPtrIR(ptrType, sig.ptrAux ?? 0, callIR)
372
+ } else if (sig.results[0] === 'i32') {
373
+ body = ['f64.convert_i32_s', callIR]
374
+ } else {
375
+ body = callIR
376
+ }
377
+ wrapNode.push(body)
378
+ wrappers.push(wrapNode)
379
+ }
380
+ return wrappers
381
+ }
382
+
383
+
384
+ /**
385
+ * Phase: emit one closure body to WAT IR.
386
+ *
387
+ * Closures share a uniform signature (env f64, argc i32, a0..a{W-1} f64) → f64
388
+ * so any closure can be invoked via call_indirect on $ftN. This function
389
+ * builds one body fn given the body record (cb) created by ctx.closure.make.
390
+ *
391
+ * Mutates ctx.func.* per-body state (locals, boxed, repByLocal) and
392
+ * ctx.schema.vars / ctx.types.typedElem (restored on exit so capture-binding
393
+ * leaks don't poison the next body). Returns the WAT IR for the func node.
394
+ */
395
+ function emitClosureBody(cb) {
396
+ const prevSchemaVars = ctx.schema.vars
397
+ const prevTypedElems = ctx.types.typedElem
398
+ // Reset per-function state for closure body
399
+ ctx.func.locals = new Map()
400
+ ctx.func.repByLocal = null
401
+ if (cb.valTypes) for (const [name, vt] of cb.valTypes) updateRep(name, { val: vt })
402
+ if (cb.schemaVars) {
403
+ ctx.schema.vars = new Map([...prevSchemaVars, ...cb.schemaVars])
404
+ for (const [name, sid] of cb.schemaVars) updateRep(name, { schemaId: sid })
405
+ }
406
+ const globalTE = ctx.scope.globalTypedElem
407
+ if (cb.typedElems) {
408
+ ctx.types.typedElem = globalTE ? new Map([...globalTE, ...cb.typedElems]) : new Map(cb.typedElems)
409
+ } else if (globalTE) {
410
+ ctx.types.typedElem = new Map(globalTE)
411
+ } else {
412
+ ctx.types.typedElem = prevTypedElems
413
+ }
414
+ // In closure bodies, boxed captures use the original name as both var and cell local
415
+ ctx.func.boxed = cb.boxed ? new Map([...cb.boxed].map(v => [v, v])) : new Map()
416
+ ctx.func.stack = []
417
+ ctx.func.uniq = Math.max(ctx.func.uniq, 100) // avoid label collisions
418
+ ctx.func.body = cb.body
419
+ // Seed direct-call dispatch for captured const-bound closures (A3 across capture boundary).
420
+ // closure.make snapshotted the parent's directClosures for each capture; here we restore
421
+ // them so calls to a captured `peek` lower to `call $closureN` instead of call_indirect.
422
+ ctx.func.directClosures = cb.directClosures ? new Map(cb.directClosures) : null
423
+ // Uniform convention: (env f64, argc i32, a0..a{width-1} f64) → f64
424
+ const W = ctx.closure.width ?? MAX_CLOSURE_ARITY
425
+ const paramDecls = [{ name: '__env', type: 'f64' }, { name: '__argc', type: 'i32' }]
426
+ for (let i = 0; i < W; i++) paramDecls.push({ name: `__a${i}`, type: 'f64' })
427
+ ctx.func.current = { params: paramDecls, results: ['f64'] }
428
+
429
+ const fn = ['func', `$${cb.name}`]
430
+ fn.push(['param', '$__env', 'f64'])
431
+ fn.push(['param', '$__argc', 'i32'])
432
+ for (let i = 0; i < W; i++) fn.push(['param', `$__a${i}`, 'f64'])
433
+ fn.push(['result', 'f64'])
434
+
435
+ // Params are locals, assigned directly from inline slots
436
+ for (const p of cb.params) ctx.func.locals.set(p, 'f64')
437
+
438
+ // Register captured variable locals: boxed = i32 cell pointer, otherwise f64 value
439
+ for (let i = 0; i < cb.captures.length; i++) {
440
+ const name = cb.captures[i]
441
+ ctx.func.locals.set(name, ctx.func.boxed.has(name) ? 'i32' : 'f64')
442
+ }
443
+
444
+ // Emit body
445
+ const block = Array.isArray(cb.body) && cb.body[0] === '{}' && cb.body[1]?.[0] !== ':'
446
+ let bodyIR
447
+ if (block) {
448
+ for (const [k, v] of analyzeLocals(cb.body)) if (!ctx.func.locals.has(k)) ctx.func.locals.set(k, v)
449
+ // Detect captures from deeper nested arrows that mutate this body's locals/params/captures
450
+ analyzeBoxedCaptures(cb.body)
451
+ for (const name of ctx.func.boxed.keys()) {
452
+ if (ctx.func.locals.get(name) === 'f64') ctx.func.locals.set(name, 'i32')
453
+ }
454
+ bodyIR = emitBody(cb.body)
455
+ } else {
456
+ bodyIR = [asF64(emit(cb.body))]
457
+ }
458
+
459
+ // Pre-allocate cache locals for env unpacking
460
+ const envBase = cb.captures.length > 0 ? `${T}envBase${ctx.func.uniq++}` : null
461
+ if (envBase) ctx.func.locals.set(envBase, 'i32')
462
+ // Rest param: allocate helper locals (len + offset) before emitting decls
463
+ let restOff, restLen
464
+ if (cb.rest) {
465
+ restOff = `${T}restOff${ctx.func.uniq++}`
466
+ restLen = `${T}restLen${ctx.func.uniq++}`
467
+ ctx.func.locals.set(restOff, 'i32')
468
+ ctx.func.locals.set(restLen, 'i32')
469
+ inc('__alloc_hdr', '__mkptr')
470
+ }
471
+
472
+ // Insert locals (captures + params + declared)
473
+ for (const [l, t] of ctx.func.locals) fn.push(['local', `$${l}`, t])
474
+
475
+ // Load captures from env: boxed → i32.load (raw cell pointer), immutable → f64.load value.
476
+ // env is the CLOSURE pointer (PTR.CLOSURE) — never an ARRAY, no forwarding chain.
477
+ // Inline the offset extraction (low 32 bits) instead of calling __ptr_offset per invocation.
478
+ if (envBase) {
479
+ fn.push(['local.set', `$${envBase}`,
480
+ ['i32.wrap_i64', ['i64.reinterpret_f64', ['local.get', '$__env']]]])
481
+ for (let i = 0; i < cb.captures.length; i++) {
482
+ const name = cb.captures[i]
483
+ const addr = ['i32.add', ['local.get', `$${envBase}`], ['i32.const', i * 8]]
484
+ fn.push(['local.set', `$${name}`,
485
+ ctx.func.boxed.has(name) ? ['i32.load', addr] : ['f64.load', addr]])
486
+ }
487
+ }
488
+
489
+ // Unpack fixed params directly from inline slots (caller padded missing with UNDEF_NAN).
490
+ // Rest name (if present) is last in cb.params — handled separately below.
491
+ const fixedParamN = cb.params.length - (cb.rest ? 1 : 0)
492
+ for (let i = 0; i < fixedParamN && i < W; i++) {
493
+ fn.push(['local.set', `$${cb.params[i]}`, ['local.get', `$__a${i}`]])
494
+ }
495
+
496
+ // Rest param: pack slots a[fixedParams..argc-1] into fresh array.
497
+ // len = clamp(argc - fixedParams, 0, restSlots). Rest-param closures receive
498
+ // at most (width - fixedParams) rest args — spread callers with
499
+ // more dynamic elements lose the overflow (documented limitation).
500
+ if (cb.rest) {
501
+ const fixedN = fixedParamN
502
+ const restSlots = W - fixedN
503
+ fn.push(['local.set', `$${restLen}`,
504
+ ['select',
505
+ ['i32.sub', ['local.get', '$__argc'], ['i32.const', fixedN]],
506
+ ['i32.const', 0],
507
+ ['i32.gt_s', ['local.get', '$__argc'], ['i32.const', fixedN]]]])
508
+ fn.push(['if', ['i32.gt_s', ['local.get', `$${restLen}`], ['i32.const', restSlots]],
509
+ ['then', ['local.set', `$${restLen}`, ['i32.const', restSlots]]]])
510
+ fn.push(['local.set', `$${restOff}`,
511
+ ['call', '$__alloc_hdr',
512
+ ['local.get', `$${restLen}`], ['local.get', `$${restLen}`], ['i32.const', 8]]])
513
+ for (let i = 0; i < restSlots; i++) {
514
+ fn.push(['if', ['i32.gt_s', ['local.get', `$${restLen}`], ['i32.const', i]],
515
+ ['then', ['f64.store',
516
+ ['i32.add', ['local.get', `$${restOff}`], ['i32.const', i * 8]],
517
+ ['local.get', `$__a${fixedN + i}`]]]])
518
+ }
519
+ fn.push(['local.set', `$${cb.rest}`,
520
+ ['call', '$__mkptr', ['i32.const', PTR.ARRAY], ['i32.const', 0], ['local.get', `$${restOff}`]]])
521
+ }
522
+
523
+ // Default params for closures (check sentinel after unpack)
524
+ if (cb.defaults) {
525
+ for (const [pname, defVal] of Object.entries(cb.defaults)) {
526
+ fn.push(['if', isNullish(['local.get', `$${pname}`]),
527
+ ['then', ['local.set', `$${pname}`, asF64(emit(defVal))]]])
528
+ }
529
+ }
530
+ fn.push(...bodyIR)
531
+ // I: Skip trailing fallback when last statement is return
532
+ if (block && !(bodyIR.at(-1)?.[0] === 'return' || bodyIR.at(-1)?.[0] === 'return_call')) fn.push(['f64.const', 0])
533
+ ctx.schema.vars = prevSchemaVars
534
+ ctx.types.typedElem = prevTypedElems
535
+ return fn
536
+ }
537
+
538
+ /**
539
+ * Phase: build module-init function `__start`.
540
+ *
541
+ * `__start` is the WebAssembly start function: runs once at instantiation, after
542
+ * imports/globals are bound but before any export is called. It threads together
543
+ * everything that must happen before user code observes a ready module:
544
+ *
545
+ * 1. Reset per-function emit state (locals/repByLocal/boxed/stack) — __start is
546
+ * a fresh function context with no params.
547
+ * 2. analyzeValTypes(ast) so emit sees correct ptrKind on top-level decls.
548
+ * 3. Sub-module init (foreign module bootstrap) emits first — its globals
549
+ * must be assigned before main-module code reads them.
550
+ * 4. emit(ast) — user top-level statements (let/const, call expressions, …).
551
+ * 5. boxInit — auto-boxing globals (vars with prop assignments lifted to OBJECT).
552
+ * 6. schemaInit — runtime schema-name table for JSON.stringify.
553
+ * 7. strPoolInit — copy passive string-pool segment to heap (shared memory).
554
+ * 8. typeofInit — preallocate typeof-result string globals.
555
+ *
556
+ * Order in the assembled body: strPool → typeof → box → schema → moduleInits → init.
557
+ *
558
+ * Late closures (those compiled during __start emit, e.g. arrows declared at
559
+ * module scope) are flushed via `compilePendingClosures` and prepended to
560
+ * `sec.funcs` so closure indices stay stable across the table.
561
+ */
562
+ function buildStartFn(ast, sec, closureFuncs, compilePendingClosures) {
563
+ ctx.func.locals = new Map()
564
+ ctx.func.repByLocal = null
565
+ ctx.func.boxed = new Map()
566
+ ctx.func.stack = []
567
+ ctx.func.current = { params: [], results: [] }
568
+ analyzeValTypes(ast)
569
+ const normalizeIR = ir => !ir?.length ? [] : Array.isArray(ir[0]) ? ir : [ir]
570
+
571
+ const moduleInits = []
572
+ if (ctx.module.moduleInits) {
573
+ for (const mi of ctx.module.moduleInits) {
574
+ analyzeValTypes(mi)
575
+ moduleInits.push(...normalizeIR(emit(mi)))
576
+ }
577
+ }
578
+ const init = emit(ast)
579
+
580
+ const boxInit = []
581
+ if (ctx.schema.autoBox) {
582
+ const bt = `${T}box`
583
+ ctx.func.locals.set(bt, 'i32')
584
+ for (const [name, { schemaId, schema }] of ctx.schema.autoBox) {
585
+ inc('__alloc', '__mkptr')
586
+ boxInit.push(
587
+ ['local.set', `$${bt}`, ['call', '$__alloc', ['i32.const', schema.length * 8]]],
588
+ ['f64.store', ['local.get', `$${bt}`],
589
+ ctx.func.names.has(name) ? ['f64.const', 0] : ['global.get', `$${name}`]],
590
+ ...schema.slice(1).map((_, i) =>
591
+ ['f64.store', ['i32.add', ['local.get', `$${bt}`], ['i32.const', (i + 1) * 8]], ['f64.const', 0]]),
592
+ ['global.set', `$${name}`, mkPtrIR(PTR.OBJECT, schemaId, ['local.get', `$${bt}`])])
593
+ }
594
+ }
595
+
596
+ const schemaInit = []
597
+ // Schema name table is needed by JSON.stringify (legacy), and by __dyn_get's
598
+ // OBJECT-schema fallback for polymorphic-receiver `.prop` access. Lift the
599
+ // gate to also populate when any __dyn_get* family helper is included so
600
+ // polymorphic OBJECT patterns (mismatched-schema `?:`, unknown-schema
601
+ // params) resolve via runtime aux→sid lookup. Direct dependents of
602
+ // __dyn_get (set transitively by resolveIncludes() later) are listed
603
+ // explicitly here because the dep graph hasn't been expanded yet at
604
+ // start-fn build time.
605
+ const needsSchemaTbl = ctx.schema.list.length && (
606
+ ctx.core.includes.has('__stringify') ||
607
+ ctx.core.includes.has('__dyn_get') ||
608
+ ctx.core.includes.has('__dyn_get_t') ||
609
+ ctx.core.includes.has('__dyn_get_any') ||
610
+ ctx.core.includes.has('__dyn_get_any_t') ||
611
+ ctx.core.includes.has('__dyn_get_expr') ||
612
+ ctx.core.includes.has('__dyn_get_expr_t') ||
613
+ ctx.core.includes.has('__dyn_get_or'))
614
+ if (needsSchemaTbl) {
615
+ const nSchemas = ctx.schema.list.length
616
+ const stbl = `${T}stbl`
617
+ const sarr = `${T}sarr`
618
+ ctx.func.locals.set(stbl, 'i32')
619
+ ctx.func.locals.set(sarr, 'i32')
620
+ inc('__alloc', '__alloc_hdr', '__mkptr')
621
+ schemaInit.push(
622
+ ['local.set', `$${stbl}`, ['call', '$__alloc', ['i32.const', nSchemas * 8]]],
623
+ ['global.set', '$__schema_tbl', ['local.get', `$${stbl}`]])
624
+ for (let s = 0; s < nSchemas; s++) {
625
+ const keys = ctx.schema.list[s]
626
+ const n = keys.length
627
+ schemaInit.push(
628
+ ['local.set', `$${sarr}`, ['call', '$__alloc_hdr', ['i32.const', n], ['i32.const', n], ['i32.const', 8]]])
629
+ for (let k = 0; k < n; k++)
630
+ schemaInit.push(
631
+ ['f64.store', ['i32.add', ['local.get', `$${sarr}`], ['i32.const', k * 8]],
632
+ emit(['str', String(keys[k])])])
633
+ schemaInit.push(
634
+ ['f64.store', ['i32.add', ['local.get', `$${stbl}`], ['i32.const', s * 8]],
635
+ mkPtrIR(PTR.ARRAY, 0, ['local.get', `$${sarr}`])])
636
+ }
637
+ }
638
+
639
+ const strPoolInit = []
640
+ if (ctx.runtime.strPool) {
641
+ const total = ctx.runtime.strPool.length
642
+ strPoolInit.push(
643
+ ['global.set', '$__strBase', ['call', '$__alloc', ['i32.const', total]]],
644
+ ['memory.init', '$__strPool', ['global.get', '$__strBase'], ['i32.const', 0], ['i32.const', total]],
645
+ ['data.drop', '$__strPool'],
646
+ )
647
+ }
648
+
649
+ const typeofInit = []
650
+ if (ctx.runtime.typeofStrs) {
651
+ for (const s of ctx.runtime.typeofStrs)
652
+ typeofInit.push(['global.set', `$__tof_${s}`, emit(['str', s])])
653
+ }
654
+ if (moduleInits.length || init?.length || boxInit.length || schemaInit.length || typeofInit.length || strPoolInit.length || ctx.features.timers) {
655
+ const initIR = normalizeIR(init)
656
+ const startFn = ['func', '$__start']
657
+ for (const [l, t] of ctx.func.locals) startFn.push(['local', `$${l}`, t])
658
+ startFn.push(...strPoolInit, ...typeofInit, ...boxInit, ...schemaInit,
659
+ ...(ctx.features.timers ? [['call', '$__timer_init']] : []),
660
+ ...moduleInits, ...initIR,
661
+ ...(ctx.features.blockingTimers ? [['call', '$__timer_loop']] : []),
662
+ )
663
+ sec.start.push(startFn, ['start', '$__start'])
664
+ }
665
+
666
+ const beforeLen = closureFuncs.length
667
+ compilePendingClosures()
668
+ if (closureFuncs.length > beforeLen)
669
+ sec.funcs.unshift(...closureFuncs.slice(beforeLen))
670
+ }
671
+
672
+ /**
673
+ * Phase: closure-body dedup.
674
+ *
675
+ * Two closures with structurally-equal bodies (same shape after alpha-renaming
676
+ * locals/params) are emitted as a single function — duplicates redirect through
677
+ * the elem table to the canonical name. Closure bodies often share shape because
678
+ * the same inner arrow can be instantiated in many places (e.g. parser combinators).
679
+ *
680
+ * IN: closureFuncs (the WAT IR list emitted by emitClosureBody),
681
+ * sec.funcs (already contains closureFuncs + regular funcs),
682
+ * ctx.closure.table (elem-section names).
683
+ * OUT: sec.funcs filtered to canonical bodies, ctx.closure.table redirected.
684
+ *
685
+ * Runs AFTER all closures (including those compiled during __start emit) are
686
+ * collected so structural duplicates across batches collapse together.
687
+ */
688
+ function dedupClosureBodies(closureFuncs, sec) {
689
+ if (closureFuncs.length <= 1) return
690
+ const canonicalize = (fn) => {
691
+ const localNames = new Set()
692
+ const collect = (node) => {
693
+ if (!Array.isArray(node)) return
694
+ if ((node[0] === 'local' || node[0] === 'param') && typeof node[1] === 'string' && node[1][0] === '$')
695
+ localNames.add(node[1])
696
+ for (const c of node) collect(c)
697
+ }
698
+ collect(fn)
699
+ let counter = 0
700
+ const renameMap = new Map()
701
+ const walk = node => {
702
+ if (typeof node === 'string') {
703
+ if (!localNames.has(node)) return node
704
+ let r = renameMap.get(node)
705
+ if (!r) { r = `$_c${counter++}`; renameMap.set(node, r) }
706
+ return r
707
+ }
708
+ if (!Array.isArray(node)) return node
709
+ return node.map(walk)
710
+ }
711
+ return JSON.stringify(['func', ...fn.slice(2).map(walk)])
712
+ }
713
+ const hashToName = new Map()
714
+ const redirect = new Map()
715
+ const keepSet = new Set()
716
+ for (const fn of closureFuncs) {
717
+ const key = canonicalize(fn)
718
+ const name = fn[1].slice(1)
719
+ const canonical = hashToName.get(key)
720
+ if (canonical) redirect.set(name, canonical)
721
+ else { hashToName.set(key, name); keepSet.add(name) }
722
+ }
723
+ if (!redirect.size) return
724
+ ctx.closure.table = ctx.closure.table.map(n => redirect.get(n) || n)
725
+ const kept = sec.funcs.filter(fn => {
726
+ if (!Array.isArray(fn) || fn[0] !== 'func') return true
727
+ const name = typeof fn[1] === 'string' && fn[1][0] === '$' ? fn[1].slice(1) : null
728
+ return !name || !redirect.has(name)
729
+ })
730
+ sec.funcs.length = 0
731
+ sec.funcs.push(...kept)
732
+ }
733
+
734
+ /**
735
+ * Phase: closure-table finalize + ABI shrink.
736
+ *
737
+ * Two opportunities, both gated on a post-emit scan for `call_indirect`:
738
+ *
739
+ * 1. Drop dead $ftN type / table / elem when the scan finds zero call_indirect
740
+ * sites (every closure call was direct-dispatched via A3 + capture-boundary
741
+ * propagation, AND no top-level fn was taken as a value). Closure pointers
742
+ * still carry funcIdx in their NaN-box aux bits, but those bits become dead
743
+ * state with no reader.
744
+ *
745
+ * 2. Per-body ABI shrink: with no call_indirect, every closure is direct-only,
746
+ * so the uniform `(env, argc, a0..a{W-1})` ABI is no longer required.
747
+ * Each body sheds:
748
+ * • $__env when captures.length === 0
749
+ * • $__argc when no rest param (defaults check param value, not argc)
750
+ * • $__a{i} for i ≥ fixedN when no rest (caller's UNDEF padding is dead)
751
+ * Rest closures keep all W slots — argc + slot{fixedN..W-1} are how rest packs.
752
+ * Both `call` and `return_call` (tail call) sites are rewritten in the same walk.
753
+ */
754
+ function finalizeClosureTable(sec) {
755
+ if (!ctx.closure.table?.length) return
756
+ let indirectUsed = false
757
+ const scan = (n) => {
758
+ if (!Array.isArray(n) || indirectUsed) return
759
+ if (n[0] === 'call_indirect') { indirectUsed = true; return }
760
+ for (const c of n) if (Array.isArray(c)) scan(c)
761
+ }
762
+ for (const fn of sec.funcs) { scan(fn); if (indirectUsed) break }
763
+ if (!indirectUsed) for (const fn of sec.start) scan(fn)
764
+ // Also scan raw stdlib strings (pullStdlib hasn't run yet, so stdlib funcs aren't in sec.funcs)
765
+ if (!indirectUsed) for (const s of Object.keys(ctx.core.stdlib)) {
766
+ if (ctx.core.stdlib[s]?.includes?.('call_indirect')) { indirectUsed = true; break }
767
+ }
768
+ // Keep table if call_indirect is used (closures, timer dispatch, etc.)
769
+ if (indirectUsed) {
770
+ sec.table = [['table', ['export', '"__jz_table"'], ctx.closure.table.length, 'funcref']]
771
+ sec.elem = [['elem', ['i32.const', 0], 'func', ...ctx.closure.table.map(n => `$${n}`)]]
772
+ return
773
+ }
774
+ sec.table = []
775
+ sec.elem = []
776
+ sec.types = sec.types.filter(t => !(Array.isArray(t) && t[1] === '$ftN'))
777
+ const W = ctx.closure.width ?? MAX_CLOSURE_ARITY
778
+ const abiOf = new Map()
779
+ for (const cb of (ctx.closure.bodies || [])) {
780
+ const fixedN = cb.params.length - (cb.rest ? 1 : 0)
781
+ abiOf.set(cb.name, {
782
+ needEnv: cb.captures.length > 0,
783
+ needArgc: !!cb.rest,
784
+ usedSlots: cb.rest ? W : fixedN,
785
+ rest: !!cb.rest,
786
+ })
787
+ }
788
+ for (const fn of sec.funcs) {
789
+ if (!Array.isArray(fn) || fn[0] !== 'func') continue
790
+ const fnName = typeof fn[1] === 'string' && fn[1][0] === '$' ? fn[1].slice(1) : null
791
+ const abi = abiOf.get(fnName)
792
+ if (!abi) continue
793
+ for (let i = fn.length - 1; i >= 0; i--) {
794
+ const node = fn[i]
795
+ if (!Array.isArray(node) || node[0] !== 'param') continue
796
+ const pname = node[1]
797
+ if (pname === '$__env' && !abi.needEnv) fn.splice(i, 1)
798
+ else if (pname === '$__argc' && !abi.needArgc) fn.splice(i, 1)
799
+ else if (typeof pname === 'string' && pname.startsWith('$__a') && !abi.rest) {
800
+ const idx = parseInt(pname.slice(4), 10)
801
+ if (Number.isFinite(idx) && idx >= abi.usedSlots) fn.splice(i, 1)
802
+ }
803
+ }
804
+ }
805
+ const rewriteCalls = (node) => {
806
+ if (!Array.isArray(node)) return
807
+ for (const c of node) if (Array.isArray(c)) rewriteCalls(c)
808
+ if ((node[0] === 'call' || node[0] === 'return_call') && typeof node[1] === 'string') {
809
+ const callee = node[1].slice(1)
810
+ const abi = abiOf.get(callee)
811
+ if (!abi) return
812
+ const newArgs = []
813
+ if (abi.needEnv) newArgs.push(node[2])
814
+ if (abi.needArgc) newArgs.push(node[3])
815
+ for (let i = 0; i < abi.usedSlots; i++) newArgs.push(node[4 + i])
816
+ node.splice(2, node.length - 2, ...newArgs)
817
+ }
818
+ }
819
+ for (const fn of sec.funcs) rewriteCalls(fn)
820
+ for (const fn of sec.start) rewriteCalls(fn)
821
+ }
822
+
823
+ /**
824
+ * Phase: pull stdlib + memory.
825
+ *
826
+ * Runs AFTER __start is built — emit calls during __start (e.g. typeofStrs,
827
+ * boxInit, schemaInit) trigger `inc()` for any helpers they need, and those
828
+ * additions must be observed before resolveIncludes() expands the dependency
829
+ * closure.
830
+ *
831
+ * Steps:
832
+ * 1. resolveIncludes() — close the include set under stdlib dependencies.
833
+ * 2. Emit memory section ONLY when some included helper uses memory ops
834
+ * (G optimization: pure scalar programs ship without memory + __heap).
835
+ * When memory is needed, the allocator (__alloc + __alloc_hdr + __reset)
836
+ * is force-included since stdlib funcs may call into it.
837
+ * 3. Pull external (host) stdlibs into sec.extStdlib (must precede normal
838
+ * imports in the module byte order).
839
+ * 4. Pull resolved factory stdlibs (those keyed by feature gates) into
840
+ * sec.stdlib via parseWat.
841
+ *
842
+ * Also reports any unresolved stdlib name (logged, not thrown — keeps test
843
+ * output readable when a missing helper is the actual bug).
844
+ */
845
+ function pullStdlib(sec) {
846
+ resolveIncludes()
847
+
848
+ const needsMemory = [...ctx.core.includes].some(n => ctx.core.stdlib[n] && MEM_OPS.test(ctx.core.stdlib[n]))
849
+ if (!needsMemory) ctx.scope.globals.delete('__heap')
850
+ if (needsMemory && ctx.module.modules.core) {
851
+ for (const fn of ['__alloc', '__alloc_hdr', '__reset']) if (!ctx.core.includes.has(fn)) ctx.core.includes.add(fn)
852
+ const pages = ctx.memory.pages || 1
853
+ if (ctx.memory.shared) sec.imports.push(['import', '"env"', '"memory"', ['memory', pages]])
854
+ else sec.memory.push(['memory', ['export', '"memory"'], pages])
855
+ if (ctx.transform.runtimeExports !== false && ctx.core._allocRawFuncs)
856
+ sec.funcs.push(...ctx.core._allocRawFuncs.map(s => parseWat(s)))
857
+ }
858
+
859
+ const stdlibStr = (name) => {
860
+ const v = ctx.core.stdlib[name]
861
+ return typeof v === 'function' ? v() : v
862
+ }
863
+ for (const name of Object.keys(ctx.core.stdlib)) {
864
+ if (name.startsWith('__ext_') && ctx.core.includes.has(name)) {
865
+ const parsed = parseWat(stdlibStr(name))
866
+ sec.extStdlib.push(parsed[0] === "module" ? parsed[1] : parsed)
867
+ ctx.core.includes.delete(name)
868
+ }
869
+ }
870
+ for (const n of ctx.core.includes) if (!ctx.core.stdlib[n]) console.error("MISSING stdlib:", n)
871
+ sec.stdlib.push(...[...ctx.core.includes].map(n => parseWat(stdlibStr(n))))
872
+ }
873
+
874
+ /**
875
+ * Phase: whole-module + per-function optimization passes.
876
+ *
877
+ * Order matters and is non-obvious — fixed deliberately:
878
+ *
879
+ * 1. specializeMkptr — replaces `call $__mkptr (T, A, off)` with `$__mkptr_T_A_d`
880
+ * for known (T, A) pairs (saves ~4 B/site). Must run BEFORE per-function
881
+ * passes so the new variants exist when fusedRewrite folds calls into them.
882
+ * 2. specializePtrBase — folds `call F (add (global G) const)` to a `_p`
883
+ * variant (saves ~3 B/site). After specializeMkptr so mkptr variants
884
+ * ($__mkptr_T_A_d) are visible to it.
885
+ * 3. sortStrPoolByFreq — reorders string-pool entries so hot strings get low
886
+ * offsets (shrinking i32.const LEB128). Shared-memory only (passive segment).
887
+ * 4. optimizeFunc per fn — hoistPtrType + fusedRewrite + sortLocalsByUse.
888
+ * Must run after specializeMkptr/specializePtrBase introduce new helpers.
889
+ * 5. hoistConstantPool — repeated f64 literals → mutable globals.
890
+ * Last because earlier passes might fold/eliminate constants.
891
+ *
892
+ * Also adjusts $__heap base when data segment exceeds 1024 bytes (default
893
+ * heap base) — keeps user code at offset 0 from clobbering the data segment.
894
+ */
895
+ function optimizeModule(sec) {
896
+ const cfg = ctx.transform.optimize // null → all on (back-compat for direct compile() callers)
897
+ if (!cfg || cfg.specializeMkptr !== false)
898
+ specializeMkptr([...sec.funcs, ...sec.stdlib, ...sec.start], wat => sec.stdlib.push(parseWat(wat)), parseWat)
899
+ if (!cfg || cfg.specializePtrBase !== false)
900
+ specializePtrBase([...sec.funcs, ...sec.stdlib, ...sec.start], wat => sec.stdlib.push(parseWat(wat)), parseWat)
901
+ if (ctx.runtime.strPool && (!cfg || cfg.sortStrPoolByFreq !== false)) {
902
+ const poolRef = { pool: ctx.runtime.strPool }
903
+ sortStrPoolByFreq([...sec.funcs, ...sec.stdlib, ...sec.start], poolRef, ctx.runtime.strPoolDedup)
904
+ ctx.runtime.strPool = poolRef.pool
905
+ }
906
+ for (const s of [...sec.funcs, ...sec.stdlib, ...sec.start]) optimizeFunc(s, cfg)
907
+ if (!cfg || cfg.hoistConstantPool !== false)
908
+ hoistConstantPool([...sec.funcs, ...sec.stdlib, ...sec.start], (name, wat) => ctx.scope.globals.set(name, wat))
909
+
910
+ const dataLen = ctx.runtime.data?.length || 0
911
+ if (dataLen > 1024 && !ctx.memory.shared) {
912
+ const heapBase = (dataLen + 7) & ~7
913
+ ctx.scope.globals.set('__heap', `(global $__heap (mut i32) (i32.const ${heapBase}))`)
914
+ for (const s of sec.stdlib)
915
+ if (s[0] === 'func' && s[1] === '$__reset')
916
+ for (let i = 2; i < s.length; i++)
917
+ if (Array.isArray(s[i]) && s[i][0] === 'global.set' && Array.isArray(s[i][2]) && s[i][2][0] === 'i32.const')
918
+ s[i][2][1] = `${heapBase}`
919
+ }
920
+ }
921
+
922
+ /**
923
+ * Phase: strip static-data prefix.
924
+ *
925
+ * R: when `__static_str` runtime helper isn't included, the leading prefix of the
926
+ * data segment (the static string-table header) is dead — strip it and shift all
927
+ * pointer offsets in user code, embedded data slots, and constant-folded NaN-box
928
+ * literals down by `prefix` bytes. ATOM/SSO have no offset, so they're unaffected.
929
+ *
930
+ * Patches both runtime-call form (`__mkptr(T, A, off)`) and the constant-folded
931
+ * form (`f64.reinterpret_i64 (i64.const ...)`) when offset >= prefix.
932
+ */
933
+ function stripStaticDataPrefix(sec) {
934
+ if (!ctx.runtime.staticDataLen || ctx.core.includes.has('__static_str')) return
935
+ const prefix = ctx.runtime.staticDataLen
936
+ const SHIFTABLE = new Set([PTR.STRING, PTR.OBJECT, PTR.ARRAY, PTR.HASH, PTR.SET, PTR.MAP, PTR.BUFFER, PTR.TYPED, PTR.CLOSURE])
937
+ const data = ctx.runtime.data || ''
938
+ const buf = new Uint8Array(data.length)
939
+ for (let i = 0; i < data.length; i++) buf[i] = data.charCodeAt(i)
940
+ const dv = new DataView(buf.buffer)
941
+ if (ctx.runtime.staticPtrSlots) {
942
+ for (const slotOff of ctx.runtime.staticPtrSlots) {
943
+ if (slotOff < prefix) continue
944
+ const bits = dv.getBigUint64(slotOff, true)
945
+ if (((bits >> 48n) & 0xFFF8n) !== NAN_PREFIX_BITS) continue
946
+ const ty = Number((bits >> 47n) & 0xFn)
947
+ if (!SHIFTABLE.has(ty)) continue
948
+ const off = Number(bits & 0xFFFFFFFFn)
949
+ if (off < prefix) continue
950
+ const hi = bits & ~0xFFFFFFFFn
951
+ dv.setBigUint64(slotOff, hi | BigInt(off - prefix), true)
952
+ }
953
+ }
954
+ let s = ''
955
+ for (let i = prefix; i < buf.length; i++) s += String.fromCharCode(buf[i])
956
+ ctx.runtime.data = s
957
+ if (ctx.runtime.staticPtrSlots) ctx.runtime.staticPtrSlots = ctx.runtime.staticPtrSlots
958
+ .filter(o => o >= prefix).map(o => o - prefix)
959
+ const shift = (node) => {
960
+ if (!Array.isArray(node)) return
961
+ for (let i = 0; i < node.length; i++) {
962
+ const child = node[i]
963
+ if (!Array.isArray(child)) continue
964
+ if (child[0] === 'call' && child[1] === '$__mkptr' &&
965
+ Array.isArray(child[2]) && SHIFTABLE.has(child[2][1]) &&
966
+ Array.isArray(child[4]) && child[4][0] === 'i32.const' &&
967
+ typeof child[4][1] === 'number' && child[4][1] >= prefix) {
968
+ child[4][1] -= prefix
969
+ } else if (child[0] === 'f64.const' &&
970
+ typeof child[1] === 'string' && child[1].startsWith('nan:0x')) {
971
+ const bits = BigInt(child[1].slice(4)) | 0x7FF0000000000000n
972
+ if (((bits >> 48n) & 0xFFF8n) === NAN_PREFIX_BITS) {
973
+ const ty = Number((bits >> 47n) & 0xFn)
974
+ if (SHIFTABLE.has(ty)) {
975
+ const off = Number(bits & 0xFFFFFFFFn)
976
+ if (off >= prefix) {
977
+ const hi = bits & ~0xFFFFFFFFn
978
+ const newBits = hi | BigInt(off - prefix)
979
+ child[1] = 'nan:0x' + newBits.toString(16).toUpperCase().padStart(16, '0')
980
+ }
981
+ }
982
+ }
983
+ }
984
+ shift(child)
985
+ }
986
+ }
987
+ for (const s of [...sec.funcs, ...sec.stdlib, ...sec.start]) shift(s)
988
+ }
989
+
990
+ /**
991
+ * Compile prepared AST to WASM module IR.
992
+ * @param {import('./prepare.js').ASTNode} ast - Prepared AST
993
+ * @returns {Array} Complete WASM module as S-expression
994
+ */
995
+ export default function compile(ast, profiler) {
996
+ // Populate known function names + lookup map on ctx.func for direct call detection
997
+ ctx.func.names.clear()
998
+ ctx.func.map.clear()
999
+ for (const f of ctx.func.list) { ctx.func.names.add(f.name); ctx.func.map.set(f.name, f) }
1000
+ // Include imported functions for call resolution (e.g. template interpolations)
1001
+ for (const imp of ctx.module.imports)
1002
+ if (imp[3]?.[0] === 'func') ctx.func.names.add(imp[3][1].replace(/^\$/, ''))
1003
+
1004
+ // Check user globals don't conflict with runtime globals (modules loaded after user decls)
1005
+ for (const name of ctx.scope.userGlobals)
1006
+ if (!ctx.scope.globals.get(name)?.includes('mut f64'))
1007
+ err(`'${name}' conflicts with a compiler internal — choose a different name`)
1008
+
1009
+ // Pre-fold const globals: evaluate constant initializers before function compilation
1010
+ // so functions see the correct global types (i32 vs f64).
1011
+ if (ast) {
1012
+ const evalConst = n => {
1013
+ if (typeof n === 'number') return n
1014
+ if (Array.isArray(n) && n[0] == null && typeof n[1] === 'number') return n[1]
1015
+ if (!Array.isArray(n)) return null
1016
+ const [op, a, b] = n
1017
+ const va = evalConst(a), vb = b !== undefined ? evalConst(b) : null
1018
+ if (va == null) return null
1019
+ if (op === 'u-' || (op === '-' && b === undefined)) return -va
1020
+ if (vb == null) return null
1021
+ if (op === '+') return va + vb; if (op === '-') return va - vb
1022
+ if (op === '*') return va * vb; if (op === '%' && vb) return va % vb
1023
+ if (op === '/' && vb) return va / vb; if (op === '**') return va ** vb
1024
+ if (op === '&') return va & vb; if (op === '|') return va | vb
1025
+ if (op === '^') return va ^ vb; if (op === '<<') return va << vb
1026
+ if (op === '>>') return va >> vb; if (op === '>>>') return va >>> vb
1027
+ return null
1028
+ }
1029
+ const stmts = Array.isArray(ast) && ast[0] === ';' ? ast.slice(1)
1030
+ : Array.isArray(ast) && ast[0] === 'const' ? [ast] : []
1031
+ for (const s of stmts) {
1032
+ if (!Array.isArray(s) || s[0] !== 'const') continue
1033
+ for (const decl of s.slice(1)) {
1034
+ if (!Array.isArray(decl) || decl[0] !== '=' || typeof decl[1] !== 'string') continue
1035
+ const [, name, init] = decl
1036
+ if (!ctx.scope.globals.has(name) || !ctx.scope.consts?.has(name)) continue
1037
+ const v = evalConst(init)
1038
+ if (v == null || !isFinite(v)) continue
1039
+ const isInt = Number.isInteger(v) && v >= -2147483648 && v <= 2147483647
1040
+ ctx.scope.globals.set(name, isInt
1041
+ ? `(global $${name} i32 (i32.const ${v}))`
1042
+ : `(global $${name} f64 (f64.const ${v}))`)
1043
+ ctx.scope.globalTypes.set(name, isInt ? 'i32' : 'f64')
1044
+ // Cache integer values for cross-call const-arg propagation: `f(N)` where
1045
+ // `const N = 8` should observe the param as intConst=8.
1046
+ if (isInt) (ctx.scope.constInts ||= new Map()).set(name, v)
1047
+ }
1048
+ }
1049
+ }
1050
+
1051
+ const programFacts = timePhase(profiler, 'plan', () => plan(ast))
1052
+
1053
+ const funcFacts = new Map()
1054
+ for (const func of ctx.func.list) if (!func.raw) funcFacts.set(func, analyzeFuncForEmit(func, programFacts))
1055
+ const funcs = ctx.func.list.map(func => emitFunc(func, funcFacts.get(func), programFacts))
1056
+ funcs.push(...synthesizeBoundaryWrappers())
1057
+
1058
+ const closureFuncs = []
1059
+ let compiledBodyCount = 0
1060
+ const compilePendingClosures = () => {
1061
+ const bodies = ctx.closure.bodies || []
1062
+ for (let bodyIndex = compiledBodyCount; bodyIndex < bodies.length; bodyIndex++) {
1063
+ closureFuncs.push(emitClosureBody(bodies[bodyIndex]))
1064
+ }
1065
+ compiledBodyCount = bodies.length
1066
+ }
1067
+ compilePendingClosures()
1068
+
1069
+ // Build module sections — named slots, assembled at the end (no index bookkeeping)
1070
+ const sec = {
1071
+ extStdlib: [], // external stdlib (imports that must precede all other imports)
1072
+ imports: [...ctx.module.imports],
1073
+ types: [], // function types for call_indirect
1074
+ memory: [], // memory declaration
1075
+ data: [], // data segment (filled after emit)
1076
+ tags: [], // error tags + related exports
1077
+ table: [], // function table (at most one)
1078
+ globals: [], // globals (filled after __start)
1079
+ funcs: [], // closure funcs + regular funcs
1080
+ elem: [], // element section (table init)
1081
+ start: [], // __start func + start directive
1082
+ stdlib: [], // stdlib functions
1083
+ customs: [], // custom sections + exports
1084
+ }
1085
+
1086
+ // Uniform closure convention: (env f64, argc i32, a0..a{MAX-1} f64) → f64.
1087
+ // argc = actual arg count passed; missing slots padded with UNDEF_NAN at caller.
1088
+ // Rest-param bodies pack slots a[fixedParams..argc-1] into their rest array.
1089
+ // MAX_CLOSURE_ARITY is the fixed inline-slot count; calls with more args error.
1090
+ if (ctx.closure.types) {
1091
+ const params = [['param', 'f64'], ['param', 'i32']] // env + argc
1092
+ for (let i = 0; i < (ctx.closure.width ?? MAX_CLOSURE_ARITY); i++) params.push(['param', 'f64'])
1093
+ sec.types.push(['type', `$ftN`, ['func', ...params, ['result', 'f64']]])
1094
+ }
1095
+
1096
+ // Memory section deferred — emitted after resolveIncludes() when __alloc is needed
1097
+
1098
+ if (ctx.runtime.throws) {
1099
+ ctx.scope.globals.set('__jz_last_err_bits', '(global $__jz_last_err_bits (mut i64) (i64.const 0))')
1100
+ sec.tags.push(['tag', '$__jz_err', ['param', 'f64']])
1101
+ sec.tags.push(['export', '"__jz_last_err_bits"', ['global', '$__jz_last_err_bits']])
1102
+ }
1103
+
1104
+ if (ctx.closure.table?.length)
1105
+ sec.table.push(['table', ['export', '"__jz_table"'], ctx.closure.table.length, 'funcref'])
1106
+
1107
+ sec.funcs.push(...closureFuncs, ...funcs)
1108
+
1109
+ if (ctx.closure.table?.length)
1110
+ sec.elem.push(['elem', ['i32.const', 0], 'func', ...ctx.closure.table.map(n => `$${n}`)])
1111
+
1112
+ buildStartFn(ast, sec, closureFuncs, compilePendingClosures)
1113
+
1114
+ dedupClosureBodies(closureFuncs, sec)
1115
+
1116
+ finalizeClosureTable(sec)
1117
+
1118
+ pullStdlib(sec)
1119
+
1120
+ stripStaticDataPrefix(sec)
1121
+
1122
+ optimizeModule(sec)
1123
+
1124
+ // Populate globals (after __start — const folding may update declarations)
1125
+ sec.globals.push(...[...ctx.scope.globals.values()].filter(g => g).map(g => parseWat(g)))
1126
+
1127
+ // Data segments (after emit — string literals append to ctx.runtime.data / strPool during emit)
1128
+ // Active segment at address 0 — skipped for shared memory (would collide across modules)
1129
+ const escBytes = (s) => {
1130
+ let esc = ''
1131
+ for (let i = 0; i < s.length; i++) {
1132
+ const c = s.charCodeAt(i)
1133
+ if (c >= 32 && c < 127 && c !== 34 && c !== 92) esc += s[i]
1134
+ else esc += '\\' + c.toString(16).padStart(2, '0')
1135
+ }
1136
+ return esc
1137
+ }
1138
+ if (ctx.runtime.data && !ctx.memory.shared)
1139
+ sec.data.push(['data', ['i32.const', 0], '"' + escBytes(ctx.runtime.data) + '"'])
1140
+ // Passive segment for shared-memory string literals (copied via memory.init at runtime)
1141
+ if (ctx.runtime.strPool)
1142
+ sec.data.push(['data', '$__strPool', '"' + escBytes(ctx.runtime.strPool) + '"'])
1143
+
1144
+ // Custom section: embed object schemas for JS-side interop.
1145
+ // Compact binary format: varint(nSchemas); per schema: varint(nProps); per prop:
1146
+ // 0x00=null, 0x01=[null, <prop>], 0x02=<varint len><utf8 bytes>. Runtime decodes.
1147
+ if (ctx.schema.list.length) {
1148
+ const bytes = []
1149
+ const utf8 = new TextEncoder()
1150
+ const varint = (n) => { while (n >= 0x80) { bytes.push((n & 0x7F) | 0x80); n >>>= 7 } bytes.push(n) }
1151
+ const enc = (p) => {
1152
+ if (p === null) bytes.push(0)
1153
+ else if (Array.isArray(p)) { bytes.push(1); enc(p[1]) }
1154
+ else { bytes.push(2); const b = utf8.encode(p); varint(b.length); for (const x of b) bytes.push(x) }
1155
+ }
1156
+ varint(ctx.schema.list.length)
1157
+ for (const s of ctx.schema.list) { varint(s.length); for (const p of s) enc(p) }
1158
+ sec.customs.push(['@custom', '"jz:schema"', bytes])
1159
+ }
1160
+
1161
+ // Custom section: rest params for exported functions (JS-side wrapping)
1162
+ const restParamFuncs = ctx.func.list.filter(f => f.exported && f.rest)
1163
+ .map(f => ({ name: f.name, fixed: f.sig.params.length - 1 }))
1164
+ if (restParamFuncs.length)
1165
+ sec.customs.push(['@custom', '"jz:rest"', `"${JSON.stringify(restParamFuncs).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`])
1166
+
1167
+ // Named export aliases: export { name } or export { source as alias }
1168
+ for (const [name, val] of Object.entries(ctx.func.exports)) {
1169
+ if (val === true) {
1170
+ if (ctx.scope.userGlobals?.has(name)) sec.customs.push(['export', `"${name}"`, ['global', `$${name}`]])
1171
+ continue
1172
+ }
1173
+ if (typeof val !== 'string') continue
1174
+ const func = ctx.func.list.find(f => f.name === val)
1175
+ // Boundary-wrapped funcs export through the synthesized $${val}$exp wrapper
1176
+ // so the JS-visible alias preserves f64 ABI.
1177
+ if (func) sec.customs.push(['export', `"${name}"`, ['func', `$${isBoundaryWrapped(func) ? val + '$exp' : val}`]])
1178
+ else if (ctx.scope.globals.has(val)) sec.customs.push(['export', `"${name}"`, ['global', `$${val}`]])
1179
+ }
1180
+
1181
+ // Whole-module: prune funcs unreachable from entry points (start, exports, elem refs).
1182
+ // Removes orphan top-level consts that never get called (e.g. watr's unused `hoist` = 26 KB).
1183
+ // Also returns callCount Map (computed during the same walk — used below for funcidx sort).
1184
+ // Reachability walk always runs (callCount feeds the sort even when shake is off);
1185
+ // actual removal gated by ctx.transform.optimize.treeshake.
1186
+ const optCfg = ctx.transform.optimize
1187
+ const { callCount } = treeshake(
1188
+ [{ arr: sec.stdlib }, { arr: sec.funcs }, { arr: sec.start }],
1189
+ [...sec.start, ...sec.elem, ...sec.customs, ...sec.extStdlib, ...sec.imports],
1190
+ { removeDead: !optCfg || optCfg.treeshake !== false }
1191
+ )
1192
+
1193
+ // Reorder non-import funcs by call count: hot callees get low LEB128 indices.
1194
+ // `call $f` encodes funcidx as ULEB128 (1 B for idx < 128, 2 B for idx < 16384).
1195
+ // On watr self-host this saves ~6 KB (hot specialized helpers migrate to idx < 128).
1196
+ // callCount was computed inline by treeshake's walk (same set of nodes).
1197
+ const byCalls = (a, b) => (callCount.get(b[1]) || 0) - (callCount.get(a[1]) || 0)
1198
+ const startFn = sec.start.find(n => n[0] === 'func')
1199
+ const startDir = sec.start.find(n => n[0] === 'start')
1200
+ const sortedFuncs = [
1201
+ ...sec.stdlib, ...sec.funcs, ...(startFn ? [startFn] : []),
1202
+ ].sort(byCalls)
1203
+
1204
+ // Assemble: named slots → flat section list.
1205
+ const sections = [
1206
+ ...sec.extStdlib, ...sec.imports, ...sec.types, ...sec.memory, ...sec.data,
1207
+ ...sec.tags, ...sec.table, ...sec.globals, ...sortedFuncs,
1208
+ ...sec.elem, ...(startDir ? [startDir] : []), ...sec.customs,
1209
+ ]
1210
+ return ['module', ...sections]
1211
+ }