@tishlang/tish-lsp 2.12.0 → 2.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/Cargo.toml +3 -0
  2. package/bin/tish-lsp +0 -0
  3. package/crates/js_to_tish/src/transform/expr.rs +12 -2
  4. package/crates/tish/tests/fixtures/fs_parity_callbacks.tish +22 -0
  5. package/crates/tish/tests/fixtures/fs_parity_promises.tish +21 -0
  6. package/crates/tish/tests/fixtures/fs_parity_sync.tish +20 -0
  7. package/crates/tish/tests/fs_parity.rs +103 -0
  8. package/crates/tish/tests/integration_test.rs +80 -0
  9. package/crates/tish/tests/shortcircuit.rs +3 -3
  10. package/crates/tish_ast/src/ast.rs +84 -0
  11. package/crates/tish_builtins/Cargo.toml +1 -0
  12. package/crates/tish_builtins/src/array.rs +724 -72
  13. package/crates/tish_builtins/src/collections.rs +149 -40
  14. package/crates/tish_builtins/src/globals.rs +215 -25
  15. package/crates/tish_builtins/src/math.rs +65 -7
  16. package/crates/tish_builtins/src/number.rs +173 -0
  17. package/crates/tish_builtins/src/string.rs +263 -23
  18. package/crates/tish_bytecode/src/chunk.rs +7 -0
  19. package/crates/tish_bytecode/src/compiler.rs +611 -63
  20. package/crates/tish_bytecode/src/lib.rs +1 -1
  21. package/crates/tish_bytecode/src/opcode.rs +156 -2
  22. package/crates/tish_bytecode/src/serialize.rs +2 -0
  23. package/crates/tish_bytecode/tests/append_local_string_builder.rs +63 -0
  24. package/crates/tish_bytecode/tests/math_unary_intrinsic.rs +47 -0
  25. package/crates/tish_compile/src/codegen.rs +18219 -5283
  26. package/crates/tish_compile/src/infer.rs +1562 -30
  27. package/crates/tish_compile/src/lib.rs +29 -4
  28. package/crates/tish_compile/src/resolve.rs +151 -13
  29. package/crates/tish_compile/src/types.rs +224 -28
  30. package/crates/tish_compile/tests/dump_codegen.rs +21 -0
  31. package/crates/tish_compile/tests/perf_codegen_169_173.rs +8 -17
  32. package/crates/tish_compile/tests/perf_codegen_173_part3.rs +61 -0
  33. package/crates/tish_compile/tests/perf_codegen_174.rs +160 -0
  34. package/crates/tish_compile/tests/perf_codegen_175.rs +190 -0
  35. package/crates/tish_compile/tests/perf_codegen_176.rs +60 -0
  36. package/crates/tish_compile/tests/perf_codegen_177.rs +167 -0
  37. package/crates/tish_compile/tests/perf_codegen_178.rs +114 -0
  38. package/crates/tish_compile/tests/perf_codegen_178_rec.rs +124 -0
  39. package/crates/tish_compile/tests/perf_codegen_181.rs +36 -0
  40. package/crates/tish_compile/tests/perf_codegen_320.rs +211 -0
  41. package/crates/tish_compile/tests/perf_codegen_module_const_forof.rs +40 -0
  42. package/crates/tish_compile_js/src/codegen.rs +94 -3
  43. package/crates/tish_compile_js/src/tests_jsx.rs +27 -0
  44. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +115 -2
  45. package/crates/tish_core/Cargo.toml +4 -0
  46. package/crates/tish_core/src/json.rs +109 -6
  47. package/crates/tish_core/src/lib.rs +222 -2
  48. package/crates/tish_core/src/shape.rs +4 -2
  49. package/crates/tish_core/src/uri.rs +45 -0
  50. package/crates/tish_core/src/value.rs +571 -35
  51. package/crates/tish_core/src/vmref.rs +14 -0
  52. package/crates/tish_eval/Cargo.toml +2 -1
  53. package/crates/tish_eval/src/eval.rs +1328 -101
  54. package/crates/tish_eval/src/natives.rs +283 -18
  55. package/crates/tish_eval/src/regex.rs +47 -1
  56. package/crates/tish_eval/src/value.rs +76 -21
  57. package/crates/tish_ffi/src/lib.rs +11 -1
  58. package/crates/tish_ffi/tests/double_free.rs +35 -0
  59. package/crates/tish_fmt/src/lib.rs +94 -1
  60. package/crates/tish_lexer/src/lib.rs +76 -0
  61. package/crates/tish_lexer/src/token.rs +4 -0
  62. package/crates/tish_lint/src/lib.rs +126 -0
  63. package/crates/tish_lsp/Cargo.toml +1 -1
  64. package/crates/tish_lsp/README.md +2 -1
  65. package/crates/tish_lsp/src/main.rs +378 -28
  66. package/crates/tish_native/src/build.rs +41 -0
  67. package/crates/tish_opt/src/lib.rs +20 -1
  68. package/crates/tish_parser/Cargo.toml +4 -0
  69. package/crates/tish_parser/src/lib.rs +68 -0
  70. package/crates/tish_parser/src/parser.rs +479 -28
  71. package/crates/tish_resolve/src/lib.rs +75 -5
  72. package/crates/tish_runtime/Cargo.toml +10 -1
  73. package/crates/tish_runtime/src/fs_ext.rs +359 -0
  74. package/crates/tish_runtime/src/http.rs +28 -10
  75. package/crates/tish_runtime/src/http_fetch.rs +150 -1
  76. package/crates/tish_runtime/src/http_hyper.rs +41 -18
  77. package/crates/tish_runtime/src/http_prefork.rs +72 -11
  78. package/crates/tish_runtime/src/lib.rs +651 -51
  79. package/crates/tish_runtime/src/timers.rs +49 -2
  80. package/crates/tish_ui/src/jsx.rs +21 -3
  81. package/crates/tish_vm/src/jit.rs +2514 -117
  82. package/crates/tish_vm/src/vm.rs +1479 -224
  83. package/package.json +1 -1
  84. package/platform/darwin-arm64/tish-lsp +0 -0
  85. package/platform/darwin-x64/tish-lsp +0 -0
  86. package/platform/linux-arm64/tish-lsp +0 -0
  87. package/platform/linux-x64/tish-lsp +0 -0
  88. package/platform/win32-x64/tish-lsp.exe +0 -0
@@ -2,6 +2,7 @@
2
2
 
3
3
  #![allow(clippy::type_complexity, clippy::cloned_ref_to_slice_refs)]
4
4
 
5
+ use std::cell::Cell;
5
6
  use std::cell::RefCell;
6
7
  use std::collections::HashMap;
7
8
  use std::path::{Path, PathBuf};
@@ -21,6 +22,74 @@ use crate::value::{
21
22
  eval_object_get, eval_object_has, eval_object_set, EvalObjectData, PropMap, Value,
22
23
  };
23
24
 
