@tishlang/tish 2.8.0 → 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 CHANGED
Binary file
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tishlang"
3
- version = "2.8.0"
3
+ version = "2.9.0"
4
4
  edition = "2021"
5
5
  description = "Tish CLI - run, REPL, compile to native"
6
6
  license-file = { workspace = true }
@@ -64,3 +64,4 @@ mimalloc = { version = "0.1", optional = true }
64
64
 
65
65
  [dev-dependencies]
66
66
  rayon = "1.11"
67
+ 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>,
@@ -136,6 +136,7 @@ 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
  }
141
142
  Some(Commands::DumpAst {
@@ -541,7 +542,14 @@ fn compile_to_js(
541
542
  output_path: &str,
542
543
  optimize: bool,
543
544
  source_map: bool,
545
+ format: &str,
544
546
  ) -> Result<(), String> {
547
+ if format != "bundle" && format != "esm" {
548
+ return Err(format!(
549
+ "Unknown --format '{}' for --target js. Use 'bundle' (single merged file) or 'esm' (one file per module).",
550
+ format
551
+ ));
552
+ }
545
553
  if source_map && optimize {
546
554
  return Err(
547
555
  "tish build --target js --source-map requires --no-optimize (mappings follow unmerged statement order)."
@@ -564,6 +572,9 @@ fn compile_to_js(
564
572
  Some(p)
565
573
  }
566
574
  });
575
+ if format == "esm" {
576
+ return compile_to_js_esm(input_path, output_path, optimize, source_map, project_root);
577
+ }
567
578
  let out_path = Path::new(output_path);
568
579
  let out_path = if out_path.extension().is_none()
569
580
  || out_path.extension() == Some(std::ffi::OsStr::new(""))
@@ -635,6 +646,63 @@ fn compile_to_js(
635
646
  Ok(())
636
647
  }
637
648
 
649
+ /// `--target js --format esm`: emit one `.js` per `.tish` module under the output directory,
650
+ /// preserving the source tree layout, with real ES `import`/`export` so a bundler (Vite/Rollup)
651
+ /// can tree-shake and code-split. `-o` is treated as a directory.
652
+ fn compile_to_js_esm(
653
+ input_path: &Path,
654
+ output_path: &str,
655
+ optimize: bool,
656
+ source_map: bool,
657
+ project_root: Option<&Path>,
658
+ ) -> Result<(), String> {
659
+ if source_map {
660
+ return Err(
661
+ "tish build --target js --format esm does not yet support --source-map; use --format bundle for source maps."
662
+ .into(),
663
+ );
664
+ }
665
+ if input_path.extension().map(|e| e == "jsx") == Some(true)
666
+ || input_path.extension().map(|e| e == "js") == Some(true)
667
+ {
668
+ return Err(
669
+ "tish build --target js --format esm is only supported for .tish entry files; use --format bundle for .jsx / .js inputs."
670
+ .into(),
671
+ );
672
+ }
673
+ let modules = tishlang_compile_js::compile_project_esm(input_path, project_root, optimize)
674
+ .map_err(|e| format!("{}", e))?;
675
+ let out_dir = Path::new(output_path);
676
+ fs::create_dir_all(out_dir)
677
+ .map_err(|e| format!("Cannot create output directory {}: {}", out_dir.display(), e))?;
678
+ for module in &modules {
679
+ let out = out_dir.join(&module.relative_path);
680
+ if let Some(parent) = out.parent() {
681
+ fs::create_dir_all(parent)
682
+ .map_err(|e| format!("Cannot create output directory {}: {}", parent.display(), e))?;
683
+ }
684
+ fs::write(&out, &module.js)
685
+ .map_err(|e| format!("Cannot write {}: {}", out.display(), e))?;
686
+ }
687
+ println!("Built {} module(s) to {}", modules.len(), out_dir.display());
688
+ // The entry may sit in a subtree (the output is rooted at the directory common to every module,
689
+ // which can be an ancestor of the entry when deps live in sibling packages / node_modules), so
690
+ // point the user at the actual entry `.js` they should hand to a bundler.
691
+ let entry_js = input_path
692
+ .canonicalize()
693
+ .unwrap_or_else(|_| input_path.to_path_buf())
694
+ .with_extension("js");
695
+ if let Some(entry_rel) = modules
696
+ .iter()
697
+ .filter(|m| entry_js.ends_with(&m.relative_path))
698
+ .max_by_key(|m| m.relative_path.components().count())
699
+ .map(|m| m.relative_path.clone())
700
+ {
701
+ println!("Entry: {}", out_dir.join(entry_rel).display());
702
+ }
703
+ Ok(())
704
+ }
705
+
638
706
  #[allow(clippy::vec_init_then_push, clippy::too_many_arguments)] // build_file maps CLI build flags 1:1
639
707
  fn build_file(
640
708
  input_path: &str,
@@ -646,6 +714,7 @@ fn build_file(
646
714
  source_map: bool,
647
715
  ios_triple: Option<&str>,
648
716
  crate_type: &str,
717
+ format: &str,
649
718
  ) -> Result<(), String> {
650
719
  let optimize = !no_optimize;
651
720
  let input_path = Path::new(input_path)
@@ -655,7 +724,7 @@ fn build_file(
655
724
  let is_js = input_path.extension().map(|e| e == "js") == Some(true);
656
725
 
657
726
  if target == "js" {
658
- return compile_to_js(&input_path, output_path, optimize, source_map);
727
+ return compile_to_js(&input_path, output_path, optimize, source_map, format);
659
728
  }
660
729
 
661
730
  // `wasm-gpu` (#277): same pipeline as `wasm` but builds the `--features gpu` WebGPU runtime and
@@ -673,6 +673,71 @@ 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
+
676
741
  /// Ignored: tishlang_eval::run() does not run the event loop.
677
742
  #[test]
678
743
  #[cfg(feature = "http")]