@tishlang/tish-lint 3.3.3 → 3.7.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 (42) hide show
  1. package/Cargo.toml +1 -0
  2. package/bin/tish-lint +0 -0
  3. package/crates/tish/Cargo.toml +4 -1
  4. package/crates/tish/src/cli_help.rs +84 -0
  5. package/crates/tish/src/main.rs +93 -1
  6. package/crates/tish/tests/scope_merge.rs +90 -0
  7. package/crates/tish/tests/test_runner.rs +673 -0
  8. package/crates/tish_bytecode/src/compiler.rs +25 -4
  9. package/crates/tish_compile/src/check.rs +178 -0
  10. package/crates/tish_compile/src/codegen.rs +383 -63
  11. package/crates/tish_compile/src/lib.rs +33 -1
  12. package/crates/tish_compile/src/resolve.rs +121 -19
  13. package/crates/tish_compile/src/types.rs +35 -1
  14. package/crates/tish_compile/tests/call_arity_641.rs +104 -0
  15. package/crates/tish_core/src/lib.rs +103 -11
  16. package/crates/tish_eval/src/eval.rs +16 -0
  17. package/crates/tish_eval/src/value_convert.rs +10 -0
  18. package/crates/tish_lint/Cargo.toml +1 -1
  19. package/crates/tish_test/Cargo.toml +47 -0
  20. package/crates/tish_test/src/assert.rs +435 -0
  21. package/crates/tish_test/src/config.rs +85 -0
  22. package/crates/tish_test/src/coverage.rs +237 -0
  23. package/crates/tish_test/src/deep_equal.rs +261 -0
  24. package/crates/tish_test/src/discovery.rs +70 -0
  25. package/crates/tish_test/src/expect.rs +977 -0
  26. package/crates/tish_test/src/instrument.rs +457 -0
  27. package/crates/tish_test/src/isolation.rs +14 -0
  28. package/crates/tish_test/src/lib.rs +22 -0
  29. package/crates/tish_test/src/load.rs +152 -0
  30. package/crates/tish_test/src/mocks.rs +270 -0
  31. package/crates/tish_test/src/module.rs +318 -0
  32. package/crates/tish_test/src/registry.rs +235 -0
  33. package/crates/tish_test/src/report.rs +312 -0
  34. package/crates/tish_test/src/runner.rs +797 -0
  35. package/crates/tish_test/src/snapshots.rs +197 -0
  36. package/crates/tish_vm/src/vm.rs +17 -14
  37. package/package.json +1 -1
  38. package/platform/darwin-arm64/tish-lint +0 -0
  39. package/platform/darwin-x64/tish-lint +0 -0
  40. package/platform/linux-arm64/tish-lint +0 -0
  41. package/platform/linux-x64/tish-lint +0 -0
  42. package/platform/win32-x64/tish-lint.exe +0 -0
