@tishlang/tish 2.2.7 → 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.
Files changed (55) hide show
  1. package/bin/tish +0 -0
  2. package/crates/js_to_tish/src/transform/expr.rs +5 -1
  3. package/crates/tish/Cargo.toml +2 -1
  4. package/crates/tish/src/cli_help.rs +15 -0
  5. package/crates/tish/src/main.rs +139 -12
  6. package/crates/tish/tests/integration_test.rs +65 -0
  7. package/crates/tish_ast/src/ast.rs +6 -2
  8. package/crates/tish_builtins/src/array.rs +82 -1
  9. package/crates/tish_builtins/src/globals.rs +50 -16
  10. package/crates/tish_builtins/src/math.rs +63 -9
  11. package/crates/tish_builtins/src/number.rs +20 -1
  12. package/crates/tish_builtins/src/string.rs +68 -4
  13. package/crates/tish_builtins/src/typedarrays.rs +23 -16
  14. package/crates/tish_bytecode/src/compiler.rs +94 -28
  15. package/crates/tish_bytecode/tests/break_continue_bytecode.rs +19 -0
  16. package/crates/tish_compile/src/check.rs +1 -1
  17. package/crates/tish_compile/src/codegen.rs +3133 -582
  18. package/crates/tish_compile/src/infer.rs +1950 -56
  19. package/crates/tish_compile/src/lib.rs +38 -0
  20. package/crates/tish_compile/src/resolve.rs +3 -2
  21. package/crates/tish_compile/src/types.rs +17 -0
  22. package/crates/tish_compile_js/Cargo.toml +3 -0
  23. package/crates/tish_compile_js/src/codegen.rs +365 -9
  24. package/crates/tish_compile_js/src/lib.rs +2 -1
  25. package/crates/tish_compile_js/src/tests_jsx.rs +229 -1
  26. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +1 -0
  27. package/crates/tish_core/Cargo.toml +2 -0
  28. package/crates/tish_core/src/json.rs +135 -34
  29. package/crates/tish_core/src/lib.rs +23 -1
  30. package/crates/tish_core/src/value.rs +64 -11
  31. package/crates/tish_eval/src/eval.rs +144 -197
  32. package/crates/tish_eval/src/natives.rs +45 -45
  33. package/crates/tish_eval/src/regex.rs +35 -27
  34. package/crates/tish_eval/src/value.rs +8 -0
  35. package/crates/tish_fmt/src/lib.rs +45 -1
  36. package/crates/tish_lint/src/bin/tish-lint.rs +17 -8
  37. package/crates/tish_lint/src/lib.rs +197 -21
  38. package/crates/tish_lsp/Cargo.toml +6 -0
  39. package/crates/tish_lsp/src/import_goto.rs +52 -7
  40. package/crates/tish_lsp/src/main.rs +856 -140
  41. package/crates/tish_opt/src/lib.rs +5 -3
  42. package/crates/tish_parser/src/lib.rs +23 -5
  43. package/crates/tish_parser/src/parser.rs +15 -26
  44. package/crates/tish_resolve/src/lib.rs +188 -18
  45. package/crates/tish_runtime/src/lib.rs +58 -18
  46. package/crates/tish_ui/src/jsx.rs +2 -2
  47. package/crates/tish_vm/src/jit.rs +212 -10
  48. package/crates/tish_vm/src/vm.rs +39 -18
  49. package/crates/tish_wasm/src/lib.rs +116 -22
  50. package/package.json +1 -1
  51. package/platform/darwin-arm64/tish +0 -0
  52. package/platform/darwin-x64/tish +0 -0
  53. package/platform/linux-arm64/tish +0 -0
  54. package/platform/linux-x64/tish +0 -0
  55. package/platform/win32-x64/tish.exe +0 -0
@@ -186,6 +186,44 @@ fn factory() {
186
186
  "expected outerVar to be inferred as f64 (Copy, no clone needed)"
187
187
  );
188
188
  }
