@tishlang/tish 2.12.0 → 2.16.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/Cargo.toml +3 -0
  2. package/bin/tish +0 -0
  3. package/crates/tish/Cargo.toml +1 -1
  4. package/crates/tish/tests/integration_test.rs +80 -0
  5. package/crates/tish_ast/src/ast.rs +10 -0
  6. package/crates/tish_builtins/src/array.rs +529 -56
  7. package/crates/tish_builtins/src/collections.rs +114 -40
  8. package/crates/tish_builtins/src/string.rs +95 -8
  9. package/crates/tish_bytecode/src/chunk.rs +7 -0
  10. package/crates/tish_bytecode/src/compiler.rs +560 -64
  11. package/crates/tish_bytecode/src/lib.rs +1 -1
  12. package/crates/tish_bytecode/src/opcode.rs +154 -2
  13. package/crates/tish_bytecode/src/serialize.rs +2 -0
  14. package/crates/tish_bytecode/tests/append_local_string_builder.rs +63 -0
  15. package/crates/tish_bytecode/tests/math_unary_intrinsic.rs +47 -0
  16. package/crates/tish_compile/src/codegen.rs +17736 -5269
  17. package/crates/tish_compile/src/infer.rs +1707 -190
  18. package/crates/tish_compile/src/lib.rs +29 -4
  19. package/crates/tish_compile/src/resolve.rs +66 -5
  20. package/crates/tish_compile/src/types.rs +224 -28
  21. package/crates/tish_compile/tests/dump_codegen.rs +21 -0
  22. package/crates/tish_compile/tests/perf_codegen_169_173.rs +8 -17
  23. package/crates/tish_compile/tests/perf_codegen_173_part3.rs +61 -0
  24. package/crates/tish_compile/tests/perf_codegen_174.rs +160 -0
  25. package/crates/tish_compile/tests/perf_codegen_175.rs +190 -0
  26. package/crates/tish_compile/tests/perf_codegen_176.rs +60 -0
  27. package/crates/tish_compile/tests/perf_codegen_177.rs +167 -0
  28. package/crates/tish_compile/tests/perf_codegen_178.rs +114 -0
  29. package/crates/tish_compile/tests/perf_codegen_178_rec.rs +124 -0
  30. package/crates/tish_compile/tests/perf_codegen_181.rs +36 -0
  31. package/crates/tish_compile/tests/perf_codegen_320.rs +211 -0
  32. package/crates/tish_compile/tests/perf_codegen_module_const_forof.rs +40 -0
  33. package/crates/tish_compile_js/src/codegen.rs +33 -0
  34. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +100 -2
  35. package/crates/tish_core/Cargo.toml +4 -0
  36. package/crates/tish_core/src/json.rs +104 -5
  37. package/crates/tish_core/src/lib.rs +174 -0
  38. package/crates/tish_core/src/shape.rs +4 -2
  39. package/crates/tish_core/src/value.rs +565 -35
  40. package/crates/tish_core/src/vmref.rs +14 -0
  41. package/crates/tish_eval/src/eval.rs +675 -76
  42. package/crates/tish_eval/src/natives.rs +19 -0
  43. package/crates/tish_eval/src/value.rs +76 -21
  44. package/crates/tish_ffi/src/lib.rs +11 -1
  45. package/crates/tish_ffi/tests/double_free.rs +35 -0
  46. package/crates/tish_fmt/src/lib.rs +75 -1
  47. package/crates/tish_lexer/src/lib.rs +76 -0
  48. package/crates/tish_lexer/src/token.rs +4 -0
  49. package/crates/tish_lint/src/lib.rs +126 -0
  50. package/crates/tish_lsp/README.md +2 -1
  51. package/crates/tish_lsp/src/main.rs +378 -28
  52. package/crates/tish_native/src/build.rs +41 -0
  53. package/crates/tish_parser/Cargo.toml +4 -0
  54. package/crates/tish_parser/src/lib.rs +27 -0
  55. package/crates/tish_parser/src/parser.rs +302 -20
  56. package/crates/tish_resolve/src/lib.rs +9 -0
  57. package/crates/tish_runtime/Cargo.toml +5 -0
  58. package/crates/tish_runtime/src/http.rs +28 -10
  59. package/crates/tish_runtime/src/http_fetch.rs +150 -1
  60. package/crates/tish_runtime/src/http_prefork.rs +72 -11
  61. package/crates/tish_runtime/src/lib.rs +523 -42
  62. package/crates/tish_runtime/src/timers.rs +49 -2
  63. package/crates/tish_ui/src/jsx.rs +3 -0
  64. package/crates/tish_vm/src/jit.rs +2514 -117
  65. package/crates/tish_vm/src/vm.rs +1227 -182
  66. package/package.json +1 -1
  67. package/platform/darwin-arm64/tish +0 -0
  68. package/platform/darwin-x64/tish +0 -0
  69. package/platform/linux-arm64/tish +0 -0
  70. package/platform/linux-x64/tish +0 -0
  71. package/platform/win32-x64/tish.exe +0 -0
@@ -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 {
@@ -193,6 +311,7 @@ impl Evaluator {
193
311
  process_obj.insert("exit".into(), Value::Native(natives::process_exit));
194
312
  process_obj.insert("cwd".into(), Value::Native(natives::process_cwd));
195
313
  process_obj.insert("exec".into(), Value::Native(natives::process_exec));
314
+ process_obj.insert("execFile".into(), Value::Native(natives::process_exec_file));
196
315
  let argv: Vec<Value> = tishlang_core::process_argv()
197
316
  .into_iter()
198
317
  .map(|s| Value::String(s.into()))
@@ -367,6 +486,9 @@ impl Evaluator {
367
486
  module_cache: Rc::new(RefCell::new(HashMap::new())),
368
487
  current_dir: RefCell::new(None),
369
488
  virtual_builtins: Rc::new(RefCell::new(HashMap::new())),
489
+ string_builder: Rc::new(StringBuilderState::default()),
490
+ call_depth: Rc::new(std::cell::Cell::new(0)),
491
+ max_call_depth: env_max_call_depth(),
370
492
  }
371
493
  }
372
494
 
@@ -401,6 +523,9 @@ impl Evaluator {
401
523
  for stmt in &program.statements {
402
524
  last = self.eval_statement(stmt).map_err(|e| e.to_string())?;
403
525
  }
526
+ // Flush any still-buffered string accumulator so the variable's slot is correct for any
527
+ // post-run observation (timers, REPL, embedders reading scope state).
528
+ self.flush_pending();
404
529
  Ok(last)
405
530
  }
406
531
 
@@ -451,7 +576,11 @@ impl Evaluator {
451
576
  self.bind_destruct_pattern(pattern, &value, *mutable)?;
452
577
  Ok(Value::Null)
453
578
  }
454
- Statement::ExprStmt { expr, .. } => self.eval_expr(expr),
579
+ Statement::ExprStmt { expr, .. } => {
580
+ // Statement position: route through the path that keeps statement-position
581
+ // `acc += x` O(1) (no result materialization) while preserving values otherwise.
582
+ self.eval_expr_discard(expr)
583
+ }
455
584
  Statement::If {
456
585
  cond,
457
586
  then_branch,
@@ -815,6 +944,33 @@ impl Evaluator {
815
944
  let v = self.eval_expr(e)?;
816
945
  self.scope.borrow_mut().set(Arc::from("default"), v, false);
817
946
  }
947
+ // #305: re-export in the running program — load the dep and bind the names locally.
948
+ ExportDeclaration::ReExport {
949
+ specifiers,
950
+ all,
951
+ from,
952
+ ..
953
+ } => {
954
+ let dep_val = self.load_module(from.as_ref())?;
955
+ let dep = match &dep_val {
956
+ Value::Object(m) => m.borrow().clone(),
957
+ _ => return Err(EvalError::Error("Module exports must be object".to_string())),
958
+ };
959
+ if *all {
960
+ for (k, v) in dep.strings.iter() {
961
+ self.scope.borrow_mut().set(Arc::from(k.as_ref()), v.clone(), false);
962
+ }
963
+ }
964
+ for spec in specifiers {
965
+ if let ImportSpecifier::Named { name, alias, .. } = spec {
966
+ let v = dep.strings.get(name.as_ref()).ok_or_else(|| {
967
+ EvalError::Error(format!("Module does not export '{}'", name))
968
+ })?;
969
+ let bind = alias.as_deref().unwrap_or(name.as_ref());
970
+ self.scope.borrow_mut().set(Arc::from(bind), v.clone(), false);
971
+ }
972
+ }
973
+ }
818
974
  }
819
975
  Ok(Value::Null)
820
976
  }