@@ -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`.
@@ -1234,7 +1260,22 @@ pub(crate) struct Codegen {
1234
1260
  /// once in `emit_program` (after type aliases + `native_fns`), consulted at `VarDecl` to force
1235
1261
  /// `RustType::Value`. This is the rust-AOT analogue of the VM array-JIT bailing to the
1236
1262
  /// interpreter on a non-numeric element. See `collect_demoted_numeric_locals`.
1237
- 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>,
1238
1279
  /// Top-level fns whose EVERY return is provably numeric → the native type a call to one yields.
1239
1280
  ///
1240
1281
  /// Without it a call is opaque, so a local assigned from one demotes to a boxed `Value` and
@@ -1434,6 +1475,7 @@ impl Codegen {
1434
1475
  aggregate_array_locals: std::collections::HashSet::new(),
1435
1476
  agg_cur_ret: None,
1436
1477
  demoted_numeric_locals: std::collections::HashSet::new(),
1478
+ demote_scope_stack: Vec::new(),
1437
1479
  user_fn_ret: std::collections::HashMap::new(),
1438
1480
  int_range_locals: std::collections::HashMap::new(),
1439
1481
  diag_coord_indices: std::collections::HashSet::new(),
@@ -2989,13 +3031,35 @@ impl Codegen {
2989
3031
  .as_ref()
2990
3032
  .map(RustType::from_annotation)
2991
3033
  .unwrap_or(RustType::Unit);
2992
- self.extern_fns.insert(
2993
- name.to_string(),
2994
- ExternSig { crate_name, symbol: export_name.clone(), params: ptys, ret },
2995
- );
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
+ }
2996
3044
  }
2997
3045
  }
2998
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
+
2999
3063
  /// Record every top-level `function f(...): Iface` whose return annotation resolves — via the type
3000
3064
  /// aliases — to a native STRUCT (`RustType::Named`, or a `ShapeUnion`). A later call to `f` in a
3001
3065
  /// typed position then coerces the boxed result to the native struct (a `Value::Struct` downcast on
@@ -5937,9 +6001,26 @@ impl Codegen {
5937
6001
  // `s = s + arr[i]`, JS string concat) must stay a boxed `Value` — a native-f64
5938
6002
  // store would panic at the `from_value_expr(F64)` coercion. See
5939
6003
  // `demoted_numeric_locals`.
5940
- if rust_type == RustType::F64 && self.demoted_numeric_locals.contains(name.as_ref())
5941
- {
5942
- 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
+ }
5943
6024
  }
5944
6025
 
5945
6026
  // Native-vec call initializer: `let cum = cumulative(probs)` → `Vec<f64>`.
@@ -7100,6 +7181,45 @@ impl Codegen {
7100
7181
  sibling_escaped, sibling_escaped
7101
7182
  ));
7102
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
+ }
7103
7223
  // Clone outer parameters so they can be captured by the move closure
7104
7224
  for outer_param in &outer_params {
7105
7225
  let param_escaped = Self::escape_ident(outer_param);
@@ -7454,9 +7574,12 @@ impl Codegen {
7454
7574
  for v in &body_cell_vars {
7455
7575
  self.refcell_wrapped_vars.insert(v.clone());
7456
7576
  }
7577
+ // #614: this body's locals were analyzed under THIS fn's scope.
7578
+ self.demote_scope_stack.push(name_raw.to_string());
7457
7579
  for s in statements {
7458
7580
  self.emit_statement(s)?;
7459
7581
  }
7582
+ self.demote_scope_stack.pop();
7460
7583
  for v in &body_cell_vars {
7461
7584
  self.refcell_wrapped_vars.remove(v);
7462
7585
  }
@@ -7468,7 +7591,9 @@ impl Codegen {
7468
7591
  self.outer_vars_stack.push(Vec::new());
7469
7592
  self.rc_cell_storage_scopes
7470
7593
  .push(std::collections::HashSet::new());
7594
+ self.demote_scope_stack.push(name_raw.to_string()); // #614
7471
7595
  self.emit_statement(body)?;
7596
+ self.demote_scope_stack.pop();
7472
7597
  self.function_scope_stack.pop();
7473
7598
  self.outer_vars_stack.pop();
7474
7599
  self.rc_cell_storage_scopes.pop();
@@ -9229,10 +9354,10 @@ impl Codegen {
9229
9354
  // `value_call` dispatch through the namespace object. Falls through to the boxed path
9230
9355
  // when the arg count/kind doesn't match (a dynamic/spread call stays correct).
9231
9356
  if let Expr::Ident { name: fname, .. } = callee.as_ref() {
9232
- if let Some(sig) = self.extern_fns.get(fname.as_ref()).cloned() {
9233
- if args.len() == sig.params.len()
9234
- && args.iter().all(|a| matches!(a, CallArg::Expr(_)))
9235
- {
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(_))) {
9236
9361
  let mut argc: Vec<String> = Vec::with_capacity(args.len());
9237
9362
  for (a, ty) in args.iter().zip(sig.params.iter()) {
9238
9363
  if let CallArg::Expr(e) = a {
@@ -9240,9 +9365,10 @@ impl Codegen {
9240
9365
  }
9241
9366
  }
9242
9367
  let call = format!(
9243
- "{}::{}_typed({})",
9368
+ "{}::{}{}({})",
9244
9369
  sig.crate_name,
9245
9370
  sig.symbol,
9371
+ suffix,
9246
9372
  argc.join(", ")
9247
9373
  );
9248
9374
  // This is a Value-producing context: a `void` extern yields `Null`; a
@@ -12575,9 +12701,16 @@ impl Codegen {
12575
12701
  /// function that mixes the two. A function whose whole signature is `i32` has one convention
12576
12702
  /// and keeps the coercion at the boundary, where the caller does it once on an argument that is
12577
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.
12578
12712
  fn fn_sig_all_i32(params: &[FunParam], return_type: &Option<TypeAnnotation>) -> bool {
12579
12713
  return_type.as_ref().is_some_and(Self::ann_is_i32)
12580
- && !params.is_empty()
12581
12714
  && params.iter().all(|p| {
12582
12715
  matches!(p, FunParam::Simple(tp)
12583
12716
  if tp.default.is_none()
@@ -12842,23 +12975,105 @@ impl Codegen {
12842
12975
  }
12843
12976
  }
12844
12977
 
12845
- fn collect_demoted_numeric_locals(&self, stmts: &[Statement]) -> HashSet<String> {
12846
- // 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
+
12847
13002
  let mut env: HashMap<String, RustType> = HashMap::new();
12848
13003
  Self::collect_annotated_types(stmts, &self.type_aliases, &mut env);
12849
- // 2. Every reassignment `(name, rhs)` anywhere in the program (incl. nested exprs/closures).
12850
13004
  let mut reassigns: Vec<(String, &Expr)> = Vec::new();
12851
13005
  Self::collect_reassignments_stmts(stmts, &mut reassigns);
12852
- // 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> {
12853
13068
  let mut demoted: HashSet<String> = HashSet::new();
12854
13069
  loop {
12855
13070
  let mut changed = false;
12856
- for (name, rhs) in &reassigns {
12857
- if demoted.contains(name) {
13071
+ for (name, rhs) in reassigns {
13072
+ if demoted.contains(name) || !considered.contains(name) {
12858
13073
  continue;
12859
13074
  }
12860
13075
  if env.get(name) == Some(&RustType::F64)
12861
- && self.expr_native_type(rhs, &env) != RustType::F64
13076
+ && self.expr_native_type(rhs, env) != RustType::F64
12862
13077
  {
12863
13078
  demoted.insert(name.clone());
12864
13079
  env.insert(name.clone(), RustType::Value);
@@ -13070,8 +13285,28 @@ impl Codegen {
13070
13285
  }
13071
13286
 
13072
13287
  fn index_in_bounds(&self, index: &Expr, arr: &str) -> bool {
13073
- let Some(len) = self.vec_fixed_len.get(arr) else {
13074
- 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
+ },
13075
13310
  };
13076
13311
  let Expr::Ident { name: idx, .. } = index else {
13077
13312
  if let Expr::Literal {
@@ -13087,6 +13322,32 @@ impl Codegen {
13087
13322
  return true;
13088
13323
  }
13089
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
+ }
13090
13351
  return self.sub_index_in_bounds(index, arr);
13091
13352
  };
13092
13353
  let idx = idx.as_ref();
@@ -15557,7 +15818,7 @@ impl Codegen {
15557
15818
  /// statements (blocks, if, loops, switch, try, return/throw). `f` is responsible for recursing
15558
15819
  /// into each expression's own subtree. Does NOT descend into nested fn-decl bodies (a different
15559
15820
  /// lexical scope; a captured loop var would be RefCell-bailed before reaching here).
15560
- 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)) {
15561
15822
  match s {
15562
15823
  Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
15563
15824
  for st in statements {
@@ -20178,16 +20439,27 @@ impl Codegen {
20178
20439
  matches!(op, UnaryOp::Neg | UnaryOp::Pos)
20179
20440
  && Self::inline_body_numeric(operand, params, self_name)
20180
20441
  }
20181
- Expr::Conditional {
20182
- cond,
20183
- then_branch,
20184
- else_branch,
20185
- ..
20186
- } => {
20187
- Self::inline_body_numeric(cond, params, self_name)
20188
- && Self::inline_body_numeric(then_branch, params, self_name)
20189
- && Self::inline_body_numeric(else_branch, params, self_name)
20190
- }
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,
20191
20463
  Expr::Call { callee, args, .. } => {
20192
20464
  // Only 1-arg `Math.<intrinsic>` — never another fn (incl. self), to keep it a pure
20193
20465
  // expression with no dispatch and no recursion.
@@ -21366,7 +21638,23 @@ impl Codegen {
21366
21638
  //
21367
21639
  // This does not close #594's other half — a lowered fn that can actually SEE module state,
21368
21640
  // which needs those bindings emitted as statics on GBA too. It stops the miscompile.
21369
- 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
+ {
21370
21658
  let mut module_bindings = Self::collect_module_level_binding_names(&program.statements);
21371
21659
  // …EXCEPT the ones that now have a top-level home. Since #594 a module const numeric
21372
21660
  // array is a `const` and a mutable numeric global is a `SingleCore` static, both
@@ -25342,6 +25630,21 @@ impl Codegen {
25342
25630
  // in (-2^53, 2^53) and `c` a positive integer literal → `(x as i64) % c` instead
25343
25631
  // of `fmod`. Bit-identical (x is exactly an integer in f64; Rust `%` and `fmod`
25344
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
+
25345
25648
  if matches!(op, BinOp::Mod) && result_ty == RustType::F64 {
25346
25649
  if let Some(c) = Self::int_literal_value_of(right).filter(|&c| c > 0) {
25347
25650
  if self.int_range(left, &self.int_range_locals).is_some() {
@@ -25510,20 +25813,15 @@ impl Codegen {
25510
25813
  name.as_ref(),
25511
25814
  ) {
25512
25815
  let in_bounds = self.index_in_bounds(index, name.as_ref());
25513
- let (idx_code, idx_ty) = self.emit_typed_expr(index)?;
25514
- let idx_usize = if idx_ty == RustType::F64 {
25515
- format!("({}) as usize", idx_code)
25516
- } else {
25517
- let iv = if idx_ty.is_native() {
25518
- idx_ty.to_value_expr(&idx_code)
25519
- } else {
25520
- idx_code
25521
- };
25522
- format!(
25523
- "{{ let _i = &{}; if let Value::Number(n) = _i {{ *n as usize }} else {{ panic!(\"array index must be a number\") }} }}",
25524
- iv
25525
- )
25526
- };
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)?;
25527
25825
  let is_int = self.module_const_int_statics.contains(&static_name);
25528
25826
  // The proven-in-bounds read on an integer array is the whole point: a
25529
25827
  // plain word load, reported as I32 so the consumer stays in the integer
@@ -25786,9 +26084,10 @@ impl Codegen {
25786
26084
  // dropping to `ops::*`. Only a native-returning extern is taken here; a `void`/`Value`
25787
26085
  // one falls through to the boxed path (this is a value-position expression).
25788
26086
  if let Expr::Ident { name: fname, .. } = callee.as_ref() {
25789
- 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
+ {
25790
26090
  if sig.ret.is_native()
25791
- && args.len() == sig.params.len()
25792
26091
  && args.iter().all(|a| matches!(a, CallArg::Expr(_)))
25793
26092
  {
25794
26093
  let mut argc: Vec<String> = Vec::with_capacity(args.len());
@@ -25798,9 +26097,10 @@ impl Codegen {
25798
26097
  }
25799
26098
  }
25800
26099
  let call = format!(
25801
- "{}::{}_typed({})",
26100
+ "{}::{}{}({})",
25802
26101
  sig.crate_name,
25803
26102
  sig.symbol,
26103
+ suffix,
25804
26104
  argc.join(", ")
25805
26105
  );
25806
26106
  return Ok((call, sig.ret));
@@ -26737,7 +27037,12 @@ impl Codegen {
26737
27037
  let RustType::Vec(inner) = self.type_context.get_type(recv_name.as_ref()) else {
26738
27038
  return Ok(None);
26739
27039
  };
26740
- 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 {
26741
27046
  return Ok(None);
26742
27047
  }
26743
27048
  let recv_code = Self::escape_ident(recv_name.as_ref()).into_owned();
@@ -26766,8 +27071,14 @@ impl Codegen {
26766
27071
  method: &str,
26767
27072
  args: &[CallArg],
26768
27073
  ) -> Result<Option<(String, RustType)>, CompileError> {
26769
- // Only numeric arrays for now: `.copied()` needs a `Copy` element.
26770
- 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 {
26771
27082
  return Ok(None);
26772
27083
  }
26773
27084
  let Some(CallArg::Expr(Expr::ArrowFunction { params, body, .. })) = args.first() else {
@@ -26799,7 +27110,12 @@ impl Codegen {
26799
27110
  res
26800
27111
  };
26801
27112
  match method {
26802
- "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 => {
26803
27119
  if args.len() != 2 || params.len() != 2 {
26804
27120
  return Ok(None);
26805
27121
  }
@@ -26893,7 +27209,7 @@ impl Codegen {
26893
27209
  let Some(x) = simple_name(&params[0]) else {
26894
27210
  return Ok(None);
26895
27211
  };
26896
- let (body_code, body_ty) = emit_with(self, &[(&x, RustType::F64)])?;
27212
+ let (body_code, body_ty) = emit_with(self, &[(&x, elem.clone())])?;
26897
27213
  if !body_ty.is_native() {
26898
27214
  return Ok(None);
26899
27215
  }
@@ -26913,17 +27229,21 @@ impl Codegen {
26913
27229
  let Some(x) = simple_name(&params[0]) else {
26914
27230
  return Ok(None);
26915
27231
  };
26916
- let (body_code, body_ty) = emit_with(self, &[(&x, RustType::F64)])?;
27232
+ let (body_code, body_ty) = emit_with(self, &[(&x, elem.clone())])?;
26917
27233
  if body_ty != RustType::Bool {
26918
27234
  return Ok(None);
26919
27235
  }
26920
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.
26921
27240
  Ok(Some((
26922
27241
  format!(
26923
- "{recv}.iter().copied().filter(|&{x}| {body}).collect::<Vec<f64>>()",
26924
- 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()
26925
27245
  ),
26926
- RustType::Vec(Box::new(RustType::F64)),
27246
+ RustType::Vec(Box::new(elem.clone())),
26927
27247
  )))
26928
27248
  }
26929
27249
  "some" | "every" => {
@@ -26933,7 +27253,7 @@ impl Codegen {
26933
27253
  let Some(x) = simple_name(&params[0]) else {
26934
27254
  return Ok(None);
26935
27255
  };
26936
- let (body_code, body_ty) = emit_with(self, &[(&x, RustType::F64)])?;
27256
+ let (body_code, body_ty) = emit_with(self, &[(&x, elem.clone())])?;
26937
27257
  if body_ty != RustType::Bool {
26938
27258
  return Ok(None);
26939
27259
  }