189
+
190
+ /// i32-loop-var lowering: an FNV-style integer/bitwise hash accumulator declared before a `for`
191
+ /// and reassigned only by `>>> 0`/bitwise ops lives in an `i32` register across the loop — no
192
+ /// per-op `to_int32(h)`↔`f64` round-trip — with a single `f64` excursion for the `h * C`
193
+ /// multiply (which exceeds 2^53, so it must round in f64 before `>>> 0`, exactly as V8 does).
194
+ #[test]
195
+ fn fnv_accumulator_lowers_to_i32_register() {
196
+ let src = r#"
197
+ let h = 2166136261
198
+ for (let i = 0; i < 100; i++) {
199
+ h = h ^ (i & 255)
200
+ h = (h * 16777619) >>> 0
201
+ h = ((h << 13) | (h >>> 19)) >>> 0
202
+ }
203
+ let check = h >>> 0
204
+ console.log(check)
205
+ "#;
206
+ let rust = compile(&parse(src).unwrap()).unwrap();
207
+ // The accumulator is an i32 register, initialized via the u32 reinterpretation so the
208
+ // > i32::MAX seed keeps its JS ToInt32 bit-pattern.
209
+ assert!(
210
+ rust.contains("let mut h: i32 = (2166136261u32) as i32;"),
211
+ "expected `h` to be an i32 register seeded via u32 reinterpretation; got:\n{}",
212
+ rust.lines().filter(|l| l.contains("h")).take(8).collect::<Vec<_>>().join("\n")
213
+ );
214
+ // The per-iteration `to_int32(h)` round-trips are gone: `h` is read straight from the
215
+ // register inside the bitwise chain (no `to_int32(h)` substring referencing the accumulator).
216
+ assert!(
217
+ !rust.contains("to_int32(h)"),
218
+ "expected NO per-op `to_int32(h)` round-trip on the i32 accumulator"
219
+ );
220
+ // The only f64 excursion is the multiply, lowered as an unchecked truncation of the
221
+ // provably-finite product (`h as f64 * 16777619`).
222
+ assert!(
223
+ rust.contains(".to_int_unchecked::<i64>()") && rust.contains("16777619"),
224
+ "expected the `h * 16777619` excursion to lower to an unchecked f64 truncation"
225
+ );
226
+ }
189
227
  }
190
228
 
191
229
  #[cfg(test)]
@@ -173,7 +173,7 @@ pub fn program_uses_document(program: &Program) -> bool {
173
173
  ArrayElement::Expr(e) | ArrayElement::Spread(e) => expr_uses_document(e),
174
174
  }),
175
175
  Expr::Object { props, .. } => props.iter().any(|p| match p {
176
- ObjectProp::KeyValue(_, e) | ObjectProp::Spread(e) => expr_uses_document(e),
176
+ ObjectProp::KeyValue(_, e, _) | ObjectProp::Spread(e) => expr_uses_document(e),
177
177
  }),
178
178
  Expr::Assign { value, .. }
179
179
  | Expr::CompoundAssign { value, .. }
@@ -1601,7 +1601,7 @@ fn rewrite_expr_scope(expr: &mut Expr, active: &HashMap<String, Arc<str>>) {
1601
1601
  Expr::Object { props, .. } => {
1602
1602
  for p in props {
1603
1603
  match p {
1604
- ObjectProp::KeyValue(_, e) | ObjectProp::Spread(e) => {
1604
+ ObjectProp::KeyValue(_, e, _) | ObjectProp::Spread(e) => {
1605
1605
  rewrite_expr_scope(e, active) // key is a property name; value recurses
1606
1606
  }
1607
1607
  }
@@ -1819,6 +1819,7 @@ pub fn merge_modules(mut modules: Vec<ResolvedModule>) -> Result<MergedProgram,
1819
1819
  name: Arc::from(v.clone()),
1820
1820
  span: *span,
1821
1821
  },
1822
+ *name_span,
1822
1823
  ));
1823
1824
  }
