@tishlang/tish-format 2.12.0 → 2.35.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 (88) hide show
  1. package/Cargo.toml +3 -0
  2. package/bin/tish-format +0 -0
  3. package/crates/js_to_tish/src/transform/expr.rs +12 -2
  4. package/crates/tish/tests/fixtures/fs_parity_callbacks.tish +22 -0
  5. package/crates/tish/tests/fixtures/fs_parity_promises.tish +21 -0
  6. package/crates/tish/tests/fixtures/fs_parity_sync.tish +20 -0
  7. package/crates/tish/tests/fs_parity.rs +103 -0
  8. package/crates/tish/tests/integration_test.rs +80 -0
  9. package/crates/tish/tests/shortcircuit.rs +3 -3
  10. package/crates/tish_ast/src/ast.rs +84 -0
  11. package/crates/tish_builtins/Cargo.toml +1 -0
  12. package/crates/tish_builtins/src/array.rs +724 -72
  13. package/crates/tish_builtins/src/collections.rs +149 -40
  14. package/crates/tish_builtins/src/globals.rs +215 -25
  15. package/crates/tish_builtins/src/math.rs +65 -7
  16. package/crates/tish_builtins/src/number.rs +173 -0
  17. package/crates/tish_builtins/src/string.rs +263 -23
  18. package/crates/tish_bytecode/src/chunk.rs +7 -0
  19. package/crates/tish_bytecode/src/compiler.rs +611 -63
  20. package/crates/tish_bytecode/src/lib.rs +1 -1
  21. package/crates/tish_bytecode/src/opcode.rs +156 -2
  22. package/crates/tish_bytecode/src/serialize.rs +2 -0
  23. package/crates/tish_bytecode/tests/append_local_string_builder.rs +63 -0
  24. package/crates/tish_bytecode/tests/math_unary_intrinsic.rs +47 -0
  25. package/crates/tish_compile/src/codegen.rs +18219 -5283
  26. package/crates/tish_compile/src/infer.rs +1562 -30
  27. package/crates/tish_compile/src/lib.rs +29 -4
  28. package/crates/tish_compile/src/resolve.rs +151 -13
  29. package/crates/tish_compile/src/types.rs +224 -28
  30. package/crates/tish_compile/tests/dump_codegen.rs +21 -0
  31. package/crates/tish_compile/tests/perf_codegen_169_173.rs +8 -17
  32. package/crates/tish_compile/tests/perf_codegen_173_part3.rs +61 -0
  33. package/crates/tish_compile/tests/perf_codegen_174.rs +160 -0
  34. package/crates/tish_compile/tests/perf_codegen_175.rs +190 -0
  35. package/crates/tish_compile/tests/perf_codegen_176.rs +60 -0
  36. package/crates/tish_compile/tests/perf_codegen_177.rs +167 -0
  37. package/crates/tish_compile/tests/perf_codegen_178.rs +114 -0
  38. package/crates/tish_compile/tests/perf_codegen_178_rec.rs +124 -0
  39. package/crates/tish_compile/tests/perf_codegen_181.rs +36 -0
  40. package/crates/tish_compile/tests/perf_codegen_320.rs +211 -0
  41. package/crates/tish_compile/tests/perf_codegen_module_const_forof.rs +40 -0
  42. package/crates/tish_compile_js/src/codegen.rs +94 -3
  43. package/crates/tish_compile_js/src/tests_jsx.rs +27 -0
  44. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +115 -2
  45. package/crates/tish_core/Cargo.toml +4 -0
  46. package/crates/tish_core/src/json.rs +109 -6
  47. package/crates/tish_core/src/lib.rs +222 -2
  48. package/crates/tish_core/src/shape.rs +4 -2
  49. package/crates/tish_core/src/uri.rs +45 -0
  50. package/crates/tish_core/src/value.rs +571 -35
  51. package/crates/tish_core/src/vmref.rs +14 -0
  52. package/crates/tish_eval/Cargo.toml +2 -1
  53. package/crates/tish_eval/src/eval.rs +1328 -101
  54. package/crates/tish_eval/src/natives.rs +283 -18
  55. package/crates/tish_eval/src/regex.rs +47 -1
  56. package/crates/tish_eval/src/value.rs +76 -21
  57. package/crates/tish_ffi/src/lib.rs +11 -1
  58. package/crates/tish_ffi/tests/double_free.rs +35 -0
  59. package/crates/tish_fmt/Cargo.toml +1 -1
  60. package/crates/tish_fmt/src/lib.rs +94 -1
  61. package/crates/tish_lexer/src/lib.rs +76 -0
  62. package/crates/tish_lexer/src/token.rs +4 -0
  63. package/crates/tish_lint/src/lib.rs +126 -0
  64. package/crates/tish_lsp/README.md +2 -1
  65. package/crates/tish_lsp/src/main.rs +378 -28
  66. package/crates/tish_native/src/build.rs +41 -0
  67. package/crates/tish_opt/src/lib.rs +20 -1
  68. package/crates/tish_parser/Cargo.toml +4 -0
  69. package/crates/tish_parser/src/lib.rs +68 -0
  70. package/crates/tish_parser/src/parser.rs +479 -28
  71. package/crates/tish_resolve/src/lib.rs +75 -5
  72. package/crates/tish_runtime/Cargo.toml +10 -1
  73. package/crates/tish_runtime/src/fs_ext.rs +359 -0
  74. package/crates/tish_runtime/src/http.rs +28 -10
  75. package/crates/tish_runtime/src/http_fetch.rs +150 -1
  76. package/crates/tish_runtime/src/http_hyper.rs +41 -18
  77. package/crates/tish_runtime/src/http_prefork.rs +72 -11
  78. package/crates/tish_runtime/src/lib.rs +651 -51
  79. package/crates/tish_runtime/src/timers.rs +49 -2
  80. package/crates/tish_ui/src/jsx.rs +21 -3
  81. package/crates/tish_vm/src/jit.rs +2514 -117
  82. package/crates/tish_vm/src/vm.rs +1479 -224
  83. package/package.json +1 -1
  84. package/platform/darwin-arm64/tish-fmt +0 -0
  85. package/platform/darwin-x64/tish-fmt +0 -0
  86. package/platform/linux-arm64/tish-fmt +0 -0
  87. package/platform/linux-x64/tish-fmt +0 -0
  88. package/platform/win32-x64/tish-fmt.exe +0 -0
