@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.
@@ -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,32 @@ 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
+
13
23
  struct Codegen {
14
24
  output: String,
15
25
  indent: usize,
16
26
  in_async: bool,
27
+ emit_mode: EmitMode,
28
+ /// ESM only: the absolute path of the module being emitted (the importer), used to rewrite
29
+ /// relative/bare import specifiers to sibling `.js` output paths.
30
+ module_path: PathBuf,
31
+ /// ESM only: the project root, so import targets can be located relative to the output tree.
32
+ project_root: PathBuf,
17
33
  }
18
34
 
19
35
  fn stmt_terminates_switch(stmt: Option<&Statement>) -> bool {
@@ -31,6 +47,20 @@ impl Codegen {
31
47
  output: String::new(),
32
48
  indent: 0,
33
49
  in_async: false,
50
+ emit_mode: EmitMode::Bundle,
51
+ module_path: PathBuf::new(),
52
+ project_root: PathBuf::new(),
53
+ }
54
+ }
55
+
56
+ fn new_esm(module_path: PathBuf, project_root: PathBuf) -> Self {
57
+ Self {
58
+ output: String::new(),
59
+ indent: 0,
60
+ in_async: false,
61
+ emit_mode: EmitMode::Esm,
62
+ module_path,
63
+ project_root,
34
64
  }
35
65
  }
36
66
 
@@ -398,9 +428,18 @@ impl Codegen {
398
428
  }
399
429
  self.writeln("}");
400
430
  }
401
- Statement::Import { .. } | Statement::Export { .. } => {
402
- // Resolved away by merge_modules
403
- }
431
+ Statement::Import {
432
+ specifiers, from, ..
433
+ } => match self.emit_mode {
434
+ // Bundle: resolved away by merge_modules (the dep bindings are already inlined).
435
+ EmitMode::Bundle => {}
436
+ EmitMode::Esm => self.emit_esm_import(specifiers, from.as_ref())?,
437
+ },
438
+ Statement::Export { declaration, .. } => match self.emit_mode {
439
+ // Bundle: merge_modules unwrapped exports into plain top-level declarations.
440
+ EmitMode::Bundle => {}
441
+ EmitMode::Esm => self.emit_esm_export(declaration)?,
442
+ },
404
443
  Statement::TypeAlias { .. }
405
444
  | Statement::DeclareVar { .. }
406
445
  | Statement::DeclareFun { .. } => {}
@@ -859,6 +898,172 @@ impl Codegen {
859
898
  CallArg::Spread(e) => Ok(format!("...{}", self.emit_expr(e)?)),
860
899
  }
861
900
  }
