@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
@@ -383,6 +383,177 @@ pub enum Value {
383
383
  #[cfg(target_pointer_width = "64")]
384
384
  const _: () = assert!(std::mem::size_of::<Value>() == 24);
385
385
 
386
+ // ─────────────────────────────────────────────────────────────────────────────
387
+ // #201 Stage A — representation abstraction (behavior-preserving, zero perf effect).
388
+ //
389
+ // `Value` is an enum today. To eventually swap it for a NaN-boxed `struct Value(u64)`
390
+ // (#201 Stage C) WITHOUT thousands of simultaneous edits, call sites should go through
391
+ // this abstraction instead of matching the enum directly:
392
+ // * construct via the named constructors (`Value::number`, `::boolean`, `::string`, …),
393
+ // * inspect via `v.unpack()` (a borrowed view) or the `as_*` / `tag` accessors.
394
+ // Once sites are migrated, the representation changes behind these method bodies and the
395
+ // call sites are untouched. Stage B (the 24→16 size-shrink) is intentionally SKIPPED — it
396
+ // was tried and regressed dispatch ~8-10% (see the size-guard note above); only Stage C's
397
+ // branch-free tag test can pay off. This layer is pure enabling: no behavior, no perf.
398
+ // ─────────────────────────────────────────────────────────────────────────────
399
+
400
+ /// Cheap type discriminant for a [`Value`]. `match v.tag()` will lower to a branch-free
401
+ /// tag test once `Value` is NaN-boxed (#201). Variant order/values are not part of any ABI.
402
+ #[derive(Clone, Copy, PartialEq, Eq, Debug)]
403
+ pub enum ValueTag {
404
+ Number,
405
+ String,
406
+ Bool,
407
+ Null,
408
+ Array,
409
+ NumberArray,
410
+ Object,
411
+ Symbol,
412
+ Function,
413
+ #[cfg(feature = "regex")]
414
+ RegExp,
415
+ Promise,
416
+ Opaque,
417
+ }
418
+
419
+ /// Borrowed view of a [`Value`]'s payload, mirroring the enum variants. Lets call sites
420
+ /// write `match v.unpack() { ValueRef::Number(n) => … }` instead of matching the enum
421
+ /// directly, so the underlying representation can change (#201) without touching the match
422
+ /// sites. Zero-cost — each arm borrows the existing payload in place.
423
+ pub enum ValueRef<'a> {
424
+ Number(f64),
425
+ String(&'a arcstr::ArcStr),
426
+ Bool(bool),
427
+ Null,
428
+ Array(&'a VmRef<Vec<Value>>),
429
+ NumberArray(&'a VmRef<Vec<f64>>),
430
+ Object(&'a VmRef<ObjectData>),
431
+ Symbol(&'a Arc<TishSymbol>),
432
+ Function(&'a NativeFn),
433
+ #[cfg(feature = "regex")]
434
+ RegExp(&'a VmRef<TishRegExp>),
435
+ Promise(&'a Arc<dyn TishPromise>),
436
+ Opaque(&'a Arc<dyn TishOpaque>),
437
+ }
438
+
439
+ impl Value {
440
+ /// Named constructor for a number (#201 abstraction). Prefer over `Value::Number(n)`.
441
+ #[inline]
442
+ pub fn number(n: f64) -> Self {
443
+ Value::Number(n)
444
+ }
445
+
446
+ /// Named constructor for a boolean (#201 abstraction). Prefer over `Value::Bool(b)`.
447
+ #[inline]
448
+ pub fn boolean(b: bool) -> Self {
449
+ Value::Bool(b)
450
+ }
451
+
452
+ /// Named constructor for a string (#201 abstraction). Accepts anything that converts
453
+ /// into the interned `ArcStr` (`&str`, `String`, `ArcStr`).
454
+ #[inline]
455
+ pub fn string(s: impl Into<arcstr::ArcStr>) -> Self {
456
+ Value::String(s.into())
457
+ }
458
+
459
+ /// Named constructor for null (#201 abstraction). Prefer over `Value::Null`.
460
+ #[inline]
461
+ pub fn null() -> Self {
462
+ Value::Null
463
+ }
464
+
465
+ /// Borrowed view of the payload — the migration target for `match v { … }`.
466
+ #[inline]
467
+ pub fn unpack(&self) -> ValueRef<'_> {
468
+ match self {
469
+ Value::Number(n) => ValueRef::Number(*n),
470
+ Value::String(s) => ValueRef::String(s),
471
+ Value::Bool(b) => ValueRef::Bool(*b),
472
+ Value::Null => ValueRef::Null,
473
+ Value::Array(a) => ValueRef::Array(a),
474
+ Value::NumberArray(a) => ValueRef::NumberArray(a),
475
+ Value::Object(o) => ValueRef::Object(o),
476
+ Value::Symbol(s) => ValueRef::Symbol(s),
477
+ Value::Function(f) => ValueRef::Function(f),
478
+ #[cfg(feature = "regex")]
479
+ Value::RegExp(r) => ValueRef::RegExp(r),
480
+ Value::Promise(p) => ValueRef::Promise(p),
481
+ Value::Opaque(o) => ValueRef::Opaque(o),
482
+ }
483
+ }
484
+
485
+ /// The value's type discriminant (#201 abstraction) — for cheap type checks.
486
+ #[inline]
487
+ pub fn tag(&self) -> ValueTag {
488
+ match self {
489
+ Value::Number(_) => ValueTag::Number,
490
+ Value::String(_) => ValueTag::String,
491
+ Value::Bool(_) => ValueTag::Bool,
492
+ Value::Null => ValueTag::Null,
493
+ Value::Array(_) => ValueTag::Array,
494
+ Value::NumberArray(_) => ValueTag::NumberArray,
495
+ Value::Object(_) => ValueTag::Object,
496
+ Value::Symbol(_) => ValueTag::Symbol,
497
+ Value::Function(_) => ValueTag::Function,
498
+ #[cfg(feature = "regex")]
499
+ Value::RegExp(_) => ValueTag::RegExp,
500
+ Value::Promise(_) => ValueTag::Promise,
501
+ Value::Opaque(_) => ValueTag::Opaque,
502
+ }
503
+ }
504
+
505
+ /// Extract the boolean payload, if this is a `Bool`.
506
+ #[inline]
507
+ pub fn as_bool(&self) -> Option<bool> {
508
+ match self {
509
+ Value::Bool(b) => Some(*b),
510
+ _ => None,
511
+ }
512
+ }
513
+
514
+ /// Borrow the string payload as `&str`, if this is a `String`.
515
+ #[inline]
516
+ pub fn as_str(&self) -> Option<&str> {
517
+ match self {
518
+ Value::String(s) => Some(s.as_str()),
519
+ _ => None,
520
+ }
521
+ }
522
+
523
+ /// Borrow the object payload, if this is an `Object`.
524
+ #[inline]
525
+ pub fn as_object(&self) -> Option<&VmRef<ObjectData>> {
526
+ match self {
527
+ Value::Object(o) => Some(o),
528
+ _ => None,
529
+ }
530
+ }
531
+
532
+ /// Borrow the array payload, if this is an `Array`.
533
+ #[inline]
534
+ pub fn as_array(&self) -> Option<&VmRef<Vec<Value>>> {
535
+ match self {
536
+ Value::Array(a) => Some(a),
537
+ _ => None,
538
+ }
539
+ }
540
+
541
+ /// Borrow the packed-number-array payload, if this is a `NumberArray`.
542
+ #[inline]
543
+ pub fn as_number_array(&self) -> Option<&VmRef<Vec<f64>>> {
544
+ match self {
545
+ Value::NumberArray(a) => Some(a),
546
+ _ => None,
547
+ }
548
+ }
549
+
550
+ /// True if this is `Null`.
551
+ #[inline]
552
+ pub fn is_null(&self) -> bool {
553
+ matches!(self, Value::Null)
554
+ }
555
+ }
556
+
386
557
  /// Number of properties kept inline (no heap hashmap) before promoting to a map.
387
558
  const PROPMAP_INLINE: usize = 8;
388
559
 
@@ -591,6 +762,37 @@ impl PropMap {
591
762
  m.reserve(additional);
592
763
  }
593
764
  }
765
+
766
+ /// Merge every entry of `other` into `self`, in `other`'s insertion order.
767
+ ///
768
+ /// Existing keys are overwritten (last write wins), so this preserves JS object-spread
769
+ /// semantics: `{ ...a, ...b }` lets `b` override `a`. The work is pre-sized — if the merge
770
+ /// would push the result past the inline threshold we promote to an `IndexMap` once, with the
771
+ /// final capacity, instead of growing/rehashing one key at a time. The native codegen calls
772
+ /// this for every `{ ...src }` spread, replacing the old "rebuild via AHashMap" path.
773
+ pub fn merge_from(&mut self, other: &PropMap) {
774
+ let incoming = other.len();
775
+ if incoming == 0 {
776
+ return;
777
+ }
778
+ // If the combined worst-case size escapes inline storage, promote once up front so the
779
+ // per-key inserts below never reallocate or re-promote.
780
+ if self.map.is_none() && self.inline.len() + incoming > PROPMAP_INLINE {
781
+ let mut m: IndexMap<Arc<str>, Value, ahash::RandomState> = IndexMap::with_capacity_and_hasher(
782
+ self.inline.len() + incoming,
783
+ ahash::RandomState::default(),
784
+ );
785
+ for (k, v) in self.inline.drain(..) {
786
+ m.insert(k, v);
787
+ }
788
+ self.map = Some(Box::new(m));
789
+ } else if let Some(m) = &mut self.map {
790
+ m.reserve(incoming);
791
+ }
792
+ for (k, v) in other.iter() {
793
+ self.insert(Arc::clone(k), v.clone());
794
+ }
795
+ }
594
796
  }
595
797
 
596
798
  impl FromIterator<(Arc<str>, Value)> for PropMap {
@@ -860,6 +1062,13 @@ pub fn to_uint32_value(v: &Value) -> u32 {
860
1062
  }
861
1063
 
862
1064
  /// Invoke a callable [`Value`]: [`Value::Function`], or an object exposing `__call` (e.g. `Symbol`).
1065
+ ///
1066
+ /// Calling a NON-callable (e.g. a method on `null` whose read returned `null`, or any non-function
1067
+ /// value) is a JS `TypeError`. In the native/runtime path this used to `panic!` — an UNCATCHABLE
1068
+ /// process abort (#381 class). It now PARKS a catchable `TypeError` and returns the `null` sentinel;
1069
+ /// the throw surfaces at the caller's next pending-throw checkpoint (statement boundary in emitted
1070
+ /// native code, or the `has_pending_throw()` polls in the shared builtins), so `try { null.foo() }
1071
+ /// catch (e) { … }` recovers instead of aborting — matching the VM/interpreter/node.
863
1072
  pub fn value_call(callee: &Value, args: &[Value]) -> Value {
864
1073
  match callee {
865
1074
  Value::Function(f) => f.call(args),
@@ -868,15 +1077,19 @@ pub fn value_call(callee: &Value, args: &[Value]) -> Value {
868
1077
  if let Some(inner) = inner {
869
1078
  return value_call(&inner, args);
870
1079
  }
871
- panic!(
872
- "Not a function: tried to call {:?} as a function (e.g. method on Null when read failed)",
873
- callee
874
- );
1080
+ crate::set_pending_throw(crate::not_a_function_error(format!(
1081
+ "{} is not a function",
1082
+ callee.to_display_string()
1083
+ )));
1084
+ Value::Null
1085
+ }
1086
+ _ => {
1087
+ crate::set_pending_throw(crate::not_a_function_error(format!(
1088
+ "{} is not a function",
1089
+ callee.to_display_string()
1090
+ )));
1091
+ Value::Null
875
1092
  }
876
- _ => panic!(
877
- "Not a function: tried to call {:?} as a function (e.g. method on Null when read failed)",
878
- callee
879
- ),
880
1093
  }
881
1094
  }
882
1095
 
@@ -967,26 +1180,75 @@ pub fn js_number_to_string_into(out: &mut String, value: f64) {
967
1180
  return;
968
1181
  }
969
1182
 
1183
+ // Fast path for exact integers within ±2^53 (the f64 "safe integer" range). ECMAScript ToString
1184
+ // renders any such integer in plain decimal (no sign-exponent — magnitude < 1e21 ⇒ `point <= 21`
1185
+ // in the general algorithm below), which `itoa` emits directly, bypassing the `format!("{:e}")`
1186
+ // round-trip + intermediate `String`/`char`-filter/`"0".repeat()`. This is the dominant case for
1187
+ // tally/counter/index loops (e.g. map keys like `"w" + (n % 1000)`). Bit-identical to the general
1188
+ // path for every integer it accepts: each such value is exactly representable in both f64 and i64,
1189
+ // so `value as i64` is exact (no saturation — `±2^53` is well inside i64), and the decimal form is
1190
+ // the same digits the general path would emit. Larger integers (where f64 can't represent
1191
+ // consecutive values) fall through to the general path unchanged.
1192
+ const MAX_SAFE_INT: f64 = 9_007_199_254_740_992.0; // 2^53
1193
+ if value.fract() == 0.0 && value.abs() <= MAX_SAFE_INT {
1194
+ let mut buf = itoa::Buffer::new();
1195
+ out.push_str(buf.format(value as i64));
1196
+ return;
1197
+ }
1198
+
970
1199
  let negative = value < 0.0;
971
- // Shortest round-trip digits + base-10 exponent, e.g. "6.022e23" ("6022", 23).
972
- let sci = format!("{:e}", value.abs());
973
- let (mantissa, exp_str) = sci
974
- .split_once('e')
975
- .expect("LowerExp formatting always contains 'e'");
976
- let exp: i32 = exp_str
977
- .parse()
978
- .expect("LowerExp exponent is a valid integer");
979
- let digits: String = mantissa.chars().filter(|&c| c != '.').collect();
1200
+ // Shortest round-trip digits via ryu, then reassembled into ECMAScript `Number::toString` form.
1201
+ // ryu writes the shortest correctly-rounded digits straight into a stack buffer with none of the
1202
+ // `core::fmt` `{:e}` Formatter machinery the previous version paid per call (profiled as ~40% of
1203
+ // JSON.stringify self-time on numeric payloads). number→string is the JSON / template-literal /
1204
+ // logging / `+=` primitive, so this is a broad win. The shortest round-trip digit *sequence* is
1205
+ // unique, so ryu emits the same digits as the old `{:e}` path output stays byte-identical.
1206
+ let mut ryu_buf = ryu::Buffer::new();
1207
+ // Finite & non-zero here (NaN / ±∞ / ±0 handled above), so `format_finite` is safe and cheaper.
1208
+ let s = ryu_buf.format_finite(value.abs());
1209
+
1210
+ // Parse ryu's `<int>[.<frac>][e<exp>]` into significant digits + ECMAScript decimal point.
1211
+ // `point` = ECMAScript's `n`: value = digits × 10^(point − k), with `k` = significant-digit count.
1212
+ let (mant, exp) = match s.as_bytes().iter().position(|&c| c == b'e' || c == b'E') {
1213
+ Some(i) => (&s[..i], s[i + 1..].parse::<i32>().expect("ryu exponent")),
1214
+ None => (s, 0i32),
1215
+ };
1216
+ let (int_part, frac_part) = match mant.as_bytes().iter().position(|&c| c == b'.') {
1217
+ Some(i) => (&mant[..i], &mant[i + 1..]),
1218
+ None => (mant, ""),
1219
+ };
1220
+ let frac_len = frac_part.len() as i32;
1221
+ // Raw digit run = int_part ++ frac_part (ryu's mantissa, ≤ ~18 chars for an f64).
1222
+ let mut digits_buf = [0u8; 32];
1223
+ let mut n = 0usize;
1224
+ for &c in int_part.as_bytes().iter().chain(frac_part.as_bytes()) {
1225
+ digits_buf[n] = c;
1226
+ n += 1;
1227
+ }
1228
+ // Strip leading zeros (e.g. ryu "0.1" → raw "01" → "1") …
1229
+ let mut lead = 0usize;
1230
+ while lead + 1 < n && digits_buf[lead] == b'0' {
1231
+ lead += 1;
1232
+ }
1233
+ // … and trailing zeros (defensive — shortest form has none — folded back via `trail`).
1234
+ let mut end = n;
1235
+ while end - 1 > lead && digits_buf[end - 1] == b'0' {
1236
+ end -= 1;
1237
+ }
1238
+ let trail = (n - end) as i32;
1239
+ let digits = std::str::from_utf8(&digits_buf[lead..end]).expect("ascii digits");
980
1240
  let k = digits.len() as i32; // significant digit count (≤ 17 for an f64)
981
- let point = exp + 1; // ECMAScript's `n`: value = digits × 10^(point − k)
1241
+ let point = k + exp - frac_len + trail;
982
1242
 
983
1243
  if negative {
984
1244
  out.push('-');
985
1245
  }
986
1246
  if k <= point && point <= 21 {
987
1247
  // Integer, zero-padded: digits then (point − k) trailing zeros.
988
- out.push_str(&digits);
989
- out.push_str(&"0".repeat((point - k) as usize));
1248
+ out.push_str(digits);
1249
+ for _ in 0..(point - k) {
1250
+ out.push('0');
1251
+ }
990
1252
  } else if 0 < point && point <= 21 {
991
1253
  // Decimal point inside the digit string.
992
1254
  out.push_str(&digits[..point as usize]);
@@ -995,8 +1257,10 @@ pub fn js_number_to_string_into(out: &mut String, value: f64) {
995
1257
  } else if -6 < point && point <= 0 {
996
1258
  // Leading-zero fraction: "0." then (−point) zeros then the digits.
997
1259
  out.push_str("0.");
998
- out.push_str(&"0".repeat((-point) as usize));
999
- out.push_str(&digits);
1260
+ for _ in 0..(-point) {
1261
+ out.push('0');
1262
+ }
1263
+ out.push_str(digits);
1000
1264
  } else {
1001
1265
  // Exponential: first digit, optional `.rest`, then `e±E`.
1002
1266
  let e = point - 1;
@@ -1007,13 +1271,22 @@ pub fn js_number_to_string_into(out: &mut String, value: f64) {
1007
1271
  }
1008
1272
  out.push('e');
1009
1273
  out.push(if e >= 0 { '+' } else { '-' });
1010
- out.push_str(&e.abs().to_string());
1274
+ let mut ib = itoa::Buffer::new();
1275
+ out.push_str(ib.format(e.abs()));
1011
1276
  }
1012
1277
  }
1013
1278
 
1014
1279
  impl Value {
1015
1280
  /// Convert value to display string (for console output).
1016
1281
  pub fn to_display_string(&self) -> String {
1282
+ self.to_display_string_guarded(&mut Vec::new())
1283
+ }
1284
+
1285
+ /// Cycle-safe recursive form (#381): `ancestors` holds the current path's container-cell pointers,
1286
+ /// so a self-referential array/object (`a.self = a`) renders `[Circular]` instead of recursing
1287
+ /// forever and wedging the thread. Ancestor-only (not all-visited), so a shared DAG node still
1288
+ /// renders in full.
1289
+ fn to_display_string_guarded(&self, ancestors: &mut Vec<*const ()>) -> String {
1017
1290
  match self {
1018
1291
  // Inspect form keeps the sign of negative zero (`console.log(-0)` → `-0`), unlike the
1019
1292
  // ECMAScript ToString used by `to_js_string`. See `js_number_to_string`. (#247)
@@ -1023,8 +1296,17 @@ impl Value {
1023
1296
  Value::Bool(b) => b.to_string(),
1024
1297
  Value::Null => "null".to_string(),
1025
1298
  Value::Array(arr) => {
1026
- let inner: Vec<String> =
1027
- arr.borrow().iter().map(|v| v.to_display_string()).collect();
1299
+ let ptr = arr.as_ptr();
1300
+ if ancestors.contains(&ptr) {
1301
+ return "[Circular]".to_string();
1302
+ }
1303
+ ancestors.push(ptr);
1304
+ let inner: Vec<String> = arr
1305
+ .borrow()
1306
+ .iter()
1307
+ .map(|v| v.to_display_string_guarded(ancestors))
1308
+ .collect();
1309
+ ancestors.pop();
1028
1310
  format!("[{}]", inner.join(", "))
1029
1311
  }
1030
1312
  Value::NumberArray(arr) => {
@@ -1036,12 +1318,18 @@ impl Value {
1036
1318
  format!("[{}]", inner.join(", "))
1037
1319
  }
1038
1320
  Value::Object(obj) => {
1321
+ let ptr = obj.as_ptr();
1322
+ if ancestors.contains(&ptr) {
1323
+ return "[Circular]".to_string();
1324
+ }
1325
+ ancestors.push(ptr);
1039
1326
  let inner: Vec<String> = obj
1040
1327
  .borrow()
1041
1328
  .strings
1042
1329
  .iter()
1043
- .map(|(k, v)| format!("{}: {}", k.as_ref(), v.to_display_string()))
1330
+ .map(|(k, v)| format!("{}: {}", k.as_ref(), v.to_display_string_guarded(ancestors)))
1044
1331
  .collect();
1332
+ ancestors.pop();
1045
1333
  format!("{{{}}}", inner.join(", "))
1046
1334
  }
1047
1335
  Value::Symbol(s) => {
@@ -1071,16 +1359,32 @@ impl Value {
1071
1359
  /// renders as `"null"` here (matching `String(null)`); `join` itself maps `null`/`undefined`
1072
1360
  /// elements to `""` *before* calling this, per the spec.
1073
1361
  pub fn to_js_string(&self) -> String {
1362
+ self.to_js_string_guarded(&mut Vec::new())
1363
+ }
1364
+
1365
+ /// Cycle-safe `ToString` (#381): only arrays recurse here (objects render `[object Object]`), so a
1366
+ /// cyclic array via `"" + a` would otherwise hang. A back-reference joins as `""` — matching V8's
1367
+ /// `Array.prototype.join`, where a self-referential element contributes the empty string.
1368
+ fn to_js_string_guarded(&self, ancestors: &mut Vec<*const ()>) -> String {
1074
1369
  match self {
1075
- Value::Array(arr) => arr
1076
- .borrow()
1077
- .iter()
1078
- .map(|v| match v {
1079
- Value::Null => String::new(),
1080
- other => other.to_js_string(),
1081
- })
1082
- .collect::<Vec<_>>()
1083
- .join(","),
1370
+ Value::Array(arr) => {
1371
+ let ptr = arr.as_ptr();
1372
+ if ancestors.contains(&ptr) {
1373
+ return String::new();
1374
+ }
1375
+ ancestors.push(ptr);
1376
+ let s = arr
1377
+ .borrow()
1378
+ .iter()
1379
+ .map(|v| match v {
1380
+ Value::Null => String::new(),
1381
+ other => other.to_js_string_guarded(ancestors),
1382
+ })
1383
+ .collect::<Vec<_>>()
1384
+ .join(",");
1385
+ ancestors.pop();
1386
+ s
1387
+ }
1084
1388
  Value::NumberArray(arr) => arr
1085
1389
  .borrow()
1086
1390
  .iter()
@@ -1354,6 +1658,46 @@ impl Value {
1354
1658
  }
1355
1659
  }
1356
1660
 
1661
+ #[cfg(test)]
1662
+ mod value_abstraction_tests {
1663
+ // #201 Stage A — the abstraction must exactly mirror the enum it will replace.
1664
+ use super::{Value, ValueRef, ValueTag};
1665
+
1666
+ #[test]
1667
+ fn named_ctors_report_the_right_tag() {
1668
+ assert_eq!(Value::number(1.5).tag(), ValueTag::Number);
1669
+ assert_eq!(Value::boolean(true).tag(), ValueTag::Bool);
1670
+ assert_eq!(Value::string("hi").tag(), ValueTag::String);
1671
+ assert_eq!(Value::null().tag(), ValueTag::Null);
1672
+ assert_eq!(Value::array(vec![]).tag(), ValueTag::Array);
1673
+ assert_eq!(Value::empty_object().tag(), ValueTag::Object);
1674
+ }
1675
+
1676
+ #[test]
1677
+ fn unpack_roundtrips_each_variant() {
1678
+ assert!(matches!(Value::number(3.0).unpack(), ValueRef::Number(n) if n == 3.0));
1679
+ assert!(matches!(Value::boolean(false).unpack(), ValueRef::Bool(false)));
1680
+ assert!(matches!(Value::string("x").unpack(), ValueRef::String(s) if s.as_str() == "x"));
1681
+ assert!(matches!(Value::null().unpack(), ValueRef::Null));
1682
+ assert!(matches!(Value::array(vec![]).unpack(), ValueRef::Array(_)));
1683
+ assert!(matches!(Value::empty_object().unpack(), ValueRef::Object(_)));
1684
+ }
1685
+
1686
+ #[test]
1687
+ fn accessors_extract_or_none() {
1688
+ assert_eq!(Value::number(2.0).as_number(), Some(2.0));
1689
+ assert_eq!(Value::boolean(true).as_bool(), Some(true));
1690
+ assert_eq!(Value::string("abc").as_str(), Some("abc"));
1691
+ assert!(Value::null().is_null());
1692
+ // wrong-variant accessors return None
1693
+ assert_eq!(Value::number(1.0).as_bool(), None);
1694
+ assert_eq!(Value::null().as_str(), None);
1695
+ assert!(!Value::number(0.0).is_null());
1696
+ assert!(Value::array(vec![Value::number(1.0)]).as_array().is_some());
1697
+ assert!(Value::number(1.0).as_array().is_none());
1698
+ }
1699
+ }
1700
+
1357
1701
  #[cfg(test)]
1358
1702
  mod number_to_string_tests {
1359
1703
  use super::js_number_to_string;
@@ -1371,6 +1715,14 @@ mod number_to_string_tests {
1371
1715
  (0.5, "0.5"),
1372
1716
  (-123.456, "-123.456"),
1373
1717
  (100000.0, "100000"),
1718
+ (-1000.0, "-1000"),
1719
+ // Integer fast-path boundary (±2^53): the largest safe integer takes the itoa path; the
1720
+ // next decade up (still an exact f64 integer, but > 2^53) takes the general path. Both must
1721
+ // match Node's `String(value)`.
1722
+ (9007199254740992.0, "9007199254740992"), // 2^53
1723
+ (9007199254740991.0, "9007199254740991"), // 2^53 - 1 (Number.MAX_SAFE_INTEGER)
1724
+ (-9007199254740992.0, "-9007199254740992"),
1725
+ (1e16, "10000000000000000"), // > 2^53, exact f64 integer → general path
1374
1726
  // Decimal/exponential boundary on the large side: 1e21 flips to exponential.
1375
1727
  (1e20, "100000000000000000000"),
1376
1728
  (1e21, "1e+21"),
@@ -1390,6 +1742,15 @@ mod number_to_string_tests {
1390
1742
  // Shortest round-trip mantissa (not full precision).
1391
1743
  (0.1, "0.1"),
1392
1744
  (0.1 + 0.2, "0.30000000000000004"),
1745
+ // Shortest-representation ties → ECMAScript rounds the final digit to EVEN (spec: "if
1746
+ // there are two such possible values, choose the one that is even"). These large-magnitude
1747
+ // fractionals each have two equally-short round-tripping decimals; Node/V8 emit the even
1748
+ // one. Verified against Node's `String(value)`. (The prior `{:e}`-based path rounded the
1749
+ // other way — ryu does ties-to-even, matching the spec.)
1750
+ (2181495296738027.3, "2181495296738027.2"),
1751
+ (75251554695404.13, "75251554695404.12"),
1752
+ (256006004960902.63, "256006004960902.62"),
1753
+ (-18546578340962.313, "-18546578340962.312"),
1393
1754
  // Non-finite.
1394
1755
  (f64::INFINITY, "Infinity"),
1395
1756
  (f64::NEG_INFINITY, "-Infinity"),
@@ -1401,3 +1762,172 @@ mod number_to_string_tests {
1401
1762
  }
1402
1763
  }
1403
1764
 
1765
+ #[cfg(test)]
1766
+ mod cycle_coercion_tests_381 {
1767
+ //! #381: string-coercing a cyclic array/object must terminate (was a silent thread hang),
1768
+ //! matching Node — `console.log`/inspect renders `[Circular]`, `"" + a` renders the back-ref as
1769
+ //! the empty string via `Array.prototype.join`.
1770
+ use super::Value;
1771
+ use crate::VmRef;
1772
+
1773
+ #[test]
1774
+ fn display_string_cyclic_array_terminates() {
1775
+ let a = Value::Array(VmRef::new(Vec::new()));
1776
+ if let Value::Array(inner) = &a {
1777
+ inner.borrow_mut().push(a.clone()); // a = [a]
1778
+ }
1779
+ assert_eq!(a.to_display_string(), "[[Circular]]");
1780
+ }
1781
+
1782
+ #[test]
1783
+ fn js_string_cyclic_array_terminates() {
1784
+ let a = Value::Array(VmRef::new(Vec::new()));
1785
+ if let Value::Array(inner) = &a {
1786
+ inner.borrow_mut().push(a.clone());
1787
+ }
1788
+ // Node: `let a=[]; a.push(a); "" + a` === "" (the self-referential element joins as "").
1789
+ assert_eq!(a.to_js_string(), "");
1790
+ }
1791
+
1792
+ #[test]
1793
+ fn display_string_cyclic_object_terminates() {
1794
+ let o = Value::object_from_pairs([]);
1795
+ if let Value::Object(inner) = &o {
1796
+ inner.borrow_mut().strings.insert(std::sync::Arc::from("self"), o.clone());
1797
+ }
1798
+ assert_eq!(o.to_display_string(), "{self: [Circular]}");
1799
+ }
1800
+
1801
+ #[test]
1802
+ fn display_string_shared_dag_is_not_circular() {
1803
+ let shared = Value::Array(VmRef::new(vec![Value::Number(1.0)]));
1804
+ let root = Value::Array(VmRef::new(vec![shared.clone(), shared.clone()]));
1805
+ assert_eq!(root.to_display_string(), "[[1], [1]]", "a shared node is a DAG, not a cycle");
1806
+ }
1807
+ }
1808
+
1809
+ #[cfg(test)]
1810
+ mod propmap_merge_tests {
1811
+ use super::{PropMap, Value, PROPMAP_INLINE};
1812
+ use std::sync::Arc;
1813
+
1814
+ fn pm(pairs: &[(&str, f64)]) -> PropMap {
1815
+ let mut m = PropMap::default();
1816
+ for (k, v) in pairs {
1817
+ m.insert(Arc::from(*k), Value::Number(*v));
1818
+ }
1819
+ m
1820
+ }
1821
+
1822
+ fn keys(m: &PropMap) -> Vec<String> {
1823
+ m.keys().map(|k| k.to_string()).collect()
1824
+ }
1825
+
1826
+ fn num(m: &PropMap, k: &str) -> Option<f64> {
1827
+ match m.get(k) {
1828
+ Some(Value::Number(n)) => Some(*n),
1829
+ _ => None,
1830
+ }
1831
+ }
1832
+
1833
+ #[test]
1834
+ fn merge_appends_new_keys_in_source_order() {
1835
+ let mut dst = pm(&[("a", 1.0), ("b", 2.0)]);
1836
+ dst.merge_from(&pm(&[("c", 3.0), ("d", 4.0)]));
1837
+ assert_eq!(keys(&dst), ["a", "b", "c", "d"]);
1838
+ assert_eq!(num(&dst, "c"), Some(3.0));
1839
+ }
1840
+
1841
+ #[test]
1842
+ fn merge_overwrites_existing_keys_without_reordering() {
1843
+ // `{ ...{a,b,c}, b: 20 }` — later write wins, key keeps its original slot.
1844
+ let mut dst = pm(&[("a", 1.0), ("b", 2.0), ("c", 3.0)]);
1845
+ dst.merge_from(&pm(&[("b", 20.0), ("d", 4.0)]));
1846
+ assert_eq!(keys(&dst), ["a", "b", "c", "d"]);
1847
+ assert_eq!(num(&dst, "b"), Some(20.0));
1848
+ assert_eq!(num(&dst, "d"), Some(4.0));
1849
+ }
1850
+
1851
+ #[test]
1852
+ fn merge_empty_source_is_noop() {
1853
+ let mut dst = pm(&[("a", 1.0)]);
1854
+ dst.merge_from(&PropMap::default());
1855
+ assert_eq!(keys(&dst), ["a"]);
1856
+ }
1857
+
1858
+ #[test]
1859
+ fn merge_promotes_past_inline_threshold_preserving_order_and_overrides() {
1860
+ // Combined unique key count exceeds the inline cap, forcing the IndexMap promotion path.
1861
+ let mut dst = PropMap::default();
1862
+ for i in 0..PROPMAP_INLINE {
1863
+ dst.insert(Arc::from(format!("k{i}").as_str()), Value::Number(i as f64));
1864
+ }
1865
+ let mut src = PropMap::default();
1866
+ // Overwrite one existing key and add several new ones to cross the threshold.
1867
+ src.insert(Arc::from("k0"), Value::Number(100.0));
1868
+ for i in PROPMAP_INLINE..(PROPMAP_INLINE + 5) {
1869
+ src.insert(Arc::from(format!("k{i}").as_str()), Value::Number(i as f64));
1870
+ }
1871
+ dst.merge_from(&src);
1872
+ assert_eq!(dst.len(), PROPMAP_INLINE + 5);
1873
+ assert_eq!(num(&dst, "k0"), Some(100.0)); // override applied
1874
+ assert_eq!(num(&dst, "k0").is_some(), keys(&dst).first().map(|k| k == "k0").unwrap());
1875
+ // Original keys come first (insertion order), then the new ones.
1876
+ let ks = keys(&dst);
1877
+ assert_eq!(ks[0], "k0");
1878
+ assert_eq!(ks[PROPMAP_INLINE], format!("k{PROPMAP_INLINE}"));
1879
+ // Lookups remain correct after promotion.
1880
+ for i in PROPMAP_INLINE..(PROPMAP_INLINE + 5) {
1881
+ assert_eq!(num(&dst, &format!("k{i}")), Some(i as f64));
1882
+ }
1883
+ }
1884
+
1885
+ #[test]
1886
+ fn merge_into_empty_matches_clone() {
1887
+ let src = pm(&[("x", 7.0), ("y", 8.0)]);
1888
+ let mut dst = PropMap::default();
1889
+ dst.merge_from(&src);
1890
+ assert_eq!(keys(&dst), keys(&src));
1891
+ assert_eq!(num(&dst, "x"), Some(7.0));
1892
+ assert_eq!(num(&dst, "y"), Some(8.0));
1893
+ }
1894
+ }
1895
+
1896
+ #[cfg(test)]
1897
+ mod value_call_non_function_tests {
1898
+ // Calling a non-callable value PARKS a catchable `TypeError` (#381) instead of `panic!`-ing
1899
+ // (an uncatchable process abort). The throw surfaces at the caller's next pending-throw
1900
+ // checkpoint. These lock the no-panic contract at the primitive level.
1901
+ use super::{value_call, Value};
1902
+ use crate::{has_pending_throw, take_pending_throw};
1903
+
1904
+ fn parked_error_name(v: &Value) -> Option<String> {
1905
+ if let Value::Object(o) = v {
1906
+ if let Some(Value::String(s)) = o.borrow().strings.get("name") {
1907
+ return Some(s.to_string());
1908
+ }
1909
+ }
1910
+ None
1911
+ }
1912
+
1913
+ #[test]
1914
+ fn calling_null_parks_type_error_and_does_not_panic() {
1915
+ let _ = take_pending_throw(); // clear any stale slot on this thread
1916
+ let r = value_call(&Value::Null, &[]);
1917
+ assert!(matches!(r, Value::Null), "returns the null sentinel, not a panic");
1918
+ assert!(has_pending_throw(), "a throw must be parked");
1919
+ let parked = take_pending_throw().expect("parked throw present");
1920
+ assert_eq!(parked_error_name(&parked).as_deref(), Some("TypeError"));
1921
+ }
1922
+
1923
+ #[test]
1924
+ fn calling_a_number_parks_type_error() {
1925
+ let _ = take_pending_throw();
1926
+ let r = value_call(&Value::Number(5.0), &[]);
1927
+ assert!(matches!(r, Value::Null));
1928
+ assert!(has_pending_throw());
1929
+ let parked = take_pending_throw().expect("parked throw present");
1930
+ assert_eq!(parked_error_name(&parked).as_deref(), Some("TypeError"));
1931
+ }
1932
+ }
1933
+