@@ -880,6 +1036,36 @@ impl Evaluator {
880
1036
  self.scope.borrow_mut().set(Arc::from("default"), v, false);
881
1037
  export_names.push("default".to_string());
882
1038
  }
1039
+ // #305: re-export — load the dep, pull the requested (or all) exports into this
1040
+ // module's scope, and re-expose them in `export_names`.
1041
+ ExportDeclaration::ReExport {
1042
+ specifiers,
1043
+ all,
1044
+ from,
1045
+ ..
1046
+ } => {
1047
+ let dep_val = self.load_module(from.as_ref())?;
1048
+ let dep = match &dep_val {
1049
+ Value::Object(m) => m.borrow().clone(),
1050
+ _ => return Err(EvalError::Error("Module exports must be object".to_string())),
1051
+ };
1052
+ if *all {
1053
+ for (k, v) in dep.strings.iter() {
1054
+ self.scope.borrow_mut().set(Arc::from(k.as_ref()), v.clone(), false);
1055
+ export_names.push(k.to_string());
1056
+ }
1057
+ }
1058
+ for spec in specifiers {
1059
+ if let ImportSpecifier::Named { name, alias, .. } = spec {
1060
+ let v = dep.strings.get(name.as_ref()).ok_or_else(|| {
1061
+ EvalError::Error(format!("Module does not export '{}'", name))
1062
+ })?;
1063
+ let bind = alias.as_deref().unwrap_or(name.as_ref());
1064
+ self.scope.borrow_mut().set(Arc::from(bind), v.clone(), false);
1065
+ export_names.push(bind.to_string());
1066
+ }
1067
+ }
1068
+ }
883
1069
  }