901
+
902
+ /// Emit a real ES `import` statement (ESM mode), rewriting the `.tish` specifier to its sibling
903
+ /// `.js` output path. Named and namespace specifiers can't share one statement, so a module that
904
+ /// imports both is split across two lines.
905
+ fn emit_esm_import(
906
+ &mut self,
907
+ specifiers: &[ImportSpecifier],
908
+ from: &str,
909
+ ) -> Result<(), CompileError> {
910
+ let spec = rewrite_import_to_js(from, &self.module_path, &self.project_root)?;
911
+ let mut named: Vec<String> = Vec::new();
912
+ let mut default_local: Option<String> = None;
913
+ let mut namespace_local: Option<String> = None;
914
+ for s in specifiers {
915
+ match s {
916
+ ImportSpecifier::Named { name, alias, .. } => match alias {
917
+ Some(a) => named.push(format!(
918
+ "{} as {}",
919
+ Self::escape_ident(name.as_ref()),
920
+ Self::escape_ident(a.as_ref())
921
+ )),
922
+ None => named.push(Self::escape_ident(name.as_ref())),
923
+ },
924
+ ImportSpecifier::Default { name, .. } => {
925
+ default_local = Some(Self::escape_ident(name.as_ref()))
926
+ }
927
+ ImportSpecifier::Namespace { name, .. } => {
928
+ namespace_local = Some(Self::escape_ident(name.as_ref()))
929
+ }
930
+ }
931
+ }
932
+ if let Some(ns) = namespace_local {
933
+ match &default_local {
934
+ Some(def) => self.writeln(&format!("import {}, * as {} from \"{}\";", def, ns, spec)),
935
+ None => self.writeln(&format!("import * as {} from \"{}\";", ns, spec)),
936
+ }
937
+ if !named.is_empty() {
938
+ self.writeln(&format!("import {{ {} }} from \"{}\";", named.join(", "), spec));
939
+ }
940
+ } else if !named.is_empty() {
941
+ match &default_local {
942
+ Some(def) => self.writeln(&format!(
943
+ "import {}, {{ {} }} from \"{}\";",
944
+ def,
945
+ named.join(", "),
946
+ spec
947
+ )),
948
+ None => self.writeln(&format!("import {{ {} }} from \"{}\";", named.join(", "), spec)),
949
+ }
950
+ } else if let Some(def) = &default_local {
951
+ self.writeln(&format!("import {} from \"{}\";", def, spec));
952
+ } else {
953
+ self.writeln(&format!("import \"{}\";", spec));
954
+ }
955
+ Ok(())
956
+ }
957
+
958
+ /// Emit a real ES `export` (ESM mode). Named exports prefix the inner declaration with the
959
+ /// `export` keyword; default exports become `export default <expr>;`.
960
+ fn emit_esm_export(&mut self, declaration: &ExportDeclaration) -> Result<(), CompileError> {
961
+ match declaration {
962
+ ExportDeclaration::Named(inner) => {
963
+ // Emit the inner declaration, then splice the `export ` keyword in front of it. The
964
+ // declaration's first line is `<indent><keyword> …`, so the keyword goes right after
965
+ // the leading indent.
966
+ let start = self.output.len();
967
+ self.emit_statement(inner)?;
968
+ let insert_at = start + self.indent_str().len();
969
+ if insert_at <= self.output.len() {
970
+ self.output.insert_str(insert_at, "export ");
971
+ }
972
+ }
973
+ ExportDeclaration::Default(e) => {
974
+ let v = self.emit_expr(e)?;
975
+ self.writeln(&format!("export default {};", v));
976
+ }
977
+ }
978
+ Ok(())
979
+ }
980
+ }
981
+
982
+ /// Rewrite a Tish import specifier to the ESM `.js` specifier that the emitted module tree uses.
983
+ /// Relative specifiers keep their shape (the output tree mirrors the source tree) with `.tish`
984
+ /// swapped to `.js`. Bare specifiers are resolved to a `.tish` file and re-expressed as a relative
985
+ /// path from the importer's output location. Native imports (`tish:*`, `cargo:*`, …) are rejected.
986
+ fn rewrite_import_to_js(
987
+ spec: &str,
988
+ importer_abs: &Path,
989
+ project_root: &Path,
990
+ ) -> Result<String, CompileError> {
991
+ if tishlang_compile::is_native_import(spec) {
992
+ return Err(CompileError {
993
+ message: format!(
994
+ "Native module import '{}' is not supported with --target js --format esm (native modules require --target native).",
995
+ spec
996
+ ),
997
+ });
998
+ }
999
+ if spec.starts_with("./") || spec.starts_with("../") {
1000
+ return Ok(spec_ext_to_js(spec));
1001
+ }
1002
+ // Bare specifier (e.g. a package): resolve to its `.tish` file and express it relative to the
1003
+ // importer's output `.js`, so the emitted module tree stays self-contained.
1004
+ let from_dir = importer_abs.parent().unwrap_or_else(|| Path::new("."));
1005
+ let dep = tishlang_compile::resolve_bare_spec(spec, from_dir, project_root).ok_or_else(|| {
1006
+ CompileError {
1007
+ message: format!(
1008
+ "Cannot resolve package import '{}' for --format esm. Only relative imports and packages resolvable to a .tish entry are supported; use --format bundle otherwise.",
1009
+ spec
1010
+ ),
1011
+ }
1012
+ })?;
1013
+ // The emitted module tree mirrors the real source tree under a common base (see
1014
+ // `compile_project_esm`), so the relative path between two *absolute* canonical paths is the
1015
+ // same as the relative path between their emitted `.js` files. This holds whether the dependency
1016
+ // lives under the entry's project root, in a sibling package, or in `node_modules` (#282), so no
1017
+ // "outside the project root" rejection is needed.
1018
+ let dep_canon = dep.canonicalize().unwrap_or(dep);
1019
+ let importer_canon = importer_abs
1020
+ .canonicalize()
1021
+ .unwrap_or_else(|_| importer_abs.to_path_buf());
1022
+ let importer_dir = importer_canon.parent().unwrap_or_else(|| Path::new("/"));
1023
+ Ok(spec_ext_to_js(&relative_specifier(importer_dir, &dep_canon)))
1024
+ }
1025
+
1026
+ /// Swap a relative import specifier's `.tish` extension for `.js` (or append `.js` when extensionless).
1027
+ /// Already-`.js`/`.mjs` specifiers pass through unchanged.
1028
+ fn spec_ext_to_js(spec: &str) -> String {
1029
+ if let Some(base) = spec.strip_suffix(".tish") {
1030
+ format!("{}.js", base)
1031
+ } else if spec.ends_with(".js") || spec.ends_with(".mjs") {
1032
+ spec.to_string()
1033
+ } else {
1034
+ format!("{}.js", spec)
1035
+ }
1036
+ }
1037
+
1038
+ /// Build a `./`-prefixed relative module specifier from `from_dir` to `to_file`, compared
1039
+ /// component-wise, using `/` separators as ESM requires. Both paths must be expressed the same way
1040
+ /// (both absolute canonical, or both relative to the same base) so the shared prefix lines up.
1041
+ fn relative_specifier(from_dir: &Path, to_file: &Path) -> String {
1042
+ let from: Vec<String> = from_dir
1043
+ .components()
1044
+ .map(|c| c.as_os_str().to_string_lossy().into_owned())
1045
+ .collect();
1046
+ let to: Vec<String> = to_file
1047
+ .components()
1048
+ .map(|c| c.as_os_str().to_string_lossy().into_owned())
1049
+ .collect();
1050
+ let mut i = 0;
1051
+ while i < from.len() && i < to.len() && from[i] == to[i] {
1052
+ i += 1;
1053
+ }
1054
+ let mut parts: Vec<String> = Vec::new();
1055
+ for _ in i..from.len() {
1056
+ parts.push("..".to_string());
1057
+ }
1058
+ for c in &to[i..] {
1059
+ parts.push(c.clone());
1060
+ }
1061
+ let joined = parts.join("/");
1062
+ if joined.starts_with("..") {
1063
+ joined
1064
+ } else {
1065
+ format!("./{}", joined)
1066
+ }
862
1067
  }
