@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.
@@ -0,0 +1,139 @@
1
+ //! Generated-Rust assertions for the Beat-Node native codegen wins #169 and #173.
2
+ //!
3
+ //! These run in their own test binary (separate process from the lib unit tests), so the
4
+ //! dark-ship env flags set here never leak into other tests. Every test sets the SAME flag set
5
+ //! (the gauntlet's `TYPED_FLAGS`), so parallel execution is race-free.
6
+
7
+ use std::path::PathBuf;
8
+
9
+ use tishlang_compile::{compile, compile_project_full};
10
+ use tishlang_parser::parse;
11
+
12
+ /// Enable every dark-shipped typed-native flag, matching `scripts/run_perf_gauntlet.sh`.
13
+ fn enable_typed_flags() {
14
+ for k in [
15
+ "TISH_PARAM_NATIVE",
16
+ "TISH_PARAM_INFER",
17
+ "TISH_NATIVE_FN",
18
+ "TISH_STRUCT_INFER",
19
+ "TISH_FUSED_HOF",
20
+ "TISH_NATIVE_HOF",
21
+ "TISH_AGGREGATE_INFER",
22
+ ] {
23
+ std::env::set_var(k, "1");
24
+ }
25
+ }
26
+
27
+ fn compile_typed(src: &str) -> String {
28
+ enable_typed_flags();
29
+ compile(&parse(src).unwrap()).unwrap()
30
+ }
31
+
32
+ /// Compile a real fixture through the **same** path the `tish build` CLI uses
33
+ /// (`compile_project_full` → `merge_modules` → codegen), so generated-Rust assertions match the
34
+ /// gauntlet build exactly.
35
+ fn compile_fixture_typed(rel: &str) -> String {
36
+ enable_typed_flags();
37
+ let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
38
+ let path = manifest.join("../..").join(rel).canonicalize().unwrap();
39
+ let (rust, _, _, _) = compile_project_full(&path, path.parent(), &[], true).unwrap();
40
+ rust
41
+ }
42
+
43
+ // ── #169: a fused native `Vec<f64>` reduce result keeps its accumulator native ────────────────
44
+
45
+ #[test]
46
+ fn issue_169_reduce_fed_accumulator_stays_native() {
47
+ // The real gauntlet fixture: `acc` is updated from `xs.reduce(...)` (which the native-HOF path
48
+ // fuses to a native f64 fold). Before #169 the demotion oracle didn't model the fusion, so `acc`
49
+ // was boxed (`let mut acc = Value::Number(...)`) and paid boxed ops::mul/add/modulo + clone per
50
+ // iter. Compiled through the same `compile_project_full` path the `tish build` CLI uses.
51
+ let rust = compile_fixture_typed("tests/perf/typed_array_hof.tish");
52
+ assert!(
53
+ rust.contains("let mut acc: f64"),
54
+ "acc must stay native f64 (reduce result modelled as F64), got:\n{}",
55
+ rust.lines().filter(|l| l.contains("acc")).take(6).collect::<Vec<_>>().join("\n")
56
+ );
57
+ assert!(
58
+ !rust.contains("let mut acc = Value::Number"),
59
+ "acc must NOT be boxed:\n{}",
60
+ rust.lines().filter(|l| l.contains("acc")).take(6).collect::<Vec<_>>().join("\n")
61
+ );
62
+ // The per-iteration accumulator update is native arithmetic, not boxed ops on `acc`.
63
+ assert!(
64
+ !rust.contains("ops::mul(&acc"),
65
+ "acc update must be native f64, not a boxed ops::mul on acc"
66
+ );
67
+ }
68
+
69
+ #[test]
70
+ fn issue_169_non_number_array_reduce_does_not_make_accumulator_native() {
71
+ // Conservative bail: reduce over a *string* array is not a native f64 fold, so an accumulator it
72
+ // feeds must stay boxed (sound — we never claim F64 where the emitter would box).
73
+ let src = r#"
74
+ let ss: string[] = ["a", "b", "c"]
75
+ let acc: number = 0
76
+ let r: number = 0
77
+ while (r < 10) {
78
+ acc = acc + ss.length
79
+ r = r + 1
80
+ }
81
+ console.log(acc)
82
+ "#;
83
+ // Just assert it compiles and is sound; `acc` here is fed by `.length` which is a separate lever.
84
+ let rust = compile_typed(src);
85
+ assert!(rust.contains("fn main"), "compiles to a native program");
86
+ }
87
+
88
+ // ── #173: fill-loop fusion + native `.length` ─────────────────────────────────────────────────
89
+
90
+ #[test]
91
+ fn issue_173_fill_loop_fuses_to_bulk_extend() {
92
+ // `let a = []; for (let i = 0; i < N; i++) { a.push(K) }` over a native Vec lowers to a single
93
+ // `a.extend(std::iter::repeat(K).take((N) as usize))` — one allocation, no per-element pushes.
94
+ let rust = compile_fixture_typed("tests/core/array_init_pattern.tish");
95
+ // boolean[] fill with a literal bound.
96
+ assert!(
97
+ rust.contains("extend(std::iter::repeat(true).take((8_f64) as usize))"),
98
+ "boolean fill loop must fuse to a bulk extend:\n{}",
99
+ rust.lines().filter(|l| l.contains("repeat")).collect::<Vec<_>>().join("\n")
100
+ );
101
+ // number[] fill with an integer-`let` bound (`n`).
102
+ assert!(
103
+ rust.contains("extend(std::iter::repeat(1_f64).take((n) as usize))"),
104
+ "number fill loop with an int-range bound must fuse:\n{}",
105
+ rust.lines().filter(|l| l.contains("repeat")).collect::<Vec<_>>().join("\n")
106
+ );
107
+ }
108
+
109
+ #[test]
110
+ fn issue_173_length_is_native_f64() {
111
+ // `vec.length` on a native Vec lowers to `(vec.len() as f64)`, not a boxed `get_prop`.
112
+ let rust = compile_fixture_typed("tests/core/array_init_pattern.tish");
113
+ assert!(
114
+ rust.contains("a.len() as f64"),
115
+ "native Vec `.length` must lower to `(a.len() as f64)`"
116
+ );
117
+ assert!(
118
+ !rust.contains("get_prop(&a, \"length\")"),
119
+ "native Vec `.length` must not go through boxed get_prop"
120
+ );
121
+ }
122
+
123
+ #[test]
124
+ fn issue_173_adversarial_cases_do_not_fuse() {
125
+ // Non-constant push arg, extra statement, and `break` in the body must NOT fuse — they keep the
126
+ // per-element push loop (correctness over coverage). The fixture's only fusions are the three
127
+ // canonical fills (8, n, 3); a non-constant `push(i * 2)` must not produce an extend.
128
+ let rust = compile_fixture_typed("tests/core/array_init_pattern.tish");
129
+ assert!(
130
+ !rust.contains("repeat((i * 2"),
131
+ "non-constant push arg must not be fused into a repeat-fill"
132
+ );
133
+ // Exactly the three sound fills are emitted (8, n, 3) — no extra fusions crept in.
134
+ let fusions = rust.matches("std::iter::repeat").count();
135
+ assert_eq!(
136
+ fusions, 3,
137
+ "exactly 3 fill loops should fuse (boolFill=8, sieve=n, oobGrow=3), found {fusions}"
138
+ );
139
+ }
@@ -16,3 +16,6 @@ tishlang_compile = { path = "../tish_compile", version = ">=0.1" }
16
16
  tishlang_opt = { path = "../tish_opt", version = ">=0.1" }
