@tishlang/tish-lsp 2.43.8 → 3.1.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 (47) hide show
  1. package/bin/tish-lsp +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/tests/scope_merge.rs +31 -0
  7. package/crates/tish_build_utils/src/lib.rs +131 -6
  8. package/crates/tish_builtins/src/globals.rs +27 -0
  9. package/crates/tish_compile/src/codegen.rs +1498 -132
  10. package/crates/tish_compile/src/resolve.rs +65 -9
  11. package/crates/tish_compile/src/schemes.rs +60 -5
  12. package/crates/tish_compile/src/types.rs +64 -32
  13. package/crates/tish_compile/tests/regr_556_param_name_collision.rs +40 -0
  14. package/crates/tish_compile/tests/regr_558_struct_array_write.rs +65 -0
  15. package/crates/tish_compile/tests/regr_558b_struct_field_read.rs +21 -0
  16. package/crates/tish_compile/tests/regr_558c_wrapped_struct_array.rs +64 -0
  17. package/crates/tish_compile/tests/regr_558d_native_vec_mutators.rs +156 -0
  18. package/crates/tish_compile/tests/regr_562_wrapped_struct_array_push.rs +50 -0
  19. package/crates/tish_compile/tests/regr_fixed_native_coercions.rs +69 -0
  20. package/crates/tish_compile/tests/regr_gba_struct_reflection.rs +106 -0
  21. package/crates/tish_compile/tests/regression/name_collision_lib.tish +6 -0
  22. package/crates/tish_compile/tests/regression/name_collision_main.tish +13 -0
  23. package/crates/tish_compile/tests/regression/struct_array_write.tish +22 -0
  24. package/crates/tish_compile/tests/regression/struct_field_return_read.tish +10 -0
  25. package/crates/tish_core/src/console_style.rs +4 -0
  26. package/crates/tish_core/src/json.rs +8 -0
  27. package/crates/tish_core/src/shape.rs +28 -3
  28. package/crates/tish_core/src/value.rs +123 -0
  29. package/crates/tish_lsp/Cargo.toml +1 -1
  30. package/crates/tish_native/src/build.rs +4 -0
  31. package/crates/tish_runtime/Cargo.toml +20 -1
  32. package/crates/tish_runtime/src/crypto.rs +127 -0
  33. package/crates/tish_runtime/src/encoding.rs +157 -0
  34. package/crates/tish_runtime/src/fs_ext.rs +27 -0
  35. package/crates/tish_runtime/src/lib.rs +33 -0
  36. package/crates/tish_runtime/src/net.rs +482 -0
  37. package/crates/tish_runtime/src/process_spawn.rs +508 -0
  38. package/crates/tish_runtime/src/ws.rs +206 -23
  39. package/crates/tish_runtime/src/zip.rs +196 -0
  40. package/crates/tish_runtime_gba/src/lib.rs +188 -4
  41. package/crates/tish_vm/src/vm.rs +48 -0
  42. package/package.json +1 -1
  43. package/platform/darwin-arm64/tish-lsp +0 -0
  44. package/platform/darwin-x64/tish-lsp +0 -0
  45. package/platform/linux-arm64/tish-lsp +0 -0
  46. package/platform/linux-x64/tish-lsp +0 -0
  47. package/platform/win32-x64/tish-lsp.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,16 @@ 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
+ /// spec -> the `run()`-local holding that module's namespace object, bound ONCE in the preamble.
1022
+ /// Building it is O(exports) — see `emit_native_namespace_preamble`.
1023
+ native_ns_locals: std::collections::HashMap<String, String>,
1024
+ /// Typed native imports (`declare fun` + a native import): tish name -> its typed signature.
1025
+ /// A matching call emits a DIRECT `<crate>::name_typed(..)` instead of a boxed `value_call`.
1026
+ extern_fns: std::collections::HashMap<String, ExternSig>,
1027
+ /// User tish functions whose declared return type is a native STRUCT (`function f(): Iface`).
1028
+ /// A call in a typed context coerces the boxed result to the native struct (a `Value::Struct`
1029
+ /// downcast on GBA), so `f().field` reads are native instead of `get_prop`. Name -> `RustType::Named`.
1030
+ user_fn_returns: std::collections::HashMap<String, crate::types::RustType>,
987
1031
  /// Stack: true = async Rust context (run body), false = sync closure (Tish fn body)
988
1032
  async_context_stack: Vec<bool>,
989
1033
  loop_stack: Vec<(String, Option<String>)>, // (break_label, continue_update) for innermost loop
@@ -1220,7 +1264,7 @@ pub(crate) struct Codegen {
1220
1264
  cse_temp_counter: usize,
1221
1265
  /// The program's used NON-ASCII characters (collected from every string literal), as a sorted
1222
1266
  /// 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.
1267
+ /// actually uses — e.g. `font:` bakes ASCII + these, not all ~24k glyphs of a CJK font.
1224
1268
  string_charset: String,
1225
1269
  }
1226
1270
 
@@ -1251,6 +1295,9 @@ impl Codegen {
1251
1295
  string_charset: String::new(),
1252
1296
  features,
1253
1297
  native_module_init,
1298
+ native_ns_locals: std::collections::HashMap::new(),
1299
+ extern_fns: std::collections::HashMap::new(),
1300
+ user_fn_returns: std::collections::HashMap::new(),
1254
1301
  async_context_stack: Vec::new(),
1255
1302
  loop_stack: Vec::new(),
1256
1303
  break_stack: Vec::new(),
@@ -1523,7 +1570,7 @@ impl Codegen {
1523
1570
  /// only way a tagless image's sprites escape). Registration into the runtime arena happens in
1524
1571
  /// the entry below.
1525
1572
  /// 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
1573
+ /// Fed to schemes via `{charset}` (see `render_template`) so a `font:` import can bake only the
1527
1574
  /// glyphs actually used. ASCII is always baked by the scheme, so it's excluded here. Runtime-built
1528
1575
  /// strings (concatenation, numbers) resolve to ASCII, which is covered; a non-ASCII char reachable
1529
1576
  /// only through a computed string is the rare gap (documented — add it to a literal if needed).
@@ -1634,6 +1681,11 @@ impl Codegen {
1634
1681
  ));
1635
1682
  }
1636
1683
  self.write("}\n\n");
1684
+ // A `TishStruct` impl so this struct can be boxed BY REFERENCE into a `Value`
1685
+ // (`Value::from_struct` → `Value::Struct` on GBA) instead of serialised to a hashmap
1686
+ // object — property access on the boxed value then dispatches here (a small match,
1687
+ // no hash / alloc). See `types::to_value_expr` `Named` arm.
1688
+ self.emit_struct_tish_impl(&struct_name, fields);
1637
1689
  // #315: emit a direct JSON serializer for this struct when every field is
1638
1690
  // JSON-simple. Emitted for each eligible alias (unused ones are covered by
1639
1691
  // the generated crate's `#![allow(unused)]`); the JSON.stringify intercept
@@ -1657,6 +1709,7 @@ impl Codegen {
1657
1709
  ));
1658
1710
  }
1659
1711
  self.write("}\n\n");
1712
+ self.emit_struct_tish_impl(&vstruct, vfields);
1660
1713
  }
1661
1714
  let enum_ident = crate::types::shape_union_enum_ident(uname);
1662
1715
  self.write("#[derive(Clone, Debug)]\n");
@@ -1683,6 +1736,68 @@ impl Codegen {
1683
1736
  }
1684
1737
  }
1685
1738
 
1739
+ /// Emit `impl tishlang_runtime::TishStruct for TishStruct_<name>` so a typed struct can be boxed
1740
+ /// BY REFERENCE into a `Value` (`Value::from_struct`) rather than eagerly serialised to a hashmap
1741
+ /// object. `tish_get`/`tish_set` are the dynamic property path (a `match` on the field name — no
1742
+ /// hash, no alloc); `tish_to_object` is the materialisation used for JSON / `Object.keys`-family
1743
+ /// and as the non-portable `from_struct` fallback (identical to the historical boundary).
1744
+ fn emit_struct_tish_impl(
1745
+ &mut self,
1746
+ struct_name: &str,
1747
+ fields: &[(std::sync::Arc<str>, crate::types::RustType)],
1748
+ ) {
1749
+ self.write("#[allow(non_snake_case, unused)]\n");
1750
+ self.write(&format!(
1751
+ "impl tishlang_runtime::TishStruct for {} {{\n",
1752
+ struct_name
1753
+ ));
1754
+
1755
+ // tish_get: field name → boxed Value (Null for an unknown key).
1756
+ self.write(" fn tish_get(&self, __k: &str) -> tishlang_runtime::Value {\n");
1757
+ self.write(" match __k {\n");
1758
+ for (k, ty) in fields {
1759
+ let access = format!("self.{}", crate::types::field_ident(k));
1760
+ let v_expr = if matches!(ty, crate::types::RustType::Value) {
1761
+ format!("{}.clone()", access)
1762
+ } else {
1763
+ ty.to_value_expr(&access)
1764
+ };
1765
+ self.write(&format!(" {:?} => {},\n", k.as_ref(), v_expr));
1766
+ }
1767
+ self.write(" _ => tishlang_runtime::Value::Null,\n");
1768
+ self.write(" }\n }\n");
1769
+
1770
+ // tish_set: field name + boxed Value → coerce into the native field (unknown key = no-op).
1771
+ self.write(" fn tish_set(&mut self, __k: &str, __v: tishlang_runtime::Value) {\n");
1772
+ self.write(" match __k {\n");
1773
+ for (k, ty) in fields {
1774
+ let coerced = if matches!(ty, crate::types::RustType::Value) {
1775
+ "__v".to_string()
1776
+ } else {
1777
+ ty.from_value_expr("__v")
1778
+ };
1779
+ self.write(&format!(
1780
+ " {:?} => {{ self.{} = {}; }},\n",
1781
+ k.as_ref(),
1782
+ crate::types::field_ident(k),
1783
+ coerced
1784
+ ));
1785
+ }
1786
+ self.write(" _ => {},\n");
1787
+ self.write(" }\n }\n");
1788
+
1789
+ // tish_to_object: ordered `object_from_pairs` (JS insertion order) — the materialisation path.
1790
+ self.write(" fn tish_to_object(&self) -> tishlang_runtime::Value {\n");
1791
+ self.write(&format!(
1792
+ " {}\n",
1793
+ crate::types::named_struct_to_object_expr(fields, "self")
1794
+ ));
1795
+ self.write(" }\n");
1796
+
1797
+ self.write(" fn as_any(&self) -> &dyn core::any::Any { self }\n");
1798
+ self.write("}\n\n");
1799
+ }
1800
+
1686
1801
  /// #315: JSON-simple = serializable by a direct per-struct writer that is byte-identical
1687
1802
  /// to node's `JSON.stringify` (reuses `write_json_number` / `escape_into`). Whitelist, so
1688
1803
  /// any future RustType variant defaults to the sound Value-path fallback.
