jz 0.8.0 → 0.9.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.
Files changed (73) hide show
  1. package/README.md +56 -9
  2. package/bench/README.md +121 -50
  3. package/bench/bench.svg +27 -27
  4. package/cli.js +3 -1
  5. package/dist/interop.js +1 -1
  6. package/dist/jz.js +7541 -6178
  7. package/index.js +165 -74
  8. package/interop.js +189 -17
  9. package/jzify/arguments.js +32 -1
  10. package/jzify/async.js +409 -0
  11. package/jzify/classes.js +136 -18
  12. package/jzify/generators.js +639 -0
  13. package/jzify/hoist-vars.js +73 -1
  14. package/jzify/index.js +123 -4
  15. package/jzify/names.js +1 -0
  16. package/jzify/transform.js +239 -1
  17. package/layout.js +48 -3
  18. package/module/array.js +318 -43
  19. package/module/atomics.js +144 -0
  20. package/module/collection.js +1429 -155
  21. package/module/core.js +431 -40
  22. package/module/date.js +142 -120
  23. package/module/fs.js +144 -0
  24. package/module/function.js +6 -3
  25. package/module/index.js +4 -1
  26. package/module/json.js +270 -49
  27. package/module/math.js +1032 -145
  28. package/module/number.js +532 -163
  29. package/module/object.js +353 -95
  30. package/module/regex.js +157 -7
  31. package/module/schema.js +100 -2
  32. package/module/string.js +428 -93
  33. package/module/typedarray.js +264 -72
  34. package/module/web.js +36 -0
  35. package/package.json +5 -5
  36. package/src/abi/string.js +82 -11
  37. package/src/ast.js +11 -5
  38. package/src/autoload.js +28 -1
  39. package/src/bridge.js +7 -2
  40. package/src/compile/analyze-scans.js +22 -7
  41. package/src/compile/analyze.js +136 -30
  42. package/src/compile/dyn-closure-tables.js +277 -0
  43. package/src/compile/emit-assign.js +281 -15
  44. package/src/compile/emit.js +1055 -70
  45. package/src/compile/index.js +277 -29
  46. package/src/compile/infer.js +42 -4
  47. package/src/compile/inplace-store.js +329 -0
  48. package/src/compile/loop-recurrence.js +167 -0
  49. package/src/compile/narrow.js +555 -18
  50. package/src/compile/peel-stencil.js +5 -0
  51. package/src/compile/plan/index.js +45 -3
  52. package/src/compile/plan/inline.js +8 -1
  53. package/src/compile/plan/literals.js +39 -0
  54. package/src/compile/plan/scope.js +430 -23
  55. package/src/compile/program-facts.js +732 -38
  56. package/src/ctx.js +64 -9
  57. package/src/helper-counters.js +7 -1
  58. package/src/ir.js +113 -5
  59. package/src/kind-traits.js +66 -3
  60. package/src/kind.js +84 -12
  61. package/src/op-policy.js +7 -4
  62. package/src/optimize/index.js +1179 -704
  63. package/src/optimize/recurse.js +2 -2
  64. package/src/optimize/vectorize.js +982 -67
  65. package/src/prepare/index.js +809 -67
  66. package/src/prepare/math-kernel.js +331 -0
  67. package/src/prepare/pre-eval.js +714 -0
  68. package/src/snapshot.js +194 -0
  69. package/src/type.js +1170 -56
  70. package/src/wat/assemble.js +403 -65
  71. package/src/wat/codegen.js +74 -14
  72. package/transform.js +113 -4
  73. package/wasi.js +3 -0
package/module/string.js CHANGED
@@ -3,8 +3,17 @@
3
3
  *
4
4
  * Type=4 (STRING) covers both encodings; aux bit LAYOUT.SSO_BIT discriminates:
5
5
  * bit clear: heap-allocated, length in header [-4:len], offset → bytes.
6
- * bit set: inline ≤4 ASCII chars packed in offset (no memory),
7
- * length in aux low bits (0..4).
6
+ * bit set: inline ≤6 ASCII chars packed in the payload (no memory),
7
+ * length at payload bits 42-44.
8
+ *
9
+ * INVARIANT (load-bearing): every producer normalizes a ≤6-byte all-ASCII result
10
+ * to SSO — literals, slices, concat, append, case, pad, repeat, number/JSON/URI
11
+ * formatting, byte decode, host marshaling (interop.js). Consequence: two strings
12
+ * where exactly one is SSO can never be content-equal, so `__str_eq` decides any
13
+ * mixed compare by bits alone and `x === 'shortLit'` lowers to a bare i64.eq
14
+ * (emit.js emitLooseEq). Breaking the invariant in ONE producer silently breaks
15
+ * string equality — new string constructors MUST route short ASCII results
16
+ * through SSO (see __sso_norm / the concat short-pack).
8
17
  *
9
18
  * Methods use type-qualified keys (.string:slice) for array-colliding names,
10
19
  * generic keys (.toUpperCase) for non-colliding ones.
@@ -17,10 +26,13 @@ import { emit, bool, method, deps, wat, bind } from '../src/bridge.js'
17
26
  import { valTypeOf } from '../src/kind.js'
18
27
  import { VAL } from '../src/reps.js'
19
28
  import { ctx, inc, PTR, LAYOUT, err, declGlobal } from '../src/ctx.js'
20
- import { ssoBitI64Hex, sliceBitI64Hex, ptrNanHex, STR_INTERN_BIT } from '../layout.js'
29
+ import { ssoBitI64Hex, sliceBitI64Hex, hcacheBitI64Hex, ptrNanHex, STR_INTERN_BIT, STR_HCACHE_BIT } from '../layout.js'
21
30
 
22
31
  const SSO_BIT_I64 = ssoBitI64Hex()
23
32
  const SLICE_BIT_I64 = sliceBitI64Hex()
33
+ const HCACHE_BIT_I64 = hcacheBitI64Hex()
34
+ // (SSO_BIT | SLICE_BIT) << AUX_SHIFT as i64 hex — BigInt-free (self-host runs this)
35
+ const SSO_SLICE_I64 = '0x' + (LAYOUT.SSO_BIT | LAYOUT.SLICE_BIT).toString(16) + '00000000'
24
36
 
25
37
  // === SSO codec — single source of truth for the inline-string bit layout ===
26
38
  // ASCII chars packed at 7 bits each into the NaN-box payload: char i at payload bit
@@ -28,13 +40,16 @@ const SLICE_BIT_I64 = sliceBitI64Hex()
28
40
  // (SLICE_BIT at 45 stays 0). 6 chars fit (6*7=42, + 3-bit len). ASCII-only — a byte
29
41
  // ≥0x80 falls back to a heap string. The 7-bit-uniform layout makes equal short strings
30
42
  // content-bit-equal (so `op === 'tag'` is a bare i64.eq) and never touches memory.
31
- const MAX_SSO = 6
43
+ export const MAX_SSO = 6
32
44
  const SSO_LEN_SHIFT = 10 // length occupies aux bits 10-12 (= payload bits 42-44)
45
+ const SSO_CHAR_MASK = '0x3ffffffffff' // payload bits 0-41: the 6 × 7-bit char lanes
33
46
  // JS: ASCII string → { aux, offset }, or null when ineligible (too long / non-ASCII).
34
47
  // BigInt-free (i32 ops only) so the self-hosted compiler — which runs this to encode
35
48
  // every string literal — stays on jz's numeric core. offset = payload bits 0-31, aux =
36
49
  // bits 32-46; char i at bit i*7 (chars 0-3 fit the offset, char 4 straddles, char 5 → aux).
