@tishlang/tish 2.2.7 → 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/js_to_tish/src/transform/expr.rs +5 -1
- package/crates/tish/Cargo.toml +2 -1
- package/crates/tish/src/cli_help.rs +15 -0
- package/crates/tish/src/main.rs +139 -12
- package/crates/tish/tests/integration_test.rs +65 -0
- package/crates/tish_ast/src/ast.rs +6 -2
- package/crates/tish_builtins/src/array.rs +82 -1
- package/crates/tish_builtins/src/globals.rs +50 -16
- package/crates/tish_builtins/src/math.rs +63 -9
- package/crates/tish_builtins/src/number.rs +20 -1
- package/crates/tish_builtins/src/string.rs +68 -4
- package/crates/tish_builtins/src/typedarrays.rs +23 -16
- package/crates/tish_bytecode/src/compiler.rs +94 -28
- package/crates/tish_bytecode/tests/break_continue_bytecode.rs +19 -0
- package/crates/tish_compile/src/check.rs +1 -1
- package/crates/tish_compile/src/codegen.rs +3133 -582
- package/crates/tish_compile/src/infer.rs +1950 -56
- package/crates/tish_compile/src/lib.rs +38 -0
- package/crates/tish_compile/src/resolve.rs +3 -2
- package/crates/tish_compile/src/types.rs +17 -0
- package/crates/tish_compile_js/Cargo.toml +3 -0
- package/crates/tish_compile_js/src/codegen.rs +365 -9
- package/crates/tish_compile_js/src/lib.rs +2 -1
- package/crates/tish_compile_js/src/tests_jsx.rs +229 -1
- package/crates/tish_compiler_wasm/src/resolve_virtual.rs +1 -0
- package/crates/tish_core/Cargo.toml +2 -0
- package/crates/tish_core/src/json.rs +135 -34
- package/crates/tish_core/src/lib.rs +23 -1
- package/crates/tish_core/src/value.rs +64 -11
- package/crates/tish_eval/src/eval.rs +144 -197
- package/crates/tish_eval/src/natives.rs +45 -45
- package/crates/tish_eval/src/regex.rs +35 -27
- package/crates/tish_eval/src/value.rs +8 -0
- package/crates/tish_fmt/src/lib.rs +45 -1
- package/crates/tish_lint/src/bin/tish-lint.rs +17 -8
- package/crates/tish_lint/src/lib.rs +197 -21
- package/crates/tish_lsp/Cargo.toml +6 -0
- package/crates/tish_lsp/src/import_goto.rs +52 -7
- package/crates/tish_lsp/src/main.rs +856 -140
- package/crates/tish_opt/src/lib.rs +5 -3
- package/crates/tish_parser/src/lib.rs +23 -5
- package/crates/tish_parser/src/parser.rs +15 -26
- package/crates/tish_resolve/src/lib.rs +188 -18
- package/crates/tish_runtime/src/lib.rs +58 -18
- package/crates/tish_ui/src/jsx.rs +2 -2
- package/crates/tish_vm/src/jit.rs +212 -10
- package/crates/tish_vm/src/vm.rs +39 -18
- package/crates/tish_wasm/src/lib.rs +116 -22
- 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
|
@@ -186,6 +186,44 @@ fn factory() {
|
|
|
186
186
|
"expected outerVar to be inferred as f64 (Copy, no clone needed)"
|
|
187
187
|
);
|
|
188
188
|
}
|
|
189
|
+
|
|
190
|
+
/// i32-loop-var lowering: an FNV-style integer/bitwise hash accumulator declared before a `for`
|
|
191
|
+
/// and reassigned only by `>>> 0`/bitwise ops lives in an `i32` register across the loop — no
|
|
192
|
+
/// per-op `to_int32(h)`↔`f64` round-trip — with a single `f64` excursion for the `h * C`
|
|
193
|
+
/// multiply (which exceeds 2^53, so it must round in f64 before `>>> 0`, exactly as V8 does).
|
|
194
|
+
#[test]
|
|
195
|
+
fn fnv_accumulator_lowers_to_i32_register() {
|
|
196
|
+
let src = r#"
|
|
197
|
+
let h = 2166136261
|
|
198
|
+
for (let i = 0; i < 100; i++) {
|
|
199
|
+
h = h ^ (i & 255)
|
|
200
|
+
h = (h * 16777619) >>> 0
|
|
201
|
+
h = ((h << 13) | (h >>> 19)) >>> 0
|
|
202
|
+
}
|
|
203
|
+
let check = h >>> 0
|
|
204
|
+
console.log(check)
|
|
205
|
+
"#;
|
|
206
|
+
let rust = compile(&parse(src).unwrap()).unwrap();
|
|
207
|
+
// The accumulator is an i32 register, initialized via the u32 reinterpretation so the
|
|
208
|
+
// > i32::MAX seed keeps its JS ToInt32 bit-pattern.
|
|
209
|
+
assert!(
|
|
210
|
+
rust.contains("let mut h: i32 = (2166136261u32) as i32;"),
|
|
211
|
+
"expected `h` to be an i32 register seeded via u32 reinterpretation; got:\n{}",
|
|
212
|
+
rust.lines().filter(|l| l.contains("h")).take(8).collect::<Vec<_>>().join("\n")
|
|
213
|
+
);
|
|
214
|
+
// The per-iteration `to_int32(h)` round-trips are gone: `h` is read straight from the
|
|
215
|
+
// register inside the bitwise chain (no `to_int32(h)` substring referencing the accumulator).
|
|
216
|
+
assert!(
|
|
217
|
+
!rust.contains("to_int32(h)"),
|
|
218
|
+
"expected NO per-op `to_int32(h)` round-trip on the i32 accumulator"
|
|
219
|
+
);
|
|
220
|
+
// The only f64 excursion is the multiply, lowered as an unchecked truncation of the
|
|
221
|
+
// provably-finite product (`h as f64 * 16777619`).
|
|
222
|
+
assert!(
|
|
223
|
+
rust.contains(".to_int_unchecked::<i64>()") && rust.contains("16777619"),
|
|
224
|
+
"expected the `h * 16777619` excursion to lower to an unchecked f64 truncation"
|
|
225
|
+
);
|
|
226
|
+
}
|
|
189
227
|
}
|
|
190
228
|
|
|
191
229
|
#[cfg(test)]
|
|
@@ -173,7 +173,7 @@ pub fn program_uses_document(program: &Program) -> bool {
|
|
|
173
173
|
ArrayElement::Expr(e) | ArrayElement::Spread(e) => expr_uses_document(e),
|
|
174
174
|
}),
|
|
175
175
|
Expr::Object { props, .. } => props.iter().any(|p| match p {
|
|
176
|
-
ObjectProp::KeyValue(_, e) | ObjectProp::Spread(e) => expr_uses_document(e),
|
|
176
|
+
ObjectProp::KeyValue(_, e, _) | ObjectProp::Spread(e) => expr_uses_document(e),
|
|
177
177
|
}),
|
|
178
178
|
Expr::Assign { value, .. }
|
|
179
179
|
| Expr::CompoundAssign { value, .. }
|
|
@@ -1601,7 +1601,7 @@ fn rewrite_expr_scope(expr: &mut Expr, active: &HashMap<String, Arc<str>>) {
|
|
|
1601
1601
|
Expr::Object { props, .. } => {
|
|
1602
1602
|
for p in props {
|
|
1603
1603
|
match p {
|
|
1604
|
-
ObjectProp::KeyValue(_, e) | ObjectProp::Spread(e) => {
|
|
1604
|
+
ObjectProp::KeyValue(_, e, _) | ObjectProp::Spread(e) => {
|
|
1605
1605
|
rewrite_expr_scope(e, active) // key is a property name; value recurses
|
|
1606
1606
|
}
|
|
1607
1607
|
}
|
|
@@ -1819,6 +1819,7 @@ pub fn merge_modules(mut modules: Vec<ResolvedModule>) -> Result<MergedProgram,
|
|
|
1819
1819
|
name: Arc::from(v.clone()),
|
|
1820
1820
|
span: *span,
|
|
1821
1821
|
},
|
|
1822
|
+
*name_span,
|
|
1822
1823
|
));
|
|
1823
1824
|
}
|
|
1824
1825
|
merge_push(
|
|
@@ -13,6 +13,12 @@ pub enum RustType {
|
|
|
13
13
|
Value,
|
|
14
14
|
/// f64 (for number)
|
|
15
15
|
F64,
|
|
16
|
+
/// i32 — a `number` local PROVEN to always hold an integer reinterpretable as a JS ToInt32
|
|
17
|
+
/// bit-pattern, kept in an integer register across a bitwise/hash hot loop (bun/JSC-style)
|
|
18
|
+
/// instead of round-tripping `f64`↔`i32` on every op. The value is the signed int32
|
|
19
|
+
/// (= JS `ToInt32`) view; `>>> 0` results are uint32 reinterpreted into this i32. Only the
|
|
20
|
+
/// codegen's i32-loop-var lowering produces this type — never `from_annotation`.
|
|
21
|
+
I32,
|
|
16
22
|
/// String (for string)
|
|
17
23
|
String,
|
|
18
24
|
/// bool (for boolean)
|
|
@@ -222,6 +228,7 @@ impl RustType {
|
|
|
222
228
|
match self {
|
|
223
229
|
RustType::Value => "Value".to_string(),
|
|
224
230
|
RustType::F64 => "f64".to_string(),
|
|
231
|
+
RustType::I32 => "i32".to_string(),
|
|
225
232
|
RustType::String => "String".to_string(),
|
|
226
233
|
RustType::Bool => "bool".to_string(),
|
|
227
234
|
RustType::Unit => "()".to_string(),
|
|
@@ -250,6 +257,7 @@ impl RustType {
|
|
|
250
257
|
match self {
|
|
251
258
|
RustType::Value => "Value::Null".to_string(),
|
|
252
259
|
RustType::F64 => "0.0".to_string(),
|
|
260
|
+
RustType::I32 => "0i32".to_string(),
|
|
253
261
|
RustType::String => "String::new()".to_string(),
|
|
254
262
|
RustType::Bool => "false".to_string(),
|
|
255
263
|
RustType::Unit => "()".to_string(),
|
|
@@ -306,6 +314,12 @@ impl RustType {
|
|
|
306
314
|
"match &{} {{ Value::Number(n) => *n, _ => panic!(\"expected number\") }}",
|
|
307
315
|
value_expr
|
|
308
316
|
),
|
|
317
|
+
// A `Value::Number` narrowed to its JS ToInt32 bit-pattern (NaN/±Inf → 0, exactly as
|
|
318
|
+
// the bitwise/shift path coerces). Only reached for an `I32`-typed binding boundary.
|
|
319
|
+
RustType::I32 => format!(
|
|
320
|
+
"match &{} {{ Value::Number(n) => tishlang_runtime::to_int32(*n), _ => panic!(\"expected number\") }}",
|
|
321
|
+
value_expr
|
|
322
|
+
),
|
|
309
323
|
RustType::String => format!(
|
|
310
324
|
"match &{} {{ Value::String(s) => s.to_string(), _ => panic!(\"expected string\") }}",
|
|
311
325
|
value_expr
|
|
@@ -363,6 +377,9 @@ impl RustType {
|
|
|
363
377
|
}
|
|
364
378
|
RustType::Value => native_expr.to_string(),
|
|
365
379
|
RustType::F64 => format!("Value::Number({})", native_expr),
|
|
380
|
+
// The signed int32 view boxes as a JS Number (every i32 is exactly representable in
|
|
381
|
+
// f64). The uint32 `>>> 0` reinterpretation is applied at the boxing site, not here.
|
|
382
|
+
RustType::I32 => format!("Value::Number(({}) as f64)", native_expr),
|
|
366
383
|
RustType::String => format!("Value::String({}.clone().into())", native_expr),
|
|
367
384
|
RustType::Bool => format!("Value::Bool({})", native_expr),
|
|
368
385
|
RustType::Unit => "Value::Null".to_string(),
|
|
@@ -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 { .. } => {}
|
|
@@ -487,9 +526,29 @@ impl Codegen {
|
|
|
487
526
|
}
|
|
488
527
|
}
|
|
489
528
|
|
|
529
|
+
/// Is `expr` the `null` literal? Used to lower `x === null` / `x !== null` to JS `== null` /
|
|
530
|
+
/// `!= null` so the check catches `undefined` too (tish treats a missing/undefined value as
|
|
531
|
+
/// null on every other backend) — see the StrictEq/StrictNe arms below.
|
|
532
|
+
fn is_null_literal(expr: &Expr) -> bool {
|
|
533
|
+
matches!(
|
|
534
|
+
expr,
|
|
535
|
+
Expr::Literal {
|
|
536
|
+
value: Literal::Null,
|
|
537
|
+
..
|
|
538
|
+
}
|
|
539
|
+
)
|
|
540
|
+
}
|
|
541
|
+
|
|
490
542
|
fn emit_expr(&mut self, expr: &Expr) -> Result<String, CompileError> {
|
|
491
543
|
Ok(match expr {
|
|
492
544
|
Expr::Literal { value, .. } => match value {
|
|
545
|
+
// Rust's `{}` prints non-finite f64 as `inf` / `-inf` / `NaN`; only `NaN` is valid JS.
|
|
546
|
+
// Emit the JS spellings so a folded `1/0` / `-1/0` doesn't become an undefined `inf`
|
|
547
|
+
// identifier in the output.
|
|
548
|
+
Literal::Number(n) if n.is_nan() => "NaN".to_string(),
|
|
549
|
+
Literal::Number(n) if n.is_infinite() => {
|
|
550
|
+
if *n < 0.0 { "-Infinity".to_string() } else { "Infinity".to_string() }
|
|
551
|
+
}
|
|
493
552
|
Literal::Number(n) => format!("{}", n),
|
|
494
553
|
Literal::String(s) => format!("{:?}", s.as_ref()),
|
|
495
554
|
Literal::Bool(b) => format!("{}", b),
|
|
@@ -510,8 +569,26 @@ impl Codegen {
|
|
|
510
569
|
BinOp::Pow => "**",
|
|
511
570
|
BinOp::Eq => "==",
|
|
512
571
|
BinOp::Ne => "!=",
|
|
513
|
-
|
|
514
|
-
|
|
572
|
+
// tish has no `undefined`: a missing/absent value reads back as `null`, so
|
|
573
|
+
// `x === null` means "is nullish" — exactly how interp/vm/native behave (a
|
|
574
|
+
// missing property is null there). In the JS runtime a missing property is
|
|
575
|
+
// `undefined`, so lower `=== null` / `!== null` to loose `== null` / `!= null`,
|
|
576
|
+
// which match BOTH null and undefined — keeping `=== null` mean the same thing on
|
|
577
|
+
// every target. Strict equality between non-null operands is unaffected.
|
|
578
|
+
BinOp::StrictEq => {
|
|
579
|
+
if Self::is_null_literal(left) || Self::is_null_literal(right) {
|
|
580
|
+
"=="
|
|
581
|
+
} else {
|
|
582
|
+
"==="
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
BinOp::StrictNe => {
|
|
586
|
+
if Self::is_null_literal(left) || Self::is_null_literal(right) {
|
|
587
|
+
"!="
|
|
588
|
+
} else {
|
|
589
|
+
"!=="
|
|
590
|
+
}
|
|
591
|
+
}
|
|
515
592
|
BinOp::Lt => "<",
|
|
516
593
|
BinOp::Le => "<=",
|
|
517
594
|
BinOp::Gt => ">",
|
|
@@ -563,6 +640,15 @@ impl Codegen {
|
|
|
563
640
|
..
|
|
564
641
|
} => {
|
|
565
642
|
let obj = self.emit_expr(object)?;
|
|
643
|
+
// `255.toString()` is a JS syntax error — the lexer reads `255.` as a float and
|
|
644
|
+
// then chokes on the method name. Parenthesize a numeric-literal object so member
|
|
645
|
+
// access / method calls stay valid: `(255).toString()`. (Folded constants reach
|
|
646
|
+
// codegen as number literals too, so this covers e.g. `(100 * 2).toString()`.)
|
|
647
|
+
let obj = if matches!(&**object, Expr::Literal { value: Literal::Number(_), .. }) {
|
|
648
|
+
format!("({})", obj)
|
|
649
|
+
} else {
|
|
650
|
+
obj
|
|
651
|
+
};
|
|
566
652
|
let expr = match prop {
|
|
567
653
|
MemberProp::Name { name, .. } => {
|
|
568
654
|
if name.parse::<u32>().is_ok()
|
|
@@ -629,7 +715,7 @@ impl Codegen {
|
|
|
629
715
|
let parts: Result<Vec<_>, _> = props
|
|
630
716
|
.iter()
|
|
631
717
|
.map(|p| match p {
|
|
632
|
-
ObjectProp::KeyValue(k, v) => {
|
|
718
|
+
ObjectProp::KeyValue(k, v, _) => {
|
|
633
719
|
let key = k.as_ref();
|
|
634
720
|
let val = self.emit_expr(v)?;
|
|
635
721
|
Ok(if key.chars().all(|c| c.is_alphanumeric() || c == '_') {
|
|
@@ -650,7 +736,11 @@ impl Codegen {
|
|
|
650
736
|
}
|
|
651
737
|
Expr::TypeOf { operand, .. } => {
|
|
652
738
|
let o = self.emit_expr(operand)?;
|
|
653
|
-
|
|
739
|
+
// tish `typeof null` is "null" (interp/vm/native all agree — null is a first-class
|
|
740
|
+
// type, not JS's `typeof null === "object"` wart). tish has no `undefined`, so any
|
|
741
|
+
// nullish operand (incl. a JS-runtime `undefined`) maps to "null". Evaluate the
|
|
742
|
+
// operand once via the arrow arg so side effects don't run twice.
|
|
743
|
+
format!("((__v) => __v == null ? \"null\" : typeof __v)({})", o)
|
|
654
744
|
}
|
|
655
745
|
Expr::Delete { target, .. } => {
|
|
656
746
|
// Emit the raw property *reference*, not a value: `emit_expr` wraps Index /
|
|
@@ -808,6 +898,172 @@ impl Codegen {
|
|
|
808
898
|
CallArg::Spread(e) => Ok(format!("...{}", self.emit_expr(e)?)),
|
|
809
899
|
}
|
|
810
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
|
+
}
|
|
811
1067
|
}
|
|
812
1068
|
|
|
813
1069
|
/// Compile a single program (no imports) to JavaScript. JSX lowers to `h` / `Fragment` (Lattish).
|
|
@@ -936,3 +1192,103 @@ pub fn compile_project_with_jsx(
|
|
|
936
1192
|
};
|
|
937
1193
|
Ok(compile_project_js_inner(entry_path, project_root, optimize, false, &out_name)?.js)
|
|
938
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
|
|