25
+ // #203: cursor cache for O(1)/near-O(1) character indexing in the interpreter. Twin of the one in
26
+ // `tishlang_builtins::string` (used by the native/VM backends); duplicated here because the
27
+ // interpreter has its own `Value`/`Arc<str>` string type rather than `tishlang_core`'s `ArcStr`. tish
28
+ // strings are UTF-8, so `chars().nth(i)` is O(i) — turning indexed/strided scans into O(n^2). For each
29
+ // recently-indexed string we cache whether it is all-ASCII (then a character index equals a byte index
30
+ // → O(1)) plus a forward cursor (so non-ASCII sequential/strided scans advance from the last position).
31
+ // Safety: the entry holds an `Arc<str>` CLONE, keeping the allocation alive so its data pointer can't
32
+ // be reused while cached (no ABA), and since strings are immutable the cached ASCII flag stays valid.
33
+ struct EvalCharCursor {
34
+ s: Arc<str>,
35
+ ascii: bool,
36
+ len_chars: usize,
37
+ char_idx: usize,
38
+ byte_off: usize,
39
+ }
40
+
41
+ thread_local! {
42
+ static EVAL_INDEX_CURSOR: RefCell<Option<EvalCharCursor>> = const { RefCell::new(None) };
43
+ }
44
+
45
+ fn with_eval_cursor<R>(s: &Arc<str>, f: impl FnOnce(&mut EvalCharCursor, &Arc<str>) -> R) -> R {
46
+ EVAL_INDEX_CURSOR.with(|cell| {
47
+ let mut slot = cell.borrow_mut();
48
+ let hit = matches!(slot.as_ref(), Some(c)
49
+ if std::ptr::eq(c.s.as_bytes().as_ptr(), s.as_bytes().as_ptr()) && c.s.len() == s.len());
50
+ if !hit {
51
+ let ascii = s.as_bytes().is_ascii();
52
+ let len_chars = if ascii { s.len() } else { s.chars().count() };
53
+ *slot = Some(EvalCharCursor {
54
+ s: Arc::clone(s),
55
+ ascii,
56
+ len_chars,
57
+ char_idx: 0,
58
+ byte_off: 0,
59
+ });
60
+ }
61
+ f(slot.as_mut().unwrap(), s)
62
+ })
63
+ }
64
+
65
+ /// Character (Unicode scalar) at index `idx` via the cursor cache. Identical results to
66
+ /// `s.chars().nth(idx)`, but O(1) for ASCII and near-O(1) for forward/strided scans.
67
+ fn nth_char_cached(s: &Arc<str>, idx: usize) -> Option<char> {
68
+ with_eval_cursor(s, |c, s| {
69
+ if c.ascii {
70
+ return s.as_bytes().get(idx).map(|&b| b as char);
71
+ }
72
+ let (base_idx, base_off) = if idx >= c.char_idx {
73
+ (c.char_idx, c.byte_off)
74
+ } else {
75
+ (0, 0)
76
+ };
77
+ match s[base_off..].char_indices().nth(idx - base_idx) {
78
+ Some((rel_off, ch)) => {
79
+ c.char_idx = idx;
80
+ c.byte_off = base_off + rel_off;
81
+ Some(ch)
82
+ }
83
+ None => None,
84
+ }
85
+ })
86
+ }
87
+
88
+ /// Character (Unicode scalar) count via the cursor cache — O(1) after the first call on a string.
89
+ fn char_count_cached(s: &Arc<str>) -> usize {
90
+ with_eval_cursor(s, |c, _| c.len_chars)
91
+ }
92
+
24
93
  pub struct Scope {
25
94
  // Scope vars: order is never observed (no Object.keys over a scope), so use a fast
26
95
  // unordered aHash map — NOT the object-strings PropMap (an insertion-ordered IndexMap),
@@ -30,6 +99,31 @@ pub struct Scope {
30
99
  parent: Option<Rc<std::cell::RefCell<Scope>>>,
31
100
  }
32
101
 
102
+ // #186 / string_build: amortized O(1) `acc += x`. tish strings are an immutable `Arc<str>`, so the
103
+ // generic `acc = acc + x` allocates a fresh String and copies the whole accumulator each time → O(n^2)
104
+ // over a build loop. Instead we keep the accumulator in a growable `String` ("the pending builder")
105
+ // and `push_str` onto it in O(1), writing it back to the scope slot ("flushing") the moment the
106
+ // variable is observed. JS strings are immutable VALUES, so soundness requires that any read sees the
107
+ // full current string: every read flushes first (see `flush_pending_for` at `Expr::Ident` and the
108
+ // other read sites). The builder is keyed by the EXACT owning scope (captured `Rc`) plus name, so a
109
+ // shadowing inner `acc` never appends onto an outer `acc`'s buffer. Shared across nested evaluators
110
+ // (function calls / closures) via `Rc` so a callee that reads the variable flushes the same buffer.
111
+ struct PendingAppend {
112
+ /// The exact scope that owns the accumulator variable (not necessarily the current scope).
113
+ scope: Rc<std::cell::RefCell<Scope>>,
114
+ name: Arc<str>,
115
+ /// The true current value of the accumulator while buffered; the scope slot is stale until flush.
116
+ buf: String,
117
+ }
118
+
119
+ #[derive(Default)]
120
+ struct StringBuilderState {
121
+ /// Fast-path guard: a single `Cell<bool>` load lets the hot identifier-read path skip the
122
+ /// `RefCell` borrow entirely when no builder is active (the case for all non-building programs).
123
+ active: Cell<bool>,
124
+ pending: RefCell<Option<PendingAppend>>,
125
+ }
126
+
33
127
  /// A reference-counted lexical scope. A `Value::Function` captures one of these at creation
34
128
  /// (the *defining* scope) so calls resolve free variables lexically — real closures.
35
129
  pub type ScopeRef = Rc<std::cell::RefCell<Scope>>;
@@ -91,6 +185,30 @@ pub struct Evaluator {
91
185
  current_dir: RefCell<Option<PathBuf>>,
92
186
  /// Extra `tish:*` builtins from `TishNativeModule::virtual_builtin_modules` (shared across nested evaluators).
93
187
  virtual_builtins: Rc<RefCell<HashMap<Arc<str>, Value>>>,
188
+ /// String-builder state for amortized O(1) `acc += x` (see [`StringBuilderState`]). Shared across
189
+ /// nested evaluators so a called function/closure that reads the accumulator flushes the buffer.
190
+ string_builder: Rc<StringBuilderState>,
191
+ /// Current user-function call depth, SHARED (`Rc`) across every nested call-frame evaluator so it
192
+ /// tracks total recursion. Past [`Evaluator::max_call_depth`] a call throws a catchable
193
+ /// `RangeError` instead of growing the stack toward OOM/abort (#381).
194
+ call_depth: Rc<std::cell::Cell<usize>>,
195
+ /// Recursion ceiling: past this many nested user-function frames a call throws a catchable
196
+ /// `RangeError('Maximum call stack size exceeded')`, matching JS, rather than aborting the process.
197
+ /// Defaults to [`DEFAULT_MAX_CALL_DEPTH`], overridable via `TISH_MAX_CALL_DEPTH`.
198
+ max_call_depth: usize,
199
+ }
200
+
201
+ /// Default recursion ceiling: far deeper than any real non-pathological recursion, yet below where
202
+ /// `stacker`'s growth would exhaust memory (the accompanying `stacker::maybe_grow` was verified safe
203
+ /// to depth 20000). Override with `TISH_MAX_CALL_DEPTH`.
204
+ const DEFAULT_MAX_CALL_DEPTH: usize = 20_000;
205
+
206
+ fn env_max_call_depth() -> usize {
207
+ std::env::var("TISH_MAX_CALL_DEPTH")
208
+ .ok()
209
+ .and_then(|v| v.parse().ok())
210
+ .filter(|&n| n > 0)
211
+ .unwrap_or(DEFAULT_MAX_CALL_DEPTH)
94
212
  }
95
213
 
96
214
  impl Evaluator {
@@ -120,6 +238,9 @@ impl Evaluator {
120
238
  );
121
239
  s.set("decodeURI".into(), Value::Native(natives::decode_uri), true);
122
240
  s.set("encodeURI".into(), Value::Native(natives::encode_uri), true);
241
+ s.set("encodeURIComponent".into(), Value::Native(natives::encode_uri_component), true);
242
+ s.set("decodeURIComponent".into(), Value::Native(natives::decode_uri_component), true);
243
+ s.set("structuredClone".into(), Value::Native(natives::structured_clone), true);
123
244
  s.set(
124
245
  "htmlEscape".into(),
125
246
  Value::Native(natives::html_escape),
@@ -144,6 +265,7 @@ impl Evaluator {
144
265
  math.insert("round".into(), Value::Native(natives::math_round));
145
266
  math.insert("random".into(), Value::Native(natives::math_random));
146
267
  math.insert("pow".into(), Value::Native(natives::math_pow));
268
+ math.insert("imul".into(), Value::Native(natives::math_imul));
147
269
  math.insert("hypot".into(), Value::Native(natives::math_hypot));
148
270
  math.insert("asin".into(), Value::Native(natives::math_asin));
149
271
  math.insert("acos".into(), Value::Native(natives::math_acos));
@@ -163,6 +285,10 @@ impl Evaluator {
163
285
  math.insert("acosh".into(), Value::Native(natives::math_acosh));
164
286
  math.insert("atanh".into(), Value::Native(natives::math_atanh));
165
287
  math.insert("cbrt".into(), Value::Native(natives::math_cbrt));
288
+ math.insert("expm1".into(), Value::Native(natives::math_expm1));
289
+ math.insert("log1p".into(), Value::Native(natives::math_log1p));
290
+ math.insert("clz32".into(), Value::Native(natives::math_clz32));
291
+ math.insert("fround".into(), Value::Native(natives::math_fround));
166
292
  math.insert("log2".into(), Value::Native(natives::math_log2));
167
293
  math.insert("log10".into(), Value::Native(natives::math_log10));
168
294
  math.insert("PI".into(), Value::Number(std::f64::consts::PI));
@@ -193,6 +319,7 @@ impl Evaluator {
193
319
  process_obj.insert("exit".into(), Value::Native(natives::process_exit));
194
320
  process_obj.insert("cwd".into(), Value::Native(natives::process_cwd));
195
321
  process_obj.insert("exec".into(), Value::Native(natives::process_exec));
322
+ process_obj.insert("execFile".into(), Value::Native(natives::process_exec_file));
196
323
  let argv: Vec<Value> = tishlang_core::process_argv()
197
324
  .into_iter()
198
325
  .map(|s| Value::String(s.into()))
@@ -210,6 +337,10 @@ impl Evaluator {
210
337
  object.insert("values".into(), Value::Native(Self::object_values));
211
338
  object.insert("entries".into(), Value::Native(Self::object_entries));
212
339
  object.insert("assign".into(), Value::Native(Self::object_assign));
340
+ object.insert("is".into(), Value::Native(natives::object_is));
341
+ object.insert("hasOwn".into(), Value::Native(natives::object_has_own));
342
+ // getOwnPropertyNames ≈ keys for tish's plain objects (own string keys, insertion order).
343
+ object.insert("getOwnPropertyNames".into(), Value::Native(Self::object_keys));
213
344
  object.insert(
214
345
  "fromEntries".into(),
215
346
  Value::Native(Self::object_from_entries),
@@ -222,6 +353,7 @@ impl Evaluator {
222
353
 
223
354
  let mut array_obj = PropMap::with_capacity(3);
224
355
  array_obj.insert("isArray".into(), Value::Native(natives::array_is_array));
356
+ array_obj.insert("of".into(), Value::Native(natives::array_of));
225
357
  // `Array(n)` and `new Array(n)` constructor (issue #72).
226
358
  array_obj.insert("__call".into(), Value::Native(natives::array_construct));
227
359
  array_obj.insert("__construct".into(), Value::Native(natives::array_construct));
@@ -257,9 +389,23 @@ impl Evaluator {
257
389
  true,
258
390
  );
259
391
 
260
- // `Number(value)` coercion as a callable global (issue #36).
261
- let mut number_obj = PropMap::with_capacity(1);
392
+ // `Number(value)` coercion as a callable global (issue #36) + the `Number.*` statics.
393
+ let mut number_obj = PropMap::with_capacity(15);
262
394
  number_obj.insert("__call".into(), Value::Native(natives::number_convert));
395
+ number_obj.insert("isInteger".into(), Value::Native(natives::number_is_integer));
396
+ number_obj.insert("isSafeInteger".into(), Value::Native(natives::number_is_safe_integer));
397
+ number_obj.insert("isNaN".into(), Value::Native(natives::number_is_nan));
398
+ number_obj.insert("isFinite".into(), Value::Native(natives::number_is_finite));
399
+ number_obj.insert("parseInt".into(), Value::Native(natives::parse_int));
400
+ number_obj.insert("parseFloat".into(), Value::Native(natives::parse_float));
401
+ number_obj.insert("MAX_SAFE_INTEGER".into(), Value::Number(9_007_199_254_740_991.0));
402
+ number_obj.insert("MIN_SAFE_INTEGER".into(), Value::Number(-9_007_199_254_740_991.0));
403
+ number_obj.insert("EPSILON".into(), Value::Number(f64::EPSILON));
404
+ number_obj.insert("MAX_VALUE".into(), Value::Number(f64::MAX));
405
+ number_obj.insert("MIN_VALUE".into(), Value::Number(5e-324));
406
+ number_obj.insert("POSITIVE_INFINITY".into(), Value::Number(f64::INFINITY));
407
+ number_obj.insert("NEGATIVE_INFINITY".into(), Value::Number(f64::NEG_INFINITY));
408
+ number_obj.insert("NaN".into(), Value::Number(f64::NAN));
263
409
  s.set("Number".into(), Value::object(number_obj), true);
264
410
 
265
411
  s.set(
@@ -367,6 +513,9 @@ impl Evaluator {
367
513
  module_cache: Rc::new(RefCell::new(HashMap::new())),
368
514
  current_dir: RefCell::new(None),
369
515
  virtual_builtins: Rc::new(RefCell::new(HashMap::new())),
516
+ string_builder: Rc::new(StringBuilderState::default()),
517
+ call_depth: Rc::new(std::cell::Cell::new(0)),
518
+ max_call_depth: env_max_call_depth(),
370
519
  }
371
520
  }
372
521
 
@@ -401,6 +550,9 @@ impl Evaluator {
401
550
  for stmt in &program.statements {
402
551
  last = self.eval_statement(stmt).map_err(|e| e.to_string())?;
403
552
  }
553
+ // Flush any still-buffered string accumulator so the variable's slot is correct for any
554
+ // post-run observation (timers, REPL, embedders reading scope state).
555
+ self.flush_pending();
404
556
  Ok(last)
405
557
  }
406
558
 
@@ -451,7 +603,11 @@ impl Evaluator {
451
603
  self.bind_destruct_pattern(pattern, &value, *mutable)?;
452
604
  Ok(Value::Null)
453
605
  }
454
- Statement::ExprStmt { expr, .. } => self.eval_expr(expr),
606
+ Statement::ExprStmt { expr, .. } => {
607
+ // Statement position: route through the path that keeps statement-position
608
+ // `acc += x` O(1) (no result materialization) while preserving values otherwise.
609
+ self.eval_expr_discard(expr)
610
+ }
455
611
  Statement::If {
456
612
  cond,
457
613
  then_branch,
@@ -531,6 +687,51 @@ impl Evaluator {
531
687
  self.scope = outer;
532
688
  ret
533
689
  }
690
+ Statement::ForIn {
691
+ name,
692
+ object,
693
+ body,
694
+ ..
695
+ } => {
696
+ let obj_val = self.eval_expr(object)?;
697
+ // for-in enumerates OWN ENUMERABLE KEYS as strings (JS semantics): objects yield keys
698
+ // in insertion order, arrays yield index strings "0".."len-1", and a non-object
699
+ // (incl. null) yields nothing — a no-op, matching JS `for (k in null)`.
700
+ let keys: Vec<crate::value::Value> = match &obj_val {
701
+ crate::value::Value::Object(obj) => obj
702
+ .borrow()
703
+ .strings
704
+ .keys()
705
+ .map(|k| crate::value::Value::String(Arc::clone(k)))
706
+ .collect(),
707
+ crate::value::Value::Array(arr) => {
708
+ let len = arr.borrow().len();
709
+ (0..len)
710
+ .map(|i| crate::value::Value::String(Arc::from(i.to_string())))
711
+ .collect()
712
+ }
713
+ _ => Vec::new(),
714
+ };
715
+ // Fresh per-iteration binding (ES6 `for (let k in …)`), like the for-of arm above.
716
+ let outer = Rc::clone(&self.scope);
717
+ let mut ret = Ok(Value::Null);
718
+ for key in keys {
719
+ let iter_env = Scope::child(Rc::clone(&outer));
720
+ iter_env.borrow_mut().set(Arc::clone(name), key, true);
721
+ self.scope = Rc::clone(&iter_env);
722
+ match self.eval_statement(body) {
723
+ Ok(_) => {}
724
+ Err(EvalError::Break) => break,
725
+ Err(EvalError::Continue) => continue,
726
+ Err(e) => {
727
+ ret = Err(e);
728
+ break;
729
+ }
730
+ }
731
+ }
732
+ self.scope = outer;
733
+ ret
734
+ }
534
735
  Statement::For {
535
736
  init,
536
737
  cond,
@@ -815,6 +1016,38 @@ impl Evaluator {
815
1016
  let v = self.eval_expr(e)?;
816
1017
  self.scope.borrow_mut().set(Arc::from("default"), v, false);
817
1018
  }
1019
+ // #305: re-export in the running program — load the dep and bind the names locally.
1020
+ ExportDeclaration::ReExport {
1021
+ specifiers,
1022
+ all,
1023
+ from: Some(from),
1024
+ ..
1025
+ } => {
1026
+ let dep_val = self.load_module(from.as_ref())?;
1027
+ let dep = match &dep_val {
1028
+ Value::Object(m) => m.borrow().clone(),
1029
+ _ => return Err(EvalError::Error("Module exports must be object".to_string())),
1030
+ };
1031
+ if *all {
1032
+ for (k, v) in dep.strings.iter() {
1033
+ self.scope.borrow_mut().set(Arc::from(k.as_ref()), v.clone(), false);
1034
+ }
1035
+ }
1036
+ for spec in specifiers {
1037
+ if let ImportSpecifier::Named { name, alias, .. } = spec {
1038
+ let v = dep.strings.get(name.as_ref()).ok_or_else(|| {
1039
+ EvalError::Error(format!("Module does not export '{}'", name))
1040
+ })?;
1041
+ let bind = alias.as_deref().unwrap_or(name.as_ref());
1042
+ self.scope.borrow_mut().set(Arc::from(bind), v.clone(), false);
1043
+ }
1044
+ }
1045
+ }
1046
+ // #415 local named export (`export { a, b as c }`, no `from`): a no-op in the
1047
+ // running (entry) program — the bindings already exist, and an export alias is
1048
+ // visible only to importers, never as a local variable (JS semantics). The module
1049
+ // export table is populated by the module-evaluation path, not here.
1050
+ ExportDeclaration::ReExport { from: None, .. } => {}
818
1051
  }
819
1052
  Ok(Value::Null)
820
1053
  }
@@ -880,6 +1113,54 @@ impl Evaluator {
880
1113
  self.scope.borrow_mut().set(Arc::from("default"), v, false);
881
1114
  export_names.push("default".to_string());
882
1115
  }
1116
+ // #305: re-export — load the dep, pull the requested (or all) exports into this
1117
+ // module's scope, and re-expose them in `export_names`.
1118
+ ExportDeclaration::ReExport {
1119
+ specifiers,
1120
+ all,
1121
+ from: Some(from),
1122
+ ..
1123
+ } => {
1124
+ let dep_val = self.load_module(from.as_ref())?;
1125
+ let dep = match &dep_val {
1126
+ Value::Object(m) => m.borrow().clone(),
1127
+ _ => return Err(EvalError::Error("Module exports must be object".to_string())),
1128
+ };
1129
+ if *all {
1130
+ for (k, v) in dep.strings.iter() {
1131
+ self.scope.borrow_mut().set(Arc::from(k.as_ref()), v.clone(), false);
1132
+ export_names.push(k.to_string());
1133
+ }
1134
+ }
1135
+ for spec in specifiers {
1136
+ if let ImportSpecifier::Named { name, alias, .. } = spec {
1137
+ let v = dep.strings.get(name.as_ref()).ok_or_else(|| {
1138
+ EvalError::Error(format!("Module does not export '{}'", name))
1139
+ })?;
1140
+ let bind = alias.as_deref().unwrap_or(name.as_ref());
1141
+ self.scope.borrow_mut().set(Arc::from(bind), v.clone(), false);
1142
+ export_names.push(bind.to_string());
1143
+ }
1144
+ }
1145
+ }
1146
+ // #415 local named export (`export { a, b as c }`, no `from`): the bindings are
1147
+ // already declared in this module's scope; expose each under its export name.
1148
+ ExportDeclaration::ReExport {
1149
+ specifiers,
1150
+ from: None,
1151
+ ..
1152
+ } => {
1153
+ for spec in specifiers {
1154
+ if let ImportSpecifier::Named { name, alias, .. } = spec {
1155
+ let v = self.scope.borrow().get(name.as_ref()).ok_or_else(|| {
1156
+ EvalError::Error(format!("Export '{}' is not defined", name))
1157
+ })?;
1158
+ let bind = alias.as_deref().unwrap_or(name.as_ref());
1159
+ self.scope.borrow_mut().set(Arc::from(bind), v.clone(), false);
1160
+ export_names.push(bind.to_string());
1161
+ }
1162
+ }
1163
+ }
883
1164
  }
884
1165
  } else {
885
1166
  let _ = self.eval_statement(stmt);
@@ -946,6 +1227,44 @@ impl Evaluator {
946
1227
  Value::Native(natives::read_file_bytes),
947
1228
  );
948
1229
  exports.insert("mkdir".into(), Value::Native(natives::mkdir));
1230
+ // Node-compatible names + new methods (sync) — issue #122
1231
+ use natives::fsx;
1232
+ exports.insert("readFileSync".into(), Value::Native(fsx::read_file));
1233
+ exports.insert("readFileBytesSync".into(), Value::Native(fsx::read_file_bytes));
1234
+ exports.insert("writeFileSync".into(), Value::Native(fsx::write_file));
1235
+ exports.insert("appendFile".into(), Value::Native(fsx::append_file));
1236
+ exports.insert("appendFileSync".into(), Value::Native(fsx::append_file));
1237
+ exports.insert("existsSync".into(), Value::Native(fsx::exists));
1238
+ exports.insert("mkdirSync".into(), Value::Native(fsx::mkdir));
1239
+ exports.insert("readdir".into(), Value::Native(fsx::readdir));
1240
+ exports.insert("readdirSync".into(), Value::Native(fsx::readdir));
1241
+ exports.insert("stat".into(), Value::Native(fsx::stat));
1242
+ exports.insert("statSync".into(), Value::Native(fsx::stat));
1243
+ exports.insert("lstat".into(), Value::Native(fsx::lstat));
1244
+ exports.insert("lstatSync".into(), Value::Native(fsx::lstat));
1245
+ exports.insert("rm".into(), Value::Native(fsx::rm));
1246
+ exports.insert("rmSync".into(), Value::Native(fsx::rm));
1247
+ exports.insert("rmdir".into(), Value::Native(fsx::rmdir));
1248
+ exports.insert("rmdirSync".into(), Value::Native(fsx::rmdir));
1249
+ exports.insert("unlink".into(), Value::Native(fsx::unlink));
1250
+ exports.insert("unlinkSync".into(), Value::Native(fsx::unlink));
1251
+ exports.insert("rename".into(), Value::Native(fsx::rename));
1252
+ exports.insert("renameSync".into(), Value::Native(fsx::rename));
1253
+ exports.insert("copyFile".into(), Value::Native(fsx::copy_file));
1254
+ exports.insert("copyFileSync".into(), Value::Native(fsx::copy_file));
1255
+ exports.insert("realpath".into(), Value::Native(fsx::realpath));
1256
+ exports.insert("realpathSync".into(), Value::Native(fsx::realpath));
1257
+ exports.insert("readlink".into(), Value::Native(fsx::readlink));
1258
+ exports.insert("readlinkSync".into(), Value::Native(fsx::readlink));
1259
+ exports.insert("truncate".into(), Value::Native(fsx::truncate));
1260
+ exports.insert("truncateSync".into(), Value::Native(fsx::truncate));
1261
+ exports.insert("mkdtemp".into(), Value::Native(fsx::mkdtemp));
1262
+ exports.insert("mkdtempSync".into(), Value::Native(fsx::mkdtemp));
1263
+ exports.insert("cp".into(), Value::Native(fsx::cp));
1264
+ exports.insert("cpSync".into(), Value::Native(fsx::cp));
1265
+ exports.insert("access".into(), Value::Native(fsx::access));
1266
+ exports.insert("accessSync".into(), Value::Native(fsx::access));
1267
+ exports.insert("constants".into(), fsx::constants());
949
1268
  Ok(Value::object(exports))
950
1269
  }
951
1270
  #[cfg(not(feature = "fs"))]
@@ -955,6 +1274,41 @@ impl Evaluator {
955
1274
  ));
956
1275
  }
957
1276
  }
1277
+ "tish:fs/promises" => {
1278
+ #[cfg(feature = "fs")]
1279
+ {
1280
+ use natives::fsx;
1281
+ let mut exports: PropMap = PropMap::default();
1282
+ exports.insert("readFile".into(), Value::Native(fsx::read_file_p));
1283
+ exports.insert("readFileBytes".into(), Value::Native(fsx::read_file_bytes_p));
1284
+ exports.insert("writeFile".into(), Value::Native(fsx::write_file_p));
1285
+ exports.insert("appendFile".into(), Value::Native(fsx::append_file_p));
1286
+ exports.insert("exists".into(), Value::Native(fsx::exists_p));
1287
+ exports.insert("mkdir".into(), Value::Native(fsx::mkdir_p));
1288
+ exports.insert("readdir".into(), Value::Native(fsx::readdir_p));
1289
+ exports.insert("stat".into(), Value::Native(fsx::stat_p));
1290
+ exports.insert("lstat".into(), Value::Native(fsx::lstat_p));
1291
+ exports.insert("rm".into(), Value::Native(fsx::rm_p));
1292
+ exports.insert("rmdir".into(), Value::Native(fsx::rmdir_p));
1293
+ exports.insert("unlink".into(), Value::Native(fsx::unlink_p));
1294
+ exports.insert("rename".into(), Value::Native(fsx::rename_p));
1295
+ exports.insert("copyFile".into(), Value::Native(fsx::copy_file_p));
1296
+ exports.insert("realpath".into(), Value::Native(fsx::realpath_p));
1297
+ exports.insert("readlink".into(), Value::Native(fsx::readlink_p));
1298
+ exports.insert("truncate".into(), Value::Native(fsx::truncate_p));
1299
+ exports.insert("mkdtemp".into(), Value::Native(fsx::mkdtemp_p));
1300
+ exports.insert("cp".into(), Value::Native(fsx::cp_p));
1301
+ exports.insert("access".into(), Value::Native(fsx::access_p));
1302
+ exports.insert("constants".into(), fsx::constants());
1303
+ Ok(Value::object(exports))
1304
+ }
1305
+ #[cfg(not(feature = "fs"))]
1306
+ {
1307
+ return Err(EvalError::Error(
1308
+ "tish:fs/promises requires the fs feature. Rebuild with: cargo build -p tishlang --features fs".into(),
1309
+ ));
1310
+ }
1311
+ }
958
1312
  "tish:http" => {
959
1313
  #[cfg(feature = "http")]
960
1314
  {
@@ -1057,6 +1411,7 @@ impl Evaluator {
1057
1411
  exports.insert("exit".into(), Value::Native(natives::process_exit));
1058
1412
  exports.insert("cwd".into(), Value::Native(natives::process_cwd));
1059
1413
  exports.insert("exec".into(), Value::Native(natives::process_exec));
1414
+ exports.insert("execFile".into(), Value::Native(natives::process_exec_file));
1060
1415
  let argv: Vec<Value> = tishlang_core::process_argv()
1061
1416
  .into_iter()
1062
1417
  .map(|s| Value::String(s.into()))
@@ -1076,6 +1431,7 @@ impl Evaluator {
1076
1431
  process_obj.insert("exit".into(), Value::Native(natives::process_exit));
1077
1432
  process_obj.insert("cwd".into(), Value::Native(natives::process_cwd));
1078
1433
  process_obj.insert("exec".into(), Value::Native(natives::process_exec));
1434
+ process_obj.insert("execFile".into(), Value::Native(natives::process_exec_file));
1079
1435
  process_obj.insert("argv".into(), Value::Array(Rc::new(RefCell::new(argv))));
1080
1436
  process_obj.insert("env".into(), Value::object(env_obj));
1081
1437
  exports.insert(
@@ -1115,6 +1471,177 @@ impl Evaluator {
1115
1471
  })
1116
1472
  }
1117
1473
 
1474
+ // --- string-builder helpers (#186 / string_build): amortized O(1) `acc += x` ---
1475
+
1476
+ /// Append a value to a builder buffer using the exact JS coercion of `eval_binop`'s `+` (string
1477
+ /// operands push their raw chars; everything else goes through `to_js_string`).
1478
+ fn append_value_to_buf(buf: &mut String, v: &Value) {
1479
+ match v {
1480
+ Value::String(s) => buf.push_str(s),
1481
+ other => buf.push_str(&other.to_js_string()),
1482
+ }
1483
+ }
1484
+
1485
+ /// Walk the scope chain from the current scope and return the exact scope that owns `name`.
1486
+ fn find_var_scope(&self, name: &str) -> Option<Rc<std::cell::RefCell<Scope>>> {
1487
+ let mut cur = Rc::clone(&self.scope);
1488
+ loop {
1489
+ if cur.borrow().vars.contains_key(name) {
1490
+ return Some(cur);
1491
+ }
1492
+ let parent = cur.borrow().parent.clone();
1493
+ match parent {
1494
+ Some(p) => cur = p,
1495
+ None => return None,
1496
+ }
1497
+ }
1498
+ }
1499
+
1500
+ /// Flush the active builder (if any) back into its owning scope slot, restoring the invariant
1501
+ /// that the slot holds the variable's true value. No-op when no builder is active.
1502
+ fn flush_pending(&self) {
1503
+ if !self.string_builder.active.get() {
1504
+ return;
1505
+ }
1506
+ if let Some(p) = self.string_builder.pending.borrow_mut().take() {
1507
+ p.scope
1508
+ .borrow_mut()
1509
+ .vars
1510
+ .insert(Arc::clone(&p.name), Value::String(p.buf.into()));
1511
+ }
1512
+ self.string_builder.active.set(false);
1513
+ }
1514
+
1515
+ /// Flush the builder iff it is buffering `name` (which is about to be read). The `active` guard
1516
+ /// keeps this ~free on the hot identifier-read path when no builder exists.
1517
+ #[inline]
1518
+ fn flush_pending_for(&self, name: &str) {
1519
+ if !self.string_builder.active.get() {
1520
+ return;
1521
+ }
1522
+ let hit = self
1523
+ .string_builder
1524
+ .pending
1525
+ .borrow()
1526
+ .as_ref()
1527
+ .is_some_and(|p| p.name.as_ref() == name);
1528
+ if hit {
1529
+ self.flush_pending();
1530
+ }
1531
+ }
1532
+
1533
+ /// Discard the builder iff it is buffering `name` (which is about to be overwritten by a plain
1534
+ /// assignment) — avoids an unnecessary O(n) flush of a value that is about to be replaced.
1535
+ #[inline]
1536
+ fn discard_pending_for(&self, name: &str) {
1537
+ if !self.string_builder.active.get() {
1538
+ return;
1539
+ }
1540
+ let hit = self
1541
+ .string_builder
1542
+ .pending
1543
+ .borrow()
1544
+ .as_ref()
1545
+ .is_some_and(|p| p.name.as_ref() == name);
1546
+ if hit {
1547
+ *self.string_builder.pending.borrow_mut() = None;
1548
+ self.string_builder.active.set(false);
1549
+ }
1550
+ }
1551
+
1552
+ /// Try to handle `name += rhs` as an amortized-O(1) string append. Returns `true` if it was a
1553
+ /// string append (buffer updated); `false` if `name` is not a (mutable) string accumulator, so
1554
+ /// the caller falls back to the generic `+=`. Any builder for a DIFFERENT slot is flushed first;
1555
+ /// keying by the exact owning scope means a shadowing inner `name` never appends onto an outer
1556
+ /// `name`'s buffer.
1557
+ fn try_string_append(&self, name: &Arc<str>, rhs: &Value) -> bool {
1558
+ let owner = match self.find_var_scope(name) {
1559
+ Some(s) => s,
1560
+ None => return false, // undefined → let the generic path raise the error
1561
+ };
1562
+ // Continue an existing builder for this exact slot.
1563
+ if self.string_builder.active.get() {
1564
+ let same = self
1565
+ .string_builder
1566
+ .pending
1567
+ .borrow()
1568
+ .as_ref()
1569
+ .is_some_and(|p| p.name == *name && Rc::ptr_eq(&p.scope, &owner));
1570
+ if same {
1571
+ if let Some(p) = self.string_builder.pending.borrow_mut().as_mut() {
1572
+ Self::append_value_to_buf(&mut p.buf, rhs);
1573
+ }
1574
+ return true;
1575
+ }
1576
+ // A different slot is buffered — flush it before starting a new one.
1577
+ self.flush_pending();
1578
+ }
1579
+ // Start a new builder only if the accumulator currently holds a mutable string.
1580
+ let start = {
1581
+ let owner_ref = owner.borrow();
1582
+ if owner_ref.consts.contains(name.as_ref()) {
1583
+ return false; // `const acc += x` must raise the same error as the generic path
1584
+ }
1585
+ match owner_ref.vars.get(name.as_ref()) {
1586
+ Some(Value::String(a)) => Some(a.clone()),
1587
+ _ => None, // non-string accumulator → numeric/other `+=`
1588
+ }
1589
+ };
1590
+ match start {
1591
+ Some(a) => {
1592
+ let mut buf = String::with_capacity(a.len() + 16);
1593
+ buf.push_str(&a);
1594
+ Self::append_value_to_buf(&mut buf, rhs);
1595
+ *self.string_builder.pending.borrow_mut() = Some(PendingAppend {
1596
+ scope: owner,
1597
+ name: Arc::clone(name),
1598
+ buf,
1599
+ });
1600
+ self.string_builder.active.set(true);
1601
+ true
1602
+ }
1603
+ None => false,
1604
+ }
1605
+ }
1606
+
1607
+ /// Evaluate `expr` in statement position. Special-cases `acc += rhs` so the string-builder never
1608
+ /// has to materialize the assignment's result value — the key to keeping the append O(1). In that
1609
+ /// one case the (discarded) statement value is `null`; every other expression returns its real
1610
+ /// value, preserving block/program last-value semantics.
1611
+ fn eval_expr_discard(&self, expr: &Expr) -> Result<Value, EvalError> {
1612
+ if let Expr::CompoundAssign {
1613
+ name,
1614
+ op: tishlang_ast::CompoundOp::Add,
1615
+ value,
1616
+ ..
1617
+ } = expr
1618
+ {
1619
+ // Evaluate rhs FIRST — it may read `name`, which flushes any active builder.
1620
+ let rhs = self.eval_expr(value)?;
1621
+ if self.try_string_append(name, &rhs) {
1622
+ // Statement-position result is discarded; returning null avoids the O(n) flatten.
1623
+ return Ok(Value::Null);
1624
+ }
1625
+ // Not a string accumulator: generic `+=` (numeric/other) — return its real value.
1626
+ self.flush_pending_for(name);
1627
+ let current = self
1628
+ .scope
1629
+ .borrow()
1630
+ .get(name.as_ref())
1631
+ .ok_or_else(|| EvalError::Error(format!("Undefined variable: {}", name)))?;
1632
+ let result = self
1633
+ .eval_binop(&current, BinOp::Add, &rhs)
1634
+ .map_err(EvalError::Error)?;
1635
+ match self.scope.borrow_mut().assign(name.as_ref(), result.clone()) {
1636
+ Ok(true) => Ok(result),
1637
+ Ok(false) => Err(EvalError::Error(format!("Undefined variable: {}", name))),
1638
+ Err(e) => Err(EvalError::Error(e)),
1639
+ }
1640
+ } else {
1641
+ self.eval_expr(expr)
1642
+ }
1643
+ }
1644
+
1118
1645
  fn eval_expr(&self, expr: &Expr) -> Result<Value, EvalError> {
1119
1646
  match expr {
1120
1647
  Expr::Literal { value, .. } => Ok(match value {
@@ -1123,11 +1650,14 @@ impl Evaluator {
1123
1650
  Literal::Bool(b) => Value::Bool(*b),
1124
1651
  Literal::Null => Value::Null,
1125
1652
  }),
1126
- Expr::Ident { name, .. } => self
1127
- .scope
1128
- .borrow()
1129
- .get(name.as_ref())
1130
- .ok_or_else(|| EvalError::Error(format!("Undefined variable: {}", name))),
1653
+ Expr::Ident { name, .. } => {
1654
+ // Flush any string-builder buffering this variable so the read sees the full string.
1655
+ self.flush_pending_for(name.as_ref());
1656
+ self.scope
1657
+ .borrow()
1658
+ .get(name.as_ref())
1659
+ .ok_or_else(|| EvalError::Error(format!("Undefined variable: {}", name)))
1660
+ }
1131
1661
  Expr::Binary {
1132
1662
  left,
1133
1663
  op,
@@ -1176,7 +1706,58 @@ impl Evaluator {
1176
1706
  {
1177
1707
  let obj = self.eval_expr(object)?;
1178
1708
  let arg_vals = self.eval_call_args(args)?;
1179
-
1709
+
1710
+ // `Array.from(source, mapFn?)` — handled inline so the mapFn callback runs with the
1711
+ // interpreter context (a plain native has no `self`). Covers array / string / Set /
1712
+ // Map / iterator (via `__drain__`) and array-like `{ length }`. (#440)
1713
+ if let Expr::Ident { name, .. } = object.as_ref() {
1714
+ if name.as_ref() == "Array" && method_name.as_ref() == "from" {
1715
+ let source = arg_vals.first().cloned().unwrap_or(Value::Null);
1716
+ let items: Vec<Value> = match &source {
1717
+ Value::Array(a) => a.borrow().clone(),
1718
+ Value::String(s) => s
1719
+ .chars()
1720
+ .map(|c| Value::String(std::sync::Arc::from(c.to_string())))
1721
+ .collect(),
1722
+ _ => {
1723
+ if let Some(d) = self.drain_eval_iterator(&source) {
1724
+ d
1725
+ } else if matches!(&source, Value::Object(_)) {
1726
+ let len = self
1727
+ .get_prop(&source, "length")
1728
+ .ok()
1729
+ .and_then(|v| match v {
1730
+ Value::Number(n) if n.is_finite() && n >= 0.0 => {
1731
+ Some(n as usize)
1732
+ }
1733
+ _ => None,
1734
+ })
1735
+ .unwrap_or(0);
1736
+ (0..len)
1737
+ .map(|i| {
1738
+ self.get_prop(&source, &i.to_string())
1739
+ .unwrap_or(Value::Null)
1740
+ })
1741
+ .collect()
1742
+ } else {
1743
+ Vec::new()
1744
+ }
1745
+ }
1746
+ };
1747
+ let out = match arg_vals.get(1) {
1748
+ Some(f) if !matches!(f, Value::Null) => {
1749
+ let mut o = Vec::with_capacity(items.len());
1750
+ for (i, item) in items.into_iter().enumerate() {
1751
+ o.push(self.call_func(f, &[item, Value::Number(i as f64)])?);
1752
+ }
1753
+ o
1754
+ }
1755
+ _ => items,
1756
+ };
1757
+ return Ok(Value::array(out));
1758
+ }
1759
+ }
1760
+
1180
1761
  // Array methods
1181
1762
  if let Value::Array(arr) = &obj {
1182
1763
  match method_name.as_ref() {
@@ -1207,13 +1788,66 @@ impl Evaluator {
1207
1788
  "indexOf" => {
1208
1789
  let search = arg_vals.first().cloned().unwrap_or(Value::Null);
1209
1790
  let arr_borrow = arr.borrow();
1210
- for (i, v) in arr_borrow.iter().enumerate() {
1791
+ // fromIndex: positive clamps to [0, len]; negative counts from the end.
1792
+ let len = arr_borrow.len() as i64;
1793
+ let start = match arg_vals.get(1) {
1794
+ Some(Value::Number(n)) if *n >= 0.0 => (*n as i64).min(len).max(0) as usize,
1795
+ Some(Value::Number(n)) if *n < 0.0 => ((len + *n as i64).max(0)) as usize,
1796
+ _ => 0,
1797
+ };
1798
+ for (i, v) in arr_borrow.iter().enumerate().skip(start) {
1211
1799
  if v.strict_eq(&search) {
1212
1800
  return Ok(Value::Number(i as f64));
1213
1801
  }
1214
1802
  }
1215
1803
  return Ok(Value::Number(-1.0));
1216
1804
  }
1805
+ "lastIndexOf" => {
1806
+ let search = arg_vals.first().cloned().unwrap_or(Value::Null);
1807
+ let arr_borrow = arr.borrow();
1808
+ let len = arr_borrow.len() as i64;
1809
+ let start = match arg_vals.get(1) {
1810
+ Some(Value::Number(n)) if *n >= 0.0 => (*n as i64).min(len - 1),
1811
+ Some(Value::Number(n)) => len + *n as i64,
1812
+ _ => len - 1,
1813
+ };
1814
+ let mut i = start;
1815
+ while i >= 0 {
1816
+ if let Some(v) = arr_borrow.get(i as usize) {
1817
+ if v.strict_eq(&search) {
1818
+ return Ok(Value::Number(i as f64));
1819
+ }
1820
+ }
1821
+ i -= 1;
1822
+ }
1823
+ return Ok(Value::Number(-1.0));
1824
+ }
1825
+ "copyWithin" => {
1826
+ let mut v = arr.borrow_mut();
1827
+ let len = v.len() as i64;
1828
+ let norm = |x: Option<&Value>, dflt: i64| -> usize {
1829
+ match x {
1830
+ Some(Value::Number(n)) => {
1831
+ let n = *n as i64;
1832
+ (if n < 0 { len + n } else { n }).clamp(0, len) as usize
1833
+ }
1834
+ _ => dflt.clamp(0, len) as usize,
1835
+ }
1836
+ };
1837
+ let t = norm(arg_vals.first(), 0);
1838
+ let s = norm(arg_vals.get(1), 0);
1839
+ let e = norm(arg_vals.get(2), len);
1840
+ let lu = len as usize;
1841
+ if s < e && t < lu {
1842
+ let count = (e - s).min(lu - t);
1843
+ let block: Vec<Value> = v[s..s + count].to_vec();
1844
+ for (k, val) in block.into_iter().enumerate() {
1845
+ v[t + k] = val;
1846
+ }
1847
+ }
1848
+ drop(v);
1849
+ return Ok(Value::Array(arr.clone()));
1850
+ }
1217
1851
  "includes" => {
1218
1852
  let search = arg_vals.first().cloned().unwrap_or(Value::Null);
1219
1853
  let arr_borrow = arr.borrow();
@@ -1255,6 +1889,91 @@ impl Evaluator {
1255
1889
  arr.borrow_mut().reverse();
1256
1890
  return Ok(obj.clone());
1257
1891
  }
1892
+ "toReversed" => {
1893
+ // ES2023: a reversed COPY; the receiver is untouched.
1894
+ let mut v = arr.borrow().clone();
1895
+ v.reverse();
1896
+ return Ok(Value::Array(Rc::new(RefCell::new(v))));
1897
+ }
1898
+ "toSorted" => {
1899
+ // ES2023: a sorted COPY. Uses the general comparator/default path (the
1900
+ // numeric fast-paths in `sort` are speed-only and give identical order).
1901
+ let comparator = arg_vals.into_iter().next();
1902
+ let arr_values: Vec<Value> = arr.borrow().clone();
1903
+ let len = arr_values.len();
1904
+ let mut indices: Vec<usize> = (0..len).collect();
1905
+ if let Some(cmp_fn) = comparator {
1906
+ let mut pending: Option<EvalError> = None;
1907
+ indices.sort_by(|&i, &j| {
1908
+ if pending.is_some() {
1909
+ return std::cmp::Ordering::Equal;
1910
+ }
1911
+ match self.call_func(&cmp_fn, &[arr_values[i].clone(), arr_values[j].clone()]) {
1912
+ Ok(Value::Number(n)) if n < 0.0 => std::cmp::Ordering::Less,
1913
+ Ok(Value::Number(n)) if n > 0.0 => std::cmp::Ordering::Greater,
1914
+ Ok(_) => std::cmp::Ordering::Equal,
1915
+ Err(e) => { pending = Some(e); std::cmp::Ordering::Equal }
1916
+ }
1917
+ });
1918
+ if let Some(e) = pending {
1919
+ return Err(e);
1920
+ }
1921
+ } else {
1922
+ indices.sort_by(|&i, &j| {
1923
+ arr_values[i].to_string().cmp(&arr_values[j].to_string())
1924
+ });
1925
+ }
1926
+ let sorted: Vec<Value> =
1927
+ indices.into_iter().map(|i| arr_values[i].clone()).collect();
1928
+ return Ok(Value::Array(Rc::new(RefCell::new(sorted))));
1929
+ }
1930
+ "with" => {
1931
+ // ES2023: a COPY with one index replaced; negative index counts from the
1932
+ // end; out-of-range throws a catchable RangeError (matches vm/native/node).
1933
+ let index = arg_vals.first().cloned().unwrap_or(Value::Null);
1934
+ let value = arg_vals.get(1).cloned().unwrap_or(Value::Null);
1935
+ let mut v = arr.borrow().clone();
1936
+ let len = v.len() as i64;
1937
+ let rel = match index {
1938
+ Value::Number(n) if n.is_finite() => n as i64,
1939
+ _ => 0,
1940
+ };
1941
+ let actual = if rel >= 0 { rel } else { len + rel };
1942
+ if actual < 0 || actual >= len {
1943
+ let err = crate::natives::range_error_construct(&[Value::String(
1944
+ format!("Invalid index : {}", rel).into(),
1945
+ )])
1946
+ .unwrap_or(Value::Null);
1947
+ return Err(EvalError::Throw(err));
1948
+ }
1949
+ v[actual as usize] = value;
1950
+ return Ok(Value::Array(Rc::new(RefCell::new(v))));
1951
+ }
1952
+ "toSpliced" => {
1953
+ // ES2023: a COPY with the splice applied; returns the resulting array
1954
+ // (splice returns the removed elements). Same normalization as `splice`.
1955
+ let mut result = arr.borrow().clone();
1956
+ let len = result.len() as i64;
1957
+ let start = match arg_vals.first() {
1958
+ Some(Value::Number(n)) => {
1959
+ let n = *n as i64;
1960
+ if n < 0 { (len + n).max(0) as usize } else { n.min(len) as usize }
1961
+ }
1962
+ _ => 0,
1963
+ };
1964
+ let delete_count = match arg_vals.get(1) {
1965
+ Some(Value::Number(n)) => (*n as i64).max(0) as usize,
1966
+ _ => (len as usize).saturating_sub(start),
1967
+ };
1968
+ let actual_delete = delete_count.min(result.len().saturating_sub(start));
1969
+ result.drain(start..start + actual_delete);
1970
+ if arg_vals.len() > 2 {
1971
+ for (i, item) in arg_vals[2..].iter().cloned().enumerate() {
1972
+ result.insert(start + i, item);
1973
+ }
1974
+ }
1975
+ return Ok(Value::Array(Rc::new(RefCell::new(result))));
1976
+ }
1258
1977
  "fill" => {
1259
1978
  // Array.prototype.fill(value, start?, end?) — in place (issue #76).
1260
1979
  let value = arg_vals.first().cloned().unwrap_or(Value::Null);
@@ -1313,27 +2032,45 @@ impl Evaluator {
1313
2032
  let len = arr_mut.len();
1314
2033
  let mut indices: Vec<usize> = (0..len).collect();
1315
2034
  let arr_values: Vec<Value> = std::mem::take(&mut *arr_mut);
2035
+ // A `throw` from the comparator must propagate (be catchable) and
2036
+ // must NOT corrupt the array. Previously the `_ => Equal` arm
2037
+ // swallowed the `Err` and wrote a bogus reordering back; now we
2038
+ // capture the throw, stop comparing, and restore the original order
2039
+ // — matching the vm/native backends and node.
2040
+ let mut pending: Option<EvalError> = None;
1316
2041
 
1317
2042
  if let Some((scope, params, body)) = self.create_callback_scope(&cmp_fn) {
1318
2043
  indices.sort_by(|&i, &j| {
1319
- let result = self.call_with_scope(&scope, &params, &body, &[arr_values[i].clone(), arr_values[j].clone()]);
1320
- match result {
2044
+ if pending.is_some() {
2045
+ return std::cmp::Ordering::Equal;
2046
+ }
2047
+ match self.call_with_scope(&scope, &params, &body, &[arr_values[i].clone(), arr_values[j].clone()]) {
1321
2048
  Ok(Value::Number(n)) if n < 0.0 => std::cmp::Ordering::Less,
1322
2049
  Ok(Value::Number(n)) if n > 0.0 => std::cmp::Ordering::Greater,
1323
- _ => std::cmp::Ordering::Equal,
2050
+ Ok(_) => std::cmp::Ordering::Equal,
2051
+ Err(e) => { pending = Some(e); std::cmp::Ordering::Equal }
1324
2052
  }
1325
2053
  });
1326
2054
  } else {
1327
2055
  indices.sort_by(|&i, &j| {
1328
- let result = self.call_func(&cmp_fn, &[arr_values[i].clone(), arr_values[j].clone()]);
1329
- match result {
2056
+ if pending.is_some() {
2057
+ return std::cmp::Ordering::Equal;
2058
+ }
2059
+ match self.call_func(&cmp_fn, &[arr_values[i].clone(), arr_values[j].clone()]) {
1330
2060
  Ok(Value::Number(n)) if n < 0.0 => std::cmp::Ordering::Less,
1331
2061
  Ok(Value::Number(n)) if n > 0.0 => std::cmp::Ordering::Greater,
1332
- _ => std::cmp::Ordering::Equal,
2062
+ Ok(_) => std::cmp::Ordering::Equal,
2063
+ Err(e) => { pending = Some(e); std::cmp::Ordering::Equal }
1333
2064
  }
1334
2065
  });
1335
2066
  }
1336
2067
 
2068
+ if let Some(e) = pending {
2069
+ // Comparator threw: leave the array untouched and re-raise.
2070
+ *arr_mut = arr_values;
2071
+ drop(arr_mut);
2072
+ return Err(e);
2073
+ }
1337
2074
  *arr_mut = indices.into_iter().map(|i| arr_values[i].clone()).collect();
1338
2075
  }
1339
2076
  } else {
@@ -1416,7 +2153,11 @@ impl Evaluator {
1416
2153
  }
1417
2154
  "map" => {
1418
2155
  let callback = arg_vals.first().cloned().unwrap_or(Value::Null);
1419
- let arr_borrow = arr.borrow();
2156
+ // #382-style snapshot: own the elements and DROP the array borrow before
2157
+ // any callback runs, so a callback that mutates the array (via the `array`
2158
+ // arg or a capture) can't RefCell-panic. `arr_value` is the JS 3rd arg.
2159
+ let arr_borrow = arr.borrow().clone();
2160
+ let arr_value = Value::Array(arr.clone());
1420
2161
  let mut result = Vec::with_capacity(arr_borrow.len());
1421
2162
  // Try fastest path: simple single-expression callbacks
1422
2163
  let first_result = self.eval_simple_callback(&callback, &[arr_borrow.first().cloned().unwrap_or(Value::Null)]);
@@ -1433,21 +2174,49 @@ impl Evaluator {
1433
2174
  } else if let Some((scope, params, body)) = self.create_callback_scope(&callback) {
1434
2175
  // Reusable scope path
1435
2176
  for (i, v) in arr_borrow.iter().enumerate() {
1436
- let mapped = self.call_with_scope(&scope, &params, &body, &[v.clone(), Value::Number(i as f64)])?;
2177
+ let mapped = self.call_with_scope(&scope, &params, &body, &[v.clone(), Value::Number(i as f64), arr_value.clone()])?;
1437
2178
  result.push(mapped);
1438
2179
  }
1439
2180
  } else {
1440
2181
  // Full call_func path
1441
2182
  for (i, v) in arr_borrow.iter().enumerate() {
1442
- let mapped = self.call_func(&callback, &[v.clone(), Value::Number(i as f64)])?;
2183
+ let mapped = self.call_func(&callback, &[v.clone(), Value::Number(i as f64), arr_value.clone()])?;
1443
2184
  result.push(mapped);
1444
2185
  }
1445
2186
  }
1446
2187
  return Ok(Value::Array(Rc::new(RefCell::new(result))));
1447
2188
  }
2189
+ "flatMap" => {
2190
+ // map + flatten one level (Array.prototype.flatMap). A callback `throw`
2191
+ // propagates via `?` (catchable), matching vm/native/node — previously
2192
+ // interp lacked flatMap entirely ("Not a function").
2193
+ let callback = arg_vals.first().cloned().unwrap_or(Value::Null);
2194
+ let arr_borrow = arr.borrow().clone(); // snapshot; drop borrow before callbacks
2195
+ let arr_value = Value::Array(arr.clone()); // JS 3rd callback arg
2196
+ let mut result: Vec<Value> = Vec::with_capacity(arr_borrow.len());
2197
+ if let Some((scope, params, body)) = self.create_callback_scope(&callback) {
2198
+ for (i, v) in arr_borrow.iter().enumerate() {
2199
+ let mapped = self.call_with_scope(&scope, &params, &body, &[v.clone(), Value::Number(i as f64), arr_value.clone()])?;
2200
+ match mapped {
2201
+ Value::Array(inner) => result.extend(inner.borrow().iter().cloned()),
2202
+ other => result.push(other),
2203
+ }
2204
+ }
2205
+ } else {
2206
+ for (i, v) in arr_borrow.iter().enumerate() {
2207
+ let mapped = self.call_func(&callback, &[v.clone(), Value::Number(i as f64), arr_value.clone()])?;
2208
+ match mapped {
2209
+ Value::Array(inner) => result.extend(inner.borrow().iter().cloned()),
2210
+ other => result.push(other),
2211
+ }
2212
+ }
2213
+ }
2214
+ return Ok(Value::Array(Rc::new(RefCell::new(result))));
2215
+ }
1448
2216
  "filter" => {
1449
2217
  let callback = arg_vals.first().cloned().unwrap_or(Value::Null);
1450
- let arr_borrow = arr.borrow();
2218
+ let arr_borrow = arr.borrow().clone(); // snapshot; drop borrow before callbacks
2219
+ let arr_value = Value::Array(arr.clone()); // JS 3rd callback arg
1451
2220
  let mut result = Vec::new();
1452
2221
  // Try simple callback fast path
1453
2222
  let use_simple = arr_borrow.first().map(|v| {
@@ -1463,14 +2232,14 @@ impl Evaluator {
1463
2232
  }
1464
2233
  } else if let Some((scope, params, body)) = self.create_callback_scope(&callback) {
1465
2234
  for (i, v) in arr_borrow.iter().enumerate() {
1466
- let keep = self.call_with_scope(&scope, &params, &body, &[v.clone(), Value::Number(i as f64)])?;
2235
+ let keep = self.call_with_scope(&scope, &params, &body, &[v.clone(), Value::Number(i as f64), arr_value.clone()])?;
1467
2236
  if keep.is_truthy() {
1468
2237
  result.push(v.clone());
1469
2238
  }
1470
2239
  }
1471
2240
  } else {
1472
2241
  for (i, v) in arr_borrow.iter().enumerate() {
1473
- let keep = self.call_func(&callback, &[v.clone(), Value::Number(i as f64)])?;
2242
+ let keep = self.call_func(&callback, &[v.clone(), Value::Number(i as f64), arr_value.clone()])?;
1474
2243
  if keep.is_truthy() {
1475
2244
  result.push(v.clone());
1476
2245
  }
@@ -1480,7 +2249,8 @@ impl Evaluator {
1480
2249
  }
1481
2250
  "reduce" => {
1482
2251
  let callback = arg_vals.first().cloned().unwrap_or(Value::Null);
1483
- let arr_borrow = arr.borrow();
2252
+ let arr_borrow = arr.borrow().clone(); // snapshot; drop borrow before callbacks
2253
+ let arr_value = Value::Array(arr.clone()); // JS 4th callback arg
1484
2254
  let (mut acc, start_idx) = if arg_vals.len() > 1 {
1485
2255
  (arg_vals[1].clone(), 0)
1486
2256
  } else if !arr_borrow.is_empty() {
@@ -1490,18 +2260,75 @@ impl Evaluator {
1490
2260
  };
1491
2261
  if let Some((scope, params, body)) = self.create_callback_scope(&callback) {
1492
2262
  for (i, v) in arr_borrow.iter().enumerate().skip(start_idx) {
1493
- acc = self.call_with_scope(&scope, &params, &body, &[acc, v.clone(), Value::Number(i as f64)])?;
2263
+ acc = self.call_with_scope(&scope, &params, &body, &[acc, v.clone(), Value::Number(i as f64), arr_value.clone()])?;
1494
2264
  }
1495
2265
  } else {
1496
2266
  for (i, v) in arr_borrow.iter().enumerate().skip(start_idx) {
1497
- acc = self.call_func(&callback, &[acc, v.clone(), Value::Number(i as f64)])?;
2267
+ acc = self.call_func(&callback, &[acc, v.clone(), Value::Number(i as f64), arr_value.clone()])?;
1498
2268
  }
1499
2269
  }
1500
2270
  return Ok(acc);
1501
2271
  }
2272
+ "reduceRight" => {
2273
+ let callback = arg_vals.first().cloned().unwrap_or(Value::Null);
2274
+ let arr_borrow = arr.borrow().clone();
2275
+ let arr_value = Value::Array(arr.clone());
2276
+ let len = arr_borrow.len();
2277
+ // With an initial value, fold indices len-1..0; without, seed from the
2278
+ // last element and fold len-2..0. Empty + no init → TypeError.
2279
+ let (mut acc, start) = if arg_vals.len() > 1 {
2280
+ (arg_vals[1].clone(), len)
2281
+ } else if len > 0 {
2282
+ (arr_borrow[len - 1].clone(), len - 1)
2283
+ } else {
2284
+ return Err(EvalError::Error("Reduce of empty array with no initial value".to_string()));
2285
+ };
2286
+ let mut i = start;
2287
+ while i > 0 {
2288
+ i -= 1;
2289
+ acc = self.call_func(&callback, &[acc, arr_borrow[i].clone(), Value::Number(i as f64), arr_value.clone()])?;
2290
+ }
2291
+ return Ok(acc);
2292
+ }
2293
+ "keys" => {
2294
+ let len = arr.borrow().len();
2295
+ let it = tishlang_builtins::iterator::array_iterator(
2296
+ (0..len).map(|i| tishlang_core::Value::Number(i as f64)).collect(),
2297
+ );
2298
+ return Ok(crate::value_convert::core_to_eval(it));
2299
+ }
2300
+ "values" => {
2301
+ let items: Vec<tishlang_core::Value> = arr
2302
+ .borrow()
2303
+ .iter()
2304
+ .map(|v| {
2305
+ crate::value_convert::eval_to_core(v)
2306
+ .unwrap_or(tishlang_core::Value::Null)
2307
+ })
2308
+ .collect();
2309
+ let it = tishlang_builtins::iterator::array_iterator(items);
2310
+ return Ok(crate::value_convert::core_to_eval(it));
2311
+ }
2312
+ "entries" => {
2313
+ let items: Vec<tishlang_core::Value> = arr
2314
+ .borrow()
2315
+ .iter()
2316
+ .enumerate()
2317
+ .map(|(i, v)| {
2318
+ tishlang_core::Value::Array(tishlang_core::VmRef::new(vec![
2319
+ tishlang_core::Value::Number(i as f64),
2320
+ crate::value_convert::eval_to_core(v)
2321
+ .unwrap_or(tishlang_core::Value::Null),
2322
+ ]))
2323
+ })
2324
+ .collect();
2325
+ let it = tishlang_builtins::iterator::array_iterator(items);
2326
+ return Ok(crate::value_convert::core_to_eval(it));
2327
+ }
1502
2328
  "find" => {
1503
2329
  let callback = arg_vals.first().cloned().unwrap_or(Value::Null);
1504
- let arr_borrow = arr.borrow();
2330
+ let arr_borrow = arr.borrow().clone(); // snapshot; drop borrow before callbacks
2331
+ let arr_value = Value::Array(arr.clone()); // JS 3rd callback arg
1505
2332
  // Try simple callback fast path
1506
2333
  let use_simple = arr_borrow.first().map(|v| {
1507
2334
  self.eval_simple_callback(&callback, &[v.clone()]).is_some()
@@ -1516,14 +2343,14 @@ impl Evaluator {
1516
2343
  }
1517
2344
  } else if let Some((scope, params, body)) = self.create_callback_scope(&callback) {
1518
2345
  for (i, v) in arr_borrow.iter().enumerate() {
1519
- let found = self.call_with_scope(&scope, &params, &body, &[v.clone(), Value::Number(i as f64)])?;
2346
+ let found = self.call_with_scope(&scope, &params, &body, &[v.clone(), Value::Number(i as f64), arr_value.clone()])?;
1520
2347
  if found.is_truthy() {
1521
2348
  return Ok(v.clone());
1522
2349
  }
1523
2350
  }
1524
2351
  } else {
1525
2352
  for (i, v) in arr_borrow.iter().enumerate() {
1526
- let found = self.call_func(&callback, &[v.clone(), Value::Number(i as f64)])?;
2353
+ let found = self.call_func(&callback, &[v.clone(), Value::Number(i as f64), arr_value.clone()])?;
1527
2354
  if found.is_truthy() {
1528
2355
  return Ok(v.clone());
1529
2356
  }
@@ -1533,17 +2360,18 @@ impl Evaluator {
1533
2360
  }
1534
2361
  "findIndex" => {
1535
2362
  let callback = arg_vals.first().cloned().unwrap_or(Value::Null);
1536
- let arr_borrow = arr.borrow();
2363
+ let arr_borrow = arr.borrow().clone(); // snapshot; drop borrow before callbacks
2364
+ let arr_value = Value::Array(arr.clone()); // JS 3rd callback arg
1537
2365
  if let Some((scope, params, body)) = self.create_callback_scope(&callback) {
1538
2366
  for (i, v) in arr_borrow.iter().enumerate() {
1539
- let found = self.call_with_scope(&scope, &params, &body, &[v.clone(), Value::Number(i as f64)])?;
2367
+ let found = self.call_with_scope(&scope, &params, &body, &[v.clone(), Value::Number(i as f64), arr_value.clone()])?;
1540
2368
  if found.is_truthy() {
1541
2369
  return Ok(Value::Number(i as f64));
1542
2370
  }
1543
2371
  }
1544
2372
  } else {
1545
2373
  for (i, v) in arr_borrow.iter().enumerate() {
1546
- let found = self.call_func(&callback, &[v.clone(), Value::Number(i as f64)])?;
2374
+ let found = self.call_func(&callback, &[v.clone(), Value::Number(i as f64), arr_value.clone()])?;
1547
2375
  if found.is_truthy() {
1548
2376
  return Ok(Value::Number(i as f64));
1549
2377
  }
@@ -1552,13 +2380,14 @@ impl Evaluator {
1552
2380
  return Ok(Value::Number(-1.0));
1553
2381
  }
1554
2382
  "findLast" => {
1555
- // Like find, from the end (#247). Callback gets (value, original index).
2383
+ // Like find, from the end (#247). Callback gets (value, original index, array).
1556
2384
  let callback = arg_vals.first().cloned().unwrap_or(Value::Null);
1557
- let arr_borrow = arr.borrow();
2385
+ let arr_borrow = arr.borrow().clone(); // snapshot; drop borrow before callbacks
2386
+ let arr_value = Value::Array(arr.clone()); // JS 3rd callback arg
1558
2387
  let scoped = self.create_callback_scope(&callback);
1559
2388
  for i in (0..arr_borrow.len()).rev() {
1560
2389
  let v = arr_borrow[i].clone();
1561
- let args = [v.clone(), Value::Number(i as f64)];
2390
+ let args = [v.clone(), Value::Number(i as f64), arr_value.clone()];
1562
2391
  let found = match &scoped {
1563
2392
  Some((scope, params, body)) => self.call_with_scope(scope, params, body, &args)?,
1564
2393
  None => self.call_func(&callback, &args)?,
@@ -1571,10 +2400,11 @@ impl Evaluator {
1571
2400
  }
1572
2401
  "findLastIndex" => {
1573
2402
  let callback = arg_vals.first().cloned().unwrap_or(Value::Null);
1574
- let arr_borrow = arr.borrow();
2403
+ let arr_borrow = arr.borrow().clone(); // snapshot; drop borrow before callbacks
2404
+ let arr_value = Value::Array(arr.clone()); // JS 3rd callback arg
1575
2405
  let scoped = self.create_callback_scope(&callback);
1576
2406
  for i in (0..arr_borrow.len()).rev() {
1577
- let args = [arr_borrow[i].clone(), Value::Number(i as f64)];
2407
+ let args = [arr_borrow[i].clone(), Value::Number(i as f64), arr_value.clone()];
1578
2408
  let found = match &scoped {
1579
2409
  Some((scope, params, body)) => self.call_with_scope(scope, params, body, &args)?,
1580
2410
  None => self.call_func(&callback, &args)?,
@@ -1601,21 +2431,23 @@ impl Evaluator {
1601
2431
  }
1602
2432
  "forEach" => {
1603
2433
  let callback = arg_vals.first().cloned().unwrap_or(Value::Null);
1604
- let arr_borrow = arr.borrow();
2434
+ let arr_borrow = arr.borrow().clone(); // snapshot; drop borrow before callbacks
2435
+ let arr_value = Value::Array(arr.clone()); // JS 3rd callback arg
1605
2436
  if let Some((scope, params, body)) = self.create_callback_scope(&callback) {
1606
2437
  for (i, v) in arr_borrow.iter().enumerate() {
1607
- self.call_with_scope(&scope, &params, &body, &[v.clone(), Value::Number(i as f64)])?;
2438
+ self.call_with_scope(&scope, &params, &body, &[v.clone(), Value::Number(i as f64), arr_value.clone()])?;
1608
2439
  }
1609
2440
  } else {
1610
2441
  for (i, v) in arr_borrow.iter().enumerate() {
1611
- self.call_func(&callback, &[v.clone(), Value::Number(i as f64)])?;
2442
+ self.call_func(&callback, &[v.clone(), Value::Number(i as f64), arr_value.clone()])?;
1612
2443
  }
1613
2444
  }
1614
2445
  return Ok(Value::Null);
1615
2446
  }
1616
2447
  "some" => {
1617
2448
  let callback = arg_vals.first().cloned().unwrap_or(Value::Null);
1618
- let arr_borrow = arr.borrow();
2449
+ let arr_borrow = arr.borrow().clone(); // snapshot; drop borrow before callbacks
2450
+ let arr_value = Value::Array(arr.clone()); // JS 3rd callback arg
1619
2451
  // Try simple callback fast path
1620
2452
  let use_simple = arr_borrow.first().map(|v| {
1621
2453
  self.eval_simple_callback(&callback, &[v.clone()]).is_some()
@@ -1630,14 +2462,14 @@ impl Evaluator {
1630
2462
  }
1631
2463
  } else if let Some((scope, params, body)) = self.create_callback_scope(&callback) {
1632
2464
  for (i, v) in arr_borrow.iter().enumerate() {
1633
- let result = self.call_with_scope(&scope, &params, &body, &[v.clone(), Value::Number(i as f64)])?;
2465
+ let result = self.call_with_scope(&scope, &params, &body, &[v.clone(), Value::Number(i as f64), arr_value.clone()])?;
1634
2466
  if result.is_truthy() {
1635
2467
  return Ok(Value::Bool(true));
1636
2468
  }
1637
2469
  }
1638
2470
  } else {
1639
2471
  for (i, v) in arr_borrow.iter().enumerate() {
1640
- let result = self.call_func(&callback, &[v.clone(), Value::Number(i as f64)])?;
2472
+ let result = self.call_func(&callback, &[v.clone(), Value::Number(i as f64), arr_value.clone()])?;
1641
2473
  if result.is_truthy() {
1642
2474
  return Ok(Value::Bool(true));
1643
2475
  }
@@ -1647,7 +2479,8 @@ impl Evaluator {
1647
2479
  }
1648
2480
  "every" => {
1649
2481
  let callback = arg_vals.first().cloned().unwrap_or(Value::Null);
1650
- let arr_borrow = arr.borrow();
2482
+ let arr_borrow = arr.borrow().clone(); // snapshot; drop borrow before callbacks
2483
+ let arr_value = Value::Array(arr.clone()); // JS 3rd callback arg
1651
2484
  // Try simple callback fast path
1652
2485
  let use_simple = arr_borrow.first().map(|v| {
1653
2486
  self.eval_simple_callback(&callback, &[v.clone()]).is_some()
@@ -1662,14 +2495,14 @@ impl Evaluator {
1662
2495
  }
1663
2496
  } else if let Some((scope, params, body)) = self.create_callback_scope(&callback) {
1664
2497
  for (i, v) in arr_borrow.iter().enumerate() {
1665
- let result = self.call_with_scope(&scope, &params, &body, &[v.clone(), Value::Number(i as f64)])?;
2498
+ let result = self.call_with_scope(&scope, &params, &body, &[v.clone(), Value::Number(i as f64), arr_value.clone()])?;
1666
2499
  if !result.is_truthy() {
1667
2500
  return Ok(Value::Bool(false));
1668
2501
  }
1669
2502
  }
1670
2503
  } else {
1671
2504
  for (i, v) in arr_borrow.iter().enumerate() {
1672
- let result = self.call_func(&callback, &[v.clone(), Value::Number(i as f64)])?;
2505
+ let result = self.call_func(&callback, &[v.clone(), Value::Number(i as f64), arr_value.clone()])?;
1673
2506
  if !result.is_truthy() {
1674
2507
  return Ok(Value::Bool(false));
1675
2508
  }
@@ -1807,6 +2640,32 @@ impl Evaluator {
1807
2640
  "trim" => {
1808
2641
  return Ok(Value::String(s.trim().into()));
1809
2642
  }
2643
+ "trimStart" => {
2644
+ return Ok(Value::String(s.trim_start().into()));
2645
+ }
2646
+ "trimEnd" => {
2647
+ return Ok(Value::String(s.trim_end().into()));
2648
+ }
2649
+ "normalize" => {
2650
+ let form = match arg_vals.first() {
2651
+ None | Some(Value::Null) => "NFC".to_string(),
2652
+ Some(Value::String(f)) => f.to_string(),
2653
+ Some(v) => v.to_string(),
2654
+ };
2655
+ match tishlang_builtins::string::normalize_form(s, &form) {
2656
+ Some(out) => return Ok(Value::String(out.into())),
2657
+ None => {
2658
+ let err = crate::natives::range_error_construct(&[
2659
+ Value::String(
2660
+ "The normalization form should be one of NFC, NFD, NFKC, NFKD."
2661
+ .into(),
2662
+ ),
2663
+ ])
2664
+ .unwrap_or(Value::Null);
2665
+ return Err(EvalError::Throw(err));
2666
+ }
2667
+ }
2668
+ }
1810
2669
  "toUpperCase" => {
1811
2670
  return Ok(Value::String(s.to_uppercase().into()));
1812
2671
  }
@@ -1818,14 +2677,28 @@ impl Evaluator {
1818
2677
  Some(Value::String(ss)) => ss.as_ref(),
1819
2678
  _ => return Ok(Value::Bool(false)),
1820
2679
  };
1821
- return Ok(Value::Bool(s.starts_with(search)));
2680
+ // position: test as if the string began at char `position`.
2681
+ let pos = match arg_vals.get(1) {
2682
+ Some(Value::Number(n)) if *n > 0.0 => *n as usize,
2683
+ _ => 0,
2684
+ };
2685
+ let byte_start = s.char_indices().nth(pos).map(|(b, _)| b).unwrap_or(s.len());
2686
+ return Ok(Value::Bool(s[byte_start..].starts_with(search)));
1822
2687
  }
1823
2688
  "endsWith" => {
1824
2689
  let search = match arg_vals.first() {
1825
2690
  Some(Value::String(ss)) => ss.as_ref(),
1826
2691
  _ => return Ok(Value::Bool(false)),
1827
2692
  };
1828
- return Ok(Value::Bool(s.ends_with(search)));
2693
+ // endPosition: test as if the string ended at char `endPosition`.
2694
+ let char_count = s.chars().count();
2695
+ let end = match arg_vals.get(1) {
2696
+ Some(Value::Number(n)) if *n >= 0.0 => (*n as usize).min(char_count),
2697
+ Some(Value::Number(_)) => 0,
2698
+ _ => char_count,
2699
+ };
2700
+ let byte_end = s.char_indices().nth(end).map(|(b, _)| b).unwrap_or(s.len());
2701
+ return Ok(Value::Bool(s[..byte_end].ends_with(search)));
1829
2702
  }
1830
2703
  "replace" => {
1831
2704
  #[cfg(feature = "regex")]
@@ -1865,6 +2738,17 @@ impl Evaluator {
1865
2738
  Some(Value::String(ss)) => ss.to_string(),
1866
2739
  _ => String::new(),
1867
2740
  };
2741
+ if replacement.contains('$') && !search.is_empty() {
2742
+ let sref = s.as_ref();
2743
+ if let Some(m) = sref.find(&search) {
2744
+ let mut out = String::with_capacity(sref.len());
2745
+ out.push_str(&sref[..m]);
2746
+ out.push_str(&tishlang_builtins::string::expand_replacement(
2747
+ &replacement, &search, &sref[..m], &sref[m + search.len()..]));
2748
+ out.push_str(&sref[m + search.len()..]);
2749
+ return Ok(Value::String(out.into()));
2750
+ }
2751
+ }
1868
2752
  return Ok(Value::String(s.replacen(&search, &replacement, 1).into()));
1869
2753
  }
1870
2754
  #[cfg(feature = "regex")]
@@ -1879,15 +2763,30 @@ impl Evaluator {
1879
2763
  Some(Value::String(ss)) => ss.to_string(),
1880
2764
  _ => String::new(),
1881
2765
  };
2766
+ if replacement.contains('$') && !search.is_empty() {
2767
+ let sref = s.as_ref();
2768
+ let mut out = String::with_capacity(sref.len());
2769
+ let (mut last, mut start) = (0usize, 0usize);
2770
+ while let Some(pos) = sref[start..].find(&search) {
2771
+ let m = start + pos;
2772
+ out.push_str(&sref[last..m]);
2773
+ out.push_str(&tishlang_builtins::string::expand_replacement(
2774
+ &replacement, &search, &sref[..m], &sref[m + search.len()..]));
2775
+ last = m + search.len();
2776
+ start = last;
2777
+ }
2778
+ out.push_str(&sref[last..]);
2779
+ return Ok(Value::String(out.into()));
2780
+ }
1882
2781
  return Ok(Value::String(s.replace(&search, &replacement).into()));
1883
2782
  }
1884
2783
  "charAt" => {
2784
+ // Cursor cache instead of collecting a fresh Vec<char> each call (#203).
1885
2785
  let idx = match arg_vals.first() {
1886
2786
  Some(Value::Number(n)) => *n as usize,
1887
2787
  _ => 0,
1888
2788
  };
1889
- let chars: Vec<char> = s.chars().collect();
1890
- return Ok(chars.get(idx)
2789
+ return Ok(nth_char_cached(s, idx)
1891
2790
  .map(|c| Value::String(c.to_string().into()))
1892
2791
  .unwrap_or(Value::String("".into())));
1893
2792
  }
@@ -1897,11 +2796,11 @@ impl Evaluator {
1897
2796
  Some(Value::Number(n)) => *n as i64,
1898
2797
  _ => 0,
1899
2798
  };
1900
- let chars: Vec<char> = s.chars().collect();
1901
- let len = chars.len() as i64;
1902
- let idx = if i < 0 { len + i } else { i };
1903
- if idx >= 0 && idx < len {
1904
- return Ok(Value::String(chars[idx as usize].to_string().into()));
2799
+ let idx = if i < 0 { s.chars().count() as i64 + i } else { i };
2800
+ if idx >= 0 {
2801
+ if let Some(c) = nth_char_cached(s, idx as usize) {
2802
+ return Ok(Value::String(c.to_string().into()));
2803
+ }
1905
2804
  }
1906
2805
  return Ok(Value::Null);
1907
2806
  }
@@ -1910,11 +2809,39 @@ impl Evaluator {
1910
2809
  Some(Value::Number(n)) => *n as usize,
1911
2810
  _ => 0,
1912
2811
  };
1913
- let chars: Vec<char> = s.chars().collect();
1914
- return Ok(chars.get(idx)
1915
- .map(|c| Value::Number(*c as u32 as f64))
2812
+ return Ok(nth_char_cached(s, idx)
2813
+ .map(|c| Value::Number(c as u32 as f64))
1916
2814
  .unwrap_or(Value::Number(f64::NAN)));
1917
2815
  }
2816
+ "codePointAt" => {
2817
+ let idx = match arg_vals.first() {
2818
+ Some(Value::Number(n)) if *n >= 0.0 => *n as usize,
2819
+ _ => return Ok(Value::Null),
2820
+ };
2821
+ return Ok(nth_char_cached(s, idx)
2822
+ .map(|c| Value::Number(c as u32 as f64))
2823
+ .unwrap_or(Value::Null));
2824
+ }
2825
+ "substr" => {
2826
+ // (interp lacked substr; vm/native had it — this closes the gap.)
2827
+ let chars: Vec<char> = s.chars().collect();
2828
+ let len = chars.len() as i64;
2829
+ let mut start = match arg_vals.first() {
2830
+ Some(Value::Number(n)) => *n as i64,
2831
+ _ => 0,
2832
+ };
2833
+ if start < 0 {
2834
+ start = (len + start).max(0);
2835
+ }
2836
+ let start = (start as usize).min(chars.len());
2837
+ let count = match arg_vals.get(1) {
2838
+ Some(Value::Number(n)) => (*n as i64).max(0) as usize,
2839
+ _ => chars.len() - start,
2840
+ };
2841
+ let out: String =
2842
+ chars[start..(start + count).min(chars.len())].iter().collect();
2843
+ return Ok(Value::String(out.into()));
2844
+ }
1918
2845
  "repeat" => {
1919
2846
  let count = match arg_vals.first() {
1920
2847
  Some(Value::Number(n)) if *n >= 0.0 => *n as usize,
@@ -1964,6 +2891,35 @@ impl Evaluator {
1964
2891
  return Ok(Value::Null);
1965
2892
  }
1966
2893
  #[cfg(feature = "regex")]
2894
+ "matchAll" => {
2895
+ let regexp = arg_vals.first().cloned().unwrap_or(Value::Null);
2896
+ match crate::regex::string_match_all(s, &regexp) {
2897
+ Ok(matches) => {
2898
+ let core_matches: Vec<tishlang_core::Value> = matches
2899
+ .iter()
2900
+ .map(|m| {
2901
+ crate::value_convert::eval_to_core(m)
2902
+ .unwrap_or(tishlang_core::Value::Null)
2903
+ })
2904
+ .collect();
2905
+ let it = tishlang_builtins::iterator::array_iterator(
2906
+ core_matches,
2907
+ );
2908
+ return Ok(crate::value_convert::core_to_eval(it));
2909
+ }
2910
+ Err(()) => {
2911
+ let err = crate::natives::type_error_construct(&[
2912
+ Value::String(
2913
+ "String.prototype.matchAll called with a non-global RegExp argument"
2914
+ .into(),
2915
+ ),
2916
+ ])
2917
+ .unwrap_or(Value::Null);
2918
+ return Err(EvalError::Throw(err));
2919
+ }
2920
+ }
2921
+ }
2922
+ #[cfg(feature = "regex")]
1967
2923
  "search" => {
1968
2924
  if let Some(regexp) = arg_vals.first() {
1969
2925
  return Ok(crate::regex::string_search(s, regexp));
@@ -1989,6 +2945,40 @@ impl Evaluator {
1989
2945
  let formatted = tishlang_builtins::number::to_fixed_str(*n, digits as usize);
1990
2946
  return Ok(Value::String(formatted.into()));
1991
2947
  }
2948
+ if method_name.as_ref() == "toExponential" {
2949
+ let digits = arg_vals.first().and_then(|v| match v {
2950
+ Value::Number(d) => Some((*d as i32).clamp(0, 100) as usize),
2951
+ _ => None,
2952
+ });
2953
+ return Ok(Value::String(
2954
+ tishlang_builtins::number::to_exponential_str(*n, digits).into(),
2955
+ ));
2956
+ }
2957
+ if method_name.as_ref() == "toPrecision" {
2958
+ match arg_vals.first() {
2959
+ Some(Value::Number(p)) => {
2960
+ let pi = *p as i32;
2961
+ if !(1..=100).contains(&pi) && n.is_finite() {
2962
+ let err = crate::natives::range_error_construct(&[
2963
+ Value::String(
2964
+ "toPrecision() argument must be between 1 and 100"
2965
+ .into(),
2966
+ ),
2967
+ ])
2968
+ .unwrap_or(Value::Null);
2969
+ return Err(EvalError::Throw(err));
2970
+ }
2971
+ return Ok(Value::String(
2972
+ tishlang_builtins::number::to_precision_str(*n, pi).into(),
2973
+ ));
2974
+ }
2975
+ _ => {
2976
+ return Ok(Value::String(
2977
+ tishlang_core::js_number_to_string(*n).into(),
2978
+ ))
2979
+ }
2980
+ }
2981
+ }
1992
2982
  if method_name.as_ref() == "toString" {
1993
2983
  // Shares the VM/native formatting via the backend-agnostic helper
1994
2984
  // (issue #59). Radix defaults to 10; 2–36 supported, else RangeError.
@@ -2158,6 +3148,9 @@ impl Evaluator {
2158
3148
  }
2159
3149
  Expr::Assign { name, value, .. } => {
2160
3150
  let v = self.eval_expr(value)?;
3151
+ // A plain assignment overwrites the variable, so drop any builder buffering it (the
3152
+ // buffered value is about to be replaced). `value` may itself have read+flushed it.
3153
+ self.discard_pending_for(name.as_ref());
2161
3154
  match self.scope.borrow_mut().assign(name.as_ref(), v.clone()) {
2162
3155
  Ok(true) => Ok(v),
2163
3156
  Ok(false) => Err(EvalError::Error(format!("Undefined variable: {}", name))),
@@ -2306,6 +3299,10 @@ impl Evaluator {
2306
3299
  }
2307
3300
  }
2308
3301
  Expr::CompoundAssign { name, op, value, .. } => {
3302
+ // Expression-position `+=` (result is used): flush any builder so `current` is the
3303
+ // full string, then take the generic path. The O(1) builder fast path is reserved
3304
+ // for statement position (see `eval_expr_discard`).
3305
+ self.flush_pending_for(name.as_ref());
2309
3306
  let current = self.scope.borrow().get(name.as_ref())
2310
3307
  .ok_or_else(|| EvalError::Error(format!("Undefined variable: {}", name)))?;
2311
3308
  let rhs = self.eval_expr(value)?;
@@ -2324,6 +3321,7 @@ impl Evaluator {
2324
3321
  }
2325
3322
  }
2326
3323
  Expr::LogicalAssign { name, op, value, .. } => {
3324
+ self.flush_pending_for(name.as_ref());
2327
3325
  let current = self.scope.borrow().get(name.as_ref())
2328
3326
  .ok_or_else(|| EvalError::Error(format!("Undefined variable: {}", name)))?;
2329
3327
  let result = match op {
@@ -2394,6 +3392,12 @@ impl Evaluator {
2394
3392
  Value::Array(arr) => {
2395
3393
  let idx = match &idx_val {
2396
3394
  Value::Number(n) => *n as usize,
3395
+ // Canonical integer string key → index (#432); a non-index string key
3396
+ // can't live in a Vec-backed array, so the write is a no-op.
3397
+ Value::String(s) => match tishlang_core::str_to_array_index(s) {
3398
+ Some(i) => i,
3399
+ None => return Ok(val),
3400
+ },
2397
3401
  _ => return Err(EvalError::Error(format!(
2398
3402
  "Array index must be a number, got {:?}",
2399
3403
  idx_val
@@ -2750,6 +3754,9 @@ impl Evaluator {
2750
3754
  module_cache: Rc::clone(&self.module_cache),
2751
3755
  current_dir: RefCell::new(self.current_dir.borrow().clone()),
2752
3756
  virtual_builtins: Rc::clone(&self.virtual_builtins),
3757
+ string_builder: Rc::clone(&self.string_builder),
3758
+ call_depth: Rc::clone(&self.call_depth),
3759
+ max_call_depth: self.max_call_depth,
2753
3760
  };
2754
3761
  match eval.eval_statement(body) {
2755
3762
  Ok(v) => Ok(v),
@@ -2901,7 +3908,31 @@ impl Evaluator {
2901
3908
  }
2902
3909
  Err(EvalError::Error("Not a function".to_string()))
2903
3910
  }
2904
- Value::Native(native_fn) => native_fn(args).map_err(EvalError::Error),
3911
+ Value::Native(native_fn) => {
3912
+ // #122: Node fs callback form — `readFile(path, (err, data) => …)`. The self-less
3913
+ // `Value::Native` bridge can't invoke an eval callback (a core `Callable` can't
3914
+ // re-enter the interpreter), so handle it here where `&self` is available: run the
3915
+ // op's core on the non-callback args, split its Result into `(err, data)`, and call
3916
+ // the callback. Matches the VM/native backends (fs_ext runs the callback in-core).
3917
+ #[cfg(feature = "fs")]
3918
+ if let Some(cb @ Value::Function { .. }) = args.last() {
3919
+ if let Some(core_op) = natives::fsx::callback_core(*native_fn) {
3920
+ let cb = cb.clone();
3921
+ let core_args = args[..args.len() - 1]
3922
+ .iter()
3923
+ .map(crate::value_convert::eval_to_core)
3924
+ .collect::<Result<Vec<_>, String>>()
3925
+ .map_err(EvalError::Error)?;
3926
+ let (err, data) = match core_op(&core_args) {
3927
+ Ok(v) => (Value::Null, crate::value_convert::core_to_eval(v)),
3928
+ Err(e) => (crate::value_convert::core_to_eval(e), Value::Null),
3929
+ };
3930
+ self.call_func(&cb, &[err, data])?;
3931
+ return Ok(Value::Null);
3932
+ }
3933
+ }
3934
+ native_fn(args).map_err(EvalError::Error)
3935
+ }
2905
3936
  #[cfg(feature = "http")]
2906
3937
  Value::PromiseResolver(r) => {
2907
3938
  let value = args.first().cloned().unwrap_or(Value::Null);
@@ -3018,6 +4049,9 @@ impl Evaluator {
3018
4049
  module_cache: Rc::clone(&self.module_cache),
3019
4050
  current_dir: RefCell::new(self.current_dir.borrow().clone()),
3020
4051
  virtual_builtins: Rc::clone(&self.virtual_builtins),
4052
+ string_builder: Rc::clone(&self.string_builder),
4053
+ call_depth: Rc::clone(&self.call_depth),
4054
+ max_call_depth: self.max_call_depth,
3021
4055
  };
3022
4056
  {
3023
4057
  let mut s = scope.borrow_mut();
@@ -3074,6 +4108,20 @@ impl Evaluator {
3074
4108
  // VM's single `run_chunk` re-entry. 128 KiB is smaller than one level's chain, so the
3075
4109
  // stack overflows BETWEEN checks; 1 MiB comfortably covers a level (verified to depth
3076
4110
  // 20000 in both debug and release). 16 MiB segments keep grow frequency low.
4111
+ // #381: bound recursion with a catchable `RangeError` instead of letting `stacker`
4112
+ // grow the stack toward OOM/abort. The counter is shared (`Rc`) across every call
4113
+ // frame, so it measures true nesting depth; it is decremented on the way out (both
4114
+ // the Ok and Err paths) via the explicit `set` below so a caught throw doesn't leak
4115
+ // depth.
4116
+ let depth = eval.call_depth.get() + 1;
4117
+ if depth > eval.max_call_depth {
4118
+ let err = crate::natives::range_error_construct(&[Value::String(
4119
+ "Maximum call stack size exceeded".into(),
4120
+ )])
4121
+ .unwrap_or(Value::Null);
4122
+ return Err(EvalError::Throw(err));
4123
+ }
4124
+ eval.call_depth.set(depth);
3077
4125
  let body_result = {
3078
4126
  #[cfg(not(target_arch = "wasm32"))]
3079
4127
  {
@@ -3086,6 +4134,7 @@ impl Evaluator {
3086
4134
  eval.eval_statement(body)
3087
4135
  }
3088
4136
  };
4137
+ eval.call_depth.set(depth - 1);
3089
4138
  match body_result {
3090
4139
  Ok(v) => Ok(v),
3091
4140
  Err(EvalError::Return(v)) => Ok(v),
@@ -3511,6 +4560,15 @@ impl Evaluator {
3511
4560
  /// `.entries()` — into a `Vec` by calling `next()` until `done`. Returns `None` when `v`
3512
4561
  /// is not such an object. Shared by `for…of` and spread so both treat iterators like JS.
3513
4562
  fn drain_eval_iterator(&self, v: &Value) -> Option<Vec<Value>> {
4563
+ // A string spreads as its characters (`[...s]`, `f(...s)`); for-of over a string is handled
4564
+ // separately at the loop site, but spread routes through here. (#440)
4565
+ if let Value::String(s) = v {
4566
+ return Some(
4567
+ s.chars()
4568
+ .map(|c| Value::String(std::sync::Arc::from(c.to_string())))
4569
+ .collect(),
4570
+ );
4571
+ }
3514
4572
  if !matches!(v, Value::Object(_)) {
3515
4573
  return None;
3516
4574
  }
@@ -3566,7 +4624,7 @@ impl Evaluator {
3566
4624
  }
3567
4625
  Value::String(s) => {
3568
4626
  if key == "length" {
3569
- Ok(Value::Number(s.chars().count() as f64))
4627
+ Ok(Value::Number(char_count_cached(s) as f64))
3570
4628
  } else {
3571
4629
  Ok(Value::Null)
3572
4630
  }
@@ -3623,6 +4681,11 @@ impl Evaluator {
3623
4681
  _ => Ok(Value::Null),
3624
4682
  }
3625
4683
  }
4684
+ // Reading a property of the nullish value throws a catchable `TypeError`, matching the
4685
+ // bytecode VM (`get_member`'s `_` arm), cranelift/wasi, and node — not a silent `null`.
4686
+ // The tree-walker used to fall through to `_ => Ok(Value::Null)`, so `null.length` read
4687
+ // back as `null` on interp while every other backend threw (a pure interp≠vm bug).
4688
+ Value::Null => Err(format!("Cannot read property '{}' of null", key)),
3626
4689
  _ => Ok(Value::Null),
3627
4690
  }
3628
4691
  }
@@ -3632,6 +4695,12 @@ impl Evaluator {
3632
4695
  Value::Array(arr) => {
3633
4696
  let idx = match index {
3634
4697
  Value::Number(n) => *n as usize,
4698
+ // A canonical integer string key ("0","1",…) indexes the array (#432), so
4699
+ // `arr["0"] === arr[0]`; any other key reads Null.
4700
+ Value::String(s) => match tishlang_core::str_to_array_index(s) {
4701
+ Some(i) => i,
4702
+ None => return Ok(Value::Null),
4703
+ },
3635
4704
  _ => return Ok(Value::Null),
3636
4705
  };
3637
4706
  Ok(arr.borrow().get(idx).cloned().unwrap_or(Value::Null))
@@ -3644,9 +4713,7 @@ impl Evaluator {
3644
4713
  Value::Number(n) if *n >= 0.0 && n.fract() == 0.0 => *n as usize,
3645
4714
  _ => return Ok(Value::Null),
3646
4715
  };
3647
- Ok(s
3648
- .chars()
3649
- .nth(idx)
4716
+ Ok(nth_char_cached(s, idx)
3650
4717
  .map(|c| Value::String(c.to_string().into()))
3651
4718
  .unwrap_or(Value::Null))
3652
4719
  }
@@ -3659,6 +4726,9 @@ impl Evaluator {
3659
4726
  };
3660
4727
  self.get_prop(obj, key)
3661
4728
  }
4729
+ // Indexing the nullish value throws a catchable `TypeError` (like `get_prop` above and
4730
+ // the VM/cranelift/wasi/node) rather than silently reading back `null`.
4731
+ Value::Null => Err(format!("Cannot read property '{}' of null", index)),
3662
4732
  _ => Ok(Value::Null),
3663
4733
  }
3664
4734
  }
@@ -3677,8 +4747,13 @@ impl Evaluator {
3677
4747
  }
3678
4748
  }
3679
4749
 
3680
- fn json_stringify_value(v: &Value) -> String {
3681
- match v {
4750
+ /// #381 — ancestor-guarded like `tishlang_core::json_stringify_into_guarded`: a back-edge to an
4751
+ /// ancestor (`a.self = a`) is a cycle and returns `Err(())` instead of recursing forever (this
4752
+ /// was an unguarded native-stack overflow → uncatchable abort; core's guard from #389 never
4753
+ /// covered the interpreter's own stringifier). Ancestor-path only, not all-visited: a node
4754
+ /// repeated across sibling branches is a legal DAG and must serialize twice.
4755
+ fn json_stringify_value(v: &Value, ancestors: &mut Vec<*const ()>) -> Result<String, ()> {
4756
+ Ok(match v {
3682
4757
  Value::Null => "null".to_string(),
3683
4758
  Value::Bool(b) => b.to_string(),
3684
4759
  Value::Number(n) => {
@@ -3689,37 +4764,50 @@ impl Evaluator {
3689
4764
  tishlang_core::write_json_number(&mut s, *n);
3690
4765
  s
3691
4766
  }
3692
- Value::String(s) => format!(
3693
- "\"{}\"",
3694
- s.replace('\\', "\\\\")
3695
- .replace('"', "\\\"")
3696
- .replace('\n', "\\n")
3697
- .replace('\r', "\\r")
3698
- .replace('\t', "\\t")
3699
- ),
4767
+ Value::String(s) => {
4768
+ // Use the shared core escaper so control chars (`\b`, `\f`, `\u00xx`) are escaped
4769
+ // exactly like the vm/native path — the old hand-rolled `.replace()` chain missed
4770
+ // them and produced invalid JSON (raw control bytes). #437
4771
+ let mut out = String::with_capacity(s.len() + 2);
4772
+ out.push('"');
4773
+ tishlang_core::escape_json_string_into(&mut out, s);
4774
+ out.push('"');
4775
+ out
4776
+ }
3700
4777
  Value::Array(arr) => {
3701
- let inner: Vec<String> = arr
3702
- .borrow()
3703
- .iter()
3704
- .map(Self::json_stringify_value)
3705
- .collect();
4778
+ let ptr = Rc::as_ptr(arr) as *const ();
4779
+ if ancestors.contains(&ptr) {
4780
+ return Err(());
4781
+ }
4782
+ ancestors.push(ptr);
4783
+ let borrowed = arr.borrow();
4784
+ let mut inner: Vec<String> = Vec::with_capacity(borrowed.len());
4785
+ for item in borrowed.iter() {
4786
+ inner.push(Self::json_stringify_value(item, ancestors)?);
4787
+ }
4788
+ drop(borrowed);
4789
+ ancestors.pop();
3706
4790
  format!("[{}]", inner.join(","))
3707
4791
  }
3708
4792
  Value::Object(map) => {
4793
+ let ptr = Rc::as_ptr(map) as *const ();
4794
+ if ancestors.contains(&ptr) {
4795
+ return Err(());
4796
+ }
4797
+ ancestors.push(ptr);
3709
4798
  // Insertion order (PropMap is an IndexMap) — matches JS/Node and the
3710
4799
  // VM/rust backends. No key sort.
3711
- let entries: Vec<String> = map
3712
- .borrow()
3713
- .strings
3714
- .iter()
3715
- .map(|(k, v)| {
3716
- format!(
3717
- "\"{}\":{}",
3718
- k.replace('\\', "\\\\").replace('"', "\\\""),
3719
- Self::json_stringify_value(v)
3720
- )
3721
- })
3722
- .collect();
4800
+ let borrowed = map.borrow();
4801
+ let mut entries: Vec<String> = Vec::with_capacity(borrowed.strings.len());
4802
+ for (k, v) in borrowed.strings.iter() {
4803
+ entries.push(format!(
4804
+ "\"{}\":{}",
4805
+ k.replace('\\', "\\\\").replace('"', "\\\""),
4806
+ Self::json_stringify_value(v, ancestors)?
4807
+ ));
4808
+ }
4809
+ drop(borrowed);
4810
+ ancestors.pop();
3723
4811
  format!("{{{}}}", entries.join(","))
3724
4812
  }
3725
4813
  Value::Symbol(_) => "null".to_string(),
@@ -3738,7 +4826,7 @@ impl Evaluator {
3738
4826
  #[cfg(feature = "regex")]
3739
4827
  Value::RegExp(_) => "null".to_string(),
3740
4828
  Value::Opaque(_) | Value::OpaqueMethod(_, _) => "null".to_string(),
3741
- }
4829
+ })
3742
4830
  }
3743
4831
 
3744
4832
  // Static native wrapper functions (these need to be fn pointers, not closures with &self)
@@ -3749,20 +4837,36 @@ impl Evaluator {
3749
4837
 
3750
4838
  fn json_stringify_native(args: &[Value]) -> Result<Value, String> {
3751
4839
  let v = args.first().cloned().unwrap_or(Value::Null);
3752
- Ok(Value::String(Self::json_stringify_value(&v).into()))
4840
+ let mut ancestors: Vec<*const ()> = Vec::new();
4841
+ match Self::json_stringify_value(&v, &mut ancestors) {
4842
+ Ok(s) => Ok(Value::String(s.into())),
4843
+ // Surfaced by `call_func` as `EvalError::Error`, which the Try handler boxes as
4844
+ // `{ name: "TypeError", message }` — node-identical for the circular case (#381).
4845
+ Err(()) => Err("Converting circular structure to JSON".to_string()),
4846
+ }
3753
4847
  }
3754
4848
 
3755
4849
  fn object_keys(args: &[Value]) -> Result<Value, String> {
3756
- if let Some(Value::Object(obj)) = args.first() {
3757
- let keys: Vec<Value> = obj
3758
- .borrow()
3759
- .strings
3760
- .keys()
3761
- .map(|k| Value::String(Arc::clone(k)))
3762
- .collect();
3763
- Ok(Value::Array(Rc::new(RefCell::new(keys))))
3764
- } else {
3765
- Ok(Value::Array(Rc::new(RefCell::new(Vec::new()))))
4850
+ match args.first() {
4851
+ Some(Value::Object(obj)) => {
4852
+ let keys: Vec<Value> = obj
4853
+ .borrow()
4854
+ .strings
4855
+ .keys()
4856
+ .map(|k| Value::String(Arc::clone(k)))
4857
+ .collect();
4858
+ Ok(Value::Array(Rc::new(RefCell::new(keys))))
4859
+ }
4860
+ // `Object.keys(array)` → index strings "0".."len-1" (own enumerable keys are the indices),
4861
+ // matching node and the shared builtin.
4862
+ Some(Value::Array(arr)) => {
4863
+ let len = arr.borrow().len();
4864
+ let keys: Vec<Value> = (0..len)
4865
+ .map(|i| Value::String(Arc::from(i.to_string())))
4866
+ .collect();
4867
+ Ok(Value::Array(Rc::new(RefCell::new(keys))))
4868
+ }
4869
+ _ => Ok(Value::Array(Rc::new(RefCell::new(Vec::new())))),
3766
4870
  }
3767
4871
  }
3768
4872
 
@@ -4214,3 +5318,126 @@ impl std::fmt::Display for EvalError {
4214
5318
  }
4215
5319
 
4216
5320
  impl std::error::Error for EvalError {}
5321
+
5322
+ #[cfg(test)]
5323
+ mod recursion_limit_tests_381 {
5324
+ use super::Evaluator;
5325
+ use tishlang_parser::parse;
5326
+
5327
+ fn run_with_depth(src: &str, max_depth: usize) -> String {
5328
+ let program = parse(src).unwrap();
5329
+ let mut eval = Evaluator::new();
5330
+ eval.max_call_depth = max_depth;
5331
+ eval.eval_program(&program).unwrap().to_string()
5332
+ }
5333
+
5334
+ #[test]
5335
+ fn deep_recursion_throws_catchable_range_error() {
5336
+ // Infinite (non-tail) recursion past the limit must throw a CATCHABLE RangeError, not abort:
5337
+ // try/catch recovers and the program keeps running.
5338
+ let src = "fn rec(n) { return 1 + rec(n + 1) }\n\
5339
+ let name = 'none'\n\
5340
+ try { rec(0) } catch (e) { name = e.name }\n\
5341
+ name";
5342
+ assert_eq!(run_with_depth(src, 200), "RangeError");
5343
+ }
5344
+
5345
+ #[test]
5346
+ fn normal_recursion_is_unaffected() {
5347
+ let src = "fn fib(n) { if (n < 2) { return n } return fib(n - 1) + fib(n - 2) }\nfib(12)";
5348
+ assert_eq!(run_with_depth(src, 20000), "144");
5349
+ }
5350
+ }
5351
+
5352
+ #[cfg(test)]
5353
+ mod null_member_access_tests {
5354
+ // Reading a property or index of the nullish value throws a catchable `TypeError`, matching the
5355
+ // bytecode VM / cranelift / wasi / node. The tree-walker used to fall through to `Ok(Null)`, so
5356
+ // `null.length` read back as `null` on interp while every other backend threw (a pure interp≠vm
5357
+ // divergence). These lock the interpreter to the throwing behavior.
5358
+ use super::Evaluator;
5359
+ use tishlang_parser::parse;
5360
+
5361
+ fn run(src: &str) -> String {
5362
+ let program = parse(src).unwrap();
5363
+ let mut eval = Evaluator::new();
5364
+ eval.eval_program(&program).unwrap().to_string()
5365
+ }
5366
+
5367
+ #[test]
5368
+ fn null_property_read_throws_type_error() {
5369
+ let src = "let name = 'none'\n\
5370
+ try { let z = null; z.length } catch (e) { name = e.name }\n\
5371
+ name";
5372
+ assert_eq!(run(src), "TypeError");
5373
+ }
5374
+
5375
+ #[test]
5376
+ fn null_index_read_throws_type_error() {
5377
+ let src = "let name = 'none'\n\
5378
+ try { let z = null; z[0] } catch (e) { name = e.name }\n\
5379
+ name";
5380
+ assert_eq!(run(src), "TypeError");
5381
+ }
5382
+
5383
+ #[test]
5384
+ fn object_missing_property_still_reads_null() {
5385
+ // Guard against over-reach: a MISSING property of a real object is `null`, not a throw.
5386
+ let src = "let o = { a: 1 }\nString(o.b === null)";
5387
+ assert_eq!(run(src), "true");
5388
+ }
5389
+
5390
+ #[test]
5391
+ fn array_oob_index_still_reads_null() {
5392
+ // Out-of-bounds array index stays nullish (only a null/undefined *receiver* throws).
5393
+ let src = "let a = [1, 2, 3]\nString(a[9] === null)";
5394
+ assert_eq!(run(src), "true");
5395
+ }
5396
+ }
5397
+
5398
+ #[cfg(test)]
5399
+ mod array_hof_callback_arg_tests {
5400
+ // Array HOFs pass the source array as the trailing callback arg (JS `(element, index, array)`,
5401
+ // reduce `(acc, element, index, array)`). The inline interp loops snapshot the backing store and
5402
+ // drop the borrow before any callback, so a callback that MUTATES the array via the `array` arg
5403
+ // (or a capture) can't RefCell-panic — it iterates the pre-call snapshot, matching JS + the shared
5404
+ // #382 builtins path.
5405
+ use super::Evaluator;
5406
+ use tishlang_parser::parse;
5407
+
5408
+ fn run(src: &str) -> String {
5409
+ let program = parse(src).unwrap();
5410
+ let mut eval = Evaluator::new();
5411
+ eval.eval_program(&program).unwrap().to_string()
5412
+ }
5413
+
5414
+ #[test]
5415
+ fn map_receives_array_as_third_arg() {
5416
+ assert_eq!(run("[5, 3, 8].map((x, i, arr) => arr.length).join(',')"), "3,3,3");
5417
+ }
5418
+
5419
+ #[test]
5420
+ fn reduce_receives_array_as_fourth_arg() {
5421
+ assert_eq!(run("[1, 2, 3].reduce((a, x, i, arr) => a + arr.length, 0)"), "9");
5422
+ }
5423
+
5424
+ #[test]
5425
+ fn callback_may_index_the_array_arg() {
5426
+ assert_eq!(run("[10, 20, 30].map((x, i, arr) => arr[i] * 2).join(',')"), "20,40,60");
5427
+ }
5428
+
5429
+ #[test]
5430
+ fn callback_mutating_the_array_arg_does_not_panic() {
5431
+ // Pre-snapshot this RefCell-panicked (map held the borrow across the loop). Now iteration is
5432
+ // over the snapshot (3 elements); the 3 pushes land on the live array → final length 6, matching
5433
+ // JS forEach semantics (does not visit elements appended during iteration).
5434
+ let src = "let a = [1, 2, 3]\na.forEach((x, i, arr) => { arr.push(x) })\na.length";
5435
+ assert_eq!(run(src), "6");
5436
+ }
5437
+
5438
+ #[test]
5439
+ fn one_and_two_arg_callbacks_unchanged() {
5440
+ assert_eq!(run("[5, 3, 8].map(x => x * 2).join(',')"), "10,6,16");
5441
+ assert_eq!(run("[5, 3, 8].map((x, i) => x + i).join(',')"), "5,4,10");
5442
+ }
5443
+ }