37
- const ssoEncode = (str) => {
50
+ // Exported: collection.js's strHashLiteral reuses this to build the exact lo/hi pair
51
+ // __str_hash's SSO branch mixes — single source of truth for the packing (see its use).
52
+ export const ssoEncode = (str) => {
38
53
  if (str.length > MAX_SSO || !/^[\x00-\x7f]*$/.test(str)) return null
39
54
  let offset = 0, auxChars = 0
40
55
  for (let i = 0; i < str.length; i++) {
@@ -144,6 +159,8 @@ export default (ctx) => {
144
159
  deps({
145
160
  __str_concat: ['__to_str', '__str_byteLen', '__alloc', '__memgrow', '__mkptr', '__str_copy'],
146
161
  __str_concat_raw: ['__str_byteLen', '__alloc', '__memgrow', '__mkptr', '__str_copy'],
162
+ __str_concat_fresh: ['__to_str', '__str_byteLen', '__alloc', '__mkptr', '__str_copy'],
163
+ __str_concat_raw_fresh: ['__str_byteLen', '__alloc', '__mkptr', '__str_copy'],
147
164
  __str_append_byte: ['__str_byteLen', '__alloc', '__memgrow', '__mkptr', '__str_copy'],
148
165
  __str_copy: [],
149
166
  // __str_slice/_view are FUNCTION templates: resolveIncludes' auto-dep scan realizes the
@@ -163,11 +180,13 @@ export default (ctx) => {
163
180
  __str_trim: ['__str_slice', '__str_byteLen', '__char_at'],
164
181
  __str_trimStart: ['__str_slice', '__str_byteLen', '__char_at'],
165
182
  __str_trimEnd: ['__str_slice', '__str_byteLen', '__char_at'],
166
- __str_repeat: ['__str_byteLen', '__str_copy', '__alloc'],
183
+ __str_repeat: ['__str_byteLen', '__str_copy', '__alloc', '__sso_norm'],
167
184
  __str_replace: ['__str_indexof', '__str_slice', '__str_concat'],
168
185
  __str_replaceall: ['__str_indexof', '__str_slice', '__str_concat'],
169
186
  __str_split: ['__str_slice', '__str_byteLen', '__char_at', '__alloc'],
170
187
  __str_idx: ['__char1byte'],
188
+ __sso_norm: [],
189
+ __bytes_decode: ['__ptr_offset', '__len', '__alloc', '__mkptr', '__sso_norm'],
171
190
  __str_eq: ['__str_eq_cold'],
172
191
  __str_eq_cold: ['__char_at', '__str_byteLen'],
173
192
  __str_cmp: ['__char_at', '__str_byteLen'],
@@ -177,8 +196,8 @@ export default (ctx) => {
177
196
  __str_pad: ['__str_byteLen', '__str_copy', '__alloc'],
178
197
  __str_join: ['__str_concat', '__to_str', '__str_byteLen', '__len', '__ptr_offset', '__mkptr'], // FN template: __mkptr body-called, must be manual (self-host auto-scan diverges)
179
198
  __str_encode: ['__str_byteLen', '__str_copy'],
180
- __encodeURIComponent: ['__to_str', '__str_byteLen', '__char_at', '__alloc', '__mkptr'],
181
- __decodeURIComponent: ['__to_str', '__str_byteLen', '__char_at', '__alloc', '__mkptr', '__uri_hex'],
199
+ __encodeURIComponent: ['__to_str', '__str_byteLen', '__char_at', '__alloc', '__mkptr', '__sso_norm'],
200
+ __decodeURIComponent: ['__to_str', '__str_byteLen', '__char_at', '__alloc', '__mkptr', '__uri_hex', '__sso_norm'],
182
201
  __uri_hex: [],
183
202
  __to_str: ['__ftoa', '__static_str', '__str_join', '__mkptr'],
184
203
  __str_byteLen: ['__ptr_type', '__ptr_aux', '__str_len'],
@@ -198,13 +217,17 @@ export default (ctx) => {
198
217
  // so a heap-free program stays heap-free — no allocator, no memory, no exports.
199
218
  inc('__mkptr', '__alloc')
200
219
 
201
- // === String literal: "abc" → SSO if ≤4 ASCII, else static data ===
220
+ // === String literal: "abc" → SSO if ≤6 ASCII, else static data ===
202
221
 
203
222
  bind('str', (str) => {
204
223
  if (ctx.features.sso) {
205
224
  const sso = ssoEncode(str)
206
225
  if (sso) return mkPtrIR(PTR.STRING, sso.aux, sso.offset)
207
226
  }
227
+ // Falls through to the static-data path below ONLY when ssoEncode returned null
228
+ // (>6 bytes or non-ASCII) — so an interned static NEVER carries ≤6-ASCII content,
229
+ // and its cached hash (below) stays byte-FNV without needing the SSO mix
230
+ // (__str_hash's SSO branch, module/collection.js, never sees an interned pointer).
208
231
  const bytes = new TextEncoder().encode(str)
209
232
  const len = bytes.length
210
233
  if (!ctx.memory.shared) {
@@ -224,7 +247,9 @@ export default (ctx) => {
224
247
  const offset = ctx.runtime.data.length
225
248
  if (interned) {
226
249
  // byte-FNV + clamp — must equal __str_hash's output exactly (it hashes
227
- // UTF-8 bytes, then clamps ≤1 → +2 for the empty/tombstone sentinels)
250
+ // UTF-8 bytes, then clamps ≤1 → +2 for the empty/tombstone sentinels).
251
+ // Always the byte-FNV branch, never the SSO mix: this string is here
252
+ // because ssoEncode rejected it (see the guard above).
228
253
  let h = 0x811c9dc5 | 0
229
254
  for (let i = 0; i < len; i++) h = Math.imul(h ^ bytes[i], 0x01000193) | 0
230
255
  if (h <= 1) h = (h + 2) | 0
@@ -361,17 +386,25 @@ export default (ctx) => {
361
386
  // the engine's wasm inliner — the call overhead disappears at every site
362
387
  // while the byte-walk lives in __str_eq_cold. Mirrors the __ptr_offset_fwd
363
388
  // split; a body containing a loop is excluded from V8's inliner.
364
- wat('__str_eq', `(func $__str_eq (param $a i64) (param $b i64) (result i32)
389
+ wat('__str_eq', () => `(func $__str_eq (param $a i64) (param $b i64) (result i32)
365
390
  (local $axA i32) (local $axB i32) (local $offA i32) (local $offB i32)
366
391
  (if (i64.eq (local.get $a) (local.get $b))
367
392
  (then (return (i32.const 1))))
368
393
  (local.set $axA (i32.wrap_i64 (i64.shr_u (local.get $a) (i64.const ${LAYOUT.AUX_SHIFT}))))
369
394
  (local.set $axB (i32.wrap_i64 (i64.shr_u (local.get $b) (i64.const ${LAYOUT.AUX_SHIFT}))))
395
+ ${ctx.features.sso ? `
396
+ ;; ANY SSO operand ⇒ bit-ne decided content-ne: ≤${MAX_SSO}-ASCII content is
397
+ ;; always SSO (module invariant), so an SSO can equal neither a bit-different
398
+ ;; SSO nor any heap string. Kills the mixed SSO×heap byte-walk class outright.
399
+ (if (i32.or
400
+ (i32.and (local.get $axA) (i32.const ${LAYOUT.SSO_BIT}))
401
+ (i32.and (local.get $axB) (i32.const ${LAYOUT.SSO_BIT})))
402
+ (then (return (i32.const 0))))` : `
370
403
  ;; both SSO ⇒ bit-ne already decided content-ne
371
404
  (if (i32.and
372
405
  (i32.and (local.get $axA) (i32.const ${LAYOUT.SSO_BIT}))
373
406
  (i32.and (local.get $axB) (i32.const ${LAYOUT.SSO_BIT})))
374
- (then (return (i32.const 0))))
407
+ (then (return (i32.const 0))))`}
375
408
  ;; both CANONICAL interned heap strings ⇒ bit-ne ⇒ content-ne
376
409
  (if (i32.and
377
410
  (i32.eq (i32.and (local.get $axA) (i32.const ${LAYOUT.SSO_BIT | LAYOUT.SLICE_BIT | 0x1})) (i32.const 0x1))
@@ -529,9 +562,10 @@ export default (ctx) => {
529
562
  (local.set $nlen (i32.sub (local.get $end) (local.get $start)))
530
563
  ${sliceSsoPackWat()}
531
564
  ${internProbeWat('(i32.add (i32.wrap_i64 (i64.and (local.get $ptr) (i64.const ' + LAYOUT.OFFSET_MASK + '))) (local.get $start))', '(i32.eqz (local.get $isSso))')}
532
- (local.set $off (call $__alloc (i32.add (i32.const 4) (local.get $nlen))))
533
- (i32.store (local.get $off) (local.get $nlen))
534
- (local.set $off (i32.add (local.get $off) (i32.const 4)))
565
+ (local.set $off (call $__alloc (i32.add (i32.const 8) (local.get $nlen))))
566
+ (i32.store (local.get $off) (i32.const 0))
567
+ (i32.store offset=4 (local.get $off) (local.get $nlen))
568
+ (local.set $off (i32.add (local.get $off) (i32.const 8)))
535
569
  (local.set $srcOff (i32.wrap_i64 (i64.and (local.get $ptr) (i64.const ${LAYOUT.OFFSET_MASK}))))
536
570
  (local.set $isSso (i32.wrap_i64 (i64.shr_u
537
571
  (i64.and (local.get $ptr) (i64.const ${SSO_BIT_I64}))
@@ -548,7 +582,7 @@ export default (ctx) => {
548
582
  (br $loop))))
549
583
  (else
550
584
  (memory.copy (local.get $off) (i32.add (local.get $srcOff) (local.get $start)) (local.get $nlen))))
551
- (call $__mkptr (i32.const ${PTR.STRING}) (i32.const 0) (local.get $off)))`)
585
+ (call $__mkptr (i32.const ${PTR.STRING}) (i32.const ${STR_HCACHE_BIT}) (local.get $off)))`)
552
586
 
553
587
  // No-copy slice: returns a VIEW into the receiver's buffer instead of copying
554
588
  // bytes. Only emitted when escape analysis proves the result never outlives the
@@ -568,13 +602,14 @@ export default (ctx) => {
568
602
  (then (return (call $__mkptr (i32.const ${PTR.STRING}) (i32.const ${LAYOUT.SSO_BIT}) (i32.const 0)))))
569
603
  (local.set $nlen (i32.sub (local.get $end) (local.get $start)))
570
604
  (local.set $tag (i32.wrap_i64 (i64.and (i64.shr_u (local.get $ptr) (i64.const ${LAYOUT.TAG_SHIFT})) (i64.const ${LAYOUT.TAG_MASK}))))
571
- ;; View-eligible: STRING parent, not SSO, length fits aux[12:0]. ≤4-byte
572
- ;; results route to __str_slice's SSO pack instead (bit-equal short tokens).
605
+ ;; View-eligible: STRING parent, not SSO, length fits aux[12:0]. ≤${MAX_SSO}-byte
606
+ ;; results route to __str_slice's SSO pack instead (bit-equal short tokens
607
+ ;; a short VIEW would break the ≤${MAX_SSO}-ASCII⇒SSO invariant).
573
608
  (if (i32.and
574
609
  (i32.and
575
610
  (i32.and
576
611
  (i32.eq (local.get $tag) (i32.const ${PTR.STRING}))
577
- (i32.gt_u (local.get $nlen) (i32.const 4)))
612
+ (i32.gt_u (local.get $nlen) (i32.const ${MAX_SSO})))
578
613
  (i64.eqz (i64.and (local.get $ptr) (i64.const ${SSO_BIT_I64}))))
579
614
  (i32.le_u (local.get $nlen) (i32.const ${LAYOUT.SLICE_LEN_MASK})))
580
615
  (then
@@ -674,15 +709,27 @@ export default (ctx) => {
674
709
 
675
710
  // Hoist SSO/heap dispatch for hay and ndl out of the inner byte loop. Inner
676
711
  // loop becomes (load8_u OR sso byte-extract) per side — no per-byte calls.
712
+ // Needle byte j, SSO-aware (packed-bits extract vs heap load) — lets the SIMD
713
+ // haystack scan serve SSO needles too (the common short "," / "://" needle),
714
+ // not just heap×heap. `$nsso` is loop-invariant so the branch predicts away.
715
+ const nByte = (j) => `(if (result i32) (local.get $nsso)
716
+ (then ${ssoCharWat('(local.get $ndl)', j)})
717
+ (else (i32.load8_u (i32.add (local.get $noff) ${j}))))`
677
718
  wat('__str_indexof', `(func $__str_indexof (param $hay i64) (param $ndl i64) (param $from i32) (result i32)
678
719
  (local $hlen i32) (local $nlen i32) (local $i i32) (local $j i32) (local $match i32)
679
720
  (local $hoff i32) (local $noff i32)
680
721
  (local $hsso i32) (local $nsso i32) (local $hb i32) (local $nb i32) (local $k i32)
722
+ (local $splat v128) (local $mask i32) (local $scanEnd i32)
681
723
  ;; The search value is ToString'd at the call site (searchArg) per 21.1.3.9 step 4 —
682
724
  ;; a known-string needle passes raw, so this helper carries no float-pulling __to_str.
683
725
  (local.set $hlen (call $__str_byteLen (local.get $hay)))
684
726
  (local.set $nlen (call $__str_byteLen (local.get $ndl)))
685
- (if (i32.eqz (local.get $nlen)) (then (return (local.get $from))))
727
+ ;; Empty needle matches at clamp(from, 0, hlen) per 21.1.3.9 step 6 (Min(Max(pos,0),len)).
728
+ (if (i32.eqz (local.get $nlen))
729
+ (then (return (select
730
+ (select (local.get $from) (local.get $hlen) (i32.lt_s (local.get $from) (local.get $hlen)))
731
+ (i32.const 0)
732
+ (i32.ge_s (local.get $from) (i32.const 0))))))
686
733
  (if (i32.gt_s (local.get $nlen) (local.get $hlen)) (then (return (i32.const -1))))
687
734
  (local.set $hoff (i32.wrap_i64 (i64.and (local.get $hay) (i64.const ${LAYOUT.OFFSET_MASK}))))
688
735
  (local.set $noff (i32.wrap_i64 (i64.and (local.get $ndl) (i64.const ${LAYOUT.OFFSET_MASK}))))
@@ -693,6 +740,83 @@ export default (ctx) => {
693
740
  (i32.wrap_i64 (i64.shr_u (local.get $ndl) (i64.const ${LAYOUT.AUX_SHIFT})))
694
741
  (i32.const ${LAYOUT.SSO_BIT})))
695
742
  (local.set $i (if (result i32) (i32.gt_s (local.get $from) (i32.const 0)) (then (local.get $from)) (else (i32.const 0))))
743
+ ;; Single-byte needle (the common s.indexOf('/') / s.includes(' ')): scan for one byte without
744
+ ;; the per-position match/j/k bookkeeping. Heap haystack gets a branchless load8_u loop (no
745
+ ;; per-byte SSO test); a 16-wide SIMD scan here would only add splat/window setup that short
746
+ ;; strings — the bulk of single-char searches — never amortize, so the scalar scan stays.
747
+ (if (i32.eq (local.get $nlen) (i32.const 1))
748
+ (then
749
+ (local.set $nb ${nByte('(i32.const 0)')})
750
+ (if (i32.eqz (local.get $hsso))
751
+ (then
752
+ (block $sd (loop $ss
753
+ (br_if $sd (i32.ge_s (local.get $i) (local.get $hlen)))
754
+ (if (i32.eq (i32.load8_u (i32.add (local.get $hoff) (local.get $i))) (local.get $nb))
755
+ (then (return (local.get $i))))
756
+ (local.set $i (i32.add (local.get $i) (i32.const 1)))
757
+ (br $ss)))
758
+ (return (i32.const -1)))
759
+ (else
760
+ (block $sd2 (loop $ss2
761
+ (br_if $sd2 (i32.ge_s (local.get $i) (local.get $hlen)))
762
+ (if (i32.eq ${ssoCharWat('(local.get $hay)', '(local.get $i)')} (local.get $nb))
763
+ (then (return (local.get $i))))
764
+ (local.set $i (i32.add (local.get $i) (i32.const 1)))
765
+ (br $ss2)))
766
+ (return (i32.const -1))))))
767
+ ;; Multi-byte needle, HEAP haystack (needle SSO or heap): a SIMD first-byte memchr — broadcast
768
+ ;; needle[0] across 16 lanes, i8x16.eq a 16-byte haystack window, and read the match-bitmask.
769
+ ;; Only positions whose first byte matches reach the branchless verify; the rare match (V8's own
770
+ ;; StringIndexOf is SIMD here) is what the scalar path lost on. The SIMD load only touches the
771
+ ;; haystack, so an SSO needle (the common short "," / "://" / "TARGET") rides this path too —
772
+ ;; its bytes are fetched SSO-aware via nByte(). scanEnd = hlen-nlen is the last viable start; the
773
+ ;; window stops at hlen-16 (a full 16-byte load stays inside the string), a scalar tail covers
774
+ ;; the rest. Only an SSO haystack (≤ MAX_SSO bytes — too short to scan 16-wide) falls through.
775
+ (if (i32.eqz (local.get $hsso))
776
+ (then
777
+ (local.set $nb ${nByte('(i32.const 0)')})
778
+ (local.set $scanEnd (i32.sub (local.get $hlen) (local.get $nlen)))
779
+ (local.set $splat (i8x16.splat (local.get $nb)))
780
+ (block $vd (loop $vo
781
+ (br_if $vd (i32.gt_s (local.get $i) (i32.sub (local.get $hlen) (i32.const 16))))
782
+ (local.set $mask (i8x16.bitmask
783
+ (i8x16.eq (v128.load (i32.add (local.get $hoff) (local.get $i))) (local.get $splat))))
784
+ (block $bd (loop $bo
785
+ (br_if $bd (i32.eqz (local.get $mask)))
786
+ (local.set $k (i32.add (local.get $i) (i32.ctz (local.get $mask)))) ;; candidate start
787
+ (br_if $bd (i32.gt_s (local.get $k) (local.get $scanEnd))) ;; positions only grow
788
+ (local.set $match (i32.const 1))
789
+ (local.set $j (i32.const 1))
790
+ (block $mn (loop $mi
791
+ (br_if $mn (i32.ge_s (local.get $j) (local.get $nlen)))
792
+ (if (i32.ne (i32.load8_u (i32.add (i32.add (local.get $hoff) (local.get $k)) (local.get $j)))
793
+ ${nByte('(local.get $j)')})
794
+ (then (local.set $match (i32.const 0)) (br $mn)))
795
+ (local.set $j (i32.add (local.get $j) (i32.const 1)))
796
+ (br $mi)))
797
+ (if (local.get $match) (then (return (local.get $k))))
798
+ (local.set $mask (i32.and (local.get $mask) (i32.sub (local.get $mask) (i32.const 1)))) ;; clear lowest match
799
+ (br $bo)))
800
+ (local.set $i (i32.add (local.get $i) (i32.const 16)))
801
+ (br $vo)))
802
+ ;; scalar tail: positions [i, scanEnd] the SIMD window couldn't cover
803
+ (block $md (loop $mo
804
+ (br_if $md (i32.gt_s (local.get $i) (local.get $scanEnd)))
805
+ (if (i32.eq (i32.load8_u (i32.add (local.get $hoff) (local.get $i))) (local.get $nb))
806
+ (then
807
+ (local.set $match (i32.const 1))
808
+ (local.set $j (i32.const 1))
809
+ (block $mn2 (loop $mi2
810
+ (br_if $mn2 (i32.ge_s (local.get $j) (local.get $nlen)))
811
+ (if (i32.ne (i32.load8_u (i32.add (i32.add (local.get $hoff) (local.get $i)) (local.get $j)))
812
+ ${nByte('(local.get $j)')})
813
+ (then (local.set $match (i32.const 0)) (br $mn2)))
814
+ (local.set $j (i32.add (local.get $j) (i32.const 1)))
815
+ (br $mi2)))
816
+ (if (local.get $match) (then (return (local.get $i))))))
817
+ (local.set $i (i32.add (local.get $i) (i32.const 1)))
818
+ (br $mo)))
819
+ (return (i32.const -1))))
696
820
  (block $done (loop $outer
697
821
  (br_if $done (i32.gt_s (local.get $i) (i32.sub (local.get $hlen) (local.get $nlen))))
698
822
  (local.set $match (i32.const 1))
@@ -830,33 +954,41 @@ export default (ctx) => {
830
954
  // Source SSO/heap dispatch hoisted out of the byte loop (was a per-byte __char_at).
831
955
  wat('__str_case', `(func $__str_case (param $ptr i64) (param $lo i32) (param $hi i32) (param $delta i32) (result f64)
832
956
  (local $len i32) (local $off i32) (local $i i32) (local $c i32)
833
- (local $srcOff i32)
957
+ (local $srcOff i32) (local $sp64 i64)
834
958
  (local.set $len (call $__str_byteLen (local.get $ptr)))
835
959
  (if (i32.eqz (local.get $len))
836
960
  (then (return (call $__mkptr (i32.const ${PTR.STRING}) (i32.const ${LAYOUT.SSO_BIT}) (i32.const 0)))))
837
- (local.set $off (call $__alloc (i32.add (i32.const 4) (local.get $len))))
838
- (i32.store (local.get $off) (local.get $len))
839
- (local.set $off (i32.add (local.get $off) (i32.const 4)))
840
- (local.set $srcOff (i32.wrap_i64 (i64.and (local.get $ptr) (i64.const ${LAYOUT.OFFSET_MASK}))))
961
+ ;; SSO in SSO out: case-map the 7-bit lanes in registers (no allocation);
962
+ ;; ASCII stays ASCII, so the result is always SSO-eligible (invariant).
841
963
  (if (i64.ne (i64.and (local.get $ptr) (i64.const ${SSO_BIT_I64})) (i64.const 0))
842
964
  (then
965
+ (local.set $sp64 (i64.const 0))
843
966
  (block $dsso (loop $lsso
844
967
  (br_if $dsso (i32.ge_s (local.get $i) (local.get $len)))
845
968
  (local.set $c ${ssoCharWat('(local.get $ptr)', '(local.get $i)')})
846
969
  (if (i32.and (i32.ge_u (local.get $c) (local.get $lo)) (i32.le_u (local.get $c) (local.get $hi)))
847
970
  (then (local.set $c (i32.add (local.get $c) (local.get $delta)))))
848
- (i32.store8 (i32.add (local.get $off) (local.get $i)) (local.get $c))
849
- (local.set $i (i32.add (local.get $i) (i32.const 1)))
850
- (br $lsso))))
851
- (else
852
- (block $dh (loop $lh
853
- (br_if $dh (i32.ge_s (local.get $i) (local.get $len)))
854
- (local.set $c (i32.load8_u (i32.add (local.get $srcOff) (local.get $i))))
855
- (if (i32.and (i32.ge_u (local.get $c) (local.get $lo)) (i32.le_u (local.get $c) (local.get $hi)))
856
- (then (local.set $c (i32.add (local.get $c) (local.get $delta)))))
857
- (i32.store8 (i32.add (local.get $off) (local.get $i)) (local.get $c))
971
+ (local.set $sp64 (i64.or (local.get $sp64)
972
+ (i64.shl (i64.extend_i32_u (local.get $c)) (i64.mul (i64.extend_i32_u (local.get $i)) (i64.const 7)))))
858
973
  (local.set $i (i32.add (local.get $i) (i32.const 1)))
859
- (br $lh)))))
974
+ (br $lsso)))
975
+ (return (f64.reinterpret_i64 (i64.or
976
+ (i64.or
977
+ (i64.const ${ptrNanHex(PTR.STRING, LAYOUT.SSO_BIT)})
978
+ (i64.shl (i64.extend_i32_u (local.get $len)) (i64.const 42)))
979
+ (local.get $sp64))))))
980
+ (local.set $off (call $__alloc (i32.add (i32.const 4) (local.get $len))))
981
+ (i32.store (local.get $off) (local.get $len))
982
+ (local.set $off (i32.add (local.get $off) (i32.const 4)))
983
+ (local.set $srcOff (i32.wrap_i64 (i64.and (local.get $ptr) (i64.const ${LAYOUT.OFFSET_MASK}))))
984
+ (block $dh (loop $lh
985
+ (br_if $dh (i32.ge_s (local.get $i) (local.get $len)))
986
+ (local.set $c (i32.load8_u (i32.add (local.get $srcOff) (local.get $i))))
987
+ (if (i32.and (i32.ge_u (local.get $c) (local.get $lo)) (i32.le_u (local.get $c) (local.get $hi)))
988
+ (then (local.set $c (i32.add (local.get $c) (local.get $delta)))))
989
+ (i32.store8 (i32.add (local.get $off) (local.get $i)) (local.get $c))
990
+ (local.set $i (i32.add (local.get $i) (i32.const 1)))
991
+ (br $lh)))
860
992
  (call $__mkptr (i32.const ${PTR.STRING}) (i32.const 0) (local.get $off)))`)
861
993
 
862
994
  wat('__str_trim', `(func $__str_trim (param $ptr i64) (result f64)
@@ -919,6 +1051,9 @@ export default (ctx) => {
919
1051
  (local.get $len))
920
1052
  (local.set $i (i32.add (local.get $i) (i32.const 1)))
921
1053
  (br $loop)))
1054
+ ;; short ASCII result → SSO (invariant); the heap copy stays as arena garbage
1055
+ (if (i32.le_u (local.get $total) (i32.const ${MAX_SSO}))
1056
+ (then (return (call $__sso_norm (call $__mkptr (i32.const ${PTR.STRING}) (i32.const 0) (local.get $off))))))
922
1057
  (call $__mkptr (i32.const ${PTR.STRING}) (i32.const 0) (local.get $off)))`)
923
1058
 
924
1059
  // Coerce value to string: numbers → __ftoa, nullish → static strings,
@@ -984,21 +1119,54 @@ export default (ctx) => {
984
1119
  // only, no heap); fires whenever the upstream `'+'` couldn't fold at
985
1120
  // compile time (one or both operands runtime values). Critical for the
986
1121
  // identifier-style `'$' + x` / `prefix + s` patterns hot in parsers.
1122
+ // Both-SSO short result: splice the two 42-bit char payloads — no walk, no alloc.
987
1123
  const ssoResultFast = `
988
1124
  (if (i32.and
989
1125
  (i32.and
990
1126
  (i64.ne (i64.and (local.get $a) (i64.const ${SSO_BIT_I64})) (i64.const 0))
991
1127
  (i64.ne (i64.and (local.get $b) (i64.const ${SSO_BIT_I64})) (i64.const 0)))
992
- (i32.le_u (local.get $total) (i32.const 4)))
1128
+ (i32.le_u (local.get $total) (i32.const ${MAX_SSO})))
993
1129
  (then
994
- (return (call $__mkptr
995
- (i32.const ${PTR.STRING})
996
- (i32.or (i32.const ${LAYOUT.SSO_BIT}) (i32.shl (local.get $total) (i32.const ${SSO_LEN_SHIFT})))
997
- (i32.or
998
- (i32.wrap_i64 (i64.and (local.get $a) (i64.const 0xFFFFFFFF)))
999
- (i32.shl
1000
- (i32.wrap_i64 (i64.and (local.get $b) (i64.const 0xFFFFFFFF)))
1001
- (i32.mul (local.get $alen) (i32.const 7))))))))`
1130
+ (return (f64.reinterpret_i64 (i64.or
1131
+ (i64.or
1132
+ (i64.const ${ptrNanHex(PTR.STRING, LAYOUT.SSO_BIT)})
1133
+ (i64.shl (i64.extend_i32_u (local.get $total)) (i64.const 42)))
1134
+ (i64.or
1135
+ (i64.and (local.get $a) (i64.const ${SSO_CHAR_MASK}))
1136
+ (i64.shl
1137
+ (i64.and (local.get $b) (i64.const ${SSO_CHAR_MASK}))
1138
+ (i64.extend_i32_u (i32.mul (local.get $alen) (i32.const 7))))))))))`
1139
+
1140
+ // Mixed/heap-source short result: walk the ≤6 source bytes (SSO lane or heap
1141
+ // load per side), bail to the caller's heap path on a non-ASCII byte. Upholds
1142
+ // the module invariant for concat regardless of operand representation.
1143
+ const concatSsoPack = `
1144
+ (if (i32.le_u (local.get $total) (i32.const ${MAX_SSO}))
1145
+ (then (block $kspill
1146
+ (local.set $ksp (i64.const 0))
1147
+ (local.set $ki (i32.const 0))
1148
+ (local.set $kao (i32.wrap_i64 (i64.and (local.get $a) (i64.const ${LAYOUT.OFFSET_MASK}))))
1149
+ (local.set $kbo (i32.wrap_i64 (i64.and (local.get $b) (i64.const ${LAYOUT.OFFSET_MASK}))))
1150
+ (loop $kl
1151
+ (if (i32.lt_u (local.get $ki) (local.get $total))
1152
+ (then
1153
+ (local.set $kc (if (result i32) (i32.lt_u (local.get $ki) (local.get $alen))
1154
+ (then (if (result i32) (i64.ne (i64.and (local.get $a) (i64.const ${SSO_BIT_I64})) (i64.const 0))
1155
+ (then ${ssoCharWat('(local.get $a)', '(local.get $ki)')})
1156
+ (else (i32.load8_u (i32.add (local.get $kao) (local.get $ki))))))
1157
+ (else (if (result i32) (i64.ne (i64.and (local.get $b) (i64.const ${SSO_BIT_I64})) (i64.const 0))
1158
+ (then ${ssoCharWat('(local.get $b)', '(i32.sub (local.get $ki) (local.get $alen))')})
1159
+ (else (i32.load8_u (i32.add (local.get $kbo) (i32.sub (local.get $ki) (local.get $alen)))))))))
1160
+ (br_if $kspill (i32.ge_u (local.get $kc) (i32.const 0x80)))
1161
+ (local.set $ksp (i64.or (local.get $ksp)
1162
+ (i64.shl (i64.extend_i32_u (local.get $kc)) (i64.mul (i64.extend_i32_u (local.get $ki)) (i64.const 7)))))
1163
+ (local.set $ki (i32.add (local.get $ki) (i32.const 1)))
1164
+ (br $kl))))
1165
+ (return (f64.reinterpret_i64 (i64.or
1166
+ (i64.or
1167
+ (i64.const ${ptrNanHex(PTR.STRING, LAYOUT.SSO_BIT)})
1168
+ (i64.shl (i64.extend_i32_u (local.get $total)) (i64.const 42)))
1169
+ (local.get $ksp)))))))`
1002
1170
 
1003
1171
  const concatFast = !ctx.memory.shared && ctx.transform.alloc !== false ? `
1004
1172
  (local.set $ta (i32.wrap_i64 (i64.and (i64.shr_u (local.get $a) (i64.const ${LAYOUT.TAG_SHIFT})) (i64.const ${LAYOUT.TAG_MASK}))))
@@ -1022,9 +1190,27 @@ export default (ctx) => {
1022
1190
  (i32.add (local.get $aoff) (local.get $alen))
1023
1191
  (local.get $blen))
1024
1192
  (i32.store (i32.sub (local.get $aoff) (i32.const 4)) (local.get $total))
1193
+ ;; bytes changed in place — drop the cached hash (cell exists iff HCACHE bit)
1194
+ (if (i64.ne (i64.and (local.get $a) (i64.const ${HCACHE_BIT_I64})) (i64.const 0))
1195
+ (then (i32.store (i32.sub (local.get $aoff) (i32.const 8)) (i32.const 0))))
1025
1196
  (global.set $__heap (local.get $newHeap))
1026
1197
  (return (f64.reinterpret_i64 (local.get $a)))))` : ''
1027
1198
 
1199
+ // Always-fresh tail: allocate a new buffer and copy both operands. Shared by the
1200
+ // bump-extend concats (reached when `a` is NOT heap-top) and the `_fresh` variants
1201
+ // (which never bump-extend at all — emit routes a NON-self-accumulating `t = s + x`
1202
+ // here so it can't mutate the live `s` the way the heap-top extend would).
1203
+ // [hash=0 u32][len u32][bytes] + STR_HCACHE_BIT: __str_hash fills the hash cell on
1204
+ // first hash so repeated keying of a built string skips the byte-FNV walk.
1205
+ const allocCopyTail = `
1206
+ (local.set $off (call $__alloc (i32.add (i32.const 8) (local.get $total))))
1207
+ (i32.store (local.get $off) (i32.const 0))
1208
+ (i32.store offset=4 (local.get $off) (local.get $total))
1209
+ (local.set $off (i32.add (local.get $off) (i32.const 8)))
1210
+ (call $__str_copy (local.get $a) (local.get $off) (local.get $alen))
1211
+ (call $__str_copy (local.get $b) (i32.add (local.get $off) (local.get $alen)) (local.get $blen))
1212
+ (call $__mkptr (i32.const ${PTR.STRING}) (i32.const ${STR_HCACHE_BIT}) (local.get $off)))`
1213
+
1028
1214
  // Fused single-byte append: `buf += str[i]` lowers to this when both sides are
1029
1215
  // VAL.STRING and the rhs is a string-index. Skips __str_idx's 1-char SSO
1030
1216
  // construction and __str_concat's type-dispatch — byte goes directly from
@@ -1054,39 +1240,53 @@ export default (ctx) => {
1054
1240
  (call $__memgrow (local.get $newHeap))
1055
1241
  (i32.store8 (i32.add (local.get $aoff) (local.get $alen)) (local.get $byte))
1056
1242
  (i32.store (i32.sub (local.get $aoff) (i32.const 4)) (i32.add (local.get $alen) (i32.const 1)))
1243
+ ;; bytes changed in place — drop the cached hash (cell exists iff HCACHE bit)
1244
+ (if (i64.ne (i64.and (local.get $a) (i64.const ${HCACHE_BIT_I64})) (i64.const 0))
1245
+ (then (i32.store (i32.sub (local.get $aoff) (i32.const 8)) (i32.const 0))))
1057
1246
  (global.set $__heap (local.get $newHeap))
1058
1247
  (return (f64.reinterpret_i64 (local.get $a)))))))` : ''}
1059
- ;; SSO (STRING with SSO bit) with len < 4 and ASCII byte: pack into SSO without allocation
1248
+ ;; SSO (STRING with SSO bit) with len < ${MAX_SSO} and ASCII byte: pack into SSO without allocation.
1249
+ ;; NB: the aux test must be a BOOLEAN (i32.ne) — bitwise-ANDing the raw 0x4000 mask
1250
+ ;; result with the boolean (i32.eq …) yields 1 & 0x4000 = 0, silently killing the path.
1060
1251
  (if (i32.and
1061
1252
  (i32.eq (local.get $ta) (i32.const ${PTR.STRING}))
1062
- (i32.and
1063
- (i32.wrap_i64 (i64.shr_u (local.get $a) (i64.const ${LAYOUT.AUX_SHIFT})))
1064
- (i32.const ${LAYOUT.SSO_BIT})))
1253
+ (i32.ne
1254
+ (i32.and
1255
+ (i32.wrap_i64 (i64.shr_u (local.get $a) (i64.const ${LAYOUT.AUX_SHIFT})))
1256
+ (i32.const ${LAYOUT.SSO_BIT}))
1257
+ (i32.const 0)))
1065
1258
  (then
1066
1259
  (local.set $alen ${ssoLenWat('(local.get $a)')})
1067
1260
  (if (i32.and
1068
- (i32.lt_u (local.get $alen) (i32.const 4))
1261
+ (i32.lt_u (local.get $alen) (i32.const ${MAX_SSO}))
1069
1262
  (i32.lt_u (local.get $byte) (i32.const 0x80)))
1070
1263
  (then
1071
- (return (call $__mkptr
1072
- (i32.const ${PTR.STRING})
1073
- (i32.or (i32.const ${LAYOUT.SSO_BIT}) (i32.shl (i32.add (local.get $alen) (i32.const 1)) (i32.const ${SSO_LEN_SHIFT})))
1074
- (i32.or
1075
- (local.get $aoff)
1076
- (i32.shl (local.get $byte) (i32.mul (local.get $alen) (i32.const 7))))))))))
1264
+ (return (f64.reinterpret_i64 (i64.or
1265
+ (i64.or
1266
+ (i64.const ${ptrNanHex(PTR.STRING, LAYOUT.SSO_BIT)})
1267
+ (i64.shl (i64.extend_i32_u (i32.add (local.get $alen) (i32.const 1))) (i64.const 42)))
1268
+ (i64.or
1269
+ (i64.and (local.get $a) (i64.const ${SSO_CHAR_MASK}))
1270
+ (i64.shl (i64.extend_i32_u (local.get $byte)) (i64.extend_i32_u (i32.mul (local.get $alen) (i32.const 7))))))))))))
1077
1271
  ;; Slow path: allocate new heap STRING with original bytes + 1 new byte
1078
1272
  (local.set $alen (call $__str_byteLen (local.get $a)))
1079
1273
  (local.set $total (i32.add (local.get $alen) (i32.const 1)))
1080
- (local.set $off (call $__alloc (i32.add (i32.const 4) (local.get $total))))
1081
- (i32.store (local.get $off) (local.get $total))
1082
- (local.set $off (i32.add (local.get $off) (i32.const 4)))
1274
+ (local.set $off (call $__alloc (i32.add (i32.const 8) (local.get $total))))
1275
+ (i32.store (local.get $off) (i32.const 0))
1276
+ (i32.store offset=4 (local.get $off) (local.get $total))
1277
+ (local.set $off (i32.add (local.get $off) (i32.const 8)))
1083
1278
  (call $__str_copy (local.get $a) (local.get $off) (local.get $alen))
1084
1279
  (i32.store8 (i32.add (local.get $off) (local.get $alen)) (local.get $byte))
1085
- (call $__mkptr (i32.const ${PTR.STRING}) (i32.const 0) (local.get $off)))`)
1280
+ (call $__mkptr (i32.const ${PTR.STRING}) (i32.const ${STR_HCACHE_BIT}) (local.get $off)))`)
1086
1281
 
1282
+ // __str_concat / __str_concat_raw bump-EXTEND `a` in place when it is the heap-top own string
1283
+ // (the O(N) accumulator path). Emit calls these ONLY when the source is a self-accumulation
1284
+ // `x = x + …` (so the mutated `a` is dead-after-reassign) or when `a` is a provably-fresh
1285
+ // module-internal temporary; a plain `t = s + x` over a live `s` routes to the _fresh twins.
1087
1286
  wat('__str_concat', `(func $__str_concat (param $a i64) (param $b i64) (result f64)
1088
1287
  (local $alen i32) (local $blen i32) (local $total i32) (local $off i32)
1089
1288
  (local $ta i32) (local $aoff i32) (local $newHeap i32)
1289
+ (local $ki i32) (local $kc i32) (local $ksp i64) (local $kao i32) (local $kbo i32)
1090
1290
  ;; Coerce operands to strings if needed
1091
1291
  (local.set $a (call $__to_str (local.get $a)))
1092
1292
  (local.set $b (call $__to_str (local.get $b)))
@@ -1096,30 +1296,48 @@ export default (ctx) => {
1096
1296
  (if (i32.eqz (local.get $total))
1097
1297
  (then (return (call $__mkptr (i32.const ${PTR.STRING}) (i32.const ${LAYOUT.SSO_BIT}) (i32.const 0)))))
1098
1298
  ${ssoResultFast}
1099
- ${concatFast}
1100
- (local.set $off (call $__alloc (i32.add (i32.const 4) (local.get $total))))
1101
- (i32.store (local.get $off) (local.get $total))
1102
- (local.set $off (i32.add (local.get $off) (i32.const 4)))
1103
- (call $__str_copy (local.get $a) (local.get $off) (local.get $alen))
1104
- (call $__str_copy (local.get $b) (i32.add (local.get $off) (local.get $alen)) (local.get $blen))
1105
- (call $__mkptr (i32.const ${PTR.STRING}) (i32.const 0) (local.get $off)))`)
1299
+ ${concatSsoPack}
1300
+ ${concatFast}${allocCopyTail}`)
1106
1301
 
1107
1302
  wat('__str_concat_raw', `(func $__str_concat_raw (param $a i64) (param $b i64) (result f64)
1108
1303
  (local $alen i32) (local $blen i32) (local $total i32) (local $off i32)
1109
1304
  (local $ta i32) (local $aoff i32) (local $newHeap i32)
1305
+ (local $ki i32) (local $kc i32) (local $ksp i64) (local $kao i32) (local $kbo i32)
1110
1306
  (local.set $alen (call $__str_byteLen (local.get $a)))
1111
1307
  (local.set $blen (call $__str_byteLen (local.get $b)))
1112
1308
  (local.set $total (i32.add (local.get $alen) (local.get $blen)))
1113
1309
  (if (i32.eqz (local.get $total))
1114
1310
  (then (return (call $__mkptr (i32.const ${PTR.STRING}) (i32.const ${LAYOUT.SSO_BIT}) (i32.const 0)))))
1115
1311
  ${ssoResultFast}
1116
- ${concatFast}
1117
- (local.set $off (call $__alloc (i32.add (i32.const 4) (local.get $total))))
1118
- (i32.store (local.get $off) (local.get $total))
1119
- (local.set $off (i32.add (local.get $off) (i32.const 4)))
1120
- (call $__str_copy (local.get $a) (local.get $off) (local.get $alen))
1121
- (call $__str_copy (local.get $b) (i32.add (local.get $off) (local.get $alen)) (local.get $blen))
1122
- (call $__mkptr (i32.const ${PTR.STRING}) (i32.const 0) (local.get $off)))`)
1312
+ ${concatSsoPack}
1313
+ ${concatFast}${allocCopyTail}`)
1314
+
1315
+ // Non-mutating twins: same SSO-pair fast path, but NEVER bump-extend — always alloc+copy a fresh
1316
+ // buffer, leaving `a` untouched. The default for emit's user-level `+` (any `t = s + x` where the
1317
+ // result is not assigned straight back to `s`), so string immutability holds for live operands.
1318
+ wat('__str_concat_fresh', `(func $__str_concat_fresh (param $a i64) (param $b i64) (result f64)
1319
+ (local $alen i32) (local $blen i32) (local $total i32) (local $off i32)
1320
+ (local $ki i32) (local $kc i32) (local $ksp i64) (local $kao i32) (local $kbo i32)
1321
+ (local.set $a (call $__to_str (local.get $a)))
1322
+ (local.set $b (call $__to_str (local.get $b)))
1323
+ (local.set $alen (call $__str_byteLen (local.get $a)))
1324
+ (local.set $blen (call $__str_byteLen (local.get $b)))
1325
+ (local.set $total (i32.add (local.get $alen) (local.get $blen)))
1326
+ (if (i32.eqz (local.get $total))
1327
+ (then (return (call $__mkptr (i32.const ${PTR.STRING}) (i32.const ${LAYOUT.SSO_BIT}) (i32.const 0)))))
1328
+ ${ssoResultFast}
1329
+ ${concatSsoPack}${allocCopyTail}`)
1330
+
1331
+ wat('__str_concat_raw_fresh', `(func $__str_concat_raw_fresh (param $a i64) (param $b i64) (result f64)
1332
+ (local $alen i32) (local $blen i32) (local $total i32) (local $off i32)
1333
+ (local $ki i32) (local $kc i32) (local $ksp i64) (local $kao i32) (local $kbo i32)
1334
+ (local.set $alen (call $__str_byteLen (local.get $a)))
1335
+ (local.set $blen (call $__str_byteLen (local.get $b)))
1336
+ (local.set $total (i32.add (local.get $alen) (local.get $blen)))
1337
+ (if (i32.eqz (local.get $total))
1338
+ (then (return (call $__mkptr (i32.const ${PTR.STRING}) (i32.const ${LAYOUT.SSO_BIT}) (i32.const 0)))))
1339
+ ${ssoResultFast}
1340
+ ${concatSsoPack}${allocCopyTail}`)
1123
1341
 
1124
1342
  wat('__str_replace', `(func $__str_replace (param $str i64) (param $search i64) (param $repl i64) (result f64)
1125
1343
  (local $idx i32) (local $slen i32)
@@ -1314,7 +1532,7 @@ export default (ctx) => {
1314
1532
  wat('__str_pad', `(func $__str_pad (param $str i64) (param $target i32) (param $pad i64) (param $before i32) (result f64)
1315
1533
  (local $slen i32) (local $plen i32) (local $fill i32) (local $off i32) (local $i i32)
1316
1534
  (local $str_off i32) (local $pad_off i32)
1317
- (local $pbits i64) (local $poff i32) (local $psso i32) (local $sp i32) (local $sb i32)
1535
+ (local $pbits i64) (local $poff i32) (local $psso i32) (local $sp64 i64) (local $sb i32)
1318
1536
  (local.set $slen (call $__str_byteLen (local.get $str)))
1319
1537
  (if (i32.ge_s (local.get $slen) (local.get $target))
1320
1538
  (then (return (f64.reinterpret_i64 (local.get $str)))))
@@ -1342,23 +1560,28 @@ export default (ctx) => {
1342
1560
  (local.set $i (i32.add (local.get $i) (i32.const 1)))
1343
1561
  (br $l2)))
1344
1562
  ${!ctx.memory.shared ? `
1345
- ;; SSO + reclaim for ≤4 ASCII results: pack into the pointer and free this pad
1563
+ ;; SSO + reclaim for ≤${MAX_SSO} ASCII results: pack into the pointer and free this pad
1346
1564
  ;; allocation (at heap top) so padStart in a builder loop is allocation-neutral
1347
1565
  ;; — the accumulator stays at heap top and keeps bump-extending (O(n)).
1348
- (if (i32.le_u (local.get $target) (i32.const 4))
1566
+ (if (i32.le_u (local.get $target) (i32.const ${MAX_SSO}))
1349
1567
  (then
1350
1568
  (block $heap
1351
- (local.set $i (i32.const 0)) (local.set $sp (i32.const 0))
1569
+ (local.set $i (i32.const 0)) (local.set $sp64 (i64.const 0))
1352
1570
  (loop $pk
1353
1571
  (if (i32.lt_u (local.get $i) (local.get $target))
1354
1572
  (then
1355
1573
  (local.set $sb (i32.load8_u (i32.add (local.get $off) (local.get $i))))
1356
1574
  (br_if $heap (i32.ge_u (local.get $sb) (i32.const 0x80)))
1357
- (local.set $sp (i32.or (local.get $sp) (i32.shl (local.get $sb) (i32.mul (local.get $i) (i32.const 7)))))
1575
+ (local.set $sp64 (i64.or (local.get $sp64)
1576
+ (i64.shl (i64.extend_i32_u (local.get $sb)) (i64.mul (i64.extend_i32_u (local.get $i)) (i64.const 7)))))
1358
1577
  (local.set $i (i32.add (local.get $i) (i32.const 1)))
1359
1578
  (br $pk))))
1360
1579
  (global.set $__heap (i32.sub (local.get $off) (i32.const 4)))
1361
- (return (call $__mkptr (i32.const ${PTR.STRING}) (i32.or (i32.const ${LAYOUT.SSO_BIT}) (i32.shl (local.get $target) (i32.const 10))) (local.get $sp))))))` : ''}
1580
+ (return (f64.reinterpret_i64 (i64.or
1581
+ (i64.or
1582
+ (i64.const ${ptrNanHex(PTR.STRING, LAYOUT.SSO_BIT)})
1583
+ (i64.shl (i64.extend_i32_u (local.get $target)) (i64.const 42)))
1584
+ (local.get $sp64)))))))` : ''}
1362
1585
  (call $__mkptr (i32.const ${PTR.STRING}) (i32.const 0) (local.get $off)))`)
1363
1586
 
1364
1587
  // Base helpers (__sso_char/__str_char/__char_at/__str_byteLen) are referenced
@@ -1374,6 +1597,17 @@ export default (ctx) => {
1374
1597
  // the receiver type can't be statically inferred (e.g. a callback param).
1375
1598
  bind('.string:toString', (str) => asF64(emit(str)))
1376
1599
  bind('.string:valueOf', (str) => asF64(emit(str)))
1600
+ // String.prototype.normalize — identity: every normalization form (NFC/NFD/
1601
+ // NFKC/NFKD) is the identity on ASCII, and jz strings are UTF-8 bytes with
1602
+ // ASCII-only case/space semantics (README divergences). No Unicode tables.
1603
+ // The form argument is ignored (a bogus form's RangeError needs the same
1604
+ // tables to justify checking — out of scope with them). Generic twin
1605
+ // ToString-coerces an untyped receiver (spec step 1), like .toString.
1606
+ bind('.string:normalize', (str) => asF64(emit(str)))
1607
+ bind('.normalize', (val) => {
1608
+ inc('__to_str')
1609
+ return typed(['f64.reinterpret_i64', ['call', '$__to_str', asI64(emit(val))]], 'f64')
1610
+ })
1377
1611
  bind('.toString', (val) => {
1378
1612
  inc('__to_str')
1379
1613
  return typed(['f64.reinterpret_i64', ['call', '$__to_str', asI64(emit(val))]], 'f64')
@@ -1621,26 +1855,57 @@ export default (ctx) => {
1621
1855
  // A VAL.BOOL part rides the 0/1 carrier, so __to_str would render "1"/"0".
1622
1856
  // bool selects the interned "true"/"false" literal (constant-folded
1623
1857
  // when the operand is known); every other part goes through __to_str.
1624
- const partStrI64 = (p) => valTypeOf(p) === VAL.BOOL ? asI64(bool(p)) : toStrI64(p, emit(p))
1858
+ // `v` is the part's pre-emitted value when the caller already emitted it.
1859
+ const partStrI64 = (p, v) => valTypeOf(p) === VAL.BOOL ? asI64(bool(p)) : toStrI64(p, v ?? emit(p))
1625
1860
 
1626
1861
  bind('strcat', (...parts) => {
1627
- inc('__to_str', '__str_byteLen', '__alloc', '__mkptr', '__str_copy')
1628
1862
  if (!parts.length) return mkPtrIR(PTR.STRING, LAYOUT.SSO_BIT, 0)
1629
1863
  if (parts.length === 1) return typed(['f64.reinterpret_i64', partStrI64(parts[0])], 'f64')
1864
+ inc('__alloc', '__mkptr', '__sso_norm')
1865
+
1866
+ // LITERAL ASCII parts (the serializer separators: ',', '\n', 'x=' …) carry their
1867
+ // bytes and length at compile time: no value temp, no __str_byteLen call, no
1868
+ // __str_copy call — the length const-folds into the total and the bytes store
1869
+ // directly at dst (grouped 4/2/1-wide). Profiled on strbuild: the copy+len
1870
+ // calls on 1-6 byte parts were 38.7% of the row — pure call overhead.
1871
+ const litBytes = (p) => {
1872
+ if (!Array.isArray(p) || p[0] !== 'str' || typeof p[1] !== 'string' || p[1].length === 0) return null
1873
+ const s = p[1]
1874
+ for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) > 0x7f) return null
1875
+ return s
1876
+ }
1877
+ const lits = parts.map(litBytes)
1630
1878
 
1631
- const vals = parts.map(() => temp('s'))
1632
- const lens = parts.map(() => tempI32('sl'))
1879
+ const vals = [], nums = []
1880
+ const lens = parts.map((_, i) => lits[i] != null ? null : tempI32('sl'))
1633
1881
  const total = tempI32('st')
1634
1882
  const off = tempI32('so')
1635
1883
  const dst = tempI32('sd')
1636
1884
  const ir = []
1885
+ let litTotal = 0
1637
1886
 
1638
1887
  for (let i = 0; i < parts.length; i++) {
1639
- ir.push(['local.set', `$${vals[i]}`, ['f64.reinterpret_i64', partStrI64(parts[i])]])
1888
+ if (lits[i] != null) { litTotal += lits[i].length; continue }
1889
+ const vt = valTypeOf(parts[i])
1890
+ const v = vt === VAL.BOOL ? null : emit(parts[i])
1891
+ // i32-PROVEN part (exactly toStrI64's __i32_to_str class): keep the raw value,
1892
+ // not a temp string — __ilen joins the total and __itoa_s renders the digits
1893
+ // directly at dst. Drops the per-number __i32_to_str (alloc+itoa+mkstr),
1894
+ // __str_byteLen and __str_copy — the whole temp-string round trip.
1895
+ if ((vt === VAL.NUMBER || vt == null) && v.type === 'i32' && v.ptrKind == null) {
1896
+ inc('__ilen', '__itoa_s')
1897
+ nums[i] = tempI32('sn')
1898
+ ir.push(['local.set', `$${nums[i]}`, v])
1899
+ ir.push(['local.set', `$${lens[i]}`, ['call', '$__ilen', ['local.get', `$${nums[i]}`]]])
1900
+ continue
1901
+ }
1902
+ inc('__str_byteLen', '__str_copy')
1903
+ vals[i] = temp('s')
1904
+ ir.push(['local.set', `$${vals[i]}`, ['f64.reinterpret_i64', partStrI64(parts[i], v)]])
1640
1905
  ir.push(['local.set', `$${lens[i]}`, ['call', '$__str_byteLen', ['i64.reinterpret_f64', ['local.get', `$${vals[i]}`]]]])
1641
1906
  }
1642
- ir.push(['local.set', `$${total}`, ['i32.const', 0]])
1643
- for (const len of lens)
1907
+ ir.push(['local.set', `$${total}`, ['i32.const', litTotal]])
1908
+ for (const len of lens) if (len != null)
1644
1909
  ir.push(['local.set', `$${total}`, ['i32.add', ['local.get', `$${total}`], ['local.get', `$${len}`]]])
1645
1910
  const alloc = [
1646
1911
  ['local.set', `$${off}`, ['call', '$__alloc', ['i32.add', ['i32.const', 4], ['local.get', `$${total}`]]]],
@@ -1648,14 +1913,43 @@ export default (ctx) => {
1648
1913
  ['local.set', `$${off}`, ['i32.add', ['local.get', `$${off}`], ['i32.const', 4]]],
1649
1914
  ['local.set', `$${dst}`, ['local.get', `$${off}`]],
1650
1915
  ]
1916
+ const dstAt = (k) => k ? ['i32.add', ['local.get', `$${dst}`], ['i32.const', k]] : ['local.get', `$${dst}`]
1651
1917
  for (let i = 0; i < parts.length; i++) {
1918
+ if (lits[i] != null) {
1919
+ const s = lits[i]
1920
+ let k = 0 // grouped little-endian stores: 4-byte words, 2-byte tail, then 1
1921
+ for (; k + 4 <= s.length; k += 4)
1922
+ alloc.push(['i32.store', dstAt(k), ['i32.const',
1923
+ (s.charCodeAt(k) | (s.charCodeAt(k + 1) << 8) | (s.charCodeAt(k + 2) << 16) | (s.charCodeAt(k + 3) << 24)) | 0]])
1924
+ if (k + 2 <= s.length) {
1925
+ alloc.push(['i32.store16', dstAt(k), ['i32.const', s.charCodeAt(k) | (s.charCodeAt(k + 1) << 8)]])
1926
+ k += 2
1927
+ }
1928
+ if (k < s.length)
1929
+ alloc.push(['i32.store8', dstAt(k), ['i32.const', s.charCodeAt(k)]])
1930
+ alloc.push(['local.set', `$${dst}`, ['i32.add', ['local.get', `$${dst}`], ['i32.const', s.length]]])
1931
+ continue
1932
+ }
1933
+ if (nums[i] != null) {
1934
+ // digits render at dst; the returned byte count (== $lens[i]) advances it
1935
+ alloc.push(['local.set', `$${dst}`, ['i32.add',
1936
+ ['call', '$__itoa_s', ['local.get', `$${nums[i]}`], ['local.get', `$${dst}`]], ['local.get', `$${dst}`]]])
1937
+ continue
1938
+ }
1652
1939
  alloc.push(['call', '$__str_copy', ['i64.reinterpret_f64', ['local.get', `$${vals[i]}`]], ['local.get', `$${dst}`], ['local.get', `$${lens[i]}`]])
1653
1940
  alloc.push(['local.set', `$${dst}`, ['i32.add', ['local.get', `$${dst}`], ['local.get', `$${lens[i]}`]]])
1654
1941
  }
1655
- alloc.push(['call', '$__mkptr', ['i32.const', PTR.STRING], ['i32.const', 0], ['local.get', `$${off}`]])
1656
- ir.push(['if', ['result', 'f64'], ['i32.eqz', ['local.get', `$${total}`]],
1657
- ['then', mkPtrIR(PTR.STRING, LAYOUT.SSO_BIT, 0)],
1658
- ['else', ['block', ['result', 'f64'], ...alloc]]])
1942
+ // ≤6-ASCII template results (`\`$\${n}\`` name-building) must SSO-normalize —
1943
+ // the module invariant; a leaked short heap string breaks bare-i64.eq ===.
1944
+ alloc.push(['call', '$__sso_norm',
1945
+ ['call', '$__mkptr', ['i32.const', PTR.STRING], ['i32.const', 0], ['local.get', `$${off}`]]])
1946
+ // A non-empty literal or a number part (≥1 digit) proves the result non-empty —
1947
+ // skip the empty-total branch entirely.
1948
+ ir.push(litTotal > 0 || nums.some(t => t != null)
1949
+ ? ['block', ['result', 'f64'], ...alloc]
1950
+ : ['if', ['result', 'f64'], ['i32.eqz', ['local.get', `$${total}`]],
1951
+ ['then', mkPtrIR(PTR.STRING, LAYOUT.SSO_BIT, 0)],
1952
+ ['else', ['block', ['result', 'f64'], ...alloc]]])
1659
1953
  return typed(['block', ['result', 'f64'], ...ir], 'f64')
1660
1954
  })
1661
1955
 
@@ -1733,6 +2027,44 @@ export default (ctx) => {
1733
2027
  (i32.store8 (i32.add (local.get $off) (i32.const 4)) (local.get $b))
1734
2028
  (call $__mkptr (i32.const ${PTR.STRING}) (i32.const 0) (i32.add (local.get $off) (i32.const 4))))))`)
1735
2029
 
2030
+ // Normalize a fresh plain-heap STRING to SSO when its content is ≤${MAX_SSO} ASCII
2031
+ // bytes — the epilogue every string PRODUCER that hand-writes heap bytes (number/
2032
+ // JSON/URI formatting, byte decode, repeat) routes through to uphold the module
2033
+ // invariant. Non-STRING values, SSO, slices, long or non-ASCII strings pass through
2034
+ // untouched. Pure loads, no calls — cheap enough for every producer return.
2035
+ wat('__sso_norm', `(func $__sso_norm (param $s f64) (result f64)
2036
+ (local $b i64) (local $len i32) (local $off i32) (local $i i32) (local $c i32) (local $sp i64)
2037
+ (local.set $b (i64.reinterpret_f64 (local.get $s)))
2038
+ ;; plain heap STRING only: tag=STRING, SSO/SLICE clear, header present
2039
+ (if (i32.eqz (i32.and
2040
+ (i32.eq
2041
+ (i32.wrap_i64 (i64.and (i64.shr_u (local.get $b) (i64.const ${LAYOUT.TAG_SHIFT})) (i64.const ${LAYOUT.TAG_MASK})))
2042
+ (i32.const ${PTR.STRING}))
2043
+ (i32.and
2044
+ (f64.ne (local.get $s) (local.get $s))
2045
+ (i64.eqz (i64.and (local.get $b) (i64.const ${SSO_SLICE_I64}))))))
2046
+ (then (return (local.get $s))))
2047
+ (local.set $off (i32.wrap_i64 (i64.and (local.get $b) (i64.const ${LAYOUT.OFFSET_MASK}))))
2048
+ (if (i32.lt_u (local.get $off) (i32.const 4)) (then (return (local.get $s))))
2049
+ (local.set $len (i32.load (i32.sub (local.get $off) (i32.const 4))))
2050
+ (if (i32.gt_u (local.get $len) (i32.const ${MAX_SSO})) (then (return (local.get $s))))
2051
+ (local.set $sp (i64.const 0))
2052
+ (block $spill
2053
+ (block $d (loop $l
2054
+ (br_if $d (i32.ge_u (local.get $i) (local.get $len)))
2055
+ (local.set $c (i32.load8_u (i32.add (local.get $off) (local.get $i))))
2056
+ (br_if $spill (i32.ge_u (local.get $c) (i32.const 0x80)))
2057
+ (local.set $sp (i64.or (local.get $sp)
2058
+ (i64.shl (i64.extend_i32_u (local.get $c)) (i64.mul (i64.extend_i32_u (local.get $i)) (i64.const 7)))))
2059
+ (local.set $i (i32.add (local.get $i) (i32.const 1)))
2060
+ (br $l)))
2061
+ (return (f64.reinterpret_i64 (i64.or
2062
+ (i64.or
2063
+ (i64.const ${ptrNanHex(PTR.STRING, LAYOUT.SSO_BIT)})
2064
+ (i64.shl (i64.extend_i32_u (local.get $len)) (i64.const 42)))
2065
+ (local.get $sp)))))
2066
+ (local.get $s))`)
2067
+
1736
2068
  // String.fromCharCode(...codes) — variadic; each arg is ToUint16(ToNumber(code))
1737
2069
  // → a 1-byte string, concatenated left to right (mirrors String.fromCodePoint).
1738
2070
  bind('String.fromCharCode', (...codes) => {
@@ -1857,7 +2189,7 @@ export default (ctx) => {
1857
2189
  (local.set $i (i32.add (local.get $i) (i32.const 1)))
1858
2190
  (br $loop)))
1859
2191
  (i32.store (local.get $base) (local.get $j))
1860
- (call $__mkptr (i32.const ${PTR.STRING}) (i32.const 0) (local.get $out)))`)
2192
+ (call $__sso_norm (call $__mkptr (i32.const ${PTR.STRING}) (i32.const 0) (local.get $out))))`)
1861
2193
 
1862
2194
  bind('encodeURIComponent', (value) => {
1863
2195
  inc('__encodeURIComponent')
@@ -1950,7 +2282,7 @@ export default (ctx) => {
1950
2282
  (local.set $outLen (i32.add (local.get $outLen) (i32.const 1)))))
1951
2283
  (br $loop)))
1952
2284
  (i32.store (local.get $base) (local.get $outLen))
1953
- (call $__mkptr (i32.const ${PTR.STRING}) (i32.const 0) (local.get $dst)))`)
2285
+ (call $__sso_norm (call $__mkptr (i32.const ${PTR.STRING}) (i32.const 0) (local.get $dst))))`)
1954
2286
 
1955
2287
  bind('decodeURIComponent', (value) => {
1956
2288
  ctx.runtime.throws = true
@@ -2046,6 +2378,9 @@ export default (ctx) => {
2046
2378
  (i32.store (local.get $dst) (local.get $len))
2047
2379
  (local.set $dst (i32.add (local.get $dst) (i32.const 4)))
2048
2380
  (memory.copy (local.get $dst) (local.get $off) (local.get $len))
2381
+ ;; short ASCII decode → SSO (invariant)
2382
+ (if (i32.le_u (local.get $len) (i32.const ${MAX_SSO}))
2383
+ (then (return (call $__sso_norm (call $__mkptr (i32.const ${PTR.STRING}) (i32.const 0) (local.get $dst))))))
2049
2384
  (call $__mkptr (i32.const ${PTR.STRING}) (i32.const 0) (local.get $dst)))`)
2050
2385
 
2051
2386
  bind('.decode', (obj, arr) => {