qala-compiler 0.1.1

Compiler and bytecode VM for the Qala programming language
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
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
//! the ARM64 backend: a typed AST lowered to AArch64 assembly text in the
//! CPSC 355 hosted-Linux dialect.
//!
//! the structural twin of [`crate::codegen`]: it consumes the same
//! [`TypedAst`] the bytecode codegen consumes and produces a `String` of
//! assembly instead of a [`Program`](crate::chunk::Program). the entry point
//! [`compile_arm64`] parallels [`crate::codegen::compile_program`].
//!
//! ## the integer core
//!
//! this module emits, for `i64` and `bool`: arithmetic (`+ - * / %`), the six
//! comparisons, the short-circuit `&&` / `||`, the unary `!` and `-`,
//! parameter loads, `let` bindings in stack slots, control flow (`if` /
//! `while` / `for` over a range), and a function's prologue / body / epilogue
//! following AAPCS64. calls arrive in plan 12-03. every construct outside the
//! integer core -- a float, a string, a struct literal, a method, a
//! `>8`-parameter function, a `defer` -- returns a [`QalaError`], never a
//! panic, never a `todo!()`.
//!
//! ## the dialect
//!
//! the emitted text matches the course tutorial files under `docs/references/`:
//! lowercase mnemonics and directives, the m4 `define(fp, x29)` /
//! `define(lr, x30)` preamble, a `.text` section, per-function `.balign 4` /
//! `.global` / `.type ..., @function` directives, and the
//! `stp fp, lr, [sp, alloc]!` / `ldp fp, lr, [sp], dealloc` prologue and
//! epilogue. an `i64` is a 64-bit `x` register and an 8-byte stack slot
//! throughout -- never a 32-bit `w` register.
//!
//! ## module layout
//!
//! `emit.rs` builds the assembly text; `frame.rs` plans each function's stack
//! frame; `labels.rs` generates unique branch labels; `expr.rs`, `stmt.rs`,
//! and `print.rs` are further `impl Arm64Backend` blocks holding the
//! expression codegen, the statement / control-flow codegen, and the
//! `print` / `println` to `printf` lowering.

mod emit;
mod expr;
mod frame;
mod labels;
mod print;
mod stmt;

use crate::errors::QalaError;
use crate::span::LineIndex;
use crate::typed_ast::{TypedAst, TypedFnDecl, TypedItem};
use crate::types::QalaType;
use std::collections::HashSet;

use emit::Asm;
use frame::FrameLayout;
use labels::LabelGen;
use stmt::LoopLabels;

/// lower a typed program to AArch64 assembly text in the CPSC 355 dialect.
///
/// errors accumulate rather than fail-fast: an unsupported construct in one
/// function does not stop the others from compiling. on success returns the
/// full assembly text; on any errors returns the errors sorted by span -- the
/// same `(span.start, span.len)` ordering contract as
/// [`crate::codegen::compile_program`].
///
/// the entry point the `qala build --target arm64` CLI seam (plan 12-03)
/// calls. `src` is the original source, kept for span-to-line lookups when
/// rendering an error.
pub fn compile_arm64(ast: &TypedAst, src: &str) -> Result<String, Vec<QalaError>> {
    let mut backend = Arm64Backend::new(src);
    backend.scan_fn_names(ast);
    for item in ast {
        if let Err(e) = backend.compile_item(item) {
            backend.errors.push(e);
        }
    }
    if !backend.errors.is_empty() {
        backend
            .errors
            .sort_by_key(|e| (e.span().start, e.span().len));
        return Err(backend.errors);
    }
    Ok(backend.finish())
}

