@tishlang/tish-format 2.43.8 → 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 (35) hide show
  1. package/bin/tish-format +0 -0
  2. package/crates/tish/tests/fixtures/cargo_example_project/crates/demo-shim/src/lib.rs +21 -0
  3. package/crates/tish/tests/fixtures/cargo_example_project/crates/demo-shim/tish.d.tish +4 -0
  4. package/crates/tish/tests/fixtures/cargo_example_project/src/main.tish +5 -1
  5. package/crates/tish/tests/regr_typed_externs.rs +59 -0
  6. package/crates/tish_builtins/src/globals.rs +27 -0
  7. package/crates/tish_compile/src/codegen.rs +1285 -116
  8. package/crates/tish_compile/src/resolve.rs +53 -7
  9. package/crates/tish_compile/src/schemes.rs +60 -5
  10. package/crates/tish_compile/src/types.rs +64 -32
  11. package/crates/tish_compile/tests/regr_556_param_name_collision.rs +40 -0
  12. package/crates/tish_compile/tests/regr_558_struct_array_write.rs +65 -0
  13. package/crates/tish_compile/tests/regr_558b_struct_field_read.rs +21 -0
  14. package/crates/tish_compile/tests/regr_558c_wrapped_struct_array.rs +64 -0
  15. package/crates/tish_compile/tests/regr_558d_native_vec_mutators.rs +156 -0
  16. package/crates/tish_compile/tests/regr_562_wrapped_struct_array_push.rs +50 -0
  17. package/crates/tish_compile/tests/regr_fixed_native_coercions.rs +69 -0
  18. package/crates/tish_compile/tests/regr_gba_struct_reflection.rs +106 -0
  19. package/crates/tish_compile/tests/regression/name_collision_lib.tish +6 -0
  20. package/crates/tish_compile/tests/regression/name_collision_main.tish +13 -0
  21. package/crates/tish_compile/tests/regression/struct_array_write.tish +22 -0
  22. package/crates/tish_compile/tests/regression/struct_field_return_read.tish +10 -0
  23. package/crates/tish_core/src/console_style.rs +4 -0
  24. package/crates/tish_core/src/json.rs +8 -0
  25. package/crates/tish_core/src/value.rs +123 -0
  26. package/crates/tish_fmt/Cargo.toml +1 -1
  27. package/crates/tish_runtime/src/lib.rs +1 -0
  28. package/crates/tish_runtime_gba/src/lib.rs +188 -4
  29. package/crates/tish_vm/src/vm.rs +48 -0
  30. package/package.json +1 -1
  31. package/platform/darwin-arm64/tish-fmt +0 -0
  32. package/platform/darwin-x64/tish-fmt +0 -0
  33. package/platform/linux-arm64/tish-fmt +0 -0
  34. package/platform/linux-x64/tish-fmt +0 -0
  35. package/platform/win32-x64/tish-fmt.exe +0 -0
@@ -8,8 +8,8 @@ use std::path::Path;
8
8
  use std::sync::Arc;
9
9
  use tishlang_ast::{
10
10
  ArrayElement, ArrowBody, BinOp, CallArg, CompoundOp, DestructElement, DestructPattern, Expr,
11
- FunParam, Literal, LogicalAssignOp, MemberProp, ObjectProp, Program, Span, Statement,
12
- TypeAnnotation, UnaryOp,
11
+ FunParam, ImportSpecifier, Literal, LogicalAssignOp, MemberProp, ObjectProp, Program, Span,
12
+ Statement, TypeAnnotation, UnaryOp,
13
13
  };
14
14
 
15
15
  /// Tracks variable usage for move/clone optimization.
@@ -603,7 +603,7 @@ pub fn compile_project_full_emit(
603
603
  message: e,
604
604
  span: None,
605
605
  })?;
606
- let merged = resolve::merge_modules(modules).map_err(|e| CompileError {
606
+ let mut merged = resolve::merge_modules(modules).map_err(|e| CompileError {
607
607
  message: e,
608
608
  span: None,
609
609
  })?;
@@ -618,6 +618,24 @@ pub fn compile_project_full_emit(
618
618
  span: None,
619
619
  })?;
620
620
  }
621
+ // Typed externs: a native module may ship a `tish.d.tish` beside its crate declaring the typed
622
+ // signatures of its exports (`declare fn set_body(e: i32, …): void`). Inject those ambient
623
+ // declarations so codegen can emit direct `crate::name_typed(..)` calls — the game never writes
624
+ // them. Only `declare fn` statements are taken; anything else in the file is ignored. Missing or
625
+ // unparseable files are silently skipped (the module simply keeps the boxed call path).
626
+ for m in &native_modules {
627
+ let Some(dir) = &m.source_dir else { continue };
628
+ let dts = dir.join("tish.d.tish");
629
+ if let Ok(src) = std::fs::read_to_string(&dts) {
630
+ if let Ok(decls) = tishlang_parser::parse(&src) {
631
+ for st in decls.statements {
632
+ if matches!(st, Statement::DeclareFun { .. }) {
633
+ merged.program.statements.push(st);
634
+ }
635
+ }
636
+ }
637
+ }
638
+ }
621
639
  let mut native_build =
622
640
  resolve::compute_native_build_artifacts(&merged.program, root, &native_modules).map_err(
623
641
  |e| CompileError {
@@ -949,6 +967,22 @@ pub(crate) struct NativeVecFnSig {
949
967
  ret: VecRetKind,
950
968
  }
951
969
 
970
+ /// A TYPED native import — the "typed externs" perf lever. Formed by pairing a `declare fun name(a:
971
+ /// i32, …): T` signature with a `cargo:`/native import of the same name (which supplies the crate).
972
+ /// A call `name(x, y)` whose args lower to `params` is then emitted as a DIRECT Rust call
973
+ /// `<crate>::name_typed(x, y)` — no `Value` boxing of the args, no `value_call` dispatch — instead of
974
+ /// the boxed namespace-object path. The crate provides `name_typed(<native params>)` and keeps its
975
+ /// boxed `fn(&[Value])` shim (which now just delegates) for dynamic call sites.
976
+ #[derive(Debug, Clone)]
977
+ pub(crate) struct ExternSig {
978
+ crate_name: String,
979
+ /// The exported Rust fn name (the import's original name, not a local alias); the direct call
980
+ /// targets `<crate_name>::<symbol>_typed(..)`.
981
+ symbol: String,
982
+ params: Vec<RustType>,
983
+ ret: RustType,
984
+ }
985
+
952
986
  /// #173 part 3 — a symbolic upper bound used by the in-bounds index proof. Two forms are matched by
953
987
  /// structural equality: an integer constant (`vec![K; 100]`, guard `i < 100`) and a single variable
954
988
  /// (`a` filled to length `n`, guard `i < n`). Anything more complex (`2 * n`, `a.length` member,
@@ -984,6 +1018,13 @@ pub(crate) struct Codegen {
984
1018
  features: std::collections::HashSet<String>,
985
1019
  /// spec -> native init strategy (legacy adapter object vs generated `generated_native` wrapper)
986
1020
  native_module_init: std::collections::HashMap<String, crate::resolve::NativeModuleInit>,
1021
+ /// Typed native imports (`declare fun` + a native import): tish name -> its typed signature.
1022
+ /// A matching call emits a DIRECT `<crate>::name_typed(..)` instead of a boxed `value_call`.
1023
+ extern_fns: std::collections::HashMap<String, ExternSig>,
1024
+ /// User tish functions whose declared return type is a native STRUCT (`function f(): Iface`).
1025
+ /// A call in a typed context coerces the boxed result to the native struct (a `Value::Struct`
1026
+ /// downcast on GBA), so `f().field` reads are native instead of `get_prop`. Name -> `RustType::Named`.
1027
+ user_fn_returns: std::collections::HashMap<String, crate::types::RustType>,
987
1028
  /// Stack: true = async Rust context (run body), false = sync closure (Tish fn body)
988
1029
  async_context_stack: Vec<bool>,
989
1030
  loop_stack: Vec<(String, Option<String>)>, // (break_label, continue_update) for innermost loop
@@ -1220,7 +1261,7 @@ pub(crate) struct Codegen {
1220
1261
  cse_temp_counter: usize,
1221
1262
  /// The program's used NON-ASCII characters (collected from every string literal), as a sorted
1222
1263
  /// string. Fed to import schemes via `{charset}` so a scheme can bake only the glyphs a program
1223
- /// actually uses — e.g. `font<N>:` bakes ASCII + these, not all ~24k glyphs of a CJK font.
1264
+ /// actually uses — e.g. `font:` bakes ASCII + these, not all ~24k glyphs of a CJK font.
1224
1265
  string_charset: String,
1225
1266
  }
1226
1267
 
@@ -1251,6 +1292,8 @@ impl Codegen {
1251
1292
  string_charset: String::new(),
1252
1293
  features,
1253
1294
  native_module_init,
1295
+ extern_fns: std::collections::HashMap::new(),
1296
+ user_fn_returns: std::collections::HashMap::new(),
1254
1297
  async_context_stack: Vec::new(),
1255
1298
  loop_stack: Vec::new(),
1256
1299
  break_stack: Vec::new(),
@@ -1523,7 +1566,7 @@ impl Codegen {
1523
1566
  /// only way a tagless image's sprites escape). Registration into the runtime arena happens in
1524
1567
  /// the entry below.
1525
1568
  /// Collect every NON-ASCII character appearing in the program's string literals, sorted+unique.
1526
- /// Fed to schemes via `{charset}` (see `render_template`) so a `font<N>:` import can bake only the
1569
+ /// Fed to schemes via `{charset}` (see `render_template`) so a `font:` import can bake only the
1527
1570
  /// glyphs actually used. ASCII is always baked by the scheme, so it's excluded here. Runtime-built
1528
1571
  /// strings (concatenation, numbers) resolve to ASCII, which is covered; a non-ASCII char reachable
1529
1572
  /// only through a computed string is the rare gap (documented — add it to a literal if needed).
@@ -1634,6 +1677,11 @@ impl Codegen {
1634
1677
  ));
1635
1678
  }
1636
1679
  self.write("}\n\n");
1680
+ // A `TishStruct` impl so this struct can be boxed BY REFERENCE into a `Value`
1681
+ // (`Value::from_struct` → `Value::Struct` on GBA) instead of serialised to a hashmap
1682
+ // object — property access on the boxed value then dispatches here (a small match,
1683
+ // no hash / alloc). See `types::to_value_expr` `Named` arm.
1684
+ self.emit_struct_tish_impl(&struct_name, fields);
1637
1685
  // #315: emit a direct JSON serializer for this struct when every field is
1638
1686
  // JSON-simple. Emitted for each eligible alias (unused ones are covered by
1639
1687
  // the generated crate's `#![allow(unused)]`); the JSON.stringify intercept
@@ -1657,6 +1705,7 @@ impl Codegen {
1657
1705
  ));
1658
1706
  }
1659
1707
  self.write("}\n\n");
1708
+ self.emit_struct_tish_impl(&vstruct, vfields);
1660
1709
  }
1661
1710
  let enum_ident = crate::types::shape_union_enum_ident(uname);
1662
1711
  self.write("#[derive(Clone, Debug)]\n");
@@ -1683,6 +1732,68 @@ impl Codegen {
1683
1732
  }
1684
1733
  }
1685
1734
 
