jz 0.4.0 → 0.5.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.
@@ -0,0 +1,529 @@
1
+ /**
2
+ * src/abi/string — string carriers.
3
+ *
4
+ * One file holds every strategy the compiler may pick for a string-typed
5
+ * binding. Carriers are named exports; the narrower tags each site with the
6
+ * chosen carrier, and codegen reads `ctx.abi.string[<carrier>]` (today only
7
+ * the default carrier `sso` is reached — per-site picking arrives with the
8
+ * JS String Builtins specialization workstream).
9
+ *
10
+ * Carriers:
11
+ * - `sso` default. NaN-boxed STRING pointer (PTR.STRING=4) with
12
+ * Small-String-Optimization for ≤4 ASCII chars packed inline
13
+ * in the aux+offset fields.
14
+ * - `jsstring` architectural scaffold. Native JS strings via JS String
15
+ * Builtins (`wasm:js-string` imports); externref slot. Empty
16
+ * ops table — the 9-item compiler-wide checklist below blocks
17
+ * real codegen.
18
+ *
19
+ * No `name`/`type` discriminant field — carriers are referenced by object
20
+ * identity from the default-bundle in `src/abi/index.js`.
21
+ *
22
+ * @module src/abi/string
23
+ */
24
+
25
+ // ─────────────────────────────────────────────────────────────────────────
26
+ // Op contract (shared by every carrier)
27
+ //
28
+ // ops.<op>(...slotCarriers, ctx) → wasm IR
29
+ //
30
+ // - Every string-valued argument is a **slot-carrier IR**: IR whose runtime
31
+ // WASM value matches `slotTypes[0]`. Under `sso` that's `f64` (the NaN-
32
+ // boxed pointer); under `jsstring` that's `externref`. The caller is
33
+ // responsible for producing slot-carrier IR — typically
34
+ // `asF64(emit(strNode))` today; once non-f64 string slots ship, call
35
+ // sites switch to a carrier-driven `coerceSlot` helper.
36
+ // - The op is self-contained: it inlines whatever WASM
37
+ // reinterprets/wraps it needs to reach its stdlib helper signature.
38
+ // `sso` emits `['i64.reinterpret_f64', sF64]` inline rather than
39
+ // importing `asI64` from `src/ir.js` — this module is loaded transitively
40
+ // from `src/ctx.js`, so importing back into `src/ir.js` would read
41
+ // `LAYOUT.NAN_PREFIX_BITS` before `src/ctx.js`'s `LAYOUT` const is bound.
42
+ // - `ctx` is the ambient compilation context, passed last. Each op
43
+ // registers its stdlib dependency via `ctx.core.includes.add(name)`.
44
+ //
45
+ // Layout the `sso` ops are calibrated against (defined in `src/ctx.js`):
46
+ // - LAYOUT.NAN_PREFIX_BITS, LAYOUT.TAG_SHIFT, LAYOUT.AUX_SHIFT,
47
+ // LAYOUT.OFFSET_MASK, LAYOUT.SSO_BIT
48
+ // - PTR.STRING = 4
49
+ // ─────────────────────────────────────────────────────────────────────────
50
+
51
+ // ── sso ───────────────────────────────────────────────────────────────────
52
+
53
+ // Local inline coercer — IR-only, no src/* import (cycle safety). Caller is
54
+ // expected to pass IR whose WASM-level value is already f64 (e.g. via
55
+ // `asF64(emit(strNode))`); this wraps the unbox to i64 the stdlib helpers
56
+ // take. Kept as a one-liner so each op reads as a single `call`.
57
+ const ssoI64 = (sF64) => ['i64.reinterpret_f64', sF64]
58
+
59
+ // LAYOUT lives in `src/ctx.js`, which loads this module mid-bootstrap (line 10
60
+ // of ctx.js, before LAYOUT itself is bound). ESM live bindings mean the import
61
+ // resolves but the value is `undefined` until ctx.js finishes. All charCodeAt /
62
+ // inline paths read these constants at *call* time (compile-time, after ctx is
63
+ // fully initialized) — never at module top level — so this is safe.
64
+ import { LAYOUT } from '../ctx.js'
65
+
66
+ // Local copy of analyze.js#isReassigned — pulling the real one creates a load-
67
+ // order cycle (abi/string ← ctx ← analyze ← ir ← ctx). Kept minimal: detects
68
+ // any `=`/compound-assign/`++`/`--` of `name` anywhere in `body`. `let`/`const`
69
+ // declarations are NOT reassignments (the param can't be redeclared anyway,
70
+ // but a shadowing inner `let s = ...` shouldn't poison the outer param's
71
+ // fast path). False positives are conservative — we just disable the fast
72
+ // path and fall through to shape 2.
73
+ const _CC_ASSIGN_OPS = new Set(['=', '+=', '-=', '*=', '/=', '%=', '&=', '|=', '^=', '>>=', '<<=', '>>>=', '||=', '&&=', '??='])
74
+ const _paramReassigned = (body, name) => {
75
+ if (!Array.isArray(body)) return false
76
+ const op = body[0]
77
+ if (_CC_ASSIGN_OPS.has(op) && body[1] === name) return true
78
+ if ((op === '++' || op === '--') && body[1] === name) return true
79
+ if (op === 'let' || op === 'const') {
80
+ for (let i = 1; i < body.length; i++) {
81
+ const d = body[i]
82
+ if (Array.isArray(d) && d[0] === '=' && d[2] != null && _paramReassigned(d[2], name)) return true
83
+ }
84
+ return false
85
+ }
86
+ for (let i = 1; i < body.length; i++) if (_paramReassigned(body[i], name)) return true
87
+ return false
88
+ }
89
+
90
+ /** Pre-shifted SSO discriminator (bit 46 of the i64 ptr). Computed lazily on
91
+ * first read since LAYOUT is undefined when this module's top level runs.
92
+ * Memoized in the closure so all later call sites pay zero overhead. */
93
+ let _ssoBitI64 = null
94
+ const ssoBitI64 = () => _ssoBitI64 ??= '0x' + (BigInt(LAYOUT.SSO_BIT) << BigInt(LAYOUT.AUX_SHIFT)).toString(16).toUpperCase().padStart(16, '0')
95
+
96
+ /** Allocate a fresh i64 local in the current function. Replicated here (not
97
+ * imported from `src/ir.js`) to keep this module loadable during ctx.js
98
+ * bootstrap — see header note about the cycle. */
99
+ const allocLocalI64 = (ctx, tag) => {
100
+ let name
101
+ do { name = `_${tag}${ctx.func.uniq++}` } while (ctx.func.locals.has(name))
102
+ ctx.func.locals.set(name, 'i64')
103
+ return name
104
+ }
105
+ const allocLocalI32 = (ctx, tag) => {
106
+ let name
107
+ do { name = `_${tag}${ctx.func.uniq++}` } while (ctx.func.locals.has(name))
108
+ ctx.func.locals.set(name, 'i32')
109
+ return name
110
+ }
111
+
112
+ const _isLeaf = (n) => Array.isArray(n) &&
113
+ (n[0] === 'local.get' || n[0] === 'global.get' ||
114
+ n[0] === 'i32.const' || n[0] === 'i64.const' ||
115
+ n[0] === 'f64.const' || n[0] === 'f32.const')
116
+
117
+ /** Per-use cheap form of `charCodeAt` against a pre-decomposed param receiver
118
+ * (shape 1): a bounds check plus a 2-arm SSO/heap byte select reading the four
119
+ * i32 decode locals in `dec`. The index is referenced three times, so a
120
+ * side-effecting `iI32` is spilled to a scratch local first — leaves are safe
121
+ * to duplicate. `oobNan` picks the OOB contract / result type exactly as in
122
+ * the generic path. */
123
+ function emitDecompCharRead(dec, iI32, ctx, oobNan) {
124
+ const rt = oobNan ? 'f64' : 'i32'
125
+ let idx = iI32, spill = null
126
+ if (!_isLeaf(iI32)) { spill = allocLocalI32(ctx, 'ci'); idx = ['local.get', `$${spill}`] }
127
+ const ssoByteExpr = ['i32.and',
128
+ ['i32.shr_u', ['local.get', `$${dec.base}`], ['i32.shl', idx, ['i32.const', 3]]],
129
+ ['i32.const', 0xFF]]
130
+ const heapByteExpr = ['i32.load8_u', ['i32.add', ['local.get', `$${dec.loadbase}`], idx]]
131
+ const ccByte = ['if', ['result', 'i32'],
132
+ ['local.get', `$${dec.sso}`],
133
+ ['then', ssoByteExpr],
134
+ ['else', heapByteExpr]]
135
+ const use = ['if', ['result', rt],
136
+ ['i32.ge_u', idx, ['local.get', `$${dec.len}`]],
137
+ ['then', oobNan ? ['f64.const', 'nan:0x7FF8000000000000'] : ['i32.const', 0]],
138
+ ['else', oobNan ? ['f64.convert_i32_u', ccByte] : ccByte]]
139
+ return spill
140
+ ? ['block', ['result', rt], ['local.set', `$${spill}`, iI32], use]
141
+ : use
142
+ }
143
+
144
+ /** Emit the function-entry decomposition prologue for the param-fast-path
145
+ * shape of `charCodeAt`. Decodes the f64 NaN-box once per call into three i32
146
+ * scratch locals (`base`, `len`, `sso`) recorded in `dec`. Per-iter
147
+ * `charCodeAt` then collapses to a length compare + 2-arm select; the load
148
+ * from `(base + i)` stays memory-safe when SSO because the inner select
149
+ * reroutes the address to 0 (always-valid memory[0]).
150
+ *
151
+ * Returns an array of IR statements suitable for splicing between the boxed-
152
+ * param inits and the user body in `emitFunc`. The off<4 guard preserves the
153
+ * pre-decomp semantics: any pointer with offset bits below 4 (null/undefined
154
+ * reaching here via type error) gets `len=0` so every bounds check trips and
155
+ * every `charCodeAt` returns 0 — same shape as the legacy `__char_at`. */
156
+ export function emitCharDecompPrologue(dec) {
157
+ const param = dec.param
158
+ const ptr = ['i64.reinterpret_f64', ['local.get', `$${param}`]]
159
+ const ssoTest = ['i64.ne',
160
+ ['i64.and', ptr, ['i64.const', ssoBitI64()]],
161
+ ['i64.const', 0]]
162
+ const offMask = `0x${LAYOUT.OFFSET_MASK.toString(16).toUpperCase()}`
163
+ const off = ['i32.wrap_i64', ['i64.and', ptr, ['i64.const', offMask]]]
164
+ const ssoLen = ['i32.and',
165
+ ['i32.wrap_i64', ['i64.shr_u', ptr, ['i64.const', LAYOUT.AUX_SHIFT]]],
166
+ ['i32.const', LAYOUT.SSO_BIT - 1]]
167
+ // Heap length is `i32.load(off - 4)`; guard against off<4 (corrupt/non-string
168
+ // payload) so the load doesn't trap on a wrapped-negative address.
169
+ const heapLen = ['if', ['result', 'i32'],
170
+ ['i32.lt_u', off, ['i32.const', 4]],
171
+ ['then', ['i32.const', 0]],
172
+ ['else', ['i32.load', ['i32.sub', off, ['i32.const', 4]]]]]
173
+ return [
174
+ ['if',
175
+ ssoTest,
176
+ ['then',
177
+ ['local.set', `$${dec.sso}`, ['i32.const', 1]],
178
+ ['local.set', `$${dec.base}`, off],
179
+ ['local.set', `$${dec.len}`, ssoLen],
180
+ // SSO: route every per-iter load to address 0 (always valid, byte
181
+ // discarded by the outer select).
182
+ ['local.set', `$${dec.loadbase}`, ['i32.const', 0]]],
183
+ ['else',
184
+ ['local.set', `$${dec.sso}`, ['i32.const', 0]],
185
+ ['local.set', `$${dec.base}`, off],
186
+ ['local.set', `$${dec.len}`, heapLen],
187
+ // Heap: per-iter loads use the real string-data base.
188
+ ['local.set', `$${dec.loadbase}`, off]]],
189
+ ]
190
+ }
191
+
192
+ export const sso = {
193
+ // Wasm slot type a string value occupies under this carrier: the f64
194
+ // NaN-boxed slot (PTR.STRING tag in the high bits, SSO inline data or
195
+ // 32-bit heap offset in the low). Read by `src/compile.js` signature
196
+ // synthesis to type string params/returns at the JS↔wasm boundary.
197
+ slotTypes: ['f64'],
198
+
199
+ ops: {
200
+ /** Byte length. Receiver: f64 slot carrier. Returns i32 — caller widens
201
+ * to f64 if it needs JS-spec `.length` semantics. */
202
+ byteLen: (sF64, ctx) => {
203
+ ctx.core.includes.add('__str_byteLen')
204
+ return ['call', '$__str_byteLen', ssoI64(sF64)]
205
+ },
206
+
207
+ /** Char code at index i. Receiver: f64 slot carrier; index: i32. The
208
+ * `oobNan` flag picks the out-of-bounds contract (see the param comment
209
+ * below): `false` ⇒ i32 result, `0` for OOB (raw-byte primitive);
210
+ * `true` ⇒ f64 result, NaN for OOB (JS-spec `String.prototype.charCodeAt`).
211
+ *
212
+ * Two emission shapes, picked at the call site:
213
+ *
214
+ * 1. **Param-decomposition fast path** — when the receiver is a `local.get`
215
+ * of a function parameter that isn't boxed (no closure mutation) and
216
+ * isn't a generator/async frame slot, we hoist the SSO-bit test, offset
217
+ * extraction, and heap-length load to a function-entry prologue. The
218
+ * prologue writes three i32 locals — `$<p>$ccbase`, `$<p>$cclen`,
219
+ * `$<p>$ccsso` — once per call. Every `charCodeAt` in the body collapses
220
+ * to a length compare + a 2-arm select between the two byte
221
+ * formulations: `(off >> (i*8)) & 0xFF` for the SSO 4-byte packed form
222
+ * and `i32.load8_u (base + i)` for the heap form. Memory safety of the
223
+ * load when the string is actually SSO is preserved by feeding the load
224
+ * address through `select(0, base+i, sso)` — for SSO strings the load
225
+ * reads byte 0 of linear memory (always valid in wasm), and the outer
226
+ * select discards the garbage. Tokenizer-shape loops go from ~13
227
+ * instructions per char (5 of them loop-invariant but un-LICM'd by V8
228
+ * because of the surrounding `if/else`) to 4: `local.get`, `i32.ge_u`,
229
+ * `i32.add`, `i32.load8_u`. Closes the AS gap on the tokenizer pin.
230
+ *
231
+ * 2. **Generic inline fallback** — when the receiver is some other
232
+ * expression (member access, call result, ternary on f64 strings, etc.)
233
+ * we emit the SSO-vs-heap dispatch in line. Watr's L2 default keeps the
234
+ * WAT-level inliner off (regex-split miscompile, watr 4.6.4), so we
235
+ * can't rely on watr to inline `__char_at` for us. Side-effect-free
236
+ * leaves (`local.get` of non-param locals, `*.const`, `global.get`)
237
+ * duplicate the f64→i64 reinterpret at every use and trust V8 CSE;
238
+ * anything heavier spills once to an i64 temp so each branch reuses the
239
+ * same value.
240
+ *
241
+ * Function-entry prologue emission for shape 1 happens in
242
+ * `src/compile.js#emitFunc` — it drains `ctx.func.charDecomp` after the
243
+ * body emit completes and splices an init block between the boxed-param
244
+ * inits and the user statements. */
245
+ charCodeAt: (sF64, iI32, ctx, oobNan = false) => {
246
+ // `oobNan` selects the out-of-bounds semantics and result type:
247
+ // - false (default): OOB → `i32.const 0`, result i32 — the raw-byte
248
+ // primitive used by the `buf += s[i]` append-byte fast path.
249
+ // - true: OOB → numeric NaN, result f64 — the JS-spec `charCodeAt`
250
+ // contract (`pos < 0 || pos >= length` ⇒ NaN). The parser hot loop
251
+ // `while ((cc = s.charCodeAt(i++)) <= 32)` relies on `NaN <= 32`
252
+ // being false to terminate; an i32 `0` would loop forever.
253
+ // A fresh OOB node per use — IR nodes must not be structurally shared
254
+ // (later passes mutate in place).
255
+ const mkOob = () => oobNan ? ['f64.const', 'nan:0x7FF8000000000000'] : ['i32.const', 0]
256
+ const rt = oobNan ? 'f64' : 'i32'
257
+ const widen = b => oobNan ? ['f64.convert_i32_u', b] : b
258
+ // Shape 1: receiver is a `local.get` of a non-boxed function parameter.
259
+ // The decomposition is correct only when the parameter's value can't
260
+ // change after entry — boxed params are stored in heap cells and read
261
+ // through `f64.load`, so we conservatively skip them. Non-param locals
262
+ // are excluded too: even if narrowing concludes they're never reassigned,
263
+ // their initialisation typically happens inside the function body and the
264
+ // prologue would run before the init.
265
+ if (Array.isArray(sF64) && sF64[0] === 'local.get') {
266
+ const raw = typeof sF64[1] === 'string' ? sF64[1] : ''
267
+ const name = raw.startsWith('$') ? raw.slice(1) : raw
268
+ const param = ctx.func.current?.params?.find(p => p.name === name)
269
+ const isBoxed = ctx.func.boxed?.has(name)
270
+ // The decomposition only stays in sync if the param's f64 slot is
271
+ // never overwritten — `s = s + 'X'` would invalidate the cached
272
+ // base/len/sso/loadbase locals. Also require the param's wasm slot
273
+ // to actually be `f64` (i64.reinterpret_f64 below would fail
274
+ // validation otherwise — narrowed-to-int params have type 'i32').
275
+ if (param && param.type === 'f64' && param.ptrKind == null
276
+ && !isBoxed && ctx.func.body && !_paramReassigned(ctx.func.body, name)) {
277
+ if (!ctx.func.charDecomp) ctx.func.charDecomp = new Map()
278
+ let dec = ctx.func.charDecomp.get(name)
279
+ if (!dec) {
280
+ const base = `${name}$ccbase`
281
+ const len = `${name}$cclen`
282
+ const sso = `${name}$ccsso`
283
+ // `loadbase` is the address used for the per-iter `load8_u`: equal
284
+ // to `base` when heap (real string data) and 0 when SSO (memory[0]
285
+ // is always valid; the loaded byte is garbage but the outer select
286
+ // discards it). Pre-computing it in the prologue removes a
287
+ // per-iter `select` and lets V8 fold the add into the load.
288
+ const loadbase = `${name}$ccldb`
289
+ ctx.func.locals.set(base, 'i32')
290
+ ctx.func.locals.set(len, 'i32')
291
+ ctx.func.locals.set(sso, 'i32')
292
+ ctx.func.locals.set(loadbase, 'i32')
293
+ dec = { base, len, sso, loadbase, param: name }
294
+ ctx.func.charDecomp.set(name, dec)
295
+ }
296
+ return emitDecompCharRead(dec, iI32, ctx, oobNan)
297
+ }
298
+ }
299
+
300
+ // Shape 2: generic inline form for non-param receivers.
301
+ //
302
+ // Both the receiver `sF64` and the index `iI32` are duplicated across the
303
+ // SSO/heap branches (and within each branch for bounds + load). Either
304
+ // side may be a side-effecting expression — e.g. parse.js's `str[i++]`
305
+ // lowers to a charCodeAt whose index is a `(block (local.set $tmp
306
+ // (f64.add (f64.load $i) 1)) (f64.store $i $tmp) (local.get $tmp))` —
307
+ // so we MUST spill anything that isn't side-effect-free to a local
308
+ // before referencing it more than once. Leaves (`local.get`, `*.const`,
309
+ // `global.get`) are safe to duplicate; everything else is spilled.
310
+ const isLeaf = (n) => Array.isArray(n) &&
311
+ (n[0] === 'local.get' || n[0] === 'global.get' ||
312
+ n[0] === 'i32.const' || n[0] === 'i64.const' || n[0] === 'f64.const' || n[0] === 'f32.const')
313
+ const sLeaf = isLeaf(sF64)
314
+ const iLeaf = isLeaf(iI32)
315
+ const ptrI64Expr = ssoI64(sF64)
316
+ let ptrName = null
317
+ let getPtr
318
+ if (sLeaf) {
319
+ getPtr = () => ssoI64(sF64)
320
+ } else {
321
+ ptrName = allocLocalI64(ctx, 'cc')
322
+ getPtr = () => ['local.get', `$${ptrName}`]
323
+ }
324
+ let idxName = null
325
+ let getIdx
326
+ if (iLeaf) {
327
+ getIdx = () => iI32
328
+ } else {
329
+ idxName = allocLocalI32(ctx, 'ci')
330
+ getIdx = () => ['local.get', `$${idxName}`]
331
+ }
332
+ const offMask = `0x${LAYOUT.OFFSET_MASK.toString(16).toUpperCase()}`
333
+ const offExpr = () => ['i32.wrap_i64', ['i64.and', getPtr(), ['i64.const', offMask]]]
334
+ const ssoLen = ['i32.and',
335
+ ['i32.wrap_i64', ['i64.shr_u', getPtr(), ['i64.const', LAYOUT.AUX_SHIFT]]],
336
+ ['i32.const', LAYOUT.SSO_BIT - 1]]
337
+ const ssoByte = ['i32.and',
338
+ ['i32.shr_u', offExpr(), ['i32.mul', getIdx(), ['i32.const', 8]]],
339
+ ['i32.const', 0xFF]]
340
+ const ssoBranch = ['if', ['result', rt],
341
+ ['i32.ge_u', getIdx(), ssoLen],
342
+ ['then', mkOob()],
343
+ ['else', widen(ssoByte)]]
344
+ const heapLen = ['i32.load', ['i32.sub', offExpr(), ['i32.const', 4]]]
345
+ const heapByte = ['i32.load8_u', ['i32.add', offExpr(), getIdx()]]
346
+ const heapBranch = ['if', ['result', rt],
347
+ ['i32.lt_u', offExpr(), ['i32.const', 4]],
348
+ ['then', mkOob()],
349
+ ['else', ['if', ['result', rt],
350
+ ['i32.ge_u', getIdx(), heapLen],
351
+ ['then', mkOob()],
352
+ ['else', widen(heapByte)]]]]
353
+ const dispatch = ['if', ['result', rt],
354
+ ['i64.ne', ['i64.and', getPtr(), ['i64.const', ssoBitI64()]], ['i64.const', 0]],
355
+ ['then', ssoBranch],
356
+ ['else', heapBranch]]
357
+ if (sLeaf && iLeaf) return dispatch
358
+ const preface = ['block', ['result', rt]]
359
+ if (!sLeaf) preface.push(['local.set', `$${ptrName}`, ptrI64Expr])
360
+ if (!iLeaf) preface.push(['local.set', `$${idxName}`, iI32])
361
+ preface.push(dispatch)
362
+ return preface
363
+ },
364
+
365
+ /** Content equality. Both args: f64 slot carriers. Returns i32 boolean. */
366
+ eq: (aF64, bF64, ctx) => {
367
+ ctx.core.includes.add('__str_eq')
368
+ return ['call', '$__str_eq', ssoI64(aF64), ssoI64(bF64)]
369
+ },
370
+
371
+ /** Three-way byte compare. Both args: f64 slot carriers. Returns i32 ∈ {-1, 0, 1}. */
372
+ cmp: (aF64, bF64, ctx) => {
373
+ ctx.core.includes.add('__str_cmp')
374
+ return ['call', '$__str_cmp', ssoI64(aF64), ssoI64(bF64)]
375
+ },
376
+
377
+ /** Concat with ToString coercion on both sides. Both args: f64 slot
378
+ * carriers. Returns f64 (the new STRING ptr's slot carrier). */
379
+ concat: (aF64, bF64, ctx) => {
380
+ ctx.core.includes.add('__str_concat')
381
+ return ['call', '$__str_concat', ssoI64(aF64), ssoI64(bF64)]
382
+ },
383
+
384
+ /** Concat assuming both sides are already strings (skip ToString). */
385
+ concatRaw: (aF64, bF64, ctx) => {
386
+ ctx.core.includes.add('__str_concat_raw')
387
+ return ['call', '$__str_concat_raw', ssoI64(aF64), ssoI64(bF64)]
388
+ },
389
+ },
390
+ }
391
+
392
+ // ── jsstring ──────────────────────────────────────────────────────────────
393
+ //
394
+ // Architectural scaffold for native JS strings via JS String Builtins.
395
+ // Under this carrier, string values flow across the wasm boundary as
396
+ // `externref` instead of nanbox-tagged heap offsets. String operations
397
+ // (`length`, `charCodeAt`, `concat`, `fromCharCode`, …) are emitted as
398
+ // calls to imports from the `wasm:js-string` namespace — engine-provided
399
+ // builtins that read/write the engine's native String representation.
400
+ //
401
+ // Spec: https://webassembly.github.io/js-string-builtins/js-api/
402
+ // Engine support: V8 17+, Safari 18.4+, Firefox behind a flag.
403
+ //
404
+ // ### Status — scaffold, not a working codegen path
405
+ //
406
+ // Today this carrier exists to:
407
+ // 1. Slot into the default-bundle in `src/abi/index.js` so the dispatch
408
+ // infrastructure is exercised end-to-end with two carriers.
409
+ // 2. Document the contract future string-codegen rerouting will plug into.
410
+ // 3. Outline the compiler-wide changes a real implementation requires —
411
+ // they're larger than "fill in the ops table" and need their own plan.
412
+ //
413
+ // Until those changes land, `jsstring` is exported alongside `sso` but the
414
+ // default bundle in `src/abi/index.js` still picks `sso`. The narrower will
415
+ // flip individual sites to `jsstring` once the codegen paths below are real.
416
+ //
417
+ // ### Wire shape
418
+ //
419
+ // (import "wasm:js-string" "length" (func $__jss_length (param externref) (result i32)))
420
+ // (import "wasm:js-string" "charCodeAt" (func $__jss_charCodeAt (param externref i32) (result i32)))
421
+ // (import "wasm:js-string" "concat" (func $__jss_concat (param externref externref) (result (ref extern))))
422
+ // (import "wasm:js-string" "compare" (func $__jss_compare (param externref externref) (result i32)))
423
+ // (import "wasm:js-string" "test" (func $__jss_test (param externref) (result i32)))
424
+ // (import "wasm:js-string" "fromCharCode" (func $__jss_fromCharCode (param i32) (result (ref extern))))
425
+ // (import "wasm:js-string" "substring" (func $__jss_substring (param externref i32 i32) (result (ref extern))))
426
+ //
427
+ // ### Compiler-wide checklist (dependency order)
428
+ //
429
+ // 1. **Import declaration channel.** Mirror `ctx.core.includes` for
430
+ // `wasm:js-string` imports — a `ctx.core.imports` set that compile.js
431
+ // drains into `(import ...)` nodes. The string-builtins API is feature-
432
+ // detected (`WebAssembly.validate` with the import set), so the host
433
+ // must either gate compile output on builtins support or polyfill the
434
+ // imports from JS for older engines.
435
+ //
436
+ // 2. **STRING-typed locals as externref.** `ctx.func.locals` today stores
437
+ // `'f64' | 'i32'` per local; STRING locals need `'externref'`. Touch
438
+ // every site that declares string locals (closures, params, refinements,
439
+ // destructuring) so the WAT `(local $name externref)` lands.
440
+ //
441
+ // 3. **emit() returns externref for STRING-typed nodes.** Today every
442
+ // `emit(strNode)` returns f64-typed IR carrying a NaN-boxed pointer.
443
+ // Under jsstring it must return externref-typed IR. The `asF64` call
444
+ // sites currently feeding the carrier would route through a carrier-
445
+ // driven `coerceSlot(emit(...))` helper instead, so the slot type swap
446
+ // is transparent to callers.
447
+ //
448
+ // 4. **Boundary wrappers.** `src/compile.js:synthesizeBoundaryWrappers`
449
+ // types every string param/result through f64 (or i64 for ptr carriers).
450
+ // Read `ctx.abi.string.slotTypes[0]` — if `externref`, declare the
451
+ // param/result `externref` and skip the nanbox box/unbox steps.
452
+ //
453
+ // 5. **Literals.** `['str', "foo"]` today writes into the heap and returns
454
+ // a NaN-boxed pointer. Under jsstring it would need a module-level
455
+ // `externref` global initialized from a JS-side literal table — likely
456
+ // via a startup import that hands back the canonical `externref` for
457
+ // each known string. Or build at runtime with `fromCharCodeArray`.
458
+ //
459
+ // 6. **Mutating fast paths.** Heap-string optimizations like
460
+ // `__str_append_byte` (mutate in place when lhs is heap-top) don't
461
+ // translate — engine strings are immutable. These paths must gate off
462
+ // under jsstring (`if (slotTypes[0] === 'f64') …`) or be removed from
463
+ // the carrier's surface entirely.
464
+ //
465
+ // 7. **Cross-carrier interop.** Mixing nanbox numeric values and externref
466
+ // strings in the same function means locals span two slot types.
467
+ // `i32`/`f64`/`externref` already coexist (closures use `i32` for boxed
468
+ // cells), so the multi-slot story is incremental, not novel.
469
+ //
470
+ // 8. **`?.length`, optional access.** The `?.` emit threads `local.get`
471
+ // through `notNullish`, which today inspects f64 NaN-shape bits.
472
+ // `externref` nullishness is a single `ref.is_null` (no NaN inspection),
473
+ // so the optional-chain emit needs a carrier-aware nullish predicate.
474
+ //
475
+ // 9. **Host wiring.** `interop.js` passes externref strings directly
476
+ // (no encode/decode) and supplies the `wasm:js-string` imports object
477
+ // when the binary references any. JS strings already act as externrefs
478
+ // at the host boundary — the bridge mostly hands them through.
479
+
480
+ export const jsstring = {
481
+ // Wasm slot type a string value occupies under this carrier. Read by
482
+ // `src/compile.js:synthesizeBoundaryWrappers` for param/result typing
483
+ // (item 4) and by the slot coercer at every STRING `emit()` call site
484
+ // (item 3).
485
+ slotTypes: ['externref'],
486
+
487
+ // Names of `wasm:js-string` imports this carrier relies on. Used
488
+ // (eventually) by the compiler to declare the import nodes once any op
489
+ // references them.
490
+ imports: ['length', 'charCodeAt', 'concat', 'fromCharCode', 'substring',
491
+ 'codePointAt', 'compare', 'test', 'intoCharCodeArray', 'fromCharCodeArray'],
492
+
493
+ // Op hooks — string operations routed through this carrier. Inputs are
494
+ // already externref-typed (the param/local that carries the value); outputs
495
+ // are i32 (`length`, `charCodeAt`) or (for future ops) externref again.
496
+ // Each op registers its builtin import via `ctx.core.jsstring.add(name)`;
497
+ // `compile.js` drains the set into `(import "wasm:js-string" …)` nodes.
498
+ ops: {
499
+ /** Byte length. Receiver: externref. Returns i32 — caller widens to f64
500
+ * if it needs JS-spec `.length` (a number for the JS-visible export). */
501
+ byteLen: (sExt, ctx) => {
502
+ ctx.core.jsstring.add('length')
503
+ return ['call', '$__jss_length', sExt]
504
+ },
505
+
506
+ /** Char code at index i. Receiver: externref; index: i32. Returns i32.
507
+ * `wasm:js-string.charCodeAt` traps on OOB — callers must only use this
508
+ * shape under an in-bounds proof (analyze.js `inBoundsCharCodeAt`). The
509
+ * generic OOB-NaN contract is still handled via the SSO fallback (which
510
+ * doesn't apply when the receiver is externref — see core.js dispatch). */
511
+ charCodeAt: (sExt, iI32, ctx) => {
512
+ ctx.core.jsstring.add('charCodeAt')
513
+ return ['call', '$__jss_charCodeAt', sExt, iI32]
514
+ },
515
+ },
516
+ }
517
+
518
+ // Signatures for the `wasm:js-string` imports this carrier emits. Indexed by
519
+ // builtin name (the bare key drained from `ctx.core.jsstring`); `compile.js`
520
+ // builds the `(import …)` node from this table.
521
+ export const JSS_IMPORT_SIGS = {
522
+ length: { params: ['externref'], result: 'i32' },
523
+ charCodeAt: { params: ['externref', 'i32'], result: 'i32' },
524
+ }
525
+
526
+ // Default carrier — picked when narrower has no stronger evidence. Reached
527
+ // via `ctx.abi.string` (which the default-bundle in `src/abi/index.js` binds
528
+ // to this export).
529
+ export default sso