@tishlang/tish-format 2.16.13 → 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 (50) hide show
  1. package/bin/tish-format +0 -0
  2. package/crates/js_to_tish/src/transform/expr.rs +12 -2
  3. package/crates/tish/tests/fixtures/fs_parity_callbacks.tish +22 -0
  4. package/crates/tish/tests/fixtures/fs_parity_promises.tish +21 -0
  5. package/crates/tish/tests/fixtures/fs_parity_sync.tish +20 -0
  6. package/crates/tish/tests/fs_parity.rs +103 -0
  7. package/crates/tish/tests/shortcircuit.rs +3 -3
  8. package/crates/tish_ast/src/ast.rs +77 -3
  9. package/crates/tish_builtins/Cargo.toml +1 -0
  10. package/crates/tish_builtins/src/array.rs +195 -16
  11. package/crates/tish_builtins/src/collections.rs +37 -2
  12. package/crates/tish_builtins/src/globals.rs +215 -25
  13. package/crates/tish_builtins/src/math.rs +65 -7
  14. package/crates/tish_builtins/src/number.rs +173 -0
  15. package/crates/tish_builtins/src/string.rs +168 -15
  16. package/crates/tish_bytecode/src/compiler.rs +56 -4
  17. package/crates/tish_bytecode/src/opcode.rs +4 -2
  18. package/crates/tish_compile/src/codegen.rs +513 -44
  19. package/crates/tish_compile/src/infer.rs +26 -11
  20. package/crates/tish_compile/src/resolve.rs +87 -10
  21. package/crates/tish_compile_js/src/codegen.rs +82 -24
  22. package/crates/tish_compile_js/src/tests_jsx.rs +27 -0
  23. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +17 -2
  24. package/crates/tish_core/src/json.rs +5 -1
  25. package/crates/tish_core/src/lib.rs +48 -2
  26. package/crates/tish_core/src/uri.rs +45 -0
  27. package/crates/tish_core/src/value.rs +6 -0
  28. package/crates/tish_eval/Cargo.toml +2 -1
  29. package/crates/tish_eval/src/eval.rs +655 -27
  30. package/crates/tish_eval/src/natives.rs +264 -18
  31. package/crates/tish_eval/src/regex.rs +47 -1
  32. package/crates/tish_fmt/Cargo.toml +1 -1
  33. package/crates/tish_fmt/src/lib.rs +23 -4
  34. package/crates/tish_opt/src/lib.rs +20 -1
  35. package/crates/tish_parser/src/lib.rs +41 -0
  36. package/crates/tish_parser/src/parser.rs +180 -11
  37. package/crates/tish_resolve/src/lib.rs +66 -5
  38. package/crates/tish_runtime/Cargo.toml +5 -1
  39. package/crates/tish_runtime/src/fs_ext.rs +359 -0
  40. package/crates/tish_runtime/src/http_hyper.rs +41 -18
  41. package/crates/tish_runtime/src/lib.rs +128 -9
  42. package/crates/tish_ui/src/jsx.rs +18 -3
  43. package/crates/tish_vm/src/jit.rs +1 -1
  44. package/crates/tish_vm/src/vm.rs +270 -60
  45. package/package.json +1 -1
  46. package/platform/darwin-arm64/tish-fmt +0 -0
  47. package/platform/darwin-x64/tish-fmt +0 -0
  48. package/platform/linux-arm64/tish-fmt +0 -0
  49. package/platform/linux-x64/tish-fmt +0 -0
  50. package/platform/win32-x64/tish-fmt.exe +0 -0
@@ -1220,7 +1220,7 @@ fn check_returns_numeric(s: &Statement, ctx: &InferCtx, ok: &mut bool) {
1220
1220
  check_returns_numeric(e, ctx, ok);
1221
1221
  }
1222
1222
  }
