@tishlang/tish-lsp 2.12.0 → 2.35.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 (88) hide show
  1. package/Cargo.toml +3 -0
  2. package/bin/tish-lsp +0 -0
  3. package/crates/js_to_tish/src/transform/expr.rs +12 -2
  4. package/crates/tish/tests/fixtures/fs_parity_callbacks.tish +22 -0
  5. package/crates/tish/tests/fixtures/fs_parity_promises.tish +21 -0
  6. package/crates/tish/tests/fixtures/fs_parity_sync.tish +20 -0
  7. package/crates/tish/tests/fs_parity.rs +103 -0
  8. package/crates/tish/tests/integration_test.rs +80 -0
  9. package/crates/tish/tests/shortcircuit.rs +3 -3
  10. package/crates/tish_ast/src/ast.rs +84 -0
  11. package/crates/tish_builtins/Cargo.toml +1 -0
  12. package/crates/tish_builtins/src/array.rs +724 -72
  13. package/crates/tish_builtins/src/collections.rs +149 -40
  14. package/crates/tish_builtins/src/globals.rs +215 -25
  15. package/crates/tish_builtins/src/math.rs +65 -7
  16. package/crates/tish_builtins/src/number.rs +173 -0
  17. package/crates/tish_builtins/src/string.rs +263 -23
  18. package/crates/tish_bytecode/src/chunk.rs +7 -0
  19. package/crates/tish_bytecode/src/compiler.rs +611 -63
  20. package/crates/tish_bytecode/src/lib.rs +1 -1
  21. package/crates/tish_bytecode/src/opcode.rs +156 -2
  22. package/crates/tish_bytecode/src/serialize.rs +2 -0
  23. package/crates/tish_bytecode/tests/append_local_string_builder.rs +63 -0
  24. package/crates/tish_bytecode/tests/math_unary_intrinsic.rs +47 -0
  25. package/crates/tish_compile/src/codegen.rs +18219 -5283
  26. package/crates/tish_compile/src/infer.rs +1562 -30
  27. package/crates/tish_compile/src/lib.rs +29 -4
  28. package/crates/tish_compile/src/resolve.rs +151 -13
  29. package/crates/tish_compile/src/types.rs +224 -28
  30. package/crates/tish_compile/tests/dump_codegen.rs +21 -0
  31. package/crates/tish_compile/tests/perf_codegen_169_173.rs +8 -17
  32. package/crates/tish_compile/tests/perf_codegen_173_part3.rs +61 -0
  33. package/crates/tish_compile/tests/perf_codegen_174.rs +160 -0
  34. package/crates/tish_compile/tests/perf_codegen_175.rs +190 -0
  35. package/crates/tish_compile/tests/perf_codegen_176.rs +60 -0
  36. package/crates/tish_compile/tests/perf_codegen_177.rs +167 -0
  37. package/crates/tish_compile/tests/perf_codegen_178.rs +114 -0
  38. package/crates/tish_compile/tests/perf_codegen_178_rec.rs +124 -0
  39. package/crates/tish_compile/tests/perf_codegen_181.rs +36 -0
  40. package/crates/tish_compile/tests/perf_codegen_320.rs +211 -0
  41. package/crates/tish_compile/tests/perf_codegen_module_const_forof.rs +40 -0
  42. package/crates/tish_compile_js/src/codegen.rs +94 -3
  43. package/crates/tish_compile_js/src/tests_jsx.rs +27 -0
  44. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +115 -2
  45. package/crates/tish_core/Cargo.toml +4 -0
  46. package/crates/tish_core/src/json.rs +109 -6
  47. package/crates/tish_core/src/lib.rs +222 -2
  48. package/crates/tish_core/src/shape.rs +4 -2
  49. package/crates/tish_core/src/uri.rs +45 -0
  50. package/crates/tish_core/src/value.rs +571 -35
  51. package/crates/tish_core/src/vmref.rs +14 -0
  52. package/crates/tish_eval/Cargo.toml +2 -1
  53. package/crates/tish_eval/src/eval.rs +1328 -101
  54. package/crates/tish_eval/src/natives.rs +283 -18
  55. package/crates/tish_eval/src/regex.rs +47 -1
  56. package/crates/tish_eval/src/value.rs +76 -21
  57. package/crates/tish_ffi/src/lib.rs +11 -1
  58. package/crates/tish_ffi/tests/double_free.rs +35 -0
  59. package/crates/tish_fmt/src/lib.rs +94 -1
  60. package/crates/tish_lexer/src/lib.rs +76 -0
  61. package/crates/tish_lexer/src/token.rs +4 -0
  62. package/crates/tish_lint/src/lib.rs +126 -0
  63. package/crates/tish_lsp/Cargo.toml +1 -1
  64. package/crates/tish_lsp/README.md +2 -1
  65. package/crates/tish_lsp/src/main.rs +378 -28
  66. package/crates/tish_native/src/build.rs +41 -0
  67. package/crates/tish_opt/src/lib.rs +20 -1
  68. package/crates/tish_parser/Cargo.toml +4 -0
  69. package/crates/tish_parser/src/lib.rs +68 -0
  70. package/crates/tish_parser/src/parser.rs +479 -28
  71. package/crates/tish_resolve/src/lib.rs +75 -5
  72. package/crates/tish_runtime/Cargo.toml +10 -1
  73. package/crates/tish_runtime/src/fs_ext.rs +359 -0
  74. package/crates/tish_runtime/src/http.rs +28 -10
  75. package/crates/tish_runtime/src/http_fetch.rs +150 -1
  76. package/crates/tish_runtime/src/http_hyper.rs +41 -18
  77. package/crates/tish_runtime/src/http_prefork.rs +72 -11
  78. package/crates/tish_runtime/src/lib.rs +651 -51
  79. package/crates/tish_runtime/src/timers.rs +49 -2
  80. package/crates/tish_ui/src/jsx.rs +21 -3
  81. package/crates/tish_vm/src/jit.rs +2514 -117
  82. package/crates/tish_vm/src/vm.rs +1479 -224
  83. package/package.json +1 -1
  84. package/platform/darwin-arm64/tish-lsp +0 -0
  85. package/platform/darwin-x64/tish-lsp +0 -0
  86. package/platform/linux-arm64/tish-lsp +0 -0
  87. package/platform/linux-x64/tish-lsp +0 -0
  88. package/platform/win32-x64/tish-lsp.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.
