localharness 0.33.0

A Rust-native agent SDK with pluggable LLM backends (Gemini today). Streaming, custom tools, safety policies, background triggers — zero external binaries.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
/// Token types (keywords, operators, literals).
pub mod token;
/// Byte-level lexer with string escapes.
pub mod lexer;
/// Full AST (structs, enums, functions, match, etc.).
pub mod ast;
/// Recursive-descent parser with precedence climbing.
pub mod parser;
/// Scope-based type resolution and mutability checking.
pub mod typecheck;
/// Wasm binary emitter (sections, opcodes, LEB128).
pub mod codegen;
/// Wasm32-only cartridge instantiation via `WebAssembly`.
pub mod loader;

/// Compile a Rust-subset source string into wasm bytes.
///
/// Pipeline: lex -> parse -> typecheck -> codegen.
pub fn compile(source: &str) -> Result<Vec<u8>, CompileError> {
    let tokens = lexer::lex(source)?;
    let module = parser::parse(&tokens)?;
    let typed = typecheck::check(&module)?;
    let wasm = codegen::emit(&typed)?;
    Ok(wasm)
}

/// An error produced during compilation (lex, parse, typecheck, or codegen).
#[derive(Debug, Clone)]
pub struct CompileError {
    /// Human-readable error description.
    pub message: String,
    /// Source location, if available.
    pub span: Option<Span>,
    /// Stable `LH0xxx` registry code (see [`crate::error_codes`]). `None` only
    /// for the rare uncoded internal error; `Display` prefixes the `LHxxxx:`
    /// label when present so every surfaced compile error carries its code.
    pub code: Option<u16>,
}

/// A byte-offset range in the source text.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Span {
    /// Start byte offset (inclusive).
    pub start: usize,
    /// End byte offset (exclusive).
    pub end: usize,
}

/// 1-based `(line, column)` of a byte offset in `source`.
///
/// The column counts CHARACTERS from the start of the line (so a caret row
/// of single-width spaces lines up). Offsets past the end of `source` clamp
/// to its last position; an offset inside a multi-byte char floors to that
/// char's start. Pure + native-testable — this is what turns the raw
/// `[start..end]` byte span every [`CompileError`] carries into something a
/// human (or an agent reading a tool result) can act on.
pub fn line_col(source: &str, offset: usize) -> (usize, usize) {
    let offset = offset.min(source.len());
    let mut line = 1usize;
    let mut col = 1usize;
    for (i, ch) in source.char_indices() {
        if i >= offset {
            break;
        }
        if ch == '\n' {
            line += 1;
            col = 1;
        } else {
            col += 1;
        }
    }
    (line, col)
}

/// Render a `line N, col M` + offending-source-line + caret-marker snippet
/// for `span`, e.g.:
///
/// ```text
/// line 2, col 11
///   let x = true + 1;
///           ^^^^^^^^
/// ```
///
/// The caret row underlines the span where it intersects its FIRST line
/// (multi-line spans clamp to that line; a zero-width or line-end span still
/// gets one `^` so there is always a visible marker). Tabs in the shown line
/// are widened to a single space so the caret row stays aligned. Returns
/// `None` only when `source` is empty (nothing to point into).
pub fn render_snippet(source: &str, span: Span) -> Option<String> {
    if source.is_empty() {
        return None;
    }
    let start = span.start.min(source.len());
    let (line, col) = line_col(source, start);
    // The full text of the line containing `start`.
    let line_start = source[..start].rfind('\n').map(|i| i + 1).unwrap_or(0);
    let line_end = source[line_start..]
        .find('\n')
        .map(|i| line_start + i)
        .unwrap_or(source.len());
    let line_text: String = source[line_start..line_end]
        .chars()
        .map(|c| if c == '\t' { ' ' } else { c })
        .collect();
    // Caret coverage: the span's char-width on this line, at least 1.
    let span_end = span.end.clamp(start, line_end.max(start));
    let width = source[start..span_end.min(source.len())].chars().count().max(1);
    let line_chars = line_text.chars().count();
    let pad = (col - 1).min(line_chars);
    let carets = width.min((line_chars + 1).saturating_sub(pad)).max(1);
    Some(format!(
        "line {line}, col {col}\n  {line_text}\n  {}{}",
        " ".repeat(pad),
        "^".repeat(carets)
    ))
}