/// the ARM64 backend's walk state -- the analog of
/// [`crate::codegen`]'s `Codegen` struct.
///
/// carries the assembly-text builder, the per-function frame layout, the label
/// generator, the set of user function names (pre-scanned so a forward call
/// resolves), the accumulated errors, and the source plus its line index.
struct Arm64Backend {
    /// the assembly-text builder.
    asm: Asm,
    /// the unique-label generator -- monotonic across the whole compile, so
    /// every branch label in the file is distinct and emission is deterministic.
    labels: LabelGen,
    /// the stack-frame layout of the function currently being compiled, or
    /// `None` between functions.
    frame: Option<FrameLayout>,
    /// the name of the function currently being compiled, or `None` between
    /// functions. a `return` branches to `.L<current_fn>_epilogue`.
    current_fn: Option<String>,
    /// the binding-scope stack: one frame per open block, each a list of
    /// `(name, slot)` pairs for the `let` / `for` bindings declared in that
    /// block. `compile_block` pushes a frame on entry and pops it on exit;
    /// `resolve_name` searches it top-down so a shadowing binding wins. empty
    /// between functions.
    scopes: Vec<Vec<(String, i64)>>,
    /// the active-loop stack: one [`LoopLabels`] record per enclosing `while`
    /// or `for`. `break` / `continue` read the innermost record. empty outside
    /// any loop.
    loops: Vec<LoopLabels>,
    /// the body binding-occurrence cursor. `plan_frame` assigned binding slots
    /// in a fixed walk order; this counts how many `let` / `for` bindings the
    /// statement walk has consumed, so the next one reads the right slot.
    /// reset to 0 at the start of each function.
    binding_cursor: usize,
    /// every user-declared function name, pre-scanned before the compile walk.
    /// plan 12-03 consults this to resolve a call target; this plan records it
    /// for that forward reference.
    fn_names: HashSet<String>,
    /// the errors accumulated across every function.
    errors: Vec<QalaError>,
    /// the original source text. plan 12-02 / 12-03 read it when an error
    /// diagnostic needs the offending source line; this plan only stores it.
    #[allow(dead_code)]
    src: String,
    /// the byte-offset-to-line index. plan 12-02 / 12-03 consult it for
    /// span-to-line lookups in richer diagnostics; this plan only stores it.
    #[allow(dead_code)]
    line_index: LineIndex,
}

impl Arm64Backend {
    /// construct a fresh backend over `src`: an empty assembly builder, no
    /// current function, no errors.
    fn new(src: &str) -> Self {
        Arm64Backend {
            asm: Asm::new(),
            labels: LabelGen::new(),
            frame: None,
            current_fn: None,
            scopes: Vec::new(),
            loops: Vec::new(),
            binding_cursor: 0,
            fn_names: HashSet::new(),
            errors: Vec::new(),
            src: src.to_string(),
            line_index: LineIndex::new(src),
        }
    }

    /// pre-scan every user function's name into `fn_names`, so a call to a
    /// function declared later in the file still resolves.
    ///
    /// the analog of `Codegen::build_tables`'s function pre-registration.
    fn scan_fn_names(&mut self, ast: &TypedAst) {
        for item in ast {
            if let TypedItem::Fn(decl) = item {
                self.fn_names.insert(decl.name.clone());
            }
        }
    }

    /// the frame layout of the function currently being compiled.
    ///
    /// panics *unconditionally* -- not via a `debug_assert!` -- if called
    /// between functions. that branch is unreachable on every live path: the
    /// per-function flow in [`compile_fn`](Arm64Backend::compile_fn) sets the
    /// frame before any body walk and clears it after, and every caller runs
    /// inside that window. the `.expect` is an invariant guard, the same
    /// category as an `unreachable!` carrying a proof.
    fn frame(&self) -> &FrameLayout {
        // invariant: set by compile_fn before any body walk, cleared after.
        // unreachable with frame == None on every live path.
        self.frame
            .as_ref()
            .expect("arm64: frame accessed with no function in progress")
    }

    /// the frame layout of the current function, mutably -- for the expression
    /// codegen's scratch-slot claim / release.
    ///
    /// panics unconditionally if called between functions -- the same
    /// unreachable invariant guard as [`frame`](Arm64Backend::frame).
    fn frame_mut(&mut self) -> &mut FrameLayout {
        // invariant: set by compile_fn before any body walk, cleared after.
        // unreachable with frame == None on every live path.
        self.frame
            .as_mut()
            .expect("arm64: frame accessed with no function in progress")
    }

