@tishlang/tish-lsp 3.2.2 → 3.3.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.
@@ -30,6 +30,12 @@ fn gba_numerics() -> bool {
30
30
  GBA_NUMERICS.with(|c| c.get())
31
31
  }
32
32
 
33
+ /// Public view of the same switch, for passes that must behave differently only where the extended
34
+ /// numeric vocabulary exists — see the native-fn bitwise widening in `codegen.rs`.
35
+ pub fn gba_numerics_enabled() -> bool {
36
+ gba_numerics()
37
+ }
38
+
33
39
  /// Concrete Rust type representation for code generation.
34
40
  #[derive(Debug, Clone, PartialEq)]
35
41
  pub enum RustType {
@@ -0,0 +1,113 @@
1
+ //! M5 native fns, with an `i32` calling convention.
2
+ //!
3
+ //! `collect_native_fns` gated parameters and returns on `RustType::F64`, so `: number` qualified for
4
+ //! a native free `fn` and `: i32` did not — and `: i32` is the annotation a device with no FPU
5
+ //! wants. Every argument and every return of such a call was therefore an i32→f64→i32 round trip,
6
+ //! which on ARM7TDMI is a soft-float call apiece.
7
+ //!
8
+ //! The shape is ALL-OR-NOTHING: every parameter and the return annotated `: i32`. Mixing is what
9
+ //! makes it unsound — an `: i32` parameter reached through the boxed path is ToInt32-coerced on
10
+ //! entry, and promoted to a native `f64` parameter it would not be, so 3.7 would arrive as 3 one
11
+ //! way and 3.7 the other.
12
+
13
+ use std::path::PathBuf;
14
+
15
+ use tishlang_compile::{compile_project_full_emit, NativeEmitMode};
16
+
17
+ /// Compile for GBA — `: i32` is only a native scalar under the GBA numeric vocabulary
18
+ /// (`types.rs`: `"i32" if gba_numerics()`), so on a desktop target this ABI cannot exist and the
19
+ /// assertions below would be vacuous.
20
+ fn compile_src(stem: &str, src: &str) -> String {
21
+ let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/perf_codegen_i32_abi");
22
+ std::fs::create_dir_all(&dir).unwrap();
23
+ let path = dir.join(format!("{stem}.tish"));
24
+ std::fs::write(&path, src).unwrap();
25
+ let path = path.canonicalize().unwrap();
26
+ let (rust, _, _, _) =
27
+ compile_project_full_emit(&path, path.parent(), &[], true, NativeEmitMode::Gba, None)
28
+ .unwrap();
29
+ rust
30
+ }
31
+
32
+ const ALL_I32: &str = r#"
33
+ function mix(a: i32, b: i32): i32 { return ((a * 3) ^ (b << 2)) & 65535 }
34
+ let acc: i32 = 0
35
+ let j: i32 = 0
36
+ while (j < 10) { acc = (acc + mix(j, acc)) & 65535; j = j + 1 }
37
+ console.log(acc)
38
+ "#;
39
+
40
+ #[test]
41
+ fn an_all_i32_signature_gets_an_i32_native_fn() {
42
+ let rust = compile_src("all_i32", ALL_I32);
43
+ assert!(
44
+ rust.contains("fn mix_native(mut a: i32, mut b: i32) -> i32"),
45
+ "expected an i32-signature native fn, got:\n{}",
46
+ rust.lines().filter(|l| l.contains("mix_native")).collect::<Vec<_>>().join("\n")
47
+ );
48
+ // And the call must reach it directly rather than through `value_call`.
49
+ assert!(rust.contains("mix_native("), "the call site should target the native fn");
50
+ }
51
+
52
+ const MIXED: &str = r#"
53
+ function half(a: i32, b: number): i32 { return (a + b) | 0 }
54
+ console.log(half(7, 2.5))
55
+ "#;
56
+
57
+ #[test]
58
+ fn a_mixed_signature_does_not_get_the_i32_abi() {
59
+ let rust = compile_src("mixed", MIXED);
60
+ assert!(
61
+ !rust.contains("fn half_native(mut a: i32"),
62
+ "a mixed i32/number signature must not take the i32 ABI — an `: i32` param is \
63
+ ToInt32-coerced on entry and an f64 native param is not"
64
+ );
65
+ }
66
+
67
+ const LIAR: &str = r#"
68
+ function liar(x: i32): i32 {
69
+ if (x > 0) { return "not a number" }
70
+ return 7
71
+ }
72
+ console.log(liar(1))
73
+ "#;
74
+
75
+ #[test]
76
+ fn the_annotation_alone_does_not_qualify_a_fn() {
77
+ // Return annotations are erased and unchecked: this program prints the STRING today. The i32
78
+ // ABI must not be handed out on the strength of `: i32`, only on the fixpoint's proof that
79
+ // every return is numeric — otherwise this would unbox a string as a number.
80
+ let rust = compile_src("liar", LIAR);
81
+ assert!(
82
+ !rust.contains("fn liar_native"),
83
+ "a fn with a non-numeric return path must stay boxed regardless of its annotation"
84
+ );
85
+ }
86
+
87
+ const READS_MODULE_STATE: &str = r#"
88
+ let ARR: i32[] = []
89
+ let k: i32 = 0
90
+ while (k < 8) { ARR.push(k); k = k + 1 }
91
+ function pure(a: i32, b: i32): i32 { return (a ^ b) & 255 }
92
+ function reads(i: i32): i32 { return ARR[i] & 255 }
93
+ let x: i32 = pure(1, 2) + reads(3)
94
+ console.log(x)
95
+ "#;
96
+
97
+ #[test]
98
+ fn a_fn_reading_module_state_stays_boxed() {
99
+ // A native fn is emitted at TOP LEVEL, where a module-scope `let` — a local of `run()`, closed
100
+ // over by the boxed closures — does not exist. `native_safe_expr`'s index arm accepted any
101
+ // identifier as the indexed object, so this compiled to a `reads_native` whose body named
102
+ // `ARR` and rustc rejected the generated program with E0425. The sibling `pure` must still be
103
+ // promoted: the check has to reject the out-of-scope read, not indexing as such.
104
+ let rust = compile_src("reads_module_state", READS_MODULE_STATE);
105
+ assert!(
106
+ !rust.contains("fn reads_native"),
107
+ "a fn indexing a module-scope binding cannot become a top-level free fn"
108
+ );
109
+ assert!(
110
+ rust.contains("fn pure_native(mut a: i32, mut b: i32) -> i32"),
111
+ "its parameter-only sibling must still get the native i32 fn"
112
+ );
113
+ }
@@ -0,0 +1,487 @@
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
+ assert!(
314
+ rust.contains("const G_TABLE: [f64; 8]") || rust.contains("const G_table: [f64; 8]"),
315
+ "a module const numeric array should get a top-level `const` on GBA (#594):\n{}",
316
+ rust.lines().filter(|l| l.contains("TABLE")).take(4).collect::<Vec<_>>().join("\n")
317
+ );
318
+ assert!(
319
+ rust.contains("fn pick_nv("),
320
+ "and the fn that reads it should lower (#594) — that is the whole point:\n{}",
321
+ rust.lines().filter(|l| l.contains("pick")).take(4).collect::<Vec<_>>().join("\n")
322
+ );
323
+ }
324
+
325
+ /// A mutable module numeric global read AND written from a lowered fn. On GBA the storage is the
326
+ /// facade's `SingleCore<Cell<f64>>`, whose `with()` deliberately mirrors `LocalKey::with` — so the
327
+ /// access shape the emitter already produces works unchanged.
328
+ const NUMERIC_GLOBAL: &str = r#"
329
+ let seed = 7
330
+ function nextBase(): number {
331
+ seed = (seed * 3877 + 29573) % 139968
332
+ if (seed < 34992) { return 0 }
333
+ return 3
334
+ }
335
+ let acc = 0
336
+ let k = 0
337
+ while (k < 10) { acc = acc + nextBase(); k = k + 1 }
338
+ console.log(acc)
339
+ "#;
340
+
341
+ #[test]
342
+ fn a_module_numeric_global_is_reachable_from_a_lowered_fn_on_gba() {
343
+ let rust = compile_gba("numeric_global_gba", NUMERIC_GLOBAL);
344
+ // No `thread_local!` may reach a no_std target.
345
+ assert!(
346
+ !rust.contains("thread_local!"),
347
+ "GBA output must not contain thread_local! (no_std):\n{}",
348
+ rust.lines().filter(|l| l.contains("thread_local")).take(3).collect::<Vec<_>>().join("\n")
349
+ );
350
+ assert!(
351
+ rust.contains("SingleCore") && rust.contains("Cell<f64>"),
352
+ "a mutable module numeric global should live in a SingleCore<Cell<f64>> static on GBA \
353
+ (#594):\n{}",
354
+ rust.lines().filter(|l| l.contains("seed") || l.contains("SingleCore")).take(5)
355
+ .collect::<Vec<_>>().join("\n")
356
+ );
357
+ assert!(
358
+ rust.contains("fn nextBase_native("),
359
+ "and the fn that touches it should lower (#594):\n{}",
360
+ rust.lines().filter(|l| l.contains("nextBase")).take(4).collect::<Vec<_>>().join("\n")
361
+ );
362
+ }
363
+
364
+ /// The Fixed conversions must agree with ToInt32 on SIGN, not just magnitude. An arithmetic shift
365
+ /// (`>> 8`) floors, so a negative fixed would land one below where `as i32` puts it — -1.5 to -2
366
+ /// rather than -1. Non-negative values agree either way, which is how such a bug ships.
367
+ #[test]
368
+ fn fixed_to_int_truncates_toward_zero_not_floor() {
369
+ let rust = compile_gba(
370
+ "fixed_trunc",
371
+ r#"
372
+ declare fn take(n: i32): i32
373
+ let f: fixed = 0 - 1.5
374
+ let g: i32 = 7
375
+ console.log(g)
376
+ "#,
377
+ );
378
+ assert!(
379
+ !rust.contains(".to_raw() >> 8"),
380
+ "a Fixed->i32 conversion must divide (truncate toward zero), never arithmetic-shift (floor)"
381
+ );
382
+ }
383
+
384
+ // ── Findings from the adversarial review of the changes above ────────────────────────────────────
385
+
386
+ /// A string-returning fn that can FALL OFF THE END must not lower. `vec_fn_return_kind` rejects an
387
+ /// explicit bare `return;` but cannot see an implicit fall-through, and the lowered tail yields
388
+ /// `String::new()` where the boxed path yields `Value::Null` — so `label(-1)` returned "" instead of
389
+ /// null, silently, from ordinary code. `label(-1) === null` flipped true to false.
390
+ #[test]
391
+ fn a_partial_string_fn_does_not_lower() {
392
+ let rust = compile_gba(
393
+ "partial_string",
394
+ r#"
395
+ function label(n) {
396
+ if (n > 0) { return "pos" }
397
+ }
398
+ console.log(label(-1))
399
+ "#,
400
+ );
401
+ assert!(
402
+ !rust.contains("fn label_nv("),
403
+ "a string fn with an implicit fall-through must stay boxed — its lowered tail returns \"\" \
404
+ where tish returns null:\n{}",
405
+ rust.lines().filter(|l| l.contains("label")).take(3).collect::<Vec<_>>().join("\n")
406
+ );
407
+ }
408
+
409
+ /// `===` is OVERLOADED: it compares strings as readily as numbers, so it is not proof that an
410
+ /// operand is numeric. Treating it as arithmetic handed a string parameter the f64 ABI, which is a
411
+ /// `panic!("expected number")` on a no_std cart.
412
+ #[test]
413
+ fn a_string_compared_param_does_not_get_the_f64_abi() {
414
+ let rust = compile_gba(
415
+ "string_param",
416
+ r#"
417
+ function suit(tag) {
418
+ if (tag === "fire") { return "hearts" }
419
+ return "spades"
420
+ }
421
+ console.log(suit("fire"))
422
+ "#,
423
+ );
424
+ assert!(
425
+ !rust.contains("fn suit_nv(mut tag: f64"),
426
+ "a param proven 'numeric' only by `=== \"fire\"` must not take the f64 ABI:\n{}",
427
+ rust.lines().filter(|l| l.contains("suit")).take(3).collect::<Vec<_>>().join("\n")
428
+ );
429
+ }
430
+
431
+ /// A module const array must be disqualified by ANY binding of its name — a parameter, a for-of
432
+ /// variable, a catch binding — not just a `let`. The const fast path in the Index arm is static with
433
+ /// no scope check, so the module array was read in place of the local one: a WRONG VALUE with no
434
+ /// diagnostic, on every target.
435
+ #[test]
436
+ fn a_param_shadowing_a_module_const_array_is_not_the_module_one() {
437
+ let rust = compile_gba(
438
+ "shadow_const",
439
+ r#"
440
+ const T = [1, 2, 3]
441
+ function f(T, i) { return T[i] * 2 }
442
+ console.log(f([9, 8, 7], 0))
443
+ "#,
444
+ );
445
+ assert!(
446
+ !rust.contains("const G_T:"),
447
+ "a name bound as a parameter anywhere must not become a module const (wrong value):\n{}",
448
+ rust.lines().filter(|l| l.contains("G_T")).take(3).collect::<Vec<_>>().join("\n")
449
+ );
450
+ }
451
+
452
+ /// `let a = 1, b = 2` parses to `Statement::Multi`, and codegen emits each declarator into the
453
+ /// current scope — at module level, a `run()` local. The module-binding guard must see them, or a
454
+ /// free fn names one and the crate fails with E0425.
455
+ #[test]
456
+ fn multi_declared_module_bindings_are_seen_by_the_guard() {
457
+ let rust = compile_gba(
458
+ "multi_binding",
459
+ r#"
460
+ const CFG = { n: 3 }, STEP = 10
461
+ function build(raceId) {
462
+ let out = []
463
+ let i = 0
464
+ while (i < CFG.n) { out.push(raceId * STEP + i); i = i + 1 }
465
+ return out
466
+ }
467
+ const R = build(2)
468
+ console.log(R.length)
469
+ "#,
470
+ );
471
+ let mut in_nv = false;
472
+ let mut offenders: Vec<String> = Vec::new();
473
+ for line in rust.lines() {
474
+ if line.trim_start().starts_with("fn ") && line.contains("_nv(") {
475
+ in_nv = true;
476
+ } else if in_nv && line.starts_with('}') {
477
+ in_nv = false;
478
+ } else if in_nv && (line.contains("CFG") || line.contains("STEP")) {
479
+ offenders.push(line.trim().to_string());
480
+ }
481
+ }
482
+ assert!(
483
+ offenders.is_empty(),
484
+ "a Multi-declared module binding leaked into a lowered fn (E0425):\n {}",
485
+ offenders.join("\n ")
486
+ );
487
+ }
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tishlang_lsp"
3
- version = "3.2.2"
3
+ version = "3.3.0"
4
4
  edition = "2021"
5
5
  description = "Language Server Protocol implementation for Tish"
6
6
  license-file = { workspace = true }
@@ -1,7 +1,7 @@
1
1
  //! Build native binary via cargo (interim path until Cranelift backend is ready).
2
2
 
3
3
  use std::fs;
4
- use std::path::Path;
4
+ use std::path::{Path, PathBuf};
5
5
 
6
6
  use tishlang_compile::ResolvedNativeModule;
7
7
 
@@ -627,15 +627,34 @@ runner = ["mgba-qt", "-C", "logToStdout=1", "-C", "logLevel.gba.debug=127"]
627
627
  // with `-Tgba.ld` — cargo *joins* rustflags arrays across config files, so a
628
628
  // config-file approach would pass `-Tgba.ld` twice ("region already defined").
629
629
  // build-std still comes from the generated `[unstable]` config (not rustflags).
630
- let status = Command::new("cargo")
630
+ //
631
+ // TISH_GBA_TARGET_DIR (opt-in, explicit — deliberately NOT the ambient
632
+ // CARGO_TARGET_DIR, which the desktop path scrubs for good reason): share ONE
633
+ // cargo target dir across every example's ROM build. Each example otherwise
634
+ // carries a full private dependency tree — measured at ~45 GB across the
635
+ // tish-gba fleet, and it filled the disk mid-build once. An inherited
636
+ // CARGO_TARGET_DIR meanwhile made cargo write where the ELF lookup below
637
+ // never looked ("GBA ELF not found" on a SUCCESSFUL compile), so that env is
638
+ // scrubbed here and only the explicit opt-in steers both paths together.
639
+ let shared_target = std::env::var_os("TISH_GBA_TARGET_DIR").map(PathBuf::from);
640
+ let target_root = shared_target
641
+ .clone()
642
+ .unwrap_or_else(|| build_dir.join("target"));
643
+ let mut cargo = Command::new("cargo");
644
+ cargo
631
645
  .arg("build")
632
646
  .arg("--release")
633
647
  .current_dir(&build_dir)
648
+ .env_remove("CARGO_TARGET_DIR")
634
649
  .env(
635
650
  "RUSTFLAGS",
636
651
  "-Clink-arg=-Tgba.ld -Ctarget-cpu=arm7tdmi -Cforce-frame-pointers=yes",
637
652
  )
638
- .env("CARGO_TERM_COLOR", "always")
653
+ .env("CARGO_TERM_COLOR", "always");
654
+ if let Some(dir) = &shared_target {
655
+ cargo.env("CARGO_TARGET_DIR", dir);
656
+ }
657
+ let status = cargo
639
658
  .status()
640
659
  .map_err(|e| format!("Failed to run cargo for GBA build: {}", e))?;
641
660
  if !status.success() {
@@ -645,9 +664,8 @@ runner = ["mgba-qt", "-C", "logToStdout=1", "-C", "logLevel.gba.debug=127"]
645
664
  ));
646
665
  }
647
666
 
648
- // ELF → .gba via agb-gbafix.
649
- let elf = build_dir
650
- .join("target")
667
+ // ELF → .gba via agb-gbafix. Resolved from the SAME root the build wrote to.
668
+ let elf = target_root
651
669
  .join("thumbv4t-none-eabi")
652
670
  .join("release")
653
671
  .join(&cargo_name);