impl std::fmt::Display for CompileError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // Prefix the stable `LHxxxx:` code when known, so a surfaced compile
        // error reads e.g. "LH0204: type mismatch: ... [12..18]".
        if let Some(code) = self.code {
            write!(f, "{}: ", crate::error_codes::fmt_label(code))?;
        }
        if let Some(span) = self.span {
            write!(f, "{} [{}..{}]", self.message, span.start, span.end)
        } else {
            write!(f, "{}", self.message)
        }
    }
}

impl std::error::Error for CompileError {}

impl CompileError {
    /// Create an error with no source span and no code.
    pub fn new(message: impl Into<String>) -> Self {
        Self { message: message.into(), span: None, code: None }
    }
    /// Create an error pinned to a source span (no code).
    pub fn at(message: impl Into<String>, span: Span) -> Self {
        Self { message: message.into(), span: Some(span), code: None }
    }
    /// Create a coded error pinned to a source span — the canonical
    /// constructor. `code` is an `LH0xxx` value from [`crate::error_codes`].
    pub fn at_code(code: u16, message: impl Into<String>, span: Span) -> Self {
        Self { message: message.into(), span: Some(span), code: Some(code) }
    }
    /// Create a coded error with no source span.
    pub fn new_code(code: u16, message: impl Into<String>) -> Self {
        Self { message: message.into(), span: None, code: Some(code) }
    }
    /// Attach (or replace) the stable code on an existing error.
    pub fn with_code(mut self, code: u16) -> Self {
        self.code = Some(code);
        self
    }

    /// `"line N, col M"` of this error's span in `source`, when it has one.
    /// The human-readable counterpart of the raw `[start..end]` byte span.
    pub fn location(&self, source: &str) -> Option<String> {
        let span = self.span?;
        let (line, col) = line_col(source, span.start.min(source.len()));
        Some(format!("line {line}, col {col}"))
    }

    /// The full agent/user-facing rendering: the `Display` form (LHxxxx code +
    /// message + byte span) plus, when the error carries a span, a
    /// line/column locator with the offending source line and a caret marker
    /// underneath. Every surface that has the source at hand (tool results,
    /// the CLI compile-check, the studio publish flow) should prefer this
    /// over bare `to_string()` — a byte offset alone makes the agent hunt.
    pub fn render(&self, source: &str) -> String {
        match self.span.and_then(|s| render_snippet(source, s)) {
            Some(snippet) => format!("{self}\n{snippet}"),
            None => self.to_string(),
        }
    }
}

impl From<String> for CompileError {
    fn from(s: String) -> Self { Self::new(s) }
}

#[cfg(test)]
mod tests {
    use super::{compile, lexer, parser, typecheck};
    use crate::error_codes as codes;

    /// Drive the full pipeline and return the `CompileError` (so the test can
    /// inspect its `.code`). Each snippet below is crafted to fail at exactly
    /// one stage.
    fn compile_err(src: &str) -> super::CompileError {
        lexer::lex(src)
            .and_then(|toks| parser::parse(&toks))
            .and_then(|m| typecheck::check(&m))
            .and_then(|t| super::codegen::emit(&t))
            .expect_err("expected a compile error")
    }

