@tishlang/tish-format 3.3.0 → 3.6.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.
@@ -705,6 +705,28 @@ pub fn compile_with_native_modules(
705
705
  /// as warnings; `TISH_CHECK=error` also fails the build. Unset/`0` → no-op (default builds are
706
706
  /// unaffected). The checker is gradual (see `check.rs`): it never flags code it can't prove wrong.
707
707
  fn run_type_check(program: &Program) -> Result<(), CompileError> {
708
+ // #641: call arity is checked UNCONDITIONALLY, ahead of the opt-in gradual checker. It cannot
709
+ // sit behind `TISH_CHECK` — the case this exists for built clean and shipped, and a diagnostic
710
+ // nobody enables would not have caught it. It is also not "gradual": an omitted argument bound
711
+ // to a typed parameter arrives as `Value::Null` and cannot satisfy the typed prologue, so it is
712
+ // provably wrong on every backend rather than an unproven annotation.
713
+ //
714
+ // `TISH_ARITY=0` is the escape hatch, for bisecting a build against pre-#641 behaviour.
715
+ if std::env::var("TISH_ARITY").as_deref() != Ok("0") {
716
+ let arity = crate::check::check_call_arity(program);
717
+ if let Some(first) = arity.first() {
718
+ for d in &arity {
719
+ eprintln!(
720
+ "tish error: {}:{}: {}",
721
+ d.span.start.0, d.span.start.1, d.message
722
+ );
723
+ }
724
+ return Err(CompileError::new(
725
+ format!("call arity: {} error(s)", arity.len()),
726
+ Some(first.span),
727
+ ));
728
+ }
729
+ }
708
730
  let mode = std::env::var("TISH_CHECK").unwrap_or_default();
709
731
  if mode.is_empty() || mode == "0" {
710
732
  return Ok(());
@@ -1067,7 +1089,11 @@ pub(crate) struct Codegen {
1067
1089
  native_ns_locals: std::collections::HashMap<String, String>,
1068
1090
  /// Typed native imports (`declare fun` + a native import): tish name -> its typed signature.
1069
1091
  /// A matching call emits a DIRECT `<crate>::name_typed(..)` instead of a boxed `value_call`.
1070
- extern_fns: std::collections::HashMap<String, ExternSig>,
1092
+ /// #615: MULTIPLE signatures per name, dispatched on EXACT arity. An extern with optional
1093
+ /// trailing args (`ui_text` 4-10, `ui_rect` 5 or 6) could previously be typed for one arity
1094
+ /// only; every other call site stayed boxed. Additive by construction: an arity with no
1095
+ /// declaration finds no match and keeps the boxed path bit-identically.
1096
+ extern_fns: std::collections::HashMap<String, Vec<ExternSig>>,
1071
1097
  /// User tish functions whose declared return type is a native STRUCT (`function f(): Iface`).
1072
1098
  /// A call in a typed context coerces the boxed result to the native struct (a `Value::Struct`
1073
1099
  /// downcast on GBA), so `f().field` reads are native instead of `get_prop`. Name -> `RustType::Named`.
@@ -1102,6 +1128,21 @@ pub(crate) struct Codegen {
1102
1128
  /// Top-level `let name = [f64 literals…]` never reassigned — emitted as `const G_name: [f64; N]`
1103
1129
  /// for direct indexing from native fns (fasta `codes`/`probs`).
1104
1130
  module_const_f64_arrays: std::collections::HashMap<String, Vec<f64>>,
1131
+ /// #597: names passed DIRECTLY as a call argument (`fill(reqs)`). A typed array in this set must
1132
+ /// not lower to a native `Vec` — see the note at the `VarDecl` typing site.
1133
+ arrays_passed_to_calls: std::collections::HashSet<String>,
1134
+ /// The STATIC names from `module_const_f64_arrays` whose every element is an exact integer in
1135
+ /// `i32` range, so the hoist emits `[i32; N]` instead of `[f64; N]`.
1136
+ ///
1137
+ /// ARM7TDMI HAS NO FPU. These passes were written for the host, where `f64` is tish's number and
1138
+ /// the representation is free; on GBA every `f64` touch is a soft-float call and every element
1139
+ /// costs 8 bytes instead of 4. Worse, the source usually declares the element type — hoisting
1140
+ /// `let A: i32[] = [1,2,3]` to `[f64; 3]` discards a type the program already stated, then pays
1141
+ /// an `i32 -> f64 -> usize` round trip on each index to get back to where it started.
1142
+ ///
1143
+ /// Keyed by static name, not source name, so every read site (including the `_cum` aliases) can
1144
+ /// ask the question with what it already has in hand.
1145
+ module_const_int_statics: std::collections::HashSet<String>,
1105
1146
  /// Precomputed cumulative arrays for `module_const_f64_arrays` used as `cumulative(p)` input
1106
1147
  /// (`probs` → `G_probs_cum`).
1107
1148
  module_const_f64_cum: std::collections::HashMap<String, String>,
@@ -1219,7 +1260,22 @@ pub(crate) struct Codegen {
1219
1260
  /// once in `emit_program` (after type aliases + `native_fns`), consulted at `VarDecl` to force
1220
1261
  /// `RustType::Value`. This is the rust-AOT analogue of the VM array-JIT bailing to the
1221
1262
  /// interpreter on a non-numeric element. See `collect_demoted_numeric_locals`.
1222
- demoted_numeric_locals: std::collections::HashSet<String>,
1263
+ ///
1264
+ /// #614: keyed by `(declaring scope, name)`, NOT by bare name. The scope is the top-level fn
1265
+ /// whose body declares the local, or `""` for a module-level binding. Flat keying meant one
1266
+ /// boxed `i` anywhere in the bundle demoted every `i` in every module — with 44 of tish-gba's
1267
+ /// 70 packages untyped, a legacy package silently de-optimized a fully-modernized one that
1268
+ /// merely shared a loop-variable name (~40-60 cycles per boxed use against 2-4 native).
1269
+ ///
1270
+ /// Module-level bindings stay program-wide (scope `""`) ON PURPOSE: a module `let` can be
1271
+ /// reassigned from inside any fn, so its demotion is genuinely a whole-program property.
1272
+ /// Only FUNCTION-LOCAL names are scoped, and for those every reassignment necessarily lives
1273
+ /// in the declaring fn (or a closure nested in it) — exactly the region analyzed.
1274
+ demoted_numeric_locals: std::collections::HashSet<(String, String)>,
1275
+ /// #614: the top-level fn currently being emitted (`""` at module level), used to key lookups
1276
+ /// into `demoted_numeric_locals`. A stack because fn emission nests; the FIRST entry is the
1277
+ /// top-level fn, which is the granularity the collector analyzes.
1278
+ demote_scope_stack: Vec<String>,
1223
1279
  /// Top-level fns whose EVERY return is provably numeric → the native type a call to one yields.
1224
1280
  ///
1225
1281
  /// Without it a call is opaque, so a local assigned from one demotes to a boxed `Value` and
@@ -1383,6 +1439,8 @@ impl Codegen {
1383
1439
  refcell_wrapped_vars: std::collections::HashSet::new(),
1384
1440
  native_numeric_globals: std::collections::HashMap::new(),
1385
1441
  module_const_f64_arrays: std::collections::HashMap::new(),
1442
+ module_const_int_statics: std::collections::HashSet::new(),
1443
+ arrays_passed_to_calls: std::collections::HashSet::new(),
1386
1444
  module_const_f64_cum: std::collections::HashMap::new(),
1387
1445
  module_const_f64_aliases: std::collections::HashMap::new(),
1388
1446
  map_instance_locals: std::collections::HashSet::new(),
@@ -1417,6 +1475,7 @@ impl Codegen {
1417
1475
  aggregate_array_locals: std::collections::HashSet::new(),
1418
1476
  agg_cur_ret: None,
1419
1477
  demoted_numeric_locals: std::collections::HashSet::new(),
1478
+ demote_scope_stack: Vec::new(),
1420
1479
  user_fn_ret: std::collections::HashMap::new(),
1421
1480
  int_range_locals: std::collections::HashMap::new(),
1422
1481
  diag_coord_indices: std::collections::HashSet::new(),
@@ -2429,6 +2488,20 @@ impl Codegen {
2429
2488
  "box", "do", "final", "macro", "override", "priv", "try", "typeof", "unsized",
2430
2489
  "virtual", "yield",
2431
2490
  ];
2491
+ // #549: FOUR KEYWORDS CANNOT BE RAW IDENTIFIERS AT ALL. `r#self`, `r#Self`, `r#super` and
2492
+ // `r#crate` are rejected by rustc outright ("`self` cannot be a raw identifier"), so the
2493
+ // blanket `r#` below emitted a program that could not compile. These are ordinary names in
2494
+ // tish and the interpreter accepts them, which made this a backend divergence that REJECTED
2495
+ // VALID SOURCE rather than merely lowering it differently.
2496
+ //
2497
+ // They are renamed instead. The suffix is deliberately not a bare `_`: `self_` is a name a
2498
+ // program could plausibly use itself, and colliding with it would trade a compile error for
2499
+ // a silent capture of the wrong binding. `__tish_kw_` cannot be written in tish source,
2500
+ // because a leading double underscore is reserved by the compiler for generated temporaries.
2501
+ const NON_RAW_KEYWORDS: &[&str] = &["self", "Self", "super", "crate"];
2502
+ if NON_RAW_KEYWORDS.contains(&name) {
2503
+ return Cow::Owned(format!("__tish_kw_{}", name));
2504
+ }
2432
2505
  if RUST_KEYWORDS.contains(&name) {
2433
2506
  Cow::Owned(format!("r#{}", name))
2434
2507
  } else {
@@ -2958,13 +3031,35 @@ impl Codegen {
2958
3031
  .as_ref()
2959
3032
  .map(RustType::from_annotation)
2960
3033
  .unwrap_or(RustType::Unit);
2961
- self.extern_fns.insert(
2962
- name.to_string(),
2963
- ExternSig { crate_name, symbol: export_name.clone(), params: ptys, ret },
2964
- );
3034
+ // #615: one name may carry several arities. A REDECLARATION of an arity already seen
3035
+ // replaces it (last wins), matching the previous single-map behaviour for the
3036
+ // single-arity case exactly.
3037
+ let sig = ExternSig { crate_name, symbol: export_name.clone(), params: ptys, ret };
3038
+ let slot = self.extern_fns.entry(name.to_string()).or_default();
3039
+ if let Some(existing) = slot.iter_mut().find(|e| e.params.len() == sig.params.len()) {
3040
+ *existing = sig;
3041
+ } else {
3042
+ slot.push(sig);
3043
+ }
2965
3044
  }
2966
3045
  }
2967
3046
 
3047
+ /// #615: the declared signature for `name` at EXACTLY `argc` arguments, plus the Rust symbol
3048
+ /// suffix to call it by. A name with a single declaration keeps the historical `<symbol>_typed`
3049
+ /// spelling, so every existing extension crate links unchanged; only a name that declares more
3050
+ /// than one arity uses `<symbol>_typed_<arity>`, which is the new surface a crate opts into by
3051
+ /// declaring the extra arities in the first place.
3052
+ fn extern_sig_for_arity(&self, name: &str, argc: usize) -> Option<(ExternSig, String)> {
3053
+ let sigs = self.extern_fns.get(name)?;
3054
+ let sig = sigs.iter().find(|s| s.params.len() == argc)?.clone();
3055
+ let suffix = if sigs.len() > 1 {
3056
+ format!("_typed_{}", argc)
3057
+ } else {
3058
+ "_typed".to_string()
3059
+ };
3060
+ Some((sig, suffix))
3061
+ }
3062
+
2968
3063
  /// Record every top-level `function f(...): Iface` whose return annotation resolves — via the type
2969
3064
  /// aliases — to a native STRUCT (`RustType::Named`, or a `ShapeUnion`). A later call to `f` in a
2970
3065
  /// typed position then coerces the boxed result to the native struct (a `Value::Struct` downcast on
@@ -3181,6 +3276,37 @@ impl Codegen {
3181
3276
  &program.statements,
3182
3277
  &self.module_const_f64_cum,
3183
3278
  );
3279
+ // #597: which annotated arrays escape into a call, and so must stay boxed to preserve
3280
+ // aliasing. Collected once over the whole program — an array declared here can be
3281
+ // passed to a fn declared anywhere.
3282
+ //
3283
+ // GATED ON GBA, and not only because the RESULT is GBA-only: this scan runs
3284
+ // `scan_param_use` over the whole program once per annotated array, so it is
3285
+ // O(arrays x program). Off-target that is pure waste — the set is never read — and it
3286
+ // would be a silent compile-time tax on every host build.
3287
+ if self.emit_mode == crate::NativeEmitMode::Gba {
3288
+ let mut array_names: Vec<String> = Vec::new();
3289
+ Self::collect_annotated_array_names(&program.statements, &mut array_names);
3290
+ self.arrays_passed_to_calls =
3291
+ Self::collect_escaping_array_names(&program.statements, &array_names);
3292
+ }
3293
+ // Pick the integer representation where the values allow it. GBA only: off-target the
3294
+ // `f64` layout is free and eligibility must stay byte-identical to before.
3295
+ //
3296
+ // A `_cum` array is deliberately NOT considered — the fasta ladder that consumes one
3297
+ // compares it against an `r_f64` and is specialised around f64 arms, so leaving cum
3298
+ // alone keeps that path untouched.
3299
+ if self.emit_mode == crate::NativeEmitMode::Gba {
3300
+ for (name, vals) in &self.module_const_f64_arrays {
3301
+ if vals
3302
+ .iter()
3303
+ .all(|v| v.fract() == 0.0 && *v >= i32::MIN as f64 && *v <= i32::MAX as f64)
3304
+ {
3305
+ self.module_const_int_statics
3306
+ .insert(Self::module_const_static(name));
3307
+ }
3308
+ }
3309
+ }
3184
3310
  if !self.module_const_f64_arrays.is_empty() {
3185
3311
  self.emit_module_const_f64_arrays()?;
3186
3312
  self.writeln("");
@@ -3675,7 +3801,25 @@ impl Codegen {
3675
3801
  if self.is_async {
3676
3802
  self.async_context_stack.push(true); // run() body is async Rust context
3677
3803
  }
3678
- self.emit_statements_with_folds(&program.statements)?;
3804
+ // #629: A FUNCTION MAY REFERENCE A MODULE BINDING DECLARED BELOW IT. Module bindings are
3805
+ // emitted into `run()` in source order, but a function VALUE is emitted where it appears and
3806
+ // captures by name — so `function readA() { return A[i] }` above `let A = [...]` emitted a
3807
+ // capture of `A` five lines before `let A`, and rustc rejected the program:
3808
+ //
3809
+ // let rangDone = { Value::native(move |args| { … vm_read(&rangLive) … }) }; // use
3810
+ // let rangLive = VmRef::new(vec![(0_f64) as i32]); // decl
3811
+ //
3812
+ // That is legal tish — the function is not CALLED before the binding is initialised — and
3813
+ // only the ORDER differs; moving the `let` above the functions compiles. It took hyrule's
3814
+ // whole ROM down (`rangDone` at weapons.tish:28, `rangLive` at :46).
3815
+ //
3816
+ // Reordered here rather than at the capture site because the capture is not wrong; its
3817
+ // position is. Restricted to LITERAL initialisers on purpose: hoisting `let x = f()` would
3818
+ // move a call, changing when its side effects run and possibly reading state the statements
3819
+ // above it establish. A literal cannot observe or disturb anything, so moving it is not
3820
+ // visible to the program.
3821
+ let reordered = Self::hoist_forward_referenced_module_bindings(&program.statements);
3822
+ self.emit_statements_with_folds(reordered.as_deref().unwrap_or(&program.statements))?;
3679
3823
  if self.is_async {
3680
3824
  self.async_context_stack.pop();
3681
3825
  }
@@ -4101,9 +4245,26 @@ impl Codegen {
4101
4245
  // `emit_int32_operand` (a `>>> 0` result is u32 reinterpreted to i32; signed
4102
4246
  // bitwise ops yield i32 directly) — so store the i32 with NO `f64` round-trip.
4103
4247
  if rust_type == RustType::I32 {
4248
+ // #599: A CAPTURED i32 IS A CELL, AND THE WRITE HAS TO SAY SO. Reads of a
4249
+ // wrapped scalar already go through `vm_read(&x)`; every store below used to
4250
+ // emit a bare `x = <i32>`, so the two halves disagreed and the crate failed with
4251
+ // `expected VmRef<i32>, found i32`. Net effect was that a mutable module-level
4252
+ // typed scalar could not be used at all on GBA — every counter had to move into
4253
+ // a function body or hide in a one-element `i32[]`.
4254
+ //
4255
+ // The narrow-int branch below has always done this; the i32 branch is the one
4256
+ // that was missed, across all three of its store shapes.
4257
+ let is_refcell = self.refcell_wrapped_vars.contains(name.as_ref());
4258
+ let store = |escaped: &str, val: String| {
4259
+ if is_refcell {
4260
+ format!("{{ let _t = {}; *{}.borrow_mut() = _t; }}", val, escaped)
4261
+ } else {
4262
+ format!("{} = {}", escaped, val)
4263
+ }
4264
+ };
4104
4265
  if let Some(int_code) = self.emit_int32_operand(value)? {
4105
4266
  let escaped = Self::escape_ident(name.as_ref());
4106
- return Ok(format!("{} = ({}) as i32", escaped, int_code));
4267
+ return Ok(store(&escaped, format!("({}) as i32", int_code)));
4107
4268
  }
4108
4269
  let (val_code, val_ty) = self.emit_typed_expr(value)?;
4109
4270
  // An RHS that is ALREADY an integer stores straight into the register. `d = d + 1`
@@ -4112,7 +4273,7 @@ impl Codegen {
4112
4273
  // out of it was the whole cost of incrementing an annotated counter.
4113
4274
  let escaped_int = Self::escape_ident(name.as_ref());
4114
4275
  if val_ty.is_integer_scalar() {
4115
- return Ok(format!("{} = ({}) as i32", escaped_int, val_code));
4276
+ return Ok(store(&escaped_int, format!("({}) as i32", val_code)));
4116
4277
  }
4117
4278
  // Defensive: gate guarantees `Some`, but if a future RHS shape slips through,
4118
4279
  // fall back to a sound f64-narrowed store rather than miscompiling.
@@ -4122,11 +4283,7 @@ impl Codegen {
4122
4283
  val_code
4123
4284
  };
4124
4285
  let escaped = Self::escape_ident(name.as_ref());
4125
- return Ok(format!(
4126
- "{} = {}",
4127
- escaped,
4128
- RustType::I32.from_value_expr(&v)
4129
- ));
4286
+ return Ok(store(&escaped, RustType::I32.from_value_expr(&v)));
4130
4287
  }
4131
4288
  // String self-append `s = s + rhs` -> in-place push_str (amortized O(1)). The
4132
4289
  // general path boxes via `ops::add(Value::String(s.clone()), ...)` which clones
@@ -5777,6 +5934,34 @@ impl Codegen {
5777
5934
  })
5778
5935
  .unwrap_or(RustType::Value);
5779
5936
 
5937
+ // #597: AN ARRAY THAT ESCAPES INTO A BOXED CALL STAYS BOXED. A native `Vec<T>`
5938
+ // handed to a `value_call` is marshalled by BUILDING A FRESH `Value::Array` from
5939
+ // its elements — a copy by construction — so the callee's `push`es land in the copy
5940
+ // and the caller's array silently keeps its old contents. Nothing reports it: not
5941
+ // the compiler, not the runtime. An UNTYPED array passes the same `VmRef` and
5942
+ // aliases correctly, which means adding an annotation changed program BEHAVIOUR
5943
+ // rather than just representation.
5944
+ //
5945
+ // The call site cannot fix this — boxing a `Vec<i32>` into a `Value::Array` is a
5946
+ // copy however it is spelled. So the decision has to be made here, at the
5947
+ // declaration: if the array is ever passed as a call argument, keep it boxed.
5948
+ //
5949
+ // Same rule the read-only param analysis already applies from the other side
5950
+ // (`arr_param_readonly_fns` refuses an owned copy when a param is mutated, escapes
5951
+ // or is forwarded). Conservative on purpose: an array that never escapes is
5952
+ // untouched and keeps its native lowering.
5953
+ // GBA ONLY. The reporter checked the other backends: `tish run` and a native
5954
+ // `tish build` both alias correctly and print the expected result, so this is a
5955
+ // TARGET divergence rather than a language-wide one. Narrowing off-target would
5956
+ // deopt host code to fix a bug host code does not have, and one host test
5957
+ // (`escaping_array_keeps_oob_safe_lowering`) pins the native store it would remove.
5958
+ if self.emit_mode == crate::NativeEmitMode::Gba
5959
+ && matches!(rust_type, RustType::Vec(_))
5960
+ && self.arrays_passed_to_calls.contains(name.as_ref())
5961
+ {
5962
+ rust_type = RustType::Value;
5963
+ }
5964
+
5780
5965
  // M5 native-fn body: `let r = genRandom(1)` without a `: number` annotation.
5781
5966
  if self.native_fn_body_emit && rust_type == RustType::Value {
5782
5967
  if let Some(Expr::Call { callee, .. }) = init.as_ref() {
@@ -5816,9 +6001,26 @@ impl Codegen {
5816
6001
  // `s = s + arr[i]`, JS string concat) must stay a boxed `Value` — a native-f64
5817
6002
  // store would panic at the `from_value_expr(F64)` coercion. See
5818
6003
  // `demoted_numeric_locals`.
5819
- if rust_type == RustType::F64 && self.demoted_numeric_locals.contains(name.as_ref())
5820
- {
5821
- rust_type = RustType::Value;
6004
+ // #614: look up (declaring scope, name). A module-level binding was decided
6005
+ // program-wide under scope `""`; a function-local under its top-level fn. Both are
6006
+ // checked because this same emission path serves module code and fn bodies, and a
6007
+ // fn-local name may legitimately shadow a module one.
6008
+ // #614: look up (declaring scope, name). A fn-local is keyed under its top-level
6009
+ // fn; a module binding under `""`. Both are consulted because this same emission
6010
+ // path serves module code and fn bodies.
6011
+ // #614: key by the scope this declaration belongs to. No module fallback — the
6012
+ // statement being emitted IS a declaration, so the variable is by definition owned
6013
+ // by the current scope: inside a fn it is that fn's local (`scope` = the top-level
6014
+ // fn), at module level `scope` is `""`, which is the module key itself. Consulting
6015
+ // the module verdict for a fn-local is exactly the false positive this fixes.
6016
+ if rust_type == RustType::F64 {
6017
+ let scope = self.demote_scope_stack.first().cloned().unwrap_or_default();
6018
+ if self
6019
+ .demoted_numeric_locals
6020
+ .contains(&(scope, name.to_string()))
6021
+ {
6022
+ rust_type = RustType::Value;
6023
+ }
5822
6024
  }
5823
6025
 
5824
6026
  // Native-vec call initializer: `let cum = cumulative(probs)` → `Vec<f64>`.
@@ -5904,12 +6106,22 @@ impl Codegen {
5904
6106
  return Ok(());
5905
6107
  }
5906
6108
 
5907
- // Module-level const f64 arrays — no local slot in `run()`.
6109
+ // Module-level const numeric arrays — no local slot in `run()`. The element type
6110
+ // must match the emitted representation, or a consumer reading the type context
6111
+ // coerces against a layout that is not there.
5908
6112
  if self.module_const_f64_arrays.contains_key(name.as_ref())
5909
6113
  && self.outer_vars_stack.len() == 1
5910
6114
  {
6115
+ let elem = if self
6116
+ .module_const_int_statics
6117
+ .contains(&Self::module_const_static(name.as_ref()))
6118
+ {
6119
+ RustType::I32
6120
+ } else {
6121
+ RustType::F64
6122
+ };
5911
6123
  self.type_context
5912
- .define(name.as_ref(), RustType::Vec(Box::new(RustType::F64)));
6124
+ .define(name.as_ref(), RustType::Vec(Box::new(elem)));
5913
6125
  return Ok(());
5914
6126
  }
5915
6127
 
@@ -6969,6 +7181,45 @@ impl Codegen {
6969
7181
  sibling_escaped, sibling_escaped
6970
7182
  ));
6971
7183
  }
7184
+ // #563: a fn from an OUTER function scope is not a sibling, so the `_ref`/`_cell`
7185
+ // path above never fires for it — but the enclosing frame already materialized it
7186
+ // as a plain `Value` local (module fns and imports arrive that way, via the
7187
+ // `let f = (*f_ref.borrow()).clone();` prelude). A `move` closure therefore MOVES
7188
+ // it, and the SECOND nested fn that names it fails:
7189
+ //
7190
+ // error[E0382]: use of moved value: `uiRowText`
7191
+ //
7192
+ // One nested fn compiles fine, which is why this reads as "nested functions are
7193
+ // broken" rather than "the second one is". Clone it in, exactly as the builtin list
7194
+ // below does for `console`/`Math` — same hazard, same remedy; that list is just a
7195
+ // hardcoded enumeration of the names that happened to hit it first.
7196
+ //
7197
+ // Scope-stack order matters: `.last()` is the current (sibling) scope and is
7198
+ // already handled, and scope 0 is module level. Anything in between is an
7199
+ // enclosing function's scope — those are the locals at risk.
7200
+ let outer_scope_fns: Vec<String> = {
7201
+ let depth = self.function_scope_stack.len();
7202
+ let mut seen = std::collections::HashSet::new();
7203
+ let mut out = Vec::new();
7204
+ for scope in self.function_scope_stack.iter().take(depth.saturating_sub(1)) {
7205
+ for f in scope {
7206
+ if f.as_str() != name_raw
7207
+ && referenced.contains(f.as_str())
7208
+ && !sibling_fns.iter().any(|s| s == f)
7209
+ && !outer_vars.iter().any(|v| v == f)
7210
+ && !outer_params.iter().any(|p| p == f)
7211
+ && seen.insert(f.clone())
7212
+ {
7213
+ out.push(f.clone());
7214
+ }
7215
+ }
7216
+ }
7217
+ out
7218
+ };
7219
+ for f in &outer_scope_fns {
7220
+ let escaped = Self::escape_ident(f);
7221
+ self.writeln(&format!("let {} = {}.clone();", escaped, escaped));
7222
+ }
6972
7223
  // Clone outer parameters so they can be captured by the move closure
6973
7224
  for outer_param in &outer_params {
6974
7225
  let param_escaped = Self::escape_ident(outer_param);
@@ -7323,9 +7574,12 @@ impl Codegen {
7323
7574
  for v in &body_cell_vars {
7324
7575
  self.refcell_wrapped_vars.insert(v.clone());
7325
7576
  }
7577
+ // #614: this body's locals were analyzed under THIS fn's scope.
7578
+ self.demote_scope_stack.push(name_raw.to_string());
7326
7579
  for s in statements {
7327
7580
  self.emit_statement(s)?;
7328
7581
  }
7582
+ self.demote_scope_stack.pop();
7329
7583
  for v in &body_cell_vars {
7330
7584
  self.refcell_wrapped_vars.remove(v);
7331
7585
  }
@@ -7337,7 +7591,9 @@ impl Codegen {
7337
7591
  self.outer_vars_stack.push(Vec::new());
7338
7592
  self.rc_cell_storage_scopes
7339
7593
  .push(std::collections::HashSet::new());
7594
+ self.demote_scope_stack.push(name_raw.to_string()); // #614
7340
7595
  self.emit_statement(body)?;
7596
+ self.demote_scope_stack.pop();
7341
7597
  self.function_scope_stack.pop();
7342
7598
  self.outer_vars_stack.pop();
7343
7599
  self.rc_cell_storage_scopes.pop();
@@ -7954,7 +8210,7 @@ impl Codegen {
7954
8210
  ));
7955
8211
  }
7956
8212
  if let Some(static_name) = self.module_const_f64_array_rust_ref(name.as_ref()) {
7957
- let v = Self::module_const_f64_array_as_value(&static_name);
8213
+ let v = self.module_const_f64_array_as_value(&static_name);
7958
8214
  return Ok(if self.value_fn_depth > 0 || !self.loop_stack.is_empty() {
7959
8215
  format!("({}).clone()", v)
7960
8216
  } else {
@@ -9098,10 +9354,10 @@ impl Codegen {
9098
9354
  // `value_call` dispatch through the namespace object. Falls through to the boxed path
9099
9355
  // when the arg count/kind doesn't match (a dynamic/spread call stays correct).
9100
9356
  if let Expr::Ident { name: fname, .. } = callee.as_ref() {
9101
- if let Some(sig) = self.extern_fns.get(fname.as_ref()).cloned() {
9102
- if args.len() == sig.params.len()
9103
- && args.iter().all(|a| matches!(a, CallArg::Expr(_)))
9104
- {
9357
+ if let Some((sig, suffix)) =
9358
+ self.extern_sig_for_arity(fname.as_ref(), args.len())
9359
+ {
9360
+ if args.iter().all(|a| matches!(a, CallArg::Expr(_))) {
9105
9361
  let mut argc: Vec<String> = Vec::with_capacity(args.len());
9106
9362
  for (a, ty) in args.iter().zip(sig.params.iter()) {
9107
9363
  if let CallArg::Expr(e) = a {
@@ -9109,9 +9365,10 @@ impl Codegen {
9109
9365
  }
9110
9366
  }
9111
9367
  let call = format!(
9112
- "{}::{}_typed({})",
9368
+ "{}::{}{}({})",
9113
9369
  sig.crate_name,
9114
9370
  sig.symbol,
9371
+ suffix,
9115
9372
  argc.join(", ")
9116
9373
  );
9117
9374
  // This is a Value-producing context: a `void` extern yields `Null`; a
@@ -12444,9 +12701,16 @@ impl Codegen {
12444
12701
  /// function that mixes the two. A function whose whole signature is `i32` has one convention
12445
12702
  /// and keeps the coercion at the boundary, where the caller does it once on an argument that is
12446
12703
  /// usually already an integer.
12704
+ ///
12705
+ /// #647: ZERO PARAMETERS QUALIFY. The rule above is about keeping ONE coercion convention
12706
+ /// across a signature — and a signature with no parameters has nothing to be inconsistent
12707
+ /// about, so the reason the rule exists cannot apply to it. Excluding it cost every niladic
12708
+ /// accessor (`rCols()`, `rPocket()`, `atkLines()`) a boxed `value_call`: measured at 55 ticks
12709
+ /// against ~0 for the identical body taking one argument. The body proof still runs, so a
12710
+ /// zero-arg fn that touches something unlowerable is rejected on its body, as before — this
12711
+ /// only stops rejecting it on its (empty) parameter list.
12447
12712
  fn fn_sig_all_i32(params: &[FunParam], return_type: &Option<TypeAnnotation>) -> bool {
12448
12713
  return_type.as_ref().is_some_and(Self::ann_is_i32)
12449
- && !params.is_empty()
12450
12714
  && params.iter().all(|p| {
12451
12715
  matches!(p, FunParam::Simple(tp)
12452
12716
  if tp.default.is_none()
@@ -12711,23 +12975,105 @@ impl Codegen {
12711
12975
  }
12712
12976
  }
12713
12977
 
12714
- fn collect_demoted_numeric_locals(&self, stmts: &[Statement]) -> HashSet<String> {
12715
- // 1. Flat env: every annotated local/param name → its native `RustType`.
12978
+ fn collect_demoted_numeric_locals(&self, stmts: &[Statement]) -> HashSet<(String, String)> {
12979
+ // #614: the analysis below is UNCHANGED — same env, same reassignment set, same fixpoint.
12980
+ // What changed is the KEY. Previously a bare name, so one boxed `i` anywhere in the bundle
12981
+ // demoted every `i` in every module (~40-60 cycles per boxed use against 2-4 native); with
12982
+ // 44 of tish-gba's 70 packages untyped, a legacy package silently de-optimized a fully
12983
+ // modernized one that merely shared a loop-variable name.
12984
+ //
12985
+ // Scope granularity is the TOP-LEVEL FN, or `""` for module level. Each fn is analyzed from
12986
+ // its own `FunDecl` statement — not just its body — so params (including a REST param, whose
12987
+ // `Vec<f64>` element type is what keeps `total` native in `typed_assign_conversion`) are
12988
+ // collected exactly as the flat pass collected them. Nested fns fold into their enclosing
12989
+ // top-level fn: still conservative, but bounded to one function instead of the whole bundle.
12990
+ let mut out: HashSet<(String, String)> = HashSet::new();
12991
+
12992
+ // Module-level names keep the program-wide verdict: a module `let` is reassignable from
12993
+ // inside any fn, so whether it can go non-numeric is genuinely a whole-program property.
12994
+ let module_names: HashSet<String> = stmts
12995
+ .iter()
12996
+ .filter_map(|s| match s {
12997
+ Statement::VarDecl { name, .. } => Some(name.to_string()),
12998
+ _ => None,
12999
+ })
13000
+ .collect();
13001
+
12716
13002
  let mut env: HashMap<String, RustType> = HashMap::new();
12717
13003
  Self::collect_annotated_types(stmts, &self.type_aliases, &mut env);
12718
- // 2. Every reassignment `(name, rhs)` anywhere in the program (incl. nested exprs/closures).
12719
13004
  let mut reassigns: Vec<(String, &Expr)> = Vec::new();
12720
13005
  Self::collect_reassignments_stmts(stmts, &mut reassigns);
12721
- // 3. Fixpoint: demote a `number` local whose any reassignment RHS isn't native `f64`.
13006
+ for name in self.demote_fixpoint(&mut env, &reassigns, &module_names) {
13007
+ out.insert((String::new(), name));
13008
+ }
13009
+
13010
+ // A fn's env must START from what MODULE scope makes visible to it. Without that, a body
13011
+ // reading a module binding — `sum = (sum + codes[j]) % M`, where `codes` is a hoisted const
13012
+ // f64 array — cannot type the RHS, wrongly demotes `sum` to `Value`, and the emitted code
13013
+ // then adds an `f64` static to a `Value`: `error[E0369]: cannot add f64 to Value`. The flat
13014
+ // pass had this information for free; scoping the KEY must not scope away the VISIBILITY.
13015
+ //
13016
+ // Module-only, and carrying the module verdicts just settled above: a demoted module binding
13017
+ // reads as `Value` inside a fn too, which is exactly what its declaration will emit.
13018
+ let mut module_env: HashMap<String, RustType> = HashMap::new();
13019
+ for s in stmts {
13020
+ if !matches!(s, Statement::FunDecl { .. }) {
13021
+ Self::collect_annotated_types(std::slice::from_ref(s), &self.type_aliases, &mut module_env);
13022
+ }
13023
+ }
13024
+ for (scope, n) in &out {
13025
+ if scope.is_empty() {
13026
+ module_env.insert(n.clone(), RustType::Value);
13027
+ }
13028
+ }
13029
+
13030
+ for s in stmts {
13031
+ let Statement::FunDecl { name, .. } = s else {
13032
+ continue;
13033
+ };
13034
+ let scope = name.to_string();
13035
+ let one = std::slice::from_ref(s);
13036
+ // Module bindings first, then this fn's own declarations OVERLAY them — so a local that
13037
+ // shadows a module name takes its own type, not the module one.
13038
+ // What this fn itself declares — computed separately from the visibility env, because
13039
+ // only these names may take a verdict under this scope. INCLUDES a name that shadows a
13040
+ // module binding: a shadowing local is a different variable and gets its own verdict;
13041
+ // leaving it out drops it back onto the module's verdict, which is the bug being fixed.
13042
+ let mut own: HashMap<String, RustType> = HashMap::new();
13043
+ Self::collect_annotated_types(one, &self.type_aliases, &mut own);
13044
+ let local_names: HashSet<String> = own.keys().cloned().collect();
13045
+ let mut fenv: HashMap<String, RustType> = module_env.clone();
13046
+ fenv.extend(own);
13047
+ if local_names.is_empty() {
13048
+ continue;
13049
+ }
13050
+ let mut freassigns: Vec<(String, &Expr)> = Vec::new();
13051
+ Self::collect_reassignments_stmts(one, &mut freassigns);
13052
+ for n in self.demote_fixpoint(&mut fenv, &freassigns, &local_names) {
13053
+ out.insert((scope.clone(), n));
13054
+ }
13055
+ }
13056
+ out
13057
+ }
13058
+
13059
+ /// #614: the demotion fixpoint, factored out so the module scope and every function scope run
13060
+ /// the IDENTICAL rule. Only names in `considered` may demote; `env` is updated in place so a
13061
+ /// demoted name's later reads see `Value` — that feedback is what makes it a fixpoint.
13062
+ fn demote_fixpoint(
13063
+ &self,
13064
+ env: &mut HashMap<String, RustType>,
13065
+ reassigns: &[(String, &Expr)],
13066
+ considered: &HashSet<String>,
13067
+ ) -> HashSet<String> {
12722
13068
  let mut demoted: HashSet<String> = HashSet::new();
12723
13069
  loop {
12724
13070
  let mut changed = false;
12725
- for (name, rhs) in &reassigns {
12726
- if demoted.contains(name) {
13071
+ for (name, rhs) in reassigns {
13072
+ if demoted.contains(name) || !considered.contains(name) {
12727
13073
  continue;
12728
13074
  }
12729
13075
  if env.get(name) == Some(&RustType::F64)
12730
- && self.expr_native_type(rhs, &env) != RustType::F64
13076
+ && self.expr_native_type(rhs, env) != RustType::F64
12731
13077
  {
12732
13078
  demoted.insert(name.clone());
12733
13079
  env.insert(name.clone(), RustType::Value);
@@ -12939,8 +13285,28 @@ impl Codegen {
12939
13285
  }
12940
13286
 
12941
13287
  fn index_in_bounds(&self, index: &Expr, arr: &str) -> bool {
12942
- let Some(len) = self.vec_fixed_len.get(arr) else {
12943
- return false;
13288
+ // #645: a PROMOTED module-const array knows its own length unconditionally — it is a
13289
+ // `const G_X: [i32; N]`, fixed at emit time. `vec_fixed_len` is per-scope inference and is
13290
+ // NOT populated for it in every body, so the SAME expression proved in bounds inside a
13291
+ // function's native twin and failed inside its boxed closure — the twin got a direct
13292
+ // integer read while the closure, which is the copy a boxed call actually runs, kept the
13293
+ // bounds-checked `f64`/NaN form. Falling back to the static's own length makes the proof
13294
+ // scope-independent, which is what a compile-time constant deserves.
13295
+ let owned_len;
13296
+ let len = match self.vec_fixed_len.get(arr) {
13297
+ Some(l) => l,
13298
+ None => match self
13299
+ .module_const_f64_arrays
13300
+ .get(arr)
13301
+ .map(|v| v.len())
13302
+ .filter(|n| *n > 0)
13303
+ {
13304
+ Some(n) => {
13305
+ owned_len = BoundKey::Const(n as i64);
13306
+ &owned_len
13307
+ }
13308
+ None => return false,
13309
+ },
12944
13310
  };
12945
13311
  let Expr::Ident { name: idx, .. } = index else {
12946
13312
  if let Expr::Literal {
@@ -12956,6 +13322,32 @@ impl Codegen {
12956
13322
  return true;
12957
13323
  }
12958
13324
  }
13325
+ // #645: a MASK bounds its result exactly. For any i32 `x` and a non-negative literal
13326
+ // mask `M`, `x & M` lies in `[0, M]` — the AND clears every bit above M's highest,
13327
+ // including the sign bit, so a negative `x` cannot escape the range either. That makes
13328
+ // `A[k & 63]` provably in bounds for a 64-element `A` without knowing anything about
13329
+ // `k`.
13330
+ //
13331
+ // Worth the special case because it is the idiomatic way to index a power-of-two table,
13332
+ // and without it such a read misses the proven-in-bounds path and falls to the
13333
+ // bounds-checked form — which returns `f64` (it must be able to answer NaN), dragging
13334
+ // an otherwise all-integer expression through an f64 round trip on EVERY element.
13335
+ if let Expr::Binary { op: BinOp::BitAnd, left, right, .. } = index {
13336
+ let mask_of = |e: &Expr| -> Option<i64> {
13337
+ if let Expr::Literal { value: Literal::Number(n), .. } = e {
13338
+ Self::int_literal_value(*n).filter(|v| *v >= 0)
13339
+ } else {
13340
+ None
13341
+ }
13342
+ };
13343
+ if let Some(m) = mask_of(right).or_else(|| mask_of(left)) {
13344
+ if let BoundKey::Const(len) = len {
13345
+ if m < *len {
13346
+ return true;
13347
+ }
13348
+ }
13349
+ }
13350
+ }
12959
13351
  return self.sub_index_in_bounds(index, arr);
12960
13352
  };
12961
13353
  let idx = idx.as_ref();
@@ -14053,13 +14445,22 @@ impl Codegen {
14053
14445
  // so a RHS that reads the same cell (`a[i] += x`, `a[i] = a[j]`) releases its shared borrow
14054
14446
  // first (no overlapping lock → no deadlock), then store into the real element through the cell.
14055
14447
  if wrapped {
14056
- let needs_resize =
14057
- matches!(elem_type.as_ref(), RustType::F64 | RustType::Bool) && !in_bounds;
14448
+ // #611: I32 GROWS TOO. A boxed array absorbs `a[i] = v` past the end by growing; a
14449
+ // typed one used to fall through to a bare element store, so the same source line
14450
+ // behaved differently depending only on whether the annotation was present — the write
14451
+ // was lost with no error at compile time or run time. That silent divergence between
14452
+ // the typed and boxed forms is the worst of the available options, so the typed form
14453
+ // now matches the boxed one. Pad is `0`, which is what the READ path already answers
14454
+ // for an out-of-range `i32` element (`.get(i).copied().unwrap_or(0)`).
14455
+ let needs_resize = matches!(
14456
+ elem_type.as_ref(),
14457
+ RustType::F64 | RustType::Bool | RustType::I32
14458
+ ) && !in_bounds;
14058
14459
  let store = if needs_resize {
14059
- let pad = if matches!(elem_type.as_ref(), RustType::F64) {
14060
- "f64::NAN"
14061
- } else {
14062
- "false"
14460
+ let pad = match elem_type.as_ref() {
14461
+ RustType::F64 => "f64::NAN",
14462
+ RustType::I32 => "0",
14463
+ _ => "false",
14063
14464
  };
14064
14465
  format!(
14065
14466
  "let __i = {idx}; let __v = {val}; let mut __g = {esc}.borrow_mut(); if __i >= __g.len() {{ __g.resize(__i + 1, {pad}); }} __g[__i] = __v;",
@@ -14085,16 +14486,22 @@ impl Codegen {
14085
14486
  format!("{{ {}[{}] = {}; Value::Null }}", esc_obj, idx_usize, native_val)
14086
14487
  }
14087
14488
  }
14088
- RustType::F64 | RustType::Bool => {
14089
- let pad = if matches!(elem_type.as_ref(), RustType::F64) {
14090
- "f64::NAN"
14091
- } else {
14092
- "false"
14489
+ // #611: same grow-to-index rule on the unwrapped path — see the note above.
14490
+ RustType::F64 | RustType::Bool | RustType::I32 => {
14491
+ let pad = match elem_type.as_ref() {
14492
+ RustType::F64 => "f64::NAN",
14493
+ RustType::I32 => "0",
14494
+ _ => "false",
14093
14495
  };
14094
- format!(
14095
- "{{ let _idx = {}; if _idx >= {}.len() {{ {}.resize(_idx + 1, {}); }} {}[_idx] = {}; Value::Null }}",
14496
+ let store = format!(
14497
+ "{{ let _idx = {}; if _idx >= {}.len() {{ {}.resize(_idx + 1, {}); }} {}[_idx] = {}",
14096
14498
  idx_usize, esc_obj, esc_obj, pad, esc_obj, native_val
14097
- )
14499
+ );
14500
+ if side_effect_only {
14501
+ format!("{} }}", store)
14502
+ } else {
14503
+ format!("{}; Value::Null }}", store)
14504
+ }
14098
14505
  }
14099
14506
  _ if side_effect_only => {
14100
14507
  format!("{}[{}] = {}", esc_obj, idx_usize, native_val)
@@ -15411,7 +15818,7 @@ impl Codegen {
15411
15818
  /// statements (blocks, if, loops, switch, try, return/throw). `f` is responsible for recursing
15412
15819
  /// into each expression's own subtree. Does NOT descend into nested fn-decl bodies (a different
15413
15820
  /// lexical scope; a captured loop var would be RefCell-bailed before reaching here).
15414
- fn for_each_stmt_expr(s: &Statement, f: &mut dyn FnMut(&Expr)) {
15821
+ pub(crate) fn for_each_stmt_expr(s: &Statement, f: &mut dyn FnMut(&Expr)) {
15415
15822
  match s {
15416
15823
  Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
15417
15824
  for st in statements {
@@ -16798,7 +17205,15 @@ impl Codegen {
16798
17205
  {
16799
17206
  let sum_esc = Self::escape_ident(sum_name.as_ref());
16800
17207
  let arms: Vec<String> = (0..n)
16801
- .map(|i| format!("{} => {}[{}]", i, codes_static, i))
17208
+ // The fasta ladder is specialised around f64 arms, so an integer-represented
17209
+ // codes array widens here rather than perturbing that path.
17210
+ .map(|i| {
17211
+ if self.module_const_int_statics.contains(codes_static.as_str()) {
17212
+ format!("{} => {}[{}] as f64", i, codes_static, i)
17213
+ } else {
17214
+ format!("{} => {}[{}]", i, codes_static, i)
17215
+ }
17216
+ })
16802
17217
  .collect();
16803
17218
  self.writeln(&format!(
16804
17219
  "{} = (({} + match ({} as usize) {{ {}, _ => 0.0 }}) % 2147483647_f64);",
@@ -16956,6 +17371,105 @@ impl Codegen {
16956
17371
  self.native_fn_emit_name.is_some() || self.native_vec_ret.is_some()
16957
17372
  }
16958
17373
 
17374
+ /// #629: move module-level `let`s with LITERAL initialisers ahead of any function declared
17375
+ /// above them that references the name. Returns `None` when nothing needs moving, so the common
17376
+ /// program keeps its exact statement slice and this costs one scan.
17377
+ ///
17378
+ /// Only bindings that are actually forward-referenced move, and only past the point where they
17379
+ /// are first needed — the relative order of everything else is preserved.
17380
+ fn hoist_forward_referenced_module_bindings(stmts: &[Statement]) -> Option<Vec<Statement>> {
17381
+ // Names bound at module level by a literal `let`, with the index of their declaration.
17382
+ let mut literal_decls: Vec<(usize, String)> = Vec::new();
17383
+ for (i, s) in stmts.iter().enumerate() {
17384
+ if let Statement::VarDecl {
17385
+ name,
17386
+ init: Some(e),
17387
+ ..
17388
+ } = s
17389
+ {
17390
+ if Self::is_literal_initialiser(e) {
17391
+ literal_decls.push((i, name.to_string()));
17392
+ }
17393
+ }
17394
+ }
17395
+ if literal_decls.is_empty() {
17396
+ return None;
17397
+ }
17398
+ // A NAME DECLARED MORE THAN ONCE AT MODULE LEVEL IS NEVER MOVED. An earlier mention of such
17399
+ // a name refers to the EARLIER binding, not forward to this one, so hoisting this
17400
+ // declaration over it silently changes which value the earlier code sees. That is a wrong
17401
+ // ANSWER, not a build break: tests/core/js_compat_gaps.tish declares `text` twice — a string
17402
+ // at the top and a different string further down — and moving the second above the first
17403
+ // made an intervening `slice` read the wrong source, printing `hello 123 world 45` where
17404
+ // `.mdx` was expected.
17405
+ let mut decl_counts: std::collections::HashMap<&str, usize> =
17406
+ std::collections::HashMap::new();
17407
+ for s in stmts {
17408
+ if let Statement::VarDecl { name, .. } = s {
17409
+ *decl_counts.entry(name.as_ref()).or_insert(0) += 1;
17410
+ }
17411
+ }
17412
+ // A binding needs moving when a FUNCTION BODY above its declaration mentions it. Deferred
17413
+ // execution is what makes a forward reference legal — the body does not run until called.
17414
+ //
17415
+ // Straight-line code above the declaration is deliberately NOT counted: there the mention
17416
+ // either refers to a previous binding of the name or is a genuine use-before-init, and in
17417
+ // neither case may the declaration be moved over it.
17418
+ let mut needed: Vec<(usize, usize)> = Vec::new(); // (decl index, first referencing index)
17419
+ for (di, name) in &literal_decls {
17420
+ if decl_counts.get(name.as_str()).copied().unwrap_or(0) > 1 {
17421
+ continue;
17422
+ }
17423
+ let mut first = None;
17424
+ for (i, s) in stmts.iter().enumerate().take(*di) {
17425
+ if matches!(s, Statement::FunDecl { .. }) && Self::stmt_mentions_ident(s, name) {
17426
+ first = Some(i);
17427
+ break;
17428
+ }
17429
+ }
17430
+ if let Some(f) = first {
17431
+ needed.push((*di, f));
17432
+ }
17433
+ }
17434
+ if needed.is_empty() {
17435
+ return None;
17436
+ }
17437
+ // Rebuild: each moved declaration is inserted immediately before the first statement that
17438
+ // references it, keeping every other statement in place.
17439
+ let mut out: Vec<Statement> = Vec::with_capacity(stmts.len());
17440
+ let moved: std::collections::HashSet<usize> = needed.iter().map(|(d, _)| *d).collect();
17441
+ for (i, s) in stmts.iter().enumerate() {
17442
+ for (di, fi) in &needed {
17443
+ if *fi == i {
17444
+ out.push(stmts[*di].clone());
17445
+ }
17446
+ }
17447
+ if !moved.contains(&i) {
17448
+ out.push(s.clone());
17449
+ }
17450
+ }
17451
+ Some(out)
17452
+ }
17453
+
17454
+ /// An initialiser that cannot observe or disturb program state, so moving it is invisible.
17455
+ /// Deliberately narrow — array/object literals are included only when every element is itself
17456
+ /// literal, so no call or property read comes along for the ride.
17457
+ fn is_literal_initialiser(e: &Expr) -> bool {
17458
+ match e {
17459
+ Expr::Literal { .. } => true,
17460
+ Expr::Unary { operand, .. } => Self::is_literal_initialiser(operand),
17461
+ Expr::Array { elements, .. } => elements.iter().all(|el| match el {
17462
+ ArrayElement::Expr(e) => Self::is_literal_initialiser(e),
17463
+ _ => false,
17464
+ }),
17465
+ Expr::Object { props, .. } => props.iter().all(|p| match p {
17466
+ ObjectProp::KeyValue(_, v, _) => Self::is_literal_initialiser(v),
17467
+ _ => false,
17468
+ }),
17469
+ _ => false,
17470
+ }
17471
+ }
17472
+
16959
17473
  fn emit_statements_with_folds(&mut self, statements: &[Statement]) -> Result<(), CompileError> {
16960
17474
  let mut i = 0;
16961
17475
  while i < statements.len() {
@@ -17155,41 +17669,82 @@ impl Codegen {
17155
17669
  Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
17156
17670
  Self::walk_stmts_for_module_const_disqualifiers(statements, g, bad);
17157
17671
  }
17672
+ // EVERY child expression has to be visited, not just the sub-statements. These arms used
17673
+ // to walk only the bodies, so a use in a loop or branch CONDITION was invisible — which
17674
+ // is how `while (i < SB_FORCE.length)` (card-gba qb-engine.tish) kept an array eligible
17675
+ // for hoisting even though `.length` is a disqualifying member use. Same defect class as
17676
+ // the missing closure arm below: the walker's silence meant "safe", not "absent".
17158
17677
  Statement::If {
17678
+ cond,
17159
17679
  then_branch,
17160
17680
  else_branch,
17161
17681
  ..
17162
17682
  } => {
17683
+ Self::walk_expr_for_module_const_disqualifiers(cond, g, bad);
17163
17684
  Self::walk_stmt_for_module_const_disqualifiers(then_branch, g, bad);
17164
17685
  if let Some(eb) = else_branch {
17165
17686
  Self::walk_stmt_for_module_const_disqualifiers(eb, g, bad);
17166
17687
  }
17167
17688
  }
17168
- Statement::While { body, .. } | Statement::DoWhile { body, .. } => {
17689
+ Statement::While { cond, body, .. } | Statement::DoWhile { body, cond, .. } => {
17690
+ Self::walk_expr_for_module_const_disqualifiers(cond, g, bad);
17169
17691
  Self::walk_stmt_for_module_const_disqualifiers(body, g, bad);
17170
17692
  }
17171
- Statement::For { init, body, .. } => {
17693
+ Statement::For {
17694
+ init,
17695
+ cond,
17696
+ update,
17697
+ body,
17698
+ ..
17699
+ } => {
17172
17700
  if let Some(i) = init {
17173
17701
  Self::walk_stmt_for_module_const_disqualifiers(i, g, bad);
17174
17702
  }
17703
+ if let Some(c) = cond {
17704
+ Self::walk_expr_for_module_const_disqualifiers(c, g, bad);
17705
+ }
17706
+ if let Some(u) = update {
17707
+ Self::walk_expr_for_module_const_disqualifiers(u, g, bad);
17708
+ }
17175
17709
  Self::walk_stmt_for_module_const_disqualifiers(body, g, bad);
17176
17710
  }
17177
- Statement::ForOf { body, .. }
17178
- | Statement::ForIn { body, .. } => {
17711
+ Statement::ForOf { iterable, body, .. }
17712
+ | Statement::ForIn {
17713
+ object: iterable,
17714
+ body,
17715
+ ..
17716
+ } => {
17717
+ // ITERATING THE ARRAY IS A SUPPORTED USE, not a disqualifying one: `for (x of X)`
17718
+ // lowers to a native index loop over the hoisted static (`for _fof_i0 in
17719
+ // 0..G_X.len()`), so the name never survives into the output. Walking the iterable
17720
+ // unconditionally would see a bare `Ident` and refuse the hoist, which loses the
17721
+ // very optimisation the pass exists for (tests/core/typed_array_forof.tish).
17722
+ // Anything else in iterable position is walked normally.
17723
+ if !matches!(iterable, Expr::Ident { name, .. } if name.as_ref() == g) {
17724
+ Self::walk_expr_for_module_const_disqualifiers(iterable, g, bad);
17725
+ }
17179
17726
  Self::walk_stmt_for_module_const_disqualifiers(body, g, bad);
17180
17727
  }
17181
17728
  Statement::Switch {
17729
+ expr,
17182
17730
  cases,
17183
17731
  default_body,
17184
17732
  ..
17185
17733
  } => {
17186
- for (_, stmts) in cases {
17734
+ Self::walk_expr_for_module_const_disqualifiers(expr, g, bad);
17735
+ for (case_expr, stmts) in cases {
17736
+ if let Some(e) = case_expr {
17737
+ Self::walk_expr_for_module_const_disqualifiers(e, g, bad);
17738
+ }
17187
17739
  Self::walk_stmts_for_module_const_disqualifiers(stmts, g, bad);
17188
17740
  }
17189
17741
  if let Some(stmts) = default_body {
17190
17742
  Self::walk_stmts_for_module_const_disqualifiers(stmts, g, bad);
17191
17743
  }
17192
17744
  }
17745
+ Statement::Throw { value, .. } => {
17746
+ Self::walk_expr_for_module_const_disqualifiers(value, g, bad);
17747
+ }
17193
17748
  Statement::Try {
17194
17749
  body,
17195
17750
  catch_body,
@@ -17220,10 +17775,32 @@ impl Codegen {
17220
17775
  Self::walk_expr_for_module_const_disqualifiers(e, g, bad);
17221
17776
  }
17222
17777
  }
17223
- _ => {}
17778
+ // CONSERVATIVE DEFAULT. This walker decides whether it is safe to move an array into a
17779
+ // `const`, so "I do not model this statement" must mean "refuse", never "permit" — the
17780
+ // old `_ => {}` meant the latter, and a wrong permit is a program that does not compile.
17781
+ // Over-rejecting only costs the hoist; under-rejecting cost four ROMs their build.
17782
+ s => {
17783
+ if Self::stmt_mentions_ident(s, g) {
17784
+ *bad = true;
17785
+ }
17786
+ }
17224
17787
  }
17225
17788
  }
17226
17789
 
17790
+ /// Complete-traversal membership test, used as the safe fallback by both walkers above.
17791
+ fn stmt_mentions_ident(s: &Statement, g: &str) -> bool {
17792
+ let mut idents = HashSet::new();
17793
+ Self::collect_stmt_idents(s, &mut idents);
17794
+ idents.contains(g)
17795
+ }
17796
+
17797
+ /// As `stmt_mentions_ident`, for an expression subtree.
17798
+ fn expr_mentions_ident(e: &Expr, g: &str) -> bool {
17799
+ let mut idents = HashSet::new();
17800
+ Self::collect_expr_idents(e, &mut idents);
17801
+ idents.contains(g)
17802
+ }
17803
+
17227
17804
  fn walk_expr_for_module_const_disqualifiers(e: &Expr, g: &str, bad: &mut bool) {
17228
17805
  if *bad {
17229
17806
  return;
@@ -17281,9 +17858,51 @@ impl Codegen {
17281
17858
  callee.as_ref(),
17282
17859
  Expr::Ident { name, .. } if name.as_ref() == "cumulative"
17283
17860
  );
17861
+ // A METHOD CALL ON THE ARRAY IS SUPPORTED — but only the ones that go through the
17862
+ // `Value` bridge. `X.join(", ")` emits the object as
17863
+ // `NumArrayBacking::Packed(G_X…)`, so the local name does not survive and the hoist
17864
+ // is fine (tests/core/template_literals.tish).
17865
+ //
17866
+ // THE FUSEABLE HOFs ARE NOT. `X.map(...)`, `X.reduce(...)` and their siblings lower
17867
+ // to a NATIVE iterator chain — `xs.iter().copied().map(...)` — which names the local
17868
+ // directly and is NOT rewritten to the static. Exempting them hoisted an array whose
17869
+ // every use then referenced a binding that no longer existed:
17870
+ //
17871
+ // let mut sum: f64 = { … for b in xs.iter().copied() { … } … };
17872
+ // error[E0425]: cannot find value `xs` in this scope
17873
+ //
17874
+ // Caught by the perf gauntlet's `typed_array_hof` fixture on the NATIVE target —
17875
+ // this walker is not GBA-gated, so a too-permissive exemption breaks the host.
17876
+ //
17877
+ // Nor does the exemption extend to a member READ such as `X.length`, which lowers to
17878
+ // a bare `X.len()` — one of the two shapes that broke the ROMs.
17879
+ // ALLOWLIST, NOT DENYLIST. This started as "any method call except the fuseable
17880
+ // HOFs", which is allow-by-default — and every MUTATING method walked straight
17881
+ // through it. `arr2.unshift(1, 2)` hoisted its receiver into a `const` and then
17882
+ // tried to mutate it, so the local vanished and rustc reported
17883
+ // `cannot find value arr2` (tests/core/array_methods.tish, under
17884
+ // TISH_PACKED_ARRAYS=1). `push`/`pop`/`splice`/`sort`/`reverse`/`fill` are the same
17885
+ // shape.
17886
+ //
17887
+ // So only methods PROVEN to route the receiver through the `Value` bridge are
17888
+ // exempt. `join` is the one this pass actually needs
17889
+ // (tests/core/template_literals.tish emits
17890
+ // `array_join(&Value::NumberArray(…Packed(G_NUMS…)))`, where the local name does not
17891
+ // survive). Anything else — mutators, the HOFs that lower to a native iterator chain
17892
+ // over the bare local, and any method added later — disqualifies the hoist, which is
17893
+ // the safe direction: it costs an optimisation, not a build.
17894
+ let member_call_on_g = matches!(
17895
+ callee.as_ref(),
17896
+ Expr::Member { object, prop, .. }
17897
+ if matches!(object.as_ref(), Expr::Ident { name, .. } if name.as_ref() == g)
17898
+ && matches!(
17899
+ prop,
17900
+ MemberProp::Name { name: m, .. } if m.as_ref() == "join"
17901
+ )
17902
+ );
17284
17903
  if matches!(callee.as_ref(), Expr::Ident { name, .. } if name.as_ref() == g) {
17285
17904
  *bad = true;
17286
- } else {
17905
+ } else if !member_call_on_g {
17287
17906
  Self::walk_expr_for_module_const_disqualifiers(callee, g, bad);
17288
17907
  }
17289
17908
  for (i, a) in args.iter().enumerate() {
@@ -17334,7 +17953,75 @@ impl Codegen {
17334
17953
  }
17335
17954
  }
17336
17955
  }
17337
- _ => {}
17956
+ // Everything below was missing, and the omission is what made this pass unsafe: an
17957
+ // admission check whose walker reports "no disqualifying use" for a shape it does not
17958
+ // recognise is silently permissive. `ArrowFunction` was the expensive one — every use
17959
+ // inside a CLOSURE landed in `_ => {}`, so an array a closure writes got hoisted to a
17960
+ // `const` anyway. Reads then renamed to `G_X` while the write kept the boxed local, and
17961
+ // that local is declared after the closure: rustc reports `cannot find value` on a name
17962
+ // the game never misspelled (card-gba queensblood/triad/gwent; tish-gba hyrule's
17963
+ // `rangLive`). Renaming the write would not have been a fix either — a `const` cannot be
17964
+ // assigned through, so a written array must not be hoisted at all. The sibling scalar
17965
+ // walker has always had these arms; this one is a copy that lost them.
17966
+ //
17967
+ // Indexing stays deliberately absent from the disqualifier list: `X[i]` is the whole
17968
+ // point of the hoist, so descending here must not become "any closure mention kills it",
17969
+ // which would give back the #594 reachability this pass exists to provide.
17970
+ Expr::ArrowFunction { body, .. } => match body {
17971
+ ArrowBody::Expr(e) => Self::walk_expr_for_module_const_disqualifiers(e, g, bad),
17972
+ ArrowBody::Block(s) => Self::walk_stmt_for_module_const_disqualifiers(s, g, bad),
17973
+ },
17974
+ // A write to a member of the const, by the same rule the `Member` arm above uses.
17975
+ Expr::MemberAssign { object, value, .. } => {
17976
+ if matches!(object.as_ref(), Expr::Ident { name, .. } if name.as_ref() == g) {
17977
+ *bad = true;
17978
+ } else {
17979
+ Self::walk_expr_for_module_const_disqualifiers(object, g, bad);
17980
+ }
17981
+ Self::walk_expr_for_module_const_disqualifiers(value, g, bad);
17982
+ }
17983
+ Expr::Delete { target, .. }
17984
+ if matches!(target.as_ref(), Expr::Ident { name, .. } if name.as_ref() == g) =>
17985
+ {
17986
+ *bad = true;
17987
+ }
17988
+ // The remaining shapes need no special rule: a bare mention of the name is already
17989
+ // disqualifying via the `Ident` arm, so plain descent is sufficient.
17990
+ Expr::New { callee, args, .. } => {
17991
+ Self::walk_expr_for_module_const_disqualifiers(callee, g, bad);
17992
+ for a in args {
17993
+ if let CallArg::Expr(e) | CallArg::Spread(e) = a {
17994
+ Self::walk_expr_for_module_const_disqualifiers(e, g, bad);
17995
+ }
17996
+ }
17997
+ }
17998
+ Expr::NullishCoalesce { left, right, .. } => {
17999
+ Self::walk_expr_for_module_const_disqualifiers(left, g, bad);
18000
+ Self::walk_expr_for_module_const_disqualifiers(right, g, bad);
18001
+ }
18002
+ Expr::TypeOf { operand, .. } => {
18003
+ Self::walk_expr_for_module_const_disqualifiers(operand, g, bad);
18004
+ }
18005
+ Expr::TemplateLiteral { exprs, .. } => {
18006
+ for e in exprs {
18007
+ Self::walk_expr_for_module_const_disqualifiers(e, g, bad);
18008
+ }
18009
+ }
18010
+ // Leaves, listed explicitly so they do NOT reach the fallback below. A literal cannot
18011
+ // mention a binding, and there is one in nearly every expression in a real program —
18012
+ // routing those through a whole-subtree ident scan turned this walk superlinear and hung
18013
+ // the compiler outright (a `tish build` of `scii` spun at 100% CPU for 40+ minutes in the
18014
+ // frontend, no rustc involved). The fallback is for shapes that might CONTAIN a
18015
+ // reference, not for every constant in the file.
18016
+ Expr::Literal { .. } => {}
18017
+ // CONSERVATIVE DEFAULT — see the statement walker. An expression shape this match does
18018
+ // not model (today: `Await`, `JsxElement`, and anything the AST gains later) disqualifies
18019
+ // if the name appears anywhere beneath it, rather than being waved through.
18020
+ other => {
18021
+ if Self::expr_mentions_ident(other, g) {
18022
+ *bad = true;
18023
+ }
18024
+ }
17338
18025
  }
17339
18026
  }
17340
18027
 
@@ -17518,15 +18205,41 @@ impl Codegen {
17518
18205
  )
17519
18206
  }
17520
18207
 
17521
- fn module_const_f64_array_as_value(static_name: &str) -> String {
18208
+ /// The boxed bridge. `NumArrayBacking::Packed` is a `Vec<f64>`, so an `[i32; N]` hoist widens on
18209
+ /// the way out — this is the escape hatch where the array is handed to code that wants a
18210
+ /// `Value`, and it is a whole-array copy either way.
18211
+ fn module_const_f64_array_as_value(&self, static_name: &str) -> String {
18212
+ let elems = if self.module_const_int_statics.contains(static_name) {
18213
+ format!("{}.iter().map(|v| *v as f64)", static_name)
18214
+ } else {
18215
+ format!("{}.iter().copied()", static_name)
18216
+ };
17522
18217
  format!(
17523
- "Value::NumberArray(VmRef::new(tishlang_runtime::NumArrayBacking::Packed({}.iter().copied().collect::<Vec<f64>>())))",
17524
- static_name
18218
+ "Value::NumberArray(VmRef::new(tishlang_runtime::NumArrayBacking::Packed({}.collect::<Vec<f64>>())))",
18219
+ elems
17525
18220
  )
17526
18221
  }
17527
18222
 
17528
18223
  fn emit_module_const_f64_arrays(&mut self) -> Result<(), CompileError> {
17529
18224
  for (name, vals) in self.module_const_f64_arrays.clone() {
18225
+ let static_name = Self::module_const_static(name.as_str());
18226
+ // `[i32; N]` where the values permit it — half the ROM, and reads that stay in the
18227
+ // integer domain instead of round-tripping through soft float. See the field comment on
18228
+ // `module_const_int_statics`.
18229
+ if self.module_const_int_statics.contains(&static_name) {
18230
+ let lit = vals
18231
+ .iter()
18232
+ .map(|v| format!("{}", *v as i32))
18233
+ .collect::<Vec<_>>()
18234
+ .join(", ");
18235
+ self.writeln(&format!(
18236
+ "const {}: [i32; {}] = [{}];",
18237
+ static_name,
18238
+ vals.len(),
18239
+ lit
18240
+ ));
18241
+ continue;
18242
+ }
17530
18243
  let lit = vals
17531
18244
  .iter()
17532
18245
  .map(|v| Self::f64_lit(*v))
@@ -17534,7 +18247,7 @@ impl Codegen {
17534
18247
  .join(", ");
17535
18248
  self.writeln(&format!(
17536
18249
  "const {}: [f64; {}] = [{}];",
17537
- Self::module_const_static(name.as_str()),
18250
+ static_name,
17538
18251
  vals.len(),
17539
18252
  lit
17540
18253
  ));
@@ -17938,35 +18651,74 @@ impl Codegen {
17938
18651
  Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
17939
18652
  Self::walk_stmts_for_global_disqualifiers(statements, g, bad);
17940
18653
  }
18654
+ // Conditions are visited, not just bodies. This walker had the same blind spot the
18655
+ // module-const one did: `while (i < X.length)` put a disqualifying use somewhere the
18656
+ // walk never reached, so the global was hoisted anyway and the emitted program
18657
+ // referenced a name that no longer existed. Latent here only because a scalar is rarely
18658
+ // spelled that way — the defect is identical.
17941
18659
  Statement::If {
18660
+ cond,
17942
18661
  then_branch,
17943
18662
  else_branch,
17944
18663
  ..
17945
18664
  } => {
18665
+ Self::walk_expr_for_global_disqualifiers(cond, g, bad);
17946
18666
  Self::walk_stmt_for_global_disqualifiers(then_branch, g, bad);
17947
18667
  if let Some(eb) = else_branch {
17948
18668
  Self::walk_stmt_for_global_disqualifiers(eb, g, bad);
17949
18669
  }
17950
18670
  }
17951
- Statement::While { body, .. }
17952
- | Statement::DoWhile { body, .. }
17953
- | Statement::For { body, .. }
17954
- | Statement::ForOf { body, .. }
17955
- | Statement::ForIn { body, .. } => {
18671
+ Statement::While { cond, body, .. } | Statement::DoWhile { body, cond, .. } => {
18672
+ Self::walk_expr_for_global_disqualifiers(cond, g, bad);
18673
+ Self::walk_stmt_for_global_disqualifiers(body, g, bad);
18674
+ }
18675
+ Statement::For {
18676
+ init,
18677
+ cond,
18678
+ update,
18679
+ body,
18680
+ ..
18681
+ } => {
18682
+ if let Some(i) = init {
18683
+ Self::walk_stmt_for_global_disqualifiers(i, g, bad);
18684
+ }
18685
+ if let Some(c) = cond {
18686
+ Self::walk_expr_for_global_disqualifiers(c, g, bad);
18687
+ }
18688
+ if let Some(u) = update {
18689
+ Self::walk_expr_for_global_disqualifiers(u, g, bad);
18690
+ }
18691
+ Self::walk_stmt_for_global_disqualifiers(body, g, bad);
18692
+ }
18693
+ Statement::ForOf { iterable, body, .. }
18694
+ | Statement::ForIn {
18695
+ object: iterable,
18696
+ body,
18697
+ ..
18698
+ } => {
18699
+ Self::walk_expr_for_global_disqualifiers(iterable, g, bad);
17956
18700
  Self::walk_stmt_for_global_disqualifiers(body, g, bad);
17957
18701
  }
17958
18702
  Statement::Switch {
18703
+ expr,
17959
18704
  cases,
17960
18705
  default_body,
17961
18706
  ..
17962
18707
  } => {
17963
- for (_, stmts) in cases {
18708
+ Self::walk_expr_for_global_disqualifiers(expr, g, bad);
18709
+ for (case_expr, stmts) in cases {
18710
+ if let Some(e) = case_expr {
18711
+ Self::walk_expr_for_global_disqualifiers(e, g, bad);
18712
+ }
17964
18713
  Self::walk_stmts_for_global_disqualifiers(stmts, g, bad);
17965
18714
  }
17966
18715
  if let Some(d) = default_body {
17967
18716
  Self::walk_stmts_for_global_disqualifiers(d, g, bad);
17968
18717
  }
17969
18718
  }
18719
+ Statement::Throw { value, .. } => {
18720
+ Self::walk_expr_for_global_disqualifiers(value, g, bad);
18721
+ }
17970
18722
  Statement::Try {
17971
18723
  body,
17972
18724
  catch_body,
@@ -18165,6 +18917,151 @@ impl Codegen {
18165
18917
  ok
18166
18918
  }
18167
18919
 
18920
+ /// #597: every `let x: T[]` name in the program, at any nesting depth. These are the candidates
18921
+ /// whose escape has to be checked before they may lower to a native `Vec`.
18922
+ fn collect_annotated_array_names(stmts: &[Statement], out: &mut Vec<String>) {
18923
+ for s in stmts {
18924
+ Self::collect_annotated_array_names_stmt(s, out);
18925
+ }
18926
+ }
18927
+
18928
+ fn collect_annotated_array_names_stmt(s: &Statement, out: &mut Vec<String>) {
18929
+ match s {
18930
+ Statement::VarDecl {
18931
+ name,
18932
+ type_ann: Some(t),
18933
+ ..
18934
+ } => {
18935
+ if matches!(crate::types::RustType::from_annotation(t), RustType::Vec(_)) {
18936
+ out.push(name.to_string());
18937
+ }
18938
+ }
18939
+ Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
18940
+ for s in statements {
18941
+ Self::collect_annotated_array_names_stmt(s, out);
18942
+ }
18943
+ }
18944
+ Statement::If {
18945
+ then_branch,
18946
+ else_branch,
18947
+ ..
18948
+ } => {
18949
+ Self::collect_annotated_array_names_stmt(then_branch, out);
18950
+ if let Some(e) = else_branch {
18951
+ Self::collect_annotated_array_names_stmt(e, out);
18952
+ }
18953
+ }
18954
+ Statement::While { body, .. }
18955
+ | Statement::DoWhile { body, .. }
18956
+ | Statement::ForOf { body, .. }
18957
+ | Statement::ForIn { body, .. }
18958
+ | Statement::FunDecl { body, .. } => {
18959
+ Self::collect_annotated_array_names_stmt(body, out);
18960
+ }
18961
+ Statement::For { init, body, .. } => {
18962
+ if let Some(i) = init {
18963
+ Self::collect_annotated_array_names_stmt(i, out);
18964
+ }
18965
+ Self::collect_annotated_array_names_stmt(body, out);
18966
+ }
18967
+ Statement::Switch {
18968
+ cases,
18969
+ default_body,
18970
+ ..
18971
+ } => {
18972
+ for (_, stmts) in cases {
18973
+ for s in stmts {
18974
+ Self::collect_annotated_array_names_stmt(s, out);
18975
+ }
18976
+ }
18977
+ if let Some(stmts) = default_body {
18978
+ for s in stmts {
18979
+ Self::collect_annotated_array_names_stmt(s, out);
18980
+ }
18981
+ }
18982
+ }
18983
+ Statement::Try {
18984
+ body,
18985
+ catch_body,
18986
+ finally_body,
18987
+ ..
18988
+ } => {
18989
+ Self::collect_annotated_array_names_stmt(body, out);
18990
+ if let Some(c) = catch_body {
18991
+ Self::collect_annotated_array_names_stmt(c, out);
18992
+ }
18993
+ if let Some(f) = finally_body {
18994
+ Self::collect_annotated_array_names_stmt(f, out);
18995
+ }
18996
+ }
18997
+ _ => {}
18998
+ }
18999
+ }
19000
+
19001
+ /// #597: array names that ESCAPE — handed to a call as a bare argument, or otherwise used as a
19002
+ /// whole value rather than indexed.
19003
+ ///
19004
+ /// Reuses `scan_param_use`, the same analysis the read-only param path already trusts for this
19005
+ /// exact hazard: its `forwarded` flag is documented as "the callee could mutate the array, and a
19006
+ /// write to the caller's array would be lost on the owned copy". That is #597 from the other
19007
+ /// side, so the answer should come from one analysis rather than a second, subtly-different one.
19008
+ ///
19009
+ /// Over-approximating only costs an array its native lowering. Under-approximating brings back a
19010
+ /// silently wrong result, so the bias is deliberate.
19011
+ fn collect_escaping_array_names(stmts: &[Statement], names: &[String]) -> std::collections::HashSet<String> {
19012
+ // `for_each_stmt_expr` deliberately does NOT descend into `FunDecl` bodies, so the forwarding
19013
+ // call is normally invisible to it — the array is declared inside a function and passed
19014
+ // inside that same function, which is exactly the reported shape. Gather nested bodies as
19015
+ // additional roots first.
19016
+ fn add_roots<'a>(s: &'a Statement, roots: &mut Vec<&'a Statement>) {
19017
+ roots.push(s);
19018
+ match s {
19019
+ Statement::FunDecl { body, .. }
19020
+ | Statement::While { body, .. }
19021
+ | Statement::DoWhile { body, .. }
19022
+ | Statement::ForOf { body, .. }
19023
+ | Statement::ForIn { body, .. }
19024
+ | Statement::For { body, .. } => add_roots(body, roots),
19025
+ Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
19026
+ for s in statements {
19027
+ add_roots(s, roots);
19028
+ }
19029
+ }
19030
+ Statement::If {
19031
+ then_branch,
19032
+ else_branch,
19033
+ ..
19034
+ } => {
19035
+ add_roots(then_branch, roots);
19036
+ if let Some(e) = else_branch {
19037
+ add_roots(e, roots);
19038
+ }
19039
+ }
19040
+ _ => {}
19041
+ }
19042
+ }
19043
+ let mut roots: Vec<&Statement> = Vec::new();
19044
+ for s in stmts {
19045
+ add_roots(s, &mut roots);
19046
+ }
19047
+ let mut out = std::collections::HashSet::new();
19048
+ for n in names {
19049
+ let mut f = ParamUse::default();
19050
+ for s in &roots {
19051
+ Self::for_each_stmt_expr(s, &mut |e| Self::scan_param_use(e, n, false, &mut f));
19052
+ }
19053
+ // `forwarded` ONLY, not `escaped`. `escaped` covers any whole-value use — including
19054
+ // `return arr`, which hands ownership out and does not create the aliasing hazard this
19055
+ // is about. Using it deopted arrays that are legitimately native
19056
+ // (`numeric_returning_fn_keeps_pushed_array_native`). #597 is specifically "passed as a
19057
+ // call argument", which is what `forwarded` means.
19058
+ if f.forwarded {
19059
+ out.insert(n.clone());
19060
+ }
19061
+ }
19062
+ out
19063
+ }
19064
+
18168
19065
  fn for_each_expr_root(stmts: &[Statement], f: &mut dyn FnMut(&Expr)) {
18169
19066
  for s in stmts {
18170
19067
  Self::for_each_expr_root_stmt(s, f);
@@ -19542,16 +20439,27 @@ impl Codegen {
19542
20439
  matches!(op, UnaryOp::Neg | UnaryOp::Pos)
19543
20440
  && Self::inline_body_numeric(operand, params, self_name)
19544
20441
  }
19545
- Expr::Conditional {
19546
- cond,
19547
- then_branch,
19548
- else_branch,
19549
- ..
19550
- } => {
19551
- Self::inline_body_numeric(cond, params, self_name)
19552
- && Self::inline_body_numeric(then_branch, params, self_name)
19553
- && Self::inline_body_numeric(else_branch, params, self_name)
19554
- }
20442
+ // #637: A TERNARY IS NEVER INLINED. The substitution that rewrites a parameter to its
20443
+ // `_inl<N>_<param>` temporary reaches the condition but NOT the branches, so an inlined
20444
+ // ternary emits branches naming parameters that do not exist in the generated scope:
20445
+ //
20446
+ // if Value::Bool((_inl0_a > _inl0_b)).is_truthy() { a } else { b }
20447
+ // ^^^^^^^ substituted ^ ^ not substituted
20448
+ // error[E0425]: cannot find value `a` in this scope
20449
+ //
20450
+ // The set this arm used to admit was exactly the broken one. `inline_body_numeric`
20451
+ // accepts a bare param, so `flag ? 10 : 20` was inlined — and since the inliner binds
20452
+ // every param as `f64`, the emitter then asked an `f64` for `.is_truthy()`
20453
+ // (`error[E0614]: type f64 cannot be dereferenced`). A comparison cond such as
20454
+ // `a > b ? a : b` was never admitted here, because `Gt` is not in the arithmetic op list
20455
+ // above — which is why that shape compiled fine and only truthiness tests broke.
20456
+ //
20457
+ // So refusing outright REMOVES no working inline: every ternary this arm ever accepted
20458
+ // produced code that did not build from the second call site onward. Widening it instead
20459
+ // (admitting comparison conds) is what surfaced the branch-substitution gap, and fixing
20460
+ // that belongs with the substitution machinery, not here. Ternaries fall back to the
20461
+ // ordinary call: correct, and merely un-inlined.
20462
+ Expr::Conditional { .. } => false,
19555
20463
  Expr::Call { callee, args, .. } => {
19556
20464
  // Only 1-arg `Math.<intrinsic>` — never another fn (incl. self), to keep it a pure
19557
20465
  // expression with no dispatch and no recursion.
@@ -20084,6 +20992,14 @@ impl Codegen {
20084
20992
  }
20085
20993
  }
20086
20994
  }
20995
+ // #630: A NAME REMOVED HERE IS REJECTED FOR GOOD. `collect_bad_vec_callsites` is a
20996
+ // SOUNDNESS verdict — the fn is called somewhere in a way the native-vec ABI cannot
20997
+ // represent — and that fact is a property of the program, which does not change during this
20998
+ // analysis. Re-admitting it later can only produce the same wrong answer.
20999
+ //
21000
+ // Recording it is also what makes the forwarding fixpoint below terminate; see the note
21001
+ // there.
21002
+ let mut rejected: std::collections::HashSet<String> = std::collections::HashSet::new();
20087
21003
  loop {
20088
21004
  let mut remove: Vec<String> = Vec::new();
20089
21005
  for s in &program.statements {
@@ -20095,6 +21011,7 @@ impl Codegen {
20095
21011
  break;
20096
21012
  }
20097
21013
  for n in remove {
21014
+ rejected.insert(n.clone());
20098
21015
  sigs.remove(&n);
20099
21016
  }
20100
21017
  if sigs.is_empty() {
@@ -20130,6 +21047,22 @@ impl Codegen {
20130
21047
  if Self::ann_is_simple(return_type.as_ref(), "fixed") {
20131
21048
  continue;
20132
21049
  }
21050
+ // #630: AND IT MUST NOT RE-ADD A NAME THE REMOVAL PASS ALREADY REJECTED. This is
21051
+ // what makes the analysis terminate. Without it the two fixpoints fight: the
21052
+ // forwarding pass admits `F`, the removal pass below proves `F` unsound and
21053
+ // drops it, `added` is true so the outer loop runs again, and `F` — re-evaluated
21054
+ // against a program that has not changed — is admitted again. Forever.
21055
+ //
21056
+ // The only exits were `!added` and `sigs.is_empty()`, and an oscillating pair
21057
+ // satisfies neither: measured on the reporter's project it sat at a steady
21058
+ // `sigs=3` for 200+ iterations at 100% CPU, with no cargo child and nothing
21059
+ // printed, which is why it read as a dead build rather than a slow one.
21060
+ //
21061
+ // With this guard every outer iteration must admit a name never admitted before,
21062
+ // so the loop is bounded by the number of top-level fns.
21063
+ if rejected.contains(name.as_ref()) {
21064
+ continue;
21065
+ }
20133
21066
  if let Some(sig) = Self::infer_vec_fn_sig_forwarding(params, body, &sigs) {
20134
21067
  let has_array_param = sig
20135
21068
  .params
@@ -20156,6 +21089,7 @@ impl Codegen {
20156
21089
  break;
20157
21090
  }
20158
21091
  for n in remove {
21092
+ rejected.insert(n.clone());
20159
21093
  sigs.remove(&n);
20160
21094
  }
20161
21095
  if sigs.is_empty() {
@@ -20704,7 +21638,23 @@ impl Codegen {
20704
21638
  //
20705
21639
  // This does not close #594's other half — a lowered fn that can actually SEE module state,
20706
21640
  // which needs those bindings emitted as statics on GBA too. It stops the miscompile.
20707
- if self.emit_mode == crate::NativeEmitMode::Gba {
21641
+ // #633: THE SAME HOLE EXISTS OFF-GBA. The scoping above reasoned that off-target "the
21642
+ // statics DO exist" — true only for what those passes actually cover: numeric arrays and
21643
+ // numeric globals. A module binding of any OTHER shape (an array of ARRAYS, a string, an
21644
+ // object) gets no top-level home on ANY target, stays a `let` in `run()`, and is just as
21645
+ // unreachable from a free `fn name_nv` on host as on GBA:
21646
+ //
21647
+ // let COLS = [[1,2,3],[4,5]] // array of arrays → no static
21648
+ // function widths(){ let o=[]; …COLS[i].length…; return o }
21649
+ // widths(); widths() // ≥2 call sites → not inlined
21650
+ //
21651
+ // compiles to `error[E0425]: cannot find value COLS in this scope`. One call site hides it
21652
+ // (the fn is inlined and never lowered), which is why this surfaced as a cross-module
21653
+ // ordering bug in a large program rather than as the two-line case it actually is.
21654
+ //
21655
+ // The carve-out is what differs per target, not the reachability rule — so run the rejection
21656
+ // everywhere and let the per-target `remove` list below decide what still has a home.
21657
+ {
20708
21658
  let mut module_bindings = Self::collect_module_level_binding_names(&program.statements);
20709
21659
  // …EXCEPT the ones that now have a top-level home. Since #594 a module const numeric
20710
21660
  // array is a `const` and a mutable numeric global is a `SingleCore` static, both
@@ -24680,6 +25630,21 @@ impl Codegen {
24680
25630
  // in (-2^53, 2^53) and `c` a positive integer literal → `(x as i64) % c` instead
24681
25631
  // of `fmod`. Bit-identical (x is exactly an integer in f64; Rust `%` and `fmod`
24682
25632
  // both truncate toward zero), and far faster — the fmod in LCG/hash recurrences.
25633
+ // #606: a mixed `fixed`/`f64` pair types as `Fixed` (or `Bool` for a
25634
+ // comparison), so the f64 side must be LIFTED before the operator — agb's
25635
+ // `Num<i32,8>` has no `f64` overload. `coerce_native_arg` owns the scaling, so
25636
+ // a value crossing here reads identically to one crossing a Value boundary or
25637
+ // the literal fold.
25638
+ let (l, r) = if lt == RustType::Fixed && rt == RustType::F64 {
25639
+ let rc = self.coerce_native_arg(&r, RustType::F64, RustType::Fixed);
25640
+ (l, rc)
25641
+ } else if lt == RustType::F64 && rt == RustType::Fixed {
25642
+ let lc = self.coerce_native_arg(&l, RustType::F64, RustType::Fixed);
25643
+ (lc, r)
25644
+ } else {
25645
+ (l, r)
25646
+ };
25647
+
24683
25648
  if matches!(op, BinOp::Mod) && result_ty == RustType::F64 {
24684
25649
  if let Some(c) = Self::int_literal_value_of(right).filter(|&c| c > 0) {
24685
25650
  if self.int_range(left, &self.int_range_locals).is_some() {
@@ -24848,47 +25813,56 @@ impl Codegen {
24848
25813
  name.as_ref(),
24849
25814
  ) {
24850
25815
  let in_bounds = self.index_in_bounds(index, name.as_ref());
24851
- let (idx_code, idx_ty) = self.emit_typed_expr(index)?;
24852
- let idx_usize = if idx_ty == RustType::F64 {
24853
- format!("({}) as usize", idx_code)
25816
+ // #645: use the SHARED index lowering. This site used to accept only
25817
+ // `F64` natively and push everything else — including a perfectly good
25818
+ // integer expression — through `Value::Number(..)` and back, or through
25819
+ // an `i32 -> f64 -> usize` cast. On a target with no FPU that cast is a
25820
+ // soft-float call PER ELEMENT, which is most of what made a promoted
25821
+ // array's read expensive. `emit_index_usize` already has the int-domain
25822
+ // shortcut (`(int).max(0) as usize`, where `.max(0)` reproduces f64's
25823
+ // saturate-to-zero for a negative index).
25824
+ let idx_usize = self.emit_index_usize(index)?;
25825
+ let is_int = self.module_const_int_statics.contains(&static_name);
25826
+ // The proven-in-bounds read on an integer array is the whole point: a
25827
+ // plain word load, reported as I32 so the consumer stays in the integer
25828
+ // domain instead of converting back and forth through f64.
25829
+ if in_bounds {
25830
+ let access = format!("{}[{}]", static_name, idx_usize);
25831
+ return Ok((
25832
+ access,
25833
+ if is_int { RustType::I32 } else { RustType::F64 },
25834
+ ));
25835
+ }
25836
+ let const_len = self
25837
+ .cum_static_and_len(name.as_ref())
25838
+ .map(|(_, n)| n)
25839
+ .or_else(|| {
25840
+ self.module_const_f64_arrays
25841
+ .get(name.as_ref())
25842
+ .map(|v| v.len())
25843
+ })
25844
+ .unwrap_or(0);
25845
+ // OUT-OF-RANGE STAYS f64. An unproven index still has to be able to
25846
+ // answer `NaN`, and there is no `i32` that means "absent" — picking a
25847
+ // sentinel here would silently turn a missing element into a real value.
25848
+ // So the bounds-checked form keeps today's exact semantics and only the
25849
+ // STORAGE narrows; the integer win is claimed above, where the index is
25850
+ // proven and no sentinel is reachable.
25851
+ let elem = if is_int {
25852
+ format!("{}[_i] as f64", static_name)
24854
25853
  } else {
24855
- let iv = if idx_ty.is_native() {
24856
- idx_ty.to_value_expr(&idx_code)
24857
- } else {
24858
- idx_code
24859
- };
25854
+ format!("{}[_i]", static_name)
25855
+ };
25856
+ let access = if const_len > 0 {
24860
25857
  format!(
24861
- "{{ let _i = &{}; if let Value::Number(n) = _i {{ *n as usize }} else {{ panic!(\"array index must be a number\") }} }}",
24862
- iv
25858
+ "{{ let _i = {}; if _i < {} {{ {} }} else {{ f64::NAN }} }}",
25859
+ idx_usize, const_len, elem
24863
25860
  )
24864
- };
24865
- let access = if in_bounds {
24866
- format!("{}[{}]", static_name, idx_usize)
24867
25861
  } else {
24868
- let const_len = self
24869
- .cum_static_and_len(name.as_ref())
24870
- .map(|(_, n)| n)
24871
- .or_else(|| {
24872
- self.module_const_f64_arrays
24873
- .get(name.as_ref())
24874
- .map(|v| v.len())
24875
- })
24876
- .unwrap_or(0);
24877
- if const_len > 0 {
24878
- format!(
24879
- "{{ let _i = {}; if _i < {} {{ {}[_i] }} else {{ f64::NAN }} }}",
24880
- idx_usize,
24881
- const_len,
24882
- static_name
24883
- )
24884
- } else {
24885
- format!(
24886
- "{{ let _i = {}; if _i < {}.len() {{ {}[_i] }} else {{ f64::NAN }} }}",
24887
- idx_usize,
24888
- static_name,
24889
- static_name
24890
- )
24891
- }
25862
+ format!(
25863
+ "{{ let _i = {}; if _i < {}.len() {{ {} }} else {{ f64::NAN }} }}",
25864
+ idx_usize, static_name, elem
25865
+ )
24892
25866
  };
24893
25867
  return Ok((access, RustType::F64));
24894
25868
  }
@@ -24960,6 +25934,19 @@ impl Codegen {
24960
25934
  // `borrow()` guard a non-Copy element must be cloned OUT (it can't be
24961
25935
  // moved from the borrow); a plain local keeps the direct place.
24962
25936
  _ if idx_wrapped => format!("{}[{}].clone()", esc_obj, idx_ref),
25937
+ // #600: a STRING element is cloned even unwrapped. `let s: string =
25938
+ // NAMES[i]` binds the element, and binding a non-Copy value out of a
25939
+ // `Vec` is a move — `error[E0507]: cannot move out of index`. The
25940
+ // obvious spelling was the one that did not compile, with indexing
25941
+ // straight into a call as the only working form.
25942
+ //
25943
+ // Scoped to String deliberately. The struct case (#571) looks
25944
+ // identical but must NOT clone here: `states[id].timer` projects a
25945
+ // field out of the place, and cloning would copy the whole struct on
25946
+ // every field read — the O(n)-per-read footgun #558 exists about.
25947
+ // A string is already copied at every boxed boundary, so the clone
25948
+ // costs nothing that was not already being paid.
25949
+ RustType::String => format!("{}[{}].clone()", esc_obj, idx_ref),
24963
25950
  _ => format!("{}[{}]", esc_obj, idx_ref),
24964
25951
  };
24965
25952
  // #567: wrap the wrapped-Vec read in a block that pre-binds the index
@@ -25097,9 +26084,10 @@ impl Codegen {
25097
26084
  // dropping to `ops::*`. Only a native-returning extern is taken here; a `void`/`Value`
25098
26085
  // one falls through to the boxed path (this is a value-position expression).
25099
26086
  if let Expr::Ident { name: fname, .. } = callee.as_ref() {
25100
- if let Some(sig) = self.extern_fns.get(fname.as_ref()).cloned() {
26087
+ if let Some((sig, suffix)) =
26088
+ self.extern_sig_for_arity(fname.as_ref(), args.len())
26089
+ {
25101
26090
  if sig.ret.is_native()
25102
- && args.len() == sig.params.len()
25103
26091
  && args.iter().all(|a| matches!(a, CallArg::Expr(_)))
25104
26092
  {
25105
26093
  let mut argc: Vec<String> = Vec::with_capacity(args.len());
@@ -25109,9 +26097,10 @@ impl Codegen {
25109
26097
  }
25110
26098
  }
25111
26099
  let call = format!(
25112
- "{}::{}_typed({})",
26100
+ "{}::{}{}({})",
25113
26101
  sig.crate_name,
25114
26102
  sig.symbol,
26103
+ suffix,
25115
26104
  argc.join(", ")
25116
26105
  );
25117
26106
  return Ok((call, sig.ret));
@@ -26048,7 +27037,12 @@ impl Codegen {
26048
27037
  let RustType::Vec(inner) = self.type_context.get_type(recv_name.as_ref()) else {
26049
27038
  return Ok(None);
26050
27039
  };
26051
- if *inner != RustType::F64 {
27040
+ // #609: the element-type gate exists in TWO places — here and at the top of
27041
+ // `try_native_vec_hof`. Widening only the inner one changes nothing, because this one
27042
+ // returns first: the array is boxed whole into a `Value::Array` and handed to
27043
+ // `tishlang_runtime::array_map`, which is the per-element `value_call` path this issue is
27044
+ // about. Both must agree.
27045
+ if *inner != RustType::F64 && *inner != RustType::I32 {
26052
27046
  return Ok(None);
26053
27047
  }
26054
27048
  let recv_code = Self::escape_ident(recv_name.as_ref()).into_owned();
@@ -26077,8 +27071,14 @@ impl Codegen {
26077
27071
  method: &str,
26078
27072
  args: &[CallArg],
26079
27073
  ) -> Result<Option<(String, RustType)>, CompileError> {
26080
- // Only numeric arrays for now: `.copied()` needs a `Copy` element.
26081
- if *elem != RustType::F64 {
27074
+ // `.copied()` needs a `Copy` element, which both of these are.
27075
+ //
27076
+ // #609: `i32` joins `f64` here. On GBA every idiomatic array is `i32[]`, so restricting the
27077
+ // fast path to `Vec<f64>` meant `.map/.filter/.some/.every` fell back to one boxed
27078
+ // `value_call` PER ELEMENT on exactly the arrays the target guidance tells you to write.
27079
+ //
27080
+ // `reduce` is deliberately NOT extended — see its arm below.
27081
+ if *elem != RustType::F64 && *elem != RustType::I32 {
26082
27082
  return Ok(None);
26083
27083
  }
26084
27084
  let Some(CallArg::Expr(Expr::ArrowFunction { params, body, .. })) = args.first() else {
@@ -26110,7 +27110,12 @@ impl Codegen {
26110
27110
  res
26111
27111
  };
26112
27112
  match method {
26113
- "reduce" => {
27113
+ // #609 stops short here on purpose. The accumulator is typed `f64` (or `i64` for the
27114
+ // integer-range form); with an `i32` element the body would mix an `i32` binding into
27115
+ // that accumulator — `acc = acc + x` either fails to compile or silently changes
27116
+ // overflow behaviour from f64's exact-integer range to i32 wrapping. That is a results
27117
+ // question, not a speed one, so it wants its own change with its own tests.
27118
+ "reduce" if *elem == RustType::F64 => {
26114
27119
  if args.len() != 2 || params.len() != 2 {
26115
27120
  return Ok(None);
26116
27121
  }
@@ -26204,7 +27209,7 @@ impl Codegen {
26204
27209
  let Some(x) = simple_name(&params[0]) else {
26205
27210
  return Ok(None);
26206
27211
  };
26207
- let (body_code, body_ty) = emit_with(self, &[(&x, RustType::F64)])?;
27212
+ let (body_code, body_ty) = emit_with(self, &[(&x, elem.clone())])?;
26208
27213
  if !body_ty.is_native() {
26209
27214
  return Ok(None);
26210
27215
  }
@@ -26224,17 +27229,21 @@ impl Codegen {
26224
27229
  let Some(x) = simple_name(&params[0]) else {
26225
27230
  return Ok(None);
26226
27231
  };
26227
- let (body_code, body_ty) = emit_with(self, &[(&x, RustType::F64)])?;
27232
+ let (body_code, body_ty) = emit_with(self, &[(&x, elem.clone())])?;
26228
27233
  if body_ty != RustType::Bool {
26229
27234
  return Ok(None);
26230
27235
  }
26231
27236
  let x_esc = Self::escape_ident(x.as_ref()).into_owned();
27237
+ // A filter PRESERVES the element type — collecting to `Vec<f64>` from an `i32`
27238
+ // receiver would not compile, and silently widening would change what the caller
27239
+ // gets back.
26232
27240
  Ok(Some((
26233
27241
  format!(
26234
- "{recv}.iter().copied().filter(|&{x}| {body}).collect::<Vec<f64>>()",
26235
- recv = recv, x = x_esc, body = body_code
27242
+ "{recv}.iter().copied().filter(|&{x}| {body}).collect::<Vec<{ety}>>()",
27243
+ recv = recv, x = x_esc, body = body_code,
27244
+ ety = elem.to_rust_type_str()
26236
27245
  ),
26237
- RustType::Vec(Box::new(RustType::F64)),
27246
+ RustType::Vec(Box::new(elem.clone())),
26238
27247
  )))
26239
27248
  }
26240
27249
  "some" | "every" => {
@@ -26244,7 +27253,7 @@ impl Codegen {
26244
27253
  let Some(x) = simple_name(&params[0]) else {
26245
27254
  return Ok(None);
26246
27255
  };
26247
- let (body_code, body_ty) = emit_with(self, &[(&x, RustType::F64)])?;
27256
+ let (body_code, body_ty) = emit_with(self, &[(&x, elem.clone())])?;
26248
27257
  if body_ty != RustType::Bool {
26249
27258
  return Ok(None);
26250
27259
  }