@tishlang/tish-format 2.39.0 → 3.0.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.
Files changed (90) hide show
  1. package/Cargo.toml +4 -1
  2. package/bin/tish-format +0 -0
  3. package/crates/tish/src/cli_help.rs +28 -0
  4. package/crates/tish/src/main.rs +41 -5
  5. package/crates/tish/tests/fixtures/cargo_example_project/crates/demo-shim/src/lib.rs +21 -0
  6. package/crates/tish/tests/fixtures/cargo_example_project/crates/demo-shim/tish.d.tish +4 -0
  7. package/crates/tish/tests/fixtures/cargo_example_project/src/main.tish +5 -1
  8. package/crates/tish/tests/regr_typed_externs.rs +59 -0
  9. package/crates/tish/tests/scope_merge.rs +79 -0
  10. package/crates/tish_build_utils/src/lib.rs +27 -1
  11. package/crates/tish_builtins/Cargo.toml +14 -6
  12. package/crates/tish_builtins/src/array.rs +130 -54
  13. package/crates/tish_builtins/src/collections.rs +9 -5
  14. package/crates/tish_builtins/src/construct.rs +7 -1
  15. package/crates/tish_builtins/src/date.rs +15 -1
  16. package/crates/tish_builtins/src/globals.rs +65 -3
  17. package/crates/tish_builtins/src/helpers.rs +7 -3
  18. package/crates/tish_builtins/src/iterator.rs +5 -1
  19. package/crates/tish_builtins/src/lib.rs +3 -0
  20. package/crates/tish_builtins/src/math.rs +14 -0
  21. package/crates/tish_builtins/src/number.rs +72 -6
  22. package/crates/tish_builtins/src/object.rs +6 -1
  23. package/crates/tish_builtins/src/string.rs +13 -2
  24. package/crates/tish_builtins/src/symbol.rs +17 -17
  25. package/crates/tish_builtins/src/typedarrays.rs +9 -2
  26. package/crates/tish_bytecode/src/compiler.rs +76 -2
  27. package/crates/tish_bytecode/src/lib.rs +1 -1
  28. package/crates/tish_bytecode/src/opcode.rs +77 -2
  29. package/crates/tish_compile/src/codegen.rs +2154 -225
  30. package/crates/tish_compile/src/infer.rs +37 -41
  31. package/crates/tish_compile/src/lib.rs +13 -1
  32. package/crates/tish_compile/src/platform_resolve.rs +361 -0
  33. package/crates/tish_compile/src/resolve.rs +195 -11
  34. package/crates/tish_compile/src/schemes.rs +333 -0
  35. package/crates/tish_compile/src/types.rs +205 -34
  36. package/crates/tish_compile/tests/perf_codegen_320.rs +2 -2
  37. package/crates/tish_compile/tests/perf_codegen_module_const_forof.rs +3 -1
  38. package/crates/tish_compile/tests/platform_resolve_cli.rs +164 -0
  39. package/crates/tish_compile/tests/regr_556_param_name_collision.rs +40 -0
  40. package/crates/tish_compile/tests/regr_558_struct_array_write.rs +65 -0
  41. package/crates/tish_compile/tests/regr_558b_struct_field_read.rs +21 -0
  42. package/crates/tish_compile/tests/regr_558c_wrapped_struct_array.rs +64 -0
  43. package/crates/tish_compile/tests/regr_558d_native_vec_mutators.rs +156 -0
  44. package/crates/tish_compile/tests/regr_562_wrapped_struct_array_push.rs +50 -0
  45. package/crates/tish_compile/tests/regr_fixed_native_coercions.rs +69 -0
  46. package/crates/tish_compile/tests/regr_gba_numerics_gated.rs +58 -0
  47. package/crates/tish_compile/tests/regr_gba_struct_reflection.rs +106 -0
  48. package/crates/tish_compile/tests/regression/name_collision_lib.tish +6 -0
  49. package/crates/tish_compile/tests/regression/name_collision_main.tish +13 -0
  50. package/crates/tish_compile/tests/regression/struct_array_write.tish +22 -0
  51. package/crates/tish_compile/tests/regression/struct_field_return_read.tish +10 -0
  52. package/crates/tish_compiler_wasm/Cargo.toml +1 -0
  53. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +56 -6
  54. package/crates/tish_core/Cargo.toml +39 -4
  55. package/crates/tish_core/src/compat.rs +506 -0
  56. package/crates/tish_core/src/console_style.rs +23 -1
  57. package/crates/tish_core/src/json.rs +42 -10
  58. package/crates/tish_core/src/lib.rs +121 -25
  59. package/crates/tish_core/src/macros.rs +1 -1
  60. package/crates/tish_core/src/shape.rs +4 -2
  61. package/crates/tish_core/src/uri.rs +8 -1
  62. package/crates/tish_core/src/value.rs +305 -54
  63. package/crates/tish_core/src/vmref.rs +17 -5
  64. package/crates/tish_eval/Cargo.toml +2 -0
  65. package/crates/tish_eval/src/eval.rs +90 -3
  66. package/crates/tish_eval/src/value.rs +8 -0
  67. package/crates/tish_eval/src/value_convert.rs +316 -21
  68. package/crates/tish_fmt/Cargo.toml +1 -1
  69. package/crates/tish_lsp/Cargo.toml +2 -0
  70. package/crates/tish_lsp/src/import_goto.rs +41 -14
  71. package/crates/tish_lsp/src/main.rs +18 -0
  72. package/crates/tish_native/src/build.rs +246 -0
  73. package/crates/tish_native/src/config.rs +20 -0
  74. package/crates/tish_native/src/lib.rs +16 -6
  75. package/crates/tish_parser/src/lib.rs +28 -0
  76. package/crates/tish_parser/src/parser.rs +25 -4
  77. package/crates/tish_runtime/src/lib.rs +55 -9
  78. package/crates/tish_runtime_gba/Cargo.lock +837 -0
  79. package/crates/tish_runtime_gba/Cargo.toml +16 -0
  80. package/crates/tish_runtime_gba/src/gba.rs +211 -0
  81. package/crates/tish_runtime_gba/src/lib.rs +804 -0
  82. package/crates/tish_vm/src/jit.rs +1446 -164
  83. package/crates/tish_vm/src/vm.rs +583 -173
  84. package/justfile +9 -0
  85. package/package.json +1 -1
  86. package/platform/darwin-arm64/tish-fmt +0 -0
  87. package/platform/darwin-x64/tish-fmt +0 -0
  88. package/platform/linux-arm64/tish-fmt +0 -0
  89. package/platform/linux-x64/tish-fmt +0 -0
  90. package/platform/win32-x64/tish-fmt.exe +0 -0