    #[test]
    fn compile_errors_carry_their_lh0xxx_code() {
        // A representative bad snippet per stage → its expected LH0xxx code.
        // type mismatch (typecheck): bool + i32.
        let e = compile_err("fn frame(t: i32) { let x = true + 1; host::display::present(); }");
        assert_eq!(e.code, Some(codes::TYPE_MISMATCH), "{e}");
        assert!(e.to_string().starts_with("LH0204:"), "surfaced: {e}");

        // undefined variable (typecheck).
        let e = compile_err("fn frame(t: i32) { host::display::clear(NOPE); host::display::present(); }");
        assert_eq!(e.code, Some(codes::UNDEFINED_VARIABLE), "{e}");

        // unexpected token (parser): missing the fn name.
        let e = compile_err("fn (t: i32) {}");
        assert_eq!(e.code, Some(codes::UNEXPECTED_TOKEN), "{e}");

        // invalid assignment target (parser): can't assign to a literal.
        // (Indexed array writes `a[0] = 9` ARE now supported — see
        // `arrays_literal_and_index`; this exercises the remaining reject path.)
        let e = compile_err("fn frame(t: i32) { 5 = 9; host::display::present(); }");
        assert_eq!(e.code, Some(codes::INVALID_ASSIGN_TARGET), "{e}");

        // unknown function (codegen): a host fn path that doesn't resolve —
        // an unknown `host::` name falls through to "undefined function" rather
        // than the registered-but-missing host-import path (LH0301).
        let e = compile_err("fn frame(t: i32) { host::display::nope(1); host::display::present(); }");
        assert_eq!(e.code, Some(codes::UNKNOWN_FUNCTION), "{e}");

        // unexpected byte (lexer).
        let e = compile_err("fn frame(t: i32) { let x = `; }");
        assert_eq!(e.code, Some(codes::UNEXPECTED_BYTE), "{e}");

        // bad cast (typecheck): bool as i32.
        let e = compile_err("fn frame(t: i32) { let x = true as i32; host::display::clear(x); host::display::present(); }");
        assert_eq!(e.code, Some(codes::BAD_CAST), "{e}");

        // Every surfaced compile error string is LHxxxx-prefixed.
        assert!(e.to_string().starts_with("LH0"), "surfaced: {e}");
    }

    #[test]
    fn line_col_is_one_based_and_clamped() {
        let src = "ab\ncde\nf";
        assert_eq!(super::line_col(src, 0), (1, 1));
        assert_eq!(super::line_col(src, 1), (1, 2));
        assert_eq!(super::line_col(src, 3), (2, 1)); // first char after \n
        assert_eq!(super::line_col(src, 5), (2, 3));
        assert_eq!(super::line_col(src, 7), (3, 1));
        // past-the-end clamps to the final position instead of panicking
        assert_eq!(super::line_col(src, 999), (3, 2));
        assert_eq!(super::line_col("", 0), (1, 1));
    }

    #[test]
    fn render_snippet_places_the_caret_under_the_span() {
        let src = "fn frame(t: i32) {\n  let x = true + 1;\n}";
        // span covering `true + 1` on line 2 (col 11)
        let start = src.find("true").unwrap();
        let snip = super::render_snippet(src, super::Span { start, end: start + 8 }).unwrap();
        let lines: Vec<&str> = snip.lines().collect();
        assert_eq!(lines[0], "line 2, col 11", "{snip}");
        // the shown line keeps its own leading whitespace under a 2-space indent
        assert_eq!(lines[1], "    let x = true + 1;", "{snip}");
        assert_eq!(lines[2], format!("  {}{}", " ".repeat(10), "^".repeat(8)), "{snip}");
    }

    #[test]
    fn render_snippet_edge_cases_never_panic() {
        // zero-width span still draws one caret
        let snip = super::render_snippet("let x;", super::Span { start: 4, end: 4 }).unwrap();
        assert!(snip.ends_with("^"), "{snip}");
        // a multi-line span clamps its carets to the FIRST line
        let src = "a\nbb\ncc";
        let snip = super::render_snippet(src, super::Span { start: 2, end: 7 }).unwrap();
        assert!(snip.contains("line 2, col 1"), "{snip}");
        assert_eq!(snip.lines().last().unwrap().matches('^').count(), 2, "{snip}");
        // span at EOF (the unterminated-string / unexpected-EOF shape)
        let src = "fn f() {";
        let snip = super::render_snippet(src, super::Span { start: 8, end: 8 }).unwrap();
        assert!(snip.contains("line 1, col 9"), "{snip}");
        // out-of-range span clamps
        assert!(super::render_snippet("x", super::Span { start: 50, end: 60 }).is_some());
        // empty source yields no snippet (nothing to point into)
        assert!(super::render_snippet("", super::Span { start: 0, end: 1 }).is_none());
        // tabs are widened to spaces so the caret row aligns
        let snip = super::render_snippet("\tlet q = ;", super::Span { start: 9, end: 10 }).unwrap();
        assert!(!snip.contains('\t'), "{snip}");
    }

