@tishlang/tish 2.2.5 → 2.8.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 (53) hide show
  1. package/bin/tish +0 -0
  2. package/crates/js_to_tish/src/transform/expr.rs +5 -1
  3. package/crates/tish/Cargo.toml +1 -1
  4. package/crates/tish/src/cli_help.rs +12 -0
  5. package/crates/tish/src/main.rs +69 -11
  6. package/crates/tish_ast/src/ast.rs +12 -5
  7. package/crates/tish_build_utils/src/lib.rs +37 -0
  8. package/crates/tish_builtins/src/array.rs +82 -1
  9. package/crates/tish_builtins/src/globals.rs +50 -16
  10. package/crates/tish_builtins/src/math.rs +63 -9
  11. package/crates/tish_builtins/src/number.rs +20 -1
  12. package/crates/tish_builtins/src/string.rs +68 -4
  13. package/crates/tish_builtins/src/typedarrays.rs +23 -16
  14. package/crates/tish_bytecode/src/compiler.rs +94 -28
  15. package/crates/tish_bytecode/tests/break_continue_bytecode.rs +19 -0
  16. package/crates/tish_compile/src/check.rs +11 -10
  17. package/crates/tish_compile/src/codegen.rs +1386 -42
  18. package/crates/tish_compile/src/infer.rs +16 -16
  19. package/crates/tish_compile/src/lib.rs +38 -0
  20. package/crates/tish_compile/src/resolve.rs +3 -2
  21. package/crates/tish_compile/src/types.rs +26 -9
  22. package/crates/tish_compile_js/src/codegen.rs +55 -4
  23. package/crates/tish_compile_js/src/tests_jsx.rs +44 -0
  24. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +1 -0
  25. package/crates/tish_core/Cargo.toml +2 -0
  26. package/crates/tish_core/src/json.rs +135 -34
  27. package/crates/tish_core/src/lib.rs +23 -1
  28. package/crates/tish_core/src/value.rs +64 -11
  29. package/crates/tish_eval/src/eval.rs +144 -197
  30. package/crates/tish_eval/src/natives.rs +45 -45
  31. package/crates/tish_eval/src/regex.rs +35 -27
  32. package/crates/tish_eval/src/value.rs +8 -0
  33. package/crates/tish_fmt/src/lib.rs +46 -2
  34. package/crates/tish_lint/src/bin/tish-lint.rs +17 -8
  35. package/crates/tish_lint/src/lib.rs +197 -21
  36. package/crates/tish_lsp/Cargo.toml +7 -1
  37. package/crates/tish_lsp/src/import_goto.rs +52 -7
  38. package/crates/tish_lsp/src/main.rs +913 -145
  39. package/crates/tish_opt/src/lib.rs +5 -3
  40. package/crates/tish_parser/src/lib.rs +23 -5
  41. package/crates/tish_parser/src/parser.rs +35 -35
  42. package/crates/tish_resolve/src/lib.rs +567 -17
  43. package/crates/tish_runtime/src/lib.rs +58 -18
  44. package/crates/tish_ui/src/jsx.rs +2 -2
  45. package/crates/tish_vm/src/jit.rs +212 -10
  46. package/crates/tish_vm/src/vm.rs +39 -18
  47. package/crates/tish_wasm/src/lib.rs +116 -22
  48. package/package.json +1 -1
  49. package/platform/darwin-arm64/tish +0 -0
  50. package/platform/darwin-x64/tish +0 -0
  51. package/platform/linux-arm64/tish +0 -0
  52. package/platform/linux-x64/tish +0 -0
  53. package/platform/win32-x64/tish.exe +0 -0
@@ -3,13 +3,70 @@
3
3
  use crate::{Value, VmRef};
4
4
  use std::sync::Arc;
5
5
 
