@tishlang/tish 2.8.0 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/tish +0 -0
- package/crates/tish/Cargo.toml +2 -1
- package/crates/tish/src/cli_help.rs +3 -0
- package/crates/tish/src/main.rs +70 -1
- package/crates/tish/tests/integration_test.rs +65 -0
- package/crates/tish_compile/src/codegen.rs +1208 -1
- package/crates/tish_compile/src/infer.rs +1946 -52
- package/crates/tish_compile_js/Cargo.toml +3 -0
- package/crates/tish_compile_js/src/codegen.rs +310 -5
- package/crates/tish_compile_js/src/lib.rs +2 -1
- package/crates/tish_compile_js/src/tests_jsx.rs +185 -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
|
@@ -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,32 @@ 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
|
+
|
|
13
23
|
struct Codegen {
|
|
14
24
|
output: String,
|
|
15
25
|
indent: usize,
|
|
16
26
|
in_async: bool,
|
|
27
|
+
emit_mode: EmitMode,
|
|
28
|
+
/// ESM only: the absolute path of the module being emitted (the importer), used to rewrite
|
|
29
|
+
/// relative/bare import specifiers to sibling `.js` output paths.
|
|
30
|
+
module_path: PathBuf,
|
|
31
|
+
/// ESM only: the project root, so import targets can be located relative to the output tree.
|
|
32
|
+
project_root: PathBuf,
|
|
17
33
|
}
|
|
18
34
|
|
|
19
35
|
fn stmt_terminates_switch(stmt: Option<&Statement>) -> bool {
|
|
@@ -31,6 +47,20 @@ impl Codegen {
|
|
|
31
47
|
output: String::new(),
|
|
32
48
|
indent: 0,
|
|
33
49
|
in_async: false,
|
|
50
|
+
emit_mode: EmitMode::Bundle,
|
|
51
|
+
module_path: PathBuf::new(),
|
|
52
|
+
project_root: PathBuf::new(),
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
fn new_esm(module_path: PathBuf, project_root: PathBuf) -> Self {
|
|
57
|
+
Self {
|
|
58
|
+
output: String::new(),
|
|
59
|
+
indent: 0,
|
|
60
|
+
in_async: false,
|
|
61
|
+
emit_mode: EmitMode::Esm,
|
|
62
|
+
module_path,
|
|
63
|
+
project_root,
|
|
34
64
|
}
|
|
35
65
|
}
|
|
36
66
|
|
|
@@ -398,9 +428,18 @@ impl Codegen {
|
|
|
398
428
|
}
|
|
399
429
|
self.writeln("}");
|
|
400
430
|
}
|
|
401
|
-
Statement::Import {
|
|
402
|
-
|
|
403
|
-
}
|
|
431
|
+
Statement::Import {
|
|
432
|
+
specifiers, from, ..
|
|
433
|
+
} => match self.emit_mode {
|
|
434
|
+
// Bundle: resolved away by merge_modules (the dep bindings are already inlined).
|
|
435
|
+
EmitMode::Bundle => {}
|
|
436
|
+
EmitMode::Esm => self.emit_esm_import(specifiers, from.as_ref())?,
|
|
437
|
+
},
|
|
438
|
+
Statement::Export { declaration, .. } => match self.emit_mode {
|
|
439
|
+
// Bundle: merge_modules unwrapped exports into plain top-level declarations.
|
|
440
|
+
EmitMode::Bundle => {}
|
|
441
|
+
EmitMode::Esm => self.emit_esm_export(declaration)?,
|
|
442
|
+
},
|
|
404
443
|
Statement::TypeAlias { .. }
|
|
405
444
|
| Statement::DeclareVar { .. }
|
|
406
445
|
| Statement::DeclareFun { .. } => {}
|
|
@@ -859,6 +898,172 @@ impl Codegen {
|
|
|
859
898
|
CallArg::Spread(e) => Ok(format!("...{}", self.emit_expr(e)?)),
|
|
860
899
|
}
|
|
861
900
|
}
|
|
901
|
+
|
|
902
|
+
/// Emit a real ES `import` statement (ESM mode), rewriting the `.tish` specifier to its sibling
|
|
903
|
+
/// `.js` output path. Named and namespace specifiers can't share one statement, so a module that
|
|
904
|
+
/// imports both is split across two lines.
|
|
905
|
+
fn emit_esm_import(
|
|
906
|
+
&mut self,
|
|
907
|
+
specifiers: &[ImportSpecifier],
|
|
908
|
+
from: &str,
|
|
909
|
+
) -> Result<(), CompileError> {
|
|
910
|
+
let spec = rewrite_import_to_js(from, &self.module_path, &self.project_root)?;
|
|
911
|
+
let mut named: Vec<String> = Vec::new();
|
|
912
|
+
let mut default_local: Option<String> = None;
|
|
913
|
+
let mut namespace_local: Option<String> = None;
|
|
914
|
+
for s in specifiers {
|
|
915
|
+
match s {
|
|
916
|
+
ImportSpecifier::Named { name, alias, .. } => match alias {
|
|
917
|
+
Some(a) => named.push(format!(
|
|
918
|
+
"{} as {}",
|
|
919
|
+
Self::escape_ident(name.as_ref()),
|
|
920
|
+
Self::escape_ident(a.as_ref())
|
|
921
|
+
)),
|
|
922
|
+
None => named.push(Self::escape_ident(name.as_ref())),
|
|
923
|
+
},
|
|
924
|
+
ImportSpecifier::Default { name, .. } => {
|
|
925
|
+
default_local = Some(Self::escape_ident(name.as_ref()))
|
|
926
|
+
}
|
|
927
|
+
ImportSpecifier::Namespace { name, .. } => {
|
|
928
|
+
namespace_local = Some(Self::escape_ident(name.as_ref()))
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
if let Some(ns) = namespace_local {
|
|
933
|
+
match &default_local {
|
|
934
|
+
Some(def) => self.writeln(&format!("import {}, * as {} from \"{}\";", def, ns, spec)),
|
|
935
|
+
None => self.writeln(&format!("import * as {} from \"{}\";", ns, spec)),
|
|
936
|
+
}
|
|
937
|
+
if !named.is_empty() {
|
|
938
|
+
self.writeln(&format!("import {{ {} }} from \"{}\";", named.join(", "), spec));
|
|
939
|
+
}
|
|
940
|
+
} else if !named.is_empty() {
|
|
941
|
+
match &default_local {
|
|
942
|
+
Some(def) => self.writeln(&format!(
|
|
943
|
+
"import {}, {{ {} }} from \"{}\";",
|
|
944
|
+
def,
|
|
945
|
+
named.join(", "),
|
|
946
|
+
spec
|
|
947
|
+
)),
|
|
948
|
+
None => self.writeln(&format!("import {{ {} }} from \"{}\";", named.join(", "), spec)),
|
|
949
|
+
}
|
|
950
|
+
} else if let Some(def) = &default_local {
|
|
951
|
+
self.writeln(&format!("import {} from \"{}\";", def, spec));
|
|
952
|
+
} else {
|
|
953
|
+
self.writeln(&format!("import \"{}\";", spec));
|
|
954
|
+
}
|
|
955
|
+
Ok(())
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/// Emit a real ES `export` (ESM mode). Named exports prefix the inner declaration with the
|
|
959
|
+
/// `export` keyword; default exports become `export default <expr>;`.
|
|
960
|
+
fn emit_esm_export(&mut self, declaration: &ExportDeclaration) -> Result<(), CompileError> {
|
|
961
|
+
match declaration {
|
|
962
|
+
ExportDeclaration::Named(inner) => {
|
|
963
|
+
// Emit the inner declaration, then splice the `export ` keyword in front of it. The
|
|
964
|
+
// declaration's first line is `<indent><keyword> …`, so the keyword goes right after
|
|
965
|
+
// the leading indent.
|
|
966
|
+
let start = self.output.len();
|
|
967
|
+
self.emit_statement(inner)?;
|
|
968
|
+
let insert_at = start + self.indent_str().len();
|
|
969
|
+
if insert_at <= self.output.len() {
|
|
970
|
+
self.output.insert_str(insert_at, "export ");
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
ExportDeclaration::Default(e) => {
|
|
974
|
+
let v = self.emit_expr(e)?;
|
|
975
|
+
self.writeln(&format!("export default {};", v));
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
Ok(())
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/// Rewrite a Tish import specifier to the ESM `.js` specifier that the emitted module tree uses.
|
|
983
|
+
/// Relative specifiers keep their shape (the output tree mirrors the source tree) with `.tish`
|
|
984
|
+
/// swapped to `.js`. Bare specifiers are resolved to a `.tish` file and re-expressed as a relative
|
|
985
|
+
/// path from the importer's output location. Native imports (`tish:*`, `cargo:*`, …) are rejected.
|
|
986
|
+
fn rewrite_import_to_js(
|
|
987
|
+
spec: &str,
|
|
988
|
+
importer_abs: &Path,
|
|
989
|
+
project_root: &Path,
|
|
990
|
+
) -> Result<String, CompileError> {
|
|
991
|
+
if tishlang_compile::is_native_import(spec) {
|
|
992
|
+
return Err(CompileError {
|
|
993
|
+
message: format!(
|
|
994
|
+
"Native module import '{}' is not supported with --target js --format esm (native modules require --target native).",
|
|
995
|
+
spec
|
|
996
|
+
),
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
if spec.starts_with("./") || spec.starts_with("../") {
|
|
1000
|
+
return Ok(spec_ext_to_js(spec));
|
|
1001
|
+
}
|
|
1002
|
+
// Bare specifier (e.g. a package): resolve to its `.tish` file and express it relative to the
|
|
1003
|
+
// importer's output `.js`, so the emitted module tree stays self-contained.
|
|
1004
|
+
let from_dir = importer_abs.parent().unwrap_or_else(|| Path::new("."));
|
|
1005
|
+
let dep = tishlang_compile::resolve_bare_spec(spec, from_dir, project_root).ok_or_else(|| {
|
|
1006
|
+
CompileError {
|
|
1007
|
+
message: format!(
|
|
1008
|
+
"Cannot resolve package import '{}' for --format esm. Only relative imports and packages resolvable to a .tish entry are supported; use --format bundle otherwise.",
|
|
1009
|
+
spec
|
|
1010
|
+
),
|
|
1011
|
+
}
|
|
1012
|
+
})?;
|
|
1013
|
+
// The emitted module tree mirrors the real source tree under a common base (see
|
|
1014
|
+
// `compile_project_esm`), so the relative path between two *absolute* canonical paths is the
|
|
1015
|
+
// same as the relative path between their emitted `.js` files. This holds whether the dependency
|
|
1016
|
+
// lives under the entry's project root, in a sibling package, or in `node_modules` (#282), so no
|
|
1017
|
+
// "outside the project root" rejection is needed.
|
|
1018
|
+
let dep_canon = dep.canonicalize().unwrap_or(dep);
|
|
1019
|
+
let importer_canon = importer_abs
|
|
1020
|
+
.canonicalize()
|
|
1021
|
+
.unwrap_or_else(|_| importer_abs.to_path_buf());
|
|
1022
|
+
let importer_dir = importer_canon.parent().unwrap_or_else(|| Path::new("/"));
|
|
1023
|
+
Ok(spec_ext_to_js(&relative_specifier(importer_dir, &dep_canon)))
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/// Swap a relative import specifier's `.tish` extension for `.js` (or append `.js` when extensionless).
|
|
1027
|
+
/// Already-`.js`/`.mjs` specifiers pass through unchanged.
|
|
1028
|
+
fn spec_ext_to_js(spec: &str) -> String {
|
|
1029
|
+
if let Some(base) = spec.strip_suffix(".tish") {
|
|
1030
|
+
format!("{}.js", base)
|
|
1031
|
+
} else if spec.ends_with(".js") || spec.ends_with(".mjs") {
|
|
1032
|
+
spec.to_string()
|
|
1033
|
+
} else {
|
|
1034
|
+
format!("{}.js", spec)
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/// Build a `./`-prefixed relative module specifier from `from_dir` to `to_file`, compared
|
|
1039
|
+
/// component-wise, using `/` separators as ESM requires. Both paths must be expressed the same way
|
|
1040
|
+
/// (both absolute canonical, or both relative to the same base) so the shared prefix lines up.
|
|
1041
|
+
fn relative_specifier(from_dir: &Path, to_file: &Path) -> String {
|
|
1042
|
+
let from: Vec<String> = from_dir
|
|
1043
|
+
.components()
|
|
1044
|
+
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
|
1045
|
+
.collect();
|
|
1046
|
+
let to: Vec<String> = to_file
|
|
1047
|
+
.components()
|
|
1048
|
+
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
|
1049
|
+
.collect();
|
|
1050
|
+
let mut i = 0;
|
|
1051
|
+
while i < from.len() && i < to.len() && from[i] == to[i] {
|
|
1052
|
+
i += 1;
|
|
1053
|
+
}
|
|
1054
|
+
let mut parts: Vec<String> = Vec::new();
|
|
1055
|
+
for _ in i..from.len() {
|
|
1056
|
+
parts.push("..".to_string());
|
|
1057
|
+
}
|
|
1058
|
+
for c in &to[i..] {
|
|
1059
|
+
parts.push(c.clone());
|
|
1060
|
+
}
|
|
1061
|
+
let joined = parts.join("/");
|
|
1062
|
+
if joined.starts_with("..") {
|
|
1063
|
+
joined
|
|
1064
|
+
} else {
|
|
1065
|
+
format!("./{}", joined)
|
|
1066
|
+
}
|
|
862
1067
|
}
|
|
863
1068
|
|
|
864
1069
|
/// Compile a single program (no imports) to JavaScript. JSX lowers to `h` / `Fragment` (Lattish).
|
|
@@ -987,3 +1192,103 @@ pub fn compile_project_with_jsx(
|
|
|
987
1192
|
};
|
|
988
1193
|
Ok(compile_project_js_inner(entry_path, project_root, optimize, false, &out_name)?.js)
|
|
989
1194
|
}
|
|
1195
|
+
|
|
1196
|
+
/// One emitted ES module: where it goes (relative to the output directory, mirroring the source
|
|
1197
|
+
/// tree) and its JavaScript. See [`compile_project_esm`].
|
|
1198
|
+
#[derive(Debug, Clone)]
|
|
1199
|
+
pub struct EmittedJsModule {
|
|
1200
|
+
pub relative_path: PathBuf,
|
|
1201
|
+
pub js: String,
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/// Compile a project to **ES modules** — one `.js` file per `.tish` module, with real `import` /
|
|
1205
|
+
/// `export` statements (issue #282). Unlike [`compile_project_with_jsx`], modules are NOT merged, so
|
|
1206
|
+
/// each keeps its own scope (no exported-name collisions) and a bundler can tree-shake the graph.
|
|
1207
|
+
/// Output paths mirror the source tree relative to the deepest directory common to every module in
|
|
1208
|
+
/// the graph (so sibling-package / `node_modules` deps are included), with `.tish` swapped to `.js`.
|
|
1209
|
+
pub fn compile_project_esm(
|
|
1210
|
+
entry_path: &Path,
|
|
1211
|
+
project_root: Option<&Path>,
|
|
1212
|
+
optimize: bool,
|
|
1213
|
+
) -> Result<Vec<EmittedJsModule>, CompileError> {
|
|
1214
|
+
let modules = tishlang_compile::resolve_project(entry_path, project_root)
|
|
1215
|
+
.map_err(|e| CompileError { message: e })?;
|
|
1216
|
+
tishlang_compile::detect_cycles(&modules).map_err(|e| CompileError { message: e })?;
|
|
1217
|
+
// Resolution root: where bare-specifier / `node_modules` lookups are anchored. Unchanged
|
|
1218
|
+
// semantics — only used for `resolve_bare_spec`, which itself walks upward from each importer.
|
|
1219
|
+
let res_root = project_root
|
|
1220
|
+
.map(Path::to_path_buf)
|
|
1221
|
+
.or_else(|| entry_path.parent().map(Path::to_path_buf))
|
|
1222
|
+
.unwrap_or_else(|| PathBuf::from("."));
|
|
1223
|
+
let res_root_canon = res_root.canonicalize().unwrap_or(res_root);
|
|
1224
|
+
|
|
1225
|
+
// Output layout base: the deepest directory that is an ancestor of *every* module in the graph.
|
|
1226
|
+
// The output tree mirrors the real filesystem beneath this base, so a dependency in a sibling
|
|
1227
|
+
// package or `node_modules` (#282 follow-up) gets a stable home in the output instead of being
|
|
1228
|
+
// rejected for living "outside the project root". For a self-contained project this is just the
|
|
1229
|
+
// project root, so existing layouts are unchanged.
|
|
1230
|
+
let mod_canons: Vec<PathBuf> = modules
|
|
1231
|
+
.iter()
|
|
1232
|
+
.map(|m| m.path.canonicalize().unwrap_or_else(|_| m.path.clone()))
|
|
1233
|
+
.collect();
|
|
1234
|
+
let layout_base = common_ancestor_dir(&mod_canons);
|
|
1235
|
+
|
|
1236
|
+
let mut out = Vec::with_capacity(modules.len());
|
|
1237
|
+
for (module, mod_canon) in modules.iter().zip(mod_canons.iter()) {
|
|
1238
|
+
let rel = mod_canon
|
|
1239
|
+
.strip_prefix(&layout_base)
|
|
1240
|
+
.map(Path::to_path_buf)
|
|
1241
|
+
.unwrap_or_else(|_| {
|
|
1242
|
+
// `layout_base` is a common ancestor of every module, so this is unreachable; fall
|
|
1243
|
+
// back to the bare file name rather than panicking if a path is unexpectedly absolute.
|
|
1244
|
+
PathBuf::from(mod_canon.file_name().unwrap_or(mod_canon.as_os_str()))
|
|
1245
|
+
});
|
|
1246
|
+
let rel_js = rel.with_extension("js");
|
|
1247
|
+
let program = if optimize {
|
|
1248
|
+
tishlang_opt::optimize(&module.program)
|
|
1249
|
+
} else {
|
|
1250
|
+
module.program.clone()
|
|
1251
|
+
};
|
|
1252
|
+
let mut gen = Codegen::new_esm(mod_canon.clone(), res_root_canon.clone());
|
|
1253
|
+
gen.emit_program(&program, None, None)?;
|
|
1254
|
+
out.push(EmittedJsModule {
|
|
1255
|
+
relative_path: rel_js,
|
|
1256
|
+
js: gen.output,
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
Ok(out)
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/// Deepest directory that is an ancestor of every given file path, compared component-wise. Used as
|
|
1263
|
+
/// the base of the `--format esm` output tree so modules outside the entry's project root (sibling
|
|
1264
|
+
/// packages, `node_modules`) still map to a stable, collision-free location. For a single file this
|
|
1265
|
+
/// is its parent directory; for files that share only the filesystem root it is `/`.
|
|
1266
|
+
fn common_ancestor_dir(files: &[PathBuf]) -> PathBuf {
|
|
1267
|
+
let parent_components = |p: &Path| -> Vec<std::ffi::OsString> {
|
|
1268
|
+
p.parent()
|
|
1269
|
+
.unwrap_or_else(|| Path::new("/"))
|
|
1270
|
+
.components()
|
|
1271
|
+
.map(|c| c.as_os_str().to_os_string())
|
|
1272
|
+
.collect()
|
|
1273
|
+
};
|
|
1274
|
+
let mut common: Vec<std::ffi::OsString> = match files.first() {
|
|
1275
|
+
Some(f) => parent_components(f),
|
|
1276
|
+
None => return PathBuf::from("/"),
|
|
1277
|
+
};
|
|
1278
|
+
for f in &files[1..] {
|
|
1279
|
+
let comps = parent_components(f);
|
|
1280
|
+
let mut i = 0;
|
|
1281
|
+
while i < common.len() && i < comps.len() && common[i] == comps[i] {
|
|
1282
|
+
i += 1;
|
|
1283
|
+
}
|
|
1284
|
+
common.truncate(i);
|
|
1285
|
+
}
|
|
1286
|
+
if common.is_empty() {
|
|
1287
|
+
return PathBuf::from("/");
|
|
1288
|
+
}
|
|
1289
|
+
let mut base = PathBuf::new();
|
|
1290
|
+
for c in &common {
|
|
1291
|
+
base.push(c);
|
|
1292
|
+
}
|
|
1293
|
+
base
|
|
1294
|
+
}
|
|
@@ -8,7 +8,8 @@ mod error;
|
|
|
8
8
|
mod tests_jsx;
|
|
9
9
|
|
|
10
10
|
pub use codegen::{
|
|
11
|
-
compile_project_with_jsx, compile_project_with_jsx_and_source_map,
|
|
11
|
+
compile_project_esm, compile_project_with_jsx, compile_project_with_jsx_and_source_map,
|
|
12
|
+
compile_with_jsx, EmittedJsModule, JsBundle,
|
|
12
13
|
};
|
|
13
14
|
pub use error::CompileError;
|
|
14
15
|
|
|
@@ -4,7 +4,7 @@ mod tests {
|
|
|
4
4
|
|
|
5
5
|
use tishlang_parser::parse;
|
|
6
6
|
|
|
7
|
-
use crate::{compile_project_with_jsx, compile_with_jsx};
|
|
7
|
+
use crate::{compile_project_esm, compile_project_with_jsx, compile_with_jsx, EmittedJsModule};
|
|
8
8
|
|
|
9
9
|
#[test]
|
|
10
10
|
fn lattish_jsx_emits_h_with_children_array() {
|
|
@@ -455,4 +455,188 @@ fn factory() {
|
|
|
455
455
|
let neg = crate::compile(&parse("console.log(-1 / 0)\n").unwrap(), true).unwrap();
|
|
456
456
|
assert!(neg.contains("-Infinity"), "-1/0 must emit -Infinity:\n{neg}");
|
|
457
457
|
}
|
|
458
|
+
|
|
459
|
+
// ── #282: ESM module output (one file per module, real import/export) ──────────────────────
|
|
460
|
+
|
|
461
|
+
/// Write a set of `(relative_path, source)` modules into a fresh temp dir and compile the entry
|
|
462
|
+
/// in ESM mode. Returns the emitted modules keyed for easy lookup by their output relative path.
|
|
463
|
+
fn build_esm(entry: &str, modules: &[(&str, &str)]) -> Vec<EmittedJsModule> {
|
|
464
|
+
let tmp = tempfile::tempdir().expect("tempdir");
|
|
465
|
+
let dir = tmp.path();
|
|
466
|
+
for (rel, src) in modules {
|
|
467
|
+
let p = dir.join(rel);
|
|
468
|
+
if let Some(parent) = p.parent() {
|
|
469
|
+
std::fs::create_dir_all(parent).unwrap();
|
|
470
|
+
}
|
|
471
|
+
let mut f = std::fs::File::create(&p).unwrap();
|
|
472
|
+
f.write_all(src.as_bytes()).unwrap();
|
|
473
|
+
f.sync_all().unwrap();
|
|
474
|
+
}
|
|
475
|
+
compile_project_esm(&dir.join(entry), Some(dir), false).expect("compile_project_esm failed")
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
fn module_js<'a>(mods: &'a [EmittedJsModule], rel: &str) -> &'a str {
|
|
479
|
+
mods.iter()
|
|
480
|
+
.find(|m| m.relative_path.to_string_lossy() == rel)
|
|
481
|
+
.map(|m| m.js.as_str())
|
|
482
|
+
.unwrap_or_else(|| panic!("module {rel} not emitted; got {:?}", mods.iter().map(|m| m.relative_path.display().to_string()).collect::<Vec<_>>()))
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
#[test]
|
|
486
|
+
fn esm_emits_named_and_default_exports() {
|
|
487
|
+
let mods = build_esm(
|
|
488
|
+
"main.tish",
|
|
489
|
+
&[(
|
|
490
|
+
"main.tish",
|
|
491
|
+
"export const VERSION = \"1.0\"\nexport fn greet(n) { return \"hi \" + n }\nexport default greet\n",
|
|
492
|
+
)],
|
|
493
|
+
);
|
|
494
|
+
let js = module_js(&mods, "main.js");
|
|
495
|
+
assert!(js.contains("export const VERSION = \"1.0\";"), "named const export:\n{js}");
|
|
496
|
+
assert!(js.contains("export function greet "), "named fn export:\n{js}");
|
|
497
|
+
assert!(js.contains("export default greet;"), "default export:\n{js}");
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
#[test]
|
|
501
|
+
fn esm_rewrites_import_specifier_to_js_with_alias() {
|
|
502
|
+
let mods = build_esm(
|
|
503
|
+
"main.tish",
|
|
504
|
+
&[
|
|
505
|
+
("dep.tish", "export const ssrH = 42\nexport fn greet(n) { return n }\n"),
|
|
506
|
+
(
|
|
507
|
+
"main.tish",
|
|
508
|
+
"import { ssrH as h, greet } from \"./dep.tish\"\nimport * as M from \"./dep.tish\"\nconsole.log(h)\nconsole.log(greet(M.ssrH))\n",
|
|
509
|
+
),
|
|
510
|
+
],
|
|
511
|
+
);
|
|
512
|
+
let js = module_js(&mods, "main.js");
|
|
513
|
+
assert!(
|
|
514
|
+
js.contains("import { ssrH as h, greet } from \"./dep.js\";"),
|
|
515
|
+
"named import with alias, .tish->.js:\n{js}"
|
|
516
|
+
);
|
|
517
|
+
assert!(js.contains("import * as M from \"./dep.js\";"), "namespace import:\n{js}");
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
#[test]
|
|
521
|
+
fn esm_one_file_per_module_preserves_tree() {
|
|
522
|
+
let mods = build_esm(
|
|
523
|
+
"main.tish",
|
|
524
|
+
&[
|
|
525
|
+
("lib/util.tish", "export fn id(x) { return x }\n"),
|
|
526
|
+
("main.tish", "import { id } from \"./lib/util.tish\"\nconsole.log(id(1))\n"),
|
|
527
|
+
],
|
|
528
|
+
);
|
|
529
|
+
// Nested module keeps its relative path; importer points at the nested `.js`.
|
|
530
|
+
let _ = module_js(&mods, "lib/util.js");
|
|
531
|
+
let main = module_js(&mods, "main.js");
|
|
532
|
+
assert!(
|
|
533
|
+
main.contains("from \"./lib/util.js\";"),
|
|
534
|
+
"nested relative import preserved:\n{main}"
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
#[test]
|
|
539
|
+
fn esm_module_outside_project_root_is_emitted() {
|
|
540
|
+
// #282 follow-up: a dependency in a *sibling* package (outside the entry's project root)
|
|
541
|
+
// must still be emitted — the output tree is rooted at the directory common to all modules.
|
|
542
|
+
let tmp = tempfile::tempdir().expect("tempdir");
|
|
543
|
+
let base = tmp.path();
|
|
544
|
+
std::fs::create_dir_all(base.join("app/src")).unwrap();
|
|
545
|
+
std::fs::create_dir_all(base.join("lib")).unwrap();
|
|
546
|
+
std::fs::write(base.join("lib/util.tish"), "export fn id(x) { return x }\n").unwrap();
|
|
547
|
+
std::fs::write(
|
|
548
|
+
base.join("app/src/main.tish"),
|
|
549
|
+
"import { id } from \"../../lib/util.tish\"\nconsole.log(id(1))\n",
|
|
550
|
+
)
|
|
551
|
+
.unwrap();
|
|
552
|
+
// Project root is the entry's package (`app`); `lib/util.tish` lives outside it.
|
|
553
|
+
let mods = compile_project_esm(
|
|
554
|
+
&base.join("app/src/main.tish"),
|
|
555
|
+
Some(&base.join("app")),
|
|
556
|
+
false,
|
|
557
|
+
)
|
|
558
|
+
.expect("compile_project_esm failed for sibling dep");
|
|
559
|
+
let rels: Vec<String> = mods
|
|
560
|
+
.iter()
|
|
561
|
+
.map(|m| m.relative_path.to_string_lossy().replace('\\', "/"))
|
|
562
|
+
.collect();
|
|
563
|
+
let main_js = mods
|
|
564
|
+
.iter()
|
|
565
|
+
.find(|m| m.relative_path.to_string_lossy().replace('\\', "/").ends_with("app/src/main.js"))
|
|
566
|
+
.map(|m| m.js.clone());
|
|
567
|
+
assert!(
|
|
568
|
+
rels.iter().any(|r| r.ends_with("lib/util.js")),
|
|
569
|
+
"sibling dep emitted under common base: {:?}",
|
|
570
|
+
rels
|
|
571
|
+
);
|
|
572
|
+
assert!(
|
|
573
|
+
rels.iter().any(|r| r.ends_with("app/src/main.js")),
|
|
574
|
+
"entry emitted under its own subtree: {:?}",
|
|
575
|
+
rels
|
|
576
|
+
);
|
|
577
|
+
let main_js = main_js.expect("entry module present");
|
|
578
|
+
assert!(
|
|
579
|
+
main_js.contains("from \"../../lib/util.js\";"),
|
|
580
|
+
"relative import to sibling rewritten to .js:\n{main_js}"
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
#[test]
|
|
585
|
+
fn esm_bare_node_modules_dep_is_emitted() {
|
|
586
|
+
// #282 follow-up: a bare specifier resolved from `node_modules` (like `lattish`) is emitted
|
|
587
|
+
// into the output tree and the importer points at it with a relative `.js` specifier.
|
|
588
|
+
let tmp = tempfile::tempdir().expect("tempdir");
|
|
589
|
+
let base = tmp.path();
|
|
590
|
+
std::fs::create_dir_all(base.join("src")).unwrap();
|
|
591
|
+
std::fs::create_dir_all(base.join("node_modules/pkg")).unwrap();
|
|
592
|
+
std::fs::write(
|
|
593
|
+
base.join("node_modules/pkg/package.json"),
|
|
594
|
+
"{\"name\":\"pkg\",\"main\":\"index.tish\"}\n",
|
|
595
|
+
)
|
|
596
|
+
.unwrap();
|
|
597
|
+
std::fs::write(
|
|
598
|
+
base.join("node_modules/pkg/index.tish"),
|
|
599
|
+
"export fn ping() { return \"pong\" }\n",
|
|
600
|
+
)
|
|
601
|
+
.unwrap();
|
|
602
|
+
std::fs::write(
|
|
603
|
+
base.join("src/main.tish"),
|
|
604
|
+
"import { ping } from \"pkg\"\nconsole.log(ping())\n",
|
|
605
|
+
)
|
|
606
|
+
.unwrap();
|
|
607
|
+
let mods = compile_project_esm(&base.join("src/main.tish"), Some(base), false)
|
|
608
|
+
.expect("compile_project_esm failed for node_modules dep");
|
|
609
|
+
let rels: Vec<String> = mods
|
|
610
|
+
.iter()
|
|
611
|
+
.map(|m| m.relative_path.to_string_lossy().replace('\\', "/"))
|
|
612
|
+
.collect();
|
|
613
|
+
let main_js = mods
|
|
614
|
+
.iter()
|
|
615
|
+
.find(|m| m.relative_path.to_string_lossy().replace('\\', "/").ends_with("src/main.js"))
|
|
616
|
+
.map(|m| m.js.clone());
|
|
617
|
+
assert!(
|
|
618
|
+
rels.iter().any(|r| r.ends_with("node_modules/pkg/index.js")),
|
|
619
|
+
"node_modules dep emitted: {:?}",
|
|
620
|
+
rels
|
|
621
|
+
);
|
|
622
|
+
let main_js = main_js.expect("entry module present");
|
|
623
|
+
assert!(
|
|
624
|
+
main_js.contains("from \"../node_modules/pkg/index.js\";"),
|
|
625
|
+
"bare specifier rewritten to relative .js path:\n{main_js}"
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
#[test]
|
|
630
|
+
fn esm_rejects_native_imports() {
|
|
631
|
+
let tmp = tempfile::tempdir().expect("tempdir");
|
|
632
|
+
let dir = tmp.path();
|
|
633
|
+
let p = dir.join("main.tish");
|
|
634
|
+
std::fs::write(&p, "import { readFile } from \"fs\"\nconsole.log(1)\n").unwrap();
|
|
635
|
+
let err = compile_project_esm(&p, Some(dir), false).unwrap_err();
|
|
636
|
+
assert!(
|
|
637
|
+
err.message.contains("Native module import") && err.message.contains("esm"),
|
|
638
|
+
"expected a native-import rejection for ESM, got: {}",
|
|
639
|
+
err.message
|
|
640
|
+
);
|
|
641
|
+
}
|
|
458
642
|
}
|
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
|