@@ -2,10 +2,34 @@
2
2
  //!
3
3
  //! Maps TypeAnnotation from the AST to concrete Rust types for code generation.
4
4
 
5
+ use std::cell::Cell;
5
6
  use std::collections::HashMap;
6
7
  use std::sync::Arc;
7
8
  use tishlang_ast::{BinOp, FunParam, TypeAnnotation, TypedParam};
8
9
 
10
+ thread_local! {
11
+ /// Whether the extended GBA numeric vocabulary — the narrow integer widths (`i8/u8/i16/u16/u32`)
12
+ /// and `fixed` — lowers to native scalar types. **Off by default** (standard targets): those
13
+ /// annotations must fall back to the boxed `Value` path off-GBA, because `fixed` has no host
14
+ /// runtime type (a hard compile error) and the narrow widths truncate on store (`u8 = 300` → 255),
15
+ /// diverging from the interpreter and breaking the typed == boxed == interpreter guarantee that
16
+ /// non-GBA targets rely on. A GBA build turns this on (see `set_gba_numerics`), where those
17
+ /// types ARE the point. `i32`/`f64` are NOT gated — `f64` is just `number`, and `i32` is the
18
+ /// pre-existing JS-ToInt32 register lowering.
19
+ static GBA_NUMERICS: Cell<bool> = const { Cell::new(false) };
20
+ }
21
+
22
+ /// Enable (GBA) or disable (every other target) the extended narrow-int/`fixed` numeric vocabulary
23
+ /// for the current compile. Set once at compile start from the emit mode; see [`GBA_NUMERICS`].
24
+ pub fn set_gba_numerics(on: bool) {
25
+ GBA_NUMERICS.with(|c| c.set(on));
26
+ }
27
+
28
+ /// Whether `from_annotation` should lower `i8/u8/i16/u16/u32/fixed` to native scalars.
29
+ fn gba_numerics() -> bool {
30
+ GBA_NUMERICS.with(|c| c.get())
31
+ }
32
+
9
33
  /// Concrete Rust type representation for code generation.
10
34
  #[derive(Debug, Clone, PartialEq)]