863
1068
 
864
1069
  /// Compile a single program (no imports) to JavaScript. JSX lowers to `h` / `Fragment` (Lattish).
@@ -987,3 +1192,103 @@ pub fn compile_project_with_jsx(
987
1192
  };
988
1193
  Ok(compile_project_js_inner(entry_path, project_root, optimize, false, &out_name)?.js)
989
1194
  }
1195
+
1196
+ /// One emitted ES module: where it goes (relative to the output directory, mirroring the source
1197
+ /// tree) and its JavaScript. See [`compile_project_esm`].
1198
+ #[derive(Debug, Clone)]
1199
+ pub struct EmittedJsModule {
1200
+ pub relative_path: PathBuf,
1201
+ pub js: String,
1202
+ }
1203
+
1204
+ /// Compile a project to **ES modules** — one `.js` file per `.tish` module, with real `import` /
1205
+ /// `export` statements (issue #282). Unlike [`compile_project_with_jsx`], modules are NOT merged, so
1206
+ /// each keeps its own scope (no exported-name collisions) and a bundler can tree-shake the graph.
1207
+ /// Output paths mirror the source tree relative to the deepest directory common to every module in
1208
+ /// the graph (so sibling-package / `node_modules` deps are included), with `.tish` swapped to `.js`.
1209
+ pub fn compile_project_esm(
1210
+ entry_path: &Path,
1211
+ project_root: Option<&Path>,
1212
+ optimize: bool,
1213
+ ) -> Result<Vec<EmittedJsModule>, CompileError> {
1214
+ let modules = tishlang_compile::resolve_project(entry_path, project_root)
1215
+ .map_err(|e| CompileError { message: e })?;
1216
+ tishlang_compile::detect_cycles(&modules).map_err(|e| CompileError { message: e })?;
1217
+ // Resolution root: where bare-specifier / `node_modules` lookups are anchored. Unchanged
1218
+ // semantics — only used for `resolve_bare_spec`, which itself walks upward from each importer.
1219
+ let res_root = project_root
1220
+ .map(Path::to_path_buf)
1221
+ .or_else(|| entry_path.parent().map(Path::to_path_buf))
1222
+ .unwrap_or_else(|| PathBuf::from("."));
1223
+ let res_root_canon = res_root.canonicalize().unwrap_or(res_root);
1224
+
1225
+ // Output layout base: the deepest directory that is an ancestor of *every* module in the graph.
1226
+ // The output tree mirrors the real filesystem beneath this base, so a dependency in a sibling
1227
+ // package or `node_modules` (#282 follow-up) gets a stable home in the output instead of being
1228
+ // rejected for living "outside the project root". For a self-contained project this is just the
1229
+ // project root, so existing layouts are unchanged.
1230
+ let mod_canons: Vec<PathBuf> = modules
1231
+ .iter()
1232
+ .map(|m| m.path.canonicalize().unwrap_or_else(|_| m.path.clone()))
1233
+ .collect();
1234
+ let layout_base = common_ancestor_dir(&mod_canons);
1235
+
1236
+ let mut out = Vec::with_capacity(modules.len());
1237
+ for (module, mod_canon) in modules.iter().zip(mod_canons.iter()) {
1238
+ let rel = mod_canon
1239
+ .strip_prefix(&layout_base)
1240
+ .map(Path::to_path_buf)
1241
+ .unwrap_or_else(|_| {
1242
+ // `layout_base` is a common ancestor of every module, so this is unreachable; fall
1243
+ // back to the bare file name rather than panicking if a path is unexpectedly absolute.
1244
+ PathBuf::from(mod_canon.file_name().unwrap_or(mod_canon.as_os_str()))
1245
+ });
1246
+ let rel_js = rel.with_extension("js");
1247
+ let program = if optimize {
1248
+ tishlang_opt::optimize(&module.program)
1249
+ } else {
1250
+ module.program.clone()
1251
+ };
1252
+ let mut gen = Codegen::new_esm(mod_canon.clone(), res_root_canon.clone());
1253
+ gen.emit_program(&program, None, None)?;
1254
+ out.push(EmittedJsModule {
1255
+ relative_path: rel_js,
1256
+ js: gen.output,
1257
+ });
1258
+ }
1259
+ Ok(out)
1260
+ }
1261
+
1262
+ /// Deepest directory that is an ancestor of every given file path, compared component-wise. Used as
1263
+ /// the base of the `--format esm` output tree so modules outside the entry's project root (sibling
1264
+ /// packages, `node_modules`) still map to a stable, collision-free location. For a single file this
1265
+ /// is its parent directory; for files that share only the filesystem root it is `/`.
1266
+ fn common_ancestor_dir(files: &[PathBuf]) -> PathBuf {
1267
+ let parent_components = |p: &Path| -> Vec<std::ffi::OsString> {
1268
+ p.parent()
1269
+ .unwrap_or_else(|| Path::new("/"))
1270
+ .components()
1271
+ .map(|c| c.as_os_str().to_os_string())
1272
+ .collect()
1273
+ };
1274
+ let mut common: Vec<std::ffi::OsString> = match files.first() {
1275
+ Some(f) => parent_components(f),
1276
+ None => return PathBuf::from("/"),
1277
+ };
1278
+ for f in &files[1..] {
1279
+ let comps = parent_components(f);
1280
+ let mut i = 0;
1281
+ while i < common.len() && i < comps.len() && common[i] == comps[i] {
1282
+ i += 1;
1283
+ }
1284
+ common.truncate(i);
1285
+ }
1286
+ if common.is_empty() {
1287
+ return PathBuf::from("/");
1288
+ }
1289
+ let mut base = PathBuf::new();
1290
+ for c in &common {
1291
+ base.push(c);
1292
+ }
1293
+ base
1294
+ }
@@ -8,7 +8,8 @@ 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_project_esm, compile_project_with_jsx, compile_project_with_jsx_and_source_map,
12
+ compile_with_jsx, EmittedJsModule, JsBundle,
12
13
  };
