@tishlang/tish-format 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.
- package/bin/tish-format +0 -0
- package/crates/tish_compile/src/codegen.rs +1838 -162
- package/crates/tish_compile/src/types.rs +6 -0
- package/crates/tish_compile/tests/perf_codegen_i32_native_abi.rs +113 -0
- package/crates/tish_compile/tests/regr_gba_lowered_call_sites.rs +891 -0
- package/crates/tish_fmt/Cargo.toml +1 -1
- package/crates/tish_native/src/build.rs +24 -6
- package/crates/tish_runtime_gba/src/gba.rs +26 -6
- package/crates/tish_runtime_gba/src/lib.rs +29 -0
- package/package.json +1 -1
- package/platform/darwin-arm64/tish-fmt +0 -0
- package/platform/darwin-x64/tish-fmt +0 -0
- package/platform/linux-arm64/tish-fmt +0 -0
- package/platform/linux-x64/tish-fmt +0 -0
- package/platform/win32-x64/tish-fmt.exe +0 -0
|
@@ -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
|
+
}
|