    #[test]
    fn compile_error_render_carries_code_location_and_caret() {
        // Full pipeline: a type mismatch on line 2 renders the LH code, the
        // line/col locator, the offending source line, and a caret row.
        let src = "fn frame(t: i32) {\n  let x = true + 1;\n  host::display::present();\n}";
        let err = compile(src).expect_err("type mismatch must fail");
        let rendered = err.render(src);
        assert!(rendered.starts_with("LH0204:"), "{rendered}");
        assert!(rendered.contains("line 2, col"), "{rendered}");
        assert!(rendered.contains("let x = true + 1;"), "{rendered}");
        assert!(rendered.lines().last().unwrap().trim_start().starts_with('^'), "{rendered}");
        // The exact column depends on which subexpression the checker pins;
        // what matters is that the locator names LINE 2 (where the bug is).
        assert!(err.location(src).expect("typed errors carry a span").starts_with("line 2, col "));
        // A span-less error renders as its plain Display form.
        let plain = super::CompileError::new("internal");
        assert_eq!(plain.render(src), "internal");
        assert_eq!(plain.location(src), None);
    }

    #[test]
    fn const_resolves_and_is_order_independent() {
        // const used in a fn declared BEFORE the const — resolution must not
        // depend on source order.
        assert!(compile(
            "fn frame(t: i32) { host::display::clear(W); host::display::present(); } const W: i32 = 256;"
        )
        .is_ok());
        // a const referencing an earlier const
        assert!(compile(
            "const A: i32 = 2; const B: i32 = A * 3; fn frame(t: i32) { host::display::clear(B); host::display::present(); }"
        )
        .is_ok());
        // a genuinely undefined name still errors
        assert!(compile(
            "fn frame(t: i32) { host::display::clear(NOPE); host::display::present(); }"
        )
        .is_err());
    }

    #[test]
    fn casts_between_numbers() {
        // i32 → f64 → i32 round-trip + a float literal truncated to i32 (the
        // common graphics pattern: float math, then cast to a pixel coord).
        assert!(compile(
            "fn frame(t: i32) { let x = t as f64; let y = x as i32; host::display::clear(y + (3.7 as i32)); host::display::present(); }"
        )
        .is_ok());
    }

    #[test]
    fn arrays_literal_and_index() {
        // array literal + variable-index read (the lookup-table pattern)
        assert!(compile(
            "fn frame(t: i32) { let pal = [16711680, 65280, 255]; host::display::clear(pal[t % 3]); host::display::present(); }"
        )
        .is_ok());
        // indexing a non-array is a clear error
        assert!(compile(
            "fn frame(t: i32) { let x = 5; host::display::clear(x[0]); host::display::present(); }"
        )
        .is_err());
        // INDEXED WRITES `arr[i] = v` now compile (the stateful-grid primitive):
        // write then read back the SAME element in one frame.
        assert!(compile(
            "fn frame(t: i32) { let mut a = [1, 2, 3]; a[0] = 9; host::display::clear(a[0]); host::display::present(); }"
        )
        .is_ok());
        // a variable index on the write side, mutating from a host value
        assert!(compile(
            "fn frame(t: i32) { let mut a = [0, 0, 0, 0]; a[t % 4] = t; host::display::clear(a[t % 4]); host::display::present(); }"
        )
        .is_ok());
        // writing the wrong element type is still a type error (i32 elems only)
        assert!(compile(
            "fn frame(t: i32) { let mut a = [1, 2, 3]; a[0] = true; host::display::present(); }"
        )
        .is_err());
        // writing into a non-mut array binding is rejected (mutability holds)
        assert!(compile(
            "fn frame(t: i32) { let a = [1, 2, 3]; a[0] = 9; host::display::present(); }"
        )
        .is_err());
        // indexing a non-array on the write side is a clear error
        assert!(compile(
            "fn frame(t: i32) { let mut x = 5; x[0] = 9; host::display::present(); }"
        )
        .is_err());
    }

