jz 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ir.js ADDED
@@ -0,0 +1,649 @@
1
+ /**
2
+ * Pure IR construction helpers for WAT-as-array output.
3
+ *
4
+ * # Stage contract
5
+ * IN: bare primitives (strings, numbers, AST nodes), ctx reads for locals/globals/schema
6
+ * OUT: tagged IR nodes (arrays with `.type` property)
7
+ * NO-EMIT: nothing here calls `emit()` — these are leaf constructors. Helpers that
8
+ * recurse into AST nodes (toBool, materializeMulti, emitDecl, buildArrayWithSpreads,
9
+ * emitTypeofCmp) live in emit.js because they invoke the dispatch table.
10
+ *
11
+ * # Layers
12
+ * - Type tagging (`typed`, coercions)
13
+ * - Nullish sentinels + NaN-boxed pointer construction
14
+ * - Literal / purity classifiers
15
+ * - Constant pools (WASM_OPS, MEM_OPS, mutator sets)
16
+ * - Temp-local factories (mutate `ctx.func.locals`)
17
+ * - Variable storage abstraction (boxed/global/local dispatch)
18
+ * - Array-layout IR (slot/elem loads, allocPtr, arrayLoop)
19
+ *
20
+ * @module ir
21
+ */
22
+
23
+ import { ctx, err, inc, PTR } from './ctx.js'
24
+ import { T, VAL, valTypeOf, lookupValType, repOf, repOfGlobal } from './analyze.js'
25
+
26
+ // === Type helpers ===
27
+
28
+ /** Tag a WASM node with its result type. */
29
+ export const typed = (node, type) => (node.type = type, node)
30
+
31
+ /** NaN-box prefix for a pointer of VAL kind K with aux bits: `0x7FF8 | type<<47 | aux<<32`. */
32
+ function ptrBoxPrefix(ptrType, aux = 0) {
33
+ return (0x7FF8n << 48n)
34
+ | ((BigInt(ptrType) & 0xFn) << 47n)
35
+ | ((BigInt(aux) & 0x7FFFn) << 32n)
36
+ }
37
+
38
+ /** Build f64 NaN-boxed pointer IR from an i32 offset node of known kind.
39
+ * `aux` is the 15-bit secondary tag (schema ID for OBJECT, element type for TYPED, etc.). */
40
+ function boxPtrIR(i32node, ptrType, aux = 0) {
41
+ const prefix = ptrBoxPrefix(ptrType, aux)
42
+ return typed(['f64.reinterpret_i64',
43
+ ['i64.or',
44
+ ['i64.const', '0x' + prefix.toString(16).toUpperCase()],
45
+ ['i64.extend_i32_u', i32node]]], 'f64')
46
+ }
47
+
48
+ /** Coerce node to f64. Pointer-kinded i32 offsets rebox via NaN-tag fusion, not numeric convert.
49
+ * The `unsigned` flag (set by `>>>` codegen) opts into `convert_i32_u` so the canonical
50
+ * `(x >>> 0)` uint32 idiom converts to a positive f64 in [0, 2^32) instead of sign-flipping. */
51
+ export const asF64 = n => {
52
+ if (n.ptrKind != null) return boxPtrIR(n, valKindToPtr(n.ptrKind), n.ptrAux || 0)
53
+ if (n.type === 'f64') return n
54
+ if (n[0] === 'i32.const' && typeof n[1] === 'number') return typed(['f64.const', n[1]], 'f64')
55
+ return typed([n.unsigned ? 'f64.convert_i32_u' : 'f64.convert_i32_s', n], 'f64')
56
+ }
57
+
58
+ /** Coerce node to i32 (saturating — fast, correct for values < 2^31). */
59
+ export const asI32 = n => {
60
+ if (n.type === 'i32') return n
61
+ // Peephole: trunc_sat_f64_s(convert_i32_*(x)) === x. The argument of f64.convert_i32_*
62
+ // is i32 by WASM validation, so peel unconditionally and re-tag.
63
+ if (Array.isArray(n) && (n[0] === 'f64.convert_i32_s' || n[0] === 'f64.convert_i32_u')) {
64
+ const inner = n[1]
65
+ return Array.isArray(inner) ? typed(inner, 'i32') : inner
66
+ }
67
+ return typed(['i32.trunc_sat_f64_s', n], 'i32')
68
+ }
69
+
70
+ /** Coerce node to i32 offset for a ptr-narrowed return / store. Same-kind unboxed
71
+ * ptr passes through; otherwise extract low 32 bits from the NaN-boxed f64
72
+ * (NOT trunc — that would convert numerically). */
73
+ export const asPtrOffset = (n, ptrKind) =>
74
+ n.ptrKind === ptrKind ? n : typed(['i32.wrap_i64', ['i64.reinterpret_f64', asF64(n)]], 'i32')
75
+
76
+ /** Coerce emitted IR to a target WASM param type ('i32' | 'f64'). */
77
+ export const asParamType = (n, t) => t === 'i32' ? asI32(n) : asF64(n)
78
+
79
+ /** Coerce node to i32 with wrapping (JS `|0` semantics: values > 2^31 wrap to negative).
80
+ * Per ECMAScript ToInt32, NaN and ±∞ map to 0. `i64.trunc_sat_f64_s` handles NaN
81
+ * and -∞ correctly, but +∞ saturates to i64_max which wraps to -1 — guard +∞ via
82
+ * branchless `select`. For non-leaf inputs `n` is stashed in a temp f64 local so it's
83
+ * evaluated exactly once (avoid side-effect re-execution and bytecode duplication). */
84
+ export const toI32 = n => {
85
+ if (n.type === 'i32') return n
86
+ // Peephole: i32.wrap_i64(i64.trunc_sat_f64_s(f64.convert_i32_*(x))) === x for all i32
87
+ // inputs (both signed and unsigned variants round-trip identically). The argument of
88
+ // f64.convert_i32_* is i32 by WASM validation, so peel unconditionally and re-tag.
89
+ if (Array.isArray(n) && (n[0] === 'f64.convert_i32_s' || n[0] === 'f64.convert_i32_u')) {
90
+ const inner = n[1]
91
+ return Array.isArray(inner) ? typed(inner, 'i32') : inner
92
+ }
93
+ // Leaf nodes are cheap to duplicate; for everything else, evaluate once via local.tee.
94
+ const isLeaf = Array.isArray(n) && n.length <= 2 &&
95
+ (n[0] === 'f64.const' || n[0] === 'local.get' || n[0] === 'global.get')
96
+ const wrap = x => typed(['i32.wrap_i64', ['i64.trunc_sat_f64_s', x]], 'i32')
97
+ if (isLeaf) {
98
+ return typed(['select',
99
+ wrap(n),
100
+ ['i32.const', 0],
101
+ ['f64.ne', n, ['f64.const', Infinity]]
102
+ ], 'i32')
103
+ }
104
+ const t = temp('inf')
105
+ return typed(['select',
106
+ wrap(['local.tee', `$${t}`, n]),
107
+ ['i32.const', 0],
108
+ ['f64.ne', ['local.get', `$${t}`], ['f64.const', Infinity]]
109
+ ], 'i32')
110
+ }
111
+
112
+ /** Extract i64 from BigInt-as-f64. */
113
+ export const asI64 = n => typed(['i64.reinterpret_f64', asF64(n)], 'i64')
114
+
115
+ /** Wrap i64 result back to BigInt-as-f64. */
116
+ export const fromI64 = n => typed(['f64.reinterpret_i64', n], 'f64')
117
+
118
+ // === Nullish sentinels ===
119
+
120
+ /** Null/undefined: one nullish value inside jz. NaN-boxed ATOM (type=0, aux=1, offset=0).
121
+ * Distinct from 0, NaN, and all pointers. Triggers default params.
122
+ * At the JS boundary, null and undefined preserve their identity for interop. */
123
+ export const NULL_NAN = '0x7FF8000100000000'
124
+ export const UNDEF_NAN = '0x7FF8000000000001'
125
+ /** WAT-template-ready sentinel expressions for use in stdlib template strings.
126
+ * `f64.const nan:0xHEX` is 3 bytes shorter than `f64.reinterpret_i64 (i64.const ...)`. */
127
+ export const NULL_WAT = `(f64.const nan:${NULL_NAN})`
128
+ export const UNDEF_WAT = `(f64.const nan:${UNDEF_NAN})`
129
+ export const NULL_IR = ['f64.const', `nan:${NULL_NAN}`]
130
+ export const UNDEF_IR = ['f64.const', `nan:${UNDEF_NAN}`]
131
+ export const nullExpr = () => typed(NULL_IR, 'f64')
132
+ export const undefExpr = () => typed(UNDEF_IR.slice(), 'f64')
133
+
134
+ // === Constants ===
135
+
136
+ /** Max arity of inline closure slots. Closures are compiled with signature
137
+ * (env f64, argc i32, a0..a{MAX-1} f64) → f64 — no per-call heap alloc.
138
+ * Calls with more args than MAX error; rest-param closures receive at most
139
+ * MAX rest args when invoked via spread with >MAX dynamic elements. */
140
+ export const MAX_CLOSURE_ARITY = 8
141
+
142
+ /** Matches WASM instructions that require a memory section. */
143
+ export const MEM_OPS = /\b(i32\.load|i32\.store|f64\.load|f64\.store|f32\.load|f32\.store|i64\.load|i64\.store|memory\.size|memory\.grow|i32\.load8|i32\.load16|i32\.store8|i32\.store16)\b/
144
+
145
+ export const WASM_OPS = new Set(['block','loop','if','then','else','br','br_if','call','call_indirect','return','return_call','throw','try_table','catch','nop','drop','unreachable','select','result','mut','param','func','module','memory','table','elem','data','type','import','export','local','global','ref'])
146
+ export const SPREAD_MUTATORS = new Set(['push', 'add', 'set', 'unshift'])
147
+ export const BOXED_MUTATORS = new Set(['push', 'pop', 'shift', 'unshift', 'splice', 'reverse', 'sort'])
148
+
149
+ // === Pointer construction ===
150
+
151
+ const NAN_PREFIX_BITS = 0x7FF8n
152
+ const litI32 = n => Array.isArray(n) && n[0] === 'i32.const' && typeof n[1] === 'number' ? n[1] : null
153
+
154
+ /** Pack (type, aux, offset) into the f64 NaN-box bit pattern as a hex string. */
155
+ function packPtrBits(type, aux, offset) {
156
+ const bits = (NAN_PREFIX_BITS << 48n)
157
+ | ((BigInt(type) & 0xFn) << 47n)
158
+ | ((BigInt(aux) & 0x7FFFn) << 32n)
159
+ | (BigInt(offset >>> 0) & 0xFFFFFFFFn)
160
+ return '0x' + bits.toString(16).toUpperCase().padStart(16, '0')
161
+ }
162
+
163
+ /** Build `__mkptr(type, aux, offset)` IR. Folds to `(f64.const nan:0x...)` — 9 bytes
164
+ * vs 12 for `f64.reinterpret_i64 (i64.const ...)` — when all args are i32 literals.
165
+ * Args may be raw IR nodes or numbers (numbers are wrapped as i32.const). */
166
+ export function mkPtrIR(type, aux, offset) {
167
+ const tIR = typeof type === 'number' ? ['i32.const', type] : type
168
+ const aIR = typeof aux === 'number' ? ['i32.const', aux] : aux
169
+ const oIR = typeof offset === 'number' ? ['i32.const', offset] : offset
170
+ const tL = litI32(tIR), aL = litI32(aIR), oL = litI32(oIR)
171
+ if (tL != null && aL != null && oL != null)
172
+ return typed(['f64.const', 'nan:' + packPtrBits(tL, aL, oL)], 'f64')
173
+ inc('__mkptr')
174
+ return typed(['call', '$__mkptr', tIR, aIR, oIR], 'f64')
175
+ }
176
+
177
+ /** Offset extraction for a NaN-boxed pointer, specialized on known value type.
178
+ * For non-ARRAY pointer kinds we skip `__ptr_offset` entirely — arrays are the
179
+ * only type that can forward on reallocation, everyone else returns the raw low 32 bits.
180
+ * If the node is already an unboxed pointer (ptrKind), return it directly. */
181
+ export function ptrOffsetIR(valIR, valType) {
182
+ if (valIR.ptrKind != null && valIR.ptrKind !== VAL.ARRAY) return valIR
183
+ if (valType != null && valType !== VAL.ARRAY) {
184
+ return ['i32.wrap_i64', ['i64.reinterpret_f64', valIR]]
185
+ }
186
+ inc('__ptr_offset')
187
+ return ['call', '$__ptr_offset', valIR]
188
+ }
189
+
190
+ /** Map VAL.* → PTR.* when unambiguous. STRING is ambiguous (heap vs SSO). ARRAY maps
191
+ * to PTR.ARRAY but callers that want to skip forwarding must check separately. */
192
+ const VAL_TO_PTR = {
193
+ array: PTR.ARRAY, object: PTR.OBJECT, set: PTR.SET, map: PTR.MAP,
194
+ closure: PTR.CLOSURE, typed: PTR.TYPED, buffer: PTR.BUFFER,
195
+ }
196
+ export const valKindToPtr = (vt) => VAL_TO_PTR[vt]
197
+
198
+ /** Type-tag extraction for a NaN-boxed pointer. Unambiguous VAL → constant; known i32
199
+ * offset of a ptrKind → constant (no reinterpret); otherwise inline bit-extraction. */
200
+ export function ptrTypeIR(valIR, valType) {
201
+ if (valIR.ptrKind != null) return typed(['i32.const', VAL_TO_PTR[valIR.ptrKind]], 'i32')
202
+ const known = valType != null ? VAL_TO_PTR[valType] : undefined
203
+ if (known != null) return ['i32.const', known]
204
+ return ['i32.wrap_i64', ['i64.and',
205
+ ['i64.shr_u', ['i64.reinterpret_f64', valIR], ['i64.const', 47]],
206
+ ['i64.const', 0xF]]]
207
+ }
208
+
209
+ const _F64_BITS_BUF = new ArrayBuffer(8)
210
+ const _F64_BITS_F = new Float64Array(_F64_BITS_BUF)
211
+ const _F64_BITS_U = new BigUint64Array(_F64_BITS_BUF)
212
+
213
+ /** Return i64 bit pattern (BigInt) of a pure-literal IR node, or null if non-literal. */
214
+ export function extractF64Bits(node) {
215
+ if (!Array.isArray(node)) return null
216
+ if (node[0] === 'f64.const') {
217
+ if (typeof node[1] === 'number') { _F64_BITS_F[0] = node[1]; return _F64_BITS_U[0] }
218
+ if (typeof node[1] === 'string' && node[1].startsWith('nan:')) {
219
+ try { return BigInt(node[1].slice(4)) | 0x7FF0000000000000n } catch { return null }
220
+ }
221
+ return null
222
+ }
223
+ if (node[0] === 'f64.reinterpret_i64' && Array.isArray(node[1]) && node[1][0] === 'i64.const' && typeof node[1][1] === 'string') {
224
+ const s = node[1][1]
225
+ if (s.startsWith('-')) {
226
+ const abs = s.slice(1)
227
+ try { return ((1n << 64n) - BigInt(abs)) & 0xFFFFFFFFFFFFFFFFn } catch { return null }
228
+ }
229
+ try { return BigInt(s) } catch { return null }
230
+ }
231
+ return null
232
+ }
233
+
234
+ /** Append `slots` (BigInt i64 each) to ctx.runtime.data 8-byte aligned, return raw byte offset of first slot.
235
+ * Slots that look like NaN-boxed pointers are recorded in `ctx.runtime.staticPtrSlots` so the
236
+ * prefix-strip pass can patch their embedded offsets. */
237
+ export function appendStaticSlots(slots, headerBytes = 0) {
238
+ if (!ctx.runtime.data) ctx.runtime.data = ''
239
+ while (ctx.runtime.data.length % 8 !== 0) ctx.runtime.data += '\0'
240
+ const off = ctx.runtime.data.length
241
+ const u8 = new Uint8Array(headerBytes + slots.length * 8)
242
+ const dv = new DataView(u8.buffer)
243
+ for (let i = 0; i < slots.length; i++) dv.setBigUint64(headerBytes + i * 8, slots[i], true)
244
+ let chunk = ''
245
+ for (let i = 0; i < u8.length; i++) chunk += String.fromCharCode(u8[i])
246
+ ctx.runtime.data += chunk
247
+ if (!ctx.runtime.staticPtrSlots) ctx.runtime.staticPtrSlots = []
248
+ for (let i = 0; i < slots.length; i++) {
249
+ const bits = slots[i]
250
+ if (((bits >> 48n) & 0xFFF8n) === NAN_PREFIX_BITS) {
251
+ ctx.runtime.staticPtrSlots.push(off + i * 8)
252
+ }
253
+ }
254
+ return off
255
+ }
256
+
257
+ // === Literal / purity checks ===
258
+
259
+ /** Check if emitted node is a compile-time constant. */
260
+ export const isLit = n => (n[0] === 'i32.const' || n[0] === 'f64.const') && typeof n[1] === 'number'
261
+ export const litVal = n => n[1]
262
+ const isNullLit = n => Array.isArray(n) && n.length === 2 && n[0] == null && n[1] == null
263
+ const isUndefLit = n => Array.isArray(n) && n.length === 0
264
+ export const isNullishLit = n => isNullLit(n) || isUndefLit(n)
265
+
266
+ /** Side-effect-free (safe for WASM select). */
267
+ const PURE_OPS = new Set(['i32.const', 'f64.const', 'local.get', 'global.get',
268
+ 'f64.add', 'f64.sub', 'f64.mul', 'f64.div', 'f64.neg', 'f64.abs', 'f64.sqrt',
269
+ 'i32.add', 'i32.sub', 'i32.mul', 'i32.and', 'i32.or', 'i32.xor',
270
+ 'f64.convert_i32_s', 'f64.convert_i32_u', 'i32.trunc_sat_f64_s',
271
+ 'i32.wrap_i64', 'i64.trunc_sat_f64_s', 'f64.eq', 'f64.ne', 'f64.lt', 'f64.gt', 'f64.le', 'f64.ge',
272
+ 'i32.eq', 'i32.ne', 'i32.lt_s', 'i32.gt_s', 'i32.le_s', 'i32.ge_s', 'i32.eqz'])
273
+ export const isPureIR = n => Array.isArray(n) && PURE_OPS.has(n[0]) && n.slice(1).every(c => !Array.isArray(c) || isPureIR(c))
274
+
275
+ /** Check if (a, op, b) is a postfix pattern: [op, name] and [, 1] literal. */
276
+ export const isPostfix = (a, op, b) => Array.isArray(a) && a[0] === op && Array.isArray(b) && b[0] == null && b[1] === 1
277
+
278
+ /** Emit a numeric constant with correct i32/f64 typing. */
279
+ export const emitNum = v => Number.isInteger(v) && v >= -2147483648 && v <= 2147483647
280
+ ? typed(['i32.const', v], 'i32') : typed(['f64.const', v], 'f64')
281
+
282
+ // === Temp locals ===
283
+
284
+ /** Allocate a temp local, returns name without $. Optional tag aids WAT readability.
285
+ * Skips names already registered (by analyzeLocals from prepare-generated names)
286
+ * to avoid collisions that would silently override the pre-analyzed type. */
287
+ export function temp(tag = '') {
288
+ let name
289
+ do { name = `${T}${tag}${ctx.func.uniq++}` } while (ctx.func.locals.has(name))
290
+ ctx.func.locals.set(name, 'f64')
291
+ return name
292
+ }
293
+ export function tempI32(tag = '') {
294
+ let name
295
+ do { name = `${T}${tag}${ctx.func.uniq++}` } while (ctx.func.locals.has(name))
296
+ ctx.func.locals.set(name, 'i32')
297
+ return name
298
+ }
299
+ export function tempI64(tag = '') {
300
+ let name
301
+ do { name = `${T}${tag}${ctx.func.uniq++}` } while (ctx.func.locals.has(name))
302
+ ctx.func.locals.set(name, 'i64')
303
+ return name
304
+ }
305
+
306
+ // === Numeric helpers ===
307
+
308
+ /** WASM has no f64.rem — implement as a - trunc(a/b) * b.
309
+ * Both `a` and `b` appear twice in the expansion; cache non-pure operands
310
+ * in locals so side effects (e.g. assignments) only execute once. */
311
+ export const f64rem = (a, b) => {
312
+ const pa = isPureIR(a), pb = isPureIR(b)
313
+ if (pa && pb) return typed(['f64.sub', a, ['f64.mul', ['f64.trunc', ['f64.div', a, b]], b]], 'f64')
314
+ const ta = pa ? null : temp(), tb = pb ? null : temp()
315
+ const ga = pa ? a : ['local.get', `$${ta}`], gb = pb ? b : ['local.get', `$${tb}`]
316
+ const pre = []
317
+ if (!pa) pre.push(['local.set', `$${ta}`, a])
318
+ if (!pb) pre.push(['local.set', `$${tb}`, b])
319
+ return typed(['block', ['result', 'f64'], ...pre,
320
+ ['f64.sub', ga, ['f64.mul', ['f64.trunc', ['f64.div', ga, gb]], gb]]], 'f64')
321
+ }
322
+
323
+ /** Coerce an emitted IR value to a plain f64 Number per JS `ToNumber`.
324
+ * Skips coercion when static type proves the value is already numeric
325
+ * (i32 node, compile-time literal, known VAL.NUMBER/VAL.BIGINT) or when
326
+ * __to_num is not available (no string module loaded → no strings possible). */
327
+ export function toNumF64(node, v) {
328
+ if (v.type === 'i32' || isLit(v)) return asF64(v)
329
+ const vt = keyValType(node)
330
+ if (vt === VAL.NUMBER || vt === VAL.BIGINT) return asF64(v)
331
+ // intCertain locals: every reachable def is integer-valued, so the binding
332
+ // never carries a NaN-boxed pointer — skip the __to_num wrapper.
333
+ if (typeof node === 'string' && repOf(node)?.intCertain === true) return asF64(v)
334
+ // IR-level shapes that produce real f64 numbers (never NaN-boxed pointers):
335
+ // i32→f64 conversions, stdlib clock helper. Skip the __to_num call wrapper.
336
+ if (Array.isArray(v)) {
337
+ if (v[0] === 'f64.convert_i32_s' || v[0] === 'f64.convert_i32_u') return v
338
+ if (v[0] === 'call' && v[1] === '$__time_ms') return v
339
+ }
340
+ if (!ctx.core.stdlib['__to_num']) return asF64(v)
341
+ inc('__to_num')
342
+ return typed(['call', '$__to_num', asF64(v)], 'f64')
343
+ }
344
+
345
+ /** Convert already-emitted WASM node to i32 boolean. NaN is falsy (like JS).
346
+ * Peepholes: i32 → as-is; `f64.convert_i32_*(x)` → x (i32 conversion never NaN);
347
+ * nested `__is_truthy(x)` → x (already 0/1); literal f64 const folds to 0/1. */
348
+ export function truthyIR(e) {
349
+ if (e.type === 'i32') return e
350
+ // Unboxed pointer offsets: truthy iff non-zero offset.
351
+ if (e.ptrKind != null) return typed(['i32.ne', e, ['i32.const', 0]], 'i32')
352
+ if (Array.isArray(e)) {
353
+ if (e[0] === 'f64.convert_i32_s' || e[0] === 'f64.convert_i32_u')
354
+ return typed(['i32.ne', e[1], ['i32.const', 0]], 'i32')
355
+ if (e[0] === 'call' && e[1] === '$__is_truthy') return typed(e, 'i32')
356
+ // Fold literal f64 constants: zero/NaN → 0, any other number → 1.
357
+ if (e[0] === 'f64.const' && typeof e[1] === 'number') {
358
+ return typed(['i32.const', (e[1] !== 0 && !Number.isNaN(e[1])) ? 1 : 0], 'i32')
359
+ }
360
+ // Fold NaN-boxed pointer literals: UNDEF/NULL/canonical-NaN sentinels are falsy;
361
+ // all other NaN-boxed pointers (SSO strings, heap ptrs, etc.) are truthy.
362
+ if (e[0] === 'f64.reinterpret_i64' && Array.isArray(e[1]) && e[1][0] === 'i64.const') {
363
+ const bits = String(e[1][1])
364
+ const FALSY = new Set([UNDEF_NAN, NULL_NAN, '0x7FF8000000000000', '0x7FFA800000000000'])
365
+ return typed(['i32.const', FALSY.has(bits) ? 0 : 1], 'i32')
366
+ }
367
+ // Fresh pointer constructors never produce nullish. Treat as always truthy.
368
+ if (e[0] === 'call' && typeof e[1] === 'string' &&
369
+ (e[1].startsWith('$__mkptr') || e[1] === '$__alloc' || e[1].startsWith('$__alloc_hdr'))) {
370
+ return typed(['i32.const', 1], 'i32')
371
+ }
372
+ // Pointer-typed local reads: value is never a plain number — truthy iff not nullish.
373
+ // (local.get $x) where $x's valType is a non-STRING pointer kind.
374
+ if (e[0] === 'local.get' && typeof e[1] === 'string') {
375
+ const name = e[1][0] === '$' ? e[1].slice(1) : e[1]
376
+ const vt = lookupValType(name)
377
+ if (vt === VAL.ARRAY || vt === VAL.OBJECT || vt === VAL.SET || vt === VAL.MAP ||
378
+ vt === VAL.CLOSURE || vt === VAL.TYPED || vt === VAL.BUFFER || vt === VAL.REGEX) {
379
+ return typed(['i32.eqz', isNullish(e)], 'i32')
380
+ }
381
+ }
382
+ }
383
+ inc('__is_truthy')
384
+ return typed(['call', '$__is_truthy', asF64(e)], 'i32')
385
+ }
386
+ export const toBoolFromEmitted = truthyIR
387
+
388
+ // === Value-type classification ===
389
+
390
+ export function keyValType(node) {
391
+ return typeof node === 'string' ? lookupValType(node) : valTypeOf(node)
392
+ }
393
+
394
+ export function usesDynProps(vt) {
395
+ return vt === VAL.ARRAY || vt === VAL.STRING || vt === VAL.CLOSURE
396
+ || vt === VAL.TYPED || vt === VAL.SET || vt === VAL.MAP || vt === VAL.REGEX
397
+ }
398
+
399
+ /** Does this object literal / property write need a `__dyn_props` shadow update?
400
+ * `target` is the var name receiving the literal (or null when escaping). */
401
+ export function needsDynShadow(target) {
402
+ if (!ctx.module.modules.collection) return false
403
+ const dyn = ctx.types?.dynKeyVars
404
+ if (target == null) return ctx.types?.anyDynKey ?? true
405
+ return dyn ? dyn.has(target) : true
406
+ }
407
+
408
+ // === Variable storage abstraction ===
409
+ // Centralizes the boxed/global/local 3-way dispatch (used by =, ++/--, +=, etc.)
410
+
411
+ /** Check if name is a module-scope global (not shadowed by local/param). */
412
+ export function isGlobal(name) {
413
+ return ctx.scope.globals.has(name) && !ctx.func.locals?.has(name) && !ctx.func.current?.params?.some(p => p.name === name)
414
+ }
415
+
416
+ /** Check if assigning to name would violate const. Only applies when not shadowed. */
417
+ export function isConst(name) {
418
+ return ctx.scope.consts?.has(name) && !ctx.func.locals?.has(name) && !ctx.func.current?.params?.some(p => p.name === name)
419
+ }
420
+
421
+ /** Get i32 memory address for a boxed variable's cell. Cell locals are always i32. */
422
+ export function boxedAddr(name) {
423
+ return ['local.get', `$${ctx.func.boxed.get(name)}`]
424
+ }
425
+
426
+ /** Read variable value: boxed → f64.load, global → global.get, local → local.get.
427
+ * Unboxed pointer locals (repOf(name).ptrKind) tag the returned node with `.ptrKind`
428
+ * so downstream coercions know it's an i32 offset, not a numeric. */
429
+ export function readVar(name) {
430
+ if (ctx.func.boxed?.has(name))
431
+ return typed(['f64.load', boxedAddr(name)], 'f64')
432
+ if (isGlobal(name)) {
433
+ const node = typed(['global.get', `$${name}`], ctx.scope.globalTypes.get(name) || 'f64')
434
+ const grep = repOfGlobal(name)
435
+ if (grep?.ptrKind != null) {
436
+ node.ptrKind = grep.ptrKind
437
+ if (grep.ptrAux != null) node.ptrAux = grep.ptrAux
438
+ }
439
+ return node
440
+ }
441
+ const t = ctx.func.locals?.get(name) || ctx.func.current?.params?.find(p => p.name === name)?.type || 'f64'
442
+ const rep = repOf(name)
443
+ // Const-arg propagation: param proven to be the same integer literal at every static
444
+ // call site (cross-call fixpoint sets rep.intConst). Substitute the read with the
445
+ // literal — lets watr fold guards and treeshake unused params without touching the
446
+ // param ABI (which the V8 inliner is sensitive to: narrowing nStages from f64→i32
447
+ // tanked biquad ~60%). Type follows the local's declared type to preserve any
448
+ // coercions the surrounding code expects.
449
+ if (rep?.intConst != null) {
450
+ return t === 'i32' ? typed(['i32.const', rep.intConst], 'i32')
451
+ : typed(['f64.const', rep.intConst], 'f64')
452
+ }
453
+ const node = typed(['local.get', `$${name}`], t)
454
+ if (rep?.ptrKind != null) {
455
+ node.ptrKind = rep.ptrKind
456
+ const aux = rep.ptrAux ?? ctx.schema.idOf?.(name)
457
+ if (aux != null) node.ptrAux = aux
458
+ }
459
+ return node
460
+ }
461
+
462
+ /** Write variable value. void_ → local.set (no result); otherwise → local.tee.
463
+ * valIR is raw emit result — coerced to f64 for boxed/global, to local type for locals. */
464
+ export function writeVar(name, valIR, void_) {
465
+ if (ctx.func.boxed?.has(name)) {
466
+ const addr = boxedAddr(name)
467
+ const v = asF64(valIR)
468
+ if (void_) return typed(['block', ['f64.store', addr, v]], 'void')
469
+ const t = temp()
470
+ return typed(['block', ['result', 'f64'],
471
+ ['local.set', `$${t}`, v],
472
+ ['f64.store', addr, ['local.get', `$${t}`]],
473
+ ['local.get', `$${t}`]], 'f64')
474
+ }
475
+ if (isGlobal(name)) {
476
+ const v = asF64(valIR)
477
+ if (void_) return typed(['block', ['global.set', `$${name}`, v]], 'void')
478
+ const t = temp()
479
+ return typed(['block', ['result', 'f64'],
480
+ ['local.set', `$${t}`, v],
481
+ ['global.set', `$${name}`, ['local.get', `$${t}`]],
482
+ ['local.get', `$${t}`]], 'f64')
483
+ }
484
+ const t = ctx.func.locals.get(name) || 'f64'
485
+ const ptrKind = repOf(name)?.ptrKind
486
+ let coerced
487
+ if (ptrKind != null) {
488
+ // Local stores unboxed i32 offset. If RHS is already a same-kind offset, pass through;
489
+ // otherwise extract low 32 bits from the NaN-boxed f64.
490
+ coerced = valIR.ptrKind === ptrKind
491
+ ? valIR
492
+ : typed(['i32.wrap_i64', ['i64.reinterpret_f64', asF64(valIR)]], 'i32')
493
+ } else {
494
+ coerced = t === 'f64' ? asF64(valIR) : asI32(valIR)
495
+ }
496
+ if (void_) return typed(['local.set', `$${name}`, coerced], 'void')
497
+ const teeNode = typed(['local.tee', `$${name}`, coerced], t)
498
+ if (ptrKind != null) teeNode.ptrKind = ptrKind
499
+ return teeNode
500
+ }
501
+
502
+ /** Check if f64 expr is nullish (NULL_NAN or UNDEF_NAN). Returns i32.
503
+ * Peepholes: fold known NaN-boxed sentinel literals; elide on numeric literals;
504
+ * unboxed pointer locals are proven non-null by analyzePtrUnboxable.
505
+ * Inlines directly: (i32.or (i64.eq bits NULL_NAN) (i64.eq bits UNDEF_NAN))
506
+ * rather than calling $__is_nullish — saves WASM call dispatch in V8 JIT. */
507
+ export const isNullish = (f64expr) => {
508
+ if (f64expr.ptrKind != null) return typed(['i32.const', 0], 'i32')
509
+ if (Array.isArray(f64expr)) {
510
+ if (f64expr[0] === 'f64.const') {
511
+ // Check for NaN-boxed sentinel: (f64.const nan:0x...) — matches NULL_IR/UNDEF_IR form.
512
+ const lit = String(f64expr[1])
513
+ if (lit.startsWith('nan:')) {
514
+ const bits = lit.slice(4)
515
+ return typed(['i32.const', (bits === NULL_NAN || bits === UNDEF_NAN) ? 1 : 0], 'i32')
516
+ }
517
+ return typed(['i32.const', 0], 'i32') // numeric literal — never nullish
518
+ }
519
+ if (f64expr[0] === 'f64.reinterpret_i64' && Array.isArray(f64expr[1]) && f64expr[1][0] === 'i64.const') {
520
+ const bits = String(f64expr[1][1])
521
+ return typed(['i32.const', (bits === NULL_NAN || bits === UNDEF_NAN) ? 1 : 0], 'i32')
522
+ }
523
+ }
524
+ // Inline the 3-op nullish test. Tee into a temp i64 so the value is computed once.
525
+ // For simple (local.get $x) we can just reinterpret twice — V8 CSEs it.
526
+ if (Array.isArray(f64expr) && f64expr[0] === 'local.get') {
527
+ const bits = ['i64.reinterpret_f64', f64expr]
528
+ return typed(['i32.or',
529
+ ['i64.eq', bits, ['i64.const', NULL_NAN]],
530
+ ['i64.eq', ['i64.reinterpret_f64', f64expr], ['i64.const', UNDEF_NAN]]], 'i32')
531
+ }
532
+ // Non-trivial expr: fall back to the helper — keeps binary size stable & preserves eval once.
533
+ inc('__is_nullish')
534
+ return typed(['call', '$__is_nullish', f64expr], 'i32')
535
+ }
536
+
537
+ // === Array layout helpers ===
538
+
539
+ /** Slot address: `base + idx*8` IR. Uses `local.get` directly when idx=0. */
540
+ export function slotAddr(baseLocal, idx) {
541
+ const base = ['local.get', `$${baseLocal}`]
542
+ return idx === 0 ? base : ['i32.add', base, ['i32.const', idx * 8]]
543
+ }
544
+
545
+ /** Load f64 element from array data at ptr + i*8. ptr/i are local name strings. */
546
+ export function elemLoad(ptr, i) {
547
+ return ['f64.load', ['i32.add', ['local.get', `$${ptr}`], ['i32.shl', ['local.get', `$${i}`], ['i32.const', 3]]]]
548
+ }
549
+
550
+ /** Store f64 val at array data ptr + i*8. ptr/i are local name strings. */
551
+ export function elemStore(ptr, i, val) {
552
+ return ['f64.store', ['i32.add', ['local.get', `$${ptr}`], ['i32.shl', ['local.get', `$${i}`], ['i32.const', 3]]], val]
553
+ }
554
+
555
+ /** Emit a loop iterating over array elements. Returns IR instruction list.
556
+ * bodyFn(ptr, len, i, item) should return an array of IR instructions.
557
+ * ARRAY-only — elemLoad assumes f64-stride data layout. After __ptr_offset
558
+ * resolves forwarding, len lives at ptr-8, so skip the second __len call
559
+ * (which would re-walk forwarding + dispatch on type).
560
+ *
561
+ * Optional `lenLocal`: caller already has the array length in an i32 local
562
+ * (e.g. from sizing the output before the loop). Reuses it instead of
563
+ * re-loading from ptr-8. */
564
+ export function arrayLoop(arrExpr, bodyFn, lenLocal) {
565
+ inc('__ptr_offset')
566
+ const arr = temp('aa'), ptr = tempI32('ap'), i = tempI32('ai'), item = temp('av')
567
+ const len = lenLocal ?? tempI32('al')
568
+ const id = ctx.func.uniq++
569
+ const setup = [
570
+ ['local.set', `$${arr}`, asF64(arrExpr)],
571
+ ['local.set', `$${ptr}`, ['call', '$__ptr_offset', ['local.get', `$${arr}`]]],
572
+ ]
573
+ if (!lenLocal) setup.push(
574
+ ['local.set', `$${len}`, ['i32.load', ['i32.sub', ['local.get', `$${ptr}`], ['i32.const', 8]]]])
575
+ setup.push(
576
+ ['local.set', `$${i}`, ['i32.const', 0]],
577
+ ['block', `$brk${id}`, ['loop', `$loop${id}`,
578
+ ['br_if', `$brk${id}`, ['i32.ge_s', ['local.get', `$${i}`], ['local.get', `$${len}`]]],
579
+ ['local.set', `$${item}`, elemLoad(ptr, i)],
580
+ ...bodyFn(ptr, len, i, typed(['local.get', `$${item}`], 'f64')),
581
+ ['local.set', `$${i}`, ['i32.add', ['local.get', `$${i}`], ['i32.const', 1]]],
582
+ ['br', `$loop${id}`]]])
583
+ return setup
584
+ }
585
+
586
+ /** Build a NaN-boxed pointer from a header allocation.
587
+ * type/aux/stride may be JS numbers; len/cap may be JS numbers or IR.
588
+ * Returns { local, init, ptr } where:
589
+ * local — i32 name pointing to data start (post-header)
590
+ * init — IR statement that allocates and sets `local`
591
+ * ptr — f64 IR expression: __mkptr(type, aux, local).
592
+ * Caller emits init, fills via local, then uses ptr (or local for further work). */
593
+ export function allocPtr({ type, aux = 0, len, cap, stride = 8, tag = 'ap' }) {
594
+ inc('__alloc_hdr')
595
+ const local = tempI32(tag)
596
+ const irOf = v => typeof v === 'number' ? ['i32.const', v] : v
597
+ const init = ['local.set', `$${local}`,
598
+ ['call', '$__alloc_hdr', irOf(len), irOf(cap == null ? len : cap), ['i32.const', stride]]]
599
+ const ptr = mkPtrIR(type, aux, ['local.get', `$${local}`])
600
+ return { local, init, ptr }
601
+ }
602
+
603
+ // === Multi-value + control-flow reads ===
604
+
605
+ /** Check if a call expression targets a multi-value function. Returns result count or 0. */
606
+ export function multiCount(callNode) {
607
+ if (!Array.isArray(callNode) || callNode[0] !== '()') return 0
608
+ const name = callNode[1]
609
+ if (typeof name !== 'string') return 0
610
+ const func = ctx.func.map?.get(name)
611
+ return func?.sig.results.length > 1 ? func.sig.results.length : 0
612
+ }
613
+
614
+ /** Get current loop labels or throw. */
615
+ export function loopTop() {
616
+ const top = ctx.func.stack.at(-1)
617
+ if (!top) err('break/continue outside loop')
618
+ return top
619
+ }
620
+
621
+ // === Data shaping ===
622
+
623
+ /** Normalize emit result to instruction list. */
624
+ export const flat = ir => {
625
+ if (ir == null) return []
626
+ if (!Array.isArray(ir)) return [ir] // bare 'drop', 'nop', etc.
627
+ if (typeof ir[0] === 'string' || ir[0] == null) return [ir] // single instruction: ['op', ...args] or [null, val]
628
+ return ir // multi-instruction: [instr1, instr2, ...]
629
+ }
630
+
631
+ /**
632
+ * Reconstruct arguments with spreads inserted at correct positions.
633
+ * Example: normal=[a, c], spreads=[{pos:1, expr:arr}] → [a, __spread(arr), c]
634
+ */
635
+ export function reconstructArgsWithSpreads(normal, spreads) {
636
+ const combined = []
637
+ let normalIdx = 0
638
+ for (let targetPos = 0; targetPos <= normal.length; targetPos++) {
639
+ for (const spread of spreads) {
640
+ if (spread.pos === targetPos) {
641
+ combined.push(['__spread', spread.expr])
642
+ }
643
+ }
644
+ if (normalIdx < normal.length) {
645
+ combined.push(normal[normalIdx++])
646
+ }
647
+ }
648
+ return combined
649
+ }