884
1070
  } else {
885
1071
  let _ = self.eval_statement(stmt);
@@ -1057,6 +1243,7 @@ impl Evaluator {
1057
1243
  exports.insert("exit".into(), Value::Native(natives::process_exit));
1058
1244
  exports.insert("cwd".into(), Value::Native(natives::process_cwd));
1059
1245
  exports.insert("exec".into(), Value::Native(natives::process_exec));
1246
+ exports.insert("execFile".into(), Value::Native(natives::process_exec_file));
1060
1247
  let argv: Vec<Value> = tishlang_core::process_argv()
1061
1248
  .into_iter()
1062
1249
  .map(|s| Value::String(s.into()))
@@ -1076,6 +1263,7 @@ impl Evaluator {
1076
1263
  process_obj.insert("exit".into(), Value::Native(natives::process_exit));
1077
1264
  process_obj.insert("cwd".into(), Value::Native(natives::process_cwd));
1078
1265
  process_obj.insert("exec".into(), Value::Native(natives::process_exec));
1266
+ process_obj.insert("execFile".into(), Value::Native(natives::process_exec_file));
1079
1267
  process_obj.insert("argv".into(), Value::Array(Rc::new(RefCell::new(argv))));
1080
1268
  process_obj.insert("env".into(), Value::object(env_obj));
1081
1269
  exports.insert(
@@ -1115,6 +1303,177 @@ impl Evaluator {
1115
1303
  })
1116
1304
  }
1117
1305
 
1306
+ // --- string-builder helpers (#186 / string_build): amortized O(1) `acc += x` ---
1307
+
1308
+ /// Append a value to a builder buffer using the exact JS coercion of `eval_binop`'s `+` (string
1309
+ /// operands push their raw chars; everything else goes through `to_js_string`).
1310
+ fn append_value_to_buf(buf: &mut String, v: &Value) {
1311
+ match v {
1312
+ Value::String(s) => buf.push_str(s),
1313
+ other => buf.push_str(&other.to_js_string()),
1314
+ }
1315
+ }
1316
+
1317
+ /// Walk the scope chain from the current scope and return the exact scope that owns `name`.
1318
+ fn find_var_scope(&self, name: &str) -> Option<Rc<std::cell::RefCell<Scope>>> {
1319
+ let mut cur = Rc::clone(&self.scope);
1320
+ loop {
1321
+ if cur.borrow().vars.contains_key(name) {
1322
+ return Some(cur);
1323
+ }
1324
+ let parent = cur.borrow().parent.clone();
1325
+ match parent {
1326
+ Some(p) => cur = p,
1327
+ None => return None,
1328
+ }
1329
+ }
1330
+ }
1331
+
1332
+ /// Flush the active builder (if any) back into its owning scope slot, restoring the invariant
1333
+ /// that the slot holds the variable's true value. No-op when no builder is active.
1334
+ fn flush_pending(&self) {
1335
+ if !self.string_builder.active.get() {
1336
+ return;
1337
+ }
1338
+ if let Some(p) = self.string_builder.pending.borrow_mut().take() {
1339
+ p.scope
1340
+ .borrow_mut()
1341
+ .vars
1342
+ .insert(Arc::clone(&p.name), Value::String(p.buf.into()));
1343
+ }
1344
+ self.string_builder.active.set(false);
1345
+ }
1346
+
1347
+ /// Flush the builder iff it is buffering `name` (which is about to be read). The `active` guard
1348
+ /// keeps this ~free on the hot identifier-read path when no builder exists.
1349
+ #[inline]
1350
+ fn flush_pending_for(&self, name: &str) {
1351
+ if !self.string_builder.active.get() {
1352
+ return;
1353
+ }
1354
+ let hit = self
1355
+ .string_builder
1356
+ .pending
1357
+ .borrow()
1358
+ .as_ref()
1359
+ .is_some_and(|p| p.name.as_ref() == name);
1360
+ if hit {
1361
+ self.flush_pending();
1362
+ }
1363
+ }
1364
+
1365
+ /// Discard the builder iff it is buffering `name` (which is about to be overwritten by a plain
1366
+ /// assignment) — avoids an unnecessary O(n) flush of a value that is about to be replaced.
1367
+ #[inline]
1368
+ fn discard_pending_for(&self, name: &str) {
1369
+ if !self.string_builder.active.get() {
1370
+ return;
1371
+ }
1372
+ let hit = self
1373
+ .string_builder
1374
+ .pending
1375
+ .borrow()
1376
+ .as_ref()
1377
+ .is_some_and(|p| p.name.as_ref() == name);
1378
+ if hit {
1379
+ *self.string_builder.pending.borrow_mut() = None;
1380
+ self.string_builder.active.set(false);
1381
+ }
1382
+ }
1383
+
1384
+ /// Try to handle `name += rhs` as an amortized-O(1) string append. Returns `true` if it was a
1385
+ /// string append (buffer updated); `false` if `name` is not a (mutable) string accumulator, so
1386
+ /// the caller falls back to the generic `+=`. Any builder for a DIFFERENT slot is flushed first;
1387
+ /// keying by the exact owning scope means a shadowing inner `name` never appends onto an outer
1388
+ /// `name`'s buffer.
1389
+ fn try_string_append(&self, name: &Arc<str>, rhs: &Value) -> bool {
1390
+ let owner = match self.find_var_scope(name) {
1391
+ Some(s) => s,
1392
+ None => return false, // undefined → let the generic path raise the error
1393
+ };
1394
+ // Continue an existing builder for this exact slot.
1395
+ if self.string_builder.active.get() {
1396
+ let same = self
1397
+ .string_builder
1398
+ .pending
1399
+ .borrow()
1400
+ .as_ref()
1401
+ .is_some_and(|p| p.name == *name && Rc::ptr_eq(&p.scope, &owner));
1402
+ if same {
1403
+ if let Some(p) = self.string_builder.pending.borrow_mut().as_mut() {
1404
+ Self::append_value_to_buf(&mut p.buf, rhs);
1405
+ }
1406
+ return true;
1407
+ }
1408
+ // A different slot is buffered — flush it before starting a new one.
1409
+ self.flush_pending();
1410
+ }
1411
+ // Start a new builder only if the accumulator currently holds a mutable string.
1412
+ let start = {
1413
+ let owner_ref = owner.borrow();
1414
+ if owner_ref.consts.contains(name.as_ref()) {
1415
+ return false; // `const acc += x` must raise the same error as the generic path
1416
+ }
1417
+ match owner_ref.vars.get(name.as_ref()) {
1418
+ Some(Value::String(a)) => Some(a.clone()),
1419
+ _ => None, // non-string accumulator → numeric/other `+=`
1420
+ }
1421
+ };
1422
+ match start {
1423
+ Some(a) => {
1424
+ let mut buf = String::with_capacity(a.len() + 16);
1425
+ buf.push_str(&a);
1426
+ Self::append_value_to_buf(&mut buf, rhs);
1427
+ *self.string_builder.pending.borrow_mut() = Some(PendingAppend {
1428
+ scope: owner,
1429
+ name: Arc::clone(name),
1430
+ buf,
1431
+ });
1432
+ self.string_builder.active.set(true);
1433
+ true
1434
+ }
1435
+ None => false,
1436
+ }
1437
+ }
1438
+
1439
+ /// Evaluate `expr` in statement position. Special-cases `acc += rhs` so the string-builder never
1440
+ /// has to materialize the assignment's result value — the key to keeping the append O(1). In that
1441
+ /// one case the (discarded) statement value is `null`; every other expression returns its real
1442
+ /// value, preserving block/program last-value semantics.
1443
+ fn eval_expr_discard(&self, expr: &Expr) -> Result<Value, EvalError> {
1444
+ if let Expr::CompoundAssign {
1445
+ name,
1446
+ op: tishlang_ast::CompoundOp::Add,
1447
+ value,
1448
+ ..
1449
+ } = expr
1450
+ {
1451
+ // Evaluate rhs FIRST — it may read `name`, which flushes any active builder.
1452
+ let rhs = self.eval_expr(value)?;
1453
+ if self.try_string_append(name, &rhs) {
1454
+ // Statement-position result is discarded; returning null avoids the O(n) flatten.
1455
+ return Ok(Value::Null);
1456
+ }
1457
+ // Not a string accumulator: generic `+=` (numeric/other) — return its real value.
1458
+ self.flush_pending_for(name);
1459
+ let current = self
1460
+ .scope
1461
+ .borrow()
1462
+ .get(name.as_ref())
1463
+ .ok_or_else(|| EvalError::Error(format!("Undefined variable: {}", name)))?;
1464
+ let result = self
1465
+ .eval_binop(&current, BinOp::Add, &rhs)
1466
+ .map_err(EvalError::Error)?;
1467
+ match self.scope.borrow_mut().assign(name.as_ref(), result.clone()) {
1468
+ Ok(true) => Ok(result),
1469
+ Ok(false) => Err(EvalError::Error(format!("Undefined variable: {}", name))),
1470
+ Err(e) => Err(EvalError::Error(e)),
1471
+ }
1472
+ } else {
1473
+ self.eval_expr(expr)
1474
+ }
1475
+ }
1476
+
1118
1477
  fn eval_expr(&self, expr: &Expr) -> Result<Value, EvalError> {
1119
1478
  match expr {
1120
1479
  Expr::Literal { value, .. } => Ok(match value {
@@ -1123,11 +1482,14 @@ impl Evaluator {
1123
1482
  Literal::Bool(b) => Value::Bool(*b),
1124
1483
  Literal::Null => Value::Null,
1125
1484
  }),
1126
- Expr::Ident { name, .. } => self
1127
- .scope
1128
- .borrow()
1129
- .get(name.as_ref())
1130
- .ok_or_else(|| EvalError::Error(format!("Undefined variable: {}", name))),
1485
+ Expr::Ident { name, .. } => {
1486
+ // Flush any string-builder buffering this variable so the read sees the full string.
1487
+ self.flush_pending_for(name.as_ref());
1488
+ self.scope
1489
+ .borrow()
1490
+ .get(name.as_ref())
1491
+ .ok_or_else(|| EvalError::Error(format!("Undefined variable: {}", name)))
1492
+ }
1131
1493
  Expr::Binary {
1132
1494
  left,
1133
1495
  op,
@@ -1313,27 +1675,45 @@ impl Evaluator {
1313
1675
  let len = arr_mut.len();
1314
1676
  let mut indices: Vec<usize> = (0..len).collect();
1315
1677
  let arr_values: Vec<Value> = std::mem::take(&mut *arr_mut);
1678
+ // A `throw` from the comparator must propagate (be catchable) and
1679
+ // must NOT corrupt the array. Previously the `_ => Equal` arm
1680
+ // swallowed the `Err` and wrote a bogus reordering back; now we
1681
+ // capture the throw, stop comparing, and restore the original order
1682
+ // — matching the vm/native backends and node.
1683
+ let mut pending: Option<EvalError> = None;
1316
1684
 
1317
1685
  if let Some((scope, params, body)) = self.create_callback_scope(&cmp_fn) {
1318
1686
  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 {
1687
+ if pending.is_some() {
1688
+ return std::cmp::Ordering::Equal;
1689
+ }
1690
+ match self.call_with_scope(&scope, &params, &body, &[arr_values[i].clone(), arr_values[j].clone()]) {
1321
1691
  Ok(Value::Number(n)) if n < 0.0 => std::cmp::Ordering::Less,
1322
1692
  Ok(Value::Number(n)) if n > 0.0 => std::cmp::Ordering::Greater,
1323
- _ => std::cmp::Ordering::Equal,
1693
+ Ok(_) => std::cmp::Ordering::Equal,
1694
+ Err(e) => { pending = Some(e); std::cmp::Ordering::Equal }
1324
1695
  }
1325
1696
  });
1326
1697
  } else {
1327
1698
  indices.sort_by(|&i, &j| {
1328
- let result = self.call_func(&cmp_fn, &[arr_values[i].clone(), arr_values[j].clone()]);
1329
- match result {
1699
+ if pending.is_some() {
1700
+ return std::cmp::Ordering::Equal;
1701
+ }
1702
+ match self.call_func(&cmp_fn, &[arr_values[i].clone(), arr_values[j].clone()]) {
1330
1703
  Ok(Value::Number(n)) if n < 0.0 => std::cmp::Ordering::Less,
1331
1704
  Ok(Value::Number(n)) if n > 0.0 => std::cmp::Ordering::Greater,
1332
- _ => std::cmp::Ordering::Equal,
1705
+ Ok(_) => std::cmp::Ordering::Equal,
1706
+ Err(e) => { pending = Some(e); std::cmp::Ordering::Equal }
1333
1707
  }
1334
1708
  });
1335
1709
  }
1336
1710
 
1711
+ if let Some(e) = pending {
1712
+ // Comparator threw: leave the array untouched and re-raise.
1713
+ *arr_mut = arr_values;
1714
+ drop(arr_mut);
1715
+ return Err(e);
1716
+ }
1337
1717
  *arr_mut = indices.into_iter().map(|i| arr_values[i].clone()).collect();
1338
1718
  }
1339
1719
  } else {
@@ -1416,7 +1796,11 @@ impl Evaluator {
1416
1796
  }
1417
1797
  "map" => {
1418
1798
  let callback = arg_vals.first().cloned().unwrap_or(Value::Null);
1419
- let arr_borrow = arr.borrow();
1799
+ // #382-style snapshot: own the elements and DROP the array borrow before
1800
+ // any callback runs, so a callback that mutates the array (via the `array`
1801
+ // arg or a capture) can't RefCell-panic. `arr_value` is the JS 3rd arg.
1802
+ let arr_borrow = arr.borrow().clone();
1803
+ let arr_value = Value::Array(arr.clone());
1420
1804
  let mut result = Vec::with_capacity(arr_borrow.len());
1421
1805
  // Try fastest path: simple single-expression callbacks
1422
1806
  let first_result = self.eval_simple_callback(&callback, &[arr_borrow.first().cloned().unwrap_or(Value::Null)]);
@@ -1433,21 +1817,49 @@ impl Evaluator {
1433
1817
  } else if let Some((scope, params, body)) = self.create_callback_scope(&callback) {
1434
1818
  // Reusable scope path
1435
1819
  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)])?;
1820
+ let mapped = self.call_with_scope(&scope, &params, &body, &[v.clone(), Value::Number(i as f64), arr_value.clone()])?;
1437
1821
  result.push(mapped);
1438
1822
  }
1439
1823
  } else {
1440
1824
  // Full call_func path
1441
1825
  for (i, v) in arr_borrow.iter().enumerate() {
1442
- let mapped = self.call_func(&callback, &[v.clone(), Value::Number(i as f64)])?;
1826
+ let mapped = self.call_func(&callback, &[v.clone(), Value::Number(i as f64), arr_value.clone()])?;
1443
1827
  result.push(mapped);
1444
1828
  }
1445
1829
  }
1446
1830
  return Ok(Value::Array(Rc::new(RefCell::new(result))));
1447
1831
  }
1832
+ "flatMap" => {
1833
+ // map + flatten one level (Array.prototype.flatMap). A callback `throw`
1834
+ // propagates via `?` (catchable), matching vm/native/node — previously
1835
+ // interp lacked flatMap entirely ("Not a function").
1836
+ let callback = arg_vals.first().cloned().unwrap_or(Value::Null);
1837
+ let arr_borrow = arr.borrow().clone(); // snapshot; drop borrow before callbacks
1838
+ let arr_value = Value::Array(arr.clone()); // JS 3rd callback arg
1839
+ let mut result: Vec<Value> = Vec::with_capacity(arr_borrow.len());
1840
+ if let Some((scope, params, body)) = self.create_callback_scope(&callback) {
1841
+ for (i, v) in arr_borrow.iter().enumerate() {
1842
+ let mapped = self.call_with_scope(&scope, &params, &body, &[v.clone(), Value::Number(i as f64), arr_value.clone()])?;
1843
+ match mapped {
1844
+ Value::Array(inner) => result.extend(inner.borrow().iter().cloned()),
1845
+ other => result.push(other),
1846
+ }
1847
+ }
1848
+ } else {
1849
+ for (i, v) in arr_borrow.iter().enumerate() {
1850
+ let mapped = self.call_func(&callback, &[v.clone(), Value::Number(i as f64), arr_value.clone()])?;
1851
+ match mapped {
1852
+ Value::Array(inner) => result.extend(inner.borrow().iter().cloned()),
1853
+ other => result.push(other),
1854
+ }
1855
+ }
1856
+ }
1857
+ return Ok(Value::Array(Rc::new(RefCell::new(result))));
1858
+ }
1448
1859
  "filter" => {
1449
1860
  let callback = arg_vals.first().cloned().unwrap_or(Value::Null);
1450
- let arr_borrow = arr.borrow();
1861
+ let arr_borrow = arr.borrow().clone(); // snapshot; drop borrow before callbacks
1862
+ let arr_value = Value::Array(arr.clone()); // JS 3rd callback arg
1451
1863
  let mut result = Vec::new();
1452
1864
  // Try simple callback fast path
1453
1865
  let use_simple = arr_borrow.first().map(|v| {
@@ -1463,14 +1875,14 @@ impl Evaluator {
1463
1875
  }
1464
1876
  } else if let Some((scope, params, body)) = self.create_callback_scope(&callback) {
1465
1877
  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)])?;
1878
+ let keep = self.call_with_scope(&scope, &params, &body, &[v.clone(), Value::Number(i as f64), arr_value.clone()])?;
1467
1879
  if keep.is_truthy() {
1468
1880
  result.push(v.clone());
1469
1881
  }
1470
1882
  }
1471
1883
  } else {
1472
1884
  for (i, v) in arr_borrow.iter().enumerate() {
1473
- let keep = self.call_func(&callback, &[v.clone(), Value::Number(i as f64)])?;
1885
+ let keep = self.call_func(&callback, &[v.clone(), Value::Number(i as f64), arr_value.clone()])?;
1474
1886
  if keep.is_truthy() {
1475
1887
  result.push(v.clone());
1476
1888
  }
@@ -1480,7 +1892,8 @@ impl Evaluator {
1480
1892
  }
1481
1893
  "reduce" => {
1482
1894
  let callback = arg_vals.first().cloned().unwrap_or(Value::Null);
1483
- let arr_borrow = arr.borrow();
1895
+ let arr_borrow = arr.borrow().clone(); // snapshot; drop borrow before callbacks
1896
+ let arr_value = Value::Array(arr.clone()); // JS 4th callback arg
1484
1897
  let (mut acc, start_idx) = if arg_vals.len() > 1 {
1485
1898
  (arg_vals[1].clone(), 0)
1486
1899
  } else if !arr_borrow.is_empty() {
@@ -1490,18 +1903,19 @@ impl Evaluator {
1490
1903
  };
1491
1904
  if let Some((scope, params, body)) = self.create_callback_scope(&callback) {
1492
1905
  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)])?;
1906
+ acc = self.call_with_scope(&scope, &params, &body, &[acc, v.clone(), Value::Number(i as f64), arr_value.clone()])?;
1494
1907
  }
1495
1908
  } else {
1496
1909
  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)])?;