    #[test]
    fn array_params_and_repeat_init() {
        // `[T; N]` is now a TYPE — an array can be a fn PARAMETER (passed as its
        // i32 base pointer). A helper reads through the array param.
        assert!(compile(
            "fn sum3(a: [i32; 3]) -> i32 { a[0] + a[1] + a[2] } \
             fn frame(t: i32) { let g = [10, 20, 30]; host::display::clear(sum3(g)); host::display::present(); }"
        )
        .is_ok());
        // An array param can be MUTATED in the callee (shared backing — the
        // base pointer aliases the caller's region, C-style).
        assert!(compile(
            "fn set0(a: [i32; 3], v: i32) { a[0] = v; } \
             fn frame(t: i32) { let mut g = [0, 0, 0]; set0(g, 7); host::display::clear(g[0]); host::display::present(); }"
        )
        .is_ok());
        // `[v; N]` sized repeat init typechecks + the result is indexable.
        assert!(compile(
            "fn frame(t: i32) { let mut g = [0; 64]; g[5] = 9; host::display::clear(g[5]); host::display::present(); }"
        )
        .is_ok());
        // The repeat value need not be a literal (any i32 expr).
        assert!(compile(
            "fn frame(t: i32) { let g = [t * 2; 8]; host::display::clear(g[3]); host::display::present(); }"
        )
        .is_ok());
        // `[v; 0]` is rejected (empty arrays unsupported, same as `[]`).
        assert!(compile(
            "fn frame(t: i32) { let g = [0; 0]; host::display::present(); }"
        )
        .is_err());
        // Non-i32 array param element type is rejected (i32-only, v1).
        assert!(compile(
            "fn f(a: [bool; 2]) {} fn frame(t: i32) { host::display::present(); }"
        )
        .is_err());
        // Array repeat with a non-i32 value is rejected.
        assert!(compile(
            "fn frame(t: i32) { let g = [true; 4]; host::display::present(); }"
        )
        .is_err());
    }

    #[test]
    fn array_return_type_is_rejected() {
        // RETURNING an array is unsound under the static-region model: the region
        // a returned array points into is reused on every call, so two live
        // results of one array-returning fn would alias and the second call
        // silently clobbers the first (proven via node:
        //   fn mk(v:i32)->[i32;3]{[v,v,v]}  let a=mk(1); let b=mk(2);
        // made `a[0]` read back as 2, not 1). The compiler must reject it rather
        // than emit corrupting code — the supported pattern is a mutable array
        // PARAM the callee fills in place (C-style shared backing).
        let e = compile("fn mk(v: i32) -> [i32; 3] { [v, v, v] } fn frame(t: i32) { let a = mk(1); host::display::clear(a[0]); host::display::present(); }")
            .expect_err("array return must be rejected");
        assert_eq!(e.code, Some(codes::UNSUPPORTED_FEATURE), "{e}");
        // Forward reference (fn declared after frame) is rejected too — the guard
        // lives in the signature-resolution pass, which runs before any body.
        assert!(compile(
            "fn frame(t: i32) { host::display::present(); } fn mk() -> [i32; 2] { [1, 2] }"
        )
        .is_err());
        // An array PARAM with an i32 return is still fine (the real pattern).
        assert!(compile(
            "fn fill(a: [i32; 3], v: i32) -> i32 { a[0] = v; a[0] } \
             fn frame(t: i32) { let mut g = [0, 0, 0]; host::display::clear(fill(g, 9)); host::display::present(); }"
        )
        .is_ok());
    }
}

