@tishlang/tish-lsp 2.16.13 → 2.36.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 (52) hide show
  1. package/bin/tish-lsp +0 -0
  2. package/crates/js_to_tish/src/transform/expr.rs +12 -2
  3. package/crates/tish/tests/fixtures/fs_parity_callbacks.tish +22 -0
  4. package/crates/tish/tests/fixtures/fs_parity_promises.tish +21 -0
  5. package/crates/tish/tests/fixtures/fs_parity_sync.tish +20 -0
  6. package/crates/tish/tests/fixtures/process_capture.tish +16 -0
  7. package/crates/tish/tests/fs_parity.rs +103 -0
  8. package/crates/tish/tests/process_capture.rs +40 -0
  9. package/crates/tish/tests/shortcircuit.rs +3 -3
  10. package/crates/tish_ast/src/ast.rs +77 -3
  11. package/crates/tish_builtins/Cargo.toml +1 -0
  12. package/crates/tish_builtins/src/array.rs +195 -16
  13. package/crates/tish_builtins/src/collections.rs +37 -2
  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 +168 -15
  18. package/crates/tish_bytecode/src/compiler.rs +56 -4
  19. package/crates/tish_bytecode/src/opcode.rs +4 -2
  20. package/crates/tish_compile/src/codegen.rs +519 -46
  21. package/crates/tish_compile/src/infer.rs +26 -11
  22. package/crates/tish_compile/src/resolve.rs +87 -10
  23. package/crates/tish_compile_js/src/codegen.rs +82 -24
  24. package/crates/tish_compile_js/src/tests_jsx.rs +27 -0
  25. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +17 -2
  26. package/crates/tish_core/src/json.rs +5 -1
  27. package/crates/tish_core/src/lib.rs +48 -2
  28. package/crates/tish_core/src/uri.rs +45 -0
  29. package/crates/tish_core/src/value.rs +6 -0
  30. package/crates/tish_eval/Cargo.toml +2 -1
  31. package/crates/tish_eval/src/eval.rs +661 -27
  32. package/crates/tish_eval/src/natives.rs +318 -18
  33. package/crates/tish_eval/src/regex.rs +47 -1
  34. package/crates/tish_fmt/src/lib.rs +23 -4
  35. package/crates/tish_lsp/Cargo.toml +1 -1
  36. package/crates/tish_opt/src/lib.rs +20 -1
  37. package/crates/tish_parser/src/lib.rs +41 -0
  38. package/crates/tish_parser/src/parser.rs +180 -11
  39. package/crates/tish_resolve/src/lib.rs +66 -5
  40. package/crates/tish_runtime/Cargo.toml +5 -1
  41. package/crates/tish_runtime/src/fs_ext.rs +359 -0
  42. package/crates/tish_runtime/src/http_hyper.rs +41 -18
  43. package/crates/tish_runtime/src/lib.rs +236 -10
  44. package/crates/tish_ui/src/jsx.rs +18 -3
  45. package/crates/tish_vm/src/jit.rs +1 -1
  46. package/crates/tish_vm/src/vm.rs +294 -60
  47. package/package.json +1 -1
  48. package/platform/darwin-arm64/tish-lsp +0 -0
  49. package/platform/darwin-x64/tish-lsp +0 -0
  50. package/platform/linux-arm64/tish-lsp +0 -0
  51. package/platform/linux-x64/tish-lsp +0 -0
  52. package/platform/win32-x64/tish-lsp.exe +0 -0
@@ -238,6 +238,9 @@ impl Evaluator {
238
238
  );
239
239
  s.set("decodeURI".into(), Value::Native(natives::decode_uri), true);
