@tishlang/tish-format 1.0.13 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/Cargo.toml +2 -0
  2. package/bin/tish-format +0 -0
  3. package/crates/js_to_tish/src/transform/expr.rs +1 -0
  4. package/crates/tish/Cargo.toml +10 -2
  5. package/crates/tish/build.rs +21 -0
  6. package/crates/tish/src/cli_help.rs +15 -4
  7. package/crates/tish/src/main.rs +93 -21
  8. package/crates/tish/src/repl_completion.rs +0 -1
  9. package/crates/tish/tests/error_source_location.rs +36 -0
  10. package/crates/tish/tests/fixtures/runtime_error_location.tish +5 -0
  11. package/crates/tish/tests/fixtures/trycatch_runtime_errors.tish +15 -0
  12. package/crates/tish/tests/fixtures/tty_capability.tish +9 -0
  13. package/crates/tish/tests/integration_test.rs +402 -91
  14. package/crates/tish/tests/trycatch_runtime_errors.rs +45 -0
  15. package/crates/tish/tests/tty_capability.rs +43 -0
  16. package/crates/tish_ast/src/ast.rs +37 -8
  17. package/crates/tish_builtins/Cargo.toml +2 -0
  18. package/crates/tish_builtins/src/array.rs +375 -13
  19. package/crates/tish_builtins/src/collections.rs +481 -0
  20. package/crates/tish_builtins/src/construct.rs +59 -19
  21. package/crates/tish_builtins/src/date.rs +538 -0
  22. package/crates/tish_builtins/src/globals.rs +86 -6
  23. package/crates/tish_builtins/src/iterator.rs +129 -0
  24. package/crates/tish_builtins/src/lib.rs +5 -0
  25. package/crates/tish_builtins/src/number.rs +96 -0
  26. package/crates/tish_builtins/src/object.rs +2 -2
  27. package/crates/tish_builtins/src/string.rs +19 -20
  28. package/crates/tish_builtins/src/symbol.rs +1 -1
  29. package/crates/tish_builtins/src/typedarrays.rs +298 -0
  30. package/crates/tish_bytecode/src/chunk.rs +69 -1
  31. package/crates/tish_bytecode/src/compiler.rs +933 -89
  32. package/crates/tish_bytecode/src/encoding.rs +2 -0
  33. package/crates/tish_bytecode/src/lib.rs +2 -1
  34. package/crates/tish_bytecode/src/opcode.rs +47 -4
  35. package/crates/tish_bytecode/src/serialize.rs +31 -1
  36. package/crates/tish_compile/Cargo.toml +1 -0
  37. package/crates/tish_compile/src/check.rs +774 -0
  38. package/crates/tish_compile/src/codegen.rs +2334 -349
  39. package/crates/tish_compile/src/infer.rs +1395 -6
  40. package/crates/tish_compile/src/lib.rs +50 -8
  41. package/crates/tish_compile/src/resolve.rs +584 -21
  42. package/crates/tish_compile/src/types.rs +106 -2
  43. package/crates/tish_compile_js/src/codegen.rs +67 -0
  44. package/crates/tish_compile_js/src/tests_jsx.rs +64 -0
  45. package/crates/tish_core/Cargo.toml +7 -1
  46. package/crates/tish_core/src/console_style.rs +11 -1
  47. package/crates/tish_core/src/json.rs +81 -38
  48. package/crates/tish_core/src/lib.rs +3 -0
  49. package/crates/tish_core/src/shape.rs +85 -0
  50. package/crates/tish_core/src/value.rs +679 -25
  51. package/crates/tish_core/src/vmref.rs +13 -8
  52. package/crates/tish_cranelift/src/link.rs +17 -4
  53. package/crates/tish_cranelift_runtime/Cargo.toml +1 -0
  54. package/crates/tish_eval/Cargo.toml +6 -0
  55. package/crates/tish_eval/src/eval.rs +665 -117
  56. package/crates/tish_eval/src/http.rs +4 -1
  57. package/crates/tish_eval/src/natives.rs +165 -13
  58. package/crates/tish_eval/src/value.rs +31 -13
  59. package/crates/tish_eval/src/value_convert.rs +10 -4
  60. package/crates/tish_ffi/Cargo.toml +26 -0
  61. package/crates/tish_ffi/src/lib.rs +518 -0
  62. package/crates/tish_ffi/tests/fixtures/testmod/Cargo.toml +18 -0
  63. package/crates/tish_ffi/tests/fixtures/testmod/src/lib.rs +46 -0
  64. package/crates/tish_ffi/tests/loader.rs +65 -0
  65. package/crates/tish_fmt/Cargo.toml +1 -1
  66. package/crates/tish_fmt/src/lib.rs +61 -5
  67. package/crates/tish_lexer/src/lib.rs +397 -9
  68. package/crates/tish_lexer/src/token.rs +7 -0
  69. package/crates/tish_lint/src/lib.rs +2 -10
  70. package/crates/tish_lsp/src/import_goto.rs +2 -0
  71. package/crates/tish_lsp/src/main.rs +439 -26
  72. package/crates/tish_native/src/build.rs +55 -1
  73. package/crates/tish_opt/src/lib.rs +126 -23
  74. package/crates/tish_parser/src/lib.rs +55 -1
  75. package/crates/tish_parser/src/parser.rs +456 -34
  76. package/crates/tish_pg/src/lib.rs +3 -3
  77. package/crates/tish_resolve/src/lib.rs +99 -59
  78. package/crates/tish_runtime/Cargo.toml +4 -0
  79. package/crates/tish_runtime/src/http.rs +66 -17
  80. package/crates/tish_runtime/src/http_fetch.rs +29 -8
  81. package/crates/tish_runtime/src/http_hyper.rs +25 -2
  82. package/crates/tish_runtime/src/lib.rs +299 -44
  83. package/crates/tish_runtime/src/promise.rs +328 -18
  84. package/crates/tish_runtime/src/timers.rs +13 -7
  85. package/crates/tish_runtime/src/tty.rs +226 -0
  86. package/crates/tish_runtime/src/ws.rs +35 -18
  87. package/crates/tish_runtime/tests/fetch_readable_stream.rs +2 -2
  88. package/crates/tish_ui/src/jsx.rs +10 -0
  89. package/crates/tish_ui/src/runtime/hooks.rs +19 -15
  90. package/crates/tish_ui/src/runtime/mod.rs +15 -12
  91. package/crates/tish_vm/Cargo.toml +14 -1
  92. package/crates/tish_vm/src/jit.rs +1050 -0
  93. package/crates/tish_vm/src/lib.rs +2 -0
  94. package/crates/tish_vm/src/vm.rs +1546 -202
  95. package/crates/tish_vm/tests/concurrent_shared_state.rs +140 -0
  96. package/crates/tish_wasm/src/lib.rs +6 -2
  97. package/crates/tish_wasm_runtime/src/gpu.rs +17 -1
  98. package/crates/tishlang_cargo_bindgen/src/classify.rs +1 -3
  99. package/crates/tishlang_cargo_bindgen/src/lib.rs +2 -2
  100. package/crates/tishlang_cargo_bindgen/src/metadata.rs +1 -1
  101. package/justfile +8 -0
  102. package/package.json +2 -2
  103. package/platform/darwin-arm64/tish-fmt +0 -0
  104. package/platform/darwin-x64/tish-fmt +0 -0
  105. package/platform/linux-arm64/tish-fmt +0 -0
  106. package/platform/linux-x64/tish-fmt +0 -0
  107. package/platform/win32-x64/tish-fmt.exe +0 -0
  108. package/README.md +0 -138
@@ -12,12 +12,49 @@ use tishlang_builtins::array as arr_builtins;
12
12
  use tishlang_builtins::construct as construct_builtin;
13
13
  use tishlang_builtins::globals as globals_builtins;
14
14
  use tishlang_builtins::math as math_builtins;
15
+ use tishlang_builtins::number as num_builtins;
15
16
  use tishlang_builtins::string as str_builtins;
16
17
  use tishlang_bytecode::{u8_to_binop, u8_to_unaryop, Chunk, Constant, Opcode, NO_REST_PARAM};
17
18
  use tishlang_core::{
18
- merge_object_data, object_get, object_has, object_set, NativeFn, ObjectData, ObjectMap, Value,
19
+ merge_object_data, object_get, object_has, object_set, to_int32, to_uint32, NativeFn,
20
+ ObjectData, ObjectMap, PropMap, Value,
19
21
  };
20
22
 
23
+ /// Error string returned by `run_chunk`/`run_framed` to mean "a thrown value is parked in
24
+ /// [`VM_PENDING_THROW`]; keep unwinding toward an enclosing `catch`" (issue #60). The leading
25
+ /// control char makes it unmistakable for a real diagnostic. `Callable::call` returns a bare
26
+ /// `Value`, so the thrown *value* can't ride the `Result`; it travels in this thread-local
27
+ /// instead and is picked up at the next call site (or the top-level boundary).
28
+ const PENDING_THROW_SENTINEL: &str = "\u{1}__tish_pending_throw__";
29
+
30
+ thread_local! {
31
+ static VM_PENDING_THROW: std::cell::RefCell<Option<Value>> =
32
+ const { std::cell::RefCell::new(None) };
33
+ }
34
+
35
+ fn set_pending_throw(v: Value) {
36
+ VM_PENDING_THROW.with(|c| *c.borrow_mut() = Some(v));
37
+ }
38
+ fn take_pending_throw() -> Option<Value> {
39
+ VM_PENDING_THROW.with(|c| c.borrow_mut().take())
40
+ }
41
+ fn pending_throw_is_set() -> bool {
42
+ VM_PENDING_THROW.with(|c| c.borrow().is_some())
43
+ }
44
+
45
+ /// Append the source location of the instruction at `off` to a runtime-error message, e.g.
46
+ /// `Cannot read property 'x' of null (at app.tish:4)` (issue #74). No-ops when the chunk
47
+ /// carries no line table (e.g. deserialized bytecode).
48
+ fn locate_error(chunk: &Chunk, off: usize, msg: &str) -> String {
49
+ match chunk.line_at(off) {
50
+ Some(line) => match &chunk.source {
51
+ Some(src) => format!("{msg} (at {src}:{line})"),
52
+ None => format!("{msg} (at line {line})"),
53
+ },
54
+ None => msg.to_string(),
55
+ }
56
+ }
57
+
21
58
  /// Wrap a closure in the right shared pointer for the current build.
22
59
  /// Under `send-values` that's `Arc<dyn Fn + Send + Sync>`; otherwise it's
23
60
  /// plain `Rc<dyn Fn>`. Call sites can stay ignorant of the distinction.
@@ -27,7 +64,7 @@ fn make_native_fn<F>(f: F) -> NativeFn
27
64
  where
28
65
  F: Fn(&[Value]) -> Value + Send + Sync + 'static,
29
66
  {
30
- Arc::new(f)
67
+ tishlang_core::native_fn(f)
31
68
  }
32
69
 
33
70
  #[cfg(not(feature = "send-values"))]
@@ -36,7 +73,7 @@ fn make_native_fn<F>(f: F) -> NativeFn
36
73
  where
37
74
  F: Fn(&[Value]) -> Value + 'static,
38
75
  {
39
- Rc::new(f)
76
+ tishlang_core::native_fn(f)
40
77
  }
41
78
 
42
79
  // Array / string / object methods have the same shape as `NativeFn`, which
@@ -92,6 +129,8 @@ pub fn all_compiled_capabilities() -> HashSet<String> {
92
129
  s.insert("regex".to_string());
93
130
  #[cfg(feature = "ws")]
94
131
  s.insert("ws".to_string());
132
+ #[cfg(feature = "tty")]
133
+ s.insert("tty".to_string());
95
134
  s
96
135
  }
97
136
 
@@ -103,7 +142,8 @@ pub fn all_compiled_capabilities() -> HashSet<String> {
103
142
  feature = "promise",
104
143
  feature = "timers",
105
144
  feature = "process",
106
- feature = "ws"
145
+ feature = "ws",
146
+ feature = "tty"
107
147
  )),
108
148
  allow(unused_variables)
109
149
  )]
@@ -160,7 +200,7 @@ fn get_builtin_export(enabled: &HashSet<String>, spec: &str, export_name: &str)
160
200
  obj_ref.strings.get(&std::sync::Arc::from("onWorker")).cloned()
161
201
  {
162
202
  let args_for_init = [Value::Number(0.0)];
163
- on_worker(&args_for_init)
203
+ on_worker.call(&args_for_init)
164
204
  } else if let Some(h) =
165
205
  obj_ref.strings.get(&std::sync::Arc::from("handler")).cloned()
166
206
  {
@@ -172,7 +212,7 @@ fn get_builtin_export(enabled: &HashSet<String>, spec: &str, export_name: &str)
172
212
  _ => Value::Null,
173
213
  };
174
214
  if let Value::Function(f) = handler_value {
175
- tishlang_runtime::http_serve(args, move |req_args| f(req_args))
215
+ tishlang_runtime::http_serve(args, move |req_args| f.call(req_args))
176
216
  } else {
177
217
  Value::Null
178
218
  }
@@ -285,6 +325,27 @@ fn get_builtin_export(enabled: &HashSet<String>, spec: &str, export_name: &str)
285
325
  _ => None,
286
326
  };
287
327
  }
328
+ #[cfg(feature = "tty")]
329
+ if spec == "tish:tty" && cap_allows(enabled, "tty") {
330
+ return match export_name {
331
+ "size" => Some(Value::native(|args: &[Value]| tishlang_runtime::tty_size(args))),
332
+ "isTTY" => Some(Value::native(|args: &[Value]| tishlang_runtime::tty_is_tty(args))),
333
+ "setRawMode" => Some(Value::native(|args: &[Value]| {
334
+ tishlang_runtime::tty_set_raw_mode(args)
335
+ })),
336
+ "enterAltScreen" => Some(Value::native(|args: &[Value]| {
337
+ tishlang_runtime::tty_enter_alt_screen(args)
338
+ })),
339
+ "leaveAltScreen" => Some(Value::native(|args: &[Value]| {
340
+ tishlang_runtime::tty_leave_alt_screen(args)
341
+ })),
342
+ "read" => Some(Value::native(|args: &[Value]| tishlang_runtime::tty_read(args))),
343
+ "readLine" => Some(Value::native(|args: &[Value]| {
344
+ tishlang_runtime::tty_read_line(args)
345
+ })),
346
+ _ => None,
347
+ };
348
+ }
288
349
  None
289
350
  }
290
351
 
@@ -495,6 +556,29 @@ fn init_globals(enabled: &HashSet<String>) -> ObjectMap {
495
556
  Value::Number(sum_sq.sqrt())
496
557
  }),
497
558
  );
559
+ // Hyperbolic, inverse-hyperbolic, cbrt and base-2/10 logs. Like the trig block above
560
+ // these aren't in `math_builtins`, and on the wasm/native VM there is no host `Math`
561
+ // to fall through to, so they previously returned `undefined` (issue #61).
562
+ macro_rules! math_unary {
563
+ ($name:literal, $method:ident) => {
564
+ math.insert(
565
+ $name.into(),
566
+ Value::native(|args: &[Value]| {
567
+ let n = args.first().and_then(|v| v.as_number()).unwrap_or(f64::NAN);
568
+ Value::Number(n.$method())
569
+ }),
570
+ );
571
+ };
572
+ }
573
+ math_unary!("sinh", sinh);
574
+ math_unary!("cosh", cosh);
575
+ math_unary!("tanh", tanh);
576
+ math_unary!("asinh", asinh);
577
+ math_unary!("acosh", acosh);
578
+ math_unary!("atanh", atanh);
579
+ math_unary!("cbrt", cbrt);
580
+ math_unary!("log2", log2);
581
+ math_unary!("log10", log10);
498
582
  math.insert("PI".into(), Value::Number(std::f64::consts::PI));
499
583
  math.insert("E".into(), Value::Number(std::f64::consts::E));
500
584
  g.insert("Math".into(), value_object_from_map(math));
@@ -567,28 +651,44 @@ fn init_globals(enabled: &HashSet<String>) -> ObjectMap {
567
651
  tishlang_builtins::symbol::symbol_object(),
568
652
  );
569
653
 