    /// compile one top-level item.
    ///
    /// a `Fn` item emits a complete function; `Struct` / `Enum` / `Interface`
    /// items are type-level and emit nothing -- exactly as
    /// [`crate::codegen`]'s `compile_item` treats them.
    fn compile_item(&mut self, item: &TypedItem) -> Result<(), QalaError> {
        match item {
            TypedItem::Fn(decl) => self.compile_fn(decl),
            // type-level items: nothing to emit.
            TypedItem::Struct(_) | TypedItem::Enum(_) | TypedItem::Interface(_) => Ok(()),
        }
    }

    /// compile one function: validate it, plan its frame, emit the directives,
    /// the prologue, the parameter spills, the body, and the epilogue.
    ///
    /// rejects, with a clean [`QalaError`], anything the integer core does not
    /// cover: a method (`type_name` set), a non-`i64`/`bool` parameter, a
    /// non-`i64`/`bool`/`void` return type, a parameter with a default, and a
    /// function with more than eight parameters.
    fn compile_fn(&mut self, decl: &TypedFnDecl) -> Result<(), QalaError> {
        self.validate_fn(decl)?;

        // plan the frame; it stays the current frame for the whole body walk.
        // `fn_names` lets the spill pre-walk size a shadowed `print` / `println`
        // call as the ordinary user call the emitter will route it as.
        self.frame = Some(FrameLayout::plan_frame(decl, &self.fn_names));
        self.current_fn = Some(decl.name.clone());
        // reset the per-function statement-walk state: the binding cursor
        // counts from 0 for each function, and the scope / loop stacks start
        // empty (a previous function always balances them, but reset to be
        // robust against an error-path early return).
        self.binding_cursor = 0;
        self.scopes.clear();
        self.loops.clear();

        // a blank separator line before the function, for readability.
        self.asm.emit_line("");
        // the per-function directives: 4-byte align, export, mark as a
        // function, then the global label.
        self.asm.emit_insn(".balign 4");
        self.asm.emit_insn(&format!(".global {}", decl.name));
        self.asm
            .emit_insn(&format!(".type   {}, @function", decl.name));
        self.asm.emit_label(&decl.name);

        // the AAPCS64 prologue.
        for insn in self.frame().prologue() {
            self.asm.emit_insn(&insn);
        }

        // spill the incoming parameters from x0..x7 into their stack slots, so
        // a later Ident load reads a stable slot rather than a clobbered reg.
        for (i, param) in decl.params.iter().enumerate() {
            let slot = self
                .frame()
                .slot_of(&param.name)
                .ok_or_else(|| QalaError::Type {
                    span: param.span,
                    message: format!("arm64 backend: parameter `{}` has no slot", param.name),
                })?;
            self.asm
                .emit_insn_commented(&format!("str     x{i}, [fp, {slot}]"), &param.name);
        }

        // walk the body. its trailing value, if any, lands in x0.
        self.compile_block(&decl.body)?;

        // the single epilogue block; a `return` branches here.
        let epilogue_label = self.epilogue_label(&decl.name);
        self.asm.emit_label(&epilogue_label);
        // a void function (and main) returns exit code 0; an i64/bool function
        // leaves its result in x0 (the body's trailing value already did).
        if decl.ret_ty == QalaType::Void {
            self.asm
                .emit_insn_commented("mov     x0, 0", "void return -> exit code 0");
        }
        for insn in self.frame().epilogue() {
            self.asm.emit_insn(&insn);
        }

        // the function is done; clear the per-function state.
        self.frame = None;
        self.current_fn = None;
        Ok(())
    }

