@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 +0 -0
- package/crates/tish/Cargo.toml +2 -1
- package/crates/tish/src/cli_help.rs +31 -0
- package/crates/tish/src/main.rs +68 -2
- package/crates/tish/tests/integration_test.rs +108 -0
- package/crates/tish_compile/src/codegen.rs +255 -0
- package/crates/tish_compile/tests/perf_codegen_169_173.rs +139 -0
- package/crates/tish_compile_js/src/codegen.rs +107 -3
- package/crates/tish_compile_js/src/lib.rs +3 -2
- package/crates/tish_compile_js/src/tests_jsx.rs +170 -1
- package/package.json +1 -1
- package/platform/darwin-arm64/tish +0 -0
- package/platform/darwin-x64/tish +0 -0
- package/platform/linux-arm64/tish +0 -0
- package/platform/linux-x64/tish +0 -0
- package/platform/win32-x64/tish.exe +0 -0
package/bin/tish
CHANGED
|
Binary file
|
package/crates/tish/Cargo.toml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "tishlang"
|
|
3
|
-
version = "2.
|
|
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 {
|
package/crates/tish/src/main.rs
CHANGED
|
@@ -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)) = (¶ms[0], ¶ms[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(
|
|
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 =
|
|
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,
|
|
12
|
-
compile_with_jsx, EmittedJsModule,
|
|
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::{
|
|
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
|
Binary file
|
package/platform/darwin-x64/tish
CHANGED
|
Binary file
|
|
Binary file
|
package/platform/linux-x64/tish
CHANGED
|
Binary file
|
|
Binary file
|