6
+ /// Per-`json_parse`-call cache of object-key text → shared `Arc<str>`. A JSON array of records
7
+ /// repeats the same handful of keys across thousands of objects; interning allocates each key
8
+ /// once and `Arc::clone`s it thereafter, instead of a fresh `Arc<str>` allocation per occurrence.
9
+ type KeyCache = ahash::AHashMap<Box<str>, Arc<str>>;
10
+
11
+ #[inline]
12
+ fn intern_key(cache: &mut KeyCache, s: &str) -> Arc<str> {
13
+ if let Some(existing) = cache.get(s) {
14
+ return Arc::clone(existing);
15
+ }
16
+ let arc: Arc<str> = Arc::from(s);
17
+ cache.insert(Box::from(s), Arc::clone(&arc));
18
+ arc
19
+ }
20
+
21
+ /// Append `n` to `buf` exactly as JS `JSON.stringify` formats a number. Integer-valued finite
22
+ /// numbers within the safe-integer range (`|n| < 2^53`) take a fast `i64` path — bit-identical to
23
+ /// JS for every such value (verified against Node over 100k values) and far cheaper than the `f64`
24
+ /// formatter. Everything else uses the ECMAScript `Number::toString` (`js_number_to_string`),
25
+ /// which matches JS where Rust's `{}` Display would not (e.g. `1e21`, `1e-7`). NaN/∞ → `null`.
26
+ /// Public so the interpreter's separate JSON path formats numbers identically (single source of
27
+ /// truth → interp/vm/native/node agree).
28
+ #[inline]
29
+ pub fn write_json_number(buf: &mut String, n: f64) {
30
+ if n.is_nan() || n.is_infinite() {
31
+ buf.push_str("null");
32
+ return;
33
+ }
34
+ if n.fract() == 0.0 && n.abs() < 9_007_199_254_740_992.0 {
35
+ let mut b = itoa::Buffer::new();
36
+ buf.push_str(b.format(n as i64));
37
+ return;
38
+ }
39
+ crate::js_number_to_string_into(buf, n);
40
+ }
41
+
42
+ /// Scan a string body (the input with its opening `"` already removed) for the closing quote.
43
+ /// Returns `Ok(Some((body, rest)))` when the string is escape-free — `body` is the raw contents and
44
+ /// `rest` is the input just past the closing quote, so the value is built in a single allocation
45
+ /// with no per-char decode. Returns `Ok(None)` on the first backslash (caller uses the escape
46
+ /// decoder) and `Err` if unterminated. Only stops on the ASCII bytes `"`/`\\`, so the byte index
47
+ /// always lands on a UTF-8 char boundary — multi-byte sequences pass through inside `body` intact.
48
+ #[inline]
49
+ fn scan_escape_free(body: &str) -> Result<Option<(&str, &str)>, String> {
50
+ let bytes = body.as_bytes();
51
+ let mut i = 0;
52
+ while i < bytes.len() {
53
+ match bytes[i] {
54
+ b'\\' => return Ok(None),
55
+ b'"' => return Ok(Some((&body[..i], &body[i + 1..]))),
56
+ _ => i += 1,
57
+ }
58
+ }
59
+ Err("Unterminated string".to_string())
60
+ }
61
+
6
62
  /// Parse JSON string into a Value.