    /// reject a function the integer core does not support, with a clean
    /// [`QalaError::Type`] carrying the offending span.
    fn validate_fn(&self, decl: &TypedFnDecl) -> Result<(), QalaError> {
        // a method (`fn Type.method`) -- methods are tied to structs, deferred.
        if decl.type_name.is_some() {
            return Err(QalaError::Type {
                span: decl.span,
                message: "the arm64 backend does not yet support methods".to_string(),
            });
        }
        // more than eight parameters spill onto the stack -- out of scope.
        if decl.params.len() > 8 {
            return Err(QalaError::Type {
                span: decl.span,
                message: "the arm64 backend supports at most 8 parameters".to_string(),
            });
        }
        for param in &decl.params {
            // a default value is an unsupported construct.
            if param.default.is_some() {
                return Err(QalaError::Type {
                    span: param.span,
                    message: "the arm64 backend does not yet support default parameters"
                        .to_string(),
                });
            }
            // only i64 and bool parameters are in the integer core.
            if !is_integer_core_type(&param.ty) {
                return Err(QalaError::Type {
                    span: param.span,
                    message: format!(
                        "the arm64 backend does not yet support {} parameters",
                        type_name(&param.ty)
                    ),
                });
            }
        }
        // the return type must be i64, bool, or void.
        if decl.ret_ty != QalaType::Void && !is_integer_core_type(&decl.ret_ty) {
            return Err(QalaError::Type {
                span: decl.span,
                message: format!(
                    "the arm64 backend does not yet support a {} return type",
                    type_name(&decl.ret_ty)
                ),
            });
        }
        Ok(())
    }

    /// the local epilogue label for a named function: `.L<name>_epilogue`.
    ///
    /// `compile_fn` emits the label; `stmt.rs`'s `Return` arm branches to it.
    fn epilogue_label(&self, fn_name: &str) -> String {
        format!(".L{fn_name}_epilogue")
    }

    /// finish the compile: produce the complete assembly text.
    fn finish(self) -> String {
        self.asm.finish()
    }
}

/// whether a type is in the ARM64 integer core: `i64` or `bool`.
fn is_integer_core_type(ty: &QalaType) -> bool {
    matches!(ty, QalaType::I64 | QalaType::Bool)
}

/// a plain human name for a type, for an unsupported-construct message.
///
/// crate-visible so the expression codegen (`expr.rs`) can name the type of a
/// rejected `print` / `println` argument, the same way `validate_fn` names a
/// rejected parameter or return type here.
pub(crate) fn type_name(ty: &QalaType) -> &'static str {
    match ty {
        QalaType::I64 => "i64",
        QalaType::F64 => "f64",
        QalaType::Bool => "bool",
        QalaType::Str => "str",
        QalaType::Byte => "byte",
        QalaType::Void => "void",
        QalaType::Array(..) => "array",
        QalaType::Tuple(_) => "tuple",
        QalaType::Function { .. } => "function-typed",
        QalaType::Named(_) => "struct or enum",
        QalaType::Result(..) => "Result",
        QalaType::Option(_) => "Option",
        QalaType::FileHandle => "file-handle",
        QalaType::Unknown => "unresolved",
    }
}

#[cfg(test)]
impl Arm64Backend {
    /// plan a function's frame and set it as current, without emitting any
    /// directives or prologue -- a test hook so the expression codegen can be
    /// exercised in isolation against a real frame.
    ///
    /// the frame is planned against the backend's current `fn_names`, so a
    /// test that scans names first sees the same shadowing context the full
    /// compile does.
    fn begin_function(&mut self, decl: &TypedFnDecl) {
        self.frame = Some(FrameLayout::plan_frame(decl, &self.fn_names));
        self.current_fn = Some(decl.name.clone());
    }

