@tishlang/tish 2.10.1 → 2.12.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.10.1"
3
+ version = "2.12.0"
4
4
  edition = "2021"
5
5
  description = "Tish CLI - run, REPL, compile to native"
6
6
  license-file = { workspace = true }
@@ -563,6 +563,14 @@ pub(crate) struct BuildArgs {
563
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
564
  #[arg(long, default_value = "bundle", value_name = "FORMAT", help_heading = "Options")]
565
565
  pub format: String,
566
+ /// For `--target js --format esm`: package the JSX runtime (`h`/`Fragment`) is auto-imported from when a module uses JSX without importing them (default `lattish`).
567
+ #[arg(
568
+ long = "jsx-import-source",
569
+ default_value = "lattish",
570
+ value_name = "PKG",
571
+ help_heading = "Options"
572
+ )]
573
+ pub jsx_import_source: String,
566
574
  /// Run the gradual type checker: `warn` prints `line:col` type diagnostics; `error` also fails the build on them. (Equivalent to setting `TISH_CHECK`.)
567
575
  #[arg(long, value_name = "MODE", help_heading = "Options")]
568
576
  pub check: Option<String>,
@@ -594,6 +602,14 @@ pub(crate) struct CompileModuleArgs {
594
602
  /// Disable AST optimizations (forced on when a source map is emitted).
595
603
  #[arg(long, help_heading = "Options")]
596
604
  pub no_optimize: bool,
605
+ /// Package the JSX runtime (`h`/`Fragment`) is auto-imported from when a module uses JSX without importing them (default `lattish`).
606
+ #[arg(
607
+ long = "jsx-import-source",
608
+ default_value = "lattish",
609
+ value_name = "PKG",
610
+ help_heading = "Options"
611
+ )]
612
+ pub jsx_import_source: String,
597
613
  /// Entry `.tish` file to compile (only this file is read; dependencies are left to the bundler).
598
614
  #[arg(required = true, value_name = "FILE", help_heading = "Arguments")]
599
615
  pub file: String,
@@ -137,6 +137,7 @@ fn main() {
137
137
  a.ios_triple.as_deref(),
138
138
  &a.crate_type,
139
139
  &a.format,
140
+ &a.jsx_import_source,
140
141
  )
141
142
  }
142
143
  Some(Commands::CompileModule(a)) => compile_module(&a),