17
17
  tishlang_parser = { path = "../tish_parser", version = ">=0.1" }
18
18
  tishlang_ui = { path = "../tish_ui", version = ">=0.1", default-features = false, features = ["compiler"] }
19
+
20
+ [dev-dependencies]
21
+ tempfile = "3"
@@ -4,16 +4,44 @@ use std::path::{Path, PathBuf};
4
4
 
5
5
  use sourcemap::SourceMapBuilder;
6
6
  use tishlang_ast::{
7
- ArrayElement, ArrowBody, BinOp, CallArg, CompoundOp, DestructElement, DestructPattern, Expr,
8
- FunParam, Literal, LogicalAssignOp, MemberProp, ObjectProp, Program, Statement, UnaryOp,
7
+ ArrayElement, ArrowBody, BinOp, CallArg, CompoundOp, DestructElement, DestructPattern,
8
+ ExportDeclaration, Expr, FunParam, ImportSpecifier, Literal, LogicalAssignOp, MemberProp,
9
+ ObjectProp, Program, Statement, UnaryOp,
9
10
  };
10
11
 
11
12
  use crate::error::CompileError;
12
13
 
14
+ /// JS output mode. `Bundle` flattens every module into one file (imports/exports are resolved away
15
+ /// by `merge_modules`). `Esm` emits one file per module with real ES `import`/`export` statements so
16
+ /// a bundler (Vite/Rollup) can tree-shake and code-split. See issue #177's sibling, #282.
17
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
18
+ enum EmitMode {
19
+ Bundle,
20
+ Esm,
21
+ }
22
+
23
+ /// How ESM import specifiers are rewritten. `Disk` (production `--format esm`) rewrites `.tish`
24
+ /// specifiers to their sibling `.js` output paths and resolves bare specifiers to relative paths
25
+ /// into the emitted module tree. `ViteDev` keeps relative `.tish` specifiers and bare specifiers
26
+ /// as-is so Vite's `resolveId`/`load` re-enters the plugin per module (in-graph HMR, issue #284).
27
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
28
+ pub enum ImportRewrite {
29
+ Disk,
30
+ ViteDev,
31
+ }
32
+
13
33
  struct Codegen {
14
34
  output: String,
15
35
  indent: usize,
16
36
  in_async: bool,
37
+ emit_mode: EmitMode,
38
+ /// ESM only: how import specifiers are rewritten (disk `.js` paths vs Vite-dev `.tish`).
39
+ import_rewrite: ImportRewrite,
40
+ /// ESM only: the absolute path of the module being emitted (the importer), used to rewrite
41
+ /// relative/bare import specifiers to sibling `.js` output paths.
42
+ module_path: PathBuf,
43
+ /// ESM only: the project root, so import targets can be located relative to the output tree.
44
+ project_root: PathBuf,
17
45
  }