@@ -1794,6 +1909,100 @@ impl Codegen {
1794
1909
 
1795
1910
  /// #315: direct JSON serializer for `JSON.stringify(arg)` when `arg`'s static type is a
1796
1911
  /// simple struct (or Vec of one). Returns None → fall through to the generic Value path.
1912
+ /// A boxed `Value` of a struct field: a `Value`-typed field is cloned out of the borrow; a native
1913
+ /// field boxes through its own `to_value_expr` (which copies/clones internally). `place` is the
1914
+ /// borrowed struct expression (e.g. `_s`), so the access is `place.field`.
1915
+ fn struct_field_to_value(place: &str, k: &std::sync::Arc<str>, fty: &crate::types::RustType) -> String {
1916
+ let access = format!("{}.{}", place, crate::types::field_ident(k));
1917
+ if matches!(fty, crate::types::RustType::Value) {
1918
+ format!("{}.clone()", access)
1919
+ } else {
1920
+ fty.to_value_expr(&access)
1921
+ }
1922
+ }
1923
+
1924
+ /// Static-type-aware reflection: `Object.keys(s)` / `Object.values(s)` / `Object.entries(s)` where
1925
+ /// `s`'s static type is a known struct (`RustType::Named`). Reads the struct's fields NATIVELY, so
1926
+ /// the struct is never boxed to a `Value::Struct` (GBA) / hashmap object (elsewhere) and no
1927
+ /// intermediate object is built — only the RESULT array is a `Value` (that is the op's output, and
1928
+ /// unavoidable). Keys come from the compile-time field list; values are native field reads. Returns
1929
+ /// `None` (→ the generic boxed `Object.*` path) for a non-struct arg, a spread/multi arg, or a
1930
+ /// dynamic key. This is target-agnostic: the `TishStruct_*` Rust struct exists on every native
1931
+ /// backend, so the same native reads apply on GBA and desktop alike.
1932
+ fn try_emit_native_object_reflection(
1933
+ &mut self,
1934
+ callee: &Expr,
1935
+ args: &[CallArg],
1936
+ ) -> Result<Option<String>, CompileError> {
1937
+ use crate::types::RustType;
1938
+ // GBA only. Desktop keeps reflection on the existing boxed path — it's already correct there
1939
+ // (a struct materialises to a real `Value::Object`), and gating avoids any churn to desktop
1940
+ // benches / codegen-string tests. GBA is where avoiding the `Value::Struct` box + hashmap
1941
+ // actually matters. The native reads are target-agnostic, so this path was validated by
1942
+ // running it on desktop before the gate went in.
1943
+ if self.emit_mode != crate::NativeEmitMode::Gba {
1944
+ return Ok(None);
1945
+ }
1946
+ let Expr::Member {
1947
+ object,
1948
+ prop: MemberProp::Name { name: m, .. },
1949
+ optional: false,
1950
+ ..
1951
+ } = callee
1952
+ else {
1953
+ return Ok(None);
1954
+ };
1955
+ if !matches!(object.as_ref(), Expr::Ident { name, .. } if name.as_ref() == "Object") {
1956
+ return Ok(None);
1957
+ }
1958
+ let kind = m.as_ref();
1959
+ if !matches!(kind, "keys" | "values" | "entries") || args.len() != 1 {
1960
+ return Ok(None);
1961
+ }
1962
+ let CallArg::Expr(arg0) = &args[0] else {
1963
+ return Ok(None);
1964
+ };
1965
+ let (code, ty) = self.emit_typed_expr(arg0)?;
1966
+ let RustType::Named { fields, .. } = &ty else {
1967
+ return Ok(None);
1968
+ };
1969
+ let out = match kind {
1970
+ // Keys are the compile-time field names; the struct is evaluated only for side effects.
1971
+ "keys" => {
1972
+ let keys = fields
1973
+ .iter()
1974
+ .map(|(k, _)| format!("Value::String({:?}.into())", k.as_ref()))
1975
+ .collect::<Vec<_>>()
1976
+ .join(", ");
1977
+ format!("{{ let _ = &({}); Value::Array(VmRef::new(vec![{}])) }}", code, keys)
1978
+ }
1979
+ "values" => {
1980
+ let vals = fields
1981
+ .iter()
1982
+ .map(|(k, fty)| Self::struct_field_to_value("_s", k, fty))
1983
+ .collect::<Vec<_>>()
1984
+ .join(", ");
1985
+ format!("{{ let _s = &({}); Value::Array(VmRef::new(vec![{}])) }}", code, vals)
1986
+ }
1987
+ "entries" => {
1988
+ let pairs = fields
1989
+ .iter()
1990
+ .map(|(k, fty)| {
1991
+ format!(
1992
+ "Value::Array(VmRef::new(vec![Value::String({:?}.into()), {}]))",
1993
+ k.as_ref(),
1994
+ Self::struct_field_to_value("_s", k, fty)
1995
+ )
1996
+ })
1997
+ .collect::<Vec<_>>()
1998
+ .join(", ");
1999
+ format!("{{ let _s = &({}); Value::Array(VmRef::new(vec![{}])) }}", code, pairs)
2000
+ }
2001
+ _ => unreachable!("kind checked above"),
2002
+ };
2003
+ Ok(Some(out))
2004
+ }
2005
+
1797
2006
  fn try_emit_direct_json_stringify(
1798
2007
  &mut self,
1799
2008
  arg: &Expr,
@@ -1839,6 +2048,48 @@ impl Codegen {
1839
2048
  }
1840
2049
  }
1841
2050
 
2051
+ /// The Rust expression that CONSTRUCTS a native module's namespace object. O(exports): it inserts
2052
+ /// every function the crate exports into a fresh map. Call it once per module, not once per import.
2053
+ fn native_module_ns_ctor(init: &crate::resolve::NativeModuleInit) -> String {
2054
+ match init {
2055
+ crate::resolve::NativeModuleInit::Legacy {
2056
+ crate_name,
2057
+ export_fn,
2058
+ } => format!("{}::{}()", crate_name, export_fn),
2059
+ crate::resolve::NativeModuleInit::Generated { export_fn, .. } => {
2060
+ format!("crate::generated_native::{}()", export_fn)
2061
+ }
2062
+ }
2063
+ }
2064
+
2065
+ /// Bind every native module's namespace object to a `run()`-local, ONCE, before any import reads
2066
+ /// from one.
2067
+ ///
2068
+ /// Each named import lowers to "get this key out of the module's namespace". Building that
2069
+ /// namespace is O(exports) — a fresh `ObjectMap` with an `Arc<str>` key and a native closure per
2070
+ /// exported function — so doing it at every import site is O(imports x exports): importing 68
2071
+ /// names from a crate that exports 68 cost 4,624 allocations to bind 68 functions. On a GBA that
2072
+ /// was measured at 3 seconds of black screen (`tish-gba/examples/bench-boot`), paid before the
2073
+ /// program's first statement; `packages/engine.tish` there imports 117 symbols across two crates
2074
+ /// and cost 4.3s. Hoisting makes it O(imports + exports).
2075
+ ///
2076
+ /// Emitted in sorted order so the generated Rust is byte-identical across builds (the map is a
2077
+ /// `HashMap`, whose iteration order is not).
2078
+ fn emit_native_namespace_preamble(&mut self) {
2079
+ let mut specs: Vec<String> = self.native_module_init.keys().cloned().collect();
2080
+ specs.sort();
2081
+ for (i, spec) in specs.iter().enumerate() {
2082
+ // Index rather than a sanitized spec: `cargo:a-b` and `cargo:a_b` sanitize to the same
2083
+ // identifier, and silently binding one module's namespace under another's name would be a
2084
+ // very quiet bug.
2085
+ let local = format!("__tish_native_ns_{}", i);
2086
+ let ctor = Self::native_module_ns_ctor(&self.native_module_init[spec]);
2087
+ // A module can be resolved but have every one of its imports shadowed or unused.
2088
+ self.writeln(&format!("#[allow(unused_variables)] let {} = {};", local, ctor));
2089
+ self.native_ns_locals.insert(spec.clone(), local);
2090
+ }
2091
+ }
2092
+
1842
2093
  /// Map native module spec to Rust init expression using resolved package.json modules.
1843
2094
  /// For built-in modules (tish:fs, tish:http, tish:process), use builtin_native_module_rust_init.
1844
2095
  fn native_module_rust_init(&self, spec: &str, export_name: &str) -> Option<String> {
@@ -1848,19 +2099,18 @@ impl Codegen {
1848
2099
  self.native_module_init.get(spec).map(|init| {
1849
2100
  // Native modules return a namespace object (like an ES module).
1850
2101
  // Named imports extract the field from that namespace: `import { foo } from "pkg"` → `ns.foo`.
1851
- let init_expr = match init {
1852
- crate::resolve::NativeModuleInit::Legacy {
1853
- crate_name,
1854
- export_fn,
1855
- } => format!("{}::{}()", crate_name, export_fn),
1856
- crate::resolve::NativeModuleInit::Generated { export_fn, .. } => {
1857
- format!("crate::generated_native::{}()", export_fn)
1858
- }
1859
- };
1860
- format!(
1861
- "{{ let _ns = {}; match _ns {{ Value::Object(ref _o) => _o.borrow().strings.get({:?}).cloned().unwrap_or(Value::Null), _ => Value::Null }} }}",
1862
- init_expr, export_name
1863
- )
2102
+ // Read it from the hoisted local when there is one; the inline constructor is the fallback
2103
+ // for anything emitted outside `run()`'s preamble.
2104
+ match self.native_ns_locals.get(spec) {
2105
+ Some(local) => format!(
2106
+ "{{ match &{} {{ Value::Object(ref _o) => _o.borrow().strings.get({:?}).cloned().unwrap_or(Value::Null), _ => Value::Null }} }}",
2107
+ local, export_name
2108
+ ),
2109
+ None => format!(
2110
+ "{{ let _ns = {}; match _ns {{ Value::Object(ref _o) => _o.borrow().strings.get({:?}).cloned().unwrap_or(Value::Null), _ => Value::Null }} }}",
2111
+ Self::native_module_ns_ctor(init), export_name
2112
+ ),
2113
+ }
1864
2114
  })
1865
2115
  }
1866
2116
 
@@ -1893,6 +2143,7 @@ impl Codegen {
1893
2143
  "realpath" | "realpathSync" => Some("Value::native(|args: &[Value]| tishlang_runtime::fs_ext::realpath(args))"),
1894
2144
  "readlink" | "readlinkSync" => Some("Value::native(|args: &[Value]| tishlang_runtime::fs_ext::readlink(args))"),
1895
2145
  "truncate" | "truncateSync" => Some("Value::native(|args: &[Value]| tishlang_runtime::fs_ext::truncate(args))"),
2146
+ "chmod" | "chmodSync" => Some("Value::native(|args: &[Value]| tishlang_runtime::fs_ext::chmod(args))"),
1896
2147
  "mkdtemp" | "mkdtempSync" => Some("Value::native(|args: &[Value]| tishlang_runtime::fs_ext::mkdtemp(args))"),
1897
2148
  "cp" | "cpSync" => Some("Value::native(|args: &[Value]| tishlang_runtime::fs_ext::cp(args))"),
1898
2149
  "access" | "accessSync" => Some("Value::native(|args: &[Value]| tishlang_runtime::fs_ext::access(args))"),
@@ -1916,6 +2167,7 @@ impl Codegen {
1916
2167
  "realpath" => Some("Value::native(|args: &[Value]| tishlang_runtime::fs_ext::realpath_promise(args))"),
1917
2168
  "readlink" => Some("Value::native(|args: &[Value]| tishlang_runtime::fs_ext::readlink_promise(args))"),
1918
2169
  "truncate" => Some("Value::native(|args: &[Value]| tishlang_runtime::fs_ext::truncate_promise(args))"),
2170
+ "chmod" => Some("Value::native(|args: &[Value]| tishlang_runtime::fs_ext::chmod_promise(args))"),
1919
2171
  "mkdtemp" => Some("Value::native(|args: &[Value]| tishlang_runtime::fs_ext::mkdtemp_promise(args))"),
1920
2172
  "cp" => Some("Value::native(|args: &[Value]| tishlang_runtime::fs_ext::cp_promise(args))"),
1921
2173
  "access" => Some("Value::native(|args: &[Value]| tishlang_runtime::fs_ext::access_promise(args))"),
@@ -1950,9 +2202,20 @@ impl Codegen {
1950
2202
  "execFile" => Some("Value::native(|args: &[Value]| tish_process_exec_file(args))"),
1951
2203
  "execCapture" => Some("Value::native(|args: &[Value]| tish_process_exec_capture(args))"),
1952
2204
  "execFileCapture" => Some("Value::native(|args: &[Value]| tish_process_exec_file_capture(args))"),
2205
+ "spawn" => Some("Value::native(|args: &[Value]| tishlang_runtime::process_spawn(args))"),
2206
+ "readStdout" => Some("Value::native(|args: &[Value]| tishlang_runtime::process_read_stdout(args))"),
2207
+ "readStderr" => Some("Value::native(|args: &[Value]| tishlang_runtime::process_read_stderr(args))"),
2208
+ "writeStdin" => Some("Value::native(|args: &[Value]| tishlang_runtime::process_write_stdin(args))"),
2209
+ "closeStdin" => Some("Value::native(|args: &[Value]| tishlang_runtime::process_close_stdin(args))"),
2210
+ "wait" => Some("Value::native(|args: &[Value]| tishlang_runtime::process_wait(args))"),
2211
+ "kill" => Some("Value::native(|args: &[Value]| tishlang_runtime::process_kill(args))"),
2212
+ "pid" => Some("Value::native(|args: &[Value]| tishlang_runtime::process_pid(args))"),
1953
2213
  "argv" => Some("Value::Array(VmRef::new(std::env::args().map(|s| Value::String(s.into())).collect()))"),
1954
2214
  "env" => Some("Value::object(std::env::vars().map(|(k,v)| (Arc::from(k.as_str()), Value::String(v.into()))).collect())"),
1955
- "process" => Some("{ let mut m = ObjectMap::default(); m.insert(Arc::from(\"exit\"), Value::native(|args: &[Value]| tish_process_exit(args))); m.insert(Arc::from(\"cwd\"), Value::native(|args: &[Value]| tish_process_cwd(args))); m.insert(Arc::from(\"exec\"), Value::native(|args: &[Value]| tish_process_exec(args))); m.insert(Arc::from(\"execFile\"), Value::native(|args: &[Value]| tish_process_exec_file(args))); m.insert(Arc::from(\"execCapture\"), Value::native(|args: &[Value]| tish_process_exec_capture(args))); m.insert(Arc::from(\"execFileCapture\"), Value::native(|args: &[Value]| tish_process_exec_file_capture(args))); m.insert(Arc::from(\"argv\"), Value::Array(VmRef::new(std::env::args().map(|s| Value::String(s.into())).collect()))); m.insert(Arc::from(\"env\"), Value::object(std::env::vars().map(|(k,v)| (Arc::from(k.as_str()), Value::String(v.into()))).collect::<ObjectMap>())); Value::object(m) }"),
2215
+ // Node-style platform string so `process.platform` reads identically on the JS
2216
+ // target (Node's real global) and native (this): macos->darwin, windows->win32.
2217
+ "platform" => Some("Value::String(match std::env::consts::OS { \"macos\" => \"darwin\", \"windows\" => \"win32\", o => o }.into())"),
2218
+ "process" => Some("{ let mut m = ObjectMap::default(); m.insert(Arc::from(\"exit\"), Value::native(|args: &[Value]| tish_process_exit(args))); m.insert(Arc::from(\"cwd\"), Value::native(|args: &[Value]| tish_process_cwd(args))); m.insert(Arc::from(\"exec\"), Value::native(|args: &[Value]| tish_process_exec(args))); m.insert(Arc::from(\"execFile\"), Value::native(|args: &[Value]| tish_process_exec_file(args))); m.insert(Arc::from(\"execCapture\"), Value::native(|args: &[Value]| tish_process_exec_capture(args))); m.insert(Arc::from(\"execFileCapture\"), Value::native(|args: &[Value]| tish_process_exec_file_capture(args))); m.insert(Arc::from(\"argv\"), Value::Array(VmRef::new(std::env::args().map(|s| Value::String(s.into())).collect()))); m.insert(Arc::from(\"env\"), Value::object(std::env::vars().map(|(k,v)| (Arc::from(k.as_str()), Value::String(v.into()))).collect::<ObjectMap>())); m.insert(Arc::from(\"platform\"), Value::String(match std::env::consts::OS { \"macos\" => \"darwin\", \"windows\" => \"win32\", o => o }.into())); Value::object(m) }"),
1956
2219
  _ => None,
1957
2220
  },
1958
2221
  "tish:ws" if self.has_feature("ws") => match export_name {
@@ -1982,6 +2245,37 @@ impl Codegen {
1982
2245
  "pid" => Some("Value::native(|args: &[Value]| tishlang_runtime::pty_pid(args))"),
1983
2246
  _ => None,
1984
2247
  },
2248
+ "tish:net" if self.has_feature("net") => match export_name {
2249
+ "connect" => Some("Value::native(|args: &[Value]| tishlang_runtime::net_connect(args))"),
2250
+ "read" => Some("Value::native(|args: &[Value]| tishlang_runtime::net_read(args))"),
2251
+ "write" => Some("Value::native(|args: &[Value]| tishlang_runtime::net_write(args))"),
2252
+ "close" => Some("Value::native(|args: &[Value]| tishlang_runtime::net_close(args))"),
2253
+ "listen" => Some("Value::native(|args: &[Value]| tishlang_runtime::net_listen(args))"),
2254
+ "accept" => Some("Value::native(|args: &[Value]| tishlang_runtime::net_accept(args))"),
2255
+ "probe" => Some("Value::native(|args: &[Value]| tishlang_runtime::net_probe(args))"),
2256
+ "sleep" => Some("Value::native(|args: &[Value]| tishlang_runtime::net_sleep(args))"),
2257
+ _ => None,
2258
+ },
2259
+ "tish:encoding" if self.has_feature("encoding") => match export_name {
2260
+ "base64Encode" => Some("Value::native(|args: &[Value]| tishlang_runtime::base64_encode(args))"),
2261
+ "base64Decode" => Some("Value::native(|args: &[Value]| tishlang_runtime::base64_decode(args))"),
2262
+ "base64UrlEncode" => Some("Value::native(|args: &[Value]| tishlang_runtime::base64_url_encode(args))"),
2263
+ "base64UrlDecode" => Some("Value::native(|args: &[Value]| tishlang_runtime::base64_url_decode(args))"),
2264
+ "utf8Decode" => Some("Value::native(|args: &[Value]| tishlang_runtime::utf8_decode(args))"),
2265
+ "utf8Encode" => Some("Value::native(|args: &[Value]| tishlang_runtime::utf8_encode(args))"),
2266
+ _ => None,
2267
+ },
2268
+ "tish:crypto" if self.has_feature("crypto") => match export_name {
2269
+ "sha256" => Some("Value::native(|args: &[Value]| tishlang_runtime::sha256(args))"),
2270
+ "sha256Hex" => Some("Value::native(|args: &[Value]| tishlang_runtime::sha256_hex(args))"),
2271
+ "randomBytes" => Some("Value::native(|args: &[Value]| tishlang_runtime::random_bytes(args))"),
2272
+ _ => None,
2273
+ },
2274
+ "tish:zip" if self.has_feature("zip") => match export_name {
2275
+ "extract" => Some("Value::native(|args: &[Value]| tishlang_runtime::zip_extract(args))"),
2276
+ "entries" => Some("Value::native(|args: &[Value]| tishlang_runtime::zip_entries(args))"),
2277
+ _ => None,
2278
+ },
1985
2279
  _ => return None,
1986
2280
  };
1987
2281
  init.map(String::from)
@@ -1991,7 +2285,8 @@ impl Codegen {
1991
2285
  if self.features.contains("full") {
1992
2286
  matches!(
1993
2287
  name,
1994
- "http" | "timers" | "fs" | "process" | "regex" | "ws" | "tty" | "pty"
2288
+ "http" | "timers" | "fs" | "process" | "regex" | "ws" | "tty" | "pty" | "net"
2289
+ | "encoding" | "crypto" | "zip"
1995
2290
  )
1996
2291
  } else {
1997
2292
  self.features.contains(name)
@@ -2502,8 +2797,117 @@ impl Codegen {
2502
2797
  out
2503
2798
  }
2504
2799
 
2800
+ /// Typed externs (#perf): pair each top-level `declare fun name(a: i32, …): T` with a native
2801
+ /// import of the same name (which supplies the crate) → an [`ExternSig`] in `extern_fns`. A later
2802
+ /// call to `name` with lowerable args emits a DIRECT `<crate>::name_typed(..)` instead of a boxed
2803
+ /// `value_call`. Only fully-typed simple-param signatures qualify; anything else stays boxed.
2804
+ fn collect_extern_fns(&mut self, program: &Program) {
2805
+ use std::collections::HashMap;
2806
+ // Resolve lowers each `import { x } from 'cargo:…'` to `let x = NativeModuleLoad{spec, export}`,
2807
+ // so pair off THOSE bindings (the `Import` statement itself is gone by codegen): local name ->
2808
+ // (module spec, exported Rust fn name). Only a native module with a known init can back a call.
2809
+ let mut name_info: HashMap<String, (String, String)> = HashMap::new();
2810
+ for stmt in &program.statements {
2811
+ if let Statement::VarDecl {
2812
+ name,
2813
+ init: Some(Expr::NativeModuleLoad { spec, export_name, .. }),
2814
+ ..
2815
+ } = stmt
2816
+ {
2817
+ if self.native_module_init.contains_key(spec.as_ref()) {
2818
+ name_info.insert(
2819
+ name.to_string(),
2820
+ (spec.to_string(), export_name.to_string()),
2821
+ );
2822
+ }
2823
+ }
2824
+ }
2825
+ if name_info.is_empty() {
2826
+ return;
2827
+ }
2828
+ for stmt in &program.statements {
2829
+ let Statement::DeclareFun {
2830
+ name,
2831
+ params,
2832
+ rest_param: None,
2833
+ return_type,
2834
+ async_: false,
2835
+ ..
2836
+ } = stmt
2837
+ else {
2838
+ continue;
2839
+ };
2840
+ let Some((spec, export_name)) = name_info.get(name.as_ref()) else {
2841
+ continue;
2842
+ };
2843
+ let crate_name = match self.native_module_init.get(spec) {
2844
+ Some(init) => init.crate_name().to_string(),
2845
+ None => continue,
2846
+ };
2847
+ // Every parameter must be a simple, explicitly-typed native scalar; a `Value` param (an
2848
+ // untyped one) means the direct call would gain nothing, so keep the whole fn boxed.
2849
+ let mut ptys = Vec::with_capacity(params.len());
2850
+ let mut ok = true;
2851
+ for p in params {
2852
+ match p {
2853
+ FunParam::Simple(tp) if tp.default.is_none() => match &tp.type_ann {
2854
+ Some(ann) => {
2855
+ let ty = RustType::from_annotation(ann);
2856
+ if ty.is_native() {
2857
+ ptys.push(ty);
2858
+ } else {
2859
+ ok = false;
2860
+ break;
2861
+ }
2862
+ }
2863
+ None => {
2864
+ ok = false;
2865
+ break;
2866
+ }
2867
+ },
2868
+ _ => {
2869
+ ok = false;
2870
+ break;
2871
+ }
2872
+ }
2873
+ }
2874
+ if !ok {
2875
+ continue;
2876
+ }
2877
+ let ret = return_type
2878
+ .as_ref()
2879
+ .map(RustType::from_annotation)
2880
+ .unwrap_or(RustType::Unit);
2881
+ self.extern_fns.insert(
2882
+ name.to_string(),
2883
+ ExternSig { crate_name, symbol: export_name.clone(), params: ptys, ret },
2884
+ );
2885
+ }
2886
+ }
2887
+
2888
+ /// Record every top-level `function f(...): Iface` whose return annotation resolves — via the type
2889
+ /// aliases — to a native STRUCT (`RustType::Named`, or a `ShapeUnion`). A later call to `f` in a
2890
+ /// typed position then coerces the boxed result to the native struct (a `Value::Struct` downcast on
2891
+ /// GBA, the `get_prop` rebuild elsewhere), so `f().field` reads are native, not boxed. Runs AFTER
2892
+ /// `collect_type_aliases` so an interface name in the annotation resolves to its `Named` type.
2893
+ fn collect_user_fn_returns(&mut self, program: &Program) {
2894
+ for stmt in &program.statements {
2895
+ if let Statement::FunDecl { name, return_type: Some(ann), .. } = stmt {
2896
+ let ty = RustType::from_annotation_with_aliases(ann, &self.type_aliases);
2897
+ // Only a concrete `Named` struct return is coerced. `ShapeUnion` is deliberately
2898
+ // excluded: its `from_value_expr` is a forbidden boundary (`unreachable!()`), so
2899
+ // admitting it here would compile a runtime panic into the coercion site. (A shape
2900
+ // union only ever arises as an array element type, never a bare fn return, so this
2901
+ // is a defensive guard against a violated invariant, not a live path.)
2902
+ if matches!(ty, RustType::Named { .. }) {
2903
+ self.user_fn_returns.insert(name.to_string(), ty);
2904
+ }
2905
+ }
2906
+ }
2907
+ }
2908
+
2505
2909
  fn emit_program(&mut self, program: &Program) -> Result<(), CompileError> {
2506
- // Only GBA `font<N>:`/`emoji:` schemes consume `{charset}` (and both `render_template`
2910
+ // Only GBA `font:`/`emoji:` schemes consume `{charset}` (and both `render_template`
2507
2911
  // callers that read `string_charset` are Gba-gated), so skip the whole-program string walk
2508
2912
  // for every other target — its result is never read off-GBA.
2509
2913
  if self.emit_mode == crate::NativeEmitMode::Gba {
@@ -2513,6 +2917,7 @@ impl Codegen {
2513
2917
  self.program_has_jsx = tishlang_ui::jsx::program_contains_jsx(program);
2514
2918
  self.program_fun_decl_names = tishlang_ui::jsx::collect_fun_decl_names(program);
2515
2919
  self.program_uses_document = crate::resolve::program_uses_document(program);
2920
+ self.collect_extern_fns(program);
2516
2921
  if self.emit_mode == crate::NativeEmitMode::Gba {
2517
2922
  // no_std GBA ROM: alloc-based, Arc aliases to Rc in the facade. The
2518
2923
  // prelude `use tishlang_runtime::{…}` below is unchanged — those names
@@ -2584,6 +2989,9 @@ impl Codegen {
2584
2989
  // are stored too, so later annotations like `let x: N = 0` still
2585
2990
  // pick up the right native type.
2586
2991
  self.collect_type_aliases(&program.statements);
2992
+ // Now that the interface/type-alias table is built, record user functions that return a native
2993
+ // struct, so a typed call to one keeps the result native (`f().field` = a Rust field read).
2994
+ self.collect_user_fn_returns(program);
2587
2995
  // Emit a Rust `struct` for every alias whose RHS is an object
2588
2996
  // shape. Subsequent `let x: Foo = ...` literals lower to plain
2589
2997
  // struct moves (no `VmRef::new(ObjectMap::from(..))` allocation),
@@ -2770,6 +3178,9 @@ impl Codegen {
2770
3178
  }
2771
3179
  self.indent += 1;
2772
3180
 
3181
+ // Before anything else: one namespace object per native module, shared by all of its imports.
3182
+ self.emit_native_namespace_preamble();
3183
+
2773
3184
  // Initialize builtins
2774
3185
  self.writeln("let mut console = Value::object(ObjectMap::from([");
2775
3186
  self.indent += 1;
@@ -3013,6 +3424,9 @@ impl Codegen {
3013
3424
  self.indent -= 1;
3014
3425
  self.writeln("}");
3015
3426
  self.writeln("p.insert(Arc::from(\"env\"), Value::object(env_obj));");
3427
+ // Node-style platform string (macos->darwin, windows->win32) so bare `process.platform`
3428
+ // matches the JS target's Node global and the named `tish:process` export.
3429
+ self.writeln("p.insert(Arc::from(\"platform\"), Value::String(match std::env::consts::OS { \"macos\" => \"darwin\", \"windows\" => \"win32\", o => o }.into()));");
3016
3430
  self.writeln("p");
3017
3431
  self.indent -= 1;
3018
3432
  self.writeln("});");
@@ -3262,6 +3676,15 @@ impl Codegen {
3262
3676
  return Ok(code);
3263
3677
  }
3264
3678
  }
3679
+ // #558: `arr[i].field = v` / `s.field = v` on a typed struct → a native element store, not a
3680
+ // discarded set_prop on a copy. Side-effect position (statement), so no value is produced.
3681
+ if let Expr::MemberAssign { object, prop, value, .. } = expr {
3682
+ if let Some(code) =
3683
+ self.try_emit_native_struct_field_assign(object, prop.as_ref(), value, true)?
3684
+ {
3685
+ return Ok(code);
3686
+ }
3687
+ }
3265
3688
  match expr {
3266
3689
  Expr::Assign { name, value, .. } => {
3267
3690
  if self.native_numeric_globals.contains_key(name.as_ref()) {
@@ -3285,9 +3708,17 @@ impl Codegen {
3285
3708
  let escaped = Self::escape_ident(name.as_ref());
3286
3709
  return Ok(format!("{} = ({}) as i32", escaped, int_code));
3287
3710
  }
3711
+ let (val_code, val_ty) = self.emit_typed_expr(value)?;
3712
+ // An RHS that is ALREADY an integer stores straight into the register. `d = d + 1`
3713
+ // reaches here — it is arithmetic, not a bitwise chain, so `emit_int32_operand`
3714
+ // declines it — and boxing that i32 into a `Value::Number` only to run ToInt32 back
3715
+ // out of it was the whole cost of incrementing an annotated counter.
3716
+ let escaped_int = Self::escape_ident(name.as_ref());
3717
+ if val_ty.is_integer_scalar() {
3718
+ return Ok(format!("{} = ({}) as i32", escaped_int, val_code));
3719
+ }
3288
3720
  // Defensive: gate guarantees `Some`, but if a future RHS shape slips through,
3289
3721
  // fall back to a sound f64-narrowed store rather than miscompiling.
3290
- let (val_code, val_ty) = self.emit_typed_expr(value)?;
3291
3722
  let v = if val_ty.is_native() {
3292
3723
  val_ty.to_value_expr(&val_code)
3293
3724
  } else {
@@ -3360,6 +3791,11 @@ impl Codegen {
3360
3791
  let (val_code, val_ty) = self.emit_typed_expr(value)?;
3361
3792
  let native_val = if val_ty == RustType::Value {
3362
3793
  rust_type.from_value_expr(&val_code)
3794
+ } else if rust_type == RustType::Fixed && val_ty == RustType::F64 {
3795
+ // an f64 (an int/float literal like `nx = 0`, or a promoted int) assigned to a
3796
+ // `fixed` local must be lifted to `Fixed` — the RHS emitter reports numeric
3797
+ // literals as F64, so without this a `fixed` reassignment mistypes (E0308).
3798
+ format!("tishlang_runtime::Fixed::from_raw((({}) * 256.0) as i32)", val_code)
3363
3799
  } else {
3364
3800
  val_code
3365
3801
  };
@@ -6322,7 +6758,8 @@ impl Codegen {
6322
6758
  matches!(
6323
6759
  t,
6324
6760
  RustType::F64 | RustType::Bool | RustType::String
6325
- )
6761
+ | RustType::Fixed | RustType::I32
6762
+ ) || t.is_narrow_int()
6326
6763
  })
6327
6764
  } else {
6328
6765
  None
@@ -6430,6 +6867,21 @@ impl Codegen {
6430
6867
  for v in &read_only_outer_vars {
6431
6868
  self.refcell_wrapped_vars.remove(v);
6432
6869
  }
6870
+ // tishlang/tish#556: a PARAMETER (or the rest param) SHADOWS any outer var of the
6871
+ // same name — inside the body it is a plain `let`-bound `Value`/native, never the
6872
+ // outer `VmRef` cell. Drop such names from the wrapped set for the body, so a
6873
+ // reference to the param isn't emitted as `vm_read(&param)` (which won't compile —
6874
+ // the param is a `Value`, not a `VmRef`). `saved_refcell` restores the outer view
6875
+ // after the body; a body-local `let` that a nested closure captures is re-added by
6876
+ // the `body_cell_vars` prepass below, so its cell handling is unaffected.
6877
+ for p in params {
6878
+ for bn in p.bound_names() {
6879
+ self.refcell_wrapped_vars.remove(bn.as_ref());
6880
+ }
6881
+ }
6882
+ if let Some(rest) = rest_param {
6883
+ self.refcell_wrapped_vars.remove(rest.name.as_ref());
6884
+ }
6433
6885
 
6434
6886
  // Pre-scan body for nested functions (handles function body as Block)
6435
6887
  if let Statement::Block { statements, .. } = body.as_ref() {
@@ -7153,6 +7605,12 @@ impl Codegen {
7153
7605
  }
7154
7606
  }
7155
7607
  }
7608
+ // Static-type-aware reflection: `Object.keys/values/entries(s)` on a statically-known
7609
+ // struct → native field reads, never boxing the struct or building an intermediate
7610
+ // object. Falls through to the generic boxed `Object.*` path for a non-struct arg.
7611
+ if let Some(code) = self.try_emit_native_object_reflection(callee, args)? {
7612
+ return Ok(code);
7613
+ }
7156
7614
  // #178: route a boxed-context call to a recursive-struct consumer
7157
7615
  // (`sum(build(n))` / `sum(node)`) → `Value::Number(sum_rec(&..))`.
7158
7616
  if self.rec_struct_plan.is_some() {
@@ -7184,29 +7642,273 @@ impl Codegen {
7184
7642
  // ── native Vec<T> push fast path ──────────────────────────────
7185
7643
  if method_name.as_ref() == "push" {
7186
7644
  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
- }
7645
+ let obj_type = self.type_context.get_type(name.as_ref());
7646
+ if let RustType::Vec(elem_type) = obj_type {
7647
+ let esc_obj = Self::escape_ident(name.as_ref()).into_owned();
7648
+ // A captured (refcell-wrapped) native Vec pushes through
7649
+ // `(*a.borrow_mut())` — WITHOUT this it fell to the boxed `array_push`,
7650
+ // which boxed the WHOLE Vec into a throwaway `Value::Array` (discarding
7651
+ // the push, O(n), and — for a `Vec<struct>` — E0282 on the `from_struct`
7652
+ // boxing closure since the native push never ran to pin the element type).
7653
+ let base = if self.refcell_wrapped_vars.contains(name.as_ref()) {
7654
+ format!("(*{}.borrow_mut())", esc_obj)
7655
+ } else {
7656
+ esc_obj
7657
+ };
7658
+ // Collect push arguments as native values.
7659
+ let mut push_stmts: Vec<String> = Vec::new();
7660
+ for a in args {
7661
+ if let CallArg::Expr(e) = a {
7662
+ // Emit the element AGAINST the Vec's element type so an object
7663
+ // literal hits the struct-literal fast path (direct `Struct { … }`
7664
+ // with each field value emitted natively — zero allocations)
7665
+ // instead of building a boxed `object_from_pairs` and then
7666
+ // `get_prop`-ing every field back out. emit_native_expr falls
7667
+ // back to the typed/value path for non-literal args.
7668
+ let native_val = self.emit_native_expr(e, elem_type.as_ref())?;
7669
+ push_stmts.push(format!("{}.push({});", base, native_val));
7204
7670
  }
7205
- return Ok(format!(
7206
- "{{ {} Value::Null }}",
7207
- push_stmts.join(" ")
7208
- ));
7209
7671
  }
7672
+ // `push` returns the new length (JS / tish interp) — a plain read, no
7673
+ // O(n) boxing. A captured Vec reads its length through `(*a.borrow())`.
7674
+ let base_read = if self.refcell_wrapped_vars.contains(name.as_ref()) {
7675
+ format!("(*{}.borrow())", Self::escape_ident(name.as_ref()))
7676
+ } else {
7677
+ Self::escape_ident(name.as_ref()).into_owned()
7678
+ };
7679
+ return Ok(format!(
7680
+ "{{ {} Value::Number({}.len() as f64) }}",
7681
+ push_stmts.join(" "),
7682
+ base_read
7683
+ ));
7684
+ }
7685
+ }
7686
+ }
7687
+
7688
+ // Native in-place mutators on a native `Vec` (`pop`/`shift`/`unshift`/`reverse`).
7689
+ // The boxed `array_*` fallback operates on a THROWAWAY boxed copy of the Vec, so the
7690
+ // mutation is DISCARDED (O(n), and — for a `Vec<struct>` — an E0282 on the boxing
7691
+ // closure); these mutate the real Vec. A captured Vec goes through `(*a.borrow_mut())`.
7692
+ // Return values match JS / tish interp: pop/shift yield the removed element (boxed) or
7693
+ // Null; unshift yields the new length; reverse yields THE array (boxed copy — a native
7694
+ // `Vec` has no O(1) by-ref boxing like interp's Rc-array, so the contents match but it
7695
+ // is not aliased; reverse is O(n) anyway so the copy is asymptotically free).
7696
+ if matches!(method_name.as_ref(), "pop" | "shift" | "unshift" | "reverse") {
7697
+ if let Expr::Ident { name, .. } = object.as_ref() {
7698
+ if let RustType::Vec(elem_type) =
7699
+ self.type_context.get_type(name.as_ref())
7700
+ {
7701
+ let esc = Self::escape_ident(name.as_ref()).into_owned();
7702
+ let wrapped_v = self.refcell_wrapped_vars.contains(name.as_ref());
7703
+ let base = if wrapped_v {
7704
+ format!("(*{}.borrow_mut())", esc)
7705
+ } else {
7706
+ esc.clone()
7707
+ };
7708
+ // Shared-borrow read (for `reverse`'s result boxing / length reads).
7709
+ let base_read = if wrapped_v {
7710
+ format!("(*{}.borrow())", esc)
7711
+ } else {
7712
+ esc.clone()
7713
+ };
7714
+ // Box a removed element back to a `Value` (`Value` elements pass through).
7715
+ let box_elem = |v: &str| -> String {
7716
+ if matches!(elem_type.as_ref(), RustType::Value) {
7717
+ v.to_string()
7718
+ } else {
7719
+ elem_type.to_value_expr(v)
7720
+ }
7721
+ };
7722
+ let code = match method_name.as_ref() {
7723
+ "pop" => format!(
7724
+ "{{ match {b}.pop() {{ Some(__e) => {v}, None => Value::Null }} }}",
7725
+ b = base,
7726
+ v = box_elem("__e")
7727
+ ),
7728
+ "shift" => format!(
7729
+ "{{ if {b}.is_empty() {{ Value::Null }} else {{ let __e = {b}.remove(0); {v} }} }}",
7730
+ b = base,
7731
+ v = box_elem("__e")
7732
+ ),
7733
+ "unshift" => {
7734
+ // Bind items to temps BEFORE any `borrow_mut` insert (an item may
7735
+ // read the same array), then insert at the front in REVERSE so the
7736
+ // argument order is preserved. Returns the new length.
7737
+ let mut binds = String::new();
7738
+ let mut names: Vec<String> = Vec::new();
7739
+ for (i, a) in args.iter().enumerate() {
7740
+ if let CallArg::Expr(e) = a {
7741
+ let it = self.emit_native_expr(e, elem_type.as_ref())?;
7742
+ binds.push_str(&format!("let __u{} = {}; ", i, it));
7743
+ names.push(format!("__u{}", i));
7744
+ }
7745
+ }
7746
+ let mut ins = String::new();
7747
+ for n in names.iter().rev() {
7748
+ ins.push_str(&format!("{}.insert(0, {}); ", base, n));
7749
+ }
7750
+ format!(
7751
+ "{{ {}{}Value::Number({}.len() as f64) }}",
7752
+ binds, ins, base
7753
+ )
7754
+ }
7755
+ // reverse in place, then return a boxed copy of the array (contents
7756
+ // match interp; see the note above).
7757
+ "reverse" => format!(
7758
+ "{{ {}.reverse(); Value::Array(VmRef::new({}.iter().cloned().map(|__e| {}).collect())) }}",
7759
+ base,
7760
+ base_read,
7761
+ box_elem("__e")
7762
+ ),
7763
+ _ => unreachable!("method checked above"),
7764
+ };
7765
+ return Ok(code);
7766
+ }
7767
+ }
7768
+ }
7769
+
7770
+ // Native `splice`/`fill`/`copyWithin` on a native `Vec`. Like the mutators above,
7771
+ // the boxed fallback would run on a THROWAWAY copy (mutation discarded). These need
7772
+ // several accesses (len + index math + a range op), so a captured Vec binds ONE
7773
+ // `borrow_mut` guard `__g` for the whole body; every argument is evaluated BEFORE the
7774
+ // guard (an arg may read the same array, which would double-borrow the cell). JS index
7775
+ // clamping (negative → from end, out-of-range → clamped) is emitted inline so the
7776
+ // result matches the interpreter.
7777
+ if matches!(method_name.as_ref(), "splice" | "fill" | "copyWithin") {
7778
+ if let Expr::Ident { name, .. } = object.as_ref() {
7779
+ if let RustType::Vec(elem_type) =
7780
+ self.type_context.get_type(name.as_ref())
7781
+ {
7782
+ let esc = Self::escape_ident(name.as_ref()).into_owned();
7783
+ let wrapped = self.refcell_wrapped_vars.contains(name.as_ref());
7784
+ // `p` is the Vec place inside the body: a `borrow_mut` guard when wrapped.
7785
+ let (guard, p) = if wrapped {
7786
+ (format!("let mut __g = {}.borrow_mut(); ", esc), "__g".to_string())
7787
+ } else {
7788
+ (String::new(), esc.clone())
7789
+ };
7790
+ let elem_rust = elem_type.to_rust_type_str();
7791
+ let box_elem = |v: &str| -> String {
7792
+ if matches!(elem_type.as_ref(), RustType::Value) {
7793
+ v.to_string()
7794
+ } else {
7795
+ elem_type.to_value_expr(v)
7796
+ }
7797
+ };
7798
+ // `fill`/`copyWithin` return THE array (JS / tish interp) — a boxed copy
7799
+ // built from the (still-borrowed) place `p` after the mutation. Contents
7800
+ // match interp; not aliased (a native `Vec` has no O(1) by-ref boxing).
7801
+ let arr_box = format!(
7802
+ "Value::Array(VmRef::new({}.iter().cloned().map(|__be| {}).collect()))",
7803
+ p,
7804
+ box_elem("__be")
7805
+ );
7806
+ // f64 arg (bound before the guard) → a clamped `usize` index.
7807
+ let clamp = |v: &str| -> String {
7808
+ format!(
7809
+ "(if {v} < 0.0 {{ (__lenf + {v}).max(0.0) }} else {{ {v}.min(__lenf) }}) as usize"
7810
+ )
7811
+ };
7812
+ let argf = |cg: &mut Self, i: usize| -> Result<Option<String>, CompileError> {
7813
+ match args.get(i) {
7814
+ Some(CallArg::Expr(e)) => {
7815
+ Ok(Some(cg.emit_native_expr(e, &RustType::F64)?))
7816
+ }
7817
+ _ => Ok(None),
7818
+ }
7819
+ };
7820
+ let code = match method_name.as_ref() {
7821
+ "splice" => {
7822
+ let start = argf(self, 0)?.unwrap_or_else(|| "0.0".into());
7823
+ let dc = argf(self, 1)?;
7824
+ // Items to insert (args[2..]) — bound before the guard.
7825
+ let mut item_binds = String::new();
7826
+ let mut item_names: Vec<String> = Vec::new();
7827
+ for (i, a) in args.iter().enumerate().skip(2) {
7828
+ if let CallArg::Expr(e) = a {
7829
+ let it = self.emit_native_expr(e, elem_type.as_ref())?;
7830
+ item_binds.push_str(&format!("let __it{} = {}; ", i, it));
7831
+ item_names.push(format!("__it{}", i));
7832
+ }
7833
+ }
7834
+ let dc_bind = dc
7835
+ .as_ref()
7836
+ .map(|d| format!("let __dc = {}; ", d))
7837
+ .unwrap_or_default();
7838
+ let del_calc = if dc.is_some() {
7839
+ "let __del = if __dc < 0.0 { 0usize } else { (__dc as usize).min(__len - __start) };"
7840
+ } else {
7841
+ "let __del = __len - __start;"
7842
+ };
7843
+ format!(
7844
+ "{{ let __s0 = {start}; {dc_bind}{item_binds}{guard}\
7845
+ let __len = {p}.len(); let __lenf = __len as f64; \
7846
+ let __start = {start_clamp}; {del_calc} \
7847
+ let __items: Vec<{elem_rust}> = vec![{items}]; \
7848
+ let __removed: Vec<{elem_rust}> = {p}.splice(__start..__start + __del, __items).collect(); \
7849
+ Value::Array(VmRef::new(__removed.into_iter().map(|__e| {boxed}).collect())) }}",
7850
+ start_clamp = clamp("__s0"),
7851
+ items = item_names.join(", "),
7852
+ boxed = box_elem("__e"),
7853
+ )
7854
+ }
7855
+ "fill" => {
7856
+ // fill(value, start=0, end=len)
7857
+ let val = match args.first() {
7858
+ Some(CallArg::Expr(e)) => {
7859
+ self.emit_native_expr(e, elem_type.as_ref())?
7860
+ }
7861
+ _ => elem_type.default_value(),
7862
+ };
7863
+ let start = argf(self, 1)?.unwrap_or_else(|| "0.0".into());
7864
+ let end = argf(self, 2)?;
7865
+ format!(
7866
+ "{{ let __v = {val}; let __s0 = {start}; {end}{guard}\
7867
+ let __len = {p}.len(); let __lenf = __len as f64; \
7868
+ let __start = {start_clamp}; {end_clamp}\
7869
+ let mut __i = __start; while __i < __end {{ {p}[__i] = __v.clone(); __i += 1; }} \
7870
+ {arr_box} }}",
7871
+ end = end
7872
+ .as_ref()
7873
+ .map(|e| format!("let __e0 = {}; ", e))
7874
+ .unwrap_or_default(),
7875
+ start_clamp = clamp("__s0"),
7876
+ end_clamp = end
7877
+ .as_ref()
7878
+ .map(|_| format!("let __end = {}; ", clamp("__e0")))
7879
+ .unwrap_or_else(|| "let __end = __len; ".into()),
7880
+ arr_box = arr_box,
7881
+ )
7882
+ }
7883
+ "copyWithin" => {
7884
+ // copyWithin(target, start=0, end=len) — overlap-safe via a temp.
7885
+ let target = argf(self, 0)?.unwrap_or_else(|| "0.0".into());
7886
+ let start = argf(self, 1)?.unwrap_or_else(|| "0.0".into());
7887
+ let end = argf(self, 2)?;
7888
+ format!(
7889
+ "{{ let __t0 = {target}; let __s0 = {start}; {end_bind}{guard}\
7890
+ let __len = {p}.len(); let __lenf = __len as f64; \
7891
+ let __target = {target_clamp}; let __start = {start_clamp}; {end_clamp}\
7892
+ let __count = if __end > __start {{ (__end - __start).min(__len - __target) }} else {{ 0 }}; \
7893
+ let __tmp: Vec<{elem_rust}> = {p}[__start..__start + __count].to_vec(); \
7894
+ let mut __k = 0usize; for __val in __tmp {{ {p}[__target + __k] = __val; __k += 1; }} \
7895
+ {arr_box} }}",
7896
+ end_bind = end
7897
+ .as_ref()
7898
+ .map(|e| format!("let __e0 = {}; ", e))
7899
+ .unwrap_or_default(),
7900
+ target_clamp = clamp("__t0"),
7901
+ start_clamp = clamp("__s0"),
7902
+ end_clamp = end
7903
+ .as_ref()
7904
+ .map(|_| format!("let __end = {}; ", clamp("__e0")))
7905
+ .unwrap_or_else(|| "let __end = __len; ".into()),
7906
+ arr_box = arr_box,
7907
+ )
7908
+ }
7909
+ _ => unreachable!("method checked above"),
7910
+ };
7911
+ return Ok(code);
7210
7912
  }
7211
7913
  }
7212
7914
  }
@@ -7219,35 +7921,122 @@ impl Codegen {
7219
7921
  // sorts via the runtime, and writes the result back into the Vec (correct).
7220
7922
  if method_name.as_ref() == "sort" {
7221
7923
  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
- }
7924
+ if let RustType::Vec(elem_type) =
7925
+ self.type_context.get_type(name.as_ref())
7926
+ {
7927
+ if matches!(elem_type.as_ref(), RustType::Named { .. }) {
7928
+ let esc = Self::escape_ident(name.as_ref()).into_owned();
7929
+ // A captured (refcell-wrapped) Vec sorts through the shared cell:
7930
+ // `base_mut` for the in-place `sort_by`, `base_read` for the boxing
7931
+ // scan, `assign` for the general-comparator write-back.
7932
+ let wrapped = self.refcell_wrapped_vars.contains(name.as_ref());
7933
+ let base_mut = if wrapped {
7934
+ format!("(*{}.borrow_mut())", esc)
7935
+ } else {
7936
+ esc.clone()
7937
+ };
7938
+ let base_read = if wrapped {
7939
+ format!("(*{}.borrow())", esc)
7940
+ } else {
7941
+ esc.clone()
7942
+ };
7943
+ let assign = if wrapped {
7944
+ format!("*{}.borrow_mut()", esc)
7945
+ } else {
7946
+ esc.clone()
7947
+ };
7948
+ // `sort` returns THE array (JS / tish interp) — a boxed copy of the
7949
+ // now-sorted Vec (contents match interp; not aliased). `sort` is
7950
+ // O(n log n), so the O(n) box is asymptotically free.
7951
+ let sort_box = format!(
7952
+ "Value::Array(VmRef::new({}.iter().cloned().map(|__se| {}).collect()))",
7953
+ base_read,
7954
+ elem_type.to_value_expr("__se")
7955
+ );
7956
+ if let Some(CallArg::Expr(cmp)) = args.first() {
7957
+ if let Some(keys) =
7958
+ Self::detect_lexicographic_key_comparator(cmp)
7959
+ {
7960
+ if keys.iter().all(|(p, _)| p.len() == 1) {
7961
+ let chain = Self::render_struct_sort_chain(&keys);
7962
+ return Ok(format!(
7963
+ "{{ {base_mut}.sort_by(|a, b| {chain}); {sort_box} }}"
7964
+ ));
7238
7965
  }
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
7966
  }
7967
+ // General comparator: box → sort → write back (correct).
7968
+ let cmp_code = self.emit_expr(cmp)?;
7969
+ let to_v = elem_type.to_value_expr("__e");
7970
+ let from_v = elem_type.from_value_expr("__v");
7971
+ return Ok(format!(
7972
+ "{{ let __sv: Vec<Value> = {base_read}.iter().map(|__e| {to_v}).collect(); \
7973
+ let __sorted = tishlang_runtime::array_sort(&Value::Array(VmRef::new(__sv)), Some(&({cmp_code}))); \
7974
+ if let Value::Array(__a) = __sorted {{ {assign} = __a.borrow().iter().map(|__v| {from_v}).collect(); }} \
7975
+ {sort_box} }}"
7976
+ ));
7250
7977
  }
7978
+ } else {
7979
+ // #568: a scalar / `Value` native `Vec` (`number[]`, `string[]`,
7980
+ // …) sorts IN PLACE like interp/vm/JS. Box a copy, sort it via the
7981
+ // SAME runtime fn the boxed path uses (so order is byte-identical),
7982
+ // then WRITE the ordered elements BACK into the native Vec — the
7983
+ // generic boxed dispatch below sorted a throwaway copy and dropped
7984
+ // the write (a silent no-op; only `arr = arr.sort()` worked).
7985
+ // Returns the sorted array (JS / interp). `sort` marks the receiver
7986
+ // assigned (see `collect_assigned_idents_in_expr`), so the Vec is
7987
+ // `mut` and the write-back compiles.
7988
+ let esc = Self::escape_ident(name.as_ref()).into_owned();
7989
+ let wrapped =
7990
+ self.refcell_wrapped_vars.contains(name.as_ref());
7991
+ let base_read = if wrapped {
7992
+ format!("(*{}.borrow())", esc)
7993
+ } else {
7994
+ esc.clone()
7995
+ };
7996
+ let assign = if wrapped {
7997
+ format!("*{}.borrow_mut()", esc)
7998
+ } else {
7999
+ esc.clone()
8000
+ };
8001
+ let to_v = elem_type.to_value_expr("__e");
8002
+ let from_v = elem_type.from_value_expr("__v");
8003
+ // Mirror the boxed path's comparator detection EXACTLY (numeric
8004
+ // fast path → lexicographic keys → general callback → default),
8005
+ // so the sorted order matches the generic path and interp/vm.
8006
+ let sort_call = if let Some(CallArg::Expr(cmp)) = args.first() {
8007
+ if let Some(ascending) =
8008
+ Self::detect_numeric_sort_comparator(cmp)
8009
+ {
8010
+ if ascending {
8011
+ "tishlang_runtime::array_sort_numeric_asc(&__boxed)"
8012
+ .to_string()
8013
+ } else {
8014
+ "tishlang_runtime::array_sort_numeric_desc(&__boxed)"
8015
+ .to_string()
8016
+ }
8017
+ } else if let Some(keys) =
8018
+ Self::detect_lexicographic_key_comparator(cmp)
8019
+ {
8020
+ format!(
8021
+ "tishlang_runtime::array_sort_by_keys(&__boxed, {})",
8022
+ Self::render_sort_keys(&keys)
8023
+ )
8024
+ } else {
8025
+ let cmp_code = self.emit_expr(cmp)?;
8026
+ format!(
8027
+ "tishlang_runtime::array_sort(&__boxed, Some(&({})))",
8028
+ cmp_code
8029
+ )
8030
+ }
8031
+ } else {
8032
+ "tishlang_runtime::array_sort(&__boxed, None)".to_string()
8033
+ };
8034
+ return Ok(format!(
8035
+ "{{ let __boxed = Value::Array(VmRef::new({base_read}.iter().cloned().map(|__e| {to_v}).collect())); \
8036
+ let __sorted = {sort_call}; \
8037
+ if let Value::Array(__a) = &__sorted {{ {assign} = __a.borrow().iter().map(|__v| {from_v}).collect(); }} \
8038
+ __sorted }}"
8039
+ ));
7251
8040
  }
7252
8041
  }
7253
8042
  }
@@ -7872,6 +8661,41 @@ impl Codegen {
7872
8661
  }
7873
8662
  }
7874
8663
 
8664
+ // Typed externs (#perf): a call to a native import with a declared typed signature →
8665
+ // a DIRECT `<crate>::name_typed(coerced args)` call. No `Value` boxing of the args, no
8666
+ // `value_call` dispatch through the namespace object. Falls through to the boxed path
8667
+ // when the arg count/kind doesn't match (a dynamic/spread call stays correct).
8668
+ if let Expr::Ident { name: fname, .. } = callee.as_ref() {
8669
+ if let Some(sig) = self.extern_fns.get(fname.as_ref()).cloned() {
8670
+ if args.len() == sig.params.len()
8671
+ && args.iter().all(|a| matches!(a, CallArg::Expr(_)))
8672
+ {
8673
+ let mut argc: Vec<String> = Vec::with_capacity(args.len());
8674
+ for (a, ty) in args.iter().zip(sig.params.iter()) {
8675
+ if let CallArg::Expr(e) = a {
8676
+ argc.push(self.emit_coerced_native(e, ty)?);
8677
+ }
8678
+ }
8679
+ let call = format!(
8680
+ "{}::{}_typed({})",
8681
+ sig.crate_name,
8682
+ sig.symbol,
8683
+ argc.join(", ")
8684
+ );
8685
+ // This is a Value-producing context: a `void` extern yields `Null`; a
8686
+ // native return is boxed once at the boundary (still far cheaper than the
8687
+ // boxed-args `value_call`); a `Value`-returning extern passes through.
8688
+ return Ok(if sig.ret == RustType::Unit {
8689
+ format!("{{ {}; Value::Null }}", call)
8690
+ } else if sig.ret.is_native() {
8691
+ sig.ret.to_value_expr(&call)
8692
+ } else {
8693
+ call
8694
+ });
8695
+ }
8696
+ }
8697
+ }
8698
+
7875
8699
  // M5: boxed-context call to an eligible native fn → `Value::Number(name_native(..))`.
7876
8700
  if let Expr::Ident { name: fname, .. } = callee.as_ref() {
7877
8701
  if self.native_fns.contains(fname.as_ref()) {
@@ -7950,17 +8774,25 @@ impl Codegen {
7950
8774
  fields.iter().find(|(k, _)| k.as_ref() == prop_name.as_ref())
7951
8775
  {
7952
8776
  let var_esc = Self::escape_ident(var_name.as_ref()).into_owned();
8777
+ // Clone a non-Copy field so reading it doesn't move out of the local
8778
+ // (E0382 when the struct is used again); a Copy scalar reads by value.
8779
+ let clone = if field_ty.is_copy() { "" } else { ".clone()" };
7953
8780
  let access = if self.refcell_wrapped_vars.contains(var_name.as_ref()) {
8781
+ // #567: scope the borrow to its own block so the lock drops
8782
+ // immediately — a `send-values` `Arc<Mutex>` VmRef is NOT
8783
+ // reentrant, so two `.borrow()`s of the same cell alive at once
8784
+ // (e.g. `s.a + s.b` in one expression) would deadlock.
7954
8785
  format!(
7955
- "(*{}.borrow()).{}.clone()",
8786
+ "{{ let __bg = {}.borrow(); (*__bg).{}.clone() }}",
7956
8787
  var_esc,
7957
8788
  crate::types::field_ident(prop_name.as_ref())
7958
8789
  )
7959
8790
  } else {
7960
8791
  format!(
7961
- "{}.{}",
8792
+ "{}.{}{}",
7962
8793
  var_esc,
7963
- crate::types::field_ident(prop_name.as_ref())
8794
+ crate::types::field_ident(prop_name.as_ref()),
8795
+ clone
7964
8796
  )
7965
8797
  };
7966
8798
  // Caller expects a `Value`; wrap.
@@ -7972,24 +8804,90 @@ impl Codegen {
7972
8804
  // Generalize the typed struct-field fast path to `xs[i].field` (array-of-structs):
7973
8805
  // when `object` indexes a `Vec<Named>`, do native struct field access.
7974
8806
  if !optional {
7975
- if let (Expr::Index { .. }, MemberProp::Name { name: prop_name, .. }) =
7976
- (object.as_ref(), prop)
8807
+ if let (
8808
+ Expr::Index { object: io, index: ii, .. },
8809
+ MemberProp::Name { name: prop_name, .. },
8810
+ ) = (object.as_ref(), prop)
7977
8811
  {
8812
+ // A captured (refcell-wrapped) `Vec<Named>` element field read: emit the field
8813
+ // access DIRECTLY on the borrowed element (`(*arr.borrow())[i].field`) — no whole
8814
+ // struct clone, and no whole-Vec boxing. This is the hot-loop `states[id].timer`
8815
+ // case (tish#558 read side). A non-Copy field is still cloned out of the borrow.
8816
+ if let Expr::Ident { name: arr_name, .. } = io.as_ref() {
8817
+ if self.refcell_wrapped_vars.contains(arr_name.as_ref()) {
8818
+ if let RustType::Vec(elem) =
8819
+ self.type_context.get_type(arr_name.as_ref())
8820
+ {
8821
+ if let RustType::Named { fields, .. } = elem.as_ref() {
8822
+ if let Some((_, field_ty)) = fields
8823
+ .iter()
8824
+ .find(|(k, _)| k.as_ref() == prop_name.as_ref())
8825
+ {
8826
+ let field_ty = field_ty.clone();
8827
+ let idx = self.emit_index_usize(ii)?;
8828
+ let esc = Self::escape_ident(arr_name.as_ref());
8829
+ let clone = if field_ty.is_copy() { "" } else { ".clone()" };
8830
+ // #567: bind the index (it may read the same cell, e.g.
8831
+ // `arr[arr.length-1]`) BEFORE taking the borrow, and scope
8832
+ // the borrow to its own block so the non-reentrant
8833
+ // `Arc<Mutex>` lock drops before any sibling read of `arr`
8834
+ // (`arr[i].a + arr[j].b`) — otherwise overlapping borrows
8835
+ // deadlock.
8836
+ let access = format!(
8837
+ "{{ let __bi = {}; let __bg = {}.borrow(); (*__bg)[__bi].{}{} }}",
8838
+ idx,
8839
+ esc,
8840
+ crate::types::field_ident(prop_name.as_ref()),
8841
+ clone
8842
+ );
8843
+ return Ok(field_ty.to_value_expr(&access));
8844
+ }
8845
+ }
8846
+ }
8847
+ }
8848
+ }
7978
8849
  let (obj_code, obj_ty) = self.emit_typed_expr(object)?;
7979
8850
  if let RustType::Named { fields, .. } = &obj_ty {
7980
8851
  if let Some((_, field_ty)) =
7981
8852
  fields.iter().find(|(k, _)| k.as_ref() == prop_name.as_ref())
7982
8853
  {
8854
+ // The element `xs[i]` is BORROWED, so a non-Copy field (a `Value`/
8855
+ // `String`) must be cloned out — otherwise reading it in a value/return
8856
+ // position moves out of the index (E0507). A Copy scalar reads by value.
8857
+ let clone = if field_ty.is_copy() { "" } else { ".clone()" };
7983
8858
  let access = format!(
7984
- "({}).{}",
8859
+ "({}).{}{}",
7985
8860
  obj_code,
7986
- crate::types::field_ident(prop_name.as_ref())
8861
+ crate::types::field_ident(prop_name.as_ref()),
8862
+ clone
7987
8863
  );
7988
8864
  return Ok(field_ty.to_value_expr(&access));
7989
8865
  }
7990
8866
  }
7991
8867
  }
7992
8868
  }
8869
+ // `arr.length` on a native `Vec` (wrapped or not) → a cheap native `len()` boxed to a
8870
+ // `Number`, instead of boxing the WHOLE Vec to a `Value::Array` just to `get_prop`
8871
+ // its length (O(n) per read — the hot `states.length`-per-frame footgun, tish#562).
8872
+ if !*optional {
8873
+ if let MemberProp::Name { name: p, .. } = prop {
8874
+ if p.as_ref() == "length" {
8875
+ if let Expr::Ident { name, .. } = object.as_ref() {
8876
+ if let RustType::Vec(_) = self.type_context.get_type(name.as_ref()) {
8877
+ let esc = Self::escape_ident(name.as_ref());
8878
+ // #567: scope the borrow (non-reentrant `Arc<Mutex>` VmRef) so it
8879
+ // can't overlap a sibling read of the same cell in one expression.
8880
+ let len = if self.refcell_wrapped_vars.contains(name.as_ref()) {
8881
+ format!("{{ let __bg = {}.borrow(); __bg.len() }}", esc)
8882
+ } else {
8883
+ format!("{}.len()", esc)
8884
+ };
8885
+ return Ok(format!("Value::Number({} as f64)", len));
8886
+ }
8887
+ }
8888
+ }
8889
+ }
8890
+ }
7993
8891
  let obj = self.emit_expr(object)?;
7994
8892
  // A literal (non-computed) key other than `size` is eligible for a per-site
7995
8893
  // polymorphic inline cache (#179). `size` is excluded because `get_prop` special-cases
@@ -8148,9 +9046,58 @@ impl Codegen {
8148
9046
  }
8149
9047
  }
8150
9048
  ObjectProp::Spread(e) => {
8151
- let val = self.emit_expr(e)?;
9049
+ // Peek the source's static type ONCE. On GBA a statically-known struct
9050
+ // spreads NATIVELY (fields read straight off the Rust struct → the source
9051
+ // is never boxed to a `Value::Struct` / hashmap). `to_value_expr` re-boxes
9052
+ // a native scalar source so the boxed arms still receive a `Value`
9053
+ // (`{ ...5 }` stays sound). Desktop is deliberately untouched — a struct
9054
+ // source there boxes exactly as before (byte-identical emit, so the hot
9055
+ // object_spread bench keeps its clone-shape path).
9056
+ let gba = self.emit_mode == crate::NativeEmitMode::Gba;
9057
+ let (typed_code, typed_ty) = self.emit_typed_expr(e)?;
9058
+ if gba {
9059
+ if let crate::types::RustType::Named { fields, .. } = &typed_ty {
9060
+ let mut ins = String::new();
9061
+ for (k, fty) in fields {
9062
+ let key = self.cached_object_key(k.as_ref());
9063
+ ins.push_str(&format!(
9064
+ "_obj.insert({}, {}); ",
9065
+ key,
9066
+ Self::struct_field_to_value("_s", k, fty)
9067
+ ));
9068
+ }
9069
+ let block = format!("{{ let _s = &({}); {}}}", typed_code, ins);
9070
+ if i == 0 {
9071
+ init = format!(
9072
+ "let mut _obj: PropMap = PropMap::with_capacity({}); {}",
9073
+ literal_keys + fields.len(),
9074
+ block
9075
+ );
9076
+ } else {
9077
+ // Later struct fields override earlier keys (JS order).
9078
+ parts.push(block);
9079
+ }
9080
+ continue;
9081
+ }
9082
+ }
9083
+ let val = if typed_ty.is_native() {
9084
+ typed_ty.to_value_expr(&typed_code)
9085
+ } else {
9086
+ typed_code
9087
+ };
9088
+ // GBA (non-struct source, e.g. a struct behind a `Value` param): route
9089
+ // through the facade's `object_spread_props`, which materialises a
9090
+ // `Value::Struct` to its object first. Desktop keeps the inline `if let
9091
+ // Value::Object` fast path untouched (no struct variant exists there, and
9092
+ // it's the hot object_spread bench).
8152
9093
  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);
9094
+ init = if gba {
9095
+ format!("let mut _obj: PropMap = tishlang_runtime::object_spread_props(&({})).unwrap_or_else(|| PropMap::with_capacity({}));", val, literal_keys)
9096
+ } else {
9097
+ format!("let mut _obj: PropMap = if let Value::Object(ref _spread) = {} {{ _spread.borrow().strings.clone() }} else {{ PropMap::with_capacity({}) }};", val, literal_keys)
9098
+ };
9099
+ } else if gba {
9100
+ parts.push(format!("if let Some(_spread) = tishlang_runtime::object_spread_props(&({})) {{ _obj.merge_from(&_spread); }}", val));
8154
9101
  } else {
8155
9102
  // `merge_from` reserves for the source's len, then inserts in one
8156
9103
  // pass (later props still override earlier keys, matching JS order).
@@ -8561,6 +9508,13 @@ impl Codegen {
8561
9508
  format!("{{ if {} {{ {} }} else {{ {} }} }}", cond, assign_and_return, else_expr)
8562
9509
  }
8563
9510
  Expr::MemberAssign { object, prop, value, .. } => {
9511
+ // #558: a native struct-element / struct-local field store (yielding the assigned
9512
+ // value) instead of set_prop on a throwaway copy that discards the write.
9513
+ if let Some(code) =
9514
+ self.try_emit_native_struct_field_assign(object, prop.as_ref(), value, false)?
9515
+ {
9516
+ return Ok(code);
9517
+ }
8564
9518
  let obj = self.emit_expr(object)?;
8565
9519
  let val = self.emit_expr(value)?;
8566
9520
  format!(
@@ -9249,6 +10203,17 @@ impl Codegen {
9249
10203
  None
9250
10204
  }
9251
10205
 
10206
+ /// The root variable of a member/index chain (`a.b.c` → `a`, `a[i].b` → `a`, `a` → `a`), or
10207
+ /// `None` when the base isn't a plain identifier (e.g. a call result `f().x`).
10208
+ fn root_ident_of(expr: &Expr) -> Option<&str> {
10209
+ match expr {
10210
+ Expr::Ident { name, .. } => Some(name.as_ref()),
10211
+ Expr::Member { object, .. } => Self::root_ident_of(object),
10212
+ Expr::Index { object, .. } => Self::root_ident_of(object),
10213
+ _ => None,
10214
+ }
10215
+ }
10216
+
9252
10217
  fn collect_assigned_idents_in_expr(expr: &Expr, names: &mut HashSet<String>) {
9253
10218
  match expr {
9254
10219
  Expr::Assign { name, value, .. } => {
@@ -9270,6 +10235,12 @@ impl Codegen {
9270
10235
  names.insert(name.to_string());
9271
10236
  }
9272
10237
  Expr::MemberAssign { object, value, .. } => {
10238
+ // Mutating a field (`x.f = v`, `a.b.c = v`) mutates the ROOT variable — record it so a
10239
+ // captured struct/object is wrapped mutably; otherwise the native field store lands on
10240
+ // an immutable `let` binding (E0594).
10241
+ if let Some(root) = Self::root_ident_of(object) {
10242
+ names.insert(root.to_string());
10243
+ }
9273
10244
  Self::collect_assigned_idents_in_expr(object, names);
9274
10245
  Self::collect_assigned_idents_in_expr(value, names);
9275
10246
  }
@@ -9279,6 +10250,9 @@ impl Codegen {
9279
10250
  value,
9280
10251
  ..
9281
10252
  } => {
10253
+ if let Some(root) = Self::root_ident_of(object) {
10254
+ names.insert(root.to_string());
10255
+ }
9282
10256
  Self::collect_assigned_idents_in_expr(object, names);
9283
10257
  Self::collect_assigned_idents_in_expr(index, names);
9284
10258
  Self::collect_assigned_idents_in_expr(value, names);
@@ -9290,6 +10264,29 @@ impl Codegen {
9290
10264
  Expr::Unary { operand, .. } => Self::collect_assigned_idents_in_expr(operand, names),
9291
10265
  Expr::Delete { target, .. } => Self::collect_assigned_idents_in_expr(target, names),
9292
10266
  Expr::Call { callee, args, .. } => {
10267
+ // An in-place mutating array method (`a.push(x)`, `a.pop()`, `a.reverse()`, …) mutates
10268
+ // the ROOT variable — record it so a captured native Vec is wrapped for shared mutation.
10269
+ // WITHOUT this, the mutation inside a closure lands on a by-value / immutable-`let`
10270
+ // captured Vec (E0596), and the boxed fallback would DISCARD it (and E0282 on a
10271
+ // `Vec<struct>`, whose element type the skipped native op never pinned). Every listed
10272
+ // method has a native fast path that handles the wrapped receiver (a `(*a.borrow_mut())`
10273
+ // access, or a `borrow_mut` guard for the multi-access `splice`/`fill`/`copyWithin`).
10274
+ if let Expr::Member {
10275
+ object,
10276
+ prop: MemberProp::Name { name: m, .. },
10277
+ ..
10278
+ } = callee.as_ref()
10279
+ {
10280
+ if matches!(
10281
+ m.as_ref(),
10282
+ "push" | "pop" | "shift" | "unshift" | "reverse" | "sort" | "splice"
10283
+ | "fill" | "copyWithin"
10284
+ ) {
10285
+ if let Some(root) = Self::root_ident_of(object) {
10286
+ names.insert(root.to_string());
10287
+ }
10288
+ }
10289
+ }
9293
10290
  Self::collect_assigned_idents_in_expr(callee, names);
9294
10291
  for arg in args {
9295
10292
  match arg {
@@ -10619,6 +11616,15 @@ impl Codegen {
10619
11616
  for v in &implicit_env_captures {
10620
11617
  self.refcell_wrapped_vars.remove(v);
10621
11618
  }
11619
+ // tishlang/tish#556: an arrow PARAMETER shadows any outer var of the same name — inside the
11620
+ // body it is a plain `Value`/native binding, not the outer `VmRef` cell, so drop such names
11621
+ // from the wrapped set for the body (restored via `saved_refcell_vars`). Without this, a
11622
+ // reference to the param compiles as `vm_read(&param)` — a `&Value` where a `&VmRef` is wanted.
11623
+ for p in params {
11624
+ for bn in p.bound_names() {
11625
+ self.refcell_wrapped_vars.remove(bn.as_ref());
11626
+ }
11627
+ }
10622
11628
 
10623
11629
  self.type_context.push_fun_param_scope(params, None);
10624
11630
 
@@ -10709,11 +11715,15 @@ impl Codegen {
10709
11715
  {
10710
11716
  if p.as_ref() == "length" {
10711
11717
  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
- {
11718
+ if matches!(self.type_context.get_type(arr.as_ref()), RustType::Vec(_)) {
10715
11719
  let esc = Self::escape_ident(arr.as_ref());
10716
- return Ok(format!("({}.len() as f64)", esc));
11720
+ // A captured Vec reads its length through the cell (`(*a.borrow()).len()`).
11721
+ let code = if self.refcell_wrapped_vars.contains(arr.as_ref()) {
11722
+ format!("{{ let __bg = {}.borrow(); __bg.len() as f64 }}", esc)
11723
+ } else {
11724
+ format!("{}.len() as f64", esc)
11725
+ };
11726
+ return Ok(format!("({})", code));
10717
11727
  }
10718
11728
  }
10719
11729
  }
@@ -10938,6 +11948,17 @@ impl Codegen {
10938
11948
  if *target_type == RustType::F64 && typed_ty.is_integer_scalar() {
10939
11949
  return Ok(format!("({}) as f64", typed_code));
10940
11950
  }
11951
+ // `fixed` target from a native integer / f64 source: lift to `Fixed` directly, no
11952
+ // Value round-trip. Lets `let dx: fixed = input_x()` (i32→fixed) or `let v: fixed = n`
11953
+ // stay native so downstream `dx * spd` is `Fixed*Fixed` instead of boxing.
11954
+ if *target_type == RustType::Fixed {
11955
+ if typed_ty.is_integer_scalar() {
11956
+ return Ok(format!("tishlang_runtime::Fixed::from_raw((({}) as i32) * 256)", typed_code));
11957
+ }
11958
+ if typed_ty == RustType::F64 {
11959
+ return Ok(format!("tishlang_runtime::Fixed::from_raw((({}) * 256.0) as i32)", typed_code));
11960
+ }
11961
+ }
10941
11962
  }
10942
11963
 
10943
11964
  // Fall back to emit_expr + conversion
@@ -12250,50 +13271,19 @@ impl Codegen {
12250
13271
  let Expr::Ident { name, .. } = object else {
12251
13272
  return Ok(None);
12252
13273
  };
12253
- if self.refcell_wrapped_vars.contains(name.as_ref()) {
12254
- return Ok(None);
12255
- }
12256
13274
  let obj_type = self.type_context.get_type(name.as_ref());
12257
13275
  let RustType::Vec(elem_type) = obj_type else {
12258
13276
  return Ok(None);
12259
13277
  };
13278
+ // #564: a closure-captured (refcell-wrapped) native Vec is handled below via a `borrow_mut`
13279
+ // store. WITHOUT this it fell to the generic boxed `set_index` fallback, which mutates a
13280
+ // THROWAWAY boxed copy of the Vec (write silently DISCARDED) — and for a compound `a[i] += x`
13281
+ // (desugared to `a[i] = a[i] + x`) the fallback's `get_index(&a) … set_index(&a …)` takes two
13282
+ // overlapping locks on the non-reentrant `Arc<Mutex>` VmRef and DEADLOCKS the binary.
13283
+ let wrapped = self.refcell_wrapped_vars.contains(name.as_ref());
12260
13284
  let esc_obj = Self::escape_ident(name.as_ref()).into_owned();
12261
13285
  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
- };
13286
+ let idx_usize = self.emit_index_usize(index)?;
12297
13287
  let native_val = if *elem_type == RustType::I32 {
12298
13288
  if let Expr::Binary {
12299
13289
  left,
@@ -12342,6 +13332,34 @@ impl Codegen {
12342
13332
  val_code
12343
13333
  }
12344
13334
  };
13335
+ // #564: wrapped receiver — bind the index and RHS to temps BEFORE taking the `borrow_mut`,
13336
+ // so a RHS that reads the same cell (`a[i] += x`, `a[i] = a[j]`) releases its shared borrow
13337
+ // first (no overlapping lock → no deadlock), then store into the real element through the cell.
13338
+ if wrapped {
13339
+ let needs_resize =
13340
+ matches!(elem_type.as_ref(), RustType::F64 | RustType::Bool) && !in_bounds;
13341
+ let store = if needs_resize {
13342
+ let pad = if matches!(elem_type.as_ref(), RustType::F64) {
13343
+ "f64::NAN"
13344
+ } else {
13345
+ "false"
13346
+ };
13347
+ format!(
13348
+ "let __i = {idx}; let __v = {val}; let mut __g = {esc}.borrow_mut(); if __i >= __g.len() {{ __g.resize(__i + 1, {pad}); }} __g[__i] = __v;",
13349
+ idx = idx_usize, val = native_val, esc = esc_obj, pad = pad
13350
+ )
13351
+ } else {
13352
+ format!(
13353
+ "let __i = {idx}; let __v = {val}; (*{esc}.borrow_mut())[__i] = __v;",
13354
+ idx = idx_usize, val = native_val, esc = esc_obj
13355
+ )
13356
+ };
13357
+ return Ok(Some(if side_effect_only {
13358
+ format!("{{ {} }}", store)
13359
+ } else {
13360
+ format!("{{ {} Value::Null }}", store)
13361
+ }));
13362
+ }
12345
13363
  let assign = match elem_type.as_ref() {
12346
13364
  RustType::F64 | RustType::Bool | RustType::I32 if in_bounds => {
12347
13365
  if side_effect_only {
@@ -12369,6 +13387,130 @@ impl Codegen {
12369
13387
  Ok(Some(assign))
12370
13388
  }
12371
13389
 
13390
+ /// Emit an array/element index expression as a Rust `usize`. A substituted loop counter reuses
13391
+ /// its `usize` binding; a numeric index casts (`(idx) as usize`); anything else unwraps a
13392
+ /// `Value::Number` at runtime. Shared by the native `Vec` index-assign and struct-field-assign
13393
+ /// lowerings so they agree on index handling.
13394
+ fn emit_index_usize(&mut self, index: &Expr) -> Result<String, CompileError> {
13395
+ if let Expr::Ident { name: idx_name, .. } = index {
13396
+ if let Some(uv) = self.usize_var_subst.get(idx_name.as_ref()) {
13397
+ return Ok(uv.clone());
13398
+ }
13399
+ }
13400
+ let (idx_code, idx_ty) = self.emit_typed_expr(index)?;
13401
+ if idx_ty == RustType::F64 || idx_ty == RustType::I32 {
13402
+ Ok(format!("({}) as usize", idx_code))
13403
+ } else {
13404
+ let iv = if idx_ty.is_native() {
13405
+ idx_ty.to_value_expr(&idx_code)
13406
+ } else {
13407
+ idx_code
13408
+ };
13409
+ Ok(format!(
13410
+ "{{ let _i = &{}; if let Value::Number(n) = _i {{ *n as usize }} else {{ panic!(\"array index must be a number\") }} }}",
13411
+ iv
13412
+ ))
13413
+ }
13414
+ }
13415
+
13416
+ /// tishlang/tish#558 — `place.field = value` where `place` is an addressable STRUCT: an element
13417
+ /// of a typed struct `Vec` (`arr[i].field`) or a struct-typed local (`s.field`). Lowers to a
13418
+ /// NATIVE field store into the real element/local, coercing `value` to the field's type. Without
13419
+ /// this, the generic member-assign path emits `set_prop` on a throwaway `Value::object_from_pairs`
13420
+ /// COPY of the element — mutating the copy and silently discarding the write. Returns `None` for
13421
+ /// anything that isn't a known struct place (the caller falls back to the dynamic `set_prop`).
13422
+ fn try_emit_native_struct_field_assign(
13423
+ &mut self,
13424
+ object: &Expr,
13425
+ prop: &str,
13426
+ value: &Expr,
13427
+ side_effect_only: bool,
13428
+ ) -> Result<Option<String>, CompileError> {
13429
+ // `prelude` binds any index temp BEFORE a `borrow_mut()` place is taken (so the index may
13430
+ // itself read the array — `arr[arr.length-1]` — without a RefCell double-borrow); it is empty
13431
+ // for the non-wrapped cases. `place` is the addressable struct expression the field stores into.
13432
+ let (prelude, place, fields) = match object {
13433
+ // `arr[i].field = v` — a typed struct Vec element. A captured (refcell-wrapped) array
13434
+ // writes through `(*arr.borrow_mut())[i]` so the store lands natively on the shared cell;
13435
+ // WITHOUT this it fell to the generic member-assign, which boxed the WHOLE Vec into a
13436
+ // throwaway `Value::Array` (cloning + `from_struct`-boxing every element) and `set_prop`'d
13437
+ // the discarded copy — O(n) per write, the write lost, and an E0282 on the boxing closure.
13438
+ Expr::Index { object: io, index, .. } => {
13439
+ let Expr::Ident { name, .. } = io.as_ref() else {
13440
+ return Ok(None);
13441
+ };
13442
+ let RustType::Vec(elem) = self.type_context.get_type(name.as_ref()) else {
13443
+ return Ok(None);
13444
+ };
13445
+ let RustType::Named { fields, .. } = elem.as_ref() else {
13446
+ return Ok(None);
13447
+ };
13448
+ let fields = fields.clone();
13449
+ let idx_usize = self.emit_index_usize(index)?;
13450
+ let esc = Self::escape_ident(name.as_ref());
13451
+ if self.refcell_wrapped_vars.contains(name.as_ref()) {
13452
+ (
13453
+ format!("let _i = {}; ", idx_usize),
13454
+ format!("(*{}.borrow_mut())[_i]", esc),
13455
+ fields,
13456
+ )
13457
+ } else {
13458
+ (String::new(), format!("{}[{}]", esc, idx_usize), fields)
13459
+ }
13460
+ }
13461
+ // `s.field = v` — a struct-typed local/var. A captured (refcell-wrapped) struct writes
13462
+ // through `(*s.borrow_mut())` so the field store lands natively on the shared cell instead
13463
+ // of falling to the boxed `set_prop(object_from_pairs(..))` path (which also wouldn't
13464
+ // persist — it built a throwaway object).
13465
+ Expr::Ident { name, .. } => {
13466
+ let RustType::Named { fields, .. } = self.type_context.get_type(name.as_ref()) else {
13467
+ return Ok(None);
13468
+ };
13469
+ let place = if self.refcell_wrapped_vars.contains(name.as_ref()) {
13470
+ format!("(*{}.borrow_mut())", Self::escape_ident(name.as_ref()))
13471
+ } else {
13472
+ Self::escape_ident(name.as_ref()).into_owned()
13473
+ };
13474
+ (String::new(), place, fields)
13475
+ }
13476
+ _ => return Ok(None),
13477
+ };
13478
+ // The property must be a real field of the struct; its type drives the RHS coercion.
13479
+ let Some((_, field_ty)) = fields.iter().find(|(k, _)| k.as_ref() == prop) else {
13480
+ return Ok(None);
13481
+ };
13482
+ let field_ty = field_ty.clone();
13483
+ let field = crate::types::field_ident(prop);
13484
+ let coerced = self.emit_coerced_native(value, &field_ty)?;
13485
+ // Bind the RHS to a temp first so a `borrow_mut()` place can't alias a `borrow()` in the RHS
13486
+ // (`s.a = s.b + 1`) — evaluate the read before taking the mutable borrow. Any index prelude
13487
+ // runs first, so its evaluation also predates the borrow.
13488
+ let via_temp = !prelude.is_empty() || place.contains(".borrow_mut()");
13489
+ if side_effect_only {
13490
+ if via_temp {
13491
+ Ok(Some(format!(
13492
+ "{{ {}let _v = {}; {}.{} = _v; }}",
13493
+ prelude, coerced, place, field
13494
+ )))
13495
+ } else {
13496
+ Ok(Some(format!("{}.{} = {}", place, field, coerced)))
13497
+ }
13498
+ } else {
13499
+ // Assignment-as-expression yields the assigned value (JS `a.b = c` ⇒ c). Bind once (the
13500
+ // RHS may have effects and the place may re-evaluate an index), store, then hand back a
13501
+ // `Value` of it — box a native field, pass a `Value` field through.
13502
+ let ret = if field_ty.is_native() {
13503
+ field_ty.to_value_expr("_v")
13504
+ } else {
13505
+ "_v".to_string()
13506
+ };
13507
+ Ok(Some(format!(
13508
+ "{{ {}let _v = {}; {}.{} = _v.clone(); {} }}",
13509
+ prelude, coerced, place, field, ret
13510
+ )))
13511
+ }
13512
+ }
13513
+
12372
13514
  /// Emit `value` as a native expression of type `target_ty`, coercing without a
12373
13515
  /// `Value` round-trip where the conversion is a cheap numeric cast. Same-typed RHS
12374
13516
  /// passes through; f64↔i32 casts directly; everything else boxes to `Value` then
@@ -13045,6 +14187,22 @@ impl Codegen {
13045
14187
  /// native `Num<i32,8>` arithmetic instead of boxing the literal side through `f64`. Uses the
13046
14188
  /// same truncating `(n * 256) as i32` conversion as the dynamic `fixed`↔`Value` boundary, so
13047
14189
  /// the constant is bit-identical to the boxed path.
14190
+ /// A numeric literal that is EXACTLY a whole number inside `i32`'s range, as an `i32`. Used to keep
14191
+ /// `x > 0` / `i < 100` in integer registers when the other side is already an integer — a fraction
14192
+ /// or an out-of-range magnitude returns `None` and stays on the `f64` path, where it is correct.
14193
+ fn int_literal_of(e: &Expr) -> Option<i32> {
14194
+ if let Expr::Literal {
14195
+ value: Literal::Number(n),
14196
+ ..
14197
+ } = e
14198
+ {
14199
+ if n.fract() == 0.0 && *n >= i32::MIN as f64 && *n <= i32::MAX as f64 {
14200
+ return Some(*n as i32);
14201
+ }
14202
+ }
14203
+ None
14204
+ }
14205
+
13048
14206
  fn fixed_literal_of(e: &Expr) -> Option<String> {
13049
14207
  if let Expr::Literal {
13050
14208
  value: Literal::Number(n),
@@ -22097,6 +23255,23 @@ impl Codegen {
22097
23255
  let (l, lt) = self.emit_typed_expr(left)?;
22098
23256
  let (r, rt) = self.emit_typed_expr(right)?;
22099
23257
 
23258
+ // `fixed ⊕ <runtime integer>` (e.g. `t * speed` with `t: i32`, `speed: fixed`): LIFT the
23259
+ // integer operand to `Fixed` via `Num::new` so the whole op stays native `Num<i32,8>`
23260
+ // arithmetic. This runs BEFORE the generic integer→f64 coercion below — otherwise the
23261
+ // integer becomes f64 and `f64 ⊕ fixed` drops to the boxed `ops::*` path (that path is
23262
+ // the deliberate escape hatch for a GENUINE runtime f64, which is lossy against fixed;
23263
+ // a plain integer is exact, so lifting is sound). Integer *literals* are already folded
23264
+ // to a Fixed constant by `fixed_literal_of` further down; this covers runtime integers.
23265
+ // `Fixed<i32,8>` raw = value << 8 (× 256), so an integer `v` lifts to fixed `v.0` as
23266
+ // `Fixed::from_raw(v * 256)` — the same constructor tish emits everywhere for fixed.
23267
+ let (l, lt, r, rt) = if lt == RustType::Fixed && rt.is_integer_scalar() {
23268
+ (l, lt, format!("tishlang_runtime::Fixed::from_raw((({}) as i32) * 256)", r), RustType::Fixed)
23269
+ } else if rt == RustType::Fixed && lt.is_integer_scalar() {
23270
+ (format!("tishlang_runtime::Fixed::from_raw((({}) as i32) * 256)", l), RustType::Fixed, r, rt)
23271
+ } else {
23272
+ (l, lt, r, rt)
23273
+ };
23274
+
22100
23275
  // An `I32` loop-accumulator (the i32-loop-var lowering) used in a NON-bitwise
22101
23276
  // expression reads as its signed int32 value coerced to `f64` — every i32 is exact
22102
23277
  // in f64. Bitwise/shift parents never see this: they recurse into the raw AST via
@@ -22106,6 +23281,83 @@ impl Codegen {
22106
23281
  // A narrow int storage read (`u8` HP, `i16` tile x) promotes the same way — read
22107
23282
  // as its exact `f64` value so arithmetic keeps JS Number semantics; the truncating
22108
23283
  // store back to the field happens at the assignment site.
23284
+ // …but when BOTH sides are already integers, that detour is pure loss on a CPU with no
23285
+ // FPU. On ARM7TDMI every f64 op is a soft-float library CALL, so `i + 1` on a `: i32`
23286
+ // counter costs two int→float conversions, a call, and a float→int store. It is why
23287
+ // annotating a loop counter `: i32` measured SLOWER than leaving it a plain number
23288
+ // (examples/bench-ai) — the opposite of what an annotation is for.
23289
+ //
23290
+ // Keeping the pair in integer registers is not an approximation: every i32 is exact in
23291
+ // f64, so `+`, `-` and the relational operators give bit-identical answers in either
23292
+ // domain. Only the operators where the two domains genuinely DISAGREE stay on floats:
23293
+ // `/` JS division is real division — `5 / 2` is 2.5, not 2
23294
+ // `%` integer `%` panics on a zero divisor where JS yields NaN
23295
+ // `*` a product can leave i32's range while staying exact in f64, and the bitwise
23296
+ // lowering below deliberately relies on that headroom (`h * 16777619`)
23297
+ // Operands widen to `i32` first so mixed widths (a `u8` HP against an `i16` tile
23298
+ // coordinate) still agree with JS Number semantics. `u32` is excluded because it is the
23299
+ // one width that does not fit — reading 3 billion `as i32` would flip its sign.
23300
+ // The adds wrap rather than panic, which matches both JS (`to_int32` is modulo 2³²) and
23301
+ // the truncating `as` store every integer target already performs.
23302
+ let fits_i32 = |t: &RustType| {
23303
+ matches!(
23304
+ t,
23305
+ RustType::I32
23306
+ | RustType::I8
23307
+ | RustType::U8
23308
+ | RustType::I16
23309
+ | RustType::U16
23310
+ )
23311
+ };
23312
+ // An integer against a whole-number LITERAL is the shape most of this actually is —
23313
+ // `x > 0`, `i < 100`, `0 - dir`. The literal types as `f64`, which would drag the
23314
+ // integer side out of its register for a soft-float compare over two values that are
23315
+ // both plainly integers. Fold it into the integer domain instead. Only exact whole
23316
+ // numbers inside i32's range qualify, so `x < 2.5` and `x < 3e9` still go to floats,
23317
+ // where they belong.
23318
+ let (l, lt, r, rt) = if fits_i32(&lt) && rt == RustType::F64 {
23319
+ match Self::int_literal_of(right) {
23320
+ Some(v) => (l, lt, format!("{}i32", v), RustType::I32),
23321
+ None => (l, lt, r, rt),
23322
+ }
23323
+ } else if fits_i32(&rt) && lt == RustType::F64 {
23324
+ match Self::int_literal_of(left) {
23325
+ Some(v) => (format!("{}i32", v), RustType::I32, r, rt),
23326
+ None => (l, lt, r, rt),
23327
+ }
23328
+ } else {
23329
+ (l, lt, r, rt)
23330
+ };
23331
+ if fits_i32(&lt) && fits_i32(&rt) {
23332
+ let li = format!("(({}) as i32)", l);
23333
+ let ri = format!("(({}) as i32)", r);
23334
+ match op {
23335
+ BinOp::Add => {
23336
+ return Ok((
23337
+ format!("({}).wrapping_add({})", li, ri),
23338
+ RustType::I32,
23339
+ ))
23340
+ }
23341
+ BinOp::Sub => {
23342
+ return Ok((
23343
+ format!("({}).wrapping_sub({})", li, ri),
23344
+ RustType::I32,
23345
+ ))
23346
+ }
23347
+ BinOp::Lt => return Ok((format!("({} < {})", li, ri), RustType::Bool)),
23348
+ BinOp::Le => return Ok((format!("({} <= {})", li, ri), RustType::Bool)),
23349
+ BinOp::Gt => return Ok((format!("({} > {})", li, ri), RustType::Bool)),
23350
+ BinOp::Ge => return Ok((format!("({} >= {})", li, ri), RustType::Bool)),
23351
+ BinOp::StrictEq => {
23352
+ return Ok((format!("({} == {})", li, ri), RustType::Bool))
23353
+ }
23354
+ BinOp::StrictNe => {
23355
+ return Ok((format!("({} != {})", li, ri), RustType::Bool))
23356
+ }
23357
+ _ => {}
23358
+ }
23359
+ }
23360
+
22109
23361
  let (l, lt) = if lt.is_integer_scalar() {
22110
23362
  (format!("(({}) as f64)", l), RustType::F64)
22111
23363
  } else {
@@ -22263,6 +23515,41 @@ impl Codegen {
22263
23515
  }
22264
23516
  }
22265
23517
 
23518
+ // GBA: `key in s` on a statically-known struct → answer NATIVELY against the field
23519
+ // names (no boxing of `s`, no hashmap). A string-literal key folds to a compile-time
23520
+ // bool; a string-typed key becomes a `matches!` over the field names (zero alloc). The
23521
+ // struct is still evaluated (bound to `_`) for its side effects. Any other key kind
23522
+ // falls through to the boxed runtime `in` below. Desktop is untouched.
23523
+ if *op == BinOp::In
23524
+ && self.emit_mode == crate::NativeEmitMode::Gba
23525
+ {
23526
+ if let RustType::Named { fields, .. } = &rt {
23527
+ if let Expr::Literal { value: Literal::String(s), .. } = left.as_ref() {
23528
+ let present = fields.iter().any(|(k, _)| k.as_ref() == s.as_ref());
23529
+ return Ok((
23530
+ format!("{{ let _ = &({}); Value::Bool({}) }}", r, present),
23531
+ RustType::Value,
23532
+ ));
23533
+ }
23534
+ if lt == RustType::String {
23535
+ let arms = fields
23536
+ .iter()
23537
+ .map(|(k, _)| format!("{:?}", k.as_ref()))
23538
+ .collect::<Vec<_>>()
23539
+ .join(" | ");
23540
+ // Bind the key BEFORE evaluating the struct — `key in obj` evaluates
23541
+ // left-to-right, so the key's side effects must run first.
23542
+ return Ok((
23543
+ format!(
23544
+ "{{ let _k = ({}); let _ = &({}); Value::Bool(matches!(_k.as_str(), {})) }}",
23545
+ l, r, arms
23546
+ ),
23547
+ RustType::Value,
23548
+ ));
23549
+ }
23550
+ }
23551
+ }
23552
+
22266
23553
  // Fall back: convert both sides to Value and use the runtime.
22267
23554
  let lv = Self::box_operand_for_runtime(left, &l, &lt);
22268
23555
  let rv = Self::box_operand_for_runtime(right, &r, &rt);
@@ -22349,10 +23636,24 @@ impl Codegen {
22349
23636
  // Native fast path: `vec[i]` where vec is Vec<T> and i is numeric.
22350
23637
  if !optional {
22351
23638
  if let Expr::Ident { name, .. } = object.as_ref() {
22352
- if !self.refcell_wrapped_vars.contains(name.as_ref()) {
23639
+ {
23640
+ let idx_wrapped = self.refcell_wrapped_vars.contains(name.as_ref());
22353
23641
  let obj_type = self.type_context.get_type(name.as_ref());
22354
23642
  if let RustType::Vec(elem_type) = &obj_type {
22355
- let esc_obj = Self::escape_ident(name.as_ref()).into_owned();
23643
+ // A captured (refcell-wrapped) Vec reads through `(*a.borrow())` — WITHOUT
23644
+ // this it fell to the boxed `get_index`, which cloned + `from_struct`-boxed
23645
+ // the WHOLE Vec into a `Value::Array` just to read one element (O(n) per
23646
+ // read — the hot-loop `states[id].timer` footgun, tish#558 read side).
23647
+ let esc_raw = Self::escape_ident(name.as_ref()).into_owned();
23648
+ // #567: for a wrapped Vec the place is the block-local guard `__bg`
23649
+ // (bound below), so the non-reentrant `Arc<Mutex>` borrow is scoped to
23650
+ // this single read and can't overlap a sibling read of the same cell
23651
+ // (`a[i] + a[j]` in one expression), which would deadlock.
23652
+ let esc_obj = if idx_wrapped {
23653
+ "(*__bg)".to_string()
23654
+ } else {
23655
+ esc_raw.clone()
23656
+ };
22356
23657
  // #173 part 3: prove the index in-bounds BEFORE emitting it.
22357
23658
  let in_bounds = self.index_in_bounds(index, name.as_ref());
22358
23659
  let idx_usize = if let Expr::Ident { name: idx_name, .. } =
@@ -22394,6 +23695,14 @@ impl Codegen {
22394
23695
  )
22395
23696
  }
22396
23697
  };
23698
+ // #567: inside the wrapped block the index is pre-bound to `__bi`
23699
+ // (evaluated BEFORE the borrow, so an index that itself reads this cell
23700
+ // — `arr[arr.length-1]` — doesn't double-lock the non-reentrant cell).
23701
+ let idx_ref = if idx_wrapped {
23702
+ "__bi".to_string()
23703
+ } else {
23704
+ idx_usize.clone()
23705
+ };
22397
23706
  let elem_ty = *elem_type.clone();
22398
23707
  // OOB-safe read for numeric/bool Vecs: JS `arr[oob]` is `undefined`
22399
23708
  // (→ NaN / false in those contexts), NOT a panic. In-bounds is the
@@ -22403,27 +23712,42 @@ impl Codegen {
22403
23712
  // #173 part 3: a proven in-bounds read skips the `.get().unwrap_or`
22404
23713
  // branch — a direct `a[i]` (the `idx < len` proof guarantees it).
22405
23714
  RustType::F64 | RustType::Bool | RustType::I32 if in_bounds => {
22406
- format!("{}[{}]", esc_obj, idx_usize)
23715
+ format!("{}[{}]", esc_obj, idx_ref)
22407
23716
  }
22408
23717
  RustType::F64 => format!(
22409
23718
  "{}.get({}).copied().unwrap_or(f64::NAN)",
22410
- esc_obj, idx_usize
23719
+ esc_obj, idx_ref
22411
23720
  ),
22412
23721
  RustType::I32 => format!(
22413
23722
  "{}.get({}).copied().unwrap_or(0)",
22414
- esc_obj, idx_usize
23723
+ esc_obj, idx_ref
22415
23724
  ),
22416
23725
  RustType::Bool => {
22417
- format!("{}.get({}).copied().unwrap_or(false)", esc_obj, idx_usize)
23726
+ format!("{}.get({}).copied().unwrap_or(false)", esc_obj, idx_ref)
22418
23727
  }
22419
- // Other element types keep the direct index (unchanged).
22420
- _ => format!("{}[{}]", esc_obj, idx_usize),
23728
+ // Other element types (struct/Value/String). From behind a
23729
+ // `borrow()` guard a non-Copy element must be cloned OUT (it can't be
23730
+ // moved from the borrow); a plain local keeps the direct place.
23731
+ _ if idx_wrapped => format!("{}[{}].clone()", esc_obj, idx_ref),
23732
+ _ => format!("{}[{}]", esc_obj, idx_ref),
23733
+ };
23734
+ // #567: wrap the wrapped-Vec read in a block that pre-binds the index
23735
+ // and scopes the borrow guard `__bg`, so the lock is released before
23736
+ // any sibling read of the same cell in the enclosing expression.
23737
+ let access = if idx_wrapped {
23738
+ format!(
23739
+ "{{ let __bi = {}; let __bg = {}.borrow(); {} }}",
23740
+ idx_usize, esc_raw, access
23741
+ )
23742
+ } else {
23743
+ access
22421
23744
  };
22422
23745
  return Ok((access, elem_ty));
22423
23746
  }
22424
23747
  // Native tuple access: `tuple[const]` -> `tuple.const` (Rust tuples
22425
23748
  // require a literal index; a variable index falls through to boxed).
22426
- if let RustType::Tuple(elems) = &obj_type {
23749
+ // (Plain local only — a wrapped tuple keeps the boxed path.)
23750
+ if let (false, RustType::Tuple(elems)) = (idx_wrapped, &obj_type) {
22427
23751
  if let Expr::Literal {
22428
23752
  value: Literal::Number(n),
22429
23753
  ..
@@ -22501,6 +23825,35 @@ impl Codegen {
22501
23825
  }
22502
23826
  }
22503
23827
  }
23828
+ // Typed extern (a `cargo:` import with a declared native signature) used INSIDE a
23829
+ // native/typed expression: `entity_x(e) + 16`, `cvar(e, k) + 1`. Emit the direct
23830
+ // `crate::name_typed(<coerced args>)` and report its NATIVE return type, so the
23831
+ // surrounding arithmetic/comparison stays native instead of boxing the result and
23832
+ // dropping to `ops::*`. Only a native-returning extern is taken here; a `void`/`Value`
23833
+ // one falls through to the boxed path (this is a value-position expression).
23834
+ if let Expr::Ident { name: fname, .. } = callee.as_ref() {
23835
+ if let Some(sig) = self.extern_fns.get(fname.as_ref()).cloned() {
23836
+ if sig.ret.is_native()
23837
+ && args.len() == sig.params.len()
23838
+ && args.iter().all(|a| matches!(a, CallArg::Expr(_)))
23839
+ {
23840
+ let mut argc: Vec<String> = Vec::with_capacity(args.len());
23841
+ for (a, ty) in args.iter().zip(sig.params.iter()) {
23842
+ if let CallArg::Expr(e) = a {
23843
+ argc.push(self.emit_coerced_native(e, ty)?);
23844
+ }
23845
+ }
23846
+ let call = format!(
23847
+ "{}::{}_typed({})",
23848
+ sig.crate_name,
23849
+ sig.symbol,
23850
+ argc.join(", ")
23851
+ );
23852
+ return Ok((call, sig.ret));
23853
+ }
23854
+ }
23855
+ }
23856
+
22504
23857
  // M5: direct call to an eligible native fn -> `name_native(<native args>)`.
22505
23858
  if let Expr::Ident { name: fname, .. } = callee.as_ref() {
22506
23859
  if self.native_fns.contains(fname.as_ref()) {
@@ -22620,6 +23973,19 @@ impl Codegen {
22620
23973
  if let Some(res) = self.native_vec_hof_for_call(callee, args)? {
22621
23974
  return Ok(res);
22622
23975
  }
23976
+ // A call to a user tish function whose declared return type is a native struct
23977
+ // (`function gameState(): GameState`). The function is still a boxed closure, so the call
23978
+ // yields a `Value` — a `Value::Struct` on GBA. Coerce it to the native struct via
23979
+ // `from_value_expr` (a cheap `downcast_struct` on GBA; the `get_prop` rebuild off-GBA), so
23980
+ // the surrounding `gs.score` / `gs.bossHp` reads lower to plain Rust field loads instead of
23981
+ // boxed `get_prop`. A false positive (name shadowed by a non-struct local) is harmless:
23982
+ // `from_value_expr` downcasts if it can and otherwise rebuilds via `get_prop`.
23983
+ if let Expr::Ident { name: fname, .. } = callee.as_ref() {
23984
+ if let Some(ret) = self.user_fn_returns.get(fname.as_ref()).cloned() {
23985
+ let boxed = self.emit_expr(expr)?;
23986
+ return Ok((ret.from_value_expr(&boxed), ret));
23987
+ }
23988
+ }
22623
23989
  let result = self.emit_expr(expr)?;
22624
23990
  Ok((result, RustType::Value))
22625
23991
  }
@@ -22644,7 +24010,7 @@ impl Codegen {
22644
24010
  if prop_name.as_ref() == "length" {
22645
24011
  let var_esc = Self::escape_ident(var_name.as_ref()).into_owned();
22646
24012
  let code = if self.refcell_wrapped_vars.contains(var_name.as_ref()) {
22647
- format!("({}.borrow().len() as f64)", var_esc)
24013
+ format!("({{ let __bg = {}.borrow(); __bg.len() as f64 }})", var_esc)
22648
24014
  } else {
22649
24015
  format!("({}.len() as f64)", var_esc)
22650
24016
  };
@@ -22659,7 +24025,7 @@ impl Codegen {
22659
24025
  let field = crate::types::field_ident(prop_name.as_ref());
22660
24026
  let access = if self.refcell_wrapped_vars.contains(var_name.as_ref())
22661
24027
  {
22662
- format!("(*{}.borrow()).{}.clone()", var_esc, field)
24028
+ format!("{{ let __bg = {}.borrow(); (*__bg).{}.clone() }}", var_esc, field)
22663
24029
  } else {
22664
24030
  format!("{}.{}", var_esc, field)
22665
24031
  };