qala-compiler 0.1.0

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
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
//! the `print` / `println` to `printf` lowering for the ARM64 backend.
//!
//! [`Arm64Backend::compile_print_call`] is the third `impl Arm64Backend` block
//! (after `expr.rs` and `stmt.rs`); it lowers a call to the `print` or
//! `println` built-in -- both typed `(str) -> void` -- into an AArch64
//! `printf` call. `expr.rs`'s `compile_call` routes a `print` / `println`
//! callee here instead of treating it as a user function.
//!
//! ## the lowering
//!
//! a `print` / `println` argument is always `str`-typed, and the integer core
//! supports two `str` forms:
//!
//! - a plain string literal ([`TypedExpr::Str`]) -- the zero-hole case. the
//!   `printf` format string is the literal text, there are no argument holes.
//! - a string interpolation ([`TypedExpr::Interpolation`]) -- the parts list
//!   is walked: a [`TypedInterpPart::Literal`] appends its text to the format
//!   string, a [`TypedInterpPart::Expr`] whose type is `i64` appends a `%lld`
//!   conversion and queues the expression as the next `printf` argument.
//!
//! `println` appends a trailing `\n` to the format string; `print` does not.
//! a literal `%` in the interpolation text is doubled to `%%` so `printf`
//! prints one literal percent.
//!
//! every other `str` form -- a string variable, a string concatenation, a
//! `str`-returning call, an interpolation hole that is itself a `str` or any
//! non-`i64` type -- is outside the integer core and is rejected with a clean
//! [`QalaError`]. the integer core has no heap and no string representation;
//! only string literals and `i64`-holed interpolations reach `printf`.
//!
//! ## the emitted call
//!
//! the format string is interned into the `.data` section
//! ([`Asm::intern_format`](super::emit::Asm::intern_format)) and reached with
//! `ldr x0, =<label>`. each queued `i64` hole is evaluated through the Phase
//! 12 expression codegen and spilled to a claimed scratch slot -- the same
//! claim/release discipline `compile_call` uses for a user call's arguments --
//! then the slots are loaded into `x1`, `x2`, ... and `bl printf` is emitted.
//! the format-string pointer takes `x0`, so the AArch64 argument registers
//! `x1`-`x7` hold at most seven holes; an interpolation with more is rejected.
//!
//! Phase 12's `main` prologue keeps `sp` 16-byte aligned for the whole body
//! (`sp` never moves mid-body -- locals are `fp`-relative), so the `bl printf`
//! site is correctly aligned.

use crate::errors::QalaError;
use crate::span::Span;
use crate::typed_ast::{TypedExpr, TypedInterpPart};

use super::Arm64Backend;

/// the maximum number of `i64` interpolation holes a single `print` /
/// `println` supports: the AArch64 integer-argument registers are `x0`-`x7`,
/// `printf`'s format-string pointer takes `x0`, so seven holes (`x1`-`x7`)
/// remain. an interpolation with more holes would need a stack-passed
/// argument, which the integer core does not emit.
///
/// derived from [`expr::MAX_CALL_ARGS`](super::expr) (the eight `x0`-`x7`
/// registers) minus the one `x0` the format-string pointer takes -- the two
/// constants encode the same AArch64 fact, so deriving one keeps them in
/// lockstep.
const MAX_PRINTF_HOLES: usize = super::expr::MAX_CALL_ARGS - 1;

