@tishlang/tish 2.8.0 → 2.10.1

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 CHANGED
Binary file
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tishlang"
3
- version = "2.8.0"
3
+ version = "2.10.1"
4
4
  edition = "2021"
5
5
  description = "Tish CLI - run, REPL, compile to native"
6
6
  license-file = { workspace = true }
@@ -59,8 +59,10 @@ tishlang_runtime = { path = "../tish_runtime", version = ">=0.1" }
59
59
  tishlang_core = { path = "../tish_core", version = ">=0.1" }
60
60
  tishlang_js_to_tish = { path = "../js_to_tish", version = ">=0.1" }
61
61
  clap = { version = "4.6.0", features = ["derive", "color"] }
62
+ serde_json = "1"
62
63
  tishlang_pg = { path = "../tish_pg", version = ">=0.1", optional = true }
63
64
  mimalloc = { version = "0.1", optional = true }
64
65
 
65
66
  [dev-dependencies]
66
67
  rayon = "1.11"
68
+ tempfile = "3"
@@ -560,6 +560,9 @@ pub(crate) struct BuildArgs {
560
560
  /// For `--target js` project builds: emit `OUTPUT.js.map` and `//# sourceMappingURL=…` so JS/TS tools can jump to original `.tish` (implies `--no-optimize` for that build).
561
561
  #[arg(long, help_heading = "Options")]
562
562
  pub source_map: bool,
563
+ /// For `--target js`: `bundle` (default) merges all modules into one file; `esm` emits one `.js` per `.tish` module with real `import`/`export` (so `-o` is a directory) for Vite/Rollup tree-shaking.
564
+ #[arg(long, default_value = "bundle", value_name = "FORMAT", help_heading = "Options")]
565
+ pub format: String,
563
566
  /// Run the gradual type checker: `warn` prints `line:col` type diagnostics; `error` also fails the build on them. (Equivalent to setting `TISH_CHECK`.)
564
567
  #[arg(long, value_name = "MODE", help_heading = "Options")]
565
568
  pub check: Option<String>,
@@ -568,6 +571,34 @@ pub(crate) struct BuildArgs {
568
571
  pub file: String,
569
572
  }
570
573
 
574
+ #[derive(Parser)]
575
+ pub(crate) struct CompileModuleArgs {
576
+ /// `--target js` only (the single-module ESM path is JS-specific).
577
+ #[arg(long, default_value = "js", value_name = "TARGET", help_heading = "Options")]
578
+ pub target: String,
579
+ /// `--format esm` only (one ES module per file).
580
+ #[arg(long, default_value = "esm", value_name = "FORMAT", help_heading = "Options")]
581
+ pub format: String,
582
+ /// Keep relative `.tish` specifiers and bare packages verbatim so Vite's resolveId/load re-enters the plugin per module (in-graph HMR). Omit for disk-style `.tish`->`.js` rewriting.
583
+ #[arg(long, help_heading = "Options")]
584
+ pub vite_dev: bool,
585
+ /// Emit a v3 source map back to the `.tish` source as a `{ "js", "map" }` JSON envelope on stdout (on by default; implies no optimization).
586
+ #[arg(long, help_heading = "Options")]
587
+ pub source_map: bool,
588
+ /// Disable the source map; print raw ES module JS to stdout instead of the JSON envelope.
589
+ #[arg(long = "no-source-map", help_heading = "Options")]
590
+ pub no_source_map: bool,
591
+ /// Project root for resolving bare specifiers / `node_modules` (defaults to the file's parent).
592
+ #[arg(long, value_name = "DIR", help_heading = "Options")]
593
+ pub project_root: Option<String>,
594
+ /// Disable AST optimizations (forced on when a source map is emitted).
595
+ #[arg(long, help_heading = "Options")]
596
+ pub no_optimize: bool,
597
+ /// Entry `.tish` file to compile (only this file is read; dependencies are left to the bundler).
598
+ #[arg(required = true, value_name = "FILE", help_heading = "Arguments")]
599
+ pub file: String,
600
+ }
601
+
571
602
  #[derive(Subcommand)]
572
603
  pub(crate) enum Commands {
573
604
  /// Run a Tish file (interpret)
@@ -576,6 +607,9 @@ pub(crate) enum Commands {
576
607
  Repl(ReplArgs),
577
608
  /// Build native binary, wasm, wasi, or JavaScript output
578
609
  Build(BuildArgs),
610
+ /// Compile a single `.tish` module to one ES module (for Vite dev / HMR); prints to stdout
611
+ #[command(name = "compile-module")]
612
+ CompileModule(CompileModuleArgs),
579
613
  /// Parse and dump AST
580
614
  #[command(name = "dump-ast")]
581
615
  DumpAst {
@@ -22,7 +22,7 @@ use tishlang_core::VmRef;
22
22
  use clap::FromArgMatches;
23
23
  use rustyline::{Behavior, ColorMode, CompletionType, Config, Editor};
24
24
 
25
- use cli_help::{Cli, Commands};
25
+ use cli_help::{Cli, Commands, CompileModuleArgs};
26
26
 
27
27
  /// Normalize `--feature` / `--feature http,timers,fs` / `--feature full` for VM runs and native builds.
28
28
  fn normalize_capability_flags(features: &[String]) -> HashSet<String> {
@@ -73,7 +73,7 @@ fn native_build_features_from_cli(cli_features: &[String]) -> Vec<String> {
73
73
  fn argv_with_implicit_run(mut argv: Vec<String>) -> Vec<String> {
74
74
  if argv.len() >= 2 {
75
75
  let first = argv[1].as_str();
76
- const SUBCOMMANDS: &[&str] = &["run", "repl", "build", "dump-ast"];
76
+ const SUBCOMMANDS: &[&str] = &["run", "repl", "build", "compile-module", "dump-ast"];
77
77
  let looks_like_file = !first.starts_with('-') && !SUBCOMMANDS.contains(&first);
78
78
  if looks_like_file {
79
79
  argv.insert(1, "run".to_string());
@@ -136,8 +136,10 @@ fn main() {
136
136
  a.source_map,
137
137
  a.ios_triple.as_deref(),
138
138
  &a.crate_type,
139
+ &a.format,
139
140
  )
140
141
  }
142
+ Some(Commands::CompileModule(a)) => compile_module(&a),
141
143
  Some(Commands::DumpAst {
142
144
  file,
143
145
  ignore_indent,
@@ -541,7 +543,14 @@ fn compile_to_js(
541
543
  output_path: &str,
542
544
  optimize: bool,
543
545
  source_map: bool,
546
+ format: &str,
544
547
  ) -> Result<(), String> {
548
+ if format != "bundle" && format != "esm" {
549
+ return Err(format!(
550
+ "Unknown --format '{}' for --target js. Use 'bundle' (single merged file) or 'esm' (one file per module).",
551
+ format
552
+ ));
553
+ }
545
554
  if source_map && optimize {
546
555
  return Err(
547
556
  "tish build --target js --source-map requires --no-optimize (mappings follow unmerged statement order)."
@@ -564,6 +573,9 @@ fn compile_to_js(
564
573
  Some(p)
565
574
  }
566
575
  });
576
+ if format == "esm" {
577
+ return compile_to_js_esm(input_path, output_path, optimize, source_map, project_root);
578
+ }
567
579
  let out_path = Path::new(output_path);
568
580
  let out_path = if out_path.extension().is_none()
569
581
  || out_path.extension() == Some(std::ffi::OsStr::new(""))
@@ -635,6 +647,128 @@ fn compile_to_js(
635
647
  Ok(())
636
648
  }
637
649
 
650
+ /// `--target js --format esm`: emit one `.js` per `.tish` module under the output directory,
651
+ /// preserving the source tree layout, with real ES `import`/`export` so a bundler (Vite/Rollup)
652
+ /// can tree-shake and code-split. `-o` is treated as a directory.
653
+ fn compile_to_js_esm(
654
+ input_path: &Path,
655
+ output_path: &str,
656
+ optimize: bool,
657
+ source_map: bool,
658
+ project_root: Option<&Path>,
659
+ ) -> Result<(), String> {
660
+ if source_map {
661
+ return Err(
662
+ "tish build --target js --format esm does not yet support --source-map; use --format bundle for source maps."
663
+ .into(),
664
+ );
665
+ }
666
+ if input_path.extension().map(|e| e == "jsx") == Some(true)
667
+ || input_path.extension().map(|e| e == "js") == Some(true)
668
+ {
669
+ return Err(
670
+ "tish build --target js --format esm is only supported for .tish entry files; use --format bundle for .jsx / .js inputs."
671
+ .into(),
672
+ );
673
+ }
674
+ let modules = tishlang_compile_js::compile_project_esm(input_path, project_root, optimize)
675
+ .map_err(|e| format!("{}", e))?;
676
+ let out_dir = Path::new(output_path);
677
+ fs::create_dir_all(out_dir)
678
+ .map_err(|e| format!("Cannot create output directory {}: {}", out_dir.display(), e))?;
679
+ for module in &modules {
680
+ let out = out_dir.join(&module.relative_path);
681
+ if let Some(parent) = out.parent() {
682
+ fs::create_dir_all(parent)
683
+ .map_err(|e| format!("Cannot create output directory {}: {}", parent.display(), e))?;
684
+ }
685
+ fs::write(&out, &module.js)
686
+ .map_err(|e| format!("Cannot write {}: {}", out.display(), e))?;
687
+ }
688
+ println!("Built {} module(s) to {}", modules.len(), out_dir.display());
689
+ // The entry may sit in a subtree (the output is rooted at the directory common to every module,
690
+ // which can be an ancestor of the entry when deps live in sibling packages / node_modules), so
691
+ // point the user at the actual entry `.js` they should hand to a bundler.
692
+ let entry_js = input_path
693
+ .canonicalize()
694
+ .unwrap_or_else(|_| input_path.to_path_buf())
695
+ .with_extension("js");
696
+ if let Some(entry_rel) = modules
697
+ .iter()
698
+ .filter(|m| entry_js.ends_with(&m.relative_path))
699
+ .max_by_key(|m| m.relative_path.components().count())
700
+ .map(|m| m.relative_path.clone())
701
+ {
702
+ println!("Entry: {}", out_dir.join(entry_rel).display());
703
+ }
704
+ Ok(())
705
+ }
706
+
707
+ /// `tish compile-module FILE` (#284): compile a single `.tish` file to one ES module and print it
708
+ /// to stdout. This is the fast, in-graph path the Vite plugin shells out to in `load()` — only the
709
+ /// one file is read (no dependency graph resolution), so Vite owns the module graph and can HMR a
710
+ /// leaf module without a full reload.
711
+ ///
712
+ /// With a source map (default), stdout is a JSON envelope `{"js":…,"map":…}` so the plugin gets
713
+ /// both artifacts from one spawn; with `--no-source-map`, stdout is raw ES module JS.
714
+ fn compile_module(args: &CompileModuleArgs) -> Result<(), String> {
715
+ if args.target != "js" {
716
+ return Err(format!(
717
+ "tish compile-module only supports --target js (got '{}').",
718
+ args.target
719
+ ));
720
+ }
721
+ if args.format != "esm" {
722
+ return Err(format!(
723
+ "tish compile-module only supports --format esm (got '{}').",
724
+ args.format
725
+ ));
726
+ }
727
+ let input_path = Path::new(&args.file)
728
+ .canonicalize()
729
+ .map_err(|e| format!("Cannot resolve {}: {}", args.file, e))?;
730
+ if input_path.extension().map(|e| e != "tish").unwrap_or(true) {
731
+ return Err(format!(
732
+ "tish compile-module expects a .tish file (got '{}').",
733
+ args.file
734
+ ));
735
+ }
736
+ let want_map = !args.no_source_map;
737
+ // A source map needs unmerged statement order, so it forces no optimization (same rule as
738
+ // `build --target js --source-map`).
739
+ let optimize = !args.no_optimize && !want_map;
740
+ let import_rewrite = if args.vite_dev {
741
+ tishlang_compile_js::ImportRewrite::ViteDev
742
+ } else {
743
+ tishlang_compile_js::ImportRewrite::Disk
744
+ };
745
+ let project_root = args
746
+ .project_root
747
+ .as_ref()
748
+ .map(PathBuf::from)
749
+ .or_else(|| input_path.parent().map(Path::to_path_buf));
750
+ let bundle = tishlang_compile_js::compile_module_esm(
751
+ &input_path,
752
+ project_root.as_deref(),
753
+ optimize,
754
+ import_rewrite,
755
+ want_map,
756
+ )
757
+ .map_err(|e| format!("{}", e))?;
758
+
759
+ if let Some(map_json) = bundle.source_map_json {
760
+ let map: serde_json::Value = serde_json::from_str(&map_json)
761
+ .map_err(|e| format!("Internal: source map is not valid JSON: {}", e))?;
762
+ let envelope = serde_json::json!({ "js": bundle.js, "map": map });
763
+ let line = serde_json::to_string(&envelope)
764
+ .map_err(|e| format!("Cannot serialize compile-module output: {}", e))?;
765
+ println!("{}", line);
766
+ } else {
767
+ print!("{}", bundle.js);
768
+ }
769
+ Ok(())
770
+ }
771
+
638
772
  #[allow(clippy::vec_init_then_push, clippy::too_many_arguments)] // build_file maps CLI build flags 1:1
639
773
  fn build_file(
640
774
  input_path: &str,
@@ -646,6 +780,7 @@ fn build_file(
646
780
  source_map: bool,
647
781
  ios_triple: Option<&str>,
648
782
  crate_type: &str,
783
+ format: &str,
649
784
  ) -> Result<(), String> {
650
785
  let optimize = !no_optimize;
651
786
  let input_path = Path::new(input_path)
@@ -655,7 +790,7 @@ fn build_file(
655
790
  let is_js = input_path.extension().map(|e| e == "js") == Some(true);
656
791
 
657
792
  if target == "js" {
658
- return compile_to_js(&input_path, output_path, optimize, source_map);
793
+ return compile_to_js(&input_path, output_path, optimize, source_map, format);
659
794
  }
660
795
 
661
796
  // `wasm-gpu` (#277): same pipeline as `wasm` but builds the `--features gpu` WebGPU runtime and
@@ -673,6 +673,179 @@ fn test_module_private_binding_isolation() {
673
673
  }
674
674
  }
675
675
 
676
+ /// #282: `--target js --format esm` emits one ES module per `.tish` file with real
677
+ /// `import`/`export`, so two modules exporting the same name (`export fn activate`) keep separate
678
+ /// scopes instead of colliding. The merge-based pipeline (interp/VM/native and `--format bundle`)
679
+ /// still flattens both into one scope — a separately-tracked collision — so this test pins the ESM
680
+ /// JS target, which is the path that isolates them. Builds the module tree and runs it under Node.
681
+ #[test]
682
+ fn test_js_esm_export_collision() {
683
+ let bin = tish_bin();
684
+ if !bin.exists() {
685
+ return;
686
+ }
687
+ let main = workspace_root()
688
+ .join("tests")
689
+ .join("modules")
690
+ .join("esm")
691
+ .join("export_collision")
692
+ .join("main.tish");
693
+ if !main.exists() {
694
+ return;
695
+ }
696
+ let expected = "ab\n";
697
+
698
+ // JS ESM target: build the module tree, then load + run the entry under Node.
699
+ let node_available = Command::new("node")
700
+ .arg("--version")
701
+ .output()
702
+ .map(|o| o.status.success())
703
+ .unwrap_or(false);
704
+ if node_available {
705
+ let tmp = tempfile::tempdir().expect("tempdir");
706
+ let out_dir = tmp.path().join("esm_out");
707
+ let build = Command::new(&bin)
708
+ .args(["build"])
709
+ .arg(&main)
710
+ .args(["-o"])
711
+ .arg(&out_dir)
712
+ .args(["--target", "js", "--format", "esm"])
713
+ .current_dir(workspace_root())
714
+ .output()
715
+ .expect("build esm");
716
+ assert!(
717
+ build.status.success(),
718
+ "esm build failed: stderr={}",
719
+ String::from_utf8_lossy(&build.stderr)
720
+ );
721
+ let entry = out_dir.join("main.js");
722
+ assert!(entry.exists(), "esm entry {} not emitted", entry.display());
723
+ let out = Command::new("node")
724
+ .arg(&entry)
725
+ .current_dir(workspace_root())
726
+ .output()
727
+ .expect("run node");
728
+ assert!(
729
+ out.status.success(),
730
+ "esm JS run failed (collision not isolated?): stderr={}",
731
+ String::from_utf8_lossy(&out.stderr)
732
+ );
733
+ assert_eq!(
734
+ String::from_utf8_lossy(&out.stdout),
735
+ expected,
736
+ "esm export_collision mismatch on JS ESM target"
737
+ );
738
+ }
739
+ }
740
+
741
+ /// #284: `tish compile-module --vite-dev --source-map` compiles a single `.tish` file (no graph
742
+ /// resolution) to one ES module, keeps relative `.tish` specifiers, and emits a `{ js, map }` JSON
743
+ /// envelope whose map references the `.tish` source. This is the in-graph path the Vite plugin uses.
744
+ #[test]
745
+ fn test_compile_module_vite_dev_with_source_map() {
746
+ let bin = tish_bin();
747
+ if !bin.exists() {
748
+ return;
749
+ }
750
+ let main = workspace_root()
751
+ .join("tests")
752
+ .join("modules")
753
+ .join("esm")
754
+ .join("vite_hmr")
755
+ .join("main.tish");
756
+ if !main.exists() {
757
+ return;
758
+ }
759
+ let out = Command::new(&bin)
760
+ .args(["compile-module"])
761
+ .arg(&main)
762
+ .args([
763
+ "--target",
764
+ "js",
765
+ "--format",
766
+ "esm",
767
+ "--vite-dev",
768
+ "--source-map",
769
+ ])
770
+ .current_dir(workspace_root())
771
+ .output()
772
+ .expect("run compile-module");
773
+ assert!(
774
+ out.status.success(),
775
+ "compile-module failed: stderr={}",
776
+ String::from_utf8_lossy(&out.stderr)
777
+ );
778
+ let stdout = String::from_utf8_lossy(&out.stdout);
779
+ let parsed: serde_json::Value =
780
+ serde_json::from_str(&stdout).unwrap_or_else(|e| panic!("stdout is not JSON: {e}\n{stdout}"));
781
+ let js = parsed["js"].as_str().expect("envelope has string `js`");
782
+ assert!(
783
+ js.contains("import { makeCounter } from \"./counter.tish\";"),
784
+ "Vite-dev keeps the .tish specifier:\n{js}"
785
+ );
786
+ assert!(
787
+ !js.contains("./counter.js"),
788
+ "Vite-dev must not rewrite .tish to .js:\n{js}"
789
+ );
790
+ let map = &parsed["map"];
791
+ assert!(map.is_object(), "envelope has object `map`: {map}");
792
+ assert_eq!(map["version"], serde_json::json!(3), "v3 source map");
793
+ let sources = map["sources"].as_array().expect("map has sources");
794
+ assert!(
795
+ sources
796
+ .iter()
797
+ .any(|s| s.as_str().map(|s| s.contains("main.tish")).unwrap_or(false)),
798
+ "map sources reference the .tish file: {sources:?}"
799
+ );
800
+ }
801
+
802
+ /// #284: without `--source-map`, `compile-module` prints raw ES module JS to stdout (no envelope),
803
+ /// so the output can be consumed directly.
804
+ #[test]
805
+ fn test_compile_module_no_source_map_prints_raw_js() {
806
+ let bin = tish_bin();
807
+ if !bin.exists() {
808
+ return;
809
+ }
810
+ let counter = workspace_root()
811
+ .join("tests")
812
+ .join("modules")
813
+ .join("esm")
814
+ .join("vite_hmr")
815
+ .join("counter.tish");
816
+ if !counter.exists() {
817
+ return;
818
+ }
819
+ let out = Command::new(&bin)
820
+ .args(["compile-module"])
821
+ .arg(&counter)
822
+ .args([
823
+ "--target",
824
+ "js",
825
+ "--format",
826
+ "esm",
827
+ "--vite-dev",
828
+ "--no-source-map",
829
+ ])
830
+ .current_dir(workspace_root())
831
+ .output()
832
+ .expect("run compile-module");
833
+ assert!(
834
+ out.status.success(),
835
+ "compile-module failed: stderr={}",
836
+ String::from_utf8_lossy(&out.stderr)
837
+ );
838
+ let stdout = String::from_utf8_lossy(&out.stdout);
839
+ assert!(
840
+ stdout.contains("export function makeCounter"),
841
+ "raw JS output includes the export:\n{stdout}"
842
+ );
843
+ assert!(
844
+ !stdout.trim_start().starts_with('{'),
845
+ "no-source-map output is raw JS, not a JSON envelope:\n{stdout}"
846
+ );
847
+ }
848
+
676
849
  /// Ignored: tishlang_eval::run() does not run the event loop.
677
850
  #[test]
678
851
  #[cfg(feature = "http")]