@tishlang/tish 2.2.7 → 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 (52) 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 +6 -2
  7. package/crates/tish_builtins/src/array.rs +82 -1
  8. package/crates/tish_builtins/src/globals.rs +50 -16
  9. package/crates/tish_builtins/src/math.rs +63 -9
  10. package/crates/tish_builtins/src/number.rs +20 -1
  11. package/crates/tish_builtins/src/string.rs +68 -4
  12. package/crates/tish_builtins/src/typedarrays.rs +23 -16
  13. package/crates/tish_bytecode/src/compiler.rs +94 -28
  14. package/crates/tish_bytecode/tests/break_continue_bytecode.rs +19 -0
  15. package/crates/tish_compile/src/check.rs +1 -1
  16. package/crates/tish_compile/src/codegen.rs +1386 -42
  17. package/crates/tish_compile/src/infer.rs +4 -4
  18. package/crates/tish_compile/src/lib.rs +38 -0
  19. package/crates/tish_compile/src/resolve.rs +3 -2
  20. package/crates/tish_compile/src/types.rs +17 -0
  21. package/crates/tish_compile_js/src/codegen.rs +55 -4
  22. package/crates/tish_compile_js/src/tests_jsx.rs +44 -0
  23. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +1 -0
  24. package/crates/tish_core/Cargo.toml +2 -0
  25. package/crates/tish_core/src/json.rs +135 -34
  26. package/crates/tish_core/src/lib.rs +23 -1
  27. package/crates/tish_core/src/value.rs +64 -11
  28. package/crates/tish_eval/src/eval.rs +144 -197
  29. package/crates/tish_eval/src/natives.rs +45 -45
  30. package/crates/tish_eval/src/regex.rs +35 -27
  31. package/crates/tish_eval/src/value.rs +8 -0
  32. package/crates/tish_fmt/src/lib.rs +45 -1
  33. package/crates/tish_lint/src/bin/tish-lint.rs +17 -8
  34. package/crates/tish_lint/src/lib.rs +197 -21
  35. package/crates/tish_lsp/Cargo.toml +6 -0
  36. package/crates/tish_lsp/src/import_goto.rs +52 -7
  37. package/crates/tish_lsp/src/main.rs +856 -140
  38. package/crates/tish_opt/src/lib.rs +5 -3
  39. package/crates/tish_parser/src/lib.rs +23 -5
  40. package/crates/tish_parser/src/parser.rs +15 -26
  41. package/crates/tish_resolve/src/lib.rs +188 -18
  42. package/crates/tish_runtime/src/lib.rs +58 -18
  43. package/crates/tish_ui/src/jsx.rs +2 -2
  44. package/crates/tish_vm/src/jit.rs +212 -10
  45. package/crates/tish_vm/src/vm.rs +39 -18
  46. package/crates/tish_wasm/src/lib.rs +116 -22
  47. package/package.json +1 -1
  48. package/platform/darwin-arm64/tish +0 -0
  49. package/platform/darwin-x64/tish +0 -0
  50. package/platform/linux-arm64/tish +0 -0
  51. package/platform/linux-x64/tish +0 -0
  52. package/platform/win32-x64/tish.exe +0 -0
@@ -558,7 +558,7 @@ pub(crate) fn pi_mentions(e: &Expr, name: &str) -> bool {
558
558
  }
559
559
  }),
560
560
  Object { props, .. } => props.iter().any(|p| match p {
561
- tishlang_ast::ObjectProp::KeyValue(_, v) => pi_mentions(v, name),
561
+ tishlang_ast::ObjectProp::KeyValue(_, v, _) => pi_mentions(v, name),
562
562
  tishlang_ast::ObjectProp::Spread(x) => pi_mentions(x, name),
563
563
  }),
564
564
  TemplateLiteral { exprs, .. } => exprs.iter().any(|x| pi_mentions(x, name)),