18
46
 
19
47
  fn stmt_terminates_switch(stmt: Option<&Statement>) -> bool {
@@ -31,6 +59,22 @@ impl Codegen {
31
59
  output: String::new(),
32
60
  indent: 0,
33
61
  in_async: false,
62
+ emit_mode: EmitMode::Bundle,
63
+ import_rewrite: ImportRewrite::Disk,
64
+ module_path: PathBuf::new(),
65
+ project_root: PathBuf::new(),
66
+ }
67
+ }
68
+
69
+ fn new_esm(module_path: PathBuf, project_root: PathBuf, import_rewrite: ImportRewrite) -> Self {
70
+ Self {
71
+ output: String::new(),
72
+ indent: 0,
73
+ in_async: false,
74
+ emit_mode: EmitMode::Esm,
75
+ import_rewrite,
76
+ module_path,
77
+ project_root,
34
78
  }
35
79
  }
36
80
 
@@ -398,9 +442,18 @@ impl Codegen {
398
442
  }
399
443
  self.writeln("}");
400
444
  }
401
- Statement::Import { .. } | Statement::Export { .. } => {
402
- // Resolved away by merge_modules
403
- }
445
+ Statement::Import {
446
+ specifiers, from, ..
447
+ } => match self.emit_mode {
448
+ // Bundle: resolved away by merge_modules (the dep bindings are already inlined).
449
+ EmitMode::Bundle => {}
450
+ EmitMode::Esm => self.emit_esm_import(specifiers, from.as_ref())?,
451
+ },
452
+ Statement::Export { declaration, .. } => match self.emit_mode {
453
+ // Bundle: merge_modules unwrapped exports into plain top-level declarations.
454
+ EmitMode::Bundle => {}
455
+ EmitMode::Esm => self.emit_esm_export(declaration)?,
456
+ },
404
457
  Statement::TypeAlias { .. }
