@tishlang/tish 2.2.7 → 2.9.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 (55) 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 +2 -1
  4. package/crates/tish/src/cli_help.rs +15 -0
  5. package/crates/tish/src/main.rs +139 -12
  6. package/crates/tish/tests/integration_test.rs +65 -0
  7. package/crates/tish_ast/src/ast.rs +6 -2
  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 +1 -1
  17. package/crates/tish_compile/src/codegen.rs +3133 -582
  18. package/crates/tish_compile/src/infer.rs +1950 -56
  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 +17 -0
  22. package/crates/tish_compile_js/Cargo.toml +3 -0
  23. package/crates/tish_compile_js/src/codegen.rs +365 -9
  24. package/crates/tish_compile_js/src/lib.rs +2 -1
  25. package/crates/tish_compile_js/src/tests_jsx.rs +229 -1
  26. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +1 -0
  27. package/crates/tish_core/Cargo.toml +2 -0
  28. package/crates/tish_core/src/json.rs +135 -34
  29. package/crates/tish_core/src/lib.rs +23 -1
  30. package/crates/tish_core/src/value.rs +64 -11
  31. package/crates/tish_eval/src/eval.rs +144 -197
  32. package/crates/tish_eval/src/natives.rs +45 -45
  33. package/crates/tish_eval/src/regex.rs +35 -27
  34. package/crates/tish_eval/src/value.rs +8 -0
  35. package/crates/tish_fmt/src/lib.rs +45 -1
  36. package/crates/tish_lint/src/bin/tish-lint.rs +17 -8
  37. package/crates/tish_lint/src/lib.rs +197 -21
  38. package/crates/tish_lsp/Cargo.toml +6 -0
  39. package/crates/tish_lsp/src/import_goto.rs +52 -7
  40. package/crates/tish_lsp/src/main.rs +856 -140
  41. package/crates/tish_opt/src/lib.rs +5 -3
  42. package/crates/tish_parser/src/lib.rs +23 -5
  43. package/crates/tish_parser/src/parser.rs +15 -26
  44. package/crates/tish_resolve/src/lib.rs +188 -18
  45. package/crates/tish_runtime/src/lib.rs +58 -18
  46. package/crates/tish_ui/src/jsx.rs +2 -2
  47. package/crates/tish_vm/src/jit.rs +212 -10
  48. package/crates/tish_vm/src/vm.rs +39 -18
  49. package/crates/tish_wasm/src/lib.rs +116 -22
  50. package/package.json +1 -1
  51. package/platform/darwin-arm64/tish +0 -0
  52. package/platform/darwin-x64/tish +0 -0
  53. package/platform/linux-arm64/tish +0 -0
  54. package/platform/linux-x64/tish +0 -0
  55. package/platform/win32-x64/tish.exe +0 -0