240
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);
241
244
  s.set(
242
245
  "htmlEscape".into(),
243
246
  Value::Native(natives::html_escape),
@@ -262,6 +265,7 @@ impl Evaluator {
262
265
  math.insert("round".into(), Value::Native(natives::math_round));
263
266
  math.insert("random".into(), Value::Native(natives::math_random));
264
267
  math.insert("pow".into(), Value::Native(natives::math_pow));
268
+ math.insert("imul".into(), Value::Native(natives::math_imul));
265
269
  math.insert("hypot".into(), Value::Native(natives::math_hypot));
266
270
  math.insert("asin".into(), Value::Native(natives::math_asin));
267
271
  math.insert("acos".into(), Value::Native(natives::math_acos));
@@ -281,6 +285,10 @@ impl Evaluator {
281
285
  math.insert("acosh".into(), Value::Native(natives::math_acosh));
282
286
  math.insert("atanh".into(), Value::Native(natives::math_atanh));
283
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));
284
292
  math.insert("log2".into(), Value::Native(natives::math_log2));
285
293
  math.insert("log10".into(), Value::Native(natives::math_log10));
286
294
  math.insert("PI".into(), Value::Number(std::f64::consts::PI));
@@ -312,6 +320,8 @@ impl Evaluator {
312
320
  process_obj.insert("cwd".into(), Value::Native(natives::process_cwd));
313
321
  process_obj.insert("exec".into(), Value::Native(natives::process_exec));
314
322
  process_obj.insert("execFile".into(), Value::Native(natives::process_exec_file));
323
+ process_obj.insert("execCapture".into(), Value::Native(natives::process_exec_capture));
324
+ process_obj.insert("execFileCapture".into(), Value::Native(natives::process_exec_file_capture));
315
325
  let argv: Vec<Value> = tishlang_core::process_argv()
316
326
  .into_iter()
317
327
  .map(|s| Value::String(s.into()))
@@ -329,6 +339,10 @@ impl Evaluator {
329
339
  object.insert("values".into(), Value::Native(Self::object_values));
330
340
  object.insert("entries".into(), Value::Native(Self::object_entries));
331
341
  object.insert("assign".into(), Value::Native(Self::object_assign));
342
+ object.insert("is".into(), Value::Native(natives::object_is));
343
+ object.insert("hasOwn".into(), Value::Native(natives::object_has_own));
344
+ // getOwnPropertyNames ≈ keys for tish's plain objects (own string keys, insertion order).
345
+ object.insert("getOwnPropertyNames".into(), Value::Native(Self::object_keys));
332
346
  object.insert(
333
347
  "fromEntries".into(),
334
348
  Value::Native(Self::object_from_entries),
@@ -341,6 +355,7 @@ impl Evaluator {
341
355
 
342
356
  let mut array_obj = PropMap::with_capacity(3);
343
357
  array_obj.insert("isArray".into(), Value::Native(natives::array_is_array));
358
+ array_obj.insert("of".into(), Value::Native(natives::array_of));
344
359
  // `Array(n)` and `new Array(n)` constructor (issue #72).
345
360
  array_obj.insert("__call".into(), Value::Native(natives::array_construct));
346
361
  array_obj.insert("__construct".into(), Value::Native(natives::array_construct));
@@ -376,9 +391,23 @@ impl Evaluator {
376
391
  true,
377
392
  );
378
393
 
379
- // `Number(value)` coercion as a callable global (issue #36).
380
- let mut number_obj = PropMap::with_capacity(1);
394
+ // `Number(value)` coercion as a callable global (issue #36) + the `Number.*` statics.
395
+ let mut number_obj = PropMap::with_capacity(15);
381
396
  number_obj.insert("__call".into(), Value::Native(natives::number_convert));
397
+ number_obj.insert("isInteger".into(), Value::Native(natives::number_is_integer));
398
+ number_obj.insert("isSafeInteger".into(), Value::Native(natives::number_is_safe_integer));
399
+ number_obj.insert("isNaN".into(), Value::Native(natives::number_is_nan));
400
+ number_obj.insert("isFinite".into(), Value::Native(natives::number_is_finite));
401
+ number_obj.insert("parseInt".into(), Value::Native(natives::parse_int));
402
+ number_obj.insert("parseFloat".into(), Value::Native(natives::parse_float));
403
+ number_obj.insert("MAX_SAFE_INTEGER".into(), Value::Number(9_007_199_254_740_991.0));
404
+ number_obj.insert("MIN_SAFE_INTEGER".into(), Value::Number(-9_007_199_254_740_991.0));
405
+ number_obj.insert("EPSILON".into(), Value::Number(f64::EPSILON));
406
+ number_obj.insert("MAX_VALUE".into(), Value::Number(f64::MAX));
407
+ number_obj.insert("MIN_VALUE".into(), Value::Number(5e-324));
408
+ number_obj.insert("POSITIVE_INFINITY".into(), Value::Number(f64::INFINITY));
409
+ number_obj.insert("NEGATIVE_INFINITY".into(), Value::Number(f64::NEG_INFINITY));
410
+ number_obj.insert("NaN".into(), Value::Number(f64::NAN));
382
411
  s.set("Number".into(), Value::object(number_obj), true);
383
412
 
384
413
  s.set(
@@ -660,6 +689,51 @@ impl Evaluator {
660
689
  self.scope = outer;
661
690
  ret
662
691
  }
692
+ Statement::ForIn {
693
+ name,
694
+ object,
695
+ body,
696
+ ..
697
+ } => {
698
+ let obj_val = self.eval_expr(object)?;
699
+ // for-in enumerates OWN ENUMERABLE KEYS as strings (JS semantics): objects yield keys
700
+ // in insertion order, arrays yield index strings "0".."len-1", and a non-object
701
+ // (incl. null) yields nothing — a no-op, matching JS `for (k in null)`.
702
+ let keys: Vec<crate::value::Value> = match &obj_val {
703
+ crate::value::Value::Object(obj) => obj
704
+ .borrow()
705
+ .strings
706
+ .keys()
707
+ .map(|k| crate::value::Value::String(Arc::clone(k)))
708
+ .collect(),
709
+ crate::value::Value::Array(arr) => {
710
+ let len = arr.borrow().len();
711
+ (0..len)
712
+ .map(|i| crate::value::Value::String(Arc::from(i.to_string())))
713
+ .collect()
714
+ }
715
+ _ => Vec::new(),
716
+ };
717
+ // Fresh per-iteration binding (ES6 `for (let k in …)`), like the for-of arm above.
718
+ let outer = Rc::clone(&self.scope);
719
+ let mut ret = Ok(Value::Null);
720
+ for key in keys {
721
+ let iter_env = Scope::child(Rc::clone(&outer));
722
+ iter_env.borrow_mut().set(Arc::clone(name), key, true);
723
+ self.scope = Rc::clone(&iter_env);
724
+ match self.eval_statement(body) {
725
+ Ok(_) => {}
726
+ Err(EvalError::Break) => break,
727
+ Err(EvalError::Continue) => continue,
728
+ Err(e) => {
729
+ ret = Err(e);
730
+ break;
731
+ }
732
+ }
733
+ }
734
+ self.scope = outer;
735
+ ret
736
+ }
663
737
  Statement::For {
664
738
  init,
665
739
  cond,
@@ -948,7 +1022,7 @@ impl Evaluator {
948
1022
  ExportDeclaration::ReExport {
949
1023
  specifiers,
950
1024
  all,
951
- from,
1025
+ from: Some(from),
952
1026
  ..
953
1027
  } => {
954
1028
  let dep_val = self.load_module(from.as_ref())?;
@@ -971,6 +1045,11 @@ impl Evaluator {
971
1045
  }
972
1046
  }
973
1047
  }
1048
+ // #415 local named export (`export { a, b as c }`, no `from`): a no-op in the
1049
+ // running (entry) program — the bindings already exist, and an export alias is
1050
+ // visible only to importers, never as a local variable (JS semantics). The module
1051
+ // export table is populated by the module-evaluation path, not here.
1052
+ ExportDeclaration::ReExport { from: None, .. } => {}
974
1053
  }
975
1054
  Ok(Value::Null)
976
1055
  }
@@ -1041,7 +1120,7 @@ impl Evaluator {
1041
1120
  ExportDeclaration::ReExport {
1042
1121
  specifiers,
1043
1122
  all,
1044
- from,
1123
+ from: Some(from),
1045
1124
  ..
1046
1125
  } => {
1047
1126
  let dep_val = self.load_module(from.as_ref())?;
@@ -1066,6 +1145,24 @@ impl Evaluator {
1066
1145
  }
1067
1146
  }
1068
1147
  }
1148
+ // #415 local named export (`export { a, b as c }`, no `from`): the bindings are
1149
+ // already declared in this module's scope; expose each under its export name.
1150
+ ExportDeclaration::ReExport {
1151
+ specifiers,
1152
+ from: None,
1153
+ ..
1154
+ } => {
1155
+ for spec in specifiers {
1156
+ if let ImportSpecifier::Named { name, alias, .. } = spec {
1157
+ let v = self.scope.borrow().get(name.as_ref()).ok_or_else(|| {
1158
+ EvalError::Error(format!("Export '{}' is not defined", name))
1159
+ })?;
1160
+ let bind = alias.as_deref().unwrap_or(name.as_ref());
1161
+ self.scope.borrow_mut().set(Arc::from(bind), v.clone(), false);
1162
+ export_names.push(bind.to_string());
1163
+ }
1164
+ }
1165
+ }
1069
1166
  }
1070
1167
  } else {
1071
1168
  let _ = self.eval_statement(stmt);
@@ -1132,6 +1229,44 @@ impl Evaluator {
1132
1229
  Value::Native(natives::read_file_bytes),
1133
1230
  );
1134
1231
  exports.insert("mkdir".into(), Value::Native(natives::mkdir));
1232
+ // Node-compatible names + new methods (sync) — issue #122
1233
+ use natives::fsx;
1234
+ exports.insert("readFileSync".into(), Value::Native(fsx::read_file));
1235
+ exports.insert("readFileBytesSync".into(), Value::Native(fsx::read_file_bytes));
1236
+ exports.insert("writeFileSync".into(), Value::Native(fsx::write_file));
1237
+ exports.insert("appendFile".into(), Value::Native(fsx::append_file));
1238
+ exports.insert("appendFileSync".into(), Value::Native(fsx::append_file));
1239
+ exports.insert("existsSync".into(), Value::Native(fsx::exists));
1240
+ exports.insert("mkdirSync".into(), Value::Native(fsx::mkdir));
1241
+ exports.insert("readdir".into(), Value::Native(fsx::readdir));
1242
+ exports.insert("readdirSync".into(), Value::Native(fsx::readdir));
1243
+ exports.insert("stat".into(), Value::Native(fsx::stat));
1244
+ exports.insert("statSync".into(), Value::Native(fsx::stat));
1245
+ exports.insert("lstat".into(), Value::Native(fsx::lstat));
1246
+ exports.insert("lstatSync".into(), Value::Native(fsx::lstat));
1247
+ exports.insert("rm".into(), Value::Native(fsx::rm));
1248
+ exports.insert("rmSync".into(), Value::Native(fsx::rm));
1249
+ exports.insert("rmdir".into(), Value::Native(fsx::rmdir));
1250
+ exports.insert("rmdirSync".into(), Value::Native(fsx::rmdir));
1251
+ exports.insert("unlink".into(), Value::Native(fsx::unlink));
1252
+ exports.insert("unlinkSync".into(), Value::Native(fsx::unlink));
1253
+ exports.insert("rename".into(), Value::Native(fsx::rename));
1254
+ exports.insert("renameSync".into(), Value::Native(fsx::rename));
1255
+ exports.insert("copyFile".into(), Value::Native(fsx::copy_file));
1256
+ exports.insert("copyFileSync".into(), Value::Native(fsx::copy_file));
1257
+ exports.insert("realpath".into(), Value::Native(fsx::realpath));
1258
+ exports.insert("realpathSync".into(), Value::Native(fsx::realpath));
1259
+ exports.insert("readlink".into(), Value::Native(fsx::readlink));
1260
+ exports.insert("readlinkSync".into(), Value::Native(fsx::readlink));
1261
+ exports.insert("truncate".into(), Value::Native(fsx::truncate));
1262
+ exports.insert("truncateSync".into(), Value::Native(fsx::truncate));
1263
+ exports.insert("mkdtemp".into(), Value::Native(fsx::mkdtemp));
1264
+ exports.insert("mkdtempSync".into(), Value::Native(fsx::mkdtemp));
1265
+ exports.insert("cp".into(), Value::Native(fsx::cp));
1266
+ exports.insert("cpSync".into(), Value::Native(fsx::cp));
1267
+ exports.insert("access".into(), Value::Native(fsx::access));
1268
+ exports.insert("accessSync".into(), Value::Native(fsx::access));
1269
+ exports.insert("constants".into(), fsx::constants());
1135
1270
  Ok(Value::object(exports))
1136
1271
  }
1137
1272
  #[cfg(not(feature = "fs"))]
@@ -1141,6 +1276,41 @@ impl Evaluator {
1141
1276
  ));
1142
1277
  }
1143
1278
  }