@@ -0,0 +1,36 @@
1
+ //! #181 — direct Map method dispatch for `new Map()` locals.
2
+
3
+ use std::path::PathBuf;
4
+
5
+ use tishlang_compile::compile_project_full;
6
+
7
+ fn enable_typed_flags() {
8
+ }
9
+
10
+ #[test]
11
+ fn k_nucleotide_uses_direct_map_has_get_set() {
12
+ enable_typed_flags();
13
+ let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
14
+ let path = manifest
15
+ .join("../../tests/perf/k_nucleotide.tish")
16
+ .canonicalize()
17
+ .unwrap();
18
+ let (rust, _, _, _) = compile_project_full(&path, path.parent(), &[], true).unwrap();
19
+ assert!(
20
+ rust.contains("tish_map_has("),
21
+ "expected direct map_has dispatch:\n{}",
22
+ rust.lines()
23
+ .filter(|l| l.contains("map_has") || l.contains("map_set"))
24
+ .take(6)
25
+ .collect::<Vec<_>>()
26
+ .join("\n")
27
+ );
28
+ assert!(
29
+ rust.contains("tish_map_get(") && rust.contains("tish_map_set("),
30
+ "expected direct map_get/set dispatch"
31
+ );
32
+ assert!(
33
+ !rust.contains("get_prop(&(m).clone(), \"has\")"),
34
+ "should not use bound-method has on map local m"
35
+ );
36
+ }
@@ -0,0 +1,211 @@
1
+ //! #320 — honest native-array lowering for fn-call-built numeric arrays.
2
+ //!
3
+ //! Two sound, name-independent inference fixes:
4
+ //! fix1: a 0-param numeric fn (`nextBase`, mutates a numeric global, returns number) is eligible
5
+ //! for native `-> f64` promotion (`collect_native_fns` no longer requires ≥1 param).
6
+ //! fix2: a fn PROVEN to always return a number lets `infer_expr_type(f(...))` be `number`, so
7
+ //! `seq.push(nextBase())` keeps `seq` a native `Vec<f64>` instead of a boxed `Value[]`.
8
+
9
+ use std::path::PathBuf;
10
+
11
+ use tishlang_compile::compile_project_full;
12
+
13
+ /// The general typed-native inference flags.
14
+ fn enable_typed_flags() {
15
+ }
16
+
17
+ /// Write `src` under the workspace `target/` (scratch lives there, per repo convention) and compile.
18
+ fn compile_src(stem: &str, src: &str) -> String {
19
+ let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/perf_codegen_320");
20
+ std::fs::create_dir_all(&dir).unwrap();
21
+ let path = dir.join(format!("{stem}.tish"));
22
+ std::fs::write(&path, src).unwrap();
23
+ let path = path.canonicalize().unwrap();
24
+ let (rust, _, _, _) = compile_project_full(&path, path.parent(), &[], true).unwrap();
25
+ rust
26
+ }
27
+
28
+ const NON_ESCAPING: &str = r#"
29
+ let seed = 7
30
+ function nextBase() {
31
+ seed = (seed * 3877 + 29573) % 139968
32
+ if (seed < 34992) { return 0 }
33
+ if (seed < 69984) { return 1 }
34
+ if (seed < 104976) { return 2 }
35
+ return 3
36
+ }
37
+ let seq = []
38
+ for (let i = 0; i < 100; i++) { seq.push(nextBase()) }
39
+ let sum = 0
40
+ for (let i = 0; i < seq.length; i++) { sum = sum + seq[i] }
41
+ console.log("R " + sum)
42
+ "#;
43
+
44
+ #[test]
45
+ fn zero_param_numeric_fn_goes_native() {
46
+ enable_typed_flags();
47
+ let rust = compile_src("zero_param", NON_ESCAPING);
48
+ // fix1: the 0-param fn is promoted to a native `-> f64` form.
49
+ assert!(
50
+ rust.contains("fn nextBase_native() -> f64"),
51
+ "0-param numeric fn should get a native f64 form (fix1)"
52
+ );
53
+ }
54
+
55
+ #[test]
56
+ fn numeric_returning_fn_keeps_pushed_array_native() {
57
+ enable_typed_flags();
58
+ let rust = compile_src("native_push", NON_ESCAPING);
59
+ // fix2: `seq.push(nextBase())` keeps `seq` a native `Vec<f64>` (the pushed value infers numeric).
60
+ assert!(
61
+ rust.contains("let mut seq: Vec<f64>"),
62
+ "seq built by push(numericFn()) should be a native Vec<f64> (fix2)"
63
+ );
64
+ // And the push routes straight to the native fn (no boxed Value array push).
65
+ assert!(
66
+ rust.contains("seq.push(nextBase_native())"),
67
+ "push should call the native fn into the native Vec (fix1+fix2)"
68
+ );
69
+ }
70
+
71
+ /// A fn that can fall through to an implicit `undefined` (no trailing unconditional return) must NOT
72
+ /// be treated as number-returning — else `a.push(maybe())` could put `undefined` in a `Vec<f64>`.
73
+ #[test]
74
+ fn fallthrough_fn_is_not_number_returning() {
75
+ enable_typed_flags();
76
+ let src = r#"
77
+ let g = 0
78
+ function maybe() {
79
+ g = g + 1
80
+ if (g < 10) { return 1 }
81
+ }
82
+ let a = []
83
+ for (let i = 0; i < 100; i++) { a.push(maybe()) }
84
+ let sum = 0
85
+ for (let i = 0; i < a.length; i++) { sum = sum + 1 }
86
+ console.log("R " + sum)
87
+ "#;
88
+ let rust = compile_src("fallthrough", src);
89
+ // Unsound to type `a` as Vec<f64> here — it must stay a boxed array.
90
+ assert!(
91
+ !rust.contains("let mut a: Vec<f64>"),
92
+ "fall-through fn must not make the pushed array native (soundness)"
93
+ );
94
+ }
95
+
96
+ /// #320 part 2: a read-only `number[]` param of a normal (boxed-body, boxed-return) fn is unboxed
97
+ /// once into an owned native `Vec<f64>`, so the body indexes it natively. This is the
98
+ /// cross-fn-boundary lever — `kNucleotide(seq, k)` indexes `seq` but builds & returns a boxed Map,
99
+ /// so it is NOT a native-vec fn.
100
+ fn enable_arr_param() {}
101
+
102
+ const ARR_PARAM: &str = r#"
103
+ let seed = 7
104
+ function nextBase() {
105
+ seed = (seed * 3877 + 29573) % 139968
106
+ if (seed < 34992) { return 0 }
107
+ if (seed < 69984) { return 1 }
108
+ if (seed < 104976) { return 2 }
109
+ return 3
110
+ }
111
+ function count(seq, k) {
112
+ let m = new Map()
113
+ let n = seq.length - k + 1
114
+ for (let i = 0; i < n; i++) {
115
+ let key = 0
116
+ for (let j = 0; j < k; j++) { key = key * 4 + seq[i + j] }
117
+ if (m.has(key)) { m.set(key, m.get(key) + 1) } else { m.set(key, 1) }
118
+ }
119
+ return m
120
+ }
121
+ let seq = []
122
+ for (let i = 0; i < 100; i++) { seq.push(nextBase()) }
123
+ let m = count(seq, 4)
124
+ let sum = 0
125
+ for (let v of m.values()) { sum = sum + v }
126
+ console.log("R " + sum)
127
+ "#;
128
+
129
+ #[test]
130
+ fn readonly_arr_param_unboxed_to_native_vec() {
131
+ enable_arr_param();
132
+ let rust = compile_src("arr_param", ARR_PARAM);
133
+ // The `seq` param is unboxed once into an owned native Vec<f64> at the closure entry (a packed
134
+ // NumberArray clones its backing; a boxed Array is mapped element-wise).
135
+ assert!(
136
+ rust.contains("let mut seq: Vec<f64> = match args.get(")
137
+ && rust.contains("Some(Value::NumberArray(a)) => a.borrow().clone()"),
138
+ "read-only number[] param should be unboxed to a native Vec<f64>"
139
+ );
140
+ // And the hot inner loop indexes it natively (f64 mul + native vec read), no boxed get_index.
141
+ assert!(
142
+ rust.contains("key * 4_f64") && rust.contains("seq.get("),
143
+ "inner loop should be native f64 arithmetic over a native Vec read"
144
+ );
145
+ assert!(
146
+ !rust.contains("tishlang_runtime::get_index(&seq")
147
+ && !rust.contains("get_index(&(seq)"),
148
+ "seq must not be read via the boxed runtime get_index inside the fn"
149
+ );
150
+ }
151
+
152
+ #[test]
153
+ fn mutating_arr_param_stays_boxed() {
154
+ enable_arr_param();
155
+ // `a` is index-ASSIGNED → an owned native copy would silently drop the write, so the param must
156
+ // stay boxed (classify_vec_param reports is_mut, and the detection requires read-only).
157
+ let src = r#"
158
+ function bump(a) { a[0] = a[0] + 1; return a.length }
159
+ let arr = []
160
+ for (let i = 0; i < 10; i++) { arr.push(i) }
161
+ let r = bump(arr)
162
+ console.log("R " + r)
163
+ "#;
164
+ let rust = compile_src("mut_arr_param", src);
165
+ assert!(
166
+ !rust.contains("let mut a: Vec<f64>"),
167
+ "a mutated array param must stay boxed (soundness — owned copy would lose writes)"
168
+ );
169
+ }
170
+
171
+ #[test]
172
+ fn escaping_arr_param_stays_boxed() {
173
+ enable_arr_param();
174
+ // `seq.push` MUTATES the param via a method (not index-assign): an owned copy would lose the
175
+ // caller-visible push. `scan_param_use` flags this as an escape → the param stays boxed.
176
+ let src = r#"
177
+ function build(seq) {
178
+ let m = new Map()
179
+ for (let i = 0; i < seq.length; i++) { m.set(i, seq[i]) }
180
+ seq.push(99)
181
+ return m
182
+ }
183
+ let arr = []
184
+ for (let i = 0; i < 10; i++) { arr.push(i) }
185
+ let m = build(arr)
186
+ console.log("R " + arr.length)
187
+ "#;
188
+ let rust = compile_src("escape_arr_param", src);
189
+ assert!(
190
+ !rust.contains("let mut seq: Vec<f64>"),
191
+ "an escaping (pushed) array param must stay boxed (soundness)"
192
+ );
193
+ }
194
+
195
+ #[test]
196
+ fn non_number_array_arg_keeps_callee_boxed() {
197
+ enable_arr_param();
198
+ // `joinup` reads its param read-only, but the ONLY caller passes a NON-number[] array. Unboxing
199
+ // to Vec<f64> would turn "x" into NaN (vs JS string concat) — so the call-site check keeps the
200
+ // callee fully boxed.
201
+ let src = r#"
202
+ function joinup(seq) { let r = ""; for (let i = 0; i < seq.length; i++) { r = r + seq[i] } return r }
203
+ let arr = [1, "x", 3]
204
+ console.log(joinup(arr))
205
+ "#;
206
+ let rust = compile_src("nonnum_arr_arg", src);
207
+ assert!(
208
+ !rust.contains("let mut seq: Vec<f64>"),
209
+ "a read-only fn whose caller passes a non-number[] array must stay boxed (call-site soundness)"
210
+ );
211
+ }
@@ -0,0 +1,40 @@
1
+ //! Hoisted module-level f64 array literals (`const G_XS`) must compile when used in for-of / join.
2
+
3
+ use std::path::PathBuf;
4
+
5
+ use tishlang_compile::compile_project_full;
6
+
7
+ #[test]
8
+ fn typed_array_forof_compiles() {
9
+ let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
10
+ let path = manifest
11
+ .join("../../tests/core/typed_array_forof.tish")
12
+ .canonicalize()
13
+ .unwrap();
14
+ let (rust, _, _, _) = compile_project_full(&path, path.parent(), &[], true).unwrap();
15
+ assert!(rust.contains("const G_XS:"), "expected hoisted xs array");
16
+ assert!(
17
+ !rust.contains("normalize_for_of((xs)"),
18
+ "for-of should use native index loop over G_XS, not boxed xs reference"
19
+ );
20
+ assert!(
21
+ rust.contains("for _fof_i0 in 0..G_XS.len()"),
22
+ "expected native for-of over hoisted G_XS"
23
+ );
24
+ }
25
+
26
+ #[test]
27
+ fn template_literals_hoisted_nums_compiles() {
28
+ let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
29
+ let path = manifest
30
+ .join("../../tests/core/template_literals.tish")
31
+ .canonicalize()
32
+ .unwrap();
33
+ let (rust, _, _, _) = compile_project_full(&path, path.parent(), &[], true).unwrap();
34
+ assert!(rust.contains("const G_NUMS:"));
35
+ assert!(
36
+ rust.contains("array_join(&Value::NumberArray(VmRef::new(G_NUMS"),
37
+ "nums.join should box hoisted G_NUMS, not reference missing local nums"
38
+ );
39
+ assert!(!rust.contains("&nums,"));
40
+ }
@@ -376,6 +376,19 @@ impl Codegen {
376
376
  self.writeln(&format!("for (const {} of {})", escaped, it));
377
377
  self.emit_js_control_body(body)?;
378
378
  }
