@tishlang/tish 2.12.0 → 2.16.13

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 (71) hide show
  1. package/Cargo.toml +3 -0
  2. package/bin/tish +0 -0
  3. package/crates/tish/Cargo.toml +1 -1
  4. package/crates/tish/tests/integration_test.rs +80 -0
  5. package/crates/tish_ast/src/ast.rs +10 -0
  6. package/crates/tish_builtins/src/array.rs +529 -56
  7. package/crates/tish_builtins/src/collections.rs +114 -40
  8. package/crates/tish_builtins/src/string.rs +95 -8
  9. package/crates/tish_bytecode/src/chunk.rs +7 -0
  10. package/crates/tish_bytecode/src/compiler.rs +560 -64
  11. package/crates/tish_bytecode/src/lib.rs +1 -1
  12. package/crates/tish_bytecode/src/opcode.rs +154 -2
  13. package/crates/tish_bytecode/src/serialize.rs +2 -0
  14. package/crates/tish_bytecode/tests/append_local_string_builder.rs +63 -0
  15. package/crates/tish_bytecode/tests/math_unary_intrinsic.rs +47 -0
  16. package/crates/tish_compile/src/codegen.rs +17736 -5269
  17. package/crates/tish_compile/src/infer.rs +1707 -190
  18. package/crates/tish_compile/src/lib.rs +29 -4
  19. package/crates/tish_compile/src/resolve.rs +66 -5
  20. package/crates/tish_compile/src/types.rs +224 -28
  21. package/crates/tish_compile/tests/dump_codegen.rs +21 -0
  22. package/crates/tish_compile/tests/perf_codegen_169_173.rs +8 -17
  23. package/crates/tish_compile/tests/perf_codegen_173_part3.rs +61 -0
  24. package/crates/tish_compile/tests/perf_codegen_174.rs +160 -0
  25. package/crates/tish_compile/tests/perf_codegen_175.rs +190 -0
  26. package/crates/tish_compile/tests/perf_codegen_176.rs +60 -0
  27. package/crates/tish_compile/tests/perf_codegen_177.rs +167 -0
  28. package/crates/tish_compile/tests/perf_codegen_178.rs +114 -0
  29. package/crates/tish_compile/tests/perf_codegen_178_rec.rs +124 -0
  30. package/crates/tish_compile/tests/perf_codegen_181.rs +36 -0
  31. package/crates/tish_compile/tests/perf_codegen_320.rs +211 -0
  32. package/crates/tish_compile/tests/perf_codegen_module_const_forof.rs +40 -0
  33. package/crates/tish_compile_js/src/codegen.rs +33 -0
  34. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +100 -2
  35. package/crates/tish_core/Cargo.toml +4 -0
  36. package/crates/tish_core/src/json.rs +104 -5
  37. package/crates/tish_core/src/lib.rs +174 -0
  38. package/crates/tish_core/src/shape.rs +4 -2
  39. package/crates/tish_core/src/value.rs +565 -35
  40. package/crates/tish_core/src/vmref.rs +14 -0
  41. package/crates/tish_eval/src/eval.rs +675 -76
  42. package/crates/tish_eval/src/natives.rs +19 -0
  43. package/crates/tish_eval/src/value.rs +76 -21
  44. package/crates/tish_ffi/src/lib.rs +11 -1
  45. package/crates/tish_ffi/tests/double_free.rs +35 -0
  46. package/crates/tish_fmt/src/lib.rs +75 -1
  47. package/crates/tish_lexer/src/lib.rs +76 -0
  48. package/crates/tish_lexer/src/token.rs +4 -0
  49. package/crates/tish_lint/src/lib.rs +126 -0
  50. package/crates/tish_lsp/README.md +2 -1
  51. package/crates/tish_lsp/src/main.rs +378 -28
  52. package/crates/tish_native/src/build.rs +41 -0
  53. package/crates/tish_parser/Cargo.toml +4 -0
  54. package/crates/tish_parser/src/lib.rs +27 -0
  55. package/crates/tish_parser/src/parser.rs +302 -20
  56. package/crates/tish_resolve/src/lib.rs +9 -0
  57. package/crates/tish_runtime/Cargo.toml +5 -0
  58. package/crates/tish_runtime/src/http.rs +28 -10
  59. package/crates/tish_runtime/src/http_fetch.rs +150 -1
  60. package/crates/tish_runtime/src/http_prefork.rs +72 -11
  61. package/crates/tish_runtime/src/lib.rs +523 -42
  62. package/crates/tish_runtime/src/timers.rs +49 -2
  63. package/crates/tish_ui/src/jsx.rs +3 -0
  64. package/crates/tish_vm/src/jit.rs +2514 -117
  65. package/crates/tish_vm/src/vm.rs +1227 -182
  66. package/package.json +1 -1
  67. package/platform/darwin-arm64/tish +0 -0
  68. package/platform/darwin-x64/tish +0 -0
  69. package/platform/linux-arm64/tish +0 -0
  70. package/platform/linux-x64/tish +0 -0
  71. package/platform/win32-x64/tish.exe +0 -0
