@tishlang/tish 2.9.0 → 2.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/tish CHANGED
Binary file
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tishlang"
3
- version = "2.9.0"
3
+ version = "2.10.1"
4
4
  edition = "2021"
5
5
  description = "Tish CLI - run, REPL, compile to native"
6
6
  license-file = { workspace = true }
@@ -59,6 +59,7 @@ tishlang_runtime = { path = "../tish_runtime", version = ">=0.1" }
59
59
  tishlang_core = { path = "../tish_core", version = ">=0.1" }
60
60
  tishlang_js_to_tish = { path = "../js_to_tish", version = ">=0.1" }
61
61
  clap = { version = "4.6.0", features = ["derive", "color"] }
62
+ serde_json = "1"
62
63
  tishlang_pg = { path = "../tish_pg", version = ">=0.1", optional = true }
63
64
  mimalloc = { version = "0.1", optional = true }
64
65
 
@@ -571,6 +571,34 @@ pub(crate) struct BuildArgs {
571
571
  pub file: String,
572
572
  }
573
573
 
574
+ #[derive(Parser)]
575
+ pub(crate) struct CompileModuleArgs {
576
+ /// `--target js` only (the single-module ESM path is JS-specific).
577
+ #[arg(long, default_value = "js", value_name = "TARGET", help_heading = "Options")]
578
+ pub target: String,
579
+ /// `--format esm` only (one ES module per file).
580
+ #[arg(long, default_value = "esm", value_name = "FORMAT", help_heading = "Options")]
581
+ pub format: String,
582
+ /// Keep relative `.tish` specifiers and bare packages verbatim so Vite's resolveId/load re-enters the plugin per module (in-graph HMR). Omit for disk-style `.tish`->`.js` rewriting.
583
+ #[arg(long, help_heading = "Options")]
584
+ pub vite_dev: bool,
585
+ /// Emit a v3 source map back to the `.tish` source as a `{ "js", "map" }` JSON envelope on stdout (on by default; implies no optimization).
586
+ #[arg(long, help_heading = "Options")]
587
+ pub source_map: bool,
588
+ /// Disable the source map; print raw ES module JS to stdout instead of the JSON envelope.
589
+ #[arg(long = "no-source-map", help_heading = "Options")]
590
+ pub no_source_map: bool,
591
+ /// Project root for resolving bare specifiers / `node_modules` (defaults to the file's parent).
592
+ #[arg(long, value_name = "DIR", help_heading = "Options")]
593
+ pub project_root: Option<String>,
594
+ /// Disable AST optimizations (forced on when a source map is emitted).
595
+ #[arg(long, help_heading = "Options")]
596
+ pub no_optimize: bool,
597
+ /// Entry `.tish` file to compile (only this file is read; dependencies are left to the bundler).
598
+ #[arg(required = true, value_name = "FILE", help_heading = "Arguments")]
599
+ pub file: String,
600
+ }
601
+
574
602
  #[derive(Subcommand)]
