@tishlang/tish 2.9.0 → 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.
@@ -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.
@@ -20,16 +24,31 @@ enum EmitMode {
20
24
  Esm,
21
25
  }
22
26
 
27
+ /// How ESM import specifiers are rewritten. `Disk` (production `--format esm`) rewrites `.tish`
28
+ /// specifiers to their sibling `.js` output paths and resolves bare specifiers to relative paths
29
+ /// into the emitted module tree. `ViteDev` keeps relative `.tish` specifiers and bare specifiers
30
+ /// as-is so Vite's `resolveId`/`load` re-enters the plugin per module (in-graph HMR, issue #284).
31
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
32
+ pub enum ImportRewrite {
33
+ Disk,
34
+ ViteDev,
35
+ }
36
+
23
37
  struct Codegen {
24
38
  output: String,
25
39
  indent: usize,
26
40
  in_async: bool,
27
41
  emit_mode: EmitMode,
42
+ /// ESM only: how import specifiers are rewritten (disk `.js` paths vs Vite-dev `.tish`).
43
+ import_rewrite: ImportRewrite,
28
44
  /// ESM only: the absolute path of the module being emitted (the importer), used to rewrite
29
45
  /// relative/bare import specifiers to sibling `.js` output paths.
30
46
  module_path: PathBuf,
31
47
  /// ESM only: the project root, so import targets can be located relative to the output tree.
32
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,
33
52
  }
34
53
 
35
54
  fn stmt_terminates_switch(stmt: Option<&Statement>) -> bool {
@@ -48,19 +67,28 @@ impl Codegen {
48
67
  indent: 0,
49
68
  in_async: false,
50
69
  emit_mode: EmitMode::Bundle,
70
+ import_rewrite: ImportRewrite::Disk,
51
71
  module_path: PathBuf::new(),
52
72
  project_root: PathBuf::new(),
73
+ jsx_import_source: DEFAULT_JSX_IMPORT_SOURCE.to_string(),
53
74
  }
54
75
  }
55
76
 