1910
+ acc = self.call_func(&callback, &[acc, v.clone(), Value::Number(i as f64), arr_value.clone()])?;
1498
1911
  }
1499
1912
  }
1500
1913
  return Ok(acc);
1501
1914
  }
1502
1915
  "find" => {
1503
1916
  let callback = arg_vals.first().cloned().unwrap_or(Value::Null);
1504
- let arr_borrow = arr.borrow();
1917
+ let arr_borrow = arr.borrow().clone(); // snapshot; drop borrow before callbacks
1918
+ let arr_value = Value::Array(arr.clone()); // JS 3rd callback arg
1505
1919
  // Try simple callback fast path
1506
1920
  let use_simple = arr_borrow.first().map(|v| {
1507
1921
  self.eval_simple_callback(&callback, &[v.clone()]).is_some()
@@ -1516,14 +1930,14 @@ impl Evaluator {
1516
1930
  }
1517
1931
  } else if let Some((scope, params, body)) = self.create_callback_scope(&callback) {
1518
1932
  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)])?;
1933
+ let found = self.call_with_scope(&scope, &params, &body, &[v.clone(), Value::Number(i as f64), arr_value.clone()])?;
1520
1934
  if found.is_truthy() {
1521
1935
  return Ok(v.clone());
1522
1936
  }
