@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.
- package/bin/tish +0 -0
- package/crates/js_to_tish/src/transform/expr.rs +5 -1
- package/crates/tish/Cargo.toml +2 -1
- package/crates/tish/src/cli_help.rs +15 -0
- package/crates/tish/src/main.rs +139 -12
- package/crates/tish/tests/integration_test.rs +65 -0
- package/crates/tish_ast/src/ast.rs +6 -2
- package/crates/tish_builtins/src/array.rs +82 -1
- package/crates/tish_builtins/src/globals.rs +50 -16
- package/crates/tish_builtins/src/math.rs +63 -9
- package/crates/tish_builtins/src/number.rs +20 -1
- package/crates/tish_builtins/src/string.rs +68 -4
- package/crates/tish_builtins/src/typedarrays.rs +23 -16
- package/crates/tish_bytecode/src/compiler.rs +94 -28
- package/crates/tish_bytecode/tests/break_continue_bytecode.rs +19 -0
- package/crates/tish_compile/src/check.rs +1 -1
- package/crates/tish_compile/src/codegen.rs +3133 -582
- package/crates/tish_compile/src/infer.rs +1950 -56
- package/crates/tish_compile/src/lib.rs +38 -0
- package/crates/tish_compile/src/resolve.rs +3 -2
- package/crates/tish_compile/src/types.rs +17 -0
- package/crates/tish_compile_js/Cargo.toml +3 -0
- package/crates/tish_compile_js/src/codegen.rs +365 -9
- package/crates/tish_compile_js/src/lib.rs +2 -1
- package/crates/tish_compile_js/src/tests_jsx.rs +229 -1
- package/crates/tish_compiler_wasm/src/resolve_virtual.rs +1 -0
- package/crates/tish_core/Cargo.toml +2 -0
- package/crates/tish_core/src/json.rs +135 -34
- package/crates/tish_core/src/lib.rs +23 -1
- package/crates/tish_core/src/value.rs +64 -11
- package/crates/tish_eval/src/eval.rs +144 -197
- package/crates/tish_eval/src/natives.rs +45 -45
- package/crates/tish_eval/src/regex.rs +35 -27
- package/crates/tish_eval/src/value.rs +8 -0
- package/crates/tish_fmt/src/lib.rs +45 -1
- package/crates/tish_lint/src/bin/tish-lint.rs +17 -8
- package/crates/tish_lint/src/lib.rs +197 -21
- package/crates/tish_lsp/Cargo.toml +6 -0
- package/crates/tish_lsp/src/import_goto.rs +52 -7
- package/crates/tish_lsp/src/main.rs +856 -140
- package/crates/tish_opt/src/lib.rs +5 -3
- package/crates/tish_parser/src/lib.rs +23 -5
- package/crates/tish_parser/src/parser.rs +15 -26
- package/crates/tish_resolve/src/lib.rs +188 -18
- package/crates/tish_runtime/src/lib.rs +58 -18
- package/crates/tish_ui/src/jsx.rs +2 -2
- package/crates/tish_vm/src/jit.rs +212 -10
- package/crates/tish_vm/src/vm.rs +39 -18
- package/crates/tish_wasm/src/lib.rs +116 -22
- package/package.json +1 -1
- package/platform/darwin-arm64/tish +0 -0
- package/platform/darwin-x64/tish +0 -0
- package/platform/linux-arm64/tish +0 -0
- package/platform/linux-x64/tish +0 -0
- 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
|
}
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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
|
+
}
|