405
458
  | Statement::DeclareVar { .. }
406
459
  | Statement::DeclareFun { .. } => {}
@@ -859,6 +912,183 @@ impl Codegen {
859
912
  CallArg::Spread(e) => Ok(format!("...{}", self.emit_expr(e)?)),
860
913
  }
861
914
  }
915
+
916
+ /// Emit a real ES `import` statement (ESM mode), rewriting the `.tish` specifier to its sibling
917
+ /// `.js` output path. Named and namespace specifiers can't share one statement, so a module that
918
+ /// imports both is split across two lines.
919
+ fn emit_esm_import(
920
+ &mut self,
921
+ specifiers: &[ImportSpecifier],
922
+ from: &str,
923
+ ) -> Result<(), CompileError> {
924
+ let spec = rewrite_import_to_js(
925
+ from,
926
+ &self.module_path,
927
+ &self.project_root,
928
+ self.import_rewrite,
929
+ )?;
930
+ let mut named: Vec<String> = Vec::new();
931
+ let mut default_local: Option<String> = None;
932
+ let mut namespace_local: Option<String> = None;
933
+ for s in specifiers {
934
+ match s {
935
+ ImportSpecifier::Named { name, alias, .. } => match alias {
936
+ Some(a) => named.push(format!(
937
+ "{} as {}",
938
+ Self::escape_ident(name.as_ref()),
939
+ Self::escape_ident(a.as_ref())
940
+ )),
941
+ None => named.push(Self::escape_ident(name.as_ref())),
942
+ },
943
+ ImportSpecifier::Default { name, .. } => {
944
+ default_local = Some(Self::escape_ident(name.as_ref()))
945
+ }
946
+ ImportSpecifier::Namespace { name, .. } => {
947
+ namespace_local = Some(Self::escape_ident(name.as_ref()))
948
+ }
949
+ }
950
+ }
951
+ if let Some(ns) = namespace_local {
952
+ match &default_local {
953
+ Some(def) => self.writeln(&format!("import {}, * as {} from \"{}\";", def, ns, spec)),
954
+ None => self.writeln(&format!("import * as {} from \"{}\";", ns, spec)),
955
+ }
956
+ if !named.is_empty() {
957
+ self.writeln(&format!("import {{ {} }} from \"{}\";", named.join(", "), spec));
958
+ }
959
+ } else if !named.is_empty() {
960
+ match &default_local {
961
+ Some(def) => self.writeln(&format!(
962
+ "import {}, {{ {} }} from \"{}\";",
963
+ def,
964
+ named.join(", "),
965
+ spec
966
+ )),
967
+ None => self.writeln(&format!("import {{ {} }} from \"{}\";", named.join(", "), spec)),
968
+ }
969
+ } else if let Some(def) = &default_local {
970
+ self.writeln(&format!("import {} from \"{}\";", def, spec));
971
+ } else {
972
+ self.writeln(&format!("import \"{}\";", spec));
973
+ }
974
+ Ok(())
975
+ }
976
+
977
+ /// Emit a real ES `export` (ESM mode). Named exports prefix the inner declaration with the
978
+ /// `export` keyword; default exports become `export default <expr>;`.
979
+ fn emit_esm_export(&mut self, declaration: &ExportDeclaration) -> Result<(), CompileError> {
980
+ match declaration {
981
+ ExportDeclaration::Named(inner) => {
982
+ // Emit the inner declaration, then splice the `export ` keyword in front of it. The
983
+ // declaration's first line is `<indent><keyword> …`, so the keyword goes right after
984
+ // the leading indent.
985
+ let start = self.output.len();
986
+ self.emit_statement(inner)?;
987
+ let insert_at = start + self.indent_str().len();
988
+ if insert_at <= self.output.len() {
989
+ self.output.insert_str(insert_at, "export ");
990
+ }
991
+ }
992
+ ExportDeclaration::Default(e) => {
993
+ let v = self.emit_expr(e)?;
994
+ self.writeln(&format!("export default {};", v));
995
+ }
996
+ }
997
+ Ok(())
998
+ }
999
+ }
1000
+
1001
+ /// Rewrite a Tish import specifier to the ESM `.js` specifier that the emitted module tree uses.
1002
+ /// Relative specifiers keep their shape (the output tree mirrors the source tree) with `.tish`
1003
+ /// swapped to `.js`. Bare specifiers are resolved to a `.tish` file and re-expressed as a relative
1004
+ /// path from the importer's output location. Native imports (`tish:*`, `cargo:*`, …) are rejected.
1005
+ fn rewrite_import_to_js(
1006
+ spec: &str,
1007
+ importer_abs: &Path,
1008
+ project_root: &Path,
1009
+ rewrite: ImportRewrite,
1010
+ ) -> Result<String, CompileError> {
1011
+ if tishlang_compile::is_native_import(spec) {
1012
+ return Err(CompileError {
1013
+ message: format!(
1014
+ "Native module import '{}' is not supported with --target js --format esm (native modules require --target native).",
1015
+ spec
1016
+ ),
1017
+ });
1018
+ }
1019
+ // Vite dev keeps specifiers verbatim: relative `.tish` paths and bare packages are left for the
1020
+ // plugin's `resolveId`/`load` (relative) or Node/Vite resolution (bare) to handle per-module.
1021
+ if rewrite == ImportRewrite::ViteDev {
1022
+ return Ok(spec.to_string());
1023
+ }
1024
+ if spec.starts_with("./") || spec.starts_with("../") {
1025
+ return Ok(spec_ext_to_js(spec));
1026
+ }
1027
+ // Bare specifier (e.g. a package): resolve to its `.tish` file and express it relative to the
1028
+ // importer's output `.js`, so the emitted module tree stays self-contained.
1029
+ let from_dir = importer_abs.parent().unwrap_or_else(|| Path::new("."));
1030
+ let dep = tishlang_compile::resolve_bare_spec(spec, from_dir, project_root).ok_or_else(|| {
1031
+ CompileError {
1032
+ message: format!(
1033
+ "Cannot resolve package import '{}' for --format esm. Only relative imports and packages resolvable to a .tish entry are supported; use --format bundle otherwise.",
1034
+ spec
1035
+ ),
1036
+ }
1037
+ })?;
1038
+ // The emitted module tree mirrors the real source tree under a common base (see
1039
+ // `compile_project_esm`), so the relative path between two *absolute* canonical paths is the
1040
+ // same as the relative path between their emitted `.js` files. This holds whether the dependency
1041
+ // lives under the entry's project root, in a sibling package, or in `node_modules` (#282), so no
1042
+ // "outside the project root" rejection is needed.
1043
+ let dep_canon = dep.canonicalize().unwrap_or(dep);
1044
+ let importer_canon = importer_abs
1045
+ .canonicalize()
1046
+ .unwrap_or_else(|_| importer_abs.to_path_buf());
1047
+ let importer_dir = importer_canon.parent().unwrap_or_else(|| Path::new("/"));
1048
+ Ok(spec_ext_to_js(&relative_specifier(importer_dir, &dep_canon)))
1049
+ }
1050
+
1051
+ /// Swap a relative import specifier's `.tish` extension for `.js` (or append `.js` when extensionless).
1052
+ /// Already-`.js`/`.mjs` specifiers pass through unchanged.
1053
+ fn spec_ext_to_js(spec: &str) -> String {
1054
+ if let Some(base) = spec.strip_suffix(".tish") {
1055
+ format!("{}.js", base)
1056
+ } else if spec.ends_with(".js") || spec.ends_with(".mjs") {
1057
+ spec.to_string()
1058
+ } else {
1059
+ format!("{}.js", spec)
1060
+ }
1061
+ }
1062
+
1063
+ /// Build a `./`-prefixed relative module specifier from `from_dir` to `to_file`, compared
1064
+ /// component-wise, using `/` separators as ESM requires. Both paths must be expressed the same way
1065
+ /// (both absolute canonical, or both relative to the same base) so the shared prefix lines up.
1066
+ fn relative_specifier(from_dir: &Path, to_file: &Path) -> String {
1067
+ let from: Vec<String> = from_dir
1068
+ .components()
1069
+ .map(|c| c.as_os_str().to_string_lossy().into_owned())
1070
+ .collect();
1071
+ let to: Vec<String> = to_file
1072
+ .components()
1073
+ .map(|c| c.as_os_str().to_string_lossy().into_owned())
1074
+ .collect();
1075
+ let mut i = 0;
1076
+ while i < from.len() && i < to.len() && from[i] == to[i] {
1077
+ i += 1;
1078
+ }
1079
+ let mut parts: Vec<String> = Vec::new();
1080
+ for _ in i..from.len() {
1081
+ parts.push("..".to_string());
1082
+ }
1083
+ for c in &to[i..] {
1084
+ parts.push(c.clone());
1085
+ }
1086
+ let joined = parts.join("/");
1087
+ if joined.starts_with("..") {
1088
+ joined
1089
+ } else {
1090
+ format!("./{}", joined)
1091
+ }
862
1092
  }