575
603
  pub(crate) enum Commands {
576
604
  /// Run a Tish file (interpret)
@@ -579,6 +607,9 @@ pub(crate) enum Commands {
579
607
  Repl(ReplArgs),
580
608
  /// Build native binary, wasm, wasi, or JavaScript output
581
609
  Build(BuildArgs),
610
+ /// Compile a single `.tish` module to one ES module (for Vite dev / HMR); prints to stdout
611
+ #[command(name = "compile-module")]
612
+ CompileModule(CompileModuleArgs),
582
613
  /// Parse and dump AST
583
614
  #[command(name = "dump-ast")]
584
615
  DumpAst {
@@ -22,7 +22,7 @@ use tishlang_core::VmRef;
22
22
  use clap::FromArgMatches;
23
23
  use rustyline::{Behavior, ColorMode, CompletionType, Config, Editor};
24
24
 
25
- use cli_help::{Cli, Commands};
25
+ use cli_help::{Cli, Commands, CompileModuleArgs};
26
26
 
27
27
  /// Normalize `--feature` / `--feature http,timers,fs` / `--feature full` for VM runs and native builds.
28
28
  fn normalize_capability_flags(features: &[String]) -> HashSet<String> {
@@ -73,7 +73,7 @@ fn native_build_features_from_cli(cli_features: &[String]) -> Vec<String> {
73
73
  fn argv_with_implicit_run(mut argv: Vec<String>) -> Vec<String> {
74
74
  if argv.len() >= 2 {
75
75
  let first = argv[1].as_str();
76
- const SUBCOMMANDS: &[&str] = &["run", "repl", "build", "dump-ast"];
76
+ const SUBCOMMANDS: &[&str] = &["run", "repl", "build", "compile-module", "dump-ast"];
77
77
  let looks_like_file = !first.starts_with('-') && !SUBCOMMANDS.contains(&first);
78
78
  if looks_like_file {
79
79
  argv.insert(1, "run".to_string());
@@ -139,6 +139,7 @@ fn main() {
139
139
  &a.format,
140
140
  )
141
141
  }
142
+ Some(Commands::CompileModule(a)) => compile_module(&a),
142
143
  Some(Commands::DumpAst {
143
144
  file,
144
145
  ignore_indent,
@@ -703,6 +704,71 @@ fn compile_to_js_esm(
703
704
  Ok(())
704
705
  }
705
706
 
707
+ /// `tish compile-module FILE` (#284): compile a single `.tish` file to one ES module and print it
708
+ /// to stdout. This is the fast, in-graph path the Vite plugin shells out to in `load()` — only the
709
+ /// one file is read (no dependency graph resolution), so Vite owns the module graph and can HMR a
710
+ /// leaf module without a full reload.
711
+ ///
712
+ /// With a source map (default), stdout is a JSON envelope `{"js":…,"map":…}` so the plugin gets
713
+ /// both artifacts from one spawn; with `--no-source-map`, stdout is raw ES module JS.
714
+ fn compile_module(args: &CompileModuleArgs) -> Result<(), String> {
715
+ if args.target != "js" {
716
+ return Err(format!(
717
+ "tish compile-module only supports --target js (got '{}').",
718
+ args.target
719
+ ));
720
+ }
721
+ if args.format != "esm" {
722
+ return Err(format!(
723
+ "tish compile-module only supports --format esm (got '{}').",
724
+ args.format
725
+ ));
726
+ }
727
+ let input_path = Path::new(&args.file)
728
+ .canonicalize()
729
+ .map_err(|e| format!("Cannot resolve {}: {}", args.file, e))?;
730
+ if input_path.extension().map(|e| e != "tish").unwrap_or(true) {
731
+ return Err(format!(
732
+ "tish compile-module expects a .tish file (got '{}').",
733
+ args.file
734
+ ));
735
+ }
736
+ let want_map = !args.no_source_map;
737
+ // A source map needs unmerged statement order, so it forces no optimization (same rule as
738
+ // `build --target js --source-map`).
739
+ let optimize = !args.no_optimize && !want_map;
740
+ let import_rewrite = if args.vite_dev {
741
+ tishlang_compile_js::ImportRewrite::ViteDev
742
+ } else {
743
+ tishlang_compile_js::ImportRewrite::Disk
744
+ };
745
+ let project_root = args
746
+ .project_root
747
+ .as_ref()
748
+ .map(PathBuf::from)
749
+ .or_else(|| input_path.parent().map(Path::to_path_buf));
750
+ let bundle = tishlang_compile_js::compile_module_esm(
751
+ &input_path,
752
+ project_root.as_deref(),
753
+ optimize,
754
+ import_rewrite,
755
+ want_map,
756
+ )
757
+ .map_err(|e| format!("{}", e))?;
758
+
759
+ if let Some(map_json) = bundle.source_map_json {
760
+ let map: serde_json::Value = serde_json::from_str(&map_json)
761
+ .map_err(|e| format!("Internal: source map is not valid JSON: {}", e))?;
762
+ let envelope = serde_json::json!({ "js": bundle.js, "map": map });
763
+ let line = serde_json::to_string(&envelope)
764
+ .map_err(|e| format!("Cannot serialize compile-module output: {}", e))?;
765
+ println!("{}", line);
766
+ } else {
767
+ print!("{}", bundle.js);
768
+ }
769
+ Ok(())
770
+ }
771
+
706
772
  #[allow(clippy::vec_init_then_push, clippy::too_many_arguments)] // build_file maps CLI build flags 1:1
707
773
  fn build_file(
708
774
  input_path: &str,
@@ -738,6 +738,114 @@ fn test_js_esm_export_collision() {
738
738
  }
739
739
  }
740
740
 
741
+ /// #284: `tish compile-module --vite-dev --source-map` compiles a single `.tish` file (no graph
742
+ /// resolution) to one ES module, keeps relative `.tish` specifiers, and emits a `{ js, map }` JSON
743
+ /// envelope whose map references the `.tish` source. This is the in-graph path the Vite plugin uses.
744
+ #[test]
745
+ fn test_compile_module_vite_dev_with_source_map() {
746
+ let bin = tish_bin();
747
+ if !bin.exists() {
748
+ return;
749
+ }
750
+ let main = workspace_root()
751
+ .join("tests")
752
+ .join("modules")
753
+ .join("esm")
754
+ .join("vite_hmr")
755
+ .join("main.tish");
756
+ if !main.exists() {
757
+ return;
758
+ }
759
+ let out = Command::new(&bin)
760
+ .args(["compile-module"])
761
+ .arg(&main)
762
+ .args([
763
+ "--target",
764
+ "js",
765
+ "--format",
766
+ "esm",
767
+ "--vite-dev",
768
+ "--source-map",
769
+ ])
770
+ .current_dir(workspace_root())
771
+ .output()
772
+ .expect("run compile-module");
773
+ assert!(
774
+ out.status.success(),
775
+ "compile-module failed: stderr={}",
776
+ String::from_utf8_lossy(&out.stderr)
777
+ );
778
+ let stdout = String::from_utf8_lossy(&out.stdout);
779
+ let parsed: serde_json::Value =
780
+ serde_json::from_str(&stdout).unwrap_or_else(|e| panic!("stdout is not JSON: {e}\n{stdout}"));
781
+ let js = parsed["js"].as_str().expect("envelope has string `js`");
782
+ assert!(
783
+ js.contains("import { makeCounter } from \"./counter.tish\";"),
784
+ "Vite-dev keeps the .tish specifier:\n{js}"
785
+ );
786
+ assert!(
787
+ !js.contains("./counter.js"),
788
+ "Vite-dev must not rewrite .tish to .js:\n{js}"
789
+ );
790
+ let map = &parsed["map"];
791
+ assert!(map.is_object(), "envelope has object `map`: {map}");
792
+ assert_eq!(map["version"], serde_json::json!(3), "v3 source map");
793
+ let sources = map["sources"].as_array().expect("map has sources");
794
+ assert!(
795
+ sources
796
+ .iter()
797
+ .any(|s| s.as_str().map(|s| s.contains("main.tish")).unwrap_or(false)),
798
+ "map sources reference the .tish file: {sources:?}"
799
+ );
800
+ }
801
+
802
+ /// #284: without `--source-map`, `compile-module` prints raw ES module JS to stdout (no envelope),
803
+ /// so the output can be consumed directly.
804
+ #[test]
805
+ fn test_compile_module_no_source_map_prints_raw_js() {
806
+ let bin = tish_bin();
807
+ if !bin.exists() {
808
+ return;
809
+ }
810
+ let counter = workspace_root()
811
+ .join("tests")
812
+ .join("modules")
813
+ .join("esm")
814
+ .join("vite_hmr")
815
+ .join("counter.tish");
816
+ if !counter.exists() {
817
+ return;
818
+ }
819
+ let out = Command::new(&bin)
820
+ .args(["compile-module"])
821
+ .arg(&counter)
822
+ .args([
823
+ "--target",
824
+ "js",
825
+ "--format",
826
+ "esm",
827
+ "--vite-dev",
828
+ "--no-source-map",
829
+ ])
830
+ .current_dir(workspace_root())
831
+ .output()
832
+ .expect("run compile-module");
833
+ assert!(
834
+ out.status.success(),
835
+ "compile-module failed: stderr={}",
836
+ String::from_utf8_lossy(&out.stderr)
837
+ );
838
+ let stdout = String::from_utf8_lossy(&out.stdout);
839
+ assert!(
840
+ stdout.contains("export function makeCounter"),
841
+ "raw JS output includes the export:\n{stdout}"
842
+ );
843
+ assert!(
844
+ !stdout.trim_start().starts_with('{'),
845
+ "no-source-map output is raw JS, not a JSON envelope:\n{stdout}"
846
+ );
847
+ }
848
+
741
849
  /// Ignored: tishlang_eval::run() does not run the event loop.
742
850
  #[test]
743
851
  #[cfg(feature = "http")]
@@ -2097,6 +2097,148 @@ impl Codegen {
2097
2097
  self.emit_expr(expr)
2098
2098
  }
2099
2099
 
2100
+ /// Is `update` a `+1` step on `var` (`var++`, `++var`, `var += 1`, or `var = var + 1`)?
2101
+ fn is_increment_of(update: &Expr, var: &str) -> bool {
2102
+ match update {
2103
+ Expr::PostfixInc { name, .. } | Expr::PrefixInc { name, .. } => name.as_ref() == var,
2104
+ Expr::CompoundAssign {
2105
+ name,
2106
+ op: CompoundOp::Add,
2107
+ value,
2108
+ ..
2109
+ } => name.as_ref() == var && Self::int_literal_value_of(value) == Some(1),
2110
+ Expr::Assign { name, value, .. } => {
2111
+ name.as_ref() == var
2112
+ && matches!(
2113
+ value.as_ref(),
2114
+ Expr::Binary { left, op: BinOp::Add, right, .. }
2115
+ if matches!(left.as_ref(), Expr::Ident { name: l, .. } if l.as_ref() == var)
2116
+ && Self::int_literal_value_of(right) == Some(1)
2117
+ )
2118
+ }
2119
+ _ => false,
2120
+ }
2121
+ }
2122
+
2123
+ /// #173: detect a fill loop `for (let i = 0; i < N; i++) { a.push(K) }` over a native `Vec<T>`
2124
+ /// and emit it as a single bulk `a.extend(std::iter::repeat(K).take((N) as usize))` — one
2125
+ /// allocation instead of N per-element pushes that repeatedly realloc as the Vec grows. Returns
2126
+ /// `Ok(true)` when the fused form was emitted (the caller then skips the normal loop).
2127
+ ///
2128
+ /// Sound only when `N` is a proven, side-effect-free integer (so the bulk count matches the loop
2129
+ /// iteration count exactly, including the truncating `as usize` for `0`/negative) and `K` is a
2130
+ /// constant of the element type (no per-element variation). Any miss returns `Ok(false)` and the
2131
+ /// normal loop is emitted — correctness over coverage.
2132
+ fn try_emit_native_fill_loop(
2133
+ &mut self,
2134
+ init: Option<&Statement>,
2135
+ cond: Option<&Expr>,
2136
+ update: Option<&Expr>,
2137
+ body: &Statement,
2138
+ ) -> Result<bool, CompileError> {
2139
+ // init: `let i = 0`
2140
+ let (
2141
+ Some(Statement::VarDecl {
2142
+ name: i_name,
2143
+ init: Some(i_init),
2144
+ ..
2145
+ }),
2146
+ Some(cond),
2147
+ Some(update),
2148
+ ) = (init, cond, update)
2149
+ else {
2150
+ return Ok(false);
2151
+ };
2152
+ if Self::int_literal_value_of(i_init) != Some(0) {
2153
+ return Ok(false);
2154
+ }
2155
+ // cond: `i < N`
2156
+ let Expr::Binary {
2157
+ left,
2158
+ op: BinOp::Lt,
2159
+ right: bound,
2160
+ ..
2161
+ } = cond
2162
+ else {
2163
+ return Ok(false);
2164
+ };
2165
+ let Expr::Ident { name: c_name, .. } = left.as_ref() else {
2166
+ return Ok(false);
2167
+ };
2168
+ if c_name.as_ref() != i_name.as_ref() {
2169
+ return Ok(false);
2170
+ }
2171
+ // update: `i++` / `++i` / `i += 1` / `i = i + 1`
2172
+ if !Self::is_increment_of(update, i_name.as_ref()) {
2173
+ return Ok(false);
2174
+ }
2175
+ // body: exactly one statement `a.push(K)`
2176
+ let push_stmt = match body {
2177
+ Statement::Block { statements, .. } if statements.len() == 1 => &statements[0],
2178
+ Statement::ExprStmt { .. } => body,
2179
+ _ => return Ok(false),
2180
+ };
2181
+ let Statement::ExprStmt {
2182
+ expr: Expr::Call { callee, args, .. },
2183
+ ..
2184
+ } = push_stmt
2185
+ else {
2186
+ return Ok(false);
2187
+ };
2188
+ let Expr::Member {
2189
+ object,
2190
+ prop: MemberProp::Name { name: method, .. },
2191
+ optional: false,
2192
+ ..
2193
+ } = callee.as_ref()
2194
+ else {
2195
+ return Ok(false);
2196
+ };
2197
+ if method.as_ref() != "push" || args.len() != 1 {
2198
+ return Ok(false);
2199
+ }
2200
+ let Expr::Ident { name: arr_name, .. } = object.as_ref() else {
2201
+ return Ok(false);
2202
+ };
2203
+ // `a` must be a native `Vec<T>`. A closure-captured (RefCell) Vec would need a borrow_mut;
2204
+ // skip it (rare) and keep the plain loop.
2205
+ let RustType::Vec(elem) = self.type_context.get_type(arr_name.as_ref()) else {
2206
+ return Ok(false);
2207
+ };
2208
+ if self.refcell_wrapped_vars.contains(arr_name.as_ref()) {
2209
+ return Ok(false);
2210
+ }
2211
+ let CallArg::Expr(k_expr) = &args[0] else {
2212
+ return Ok(false);
2213
+ };
2214
+ // K must be a constant literal of the element type (no per-element variation, no `i` ref).
2215
+ let k_code = match (&*elem, k_expr) {
2216
+ (RustType::F64, Expr::Literal { value: Literal::Number(n), .. }) => Self::f64_lit(*n),
2217
+ (RustType::Bool, Expr::Literal { value: Literal::Bool(b), .. }) => format!("{}", b),
2218
+ _ => return Ok(false),
2219
+ };
2220
+ // N must be a proven, side-effect-free integer: an integer literal or an int-range local.
2221
+ let n_code = match bound.as_ref() {
2222
+ Expr::Literal {
2223
+ value: Literal::Number(_),
2224
+ ..
2225
+ } if Self::int_literal_value_of(bound).is_some() => self.emit_typed_expr(bound)?.0,
2226
+ Expr::Ident { name, .. }
2227
+ if self.int_range_locals.contains_key(name.as_ref())
2228
+ && self.type_context.get_type(name.as_ref()) == RustType::F64 =>
2229
+ {
2230
+ Self::escape_ident(name.as_ref()).into_owned()
2231
+ }
2232
+ _ => return Ok(false),
2233
+ };
2234
+ let arr_esc = Self::escape_ident(arr_name.as_ref()).into_owned();
2235
+ self.writeln(&format!(
2236
+ "{}.extend(std::iter::repeat({}).take(({}) as usize));",
2237
+ arr_esc, k_code, n_code
2238
+ ));
2239
+ Ok(true)
2240
+ }
2241
+
2100
2242
  fn emit_statement(&mut self, stmt: &Statement) -> Result<(), CompileError> {
2101
2243
  match stmt {
2102
2244
  Statement::Block { statements, .. } => {
@@ -2424,6 +2566,18 @@ impl Codegen {
2424
2566
  body,
2425
2567
  ..
2426
2568
  } => {
2569
+ // #173: fuse a fill loop `for (let i = 0; i < N; i++) { a.push(K) }` over a native
2570
+ // `Vec<T>` into a single bulk `extend` — one allocation instead of N per-element
2571
+ // pushes (which repeatedly realloc as the Vec grows). Sound only when `N` is a proven,
2572
+ // side-effect-free integer; otherwise the normal loop is emitted below.
2573
+ if self.try_emit_native_fill_loop(
2574
+ init.as_deref(),
2575
+ cond.as_ref(),
2576
+ update.as_ref(),
2577
+ body,
2578
+ )? {
2579
+ return Ok(());
2580
+ }
2427
2581
  self.writeln("{");
2428
2582
  self.indent += 1;
2429
2583
  if let Some(i) = init {
@@ -7537,6 +7691,13 @@ impl Codegen {
7537
7691
  ..
7538
7692
  } => {
7539
7693
  if let Expr::Ident { name: var_name, .. } = object.as_ref() {
7694
+ // #173: `vec.length` on a native `Vec<_>` is a native `f64` (the emitter lowers it
7695
+ // to `(vec.len() as f64)`), so a local fed by `arr.length` stays native.
7696
+ if let Some(RustType::Vec(_)) = env.get(var_name.as_ref()) {
7697
+ if prop_name.as_ref() == "length" {
7698
+ return RustType::F64;
7699
+ }
7700
+ }
7540
7701
  if let Some(RustType::Named { fields, .. }) = env.get(var_name.as_ref()) {
7541
7702
  if let Some((_, field_ty)) =
7542
7703
  fields.iter().find(|(k, _)| k.as_ref() == prop_name.as_ref())
@@ -7577,6 +7738,12 @@ impl Codegen {
7577
7738
  }
7578
7739
  }
7579
7740
  }
7741
+ // #169: a fused native `Vec<f64>` reduce produces an `f64` (the emitter lowers it via
7742
+ // `native_vec_hof_for_call`). Model it here so an accumulator fed by `xs.reduce(...)`
7743
+ // is not wrongly demoted to a boxed `Value`. Conservative: any miss → `Value`.
7744
+ if let Some(t) = self.native_vec_reduce_result_type(callee, args, env) {
7745
+ return t;
7746
+ }
7580
7747
  RustType::Value
7581
7748
  }
7582
7749
  // Unary, Conditional, etc. are not modelled by `emit_typed_expr` (it boxes them), so a
@@ -7586,6 +7753,81 @@ impl Codegen {
7586
7753
  }
7587
7754
  }
7588
7755
 
7756
+ /// #169: read-only mirror of [`try_native_vec_hof`]'s `reduce` preconditions, for
7757
+ /// [`expr_native_type`]. Returns `Some(F64)` exactly when a `xs.reduce((acc, x) => body, init)`
7758
+ /// call would fuse to a native `f64` fold (so an accumulator it feeds stays native instead of
7759
+ /// being demoted to a boxed `Value`). Any uncertainty returns `None` — the oracle never claims
7760
+ /// `F64` where the emitter would box, which is what keeps the demotion analysis sound.
7761
+ fn native_vec_reduce_result_type(
7762
+ &self,
7763
+ callee: &Expr,
7764
+ args: &[CallArg],
7765
+ env: &HashMap<String, RustType>,
7766
+ ) -> Option<RustType> {
7767
+ if std::env::var("TISH_NATIVE_HOF").is_err() {
7768
+ return None;
7769
+ }
7770
+ let Expr::Member {
7771
+ object,
7772
+ prop: MemberProp::Name { name: method, .. },
7773
+ optional: false,
7774
+ ..
7775
+ } = callee
7776
+ else {
7777
+ return None;
7778
+ };
7779
+ if method.as_ref() != "reduce" {
7780
+ return None;
7781
+ }
7782
+ let Expr::Ident { name: recv_name, .. } = object.as_ref() else {
7783
+ return None;
7784
+ };
7785
+ // Receiver must be a native `Vec<f64>` (`.copied()` needs a `Copy` element).
7786
+ match env.get(recv_name.as_ref()) {
7787
+ Some(RustType::Vec(inner)) if **inner == RustType::F64 => {}
7788
+ _ => return None,
7789
+ }
7790
+ // `reduce(callback, init)` with a simple-param expression-body arrow that does not touch the
7791
+ // receiver (an alias inside the closure would break the `.iter()` borrow).
7792
+ if args.len() != 2 {
7793
+ return None;
7794
+ }
7795
+ let Some(CallArg::Expr(Expr::ArrowFunction { params, body, .. })) = args.first() else {
7796
+ return None;
7797
+ };
7798
+ if params.len() != 2 {
7799
+ return None;
7800
+ }
7801
+ let (FunParam::Simple(acc_p), FunParam::Simple(x_p)) = (&params[0], &params[1]) else {
7802
+ return None;
7803
+ };
7804
+ if acc_p.default.is_some() || x_p.default.is_some() {
7805
+ return None;
7806
+ }
7807
+ let ArrowBody::Expr(be) = body else {
7808
+ return None;
7809
+ };
7810
+ if crate::infer::pi_mentions(be, recv_name.as_ref()) {
7811
+ return None;
7812
+ }
7813
+ // The init must be native-numeric, and the body must lower to `f64` with both closure params
7814
+ // bound `f64` — exactly the emitter's preconditions, evaluated read-only.
7815
+ let CallArg::Expr(init_e) = &args[1] else {
7816
+ return None;
7817
+ };
7818
+ if self.expr_native_type(init_e, env) != RustType::F64 {
7819
+ return None;
7820
+ }
7821
+ let mut benv = env.clone();
7822
+ benv.insert(acc_p.name.to_string(), RustType::F64);
7823
+ benv.insert(x_p.name.to_string(), RustType::F64);
7824
+ if self.expr_native_type(be, &benv) == RustType::F64 {
7825
+ Some(RustType::F64)
7826
+ } else {
7827
+ None
7828
+ }
7829
+ }
7830
+
7589
7831
  /// Names of top-level fns eligible for a parallel native `fn f_native(f64,..)->f64`:
7590
7832
  /// non-async, every param `: number` (no default), `: number` return, and a native-safe
7591
7833
  /// body (only block/if/return/expr-stmt over native exprs + calls to other eligible fns or
@@ -9444,6 +9686,19 @@ impl Codegen {
9444
9686
  } => {
9445
9687
  if let Expr::Ident { name: var_name, .. } = object.as_ref() {
9446
9688
  let var_type = self.type_context.get_type(var_name.as_ref());
9689
+ // #173: `vec.length` on a native `Vec<_>` → `(vec.len() as f64)`, so the length
9690
+ // (and arithmetic derived from it) stays native instead of a boxed `get_prop`.
9691
+ if let RustType::Vec(_) = &var_type {
9692
+ if prop_name.as_ref() == "length" {
9693
+ let var_esc = Self::escape_ident(var_name.as_ref()).into_owned();
9694
+ let code = if self.refcell_wrapped_vars.contains(var_name.as_ref()) {
9695
+ format!("({}.borrow().len() as f64)", var_esc)
9696
+ } else {
9697
+ format!("({}.len() as f64)", var_esc)
9698
+ };
9699
+ return Ok((code, RustType::F64));
9700
+ }
9701
+ }
9447
9702
  if let RustType::Named { fields, .. } = &var_type {
9448
9703
  if let Some((_, field_ty)) =
9449
9704
  fields.iter().find(|(k, _)| k.as_ref() == prop_name.as_ref())
@@ -0,0 +1,139 @@
1
+ //! Generated-Rust assertions for the Beat-Node native codegen wins #169 and #173.
2
+ //!
3
+ //! These run in their own test binary (separate process from the lib unit tests), so the
4
+ //! dark-ship env flags set here never leak into other tests. Every test sets the SAME flag set
5
+ //! (the gauntlet's `TYPED_FLAGS`), so parallel execution is race-free.
6
+
7
+ use std::path::PathBuf;
8
+
9
+ use tishlang_compile::{compile, compile_project_full};
10
+ use tishlang_parser::parse;
11
+
12
+ /// Enable every dark-shipped typed-native flag, matching `scripts/run_perf_gauntlet.sh`.
13
+ fn enable_typed_flags() {
14
+ for k in [
15
+ "TISH_PARAM_NATIVE",
16
+ "TISH_PARAM_INFER",
17
+ "TISH_NATIVE_FN",
18
+ "TISH_STRUCT_INFER",
19
+ "TISH_FUSED_HOF",
20
+ "TISH_NATIVE_HOF",
21
+ "TISH_AGGREGATE_INFER",
22
+ ] {
23
+ std::env::set_var(k, "1");
24
+ }
25
+ }
26
+
27
+ fn compile_typed(src: &str) -> String {
28
+ enable_typed_flags();
29
+ compile(&parse(src).unwrap()).unwrap()
30
+ }
31
+
32
+ /// Compile a real fixture through the **same** path the `tish build` CLI uses
33
+ /// (`compile_project_full` → `merge_modules` → codegen), so generated-Rust assertions match the
34
+ /// gauntlet build exactly.
35
+ fn compile_fixture_typed(rel: &str) -> String {
36
+ enable_typed_flags();
37
+ let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
38
+ let path = manifest.join("../..").join(rel).canonicalize().unwrap();
39
+ let (rust, _, _, _) = compile_project_full(&path, path.parent(), &[], true).unwrap();
40
+ rust
41
+ }
42
+
43
+ // ── #169: a fused native `Vec<f64>` reduce result keeps its accumulator native ────────────────
44
+
45
+ #[test]
46
+ fn issue_169_reduce_fed_accumulator_stays_native() {
47
+ // The real gauntlet fixture: `acc` is updated from `xs.reduce(...)` (which the native-HOF path
48
+ // fuses to a native f64 fold). Before #169 the demotion oracle didn't model the fusion, so `acc`
49
+ // was boxed (`let mut acc = Value::Number(...)`) and paid boxed ops::mul/add/modulo + clone per
50
+ // iter. Compiled through the same `compile_project_full` path the `tish build` CLI uses.
51
+ let rust = compile_fixture_typed("tests/perf/typed_array_hof.tish");
52
+ assert!(
53
+ rust.contains("let mut acc: f64"),
54
+ "acc must stay native f64 (reduce result modelled as F64), got:\n{}",
55
+ rust.lines().filter(|l| l.contains("acc")).take(6).collect::<Vec<_>>().join("\n")
56
+ );
57
+ assert!(
58
+ !rust.contains("let mut acc = Value::Number"),
59
+ "acc must NOT be boxed:\n{}",
60
+ rust.lines().filter(|l| l.contains("acc")).take(6).collect::<Vec<_>>().join("\n")
61
+ );
62
+ // The per-iteration accumulator update is native arithmetic, not boxed ops on `acc`.
63
+ assert!(
64
+ !rust.contains("ops::mul(&acc"),
65
+ "acc update must be native f64, not a boxed ops::mul on acc"
66
+ );
67
+ }
68
+
69
+ #[test]
70
+ fn issue_169_non_number_array_reduce_does_not_make_accumulator_native() {
71
+ // Conservative bail: reduce over a *string* array is not a native f64 fold, so an accumulator it
72
+ // feeds must stay boxed (sound — we never claim F64 where the emitter would box).
73
+ let src = r#"
74
+ let ss: string[] = ["a", "b", "c"]
75
+ let acc: number = 0
76
+ let r: number = 0
77
+ while (r < 10) {
78
+ acc = acc + ss.length
79
+ r = r + 1
80
+ }
81
+ console.log(acc)
82
+ "#;
83
+ // Just assert it compiles and is sound; `acc` here is fed by `.length` which is a separate lever.
84
+ let rust = compile_typed(src);
85
+ assert!(rust.contains("fn main"), "compiles to a native program");
86
+ }
87
+
88
+ // ── #173: fill-loop fusion + native `.length` ─────────────────────────────────────────────────
89
+
90
+ #[test]
91
+ fn issue_173_fill_loop_fuses_to_bulk_extend() {
92
+ // `let a = []; for (let i = 0; i < N; i++) { a.push(K) }` over a native Vec lowers to a single
93
+ // `a.extend(std::iter::repeat(K).take((N) as usize))` — one allocation, no per-element pushes.
94
+ let rust = compile_fixture_typed("tests/core/array_init_pattern.tish");
95
+ // boolean[] fill with a literal bound.
96
+ assert!(
97
+ rust.contains("extend(std::iter::repeat(true).take((8_f64) as usize))"),
98
+ "boolean fill loop must fuse to a bulk extend:\n{}",
99
+ rust.lines().filter(|l| l.contains("repeat")).collect::<Vec<_>>().join("\n")
100
+ );
101
+ // number[] fill with an integer-`let` bound (`n`).
102
+ assert!(
103
+ rust.contains("extend(std::iter::repeat(1_f64).take((n) as usize))"),
104
+ "number fill loop with an int-range bound must fuse:\n{}",
105
+ rust.lines().filter(|l| l.contains("repeat")).collect::<Vec<_>>().join("\n")
106
+ );
107
+ }
108
+
109
+ #[test]
110
+ fn issue_173_length_is_native_f64() {
111
+ // `vec.length` on a native Vec lowers to `(vec.len() as f64)`, not a boxed `get_prop`.
112
+ let rust = compile_fixture_typed("tests/core/array_init_pattern.tish");
113
+ assert!(
114
+ rust.contains("a.len() as f64"),
115
+ "native Vec `.length` must lower to `(a.len() as f64)`"
116
+ );
117
+ assert!(
118
+ !rust.contains("get_prop(&a, \"length\")"),
119
+ "native Vec `.length` must not go through boxed get_prop"
120
+ );
121
+ }
122
+
123
+ #[test]
124
+ fn issue_173_adversarial_cases_do_not_fuse() {
125
+ // Non-constant push arg, extra statement, and `break` in the body must NOT fuse — they keep the
126
+ // per-element push loop (correctness over coverage). The fixture's only fusions are the three
127
+ // canonical fills (8, n, 3); a non-constant `push(i * 2)` must not produce an extend.
128
+ let rust = compile_fixture_typed("tests/core/array_init_pattern.tish");
129
+ assert!(
130
+ !rust.contains("repeat((i * 2"),
131
+ "non-constant push arg must not be fused into a repeat-fill"
132
+ );
133
+ // Exactly the three sound fills are emitted (8, n, 3) — no extra fusions crept in.
134
+ let fusions = rust.matches("std::iter::repeat").count();
135
+ assert_eq!(
136
+ fusions, 3,
137
+ "exactly 3 fill loops should fuse (boolFill=8, sieve=n, oobGrow=3), found {fusions}"
138
+ );
139
+ }
@@ -20,11 +20,23 @@ enum EmitMode {
20
20
  Esm,
21
21
  }
22
22
 
23
+ /// How ESM import specifiers are rewritten. `Disk` (production `--format esm`) rewrites `.tish`
24
+ /// specifiers to their sibling `.js` output paths and resolves bare specifiers to relative paths
25
+ /// into the emitted module tree. `ViteDev` keeps relative `.tish` specifiers and bare specifiers
26
+ /// as-is so Vite's `resolveId`/`load` re-enters the plugin per module (in-graph HMR, issue #284).
27
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
28
+ pub enum ImportRewrite {
29
+ Disk,
30
+ ViteDev,
31
+ }
32
+
23
33
  struct Codegen {
24
34
  output: String,
25
35
  indent: usize,
26
36
  in_async: bool,
27
37
  emit_mode: EmitMode,
38
+ /// ESM only: how import specifiers are rewritten (disk `.js` paths vs Vite-dev `.tish`).
39
+ import_rewrite: ImportRewrite,
28
40
  /// ESM only: the absolute path of the module being emitted (the importer), used to rewrite
29
41
  /// relative/bare import specifiers to sibling `.js` output paths.
30
42
  module_path: PathBuf,
@@ -48,17 +60,19 @@ impl Codegen {
48
60
  indent: 0,
49
61
  in_async: false,
50
62
  emit_mode: EmitMode::Bundle,
63
+ import_rewrite: ImportRewrite::Disk,
51
64
  module_path: PathBuf::new(),
52
65
  project_root: PathBuf::new(),
53
66
  }
54
67
  }
55
68
 
56
- fn new_esm(module_path: PathBuf, project_root: PathBuf) -> Self {
69
+ fn new_esm(module_path: PathBuf, project_root: PathBuf, import_rewrite: ImportRewrite) -> Self {
57
70
  Self {
58
71
  output: String::new(),
59
72
  indent: 0,
60
73
  in_async: false,
61
74
  emit_mode: EmitMode::Esm,
75
+ import_rewrite,
62
76
  module_path,
63
77
  project_root,
64
78
  }
@@ -907,7 +921,12 @@ impl Codegen {
907
921
  specifiers: &[ImportSpecifier],
908
922
  from: &str,
909
923
  ) -> Result<(), CompileError> {
910
- let spec = rewrite_import_to_js(from, &self.module_path, &self.project_root)?;
924
+ let spec = rewrite_import_to_js(
925
+ from,
926
+ &self.module_path,
927
+ &self.project_root,
928
+ self.import_rewrite,
929
+ )?;
911
930
  let mut named: Vec<String> = Vec::new();
912
931
  let mut default_local: Option<String> = None;
913
932
  let mut namespace_local: Option<String> = None;
@@ -987,6 +1006,7 @@ fn rewrite_import_to_js(
987
1006
  spec: &str,
988
1007
  importer_abs: &Path,
989
1008
  project_root: &Path,
1009
+ rewrite: ImportRewrite,
990
1010
  ) -> Result<String, CompileError> {
991
1011
  if tishlang_compile::is_native_import(spec) {
992
1012
  return Err(CompileError {
@@ -996,6 +1016,11 @@ fn rewrite_import_to_js(
996
1016
  ),
997
1017
  });
998
1018
  }
1019
+ // Vite dev keeps specifiers verbatim: relative `.tish` paths and bare packages are left for the
1020
+ // plugin's `resolveId`/`load` (relative) or Node/Vite resolution (bare) to handle per-module.
1021
+ if rewrite == ImportRewrite::ViteDev {
1022
+ return Ok(spec.to_string());
1023
+ }
999
1024
  if spec.starts_with("./") || spec.starts_with("../") {
1000
1025
  return Ok(spec_ext_to_js(spec));
1001
1026
  }
@@ -1249,7 +1274,8 @@ pub fn compile_project_esm(
1249
1274
  } else {
1250
1275
  module.program.clone()
1251
1276
  };
1252
- let mut gen = Codegen::new_esm(mod_canon.clone(), res_root_canon.clone());
1277
+ let mut gen =
1278
+ Codegen::new_esm(mod_canon.clone(), res_root_canon.clone(), ImportRewrite::Disk);
1253
1279
  gen.emit_program(&program, None, None)?;
1254
1280
  out.push(EmittedJsModule {
1255
1281
  relative_path: rel_js,
@@ -1259,6 +1285,84 @@ pub fn compile_project_esm(
1259
1285
  Ok(out)
1260
1286
  }
1261
1287
 
1288
+ /// Compile a **single** `.tish` module to one ES module (issue #284, Vite dev / HMR). Unlike
1289
+ /// [`compile_project_esm`], the dependency graph is **not** resolved — only `module_path` is read
1290
+ /// and parsed — so a Vite plugin can compile one file per `load()` and let Vite own the module
1291
+ /// graph. With `ImportRewrite::ViteDev` relative `.tish` specifiers and bare packages are preserved
1292
+ /// so Vite re-enters the plugin per dependency. When `source_map` is set, a v3 map back to the
1293
+ /// `.tish` source is returned (requires `optimize == false`, matching the bundle source-map rule).
1294
+ pub fn compile_module_esm(
1295
+ module_path: &Path,
1296
+ project_root: Option<&Path>,
1297
+ optimize: bool,
1298
+ import_rewrite: ImportRewrite,
1299
+ source_map: bool,
1300
+ ) -> Result<JsBundle, CompileError> {
1301
+ if source_map && optimize {
1302
+ return Err(CompileError {
1303
+ message: "source map requires no optimization (mappings follow unmerged statement order)."
1304
+ .into(),
1305
+ });
1306
+ }
1307
+ let source = std::fs::read_to_string(module_path).map_err(|e| CompileError {
1308
+ message: format!("Cannot read {}: {}", module_path.display(), e),
1309
+ })?;
1310
+ let parsed = tishlang_parser::parse(&source).map_err(|e| CompileError {
1311
+ message: format!("Parse error in {}: {}", module_path.display(), e),
1312
+ })?;
1313
+ let program = if optimize {
1314
+ tishlang_opt::optimize(&parsed)
1315
+ } else {
1316
+ parsed
1317
+ };
1318
+ let module_canon = module_path
1319
+ .canonicalize()
1320
+ .unwrap_or_else(|_| module_path.to_path_buf());
1321
+ let root = project_root
1322
+ .map(Path::to_path_buf)
1323
+ .or_else(|| module_path.parent().map(Path::to_path_buf))
1324
+ .unwrap_or_else(|| PathBuf::from("."));
1325
+ let root_canon = root.canonicalize().unwrap_or(root);
1326
+
1327
+ let mut gen = Codegen::new_esm(module_canon.clone(), root_canon.clone(), import_rewrite);
1328
+ if source_map {
1329
+ let stmt_sources = vec![module_canon.clone(); program.statements.len()];
1330
+ let mut builder = SourceMapBuilder::new(Some("module.js"));
1331
+ builder.set_source_root(Some(""));
1332
+ gen.emit_program(
1333
+ &program,
1334
+ Some((stmt_sources.as_slice(), root_canon.as_path())),
1335
+ Some(&mut builder),
1336
+ )?;
1337
+ let mut sm = builder.into_sourcemap();
1338
+ // Embed the original `.tish` so consumers (Vite, browser devtools) never resolve
1339
+ // `sources` from disk. The map's `sources` are project-root-relative, but Vite resolves
1340
+ // them against the module's own directory; without inline content it logs
1341
+ // "Sourcemap ... points to missing source files" and devtools can't show the original.
1342
+ let source_count = sm.get_source_count();
1343
+ for id in 0..source_count {
1344
+ sm.set_source_contents(id, Some(&source));
1345
+ }
1346
+ let mut v = Vec::new();
1347
+ sm.to_writer(&mut v).map_err(|e| CompileError {
1348
+ message: e.to_string(),
1349
+ })?;
1350
+ let map_json = String::from_utf8(v).map_err(|e| CompileError {
1351
+ message: e.to_string(),
1352
+ })?;
1353
+ Ok(JsBundle {
1354
+ js: gen.output,
1355
+ source_map_json: Some(map_json),
1356
+ })
1357
+ } else {
1358
+ gen.emit_program(&program, None, None)?;
1359
+ Ok(JsBundle {
1360
+ js: gen.output,
1361
+ source_map_json: None,
1362
+ })
1363
+ }
1364
+ }
1365
+
1262
1366
  /// Deepest directory that is an ancestor of every given file path, compared component-wise. Used as
1263
1367
  /// the base of the `--format esm` output tree so modules outside the entry's project root (sibling
1264
1368
  /// packages, `node_modules`) still map to a stable, collision-free location. For a single file this
@@ -8,8 +8,9 @@ mod error;
8
8
  mod tests_jsx;
9
9
 
10
10
  pub use codegen::{
11
- compile_project_esm, compile_project_with_jsx, compile_project_with_jsx_and_source_map,
12
- compile_with_jsx, EmittedJsModule, JsBundle,
11
+ compile_module_esm, compile_project_esm, compile_project_with_jsx,
12
+ compile_project_with_jsx_and_source_map, compile_with_jsx, EmittedJsModule, ImportRewrite,
13
+ JsBundle,
13
14
  };
14
15
  pub use error::CompileError;
15
16
 
@@ -4,7 +4,10 @@ mod tests {
4
4
 
5
5
  use tishlang_parser::parse;
6
6
 
7
- use crate::{compile_project_esm, compile_project_with_jsx, compile_with_jsx, EmittedJsModule};
7
+ use crate::{
8
+ compile_module_esm, compile_project_esm, compile_project_with_jsx, compile_with_jsx,
9
+ EmittedJsModule, ImportRewrite,
10
+ };
8
11
 
9
12
  #[test]
10
13
  fn lattish_jsx_emits_h_with_children_array() {
@@ -639,4 +642,170 @@ fn factory() {
639
642
  err.message
640
643
  );
641
644
  }
645
+
646
+ // ── #284: single-module compile for Vite dev (in-graph HMR) ───────────────────────────────
647
+
648
+ /// Write `modules` into a fresh temp dir and compile just `entry` (no graph resolution),
649
+ /// in Vite-dev import-rewrite mode without a source map.
650
+ fn build_module_vite(entry: &str, modules: &[(&str, &str)]) -> String {
651
+ let tmp = tempfile::tempdir().expect("tempdir");
652
+ let dir = tmp.path();
653
+ for (rel, src) in modules {
654
+ let p = dir.join(rel);
655
+ if let Some(parent) = p.parent() {
656
+ std::fs::create_dir_all(parent).unwrap();
657
+ }
658
+ let mut f = std::fs::File::create(&p).unwrap();
659
+ f.write_all(src.as_bytes()).unwrap();
660
+ f.sync_all().unwrap();
661
+ }
662
+ compile_module_esm(
663
+ &dir.join(entry),
664
+ Some(dir),
665
+ false,
666
+ ImportRewrite::ViteDev,
667
+ false,
668
+ )
669
+ .expect("compile_module_esm failed")
670
+ .js
671
+ }
672
+
673
+ #[test]
674
+ fn compile_module_esm_vite_preserves_relative_tish_imports() {
675
+ let js = build_module_vite(
676
+ "main.tish",
677
+ &[(
678
+ "main.tish",
679
+ "import { id } from \"./dep.tish\"\nconsole.log(id(1))\n",
680
+ )],
681
+ );
682
+ assert!(
683
+ js.contains("import { id } from \"./dep.tish\";"),
684
+ "Vite dev keeps the .tish specifier so resolveId re-enters the plugin:\n{js}"
685
+ );
686
+ assert!(
687
+ !js.contains("./dep.js"),
688
+ "Vite dev must NOT rewrite .tish to .js:\n{js}"
689
+ );
690
+ }
691
+
692
+ #[test]
693
+ fn compile_module_esm_vite_leaves_bare_specifiers_unchanged() {
694
+ let js = build_module_vite(
695
+ "main.tish",
696
+ &[(
697
+ "main.tish",
698
+ "import { h } from \"lattish\"\nconsole.log(h)\n",
699
+ )],
700
+ );
701
+ assert!(
702
+ js.contains("import { h } from \"lattish\";"),
703
+ "bare specifier passes through for normal Node/Vite resolution:\n{js}"
704
+ );
705
+ }
706
+
707
+ #[test]
708
+ fn compile_module_esm_vite_emits_exports() {
709
+ let js = build_module_vite(
710
+ "main.tish",
711
+ &[(
712
+ "main.tish",
713
+ "export const VERSION = \"1.0\"\nexport fn greet(n) { return \"hi \" + n }\nexport default greet\n",
714
+ )],
715
+ );
716
+ assert!(js.contains("export const VERSION = \"1.0\";"), "named const export:\n{js}");
717
+ assert!(js.contains("export function greet "), "named fn export:\n{js}");
718
+ assert!(js.contains("export default greet;"), "default export:\n{js}");
719
+ }
720
+
721
+ #[test]
722
+ fn compile_module_esm_does_not_resolve_graph() {
723
+ // Compiling a single module must not read or require its dependencies to exist on disk:
724
+ // only the entry file is created, yet the import to a missing `./dep.tish` compiles fine.
725
+ let js = build_module_vite(
726
+ "main.tish",
727
+ &[(
728
+ "main.tish",
729
+ "import { id } from \"./dep.tish\"\nconsole.log(id(1))\n",
730
+ )],
731
+ );
732
+ assert!(
733
+ js.contains("import { id } from \"./dep.tish\";"),
734
+ "single-module compile emits the import without loading the dependency:\n{js}"
735
+ );
736
+ }
737
+
738
+ #[test]
739
+ fn compile_module_esm_rejects_native_imports() {
740
+ let tmp = tempfile::tempdir().expect("tempdir");
741
+ let dir = tmp.path();
742
+ let p = dir.join("main.tish");
743
+ std::fs::write(&p, "import { readFile } from \"fs\"\nconsole.log(1)\n").unwrap();
744
+ let err = compile_module_esm(&p, Some(dir), false, ImportRewrite::ViteDev, false)
745
+ .unwrap_err();
746
+ assert!(
747
+ err.message.contains("Native module import"),
748
+ "expected a native-import rejection, got: {}",
749
+ err.message
750
+ );
751
+ }
752
+
753
+ #[test]
754
+ fn compile_module_esm_source_map_maps_statement_to_tish_file() {
755
+ let tmp = tempfile::tempdir().expect("tempdir");
756
+ let dir = tmp.path();
757
+ let p = dir.join("main.tish");
758
+ std::fs::write(&p, "export fn greet(n) { return \"hi \" + n }\nconsole.log(greet(\"x\"))\n")
759
+ .unwrap();
760
+ let bundle =
761
+ compile_module_esm(&p, Some(dir), false, ImportRewrite::ViteDev, true).unwrap();
762
+ let map = bundle
763
+ .source_map_json
764
+ .expect("source map requested → must be present");
765
+ assert!(
766
+ map.contains("\"version\":3") || map.contains("\"version\": 3"),
767
+ "v3 source map:\n{map}"
768
+ );
769
+ assert!(
770
+ map.contains("main.tish"),
771
+ "map sources reference the .tish file:\n{map}"
772
+ );
773
+ }
774
+
775
+ #[test]
776
+ fn compile_module_esm_source_map_embeds_sources_content() {
777
+ // The map must carry sourcesContent so Vite/devtools never resolve `sources` from disk
778
+ // (otherwise Vite warns "Sourcemap ... points to missing source files").
779
+ let tmp = tempfile::tempdir().expect("tempdir");
780
+ let dir = tmp.path();
781
+ let p = dir.join("main.tish");
782
+ let src = "export fn greet(n) { return \"hi \" + n }\n";
783
+ std::fs::write(&p, src).unwrap();
784
+ let bundle =
785
+ compile_module_esm(&p, Some(dir), false, ImportRewrite::ViteDev, true).unwrap();
786
+ let map = bundle.source_map_json.expect("source map requested");
787
+ assert!(
788
+ map.contains("\"sourcesContent\""),
789
+ "map must include a sourcesContent field:\n{map}"
790
+ );
791
+ assert!(
792
+ map.contains("export fn greet"),
793
+ "sourcesContent must embed the original .tish source:\n{map}"
794
+ );
795
+ }
796
+
797
+ #[test]
798
+ fn compile_module_esm_source_map_requires_no_optimize() {
799
+ let tmp = tempfile::tempdir().expect("tempdir");
800
+ let dir = tmp.path();
801
+ let p = dir.join("main.tish");
802
+ std::fs::write(&p, "console.log(1)\n").unwrap();
803
+ let err = compile_module_esm(&p, Some(dir), true, ImportRewrite::ViteDev, true)
804
+ .unwrap_err();
805
+ assert!(
806
+ err.message.to_lowercase().contains("optimiz"),
807
+ "source map + optimize must be rejected, got: {}",
808
+ err.message
809
+ );
810
+ }
642
811
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tishlang/tish",
3
- "version": "2.9.0",
3
+ "version": "2.10.1",
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