    /// the assembly text emitted so far -- a test hook for inspecting partial
    /// output without going through `finish`.
    fn take_text(&mut self) -> String {
        std::mem::replace(&mut self.asm, Asm::new()).finish()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::lexer::Lexer;
    use crate::parser::Parser;
    use crate::typechecker::check_program;

    /// lex, parse, and typecheck `src` into a typed AST, asserting no errors.
    fn typecheck(src: &str) -> TypedAst {
        let tokens = Lexer::tokenize(src).expect("lex failed");
        let ast = Parser::parse(&tokens).expect("parse failed");
        let (typed, terrors, _) = check_program(&ast, src);
        assert!(terrors.is_empty(), "typecheck errors: {terrors:?}");
        typed
    }

    /// compile `src` to assembly, panicking on any backend error.
    fn compile_ok(src: &str) -> String {
        let typed = typecheck(src);
        compile_arm64(&typed, src).unwrap_or_else(|e| panic!("arm64 errors: {e:?}"))
    }

    /// read a snapshot fixture, normalising CRLF to LF. a checked-out file on
    /// Windows has CRLF line endings; the emitter always produces LF, so the
    /// fixture's carriage returns are stripped for a platform-stable compare.
    fn read_snapshot(name: &str) -> String {
        let path = format!("{}/tests/snapshots/{name}", env!("CARGO_MANIFEST_DIR"));
        std::fs::read_to_string(&path)
            .unwrap_or_else(|e| panic!("read {path}: {e}"))
            .replace("\r\n", "\n")
    }

    #[test]
    fn an_empty_program_emits_just_the_preamble() {
        // no functions: the output is the m4 preamble and the .text header.
        let out = compile_arm64(&Vec::new(), "").expect("empty program compiles");
        assert!(out.starts_with("define(fp, x29)\ndefine(lr, x30)\n"));
        assert!(out.contains(".text"));
    }

    #[test]
    fn a_function_emits_its_directives_and_prologue() {
        let out = compile_ok("fn main() { }");
        assert!(out.contains(".balign 4"), "{out}");
        assert!(out.contains(".global main"), "{out}");
        assert!(out.contains(".type   main, @function"), "{out}");
        assert!(out.contains("\nmain:\n"), "{out}");
        assert!(
            out.contains("stp     fp, lr, [sp, "),
            "missing prologue: {out}"
        );
        assert!(out.contains("mov     fp, sp"), "{out}");
    }

    #[test]
    fn a_function_emits_one_epilogue_block() {
        let out = compile_ok("fn main() { }");
        assert!(out.contains(".Lmain_epilogue:"), "{out}");
        assert!(
            out.contains("ldp     fp, lr, [sp], "),
            "missing epilogue: {out}"
        );
        assert!(out.contains("\n        ret\n"), "{out}");
    }

    #[test]
    fn a_void_function_zeroes_x0_before_the_epilogue() {
        // a void main exits 0; the backend movs 0 into x0 before returning.
        let out = compile_ok("fn main() { }");
        assert!(out.contains("mov     x0, 0"), "{out}");
    }

    #[test]
    fn parameters_are_spilled_into_their_stack_slots() {
        // the two parameters spill to [fp, 16] and [fp, 24] -- positive
        // offsets above the saved fp/lr pair.
        let out = compile_ok("fn add(a: i64, b: i64) -> i64 { a + b }");
        assert!(out.contains("str     x0, [fp, 16]  // a"), "{out}");
        assert!(out.contains("str     x1, [fp, 24]  // b"), "{out}");
    }

    #[test]
    fn a_returning_function_leaves_its_value_in_x0() {
        // an i64 function: the trailing value already put the result in x0, so
        // there is no `mov x0, 0` before the epilogue.
        let out = compile_ok("fn seven() -> i64 { 7 }");
        assert!(out.contains("mov     x0, 7"), "{out}");
        assert!(
            !out.contains("mov     x0, 0"),
            "an i64 function must not zero x0: {out}"
        );
    }

    #[test]
    fn an_explicit_return_branches_to_the_epilogue() {
        let out = compile_ok("fn f() -> i64 { return 3 }");
        assert!(out.contains("b       .Lf_epilogue"), "{out}");
    }

    #[test]
    fn structs_and_enums_emit_nothing() {
        // a struct / enum declaration is type-level; only `main` produces text.
        let out = compile_ok("struct P { x: i64 }\nenum E { A, B }\nfn main() { }");
        assert!(out.contains("\nmain:\n"));
        assert!(!out.contains("\nP:\n"));
        assert!(!out.contains("\nE:\n"));
    }

    #[test]
    fn a_method_is_rejected_cleanly() {
        // `fn Type.method` is out of the integer core -- a clean error, not a
        // panic and not bad assembly.
        let typed = typecheck("struct P { x: i64 }\nfn P.get(self) -> i64 { self.x }");
        let err = compile_arm64(
            &typed,
            "struct P { x: i64 }\nfn P.get(self) -> i64 { self.x }",
        )
        .expect_err("a method must be rejected");
        assert!(err[0].message().contains("method"), "{:?}", err);
    }

    #[test]
    fn a_float_parameter_is_rejected_cleanly() {
        let src = "fn f(x: f64) -> f64 { x }";
        let typed = typecheck(src);
        let err = compile_arm64(&typed, src).expect_err("a float param must be rejected");
        assert!(err[0].message().contains("f64"), "{:?}", err);
    }

    #[test]
    fn more_than_eight_parameters_is_rejected_cleanly() {
        let src = "fn f(a: i64, b: i64, c: i64, d: i64, e: i64, g: i64, h: i64, i: i64, j: i64) -> i64 { a }";
        let typed = typecheck(src);
        let err = compile_arm64(&typed, src).expect_err(">8 params must be rejected");
        assert!(
            err[0].message().contains("at most 8 parameters"),
            "{:?}",
            err
        );
    }

    #[test]
    fn an_unsupported_statement_is_rejected_cleanly() {
        // `let` / `if` / `while` / `for` are now supported; `defer` stays a
        // permanent rejection -- the backend rejects it with a clean error,
        // never a panic and never bad assembly.
        let src = "fn f() { let x = 1\ndefer x }";
        let typed = typecheck(src);
        let err = compile_arm64(&typed, src).expect_err("a defer must be rejected");
        assert!(err[0].message().contains("defer"), "{:?}", err);
    }

    #[test]
    fn emission_is_deterministic() {
        // compiling the same source twice yields byte-identical text -- the
        // guard on the label counter and any map iteration order.
        let src = "fn f(a: i64, b: i64) -> bool { (a + b) > 0 && a != b }";
        let first = compile_ok(src);
        let second = compile_ok(src);
        assert_eq!(first, second, "arm64 emission must be deterministic");
    }

    #[test]
    fn arithmetic_matches_the_snapshot() {
        let src = "fn arith(a: i64, b: i64) -> i64 { (a + b) * (a - b) / 2 % 7 }";
        let emitted = compile_ok(src);
        assert_eq!(
            emitted,
            read_snapshot("arm64_arithmetic.s"),
            "arm64 arithmetic emission drifted from snapshot"
        );
    }

    #[test]
    fn comparisons_match_the_snapshot() {
        let src = "fn cmp(a: i64, b: i64) -> bool { a == b }\n\
                   fn lt(a: i64, b: i64) -> bool { a < b }\n\
                   fn ge(a: i64, b: i64) -> bool { a >= b }";
        let emitted = compile_ok(src);
        assert_eq!(
            emitted,
            read_snapshot("arm64_comparisons.s"),
            "arm64 comparison emission drifted from snapshot"
        );
    }

    #[test]
    fn booleans_match_the_snapshot() {
        let src = "fn andor(a: bool, b: bool) -> bool { a && b || !a }";
        let emitted = compile_ok(src);
        assert_eq!(
            emitted,
            read_snapshot("arm64_booleans.s"),
            "arm64 boolean emission drifted from snapshot"
        );
    }

    #[test]
    fn a_function_call_matches_the_snapshot() {
        // a multi-function program: `add3` with three i64 parameters and a
        // `let`, and `main` calling it with three arguments -- the worked
        // example. the fixture shows the callee's prologue / parameter spill /
        // body / epilogue and the caller's argument spill/load + `bl`.
        let src = "fn add3(a: i64, b: i64, c: i64) -> i64 {\n\
                   \x20   let sum = a + b\n\
                   \x20   sum + c\n\
                   }\n\
                   fn main() {\n\
                   \x20   let r = add3(10, 20, 12)\n\
                   }\n";
        let emitted = compile_ok(src);
        assert_eq!(
            emitted,
            read_snapshot("arm64_function_call.s"),
            "arm64 function-call emission drifted from snapshot"
        );
    }

    #[test]
    fn recursion_matches_the_snapshot() {
        // a recursive `factorial`: an `if` base case, a `bl` to itself, the
        // return -- the prologue/epilogue, the frame, a call, and recursion
        // exercised together. the structural shape Phase 13's `fibonacci`
        // round-trip needs.
        let src = "fn factorial(n: i64) -> i64 {\n\
                   \x20   if n <= 1 { return 1 }\n\
                   \x20   n * factorial(n - 1)\n\
                   }\n\
                   fn main() {\n\
                   \x20   let f = factorial(5)\n\
                   }\n";
        let emitted = compile_ok(src);
        assert_eq!(
            emitted,
            read_snapshot("arm64_recursion.s"),
            "arm64 recursion emission drifted from snapshot"
        );
    }

    #[test]
    fn an_unsupported_construct_matches_the_error_snapshot() {
        // a program using a float -- outside the integer core. `compile_arm64`
        // must return `Err`, never bad assembly and never a panic. the
        // rendered diagnostic text is snapshotted: the backend's clean
        // rejection of an unsupported construct.
        use crate::diagnostics::Diagnostic;
        let src = "fn main() {\n    let x = 1.5\n}\n";
        let typed = typecheck(src);
        let errors = compile_arm64(&typed, src)
            .expect_err("a float program must be rejected by the backend");
        let rendered = errors
            .iter()
            .map(|e| Diagnostic::from(e.clone()).render(src))
            .collect::<Vec<_>>()
            .join("\n");
        assert_eq!(
            rendered,
            read_snapshot("arm64_unsupported.txt"),
            "arm64 unsupported-construct rejection drifted from snapshot"
        );
    }

    #[test]
    fn every_unsupported_construct_is_rejected_with_a_specific_named_diagnostic() {
        // the ARM-05 contract, exercised across the spread of constructs the
        // integer core does not support. each program is well-typed -- the
        // rejection is the BACKEND's, not the typechecker's -- and must yield
        // a `QalaError` (never a panic, never bad assembly) whose message
        // names the construct and uses the lowercase house spelling `arm64`.
        let cases: [(&str, &str); 11] = [
            // a float value.
            ("fn f() -> i64 { let x = 1.5\n0 }", "floats"),
            // a string value bound to a let.
            ("fn f() -> i64 { let s = \"hi\"\n0 }", "strings"),
            // a byte value.
            ("fn f() -> i64 { let b = b'a'\n0 }", "byte values"),
            // an array value.
            ("fn f() -> i64 { let a = [1, 2, 3]\n0 }", "arrays"),
            // a tuple value.
            ("fn f() -> i64 { let t = (1, 2)\n0 }", "tuples"),
            // a struct literal.
            (
                "struct P { x: i64 }\nfn f() -> i64 { let p = P { x: 1 }\n0 }",
                "struct literals",
            ),
            // a match expression.
            (
                "fn f(n: i64) -> i64 { match n { _ => 0 } }",
                "match expressions",
            ),
            // a comptime block.
            ("fn f() -> i64 { comptime { 1 + 1 } }", "comptime blocks"),
            // a defer statement.
            ("fn f() { let x = 1\ndefer x }", "defer"),
            // a float function parameter.
            ("fn f(x: f64) -> f64 { x }", "f64 parameters"),
            // a method definition.
            (
                "struct P { x: i64 }\nfn P.get(self) -> i64 { self.x }",
                "method",
            ),
        ];
        for (src, needle) in cases {
            let typed = typecheck(src);
            let errors = compile_arm64(&typed, src)
                .expect_err("the program must be rejected by the backend");
            let message = errors[0].message();
            assert!(
                message.contains(needle),
                "the rejection of `{src}` must name `{needle}`: got {message:?}"
            );
            assert!(
                message.contains("arm64"),
                "the rejection of `{src}` must use the lowercase `arm64` spelling: \
                 got {message:?}"
            );
        }
    }
}