379
+ Statement::ForIn {
380
+ name,
381
+ object,
382
+ body,
383
+ ..
384
+ } => {
385
+ // Native JS `for-in` — iterates own enumerable keys (#413). tish objects have no
386
+ // prototype chain, so this matches the key-enumeration the other backends perform.
387
+ let escaped = Self::escape_ident(name.as_ref());
388
+ let obj = self.emit_expr(object)?;
389
+ self.writeln(&format!("for (const {} in {})", escaped, obj));
390
+ self.emit_js_control_body(body)?;
391
+ }
379
392
  Statement::Return { value, .. } => {
380
393
  if let Some(v) = value {
381
394
  let e = self.emit_expr(v)?;
@@ -677,6 +690,14 @@ impl Codegen {
677
690
  }
678
691
  Expr::Call { callee, args, .. } => {
679
692
  let c = self.emit_expr(callee)?;
693
+ // An arrow-function callee (an IIFE — `(function(x){…})(n)` lowers to an arrow, as does
694
+ // `((x)=>…)(n)`) must be parenthesized: without it the call `(args)` binds to the arrow
695
+ // BODY, not the whole arrow — a `SyntaxError` for a block body, wrong result otherwise.
696
+ let c = if matches!(&**callee, Expr::ArrowFunction { .. }) {
697
+ format!("({})", c)
698
+ } else {
699
+ c
700
+ };
680
701
  let arg_strs: Result<Vec<_>, _> =
681
702
  args.iter().map(|a| self.emit_call_arg(a)).collect();
682
703
  let arg_strs = arg_strs?.join(", ");
@@ -889,7 +910,9 @@ impl Codegen {
889
910
  let val = self.emit_expr(value)?;
890
911
  format!("({}[{}] = {})", obj, idx, val)
891
912
  }
892
- Expr::ArrowFunction { params, body, .. } => {
913
+ Expr::ArrowFunction {
914
+ async_, params, body, ..
915
+ } => {
893
916
  let ps = self.emit_params(params, None)?;
894
917
  let body_str = match body {
895
918
  ArrowBody::Expr(e) => self.emit_expr(e)?,
@@ -905,10 +928,11 @@ impl Codegen {
905
928
  block
906
929
  }
907
930
  };
931
+ let prefix = if *async_ { "async " } else { "" }; // #428
908
932
  if matches!(body, ArrowBody::Expr(_)) {
909
- format!("({}) => ({})", ps, body_str)
933
+ format!("{}({}) => ({})", prefix, ps, body_str)
910
934
  } else {
911
- format!("({}) => {}", ps, body_str)
935
+ format!("{}({}) => {}", prefix, ps, body_str)
912
936
  }
913
937
  }
914
938
  Expr::TemplateLiteral { quasis, exprs, .. } => {
@@ -1036,6 +1060,52 @@ impl Codegen {
1036
1060
  let v = self.emit_expr(e)?;
1037
1061
  self.writeln(&format!("export default {};", v));
1038
1062
  }
1063
+ // #305: re-export in ESM mode — emit the native `export { … } from` / `export * from`,
1064
+ // rewriting the `.tish` specifier to the emitted `.js` module path.
1065
+ ExportDeclaration::ReExport {
1066
+ specifiers,
1067
+ all,
1068
+ from,
1069
+ ..
1070
+ } => {
1071
+ let mut parts: Vec<String> = Vec::new();
1072
+ for s in specifiers {
1073
+ if let ImportSpecifier::Named { name, alias, .. } = s {
1074
+ match alias {
1075
+ Some(a) => parts.push(format!(
1076
+ "{} as {}",
1077
+ Self::escape_ident(name.as_ref()),
1078
+ Self::escape_ident(a.as_ref())
1079
+ )),
1080
+ None => parts.push(Self::escape_ident(name.as_ref()).to_string()),
1081
+ }
1082
+ }
1083
+ }
1084
+ match from {
1085
+ // Re-export from another module: `export … from "…"`.
1086
+ Some(from) => {
1087
+ let spec = rewrite_import_to_js(
1088
+ from.as_ref(),
1089
+ &self.module_path,
1090
+ &self.project_root,
1091
+ self.import_rewrite,
1092
+ )?;
1093
+ if *all {
1094
+ self.writeln(&format!("export * from {:?};", spec));
1095
+ } else {
1096
+ self.writeln(&format!(
1097
+ "export {{ {} }} from {:?};",
1098
+ parts.join(", "),
1099
+ spec
1100
+ ));
1101
+ }
1102
+ }
1103
+ // #415 local named export — no `from` clause.
1104
+ None => {
1105
+ self.writeln(&format!("export {{ {} }};", parts.join(", ")));
1106
+ }
1107
+ }
1108
+ }
1039
1109
  }
1040
1110
  Ok(())
1041
1111
  }
@@ -1182,6 +1252,7 @@ fn compile_project_js_inner(
1182
1252
  merged.program.clone()
1183
1253
  };
1184
1254
  let stmt_sources = merged.statement_sources;
1255
+ let entry_exports = merged.entry_exports; // #295: entry module's named exports for the bundle
1185
1256
  let default_export = program.statements.iter().find_map(|s| {
1186
1257
  if let Statement::VarDecl { name, .. } = s {
1187
1258
  let n = name.as_ref();
@@ -1221,6 +1292,26 @@ fn compile_project_js_inner(
1221
1292
  gen.emit_program(&program, None, None)?;
1222
1293
  }
1223
1294
  let mut js = gen.output;
1295
+ // #295: re-emit the entry module's named exports as a real ES `export { … }` so the bundle is a
1296
+ // valid ES module (previously only `export default` was emitted; named `export fn/let/{…}` were
1297
+ // silently dropped, forcing downstream regex post-processing).
1298
+ if !entry_exports.is_empty() {
1299
+ let parts: Vec<String> = entry_exports
1300
+ .iter()
1301
+ .map(|(local, exported)| {
1302
+ if local == exported {
1303
+ Codegen::escape_ident(local)
1304
+ } else {
1305
+ format!(
1306
+ "{} as {}",
1307
+ Codegen::escape_ident(local),
1308
+ Codegen::escape_ident(exported)
1309
+ )
1310
+ }
1311
+ })
1312
+ .collect();
1313
+ js.push_str(&format!("\nexport {{ {} }};\n", parts.join(", ")));
1314
+ }
1224
1315
  if let Some(name) = default_export {
1225
1316
  js.push_str(&format!("\nexport default {};\n", name));
1226
1317
  }
@@ -167,6 +167,33 @@ mod tests {
167
167
  let _ = std::fs::remove_file(&path);
168
168
  }
169
169
 
170
+ /// #295: `--target js --format bundle` must re-emit the entry module's named exports as a real ES
171
+ /// `export { … }` (previously only `export default` surfaced; `export fn/const/{…}` were silently
172
+ /// dropped, forcing downstream regex post-processing of every bundle).
173
+ #[test]
174
+ fn bundle_emits_named_exports() {
175
+ // `tempfile::tempdir()` (RAII, unique dir) rather than a fixed `std::env::temp_dir()` path.
176
+ let dir = tempfile::tempdir().expect("tempdir");
177
+ let path = dir.path().join("entry.tish");
178
+ let src = "export fn mountFoo(h) { return h }\n\
179
+ export const VERSION = \"1\"\n\
180
+ fn helper() { return 2 }\n\
181
+ export { helper as helperExported }\n\
182
+ export default 42\n";
183
+ std::fs::write(&path, src).unwrap();
184
+ let js = compile_project_with_jsx(&path, Some(dir.path()), false)
185
+ .expect("compile_project_with_jsx failed");
186
+ assert!(
187
+ js.contains("export { VERSION, helper as helperExported, mountFoo };"),
188
+ "bundle must emit named exports, got:\n{}",
189
+ &js[js.len().saturating_sub(300)..]
190
+ );
191
+ assert!(
192
+ js.contains("export default __default_0;"),
193
+ "bundle must still emit the default export"
194
+ );
195
+ }
196
+
170
197
  #[test]
171
198
  fn jsx_never_emits_vdom_helpers_or_prelude_flags() {
172
199
  let src = r#"fn X() { return <div class="x">{"a"}</div> }"#;
@@ -98,6 +98,22 @@ fn resolve_import_to_key(
98
98
  ))
99
99
  }
100
100
 
101
+ /// The module specifier a statement imports/re-exports from, if any — covers both
102
+ /// `import … from "x"` and `export … from "x"` (#305 re-export). Dependency discovery and cycle
103
+ /// detection use this so they follow re-export edges, not just plain imports (otherwise a module
104
+ /// reached only via a re-export would never be loaded).
105
+ fn stmt_module_source(stmt: &Statement) -> Option<&str> {
106
+ match stmt {
107
+ Statement::Import { from, .. } => Some(&**from),
108
+ Statement::Export { declaration, .. } => match declaration.as_ref() {
109
+ // A local named export (`export { a }`, from=None) has no module dependency.
110
+ ExportDeclaration::ReExport { from, .. } => from.as_deref(),
111
+ _ => None,
112
+ },
113
+ _ => None,
114
+ }
115
+ }
116
+
101
117
  /// Resolve all modules starting from the entry file. Returns modules in dependency order.
102
118
  pub fn resolve_virtual(
103
119
  entry_path: &str,
@@ -163,7 +179,7 @@ fn load_module_recursive(
163
179
 
164
180
  let from_dir = parent_dir(module_path);
165
181
  for stmt in &program.statements {
166
- if let Statement::Import { from, .. } = stmt {
182
+ if let Some(from) = stmt_module_source(stmt) {
167
183
  if is_native_import(from) {
168
184
  continue;
169
185
  }
@@ -217,7 +233,7 @@ fn has_cycle_from(
217
233
  visiting: &mut HashSet<usize>,
218
234
  ) -> Result<bool, String> {
219
235
  for stmt in &program.statements {
220
- if let Statement::Import { from, .. } = stmt {
236
+ if let Some(from) = stmt_module_source(stmt) {
221
237
  if is_native_import(from) {
222
238
  continue;
223
239
  }
@@ -292,6 +308,54 @@ pub fn merge_modules_virtual(modules: Vec<VirtualModule>) -> Result<Program, Str
292
308
  let default_name = format!("__default_{}", idx);
293
309
  module_exports[idx].insert("default".to_string(), default_name);
294
310
  }
311
+ // #305: re-export — map each re-exported name to the DEP's binding (the dep is
312
+ // earlier in load order, so its export table is already built). `export *` copies
313
+ // every dep export without overriding an explicit one; `export { a as b }` maps
314
+ // b -> dep's binding for a. Mirrors `merge_modules` in tish_compile/src/resolve.rs.
315
+ ExportDeclaration::ReExport {
316
+ specifiers,
317
+ all,
318
+ from: Some(from),
319
+ ..
320
+ } => {
321
+ let dir = parent_dir(&module.path);
322
+ let dep = resolve_import_to_key_for_cycle(from, dir, &path_to_idx)
323
+ .ok()
324
+ .and_then(|key| path_to_idx.get(&key).copied())
325
+ .map(|dep_idx| module_exports[dep_idx].clone());
326
+ if let Some(dep) = dep {
327
+ if *all {
328
+ for (k, v) in &dep {
329
+ module_exports[idx]
330
+ .entry(k.clone())
331
+ .or_insert_with(|| v.clone());
332
+ }
333
+ }
334
+ for spec in specifiers {
335
+ if let ImportSpecifier::Named { name, alias, .. } = spec {
336
+ if let Some(binding) = dep.get(name.as_ref()) {
337
+ let export_name =
338
+ alias.as_deref().unwrap_or(name.as_ref()).to_string();
339
+ module_exports[idx].insert(export_name, binding.clone());
340
+ }
341
+ }
342
+ }
343
+ }
344
+ }
345
+ // #415 local named export (no `from`): map each export name to the local binding.
346
+ ExportDeclaration::ReExport {
347
+ specifiers,
348
+ from: None,
349
+ ..
350
+ } => {
351
+ for spec in specifiers {
352
+ if let ImportSpecifier::Named { name, alias, .. } = spec {
353
+ let export_name =
354
+ alias.as_deref().unwrap_or(name.as_ref()).to_string();
355
+ module_exports[idx].insert(export_name, name.to_string());
356
+ }
357
+ }
358
+ }
295
359
  }
296
360
  }
297
361
  }
@@ -440,6 +504,10 @@ pub fn merge_modules_virtual(modules: Vec<VirtualModule>) -> Result<Program, Str
440
504
  span: espan,
441
505
  });
442
506
  }
507
+ // #305: re-export emits no code — `module_exports` already maps the re-exported
508
+ // names to the dep's bindings (in scope in the flattened bundle), so downstream
509
+ // imports resolve directly. Nothing to push here.
510
+ ExportDeclaration::ReExport { .. } => {}
443
511
  },
444
512
  _ => statements.push(stmt.clone()),
445
513
  }
@@ -471,4 +539,49 @@ mod tests {
471
539
  let program = merge_modules_virtual(modules).unwrap();
472
540
  assert!(!program.statements.is_empty());
473
541
  }
542
+
543
+ #[test]
544
+ fn test_resolve_virtual_reexport_alias() {
545
+ // #305: `export { add as plus } from "./dep"` re-exports dep's `add` as `plus`; a downstream
546
+ // `import { plus }` must resolve to dep's binding. Regression guard: the merge previously did
547
+ // not handle `ExportDeclaration::ReExport` (E0004 build failure), and discovery did not follow
548
+ // the re-export edge (so a dep reached only via re-export was never loaded).
549
+ let mut files = HashMap::new();
550
+ files.insert(
551
+ "dep.tish".to_string(),
552
+ "export fn add(a, b) { return a + b }".to_string(),
553
+ );
554
+ files.insert(
555
+ "mid.tish".to_string(),
556
+ "export { add as plus } from \"./dep.tish\"".to_string(),
557
+ );
558
+ files.insert(
559
+ "main.tish".to_string(),
560
+ "import { plus } from \"./mid.tish\"\nconsole.log(plus(1, 2))".to_string(),
561
+ );
562
+ let modules = resolve_virtual("main.tish", &files).unwrap();
563
+ // dep.tish is reached ONLY via mid's re-export → discovery must have loaded it.
564
+ assert!(
565
+ modules.iter().any(|m| m.path == "dep.tish"),
566
+ "re-exported dep should be discovered and loaded"
567
+ );
568
+ detect_cycles_virtual(&modules).unwrap();
569
+ let program = merge_modules_virtual(modules).unwrap();
570
+ let has_add_fn = program
571
+ .statements
572
+ .iter()
573
+ .any(|s| matches!(s, Statement::FunDecl { name, .. } if name.as_ref() == "add"));
574
+ let binds_plus_to_add = program.statements.iter().any(|s| {
575
+ matches!(
576
+ s,
577
+ Statement::VarDecl { name, init: Some(Expr::Ident { name: src, .. }), .. }
578
+ if name.as_ref() == "plus" && src.as_ref() == "add"
579
+ )
580
+ });
581
+ assert!(has_add_fn, "dep's `add` fn should be in the merged bundle");
582
+ assert!(
583
+ binds_plus_to_add,
584
+ "import of re-exported `plus` should bind to dep's `add`"
585
+ );
586
+ }
474
587
  }
@@ -25,6 +25,10 @@ smallvec = "1"
25
25
  indexmap = "2"
26
26
  # Fast integer→decimal for the JSON.stringify number fast-path (already in the workspace lock).
27
27
  itoa = "1"
28
+ # Fast shortest-float→decimal for the number→string general path (JSON/template/log). ryu writes the
29
+ # shortest round-trip digits straight to a buffer with none of `core::fmt`'s `{:e}` Formatter overhead
30
+ # (profiled as ~40% of JSON.stringify self-time on numeric payloads). Already in the workspace lock.
31
+ ryu = "1"
28
32
  fancy-regex = { version = "0.17.0", optional = true }
29
33
  # Only under `send-values` (the Arc<Mutex> path): parking_lot's uncontended lock is a
30
34
  # single atomic with no pthread syscall — profiled as a top cost on object/array access.