1735
+ /// Emit `impl tishlang_runtime::TishStruct for TishStruct_<name>` so a typed struct can be boxed
1736
+ /// BY REFERENCE into a `Value` (`Value::from_struct`) rather than eagerly serialised to a hashmap
1737
+ /// object. `tish_get`/`tish_set` are the dynamic property path (a `match` on the field name — no
1738
+ /// hash, no alloc); `tish_to_object` is the materialisation used for JSON / `Object.keys`-family
1739
+ /// and as the non-portable `from_struct` fallback (identical to the historical boundary).
1740
+ fn emit_struct_tish_impl(
1741
+ &mut self,
1742
+ struct_name: &str,
1743
+ fields: &[(std::sync::Arc<str>, crate::types::RustType)],
1744
+ ) {
1745
+ self.write("#[allow(non_snake_case, unused)]\n");
1746
+ self.write(&format!(
1747
+ "impl tishlang_runtime::TishStruct for {} {{\n",
1748
+ struct_name
1749
+ ));
1750
+
1751
+ // tish_get: field name → boxed Value (Null for an unknown key).
1752
+ self.write(" fn tish_get(&self, __k: &str) -> tishlang_runtime::Value {\n");
1753
+ self.write(" match __k {\n");
1754
+ for (k, ty) in fields {
1755
+ let access = format!("self.{}", crate::types::field_ident(k));
1756
+ let v_expr = if matches!(ty, crate::types::RustType::Value) {
1757
+ format!("{}.clone()", access)
1758
+ } else {
1759
+ ty.to_value_expr(&access)
1760
+ };
1761
+ self.write(&format!(" {:?} => {},\n", k.as_ref(), v_expr));
1762
+ }
1763
+ self.write(" _ => tishlang_runtime::Value::Null,\n");
1764
+ self.write(" }\n }\n");
1765
+
1766
+ // tish_set: field name + boxed Value → coerce into the native field (unknown key = no-op).
1767
+ self.write(" fn tish_set(&mut self, __k: &str, __v: tishlang_runtime::Value) {\n");
1768
+ self.write(" match __k {\n");
1769
+ for (k, ty) in fields {
1770
+ let coerced = if matches!(ty, crate::types::RustType::Value) {
1771
+ "__v".to_string()
1772
+ } else {
1773
+ ty.from_value_expr("__v")
1774
+ };
1775
+ self.write(&format!(
1776
+ " {:?} => {{ self.{} = {}; }},\n",
1777
+ k.as_ref(),
1778
+ crate::types::field_ident(k),
1779
+ coerced
1780
+ ));
1781
+ }
1782
+ self.write(" _ => {},\n");
1783
+ self.write(" }\n }\n");
1784
+
1785
+ // tish_to_object: ordered `object_from_pairs` (JS insertion order) — the materialisation path.
1786
+ self.write(" fn tish_to_object(&self) -> tishlang_runtime::Value {\n");
1787
+ self.write(&format!(
1788
+ " {}\n",
1789
+ crate::types::named_struct_to_object_expr(fields, "self")
1790
+ ));
1791
+ self.write(" }\n");
1792
+
1793
+ self.write(" fn as_any(&self) -> &dyn core::any::Any { self }\n");
1794
+ self.write("}\n\n");
1795
+ }
1796
+
1686
1797
  /// #315: JSON-simple = serializable by a direct per-struct writer that is byte-identical
1687
1798
  /// to node's `JSON.stringify` (reuses `write_json_number` / `escape_into`). Whitelist, so
1688
1799
  /// any future RustType variant defaults to the sound Value-path fallback.
