@tishlang/tish-lsp 2.12.0 → 2.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/Cargo.toml +3 -0
  2. package/bin/tish-lsp +0 -0
  3. package/crates/js_to_tish/src/transform/expr.rs +12 -2
  4. package/crates/tish/tests/fixtures/fs_parity_callbacks.tish +22 -0
  5. package/crates/tish/tests/fixtures/fs_parity_promises.tish +21 -0
  6. package/crates/tish/tests/fixtures/fs_parity_sync.tish +20 -0
  7. package/crates/tish/tests/fs_parity.rs +103 -0
  8. package/crates/tish/tests/integration_test.rs +80 -0
  9. package/crates/tish/tests/shortcircuit.rs +3 -3
  10. package/crates/tish_ast/src/ast.rs +84 -0
  11. package/crates/tish_builtins/Cargo.toml +1 -0
  12. package/crates/tish_builtins/src/array.rs +724 -72
  13. package/crates/tish_builtins/src/collections.rs +149 -40
  14. package/crates/tish_builtins/src/globals.rs +215 -25
  15. package/crates/tish_builtins/src/math.rs +65 -7
  16. package/crates/tish_builtins/src/number.rs +173 -0
  17. package/crates/tish_builtins/src/string.rs +263 -23
  18. package/crates/tish_bytecode/src/chunk.rs +7 -0
  19. package/crates/tish_bytecode/src/compiler.rs +611 -63
  20. package/crates/tish_bytecode/src/lib.rs +1 -1
  21. package/crates/tish_bytecode/src/opcode.rs +156 -2
  22. package/crates/tish_bytecode/src/serialize.rs +2 -0
  23. package/crates/tish_bytecode/tests/append_local_string_builder.rs +63 -0
  24. package/crates/tish_bytecode/tests/math_unary_intrinsic.rs +47 -0
  25. package/crates/tish_compile/src/codegen.rs +18219 -5283
  26. package/crates/tish_compile/src/infer.rs +1562 -30
  27. package/crates/tish_compile/src/lib.rs +29 -4
  28. package/crates/tish_compile/src/resolve.rs +151 -13
  29. package/crates/tish_compile/src/types.rs +224 -28
  30. package/crates/tish_compile/tests/dump_codegen.rs +21 -0
  31. package/crates/tish_compile/tests/perf_codegen_169_173.rs +8 -17
  32. package/crates/tish_compile/tests/perf_codegen_173_part3.rs +61 -0
  33. package/crates/tish_compile/tests/perf_codegen_174.rs +160 -0
  34. package/crates/tish_compile/tests/perf_codegen_175.rs +190 -0
  35. package/crates/tish_compile/tests/perf_codegen_176.rs +60 -0
  36. package/crates/tish_compile/tests/perf_codegen_177.rs +167 -0
  37. package/crates/tish_compile/tests/perf_codegen_178.rs +114 -0
  38. package/crates/tish_compile/tests/perf_codegen_178_rec.rs +124 -0
  39. package/crates/tish_compile/tests/perf_codegen_181.rs +36 -0
  40. package/crates/tish_compile/tests/perf_codegen_320.rs +211 -0
  41. package/crates/tish_compile/tests/perf_codegen_module_const_forof.rs +40 -0
  42. package/crates/tish_compile_js/src/codegen.rs +94 -3
  43. package/crates/tish_compile_js/src/tests_jsx.rs +27 -0
  44. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +115 -2
  45. package/crates/tish_core/Cargo.toml +4 -0
  46. package/crates/tish_core/src/json.rs +109 -6
  47. package/crates/tish_core/src/lib.rs +222 -2
  48. package/crates/tish_core/src/shape.rs +4 -2
  49. package/crates/tish_core/src/uri.rs +45 -0
  50. package/crates/tish_core/src/value.rs +571 -35
  51. package/crates/tish_core/src/vmref.rs +14 -0
  52. package/crates/tish_eval/Cargo.toml +2 -1
  53. package/crates/tish_eval/src/eval.rs +1328 -101
  54. package/crates/tish_eval/src/natives.rs +283 -18
  55. package/crates/tish_eval/src/regex.rs +47 -1
  56. package/crates/tish_eval/src/value.rs +76 -21
  57. package/crates/tish_ffi/src/lib.rs +11 -1
  58. package/crates/tish_ffi/tests/double_free.rs +35 -0
  59. package/crates/tish_fmt/src/lib.rs +94 -1
  60. package/crates/tish_lexer/src/lib.rs +76 -0
  61. package/crates/tish_lexer/src/token.rs +4 -0
  62. package/crates/tish_lint/src/lib.rs +126 -0
  63. package/crates/tish_lsp/Cargo.toml +1 -1
  64. package/crates/tish_lsp/README.md +2 -1
  65. package/crates/tish_lsp/src/main.rs +378 -28
  66. package/crates/tish_native/src/build.rs +41 -0
  67. package/crates/tish_opt/src/lib.rs +20 -1
  68. package/crates/tish_parser/Cargo.toml +4 -0
  69. package/crates/tish_parser/src/lib.rs +68 -0
  70. package/crates/tish_parser/src/parser.rs +479 -28
  71. package/crates/tish_resolve/src/lib.rs +75 -5
  72. package/crates/tish_runtime/Cargo.toml +10 -1
  73. package/crates/tish_runtime/src/fs_ext.rs +359 -0
  74. package/crates/tish_runtime/src/http.rs +28 -10
  75. package/crates/tish_runtime/src/http_fetch.rs +150 -1
  76. package/crates/tish_runtime/src/http_hyper.rs +41 -18
  77. package/crates/tish_runtime/src/http_prefork.rs +72 -11
  78. package/crates/tish_runtime/src/lib.rs +651 -51
  79. package/crates/tish_runtime/src/timers.rs +49 -2
  80. package/crates/tish_ui/src/jsx.rs +21 -3
  81. package/crates/tish_vm/src/jit.rs +2514 -117
  82. package/crates/tish_vm/src/vm.rs +1479 -224
  83. package/package.json +1 -1
  84. package/platform/darwin-arm64/tish-lsp +0 -0
  85. package/platform/darwin-x64/tish-lsp +0 -0
  86. package/platform/linux-arm64/tish-lsp +0 -0
  87. package/platform/linux-x64/tish-lsp +0 -0
  88. package/platform/win32-x64/tish-lsp.exe +0 -0