impl Arm64Backend {
    /// lower a `print` / `println` call to a `printf` call, leaving `printf`'s
    /// (discarded) return value in `x0`.
    ///
    /// `name` is `"print"` or `"println"`; `args` is the call's argument list
    /// (a well-typed `print` / `println` has exactly one `str` argument);
    /// `span` is the call site, carried on any rejection.
    ///
    /// returns a [`QalaError`] -- never a panic -- for a construct the integer
    /// core does not support: a `str` argument that is neither a literal nor
    /// an interpolation, an interpolation hole whose type is not `i64`, or an
    /// interpolation with more than seven holes.
    pub(super) fn compile_print_call(
        &mut self,
        name: &str,
        args: &[TypedExpr],
        span: Span,
    ) -> Result<(), QalaError> {
        // `print` / `println` are typed `(str) -> void`: exactly one argument.
        // a well-typed program always satisfies this; the guard is defensive
        // so a malformed call is a clean error rather than an index panic.
        let [arg] = args else {
            return Err(QalaError::Type {
                span,
                message: format!("the arm64 backend expects `{name}` to take exactly one argument"),
            });
        };
        // `println` terminates its output with a newline; `print` does not.
        let newline = name == "println";

        // build the printf format string and the ordered list of i64 holes.
        let (format, holes) = self.build_format(arg, newline)?;

        // cap the hole count at the seven argument registers x1-x7.
        if holes.len() > MAX_PRINTF_HOLES {
            return Err(QalaError::Type {
                span,
                message: format!(
                    "the arm64 backend supports at most {MAX_PRINTF_HOLES} \
                     interpolation holes in a `{name}`"
                ),
            });
        }

        self.emit_printf(&format, &holes)
    }

    /// walk a `print` / `println` argument into a `printf` format string and a
    /// borrowed, ordered list of the `i64` hole expressions.
    ///
    /// a [`TypedExpr::Str`] argument is the zero-hole case -- the format
    /// string is the literal text (percent-escaped), the hole list is empty. a
    /// [`TypedExpr::Interpolation`] argument is walked part by part. any other
    /// argument shape is rejected. `newline` appends a trailing `\n` to the
    /// format string for a `println`.
    fn build_format<'a>(
        &self,
        arg: &'a TypedExpr,
        newline: bool,
    ) -> Result<(String, Vec<&'a TypedExpr>), QalaError> {
        let mut format = String::new();
        let mut holes: Vec<&TypedExpr> = Vec::new();
        match arg {
            // a plain string literal: the format string is the literal text,
            // its percents doubled so printf prints them literally.
            TypedExpr::Str { value, .. } => {
                format.push_str(&escape_percent(value));
            }
            // an interpolation: a literal part appends its (percent-escaped)
            // text, an i64 hole appends a `%lld` conversion and is queued.
            TypedExpr::Interpolation { parts, .. } => {
                for part in parts {
                    match part {
                        TypedInterpPart::Literal(text) => {
                            format.push_str(&escape_percent(text));
                        }
                        TypedInterpPart::Expr(expr) => {
                            // only an i64 hole can become a `%lld`. a str, an
                            // f64, a bool -- any non-i64 hole -- is outside the
                            // integer core and is rejected.
                            if !matches!(expr.ty(), crate::types::QalaType::I64) {
                                return Err(QalaError::Type {
                                    span: expr.span(),
                                    message: format!(
                                        "the arm64 backend does not yet support \
                                         {} values in interpolation",
                                        super::type_name(expr.ty())
                                    ),
                                });
                            }
                            format.push_str("%lld");
                            holes.push(expr);
                        }
                    }
                }
            }
            // any other str-valued expression -- a string variable, a string
            // concatenation, a str-returning call -- has no integer-core
            // representation and is rejected.
            other => {
                return Err(QalaError::Type {
                    span: other.span(),
                    message: format!(
                        "the arm64 backend does not yet support {} as a \
                         `print` / `println` argument",
                        print_arg_description(other)
                    ),
                });
            }
        }
        // a println terminates with a newline.
        if newline {
            format.push('\n');
        }
        Ok((format, holes))
    }

    /// emit the `printf` call: intern the format string, evaluate every hole
    /// into an argument register, and `bl printf`.
    ///
    /// each hole is evaluated into `x0` through the Phase 12 expression codegen
    /// and spilled to a freshly claimed scratch slot -- evaluating hole `k+1`
    /// into `x0` would clobber hole `k`, exactly the clobber `compile_call`
    /// avoids for a user call's arguments. once every hole is in a slot the
    /// slots are loaded into `x1`, `x2`, ..., the format-string pointer is
    /// loaded into `x0` with `ldr x0, =<label>`, and `bl printf` is emitted.
    /// the claimed slots are released after the call.
    ///
    /// returns a [`QalaError`] -- never a panic -- if a hole's evaluation
    /// fails. `build_format` proved every hole `i64`-typed, but an `i64`-typed
    /// hole may still be a construct the integer core does not lower (a hole
    /// that is an `i64`-returning stdlib call, say), and `compile_expr`
    /// rejects that. the error propagates with `?` here and on through
    /// `compile_print_call`, the same shape `compile_call` uses for a user
    /// call -- the two halves of the lowering flow errors the same way. on the
    /// error path the hole-slot release loop is skipped: the compile is
    /// already failing and the frame is discarded, so the unbalanced claim is
    /// inert.
    fn emit_printf(&mut self, format: &str, holes: &[&TypedExpr]) -> Result<(), QalaError> {
        let label = self.asm.intern_format(format);

        // evaluate each hole into x0, then spill it to a claimed scratch slot.
        // the slot is claimed AFTER the hole is evaluated, so a hole that is
        // itself a call releases its own argument slots before this persistent
        // slot is taken -- the same discipline `compile_call` documents.
        let mut hole_slots = Vec::with_capacity(holes.len());
        for (i, hole) in holes.iter().enumerate() {
            // a hole is i64-typed, but an i64-typed construct can still be
            // unsupported (an i64-returning stdlib call) -- `?` makes that a
            // clean error, propagated like `compile_call`'s, never a panic.
            self.compile_expr(hole)?;
            let slot = self.frame_mut().claim_scratch();
            self.asm.emit_insn_commented(
                &format!("str     x0, [fp, {slot}]"),
                &format!("printf arg {}", i + 1),
            );
            hole_slots.push(slot);
        }
        // load the holes into x1..x{n}: argument register i+1 holds hole i,
        // since x0 is reserved for the format-string pointer.
        for (i, slot) in hole_slots.iter().enumerate() {
            self.asm
                .emit_insn(&format!("ldr     x{}, [fp, {slot}]", i + 1));
        }
        // the format-string pointer in x0, then the call.
        self.asm
            .emit_insn_commented(&format!("ldr     x0, ={label}"), "printf format");
        self.asm.emit_insn("bl      printf");
        // release the hole slots -- one per hole, balancing the claims above.
        for _ in &hole_slots {
            self.frame_mut().release_scratch();
        }
        Ok(())
    }
}