@@ -150,28 +180,65 @@ pub fn all_compiled_capabilities() -> HashSet<String> {
150
180
  fn get_builtin_export(enabled: &HashSet<String>, spec: &str, export_name: &str) -> Option<Value> {
151
181
  #[cfg(feature = "fs")]
152
182
  if spec == "tish:fs" && cap_allows(enabled, "fs") {
183
+ use tishlang_runtime::fs_ext as fx;
153
184
  return match export_name {
154
- "readFile" => Some(Value::native(|args: &[Value]| {
155
- tishlang_runtime::read_file(args)
156
- })),
157
- "writeFile" => Some(Value::native(|args: &[Value]| {
158
- tishlang_runtime::write_file(args)
159
- })),
160
- "fileExists" => Some(Value::native(|args: &[Value]| {
161
- tishlang_runtime::file_exists(args)
162
- })),
163
- "isDir" => Some(Value::native(|args: &[Value]| {
164
- tishlang_runtime::is_dir(args)
165
- })),
166
- "readDir" => Some(Value::native(|args: &[Value]| {
167
- tishlang_runtime::read_dir(args)
168
- })),
169
- "mkdir" => Some(Value::native(|args: &[Value]| {
170
- tishlang_runtime::mkdir(args)
171
- })),
172
- "readFileBytes" => Some(Value::native(|args: &[Value]| {
173
- tishlang_runtime::read_file_bytes(args)
174
- })),
185
+ // tish names → the dual fs_ext functions (sync, or Node callback if a fn is passed)
186
+ "readFile" => Some(Value::native(|a: &[Value]| fx::read_file(a))),
187
+ "writeFile" => Some(Value::native(|a: &[Value]| fx::write_file(a))),
188
+ "fileExists" => Some(Value::native(|a: &[Value]| tishlang_runtime::file_exists(a))),
189
+ "isDir" => Some(Value::native(|a: &[Value]| tishlang_runtime::is_dir(a))),
190
+ "readDir" => Some(Value::native(|a: &[Value]| fx::readdir(a))),
191
+ "readFileBytes" => Some(Value::native(|a: &[Value]| fx::read_file_bytes(a))),
192
+ // Node-compatible names + new methods (sync) — issue #122
193
+ "readFileSync" => Some(Value::native(|a: &[Value]| fx::read_file(a))),
194
+ "readFileBytesSync" => Some(Value::native(|a: &[Value]| fx::read_file_bytes(a))),
195
+ "writeFileSync" => Some(Value::native(|a: &[Value]| fx::write_file(a))),
196
+ "appendFile" | "appendFileSync" => Some(Value::native(|a: &[Value]| fx::append_file(a))),
197
+ "existsSync" => Some(Value::native(|a: &[Value]| fx::exists(a))),
198
+ "mkdir" | "mkdirSync" => Some(Value::native(|a: &[Value]| fx::mkdir(a))),
199
+ "readdir" | "readdirSync" => Some(Value::native(|a: &[Value]| fx::readdir(a))),
200
+ "stat" | "statSync" => Some(Value::native(|a: &[Value]| fx::stat(a))),
201
+ "lstat" | "lstatSync" => Some(Value::native(|a: &[Value]| fx::lstat(a))),
202
+ "rm" | "rmSync" => Some(Value::native(|a: &[Value]| fx::rm(a))),
203
+ "rmdir" | "rmdirSync" => Some(Value::native(|a: &[Value]| fx::rmdir(a))),
204
+ "unlink" | "unlinkSync" => Some(Value::native(|a: &[Value]| fx::unlink(a))),
205
+ "rename" | "renameSync" => Some(Value::native(|a: &[Value]| fx::rename(a))),
206
+ "copyFile" | "copyFileSync" => Some(Value::native(|a: &[Value]| fx::copy_file(a))),
207
+ "realpath" | "realpathSync" => Some(Value::native(|a: &[Value]| fx::realpath(a))),
208
+ "readlink" | "readlinkSync" => Some(Value::native(|a: &[Value]| fx::readlink(a))),
209
+ "truncate" | "truncateSync" => Some(Value::native(|a: &[Value]| fx::truncate(a))),
210
+ "mkdtemp" | "mkdtempSync" => Some(Value::native(|a: &[Value]| fx::mkdtemp(a))),
211
+ "cp" | "cpSync" => Some(Value::native(|a: &[Value]| fx::cp(a))),
212
+ "access" | "accessSync" => Some(Value::native(|a: &[Value]| fx::access(a))),
213
+ "constants" => Some(fx::constants()),
214
+ _ => None,
215
+ };
216
+ }
217
+ #[cfg(feature = "fs")]
218
+ if spec == "tish:fs/promises" && cap_allows(enabled, "fs") {
219
+ use tishlang_runtime::fs_ext as fx;
220
+ return match export_name {
221
+ "readFile" => Some(Value::native(|a: &[Value]| fx::read_file_promise(a))),
222
+ "readFileBytes" => Some(Value::native(|a: &[Value]| fx::read_file_bytes_promise(a))),
223
+ "writeFile" => Some(Value::native(|a: &[Value]| fx::write_file_promise(a))),
224
+ "appendFile" => Some(Value::native(|a: &[Value]| fx::append_file_promise(a))),
225
+ "mkdir" => Some(Value::native(|a: &[Value]| fx::mkdir_promise(a))),
226
+ "readdir" => Some(Value::native(|a: &[Value]| fx::readdir_promise(a))),
227
+ "stat" => Some(Value::native(|a: &[Value]| fx::stat_promise(a))),
228
+ "lstat" => Some(Value::native(|a: &[Value]| fx::lstat_promise(a))),
229
+ "rm" => Some(Value::native(|a: &[Value]| fx::rm_promise(a))),
230
+ "rmdir" => Some(Value::native(|a: &[Value]| fx::rmdir_promise(a))),
231
+ "unlink" => Some(Value::native(|a: &[Value]| fx::unlink_promise(a))),
232
+ "rename" => Some(Value::native(|a: &[Value]| fx::rename_promise(a))),
233
+ "copyFile" => Some(Value::native(|a: &[Value]| fx::copy_file_promise(a))),
234
+ "realpath" => Some(Value::native(|a: &[Value]| fx::realpath_promise(a))),
235
+ "readlink" => Some(Value::native(|a: &[Value]| fx::readlink_promise(a))),
236
+ "truncate" => Some(Value::native(|a: &[Value]| fx::truncate_promise(a))),
237
+ "mkdtemp" => Some(Value::native(|a: &[Value]| fx::mkdtemp_promise(a))),
238
+ "cp" => Some(Value::native(|a: &[Value]| fx::cp_promise(a))),
239
+ "access" => Some(Value::native(|a: &[Value]| fx::access_promise(a))),
240
+ "exists" => Some(Value::native(|a: &[Value]| fx::exists_promise(a))),
241
+ "constants" => Some(fx::constants()),
175
242
  _ => None,
176
243
  };
177
244
  }
@@ -199,13 +266,17 @@ fn get_builtin_export(enabled: &HashSet<String>, spec: &str, export_name: &str)
199
266
  Value::Function(_) => raw,
200
267
  Value::Object(ref obj) => {
201
268
  let obj_ref = obj.borrow();
202
- if let Some(Value::Function(on_worker)) =
203
- obj_ref.strings.get(&std::sync::Arc::from("onWorker")).cloned()
269
+ if let Some(Value::Function(on_worker)) = obj_ref
270
+ .strings
271
+ .get(&std::sync::Arc::from("onWorker"))
272
+ .cloned()
204
273
  {
205
274
  let args_for_init = [Value::Number(0.0)];
206
275
  on_worker.call(&args_for_init)
207
- } else if let Some(h) =
208
- obj_ref.strings.get(&std::sync::Arc::from("handler")).cloned()
276
+ } else if let Some(h) = obj_ref
277
+ .strings
278
+ .get(&std::sync::Arc::from("handler"))
279
+ .cloned()
209
280
  {
210
281
  h
211
282
  } else {
@@ -263,8 +334,14 @@ fn get_builtin_export(enabled: &HashSet<String>, spec: &str, export_name: &str)
263
334
  "exec" => Some(Value::native(|args: &[Value]| {
264
335
  tishlang_runtime::process_exec(args)
265
336
  })),
337
+ "execFile" => Some(Value::native(|args: &[Value]| {
338
+ tishlang_runtime::process_exec_file(args)
339
+ })),
266
340
  "argv" => Some(Value::Array(VmRef::new(
267
- tishlang_core::process_argv().into_iter().map(|s| Value::String(s.into())).collect(),
341
+ tishlang_core::process_argv()
342
+ .into_iter()
343
+ .map(|s| Value::String(s.into()))
344
+ .collect(),
268
345
  ))),
269
346
  "env" => Some(value_object_from_map(
270
347
  std::env::vars()
@@ -285,10 +362,17 @@ fn get_builtin_export(enabled: &HashSet<String>, spec: &str, export_name: &str)
285
362
  "exec".into(),
286
363
  Value::native(|args: &[Value]| tishlang_runtime::process_exec(args)),
287
364
  );
365
+ m.insert(
366
+ "execFile".into(),
367
+ Value::native(|args: &[Value]| tishlang_runtime::process_exec_file(args)),
368
+ );
288
369
  m.insert(
289
370
  "argv".into(),
290
371
  Value::Array(VmRef::new(
291
- tishlang_core::process_argv().into_iter().map(|s| Value::String(s.into())).collect(),
372
+ tishlang_core::process_argv()
373
+ .into_iter()
374
+ .map(|s| Value::String(s.into()))
375
+ .collect(),
292
376
  )),
293
377
  );
294
378
  m.insert(
@@ -331,8 +415,12 @@ fn get_builtin_export(enabled: &HashSet<String>, spec: &str, export_name: &str)
331
415
  #[cfg(feature = "tty")]
332
416
  if spec == "tish:tty" && cap_allows(enabled, "tty") {
333
417
  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))),
418
+ "size" => Some(Value::native(|args: &[Value]| {
419
+ tishlang_runtime::tty_size(args)
420
+ })),
421
+ "isTTY" => Some(Value::native(|args: &[Value]| {
422
+ tishlang_runtime::tty_is_tty(args)
423
+ })),
336
424
  "setRawMode" => Some(Value::native(|args: &[Value]| {
337
425
  tishlang_runtime::tty_set_raw_mode(args)
338
426
  })),
@@ -342,7 +430,9 @@ fn get_builtin_export(enabled: &HashSet<String>, spec: &str, export_name: &str)
342
430
  "leaveAltScreen" => Some(Value::native(|args: &[Value]| {
343
431
  tishlang_runtime::tty_leave_alt_screen(args)
344
432
  })),
345
- "read" => Some(Value::native(|args: &[Value]| tishlang_runtime::tty_read(args))),
433
+ "read" => Some(Value::native(|args: &[Value]| {
434
+ tishlang_runtime::tty_read(args)
435
+ })),
346
436
  "readLine" => Some(Value::native(|args: &[Value]| {
347
437
  tishlang_runtime::tty_read_line(args)
348
438
  })),
@@ -484,6 +574,13 @@ fn init_globals(enabled: &HashSet<String>) -> ObjectMap {
484
574
  "pow".into(),
485
575
  Value::native(|args: &[Value]| math_builtins::pow(args)),
486
576
  );
577
+ // `imul` delegates to the shared builtin (ToInt32 + wrapping i32 multiply) so the vm never
578
+ // diverges from interp/native (#247). Was previously absent from the vm `Math` object, so
579
+ // `Math.imul(...)` threw "Call of non-function" on the bytecode VM.
580
+ math.insert(
581
+ "imul".into(),
582
+ Value::native(|args: &[Value]| math_builtins::imul(args)),
583
+ );
487
584
  math.insert(
488
585
  "sin".into(),
489
586
  Value::native(|args: &[Value]| math_builtins::sin(args)),
@@ -544,13 +641,12 @@ fn init_globals(enabled: &HashSet<String>) -> ObjectMap {
544
641
  Value::Number(n.acos())
545
642
  }),
546
643
  );
644
+ // hypot delegates to the shared builtin (variadic, NaN/Infinity-correct) so the vm never diverges
645
+ // from interp/native (#247). The old inline `filter_map(as_number)` dropped non-number args
646
+ // instead of propagating NaN.
547
647
  math.insert(
548
648
  "hypot".into(),
549
- Value::native(|args: &[Value]| {
550
- let nums: Vec<f64> = args.iter().filter_map(|v| v.as_number()).collect();
551
- let sum_sq: f64 = nums.iter().map(|n| n * n).sum();
552
- Value::Number(sum_sq.sqrt())
553
- }),
649
+ Value::native(|args: &[Value]| math_builtins::hypot(args)),
554
650
  );
555
651
  // Hyperbolic, inverse-hyperbolic, cbrt and base-2/10 logs. Like the trig block above
556
652
  // these aren't in `math_builtins`, and on the wasm/native VM there is no host `Math`
@@ -575,6 +671,10 @@ fn init_globals(enabled: &HashSet<String>) -> ObjectMap {
575
671
  math_unary!("cbrt", cbrt);
576
672
  math_unary!("log2", log2);
577
673
  math_unary!("log10", log10);
674
+ math_unary!("expm1", exp_m1);
675
+ math_unary!("log1p", ln_1p);
676
+ math.insert("clz32".into(), Value::native(|a: &[Value]| math_builtins::clz32(a)));
677
+ math.insert("fround".into(), Value::native(|a: &[Value]| math_builtins::fround(a)));
578
678
  math.insert("PI".into(), Value::Number(std::f64::consts::PI));
579
679
  math.insert("E".into(), Value::Number(std::f64::consts::E));
580
680
  g.insert("Math".into(), value_object_from_map(math));
@@ -615,6 +715,18 @@ fn init_globals(enabled: &HashSet<String>) -> ObjectMap {
615
715
  "decodeURI".into(),
616
716
  Value::native(|args: &[Value]| globals_builtins::decode_uri(args)),
617
717
  );
718
+ g.insert(
719
+ "encodeURIComponent".into(),
720
+ Value::native(|args: &[Value]| globals_builtins::encode_uri_component(args)),
721
+ );
722
+ g.insert(
723
+ "decodeURIComponent".into(),
724
+ Value::native(|args: &[Value]| globals_builtins::decode_uri_component(args)),
725
+ );
726
+ g.insert(
727
+ "structuredClone".into(),
728
+ Value::native(|args: &[Value]| globals_builtins::structured_clone(args)),
729
+ );
618
730
  g.insert(
619
731
  "htmlEscape".into(),
620
732
  Value::native(|args: &[Value]| {
@@ -642,10 +754,7 @@ fn init_globals(enabled: &HashSet<String>) -> ObjectMap {
642
754
  Value::String(v.type_name().into())
643
755
  }),
644
756
  );
645
- g.insert(
646
- "Symbol".into(),
647
- tishlang_builtins::symbol::symbol_object(),
648
- );
757
+ g.insert("Symbol".into(), tishlang_builtins::symbol::symbol_object());
649
758
 
650
759
  // Date - full constructor (new Date(...)) plus statics now()/parse()/UTC().
651
760
  g.insert(
@@ -666,14 +775,38 @@ fn init_globals(enabled: &HashSet<String>) -> ObjectMap {
666
775
  "Float64Array",
667
776
  tishlang_builtins::typedarrays::float64_array_constructor_value as fn() -> Value,
668
777
  ),
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),
778
+ (
779
+ "Float32Array",
780
+ tishlang_builtins::typedarrays::float32_array_constructor_value,
781
+ ),
782
+ (
783
+ "Int8Array",
784
+ tishlang_builtins::typedarrays::int8_array_constructor_value,
785
+ ),
786
+ (
787
+ "Uint8Array",
788
+ tishlang_builtins::typedarrays::uint8_array_constructor_value,
789
+ ),
790
+ (
791
+ "Uint8ClampedArray",
792
+ tishlang_builtins::typedarrays::uint8_clamped_array_constructor_value,
793
+ ),
794
+ (
795
+ "Int16Array",
796
+ tishlang_builtins::typedarrays::int16_array_constructor_value,
797
+ ),
798
+ (
799
+ "Uint16Array",
800
+ tishlang_builtins::typedarrays::uint16_array_constructor_value,
801
+ ),
802
+ (
803
+ "Int32Array",
804
+ tishlang_builtins::typedarrays::int32_array_constructor_value,
805
+ ),
806
+ (
807
+ "Uint32Array",
808
+ tishlang_builtins::typedarrays::uint32_array_constructor_value,
809
+ ),
677
810
  ] {
678
811
  g.insert(name.into(), ctor());
679
812
  }
@@ -683,7 +816,10 @@ fn init_globals(enabled: &HashSet<String>) -> ObjectMap {
683
816
  );
684
817
  // Error constructors (issue #60): `new Error(msg)` / `Error(msg)` → `{ name, message }`.
685
818
  for name in ["Error", "TypeError", "RangeError", "SyntaxError"] {
686
- g.insert(name.into(), construct_builtin::error_constructor_value(name));
819
+ g.insert(
820
+ name.into(),
821
+ construct_builtin::error_constructor_value(name),
822
+ );
687
823
  }
688
824
 
689
825
  // Object methods - delegate to tishlang_builtins::globals
@@ -692,6 +828,18 @@ fn init_globals(enabled: &HashSet<String>) -> ObjectMap {
692
828
  "assign".into(),
693
829
  Value::native(|args: &[Value]| globals_builtins::object_assign(args)),
694
830
  );
831
+ object_methods.insert(
832
+ "is".into(),
833
+ Value::native(|args: &[Value]| globals_builtins::object_is(args)),
834
+ );
835
+ object_methods.insert(
836
+ "hasOwn".into(),
837
+ Value::native(|args: &[Value]| globals_builtins::object_has_own(args)),
838
+ );
839
+ object_methods.insert(
840
+ "getOwnPropertyNames".into(),
841
+ Value::native(|args: &[Value]| globals_builtins::object_keys(args)),
842
+ );
695
843
  object_methods.insert(
696
844
  "fromEntries".into(),
697
845
  Value::native(|args: &[Value]| globals_builtins::object_from_entries(args)),
@@ -717,6 +865,14 @@ fn init_globals(enabled: &HashSet<String>) -> ObjectMap {
717
865
  "isArray".into(),
718
866
  Value::native(|args: &[Value]| globals_builtins::array_is_array(args)),
719
867
  );
868
+ array_static.insert(
869
+ "of".into(),
870
+ Value::native(|args: &[Value]| globals_builtins::array_of(args)),
871
+ );
872
+ array_static.insert(
873
+ "from".into(),
874
+ Value::native(|args: &[Value]| globals_builtins::array_from(args)),
875
+ );
720
876
  array_static.insert(
721
877
  Arc::from("__call"),
722
878
  Value::native(|args: &[Value]| construct_builtin::array_construct(args)),
@@ -733,12 +889,26 @@ fn init_globals(enabled: &HashSet<String>) -> ObjectMap {
733
889
  string_static.insert(Arc::from("__call"), string_convert_fn);
734
890
  g.insert("String".into(), value_object_from_map(string_static));
735
891
 
736
- // Number(value) coercion as a callable global (issue #36).
892
+ // Number(value) coercion as a callable global (issue #36) + the `Number.*` statics.
737
893
  let mut number_static = ObjectMap::default();
738
894
  number_static.insert(
739
895
  Arc::from("__call"),
740
896
  Value::native(|args: &[Value]| globals_builtins::number_convert(args)),
741
897
  );
898
+ number_static.insert(Arc::from("isInteger"), Value::native(|a: &[Value]| globals_builtins::number_is_integer(a)));
899
+ number_static.insert(Arc::from("isSafeInteger"), Value::native(|a: &[Value]| globals_builtins::number_is_safe_integer(a)));
900
+ number_static.insert(Arc::from("isNaN"), Value::native(|a: &[Value]| globals_builtins::number_is_nan(a)));
901
+ number_static.insert(Arc::from("isFinite"), Value::native(|a: &[Value]| globals_builtins::number_is_finite(a)));
902
+ number_static.insert(Arc::from("parseInt"), Value::native(|a: &[Value]| globals_builtins::parse_int(a)));
903
+ number_static.insert(Arc::from("parseFloat"), Value::native(|a: &[Value]| globals_builtins::parse_float(a)));
904
+ number_static.insert(Arc::from("MAX_SAFE_INTEGER"), Value::Number(9_007_199_254_740_991.0));
905
+ number_static.insert(Arc::from("MIN_SAFE_INTEGER"), Value::Number(-9_007_199_254_740_991.0));
906
+ number_static.insert(Arc::from("EPSILON"), Value::Number(f64::EPSILON));
907
+ number_static.insert(Arc::from("MAX_VALUE"), Value::Number(f64::MAX));
908
+ number_static.insert(Arc::from("MIN_VALUE"), Value::Number(5e-324));
909
+ number_static.insert(Arc::from("POSITIVE_INFINITY"), Value::Number(f64::INFINITY));
910
+ number_static.insert(Arc::from("NEGATIVE_INFINITY"), Value::Number(f64::NEG_INFINITY));
911
+ number_static.insert(Arc::from("NaN"), Value::Number(f64::NAN));
742
912
  g.insert("Number".into(), value_object_from_map(number_static));
743
913
 
744
914
  // JSX / Lattish: stubs for bytecode VM when no DOM (e.g. console). Override via set_global in browser.
@@ -785,10 +955,17 @@ fn init_globals(enabled: &HashSet<String>) -> ObjectMap {
785
955
  "exec".into(),
786
956
  Value::native(|args: &[Value]| tishlang_runtime::process_exec(args)),
787
957
  );
958
+ process_obj.insert(
959
+ "execFile".into(),
960
+ Value::native(|args: &[Value]| tishlang_runtime::process_exec_file(args)),
961
+ );
788
962
  process_obj.insert(
789
963
  "argv".into(),
790
964
  Value::Array(VmRef::new(
791
- tishlang_core::process_argv().into_iter().map(|s| Value::String(s.into())).collect(),
965
+ tishlang_core::process_argv()
966
+ .into_iter()
967
+ .map(|s| Value::String(s.into()))
968
+ .collect(),
792
969
  )),
793
970
  );
794
971
  process_obj.insert(
@@ -860,13 +1037,17 @@ fn init_globals(enabled: &HashSet<String>) -> ObjectMap {
860
1037
  Value::Function(_) => raw,
861
1038
  Value::Object(ref obj) => {
862
1039
  let obj_ref = obj.borrow();
863
- if let Some(Value::Function(on_worker)) =
864
- obj_ref.strings.get(&std::sync::Arc::from("onWorker")).cloned()
1040
+ if let Some(Value::Function(on_worker)) = obj_ref
1041
+ .strings
1042
+ .get(&std::sync::Arc::from("onWorker"))
1043
+ .cloned()
865
1044
  {
866
1045
  let args_for_init = [Value::Number(0.0)];
867
1046
  on_worker.call(&args_for_init)
868
- } else if let Some(h) =
869
- obj_ref.strings.get(&std::sync::Arc::from("handler")).cloned()
1047
+ } else if let Some(h) = obj_ref
1048
+ .strings
1049
+ .get(&std::sync::Arc::from("handler"))
1050
+ .cloned()
870
1051
  {
871
1052
  h
872
1053
  } else {
@@ -967,24 +1148,66 @@ fn try_call_array_jit(
967
1148
  arity: usize,
968
1149
  mask: u8,
969
1150
  ) -> Option<Value> {
1151
+ let writable = nf.array_writable_mask();
970
1152
  let mut numeric: Vec<f64> = Vec::with_capacity(arity);
971
1153
  // `scratch` OWNS the extracted f64 data; handles point into it. Build handles only AFTER scratch is
972
1154
  // fully populated so its backing buffers never reallocate out from under a live pointer.
973
1155
  let mut scratch: Vec<Vec<f64>> = Vec::new();
974
- #[allow(clippy::needless_range_loop)] // `i` drives bit-mask math (`mask >> i`), not just indexing
1156
+ let mut array_arg: Vec<usize> = Vec::new(); // the `args` index each scratch buffer came from
1157
+ let mut is_bool_arr: Vec<bool> = Vec::new(); // #187: parallel to `scratch` — was it a Bool array?
1158
+ // #187: track whether ANY arg is a (non-empty) Bool array and whether ANY is a Number array. If both
1159
+ // element types are present the JIT could launder a bool value read from one array into a number
1160
+ // array (or vice versa) — the flattened `f64` writeback would then re-box it wrong — so we bail
1161
+ // (below). queens (all-bool arrays) and spectral_norm (all-number) never mix, so they stay JIT'd.
1162
+ let mut any_bool_arr = false;
1163
+ let mut any_num_arr = false;
1164
+ #[allow(clippy::needless_range_loop)]
1165
+ // `i` drives bit-mask math (`mask >> i`), not just indexing
975
1166
  for i in 0..arity {
976
1167
  if (mask >> i) & 1 == 1 {
977
1168
  match &args[i] {
978
1169
  Value::Array(a) => {
1170
+ // #187: a writable array must not alias another array arg — the JIT reads/writes a
1171
+ // private scratch copy, so aliasing would make reads see stale data or a writeback
1172
+ // silently drop the other param's writes. Bail (→ interpret) on any such overlap.
1173
+ if writable != 0 {
1174
+ for &j in &array_arg {
1175
+ if let Value::Array(other) = &args[j] {
1176
+ if VmRef::ptr_eq(a, other)
1177
+ && ((writable >> i) & 1 == 1 || (writable >> j) & 1 == 1)
1178
+ {
1179
+ return None;
1180
+ }
1181
+ }
1182
+ }
1183
+ }
1184
+ // #187: accept an all-`Number` OR an all-`Bool` array (bool → `f64` 0/1, e.g.
1185
+ // queens' `cols`/`diag*`). A MIXED array is ambiguous for the typed writeback → bail.
979
1186
  let b = a.borrow();
980
1187
  let mut buf: Vec<f64> = Vec::with_capacity(b.len());
1188
+ let mut seen_bool = false;
1189
+ let mut seen_num = false;
981
1190
  for el in b.iter() {
982
1191
  match el {
983
- Value::Number(n) => buf.push(*n),
984
- _ => return None, // non-numeric element → interpreter
1192
+ Value::Number(n) => {
1193
+ seen_num = true;
1194
+ buf.push(*n);
1195
+ }
1196
+ Value::Bool(bl) => {
1197
+ seen_bool = true;
1198
+ buf.push(if *bl { 1.0 } else { 0.0 });
1199
+ }
1200
+ _ => return None, // non-numeric/non-bool element → interpreter
985
1201
  }
986
1202
  }
1203
+ if seen_bool && seen_num {
1204
+ return None; // mixed Bool/Number array → can't type the writeback
1205
+ }
1206
+ any_bool_arr |= seen_bool;
1207
+ any_num_arr |= seen_num;
987
1208
  scratch.push(buf);
1209
+ array_arg.push(i);
1210
+ is_bool_arr.push(seen_bool);
988
1211
  }
989
1212
  _ => return None, // NumberArray / non-array → interpreter
990
1213
  }
@@ -995,20 +1218,311 @@ fn try_call_array_jit(
995
1218
  }
996
1219
  }
997
1220
  }
1221
+ // #187: mixed Bool + Number array args in one call could launder a value across element types (a bool
1222
+ // read from one array stored into a number array, or vice versa) — the flat `f64` writeback can't
1223
+ // re-box that correctly. Bail when both element types are present among the args.
1224
+ if any_bool_arr && any_num_arr {
1225
+ return None;
1226
+ }
1227
+ // #187: the writeback re-boxes a writable array by its ENTRY element type (bool vs number, per
1228
+ // `is_bool_arr`), so the value kind the JIT actually stored must match that type. A function that
1229
+ // writes bool consts (`arr[i] = true`) handed a NUMBER array — or one that writes numbers handed a
1230
+ // BOOL array — would re-box to the wrong type (e.g. `arr[i] = true` on `[0,0,0]` boxing `Number(1)`
1231
+ // where the interpreter stores `Bool(true)`). Bail to the interpreter on any such mismatch. (A single
1232
+ // array with mixed bool + non-bool writes was already rejected at compile time in `classify_params`.)
1233
+ let bool_write_mask = nf.array_bool_write_mask();
1234
+ if writable != 0 {
1235
+ for (k, &entry_bool) in is_bool_arr.iter().enumerate() {
1236
+ let i = array_arg[k];
1237
+ if (writable >> i) & 1 == 1 && entry_bool != ((bool_write_mask >> i) & 1 == 1) {
1238
+ return None;
1239
+ }
1240
+ }
1241
+ }
1242
+ // `as_mut_ptr`: #187 writable params store through this pointer, so it must carry mut provenance
1243
+ // (read-only params only load — harmless). Handles are built only AFTER `scratch` is fully
1244
+ // populated so its backing buffers never reallocate out from under a live pointer.
998
1245
  let handles: Vec<crate::jit::ArrayHandle> = scratch
999
- .iter()
1246
+ .iter_mut()
1000
1247
  .map(|buf| crate::jit::ArrayHandle {
1001
- ptr: buf.as_ptr(),
1248
+ ptr: buf.as_mut_ptr(),
1002
1249
  len: buf.len(),
1003
1250
  })
1004
1251
  .collect();
1005
- let (res, deopt) = nf.call_arrays(&numeric, &handles);
1252
+ // #187: a self-recursive array-mode function (queens' `place`) recurses on the NATIVE stack, so
1253
+ // pass a RecurGuard (like #381) — its entry SP-bail turns unbounded recursion into a catchable
1254
+ // RangeError instead of a SIGSEGV. Non-recursive array fns pass a null guard (unchanged ABI).
1255
+ let (res, deopt, tripped) = if nf.recur_guarded() {
1256
+ let anchor = 0u8;
1257
+ let current_sp = &anchor as *const u8 as usize;
1258
+ let stack_limit = match stacker::remaining_stack() {
1259
+ Some(rem) => {
1260
+ let margin = RECUR_STACK_MARGIN.min(rem / 2);
1261
+ current_sp.saturating_sub(rem).saturating_add(margin)
1262
+ }
1263
+ None => 0,
1264
+ };
1265
+ let mut guard = crate::jit::RecurGuard {
1266
+ stack_limit,
1267
+ tripped: 0,
1268
+ };
1269
+ let (res, deopt) = nf.call_arrays_guarded(&numeric, &handles, &mut guard);
1270
+ (res, deopt, guard.tripped != 0)
1271
+ } else {
1272
+ let (res, deopt) = nf.call_arrays(&numeric, &handles);
1273
+ (res, deopt, false)
1274
+ };
1006
1275
  if deopt {
1007
- return None; // OOB access → re-run interpreter (OOB reads coerce as Value::Null)
1276
+ return None; // OOB access → re-run interpreter (the discarded scratch writes never escaped)
1277
+ }
1278
+ // #187: copy each WRITTEN array param's (possibly-mutated) scratch back into its `Value::Array`. The
1279
+ // length is unchanged (`SetIndex` can't grow; an OOB write deopts above), so this is a same-length
1280
+ // element overwrite. On a normal return this reflects the final state; on a recursion-guard TRIP
1281
+ // (below) it reflects the partial in-place mutations made before the overflow — matching the
1282
+ // interpreter/node, which likewise leak whatever was mutated when a deep recursion throws.
1283
+ if writable != 0 {
1284
+ for (k, buf) in scratch.iter().enumerate() {
1285
+ let i = array_arg[k];
1286
+ if (writable >> i) & 1 == 1 {
1287
+ if let Value::Array(a) = &args[i] {
1288
+ let bool_arr = is_bool_arr[k]; // #187: re-box a bool array's elements as Bool
1289
+ let mut b = a.borrow_mut();
1290
+ for (j, &v) in buf.iter().enumerate() {
1291
+ if let Some(slot) = b.get_mut(j) {
1292
+ *slot = if bool_arr {
1293
+ Value::Bool(v != 0.0)
1294
+ } else {
1295
+ Value::Number(v)
1296
+ };
1297
+ }
1298
+ }
1299
+ }
1300
+ }
1301
+ }
1302
+ }
1303
+ // #187: a self-recursive array-mode fn that overflowed the native stack raises the same catchable
1304
+ // RangeError as every other tier (#381). The writeback above already flushed the partial mutations,
1305
+ // so the caller's arrays reflect the same "leaked" state the interpreter/node leave on a deep throw.
1306
+ if tripped {
1307
+ set_pending_throw(stack_overflow_error());
1308
+ return Some(Value::Null);
1309
+ }
1310
+ // #187: a VOID function (side-effect writer, e.g. `multiplyAv`) returns the implicit `null` — its
1311
+ // `f64` result is a dummy. Return `Value::Null` to match the interpreter; the real effect is the
1312
+ // array writeback above.
1313
+ if nf.returns_void() {
1314
+ return Some(Value::Null);
1008
1315
  }
1009
1316
  Some(Value::Number(res))
1010
1317
  }
1011
1318
 
1319
+ /// #187 native HOF fusion. When `arr.map/filter/reduce/forEach(cb)` has a JIT-compiled pure-numeric
1320
+ /// callback (`cb` is a [`VmClosure`] with a plain register-`f64` [`crate::jit::NumericFn`]) AND the
1321
+ /// array is all-numeric, the whole higher-order call runs as a tight native loop calling the
1322
+ /// `NumericFn` per element — skipping the per-element boxed `Callable::call` dispatch AND the
1323
+ /// `Value` (un)boxing of each element that the generic `arr_builtins` path pays.
1324
+ ///
1325
+ /// This is PURELY ADDITIVE: every helper returns `None` to fall back to the existing generic path
1326
+ /// (which is byte-identical to the interpreter), so a bail is always sound. The gate is deliberately
1327
+ /// narrow — see [`fused_numeric_fn`] and the per-helper arity/`result_bool` conditions.
1328
+ #[cfg(not(target_arch = "wasm32"))]
1329
+ mod hof_fusion {
1330
+ use super::VmClosure;
1331
+ use tishlang_core::{Value, VmRef};
1332
+
1333
+ /// Extract the callback's fusable [`crate::jit::NumericFn`], if any. Fires ONLY for a `VmClosure`
1334
+ /// whose JIT is a plain pure-numeric register-`f64` function:
1335
+ /// * `array_param_mask() == 0` — not an array-mode ABI (that path reads `arr[i]`, needs handles);
1336
+ /// * `!is_jv()` — no function-local JIT arrays (those signal a deopt via a per-thread flag we'd
1337
+ /// have to poll — the generic path handles them correctly, so bail);
1338
+ /// * `!recur_guarded()` — not self-recursive (would need the `RecurGuard` trailing-ptr ABI; a
1339
+ /// lambda callback is never self-recursive, so this only ever excludes pathological input).
1340
+ ///
1341
+ /// Any other callable (builtin native fn, non-JIT closure, array-mode/JV/recursive fn) → `None`.
1342
+ #[inline]
1343
+ pub(super) fn fused_numeric_fn(cb: &Value) -> Option<crate::jit::NumericFn> {
1344
+ let Value::Function(f) = cb else { return None };
1345
+ let vc = f.as_any().downcast_ref::<VmClosure>()?;
1346
+ let nf = vc.jit_fn?;
1347
+ if nf.array_param_mask() != 0 || nf.is_jv() || nf.recur_guarded() {
1348
+ return None;
1349
+ }
1350
+ Some(nf)
1351
+ }
1352
+
1353
+ /// Snapshot an all-numeric array as a `Vec<f64>`. Accepts a packed [`Value::NumberArray`] and a
1354
+ /// boxed [`Value::Array`] whose every element is a [`Value::Number`]. Returns `None` for any other
1355
+ /// shape (mixed/boxed non-number element, a non-array) so the caller falls back to the generic
1356
+ /// path. Snapshots (clones) so — exactly like the generic `arr_builtins` path (#382) — a callback
1357
+ /// that re-enters the same array observes the pre-call contents and can never deadlock the borrow.
1358
+ #[inline]
1359
+ fn numeric_snapshot(arr: &Value) -> Option<Vec<f64>> {
1360
+ match arr {
1361
+ Value::NumberArray(a) => Some(a.borrow().clone()),
1362
+ Value::Array(a) => {
1363
+ let b = a.borrow();
1364
+ let mut out = Vec::with_capacity(b.len());
1365
+ for v in b.iter() {
1366
+ match v {
1367
+ Value::Number(n) => out.push(*n),
1368
+ _ => return None,
1369
+ }
1370
+ }
1371
+ Some(out)
1372
+ }
1373
+ _ => None,
1374
+ }
1375
+ }
1376
+
1377
+ /// Box a `NumericFn` f64 result the SAME way the interpreter/generic path does: as `Value::Bool`
1378
+ /// when the callback's result is a comparison (`result_is_bool`), else `Value::Number`. A bool
1379
+ /// `NumericFn` returns exactly `0.0`/`1.0`, so `r != 0.0` is exact.
1380
+ #[inline]
1381
+ fn box_result(nf: &crate::jit::NumericFn, r: f64) -> Value {
1382
+ if nf.result_is_bool() {
1383
+ Value::Bool(r != 0.0)
1384
+ } else {
1385
+ Value::Number(r)
1386
+ }
1387
+ }
1388
+
1389
+ /// Truthiness of a `NumericFn` result under tish's rules — matches `Value::is_truthy` on the
1390
+ /// boxed form: a number is truthy iff `!= 0` and not NaN; a bool-result fn returns `0.0`/`1.0`
1391
+ /// (never NaN) so the same test is exact for it too.
1392
+ #[inline]
1393
+ fn result_truthy(r: f64) -> bool {
1394
+ r != 0.0 && !r.is_nan()
1395
+ }
1396
+
1397
+ /// Fused `map`. Fires when the callback is a fusable numeric fn of arity ≤ 2 (it may read the
1398
+ /// element and the index; a 3rd `array` param can't be a number, so arity 3+ bails). Returns the
1399
+ /// SAME `Value` variant the generic path would: a numeric-returning map over a `NumberArray` yields
1400
+ /// a packed `NumberArray` (empty → boxed empty `Array`, matching `packed_or_empty`); a
1401
+ /// bool-returning map, or any map over a boxed `Array`, yields a boxed `Value::Array`.
1402
+ pub(super) fn map(arr: &Value, cb: &Value) -> Option<Value> {
1403
+ let nf = fused_numeric_fn(cb)?;
1404
+ if nf.arity() > 2 {
1405
+ return None; // 3rd param is the array arg — not a number; bail (sound).
1406
+ }
1407
+ let data = numeric_snapshot(arr)?;
1408
+ let arity = nf.arity();
1409
+ let packed_input = matches!(arr, Value::NumberArray(_));
1410
+ // A numeric-returning map over a packed input keeps its result packed (byte-identical to
1411
+ // `packed_or_empty`); every other case (bool result, or a boxed `Array` input) boxes.
1412
+ if !nf.result_is_bool() && packed_input {
1413
+ let mut out: Vec<f64> = Vec::with_capacity(data.len());
1414
+ let mut args = [0.0f64; 2];
1415
+ for (i, &n) in data.iter().enumerate() {
1416
+ args[0] = n;
1417
+ args[1] = i as f64;
1418
+ out.push(nf.call(&args[..arity]));
1419
+ }
1420
+ // Empty → boxed empty `Array`, matching `packed_or_empty`.
1421
+ if out.is_empty() {
1422
+ return Some(Value::Array(VmRef::new(Vec::new())));
1423
+ }
1424
+ return Some(Value::number_array(out));
1425
+ }
1426
+ let mut out: Vec<Value> = Vec::with_capacity(data.len());
1427
+ let mut args = [0.0f64; 2];
1428
+ for (i, &n) in data.iter().enumerate() {
1429
+ args[0] = n;
1430
+ args[1] = i as f64;
1431
+ out.push(box_result(&nf, nf.call(&args[..arity])));
1432
+ }
1433
+ Some(Value::Array(VmRef::new(out)))
1434
+ }
1435
+
1436
+ /// Fused `filter`. Callback is a predicate of arity ≤ 2. Keeps the ORIGINAL element (not the
1437
+ /// callback result) when the result is truthy — so the output is always a subset of the numeric
1438
+ /// input. Packed input → packed output (`packed_or_empty`); boxed `Array` input → boxed `Array`.
1439
+ pub(super) fn filter(arr: &Value, cb: &Value) -> Option<Value> {
1440
+ let nf = fused_numeric_fn(cb)?;
1441
+ if nf.arity() > 2 {
1442
+ return None;
1443
+ }
1444
+ let data = numeric_snapshot(arr)?;
1445
+ let arity = nf.arity();
1446
+ let packed_input = matches!(arr, Value::NumberArray(_));
1447
+ let mut args = [0.0f64; 2];
1448
+ if packed_input {
1449
+ let mut out: Vec<f64> = Vec::new();
1450
+ for (i, &n) in data.iter().enumerate() {
1451
+ args[0] = n;
1452
+ args[1] = i as f64;
1453
+ if result_truthy(nf.call(&args[..arity])) {
1454
+ out.push(n);
1455
+ }
1456
+ }
1457
+ if out.is_empty() {
1458
+ return Some(Value::Array(VmRef::new(Vec::new())));
1459
+ }
1460
+ return Some(Value::number_array(out));
1461
+ }
1462
+ let mut out: Vec<Value> = Vec::new();
1463
+ for (i, &n) in data.iter().enumerate() {
1464
+ args[0] = n;
1465
+ args[1] = i as f64;
1466
+ if result_truthy(nf.call(&args[..arity])) {
1467
+ out.push(Value::Number(n));
1468
+ }
1469
+ }
1470
+ Some(Value::Array(VmRef::new(out)))
1471
+ }
1472
+
1473
+ /// Fused `reduce`. Callback is `(acc, element, index)` — arity ≤ 3 (a 4th `array` param bails).
1474
+ /// The accumulator stays an `f64` across the whole fold, so the callback result must NOT be a bool
1475
+ /// (`!result_is_bool()`): a bool acc fed back into the numeric fn would diverge from the interpreter
1476
+ /// (which would pass a `Value::Bool`, not a number). No-initial-value semantics match the generic
1477
+ /// path: absent init (`Value::Null`) with a non-empty array seeds `acc = data[0]` and scans from
1478
+ /// index 1; an empty array with no init throws (bail to the generic path, which raises it).
1479
+ pub(super) fn reduce(arr: &Value, cb: &Value, initial: Option<&Value>) -> Option<Value> {
1480
+ let nf = fused_numeric_fn(cb)?;
1481
+ if nf.arity() > 3 || nf.result_is_bool() {
1482
+ return None;
1483
+ }
1484
+ let data = numeric_snapshot(arr)?;
1485
+ let arity = nf.arity();
1486
+ // Determine seed. Reduce with no initial value AND an empty array is a TypeError in JS — let
1487
+ // the generic path produce that exact throw rather than replicate it here.
1488
+ let (start, mut acc) = match initial {
1489
+ None if !data.is_empty() => (1usize, data[0]),
1490
+ None => return None, // empty + no init → generic path throws
1491
+ Some(Value::Number(n)) => (0usize, *n),
1492
+ Some(_) => return None, // non-numeric/explicit-null init → generic path (acc wouldn't stay f64)
1493
+ };
1494
+ let mut args = [0.0f64; 3];
1495
+ for (i, &x) in data.iter().enumerate().skip(start) {
1496
+ args[0] = acc;
1497
+ args[1] = x;
1498
+ args[2] = i as f64;
1499
+ acc = nf.call(&args[..arity]);
1500
+ }
1501
+ Some(Value::Number(acc))
1502
+ }
1503
+
1504
+ /// Fused `forEach`. Callback arity ≤ 2; result discarded; always returns `Value::Null`. Only worth
1505
+ /// fusing when the callback is side-effect-free numeric arithmetic — but a `NumericFn` is pure by
1506
+ /// construction (no calls/member/throw), so a fused `forEach` is observably a no-op EXCEPT for its
1507
+ /// (absent) side effects: it computes and discards. Kept for completeness / uniformity; it can
1508
+ /// never diverge because there is nothing observable to diverge on.
1509
+ pub(super) fn for_each(arr: &Value, cb: &Value) -> Option<Value> {
1510
+ let nf = fused_numeric_fn(cb)?;
1511
+ if nf.arity() > 2 {
1512
+ return None;
1513
+ }
1514
+ let data = numeric_snapshot(arr)?;
1515
+ let arity = nf.arity();
1516
+ let mut args = [0.0f64; 2];
1517
+ for (i, &n) in data.iter().enumerate() {
1518
+ args[0] = n;
1519
+ args[1] = i as f64;
1520
+ let _ = nf.call(&args[..arity]);
1521
+ }
1522
+ Some(Value::Null)
1523
+ }
1524
+ }
1525
+
1012
1526
  struct VmClosure {
1013
1527
  chunk: Arc<Chunk>,
1014
1528
  /// Whether this closure can run on the frame stack — computed ONCE at creation (eligibility is an
@@ -1044,12 +1558,66 @@ impl tishlang_core::Callable for VmClosure {
1044
1558
  }
1045
1559
  }
1046
1560
  if all_numbers {
1047
- let res = nf.call(&nums[..arity]);
1048
- return if nf.result_is_bool() {
1049
- Value::Bool(res != 0.0)
1561
+ // #189: a JV function has function-local `f64` arrays lowered to
1562
+ // `tish_jv_*` calls over a per-thread arena. An out-of-bounds access (or a
1563
+ // non-numeric return) sets a per-thread deopt flag; we discard the numeric
1564
+ // result and re-run the interpreter. Sound because JV arrays never escape
1565
+ // the function — nothing observable was mutated, so re-execution
1566
+ // reproduces identical behaviour.
1567
+ if nf.is_jv() {
1568
+ crate::jit::jv_reset_deopt();
1569
+ let r = nf.call(&nums[..arity]);
1570
+ if !crate::jit::jv_take_deopt() {
1571
+ return if nf.result_is_bool() {
1572
+ Value::Bool(r != 0.0)
1573
+ } else {
1574
+ Value::Number(r)
1575
+ };
1576
+ }
1577
+ // deopt ⇒ fall through to the interpreter below.
1050
1578
  } else {
1051
- Value::Number(res)
1052
- };
1579
+ // #381: a self-recursive JIT'd function carries a RecurGuard so it bails
1580
+ // (rather than overflowing the native stack) when the recursion nears the
1581
+ // real remaining stack. On a bail we raise the same catchable RangeError as
1582
+ // the non-JIT paths — tish's deopt tier producing the throw, not the JIT.
1583
+ let res = if nf.recur_guarded() {
1584
+ let anchor = 0u8;
1585
+ let current_sp = &anchor as *const u8 as usize;
1586
+ // `stack_limit` = the stack address below which we bail, leaving a
1587
+ // headroom margin. If the remaining stack is unknown we pass 0 (SP is
1588
+ // never below 0 → never trips), i.e. behave as before.
1589
+ let stack_limit = match stacker::remaining_stack() {
1590
+ Some(rem) => {
1591
+ // Cap the margin at half of what's left: when a JIT'd function
1592
+ // is first entered on an already-deep stack (rem < margin),
1593
+ // `bottom + margin` would sit ABOVE the current SP and trip on
1594
+ // the very first call — a false positive on shallow recursion.
1595
+ // Capping keeps the limit strictly below SP for any rem, while
1596
+ // still bailing with real headroom to spare.
1597
+ let margin = RECUR_STACK_MARGIN.min(rem / 2);
1598
+ current_sp.saturating_sub(rem).saturating_add(margin)
1599
+ }
1600
+ None => 0,
1601
+ };
1602
+ let mut guard = crate::jit::RecurGuard {
1603
+ stack_limit,
1604
+ tripped: 0,
1605
+ };
1606
+ let r = nf.call_guarded(&nums[..arity], &mut guard);
1607
+ if guard.tripped != 0 {
1608
+ set_pending_throw(stack_overflow_error());
1609
+ return Value::Null;
1610
+ }
1611
+ r
1612
+ } else {
1613
+ nf.call(&nums[..arity])
1614
+ };
1615
+ return if nf.result_is_bool() {
1616
+ Value::Bool(res != 0.0)
1617
+ } else {
1618
+ Value::Number(res)
1619
+ };
1620
+ }
1053
1621
  }
1054
1622
  } else if let Some(v) = try_call_array_jit(&nf, args, arity, mask) {
1055
1623
  // Array-mode path: succeeded (all-numeric arrays, in-bounds). On any bail
@@ -1060,6 +1628,16 @@ impl tishlang_core::Callable for VmClosure {
1060
1628
  }
1061
1629
  }
1062
1630
  }
1631
+ // #381: bound recursion so a runaway recursive closure throws a catchable RangeError rather
1632
+ // than overflowing the native stack (this recursive re-entry is the DEFAULT `tish run` path).
1633
+ // The counter is thread-local because each closure call builds a fresh `Vm`; the parked throw
1634
+ // is picked up by the caller's `take_pending_throw()` check (Call/SelfCall post-call).
1635
+ let depth = tishlang_core::inc_call_depth();
1636
+ if depth > max_call_depth() {
1637
+ tishlang_core::dec_call_depth();
1638
+ set_pending_throw(stack_overflow_error());
1639
+ return Value::Null;
1640
+ }
1063
1641
  let mut vm = Vm {
1064
1642
  stack: Vec::new(),
1065
1643
  scope: ObjectMap::default(),
@@ -1069,17 +1647,19 @@ impl tishlang_core::Callable for VmClosure {
1069
1647
  native_modules: self.native_modules.clone(),
1070
1648
  };
1071
1649
  #[cfg(not(target_arch = "wasm32"))]
1072
- {
1650
+ let result = {
1073
1651
  stacker::maybe_grow(128 * 1024, 2 * 1024 * 1024, || {
1074
1652
  vm.run_chunk(self.chunk.as_ref(), &self.chunk.nested, args, false)
1075
1653
  .unwrap_or(Value::Null)
1076
1654
  })
1077
- }
1655
+ };
1078
1656
  #[cfg(target_arch = "wasm32")]
1079
- {
1657
+ let result = {
1080
1658
  vm.run_chunk(&self.chunk, &self.chunk.nested, args, false)
1081
1659
  .unwrap_or(Value::Null)
1082
- }
1660
+ };
1661
+ tishlang_core::dec_call_depth();
1662
+ result
1083
1663
  }
1084
1664
  fn as_any(&self) -> &dyn std::any::Any {
1085
1665
  self
@@ -1170,6 +1750,12 @@ impl Vm {
1170
1750
 
1171
1751
  /// Run a chunk using this VM's capability set. `repl_mode` persists top-level `let` across REPL lines.
1172
1752
  pub fn run_with_options(&mut self, chunk: &Chunk, repl_mode: bool) -> Result<Value, String> {
1753
+ // #187: the directly-callable-callee registry is scoped to ONE top-level program run. Clear it
1754
+ // here (the outermost entry — closures run via `run_chunk`, not this) so a long-lived process
1755
+ // (REPL / embedder) running a NEW program never resolves a stale callee registered by a prior
1756
+ // one. A cross-caller and its callee always compile within the same run, so this is sufficient.
1757
+ #[cfg(not(target_arch = "wasm32"))]
1758
+ crate::jit::reset_callees();
1173
1759
  let result = self.run_chunk(chunk, &chunk.nested, &[], repl_mode);
1174
1760
  // A throw that escaped every `catch` reaches here as the pending-throw sentinel; turn the
1175
1761
  // parked value into the conventional uncaught-error message (issue #60).
@@ -1192,7 +1778,11 @@ impl Vm {
1192
1778
  // regression to the DEFAULT path — caching makes the flag-off check a single atomic load.
1193
1779
  use std::sync::OnceLock;
1194
1780
  static ENABLED: OnceLock<bool> = OnceLock::new();
1195
- *ENABLED.get_or_init(|| std::env::var("TISH_FRAME_VM").map(|v| v == "1").unwrap_or(false))
1781
+ *ENABLED.get_or_init(|| {
1782
+ std::env::var("TISH_FRAME_VM")
1783
+ .map(|v| v == "1")
1784
+ .unwrap_or(false)
1785
+ })
1196
1786
  }
1197
1787
 
1198
1788
  /// A `VmClosure` runs on the frame stack iff its chunk is frame-eligible AND it has no numeric
@@ -1245,6 +1835,64 @@ impl Vm {
1245
1835
  true
1246
1836
  }
1247
1837
 
1838
+ /// On-stack replacement (#190): run one hot loop region natively, or report why we can't.
1839
+ ///
1840
+ /// Called from the frame VM's `JumpBack` handler once a loop's back-edge counter trips. Compiles
1841
+ /// (cached) the region `[header_ip, region_end)`; if it is a pure-numeric slot loop AND every live
1842
+ /// slot currently holds a `Number` (else the native f64 math would diverge from the interpreter),
1843
+ /// copies the live-ins into an f64 buffer, runs the whole remaining loop natively, writes the
1844
+ /// live-outs back as `Number`s, and returns the chunk `ip` of the loop exit to resume dispatch at.
1845
+ ///
1846
+ /// Soundness: the v1 region whitelist is pure slot/stack arithmetic — no calls, no member/index,
1847
+ /// no object or array mutation — so nothing observable happens inside the region, and the
1848
+ /// interpreter running the same bytecode from the same numeric live-ins produces bit-identical
1849
+ /// slots (the `emit_simple_op` lowering matches `eval_binop`). A non-numeric live-in is the deopt
1850
+ /// case: we simply don't OSR and keep interpreting, so state is never corrupted.
1851
+ #[cfg(not(target_arch = "wasm32"))]
1852
+ fn run_osr(
1853
+ chunk: &Chunk,
1854
+ header_ip: usize,
1855
+ region_end: usize,
1856
+ slots: &mut [Value],
1857
+ slot_base: usize,
1858
+ ) -> OsrResult {
1859
+ let loopfn = match crate::jit::try_compile_loop(chunk, header_ip, region_end) {
1860
+ Some(lf) => lf,
1861
+ None => return OsrResult::NotCompilable,
1862
+ };
1863
+ let mut buf: Vec<f64> = Vec::with_capacity(loopfn.used_slots.len());
1864
+ for &slot in &loopfn.used_slots {
1865
+ match slots.get(slot_base + slot as usize) {
1866
+ Some(Value::Number(n)) => buf.push(*n),
1867
+ _ => return OsrResult::LiveInMiss, // a live slot is non-numeric → deopt, keep interpreting
1868
+ }
1869
+ }
1870
+ let mut deopt: u8 = 0;
1871
+ // SAFETY: `buf` is a valid `[f64; used_slots.len()]`; `deopt` is a valid `*mut u8`. The region
1872
+ // was compiled for exactly this chunk+header (fingerprint-checked in the cache).
1873
+ let exit_id = loopfn.call(&mut buf, &mut deopt);
1874
+ for (p, &slot) in loopfn.used_slots.iter().enumerate() {
1875
+ if let Some(d) = slots.get_mut(slot_base + slot as usize) {
1876
+ *d = Value::Number(buf[p]);
1877
+ }
1878
+ }
1879
+ match loopfn.exits.get(exit_id as usize) {
1880
+ Some(&ip) => OsrResult::Compiled(ip),
1881
+ // Out-of-range exit id is impossible (the region returns an in-range id), but if it ever
1882
+ // happened the slots are already a consistent post-loop state, so re-interpreting the
1883
+ // (now-false) loop header exits correctly.
1884
+ None => OsrResult::LiveInMiss,
1885
+ }
1886
+ }
1887
+
1888
+ /// OSR is **default ON**; `TISH_OSR=0` disables it (escape hatch, mirrors `TISH_JIT_ARRAYS`).
1889
+ #[cfg(not(target_arch = "wasm32"))]
1890
+ #[inline]
1891
+ fn osr_enabled() -> bool {
1892
+ static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1893
+ *ENABLED.get_or_init(|| std::env::var("TISH_OSR").map(|v| v != "0").unwrap_or(true))
1894
+ }
1895
+
1248
1896
  /// Iterative frame-stack execution of a frame-eligible `VmClosure` (the frame-VM, flag-on).
1249
1897
  /// Returns `None` if the entry chunk is ineligible (caller falls back to `VmClosure::call`).
1250
1898
  /// Calls + recursion run on the heap `frames` stack — no per-call `Vm`, no recursive `run_chunk`
@@ -1264,6 +1912,14 @@ impl Vm {
1264
1912
  let mut slots: Vec<Value> = Vec::new();
1265
1913
  let mut slot_base: usize = 0;
1266
1914
  slots.resize(cur.num_slots as usize, Value::Null);
1915
+ // #190 OSR: per-`(chunk, loop header)` back-edge counters (`u32::MAX` = gave up), with a
1916
+ // single-entry fast slot for the loop currently spinning (see the `run_chunk` copy for the
1917
+ // rationale) so a resolved hot loop stays off the HashMap.
1918
+ #[cfg(not(target_arch = "wasm32"))]
1919
+ let mut osr_counters: std::collections::HashMap<(usize, usize), u32> =
1920
+ std::collections::HashMap::new();
1921
+ #[cfg(not(target_arch = "wasm32"))]
1922
+ let mut osr_hot: ((usize, usize), u32) = ((usize::MAX, usize::MAX), 0);
1267
1923
  for i in 0..(cur.param_count as usize) {
1268
1924
  if let Some(v) = args.get(i) {
1269
1925
  if let Some(d) = slots.get_mut(slot_base + i) {
@@ -1339,7 +1995,9 @@ impl Vm {
1339
1995
  let idx = Self::read_u16(code, &mut ip) as usize;
1340
1996
  let v = match cur.constants.get(idx) {
1341
1997
  Some(Constant::Number(n)) => Value::Number(*n),
1342
- Some(Constant::String(s)) => Value::String(tishlang_core::ArcStr::from(s.as_ref())),
1998
+ Some(Constant::String(s)) => {
1999
+ Value::String(tishlang_core::ArcStr::from(s.as_ref()))
2000
+ }
1343
2001
  Some(Constant::Bool(b)) => Value::Bool(*b),
1344
2002
  Some(Constant::Null) => Value::Null,
1345
2003
  _ => ferr!("Ineligible constant {} in run_framed", idx),
@@ -1388,6 +2046,41 @@ impl Vm {
1388
2046
  }
1389
2047
  Opcode::JumpBack => {
1390
2048
  let dist = Self::read_u16(code, &mut ip) as usize;
2049
+ // #190 OSR: `ip` now points just past the JumpBack (region end); the loop header is
2050
+ // `region_end - dist`. Once a loop is hot, try to run its remaining iterations in
2051
+ // native code (numeric slot loops only; anything else falls straight through).
2052
+ #[cfg(not(target_arch = "wasm32"))]
2053
+ if Self::osr_enabled() {
2054
+ let region_end = ip;
2055
+ let header_ip = ip.saturating_sub(dist);
2056
+ let key = (Arc::as_ptr(&cur) as usize, header_ip);
2057
+ if key != osr_hot.0 {
2058
+ if osr_hot.0 .0 != usize::MAX {
2059
+ osr_counters.insert(osr_hot.0, osr_hot.1);
2060
+ }
2061
+ osr_hot = (key, osr_counters.get(&key).copied().unwrap_or(0));
2062
+ }
2063
+ if osr_hot.1 != u32::MAX {
2064
+ osr_hot.1 = osr_hot.1.saturating_add(1);
2065
+ if osr_hot.1 >= OSR_THRESHOLD
2066
+ && osr_hot
2067
+ .1
2068
+ .saturating_sub(OSR_THRESHOLD)
2069
+ .is_multiple_of(OSR_RETRY)
2070
+ {
2071
+ match Self::run_osr(
2072
+ &cur, header_ip, region_end, &mut slots, slot_base,
2073
+ ) {
2074
+ OsrResult::Compiled(exit_ip) => {
2075
+ ip = exit_ip;
2076
+ continue;
2077
+ }
2078
+ OsrResult::LiveInMiss => {}
2079
+ OsrResult::NotCompilable => osr_hot.1 = u32::MAX,
2080
+ }
2081
+ }
2082
+ }
2083
+ }
1391
2084
  ip = ip.saturating_sub(dist);
1392
2085
  }
1393
2086
  Opcode::Pop => {
@@ -1400,6 +2093,12 @@ impl Vm {
1400
2093
  call_args.push(fpop!());
1401
2094
  }
1402
2095
  call_args.reverse();
2096
+ // #381: bound the heap frame stack (the opt-in frame-VM path grows a Vec instead
2097
+ // of the native stack, so unbounded recursion here is OOM rather than overflow).
2098
+ if frames.len() >= max_call_depth() {
2099
+ set_pending_throw(stack_overflow_error());
2100
+ return Some(Err(PENDING_THROW_SENTINEL.to_string()));
2101
+ }
1403
2102
  frames.push((cur.clone(), ip, slot_base, stack_base, enclosing.clone()));
1404
2103
  let new_base = slots.len();
1405
2104
  slots.resize(new_base + cur.num_slots as usize, Value::Null);
@@ -1435,6 +2134,10 @@ impl Vm {
1435
2134
  // refcounts are unchanged (the chunk heap data doesn't move, so the
1436
2135
  // laundered `code` ptr stays valid until rebind below). Halves the
1437
2136
  // per-call Arc traffic vs cloning for the push.
2137
+ if frames.len() >= max_call_depth() {
2138
+ set_pending_throw(stack_overflow_error());
2139
+ return Some(Err(PENDING_THROW_SENTINEL.to_string()));
2140
+ }
1438
2141
  frames.push((cur, ip, slot_base, stack_base, enclosing));
1439
2142
  cur = next_chunk;
1440
2143
  enclosing = next_enc;
@@ -1528,6 +2231,35 @@ impl Vm {
1528
2231
  // frame indexed by slot — no per-call hashmap, no name lookups. Args bind
1529
2232
  // to slots 0..param_count. Empty for name-based chunks.
1530
2233
  let mut slot_locals: Vec<Value> = Vec::new();
2234
+ // #190 OSR: per-loop-header back-edge counters for this frame (`u32::MAX` = gave up). `osr_hot`
2235
+ // is a single-entry fast slot for the loop currently spinning — its header is constant across
2236
+ // iterations, so the common hot loop never touches the HashMap (which is hit only when the
2237
+ // active loop changes: entry, exit, or a nested-loop switch). Keeps the per-back-edge cost of a
2238
+ // resolved loop to two integer compares.
2239
+ #[cfg(not(target_arch = "wasm32"))]
2240
+ let mut osr_counters: std::collections::HashMap<usize, u32> =
2241
+ std::collections::HashMap::new();
2242
+ #[cfg(not(target_arch = "wasm32"))]
2243
+ let mut osr_hot: (usize, u32) = (usize::MAX, 0);
2244
+ // Frame-local string builder for `acc += x` on a slot local (Opcode::AppendLocal): keeps the
2245
+ // accumulator in a growable `sb_buf` so appends are amortized O(1) instead of reallocating the
2246
+ // whole string each time. `sb_slot` is the slot currently buffered (at most one). The slot's
2247
+ // own value is stale while buffered; `sb_flush!()` writes the buffer back. Slots are
2248
+ // frame-private (never captured by closures and invisible to callees), and every read of a
2249
+ // slot goes through `LoadLocal`, so flushing there is sufficient for soundness — no
2250
+ // cross-frame state. JS string immutability is preserved: reads always see the full string.
2251
+ let mut sb_slot: Option<usize> = None;
2252
+ let mut sb_buf = String::new();
2253
+ macro_rules! sb_flush {
2254
+ () => {{
2255
+ if let Some(s) = sb_slot.take() {
2256
+ if let Some(dst) = slot_locals.get_mut(s) {
2257
+ *dst = Value::String(std::mem::take(&mut sb_buf).into());
2258
+ }
2259
+ sb_buf.clear();
2260
+ }
2261
+ }};
2262
+ }
1531
2263
  if chunk.slot_based {
1532
2264
  slot_locals = vec![Value::Null; chunk.num_slots as usize];
1533
2265
  let param_count = chunk.param_count as usize;
@@ -1620,6 +2352,10 @@ impl Vm {
1620
2352
  Opcode::Nop => {}
1621
2353
  Opcode::LoadLocal => {
1622
2354
  let slot = Self::read_u16(code, &mut ip) as usize;
2355
+ // Flush a pending string-builder for this slot so the read sees the full string.
2356
+ if sb_slot == Some(slot) {
2357
+ sb_flush!();
2358
+ }
1623
2359
  let v = slot_locals
1624
2360
  .get(slot)
1625
2361
  .cloned()
@@ -1628,6 +2364,11 @@ impl Vm {
1628
2364
  }
1629
2365
  Opcode::StoreLocal => {
1630
2366
  let slot = Self::read_u16(code, &mut ip) as usize;
2367
+ // A plain store overwrites the slot — drop any builder buffering it.
2368
+ if sb_slot == Some(slot) {
2369
+ sb_slot = None;
2370
+ sb_buf.clear();
2371
+ }
1631
2372
  let v = self
1632
2373
  .stack
1633
2374
  .pop()
@@ -1637,10 +2378,59 @@ impl Vm {
1637
2378
  None => return Err(format!("Local slot out of bounds: {}", slot)),
1638
2379
  }
1639
2380
  }
2381
+ Opcode::AppendLocal => {
2382
+ let slot = Self::read_u16(code, &mut ip) as usize;
2383
+ let rhs = self
2384
+ .stack
2385
+ .pop()
2386
+ .ok_or_else(|| "Stack underflow in AppendLocal".to_string())?;
2387
+ if sb_slot == Some(slot) {
2388
+ // Continue the active builder for this slot — amortized O(1) append.
2389
+ append_value_for_string_concat(&mut sb_buf, &rhs);
2390
+ } else {
2391
+ // Switching slots: flush the previous builder, then (re)start for this one.
2392
+ sb_flush!();
2393
+ match slot_locals.get(slot) {
2394
+ Some(Value::String(a)) => {
2395
+ sb_buf.clear();
2396
+ sb_buf.reserve(a.len() + estimate_string_concat_len(&rhs));
2397
+ sb_buf.push_str(a);
2398
+ append_value_for_string_concat(&mut sb_buf, &rhs);
2399
+ sb_slot = Some(slot);
2400
+ }
2401
+ _ => {
2402
+ // Non-string (or absent) accumulator: generic `+=` in place,
2403
+ // identical to LoadLocal; BinOp Add; StoreLocal.
2404
+ let current = slot_locals.get(slot).cloned().unwrap_or(Value::Null);
2405
+ let result = eval_binop(BinOp::Add, &current, &rhs)?;
2406
+ match slot_locals.get_mut(slot) {
2407
+ Some(dst) => *dst = result,
2408
+ None => {
2409
+ return Err(format!("Local slot out of bounds: {}", slot))
2410
+ }
2411
+ }
2412
+ }
2413
+ }
2414
+ }
2415
+ }
1640
2416
  Opcode::LoadUpvalue | Opcode::StoreUpvalue => {
1641
2417
  // Reserved for the linked-frame upvalue model (not emitted yet).
1642
2418
  return Err("Upvalue opcodes not supported in this VM build".to_string());
1643
2419
  }
2420
+ Opcode::MathUnary => {
2421
+ // #186 — `Math.<fn>(x)` on a number. The compiler only emits this when `Math` is
2422
+ // the global builtin, so it is behaviour-identical to the general call: a number
2423
+ // maps through `MathUnaryFn::apply`, a non-number coerces to NaN (matching the
2424
+ // builtin's `extract_num(..).unwrap_or(NaN)`).
2425
+ let id = Self::read_u16(code, &mut ip);
2426
+ let x = match self.stack.pop() {
2427
+ Some(Value::Number(n)) => n,
2428
+ Some(_) | None => f64::NAN,
2429
+ };
2430
+ let mfn = tishlang_bytecode::MathUnaryFn::from_u16(id)
2431
+ .ok_or_else(|| format!("Bad MathUnary id: {}", id))?;
2432
+ self.stack.push(Value::Number(mfn.apply(x)));
2433
+ }
1644
2434
  Opcode::LoadConst => {
1645
2435
  let idx = Self::read_u16(code, &mut ip);
1646
2436
  let c = constants
@@ -1648,7 +2438,9 @@ impl Vm {
1648
2438
  .ok_or_else(|| format!("Constant index out of bounds: {}", idx))?;
1649
2439
  let v = match c {
1650
2440
  Constant::Number(n) => Value::Number(*n),
1651
- Constant::String(s) => Value::String(tishlang_core::ArcStr::from(s.as_ref())),
2441
+ Constant::String(s) => {
2442
+ Value::String(tishlang_core::ArcStr::from(s.as_ref()))
2443
+ }
1652
2444
  Constant::Bool(b) => Value::Bool(*b),
1653
2445
  Constant::Null => Value::Null,
1654
2446
  Constant::Closure(nested_idx) => {
@@ -1669,45 +2461,45 @@ impl Vm {
1669
2461
  // A closure must capture a real scope (even if empty) so that, post-creation,
1670
2462
  // the parent's name-based locals are visible. Materialise local_scope here.
1671
2463
  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());
2464
+ let enclosing_chain: SharedChain =
2465
+ SharedChain::new(if active_loop_vars.is_empty() {
2466
+ let mut chain = Vec::with_capacity(self.enclosing.len() + 1);
2467
+ chain.push(captured_scope.clone());
2468
+ chain.extend(self.enclosing.iter().cloned());
2469
+ chain
2470
+ } else {
2471
+ // Per-iteration `let`: freeze the loop var(s) into an overlay that
2472
+ // shadows the still-shared frame scope, then the inherited chain.
2473
+ let mut overlay = ObjectMap::default();
2474
+ {
2475
+ let ls = captured_scope.borrow();
2476
+ for n in &active_loop_vars {
2477
+ if let Some(v) = ls.get(n.as_ref()) {
2478
+ overlay.insert(Arc::clone(n), v.clone());
2479
+ }
1686
2480
  }
1687
2481
  }
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
- });
2482
+ let mut chain = Vec::with_capacity(self.enclosing.len() + 2);
2483
+ chain.push(VmRef::new(overlay));
2484
+ chain.push(captured_scope.clone());
2485
+ chain.extend(self.enclosing.iter().cloned());
2486
+ chain
2487
+ });
1695
2488
  let capabilities = Arc::clone(&self.capabilities);
1696
2489
  let native_modules = self.native_modules.clone();
1697
2490
  // Frame-eligibility is an O(chunk) bytecode scan; gate it behind the
1698
2491
  // (cached) frame-VM flag so the DEFAULT path skips it entirely — flag-off
1699
2492
  // 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
- };
2493
+ let frameable = Vm::frame_vm_enabled() && {
2494
+ #[cfg(not(target_arch = "wasm32"))]
2495
+ {
2496
+ jit_fn.is_none() && Vm::chunk_frame_eligible(&inner_clone)
2497
+ }
2498
+ #[cfg(target_arch = "wasm32")]
2499
+ {
2500
+ Vm::chunk_frame_eligible(&inner_clone)
2501
+ }
2502
+ };
1711
2503
  let vmclosure = VmClosure {
1712
2504
  chunk: std::sync::Arc::new(inner_clone),
1713
2505
  frameable,
@@ -1759,7 +2551,10 @@ impl Vm {
1759
2551
  .pop()
1760
2552
  .ok_or_else(|| "Stack underflow".to_string())?;
1761
2553
  // 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())) {
2554
+ if local_scope
2555
+ .as_ref()
2556
+ .is_some_and(|ls| ls.borrow().contains_key(name.as_ref()))
2557
+ {
1763
2558
  ls_get_or_init!().borrow_mut().insert(Arc::clone(name), v);
1764
2559
  } else if let Some(e) = self
1765
2560
  .enclosing
@@ -1994,6 +2789,14 @@ impl Vm {
1994
2789
  );
1995
2790
  }
1996
2791
  args.reverse();
2792
+ // #381: SelfCall is a second native recursive re-entry (a self-recursive function
2793
+ // re-enters run_chunk directly). Bound it with the same shared counter; `raise!` is
2794
+ // in scope here, so throw the catchable RangeError directly.
2795
+ let depth = tishlang_core::inc_call_depth();
2796
+ if depth > max_call_depth() {
2797
+ tishlang_core::dec_call_depth();
2798
+ raise!(stack_overflow_error());
2799
+ }
1997
2800
  let mut vm = Vm {
1998
2801
  stack: Vec::new(),
1999
2802
  scope: ObjectMap::default(),
@@ -2008,7 +2811,10 @@ impl Vm {
2008
2811
  .unwrap_or(Value::Null)
2009
2812
  });
2010
2813
  #[cfg(target_arch = "wasm32")]
2011
- let result = vm.run_chunk(chunk, nested, &args, false).unwrap_or(Value::Null);
2814
+ let result = vm
2815
+ .run_chunk(chunk, nested, &args, false)
2816
+ .unwrap_or(Value::Null);
2817
+ tishlang_core::dec_call_depth();
2012
2818
  if let Some(v) = take_pending_throw() {
2013
2819
  raise!(v);
2014
2820
  }
@@ -2040,8 +2846,7 @@ impl Vm {
2040
2846
  let f = match &callee {
2041
2847
  Value::Function(f) => f.clone(),
2042
2848
  Value::Object(o) => {
2043
- if let Some(Value::Function(call_fn)) =
2044
- o.borrow().strings.get("__call")
2849
+ if let Some(Value::Function(call_fn)) = o.borrow().strings.get("__call")
2045
2850
  {
2046
2851
  call_fn.clone()
2047
2852
  } else {
@@ -2131,6 +2936,49 @@ impl Vm {
2131
2936
  }
2132
2937
  Opcode::JumpBack => {
2133
2938
  let dist = Self::read_u16(code, &mut ip) as usize;
2939
+ // #190 OSR: on a hot back-edge, try to finish the loop natively. Slot-based frames
2940
+ // only (`slot_locals` is the live frame); non-slot / non-numeric loops fail the
2941
+ // whitelist or the live-in check and fall straight through to the interpreter.
2942
+ #[cfg(not(target_arch = "wasm32"))]
2943
+ if chunk.slot_based && Self::osr_enabled() {
2944
+ let region_end = ip;
2945
+ let header_ip = ip.saturating_sub(dist);
2946
+ // Switch the fast slot only when the active loop changes: stash the old count,
2947
+ // load this header's (default 0). The hot loop keeps `header_ip == osr_hot.0`.
2948
+ if header_ip != osr_hot.0 {
2949
+ if osr_hot.0 != usize::MAX {
2950
+ osr_counters.insert(osr_hot.0, osr_hot.1);
2951
+ }
2952
+ osr_hot = (
2953
+ header_ip,
2954
+ osr_counters.get(&header_ip).copied().unwrap_or(0),
2955
+ );
2956
+ }
2957
+ if osr_hot.1 != u32::MAX {
2958
+ osr_hot.1 = osr_hot.1.saturating_add(1);
2959
+ if osr_hot.1 >= OSR_THRESHOLD
2960
+ && osr_hot
2961
+ .1
2962
+ .saturating_sub(OSR_THRESHOLD)
2963
+ .is_multiple_of(OSR_RETRY)
2964
+ {
2965
+ match Self::run_osr(
2966
+ chunk,
2967
+ header_ip,
2968
+ region_end,
2969
+ &mut slot_locals,
2970
+ 0,
2971
+ ) {
2972
+ OsrResult::Compiled(exit_ip) => {
2973
+ ip = exit_ip;
2974
+ continue;
2975
+ }
2976
+ OsrResult::LiveInMiss => {}
2977
+ OsrResult::NotCompilable => osr_hot.1 = u32::MAX,
2978
+ }
2979
+ }
2980
+ }
2981
+ }
2134
2982
  ip = ip.saturating_sub(dist);
2135
2983
  }
2136
2984
  Opcode::BinOp => {
@@ -2265,8 +3113,12 @@ impl Vm {
2265
3113
  if let Some(nums) = elems.iter().try_fold(
2266
3114
  Vec::<f64>::with_capacity(elems.len()),
2267
3115
  |mut acc, v| {
2268
- if let Value::Number(n) = v { acc.push(*n); Some(acc) }
2269
- else { None }
3116
+ if let Value::Number(n) = v {
3117
+ acc.push(*n);
3118
+ Some(acc)
3119
+ } else {
3120
+ None
3121
+ }
2270
3122
  },
2271
3123
  ) {
2272
3124
  self.stack.push(Value::number_array(nums));
@@ -2289,10 +3141,8 @@ impl Vm {
2289
3141
  let base = self.stack.len() - 2 * n;
2290
3142
  let mut map = PropMap::with_capacity(n);
2291
3143
  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);
3144
+ let key_val = std::mem::replace(&mut self.stack[base + 2 * i], Value::Null);
3145
+ let val = std::mem::replace(&mut self.stack[base + 2 * i + 1], Value::Null);
2296
3146
  let key: Arc<str> = key_val.to_display_string().into();
2297
3147
  map.insert(key, val);
2298
3148
  }
@@ -2448,13 +3298,25 @@ impl Vm {
2448
3298
  .iter()
2449
3299
  .map(|&n| {
2450
3300
  let elem = Value::Number(n);
2451
- let (l, r) = if param_left { (elem, const_val.clone()) } else { (const_val.clone(), elem) };
3301
+ let (l, r) = if param_left {
3302
+ (elem, const_val.clone())
3303
+ } else {
3304
+ (const_val.clone(), elem)
3305
+ };
2452
3306
  eval_binop(binop, &l, &r).unwrap_or(Value::Null)
2453
3307
  })
2454
3308
  .collect();
2455
3309
  // If every result is numeric, stay packed (the common case for x*2, x+1, etc).
2456
3310
  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())
3311
+ Value::number_array(
3312
+ mapped
3313
+ .into_iter()
3314
+ .map(|v| match v {
3315
+ Value::Number(n) => n,
3316
+ _ => unreachable!(),
3317
+ })
3318
+ .collect(),
3319
+ )
2458
3320
  } else {
2459
3321
  Value::Array(VmRef::new(mapped))
2460
3322
  }
@@ -2464,8 +3326,16 @@ impl Vm {
2464
3326
  let mapped: Vec<Value> = arr_borrow
2465
3327
  .iter()
2466
3328
  .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() };
3329
+ let l: Value = if param_left {
3330
+ (*v).clone()
3331
+ } else {
3332
+ const_val.clone()
3333
+ };
3334
+ let r: Value = if param_left {
3335
+ const_val.clone()
3336
+ } else {
3337
+ (*v).clone()
3338
+ };
2469
3339
  eval_binop(binop, &l, &r).unwrap_or(Value::Null)
2470
3340
  })
2471
3341
  .collect();
@@ -2499,7 +3369,11 @@ impl Vm {
2499
3369
  .iter()
2500
3370
  .filter(|&&n| {
2501
3371
  let elem = Value::Number(n);
2502
- let (l, r) = if param_left { (elem, const_val.clone()) } else { (const_val.clone(), elem) };
3372
+ let (l, r) = if param_left {
3373
+ (elem, const_val.clone())
3374
+ } else {
3375
+ (const_val.clone(), elem)
3376
+ };
2503
3377
  eval_binop(binop, &l, &r).unwrap_or(Value::Null).is_truthy()
2504
3378
  })
2505
3379
  .copied()
@@ -2511,7 +3385,11 @@ impl Vm {
2511
3385
  let filtered: Vec<Value> = arr_borrow
2512
3386
  .iter()
2513
3387
  .filter(|v| {
2514
- let (l, r) = if param_left { ((*v).clone(), const_val.clone()) } else { (const_val.clone(), (*v).clone()) };
3388
+ let (l, r) = if param_left {
3389
+ ((*v).clone(), const_val.clone())
3390
+ } else {
3391
+ (const_val.clone(), (*v).clone())
3392
+ };
2515
3393
  eval_binop(binop, &l, &r).unwrap_or(Value::Null).is_truthy()
2516
3394
  })
2517
3395
  .cloned()
@@ -2581,15 +3459,14 @@ impl Vm {
2581
3459
  // on the cranelift / llvm backends that want to expose
2582
3460
  // `cargo:…` Rust crates should register the module's
2583
3461
  // 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
- };
3462
+ let from_registry: Option<Value> =
3463
+ if spec.starts_with("cargo:") || spec.starts_with("ffi:") {
3464
+ let regs = self.native_modules.borrow();
3465
+ regs.get(spec)
3466
+ .and_then(|m| m.borrow().get(&Arc::from(export_name)).cloned())
3467
+ } else {
3468
+ None
3469
+ };
2593
3470
  let v = from_registry
2594
3471
  .or_else(|| get_builtin_export(self.capabilities.as_ref(), spec, export_name))
2595
3472
  .ok_or_else(|| {
@@ -2772,7 +3649,12 @@ fn eval_unary(op: UnaryOp, o: &Value) -> Result<Value, String> {
2772
3649
  /// error), refilling the cache when the object *does* have the property. Result-equivalent to
2773
3650
  /// `get_member` — the cache only skips the lookup; the shape uniquely fixes the slot for a property.
2774
3651
  #[inline]
2775
- fn ic_get_member(chunk: &Chunk, name_idx: u16, obj: &Value, key: &Arc<str>) -> Result<Value, String> {
3652
+ fn ic_get_member(
3653
+ chunk: &Chunk,
3654
+ name_idx: u16,
3655
+ obj: &Value,
3656
+ key: &Arc<str>,
3657
+ ) -> Result<Value, String> {
2776
3658
  use std::sync::atomic::Ordering::Relaxed;
2777
3659
  if let Value::Object(od) = obj {
2778
3660
  let b = od.borrow();
@@ -2853,13 +3735,21 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
2853
3735
  let map = m.borrow();
2854
3736
  // Reading a missing own property returns `null` (tish's nullish value), matching
2855
3737
  // 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))
3738
+ Ok(map
3739
+ .strings
3740
+ .get(key.as_ref())
3741
+ .cloned()
3742
+ .unwrap_or(Value::Null))
2857
3743
  }
2858
3744
  Value::NumberArray(a) => {
2859
3745
  let key_s = key.as_ref();
2860
3746
  // Numeric index fast path.
2861
3747
  if let Ok(idx) = key_s.parse::<usize>() {
2862
- return Ok(a.borrow().get(idx).map(|&n| Value::Number(n)).unwrap_or(Value::Null));
3748
+ return Ok(a
3749
+ .borrow()
3750
+ .get(idx)
3751
+ .map(|&n| Value::Number(n))
3752
+ .unwrap_or(Value::Null));
2863
3753
  }
2864
3754
  if key_s == "length" {
2865
3755
  return Ok(Value::Number(a.borrow().len() as f64));
@@ -2880,19 +3770,38 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
2880
3770
  Value::Number(arr.len() as f64)
2881
3771
  }),
2882
3772
  "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) })
3773
+ a_clone
3774
+ .borrow_mut()
3775
+ .pop()
3776
+ .map(|n| {
3777
+ if n.is_nan() {
3778
+ Value::Null
3779
+ } else {
3780
+ Value::Number(n)
3781
+ }
3782
+ })
2885
3783
  .unwrap_or(Value::Null)
2886
3784
  }),
2887
3785
  "shift" => make_native_fn(move |_: &[Value]| {
2888
3786
  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) } }
3787
+ if arr.is_empty() {
3788
+ Value::Null
3789
+ } else {
3790
+ let n = arr.remove(0);
3791
+ if n.is_nan() {
3792
+ Value::Null
3793
+ } else {
3794
+ Value::Number(n)
3795
+ }
3796
+ }
2891
3797
  }),
2892
3798
  "unshift" => make_native_fn(move |args: &[Value]| {
2893
3799
  let mut arr = a_clone.borrow_mut();
2894
3800
  for (i, v) in args.iter().enumerate() {
2895
- let n = match v { Value::Number(n) => *n, _ => f64::NAN };
3801
+ let n = match v {
3802
+ Value::Number(n) => *n,
3803
+ _ => f64::NAN,
3804
+ };
2896
3805
  arr.insert(i, n);
2897
3806
  }
2898
3807
  Value::Number(arr.len() as f64)
@@ -2905,7 +3814,10 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
2905
3814
  let a2 = a_clone.clone();
2906
3815
  make_native_fn(move |args: &[Value]| {
2907
3816
  // Check if there are non-numeric items to insert (args[2..]).
2908
- let has_non_numeric = args.get(2..).unwrap_or(&[]).iter()
3817
+ let has_non_numeric = args
3818
+ .get(2..)
3819
+ .unwrap_or(&[])
3820
+ .iter()
2909
3821
  .any(|v| !matches!(v, Value::Number(_)));
2910
3822
  if has_non_numeric {
2911
3823
  // Deopt: materialise, splice on the boxed array, then write numeric
@@ -2913,18 +3825,37 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
2913
3825
  // identity for subsequent accesses. The array may have non-numeric
2914
3826
  // elements after this splice — they become NaN holes in the VmRef.
2915
3827
  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(&[]));
3828
+ let result = arr_builtins::splice(
3829
+ &boxed,
3830
+ args.first().unwrap_or(&Value::Null),
3831
+ args.get(1),
3832
+ args.get(2..).unwrap_or(&[]),
3833
+ );
2917
3834
  // Sync the modified boxed Vec back into the original VmRef.
2918
3835
  if let Value::Array(boxed_vmref) = &boxed {
2919
3836
  let mut packed = a2.borrow_mut();
2920
- *packed = boxed_vmref.borrow().iter().map(|v| match v { Value::Number(n) => *n, _ => f64::NAN }).collect();
3837
+ *packed = boxed_vmref
3838
+ .borrow()
3839
+ .iter()
3840
+ .map(|v| match v {
3841
+ Value::Number(n) => *n,
3842
+ _ => f64::NAN,
3843
+ })
3844
+ .collect();
2921
3845
  }
2922
3846
  result
2923
3847
  } else {
2924
3848
  let mut arr = a2.borrow_mut();
2925
3849
  let len = arr.len() as i64;
2926
3850
  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()) } }
3851
+ Some(Value::Number(n)) => {
3852
+ let s = *n as i64;
3853
+ if s < 0 {
3854
+ (len + s).max(0) as usize
3855
+ } else {
3856
+ (s as usize).min(arr.len())
3857
+ }
3858
+ }
2928
3859
  _ => 0,
2929
3860
  };
2930
3861
  let del = match args.get(1) {
@@ -2932,8 +3863,17 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
2932
3863
  _ => arr.len().saturating_sub(start),
2933
3864
  };
2934
3865
  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();
3866
+ let new_nums: Vec<f64> = args
3867
+ .get(2..)
3868
+ .unwrap_or(&[])
3869
+ .iter()
3870
+ .map(|v| match v {
3871
+ Value::Number(n) => *n,
3872
+ _ => f64::NAN,
3873
+ })
3874
+ .collect();
3875
+ let removed: Vec<f64> =
3876
+ arr.splice(start..start + del, new_nums).collect();
2937
3877
  Value::number_array(removed)
2938
3878
  }
2939
3879
  })
@@ -2956,29 +3896,144 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
2956
3896
  let boxed = Value::materialize_number_array(&a_clone);
2957
3897
  let bv = boxed.clone();
2958
3898
  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) }),
3899
+ "map" => make_native_fn(move |args| {
3900
+ let cb = args.first().cloned().unwrap_or(Value::Null);
3901
+ // #187 fusion over the SAME boxed snapshot `bv` the generic path uses, so a
3902
+ // fused result is byte-identical (a boxed all-number `Array` → boxed output).
3903
+ #[cfg(not(target_arch = "wasm32"))]
3904
+ if let Some(v) = hof_fusion::map(&bv, &cb) {
3905
+ return v;
3906
+ }
3907
+ arr_builtins::map(&bv, &cb)
3908
+ }),
3909
+ "filter" => make_native_fn(move |args| {
3910
+ let cb = args.first().cloned().unwrap_or(Value::Null);
3911
+ #[cfg(not(target_arch = "wasm32"))]
3912
+ if let Some(v) = hof_fusion::filter(&bv, &cb) {
3913
+ return v;
3914
+ }
3915
+ arr_builtins::filter(&bv, &cb)
3916
+ }),
3917
+ "reduce" => make_native_fn(move |args| {
3918
+ let cb = args.first().cloned().unwrap_or(Value::Null);
3919
+ let init = args.get(1).cloned(); // None = no initial value (empty array → throw)
3920
+ #[cfg(not(target_arch = "wasm32"))]
3921
+ if let Some(v) = hof_fusion::reduce(&bv, &cb, init.as_ref()) {
3922
+ return v;
3923
+ }
3924
+ arr_builtins::reduce(&bv, &cb, init.as_ref())
3925
+ }),
3926
+ "reduceRight" => make_native_fn(move |args| {
3927
+ let cb = args.first().cloned().unwrap_or(Value::Null);
3928
+ let init = args.get(1).cloned();
3929
+ arr_builtins::reduce_right(&bv, &cb, init.as_ref())
3930
+ }),
3931
+ "keys" => make_native_fn(move |_| arr_builtins::keys(&bv)),
3932
+ "values" => make_native_fn(move |_| arr_builtins::values(&bv)),
3933
+ "entries" => make_native_fn(move |_| arr_builtins::entries(&bv)),
3934
+ "forEach" => make_native_fn(move |args| {
3935
+ let cb = args.first().cloned().unwrap_or(Value::Null);
3936
+ #[cfg(not(target_arch = "wasm32"))]
3937
+ if let Some(v) = hof_fusion::for_each(&bv, &cb) {
3938
+ return v;
3939
+ }
3940
+ arr_builtins::for_each(&bv, &cb)
3941
+ }),
3942
+ "find" => make_native_fn(move |args| {
3943
+ let cb = args.first().cloned().unwrap_or(Value::Null);
3944
+ arr_builtins::find(&bv, &cb)
3945
+ }),
3946
+ "findIndex" => make_native_fn(move |args| {
3947
+ let cb = args.first().cloned().unwrap_or(Value::Null);
3948
+ arr_builtins::find_index(&bv, &cb)
3949
+ }),
3950
+ "findLast" => make_native_fn(move |args| {
3951
+ let cb = args.first().cloned().unwrap_or(Value::Null);
3952
+ arr_builtins::find_last(&bv, &cb)
3953
+ }),
3954
+ "findLastIndex" => make_native_fn(move |args| {
3955
+ let cb = args.first().cloned().unwrap_or(Value::Null);
3956
+ arr_builtins::find_last_index(&bv, &cb)
3957
+ }),
3958
+ "at" => make_native_fn(move |args| {
3959
+ let i = args.first().cloned().unwrap_or(Value::Null);
3960
+ arr_builtins::at(&bv, &i)
3961
+ }),
3962
+ "some" => make_native_fn(move |args| {
3963
+ let cb = args.first().cloned().unwrap_or(Value::Null);
3964
+ arr_builtins::some(&bv, &cb)
3965
+ }),
3966
+ "every" => make_native_fn(move |args| {
3967
+ let cb = args.first().cloned().unwrap_or(Value::Null);
3968
+ arr_builtins::every(&bv, &cb)
3969
+ }),
3970
+ "join" => make_native_fn(move |args| {
3971
+ let sep = args.first().cloned().unwrap_or(Value::Null);
3972
+ arr_builtins::join(&bv, &sep)
3973
+ }),
3974
+ "flat" => make_native_fn(move |args| {
3975
+ let d = args.first().cloned().unwrap_or(Value::Number(1.0));
3976
+ arr_builtins::flat(&bv, &d)
3977
+ }),
3978
+ "flatMap" => make_native_fn(move |args| {
3979
+ let cb = args.first().cloned().unwrap_or(Value::Null);
3980
+ arr_builtins::flat_map(&bv, &cb)
3981
+ }),
3982
+ "reverse" => make_native_fn(move |_| arr_builtins::reverse(&bv)),
3983
+ "toReversed" => make_native_fn(move |_| arr_builtins::to_reversed(&bv)),
3984
+ "toSorted" => {
3985
+ make_native_fn(move |args| arr_builtins::to_sorted(&bv, args.first()))
3986
+ }
3987
+ "with" => make_native_fn(move |args| {
3988
+ let i = args.first().cloned().unwrap_or(Value::Null);
3989
+ let v = args.get(1).cloned().unwrap_or(Value::Null);
3990
+ arr_builtins::with(&bv, &i, &v)
3991
+ }),
3992
+ "toSpliced" => make_native_fn(move |args| {
3993
+ let start = args.first().cloned().unwrap_or(Value::Null);
3994
+ let dc = args.get(1).cloned();
3995
+ let items: Vec<Value> = args.iter().skip(2).cloned().collect();
3996
+ arr_builtins::to_spliced(&bv, &start, dc.as_ref(), &items)
3997
+ }),
3998
+ "fill" => make_native_fn(move |args| {
3999
+ let v = args.first().cloned().unwrap_or(Value::Null);
4000
+ let s = args.get(1).cloned().unwrap_or(Value::Null);
4001
+ let e = args.get(2).cloned().unwrap_or(Value::Null);
4002
+ arr_builtins::fill(&bv, &v, &s, &e)
4003
+ }),
4004
+ "slice" => make_native_fn(move |args| {
4005
+ let s = args.first().cloned().unwrap_or(Value::Null);
4006
+ let e = args.get(1).cloned().unwrap_or(Value::Null);
4007
+ arr_builtins::slice(&bv, &s, &e)
4008
+ }),
4009
+ "concat" => make_native_fn(move |args| arr_builtins::concat(&bv, args)),
4010
+ "indexOf" => make_native_fn(move |args| {
4011
+ let s = args.first().cloned().unwrap_or(Value::Null);
4012
+ arr_builtins::index_of(&bv, &s, args.get(1))
4013
+ }),
4014
+ "lastIndexOf" => make_native_fn(move |args| {
4015
+ let s = args.first().cloned().unwrap_or(Value::Null);
4016
+ arr_builtins::last_index_of(&bv, &s, args.get(1))
4017
+ }),
4018
+ "copyWithin" => make_native_fn(move |args| {
4019
+ let t = args.first().cloned().unwrap_or(Value::Null);
4020
+ let s = args.get(1).cloned().unwrap_or(Value::Null);
4021
+ let e = args.get(2).cloned().unwrap_or(Value::Null);
4022
+ arr_builtins::copy_within(&bv, &t, &s, &e)
4023
+ }),
4024
+ "includes" => make_native_fn(move |args| {
4025
+ let s = args.first().cloned().unwrap_or(Value::Null);
4026
+ let f = args.get(1).cloned();
4027
+ arr_builtins::includes(&bv, &s, f.as_ref())
4028
+ }),
4029
+ "unshift" => make_native_fn(move |args| arr_builtins::unshift(&bv, args)),
4030
+ "shift" => make_native_fn(move |_| arr_builtins::shift(&bv)),
4031
+ "splice" => make_native_fn(move |args| {
4032
+ let s = args.first().cloned().unwrap_or(Value::Null);
4033
+ let dc = args.get(1).cloned();
4034
+ let items: Vec<Value> = args.get(2..).unwrap_or(&[]).to_vec();
4035
+ arr_builtins::splice(&bv, &s, dc.as_ref(), &items)
4036
+ }),
2982
4037
  _ => return Err(format!("Property '{}' not found", key)),
2983
4038
  }
2984
4039
  }
@@ -3037,7 +4092,17 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
3037
4092
  }),
3038
4093
  "indexOf" => make_native_fn(move |args: &[Value]| {
3039
4094
  let search = args.first().unwrap_or(&Value::Null);
3040
- arr_builtins::index_of(&Value::Array(a_clone.clone()), search)
4095
+ arr_builtins::index_of(&Value::Array(a_clone.clone()), search, args.get(1))
4096
+ }),
4097
+ "lastIndexOf" => make_native_fn(move |args: &[Value]| {
4098
+ let search = args.first().unwrap_or(&Value::Null);
4099
+ arr_builtins::last_index_of(&Value::Array(a_clone.clone()), search, args.get(1))
4100
+ }),
4101
+ "copyWithin" => make_native_fn(move |args: &[Value]| {
4102
+ let t = args.first().cloned().unwrap_or(Value::Null);
4103
+ let s = args.get(1).cloned().unwrap_or(Value::Null);
4104
+ let e = args.get(2).cloned().unwrap_or(Value::Null);
4105
+ arr_builtins::copy_within(&Value::Array(a_clone.clone()), &t, &s, &e)
3041
4106
  }),
3042
4107
  "includes" => make_native_fn(move |args: &[Value]| {
3043
4108
  let search = args.first().unwrap_or(&Value::Null);
@@ -3046,20 +4111,50 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
3046
4111
  }),
3047
4112
  "map" => make_native_fn(move |args: &[Value]| {
3048
4113
  let cb = args.first().cloned().unwrap_or(Value::Null);
3049
- arr_builtins::map(&Value::Array(a_clone.clone()), &cb)
4114
+ let arr = Value::Array(a_clone.clone());
4115
+ // #187 native HOF fusion — tight native loop over a JIT'd numeric callback; bails
4116
+ // (None) to the byte-identical generic path for any non-fusable callback/array.
4117
+ #[cfg(not(target_arch = "wasm32"))]
4118
+ if let Some(v) = hof_fusion::map(&arr, &cb) {
4119
+ return v;
4120
+ }
4121
+ arr_builtins::map(&arr, &cb)
3050
4122
  }),
3051
4123
  "filter" => make_native_fn(move |args: &[Value]| {
3052
4124
  let cb = args.first().cloned().unwrap_or(Value::Null);
3053
- arr_builtins::filter(&Value::Array(a_clone.clone()), &cb)
4125
+ let arr = Value::Array(a_clone.clone());
4126
+ #[cfg(not(target_arch = "wasm32"))]
4127
+ if let Some(v) = hof_fusion::filter(&arr, &cb) {
4128
+ return v;
4129
+ }
4130
+ arr_builtins::filter(&arr, &cb)
3054
4131
  }),
3055
4132
  "reduce" => make_native_fn(move |args: &[Value]| {
3056
4133
  let cb = args.first().cloned().unwrap_or(Value::Null);
3057
- let init = args.get(1).cloned().unwrap_or(Value::Null);
3058
- arr_builtins::reduce(&Value::Array(a_clone.clone()), &cb, &init)
4134
+ let init = args.get(1).cloned(); // None = no initial value (empty array → throw)
4135
+ let arr = Value::Array(a_clone.clone());
4136
+ #[cfg(not(target_arch = "wasm32"))]
4137
+ if let Some(v) = hof_fusion::reduce(&arr, &cb, init.as_ref()) {
4138
+ return v;
4139
+ }
4140
+ arr_builtins::reduce(&arr, &cb, init.as_ref())
4141
+ }),
4142
+ "reduceRight" => make_native_fn(move |args: &[Value]| {
4143
+ let cb = args.first().cloned().unwrap_or(Value::Null);
4144
+ let init = args.get(1).cloned();
4145
+ arr_builtins::reduce_right(&Value::Array(a_clone.clone()), &cb, init.as_ref())
3059
4146
  }),
4147
+ "keys" => make_native_fn(move |_: &[Value]| arr_builtins::keys(&Value::Array(a_clone.clone()))),
4148
+ "values" => make_native_fn(move |_: &[Value]| arr_builtins::values(&Value::Array(a_clone.clone()))),
4149
+ "entries" => make_native_fn(move |_: &[Value]| arr_builtins::entries(&Value::Array(a_clone.clone()))),
3060
4150
  "forEach" => make_native_fn(move |args: &[Value]| {
3061
4151
  let cb = args.first().cloned().unwrap_or(Value::Null);
3062
- arr_builtins::for_each(&Value::Array(a_clone.clone()), &cb)
4152
+ let arr = Value::Array(a_clone.clone());
4153
+ #[cfg(not(target_arch = "wasm32"))]
4154
+ if let Some(v) = hof_fusion::for_each(&arr, &cb) {
4155
+ return v;
4156
+ }
4157
+ arr_builtins::for_each(&arr, &cb)
3063
4158
  }),
3064
4159
  "find" => make_native_fn(move |args: &[Value]| {
3065
4160
  let cb = args.first().cloned().unwrap_or(Value::Null);
@@ -3119,6 +4214,28 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
3119
4214
  &items,
3120
4215
  )
3121
4216
  }),
4217
+ "toReversed" => make_native_fn(move |_: &[Value]| {
4218
+ arr_builtins::to_reversed(&Value::Array(a_clone.clone()))
4219
+ }),
4220
+ "toSorted" => make_native_fn(move |args: &[Value]| {
4221
+ arr_builtins::to_sorted(&Value::Array(a_clone.clone()), args.first())
4222
+ }),
4223
+ "with" => make_native_fn(move |args: &[Value]| {
4224
+ let i = args.first().cloned().unwrap_or(Value::Null);
4225
+ let v = args.get(1).cloned().unwrap_or(Value::Null);
4226
+ arr_builtins::with(&Value::Array(a_clone.clone()), &i, &v)
4227
+ }),
4228
+ "toSpliced" => make_native_fn(move |args: &[Value]| {
4229
+ let start = args.first().unwrap_or(&Value::Null);
4230
+ let delete_count = args.get(1).map(|v| v as &Value);
4231
+ let items: Vec<Value> = args.get(2..).unwrap_or(&[]).to_vec();
4232
+ arr_builtins::to_spliced(
4233
+ &Value::Array(a_clone.clone()),
4234
+ start,
4235
+ delete_count,
4236
+ &items,
4237
+ )
4238
+ }),
3122
4239
  _ => return Err(format!("Property '{}' not found", key)),
3123
4240
  };
3124
4241
  Ok(Value::Function(method))
@@ -3126,13 +4243,13 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
3126
4243
  Value::String(s) => {
3127
4244
  let key_s = key.as_ref();
3128
4245
  if let Ok(idx) = key_s.parse::<usize>() {
3129
- return match s.chars().nth(idx) {
4246
+ return match str_builtins::nth_char(s, idx) {
3130
4247
  Some(c) => Ok(Value::String(tishlang_core::ArcStr::from(c.to_string()))),
3131
4248
  None => Err("Index out of bounds".to_string()),
3132
4249
  };
3133
4250
  }
3134
4251
  if key_s == "length" {
3135
- return Ok(Value::Number(s.chars().count() as f64));
4252
+ return Ok(Value::Number(str_builtins::char_count(s) as f64));
3136
4253
  }
3137
4254
  let s_clone: tishlang_core::ArcStr = s.clone();
3138
4255
  let method: ArrayMethodFn = match key_s {
@@ -3144,11 +4261,7 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
3144
4261
  "lastIndexOf" => make_native_fn(move |args: &[Value]| {
3145
4262
  let search = args.first().unwrap_or(&Value::Null);
3146
4263
  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
- )
4264
+ str_builtins::last_index_of(&Value::String(s_clone.clone()), search, &position)
3152
4265
  }),
3153
4266
  "includes" => make_native_fn(move |args: &[Value]| {
3154
4267
  let search = args.first().unwrap_or(&Value::Null);
@@ -3189,6 +4302,16 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
3189
4302
  "trim" => make_native_fn(move |_args: &[Value]| {
3190
4303
  str_builtins::trim(&Value::String(s_clone.clone()))
3191
4304
  }),
4305
+ "trimStart" => make_native_fn(move |_args: &[Value]| {
4306
+ str_builtins::trim_start(&Value::String(s_clone.clone()))
4307
+ }),
4308
+ "trimEnd" => make_native_fn(move |_args: &[Value]| {
4309
+ str_builtins::trim_end(&Value::String(s_clone.clone()))
4310
+ }),
4311
+ "normalize" => make_native_fn(move |args: &[Value]| {
4312
+ let form = args.first().cloned().unwrap_or(Value::Null);
4313
+ str_builtins::normalize(&Value::String(s_clone.clone()), &form)
4314
+ }),
3192
4315
  "toUpperCase" => make_native_fn(move |_args: &[Value]| {
3193
4316
  str_builtins::to_upper_case(&Value::String(s_clone.clone()))
3194
4317
  }),
@@ -3197,11 +4320,11 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
3197
4320
  }),
3198
4321
  "startsWith" => make_native_fn(move |args: &[Value]| {
3199
4322
  let search = args.first().unwrap_or(&Value::Null);
3200
- str_builtins::starts_with(&Value::String(s_clone.clone()), search)
4323
+ str_builtins::starts_with(&Value::String(s_clone.clone()), search, args.get(1))
3201
4324
  }),
3202
4325
  "endsWith" => make_native_fn(move |args: &[Value]| {
3203
4326
  let search = args.first().unwrap_or(&Value::Null);
3204
- str_builtins::ends_with(&Value::String(s_clone.clone()), search)
4327
+ str_builtins::ends_with(&Value::String(s_clone.clone()), search, args.get(1))
3205
4328
  }),
3206
4329
  "replace" => make_native_fn(move |args: &[Value]| {
3207
4330
  let search = args.first().unwrap_or(&Value::Null);
@@ -3221,11 +4344,7 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
3221
4344
  "replaceAll" => make_native_fn(move |args: &[Value]| {
3222
4345
  let search = args.first().unwrap_or(&Value::Null);
3223
4346
  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
- )
4347
+ str_builtins::replace_all(&Value::String(s_clone.clone()), search, replacement)
3229
4348
  }),
3230
4349
  #[cfg(feature = "regex")]
3231
4350
  "match" => make_native_fn(move |args: &[Value]| {
@@ -3233,6 +4352,11 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
3233
4352
  tishlang_runtime::string_match_regex(&Value::String(s_clone.clone()), re)
3234
4353
  }),
3235
4354
  #[cfg(feature = "regex")]
4355
+ "matchAll" => make_native_fn(move |args: &[Value]| {
4356
+ let re = args.first().unwrap_or(&Value::Null);
4357
+ tishlang_runtime::string_match_all_regex(&Value::String(s_clone.clone()), re)
4358
+ }),
4359
+ #[cfg(feature = "regex")]
3236
4360
  "search" => make_native_fn(move |args: &[Value]| {
3237
4361
  let re = args.first().unwrap_or(&Value::Null);
3238
4362
  tishlang_runtime::string_search_regex(&Value::String(s_clone.clone()), re)
@@ -3249,6 +4373,15 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
3249
4373
  let idx = args.first().unwrap_or(&Value::Null);
3250
4374
  str_builtins::char_code_at(&Value::String(s_clone.clone()), idx)
3251
4375
  }),
4376
+ "codePointAt" => make_native_fn(move |args: &[Value]| {
4377
+ let idx = args.first().unwrap_or(&Value::Null);
4378
+ str_builtins::code_point_at(&Value::String(s_clone.clone()), idx)
4379
+ }),
4380
+ "substr" => make_native_fn(move |args: &[Value]| {
4381
+ let start = args.first().cloned().unwrap_or(Value::Null);
4382
+ let length = args.get(1).cloned().unwrap_or(Value::Null);
4383
+ str_builtins::substr(&Value::String(s_clone.clone()), &start, &length)
4384
+ }),
3252
4385
  "repeat" => make_native_fn(move |args: &[Value]| {
3253
4386
  let count = args.first().unwrap_or(&Value::Null);
3254
4387
  str_builtins::repeat(&Value::String(s_clone.clone()), count)
@@ -3276,6 +4409,14 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
3276
4409
  let digits = args.first().unwrap_or(&Value::Null);
3277
4410
  num_builtins::to_fixed(&Value::Number(n_val), digits)
3278
4411
  }),
4412
+ "toExponential" => make_native_fn(move |args: &[Value]| {
4413
+ let digits = args.first().unwrap_or(&Value::Null);
4414
+ num_builtins::to_exponential(&Value::Number(n_val), digits)
4415
+ }),
4416
+ "toPrecision" => make_native_fn(move |args: &[Value]| {
4417
+ let precision = args.first().unwrap_or(&Value::Null);
4418
+ num_builtins::to_precision(&Value::Number(n_val), precision)
4419
+ }),
3279
4420
  "toString" => make_native_fn(move |args: &[Value]| {
3280
4421
  let radix = args.first().unwrap_or(&Value::Null);
3281
4422
  num_builtins::to_string(&Value::Number(n_val), radix)
@@ -3390,43 +4531,48 @@ fn get_index(obj: &Value, idx: &Value) -> Result<Value, String> {
3390
4531
  Value::NumberArray(a) => {
3391
4532
  let i = match idx {
3392
4533
  Value::Number(n) => *n as usize,
3393
- _ => return Err(format!("Array index must be number, got {}", idx.type_name())),
4534
+ // A canonical integer string key ("0","1",…) indexes the array (#432); any other key
4535
+ // reads Null (JS reads a missing property as undefined) rather than throwing.
4536
+ Value::String(s) => match tishlang_core::str_to_array_index(s) {
4537
+ Some(i) => i,
4538
+ None => return Ok(Value::Null),
4539
+ },
4540
+ _ => return Ok(Value::Null),
3394
4541
  };
3395
4542
  // 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))
4543
+ Ok(a.borrow()
4544
+ .get(i)
4545
+ .map(|&n| {
4546
+ if n.is_nan() {
4547
+ Value::Null
4548
+ } else {
4549
+ Value::Number(n)
4550
+ }
4551
+ })
4552
+ .unwrap_or(Value::Null))
3397
4553
  }
3398
4554
  Value::Array(a) => {
3399
4555
  let i = match idx {
3400
4556
  Value::Number(n) => *n as usize,
3401
- _ => {
3402
- return Err(format!(
3403
- "Array index must be number, got {}",
3404
- idx.type_name()
3405
- ));
3406
- }
4557
+ // Canonical integer string key → index (#432); any other key reads Null, not a throw.
4558
+ Value::String(s) => match tishlang_core::str_to_array_index(s) {
4559
+ Some(i) => i,
4560
+ None => return Ok(Value::Null),
4561
+ },
4562
+ _ => return Ok(Value::Null),
3407
4563
  };
3408
- Ok(a
3409
- .borrow()
3410
- .get(i)
3411
- .cloned()
3412
- .unwrap_or(Value::Null))
4564
+ Ok(a.borrow().get(i).cloned().unwrap_or(Value::Null))
3413
4565
  }
3414
4566
  Value::String(s) => {
3415
4567
  let i = match idx {
3416
4568
  Value::Number(n) => {
3417
4569
  let n = *n;
3418
4570
  if n < 0.0 || n.fract() != 0.0 {
3419
- return Err(format!(
3420
- "String index must be non-negative integer, got {}",
3421
- n
3422
- ));
3423
- }
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());
4571
+ // A negative / non-integer string index reads `null` (JS `undefined`), NOT a
4572
+ // throw — interp and native already return null; the vm must agree. (#437)
4573
+ return Ok(Value::Null);
3428
4574
  }
3429
- i
4575
+ n as usize
3430
4576
  }
3431
4577
  _ => {
3432
4578
  return Err(format!(
@@ -3435,9 +4581,12 @@ fn get_index(obj: &Value, idx: &Value) -> Result<Value, String> {
3435
4581
  ));
3436
4582
  }
3437
4583
  };
3438
- match s.chars().nth(i) {
4584
+ // `nth_char` returns None past the end, so the cursor cache handles bounds — no separate
4585
+ // O(n) `chars().count()` pre-check (#203).
4586
+ match str_builtins::nth_char(s, i) {
3439
4587
  Some(c) => Ok(Value::String(tishlang_core::ArcStr::from(c.to_string()))),
3440
- None => Err("Index out of bounds".to_string()),
4588
+ // Past the end → `null` (JS `undefined`), matching interp/native, not a throw. (#437)
4589
+ None => Ok(Value::Null),
3441
4590
  }
3442
4591
  }
3443
4592
  // A missing own property returns `null`, not a thrown error — matching dot reads
@@ -3455,7 +4604,7 @@ fn get_index(obj: &Value, idx: &Value) -> Result<Value, String> {
3455
4604
  }
3456
4605
  };
3457
4606
  get_member(obj, &key_arc)
3458
- },
4607
+ }
3459
4608
  _ => Err(format!(
3460
4609
  "Cannot read property '{}' of {}",
3461
4610
  idx.to_display_string(),
@@ -3497,7 +4646,18 @@ fn set_index(obj: &Value, idx: &Value, val: Value) -> Result<(), String> {
3497
4646
  Value::NumberArray(a) => {
3498
4647
  let i = match idx {
3499
4648
  Value::Number(n) => *n as usize,
3500
- _ => return Err(format!("Array index must be number, got {}", idx.type_name())),
4649
+ // Canonical integer string key → index (#432); a non-index string key can't be stored
4650
+ // in a Vec-backed array, so the write is a no-op (rather than a throw).
4651
+ Value::String(s) => match tishlang_core::str_to_array_index(s) {
4652
+ Some(i) => i,
4653
+ None => return Ok(()),
4654
+ },
4655
+ _ => {
4656
+ return Err(format!(
4657
+ "Array index must be number, got {}",
4658
+ idx.type_name()
4659
+ ))
4660
+ }
3501
4661
  };
3502
4662
  // In-bounds numeric assignment stays packed.
3503
4663
  // Out-of-bounds or non-numeric falls through to the Array path by returning
@@ -3508,7 +4668,9 @@ fn set_index(obj: &Value, idx: &Value, val: Value) -> Result<(), String> {
3508
4668
  Value::Number(n) => {
3509
4669
  let mut arr = a.borrow_mut();
3510
4670
  // Extend with NaN "holes" if needed (NaN = sparse hole; read back as Null).
3511
- while arr.len() <= i { arr.push(f64::NAN); }
4671
+ while arr.len() <= i {
4672
+ arr.push(f64::NAN);
4673
+ }
3512
4674
  arr[i] = n;
3513
4675
  }
3514
4676
  // Non-numeric set: the Vec<f64> can't represent this type. Extend with NaN holes
@@ -3518,7 +4680,9 @@ fn set_index(obj: &Value, idx: &Value, val: Value) -> Result<(), String> {
3518
4680
  // numeric elements and Null for the NaN holes.
3519
4681
  _ => {
3520
4682
  let mut arr = a.borrow_mut();
3521
- while arr.len() <= i { arr.push(f64::NAN); }
4683
+ while arr.len() <= i {
4684
+ arr.push(f64::NAN);
4685
+ }
3522
4686
  // arr[i] is already NaN (hole); we can't store the non-numeric value — acceptable
3523
4687
  // for the experimental TISH_PACKED_ARRAYS path.
3524
4688
  }
@@ -3528,6 +4692,11 @@ fn set_index(obj: &Value, idx: &Value, val: Value) -> Result<(), String> {
3528
4692
  Value::Array(a) => {
3529
4693
  let i = match idx {
3530
4694
  Value::Number(n) => *n as usize,
4695
+ // Canonical integer string key → index (#432); a non-index string key is a no-op write.
4696
+ Value::String(s) => match tishlang_core::str_to_array_index(s) {
4697
+ Some(i) => i,
4698
+ None => return Ok(()),
4699
+ },
3531
4700
  _ => {
3532
4701
  return Err(format!(
3533
4702
  "Array index must be number, got {}",
@@ -3558,3 +4727,89 @@ pub fn run_with_options(chunk: &Chunk, opts: VmRunOptions) -> Result<Value, Stri
3558
4727
  let mut vm = Vm::with_capabilities(opts.capabilities);
3559
4728
  vm.run_with_options(chunk, opts.repl_mode)
3560
4729
  }
4730
+
4731
+ #[cfg(test)]
4732
+ mod recursion_limit_tests_381 {
4733
+ use tishlang_core::{set_max_call_depth_for_test, Value, DEFAULT_MAX_CALL_DEPTH};
4734
+
4735
+ fn run_src(src: &str) -> Result<Value, String> {
4736
+ let program = tishlang_parser::parse(src).expect("parse");
4737
+ let chunk = tishlang_bytecode::compile(&program).expect("compile");
4738
+ super::run(&chunk)
4739
+ }
4740
+
4741
+ #[test]
4742
+ fn deep_recursion_is_catchable_not_abort() {
4743
+ // Without the guard this infinite recursion aborts the process (SIGABRT). With it, the throw
4744
+ // is a catchable RangeError: try/catch recovers and the program returns normally.
4745
+ set_max_call_depth_for_test(300);
4746
+ // Object-returning recursion is NOT JIT-eligible, so it takes the guarded VM call path.
4747
+ let src = "let ok = false\n\
4748
+ fn rec(n) { return { v: rec(n + 1) } }\n\
4749
+ try { rec(0) } catch (e) { ok = true }\n\
4750
+ ok";
4751
+ let out = run_src(src);
4752
+ set_max_call_depth_for_test(DEFAULT_MAX_CALL_DEPTH);
4753
+ assert!(
4754
+ out.is_ok(),
4755
+ "deep recursion must be catchable, not abort/error: {out:?}"
4756
+ );
4757
+ }
4758
+
4759
+ #[test]
4760
+ fn uncaught_deep_recursion_surfaces_error_not_abort() {
4761
+ // An UNCAUGHT infinite recursion must surface as a returned error, never a SIGABRT.
4762
+ set_max_call_depth_for_test(300);
4763
+ let out = run_src("fn rec(n) { return { v: rec(n + 1) } }\nrec(0)");
4764
+ set_max_call_depth_for_test(DEFAULT_MAX_CALL_DEPTH);
4765
+ assert!(
4766
+ out.is_err(),
4767
+ "uncaught deep recursion must return an error, got {out:?}"
4768
+ );
4769
+ }
4770
+
4771
+ #[test]
4772
+ fn normal_recursion_is_unaffected() {
4773
+ set_max_call_depth_for_test(20_000);
4774
+ let out = run_src(
4775
+ "fn fib(n) { if (n < 2) { return n } return fib(n - 1) + fib(n - 2) }\nfib(15)",
4776
+ );
4777
+ set_max_call_depth_for_test(DEFAULT_MAX_CALL_DEPTH);
4778
+ assert!(
4779
+ out.is_ok(),
4780
+ "normal recursion must not be affected: {out:?}"
4781
+ );
4782
+ }
4783
+
4784
+ // The core of #381 for the JIT tier: a pure-numeric self-recursive function JIT-compiles and
4785
+ // recurses on the native stack via SelfCall, BELOW the VM's depth counter (so `set_max_call_depth`
4786
+ // can't bound it). Without the guard this overflows the native stack — an uncatchable SIGSEGV/abort
4787
+ // that would kill the whole process. With it, the entry SP check turns the overflow into a
4788
+ // RangeError which, uncaught, must surface as a returned `Err`. Run on a bounded-stack thread so
4789
+ // the guard (or, on a regression, the overflow) is reached fast: a working guard lets the thread
4790
+ // `join` cleanly with the error flag; a broken one aborts the process (the loud regression signal).
4791
+ #[test]
4792
+ fn jit_deep_recursion_surfaces_error_not_abort() {
4793
+ // `Value` is `!Send` (holds `Rc`), so reduce to a Send bool inside the thread: true iff the
4794
+ // overflow surfaced as the catchable stack-overflow error rather than aborting.
4795
+ let handle = std::thread::Builder::new()
4796
+ .stack_size(1024 * 1024)
4797
+ .spawn(|| {
4798
+ matches!(
4799
+ run_src(
4800
+ "fn dive(n) { if (n <= 0.0) { return 0.0 } return dive(n - 1.0) + 1.0 }\n\
4801
+ dive(50000000.0)",
4802
+ ),
4803
+ Err(ref e) if e.contains("call stack")
4804
+ )
4805
+ })
4806
+ .expect("spawn");
4807
+ let surfaced = handle
4808
+ .join()
4809
+ .expect("thread must not abort — the guard must catch the overflow");
4810
+ assert!(
4811
+ surfaced,
4812
+ "uncaught JIT deep recursion must surface as a RangeError, not abort"
4813
+ );
4814
+ }
4815
+ }