@tishlang/tish 2.8.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 +3 -1
- package/crates/tish/src/cli_help.rs +34 -0
- package/crates/tish/src/main.rs +138 -3
- package/crates/tish/tests/integration_test.rs +173 -0
- package/crates/tish_compile/src/codegen.rs +1569 -107
- package/crates/tish_compile/src/infer.rs +1946 -52
- package/crates/tish_compile/tests/perf_codegen_169_173.rs +139 -0
- package/crates/tish_compile_js/Cargo.toml +3 -0
- package/crates/tish_compile_js/src/codegen.rs +414 -5
- package/crates/tish_compile_js/src/lib.rs +3 -1
- package/crates/tish_compile_js/src/tests_jsx.rs +354 -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
|
@@ -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
|
+
}
|
|
@@ -16,3 +16,6 @@ tishlang_compile = { path = "../tish_compile", version = ">=0.1" }
|
|
|
16
16
|
tishlang_opt = { path = "../tish_opt", version = ">=0.1" }
|
|
17
17
|
tishlang_parser = { path = "../tish_parser", version = ">=0.1" }
|
|
18
18
|
tishlang_ui = { path = "../tish_ui", version = ">=0.1", default-features = false, features = ["compiler"] }
|
|
19
|
+
|
|
20
|
+
[dev-dependencies]
|
|
21
|
+
tempfile = "3"
|
|
@@ -4,16 +4,44 @@ use std::path::{Path, PathBuf};
|
|
|
4
4
|
|
|
5
5
|
use sourcemap::SourceMapBuilder;
|
|
6
6
|
use tishlang_ast::{
|
|
7
|
-
ArrayElement, ArrowBody, BinOp, CallArg, CompoundOp, DestructElement, DestructPattern,
|
|
8
|
-
|
|
7
|
+
ArrayElement, ArrowBody, BinOp, CallArg, CompoundOp, DestructElement, DestructPattern,
|
|
8
|
+
ExportDeclaration, Expr, FunParam, ImportSpecifier, Literal, LogicalAssignOp, MemberProp,
|
|
9
|
+
ObjectProp, Program, Statement, UnaryOp,
|
|
9
10
|
};
|
|
10
11
|
|
|
11
12
|
use crate::error::CompileError;
|
|
12
13
|
|
|
14
|
+
/// JS output mode. `Bundle` flattens every module into one file (imports/exports are resolved away
|
|
15
|
+
/// by `merge_modules`). `Esm` emits one file per module with real ES `import`/`export` statements so
|
|
16
|
+
/// a bundler (Vite/Rollup) can tree-shake and code-split. See issue #177's sibling, #282.
|
|
17
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
18
|
+
enum EmitMode {
|
|
19
|
+
Bundle,
|
|
20
|
+
Esm,
|
|
21
|
+
}
|
|
22
|
+
|
|
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
|
+
|
|
13
33
|
struct Codegen {
|
|
14
34
|
output: String,
|
|
15
35
|
indent: usize,
|
|
16
36
|
in_async: bool,
|
|
37
|
+
emit_mode: EmitMode,
|
|
38
|
+
/// ESM only: how import specifiers are rewritten (disk `.js` paths vs Vite-dev `.tish`).
|
|
39
|
+
import_rewrite: ImportRewrite,
|
|
40
|
+
/// ESM only: the absolute path of the module being emitted (the importer), used to rewrite
|
|
41
|
+
/// relative/bare import specifiers to sibling `.js` output paths.
|
|
42
|
+
module_path: PathBuf,
|
|
43
|
+
/// ESM only: the project root, so import targets can be located relative to the output tree.
|
|
44
|
+
project_root: PathBuf,
|
|
17
45
|
}
|
|
18
46
|
|
|
19
47
|
fn stmt_terminates_switch(stmt: Option<&Statement>) -> bool {
|
|
@@ -31,6 +59,22 @@ impl Codegen {
|
|
|
31
59
|
output: String::new(),
|
|
32
60
|
indent: 0,
|
|
33
61
|
in_async: false,
|
|
62
|
+
emit_mode: EmitMode::Bundle,
|
|
63
|
+
import_rewrite: ImportRewrite::Disk,
|
|
64
|
+
module_path: PathBuf::new(),
|
|
65
|
+
project_root: PathBuf::new(),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
fn new_esm(module_path: PathBuf, project_root: PathBuf, import_rewrite: ImportRewrite) -> Self {
|
|
70
|
+
Self {
|
|
71
|
+
output: String::new(),
|
|
72
|
+
indent: 0,
|
|
73
|
+
in_async: false,
|
|
74
|
+
emit_mode: EmitMode::Esm,
|
|
75
|
+
import_rewrite,
|
|
76
|
+
module_path,
|
|
77
|
+
project_root,
|
|
34
78
|
}
|
|
35
79
|
}
|
|
36
80
|
|
|
@@ -398,9 +442,18 @@ impl Codegen {
|
|
|
398
442
|
}
|
|
399
443
|
self.writeln("}");
|
|
400
444
|
}
|
|
401
|
-
Statement::Import {
|
|
402
|
-
|
|
403
|
-
}
|
|
445
|
+
Statement::Import {
|
|
446
|
+
specifiers, from, ..
|
|
447
|
+
} => match self.emit_mode {
|
|
448
|
+
// Bundle: resolved away by merge_modules (the dep bindings are already inlined).
|
|
449
|
+
EmitMode::Bundle => {}
|
|
450
|
+
EmitMode::Esm => self.emit_esm_import(specifiers, from.as_ref())?,
|
|
451
|
+
},
|
|
452
|
+
Statement::Export { declaration, .. } => match self.emit_mode {
|
|
453
|
+
// Bundle: merge_modules unwrapped exports into plain top-level declarations.
|
|
454
|
+
EmitMode::Bundle => {}
|
|
455
|
+
EmitMode::Esm => self.emit_esm_export(declaration)?,
|
|
456
|
+
},
|
|
404
457
|
Statement::TypeAlias { .. }
|
|
405
458
|
| Statement::DeclareVar { .. }
|
|
406
459
|
| Statement::DeclareFun { .. } => {}
|
|
@@ -859,6 +912,183 @@ impl Codegen {
|
|
|
859
912
|
CallArg::Spread(e) => Ok(format!("...{}", self.emit_expr(e)?)),
|
|
860
913
|
}
|
|
861
914
|
}
|
|
915
|
+
|
|
916
|
+
/// Emit a real ES `import` statement (ESM mode), rewriting the `.tish` specifier to its sibling
|
|
917
|
+
/// `.js` output path. Named and namespace specifiers can't share one statement, so a module that
|
|
918
|
+
/// imports both is split across two lines.
|
|
919
|
+
fn emit_esm_import(
|
|
920
|
+
&mut self,
|
|
921
|
+
specifiers: &[ImportSpecifier],
|
|
922
|
+
from: &str,
|
|
923
|
+
) -> Result<(), CompileError> {
|
|
924
|
+
let spec = rewrite_import_to_js(
|
|
925
|
+
from,
|
|
926
|
+
&self.module_path,
|
|
927
|
+
&self.project_root,
|
|
928
|
+
self.import_rewrite,
|
|
929
|
+
)?;
|
|
930
|
+
let mut named: Vec<String> = Vec::new();
|
|
931
|
+
let mut default_local: Option<String> = None;
|
|
932
|
+
let mut namespace_local: Option<String> = None;
|
|
933
|
+
for s in specifiers {
|
|
934
|
+
match s {
|
|
935
|
+
ImportSpecifier::Named { name, alias, .. } => match alias {
|
|
936
|
+
Some(a) => named.push(format!(
|
|
937
|
+
"{} as {}",
|
|
938
|
+
Self::escape_ident(name.as_ref()),
|
|
939
|
+
Self::escape_ident(a.as_ref())
|
|
940
|
+
)),
|
|
941
|
+
None => named.push(Self::escape_ident(name.as_ref())),
|
|
942
|
+
},
|
|
943
|
+
ImportSpecifier::Default { name, .. } => {
|
|
944
|
+
default_local = Some(Self::escape_ident(name.as_ref()))
|
|
945
|
+
}
|
|
946
|
+
ImportSpecifier::Namespace { name, .. } => {
|
|
947
|
+
namespace_local = Some(Self::escape_ident(name.as_ref()))
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
if let Some(ns) = namespace_local {
|
|
952
|
+
match &default_local {
|
|
953
|
+
Some(def) => self.writeln(&format!("import {}, * as {} from \"{}\";", def, ns, spec)),
|
|
954
|
+
None => self.writeln(&format!("import * as {} from \"{}\";", ns, spec)),
|
|
955
|
+
}
|
|
956
|
+
if !named.is_empty() {
|
|
957
|
+
self.writeln(&format!("import {{ {} }} from \"{}\";", named.join(", "), spec));
|
|
958
|
+
}
|
|
959
|
+
} else if !named.is_empty() {
|
|
960
|
+
match &default_local {
|
|
961
|
+
Some(def) => self.writeln(&format!(
|
|
962
|
+
"import {}, {{ {} }} from \"{}\";",
|
|
963
|
+
def,
|
|
964
|
+
named.join(", "),
|
|
965
|
+
spec
|
|
966
|
+
)),
|
|
967
|
+
None => self.writeln(&format!("import {{ {} }} from \"{}\";", named.join(", "), spec)),
|
|
968
|
+
}
|
|
969
|
+
} else if let Some(def) = &default_local {
|
|
970
|
+
self.writeln(&format!("import {} from \"{}\";", def, spec));
|
|
971
|
+
} else {
|
|
972
|
+
self.writeln(&format!("import \"{}\";", spec));
|
|
973
|
+
}
|
|
974
|
+
Ok(())
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/// Emit a real ES `export` (ESM mode). Named exports prefix the inner declaration with the
|
|
978
|
+
/// `export` keyword; default exports become `export default <expr>;`.
|
|
979
|
+
fn emit_esm_export(&mut self, declaration: &ExportDeclaration) -> Result<(), CompileError> {
|
|
980
|
+
match declaration {
|
|
981
|
+
ExportDeclaration::Named(inner) => {
|
|
982
|
+
// Emit the inner declaration, then splice the `export ` keyword in front of it. The
|
|
983
|
+
// declaration's first line is `<indent><keyword> …`, so the keyword goes right after
|
|
984
|
+
// the leading indent.
|
|
985
|
+
let start = self.output.len();
|
|
986
|
+
self.emit_statement(inner)?;
|
|
987
|
+
let insert_at = start + self.indent_str().len();
|
|
988
|
+
if insert_at <= self.output.len() {
|
|
989
|
+
self.output.insert_str(insert_at, "export ");
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
ExportDeclaration::Default(e) => {
|
|
993
|
+
let v = self.emit_expr(e)?;
|
|
994
|
+
self.writeln(&format!("export default {};", v));
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
Ok(())
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/// Rewrite a Tish import specifier to the ESM `.js` specifier that the emitted module tree uses.
|
|
1002
|
+
/// Relative specifiers keep their shape (the output tree mirrors the source tree) with `.tish`
|
|
1003
|
+
/// swapped to `.js`. Bare specifiers are resolved to a `.tish` file and re-expressed as a relative
|
|
1004
|
+
/// path from the importer's output location. Native imports (`tish:*`, `cargo:*`, …) are rejected.
|
|
1005
|
+
fn rewrite_import_to_js(
|
|
1006
|
+
spec: &str,
|
|
1007
|
+
importer_abs: &Path,
|
|
1008
|
+
project_root: &Path,
|
|
1009
|
+
rewrite: ImportRewrite,
|
|
1010
|
+
) -> Result<String, CompileError> {
|
|
1011
|
+
if tishlang_compile::is_native_import(spec) {
|
|
1012
|
+
return Err(CompileError {
|
|
1013
|
+
message: format!(
|
|
1014
|
+
"Native module import '{}' is not supported with --target js --format esm (native modules require --target native).",
|
|
1015
|
+
spec
|
|
1016
|
+
),
|
|
1017
|
+
});
|
|
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
|
+
}
|
|
1024
|
+
if spec.starts_with("./") || spec.starts_with("../") {
|
|
1025
|
+
return Ok(spec_ext_to_js(spec));
|
|
1026
|
+
}
|
|
1027
|
+
// Bare specifier (e.g. a package): resolve to its `.tish` file and express it relative to the
|
|
1028
|
+
// importer's output `.js`, so the emitted module tree stays self-contained.
|
|
1029
|
+
let from_dir = importer_abs.parent().unwrap_or_else(|| Path::new("."));
|
|
1030
|
+
let dep = tishlang_compile::resolve_bare_spec(spec, from_dir, project_root).ok_or_else(|| {
|
|
1031
|
+
CompileError {
|
|
1032
|
+
message: format!(
|
|
1033
|
+
"Cannot resolve package import '{}' for --format esm. Only relative imports and packages resolvable to a .tish entry are supported; use --format bundle otherwise.",
|
|
1034
|
+
spec
|
|
1035
|
+
),
|
|
1036
|
+
}
|
|
1037
|
+
})?;
|
|
1038
|
+
// The emitted module tree mirrors the real source tree under a common base (see
|
|
1039
|
+
// `compile_project_esm`), so the relative path between two *absolute* canonical paths is the
|
|
1040
|
+
// same as the relative path between their emitted `.js` files. This holds whether the dependency
|
|
1041
|
+
// lives under the entry's project root, in a sibling package, or in `node_modules` (#282), so no
|
|
1042
|
+
// "outside the project root" rejection is needed.
|
|
1043
|
+
let dep_canon = dep.canonicalize().unwrap_or(dep);
|
|
1044
|
+
let importer_canon = importer_abs
|
|
1045
|
+
.canonicalize()
|
|
1046
|
+
.unwrap_or_else(|_| importer_abs.to_path_buf());
|
|
1047
|
+
let importer_dir = importer_canon.parent().unwrap_or_else(|| Path::new("/"));
|
|
1048
|
+
Ok(spec_ext_to_js(&relative_specifier(importer_dir, &dep_canon)))
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/// Swap a relative import specifier's `.tish` extension for `.js` (or append `.js` when extensionless).
|
|
1052
|
+
/// Already-`.js`/`.mjs` specifiers pass through unchanged.
|
|
1053
|
+
fn spec_ext_to_js(spec: &str) -> String {
|
|
1054
|
+
if let Some(base) = spec.strip_suffix(".tish") {
|
|
1055
|
+
format!("{}.js", base)
|
|
1056
|
+
} else if spec.ends_with(".js") || spec.ends_with(".mjs") {
|
|
1057
|
+
spec.to_string()
|
|
1058
|
+
} else {
|
|
1059
|
+
format!("{}.js", spec)
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/// Build a `./`-prefixed relative module specifier from `from_dir` to `to_file`, compared
|
|
1064
|
+
/// component-wise, using `/` separators as ESM requires. Both paths must be expressed the same way
|
|
1065
|
+
/// (both absolute canonical, or both relative to the same base) so the shared prefix lines up.
|
|
1066
|
+
fn relative_specifier(from_dir: &Path, to_file: &Path) -> String {
|
|
1067
|
+
let from: Vec<String> = from_dir
|
|
1068
|
+
.components()
|
|
1069
|
+
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
|
1070
|
+
.collect();
|
|
1071
|
+
let to: Vec<String> = to_file
|
|
1072
|
+
.components()
|
|
1073
|
+
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
|
1074
|
+
.collect();
|
|
1075
|
+
let mut i = 0;
|
|
1076
|
+
while i < from.len() && i < to.len() && from[i] == to[i] {
|
|
1077
|
+
i += 1;
|
|
1078
|
+
}
|
|
1079
|
+
let mut parts: Vec<String> = Vec::new();
|
|
1080
|
+
for _ in i..from.len() {
|
|
1081
|
+
parts.push("..".to_string());
|
|
1082
|
+
}
|
|
1083
|
+
for c in &to[i..] {
|
|
1084
|
+
parts.push(c.clone());
|
|
1085
|
+
}
|
|
1086
|
+
let joined = parts.join("/");
|
|
1087
|
+
if joined.starts_with("..") {
|
|
1088
|
+
joined
|
|
1089
|
+
} else {
|
|
1090
|
+
format!("./{}", joined)
|
|
1091
|
+
}
|
|
862
1092
|
}
|
|
863
1093
|
|
|
864
1094
|
/// Compile a single program (no imports) to JavaScript. JSX lowers to `h` / `Fragment` (Lattish).
|
|
@@ -987,3 +1217,182 @@ pub fn compile_project_with_jsx(
|
|
|
987
1217
|
};
|
|
988
1218
|
Ok(compile_project_js_inner(entry_path, project_root, optimize, false, &out_name)?.js)
|
|
989
1219
|
}
|
|
1220
|
+
|
|
1221
|
+
/// One emitted ES module: where it goes (relative to the output directory, mirroring the source
|
|
1222
|
+
/// tree) and its JavaScript. See [`compile_project_esm`].
|
|
1223
|
+
#[derive(Debug, Clone)]
|
|
1224
|
+
pub struct EmittedJsModule {
|
|
1225
|
+
pub relative_path: PathBuf,
|
|
1226
|
+
pub js: String,
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/// Compile a project to **ES modules** — one `.js` file per `.tish` module, with real `import` /
|
|
1230
|
+
/// `export` statements (issue #282). Unlike [`compile_project_with_jsx`], modules are NOT merged, so
|
|
1231
|
+
/// each keeps its own scope (no exported-name collisions) and a bundler can tree-shake the graph.
|
|
1232
|
+
/// Output paths mirror the source tree relative to the deepest directory common to every module in
|
|
1233
|
+
/// the graph (so sibling-package / `node_modules` deps are included), with `.tish` swapped to `.js`.
|
|
1234
|
+
pub fn compile_project_esm(
|
|
1235
|
+
entry_path: &Path,
|
|
1236
|
+
project_root: Option<&Path>,
|
|
1237
|
+
optimize: bool,
|
|
1238
|
+
) -> Result<Vec<EmittedJsModule>, CompileError> {
|
|
1239
|
+
let modules = tishlang_compile::resolve_project(entry_path, project_root)
|
|
1240
|
+
.map_err(|e| CompileError { message: e })?;
|
|
1241
|
+
tishlang_compile::detect_cycles(&modules).map_err(|e| CompileError { message: e })?;
|
|
1242
|
+
// Resolution root: where bare-specifier / `node_modules` lookups are anchored. Unchanged
|
|
1243
|
+
// semantics — only used for `resolve_bare_spec`, which itself walks upward from each importer.
|
|
1244
|
+
let res_root = project_root
|
|
1245
|
+
.map(Path::to_path_buf)
|
|
1246
|
+
.or_else(|| entry_path.parent().map(Path::to_path_buf))
|
|
1247
|
+
.unwrap_or_else(|| PathBuf::from("."));
|
|
1248
|
+
let res_root_canon = res_root.canonicalize().unwrap_or(res_root);
|
|
1249
|
+
|
|
1250
|
+
// Output layout base: the deepest directory that is an ancestor of *every* module in the graph.
|
|
1251
|
+
// The output tree mirrors the real filesystem beneath this base, so a dependency in a sibling
|
|
1252
|
+
// package or `node_modules` (#282 follow-up) gets a stable home in the output instead of being
|
|
1253
|
+
// rejected for living "outside the project root". For a self-contained project this is just the
|
|
1254
|
+
// project root, so existing layouts are unchanged.
|
|
1255
|
+
let mod_canons: Vec<PathBuf> = modules
|
|
1256
|
+
.iter()
|
|
1257
|
+
.map(|m| m.path.canonicalize().unwrap_or_else(|_| m.path.clone()))
|
|
1258
|
+
.collect();
|
|
1259
|
+
let layout_base = common_ancestor_dir(&mod_canons);
|
|
1260
|
+
|
|
1261
|
+
let mut out = Vec::with_capacity(modules.len());
|
|
1262
|
+
for (module, mod_canon) in modules.iter().zip(mod_canons.iter()) {
|
|
1263
|
+
let rel = mod_canon
|
|
1264
|
+
.strip_prefix(&layout_base)
|
|
1265
|
+
.map(Path::to_path_buf)
|
|
1266
|
+
.unwrap_or_else(|_| {
|
|
1267
|
+
// `layout_base` is a common ancestor of every module, so this is unreachable; fall
|
|
1268
|
+
// back to the bare file name rather than panicking if a path is unexpectedly absolute.
|
|
1269
|
+
PathBuf::from(mod_canon.file_name().unwrap_or(mod_canon.as_os_str()))
|
|
1270
|
+
});
|
|
1271
|
+
let rel_js = rel.with_extension("js");
|
|
1272
|
+
let program = if optimize {
|
|
1273
|
+
tishlang_opt::optimize(&module.program)
|
|
1274
|
+
} else {
|
|
1275
|
+
module.program.clone()
|
|
1276
|
+
};
|
|
1277
|
+
let mut gen =
|
|
1278
|
+
Codegen::new_esm(mod_canon.clone(), res_root_canon.clone(), ImportRewrite::Disk);
|
|
1279
|
+
gen.emit_program(&program, None, None)?;
|
|
1280
|
+
out.push(EmittedJsModule {
|
|
1281
|
+
relative_path: rel_js,
|
|
1282
|
+
js: gen.output,
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
Ok(out)
|
|
1286
|
+
}
|
|
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
|
+
|
|
1366
|
+
/// Deepest directory that is an ancestor of every given file path, compared component-wise. Used as
|
|
1367
|
+
/// the base of the `--format esm` output tree so modules outside the entry's project root (sibling
|
|
1368
|
+
/// packages, `node_modules`) still map to a stable, collision-free location. For a single file this
|
|
1369
|
+
/// is its parent directory; for files that share only the filesystem root it is `/`.
|
|
1370
|
+
fn common_ancestor_dir(files: &[PathBuf]) -> PathBuf {
|
|
1371
|
+
let parent_components = |p: &Path| -> Vec<std::ffi::OsString> {
|
|
1372
|
+
p.parent()
|
|
1373
|
+
.unwrap_or_else(|| Path::new("/"))
|
|
1374
|
+
.components()
|
|
1375
|
+
.map(|c| c.as_os_str().to_os_string())
|
|
1376
|
+
.collect()
|
|
1377
|
+
};
|
|
1378
|
+
let mut common: Vec<std::ffi::OsString> = match files.first() {
|
|
1379
|
+
Some(f) => parent_components(f),
|
|
1380
|
+
None => return PathBuf::from("/"),
|
|
1381
|
+
};
|
|
1382
|
+
for f in &files[1..] {
|
|
1383
|
+
let comps = parent_components(f);
|
|
1384
|
+
let mut i = 0;
|
|
1385
|
+
while i < common.len() && i < comps.len() && common[i] == comps[i] {
|
|
1386
|
+
i += 1;
|
|
1387
|
+
}
|
|
1388
|
+
common.truncate(i);
|
|
1389
|
+
}
|
|
1390
|
+
if common.is_empty() {
|
|
1391
|
+
return PathBuf::from("/");
|
|
1392
|
+
}
|
|
1393
|
+
let mut base = PathBuf::new();
|
|
1394
|
+
for c in &common {
|
|
1395
|
+
base.push(c);
|
|
1396
|
+
}
|
|
1397
|
+
base
|
|
1398
|
+
}
|
|
@@ -8,7 +8,9 @@ mod error;
|
|
|
8
8
|
mod tests_jsx;
|
|
9
9
|
|
|
10
10
|
pub use codegen::{
|
|
11
|
-
|
|
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,
|
|
12
14
|
};
|
|
13
15
|
pub use error::CompileError;
|
|
14
16
|
|