570
- // Date - at minimum Date.now() for timing
571
- let mut date = ObjectMap::default();
572
- date.insert(
573
- "now".into(),
574
- Value::native(|_args: &[Value]| {
575
- let ms = std::time::SystemTime::now()
576
- .duration_since(std::time::UNIX_EPOCH)
577
- .unwrap_or_default()
578
- .as_millis() as f64;
579
- Value::Number(ms)
580
- }),
654
+ // Date - full constructor (new Date(...)) plus statics now()/parse()/UTC().
655
+ g.insert(
656
+ "Date".into(),
657
+ tishlang_builtins::date::date_constructor_value(),
581
658
  );
582
- g.insert("Date".into(), value_object_from_map(date));
583
-
584
659
  g.insert(
585
- "Uint8Array".into(),
586
- construct_builtin::uint8_array_constructor_value(),
660
+ "Set".into(),
661
+ tishlang_builtins::collections::set_constructor_value(),
587
662
  );
663
+ g.insert(
664
+ "Map".into(),
665
+ tishlang_builtins::collections::map_constructor_value(),
666
+ );
667
+
668
+ for (name, ctor) in [
669
+ (
670
+ "Float64Array",
671
+ tishlang_builtins::typedarrays::float64_array_constructor_value as fn() -> Value,
672
+ ),
673
+ ("Float32Array", tishlang_builtins::typedarrays::float32_array_constructor_value),
674
+ ("Int8Array", tishlang_builtins::typedarrays::int8_array_constructor_value),
675
+ ("Uint8Array", tishlang_builtins::typedarrays::uint8_array_constructor_value),
676
+ ("Uint8ClampedArray", tishlang_builtins::typedarrays::uint8_clamped_array_constructor_value),
677
+ ("Int16Array", tishlang_builtins::typedarrays::int16_array_constructor_value),
678
+ ("Uint16Array", tishlang_builtins::typedarrays::uint16_array_constructor_value),
679
+ ("Int32Array", tishlang_builtins::typedarrays::int32_array_constructor_value),
680
+ ("Uint32Array", tishlang_builtins::typedarrays::uint32_array_constructor_value),
681
+ ] {
682
+ g.insert(name.into(), ctor());
683
+ }
588
684
  g.insert(
589
685
  "AudioContext".into(),
590
686
  construct_builtin::audio_context_constructor_value(),
591
687
  );
688
+ // Error constructors (issue #60): `new Error(msg)` / `Error(msg)` → `{ name, message }`.
689
+ for name in ["Error", "TypeError", "RangeError", "SyntaxError"] {
690
+ g.insert(name.into(), construct_builtin::error_constructor_value(name));
691
+ }
592
692
 
593
693
  // Object methods - delegate to tishlang_builtins::globals
594
694
  let mut object_methods = ObjectMap::default();
@@ -614,12 +714,17 @@ fn init_globals(enabled: &HashSet<String>) -> ObjectMap {
614
714
  );
615
715
  g.insert("Object".into(), value_object_from_map(object_methods));
616
716
 
617
- // Array.isArray
717
+ // Array.isArray + the `Array(n)` / `new Array(n)` constructor (issue #72). `__call`
718
+ // serves both forms — `construct()` falls back to `__call` when there's no `__construct`.
618
719
  let mut array_static = ObjectMap::default();
619
720
  array_static.insert(
620
721
  "isArray".into(),
621
722
  Value::native(|args: &[Value]| globals_builtins::array_is_array(args)),
622
723
  );
724
+ array_static.insert(
725
+ Arc::from("__call"),
726
+ Value::native(|args: &[Value]| construct_builtin::array_construct(args)),
727
+ );
623
728
  g.insert("Array".into(), value_object_from_map(array_static));
624
729
 
625
730
  // String(value) as callable + String.fromCharCode
@@ -632,6 +737,14 @@ fn init_globals(enabled: &HashSet<String>) -> ObjectMap {
632
737
  string_static.insert(Arc::from("__call"), string_convert_fn);
633
738
  g.insert("String".into(), value_object_from_map(string_static));
634
739
 
740
+ // Number(value) coercion as a callable global (issue #36).
741
+ let mut number_static = ObjectMap::default();
742
+ number_static.insert(
743
+ Arc::from("__call"),
744
+ Value::native(|args: &[Value]| globals_builtins::number_convert(args)),
745
+ );
746
+ g.insert("Number".into(), value_object_from_map(number_static));
747
+
635
748
  // JSX / Lattish: stubs for bytecode VM when no DOM (e.g. console). Override via set_global in browser.
636
749
  g.insert("h".into(), Value::native(|_args: &[Value]| Value::Null));
637
750
  g.insert(
@@ -755,7 +868,7 @@ fn init_globals(enabled: &HashSet<String>) -> ObjectMap {
755
868
  obj_ref.strings.get(&std::sync::Arc::from("onWorker")).cloned()
756
869
  {
757
870
  let args_for_init = [Value::Number(0.0)];
758
- on_worker(&args_for_init)
871
+ on_worker.call(&args_for_init)
759
872
  } else if let Some(h) =
760
873
  obj_ref.strings.get(&std::sync::Arc::from("handler")).cloned()
761
874
  {
@@ -767,7 +880,7 @@ fn init_globals(enabled: &HashSet<String>) -> ObjectMap {
767
880
  _ => Value::Null,
768
881
  };
769
882
  if let Value::Function(f) = handler_value {
770
- tishlang_runtime::http_serve(args, move |req_args| f(req_args))
883
+ tishlang_runtime::http_serve(args, move |req_args| f.call(req_args))
771
884
  } else {
772
885
  Value::Null
773
886
  }
@@ -780,12 +893,32 @@ fn init_globals(enabled: &HashSet<String>) -> ObjectMap {
780
893
  g.insert("Promise".into(), tishlang_runtime::promise_object());
781
894
  }
782
895
 
896
+ // `RegExp(pattern, flags)` constructor. A language feature (not a sandboxed capability),
897
+ // so it's available whenever the `regex` feature is compiled — matching the interpreter.
898
+ // Routes to the same `regexp_new` the rust backend uses (full-backend-parity-plan.md).
899
+ #[cfg(feature = "regex")]
900
+ g.insert(
901
+ "RegExp".into(),
902
+ Value::native(|args: &[Value]| tishlang_runtime::regexp_new(args)),
903
+ );
904
+
783
905
  g
784
906
  }
785
907
 
786
908
  /// Shared scope for closure capture (parent frame's locals).
787
909
  type ScopeMap = VmRef<ObjectMap>;
788
910
 
911
+ /// The captured lexical chain for closures. Shared immutably (never mutated after a closure is
912
+ /// built — `run_chunk` only reads it: `.len()`/`.iter()`/`.is_empty()`), so it lives behind an
913
+ /// `Rc`/`Arc` instead of a `Vec` that would be deep-cloned on every call. This makes the per-call
914
+ /// `enclosing` propagation a single refcount bump rather than a `Vec` allocation + N element clones
915
+ /// — a direct cut to function-call overhead. `Arc` under `send-values` (closures must be `Send`),
916
+ /// `Rc` otherwise.
917
+ #[cfg(feature = "send-values")]
918
+ type SharedChain = std::sync::Arc<Vec<ScopeMap>>;
919
+ #[cfg(not(feature = "send-values"))]
920
+ type SharedChain = std::rc::Rc<Vec<ScopeMap>>;
921
+
789
922
  /// Options for the convenience [`run_with_options`] helper (one-shot VM run from the CLI).
790
923
  #[derive(Clone, Debug, Default)]
791
924
  pub struct VmRunOptions {
@@ -799,8 +932,16 @@ pub struct VmRunOptions {
799
932
  pub struct Vm {
800
933
  stack: Vec<Value>,
801
934
  scope: ObjectMap,
802
- /// Enclosing scope for closures (captured parent frame locals).
803
- enclosing: Option<ScopeMap>,
935
+ /// Captured enclosing scopes for closures, **innermost first**. A free variable resolves by
936
+ /// walking `local_scope` → each entry here in order → `scope` → `globals`. This is the full
937
+ /// lexical chain: a closure captures its defining frame's scope *plus that frame's own
938
+ /// enclosing chain*, so a function nested N levels deep still sees every ancestor's locals
939
+ /// (was a fixed `enclosing` + `enclosing2`, which silently lost captures >2 levels deep — see
940
+ /// `nested_complex`). Per-iteration `let`: a fresh frozen overlay of the loop var(s) is
941
+ /// prepended as the innermost entry, shadowing the still-shared frame scope that follows it,
942
+ /// so the loop var is frozen per-iteration while everything else stays live. Empty at top level.
943
+ /// Shared via `SharedChain` (Rc/Arc) so per-call propagation is a refcount bump, not a Vec clone.
944
+ enclosing: SharedChain,
804
945
  globals: VmRef<ObjectMap>,
805
946
  /// Capabilities for `LoadNativeExport` and globals such as `process` / `serve`.
806
947
  capabilities: Arc<HashSet<String>>,
@@ -811,6 +952,144 @@ pub struct Vm {
811
952
  native_modules: VmRef<HashMap<String, VmRef<ObjectMap>>>,
812
953
  }
813
954
 
955
+ /// A bytecode-VM closure: a compiled chunk plus its captured lexical chain and shared VM state.
956
+ /// Implements [`tishlang_core::Callable`] so it lives in `Value::Function` like any callable, but
957
+ /// the `Call` opcode can `as_any`-downcast to it to run the call on the VM's explicit frame stack
958
+ /// (the frame-VM, task #39) instead of recursively re-entering `run_chunk`. `call()` is the
959
+ /// fallback path (builtin callbacks, and any not-yet-framed call) — byte-identical to the former
960
+ /// inline `Value::native` closure, so building these instead of raw closures changes nothing on
961
+ /// its own; the behavioural win comes when `Call` starts using the downcast + frame stack.
962
+ /// Try the array-mode JIT for `nf` (`array_param_mask != 0`). Splits `args` into numeric `f64`s and
963
+ /// flat [`crate::jit::ArrayHandle`]s — extracting all-numeric `Value::Array`s into scratch `Vec<f64>`s
964
+ /// that outlive the call. Returns `None` (caller falls back to the interpreter, so behaviour is always
965
+ /// correct) when an array arg is not an all-numeric `Value::Array` (covers `NumberArray`, whose
966
+ /// NaN-hole semantics differ), a numeric arg isn't a `Number`, or the JIT signals an OOB deopt.
967
+ #[cfg(not(target_arch = "wasm32"))]
968
+ fn try_call_array_jit(
969
+ nf: &crate::jit::NumericFn,
970
+ args: &[Value],
971
+ arity: usize,
972
+ mask: u8,
973
+ ) -> Option<Value> {
974
+ let mut numeric: Vec<f64> = Vec::with_capacity(arity);
975
+ // `scratch` OWNS the extracted f64 data; handles point into it. Build handles only AFTER scratch is
976
+ // fully populated so its backing buffers never reallocate out from under a live pointer.
977
+ let mut scratch: Vec<Vec<f64>> = Vec::new();
978
+ #[allow(clippy::needless_range_loop)] // `i` drives bit-mask math (`mask >> i`), not just indexing
979
+ for i in 0..arity {
980
+ if (mask >> i) & 1 == 1 {
981
+ match &args[i] {
982
+ Value::Array(a) => {
983
+ let b = a.borrow();
984
+ let mut buf: Vec<f64> = Vec::with_capacity(b.len());
985
+ for el in b.iter() {
986
+ match el {
987
+ Value::Number(n) => buf.push(*n),
988
+ _ => return None, // non-numeric element → interpreter
989
+ }
990
+ }
991
+ scratch.push(buf);
992
+ }
993
+ _ => return None, // NumberArray / non-array → interpreter
994
+ }
995
+ } else {
996
+ match &args[i] {
997
+ Value::Number(n) => numeric.push(*n),
998
+ _ => return None,
999
+ }
1000
+ }
1001
+ }
1002
+ let handles: Vec<crate::jit::ArrayHandle> = scratch
1003
+ .iter()
1004
+ .map(|buf| crate::jit::ArrayHandle {
1005
+ ptr: buf.as_ptr(),
1006
+ len: buf.len(),
1007
+ })
1008
+ .collect();
1009
+ let (res, deopt) = nf.call_arrays(&numeric, &handles);
1010
+ if deopt {
1011
+ return None; // OOB access → re-run interpreter (OOB reads coerce as Value::Null)
1012
+ }
1013
+ Some(Value::Number(res))
1014
+ }
1015
+
1016
+ struct VmClosure {
1017
+ chunk: Arc<Chunk>,
1018
+ /// Whether this closure can run on the frame stack — computed ONCE at creation (eligibility is an
1019
+ /// O(chunk) bytecode scan; doing it per call regressed perf). `true` iff the chunk is frame-eligible
1020
+ /// and there is no numeric JIT for it.
1021
+ frameable: bool,
1022
+ #[cfg(not(target_arch = "wasm32"))]
1023
+ jit_fn: Option<crate::jit::NumericFn>,
1024
+ enclosing: SharedChain,
1025
+ globals: VmRef<ObjectMap>,
1026
+ capabilities: Arc<HashSet<String>>,
1027
+ native_modules: VmRef<HashMap<String, VmRef<ObjectMap>>>,
1028
+ }
1029
+
1030
+ impl tishlang_core::Callable for VmClosure {
1031
+ fn call(&self, args: &[Value]) -> Value {
1032
+ #[cfg(not(target_arch = "wasm32"))]
1033
+ {
1034
+ if let Some(nf) = self.jit_fn {
1035
+ let arity = nf.arity();
1036
+ if args.len() >= arity {
1037
+ let mask = nf.array_param_mask();
1038
+ if mask == 0 {
1039
+ // Pure-numeric register-f64 path.
1040
+ let mut nums = [0f64; 8];
1041
+ let mut all_numbers = true;
1042
+ for i in 0..arity {
1043
+ if let Value::Number(n) = &args[i] {
1044
+ nums[i] = *n;
1045
+ } else {
1046
+ all_numbers = false;
1047
+ break;
1048
+ }
1049
+ }
1050
+ if all_numbers {
1051
+ let res = nf.call(&nums[..arity]);
1052
+ return if nf.result_is_bool() {
1053
+ Value::Bool(res != 0.0)
1054
+ } else {
1055
+ Value::Number(res)
1056
+ };
1057
+ }
1058
+ } else if let Some(v) = try_call_array_jit(&nf, args, arity, mask) {
1059
+ // Array-mode path: succeeded (all-numeric arrays, in-bounds). On any bail
1060
+ // (non-numeric element, NumberArray, OOB deopt) this returns None and we fall
1061
+ // through to the interpreter — so behaviour is always correct.
1062
+ return v;
1063
+ }
1064
+ }
1065
+ }
1066
+ }
1067
+ let mut vm = Vm {
1068
+ stack: Vec::new(),
1069
+ scope: ObjectMap::default(),
1070
+ enclosing: self.enclosing.clone(),
1071
+ globals: self.globals.clone(),
1072
+ capabilities: Arc::clone(&self.capabilities),
1073
+ native_modules: self.native_modules.clone(),
1074
+ };
1075
+ #[cfg(not(target_arch = "wasm32"))]
1076
+ {
1077
+ stacker::maybe_grow(128 * 1024, 2 * 1024 * 1024, || {
1078
+ vm.run_chunk(self.chunk.as_ref(), &self.chunk.nested, args, false)
1079
+ .unwrap_or(Value::Null)
1080
+ })
1081
+ }
1082
+ #[cfg(target_arch = "wasm32")]
1083
+ {
1084
+ vm.run_chunk(&self.chunk, &self.chunk.nested, args, false)
1085
+ .unwrap_or(Value::Null)
1086
+ }
1087
+ }
1088
+ fn as_any(&self) -> &dyn std::any::Any {
1089
+ self
1090
+ }
1091
+ }
1092
+
814
1093
  impl Vm {
815
1094
  /// VM with every capability that exists in this `tishlang_vm` build (embedders, tests, `run()`).
816
1095
  pub fn new() -> Self {
@@ -826,7 +1105,7 @@ impl Vm {
826
1105
  Self {
827
1106
  stack: Vec::new(),
828
1107
  scope: ObjectMap::default(),
829
- enclosing: None,
1108
+ enclosing: SharedChain::new(Vec::new()),
830
1109
  globals: VmRef::new(init_globals(capabilities.as_ref())),
831
1110
  capabilities,
832
1111
  native_modules: VmRef::new(HashMap::new()),
@@ -895,7 +1174,331 @@ impl Vm {
895
1174
 
896
1175
  /// Run a chunk using this VM's capability set. `repl_mode` persists top-level `let` across REPL lines.
897
1176
  pub fn run_with_options(&mut self, chunk: &Chunk, repl_mode: bool) -> Result<Value, String> {
898
- self.run_chunk(chunk, &chunk.nested, &[], repl_mode)
1177
+ let result = self.run_chunk(chunk, &chunk.nested, &[], repl_mode);
1178
+ // A throw that escaped every `catch` reaches here as the pending-throw sentinel; turn the
1179
+ // parked value into the conventional uncaught-error message (issue #60).
1180
+ if let Err(e) = &result {
1181
+ if e == PENDING_THROW_SENTINEL {
1182
+ let v = take_pending_throw().unwrap_or(Value::Null);
1183
+ return Err(format!("Uncaught {}", v.to_display_string()));
1184
+ }
1185
+ }
1186
+ result
1187
+ }
1188
+
1189
+ /// Whether the experimental frame-VM path is on (`TISH_FRAME_VM=1`). Flag-off (default) is
1190
+ /// byte-identical to the recursive `run_chunk` model — every `Value::Function` call goes through
1191
+ /// `VmClosure::call` exactly as before.
1192
+ #[inline]
1193
+ fn frame_vm_enabled() -> bool {
1194
+ // Read the env var ONCE and cache it. This is checked on the hot path (every Call opcode +
1195
+ // every closure creation), so a per-call `std::env::var` (a lock + String alloc) is a severe
1196
+ // regression to the DEFAULT path — caching makes the flag-off check a single atomic load.
1197
+ use std::sync::OnceLock;
1198
+ static ENABLED: OnceLock<bool> = OnceLock::new();
1199
+ *ENABLED.get_or_init(|| std::env::var("TISH_FRAME_VM").map(|v| v == "1").unwrap_or(false))
1200
+ }
1201
+
1202
+ /// A `VmClosure` runs on the frame stack iff its chunk is frame-eligible AND it has no numeric
1203
+ /// JIT (jit'd functions stay on the faster native path via `VmClosure::call`; the frame loop's
1204
+ /// niche is non-jit'd call-heavy / mutually-recursive functions + wasi where there is no JIT).
1205
+ fn vmclosure_frameable(vc: &VmClosure) -> bool {
1206
+ vc.frameable
1207
+ }
1208
+
1209
+ /// A chunk is frame-eligible iff slot-based and every opcode is one `run_framed` handles.
1210
+ /// `LoadConst` of a nested `Closure` is excluded (closure creation needs the full `run_chunk`).
1211
+ fn chunk_frame_eligible(chunk: &Chunk) -> bool {
1212
+ if !chunk.slot_based {
1213
+ return false;
1214
+ }
1215
+ let code = &chunk.code;
1216
+ let mut ip = 0usize;
1217
+ while ip < code.len() {
1218
+ let op = match Opcode::from_u8(code[ip]) {
1219
+ Some(o) => o,
1220
+ None => return false,
1221
+ };
1222
+ match op {
1223
+ Opcode::Nop
1224
+ | Opcode::LoadLocal
1225
+ | Opcode::StoreLocal
1226
+ | Opcode::LoadVar
1227
+ | Opcode::BinOp
1228
+ | Opcode::Jump
1229
+ | Opcode::JumpIfFalse
1230
+ | Opcode::JumpBack
1231
+ | Opcode::Pop
1232
+ | Opcode::Call
1233
+ | Opcode::SelfCall
1234
+ | Opcode::Return => {}
1235
+ Opcode::LoadConst => {
1236
+ let idx = (((*code.get(ip + 1).unwrap_or(&0)) as usize) << 8)
1237
+ | ((*code.get(ip + 2).unwrap_or(&0)) as usize);
1238
+ if matches!(chunk.constants.get(idx), Some(Constant::Closure(_))) {
1239
+ return false;
1240
+ }
1241
+ }
1242
+ _ => return false,
1243
+ }
1244
+ ip += match op.instruction_size(code, ip) {
1245
+ Some(s) => s,
1246
+ None => return false,
1247
+ };
1248
+ }
1249
+ true
1250
+ }
1251
+
1252
+ /// Iterative frame-stack execution of a frame-eligible `VmClosure` (the frame-VM, flag-on).
1253
+ /// Returns `None` if the entry chunk is ineligible (caller falls back to `VmClosure::call`).
1254
+ /// Calls + recursion run on the heap `frames` stack — no per-call `Vm`, no recursive `run_chunk`
1255
+ /// re-entry, so deep + mutual recursion can't overflow and it works on wasi (no JIT there).
1256
+ fn run_framed(&mut self, top: &VmClosure, args: &[Value]) -> Option<Result<Value, String>> {
1257
+ if !Self::vmclosure_frameable(top) {
1258
+ return None;
1259
+ }
1260
+ let mut cur: Arc<Chunk> = top.chunk.clone();
1261
+ let mut enclosing: SharedChain = top.enclosing.clone();
1262
+ let mut ip: usize = 0;
1263
+ let mut stack_base: usize = self.stack.len();
1264
+ // Slot-region pooling: ALL frames' locals share one `slots` Vec; each frame occupies
1265
+ // `slots[slot_base .. slot_base + num_slots]`. A call does `resize` (amortized, no per-call
1266
+ // heap alloc — unlike `run_chunk` which `vec!`s a fresh `slot_locals` every call); a return
1267
+ // does `truncate`. This is what makes the frame loop cheaper than the recursive path.
1268
+ let mut slots: Vec<Value> = Vec::new();
1269
+ let mut slot_base: usize = 0;
1270
+ slots.resize(cur.num_slots as usize, Value::Null);
1271
+ for i in 0..(cur.param_count as usize) {
1272
+ if let Some(v) = args.get(i) {
1273
+ if let Some(d) = slots.get_mut(slot_base + i) {
1274
+ *d = v.clone();
1275
+ }
1276
+ }
1277
+ }
1278
+ // Suspended callers: (chunk, return ip, caller slot_base, caller stack_base, enclosing).
1279
+ let mut frames: Vec<(Arc<Chunk>, usize, usize, usize, SharedChain)> = Vec::new();
1280
+
1281
+ macro_rules! ferr {
1282
+ ($($t:tt)*) => {
1283
+ return Some(Err(format!($($t)*)))
1284
+ };
1285
+ }
1286
+ macro_rules! fpop {
1287
+ () => {
1288
+ match self.stack.pop() {
1289
+ Some(v) => v,
1290
+ None => ferr!("Stack underflow in run_framed"),
1291
+ }
1292
+ };
1293
+ }
1294
+
1295
+ // SAFETY: `code` aliases the current frame's chunk bytecode. The chunk is kept alive by `cur`
1296
+ // (and suspended-frame chunks by `frames`), so the slice stays valid for as long as we read
1297
+ // it; it is re-derived via `rebind_code!()` after every frame switch (Call/Return/end).
1298
+ // Laundering the borrow lets the hot opcode path index `code[ip]` directly with no per-opcode
1299
+ // Arc deref — matching run_chunk (the per-opcode Arc deref was a measured ~10% shallow-call regression).
1300
+ let mut code: &[u8] = unsafe { &*(cur.code.as_slice() as *const [u8]) };
1301
+
1302
+ loop {
1303
+ if ip >= code.len() {
1304
+ self.stack.truncate(stack_base);
1305
+ slots.truncate(slot_base);
1306
+ match frames.pop() {
1307
+ Some((c, rip, sbase, sb, enc)) => {
1308
+ cur = c;
1309
+ ip = rip;
1310
+ slot_base = sbase;
1311
+ stack_base = sb;
1312
+ enclosing = enc;
1313
+ code = unsafe { &*(cur.code.as_slice() as *const [u8]) };
1314
+ self.stack.push(Value::Null);
1315
+ continue;
1316
+ }
1317
+ None => return Some(Ok(Value::Null)),
1318
+ }
1319
+ }
1320
+ let op = match Opcode::from_u8(code[ip]) {
1321
+ Some(o) => o,
1322
+ None => ferr!("Bad opcode {} in run_framed", code[ip]),
1323
+ };
1324
+ ip += 1;
1325
+ match op {
1326
+ Opcode::Nop => {}
1327
+ Opcode::LoadLocal => {
1328
+ let slot = Self::read_u16(code, &mut ip) as usize;
1329
+ match slots.get(slot_base + slot) {
1330
+ Some(v) => self.stack.push(v.clone()),
1331
+ None => ferr!("Local slot out of bounds: {}", slot),
1332
+ }
1333
+ }
1334
+ Opcode::StoreLocal => {
1335
+ let slot = Self::read_u16(code, &mut ip) as usize;
1336
+ let v = fpop!();
1337
+ match slots.get_mut(slot_base + slot) {
1338
+ Some(d) => *d = v,
1339
+ None => ferr!("Local slot out of bounds: {}", slot),
1340
+ }
1341
+ }
1342
+ Opcode::LoadConst => {
1343
+ let idx = Self::read_u16(code, &mut ip) as usize;
1344
+ let v = match cur.constants.get(idx) {
1345
+ Some(Constant::Number(n)) => Value::Number(*n),
1346
+ Some(Constant::String(s)) => Value::String(tishlang_core::ArcStr::from(s.as_ref())),
1347
+ Some(Constant::Bool(b)) => Value::Bool(*b),
1348
+ Some(Constant::Null) => Value::Null,
1349
+ _ => ferr!("Ineligible constant {} in run_framed", idx),
1350
+ };
1351
+ self.stack.push(v);
1352
+ }
1353
+ Opcode::LoadVar => {
1354
+ let idx = Self::read_u16(code, &mut ip) as usize;
1355
+ let name = match cur.names.get(idx) {
1356
+ Some(n) => n.clone(),
1357
+ None => ferr!("Name index out of bounds: {}", idx),
1358
+ };
1359
+ let v = enclosing
1360
+ .iter()
1361
+ .find_map(|e| e.borrow().get(name.as_ref()).cloned())
1362
+ .or_else(|| self.scope.get(name.as_ref()).cloned())
1363
+ .or_else(|| self.globals.borrow().get(name.as_ref()).cloned());
1364
+ match v {
1365
+ Some(v) => self.stack.push(v),
1366
+ None => ferr!("Undefined variable: {}", name),
1367
+ }
1368
+ }
1369
+ Opcode::BinOp => {
1370
+ let op_u8 = Self::read_u16(code, &mut ip) as u8;
1371
+ let r = fpop!();
1372
+ let l = fpop!();
1373
+ let bop = match u8_to_binop(op_u8) {
1374
+ Some(b) => b,
1375
+ None => ferr!("Unknown binop: {}", op_u8),
1376
+ };
1377
+ match eval_binop(bop, &l, &r) {
1378
+ Ok(res) => self.stack.push(res),
1379
+ Err(e) => return Some(Err(e)),
1380
+ }
1381
+ }
1382
+ Opcode::Jump => {
1383
+ let offset = Self::read_i16(code, &mut ip) as isize;
1384
+ ip = (ip as isize + offset).max(0) as usize;
1385
+ }
1386
+ Opcode::JumpIfFalse => {
1387
+ let offset = Self::read_i16(code, &mut ip) as isize;
1388
+ let v = fpop!();
1389
+ if !v.is_truthy() {
1390
+ ip = (ip as isize + offset).max(0) as usize;
1391
+ }
1392
+ }
1393
+ Opcode::JumpBack => {
1394
+ let dist = Self::read_u16(code, &mut ip) as usize;
1395
+ ip = ip.saturating_sub(dist);
1396
+ }
1397
+ Opcode::Pop => {
1398
+ let _ = fpop!();
1399
+ }
1400
+ Opcode::SelfCall => {
1401
+ let argc = Self::read_u16(code, &mut ip) as usize;
1402
+ let mut call_args = Vec::with_capacity(argc);
1403
+ for _ in 0..argc {
1404
+ call_args.push(fpop!());
1405
+ }
1406
+ call_args.reverse();
1407
+ frames.push((cur.clone(), ip, slot_base, stack_base, enclosing.clone()));
1408
+ let new_base = slots.len();
1409
+ slots.resize(new_base + cur.num_slots as usize, Value::Null);
1410
+ slot_base = new_base;
1411
+ ip = 0;
1412
+ stack_base = self.stack.len();
1413
+ for i in 0..(cur.param_count as usize) {
1414
+ if let Some(v) = call_args.get(i) {
1415
+ if let Some(d) = slots.get_mut(slot_base + i) {
1416
+ *d = v.clone();
1417
+ }
1418
+ }
1419
+ }
1420
+ }
1421
+ Opcode::Call => {
1422
+ let argc = Self::read_u16(code, &mut ip) as usize;
1423
+ let mut call_args = Vec::with_capacity(argc);
1424
+ for _ in 0..argc {
1425
+ call_args.push(fpop!());
1426
+ }
1427
+ call_args.reverse();
1428
+ let callee = fpop!();
1429
+ match &callee {
1430
+ Value::Function(f) => {
1431
+ let framed = f
1432
+ .as_any()
1433
+ .downcast_ref::<VmClosure>()
1434
+ .filter(|vc| Self::vmclosure_frameable(vc));
1435
+ if let Some(vc) = framed {
1436
+ let next_chunk = vc.chunk.clone();
1437
+ let next_enc = vc.enclosing.clone();
1438
+ // Move (not clone) the caller's chunk+chain into the frame; the Arc
1439
+ // refcounts are unchanged (the chunk heap data doesn't move, so the
1440
+ // laundered `code` ptr stays valid until rebind below). Halves the
1441
+ // per-call Arc traffic vs cloning for the push.
1442
+ frames.push((cur, ip, slot_base, stack_base, enclosing));
1443
+ cur = next_chunk;
1444
+ enclosing = next_enc;
1445
+ code = unsafe { &*(cur.code.as_slice() as *const [u8]) };
1446
+ let new_base = slots.len();
1447
+ slots.resize(new_base + cur.num_slots as usize, Value::Null);
1448
+ slot_base = new_base;
1449
+ ip = 0;
1450
+ stack_base = self.stack.len();
1451
+ for i in 0..(cur.param_count as usize) {
1452
+ if let Some(v) = call_args.get(i) {
1453
+ if let Some(d) = slots.get_mut(slot_base + i) {
1454
+ *d = v.clone();
1455
+ }
1456
+ }
1457
+ }
1458
+ } else {
1459
+ let r = f.call(&call_args);
1460
+ // A throw escaping the callee can't be caught here (frameable
1461
+ // chunks have no `try`); bubble it to an enclosing frame (#60).
1462
+ if pending_throw_is_set() {
1463
+ return Some(Err(PENDING_THROW_SENTINEL.to_string()));
1464
+ }
1465
+ self.stack.push(r);
1466
+ }
1467
+ }
1468
+ Value::Object(o) => {
1469
+ let cf = match o.borrow().strings.get("__call") {
1470
+ Some(Value::Function(cf)) => cf.clone(),
1471
+ _ => ferr!("Call of non-function: {}", callee.type_name()),
1472
+ };
1473
+ let r = cf.call(&call_args);
1474
+ if pending_throw_is_set() {
1475
+ return Some(Err(PENDING_THROW_SENTINEL.to_string()));
1476
+ }
1477
+ self.stack.push(r);
1478
+ }
1479
+ _ => ferr!("Call of non-function: {}", callee.type_name()),
1480
+ }
1481
+ }
1482
+ Opcode::Return => {
1483
+ let result = self.stack.pop().unwrap_or(Value::Null);
1484
+ self.stack.truncate(stack_base);
1485
+ slots.truncate(slot_base);
1486
+ match frames.pop() {
1487
+ Some((c, rip, sbase, sb, enc)) => {
1488
+ cur = c;
1489
+ ip = rip;
1490
+ slot_base = sbase;
1491
+ stack_base = sb;
1492
+ enclosing = enc;
1493
+ code = unsafe { &*(cur.code.as_slice() as *const [u8]) };
1494
+ self.stack.push(result);
1495
+ }
1496
+ None => return Some(Ok(result)),
1497
+ }
1498
+ }
1499
+ other => ferr!("Unhandled opcode {:?} in run_framed", other),
1500
+ }
1501
+ }
899
1502
  }
900
1503
 
901
1504
  fn run_chunk(
@@ -910,9 +1513,37 @@ impl Vm {
910
1513
  let names = &chunk.names;
911
1514
 
912
1515
  let mut ip = 0;
913
- let local_scope: ScopeMap = VmRef::new(ObjectMap::default());
914
- {
915
- let mut ls = local_scope.borrow_mut();
1516
+ // Lazily allocated name-keyed scope. Slot-based chunks never WRITE it (params + body locals
1517
+ // live in `slot_locals`; `StoreVar` checks-then-falls-through to globals; a slot-based chunk
1518
+ // has no captured locals by construction), so on the hot slot-based call path we skip the
1519
+ // `VmRef::new(Arc<Mutex<HashMap>>)` box entirely. Non-slot chunks need it eagerly for params.
1520
+ // `ls_get_or_init!()` lazily creates it on the first write/capture; reads treat `None` as empty.
1521
+ let mut local_scope: Option<ScopeMap> = if chunk.slot_based {
1522
+ None
1523
+ } else {
1524
+ Some(VmRef::new(ObjectMap::default()))
1525
+ };
1526
+ macro_rules! ls_get_or_init {
1527
+ () => {{
1528
+ local_scope.get_or_insert_with(|| VmRef::new(ObjectMap::default()))
1529
+ }};
1530
+ }
1531
+ // Slot-based chunks (self-contained functions) use a bare `Vec<Value>`
1532
+ // frame indexed by slot — no per-call hashmap, no name lookups. Args bind
1533
+ // to slots 0..param_count. Empty for name-based chunks.
1534
+ let mut slot_locals: Vec<Value> = Vec::new();
1535
+ if chunk.slot_based {
1536
+ slot_locals = vec![Value::Null; chunk.num_slots as usize];
1537
+ let param_count = chunk.param_count as usize;
1538
+ for i in 0..param_count {
1539
+ if let Some(v) = args.get(i) {
1540
+ if let Some(dst) = slot_locals.get_mut(i) {
1541
+ *dst = v.clone();
1542
+ }
1543
+ }
1544
+ }
1545
+ } else {
1546
+ let mut ls = ls_get_or_init!().borrow_mut();
916
1547
  let param_count = chunk.param_count as usize;
917
1548
  if chunk.rest_param_index != NO_REST_PARAM {
918
1549
  let ri = chunk.rest_param_index as usize;
@@ -935,11 +1566,53 @@ impl Vm {
935
1566
  }
936
1567
  let mut try_handlers: Vec<(usize, usize)> = vec![];
937
1568
  let mut block_undo_stack: Vec<Vec<(Arc<str>, Option<Value>)>> = vec![];
1569
+ // Names of loop variables currently in a per-iteration binding region (ES `let` semantics).
1570
+ // A closure created while this is non-empty snapshots these into a fresh overlay so it
1571
+ // captures the loop variable's value for THIS iteration. Pushed/popped by LoopVarsBegin/End.
1572
+ let mut active_loop_vars: Vec<Arc<str>> = Vec::new();
1573
+ // Offset of the instruction currently executing — updated each iteration, read by the
1574
+ // error macros to attach a source location (issue #74). Declared here (not in the loop)
1575
+ // so it's in scope where `catchable!` is defined (macro hygiene).
1576
+ let mut instr_off = 0usize;
1577
+
1578
+ // Throw `$v` to the nearest enclosing handler (issue #60): if this frame has a live
1579
+ // `try`, jump to its `catch` with `$v` on the stack; otherwise park `$v` in the
1580
+ // thread-local and bubble the sentinel so an enclosing frame's catch can take it.
1581
+ macro_rules! raise {
1582
+ ($v:expr) => {{
1583
+ let __thrown = $v;
1584
+ if let Some((catch_ip, stack_len)) = try_handlers.pop() {
1585
+ self.stack.truncate(stack_len);
1586
+ self.stack.push(__thrown);
1587
+ ip = catch_ip;
1588
+ continue;
1589
+ } else {
1590
+ set_pending_throw(__thrown);
1591
+ return Err(PENDING_THROW_SENTINEL.to_string());
1592
+ }
1593
+ }};
1594
+ }
1595
+ // Evaluate a fallible, JS-throwable opcode helper: on `Err(msg)` the message becomes a
1596
+ // catchable `TypeError` (`x.foo()` on null, calling a non-function, …) routed through
1597
+ // `raise!` instead of aborting the whole VM.
1598
+ macro_rules! catchable {
1599
+ ($expr:expr) => {
1600
+ match $expr {
1601
+ Ok(v) => v,
1602
+ Err(msg) => raise!(construct_builtin::error_object(
1603
+ "TypeError",
1604
+ &locate_error(chunk, instr_off, &msg)
1605
+ )),
1606
+ }
1607
+ };
1608
+ }
938
1609
 
939
1610
  loop {
940
1611
  if ip >= code.len() {
941
1612
  break;
942
1613
  }
1614
+ // Offset of the instruction about to execute (read by the error macros, #74).
1615
+ instr_off = ip;
943
1616
  let op = code[ip];
944
1617
  ip += 1;
945
1618
  if op == Opcode::Nop as u8 {
@@ -949,6 +1622,29 @@ impl Vm {
949
1622
 
950
1623
  match opcode {
951
1624
  Opcode::Nop => {}
1625
+ Opcode::LoadLocal => {
1626
+ let slot = Self::read_u16(code, &mut ip) as usize;
1627
+ let v = slot_locals
1628
+ .get(slot)
1629
+ .cloned()
1630
+ .ok_or_else(|| format!("Local slot out of bounds: {}", slot))?;
1631
+ self.stack.push(v);
1632
+ }
1633
+ Opcode::StoreLocal => {
1634
+ let slot = Self::read_u16(code, &mut ip) as usize;
1635
+ let v = self
1636
+ .stack
1637
+ .pop()
1638
+ .ok_or_else(|| "Stack underflow in StoreLocal".to_string())?;
1639
+ match slot_locals.get_mut(slot) {
1640
+ Some(dst) => *dst = v,
1641
+ None => return Err(format!("Local slot out of bounds: {}", slot)),
1642
+ }
1643
+ }
1644
+ Opcode::LoadUpvalue | Opcode::StoreUpvalue => {
1645
+ // Reserved for the linked-frame upvalue model (not emitted yet).
1646
+ return Err("Upvalue opcodes not supported in this VM build".to_string());
1647
+ }
952
1648
  Opcode::LoadConst => {
953
1649
  let idx = Self::read_u16(code, &mut ip);
954
1650
  let c = constants
@@ -956,30 +1652,84 @@ impl Vm {
956
1652
  .ok_or_else(|| format!("Constant index out of bounds: {}", idx))?;
957
1653
  let v = match c {
958
1654
  Constant::Number(n) => Value::Number(*n),
959
- Constant::String(s) => Value::String(Arc::clone(s)),
1655
+ Constant::String(s) => Value::String(tishlang_core::ArcStr::from(s.as_ref())),
960
1656
  Constant::Bool(b) => Value::Bool(*b),
961
1657
  Constant::Null => Value::Null,
962
1658
  Constant::Closure(nested_idx) => {
963
1659
  let inner = nested
964
1660
  .get(*nested_idx)
965
1661
  .ok_or_else(|| "Nested chunk index out of bounds".to_string())?;
1662
+ // Numeric JIT fast path (native codegen, non-wasm): if this is a
1663
+ // straight-line numeric function, compile it once (cached per chunk)
1664
+ // and call native code when all args are numbers; else fall back to
1665
+ // the interpreter below. Purely additive — can't change behaviour.
1666
+ #[cfg(not(target_arch = "wasm32"))]
1667
+ let jit_fn = crate::jit::try_compile_numeric(inner);
966
1668
  let inner_clone = inner.clone();
967
1669
  let globals = self.globals.clone();
968
- let enclosing = Some(local_scope.clone());
1670
+ // The closure captures its defining frame's scope PLUS that frame's own
1671
+ // enclosing chain, so functions nested arbitrarily deep still resolve
1672
+ // every ancestor's locals (innermost first).
1673
+ // A closure must capture a real scope (even if empty) so that, post-creation,
1674
+ // the parent's name-based locals are visible. Materialise local_scope here.
1675
+ let captured_scope: ScopeMap = ls_get_or_init!().clone();
1676
+ let enclosing_chain: SharedChain = SharedChain::new(if active_loop_vars.is_empty() {
1677
+ let mut chain = Vec::with_capacity(self.enclosing.len() + 1);
1678
+ chain.push(captured_scope.clone());
1679
+ chain.extend(self.enclosing.iter().cloned());
1680
+ chain
1681
+ } else {
1682
+ // Per-iteration `let`: freeze the loop var(s) into an overlay that
1683
+ // shadows the still-shared frame scope, then the inherited chain.
1684
+ let mut overlay = ObjectMap::default();
1685
+ {
1686
+ let ls = captured_scope.borrow();
1687
+ for n in &active_loop_vars {
1688
+ if let Some(v) = ls.get(n.as_ref()) {
1689
+ overlay.insert(Arc::clone(n), v.clone());
1690
+ }
1691
+ }
1692
+ }
1693
+ let mut chain = Vec::with_capacity(self.enclosing.len() + 2);
1694
+ chain.push(VmRef::new(overlay));
1695
+ chain.push(captured_scope.clone());
1696
+ chain.extend(self.enclosing.iter().cloned());
1697
+ chain
1698
+ });
969
1699
  let capabilities = Arc::clone(&self.capabilities);
970
1700
  let native_modules = self.native_modules.clone();
971
- Value::native(move |args: &[Value]| {
972
- let mut vm = Vm {
973
- stack: Vec::new(),
974
- scope: ObjectMap::default(),
975
- enclosing: enclosing.clone(),
976
- globals: globals.clone(),
977
- capabilities: Arc::clone(&capabilities),
978
- native_modules: native_modules.clone(),
1701
+ // Frame-eligibility is an O(chunk) bytecode scan; gate it behind the
1702
+ // (cached) frame-VM flag so the DEFAULT path skips it entirely — flag-off
1703
+ // closure creation pays nothing.
1704
+ let frameable = Vm::frame_vm_enabled()
1705
+ && {
1706
+ #[cfg(not(target_arch = "wasm32"))]
1707
+ {
1708
+ jit_fn.is_none() && Vm::chunk_frame_eligible(&inner_clone)
1709
+ }
1710
+ #[cfg(target_arch = "wasm32")]
1711
+ {
1712
+ Vm::chunk_frame_eligible(&inner_clone)
1713
+ }
979
1714
  };
980
- vm.run_chunk(&inner_clone, &inner_clone.nested, args, false)
981
- .unwrap_or(Value::Null)
982
- })
1715
+ let vmclosure = VmClosure {
1716
+ chunk: std::sync::Arc::new(inner_clone),
1717
+ frameable,
1718
+ #[cfg(not(target_arch = "wasm32"))]
1719
+ jit_fn,
1720
+ enclosing: enclosing_chain,
1721
+ globals,
1722
+ capabilities,
1723
+ native_modules,
1724
+ };
1725
+ #[cfg(feature = "send-values")]
1726
+ {
1727
+ Value::Function(std::sync::Arc::new(vmclosure))
1728
+ }
1729
+ #[cfg(not(feature = "send-values"))]
1730
+ {
1731
+ Value::Function(std::rc::Rc::new(vmclosure))
1732
+ }
983
1733
  }
984
1734
  };
985
1735
  self.stack.push(v);
@@ -990,13 +1740,13 @@ impl Vm {
990
1740
  .get(idx as usize)
991
1741
  .ok_or_else(|| format!("Name index out of bounds: {}", idx))?;
992
1742
  let v = local_scope
993
- .borrow()
994
- .get(name.as_ref())
995
- .cloned()
1743
+ .as_ref()
1744
+ .and_then(|ls| ls.borrow().get(name.as_ref()).cloned())
996
1745
  .or_else(|| {
1746
+ // Walk the captured lexical chain, innermost first.
997
1747
  self.enclosing
998
- .as_ref()
999
- .and_then(|e| e.borrow().get(name.as_ref()).cloned())
1748
+ .iter()
1749
+ .find_map(|e| e.borrow().get(name.as_ref()).cloned())
1000
1750
  })
1001
1751
  .or_else(|| self.scope.get(name.as_ref()).cloned())
1002
1752
  .or_else(|| self.globals.borrow().get(name.as_ref()).cloned())
@@ -1013,26 +1763,26 @@ impl Vm {
1013
1763
  .pop()
1014
1764
  .ok_or_else(|| "Stack underflow".to_string())?;
1015
1765
  // Update innermost scope that has the variable (matches interpreter Scope.assign)
1016
- if local_scope.borrow().contains_key(name.as_ref()) {
1017
- local_scope.borrow_mut().insert(Arc::clone(name), v);
1018
- } else if self
1766
+ if local_scope.as_ref().is_some_and(|ls| ls.borrow().contains_key(name.as_ref())) {
1767
+ ls_get_or_init!().borrow_mut().insert(Arc::clone(name), v);
1768
+ } else if let Some(e) = self
1019
1769
  .enclosing
1020
- .as_ref()
1021
- .map(|e| e.borrow().contains_key(name.as_ref()))
1022
- .unwrap_or(false)
1770
+ .iter()
1771
+ .find(|e| e.borrow().contains_key(name.as_ref()))
1023
1772
  {
1024
- let en = self.enclosing.as_ref().unwrap();
1025
- en.borrow_mut().insert(Arc::clone(name), v);
1773
+ // Innermost captured scope that already binds the name (matches the
1774
+ // interpreter's Scope.assign walking the lexical chain).
1775
+ e.borrow_mut().insert(Arc::clone(name), v);
1026
1776
  } else if self.scope.contains_key(name.as_ref()) {
1027
1777
  self.scope.insert(Arc::clone(name), v);
1028
1778
  } else if self.globals.borrow().contains_key(name.as_ref()) {
1029
1779
  self.globals.borrow_mut().insert(Arc::clone(name), v);
1030
1780
  } else {
1031
1781
  // New variable: at top level (no enclosing) store in globals so REPL persists across lines
1032
- if self.enclosing.is_none() {
1782
+ if self.enclosing.is_empty() {
1033
1783
  self.globals.borrow_mut().insert(Arc::clone(name), v);
1034
1784
  } else {
1035
- local_scope.borrow_mut().insert(Arc::clone(name), v);
1785
+ ls_get_or_init!().borrow_mut().insert(Arc::clone(name), v);
1036
1786
  }
1037
1787
  }
1038
1788
  }
@@ -1046,16 +1796,18 @@ impl Vm {
1046
1796
  .pop()
1047
1797
  .ok_or_else(|| "Stack underflow".to_string())?;
1048
1798
  if let Some(frame) = block_undo_stack.last_mut() {
1049
- let old = local_scope.borrow().get(name.as_ref()).cloned();
1799
+ let old = local_scope
1800
+ .as_ref()
1801
+ .and_then(|ls| ls.borrow().get(name.as_ref()).cloned());
1050
1802
  frame.push((Arc::clone(name), old));
1051
1803
  }
1052
1804
  // REPL: persist top-level bindings only (not block-locals shadowing globals).
1053
- if repl_mode && self.enclosing.is_none() && block_undo_stack.is_empty() {
1805
+ if repl_mode && self.enclosing.is_empty() && block_undo_stack.is_empty() {
1054
1806
  self.globals
1055
1807
  .borrow_mut()
1056
1808
  .insert(Arc::clone(name), v.clone());
1057
1809
  }
1058
- local_scope.borrow_mut().insert(Arc::clone(name), v);
1810
+ ls_get_or_init!().borrow_mut().insert(Arc::clone(name), v);
1059
1811
  }
1060
1812
  Opcode::DeclareVarPlain => {
1061
1813
  let idx = Self::read_u16(code, &mut ip);
@@ -1066,12 +1818,12 @@ impl Vm {
1066
1818
  .stack
1067
1819
  .pop()
1068
1820
  .ok_or_else(|| "Stack underflow".to_string())?;
1069
- if repl_mode && self.enclosing.is_none() && block_undo_stack.is_empty() {
1821
+ if repl_mode && self.enclosing.is_empty() && block_undo_stack.is_empty() {
1070
1822
  self.globals
1071
1823
  .borrow_mut()
1072
1824
  .insert(Arc::clone(name), v.clone());
1073
1825
  }
1074
- local_scope.borrow_mut().insert(Arc::clone(name), v);
1826
+ ls_get_or_init!().borrow_mut().insert(Arc::clone(name), v);
1075
1827
  }
1076
1828
  Opcode::EnterBlock => {
1077
1829
  block_undo_stack.push(Vec::new());
@@ -1081,7 +1833,7 @@ impl Vm {
1081
1833
  .pop()
1082
1834
  .ok_or_else(|| "ExitBlock without matching EnterBlock".to_string())?;
1083
1835
  for (name, old) in frame.into_iter().rev() {
1084
- let mut ls = local_scope.borrow_mut();
1836
+ let mut ls = ls_get_or_init!().borrow_mut();
1085
1837
  match old {
1086
1838
  Some(prev) => {
1087
1839
  ls.insert(name, prev);
@@ -1092,6 +1844,23 @@ impl Vm {
1092
1844
  }
1093
1845
  }
1094
1846
  }
1847
+ Opcode::LoopVarsBegin => {
1848
+ let idx = Self::read_u16(code, &mut ip);
1849
+ let name = names
1850
+ .get(idx as usize)
1851
+ .ok_or_else(|| format!("Name index out of bounds: {}", idx))?;
1852
+ active_loop_vars.push(Arc::clone(name));
1853
+ }
1854
+ Opcode::LoopVarsEnd => {
1855
+ active_loop_vars.pop();
1856
+ }
1857
+ Opcode::ArgMissing => {
1858
+ // True iff the positional arg at `idx` was not supplied → the function
1859
+ // prologue applies the param's default. Matches the interpreter: an
1860
+ // explicit `null` arg is "supplied" and keeps the `null`.
1861
+ let idx = Self::read_u16(code, &mut ip) as usize;
1862
+ self.stack.push(Value::Bool(idx >= args.len()));
1863
+ }
1095
1864
  Opcode::LoadGlobal => {
1096
1865
  let idx = Self::read_u16(code, &mut ip);
1097
1866
  let name = names
@@ -1137,6 +1906,19 @@ impl Vm {
1137
1906
  .clone();
1138
1907
  self.stack.push(v);
1139
1908
  }
1909
+ Opcode::IterNormalize => {
1910
+ // `for…of`: turn a JS iterator object (callable `next()` → `{ value, done }`,
1911
+ // e.g. a Map/Set `.values()` result) into an array so the index loop iterates
1912
+ // it. Arrays/strings/everything else pass through unchanged.
1913
+ let v = self
1914
+ .stack
1915
+ .last()
1916
+ .ok_or_else(|| "Stack underflow".to_string())?;
1917
+ if let Some(items) = tishlang_core::drain_iterator(v) {
1918
+ self.stack.pop();
1919
+ self.stack.push(Value::Array(VmRef::new(items)));
1920
+ }
1921
+ }
1140
1922
  Opcode::Call => {
1141
1923
  let argc = Self::read_u16(code, &mut ip) as usize;
1142
1924
  let mut args = Vec::with_capacity(argc);
@@ -1152,25 +1934,88 @@ impl Vm {
1152
1934
  .stack
1153
1935
  .pop()
1154
1936
  .ok_or_else(|| "Stack underflow: no callee".to_string())?;
1155
- let f = match &callee {
1156
- Value::Function(f) => f.clone(),
1157
- Value::Object(o) => {
1158
- if let Some(Value::Function(call_fn)) =
1159
- o.borrow().strings.get("__call")
1160
- {
1161
- call_fn.clone()
1937
+ // Call the function in place — no `Arc` clone on the hot direct-call path. The
1938
+ // immutable borrow of `callee` is held only across the call, which never touches it.
1939
+ let result = match &callee {
1940
+ Value::Function(f) => {
1941
+ // Frame-VM (flag-on): a frameable VmClosure runs on the heap frame stack
1942
+ // (iterative, no per-call Vm / native recursion). Else the normal path.
1943
+ if Self::frame_vm_enabled() {
1944
+ match f.as_any().downcast_ref::<VmClosure>() {
1945
+ Some(vc) if Self::vmclosure_frameable(vc) => {
1946
+ match self.run_framed(vc, &args) {
1947
+ // A pending throw is handled by the post-call check
1948
+ // below (issue #60); a real fatal error propagates.
1949
+ Some(Ok(v)) => v,
1950
+ Some(Err(e)) if e == PENDING_THROW_SENTINEL => {
1951
+ Value::Null
1952
+ }
1953
+ Some(Err(e)) => return Err(e),
1954
+ None => f.call(&args),
1955
+ }
1956
+ }
1957
+ _ => f.call(&args),
1958
+ }
1162
1959
  } else {
1163
- return Err(format!(
1164
- "Call of non-function: {}",
1165
- callee.type_name()
1166
- ));
1960
+ f.call(&args)
1167
1961
  }
1168
1962
  }
1169
- _ => {
1170
- return Err(format!("Call of non-function: {}", callee.type_name()));
1963
+ Value::Object(o) => {
1964
+ let call_fn = match o.borrow().strings.get("__call") {
1965
+ Some(Value::Function(cf)) => cf.clone(),
1966
+ _ => raise!(construct_builtin::error_object(
1967
+ "TypeError",
1968
+ &format!("Call of non-function: {}", callee.type_name())
1969
+ )),
1970
+ };
1971
+ call_fn.call(&args)
1171
1972
  }
1973
+ _ => raise!(construct_builtin::error_object(
1974
+ "TypeError",
1975
+ &format!("Call of non-function: {}", callee.type_name())
1976
+ )),
1172
1977
  };
1173
- let result = f(&args);
1978
+ // A throw that escaped the callee's own `catch` is parked in the thread-local;
1979
+ // surface it here so this frame's `try` (if any) can catch it (issue #60).
1980
+ if let Some(v) = take_pending_throw() {
1981
+ raise!(v);
1982
+ }
1983
+ self.stack.push(result);
1984
+ }
1985
+ Opcode::SelfCall => {
1986
+ // Direct recursive call to the CURRENT function (`chunk`). The compiler emits
1987
+ // this only when the function's own name is provably stable, so the callee is
1988
+ // implicitly `chunk` — no callee on the stack, no name lookup, no closure
1989
+ // dispatch. Behaviour matches `LoadVar name; Call argc` (a closure call that
1990
+ // swallows errors to Null), and uses the SAME captured `enclosing`.
1991
+ let argc = Self::read_u16(code, &mut ip) as usize;
1992
+ let mut args = Vec::with_capacity(argc);
1993
+ for _ in 0..argc {
1994
+ args.push(
1995
+ self.stack
1996
+ .pop()
1997
+ .ok_or_else(|| "Stack underflow in self-call".to_string())?,
1998
+ );
1999
+ }
2000
+ args.reverse();
2001
+ let mut vm = Vm {
2002
+ stack: Vec::new(),
2003
+ scope: ObjectMap::default(),
2004
+ enclosing: self.enclosing.clone(),
2005
+ globals: self.globals.clone(),
2006
+ capabilities: Arc::clone(&self.capabilities),
2007
+ native_modules: self.native_modules.clone(),
2008
+ };
2009
+ #[cfg(not(target_arch = "wasm32"))]
2010
+ let result = stacker::maybe_grow(128 * 1024, 2 * 1024 * 1024, || {
2011
+ vm.run_chunk(chunk, nested, &args, false)
2012
+ .unwrap_or(Value::Null)
2013
+ });
2014
+ #[cfg(target_arch = "wasm32")]
2015
+ let result = vm.run_chunk(chunk, nested, &args, false).unwrap_or(Value::Null);
2016
+ if let Some(v) = take_pending_throw() {
2017
+ raise!(v);
2018
+ }
1174
2019
  self.stack.push(result);
1175
2020
  }
1176
2021
  Opcode::CallSpread => {
@@ -1182,6 +2027,11 @@ impl Vm {
1182
2027
  .stack
1183
2028
  .pop()
1184
2029
  .ok_or_else(|| "Stack underflow in CallSpread".to_string())?;
2030
+ // A lone iterator spread (`f(...m.values())`) — drain to an array.
2031
+ let args_array = match tishlang_core::drain_iterator(&args_array) {
2032
+ Some(items) => Value::Array(VmRef::new(items)),
2033
+ None => args_array,
2034
+ };
1185
2035
  let args: Vec<Value> = match &args_array {
1186
2036
  Value::Array(a) => a.borrow().clone(),
1187
2037
  _ => {
@@ -1209,7 +2059,10 @@ impl Vm {
1209
2059
  return Err(format!("Call of non-function: {}", callee.type_name()));
1210
2060
  }
1211
2061
  };
1212
- let result = f(&args);
2062
+ let result = f.call(&args);
2063
+ if let Some(v) = take_pending_throw() {
2064
+ raise!(v);
2065
+ }
1213
2066
  self.stack.push(result);
1214
2067
  }
1215
2068
  Opcode::Construct => {
@@ -1228,6 +2081,9 @@ impl Vm {
1228
2081
  .pop()
1229
2082
  .ok_or_else(|| "Stack underflow: no callee for construct".to_string())?;
1230
2083
  let result = construct_builtin::construct(&callee, &args);
2084
+ if let Some(v) = take_pending_throw() {
2085
+ raise!(v);
2086
+ }
1231
2087
  self.stack.push(result);
1232
2088
  }
1233
2089
  Opcode::ConstructSpread => {
@@ -1239,6 +2095,11 @@ impl Vm {
1239
2095
  .stack
1240
2096
  .pop()
1241
2097
  .ok_or_else(|| "Stack underflow in ConstructSpread".to_string())?;
2098
+ // A lone iterator spread (`new X(...m.values())`) — drain to an array.
2099
+ let args_array = match tishlang_core::drain_iterator(&args_array) {
2100
+ Some(items) => Value::Array(VmRef::new(items)),
2101
+ None => args_array,
2102
+ };
1242
2103
  let args: Vec<Value> = match &args_array {
1243
2104
  Value::Array(a) => a.borrow().clone(),
1244
2105
  _ => {
@@ -1249,6 +2110,9 @@ impl Vm {
1249
2110
  }
1250
2111
  };
1251
2112
  let result = construct_builtin::construct(&callee, &args);
2113
+ if let Some(v) = take_pending_throw() {
2114
+ raise!(v);
2115
+ }
1252
2116
  self.stack.push(result);
1253
2117
  }
1254
2118
  Opcode::Return => {
@@ -1308,7 +2172,7 @@ impl Vm {
1308
2172
  .stack
1309
2173
  .pop()
1310
2174
  .ok_or_else(|| "Stack underflow".to_string())?;
1311
- let v = get_member(&obj, key)?;
2175
+ let v = catchable!(ic_get_member(chunk, idx, &obj, key));
1312
2176
  self.stack.push(v);
1313
2177
  }
1314
2178
  Opcode::GetMemberOptional => {
@@ -1320,7 +2184,7 @@ impl Vm {
1320
2184
  .stack
1321
2185
  .pop()
1322
2186
  .ok_or_else(|| "Stack underflow".to_string())?;
1323
- let v = get_member(&obj, key).unwrap_or(Value::Null);
2187
+ let v = ic_get_member(chunk, idx, &obj, key).unwrap_or(Value::Null);
1324
2188
  self.stack.push(v);
1325
2189
  }
1326
2190
  Opcode::SetMember => {
@@ -1336,7 +2200,7 @@ impl Vm {
1336
2200
  .stack
1337
2201
  .pop()
1338
2202
  .ok_or_else(|| "Stack underflow".to_string())?;
1339
- set_member(&obj, key, val.clone())?;
2203
+ catchable!(ic_set_member(chunk, idx, &obj, key, val.clone()));
1340
2204
  self.stack.push(val); // assignment yields value
1341
2205
  }
1342
2206
  Opcode::GetIndex => {
@@ -1348,7 +2212,7 @@ impl Vm {
1348
2212
  .stack
1349
2213
  .pop()
1350
2214
  .ok_or_else(|| "Stack underflow".to_string())?;
1351
- let v = get_index(&obj, &idx_val)?;
2215
+ let v = catchable!(get_index(&obj, &idx_val));
1352
2216
  self.stack.push(v);
1353
2217
  }
1354
2218
  Opcode::SetIndex => {
@@ -1370,9 +2234,22 @@ impl Vm {
1370
2234
  .stack
1371
2235
  .pop()
1372
2236
  .ok_or_else(|| "Stack underflow".to_string())?;
1373
- set_index(&obj, &idx_val, val.clone())?;
2237
+ catchable!(set_index(&obj, &idx_val, val.clone()));
1374
2238
  self.stack.push(dup_val); // assignment yields the assigned value
1375
2239
  }
2240
+ Opcode::DeleteIndex => {
2241
+ // `delete obj[key]` / `delete obj.prop`: pop [obj, key], remove, push true.
2242
+ let key = self
2243
+ .stack
2244
+ .pop()
2245
+ .ok_or_else(|| "Stack underflow".to_string())?;
2246
+ let obj = self
2247
+ .stack
2248
+ .pop()
2249
+ .ok_or_else(|| "Stack underflow".to_string())?;
2250
+ delete_index(&obj, &key);
2251
+ self.stack.push(Value::Bool(true));
2252
+ }
1376
2253
  Opcode::NewArray => {
1377
2254
  let n = Self::read_u16(code, &mut ip) as usize;
1378
2255
  let mut elems = Vec::with_capacity(n);
@@ -1384,24 +2261,50 @@ impl Vm {
1384
2261
  );
1385
2262
  }
1386
2263
  elems.reverse();
1387
- self.stack.push(Value::Array(VmRef::new(elems)));
2264
+ // Packed-array fast path: if every element is a number AND there is at
2265
+ // least one element, store as Vec<f64>. Empty arrays stay as Value::Array
2266
+ // because they are commonly used as general-purpose containers (the type
2267
+ // can't be inferred from zero elements).
2268
+ if Value::packed_arrays_enabled() && !elems.is_empty() {
2269
+ if let Some(nums) = elems.iter().try_fold(
2270
+ Vec::<f64>::with_capacity(elems.len()),
2271
+ |mut acc, v| {
2272
+ if let Value::Number(n) = v { acc.push(*n); Some(acc) }
2273
+ else { None }
2274
+ },
2275
+ ) {
2276
+ self.stack.push(Value::number_array(nums));
2277
+ } else {
2278
+ self.stack.push(Value::Array(VmRef::new(elems)));
2279
+ }
2280
+ } else {
2281
+ self.stack.push(Value::Array(VmRef::new(elems)));
2282
+ }
1388
2283
  }
1389
2284
  Opcode::NewObject => {
1390
2285
  let n = Self::read_u16(code, &mut ip) as usize;
1391
- let mut map = ObjectMap::with_capacity(n.max(1));
1392
- for _ in 0..n {
1393
- let val = self
1394
- .stack
1395
- .pop()
1396
- .ok_or_else(|| "Stack underflow".to_string())?;
1397
- let key_val = self
1398
- .stack
1399
- .pop()
1400
- .ok_or_else(|| "Stack underflow".to_string())?;
1401
- let key = key_val.to_display_string().into();
2286
+ if self.stack.len() < 2 * n {
2287
+ return Err("Stack underflow".to_string());
2288
+ }
2289
+ // Pairs sit on the stack in source order: key1,val1,…,keyN,valN. Read them
2290
+ // in place into the PropMap (insertion order = JS order) and drop them in
2291
+ // one truncate — no intermediate Vec per object literal (a hot path: every
2292
+ // `{...}` and every HTTP JSON response).
2293
+ let base = self.stack.len() - 2 * n;
2294
+ let mut map = PropMap::with_capacity(n);
2295
+ for i in 0..n {
2296
+ let key_val =
2297
+ std::mem::replace(&mut self.stack[base + 2 * i], Value::Null);
2298
+ let val =
2299
+ std::mem::replace(&mut self.stack[base + 2 * i + 1], Value::Null);
2300
+ let key: Arc<str> = key_val.to_display_string().into();
1402
2301
  map.insert(key, val);
1403
2302
  }
1404
- self.stack.push(value_object_from_map(map));
2303
+ self.stack.truncate(base);
2304
+ self.stack.push(Value::Object(VmRef::new(ObjectData {
2305
+ strings: map,
2306
+ symbols: None,
2307
+ })));
1405
2308
  }
1406
2309
  Opcode::EnterTry => {
1407
2310
  let offset = Self::read_u16(code, &mut ip) as usize;
@@ -1420,6 +2323,18 @@ impl Vm {
1420
2323
  .stack
1421
2324
  .pop()
1422
2325
  .ok_or_else(|| "Stack underflow".to_string())?;
2326
+ // Materialise NumberArray on either side before concatenation.
2327
+ let left = left.coerce_number_array();
2328
+ let right = right.coerce_number_array();
2329
+ // Spread of a Map/Set iterator (`[...m.values()]`): drain to an array.
2330
+ let left = match tishlang_core::drain_iterator(&left) {
2331
+ Some(items) => Value::Array(VmRef::new(items)),
2332
+ None => left,
2333
+ };
2334
+ let right = match tishlang_core::drain_iterator(&right) {
2335
+ Some(items) => Value::Array(VmRef::new(items)),
2336
+ None => right,
2337
+ };
1423
2338
  let (mut a, b) = (
1424
2339
  match &left {
1425
2340
  Value::Array(arr) => arr.borrow().clone(),
@@ -1507,6 +2422,8 @@ impl Vm {
1507
2422
  .ok_or_else(|| "Stack underflow".to_string())?;
1508
2423
  let result = match &arr {
1509
2424
  Value::Array(a) => Value::Array(VmRef::new(a.borrow().clone())),
2425
+ // Identity map on a NumberArray = clone the packed vec (stays packed).
2426
+ Value::NumberArray(a) => Value::NumberArray(VmRef::new(a.borrow().clone())),
1510
2427
  _ => Value::Null,
1511
2428
  };
1512
2429
  self.stack.push(result);
@@ -1527,27 +2444,38 @@ impl Vm {
1527
2444
  .get(const_idx as usize)
1528
2445
  .map(|c| c.to_value())
1529
2446
  .unwrap_or(Value::Null);
1530
- let result = if let Value::Array(a) = &arr {
1531
- let arr_borrow = a.borrow();
1532
- let mapped: Vec<Value> = arr_borrow
1533
- .iter()
1534
- .map(|v| {
1535
- let l: Value = if param_left {
1536
- (*v).clone()
1537
- } else {
1538
- const_val.clone()
1539
- };
1540
- let r: Value = if param_left {
1541
- const_val.clone()
1542
- } else {
1543
- (*v).clone()
1544
- };
1545
- eval_binop(binop, &l, &r).unwrap_or(Value::Null)
1546
- })
1547
- .collect();
1548
- Value::Array(VmRef::new(mapped))
1549
- } else {
1550
- Value::Null
2447
+ let result = match &arr {
2448
+ Value::NumberArray(a) => {
2449
+ // All-numeric fast path: operate on raw f64, no boxing/unboxing.
2450
+ let arr_borrow = a.borrow();
2451
+ let mapped: Vec<Value> = arr_borrow
2452
+ .iter()
2453
+ .map(|&n| {
2454
+ let elem = Value::Number(n);
2455
+ let (l, r) = if param_left { (elem, const_val.clone()) } else { (const_val.clone(), elem) };
2456
+ eval_binop(binop, &l, &r).unwrap_or(Value::Null)
2457
+ })
2458
+ .collect();
2459
+ // If every result is numeric, stay packed (the common case for x*2, x+1, etc).
2460
+ if mapped.iter().all(|v| matches!(v, Value::Number(_))) {
2461
+ Value::number_array(mapped.into_iter().map(|v| match v { Value::Number(n) => n, _ => unreachable!() }).collect())
2462
+ } else {
2463
+ Value::Array(VmRef::new(mapped))
2464
+ }
2465
+ }
2466
+ Value::Array(a) => {
2467
+ let arr_borrow = a.borrow();
2468
+ let mapped: Vec<Value> = arr_borrow
2469
+ .iter()
2470
+ .map(|v| {
2471
+ let l: Value = if param_left { (*v).clone() } else { const_val.clone() };
2472
+ let r: Value = if param_left { const_val.clone() } else { (*v).clone() };
2473
+ eval_binop(binop, &l, &r).unwrap_or(Value::Null)
2474
+ })
2475
+ .collect();
2476
+ Value::Array(VmRef::new(mapped))
2477
+ }
2478
+ _ => Value::Null,
1551
2479
  };
1552
2480
  self.stack.push(result);
1553
2481
  }
@@ -1568,29 +2496,33 @@ impl Vm {
1568
2496
  .get(const_idx as usize)
1569
2497
  .map(|c| c.to_value())
1570
2498
  .unwrap_or(Value::Null);
1571
- let result = if let Value::Array(a) = &arr {
1572
- let arr_borrow = a.borrow();
1573
- let filtered: Vec<Value> = arr_borrow
1574
- .iter()
1575
- .filter(|v| {
1576
- let l: Value = if param_left {
1577
- (*v).clone()
1578
- } else {
1579
- const_val.clone()
1580
- };
1581
- let r: Value = if param_left {
1582
- const_val.clone()
1583
- } else {
1584
- (*v).clone()
1585
- };
1586
- let b = eval_binop(binop, &l, &r).unwrap_or(Value::Null);
1587
- b.is_truthy()
1588
- })
1589
- .cloned()
1590
- .collect();
1591
- Value::Array(VmRef::new(filtered))
1592
- } else {
1593
- Value::Null
2499
+ let result = match &arr {
2500
+ Value::NumberArray(a) => {
2501
+ let arr_borrow = a.borrow();
2502
+ let filtered: Vec<f64> = arr_borrow
2503
+ .iter()
2504
+ .filter(|&&n| {
2505
+ let elem = Value::Number(n);
2506
+ let (l, r) = if param_left { (elem, const_val.clone()) } else { (const_val.clone(), elem) };
2507
+ eval_binop(binop, &l, &r).unwrap_or(Value::Null).is_truthy()
2508
+ })
2509
+ .copied()
2510
+ .collect();
2511
+ Value::number_array(filtered)
2512
+ }
2513
+ Value::Array(a) => {
2514
+ let arr_borrow = a.borrow();
2515
+ let filtered: Vec<Value> = arr_borrow
2516
+ .iter()
2517
+ .filter(|v| {
2518
+ let (l, r) = if param_left { ((*v).clone(), const_val.clone()) } else { (const_val.clone(), (*v).clone()) };
2519
+ eval_binop(binop, &l, &r).unwrap_or(Value::Null).is_truthy()
2520
+ })
2521
+ .cloned()
2522
+ .collect();
2523
+ Value::Array(VmRef::new(filtered))
2524
+ }
2525
+ _ => Value::Null,
1594
2526
  };
1595
2527
  self.stack.push(result);
1596
2528
  }
@@ -1599,7 +2531,7 @@ impl Vm {
1599
2531
  .stack
1600
2532
  .pop()
1601
2533
  .ok_or_else(|| "Stack underflow".to_string())?;
1602
- Self::unwind_throw(&mut try_handlers, &mut self.stack, &mut ip, v)?;
2534
+ raise!(v);
1603
2535
  }
1604
2536
  Opcode::AwaitPromise => {
1605
2537
  let v = self
@@ -1653,7 +2585,9 @@ impl Vm {
1653
2585
  // on the cranelift / llvm backends that want to expose
1654
2586
  // `cargo:…` Rust crates should register the module's
1655
2587
  // exports map before calling `vm.run(chunk)`.
1656
- let from_registry: Option<Value> = if spec.starts_with("cargo:") {
2588
+ let from_registry: Option<Value> = if spec.starts_with("cargo:")
2589
+ || spec.starts_with("ffi:")
2590
+ {
1657
2591
  let regs = self.native_modules.borrow();
1658
2592
  regs.get(spec)
1659
2593
  .and_then(|m| m.borrow().get(&Arc::from(export_name)).cloned())
@@ -1714,23 +2648,16 @@ fn estimate_string_concat_len(v: &Value) -> usize {
1714
2648
  /// Append JS-style string conversion without an intermediate `String` per operand (unlike
1715
2649
  /// `format!("{}{}", a.to_display_string(), b.to_display_string())`, which triple-allocates).
1716
2650
  fn append_value_for_string_concat(out: &mut String, v: &Value) {
1717
- use std::fmt::Write;
1718
2651
  match v {
1719
- Value::Number(n) => {
1720
- if n.is_nan() {
1721
- out.push_str("NaN");
1722
- } else if *n == f64::INFINITY {
1723
- out.push_str("Infinity");
1724
- } else if *n == f64::NEG_INFINITY {
1725
- out.push_str("-Infinity");
1726
- } else {
1727
- let _ = write!(out, "{n}");
1728
- }
1729
- }
2652
+ // JS `Number.prototype.toString` (exponential past digit 21 / before −6), shared
2653
+ // with `console.log` so `"" + n` and `` `${n}` `` match Node exactly.
2654
+ Value::Number(n) => out.push_str(&tishlang_core::js_number_to_string(*n)),
1730
2655
  Value::String(s) => out.push_str(s.as_ref()),
1731
2656
  Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
1732
2657
  Value::Null => out.push_str("null"),
1733
- _ => out.push_str(&v.to_display_string()),
2658
+ // Arrays/objects use JS `ToString` (recursive comma-join / "[object Object]"),
2659
+ // not the inspect form, so `"" + [1,[2,3]]` and templates match Node.
2660
+ _ => out.push_str(&v.to_js_string()),
1734
2661
  }
1735
2662
  }
1736
2663
 
@@ -1753,29 +2680,68 @@ fn eval_binop(op: BinOp, l: &Value, r: &Value) -> Result<Value, String> {
1753
2680
  }
1754
2681
  Sub => Ok(Number(ln - rn)),
1755
2682
  Mul => Ok(Number(ln * rn)),
1756
- Div => Ok(Number(if rn == 0.0 { f64::NAN } else { ln / rn })),
1757
- Mod => Ok(Number(if rn == 0.0 { f64::NAN } else { ln % rn })),
2683
+ // IEEE division/remainder, matching JS (and the interp + rust-AOT backends): `5/0` Infinity,
2684
+ // `-5/0` -Infinity, `0/0` → NaN, `5%0` → NaN. The former `if rn==0 { NaN }` special-case made
2685
+ // the VM the only backend that returned NaN for `n/0` at runtime (literals were masked by
2686
+ // constant-folding) — a cross-backend divergence. Null/non-number operands already coerce to
2687
+ // NaN via `as_number().unwrap_or(NaN)` above, so `5/null` stays NaN (tish's null-coercion).
2688
+ Div => Ok(Number(ln / rn)),
2689
+ Mod => Ok(Number(ln % rn)),
1758
2690
  Pow => Ok(Number(ln.powf(rn))),
1759
2691
  Eq => Ok(Bool(l.strict_eq(r))),
1760
2692
  Ne => Ok(Bool(!l.strict_eq(r))),
1761
2693
  StrictEq => Ok(Bool(l.strict_eq(r))),
1762
2694
  StrictNe => Ok(Bool(!l.strict_eq(r))),
1763
- Lt => Ok(Bool(ln < rn)),
1764
- Le => Ok(Bool(ln <= rn)),
1765
- Gt => Ok(Bool(ln > rn)),
1766
- Ge => Ok(Bool(ln >= rn)),
2695
+ // Relational operators: when BOTH operands are strings, compare them
2696
+ // lexicographically (JS semantics). Otherwise coerce to numbers — a string
2697
+ // mixed with a number still falls through to numeric coercion (NaN false).
2698
+ Lt => Ok(Bool(match (l, r) {
2699
+ (String(a), String(b)) => a.as_str() < b.as_str(),
2700
+ _ => ln < rn,
2701
+ })),
2702
+ Le => Ok(Bool(match (l, r) {
2703
+ (String(a), String(b)) => a.as_str() <= b.as_str(),
2704
+ _ => ln <= rn,
2705
+ })),
2706
+ Gt => Ok(Bool(match (l, r) {
2707
+ (String(a), String(b)) => a.as_str() > b.as_str(),
2708
+ _ => ln > rn,
2709
+ })),
2710
+ Ge => Ok(Bool(match (l, r) {
2711
+ (String(a), String(b)) => a.as_str() >= b.as_str(),
2712
+ _ => ln >= rn,
2713
+ })),
1767
2714
  And => Ok(Bool(l.is_truthy() && r.is_truthy())),
1768
2715
  Or => Ok(Bool(l.is_truthy() || r.is_truthy())),
1769
- BitAnd => Ok(Number((ln as i32 & rn as i32) as f64)),
1770
- BitOr => Ok(Number((ln as i32 | rn as i32) as f64)),
1771
- BitXor => Ok(Number((ln as i32 ^ rn as i32) as f64)),
1772
- Shl => Ok(Number(((ln as i32) << (rn as i32)) as f64)),
1773
- Shr => Ok(Number(((ln as i32) >> (rn as i32)) as f64)),
2716
+ // `to_int32`/`to_uint32` = JS ToInt32/ToUint32 (modulo 2³², NaN/±Infinity → 0); not a
2717
+ // saturating cast, so out-of-range operands wrap exactly like JS instead of clamping.
2718
+ BitAnd => Ok(Number((to_int32(ln) & to_int32(rn)) as f64)),
2719
+ BitOr => Ok(Number((to_int32(ln) | to_int32(rn)) as f64)),
2720
+ BitXor => Ok(Number((to_int32(ln) ^ to_int32(rn)) as f64)),
2721
+ // JS shifts mask the count to 5 bits; `wrapping_sh*` matches that and avoids
2722
+ // the debug-mode panic that plain `<<`/`>>` raise for a count of 32+.
2723
+ Shl => Ok(Number(to_int32(ln).wrapping_shl(to_uint32(rn)) as f64)),
2724
+ Shr => Ok(Number(to_int32(ln).wrapping_shr(to_uint32(rn)) as f64)),
2725
+ UShr => Ok(Number(to_uint32(ln).wrapping_shr(to_uint32(rn)) as f64)),
1774
2726
  In => Ok(Bool(match r {
1775
2727
  Value::Object(_) => object_has(r, l),
1776
2728
  Value::Array(a) => {
1777
2729
  let key_s: Arc<str> = match l {
1778
- Value::String(s) => Arc::clone(s),
2730
+ Value::String(s) => Arc::from(s.as_str()),
2731
+ Value::Number(n) => n.to_string().into(),
2732
+ _ => l.to_display_string().into(),
2733
+ };
2734
+ if key_s.as_ref() == "length" {
2735
+ true
2736
+ } else if let Ok(idx) = key_s.parse::<usize>() {
2737
+ idx < a.borrow().len()
2738
+ } else {
2739
+ false
2740
+ }
2741
+ }
2742
+ Value::NumberArray(a) => {
2743
+ let key_s: Arc<str> = match l {
2744
+ Value::String(s) => Arc::from(s.as_str()),
1779
2745
  Value::Number(n) => n.to_string().into(),
1780
2746
  _ => l.to_display_string().into(),
1781
2747
  };
@@ -1799,19 +2765,226 @@ fn eval_unary(op: UnaryOp, o: &Value) -> Result<Value, String> {
1799
2765
  Not => Ok(Bool(!o.is_truthy())),
1800
2766
  Neg => Ok(Number(-o.as_number().unwrap_or(f64::NAN))),
1801
2767
  Pos => Ok(Number(o.as_number().unwrap_or(f64::NAN))),
1802
- BitNot => Ok(Number(!(o.as_number().unwrap_or(0.0) as i32) as f64)),
2768
+ BitNot => Ok(Number(!to_int32(o.as_number().unwrap_or(0.0)) as f64)),
1803
2769
  Void => Ok(Null),
1804
2770
  }
1805
2771
  }
1806
2772
 
2773
+ /// `GetMember` with the per-name inline cache (JSC-style, Phase 1a). On a shape hit the property is at
2774
+ /// a cached slot index → a direct load, no key hash/compare. A miss (or a non-plain-object, or a
2775
+ /// `DICT_SHAPE` object) falls to [`get_member`] (arrays/strings/`length`/methods/missing-property
2776
+ /// error), refilling the cache when the object *does* have the property. Result-equivalent to
2777
+ /// `get_member` — the cache only skips the lookup; the shape uniquely fixes the slot for a property.
2778
+ #[inline]
2779
+ fn ic_get_member(chunk: &Chunk, name_idx: u16, obj: &Value, key: &Arc<str>) -> Result<Value, String> {
2780
+ use std::sync::atomic::Ordering::Relaxed;
2781
+ if let Value::Object(od) = obj {
2782
+ let b = od.borrow();
2783
+ let shape = b.strings.shape();
2784
+ if shape != tishlang_core::DICT_SHAPE {
2785
+ if let Some(cell) = chunk.inline_caches.0.get(name_idx as usize) {
2786
+ let ic = cell.load(Relaxed);
2787
+ let cached_shape = (ic >> 32) as u32; // 0 == uncached
2788
+ if cached_shape != 0 && cached_shape == shape {
2789
+ if let Some(v) = b.strings.value_at_index((ic & 0xffff_ffff) as usize) {
2790
+ return Ok(v.clone());
2791
+ }
2792
+ }
2793
+ // Miss: do the real lookup once, and if the property exists, cache its slot.
2794
+ if let Some((v, i)) = b.strings.get_with_index(key.as_ref()) {
2795
+ cell.store(((shape as u64) << 32) | i as u64, Relaxed);
2796
+ return Ok(v.clone());
2797
+ }
2798
+ }
2799
+ }
2800
+ // `b` drops at the end of this block → safe to re-borrow `obj` in `get_member` below.
2801
+ }
2802
+ get_member(obj, key)
2803
+ }
2804
+
2805
+ /// `SetMember` with the per-name inline cache. On a shape hit for an existing property → an in-place
2806
+ /// store at the cached slot (no key lookup, no shape change). Otherwise the slow path inserts (a new
2807
+ /// key transitions the shape) and refills the cache. Non-objects fall to [`set_member`].
2808
+ #[inline]
2809
+ fn ic_set_member(
2810
+ chunk: &Chunk,
2811
+ name_idx: u16,
2812
+ obj: &Value,
2813
+ key: &Arc<str>,
2814
+ val: Value,
2815
+ ) -> Result<(), String> {
2816
+ use std::sync::atomic::Ordering::Relaxed;
2817
+ if let Value::Object(od) = obj {
2818
+ let mut b = od.borrow_mut();
2819
+ let shape = b.strings.shape();
2820
+ let cell = chunk.inline_caches.0.get(name_idx as usize);
2821
+ if shape != tishlang_core::DICT_SHAPE {
2822
+ if let Some(c) = cell {
2823
+ let ic = c.load(Relaxed);
2824
+ let cached_shape = (ic >> 32) as u32;
2825
+ if cached_shape != 0 && cached_shape == shape {
2826
+ if let Some(slot) = b.strings.value_at_index_mut((ic & 0xffff_ffff) as usize) {
2827
+ *slot = val; // existing property, same shape → in-place update
2828
+ return Ok(());
2829
+ }
2830
+ }
2831
+ }
2832
+ }
2833
+ // Slow path: insert (a new key transitions the shape) + refill the cache for next time.
2834
+ b.strings.insert(Arc::clone(key), val);
2835
+ if let Some(c) = cell {
2836
+ let ns = b.strings.shape();
2837
+ if ns != tishlang_core::DICT_SHAPE {
2838
+ if let Some((_, i)) = b.strings.get_with_index(key.as_ref()) {
2839
+ c.store(((ns as u64) << 32) | i as u64, Relaxed);
2840
+ }
2841
+ }
2842
+ }
2843
+ return Ok(());
2844
+ }
2845
+ set_member(obj, key, val)
2846
+ }
2847
+
1807
2848
  fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
1808
2849
  match obj {
1809
2850
  Value::Object(m) => {
2851
+ // `Set`/`Map` instances expose a computed `.size` (via a hidden `SizeProbe` opaque).
2852
+ if key.as_ref() == "size" {
2853
+ if let Some(n) = tishlang_builtins::collections::collection_size(obj) {
2854
+ return Ok(Value::Number(n));
2855
+ }
2856
+ }
1810
2857
  let map = m.borrow();
1811
- map.strings
1812
- .get(key.as_ref())
1813
- .cloned()
1814
- .ok_or_else(|| format!("Property '{}' not found", key))
2858
+ // Reading a missing own property returns `null` (tish's nullish value), matching
2859
+ // JS object semantics and the tree-walk interpreter — not a thrown error (#66).
2860
+ Ok(map.strings.get(key.as_ref()).cloned().unwrap_or(Value::Null))
2861
+ }
2862
+ Value::NumberArray(a) => {
2863
+ let key_s = key.as_ref();
2864
+ // Numeric index fast path.
2865
+ if let Ok(idx) = key_s.parse::<usize>() {
2866
+ return Ok(a.borrow().get(idx).map(|&n| Value::Number(n)).unwrap_or(Value::Null));
2867
+ }
2868
+ if key_s == "length" {
2869
+ return Ok(Value::Number(a.borrow().len() as f64));
2870
+ }
2871
+ // push/pop/sort — stay packed; everything else materialise + delegate.
2872
+ let a_clone = a.clone();
2873
+ let method: ArrayMethodFn = match key_s {
2874
+ "push" => make_native_fn(move |args: &[Value]| {
2875
+ let mut arr = a_clone.borrow_mut();
2876
+ for v in args {
2877
+ match v {
2878
+ Value::Number(n) => arr.push(*n),
2879
+ _ => {
2880
+ arr.push(f64::NAN); // hole-marker for non-numeric
2881
+ }
2882
+ }
2883
+ }
2884
+ Value::Number(arr.len() as f64)
2885
+ }),
2886
+ "pop" => make_native_fn(move |_: &[Value]| {
2887
+ a_clone.borrow_mut().pop()
2888
+ .map(|n| if n.is_nan() { Value::Null } else { Value::Number(n) })
2889
+ .unwrap_or(Value::Null)
2890
+ }),
2891
+ "shift" => make_native_fn(move |_: &[Value]| {
2892
+ let mut arr = a_clone.borrow_mut();
2893
+ if arr.is_empty() { Value::Null }
2894
+ else { let n = arr.remove(0); if n.is_nan() { Value::Null } else { Value::Number(n) } }
2895
+ }),
2896
+ "unshift" => make_native_fn(move |args: &[Value]| {
2897
+ let mut arr = a_clone.borrow_mut();
2898
+ for (i, v) in args.iter().enumerate() {
2899
+ let n = match v { Value::Number(n) => *n, _ => f64::NAN };
2900
+ arr.insert(i, n);
2901
+ }
2902
+ Value::Number(arr.len() as f64)
2903
+ }),
2904
+ "reverse" => make_native_fn(move |_: &[Value]| {
2905
+ a_clone.borrow_mut().reverse();
2906
+ Value::NumberArray(a_clone.clone())
2907
+ }),
2908
+ "splice" => {
2909
+ let a2 = a_clone.clone();
2910
+ make_native_fn(move |args: &[Value]| {
2911
+ // Check if there are non-numeric items to insert (args[2..]).
2912
+ let has_non_numeric = args.get(2..).unwrap_or(&[]).iter()
2913
+ .any(|v| !matches!(v, Value::Number(_)));
2914
+ if has_non_numeric {
2915
+ // Deopt: materialise, splice on the boxed array, then write numeric
2916
+ // elements back to the original Vec<f64>. This preserves the VmRef
2917
+ // identity for subsequent accesses. The array may have non-numeric
2918
+ // elements after this splice — they become NaN holes in the VmRef.
2919
+ let boxed = Value::materialize_number_array(&a2);
2920
+ let result = arr_builtins::splice(&boxed, args.first().unwrap_or(&Value::Null), args.get(1), args.get(2..).unwrap_or(&[]));
2921
+ // Sync the modified boxed Vec back into the original VmRef.
2922
+ if let Value::Array(boxed_vmref) = &boxed {
2923
+ let mut packed = a2.borrow_mut();
2924
+ *packed = boxed_vmref.borrow().iter().map(|v| match v { Value::Number(n) => *n, _ => f64::NAN }).collect();
2925
+ }
2926
+ result
2927
+ } else {
2928
+ let mut arr = a2.borrow_mut();
2929
+ let len = arr.len() as i64;
2930
+ let start = match args.first() {
2931
+ 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()) } }
2932
+ _ => 0,
2933
+ };
2934
+ let del = match args.get(1) {
2935
+ Some(Value::Number(n)) => (*n as i64).max(0) as usize,
2936
+ _ => arr.len().saturating_sub(start),
2937
+ };
2938
+ let del = del.min(arr.len().saturating_sub(start));
2939
+ let new_nums: Vec<f64> = args.get(2..).unwrap_or(&[]).iter().map(|v| match v { Value::Number(n) => *n, _ => f64::NAN }).collect();
2940
+ let removed: Vec<f64> = arr.splice(start..start + del, new_nums).collect();
2941
+ Value::number_array(removed)
2942
+ }
2943
+ })
2944
+ }
2945
+ "sort" => make_native_fn(move |args: &[Value]| {
2946
+ let arr_val = Value::NumberArray(a_clone.clone());
2947
+ let cmp = args.first();
2948
+ if let Some(Value::Function(_)) = cmp {
2949
+ // Comparator sort: materialise first (comparator may return non-numeric).
2950
+ let boxed = Value::materialize_number_array(&a_clone);
2951
+ arr_builtins::sort_with_comparator(&boxed, cmp.unwrap())
2952
+ } else {
2953
+ arr_builtins::sort_numeric_asc(&arr_val)
2954
+ }
2955
+ }),
2956
+ _ => {
2957
+ // All other methods: materialise to a boxed Array and delegate.
2958
+ // The a_clone is the original NumberArray VmRef; we materialise once per
2959
+ // method lookup (not per call) so the closure captures a stable boxed Array.
2960
+ let boxed = Value::materialize_number_array(&a_clone);
2961
+ let bv = boxed.clone();
2962
+ match key_s {
2963
+ "map" => make_native_fn(move |args| { let cb = args.first().cloned().unwrap_or(Value::Null); arr_builtins::map(&bv, &cb) }),
2964
+ "filter" => make_native_fn(move |args| { let cb = args.first().cloned().unwrap_or(Value::Null); arr_builtins::filter(&bv, &cb) }),
2965
+ "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) }),
2966
+ "forEach" => make_native_fn(move |args| { let cb = args.first().cloned().unwrap_or(Value::Null); arr_builtins::for_each(&bv, &cb) }),
2967
+ "find" => make_native_fn(move |args| { let cb = args.first().cloned().unwrap_or(Value::Null); arr_builtins::find(&bv, &cb) }),
2968
+ "findIndex" => make_native_fn(move |args| { let cb = args.first().cloned().unwrap_or(Value::Null); arr_builtins::find_index(&bv, &cb) }),
2969
+ "some" => make_native_fn(move |args| { let cb = args.first().cloned().unwrap_or(Value::Null); arr_builtins::some(&bv, &cb) }),
2970
+ "every" => make_native_fn(move |args| { let cb = args.first().cloned().unwrap_or(Value::Null); arr_builtins::every(&bv, &cb) }),
2971
+ "join" => make_native_fn(move |args| { let sep = args.first().cloned().unwrap_or(Value::Null); arr_builtins::join(&bv, &sep) }),
2972
+ "flat" => make_native_fn(move |args| { let d = args.first().cloned().unwrap_or(Value::Number(1.0)); arr_builtins::flat(&bv, &d) }),
2973
+ "flatMap" => make_native_fn(move |args| { let cb = args.first().cloned().unwrap_or(Value::Null); arr_builtins::flat_map(&bv, &cb) }),
2974
+ "reverse" => make_native_fn(move |_| arr_builtins::reverse(&bv)),
2975
+ "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) }),
2976
+ "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) }),
2977
+ "concat" => make_native_fn(move |args| arr_builtins::concat(&bv, args)),
2978
+ "indexOf" => make_native_fn(move |args| { let s = args.first().cloned().unwrap_or(Value::Null); arr_builtins::index_of(&bv, &s) }),
2979
+ "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()) }),
2980
+ "unshift" => make_native_fn(move |args| arr_builtins::unshift(&bv, args)),
2981
+ "shift" => make_native_fn(move |_| arr_builtins::shift(&bv)),
2982
+ "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) }),
2983
+ _ => return Err(format!("Property '{}' not found", key)),
2984
+ }
2985
+ }
2986
+ };
2987
+ Ok(Value::Function(method))
1815
2988
  }
1816
2989
  Value::Array(a) => {
1817
2990
  let key_s = key.as_ref();
@@ -1842,6 +3015,12 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
1842
3015
  "reverse" => make_native_fn(move |_args: &[Value]| {
1843
3016
  arr_builtins::reverse(&Value::Array(a_clone.clone()))
1844
3017
  }),
3018
+ "fill" => make_native_fn(move |args: &[Value]| {
3019
+ let value = args.first().unwrap_or(&Value::Null);
3020
+ let start = args.get(1).unwrap_or(&Value::Null);
3021
+ let end = args.get(2).unwrap_or(&Value::Null);
3022
+ arr_builtins::fill(&Value::Array(a_clone.clone()), value, start, end)
3023
+ }),
1845
3024
  "shuffle" => make_native_fn(move |_args: &[Value]| {
1846
3025
  arr_builtins::shuffle(&Value::Array(a_clone.clone()))
1847
3026
  }),
@@ -1937,25 +3116,25 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
1937
3116
  let key_s = key.as_ref();
1938
3117
  if let Ok(idx) = key_s.parse::<usize>() {
1939
3118
  return match s.chars().nth(idx) {
1940
- Some(c) => Ok(Value::String(Arc::from(c.to_string()))),
3119
+ Some(c) => Ok(Value::String(tishlang_core::ArcStr::from(c.to_string()))),
1941
3120
  None => Err("Index out of bounds".to_string()),
1942
3121
  };
1943
3122
  }
1944
3123
  if key_s == "length" {
1945
3124
  return Ok(Value::Number(s.chars().count() as f64));
1946
3125
  }
1947
- let s_clone: Arc<str> = Arc::clone(s);
3126
+ let s_clone: tishlang_core::ArcStr = s.clone();
1948
3127
  let method: ArrayMethodFn = match key_s {
1949
3128
  "indexOf" => make_native_fn(move |args: &[Value]| {
1950
3129
  let search = args.first().unwrap_or(&Value::Null);
1951
3130
  let from = args.get(1);
1952
- str_builtins::index_of(&Value::String(Arc::clone(&s_clone)), search, from)
3131
+ str_builtins::index_of(&Value::String(s_clone.clone()), search, from)
1953
3132
  }),
1954
3133
  "lastIndexOf" => make_native_fn(move |args: &[Value]| {
1955
3134
  let search = args.first().unwrap_or(&Value::Null);
1956
3135
  let position = args.get(1).cloned().unwrap_or(Value::Number(f64::INFINITY));
1957
3136
  str_builtins::last_index_of(
1958
- &Value::String(Arc::clone(&s_clone)),
3137
+ &Value::String(s_clone.clone()),
1959
3138
  search,
1960
3139
  &position,
1961
3140
  )
@@ -1963,79 +3142,154 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
1963
3142
  "includes" => make_native_fn(move |args: &[Value]| {
1964
3143
  let search = args.first().unwrap_or(&Value::Null);
1965
3144
  let from = args.get(1);
1966
- str_builtins::includes(&Value::String(Arc::clone(&s_clone)), search, from)
3145
+ str_builtins::includes(&Value::String(s_clone.clone()), search, from)
1967
3146
  }),
1968
3147
  "slice" => make_native_fn(move |args: &[Value]| {
1969
3148
  let start = args.first().unwrap_or(&Value::Null);
1970
3149
  let end = args.get(1).unwrap_or(&Value::Null);
1971
- str_builtins::slice(&Value::String(Arc::clone(&s_clone)), start, end)
3150
+ str_builtins::slice(&Value::String(s_clone.clone()), start, end)
1972
3151
  }),
1973
3152
  "substring" => make_native_fn(move |args: &[Value]| {
1974
3153
  let start = args.first().unwrap_or(&Value::Null);
1975
3154
  let end = args.get(1).unwrap_or(&Value::Null);
1976
- str_builtins::substring(&Value::String(Arc::clone(&s_clone)), start, end)
3155
+ str_builtins::substring(&Value::String(s_clone.clone()), start, end)
1977
3156
  }),
1978
3157
  "split" => make_native_fn(move |args: &[Value]| {
1979
3158
  let sep = args.first().unwrap_or(&Value::Null);
1980
- str_builtins::split(&Value::String(Arc::clone(&s_clone)), sep)
3159
+ #[cfg(feature = "regex")]
3160
+ if matches!(sep, Value::RegExp(_)) {
3161
+ return tishlang_runtime::string_split_regex(
3162
+ &Value::String(s_clone.clone()),
3163
+ sep,
3164
+ None,
3165
+ );
3166
+ }
3167
+ str_builtins::split(&Value::String(s_clone.clone()), sep)
1981
3168
  }),
1982
3169
  "trim" => make_native_fn(move |_args: &[Value]| {
1983
- str_builtins::trim(&Value::String(Arc::clone(&s_clone)))
3170
+ str_builtins::trim(&Value::String(s_clone.clone()))
1984
3171
  }),
1985
3172
  "toUpperCase" => make_native_fn(move |_args: &[Value]| {
1986
- str_builtins::to_upper_case(&Value::String(Arc::clone(&s_clone)))
3173
+ str_builtins::to_upper_case(&Value::String(s_clone.clone()))
1987
3174
  }),
1988
3175
  "toLowerCase" => make_native_fn(move |_args: &[Value]| {
1989
- str_builtins::to_lower_case(&Value::String(Arc::clone(&s_clone)))
3176
+ str_builtins::to_lower_case(&Value::String(s_clone.clone()))
1990
3177
  }),
1991
3178
  "startsWith" => make_native_fn(move |args: &[Value]| {
1992
3179
  let search = args.first().unwrap_or(&Value::Null);
1993
- str_builtins::starts_with(&Value::String(Arc::clone(&s_clone)), search)
3180
+ str_builtins::starts_with(&Value::String(s_clone.clone()), search)
1994
3181
  }),
1995
3182
  "endsWith" => make_native_fn(move |args: &[Value]| {
1996
3183
  let search = args.first().unwrap_or(&Value::Null);
1997
- str_builtins::ends_with(&Value::String(Arc::clone(&s_clone)), search)
3184
+ str_builtins::ends_with(&Value::String(s_clone.clone()), search)
1998
3185
  }),
1999
3186
  "replace" => make_native_fn(move |args: &[Value]| {
2000
3187
  let search = args.first().unwrap_or(&Value::Null);
2001
3188
  let replacement = args.get(1).unwrap_or(&Value::Null);
2002
- str_builtins::replace(&Value::String(Arc::clone(&s_clone)), search, replacement)
3189
+ // RegExp search (incl. global flag + function replacer) routes to the runtime's
3190
+ // regex-aware string_replace, identical to the rust backend.
3191
+ #[cfg(feature = "regex")]
3192
+ if matches!(search, Value::RegExp(_)) {
3193
+ return tishlang_runtime::string_replace(
3194
+ &Value::String(s_clone.clone()),
3195
+ search,
3196
+ replacement,
3197
+ );
3198
+ }
3199
+ str_builtins::replace(&Value::String(s_clone.clone()), search, replacement)
2003
3200
  }),
2004
3201
  "replaceAll" => make_native_fn(move |args: &[Value]| {
2005
3202
  let search = args.first().unwrap_or(&Value::Null);
2006
3203
  let replacement = args.get(1).unwrap_or(&Value::Null);
2007
3204
  str_builtins::replace_all(
2008
- &Value::String(Arc::clone(&s_clone)),
3205
+ &Value::String(s_clone.clone()),
2009
3206
  search,
2010
3207
  replacement,
2011
3208
  )
2012
3209
  }),
3210
+ #[cfg(feature = "regex")]
3211
+ "match" => make_native_fn(move |args: &[Value]| {
3212
+ let re = args.first().unwrap_or(&Value::Null);
3213
+ tishlang_runtime::string_match_regex(&Value::String(s_clone.clone()), re)
3214
+ }),
3215
+ #[cfg(feature = "regex")]
3216
+ "search" => make_native_fn(move |args: &[Value]| {
3217
+ let re = args.first().unwrap_or(&Value::Null);
3218
+ tishlang_runtime::string_search_regex(&Value::String(s_clone.clone()), re)
3219
+ }),
2013
3220
  "charAt" => make_native_fn(move |args: &[Value]| {
2014
3221
  let idx = args.first().unwrap_or(&Value::Null);
2015
- str_builtins::char_at(&Value::String(Arc::clone(&s_clone)), idx)
3222
+ str_builtins::char_at(&Value::String(s_clone.clone()), idx)
2016
3223
  }),
2017
3224
  "charCodeAt" => make_native_fn(move |args: &[Value]| {
2018
3225
  let idx = args.first().unwrap_or(&Value::Null);
2019
- str_builtins::char_code_at(&Value::String(Arc::clone(&s_clone)), idx)
3226
+ str_builtins::char_code_at(&Value::String(s_clone.clone()), idx)
2020
3227
  }),
2021
3228
  "repeat" => make_native_fn(move |args: &[Value]| {
2022
3229
  let count = args.first().unwrap_or(&Value::Null);
2023
- str_builtins::repeat(&Value::String(Arc::clone(&s_clone)), count)
3230
+ str_builtins::repeat(&Value::String(s_clone.clone()), count)
2024
3231
  }),
2025
3232
  "padStart" => make_native_fn(move |args: &[Value]| {
2026
3233
  let target_len = args.first().unwrap_or(&Value::Null);
2027
3234
  let pad = args.get(1).unwrap_or(&Value::Null);
2028
- str_builtins::pad_start(&Value::String(Arc::clone(&s_clone)), target_len, pad)
3235
+ str_builtins::pad_start(&Value::String(s_clone.clone()), target_len, pad)
2029
3236
  }),
2030
3237
  "padEnd" => make_native_fn(move |args: &[Value]| {
2031
3238
  let target_len = args.first().unwrap_or(&Value::Null);
2032
3239
  let pad = args.get(1).unwrap_or(&Value::Null);
2033
- str_builtins::pad_end(&Value::String(Arc::clone(&s_clone)), target_len, pad)
3240
+ str_builtins::pad_end(&Value::String(s_clone.clone()), target_len, pad)
3241
+ }),
3242
+ _ => return Err(format!("Property '{}' not found", key)),
3243
+ };
3244
+ Ok(Value::Function(method))
3245
+ }
3246
+ Value::Number(n) => {
3247
+ // Number.prototype methods. Shared impls live in tishlang_builtins::number so
3248
+ // the VM, rust runtime, and interpreter stay byte-identical (full-backend-parity-plan.md).
3249
+ let n_val = *n;
3250
+ let method: ArrayMethodFn = match key.as_ref() {
3251
+ "toFixed" => make_native_fn(move |args: &[Value]| {
3252
+ let digits = args.first().unwrap_or(&Value::Null);
3253
+ num_builtins::to_fixed(&Value::Number(n_val), digits)
3254
+ }),
3255
+ "toString" => make_native_fn(move |args: &[Value]| {
3256
+ let radix = args.first().unwrap_or(&Value::Null);
3257
+ num_builtins::to_string(&Value::Number(n_val), radix)
2034
3258
  }),
2035
3259
  _ => return Err(format!("Property '{}' not found", key)),
2036
3260
  };
2037
3261
  Ok(Value::Function(method))
2038
3262
  }
3263
+ #[cfg(feature = "regex")]
3264
+ Value::RegExp(re) => match key.as_ref() {
3265
+ // `test`/`exec` route to the same runtime impls the rust backend uses, so the match
3266
+ // object shape (keys "0".."n" + "index") and lastIndex advancement are identical.
3267
+ "test" => {
3268
+ let rc = re.clone();
3269
+ Ok(Value::native(move |args: &[Value]| {
3270
+ let input = args.first().unwrap_or(&Value::Null);
3271
+ tishlang_runtime::regexp_test(&Value::RegExp(rc.clone()), input)
3272
+ }))
3273
+ }
3274
+ "exec" => {
3275
+ let rc = re.clone();
3276
+ Ok(Value::native(move |args: &[Value]| {
3277
+ let input = args.first().unwrap_or(&Value::Null);
3278
+ tishlang_runtime::regexp_exec(&Value::RegExp(rc.clone()), input)
3279
+ }))
3280
+ }
3281
+ // Properties mirror the interpreter (eval.rs get_prop RegExp arm) exactly.
3282
+ "source" => Ok(Value::String(re.borrow().source.clone().into())),
3283
+ "flags" => Ok(Value::String(re.borrow().flags_string().into())),
3284
+ "lastIndex" => Ok(Value::Number(re.borrow().last_index as f64)),
3285
+ "global" => Ok(Value::Bool(re.borrow().flags.global)),
3286
+ "ignoreCase" => Ok(Value::Bool(re.borrow().flags.ignore_case)),
3287
+ "multiline" => Ok(Value::Bool(re.borrow().flags.multiline)),
3288
+ "dotAll" => Ok(Value::Bool(re.borrow().flags.dot_all)),
3289
+ "unicode" => Ok(Value::Bool(re.borrow().flags.unicode)),
3290
+ "sticky" => Ok(Value::Bool(re.borrow().flags.sticky)),
3291
+ _ => Err(format!("Property '{}' not found", key)),
3292
+ },
2039
3293
  #[cfg(any(feature = "http", feature = "promise"))]
2040
3294
  Value::Promise(p) => match key.as_ref() {
2041
3295
  "then" => {
@@ -2067,6 +3321,13 @@ fn set_member(obj: &Value, key: &Arc<str>, val: Value) -> Result<(), String> {
2067
3321
  Ok(())
2068
3322
  }
2069
3323
  Value::Array(a) => {
3324
+ if key.as_ref() == "length" {
3325
+ // `arr.length = k` truncates or grows (holes read back as Null), JS-style.
3326
+ let new_len = array_length_arg(&val)?;
3327
+ let mut arr = a.borrow_mut();
3328
+ arr.resize(new_len, Value::Null);
3329
+ return Ok(());
3330
+ }
2070
3331
  let idx: usize = key.as_ref().parse().unwrap_or(0);
2071
3332
  let mut arr = a.borrow_mut();
2072
3333
  if idx < arr.len() {
@@ -2077,12 +3338,39 @@ fn set_member(obj: &Value, key: &Arc<str>, val: Value) -> Result<(), String> {
2077
3338
  }
2078
3339
  Ok(())
2079
3340
  }
3341
+ Value::NumberArray(a) => {
3342
+ if key.as_ref() == "length" {
3343
+ let new_len = array_length_arg(&val)?;
3344
+ // NaN is the packed-array hole marker (read back as Null), matching get_index.
3345
+ a.borrow_mut().resize(new_len, f64::NAN);
3346
+ return Ok(());
3347
+ }
3348
+ Err(format!("Cannot set property of {}", obj.type_name()))
3349
+ }
2080
3350
  _ => Err(format!("Cannot set property of {}", obj.type_name())),
2081
3351
  }
2082
3352
  }
2083
3353
 
3354
+ /// JS `arr.length = v`: `v` is coerced to a number and must be a valid array length —
3355
+ /// a non-negative integer below 2³². Anything else is a RangeError ("Invalid array length").
3356
+ fn array_length_arg(val: &Value) -> Result<usize, String> {
3357
+ let n = val.as_number().unwrap_or(f64::NAN);
3358
+ if n.is_nan() || n < 0.0 || n.fract() != 0.0 || n > 4_294_967_295.0 {
3359
+ return Err("Invalid array length".to_string());
3360
+ }
3361
+ Ok(n as usize)
3362
+ }
3363
+
2084
3364
  fn get_index(obj: &Value, idx: &Value) -> Result<Value, String> {
2085
3365
  match obj {
3366
+ Value::NumberArray(a) => {
3367
+ let i = match idx {
3368
+ Value::Number(n) => *n as usize,
3369
+ _ => return Err(format!("Array index must be number, got {}", idx.type_name())),
3370
+ };
3371
+ // NaN is used as the hole marker (sparse-array positions); reads return Null.
3372
+ Ok(a.borrow().get(i).map(|&n| if n.is_nan() { Value::Null } else { Value::Number(n) }).unwrap_or(Value::Null))
3373
+ }
2086
3374
  Value::Array(a) => {
2087
3375
  let i = match idx {
2088
3376
  Value::Number(n) => *n as usize,
@@ -2124,20 +3412,17 @@ fn get_index(obj: &Value, idx: &Value) -> Result<Value, String> {
2124
3412
  }
2125
3413
  };
2126
3414
  match s.chars().nth(i) {
2127
- Some(c) => Ok(Value::String(Arc::from(c.to_string()))),
3415
+ Some(c) => Ok(Value::String(tishlang_core::ArcStr::from(c.to_string()))),
2128
3416
  None => Err("Index out of bounds".to_string()),
2129
3417
  }
2130
3418
  }
2131
- Value::Object(_) => object_get(obj, idx).ok_or_else(|| {
2132
- format!(
2133
- "Property '{}' not found",
2134
- idx.to_display_string()
2135
- )
2136
- }),
3419
+ // A missing own property returns `null`, not a thrown error — matching dot reads
3420
+ // (#66) and JS object semantics. Keeps `obj[key]` and `obj.key` in lockstep (#113).
3421
+ Value::Object(_) => Ok(object_get(obj, idx).unwrap_or(Value::Null)),
2137
3422
  #[cfg(any(feature = "http", feature = "promise"))]
2138
3423
  Value::Promise(_) => {
2139
3424
  let key_arc: std::sync::Arc<str> = match idx {
2140
- Value::String(s) => std::sync::Arc::clone(s),
3425
+ Value::String(s) => std::sync::Arc::from(s.as_str()),
2141
3426
  _ => {
2142
3427
  return Err(format!(
2143
3428
  "Promise bracket access requires a string key, got {}",
@@ -2155,8 +3440,67 @@ fn get_index(obj: &Value, idx: &Value) -> Result<Value, String> {
2155
3440
  }
2156
3441
  }
2157
3442
 
3443
+ /// `delete obj[key]` semantics (issue #40). Objects drop the string key; arrays clear the
3444
+ /// element at a numeric index to a `null` hole (length is preserved, JS-style). Anything else
3445
+ /// is a no-op. The operator always evaluates to `true` (handled by the caller).
3446
+ fn delete_index(obj: &Value, key: &Value) {
3447
+ match obj {
3448
+ Value::Object(m) => {
3449
+ let key_s: Arc<str> = match key {
3450
+ Value::String(s) => Arc::from(s.as_str()),
3451
+ other => Arc::from(other.to_display_string().as_str()),
3452
+ };
3453
+ m.borrow_mut().strings.remove(key_s.as_ref());
3454
+ }
3455
+ Value::Array(a) => {
3456
+ if let Value::Number(n) = key {
3457
+ let n = *n;
3458
+ if n >= 0.0 && n.fract() == 0.0 {
3459
+ let i = n as usize;
3460
+ let mut arr = a.borrow_mut();
3461
+ if i < arr.len() {
3462
+ arr[i] = Value::Null;
3463
+ }
3464
+ }
3465
+ }
3466
+ }
3467
+ _ => {}
3468
+ }
3469
+ }
3470
+
2158
3471
  fn set_index(obj: &Value, idx: &Value, val: Value) -> Result<(), String> {
2159
3472
  match obj {
3473
+ Value::NumberArray(a) => {
3474
+ let i = match idx {
3475
+ Value::Number(n) => *n as usize,
3476
+ _ => return Err(format!("Array index must be number, got {}", idx.type_name())),
3477
+ };
3478
+ // In-bounds numeric assignment stays packed.
3479
+ // Out-of-bounds or non-numeric falls through to the Array path by returning
3480
+ // a sentinel error — the caller (SetIndex opcode) does NOT handle deopt.
3481
+ // Instead we only do in-bounds-or-next-element numeric assignments here;
3482
+ // anything that creates holes (i > len) or sets a non-number is unsupported.
3483
+ match val {
3484
+ Value::Number(n) => {
3485
+ let mut arr = a.borrow_mut();
3486
+ // Extend with NaN "holes" if needed (NaN = sparse hole; read back as Null).
3487
+ while arr.len() <= i { arr.push(f64::NAN); }
3488
+ arr[i] = n;
3489
+ }
3490
+ // Non-numeric set: the Vec<f64> can't represent this type. Extend with NaN holes
3491
+ // up to the index, then leave the slot as NaN (the value is lost). This is a
3492
+ // known limitation of NumberArray; the uncommon mixed-type path should not produce
3493
+ // a NumberArray in the first place. The caller will see the correct index reads for
3494
+ // numeric elements and Null for the NaN holes.
3495
+ _ => {
3496
+ let mut arr = a.borrow_mut();
3497
+ while arr.len() <= i { arr.push(f64::NAN); }
3498
+ // arr[i] is already NaN (hole); we can't store the non-numeric value — acceptable
3499
+ // for the experimental TISH_PACKED_ARRAYS path.
3500
+ }
3501
+ }
3502
+ Ok(())
3503
+ }
2160
3504
  Value::Array(a) => {
2161
3505
  let i = match idx {
2162
3506
  Value::Number(n) => *n as usize,