@tishlang/tish-format 3.1.0 → 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.
- package/bin/tish-format +0 -0
- package/crates/tish/src/cli_help.rs +4 -0
- package/crates/tish/src/main.rs +13 -2
- package/crates/tish/tests/rust_lib_crate.rs +140 -0
- package/crates/tish_compile/src/codegen.rs +1395 -107
- package/crates/tish_compile/src/lib.rs +14 -1
- 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_andor_rhs_move.rs +85 -0
- package/crates/tish_compile/tests/regr_gba_lowered_call_sites.rs +487 -0
- package/crates/tish_compile/tests/regr_rust_lib_emit.rs +150 -0
- package/crates/tish_fmt/Cargo.toml +1 -1
- package/crates/tish_fmt/src/lib.rs +54 -1
- package/crates/tish_native/src/build.rs +151 -6
- package/crates/tish_native/src/config.rs +14 -0
- package/crates/tish_runtime_gba/src/gba.rs +26 -6
- package/crates/tish_runtime_gba/src/lib.rs +46 -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,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
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
//! `--target rust-lib` (`NativeEmitMode::RustLib`): a Tish library compiled to a Rust library crate
|
|
2
|
+
//! whose public API is the entry module's `export fn`s — the Rust counterpart of `--target js`.
|
|
3
|
+
//!
|
|
4
|
+
//! The module body still lowers to the ordinary whole-program `run()`. What the library emit adds is
|
|
5
|
+
//! (a) one `pub fn` per export and (b) keeping the exported bindings alive past `run()` in a
|
|
6
|
+
//! thread-local, so module-level state survives between calls. These tests pin that emitted shape;
|
|
7
|
+
//! the crate layout and the behavioural end (state persists, reassignment is observable, init runs
|
|
8
|
+
//! once) are covered in `crates/tish/tests/rust_lib_crate.rs`.
|
|
9
|
+
use std::path::PathBuf;
|
|
10
|
+
use tishlang_compile::{compile_project_full, compile_project_full_emit, NativeEmitMode};
|
|
11
|
+
|
|
12
|
+
fn fixture(rel: &str) -> PathBuf {
|
|
13
|
+
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
|
14
|
+
manifest.join("../..").join(rel).canonicalize().unwrap()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
fn compile_lib(rel: &str) -> String {
|
|
18
|
+
let path = fixture(rel);
|
|
19
|
+
compile_project_full_emit(&path, path.parent(), &[], true, NativeEmitMode::RustLib, None)
|
|
20
|
+
.expect("rust-lib emit")
|
|
21
|
+
.0
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
#[test]
|
|
25
|
+
fn every_export_becomes_an_arity_matched_pub_fn() {
|
|
26
|
+
let rust = compile_lib("tests/regression/rust_lib_emit.tish");
|
|
27
|
+
for sig in [
|
|
28
|
+
"pub fn register(a0: Value, a1: Value) -> Value",
|
|
29
|
+
"pub fn lookup(a0: Value) -> Value",
|
|
30
|
+
"pub fn clearAll() -> Value",
|
|
31
|
+
"pub fn initRuns() -> Value",
|
|
32
|
+
"pub fn add(a0: Value, a1: Value) -> Value",
|
|
33
|
+
] {
|
|
34
|
+
assert!(
|
|
35
|
+
rust.contains(sig),
|
|
36
|
+
"missing `{sig}` in the emitted library surface:\n{rust}"
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#[test]
|
|
42
|
+
fn library_crate_has_no_binary_entry_point() {
|
|
43
|
+
let rust = compile_lib("tests/regression/rust_lib_emit.tish");
|
|
44
|
+
// A `fn main` (or the iOS C entry) would make this an executable/staticlib, not an rlib.
|
|
45
|
+
assert!(
|
|
46
|
+
!rust.contains("fn main()"),
|
|
47
|
+
"a library crate must not emit a binary entry point:\n{rust}"
|
|
48
|
+
);
|
|
49
|
+
assert!(
|
|
50
|
+
!rust.contains("tish_ios_launch"),
|
|
51
|
+
"the iOS staticlib entry belongs to EmbeddedLib, not RustLib:\n{rust}"
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#[test]
|
|
56
|
+
fn exports_are_published_out_of_run_so_module_state_outlives_it() {
|
|
57
|
+
let rust = compile_lib("tests/regression/rust_lib_emit.tish");
|
|
58
|
+
// Without this call the closures — and the `VmRef` cells they captured — drop with `run()`'s
|
|
59
|
+
// frame, and every `pub fn` would see a fresh module on each call.
|
|
60
|
+
assert!(
|
|
61
|
+
rust.contains("__tish_publish_exports(::std::vec!["),
|
|
62
|
+
"run() must hand its exported bindings to the module table:\n{rust}"
|
|
63
|
+
);
|
|
64
|
+
assert!(
|
|
65
|
+
rust.contains("(\"clearAll\", clearAll.clone())"),
|
|
66
|
+
"each export must be published under its exported name:\n{rust}"
|
|
67
|
+
);
|
|
68
|
+
// `Value` is `!Send` unless `send-values` (which follows `http`) is on, so a `static` would not
|
|
69
|
+
// compile for the common configuration.
|
|
70
|
+
assert!(
|
|
71
|
+
rust.contains("thread_local! {") && rust.contains("static __TISH_MODULE"),
|
|
72
|
+
"module storage must be thread-local:\n{rust}"
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#[test]
|
|
77
|
+
fn runtime_prelude_is_re_exported_so_consumers_cannot_version_skew_value() {
|
|
78
|
+
let rust = compile_lib("tests/regression/rust_lib_emit.tish");
|
|
79
|
+
assert!(
|
|
80
|
+
rust.contains("pub use tishlang_runtime::{console_debug"),
|
|
81
|
+
"RustLib must re-export the runtime prelude (so `Value` comes from THIS crate):\n{rust}"
|
|
82
|
+
);
|
|
83
|
+
assert!(
|
|
84
|
+
rust.contains("pub use tishlang_runtime as runtime;"),
|
|
85
|
+
"RustLib must re-export the runtime module:\n{rust}"
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#[test]
|
|
90
|
+
fn other_emit_modes_keep_the_prelude_private_and_gain_no_surface() {
|
|
91
|
+
let path = fixture("tests/regression/rust_lib_emit.tish");
|
|
92
|
+
let rust = compile_project_full(&path, path.parent(), &[], true)
|
|
93
|
+
.expect("desktop emit")
|
|
94
|
+
.0;
|
|
95
|
+
assert!(
|
|
96
|
+
!rust.contains("__TISH_MODULE") && !rust.contains("pub fn clearAll"),
|
|
97
|
+
"the library surface must be RustLib-only:\n{rust}"
|
|
98
|
+
);
|
|
99
|
+
assert!(
|
|
100
|
+
rust.contains("use tishlang_runtime::{console_debug")
|
|
101
|
+
&& !rust.contains("pub use tishlang_runtime::{console_debug"),
|
|
102
|
+
"a binary must keep the runtime prelude private:\n{rust}"
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
#[test]
|
|
107
|
+
fn a_value_export_is_rejected_rather_than_silently_returning_null() {
|
|
108
|
+
// A value export has no arity and its Rust local need not even be a `Value` (a `const` string
|
|
109
|
+
// lowers to `&str`), so it cannot be published like a closure. Fail loudly instead.
|
|
110
|
+
let dir = std::env::temp_dir().join("tish_rustlib_value_export");
|
|
111
|
+
std::fs::create_dir_all(&dir).unwrap();
|
|
112
|
+
let path = dir.join("index.tish");
|
|
113
|
+
std::fs::write(&path, "export let answer = 42\nexport fn get() { return answer }\n").unwrap();
|
|
114
|
+
let err = compile_project_full_emit(
|
|
115
|
+
&path,
|
|
116
|
+
Some(dir.as_path()),
|
|
117
|
+
&[],
|
|
118
|
+
true,
|
|
119
|
+
NativeEmitMode::RustLib,
|
|
120
|
+
None,
|
|
121
|
+
)
|
|
122
|
+
.expect_err("a value export must be rejected");
|
|
123
|
+
assert!(
|
|
124
|
+
err.message.contains("only `export fn`") && err.message.contains("answer"),
|
|
125
|
+
"the error should name the offending export: {}",
|
|
126
|
+
err.message
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#[test]
|
|
131
|
+
fn an_entry_module_with_no_exports_is_rejected() {
|
|
132
|
+
let dir = std::env::temp_dir().join("tish_rustlib_no_exports");
|
|
133
|
+
std::fs::create_dir_all(&dir).unwrap();
|
|
134
|
+
let path = dir.join("index.tish");
|
|
135
|
+
std::fs::write(&path, "fn unused() { return 1 }\n").unwrap();
|
|
136
|
+
let err = compile_project_full_emit(
|
|
137
|
+
&path,
|
|
138
|
+
Some(dir.as_path()),
|
|
139
|
+
&[],
|
|
140
|
+
true,
|
|
141
|
+
NativeEmitMode::RustLib,
|
|
142
|
+
None,
|
|
143
|
+
)
|
|
144
|
+
.expect_err("a crate with no public API must be rejected");
|
|
145
|
+
assert!(
|
|
146
|
+
err.message.contains("exports nothing"),
|
|
147
|
+
"unexpected error: {}",
|
|
148
|
+
err.message
|
|
149
|
+
);
|
|
150
|
+
}
|