863
1093
 
864
1094
  /// Compile a single program (no imports) to JavaScript. JSX lowers to `h` / `Fragment` (Lattish).
@@ -987,3 +1217,182 @@ pub fn compile_project_with_jsx(
987
1217
  };
988
1218
  Ok(compile_project_js_inner(entry_path, project_root, optimize, false, &out_name)?.js)
989
1219
  }
1220
+
1221
+ /// One emitted ES module: where it goes (relative to the output directory, mirroring the source
1222
+ /// tree) and its JavaScript. See [`compile_project_esm`].
1223
+ #[derive(Debug, Clone)]
1224
+ pub struct EmittedJsModule {
1225
+ pub relative_path: PathBuf,
1226
+ pub js: String,
1227
+ }
1228
+
1229
+ /// Compile a project to **ES modules** — one `.js` file per `.tish` module, with real `import` /
1230
+ /// `export` statements (issue #282). Unlike [`compile_project_with_jsx`], modules are NOT merged, so
1231
+ /// each keeps its own scope (no exported-name collisions) and a bundler can tree-shake the graph.
1232
+ /// Output paths mirror the source tree relative to the deepest directory common to every module in
1233
+ /// the graph (so sibling-package / `node_modules` deps are included), with `.tish` swapped to `.js`.
1234
+ pub fn compile_project_esm(
1235
+ entry_path: &Path,
1236
+ project_root: Option<&Path>,
1237
+ optimize: bool,
1238
+ ) -> Result<Vec<EmittedJsModule>, CompileError> {
1239
+ let modules = tishlang_compile::resolve_project(entry_path, project_root)
1240
+ .map_err(|e| CompileError { message: e })?;
1241
+ tishlang_compile::detect_cycles(&modules).map_err(|e| CompileError { message: e })?;
1242
+ // Resolution root: where bare-specifier / `node_modules` lookups are anchored. Unchanged
1243
+ // semantics — only used for `resolve_bare_spec`, which itself walks upward from each importer.
1244
+ let res_root = project_root
1245
+ .map(Path::to_path_buf)
1246
+ .or_else(|| entry_path.parent().map(Path::to_path_buf))
1247
+ .unwrap_or_else(|| PathBuf::from("."));
1248
+ let res_root_canon = res_root.canonicalize().unwrap_or(res_root);
1249
+
1250
+ // Output layout base: the deepest directory that is an ancestor of *every* module in the graph.
1251
+ // The output tree mirrors the real filesystem beneath this base, so a dependency in a sibling
1252
+ // package or `node_modules` (#282 follow-up) gets a stable home in the output instead of being
1253
+ // rejected for living "outside the project root". For a self-contained project this is just the
1254
+ // project root, so existing layouts are unchanged.
1255
+ let mod_canons: Vec<PathBuf> = modules
1256
+ .iter()
1257
+ .map(|m| m.path.canonicalize().unwrap_or_else(|_| m.path.clone()))
1258
+ .collect();
1259
+ let layout_base = common_ancestor_dir(&mod_canons);
1260
+
1261
+ let mut out = Vec::with_capacity(modules.len());
1262
+ for (module, mod_canon) in modules.iter().zip(mod_canons.iter()) {
1263
+ let rel = mod_canon
1264
+ .strip_prefix(&layout_base)
1265
+ .map(Path::to_path_buf)
1266
+ .unwrap_or_else(|_| {
1267
+ // `layout_base` is a common ancestor of every module, so this is unreachable; fall
1268
+ // back to the bare file name rather than panicking if a path is unexpectedly absolute.
1269
+ PathBuf::from(mod_canon.file_name().unwrap_or(mod_canon.as_os_str()))
1270
+ });
1271
+ let rel_js = rel.with_extension("js");
1272
+ let program = if optimize {
1273
+ tishlang_opt::optimize(&module.program)
1274
+ } else {
1275
+ module.program.clone()
1276
+ };
1277
+ let mut gen =
1278
+ Codegen::new_esm(mod_canon.clone(), res_root_canon.clone(), ImportRewrite::Disk);
1279
+ gen.emit_program(&program, None, None)?;
1280
+ out.push(EmittedJsModule {
1281
+ relative_path: rel_js,
1282
+ js: gen.output,
1283
+ });
1284
+ }
1285
+ Ok(out)
1286
+ }
1287
+
1288
+ /// Compile a **single** `.tish` module to one ES module (issue #284, Vite dev / HMR). Unlike
1289
+ /// [`compile_project_esm`], the dependency graph is **not** resolved — only `module_path` is read
1290
+ /// and parsed — so a Vite plugin can compile one file per `load()` and let Vite own the module
1291
+ /// graph. With `ImportRewrite::ViteDev` relative `.tish` specifiers and bare packages are preserved
1292
+ /// so Vite re-enters the plugin per dependency. When `source_map` is set, a v3 map back to the
1293
+ /// `.tish` source is returned (requires `optimize == false`, matching the bundle source-map rule).
1294
+ pub fn compile_module_esm(
1295
+ module_path: &Path,
1296
+ project_root: Option<&Path>,
1297
+ optimize: bool,
1298
+ import_rewrite: ImportRewrite,
1299
+ source_map: bool,
1300
+ ) -> Result<JsBundle, CompileError> {
1301
+ if source_map && optimize {
1302
+ return Err(CompileError {
1303
+ message: "source map requires no optimization (mappings follow unmerged statement order)."
1304
+ .into(),
1305
+ });
1306
+ }
1307
+ let source = std::fs::read_to_string(module_path).map_err(|e| CompileError {
1308
+ message: format!("Cannot read {}: {}", module_path.display(), e),
1309
+ })?;
1310
+ let parsed = tishlang_parser::parse(&source).map_err(|e| CompileError {
1311
+ message: format!("Parse error in {}: {}", module_path.display(), e),
1312
+ })?;
1313
+ let program = if optimize {
1314
+ tishlang_opt::optimize(&parsed)
1315
+ } else {
1316
+ parsed
1317
+ };
1318
+ let module_canon = module_path
1319
+ .canonicalize()
1320
+ .unwrap_or_else(|_| module_path.to_path_buf());
1321
+ let root = project_root
1322
+ .map(Path::to_path_buf)
1323
+ .or_else(|| module_path.parent().map(Path::to_path_buf))
1324
+ .unwrap_or_else(|| PathBuf::from("."));
1325
+ let root_canon = root.canonicalize().unwrap_or(root);
1326
+
1327
+ let mut gen = Codegen::new_esm(module_canon.clone(), root_canon.clone(), import_rewrite);
1328
+ if source_map {
1329
+ let stmt_sources = vec![module_canon.clone(); program.statements.len()];
1330
+ let mut builder = SourceMapBuilder::new(Some("module.js"));
1331
+ builder.set_source_root(Some(""));
1332
+ gen.emit_program(
1333
+ &program,
1334
+ Some((stmt_sources.as_slice(), root_canon.as_path())),
1335
+ Some(&mut builder),
1336
+ )?;
1337
+ let mut sm = builder.into_sourcemap();
1338
+ // Embed the original `.tish` so consumers (Vite, browser devtools) never resolve
1339
+ // `sources` from disk. The map's `sources` are project-root-relative, but Vite resolves
1340
+ // them against the module's own directory; without inline content it logs
1341
+ // "Sourcemap ... points to missing source files" and devtools can't show the original.
1342
+ let source_count = sm.get_source_count();
1343
+ for id in 0..source_count {
1344
+ sm.set_source_contents(id, Some(&source));
1345
+ }
1346
+ let mut v = Vec::new();
1347
+ sm.to_writer(&mut v).map_err(|e| CompileError {
1348
+ message: e.to_string(),
1349
+ })?;
1350
+ let map_json = String::from_utf8(v).map_err(|e| CompileError {
1351
+ message: e.to_string(),
1352
+ })?;
1353
+ Ok(JsBundle {
1354
+ js: gen.output,
1355
+ source_map_json: Some(map_json),
1356
+ })
1357
+ } else {
1358
+ gen.emit_program(&program, None, None)?;
1359
+ Ok(JsBundle {
1360
+ js: gen.output,
1361
+ source_map_json: None,
1362
+ })
1363
+ }
1364
+ }
1365
+
1366
+ /// Deepest directory that is an ancestor of every given file path, compared component-wise. Used as
1367
+ /// the base of the `--format esm` output tree so modules outside the entry's project root (sibling
1368
+ /// packages, `node_modules`) still map to a stable, collision-free location. For a single file this
1369
+ /// is its parent directory; for files that share only the filesystem root it is `/`.
1370
+ fn common_ancestor_dir(files: &[PathBuf]) -> PathBuf {
1371
+ let parent_components = |p: &Path| -> Vec<std::ffi::OsString> {
1372
+ p.parent()
1373
+ .unwrap_or_else(|| Path::new("/"))
1374
+ .components()
1375
+ .map(|c| c.as_os_str().to_os_string())
1376
+ .collect()
1377
+ };
1378
+ let mut common: Vec<std::ffi::OsString> = match files.first() {
1379
+ Some(f) => parent_components(f),
1380
+ None => return PathBuf::from("/"),
1381
+ };
1382
+ for f in &files[1..] {
1383
+ let comps = parent_components(f);
1384
+ let mut i = 0;
1385
+ while i < common.len() && i < comps.len() && common[i] == comps[i] {
1386
+ i += 1;
1387
+ }
1388
+ common.truncate(i);
1389
+ }
1390
+ if common.is_empty() {
1391
+ return PathBuf::from("/");
1392
+ }
1393
+ let mut base = PathBuf::new();
1394
+ for c in &common {
1395
+ base.push(c);
1396
+ }
1397
+ base
1398
+ }
@@ -8,7 +8,9 @@ mod error;
8
8
  mod tests_jsx;
9
9
 
10
10
  pub use codegen::{
11
- compile_project_with_jsx, compile_project_with_jsx_and_source_map, compile_with_jsx, 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,
12
14
  };
13
15
  pub use error::CompileError;
14
16