Skip to main content

qala_compiler/
codegen.rs

1//! the bytecode codegen: lower a [`TypedAst`] to a [`Program`] of bytecode
2//! chunks, ready for the Phase 5 stack VM to execute.
3//!
4//! one recursive walk over the typed AST; each typed expression and
5//! statement compiles to a sequence of opcodes via the write helpers on
6//! [`crate::chunk::Chunk`]. inline constant folding runs as bytecode is
7//! emitted -- a literal arithmetic / comparison / logic expression collapses
8//! to a single CONST before reaching the optimizer; an i64 fold that would
9//! overflow errors with [`crate::errors::QalaError::IntegerOverflow`] rather
10//! than silently wrapping. dead code after `return` / `break` / `continue`
11//! is not emitted; a constant-condition `if` emits only the taken branch.
12//!
13//! `defer` compilation: each [`LocalsScope`] carries a deferred-expression
14//! list. defer statements append, never emit; every scope exit (fall-through,
15//! return, break, continue, ?-propagation) walks the deferred list in
16//! reverse and emits each expression's bytecode. defers inside a loop body
17//! live in the loop-body scope, which is pushed fresh per iteration; LIFO
18//! per-iteration semantics fall out of this.
19//!
20//! `comptime` evaluation: a [`ComptimeInterpreter`] (file-private) walks the
21//! same opcode set as the runtime VM but enforces a 100K-instruction budget
22//! and refuses to dispatch a CALL whose callee is not pure -- defense in
23//! depth against a typechecker miss. results that cannot be represented in
24//! the constant pool (arrays, structs, enum variants) error at the comptime
25//! block.
26//!
27//! errors accumulate rather than fail-fast: a fold overflow in one function
28//! does not stop the others from compiling. on any errors, the public entry
29//! returns Err(Vec<QalaError>) sorted by (span.start, span.len); on full
30//! success it returns Ok(Program).
31//!
32//! file order (per compiler/CLAUDE.md): `value -> opcode -> chunk -> codegen`.
33//! every byte this module writes goes through [`Chunk::write_op`] /
34//! [`Chunk::write_u16`] / [`Chunk::write_i16`] so the lockstep invariant
35//! `chunk.code.len() == chunk.source_lines.len()` survives codegen.
36
37use crate::ast::{BinOp, Pattern, UnaryOp};
38use crate::chunk::{Chunk, Program, StructInfo};
39use crate::effects::EffectSet;
40use crate::errors::QalaError;
41use crate::opcode::{Opcode, STDLIB_FN_BASE};
42use crate::span::{LineIndex, Span};
43use crate::typed_ast::{
44    TypedAst, TypedBlock, TypedElseBranch, TypedExpr, TypedInterpPart, TypedItem, TypedMatchArm,
45    TypedMatchArmBody, TypedStmt,
46};
47use crate::types::{QalaType, Symbol};
48use crate::value::ConstValue;
49use std::collections::{BTreeMap, HashMap};
50
51/// the comptime interpreter's instruction budget. hitting it produces
52/// [`QalaError::ComptimeBudgetExceeded`]; raising it is a v2 concern.
53const COMPTIME_BUDGET: u32 = 100_000;
54
55/// the comptime interpreter's call-depth cap. an explicit `Vec<Frame>` stack
56/// (not Rust recursion) means a runaway recursion is bounded here rather than
57/// blowing the host stack; over the cap is treated as a budget overflow.
58const COMPTIME_MAX_FRAMES: usize = 256;
59
60/// one row of [`STDLIB_TABLE`]: `(name, type_name, effect-constructor)`.
61type StdlibRow = (&'static str, Option<&'static str>, fn() -> EffectSet);
62
63/// the stdlib reserved-function table.
64///
65/// `(name, type_name, effect)`: `type_name` is `Some(T)` for a `fn T.method`
66/// (only `FileHandle.read_all` in v1), `None` for a free function. the array
67/// ORDER is the stdlib id assignment -- entry `i` gets fn-id
68/// `STDLIB_FN_BASE + i`. the effect column mirrors the typechecker's
69/// `stdlib_signatures` table verbatim so the [`ComptimeInterpreter`]'s CALL
70/// gate is honest. `Ok` / `Err` / `Some` / `None` are NOT here -- they are
71/// built-in Result/Option constructors handled as enum-variant emission.
72const STDLIB_TABLE: &[StdlibRow] = &[
73    ("print", None, EffectSet::io),
74    ("println", None, EffectSet::io),
75    ("sqrt", None, EffectSet::pure),
76    ("abs", None, EffectSet::pure),
77    ("assert", None, EffectSet::panic),
78    ("len", None, EffectSet::pure),
79    ("push", None, EffectSet::alloc),
80    ("pop", None, EffectSet::alloc),
81    ("type_of", None, EffectSet::pure),
82    ("open", None, EffectSet::io),
83    ("close", None, EffectSet::io),
84    ("map", None, EffectSet::pure),
85    ("filter", None, EffectSet::pure),
86    ("reduce", None, EffectSet::pure),
87    ("read_all", Some("FileHandle"), EffectSet::io),
88];
89
90/// the four built-in Result/Option variant constructors and their reserved
91/// `MAKE_ENUM_VARIANT` ids. these ids are pre-registered in
92/// [`Program::enum_variant_names`] before any user enum variant, so a user
93/// enum's first variant starts at id 4. the (enum, variant) pair the
94/// disassembler renders is `("Result", "Ok")` and so on.
95const BUILTIN_VARIANTS: &[(&str, &str, &str)] = &[
96    ("Result", "Ok", "Ok"),
97    ("Result", "Err", "Err"),
98    ("Option", "Some", "Some"),
99    ("Option", "None", "None"),
100];
101
102/// the codegen state: the program under construction plus every lookup table
103/// the recursive walk consults.
104struct Codegen<'a> {
105    /// the program being built -- `chunks` holds one chunk per user function
106    /// in fn-id order, plus the parallel `fn_names` / `enum_variant_names`.
107    program: Program,
108    /// the lexical-scope stack. the topmost scope is the innermost block;
109    /// `let` bindings register locals here, `defer` appends here.
110    scopes: Vec<LocalsScope>,
111    /// the fn-id (index into `program.chunks`) currently being compiled.
112    current_fn_id: u16,
113    /// the original source text -- needed for [`LineIndex`] line lookups.
114    src: &'a str,
115    /// the line-start table for converting span byte-offsets to source lines.
116    line_index: LineIndex,
117    /// `(None, name)` for a free fn; `(Some(T), name)` for a `fn T.method`.
118    /// every call site the typechecker resolved gets a unique fn-id; codegen
119    /// builds the same key map.
120    fn_table: HashMap<FnKey, u16>,
121    /// stdlib name -> `(fn_id, effect)`. stdlib fns get ids in the
122    /// `STDLIB_FN_BASE..` namespace; the effect drives the comptime CALL gate.
123    stdlib_table: HashMap<FnKey, (u16, EffectSet)>,
124    /// `(enum_name, variant_name)` -> variant id (parallel to
125    /// [`Program::enum_variant_names`]). `BTreeMap` so `enum_variant_lookup`'s
126    /// `.iter().find(...)` walks in sorted key order and two enums with a
127    /// same-named variant always resolve to the same id across runs.
128    enum_variant_table: BTreeMap<(String, String), u16>,
129    /// `(enum_name, variant_name)` -> the variant's payload field count.
130    enum_variant_payload_count: HashMap<(String, String), u8>,
131    /// `(struct_name, field_name)` -> the field's stable index within the
132    /// struct, used as the `FIELD` opcode operand.
133    struct_field_index: HashMap<(String, String), u16>,
134    /// `struct_name` -> struct id, parallel to [`Program::structs`]. a struct
135    /// is registered on first sight at a struct literal; the id is the
136    /// `MAKE_STRUCT` operand. `BTreeMap` for deterministic id assignment,
137    /// matching the `enum_variant_table` precedent.
138    struct_id_table: BTreeMap<String, u16>,
139    /// fn-id -> declared/inferred effect; the [`ComptimeInterpreter`] consults
140    /// this at every CALL for the defense-in-depth purity gate.
141    fn_effects: HashMap<u16, EffectSet>,
142    /// the accumulated codegen errors -- the public entry returns these
143    /// sorted rather than failing fast on the first one.
144    errors: Vec<QalaError>,
145    /// `(slot, name)` for every local bound while compiling the CURRENT
146    /// function. cleared at the start of each function in [`Codegen::compile_item`]
147    /// and drained into the finished chunk's [`Chunk::local_names`]. a scope's
148    /// `(name, slot)` list is gone once the scope is popped, so this outlives
149    /// the scopes and gives the VM the real source name of every slot.
150    local_names_acc: Vec<(u16, String)>,
151}
152
153/// one lexical scope: the locals it binds and the defers it has registered.
154struct LocalsScope {
155    /// `(name, slot)` for every local bound in this scope, in declaration
156    /// order. lookups walk scopes top-down so an inner binding shadows.
157    locals: Vec<(String, u16)>,
158    /// the deferred expressions, in registration order. emitted REVERSED at
159    /// every exit path that crosses this scope (LIFO).
160    deferred: Vec<TypedExpr>,
161    /// `Some` when this scope is a loop scope -- it then also carries the
162    /// loop-start byte position and the break-jump patch sites.
163    loop_meta: Option<LoopMeta>,
164}
165
166/// the loop-specific bookkeeping a loop scope carries.
167struct LoopMeta {
168    /// the byte position `break` jumps are patched to point past.
169    break_patches: Vec<usize>,
170    /// the byte position `continue` jumps backward to (the increment label).
171    /// `usize::MAX` is the sentinel meaning "not yet known" -- used by `for`
172    /// loops, which set the real target after the body compiles.
173    continue_target: usize,
174    /// forward-jump patch sites for `continue` in `for` loops. when
175    /// `continue_target == usize::MAX`, `continue` emits a forward jump
176    /// placeholder recorded here; `patch_loop_continues` fixes them up after
177    /// the increment label is known.
178    continue_patches: Vec<usize>,
179}
180
181/// a function-table key: `(type_name, name)`. matches the typechecker's
182/// `FnKey` shape so the same call site resolves to the same fn-id.
183#[derive(Hash, Eq, PartialEq, Clone)]
184struct FnKey {
185    /// `Some(T)` for a `fn T.method`; `None` for a free function.
186    type_name: Option<String>,
187    /// the function name.
188    name: String,
189}
190
191/// which scopes a control-flow exit unwinds, for defer emission.
192#[derive(Copy, Clone, PartialEq, Eq)]
193enum ExitKind {
194    /// reached the end of a block normally -- emit just the top scope's
195    /// defers.
196    Fallthrough,
197    /// `return` -- emit every scope's defers up to the fn body.
198    Return,
199    /// `break` -- emit scopes up to and including the innermost loop scope.
200    Break,
201    /// `continue` -- same scopes as `break`.
202    Continue,
203    /// `?`-propagation -- same scopes as `return`.
204    QuestionProp,
205}
206
207/// the codegen module's public entry: lower a typed program to bytecode.
208///
209/// errors accumulate rather than fail-fast -- a constant-folding overflow or
210/// a comptime budget exhaustion in one function does not stop the others
211/// from compiling. on success returns `Ok(Program)`; on any errors returns
212/// `Err(Vec<QalaError>)` sorted by `(span.start, span.len)` for deterministic
213/// rendering.
214pub fn compile_program(ast: &TypedAst, src: &str) -> Result<Program, Vec<QalaError>> {
215    let mut cg = Codegen::new(src);
216    cg.build_tables(ast);
217    for item in ast {
218        if let Err(e) = cg.compile_item(item) {
219            cg.errors.push(e);
220        }
221    }
222    if !cg.errors.is_empty() {
223        cg.errors.sort_by_key(|e| (e.span().start, e.span().len));
224        return Err(cg.errors);
225    }
226    Ok(cg.program)
227}
228
229impl<'a> Codegen<'a> {
230    /// construct a fresh codegen state: empty program, the stdlib table and
231    /// built-in variant ids pre-registered, every other table empty.
232    fn new(src: &'a str) -> Self {
233        let mut program = Program::new();
234        // pre-register the four built-in Result/Option variant ids before
235        // any user enum variant.
236        for (enum_name, variant_name, _) in BUILTIN_VARIANTS {
237            program
238                .enum_variant_names
239                .push((enum_name.to_string(), variant_name.to_string()));
240        }
241        let mut enum_variant_table: BTreeMap<(String, String), u16> = BTreeMap::new();
242        let mut enum_variant_payload_count: HashMap<(String, String), u8> = HashMap::new();
243        for (i, (enum_name, variant_name, _)) in BUILTIN_VARIANTS.iter().enumerate() {
244            enum_variant_table.insert((enum_name.to_string(), variant_name.to_string()), i as u16);
245            // Ok / Err / Some carry one payload; None carries zero.
246            let payload = if *variant_name == "None" { 0 } else { 1 };
247            enum_variant_payload_count
248                .insert((enum_name.to_string(), variant_name.to_string()), payload);
249        }
250        let mut stdlib_table: HashMap<FnKey, (u16, EffectSet)> = HashMap::new();
251        let mut fn_effects: HashMap<u16, EffectSet> = HashMap::new();
252        for (i, (name, type_name, effect_fn)) in STDLIB_TABLE.iter().enumerate() {
253            let fn_id = STDLIB_FN_BASE + i as u16;
254            let effect = effect_fn();
255            let key = FnKey {
256                type_name: type_name.map(|s| s.to_string()),
257                name: name.to_string(),
258            };
259            stdlib_table.insert(key, (fn_id, effect));
260            fn_effects.insert(fn_id, effect);
261        }
262        Codegen {
263            program,
264            scopes: Vec::new(),
265            current_fn_id: 0,
266            src,
267            line_index: LineIndex::new(src),
268            fn_table: HashMap::new(),
269            stdlib_table,
270            enum_variant_table,
271            enum_variant_payload_count,
272            struct_field_index: HashMap::new(),
273            struct_id_table: BTreeMap::new(),
274            fn_effects,
275            errors: Vec::new(),
276            local_names_acc: Vec::new(),
277        }
278    }
279
280    /// pass 1: pre-register every user function's fn-id, every enum variant's
281    /// id, and every struct field's index, so the recursive compile pass can
282    /// resolve forward references (a fn calling a fn declared later).
283    fn build_tables(&mut self, ast: &TypedAst) {
284        for item in ast {
285            match item {
286                TypedItem::Fn(decl) => {
287                    let fn_id = self.program.chunks.len() as u16;
288                    self.program.chunks.push(Chunk::new());
289                    self.program.fn_names.push(decl.name.clone());
290                    self.fn_table.insert(
291                        FnKey {
292                            type_name: decl.type_name.clone(),
293                            name: decl.name.clone(),
294                        },
295                        fn_id,
296                    );
297                    self.fn_effects.insert(fn_id, decl.effect);
298                }
299                TypedItem::Struct(decl) => {
300                    for (i, field) in decl.fields.iter().enumerate() {
301                        self.struct_field_index
302                            .insert((decl.name.clone(), field.name.clone()), i as u16);
303                    }
304                }
305                TypedItem::Enum(decl) => {
306                    for variant in &decl.variants {
307                        let variant_id = self.program.enum_variant_names.len() as u16;
308                        self.program
309                            .enum_variant_names
310                            .push((decl.name.clone(), variant.name.clone()));
311                        self.enum_variant_table
312                            .insert((decl.name.clone(), variant.name.clone()), variant_id);
313                        self.enum_variant_payload_count.insert(
314                            (decl.name.clone(), variant.name.clone()),
315                            variant.fields.len() as u8,
316                        );
317                    }
318                }
319                // interfaces are type-level only; the typechecker already
320                // verified structural satisfaction. no chunk, no fn-id.
321                TypedItem::Interface(_) => {}
322            }
323        }
324        // record the entry point: the fn-id of `main`, if present.
325        if let Some(&main_id) = self.fn_table.get(&FnKey {
326            type_name: None,
327            name: "main".to_string(),
328        }) {
329            self.program.main_index = main_id as usize;
330        }
331    }
332
333    /// compile one top-level item. `fn` items produce a chunk; `struct` /
334    /// `enum` / `interface` items emit nothing -- [`Codegen::build_tables`]
335    /// already recorded everything codegen needs about them.
336    fn compile_item(&mut self, item: &TypedItem) -> Result<(), QalaError> {
337        match item {
338            TypedItem::Fn(decl) => {
339                let fn_id = self
340                    .fn_table
341                    .get(&FnKey {
342                        type_name: decl.type_name.clone(),
343                        name: decl.name.clone(),
344                    })
345                    .copied()
346                    .ok_or_else(|| QalaError::Type {
347                        span: decl.span,
348                        message: format!(
349                            "codegen: function `{}` was not pre-registered",
350                            decl.name
351                        ),
352                    })?;
353                self.current_fn_id = fn_id;
354                // a fresh function: the slot-to-name accumulator starts empty.
355                self.local_names_acc.clear();
356                // the outermost scope holds the parameters in slots 0..N.
357                self.push_scope();
358                for (i, param) in decl.params.iter().enumerate() {
359                    self.register_local(param.name.clone(), i as u16);
360                }
361                let terminated = self.compile_block(&decl.body)?;
362                if !terminated {
363                    // a fall-through completion: emit the outermost scope's
364                    // defers, then a RETURN.
365                    let line = self.line_at(decl.body.span);
366                    self.emit_defers_for_exit(ExitKind::Fallthrough, line)?;
367                    self.chunk_mut().write_op(Opcode::Return, line);
368                }
369                // remove the outermost scope. its defers fired above (or at
370                // the terminator's exit); no second emission.
371                self.pop_scope_no_defers();
372                // record the slot-to-name table on the finished chunk.
373                self.finalize_local_names();
374                Ok(())
375            }
376            // type-level items: build_tables recorded what codegen needs.
377            TypedItem::Struct(_) | TypedItem::Enum(_) | TypedItem::Interface(_) => Ok(()),
378        }
379    }
380
381    // ---- chunk + line helpers ----------------------------------------------
382
383    /// the chunk currently being written.
384    fn chunk_mut(&mut self) -> &mut Chunk {
385        &mut self.program.chunks[self.current_fn_id as usize]
386    }
387
388    /// the 1-based source line of a span's start byte.
389    fn line_at(&self, span: Span) -> u32 {
390        self.line_index.location(self.src, span.start as usize).0 as u32
391    }
392
393    /// resolve a struct name to its id, registering it on first sight.
394    ///
395    /// the id is an index into [`Program::structs`]; the first struct seen
396    /// gets id 0, the next id 1, and so on. a second literal of an
397    /// already-seen struct reuses the same id. the `BTreeMap` keeps id
398    /// assignment deterministic regardless of the order struct literals
399    /// appear, matching the `enum_variant_table` precedent. `field_count`
400    /// is recorded so the VM knows how many values `MAKE_STRUCT` pops.
401    fn register_struct(&mut self, name: &str, field_count: u16) -> u16 {
402        if let Some(&id) = self.struct_id_table.get(name) {
403            return id;
404        }
405        let id = self.program.structs.len() as u16;
406        self.program.structs.push(StructInfo {
407            name: name.to_string(),
408            field_count,
409        });
410        self.struct_id_table.insert(name.to_string(), id);
411        id
412    }
413
414    // ---- scope management --------------------------------------------------
415
416    /// push a plain (non-loop) lexical scope.
417    fn push_scope(&mut self) {
418        self.scopes.push(LocalsScope {
419            locals: Vec::new(),
420            deferred: Vec::new(),
421            loop_meta: None,
422        });
423    }
424
425    /// push a loop scope: a plain scope plus the loop bookkeeping.
426    fn push_loop_scope(&mut self, continue_target: usize) {
427        self.scopes.push(LocalsScope {
428            locals: Vec::new(),
429            deferred: Vec::new(),
430            loop_meta: Some(LoopMeta {
431                break_patches: Vec::new(),
432                continue_target,
433                continue_patches: Vec::new(),
434            }),
435        });
436    }
437
438    /// pop the topmost scope WITHOUT emitting its defers. used after a
439    /// terminator (`return` / `break` / `continue`) already fired the scope's
440    /// defers at its exit -- a second emission would double-run them.
441    fn pop_scope_no_defers(&mut self) {
442        self.scopes.pop();
443    }
444
445    /// pop the topmost scope AND emit its deferred expressions, reversed
446    /// (LIFO). used at a block's fall-through exit. each defer's bytecode is
447    /// followed by a POP: a defer is evaluated for effect, its result value
448    /// discarded.
449    ///
450    /// the POP is unconditional. a deferred expression is a function call
451    /// (`defer close(f)`), and the VM pushes a result value for every call --
452    /// a `void`-returning call pushes a `void` (the VM's uniform one-value-
453    /// per-expression convention). so a void defer leaves a `void` on the
454    /// stack that MUST be popped: otherwise it sits above the function's real
455    /// return value and the next `RETURN` returns the stray `void` instead.
456    ///
457    /// IMPORTANT: the deferred expressions are collected (cloned) BEFORE the
458    /// scope is popped, so that any locals declared in this scope are still
459    /// visible to `compile_expr`. only after all defers are emitted is the
460    /// scope removed. `emit_defers_for_exit` (the control-flow exit path)
461    /// never pops a scope and therefore never had this ordering issue; this
462    /// function previously popped first, which broke `defer close(f)` on a
463    /// fall-through exit when `f` was declared in the same block.
464    fn pop_scope_and_emit_defers(&mut self, line: u32) -> Result<(), QalaError> {
465        // collect the deferred list while the scope is still visible to name
466        // resolution. cloned out so the immutable borrow of `self.scopes`
467        // ends before `compile_expr` takes a mutable borrow of `self`.
468        let deferred: Vec<TypedExpr> = match self.scopes.last() {
469            Some(s) => s.deferred.iter().rev().cloned().collect(),
470            None => return Ok(()),
471        };
472        // emit each deferred expression -- the scope's locals are still on
473        // the scope stack and resolve normally.
474        for expr in &deferred {
475            self.compile_expr(expr)?;
476            self.chunk_mut().write_op(Opcode::Pop, line);
477        }
478        // pop the scope only after all defers have been compiled.
479        self.scopes.pop();
480        Ok(())
481    }
482
483    /// emit the defers for a control-flow exit WITHOUT popping any scope.
484    /// the depth depends on the exit kind: a fall-through fires one scope; a
485    /// `return` / `?`-propagation fires every scope; a `break` / `continue`
486    /// fires scopes up to and including the innermost loop scope.
487    fn emit_defers_for_exit(&mut self, exit_kind: ExitKind, line: u32) -> Result<(), QalaError> {
488        let depth = match exit_kind {
489            ExitKind::Fallthrough => 1.min(self.scopes.len()),
490            ExitKind::Return | ExitKind::QuestionProp => self.scopes.len(),
491            ExitKind::Break | ExitKind::Continue => {
492                // count from the top down until (inclusive) the loop scope.
493                let mut count = 0usize;
494                let mut found = false;
495                for scope in self.scopes.iter().rev() {
496                    count += 1;
497                    if scope.loop_meta.is_some() {
498                        found = true;
499                        break;
500                    }
501                }
502                if !found {
503                    return Err(QalaError::Type {
504                        span: Span::new(0, 0),
505                        message: "codegen: break/continue outside a loop".to_string(),
506                    });
507                }
508                count
509            }
510        };
511        // collect the deferred expressions to emit, top scope first, each
512        // scope's list reversed. cloned out so the borrow of `self.scopes`
513        // ends before `compile_expr` borrows `self` mutably.
514        let mut to_emit: Vec<TypedExpr> = Vec::new();
515        let n = self.scopes.len();
516        for scope in self.scopes[n - depth..].iter().rev() {
517            for deferred in scope.deferred.iter().rev() {
518                to_emit.push(deferred.clone());
519            }
520        }
521        // each defer's result is discarded with an unconditional POP -- see
522        // pop_scope_and_emit_defers for why a void defer also leaves a value.
523        for deferred in &to_emit {
524            self.compile_expr(deferred)?;
525            self.chunk_mut().write_op(Opcode::Pop, line);
526        }
527        Ok(())
528    }
529
530    // ---- statement + block -------------------------------------------------
531
532    /// compile a `{ ... }` block. pushes its own lexical scope, walks the
533    /// statements (stopping at the first terminator -- dead-code elimination),
534    /// emits the trailing value if any, then emits the scope's fall-through
535    /// defers and pops the scope. returns `true` when the block was
536    /// terminated by a `return` / `break` / `continue` so a caller skips a
537    /// fall-through RETURN.
538    ///
539    /// `compile_item` does NOT push a scope itself -- it relies on this
540    /// method's scope push; arm bodies and block expressions get their own
541    /// scope the same way.
542    fn compile_block(&mut self, block: &TypedBlock) -> Result<bool, QalaError> {
543        self.push_scope();
544        let mut terminated = false;
545        for stmt in &block.stmts {
546            if self.compile_stmt(stmt)? {
547                // a terminator: the remaining statements are dead code.
548                terminated = true;
549                break;
550            }
551        }
552        if !terminated {
553            if let Some(value) = &block.value {
554                self.compile_expr(value)?;
555            }
556            let line = self.line_at(block.span);
557            // the trailing value (if any) is already on the stack; the
558            // fall-through defers fire after it, each popping its own result.
559            self.pop_scope_and_emit_defers(line)?;
560        } else {
561            // the terminator's exit already fired this scope's defers.
562            self.pop_scope_no_defers();
563        }
564        Ok(terminated)
565    }
566
567    /// compile one statement. returns `true` when the statement terminated
568    /// the surrounding block (`return` / `break` / `continue`).
569    fn compile_stmt(&mut self, stmt: &TypedStmt) -> Result<bool, QalaError> {
570        match stmt {
571            TypedStmt::Let {
572                name, init, span, ..
573            } => {
574                let line = self.line_at(*span);
575                self.compile_expr(init)?;
576                let slot = self.next_slot();
577                self.register_local(name.clone(), slot);
578                self.chunk_mut().write_op(Opcode::SetLocal, line);
579                self.chunk_mut().write_u16(slot, line);
580                Ok(false)
581            }
582            TypedStmt::If {
583                cond,
584                then_block,
585                else_branch,
586                span,
587            } => self.compile_if(cond, then_block, else_branch.as_ref(), *span),
588            TypedStmt::While { cond, body, span } => self.compile_while(cond, body, *span),
589            TypedStmt::For {
590                var,
591                iter,
592                body,
593                span,
594                ..
595            } => self.compile_for(var, iter, body, *span),
596            TypedStmt::Return { value, span } => {
597                let line = self.line_at(*span);
598                if let Some(v) = value {
599                    self.compile_expr(v)?;
600                }
601                self.emit_defers_for_exit(ExitKind::Return, line)?;
602                self.chunk_mut().write_op(Opcode::Return, line);
603                Ok(true)
604            }
605            TypedStmt::Break { span } => {
606                let line = self.line_at(*span);
607                self.emit_defers_for_exit(ExitKind::Break, line)?;
608                let pos = self.chunk_mut().emit_jump(Opcode::Jump, line);
609                // record the jump for the innermost loop scope to patch.
610                self.record_break_patch(pos)?;
611                Ok(true)
612            }
613            TypedStmt::Continue { span } => {
614                let line = self.line_at(*span);
615                self.emit_defers_for_exit(ExitKind::Continue, line)?;
616                let target = self.innermost_continue_target()?;
617                if target == usize::MAX {
618                    // inside a for loop: the increment label is not yet
619                    // emitted. emit a forward jump placeholder and record it
620                    // for back-patching by patch_loop_continues.
621                    let pos = self.chunk_mut().emit_jump(Opcode::Jump, line);
622                    self.record_continue_patch(pos)?;
623                } else {
624                    self.chunk_mut().emit_loop(target, line)?;
625                }
626                Ok(true)
627            }
628            TypedStmt::Defer { expr, .. } => {
629                // a defer is NOT emitted now -- it is appended to the topmost
630                // scope's deferred list and emitted (reversed) at every exit.
631                if let Some(scope) = self.scopes.last_mut() {
632                    scope.deferred.push(expr.clone());
633                }
634                Ok(false)
635            }
636            TypedStmt::Expr { expr, span } => {
637                let line = self.line_at(*span);
638                self.compile_expr(expr)?;
639                // an expression statement discards its value.
640                if !matches!(expr.ty(), QalaType::Void) {
641                    self.chunk_mut().write_op(Opcode::Pop, line);
642                }
643                Ok(false)
644            }
645        }
646    }
647
648    /// bind a local in the topmost scope: register `(name, slot)` for
649    /// identifier resolution AND record it in [`Codegen::local_names_acc`] so
650    /// the finished chunk can map slot indices back to source names.
651    ///
652    /// every place that introduces a slot a `GET_LOCAL` reads -- a parameter,
653    /// a `let`, a `for` variable, a `match` payload binding, a hidden
654    /// compiler temporary -- goes through here, so the accumulator is the
655    /// complete slot-to-name picture for the current function. a hidden slot
656    /// passes an empty `name` (it is never resolved by an identifier); the VM
657    /// shows `slot{i}` for an empty-named slot.
658    fn register_local(&mut self, name: String, slot: u16) {
659        if let Some(scope) = self.scopes.last_mut() {
660            scope.locals.push((name.clone(), slot));
661        }
662        self.local_names_acc.push((slot, name));
663    }
664
665    /// drain [`Codegen::local_names_acc`] into a dense slot-indexed name table
666    /// and store it on the chunk just finished. called once per function at
667    /// the end of [`Codegen::compile_item`].
668    ///
669    /// slots are dense (each [`Codegen::next_slot`] hands out the next free
670    /// index) so the table is sized to the highest slot seen. a slot the
671    /// accumulator never recorded -- and the last-write-wins for a slot a
672    /// `for` loop rebinds across nested scopes -- both resolve to the empty
673    /// string, which the VM renders as `slot{i}`.
674    fn finalize_local_names(&mut self) {
675        let max_slot = self
676            .local_names_acc
677            .iter()
678            .map(|(slot, _)| *slot as usize)
679            .max();
680        let mut names: Vec<String> = match max_slot {
681            Some(m) => vec![String::new(); m + 1],
682            None => Vec::new(),
683        };
684        for (slot, name) in self.local_names_acc.drain(..) {
685            names[slot as usize] = name;
686        }
687        self.chunk_mut().local_names = names;
688    }
689
690    /// the next free local slot in the current chunk: one past the highest
691    /// slot any scope currently binds.
692    fn next_slot(&self) -> u16 {
693        let mut max: i32 = -1;
694        for scope in &self.scopes {
695            for (_, slot) in &scope.locals {
696                max = max.max(*slot as i32);
697            }
698        }
699        (max + 1) as u16
700    }
701
702    /// record a `break`'s jump-patch site on the innermost loop scope.
703    fn record_break_patch(&mut self, pos: usize) -> Result<(), QalaError> {
704        for scope in self.scopes.iter_mut().rev() {
705            if let Some(meta) = &mut scope.loop_meta {
706                meta.break_patches.push(pos);
707                return Ok(());
708            }
709        }
710        Err(QalaError::Type {
711            span: Span::new(0, 0),
712            message: "codegen: break outside a loop".to_string(),
713        })
714    }
715
716    /// record a `continue`'s forward-jump patch site on the innermost loop
717    /// scope. used only by `for` loops where the increment label is not yet
718    /// emitted when `continue` is compiled.
719    fn record_continue_patch(&mut self, pos: usize) -> Result<(), QalaError> {
720        for scope in self.scopes.iter_mut().rev() {
721            if let Some(meta) = &mut scope.loop_meta {
722                meta.continue_patches.push(pos);
723                return Ok(());
724            }
725        }
726        Err(QalaError::Type {
727            span: Span::new(0, 0),
728            message: "codegen: continue outside a loop".to_string(),
729        })
730    }
731
732    /// the innermost loop scope's `continue` target byte offset.
733    fn innermost_continue_target(&self) -> Result<usize, QalaError> {
734        for scope in self.scopes.iter().rev() {
735            if let Some(meta) = &scope.loop_meta {
736                return Ok(meta.continue_target);
737            }
738        }
739        Err(QalaError::Type {
740            span: Span::new(0, 0),
741            message: "codegen: continue outside a loop".to_string(),
742        })
743    }
744
745    // ---- expression emission helpers ---------------------------------------
746
747    /// emit `CONST idx` for `value` on `line`: append the value to the
748    /// current chunk's constant pool, then the opcode + u16 index.
749    fn emit_const(&mut self, value: ConstValue, line: u32) {
750        let idx = self.chunk_mut().add_constant(value);
751        self.chunk_mut().write_op(Opcode::Const, line);
752        self.chunk_mut().write_u16(idx, line);
753    }
754
755    /// emit a `CALL fn_id argc` instruction. CALL carries a `u16` fn-id plus
756    /// a `u8` argc; there is no `write_u8` helper, so the one-byte argc is
757    /// pushed directly with a matching `source_lines` entry to keep the
758    /// `code.len() == source_lines.len()` invariant.
759    fn emit_call(&mut self, fn_id: u16, argc: u8, line: u32) {
760        self.chunk_mut().write_op(Opcode::Call, line);
761        self.chunk_mut().write_u16(fn_id, line);
762        let chunk = self.chunk_mut();
763        chunk.code.push(argc);
764        chunk.source_lines.push(line);
765    }
766
767    /// resolve an identifier in callee position to its fn-id, checking the
768    /// user `fn_table` first, then the stdlib table. returns `None` when the
769    /// name is neither (an enum-variant constructor or a local-bound function
770    /// value -- the caller handles those).
771    fn resolve_callee_fn_id(&self, name: &str) -> Option<u16> {
772        let key = FnKey {
773            type_name: None,
774            name: name.to_string(),
775        };
776        if let Some(&fn_id) = self.fn_table.get(&key) {
777            return Some(fn_id);
778        }
779        self.stdlib_table.get(&key).map(|(fn_id, _)| *fn_id)
780    }
781
782    /// look up a local by name, walking scopes from innermost to outermost so
783    /// an inner binding shadows an outer one. returns the slot index.
784    fn resolve_local(&self, name: &str) -> Option<u16> {
785        for scope in self.scopes.iter().rev() {
786            for (local_name, slot) in scope.locals.iter().rev() {
787                if local_name == name {
788                    return Some(*slot);
789                }
790            }
791        }
792        None
793    }
794
795    /// pick the arithmetic / comparison opcode for a binary operator given
796    /// the OPERAND type (`i64` vs `f64`). `&&` / `||` never reach this -- they
797    /// compile to short-circuit jump patterns in the `Binary` arm. string
798    /// `+` lowers to `CONCAT_N 2` and is handled inline by the caller.
799    fn emit_binop(&mut self, op: &BinOp, operand_ty: &QalaType, line: u32) {
800        let is_float = matches!(operand_ty, QalaType::F64);
801        let opcode = match (op, is_float) {
802            (BinOp::Add, false) => Opcode::Add,
803            (BinOp::Add, true) => Opcode::FAdd,
804            (BinOp::Sub, false) => Opcode::Sub,
805            (BinOp::Sub, true) => Opcode::FSub,
806            (BinOp::Mul, false) => Opcode::Mul,
807            (BinOp::Mul, true) => Opcode::FMul,
808            (BinOp::Div, false) => Opcode::Div,
809            (BinOp::Div, true) => Opcode::FDiv,
810            (BinOp::Rem, _) => Opcode::Mod,
811            (BinOp::Eq, false) => Opcode::Eq,
812            (BinOp::Eq, true) => Opcode::FEq,
813            (BinOp::Ne, false) => Opcode::Ne,
814            (BinOp::Ne, true) => Opcode::FNe,
815            (BinOp::Lt, false) => Opcode::Lt,
816            (BinOp::Lt, true) => Opcode::FLt,
817            (BinOp::Le, false) => Opcode::Le,
818            (BinOp::Le, true) => Opcode::FLe,
819            (BinOp::Gt, false) => Opcode::Gt,
820            (BinOp::Gt, true) => Opcode::FGt,
821            (BinOp::Ge, false) => Opcode::Ge,
822            (BinOp::Ge, true) => Opcode::FGe,
823            // And / Or never reach emit_binop; if they somehow do, NOT is a
824            // harmless no-payload placeholder. the Binary arm prevents this.
825            (BinOp::And, _) | (BinOp::Or, _) => Opcode::Not,
826        };
827        self.chunk_mut().write_op(opcode, line);
828    }
829
830    /// emit the opcode for a unary operator given the operand type.
831    fn emit_unop(&mut self, op: &UnaryOp, operand_ty: &QalaType, line: u32) {
832        let opcode = match op {
833            UnaryOp::Not => Opcode::Not,
834            UnaryOp::Neg => {
835                if matches!(operand_ty, QalaType::F64) {
836                    Opcode::FNeg
837                } else {
838                    Opcode::Neg
839                }
840            }
841        };
842        self.chunk_mut().write_op(opcode, line);
843    }
844
845    /// try to constant-fold a binary operator. `lhs_start` / `rhs_start` are
846    /// the chunk byte offsets where the lhs and the rhs sub-expressions began
847    /// emitting; `end` is the current end of `code`. a fold happens only when
848    /// the lhs region is exactly one `CONST` instruction (3 bytes) and the
849    /// rhs region is exactly one `CONST` instruction (3 bytes) -- this is the
850    /// structural, non-heuristic check (the `Const` discriminant is 0, so a
851    /// byte-pattern peek would mis-fire on operand bytes).
852    ///
853    /// returns `Ok(true)` when a fold happened (the two source CONSTs are
854    /// rewound and one result CONST emitted on `line`), `Ok(false)` when no
855    /// fold applies (the caller emits the opcode), `Err` on an i64 overflow.
856    fn try_fold_binary(
857        &mut self,
858        op: &BinOp,
859        operand_ty: &QalaType,
860        line: u32,
861        span: Span,
862        lhs_start: usize,
863        rhs_start: usize,
864    ) -> Result<bool, QalaError> {
865        let chunk = self.chunk_mut();
866        let end = chunk.code.len();
867        // each operand must have emitted exactly one 3-byte CONST.
868        if rhs_start - lhs_start != 3 || end - rhs_start != 3 {
869            return Ok(false);
870        }
871        if chunk.code[lhs_start] != Opcode::Const as u8
872            || chunk.code[rhs_start] != Opcode::Const as u8
873        {
874            return Ok(false);
875        }
876        let a_idx = chunk.read_u16(lhs_start + 1);
877        let b_idx = chunk.read_u16(rhs_start + 1);
878        if a_idx as usize >= chunk.constants.len() || b_idx as usize >= chunk.constants.len() {
879            return Ok(false);
880        }
881        let a = chunk.constants[a_idx as usize].clone();
882        let b = chunk.constants[b_idx as usize].clone();
883        let result = match fold_binary_value(&a, &b, op, operand_ty, span)? {
884            Some(v) => v,
885            None => return Ok(false),
886        };
887        // rewind both source CONSTs, then emit the folded result.
888        let chunk = self.chunk_mut();
889        chunk.code.truncate(lhs_start);
890        chunk.source_lines.truncate(lhs_start);
891        self.emit_const(result, line);
892        Ok(true)
893    }
894
895    /// try to constant-fold a unary operator whose operand was just emitted
896    /// as a CONST. `operand_start` is the chunk offset where the operand
897    /// began emitting; the fold runs only when the operand compiled to
898    /// exactly one 3-byte CONST. returns `Ok(true)` on a fold, `Ok(false)`
899    /// when none applies, `Err` on an i64 negation overflow (`-i64::MIN`).
900    fn try_fold_unary(
901        &mut self,
902        op: &UnaryOp,
903        line: u32,
904        span: Span,
905        operand_start: usize,
906    ) -> Result<bool, QalaError> {
907        let chunk = self.chunk_mut();
908        let end = chunk.code.len();
909        // the operand must have emitted exactly one 3-byte CONST.
910        if end - operand_start != 3 || chunk.code[operand_start] != Opcode::Const as u8 {
911            return Ok(false);
912        }
913        let idx = chunk.read_u16(operand_start + 1);
914        if idx as usize >= chunk.constants.len() {
915            return Ok(false);
916        }
917        let off = operand_start;
918        let v = chunk.constants[idx as usize].clone();
919        let result = match (op, &v) {
920            (UnaryOp::Not, ConstValue::Bool(b)) => ConstValue::Bool(!b),
921            (UnaryOp::Neg, ConstValue::I64(n)) => {
922                // -x overflows only at i64::MIN; surface as IntegerOverflow.
923                // the synthetic op is Sub because `-x` equals `0 - x`; a
924                // future enhancement could widen IntegerOverflow to carry a
925                // UnaryOp.
926                let r = n.checked_neg().ok_or(QalaError::IntegerOverflow {
927                    span,
928                    op: BinOp::Sub,
929                    lhs: 0,
930                    rhs: *n,
931                })?;
932                ConstValue::I64(r)
933            }
934            (UnaryOp::Neg, ConstValue::F64(x)) => ConstValue::F64(-x),
935            // any other operand: no fold.
936            _ => return Ok(false),
937        };
938        let chunk = self.chunk_mut();
939        chunk.code.truncate(off);
940        chunk.source_lines.truncate(off);
941        self.emit_const(result, line);
942        Ok(true)
943    }
944
945    // ---- expression dispatch -----------------------------------------------
946
947    /// compile one expression, leaving its value on the stack.
948    ///
949    /// Task 2 covers primitives, identifiers, parenthesized expressions,
950    /// unary / binary operators (with inline constant folding), and the
951    /// simple ident-callee call. Task 3 adds the compound expressions and
952    /// `match`; Task 5 adds `comptime`.
953    fn compile_expr(&mut self, e: &TypedExpr) -> Result<(), QalaError> {
954        match e {
955            TypedExpr::Int { value, span, .. } => {
956                let line = self.line_at(*span);
957                self.emit_const(ConstValue::I64(*value), line);
958                Ok(())
959            }
960            TypedExpr::Float { value, span, .. } => {
961                let line = self.line_at(*span);
962                self.emit_const(ConstValue::F64(*value), line);
963                Ok(())
964            }
965            TypedExpr::Byte { value, span, .. } => {
966                let line = self.line_at(*span);
967                self.emit_const(ConstValue::Byte(*value), line);
968                Ok(())
969            }
970            TypedExpr::Str { value, span, .. } => {
971                let line = self.line_at(*span);
972                self.emit_const(ConstValue::Str(value.clone()), line);
973                Ok(())
974            }
975            TypedExpr::Bool { value, span, .. } => {
976                let line = self.line_at(*span);
977                self.emit_const(ConstValue::Bool(*value), line);
978                Ok(())
979            }
980            TypedExpr::Ident { name, span, .. } => {
981                let line = self.line_at(*span);
982                if let Some(slot) = self.resolve_local(name) {
983                    self.chunk_mut().write_op(Opcode::GetLocal, line);
984                    self.chunk_mut().write_u16(slot, line);
985                } else if let Some(fn_id) = self.resolve_callee_fn_id(name) {
986                    // a bare function name used as a value -- a function
987                    // reference. emitted as a CONST of ConstValue::Function.
988                    self.emit_const(ConstValue::Function(fn_id), line);
989                } else {
990                    // the typechecker would have rejected an unresolved name;
991                    // defense-in-depth.
992                    return Err(QalaError::Type {
993                        span: *span,
994                        message: format!("codegen: unresolved name `{name}`"),
995                    });
996                }
997                Ok(())
998            }
999            TypedExpr::Paren { inner, .. } => self.compile_expr(inner),
1000            TypedExpr::Unary {
1001                op, operand, span, ..
1002            } => {
1003                let operand_start = self.chunk_mut().code.len();
1004                self.compile_expr(operand)?;
1005                let line = self.line_at(*span);
1006                if !self.try_fold_unary(op, line, *span, operand_start)? {
1007                    self.emit_unop(op, operand.ty(), line);
1008                }
1009                Ok(())
1010            }
1011            TypedExpr::Binary {
1012                op, lhs, rhs, span, ..
1013            } => {
1014                let line = self.line_at(*span);
1015                if matches!(op, BinOp::And | BinOp::Or) {
1016                    // short-circuit: compile lhs, DUP it, conditionally jump
1017                    // past rhs, otherwise POP the lhs copy and evaluate rhs.
1018                    // `&&` jumps past on a false lhs; `||` jumps past on true.
1019                    let lhs_start = self.chunk_mut().code.len();
1020                    self.compile_expr(lhs)?;
1021                    // a literal lhs+rhs both-CONST case still folds.
1022                    if self.try_fold_short_circuit(op, rhs, line, lhs_start)? {
1023                        return Ok(());
1024                    }
1025                    self.chunk_mut().write_op(Opcode::Dup, line);
1026                    let skip = if matches!(op, BinOp::And) {
1027                        self.chunk_mut().emit_jump(Opcode::JumpIfFalse, line)
1028                    } else {
1029                        self.chunk_mut().emit_jump(Opcode::JumpIfTrue, line)
1030                    };
1031                    self.chunk_mut().write_op(Opcode::Pop, line);
1032                    self.compile_expr(rhs)?;
1033                    self.chunk_mut().patch_jump(skip)?;
1034                    Ok(())
1035                } else {
1036                    let lhs_start = self.chunk_mut().code.len();
1037                    self.compile_expr(lhs)?;
1038                    let rhs_start = self.chunk_mut().code.len();
1039                    self.compile_expr(rhs)?;
1040                    if !self.try_fold_binary(op, lhs.ty(), line, *span, lhs_start, rhs_start)? {
1041                        self.emit_binop(op, lhs.ty(), line);
1042                    }
1043                    Ok(())
1044                }
1045            }
1046            TypedExpr::Call {
1047                callee, args, span, ..
1048            } => {
1049                let line = self.line_at(*span);
1050                self.compile_call(callee, args, line, *span)
1051            }
1052            TypedExpr::Pipeline {
1053                lhs, call, span, ..
1054            } => {
1055                let line = self.line_at(*span);
1056                self.compile_pipeline(lhs, call, line, *span)
1057            }
1058            TypedExpr::Interpolation { parts, span, .. } => {
1059                let line = self.line_at(*span);
1060                self.compile_interpolation(parts, line)
1061            }
1062            TypedExpr::Try { expr, span, .. } => {
1063                let line = self.line_at(*span);
1064                self.compile_try(expr, line, *span)
1065            }
1066            TypedExpr::OrElse {
1067                expr,
1068                fallback,
1069                span,
1070                ..
1071            } => {
1072                let line = self.line_at(*span);
1073                self.compile_or_else(expr, fallback, line, *span)
1074            }
1075            TypedExpr::MethodCall {
1076                receiver,
1077                name,
1078                args,
1079                span,
1080                ..
1081            } => {
1082                let line = self.line_at(*span);
1083                self.compile_method_call(receiver, name, args, line, *span)
1084            }
1085            TypedExpr::FieldAccess {
1086                obj, name, span, ..
1087            } => {
1088                let line = self.line_at(*span);
1089                self.compile_expr(obj)?;
1090                // resolve the struct + field to the FIELD operand index.
1091                let field_idx = self.resolve_field_index(obj.ty(), name, *span)?;
1092                self.chunk_mut().write_op(Opcode::Field, line);
1093                self.chunk_mut().write_u16(field_idx, line);
1094                Ok(())
1095            }
1096            TypedExpr::Index {
1097                obj, index, span, ..
1098            } => {
1099                let line = self.line_at(*span);
1100                self.compile_expr(obj)?;
1101                self.compile_expr(index)?;
1102                self.chunk_mut().write_op(Opcode::Index, line);
1103                Ok(())
1104            }
1105            TypedExpr::Tuple { elems, span, .. } => {
1106                let line = self.line_at(*span);
1107                for elem in elems {
1108                    self.compile_expr(elem)?;
1109                }
1110                self.chunk_mut().write_op(Opcode::MakeTuple, line);
1111                self.chunk_mut().write_u16(elems.len() as u16, line);
1112                Ok(())
1113            }
1114            TypedExpr::ArrayLit { elems, span, .. } => {
1115                let line = self.line_at(*span);
1116                for elem in elems {
1117                    self.compile_expr(elem)?;
1118                }
1119                self.chunk_mut().write_op(Opcode::MakeArray, line);
1120                self.chunk_mut().write_u16(elems.len() as u16, line);
1121                Ok(())
1122            }
1123            TypedExpr::ArrayRepeat {
1124                value, count, span, ..
1125            } => {
1126                let line = self.line_at(*span);
1127                self.compile_array_repeat(value, count, line, *span)
1128            }
1129            TypedExpr::StructLit {
1130                name, fields, span, ..
1131            } => {
1132                let line = self.line_at(*span);
1133                // emit field values in declaration order. the typechecker
1134                // already verified the field set; codegen trusts it.
1135                for field in fields {
1136                    self.compile_expr(&field.value)?;
1137                }
1138                // register the struct (name + field count) in Program.structs
1139                // and emit its id as the MAKE_STRUCT operand, so the VM can
1140                // label the heap struct with its declared name.
1141                let struct_id = self.register_struct(name, fields.len() as u16);
1142                self.chunk_mut().write_op(Opcode::MakeStruct, line);
1143                self.chunk_mut().write_u16(struct_id, line);
1144                Ok(())
1145            }
1146            TypedExpr::Range {
1147                start,
1148                end,
1149                inclusive,
1150                span,
1151                ..
1152            } => {
1153                let line = self.line_at(*span);
1154                self.compile_range(start.as_deref(), end.as_deref(), *inclusive, line, *span)
1155            }
1156            TypedExpr::Block { block, .. } => {
1157                // a block expression: compile_block runs its statements and
1158                // leaves the trailing value on the stack.
1159                self.compile_block(block)?;
1160                Ok(())
1161            }
1162            TypedExpr::Match {
1163                scrutinee,
1164                arms,
1165                span,
1166                ..
1167            } => {
1168                let line = self.line_at(*span);
1169                self.compile_match(scrutinee, arms, line, *span)
1170            }
1171            TypedExpr::Comptime { body, span, .. } => {
1172                let line = self.line_at(*span);
1173                self.compile_comptime(body, line, *span)
1174            }
1175        }
1176    }
1177
1178    /// compile an `&&` / `||` whose operands may both be literal CONSTs.
1179    /// `lhs_start` is the chunk offset where the (already-emitted) lhs began.
1180    /// when the lhs compiled to exactly one CONST, the rhs is compiled and
1181    /// [`try_fold_binary`] attempts the fold. returns `Ok(true)` when the
1182    /// whole short-circuit folded to one CONST; `Ok(false)` otherwise, with
1183    /// any rhs emission rewound so the caller emits the jump pattern from a
1184    /// clean state (just the lhs).
1185    fn try_fold_short_circuit(
1186        &mut self,
1187        op: &BinOp,
1188        rhs: &TypedExpr,
1189        line: u32,
1190        lhs_start: usize,
1191    ) -> Result<bool, QalaError> {
1192        // the lhs must have emitted exactly one 3-byte CONST.
1193        let rhs_start = self.chunk_mut().code.len();
1194        if rhs_start - lhs_start != 3 || self.chunk_mut().code[lhs_start] != Opcode::Const as u8 {
1195            return Ok(false);
1196        }
1197        self.compile_expr(rhs)?;
1198        if self.try_fold_binary(op, &QalaType::Bool, line, rhs.span(), lhs_start, rhs_start)? {
1199            return Ok(true);
1200        }
1201        // no fold: rewind the rhs emission so the caller emits the
1202        // short-circuit jump pattern from a clean state (just the lhs CONST).
1203        let chunk = self.chunk_mut();
1204        chunk.code.truncate(rhs_start);
1205        chunk.source_lines.truncate(rhs_start);
1206        Ok(false)
1207    }
1208
1209    /// compile a call: emit the arguments left-to-right then dispatch. the
1210    /// callee is always a [`TypedExpr::Ident`] in v1 -- either an enum-variant
1211    /// constructor (`Circle(5.0)`, `Ok(x)`), a user / stdlib function, or a
1212    /// local-bound function value.
1213    fn compile_call(
1214        &mut self,
1215        callee: &TypedExpr,
1216        args: &[TypedExpr],
1217        line: u32,
1218        span: Span,
1219    ) -> Result<(), QalaError> {
1220        let name = match callee {
1221            TypedExpr::Ident { name, .. } => name.clone(),
1222            // a non-ident callee: compile it as a function value, emit args,
1223            // then CALL via the value on the stack. v1's typechecker makes
1224            // this rare; treat the callee's resolved fn-id as unavailable.
1225            _ => {
1226                return Err(QalaError::Type {
1227                    span,
1228                    message: "codegen: only named callees are supported in v1".to_string(),
1229                });
1230            }
1231        };
1232        // enum-variant constructor: emit args, then MAKE_ENUM_VARIANT.
1233        // covers user enum variants AND the built-in Ok / Err / Some / None.
1234        if let Some(&variant_id) = self.enum_variant_lookup(&name) {
1235            for arg in args {
1236                self.compile_expr(arg)?;
1237            }
1238            let payload = args.len() as u8;
1239            self.chunk_mut().write_op(Opcode::MakeEnumVariant, line);
1240            self.chunk_mut().write_u16(variant_id, line);
1241            let chunk = self.chunk_mut();
1242            chunk.code.push(payload);
1243            chunk.source_lines.push(line);
1244            return Ok(());
1245        }
1246        // a user or stdlib function call.
1247        if let Some(fn_id) = self.resolve_callee_fn_id(&name) {
1248            for arg in args {
1249                self.compile_expr(arg)?;
1250            }
1251            self.emit_call(fn_id, args.len() as u8, line);
1252            return Ok(());
1253        }
1254        Err(QalaError::Type {
1255            span,
1256            message: format!("codegen: unresolved callee `{name}`"),
1257        })
1258    }
1259
1260    /// look up an enum-variant id by the variant's bare name -- the form a
1261    /// constructor call uses. searches every `(enum, variant)` pair for a
1262    /// matching variant name. ambiguous names (the same variant name in two
1263    /// enums) resolve to the first match; the typechecker has already pinned
1264    /// the concrete enum, so v1's bundled examples never collide.
1265    fn enum_variant_lookup(&self, variant_name: &str) -> Option<&u16> {
1266        self.enum_variant_table
1267            .iter()
1268            .find(|((_, v), _)| v == variant_name)
1269            .map(|(_, id)| id)
1270    }
1271
1272    // ---- comptime ----------------------------------------------------------
1273
1274    /// compile a `comptime { body }` expression. the body is compiled into a
1275    /// throwaway chunk, run by the [`ComptimeInterpreter`] under the 100K
1276    /// instruction budget, and the result embedded as a single `CONST` in the
1277    /// surrounding chunk.
1278    ///
1279    /// the result must be a constant-pool-representable value -- a primitive
1280    /// or a string; an array / struct / enum-variant result is rejected with
1281    /// [`QalaError::ComptimeResultNotConstable`].
1282    fn compile_comptime(
1283        &mut self,
1284        body: &TypedExpr,
1285        line: u32,
1286        span: Span,
1287    ) -> Result<(), QalaError> {
1288        // compile the body into a throwaway chunk appended at the END of
1289        // program.chunks (so the existing dense fn-ids 0..N are untouched),
1290        // then pop it back off. the body's own scopes are isolated by
1291        // swapping in a fresh scope stack.
1292        let throwaway_id = self.program.chunks.len() as u16;
1293        self.program.chunks.push(Chunk::new());
1294        let saved_fn_id = self.current_fn_id;
1295        let saved_scopes = std::mem::take(&mut self.scopes);
1296        self.current_fn_id = throwaway_id;
1297        self.push_scope();
1298        let compile_result = self.compile_expr(body);
1299        // append a RETURN so the interpreter has a halt point: the body's
1300        // value is on the stack, RETURN hands it back.
1301        self.chunk_mut().write_op(Opcode::Return, line);
1302        // detach the throwaway chunk and restore the real compile state.
1303        let throwaway = self.program.chunks.pop().ok_or_else(|| QalaError::Parse {
1304            span,
1305            message: "internal: comptime chunk was removed during compilation".to_string(),
1306        })?;
1307        self.current_fn_id = saved_fn_id;
1308        self.scopes = saved_scopes;
1309        compile_result?;
1310        // run the throwaway chunk on the comptime interpreter.
1311        let mut interp = ComptimeInterpreter::new(&self.program, &self.fn_effects, span);
1312        let value = interp.run(throwaway)?;
1313        // the result must be representable in the constant pool.
1314        if !is_constable_primitive(&value) {
1315            return Err(QalaError::ComptimeResultNotConstable {
1316                span,
1317                type_name: value_type_name(&value),
1318            });
1319        }
1320        // embed the result as a single CONST.
1321        self.emit_const(value, line);
1322        Ok(())
1323    }
1324
1325    // ---- compound-expression helpers ---------------------------------------
1326
1327    /// compile a pipeline `lhs |> call`: desugar to a call with `lhs`
1328    /// prepended to the call's arguments. the `call` node is either a bare
1329    /// `Ident` (a unary call -- `lhs` is the only argument) or a `Call` (its
1330    /// args follow `lhs`). the emitted CALL's source line is the pipeline's
1331    /// own span -- a runtime error in the callee then points at the `|>`.
1332    fn compile_pipeline(
1333        &mut self,
1334        lhs: &TypedExpr,
1335        call: &TypedExpr,
1336        line: u32,
1337        span: Span,
1338    ) -> Result<(), QalaError> {
1339        match call {
1340            // `lhs |> f` -- f takes lhs as its single argument.
1341            TypedExpr::Ident { name, .. } => {
1342                self.compile_expr(lhs)?;
1343                self.dispatch_named_call(name, 1, line, span)
1344            }
1345            // `lhs |> f(a, b)` -- lhs is prepended: f(lhs, a, b).
1346            TypedExpr::Call { callee, args, .. } => {
1347                let name = match callee.as_ref() {
1348                    TypedExpr::Ident { name, .. } => name.clone(),
1349                    _ => {
1350                        return Err(QalaError::Type {
1351                            span,
1352                            message: "codegen: pipeline callee must be a named function"
1353                                .to_string(),
1354                        });
1355                    }
1356                };
1357                self.compile_expr(lhs)?;
1358                for arg in args {
1359                    self.compile_expr(arg)?;
1360                }
1361                self.dispatch_named_call(&name, (args.len() + 1) as u8, line, span)
1362            }
1363            _ => Err(QalaError::Type {
1364                span,
1365                message: "codegen: pipeline right-hand side must be a call".to_string(),
1366            }),
1367        }
1368    }
1369
1370    /// emit the dispatch for a named call whose arguments are already on the
1371    /// stack. resolves the name as an enum-variant constructor first, then a
1372    /// user / stdlib function. `argc` is the count of values on the stack.
1373    fn dispatch_named_call(
1374        &mut self,
1375        name: &str,
1376        argc: u8,
1377        line: u32,
1378        span: Span,
1379    ) -> Result<(), QalaError> {
1380        if let Some(&variant_id) = self.enum_variant_lookup(name) {
1381            self.chunk_mut().write_op(Opcode::MakeEnumVariant, line);
1382            self.chunk_mut().write_u16(variant_id, line);
1383            let chunk = self.chunk_mut();
1384            chunk.code.push(argc);
1385            chunk.source_lines.push(line);
1386            return Ok(());
1387        }
1388        if let Some(fn_id) = self.resolve_callee_fn_id(name) {
1389            self.emit_call(fn_id, argc, line);
1390            return Ok(());
1391        }
1392        Err(QalaError::Type {
1393            span,
1394            message: format!("codegen: unresolved callee `{name}`"),
1395        })
1396    }
1397
1398    /// compile a string interpolation. each literal part emits a `CONST(Str)`;
1399    /// each expression part emits the expression then `TO_STR` when its type
1400    /// is not already `str`; a `CONCAT_N(part_count)` joins them. an
1401    /// all-literal interpolation folds to a single `CONST` at codegen time.
1402    fn compile_interpolation(
1403        &mut self,
1404        parts: &[TypedInterpPart],
1405        line: u32,
1406    ) -> Result<(), QalaError> {
1407        // all-literal fast path: concatenate at codegen time.
1408        if parts
1409            .iter()
1410            .all(|p| matches!(p, TypedInterpPart::Literal(_)))
1411        {
1412            let mut s = String::new();
1413            for part in parts {
1414                if let TypedInterpPart::Literal(text) = part {
1415                    s.push_str(text);
1416                }
1417            }
1418            self.emit_const(ConstValue::Str(s), line);
1419            return Ok(());
1420        }
1421        // emit each non-empty part; an empty literal (the parser always
1422        // brackets an interpolation with literal text, often empty) adds no
1423        // useful CONST and would inflate the CONCAT_N count.
1424        let mut emitted = 0u16;
1425        for part in parts {
1426            match part {
1427                TypedInterpPart::Literal(text) => {
1428                    if text.is_empty() {
1429                        continue;
1430                    }
1431                    self.emit_const(ConstValue::Str(text.clone()), line);
1432                    emitted += 1;
1433                }
1434                TypedInterpPart::Expr(e) => {
1435                    self.compile_expr(e)?;
1436                    if !matches!(e.ty(), QalaType::Str) {
1437                        self.chunk_mut().write_op(Opcode::ToStr, line);
1438                    }
1439                    emitted += 1;
1440                }
1441            }
1442        }
1443        // a single emitted part is already the whole string -- no CONCAT_N.
1444        if emitted == 1 {
1445            return Ok(());
1446        }
1447        // zero parts (an empty interpolated string) -> the empty string.
1448        if emitted == 0 {
1449            self.emit_const(ConstValue::Str(String::new()), line);
1450            return Ok(());
1451        }
1452        self.chunk_mut().write_op(Opcode::ConcatN, line);
1453        self.chunk_mut().write_u16(emitted, line);
1454        Ok(())
1455    }
1456
1457    /// compile a `?` propagation. the inner expression yields a `Result` or
1458    /// `Option`; on the success variant the payload is unwrapped to the
1459    /// stack, on the failure variant the scope's defers fire and the original
1460    /// `Err` / `None` is returned.
1461    fn compile_try(&mut self, inner: &TypedExpr, line: u32, span: Span) -> Result<(), QalaError> {
1462        self.compile_expr(inner)?;
1463        // the success-variant id: Ok (0) for Result, Some (2) for Option.
1464        let ok_id = match inner.ty() {
1465            QalaType::Result(_, _) => 0u16,
1466            QalaType::Option(_) => 2u16,
1467            _ => {
1468                return Err(QalaError::Type {
1469                    span,
1470                    message: "codegen: `?` operand is not a Result or Option".to_string(),
1471                });
1472            }
1473        };
1474        // MATCH_VARIANT(ok_id, miss-> err_branch) directly on the scrutinee --
1475        // no DUP. `?` is a single Ok-or-Err test, not a multi-arm re-test, so
1476        // one copy of the scrutinee suffices. on a HIT, MATCH_VARIANT consumes
1477        // the scrutinee and pushes the Ok/Some payload, leaving exactly the
1478        // unwrapped value on the stack. on a MISS it leaves the scrutinee
1479        // untouched and branches -- and that scrutinee IS the Err/None value
1480        // the err branch RETURNs.
1481        self.chunk_mut().write_op(Opcode::MatchVariant, line);
1482        self.chunk_mut().write_u16(ok_id, line);
1483        let miss_patch = self.chunk_mut().code.len();
1484        self.chunk_mut().write_i16(i16::MAX, line);
1485        // hit: MATCH_VARIANT already left only the unwrapped payload -- there
1486        // is nothing to pop. jump past the err branch.
1487        let skip_err = self.chunk_mut().emit_jump(Opcode::Jump, line);
1488        // err branch: the MATCH_VARIANT missed and left the scrutinee on the
1489        // stack. fire the defers up to the fn body, then RETURN -- the
1490        // scrutinee IS the Err/None value the function returns.
1491        self.chunk_mut().patch_jump(miss_patch)?;
1492        self.emit_defers_for_exit(ExitKind::QuestionProp, line)?;
1493        self.chunk_mut().write_op(Opcode::Return, line);
1494        // end of try: the unwrapped success payload is on the stack.
1495        self.chunk_mut().patch_jump(skip_err)?;
1496        Ok(())
1497    }
1498
1499    /// compile an `expr or fallback`. on the success variant the payload is
1500    /// unwrapped; on the failure variant the fallback expression's value is
1501    /// used instead.
1502    fn compile_or_else(
1503        &mut self,
1504        expr: &TypedExpr,
1505        fallback: &TypedExpr,
1506        line: u32,
1507        span: Span,
1508    ) -> Result<(), QalaError> {
1509        self.compile_expr(expr)?;
1510        let ok_id = match expr.ty() {
1511            QalaType::Result(_, _) => 0u16,
1512            QalaType::Option(_) => 2u16,
1513            _ => {
1514                return Err(QalaError::Type {
1515                    span,
1516                    message: "codegen: `or` operand is not a Result or Option".to_string(),
1517                });
1518            }
1519        };
1520        // MATCH_VARIANT directly on the scrutinee -- no DUP, the same single-
1521        // test reasoning as `?`. on a HIT it consumes the scrutinee and pushes
1522        // the unwrapped success payload. on a MISS it leaves the scrutinee on
1523        // the stack and branches.
1524        self.chunk_mut().write_op(Opcode::MatchVariant, line);
1525        self.chunk_mut().write_u16(ok_id, line);
1526        let miss_patch = self.chunk_mut().code.len();
1527        self.chunk_mut().write_i16(i16::MAX, line);
1528        // hit: MATCH_VARIANT already left only the unwrapped payload. jump past
1529        // the fallback.
1530        let skip_fallback = self.chunk_mut().emit_jump(Opcode::Jump, line);
1531        // miss: the scrutinee MATCH_VARIANT left is the Err/None value, no
1532        // longer needed -- POP it, then evaluate the fallback in its place.
1533        self.chunk_mut().patch_jump(miss_patch)?;
1534        self.chunk_mut().write_op(Opcode::Pop, line);
1535        self.compile_expr(fallback)?;
1536        self.chunk_mut().patch_jump(skip_fallback)?;
1537        Ok(())
1538    }
1539
1540    /// compile a method call `receiver.name(args)`. the receiver is emitted
1541    /// as the first argument, then the explicit args; the method resolves to
1542    /// a `fn Type.name` via the receiver's type.
1543    fn compile_method_call(
1544        &mut self,
1545        receiver: &TypedExpr,
1546        name: &str,
1547        args: &[TypedExpr],
1548        line: u32,
1549        span: Span,
1550    ) -> Result<(), QalaError> {
1551        // the receiver type's name keys the method lookup.
1552        let type_name = match receiver.ty() {
1553            QalaType::Named(Symbol(s)) => s.clone(),
1554            QalaType::FileHandle => "FileHandle".to_string(),
1555            other => {
1556                return Err(QalaError::Type {
1557                    span,
1558                    message: format!(
1559                        "codegen: method call on non-named type `{}`",
1560                        other.display()
1561                    ),
1562                });
1563            }
1564        };
1565        let key = FnKey {
1566            type_name: Some(type_name.clone()),
1567            name: name.to_string(),
1568        };
1569        let fn_id = self
1570            .fn_table
1571            .get(&key)
1572            .copied()
1573            .or_else(|| self.stdlib_table.get(&key).map(|(id, _)| *id))
1574            .ok_or_else(|| QalaError::Type {
1575                span,
1576                message: format!("codegen: unresolved method `{type_name}.{name}`"),
1577            })?;
1578        // the receiver is argument 0; the explicit args follow.
1579        self.compile_expr(receiver)?;
1580        for arg in args {
1581            self.compile_expr(arg)?;
1582        }
1583        self.emit_call(fn_id, (args.len() + 1) as u8, line);
1584        Ok(())
1585    }
1586
1587    /// resolve a `struct.field` access to the `FIELD` operand index. the
1588    /// index is the field's declaration order within the struct.
1589    fn resolve_field_index(
1590        &self,
1591        obj_ty: &QalaType,
1592        field_name: &str,
1593        span: Span,
1594    ) -> Result<u16, QalaError> {
1595        let struct_name = match obj_ty {
1596            QalaType::Named(Symbol(s)) => s.clone(),
1597            other => {
1598                return Err(QalaError::Type {
1599                    span,
1600                    message: format!(
1601                        "codegen: field access on non-struct type `{}`",
1602                        other.display()
1603                    ),
1604                });
1605            }
1606        };
1607        self.struct_field_index
1608            .get(&(struct_name.clone(), field_name.to_string()))
1609            .copied()
1610            .ok_or_else(|| QalaError::Type {
1611                span,
1612                message: format!("codegen: no field `{field_name}` on `{struct_name}`"),
1613            })
1614    }
1615
1616    /// compile an array-repeat `[value; count]`. v1 requires the count to be
1617    /// a literal `i64`; the value is emitted `count` times then a
1618    /// `MAKE_ARRAY(count)`. the count is capped at 1024 -- a larger repeat is
1619    /// a v1 limitation, a future `MAKE_ARRAY_REPEAT` opcode lifts it.
1620    fn compile_array_repeat(
1621        &mut self,
1622        value: &TypedExpr,
1623        count: &TypedExpr,
1624        line: u32,
1625        span: Span,
1626    ) -> Result<(), QalaError> {
1627        let n = match count {
1628            TypedExpr::Int { value: n, .. } if *n >= 0 => *n,
1629            _ => {
1630                return Err(QalaError::Parse {
1631                    span,
1632                    message: "array repeat count must be a non-negative integer literal (v1)"
1633                        .to_string(),
1634                });
1635            }
1636        };
1637        if n > 1024 {
1638            return Err(QalaError::Parse {
1639                span,
1640                message: "array repeat count too large (v1 limit is 1024)".to_string(),
1641            });
1642        }
1643        for _ in 0..n {
1644            self.compile_expr(value)?;
1645        }
1646        self.chunk_mut().write_op(Opcode::MakeArray, line);
1647        self.chunk_mut().write_u16(n as u16, line);
1648        Ok(())
1649    }
1650
1651    /// compile a range `start..end` / `start..=end`. v1 materializes the
1652    /// range to a fixed array of `i64`s -- it requires literal bounds and
1653    /// caps the length at 1024. a real lazy range iterator is a v2 concern.
1654    fn compile_range(
1655        &mut self,
1656        start: Option<&TypedExpr>,
1657        end: Option<&TypedExpr>,
1658        inclusive: bool,
1659        line: u32,
1660        span: Span,
1661    ) -> Result<(), QalaError> {
1662        let bound = |e: Option<&TypedExpr>| match e {
1663            Some(TypedExpr::Int { value, .. }) => Some(*value),
1664            _ => None,
1665        };
1666        let (Some(lo), Some(hi)) = (bound(start), bound(end)) else {
1667            return Err(QalaError::Parse {
1668                span,
1669                message: "range bounds must be integer literals in v1 (a runtime range \
1670                          iterator is a v2 feature)"
1671                    .to_string(),
1672            });
1673        };
1674        let last = if inclusive { hi } else { hi - 1 };
1675        if last < lo {
1676            // an empty range materializes to an empty array.
1677            self.chunk_mut().write_op(Opcode::MakeArray, line);
1678            self.chunk_mut().write_u16(0, line);
1679            return Ok(());
1680        }
1681        let count = last - lo + 1;
1682        if count > 1024 {
1683            return Err(QalaError::Parse {
1684                span,
1685                message: "range too large to materialize (v1 limit is 1024 elements)".to_string(),
1686            });
1687        }
1688        for v in lo..=last {
1689            self.emit_const(ConstValue::I64(v), line);
1690        }
1691        self.chunk_mut().write_op(Opcode::MakeArray, line);
1692        self.chunk_mut().write_u16(count as u16, line);
1693        Ok(())
1694    }
1695
1696    // ---- control-flow statement helpers ------------------------------------
1697
1698    /// compile an `if` statement. a literal-`true` / literal-`false` condition
1699    /// is dead-code-eliminated: only the taken branch compiles, no JUMP is
1700    /// emitted. a runtime condition emits the JUMP_IF_FALSE / JUMP pattern.
1701    fn compile_if(
1702        &mut self,
1703        cond: &TypedExpr,
1704        then_block: &TypedBlock,
1705        else_branch: Option<&TypedElseBranch>,
1706        span: Span,
1707    ) -> Result<bool, QalaError> {
1708        let line = self.line_at(span);
1709        // DCE: a compile-time-constant condition emits only the taken branch.
1710        match cond {
1711            TypedExpr::Bool { value: true, .. } => {
1712                self.compile_block(then_block)?;
1713                return Ok(false);
1714            }
1715            TypedExpr::Bool { value: false, .. } => {
1716                match else_branch {
1717                    Some(TypedElseBranch::Block(b)) => {
1718                        self.compile_block(b)?;
1719                    }
1720                    Some(TypedElseBranch::If(s)) => {
1721                        self.compile_stmt(s)?;
1722                    }
1723                    None => {}
1724                }
1725                return Ok(false);
1726            }
1727            _ => {}
1728        }
1729        // a runtime condition.
1730        self.compile_expr(cond)?;
1731        let jmp_else = self.chunk_mut().emit_jump(Opcode::JumpIfFalse, line);
1732        let then_terminated = self.compile_block(then_block)?;
1733        let jmp_end = if !then_terminated && else_branch.is_some() {
1734            Some(self.chunk_mut().emit_jump(Opcode::Jump, line))
1735        } else {
1736            None
1737        };
1738        self.chunk_mut().patch_jump(jmp_else)?;
1739        match else_branch {
1740            Some(TypedElseBranch::Block(b)) => {
1741                self.compile_block(b)?;
1742            }
1743            Some(TypedElseBranch::If(s)) => {
1744                self.compile_stmt(s)?;
1745            }
1746            None => {}
1747        }
1748        if let Some(je) = jmp_end {
1749            self.chunk_mut().patch_jump(je)?;
1750        }
1751        // an `if` statement does not terminate the surrounding block in v1;
1752        // the typechecker handles fn-level missing-return separately.
1753        Ok(false)
1754    }
1755
1756    /// compile a `while` loop: `loop_start` records the condition, a
1757    /// `JUMP_IF_FALSE` exits, and an `emit_loop` jumps back. `break` and
1758    /// `continue` patch through the loop scope's `LoopMeta`.
1759    fn compile_while(
1760        &mut self,
1761        cond: &TypedExpr,
1762        body: &TypedBlock,
1763        span: Span,
1764    ) -> Result<bool, QalaError> {
1765        let line = self.line_at(span);
1766        let loop_start = self.chunk_mut().code.len();
1767        self.compile_expr(cond)?;
1768        let jmp_end = self.chunk_mut().emit_jump(Opcode::JumpIfFalse, line);
1769        // the loop scope: `continue` jumps to loop_start (re-test the cond).
1770        self.push_loop_scope(loop_start);
1771        self.compile_block(body)?;
1772        self.chunk_mut().emit_loop(loop_start, line)?;
1773        self.chunk_mut().patch_jump(jmp_end)?;
1774        // patch every `break` jump to land here, past the loop.
1775        self.patch_loop_breaks()?;
1776        self.pop_scope_no_defers();
1777        Ok(false)
1778    }
1779
1780    /// compile a `for var in iter` loop. v1 lowers it to an indexed walk over
1781    /// the materialized `iter` array: a hidden array slot, a hidden index
1782    /// slot, a `GET_LOCAL idx; GET_LOCAL arr; LEN; LT` guard, and an
1783    /// increment. each iteration pushes a fresh body scope so loop-body
1784    /// defers fire per iteration.
1785    fn compile_for(
1786        &mut self,
1787        var: &str,
1788        iter: &TypedExpr,
1789        body: &TypedBlock,
1790        span: Span,
1791    ) -> Result<bool, QalaError> {
1792        let line = self.line_at(span);
1793        // the iterable lands on the stack, then is stored in a hidden slot.
1794        self.compile_expr(iter)?;
1795        let arr_slot = self.next_slot();
1796        self.bind_hidden_slot(arr_slot);
1797        self.chunk_mut().write_op(Opcode::SetLocal, line);
1798        self.chunk_mut().write_u16(arr_slot, line);
1799        // the index counter starts at 0.
1800        self.emit_const(ConstValue::I64(0), line);
1801        let idx_slot = self.next_slot();
1802        self.bind_hidden_slot(idx_slot);
1803        self.chunk_mut().write_op(Opcode::SetLocal, line);
1804        self.chunk_mut().write_u16(idx_slot, line);
1805        // loop_start: the bounds check `idx < len(arr)`.
1806        let loop_start = self.chunk_mut().code.len();
1807        self.chunk_mut().write_op(Opcode::GetLocal, line);
1808        self.chunk_mut().write_u16(idx_slot, line);
1809        self.chunk_mut().write_op(Opcode::GetLocal, line);
1810        self.chunk_mut().write_u16(arr_slot, line);
1811        self.chunk_mut().write_op(Opcode::Len, line);
1812        self.chunk_mut().write_op(Opcode::Lt, line);
1813        let jmp_end = self.chunk_mut().emit_jump(Opcode::JumpIfFalse, line);
1814        // sentinel: continue_target == usize::MAX signals that `continue`
1815        // sites must emit forward-jump placeholders (recorded in
1816        // continue_patches) instead of backward jumps to loop_start, because
1817        // the increment label is not yet known at body-compile time.
1818        self.push_loop_scope(usize::MAX);
1819        // the loop variable's slot, bound in the loop scope so it survives
1820        // the per-iteration body scope `compile_block` pushes.
1821        let var_slot = self.next_slot();
1822        self.register_local(var.to_string(), var_slot);
1823        // `var = arr[idx]`.
1824        self.chunk_mut().write_op(Opcode::GetLocal, line);
1825        self.chunk_mut().write_u16(arr_slot, line);
1826        self.chunk_mut().write_op(Opcode::GetLocal, line);
1827        self.chunk_mut().write_u16(idx_slot, line);
1828        self.chunk_mut().write_op(Opcode::Index, line);
1829        self.chunk_mut().write_op(Opcode::SetLocal, line);
1830        self.chunk_mut().write_u16(var_slot, line);
1831        // the body -- compile_block pushes its own per-iteration scope.
1832        self.compile_block(body)?;
1833        // patch all `continue` forward-jumps to land here (the increment).
1834        self.patch_loop_continues()?;
1835        self.chunk_mut().write_op(Opcode::GetLocal, line);
1836        self.chunk_mut().write_u16(idx_slot, line);
1837        self.emit_const(ConstValue::I64(1), line);
1838        self.chunk_mut().write_op(Opcode::Add, line);
1839        self.chunk_mut().write_op(Opcode::SetLocal, line);
1840        self.chunk_mut().write_u16(idx_slot, line);
1841        self.chunk_mut().emit_loop(loop_start, line)?;
1842        self.chunk_mut().patch_jump(jmp_end)?;
1843        self.patch_loop_breaks()?;
1844        self.pop_scope_no_defers();
1845        Ok(false)
1846    }
1847
1848    /// bind a hidden (compiler-generated) local slot in the topmost scope so
1849    /// [`Codegen::next_slot`] does not hand the same slot out twice. the name
1850    /// is empty -- a hidden slot is never resolved by an identifier, and the
1851    /// VM renders an empty-named slot as `slot{i}`.
1852    fn bind_hidden_slot(&mut self, slot: u16) {
1853        self.register_local(String::new(), slot);
1854    }
1855
1856    /// patch every `break` jump recorded on the innermost loop scope to land
1857    /// at the current end of `code`.
1858    fn patch_loop_breaks(&mut self) -> Result<(), QalaError> {
1859        let patches: Vec<usize> = self
1860            .scopes
1861            .iter()
1862            .rev()
1863            .find_map(|s| s.loop_meta.as_ref().map(|m| m.break_patches.clone()))
1864            .unwrap_or_default();
1865        for pos in patches {
1866            self.chunk_mut().patch_jump(pos)?;
1867        }
1868        Ok(())
1869    }
1870
1871    /// patch every `continue` forward-jump recorded on the innermost loop
1872    /// scope to land at the current end of `code` (the increment label).
1873    /// called by `compile_for` immediately before emitting the increment.
1874    fn patch_loop_continues(&mut self) -> Result<(), QalaError> {
1875        let patches: Vec<usize> = self
1876            .scopes
1877            .iter()
1878            .rev()
1879            .find_map(|s| s.loop_meta.as_ref().map(|m| m.continue_patches.clone()))
1880            .unwrap_or_default();
1881        for pos in patches {
1882            self.chunk_mut().patch_jump(pos)?;
1883        }
1884        Ok(())
1885    }
1886
1887    /// compile a `match`. each arm tests the scrutinee and, on a match, runs
1888    /// its body; the body's value is the match's value. variant patterns use
1889    /// `MATCH_VARIANT`, literal patterns a `DUP; CONST; EQ` test, binding and
1890    /// wildcard patterns match unconditionally. each arm body runs in its own
1891    /// `LocalsScope` so a `defer` inside an arm fires at arm exit.
1892    ///
1893    /// the precise stack discipline of `MATCH_VARIANT` is finalized by the
1894    /// Phase 5 VM; codegen emits the locked DUP / MATCH_VARIANT / POP shape
1895    /// per arm so the bytecode is well-formed for that VM to interpret.
1896    fn compile_match(
1897        &mut self,
1898        scrutinee: &TypedExpr,
1899        arms: &[TypedMatchArm],
1900        line: u32,
1901        span: Span,
1902    ) -> Result<(), QalaError> {
1903        self.compile_expr(scrutinee)?;
1904        // the enum name (for variant-id lookup) when the scrutinee is an enum.
1905        let enum_name = match scrutinee.ty() {
1906            QalaType::Named(Symbol(s)) => Some(s.clone()),
1907            _ => None,
1908        };
1909        // the byte positions of each arm's jump-to-end, patched at the end.
1910        let mut end_jumps: Vec<usize> = Vec::new();
1911        for (i, arm) in arms.iter().enumerate() {
1912            let is_last = i + 1 == arms.len();
1913            // every byte position that should jump to the NEXT arm when this
1914            // arm fails to match (the pattern test and/or the guard).
1915            let mut fail_jumps: Vec<usize> = Vec::new();
1916            // the `(name, slot)` bindings this arm's pattern introduces --
1917            // one for a bare binding, one per destructured variant field.
1918            let mut bindings: Vec<(String, u16)> = Vec::new();
1919            // a guarded binding pattern on a non-last arm DUPs the scrutinee
1920            // (so a guard miss still leaves it for the next arm) and the SET_
1921            // LOCAL consumes the copy; the surviving original must then be
1922            // POPped on the guard-success path, before the arm body. this flag
1923            // tells the shared guard-emission code below to emit that POP.
1924            let mut binding_keeps_scrutinee = false;
1925            match &arm.pattern {
1926                Pattern::Wildcard { .. } => {
1927                    // `_` matches anything: consume the scrutinee, run body.
1928                    self.chunk_mut().write_op(Opcode::Pop, line);
1929                }
1930                Pattern::Binding { name, .. } => {
1931                    // a bare uppercase name is ambiguous in the grammar: it
1932                    // parses as a binding, but may name a zero-payload enum
1933                    // variant (`Triangle`, `None`). when the scrutinee's enum
1934                    // has a variant by this name, treat it as a variant
1935                    // pattern -- a MATCH_VARIANT test -- not a binding.
1936                    if let Some(variant_id) = self.maybe_variant_id(enum_name.as_deref(), name) {
1937                        if !is_last {
1938                            self.chunk_mut().write_op(Opcode::Dup, line);
1939                        }
1940                        self.chunk_mut().write_op(Opcode::MatchVariant, line);
1941                        self.chunk_mut().write_u16(variant_id, line);
1942                        let patch = self.chunk_mut().code.len();
1943                        self.chunk_mut().write_i16(i16::MAX, line);
1944                        fail_jumps.push(patch);
1945                    } else {
1946                        // a genuine binding: matches the pattern unconditionally,
1947                        // binds the scrutinee value. a binding can still FAIL
1948                        // its arm via a guard, so on a non-last guarded arm the
1949                        // scrutinee must survive a guard miss: DUP it, and the
1950                        // SET_LOCAL below consumes the copy. without a guard the
1951                        // arm always matches (later arms are dead) -- no DUP,
1952                        // SET_LOCAL consumes the scrutinee directly.
1953                        if !is_last && arm.guard.is_some() {
1954                            self.chunk_mut().write_op(Opcode::Dup, line);
1955                            binding_keeps_scrutinee = true;
1956                        }
1957                        let slot = self.next_slot();
1958                        self.bind_hidden_slot(slot);
1959                        self.chunk_mut().write_op(Opcode::SetLocal, line);
1960                        self.chunk_mut().write_u16(slot, line);
1961                        bindings.push((name.clone(), slot));
1962                    }
1963                }
1964                Pattern::Variant { name, sub, .. } => {
1965                    // DUP so MATCH_VARIANT consumes a copy and the original
1966                    // survives for the next arm on a miss.
1967                    if !is_last {
1968                        self.chunk_mut().write_op(Opcode::Dup, line);
1969                    }
1970                    let variant_id = self.variant_id_for(enum_name.as_deref(), name, span)?;
1971                    self.chunk_mut().write_op(Opcode::MatchVariant, line);
1972                    self.chunk_mut().write_u16(variant_id, line);
1973                    let patch = self.chunk_mut().code.len();
1974                    self.chunk_mut().write_i16(i16::MAX, line);
1975                    fail_jumps.push(patch);
1976                    // on a hit, MATCH_VARIANT pushed the variant's payload
1977                    // values, the last field on top. destructure each
1978                    // sub-pattern binding into a fresh slot, SET_LOCAL in
1979                    // REVERSE field order (top of stack is the last field).
1980                    let mut slots: Vec<(String, u16)> = Vec::new();
1981                    for sub_pat in sub {
1982                        // v1 supports a Binding (or Wildcard) per field; a
1983                        // nested variant pattern is not destructured.
1984                        let bind_name = match sub_pat {
1985                            Pattern::Binding { name, .. } => Some(name.clone()),
1986                            Pattern::Wildcard { .. } => None,
1987                            _ => None,
1988                        };
1989                        let slot = self.next_slot();
1990                        self.bind_hidden_slot(slot);
1991                        slots.push((bind_name.unwrap_or_default(), slot));
1992                    }
1993                    // SET_LOCAL in reverse: the top of the stack is the last
1994                    // field, which fills the last slot.
1995                    for (_, slot) in slots.iter().rev() {
1996                        self.chunk_mut().write_op(Opcode::SetLocal, line);
1997                        self.chunk_mut().write_u16(*slot, line);
1998                    }
1999                    // register the named field bindings for the arm body.
2000                    for (bind_name, slot) in slots {
2001                        if !bind_name.is_empty() {
2002                            bindings.push((bind_name, slot));
2003                        }
2004                    }
2005                }
2006                Pattern::Int { value, .. } => {
2007                    fail_jumps.push(self.emit_literal_pattern_test(
2008                        ConstValue::I64(*value),
2009                        is_last,
2010                        line,
2011                    ));
2012                }
2013                Pattern::Bool { value, .. } => {
2014                    fail_jumps.push(self.emit_literal_pattern_test(
2015                        ConstValue::Bool(*value),
2016                        is_last,
2017                        line,
2018                    ));
2019                }
2020                Pattern::Byte { value, .. } => {
2021                    fail_jumps.push(self.emit_literal_pattern_test(
2022                        ConstValue::Byte(*value),
2023                        is_last,
2024                        line,
2025                    ));
2026                }
2027                Pattern::Str { value, .. } => {
2028                    fail_jumps.push(self.emit_literal_pattern_test(
2029                        ConstValue::Str(value.clone()),
2030                        is_last,
2031                        line,
2032                    ));
2033                }
2034                Pattern::Float { value, .. } => {
2035                    fail_jumps.push(self.emit_literal_pattern_test(
2036                        ConstValue::F64(*value),
2037                        is_last,
2038                        line,
2039                    ));
2040                }
2041            }
2042            // the arm body runs in its own scope so an arm-local `defer`
2043            // fires at arm exit. the pattern's bindings live in it.
2044            self.push_scope();
2045            for (bind_name, bind_slot) in bindings {
2046                self.register_local(bind_name, bind_slot);
2047            }
2048            // an optional guard: when false, fall through to the next arm.
2049            if let Some(guard) = &arm.guard {
2050                self.compile_expr(guard)?;
2051                fail_jumps.push(self.chunk_mut().emit_jump(Opcode::JumpIfFalse, line));
2052            }
2053            // a guarded binding pattern on a non-last arm DUPed the scrutinee so
2054            // a guard miss leaves it for the next arm. control reaches here only
2055            // on a guard hit (the JUMP_IF_FALSE above jumped away on a miss), so
2056            // POP the now-unneeded scrutinee before the arm body runs -- the
2057            // body's value is the match result, with nothing stale beneath it.
2058            if binding_keeps_scrutinee {
2059                self.chunk_mut().write_op(Opcode::Pop, line);
2060            }
2061            self.compile_match_arm_body(&arm.body)?;
2062            self.pop_scope_and_emit_defers(line)?;
2063            // jump past the remaining arms to the end of the match.
2064            end_jumps.push(self.chunk_mut().emit_jump(Opcode::Jump, line));
2065            // the next arm starts here: patch every fail jump for this arm.
2066            for pf in fail_jumps {
2067                self.chunk_mut().patch_jump(pf)?;
2068            }
2069        }
2070        // every arm's jump-to-end lands here, past the last arm body.
2071        for je in end_jumps {
2072            self.chunk_mut().patch_jump(je)?;
2073        }
2074        Ok(())
2075    }
2076
2077    /// emit a literal-pattern equality test: `DUP` (for a non-final arm so
2078    /// the scrutinee survives a miss), `CONST(lit)`, `EQ`, then a
2079    /// `JUMP_IF_FALSE` placeholder. returns the placeholder's byte position.
2080    fn emit_literal_pattern_test(&mut self, lit: ConstValue, is_last: bool, line: u32) -> usize {
2081        if !is_last {
2082            self.chunk_mut().write_op(Opcode::Dup, line);
2083        }
2084        self.emit_const(lit, line);
2085        self.chunk_mut().write_op(Opcode::Eq, line);
2086        self.chunk_mut().emit_jump(Opcode::JumpIfFalse, line)
2087    }
2088
2089    /// the non-erroring form of [`Codegen::variant_id_for`]: `Some(id)` when
2090    /// `variant_name` names a variant of the scrutinee's enum (or, if the
2091    /// enum is unknown, any variant), `None` otherwise. used to disambiguate
2092    /// a bare-name binding pattern from a zero-payload variant pattern.
2093    fn maybe_variant_id(&self, enum_name: Option<&str>, variant_name: &str) -> Option<u16> {
2094        if let Some(en) = enum_name {
2095            if let Some(&id) = self
2096                .enum_variant_table
2097                .get(&(en.to_string(), variant_name.to_string()))
2098            {
2099                return Some(id);
2100            }
2101            // a known enum that lacks this variant -- it is a real binding.
2102            return None;
2103        }
2104        // no enum context: a bare uppercase name that matches some variant.
2105        self.enum_variant_lookup(variant_name).copied()
2106    }
2107
2108    /// resolve a variant pattern's name to a variant id. when the scrutinee's
2109    /// enum name is known, the `(enum, variant)` pair keys the lookup
2110    /// directly; otherwise fall back to the bare-name search.
2111    fn variant_id_for(
2112        &self,
2113        enum_name: Option<&str>,
2114        variant_name: &str,
2115        span: Span,
2116    ) -> Result<u16, QalaError> {
2117        if let Some(en) = enum_name
2118            && let Some(&id) = self
2119                .enum_variant_table
2120                .get(&(en.to_string(), variant_name.to_string()))
2121        {
2122            return Ok(id);
2123        }
2124        self.enum_variant_lookup(variant_name)
2125            .copied()
2126            .ok_or_else(|| QalaError::Type {
2127                span,
2128                message: format!("codegen: unknown variant `{variant_name}` in match"),
2129            })
2130    }
2131
2132    /// compile a match-arm body -- a bare expression or a block.
2133    fn compile_match_arm_body(&mut self, body: &TypedMatchArmBody) -> Result<(), QalaError> {
2134        match body {
2135            TypedMatchArmBody::Expr(e) => self.compile_expr(e),
2136            TypedMatchArmBody::Block(b) => {
2137                self.compile_block(b)?;
2138                Ok(())
2139            }
2140        }
2141    }
2142}
2143
2144/// compute the constant-folded value of a binary operator on two literal
2145/// operands. returns `Ok(Some(v))` on a successful fold, `Ok(None)` when the
2146/// operand/operator combination has no fold rule (the caller emits the
2147/// opcode), `Err` on an i64 arithmetic overflow.
2148///
2149/// i64 arithmetic uses `checked_*` so an overflow is an error, not a wrap.
2150/// f64 arithmetic follows IEEE 754 -- `inf` / `NaN` are valid results, never
2151/// an error. string `+` of two literals folds to the joined string. integer
2152/// division / remainder by a zero literal does NOT fold (the runtime VM
2153/// raises the division-by-zero error so the diagnostic points at the source).
2154fn fold_binary_value(
2155    a: &ConstValue,
2156    b: &ConstValue,
2157    op: &BinOp,
2158    operand_ty: &QalaType,
2159    span: Span,
2160) -> Result<Option<ConstValue>, QalaError> {
2161    let overflow =
2162        |op: BinOp, lhs: i64, rhs: i64| QalaError::IntegerOverflow { span, op, lhs, rhs };
2163    let folded = match (a, b, op) {
2164        // ---- i64 arithmetic: checked, overflow is an error ----
2165        (ConstValue::I64(x), ConstValue::I64(y), BinOp::Add)
2166            if matches!(operand_ty, QalaType::I64) =>
2167        {
2168            ConstValue::I64(
2169                x.checked_add(*y)
2170                    .ok_or_else(|| overflow(BinOp::Add, *x, *y))?,
2171            )
2172        }
2173        (ConstValue::I64(x), ConstValue::I64(y), BinOp::Sub)
2174            if matches!(operand_ty, QalaType::I64) =>
2175        {
2176            ConstValue::I64(
2177                x.checked_sub(*y)
2178                    .ok_or_else(|| overflow(BinOp::Sub, *x, *y))?,
2179            )
2180        }
2181        (ConstValue::I64(x), ConstValue::I64(y), BinOp::Mul)
2182            if matches!(operand_ty, QalaType::I64) =>
2183        {
2184            ConstValue::I64(
2185                x.checked_mul(*y)
2186                    .ok_or_else(|| overflow(BinOp::Mul, *x, *y))?,
2187            )
2188        }
2189        (ConstValue::I64(x), ConstValue::I64(y), BinOp::Div)
2190            if matches!(operand_ty, QalaType::I64) =>
2191        {
2192            if *y == 0 {
2193                return Ok(None);
2194            }
2195            ConstValue::I64(
2196                x.checked_div(*y)
2197                    .ok_or_else(|| overflow(BinOp::Div, *x, *y))?,
2198            )
2199        }
2200        (ConstValue::I64(x), ConstValue::I64(y), BinOp::Rem)
2201            if matches!(operand_ty, QalaType::I64) =>
2202        {
2203            if *y == 0 {
2204                return Ok(None);
2205            }
2206            ConstValue::I64(
2207                x.checked_rem(*y)
2208                    .ok_or_else(|| overflow(BinOp::Rem, *x, *y))?,
2209            )
2210        }
2211        // ---- f64 arithmetic: IEEE 754, no overflow error ----
2212        (ConstValue::F64(x), ConstValue::F64(y), BinOp::Add) => ConstValue::F64(x + y),
2213        (ConstValue::F64(x), ConstValue::F64(y), BinOp::Sub) => ConstValue::F64(x - y),
2214        (ConstValue::F64(x), ConstValue::F64(y), BinOp::Mul) => ConstValue::F64(x * y),
2215        (ConstValue::F64(x), ConstValue::F64(y), BinOp::Div) => ConstValue::F64(x / y),
2216        // ---- string concatenation of two literals ----
2217        (ConstValue::Str(x), ConstValue::Str(y), BinOp::Add) => {
2218            let mut s = String::with_capacity(x.len() + y.len());
2219            s.push_str(x);
2220            s.push_str(y);
2221            ConstValue::Str(s)
2222        }
2223        // ---- i64 comparisons ----
2224        (ConstValue::I64(x), ConstValue::I64(y), BinOp::Eq) => ConstValue::Bool(x == y),
2225        (ConstValue::I64(x), ConstValue::I64(y), BinOp::Ne) => ConstValue::Bool(x != y),
2226        (ConstValue::I64(x), ConstValue::I64(y), BinOp::Lt) => ConstValue::Bool(x < y),
2227        (ConstValue::I64(x), ConstValue::I64(y), BinOp::Le) => ConstValue::Bool(x <= y),
2228        (ConstValue::I64(x), ConstValue::I64(y), BinOp::Gt) => ConstValue::Bool(x > y),
2229        (ConstValue::I64(x), ConstValue::I64(y), BinOp::Ge) => ConstValue::Bool(x >= y),
2230        // ---- f64 comparisons (IEEE 754: NaN compares unequal) ----
2231        (ConstValue::F64(x), ConstValue::F64(y), BinOp::Eq) => ConstValue::Bool(x == y),
2232        (ConstValue::F64(x), ConstValue::F64(y), BinOp::Ne) => ConstValue::Bool(x != y),
2233        (ConstValue::F64(x), ConstValue::F64(y), BinOp::Lt) => ConstValue::Bool(x < y),
2234        (ConstValue::F64(x), ConstValue::F64(y), BinOp::Le) => ConstValue::Bool(x <= y),
2235        (ConstValue::F64(x), ConstValue::F64(y), BinOp::Gt) => ConstValue::Bool(x > y),
2236        (ConstValue::F64(x), ConstValue::F64(y), BinOp::Ge) => ConstValue::Bool(x >= y),
2237        // ---- byte comparisons ----
2238        (ConstValue::Byte(x), ConstValue::Byte(y), BinOp::Eq) => ConstValue::Bool(x == y),
2239        (ConstValue::Byte(x), ConstValue::Byte(y), BinOp::Ne) => ConstValue::Bool(x != y),
2240        // ---- bool logic + equality ----
2241        (ConstValue::Bool(x), ConstValue::Bool(y), BinOp::And) => ConstValue::Bool(*x && *y),
2242        (ConstValue::Bool(x), ConstValue::Bool(y), BinOp::Or) => ConstValue::Bool(*x || *y),
2243        (ConstValue::Bool(x), ConstValue::Bool(y), BinOp::Eq) => ConstValue::Bool(x == y),
2244        (ConstValue::Bool(x), ConstValue::Bool(y), BinOp::Ne) => ConstValue::Bool(x != y),
2245        // ---- string equality ----
2246        (ConstValue::Str(x), ConstValue::Str(y), BinOp::Eq) => ConstValue::Bool(x == y),
2247        (ConstValue::Str(x), ConstValue::Str(y), BinOp::Ne) => ConstValue::Bool(x != y),
2248        // anything else: no fold rule.
2249        _ => return Ok(None),
2250    };
2251    Ok(Some(folded))
2252}
2253
2254/// is `v` a value the constant pool can hold? primitives and strings are;
2255/// heap objects (arrays, structs, enum variants) and function references
2256/// are not. a `comptime` block whose result is not constable is rejected.
2257fn is_constable_primitive(v: &ConstValue) -> bool {
2258    matches!(
2259        v,
2260        ConstValue::I64(_)
2261            | ConstValue::F64(_)
2262            | ConstValue::Bool(_)
2263            | ConstValue::Byte(_)
2264            | ConstValue::Str(_)
2265            | ConstValue::Void
2266    )
2267}
2268
2269/// the canonical type name of a [`ConstValue`], for the
2270/// [`QalaError::ComptimeResultNotConstable`] message. only [`ConstValue::Function`]
2271/// reaches this in practice (every other variant is constable).
2272fn value_type_name(v: &ConstValue) -> String {
2273    match v {
2274        ConstValue::I64(_) => "i64",
2275        ConstValue::F64(_) => "f64",
2276        ConstValue::Bool(_) => "bool",
2277        ConstValue::Byte(_) => "byte",
2278        ConstValue::Str(_) => "str",
2279        ConstValue::Void => "void",
2280        ConstValue::Function(_) => "fn",
2281    }
2282    .to_string()
2283}
2284
2285/// one call frame on the [`ComptimeInterpreter`]'s explicit call stack. the
2286/// explicit `Vec<Frame>` (rather than Rust recursion) bounds call depth at
2287/// [`COMPTIME_MAX_FRAMES`] so a runaway comptime recursion cannot blow the
2288/// host stack.
2289struct Frame {
2290    /// the chunk this frame executes -- an index into `program.chunks`, or
2291    /// [`u16::MAX`] for the throwaway chunk the comptime body compiled to.
2292    chunk_id: u16,
2293    /// the frame's local slots, indexed by `GET_LOCAL` / `SET_LOCAL`. a
2294    /// callee's slots 0..argc are pre-filled with the call arguments.
2295    locals: Vec<ConstValue>,
2296    /// the instruction pointer -- a byte offset into the frame's chunk.
2297    ip: usize,
2298}
2299
2300/// the compile-time bytecode interpreter. a small stack VM that executes a
2301/// `comptime` block's bytecode under a hard instruction budget; it supports
2302/// only pure computation -- arithmetic, comparisons, locals, jumps, and calls
2303/// to functions whose effect set is pure (a defense-in-depth gate; Phase 3
2304/// already enforces comptime-body purity).
2305struct ComptimeInterpreter<'a> {
2306    /// the program -- supplies the chunks that comptime CALLs dispatch into.
2307    program: &'a Program,
2308    /// fn-id -> effect, for the per-CALL purity gate.
2309    fn_effects: &'a HashMap<u16, EffectSet>,
2310    /// the value stack.
2311    stack: Vec<ConstValue>,
2312    /// the explicit call-frame stack -- no Rust recursion.
2313    frames: Vec<Frame>,
2314    /// the remaining instruction budget; ticks down per instruction.
2315    budget: u32,
2316    /// the originating `comptime { }` span, for error reporting.
2317    block_span: Span,
2318}
2319
2320impl<'a> ComptimeInterpreter<'a> {
2321    /// construct an interpreter for the `comptime` block at `block_span`.
2322    fn new(
2323        program: &'a Program,
2324        fn_effects: &'a HashMap<u16, EffectSet>,
2325        block_span: Span,
2326    ) -> Self {
2327        ComptimeInterpreter {
2328            program,
2329            fn_effects,
2330            stack: Vec::new(),
2331            frames: Vec::new(),
2332            budget: COMPTIME_BUDGET,
2333            block_span,
2334        }
2335    }
2336
2337    /// a comptime stack-underflow error -- a codegen bug if it fires (the
2338    /// bytecode codegen produces is stack-balanced), surfaced rather than
2339    /// panicking.
2340    fn underflow(&self) -> QalaError {
2341        QalaError::Type {
2342            span: self.block_span,
2343            message: "comptime interpreter: stack underflow".to_string(),
2344        }
2345    }
2346
2347    /// pop one value, or an underflow error.
2348    fn pop(&mut self) -> Result<ConstValue, QalaError> {
2349        self.stack.pop().ok_or_else(|| self.underflow())
2350    }
2351
2352    /// run the throwaway chunk to completion, returning the value the
2353    /// outermost `RETURN` leaves on the stack.
2354    fn run(&mut self, throwaway: Chunk) -> Result<ConstValue, QalaError> {
2355        // the initial frame executes the throwaway chunk (chunk_id u16::MAX).
2356        self.frames.push(Frame {
2357            chunk_id: u16::MAX,
2358            locals: Vec::new(),
2359            ip: 0,
2360        });
2361        while !self.frames.is_empty() {
2362            // the chunk the current frame executes.
2363            let frame_idx = self.frames.len() - 1;
2364            let chunk_id = self.frames[frame_idx].chunk_id;
2365            let chunk: &Chunk = if chunk_id == u16::MAX {
2366                &throwaway
2367            } else {
2368                self.program
2369                    .chunks
2370                    .get(chunk_id as usize)
2371                    .ok_or_else(|| QalaError::Type {
2372                        span: self.block_span,
2373                        message: "comptime interpreter: call to a missing chunk".to_string(),
2374                    })?
2375            };
2376            let ip = self.frames[frame_idx].ip;
2377            // ran off the end without a RETURN -- treat the result as void.
2378            if ip >= chunk.code.len() {
2379                self.frames.pop();
2380                if self.frames.is_empty() {
2381                    return Ok(ConstValue::Void);
2382                }
2383                self.stack.push(ConstValue::Void);
2384                continue;
2385            }
2386            if self.budget == 0 {
2387                return Err(QalaError::ComptimeBudgetExceeded {
2388                    span: self.block_span,
2389                });
2390            }
2391            self.budget -= 1;
2392            let op_byte = chunk.code[ip];
2393            let op = Opcode::from_u8(op_byte).ok_or_else(|| QalaError::Type {
2394                span: self.block_span,
2395                message: format!("comptime interpreter: undefined opcode {op_byte:#x}"),
2396            })?;
2397            // advance the ip past the opcode + its operands; specific arms
2398            // read the operands from the bytes that follow `ip`.
2399            let next_ip = ip + 1 + op.operand_bytes() as usize;
2400            self.frames[frame_idx].ip = next_ip;
2401            match op {
2402                Opcode::Const => {
2403                    let idx = chunk.read_u16(ip + 1) as usize;
2404                    let v = chunk
2405                        .constants
2406                        .get(idx)
2407                        .cloned()
2408                        .ok_or_else(|| QalaError::Type {
2409                            span: self.block_span,
2410                            message: "comptime interpreter: bad constant index".to_string(),
2411                        })?;
2412                    self.stack.push(v);
2413                }
2414                Opcode::Pop => {
2415                    self.pop()?;
2416                }
2417                Opcode::Dup => {
2418                    let top = self.stack.last().cloned().ok_or_else(|| self.underflow())?;
2419                    self.stack.push(top);
2420                }
2421                Opcode::GetLocal => {
2422                    let slot = chunk.read_u16(ip + 1) as usize;
2423                    let v = self.frames[frame_idx]
2424                        .locals
2425                        .get(slot)
2426                        .cloned()
2427                        .ok_or_else(|| QalaError::Type {
2428                            span: self.block_span,
2429                            message: "comptime interpreter: unbound local".to_string(),
2430                        })?;
2431                    self.stack.push(v);
2432                }
2433                Opcode::SetLocal => {
2434                    let slot = chunk.read_u16(ip + 1) as usize;
2435                    let v = self.pop()?;
2436                    let locals = &mut self.frames[frame_idx].locals;
2437                    if slot >= locals.len() {
2438                        locals.resize(slot + 1, ConstValue::Void);
2439                    }
2440                    locals[slot] = v;
2441                }
2442                Opcode::Add
2443                | Opcode::Sub
2444                | Opcode::Mul
2445                | Opcode::Div
2446                | Opcode::Mod
2447                | Opcode::FAdd
2448                | Opcode::FSub
2449                | Opcode::FMul
2450                | Opcode::FDiv => {
2451                    let b = self.pop()?;
2452                    let a = self.pop()?;
2453                    let r = self.eval_arith(op, a, b)?;
2454                    self.stack.push(r);
2455                }
2456                Opcode::Neg => {
2457                    let a = self.pop()?;
2458                    match a {
2459                        ConstValue::I64(n) => {
2460                            let r = n.checked_neg().ok_or(QalaError::IntegerOverflow {
2461                                span: self.block_span,
2462                                op: BinOp::Sub,
2463                                lhs: 0,
2464                                rhs: n,
2465                            })?;
2466                            self.stack.push(ConstValue::I64(r));
2467                        }
2468                        _ => return Err(self.type_error("NEG expects i64")),
2469                    }
2470                }
2471                Opcode::FNeg => {
2472                    let a = self.pop()?;
2473                    match a {
2474                        ConstValue::F64(x) => self.stack.push(ConstValue::F64(-x)),
2475                        _ => return Err(self.type_error("F_NEG expects f64")),
2476                    }
2477                }
2478                Opcode::Eq
2479                | Opcode::Ne
2480                | Opcode::Lt
2481                | Opcode::Le
2482                | Opcode::Gt
2483                | Opcode::Ge
2484                | Opcode::FEq
2485                | Opcode::FNe
2486                | Opcode::FLt
2487                | Opcode::FLe
2488                | Opcode::FGt
2489                | Opcode::FGe => {
2490                    let b = self.pop()?;
2491                    let a = self.pop()?;
2492                    let r = self.eval_compare(op, a, b)?;
2493                    self.stack.push(ConstValue::Bool(r));
2494                }
2495                Opcode::Not => {
2496                    let a = self.pop()?;
2497                    match a {
2498                        ConstValue::Bool(b) => self.stack.push(ConstValue::Bool(!b)),
2499                        _ => return Err(self.type_error("NOT expects bool")),
2500                    }
2501                }
2502                Opcode::Jump => {
2503                    let offset = chunk.read_i16(ip + 1);
2504                    self.frames[frame_idx].ip = jump_target(next_ip, offset)?;
2505                }
2506                Opcode::JumpIfFalse => {
2507                    let offset = chunk.read_i16(ip + 1);
2508                    let cond = self.pop()?;
2509                    if matches!(cond, ConstValue::Bool(false)) {
2510                        self.frames[frame_idx].ip = jump_target(next_ip, offset)?;
2511                    }
2512                }
2513                Opcode::JumpIfTrue => {
2514                    let offset = chunk.read_i16(ip + 1);
2515                    let cond = self.pop()?;
2516                    if matches!(cond, ConstValue::Bool(true)) {
2517                        self.frames[frame_idx].ip = jump_target(next_ip, offset)?;
2518                    }
2519                }
2520                Opcode::Call => {
2521                    let fn_id = chunk.read_u16(ip + 1);
2522                    let argc = chunk.code[ip + 3];
2523                    // defense-in-depth: a non-pure callee is rejected even
2524                    // though Phase 3 should already have caught it.
2525                    let effect = self
2526                        .fn_effects
2527                        .get(&fn_id)
2528                        .copied()
2529                        .unwrap_or_else(EffectSet::full);
2530                    if !effect.is_pure() {
2531                        let fn_name = self
2532                            .program
2533                            .fn_names
2534                            .get(fn_id as usize)
2535                            .cloned()
2536                            .unwrap_or_else(|| format!("#{fn_id}"));
2537                        return Err(QalaError::ComptimeEffectViolation {
2538                            span: self.block_span,
2539                            fn_name,
2540                            effect: effect.display(),
2541                        });
2542                    }
2543                    // a stdlib callee has no chunk to execute; v1 does not
2544                    // support stdlib calls inside comptime.
2545                    if fn_id >= STDLIB_FN_BASE {
2546                        return Err(QalaError::Type {
2547                            span: self.block_span,
2548                            message: "comptime interpreter: stdlib calls inside comptime \
2549                                      are a v2 feature"
2550                                .to_string(),
2551                        });
2552                    }
2553                    if self.frames.len() >= COMPTIME_MAX_FRAMES {
2554                        // call-depth overflow is treated as a budget overflow.
2555                        return Err(QalaError::ComptimeBudgetExceeded {
2556                            span: self.block_span,
2557                        });
2558                    }
2559                    // pop argc arguments; slot 0 is the first argument.
2560                    let mut locals: Vec<ConstValue> = Vec::with_capacity(argc as usize);
2561                    for _ in 0..argc {
2562                        locals.push(self.pop()?);
2563                    }
2564                    locals.reverse();
2565                    self.frames.push(Frame {
2566                        chunk_id: fn_id,
2567                        locals,
2568                        ip: 0,
2569                    });
2570                }
2571                Opcode::Return => {
2572                    let result = self.pop()?;
2573                    self.frames.pop();
2574                    if self.frames.is_empty() {
2575                        return Ok(result);
2576                    }
2577                    self.stack.push(result);
2578                }
2579                Opcode::ToStr => {
2580                    let v = self.pop()?;
2581                    self.stack.push(ConstValue::Str(v.to_string()));
2582                }
2583                Opcode::ConcatN => {
2584                    let count = chunk.read_u16(ip + 1) as usize;
2585                    if self.stack.len() < count {
2586                        return Err(self.underflow());
2587                    }
2588                    let parts = self.stack.split_off(self.stack.len() - count);
2589                    let mut s = String::new();
2590                    for part in parts {
2591                        match part {
2592                            ConstValue::Str(p) => s.push_str(&p),
2593                            other => s.push_str(&other.to_string()),
2594                        }
2595                    }
2596                    self.stack.push(ConstValue::Str(s));
2597                }
2598                // heap-construction and pattern opcodes are not supported in
2599                // comptime v1 -- the result must be a primitive or string.
2600                Opcode::MakeArray
2601                | Opcode::MakeTuple
2602                | Opcode::MakeStruct
2603                | Opcode::MakeEnumVariant
2604                | Opcode::Index
2605                | Opcode::Field
2606                | Opcode::Len
2607                | Opcode::MatchVariant => {
2608                    return Err(QalaError::Type {
2609                        span: self.block_span,
2610                        message: format!(
2611                            "comptime interpreter: {} is not supported inside comptime (v1)",
2612                            op.name()
2613                        ),
2614                    });
2615                }
2616                Opcode::GetGlobal | Opcode::SetGlobal => {
2617                    return Err(QalaError::Type {
2618                        span: self.block_span,
2619                        message: "comptime interpreter: globals are not supported in comptime"
2620                            .to_string(),
2621                    });
2622                }
2623                Opcode::Halt => {
2624                    return Err(QalaError::Type {
2625                        span: self.block_span,
2626                        message: "comptime interpreter: unexpected HALT".to_string(),
2627                    });
2628                }
2629            }
2630        }
2631        Err(QalaError::Type {
2632            span: self.block_span,
2633            message: "comptime interpreter: ran out of frames without a result".to_string(),
2634        })
2635    }
2636
2637    /// a comptime type error with the block span.
2638    fn type_error(&self, message: &str) -> QalaError {
2639        QalaError::Type {
2640            span: self.block_span,
2641            message: format!("comptime interpreter: {message}"),
2642        }
2643    }
2644
2645    /// evaluate an arithmetic opcode on two values. i64 ops are checked --
2646    /// an overflow is an error; division / remainder by zero is an error.
2647    /// f64 ops follow IEEE 754.
2648    fn eval_arith(
2649        &self,
2650        op: Opcode,
2651        a: ConstValue,
2652        b: ConstValue,
2653    ) -> Result<ConstValue, QalaError> {
2654        let int_overflow = |bin: BinOp, x: i64, y: i64| QalaError::IntegerOverflow {
2655            span: self.block_span,
2656            op: bin,
2657            lhs: x,
2658            rhs: y,
2659        };
2660        match (op, a, b) {
2661            (Opcode::Add, ConstValue::I64(x), ConstValue::I64(y)) => Ok(ConstValue::I64(
2662                x.checked_add(y)
2663                    .ok_or_else(|| int_overflow(BinOp::Add, x, y))?,
2664            )),
2665            (Opcode::Sub, ConstValue::I64(x), ConstValue::I64(y)) => Ok(ConstValue::I64(
2666                x.checked_sub(y)
2667                    .ok_or_else(|| int_overflow(BinOp::Sub, x, y))?,
2668            )),
2669            (Opcode::Mul, ConstValue::I64(x), ConstValue::I64(y)) => Ok(ConstValue::I64(
2670                x.checked_mul(y)
2671                    .ok_or_else(|| int_overflow(BinOp::Mul, x, y))?,
2672            )),
2673            (Opcode::Div, ConstValue::I64(x), ConstValue::I64(y)) => {
2674                if y == 0 {
2675                    return Err(self.type_error("division by zero"));
2676                }
2677                Ok(ConstValue::I64(
2678                    x.checked_div(y)
2679                        .ok_or_else(|| int_overflow(BinOp::Div, x, y))?,
2680                ))
2681            }
2682            (Opcode::Mod, ConstValue::I64(x), ConstValue::I64(y)) => {
2683                if y == 0 {
2684                    return Err(self.type_error("remainder by zero"));
2685                }
2686                Ok(ConstValue::I64(
2687                    x.checked_rem(y)
2688                        .ok_or_else(|| int_overflow(BinOp::Rem, x, y))?,
2689                ))
2690            }
2691            (Opcode::FAdd, ConstValue::F64(x), ConstValue::F64(y)) => Ok(ConstValue::F64(x + y)),
2692            (Opcode::FSub, ConstValue::F64(x), ConstValue::F64(y)) => Ok(ConstValue::F64(x - y)),
2693            (Opcode::FMul, ConstValue::F64(x), ConstValue::F64(y)) => Ok(ConstValue::F64(x * y)),
2694            (Opcode::FDiv, ConstValue::F64(x), ConstValue::F64(y)) => Ok(ConstValue::F64(x / y)),
2695            _ => Err(self.type_error("arithmetic operand type mismatch")),
2696        }
2697    }
2698
2699    /// evaluate a comparison opcode on two values, returning the bool result.
2700    fn eval_compare(&self, op: Opcode, a: ConstValue, b: ConstValue) -> Result<bool, QalaError> {
2701        match (op, a, b) {
2702            (Opcode::Eq, ConstValue::I64(x), ConstValue::I64(y)) => Ok(x == y),
2703            (Opcode::Ne, ConstValue::I64(x), ConstValue::I64(y)) => Ok(x != y),
2704            (Opcode::Lt, ConstValue::I64(x), ConstValue::I64(y)) => Ok(x < y),
2705            (Opcode::Le, ConstValue::I64(x), ConstValue::I64(y)) => Ok(x <= y),
2706            (Opcode::Gt, ConstValue::I64(x), ConstValue::I64(y)) => Ok(x > y),
2707            (Opcode::Ge, ConstValue::I64(x), ConstValue::I64(y)) => Ok(x >= y),
2708            (Opcode::Eq, ConstValue::Bool(x), ConstValue::Bool(y)) => Ok(x == y),
2709            (Opcode::Ne, ConstValue::Bool(x), ConstValue::Bool(y)) => Ok(x != y),
2710            (Opcode::Eq, ConstValue::Str(x), ConstValue::Str(y)) => Ok(x == y),
2711            (Opcode::Ne, ConstValue::Str(x), ConstValue::Str(y)) => Ok(x != y),
2712            (Opcode::FEq, ConstValue::F64(x), ConstValue::F64(y)) => Ok(x == y),
2713            (Opcode::FNe, ConstValue::F64(x), ConstValue::F64(y)) => Ok(x != y),
2714            (Opcode::FLt, ConstValue::F64(x), ConstValue::F64(y)) => Ok(x < y),
2715            (Opcode::FLe, ConstValue::F64(x), ConstValue::F64(y)) => Ok(x <= y),
2716            (Opcode::FGt, ConstValue::F64(x), ConstValue::F64(y)) => Ok(x > y),
2717            (Opcode::FGe, ConstValue::F64(x), ConstValue::F64(y)) => Ok(x >= y),
2718            _ => Err(self.type_error("comparison operand type mismatch")),
2719        }
2720    }
2721}
2722
2723/// compute a jump's absolute target byte offset from the byte AFTER the
2724/// jump's operand and the signed relative `offset`. an out-of-range target
2725/// is a corrupted-bytecode error rather than a panic.
2726fn jump_target(after_operand: usize, offset: i16) -> Result<usize, QalaError> {
2727    let target = after_operand as isize + offset as isize;
2728    if target < 0 {
2729        return Err(QalaError::Type {
2730            span: Span::new(0, 0),
2731            message: "comptime interpreter: jump target out of range".to_string(),
2732        });
2733    }
2734    Ok(target as usize)
2735}
2736
2737#[cfg(test)]
2738mod tests {
2739    use super::*;
2740    use crate::lexer::Lexer;
2741    use crate::parser::Parser;
2742    use crate::typechecker::check_program;
2743
2744    /// lex + parse + typecheck + compile a source string; return the codegen
2745    /// result directly (errors and all).
2746    fn compile(src: &str) -> Result<Program, Vec<QalaError>> {
2747        let tokens = Lexer::tokenize(src).expect("lex failed");
2748        let ast = Parser::parse(&tokens).expect("parse failed");
2749        let (typed, terrors, _) = check_program(&ast, src);
2750        assert!(terrors.is_empty(), "typecheck errors: {terrors:?}");
2751        compile_program(&typed, src)
2752    }
2753
2754    /// like [`compile`] but panics on any codegen error -- happy-path tests.
2755    fn compile_ok(src: &str) -> Program {
2756        compile(src).unwrap_or_else(|e| panic!("codegen errors: {e:?}"))
2757    }
2758
2759    /// typecheck `src` and return the typed AST plus the source. shared by
2760    /// the expression-level test helpers below.
2761    fn typecheck(src: &str) -> TypedAst {
2762        let tokens = Lexer::tokenize(src).expect("lex failed");
2763        let ast = Parser::parse(&tokens).expect("parse failed");
2764        let (typed, terrors, _) = check_program(&ast, src);
2765        assert!(terrors.is_empty(), "typecheck errors: {terrors:?}");
2766        typed
2767    }
2768
2769    /// compile a single expression directly into a fresh chunk and return
2770    /// `(code_bytes, constants)`. the expression is the trailing value of
2771    /// `fn main`'s body in the wrapper program -- so test sources are
2772    /// written as `fn main() is io { EXPR }` (or include the helper fns the
2773    /// expression needs). `compile_block` / `compile_stmt` are still
2774    /// placeholders in Task 2, so the public entry cannot be used yet;
2775    /// running `compile_expr` directly is the Task-2 / Task-3 test path.
2776    fn compile_expr_of(src: &str, fn_name: &str) -> (Vec<u8>, Vec<ConstValue>) {
2777        let typed = typecheck(src);
2778        let mut cg = Codegen::new(src);
2779        cg.build_tables(&typed);
2780        // find the named function and its trailing-value expression.
2781        let decl = typed
2782            .iter()
2783            .find_map(|item| match item {
2784                TypedItem::Fn(d) if d.name == fn_name => Some(d),
2785                _ => None,
2786            })
2787            .unwrap_or_else(|| panic!("function `{fn_name}` not found"));
2788        let fn_id = cg.fn_table[&FnKey {
2789            type_name: None,
2790            name: fn_name.to_string(),
2791        }];
2792        cg.current_fn_id = fn_id;
2793        cg.push_scope();
2794        for (i, param) in decl.params.iter().enumerate() {
2795            if let Some(scope) = cg.scopes.last_mut() {
2796                scope.locals.push((param.name.clone(), i as u16));
2797            }
2798        }
2799        let value = decl
2800            .body
2801            .value
2802            .as_ref()
2803            .unwrap_or_else(|| panic!("function `{fn_name}` body has no trailing value"));
2804        cg.compile_expr(value).expect("compile_expr failed");
2805        let chunk = &cg.program.chunks[fn_id as usize];
2806        (chunk.code.clone(), chunk.constants.clone())
2807    }
2808
2809    /// like [`compile_expr_of`] but returns the codegen error rather than
2810    /// panicking -- used by the fold-overflow tests.
2811    fn compile_expr_err(src: &str, fn_name: &str) -> QalaError {
2812        let typed = typecheck(src);
2813        let mut cg = Codegen::new(src);
2814        cg.build_tables(&typed);
2815        let decl = typed
2816            .iter()
2817            .find_map(|item| match item {
2818                TypedItem::Fn(d) if d.name == fn_name => Some(d),
2819                _ => None,
2820            })
2821            .unwrap_or_else(|| panic!("function `{fn_name}` not found"));
2822        let fn_id = cg.fn_table[&FnKey {
2823            type_name: None,
2824            name: fn_name.to_string(),
2825        }];
2826        cg.current_fn_id = fn_id;
2827        cg.push_scope();
2828        for (i, param) in decl.params.iter().enumerate() {
2829            if let Some(scope) = cg.scopes.last_mut() {
2830                scope.locals.push((param.name.clone(), i as u16));
2831            }
2832        }
2833        let value = decl
2834            .body
2835            .value
2836            .as_ref()
2837            .expect("body has no trailing value");
2838        cg.compile_expr(value)
2839            .expect_err("expected a codegen error")
2840    }
2841
2842    /// the opcodes (no operands) appearing in `code`, in order -- a coarse
2843    /// shape check for tests that do not need exact operand bytes.
2844    fn opcodes(code: &[u8]) -> Vec<Opcode> {
2845        let mut ops = Vec::new();
2846        let mut off = 0;
2847        while off < code.len() {
2848            match Opcode::from_u8(code[off]) {
2849                Some(op) => {
2850                    ops.push(op);
2851                    off += 1 + op.operand_bytes() as usize;
2852                }
2853                None => break,
2854            }
2855        }
2856        ops
2857    }
2858
2859    // Test 1: an empty program compiles to an empty Program.
2860    #[test]
2861    fn skeleton_empty_program_compiles_to_empty_program() {
2862        let p = compile_program(&Vec::new(), "").expect("empty program should compile");
2863        assert!(p.chunks.is_empty(), "chunks should be empty");
2864        assert_eq!(p.main_index, 0);
2865        assert!(p.globals.is_empty());
2866        assert!(p.fn_names.is_empty());
2867        // enum_variant_names holds the four built-in Result/Option variants.
2868        assert_eq!(p.enum_variant_names.len(), 4);
2869    }
2870
2871    // Test 2: a single `fn main` produces one chunk with main at fn-id 0.
2872    #[test]
2873    fn skeleton_single_main_fn_produces_one_chunk() {
2874        let p = compile_ok("fn main() is io { }");
2875        assert_eq!(p.chunks.len(), 1, "one user fn -> one chunk");
2876        assert_eq!(p.main_index, 0);
2877        assert_eq!(p.fn_names, vec!["main".to_string()]);
2878        // the fall-through completion emits at least a RETURN byte.
2879        assert!(
2880            !p.chunks[0].code.is_empty(),
2881            "main chunk should have a RETURN"
2882        );
2883        assert_eq!(p.chunks[0].code[0], Opcode::Return as u8);
2884    }
2885
2886    // Test 3: build_tables records struct field indices densely.
2887    #[test]
2888    fn skeleton_build_tables_records_struct_field_indices() {
2889        let src = "struct Point { x: f64, y: f64 }\nfn main() is io { }";
2890        let tokens = Lexer::tokenize(src).expect("lex");
2891        let ast = Parser::parse(&tokens).expect("parse");
2892        let (typed, terrors, _) = check_program(&ast, src);
2893        assert!(terrors.is_empty(), "{terrors:?}");
2894        let mut cg = Codegen::new(src);
2895        cg.build_tables(&typed);
2896        assert_eq!(
2897            cg.struct_field_index
2898                .get(&("Point".to_string(), "x".to_string())),
2899            Some(&0)
2900        );
2901        assert_eq!(
2902            cg.struct_field_index
2903                .get(&("Point".to_string(), "y".to_string())),
2904            Some(&1)
2905        );
2906    }
2907
2908    // Test 4: build_tables records enum variant ids + payload counts.
2909    #[test]
2910    fn skeleton_build_tables_records_enum_variant_ids_and_payloads() {
2911        let src = "enum Shape { Circle(f64), Rect(f64, f64), Triangle }\nfn main() is io { }";
2912        let tokens = Lexer::tokenize(src).expect("lex");
2913        let ast = Parser::parse(&tokens).expect("parse");
2914        let (typed, terrors, _) = check_program(&ast, src);
2915        assert!(terrors.is_empty(), "{terrors:?}");
2916        let mut cg = Codegen::new(src);
2917        cg.build_tables(&typed);
2918        // three user variants; built-in Result/Option occupy ids 0..4, so
2919        // the user variants start at 4.
2920        for v in ["Circle", "Rect", "Triangle"] {
2921            assert!(
2922                cg.enum_variant_table
2923                    .contains_key(&("Shape".to_string(), v.to_string())),
2924                "missing variant {v}"
2925            );
2926        }
2927        assert_eq!(
2928            cg.enum_variant_payload_count
2929                .get(&("Shape".to_string(), "Circle".to_string())),
2930            Some(&1)
2931        );
2932        assert_eq!(
2933            cg.enum_variant_payload_count
2934                .get(&("Shape".to_string(), "Rect".to_string())),
2935            Some(&2)
2936        );
2937        assert_eq!(
2938            cg.enum_variant_payload_count
2939                .get(&("Shape".to_string(), "Triangle".to_string())),
2940            Some(&0)
2941        );
2942        // the variant ids index program.enum_variant_names: id N names
2943        // (Shape, that variant).
2944        let circle_id = cg.enum_variant_table[&("Shape".to_string(), "Circle".to_string())];
2945        assert_eq!(
2946            cg.program.enum_variant_names[circle_id as usize],
2947            ("Shape".to_string(), "Circle".to_string())
2948        );
2949    }
2950
2951    // Test 4b: enum_variant_lookup resolves deterministically when two enums
2952    // share a variant name. the BTreeMap walk visits keys in sorted order, so
2953    // the result is the same across every run regardless of HashMap seed.
2954    #[test]
2955    fn enum_variant_lookup_is_deterministic_with_shared_variant_name() {
2956        let src = "enum A { Done }\nenum B { Done }\nfn main() is io { }";
2957        let tokens = Lexer::tokenize(src).expect("lex");
2958        let ast = Parser::parse(&tokens).expect("parse");
2959        let (typed, terrors, _) = check_program(&ast, src);
2960        assert!(terrors.is_empty(), "{terrors:?}");
2961        let mut cg1 = Codegen::new(src);
2962        cg1.build_tables(&typed);
2963        let mut cg2 = Codegen::new(src);
2964        cg2.build_tables(&typed);
2965        // both independent Codegen instances must resolve "Done" to the
2966        // same variant id on every call.
2967        let id1 = cg1.enum_variant_lookup("Done").copied();
2968        let id2 = cg2.enum_variant_lookup("Done").copied();
2969        assert!(id1.is_some(), "lookup should find a variant named Done");
2970        assert_eq!(
2971            id1, id2,
2972            "variant id must be deterministic across instances"
2973        );
2974    }
2975
2976    // Test 5: stdlib fn-ids live in the STDLIB_FN_BASE namespace; user fns
2977    // get dense ids from 0; fn_effects holds each stdlib entry's effect.
2978    #[test]
2979    fn skeleton_stdlib_fn_ids_are_reserved_and_separate_from_user_ids() {
2980        let src = "fn helper() is pure { }\nfn main() is io { }";
2981        let tokens = Lexer::tokenize(src).expect("lex");
2982        let ast = Parser::parse(&tokens).expect("parse");
2983        let (typed, terrors, _) = check_program(&ast, src);
2984        assert!(terrors.is_empty(), "{terrors:?}");
2985        let mut cg = Codegen::new(src);
2986        cg.build_tables(&typed);
2987        // the first user fn gets fn-id 0 (dense from zero).
2988        let helper_id = cg.fn_table[&FnKey {
2989            type_name: None,
2990            name: "helper".to_string(),
2991        }];
2992        assert_eq!(helper_id, 0);
2993        // stdlib `println` lives at STDLIB_FN_BASE + its index (index 1).
2994        let (println_id, println_effect) = cg.stdlib_table[&FnKey {
2995            type_name: None,
2996            name: "println".to_string(),
2997        }];
2998        assert_eq!(println_id, STDLIB_FN_BASE + 1);
2999        assert_eq!(println_effect, EffectSet::io());
3000        // fn_effects holds the stdlib entry's effect under its fn-id.
3001        assert_eq!(cg.fn_effects.get(&println_id), Some(&EffectSet::io()));
3002        // `sqrt` is pure.
3003        let (sqrt_id, _) = cg.stdlib_table[&FnKey {
3004            type_name: None,
3005            name: "sqrt".to_string(),
3006        }];
3007        assert_eq!(cg.fn_effects.get(&sqrt_id), Some(&EffectSet::pure()));
3008    }
3009
3010    // Test 6: push/pop an empty scope does nothing and leaves no scope.
3011    #[test]
3012    fn skeleton_push_then_pop_empty_scope_is_a_noop() {
3013        let mut cg = Codegen::new("");
3014        cg.program.chunks.push(Chunk::new());
3015        cg.program.fn_names.push("f".to_string());
3016        cg.current_fn_id = 0;
3017        cg.push_scope();
3018        assert_eq!(cg.scopes.len(), 1);
3019        cg.pop_scope_and_emit_defers(1).expect("pop empty scope");
3020        assert!(cg.scopes.is_empty());
3021        // no defers -> no bytecode emitted.
3022        assert!(cg.program.chunks[0].code.is_empty());
3023    }
3024
3025    // Test 7: a single registered defer emits its bytecode at scope exit.
3026    #[test]
3027    fn skeleton_pop_scope_emits_a_single_registered_defer() {
3028        // `fn f() is io { defer println("a") }` -- the defer is registered
3029        // by Task 4's compile_stmt, but here we exercise the helper directly
3030        // by hand-registering a typed Call expression in a scope.
3031        let src = "fn f() is io { }";
3032        let tokens = Lexer::tokenize(src).expect("lex");
3033        let ast = Parser::parse(&tokens).expect("parse");
3034        let (typed, _, _) = check_program(&ast, src);
3035        let mut cg = Codegen::new(src);
3036        cg.build_tables(&typed);
3037        cg.current_fn_id = 0;
3038        cg.push_scope();
3039        // a Void-typed Call -- the defer body. `pop_scope_and_emit_defers`
3040        // compiles it (the arg CONST + the CALL) and pops the scope. the call
3041        // leaves a result value on the stack -- a `void` for a void call --
3042        // which the defer discards with a trailing POP.
3043        let call = TypedExpr::Call {
3044            callee: Box::new(TypedExpr::Ident {
3045                name: "println".to_string(),
3046                ty: QalaType::Function {
3047                    params: vec![QalaType::Str],
3048                    returns: Box::new(QalaType::Void),
3049                },
3050                span: Span::new(0, 7),
3051            }),
3052            args: vec![TypedExpr::Str {
3053                value: "a".to_string(),
3054                ty: QalaType::Str,
3055                span: Span::new(8, 3),
3056            }],
3057            ty: QalaType::Void,
3058            span: Span::new(0, 12),
3059        };
3060        if let Some(scope) = cg.scopes.last_mut() {
3061            scope.deferred.push(call);
3062        }
3063        cg.pop_scope_and_emit_defers(1).expect("emit defers");
3064        assert!(cg.scopes.is_empty(), "scope should be popped");
3065        // the defer body emitted: CONST("a"), CALL(println), then POP -- the
3066        // call leaves a result value (a `void` here) that the defer discards.
3067        let ops = opcodes(&cg.program.chunks[0].code);
3068        assert_eq!(ops, vec![Opcode::Const, Opcode::Call, Opcode::Pop]);
3069    }
3070
3071    // Test 8: a fn with two params has them in slots 0 and 1.
3072    #[test]
3073    fn skeleton_fn_params_occupy_slots_zero_and_one() {
3074        // with Task 1's placeholder compile_block, the body bytecode is just
3075        // a RETURN; this test pins the table/scope wiring: two params, one
3076        // user chunk, fn_names == ["add"].
3077        let p = compile_ok("fn add(a: i64, b: i64) -> i64 is pure { return a + b }");
3078        assert_eq!(p.chunks.len(), 1);
3079        assert_eq!(p.fn_names, vec!["add".to_string()]);
3080    }
3081
3082    // Test 9: struct / enum / interface items emit no chunks.
3083    #[test]
3084    fn skeleton_type_level_items_emit_no_chunks() {
3085        let p = compile_ok(
3086            "struct Point { x: i64, y: i64 }\n\
3087             enum Dir { North, South }\n\
3088             interface Show { fn show(self) -> str }\n\
3089             fn main() is io { }",
3090        );
3091        // only `main` produces a chunk.
3092        assert_eq!(p.chunks.len(), 1);
3093        assert_eq!(p.fn_names, vec!["main".to_string()]);
3094    }
3095
3096    // Test 10: two functions get sequential fn-ids and chunks.
3097    #[test]
3098    fn skeleton_two_functions_get_sequential_fn_ids() {
3099        let p = compile_ok("fn first() is pure { }\nfn second() is pure { }");
3100        assert_eq!(p.chunks.len(), 2);
3101        assert_eq!(p.fn_names, vec!["first".to_string(), "second".to_string()]);
3102    }
3103
3104    // Test 11: an empty `fn main` body still emits a RETURN.
3105    #[test]
3106    fn skeleton_empty_fn_body_emits_a_return() {
3107        let p = compile_ok("fn main() is io { }");
3108        assert_eq!(p.chunks[0].code, vec![Opcode::Return as u8]);
3109        assert_eq!(p.chunks[0].source_lines.len(), 1);
3110    }
3111
3112    // Test 12: line_at maps a span byte-offset to a 1-based source line.
3113    #[test]
3114    fn skeleton_line_at_maps_span_to_source_line() {
3115        let cg = Codegen::new("abc");
3116        assert_eq!(cg.line_at(Span::new(0, 3)), 1);
3117        let cg2 = Codegen::new("abc\ndef");
3118        // byte 4 is the 'd' on line 2.
3119        assert_eq!(cg2.line_at(Span::new(4, 3)), 2);
3120    }
3121
3122    // ===== Task 2: compile_expr literals / locals / binary / unary / calls ===
3123
3124    // Test 1: an integer literal expression compiles to one CONST(I64). the
3125    // wrapper fn returns i64 so the literal is the body's trailing value.
3126    #[test]
3127    fn compile_expr_int_literal_emits_one_const() {
3128        let (code, consts) = compile_expr_of("fn f() -> i64 is pure { 42 }", "f");
3129        assert_eq!(opcodes(&code), vec![Opcode::Const]);
3130        assert_eq!(consts, vec![ConstValue::I64(42)]);
3131    }
3132
3133    // Test 2: a float literal compiles to one CONST(F64).
3134    #[test]
3135    fn compile_expr_float_literal_emits_one_const() {
3136        let (code, consts) = compile_expr_of("fn f() -> f64 is pure { 3.5 }", "f");
3137        assert_eq!(opcodes(&code), vec![Opcode::Const]);
3138        assert_eq!(consts, vec![ConstValue::F64(3.5)]);
3139    }
3140
3141    // Test 3: a bool literal compiles to CONST(Bool).
3142    #[test]
3143    fn compile_expr_bool_literal_emits_one_const() {
3144        let (_, consts) = compile_expr_of("fn f() -> bool is pure { true }", "f");
3145        assert_eq!(consts, vec![ConstValue::Bool(true)]);
3146    }
3147
3148    // Test 4: a byte literal compiles to CONST(Byte).
3149    #[test]
3150    fn compile_expr_byte_literal_emits_one_const() {
3151        let (_, consts) = compile_expr_of("fn f() -> byte is pure { b'A' }", "f");
3152        assert_eq!(consts, vec![ConstValue::Byte(0x41)]);
3153    }
3154
3155    // Test 5: a string literal compiles to CONST(Str).
3156    #[test]
3157    fn compile_expr_str_literal_emits_one_const() {
3158        let (_, consts) = compile_expr_of("fn f() -> str is pure { \"hi\" }", "f");
3159        assert_eq!(consts, vec![ConstValue::Str("hi".to_string())]);
3160    }
3161
3162    // Test 6: an identifier bound as a parameter compiles to GET_LOCAL.
3163    #[test]
3164    fn compile_expr_ident_param_emits_get_local() {
3165        let (code, _) = compile_expr_of("fn f(x: i64) -> i64 is pure { x }", "f");
3166        assert_eq!(opcodes(&code), vec![Opcode::GetLocal]);
3167        // the GET_LOCAL operand is slot 0 (the first parameter).
3168        assert_eq!(code[0], Opcode::GetLocal as u8);
3169        assert_eq!(u16::from_le_bytes([code[1], code[2]]), 0);
3170    }
3171
3172    // Test 7: `1 + 2` folds at codegen to a single CONST(I64(3)) -- two
3173    // operand CONSTs + ADD collapse to one CONST.
3174    #[test]
3175    fn compile_expr_binary_arith_folds_to_one_const() {
3176        let (code, consts) = compile_expr_of("fn f() -> i64 is pure { 1 + 2 }", "f");
3177        assert_eq!(opcodes(&code), vec![Opcode::Const], "1+2 should fold");
3178        // the folded pool entry is I64(3); the two source CONSTs were rewound
3179        // (orphans 0 and 1 remain in the pool, the live CONST points at 2).
3180        assert_eq!(consts.last(), Some(&ConstValue::I64(3)));
3181    }
3182
3183    // Test 8: `1 + 2 + 3` folds bottom-up to a single CONST(I64(6)).
3184    #[test]
3185    fn compile_expr_binary_chain_folds_bottom_up() {
3186        let (code, consts) = compile_expr_of("fn f() -> i64 is pure { 1 + 2 + 3 }", "f");
3187        assert_eq!(opcodes(&code), vec![Opcode::Const]);
3188        assert_eq!(consts.last(), Some(&ConstValue::I64(6)));
3189    }
3190
3191    // Test 9: `x + 1` with x a local does NOT fold.
3192    #[test]
3193    fn compile_expr_binary_with_local_does_not_fold() {
3194        let (code, _) = compile_expr_of("fn f(x: i64) -> i64 is pure { x + 1 }", "f");
3195        assert_eq!(
3196            opcodes(&code),
3197            vec![Opcode::GetLocal, Opcode::Const, Opcode::Add]
3198        );
3199    }
3200
3201    // Test 10: `i64::MAX + 1` is a fold-time IntegerOverflow.
3202    #[test]
3203    fn compile_expr_fold_add_overflow_is_an_error() {
3204        let err = compile_expr_err("fn f() -> i64 is pure { 9223372036854775807 + 1 }", "f");
3205        match err {
3206            QalaError::IntegerOverflow { op, lhs, rhs, .. } => {
3207                assert_eq!(op, BinOp::Add);
3208                assert_eq!(lhs, i64::MAX);
3209                assert_eq!(rhs, 1);
3210            }
3211            other => panic!("expected IntegerOverflow, got {other:?}"),
3212        }
3213    }
3214
3215    // Test 11: `i64::MAX * 2` overflows on fold.
3216    #[test]
3217    fn compile_expr_fold_mul_overflow_is_an_error() {
3218        let err = compile_expr_err("fn f() -> i64 is pure { 9223372036854775807 * 2 }", "f");
3219        match err {
3220            QalaError::IntegerOverflow { op, lhs, rhs, .. } => {
3221                assert_eq!(op, BinOp::Mul);
3222                assert_eq!(lhs, i64::MAX);
3223                assert_eq!(rhs, 2);
3224            }
3225            other => panic!("expected IntegerOverflow, got {other:?}"),
3226        }
3227    }
3228
3229    // Test 12: `1e300 * 1e300` folds to F64(inf) with NO error.
3230    #[test]
3231    fn compile_expr_fold_f64_overflow_yields_infinity() {
3232        let (code, consts) = compile_expr_of("fn f() -> f64 is pure { 1e300 * 1e300 }", "f");
3233        assert_eq!(opcodes(&code), vec![Opcode::Const]);
3234        match consts.last() {
3235            Some(ConstValue::F64(x)) => assert!(x.is_infinite() && *x > 0.0),
3236            other => panic!("expected F64(inf), got {other:?}"),
3237        }
3238    }
3239
3240    // Test 13: `0.0 / 0.0` folds to F64(NaN) with NO error.
3241    #[test]
3242    fn compile_expr_fold_f64_div_zero_yields_nan() {
3243        let (code, consts) = compile_expr_of("fn f() -> f64 is pure { 0.0 / 0.0 }", "f");
3244        assert_eq!(opcodes(&code), vec![Opcode::Const]);
3245        match consts.last() {
3246            Some(ConstValue::F64(x)) => assert!(x.is_nan()),
3247            other => panic!("expected F64(NaN), got {other:?}"),
3248        }
3249    }
3250
3251    // Test 14: `"a" + "b"` folds to a single CONST(Str("ab")).
3252    #[test]
3253    fn compile_expr_fold_string_concat() {
3254        let (code, consts) = compile_expr_of("fn f() -> str is pure { \"a\" + \"b\" }", "f");
3255        assert_eq!(opcodes(&code), vec![Opcode::Const]);
3256        assert_eq!(consts.last(), Some(&ConstValue::Str("ab".to_string())));
3257    }
3258
3259    // Test 15: `1 < 2` folds to CONST(Bool(true)).
3260    #[test]
3261    fn compile_expr_fold_comparison() {
3262        let (code, consts) = compile_expr_of("fn f() -> bool is pure { 1 < 2 }", "f");
3263        assert_eq!(opcodes(&code), vec![Opcode::Const]);
3264        assert_eq!(consts.last(), Some(&ConstValue::Bool(true)));
3265    }
3266
3267    // Test 16: `x < 2` with x a local emits GET_LOCAL + CONST + LT.
3268    #[test]
3269    fn compile_expr_comparison_with_local_does_not_fold() {
3270        let (code, _) = compile_expr_of("fn f(x: i64) -> bool is pure { x < 2 }", "f");
3271        assert_eq!(
3272            opcodes(&code),
3273            vec![Opcode::GetLocal, Opcode::Const, Opcode::Lt]
3274        );
3275    }
3276
3277    // Test 17: `true && false` folds to CONST(Bool(false)).
3278    #[test]
3279    fn compile_expr_fold_boolean_and() {
3280        let (code, consts) = compile_expr_of("fn f() -> bool is pure { true && false }", "f");
3281        assert_eq!(opcodes(&code), vec![Opcode::Const]);
3282        assert_eq!(consts.last(), Some(&ConstValue::Bool(false)));
3283    }
3284
3285    // Test 18: unary fold -- `!true`, `!false`, `-(42)` all collapse to one
3286    // CONST. (the `-(i64::MIN)` overflow case is unreachable from source --
3287    // the lexer caps integer literals at the i64::MAX magnitude, so a
3288    // literal i64::MIN cannot be written and then negated. the `checked_neg`
3289    // overflow path in `try_fold_unary` is still exercised by the
3290    // ComptimeInterpreter's NEG opcode test in Task 5.)
3291    #[test]
3292    fn compile_expr_fold_unary() {
3293        let (code, consts) = compile_expr_of("fn f() -> bool is pure { !true }", "f");
3294        assert_eq!(opcodes(&code), vec![Opcode::Const]);
3295        assert_eq!(consts.last(), Some(&ConstValue::Bool(false)));
3296        let (_, consts) = compile_expr_of("fn f() -> bool is pure { !false }", "f");
3297        assert_eq!(consts.last(), Some(&ConstValue::Bool(true)));
3298        let (code, consts) = compile_expr_of("fn f() -> i64 is pure { -(42) }", "f");
3299        assert_eq!(opcodes(&code), vec![Opcode::Const]);
3300        assert_eq!(consts.last(), Some(&ConstValue::I64(-42)));
3301    }
3302
3303    // Test 19: `-(3.5)` folds to CONST(F64(-3.5)).
3304    #[test]
3305    fn compile_expr_fold_f64_unary() {
3306        let (code, consts) = compile_expr_of("fn f() -> f64 is pure { -(3.5) }", "f");
3307        assert_eq!(opcodes(&code), vec![Opcode::Const]);
3308        assert_eq!(consts.last(), Some(&ConstValue::F64(-3.5)));
3309    }
3310
3311    // Test 20: mixed i64/f64 with locals picks ADD vs F_ADD by operand type.
3312    #[test]
3313    fn compile_expr_picks_int_vs_float_opcode() {
3314        let (code, _) = compile_expr_of("fn f(a: i64, b: i64) -> i64 is pure { a + b }", "f");
3315        assert!(opcodes(&code).contains(&Opcode::Add));
3316        let (code, _) = compile_expr_of("fn f(a: f64, b: f64) -> f64 is pure { a + b }", "f");
3317        assert!(opcodes(&code).contains(&Opcode::FAdd));
3318    }
3319
3320    // Test 21: a user-fn call emits args then CALL(fn_id, argc).
3321    #[test]
3322    fn compile_expr_user_call_emits_args_then_call() {
3323        let src = "fn add(a: i64, b: i64) -> i64 is pure { return a + b }\n\
3324                   fn main() -> i64 is pure { add(1, 2) }";
3325        let (code, _) = compile_expr_of(src, "main");
3326        assert_eq!(
3327            opcodes(&code),
3328            vec![Opcode::Const, Opcode::Const, Opcode::Call]
3329        );
3330        // CALL operand: fn-id of `add` (0), argc 2.
3331        let call_off = 6; // two CONSTs (3 bytes each) precede CALL.
3332        assert_eq!(code[call_off], Opcode::Call as u8);
3333        assert_eq!(
3334            u16::from_le_bytes([code[call_off + 1], code[call_off + 2]]),
3335            0
3336        );
3337        assert_eq!(code[call_off + 3], 2);
3338    }
3339
3340    // Test 22: a stdlib call emits the arg then CALL(stdlib_id, argc).
3341    #[test]
3342    fn compile_expr_stdlib_call_emits_arg_then_call() {
3343        let (code, _) = compile_expr_of("fn main() is io { println(\"hi\") }", "main");
3344        assert_eq!(opcodes(&code), vec![Opcode::Const, Opcode::Call]);
3345        // the CALL fn-id is println's stdlib id (STDLIB_FN_BASE + 1).
3346        assert_eq!(code[3], Opcode::Call as u8);
3347        assert_eq!(u16::from_le_bytes([code[4], code[5]]), STDLIB_FN_BASE + 1);
3348        assert_eq!(code[6], 1);
3349    }
3350
3351    // Test 23: every emission records its source line; lockstep holds.
3352    #[test]
3353    fn compile_expr_records_source_lines_in_lockstep() {
3354        // a two-line body: the trailing expr is on line 2.
3355        let src = "fn f() -> i64 is pure {\n    x_helper() + 1\n}\n\
3356                   fn x_helper() -> i64 is pure { 5 }";
3357        let typed = typecheck(src);
3358        let mut cg = Codegen::new(src);
3359        cg.build_tables(&typed);
3360        let fn_id = cg.fn_table[&FnKey {
3361            type_name: None,
3362            name: "f".to_string(),
3363        }];
3364        cg.current_fn_id = fn_id;
3365        cg.push_scope();
3366        let decl = typed
3367            .iter()
3368            .find_map(|i| match i {
3369                TypedItem::Fn(d) if d.name == "f" => Some(d),
3370                _ => None,
3371            })
3372            .unwrap();
3373        cg.compile_expr(decl.body.value.as_ref().unwrap())
3374            .expect("compile_expr");
3375        let chunk = &cg.program.chunks[fn_id as usize];
3376        // lockstep invariant.
3377        assert_eq!(chunk.code.len(), chunk.source_lines.len());
3378        // every emitted byte for the trailing expr is on line 2.
3379        assert!(chunk.source_lines.iter().all(|&l| l == 2));
3380    }
3381
3382    // ===== Task 3: compound expressions + match ==============================
3383
3384    // Test 1: a simple pipeline `5 |> double` desugars to `double(5)`.
3385    #[test]
3386    fn compile_expr_pipeline_desugars_to_call() {
3387        let src = "fn double(x: i64) -> i64 is pure { return x * 2 }\n\
3388                   fn f() -> i64 is pure { 5 |> double }";
3389        let (code, _) = compile_expr_of(src, "f");
3390        assert_eq!(opcodes(&code), vec![Opcode::Const, Opcode::Call]);
3391        // the CALL targets `double` (fn-id 0) with argc 1.
3392        assert_eq!(code[3], Opcode::Call as u8);
3393        assert_eq!(u16::from_le_bytes([code[4], code[5]]), 0);
3394        assert_eq!(code[6], 1);
3395    }
3396
3397    // Test 2: a pipeline chain `5 |> double |> add_one`.
3398    #[test]
3399    fn compile_expr_pipeline_chain() {
3400        let src = "fn double(x: i64) -> i64 is pure { return x * 2 }\n\
3401                   fn add_one(x: i64) -> i64 is pure { return x + 1 }\n\
3402                   fn f() -> i64 is pure { 5 |> double |> add_one }";
3403        let (code, _) = compile_expr_of(src, "f");
3404        assert_eq!(
3405            opcodes(&code),
3406            vec![Opcode::Const, Opcode::Call, Opcode::Call]
3407        );
3408    }
3409
3410    // Test 3: a pipeline with extra args prepends the lhs as the first
3411    // argument. (a user fn with extra args fails the Phase 3 pipeline arity
3412    // check; only the generic stdlib functions accept the form, and only in
3413    // a `let` binding -- a pipeline as a fn trailing-return-value hits a
3414    // stricter check path. so this mirrors the bundled `pipeline.qala`:
3415    // `let evens = numbers |> filter(is_even)`.)
3416    #[test]
3417    fn compile_expr_pipeline_with_extra_args() {
3418        let src = "fn is_even(x: i64) -> bool is pure { return x % 2 == 0 }\n\
3419                   fn main() is io {\n\
3420                     let numbers = [1, 2, 3]\n\
3421                     let evens = numbers |> filter(is_even)\n\
3422                   }";
3423        let p = compile_ok(src);
3424        let main_id = p.fn_names.iter().position(|n| n == "main").unwrap();
3425        let ops = opcodes(&p.chunks[main_id].code);
3426        // the pipeline desugars to a CALL of `filter`; its argc is 2 (the
3427        // piped array plus the `is_even` function argument).
3428        assert!(ops.contains(&Opcode::Call), "pipeline emits a CALL");
3429        // find the CALL and check its argc operand is 2.
3430        let mut off = 0;
3431        let mut found_argc2 = false;
3432        let code = &p.chunks[main_id].code;
3433        while off < code.len() {
3434            let op = Opcode::from_u8(code[off]).unwrap();
3435            if op == Opcode::Call && code[off + 3] == 2 {
3436                found_argc2 = true;
3437            }
3438            off += 1 + op.operand_bytes() as usize;
3439        }
3440        assert!(found_argc2, "the filter CALL should have argc 2");
3441    }
3442
3443    // Test 4: a simple interpolation `"hi {name}"` with name already str.
3444    #[test]
3445    fn compile_expr_interpolation_no_conversion() {
3446        let src = "fn f(name: str) -> str is pure { \"hi {name}\" }";
3447        let (code, _) = compile_expr_of(src, "f");
3448        // CONST("hi ") + GET_LOCAL(name) + CONCAT_N -- no TO_STR (str arg).
3449        assert_eq!(
3450            opcodes(&code),
3451            vec![Opcode::Const, Opcode::GetLocal, Opcode::ConcatN]
3452        );
3453    }
3454
3455    // Test 5: an interpolation with a non-str expr emits TO_STR.
3456    #[test]
3457    fn compile_expr_interpolation_with_conversion() {
3458        let src = "fn f(x: i64) -> str is pure { \"got: {x}\" }";
3459        let (code, _) = compile_expr_of(src, "f");
3460        assert_eq!(
3461            opcodes(&code),
3462            vec![
3463                Opcode::Const,
3464                Opcode::GetLocal,
3465                Opcode::ToStr,
3466                Opcode::ConcatN
3467            ]
3468        );
3469    }
3470
3471    // Test 6: an all-literal "interpolation" folds to a single CONST.
3472    #[test]
3473    fn compile_expr_all_literal_string_is_one_const() {
3474        let (code, consts) = compile_expr_of("fn f() -> str is pure { \"hello, world!\" }", "f");
3475        assert_eq!(opcodes(&code), vec![Opcode::Const]);
3476        assert_eq!(
3477            consts.last(),
3478            Some(&ConstValue::Str("hello, world!".to_string()))
3479        );
3480    }
3481
3482    // Test 7: `?` propagation emits MATCH_VARIANT + RETURN. it does NOT DUP --
3483    // `?` is a single Ok-or-Err test; MATCH_VARIANT consumes the scrutinee on a
3484    // hit and leaves it on a miss, so one copy suffices.
3485    #[test]
3486    fn compile_expr_try_emits_match_variant_and_return() {
3487        let src = "fn get() -> Result<i64, str> is pure { return Ok(1) }\n\
3488                   fn f() -> Result<i64, str> is pure { let x = get()?\n return Ok(x) }";
3489        let p = compile_ok(src);
3490        let f_id = p.fn_names.iter().position(|n| n == "f").unwrap();
3491        let ops = opcodes(&p.chunks[f_id].code);
3492        assert!(
3493            ops.contains(&Opcode::MatchVariant),
3494            "try should MATCH_VARIANT"
3495        );
3496        assert!(
3497            ops.contains(&Opcode::Return),
3498            "try should RETURN on the err path"
3499        );
3500    }
3501
3502    // Test 8: `or` fallback emits MATCH_VARIANT + the fallback. like `?`, no
3503    // DUP -- a single test, the scrutinee copy is consumed on a hit.
3504    #[test]
3505    fn compile_expr_or_else_emits_match_variant_and_fallback() {
3506        let src = "fn get() -> Result<i64, str> is pure { return Ok(1) }\n\
3507                   fn f() -> i64 is pure { get() or 0 }";
3508        let (code, _) = compile_expr_of(src, "f");
3509        let ops = opcodes(&code);
3510        assert!(ops.contains(&Opcode::MatchVariant));
3511        // the fallback `0` is a CONST somewhere after the MATCH_VARIANT.
3512        assert!(ops.contains(&Opcode::Const));
3513    }
3514
3515    // Test 9: a method call emits the receiver then CALL.
3516    #[test]
3517    fn compile_expr_method_call_emits_receiver_then_call() {
3518        let src = "fn f(h: FileHandle) -> Result<str, str> is io { h.read_all() }";
3519        let (code, _) = compile_expr_of(src, "f");
3520        // GET_LOCAL(h) + CALL(FileHandle.read_all stdlib id).
3521        assert_eq!(opcodes(&code), vec![Opcode::GetLocal, Opcode::Call]);
3522        // the receiver counts as argc 1.
3523        let call_off = 3;
3524        assert_eq!(code[call_off + 3], 1);
3525    }
3526
3527    // Test 10: field access emits the object then FIELD with the field idx.
3528    #[test]
3529    fn compile_expr_field_access_emits_field_opcode() {
3530        let src = "struct Point { x: i64, y: i64 }\n\
3531                   fn f(p: Point) -> i64 is pure { p.y }";
3532        let (code, _) = compile_expr_of(src, "f");
3533        assert_eq!(opcodes(&code), vec![Opcode::GetLocal, Opcode::Field]);
3534        // `y` is field index 1.
3535        assert_eq!(u16::from_le_bytes([code[4], code[5]]), 1);
3536    }
3537
3538    // Test 11: indexing emits obj + index + INDEX.
3539    #[test]
3540    fn compile_expr_index_emits_index_opcode() {
3541        let src = "fn f(arr: [i64]) -> i64 is pure { arr[0] }";
3542        let (code, _) = compile_expr_of(src, "f");
3543        assert_eq!(
3544            opcodes(&code),
3545            vec![Opcode::GetLocal, Opcode::Const, Opcode::Index]
3546        );
3547    }
3548
3549    // Test 12: a tuple literal emits its elements then MAKE_TUPLE.
3550    #[test]
3551    fn compile_expr_tuple_emits_make_tuple() {
3552        let (code, _) = compile_expr_of("fn f() -> (i64, i64, i64) is pure { (1, 2, 3) }", "f");
3553        let ops = opcodes(&code);
3554        assert_eq!(ops.last(), Some(&Opcode::MakeTuple));
3555        // the operand is the element count, 3.
3556        let mt = code.len() - 3;
3557        assert_eq!(u16::from_le_bytes([code[mt + 1], code[mt + 2]]), 3);
3558    }
3559
3560    // Test 13: an array literal emits its elements then MAKE_ARRAY. an array
3561    // literal types as a fixed `[i64; 3]`, so the wrapper fn returns that.
3562    #[test]
3563    fn compile_expr_array_lit_emits_make_array() {
3564        let (code, _) = compile_expr_of("fn f() -> [i64; 3] is pure { [1, 2, 3] }", "f");
3565        let ops = opcodes(&code);
3566        assert_eq!(ops.last(), Some(&Opcode::MakeArray));
3567        let ma = code.len() - 3;
3568        assert_eq!(u16::from_le_bytes([code[ma + 1], code[ma + 2]]), 3);
3569    }
3570
3571    // Test 14: an array-repeat `[0; 5]` emits the value 5 times + MAKE_ARRAY.
3572    #[test]
3573    fn compile_expr_array_repeat_materializes() {
3574        let (code, _) = compile_expr_of("fn f() -> [i64] is pure { [0; 5] }", "f");
3575        let ops = opcodes(&code);
3576        // five CONSTs then MAKE_ARRAY.
3577        assert_eq!(ops.iter().filter(|o| **o == Opcode::Const).count(), 5);
3578        assert_eq!(ops.last(), Some(&Opcode::MakeArray));
3579    }
3580
3581    // Test 15: a struct literal emits its field values then MAKE_STRUCT. the
3582    // MAKE_STRUCT operand is now a struct id (Point is the first struct
3583    // registered, so id 0), NOT the bare field count.
3584    #[test]
3585    fn compile_expr_struct_lit_emits_make_struct() {
3586        let src = "struct Point { x: f64, y: f64 }\n\
3587                   fn f() -> Point is pure { Point { x: 1.0, y: 2.0 } }";
3588        let (code, _) = compile_expr_of(src, "f");
3589        let ops = opcodes(&code);
3590        assert_eq!(ops.last(), Some(&Opcode::MakeStruct));
3591        let ms = code.len() - 3;
3592        assert_eq!(
3593            u16::from_le_bytes([code[ms + 1], code[ms + 2]]),
3594            0,
3595            "MAKE_STRUCT operand is the struct id of the first registered struct"
3596        );
3597    }
3598
3599    // Test 15b: a struct literal registers its struct in Program.structs --
3600    // the id MAKE_STRUCT carries indexes a StructInfo with the declared name
3601    // and field count.
3602    #[test]
3603    fn struct_literal_registers_the_struct_in_the_program_table() {
3604        let src = "struct Point { x: f64, y: f64 }\n\
3605                   fn main() -> Point is pure { Point { x: 1.0, y: 2.0 } }";
3606        let typed = typecheck(src);
3607        let program = compile_program(&typed, src).expect("compile");
3608        assert_eq!(program.structs.len(), 1, "exactly one struct registered");
3609        assert_eq!(program.structs[0].name, "Point");
3610        assert_eq!(program.structs[0].field_count, 2);
3611    }
3612
3613    // Test 15c: two literals of the same struct reuse one id; two distinct
3614    // structs get distinct ids assigned deterministically.
3615    #[test]
3616    fn repeated_struct_literals_reuse_one_id_distinct_structs_get_distinct_ids() {
3617        let src = "struct A { v: i64 }\n\
3618                   struct B { v: i64 }\n\
3619                   fn two() -> A is pure { let _x = B { v: 1 }\nA { v: 2 } }\n\
3620                   fn again() -> A is pure { A { v: 3 } }\n\
3621                   fn main() is pure { }";
3622        let typed = typecheck(src);
3623        let program = compile_program(&typed, src).expect("compile");
3624        // A is referenced twice but registered once; B once. two entries.
3625        assert_eq!(program.structs.len(), 2, "A reused, B distinct");
3626        // B is seen first (inside `two`'s body), so it takes id 0, A id 1.
3627        let names: Vec<&str> = program.structs.iter().map(|s| s.name.as_str()).collect();
3628        assert_eq!(names, vec!["B", "A"]);
3629    }
3630
3631    // Test 16: an enum-variant constructor emits MAKE_ENUM_VARIANT.
3632    #[test]
3633    fn compile_expr_enum_variant_constructor() {
3634        let src = "enum Shape { Circle(f64), Rect(f64, f64) }\n\
3635                   fn f() -> Shape is pure { Circle(5.0) }";
3636        let (code, _) = compile_expr_of(src, "f");
3637        let ops = opcodes(&code);
3638        assert_eq!(ops, vec![Opcode::Const, Opcode::MakeEnumVariant]);
3639        // the payload count operand is 1.
3640        let mev = 3;
3641        assert_eq!(code[mev + 3], 1);
3642    }
3643
3644    // Test 17: a three-variant match emits MATCH_VARIANT per variant arm.
3645    #[test]
3646    fn compile_expr_match_with_variants() {
3647        let src = "enum Shape { Circle(f64), Rect(f64, f64), Triangle }\n\
3648                   fn f(s: Shape) -> f64 is pure {\n\
3649                     match s { Circle(r) => 1.0, Rect(w, h) => 2.0, Triangle => 0.0 }\n\
3650                   }";
3651        let (code, _) = compile_expr_of(src, "f");
3652        let ops = opcodes(&code);
3653        // three variant arms -> three MATCH_VARIANT opcodes.
3654        assert_eq!(
3655            ops.iter().filter(|o| **o == Opcode::MatchVariant).count(),
3656            3
3657        );
3658        // the scrutinee is loaded once via GET_LOCAL.
3659        assert!(ops.contains(&Opcode::GetLocal));
3660    }
3661
3662    // Test 18: a match with a wildcard uses POP for the `_` arm.
3663    #[test]
3664    fn compile_expr_match_with_wildcard() {
3665        let src = "fn f(v: i64) -> str is pure {\n\
3666                     match v { 0 => \"zero\", _ => \"other\" }\n\
3667                   }";
3668        let (code, _) = compile_expr_of(src, "f");
3669        let ops = opcodes(&code);
3670        // the literal arm uses DUP + CONST + EQ; the wildcard uses POP.
3671        assert!(ops.contains(&Opcode::Eq), "literal arm should test via EQ");
3672        assert!(
3673            ops.contains(&Opcode::Pop),
3674            "wildcard arm should POP scrutinee"
3675        );
3676    }
3677
3678    // Test 19: a match with guards (the classify example shape) compiles.
3679    #[test]
3680    fn compile_expr_match_with_guards() {
3681        let src = "fn classify(value: i64) -> str is pure {\n\
3682                     match value {\n\
3683                       v if v > 0 => \"positive\",\n\
3684                       v if v < 0 => \"negative\",\n\
3685                       _ => \"zero\",\n\
3686                     }\n\
3687                   }";
3688        let (code, _) = compile_expr_of(src, "classify");
3689        let ops = opcodes(&code);
3690        // each guard compiles to a comparison + JUMP_IF_FALSE.
3691        assert!(
3692            ops.contains(&Opcode::JumpIfFalse),
3693            "guards emit JUMP_IF_FALSE"
3694        );
3695        assert!(!code.is_empty());
3696    }
3697
3698    // Test 20: a range `0..3` materializes to CONSTs + MAKE_ARRAY.
3699    #[test]
3700    fn compile_expr_range_materializes_to_array() {
3701        let src = "fn f() -> void is pure { for i in 0..3 { } }";
3702        // the range is inside the for; compile the whole fn and inspect.
3703        let p = compile_ok(src);
3704        let ops = opcodes(&p.chunks[0].code);
3705        // 0..3 -> CONST(0) CONST(1) CONST(2) MAKE_ARRAY(3) at the loop head.
3706        assert!(ops.contains(&Opcode::MakeArray));
3707    }
3708
3709    // Test 21: a block expression compiles its statements + trailing value.
3710    #[test]
3711    fn compile_expr_block_expression() {
3712        let src = "fn f() -> i64 is pure { let y = { let x = 1\n x + 1 }\n return y }";
3713        let p = compile_ok(src);
3714        // the block's `let x = 1` + `x + 1` + the outer `let y` all compile.
3715        assert!(!p.chunks[0].code.is_empty());
3716        let ops = opcodes(&p.chunks[0].code);
3717        assert!(ops.contains(&Opcode::SetLocal));
3718        assert!(ops.contains(&Opcode::Return));
3719    }
3720
3721    // ===== Task 4: statements, DCE, defer, six-examples smoke ================
3722
3723    /// the opcodes of the single chunk a one-fn program compiles to.
3724    fn fn_ops(src: &str, fn_name: &str) -> Vec<Opcode> {
3725        let p = compile_ok(src);
3726        let id = p
3727            .fn_names
3728            .iter()
3729            .position(|n| n == fn_name)
3730            .unwrap_or_else(|| panic!("fn `{fn_name}` not compiled"));
3731        opcodes(&p.chunks[id].code)
3732    }
3733
3734    /// the raw code bytes of a named function's chunk.
3735    fn fn_code(src: &str, fn_name: &str) -> Vec<u8> {
3736        let p = compile_ok(src);
3737        let id = p.fn_names.iter().position(|n| n == fn_name).unwrap();
3738        p.chunks[id].code.clone()
3739    }
3740
3741    // Test 1: `let x = 1` emits CONST then SET_LOCAL.
3742    #[test]
3743    fn stmt_let_emits_const_and_set_local() {
3744        let ops = fn_ops("fn main() is io { let x = 1\n println(\"{x}\") }", "main");
3745        // the first two opcodes are the let's CONST + SET_LOCAL.
3746        assert_eq!(ops[0], Opcode::Const);
3747        assert_eq!(ops[1], Opcode::SetLocal);
3748    }
3749
3750    // Test 2: `if cond { } else { }` emits JUMP_IF_FALSE + JUMP.
3751    #[test]
3752    fn stmt_if_with_else_emits_jump_pattern() {
3753        let src = "fn main() is io {\n\
3754                     let c = 1 < 2\n\
3755                     if c { println(\"a\") } else { println(\"b\") }\n\
3756                   }";
3757        let ops = fn_ops(src, "main");
3758        assert!(ops.contains(&Opcode::JumpIfFalse));
3759        assert!(ops.contains(&Opcode::Jump));
3760    }
3761
3762    // Test 3: `if cond { }` without else emits JUMP_IF_FALSE, no extra JUMP.
3763    #[test]
3764    fn stmt_if_without_else_emits_only_jump_if_false() {
3765        let src = "fn main() is io {\n\
3766                     let c = 1 < 2\n\
3767                     if c { println(\"a\") }\n\
3768                   }";
3769        let ops = fn_ops(src, "main");
3770        assert!(ops.contains(&Opcode::JumpIfFalse));
3771    }
3772
3773    // Test 4: `while cond { }` records loop_start + JUMP_IF_FALSE + a back-
3774    // jump (JUMP with a negative offset). (Qala v1 has no assignment
3775    // statement, so the loop body uses a `break` rather than mutating a
3776    // local; the codegen shape -- exit jump + back-jump -- is the same.)
3777    #[test]
3778    fn stmt_while_emits_loop_pattern() {
3779        let src = "fn main(flag: bool) is io {\n\
3780                     while flag { break }\n\
3781                   }";
3782        let ops = fn_ops(src, "main");
3783        assert!(ops.contains(&Opcode::JumpIfFalse));
3784        // a while emits at least two JUMP-family opcodes (the exit + the
3785        // back-jump).
3786        let jumps = ops
3787            .iter()
3788            .filter(|o| matches!(o, Opcode::Jump | Opcode::JumpIfFalse))
3789            .count();
3790        assert!(jumps >= 2, "while should emit an exit jump and a back-jump");
3791    }
3792
3793    // Test 5: `for i in 0..3 { }` materializes the range and emits the
3794    // indexed-walk pattern (LEN + LT + INDEX).
3795    #[test]
3796    fn stmt_for_emits_indexed_walk() {
3797        let ops = fn_ops(
3798            "fn main() is io { for i in 0..3 { println(\"{i}\") } }",
3799            "main",
3800        );
3801        assert!(ops.contains(&Opcode::MakeArray), "the range materializes");
3802        assert!(ops.contains(&Opcode::Len), "the bounds check uses LEN");
3803        assert!(ops.contains(&Opcode::Index), "the element load uses INDEX");
3804    }
3805
3806    // Test 6: `return x + 1` compiles the value then RETURN; the block is
3807    // terminated.
3808    #[test]
3809    fn stmt_return_with_value_emits_return() {
3810        let ops = fn_ops("fn f(x: i64) -> i64 is pure { return x + 1 }", "f");
3811        assert_eq!(ops.last(), Some(&Opcode::Return));
3812    }
3813
3814    // Test 7: a bare `return` in a void fn emits RETURN.
3815    #[test]
3816    fn stmt_bare_return_emits_return() {
3817        let ops = fn_ops("fn main() is io { return }", "main");
3818        assert!(ops.contains(&Opcode::Return));
3819    }
3820
3821    // Test 10: a `defer` registers but emits nothing at the defer site.
3822    #[test]
3823    fn stmt_defer_emits_nothing_at_the_defer_site() {
3824        // a fn with a single defer: the defer body runs at the fall-through
3825        // exit, not where `defer` is written. so the FIRST opcode is the
3826        // defer body's, not anything from before it -- verify the defer body
3827        // (a CALL) appears, and that nothing is emitted "early".
3828        let src = "fn cleanup() is io { }\n\
3829                   fn main() is io { defer cleanup() }";
3830        let ops = fn_ops(src, "main");
3831        // the only real work is the deferred CALL at the fall-through, then
3832        // RETURN.
3833        assert!(ops.contains(&Opcode::Call), "the defer body runs at exit");
3834        assert_eq!(ops.last(), Some(&Opcode::Return));
3835    }
3836
3837    // Test 11: defer LIFO -- three defers run last-registered-first.
3838    #[test]
3839    fn stmt_defer_lifo_at_fall_through() {
3840        let src = "fn a() is io { }\n\
3841                   fn b() is io { }\n\
3842                   fn c() is io { }\n\
3843                   fn main() is io { defer a()\n defer b()\n defer c() }";
3844        let p = compile_ok(src);
3845        let main_id = p.fn_names.iter().position(|n| n == "main").unwrap();
3846        let a_id = p.fn_names.iter().position(|n| n == "a").unwrap() as u16;
3847        let b_id = p.fn_names.iter().position(|n| n == "b").unwrap() as u16;
3848        let c_id = p.fn_names.iter().position(|n| n == "c").unwrap() as u16;
3849        // collect the fn-ids of every CALL in main, in emission order.
3850        let code = &p.chunks[main_id].code;
3851        let mut call_ids = Vec::new();
3852        let mut off = 0;
3853        while off < code.len() {
3854            let op = Opcode::from_u8(code[off]).unwrap();
3855            if op == Opcode::Call {
3856                call_ids.push(u16::from_le_bytes([code[off + 1], code[off + 2]]));
3857            }
3858            off += 1 + op.operand_bytes() as usize;
3859        }
3860        // LIFO: c, then b, then a.
3861        assert_eq!(call_ids, vec![c_id, b_id, a_id]);
3862    }
3863
3864    // Test 12: a defer before an explicit `return` fires once, at the return
3865    // -- not a second time at the fall-through.
3866    #[test]
3867    fn stmt_defer_fires_once_at_explicit_return() {
3868        let src = "fn cleanup() is io { }\n\
3869                   fn main() is io { defer cleanup()\n return }";
3870        let p = compile_ok(src);
3871        let main_id = p.fn_names.iter().position(|n| n == "main").unwrap();
3872        let cleanup_id = p.fn_names.iter().position(|n| n == "cleanup").unwrap() as u16;
3873        let code = &p.chunks[main_id].code;
3874        let mut cleanup_calls = 0;
3875        let mut off = 0;
3876        while off < code.len() {
3877            let op = Opcode::from_u8(code[off]).unwrap();
3878            if op == Opcode::Call
3879                && u16::from_le_bytes([code[off + 1], code[off + 2]]) == cleanup_id
3880            {
3881                cleanup_calls += 1;
3882            }
3883            off += 1 + op.operand_bytes() as usize;
3884        }
3885        // exactly one CALL to cleanup -- the explicit return fired it; the
3886        // dead fall-through emitted nothing (Pitfall 4).
3887        assert_eq!(cleanup_calls, 1, "defer must fire exactly once");
3888    }
3889
3890    // Test 14: a defer in a loop body fires per iteration -- it lives in the
3891    // loop-body scope, emitted inside the loop.
3892    #[test]
3893    fn stmt_defer_in_loop_fires_per_iteration() {
3894        let src = "fn cleanup() is io { }\n\
3895                   fn main() is io { for i in 0..3 { defer cleanup() } }";
3896        let p = compile_ok(src);
3897        let main_id = p.fn_names.iter().position(|n| n == "main").unwrap();
3898        let cleanup_id = p.fn_names.iter().position(|n| n == "cleanup").unwrap() as u16;
3899        let code = &p.chunks[main_id].code;
3900        // the cleanup CALL is emitted once in the bytecode (the loop body is
3901        // a single emitted block) but it sits BETWEEN the loop_start and the
3902        // emit_loop back-jump, so it runs once per iteration at run time.
3903        let mut cleanup_calls = 0;
3904        let mut off = 0;
3905        while off < code.len() {
3906            let op = Opcode::from_u8(code[off]).unwrap();
3907            if op == Opcode::Call
3908                && u16::from_le_bytes([code[off + 1], code[off + 2]]) == cleanup_id
3909            {
3910                cleanup_calls += 1;
3911            }
3912            off += 1 + op.operand_bytes() as usize;
3913        }
3914        assert!(
3915            cleanup_calls >= 1,
3916            "the per-iteration defer body is emitted"
3917        );
3918    }
3919
3920    // Test 15: DCE -- statements after a `return` are not compiled. a void
3921    // fn is used so the dead trailing statement does not trip the
3922    // typechecker's missing-return check.
3923    #[test]
3924    fn stmt_dce_skips_code_after_return() {
3925        let src = "fn f() is io { return\n let unused = 2 }";
3926        let code = fn_code(src, "f");
3927        // only the RETURN -- the dead `let unused = 2` emits nothing.
3928        assert_eq!(opcodes(&code), vec![Opcode::Return]);
3929    }
3930
3931    // Test 16: DCE -- a constant-`true` `if` emits only the then-branch.
3932    #[test]
3933    fn stmt_dce_constant_if_true_emits_only_then() {
3934        let src = "fn do_a() is io { }\n\
3935                   fn do_b() is io { }\n\
3936                   fn main() is io { if true { do_a() } else { do_b() } }";
3937        let p = compile_ok(src);
3938        let main_id = p.fn_names.iter().position(|n| n == "main").unwrap();
3939        let do_a = p.fn_names.iter().position(|n| n == "do_a").unwrap() as u16;
3940        let do_b = p.fn_names.iter().position(|n| n == "do_b").unwrap() as u16;
3941        let code = &p.chunks[main_id].code;
3942        let ops = opcodes(code);
3943        // no JUMP_IF_FALSE -- the constant condition was eliminated.
3944        assert!(
3945            !ops.contains(&Opcode::JumpIfFalse),
3946            "constant if needs no jump"
3947        );
3948        // do_a is called, do_b is not.
3949        let mut calls = Vec::new();
3950        let mut off = 0;
3951        while off < code.len() {
3952            let op = Opcode::from_u8(code[off]).unwrap();
3953            if op == Opcode::Call {
3954                calls.push(u16::from_le_bytes([code[off + 1], code[off + 2]]));
3955            }
3956            off += 1 + op.operand_bytes() as usize;
3957        }
3958        assert!(calls.contains(&do_a), "the then-branch runs");
3959        assert!(!calls.contains(&do_b), "the dead else-branch is dropped");
3960    }
3961
3962    // Test 17: DCE -- a constant-`false` `if` emits only the else-branch.
3963    #[test]
3964    fn stmt_dce_constant_if_false_emits_only_else() {
3965        let src = "fn do_a() is io { }\n\
3966                   fn do_b() is io { }\n\
3967                   fn main() is io { if false { do_a() } else { do_b() } }";
3968        let p = compile_ok(src);
3969        let main_id = p.fn_names.iter().position(|n| n == "main").unwrap();
3970        let do_a = p.fn_names.iter().position(|n| n == "do_a").unwrap() as u16;
3971        let do_b = p.fn_names.iter().position(|n| n == "do_b").unwrap() as u16;
3972        let code = &p.chunks[main_id].code;
3973        let mut calls = Vec::new();
3974        let mut off = 0;
3975        while off < code.len() {
3976            let op = Opcode::from_u8(code[off]).unwrap();
3977            if op == Opcode::Call {
3978                calls.push(u16::from_le_bytes([code[off + 1], code[off + 2]]));
3979            }
3980            off += 1 + op.operand_bytes() as usize;
3981        }
3982        assert!(!calls.contains(&do_a), "the dead then-branch is dropped");
3983        assert!(calls.contains(&do_b), "the else-branch runs");
3984    }
3985
3986    // Test 18: a non-const `if` condition keeps the full JUMP_IF_FALSE shape.
3987    #[test]
3988    fn stmt_no_dce_for_runtime_if_condition() {
3989        let src = "fn do_a() is io { }\n\
3990                   fn do_b() is io { }\n\
3991                   fn main(x: i64) is io { if x > 0 { do_a() } else { do_b() } }";
3992        let ops = fn_ops(src, "main");
3993        assert!(
3994            ops.contains(&Opcode::JumpIfFalse),
3995            "runtime if keeps its jump"
3996        );
3997    }
3998
3999    // Test 19: the six bundled examples each compile to non-empty bytecode.
4000    #[test]
4001    fn six_bundled_examples_compile_to_non_empty_bytecode() {
4002        for name in [
4003            "hello",
4004            "fibonacci",
4005            "effects",
4006            "pattern-matching",
4007            "pipeline",
4008            "defer-demo",
4009        ] {
4010            let path = format!(
4011                "{}/../../playground/public/examples/{name}.qala",
4012                env!("CARGO_MANIFEST_DIR"),
4013            );
4014            let src = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path}: {e}"));
4015            let tokens =
4016                Lexer::tokenize(&src).unwrap_or_else(|e| panic!("{name}.qala: lex error: {e:?}"));
4017            let ast = Parser::parse(&tokens)
4018                .unwrap_or_else(|e| panic!("{name}.qala: parse error: {e:?}"));
4019            let (typed, terrors, _) = check_program(&ast, &src);
4020            assert!(
4021                terrors.is_empty(),
4022                "{name}.qala: typecheck errors: {terrors:?}"
4023            );
4024            let program = compile_program(&typed, &src)
4025                .unwrap_or_else(|e| panic!("{name}.qala: compile errors: {e:?}"));
4026            assert!(
4027                !program.chunks.is_empty(),
4028                "{name}.qala: program has no chunks"
4029            );
4030            // every user-fn chunk has at least one byte (a RETURN minimum).
4031            for (i, chunk) in program.chunks.iter().enumerate() {
4032                assert!(
4033                    !chunk.code.is_empty(),
4034                    "{name}.qala: chunk {i} ({}) is empty",
4035                    program.fn_names[i],
4036                );
4037            }
4038            let disassembly = program.disassemble();
4039            assert!(!disassembly.is_empty(), "{name}.qala: disassembly is empty");
4040        }
4041    }
4042
4043    // Test 20: compiling the same example twice produces byte-identical
4044    // bytecode -- the determinism contract.
4045    #[test]
4046    fn compile_is_deterministic() {
4047        let path = format!(
4048            "{}/../../playground/public/examples/fibonacci.qala",
4049            env!("CARGO_MANIFEST_DIR"),
4050        );
4051        let src = std::fs::read_to_string(&path).expect("read fibonacci.qala");
4052        let tokens = Lexer::tokenize(&src).expect("lex");
4053        let ast = Parser::parse(&tokens).expect("parse");
4054        let (typed, _, _) = check_program(&ast, &src);
4055        let first = compile_program(&typed, &src).expect("compile 1");
4056        let second = compile_program(&typed, &src).expect("compile 2");
4057        assert_eq!(first.chunks.len(), second.chunks.len());
4058        for (a, b) in first.chunks.iter().zip(second.chunks.iter()) {
4059            assert_eq!(a.code, b.code, "chunk code differs across compiles");
4060            assert_eq!(
4061                a.source_lines, b.source_lines,
4062                "source map differs across compiles"
4063            );
4064        }
4065        assert_eq!(
4066            first.disassemble(),
4067            second.disassemble(),
4068            "disassembly is non-deterministic"
4069        );
4070    }
4071
4072    // Test 8: `break` inside a loop emits a forward JUMP.
4073    #[test]
4074    fn stmt_break_emits_forward_jump() {
4075        let src = "fn main() is io { for i in 0..3 { if i == 1 { break } } }";
4076        let ops = fn_ops(src, "main");
4077        // a break is a JUMP; the loop already has JUMP_IF_FALSE for the
4078        // bounds check and the if -- assert at least one bare JUMP exists.
4079        assert!(ops.contains(&Opcode::Jump), "break emits a JUMP");
4080    }
4081
4082    // Test 9: `continue` inside a loop emits a backward JUMP (emit_loop).
4083    #[test]
4084    fn stmt_continue_emits_back_jump() {
4085        let src = "fn main() is io { for i in 0..3 { if i == 1 { continue } } }";
4086        let ops = fn_ops(src, "main");
4087        assert!(ops.contains(&Opcode::Jump), "continue emits a JUMP");
4088        // the program compiles cleanly -- continue resolves to the loop's
4089        // increment label.
4090        assert!(!ops.is_empty());
4091    }
4092
4093    // Test 13: a defer plus a `break` -- the defer fires on the break path
4094    // AND on the per-iteration fall-through (cleanup emitted for both).
4095    #[test]
4096    fn stmt_defer_resolves_block_local_on_fall_through() {
4097        // regression: a defer whose body references a local declared in the
4098        // same block used to fail codegen with "unresolved name" when the
4099        // block fell through (no explicit return). the bug was that
4100        // pop_scope_and_emit_defers popped the scope BEFORE compiling the
4101        // deferred expressions, making block-local names invisible. the fix
4102        // collects the deferred list first, then pops.
4103        //
4104        // this source is the minimal repro from deferred-items.md -- it must
4105        // compile AND run correctly: the defer call executes and its side
4106        // effect (a println) appears in the console.
4107        // capture wraps println with a str conversion via interpolation so the
4108        // typechecker accepts the i64 argument.
4109        let src = "fn capture(n: i64) is io { println(\"{n}\") }\n\
4110                   fn main() is io {\n\
4111                     let v = 42\n\
4112                     defer capture(v)\n\
4113                   }";
4114        // compile -- previously this would error with "unresolved name: v".
4115        let p = compile_ok(src);
4116        // run -- the deferred capture(v) must execute and print "42".
4117        let mut vm = crate::vm::Vm::new(p, src.to_string());
4118        vm.run().expect("the program runs without error");
4119        assert_eq!(
4120            vm.console,
4121            vec!["42\n"],
4122            "the defer ran and printed the block-local value"
4123        );
4124    }
4125
4126    // Test 14a: defer with break fires on both paths.
4127    #[test]
4128    fn stmt_defer_with_break_fires_on_both_paths() {
4129        let src = "fn cleanup() is io { }\n\
4130                   fn main() is io {\n\
4131                     for i in 0..3 { defer cleanup()\n if i == 1 { break } }\n\
4132                   }";
4133        let p = compile_ok(src);
4134        let main_id = p.fn_names.iter().position(|n| n == "main").unwrap();
4135        let cleanup_id = p.fn_names.iter().position(|n| n == "cleanup").unwrap() as u16;
4136        let code = &p.chunks[main_id].code;
4137        let mut cleanup_calls = 0;
4138        let mut off = 0;
4139        while off < code.len() {
4140            let op = Opcode::from_u8(code[off]).unwrap();
4141            if op == Opcode::Call
4142                && u16::from_le_bytes([code[off + 1], code[off + 2]]) == cleanup_id
4143            {
4144                cleanup_calls += 1;
4145            }
4146            off += 1 + op.operand_bytes() as usize;
4147        }
4148        // cleanup is emitted twice: once before the `break` JUMP, once at the
4149        // per-iteration fall-through.
4150        assert_eq!(
4151            cleanup_calls, 2,
4152            "the defer fires on the break path and the fall-through"
4153        );
4154    }
4155
4156    // ===== Task 5: ComptimeInterpreter =======================================
4157
4158    /// build a Chunk from a list of (opcode, operands) -- a tiny assembler
4159    /// for the comptime interpreter unit tests.
4160    fn asm(ops: &[(Opcode, &[u8])]) -> Chunk {
4161        let mut c = Chunk::new();
4162        for (op, operands) in ops {
4163            c.write_op(*op, 1);
4164            for b in *operands {
4165                c.code.push(*b);
4166                c.source_lines.push(1);
4167            }
4168        }
4169        c
4170    }
4171
4172    // Test 1: `comptime { 1 + 2 }` embeds a single CONST(I64(3)). (the `1+2`
4173    // folds at codegen before the comptime even runs; the comptime body
4174    // chunk is `CONST 3; RETURN`, the interpreter returns I64(3).)
4175    #[test]
4176    fn comptime_folds_arithmetic_to_one_const() {
4177        let src = "fn main() is io { let x = comptime { 1 + 2 }\n println(\"{x}\") }";
4178        let p = compile_ok(src);
4179        let main_id = p.fn_names.iter().position(|n| n == "main").unwrap();
4180        // the comptime result CONST(I64(3)) is in main's constant pool.
4181        assert!(
4182            p.chunks[main_id].constants.contains(&ConstValue::I64(3)),
4183            "comptime of 1 + 2 should embed CONST(I64(3))"
4184        );
4185    }
4186
4187    // Test 2: `comptime { "hello" }` embeds CONST(Str("hello")).
4188    #[test]
4189    fn comptime_string_result() {
4190        let src = "fn f() -> str is pure { comptime { \"hello\" } }";
4191        let p = compile_ok(src);
4192        assert!(
4193            p.chunks[0]
4194                .constants
4195                .contains(&ConstValue::Str("hello".to_string()))
4196        );
4197    }
4198
4199    // Test 3: `comptime { let x = 2; x * x }` evaluates to I64(4).
4200    #[test]
4201    fn comptime_block_with_local() {
4202        let src = "fn f() -> i64 is pure { comptime { let x = 2\n x * x } }";
4203        let p = compile_ok(src);
4204        assert!(
4205            p.chunks[0].constants.contains(&ConstValue::I64(4)),
4206            "comptime block result should be I64(4)"
4207        );
4208    }
4209
4210    // Test 4: comptime division by zero is a codegen error.
4211    #[test]
4212    fn comptime_division_by_zero_errors() {
4213        // run a hand-built chunk `CONST 1; CONST 0; DIV; RETURN` directly.
4214        let program = Program::new();
4215        let effects: HashMap<u16, EffectSet> = HashMap::new();
4216        let mut chunk = Chunk::new();
4217        let one = chunk.add_constant(ConstValue::I64(1));
4218        let zero = chunk.add_constant(ConstValue::I64(0));
4219        let mut body = asm(&[
4220            (Opcode::Const, &one.to_le_bytes()),
4221            (Opcode::Const, &zero.to_le_bytes()),
4222            (Opcode::Div, &[]),
4223            (Opcode::Return, &[]),
4224        ]);
4225        body.constants = chunk.constants;
4226        let mut interp = ComptimeInterpreter::new(&program, &effects, Span::new(0, 1));
4227        match interp.run(body) {
4228            Err(QalaError::Type { message, .. }) => {
4229                assert!(
4230                    message.contains("division by zero"),
4231                    "expected division-by-zero, got: {message}"
4232                );
4233            }
4234            other => panic!("expected a division-by-zero Type error, got {other:?}"),
4235        }
4236    }
4237
4238    // Test 5: a 100000-instruction budget is enforced. a hand-built chunk of
4239    // a tight self-loop runs forever; the interpreter halts it at the budget.
4240    #[test]
4241    fn comptime_budget_exhaustion_errors() {
4242        let program = Program::new();
4243        let effects: HashMap<u16, EffectSet> = HashMap::new();
4244        // `loop: JUMP loop` -- an infinite loop with no RETURN.
4245        // JUMP is 3 bytes; the offset back to 0 from after-the-operand (3) is
4246        // -3.
4247        let body = asm(&[(Opcode::Jump, &(-3i16).to_le_bytes())]);
4248        let mut interp = ComptimeInterpreter::new(&program, &effects, Span::new(5, 1));
4249        match interp.run(body) {
4250            Err(QalaError::ComptimeBudgetExceeded { span }) => {
4251                assert_eq!(span, Span::new(5, 1));
4252            }
4253            other => panic!("expected ComptimeBudgetExceeded, got {other:?}"),
4254        }
4255    }
4256
4257    // Test 6: defense-in-depth -- a comptime CALL to a non-pure function is
4258    // rejected with ComptimeEffectViolation.
4259    #[test]
4260    fn comptime_effect_violation_on_impure_call() {
4261        // a program with one io function (fn-id 0); the comptime body CALLs
4262        // it. the throwaway chunk: `CALL 0/0; RETURN`.
4263        let mut program = Program::new();
4264        program.chunks.push(Chunk::new()); // fn-id 0's (empty) chunk.
4265        program.fn_names.push("do_io".to_string());
4266        let mut effects: HashMap<u16, EffectSet> = HashMap::new();
4267        effects.insert(0, EffectSet::io());
4268        let body = asm(&[
4269            (Opcode::Call, &[0, 0, 0]), // fn-id 0 (u16), argc 0 (u8).
4270            (Opcode::Return, &[]),
4271        ]);
4272        let mut interp = ComptimeInterpreter::new(&program, &effects, Span::new(3, 1));
4273        match interp.run(body) {
4274            Err(QalaError::ComptimeEffectViolation {
4275                fn_name, effect, ..
4276            }) => {
4277                assert_eq!(fn_name, "do_io");
4278                assert_eq!(effect, "io");
4279            }
4280            other => panic!("expected ComptimeEffectViolation, got {other:?}"),
4281        }
4282    }
4283
4284    // Test 7: a comptime result that is not a primitive / string is
4285    // rejected. (an array cannot even be built inside the comptime
4286    // interpreter -- MAKE_ARRAY is unsupported -- so `comptime { [1,2,3] }`
4287    // errors at the MAKE_ARRAY, the documented v1 behavior. the
4288    // result-constability check itself is unit-tested via the helpers
4289    // below.)
4290    #[test]
4291    fn comptime_array_body_is_rejected() {
4292        let src = "fn f() -> [i64; 3] is pure { comptime { [1, 2, 3] } }";
4293        match compile(src) {
4294            Err(errors) => {
4295                assert!(
4296                    errors.iter().any(|e| matches!(
4297                        e,
4298                        QalaError::Type { message, .. } if message.contains("MAKE_ARRAY")
4299                    )),
4300                    "expected a MAKE_ARRAY-unsupported error, got {errors:?}"
4301                );
4302            }
4303            Ok(_) => panic!("comptime with an array body should not compile"),
4304        }
4305    }
4306
4307    // Test 7b: the result-constability predicate and its type-name helper.
4308    #[test]
4309    fn comptime_constable_primitive_predicate() {
4310        assert!(is_constable_primitive(&ConstValue::I64(1)));
4311        assert!(is_constable_primitive(&ConstValue::F64(1.0)));
4312        assert!(is_constable_primitive(&ConstValue::Bool(true)));
4313        assert!(is_constable_primitive(&ConstValue::Byte(0)));
4314        assert!(is_constable_primitive(&ConstValue::Str(String::new())));
4315        assert!(is_constable_primitive(&ConstValue::Void));
4316        // a function reference is NOT constable.
4317        assert!(!is_constable_primitive(&ConstValue::Function(0)));
4318        assert_eq!(value_type_name(&ConstValue::Function(0)), "fn");
4319        assert_eq!(value_type_name(&ConstValue::I64(0)), "i64");
4320    }
4321
4322    // Test 8: the interpreter uses an explicit Vec<Frame>, so deep recursion
4323    // is bounded by COMPTIME_MAX_FRAMES rather than the host stack. a chunk
4324    // that calls itself unconditionally overflows the frame cap and reports
4325    // ComptimeBudgetExceeded -- WITHOUT a Rust stack overflow.
4326    #[test]
4327    fn comptime_deep_recursion_is_bounded_by_the_frame_cap() {
4328        let mut program = Program::new();
4329        // fn-id 0: a pure function whose body is `CALL 0/0` -- it calls
4330        // itself forever. the explicit frame stack caps the depth.
4331        let recursive = asm(&[(Opcode::Call, &[0, 0, 0])]);
4332        program.chunks.push(recursive);
4333        program.fn_names.push("recurse".to_string());
4334        let mut effects: HashMap<u16, EffectSet> = HashMap::new();
4335        effects.insert(0, EffectSet::pure());
4336        // the throwaway body just calls `recurse`.
4337        let body = asm(&[(Opcode::Call, &[0, 0, 0]), (Opcode::Return, &[])]);
4338        let mut interp = ComptimeInterpreter::new(&program, &effects, Span::new(0, 1));
4339        match interp.run(body) {
4340            Err(QalaError::ComptimeBudgetExceeded { .. }) => {
4341                // the frame cap (or the budget) halted it -- no host-stack
4342                // overflow, which is the point of the explicit Vec<Frame>.
4343            }
4344            other => panic!("expected ComptimeBudgetExceeded, got {other:?}"),
4345        }
4346    }
4347
4348    // Test 9: a comptime block calling a pure user function. `square` is
4349    // compiled to a real chunk; the comptime interpreter dispatches the CALL
4350    // into it via the frame stack and embeds the I64 result.
4351    #[test]
4352    fn comptime_calls_a_pure_user_function() {
4353        let src = "fn square(x: i64) -> i64 is pure { return x * x }\n\
4354                   fn f() -> i64 is pure { comptime { square(5) } }";
4355        let p = compile_ok(src);
4356        let f_id = p.fn_names.iter().position(|n| n == "f").unwrap();
4357        // square(5) == 25 -- embedded as a CONST in f's pool.
4358        assert!(
4359            p.chunks[f_id].constants.contains(&ConstValue::I64(25)),
4360            "comptime square(5) should embed CONST(I64(25))"
4361        );
4362        // the disassembly of f shows a CONST 25 and no trace of `square`'s
4363        // call -- the comptime evaluation collapsed it.
4364        let ops = opcodes(&p.chunks[f_id].code);
4365        assert!(ops.contains(&Opcode::Const));
4366    }
4367
4368    // Test 10: the public entry compile_program is reachable and produces a
4369    // Program for a comptime-using source.
4370    #[test]
4371    fn comptime_program_compiles_through_the_public_entry() {
4372        let src = "fn f() -> i64 is pure { comptime { 10 * 10 } }";
4373        let p = compile_ok(src);
4374        assert_eq!(p.chunks.len(), 1);
4375        assert!(p.chunks[0].constants.contains(&ConstValue::I64(100)));
4376    }
4377
4378    // `continue` in a `for` loop must reach the increment, not the bounds
4379    // check. we verify structurally: the chunk must compile cleanly, the
4380    // lockstep invariant must hold, and the disassembler must decode every
4381    // byte without losing sync (a mis-patched continue-jump landing
4382    // mid-instruction would cause the opcode walk to produce a different
4383    // instruction count than the disassembler's line count).
4384    #[test]
4385    fn for_loop_continue_reaches_the_increment_not_the_bounds_check() {
4386        let src = "fn main() is io {\n\
4387                   for i in 0..5 {\n\
4388                   if i == 2 { continue }\n\
4389                   println(\"{i}\")\n\
4390                   }\n\
4391                   }";
4392        let p = compile_ok(src);
4393        let main_id = p.fn_names.iter().position(|n| n == "main").unwrap();
4394        let chunk = &p.chunks[main_id];
4395        assert_eq!(
4396            chunk.code.len(),
4397            chunk.source_lines.len(),
4398            "lockstep invariant broken after for+continue"
4399        );
4400        let dis = chunk.disassemble(&p);
4401        assert!(!dis.is_empty(), "disassembly is empty");
4402        // if a continue-jump landed mid-instruction the opcode walker would
4403        // decode garbage and produce a different count than the line count.
4404        let line_count = dis.matches('\n').count();
4405        let op_count = opcodes(&chunk.code).len();
4406        assert_eq!(
4407            line_count, op_count,
4408            "disassembly line count ({line_count}) != opcode count ({op_count}): \
4409             continue-jump may have landed mid-instruction"
4410        );
4411    }
4412
4413    // a function's chunk records the source name of every parameter and `let`
4414    // binding in local_names, indexed by slot -- the table the VM's get_state
4415    // reads to show `x`, not `slot0`.
4416    #[test]
4417    fn compiled_chunk_records_parameter_and_let_names_by_slot() {
4418        let src = "fn add(a: i64, b: i64) -> i64 is pure {\n\
4419                   \x20\x20let sum = a + b\n\
4420                   \x20\x20return sum\n\
4421                   }";
4422        let p = compile_ok(src);
4423        let id = p.fn_names.iter().position(|n| n == "add").unwrap();
4424        let names = &p.chunks[id].local_names;
4425        // slot 0 / 1 are the two params; slot 2 is the `let sum`.
4426        assert_eq!(names[0], "a", "param slot 0 is `a`");
4427        assert_eq!(names[1], "b", "param slot 1 is `b`");
4428        assert_eq!(names[2], "sum", "the `let` slot is `sum`");
4429    }
4430
4431    // a `for` loop's variable is a real name in local_names; the loop's hidden
4432    // array / index temporaries have empty names (the VM renders those as
4433    // `slot{i}`). a hand-built or comptime chunk leaves local_names empty.
4434    #[test]
4435    fn compiled_chunk_names_the_for_variable_and_leaves_hidden_slots_empty() {
4436        let src = "fn main() -> void is pure {\n\
4437                   \x20\x20for n in [1, 2, 3] {\n\
4438                   \x20\x20\x20\x20let doubled = n + n\n\
4439                   \x20\x20}\n\
4440                   }";
4441        let p = compile_ok(src);
4442        let id = p.fn_names.iter().position(|n| n == "main").unwrap();
4443        let names = &p.chunks[id].local_names;
4444        // the loop variable `n` and the body `let doubled` are real names.
4445        assert!(
4446            names.iter().any(|n| n == "n"),
4447            "the for variable `n` is named"
4448        );
4449        assert!(
4450            names.iter().any(|n| n == "doubled"),
4451            "the body `let doubled` is named"
4452        );
4453        // the hidden for-loop array + index slots carry empty names.
4454        assert!(
4455            names.iter().any(|n| n.is_empty()),
4456            "a for loop has hidden unnamed temporary slots"
4457        );
4458    }
4459}