@@ -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 {
@@ -780,6 +982,12 @@ pub fn object_has(obj: &Value, key: &Value) -> bool {
780
982
  /// A missing/absent `done` is treated as truthy so a malformed object can't spin forever;
781
983
  /// the native iterators always set `done`, so well-formed iteration is exact.
782
984
  pub fn drain_iterator(obj: &Value) -> Option<Vec<Value>> {
985
+ // A string iterates as its characters, each a single-char string (`[...s]`, `for (c of s)`,
986
+ // call-spread `f(...s)`, `Array.from(s)`). Every caller of this helper is a spread/for-of
987
+ // context, so treating a string as iterable here is always correct. (#440)
988
+ if let Value::String(s) = obj {
989
+ return Some(s.chars().map(|c| Value::String(c.to_string().into())).collect());
990
+ }
783
991
  if !matches!(obj, Value::Object(_)) {
784
992
  return None;
785
993
  }
@@ -860,6 +1068,13 @@ pub fn to_uint32_value(v: &Value) -> u32 {
860
1068
  }
861
1069
 
862
1070
  /// Invoke a callable [`Value`]: [`Value::Function`], or an object exposing `__call` (e.g. `Symbol`).
1071
+ ///
1072
+ /// Calling a NON-callable (e.g. a method on `null` whose read returned `null`, or any non-function
1073
+ /// value) is a JS `TypeError`. In the native/runtime path this used to `panic!` — an UNCATCHABLE
1074
+ /// process abort (#381 class). It now PARKS a catchable `TypeError` and returns the `null` sentinel;
1075
+ /// the throw surfaces at the caller's next pending-throw checkpoint (statement boundary in emitted
1076
+ /// native code, or the `has_pending_throw()` polls in the shared builtins), so `try { null.foo() }
1077
+ /// catch (e) { … }` recovers instead of aborting — matching the VM/interpreter/node.
863
1078
  pub fn value_call(callee: &Value, args: &[Value]) -> Value {
864
1079
  match callee {
865
1080
  Value::Function(f) => f.call(args),
@@ -868,15 +1083,19 @@ pub fn value_call(callee: &Value, args: &[Value]) -> Value {
868
1083
  if let Some(inner) = inner {
869
1084
  return value_call(&inner, args);
870
1085
  }
871
- panic!(
872
- "Not a function: tried to call {:?} as a function (e.g. method on Null when read failed)",
873
- callee
874
- );
1086
+ crate::set_pending_throw(crate::not_a_function_error(format!(
1087
+ "{} is not a function",
1088
+ callee.to_display_string()
1089
+ )));
1090
+ Value::Null
1091
+ }
1092
+ _ => {
1093
+ crate::set_pending_throw(crate::not_a_function_error(format!(
1094
+ "{} is not a function",
1095
+ callee.to_display_string()
1096
+ )));
1097
+ Value::Null
875
1098
  }
876
- _ => panic!(
877
- "Not a function: tried to call {:?} as a function (e.g. method on Null when read failed)",
878
- callee
879
- ),
880
1099
  }
881
1100
  }
882
1101
 
@@ -967,26 +1186,75 @@ pub fn js_number_to_string_into(out: &mut String, value: f64) {
967
1186
  return;
968
1187
  }
969
1188
 
1189
+ // Fast path for exact integers within ±2^53 (the f64 "safe integer" range). ECMAScript ToString
1190
+ // renders any such integer in plain decimal (no sign-exponent — magnitude < 1e21 ⇒ `point <= 21`
1191
+ // in the general algorithm below), which `itoa` emits directly, bypassing the `format!("{:e}")`
1192
+ // round-trip + intermediate `String`/`char`-filter/`"0".repeat()`. This is the dominant case for
1193
+ // tally/counter/index loops (e.g. map keys like `"w" + (n % 1000)`). Bit-identical to the general
1194
+ // path for every integer it accepts: each such value is exactly representable in both f64 and i64,
1195
+ // so `value as i64` is exact (no saturation — `±2^53` is well inside i64), and the decimal form is
1196
+ // the same digits the general path would emit. Larger integers (where f64 can't represent
1197
+ // consecutive values) fall through to the general path unchanged.
1198
+ const MAX_SAFE_INT: f64 = 9_007_199_254_740_992.0; // 2^53
1199
+ if value.fract() == 0.0 && value.abs() <= MAX_SAFE_INT {
1200
+ let mut buf = itoa::Buffer::new();
1201
+ out.push_str(buf.format(value as i64));
1202
+ return;
1203
+ }
1204
+
970
1205
  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();
1206
+ // Shortest round-trip digits via ryu, then reassembled into ECMAScript `Number::toString` form.
1207
+ // ryu writes the shortest correctly-rounded digits straight into a stack buffer with none of the
1208
+ // `core::fmt` `{:e}` Formatter machinery the previous version paid per call (profiled as ~40% of
1209
+ // JSON.stringify self-time on numeric payloads). number→string is the JSON / template-literal /
1210
+ // logging / `+=` primitive, so this is a broad win. The shortest round-trip digit *sequence* is
1211
+ // unique, so ryu emits the same digits as the old `{:e}` path → output stays byte-identical.
1212
+ let mut ryu_buf = ryu::Buffer::new();
1213
+ // Finite & non-zero here (NaN / ±∞ / ±0 handled above), so `format_finite` is safe and cheaper.
1214
+ let s = ryu_buf.format_finite(value.abs());
1215
+
1216
+ // Parse ryu's `<int>[.<frac>][e<exp>]` into significant digits + ECMAScript decimal point.
1217
+ // `point` = ECMAScript's `n`: value = digits × 10^(point − k), with `k` = significant-digit count.
1218
+ let (mant, exp) = match s.as_bytes().iter().position(|&c| c == b'e' || c == b'E') {
1219
+ Some(i) => (&s[..i], s[i + 1..].parse::<i32>().expect("ryu exponent")),
1220
+ None => (s, 0i32),
1221
+ };
1222
+ let (int_part, frac_part) = match mant.as_bytes().iter().position(|&c| c == b'.') {
1223
+ Some(i) => (&mant[..i], &mant[i + 1..]),
1224
+ None => (mant, ""),
1225
+ };
1226
+ let frac_len = frac_part.len() as i32;
1227
+ // Raw digit run = int_part ++ frac_part (ryu's mantissa, ≤ ~18 chars for an f64).
1228
+ let mut digits_buf = [0u8; 32];
1229
+ let mut n = 0usize;
1230
+ for &c in int_part.as_bytes().iter().chain(frac_part.as_bytes()) {
1231
+ digits_buf[n] = c;
1232
+ n += 1;
1233
+ }
1234
+ // Strip leading zeros (e.g. ryu "0.1" → raw "01" → "1") …
1235
+ let mut lead = 0usize;
1236
+ while lead + 1 < n && digits_buf[lead] == b'0' {
1237
+ lead += 1;
1238
+ }
1239
+ // … and trailing zeros (defensive — shortest form has none — folded back via `trail`).
1240
+ let mut end = n;
1241
+ while end - 1 > lead && digits_buf[end - 1] == b'0' {
1242
+ end -= 1;
1243
+ }
1244
+ let trail = (n - end) as i32;
1245
+ let digits = std::str::from_utf8(&digits_buf[lead..end]).expect("ascii digits");
980
1246
  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)
1247
+ let point = k + exp - frac_len + trail;
982
1248
 
983
1249
  if negative {
984
1250
  out.push('-');
985
1251
  }
986
1252
  if k <= point && point <= 21 {
987
1253
  // Integer, zero-padded: digits then (point − k) trailing zeros.
988
- out.push_str(&digits);
989
- out.push_str(&"0".repeat((point - k) as usize));
1254
+ out.push_str(digits);
1255
+ for _ in 0..(point - k) {
1256
+ out.push('0');
1257
+ }
990
1258
  } else if 0 < point && point <= 21 {
991
1259
  // Decimal point inside the digit string.
992
1260
  out.push_str(&digits[..point as usize]);
@@ -995,8 +1263,10 @@ pub fn js_number_to_string_into(out: &mut String, value: f64) {
995
1263
  } else if -6 < point && point <= 0 {
996
1264
  // Leading-zero fraction: "0." then (−point) zeros then the digits.
997
1265
  out.push_str("0.");
998
- out.push_str(&"0".repeat((-point) as usize));
999
- out.push_str(&digits);
1266
+ for _ in 0..(-point) {
1267
+ out.push('0');
1268
+ }
1269
+ out.push_str(digits);
1000
1270
  } else {
1001
1271
  // Exponential: first digit, optional `.rest`, then `e±E`.
1002
1272
  let e = point - 1;
@@ -1007,13 +1277,22 @@ pub fn js_number_to_string_into(out: &mut String, value: f64) {
1007
1277
  }
1008
1278
  out.push('e');
1009
1279
  out.push(if e >= 0 { '+' } else { '-' });
1010
- out.push_str(&e.abs().to_string());
1280
+ let mut ib = itoa::Buffer::new();
1281
+ out.push_str(ib.format(e.abs()));
1011
1282
  }
1012
1283
  }
1013
1284
 
1014
1285
  impl Value {
1015
1286
  /// Convert value to display string (for console output).
1016
1287
  pub fn to_display_string(&self) -> String {
1288
+ self.to_display_string_guarded(&mut Vec::new())
1289
+ }
1290
+
1291
+ /// Cycle-safe recursive form (#381): `ancestors` holds the current path's container-cell pointers,
1292
+ /// so a self-referential array/object (`a.self = a`) renders `[Circular]` instead of recursing
1293
+ /// forever and wedging the thread. Ancestor-only (not all-visited), so a shared DAG node still
1294
+ /// renders in full.
1295
+ fn to_display_string_guarded(&self, ancestors: &mut Vec<*const ()>) -> String {
1017
1296
  match self {
1018
1297
  // Inspect form keeps the sign of negative zero (`console.log(-0)` → `-0`), unlike the
1019
1298
  // ECMAScript ToString used by `to_js_string`. See `js_number_to_string`. (#247)
@@ -1023,8 +1302,17 @@ impl Value {
1023
1302
  Value::Bool(b) => b.to_string(),
1024
1303
  Value::Null => "null".to_string(),
1025
1304
  Value::Array(arr) => {
1026
- let inner: Vec<String> =
1027
- arr.borrow().iter().map(|v| v.to_display_string()).collect();
1305
+ let ptr = arr.as_ptr();
1306
+ if ancestors.contains(&ptr) {
1307
+ return "[Circular]".to_string();
1308
+ }
1309
+ ancestors.push(ptr);
1310
+ let inner: Vec<String> = arr
1311
+ .borrow()
1312
+ .iter()
1313
+ .map(|v| v.to_display_string_guarded(ancestors))
1314
+ .collect();
1315
+ ancestors.pop();
1028
1316
  format!("[{}]", inner.join(", "))
1029
1317
  }
1030
1318
  Value::NumberArray(arr) => {
@@ -1036,12 +1324,18 @@ impl Value {
1036
1324
  format!("[{}]", inner.join(", "))
1037
1325
  }
1038
1326
  Value::Object(obj) => {
1327
+ let ptr = obj.as_ptr();
1328
+ if ancestors.contains(&ptr) {
1329
+ return "[Circular]".to_string();
1330
+ }
1331
+ ancestors.push(ptr);
1039
1332
  let inner: Vec<String> = obj
1040
1333
  .borrow()
1041
1334
  .strings
1042
1335
  .iter()
1043
- .map(|(k, v)| format!("{}: {}", k.as_ref(), v.to_display_string()))
1336
+ .map(|(k, v)| format!("{}: {}", k.as_ref(), v.to_display_string_guarded(ancestors)))
1044
1337
  .collect();
1338
+ ancestors.pop();
1045
1339
  format!("{{{}}}", inner.join(", "))
1046
1340
  }
1047
1341
  Value::Symbol(s) => {
@@ -1071,16 +1365,32 @@ impl Value {
1071
1365
  /// renders as `"null"` here (matching `String(null)`); `join` itself maps `null`/`undefined`
1072
1366
  /// elements to `""` *before* calling this, per the spec.
1073
1367
  pub fn to_js_string(&self) -> String {
1368
+ self.to_js_string_guarded(&mut Vec::new())
1369
+ }
1370
+
1371
+ /// Cycle-safe `ToString` (#381): only arrays recurse here (objects render `[object Object]`), so a
1372
+ /// cyclic array via `"" + a` would otherwise hang. A back-reference joins as `""` — matching V8's
1373
+ /// `Array.prototype.join`, where a self-referential element contributes the empty string.
1374
+ fn to_js_string_guarded(&self, ancestors: &mut Vec<*const ()>) -> String {
1074
1375
  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(","),
1376
+ Value::Array(arr) => {
1377
+ let ptr = arr.as_ptr();
1378
+ if ancestors.contains(&ptr) {
1379
+ return String::new();
1380
+ }
1381
+ ancestors.push(ptr);
1382
+ let s = arr
1383
+ .borrow()
1384
+ .iter()
1385
+ .map(|v| match v {
1386
+ Value::Null => String::new(),
1387
+ other => other.to_js_string_guarded(ancestors),
1388
+ })
1389
+ .collect::<Vec<_>>()
1390
+ .join(",");
1391
+ ancestors.pop();
1392
+ s
1393
+ }
1084
1394
  Value::NumberArray(arr) => arr
1085
1395
  .borrow()
1086
1396
  .iter()
@@ -1354,6 +1664,46 @@ impl Value {
1354
1664
  }
1355
1665
  }
1356
1666
 
1667
+ #[cfg(test)]
1668
+ mod value_abstraction_tests {
1669
+ // #201 Stage A — the abstraction must exactly mirror the enum it will replace.
1670
+ use super::{Value, ValueRef, ValueTag};
1671
+
1672
+ #[test]
1673
+ fn named_ctors_report_the_right_tag() {
1674
+ assert_eq!(Value::number(1.5).tag(), ValueTag::Number);
1675
+ assert_eq!(Value::boolean(true).tag(), ValueTag::Bool);
1676
+ assert_eq!(Value::string("hi").tag(), ValueTag::String);
1677
+ assert_eq!(Value::null().tag(), ValueTag::Null);
1678
+ assert_eq!(Value::array(vec![]).tag(), ValueTag::Array);
1679
+ assert_eq!(Value::empty_object().tag(), ValueTag::Object);
1680
+ }
1681
+
1682
+ #[test]
1683
+ fn unpack_roundtrips_each_variant() {
1684
+ assert!(matches!(Value::number(3.0).unpack(), ValueRef::Number(n) if n == 3.0));
1685
+ assert!(matches!(Value::boolean(false).unpack(), ValueRef::Bool(false)));
1686
+ assert!(matches!(Value::string("x").unpack(), ValueRef::String(s) if s.as_str() == "x"));
1687
+ assert!(matches!(Value::null().unpack(), ValueRef::Null));
1688
+ assert!(matches!(Value::array(vec![]).unpack(), ValueRef::Array(_)));
1689
+ assert!(matches!(Value::empty_object().unpack(), ValueRef::Object(_)));
1690
+ }
1691
+
1692
+ #[test]
1693
+ fn accessors_extract_or_none() {
1694
+ assert_eq!(Value::number(2.0).as_number(), Some(2.0));
1695
+ assert_eq!(Value::boolean(true).as_bool(), Some(true));
1696
+ assert_eq!(Value::string("abc").as_str(), Some("abc"));
1697
+ assert!(Value::null().is_null());
1698
+ // wrong-variant accessors return None
1699
+ assert_eq!(Value::number(1.0).as_bool(), None);
1700
+ assert_eq!(Value::null().as_str(), None);
1701
+ assert!(!Value::number(0.0).is_null());
1702
+ assert!(Value::array(vec![Value::number(1.0)]).as_array().is_some());
1703
+ assert!(Value::number(1.0).as_array().is_none());
1704
+ }
1705
+ }
1706
+
1357
1707
  #[cfg(test)]
1358
1708
  mod number_to_string_tests {
1359
1709
  use super::js_number_to_string;
@@ -1371,6 +1721,14 @@ mod number_to_string_tests {
1371
1721
  (0.5, "0.5"),
1372
1722
  (-123.456, "-123.456"),
1373
1723
  (100000.0, "100000"),
1724
+ (-1000.0, "-1000"),
1725
+ // Integer fast-path boundary (±2^53): the largest safe integer takes the itoa path; the
1726
+ // next decade up (still an exact f64 integer, but > 2^53) takes the general path. Both must
1727
+ // match Node's `String(value)`.
1728
+ (9007199254740992.0, "9007199254740992"), // 2^53
1729
+ (9007199254740991.0, "9007199254740991"), // 2^53 - 1 (Number.MAX_SAFE_INTEGER)
1730
+ (-9007199254740992.0, "-9007199254740992"),
1731
+ (1e16, "10000000000000000"), // > 2^53, exact f64 integer → general path
1374
1732
  // Decimal/exponential boundary on the large side: 1e21 flips to exponential.
1375
1733
  (1e20, "100000000000000000000"),
1376
1734
  (1e21, "1e+21"),
@@ -1390,6 +1748,15 @@ mod number_to_string_tests {
1390
1748
  // Shortest round-trip mantissa (not full precision).
1391
1749
  (0.1, "0.1"),
1392
1750
  (0.1 + 0.2, "0.30000000000000004"),
1751
+ // Shortest-representation ties → ECMAScript rounds the final digit to EVEN (spec: "if
1752
+ // there are two such possible values, choose the one that is even"). These large-magnitude
1753
+ // fractionals each have two equally-short round-tripping decimals; Node/V8 emit the even
1754
+ // one. Verified against Node's `String(value)`. (The prior `{:e}`-based path rounded the
1755
+ // other way — ryu does ties-to-even, matching the spec.)
1756
+ (2181495296738027.3, "2181495296738027.2"),
1757
+ (75251554695404.13, "75251554695404.12"),
1758
+ (256006004960902.63, "256006004960902.62"),
1759
+ (-18546578340962.313, "-18546578340962.312"),
1393
1760
  // Non-finite.
1394
1761
  (f64::INFINITY, "Infinity"),
1395
1762
  (f64::NEG_INFINITY, "-Infinity"),
@@ -1401,3 +1768,172 @@ mod number_to_string_tests {
1401
1768
  }
1402
1769
  }
1403
1770
 
1771
+ #[cfg(test)]
1772
+ mod cycle_coercion_tests_381 {
1773
+ //! #381: string-coercing a cyclic array/object must terminate (was a silent thread hang),
1774
+ //! matching Node — `console.log`/inspect renders `[Circular]`, `"" + a` renders the back-ref as
1775
+ //! the empty string via `Array.prototype.join`.
1776
+ use super::Value;
1777
+ use crate::VmRef;
1778
+
1779
+ #[test]
1780
+ fn display_string_cyclic_array_terminates() {
1781
+ let a = Value::Array(VmRef::new(Vec::new()));
1782
+ if let Value::Array(inner) = &a {
1783
+ inner.borrow_mut().push(a.clone()); // a = [a]
1784
+ }
1785
+ assert_eq!(a.to_display_string(), "[[Circular]]");
1786
+ }
1787
+
1788
+ #[test]
1789
+ fn js_string_cyclic_array_terminates() {
1790
+ let a = Value::Array(VmRef::new(Vec::new()));
1791
+ if let Value::Array(inner) = &a {
1792
+ inner.borrow_mut().push(a.clone());
1793
+ }
1794
+ // Node: `let a=[]; a.push(a); "" + a` === "" (the self-referential element joins as "").
1795
+ assert_eq!(a.to_js_string(), "");
1796
+ }
1797
+
1798
+ #[test]
1799
+ fn display_string_cyclic_object_terminates() {
1800
+ let o = Value::object_from_pairs([]);
1801
+ if let Value::Object(inner) = &o {
1802
+ inner.borrow_mut().strings.insert(std::sync::Arc::from("self"), o.clone());
1803
+ }
1804
+ assert_eq!(o.to_display_string(), "{self: [Circular]}");
1805
+ }
1806
+
1807
+ #[test]
1808
+ fn display_string_shared_dag_is_not_circular() {
1809
+ let shared = Value::Array(VmRef::new(vec![Value::Number(1.0)]));
1810
+ let root = Value::Array(VmRef::new(vec![shared.clone(), shared.clone()]));
1811
+ assert_eq!(root.to_display_string(), "[[1], [1]]", "a shared node is a DAG, not a cycle");
1812
+ }
1813
+ }
1814
+
1815
+ #[cfg(test)]
1816
+ mod propmap_merge_tests {
1817
+ use super::{PropMap, Value, PROPMAP_INLINE};
1818
+ use std::sync::Arc;
1819
+
1820
+ fn pm(pairs: &[(&str, f64)]) -> PropMap {
1821
+ let mut m = PropMap::default();
1822
+ for (k, v) in pairs {
1823
+ m.insert(Arc::from(*k), Value::Number(*v));
1824
+ }
1825
+ m
1826
+ }
1827
+
1828
+ fn keys(m: &PropMap) -> Vec<String> {
1829
+ m.keys().map(|k| k.to_string()).collect()
1830
+ }
1831
+
1832
+ fn num(m: &PropMap, k: &str) -> Option<f64> {
1833
+ match m.get(k) {
1834
+ Some(Value::Number(n)) => Some(*n),
1835
+ _ => None,
1836
+ }
1837
+ }
1838
+
1839
+ #[test]
1840
+ fn merge_appends_new_keys_in_source_order() {
1841
+ let mut dst = pm(&[("a", 1.0), ("b", 2.0)]);
1842
+ dst.merge_from(&pm(&[("c", 3.0), ("d", 4.0)]));
1843
+ assert_eq!(keys(&dst), ["a", "b", "c", "d"]);
1844
+ assert_eq!(num(&dst, "c"), Some(3.0));
1845
+ }
1846
+
1847
+ #[test]
1848
+ fn merge_overwrites_existing_keys_without_reordering() {
1849
+ // `{ ...{a,b,c}, b: 20 }` — later write wins, key keeps its original slot.
1850
+ let mut dst = pm(&[("a", 1.0), ("b", 2.0), ("c", 3.0)]);
1851
+ dst.merge_from(&pm(&[("b", 20.0), ("d", 4.0)]));
1852
+ assert_eq!(keys(&dst), ["a", "b", "c", "d"]);
1853
+ assert_eq!(num(&dst, "b"), Some(20.0));
1854
+ assert_eq!(num(&dst, "d"), Some(4.0));
1855
+ }
1856
+
1857
+ #[test]
1858
+ fn merge_empty_source_is_noop() {
1859
+ let mut dst = pm(&[("a", 1.0)]);
1860
+ dst.merge_from(&PropMap::default());
1861
+ assert_eq!(keys(&dst), ["a"]);
1862
+ }
1863
+
1864
+ #[test]
1865
+ fn merge_promotes_past_inline_threshold_preserving_order_and_overrides() {
1866
+ // Combined unique key count exceeds the inline cap, forcing the IndexMap promotion path.
1867
+ let mut dst = PropMap::default();
1868
+ for i in 0..PROPMAP_INLINE {
1869
+ dst.insert(Arc::from(format!("k{i}").as_str()), Value::Number(i as f64));
1870
+ }
1871
+ let mut src = PropMap::default();
1872
+ // Overwrite one existing key and add several new ones to cross the threshold.
1873
+ src.insert(Arc::from("k0"), Value::Number(100.0));
1874
+ for i in PROPMAP_INLINE..(PROPMAP_INLINE + 5) {
1875
+ src.insert(Arc::from(format!("k{i}").as_str()), Value::Number(i as f64));
1876
+ }
1877
+ dst.merge_from(&src);
1878
+ assert_eq!(dst.len(), PROPMAP_INLINE + 5);
1879
+ assert_eq!(num(&dst, "k0"), Some(100.0)); // override applied
1880
+ assert_eq!(num(&dst, "k0").is_some(), keys(&dst).first().map(|k| k == "k0").unwrap());
1881
+ // Original keys come first (insertion order), then the new ones.
1882
+ let ks = keys(&dst);
1883
+ assert_eq!(ks[0], "k0");
1884
+ assert_eq!(ks[PROPMAP_INLINE], format!("k{PROPMAP_INLINE}"));
1885
+ // Lookups remain correct after promotion.
1886
+ for i in PROPMAP_INLINE..(PROPMAP_INLINE + 5) {
1887
+ assert_eq!(num(&dst, &format!("k{i}")), Some(i as f64));
1888
+ }
1889
+ }
1890
+
1891
+ #[test]
1892
+ fn merge_into_empty_matches_clone() {
1893
+ let src = pm(&[("x", 7.0), ("y", 8.0)]);
1894
+ let mut dst = PropMap::default();
1895
+ dst.merge_from(&src);
1896
+ assert_eq!(keys(&dst), keys(&src));
1897
+ assert_eq!(num(&dst, "x"), Some(7.0));
1898
+ assert_eq!(num(&dst, "y"), Some(8.0));
1899
+ }
1900
+ }
1901
+
1902
+ #[cfg(test)]
1903
+ mod value_call_non_function_tests {
1904
+ // Calling a non-callable value PARKS a catchable `TypeError` (#381) instead of `panic!`-ing
1905
+ // (an uncatchable process abort). The throw surfaces at the caller's next pending-throw
1906
+ // checkpoint. These lock the no-panic contract at the primitive level.
1907
+ use super::{value_call, Value};
1908
+ use crate::{has_pending_throw, take_pending_throw};
1909
+
1910
+ fn parked_error_name(v: &Value) -> Option<String> {
1911
+ if let Value::Object(o) = v {
1912
+ if let Some(Value::String(s)) = o.borrow().strings.get("name") {
1913
+ return Some(s.to_string());
1914
+ }
1915
+ }
1916
+ None
1917
+ }
1918
+
1919
+ #[test]
1920
+ fn calling_null_parks_type_error_and_does_not_panic() {
1921
+ let _ = take_pending_throw(); // clear any stale slot on this thread
1922
+ let r = value_call(&Value::Null, &[]);
1923
+ assert!(matches!(r, Value::Null), "returns the null sentinel, not a panic");
1924
+ assert!(has_pending_throw(), "a throw must be parked");
1925
+ let parked = take_pending_throw().expect("parked throw present");
1926
+ assert_eq!(parked_error_name(&parked).as_deref(), Some("TypeError"));
1927
+ }
1928
+
1929
+ #[test]
1930
+ fn calling_a_number_parks_type_error() {
1931
+ let _ = take_pending_throw();
1932
+ let r = value_call(&Value::Number(5.0), &[]);
1933
+ assert!(matches!(r, Value::Null));
1934
+ assert!(has_pending_throw());
1935
+ let parked = take_pending_throw().expect("parked throw present");
1936
+ assert_eq!(parked_error_name(&parked).as_deref(), Some("TypeError"));
1937
+ }
1938
+ }
1939
+