/// double every `%` in interpolation text so `printf` prints a literal percent.
///
/// `printf` reads `%` as the start of a conversion; a literal percent in the
/// program's string text must therefore be written `%%` in the format string.
/// the `%lld` conversions the lowering itself appends are added separately and
/// never pass through here, so this only ever sees the program's own text.
fn escape_percent(text: &str) -> String {
    text.replace('%', "%%")
}

/// a human description of a rejected `print` / `println` argument, for the
/// unsupported-construct diagnostic.
///
/// the integer core prints a string literal and an `i64`-holed interpolation;
/// every other `str`-valued expression is rejected, and this names the shape
/// so the message is specific: a string variable, a string concatenation, a
/// string-returning call.
fn print_arg_description(expr: &TypedExpr) -> &'static str {
    match expr {
        TypedExpr::Ident { .. } => "a string variable",
        TypedExpr::Binary { .. } => "string concatenation",
        TypedExpr::Call { .. } | TypedExpr::MethodCall { .. } => "a string-returning call",
        TypedExpr::Paren { .. } => "a parenthesized string expression",
        TypedExpr::Block { .. } => "a block expression",
        TypedExpr::OrElse { .. } => "an `or` fallback expression",
        TypedExpr::Match { .. } => "a match expression",
        TypedExpr::FieldAccess { .. } => "a string field access",
        TypedExpr::Index { .. } => "a string index expression",
        // a catch-all: any other str-typed expression the integer core does
        // not lower. the specific arms above name the shapes a program is
        // likely to write; this keeps the message honest for the rest.
        _ => "this string expression",
    }
}

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

    /// compile a whole program to assembly, panicking on a backend error.
    fn compile_ok(src: &str) -> String {
        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:?}");
        super::super::compile_arm64(&typed, src).unwrap_or_else(|e| panic!("arm64 errors: {e:?}"))
    }

    /// compile a program expected to be rejected, returning the first error's
    /// message.
    fn compile_err(src: &str) -> String {
        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:?}");
        let errors = super::super::compile_arm64(&typed, src)
            .expect_err("the program must be rejected by the backend");
        errors[0].message()
    }

    #[test]
    fn escape_percent_doubles_a_literal_percent() {
        assert_eq!(escape_percent("100% sure"), "100%% sure");
        assert_eq!(escape_percent("no percent here"), "no percent here");
        assert_eq!(escape_percent("%%"), "%%%%");
    }

    #[test]
    fn a_string_literal_println_emits_a_printf_with_no_holes() {
        // `println("done")` -- the zero-hole case. the format string is the
        // literal text plus a newline, in .data; printf is called with just
        // the format-string pointer in x0.
        let out = compile_ok("fn main() is io { println(\"done\") }");
        assert!(
            out.contains("        .data\n"),
            "missing .data section: {out}"
        );
        assert!(
            out.contains(".string \"done\\n\"\n"),
            "missing the println format string: {out}"
        );
        assert!(
            out.contains("ldr     x0, =.Lfmt_0"),
            "missing the format load: {out}"
        );
        assert!(
            out.contains("bl      printf"),
            "missing the printf call: {out}"
        );
    }

    #[test]
    fn a_string_literal_print_omits_the_trailing_newline() {
        // `print` -- unlike `println` -- adds no newline to the format string.
        let out = compile_ok("fn main() is io { print(\"hi\") }");
        assert!(
            out.contains(".string \"hi\"\n"),
            "print must not add a newline: {out}"
        );
        assert!(
            !out.contains(".string \"hi\\n\""),
            "print added a newline: {out}"
        );
    }

    #[test]
    fn an_interpolation_hole_becomes_a_percent_lld_and_an_argument() {
        // `println("{x}")` -- a single i64 hole. the format string carries one
        // `%lld`; the hole is evaluated and loaded into x1.
        let out = compile_ok("fn main() is io { let x = 7\nprintln(\"{x}\") }");
        assert!(
            out.contains(".string \"%lld\\n\"\n"),
            "missing the %lld format: {out}"
        );
        assert!(
            out.contains("str     x0, [fp,") && out.contains("// printf arg 1"),
            "missing the hole spill: {out}"
        );
        assert!(
            out.contains("ldr     x1, [fp,"),
            "missing the x1 load: {out}"
        );
        assert!(
            out.contains("ldr     x0, =.Lfmt_0"),
            "missing the format load: {out}"
        );
        assert!(
            out.contains("bl      printf"),
            "missing the printf call: {out}"
        );
    }

    #[test]
    fn the_fibonacci_interpolation_builds_a_two_hole_format() {
        // the fibonacci.qala line: `println("fib({i}) = {fibonacci(i)}")`.
        // two i64 holes -> "fib(%lld) = %lld\n" and two argument registers.
        let out = compile_ok(
            "fn fib(n: i64) -> i64 { n }\n\
             fn main() is io {\n\
             \x20   for i in 0..3 { println(\"fib({i}) = {fib(i)}\") }\n\
             }",
        );
        assert!(
            out.contains(".string \"fib(%lld) = %lld\\n\"\n"),
            "the two-hole format string is wrong: {out}"
        );
        // two holes -> x1 and x2 both loaded.
        assert!(
            out.contains("ldr     x1, [fp,"),
            "missing the x1 load: {out}"
        );
        assert!(
            out.contains("ldr     x2, [fp,"),
            "missing the x2 load: {out}"
        );
        assert!(
            out.contains("bl      printf"),
            "missing the printf call: {out}"
        );
    }

    #[test]
    fn the_hole_arguments_are_loaded_after_every_spill() {
        // the clobber trap: hole 2's evaluation lands in x0 and would overwrite
        // hole 1 if hole 1 were already in a register. every `str` spill must
        // precede the first `ldr` of an argument register.
        let out = compile_ok(
            "fn fib(n: i64) -> i64 { n }\n\
             fn main() is io {\n\
             \x20   for i in 0..3 { println(\"fib({i}) = {fib(i)}\") }\n\
             }",
        );
        let lines: Vec<&str> = out.lines().collect();
        let last_spill = lines
            .iter()
            .rposition(|l| l.contains("str     x0, [fp,") && l.contains("// printf arg "))
            .expect("no hole spill");
        let first_arg_load = lines
            .iter()
            .position(|l| l.contains("ldr     x1, [fp,"))
            .expect("no x1 load");
        assert!(
            last_spill < first_arg_load,
            "every hole spill must precede the first argument load: {out}"
        );
    }

    #[test]
    fn a_literal_percent_in_the_text_is_doubled() {
        // a literal `%` in the program's string must reach printf as `%%`.
        let out = compile_ok("fn main() is io { println(\"100% done\") }");
        assert!(
            out.contains(".string \"100%% done\\n\"\n"),
            "a literal percent must be doubled to %%: {out}"
        );
    }

    #[test]
    fn two_identical_format_strings_share_one_data_entry() {
        // two `println("done")` calls intern the same format string -- one
        // .data entry, one label, reused.
        let out = compile_ok("fn main() is io { println(\"done\")\nprintln(\"done\") }");
        assert_eq!(
            out.matches(".Lfmt_0:").count(),
            1,
            "an identical format string must be interned once: {out}"
        );
        assert!(
            !out.contains(".Lfmt_1:"),
            "no second entry for the same string: {out}"
        );
    }

    #[test]
    fn main_returns_zero_after_a_printf() {
        // `main` is void: it zeroes x0 before the epilogue, so the assembled
        // program exits success even after a printf left a byte count in x0.
        let out = compile_ok("fn main() is io { println(\"done\") }");
        assert!(
            out.contains("mov     x0, 0  // void return -> exit code 0"),
            "main must return 0 after the printf: {out}"
        );
    }

    #[test]
    fn a_non_i64_interpolation_hole_is_rejected_cleanly() {
        // a bool hole -- `{b}` where b is a bool -- is not an i64, so it cannot
        // become a `%lld`. a clean rejection naming the type, never a panic.
        let msg = compile_err("fn main() is io { let b = true\nprintln(\"{b}\") }");
        assert!(
            msg.contains("bool") && msg.contains("interpolation"),
            "the rejection must name the bool hole: {msg}"
        );
    }

    #[test]
    fn an_i64_typed_but_unsupported_hole_is_rejected_cleanly() {
        // a hole that IS i64-typed -- so it passes `build_format`'s hole-type
        // check -- but is still a construct the integer core does not lower:
        // `abs` is an i64-returning stdlib call, and `compile_expr` rejects a
        // stdlib call. this is the reachable error path `emit_printf`'s `?`
        // exists for: the error propagates out as a clean `QalaError` naming
        // `abs`, never a panic, never swallowed. it proves `emit_printf` flows
        // a hole-evaluation error the same way `compile_call` does.
        let msg = compile_err("fn main() is io { let a = -3\nprintln(\"{abs(a)}\") }");
        assert!(
            msg.contains("abs"),
            "an i64-typed unsupported hole must be rejected naming `abs`: {msg}"
        );
    }

    #[test]
    fn a_float_interpolation_hole_is_rejected_cleanly() {
        // an f64 hole is outside the integer core -- a clean rejection. the
        // typed AST is built directly: a float reaching the backend through a
        // `let` would be rejected at the `let` first, so the interpolation
        // node is constructed here to exercise the hole-type check itself.
        use crate::types::QalaType;
        let interp = TypedExpr::Interpolation {
            parts: vec![TypedInterpPart::Expr(TypedExpr::Float {
                value: 1.5,
                ty: QalaType::F64,
                span: Span::new(0, 3),
            })],
            ty: QalaType::Str,
            span: Span::new(0, 5),
        };
        let mut backend = Arm64Backend::new("");
        let err = backend
            .compile_print_call("println", std::slice::from_ref(&interp), Span::new(0, 5))
            .expect_err("an f64 hole must be rejected");
        match err {
            QalaError::Type { message, .. } => assert!(
                message.contains("f64") && message.contains("interpolation"),
                "the rejection must name the f64 hole: {message}"
            ),
            other => panic!("expected QalaError::Type, got {other:?}"),
        }
    }

    #[test]
    fn a_string_variable_argument_is_rejected_cleanly() {
        // `println(s)` where `s` is a str variable -- not a literal, not an
        // interpolation. the integer core has no string values; a clean
        // rejection naming the construct. the typed AST is built directly: a
        // str `let` would be rejected at the `let` first, so the str-typed
        // identifier argument is constructed here to exercise build_format.
        use crate::types::QalaType;
        let ident = TypedExpr::Ident {
            name: "s".to_string(),
            ty: QalaType::Str,
            span: Span::new(0, 1),
        };
        let mut backend = Arm64Backend::new("");
        let err = backend
            .compile_print_call("println", std::slice::from_ref(&ident), Span::new(0, 5))
            .expect_err("a string variable argument must be rejected");
        match err {
            QalaError::Type { message, .. } => assert!(
                message.contains("string variable") && message.contains("arm64"),
                "the rejection must name the string variable: {message}"
            ),
            other => panic!("expected QalaError::Type, got {other:?}"),
        }
    }

    #[test]
    fn a_string_concatenation_argument_is_rejected_cleanly() {
        // `println("a" + "b")` -- a string concatenation reaches the print
        // lowering as a Binary, outside the integer core.
        let msg = compile_err("fn main() is io { println(\"a\" + \"b\") }");
        assert!(
            msg.contains("string concatenation"),
            "the rejection must name the concatenation: {msg}"
        );
    }

    #[test]
    fn more_than_seven_holes_is_rejected_cleanly() {
        // eight i64 holes need eight argument registers -- x0 is the format
        // pointer, leaving only x1-x7. a clean rejection, never a panic.
        let src = "fn main() is io {\n\
                   \x20   let a = 1\n\
                   \x20   println(\"{a}{a}{a}{a}{a}{a}{a}{a}\")\n\
                   }";
        let msg = compile_err(src);
        assert!(
            msg.contains("at most 7") && msg.contains("holes"),
            "more than seven holes must be rejected: {msg}"
        );
    }

    #[test]
    fn seven_holes_is_accepted() {
        // exactly seven holes fits x1-x7 -- the boundary case is supported.
        let src = "fn main() is io {\n\
                   \x20   let a = 1\n\
                   \x20   println(\"{a}{a}{a}{a}{a}{a}{a}\")\n\
                   }";
        let out = compile_ok(src);
        assert!(
            out.contains("ldr     x7, [fp,"),
            "the seventh hole must load x7: {out}"
        );
        assert!(
            !out.contains("ldr     x8, [fp,"),
            "there is no eighth argument register: {out}"
        );
    }

    #[test]
    fn the_emitted_program_has_a_text_section_and_a_main() {
        // a sanity check that the printf path produces a complete program: the
        // preamble, the .data format string, the .text section, and `main`.
        let out = compile_ok("fn main() is io { println(\"done\") }");
        assert!(
            out.starts_with("define(fp, x29)\n"),
            "missing the m4 preamble: {out}"
        );
        assert!(
            out.contains("        .text\n"),
            "missing the .text section: {out}"
        );
        assert!(out.contains("\nmain:\n"), "missing the main label: {out}");
    }

    #[test]
    fn a_print_call_is_not_treated_as_a_user_function() {
        // `print` / `println` must route to the printf lowering, never to the
        // user-function `bl` path -- there is no `bl print` / `bl println`.
        // the only `bl` the printf path emits is `bl printf` itself.
        let out = compile_ok("fn main() is io { println(\"done\") }");
        let bl_targets: Vec<&str> = out
            .lines()
            .filter_map(|l| l.trim().strip_prefix("bl      "))
            .collect();
        assert_eq!(
            bl_targets,
            vec!["printf"],
            "the only call the println path emits is `bl printf`: {out}"
        );
    }

    #[test]
    fn a_hole_that_is_a_call_evaluates_before_the_printf() {
        // `println("{fib(3)}")` -- the hole is a call. the call's own `bl`
        // must run before `bl printf`, leaving the result spilled as a hole.
        let out = compile_ok(
            "fn fib(n: i64) -> i64 { n }\n\
             fn main() is io { println(\"{fib(3)}\") }",
        );
        let lines: Vec<&str> = out.lines().collect();
        let bl_fib = lines
            .iter()
            .position(|l| l.contains("bl      fib"))
            .expect("missing bl fib");
        let bl_printf = lines
            .iter()
            .position(|l| l.contains("bl      printf"))
            .expect("missing bl printf");
        assert!(
            bl_fib < bl_printf,
            "the hole's call must run before printf: {out}"
        );
    }

    #[test]
    fn typed_items_have_no_extra_emission_for_the_printf_path() {
        // a struct alongside a printing main: the struct emits nothing, the
        // printf path is unaffected.
        let out = compile_ok("struct P { x: i64 }\nfn main() is io { println(\"done\") }");
        assert!(
            out.contains("bl      printf"),
            "the printf call is missing: {out}"
        );
        assert!(
            !out.contains("\nP:\n"),
            "the struct must emit nothing: {out}"
        );
    }

    /// read a snapshot fixture, normalising CRLF to LF. a checked-out file on
    /// Windows may carry CRLF line endings; the emitter always produces LF, so
    /// the fixture's carriage returns are stripped for a platform-stable
    /// compare -- the same helper shape `mod.rs` and `stmt.rs` use.
    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 a_string_literal_println_matches_the_snapshot() {
        // the zero-hole printf path: a `println` of a string literal. the
        // fixture shows the `.data` format string, the `ldr x0, =label`, and
        // the `bl printf`, with `main` returning 0.
        let emitted = compile_ok("fn main() is io {\n    println(\"done\")\n}\n");
        assert_eq!(
            emitted,
            read_snapshot("arm64_printf_literal.s"),
            "arm64 printf literal emission drifted from snapshot"
        );
    }

    #[test]
    fn an_interpolation_println_matches_the_snapshot() {
        // the multi-hole printf path: a `println` whose interpolation has two
        // i64 holes, the second a call. the fixture shows the two holes
        // spilled to scratch slots, loaded into x1/x2, and `bl printf`.
        let src = "fn square(n: i64) -> i64 {\n\
                   \x20   n * n\n\
                   }\n\
                   \n\
                   fn main() is io {\n\
                   \x20   let x = 6\n\
                   \x20   println(\"square({x}) = {square(x)}\")\n\
                   }\n";
        let emitted = compile_ok(src);
        assert_eq!(
            emitted,
            read_snapshot("arm64_printf_interpolation.s"),
            "arm64 printf interpolation emission drifted from snapshot"
        );
    }

    #[test]
    fn a_non_i64_hole_rejection_matches_the_error_snapshot() {
        // the new ARM-05 boundary the printf path introduces: a non-i64
        // interpolation hole. `compile_arm64` returns `Err`; the rendered
        // diagnostic text is snapshotted -- the backend's clean rejection of
        // an unsupported interpolation hole.
        use crate::diagnostics::Diagnostic;
        let src = "fn main() is io {\n    let b = true\n    println(\"flag is {b}\")\n}\n";
        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:?}");
        let errors = super::super::compile_arm64(&typed, src)
            .expect_err("a non-i64 interpolation hole must be rejected");
        let rendered = errors
            .iter()
            .map(|e| Diagnostic::from(e.clone()).render(src))
            .collect::<Vec<_>>()
            .join("\n");
        assert_eq!(
            rendered,
            read_snapshot("arm64_printf_rejected.txt"),
            "arm64 printf rejection diagnostic drifted from snapshot"
        );
    }

    /// the `[fp, N]` byte offset referenced by an instruction line, or `None`
    /// if the line has no `[fp, ...]` operand -- the same parser the `expr.rs`
    /// frame-containment proof uses.
    fn fp_offset(line: &str) -> Option<i64> {
        let start = line.find("[fp, ")? + "[fp, ".len();
        let rest = &line[start..];
        let end = rest.find(']')?;
        rest[..end].trim().parse().ok()
    }

    #[test]
    fn a_printf_with_deep_holes_keeps_every_slot_inside_the_frame() {
        // WR-02 regression. the printf frame-sizing path
        // (`printf_call_spill_depth` / `emit_printf`) had no isolated
        // frame-containment proof: the Phase 12 "scratch overflow past the
        // frame" CRITICAL was caught only by the `expr.rs` containment test,
        // and a regression dropping the `+ deepest` term -- so a hole that is
        // a deep arithmetic expression or a multi-arg call under-reserves --
        // would pass every snapshot-style printf test.
        //
        // this mirrors the `expr.rs` proof for the printf path: a `println`
        // whose interpolation has a deep-arithmetic hole `(a+1)*(a+2)` AND a
        // three-argument-call hole `three(a, a+1, a+2)` -- the two hole shapes
        // that exercise the `deepest` term. read `main`'s `dealloc` from its
        // epilogue `ldp fp, lr, [sp], N` and assert every `[fp, N]` the body
        // stores or loads satisfies `0 <= N < dealloc` -- inside the frame
        // `main` owns. fp == sp after `mov fp, sp`, so `[fp, dealloc]` and
        // beyond is the caller's frame.
        let asm = compile_ok(
            "fn three(a: i64, b: i64, c: i64) -> i64 { a + b + c }\n\
             fn main() is io {\n\
             \x20   let a = 4\n\
             \x20   println(\"{(a+1)*(a+2)} and {three(a, a+1, a+2)} and {a}\")\n\
             }\n",
        );
        // the whole `main` function, label through epilogue, so the `ldp`
        // line (which sits after the epilogue label) is included.
        let main_fn: Vec<&str> = asm
            .lines()
            .skip_while(|l| l.trim() != "main:")
            .take_while(|l| l.trim() != "ret" && !l.trim().is_empty())
            .collect();
        // the epilogue's `ldp fp, lr, [sp], N` carries the frame size N.
        let dealloc: i64 = main_fn
            .iter()
            .find_map(|l| {
                l.trim()
                    .strip_prefix("ldp     fp, lr, [sp], ")
                    .and_then(|n| n.trim().parse().ok())
            })
            .expect("missing the epilogue `ldp` line with the frame size");
        // every fp-relative store/load must land strictly inside the frame.
        for line in &main_fn {
            if let Some(offset) = fp_offset(line) {
                assert!(
                    offset < dealloc,
                    "`{}` writes [fp, {offset}] -- outside the {dealloc}-byte frame",
                    line.trim()
                );
                assert!(offset >= 0, "`{}` has a negative fp offset", line.trim());
            }
        }
        // the test is only meaningful if the printf body actually spilled the
        // holes -- a deep arithmetic hole plus a 3-arg-call hole must produce
        // several distinct spill slots.
        let spill_slots: std::collections::BTreeSet<i64> = main_fn
            .iter()
            .filter(|l| l.contains("str     x0, [fp, "))
            .filter_map(|l| fp_offset(l))
            .collect();
        assert!(
            spill_slots.len() >= 3,
            "the deep-hole printf must use several scratch slots: {main_fn:?}"
        );
        // the highest argument register the printf body loads must not exceed
        // the hole count: three holes -> x1, x2, x3 and no higher.
        for reg in 4..=7 {
            assert!(
                !asm.contains(&format!("ldr     x{reg}, [fp, ")),
                "a three-hole printf must not load x{reg}: {asm}"
            );
        }
    }
}