1523
1937
  }
1524
1938
  } else {
1525
1939
  for (i, v) in arr_borrow.iter().enumerate() {
1526
- let found = self.call_func(&callback, &[v.clone(), Value::Number(i as f64)])?;
1940
+ let found = self.call_func(&callback, &[v.clone(), Value::Number(i as f64), arr_value.clone()])?;
1527
1941
  if found.is_truthy() {
1528
1942
  return Ok(v.clone());
1529
1943
  }
@@ -1533,17 +1947,18 @@ impl Evaluator {
1533
1947
  }
1534
1948
  "findIndex" => {
1535
1949
  let callback = arg_vals.first().cloned().unwrap_or(Value::Null);
1536
- let arr_borrow = arr.borrow();
1950
+ let arr_borrow = arr.borrow().clone(); // snapshot; drop borrow before callbacks
1951
+ let arr_value = Value::Array(arr.clone()); // JS 3rd callback arg
1537
1952
  if let Some((scope, params, body)) = self.create_callback_scope(&callback) {
1538
1953
  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)])?;
1954
+ let found = self.call_with_scope(&scope, &params, &body, &[v.clone(), Value::Number(i as f64), arr_value.clone()])?;
1540
1955
  if found.is_truthy() {
1541
1956
  return Ok(Value::Number(i as f64));
1542
1957
  }
1543
1958
  }