/// Emit the indexed-array-write cartridges that the node run-proof
/// (`scripts/verify-array-write.mjs`) instantiates + runs. This is the bridge
/// the task asks for: "use a Rust test that compiles + writes the bytes for
/// node to load." Each cartridge ends its `frame` by `clear()`-ing with a value
/// it READ BACK out of an array it just WROTE; node asserts the value matches.
///
/// Run `cargo test emits_wasm_for_node_proof`, then `node
/// scripts/verify-array-write.mjs`. Native-only (writes to the source tree).
#[cfg(all(test, feature = "native"))]
mod array_write_run_proof {
    use super::compile;

    #[test]
    fn emits_wasm_for_node_proof() {
        let out_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("scripts")
            .join(".array-write-proof");
        std::fs::create_dir_all(&out_dir).expect("create proof dir");

        let cases: &[(&str, &str)] = &[
            // 1) single write then read back the SAME element
            (
                "single.wasm",
                "fn frame(t: i32) { let mut a = [0, 0, 0, 0]; a[2] = 42; host::display::clear(a[2]); host::display::present(); }",
            ),
            // 2) loop-fill a[i] = i*10, read a fixed cell (a[3] => 30)
            (
                "loopfill.wasm",
                "fn frame(t: i32) { let mut a = [0, 0, 0, 0, 0]; for i in 0..5 { a[i] = i * 10; } host::display::clear(a[3]); host::display::present(); }",
            ),
            // 2b) same loop-fill, read a[t] so node can pick the cell at runtime
            (
                "loopfill_t.wasm",
                "fn frame(t: i32) { let mut a = [0, 0, 0, 0, 0]; for i in 0..5 { a[i] = i * 10; } host::display::clear(a[t]); host::display::present(); }",
            ),
            // 3) overwrite the same cell twice — later write wins
            (
                "overwrite.wasm",
                "fn frame(t: i32) { let mut a = [0, 0]; a[0] = 7; a[0] = 99; host::display::clear(a[0]); host::display::present(); }",
            ),
            // 4) ARRAY PARAM — read through it in a helper. sum([3,4,5]) = 12.
            //    Proves an array typed `[i32; N]` lowers to its base pointer and
            //    the callee indexes it correctly.
            (
                "param_read.wasm",
                "fn sum(a: [i32; 3]) -> i32 { a[0] + a[1] + a[2] } \
                 fn frame(t: i32) { let g = [3, 4, 5]; host::display::clear(sum(g)); host::display::present(); }",
            ),
            // 5) ARRAY PARAM — SHARED BACKING. A write IN THE CALLEE through the
            //    array param is visible to the CALLER (the pointer aliases the
            //    same static region, C-style). set(g, 77); read g[1] => 77.
            (
                "param_shared_write.wasm",
                "fn set1(a: [i32; 3], v: i32) { a[1] = v; } \
                 fn frame(t: i32) { let mut g = [0, 0, 0]; set1(g, 77); host::display::clear(g[1]); host::display::present(); }",
            ),
            // 6) `[v; N]` SIZED REPEAT INIT — every slot is filled with v.
            //    let g = [9; 16]; read g[7] => 9 (a slot the literal didn't
            //    special-case), proving the fill loop covers the whole region.
            (
                "repeat_fill.wasm",
                "fn frame(t: i32) { let g = [9; 16]; host::display::clear(g[7]); host::display::present(); }",
            ),
            // 7) `[v; N]` then WRITE one cell — the rest stay at the fill value.
            //    let mut g = [5; 8]; g[2] = 88; read g[t] (t picks the cell).
            (
                "repeat_then_write.wasm",
                "fn frame(t: i32) { let mut g = [5; 8]; g[2] = 88; host::display::clear(g[t]); host::display::present(); }",
            ),
        ];

        for (file, src) in cases {
            let wasm = compile(src).unwrap_or_else(|e| panic!("compile {file}: {e}"));
            std::fs::write(out_dir.join(file), &wasm).unwrap_or_else(|e| panic!("write {file}: {e}"));
        }
    }
}