@@ -634,7 +634,7 @@ fn infer_object_shape(
634
634
  let mut fields = Vec::with_capacity(props.len());
635
635
  for p in props {
636
636
  match p {
637
- tishlang_ast::ObjectProp::KeyValue(k, v) => {
637
+ tishlang_ast::ObjectProp::KeyValue(k, v, _) => {
638
638
  let ty = infer_expr_type(v, ctx)?;
639
639
  // Only primitive field types in this conservative version.
640
640
  if !matches!(&ty, TypeAnnotation::Simple(s, _)
@@ -1303,7 +1303,7 @@ fn arr_expr_safe(e: &Expr, name: &str) -> bool {
1303
1303
  }
1304
1304
  }),
1305
1305
  Object { props, .. } => props.iter().all(|p| match p {
1306
- tishlang_ast::ObjectProp::KeyValue(_, v) => arr_expr_safe(v, name),
1306
+ tishlang_ast::ObjectProp::KeyValue(_, v, _) => arr_expr_safe(v, name),
1307
1307
  tishlang_ast::ObjectProp::Spread(v) => arr_expr_safe(v, name),
1308
1308
  }),
1309
1309
  // Anything else that mentions `name`: be safe, bail.
@@ -1423,7 +1423,7 @@ fn expr_name_safe(e: &Expr, name: &str, keys: &std::collections::HashSet<&str>)
1423
1423
  }
1424
1424
  }),
1425
1425
  Object { props, .. } => props.iter().all(|p| match p {
1426
- tishlang_ast::ObjectProp::KeyValue(_, v) => expr_name_safe(v, name, keys),
1426
+ tishlang_ast::ObjectProp::KeyValue(_, v, _) => expr_name_safe(v, name, keys),
1427
1427
  tishlang_ast::ObjectProp::Spread(e) => expr_name_safe(e, name, keys),
1428
1428
  }),
1429
1429
  TemplateLiteral { exprs, .. } => exprs.iter().all(|e| expr_name_safe(e, name, keys)),
@@ -186,6 +186,44 @@ fn factory() {
186
186
  "expected outerVar to be inferred as f64 (Copy, no clone needed)"
187
187
  );
188
188
  }
189
+
190
+ /// i32-loop-var lowering: an FNV-style integer/bitwise hash accumulator declared before a `for`
191
+ /// and reassigned only by `>>> 0`/bitwise ops lives in an `i32` register across the loop — no
192
+ /// per-op `to_int32(h)`↔`f64` round-trip — with a single `f64` excursion for the `h * C`
193
+ /// multiply (which exceeds 2^53, so it must round in f64 before `>>> 0`, exactly as V8 does).
194
+ #[test]
195
+ fn fnv_accumulator_lowers_to_i32_register() {
196
+ let src = r#"
197
+ let h = 2166136261
198
+ for (let i = 0; i < 100; i++) {
199
+ h = h ^ (i & 255)
200
+ h = (h * 16777619) >>> 0
201
+ h = ((h << 13) | (h >>> 19)) >>> 0
202
+ }
203
+ let check = h >>> 0
204
+ console.log(check)
205
+ "#;
206
+ let rust = compile(&parse(src).unwrap()).unwrap();
207
+ // The accumulator is an i32 register, initialized via the u32 reinterpretation so the
208
+ // > i32::MAX seed keeps its JS ToInt32 bit-pattern.
209
+ assert!(
210
+ rust.contains("let mut h: i32 = (2166136261u32) as i32;"),
211
+ "expected `h` to be an i32 register seeded via u32 reinterpretation; got:\n{}",
212
+ rust.lines().filter(|l| l.contains("h")).take(8).collect::<Vec<_>>().join("\n")
213
+ );
214
+ // The per-iteration `to_int32(h)` round-trips are gone: `h` is read straight from the
215
+ // register inside the bitwise chain (no `to_int32(h)` substring referencing the accumulator).
216
+ assert!(
217
+ !rust.contains("to_int32(h)"),
218
+ "expected NO per-op `to_int32(h)` round-trip on the i32 accumulator"
219
+ );
220
+ // The only f64 excursion is the multiply, lowered as an unchecked truncation of the
221
+ // provably-finite product (`h as f64 * 16777619`).
222
+ assert!(
223
+ rust.contains(".to_int_unchecked::<i64>()") && rust.contains("16777619"),
224
+ "expected the `h * 16777619` excursion to lower to an unchecked f64 truncation"
225
+ );
226
+ }
189
227
  }
190
228
 
191
229
  #[cfg(test)]
@@ -173,7 +173,7 @@ pub fn program_uses_document(program: &Program) -> bool {
173
173
  ArrayElement::Expr(e) | ArrayElement::Spread(e) => expr_uses_document(e),
174
174
  }),
175
175
  Expr::Object { props, .. } => props.iter().any(|p| match p {
176
- ObjectProp::KeyValue(_, e) | ObjectProp::Spread(e) => expr_uses_document(e),
176
+ ObjectProp::KeyValue(_, e, _) | ObjectProp::Spread(e) => expr_uses_document(e),
177
177
  }),