56
- fn new_esm(module_path: PathBuf, project_root: PathBuf) -> Self {
77
+ fn new_esm(
78
+ module_path: PathBuf,
79
+ project_root: PathBuf,
80
+ import_rewrite: ImportRewrite,
81
+ jsx_import_source: String,
82
+ ) -> Self {
57
83
  Self {
58
84
  output: String::new(),
59
85
  indent: 0,
60
86
  in_async: false,
61
87
  emit_mode: EmitMode::Esm,
88
+ import_rewrite,
62
89
  module_path,
63
90
  project_root,
91
+ jsx_import_source,
64
92
  }
65
93
  }
66
94
 
@@ -125,6 +153,7 @@ impl Codegen {
125
153
  map_builder: Option<&mut SourceMapBuilder>,
126
154
  ) -> Result<(), CompileError> {
127
155
  self.write("// Generated by tishlang_compile_js\n");
156
+ self.emit_jsx_runtime_auto_import(program)?;
128
157
  match (map_sources, map_builder) {
129
158
  (Some((srcs, root)), Some(sm)) => {
130
159
  for (i, stmt) in program.statements.iter().enumerate() {
@@ -162,6 +191,34 @@ impl Codegen {
162
191
  Ok(())
163
192
  }
164
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
+
165
222
  fn emit_statement(&mut self, stmt: &Statement) -> Result<(), CompileError> {
166
223
  match stmt {
167
224
  Statement::Block { statements, .. } => {
@@ -907,7 +964,12 @@ impl Codegen {
907
964
  specifiers: &[ImportSpecifier],
908
965
  from: &str,
909
966
  ) -> Result<(), CompileError> {
910
- let spec = rewrite_import_to_js(from, &self.module_path, &self.project_root)?;
967
+ let spec = rewrite_import_to_js(
968
+ from,
969
+ &self.module_path,
970
+ &self.project_root,
971
+ self.import_rewrite,
972
+ )?;
911
973
  let mut named: Vec<String> = Vec::new();
912
974
  let mut default_local: Option<String> = None;
913
975
  let mut namespace_local: Option<String> = None;
@@ -987,6 +1049,7 @@ fn rewrite_import_to_js(
987
1049
  spec: &str,
988
1050
  importer_abs: &Path,
989
1051
  project_root: &Path,
1052
+ rewrite: ImportRewrite,
990
1053
  ) -> Result<String, CompileError> {
991
1054
  if tishlang_compile::is_native_import(spec) {
992
1055
  return Err(CompileError {
@@ -996,6 +1059,11 @@ fn rewrite_import_to_js(
996
1059
  ),
997
1060
  });
998
1061
  }
1062
+ // Vite dev keeps specifiers verbatim: relative `.tish` paths and bare packages are left for the
1063
+ // plugin's `resolveId`/`load` (relative) or Node/Vite resolution (bare) to handle per-module.
1064
+ if rewrite == ImportRewrite::ViteDev {
1065
+ return Ok(spec.to_string());
1066
+ }
999
1067
  if spec.starts_with("./") || spec.starts_with("../") {
1000
1068
  return Ok(spec_ext_to_js(spec));
1001
1069
  }
@@ -1210,6 +1278,7 @@ pub fn compile_project_esm(
1210
1278
  entry_path: &Path,
1211
1279
  project_root: Option<&Path>,
1212
1280
  optimize: bool,
1281
+ jsx_import_source: &str,
1213
1282
  ) -> Result<Vec<EmittedJsModule>, CompileError> {
1214
1283
  let modules = tishlang_compile::resolve_project(entry_path, project_root)
1215
1284
  .map_err(|e| CompileError { message: e })?;
@@ -1249,7 +1318,12 @@ pub fn compile_project_esm(
1249
1318
  } else {
1250
1319
  module.program.clone()
1251
1320
  };
1252
- let mut gen = Codegen::new_esm(mod_canon.clone(), res_root_canon.clone());
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
+ );
1253
1327
  gen.emit_program(&program, None, None)?;
1254
1328
  out.push(EmittedJsModule {
1255
1329
  relative_path: rel_js,
@@ -1259,6 +1333,90 @@ pub fn compile_project_esm(
1259
1333
  Ok(out)
1260
1334
  }
1261
1335
 
1336
+ /// Compile a **single** `.tish` module to one ES module (issue #284, Vite dev / HMR). Unlike
1337
+ /// [`compile_project_esm`], the dependency graph is **not** resolved — only `module_path` is read
1338
+ /// and parsed — so a Vite plugin can compile one file per `load()` and let Vite own the module
1339
+ /// graph. With `ImportRewrite::ViteDev` relative `.tish` specifiers and bare packages are preserved
1340
+ /// so Vite re-enters the plugin per dependency. When `source_map` is set, a v3 map back to the
1341
+ /// `.tish` source is returned (requires `optimize == false`, matching the bundle source-map rule).
1342
+ pub fn compile_module_esm(
1343
+ module_path: &Path,
1344
+ project_root: Option<&Path>,
1345
+ optimize: bool,
1346
+ import_rewrite: ImportRewrite,
1347
+ source_map: bool,
1348
+ jsx_import_source: &str,
1349
+ ) -> Result<JsBundle, CompileError> {
1350
+ if source_map && optimize {
1351
+ return Err(CompileError {
1352
+ message: "source map requires no optimization (mappings follow unmerged statement order)."
1353
+ .into(),
1354
+ });
1355
+ }
1356
+ let source = std::fs::read_to_string(module_path).map_err(|e| CompileError {
1357
+ message: format!("Cannot read {}: {}", module_path.display(), e),
1358
+ })?;
1359
+ let parsed = tishlang_parser::parse(&source).map_err(|e| CompileError {
1360
+ message: format!("Parse error in {}: {}", module_path.display(), e),
1361
+ })?;
1362
+ let program = if optimize {
1363
+ tishlang_opt::optimize(&parsed)
1364
+ } else {
1365
+ parsed
1366
+ };
1367
+ let module_canon = module_path
1368
+ .canonicalize()
1369
+ .unwrap_or_else(|_| module_path.to_path_buf());
1370
+ let root = project_root
1371
+ .map(Path::to_path_buf)
1372
+ .or_else(|| module_path.parent().map(Path::to_path_buf))
1373
+ .unwrap_or_else(|| PathBuf::from("."));
1374
+ let root_canon = root.canonicalize().unwrap_or(root);
1375
+
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
+ );
1382
+ if source_map {
1383
+ let stmt_sources = vec![module_canon.clone(); program.statements.len()];
1384
+ let mut builder = SourceMapBuilder::new(Some("module.js"));
1385
+ builder.set_source_root(Some(""));
1386
+ gen.emit_program(
1387
+ &program,
1388
+ Some((stmt_sources.as_slice(), root_canon.as_path())),
1389
+ Some(&mut builder),
1390
+ )?;
1391
+ let mut sm = builder.into_sourcemap();
1392
+ // Embed the original `.tish` so consumers (Vite, browser devtools) never resolve
1393
+ // `sources` from disk. The map's `sources` are project-root-relative, but Vite resolves
1394
+ // them against the module's own directory; without inline content it logs
1395
+ // "Sourcemap ... points to missing source files" and devtools can't show the original.
1396
+ let source_count = sm.get_source_count();
1397
+ for id in 0..source_count {
1398
+ sm.set_source_contents(id, Some(&source));
1399
+ }
1400
+ let mut v = Vec::new();
1401
+ sm.to_writer(&mut v).map_err(|e| CompileError {
1402
+ message: e.to_string(),
1403
+ })?;
1404
+ let map_json = String::from_utf8(v).map_err(|e| CompileError {
1405
+ message: e.to_string(),
1406
+ })?;
1407
+ Ok(JsBundle {
1408
+ js: gen.output,
1409
+ source_map_json: Some(map_json),
1410
+ })
1411
+ } else {
1412
+ gen.emit_program(&program, None, None)?;
1413
+ Ok(JsBundle {
1414
+ js: gen.output,
1415
+ source_map_json: None,
1416
+ })
1417
+ }
1418
+ }
1419
+
1262
1420
  /// Deepest directory that is an ancestor of every given file path, compared component-wise. Used as
1263
1421
  /// the base of the `--format esm` output tree so modules outside the entry's project root (sibling
1264
1422
  /// packages, `node_modules`) still map to a stable, collision-free location. For a single file this
@@ -8,12 +8,16 @@ mod error;
8
8
  mod tests_jsx;
9
9
 
10
10
  pub use codegen::{
11
- compile_project_esm, compile_project_with_jsx, compile_project_with_jsx_and_source_map,
12
- compile_with_jsx, EmittedJsModule, JsBundle,
11
+ compile_module_esm, compile_project_esm, compile_project_with_jsx,
12
+ compile_project_with_jsx_and_source_map, compile_with_jsx, EmittedJsModule, ImportRewrite,
13
+ JsBundle, DEFAULT_JSX_IMPORT_SOURCE,
13
14
  };
14
15
  pub use error::CompileError;
15
16
 
16
- /// 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).
17
21
  pub fn compile(program: &tishlang_ast::Program, optimize: bool) -> Result<String, CompileError> {
18
22
  compile_with_jsx(program, optimize)
19
23
  }
@@ -4,7 +4,10 @@ mod tests {
4
4
 
5
5
  use tishlang_parser::parse;
6
6
 
7
- use crate::{compile_project_esm, compile_project_with_jsx, compile_with_jsx, EmittedJsModule};
7
+ use crate::{
8
+ compile_module_esm, compile_project_esm, compile_project_with_jsx, compile_with_jsx,
9
+ EmittedJsModule, ImportRewrite,
10
+ };
8
11
 
9
12
  #[test]
10
13
  fn lattish_jsx_emits_h_with_children_array() {
@@ -472,7 +475,8 @@ fn factory() {
472
475
  f.write_all(src.as_bytes()).unwrap();
473
476
  f.sync_all().unwrap();
474
477
  }
475
- 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")
476
480
  }
477
481
 
478
482
  fn module_js<'a>(mods: &'a [EmittedJsModule], rel: &str) -> &'a str {
@@ -554,6 +558,7 @@ fn factory() {
554
558
  &base.join("app/src/main.tish"),
555
559
  Some(&base.join("app")),
556
560
  false,
561
+ "lattish",
557
562
  )
558
563
  .expect("compile_project_esm failed for sibling dep");
559
564
  let rels: Vec<String> = mods
@@ -604,7 +609,7 @@ fn factory() {
604
609
  "import { ping } from \"pkg\"\nconsole.log(ping())\n",
605
610
  )
606
611
  .unwrap();
607
- 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")
608
613
  .expect("compile_project_esm failed for node_modules dep");
609
614
  let rels: Vec<String> = mods
610
615
  .iter()
@@ -632,11 +637,343 @@ fn factory() {
632
637
  let dir = tmp.path();
633
638
  let p = dir.join("main.tish");
634
639
  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();
640
+ let err = compile_project_esm(&p, Some(dir), false, "lattish").unwrap_err();
636
641
  assert!(
637
642
  err.message.contains("Native module import") && err.message.contains("esm"),
638
643
  "expected a native-import rejection for ESM, got: {}",
639
644
  err.message
640
645
  );
641
646
  }
647
+
648
+ // ── #284: single-module compile for Vite dev (in-graph HMR) ───────────────────────────────
649
+
650
+ /// Write `modules` into a fresh temp dir and compile just `entry` (no graph resolution),
651
+ /// in Vite-dev import-rewrite mode without a source map.
652
+ fn build_module_vite(entry: &str, modules: &[(&str, &str)]) -> String {
653
+ let tmp = tempfile::tempdir().expect("tempdir");
654
+ let dir = tmp.path();
655
+ for (rel, src) in modules {
656
+ let p = dir.join(rel);
657
+ if let Some(parent) = p.parent() {
658
+ std::fs::create_dir_all(parent).unwrap();
659
+ }
660
+ let mut f = std::fs::File::create(&p).unwrap();
661
+ f.write_all(src.as_bytes()).unwrap();
662
+ f.sync_all().unwrap();
663
+ }
664
+ compile_module_esm(
665
+ &dir.join(entry),
666
+ Some(dir),
667
+ false,
668
+ ImportRewrite::ViteDev,
669
+ false,
670
+ "lattish",
671
+ )
672
+ .expect("compile_module_esm failed")
673
+ .js
674
+ }
675
+
676
+ #[test]
677
+ fn compile_module_esm_vite_preserves_relative_tish_imports() {
678
+ let js = build_module_vite(
679
+ "main.tish",
680
+ &[(
681
+ "main.tish",
682
+ "import { id } from \"./dep.tish\"\nconsole.log(id(1))\n",
683
+ )],
684
+ );
685
+ assert!(
686
+ js.contains("import { id } from \"./dep.tish\";"),
687
+ "Vite dev keeps the .tish specifier so resolveId re-enters the plugin:\n{js}"
688
+ );
689
+ assert!(
690
+ !js.contains("./dep.js"),
691
+ "Vite dev must NOT rewrite .tish to .js:\n{js}"
692
+ );
693
+ }
694
+
695
+ #[test]
696
+ fn compile_module_esm_vite_leaves_bare_specifiers_unchanged() {
697
+ let js = build_module_vite(
698
+ "main.tish",
699
+ &[(
700
+ "main.tish",
701
+ "import { h } from \"lattish\"\nconsole.log(h)\n",
702
+ )],
703
+ );
704
+ assert!(
705
+ js.contains("import { h } from \"lattish\";"),
706
+ "bare specifier passes through for normal Node/Vite resolution:\n{js}"
707
+ );
708
+ }
709
+
710
+ #[test]
711
+ fn compile_module_esm_vite_emits_exports() {
712
+ let js = build_module_vite(
713
+ "main.tish",
714
+ &[(
715
+ "main.tish",
716
+ "export const VERSION = \"1.0\"\nexport fn greet(n) { return \"hi \" + n }\nexport default greet\n",
717
+ )],
718
+ );
719
+ assert!(js.contains("export const VERSION = \"1.0\";"), "named const export:\n{js}");
720
+ assert!(js.contains("export function greet "), "named fn export:\n{js}");
721
+ assert!(js.contains("export default greet;"), "default export:\n{js}");
722
+ }
723
+
724
+ #[test]
725
+ fn compile_module_esm_does_not_resolve_graph() {
726
+ // Compiling a single module must not read or require its dependencies to exist on disk:
727
+ // only the entry file is created, yet the import to a missing `./dep.tish` compiles fine.
728
+ let js = build_module_vite(
729
+ "main.tish",
730
+ &[(
731
+ "main.tish",
732
+ "import { id } from \"./dep.tish\"\nconsole.log(id(1))\n",
733
+ )],
734
+ );
735
+ assert!(
736
+ js.contains("import { id } from \"./dep.tish\";"),
737
+ "single-module compile emits the import without loading the dependency:\n{js}"
738
+ );
739
+ }
740
+
741
+ #[test]
742
+ fn compile_module_esm_rejects_native_imports() {
743
+ let tmp = tempfile::tempdir().expect("tempdir");
744
+ let dir = tmp.path();
745
+ let p = dir.join("main.tish");
746
+ std::fs::write(&p, "import { readFile } from \"fs\"\nconsole.log(1)\n").unwrap();
747
+ let err = compile_module_esm(&p, Some(dir), false, ImportRewrite::ViteDev, false, "lattish")
748
+ .unwrap_err();
749
+ assert!(
750
+ err.message.contains("Native module import"),
751
+ "expected a native-import rejection, got: {}",
752
+ err.message
753
+ );
754
+ }
755
+
756
+ #[test]
757
+ fn compile_module_esm_source_map_maps_statement_to_tish_file() {
758
+ let tmp = tempfile::tempdir().expect("tempdir");
759
+ let dir = tmp.path();
760
+ let p = dir.join("main.tish");
761
+ std::fs::write(&p, "export fn greet(n) { return \"hi \" + n }\nconsole.log(greet(\"x\"))\n")
762
+ .unwrap();
763
+ let bundle =
764
+ compile_module_esm(&p, Some(dir), false, ImportRewrite::ViteDev, true, "lattish")
765
+ .unwrap();
766
+ let map = bundle
767
+ .source_map_json
768
+ .expect("source map requested → must be present");
769
+ assert!(
770
+ map.contains("\"version\":3") || map.contains("\"version\": 3"),
771
+ "v3 source map:\n{map}"
772
+ );
773
+ assert!(
774
+ map.contains("main.tish"),
775
+ "map sources reference the .tish file:\n{map}"
776
+ );
777
+ }
778
+
779
+ #[test]
780
+ fn compile_module_esm_source_map_embeds_sources_content() {
781
+ // The map must carry sourcesContent so Vite/devtools never resolve `sources` from disk
782
+ // (otherwise Vite warns "Sourcemap ... points to missing source files").
783
+ let tmp = tempfile::tempdir().expect("tempdir");
784
+ let dir = tmp.path();
785
+ let p = dir.join("main.tish");
786
+ let src = "export fn greet(n) { return \"hi \" + n }\n";
787
+ std::fs::write(&p, src).unwrap();
788
+ let bundle =
789
+ compile_module_esm(&p, Some(dir), false, ImportRewrite::ViteDev, true, "lattish")
790
+ .unwrap();
791
+ let map = bundle.source_map_json.expect("source map requested");
792
+ assert!(
793
+ map.contains("\"sourcesContent\""),
794
+ "map must include a sourcesContent field:\n{map}"
795
+ );
796
+ assert!(
797
+ map.contains("export fn greet"),
798
+ "sourcesContent must embed the original .tish source:\n{map}"
799
+ );
800
+ }
801
+
802
+ #[test]
803
+ fn compile_module_esm_source_map_requires_no_optimize() {
804
+ let tmp = tempfile::tempdir().expect("tempdir");
805
+ let dir = tmp.path();
806
+ let p = dir.join("main.tish");
807
+ std::fs::write(&p, "console.log(1)\n").unwrap();
808
+ let err = compile_module_esm(&p, Some(dir), true, ImportRewrite::ViteDev, true, "lattish")
809
+ .unwrap_err();
810
+ assert!(
811
+ err.message.to_lowercase().contains("optimiz"),
812
+ "source map + optimize must be rejected, got: {}",
813
+ err.message
814
+ );
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
+ }
642
979
  }