@@ -544,6 +545,7 @@ fn compile_to_js(
544
545
  optimize: bool,
545
546
  source_map: bool,
546
547
  format: &str,
548
+ jsx_import_source: &str,
547
549
  ) -> Result<(), String> {
548
550
  if format != "bundle" && format != "esm" {
549
551
  return Err(format!(
@@ -574,7 +576,14 @@ fn compile_to_js(
574
576
  }
575
577
  });
576
578
  if format == "esm" {
577
- return compile_to_js_esm(input_path, output_path, optimize, source_map, project_root);
579
+ return compile_to_js_esm(
580
+ input_path,
581
+ output_path,
582
+ optimize,
583
+ source_map,
584
+ project_root,
585
+ jsx_import_source,
586
+ );
578
587
  }
579
588
  let out_path = Path::new(output_path);
580
589
  let out_path = if out_path.extension().is_none()
@@ -656,6 +665,7 @@ fn compile_to_js_esm(
656
665
  optimize: bool,
657
666
  source_map: bool,
658
667
  project_root: Option<&Path>,
668
+ jsx_import_source: &str,
659
669
  ) -> Result<(), String> {
660
670
  if source_map {
661
671
  return Err(
@@ -671,8 +681,9 @@ fn compile_to_js_esm(
671
681
  .into(),
672
682
  );
673
683
  }
674
- let modules = tishlang_compile_js::compile_project_esm(input_path, project_root, optimize)
675
- .map_err(|e| format!("{}", e))?;
684
+ let modules =
685
+ tishlang_compile_js::compile_project_esm(input_path, project_root, optimize, jsx_import_source)
686
+ .map_err(|e| format!("{}", e))?;
676
687
  let out_dir = Path::new(output_path);
677
688
  fs::create_dir_all(out_dir)
678
689
  .map_err(|e| format!("Cannot create output directory {}: {}", out_dir.display(), e))?;
@@ -753,6 +764,7 @@ fn compile_module(args: &CompileModuleArgs) -> Result<(), String> {
753
764
  optimize,
754
765
  import_rewrite,
755
766
  want_map,
767
+ &args.jsx_import_source,
756
768
  )
757
769
  .map_err(|e| format!("{}", e))?;
758
770
 
@@ -781,6 +793,7 @@ fn build_file(
781
793
  ios_triple: Option<&str>,
782
794
  crate_type: &str,
783
795
  format: &str,
796
+ jsx_import_source: &str,
784
797
  ) -> Result<(), String> {
785
798
  let optimize = !no_optimize;
786
799
  let input_path = Path::new(input_path)
@@ -790,7 +803,14 @@ fn build_file(
790
803
  let is_js = input_path.extension().map(|e| e == "js") == Some(true);
791
804
 
792
805
  if target == "js" {
793
- return compile_to_js(&input_path, output_path, optimize, source_map, format);
806
+ return compile_to_js(
807
+ &input_path,
808
+ output_path,
809
+ optimize,
810
+ source_map,
811
+ format,
812
+ jsx_import_source,
813
+ );
794
814
  }
795
815
 
796
816
  // `wasm-gpu` (#277): same pipeline as `wasm` but builds the `--features gpu` WebGPU runtime and
@@ -846,6 +846,75 @@ fn test_compile_module_no_source_map_prints_raw_js() {
846
846
  );
847
847
  }
848
848
 
849
+ /// #291: `compile-module` auto-imports the JSX runtime (`h` / `Fragment`) when a module uses JSX
850
+ /// but doesn't import them itself, so per-module ESM output doesn't throw `ReferenceError` at load.
851
+ /// `--jsx-import-source` overrides the runtime package.
852
+ #[test]
853
+ fn test_compile_module_jsx_auto_imports_runtime() {
854
+ let bin = tish_bin();
855
+ if !bin.exists() {
856
+ return;
857
+ }
858
+ let widget = workspace_root()
859
+ .join("tests")
860
+ .join("modules")
861
+ .join("esm")
862
+ .join("vite_hmr")
863
+ .join("widget.tish");
864
+ if !widget.exists() {
865
+ return;
866
+ }
867
+ // Default runtime source (`lattish`), Vite-dev so the specifier stays bare.
868
+ let out = Command::new(&bin)
869
+ .args(["compile-module"])
870
+ .arg(&widget)
871
+ .args([
872
+ "--target",
873
+ "js",
874
+ "--format",
875
+ "esm",
876
+ "--vite-dev",
877
+ "--no-source-map",
878
+ ])
879
+ .current_dir(workspace_root())
880
+ .output()
881
+ .expect("run compile-module");
882
+ assert!(
883
+ out.status.success(),
884
+ "compile-module failed: stderr={}",
885
+ String::from_utf8_lossy(&out.stderr)
886
+ );
887
+ let stdout = String::from_utf8_lossy(&out.stdout);
888
+ assert!(
889
+ stdout.contains("import { h, Fragment } from \"lattish\";"),
890
+ "JSX module with a fragment auto-imports both h and Fragment:\n{stdout}"
891
+ );
892
+
893
+ // Custom runtime source via --jsx-import-source.
894
+ let out2 = Command::new(&bin)
895
+ .args(["compile-module"])
896
+ .arg(&widget)
897
+ .args([
898
+ "--target",
899
+ "js",
900
+ "--format",
901
+ "esm",
902
+ "--vite-dev",
903
+ "--no-source-map",
904
+ "--jsx-import-source",
905
+ "@tishlang/lattish",
906
+ ])
907
+ .current_dir(workspace_root())
908
+ .output()
909
+ .expect("run compile-module");
910
+ assert!(out2.status.success());
911
+ let stdout2 = String::from_utf8_lossy(&out2.stdout);
912
+ assert!(
913
+ stdout2.contains("from \"@tishlang/lattish\";"),
914
+ "--jsx-import-source overrides the runtime package:\n{stdout2}"
915
+ );
916
+ }
917
+
849
918
  /// Ignored: tishlang_eval::run() does not run the event loop.
850
919
  #[test]
851
920
  #[cfg(feature = "http")]
@@ -11,6 +11,10 @@ use tishlang_ast::{
11
11
 
12
12
  use crate::error::CompileError;
13
13
 
14
+ /// Default module specifier the JSX runtime (`h` / `Fragment`) is auto-imported from (issue #291).
15
+ /// Matches the repo convention (`import { h } from "lattish"`); overridable via `--jsx-import-source`.
16
+ pub const DEFAULT_JSX_IMPORT_SOURCE: &str = "lattish";
17
+
14
18
  /// JS output mode. `Bundle` flattens every module into one file (imports/exports are resolved away
15
19
  /// by `merge_modules`). `Esm` emits one file per module with real ES `import`/`export` statements so
16
20
  /// a bundler (Vite/Rollup) can tree-shake and code-split. See issue #177's sibling, #282.
@@ -42,6 +46,9 @@ struct Codegen {
42
46
  module_path: PathBuf,
43
47
  /// ESM only: the project root, so import targets can be located relative to the output tree.
44
48
  project_root: PathBuf,
49
+ /// ESM only: the module specifier the JSX runtime (`h` / `Fragment`) is auto-imported from when a
50
+ /// module uses JSX but doesn't import them itself (issue #291). Defaults to `lattish`.
51
+ jsx_import_source: String,
45
52
  }
46
53
 
47
54
  fn stmt_terminates_switch(stmt: Option<&Statement>) -> bool {
@@ -63,10 +70,16 @@ impl Codegen {
63
70
  import_rewrite: ImportRewrite::Disk,
64
71
  module_path: PathBuf::new(),
65
72
  project_root: PathBuf::new(),
73
+ jsx_import_source: DEFAULT_JSX_IMPORT_SOURCE.to_string(),
66
74
  }
67
75
  }
68
76
 
69
- fn new_esm(module_path: PathBuf, project_root: PathBuf, import_rewrite: ImportRewrite) -> Self {
77
+ fn new_esm(
78
+ module_path: PathBuf,
79
+ project_root: PathBuf,
80
+ import_rewrite: ImportRewrite,
81
+ jsx_import_source: String,
82
+ ) -> Self {
70
83
  Self {
71
84
  output: String::new(),
72
85
  indent: 0,
@@ -75,6 +88,7 @@ impl Codegen {
75
88
  import_rewrite,
76
89
  module_path,
77
90
  project_root,
91
+ jsx_import_source,
78
92
  }
79
93
  }
80
94
 
@@ -139,6 +153,7 @@ impl Codegen {
139
153
  map_builder: Option<&mut SourceMapBuilder>,
140
154
  ) -> Result<(), CompileError> {
141
155
  self.write("// Generated by tishlang_compile_js\n");
156
+ self.emit_jsx_runtime_auto_import(program)?;
142
157
  match (map_sources, map_builder) {
143
158
  (Some((srcs, root)), Some(sm)) => {
144
159
  for (i, stmt) in program.statements.iter().enumerate() {
@@ -176,6 +191,34 @@ impl Codegen {
176
191
  Ok(())
177
192
  }
178
193
 
194
+ /// Auto-import the JSX runtime (`h` / `Fragment`) for ESM modules that use JSX but don't bring
195
+ /// those bindings into scope themselves (issue #291). In `--format bundle` the merged scope makes
196
+ /// a single `lattish` import visible everywhere, but each ES module is its own scope, so a JSX
197
+ /// module that didn't `import { h }` throws `ReferenceError` at load. We inject only the missing
198
+ /// names (so an explicit `import { h }` isn't duplicated) from `jsx_import_source`, rewritten the
199
+ /// same way as user imports (disk `.js` path vs. Vite-dev bare specifier). No-op outside ESM.
200
+ fn emit_jsx_runtime_auto_import(&mut self, program: &Program) -> Result<(), CompileError> {
201
+ if self.emit_mode != EmitMode::Esm {
202
+ return Ok(());
203
+ }
204
+ let needed = tishlang_ui::jsx::jsx_runtime_imports_needed(program);
205
+ if needed.is_empty() {
206
+ return Ok(());
207
+ }
208
+ let spec = rewrite_import_to_js(
209
+ &self.jsx_import_source,
210
+ &self.module_path,
211
+ &self.project_root,
212
+ self.import_rewrite,
213
+ )?;
214
+ self.writeln(&format!(
215
+ "import {{ {} }} from \"{}\";",
216
+ needed.join(", "),
217
+ spec
218
+ ));
219
+ Ok(())
220
+ }
221
+
179
222
  fn emit_statement(&mut self, stmt: &Statement) -> Result<(), CompileError> {
180
223
  match stmt {
181
224
  Statement::Block { statements, .. } => {
@@ -1235,6 +1278,7 @@ pub fn compile_project_esm(
1235
1278
  entry_path: &Path,
1236
1279
  project_root: Option<&Path>,
1237
1280
  optimize: bool,
1281
+ jsx_import_source: &str,
1238
1282
  ) -> Result<Vec<EmittedJsModule>, CompileError> {
1239
1283
  let modules = tishlang_compile::resolve_project(entry_path, project_root)
1240
1284
  .map_err(|e| CompileError { message: e })?;
@@ -1274,8 +1318,12 @@ pub fn compile_project_esm(
1274
1318
  } else {
1275
1319
  module.program.clone()
1276
1320
  };
1277
- let mut gen =
1278
- Codegen::new_esm(mod_canon.clone(), res_root_canon.clone(), ImportRewrite::Disk);
1321
+ let mut gen = Codegen::new_esm(
1322
+ mod_canon.clone(),
1323
+ res_root_canon.clone(),
1324
+ ImportRewrite::Disk,
1325
+ jsx_import_source.to_string(),
1326
+ );
1279
1327
  gen.emit_program(&program, None, None)?;
1280
1328
  out.push(EmittedJsModule {
1281
1329
  relative_path: rel_js,
@@ -1297,6 +1345,7 @@ pub fn compile_module_esm(
1297
1345
  optimize: bool,
1298
1346
  import_rewrite: ImportRewrite,
1299
1347
  source_map: bool,
1348
+ jsx_import_source: &str,
1300
1349
  ) -> Result<JsBundle, CompileError> {
1301
1350
  if source_map && optimize {
1302
1351
  return Err(CompileError {
@@ -1324,7 +1373,12 @@ pub fn compile_module_esm(
1324
1373
  .unwrap_or_else(|| PathBuf::from("."));
1325
1374
  let root_canon = root.canonicalize().unwrap_or(root);
1326
1375
 
1327
- let mut gen = Codegen::new_esm(module_canon.clone(), root_canon.clone(), import_rewrite);
1376
+ let mut gen = Codegen::new_esm(
1377
+ module_canon.clone(),
1378
+ root_canon.clone(),
1379
+ import_rewrite,
1380
+ jsx_import_source.to_string(),
1381
+ );
1328
1382
  if source_map {
1329
1383
  let stmt_sources = vec![module_canon.clone(); program.statements.len()];
1330
1384
  let mut builder = SourceMapBuilder::new(Some("module.js"));
@@ -10,11 +10,14 @@ mod tests_jsx;
10
10
  pub use codegen::{
11
11
  compile_module_esm, compile_project_esm, compile_project_with_jsx,
12
12
  compile_project_with_jsx_and_source_map, compile_with_jsx, EmittedJsModule, ImportRewrite,
13
- JsBundle,
13
+ JsBundle, DEFAULT_JSX_IMPORT_SOURCE,
14
14
  };
15
15
  pub use error::CompileError;
16
16
 
17
- /// JSX lowers to `h` / `Fragment`; merge the `lattish` runtime for hooks and DOM.
17
+ /// Compile a single program to a bundle-style JS string. JSX lowers to `h` / `Fragment`; in bundle
18
+ /// mode those resolve against the merged scope (merge the `lattish` runtime for hooks and DOM). The
19
+ /// per-module ESM paths ([`compile_module_esm`] / [`compile_project_esm`]) auto-import the runtime
20
+ /// for JSX modules that don't import it themselves (issue #291).
18
21
  pub fn compile(program: &tishlang_ast::Program, optimize: bool) -> Result<String, CompileError> {
19
22
  compile_with_jsx(program, optimize)
20
23
  }
@@ -475,7 +475,8 @@ fn factory() {
475
475
  f.write_all(src.as_bytes()).unwrap();
476
476
  f.sync_all().unwrap();
477
477
  }
478
- compile_project_esm(&dir.join(entry), Some(dir), false).expect("compile_project_esm failed")
478
+ compile_project_esm(&dir.join(entry), Some(dir), false, "lattish")
479
+ .expect("compile_project_esm failed")
479
480
  }
480
481
 
481
482
  fn module_js<'a>(mods: &'a [EmittedJsModule], rel: &str) -> &'a str {
@@ -557,6 +558,7 @@ fn factory() {
557
558
  &base.join("app/src/main.tish"),
558
559
  Some(&base.join("app")),
559
560
  false,
561
+ "lattish",
560
562
  )
561
563
  .expect("compile_project_esm failed for sibling dep");
562
564
  let rels: Vec<String> = mods
@@ -607,7 +609,7 @@ fn factory() {
607
609
  "import { ping } from \"pkg\"\nconsole.log(ping())\n",
608
610
  )
609
611
  .unwrap();
610
- let mods = compile_project_esm(&base.join("src/main.tish"), Some(base), false)
612
+ let mods = compile_project_esm(&base.join("src/main.tish"), Some(base), false, "lattish")
611
613
  .expect("compile_project_esm failed for node_modules dep");
612
614
  let rels: Vec<String> = mods
613
615
  .iter()
@@ -635,7 +637,7 @@ fn factory() {
635
637
  let dir = tmp.path();
636
638
  let p = dir.join("main.tish");
637
639
  std::fs::write(&p, "import { readFile } from \"fs\"\nconsole.log(1)\n").unwrap();
638
- let err = compile_project_esm(&p, Some(dir), false).unwrap_err();
640
+ let err = compile_project_esm(&p, Some(dir), false, "lattish").unwrap_err();
639
641
  assert!(
640
642
  err.message.contains("Native module import") && err.message.contains("esm"),
641
643
  "expected a native-import rejection for ESM, got: {}",
@@ -665,6 +667,7 @@ fn factory() {
665
667
  false,
666
668
  ImportRewrite::ViteDev,
667
669
  false,
670
+ "lattish",
668
671
  )
669
672
  .expect("compile_module_esm failed")
670
673
  .js
@@ -741,7 +744,7 @@ fn factory() {
741
744
  let dir = tmp.path();
742
745
  let p = dir.join("main.tish");
743
746
  std::fs::write(&p, "import { readFile } from \"fs\"\nconsole.log(1)\n").unwrap();
744
- let err = compile_module_esm(&p, Some(dir), false, ImportRewrite::ViteDev, false)
747
+ let err = compile_module_esm(&p, Some(dir), false, ImportRewrite::ViteDev, false, "lattish")
745
748
  .unwrap_err();
746
749
  assert!(
747
750
  err.message.contains("Native module import"),
@@ -758,7 +761,8 @@ fn factory() {
758
761
  std::fs::write(&p, "export fn greet(n) { return \"hi \" + n }\nconsole.log(greet(\"x\"))\n")
759
762
  .unwrap();
760
763
  let bundle =
761
- compile_module_esm(&p, Some(dir), false, ImportRewrite::ViteDev, true).unwrap();
764
+ compile_module_esm(&p, Some(dir), false, ImportRewrite::ViteDev, true, "lattish")
765
+ .unwrap();
762
766
  let map = bundle
763
767
  .source_map_json
764
768
  .expect("source map requested → must be present");
@@ -782,7 +786,8 @@ fn factory() {
782
786
  let src = "export fn greet(n) { return \"hi \" + n }\n";
783
787
  std::fs::write(&p, src).unwrap();
784
788
  let bundle =
785
- compile_module_esm(&p, Some(dir), false, ImportRewrite::ViteDev, true).unwrap();
789
+ compile_module_esm(&p, Some(dir), false, ImportRewrite::ViteDev, true, "lattish")
790
+ .unwrap();
786
791
  let map = bundle.source_map_json.expect("source map requested");
787
792
  assert!(
788
793
  map.contains("\"sourcesContent\""),
@@ -800,7 +805,7 @@ fn factory() {
800
805
  let dir = tmp.path();
801
806
  let p = dir.join("main.tish");
802
807
  std::fs::write(&p, "console.log(1)\n").unwrap();
803
- let err = compile_module_esm(&p, Some(dir), true, ImportRewrite::ViteDev, true)
808
+ let err = compile_module_esm(&p, Some(dir), true, ImportRewrite::ViteDev, true, "lattish")
804
809
  .unwrap_err();
805
810
  assert!(
806
811
  err.message.to_lowercase().contains("optimiz"),
@@ -808,4 +813,167 @@ fn factory() {
808
813
  err.message
809
814
  );
810
815
  }
816
+
817
+ // ── #291: JSX runtime auto-import (h / Fragment) for per-module ESM ────────────────────────
818
+
819
+ #[test]
820
+ fn compile_module_esm_jsx_auto_imports_h() {
821
+ // A module that uses a JSX element but never imports `h` must get the runtime auto-imported,
822
+ // or it throws `ReferenceError: h` at load in per-module ESM (issue #291).
823
+ let js = build_module_vite("main.tish", &[("main.tish", "fn X() { return <div/> }\n")]);
824
+ assert!(
825
+ js.contains("import { h } from \"lattish\";"),
826
+ "JSX element module must auto-import h:\n{js}"
827
+ );
828
+ assert!(
829
+ !js.contains("Fragment"),
830
+ "no fragment used → Fragment must not be imported:\n{js}"
831
+ );
832
+ assert!(js.contains("h(\"div\""), "JSX still lowers to h(...):\n{js}");
833
+ }
834
+
835
+ #[test]
836
+ fn compile_module_esm_jsx_auto_imports_fragment() {
837
+ // A fragment lowers to `h(Fragment, …)`, so both `h` and `Fragment` must be auto-imported.
838
+ let js = build_module_vite(
839
+ "main.tish",
840
+ &[("main.tish", "fn X() { return <>{\"a\"}</> }\n")],
841
+ );
842
+ assert!(
843
+ js.contains("import { h, Fragment } from \"lattish\";"),
844
+ "fragment module must auto-import both h and Fragment:\n{js}"
845
+ );
846
+ }
847
+
848
+ #[test]
849
+ fn compile_module_esm_jsx_skips_import_when_present() {
850
+ // An explicit `import { h, Fragment } from "lattish"` must not be duplicated.
851
+ let js = build_module_vite(
852
+ "main.tish",
853
+ &[(
854
+ "main.tish",
855
+ "import { h, Fragment } from \"lattish\"\nfn X() { return <></> }\n",
856
+ )],
857
+ );
858
+ assert_eq!(
859
+ js.matches("from \"lattish\"").count(),
860
+ 1,
861
+ "exactly one lattish import (the author's), no auto-import duplicate:\n{js}"
862
+ );
863
+ assert!(
864
+ js.contains("import { h, Fragment } from \"lattish\";"),
865
+ "the author's import is preserved:\n{js}"
866
+ );
867
+ }
868
+
869
+ #[test]
870
+ fn compile_module_esm_jsx_partial_import_extends() {
871
+ // The author imported only `h`; a fragment also needs `Fragment`, so just that one missing
872
+ // binding is auto-imported (no re-import of `h`).
873
+ let js = build_module_vite(
874
+ "main.tish",
875
+ &[(
876
+ "main.tish",
877
+ "import { h } from \"lattish\"\nfn X() { return <></> }\n",
878
+ )],
879
+ );
880
+ assert!(
881
+ js.contains("import { Fragment } from \"lattish\";"),
882
+ "only the missing Fragment binding is auto-imported:\n{js}"
883
+ );
884
+ assert!(
885
+ js.contains("import { h } from \"lattish\";"),
886
+ "the author's h import is preserved:\n{js}"
887
+ );
888
+ assert!(
889
+ !js.contains("import { h, Fragment }"),
890
+ "h must not be re-imported alongside Fragment:\n{js}"
891
+ );
892
+ }
893
+
894
+ #[test]
895
+ fn compile_module_esm_vite_dev_jsx_keeps_bare_runtime_import() {
896
+ // In Vite-dev mode the auto-import specifier stays the bare package so Node/Vite resolves it
897
+ // (it is NOT rewritten to a relative `.js` path the way disk ESM does).
898
+ let js = build_module_vite("main.tish", &[("main.tish", "fn X() { return <div/> }\n")]);
899
+ assert!(
900
+ js.contains("from \"lattish\";"),
901
+ "Vite dev keeps the bare runtime specifier:\n{js}"
902
+ );
903
+ assert!(
904
+ !js.contains("lattish.js") && !js.contains("./lattish"),
905
+ "Vite dev must not rewrite the runtime specifier to a path:\n{js}"
906
+ );
907
+ }
908
+
909
+ #[test]
910
+ fn compile_module_esm_jsx_import_source_is_configurable() {
911
+ // The runtime package is overridable (e.g. the scoped `@tishlang/lattish`, or a custom runtime).
912
+ let tmp = tempfile::tempdir().expect("tempdir");
913
+ let dir = tmp.path();
914
+ let p = dir.join("main.tish");
915
+ std::fs::write(&p, "fn X() { return <div/> }\n").unwrap();
916
+ let js = compile_module_esm(
917
+ &p,
918
+ Some(dir),
919
+ false,
920
+ ImportRewrite::ViteDev,
921
+ false,
922
+ "@tishlang/lattish",
923
+ )
924
+ .expect("compile_module_esm failed")
925
+ .js;
926
+ assert!(
927
+ js.contains("import { h } from \"@tishlang/lattish\";"),
928
+ "custom jsx import source used:\n{js}"
929
+ );
930
+ }
931
+
932
+ #[test]
933
+ fn compile_project_esm_jsx_auto_import_per_module() {
934
+ // Only the module that actually uses JSX gets the runtime import; plain modules are untouched.
935
+ let tmp = tempfile::tempdir().expect("tempdir");
936
+ let base = tmp.path();
937
+ std::fs::create_dir_all(base.join("src")).unwrap();
938
+ std::fs::create_dir_all(base.join("node_modules/lattish")).unwrap();
939
+ std::fs::write(
940
+ base.join("node_modules/lattish/package.json"),
941
+ "{\"name\":\"lattish\",\"main\":\"index.tish\"}\n",
942
+ )
943
+ .unwrap();
944
+ std::fs::write(
945
+ base.join("node_modules/lattish/index.tish"),
946
+ "export fn h(tag, props, children) { return tag }\nexport let Fragment = \"frag\"\n",
947
+ )
948
+ .unwrap();
949
+ std::fs::write(
950
+ base.join("src/view.tish"),
951
+ "export fn V() { return <div/> }\n",
952
+ )
953
+ .unwrap();
954
+ std::fs::write(
955
+ base.join("src/plain.tish"),
956
+ "export fn add(a, b) { return a + b }\n",
957
+ )
958
+ .unwrap();
959
+ std::fs::write(
960
+ base.join("src/main.tish"),
961
+ "import { V } from \"./view.tish\"\nimport { add } from \"./plain.tish\"\nconsole.log(add(1, 2))\n",
962
+ )
963
+ .unwrap();
964
+ let mods = compile_project_esm(&base.join("src/main.tish"), Some(base), false, "lattish")
965
+ .expect("compile_project_esm failed");
966
+ // The runtime isn't imported by any module, so it's not in the graph; the common-ancestor
967
+ // output base is `src/`, making the emitted paths flat (`view.js`, `plain.js`, `main.js`).
968
+ let view = module_js(&mods, "view.js");
969
+ let plain = module_js(&mods, "plain.js");
970
+ assert!(
971
+ view.contains("import { h } from") && view.contains("lattish"),
972
+ "JSX module auto-imports the runtime:\n{view}"
973
+ );
974
+ assert!(
975
+ !plain.contains("import { h }") && !plain.contains("Fragment"),
976
+ "non-JSX module must not get a runtime import:\n{plain}"
977
+ );
978
+ }
811
979
  }
@@ -541,6 +541,256 @@ pub fn program_contains_jsx(program: &tishlang_ast::Program) -> bool {
541
541
  program.statements.iter().any(stmt_contains_jsx)
542
542
  }
543
543
 
544
+ /// Whether the program contains any JSX **fragment** (`<>…</>`). Fragments lower to
545
+ /// `h(Fragment, null, …)`, so they require the `Fragment` runtime binding in addition to `h`.
546
+ pub fn program_contains_jsx_fragment(program: &tishlang_ast::Program) -> bool {
547
+ program.statements.iter().any(stmt_contains_jsx_fragment)
548
+ }
549
+
550
+ /// Which JSX runtime bindings (`h`, `Fragment`) a module needs auto-imported, given which names are
551
+ /// already in module scope. Any JSX needs `h`; fragments additionally need `Fragment`. A binding is
552
+ /// omitted if it's already provided by a top-level import/declaration (see
553
+ /// [`collect_jsx_runtime_bindings_in_scope`]).
554
+ ///
555
+ /// Returns a subset of `["h", "Fragment"]` (in that order), or `[]` when the module has no JSX.
556
+ pub fn jsx_runtime_imports_needed(program: &tishlang_ast::Program) -> Vec<&'static str> {
557
+ if !program_contains_jsx(program) {
558
+ return Vec::new();
559
+ }
560
+ let in_scope = collect_jsx_runtime_bindings_in_scope(program);
561
+ let mut needed = Vec::new();
562
+ if !in_scope.contains("h") {
563
+ needed.push("h");
564
+ }
565
+ if program_contains_jsx_fragment(program) && !in_scope.contains("Fragment") {
566
+ needed.push("Fragment");
567
+ }
568
+ needed
569
+ }
570
+
571
+ /// Top-level names that already bind the JSX runtime symbols `h` / `Fragment`, so the auto-import
572
+ /// must not shadow or duplicate them. Covers named imports (local alias), top-level `const`/`let`,
573
+ /// and `fn` declarations, including those wrapped in `export`.
574
+ pub fn collect_jsx_runtime_bindings_in_scope(program: &tishlang_ast::Program) -> HashSet<String> {
575
+ use tishlang_ast::{ExportDeclaration, ImportSpecifier, Statement};
576
+ let mut names = HashSet::new();
577
+ let note = |n: &str, names: &mut HashSet<String>| {
578
+ if n == "h" || n == "Fragment" {
579
+ names.insert(n.to_string());
580
+ }
581
+ };
582
+ for stmt in &program.statements {
583
+ match stmt {
584
+ Statement::Import { specifiers, .. } => {
585
+ for s in specifiers {
586
+ if let ImportSpecifier::Named { name, alias, .. } = s {
587
+ let local = alias.as_deref().unwrap_or(name.as_ref());
588
+ note(local, &mut names);
589
+ }
590
+ }
591
+ }
592
+ Statement::VarDecl { name, .. } | Statement::FunDecl { name, .. } => {
593
+ note(name.as_ref(), &mut names)
594
+ }
595
+ Statement::Export { declaration, .. } => {
596
+ if let ExportDeclaration::Named(inner) = declaration.as_ref() {
597
+ match inner.as_ref() {
598
+ Statement::VarDecl { name, .. } | Statement::FunDecl { name, .. } => {
599
+ note(name.as_ref(), &mut names)
600
+ }
601
+ _ => {}
602
+ }
603
+ }
604
+ }
605
+ _ => {}
606
+ }
607
+ }
608
+ names
609
+ }
610
+
611
+ fn stmt_contains_jsx_fragment(stmt: &tishlang_ast::Statement) -> bool {
612
+ use tishlang_ast::{ExportDeclaration, Statement};
613
+ match stmt {
614
+ Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
615
+ statements.iter().any(stmt_contains_jsx_fragment)
616
+ }
617
+ Statement::VarDecl { init, .. } => init.as_ref().is_some_and(expr_contains_jsx_fragment),
618
+ Statement::VarDeclDestructure { init, .. } => expr_contains_jsx_fragment(init),
619
+ Statement::ExprStmt { expr, .. } => expr_contains_jsx_fragment(expr),
620
+ Statement::Return { value, .. } => value.as_ref().is_some_and(expr_contains_jsx_fragment),
621
+ Statement::If {
622
+ cond,
623
+ then_branch,
624
+ else_branch,
625
+ ..
626
+ } => {
627
+ expr_contains_jsx_fragment(cond)
628
+ || stmt_contains_jsx_fragment(then_branch)
629
+ || else_branch
630
+ .as_ref()
631
+ .is_some_and(|s| stmt_contains_jsx_fragment(s))
632
+ }
633
+ Statement::While { cond, body, .. } | Statement::DoWhile { body, cond, .. } => {
634
+ expr_contains_jsx_fragment(cond) || stmt_contains_jsx_fragment(body)
635
+ }
636
+ Statement::For {
637
+ init,
638
+ cond,
639
+ update,
640
+ body,
641
+ ..
642
+ } => {
643
+ init.as_ref().is_some_and(|s| stmt_contains_jsx_fragment(s))
644
+ || cond.as_ref().is_some_and(expr_contains_jsx_fragment)
645
+ || update.as_ref().is_some_and(expr_contains_jsx_fragment)
646
+ || stmt_contains_jsx_fragment(body)
647
+ }
648
+ Statement::ForOf { iterable, body, .. } => {
649
+ expr_contains_jsx_fragment(iterable) || stmt_contains_jsx_fragment(body)
650
+ }
651
+ Statement::Switch {
652
+ expr,
653
+ cases,
654
+ default_body,
655
+ ..
656
+ } => {
657
+ expr_contains_jsx_fragment(expr)
658
+ || cases.iter().any(|(e, ss)| {
659
+ e.as_ref().is_some_and(expr_contains_jsx_fragment)
660
+ || ss.iter().any(stmt_contains_jsx_fragment)
661
+ })
662
+ || default_body
663
+ .as_ref()
664
+ .is_some_and(|ss| ss.iter().any(stmt_contains_jsx_fragment))
665
+ }
666
+ Statement::Try {
667
+ body,
668
+ catch_body,
669
+ finally_body,
670
+ ..
671
+ } => {
672
+ stmt_contains_jsx_fragment(body)
673
+ || catch_body
674
+ .as_ref()
675
+ .is_some_and(|s| stmt_contains_jsx_fragment(s))
676
+ || finally_body
677
+ .as_ref()
678
+ .is_some_and(|s| stmt_contains_jsx_fragment(s))
679
+ }
680
+ Statement::FunDecl { body, .. } => stmt_contains_jsx_fragment(body),
681
+ Statement::Throw { value, .. } => expr_contains_jsx_fragment(value),
682
+ Statement::Export { declaration, .. } => match declaration.as_ref() {
683
+ ExportDeclaration::Named(inner) => stmt_contains_jsx_fragment(inner),
684
+ ExportDeclaration::Default(e) => expr_contains_jsx_fragment(e),
685
+ },
686
+ Statement::Import { .. }
687
+ | Statement::Break { .. }
688
+ | Statement::Continue { .. }
689
+ | Statement::TypeAlias { .. }
690
+ | Statement::DeclareVar { .. }
691
+ | Statement::DeclareFun { .. } => false,
692
+ }
693
+ }
694
+
695
+ fn expr_contains_jsx_fragment(expr: &Expr) -> bool {
696
+ match expr {
697
+ Expr::JsxFragment { .. } => true,
698
+ Expr::JsxElement {
699
+ props, children, ..
700
+ } => {
701
+ props.iter().any(|p| match p {
702
+ JsxProp::Attr {
703
+ value: JsxAttrValue::Expr(e),
704
+ ..
705
+ }
706
+ | JsxProp::Spread(e) => expr_contains_jsx_fragment(e),
707
+ _ => false,
708
+ }) || children.iter().any(|c| match c {
709
+ JsxChild::Expr(e) => expr_contains_jsx_fragment(e),
710
+ JsxChild::Text(_) => false,
711
+ })
712
+ }
713
+ Expr::Binary { left, right, .. } => {
714
+ expr_contains_jsx_fragment(left) || expr_contains_jsx_fragment(right)
715
+ }
716
+ Expr::Unary { operand, .. } => expr_contains_jsx_fragment(operand),
717
+ Expr::Assign { value, .. } => expr_contains_jsx_fragment(value),
718
+ Expr::Call { callee, args, .. } => {
719
+ expr_contains_jsx_fragment(callee)
720
+ || args.iter().any(|a| match a {
721
+ tishlang_ast::CallArg::Expr(e) | tishlang_ast::CallArg::Spread(e) => {
722
+ expr_contains_jsx_fragment(e)
723
+ }
724
+ })
725
+ }
726
+ Expr::Member { object, prop, .. } => {
727
+ expr_contains_jsx_fragment(object)
728
+ || matches!(prop, tishlang_ast::MemberProp::Expr(e) if expr_contains_jsx_fragment(e))
729
+ }
730
+ Expr::Index { object, index, .. } => {
731
+ expr_contains_jsx_fragment(object) || expr_contains_jsx_fragment(index)
732
+ }
733
+ Expr::Conditional {
734
+ cond,
735
+ then_branch,
736
+ else_branch,
737
+ ..
738
+ } => {
739
+ expr_contains_jsx_fragment(cond)
740
+ || expr_contains_jsx_fragment(then_branch)
741
+ || expr_contains_jsx_fragment(else_branch)
742
+ }
743
+ Expr::Array { elements, .. } => elements.iter().any(|el| match el {
744
+ ArrayElement::Expr(e) | ArrayElement::Spread(e) => expr_contains_jsx_fragment(e),
745
+ }),
746
+ Expr::Object { props, .. } => props.iter().any(|p| match p {
747
+ ObjectProp::KeyValue(_, e, _) | ObjectProp::Spread(e) => expr_contains_jsx_fragment(e),
748
+ }),
749
+ Expr::ArrowFunction { body, .. } => match body {
750
+ tishlang_ast::ArrowBody::Expr(e) => expr_contains_jsx_fragment(e),
751
+ tishlang_ast::ArrowBody::Block(s) => stmt_contains_jsx_fragment(s),
752
+ },
753
+ Expr::NullishCoalesce { left, right, .. } => {
754
+ expr_contains_jsx_fragment(left) || expr_contains_jsx_fragment(right)
755
+ }
756
+ Expr::TemplateLiteral { exprs, .. } => exprs.iter().any(expr_contains_jsx_fragment),
757
+ Expr::Await { operand, .. } => expr_contains_jsx_fragment(operand),
758
+ Expr::TypeOf { operand, .. } => expr_contains_jsx_fragment(operand),
759
+ Expr::Delete { target, .. } => expr_contains_jsx_fragment(target),
760
+ Expr::CompoundAssign { value, .. } | Expr::LogicalAssign { value, .. } => {
761
+ expr_contains_jsx_fragment(value)
762
+ }
763
+ Expr::MemberAssign { object, value, .. } => {
764
+ expr_contains_jsx_fragment(object) || expr_contains_jsx_fragment(value)
765
+ }
766
+ Expr::IndexAssign {
767
+ object,
768
+ index,
769
+ value,
770
+ ..
771
+ } => {
772
+ expr_contains_jsx_fragment(object)
773
+ || expr_contains_jsx_fragment(index)
774
+ || expr_contains_jsx_fragment(value)
775
+ }
776
+ Expr::New { callee, args, .. } => {
777
+ expr_contains_jsx_fragment(callee)
778
+ || args.iter().any(|a| match a {
779
+ tishlang_ast::CallArg::Expr(e) | tishlang_ast::CallArg::Spread(e) => {
780
+ expr_contains_jsx_fragment(e)
781
+ }
782
+ })
783
+ }
784
+ Expr::PostfixInc { .. }
785
+ | Expr::PrefixInc { .. }
786
+ | Expr::PostfixDec { .. }
787
+ | Expr::PrefixDec { .. }
788
+ | Expr::Literal { .. }
789
+ | Expr::Ident { .. }
790
+ | Expr::NativeModuleLoad { .. } => false,
791
+ }
792
+ }
793
+
544
794
  fn stmt_contains_jsx(stmt: &tishlang_ast::Statement) -> bool {
545
795
  use tishlang_ast::{ExportDeclaration, Statement};
546
796
  match stmt {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tishlang/tish",
3
- "version": "2.10.1",
3
+ "version": "2.12.0",
4
4
  "description": "Tish - minimal TS/JS-compatible language. Run, REPL, build to native or other targets.",
5
5
  "license": "PIF",
6
6
  "repository": {
Binary file
Binary file
Binary file
Binary file
Binary file