1279
+ "tish:fs/promises" => {
1280
+ #[cfg(feature = "fs")]
1281
+ {
1282
+ use natives::fsx;
1283
+ let mut exports: PropMap = PropMap::default();
1284
+ exports.insert("readFile".into(), Value::Native(fsx::read_file_p));
1285
+ exports.insert("readFileBytes".into(), Value::Native(fsx::read_file_bytes_p));
1286
+ exports.insert("writeFile".into(), Value::Native(fsx::write_file_p));
1287
+ exports.insert("appendFile".into(), Value::Native(fsx::append_file_p));
1288
+ exports.insert("exists".into(), Value::Native(fsx::exists_p));
1289
+ exports.insert("mkdir".into(), Value::Native(fsx::mkdir_p));
1290
+ exports.insert("readdir".into(), Value::Native(fsx::readdir_p));
1291
+ exports.insert("stat".into(), Value::Native(fsx::stat_p));
1292
+ exports.insert("lstat".into(), Value::Native(fsx::lstat_p));
1293
+ exports.insert("rm".into(), Value::Native(fsx::rm_p));
1294
+ exports.insert("rmdir".into(), Value::Native(fsx::rmdir_p));
1295
+ exports.insert("unlink".into(), Value::Native(fsx::unlink_p));
1296
+ exports.insert("rename".into(), Value::Native(fsx::rename_p));
1297
+ exports.insert("copyFile".into(), Value::Native(fsx::copy_file_p));
1298
+ exports.insert("realpath".into(), Value::Native(fsx::realpath_p));
1299
+ exports.insert("readlink".into(), Value::Native(fsx::readlink_p));
1300
+ exports.insert("truncate".into(), Value::Native(fsx::truncate_p));
1301
+ exports.insert("mkdtemp".into(), Value::Native(fsx::mkdtemp_p));
1302
+ exports.insert("cp".into(), Value::Native(fsx::cp_p));
1303
+ exports.insert("access".into(), Value::Native(fsx::access_p));
1304
+ exports.insert("constants".into(), fsx::constants());
1305
+ Ok(Value::object(exports))
1306
+ }
1307
+ #[cfg(not(feature = "fs"))]
1308
+ {
1309
+ return Err(EvalError::Error(
1310
+ "tish:fs/promises requires the fs feature. Rebuild with: cargo build -p tishlang --features fs".into(),
1311
+ ));
1312
+ }
1313
+ }
1144
1314
  "tish:http" => {
1145
1315
  #[cfg(feature = "http")]
1146
1316
  {
@@ -1244,6 +1414,8 @@ impl Evaluator {
1244
1414
  exports.insert("cwd".into(), Value::Native(natives::process_cwd));
1245
1415
  exports.insert("exec".into(), Value::Native(natives::process_exec));
1246
1416
  exports.insert("execFile".into(), Value::Native(natives::process_exec_file));
1417
+ exports.insert("execCapture".into(), Value::Native(natives::process_exec_capture));
1418
+ exports.insert("execFileCapture".into(), Value::Native(natives::process_exec_file_capture));
1247
1419
  let argv: Vec<Value> = tishlang_core::process_argv()
1248
1420
  .into_iter()
1249
1421
  .map(|s| Value::String(s.into()))
@@ -1264,6 +1436,8 @@ impl Evaluator {
1264
1436
  process_obj.insert("cwd".into(), Value::Native(natives::process_cwd));
1265
1437
  process_obj.insert("exec".into(), Value::Native(natives::process_exec));
1266
1438
  process_obj.insert("execFile".into(), Value::Native(natives::process_exec_file));
1439
+ process_obj.insert("execCapture".into(), Value::Native(natives::process_exec_capture));
1440
+ process_obj.insert("execFileCapture".into(), Value::Native(natives::process_exec_file_capture));
1267
1441
  process_obj.insert("argv".into(), Value::Array(Rc::new(RefCell::new(argv))));
1268
1442
  process_obj.insert("env".into(), Value::object(env_obj));
1269
1443
  exports.insert(
@@ -1538,7 +1712,58 @@ impl Evaluator {
1538
1712
  {
1539
1713
  let obj = self.eval_expr(object)?;
1540
1714
  let arg_vals = self.eval_call_args(args)?;
1541
-
1715
+
1716
+ // `Array.from(source, mapFn?)` — handled inline so the mapFn callback runs with the
1717
+ // interpreter context (a plain native has no `self`). Covers array / string / Set /
1718
+ // Map / iterator (via `__drain__`) and array-like `{ length }`. (#440)
1719
+ if let Expr::Ident { name, .. } = object.as_ref() {
1720
+ if name.as_ref() == "Array" && method_name.as_ref() == "from" {
1721
+ let source = arg_vals.first().cloned().unwrap_or(Value::Null);
1722
+ let items: Vec<Value> = match &source {
1723
+ Value::Array(a) => a.borrow().clone(),
1724
+ Value::String(s) => s
1725
+ .chars()
1726
+ .map(|c| Value::String(std::sync::Arc::from(c.to_string())))
1727
+ .collect(),
1728
+ _ => {
1729
+ if let Some(d) = self.drain_eval_iterator(&source) {
1730
+ d
1731
+ } else if matches!(&source, Value::Object(_)) {
1732
+ let len = self
1733
+ .get_prop(&source, "length")
1734
+ .ok()
1735
+ .and_then(|v| match v {
1736
+ Value::Number(n) if n.is_finite() && n >= 0.0 => {
1737
+ Some(n as usize)
1738
+ }
1739
+ _ => None,
1740
+ })
1741
+ .unwrap_or(0);
1742
+ (0..len)
1743
+ .map(|i| {
1744
+ self.get_prop(&source, &i.to_string())
1745
+ .unwrap_or(Value::Null)
1746
+ })
1747
+ .collect()
1748
+ } else {
1749
+ Vec::new()
1750
+ }
1751
+ }
1752
+ };
1753
+ let out = match arg_vals.get(1) {
1754
+ Some(f) if !matches!(f, Value::Null) => {
1755
+ let mut o = Vec::with_capacity(items.len());
1756
+ for (i, item) in items.into_iter().enumerate() {
1757
+ o.push(self.call_func(f, &[item, Value::Number(i as f64)])?);
1758
+ }
1759
+ o
1760
+ }
1761
+ _ => items,
1762
+ };
1763
+ return Ok(Value::array(out));
1764
+ }
1765
+ }
1766
+
1542
1767
  // Array methods
1543
1768
  if let Value::Array(arr) = &obj {
1544
1769
  match method_name.as_ref() {
@@ -1569,13 +1794,66 @@ impl Evaluator {
1569
1794
  "indexOf" => {
1570
1795
  let search = arg_vals.first().cloned().unwrap_or(Value::Null);
1571
1796
  let arr_borrow = arr.borrow();
1572
- for (i, v) in arr_borrow.iter().enumerate() {
1797
+ // fromIndex: positive clamps to [0, len]; negative counts from the end.
1798
+ let len = arr_borrow.len() as i64;
1799
+ let start = match arg_vals.get(1) {
1800
+ Some(Value::Number(n)) if *n >= 0.0 => (*n as i64).min(len).max(0) as usize,
1801
+ Some(Value::Number(n)) if *n < 0.0 => ((len + *n as i64).max(0)) as usize,
1802
+ _ => 0,
1803
+ };
1804
+ for (i, v) in arr_borrow.iter().enumerate().skip(start) {
1573
1805
  if v.strict_eq(&search) {
1574
1806
  return Ok(Value::Number(i as f64));
1575
1807
  }
1576
1808
  }
1577
1809
  return Ok(Value::Number(-1.0));
1578
1810
  }
1811
+ "lastIndexOf" => {
1812
+ let search = arg_vals.first().cloned().unwrap_or(Value::Null);
1813
+ let arr_borrow = arr.borrow();
1814
+ let len = arr_borrow.len() as i64;
1815
+ let start = match arg_vals.get(1) {
1816
+ Some(Value::Number(n)) if *n >= 0.0 => (*n as i64).min(len - 1),
1817
+ Some(Value::Number(n)) => len + *n as i64,
1818
+ _ => len - 1,
1819
+ };
1820
+ let mut i = start;
1821
+ while i >= 0 {
1822
+ if let Some(v) = arr_borrow.get(i as usize) {
1823
+ if v.strict_eq(&search) {
1824
+ return Ok(Value::Number(i as f64));
1825
+ }
1826
+ }
1827
+ i -= 1;
1828
+ }
1829
+ return Ok(Value::Number(-1.0));
1830
+ }
1831
+ "copyWithin" => {
1832
+ let mut v = arr.borrow_mut();
1833
+ let len = v.len() as i64;
1834
+ let norm = |x: Option<&Value>, dflt: i64| -> usize {
1835
+ match x {
1836
+ Some(Value::Number(n)) => {
1837
+ let n = *n as i64;
1838
+ (if n < 0 { len + n } else { n }).clamp(0, len) as usize
1839
+ }
1840
+ _ => dflt.clamp(0, len) as usize,
1841
+ }
1842
+ };
1843
+ let t = norm(arg_vals.first(), 0);
1844
+ let s = norm(arg_vals.get(1), 0);
1845
+ let e = norm(arg_vals.get(2), len);
1846
+ let lu = len as usize;
1847
+ if s < e && t < lu {
1848
+ let count = (e - s).min(lu - t);
1849
+ let block: Vec<Value> = v[s..s + count].to_vec();
1850
+ for (k, val) in block.into_iter().enumerate() {
1851
+ v[t + k] = val;
1852
+ }
1853
+ }
1854
+ drop(v);
1855
+ return Ok(Value::Array(arr.clone()));
1856
+ }
1579
1857
  "includes" => {
1580
1858
  let search = arg_vals.first().cloned().unwrap_or(Value::Null);
1581
1859
  let arr_borrow = arr.borrow();
@@ -1617,6 +1895,91 @@ impl Evaluator {
1617
1895
  arr.borrow_mut().reverse();
1618
1896
  return Ok(obj.clone());
1619
1897
  }
1898
+ "toReversed" => {
1899
+ // ES2023: a reversed COPY; the receiver is untouched.
1900
+ let mut v = arr.borrow().clone();
1901
+ v.reverse();
1902
+ return Ok(Value::Array(Rc::new(RefCell::new(v))));
1903
+ }
1904
+ "toSorted" => {
1905
+ // ES2023: a sorted COPY. Uses the general comparator/default path (the
1906
+ // numeric fast-paths in `sort` are speed-only and give identical order).
1907
+ let comparator = arg_vals.into_iter().next();
1908
+ let arr_values: Vec<Value> = arr.borrow().clone();
1909
+ let len = arr_values.len();
1910
+ let mut indices: Vec<usize> = (0..len).collect();
1911
+ if let Some(cmp_fn) = comparator {
1912
+ let mut pending: Option<EvalError> = None;
1913
+ indices.sort_by(|&i, &j| {
1914
+ if pending.is_some() {
1915
+ return std::cmp::Ordering::Equal;
1916
+ }
1917
+ match self.call_func(&cmp_fn, &[arr_values[i].clone(), arr_values[j].clone()]) {
1918
+ Ok(Value::Number(n)) if n < 0.0 => std::cmp::Ordering::Less,
1919
+ Ok(Value::Number(n)) if n > 0.0 => std::cmp::Ordering::Greater,
1920
+ Ok(_) => std::cmp::Ordering::Equal,
1921
+ Err(e) => { pending = Some(e); std::cmp::Ordering::Equal }
1922
+ }
1923
+ });
1924
+ if let Some(e) = pending {
1925
+ return Err(e);
1926
+ }
1927
+ } else {
1928
+ indices.sort_by(|&i, &j| {
1929
+ arr_values[i].to_string().cmp(&arr_values[j].to_string())
1930
+ });
1931
+ }
1932
+ let sorted: Vec<Value> =
1933
+ indices.into_iter().map(|i| arr_values[i].clone()).collect();
1934
+ return Ok(Value::Array(Rc::new(RefCell::new(sorted))));
1935
+ }
1936
+ "with" => {
1937
+ // ES2023: a COPY with one index replaced; negative index counts from the
1938
+ // end; out-of-range throws a catchable RangeError (matches vm/native/node).
1939
+ let index = arg_vals.first().cloned().unwrap_or(Value::Null);
1940
+ let value = arg_vals.get(1).cloned().unwrap_or(Value::Null);
1941
+ let mut v = arr.borrow().clone();
1942
+ let len = v.len() as i64;
1943
+ let rel = match index {
1944
+ Value::Number(n) if n.is_finite() => n as i64,
1945
+ _ => 0,
1946
+ };
1947
+ let actual = if rel >= 0 { rel } else { len + rel };
1948
+ if actual < 0 || actual >= len {
1949
+ let err = crate::natives::range_error_construct(&[Value::String(
1950
+ format!("Invalid index : {}", rel).into(),
1951
+ )])
1952
+ .unwrap_or(Value::Null);
1953
+ return Err(EvalError::Throw(err));
1954
+ }
1955
+ v[actual as usize] = value;
1956
+ return Ok(Value::Array(Rc::new(RefCell::new(v))));
1957
+ }
1958
+ "toSpliced" => {
1959
+ // ES2023: a COPY with the splice applied; returns the resulting array
1960
+ // (splice returns the removed elements). Same normalization as `splice`.
1961
+ let mut result = arr.borrow().clone();
1962
+ let len = result.len() as i64;
1963
+ let start = match arg_vals.first() {
1964
+ Some(Value::Number(n)) => {
1965
+ let n = *n as i64;
1966
+ if n < 0 { (len + n).max(0) as usize } else { n.min(len) as usize }
1967
+ }
1968
+ _ => 0,
1969
+ };
1970
+ let delete_count = match arg_vals.get(1) {
1971
+ Some(Value::Number(n)) => (*n as i64).max(0) as usize,
1972
+ _ => (len as usize).saturating_sub(start),
1973
+ };
1974
+ let actual_delete = delete_count.min(result.len().saturating_sub(start));
1975
+ result.drain(start..start + actual_delete);
1976
+ if arg_vals.len() > 2 {
1977
+ for (i, item) in arg_vals[2..].iter().cloned().enumerate() {
1978
+ result.insert(start + i, item);
1979
+ }
1980
+ }
1981
+ return Ok(Value::Array(Rc::new(RefCell::new(result))));
1982
+ }
1620
1983
  "fill" => {
1621
1984
  // Array.prototype.fill(value, start?, end?) — in place (issue #76).
1622
1985
  let value = arg_vals.first().cloned().unwrap_or(Value::Null);
@@ -1912,6 +2275,62 @@ impl Evaluator {
1912
2275
  }
1913
2276
  return Ok(acc);
1914
2277
  }
2278
+ "reduceRight" => {
2279
+ let callback = arg_vals.first().cloned().unwrap_or(Value::Null);
2280
+ let arr_borrow = arr.borrow().clone();
2281
+ let arr_value = Value::Array(arr.clone());
2282
+ let len = arr_borrow.len();
2283
+ // With an initial value, fold indices len-1..0; without, seed from the
2284
+ // last element and fold len-2..0. Empty + no init → TypeError.
2285
+ let (mut acc, start) = if arg_vals.len() > 1 {
2286
+ (arg_vals[1].clone(), len)
2287
+ } else if len > 0 {
2288
+ (arr_borrow[len - 1].clone(), len - 1)
2289
+ } else {
2290
+ return Err(EvalError::Error("Reduce of empty array with no initial value".to_string()));
2291
+ };
2292
+ let mut i = start;
2293
+ while i > 0 {
2294
+ i -= 1;
2295
+ acc = self.call_func(&callback, &[acc, arr_borrow[i].clone(), Value::Number(i as f64), arr_value.clone()])?;
2296
+ }
2297
+ return Ok(acc);
2298
+ }
2299
+ "keys" => {
2300
+ let len = arr.borrow().len();
2301
+ let it = tishlang_builtins::iterator::array_iterator(
2302
+ (0..len).map(|i| tishlang_core::Value::Number(i as f64)).collect(),
2303
+ );
2304
+ return Ok(crate::value_convert::core_to_eval(it));
2305
+ }
2306
+ "values" => {
2307
+ let items: Vec<tishlang_core::Value> = arr
2308
+ .borrow()
2309
+ .iter()
2310
+ .map(|v| {
2311
+ crate::value_convert::eval_to_core(v)
2312
+ .unwrap_or(tishlang_core::Value::Null)
2313
+ })
2314
+ .collect();
2315
+ let it = tishlang_builtins::iterator::array_iterator(items);
2316
+ return Ok(crate::value_convert::core_to_eval(it));
2317
+ }
2318
+ "entries" => {
2319
+ let items: Vec<tishlang_core::Value> = arr
2320
+ .borrow()
2321
+ .iter()
2322
+ .enumerate()
2323
+ .map(|(i, v)| {
2324
+ tishlang_core::Value::Array(tishlang_core::VmRef::new(vec![
2325
+ tishlang_core::Value::Number(i as f64),
2326
+ crate::value_convert::eval_to_core(v)
2327
+ .unwrap_or(tishlang_core::Value::Null),
2328
+ ]))
2329
+ })
2330
+ .collect();
2331
+ let it = tishlang_builtins::iterator::array_iterator(items);
2332
+ return Ok(crate::value_convert::core_to_eval(it));
2333
+ }
1915
2334
  "find" => {
1916
2335
  let callback = arg_vals.first().cloned().unwrap_or(Value::Null);
1917
2336
  let arr_borrow = arr.borrow().clone(); // snapshot; drop borrow before callbacks
@@ -2227,6 +2646,32 @@ impl Evaluator {
2227
2646
  "trim" => {
2228
2647
  return Ok(Value::String(s.trim().into()));
2229
2648
  }
2649
+ "trimStart" => {
2650
+ return Ok(Value::String(s.trim_start().into()));
2651
+ }
2652
+ "trimEnd" => {
2653
+ return Ok(Value::String(s.trim_end().into()));
2654
+ }
2655
+ "normalize" => {
2656
+ let form = match arg_vals.first() {
2657
+ None | Some(Value::Null) => "NFC".to_string(),
2658
+ Some(Value::String(f)) => f.to_string(),
2659
+ Some(v) => v.to_string(),
2660
+ };
2661
+ match tishlang_builtins::string::normalize_form(s, &form) {
2662
+ Some(out) => return Ok(Value::String(out.into())),
2663
+ None => {
2664
+ let err = crate::natives::range_error_construct(&[
2665
+ Value::String(
2666
+ "The normalization form should be one of NFC, NFD, NFKC, NFKD."
2667
+ .into(),
2668
+ ),
2669
+ ])
2670
+ .unwrap_or(Value::Null);
2671
+ return Err(EvalError::Throw(err));
2672
+ }
2673
+ }
2674
+ }
2230
2675
  "toUpperCase" => {
2231
2676
  return Ok(Value::String(s.to_uppercase().into()));
2232
2677
  }
@@ -2238,14 +2683,28 @@ impl Evaluator {
2238
2683
  Some(Value::String(ss)) => ss.as_ref(),
2239
2684
  _ => return Ok(Value::Bool(false)),
2240
2685
  };
2241
- return Ok(Value::Bool(s.starts_with(search)));
2686
+ // position: test as if the string began at char `position`.
2687
+ let pos = match arg_vals.get(1) {
2688
+ Some(Value::Number(n)) if *n > 0.0 => *n as usize,
2689
+ _ => 0,
2690
+ };
2691
+ let byte_start = s.char_indices().nth(pos).map(|(b, _)| b).unwrap_or(s.len());
2692
+ return Ok(Value::Bool(s[byte_start..].starts_with(search)));
2242
2693
  }
2243
2694
  "endsWith" => {
2244
2695
  let search = match arg_vals.first() {
2245
2696
  Some(Value::String(ss)) => ss.as_ref(),
2246
2697
  _ => return Ok(Value::Bool(false)),
2247
2698
  };
2248
- return Ok(Value::Bool(s.ends_with(search)));
2699
+ // endPosition: test as if the string ended at char `endPosition`.
2700
+ let char_count = s.chars().count();
2701
+ let end = match arg_vals.get(1) {
2702
+ Some(Value::Number(n)) if *n >= 0.0 => (*n as usize).min(char_count),
2703
+ Some(Value::Number(_)) => 0,
2704
+ _ => char_count,
2705
+ };
2706
+ let byte_end = s.char_indices().nth(end).map(|(b, _)| b).unwrap_or(s.len());
2707
+ return Ok(Value::Bool(s[..byte_end].ends_with(search)));
2249
2708
  }
2250
2709
  "replace" => {
2251
2710
  #[cfg(feature = "regex")]
@@ -2285,6 +2744,17 @@ impl Evaluator {
2285
2744
  Some(Value::String(ss)) => ss.to_string(),
2286
2745
  _ => String::new(),
2287
2746
  };
2747
+ if replacement.contains('$') && !search.is_empty() {
2748
+ let sref = s.as_ref();
2749
+ if let Some(m) = sref.find(&search) {
2750
+ let mut out = String::with_capacity(sref.len());
2751
+ out.push_str(&sref[..m]);
2752
+ out.push_str(&tishlang_builtins::string::expand_replacement(
2753
+ &replacement, &search, &sref[..m], &sref[m + search.len()..]));
2754
+ out.push_str(&sref[m + search.len()..]);
2755
+ return Ok(Value::String(out.into()));
2756
+ }
2757
+ }
2288
2758
  return Ok(Value::String(s.replacen(&search, &replacement, 1).into()));
2289
2759
  }
2290
2760
  #[cfg(feature = "regex")]
@@ -2299,6 +2769,21 @@ impl Evaluator {
2299
2769
  Some(Value::String(ss)) => ss.to_string(),
2300
2770
  _ => String::new(),
2301
2771
  };
2772
+ if replacement.contains('$') && !search.is_empty() {
2773
+ let sref = s.as_ref();
2774
+ let mut out = String::with_capacity(sref.len());
2775
+ let (mut last, mut start) = (0usize, 0usize);
2776
+ while let Some(pos) = sref[start..].find(&search) {
2777
+ let m = start + pos;
2778
+ out.push_str(&sref[last..m]);
2779
+ out.push_str(&tishlang_builtins::string::expand_replacement(
2780
+ &replacement, &search, &sref[..m], &sref[m + search.len()..]));
2781
+ last = m + search.len();
2782
+ start = last;
2783
+ }
2784
+ out.push_str(&sref[last..]);
2785
+ return Ok(Value::String(out.into()));
2786
+ }
2302
2787
  return Ok(Value::String(s.replace(&search, &replacement).into()));
2303
2788
  }
2304
2789
  "charAt" => {
@@ -2334,6 +2819,35 @@ impl Evaluator {
2334
2819
  .map(|c| Value::Number(c as u32 as f64))
2335
2820
  .unwrap_or(Value::Number(f64::NAN)));
2336
2821
  }
2822
+ "codePointAt" => {
2823
+ let idx = match arg_vals.first() {
2824
+ Some(Value::Number(n)) if *n >= 0.0 => *n as usize,
2825
+ _ => return Ok(Value::Null),
2826
+ };
2827
+ return Ok(nth_char_cached(s, idx)
2828
+ .map(|c| Value::Number(c as u32 as f64))
2829
+ .unwrap_or(Value::Null));
2830
+ }
2831
+ "substr" => {
2832
+ // (interp lacked substr; vm/native had it — this closes the gap.)
2833
+ let chars: Vec<char> = s.chars().collect();
2834
+ let len = chars.len() as i64;
2835
+ let mut start = match arg_vals.first() {
2836
+ Some(Value::Number(n)) => *n as i64,
2837
+ _ => 0,
2838
+ };
2839
+ if start < 0 {
2840
+ start = (len + start).max(0);
2841
+ }
2842
+ let start = (start as usize).min(chars.len());
2843
+ let count = match arg_vals.get(1) {
2844
+ Some(Value::Number(n)) => (*n as i64).max(0) as usize,
2845
+ _ => chars.len() - start,
2846
+ };
2847
+ let out: String =
2848
+ chars[start..(start + count).min(chars.len())].iter().collect();
2849
+ return Ok(Value::String(out.into()));
2850
+ }
2337
2851
  "repeat" => {
2338
2852
  let count = match arg_vals.first() {
2339
2853
  Some(Value::Number(n)) if *n >= 0.0 => *n as usize,
@@ -2383,6 +2897,35 @@ impl Evaluator {
2383
2897
  return Ok(Value::Null);
2384
2898
  }
2385
2899
  #[cfg(feature = "regex")]
2900
+ "matchAll" => {
2901
+ let regexp = arg_vals.first().cloned().unwrap_or(Value::Null);
2902
+ match crate::regex::string_match_all(s, &regexp) {
2903
+ Ok(matches) => {
2904
+ let core_matches: Vec<tishlang_core::Value> = matches
2905
+ .iter()
2906
+ .map(|m| {
2907
+ crate::value_convert::eval_to_core(m)
2908
+ .unwrap_or(tishlang_core::Value::Null)
2909
+ })
2910
+ .collect();
2911
+ let it = tishlang_builtins::iterator::array_iterator(
2912
+ core_matches,
2913
+ );
2914
+ return Ok(crate::value_convert::core_to_eval(it));
2915
+ }
2916
+ Err(()) => {
2917
+ let err = crate::natives::type_error_construct(&[
2918
+ Value::String(
2919
+ "String.prototype.matchAll called with a non-global RegExp argument"
2920
+ .into(),
2921
+ ),
2922
+ ])
2923
+ .unwrap_or(Value::Null);
2924
+ return Err(EvalError::Throw(err));
2925
+ }
2926
+ }
2927
+ }
2928
+ #[cfg(feature = "regex")]
2386
2929
  "search" => {
2387
2930
  if let Some(regexp) = arg_vals.first() {
2388
2931
  return Ok(crate::regex::string_search(s, regexp));
@@ -2408,6 +2951,40 @@ impl Evaluator {
2408
2951
  let formatted = tishlang_builtins::number::to_fixed_str(*n, digits as usize);
2409
2952
  return Ok(Value::String(formatted.into()));
2410
2953
  }
2954
+ if method_name.as_ref() == "toExponential" {
2955
+ let digits = arg_vals.first().and_then(|v| match v {
2956
+ Value::Number(d) => Some((*d as i32).clamp(0, 100) as usize),
2957
+ _ => None,
2958
+ });
2959
+ return Ok(Value::String(
2960
+ tishlang_builtins::number::to_exponential_str(*n, digits).into(),
2961
+ ));
2962
+ }
2963
+ if method_name.as_ref() == "toPrecision" {
2964
+ match arg_vals.first() {
2965
+ Some(Value::Number(p)) => {
2966
+ let pi = *p as i32;
2967
+ if !(1..=100).contains(&pi) && n.is_finite() {
2968
+ let err = crate::natives::range_error_construct(&[
2969
+ Value::String(
2970
+ "toPrecision() argument must be between 1 and 100"
2971
+ .into(),
2972
+ ),
2973
+ ])
2974
+ .unwrap_or(Value::Null);
2975
+ return Err(EvalError::Throw(err));
2976
+ }
2977
+ return Ok(Value::String(
2978
+ tishlang_builtins::number::to_precision_str(*n, pi).into(),
2979
+ ));
2980
+ }
2981
+ _ => {
2982
+ return Ok(Value::String(
2983
+ tishlang_core::js_number_to_string(*n).into(),
2984
+ ))
2985
+ }
2986
+ }
2987
+ }
2411
2988
  if method_name.as_ref() == "toString" {
2412
2989
  // Shares the VM/native formatting via the backend-agnostic helper
2413
2990
  // (issue #59). Radix defaults to 10; 2–36 supported, else RangeError.
@@ -2821,6 +3398,12 @@ impl Evaluator {
2821
3398
  Value::Array(arr) => {
2822
3399
  let idx = match &idx_val {
2823
3400
  Value::Number(n) => *n as usize,
3401
+ // Canonical integer string key → index (#432); a non-index string key
3402
+ // can't live in a Vec-backed array, so the write is a no-op.
3403
+ Value::String(s) => match tishlang_core::str_to_array_index(s) {
3404
+ Some(i) => i,
3405
+ None => return Ok(val),
3406
+ },
2824
3407
  _ => return Err(EvalError::Error(format!(
2825
3408
  "Array index must be a number, got {:?}",
2826
3409
  idx_val
@@ -3331,7 +3914,31 @@ impl Evaluator {
3331
3914
  }
3332
3915
  Err(EvalError::Error("Not a function".to_string()))
3333
3916
  }
3334
- Value::Native(native_fn) => native_fn(args).map_err(EvalError::Error),
3917
+ Value::Native(native_fn) => {
3918
+ // #122: Node fs callback form — `readFile(path, (err, data) => …)`. The self-less
3919
+ // `Value::Native` bridge can't invoke an eval callback (a core `Callable` can't
3920
+ // re-enter the interpreter), so handle it here where `&self` is available: run the
3921
+ // op's core on the non-callback args, split its Result into `(err, data)`, and call
3922
+ // the callback. Matches the VM/native backends (fs_ext runs the callback in-core).
3923
+ #[cfg(feature = "fs")]
3924
+ if let Some(cb @ Value::Function { .. }) = args.last() {
3925
+ if let Some(core_op) = natives::fsx::callback_core(*native_fn) {
3926
+ let cb = cb.clone();
3927
+ let core_args = args[..args.len() - 1]
3928
+ .iter()
3929
+ .map(crate::value_convert::eval_to_core)
3930
+ .collect::<Result<Vec<_>, String>>()
3931
+ .map_err(EvalError::Error)?;
3932
+ let (err, data) = match core_op(&core_args) {
3933
+ Ok(v) => (Value::Null, crate::value_convert::core_to_eval(v)),
3934
+ Err(e) => (crate::value_convert::core_to_eval(e), Value::Null),
3935
+ };
3936
+ self.call_func(&cb, &[err, data])?;
3937
+ return Ok(Value::Null);
3938
+ }
3939
+ }
3940
+ native_fn(args).map_err(EvalError::Error)
3941
+ }
3335
3942
  #[cfg(feature = "http")]
3336
3943
  Value::PromiseResolver(r) => {
3337
3944
  let value = args.first().cloned().unwrap_or(Value::Null);
@@ -3959,6 +4566,15 @@ impl Evaluator {
3959
4566
  /// `.entries()` — into a `Vec` by calling `next()` until `done`. Returns `None` when `v`
3960
4567
  /// is not such an object. Shared by `for…of` and spread so both treat iterators like JS.
3961
4568
  fn drain_eval_iterator(&self, v: &Value) -> Option<Vec<Value>> {
4569
+ // A string spreads as its characters (`[...s]`, `f(...s)`); for-of over a string is handled
4570
+ // separately at the loop site, but spread routes through here. (#440)
4571
+ if let Value::String(s) = v {
4572
+ return Some(
4573
+ s.chars()
4574
+ .map(|c| Value::String(std::sync::Arc::from(c.to_string())))
4575
+ .collect(),
4576
+ );
4577
+ }
3962
4578
  if !matches!(v, Value::Object(_)) {
3963
4579
  return None;
3964
4580
  }
@@ -4085,6 +4701,12 @@ impl Evaluator {
4085
4701
  Value::Array(arr) => {
4086
4702
  let idx = match index {
4087
4703
  Value::Number(n) => *n as usize,
4704
+ // A canonical integer string key ("0","1",…) indexes the array (#432), so
4705
+ // `arr["0"] === arr[0]`; any other key reads Null.
4706
+ Value::String(s) => match tishlang_core::str_to_array_index(s) {
4707
+ Some(i) => i,
4708
+ None => return Ok(Value::Null),
4709
+ },
4088
4710
  _ => return Ok(Value::Null),
4089
4711
  };
4090
4712
  Ok(arr.borrow().get(idx).cloned().unwrap_or(Value::Null))
@@ -4148,14 +4770,16 @@ impl Evaluator {
4148
4770
  tishlang_core::write_json_number(&mut s, *n);
4149
4771
  s
4150
4772
  }
4151
- Value::String(s) => format!(
4152
- "\"{}\"",
4153
- s.replace('\\', "\\\\")
4154
- .replace('"', "\\\"")
4155
- .replace('\n', "\\n")
4156
- .replace('\r', "\\r")
4157
- .replace('\t', "\\t")
4158
- ),
4773
+ Value::String(s) => {
4774
+ // Use the shared core escaper so control chars (`\b`, `\f`, `\u00xx`) are escaped
4775
+ // exactly like the vm/native path — the old hand-rolled `.replace()` chain missed
4776
+ // them and produced invalid JSON (raw control bytes). #437
4777
+ let mut out = String::with_capacity(s.len() + 2);
4778
+ out.push('"');
4779
+ tishlang_core::escape_json_string_into(&mut out, s);
4780
+ out.push('"');
4781
+ out
4782
+ }
4159
4783
  Value::Array(arr) => {
4160
4784
  let ptr = Rc::as_ptr(arr) as *const ();
4161
4785
  if ancestors.contains(&ptr) {
@@ -4229,16 +4853,26 @@ impl Evaluator {
4229
4853
  }
4230
4854
 
4231
4855
  fn object_keys(args: &[Value]) -> Result<Value, String> {
4232
- if let Some(Value::Object(obj)) = args.first() {
4233
- let keys: Vec<Value> = obj
4234
- .borrow()
4235
- .strings
4236
- .keys()
4237
- .map(|k| Value::String(Arc::clone(k)))
4238
- .collect();
4239
- Ok(Value::Array(Rc::new(RefCell::new(keys))))
4240
- } else {
4241
- Ok(Value::Array(Rc::new(RefCell::new(Vec::new()))))
4856
+ match args.first() {
4857
+ Some(Value::Object(obj)) => {
4858
+ let keys: Vec<Value> = obj
4859
+ .borrow()
4860
+ .strings
4861
+ .keys()
4862
+ .map(|k| Value::String(Arc::clone(k)))
4863
+ .collect();
4864
+ Ok(Value::Array(Rc::new(RefCell::new(keys))))
4865
+ }
4866
+ // `Object.keys(array)` → index strings "0".."len-1" (own enumerable keys are the indices),
4867
+ // matching node and the shared builtin.
4868
+ Some(Value::Array(arr)) => {
4869
+ let len = arr.borrow().len();
4870
+ let keys: Vec<Value> = (0..len)
4871
+ .map(|i| Value::String(Arc::from(i.to_string())))
4872
+ .collect();
4873
+ Ok(Value::Array(Rc::new(RefCell::new(keys))))
4874
+ }
4875
+ _ => Ok(Value::Array(Rc::new(RefCell::new(Vec::new())))),
4242
4876
  }
4243
4877
  }
4244
4878