13
14
  pub use error::CompileError;
14
15
 
@@ -4,7 +4,7 @@ mod tests {
4
4
 
5
5
  use tishlang_parser::parse;
6
6
 
7
- use crate::{compile_project_with_jsx, compile_with_jsx};
7
+ use crate::{compile_project_esm, compile_project_with_jsx, compile_with_jsx, EmittedJsModule};
8
8
 
9
9
  #[test]
10
10
  fn lattish_jsx_emits_h_with_children_array() {
@@ -455,4 +455,188 @@ fn factory() {
455
455
  let neg = crate::compile(&parse("console.log(-1 / 0)\n").unwrap(), true).unwrap();
456
456
  assert!(neg.contains("-Infinity"), "-1/0 must emit -Infinity:\n{neg}");
457
457
  }
458
+
459
+ // ── #282: ESM module output (one file per module, real import/export) ──────────────────────
460
+
461
+ /// Write a set of `(relative_path, source)` modules into a fresh temp dir and compile the entry
462
+ /// in ESM mode. Returns the emitted modules keyed for easy lookup by their output relative path.
463
+ fn build_esm(entry: &str, modules: &[(&str, &str)]) -> Vec<EmittedJsModule> {
464
+ let tmp = tempfile::tempdir().expect("tempdir");
465
+ let dir = tmp.path();
466
+ for (rel, src) in modules {
467
+ let p = dir.join(rel);
468
+ if let Some(parent) = p.parent() {
469
+ std::fs::create_dir_all(parent).unwrap();
470
+ }
471
+ let mut f = std::fs::File::create(&p).unwrap();
472
+ f.write_all(src.as_bytes()).unwrap();
473
+ f.sync_all().unwrap();
474
+ }
475
+ compile_project_esm(&dir.join(entry), Some(dir), false).expect("compile_project_esm failed")
476
+ }
477
+
478
+ fn module_js<'a>(mods: &'a [EmittedJsModule], rel: &str) -> &'a str {
479
+ mods.iter()
480
+ .find(|m| m.relative_path.to_string_lossy() == rel)
481
+ .map(|m| m.js.as_str())
482
+ .unwrap_or_else(|| panic!("module {rel} not emitted; got {:?}", mods.iter().map(|m| m.relative_path.display().to_string()).collect::<Vec<_>>()))
483
+ }
484
+
485
+ #[test]
486
+ fn esm_emits_named_and_default_exports() {
487
+ let mods = build_esm(
488
+ "main.tish",
489
+ &[(
490
+ "main.tish",
491
+ "export const VERSION = \"1.0\"\nexport fn greet(n) { return \"hi \" + n }\nexport default greet\n",
492
+ )],
493
+ );
494
+ let js = module_js(&mods, "main.js");
495
+ assert!(js.contains("export const VERSION = \"1.0\";"), "named const export:\n{js}");
496
+ assert!(js.contains("export function greet "), "named fn export:\n{js}");
497
+ assert!(js.contains("export default greet;"), "default export:\n{js}");
498
+ }
499
+
500
+ #[test]
501
+ fn esm_rewrites_import_specifier_to_js_with_alias() {
502
+ let mods = build_esm(
503
+ "main.tish",
504
+ &[
505
+ ("dep.tish", "export const ssrH = 42\nexport fn greet(n) { return n }\n"),
506
+ (
507
+ "main.tish",
508
+ "import { ssrH as h, greet } from \"./dep.tish\"\nimport * as M from \"./dep.tish\"\nconsole.log(h)\nconsole.log(greet(M.ssrH))\n",
509
+ ),
510
+ ],
511
+ );
512
+ let js = module_js(&mods, "main.js");
513
+ assert!(
514
+ js.contains("import { ssrH as h, greet } from \"./dep.js\";"),
515
+ "named import with alias, .tish->.js:\n{js}"
516
+ );
517
+ assert!(js.contains("import * as M from \"./dep.js\";"), "namespace import:\n{js}");
518
+ }
519
+
520
+ #[test]
521
+ fn esm_one_file_per_module_preserves_tree() {
522
+ let mods = build_esm(
523
+ "main.tish",
524
+ &[
525
+ ("lib/util.tish", "export fn id(x) { return x }\n"),
526
+ ("main.tish", "import { id } from \"./lib/util.tish\"\nconsole.log(id(1))\n"),
527
+ ],
528
+ );
529
+ // Nested module keeps its relative path; importer points at the nested `.js`.
530
+ let _ = module_js(&mods, "lib/util.js");
531
+ let main = module_js(&mods, "main.js");
532
+ assert!(
533
+ main.contains("from \"./lib/util.js\";"),
534
+ "nested relative import preserved:\n{main}"
535
+ );
536
+ }
537
+
538
+ #[test]
539
+ fn esm_module_outside_project_root_is_emitted() {
540
+ // #282 follow-up: a dependency in a *sibling* package (outside the entry's project root)
541
+ // must still be emitted — the output tree is rooted at the directory common to all modules.
542
+ let tmp = tempfile::tempdir().expect("tempdir");
543
+ let base = tmp.path();
544
+ std::fs::create_dir_all(base.join("app/src")).unwrap();
545
+ std::fs::create_dir_all(base.join("lib")).unwrap();
546
+ std::fs::write(base.join("lib/util.tish"), "export fn id(x) { return x }\n").unwrap();
547
+ std::fs::write(
548
+ base.join("app/src/main.tish"),
549
+ "import { id } from \"../../lib/util.tish\"\nconsole.log(id(1))\n",
550
+ )
551
+ .unwrap();
552
+ // Project root is the entry's package (`app`); `lib/util.tish` lives outside it.
553
+ let mods = compile_project_esm(
554
+ &base.join("app/src/main.tish"),
555
+ Some(&base.join("app")),
556
+ false,
557
+ )
558
+ .expect("compile_project_esm failed for sibling dep");
559
+ let rels: Vec<String> = mods
560
+ .iter()
561
+ .map(|m| m.relative_path.to_string_lossy().replace('\\', "/"))
562
+ .collect();
563
+ let main_js = mods
564
+ .iter()
565
+ .find(|m| m.relative_path.to_string_lossy().replace('\\', "/").ends_with("app/src/main.js"))
566
+ .map(|m| m.js.clone());
567
+ assert!(
568
+ rels.iter().any(|r| r.ends_with("lib/util.js")),
569
+ "sibling dep emitted under common base: {:?}",
570
+ rels
571
+ );
572
+ assert!(
573
+ rels.iter().any(|r| r.ends_with("app/src/main.js")),
574
+ "entry emitted under its own subtree: {:?}",
575
+ rels
576
+ );
577
+ let main_js = main_js.expect("entry module present");
578
+ assert!(
579
+ main_js.contains("from \"../../lib/util.js\";"),
580
+ "relative import to sibling rewritten to .js:\n{main_js}"
581
+ );
582
+ }
583
+
584
+ #[test]
585
+ fn esm_bare_node_modules_dep_is_emitted() {
586
+ // #282 follow-up: a bare specifier resolved from `node_modules` (like `lattish`) is emitted
587
+ // into the output tree and the importer points at it with a relative `.js` specifier.
588
+ let tmp = tempfile::tempdir().expect("tempdir");
589
+ let base = tmp.path();
590
+ std::fs::create_dir_all(base.join("src")).unwrap();
591
+ std::fs::create_dir_all(base.join("node_modules/pkg")).unwrap();
592
+ std::fs::write(
593
+ base.join("node_modules/pkg/package.json"),
594
+ "{\"name\":\"pkg\",\"main\":\"index.tish\"}\n",
595
+ )
596
+ .unwrap();
597
+ std::fs::write(
598
+ base.join("node_modules/pkg/index.tish"),
599
+ "export fn ping() { return \"pong\" }\n",
600
+ )
601
+ .unwrap();
602
+ std::fs::write(
603
+ base.join("src/main.tish"),
604
+ "import { ping } from \"pkg\"\nconsole.log(ping())\n",
605
+ )
606
+ .unwrap();
607
+ let mods = compile_project_esm(&base.join("src/main.tish"), Some(base), false)
608
+ .expect("compile_project_esm failed for node_modules dep");
609
+ let rels: Vec<String> = mods
610
+ .iter()
611
+ .map(|m| m.relative_path.to_string_lossy().replace('\\', "/"))
612
+ .collect();
613
+ let main_js = mods
614
+ .iter()
615
+ .find(|m| m.relative_path.to_string_lossy().replace('\\', "/").ends_with("src/main.js"))
616
+ .map(|m| m.js.clone());
617
+ assert!(
618
+ rels.iter().any(|r| r.ends_with("node_modules/pkg/index.js")),
619
+ "node_modules dep emitted: {:?}",
620
+ rels
621
+ );
622
+ let main_js = main_js.expect("entry module present");
623
+ assert!(
624
+ main_js.contains("from \"../node_modules/pkg/index.js\";"),
625
+ "bare specifier rewritten to relative .js path:\n{main_js}"
626
+ );
627
+ }
628
+
629
+ #[test]
630
+ fn esm_rejects_native_imports() {
631
+ let tmp = tempfile::tempdir().expect("tempdir");
632
+ let dir = tmp.path();
633
+ let p = dir.join("main.tish");
634
+ 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();
636
+ assert!(
637
+ err.message.contains("Native module import") && err.message.contains("esm"),
638
+ "expected a native-import rejection for ESM, got: {}",
639
+ err.message
640
+ );
641
+ }
458
642
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tishlang/tish",
3
- "version": "2.8.0",
3
+ "version": "2.9.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