1824
1825
  merge_push(
@@ -13,6 +13,12 @@ pub enum RustType {
13
13
  Value,
14
14
  /// f64 (for number)
15
15
  F64,
16
+ /// i32 — a `number` local PROVEN to always hold an integer reinterpretable as a JS ToInt32
17
+ /// bit-pattern, kept in an integer register across a bitwise/hash hot loop (bun/JSC-style)
18
+ /// instead of round-tripping `f64`↔`i32` on every op. The value is the signed int32
19
+ /// (= JS `ToInt32`) view; `>>> 0` results are uint32 reinterpreted into this i32. Only the
20
+ /// codegen's i32-loop-var lowering produces this type — never `from_annotation`.
21
+ I32,
16
22
  /// String (for string)
17
23
  String,
18
24
  /// bool (for boolean)
@@ -222,6 +228,7 @@ impl RustType {
222
228
  match self {
223
229
  RustType::Value => "Value".to_string(),
224
230
  RustType::F64 => "f64".to_string(),
231
+ RustType::I32 => "i32".to_string(),
225
232
  RustType::String => "String".to_string(),
226
233
  RustType::Bool => "bool".to_string(),
227
234
  RustType::Unit => "()".to_string(),
@@ -250,6 +257,7 @@ impl RustType {
250
257
  match self {
251
258
  RustType::Value => "Value::Null".to_string(),
252
259
  RustType::F64 => "0.0".to_string(),
260
+ RustType::I32 => "0i32".to_string(),
253
261
  RustType::String => "String::new()".to_string(),
254
262
  RustType::Bool => "false".to_string(),
255
263
  RustType::Unit => "()".to_string(),
@@ -306,6 +314,12 @@ impl RustType {
306
314
  "match &{} {{ Value::Number(n) => *n, _ => panic!(\"expected number\") }}",
307
315
  value_expr
308
316
  ),
317
+ // A `Value::Number` narrowed to its JS ToInt32 bit-pattern (NaN/±Inf → 0, exactly as
318
+ // the bitwise/shift path coerces). Only reached for an `I32`-typed binding boundary.
319
+ RustType::I32 => format!(
320
+ "match &{} {{ Value::Number(n) => tishlang_runtime::to_int32(*n), _ => panic!(\"expected number\") }}",
321
+ value_expr
322
+ ),
309
323
  RustType::String => format!(
310
324
  "match &{} {{ Value::String(s) => s.to_string(), _ => panic!(\"expected string\") }}",
311
325
  value_expr
@@ -363,6 +377,9 @@ impl RustType {
363
377
  }
364
378
  RustType::Value => native_expr.to_string(),
365
379
  RustType::F64 => format!("Value::Number({})", native_expr),
380
+ // The signed int32 view boxes as a JS Number (every i32 is exactly representable in
381
+ // f64). The uint32 `>>> 0` reinterpretation is applied at the boxing site, not here.
382
+ RustType::I32 => format!("Value::Number(({}) as f64)", native_expr),
366
383
  RustType::String => format!("Value::String({}.clone().into())", native_expr),
367
384
  RustType::Bool => format!("Value::Bool({})", native_expr),
368
385
  RustType::Unit => "Value::Null".to_string(),
@@ -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 { .. } => {}
@@ -487,9 +526,29 @@ impl Codegen {
487
526
  }
488
527
  }
489
528
 
529
+ /// Is `expr` the `null` literal? Used to lower `x === null` / `x !== null` to JS `== null` /
530
+ /// `!= null` so the check catches `undefined` too (tish treats a missing/undefined value as
531
+ /// null on every other backend) — see the StrictEq/StrictNe arms below.
532
+ fn is_null_literal(expr: &Expr) -> bool {
533
+ matches!(
534
+ expr,
535
+ Expr::Literal {
536
+ value: Literal::Null,
537
+ ..
538
+ }
539
+ )
540
+ }
541
+
490
542
  fn emit_expr(&mut self, expr: &Expr) -> Result<String, CompileError> {
491
543
  Ok(match expr {
492
544
  Expr::Literal { value, .. } => match value {
545
+ // Rust's `{}` prints non-finite f64 as `inf` / `-inf` / `NaN`; only `NaN` is valid JS.
546
+ // Emit the JS spellings so a folded `1/0` / `-1/0` doesn't become an undefined `inf`
547
+ // identifier in the output.
548
+ Literal::Number(n) if n.is_nan() => "NaN".to_string(),
549
+ Literal::Number(n) if n.is_infinite() => {
550
+ if *n < 0.0 { "-Infinity".to_string() } else { "Infinity".to_string() }
551
+ }
493
552
  Literal::Number(n) => format!("{}", n),
494
553
  Literal::String(s) => format!("{:?}", s.as_ref()),
495
554
  Literal::Bool(b) => format!("{}", b),
@@ -510,8 +569,26 @@ impl Codegen {
510
569
  BinOp::Pow => "**",
511
570
  BinOp::Eq => "==",
512
571
  BinOp::Ne => "!=",
513
- BinOp::StrictEq => "===",
514
- BinOp::StrictNe => "!==",
572
+ // tish has no `undefined`: a missing/absent value reads back as `null`, so
573
+ // `x === null` means "is nullish" — exactly how interp/vm/native behave (a
574
+ // missing property is null there). In the JS runtime a missing property is
575
+ // `undefined`, so lower `=== null` / `!== null` to loose `== null` / `!= null`,
576
+ // which match BOTH null and undefined — keeping `=== null` mean the same thing on
577
+ // every target. Strict equality between non-null operands is unaffected.
578
+ BinOp::StrictEq => {
579
+ if Self::is_null_literal(left) || Self::is_null_literal(right) {
580
+ "=="
581
+ } else {
582
+ "==="
583
+ }
584
+ }
585
+ BinOp::StrictNe => {
586
+ if Self::is_null_literal(left) || Self::is_null_literal(right) {
587
+ "!="
588
+ } else {
589
+ "!=="
590
+ }
591
+ }
515
592
  BinOp::Lt => "<",
516
593
  BinOp::Le => "<=",
517
594
  BinOp::Gt => ">",
@@ -563,6 +640,15 @@ impl Codegen {
563
640
  ..
564
641
  } => {
565
642
  let obj = self.emit_expr(object)?;
643
+ // `255.toString()` is a JS syntax error — the lexer reads `255.` as a float and
644
+ // then chokes on the method name. Parenthesize a numeric-literal object so member
645
+ // access / method calls stay valid: `(255).toString()`. (Folded constants reach
646
+ // codegen as number literals too, so this covers e.g. `(100 * 2).toString()`.)
647
+ let obj = if matches!(&**object, Expr::Literal { value: Literal::Number(_), .. }) {
648
+ format!("({})", obj)
649
+ } else {
650
+ obj
651
+ };
566
652
  let expr = match prop {
567
653
  MemberProp::Name { name, .. } => {
568
654
  if name.parse::<u32>().is_ok()
@@ -629,7 +715,7 @@ impl Codegen {
629
715
  let parts: Result<Vec<_>, _> = props
630
716
  .iter()
631
717
  .map(|p| match p {
632
- ObjectProp::KeyValue(k, v) => {
718
+ ObjectProp::KeyValue(k, v, _) => {
633
719
  let key = k.as_ref();
634
720
  let val = self.emit_expr(v)?;
635
721
  Ok(if key.chars().all(|c| c.is_alphanumeric() || c == '_') {
@@ -650,7 +736,11 @@ impl Codegen {
650
736
  }
651
737
  Expr::TypeOf { operand, .. } => {
652
738
  let o = self.emit_expr(operand)?;
653
- format!("(typeof {})", o)
739
+ // tish `typeof null` is "null" (interp/vm/native all agree — null is a first-class
740
+ // type, not JS's `typeof null === "object"` wart). tish has no `undefined`, so any
741
+ // nullish operand (incl. a JS-runtime `undefined`) maps to "null". Evaluate the
742
+ // operand once via the arrow arg so side effects don't run twice.
743
+ format!("((__v) => __v == null ? \"null\" : typeof __v)({})", o)
654
744
  }
655
745
  Expr::Delete { target, .. } => {
656
746
  // Emit the raw property *reference*, not a value: `emit_expr` wraps Index /
@@ -808,6 +898,172 @@ impl Codegen {
808
898
  CallArg::Spread(e) => Ok(format!("...{}", self.emit_expr(e)?)),
809
899
  }
810
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
+ }
811
1067
  }
812
1068
 
813
1069
  /// Compile a single program (no imports) to JavaScript. JSX lowers to `h` / `Fragment` (Lattish).
@@ -936,3 +1192,103 @@ pub fn compile_project_with_jsx(
936
1192
  };
937
1193
  Ok(compile_project_js_inner(entry_path, project_root, optimize, false, &out_name)?.js)
938
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