11
35
  pub enum RustType {
@@ -16,9 +40,27 @@ pub enum RustType {
16
40
  /// i32 — a `number` local PROVEN to always hold an integer reinterpretable as a JS ToInt32
17
41
  /// bit-pattern, kept in an integer register across a bitwise/hash hot loop (bun/JSC-style)
18
42
  /// 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`.
43
+ /// (= JS `ToInt32`) view; `>>> 0` results are uint32 reinterpreted into this i32. Off-GBA only
44
+ /// the codegen's i32-loop-var lowering produces this type (a proven-in-range value) — never
45
+ /// `from_annotation`; a `--target gba` build additionally lowers a `: i32` annotation to it
46
+ /// (part of the GBA typed-scalar vocabulary, gated by `GBA_NUMERICS`).
21
47
  I32,
48
+ /// Fixed-point `tishlang_runtime::Fixed` (= agb `Num<i32, 8>`), for the `fixed`
49
+ /// annotation — fast, FPU-free math for positions/velocities on the GBA. Only
50
+ /// `from_annotation` (and fixed-typed inference) produces this. Boxes to/from
51
+ /// `Value::Number` losslessly at boundaries.
52
+ Fixed,
53
+ /// Narrow integer STORAGE types (`i8`/`u8`/`i16`/`u16`/`u32` annotations). Their whole
54
+ /// point is compact struct fields — a `u8` HP or an `i16` tile coordinate costs 1–2
55
+ /// bytes in scarce EWRAM instead of 8. Arithmetic PROMOTES to `f64` (JS Number
56
+ /// semantics, exactly like `I32` at [`Self::result_type_of_binop`]); a store back into
57
+ /// the narrow field truncates with a saturating `as` cast. Only `from_annotation`
58
+ /// produces these. Box to/from `Value::Number` (every value in-range is exact in f64).
59
+ I8,
60
+ U8,
61
+ I16,
62
+ U16,
63
+ U32,
22
64
  /// String (for string)
23
65
  String,
24
66
  /// bool (for boolean)
@@ -83,6 +125,28 @@ impl RustType {
83
125
  match ann {
84
126
  TypeAnnotation::Simple(name, _) => match name.as_ref() {
85
127
  "number" => RustType::F64,
128
+ // Concrete Rust scalar names in typed tish (user requirement): they
129
+ // lower to the corresponding native Rust type instead of a boxed
130
+ // `Value`. `f64` is an alias for `number`; `i32` reuses the existing
131
+ // integer-register lowering (its value is the JS ToInt32 view, which
132
+ // is exactly an annotated `i32`). The narrow widths are compact struct
133
+ // storage; `fixed` is agb `Num<i32,8>`.
134
+ // GBA-only numeric vocabulary (see `GBA_NUMERICS`): off-GBA these fall through to
135
+ // the `other` arm → boxed `Value` (matching main, where these annotations were
136
+ // unrecognized), so `fixed` compiles (no host `Fixed` type), narrow widths keep
137
+ // interpreter number semantics instead of truncating, and a `: i32` annotation
138
+ // doesn't saturate/ToInt32 an out-of-range value — restoring the invariant that
139
+ // `I32` comes ONLY from the proven-in-range i32-loop-var lowering, never a raw
140
+ // annotation. `f64` is the redundant Rust spelling of `number`; gated alongside the
141
+ // rest so it too is inert off-GBA (`number` itself stays `F64`, always).
142
+ "f64" if gba_numerics() => RustType::F64,
143
+ "i32" if gba_numerics() => RustType::I32,
144
+ "i8" if gba_numerics() => RustType::I8,
145
+ "u8" if gba_numerics() => RustType::U8,
146
+ "i16" if gba_numerics() => RustType::I16,
147
+ "u16" if gba_numerics() => RustType::U16,
148
+ "u32" if gba_numerics() => RustType::U32,
149
+ "fixed" if gba_numerics() => RustType::Fixed,
86
150
  "string" => RustType::String,
87
151
  "boolean" | "bool" => RustType::Bool,
88
152
  "void" | "undefined" => RustType::Unit,
@@ -191,11 +255,47 @@ impl RustType {
191
255
  !matches!(self, RustType::Value)
192
256
  }
193
257
 
258
+ /// Whether the generated Rust type is `Copy` — the native numeric scalars, `Fixed`, and `Unit`.
259
+ /// A `Copy` field can be read out of a borrowed place (`arr[i].field`, `(*cell.borrow()).field`)
260
+ /// by value; a non-`Copy` one (`Value`/`String`/an aggregate) must be `.clone()`d there or it
261
+ /// moves out of the borrow — a use-after-move / cannot-move-out-of-index error (E0382 / E0507).
262
+ pub fn is_copy(&self) -> bool {
263
+ matches!(
264
+ self,
265
+ RustType::F64 | RustType::I32 | RustType::Bool | RustType::Fixed | RustType::Unit
266
+ ) || self.is_narrow_int()
267
+ }
268
+
194
269
  /// Check if this type is numeric (f64).
195
270
  pub fn is_numeric(&self) -> bool {
196
271
  matches!(self, RustType::F64)
197
272
  }
198
273
 
274
+ /// A native integer scalar (`i32` or one of the narrow storage widths). These share one
275
+ /// arithmetic model: read as `f64` (JS Number semantics), store with a truncating `as`
276
+ /// cast. `Fixed` is deliberately excluded — it is fixed-point, not an integer.
277
+ pub fn is_integer_scalar(&self) -> bool {
278
+ matches!(
279
+ self,
280
+ RustType::I32
281
+ | RustType::I8
282
+ | RustType::U8
283
+ | RustType::I16
284
+ | RustType::U16
285
+ | RustType::U32
286
+ )
287
+ }
288
+
289
+ /// A NARROW integer storage width (`i8`/`u8`/`i16`/`u16`/`u32`) — an integer scalar other
290
+ /// than the `I32` register type, which has its own JS-ToInt32 lowering. These promote to
291
+ /// `f64` for arithmetic and truncate-cast on store.
292
+ pub fn is_narrow_int(&self) -> bool {
293
+ matches!(
294
+ self,
295
+ RustType::I8 | RustType::U8 | RustType::I16 | RustType::U16 | RustType::U32
296
+ )
297
+ }
298
+
199
299
  /// Infer the result type of a binary operation given the operand types.
200
300
  /// Returns `None` if native code cannot be emitted (fall back to Value path).
201
301
  pub fn result_type_of_binop(op: BinOp, lhs: &RustType, rhs: &RustType) -> Option<RustType> {
@@ -221,6 +321,21 @@ impl RustType {
221
321
  | BinOp::StrictNe => Some(RustType::Bool),
222
322
  _ => None,
223
323
  }
324
+ } else if lhs == &RustType::Fixed && rhs == &RustType::Fixed {
325
+ // agb `Num<i32,8>` overloads `+ - * /` (Mul/Div apply the Q24.8 shift
326
+ // correction) and `PartialOrd`/`PartialEq`, so these lower to native
327
+ // fixed-point ops — no f64 round-trip, no FPU. `%`/`**`/bitwise fall to
328
+ // the boxed path (rare on positions/velocities).
329
+ match op {
330
+ BinOp::Add | BinOp::Sub | BinOp::Mul | BinOp::Div => Some(RustType::Fixed),
331
+ BinOp::Lt
332
+ | BinOp::Le
333
+ | BinOp::Gt
334
+ | BinOp::Ge
335
+ | BinOp::StrictEq
336
+ | BinOp::StrictNe => Some(RustType::Bool),
337
+ _ => None,
338
+ }
224
339
  } else if lhs == &RustType::Bool && rhs == &RustType::Bool {
225
340
  match op {
226
341
  BinOp::And | BinOp::Or => Some(RustType::Bool),
@@ -248,6 +363,12 @@ impl RustType {
248
363
  RustType::Value => "Value".to_string(),
249
364
  RustType::F64 => "f64".to_string(),
250
365
  RustType::I32 => "i32".to_string(),
366
+ RustType::I8 => "i8".to_string(),
367
+ RustType::U8 => "u8".to_string(),
368
+ RustType::I16 => "i16".to_string(),
369
+ RustType::U16 => "u16".to_string(),
370
+ RustType::U32 => "u32".to_string(),
371
+ RustType::Fixed => "tishlang_runtime::Fixed".to_string(),
251
372
  RustType::String => "String".to_string(),
252
373
  RustType::Bool => "bool".to_string(),
253
374
  RustType::Unit => "()".to_string(),
@@ -279,6 +400,12 @@ impl RustType {
279
400
  RustType::Value => "Value::Null".to_string(),
280
401
  RustType::F64 => "0.0".to_string(),
281
402
  RustType::I32 => "0i32".to_string(),
403
+ RustType::I8 => "0i8".to_string(),
404
+ RustType::U8 => "0u8".to_string(),
405
+ RustType::I16 => "0i16".to_string(),
406
+ RustType::U16 => "0u16".to_string(),
407
+ RustType::U32 => "0u32".to_string(),
408
+ RustType::Fixed => "tishlang_runtime::Fixed::from_raw(0)".to_string(),
282
409
  RustType::String => "String::new()".to_string(),
283
410
  RustType::Bool => "false".to_string(),
284
411
  RustType::Unit => "()".to_string(),
@@ -358,6 +485,23 @@ impl RustType {
358
485
  "match &{} {{ Value::Number(n) => tishlang_runtime::to_int32(*n), _ => panic!(\"expected number\") }}",
359
486
  value_expr
360
487
  ),
488
+ // Narrow int storage: extract the JS Number and truncate-cast to the field width
489
+ // (Rust `f64 as iN` saturates out-of-range and maps NaN→0 — safe for a storage slot).
490
+ RustType::I8 | RustType::U8 | RustType::I16 | RustType::U16 | RustType::U32 => {
491
+ format!(
492
+ "match &{} {{ Value::Number(n) => (*n as {}), _ => panic!(\"expected number\") }}",
493
+ value_expr,
494
+ self.to_rust_type_str()
495
+ )
496
+ }
497
+ // f64 → Q24.8: scale by 256 and TRUNCATE toward zero (`as i32`), keeping only 8
498
+ // fractional bits (`0.1` → raw 25 ≈ 0.0977, not exact). Must stay bit-identical with
499
+ // the compile-time literal fold in codegen (`fixed_literal_of`) so a `fixed` value is
500
+ // the same whether it came from a folded literal or this runtime conversion.
501
+ RustType::Fixed => format!(
502
+ "match &{} {{ Value::Number(n) => tishlang_runtime::Fixed::from_raw((*n * 256.0) as i32), _ => panic!(\"expected number\") }}",
503
+ value_expr
504
+ ),
361
505
  RustType::String => format!(
362
506
  "match &{} {{ Value::String(s) => s.to_string(), _ => panic!(\"expected string\") }}",
363
507
  value_expr
@@ -400,11 +544,19 @@ impl RustType {
400
544
  // e.g. an assignment-as-expression `{ let _v = obj; lhs = <coerce _v>; _v }` reuses
401
545
  // `_v` for its value (tishlang/tish#486). `&(expr)` evaluates the source once and
402
546
  // (for a temporary) lifetime-extends it to this block, so it's still single-eval.
547
+ //
548
+ // FAST PATH (GBA/portable): if the source is a BY-REFERENCE boxed struct of exactly this
549
+ // type (`Value::Struct`, e.g. from a `Value::from_struct` return), `downcast_struct`
550
+ // clones it out directly — no per-field `get_prop`. Off-portable there is no struct
551
+ // variant, so `downcast_struct` is a `None` constant the optimiser removes, leaving the
552
+ // rebuild path byte-identical to before.
553
+ let struct_ident = named_struct_ident(name);
403
554
  format!(
404
- "{{ let _src = &({}); {} {{ {} }} }}",
405
- value_expr,
406
- named_struct_ident(name),
407
- field_assigns
555
+ "{{ let _src = &({}); match _src.downcast_struct::<{}>() {{ \
556
+ Some(_s) => _s, \
557
+ None => {} {{ {} }} \
558
+ }} }}",
559
+ value_expr, struct_ident, struct_ident, field_assigns
408
560
  )
409
561
  }
410
562
  RustType::ShapeUnion { .. } => {
@@ -437,6 +589,12 @@ impl RustType {
437
589
  // The signed int32 view boxes as a JS Number (every i32 is exactly representable in
438
590
  // f64). The uint32 `>>> 0` reinterpretation is applied at the boxing site, not here.
439
591
  RustType::I32 => format!("Value::Number(({}) as f64)", native_expr),
592
+ // Narrow int → JS Number: every in-range narrow int is exact in f64.
593
+ RustType::I8 | RustType::U8 | RustType::I16 | RustType::U16 | RustType::U32 => {
594
+ format!("Value::Number(({}) as f64)", native_expr)
595
+ }
596
+ // Q24.8 → f64 is exact (32 significant bits into a 52-bit mantissa).
597
+ RustType::Fixed => format!("Value::Number(({}).to_raw() as f64 / 256.0)", native_expr),
440
598
  RustType::String => format!("Value::String({}.clone().into())", native_expr),
441
599
  RustType::Bool => format!("Value::Bool({})", native_expr),
442
600
  RustType::Unit => "Value::Null".to_string(),
@@ -463,34 +621,22 @@ impl RustType {
463
621
  native_expr, inner_to_value
464
622
  )
465
623
  }
466
- RustType::Named { fields, .. } => {
467
- // Build the boxed Value with an ORDERED PropMap via `object_from_pairs` (no
468
- // intermediate `AHashMap`), so key order == field-declaration order == JS
469
- // insertion order. The old `ObjectMap::default()` (`AHashMap`) + insert path
470
- // scrambled key order NON-DETERMINISTICALLY per run (ahash seed) — a shipped
471
- // `JSON.stringify` / `Object.keys` / `for..in` divergence from node whenever a
472
- // native struct crosses back into untyped Tish. This boundary is paid only on
473
- // that crossing (JSON.stringify, calling a Value::Function, etc.); direct
474
- // Rust-to-Rust paths between two Named values stay as plain struct moves.
475
- let pairs = fields
476
- .iter()
477
- .map(|(k, ty)| {
478
- let access = format!("{}.{}", native_expr, field_ident(k));
479
- // A `Value`-typed field (e.g. from a generic struct `Box<T>`) accessed
480
- // behind `&self` must be cloned — it isn't `Copy` and `to_value_expr(Value)`
481
- // is identity. Native field types clone/copy inside their own `to_value_expr`.
482
- let v_expr = if matches!(ty, RustType::Value) {
483
- format!("{}.clone()", access)
484
- } else {
485
- ty.to_value_expr(&access)
486
- };
487
- format!("(::std::sync::Arc::from({:?}), {})", k.as_ref(), v_expr)
488
- })
489
- .collect::<Vec<_>>()
490
- .join(", ");
491
- // `object_from_pairs::<N>` builds the `PropMap` in one ordered pass (N = field
492
- // count, a codegen-time constant) — no AHashMap, so key order is preserved.
493
- format!("Value::object_from_pairs([{}])", pairs)
624
+ RustType::Named { .. } => {
625
+ // Box the struct BY SHARED REFERENCE via `Value::from_struct_ref(&(..))`. Portable/GBA
626
+ // keeps it as a cheap `Value::Struct` (one `Rc` — the struct must be OWNED there, so it
627
+ // clones internally). Every other native backend serialises the borrow directly through
628
+ // `TishStruct::tish_to_object` (the ordered `object_from_pairs` boundary — see
629
+ // [`named_struct_to_object_expr`]) with NO whole-struct clone, byte-identical to the
630
+ // historical behaviour that read fields straight off the place (#565: the previous
631
+ // `from_struct((..).clone())` cloned the entire struct just to serialise it off-GBA).
632
+ // Property access on a boxed struct dispatches through the generated `impl TishStruct`
633
+ // (small match, no hash); materialisation is reached only on JSON.stringify / display /
634
+ // `Object.keys`-family / `for..in` / `in` / `Object.assign` / `{...s}` spread. `&(..)`
635
+ // borrows the place codegen already emits here (a value or a deref like `(*gs.borrow())`).
636
+ format!(
637
+ "tishlang_runtime::Value::from_struct_ref(&({}))",
638
+ native_expr
639
+ )
494
640
  }
495
641
  RustType::ShapeUnion { name, variants } => {
496
642
  // union → boxed Value::Object: one match arm per variant, delegating to that
@@ -534,6 +680,31 @@ pub fn named_struct_ident(tish_name: &str) -> String {
534
680
  format!("TishStruct_{}", tish_name)
535
681
  }
536
682
 
683
+ /// Build the `Value::object_from_pairs([...])` that materialises a Named struct at `native_expr` into
684
+ /// an ORDERED boxed object (field-declaration = JS insertion order — deterministic, unlike an AHashMap
685
+ /// insert). This was the body of `to_value_expr`'s `Named` arm; it now backs the generated
686
+ /// `TishStruct::tish_to_object` (the JSON / display / `Object.keys`-family / `for..in` / `in` /
687
+ /// `Object.assign` / `{...s}` spread materialisation, and the non-portable
688
+ /// `Value::from_struct` fallback), while the live boxing path emits the cheap by-ref `Value::from_struct`.
689
+ pub fn named_struct_to_object_expr(fields: &[(Arc<str>, RustType)], native_expr: &str) -> String {
690
+ let pairs = fields
691
+ .iter()
692
+ .map(|(k, ty)| {
693
+ let access = format!("{}.{}", native_expr, field_ident(k));
694
+ // A `Value`-typed field must be cloned (not `Copy`, and `to_value_expr(Value)` is identity);
695
+ // native field types clone/copy inside their own `to_value_expr`.
696
+ let v_expr = if matches!(ty, RustType::Value) {
697
+ format!("{}.clone()", access)
698
+ } else {
699
+ ty.to_value_expr(&access)
700
+ };
701
+ format!("(::std::sync::Arc::from({:?}), {})", k.as_ref(), v_expr)
702
+ })
703
+ .collect::<Vec<_>>()
704
+ .join(", ");
705
+ format!("Value::object_from_pairs([{}])", pairs)
706
+ }
707
+
537
708
  /// #179 Stage B: map a shape-union alias to the generated Rust enum identifier.
538
709
  pub fn shape_union_enum_ident(tish_name: &str) -> String {
539
710
  format!("TishUnion_{}", tish_name)
@@ -134,8 +134,8 @@ fn readonly_arr_param_unboxed_to_native_vec() {
134
134
  // NumberArray clones its backing; a boxed Array is mapped element-wise).
135
135
  assert!(
136
136
  rust.contains("let mut seq: Vec<f64> = match args.get(")
137
- && rust.contains("Some(Value::NumberArray(a)) => a.borrow().clone()"),
138
- "read-only number[] param should be unboxed to a native Vec<f64>"
137
+ && rust.contains("NumArrayBacking::Packed(p) => p.clone()"),
138
+ "read-only number[] param should be unboxed to a native Vec<f64> (packed backing cloned directly)"
139
139
  );
140
140
  // And the hot inner loop indexes it natively (f64 mul + native vec read), no boxed get_index.
141
141
  assert!(
@@ -33,7 +33,9 @@ fn template_literals_hoisted_nums_compiles() {
33
33
  let (rust, _, _, _) = compile_project_full(&path, path.parent(), &[], true).unwrap();
34
34
  assert!(rust.contains("const G_NUMS:"));
35
35
  assert!(
36
- rust.contains("array_join(&Value::NumberArray(VmRef::new(G_NUMS"),
36
+ rust.contains(
37
+ "array_join(&Value::NumberArray(VmRef::new(tishlang_runtime::NumArrayBacking::Packed(G_NUMS"
38
+ ),
37
39
  "nums.join should box hoisted G_NUMS, not reference missing local nums"
38
40
  );
39
41
  assert!(!rust.contains("&nums,"));
@@ -0,0 +1,164 @@
1
+ //! Golden: `resolve_with_platform` cascade matches the documented order.
2
+ //! Vite consumes the same rules via `tish resolve-id`.
3
+ //!
4
+ //! The CLI parity test **fails** (does not skip) when no usable `tish` binary is found,
5
+ //! so CI cannot silently ship without `resolve-id`.
6
+
7
+ use std::fs;
8
+ use std::path::{Path, PathBuf};
9
+ use std::process::Command;
10
+
11
+ use tempfile::tempdir;
12
+ use tishlang_compile::{resolve_with_platform, Platform, ResolveContext, Surface};
13
+
14
+ #[test]
15
+ fn cascade_orders_match_language_md() {
16
+ let dir = tempdir().unwrap();
17
+ let root = dir.path();
18
+ fs::write(root.join("Button.tish"), "export fn Button() {}").unwrap();
19
+ fs::write(root.join("Button.web.tish"), "export fn Button() {}").unwrap();
20
+ fs::write(root.join("Button.webview.tish"), "export fn Button() {}").unwrap();
21
+ fs::write(root.join("Button.macos.tish"), "export fn Button() {}").unwrap();
22
+ fs::write(root.join("Button.desktop.tish"), "export fn Button() {}").unwrap();
23
+
24
+ let macos_native = resolve_with_platform(
25
+ "./Button",
26
+ root,
27
+ ResolveContext {
28
+ platform: Platform::Macos,
29
+ surface: Surface::Native,
30
+ },
31
+ )
32
+ .unwrap();
33
+ assert!(macos_native.ends_with("Button.macos.tish"));
34
+
35
+ let webview = resolve_with_platform(
36
+ "./Button",
37
+ root,
38
+ ResolveContext {
39
+ platform: Platform::Macos,
40
+ surface: Surface::Webview,
41
+ },
42
+ )
43
+ .unwrap();
44
+ assert!(webview.ends_with("Button.webview.tish"));
45
+
46
+ let web = resolve_with_platform(
47
+ "./Button",
48
+ root,
49
+ ResolveContext {
50
+ platform: Platform::Web,
51
+ surface: Surface::Web,
52
+ },
53
+ )
54
+ .unwrap();
55
+ assert!(web.ends_with("Button.web.tish"));
56
+
57
+ // Remap explicit .tish to platform file when present
58
+ let remapped = resolve_with_platform(
59
+ "./Button.tish",
60
+ root,
61
+ ResolveContext {
62
+ platform: Platform::Macos,
63
+ surface: Surface::Native,
64
+ },
65
+ )
66
+ .unwrap();
67
+ assert!(remapped.ends_with("Button.macos.tish"));
68
+ }
69
+
70
+ /// Locate a `tish` binary that supports `resolve-id`.
71
+ ///
72
+ /// Does **not** shell out to `cargo build --bin tish` (under `cargo llvm-cov` that can shadow the
73
+ /// instrumented CLI). Searches the same places as `integration_test::tish_bin()`, including
74
+ /// `llvm-cov-target`. Callers must fail hard if this returns `None` — CI must not soft-skip.
75
+ fn find_tish_binary() -> Option<PathBuf> {
76
+ if let Ok(p) = std::env::var("TISH_PATH") {
77
+ let pb = PathBuf::from(&p);
78
+ if pb.is_file() {
79
+ return Some(pb);
80
+ }
81
+ }
82
+ let bin = if cfg!(windows) { "tish.exe" } else { "tish" };
83
+ let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
84
+ let mut roots: Vec<PathBuf> = Vec::new();
85
+ if let Ok(td) = std::env::var("CARGO_TARGET_DIR") {
86
+ roots.push(PathBuf::from(td));
87
+ }
88
+ // crates/tish_compile → workspace `target/` (and the coverage-instrumented `llvm-cov-target`).
89
+ for rel in ["../../target", "../../../target"] {
90
+ roots.push(manifest.join(rel));
91
+ roots.push(manifest.join(rel).join("llvm-cov-target"));
92
+ }
93
+ for root in roots {
94
+ for profile in ["debug", "release"] {
95
+ let cand = root.join(profile).join(bin);
96
+ if cand.is_file() {
97
+ return Some(cand);
98
+ }
99
+ }
100
+ }
101
+ if let Ok(out) = Command::new("which").arg("tish").output() {
102
+ if out.status.success() {
103
+ let p = String::from_utf8_lossy(&out.stdout).trim().to_string();
104
+ if !p.is_empty() && Path::new(&p).is_file() {
105
+ return Some(PathBuf::from(p));
106
+ }
107
+ }
108
+ }
109
+ None
110
+ }
111
+
112
+ #[test]
113
+ fn resolve_id_cli_matches_library() {
114
+ let dir = tempdir().unwrap();
115
+ let root = dir.path();
116
+ fs::write(root.join("X.web.tish"), "").unwrap();
117
+ fs::write(root.join("X.tish"), "").unwrap();
118
+ let importer = root.join("App.tish");
119
+ fs::write(&importer, "").unwrap();
120
+
121
+ let lib = resolve_with_platform(
122
+ "./X",
123
+ root,
124
+ ResolveContext {
125
+ platform: Platform::Web,
126
+ surface: Surface::Web,
127
+ },
128
+ )
129
+ .unwrap();
130
+
131
+ let tish = find_tish_binary().unwrap_or_else(|| {
132
+ panic!(
133
+ "tish binary with resolve-id required (set TISH_PATH or cargo build -p tishlang --bin tish). \
134
+ Soft-skip is forbidden — Vite and tish build must share resolve rules."
135
+ )
136
+ });
137
+ let out = Command::new(&tish)
138
+ .args([
139
+ "resolve-id",
140
+ "./X",
141
+ "--importer",
142
+ importer.to_str().unwrap(),
143
+ "--platform",
144
+ "web",
145
+ "--surface",
146
+ "web",
147
+ ])
148
+ .output()
149
+ .unwrap_or_else(|e| panic!("failed to run {}: {e}", tish.display()));
150
+
151
+ assert!(
152
+ out.status.success(),
153
+ "tish resolve-id failed ({}):\nstdout: {}\nstderr: {}",
154
+ tish.display(),
155
+ String::from_utf8_lossy(&out.stdout),
156
+ String::from_utf8_lossy(&out.stderr)
157
+ );
158
+ let cli_path = String::from_utf8_lossy(&out.stdout).trim().to_string();
159
+ assert_eq!(
160
+ std::fs::canonicalize(&cli_path).unwrap(),
161
+ lib,
162
+ "Vite/CLI resolve-id must match library cascade"
163
+ );
164
+ }
@@ -0,0 +1,40 @@
1
+ //! tishlang/tish#556 — a top-level MUTABLE var captured by a closure becomes a `VmRef` cell; if its
2
+ //! name matches an imported/other function's PARAMETER, a reference to the parameter inside that
3
+ //! function must resolve to the parameter, NOT a `vm_read` of the outer cell (which emits
4
+ //! `vm_read(&param)` — a `&Value` where a `&VmRef` is wanted, so the generated Rust won't compile).
5
+ //! The bug bit the GBA backend; guard both emit modes.
6
+ use std::collections::HashSet;
7
+ use std::path::PathBuf;
8
+ use tishlang_compile::{compile_project_full_emit, NativeEmitMode};
9
+
10
+ fn compile(rel: &str, mode: NativeEmitMode) -> String {
11
+ let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
12
+ let path = manifest.join("../..").join(rel).canonicalize().unwrap();
13
+ let caps: HashSet<String> = HashSet::new();
14
+ compile_project_full_emit(&path, path.parent(), &[], true, mode, Some(&caps))
15
+ .unwrap()
16
+ .0
17
+ }
18
+
19
+ #[test]
20
+ fn param_shadows_outer_cell_not_read_via_vm_read() {
21
+ let rel = "crates/tish_compile/tests/regression/name_collision_main.tish";
22
+ for mode in [NativeEmitMode::DesktopBin, NativeEmitMode::Gba] {
23
+ let rust = compile(rel, mode);
24
+ // The bug: inside `loadScene`, calling the `scene` PARAMETER (`scene(1)`) compiled the callee
25
+ // as `vm_read(&scene)` — a read of the outer `VmRef` cell, i.e. `&Value` where a `&VmRef` is
26
+ // wanted (won't compile). The param must be used directly instead. (A plain `vm_read(&scene)`
27
+ // still legitimately appears where `bump` mutates the real outer cell, so we target this
28
+ // exact param-as-callee shape.)
29
+ assert!(
30
+ !rust.contains("_callee = (tishlang_runtime::vm_read(&scene))"),
31
+ "#556 ({mode:?}): `loadScene`'s `scene` parameter must shadow the outer cell — a call to \
32
+ it must not read `vm_read(&scene)`\n{rust}"
33
+ );
34
+ // The outer cell itself is still legitimately a real `VmRef` where `bump` mutates it.
35
+ assert!(
36
+ rust.contains("scene.borrow_mut()"),
37
+ "#556 ({mode:?}): expected the outer captured `scene` to remain a real `VmRef` cell\n{rust}"
38
+ );
39
+ }
40
+ }
@@ -0,0 +1,65 @@
1
+ //! tishlang/tish#558 — a typed `Vec<struct>` element field write (`arr[i].field = v`) must lower to
2
+ //! a NATIVE store into the Vec element, not a `set_prop` on a throwaway `Value::object_from_pairs`
3
+ //! copy of the element (which silently discards the write). Regressed the module-level array and the
4
+ //! local array; both are covered here.
5
+ use std::collections::HashSet;
6
+ use std::path::PathBuf;
7
+ use tishlang_compile::{compile_project_full, compile_project_full_emit, NativeEmitMode};
8
+
9
+ fn compile(rel: &str) -> String {
10
+ let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
11
+ let path = manifest.join("../..").join(rel).canonicalize().unwrap();
12
+ compile_project_full(&path, path.parent(), &[], true).unwrap().0
13
+ }
14
+
15
+ fn compile_gba(rel: &str) -> String {
16
+ let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
17
+ let path = manifest.join("../..").join(rel).canonicalize().unwrap();
18
+ let caps: HashSet<String> = HashSet::new();
19
+ compile_project_full_emit(&path, path.parent(), &[], true, NativeEmitMode::Gba, Some(&caps))
20
+ .unwrap()
21
+ .0
22
+ }
23
+
24
+ #[test]
25
+ fn gba_struct_write_is_a_fully_native_i32_store() {
26
+ // On the GBA target a `: i32` struct field is a native `i32`, so the write is a single-word store
27
+ // into the Vec element — the whole point of typed struct arrays for hot AI loops. Guard that the
28
+ // perf win is maximal there: no `Value`, no `set_prop`, no per-write allocation.
29
+ let rust = compile_gba("crates/tish_compile/tests/regression/struct_array_write.tish");
30
+ assert!(
31
+ rust.contains("pub x: i32"),
32
+ "expected the `: i32` field to be a native i32 on the GBA target\n{rust}"
33
+ );
34
+ assert!(
35
+ rust.contains("arr[(0_f64) as usize].x = (200_f64) as i32"),
36
+ "#558: expected a fully-native i32 element store on GBA\n{rust}"
37
+ );
38
+ assert!(
39
+ !rust.contains("set_prop(&(Value::object_from_pairs"),
40
+ "#558: the GBA struct write must not box + set_prop on a throwaway copy\n{rust}"
41
+ );
42
+ }
43
+
44
+ #[test]
45
+ fn struct_array_write_is_a_native_store_not_a_discarded_copy() {
46
+ let rust = compile("crates/tish_compile/tests/regression/struct_array_write.tish");
47
+
48
+ // The write must NOT build a throwaway object and set_prop on it — that mutates a copy and
49
+ // discards the store. This exact shape is what the bug emitted.
50
+ assert!(
51
+ !rust.contains("set_prop(&(Value::object_from_pairs"),
52
+ "#558: `arr[i].field = v` must not set_prop on a throwaway object_from_pairs copy\n{rust}"
53
+ );
54
+
55
+ // It must instead be a direct field store into the Vec element (module-level `arr` here).
56
+ assert!(
57
+ rust.contains("arr[(0_f64) as usize].x ="),
58
+ "#558: expected a native `arr[(0_f64) as usize].x = …` store into the Vec element\n{rust}"
59
+ );
60
+ // …and the local array `es` inside the function.
61
+ assert!(
62
+ rust.contains("es[(0_f64) as usize].x ="),
63
+ "#558: expected a native `es[(0_f64) as usize].x = …` store into the local Vec element\n{rust}"
64
+ );
65
+ }
@@ -0,0 +1,21 @@
1
+ //! tishlang/tish#558 residual — a struct-field read from a Vec element in a value/return position must
2
+ //! clone a non-Copy field instead of moving it out of the borrowed element (E0507).
3
+ use std::path::PathBuf;
4
+ use tishlang_compile::compile_project_full;
5
+
6
+ fn compile(rel: &str) -> String {
7
+ let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
8
+ let path = manifest.join("../..").join(rel).canonicalize().unwrap();
9
+ compile_project_full(&path, path.parent(), &[], true).unwrap().0
10
+ }
11
+
12
+ #[test]
13
+ fn struct_field_read_from_vec_element_clones_non_copy() {
14
+ let rust = compile("crates/tish_compile/tests/regression/struct_field_return_read.tish");
15
+ // Off-GBA the `: i32` field is a boxed `Value` (non-Copy); the read must clone it out of the
16
+ // borrowed `es[0]`, not move it.
17
+ assert!(
18
+ rust.contains("es[(0_f64) as usize]).x.clone()"),
19
+ "#558 residual: `es[0].x` read must clone the non-Copy field out of the borrowed element\n{rust}"
20
+ );
21
+ }