1223
- While { body, .. } | DoWhile { body, .. } | ForOf { body, .. } => {
1223
+ While { body, .. } | DoWhile { body, .. } | ForOf { body, .. } | ForIn { body, .. } => {
1224
1224
  check_returns_numeric(body, ctx, ok);
1225
1225
  }
1226
1226
  For { init, body, .. } => {
@@ -1319,7 +1319,12 @@ fn count_uses_stmt(s: &Statement, f: &str, calls: &mut usize, others: &mut usize
1319
1319
  }
1320
1320
  count_uses_stmt(body, f, calls, others);
1321
1321
  }
1322
- ForOf { iterable, body, .. } => {
1322
+ ForOf { iterable, body, .. }
1323
+ | ForIn {
1324
+ object: iterable,
1325
+ body,
1326
+ ..
1327
+ } => {
1323
1328
  count_uses_expr(iterable, f, calls, others);
1324
1329
  count_uses_stmt(body, f, calls, others);
1325
1330
  }
@@ -1522,7 +1527,7 @@ fn stmt_contains_fundecl(s: &Statement) -> bool {
1522
1527
  stmt_contains_fundecl(then_branch)
1523
1528
  || else_branch.as_ref().is_some_and(|e| stmt_contains_fundecl(e))
1524
1529
  }
1525
- While { body, .. } | DoWhile { body, .. } | For { body, .. } | ForOf { body, .. } => {
1530
+ While { body, .. } | DoWhile { body, .. } | For { body, .. } | ForOf { body, .. } | ForIn { body, .. } => {
1526
1531
  stmt_contains_fundecl(body)
1527
1532
  }
1528
1533
  Try { body, catch_body, finally_body, .. } => {
@@ -1574,7 +1579,12 @@ fn walk_exprs_stmt_full(s: &Statement, f: &mut impl FnMut(&Expr)) {
1574
1579
  }
1575
1580
  walk_exprs_stmt_full(body, f);
1576
1581
  }
1577
- ForOf { iterable, body, .. } => {
1582
+ ForOf { iterable, body, .. }
1583
+ | ForIn {
1584
+ object: iterable,
1585
+ body,
1586
+ ..
1587
+ } => {
1578
1588
  walk_exprs_expr(iterable, f);
1579
1589
  walk_exprs_stmt_full(body, f);
1580
1590
  }
@@ -1639,7 +1649,7 @@ fn count_returns(s: &Statement) -> usize {
1639
1649
  count_returns(then_branch)
1640
1650
  + else_branch.as_ref().map_or(0, |e| count_returns(e))
1641
1651
  }
1642
- While { body, .. } | DoWhile { body, .. } | For { body, .. } | ForOf { body, .. } => {
1652
+ While { body, .. } | DoWhile { body, .. } | For { body, .. } | ForOf { body, .. } | ForIn { body, .. } => {
1643
1653
  count_returns(body)
1644
1654
  }
1645
1655
  Try { body, catch_body, finally_body, .. } => {
@@ -2694,7 +2704,7 @@ fn seed_numeric_locals(s: &Statement, hyp: &mut InferCtx) {
2694
2704
  }
2695
2705
  seed_numeric_locals(body, hyp);
2696
2706
  }
2697
- While { body, .. } | DoWhile { body, .. } | ForOf { body, .. } => {
2707
+ While { body, .. } | DoWhile { body, .. } | ForOf { body, .. } | ForIn { body, .. } => {
2698
2708
  seed_numeric_locals(body, hyp)
2699
2709
  }
2700
2710
  _ => {}
@@ -3649,7 +3659,7 @@ fn collect_return_shapes(
3649
3659
  }
3650
3660
  }
3651
3661
  }
3652
- For { body, .. } | While { body, .. } | DoWhile { body, .. } | ForOf { body, .. } => {
3662
+ For { body, .. } | While { body, .. } | DoWhile { body, .. } | ForOf { body, .. } | ForIn { body, .. } => {
3653
3663
  if !collect_return_shapes(body, pctx, found, ok) {
3654
3664
  return false;
3655
3665
  }
@@ -3916,7 +3926,7 @@ fn collect_element_aliases(s: &Statement, p: &str, out: &mut HashSet<String>) {
3916
3926
  }
3917
3927
  collect_element_aliases(body, p, out);
3918
3928
  }
3919
- While { body, .. } | DoWhile { body, .. } | ForOf { body, .. } => {
3929
+ While { body, .. } | DoWhile { body, .. } | ForOf { body, .. } | ForIn { body, .. } => {
3920
3930
  collect_element_aliases(body, p, out)
3921
3931
  }
3922
3932
  _ => {}
@@ -4241,7 +4251,12 @@ fn walk_exprs_stmt(s: &Statement, f: &mut impl FnMut(&Expr)) {
4241
4251
  }
4242
4252
  walk_exprs_stmt(body, f);
4243
4253
  }
4244
- ForOf { iterable, body, .. } => {
4254
+ ForOf { iterable, body, .. }
4255
+ | ForIn {
4256
+ object: iterable,
4257
+ body,
4258
+ ..
4259
+ } => {
4245
4260
  walk_exprs_expr(iterable, f);
4246
4261
  walk_exprs_stmt(body, f);
4247
4262
  }
@@ -4354,7 +4369,7 @@ fn collect_body_struct_locals(
4354
4369
  collect_body_struct_locals(e, analysis, out);
4355
4370
  }
4356
4371
  }
4357
- For { body, .. } | While { body, .. } | DoWhile { body, .. } | ForOf { body, .. } => {
4372
+ For { body, .. } | While { body, .. } | DoWhile { body, .. } | ForOf { body, .. } | ForIn { body, .. } => {
4358
4373
  collect_body_struct_locals(body, analysis, out)
4359
4374
  }
4360
4375
  _ => {}
@@ -4388,7 +4403,7 @@ fn find_array_return(s: &Statement, locals: &HashMap<String, String>) -> Option<
4388
4403
  }
4389
4404
  If { then_branch, else_branch, .. } => find_array_return(then_branch, locals)
4390
4405
  .or_else(|| else_branch.as_ref().and_then(|e| find_array_return(e, locals))),
4391
- For { body, .. } | While { body, .. } | DoWhile { body, .. } | ForOf { body, .. } => {
4406
+ For { body, .. } | While { body, .. } | DoWhile { body, .. } | ForOf { body, .. } | ForIn { body, .. } => {
4392
4407
  find_array_return(body, locals)
4393
4408
  }
4394
4409
  _ => None,
@@ -49,17 +49,23 @@ pub struct NativeBuildArtifacts {
49
49
  pub native_init: std::collections::HashMap<String, NativeModuleInit>,
50
50
  }
51
51
 
52
- /// Node-compatible aliases for built-in modules (fs -> tish:fs, etc.).
52
+ /// Node-compatible aliases for built-in modules (fs -> tish:fs, etc.). The `node:` prefix
53
+ /// (e.g. `node:fs`, `node:fs/promises`) is stripped before lookup.
53
54
  const BUILTIN_ALIASES: &[(&str, &str)] = &[
54
55
  ("fs", "tish:fs"),
56
+ ("fs/promises", "tish:fs/promises"),
55
57
  ("http", "tish:http"),
56
58
  ("timers", "tish:timers"),
57
59
  ("process", "tish:process"),
58
60
  ("ws", "tish:ws"),
61
+ ("tty", "tish:tty"),
59
62
  ];
60
63
 
61
- /// Normalize built-in spec to canonical form. E.g. "fs" -> "tish:fs".
64
+ /// Normalize built-in spec to canonical form. Handles the Node `node:` prefix
65
+ /// (`node:fs` -> `tish:fs`, `node:fs/promises` -> `tish:fs/promises`) and bare aliases.
62
66
  pub fn normalize_builtin_spec(spec: &str) -> Option<String> {
67
+ // Strip a leading `node:` so `node:fs` resolves the same as `fs`.
68
+ let spec = spec.strip_prefix("node:").unwrap_or(spec);
63
69
  if spec.starts_with("tish:") {
64
70
  return Some(spec.to_string());
65
71
  }
@@ -71,10 +77,20 @@ pub fn normalize_builtin_spec(spec: &str) -> Option<String> {
71
77
 
72
78
  /// Built-in modules that come from tishlang_runtime, not from package.json.
73
79
  pub fn is_builtin_native_spec(spec: &str) -> bool {
80
+ let spec = spec.strip_prefix("node:").unwrap_or(spec);
74
81
  matches!(
75
82
  spec,
76
- "tish:fs" | "tish:http" | "tish:timers" | "tish:process" | "tish:ws" | "tish:tty"
77
- ) || matches!(spec, "fs" | "http" | "timers" | "process" | "ws" | "tty")
83
+ "tish:fs"
84
+ | "tish:fs/promises"
85
+ | "tish:http"
86
+ | "tish:timers"
87
+ | "tish:process"
88
+ | "tish:ws"
89
+ | "tish:tty"
90
+ ) || matches!(
91
+ spec,
92
+ "fs" | "fs/promises" | "http" | "timers" | "process" | "ws" | "tty"
93
+ )
78
94
  }
79
95
 
80
96
  /// Resolve all native imports in a merged program via package.json lookup.
@@ -238,9 +254,12 @@ pub fn program_uses_document(program: &Program) -> bool {
238
254
  || update.as_ref().is_some_and(expr_uses_document)
239
255
  || stmt_uses_document(body)
240
256
  }
241
- Statement::ForOf { iterable, body, .. } => {
242
- expr_uses_document(iterable) || stmt_uses_document(body)
243
- }
257
+ Statement::ForOf { iterable, body, .. }
258
+ | Statement::ForIn {
259
+ object: iterable,
260
+ body,
261
+ ..
262
+ } => expr_uses_document(iterable) || stmt_uses_document(body),
244
263
  Statement::Switch {
245
264
  expr,
246
265
  cases,
@@ -890,7 +909,8 @@ fn stmt_module_dep(stmt: &Statement) -> Option<&std::sync::Arc<str>> {
890
909
  match stmt {
891
910
  Statement::Import { from, .. } => Some(from),
892
911
  Statement::Export { declaration, .. } => match declaration.as_ref() {
893
- ExportDeclaration::ReExport { from, .. } => Some(from),
912
+ // A local named export (`export { a }`, from=None) has no module dependency.
913
+ ExportDeclaration::ReExport { from, .. } => from.as_ref(),
894
914
  _ => None,
895
915
  },
896
916
  _ => None,
@@ -951,10 +971,15 @@ fn load_module_recursive(
951
971
  ///
952
972
  /// Scoped npm packages (`@scope/pkg`) are merged as Tish source unless imported via `tish:…`.
953
973
  pub fn is_native_import(spec: &str) -> bool {
974
+ // A leading `node:` (node:fs, node:fs/promises) resolves the same as the bare form.
975
+ let spec = spec.strip_prefix("node:").unwrap_or(spec);
954
976
  spec.starts_with("tish:")
955
977
  || spec.starts_with("cargo:")
956
978
  || spec.starts_with("ffi:")
957
- || matches!(spec, "fs" | "http" | "timers" | "process" | "ws")
979
+ || matches!(
980
+ spec,
981
+ "fs" | "fs/promises" | "http" | "timers" | "process" | "ws" | "tty"
982
+ )
958
983
  }
959
984
 
960
985
  /// True for `ffi:…` specs (portable C-ABI cdylib extensions, loadable on every backend). The
@@ -1160,6 +1185,11 @@ fn has_cycle_from(
1160
1185
  pub struct MergedProgram {
1161
1186
  pub program: Program,
1162
1187
  pub statement_sources: Vec<PathBuf>,
1188
+ /// #295: the ENTRY module's exported `(local_name, exported_name)` pairs, excluding `default`
1189
+ /// (handled separately as `export default`). Merge unwraps exports into plain top-level
1190
+ /// declarations, so `--target js --format bundle` re-emits these as a trailing `export { … }` to
1191
+ /// produce a valid ES module. Sorted by local name for deterministic output.
1192
+ pub entry_exports: Vec<(String, String)>,
1163
1193
  }
1164
1194
 
1165
1195
  fn merge_push(
@@ -1465,6 +1495,12 @@ pub(crate) fn rewrite_stmt_scope(
1465
1495
  iterable,
1466
1496
  body,
1467
1497
  ..
1498
+ }
1499
+ | Statement::ForIn {
1500
+ name,
1501
+ object: iterable,
1502
+ body,
1503
+ ..
1468
1504
  } => {
1469
1505
  rewrite_expr_scope(iterable, active);
1470
1506
  let mut child = active.clone();
@@ -1696,6 +1732,14 @@ pub(crate) fn rewrite_expr_scope(expr: &mut Expr, active: &HashMap<String, Arc<s
1696
1732
  /// Import statements are rewritten as bindings from already-emitted dep exports.
1697
1733
  /// Export statements are unwrapped (the inner declaration is emitted).
1698
1734
  pub fn merge_modules(mut modules: Vec<ResolvedModule>) -> Result<MergedProgram, String> {
1735
+ // #469: give the ENTRY module a Go/Rust/Swift-style `main` entry point. The entry is the last
1736
+ // module (post-order dependency load order), and this runs BEFORE private-binding isolation so the
1737
+ // synthetic `main()` call is renamed in lockstep with the `main` declaration if isolation renames
1738
+ // it. Every backend consumes the merged program, so this keeps interp/vm/native/js consistent.
1739
+ if let Some(entry) = modules.last_mut() {
1740
+ tishlang_ast::append_main_entry(&mut entry.program.statements);
1741
+ }
1742
+
1699
1743
  // #97: isolate module-private top-level bindings before they are flattened together.
1700
1744
  isolate_private_top_level_bindings(&mut modules);
1701
1745
 
@@ -1730,7 +1774,7 @@ pub fn merge_modules(mut modules: Vec<ResolvedModule>) -> Result<MergedProgram,
1730
1774
  ExportDeclaration::ReExport {
1731
1775
  specifiers,
1732
1776
  all,
1733
- from,
1777
+ from: Some(from),
1734
1778
  ..
1735
1779
  } => {
1736
1780
  let dir = module.path.parent().unwrap_or_else(|| Path::new("."));
@@ -1758,6 +1802,22 @@ pub fn merge_modules(mut modules: Vec<ResolvedModule>) -> Result<MergedProgram,
1758
1802
  }
1759
1803
  }
1760
1804
  }
1805
+ // #415 local named export (`export { a, b as c }`, no `from`): each specifier
1806
+ // exports an already-declared LOCAL top-level binding, mapping export-name -> that
1807
+ // binding. (`isolate_private_top_level_bindings` keeps exported names un-renamed.)
1808
+ ExportDeclaration::ReExport {
1809
+ specifiers,
1810
+ from: None,
1811
+ ..
1812
+ } => {
1813
+ for spec in specifiers {
1814
+ if let ImportSpecifier::Named { name, alias, .. } = spec {
1815
+ let export_name =
1816
+ alias.as_deref().unwrap_or(name.as_ref()).to_string();
1817
+ module_exports[idx].insert(export_name, name.to_string());
1818
+ }
1819
+ }
1820
+ }
1761
1821
  }
1762
1822
  }
1763
1823
  }
@@ -1956,9 +2016,26 @@ pub fn merge_modules(mut modules: Vec<ResolvedModule>) -> Result<MergedProgram,
1956
2016
  }
1957
2017
  }
1958
2018
  }
2019
+ // #295: capture the ENTRY module's exports (the entry is last — deps are emitted first). Merge
2020
+ // already built `module_exports[idx]` as exported_name -> local_binding; re-express as
2021
+ // (local, exported) for the JS bundle emitter, excluding `default` (emitted as `export default`).
2022
+ let entry_exports: Vec<(String, String)> = modules
2023
+ .len()
2024
+ .checked_sub(1)
2025
+ .map(|last| {
2026
+ let mut v: Vec<(String, String)> = module_exports[last]
2027
+ .iter()
2028
+ .filter(|(exported, _)| exported.as_str() != "default")
2029
+ .map(|(exported, local)| (local.clone(), exported.clone()))
2030
+ .collect();
2031
+ v.sort();
2032
+ v
2033
+ })
2034
+ .unwrap_or_default();
1959
2035
  Ok(MergedProgram {
1960
2036
  program: Program { statements },
1961
2037
  statement_sources,
2038
+ entry_exports,
1962
2039
  })
1963
2040
  }
1964
2041
 
@@ -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, .. } => {
@@ -1044,29 +1068,42 @@ impl Codegen {
1044
1068
  from,
1045
1069
  ..
1046
1070
  } => {
1047
- let spec = rewrite_import_to_js(
1048
- from.as_ref(),
1049
- &self.module_path,
1050
- &self.project_root,
1051
- self.import_rewrite,
1052
- )?;
1053
- if *all {
1054
- self.writeln(&format!("export * from {:?};", spec));
1055
- } else {
1056
- let mut parts: Vec<String> = Vec::new();
1057
- for s in specifiers {
1058
- if let ImportSpecifier::Named { name, alias, .. } = s {
1059
- match alias {
1060
- Some(a) => parts.push(format!(
1061
- "{} as {}",
1062
- Self::escape_ident(name.as_ref()),
1063
- Self::escape_ident(a.as_ref())
1064
- )),
1065
- None => parts.push(Self::escape_ident(name.as_ref()).to_string()),
1066
- }
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
+ ));
1067
1101
  }
1068
1102
  }
1069
- self.writeln(&format!("export {{ {} }} from {:?};", parts.join(", "), spec));
1103
+ // #415 local named export — no `from` clause.
1104
+ None => {
1105
+ self.writeln(&format!("export {{ {} }};", parts.join(", ")));
1106
+ }
1070
1107
  }
1071
1108
  }
1072
1109
  }
@@ -1215,6 +1252,7 @@ fn compile_project_js_inner(
1215
1252
  merged.program.clone()
1216
1253
  };
1217
1254
  let stmt_sources = merged.statement_sources;
1255
+ let entry_exports = merged.entry_exports; // #295: entry module's named exports for the bundle
1218
1256
  let default_export = program.statements.iter().find_map(|s| {
1219
1257
  if let Statement::VarDecl { name, .. } = s {
1220
1258
  let n = name.as_ref();
@@ -1254,6 +1292,26 @@ fn compile_project_js_inner(
1254
1292
  gen.emit_program(&program, None, None)?;
1255
1293
  }
1256
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
+ }
1257
1315
  if let Some(name) = default_export {
1258
1316
  js.push_str(&format!("\nexport default {};\n", name));
1259
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> }"#;
@@ -106,7 +106,8 @@ fn stmt_module_source(stmt: &Statement) -> Option<&str> {
106
106
  match stmt {
107
107
  Statement::Import { from, .. } => Some(&**from),
108
108
  Statement::Export { declaration, .. } => match declaration.as_ref() {
109
- ExportDeclaration::ReExport { from, .. } => Some(&**from),
109
+ // A local named export (`export { a }`, from=None) has no module dependency.
110
+ ExportDeclaration::ReExport { from, .. } => from.as_deref(),
110
111
  _ => None,
111
112
  },
112
113
  _ => None,
@@ -314,7 +315,7 @@ pub fn merge_modules_virtual(modules: Vec<VirtualModule>) -> Result<Program, Str
314
315
  ExportDeclaration::ReExport {
315
316
  specifiers,
316
317
  all,
317
- from,
318
+ from: Some(from),
318
319
  ..
319
320
  } => {
320
321
  let dir = parent_dir(&module.path);
@@ -341,6 +342,20 @@ pub fn merge_modules_virtual(modules: Vec<VirtualModule>) -> Result<Program, Str
341
342
  }
342
343
  }
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
+ }
344
359
  }
345
360
  }
346
361
  }
@@ -224,7 +224,11 @@ fn json_stringify_into_guarded(buf: &mut String, value: &Value, ancestors: &mut
224
224
  /// to `buf`. Optimised for the common case where the input is ASCII and
225
225
  /// contains no characters that need escaping — we fast-pass the bytes
226
226
  /// straight through, only falling into the per-char path on a hit.
227
- fn escape_json_string_into(buf: &mut String, s: &str) {
227
+ /// Append `s` to `buf` as a JSON string body (WITHOUT the surrounding quotes), escaping `"`, `\`,
228
+ /// and every control char < 0x20 (`\n \r \t \b \f`, else `\u00xx`). The single source of truth for
229
+ /// JSON string escaping, shared by the vm/native path here and the interpreter's `JSON.stringify`
230
+ /// (which otherwise hand-rolled an escaper that missed `\b`/`\f`/`\u00xx` and emitted invalid JSON).
231
+ pub fn escape_json_string_into(buf: &mut String, s: &str) {
228
232
  let bytes = s.as_bytes();
229
233
  let mut start = 0usize;
230
234
  for (i, &b) in bytes.iter().enumerate() {
@@ -12,9 +12,11 @@ mod value;
12
12
  mod vmref;
13
13
 
14
14
  pub use console_style::{format_value_styled, format_values_for_console, use_console_colors};
15
- pub use json::{json_parse, json_stringify, json_stringify_into, write_json_number};
15
+ pub use json::{
16
+ escape_json_string_into, json_parse, json_stringify, json_stringify_into, write_json_number,
17
+ };
16
18
  pub use shape::{ShapeId, DICT_SHAPE, EMPTY_SHAPE};
17
- pub use uri::{percent_decode, percent_encode};
19
+ pub use uri::{percent_decode, percent_decode_component, percent_encode, percent_encode_component};
18
20
  pub use arcstr::ArcStr;
19
21
  pub use value::*;
20
22
  pub use vmref::{VmReadGuard, VmRef, VmWriteGuard};
@@ -171,6 +173,50 @@ pub fn not_a_function_error(message: impl Into<String>) -> Value {
171
173
  Value::object(e)
172
174
  }
173
175
 
176
+ /// A catchable `TypeError` with an arbitrary message (`{ name: "TypeError", message }`), matching the
177
+ /// VM/interpreter/node shape. Used to PARK a pending throw from a shared builtin that returns a plain
178
+ /// `Value` and so cannot signal an error with `Result` (e.g. `[].reduce(fn)` with no initial value).
179
+ pub fn type_error(message: impl Into<String>) -> Value {
180
+ let mut e = ObjectMap::default();
181
+ e.insert(
182
+ std::sync::Arc::from("name"),
183
+ Value::String("TypeError".into()),
184
+ );
185
+ e.insert(
186
+ std::sync::Arc::from("message"),
187
+ Value::String(message.into().into()),
188
+ );
189
+ Value::object(e)
190
+ }
191
+
192
+ /// A string key names a canonical array index iff it is a non-negative integer whose decimal form
193
+ /// round-trips and is below the array-index ceiling (`2³²−1`): `"0"`, `"12"` → `Some`; `"01"`, `"-1"`,
194
+ /// `"1.5"`, `"foo"`, `" 1"`, `""` → `None`. Shared so `arr["0"] === arr[0]` on every backend (#432).
195
+ pub fn str_to_array_index(s: &str) -> Option<usize> {
196
+ let i: usize = s.parse().ok()?;
197
+ if i < 4_294_967_295 && i.to_string() == s {
198
+ Some(i)
199
+ } else {
200
+ None
201
+ }
202
+ }
203
+
204
+ /// A catchable `RangeError` with an arbitrary message (`{ name: "RangeError", message }`), matching the
205
+ /// VM/interpreter/node shape. Used to PARK a pending throw from a shared builtin that returns a plain
206
+ /// `Value` (e.g. `[1,2].with(5, x)` with an out-of-range index).
207
+ pub fn range_error(message: impl Into<String>) -> Value {
208
+ let mut e = ObjectMap::default();
209
+ e.insert(
210
+ std::sync::Arc::from("name"),
211
+ Value::String("RangeError".into()),
212
+ );
213
+ e.insert(
214
+ std::sync::Arc::from("message"),
215
+ Value::String(message.into().into()),
216
+ );
217
+ Value::object(e)
218
+ }
219
+
174
220
  /// A catchable `TypeError` for reading a property/index of the nullish value — the `{ name, message }`
175
221
  /// shape matches the VM/interpreter/node (`e.name === "TypeError"`). Used to PARK a pending throw in
176
222
  /// the native/runtime `get_prop`/`get_index` null arm (#425), so `null.length` / `null[0]` surface a
@@ -88,6 +88,51 @@ pub fn percent_encode(input: &str) -> String {
88
88
  result
89
89
  }
90
90
 
91
+ /// Percent-DECODE for `decodeURIComponent` — decodes EVERY `%XX` (unlike `percent_decode`/`decodeURI`,
92
+ /// which preserves the reserved delimiters). Bytes are collected then UTF-8 decoded, so multi-byte
93
+ /// escapes (`%E2%82%AC` → `€`) round-trip. Errors on a malformed `%` sequence (JS `URIError`).
94
+ pub fn percent_decode_component(input: &str) -> Result<String, String> {
95
+ let bytes = input.as_bytes();
96
+ let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
97
+ let mut i = 0;
98
+ while i < bytes.len() {
99
+ if bytes[i] == b'%' {
100
+ if i + 2 < bytes.len() {
101
+ if let Some(b) = std::str::from_utf8(&bytes[i + 1..i + 3])
102
+ .ok()
103
+ .and_then(|s| u8::from_str_radix(s, 16).ok())
104
+ {
105
+ out.push(b);
106
+ i += 3;
107
+ continue;
108
+ }
109
+ }
110
+ return Err("URIError: malformed URI sequence".to_string());
111
+ }
112
+ out.push(bytes[i]);
113
+ i += 1;
114
+ }
115
+ String::from_utf8(out).map_err(|_| "URIError: malformed URI sequence".to_string())
116
+ }
117
+
118
+ /// Percent-encode for `encodeURIComponent` — escapes everything except the unreserved set
119
+ /// `A-Za-z0-9 - _ . ! ~ * ' ( )` (stricter than `encodeURI`, which also preserves the URI reserved
120
+ /// delimiters `; / ? : @ & = + $ , #`).
121
+ pub fn percent_encode_component(input: &str) -> String {
122
+ const UNRESERVED: &[char] = &['-', '_', '.', '!', '~', '*', '\'', '(', ')'];
123
+ let mut result = String::with_capacity(input.len());
124
+ for c in input.chars() {
125
+ if c.is_ascii_alphanumeric() || UNRESERVED.contains(&c) {
126
+ result.push(c);
127
+ } else {
128
+ for byte in c.to_string().as_bytes() {
129
+ result.push_str(&format!("%{:02X}", byte));
130
+ }
131
+ }
132
+ }
133
+ result
134
+ }
135
+
91
136
  #[cfg(test)]
92
137
  mod tests {
93
138
  use super::*;