7
63
  pub fn json_parse(json: &str) -> Result<Value, String> {
8
64
  let json = json.trim();
9
65
  if json.is_empty() {
10
66
  return Err("SyntaxError: Unexpected end of JSON input".to_string());
11
67
  }
12
- let (value, rest) = parse_value(json, 0)?;
68
+ let mut cache = KeyCache::default();
69
+ let (value, rest) = parse_value(json, 0, &mut cache)?;
13
70
  if !rest.trim().is_empty() {
14
71
  return Err("SyntaxError: Unexpected token at end of JSON".to_string());
15
72
  }
@@ -39,17 +96,7 @@ pub fn json_stringify_into(buf: &mut String, value: &Value) {
39
96
  Value::Null => buf.push_str("null"),
40
97
  Value::Bool(true) => buf.push_str("true"),
41
98
  Value::Bool(false) => buf.push_str("false"),
42
- Value::Number(n) => {
43
- if n.is_nan() || n.is_infinite() {
44
- buf.push_str("null");
45
- } else {
46
- // `write!` avoids the heap allocation that `to_string`
47
- // produces. The f64 → decimal formatter is the same
48
- // either way (`std::fmt::Display`).
49
- use std::fmt::Write;
50
- let _ = write!(buf, "{}", n);
51
- }
52
- }
99
+ Value::Number(n) => write_json_number(buf, *n),
53
100
  Value::String(s) => {
54
101
  buf.push('"');
55
102
  escape_json_string_into(buf, s);
@@ -69,16 +116,11 @@ pub fn json_stringify_into(buf: &mut String, value: &Value) {
69
116
  Value::NumberArray(arr) => {
70
117
  let borrowed = arr.borrow();
71
118
  buf.push('[');
72
- use std::fmt::Write;
73
119
  for (i, n) in borrowed.iter().enumerate() {
74
120
  if i > 0 {
75
121
  buf.push(',');
76
122
  }
77
- if n.is_nan() || n.is_infinite() {
78
- buf.push_str("null");
79
- } else {
80
- let _ = write!(buf, "{}", n);
81
- }
123
+ write_json_number(buf, *n);
82
124
  }
83
125
  buf.push(']');
84
126
  }
@@ -153,7 +195,11 @@ fn escape_json_string_into(buf: &mut String, s: &str) {
153
195
  /// whole process (uncatchable, SIGABRT). 128 matches serde_json's default limit.
154
196
  const MAX_JSON_DEPTH: usize = 128;
155
197
 
156
- fn parse_value(input: &str, depth: usize) -> Result<(Value, &str), String> {
198
+ fn parse_value<'a>(
199
+ input: &'a str,
200
+ depth: usize,
201
+ cache: &mut KeyCache,
202
+ ) -> Result<(Value, &'a str), String> {
157
203
  let input = input.trim_start();
158
204
  if input.is_empty() {
159
205
  return Err("Unexpected end of JSON input".to_string());
@@ -163,8 +209,8 @@ fn parse_value(input: &str, depth: usize) -> Result<(Value, &str), String> {
163
209
  'n' => parse_null(input),
164
210
  't' | 'f' => parse_bool(input),
165
211
  '"' => parse_string(input),
166
- '[' => parse_array(input, depth),
167
- '{' => parse_object(input, depth),
212
+ '[' => parse_array(input, depth, cache),
213
+ '{' => parse_object(input, depth, cache),
168
214
  c if c == '-' || c.is_ascii_digit() => parse_number(input),
169
215
  c => Err(format!("Unexpected character '{}' in JSON", c)),
170
216
  }
@@ -189,6 +235,31 @@ fn parse_bool(input: &str) -> Result<(Value, &str), String> {
189
235
  }
190
236
 
191
237
  fn parse_string(input: &str) -> Result<(Value, &str), String> {
238
+ // Fast path: an escape-free string is a direct slice of the input — one allocation, no decode.
239
+ let body = &input[1..]; // skip opening quote (ASCII, safe byte index)
240
+ if let Some((s, rest)) = scan_escape_free(body)? {
241
+ return Ok((Value::String(s.into()), rest));
242
+ }
243
+ parse_string_escaped(input)
244
+ }
245
+
246
+ /// Read an object key, interning it through `cache` so repeated keys share one `Arc<str>` instead
247
+ /// of allocating a fresh one per occurrence. Escape-free keys (the common case) look up the cache
248
+ /// with the borrowed slice — zero allocations on a hit.
249
+ fn parse_key<'a>(input: &'a str, cache: &mut KeyCache) -> Result<(Arc<str>, &'a str), String> {
250
+ let body = &input[1..];
251
+ if let Some((s, rest)) = scan_escape_free(body)? {
252
+ return Ok((intern_key(cache, s), rest));
253
+ }
254
+ let (val, rest) = parse_string_escaped(input)?;
255
+ match val {
256
+ Value::String(s) => Ok((intern_key(cache, s.as_str()), rest)),
257
+ _ => unreachable!("parse_string_escaped always yields Value::String"),
258
+ }
259
+ }
260
+
261
+ /// Decode a JSON string that contains at least one escape (the slow path).
262
+ fn parse_string_escaped(input: &str) -> Result<(Value, &str), String> {
192
263
  let input = &input[1..]; // skip opening quote
193
264
  let mut result = String::new();
194
265
  let mut chars = input.chars().peekable();
@@ -267,19 +338,25 @@ fn parse_number(input: &str) -> Result<(Value, &str), String> {
267
338
  let bytes = input.as_bytes();
268
339
  let mut end = 0;
269
340
 
270
- if bytes.first() == Some(&b'-') {
341
+ let neg = bytes.first() == Some(&b'-');
342
+ if neg {
271
343
  end += 1;
272
344
  }
345
+ let int_start = end;
273
346
  while end < bytes.len() && bytes[end].is_ascii_digit() {
274
347
  end += 1;
275
348
  }
349
+ let int_len = end - int_start;
350
+ let mut is_integer = true;
276
351
  if bytes.get(end) == Some(&b'.') {
352
+ is_integer = false;
277
353
  end += 1;
278
354
  while end < bytes.len() && bytes[end].is_ascii_digit() {
279
355
  end += 1;
280
356
  }
281
357
  }
282
358
  if matches!(bytes.get(end), Some(&b'e') | Some(&b'E')) {
359
+ is_integer = false;
283
360
  end += 1;
284
361
  if matches!(bytes.get(end), Some(&b'+') | Some(&b'-')) {
285
362
  end += 1;
@@ -291,13 +368,27 @@ fn parse_number(input: &str) -> Result<(Value, &str), String> {
291
368
 
292
369
  // `end` lands on an ASCII boundary, so slicing `input` by byte index is valid.
293
370
  let num_str = &input[..end];
371
+ // Integer fast-path: a pure integer of ≤15 digits fits `i64` and is exact in `f64`
372
+ // (|value| < 10^15 < 2^53), so `i64 as f64` equals the full float parse but is far cheaper —
373
+ // and JSON arrays of records are integer-heavy. Negative zero is excluded: `i64` loses the
374
+ // sign, but `JSON.parse("-0")` must yield `-0.0` (matches std `f64` parse and Node).
375
+ if is_integer && (1..=15).contains(&int_len) {
376
+ if let Ok(i) = num_str.parse::<i64>() {
377
+ let n = if neg && i == 0 { -0.0 } else { i as f64 };
378
+ return Ok((Value::Number(n), &input[end..]));
379
+ }
380
+ }
294
381
  num_str
295
382
  .parse::<f64>()
296
383
  .map(|n| (Value::Number(n), &input[end..]))
297
384
  .map_err(|_| format!("Invalid number: {}", num_str))
298
385
  }
299
386
 
300
- fn parse_array(input: &str, depth: usize) -> Result<(Value, &str), String> {
387
+ fn parse_array<'a>(
388
+ input: &'a str,
389
+ depth: usize,
390
+ cache: &mut KeyCache,
391
+ ) -> Result<(Value, &'a str), String> {
301
392
  if depth >= MAX_JSON_DEPTH {
302
393
  return Err("JSON nesting too deep".to_string());
303
394
  }
@@ -310,7 +401,7 @@ fn parse_array(input: &str, depth: usize) -> Result<(Value, &str), String> {
310
401
  }
311
402
 
312
403
  loop {
313
- let (value, rest) = parse_value(input, depth + 1)?;
404
+ let (value, rest) = parse_value(input, depth + 1, cache)?;
314
405
  items.push(value);
315
406
  input = rest.trim_start();
316
407
 
@@ -322,17 +413,28 @@ fn parse_array(input: &str, depth: usize) -> Result<(Value, &str), String> {
322
413
  }
323
414
  }
324
415
 
325
- fn parse_object(input: &str, depth: usize) -> Result<(Value, &str), String> {
416
+ fn parse_object<'a>(
417
+ input: &'a str,
418
+ depth: usize,
419
+ cache: &mut KeyCache,
420
+ ) -> Result<(Value, &'a str), String> {
326
421
  if depth >= MAX_JSON_DEPTH {
327
422
  return Err("JSON nesting too deep".to_string());
328
423
  }
329
424
  let mut input = &input[1..]; // skip '{'
330
- let mut map = crate::ObjectMap::default();
425
+ // Build the insertion-ordered `PropMap` directly. The old path collected into an `AHashMap`
426
+ // and then re-inserted every pair into a `PropMap` via `from_strings` — a wasted map + rehash
427
+ // per object, AND the `AHashMap` iteration order scrambled JSON key order (its `RandomState`
428
+ // reseeds per process), so `Object.keys` after `JSON.parse` came out in a non-spec order.
429
+ let mut map = crate::PropMap::new();
331
430
 
332
431
  input = input.trim_start();
333
432
  if let Some(rest) = input.strip_prefix('}') {
334
433
  return Ok((
335
- Value::Object(VmRef::new(crate::ObjectData::from_strings(map))),
434
+ Value::Object(VmRef::new(crate::ObjectData {
435
+ strings: map,
436
+ symbols: None,
437
+ })),
336
438
  rest,
337
439
  ));
338
440
  }
@@ -343,11 +445,7 @@ fn parse_object(input: &str, depth: usize) -> Result<(Value, &str), String> {
343
445
  return Err("Expected string key in object".to_string());
344
446
  }
345
447
 
346
- let (key_val, rest) = parse_string(input)?;
347
- let key: Arc<str> = match key_val {
348
- Value::String(s) => Arc::from(s.as_str()),
349
- _ => unreachable!(),
350
- };
448
+ let (key, rest) = parse_key(input, cache)?;
351
449
 
352
450
  input = rest.trim_start();
353
451
  if !input.starts_with(':') {
@@ -355,7 +453,7 @@ fn parse_object(input: &str, depth: usize) -> Result<(Value, &str), String> {
355
453
  }
356
454
  input = &input[1..];
357
455
 
358
- let (value, rest) = parse_value(input, depth + 1)?;
456
+ let (value, rest) = parse_value(input, depth + 1, cache)?;
359
457
  map.insert(key, value);
360
458
  input = rest.trim_start();
361
459
 
@@ -363,7 +461,10 @@ fn parse_object(input: &str, depth: usize) -> Result<(Value, &str), String> {
363
461
  Some(',') => input = &input[1..],
364
462
  Some('}') => {
365
463
  return Ok((
366
- Value::Object(VmRef::new(crate::ObjectData::from_strings(map))),
464
+ Value::Object(VmRef::new(crate::ObjectData {
465
+ strings: map,
466
+ symbols: None,
467
+ })),
367
468
  &input[1..],
368
469
  ));
369
470
  }
@@ -12,9 +12,31 @@ mod value;
12
12
  mod vmref;
13
13
 
14
14
  pub use console_style::{format_value_styled, format_values_for_console, use_console_colors};
15
- pub use json::{json_parse, json_stringify, json_stringify_into};
15
+ pub use json::{json_parse, json_stringify, json_stringify_into, write_json_number};
16
16
  pub use shape::{ShapeId, DICT_SHAPE, EMPTY_SHAPE};
17
17
  pub use uri::{percent_decode, percent_encode};
18
18
  pub use arcstr::ArcStr;
19
19
  pub use value::*;
20
20
  pub use vmref::{VmReadGuard, VmRef, VmWriteGuard};
21
+
22
+ /// `process.argv` for the interpreter / VM. Defaults to the host process's own `std::env::args()`,
23
+ /// but `tish run <file> [args...]` overrides it (via [`set_process_argv`]) with a node-shaped argv
24
+ /// `[tish-exe, <file>, args...]` so a script sees its own args — not the `run` subcommand. Compiled
25
+ /// native binaries don't touch this; they read `std::env::args()` directly (which is already their
26
+ /// own argv). #88
27
+ static PROCESS_ARGV: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
28
+
29
+ /// Override `process.argv` for the interpreter/VM (set once by `tish run` before executing). A
30
+ /// later call is ignored (the first override wins), matching the single-program-per-process model.
31
+ pub fn set_process_argv(argv: Vec<String>) {
32
+ let _ = PROCESS_ARGV.set(argv);
33
+ }
34
+
35
+ /// The argv a script should see as `process.argv`: the [`set_process_argv`] override if present,
36
+ /// else the host process's `std::env::args()`.
37
+ pub fn process_argv() -> Vec<String> {
38
+ match PROCESS_ARGV.get() {
39
+ Some(v) => v.clone(),
40
+ None => std::env::args().collect(),
41
+ }
42
+ }
@@ -834,6 +834,31 @@ pub fn to_uint32(x: f64) -> u32 {
834
834
  }
835
835
  }
836
836
 
837
+ /// `ToNumber` for the native boxed runtime: a `Value::Number` passes through, every other variant
838
+ /// coerces to `NaN`. This is the same `as_number().unwrap_or(NaN)` convention used by the
839
+ /// interpreter (`binop_number` / `to_int32`), the VM (`eval_binop`), and `ops::add`/`sub`/`mul`/`div`
840
+ /// — so the native backend stays bit-for-bit in lock-step with them at runtime (a string/bool/null
841
+ /// operand is `NaN`, hence `0` for bitwise). The compiler constant-folds literal cases like
842
+ /// `"5" | 0 === 5` separately, so this only governs runtime (non-constant) operands.
843
+ #[inline]
844
+ pub fn to_number_value(v: &Value) -> f64 {
845
+ v.as_number().unwrap_or(f64::NAN)
846
+ }
847
+
848
+ /// `ToInt32` of an arbitrary [`Value`] (coerce via [`to_number_value`], then [`to_int32`]). Designed
849
+ /// to compose in generated code: unlike a `let Value::Number(a) = &(..) else { panic!() }` block,
850
+ /// it binds no name, so nested bitwise/shift operands can never shadow each other.
851
+ #[inline]
852
+ pub fn to_int32_value(v: &Value) -> i32 {
853
+ to_int32(to_number_value(v))
854
+ }
855
+
856
+ /// `ToUint32` companion to [`to_int32_value`].
857
+ #[inline]
858
+ pub fn to_uint32_value(v: &Value) -> u32 {
859
+ to_uint32(to_number_value(v))
860
+ }
861
+
837
862
  /// Invoke a callable [`Value`]: [`Value::Function`], or an object exposing `__call` (e.g. `Symbol`).
838
863
  pub fn value_call(callee: &Value, args: &[Value]) -> Value {
839
864
  match callee {
@@ -910,20 +935,36 @@ impl std::fmt::Debug for Value {
910
935
  /// We take the shortest round-tripping digits from Rust's `{:e}` (a Ryū/Grisu-class shortest
911
936
  /// formatter, matching V8's digit choice) and lay them out per the ECMAScript rule: plain
912
937
  /// decimal when the point position `n` is in `(-6, 21]`, otherwise `d[.ddd]e±E` with `E = n-1`
913
- /// (sign always shown, no leading zeros in the exponent). `-0` renders as `"-0"` (matching
914
- /// `console.log` and tish's existing behavior).
938
+ /// (sign always shown, no leading zeros in the exponent). This is the ECMAScript `Number::toString`
939
+ /// used by `.toString()`, `String(n)`, `+` concatenation, and template literals, so `-0` renders as
940
+ /// `"0"` (the sign is dropped — `(-0).toString() === "0"`). The *inspect* form (`console.log` of a
941
+ /// bare number / array element) keeps `"-0"`; that distinction lives in [`Value::to_display_string`].
915
942
  pub fn js_number_to_string(value: f64) -> String {
943
+ let mut out = String::new();
944
+ js_number_to_string_into(&mut out, value);
945
+ out
946
+ }
947
+
948
+ /// Append the ECMAScript `Number::toString` of `value` to `out` — identical result to
949
+ /// [`js_number_to_string`] but with no intermediate `String` allocation. The JSON.stringify
950
+ /// hot path appends millions of numbers into a single buffer, so the alloc-free form matters.
951
+ pub fn js_number_to_string_into(out: &mut String, value: f64) {
916
952
  if value.is_nan() {
917
- return "NaN".to_string();
953
+ out.push_str("NaN");
954
+ return;
918
955
  }
919
956
  if value == f64::INFINITY {
920
- return "Infinity".to_string();
957
+ out.push_str("Infinity");
958
+ return;
921
959
  }
922
960
  if value == f64::NEG_INFINITY {
923
- return "-Infinity".to_string();
961
+ out.push_str("-Infinity");
962
+ return;
924
963
  }
925
964
  if value == 0.0 {
926
- return if value.is_sign_negative() { "-0" } else { "0" }.to_string();
965
+ // ECMAScript `Number::toString`: both `+0` and `-0` stringify to `"0"`.
966
+ out.push('0');
967
+ return;
927
968
  }
928
969
 
929
970
  let negative = value < 0.0;
@@ -939,7 +980,6 @@ pub fn js_number_to_string(value: f64) -> String {
939
980
  let k = digits.len() as i32; // significant digit count (≤ 17 for an f64)
940
981
  let point = exp + 1; // ECMAScript's `n`: value = digits × 10^(point − k)
941
982
 
942
- let mut out = String::new();
943
983
  if negative {
944
984
  out.push('-');
945
985
  }
@@ -969,13 +1009,15 @@ pub fn js_number_to_string(value: f64) -> String {
969
1009
  out.push(if e >= 0 { '+' } else { '-' });
970
1010
  out.push_str(&e.abs().to_string());
971
1011
  }
972
- out
973
1012
  }
974
1013
 
975
1014
  impl Value {
976
1015
  /// Convert value to display string (for console output).
977
1016
  pub fn to_display_string(&self) -> String {
978
1017
  match self {
1018
+ // Inspect form keeps the sign of negative zero (`console.log(-0)` → `-0`), unlike the
1019
+ // ECMAScript ToString used by `to_js_string`. See `js_number_to_string`. (#247)
1020
+ Value::Number(n) if *n == 0.0 && n.is_sign_negative() => "-0".to_string(),
979
1021
  Value::Number(n) => js_number_to_string(*n),
980
1022
  Value::String(s) => s.to_string(),
981
1023
  Value::Bool(b) => b.to_string(),
@@ -1046,6 +1088,9 @@ impl Value {
1046
1088
  .collect::<Vec<_>>()
1047
1089
  .join(","),
1048
1090
  Value::Object(_) => "[object Object]".to_string(),
1091
+ // ECMAScript ToString of a number (drops `-0`'s sign), distinct from the inspect form
1092
+ // that `to_display_string` would give for `-0`. (#247)
1093
+ Value::Number(n) => js_number_to_string(*n),
1049
1094
  // Primitives (and the remaining cases) coincide with the display form.
1050
1095
  _ => self.to_display_string(),
1051
1096
  }
@@ -1148,12 +1193,18 @@ impl Value {
1148
1193
  // -------------------------------------------------------------------------
1149
1194
 
1150
1195
  /// Whether packed f64 arrays are enabled this run. Default: **off** (`TISH_PACKED_ARRAYS=1`
1151
- /// opts in). Checked at every creation site so flag changes take effect per-process.
1196
+ /// opts in). Read once per process and cached the VM calls this once per executed array
1197
+ /// literal, and a `std::env::var` there is a libc env lock plus a `String` allocation per
1198
+ /// `NewArray` for a flag that never changes after startup (#166). Set the variable before the
1199
+ /// process starts (as the CI sweep does); mid-process toggling is not observed by design.
1152
1200
  /// The flag is intentionally backwards from the slot/JIT flags (those were default-on) to
1153
1201
  /// keep the default binary behaviour byte-identical while we validate coverage.
1154
1202
  #[inline]
1155
1203
  pub fn packed_arrays_enabled() -> bool {
1156
- std::env::var("TISH_PACKED_ARRAYS").map(|v| v == "1").unwrap_or(false)
1204
+ static PACKED_ARRAYS: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1205
+ *PACKED_ARRAYS.get_or_init(|| {
1206
+ std::env::var("TISH_PACKED_ARRAYS").map(|v| v == "1").unwrap_or(false)
1207
+ })
1157
1208
  }
1158
1209
 
1159
1210
  /// Wrap a `Vec<f64>` as a `Value::NumberArray`. Only call when `packed_arrays_enabled()`.
@@ -1310,9 +1361,11 @@ mod number_to_string_tests {
1310
1361
  #[test]
1311
1362
  fn matches_javascript_number_tostring() {
1312
1363
  // (value, expected) — every `expected` is what Node's `String(value)` produces.
1364
+ // `String(-0) === "0"`: ToString drops the sign of negative zero (the inspect form keeps
1365
+ // it, but that path is `Value::to_display_string`, not this function). (#247)
1313
1366
  let cases: &[(f64, &str)] = &[
1314
1367
  (0.0, "0"),
1315
- (-0.0, "-0"),
1368
+ (-0.0, "0"),
1316
1369
  (123.0, "123"),
1317
1370
  (123.456, "123.456"),
1318
1371
  (0.5, "0.5"),