@@ -1794,6 +1905,100 @@ impl Codegen {
1794
1905
 
1795
1906
  /// #315: direct JSON serializer for `JSON.stringify(arg)` when `arg`'s static type is a
1796
1907
  /// simple struct (or Vec of one). Returns None → fall through to the generic Value path.
1908
+ /// A boxed `Value` of a struct field: a `Value`-typed field is cloned out of the borrow; a native
1909
+ /// field boxes through its own `to_value_expr` (which copies/clones internally). `place` is the
1910
+ /// borrowed struct expression (e.g. `_s`), so the access is `place.field`.
1911
+ fn struct_field_to_value(place: &str, k: &std::sync::Arc<str>, fty: &crate::types::RustType) -> String {
1912
+ let access = format!("{}.{}", place, crate::types::field_ident(k));
1913
+ if matches!(fty, crate::types::RustType::Value) {
1914
+ format!("{}.clone()", access)
1915
+ } else {
1916
+ fty.to_value_expr(&access)
1917
+ }
1918
+ }
1919
+
1920
+ /// Static-type-aware reflection: `Object.keys(s)` / `Object.values(s)` / `Object.entries(s)` where
1921
+ /// `s`'s static type is a known struct (`RustType::Named`). Reads the struct's fields NATIVELY, so
1922
+ /// the struct is never boxed to a `Value::Struct` (GBA) / hashmap object (elsewhere) and no
1923
+ /// intermediate object is built — only the RESULT array is a `Value` (that is the op's output, and
1924
+ /// unavoidable). Keys come from the compile-time field list; values are native field reads. Returns
1925
+ /// `None` (→ the generic boxed `Object.*` path) for a non-struct arg, a spread/multi arg, or a
1926
+ /// dynamic key. This is target-agnostic: the `TishStruct_*` Rust struct exists on every native
1927
+ /// backend, so the same native reads apply on GBA and desktop alike.
1928
+ fn try_emit_native_object_reflection(
1929
+ &mut self,
1930
+ callee: &Expr,
1931
+ args: &[CallArg],
1932
+ ) -> Result<Option<String>, CompileError> {
1933
+ use crate::types::RustType;
1934
+ // GBA only. Desktop keeps reflection on the existing boxed path — it's already correct there
1935
+ // (a struct materialises to a real `Value::Object`), and gating avoids any churn to desktop
1936
+ // benches / codegen-string tests. GBA is where avoiding the `Value::Struct` box + hashmap
1937
+ // actually matters. The native reads are target-agnostic, so this path was validated by
1938
+ // running it on desktop before the gate went in.
1939
+ if self.emit_mode != crate::NativeEmitMode::Gba {
1940
+ return Ok(None);
1941
+ }
1942
+ let Expr::Member {
1943
+ object,
1944
+ prop: MemberProp::Name { name: m, .. },
1945
+ optional: false,
1946
+ ..
1947
+ } = callee
1948
+ else {
1949
+ return Ok(None);
1950
+ };
1951
+ if !matches!(object.as_ref(), Expr::Ident { name, .. } if name.as_ref() == "Object") {
1952
+ return Ok(None);
1953
+ }
1954
+ let kind = m.as_ref();
1955
+ if !matches!(kind, "keys" | "values" | "entries") || args.len() != 1 {
1956
+ return Ok(None);
1957
+ }
1958
+ let CallArg::Expr(arg0) = &args[0] else {
1959
+ return Ok(None);
1960
+ };
1961
+ let (code, ty) = self.emit_typed_expr(arg0)?;
1962
+ let RustType::Named { fields, .. } = &ty else {
1963
+ return Ok(None);
1964
+ };
1965
+ let out = match kind {
1966
+ // Keys are the compile-time field names; the struct is evaluated only for side effects.
1967
+ "keys" => {
1968
+ let keys = fields
1969
+ .iter()
1970
+ .map(|(k, _)| format!("Value::String({:?}.into())", k.as_ref()))
1971
+ .collect::<Vec<_>>()
1972
+ .join(", ");
1973
+ format!("{{ let _ = &({}); Value::Array(VmRef::new(vec![{}])) }}", code, keys)
1974
+ }
1975
+ "values" => {
1976
+ let vals = fields
1977
+ .iter()
1978
+ .map(|(k, fty)| Self::struct_field_to_value("_s", k, fty))
1979
+ .collect::<Vec<_>>()
1980
+ .join(", ");
1981
+ format!("{{ let _s = &({}); Value::Array(VmRef::new(vec![{}])) }}", code, vals)
1982
+ }
1983
+ "entries" => {
1984
+ let pairs = fields
1985
+ .iter()
1986
+ .map(|(k, fty)| {
1987
+ format!(
1988
+ "Value::Array(VmRef::new(vec![Value::String({:?}.into()), {}]))",
1989
+ k.as_ref(),
1990
+ Self::struct_field_to_value("_s", k, fty)
1991
+ )
1992
+ })
1993
+ .collect::<Vec<_>>()
1994
+ .join(", ");
1995
+ format!("{{ let _s = &({}); Value::Array(VmRef::new(vec![{}])) }}", code, pairs)
1996
+ }
1997
+ _ => unreachable!("kind checked above"),
1998
+ };
1999
+ Ok(Some(out))
2000
+ }
2001
+
1797
2002
  fn try_emit_direct_json_stringify(
1798
2003
  &mut self,
1799
2004
  arg: &Expr,
@@ -2502,8 +2707,117 @@ impl Codegen {
2502
2707
  out
2503
2708
  }
2504
2709
 
2710
+ /// Typed externs (#perf): pair each top-level `declare fun name(a: i32, …): T` with a native
2711
+ /// import of the same name (which supplies the crate) → an [`ExternSig`] in `extern_fns`. A later
2712
+ /// call to `name` with lowerable args emits a DIRECT `<crate>::name_typed(..)` instead of a boxed
2713
+ /// `value_call`. Only fully-typed simple-param signatures qualify; anything else stays boxed.
2714
+ fn collect_extern_fns(&mut self, program: &Program) {
2715
+ use std::collections::HashMap;
2716
+ // Resolve lowers each `import { x } from 'cargo:…'` to `let x = NativeModuleLoad{spec, export}`,
2717
+ // so pair off THOSE bindings (the `Import` statement itself is gone by codegen): local name ->
2718
+ // (module spec, exported Rust fn name). Only a native module with a known init can back a call.
2719
+ let mut name_info: HashMap<String, (String, String)> = HashMap::new();
2720
+ for stmt in &program.statements {
2721
+ if let Statement::VarDecl {
2722
+ name,
2723
+ init: Some(Expr::NativeModuleLoad { spec, export_name, .. }),
2724
+ ..
2725
+ } = stmt
2726
+ {
2727
+ if self.native_module_init.contains_key(spec.as_ref()) {
2728
+ name_info.insert(
2729
+ name.to_string(),
2730
+ (spec.to_string(), export_name.to_string()),
2731
+ );
2732
+ }
2733
+ }
2734
+ }
2735
+ if name_info.is_empty() {
2736
+ return;
2737
+ }
2738
+ for stmt in &program.statements {
2739
+ let Statement::DeclareFun {
2740
+ name,
2741
+ params,
2742
+ rest_param: None,
2743
+ return_type,
2744
+ async_: false,
2745
+ ..
2746
+ } = stmt
2747
+ else {
2748
+ continue;
2749
+ };
2750
+ let Some((spec, export_name)) = name_info.get(name.as_ref()) else {
2751
+ continue;
2752
+ };
2753
+ let crate_name = match self.native_module_init.get(spec) {
2754
+ Some(init) => init.crate_name().to_string(),
2755
+ None => continue,
2756
+ };
2757
+ // Every parameter must be a simple, explicitly-typed native scalar; a `Value` param (an
2758
+ // untyped one) means the direct call would gain nothing, so keep the whole fn boxed.
2759
+ let mut ptys = Vec::with_capacity(params.len());
2760
+ let mut ok = true;
2761
+ for p in params {
2762
+ match p {
2763
+ FunParam::Simple(tp) if tp.default.is_none() => match &tp.type_ann {
2764
+ Some(ann) => {
2765
+ let ty = RustType::from_annotation(ann);
2766
+ if ty.is_native() {
2767
+ ptys.push(ty);
2768
+ } else {
2769
+ ok = false;
2770
+ break;
2771
+ }
2772
+ }
2773
+ None => {
2774
+ ok = false;
2775
+ break;
2776
+ }
2777
+ },
2778
+ _ => {
2779
+ ok = false;
2780
+ break;
2781
+ }
2782
+ }
2783
+ }
2784
+ if !ok {
2785
+ continue;
2786
+ }
2787
+ let ret = return_type
2788
+ .as_ref()
2789
+ .map(RustType::from_annotation)
2790
+ .unwrap_or(RustType::Unit);
2791
+ self.extern_fns.insert(
2792
+ name.to_string(),
2793
+ ExternSig { crate_name, symbol: export_name.clone(), params: ptys, ret },
2794
+ );
2795
+ }
2796
+ }
2797
+
2798
+ /// Record every top-level `function f(...): Iface` whose return annotation resolves — via the type
2799
+ /// aliases — to a native STRUCT (`RustType::Named`, or a `ShapeUnion`). A later call to `f` in a
2800
+ /// typed position then coerces the boxed result to the native struct (a `Value::Struct` downcast on
2801
+ /// GBA, the `get_prop` rebuild elsewhere), so `f().field` reads are native, not boxed. Runs AFTER
2802
+ /// `collect_type_aliases` so an interface name in the annotation resolves to its `Named` type.
2803
+ fn collect_user_fn_returns(&mut self, program: &Program) {
2804
+ for stmt in &program.statements {
2805
+ if let Statement::FunDecl { name, return_type: Some(ann), .. } = stmt {
2806
+ let ty = RustType::from_annotation_with_aliases(ann, &self.type_aliases);
2807
+ // Only a concrete `Named` struct return is coerced. `ShapeUnion` is deliberately
2808
+ // excluded: its `from_value_expr` is a forbidden boundary (`unreachable!()`), so
2809
+ // admitting it here would compile a runtime panic into the coercion site. (A shape
2810
+ // union only ever arises as an array element type, never a bare fn return, so this
2811
+ // is a defensive guard against a violated invariant, not a live path.)
2812
+ if matches!(ty, RustType::Named { .. }) {
2813
+ self.user_fn_returns.insert(name.to_string(), ty);
2814
+ }
2815
+ }
2816
+ }
2817
+ }
2818
+
2505
2819
  fn emit_program(&mut self, program: &Program) -> Result<(), CompileError> {
2506
- // Only GBA `font<N>:`/`emoji:` schemes consume `{charset}` (and both `render_template`
2820
+ // Only GBA `font:`/`emoji:` schemes consume `{charset}` (and both `render_template`
2507
2821
  // callers that read `string_charset` are Gba-gated), so skip the whole-program string walk
2508
2822
  // for every other target — its result is never read off-GBA.
2509
2823
  if self.emit_mode == crate::NativeEmitMode::Gba {
@@ -2513,6 +2827,7 @@ impl Codegen {
2513
2827
  self.program_has_jsx = tishlang_ui::jsx::program_contains_jsx(program);
2514
2828
  self.program_fun_decl_names = tishlang_ui::jsx::collect_fun_decl_names(program);
2515
2829
  self.program_uses_document = crate::resolve::program_uses_document(program);
2830
+ self.collect_extern_fns(program);
2516
2831
  if self.emit_mode == crate::NativeEmitMode::Gba {
2517
2832
  // no_std GBA ROM: alloc-based, Arc aliases to Rc in the facade. The
2518
2833
  // prelude `use tishlang_runtime::{…}` below is unchanged — those names
@@ -2584,6 +2899,9 @@ impl Codegen {
2584
2899
  // are stored too, so later annotations like `let x: N = 0` still
2585
2900
  // pick up the right native type.
2586
2901
  self.collect_type_aliases(&program.statements);
2902
+ // Now that the interface/type-alias table is built, record user functions that return a native
2903
+ // struct, so a typed call to one keeps the result native (`f().field` = a Rust field read).
2904
+ self.collect_user_fn_returns(program);
2587
2905
  // Emit a Rust `struct` for every alias whose RHS is an object
2588
2906
  // shape. Subsequent `let x: Foo = ...` literals lower to plain
2589
2907
  // struct moves (no `VmRef::new(ObjectMap::from(..))` allocation),
@@ -3262,6 +3580,15 @@ impl Codegen {
3262
3580
  return Ok(code);
3263
3581
  }
3264
3582
  }
3583
+ // #558: `arr[i].field = v` / `s.field = v` on a typed struct → a native element store, not a
3584
+ // discarded set_prop on a copy. Side-effect position (statement), so no value is produced.
3585
+ if let Expr::MemberAssign { object, prop, value, .. } = expr {
3586
+ if let Some(code) =
3587
+ self.try_emit_native_struct_field_assign(object, prop.as_ref(), value, true)?
3588
+ {
3589
+ return Ok(code);
3590
+ }
3591
+ }
3265
3592
  match expr {
3266
3593
  Expr::Assign { name, value, .. } => {
3267
3594
  if self.native_numeric_globals.contains_key(name.as_ref()) {
@@ -3360,6 +3687,11 @@ impl Codegen {
3360
3687
  let (val_code, val_ty) = self.emit_typed_expr(value)?;
3361
3688
  let native_val = if val_ty == RustType::Value {
3362
3689
  rust_type.from_value_expr(&val_code)
3690
+ } else if rust_type == RustType::Fixed && val_ty == RustType::F64 {
3691
+ // an f64 (an int/float literal like `nx = 0`, or a promoted int) assigned to a
3692
+ // `fixed` local must be lifted to `Fixed` — the RHS emitter reports numeric
3693
+ // literals as F64, so without this a `fixed` reassignment mistypes (E0308).
3694
+ format!("tishlang_runtime::Fixed::from_raw((({}) * 256.0) as i32)", val_code)
3363
3695
  } else {
3364
3696
  val_code
3365
3697
  };
@@ -6322,7 +6654,8 @@ impl Codegen {
6322
6654
  matches!(
6323
6655
  t,
6324
6656
  RustType::F64 | RustType::Bool | RustType::String
6325
- )
6657
+ | RustType::Fixed | RustType::I32
6658
+ ) || t.is_narrow_int()
6326
6659
  })
6327
6660
  } else {
6328
6661
  None
@@ -6430,6 +6763,21 @@ impl Codegen {
6430
6763
  for v in &read_only_outer_vars {
6431
6764
  self.refcell_wrapped_vars.remove(v);
6432
6765
  }
6766
+ // tishlang/tish#556: a PARAMETER (or the rest param) SHADOWS any outer var of the
6767
+ // same name — inside the body it is a plain `let`-bound `Value`/native, never the
6768
+ // outer `VmRef` cell. Drop such names from the wrapped set for the body, so a
6769
+ // reference to the param isn't emitted as `vm_read(&param)` (which won't compile —
6770
+ // the param is a `Value`, not a `VmRef`). `saved_refcell` restores the outer view
6771
+ // after the body; a body-local `let` that a nested closure captures is re-added by
6772
+ // the `body_cell_vars` prepass below, so its cell handling is unaffected.
6773
+ for p in params {
6774
+ for bn in p.bound_names() {
6775
+ self.refcell_wrapped_vars.remove(bn.as_ref());
6776
+ }
6777
+ }
6778
+ if let Some(rest) = rest_param {
6779
+ self.refcell_wrapped_vars.remove(rest.name.as_ref());
6780
+ }
6433
6781
 
6434
6782
  // Pre-scan body for nested functions (handles function body as Block)
6435
6783
  if let Statement::Block { statements, .. } = body.as_ref() {
@@ -7153,6 +7501,12 @@ impl Codegen {
7153
7501
  }
7154
7502
  }
7155
7503
  }
7504
+ // Static-type-aware reflection: `Object.keys/values/entries(s)` on a statically-known
7505
+ // struct → native field reads, never boxing the struct or building an intermediate
7506
+ // object. Falls through to the generic boxed `Object.*` path for a non-struct arg.
7507
+ if let Some(code) = self.try_emit_native_object_reflection(callee, args)? {
7508
+ return Ok(code);
7509
+ }
7156
7510
  // #178: route a boxed-context call to a recursive-struct consumer
7157
7511
  // (`sum(build(n))` / `sum(node)`) → `Value::Number(sum_rec(&..))`.
7158
7512
  if self.rec_struct_plan.is_some() {
@@ -7184,29 +7538,273 @@ impl Codegen {
7184
7538
  // ── native Vec<T> push fast path ──────────────────────────────
7185
7539
  if method_name.as_ref() == "push" {
7186
7540
  if let Expr::Ident { name, .. } = object.as_ref() {
7187
- if !self.refcell_wrapped_vars.contains(name.as_ref()) {
7188
- let obj_type = self.type_context.get_type(name.as_ref());
7189
- if let RustType::Vec(elem_type) = obj_type {
7190
- let esc_obj = Self::escape_ident(name.as_ref()).into_owned();
7191
- // Collect push arguments as native values.
7192
- let mut push_stmts: Vec<String> = Vec::new();
7193
- for a in args {
7194
- if let CallArg::Expr(e) = a {
7195
- // Emit the element AGAINST the Vec's element type so an object
7196
- // literal hits the struct-literal fast path (direct `Struct { … }`
7197
- // with each field value emitted natively — zero allocations)
7198
- // instead of building a boxed `object_from_pairs` and then
7199
- // `get_prop`-ing every field back out. emit_native_expr falls
7200
- // back to the typed/value path for non-literal args.
7201
- let native_val = self.emit_native_expr(e, elem_type.as_ref())?;
7202
- push_stmts.push(format!("{}.push({});", esc_obj, native_val));
7203
- }
7541
+ let obj_type = self.type_context.get_type(name.as_ref());
7542
+ if let RustType::Vec(elem_type) = obj_type {
7543
+ let esc_obj = Self::escape_ident(name.as_ref()).into_owned();
7544
+ // A captured (refcell-wrapped) native Vec pushes through
7545
+ // `(*a.borrow_mut())` — WITHOUT this it fell to the boxed `array_push`,
7546
+ // which boxed the WHOLE Vec into a throwaway `Value::Array` (discarding
7547
+ // the push, O(n), and — for a `Vec<struct>` — E0282 on the `from_struct`
7548
+ // boxing closure since the native push never ran to pin the element type).
7549
+ let base = if self.refcell_wrapped_vars.contains(name.as_ref()) {
7550
+ format!("(*{}.borrow_mut())", esc_obj)
7551
+ } else {
7552
+ esc_obj
7553
+ };
7554
+ // Collect push arguments as native values.
7555
+ let mut push_stmts: Vec<String> = Vec::new();
7556
+ for a in args {
7557
+ if let CallArg::Expr(e) = a {
7558
+ // Emit the element AGAINST the Vec's element type so an object
7559
+ // literal hits the struct-literal fast path (direct `Struct { … }`
7560
+ // with each field value emitted natively — zero allocations)
7561
+ // instead of building a boxed `object_from_pairs` and then
7562
+ // `get_prop`-ing every field back out. emit_native_expr falls
7563
+ // back to the typed/value path for non-literal args.
7564
+ let native_val = self.emit_native_expr(e, elem_type.as_ref())?;
7565
+ push_stmts.push(format!("{}.push({});", base, native_val));
7204
7566
  }
7205
- return Ok(format!(
7206
- "{{ {} Value::Null }}",
7207
- push_stmts.join(" ")
7208
- ));
7209
7567
  }
7568
+ // `push` returns the new length (JS / tish interp) — a plain read, no
7569
+ // O(n) boxing. A captured Vec reads its length through `(*a.borrow())`.
7570
+ let base_read = if self.refcell_wrapped_vars.contains(name.as_ref()) {
7571
+ format!("(*{}.borrow())", Self::escape_ident(name.as_ref()))
7572
+ } else {
7573
+ Self::escape_ident(name.as_ref()).into_owned()
7574
+ };
7575
+ return Ok(format!(
7576
+ "{{ {} Value::Number({}.len() as f64) }}",
7577
+ push_stmts.join(" "),
7578
+ base_read
7579
+ ));
7580
+ }
7581
+ }
7582
+ }
7583
+
7584
+ // Native in-place mutators on a native `Vec` (`pop`/`shift`/`unshift`/`reverse`).
7585
+ // The boxed `array_*` fallback operates on a THROWAWAY boxed copy of the Vec, so the
7586
+ // mutation is DISCARDED (O(n), and — for a `Vec<struct>` — an E0282 on the boxing
7587
+ // closure); these mutate the real Vec. A captured Vec goes through `(*a.borrow_mut())`.
7588
+ // Return values match JS / tish interp: pop/shift yield the removed element (boxed) or
7589
+ // Null; unshift yields the new length; reverse yields THE array (boxed copy — a native
7590
+ // `Vec` has no O(1) by-ref boxing like interp's Rc-array, so the contents match but it
7591
+ // is not aliased; reverse is O(n) anyway so the copy is asymptotically free).
7592
+ if matches!(method_name.as_ref(), "pop" | "shift" | "unshift" | "reverse") {
7593
+ if let Expr::Ident { name, .. } = object.as_ref() {
7594
+ if let RustType::Vec(elem_type) =
7595
+ self.type_context.get_type(name.as_ref())
7596
+ {
7597
+ let esc = Self::escape_ident(name.as_ref()).into_owned();
7598
+ let wrapped_v = self.refcell_wrapped_vars.contains(name.as_ref());
7599
+ let base = if wrapped_v {
7600
+ format!("(*{}.borrow_mut())", esc)
7601
+ } else {
7602
+ esc.clone()
7603
+ };
7604
+ // Shared-borrow read (for `reverse`'s result boxing / length reads).
7605
+ let base_read = if wrapped_v {
7606
+ format!("(*{}.borrow())", esc)
7607
+ } else {
7608
+ esc.clone()
7609
+ };
7610
+ // Box a removed element back to a `Value` (`Value` elements pass through).
7611
+ let box_elem = |v: &str| -> String {
7612
+ if matches!(elem_type.as_ref(), RustType::Value) {
7613
+ v.to_string()
7614
+ } else {
7615
+ elem_type.to_value_expr(v)
7616
+ }
7617
+ };
7618
+ let code = match method_name.as_ref() {
7619
+ "pop" => format!(
7620
+ "{{ match {b}.pop() {{ Some(__e) => {v}, None => Value::Null }} }}",
7621
+ b = base,
7622
+ v = box_elem("__e")
7623
+ ),
7624
+ "shift" => format!(
7625
+ "{{ if {b}.is_empty() {{ Value::Null }} else {{ let __e = {b}.remove(0); {v} }} }}",
7626
+ b = base,
7627
+ v = box_elem("__e")
7628
+ ),
7629
+ "unshift" => {
7630
+ // Bind items to temps BEFORE any `borrow_mut` insert (an item may
7631
+ // read the same array), then insert at the front in REVERSE so the
7632
+ // argument order is preserved. Returns the new length.
7633
+ let mut binds = String::new();
7634
+ let mut names: Vec<String> = Vec::new();
7635
+ for (i, a) in args.iter().enumerate() {
7636
+ if let CallArg::Expr(e) = a {
7637
+ let it = self.emit_native_expr(e, elem_type.as_ref())?;
7638
+ binds.push_str(&format!("let __u{} = {}; ", i, it));
7639
+ names.push(format!("__u{}", i));
7640
+ }
7641
+ }
7642
+ let mut ins = String::new();
7643
+ for n in names.iter().rev() {
7644
+ ins.push_str(&format!("{}.insert(0, {}); ", base, n));
7645
+ }
7646
+ format!(
7647
+ "{{ {}{}Value::Number({}.len() as f64) }}",
7648
+ binds, ins, base
7649
+ )
7650
+ }
7651
+ // reverse in place, then return a boxed copy of the array (contents
7652
+ // match interp; see the note above).
7653
+ "reverse" => format!(
7654
+ "{{ {}.reverse(); Value::Array(VmRef::new({}.iter().cloned().map(|__e| {}).collect())) }}",
7655
+ base,
7656
+ base_read,
7657
+ box_elem("__e")
7658
+ ),
7659
+ _ => unreachable!("method checked above"),
7660
+ };
7661
+ return Ok(code);
7662
+ }
7663
+ }
7664
+ }
7665
+
7666
+ // Native `splice`/`fill`/`copyWithin` on a native `Vec`. Like the mutators above,
7667
+ // the boxed fallback would run on a THROWAWAY copy (mutation discarded). These need
7668
+ // several accesses (len + index math + a range op), so a captured Vec binds ONE
7669
+ // `borrow_mut` guard `__g` for the whole body; every argument is evaluated BEFORE the
7670
+ // guard (an arg may read the same array, which would double-borrow the cell). JS index
7671
+ // clamping (negative → from end, out-of-range → clamped) is emitted inline so the
7672
+ // result matches the interpreter.
7673
+ if matches!(method_name.as_ref(), "splice" | "fill" | "copyWithin") {
7674
+ if let Expr::Ident { name, .. } = object.as_ref() {
7675
+ if let RustType::Vec(elem_type) =
7676
+ self.type_context.get_type(name.as_ref())
7677
+ {
7678
+ let esc = Self::escape_ident(name.as_ref()).into_owned();
7679
+ let wrapped = self.refcell_wrapped_vars.contains(name.as_ref());
7680
+ // `p` is the Vec place inside the body: a `borrow_mut` guard when wrapped.
7681
+ let (guard, p) = if wrapped {
7682
+ (format!("let mut __g = {}.borrow_mut(); ", esc), "__g".to_string())
7683
+ } else {
7684
+ (String::new(), esc.clone())
7685
+ };
7686
+ let elem_rust = elem_type.to_rust_type_str();
7687
+ let box_elem = |v: &str| -> String {
7688
+ if matches!(elem_type.as_ref(), RustType::Value) {
7689
+ v.to_string()
7690
+ } else {
7691
+ elem_type.to_value_expr(v)
7692
+ }
7693
+ };
7694
+ // `fill`/`copyWithin` return THE array (JS / tish interp) — a boxed copy
7695
+ // built from the (still-borrowed) place `p` after the mutation. Contents
7696
+ // match interp; not aliased (a native `Vec` has no O(1) by-ref boxing).
7697
+ let arr_box = format!(
7698
+ "Value::Array(VmRef::new({}.iter().cloned().map(|__be| {}).collect()))",
7699
+ p,
7700
+ box_elem("__be")
7701
+ );
7702
+ // f64 arg (bound before the guard) → a clamped `usize` index.
7703
+ let clamp = |v: &str| -> String {
7704
+ format!(
7705
+ "(if {v} < 0.0 {{ (__lenf + {v}).max(0.0) }} else {{ {v}.min(__lenf) }}) as usize"
7706
+ )
7707
+ };
7708
+ let argf = |cg: &mut Self, i: usize| -> Result<Option<String>, CompileError> {
7709
+ match args.get(i) {
7710
+ Some(CallArg::Expr(e)) => {
7711
+ Ok(Some(cg.emit_native_expr(e, &RustType::F64)?))
7712
+ }
7713
+ _ => Ok(None),
7714
+ }
7715
+ };
7716
+ let code = match method_name.as_ref() {
7717
+ "splice" => {
7718
+ let start = argf(self, 0)?.unwrap_or_else(|| "0.0".into());
7719
+ let dc = argf(self, 1)?;
7720
+ // Items to insert (args[2..]) — bound before the guard.
7721
+ let mut item_binds = String::new();
7722
+ let mut item_names: Vec<String> = Vec::new();
7723
+ for (i, a) in args.iter().enumerate().skip(2) {
7724
+ if let CallArg::Expr(e) = a {
7725
+ let it = self.emit_native_expr(e, elem_type.as_ref())?;
7726
+ item_binds.push_str(&format!("let __it{} = {}; ", i, it));
7727
+ item_names.push(format!("__it{}", i));
7728
+ }
7729
+ }
7730
+ let dc_bind = dc
7731
+ .as_ref()
7732
+ .map(|d| format!("let __dc = {}; ", d))
7733
+ .unwrap_or_default();
7734
+ let del_calc = if dc.is_some() {
7735
+ "let __del = if __dc < 0.0 { 0usize } else { (__dc as usize).min(__len - __start) };"
7736
+ } else {
7737
+ "let __del = __len - __start;"
7738
+ };
7739
+ format!(
7740
+ "{{ let __s0 = {start}; {dc_bind}{item_binds}{guard}\
7741
+ let __len = {p}.len(); let __lenf = __len as f64; \
7742
+ let __start = {start_clamp}; {del_calc} \
7743
+ let __items: Vec<{elem_rust}> = vec![{items}]; \
7744
+ let __removed: Vec<{elem_rust}> = {p}.splice(__start..__start + __del, __items).collect(); \
7745
+ Value::Array(VmRef::new(__removed.into_iter().map(|__e| {boxed}).collect())) }}",
7746
+ start_clamp = clamp("__s0"),
7747
+ items = item_names.join(", "),
7748
+ boxed = box_elem("__e"),
7749
+ )
7750
+ }
7751
+ "fill" => {
7752
+ // fill(value, start=0, end=len)
7753
+ let val = match args.first() {
7754
+ Some(CallArg::Expr(e)) => {
7755
+ self.emit_native_expr(e, elem_type.as_ref())?
7756
+ }
7757
+ _ => elem_type.default_value(),
7758
+ };
7759
+ let start = argf(self, 1)?.unwrap_or_else(|| "0.0".into());
7760
+ let end = argf(self, 2)?;
7761
+ format!(
7762
+ "{{ let __v = {val}; let __s0 = {start}; {end}{guard}\
7763
+ let __len = {p}.len(); let __lenf = __len as f64; \
7764
+ let __start = {start_clamp}; {end_clamp}\
7765
+ let mut __i = __start; while __i < __end {{ {p}[__i] = __v.clone(); __i += 1; }} \
7766
+ {arr_box} }}",
7767
+ end = end
7768
+ .as_ref()
7769
+ .map(|e| format!("let __e0 = {}; ", e))
7770
+ .unwrap_or_default(),
7771
+ start_clamp = clamp("__s0"),
7772
+ end_clamp = end
7773
+ .as_ref()
7774
+ .map(|_| format!("let __end = {}; ", clamp("__e0")))
7775
+ .unwrap_or_else(|| "let __end = __len; ".into()),
7776
+ arr_box = arr_box,
7777
+ )
7778
+ }
7779
+ "copyWithin" => {
7780
+ // copyWithin(target, start=0, end=len) — overlap-safe via a temp.
7781
+ let target = argf(self, 0)?.unwrap_or_else(|| "0.0".into());
7782
+ let start = argf(self, 1)?.unwrap_or_else(|| "0.0".into());
7783
+ let end = argf(self, 2)?;
7784
+ format!(
7785
+ "{{ let __t0 = {target}; let __s0 = {start}; {end_bind}{guard}\
7786
+ let __len = {p}.len(); let __lenf = __len as f64; \
7787
+ let __target = {target_clamp}; let __start = {start_clamp}; {end_clamp}\
7788
+ let __count = if __end > __start {{ (__end - __start).min(__len - __target) }} else {{ 0 }}; \
7789
+ let __tmp: Vec<{elem_rust}> = {p}[__start..__start + __count].to_vec(); \
7790
+ let mut __k = 0usize; for __val in __tmp {{ {p}[__target + __k] = __val; __k += 1; }} \
7791
+ {arr_box} }}",
7792
+ end_bind = end
7793
+ .as_ref()
7794
+ .map(|e| format!("let __e0 = {}; ", e))
7795
+ .unwrap_or_default(),
7796
+ target_clamp = clamp("__t0"),
7797
+ start_clamp = clamp("__s0"),
7798
+ end_clamp = end
7799
+ .as_ref()
7800
+ .map(|_| format!("let __end = {}; ", clamp("__e0")))
7801
+ .unwrap_or_else(|| "let __end = __len; ".into()),
7802
+ arr_box = arr_box,
7803
+ )
7804
+ }
7805
+ _ => unreachable!("method checked above"),
7806
+ };
7807
+ return Ok(code);
7210
7808
  }
7211
7809
  }
7212
7810
  }
@@ -7219,35 +7817,122 @@ impl Codegen {
7219
7817
  // sorts via the runtime, and writes the result back into the Vec (correct).
7220
7818
  if method_name.as_ref() == "sort" {
7221
7819
  if let Expr::Ident { name, .. } = object.as_ref() {
7222
- if !self.refcell_wrapped_vars.contains(name.as_ref()) {
7223
- if let RustType::Vec(elem_type) =
7224
- self.type_context.get_type(name.as_ref())
7225
- {
7226
- if matches!(elem_type.as_ref(), RustType::Named { .. }) {
7227
- let esc = Self::escape_ident(name.as_ref()).into_owned();
7228
- if let Some(CallArg::Expr(cmp)) = args.first() {
7229
- if let Some(keys) =
7230
- Self::detect_lexicographic_key_comparator(cmp)
7231
- {
7232
- if keys.iter().all(|(p, _)| p.len() == 1) {
7233
- let chain = Self::render_struct_sort_chain(&keys);
7234
- return Ok(format!(
7235
- "{{ {esc}.sort_by(|a, b| {chain}); Value::Null }}"
7236
- ));
7237
- }
7820
+ if let RustType::Vec(elem_type) =
7821
+ self.type_context.get_type(name.as_ref())
7822
+ {
7823
+ if matches!(elem_type.as_ref(), RustType::Named { .. }) {
7824
+ let esc = Self::escape_ident(name.as_ref()).into_owned();
7825
+ // A captured (refcell-wrapped) Vec sorts through the shared cell:
7826
+ // `base_mut` for the in-place `sort_by`, `base_read` for the boxing
7827
+ // scan, `assign` for the general-comparator write-back.
7828
+ let wrapped = self.refcell_wrapped_vars.contains(name.as_ref());
7829
+ let base_mut = if wrapped {
7830
+ format!("(*{}.borrow_mut())", esc)
7831
+ } else {
7832
+ esc.clone()
7833
+ };
7834
+ let base_read = if wrapped {
7835
+ format!("(*{}.borrow())", esc)
7836
+ } else {
7837
+ esc.clone()
7838
+ };
7839
+ let assign = if wrapped {
7840
+ format!("*{}.borrow_mut()", esc)
7841
+ } else {
7842
+ esc.clone()
7843
+ };
7844
+ // `sort` returns THE array (JS / tish interp) — a boxed copy of the
7845
+ // now-sorted Vec (contents match interp; not aliased). `sort` is
7846
+ // O(n log n), so the O(n) box is asymptotically free.
7847
+ let sort_box = format!(
7848
+ "Value::Array(VmRef::new({}.iter().cloned().map(|__se| {}).collect()))",
7849
+ base_read,
7850
+ elem_type.to_value_expr("__se")
7851
+ );
7852
+ if let Some(CallArg::Expr(cmp)) = args.first() {
7853
+ if let Some(keys) =
7854
+ Self::detect_lexicographic_key_comparator(cmp)
7855
+ {
7856
+ if keys.iter().all(|(p, _)| p.len() == 1) {
7857
+ let chain = Self::render_struct_sort_chain(&keys);
7858
+ return Ok(format!(
7859
+ "{{ {base_mut}.sort_by(|a, b| {chain}); {sort_box} }}"
7860
+ ));
7238
7861
  }
7239
- // General comparator: box → sort → write back (correct).
7240
- let cmp_code = self.emit_expr(cmp)?;
7241
- let to_v = elem_type.to_value_expr("__e");
7242
- let from_v = elem_type.from_value_expr("__v");
7243
- return Ok(format!(
7244
- "{{ let __sv: Vec<Value> = {esc}.iter().map(|__e| {to_v}).collect(); \
7245
- let __sorted = tishlang_runtime::array_sort(&Value::Array(VmRef::new(__sv)), Some(&({cmp_code}))); \
7246
- if let Value::Array(__a) = __sorted {{ {esc} = __a.borrow().iter().map(|__v| {from_v}).collect(); }} \
7247
- Value::Null }}"
7248
- ));
7249
7862
  }
7863
+ // General comparator: box → sort → write back (correct).
7864
+ let cmp_code = self.emit_expr(cmp)?;
7865
+ let to_v = elem_type.to_value_expr("__e");
7866
+ let from_v = elem_type.from_value_expr("__v");
7867
+ return Ok(format!(
7868
+ "{{ let __sv: Vec<Value> = {base_read}.iter().map(|__e| {to_v}).collect(); \
7869
+ let __sorted = tishlang_runtime::array_sort(&Value::Array(VmRef::new(__sv)), Some(&({cmp_code}))); \
7870
+ if let Value::Array(__a) = __sorted {{ {assign} = __a.borrow().iter().map(|__v| {from_v}).collect(); }} \
7871
+ {sort_box} }}"
7872
+ ));
7250
7873
  }
7874
+ } else {
7875
+ // #568: a scalar / `Value` native `Vec` (`number[]`, `string[]`,
7876
+ // …) sorts IN PLACE like interp/vm/JS. Box a copy, sort it via the
7877
+ // SAME runtime fn the boxed path uses (so order is byte-identical),
7878
+ // then WRITE the ordered elements BACK into the native Vec — the
7879
+ // generic boxed dispatch below sorted a throwaway copy and dropped
7880
+ // the write (a silent no-op; only `arr = arr.sort()` worked).
7881
+ // Returns the sorted array (JS / interp). `sort` marks the receiver
7882
+ // assigned (see `collect_assigned_idents_in_expr`), so the Vec is
7883
+ // `mut` and the write-back compiles.
7884
+ let esc = Self::escape_ident(name.as_ref()).into_owned();
7885
+ let wrapped =
7886
+ self.refcell_wrapped_vars.contains(name.as_ref());
7887
+ let base_read = if wrapped {
7888
+ format!("(*{}.borrow())", esc)
7889
+ } else {
7890
+ esc.clone()
7891
+ };
7892
+ let assign = if wrapped {
7893
+ format!("*{}.borrow_mut()", esc)
7894
+ } else {
7895
+ esc.clone()
7896
+ };
7897
+ let to_v = elem_type.to_value_expr("__e");
7898
+ let from_v = elem_type.from_value_expr("__v");
7899
+ // Mirror the boxed path's comparator detection EXACTLY (numeric
7900
+ // fast path → lexicographic keys → general callback → default),
7901
+ // so the sorted order matches the generic path and interp/vm.
7902
+ let sort_call = if let Some(CallArg::Expr(cmp)) = args.first() {
7903
+ if let Some(ascending) =
7904
+ Self::detect_numeric_sort_comparator(cmp)
7905
+ {
7906
+ if ascending {
7907
+ "tishlang_runtime::array_sort_numeric_asc(&__boxed)"
7908
+ .to_string()
7909
+ } else {
7910
+ "tishlang_runtime::array_sort_numeric_desc(&__boxed)"
7911
+ .to_string()
7912
+ }
7913
+ } else if let Some(keys) =
7914
+ Self::detect_lexicographic_key_comparator(cmp)
7915
+ {
7916
+ format!(
7917
+ "tishlang_runtime::array_sort_by_keys(&__boxed, {})",
7918
+ Self::render_sort_keys(&keys)
7919
+ )
7920
+ } else {
7921
+ let cmp_code = self.emit_expr(cmp)?;
7922
+ format!(
7923
+ "tishlang_runtime::array_sort(&__boxed, Some(&({})))",
7924
+ cmp_code
7925
+ )
7926
+ }
7927
+ } else {
7928
+ "tishlang_runtime::array_sort(&__boxed, None)".to_string()
7929
+ };
7930
+ return Ok(format!(
7931
+ "{{ let __boxed = Value::Array(VmRef::new({base_read}.iter().cloned().map(|__e| {to_v}).collect())); \
7932
+ let __sorted = {sort_call}; \
7933
+ if let Value::Array(__a) = &__sorted {{ {assign} = __a.borrow().iter().map(|__v| {from_v}).collect(); }} \
7934
+ __sorted }}"
7935
+ ));
7251
7936
  }
7252
7937
  }
7253
7938
  }
@@ -7872,6 +8557,41 @@ impl Codegen {
7872
8557
  }
7873
8558
  }
7874
8559
 
8560
+ // Typed externs (#perf): a call to a native import with a declared typed signature →
8561
+ // a DIRECT `<crate>::name_typed(coerced args)` call. No `Value` boxing of the args, no
8562
+ // `value_call` dispatch through the namespace object. Falls through to the boxed path
8563
+ // when the arg count/kind doesn't match (a dynamic/spread call stays correct).
8564
+ if let Expr::Ident { name: fname, .. } = callee.as_ref() {
8565
+ if let Some(sig) = self.extern_fns.get(fname.as_ref()).cloned() {
8566
+ if args.len() == sig.params.len()
8567
+ && args.iter().all(|a| matches!(a, CallArg::Expr(_)))
8568
+ {
8569
+ let mut argc: Vec<String> = Vec::with_capacity(args.len());
8570
+ for (a, ty) in args.iter().zip(sig.params.iter()) {
8571
+ if let CallArg::Expr(e) = a {
8572
+ argc.push(self.emit_coerced_native(e, ty)?);
8573
+ }
8574
+ }
8575
+ let call = format!(
8576
+ "{}::{}_typed({})",
8577
+ sig.crate_name,
8578
+ sig.symbol,
8579
+ argc.join(", ")
8580
+ );
8581
+ // This is a Value-producing context: a `void` extern yields `Null`; a
8582
+ // native return is boxed once at the boundary (still far cheaper than the
8583
+ // boxed-args `value_call`); a `Value`-returning extern passes through.
8584
+ return Ok(if sig.ret == RustType::Unit {
8585
+ format!("{{ {}; Value::Null }}", call)
8586
+ } else if sig.ret.is_native() {
8587
+ sig.ret.to_value_expr(&call)
8588
+ } else {
8589
+ call
8590
+ });
8591
+ }
8592
+ }
8593
+ }
8594
+
7875
8595
  // M5: boxed-context call to an eligible native fn → `Value::Number(name_native(..))`.
7876
8596
  if let Expr::Ident { name: fname, .. } = callee.as_ref() {
7877
8597
  if self.native_fns.contains(fname.as_ref()) {
@@ -7950,17 +8670,25 @@ impl Codegen {
7950
8670
  fields.iter().find(|(k, _)| k.as_ref() == prop_name.as_ref())
7951
8671
  {
7952
8672
  let var_esc = Self::escape_ident(var_name.as_ref()).into_owned();
8673
+ // Clone a non-Copy field so reading it doesn't move out of the local
8674
+ // (E0382 when the struct is used again); a Copy scalar reads by value.
8675
+ let clone = if field_ty.is_copy() { "" } else { ".clone()" };
7953
8676
  let access = if self.refcell_wrapped_vars.contains(var_name.as_ref()) {
8677
+ // #567: scope the borrow to its own block so the lock drops
8678
+ // immediately — a `send-values` `Arc<Mutex>` VmRef is NOT
8679
+ // reentrant, so two `.borrow()`s of the same cell alive at once
8680
+ // (e.g. `s.a + s.b` in one expression) would deadlock.
7954
8681
  format!(
7955
- "(*{}.borrow()).{}.clone()",
8682
+ "{{ let __bg = {}.borrow(); (*__bg).{}.clone() }}",
7956
8683
  var_esc,
7957
8684
  crate::types::field_ident(prop_name.as_ref())
7958
8685
  )
7959
8686
  } else {
7960
8687
  format!(
7961
- "{}.{}",
8688
+ "{}.{}{}",
7962
8689
  var_esc,
7963
- crate::types::field_ident(prop_name.as_ref())
8690
+ crate::types::field_ident(prop_name.as_ref()),
8691
+ clone
7964
8692
  )
7965
8693
  };
7966
8694
  // Caller expects a `Value`; wrap.
@@ -7972,24 +8700,90 @@ impl Codegen {
7972
8700
  // Generalize the typed struct-field fast path to `xs[i].field` (array-of-structs):
7973
8701
  // when `object` indexes a `Vec<Named>`, do native struct field access.
7974
8702
  if !optional {
7975
- if let (Expr::Index { .. }, MemberProp::Name { name: prop_name, .. }) =
7976
- (object.as_ref(), prop)
8703
+ if let (
8704
+ Expr::Index { object: io, index: ii, .. },
8705
+ MemberProp::Name { name: prop_name, .. },
8706
+ ) = (object.as_ref(), prop)
7977
8707
  {
8708
+ // A captured (refcell-wrapped) `Vec<Named>` element field read: emit the field
8709
+ // access DIRECTLY on the borrowed element (`(*arr.borrow())[i].field`) — no whole
8710
+ // struct clone, and no whole-Vec boxing. This is the hot-loop `states[id].timer`
8711
+ // case (tish#558 read side). A non-Copy field is still cloned out of the borrow.
8712
+ if let Expr::Ident { name: arr_name, .. } = io.as_ref() {
8713
+ if self.refcell_wrapped_vars.contains(arr_name.as_ref()) {
8714
+ if let RustType::Vec(elem) =
8715
+ self.type_context.get_type(arr_name.as_ref())
8716
+ {
8717
+ if let RustType::Named { fields, .. } = elem.as_ref() {
8718
+ if let Some((_, field_ty)) = fields
8719
+ .iter()
8720
+ .find(|(k, _)| k.as_ref() == prop_name.as_ref())
8721
+ {
8722
+ let field_ty = field_ty.clone();
8723
+ let idx = self.emit_index_usize(ii)?;
8724
+ let esc = Self::escape_ident(arr_name.as_ref());
8725
+ let clone = if field_ty.is_copy() { "" } else { ".clone()" };
8726
+ // #567: bind the index (it may read the same cell, e.g.
8727
+ // `arr[arr.length-1]`) BEFORE taking the borrow, and scope
8728
+ // the borrow to its own block so the non-reentrant
8729
+ // `Arc<Mutex>` lock drops before any sibling read of `arr`
8730
+ // (`arr[i].a + arr[j].b`) — otherwise overlapping borrows
8731
+ // deadlock.
8732
+ let access = format!(
8733
+ "{{ let __bi = {}; let __bg = {}.borrow(); (*__bg)[__bi].{}{} }}",
8734
+ idx,
8735
+ esc,
8736
+ crate::types::field_ident(prop_name.as_ref()),
8737
+ clone
8738
+ );
8739
+ return Ok(field_ty.to_value_expr(&access));
8740
+ }
8741
+ }
8742
+ }
8743
+ }
8744
+ }
7978
8745
  let (obj_code, obj_ty) = self.emit_typed_expr(object)?;
7979
8746
  if let RustType::Named { fields, .. } = &obj_ty {
7980
8747
  if let Some((_, field_ty)) =
7981
8748
  fields.iter().find(|(k, _)| k.as_ref() == prop_name.as_ref())
7982
8749
  {
8750
+ // The element `xs[i]` is BORROWED, so a non-Copy field (a `Value`/
8751
+ // `String`) must be cloned out — otherwise reading it in a value/return
8752
+ // position moves out of the index (E0507). A Copy scalar reads by value.
8753
+ let clone = if field_ty.is_copy() { "" } else { ".clone()" };
7983
8754
  let access = format!(
7984
- "({}).{}",
8755
+ "({}).{}{}",
7985
8756
  obj_code,
7986
- crate::types::field_ident(prop_name.as_ref())
8757
+ crate::types::field_ident(prop_name.as_ref()),
8758
+ clone
7987
8759
  );
7988
8760
  return Ok(field_ty.to_value_expr(&access));
7989
8761
  }
7990
8762
  }
7991
8763
  }
7992
8764
  }
8765
+ // `arr.length` on a native `Vec` (wrapped or not) → a cheap native `len()` boxed to a
8766
+ // `Number`, instead of boxing the WHOLE Vec to a `Value::Array` just to `get_prop`
8767
+ // its length (O(n) per read — the hot `states.length`-per-frame footgun, tish#562).
8768
+ if !*optional {
8769
+ if let MemberProp::Name { name: p, .. } = prop {
8770
+ if p.as_ref() == "length" {
8771
+ if let Expr::Ident { name, .. } = object.as_ref() {
8772
+ if let RustType::Vec(_) = self.type_context.get_type(name.as_ref()) {
8773
+ let esc = Self::escape_ident(name.as_ref());
8774
+ // #567: scope the borrow (non-reentrant `Arc<Mutex>` VmRef) so it
8775
+ // can't overlap a sibling read of the same cell in one expression.
8776
+ let len = if self.refcell_wrapped_vars.contains(name.as_ref()) {
8777
+ format!("{{ let __bg = {}.borrow(); __bg.len() }}", esc)
8778
+ } else {
8779
+ format!("{}.len()", esc)
8780
+ };
8781
+ return Ok(format!("Value::Number({} as f64)", len));
8782
+ }
8783
+ }
8784
+ }
8785
+ }
8786
+ }
7993
8787
  let obj = self.emit_expr(object)?;
7994
8788
  // A literal (non-computed) key other than `size` is eligible for a per-site
7995
8789
  // polymorphic inline cache (#179). `size` is excluded because `get_prop` special-cases
@@ -8148,9 +8942,58 @@ impl Codegen {
8148
8942
  }
8149
8943
  }
8150
8944
  ObjectProp::Spread(e) => {
8151
- let val = self.emit_expr(e)?;
8945
+ // Peek the source's static type ONCE. On GBA a statically-known struct
8946
+ // spreads NATIVELY (fields read straight off the Rust struct → the source
8947
+ // is never boxed to a `Value::Struct` / hashmap). `to_value_expr` re-boxes
8948
+ // a native scalar source so the boxed arms still receive a `Value`
8949
+ // (`{ ...5 }` stays sound). Desktop is deliberately untouched — a struct
8950
+ // source there boxes exactly as before (byte-identical emit, so the hot
8951
+ // object_spread bench keeps its clone-shape path).
8952
+ let gba = self.emit_mode == crate::NativeEmitMode::Gba;
8953
+ let (typed_code, typed_ty) = self.emit_typed_expr(e)?;
8954
+ if gba {
8955
+ if let crate::types::RustType::Named { fields, .. } = &typed_ty {
8956
+ let mut ins = String::new();
8957
+ for (k, fty) in fields {
8958
+ let key = self.cached_object_key(k.as_ref());
8959
+ ins.push_str(&format!(
8960
+ "_obj.insert({}, {}); ",
8961
+ key,
8962
+ Self::struct_field_to_value("_s", k, fty)
8963
+ ));
8964
+ }
8965
+ let block = format!("{{ let _s = &({}); {}}}", typed_code, ins);
8966
+ if i == 0 {
8967
+ init = format!(
8968
+ "let mut _obj: PropMap = PropMap::with_capacity({}); {}",
8969
+ literal_keys + fields.len(),
8970
+ block
8971
+ );
8972
+ } else {
8973
+ // Later struct fields override earlier keys (JS order).
8974
+ parts.push(block);
8975
+ }
8976
+ continue;
8977
+ }
8978
+ }
8979
+ let val = if typed_ty.is_native() {
8980
+ typed_ty.to_value_expr(&typed_code)
8981
+ } else {
8982
+ typed_code
8983
+ };
8984
+ // GBA (non-struct source, e.g. a struct behind a `Value` param): route
8985
+ // through the facade's `object_spread_props`, which materialises a
8986
+ // `Value::Struct` to its object first. Desktop keeps the inline `if let
8987
+ // Value::Object` fast path untouched (no struct variant exists there, and
8988
+ // it's the hot object_spread bench).
8152
8989
  if i == 0 {
8153
- init = format!("let mut _obj: PropMap = if let Value::Object(ref _spread) = {} {{ _spread.borrow().strings.clone() }} else {{ PropMap::with_capacity({}) }};", val, literal_keys);
8990
+ init = if gba {
8991
+ format!("let mut _obj: PropMap = tishlang_runtime::object_spread_props(&({})).unwrap_or_else(|| PropMap::with_capacity({}));", val, literal_keys)
8992
+ } else {
8993
+ format!("let mut _obj: PropMap = if let Value::Object(ref _spread) = {} {{ _spread.borrow().strings.clone() }} else {{ PropMap::with_capacity({}) }};", val, literal_keys)
8994
+ };
8995
+ } else if gba {
8996
+ parts.push(format!("if let Some(_spread) = tishlang_runtime::object_spread_props(&({})) {{ _obj.merge_from(&_spread); }}", val));
8154
8997
  } else {
8155
8998
  // `merge_from` reserves for the source's len, then inserts in one
8156
8999
  // pass (later props still override earlier keys, matching JS order).
@@ -8561,6 +9404,13 @@ impl Codegen {
8561
9404
  format!("{{ if {} {{ {} }} else {{ {} }} }}", cond, assign_and_return, else_expr)
8562
9405
  }
8563
9406
  Expr::MemberAssign { object, prop, value, .. } => {
9407
+ // #558: a native struct-element / struct-local field store (yielding the assigned
9408
+ // value) instead of set_prop on a throwaway copy that discards the write.
9409
+ if let Some(code) =
9410
+ self.try_emit_native_struct_field_assign(object, prop.as_ref(), value, false)?
9411
+ {
9412
+ return Ok(code);
9413
+ }
8564
9414
  let obj = self.emit_expr(object)?;
8565
9415
  let val = self.emit_expr(value)?;
8566
9416
  format!(
@@ -9249,6 +10099,17 @@ impl Codegen {
9249
10099
  None
9250
10100
  }
9251
10101
 
10102
+ /// The root variable of a member/index chain (`a.b.c` → `a`, `a[i].b` → `a`, `a` → `a`), or
10103
+ /// `None` when the base isn't a plain identifier (e.g. a call result `f().x`).
10104
+ fn root_ident_of(expr: &Expr) -> Option<&str> {
10105
+ match expr {
10106
+ Expr::Ident { name, .. } => Some(name.as_ref()),
10107
+ Expr::Member { object, .. } => Self::root_ident_of(object),
10108
+ Expr::Index { object, .. } => Self::root_ident_of(object),
10109
+ _ => None,
10110
+ }
10111
+ }
10112
+
9252
10113
  fn collect_assigned_idents_in_expr(expr: &Expr, names: &mut HashSet<String>) {
9253
10114
  match expr {
9254
10115
  Expr::Assign { name, value, .. } => {
@@ -9270,6 +10131,12 @@ impl Codegen {
9270
10131
  names.insert(name.to_string());
9271
10132
  }
9272
10133
  Expr::MemberAssign { object, value, .. } => {
10134
+ // Mutating a field (`x.f = v`, `a.b.c = v`) mutates the ROOT variable — record it so a
10135
+ // captured struct/object is wrapped mutably; otherwise the native field store lands on
10136
+ // an immutable `let` binding (E0594).
10137
+ if let Some(root) = Self::root_ident_of(object) {
10138
+ names.insert(root.to_string());
10139
+ }
9273
10140
  Self::collect_assigned_idents_in_expr(object, names);
9274
10141
  Self::collect_assigned_idents_in_expr(value, names);
9275
10142
  }
@@ -9279,6 +10146,9 @@ impl Codegen {
9279
10146
  value,
9280
10147
  ..
9281
10148
  } => {
10149
+ if let Some(root) = Self::root_ident_of(object) {
10150
+ names.insert(root.to_string());
10151
+ }
9282
10152
  Self::collect_assigned_idents_in_expr(object, names);
9283
10153
  Self::collect_assigned_idents_in_expr(index, names);
9284
10154
  Self::collect_assigned_idents_in_expr(value, names);
@@ -9290,6 +10160,29 @@ impl Codegen {
9290
10160
  Expr::Unary { operand, .. } => Self::collect_assigned_idents_in_expr(operand, names),
9291
10161
  Expr::Delete { target, .. } => Self::collect_assigned_idents_in_expr(target, names),
9292
10162
  Expr::Call { callee, args, .. } => {
10163
+ // An in-place mutating array method (`a.push(x)`, `a.pop()`, `a.reverse()`, …) mutates
10164
+ // the ROOT variable — record it so a captured native Vec is wrapped for shared mutation.
10165
+ // WITHOUT this, the mutation inside a closure lands on a by-value / immutable-`let`
10166
+ // captured Vec (E0596), and the boxed fallback would DISCARD it (and E0282 on a
10167
+ // `Vec<struct>`, whose element type the skipped native op never pinned). Every listed
10168
+ // method has a native fast path that handles the wrapped receiver (a `(*a.borrow_mut())`
10169
+ // access, or a `borrow_mut` guard for the multi-access `splice`/`fill`/`copyWithin`).
10170
+ if let Expr::Member {
10171
+ object,
10172
+ prop: MemberProp::Name { name: m, .. },
10173
+ ..
10174
+ } = callee.as_ref()
10175
+ {
10176
+ if matches!(
10177
+ m.as_ref(),
10178
+ "push" | "pop" | "shift" | "unshift" | "reverse" | "sort" | "splice"
10179
+ | "fill" | "copyWithin"
10180
+ ) {
10181
+ if let Some(root) = Self::root_ident_of(object) {
10182
+ names.insert(root.to_string());
10183
+ }
10184
+ }
10185
+ }
9293
10186
  Self::collect_assigned_idents_in_expr(callee, names);
9294
10187
  for arg in args {
9295
10188
  match arg {
@@ -10619,6 +11512,15 @@ impl Codegen {
10619
11512
  for v in &implicit_env_captures {
10620
11513
  self.refcell_wrapped_vars.remove(v);
10621
11514
  }
11515
+ // tishlang/tish#556: an arrow PARAMETER shadows any outer var of the same name — inside the
11516
+ // body it is a plain `Value`/native binding, not the outer `VmRef` cell, so drop such names
11517
+ // from the wrapped set for the body (restored via `saved_refcell_vars`). Without this, a
11518
+ // reference to the param compiles as `vm_read(&param)` — a `&Value` where a `&VmRef` is wanted.
11519
+ for p in params {
11520
+ for bn in p.bound_names() {
11521
+ self.refcell_wrapped_vars.remove(bn.as_ref());
11522
+ }
11523
+ }
10622
11524
 
10623
11525
  self.type_context.push_fun_param_scope(params, None);
10624
11526
 
@@ -10709,11 +11611,15 @@ impl Codegen {
10709
11611
  {
10710
11612
  if p.as_ref() == "length" {
10711
11613
  if let Expr::Ident { name: arr, .. } = object.as_ref() {
10712
- if matches!(self.type_context.get_type(arr.as_ref()), RustType::Vec(_))
10713
- && !self.refcell_wrapped_vars.contains(arr.as_ref())
10714
- {
11614
+ if matches!(self.type_context.get_type(arr.as_ref()), RustType::Vec(_)) {
10715
11615
  let esc = Self::escape_ident(arr.as_ref());
10716
- return Ok(format!("({}.len() as f64)", esc));
11616
+ // A captured Vec reads its length through the cell (`(*a.borrow()).len()`).
11617
+ let code = if self.refcell_wrapped_vars.contains(arr.as_ref()) {
11618
+ format!("{{ let __bg = {}.borrow(); __bg.len() as f64 }}", esc)
11619
+ } else {
11620
+ format!("{}.len() as f64", esc)
11621
+ };
11622
+ return Ok(format!("({})", code));
10717
11623
  }
10718
11624
  }
10719
11625
  }
@@ -10938,6 +11844,17 @@ impl Codegen {
10938
11844
  if *target_type == RustType::F64 && typed_ty.is_integer_scalar() {
10939
11845
  return Ok(format!("({}) as f64", typed_code));
10940
11846
  }
11847
+ // `fixed` target from a native integer / f64 source: lift to `Fixed` directly, no
11848
+ // Value round-trip. Lets `let dx: fixed = input_x()` (i32→fixed) or `let v: fixed = n`
11849
+ // stay native so downstream `dx * spd` is `Fixed*Fixed` instead of boxing.
11850
+ if *target_type == RustType::Fixed {
11851
+ if typed_ty.is_integer_scalar() {
11852
+ return Ok(format!("tishlang_runtime::Fixed::from_raw((({}) as i32) * 256)", typed_code));
11853
+ }
11854
+ if typed_ty == RustType::F64 {
11855
+ return Ok(format!("tishlang_runtime::Fixed::from_raw((({}) * 256.0) as i32)", typed_code));
11856
+ }
11857
+ }
10941
11858
  }
10942
11859
 
10943
11860
  // Fall back to emit_expr + conversion
@@ -12250,50 +13167,19 @@ impl Codegen {
12250
13167
  let Expr::Ident { name, .. } = object else {
12251
13168
  return Ok(None);
12252
13169
  };
12253
- if self.refcell_wrapped_vars.contains(name.as_ref()) {
12254
- return Ok(None);
12255
- }
12256
13170
  let obj_type = self.type_context.get_type(name.as_ref());
12257
13171
  let RustType::Vec(elem_type) = obj_type else {
12258
13172
  return Ok(None);
12259
13173
  };
13174
+ // #564: a closure-captured (refcell-wrapped) native Vec is handled below via a `borrow_mut`
13175
+ // store. WITHOUT this it fell to the generic boxed `set_index` fallback, which mutates a
13176
+ // THROWAWAY boxed copy of the Vec (write silently DISCARDED) — and for a compound `a[i] += x`
13177
+ // (desugared to `a[i] = a[i] + x`) the fallback's `get_index(&a) … set_index(&a …)` takes two
13178
+ // overlapping locks on the non-reentrant `Arc<Mutex>` VmRef and DEADLOCKS the binary.
13179
+ let wrapped = self.refcell_wrapped_vars.contains(name.as_ref());
12260
13180
  let esc_obj = Self::escape_ident(name.as_ref()).into_owned();
12261
13181
  let in_bounds = self.index_in_bounds(index, name.as_ref());
12262
- let idx_usize = if let Expr::Ident { name: idx_name, .. } = index {
12263
- if let Some(uv) = self.usize_var_subst.get(idx_name.as_ref()) {
12264
- uv.clone()
12265
- } else {
12266
- let (idx_code, idx_ty) = self.emit_typed_expr(index)?;
12267
- if idx_ty == RustType::F64 || idx_ty == RustType::I32 {
12268
- format!("({}) as usize", idx_code)
12269
- } else {
12270
- let iv = if idx_ty.is_native() {
12271
- idx_ty.to_value_expr(&idx_code)
12272
- } else {
12273
- idx_code
12274
- };
12275
- format!(
12276
- "{{ let _i = &{}; if let Value::Number(n) = _i {{ *n as usize }} else {{ panic!(\"array index must be a number\") }} }}",
12277
- iv
12278
- )
12279
- }
12280
- }
12281
- } else {
12282
- let (idx_code, idx_ty) = self.emit_typed_expr(index)?;
12283
- if idx_ty == RustType::F64 || idx_ty == RustType::I32 {
12284
- format!("({}) as usize", idx_code)
12285
- } else {
12286
- let iv = if idx_ty.is_native() {
12287
- idx_ty.to_value_expr(&idx_code)
12288
- } else {
12289
- idx_code
12290
- };
12291
- format!(
12292
- "{{ let _i = &{}; if let Value::Number(n) = _i {{ *n as usize }} else {{ panic!(\"array index must be a number\") }} }}",
12293
- iv
12294
- )
12295
- }
12296
- };
13182
+ let idx_usize = self.emit_index_usize(index)?;
12297
13183
  let native_val = if *elem_type == RustType::I32 {
12298
13184
  if let Expr::Binary {
12299
13185
  left,
@@ -12342,6 +13228,34 @@ impl Codegen {
12342
13228
  val_code
12343
13229
  }
12344
13230
  };
13231
+ // #564: wrapped receiver — bind the index and RHS to temps BEFORE taking the `borrow_mut`,
13232
+ // so a RHS that reads the same cell (`a[i] += x`, `a[i] = a[j]`) releases its shared borrow
13233
+ // first (no overlapping lock → no deadlock), then store into the real element through the cell.
13234
+ if wrapped {
13235
+ let needs_resize =
13236
+ matches!(elem_type.as_ref(), RustType::F64 | RustType::Bool) && !in_bounds;
13237
+ let store = if needs_resize {
13238
+ let pad = if matches!(elem_type.as_ref(), RustType::F64) {
13239
+ "f64::NAN"
13240
+ } else {
13241
+ "false"
13242
+ };
13243
+ format!(
13244
+ "let __i = {idx}; let __v = {val}; let mut __g = {esc}.borrow_mut(); if __i >= __g.len() {{ __g.resize(__i + 1, {pad}); }} __g[__i] = __v;",
13245
+ idx = idx_usize, val = native_val, esc = esc_obj, pad = pad
13246
+ )
13247
+ } else {
13248
+ format!(
13249
+ "let __i = {idx}; let __v = {val}; (*{esc}.borrow_mut())[__i] = __v;",
13250
+ idx = idx_usize, val = native_val, esc = esc_obj
13251
+ )
13252
+ };
13253
+ return Ok(Some(if side_effect_only {
13254
+ format!("{{ {} }}", store)
13255
+ } else {
13256
+ format!("{{ {} Value::Null }}", store)
13257
+ }));
13258
+ }
12345
13259
  let assign = match elem_type.as_ref() {
12346
13260
  RustType::F64 | RustType::Bool | RustType::I32 if in_bounds => {
12347
13261
  if side_effect_only {
@@ -12369,6 +13283,130 @@ impl Codegen {
12369
13283
  Ok(Some(assign))
12370
13284
  }
12371
13285
 
13286
+ /// Emit an array/element index expression as a Rust `usize`. A substituted loop counter reuses
13287
+ /// its `usize` binding; a numeric index casts (`(idx) as usize`); anything else unwraps a
13288
+ /// `Value::Number` at runtime. Shared by the native `Vec` index-assign and struct-field-assign
13289
+ /// lowerings so they agree on index handling.
13290
+ fn emit_index_usize(&mut self, index: &Expr) -> Result<String, CompileError> {
13291
+ if let Expr::Ident { name: idx_name, .. } = index {
13292
+ if let Some(uv) = self.usize_var_subst.get(idx_name.as_ref()) {
13293
+ return Ok(uv.clone());
13294
+ }
13295
+ }
13296
+ let (idx_code, idx_ty) = self.emit_typed_expr(index)?;
13297
+ if idx_ty == RustType::F64 || idx_ty == RustType::I32 {
13298
+ Ok(format!("({}) as usize", idx_code))
13299
+ } else {
13300
+ let iv = if idx_ty.is_native() {
13301
+ idx_ty.to_value_expr(&idx_code)
13302
+ } else {
13303
+ idx_code
13304
+ };
13305
+ Ok(format!(
13306
+ "{{ let _i = &{}; if let Value::Number(n) = _i {{ *n as usize }} else {{ panic!(\"array index must be a number\") }} }}",
13307
+ iv
13308
+ ))
13309
+ }
13310
+ }
13311
+
13312
+ /// tishlang/tish#558 — `place.field = value` where `place` is an addressable STRUCT: an element
13313
+ /// of a typed struct `Vec` (`arr[i].field`) or a struct-typed local (`s.field`). Lowers to a
13314
+ /// NATIVE field store into the real element/local, coercing `value` to the field's type. Without
13315
+ /// this, the generic member-assign path emits `set_prop` on a throwaway `Value::object_from_pairs`
13316
+ /// COPY of the element — mutating the copy and silently discarding the write. Returns `None` for
13317
+ /// anything that isn't a known struct place (the caller falls back to the dynamic `set_prop`).
13318
+ fn try_emit_native_struct_field_assign(
13319
+ &mut self,
13320
+ object: &Expr,
13321
+ prop: &str,
13322
+ value: &Expr,
13323
+ side_effect_only: bool,
13324
+ ) -> Result<Option<String>, CompileError> {
13325
+ // `prelude` binds any index temp BEFORE a `borrow_mut()` place is taken (so the index may
13326
+ // itself read the array — `arr[arr.length-1]` — without a RefCell double-borrow); it is empty
13327
+ // for the non-wrapped cases. `place` is the addressable struct expression the field stores into.
13328
+ let (prelude, place, fields) = match object {
13329
+ // `arr[i].field = v` — a typed struct Vec element. A captured (refcell-wrapped) array
13330
+ // writes through `(*arr.borrow_mut())[i]` so the store lands natively on the shared cell;
13331
+ // WITHOUT this it fell to the generic member-assign, which boxed the WHOLE Vec into a
13332
+ // throwaway `Value::Array` (cloning + `from_struct`-boxing every element) and `set_prop`'d
13333
+ // the discarded copy — O(n) per write, the write lost, and an E0282 on the boxing closure.
13334
+ Expr::Index { object: io, index, .. } => {
13335
+ let Expr::Ident { name, .. } = io.as_ref() else {
13336
+ return Ok(None);
13337
+ };
13338
+ let RustType::Vec(elem) = self.type_context.get_type(name.as_ref()) else {
13339
+ return Ok(None);
13340
+ };
13341
+ let RustType::Named { fields, .. } = elem.as_ref() else {
13342
+ return Ok(None);
13343
+ };
13344
+ let fields = fields.clone();
13345
+ let idx_usize = self.emit_index_usize(index)?;
13346
+ let esc = Self::escape_ident(name.as_ref());
13347
+ if self.refcell_wrapped_vars.contains(name.as_ref()) {
13348
+ (
13349
+ format!("let _i = {}; ", idx_usize),
13350
+ format!("(*{}.borrow_mut())[_i]", esc),
13351
+ fields,
13352
+ )
13353
+ } else {
13354
+ (String::new(), format!("{}[{}]", esc, idx_usize), fields)
13355
+ }
13356
+ }
13357
+ // `s.field = v` — a struct-typed local/var. A captured (refcell-wrapped) struct writes
13358
+ // through `(*s.borrow_mut())` so the field store lands natively on the shared cell instead
13359
+ // of falling to the boxed `set_prop(object_from_pairs(..))` path (which also wouldn't
13360
+ // persist — it built a throwaway object).
13361
+ Expr::Ident { name, .. } => {
13362
+ let RustType::Named { fields, .. } = self.type_context.get_type(name.as_ref()) else {
13363
+ return Ok(None);
13364
+ };
13365
+ let place = if self.refcell_wrapped_vars.contains(name.as_ref()) {
13366
+ format!("(*{}.borrow_mut())", Self::escape_ident(name.as_ref()))
13367
+ } else {
13368
+ Self::escape_ident(name.as_ref()).into_owned()
13369
+ };
13370
+ (String::new(), place, fields)
13371
+ }
13372
+ _ => return Ok(None),
13373
+ };
13374
+ // The property must be a real field of the struct; its type drives the RHS coercion.
13375
+ let Some((_, field_ty)) = fields.iter().find(|(k, _)| k.as_ref() == prop) else {
13376
+ return Ok(None);
13377
+ };
13378
+ let field_ty = field_ty.clone();
13379
+ let field = crate::types::field_ident(prop);
13380
+ let coerced = self.emit_coerced_native(value, &field_ty)?;
13381
+ // Bind the RHS to a temp first so a `borrow_mut()` place can't alias a `borrow()` in the RHS
13382
+ // (`s.a = s.b + 1`) — evaluate the read before taking the mutable borrow. Any index prelude
13383
+ // runs first, so its evaluation also predates the borrow.
13384
+ let via_temp = !prelude.is_empty() || place.contains(".borrow_mut()");
13385
+ if side_effect_only {
13386
+ if via_temp {
13387
+ Ok(Some(format!(
13388
+ "{{ {}let _v = {}; {}.{} = _v; }}",
13389
+ prelude, coerced, place, field
13390
+ )))
13391
+ } else {
13392
+ Ok(Some(format!("{}.{} = {}", place, field, coerced)))
13393
+ }
13394
+ } else {
13395
+ // Assignment-as-expression yields the assigned value (JS `a.b = c` ⇒ c). Bind once (the
13396
+ // RHS may have effects and the place may re-evaluate an index), store, then hand back a
13397
+ // `Value` of it — box a native field, pass a `Value` field through.
13398
+ let ret = if field_ty.is_native() {
13399
+ field_ty.to_value_expr("_v")
13400
+ } else {
13401
+ "_v".to_string()
13402
+ };
13403
+ Ok(Some(format!(
13404
+ "{{ {}let _v = {}; {}.{} = _v.clone(); {} }}",
13405
+ prelude, coerced, place, field, ret
13406
+ )))
13407
+ }
13408
+ }
13409
+
12372
13410
  /// Emit `value` as a native expression of type `target_ty`, coercing without a
12373
13411
  /// `Value` round-trip where the conversion is a cheap numeric cast. Same-typed RHS
12374
13412
  /// passes through; f64↔i32 casts directly; everything else boxes to `Value` then
@@ -22097,6 +23135,23 @@ impl Codegen {
22097
23135
  let (l, lt) = self.emit_typed_expr(left)?;
22098
23136
  let (r, rt) = self.emit_typed_expr(right)?;
22099
23137
 
23138
+ // `fixed ⊕ <runtime integer>` (e.g. `t * speed` with `t: i32`, `speed: fixed`): LIFT the
23139
+ // integer operand to `Fixed` via `Num::new` so the whole op stays native `Num<i32,8>`
23140
+ // arithmetic. This runs BEFORE the generic integer→f64 coercion below — otherwise the
23141
+ // integer becomes f64 and `f64 ⊕ fixed` drops to the boxed `ops::*` path (that path is
23142
+ // the deliberate escape hatch for a GENUINE runtime f64, which is lossy against fixed;
23143
+ // a plain integer is exact, so lifting is sound). Integer *literals* are already folded
23144
+ // to a Fixed constant by `fixed_literal_of` further down; this covers runtime integers.
23145
+ // `Fixed<i32,8>` raw = value << 8 (× 256), so an integer `v` lifts to fixed `v.0` as
23146
+ // `Fixed::from_raw(v * 256)` — the same constructor tish emits everywhere for fixed.
23147
+ let (l, lt, r, rt) = if lt == RustType::Fixed && rt.is_integer_scalar() {
23148
+ (l, lt, format!("tishlang_runtime::Fixed::from_raw((({}) as i32) * 256)", r), RustType::Fixed)
23149
+ } else if rt == RustType::Fixed && lt.is_integer_scalar() {
23150
+ (format!("tishlang_runtime::Fixed::from_raw((({}) as i32) * 256)", l), RustType::Fixed, r, rt)
23151
+ } else {
23152
+ (l, lt, r, rt)
23153
+ };
23154
+
22100
23155
  // An `I32` loop-accumulator (the i32-loop-var lowering) used in a NON-bitwise
22101
23156
  // expression reads as its signed int32 value coerced to `f64` — every i32 is exact
22102
23157
  // in f64. Bitwise/shift parents never see this: they recurse into the raw AST via
@@ -22263,6 +23318,41 @@ impl Codegen {
22263
23318
  }
22264
23319
  }
22265
23320
 
23321
+ // GBA: `key in s` on a statically-known struct → answer NATIVELY against the field
23322
+ // names (no boxing of `s`, no hashmap). A string-literal key folds to a compile-time
23323
+ // bool; a string-typed key becomes a `matches!` over the field names (zero alloc). The
23324
+ // struct is still evaluated (bound to `_`) for its side effects. Any other key kind
23325
+ // falls through to the boxed runtime `in` below. Desktop is untouched.
23326
+ if *op == BinOp::In
23327
+ && self.emit_mode == crate::NativeEmitMode::Gba
23328
+ {
23329
+ if let RustType::Named { fields, .. } = &rt {
23330
+ if let Expr::Literal { value: Literal::String(s), .. } = left.as_ref() {
23331
+ let present = fields.iter().any(|(k, _)| k.as_ref() == s.as_ref());
23332
+ return Ok((
23333
+ format!("{{ let _ = &({}); Value::Bool({}) }}", r, present),
23334
+ RustType::Value,
23335
+ ));
23336
+ }
23337
+ if lt == RustType::String {
23338
+ let arms = fields
23339
+ .iter()
23340
+ .map(|(k, _)| format!("{:?}", k.as_ref()))
23341
+ .collect::<Vec<_>>()
23342
+ .join(" | ");
23343
+ // Bind the key BEFORE evaluating the struct — `key in obj` evaluates
23344
+ // left-to-right, so the key's side effects must run first.
23345
+ return Ok((
23346
+ format!(
23347
+ "{{ let _k = ({}); let _ = &({}); Value::Bool(matches!(_k.as_str(), {})) }}",
23348
+ l, r, arms
23349
+ ),
23350
+ RustType::Value,
23351
+ ));
23352
+ }
23353
+ }
23354
+ }
23355
+
22266
23356
  // Fall back: convert both sides to Value and use the runtime.
22267
23357
  let lv = Self::box_operand_for_runtime(left, &l, &lt);
22268
23358
  let rv = Self::box_operand_for_runtime(right, &r, &rt);
@@ -22349,10 +23439,24 @@ impl Codegen {
22349
23439
  // Native fast path: `vec[i]` where vec is Vec<T> and i is numeric.
22350
23440
  if !optional {
22351
23441
  if let Expr::Ident { name, .. } = object.as_ref() {
22352
- if !self.refcell_wrapped_vars.contains(name.as_ref()) {
23442
+ {
23443
+ let idx_wrapped = self.refcell_wrapped_vars.contains(name.as_ref());
22353
23444
  let obj_type = self.type_context.get_type(name.as_ref());
22354
23445
  if let RustType::Vec(elem_type) = &obj_type {
22355
- let esc_obj = Self::escape_ident(name.as_ref()).into_owned();
23446
+ // A captured (refcell-wrapped) Vec reads through `(*a.borrow())` — WITHOUT
23447
+ // this it fell to the boxed `get_index`, which cloned + `from_struct`-boxed
23448
+ // the WHOLE Vec into a `Value::Array` just to read one element (O(n) per
23449
+ // read — the hot-loop `states[id].timer` footgun, tish#558 read side).
23450
+ let esc_raw = Self::escape_ident(name.as_ref()).into_owned();
23451
+ // #567: for a wrapped Vec the place is the block-local guard `__bg`
23452
+ // (bound below), so the non-reentrant `Arc<Mutex>` borrow is scoped to
23453
+ // this single read and can't overlap a sibling read of the same cell
23454
+ // (`a[i] + a[j]` in one expression), which would deadlock.
23455
+ let esc_obj = if idx_wrapped {
23456
+ "(*__bg)".to_string()
23457
+ } else {
23458
+ esc_raw.clone()
23459
+ };
22356
23460
  // #173 part 3: prove the index in-bounds BEFORE emitting it.
22357
23461
  let in_bounds = self.index_in_bounds(index, name.as_ref());
22358
23462
  let idx_usize = if let Expr::Ident { name: idx_name, .. } =
@@ -22394,6 +23498,14 @@ impl Codegen {
22394
23498
  )
22395
23499
  }
22396
23500
  };
23501
+ // #567: inside the wrapped block the index is pre-bound to `__bi`
23502
+ // (evaluated BEFORE the borrow, so an index that itself reads this cell
23503
+ // — `arr[arr.length-1]` — doesn't double-lock the non-reentrant cell).
23504
+ let idx_ref = if idx_wrapped {
23505
+ "__bi".to_string()
23506
+ } else {
23507
+ idx_usize.clone()
23508
+ };
22397
23509
  let elem_ty = *elem_type.clone();
22398
23510
  // OOB-safe read for numeric/bool Vecs: JS `arr[oob]` is `undefined`
22399
23511
  // (→ NaN / false in those contexts), NOT a panic. In-bounds is the
@@ -22403,27 +23515,42 @@ impl Codegen {
22403
23515
  // #173 part 3: a proven in-bounds read skips the `.get().unwrap_or`
22404
23516
  // branch — a direct `a[i]` (the `idx < len` proof guarantees it).
22405
23517
  RustType::F64 | RustType::Bool | RustType::I32 if in_bounds => {
22406
- format!("{}[{}]", esc_obj, idx_usize)
23518
+ format!("{}[{}]", esc_obj, idx_ref)
22407
23519
  }
22408
23520
  RustType::F64 => format!(
22409
23521
  "{}.get({}).copied().unwrap_or(f64::NAN)",
22410
- esc_obj, idx_usize
23522
+ esc_obj, idx_ref
22411
23523
  ),
22412
23524
  RustType::I32 => format!(
22413
23525
  "{}.get({}).copied().unwrap_or(0)",
22414
- esc_obj, idx_usize
23526
+ esc_obj, idx_ref
22415
23527
  ),
22416
23528
  RustType::Bool => {
22417
- format!("{}.get({}).copied().unwrap_or(false)", esc_obj, idx_usize)
23529
+ format!("{}.get({}).copied().unwrap_or(false)", esc_obj, idx_ref)
22418
23530
  }
22419
- // Other element types keep the direct index (unchanged).
22420
- _ => format!("{}[{}]", esc_obj, idx_usize),
23531
+ // Other element types (struct/Value/String). From behind a
23532
+ // `borrow()` guard a non-Copy element must be cloned OUT (it can't be
23533
+ // moved from the borrow); a plain local keeps the direct place.
23534
+ _ if idx_wrapped => format!("{}[{}].clone()", esc_obj, idx_ref),
23535
+ _ => format!("{}[{}]", esc_obj, idx_ref),
23536
+ };
23537
+ // #567: wrap the wrapped-Vec read in a block that pre-binds the index
23538
+ // and scopes the borrow guard `__bg`, so the lock is released before
23539
+ // any sibling read of the same cell in the enclosing expression.
23540
+ let access = if idx_wrapped {
23541
+ format!(
23542
+ "{{ let __bi = {}; let __bg = {}.borrow(); {} }}",
23543
+ idx_usize, esc_raw, access
23544
+ )
23545
+ } else {
23546
+ access
22421
23547
  };
22422
23548
  return Ok((access, elem_ty));
22423
23549
  }
22424
23550
  // Native tuple access: `tuple[const]` -> `tuple.const` (Rust tuples
22425
23551
  // require a literal index; a variable index falls through to boxed).
22426
- if let RustType::Tuple(elems) = &obj_type {
23552
+ // (Plain local only — a wrapped tuple keeps the boxed path.)
23553
+ if let (false, RustType::Tuple(elems)) = (idx_wrapped, &obj_type) {
22427
23554
  if let Expr::Literal {
22428
23555
  value: Literal::Number(n),
22429
23556
  ..
@@ -22501,6 +23628,35 @@ impl Codegen {
22501
23628
  }
22502
23629
  }
22503
23630
  }
23631
+ // Typed extern (a `cargo:` import with a declared native signature) used INSIDE a
23632
+ // native/typed expression: `entity_x(e) + 16`, `cvar(e, k) + 1`. Emit the direct
23633
+ // `crate::name_typed(<coerced args>)` and report its NATIVE return type, so the
23634
+ // surrounding arithmetic/comparison stays native instead of boxing the result and
23635
+ // dropping to `ops::*`. Only a native-returning extern is taken here; a `void`/`Value`
23636
+ // one falls through to the boxed path (this is a value-position expression).
23637
+ if let Expr::Ident { name: fname, .. } = callee.as_ref() {
23638
+ if let Some(sig) = self.extern_fns.get(fname.as_ref()).cloned() {
23639
+ if sig.ret.is_native()
23640
+ && args.len() == sig.params.len()
23641
+ && args.iter().all(|a| matches!(a, CallArg::Expr(_)))
23642
+ {
23643
+ let mut argc: Vec<String> = Vec::with_capacity(args.len());
23644
+ for (a, ty) in args.iter().zip(sig.params.iter()) {
23645
+ if let CallArg::Expr(e) = a {
23646
+ argc.push(self.emit_coerced_native(e, ty)?);
23647
+ }
23648
+ }
23649
+ let call = format!(
23650
+ "{}::{}_typed({})",
23651
+ sig.crate_name,
23652
+ sig.symbol,
23653
+ argc.join(", ")
23654
+ );
23655
+ return Ok((call, sig.ret));
23656
+ }
23657
+ }
23658
+ }
23659
+
22504
23660
  // M5: direct call to an eligible native fn -> `name_native(<native args>)`.
22505
23661
  if let Expr::Ident { name: fname, .. } = callee.as_ref() {
22506
23662
  if self.native_fns.contains(fname.as_ref()) {
@@ -22620,6 +23776,19 @@ impl Codegen {
22620
23776
  if let Some(res) = self.native_vec_hof_for_call(callee, args)? {
22621
23777
  return Ok(res);
22622
23778
  }
23779
+ // A call to a user tish function whose declared return type is a native struct
23780
+ // (`function gameState(): GameState`). The function is still a boxed closure, so the call
23781
+ // yields a `Value` — a `Value::Struct` on GBA. Coerce it to the native struct via
23782
+ // `from_value_expr` (a cheap `downcast_struct` on GBA; the `get_prop` rebuild off-GBA), so
23783
+ // the surrounding `gs.score` / `gs.bossHp` reads lower to plain Rust field loads instead of
23784
+ // boxed `get_prop`. A false positive (name shadowed by a non-struct local) is harmless:
23785
+ // `from_value_expr` downcasts if it can and otherwise rebuilds via `get_prop`.
23786
+ if let Expr::Ident { name: fname, .. } = callee.as_ref() {
23787
+ if let Some(ret) = self.user_fn_returns.get(fname.as_ref()).cloned() {
23788
+ let boxed = self.emit_expr(expr)?;
23789
+ return Ok((ret.from_value_expr(&boxed), ret));
23790
+ }
23791
+ }
22623
23792
  let result = self.emit_expr(expr)?;
22624
23793
  Ok((result, RustType::Value))
22625
23794
  }
@@ -22644,7 +23813,7 @@ impl Codegen {
22644
23813
  if prop_name.as_ref() == "length" {
22645
23814
  let var_esc = Self::escape_ident(var_name.as_ref()).into_owned();
22646
23815
  let code = if self.refcell_wrapped_vars.contains(var_name.as_ref()) {
22647
- format!("({}.borrow().len() as f64)", var_esc)
23816
+ format!("({{ let __bg = {}.borrow(); __bg.len() as f64 }})", var_esc)
22648
23817
  } else {
22649
23818
  format!("({}.len() as f64)", var_esc)
22650
23819
  };
@@ -22659,7 +23828,7 @@ impl Codegen {
22659
23828
  let field = crate::types::field_ident(prop_name.as_ref());
22660
23829
  let access = if self.refcell_wrapped_vars.contains(var_name.as_ref())
22661
23830
  {
22662
- format!("(*{}.borrow()).{}.clone()", var_esc, field)
23831
+ format!("{{ let __bg = {}.borrow(); (*__bg).{}.clone() }}", var_esc, field)
22663
23832
  } else {
22664
23833
  format!("{}.{}", var_esc, field)
22665
23834
  };