@@ -4,7 +4,7 @@ mod tests {
4
4
 
5
5
  use tishlang_parser::parse;
6
6
 
7
- use crate::{compile_project_with_jsx, compile_with_jsx};
7
+ use crate::{compile_project_esm, compile_project_with_jsx, compile_with_jsx, EmittedJsModule};
8
8
 
9
9
  #[test]
10
10
  fn lattish_jsx_emits_h_with_children_array() {
@@ -411,4 +411,232 @@ 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
+ }
458
+
459
+ // ── #282: ESM module output (one file per module, real import/export) ──────────────────────
460
+
461
+ /// Write a set of `(relative_path, source)` modules into a fresh temp dir and compile the entry
462
+ /// in ESM mode. Returns the emitted modules keyed for easy lookup by their output relative path.
463
+ fn build_esm(entry: &str, modules: &[(&str, &str)]) -> Vec<EmittedJsModule> {
464
+ let tmp = tempfile::tempdir().expect("tempdir");
465
+ let dir = tmp.path();
466
+ for (rel, src) in modules {
467
+ let p = dir.join(rel);
468
+ if let Some(parent) = p.parent() {
469
+ std::fs::create_dir_all(parent).unwrap();
470
+ }
471
+ let mut f = std::fs::File::create(&p).unwrap();
472
+ f.write_all(src.as_bytes()).unwrap();
473
+ f.sync_all().unwrap();
474
+ }
475
+ compile_project_esm(&dir.join(entry), Some(dir), false).expect("compile_project_esm failed")
476
+ }
477
+
478
+ fn module_js<'a>(mods: &'a [EmittedJsModule], rel: &str) -> &'a str {
479
+ mods.iter()
480
+ .find(|m| m.relative_path.to_string_lossy() == rel)
481
+ .map(|m| m.js.as_str())
482
+ .unwrap_or_else(|| panic!("module {rel} not emitted; got {:?}", mods.iter().map(|m| m.relative_path.display().to_string()).collect::<Vec<_>>()))
483
+ }
484
+
485
+ #[test]
486
+ fn esm_emits_named_and_default_exports() {
487
+ let mods = build_esm(
488
+ "main.tish",
489
+ &[(
490
+ "main.tish",
491
+ "export const VERSION = \"1.0\"\nexport fn greet(n) { return \"hi \" + n }\nexport default greet\n",
492
+ )],
493
+ );
494
+ let js = module_js(&mods, "main.js");
495
+ assert!(js.contains("export const VERSION = \"1.0\";"), "named const export:\n{js}");
496
+ assert!(js.contains("export function greet "), "named fn export:\n{js}");
497
+ assert!(js.contains("export default greet;"), "default export:\n{js}");
498
+ }
499
+
500
+ #[test]
501
+ fn esm_rewrites_import_specifier_to_js_with_alias() {
502
+ let mods = build_esm(
503
+ "main.tish",
504
+ &[
505
+ ("dep.tish", "export const ssrH = 42\nexport fn greet(n) { return n }\n"),
506
+ (
507
+ "main.tish",
508
+ "import { ssrH as h, greet } from \"./dep.tish\"\nimport * as M from \"./dep.tish\"\nconsole.log(h)\nconsole.log(greet(M.ssrH))\n",
509
+ ),
510
+ ],
511
+ );
512
+ let js = module_js(&mods, "main.js");
513
+ assert!(
514
+ js.contains("import { ssrH as h, greet } from \"./dep.js\";"),
515
+ "named import with alias, .tish->.js:\n{js}"
516
+ );
517
+ assert!(js.contains("import * as M from \"./dep.js\";"), "namespace import:\n{js}");
518
+ }
519
+
520
+ #[test]
521
+ fn esm_one_file_per_module_preserves_tree() {
522
+ let mods = build_esm(
523
+ "main.tish",
524
+ &[
525
+ ("lib/util.tish", "export fn id(x) { return x }\n"),
526
+ ("main.tish", "import { id } from \"./lib/util.tish\"\nconsole.log(id(1))\n"),
527
+ ],
528
+ );
529
+ // Nested module keeps its relative path; importer points at the nested `.js`.
530
+ let _ = module_js(&mods, "lib/util.js");
531
+ let main = module_js(&mods, "main.js");
532
+ assert!(
533
+ main.contains("from \"./lib/util.js\";"),
534
+ "nested relative import preserved:\n{main}"
535
+ );
536
+ }
537
+
538
+ #[test]
539
+ fn esm_module_outside_project_root_is_emitted() {
540
+ // #282 follow-up: a dependency in a *sibling* package (outside the entry's project root)
541
+ // must still be emitted — the output tree is rooted at the directory common to all modules.
542
+ let tmp = tempfile::tempdir().expect("tempdir");
543
+ let base = tmp.path();
544
+ std::fs::create_dir_all(base.join("app/src")).unwrap();
545
+ std::fs::create_dir_all(base.join("lib")).unwrap();
546
+ std::fs::write(base.join("lib/util.tish"), "export fn id(x) { return x }\n").unwrap();
547
+ std::fs::write(
548
+ base.join("app/src/main.tish"),
549
+ "import { id } from \"../../lib/util.tish\"\nconsole.log(id(1))\n",
550
+ )
551
+ .unwrap();
552
+ // Project root is the entry's package (`app`); `lib/util.tish` lives outside it.
553
+ let mods = compile_project_esm(
554
+ &base.join("app/src/main.tish"),
555
+ Some(&base.join("app")),
556
+ false,
557
+ )
558
+ .expect("compile_project_esm failed for sibling dep");
559
+ let rels: Vec<String> = mods
560
+ .iter()
561
+ .map(|m| m.relative_path.to_string_lossy().replace('\\', "/"))
562
+ .collect();
563
+ let main_js = mods
564
+ .iter()
565
+ .find(|m| m.relative_path.to_string_lossy().replace('\\', "/").ends_with("app/src/main.js"))
566
+ .map(|m| m.js.clone());
567
+ assert!(
568
+ rels.iter().any(|r| r.ends_with("lib/util.js")),
569
+ "sibling dep emitted under common base: {:?}",
570
+ rels
571
+ );
572
+ assert!(
573
+ rels.iter().any(|r| r.ends_with("app/src/main.js")),
574
+ "entry emitted under its own subtree: {:?}",
575
+ rels
576
+ );
577
+ let main_js = main_js.expect("entry module present");
578
+ assert!(
579
+ main_js.contains("from \"../../lib/util.js\";"),
580
+ "relative import to sibling rewritten to .js:\n{main_js}"
581
+ );
582
+ }
583
+
584
+ #[test]
585
+ fn esm_bare_node_modules_dep_is_emitted() {
586
+ // #282 follow-up: a bare specifier resolved from `node_modules` (like `lattish`) is emitted
587
+ // into the output tree and the importer points at it with a relative `.js` specifier.
588
+ let tmp = tempfile::tempdir().expect("tempdir");
589
+ let base = tmp.path();
590
+ std::fs::create_dir_all(base.join("src")).unwrap();
591
+ std::fs::create_dir_all(base.join("node_modules/pkg")).unwrap();
592
+ std::fs::write(
593
+ base.join("node_modules/pkg/package.json"),
594
+ "{\"name\":\"pkg\",\"main\":\"index.tish\"}\n",
595
+ )
596
+ .unwrap();
597
+ std::fs::write(
598
+ base.join("node_modules/pkg/index.tish"),
599
+ "export fn ping() { return \"pong\" }\n",
600
+ )
601
+ .unwrap();
602
+ std::fs::write(
603
+ base.join("src/main.tish"),
604
+ "import { ping } from \"pkg\"\nconsole.log(ping())\n",
605
+ )
606
+ .unwrap();
607
+ let mods = compile_project_esm(&base.join("src/main.tish"), Some(base), false)
608
+ .expect("compile_project_esm failed for node_modules dep");
609
+ let rels: Vec<String> = mods
610
+ .iter()
611
+ .map(|m| m.relative_path.to_string_lossy().replace('\\', "/"))
612
+ .collect();
613
+ let main_js = mods
614
+ .iter()
615
+ .find(|m| m.relative_path.to_string_lossy().replace('\\', "/").ends_with("src/main.js"))
616
+ .map(|m| m.js.clone());
617
+ assert!(
618
+ rels.iter().any(|r| r.ends_with("node_modules/pkg/index.js")),
619
+ "node_modules dep emitted: {:?}",
620
+ rels
621
+ );
622
+ let main_js = main_js.expect("entry module present");
623
+ assert!(
624
+ main_js.contains("from \"../node_modules/pkg/index.js\";"),
625
+ "bare specifier rewritten to relative .js path:\n{main_js}"
626
+ );
627
+ }
628
+
629
+ #[test]
630
+ fn esm_rejects_native_imports() {
631
+ let tmp = tempfile::tempdir().expect("tempdir");
632
+ let dir = tmp.path();
633
+ let p = dir.join("main.tish");
634
+ std::fs::write(&p, "import { readFile } from \"fs\"\nconsole.log(1)\n").unwrap();
635
+ let err = compile_project_esm(&p, Some(dir), false).unwrap_err();
636
+ assert!(
637
+ err.message.contains("Native module import") && err.message.contains("esm"),
638
+ "expected a native-import rejection for ESM, got: {}",
639
+ err.message
640
+ );
641
+ }
414
642
  }
@@ -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
+ }