@tishlang/tish-lsp 3.2.2 → 3.3.3

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.
@@ -0,0 +1,891 @@
1
+ //! Regressions at the boundary between a LOWERED function and the code that calls it, both found by
2
+ //! building real GBA games (tish-gba `examples/hyrule`, `examples/ffta`) after M5 was enabled there.
3
+ //!
4
+ //! Both are compile-time breaks in the GENERATED Rust, so the assertions read the emitted source
5
+ //! rather than running anything: if the shape is wrong, rustc rejects the program and the game does
6
+ //! not build at all. Emitted through `NativeEmitMode::Gba` because that is the only mode where the
7
+ //! extended numeric vocabulary (`i32`/`fixed`) lowers natively — see #603.
8
+
9
+ use std::path::PathBuf;
10
+
11
+ use tishlang_compile::{compile_project_full_emit, NativeEmitMode};
12
+
13
+ fn compile_gba(stem: &str, src: &str) -> String {
14
+ compile_gba_multi(stem, src, &[])
15
+ }
16
+
17
+ /// Compile `src` as the entry point, with `extra` written beside it as importable modules.
18
+ /// #622 only reproduces ACROSS a module boundary — the whole point of the bug is that the error
19
+ /// lands in a shared package the consumer never wrote — so the harness has to be able to build one.
20
+ fn compile_gba_multi(stem: &str, src: &str, extra: &[(&str, &str)]) -> String {
21
+ let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
22
+ .join("../../target/regr_gba_call_sites")
23
+ .join(stem);
24
+ std::fs::create_dir_all(&dir).unwrap();
25
+ for (name, body) in extra {
26
+ std::fs::write(dir.join(name), body).unwrap();
27
+ }
28
+ let path = dir.join("main.tish");
29
+ std::fs::write(&path, src).unwrap();
30
+ let path = path.canonicalize().unwrap();
31
+ let (rust, _, _, _) =
32
+ compile_project_full_emit(&path, path.parent(), &[], true, NativeEmitMode::Gba, None)
33
+ .unwrap();
34
+ rust
35
+ }
36
+
37
+ // ── #623: a fixed-point argument reaching an f64 parameter ───────────────────────────────────────
38
+
39
+ /// `declare fn` gives the compiler a typed extern whose return is `fixed` (agb `Num<i32,8>`), which
40
+ /// is exactly what `entity_x` is in tish-gba. Passing that straight into a lowered `-> f64` helper
41
+ /// emitted the raw `Num<i32,8>` with no coercion: `error[E0308]: expected f64, found Num<i32, 8>`,
42
+ /// four times in one file of hyrule.
43
+ const FIXED_ARG: &str = r#"
44
+ function pxTile(px: number): number {
45
+ let frac = px % 1
46
+ let ipx = px - frac
47
+ let m = ipx % 16
48
+ return (ipx - m) / 16
49
+ }
50
+
51
+
52
+ let ex: fixed = 12.5
53
+ let col: i32 = pxTile(ex)
54
+ console.log(col)
55
+ "#;
56
+
57
+ #[test]
58
+ fn fixed_extern_result_coerces_at_a_lowered_call_site() {
59
+ let rust = compile_gba("fixed_arg", FIXED_ARG);
60
+
61
+ // Only meaningful if the helper actually lowered; otherwise the call is boxed and the bug
62
+ // cannot arise. Guard so a future eligibility change turns this into a failure to investigate
63
+ // rather than a silent pass.
64
+ assert!(
65
+ rust.contains("_native(mut px"),
66
+ "pxTile should lower to a native fn — without that this test proves nothing:\n{}",
67
+ rust.lines().filter(|l| l.contains("pxTile")).take(4).collect::<Vec<_>>().join("\n")
68
+ );
69
+
70
+ // The call site must not hand a Num<i32,8> to an f64 parameter. `ex` is `fixed`; reaching a
71
+ // native f64 param it has to be converted, not passed raw.
72
+ let bad = rust.lines().find(|l| {
73
+ l.contains("_native(") && l.contains("(ex)") && !l.contains("as f64") && !l.contains("/ 256")
74
+ });
75
+ assert!(
76
+ bad.is_none(),
77
+ "a `fixed` value reaches an f64 native parameter with no coercion (#623):\n {}",
78
+ bad.unwrap_or("")
79
+ );
80
+ }
81
+
82
+ // ── #622: a lowered fn's parameter name hoisted into an unrelated capture list ───────────────────
83
+
84
+ /// The shape that broke 21 tish-gba examples at once: a shared package builds an object out of
85
+ /// arrow properties whose parameter is named `v`, and ANY consumer that also has a lowered function
86
+ /// with a parameter named `v` fails with `cannot find value v` — in the package, which the consumer
87
+ /// never wrote. The lowered fn's parameters vanish as far as the module scope is concerned, and a
88
+ /// hoisted capture of the same name then has nothing to bind to.
89
+ const PKG: &str = r#"
90
+ let acc: i32 = 0
91
+ export function makeEntity(id: i32) {
92
+ let obj = { id: id }
93
+ obj.setVy = (v) => { acc = acc + v }
94
+ obj.moveSpeed = (v) => { acc = acc + v * 2 }
95
+ obj.hurt = (v) => { acc = acc - v }
96
+ return obj
97
+ }
98
+ export function total(): i32 { return acc }
99
+ "#;
100
+
101
+ const PARAM_COLLISION: &str = r#"
102
+ import { makeEntity, total } from './pkg'
103
+
104
+ function roomBase(v: i32, size: i32): i32 { return v - (v % size) }
105
+
106
+ let e = makeEntity(1)
107
+ e.setVy(3)
108
+ e.moveSpeed(4)
109
+ console.log(roomBase(37, 16) + total())
110
+ "#;
111
+
112
+ #[test]
113
+ fn a_lowered_fn_param_is_not_hoisted_into_a_capture_list() {
114
+ let rust = compile_gba_multi("param_collision", PARAM_COLLISION, &[("pkg.tish", PKG)]);
115
+
116
+ // Must actually LOWER, or the collision this guards cannot arise and the test proves nothing.
117
+ assert!(
118
+ rust.contains("fn roomBase_native(mut v: i32"),
119
+ "roomBase should lower with the i32 ABI — otherwise this test is vacuous:\n{}",
120
+ rust.lines().filter(|l| l.contains("roomBase")).take(4).collect::<Vec<_>>().join("\n")
121
+ );
122
+
123
+ // `v` is an arrow PARAMETER and a lowered fn's parameter. Neither is a module binding, so no
124
+ // module-level capture cell may be minted for it — that cell has nothing to clone from.
125
+ let hoisted: Vec<&str> = rust
126
+ .lines()
127
+ .map(|l| l.trim())
128
+ .filter(|l| l.starts_with("let v_cell = VmRef::new(v.clone());"))
129
+ .collect();
130
+ assert!(
131
+ hoisted.is_empty(),
132
+ "a lowered fn's parameter name was hoisted into a capture list (#622): {} site(s)\n {}",
133
+ hoisted.len(),
134
+ hoisted.join("\n ")
135
+ );
136
+ }
137
+
138
+ /// The same defect from the other side: whatever the capture machinery decides, the emitted program
139
+ /// must not reference a binding that does not exist at that point. This is the assertion that would
140
+ /// have caught #622, #52 and #594 alike, so it is worth having as a standing shape check.
141
+ #[test]
142
+ fn no_capture_cell_references_an_undefined_binding() {
143
+ for (stem, src, extra) in [
144
+ ("param_collision2", PARAM_COLLISION, &[("pkg.tish", PKG)][..]),
145
+ ("fixed_arg2", FIXED_ARG, &[][..]),
146
+ ] {
147
+ let rust = compile_gba_multi(stem, src, extra);
148
+ for (i, line) in rust.lines().enumerate() {
149
+ let t = line.trim();
150
+ // `let X_cell = VmRef::new(X.clone());` is only sound when `X` is in scope where it is
151
+ // emitted. At module level that means a module binding — a fn parameter never is.
152
+ if let Some(rest) = t.strip_prefix("let ") {
153
+ if let Some((cell, tail)) = rest.split_once("_cell = VmRef::new(") {
154
+ let src_name = tail.trim_end_matches(");").trim_end_matches(".clone()");
155
+ if src_name == cell && !line.starts_with(" ") {
156
+ // Module-level (shallow indent) capture of a bare name: it must be declared
157
+ // somewhere above as a `let`.
158
+ let declared = rust
159
+ .lines()
160
+ .take(i)
161
+ .any(|d| {
162
+ let d = d.trim();
163
+ d.starts_with(&format!("let {} ", cell))
164
+ || d.starts_with(&format!("let {}:", cell))
165
+ || d.starts_with(&format!("let mut {} ", cell))
166
+ || d.starts_with(&format!("let {} =", cell))
167
+ });
168
+ assert!(
169
+ declared,
170
+ "{stem}: capture cell for `{cell}` at line {} references a binding that \
171
+ is never declared at that scope:\n {t}",
172
+ i + 1
173
+ );
174
+ }
175
+ }
176
+ }
177
+ }
178
+ }
179
+ }
180
+
181
+ // ── #594: a lowered fn's body referencing a module binding it cannot see ─────────────────────────
182
+
183
+ /// Scalars in, ARRAY out — lowered to a free `fn build_nv(..) -> Vec<f64>` whose body still names
184
+ /// `CFG`, a `let` inside `run()`. `error[E0425]: cannot find value CFG in this scope`.
185
+ ///
186
+ /// This is the NATIVE-VEC path, a different collector from the M5 one that `native_safe_indexable`
187
+ /// hardened, and it reads a MEMBER rather than an index — which is why hardening M5 did not cover
188
+ /// it. The fix is the same shape: a fn whose body needs module scope is not eligible to be lowered
189
+ /// to a free fn, so it stays boxed and the program compiles. Giving such a fn real access to module
190
+ /// state (emitting those bindings as statics) is the separate capability #594 also asks for.
191
+ const MODULE_SCOPE_ARRAY_OUT: &str = r#"
192
+ const CFG = { n: 3 }
193
+ function build(raceId) {
194
+ let out = []
195
+ let i = 0
196
+ while (i < CFG.n) {
197
+ out.push(raceId * 10 + i)
198
+ i = i + 1
199
+ }
200
+ return out
201
+ }
202
+ const R = build(2)
203
+ console.log(R.length)
204
+ "#;
205
+
206
+ #[test]
207
+ fn a_lowered_vec_fn_never_references_module_scope() {
208
+ let rust = compile_gba("module_scope_nv", MODULE_SCOPE_ARRAY_OUT);
209
+
210
+ // Find every free `fn ..._nv(` body and prove none of them names `CFG`. A free fn is emitted at
211
+ // top level; `CFG` is a local of `run()`; the two cannot see each other.
212
+ let mut in_nv = false;
213
+ let mut depth = 0i32;
214
+ let mut offenders: Vec<String> = Vec::new();
215
+ for line in rust.lines() {
216
+ if !in_nv && line.trim_start().starts_with("fn ") && line.contains("_nv(") {
217
+ in_nv = true;
218
+ depth = 0;
219
+ }
220
+ if in_nv {
221
+ depth += line.matches('{').count() as i32 - line.matches('}').count() as i32;
222
+ if line.contains("CFG") && !line.trim_start().starts_with("fn ") {
223
+ offenders.push(line.trim().to_string());
224
+ }
225
+ if depth <= 0 && line.contains('}') {
226
+ in_nv = false;
227
+ }
228
+ }
229
+ }
230
+ assert!(
231
+ offenders.is_empty(),
232
+ "a lowered `_nv` free fn references module-scope `CFG`, which does not exist at top level \
233
+ (#594) — {} line(s):\n {}",
234
+ offenders.len(),
235
+ offenders.join("\n ")
236
+ );
237
+ }
238
+
239
+ // ── #605: VecRetKind has no String, so a string-returning fn cannot lower ────────────────────────
240
+
241
+ /// Scalars in, STRING out. Correct today but boxed: the fn becomes a `Value::native` closure in a
242
+ /// `VmRef` cell and every call is a `value_call`. `VecRetKind` only spoke `{F64, VecF64, Unit}`, so
243
+ /// there was no shape for it to lower into.
244
+ ///
245
+ /// Every return here is a string literal — the same standard the numeric path holds itself to
246
+ /// (`vec_fn_return_kind` proves EVERY return agrees on one shape, never trusting the annotation,
247
+ /// because a return type is erased and unchecked: the `liar()` case).
248
+ const STRING_OUT: &str = r#"
249
+ function label(n: i32): string {
250
+ if (n === 0) { return "zero" }
251
+ if (n === 1) { return "one" }
252
+ return "many"
253
+ }
254
+ let s: string = label(2)
255
+ console.log(s)
256
+ "#;
257
+
258
+ #[test]
259
+ fn a_string_returning_fn_lowers_to_a_native_fn() {
260
+ let rust = compile_gba("string_out", STRING_OUT);
261
+ assert!(
262
+ rust.contains("fn label_nv(") && rust.contains("-> String"),
263
+ "a scalars-in/string-out fn should lower to a native `-> String` fn (#605), got:\n{}",
264
+ rust.lines().filter(|l| l.contains("label")).take(5).collect::<Vec<_>>().join("\n")
265
+ );
266
+ }
267
+
268
+ /// The guard that keeps #605 sound. A fn whose returns do NOT all agree on `String` must stay
269
+ /// boxed — mixing a string and a number is exactly the shape a single native signature cannot
270
+ /// express, and guessing costs a miscompile rather than a slow path.
271
+ const MIXED_OUT: &str = r#"
272
+ function maybe(n: i32) {
273
+ if (n === 0) { return "zero" }
274
+ return n * 2
275
+ }
276
+ console.log(maybe(1))
277
+ console.log(maybe(0))
278
+ "#;
279
+
280
+ #[test]
281
+ fn a_mixed_string_number_return_stays_boxed() {
282
+ let rust = compile_gba("mixed_out", MIXED_OUT);
283
+ assert!(
284
+ !rust.contains("fn maybe_nv("),
285
+ "a fn returning both a string and a number must NOT lower (#605 soundness):\n{}",
286
+ rust.lines().filter(|l| l.contains("maybe_nv")).take(3).collect::<Vec<_>>().join("\n")
287
+ );
288
+ }
289
+
290
+ // ── #594 (the capability): module state a lowered fn CAN see, on GBA ─────────────────────────────
291
+
292
+ /// A module const numeric array read from a lowered fn. `const G_TABLE: [f64; N] = [..]` needs no
293
+ /// std at all — it was only ever unavailable on GBA because its pass shared a gate with the
294
+ /// `thread_local!` one.
295
+ const CONST_ARRAY_READ: &str = r#"
296
+ const TABLE = [3, 1, 4, 1, 5, 9, 2, 6]
297
+ function pick(n) {
298
+ let out = []
299
+ let i = 0
300
+ while (i < n) {
301
+ out.push(TABLE[i] * 2)
302
+ i = i + 1
303
+ }
304
+ return out
305
+ }
306
+ const R = pick(4)
307
+ console.log(R.length)
308
+ "#;
309
+
310
+ #[test]
311
+ fn a_module_const_array_is_reachable_from_a_lowered_fn_on_gba() {
312
+ let rust = compile_gba("const_array_gba", CONST_ARRAY_READ);
313
+ // Element type is deliberately NOT pinned here — this test is about REACHABILITY (#594), and an
314
+ // all-integer table is now expected to hoist as `[i32; 8]` on GBA. The representation itself is
315
+ // pinned by `an_integral_module_const_array_is_emitted_as_i32_on_gba`.
316
+ assert!(
317
+ rust.contains("const G_TABLE: [i32; 8]")
318
+ || rust.contains("const G_TABLE: [f64; 8]")
319
+ || rust.contains("const G_table: [i32; 8]")
320
+ || rust.contains("const G_table: [f64; 8]"),
321
+ "a module const numeric array should get a top-level `const` on GBA (#594):\n{}",
322
+ rust.lines().filter(|l| l.contains("TABLE")).take(4).collect::<Vec<_>>().join("\n")
323
+ );
324
+ assert!(
325
+ rust.contains("fn pick_nv("),
326
+ "and the fn that reads it should lower (#594) — that is the whole point:\n{}",
327
+ rust.lines().filter(|l| l.contains("pick")).take(4).collect::<Vec<_>>().join("\n")
328
+ );
329
+ }
330
+
331
+ /// A mutable module numeric global read AND written from a lowered fn. On GBA the storage is the
332
+ /// facade's `SingleCore<Cell<f64>>`, whose `with()` deliberately mirrors `LocalKey::with` — so the
333
+ /// access shape the emitter already produces works unchanged.
334
+ const NUMERIC_GLOBAL: &str = r#"
335
+ let seed = 7
336
+ function nextBase(): number {
337
+ seed = (seed * 3877 + 29573) % 139968
338
+ if (seed < 34992) { return 0 }
339
+ return 3
340
+ }
341
+ let acc = 0
342
+ let k = 0
343
+ while (k < 10) { acc = acc + nextBase(); k = k + 1 }
344
+ console.log(acc)
345
+ "#;
346
+
347
+ #[test]
348
+ fn a_module_numeric_global_is_reachable_from_a_lowered_fn_on_gba() {
349
+ let rust = compile_gba("numeric_global_gba", NUMERIC_GLOBAL);
350
+ // No `thread_local!` may reach a no_std target.
351
+ assert!(
352
+ !rust.contains("thread_local!"),
353
+ "GBA output must not contain thread_local! (no_std):\n{}",
354
+ rust.lines().filter(|l| l.contains("thread_local")).take(3).collect::<Vec<_>>().join("\n")
355
+ );
356
+ assert!(
357
+ rust.contains("SingleCore") && rust.contains("Cell<f64>"),
358
+ "a mutable module numeric global should live in a SingleCore<Cell<f64>> static on GBA \
359
+ (#594):\n{}",
360
+ rust.lines().filter(|l| l.contains("seed") || l.contains("SingleCore")).take(5)
361
+ .collect::<Vec<_>>().join("\n")
362
+ );
363
+ assert!(
364
+ rust.contains("fn nextBase_native("),
365
+ "and the fn that touches it should lower (#594):\n{}",
366
+ rust.lines().filter(|l| l.contains("nextBase")).take(4).collect::<Vec<_>>().join("\n")
367
+ );
368
+ }
369
+
370
+ /// The Fixed conversions must agree with ToInt32 on SIGN, not just magnitude. An arithmetic shift
371
+ /// (`>> 8`) floors, so a negative fixed would land one below where `as i32` puts it — -1.5 to -2
372
+ /// rather than -1. Non-negative values agree either way, which is how such a bug ships.
373
+ #[test]
374
+ fn fixed_to_int_truncates_toward_zero_not_floor() {
375
+ let rust = compile_gba(
376
+ "fixed_trunc",
377
+ r#"
378
+ declare fn take(n: i32): i32
379
+ let f: fixed = 0 - 1.5
380
+ let g: i32 = 7
381
+ console.log(g)
382
+ "#,
383
+ );
384
+ assert!(
385
+ !rust.contains(".to_raw() >> 8"),
386
+ "a Fixed->i32 conversion must divide (truncate toward zero), never arithmetic-shift (floor)"
387
+ );
388
+ }
389
+
390
+ // ── Findings from the adversarial review of the changes above ────────────────────────────────────
391
+
392
+ /// A string-returning fn that can FALL OFF THE END must not lower. `vec_fn_return_kind` rejects an
393
+ /// explicit bare `return;` but cannot see an implicit fall-through, and the lowered tail yields
394
+ /// `String::new()` where the boxed path yields `Value::Null` — so `label(-1)` returned "" instead of
395
+ /// null, silently, from ordinary code. `label(-1) === null` flipped true to false.
396
+ #[test]
397
+ fn a_partial_string_fn_does_not_lower() {
398
+ let rust = compile_gba(
399
+ "partial_string",
400
+ r#"
401
+ function label(n) {
402
+ if (n > 0) { return "pos" }
403
+ }
404
+ console.log(label(-1))
405
+ "#,
406
+ );
407
+ assert!(
408
+ !rust.contains("fn label_nv("),
409
+ "a string fn with an implicit fall-through must stay boxed — its lowered tail returns \"\" \
410
+ where tish returns null:\n{}",
411
+ rust.lines().filter(|l| l.contains("label")).take(3).collect::<Vec<_>>().join("\n")
412
+ );
413
+ }
414
+
415
+ /// `===` is OVERLOADED: it compares strings as readily as numbers, so it is not proof that an
416
+ /// operand is numeric. Treating it as arithmetic handed a string parameter the f64 ABI, which is a
417
+ /// `panic!("expected number")` on a no_std cart.
418
+ #[test]
419
+ fn a_string_compared_param_does_not_get_the_f64_abi() {
420
+ let rust = compile_gba(
421
+ "string_param",
422
+ r#"
423
+ function suit(tag) {
424
+ if (tag === "fire") { return "hearts" }
425
+ return "spades"
426
+ }
427
+ console.log(suit("fire"))
428
+ "#,
429
+ );
430
+ assert!(
431
+ !rust.contains("fn suit_nv(mut tag: f64"),
432
+ "a param proven 'numeric' only by `=== \"fire\"` must not take the f64 ABI:\n{}",
433
+ rust.lines().filter(|l| l.contains("suit")).take(3).collect::<Vec<_>>().join("\n")
434
+ );
435
+ }
436
+
437
+ /// A module const array must be disqualified by ANY binding of its name — a parameter, a for-of
438
+ /// variable, a catch binding — not just a `let`. The const fast path in the Index arm is static with
439
+ /// no scope check, so the module array was read in place of the local one: a WRONG VALUE with no
440
+ /// diagnostic, on every target.
441
+ #[test]
442
+ fn a_param_shadowing_a_module_const_array_is_not_the_module_one() {
443
+ let rust = compile_gba(
444
+ "shadow_const",
445
+ r#"
446
+ const T = [1, 2, 3]
447
+ function f(T, i) { return T[i] * 2 }
448
+ console.log(f([9, 8, 7], 0))
449
+ "#,
450
+ );
451
+ assert!(
452
+ !rust.contains("const G_T:"),
453
+ "a name bound as a parameter anywhere must not become a module const (wrong value):\n{}",
454
+ rust.lines().filter(|l| l.contains("G_T")).take(3).collect::<Vec<_>>().join("\n")
455
+ );
456
+ }
457
+
458
+ /// `let a = 1, b = 2` parses to `Statement::Multi`, and codegen emits each declarator into the
459
+ /// current scope — at module level, a `run()` local. The module-binding guard must see them, or a
460
+ /// free fn names one and the crate fails with E0425.
461
+ #[test]
462
+ fn multi_declared_module_bindings_are_seen_by_the_guard() {
463
+ let rust = compile_gba(
464
+ "multi_binding",
465
+ r#"
466
+ const CFG = { n: 3 }, STEP = 10
467
+ function build(raceId) {
468
+ let out = []
469
+ let i = 0
470
+ while (i < CFG.n) { out.push(raceId * STEP + i); i = i + 1 }
471
+ return out
472
+ }
473
+ const R = build(2)
474
+ console.log(R.length)
475
+ "#,
476
+ );
477
+ let mut in_nv = false;
478
+ let mut offenders: Vec<String> = Vec::new();
479
+ for line in rust.lines() {
480
+ if line.trim_start().starts_with("fn ") && line.contains("_nv(") {
481
+ in_nv = true;
482
+ } else if in_nv && line.starts_with('}') {
483
+ in_nv = false;
484
+ } else if in_nv && (line.contains("CFG") || line.contains("STEP")) {
485
+ offenders.push(line.trim().to_string());
486
+ }
487
+ }
488
+ assert!(
489
+ offenders.is_empty(),
490
+ "a Multi-declared module binding leaked into a lowered fn (E0425):\n {}",
491
+ offenders.join("\n ")
492
+ );
493
+ }
494
+
495
+ // ── #48/#52-follow-up: the module-const-array hoist was blind to closures ─────────────────────────
496
+ //
497
+ // `collect_module_const_f64_arrays` hoists a module-level numeric array to a top-level
498
+ // `const G_X: [f64; N]` so a LOWERED fn can reach it (that reachability is the whole point of #594).
499
+ // Admission is supposed to reject any array that is written, or used as a value/member, because a
500
+ // `const` is neither mutable nor addressable. The admission walker checks all of that — but its
501
+ // expression arm had no `ArrowFunction` case, so every use inside a CLOSURE fell into `_ => {}` and
502
+ // was invisible. The sibling scalar pass (`walk_expr_for_global_disqualifiers`) has always had that
503
+ // arm; this walker is a copy that lost it.
504
+ //
505
+ // Net effect once the pass was enabled on GBA: an array that a closure writes got hoisted anyway.
506
+ // The read side renamed to `G_X`, the write side kept the boxed local, and the boxed local is
507
+ // declared *after* the closure — so rustc reports `cannot find value` on a name the game never
508
+ // misspelled. Reported against card-gba (queensblood/triad/gwent) and reproduced independently by
509
+ // tish-gba `examples/hyrule` (`rangLive`, weapons.tish).
510
+ //
511
+ // The trigger needs all three ingredients: a module numeric array, a typed top-level fn that reads
512
+ // it (that is what nominates it for hoisting), and a write from inside a closure.
513
+
514
+ /// The reported repro, reduced. Before the fix this emitted `const G_A: [f64; 3]` plus a write
515
+ /// through a bare `A`, and rustc rejected it with E0425.
516
+ const CLOSURE_WRITE: &str = r#"
517
+ let A: i32[] = [1, 2, 3]
518
+
519
+ function readA(i: i32): i32 { return A[i] }
520
+
521
+ export function run(): i32 {
522
+ let bump = () => { A[0] = A[0] + 1 }
523
+ bump()
524
+ return readA(0)
525
+ }
526
+ "#;
527
+
528
+ #[test]
529
+ fn module_array_written_in_closure_is_not_hoisted_to_const() {
530
+ let rust = compile_gba("closure_write", CLOSURE_WRITE);
531
+ // The array is written, so it must stay a runtime binding: no const hoist at all. Renaming the
532
+ // write site would not be enough — a `const` cannot be assigned through.
533
+ assert!(
534
+ !rust.contains("const G_A"),
535
+ "a module array written from a closure must not be hoisted to a const:\n{}",
536
+ rust
537
+ );
538
+ // And the generated program must not reference a hoisted name it never declared.
539
+ assert!(
540
+ !rust.contains("G_A["),
541
+ "no read may be renamed to a hoist that does not exist:\n{}",
542
+ rust
543
+ );
544
+ }
545
+
546
+ /// The second missed case from the same report: `.length` lowers to `.len()`, which is a member use
547
+ /// the walker already rejects — it was just unreachable inside a closure. A hoist here emitted
548
+ /// `A.len()` against a name not in scope.
549
+ const CLOSURE_LENGTH: &str = r#"
550
+ let A: i32[] = [1, 2, 3]
551
+
552
+ function readA(i: i32): i32 { return A[i] }
553
+
554
+ export function run(): i32 {
555
+ let count = () => { return A.length }
556
+ return readA(0) + count()
557
+ }
558
+ "#;
559
+
560
+ #[test]
561
+ fn module_array_member_used_in_closure_is_not_hoisted_to_const() {
562
+ let rust = compile_gba("closure_length", CLOSURE_LENGTH);
563
+ assert!(
564
+ !rust.contains("const G_A"),
565
+ "a module array whose `.length` a closure reads must not be hoisted:\n{}",
566
+ rust
567
+ );
568
+ }
569
+
570
+ /// The win must survive: a read-only array indexed from a closure is still a legal hoist, because
571
+ /// the Index arm deliberately does not count `X[i]` as a disqualifying use of `X`. Without this the
572
+ /// fix would be a blanket "any closure mention disables hoisting" and would give back #594.
573
+ const CLOSURE_READ_ONLY: &str = r#"
574
+ let A: i32[] = [1, 2, 3]
575
+
576
+ function readA(i: i32): i32 { return A[i] }
577
+
578
+ export function run(): i32 {
579
+ let pick = () => { return A[1] }
580
+ return readA(0) + pick()
581
+ }
582
+ "#;
583
+
584
+ #[test]
585
+ fn read_only_module_array_still_hoists_when_a_closure_indexes_it() {
586
+ let rust = compile_gba("closure_read_only", CLOSURE_READ_ONLY);
587
+ assert!(
588
+ rust.contains("G_A"),
589
+ "a read-only module array must still hoist — that reachability is #594:\n{}",
590
+ rust
591
+ );
592
+ }
593
+
594
+ // ── GBA representation: the hoist must not carry the host's f64 onto a CPU with no FPU ───────────
595
+ //
596
+ // ARM7TDMI has no hardware float. Hoisting `let A: i32[] = [1,2,3]` to `const G_A: [f64; 3]` cost
597
+ // 8 bytes per element instead of 4, an `i32 -> f64 -> usize` round trip on every index, and an
598
+ // `f64::NAN` out-of-range sentinel — while DISCARDING the element type the source had already
599
+ // declared. These pin the integer representation.
600
+
601
+ #[test]
602
+ fn an_integral_module_const_array_is_emitted_as_i32_on_gba() {
603
+ let rust = compile_gba("repr_int", CLOSURE_READ_ONLY);
604
+ assert!(
605
+ rust.contains("const G_A: [i32; 3]"),
606
+ "an all-integer module const array must hoist as [i32; N], not [f64; N]:\n{}",
607
+ rust
608
+ );
609
+ assert!(
610
+ !rust.contains("const G_A: [f64; 3]"),
611
+ "the f64 representation must be gone:\n{}",
612
+ rust
613
+ );
614
+ }
615
+
616
+ /// Non-integral values still need f64 — the narrowing is value-driven, not blanket.
617
+ const FRACTIONAL: &str = r#"
618
+ let P: number[] = [0.25, 0.5, 0.25]
619
+
620
+ function readP(i: i32): number { return P[i] }
621
+
622
+ export function run(): number {
623
+ let pick = () => { return P[1] }
624
+ return readP(0) + pick()
625
+ }
626
+ "#;
627
+
628
+ #[test]
629
+ fn a_fractional_module_const_array_stays_f64() {
630
+ let rust = compile_gba("repr_frac", FRACTIONAL);
631
+ assert!(
632
+ rust.contains("const G_P: [f64; 3]"),
633
+ "a fractional array must keep f64 — narrowing it would change its values:\n{}",
634
+ rust
635
+ );
636
+ }
637
+
638
+ /// The out-of-range read still has to answer NaN. There is no `i32` meaning "absent", so the
639
+ /// bounds-checked form widens rather than inventing a sentinel that would read as a real element.
640
+ #[test]
641
+ fn an_unproven_index_still_answers_nan() {
642
+ let rust = compile_gba("repr_int", CLOSURE_READ_ONLY);
643
+ if rust.contains("f64::NAN") {
644
+ assert!(
645
+ rust.contains("as f64"),
646
+ "an i32-backed out-of-range read must widen before yielding NaN:\n{}",
647
+ rust
648
+ );
649
+ }
650
+ }
651
+
652
+ /// The SECOND hole, found only because the first fix left queensblood at 4 errors instead of 12: the
653
+ /// statement walker visited loop and branch BODIES but not their CONDITIONS. `.length` is a member
654
+ /// use the walker already rejects — it was simply never reached in `while (i < X.length)`, which is
655
+ /// how card-gba's `SB_FORCE`/`SB_PRE`/`TN_REWARD` (exported `i32[]` in qb-catalog.tish, consumed by
656
+ /// qb-engine.tish:567 and main.tish:297) stayed eligible and hoisted.
657
+ const COND_LENGTH: &str = r#"
658
+ let A: i32[] = [1, 2, 3]
659
+
660
+ function readA(i: i32): i32 { return A[i] }
661
+
662
+ export function run(): i32 {
663
+ let i: i32 = 0
664
+ let n: i32 = 0
665
+ while (i < A.length) { n = n + readA(i); i = i + 1 }
666
+ return n
667
+ }
668
+ "#;
669
+
670
+ #[test]
671
+ fn a_use_in_a_loop_condition_disqualifies_the_hoist() {
672
+ let rust = compile_gba("cond_length", COND_LENGTH);
673
+ assert!(
674
+ !rust.contains("const G_A"),
675
+ "`.length` in a while-condition is a member use and must block the hoist:\n{}",
676
+ rust
677
+ );
678
+ }
679
+
680
+ /// Same shape, in an `if` condition — that arm had the identical omission.
681
+ const IF_COND: &str = r#"
682
+ let A: i32[] = [1, 2, 3]
683
+
684
+ function readA(i: i32): i32 { return A[i] }
685
+
686
+ export function run(): i32 {
687
+ if (A.length > 2) { return readA(0) }
688
+ return 0
689
+ }
690
+ "#;
691
+
692
+ #[test]
693
+ fn a_use_in_an_if_condition_disqualifies_the_hoist() {
694
+ let rust = compile_gba("if_cond", IF_COND);
695
+ assert!(
696
+ !rust.contains("const G_A"),
697
+ "`.length` in an if-condition must block the hoist:\n{}",
698
+ rust
699
+ );
700
+ }
701
+
702
+ // ── #594 residual, reported from tish-gba examples/hyrule ─────────────────────────────────────────
703
+ //
704
+ // Reported shape: a fn with NO PARAMS and NO RETURN that WRITES a module array, and IS CALLED. On
705
+ // 3.3.0 that emitted the new `vm_read(&CELL)` module-scope capability without bringing the cell into
706
+ // scope. Two facts from the report narrow it and are both encoded below:
707
+ // - the identical shape with NO callers compiles, because it is dead-code eliminated, so a repro
708
+ // MUST contain a call site;
709
+ // - inlining the body at the call site is a complete workaround.
710
+ //
711
+ // This is the `rangDone()` shape in hyrule's weapons.tish (`export function rangDone() { rangLive[0] = 0 }`),
712
+ // which is the same `rangLive` E0425 that started this investigation.
713
+
714
+ /// No params, no return, writes a module array, and — critically — is called.
715
+ const WRITER_WITH_CALLER: &str = r#"
716
+ let A: i32[] = [0, 0, 0]
717
+
718
+ function readA(i: i32): i32 { return A[i] }
719
+
720
+ function clearA() { A[0] = 0 }
721
+
722
+ export function run(): i32 {
723
+ clearA()
724
+ return readA(0)
725
+ }
726
+ "#;
727
+
728
+ #[test]
729
+ fn a_called_no_param_no_return_module_array_writer_compiles() {
730
+ let rust = compile_gba("writer_with_caller", WRITER_WITH_CALLER);
731
+ // The array is written, so it must NOT have been hoisted…
732
+ assert!(
733
+ !rust.contains("const G_A"),
734
+ "a written module array must not be hoisted:\n{}",
735
+ rust
736
+ );
737
+ // …and no lowered free fn may reference the boxed cell, which lives in `run()`. A free `fn`
738
+ // mentioning `A` is precisely the E0425 the report describes.
739
+ for (i, line) in rust.lines().enumerate() {
740
+ let t = line.trim_start();
741
+ if t.starts_with("fn ") || t.starts_with("pub fn ") {
742
+ assert!(
743
+ !t.contains("clearA") || t.contains("_cell"),
744
+ "clearA must not be emitted as a free fn that cannot see module scope (line {}): {}",
745
+ i + 1,
746
+ t
747
+ );
748
+ }
749
+ }
750
+ }
751
+
752
+ // ── #600: `let s: string = strArr[i]` moves out of the Vec ────────────────────────────────────────
753
+ const STR_ELEM_BIND: &str = r#"
754
+ let NAMES: string[] = ["alpha", "beta"]
755
+
756
+ export function show(i: i32): string {
757
+ let name: string = NAMES[i]
758
+ return name
759
+ }
760
+ "#;
761
+
762
+ #[test]
763
+ fn binding_a_string_element_clones_instead_of_moving() {
764
+ let rust = compile_gba("str_elem_bind", STR_ELEM_BIND);
765
+ let bad = rust.lines().any(|l| {
766
+ let t = l.trim();
767
+ t.contains("= NAMES[") && !t.contains(".clone()") && !t.contains("&NAMES[")
768
+ });
769
+ assert!(
770
+ !bad,
771
+ "a string element read must clone — a bare index is a move out of the Vec (E0507):\n{}",
772
+ rust.lines().filter(|l| l.contains("NAMES[")).take(4).collect::<Vec<_>>().join("\n")
773
+ );
774
+ }
775
+
776
+ // ── #599: a written module-level `let x: i32` ────────────────────────────────────────────────────
777
+ const MODULE_SCALAR_WRITE: &str = r#"
778
+ let counter: i32 = 0
779
+
780
+ function bump() { counter = counter + 1 }
781
+
782
+ export function run(): i32 {
783
+ bump()
784
+ return counter
785
+ }
786
+ "#;
787
+
788
+ #[test]
789
+ fn a_written_module_scalar_reads_and_writes_through_the_same_cell() {
790
+ let rust = compile_gba("module_scalar_write", MODULE_SCALAR_WRITE);
791
+ // If reads go through a cell, writes must too. A bare `counter = <i32>` against a VmRef<i32>
792
+ // is E0308 — the two halves disagreeing is the bug.
793
+ for line in rust.lines() {
794
+ let t = line.trim();
795
+ if t.starts_with("counter = ") && !t.contains("vm_write") {
796
+ panic!(
797
+ "write must match the read's representation (#599):\n{}",
798
+ rust.lines().filter(|l| l.contains("counter")).take(6).collect::<Vec<_>>().join("\n")
799
+ );
800
+ }
801
+ }
802
+ }
803
+
804
+ // ── #597: a typed array passed to a function is COPIED, not aliased ───────────────────────────────
805
+ //
806
+ // `let reqs: i32[] = []` lowers to `Vec<i32>`, but a call site marshals a FRESH `Value::Array` from
807
+ // its elements. The callee's pushes land in the copy and the caller's array stays empty — silently,
808
+ // with no error at compile time or run time. An UNTYPED array passes the same `VmRef` and aliases
809
+ // correctly, so adding an annotation changes program behaviour rather than just representation.
810
+ //
811
+ // The fix cannot live at the call site: building a `Value::Array` out of a `Vec<i32>` is a copy by
812
+ // construction. An array that escapes into a boxed call has to stay boxed.
813
+ const ARRAY_ESCAPES: &str = r#"
814
+ function fill(out) {
815
+ out.push(1)
816
+ }
817
+
818
+ export function run(): i32 {
819
+ let reqs: i32[] = []
820
+ fill(reqs)
821
+ return reqs.length
822
+ }
823
+ "#;
824
+
825
+ #[test]
826
+ fn a_typed_array_passed_to_a_function_is_not_copied() {
827
+ let rust = compile_gba("array_escapes", ARRAY_ESCAPES);
828
+ let copied = rust.lines().find(|l| {
829
+ l.contains("Value::Array(VmRef::new(") && l.contains("reqs") && l.contains(".iter()")
830
+ });
831
+ assert!(
832
+ copied.is_none(),
833
+ "a typed array passed to a fn was marshalled as a fresh copy — the callee's writes are \
834
+ silently lost (#597):\n {}",
835
+ copied.unwrap_or("")
836
+ );
837
+ }
838
+
839
+ // ── #629: module bindings are emitted in SOURCE ORDER — a fn referencing a LATER binding breaks ───
840
+ //
841
+ // The variable is ORDER, nothing else. Module-level bindings are emitted into `run()` where they
842
+ // appear, but the function values that capture them are emitted at their own source position, so a
843
+ // function declared ABOVE its binding captures a name that is not in scope yet:
844
+ //
845
+ // let rangDone = { Value::native(move |args| { … vm_read(&rangLive) … }) }; // use
846
+ // let rangLive = VmRef::new(vec![(0_f64) as i32]); // declaration
847
+ //
848
+ // This is legal tish — the function is not CALLED before the binding is initialised.
849
+ //
850
+ // The fixture pinned in 57d6aa1be for this got it wrong: it declared `let A` FIRST, so it passed
851
+ // while the bug was live and proved nothing. Moving the declaration is the whole difference, which
852
+ // is why the earlier reduction "reproduced" on neither compiler. Hits readers and writers alike.
853
+ const FORWARD_REF: &str = r#"
854
+ function readA(i: i32): i32 { return A[i] }
855
+ function clearA() { A[0] = 0 }
856
+
857
+ let A: i32[] = [0, 0, 0]
858
+
859
+ export function run(): i32 {
860
+ clearA()
861
+ return readA(0)
862
+ }
863
+ "#;
864
+
865
+ #[test]
866
+ fn a_fn_may_reference_a_module_binding_declared_after_it() {
867
+ let rust = compile_gba("forward_ref", FORWARD_REF);
868
+ // Find where `A` is DECLARED and where it is first USED. A use above the declaration is the bug.
869
+ let decl = rust
870
+ .lines()
871
+ .position(|l| {
872
+ let t = l.trim();
873
+ t.starts_with("let A = ") || t.starts_with("let A:") || t.starts_with("let mut A")
874
+ });
875
+ let first_use = rust.lines().position(|l| {
876
+ let t = l.trim();
877
+ (t.contains("vm_read(&A)") || t.contains("(&A)") || t.contains("A.borrow"))
878
+ && !t.starts_with("let A")
879
+ });
880
+ if let (Some(d), Some(u)) = (decl, first_use) {
881
+ assert!(
882
+ u > d,
883
+ "#629: `A` is used at line {} but only declared at line {} — use before declaration \
884
+ (E0425). Module bindings must be emitted ahead of the function values that capture \
885
+ them.\n{}",
886
+ u + 1,
887
+ d + 1,
888
+ rust.lines().skip(d.saturating_sub(6)).take(12).collect::<Vec<_>>().join("\n")
889
+ );
890
+ }
891
+ }