1544
1959
  } else {
1545
1960
  for (i, v) in arr_borrow.iter().enumerate() {
1546
- let found = self.call_func(&callback, &[v.clone(), Value::Number(i as f64)])?;
1961
+ let found = self.call_func(&callback, &[v.clone(), Value::Number(i as f64), arr_value.clone()])?;
1547
1962
  if found.is_truthy() {
1548
1963
  return Ok(Value::Number(i as f64));
1549
1964
  }
@@ -1552,13 +1967,14 @@ impl Evaluator {
1552
1967
  return Ok(Value::Number(-1.0));
1553
1968
  }
1554
1969
  "findLast" => {
1555
- // Like find, from the end (#247). Callback gets (value, original index).
1970
+ // Like find, from the end (#247). Callback gets (value, original index, array).
1556
1971
  let callback = arg_vals.first().cloned().unwrap_or(Value::Null);
1557
- let arr_borrow = arr.borrow();
1972
+ let arr_borrow = arr.borrow().clone(); // snapshot; drop borrow before callbacks
1973
+ let arr_value = Value::Array(arr.clone()); // JS 3rd callback arg
1558
1974
  let scoped = self.create_callback_scope(&callback);
1559
1975
  for i in (0..arr_borrow.len()).rev() {
1560
1976
  let v = arr_borrow[i].clone();
1561
- let args = [v.clone(), Value::Number(i as f64)];
1977
+ let args = [v.clone(), Value::Number(i as f64), arr_value.clone()];
1562
1978
  let found = match &scoped {
1563
1979
  Some((scope, params, body)) => self.call_with_scope(scope, params, body, &args)?,
1564
1980
  None => self.call_func(&callback, &args)?,
@@ -1571,10 +1987,11 @@ impl Evaluator {
1571
1987
  }
1572
1988
  "findLastIndex" => {
1573
1989
  let callback = arg_vals.first().cloned().unwrap_or(Value::Null);
1574
- let arr_borrow = arr.borrow();
1990
+ let arr_borrow = arr.borrow().clone(); // snapshot; drop borrow before callbacks
1991
+ let arr_value = Value::Array(arr.clone()); // JS 3rd callback arg
1575
1992
  let scoped = self.create_callback_scope(&callback);
1576
1993
  for i in (0..arr_borrow.len()).rev() {
1577
- let args = [arr_borrow[i].clone(), Value::Number(i as f64)];
1994
+ let args = [arr_borrow[i].clone(), Value::Number(i as f64), arr_value.clone()];
1578
1995
  let found = match &scoped {
1579
1996
  Some((scope, params, body)) => self.call_with_scope(scope, params, body, &args)?,
1580
1997
  None => self.call_func(&callback, &args)?,
@@ -1601,21 +2018,23 @@ impl Evaluator {
1601
2018
  }
1602
2019
  "forEach" => {
1603
2020
  let callback = arg_vals.first().cloned().unwrap_or(Value::Null);
1604
- let arr_borrow = arr.borrow();
2021
+ let arr_borrow = arr.borrow().clone(); // snapshot; drop borrow before callbacks
2022
+ let arr_value = Value::Array(arr.clone()); // JS 3rd callback arg
1605
2023
  if let Some((scope, params, body)) = self.create_callback_scope(&callback) {
1606
2024
  for (i, v) in arr_borrow.iter().enumerate() {
1607
- self.call_with_scope(&scope, &params, &body, &[v.clone(), Value::Number(i as f64)])?;
2025
+ self.call_with_scope(&scope, &params, &body, &[v.clone(), Value::Number(i as f64), arr_value.clone()])?;
1608
2026
  }
1609
2027
  } else {
1610
2028
  for (i, v) in arr_borrow.iter().enumerate() {
1611
- self.call_func(&callback, &[v.clone(), Value::Number(i as f64)])?;
2029
+ self.call_func(&callback, &[v.clone(), Value::Number(i as f64), arr_value.clone()])?;
1612
2030
  }
1613
2031
  }
1614
2032
  return Ok(Value::Null);
1615
2033
  }
1616
2034
  "some" => {
1617
2035
  let callback = arg_vals.first().cloned().unwrap_or(Value::Null);
1618
- let arr_borrow = arr.borrow();
2036
+ let arr_borrow = arr.borrow().clone(); // snapshot; drop borrow before callbacks
2037
+ let arr_value = Value::Array(arr.clone()); // JS 3rd callback arg
1619
2038
  // Try simple callback fast path
1620
2039
  let use_simple = arr_borrow.first().map(|v| {
1621
2040
  self.eval_simple_callback(&callback, &[v.clone()]).is_some()
@@ -1630,14 +2049,14 @@ impl Evaluator {
1630
2049
  }
1631
2050
  } else if let Some((scope, params, body)) = self.create_callback_scope(&callback) {
1632
2051
  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)])?;
2052
+ let result = self.call_with_scope(&scope, &params, &body, &[v.clone(), Value::Number(i as f64), arr_value.clone()])?;
1634
2053
  if result.is_truthy() {
1635
2054
  return Ok(Value::Bool(true));
1636
2055
  }
1637
2056
  }
1638
2057
  } else {
1639
2058
  for (i, v) in arr_borrow.iter().enumerate() {
1640
- let result = self.call_func(&callback, &[v.clone(), Value::Number(i as f64)])?;
2059
+ let result = self.call_func(&callback, &[v.clone(), Value::Number(i as f64), arr_value.clone()])?;
1641
2060
  if result.is_truthy() {
1642
2061
  return Ok(Value::Bool(true));
1643
2062
  }
@@ -1647,7 +2066,8 @@ impl Evaluator {
1647
2066
  }
1648
2067
  "every" => {
1649
2068
  let callback = arg_vals.first().cloned().unwrap_or(Value::Null);
1650
- let arr_borrow = arr.borrow();
2069
+ let arr_borrow = arr.borrow().clone(); // snapshot; drop borrow before callbacks
2070
+ let arr_value = Value::Array(arr.clone()); // JS 3rd callback arg
1651
2071
  // Try simple callback fast path
1652
2072
  let use_simple = arr_borrow.first().map(|v| {
1653
2073
  self.eval_simple_callback(&callback, &[v.clone()]).is_some()
@@ -1662,14 +2082,14 @@ impl Evaluator {
1662
2082
  }
1663
2083
  } else if let Some((scope, params, body)) = self.create_callback_scope(&callback) {
1664
2084
  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)])?;
2085
+ let result = self.call_with_scope(&scope, &params, &body, &[v.clone(), Value::Number(i as f64), arr_value.clone()])?;
1666
2086
  if !result.is_truthy() {
1667
2087
  return Ok(Value::Bool(false));
1668
2088
  }
1669
2089
  }
1670
2090
  } else {
1671
2091
  for (i, v) in arr_borrow.iter().enumerate() {
1672
- let result = self.call_func(&callback, &[v.clone(), Value::Number(i as f64)])?;
2092
+ let result = self.call_func(&callback, &[v.clone(), Value::Number(i as f64), arr_value.clone()])?;
1673
2093
  if !result.is_truthy() {
1674
2094
  return Ok(Value::Bool(false));
1675
2095
  }
@@ -1882,12 +2302,12 @@ impl Evaluator {
1882
2302
  return Ok(Value::String(s.replace(&search, &replacement).into()));
1883
2303
  }
1884
2304
  "charAt" => {
2305
+ // Cursor cache instead of collecting a fresh Vec<char> each call (#203).
1885
2306
  let idx = match arg_vals.first() {
1886
2307
  Some(Value::Number(n)) => *n as usize,
1887
2308
  _ => 0,
1888
2309
  };
1889
- let chars: Vec<char> = s.chars().collect();
1890
- return Ok(chars.get(idx)
2310
+ return Ok(nth_char_cached(s, idx)
1891
2311
  .map(|c| Value::String(c.to_string().into()))
1892
2312
  .unwrap_or(Value::String("".into())));
1893
2313
  }
@@ -1897,11 +2317,11 @@ impl Evaluator {
1897
2317
  Some(Value::Number(n)) => *n as i64,
1898
2318
  _ => 0,
1899
2319
  };
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()));
2320
+ let idx = if i < 0 { s.chars().count() as i64 + i } else { i };
2321
+ if idx >= 0 {
2322
+ if let Some(c) = nth_char_cached(s, idx as usize) {
2323
+ return Ok(Value::String(c.to_string().into()));
2324
+ }
1905
2325
  }
1906
2326
  return Ok(Value::Null);
1907
2327
  }
@@ -1910,9 +2330,8 @@ impl Evaluator {
1910
2330
  Some(Value::Number(n)) => *n as usize,
1911
2331
  _ => 0,
1912
2332
  };
1913
- let chars: Vec<char> = s.chars().collect();
1914
- return Ok(chars.get(idx)
1915
- .map(|c| Value::Number(*c as u32 as f64))
2333
+ return Ok(nth_char_cached(s, idx)
2334
+ .map(|c| Value::Number(c as u32 as f64))
1916
2335
  .unwrap_or(Value::Number(f64::NAN)));
1917
2336
  }
1918
2337
  "repeat" => {
@@ -2158,6 +2577,9 @@ impl Evaluator {
2158
2577
  }
2159
2578
  Expr::Assign { name, value, .. } => {
2160
2579
  let v = self.eval_expr(value)?;
2580
+ // A plain assignment overwrites the variable, so drop any builder buffering it (the
2581
+ // buffered value is about to be replaced). `value` may itself have read+flushed it.
2582
+ self.discard_pending_for(name.as_ref());
2161
2583
  match self.scope.borrow_mut().assign(name.as_ref(), v.clone()) {
2162
2584
  Ok(true) => Ok(v),
2163
2585
  Ok(false) => Err(EvalError::Error(format!("Undefined variable: {}", name))),
@@ -2306,6 +2728,10 @@ impl Evaluator {
2306
2728
  }
2307
2729
  }
2308
2730
  Expr::CompoundAssign { name, op, value, .. } => {
2731
+ // Expression-position `+=` (result is used): flush any builder so `current` is the
2732
+ // full string, then take the generic path. The O(1) builder fast path is reserved
2733
+ // for statement position (see `eval_expr_discard`).
2734
+ self.flush_pending_for(name.as_ref());
2309
2735
  let current = self.scope.borrow().get(name.as_ref())
2310
2736
  .ok_or_else(|| EvalError::Error(format!("Undefined variable: {}", name)))?;
2311
2737
  let rhs = self.eval_expr(value)?;
@@ -2324,6 +2750,7 @@ impl Evaluator {
2324
2750
  }
2325
2751
  }
2326
2752
  Expr::LogicalAssign { name, op, value, .. } => {
2753
+ self.flush_pending_for(name.as_ref());
2327
2754
  let current = self.scope.borrow().get(name.as_ref())
2328
2755
  .ok_or_else(|| EvalError::Error(format!("Undefined variable: {}", name)))?;
2329
2756
  let result = match op {
@@ -2750,6 +3177,9 @@ impl Evaluator {
2750
3177
  module_cache: Rc::clone(&self.module_cache),
2751
3178
  current_dir: RefCell::new(self.current_dir.borrow().clone()),
2752
3179
  virtual_builtins: Rc::clone(&self.virtual_builtins),
3180
+ string_builder: Rc::clone(&self.string_builder),
3181
+ call_depth: Rc::clone(&self.call_depth),
3182
+ max_call_depth: self.max_call_depth,
2753
3183
  };
2754
3184
  match eval.eval_statement(body) {
2755
3185
  Ok(v) => Ok(v),
@@ -3018,6 +3448,9 @@ impl Evaluator {
3018
3448
  module_cache: Rc::clone(&self.module_cache),
3019
3449
  current_dir: RefCell::new(self.current_dir.borrow().clone()),
3020
3450
  virtual_builtins: Rc::clone(&self.virtual_builtins),
3451
+ string_builder: Rc::clone(&self.string_builder),
3452
+ call_depth: Rc::clone(&self.call_depth),
3453
+ max_call_depth: self.max_call_depth,
3021
3454
  };
3022
3455
  {
3023
3456
  let mut s = scope.borrow_mut();
@@ -3074,6 +3507,20 @@ impl Evaluator {
3074
3507
  // VM's single `run_chunk` re-entry. 128 KiB is smaller than one level's chain, so the
3075
3508
  // stack overflows BETWEEN checks; 1 MiB comfortably covers a level (verified to depth
3076
3509
  // 20000 in both debug and release). 16 MiB segments keep grow frequency low.
3510
+ // #381: bound recursion with a catchable `RangeError` instead of letting `stacker`
3511
+ // grow the stack toward OOM/abort. The counter is shared (`Rc`) across every call
3512
+ // frame, so it measures true nesting depth; it is decremented on the way out (both
3513
+ // the Ok and Err paths) via the explicit `set` below so a caught throw doesn't leak
3514
+ // depth.
3515
+ let depth = eval.call_depth.get() + 1;
3516
+ if depth > eval.max_call_depth {
3517
+ let err = crate::natives::range_error_construct(&[Value::String(
3518
+ "Maximum call stack size exceeded".into(),
3519
+ )])
3520
+ .unwrap_or(Value::Null);
3521
+ return Err(EvalError::Throw(err));
3522
+ }
3523
+ eval.call_depth.set(depth);
3077
3524
  let body_result = {
3078
3525
  #[cfg(not(target_arch = "wasm32"))]
3079
3526
  {
@@ -3086,6 +3533,7 @@ impl Evaluator {
3086
3533
  eval.eval_statement(body)
3087
3534
  }
3088
3535
  };
3536
+ eval.call_depth.set(depth - 1);
3089
3537
  match body_result {
3090
3538
  Ok(v) => Ok(v),
3091
3539
  Err(EvalError::Return(v)) => Ok(v),
@@ -3566,7 +4014,7 @@ impl Evaluator {
3566
4014
  }
3567
4015
  Value::String(s) => {
3568
4016
  if key == "length" {
3569
- Ok(Value::Number(s.chars().count() as f64))
4017
+ Ok(Value::Number(char_count_cached(s) as f64))
3570
4018
  } else {
3571
4019
  Ok(Value::Null)
3572
4020
  }
@@ -3623,6 +4071,11 @@ impl Evaluator {
3623
4071
  _ => Ok(Value::Null),
3624
4072
  }
3625
4073
  }
4074
+ // Reading a property of the nullish value throws a catchable `TypeError`, matching the
4075
+ // bytecode VM (`get_member`'s `_` arm), cranelift/wasi, and node — not a silent `null`.
4076
+ // The tree-walker used to fall through to `_ => Ok(Value::Null)`, so `null.length` read
4077
+ // back as `null` on interp while every other backend threw (a pure interp≠vm bug).
4078
+ Value::Null => Err(format!("Cannot read property '{}' of null", key)),
3626
4079
  _ => Ok(Value::Null),
3627
4080
  }
3628
4081
  }
@@ -3644,9 +4097,7 @@ impl Evaluator {
3644
4097
  Value::Number(n) if *n >= 0.0 && n.fract() == 0.0 => *n as usize,
3645
4098
  _ => return Ok(Value::Null),
3646
4099
  };
3647
- Ok(s
3648
- .chars()
3649
- .nth(idx)
4100
+ Ok(nth_char_cached(s, idx)
3650
4101
  .map(|c| Value::String(c.to_string().into()))
3651
4102
  .unwrap_or(Value::Null))
3652
4103
  }
@@ -3659,6 +4110,9 @@ impl Evaluator {
3659
4110
  };
3660
4111
  self.get_prop(obj, key)
3661
4112
  }
4113
+ // Indexing the nullish value throws a catchable `TypeError` (like `get_prop` above and
4114
+ // the VM/cranelift/wasi/node) rather than silently reading back `null`.
4115
+ Value::Null => Err(format!("Cannot read property '{}' of null", index)),
3662
4116
  _ => Ok(Value::Null),
3663
4117
  }
3664
4118
  }
@@ -3677,8 +4131,13 @@ impl Evaluator {
3677
4131
  }
3678
4132
  }
3679
4133
 
3680
- fn json_stringify_value(v: &Value) -> String {
3681
- match v {
4134
+ /// #381 ancestor-guarded like `tishlang_core::json_stringify_into_guarded`: a back-edge to an
4135
+ /// ancestor (`a.self = a`) is a cycle and returns `Err(())` instead of recursing forever (this
4136
+ /// was an unguarded native-stack overflow → uncatchable abort; core's guard from #389 never
4137
+ /// covered the interpreter's own stringifier). Ancestor-path only, not all-visited: a node
4138
+ /// repeated across sibling branches is a legal DAG and must serialize twice.
4139
+ fn json_stringify_value(v: &Value, ancestors: &mut Vec<*const ()>) -> Result<String, ()> {
4140
+ Ok(match v {
3682
4141
  Value::Null => "null".to_string(),
3683
4142
  Value::Bool(b) => b.to_string(),
3684
4143
  Value::Number(n) => {
@@ -3698,28 +4157,39 @@ impl Evaluator {
3698
4157
  .replace('\t', "\\t")
3699
4158
  ),
3700
4159
  Value::Array(arr) => {
3701
- let inner: Vec<String> = arr
3702
- .borrow()
3703
- .iter()
3704
- .map(Self::json_stringify_value)
3705
- .collect();
4160
+ let ptr = Rc::as_ptr(arr) as *const ();
4161
+ if ancestors.contains(&ptr) {
4162
+ return Err(());
4163
+ }
4164
+ ancestors.push(ptr);
4165
+ let borrowed = arr.borrow();
4166
+ let mut inner: Vec<String> = Vec::with_capacity(borrowed.len());
4167
+ for item in borrowed.iter() {
4168
+ inner.push(Self::json_stringify_value(item, ancestors)?);
4169
+ }
4170
+ drop(borrowed);
4171
+ ancestors.pop();
3706
4172
  format!("[{}]", inner.join(","))
3707
4173
  }
3708
4174
  Value::Object(map) => {
4175
+ let ptr = Rc::as_ptr(map) as *const ();
4176
+ if ancestors.contains(&ptr) {
4177
+ return Err(());
4178
+ }
4179
+ ancestors.push(ptr);
3709
4180
  // Insertion order (PropMap is an IndexMap) — matches JS/Node and the
3710
4181
  // 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();
4182
+ let borrowed = map.borrow();
4183
+ let mut entries: Vec<String> = Vec::with_capacity(borrowed.strings.len());
4184
+ for (k, v) in borrowed.strings.iter() {
4185
+ entries.push(format!(
4186
+ "\"{}\":{}",
4187
+ k.replace('\\', "\\\\").replace('"', "\\\""),
4188
+ Self::json_stringify_value(v, ancestors)?
4189
+ ));
4190
+ }
4191
+ drop(borrowed);
4192
+ ancestors.pop();
3723
4193
  format!("{{{}}}", entries.join(","))
3724
4194
  }
3725
4195
  Value::Symbol(_) => "null".to_string(),
@@ -3738,7 +4208,7 @@ impl Evaluator {
3738
4208
  #[cfg(feature = "regex")]
3739
4209
  Value::RegExp(_) => "null".to_string(),
3740
4210
  Value::Opaque(_) | Value::OpaqueMethod(_, _) => "null".to_string(),
3741
- }
4211
+ })
3742
4212
  }
3743
4213
 
3744
4214
  // Static native wrapper functions (these need to be fn pointers, not closures with &self)
@@ -3749,7 +4219,13 @@ impl Evaluator {
3749
4219
 
3750
4220
  fn json_stringify_native(args: &[Value]) -> Result<Value, String> {
3751
4221
  let v = args.first().cloned().unwrap_or(Value::Null);
3752
- Ok(Value::String(Self::json_stringify_value(&v).into()))
4222
+ let mut ancestors: Vec<*const ()> = Vec::new();
4223
+ match Self::json_stringify_value(&v, &mut ancestors) {
4224
+ Ok(s) => Ok(Value::String(s.into())),
4225
+ // Surfaced by `call_func` as `EvalError::Error`, which the Try handler boxes as
4226
+ // `{ name: "TypeError", message }` — node-identical for the circular case (#381).
4227
+ Err(()) => Err("Converting circular structure to JSON".to_string()),
4228
+ }
3753
4229
  }
3754
4230
 
3755
4231
  fn object_keys(args: &[Value]) -> Result<Value, String> {
@@ -4214,3 +4690,126 @@ impl std::fmt::Display for EvalError {
4214
4690
  }
4215
4691
 
4216
4692
  impl std::error::Error for EvalError {}
4693
+
4694
+ #[cfg(test)]
4695
+ mod recursion_limit_tests_381 {
4696
+ use super::Evaluator;
4697
+ use tishlang_parser::parse;
4698
+
4699
+ fn run_with_depth(src: &str, max_depth: usize) -> String {
4700
+ let program = parse(src).unwrap();
4701
+ let mut eval = Evaluator::new();
4702
+ eval.max_call_depth = max_depth;
4703
+ eval.eval_program(&program).unwrap().to_string()
4704
+ }
4705
+
4706
+ #[test]
4707
+ fn deep_recursion_throws_catchable_range_error() {
4708
+ // Infinite (non-tail) recursion past the limit must throw a CATCHABLE RangeError, not abort:
4709
+ // try/catch recovers and the program keeps running.
4710
+ let src = "fn rec(n) { return 1 + rec(n + 1) }\n\
4711
+ let name = 'none'\n\
4712
+ try { rec(0) } catch (e) { name = e.name }\n\
4713
+ name";
4714
+ assert_eq!(run_with_depth(src, 200), "RangeError");
4715
+ }
4716
+
4717
+ #[test]
4718
+ fn normal_recursion_is_unaffected() {
4719
+ let src = "fn fib(n) { if (n < 2) { return n } return fib(n - 1) + fib(n - 2) }\nfib(12)";
4720
+ assert_eq!(run_with_depth(src, 20000), "144");
4721
+ }
4722
+ }
4723
+
4724
+ #[cfg(test)]
4725
+ mod null_member_access_tests {
4726
+ // Reading a property or index of the nullish value throws a catchable `TypeError`, matching the
4727
+ // bytecode VM / cranelift / wasi / node. The tree-walker used to fall through to `Ok(Null)`, so
4728
+ // `null.length` read back as `null` on interp while every other backend threw (a pure interp≠vm
4729
+ // divergence). These lock the interpreter to the throwing behavior.
4730
+ use super::Evaluator;
4731
+ use tishlang_parser::parse;
4732
+
4733
+ fn run(src: &str) -> String {
4734
+ let program = parse(src).unwrap();
4735
+ let mut eval = Evaluator::new();
4736
+ eval.eval_program(&program).unwrap().to_string()
4737
+ }
4738
+
4739
+ #[test]
4740
+ fn null_property_read_throws_type_error() {
4741
+ let src = "let name = 'none'\n\
4742
+ try { let z = null; z.length } catch (e) { name = e.name }\n\
4743
+ name";
4744
+ assert_eq!(run(src), "TypeError");
4745
+ }
4746
+
4747
+ #[test]
4748
+ fn null_index_read_throws_type_error() {
4749
+ let src = "let name = 'none'\n\
4750
+ try { let z = null; z[0] } catch (e) { name = e.name }\n\
4751
+ name";
4752
+ assert_eq!(run(src), "TypeError");
4753
+ }
4754
+
4755
+ #[test]
4756
+ fn object_missing_property_still_reads_null() {
4757
+ // Guard against over-reach: a MISSING property of a real object is `null`, not a throw.
4758
+ let src = "let o = { a: 1 }\nString(o.b === null)";
4759
+ assert_eq!(run(src), "true");
4760
+ }
4761
+
4762
+ #[test]
4763
+ fn array_oob_index_still_reads_null() {
4764
+ // Out-of-bounds array index stays nullish (only a null/undefined *receiver* throws).
4765
+ let src = "let a = [1, 2, 3]\nString(a[9] === null)";
4766
+ assert_eq!(run(src), "true");
4767
+ }
4768
+ }
4769
+
4770
+ #[cfg(test)]
4771
+ mod array_hof_callback_arg_tests {
4772
+ // Array HOFs pass the source array as the trailing callback arg (JS `(element, index, array)`,
4773
+ // reduce `(acc, element, index, array)`). The inline interp loops snapshot the backing store and
4774
+ // drop the borrow before any callback, so a callback that MUTATES the array via the `array` arg
4775
+ // (or a capture) can't RefCell-panic — it iterates the pre-call snapshot, matching JS + the shared
4776
+ // #382 builtins path.
4777
+ use super::Evaluator;
4778
+ use tishlang_parser::parse;
4779
+
4780
+ fn run(src: &str) -> String {
4781
+ let program = parse(src).unwrap();
4782
+ let mut eval = Evaluator::new();
4783
+ eval.eval_program(&program).unwrap().to_string()
4784
+ }
4785
+
4786
+ #[test]
4787
+ fn map_receives_array_as_third_arg() {
4788
+ assert_eq!(run("[5, 3, 8].map((x, i, arr) => arr.length).join(',')"), "3,3,3");
4789
+ }
4790
+
4791
+ #[test]
4792
+ fn reduce_receives_array_as_fourth_arg() {
4793
+ assert_eq!(run("[1, 2, 3].reduce((a, x, i, arr) => a + arr.length, 0)"), "9");
4794
+ }
4795
+
4796
+ #[test]
4797
+ fn callback_may_index_the_array_arg() {
4798
+ assert_eq!(run("[10, 20, 30].map((x, i, arr) => arr[i] * 2).join(',')"), "20,40,60");
4799
+ }
4800
+
4801
+ #[test]
4802
+ fn callback_mutating_the_array_arg_does_not_panic() {
4803
+ // Pre-snapshot this RefCell-panicked (map held the borrow across the loop). Now iteration is
4804
+ // over the snapshot (3 elements); the 3 pushes land on the live array → final length 6, matching
4805
+ // JS forEach semantics (does not visit elements appended during iteration).
4806
+ let src = "let a = [1, 2, 3]\na.forEach((x, i, arr) => { arr.push(x) })\na.length";
4807
+ assert_eq!(run(src), "6");
4808
+ }
4809
+
4810
+ #[test]
4811
+ fn one_and_two_arg_callbacks_unchanged() {
4812
+ assert_eq!(run("[5, 3, 8].map(x => x * 2).join(',')"), "10,6,16");
4813
+ assert_eq!(run("[5, 3, 8].map((x, i) => x + i).join(',')"), "5,4,10");
4814
+ }
4815
+ }