178
178
  Expr::Assign { value, .. }
179
179
  | Expr::CompoundAssign { value, .. }
@@ -1601,7 +1601,7 @@ fn rewrite_expr_scope(expr: &mut Expr, active: &HashMap<String, Arc<str>>) {
1601
1601
  Expr::Object { props, .. } => {
1602
1602
  for p in props {
1603
1603
  match p {
1604
- ObjectProp::KeyValue(_, e) | ObjectProp::Spread(e) => {
1604
+ ObjectProp::KeyValue(_, e, _) | ObjectProp::Spread(e) => {
1605
1605
  rewrite_expr_scope(e, active) // key is a property name; value recurses
1606
1606
  }
1607
1607
  }
@@ -1819,6 +1819,7 @@ pub fn merge_modules(mut modules: Vec<ResolvedModule>) -> Result<MergedProgram,
1819
1819
  name: Arc::from(v.clone()),
1820
1820
  span: *span,
1821
1821
  },
1822
+ *name_span,
1822
1823
  ));
1823
1824
  }
1824
1825
  merge_push(
@@ -13,6 +13,12 @@ pub enum RustType {
13
13
  Value,
14
14
  /// f64 (for number)
15
15
  F64,
16
+ /// i32 — a `number` local PROVEN to always hold an integer reinterpretable as a JS ToInt32
17
+ /// bit-pattern, kept in an integer register across a bitwise/hash hot loop (bun/JSC-style)
18
+ /// instead of round-tripping `f64`↔`i32` on every op. The value is the signed int32
19
+ /// (= JS `ToInt32`) view; `>>> 0` results are uint32 reinterpreted into this i32. Only the
20
+ /// codegen's i32-loop-var lowering produces this type — never `from_annotation`.
21
+ I32,
16
22
  /// String (for string)
17
23
  String,
18
24
  /// bool (for boolean)
@@ -222,6 +228,7 @@ impl RustType {
222
228
  match self {
223
229
  RustType::Value => "Value".to_string(),
224
230
  RustType::F64 => "f64".to_string(),
231
+ RustType::I32 => "i32".to_string(),
225
232
  RustType::String => "String".to_string(),
226
233
  RustType::Bool => "bool".to_string(),
227
234
  RustType::Unit => "()".to_string(),
@@ -250,6 +257,7 @@ impl RustType {
250
257
  match self {
251
258
  RustType::Value => "Value::Null".to_string(),
252
259
  RustType::F64 => "0.0".to_string(),
260
+ RustType::I32 => "0i32".to_string(),
253
261
  RustType::String => "String::new()".to_string(),
254
262
  RustType::Bool => "false".to_string(),
255
263
  RustType::Unit => "()".to_string(),
@@ -306,6 +314,12 @@ impl RustType {
306
314
  "match &{} {{ Value::Number(n) => *n, _ => panic!(\"expected number\") }}",
307
315
  value_expr
308
316
  ),
317
+ // A `Value::Number` narrowed to its JS ToInt32 bit-pattern (NaN/±Inf → 0, exactly as
318
+ // the bitwise/shift path coerces). Only reached for an `I32`-typed binding boundary.
319
+ RustType::I32 => format!(
320
+ "match &{} {{ Value::Number(n) => tishlang_runtime::to_int32(*n), _ => panic!(\"expected number\") }}",
321
+ value_expr
322
+ ),
309
323
  RustType::String => format!(
310
324
  "match &{} {{ Value::String(s) => s.to_string(), _ => panic!(\"expected string\") }}",
311
325
  value_expr
@@ -363,6 +377,9 @@ impl RustType {
363
377
  }
364
378
  RustType::Value => native_expr.to_string(),
365
379
  RustType::F64 => format!("Value::Number({})", native_expr),
380
+ // The signed int32 view boxes as a JS Number (every i32 is exactly representable in
381
+ // f64). The uint32 `>>> 0` reinterpretation is applied at the boxing site, not here.
382
+ RustType::I32 => format!("Value::Number(({}) as f64)", native_expr),
366
383
  RustType::String => format!("Value::String({}.clone().into())", native_expr),
367
384
  RustType::Bool => format!("Value::Bool({})", native_expr),
368
385
  RustType::Unit => "Value::Null".to_string(),
@@ -487,9 +487,29 @@ impl Codegen {
487
487
  }
488
488
  }
489
489
 
490
+ /// Is `expr` the `null` literal? Used to lower `x === null` / `x !== null` to JS `== null` /
491
+ /// `!= null` so the check catches `undefined` too (tish treats a missing/undefined value as
492
+ /// null on every other backend) — see the StrictEq/StrictNe arms below.
493
+ fn is_null_literal(expr: &Expr) -> bool {
494
+ matches!(
495
+ expr,
496
+ Expr::Literal {
497
+ value: Literal::Null,
498
+ ..
499
+ }
500
+ )
501
+ }
502
+
490
503
  fn emit_expr(&mut self, expr: &Expr) -> Result<String, CompileError> {
491
504
  Ok(match expr {
492
505
  Expr::Literal { value, .. } => match value {
506
+ // Rust's `{}` prints non-finite f64 as `inf` / `-inf` / `NaN`; only `NaN` is valid JS.
507
+ // Emit the JS spellings so a folded `1/0` / `-1/0` doesn't become an undefined `inf`
508
+ // identifier in the output.
509
+ Literal::Number(n) if n.is_nan() => "NaN".to_string(),
510
+ Literal::Number(n) if n.is_infinite() => {
511
+ if *n < 0.0 { "-Infinity".to_string() } else { "Infinity".to_string() }
512
+ }
493
513
  Literal::Number(n) => format!("{}", n),
494
514
  Literal::String(s) => format!("{:?}", s.as_ref()),
495
515
  Literal::Bool(b) => format!("{}", b),
@@ -510,8 +530,26 @@ impl Codegen {
510
530
  BinOp::Pow => "**",
511
531
  BinOp::Eq => "==",
512
532
  BinOp::Ne => "!=",
513
- BinOp::StrictEq => "===",
514
- BinOp::StrictNe => "!==",
533
+ // tish has no `undefined`: a missing/absent value reads back as `null`, so
534
+ // `x === null` means "is nullish" — exactly how interp/vm/native behave (a
535
+ // missing property is null there). In the JS runtime a missing property is
536
+ // `undefined`, so lower `=== null` / `!== null` to loose `== null` / `!= null`,
537
+ // which match BOTH null and undefined — keeping `=== null` mean the same thing on
538
+ // every target. Strict equality between non-null operands is unaffected.
539
+ BinOp::StrictEq => {
540
+ if Self::is_null_literal(left) || Self::is_null_literal(right) {
541
+ "=="
542
+ } else {
543
+ "==="
544
+ }
545
+ }
546
+ BinOp::StrictNe => {
547
+ if Self::is_null_literal(left) || Self::is_null_literal(right) {
548
+ "!="
549
+ } else {
550
+ "!=="
551
+ }
552
+ }
515
553
  BinOp::Lt => "<",
516
554
  BinOp::Le => "<=",
517
555
  BinOp::Gt => ">",
@@ -563,6 +601,15 @@ impl Codegen {
563
601
  ..
564
602
  } => {
565
603
  let obj = self.emit_expr(object)?;
604
+ // `255.toString()` is a JS syntax error — the lexer reads `255.` as a float and
605
+ // then chokes on the method name. Parenthesize a numeric-literal object so member
606
+ // access / method calls stay valid: `(255).toString()`. (Folded constants reach
607
+ // codegen as number literals too, so this covers e.g. `(100 * 2).toString()`.)
608
+ let obj = if matches!(&**object, Expr::Literal { value: Literal::Number(_), .. }) {
609
+ format!("({})", obj)
610
+ } else {
611
+ obj
612
+ };
566
613
  let expr = match prop {
567
614
  MemberProp::Name { name, .. } => {
568
615
  if name.parse::<u32>().is_ok()
@@ -629,7 +676,7 @@ impl Codegen {
629
676
  let parts: Result<Vec<_>, _> = props
630
677
  .iter()
631
678
  .map(|p| match p {
632
- ObjectProp::KeyValue(k, v) => {
679
+ ObjectProp::KeyValue(k, v, _) => {
633
680
  let key = k.as_ref();
634
681
  let val = self.emit_expr(v)?;
635
682
  Ok(if key.chars().all(|c| c.is_alphanumeric() || c == '_') {
@@ -650,7 +697,11 @@ impl Codegen {
650
697
  }
651
698
  Expr::TypeOf { operand, .. } => {
652
699
  let o = self.emit_expr(operand)?;
653
- format!("(typeof {})", o)
700
+ // tish `typeof null` is "null" (interp/vm/native all agree — null is a first-class
701
+ // type, not JS's `typeof null === "object"` wart). tish has no `undefined`, so any
702
+ // nullish operand (incl. a JS-runtime `undefined`) maps to "null". Evaluate the
703
+ // operand once via the arrow arg so side effects don't run twice.
704
+ format!("((__v) => __v == null ? \"null\" : typeof __v)({})", o)
654
705
  }
655
706
  Expr::Delete { target, .. } => {
656
707
  // Emit the raw property *reference*, not a value: `emit_expr` wraps Index /
@@ -411,4 +411,48 @@ fn factory() {
411
411
  );
412
412
  }
413
413
  }
414
+
415
+ // tish `=== null` / `!== null` lower to JS `== null` / `!= null` so the nullish check catches the
416
+ // JS-runtime `undefined` (missing props / holes) too — matching interp/vm/native, which read a
417
+ // missing property back as null. Strict equality between non-null operands stays strict.
418
+ #[test]
419
+ fn strict_eq_null_lowers_to_loose_null() {
420
+ let program = parse("let x = 1\nconsole.log(x === null)\nconsole.log(x !== null)\n").unwrap();
421
+ let js = crate::compile(&program, false).unwrap();
422
+ assert!(!js.contains("=== null"), "`=== null` must lower to `== null`:\n{js}");
423
+ assert!(!js.contains("!== null"), "`!== null` must lower to `!= null`:\n{js}");
424
+ assert!(
425
+ js.contains("== null") && js.contains("!= null"),
426
+ "expected loose null checks:\n{js}"
427
+ );
428
+ }
429
+
430
+ #[test]
431
+ fn strict_eq_between_non_null_operands_stays_strict() {
432
+ let program = parse("let a = 1\nlet b = 2\nconsole.log(a === b)\nconsole.log(a !== b)\n").unwrap();
433
+ let js = crate::compile(&program, false).unwrap();
434
+ assert!(js.contains("==="), "non-null `===` must stay strict:\n{js}");
435
+ assert!(js.contains("!=="), "non-null `!==` must stay strict:\n{js}");
436
+ }
437
+
438
+ // `typeof null` is "null" in tish (interp/vm/native agree — null is a first-class type), not JS's
439
+ // `typeof null === "object"` wart. The JS backend must map a nullish operand to "null".
440
+ #[test]
441
+ fn typeof_null_emits_null_not_object() {
442
+ let program = parse("console.log(typeof null)\n").unwrap();
443
+ let js = crate::compile(&program, false).unwrap();
444
+ assert!(!js.contains("(typeof null)"), "must not emit raw `typeof null`:\n{js}");
445
+ assert!(js.contains("\"null\""), "typeof of a nullish value must yield \"null\":\n{js}");
446
+ }
447
+
448
+ // 1/0 and -1/0 fold to Infinity / -Infinity; emit the JS spellings, not Rust's `inf` / `-inf`
449
+ // (which would be undefined identifiers in the output).
450
+ #[test]
451
+ fn non_finite_number_literals_use_js_spellings() {
452
+ let pos = crate::compile(&parse("console.log(1 / 0)\n").unwrap(), true).unwrap();
453
+ assert!(pos.contains("Infinity"), "1/0 must emit Infinity:\n{pos}");
454
+ assert!(!pos.contains("inf"), "must not emit Rust's lowercase `inf`:\n{pos}");
455
+ let neg = crate::compile(&parse("console.log(-1 / 0)\n").unwrap(), true).unwrap();
456
+ assert!(neg.contains("-Infinity"), "-1/0 must emit -Infinity:\n{neg}");
457
+ }
414
458
  }
@@ -394,6 +394,7 @@ pub fn merge_modules_virtual(modules: Vec<VirtualModule>) -> Result<Program, Str
394
394
  name: Arc::from(v.clone()),
395
395
  span: *span,
396
396
  },
397
+ *name_span,
397
398
  ));
398
399
  }
399
400
  statements.push(Statement::VarDecl {
@@ -23,6 +23,8 @@ ahash = "0.8.11"
23
23
  arcstr = "1"
24
24
  smallvec = "1"
25
25
  indexmap = "2"
26
+ # Fast integer→decimal for the JSON.stringify number fast-path (already in the workspace lock).
27
+ itoa = "1"
26
28
  fancy-regex = { version = "0.17.0", optional = true }
27
29
  # Only under `send-values` (the Arc<Mutex> path): parking_lot's uncontended lock is a
28
30
  # single atomic with no pthread syscall — profiled as a top cost on object/array access.
@@ -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
+ }