@@ -27,19 +27,49 @@ use tishlang_core::{
27
27
  /// instead and is picked up at the next call site (or the top-level boundary).
28
28
  const PENDING_THROW_SENTINEL: &str = "\u{1}__tish_pending_throw__";
29
29
 
30
- thread_local! {
31
- static VM_PENDING_THROW: std::cell::RefCell<Option<Value>> =
32
- const { std::cell::RefCell::new(None) };
33
- }
34
-
30
+ // #303 — the VM shares the one pending-throw slot defined in `tishlang_core`, so the array builtins
31
+ // it calls (`tishlang_builtins::array`) can poll the same slot and so native + VM agree. These thin
32
+ // wrappers keep every VM call site unchanged. `core::set_pending_throw` is first-throw-wins, but the
33
+ // VM only sets (one site) after draining at a try boundary, so behavior is unchanged.
35
34
  fn set_pending_throw(v: Value) {
36
- VM_PENDING_THROW.with(|c| *c.borrow_mut() = Some(v));
35
+ tishlang_core::set_pending_throw(v);
37
36
  }
38
37
  fn take_pending_throw() -> Option<Value> {
39
- VM_PENDING_THROW.with(|c| c.borrow_mut().take())
38
+ tishlang_core::take_pending_throw()
40
39
  }
41
40
  fn pending_throw_is_set() -> bool {
42
- VM_PENDING_THROW.with(|c| c.borrow().is_some())
41
+ tishlang_core::has_pending_throw()
42
+ }
43
+
44
+ // The recursion ceiling + trip error moved to `tishlang_core` (#381) so the native backend's
45
+ // boxed-call guard shares one implementation with the VM. Same semantics: thread-local ceiling
46
+ // lazily read from `TISH_MAX_CALL_DEPTH` (default 20000), `{name, message}` RangeError.
47
+ use tishlang_core::{max_call_depth, stack_overflow_error};
48
+
49
+ /// Headroom (bytes) a self-recursive JIT'd function leaves below the real stack bottom before it
50
+ /// bails (#381). Must cover the bail path plus building the `RangeError` back in the VM; 256 KiB is
51
+ /// comfortably more than either needs while still far larger than a single f64-ABI recursion frame.
52
+ const RECUR_STACK_MARGIN: usize = 256 * 1024;
53
+
54
+ /// OSR back-edge count at which a hot top-level loop is first offered to the region JIT (#190). High
55
+ /// enough that the compile + first-attempt cost is amortized over a genuinely hot loop.
56
+ #[cfg(not(target_arch = "wasm32"))]
57
+ const OSR_THRESHOLD: u32 = 10_000;
58
+ /// After the first attempt, retry OSR every this-many back-edges — only relevant to the live-in-miss
59
+ /// path (a numeric loop compiles and consumes itself on the first attempt). Keeps the re-check off the
60
+ /// per-iteration hot path.
61
+ #[cfg(not(target_arch = "wasm32"))]
62
+ const OSR_RETRY: u32 = 50_000;
63
+
64
+ /// Outcome of an OSR attempt (#190).
65
+ #[cfg(not(target_arch = "wasm32"))]
66
+ enum OsrResult {
67
+ /// Ran the loop natively; resume interpreting at this chunk `ip` (the loop exit).
68
+ Compiled(usize),
69
+ /// A live-in slot is non-numeric; keep interpreting (may become eligible later → retry).
70
+ LiveInMiss,
71
+ /// The region is not a pure-numeric slot loop; give up on this loop (negative-cached).
72
+ NotCompilable,
43
73
  }
44
74
 
45
75
  /// Append the source location of the instruction at `off` to a runtime-error message, e.g.
@@ -199,13 +229,17 @@ fn get_builtin_export(enabled: &HashSet<String>, spec: &str, export_name: &str)
199
229
  Value::Function(_) => raw,
200
230
  Value::Object(ref obj) => {
201
231
  let obj_ref = obj.borrow();
202
- if let Some(Value::Function(on_worker)) =
203
- obj_ref.strings.get(&std::sync::Arc::from("onWorker")).cloned()
232
+ if let Some(Value::Function(on_worker)) = obj_ref
233
+ .strings
234
+ .get(&std::sync::Arc::from("onWorker"))
235
+ .cloned()
204
236
  {
205
237
  let args_for_init = [Value::Number(0.0)];
206
238
  on_worker.call(&args_for_init)
207
- } else if let Some(h) =
208
- obj_ref.strings.get(&std::sync::Arc::from("handler")).cloned()
239
+ } else if let Some(h) = obj_ref
240
+ .strings
241
+ .get(&std::sync::Arc::from("handler"))
242
+ .cloned()
209
243
  {
210
244
  h
211
245
  } else {
@@ -263,8 +297,14 @@ fn get_builtin_export(enabled: &HashSet<String>, spec: &str, export_name: &str)
263
297
  "exec" => Some(Value::native(|args: &[Value]| {
264
298
  tishlang_runtime::process_exec(args)
265
299
  })),
300
+ "execFile" => Some(Value::native(|args: &[Value]| {
301
+ tishlang_runtime::process_exec_file(args)
302
+ })),
266
303
  "argv" => Some(Value::Array(VmRef::new(
267
- tishlang_core::process_argv().into_iter().map(|s| Value::String(s.into())).collect(),
304
+ tishlang_core::process_argv()
305
+ .into_iter()
306
+ .map(|s| Value::String(s.into()))
307
+ .collect(),
268
308
  ))),
269
309
  "env" => Some(value_object_from_map(
270
310
  std::env::vars()
@@ -285,10 +325,17 @@ fn get_builtin_export(enabled: &HashSet<String>, spec: &str, export_name: &str)
285
325
  "exec".into(),
286
326
  Value::native(|args: &[Value]| tishlang_runtime::process_exec(args)),
287
327
  );
328
+ m.insert(
329
+ "execFile".into(),
330
+ Value::native(|args: &[Value]| tishlang_runtime::process_exec_file(args)),
331
+ );
288
332
  m.insert(
289
333
  "argv".into(),
290
334
  Value::Array(VmRef::new(
291
- tishlang_core::process_argv().into_iter().map(|s| Value::String(s.into())).collect(),
335
+ tishlang_core::process_argv()
336
+ .into_iter()
337
+ .map(|s| Value::String(s.into()))
338
+ .collect(),
292
339
  )),
293
340
  );
294
341
  m.insert(
@@ -331,8 +378,12 @@ fn get_builtin_export(enabled: &HashSet<String>, spec: &str, export_name: &str)
331
378
  #[cfg(feature = "tty")]
332
379
  if spec == "tish:tty" && cap_allows(enabled, "tty") {
333
380
  return match export_name {
334
- "size" => Some(Value::native(|args: &[Value]| tishlang_runtime::tty_size(args))),
335
- "isTTY" => Some(Value::native(|args: &[Value]| tishlang_runtime::tty_is_tty(args))),
381
+ "size" => Some(Value::native(|args: &[Value]| {
382
+ tishlang_runtime::tty_size(args)
383
+ })),
384
+ "isTTY" => Some(Value::native(|args: &[Value]| {
385
+ tishlang_runtime::tty_is_tty(args)
386
+ })),
336
387
  "setRawMode" => Some(Value::native(|args: &[Value]| {
337
388
  tishlang_runtime::tty_set_raw_mode(args)
338
389
  })),
@@ -342,7 +393,9 @@ fn get_builtin_export(enabled: &HashSet<String>, spec: &str, export_name: &str)
342
393
  "leaveAltScreen" => Some(Value::native(|args: &[Value]| {
343
394
  tishlang_runtime::tty_leave_alt_screen(args)
344
395
  })),
345
- "read" => Some(Value::native(|args: &[Value]| tishlang_runtime::tty_read(args))),
396
+ "read" => Some(Value::native(|args: &[Value]| {
397
+ tishlang_runtime::tty_read(args)
398
+ })),
346
399
  "readLine" => Some(Value::native(|args: &[Value]| {
347
400
  tishlang_runtime::tty_read_line(args)
348
401
  })),
@@ -642,10 +695,7 @@ fn init_globals(enabled: &HashSet<String>) -> ObjectMap {
642
695
  Value::String(v.type_name().into())
643
696
  }),
644
697
  );
645
- g.insert(
646
- "Symbol".into(),
647
- tishlang_builtins::symbol::symbol_object(),
648
- );
698
+ g.insert("Symbol".into(), tishlang_builtins::symbol::symbol_object());
649
699
 
650
700
  // Date - full constructor (new Date(...)) plus statics now()/parse()/UTC().
651
701
  g.insert(
@@ -666,14 +716,38 @@ fn init_globals(enabled: &HashSet<String>) -> ObjectMap {
666
716
  "Float64Array",
667
717
  tishlang_builtins::typedarrays::float64_array_constructor_value as fn() -> Value,
668
718
  ),
669
- ("Float32Array", tishlang_builtins::typedarrays::float32_array_constructor_value),
670
- ("Int8Array", tishlang_builtins::typedarrays::int8_array_constructor_value),
671
- ("Uint8Array", tishlang_builtins::typedarrays::uint8_array_constructor_value),
672
- ("Uint8ClampedArray", tishlang_builtins::typedarrays::uint8_clamped_array_constructor_value),
673
- ("Int16Array", tishlang_builtins::typedarrays::int16_array_constructor_value),
674
- ("Uint16Array", tishlang_builtins::typedarrays::uint16_array_constructor_value),
675
- ("Int32Array", tishlang_builtins::typedarrays::int32_array_constructor_value),
676
- ("Uint32Array", tishlang_builtins::typedarrays::uint32_array_constructor_value),
719
+ (
720
+ "Float32Array",
721
+ tishlang_builtins::typedarrays::float32_array_constructor_value,
722
+ ),
723
+ (
724
+ "Int8Array",
725
+ tishlang_builtins::typedarrays::int8_array_constructor_value,
726
+ ),
727
+ (
728
+ "Uint8Array",
729
+ tishlang_builtins::typedarrays::uint8_array_constructor_value,
730
+ ),
731
+ (
732
+ "Uint8ClampedArray",
733
+ tishlang_builtins::typedarrays::uint8_clamped_array_constructor_value,
734
+ ),
735
+ (
736
+ "Int16Array",
737
+ tishlang_builtins::typedarrays::int16_array_constructor_value,
738
+ ),
739
+ (
740
+ "Uint16Array",
741
+ tishlang_builtins::typedarrays::uint16_array_constructor_value,
742
+ ),
743
+ (
744
+ "Int32Array",
745
+ tishlang_builtins::typedarrays::int32_array_constructor_value,
746
+ ),
747
+ (
748
+ "Uint32Array",
749
+ tishlang_builtins::typedarrays::uint32_array_constructor_value,
750
+ ),
677
751
  ] {
678
752
  g.insert(name.into(), ctor());
679
753
  }
@@ -683,7 +757,10 @@ fn init_globals(enabled: &HashSet<String>) -> ObjectMap {
683
757
  );
684
758
  // Error constructors (issue #60): `new Error(msg)` / `Error(msg)` → `{ name, message }`.
685
759
  for name in ["Error", "TypeError", "RangeError", "SyntaxError"] {
686
- g.insert(name.into(), construct_builtin::error_constructor_value(name));
760
+ g.insert(
761
+ name.into(),
762
+ construct_builtin::error_constructor_value(name),
763
+ );
687
764
  }
688
765
 
689
766
  // Object methods - delegate to tishlang_builtins::globals
@@ -785,10 +862,17 @@ fn init_globals(enabled: &HashSet<String>) -> ObjectMap {
785
862
  "exec".into(),
786
863
  Value::native(|args: &[Value]| tishlang_runtime::process_exec(args)),
787
864
  );
865
+ process_obj.insert(
866
+ "execFile".into(),
867
+ Value::native(|args: &[Value]| tishlang_runtime::process_exec_file(args)),
868
+ );
788
869
  process_obj.insert(
789
870
  "argv".into(),
790
871
  Value::Array(VmRef::new(
791
- tishlang_core::process_argv().into_iter().map(|s| Value::String(s.into())).collect(),
872
+ tishlang_core::process_argv()
873
+ .into_iter()
874
+ .map(|s| Value::String(s.into()))
875
+ .collect(),
792
876
  )),
793
877
  );
794
878
  process_obj.insert(
@@ -860,13 +944,17 @@ fn init_globals(enabled: &HashSet<String>) -> ObjectMap {
860
944
  Value::Function(_) => raw,
861
945
  Value::Object(ref obj) => {
862
946
  let obj_ref = obj.borrow();
863
- if let Some(Value::Function(on_worker)) =
864
- obj_ref.strings.get(&std::sync::Arc::from("onWorker")).cloned()
947
+ if let Some(Value::Function(on_worker)) = obj_ref
948
+ .strings
949
+ .get(&std::sync::Arc::from("onWorker"))
950
+ .cloned()
865
951
  {
866
952
  let args_for_init = [Value::Number(0.0)];
867
953
  on_worker.call(&args_for_init)
868
- } else if let Some(h) =
869
- obj_ref.strings.get(&std::sync::Arc::from("handler")).cloned()
954
+ } else if let Some(h) = obj_ref
955
+ .strings
956
+ .get(&std::sync::Arc::from("handler"))
957
+ .cloned()
870
958
  {
871
959
  h
872
960
  } else {
@@ -967,24 +1055,66 @@ fn try_call_array_jit(
967
1055
  arity: usize,
968
1056
  mask: u8,
969
1057
  ) -> Option<Value> {
1058
+ let writable = nf.array_writable_mask();
970
1059
  let mut numeric: Vec<f64> = Vec::with_capacity(arity);
971
1060
  // `scratch` OWNS the extracted f64 data; handles point into it. Build handles only AFTER scratch is
972
1061
  // fully populated so its backing buffers never reallocate out from under a live pointer.
973
1062
  let mut scratch: Vec<Vec<f64>> = Vec::new();
974
- #[allow(clippy::needless_range_loop)] // `i` drives bit-mask math (`mask >> i`), not just indexing
1063
+ let mut array_arg: Vec<usize> = Vec::new(); // the `args` index each scratch buffer came from
1064
+ let mut is_bool_arr: Vec<bool> = Vec::new(); // #187: parallel to `scratch` — was it a Bool array?
1065
+ // #187: track whether ANY arg is a (non-empty) Bool array and whether ANY is a Number array. If both
1066
+ // element types are present the JIT could launder a bool value read from one array into a number
1067
+ // array (or vice versa) — the flattened `f64` writeback would then re-box it wrong — so we bail
1068
+ // (below). queens (all-bool arrays) and spectral_norm (all-number) never mix, so they stay JIT'd.
1069
+ let mut any_bool_arr = false;
1070
+ let mut any_num_arr = false;
1071
+ #[allow(clippy::needless_range_loop)]
1072
+ // `i` drives bit-mask math (`mask >> i`), not just indexing
975
1073
  for i in 0..arity {
976
1074
  if (mask >> i) & 1 == 1 {
977
1075
  match &args[i] {
978
1076
  Value::Array(a) => {
1077
+ // #187: a writable array must not alias another array arg — the JIT reads/writes a
1078
+ // private scratch copy, so aliasing would make reads see stale data or a writeback
1079
+ // silently drop the other param's writes. Bail (→ interpret) on any such overlap.
1080
+ if writable != 0 {
1081
+ for &j in &array_arg {
1082
+ if let Value::Array(other) = &args[j] {
1083
+ if VmRef::ptr_eq(a, other)
1084
+ && ((writable >> i) & 1 == 1 || (writable >> j) & 1 == 1)
1085
+ {
1086
+ return None;
1087
+ }
1088
+ }
1089
+ }
1090
+ }
1091
+ // #187: accept an all-`Number` OR an all-`Bool` array (bool → `f64` 0/1, e.g.
1092
+ // queens' `cols`/`diag*`). A MIXED array is ambiguous for the typed writeback → bail.
979
1093
  let b = a.borrow();
980
1094
  let mut buf: Vec<f64> = Vec::with_capacity(b.len());
1095
+ let mut seen_bool = false;
1096
+ let mut seen_num = false;
981
1097
  for el in b.iter() {
982
1098
  match el {
983
- Value::Number(n) => buf.push(*n),
984
- _ => return None, // non-numeric element → interpreter
1099
+ Value::Number(n) => {
1100
+ seen_num = true;
1101
+ buf.push(*n);
1102
+ }
1103
+ Value::Bool(bl) => {
1104
+ seen_bool = true;
1105
+ buf.push(if *bl { 1.0 } else { 0.0 });
1106
+ }
1107
+ _ => return None, // non-numeric/non-bool element → interpreter
985
1108
  }
986
1109
  }
1110
+ if seen_bool && seen_num {
1111
+ return None; // mixed Bool/Number array → can't type the writeback
1112
+ }
1113
+ any_bool_arr |= seen_bool;
1114
+ any_num_arr |= seen_num;
987
1115
  scratch.push(buf);
1116
+ array_arg.push(i);
1117
+ is_bool_arr.push(seen_bool);
988
1118
  }
989
1119
  _ => return None, // NumberArray / non-array → interpreter
990
1120
  }
@@ -995,20 +1125,311 @@ fn try_call_array_jit(
995
1125
  }
996
1126
  }
997
1127
  }
1128
+ // #187: mixed Bool + Number array args in one call could launder a value across element types (a bool
1129
+ // read from one array stored into a number array, or vice versa) — the flat `f64` writeback can't
1130
+ // re-box that correctly. Bail when both element types are present among the args.
1131
+ if any_bool_arr && any_num_arr {
1132
+ return None;
1133
+ }
1134
+ // #187: the writeback re-boxes a writable array by its ENTRY element type (bool vs number, per
1135
+ // `is_bool_arr`), so the value kind the JIT actually stored must match that type. A function that
1136
+ // writes bool consts (`arr[i] = true`) handed a NUMBER array — or one that writes numbers handed a
1137
+ // BOOL array — would re-box to the wrong type (e.g. `arr[i] = true` on `[0,0,0]` boxing `Number(1)`
1138
+ // where the interpreter stores `Bool(true)`). Bail to the interpreter on any such mismatch. (A single
1139
+ // array with mixed bool + non-bool writes was already rejected at compile time in `classify_params`.)
1140
+ let bool_write_mask = nf.array_bool_write_mask();
1141
+ if writable != 0 {
1142
+ for (k, &entry_bool) in is_bool_arr.iter().enumerate() {
1143
+ let i = array_arg[k];
1144
+ if (writable >> i) & 1 == 1 && entry_bool != ((bool_write_mask >> i) & 1 == 1) {
1145
+ return None;
1146
+ }
1147
+ }
1148
+ }
1149
+ // `as_mut_ptr`: #187 writable params store through this pointer, so it must carry mut provenance
1150
+ // (read-only params only load — harmless). Handles are built only AFTER `scratch` is fully
1151
+ // populated so its backing buffers never reallocate out from under a live pointer.
998
1152
  let handles: Vec<crate::jit::ArrayHandle> = scratch
999
- .iter()
1153
+ .iter_mut()
1000
1154
  .map(|buf| crate::jit::ArrayHandle {
1001
- ptr: buf.as_ptr(),
1155
+ ptr: buf.as_mut_ptr(),
1002
1156
  len: buf.len(),
1003
1157
  })
1004
1158
  .collect();
1005
- let (res, deopt) = nf.call_arrays(&numeric, &handles);
1159
+ // #187: a self-recursive array-mode function (queens' `place`) recurses on the NATIVE stack, so
1160
+ // pass a RecurGuard (like #381) — its entry SP-bail turns unbounded recursion into a catchable
1161
+ // RangeError instead of a SIGSEGV. Non-recursive array fns pass a null guard (unchanged ABI).
1162
+ let (res, deopt, tripped) = if nf.recur_guarded() {
1163
+ let anchor = 0u8;
1164
+ let current_sp = &anchor as *const u8 as usize;
1165
+ let stack_limit = match stacker::remaining_stack() {
1166
+ Some(rem) => {
1167
+ let margin = RECUR_STACK_MARGIN.min(rem / 2);
1168
+ current_sp.saturating_sub(rem).saturating_add(margin)
1169
+ }
1170
+ None => 0,
1171
+ };
1172
+ let mut guard = crate::jit::RecurGuard {
1173
+ stack_limit,
1174
+ tripped: 0,
1175
+ };
1176
+ let (res, deopt) = nf.call_arrays_guarded(&numeric, &handles, &mut guard);
1177
+ (res, deopt, guard.tripped != 0)
1178
+ } else {
1179
+ let (res, deopt) = nf.call_arrays(&numeric, &handles);
1180
+ (res, deopt, false)
1181
+ };
1006
1182
  if deopt {
1007
- return None; // OOB access → re-run interpreter (OOB reads coerce as Value::Null)
1183
+ return None; // OOB access → re-run interpreter (the discarded scratch writes never escaped)
1184
+ }
1185
+ // #187: copy each WRITTEN array param's (possibly-mutated) scratch back into its `Value::Array`. The
1186
+ // length is unchanged (`SetIndex` can't grow; an OOB write deopts above), so this is a same-length
1187
+ // element overwrite. On a normal return this reflects the final state; on a recursion-guard TRIP
1188
+ // (below) it reflects the partial in-place mutations made before the overflow — matching the
1189
+ // interpreter/node, which likewise leak whatever was mutated when a deep recursion throws.
1190
+ if writable != 0 {
1191
+ for (k, buf) in scratch.iter().enumerate() {
1192
+ let i = array_arg[k];
1193
+ if (writable >> i) & 1 == 1 {
1194
+ if let Value::Array(a) = &args[i] {
1195
+ let bool_arr = is_bool_arr[k]; // #187: re-box a bool array's elements as Bool
1196
+ let mut b = a.borrow_mut();
1197
+ for (j, &v) in buf.iter().enumerate() {
1198
+ if let Some(slot) = b.get_mut(j) {
1199
+ *slot = if bool_arr {
1200
+ Value::Bool(v != 0.0)
1201
+ } else {
1202
+ Value::Number(v)
1203
+ };
1204
+ }
1205
+ }
1206
+ }
1207
+ }
1208
+ }
1209
+ }
1210
+ // #187: a self-recursive array-mode fn that overflowed the native stack raises the same catchable
1211
+ // RangeError as every other tier (#381). The writeback above already flushed the partial mutations,
1212
+ // so the caller's arrays reflect the same "leaked" state the interpreter/node leave on a deep throw.
1213
+ if tripped {
1214
+ set_pending_throw(stack_overflow_error());
1215
+ return Some(Value::Null);
1216
+ }
1217
+ // #187: a VOID function (side-effect writer, e.g. `multiplyAv`) returns the implicit `null` — its
1218
+ // `f64` result is a dummy. Return `Value::Null` to match the interpreter; the real effect is the
1219
+ // array writeback above.
1220
+ if nf.returns_void() {
1221
+ return Some(Value::Null);
1008
1222
  }
1009
1223
  Some(Value::Number(res))
1010
1224
  }
1011
1225
 
1226
+ /// #187 native HOF fusion. When `arr.map/filter/reduce/forEach(cb)` has a JIT-compiled pure-numeric
1227
+ /// callback (`cb` is a [`VmClosure`] with a plain register-`f64` [`crate::jit::NumericFn`]) AND the
1228
+ /// array is all-numeric, the whole higher-order call runs as a tight native loop calling the
1229
+ /// `NumericFn` per element — skipping the per-element boxed `Callable::call` dispatch AND the
1230
+ /// `Value` (un)boxing of each element that the generic `arr_builtins` path pays.
1231
+ ///
1232
+ /// This is PURELY ADDITIVE: every helper returns `None` to fall back to the existing generic path
1233
+ /// (which is byte-identical to the interpreter), so a bail is always sound. The gate is deliberately
1234
+ /// narrow — see [`fused_numeric_fn`] and the per-helper arity/`result_bool` conditions.
1235
+ #[cfg(not(target_arch = "wasm32"))]
1236
+ mod hof_fusion {
1237
+ use super::VmClosure;
1238
+ use tishlang_core::{Value, VmRef};
1239
+
1240
+ /// Extract the callback's fusable [`crate::jit::NumericFn`], if any. Fires ONLY for a `VmClosure`
1241
+ /// whose JIT is a plain pure-numeric register-`f64` function:
1242
+ /// * `array_param_mask() == 0` — not an array-mode ABI (that path reads `arr[i]`, needs handles);
1243
+ /// * `!is_jv()` — no function-local JIT arrays (those signal a deopt via a per-thread flag we'd
1244
+ /// have to poll — the generic path handles them correctly, so bail);
1245
+ /// * `!recur_guarded()` — not self-recursive (would need the `RecurGuard` trailing-ptr ABI; a
1246
+ /// lambda callback is never self-recursive, so this only ever excludes pathological input).
1247
+ ///
1248
+ /// Any other callable (builtin native fn, non-JIT closure, array-mode/JV/recursive fn) → `None`.
1249
+ #[inline]
1250
+ pub(super) fn fused_numeric_fn(cb: &Value) -> Option<crate::jit::NumericFn> {
1251
+ let Value::Function(f) = cb else { return None };
1252
+ let vc = f.as_any().downcast_ref::<VmClosure>()?;
1253
+ let nf = vc.jit_fn?;
1254
+ if nf.array_param_mask() != 0 || nf.is_jv() || nf.recur_guarded() {
1255
+ return None;
1256
+ }
1257
+ Some(nf)
1258
+ }
1259
+
1260
+ /// Snapshot an all-numeric array as a `Vec<f64>`. Accepts a packed [`Value::NumberArray`] and a
1261
+ /// boxed [`Value::Array`] whose every element is a [`Value::Number`]. Returns `None` for any other
1262
+ /// shape (mixed/boxed non-number element, a non-array) so the caller falls back to the generic
1263
+ /// path. Snapshots (clones) so — exactly like the generic `arr_builtins` path (#382) — a callback
1264
+ /// that re-enters the same array observes the pre-call contents and can never deadlock the borrow.
1265
+ #[inline]
1266
+ fn numeric_snapshot(arr: &Value) -> Option<Vec<f64>> {
1267
+ match arr {
1268
+ Value::NumberArray(a) => Some(a.borrow().clone()),
1269
+ Value::Array(a) => {
1270
+ let b = a.borrow();
1271
+ let mut out = Vec::with_capacity(b.len());
1272
+ for v in b.iter() {
1273
+ match v {
1274
+ Value::Number(n) => out.push(*n),
1275
+ _ => return None,
1276
+ }
1277
+ }
1278
+ Some(out)
1279
+ }
1280
+ _ => None,
1281
+ }
1282
+ }
1283
+
1284
+ /// Box a `NumericFn` f64 result the SAME way the interpreter/generic path does: as `Value::Bool`
1285
+ /// when the callback's result is a comparison (`result_is_bool`), else `Value::Number`. A bool
1286
+ /// `NumericFn` returns exactly `0.0`/`1.0`, so `r != 0.0` is exact.
1287
+ #[inline]
1288
+ fn box_result(nf: &crate::jit::NumericFn, r: f64) -> Value {
1289
+ if nf.result_is_bool() {
1290
+ Value::Bool(r != 0.0)
1291
+ } else {
1292
+ Value::Number(r)
1293
+ }
1294
+ }
1295
+
1296
+ /// Truthiness of a `NumericFn` result under tish's rules — matches `Value::is_truthy` on the
1297
+ /// boxed form: a number is truthy iff `!= 0` and not NaN; a bool-result fn returns `0.0`/`1.0`
1298
+ /// (never NaN) so the same test is exact for it too.
1299
+ #[inline]
1300
+ fn result_truthy(r: f64) -> bool {
1301
+ r != 0.0 && !r.is_nan()
1302
+ }
1303
+
1304
+ /// Fused `map`. Fires when the callback is a fusable numeric fn of arity ≤ 2 (it may read the
1305
+ /// element and the index; a 3rd `array` param can't be a number, so arity 3+ bails). Returns the
1306
+ /// SAME `Value` variant the generic path would: a numeric-returning map over a `NumberArray` yields
1307
+ /// a packed `NumberArray` (empty → boxed empty `Array`, matching `packed_or_empty`); a
1308
+ /// bool-returning map, or any map over a boxed `Array`, yields a boxed `Value::Array`.
1309
+ pub(super) fn map(arr: &Value, cb: &Value) -> Option<Value> {
1310
+ let nf = fused_numeric_fn(cb)?;
1311
+ if nf.arity() > 2 {
1312
+ return None; // 3rd param is the array arg — not a number; bail (sound).
1313
+ }
1314
+ let data = numeric_snapshot(arr)?;
1315
+ let arity = nf.arity();
1316
+ let packed_input = matches!(arr, Value::NumberArray(_));
1317
+ // A numeric-returning map over a packed input keeps its result packed (byte-identical to
1318
+ // `packed_or_empty`); every other case (bool result, or a boxed `Array` input) boxes.
1319
+ if !nf.result_is_bool() && packed_input {
1320
+ let mut out: Vec<f64> = Vec::with_capacity(data.len());
1321
+ let mut args = [0.0f64; 2];
1322
+ for (i, &n) in data.iter().enumerate() {
1323
+ args[0] = n;
1324
+ args[1] = i as f64;
1325
+ out.push(nf.call(&args[..arity]));
1326
+ }
1327
+ // Empty → boxed empty `Array`, matching `packed_or_empty`.
1328
+ if out.is_empty() {
1329
+ return Some(Value::Array(VmRef::new(Vec::new())));
1330
+ }
1331
+ return Some(Value::number_array(out));
1332
+ }
1333
+ let mut out: Vec<Value> = Vec::with_capacity(data.len());
1334
+ let mut args = [0.0f64; 2];
1335
+ for (i, &n) in data.iter().enumerate() {
1336
+ args[0] = n;
1337
+ args[1] = i as f64;
1338
+ out.push(box_result(&nf, nf.call(&args[..arity])));
1339
+ }
1340
+ Some(Value::Array(VmRef::new(out)))
1341
+ }
1342
+
1343
+ /// Fused `filter`. Callback is a predicate of arity ≤ 2. Keeps the ORIGINAL element (not the
1344
+ /// callback result) when the result is truthy — so the output is always a subset of the numeric
1345
+ /// input. Packed input → packed output (`packed_or_empty`); boxed `Array` input → boxed `Array`.
1346
+ pub(super) fn filter(arr: &Value, cb: &Value) -> Option<Value> {
1347
+ let nf = fused_numeric_fn(cb)?;
1348
+ if nf.arity() > 2 {
1349
+ return None;
1350
+ }
1351
+ let data = numeric_snapshot(arr)?;
1352
+ let arity = nf.arity();
1353
+ let packed_input = matches!(arr, Value::NumberArray(_));
1354
+ let mut args = [0.0f64; 2];
1355
+ if packed_input {
1356
+ let mut out: Vec<f64> = Vec::new();
1357
+ for (i, &n) in data.iter().enumerate() {
1358
+ args[0] = n;
1359
+ args[1] = i as f64;
1360
+ if result_truthy(nf.call(&args[..arity])) {
1361
+ out.push(n);
1362
+ }
1363
+ }
1364
+ if out.is_empty() {
1365
+ return Some(Value::Array(VmRef::new(Vec::new())));
1366
+ }
1367
+ return Some(Value::number_array(out));
1368
+ }
1369
+ let mut out: Vec<Value> = Vec::new();
1370
+ for (i, &n) in data.iter().enumerate() {
1371
+ args[0] = n;
1372
+ args[1] = i as f64;
1373
+ if result_truthy(nf.call(&args[..arity])) {
1374
+ out.push(Value::Number(n));
1375
+ }
1376
+ }
1377
+ Some(Value::Array(VmRef::new(out)))
1378
+ }
1379
+
1380
+ /// Fused `reduce`. Callback is `(acc, element, index)` — arity ≤ 3 (a 4th `array` param bails).
1381
+ /// The accumulator stays an `f64` across the whole fold, so the callback result must NOT be a bool
1382
+ /// (`!result_is_bool()`): a bool acc fed back into the numeric fn would diverge from the interpreter
1383
+ /// (which would pass a `Value::Bool`, not a number). No-initial-value semantics match the generic
1384
+ /// path: absent init (`Value::Null`) with a non-empty array seeds `acc = data[0]` and scans from
1385
+ /// index 1; an empty array with no init throws (bail to the generic path, which raises it).
1386
+ pub(super) fn reduce(arr: &Value, cb: &Value, init: &Value) -> Option<Value> {
1387
+ let nf = fused_numeric_fn(cb)?;
1388
+ if nf.arity() > 3 || nf.result_is_bool() {
1389
+ return None;
1390
+ }
1391
+ let data = numeric_snapshot(arr)?;
1392
+ let arity = nf.arity();
1393
+ // Determine seed. Reduce with no initial value AND an empty array is a TypeError in JS — let
1394
+ // the generic path produce that exact throw rather than replicate it here.
1395
+ let (start, mut acc) = match init {
1396
+ Value::Null if !data.is_empty() => (1usize, data[0]),
1397
+ Value::Null => return None, // empty + no init → generic path throws
1398
+ Value::Number(n) => (0usize, *n),
1399
+ _ => return None, // non-numeric explicit init → generic path (acc wouldn't stay f64)
1400
+ };
1401
+ let mut args = [0.0f64; 3];
1402
+ for (i, &x) in data.iter().enumerate().skip(start) {
1403
+ args[0] = acc;
1404
+ args[1] = x;
1405
+ args[2] = i as f64;
1406
+ acc = nf.call(&args[..arity]);
1407
+ }
1408
+ Some(Value::Number(acc))
1409
+ }
1410
+
1411
+ /// Fused `forEach`. Callback arity ≤ 2; result discarded; always returns `Value::Null`. Only worth
1412
+ /// fusing when the callback is side-effect-free numeric arithmetic — but a `NumericFn` is pure by
1413
+ /// construction (no calls/member/throw), so a fused `forEach` is observably a no-op EXCEPT for its
1414
+ /// (absent) side effects: it computes and discards. Kept for completeness / uniformity; it can
1415
+ /// never diverge because there is nothing observable to diverge on.
1416
+ pub(super) fn for_each(arr: &Value, cb: &Value) -> Option<Value> {
1417
+ let nf = fused_numeric_fn(cb)?;
1418
+ if nf.arity() > 2 {
1419
+ return None;
1420
+ }
1421
+ let data = numeric_snapshot(arr)?;
1422
+ let arity = nf.arity();
1423
+ let mut args = [0.0f64; 2];
1424
+ for (i, &n) in data.iter().enumerate() {
1425
+ args[0] = n;
1426
+ args[1] = i as f64;
1427
+ let _ = nf.call(&args[..arity]);
1428
+ }
1429
+ Some(Value::Null)
1430
+ }
1431
+ }
1432
+
1012
1433
  struct VmClosure {
1013
1434
  chunk: Arc<Chunk>,
1014
1435
  /// Whether this closure can run on the frame stack — computed ONCE at creation (eligibility is an
@@ -1044,12 +1465,66 @@ impl tishlang_core::Callable for VmClosure {
1044
1465
  }
1045
1466
  }
1046
1467
  if all_numbers {
1047
- let res = nf.call(&nums[..arity]);
1048
- return if nf.result_is_bool() {
1049
- Value::Bool(res != 0.0)
1468
+ // #189: a JV function has function-local `f64` arrays lowered to
1469
+ // `tish_jv_*` calls over a per-thread arena. An out-of-bounds access (or a
1470
+ // non-numeric return) sets a per-thread deopt flag; we discard the numeric
1471
+ // result and re-run the interpreter. Sound because JV arrays never escape
1472
+ // the function — nothing observable was mutated, so re-execution
1473
+ // reproduces identical behaviour.
1474
+ if nf.is_jv() {
1475
+ crate::jit::jv_reset_deopt();
1476
+ let r = nf.call(&nums[..arity]);
1477
+ if !crate::jit::jv_take_deopt() {
1478
+ return if nf.result_is_bool() {
1479
+ Value::Bool(r != 0.0)
1480
+ } else {
1481
+ Value::Number(r)
1482
+ };
1483
+ }
1484
+ // deopt ⇒ fall through to the interpreter below.
1050
1485
  } else {
1051
- Value::Number(res)
1052
- };
1486
+ // #381: a self-recursive JIT'd function carries a RecurGuard so it bails
1487
+ // (rather than overflowing the native stack) when the recursion nears the
1488
+ // real remaining stack. On a bail we raise the same catchable RangeError as
1489
+ // the non-JIT paths — tish's deopt tier producing the throw, not the JIT.
1490
+ let res = if nf.recur_guarded() {
1491
+ let anchor = 0u8;
1492
+ let current_sp = &anchor as *const u8 as usize;
1493
+ // `stack_limit` = the stack address below which we bail, leaving a
1494
+ // headroom margin. If the remaining stack is unknown we pass 0 (SP is
1495
+ // never below 0 → never trips), i.e. behave as before.
1496
+ let stack_limit = match stacker::remaining_stack() {
1497
+ Some(rem) => {
1498
+ // Cap the margin at half of what's left: when a JIT'd function
1499
+ // is first entered on an already-deep stack (rem < margin),
1500
+ // `bottom + margin` would sit ABOVE the current SP and trip on
1501
+ // the very first call — a false positive on shallow recursion.
1502
+ // Capping keeps the limit strictly below SP for any rem, while
1503
+ // still bailing with real headroom to spare.
1504
+ let margin = RECUR_STACK_MARGIN.min(rem / 2);
1505
+ current_sp.saturating_sub(rem).saturating_add(margin)
1506
+ }
1507
+ None => 0,
1508
+ };
1509
+ let mut guard = crate::jit::RecurGuard {
1510
+ stack_limit,
1511
+ tripped: 0,
1512
+ };
1513
+ let r = nf.call_guarded(&nums[..arity], &mut guard);
1514
+ if guard.tripped != 0 {
1515
+ set_pending_throw(stack_overflow_error());
1516
+ return Value::Null;
1517
+ }
1518
+ r
1519
+ } else {
1520
+ nf.call(&nums[..arity])
1521
+ };
1522
+ return if nf.result_is_bool() {
1523
+ Value::Bool(res != 0.0)
1524
+ } else {
1525
+ Value::Number(res)
1526
+ };
1527
+ }
1053
1528
  }
1054
1529
  } else if let Some(v) = try_call_array_jit(&nf, args, arity, mask) {
1055
1530
  // Array-mode path: succeeded (all-numeric arrays, in-bounds). On any bail
@@ -1060,6 +1535,16 @@ impl tishlang_core::Callable for VmClosure {
1060
1535
  }
1061
1536
  }
1062
1537
  }
1538
+ // #381: bound recursion so a runaway recursive closure throws a catchable RangeError rather
1539
+ // than overflowing the native stack (this recursive re-entry is the DEFAULT `tish run` path).
1540
+ // The counter is thread-local because each closure call builds a fresh `Vm`; the parked throw
1541
+ // is picked up by the caller's `take_pending_throw()` check (Call/SelfCall post-call).
1542
+ let depth = tishlang_core::inc_call_depth();
1543
+ if depth > max_call_depth() {
1544
+ tishlang_core::dec_call_depth();
1545
+ set_pending_throw(stack_overflow_error());
1546
+ return Value::Null;
1547
+ }
1063
1548
  let mut vm = Vm {
1064
1549
  stack: Vec::new(),
1065
1550
  scope: ObjectMap::default(),
@@ -1069,17 +1554,19 @@ impl tishlang_core::Callable for VmClosure {
1069
1554
  native_modules: self.native_modules.clone(),
1070
1555
  };
1071
1556
  #[cfg(not(target_arch = "wasm32"))]
1072
- {
1557
+ let result = {
1073
1558
  stacker::maybe_grow(128 * 1024, 2 * 1024 * 1024, || {
1074
1559
  vm.run_chunk(self.chunk.as_ref(), &self.chunk.nested, args, false)
1075
1560
  .unwrap_or(Value::Null)
1076
1561
  })
1077
- }
1562
+ };
1078
1563
  #[cfg(target_arch = "wasm32")]
1079
- {
1564
+ let result = {
1080
1565
  vm.run_chunk(&self.chunk, &self.chunk.nested, args, false)
1081
1566
  .unwrap_or(Value::Null)
1082
- }
1567
+ };
1568
+ tishlang_core::dec_call_depth();
1569
+ result
1083
1570
  }
1084
1571
  fn as_any(&self) -> &dyn std::any::Any {
1085
1572
  self
@@ -1170,6 +1657,12 @@ impl Vm {
1170
1657
 
1171
1658
  /// Run a chunk using this VM's capability set. `repl_mode` persists top-level `let` across REPL lines.
1172
1659
  pub fn run_with_options(&mut self, chunk: &Chunk, repl_mode: bool) -> Result<Value, String> {
1660
+ // #187: the directly-callable-callee registry is scoped to ONE top-level program run. Clear it
1661
+ // here (the outermost entry — closures run via `run_chunk`, not this) so a long-lived process
1662
+ // (REPL / embedder) running a NEW program never resolves a stale callee registered by a prior
1663
+ // one. A cross-caller and its callee always compile within the same run, so this is sufficient.
1664
+ #[cfg(not(target_arch = "wasm32"))]
1665
+ crate::jit::reset_callees();
1173
1666
  let result = self.run_chunk(chunk, &chunk.nested, &[], repl_mode);
1174
1667
  // A throw that escaped every `catch` reaches here as the pending-throw sentinel; turn the
1175
1668
  // parked value into the conventional uncaught-error message (issue #60).
@@ -1192,7 +1685,11 @@ impl Vm {
1192
1685
  // regression to the DEFAULT path — caching makes the flag-off check a single atomic load.
1193
1686
  use std::sync::OnceLock;
1194
1687
  static ENABLED: OnceLock<bool> = OnceLock::new();
1195
- *ENABLED.get_or_init(|| std::env::var("TISH_FRAME_VM").map(|v| v == "1").unwrap_or(false))
1688
+ *ENABLED.get_or_init(|| {
1689
+ std::env::var("TISH_FRAME_VM")
1690
+ .map(|v| v == "1")
1691
+ .unwrap_or(false)
1692
+ })
1196
1693
  }
1197
1694
 
1198
1695
  /// A `VmClosure` runs on the frame stack iff its chunk is frame-eligible AND it has no numeric
@@ -1245,6 +1742,64 @@ impl Vm {
1245
1742
  true
1246
1743
  }
1247
1744
 
1745
+ /// On-stack replacement (#190): run one hot loop region natively, or report why we can't.
1746
+ ///
1747
+ /// Called from the frame VM's `JumpBack` handler once a loop's back-edge counter trips. Compiles
1748
+ /// (cached) the region `[header_ip, region_end)`; if it is a pure-numeric slot loop AND every live
1749
+ /// slot currently holds a `Number` (else the native f64 math would diverge from the interpreter),
1750
+ /// copies the live-ins into an f64 buffer, runs the whole remaining loop natively, writes the
1751
+ /// live-outs back as `Number`s, and returns the chunk `ip` of the loop exit to resume dispatch at.
1752
+ ///
1753
+ /// Soundness: the v1 region whitelist is pure slot/stack arithmetic — no calls, no member/index,
1754
+ /// no object or array mutation — so nothing observable happens inside the region, and the
1755
+ /// interpreter running the same bytecode from the same numeric live-ins produces bit-identical
1756
+ /// slots (the `emit_simple_op` lowering matches `eval_binop`). A non-numeric live-in is the deopt
1757
+ /// case: we simply don't OSR and keep interpreting, so state is never corrupted.
1758
+ #[cfg(not(target_arch = "wasm32"))]
1759
+ fn run_osr(
1760
+ chunk: &Chunk,
1761
+ header_ip: usize,
1762
+ region_end: usize,
1763
+ slots: &mut [Value],
1764
+ slot_base: usize,
1765
+ ) -> OsrResult {
1766
+ let loopfn = match crate::jit::try_compile_loop(chunk, header_ip, region_end) {
1767
+ Some(lf) => lf,
1768
+ None => return OsrResult::NotCompilable,
1769
+ };
1770
+ let mut buf: Vec<f64> = Vec::with_capacity(loopfn.used_slots.len());
1771
+ for &slot in &loopfn.used_slots {
1772
+ match slots.get(slot_base + slot as usize) {
1773
+ Some(Value::Number(n)) => buf.push(*n),
1774
+ _ => return OsrResult::LiveInMiss, // a live slot is non-numeric → deopt, keep interpreting
1775
+ }
1776
+ }
1777
+ let mut deopt: u8 = 0;
1778
+ // SAFETY: `buf` is a valid `[f64; used_slots.len()]`; `deopt` is a valid `*mut u8`. The region
1779
+ // was compiled for exactly this chunk+header (fingerprint-checked in the cache).
1780
+ let exit_id = loopfn.call(&mut buf, &mut deopt);
1781
+ for (p, &slot) in loopfn.used_slots.iter().enumerate() {
1782
+ if let Some(d) = slots.get_mut(slot_base + slot as usize) {
1783
+ *d = Value::Number(buf[p]);
1784
+ }
1785
+ }
1786
+ match loopfn.exits.get(exit_id as usize) {
1787
+ Some(&ip) => OsrResult::Compiled(ip),
1788
+ // Out-of-range exit id is impossible (the region returns an in-range id), but if it ever
1789
+ // happened the slots are already a consistent post-loop state, so re-interpreting the
1790
+ // (now-false) loop header exits correctly.
1791
+ None => OsrResult::LiveInMiss,
1792
+ }
1793
+ }
1794
+
1795
+ /// OSR is **default ON**; `TISH_OSR=0` disables it (escape hatch, mirrors `TISH_JIT_ARRAYS`).
1796
+ #[cfg(not(target_arch = "wasm32"))]
1797
+ #[inline]
1798
+ fn osr_enabled() -> bool {
1799
+ static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1800
+ *ENABLED.get_or_init(|| std::env::var("TISH_OSR").map(|v| v != "0").unwrap_or(true))
1801
+ }
1802
+
1248
1803
  /// Iterative frame-stack execution of a frame-eligible `VmClosure` (the frame-VM, flag-on).
1249
1804
  /// Returns `None` if the entry chunk is ineligible (caller falls back to `VmClosure::call`).
1250
1805
  /// Calls + recursion run on the heap `frames` stack — no per-call `Vm`, no recursive `run_chunk`
@@ -1264,6 +1819,14 @@ impl Vm {
1264
1819
  let mut slots: Vec<Value> = Vec::new();
1265
1820
  let mut slot_base: usize = 0;
1266
1821
  slots.resize(cur.num_slots as usize, Value::Null);
1822
+ // #190 OSR: per-`(chunk, loop header)` back-edge counters (`u32::MAX` = gave up), with a
1823
+ // single-entry fast slot for the loop currently spinning (see the `run_chunk` copy for the
1824
+ // rationale) so a resolved hot loop stays off the HashMap.
1825
+ #[cfg(not(target_arch = "wasm32"))]
1826
+ let mut osr_counters: std::collections::HashMap<(usize, usize), u32> =
1827
+ std::collections::HashMap::new();
1828
+ #[cfg(not(target_arch = "wasm32"))]
1829
+ let mut osr_hot: ((usize, usize), u32) = ((usize::MAX, usize::MAX), 0);
1267
1830
  for i in 0..(cur.param_count as usize) {
1268
1831
  if let Some(v) = args.get(i) {
1269
1832
  if let Some(d) = slots.get_mut(slot_base + i) {
@@ -1339,7 +1902,9 @@ impl Vm {
1339
1902
  let idx = Self::read_u16(code, &mut ip) as usize;
1340
1903
  let v = match cur.constants.get(idx) {
1341
1904
  Some(Constant::Number(n)) => Value::Number(*n),
1342
- Some(Constant::String(s)) => Value::String(tishlang_core::ArcStr::from(s.as_ref())),
1905
+ Some(Constant::String(s)) => {
1906
+ Value::String(tishlang_core::ArcStr::from(s.as_ref()))
1907
+ }
1343
1908
  Some(Constant::Bool(b)) => Value::Bool(*b),
1344
1909
  Some(Constant::Null) => Value::Null,
1345
1910
  _ => ferr!("Ineligible constant {} in run_framed", idx),
@@ -1388,6 +1953,41 @@ impl Vm {
1388
1953
  }
1389
1954
  Opcode::JumpBack => {
1390
1955
  let dist = Self::read_u16(code, &mut ip) as usize;
1956
+ // #190 OSR: `ip` now points just past the JumpBack (region end); the loop header is
1957
+ // `region_end - dist`. Once a loop is hot, try to run its remaining iterations in
1958
+ // native code (numeric slot loops only; anything else falls straight through).
1959
+ #[cfg(not(target_arch = "wasm32"))]
1960
+ if Self::osr_enabled() {
1961
+ let region_end = ip;
1962
+ let header_ip = ip.saturating_sub(dist);
1963
+ let key = (Arc::as_ptr(&cur) as usize, header_ip);
1964
+ if key != osr_hot.0 {
1965
+ if osr_hot.0 .0 != usize::MAX {
1966
+ osr_counters.insert(osr_hot.0, osr_hot.1);
1967
+ }
1968
+ osr_hot = (key, osr_counters.get(&key).copied().unwrap_or(0));
1969
+ }
1970
+ if osr_hot.1 != u32::MAX {
1971
+ osr_hot.1 = osr_hot.1.saturating_add(1);
1972
+ if osr_hot.1 >= OSR_THRESHOLD
1973
+ && osr_hot
1974
+ .1
1975
+ .saturating_sub(OSR_THRESHOLD)
1976
+ .is_multiple_of(OSR_RETRY)
1977
+ {
1978
+ match Self::run_osr(
1979
+ &cur, header_ip, region_end, &mut slots, slot_base,
1980
+ ) {
1981
+ OsrResult::Compiled(exit_ip) => {
1982
+ ip = exit_ip;
1983
+ continue;
1984
+ }
1985
+ OsrResult::LiveInMiss => {}
1986
+ OsrResult::NotCompilable => osr_hot.1 = u32::MAX,
1987
+ }
1988
+ }
1989
+ }
1990
+ }
1391
1991
  ip = ip.saturating_sub(dist);
1392
1992
  }
1393
1993
  Opcode::Pop => {
@@ -1400,6 +2000,12 @@ impl Vm {
1400
2000
  call_args.push(fpop!());
1401
2001
  }
1402
2002
  call_args.reverse();
2003
+ // #381: bound the heap frame stack (the opt-in frame-VM path grows a Vec instead
2004
+ // of the native stack, so unbounded recursion here is OOM rather than overflow).
2005
+ if frames.len() >= max_call_depth() {
2006
+ set_pending_throw(stack_overflow_error());
2007
+ return Some(Err(PENDING_THROW_SENTINEL.to_string()));
2008
+ }
1403
2009
  frames.push((cur.clone(), ip, slot_base, stack_base, enclosing.clone()));
1404
2010
  let new_base = slots.len();
1405
2011
  slots.resize(new_base + cur.num_slots as usize, Value::Null);
@@ -1435,6 +2041,10 @@ impl Vm {
1435
2041
  // refcounts are unchanged (the chunk heap data doesn't move, so the
1436
2042
  // laundered `code` ptr stays valid until rebind below). Halves the
1437
2043
  // per-call Arc traffic vs cloning for the push.
2044
+ if frames.len() >= max_call_depth() {
2045
+ set_pending_throw(stack_overflow_error());
2046
+ return Some(Err(PENDING_THROW_SENTINEL.to_string()));
2047
+ }
1438
2048
  frames.push((cur, ip, slot_base, stack_base, enclosing));
1439
2049
  cur = next_chunk;
1440
2050
  enclosing = next_enc;
@@ -1528,6 +2138,35 @@ impl Vm {
1528
2138
  // frame indexed by slot — no per-call hashmap, no name lookups. Args bind
1529
2139
  // to slots 0..param_count. Empty for name-based chunks.
1530
2140
  let mut slot_locals: Vec<Value> = Vec::new();
2141
+ // #190 OSR: per-loop-header back-edge counters for this frame (`u32::MAX` = gave up). `osr_hot`
2142
+ // is a single-entry fast slot for the loop currently spinning — its header is constant across
2143
+ // iterations, so the common hot loop never touches the HashMap (which is hit only when the
2144
+ // active loop changes: entry, exit, or a nested-loop switch). Keeps the per-back-edge cost of a
2145
+ // resolved loop to two integer compares.
2146
+ #[cfg(not(target_arch = "wasm32"))]
2147
+ let mut osr_counters: std::collections::HashMap<usize, u32> =
2148
+ std::collections::HashMap::new();
2149
+ #[cfg(not(target_arch = "wasm32"))]
2150
+ let mut osr_hot: (usize, u32) = (usize::MAX, 0);
2151
+ // Frame-local string builder for `acc += x` on a slot local (Opcode::AppendLocal): keeps the
2152
+ // accumulator in a growable `sb_buf` so appends are amortized O(1) instead of reallocating the
2153
+ // whole string each time. `sb_slot` is the slot currently buffered (at most one). The slot's
2154
+ // own value is stale while buffered; `sb_flush!()` writes the buffer back. Slots are
2155
+ // frame-private (never captured by closures and invisible to callees), and every read of a
2156
+ // slot goes through `LoadLocal`, so flushing there is sufficient for soundness — no
2157
+ // cross-frame state. JS string immutability is preserved: reads always see the full string.
2158
+ let mut sb_slot: Option<usize> = None;
2159
+ let mut sb_buf = String::new();
2160
+ macro_rules! sb_flush {
2161
+ () => {{
2162
+ if let Some(s) = sb_slot.take() {
2163
+ if let Some(dst) = slot_locals.get_mut(s) {
2164
+ *dst = Value::String(std::mem::take(&mut sb_buf).into());
2165
+ }
2166
+ sb_buf.clear();
2167
+ }
2168
+ }};
2169
+ }
1531
2170
  if chunk.slot_based {
1532
2171
  slot_locals = vec![Value::Null; chunk.num_slots as usize];
1533
2172
  let param_count = chunk.param_count as usize;
@@ -1620,6 +2259,10 @@ impl Vm {
1620
2259
  Opcode::Nop => {}
1621
2260
  Opcode::LoadLocal => {
1622
2261
  let slot = Self::read_u16(code, &mut ip) as usize;
2262
+ // Flush a pending string-builder for this slot so the read sees the full string.
2263
+ if sb_slot == Some(slot) {
2264
+ sb_flush!();
2265
+ }
1623
2266
  let v = slot_locals
1624
2267
  .get(slot)
1625
2268
  .cloned()
@@ -1628,6 +2271,11 @@ impl Vm {
1628
2271
  }
1629
2272
  Opcode::StoreLocal => {
1630
2273
  let slot = Self::read_u16(code, &mut ip) as usize;
2274
+ // A plain store overwrites the slot — drop any builder buffering it.
2275
+ if sb_slot == Some(slot) {
2276
+ sb_slot = None;
2277
+ sb_buf.clear();
2278
+ }
1631
2279
  let v = self
1632
2280
  .stack
1633
2281
  .pop()
@@ -1637,10 +2285,59 @@ impl Vm {
1637
2285
  None => return Err(format!("Local slot out of bounds: {}", slot)),
1638
2286
  }
1639
2287
  }
2288
+ Opcode::AppendLocal => {
2289
+ let slot = Self::read_u16(code, &mut ip) as usize;
2290
+ let rhs = self
2291
+ .stack
2292
+ .pop()
2293
+ .ok_or_else(|| "Stack underflow in AppendLocal".to_string())?;
2294
+ if sb_slot == Some(slot) {
2295
+ // Continue the active builder for this slot — amortized O(1) append.
2296
+ append_value_for_string_concat(&mut sb_buf, &rhs);
2297
+ } else {
2298
+ // Switching slots: flush the previous builder, then (re)start for this one.
2299
+ sb_flush!();
2300
+ match slot_locals.get(slot) {
2301
+ Some(Value::String(a)) => {
2302
+ sb_buf.clear();
2303
+ sb_buf.reserve(a.len() + estimate_string_concat_len(&rhs));
2304
+ sb_buf.push_str(a);
2305
+ append_value_for_string_concat(&mut sb_buf, &rhs);
2306
+ sb_slot = Some(slot);
2307
+ }
2308
+ _ => {
2309
+ // Non-string (or absent) accumulator: generic `+=` in place,
2310
+ // identical to LoadLocal; BinOp Add; StoreLocal.
2311
+ let current = slot_locals.get(slot).cloned().unwrap_or(Value::Null);
2312
+ let result = eval_binop(BinOp::Add, &current, &rhs)?;
2313
+ match slot_locals.get_mut(slot) {
2314
+ Some(dst) => *dst = result,
2315
+ None => {
2316
+ return Err(format!("Local slot out of bounds: {}", slot))
2317
+ }
2318
+ }
2319
+ }
2320
+ }
2321
+ }
2322
+ }
1640
2323
  Opcode::LoadUpvalue | Opcode::StoreUpvalue => {
1641
2324
  // Reserved for the linked-frame upvalue model (not emitted yet).
1642
2325
  return Err("Upvalue opcodes not supported in this VM build".to_string());
1643
2326
  }
2327
+ Opcode::MathUnary => {
2328
+ // #186 — `Math.<fn>(x)` on a number. The compiler only emits this when `Math` is
2329
+ // the global builtin, so it is behaviour-identical to the general call: a number
2330
+ // maps through `MathUnaryFn::apply`, a non-number coerces to NaN (matching the
2331
+ // builtin's `extract_num(..).unwrap_or(NaN)`).
2332
+ let id = Self::read_u16(code, &mut ip);
2333
+ let x = match self.stack.pop() {
2334
+ Some(Value::Number(n)) => n,
2335
+ Some(_) | None => f64::NAN,
2336
+ };
2337
+ let mfn = tishlang_bytecode::MathUnaryFn::from_u16(id)
2338
+ .ok_or_else(|| format!("Bad MathUnary id: {}", id))?;
2339
+ self.stack.push(Value::Number(mfn.apply(x)));
2340
+ }
1644
2341
  Opcode::LoadConst => {
1645
2342
  let idx = Self::read_u16(code, &mut ip);
1646
2343
  let c = constants
@@ -1648,7 +2345,9 @@ impl Vm {
1648
2345
  .ok_or_else(|| format!("Constant index out of bounds: {}", idx))?;
1649
2346
  let v = match c {
1650
2347
  Constant::Number(n) => Value::Number(*n),
1651
- Constant::String(s) => Value::String(tishlang_core::ArcStr::from(s.as_ref())),
2348
+ Constant::String(s) => {
2349
+ Value::String(tishlang_core::ArcStr::from(s.as_ref()))
2350
+ }
1652
2351
  Constant::Bool(b) => Value::Bool(*b),
1653
2352
  Constant::Null => Value::Null,
1654
2353
  Constant::Closure(nested_idx) => {
@@ -1669,45 +2368,45 @@ impl Vm {
1669
2368
  // A closure must capture a real scope (even if empty) so that, post-creation,
1670
2369
  // the parent's name-based locals are visible. Materialise local_scope here.
1671
2370
  let captured_scope: ScopeMap = ls_get_or_init!().clone();
1672
- let enclosing_chain: SharedChain = SharedChain::new(if active_loop_vars.is_empty() {
1673
- let mut chain = Vec::with_capacity(self.enclosing.len() + 1);
1674
- chain.push(captured_scope.clone());
1675
- chain.extend(self.enclosing.iter().cloned());
1676
- chain
1677
- } else {
1678
- // Per-iteration `let`: freeze the loop var(s) into an overlay that
1679
- // shadows the still-shared frame scope, then the inherited chain.
1680
- let mut overlay = ObjectMap::default();
1681
- {
1682
- let ls = captured_scope.borrow();
1683
- for n in &active_loop_vars {
1684
- if let Some(v) = ls.get(n.as_ref()) {
1685
- overlay.insert(Arc::clone(n), v.clone());
2371
+ let enclosing_chain: SharedChain =
2372
+ SharedChain::new(if active_loop_vars.is_empty() {
2373
+ let mut chain = Vec::with_capacity(self.enclosing.len() + 1);
2374
+ chain.push(captured_scope.clone());
2375
+ chain.extend(self.enclosing.iter().cloned());
2376
+ chain
2377
+ } else {
2378
+ // Per-iteration `let`: freeze the loop var(s) into an overlay that
2379
+ // shadows the still-shared frame scope, then the inherited chain.
2380
+ let mut overlay = ObjectMap::default();
2381
+ {
2382
+ let ls = captured_scope.borrow();
2383
+ for n in &active_loop_vars {
2384
+ if let Some(v) = ls.get(n.as_ref()) {
2385
+ overlay.insert(Arc::clone(n), v.clone());
2386
+ }
1686
2387
  }
1687
2388
  }
1688
- }
1689
- let mut chain = Vec::with_capacity(self.enclosing.len() + 2);
1690
- chain.push(VmRef::new(overlay));
1691
- chain.push(captured_scope.clone());
1692
- chain.extend(self.enclosing.iter().cloned());
1693
- chain
1694
- });
2389
+ let mut chain = Vec::with_capacity(self.enclosing.len() + 2);
2390
+ chain.push(VmRef::new(overlay));
2391
+ chain.push(captured_scope.clone());
2392
+ chain.extend(self.enclosing.iter().cloned());
2393
+ chain
2394
+ });
1695
2395
  let capabilities = Arc::clone(&self.capabilities);
1696
2396
  let native_modules = self.native_modules.clone();
1697
2397
  // Frame-eligibility is an O(chunk) bytecode scan; gate it behind the
1698
2398
  // (cached) frame-VM flag so the DEFAULT path skips it entirely — flag-off
1699
2399
  // closure creation pays nothing.
1700
- let frameable = Vm::frame_vm_enabled()
1701
- && {
1702
- #[cfg(not(target_arch = "wasm32"))]
1703
- {
1704
- jit_fn.is_none() && Vm::chunk_frame_eligible(&inner_clone)
1705
- }
1706
- #[cfg(target_arch = "wasm32")]
1707
- {
1708
- Vm::chunk_frame_eligible(&inner_clone)
1709
- }
1710
- };
2400
+ let frameable = Vm::frame_vm_enabled() && {
2401
+ #[cfg(not(target_arch = "wasm32"))]
2402
+ {
2403
+ jit_fn.is_none() && Vm::chunk_frame_eligible(&inner_clone)
2404
+ }
2405
+ #[cfg(target_arch = "wasm32")]
2406
+ {
2407
+ Vm::chunk_frame_eligible(&inner_clone)
2408
+ }
2409
+ };
1711
2410
  let vmclosure = VmClosure {
1712
2411
  chunk: std::sync::Arc::new(inner_clone),
1713
2412
  frameable,
@@ -1759,7 +2458,10 @@ impl Vm {
1759
2458
  .pop()
1760
2459
  .ok_or_else(|| "Stack underflow".to_string())?;
1761
2460
  // Update innermost scope that has the variable (matches interpreter Scope.assign)
1762
- if local_scope.as_ref().is_some_and(|ls| ls.borrow().contains_key(name.as_ref())) {
2461
+ if local_scope
2462
+ .as_ref()
2463
+ .is_some_and(|ls| ls.borrow().contains_key(name.as_ref()))
2464
+ {
1763
2465
  ls_get_or_init!().borrow_mut().insert(Arc::clone(name), v);
1764
2466
  } else if let Some(e) = self
1765
2467
  .enclosing
@@ -1994,6 +2696,14 @@ impl Vm {
1994
2696
  );
1995
2697
  }
1996
2698
  args.reverse();
2699
+ // #381: SelfCall is a second native recursive re-entry (a self-recursive function
2700
+ // re-enters run_chunk directly). Bound it with the same shared counter; `raise!` is
2701
+ // in scope here, so throw the catchable RangeError directly.
2702
+ let depth = tishlang_core::inc_call_depth();
2703
+ if depth > max_call_depth() {
2704
+ tishlang_core::dec_call_depth();
2705
+ raise!(stack_overflow_error());
2706
+ }
1997
2707
  let mut vm = Vm {
1998
2708
  stack: Vec::new(),
1999
2709
  scope: ObjectMap::default(),
@@ -2008,7 +2718,10 @@ impl Vm {
2008
2718
  .unwrap_or(Value::Null)
2009
2719
  });
2010
2720
  #[cfg(target_arch = "wasm32")]
2011
- let result = vm.run_chunk(chunk, nested, &args, false).unwrap_or(Value::Null);
2721
+ let result = vm
2722
+ .run_chunk(chunk, nested, &args, false)
2723
+ .unwrap_or(Value::Null);
2724
+ tishlang_core::dec_call_depth();
2012
2725
  if let Some(v) = take_pending_throw() {
2013
2726
  raise!(v);
2014
2727
  }
@@ -2040,8 +2753,7 @@ impl Vm {
2040
2753
  let f = match &callee {
2041
2754
  Value::Function(f) => f.clone(),
2042
2755
  Value::Object(o) => {
2043
- if let Some(Value::Function(call_fn)) =
2044
- o.borrow().strings.get("__call")
2756
+ if let Some(Value::Function(call_fn)) = o.borrow().strings.get("__call")
2045
2757
  {
2046
2758
  call_fn.clone()
2047
2759
  } else {
@@ -2131,6 +2843,49 @@ impl Vm {
2131
2843
  }
2132
2844
  Opcode::JumpBack => {
2133
2845
  let dist = Self::read_u16(code, &mut ip) as usize;
2846
+ // #190 OSR: on a hot back-edge, try to finish the loop natively. Slot-based frames
2847
+ // only (`slot_locals` is the live frame); non-slot / non-numeric loops fail the
2848
+ // whitelist or the live-in check and fall straight through to the interpreter.
2849
+ #[cfg(not(target_arch = "wasm32"))]
2850
+ if chunk.slot_based && Self::osr_enabled() {
2851
+ let region_end = ip;
2852
+ let header_ip = ip.saturating_sub(dist);
2853
+ // Switch the fast slot only when the active loop changes: stash the old count,
2854
+ // load this header's (default 0). The hot loop keeps `header_ip == osr_hot.0`.
2855
+ if header_ip != osr_hot.0 {
2856
+ if osr_hot.0 != usize::MAX {
2857
+ osr_counters.insert(osr_hot.0, osr_hot.1);
2858
+ }
2859
+ osr_hot = (
2860
+ header_ip,
2861
+ osr_counters.get(&header_ip).copied().unwrap_or(0),
2862
+ );
2863
+ }
2864
+ if osr_hot.1 != u32::MAX {
2865
+ osr_hot.1 = osr_hot.1.saturating_add(1);
2866
+ if osr_hot.1 >= OSR_THRESHOLD
2867
+ && osr_hot
2868
+ .1
2869
+ .saturating_sub(OSR_THRESHOLD)
2870
+ .is_multiple_of(OSR_RETRY)
2871
+ {
2872
+ match Self::run_osr(
2873
+ chunk,
2874
+ header_ip,
2875
+ region_end,
2876
+ &mut slot_locals,
2877
+ 0,
2878
+ ) {
2879
+ OsrResult::Compiled(exit_ip) => {
2880
+ ip = exit_ip;
2881
+ continue;
2882
+ }
2883
+ OsrResult::LiveInMiss => {}
2884
+ OsrResult::NotCompilable => osr_hot.1 = u32::MAX,
2885
+ }
2886
+ }
2887
+ }
2888
+ }
2134
2889
  ip = ip.saturating_sub(dist);
2135
2890
  }
2136
2891
  Opcode::BinOp => {
@@ -2265,8 +3020,12 @@ impl Vm {
2265
3020
  if let Some(nums) = elems.iter().try_fold(
2266
3021
  Vec::<f64>::with_capacity(elems.len()),
2267
3022
  |mut acc, v| {
2268
- if let Value::Number(n) = v { acc.push(*n); Some(acc) }
2269
- else { None }
3023
+ if let Value::Number(n) = v {
3024
+ acc.push(*n);
3025
+ Some(acc)
3026
+ } else {
3027
+ None
3028
+ }
2270
3029
  },
2271
3030
  ) {
2272
3031
  self.stack.push(Value::number_array(nums));
@@ -2289,10 +3048,8 @@ impl Vm {
2289
3048
  let base = self.stack.len() - 2 * n;
2290
3049
  let mut map = PropMap::with_capacity(n);
2291
3050
  for i in 0..n {
2292
- let key_val =
2293
- std::mem::replace(&mut self.stack[base + 2 * i], Value::Null);
2294
- let val =
2295
- std::mem::replace(&mut self.stack[base + 2 * i + 1], Value::Null);
3051
+ let key_val = std::mem::replace(&mut self.stack[base + 2 * i], Value::Null);
3052
+ let val = std::mem::replace(&mut self.stack[base + 2 * i + 1], Value::Null);
2296
3053
  let key: Arc<str> = key_val.to_display_string().into();
2297
3054
  map.insert(key, val);
2298
3055
  }
@@ -2448,13 +3205,25 @@ impl Vm {
2448
3205
  .iter()
2449
3206
  .map(|&n| {
2450
3207
  let elem = Value::Number(n);
2451
- let (l, r) = if param_left { (elem, const_val.clone()) } else { (const_val.clone(), elem) };
3208
+ let (l, r) = if param_left {
3209
+ (elem, const_val.clone())
3210
+ } else {
3211
+ (const_val.clone(), elem)
3212
+ };
2452
3213
  eval_binop(binop, &l, &r).unwrap_or(Value::Null)
2453
3214
  })
2454
3215
  .collect();
2455
3216
  // If every result is numeric, stay packed (the common case for x*2, x+1, etc).
2456
3217
  if mapped.iter().all(|v| matches!(v, Value::Number(_))) {
2457
- Value::number_array(mapped.into_iter().map(|v| match v { Value::Number(n) => n, _ => unreachable!() }).collect())
3218
+ Value::number_array(
3219
+ mapped
3220
+ .into_iter()
3221
+ .map(|v| match v {
3222
+ Value::Number(n) => n,
3223
+ _ => unreachable!(),
3224
+ })
3225
+ .collect(),
3226
+ )
2458
3227
  } else {
2459
3228
  Value::Array(VmRef::new(mapped))
2460
3229
  }
@@ -2464,8 +3233,16 @@ impl Vm {
2464
3233
  let mapped: Vec<Value> = arr_borrow
2465
3234
  .iter()
2466
3235
  .map(|v| {
2467
- let l: Value = if param_left { (*v).clone() } else { const_val.clone() };
2468
- let r: Value = if param_left { const_val.clone() } else { (*v).clone() };
3236
+ let l: Value = if param_left {
3237
+ (*v).clone()
3238
+ } else {
3239
+ const_val.clone()
3240
+ };
3241
+ let r: Value = if param_left {
3242
+ const_val.clone()
3243
+ } else {
3244
+ (*v).clone()
3245
+ };
2469
3246
  eval_binop(binop, &l, &r).unwrap_or(Value::Null)
2470
3247
  })
2471
3248
  .collect();
@@ -2499,7 +3276,11 @@ impl Vm {
2499
3276
  .iter()
2500
3277
  .filter(|&&n| {
2501
3278
  let elem = Value::Number(n);
2502
- let (l, r) = if param_left { (elem, const_val.clone()) } else { (const_val.clone(), elem) };
3279
+ let (l, r) = if param_left {
3280
+ (elem, const_val.clone())
3281
+ } else {
3282
+ (const_val.clone(), elem)
3283
+ };
2503
3284
  eval_binop(binop, &l, &r).unwrap_or(Value::Null).is_truthy()
2504
3285
  })
2505
3286
  .copied()
@@ -2511,7 +3292,11 @@ impl Vm {
2511
3292
  let filtered: Vec<Value> = arr_borrow
2512
3293
  .iter()
2513
3294
  .filter(|v| {
2514
- let (l, r) = if param_left { ((*v).clone(), const_val.clone()) } else { (const_val.clone(), (*v).clone()) };
3295
+ let (l, r) = if param_left {
3296
+ ((*v).clone(), const_val.clone())
3297
+ } else {
3298
+ (const_val.clone(), (*v).clone())
3299
+ };
2515
3300
  eval_binop(binop, &l, &r).unwrap_or(Value::Null).is_truthy()
2516
3301
  })
2517
3302
  .cloned()
@@ -2581,15 +3366,14 @@ impl Vm {
2581
3366
  // on the cranelift / llvm backends that want to expose
2582
3367
  // `cargo:…` Rust crates should register the module's
2583
3368
  // exports map before calling `vm.run(chunk)`.
2584
- let from_registry: Option<Value> = if spec.starts_with("cargo:")
2585
- || spec.starts_with("ffi:")
2586
- {
2587
- let regs = self.native_modules.borrow();
2588
- regs.get(spec)
2589
- .and_then(|m| m.borrow().get(&Arc::from(export_name)).cloned())
2590
- } else {
2591
- None
2592
- };
3369
+ let from_registry: Option<Value> =
3370
+ if spec.starts_with("cargo:") || spec.starts_with("ffi:") {
3371
+ let regs = self.native_modules.borrow();
3372
+ regs.get(spec)
3373
+ .and_then(|m| m.borrow().get(&Arc::from(export_name)).cloned())
3374
+ } else {
3375
+ None
3376
+ };
2593
3377
  let v = from_registry
2594
3378
  .or_else(|| get_builtin_export(self.capabilities.as_ref(), spec, export_name))
2595
3379
  .ok_or_else(|| {
@@ -2772,7 +3556,12 @@ fn eval_unary(op: UnaryOp, o: &Value) -> Result<Value, String> {
2772
3556
  /// error), refilling the cache when the object *does* have the property. Result-equivalent to
2773
3557
  /// `get_member` — the cache only skips the lookup; the shape uniquely fixes the slot for a property.
2774
3558
  #[inline]
2775
- fn ic_get_member(chunk: &Chunk, name_idx: u16, obj: &Value, key: &Arc<str>) -> Result<Value, String> {
3559
+ fn ic_get_member(
3560
+ chunk: &Chunk,
3561
+ name_idx: u16,
3562
+ obj: &Value,
3563
+ key: &Arc<str>,
3564
+ ) -> Result<Value, String> {
2776
3565
  use std::sync::atomic::Ordering::Relaxed;
2777
3566
  if let Value::Object(od) = obj {
2778
3567
  let b = od.borrow();
@@ -2853,13 +3642,21 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
2853
3642
  let map = m.borrow();
2854
3643
  // Reading a missing own property returns `null` (tish's nullish value), matching
2855
3644
  // JS object semantics and the tree-walk interpreter — not a thrown error (#66).
2856
- Ok(map.strings.get(key.as_ref()).cloned().unwrap_or(Value::Null))
3645
+ Ok(map
3646
+ .strings
3647
+ .get(key.as_ref())
3648
+ .cloned()
3649
+ .unwrap_or(Value::Null))
2857
3650
  }
2858
3651
  Value::NumberArray(a) => {
2859
3652
  let key_s = key.as_ref();
2860
3653
  // Numeric index fast path.
2861
3654
  if let Ok(idx) = key_s.parse::<usize>() {
2862
- return Ok(a.borrow().get(idx).map(|&n| Value::Number(n)).unwrap_or(Value::Null));
3655
+ return Ok(a
3656
+ .borrow()
3657
+ .get(idx)
3658
+ .map(|&n| Value::Number(n))
3659
+ .unwrap_or(Value::Null));
2863
3660
  }
2864
3661
  if key_s == "length" {
2865
3662
  return Ok(Value::Number(a.borrow().len() as f64));
@@ -2880,19 +3677,38 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
2880
3677
  Value::Number(arr.len() as f64)
2881
3678
  }),
2882
3679
  "pop" => make_native_fn(move |_: &[Value]| {
2883
- a_clone.borrow_mut().pop()
2884
- .map(|n| if n.is_nan() { Value::Null } else { Value::Number(n) })
3680
+ a_clone
3681
+ .borrow_mut()
3682
+ .pop()
3683
+ .map(|n| {
3684
+ if n.is_nan() {
3685
+ Value::Null
3686
+ } else {
3687
+ Value::Number(n)
3688
+ }
3689
+ })
2885
3690
  .unwrap_or(Value::Null)
2886
3691
  }),
2887
3692
  "shift" => make_native_fn(move |_: &[Value]| {
2888
3693
  let mut arr = a_clone.borrow_mut();
2889
- if arr.is_empty() { Value::Null }
2890
- else { let n = arr.remove(0); if n.is_nan() { Value::Null } else { Value::Number(n) } }
3694
+ if arr.is_empty() {
3695
+ Value::Null
3696
+ } else {
3697
+ let n = arr.remove(0);
3698
+ if n.is_nan() {
3699
+ Value::Null
3700
+ } else {
3701
+ Value::Number(n)
3702
+ }
3703
+ }
2891
3704
  }),
2892
3705
  "unshift" => make_native_fn(move |args: &[Value]| {
2893
3706
  let mut arr = a_clone.borrow_mut();
2894
3707
  for (i, v) in args.iter().enumerate() {
2895
- let n = match v { Value::Number(n) => *n, _ => f64::NAN };
3708
+ let n = match v {
3709
+ Value::Number(n) => *n,
3710
+ _ => f64::NAN,
3711
+ };
2896
3712
  arr.insert(i, n);
2897
3713
  }
2898
3714
  Value::Number(arr.len() as f64)
@@ -2905,7 +3721,10 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
2905
3721
  let a2 = a_clone.clone();
2906
3722
  make_native_fn(move |args: &[Value]| {
2907
3723
  // Check if there are non-numeric items to insert (args[2..]).
2908
- let has_non_numeric = args.get(2..).unwrap_or(&[]).iter()
3724
+ let has_non_numeric = args
3725
+ .get(2..)
3726
+ .unwrap_or(&[])
3727
+ .iter()
2909
3728
  .any(|v| !matches!(v, Value::Number(_)));
2910
3729
  if has_non_numeric {
2911
3730
  // Deopt: materialise, splice on the boxed array, then write numeric
@@ -2913,18 +3732,37 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
2913
3732
  // identity for subsequent accesses. The array may have non-numeric
2914
3733
  // elements after this splice — they become NaN holes in the VmRef.
2915
3734
  let boxed = Value::materialize_number_array(&a2);
2916
- let result = arr_builtins::splice(&boxed, args.first().unwrap_or(&Value::Null), args.get(1), args.get(2..).unwrap_or(&[]));
3735
+ let result = arr_builtins::splice(
3736
+ &boxed,
3737
+ args.first().unwrap_or(&Value::Null),
3738
+ args.get(1),
3739
+ args.get(2..).unwrap_or(&[]),
3740
+ );
2917
3741
  // Sync the modified boxed Vec back into the original VmRef.
2918
3742
  if let Value::Array(boxed_vmref) = &boxed {
2919
3743
  let mut packed = a2.borrow_mut();
2920
- *packed = boxed_vmref.borrow().iter().map(|v| match v { Value::Number(n) => *n, _ => f64::NAN }).collect();
3744
+ *packed = boxed_vmref
3745
+ .borrow()
3746
+ .iter()
3747
+ .map(|v| match v {
3748
+ Value::Number(n) => *n,
3749
+ _ => f64::NAN,
3750
+ })
3751
+ .collect();
2921
3752
  }
2922
3753
  result
2923
3754
  } else {
2924
3755
  let mut arr = a2.borrow_mut();
2925
3756
  let len = arr.len() as i64;
2926
3757
  let start = match args.first() {
2927
- Some(Value::Number(n)) => { let s = *n as i64; if s < 0 { (len + s).max(0) as usize } else { (s as usize).min(arr.len()) } }
3758
+ Some(Value::Number(n)) => {
3759
+ let s = *n as i64;
3760
+ if s < 0 {
3761
+ (len + s).max(0) as usize
3762
+ } else {
3763
+ (s as usize).min(arr.len())
3764
+ }
3765
+ }
2928
3766
  _ => 0,
2929
3767
  };
2930
3768
  let del = match args.get(1) {
@@ -2932,8 +3770,17 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
2932
3770
  _ => arr.len().saturating_sub(start),
2933
3771
  };
2934
3772
  let del = del.min(arr.len().saturating_sub(start));
2935
- let new_nums: Vec<f64> = args.get(2..).unwrap_or(&[]).iter().map(|v| match v { Value::Number(n) => *n, _ => f64::NAN }).collect();
2936
- let removed: Vec<f64> = arr.splice(start..start + del, new_nums).collect();
3773
+ let new_nums: Vec<f64> = args
3774
+ .get(2..)
3775
+ .unwrap_or(&[])
3776
+ .iter()
3777
+ .map(|v| match v {
3778
+ Value::Number(n) => *n,
3779
+ _ => f64::NAN,
3780
+ })
3781
+ .collect();
3782
+ let removed: Vec<f64> =
3783
+ arr.splice(start..start + del, new_nums).collect();
2937
3784
  Value::number_array(removed)
2938
3785
  }
2939
3786
  })
@@ -2956,29 +3803,111 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
2956
3803
  let boxed = Value::materialize_number_array(&a_clone);
2957
3804
  let bv = boxed.clone();
2958
3805
  match key_s {
2959
- "map" => make_native_fn(move |args| { let cb = args.first().cloned().unwrap_or(Value::Null); arr_builtins::map(&bv, &cb) }),
2960
- "filter" => make_native_fn(move |args| { let cb = args.first().cloned().unwrap_or(Value::Null); arr_builtins::filter(&bv, &cb) }),
2961
- "reduce" => make_native_fn(move |args| { let cb = args.first().cloned().unwrap_or(Value::Null); let init = args.get(1).cloned().unwrap_or(Value::Null); arr_builtins::reduce(&bv, &cb, &init) }),
2962
- "forEach" => make_native_fn(move |args| { let cb = args.first().cloned().unwrap_or(Value::Null); arr_builtins::for_each(&bv, &cb) }),
2963
- "find" => make_native_fn(move |args| { let cb = args.first().cloned().unwrap_or(Value::Null); arr_builtins::find(&bv, &cb) }),
2964
- "findIndex" => make_native_fn(move |args| { let cb = args.first().cloned().unwrap_or(Value::Null); arr_builtins::find_index(&bv, &cb) }),
2965
- "findLast" => make_native_fn(move |args| { let cb = args.first().cloned().unwrap_or(Value::Null); arr_builtins::find_last(&bv, &cb) }),
2966
- "findLastIndex" => make_native_fn(move |args| { let cb = args.first().cloned().unwrap_or(Value::Null); arr_builtins::find_last_index(&bv, &cb) }),
2967
- "at" => make_native_fn(move |args| { let i = args.first().cloned().unwrap_or(Value::Null); arr_builtins::at(&bv, &i) }),
2968
- "some" => make_native_fn(move |args| { let cb = args.first().cloned().unwrap_or(Value::Null); arr_builtins::some(&bv, &cb) }),
2969
- "every" => make_native_fn(move |args| { let cb = args.first().cloned().unwrap_or(Value::Null); arr_builtins::every(&bv, &cb) }),
2970
- "join" => make_native_fn(move |args| { let sep = args.first().cloned().unwrap_or(Value::Null); arr_builtins::join(&bv, &sep) }),
2971
- "flat" => make_native_fn(move |args| { let d = args.first().cloned().unwrap_or(Value::Number(1.0)); arr_builtins::flat(&bv, &d) }),
2972
- "flatMap" => make_native_fn(move |args| { let cb = args.first().cloned().unwrap_or(Value::Null); arr_builtins::flat_map(&bv, &cb) }),
2973
- "reverse" => make_native_fn(move |_| arr_builtins::reverse(&bv)),
2974
- "fill" => make_native_fn(move |args| { let v = args.first().cloned().unwrap_or(Value::Null); let s = args.get(1).cloned().unwrap_or(Value::Null); let e = args.get(2).cloned().unwrap_or(Value::Null); arr_builtins::fill(&bv, &v, &s, &e) }),
2975
- "slice" => make_native_fn(move |args| { let s = args.first().cloned().unwrap_or(Value::Null); let e = args.get(1).cloned().unwrap_or(Value::Null); arr_builtins::slice(&bv, &s, &e) }),
2976
- "concat" => make_native_fn(move |args| arr_builtins::concat(&bv, args)),
2977
- "indexOf" => make_native_fn(move |args| { let s = args.first().cloned().unwrap_or(Value::Null); arr_builtins::index_of(&bv, &s) }),
2978
- "includes" => make_native_fn(move |args| { let s = args.first().cloned().unwrap_or(Value::Null); let f = args.get(1).cloned(); arr_builtins::includes(&bv, &s, f.as_ref()) }),
2979
- "unshift" => make_native_fn(move |args| arr_builtins::unshift(&bv, args)),
2980
- "shift" => make_native_fn(move |_| arr_builtins::shift(&bv)),
2981
- "splice" => make_native_fn(move |args| { let s = args.first().cloned().unwrap_or(Value::Null); let dc = args.get(1).cloned(); let items: Vec<Value> = args.get(2..).unwrap_or(&[]).to_vec(); arr_builtins::splice(&bv, &s, dc.as_ref(), &items) }),
3806
+ "map" => make_native_fn(move |args| {
3807
+ let cb = args.first().cloned().unwrap_or(Value::Null);
3808
+ // #187 fusion over the SAME boxed snapshot `bv` the generic path uses, so a
3809
+ // fused result is byte-identical (a boxed all-number `Array` boxed output).
3810
+ #[cfg(not(target_arch = "wasm32"))]
3811
+ if let Some(v) = hof_fusion::map(&bv, &cb) {
3812
+ return v;
3813
+ }
3814
+ arr_builtins::map(&bv, &cb)
3815
+ }),
3816
+ "filter" => make_native_fn(move |args| {
3817
+ let cb = args.first().cloned().unwrap_or(Value::Null);
3818
+ #[cfg(not(target_arch = "wasm32"))]
3819
+ if let Some(v) = hof_fusion::filter(&bv, &cb) {
3820
+ return v;
3821
+ }
3822
+ arr_builtins::filter(&bv, &cb)
3823
+ }),
3824
+ "reduce" => make_native_fn(move |args| {
3825
+ let cb = args.first().cloned().unwrap_or(Value::Null);
3826
+ let init = args.get(1).cloned().unwrap_or(Value::Null);
3827
+ #[cfg(not(target_arch = "wasm32"))]
3828
+ if let Some(v) = hof_fusion::reduce(&bv, &cb, &init) {
3829
+ return v;
3830
+ }
3831
+ arr_builtins::reduce(&bv, &cb, &init)
3832
+ }),
3833
+ "forEach" => make_native_fn(move |args| {
3834
+ let cb = args.first().cloned().unwrap_or(Value::Null);
3835
+ #[cfg(not(target_arch = "wasm32"))]
3836
+ if let Some(v) = hof_fusion::for_each(&bv, &cb) {
3837
+ return v;
3838
+ }
3839
+ arr_builtins::for_each(&bv, &cb)
3840
+ }),
3841
+ "find" => make_native_fn(move |args| {
3842
+ let cb = args.first().cloned().unwrap_or(Value::Null);
3843
+ arr_builtins::find(&bv, &cb)
3844
+ }),
3845
+ "findIndex" => make_native_fn(move |args| {
3846
+ let cb = args.first().cloned().unwrap_or(Value::Null);
3847
+ arr_builtins::find_index(&bv, &cb)
3848
+ }),
3849
+ "findLast" => make_native_fn(move |args| {
3850
+ let cb = args.first().cloned().unwrap_or(Value::Null);
3851
+ arr_builtins::find_last(&bv, &cb)
3852
+ }),
3853
+ "findLastIndex" => make_native_fn(move |args| {
3854
+ let cb = args.first().cloned().unwrap_or(Value::Null);
3855
+ arr_builtins::find_last_index(&bv, &cb)
3856
+ }),
3857
+ "at" => make_native_fn(move |args| {
3858
+ let i = args.first().cloned().unwrap_or(Value::Null);
3859
+ arr_builtins::at(&bv, &i)
3860
+ }),
3861
+ "some" => make_native_fn(move |args| {
3862
+ let cb = args.first().cloned().unwrap_or(Value::Null);
3863
+ arr_builtins::some(&bv, &cb)
3864
+ }),
3865
+ "every" => make_native_fn(move |args| {
3866
+ let cb = args.first().cloned().unwrap_or(Value::Null);
3867
+ arr_builtins::every(&bv, &cb)
3868
+ }),
3869
+ "join" => make_native_fn(move |args| {
3870
+ let sep = args.first().cloned().unwrap_or(Value::Null);
3871
+ arr_builtins::join(&bv, &sep)
3872
+ }),
3873
+ "flat" => make_native_fn(move |args| {
3874
+ let d = args.first().cloned().unwrap_or(Value::Number(1.0));
3875
+ arr_builtins::flat(&bv, &d)
3876
+ }),
3877
+ "flatMap" => make_native_fn(move |args| {
3878
+ let cb = args.first().cloned().unwrap_or(Value::Null);
3879
+ arr_builtins::flat_map(&bv, &cb)
3880
+ }),
3881
+ "reverse" => make_native_fn(move |_| arr_builtins::reverse(&bv)),
3882
+ "fill" => make_native_fn(move |args| {
3883
+ let v = args.first().cloned().unwrap_or(Value::Null);
3884
+ let s = args.get(1).cloned().unwrap_or(Value::Null);
3885
+ let e = args.get(2).cloned().unwrap_or(Value::Null);
3886
+ arr_builtins::fill(&bv, &v, &s, &e)
3887
+ }),
3888
+ "slice" => make_native_fn(move |args| {
3889
+ let s = args.first().cloned().unwrap_or(Value::Null);
3890
+ let e = args.get(1).cloned().unwrap_or(Value::Null);
3891
+ arr_builtins::slice(&bv, &s, &e)
3892
+ }),
3893
+ "concat" => make_native_fn(move |args| arr_builtins::concat(&bv, args)),
3894
+ "indexOf" => make_native_fn(move |args| {
3895
+ let s = args.first().cloned().unwrap_or(Value::Null);
3896
+ arr_builtins::index_of(&bv, &s)
3897
+ }),
3898
+ "includes" => make_native_fn(move |args| {
3899
+ let s = args.first().cloned().unwrap_or(Value::Null);
3900
+ let f = args.get(1).cloned();
3901
+ arr_builtins::includes(&bv, &s, f.as_ref())
3902
+ }),
3903
+ "unshift" => make_native_fn(move |args| arr_builtins::unshift(&bv, args)),
3904
+ "shift" => make_native_fn(move |_| arr_builtins::shift(&bv)),
3905
+ "splice" => make_native_fn(move |args| {
3906
+ let s = args.first().cloned().unwrap_or(Value::Null);
3907
+ let dc = args.get(1).cloned();
3908
+ let items: Vec<Value> = args.get(2..).unwrap_or(&[]).to_vec();
3909
+ arr_builtins::splice(&bv, &s, dc.as_ref(), &items)
3910
+ }),
2982
3911
  _ => return Err(format!("Property '{}' not found", key)),
2983
3912
  }
2984
3913
  }
@@ -3046,20 +3975,42 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
3046
3975
  }),
3047
3976
  "map" => make_native_fn(move |args: &[Value]| {
3048
3977
  let cb = args.first().cloned().unwrap_or(Value::Null);
3049
- arr_builtins::map(&Value::Array(a_clone.clone()), &cb)
3978
+ let arr = Value::Array(a_clone.clone());
3979
+ // #187 native HOF fusion — tight native loop over a JIT'd numeric callback; bails
3980
+ // (None) to the byte-identical generic path for any non-fusable callback/array.
3981
+ #[cfg(not(target_arch = "wasm32"))]
3982
+ if let Some(v) = hof_fusion::map(&arr, &cb) {
3983
+ return v;
3984
+ }
3985
+ arr_builtins::map(&arr, &cb)
3050
3986
  }),
3051
3987
  "filter" => make_native_fn(move |args: &[Value]| {
3052
3988
  let cb = args.first().cloned().unwrap_or(Value::Null);
3053
- arr_builtins::filter(&Value::Array(a_clone.clone()), &cb)
3989
+ let arr = Value::Array(a_clone.clone());
3990
+ #[cfg(not(target_arch = "wasm32"))]
3991
+ if let Some(v) = hof_fusion::filter(&arr, &cb) {
3992
+ return v;
3993
+ }
3994
+ arr_builtins::filter(&arr, &cb)
3054
3995
  }),
3055
3996
  "reduce" => make_native_fn(move |args: &[Value]| {
3056
3997
  let cb = args.first().cloned().unwrap_or(Value::Null);
3057
3998
  let init = args.get(1).cloned().unwrap_or(Value::Null);
3058
- arr_builtins::reduce(&Value::Array(a_clone.clone()), &cb, &init)
3999
+ let arr = Value::Array(a_clone.clone());
4000
+ #[cfg(not(target_arch = "wasm32"))]
4001
+ if let Some(v) = hof_fusion::reduce(&arr, &cb, &init) {
4002
+ return v;
4003
+ }
4004
+ arr_builtins::reduce(&arr, &cb, &init)
3059
4005
  }),
3060
4006
  "forEach" => make_native_fn(move |args: &[Value]| {
3061
4007
  let cb = args.first().cloned().unwrap_or(Value::Null);
3062
- arr_builtins::for_each(&Value::Array(a_clone.clone()), &cb)
4008
+ let arr = Value::Array(a_clone.clone());
4009
+ #[cfg(not(target_arch = "wasm32"))]
4010
+ if let Some(v) = hof_fusion::for_each(&arr, &cb) {
4011
+ return v;
4012
+ }
4013
+ arr_builtins::for_each(&arr, &cb)
3063
4014
  }),
3064
4015
  "find" => make_native_fn(move |args: &[Value]| {
3065
4016
  let cb = args.first().cloned().unwrap_or(Value::Null);
@@ -3126,13 +4077,13 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
3126
4077
  Value::String(s) => {
3127
4078
  let key_s = key.as_ref();
3128
4079
  if let Ok(idx) = key_s.parse::<usize>() {
3129
- return match s.chars().nth(idx) {
4080
+ return match str_builtins::nth_char(s, idx) {
3130
4081
  Some(c) => Ok(Value::String(tishlang_core::ArcStr::from(c.to_string()))),
3131
4082
  None => Err("Index out of bounds".to_string()),
3132
4083
  };
3133
4084
  }
3134
4085
  if key_s == "length" {
3135
- return Ok(Value::Number(s.chars().count() as f64));
4086
+ return Ok(Value::Number(str_builtins::char_count(s) as f64));
3136
4087
  }
3137
4088
  let s_clone: tishlang_core::ArcStr = s.clone();
3138
4089
  let method: ArrayMethodFn = match key_s {
@@ -3144,11 +4095,7 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
3144
4095
  "lastIndexOf" => make_native_fn(move |args: &[Value]| {
3145
4096
  let search = args.first().unwrap_or(&Value::Null);
3146
4097
  let position = args.get(1).cloned().unwrap_or(Value::Number(f64::INFINITY));
3147
- str_builtins::last_index_of(
3148
- &Value::String(s_clone.clone()),
3149
- search,
3150
- &position,
3151
- )
4098
+ str_builtins::last_index_of(&Value::String(s_clone.clone()), search, &position)
3152
4099
  }),
3153
4100
  "includes" => make_native_fn(move |args: &[Value]| {
3154
4101
  let search = args.first().unwrap_or(&Value::Null);
@@ -3221,11 +4168,7 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
3221
4168
  "replaceAll" => make_native_fn(move |args: &[Value]| {
3222
4169
  let search = args.first().unwrap_or(&Value::Null);
3223
4170
  let replacement = args.get(1).unwrap_or(&Value::Null);
3224
- str_builtins::replace_all(
3225
- &Value::String(s_clone.clone()),
3226
- search,
3227
- replacement,
3228
- )
4171
+ str_builtins::replace_all(&Value::String(s_clone.clone()), search, replacement)
3229
4172
  }),
3230
4173
  #[cfg(feature = "regex")]
3231
4174
  "match" => make_native_fn(move |args: &[Value]| {
@@ -3390,10 +4333,24 @@ fn get_index(obj: &Value, idx: &Value) -> Result<Value, String> {
3390
4333
  Value::NumberArray(a) => {
3391
4334
  let i = match idx {
3392
4335
  Value::Number(n) => *n as usize,
3393
- _ => return Err(format!("Array index must be number, got {}", idx.type_name())),
4336
+ _ => {
4337
+ return Err(format!(
4338
+ "Array index must be number, got {}",
4339
+ idx.type_name()
4340
+ ))
4341
+ }
3394
4342
  };
3395
4343
  // NaN is used as the hole marker (sparse-array positions); reads return Null.
3396
- Ok(a.borrow().get(i).map(|&n| if n.is_nan() { Value::Null } else { Value::Number(n) }).unwrap_or(Value::Null))
4344
+ Ok(a.borrow()
4345
+ .get(i)
4346
+ .map(|&n| {
4347
+ if n.is_nan() {
4348
+ Value::Null
4349
+ } else {
4350
+ Value::Number(n)
4351
+ }
4352
+ })
4353
+ .unwrap_or(Value::Null))
3397
4354
  }
3398
4355
  Value::Array(a) => {
3399
4356
  let i = match idx {
@@ -3405,11 +4362,7 @@ fn get_index(obj: &Value, idx: &Value) -> Result<Value, String> {
3405
4362
  ));
3406
4363
  }
3407
4364
  };
3408
- Ok(a
3409
- .borrow()
3410
- .get(i)
3411
- .cloned()
3412
- .unwrap_or(Value::Null))
4365
+ Ok(a.borrow().get(i).cloned().unwrap_or(Value::Null))
3413
4366
  }
3414
4367
  Value::String(s) => {
3415
4368
  let i = match idx {
@@ -3421,12 +4374,7 @@ fn get_index(obj: &Value, idx: &Value) -> Result<Value, String> {
3421
4374
  n
3422
4375
  ));
3423
4376
  }
3424
- let i = n as usize;
3425
- let len = s.chars().count();
3426
- if i >= len {
3427
- return Err("Index out of bounds".to_string());
3428
- }
3429
- i
4377
+ n as usize
3430
4378
  }
3431
4379
  _ => {
3432
4380
  return Err(format!(
@@ -3435,7 +4383,9 @@ fn get_index(obj: &Value, idx: &Value) -> Result<Value, String> {
3435
4383
  ));
3436
4384
  }
3437
4385
  };
3438
- match s.chars().nth(i) {
4386
+ // `nth_char` returns None past the end, so the cursor cache handles bounds — no separate
4387
+ // O(n) `chars().count()` pre-check (#203).
4388
+ match str_builtins::nth_char(s, i) {
3439
4389
  Some(c) => Ok(Value::String(tishlang_core::ArcStr::from(c.to_string()))),
3440
4390
  None => Err("Index out of bounds".to_string()),
3441
4391
  }
@@ -3455,7 +4405,7 @@ fn get_index(obj: &Value, idx: &Value) -> Result<Value, String> {
3455
4405
  }
3456
4406
  };
3457
4407
  get_member(obj, &key_arc)
3458
- },
4408
+ }
3459
4409
  _ => Err(format!(
3460
4410
  "Cannot read property '{}' of {}",
3461
4411
  idx.to_display_string(),
@@ -3497,7 +4447,12 @@ fn set_index(obj: &Value, idx: &Value, val: Value) -> Result<(), String> {
3497
4447
  Value::NumberArray(a) => {
3498
4448
  let i = match idx {
3499
4449
  Value::Number(n) => *n as usize,
3500
- _ => return Err(format!("Array index must be number, got {}", idx.type_name())),
4450
+ _ => {
4451
+ return Err(format!(
4452
+ "Array index must be number, got {}",
4453
+ idx.type_name()
4454
+ ))
4455
+ }
3501
4456
  };
3502
4457
  // In-bounds numeric assignment stays packed.
3503
4458
  // Out-of-bounds or non-numeric falls through to the Array path by returning
@@ -3508,7 +4463,9 @@ fn set_index(obj: &Value, idx: &Value, val: Value) -> Result<(), String> {
3508
4463
  Value::Number(n) => {
3509
4464
  let mut arr = a.borrow_mut();
3510
4465
  // Extend with NaN "holes" if needed (NaN = sparse hole; read back as Null).
3511
- while arr.len() <= i { arr.push(f64::NAN); }
4466
+ while arr.len() <= i {
4467
+ arr.push(f64::NAN);
4468
+ }
3512
4469
  arr[i] = n;
3513
4470
  }
3514
4471
  // Non-numeric set: the Vec<f64> can't represent this type. Extend with NaN holes
@@ -3518,7 +4475,9 @@ fn set_index(obj: &Value, idx: &Value, val: Value) -> Result<(), String> {
3518
4475
  // numeric elements and Null for the NaN holes.
3519
4476
  _ => {
3520
4477
  let mut arr = a.borrow_mut();
3521
- while arr.len() <= i { arr.push(f64::NAN); }
4478
+ while arr.len() <= i {
4479
+ arr.push(f64::NAN);
4480
+ }
3522
4481
  // arr[i] is already NaN (hole); we can't store the non-numeric value — acceptable
3523
4482
  // for the experimental TISH_PACKED_ARRAYS path.
3524
4483
  }
@@ -3558,3 +4517,89 @@ pub fn run_with_options(chunk: &Chunk, opts: VmRunOptions) -> Result<Value, Stri
3558
4517
  let mut vm = Vm::with_capabilities(opts.capabilities);
3559
4518
  vm.run_with_options(chunk, opts.repl_mode)
3560
4519
  }
4520
+
4521
+ #[cfg(test)]
4522
+ mod recursion_limit_tests_381 {
4523
+ use tishlang_core::{set_max_call_depth_for_test, Value, DEFAULT_MAX_CALL_DEPTH};
4524
+
4525
+ fn run_src(src: &str) -> Result<Value, String> {
4526
+ let program = tishlang_parser::parse(src).expect("parse");
4527
+ let chunk = tishlang_bytecode::compile(&program).expect("compile");
4528
+ super::run(&chunk)
4529
+ }
4530
+
4531
+ #[test]
4532
+ fn deep_recursion_is_catchable_not_abort() {
4533
+ // Without the guard this infinite recursion aborts the process (SIGABRT). With it, the throw
4534
+ // is a catchable RangeError: try/catch recovers and the program returns normally.
4535
+ set_max_call_depth_for_test(300);
4536
+ // Object-returning recursion is NOT JIT-eligible, so it takes the guarded VM call path.
4537
+ let src = "let ok = false\n\
4538
+ fn rec(n) { return { v: rec(n + 1) } }\n\
4539
+ try { rec(0) } catch (e) { ok = true }\n\
4540
+ ok";
4541
+ let out = run_src(src);
4542
+ set_max_call_depth_for_test(DEFAULT_MAX_CALL_DEPTH);
4543
+ assert!(
4544
+ out.is_ok(),
4545
+ "deep recursion must be catchable, not abort/error: {out:?}"
4546
+ );
4547
+ }
4548
+
4549
+ #[test]
4550
+ fn uncaught_deep_recursion_surfaces_error_not_abort() {
4551
+ // An UNCAUGHT infinite recursion must surface as a returned error, never a SIGABRT.
4552
+ set_max_call_depth_for_test(300);
4553
+ let out = run_src("fn rec(n) { return { v: rec(n + 1) } }\nrec(0)");
4554
+ set_max_call_depth_for_test(DEFAULT_MAX_CALL_DEPTH);
4555
+ assert!(
4556
+ out.is_err(),
4557
+ "uncaught deep recursion must return an error, got {out:?}"
4558
+ );
4559
+ }
4560
+
4561
+ #[test]
4562
+ fn normal_recursion_is_unaffected() {
4563
+ set_max_call_depth_for_test(20_000);
4564
+ let out = run_src(
4565
+ "fn fib(n) { if (n < 2) { return n } return fib(n - 1) + fib(n - 2) }\nfib(15)",
4566
+ );
4567
+ set_max_call_depth_for_test(DEFAULT_MAX_CALL_DEPTH);
4568
+ assert!(
4569
+ out.is_ok(),
4570
+ "normal recursion must not be affected: {out:?}"
4571
+ );
4572
+ }
4573
+
4574
+ // The core of #381 for the JIT tier: a pure-numeric self-recursive function JIT-compiles and
4575
+ // recurses on the native stack via SelfCall, BELOW the VM's depth counter (so `set_max_call_depth`
4576
+ // can't bound it). Without the guard this overflows the native stack — an uncatchable SIGSEGV/abort
4577
+ // that would kill the whole process. With it, the entry SP check turns the overflow into a
4578
+ // RangeError which, uncaught, must surface as a returned `Err`. Run on a bounded-stack thread so
4579
+ // the guard (or, on a regression, the overflow) is reached fast: a working guard lets the thread
4580
+ // `join` cleanly with the error flag; a broken one aborts the process (the loud regression signal).
4581
+ #[test]
4582
+ fn jit_deep_recursion_surfaces_error_not_abort() {
4583
+ // `Value` is `!Send` (holds `Rc`), so reduce to a Send bool inside the thread: true iff the
4584
+ // overflow surfaced as the catchable stack-overflow error rather than aborting.
4585
+ let handle = std::thread::Builder::new()
4586
+ .stack_size(1024 * 1024)
4587
+ .spawn(|| {
4588
+ matches!(
4589
+ run_src(
4590
+ "fn dive(n) { if (n <= 0.0) { return 0.0 } return dive(n - 1.0) + 1.0 }\n\
4591
+ dive(50000000.0)",
4592
+ ),
4593
+ Err(ref e) if e.contains("call stack")
4594
+ )
4595
+ })
4596
+ .expect("spawn");
4597
+ let surfaced = handle
4598
+ .join()
4599
+ .expect("thread must not abort — the guard must catch the overflow");
4600
+ assert!(
4601
+ surfaced,
4602
+ "uncaught JIT deep recursion must surface as a RangeError, not abort"
4603
+ );
4604
+ }
4605
+ }