plum

#treesitter#compiler#wasm

git clone https://git.pyrossh.dev/plum

A statically typed, imperative programming language inspired by rust, python


plum-wasm-codegen/src/lib.rs
// Functions/methods are named camelCase across this project (matching plum's own
// naming convention), not Rust's idiomatic snake_case — silence the resulting lint.
#![allow(non_snake_case)]

use wasm_encoder::*;
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use plum_core::ast;
use plum_checker::types::{PlumType, TypeEnv, TypeScheme};
use plum_checker::{ClassEnv, MethodEnv, EnumVariants, EnumVariantInfo, EnumParams};

/// One entry in the module's type section. Wasm's type section is a SINGLE shared
/// index space for function types AND (once wasm-gc is in play) composite
/// struct/array types — `Rec` entries occupy as many consecutive indices as they
/// have members, exactly like `CoreTypeEncoder::rec` groups multiple sub-types
/// under one recursive-group declaration.
enum TypeEntry {
    Func(FuncType),
    /// A whole `rec` group of struct/array sub-types, declared together so members
    /// can reference each other (including themselves) regardless of declaration
    /// order within the group.
    Rec(Vec<SubType>),
}

/// One entry in the module's data section. Like the type section, data segments
/// share ONE index space regardless of kind — a `Passive` segment's index (needed by
/// `array.new_data`) is its position among ALL segments, active or passive.
enum DataSegmentEntry {
    Active(u32, Vec<u8>),
    Passive(Vec<u8>),
}

pub struct WasmModule {
    types: Vec<TypeEntry>,
    /// Running count of type-section INDICES assigned so far — NOT the same as
    /// `types.len()`, since one `TypeEntry::Rec` occupies as many indices as it has
    /// members while still being a single `Vec` element.
    next_type_idx: u32,
    imports: Vec<(String, String, u32)>,
    functions: Vec<(u32, Vec<u8>)>,
    exports: Vec<(String, ExportKind, u32)>,
    globals: Vec<(ValType, bool, Vec<u8>)>,
    data_segments: Vec<DataSegmentEntry>,
    /// Function indices, in table order — the single funcref table used for
    /// closure `call_indirect` dispatch. Index into this vec IS the table index.
    table_elements: Vec<u32>,
    pub func_import_count: u32,
    pub func_count: u32,
    global_count: u32,
    start_function: Option<u32>,
}

impl WasmModule {
    pub fn new() -> Self {
        Self {
            types: Vec::new(),
            next_type_idx: 0,
            imports: Vec::new(),
            functions: Vec::new(),
            exports: Vec::new(),
            globals: Vec::new(),
            data_segments: Vec::new(),
            table_elements: Vec::new(),
            func_import_count: 0,
            func_count: 0,
            global_count: 0,
            start_function: None,
        }
    }

    pub fn addType(&mut self, params: &[ValType], results: &[ValType]) -> u32 {
        let idx = self.next_type_idx;
        self.types.push(TypeEntry::Func(FuncType::new(params.iter().copied(), results.iter().copied())));
        self.next_type_idx += 1;
        idx
    }

    /// Declares a whole `rec` group of wasm-gc struct/array sub-types together,
    /// returning the type index assigned to each member, in order. Grouping
    /// unrelated types is harmless — the point is that MUTUALLY referencing types
    /// (e.g. an enum's supertype and its variant subtypes, or a self-referential
    /// struct field) MUST share a `rec` group to reference each other regardless of
    /// which one is declared "first".
    pub fn addGcTypes(&mut self, subtypes: Vec<SubType>) -> Vec<u32> {
        let base = self.next_type_idx;
        let count = subtypes.len() as u32;
        self.types.push(TypeEntry::Rec(subtypes));
        self.next_type_idx += count;
        (base..base + count).collect()
    }

    pub fn addImport(&mut self, module: &str, name: &str, type_idx: u32) -> u32 {
        let idx = self.func_import_count;
        self.imports.push((module.to_string(), name.to_string(), type_idx));
        self.func_import_count += 1;
        idx
    }

    pub fn addFunction(&mut self, type_idx: u32, body: &[u8]) -> u32 {
        let idx = self.func_import_count + self.func_count;
        self.functions.push((type_idx, body.to_vec()));
        self.func_count += 1;
        idx
    }

    pub fn addExport(&mut self, name: &str, kind: ExportKind, idx: u32) {
        self.exports.push((name.to_string(), kind, idx));
    }

    pub fn addGlobal(&mut self, val_type: ValType, mutable: bool, init: &[u8]) -> u32 {
        let idx = self.global_count;
        self.globals.push((val_type, mutable, init.to_vec()));
        self.global_count += 1;
        idx
    }

    pub fn addDataSegment(&mut self, offset: u32, data: &[u8]) {
        self.data_segments.push(DataSegmentEntry::Active(offset, data.to_vec()));
    }

    /// Adds a passive segment (no implicit memory-init offset) and returns its index
    /// in the shared active/passive data-segment index space — the index `array.new_data`
    /// needs to reference it. Passive segments require a `DataCountSection` (see `finish`).
    pub fn addPassiveDataSegment(&mut self, data: &[u8]) -> u32 {
        let idx = self.data_segments.len() as u32;
        self.data_segments.push(DataSegmentEntry::Passive(data.to_vec()));
        idx
    }

    /// Registers `func_idx` to run once automatically at instantiation, before any
    /// export is callable — needed because `struct.new` (and therefore constructing
    /// any GC singleton, like the pre-allocated payload-free enum variants) is not
    /// allowed inside a `global`'s own const-expr initializer (confirmed empirically
    /// in Task 1 — `wasmtimeGcConfigAllowsStructNewInGlobalConstExpr` fails), so those
    /// globals are declared `mutable` with a `ref.null` initial value and populated
    /// here instead.
    pub fn setStartFunction(&mut self, func_idx: u32) {
        self.start_function = Some(func_idx);
    }

    /// Registers `func_idx` as the next slot in the single funcref table used for
    /// closure `call_indirect` dispatch, returning its table index.
    pub fn addTableElement(&mut self, func_idx: u32) -> u32 {
        let table_idx = self.table_elements.len() as u32;
        self.table_elements.push(func_idx);
        table_idx
    }

    pub fn finish(&mut self) -> Vec<u8> {
        let mut module = wasm_encoder::Module::new();

        // Type section
        let mut types = TypeSection::new();
        for entry in &self.types {
            match entry {
                TypeEntry::Func(ft) => {
                    types.ty().function(ft.params().iter().copied(), ft.results().iter().copied());
                }
                TypeEntry::Rec(subtypes) => {
                    types.ty().rec(subtypes.iter().cloned());
                }
            }
        }
        module.section(&types);

        // Import section
        if !self.imports.is_empty() {
            let mut imports = ImportSection::new();
            for (module_name, name, type_idx) in &self.imports {
                imports.import(module_name, name, EntityType::Function(*type_idx));
            }
            module.section(&imports);
        }

        // Function section
        if !self.functions.is_empty() {
            let mut funcs = FunctionSection::new();
            for (type_idx, _) in &self.functions {
                funcs.function(*type_idx);
            }
            module.section(&funcs);
        }

        // Table section
        if !self.table_elements.is_empty() {
            let mut tables = TableSection::new();
            tables.table(TableType {
                element_type: RefType::FUNCREF,
                minimum: self.table_elements.len() as u64,
                maximum: Some(self.table_elements.len() as u64),
                table64: false,
                shared: false,
            });
            module.section(&tables);
        }

        // Global section
        if !self.globals.is_empty() {
            let mut globals = GlobalSection::new();
            for (val_type, mutable, init_expr) in &self.globals {
                let expr = ConstExpr::raw(init_expr.iter().copied());
                globals.global(
                    GlobalType { val_type: *val_type, mutable: *mutable, shared: false },
                    &expr,
                );
            }
            module.section(&globals);
        }

        // Export section
        if !self.exports.is_empty() {
            let mut exports = ExportSection::new();
            for (name, kind, idx) in &self.exports {
                exports.export(name, *kind, *idx);
            }
            module.section(&exports);
        }

        // Start section
        if let Some(func_idx) = self.start_function {
            module.section(&StartSection { function_index: func_idx });
        }

        // Element section
        if !self.table_elements.is_empty() {
            let mut elements = ElementSection::new();
            let offset = ConstExpr::i32_const(0);
            elements.active(Some(0), &offset, Elements::Functions(std::borrow::Cow::Borrowed(&self.table_elements)));
            module.section(&elements);
        }

        // DataCount section — required whenever `array.new_data`/`data.drop` reference
        // a passive segment, and must appear before the code section.
        let has_passive = self.data_segments.iter().any(|e| matches!(e, DataSegmentEntry::Passive(_)));
        if has_passive {
            module.section(&DataCountSection { count: self.data_segments.len() as u32 });
        }

        // Code section
        if !self.functions.is_empty() {
            let mut code = CodeSection::new();
            for (_, body_bytes) in &self.functions {
                code.raw(body_bytes);
            }
            module.section(&code);
        }

        // Data section
        if !self.data_segments.is_empty() {
            let mut data = DataSection::new();
            for entry in &self.data_segments {
                match entry {
                    DataSegmentEntry::Active(offset, bytes) => {
                        let offset_expr = ConstExpr::i32_const(*offset as i32);
                        data.active(0, &offset_expr, bytes.iter().copied());
                    }
                    DataSegmentEntry::Passive(bytes) => {
                        data.passive(bytes.iter().copied());
                    }
                }
            }
            module.section(&data);
        }

        module.finish()
    }
}

impl Default for WasmModule {
    fn default() -> Self {
        Self::new()
    }
}

#[derive(Clone)]
pub struct FuncSig {
    pub params: Vec<ValType>,
    pub ret: Option<ValType>,
}

/// Everything codegen needs to know about one closure *literal* found in the program.
/// wasm has no native closures: each literal `|v| body` becomes its own real wasm
/// function (registered in the funcref table), and a closure *value* is a `ref` to
/// the shared `{table_idx: i32, env: anyref}` struct (`GcTypeRegistry::closure_type_idx`).
/// `env` is a `ref.cast` of this closure literal's OWN env struct type
/// (`env_type_idx`), one field per captured (free) variable in `free_vars` order.
pub struct ClosureInfo {
    /// Reserved wasm function index for this closure's compiled body.
    pub func_idx: u32,
    /// Index of `func_idx` in the funcref table (the `i32` stored in the closure
    /// struct's `table_idx` field).
    pub table_idx: u32,
    /// This closure literal's own env struct type index (fields = `free_vars`, in
    /// order) — assigned once all closures are discovered, alongside every other
    /// closure's env type and the shared closure-value struct, in one `rec` group.
    pub env_type_idx: u32,
    /// Closure param val types (NOT including the implicit leading env pointer).
    pub param_vts: Vec<ValType>,
    /// Closure param plum types (for the closure body's own type env).
    pub param_ptypes: Vec<PlumType>,
    /// Closure return val type (`None` for a `Unit`-returning closure).
    pub ret_vt: Option<ValType>,
    /// Free variables captured by value, in a stable (first-appearance) order; the
    /// index into this vec IS the variable's field index in the env struct.
    pub free_vars: Vec<(String, PlumType)>,
}

/// Key for deduplicating `call_indirect` function-type indices: the full wasm
/// signature (leading env-ptr param included) of a closure.
type ClosureSigKey = (Vec<ValType>, Option<ValType>);

/// Global, read-only lookup tables shared by every function body being compiled.
pub struct CompileCtx<'a> {
    pub func_ids: HashMap<String, u32>,
    pub func_sigs: HashMap<String, FuncSig>,
    pub classes: ClassEnv,
    pub methods: MethodEnv,
    pub enum_variants: EnumVariants,
    pub enum_params: EnumParams,
    pub global_env: TypeEnv,
    /// Closure literal (keyed by `&Expr::Closure` pointer identity) -> its `ClosureInfo`.
    pub closures: HashMap<usize, ClosureInfo>,
    /// The AST of each discovered closure literal, keyed the same way, so its body can
    /// be compiled in a second pass after all closures are registered.
    pub closure_asts: HashMap<usize, &'a ast::Closure>,
    /// Closure wasm signature -> function-type index, for `call_indirect` at call sites.
    pub closure_call_types: HashMap<ClosureSigKey, u32>,
    /// Top-level function name -> global index holding its zero-capture "trampoline"
    /// closure struct `{table_idx, env=null}`, for using a plain named function
    /// wherever a `fn(...)`-typed value is expected (e.g. `each(double)`). Since the
    /// struct has no captures it never changes, so it's built once by the shared
    /// `start` function instead of being reconstructed per reference.
    pub named_fn_values: HashMap<String, u32>,
    /// Shared runtime helper `(a: ref Str, b: ref Str) -> ref Str`: allocates a new
    /// `array<i8>` exactly long enough to hold `a`'s bytes followed by `b`'s, for
    /// lowering string interpolation (`"{expr}"`).
    pub string_concat_func: u32,
    /// Shared runtime helper `(n: i64) -> ref Str`: allocates a new `array<i8>`
    /// holding `n`'s decimal representation, for interpolating an `Int`.
    pub int_to_string_func: u32,
    /// wasm-gc type-section indices for this program's classes/enums/Str/closures —
    /// every value's real representation (see `docs/superpowers/plans/2026-07-25-wasm-gc-migration.md`).
    pub gc_types: GcTypeRegistry,
    /// Payload-free variant name (True/False/None/...) -> the global index holding
    /// its one pre-allocated instance (see this migration plan's Decision 2).
    pub singleton_globals: HashMap<String, u32>,
}

/// Per-module state that accumulates as function bodies are compiled: every static
/// string literal's bytes, staged here (rather than added straight to `WasmModule`)
/// so `compileStaticString` can know a segment's final passive-data-section index —
/// its position among ALL staged segments — before that section is actually
/// assembled at the end of `compileSource`.
struct ModuleState {
    passive_segments: Vec<Vec<u8>>,
}

struct LocalCtx<'a> {
    locals: HashMap<String, u32>,
    /// First local index reserved for `match` subject scratch temporaries.
    match_scratch_base: u32,
    /// `Match` stmt identity (pointer address) -> scratch slot offset.
    match_scratch_index: HashMap<usize, u32>,
    /// First local index reserved for nested-constructor-pattern scratch temporaries
    /// (`Some(Some(v))`'s inner `Some(v)`); the outermost pattern uses a
    /// `match_scratch` slot instead, so this only covers depth >= 1.
    nested_class_scratch_base: u32,
    /// `CasePattern::Class` identity (pointer address) -> scratch slot offset.
    nested_class_scratch: HashMap<usize, u32>,
    /// First local index reserved for variadic-`for` scratch temporaries (2 `i32`
    /// slots per `for` statement that iterates a `TVariadic`: count, loop index).
    variadic_for_scratch_base: u32,
    /// `For` stmt identity (pointer address) -> slot number (multiply by 2 and add
    /// `variadic_for_scratch_base` for the count local; +1 more for the index local).
    variadic_for_scratch: HashMap<usize, u32>,
    func_ids: &'a HashMap<String, u32>,
    func_sigs: &'a HashMap<String, FuncSig>,
    closures: &'a HashMap<usize, ClosureInfo>,
    closure_call_types: &'a HashMap<ClosureSigKey, u32>,
    named_fn_values: &'a HashMap<String, u32>,
    string_concat_func: u32,
    int_to_string_func: u32,
    classes: &'a ClassEnv,
    methods: &'a MethodEnv,
    enum_variants: &'a EnumVariants,
    enum_params: &'a EnumParams,
    gc_types: &'a GcTypeRegistry,
    singleton_globals: &'a HashMap<String, u32>,
    /// Tracks each binding's inferred type as compilation proceeds through
    /// statements in order, mirroring `plum-checker`'s own env evolution — needed
    /// to resolve `Attribute`/`ClassCall` targets and pick the right load/store width.
    type_env: RefCell<TypeEnv>,
    /// Local name -> the exact wasm `call_indirect` signature of the closure literal
    /// assigned to it (populated when compiling that `Stmt::Assign`, straight from
    /// the already-correct `ClosureInfo` the discovery pass computed). Exists so
    /// `compileClosureCall` doesn't have to re-derive the signature via
    /// `plum_checker::inferExpr` on the closure a second time — which, unlike the
    /// discovery pass, doesn't have `resolveClosureParamTypesFromUsage`'s fix
    /// and would fall back to its old TVar-defaults-to-Int behavior, disagreeing
    /// with the (now correct) signature the closure's body was actually compiled with.
    closure_local_sigs: RefCell<HashMap<String, ClosureSigKey>>,
}

fn fnKey(f: &ast::Fn) -> String {
    match &f.type_param {
        Some(recv) => format!("{}::{}", recv, f.name),
        None => f.name.clone(),
    }
}

/// `f`'s full wasm param signature (implicit leading receiver param included, for
/// a method) — shared by real function registration and `extern fun` import
/// registration so both compute a param list the exact same way.
fn fnWasmParamTypes(f: &ast::Fn, gc_types: &GcTypeRegistry) -> Vec<ValType> {
    let mut param_types: Vec<ValType> = Vec::new();
    if let Some(recv) = &f.type_param {
        param_types.push(astTypeToWasm(recv).unwrap_or(ValType::I32));
    }
    for p in &f.params {
        let vt = match &p.ty {
            ast::ParamType::Variadic(t) => {
                let elem_vt = astTypeToWasm(&t.name).unwrap_or(ValType::I64);
                let arr_idx = *gc_types.variadic_array_type_idx.get(&elem_vt)
                    .expect("internal codegen error: variadic array type must be pre-registered for every elem type in the program");
                gcRef(arr_idx)
            }
            other => astTypeToWasm(paramTypeName(other)).unwrap_or(ValType::I32),
        };
        param_types.push(vt);
    }
    param_types
}


fn paramTypeName(pt: &ast::ParamType) -> &str {
    match pt {
        ast::ParamType::Type(t) => t.name.as_str(),
        ast::ParamType::Variadic(t) => t.name.as_str(),
        // TODO: fn-value params aren't modeled as a wasm value type yet; treat as
        // an unmodeled type (pointer), same as a class instance.
        ast::ParamType::Fn(_, _) => "Fn",
    }
}

thread_local! {
    /// The current `compileSource` call's wasm-gc type registry. `compileSource` is
    /// the sole entry point and is never re-entrant/concurrent within one thread, so
    /// a thread-local avoids threading an explicit `&GcTypeRegistry` parameter through
    /// every one of `plumTypeToValtype`/`astTypeToWasm`'s ~25 call sites (many several
    /// functions removed from anywhere a `CompileCtx`/`LocalCtx` is in scope, e.g.
    /// function-signature registration that runs before any `LocalCtx` exists). Set
    /// once near the top of `compileSource`, before anything below reads it.
    static CURRENT_GC_TYPES: RefCell<Option<GcTypeRegistry>> = const { RefCell::new(None) };
}

thread_local! {
    /// Top-level `NAME = literal` const values, keyed by name — set once near the
    /// top of `compileSource`, alongside `CURRENT_GC_TYPES` (same non-reentrancy
    /// rationale). A bare `NAME` reference always lexes as a type_identifier (any
    /// uppercase-leading name does, there's no separate "constant" token — see
    /// `plum-checker`'s `inferExpr`'s matching `TypeName` comment), so `Expr::TypeName`
    /// consults this map before falling back to enum-variant/unmodeled-type handling.
    static CURRENT_CONSTS: RefCell<HashMap<String, ast::Expr>> = RefCell::new(HashMap::new());
}

fn withGcTypes<R>(f: impl FnOnce(&GcTypeRegistry) -> R) -> R {
    CURRENT_GC_TYPES.with(|c| {
        let borrow = c.borrow();
        let registry = borrow.as_ref().expect("internal codegen error: GC type registry read before compileSource initialized it");
        f(registry)
    })
}

/// Resolves an `ast::Type`/`ast::ParamType`'s bare name (e.g. from a function
/// signature, before any `PlumType`/checker involvement) to its wasm-gc `ValType`.
fn astTypeToWasm(name: &str) -> Option<ValType> {
    match name {
        "Int" => Some(ValType::I64),
        "Float" => Some(ValType::F64),
        "Unit" => None,
        "Bool" => Some(plumTypeToValtype(&PlumType::TBool)),
        "Str" => Some(plumTypeToValtype(&PlumType::TStr)),
        "Byte" => Some(plumTypeToValtype(&PlumType::TByte)),
        "[]Byte" => Some(plumTypeToValtype(&PlumType::TByteSlice)),
        // "ByteSlice" (as opposed to "[]Byte") is never written in source as a type
        // annotation — it's `methodReceiverName`'s name for `TByteSlice`, which is
        // how a `ByteSlice` method's `self` param and any `ByteSlice.foo(...)`
        // static-call receiver slot get resolved here (see `fnWasmParamTypes` and
        // the `is_static_call` site). Without this arm it would fall through to the
        // generic `TNamed` branch below and get treated as an ordinary (and, for
        // this name, orphaned/unused) class struct type instead of the shared
        // `array<i8>` ref every `[]Byte` value actually is.
        "ByteSlice" => Some(plumTypeToValtype(&PlumType::TByteSlice)),
        other => Some(plumTypeToValtype(&PlumType::TNamed(other.to_string()))),
    }
}

/// A `ref null $Ty` — every heap value (class instance, enum/Bool variant, Str
/// array, closure struct) is nullable-by-convention, matching how a bump-allocator
/// i32 pointer could be "null" (0) too; nothing in this codegen currently relies on
/// non-nullable refs for an optimization, so nullable everywhere keeps this simple.
fn gcRef(idx: u32) -> ValType {
    ValType::Ref(RefType { nullable: true, heap_type: HeapType::Concrete(idx) })
}

/// Pushes an arbitrary value of the right wasm type for a "self" slot nothing
/// will ever actually read — see the `is_static_call` case in `Expr::Attribute`'s
/// `AttrKind::Method` codegen. A GC ref's null is exactly as good as a real
/// instance when the callee's body has no `self` binding to dereference it with.
fn pushSelfPlaceholder(vt: ValType, body: &mut Vec<u8>) {
    match vt {
        ValType::I64 => Instruction::I64Const(0).encode(body),
        ValType::I32 => Instruction::I32Const(0).encode(body),
        ValType::F64 => Instruction::F64Const(0.0).encode(body),
        ValType::F32 => Instruction::F32Const(0.0).encode(body),
        ValType::Ref(rt) => Instruction::RefNull(rt.heap_type).encode(body),
        ValType::V128 => Instruction::V128Const(0).encode(body),
    }
}

fn plumTypeToValtype(t: &PlumType) -> ValType {
    match t {
        PlumType::TInt => ValType::I64,
        PlumType::TFloat => ValType::F64,
        PlumType::TBool => withGcTypes(|r| gcRef(*r.enum_super_type_idx.get("Bool").expect("Bool must be registered"))),
        PlumType::TStr => withGcTypes(|r| gcRef(r.str_type_idx)),
        PlumType::TByte => ValType::I32,
        // `[]Byte` is represented by the EXACT SAME wasm-gc array type as `Str`
        // (a mutable `array<i8>`) — they're structurally identical, and nothing
        // in this codegen needs to distinguish them at the wasm-type level
        // (no runtime `ref.test`/dynamic dispatch keys off it), so reusing
        // `str_type_idx` avoids a second, redundant GC type-section entry.
        PlumType::TByteSlice => withGcTypes(|r| gcRef(r.str_type_idx)),
        PlumType::TNamed(name) => withGcTypes(|r| {
            match r.class_type_idx.get(name).or_else(|| r.enum_super_type_idx.get(name)) {
                Some(idx) => gcRef(*idx),
                // Genuinely unmodeled type name (not a real class/enum) — permissive
                // fallback, matching this function's pre-wasm-gc "unmodeled type:
                // pointer" behavior; codegen sites that actually need a concrete
                // struct type still resolve it themselves and error clearly if absent.
                None => ValType::Ref(RefType::ANYREF),
            }
        }),
        // A closure value is a 2-field `{table_index: i32, env: anyref}` struct —
        // its OWN concrete GC type, registered per Task 2e (closures), not through
        // this general resolver; `anyref` here is a safe placeholder used only
        // where a closure's exact struct type isn't being constructed/destructured
        // directly (e.g. deciding a local's storage class), matching `TVariadic`'s
        // existing "not modeled as a concrete shape here" treatment.
        PlumType::TFun(_, _) | PlumType::TVariadic(_) => ValType::Ref(RefType::ANYREF),
        PlumType::TVar(_) | PlumType::TUnit => ValType::I64,
    }
}

/// Type-section indices for every wasm-gc composite type this program's monomorphized
/// classes/enums/`Str` need. Populated once in `compileSource` from the checker's
/// global tables.
#[derive(Clone)]
pub struct GcTypeRegistry {
    /// Concrete class/struct name -> its one wasm-gc `struct` type index.
    pub class_type_idx: HashMap<String, u32>,
    /// Enum name -> its abstract supertype `struct` type index (every variant
    /// subtypes this) — includes the built-in `Bool` enum, which has no
    /// `ast::Item::Enum` of its own (`buildGlobalTables` hardcodes its
    /// `True`/`False` variants directly into `EnumVariants`).
    pub enum_super_type_idx: HashMap<String, u32>,
    /// Variant name (flat namespace, matching `EnumVariants`) -> its concrete
    /// subtype `struct` type index.
    pub variant_type_idx: HashMap<String, u32>,
    /// The single shared `array<i8>` type index every `Str` value uses.
    pub str_type_idx: u32,
    /// The single shared closure-value struct type index — `{table_idx: i32, env:
    /// anyref}` — every closure (and zero-capture "trampoline") uses regardless of
    /// its own captures; only assigned once closures are discovered (see
    /// `compileSource`), so `0` until then (nothing reads it earlier).
    pub closure_type_idx: u32,
    /// Wasm value type -> the shared `array<T>` type index used to pass a `...T`
    /// variadic argument pack as one GC array (replacing the old bump-allocated
    /// `[count][elem...]` blob — `array.len` replaces the explicit count). Populated
    /// alongside `closure_type_idx`, from every `ParamType::Variadic` in the program.
    pub variadic_array_type_idx: HashMap<ValType, u32>,
}

/// Resolves a plum type to the wasm-gc `ValType` its values are represented as, given
/// an ALREADY fully-populated `GcTypeRegistry` — every class/enum/Str index must exist
/// before this is called, since e.g. a class field of another class's type needs that
/// other class's index to already be assigned (see `buildGcTypeRegistry`'s two-pass
/// structure: this function is only ever called during its second pass).
fn plumTypeToGcValtype(t: &PlumType, registry: &GcTypeRegistry) -> ValType {
    match t {
        PlumType::TInt => ValType::I64,
        PlumType::TFloat => ValType::F64,
        PlumType::TBool => {
            let idx = *registry.enum_super_type_idx.get("Bool").expect("internal codegen error: Bool must be registered in the GC type registry");
            ValType::Ref(RefType { nullable: true, heap_type: HeapType::Concrete(idx) })
        }
        PlumType::TStr => ValType::Ref(RefType { nullable: true, heap_type: HeapType::Concrete(registry.str_type_idx) }),
        PlumType::TByte => ValType::I32,
        // See the matching comment in `plumTypeToValtype` — `[]Byte` reuses `Str`'s
        // `array<i8>` GC type index rather than getting its own.
        PlumType::TByteSlice => ValType::Ref(RefType { nullable: true, heap_type: HeapType::Concrete(registry.str_type_idx) }),
        PlumType::TNamed(name) => match registry.class_type_idx.get(name).or_else(|| registry.enum_super_type_idx.get(name)) {
            Some(idx) => ValType::Ref(RefType { nullable: true, heap_type: HeapType::Concrete(*idx) }),
            // A field typed as a generic enum/class (e.g. `Node.next: Option[Node]`)
            // resolves here to the BARE generic name — `ClassEnv`'s `plumTypeFromAst`
            // has no representation for type arguments, so it can't know this means
            // `Option$Int` once monomorphization specializes (and removes the
            // unspecialized) `Option` — same permissive `anyref` fallback as
            // `plumTypeToValtype` above, for the same "genuinely unmodeled" reason:
            // `struct.get`/`ref.test`/`ref.cast` all work against `anyref` operands
            // fine, so a field merely being STORED as `anyref` instead of the exact
            // concrete type costs nothing but static precision.
            None => ValType::Ref(RefType::ANYREF),
        },
        // TFun (closures) and TVariadic get their own concrete representation once
        // Task 2 (closures/variadic calls) lands — `anyref` is a safe, valid-but-not-
        // yet-meaningful placeholder in the meantime, since nothing consumes it yet.
        PlumType::TFun(_, _) | PlumType::TVariadic(_) => ValType::Ref(RefType::ANYREF),
        PlumType::TVar(_) | PlumType::TUnit => ValType::I64,
    }
}

/// Builds the wasm-gc type registry for every concrete class/enum in `source`, plus
/// the built-in `Bool` enum and the shared `Str` array type, and declares them all as
/// ONE `rec` group via `module.addGcTypes` — a single group sidesteps every ordering
/// question about mutual/self-references (a class field of another class's type, an
/// enum variant field referencing its own enum, `Node.next: Option[Node]`, etc.),
/// since within one `rec` group members may reference each other regardless of
/// declaration order.
fn buildGcTypeRegistry(
    module: &mut WasmModule,
    source: &ast::Source,
    classes: &ClassEnv,
    enum_variants: &EnumVariants,
    enum_params: &EnumParams,
) -> GcTypeRegistry {
    enum Slot {
        Str,
        Class(String),
        EnumSuper(String),
        Variant(String),
    }

    // Pass 1: assign every entry a slot (and therefore a type index) up front, before
    // any field list is built, so field-type resolution can reference ANY other entry.
    let mut slots: Vec<Slot> = vec![Slot::Str];
    let mut class_type_idx: HashMap<String, u32> = HashMap::new();
    let mut enum_super_type_idx: HashMap<String, u32> = HashMap::new();
    let mut variant_type_idx: HashMap<String, u32> = HashMap::new();

    for item in &source.items {
        if let ast::Item::Class(c) = item {
            class_type_idx.insert(c.name.clone(), slots.len() as u32);
            slots.push(Slot::Class(c.name.clone()));
        }
    }

    // Bool is built into `EnumVariants` (True/False) by `buildGlobalTables` with no
    // `ast::Item::Enum` of its own (see `docs/superpowers/plans/2026-07-25-wasm-gc-migration.md`'s
    // Decision 1: Bool is a full wasm-gc struct, no special-casing) — register it
    // exactly like a real enum here, ahead of whatever the source actually declares.
    let mut enum_decls: Vec<(String, Vec<String>)> =
        vec![("Bool".to_string(), vec!["False".to_string(), "True".to_string()])];
    for item in &source.items {
        // A source file may re-"declare" `enum Bool = | True | False` purely to
        // give it a nesting site for methods (no other way exists to attach a
        // method to a builtin type) — see the identical skip, with the full
        // rationale, in `plum-checker`'s `buildGlobalTables`. Registering it
        // again here would give Bool a SECOND, orphaned GC struct (the first,
        // hardcoded one is still referenced by every OTHER already-registered
        // slot/type by index) and — worse — since slot assignment for a
        // specialized generic enum with a `Bool` field (e.g. `Result[Bool,
        // Str]`) resolves "Bool" by NAME at the point it's compiled, later
        // duplicate registrations can leave that field pointing at whichever
        // Bool slot was assigned last, an index that isn't guaranteed to
        // satisfy wasm-gc's "supertypes before subtypes" ordering rule.
        if let ast::Item::Enum(e) = item {
            if e.name != "Bool" {
                enum_decls.push((e.name.clone(), e.variants.iter().map(|v| v.name.clone()).collect()));
            }
        }
    }
    for (enum_name, variant_names) in &enum_decls {
        enum_super_type_idx.insert(enum_name.clone(), slots.len() as u32);
        slots.push(Slot::EnumSuper(enum_name.clone()));
        for vname in variant_names {
            variant_type_idx.insert(vname.clone(), slots.len() as u32);
            slots.push(Slot::Variant(vname.clone()));
        }
    }

    // The registry is fully index-complete after pass 1 (every name has an assigned
    // slot) even though no field lists exist yet — safe to hand to `plumTypeToGcValtype`
    // for pass 2's field-type resolution.
    let registry = GcTypeRegistry {
        class_type_idx,
        enum_super_type_idx,
        variant_type_idx,
        str_type_idx: 0,
        closure_type_idx: 0,
        variadic_array_type_idx: HashMap::new(),
    };

    // Pass 2: build the real SubType for every slot, now that every cross-reference
    // resolves.
    let subtypes: Vec<SubType> = slots.iter().map(|slot| match slot {
        Slot::Str => SubType {
            is_final: true,
            supertype_idx: None,
            composite_type: CompositeType {
                inner: CompositeInnerType::Array(ArrayType(FieldType { element_type: StorageType::I8, mutable: true })),
                shared: false,
            },
        },
        Slot::Class(name) => {
            let fields = classes.get(name).cloned().unwrap_or_default();
            let field_types: Vec<FieldType> = fields.iter().map(|(_, ty)| FieldType {
                element_type: StorageType::Val(plumTypeToGcValtype(ty, &registry)),
                mutable: true,
            }).collect();
            SubType {
                is_final: true,
                supertype_idx: None,
                composite_type: CompositeType { inner: CompositeInnerType::Struct(StructType { fields: field_types.into() }), shared: false },
            }
        }
        // An ORDINARY enum's supertype declares zero fields (each variant adds its
        // own distinct payload fields below it). A DISCRIMINANT enum (`enum
        // Foo(n: Int) = ...`) is different: every variant shares the EXACT SAME
        // field list (`plum-checker::buildGlobalTables` already gives every variant
        // of such an enum identical `field_types`, equal to the shared params), so
        // the supertype declares those fields directly — this is what lets `self.n`
        // field access work on the plain supertype-typed reference with a
        // `struct.get`, no `ref.cast` to one arbitrary variant required (which would
        // trap at runtime whenever `self` isn't actually THAT variant).
        Slot::EnumSuper(enum_name) => {
            let params = enum_params.get(enum_name).cloned().unwrap_or_default();
            let field_types: Vec<FieldType> = params.iter().map(|(_, ty)| FieldType {
                element_type: StorageType::Val(plumTypeToGcValtype(ty, &registry)),
                mutable: false,
            }).collect();
            SubType {
                is_final: false,
                supertype_idx: None,
                composite_type: CompositeType { inner: CompositeInnerType::Struct(StructType { fields: field_types.into() }), shared: false },
            }
        }
        Slot::Variant(vname) => {
            let info = enum_variants.get(vname)
                .unwrap_or_else(|| panic!("internal codegen error: variant '{}' missing from EnumVariants", vname));
            let super_idx = *registry.enum_super_type_idx.get(&info.enum_name)
                .unwrap_or_else(|| panic!("internal codegen error: enum '{}' missing its supertype slot", info.enum_name));
            // For a discriminant enum, `info.field_types` is ALREADY identical to the
            // supertype's own fields (see the `EnumSuper` arm above) — a variant with
            // zero ADDED fields beyond its supertype is still valid wasm-gc
            // subtyping, so no special-casing is needed here.
            let field_types: Vec<FieldType> = info.field_types.iter().map(|ty| FieldType {
                element_type: StorageType::Val(plumTypeToGcValtype(ty, &registry)),
                mutable: false,
            }).collect();
            SubType {
                is_final: true,
                supertype_idx: Some(super_idx),
                composite_type: CompositeType { inner: CompositeInnerType::Struct(StructType { fields: field_types.into() }), shared: false },
            }
        }
    }).collect();

    module.addGcTypes(subtypes);

    registry
}

/// Maps an `ast::ParamType::Fn(params, ret)` to the wasm signature of the *closure
/// function* it compiles to: an implicit leading `env: anyref`, then one param per
/// declared param type, returning `ret`'s val type (or nothing for `Unit`).
fn fnParamTypeToWasmSig(params: &[ast::Type], ret: &Option<Box<ast::Type>>) -> (Vec<ValType>, Option<ValType>) {
    let mut vts = vec![ValType::Ref(RefType::ANYREF)]; // env pointer
    for p in params {
        vts.push(astTypeToWasm(&p.name).unwrap_or(ValType::I32));
    }
    let ret_vt = ret.as_ref().and_then(|t| astTypeToWasm(&t.name));
    (vts, ret_vt)
}

fn blockTypeFor(result_vt: Option<ValType>) -> BlockType {
    result_vt.map(BlockType::Result).unwrap_or(BlockType::Empty)
}

fn retTypeToWasm(ret: Option<&ast::Type>) -> Option<ValType> {
    ret.and_then(|r| astTypeToWasm(&r.name))
}

fn encodeLeb128U32(mut val: u32) -> Vec<u8> {
    let mut bytes = Vec::new();
    loop {
        let mut byte = (val & 0x7f) as u8;
        val >>= 7;
        if val != 0 {
            byte |= 0x80;
        }
        bytes.push(byte);
        if val == 0 {
            break;
        }
    }
    bytes
}

fn checkCtxOf<'a>(ctx_classes: &'a ClassEnv, ctx_methods: &'a MethodEnv, ctx_enum_variants: &'a EnumVariants, ctx_enum_params: &'a EnumParams) -> plum_checker::CheckCtx<'a> {
    plum_checker::CheckCtx { classes: ctx_classes, methods: ctx_methods, enum_variants: ctx_enum_variants, enum_params: ctx_enum_params }
}

/// Infers an expression's type using the function's current (mutable, evolving) type
/// environment. Defaults to `TInt` if inference fails — codegen assumes the source was
/// already accepted by `plum_checker::checkSource`, so a failure here would indicate
/// codegen is being driven directly on unchecked input (as some tests do).
fn inferLocalType(expr: &ast::Expr, ctx: &LocalCtx) -> PlumType {
    let env = ctx.type_env.borrow();
    let cctx = checkCtxOf(ctx.classes, ctx.methods, ctx.enum_variants, ctx.enum_params);
    plum_checker::inferExpr(expr, &env, &cctx).unwrap_or(PlumType::TInt)
}

pub fn compileSource(source: &ast::Source) -> Result<Vec<u8>, String> {
    let source = &plum_checker::monomorphize::monomorphizeSource(source)?;
    let (global_env, classes, methods, enum_variants, enum_params) = plum_checker::buildGlobalTables(source);

    let mut module = WasmModule::new();

    let mut gc_types = buildGcTypeRegistry(&mut module, source, &classes, &enum_variants, &enum_params);
    CURRENT_GC_TYPES.with(|c| *c.borrow_mut() = Some(gc_types.clone()));

    CURRENT_CONSTS.with(|c| {
        let mut consts = c.borrow_mut();
        for item in &source.items {
            if let ast::Item::Const(cst) = item {
                consts.insert(cst.name.clone(), cst.value.clone());
            }
        }
    });

    // Register one shared `array<T>` GC type per distinct wasm value type used by a
    // `...T` variadic parameter anywhere in the program (almost always just one, e.g.
    // `Int...`) — replaces the old bump-allocated `[count][elem...]` blob (`array.len`
    // replaces the explicit count word). Must happen before function signatures are
    // registered below, since a variadic param's wasm type is this array's `ref`.
    let mut variadic_elem_vts: Vec<ValType> = Vec::new();
    for item in &source.items {
        if let ast::Item::Fn(f) = item {
            for p in &f.params {
                if let ast::ParamType::Variadic(t) = &p.ty {
                    let vt = astTypeToWasm(&t.name).unwrap_or(ValType::I64);
                    if !variadic_elem_vts.contains(&vt) {
                        variadic_elem_vts.push(vt);
                    }
                }
            }
        }
    }
    let variadic_array_subtypes: Vec<SubType> = variadic_elem_vts.iter().map(|vt| SubType {
        is_final: true,
        supertype_idx: None,
        composite_type: CompositeType {
            inner: CompositeInnerType::Array(ArrayType(FieldType { element_type: StorageType::Val(*vt), mutable: false })),
            shared: false,
        },
    }).collect();
    if !variadic_array_subtypes.is_empty() {
        let indices = module.addGcTypes(variadic_array_subtypes);
        for (vt, idx) in variadic_elem_vts.iter().zip(indices) {
            gc_types.variadic_array_type_idx.insert(*vt, idx);
        }
        CURRENT_GC_TYPES.with(|c| *c.borrow_mut() = Some(gc_types.clone()));
    }

    // Register every `extern fun` (e.g. `libs/std/os.plum`'s `printLn`) as a
    // genuine wasm import BEFORE any function (including the `start` function
    // set up right below) — imports must occupy the low end of the function
    // index space for every later `addFunction`'s index arithmetic to stay
    // correct. `plum-checker` has already confirmed every extern fn has no
    // receiver and no body, so `f.name` alone (no `fnKey` receiver-mangling) is
    // always its unique key.
    let mut func_ids: HashMap<String, u32> = HashMap::new();
    let mut func_sigs: HashMap<String, FuncSig> = HashMap::new();
    for item in &source.items {
        if let ast::Item::Fn(f) = item {
            if !f.is_extern {
                continue;
            }
            let param_types = fnWasmParamTypes(f, &gc_types);
            let ret = retTypeToWasm(f.returns.as_ref());
            let results_vec: Vec<ValType> = ret.into_iter().collect();
            let type_idx = module.addType(&param_types, &results_vec);
            let func_idx = module.addImport("plum", &f.name, type_idx);
            func_ids.insert(f.name.clone(), func_idx);
            func_sigs.insert(f.name.clone(), FuncSig { params: param_types, ret });
        }
    }

    // Pre-allocate one instance of every payload-free variant (True/False/None/...)
    // as a global, populated once by a `start` function rather than reconstructed on
    // every reference — see this migration plan's Decision 2. `struct.new` isn't
    // allowed inside a global's own const-expr initializer (confirmed empirically in
    // Task 1), so each global starts `ref.null` and a `start` function fills it in
    // before any export is callable. The zero-capture "trampoline" closures
    // registered below (once closures are discovered) append to this SAME start
    // function, so its body isn't finalized/patched into the module until then.
    let mut singleton_globals: HashMap<String, u32> = HashMap::new();
    let mut start_body = Vec::new();
    for (name, info) in &enum_variants {
        if !info.field_types.is_empty() {
            continue;
        }
        let variant_idx = *gc_types.variant_type_idx.get(name)
            .unwrap_or_else(|| panic!("internal codegen error: payload-free variant '{}' missing from GC type registry", name));
        let mut init = Vec::new();
        Instruction::RefNull(HeapType::Concrete(variant_idx)).encode(&mut init);
        let global_idx = module.addGlobal(gcRef(variant_idx), true, &init);
        singleton_globals.insert(name.clone(), global_idx);

        Instruction::StructNewDefault(variant_idx).encode(&mut start_body);
        Instruction::GlobalSet(global_idx).encode(&mut start_body);
    }
    let start_type_idx = module.addType(&[], &[]);
    let start_func_idx = module.addFunction(start_type_idx, &[]);
    module.setStartFunction(start_func_idx);

    // Closure wasm signature -> function-type index, deduped so every closure/call site
    // of the same shape shares one `call_indirect` type.
    let mut closure_call_types: HashMap<ClosureSigKey, u32> = HashMap::new();

    // Register every function AND method signature up front (methods get an implicit
    // leading `self: pointer` param and are keyed as "Receiver::method").
    for item in &source.items {
        if let ast::Item::Fn(f) = item {
            if f.is_extern {
                continue;
            }
            let param_types = fnWasmParamTypes(f, &gc_types);
            let ret = retTypeToWasm(f.returns.as_ref());
            let results_vec: Vec<ValType> = ret.into_iter().collect();
            let type_idx = module.addType(&param_types, &results_vec);
            let func_idx = module.addFunction(type_idx, &[]);
            let key = fnKey(f);
            func_ids.insert(key.clone(), func_idx);
            func_sigs.insert(key, FuncSig { params: param_types, ret });

            // Any `fn(...) -> ...`-typed param is callable via `call_indirect`; register
            // its wasm signature (leading env-ptr param included) so call sites can
            // resolve a consistent type index even if no matching closure literal exists.
            for p in &f.params {
                if let ast::ParamType::Fn(params, ret) = &p.ty {
                    let (sig_params, ret_vt) = fnParamTypeToWasmSig(params, ret);
                    let sig_key: ClosureSigKey = (sig_params.clone(), ret_vt);
                    if !closure_call_types.contains_key(&sig_key) {
                        let results: Vec<ValType> = ret_vt.into_iter().collect();
                        let tidx = module.addType(&sig_params, &results);
                        closure_call_types.insert(sig_key, tidx);
                    }
                }
            }
        }
    }

    let fns: Vec<&ast::Fn> = source.items.iter().filter_map(|item| match item {
        ast::Item::Fn(f) if !f.is_extern => Some(f),
        _ => None,
    }).collect();

    // ---- Discovery pre-pass: find every closure literal in every function body. ----
    // (Runs on the monomorphized source, so any generic types in a closure's context
    // are already concrete.) Registers each closure as its own wasm function + table
    // element and records the free variables it must capture.
    let fn_decls: HashMap<String, &ast::Fn> = fns.iter().map(|f| (f.name.clone(), *f)).collect();
    let mut raw_closures: Vec<RawClosure> = Vec::new();
    let mut named_fn_refs: HashSet<String> = HashSet::new();
    for f in &fns {
        let mut env = global_env.clone();
        if let Some(recv) = &f.type_param {
            env.insert("self".to_string(), TypeScheme::mono(plum_checker::plumTypeFromName(recv)));
        }
        let mut locals: HashSet<String> = HashSet::new();
        for p in &f.params {
            env.insert(p.name.clone(), TypeScheme::mono(paramPlumType(&p.ty)));
            locals.insert(p.name.clone());
        }
        let mut walker = ClosureWalker {
            env,
            cctx: checkCtxOf(&classes, &methods, &enum_variants, &enum_params),
            fn_decls: &fn_decls,
            found: Vec::new(),
            locals,
            named_fn_refs: HashSet::new(),
        };
        match &f.body {
            ast::FnBody::Block(block) => walker.walkBlock(block),
            ast::FnBody::Expr(e) => walker.walkExpr(e, None),
            // `fns` excludes every `extern fun` (no body to discover closures in).
            ast::FnBody::Extern => unreachable!("extern fns are excluded from `fns`"),
        }
        raw_closures.extend(walker.found);
        named_fn_refs.extend(walker.named_fn_refs);
    }

    // Register every closure literal's own env struct type, plus the ONE shared
    // closure-value struct `{table_idx: i32, env: anyref}` every closure (and
    // trampoline, below) wraps its env in — declared together as a single `rec`
    // group now that every closure's free-variable types are known. The shared
    // struct is always slot 0; closure `i`'s env type is slot `i + 1`.
    let mut closure_subtypes: Vec<SubType> = vec![SubType {
        is_final: true,
        supertype_idx: None,
        composite_type: CompositeType {
            inner: CompositeInnerType::Struct(StructType {
                fields: vec![
                    FieldType { element_type: StorageType::Val(ValType::I32), mutable: false },
                    FieldType { element_type: StorageType::Val(ValType::Ref(RefType::ANYREF)), mutable: false },
                ].into(),
            }),
            shared: false,
        },
    }];
    for rc in &raw_closures {
        let field_types: Vec<FieldType> = rc.free_vars.iter().map(|(_, ty)| FieldType {
            element_type: StorageType::Val(plumTypeToValtype(ty)),
            mutable: false,
        }).collect();
        closure_subtypes.push(SubType {
            is_final: true,
            supertype_idx: None,
            composite_type: CompositeType { inner: CompositeInnerType::Struct(StructType { fields: field_types.into() }), shared: false },
        });
    }
    let closure_type_indices = module.addGcTypes(closure_subtypes);
    gc_types.closure_type_idx = closure_type_indices[0];
    CURRENT_GC_TYPES.with(|c| *c.borrow_mut() = Some(gc_types.clone()));

    let mut closures: HashMap<usize, ClosureInfo> = HashMap::new();
    let mut closure_asts: HashMap<usize, &ast::Closure> = HashMap::new();
    for (i, rc) in raw_closures.iter().enumerate() {
        let mut sig_params = vec![ValType::Ref(RefType::ANYREF)]; // env pointer
        sig_params.extend(rc.param_vts.iter().copied());
        let sig_key: ClosureSigKey = (sig_params.clone(), rc.ret_vt);
        let type_idx = match closure_call_types.get(&sig_key) {
            Some(t) => *t,
            None => {
                let results: Vec<ValType> = rc.ret_vt.into_iter().collect();
                let t = module.addType(&sig_params, &results);
                closure_call_types.insert(sig_key, t);
                t
            }
        };
        let func_idx = module.addFunction(type_idx, &[]);
        let table_idx = module.addTableElement(func_idx);
        closures.insert(rc.ptr, ClosureInfo {
            func_idx,
            table_idx,
            env_type_idx: closure_type_indices[i + 1],
            param_vts: rc.param_vts.clone(),
            param_ptypes: rc.param_ptypes.clone(),
            ret_vt: rc.ret_vt,
            free_vars: rc.free_vars.clone(),
        });
        closure_asts.insert(rc.ptr, rc.closure);
    }

    // Register a zero-capture "trampoline" closure for every top-level function
    // referenced as a bare value (e.g. `each(double)`): a real wasm function with the
    // closure calling convention `(env, ...real_params) -> ret` that ignores its env
    // and forwards straight to the real function, plus a funcref-table entry for it.
    // Since it never captures anything, its `{table_idx, env=null}` closure struct is
    // a compile-time constant — like every payload-free enum variant, built once by
    // the shared `start` function and stored in its own global.
    let mut named_fn_values: HashMap<String, u32> = HashMap::new();
    for name in &named_fn_refs {
        let sig = func_sigs.get(name).expect("named fn ref must be a registered top-level function");
        let real_func_idx = *func_ids.get(name).expect("named fn ref must be a registered top-level function");

        let mut sig_params = vec![ValType::Ref(RefType::ANYREF)]; // env pointer
        sig_params.extend(sig.params.iter().copied());
        let sig_key: ClosureSigKey = (sig_params.clone(), sig.ret);
        let type_idx = match closure_call_types.get(&sig_key) {
            Some(t) => *t,
            None => {
                let results: Vec<ValType> = sig.ret.into_iter().collect();
                let t = module.addType(&sig_params, &results);
                closure_call_types.insert(sig_key, t);
                t
            }
        };

        let mut tbody = Vec::new();
        tbody.push(0u8); // no locals beyond the params already in the signature
        for i in 0..sig.params.len() {
            Instruction::LocalGet((i + 1) as u32).encode(&mut tbody); // local 0 is env, ignored
        }
        Instruction::Call(real_func_idx).encode(&mut tbody);
        Instruction::End.encode(&mut tbody);
        let func_idx = module.addFunction(type_idx, &tbody);
        let table_idx = module.addTableElement(func_idx);

        let mut init = Vec::new();
        Instruction::RefNull(HeapType::Concrete(gc_types.closure_type_idx)).encode(&mut init);
        let global_idx = module.addGlobal(gcRef(gc_types.closure_type_idx), true, &init);
        named_fn_values.insert(name.clone(), global_idx);

        Instruction::I32Const(table_idx as i32).encode(&mut start_body);
        Instruction::RefNull(HeapType::ANY).encode(&mut start_body);
        Instruction::StructNew(gc_types.closure_type_idx).encode(&mut start_body);
        Instruction::GlobalSet(global_idx).encode(&mut start_body);
    }

    // The `start` function is now complete — every payload-free variant singleton
    // (above) and every trampoline closure (above) has appended its own init code.
    // It declares no locals of its own, but a function body's raw bytes must still
    // start with the (empty) locals-declaration vector — a single `0x00` — before
    // the instruction stream, exactly like every other compiled function body below.
    Instruction::End.encode(&mut start_body);
    let mut final_start_body = vec![0u8];
    final_start_body.extend(start_body);
    let start_slot = (start_func_idx - module.func_import_count) as usize;
    module.functions[start_slot].1 = final_start_body;

    // Runtime helpers backing string interpolation (`"{expr}"`): always registered
    // (unconditionally, for simplicity) since they're cheap and self-contained.
    let string_concat_func = registerStringConcatHelper(&mut module, gc_types.str_type_idx);
    let int_to_string_func = registerIntToStringHelper(&mut module, gc_types.str_type_idx);

    let ctx = CompileCtx {
        func_ids, func_sigs, classes, methods, enum_variants, enum_params, global_env,
        closures, closure_asts, closure_call_types, named_fn_values,
        string_concat_func, int_to_string_func, gc_types, singleton_globals,
    };

    let mut state = ModuleState { passive_segments: Vec::new() };

    let mut compiled_bodies: Vec<(String, Vec<u8>)> = Vec::new();
    for f in &fns {
        let body = match compileIntrinsicFnBody(f) {
            Some(body) => body,
            None => compileFnBody(f, &ctx, &mut state)?,
        };
        compiled_bodies.push((fnKey(f), body));
    }

    // Patch compiled bodies back into their pre-registered function slots.
    for (name, body) in &compiled_bodies {
        let idx = *ctx.func_ids.get(name).expect("function was registered in the first pass");
        let slot = (idx - module.func_import_count) as usize;
        module.functions[slot].1 = body.clone();
    }

    // Compile each closure literal's own body into its reserved function slot.
    let mut closure_bodies: Vec<(u32, Vec<u8>)> = Vec::new();
    for (ptr, info) in &ctx.closures {
        let cl = ctx.closure_asts.get(ptr).expect("every registered closure has its AST recorded");
        let body = compileClosureBody(cl, info, &ctx, &mut state)?;
        closure_bodies.push((info.func_idx, body));
    }
    for (func_idx, body) in &closure_bodies {
        let slot = (*func_idx - module.func_import_count) as usize;
        module.functions[slot].1 = body.clone();
    }

    // Flush every staged string literal into the module in the SAME order they were
    // staged — `compileStaticString` already baked each one's index (its position in
    // `state.passive_segments` at staging time) into an `array.new_data` instruction,
    // so that order must be preserved exactly for those indices to still be correct.
    for bytes in &state.passive_segments {
        module.addPassiveDataSegment(bytes);
    }

    if let Some(&main_idx) = ctx.func_ids.get("main") {
        module.addExport("main", ExportKind::Func, main_idx);
    }

    Ok(module.finish())
}

/// Registers `__string_concat(a: ref Str, b: ref Str) -> ref Str`, a hand-written
/// runtime helper backing string interpolation. `Str` is a wasm-gc `array<i8>`,
/// which tracks its own length (`array.len`) — building the concatenation is:
/// allocate a new array sized `len(a) + len(b)`, then two `array.copy`s (a
/// whole-array-in-one-instruction bulk copy).
fn registerStringConcatHelper(module: &mut WasmModule, str_type_idx: u32) -> u32 {
    let str_ref = gcRef(str_type_idx);
    let type_idx = module.addType(&[str_ref, str_ref], &[str_ref]);

    // locals: 0=a (param), 1=b (param), 2=len_a, 3=len_b, 4=result
    const A: u32 = 0;
    const B: u32 = 1;
    const LEN_A: u32 = 2;
    const LEN_B: u32 = 3;
    const RESULT: u32 = 4;

    let mut body = Vec::new();
    body.extend(encodeLeb128U32(2)); // two locals groups
    body.extend(encodeLeb128U32(2)); // len_a, len_b: i32
    ValType::I32.encode(&mut body);
    body.extend(encodeLeb128U32(1)); // result: ref
    str_ref.encode(&mut body);

    // len_a = array.len(a); len_b = array.len(b)
    Instruction::LocalGet(A).encode(&mut body);
    Instruction::ArrayLen.encode(&mut body);
    Instruction::LocalSet(LEN_A).encode(&mut body);
    Instruction::LocalGet(B).encode(&mut body);
    Instruction::ArrayLen.encode(&mut body);
    Instruction::LocalSet(LEN_B).encode(&mut body);

    // result = array.new_default(str_type_idx, len_a + len_b)
    Instruction::LocalGet(LEN_A).encode(&mut body);
    Instruction::LocalGet(LEN_B).encode(&mut body);
    Instruction::I32Add.encode(&mut body);
    Instruction::ArrayNewDefault(str_type_idx).encode(&mut body);
    Instruction::LocalSet(RESULT).encode(&mut body);

    // array.copy(dst: result, dst_offset: 0, src: a, src_offset: 0, len: len_a)
    Instruction::LocalGet(RESULT).encode(&mut body);
    Instruction::I32Const(0).encode(&mut body);
    Instruction::LocalGet(A).encode(&mut body);
    Instruction::I32Const(0).encode(&mut body);
    Instruction::LocalGet(LEN_A).encode(&mut body);
    Instruction::ArrayCopy { array_type_index_dst: str_type_idx, array_type_index_src: str_type_idx }.encode(&mut body);

    // array.copy(dst: result, dst_offset: len_a, src: b, src_offset: 0, len: len_b)
    Instruction::LocalGet(RESULT).encode(&mut body);
    Instruction::LocalGet(LEN_A).encode(&mut body);
    Instruction::LocalGet(B).encode(&mut body);
    Instruction::I32Const(0).encode(&mut body);
    Instruction::LocalGet(LEN_B).encode(&mut body);
    Instruction::ArrayCopy { array_type_index_dst: str_type_idx, array_type_index_src: str_type_idx }.encode(&mut body);

    Instruction::LocalGet(RESULT).encode(&mut body);
    Instruction::End.encode(&mut body);

    module.addFunction(type_idx, &body)
}

/// Registers `__int_to_string(n: i64) -> ref Str`, a hand-written runtime helper
/// backing string interpolation: allocates a new `array<i8>` holding `n`'s decimal
/// representation (handling a leading `-` for negatives, and `0` correctly via a
/// do-while digit count that always runs at least once).
fn registerIntToStringHelper(module: &mut WasmModule, str_type_idx: u32) -> u32 {
    let str_ref = gcRef(str_type_idx);
    let type_idx = module.addType(&[ValType::I64], &[str_ref]);

    // locals: 0=n (param, i64), 1=is_neg (i32), 2=count (i32), 3=total_len (i32),
    // 4=pos (i32), 5=result (ref), 6=abs_n (i64), 7=temp (i64)
    // Locals are declared as one group of 4 `i32`s, one group of 1 `ref`, then one
    // group of 2 `i64`s (see below), so indices must stay grouped by type in that
    // same order — NOT in whatever order reads best logically.
    const N: u32 = 0;
    const IS_NEG: u32 = 1;
    const COUNT: u32 = 2;
    const TOTAL_LEN: u32 = 3;
    const POS: u32 = 4;
    const RESULT: u32 = 5;
    const ABS_N: u32 = 6;
    const TEMP: u32 = 7;

    let mut body = Vec::new();
    body.extend(encodeLeb128U32(3)); // three locals groups
    body.extend(encodeLeb128U32(4)); // is_neg, count, total_len, pos: i32
    ValType::I32.encode(&mut body);
    body.extend(encodeLeb128U32(1)); // result: ref
    str_ref.encode(&mut body);
    body.extend(encodeLeb128U32(2)); // abs_n, temp: i64
    ValType::I64.encode(&mut body);

    // is_neg = n < 0
    Instruction::LocalGet(N).encode(&mut body);
    Instruction::I64Const(0).encode(&mut body);
    Instruction::I64LtS.encode(&mut body);
    Instruction::LocalSet(IS_NEG).encode(&mut body);

    // abs_n = is_neg ? (0 - n) : n
    Instruction::LocalGet(IS_NEG).encode(&mut body);
    Instruction::If(BlockType::Result(ValType::I64)).encode(&mut body);
    Instruction::I64Const(0).encode(&mut body);
    Instruction::LocalGet(N).encode(&mut body);
    Instruction::I64Sub.encode(&mut body);
    Instruction::Else.encode(&mut body);
    Instruction::LocalGet(N).encode(&mut body);
    Instruction::End.encode(&mut body);
    Instruction::LocalSet(ABS_N).encode(&mut body);

    // count digits: do { temp /= 10; count++ } while (temp != 0); temp starts as abs_n
    Instruction::I32Const(0).encode(&mut body);
    Instruction::LocalSet(COUNT).encode(&mut body);
    Instruction::LocalGet(ABS_N).encode(&mut body);
    Instruction::LocalSet(TEMP).encode(&mut body);
    Instruction::Loop(BlockType::Empty).encode(&mut body);
    Instruction::LocalGet(TEMP).encode(&mut body);
    Instruction::I64Const(10).encode(&mut body);
    Instruction::I64DivS.encode(&mut body);
    Instruction::LocalSet(TEMP).encode(&mut body);
    Instruction::LocalGet(COUNT).encode(&mut body);
    Instruction::I32Const(1).encode(&mut body);
    Instruction::I32Add.encode(&mut body);
    Instruction::LocalSet(COUNT).encode(&mut body);
    Instruction::LocalGet(TEMP).encode(&mut body);
    Instruction::I64Const(0).encode(&mut body);
    Instruction::I64Ne.encode(&mut body);
    Instruction::BrIf(0).encode(&mut body);
    Instruction::End.encode(&mut body);

    // total_len = count + is_neg
    Instruction::LocalGet(COUNT).encode(&mut body);
    Instruction::LocalGet(IS_NEG).encode(&mut body);
    Instruction::I32Add.encode(&mut body);
    Instruction::LocalSet(TOTAL_LEN).encode(&mut body);

    // result = array.new_default(str_type_idx, total_len) — no length prefix needed,
    // `array.len` reads it back natively.
    Instruction::LocalGet(TOTAL_LEN).encode(&mut body);
    Instruction::ArrayNewDefault(str_type_idx).encode(&mut body);
    Instruction::LocalSet(RESULT).encode(&mut body);

    // pos = total_len; temp = abs_n
    Instruction::LocalGet(TOTAL_LEN).encode(&mut body);
    Instruction::LocalSet(POS).encode(&mut body);
    Instruction::LocalGet(ABS_N).encode(&mut body);
    Instruction::LocalSet(TEMP).encode(&mut body);

    // do { pos--; result[pos] = '0' + temp % 10; temp /= 10 } while (pos > is_neg)
    Instruction::Loop(BlockType::Empty).encode(&mut body);
    Instruction::LocalGet(POS).encode(&mut body);
    Instruction::I32Const(1).encode(&mut body);
    Instruction::I32Sub.encode(&mut body);
    Instruction::LocalSet(POS).encode(&mut body);
    // array.set(result, pos, value) — stack order [array_ref, index, value]
    Instruction::LocalGet(RESULT).encode(&mut body);
    Instruction::LocalGet(POS).encode(&mut body);
    // value = '0' + (temp % 10)
    Instruction::LocalGet(TEMP).encode(&mut body);
    Instruction::I64Const(10).encode(&mut body);
    Instruction::I64RemS.encode(&mut body);
    Instruction::I64Const(48).encode(&mut body);
    Instruction::I64Add.encode(&mut body);
    Instruction::I32WrapI64.encode(&mut body);
    Instruction::ArraySet(str_type_idx).encode(&mut body);
    // temp /= 10
    Instruction::LocalGet(TEMP).encode(&mut body);
    Instruction::I64Const(10).encode(&mut body);
    Instruction::I64DivS.encode(&mut body);
    Instruction::LocalSet(TEMP).encode(&mut body);
    // while (pos > is_neg)
    Instruction::LocalGet(POS).encode(&mut body);
    Instruction::LocalGet(IS_NEG).encode(&mut body);
    Instruction::I32GtU.encode(&mut body);
    Instruction::BrIf(0).encode(&mut body);
    Instruction::End.encode(&mut body);

    // if (is_neg) result[0] = '-'
    Instruction::LocalGet(IS_NEG).encode(&mut body);
    Instruction::If(BlockType::Empty).encode(&mut body);
    Instruction::LocalGet(RESULT).encode(&mut body);
    Instruction::I32Const(0).encode(&mut body);
    Instruction::I32Const(45).encode(&mut body); // '-'
    Instruction::ArraySet(str_type_idx).encode(&mut body);
    Instruction::End.encode(&mut body);

    Instruction::LocalGet(RESULT).encode(&mut body);
    Instruction::End.encode(&mut body);

    module.addFunction(type_idx, &body)
}

/// Hand-written bodies for a handful of `libs/std/str.plum` primitives that can't
/// be expressed in Plum source at all (byte-level array access, building a new
/// one-element array) — these three are the whole reason every other `Str` method
/// (case conversion, trim, split, ...) can now be written in pure Plum on top of
/// them. Declared normally in `str.plum` (with `= todo` bodies so the checker
/// registers their real signature and validates call sites), then intercepted
/// here — by `fnKey` — instead of compiling their `todo` body to `unreachable`.
/// Returns `None` for any other function, meaning "compile it normally."
fn compileIntrinsicFnBody(f: &ast::Fn) -> Option<Vec<u8>> {
    let str_type_idx = withGcTypes(|r| r.str_type_idx);
    match (f.type_param.as_deref(), f.name.as_str()) {
        // Str.length(self) -> Int
        (Some("Str"), "length") => {
            let mut body = vec![0u8]; // no locals
            Instruction::LocalGet(0).encode(&mut body); // self
            Instruction::ArrayLen.encode(&mut body);
            Instruction::I64ExtendI32U.encode(&mut body);
            Instruction::End.encode(&mut body);
            Some(body)
        }
        // Str.byteAt(self, i: Int) -> Int — the byte's unsigned value (0-255).
        (Some("Str"), "byteAt") => {
            let mut body = vec![0u8];
            Instruction::LocalGet(0).encode(&mut body); // self
            Instruction::LocalGet(1).encode(&mut body); // i
            Instruction::I32WrapI64.encode(&mut body);
            Instruction::ArrayGetU(str_type_idx).encode(&mut body);
            Instruction::I64ExtendI32U.encode(&mut body);
            Instruction::End.encode(&mut body);
            Some(body)
        }
        // byteToStr(b: Int) -> Str — a new 1-byte Str holding `b`'s low 8 bits.
        (None, "byteToStr") => {
            let mut body = vec![0u8];
            Instruction::LocalGet(0).encode(&mut body); // b
            Instruction::I32WrapI64.encode(&mut body);
            Instruction::ArrayNewFixed { array_type_index: str_type_idx, array_size: 1 }.encode(&mut body);
            Instruction::End.encode(&mut body);
            Some(body)
        }
        // ByteSlice.length(self) -> Int — `[]Byte` reuses `Str`'s array<i8> wasm
        // type (see the comment on `PlumType::TByteSlice` in `plumTypeToValtype`),
        // so this is byte-for-byte identical to `Str.length` above.
        (Some("ByteSlice"), "length") => {
            let mut body = vec![0u8];
            Instruction::LocalGet(0).encode(&mut body); // self
            Instruction::ArrayLen.encode(&mut body);
            Instruction::I64ExtendI32U.encode(&mut body);
            Instruction::End.encode(&mut body);
            Some(body)
        }
        // ByteSlice.get(self, i: Int) -> Byte — unlike `Str.byteAt`, the result
        // is already `Byte`'s wasm representation (i32), so no `i64.extend` here.
        (Some("ByteSlice"), "get") => {
            let mut body = vec![0u8];
            Instruction::LocalGet(0).encode(&mut body); // self
            Instruction::LocalGet(1).encode(&mut body); // i
            Instruction::I32WrapI64.encode(&mut body);
            Instruction::ArrayGetU(str_type_idx).encode(&mut body);
            Instruction::End.encode(&mut body);
            Some(body)
        }
        // ByteSlice.set(self, i: Int, b: Byte) -> Unit
        (Some("ByteSlice"), "set") => {
            let mut body = vec![0u8];
            Instruction::LocalGet(0).encode(&mut body); // self
            Instruction::LocalGet(1).encode(&mut body); // i
            Instruction::I32WrapI64.encode(&mut body);
            Instruction::LocalGet(2).encode(&mut body); // b (already i32)
            Instruction::ArraySet(str_type_idx).encode(&mut body);
            Instruction::End.encode(&mut body);
            Some(body)
        }
        // makeBytes(n: Int) -> []Byte — a fresh, zero-filled byte slice of length `n`.
        (None, "makeBytes") => {
            let mut body = vec![0u8];
            Instruction::LocalGet(0).encode(&mut body); // n
            Instruction::I32WrapI64.encode(&mut body);
            Instruction::ArrayNewDefault(str_type_idx).encode(&mut body);
            Instruction::End.encode(&mut body);
            Some(body)
        }
        // copyBytes(dst: []Byte, dstStart: Int, src: []Byte, srcStart: Int, n: Int) -> Unit
        // copyStrToBytes(dst: []Byte, dstStart: Int, src: Str, srcStart: Int, n: Int) -> Unit
        // Both compile to the exact same `array.copy` — `[]Byte` and `Str` share
        // one underlying wasm-gc array type, so a bulk copy between them needs no
        // conversion, just the one instruction. Two Plum-level names exist only so
        // the checker can enforce each argument's declared type.
        (None, "copyBytes") | (None, "copyStrToBytes") => {
            let mut body = vec![0u8];
            Instruction::LocalGet(0).encode(&mut body); // dst
            Instruction::LocalGet(1).encode(&mut body); // dstStart
            Instruction::I32WrapI64.encode(&mut body);
            Instruction::LocalGet(2).encode(&mut body); // src
            Instruction::LocalGet(3).encode(&mut body); // srcStart
            Instruction::I32WrapI64.encode(&mut body);
            Instruction::LocalGet(4).encode(&mut body); // n
            Instruction::I32WrapI64.encode(&mut body);
            Instruction::ArrayCopy { array_type_index_dst: str_type_idx, array_type_index_src: str_type_idx }.encode(&mut body);
            Instruction::End.encode(&mut body);
            Some(body)
        }
        // bytesToStr(src: []Byte, start: Int, n: Int) -> Str — copies out `n` bytes
        // starting at `start` into a fresh `Str`, rather than aliasing `src`
        // directly, so a later mutation of `src` (e.g. `Buffer` reusing/growing its
        // backing slice) can never retroactively change an already-returned `Str`.
        (None, "bytesToStr") => {
            const SRC: u32 = 0;
            const START: u32 = 1;
            const N: u32 = 2;
            const RESULT: u32 = 3;
            let str_ref = gcRef(str_type_idx);
            let mut body = Vec::new();
            body.extend(encodeLeb128U32(1)); // one locals group
            body.extend(encodeLeb128U32(1)); // result: ref
            str_ref.encode(&mut body);

            Instruction::LocalGet(N).encode(&mut body);
            Instruction::I32WrapI64.encode(&mut body);
            Instruction::ArrayNewDefault(str_type_idx).encode(&mut body);
            Instruction::LocalSet(RESULT).encode(&mut body);

            Instruction::LocalGet(RESULT).encode(&mut body);
            Instruction::I32Const(0).encode(&mut body);
            Instruction::LocalGet(SRC).encode(&mut body);
            Instruction::LocalGet(START).encode(&mut body);
            Instruction::I32WrapI64.encode(&mut body);
            Instruction::LocalGet(N).encode(&mut body);
            Instruction::I32WrapI64.encode(&mut body);
            Instruction::ArrayCopy { array_type_index_dst: str_type_idx, array_type_index_src: str_type_idx }.encode(&mut body);

            Instruction::LocalGet(RESULT).encode(&mut body);
            Instruction::End.encode(&mut body);
            Some(body)
        }
        _ => None,
    }
}

/// The `PlumType` of a declared parameter, including `fn(...)`-typed params as `TFun`.
fn paramPlumType(pt: &ast::ParamType) -> PlumType {
    match pt {
        ast::ParamType::Type(t) => plum_checker::plumTypeFromAst(t),
        ast::ParamType::Variadic(t) => PlumType::TVariadic(Box::new(plum_checker::plumTypeFromAst(t))),
        ast::ParamType::Fn(params, ret) => {
            let param_types = params.iter().map(plum_checker::plumTypeFromAst).collect();
            let ret_ty = ret.as_ref().map(|r| plum_checker::plumTypeFromAst(r)).unwrap_or(PlumType::TUnit);
            PlumType::TFun(param_types, Box::new(ret_ty))
        }
    }
}

/// One closure literal discovered by the pre-pass, before it is registered as a wasm
/// function. `ptr` is the closure's `&ast::Closure` pointer identity (its stable key).
struct RawClosure<'a> {
    ptr: usize,
    closure: &'a ast::Closure,
    param_vts: Vec<ValType>,
    param_ptypes: Vec<PlumType>,
    ret_vt: Option<ValType>,
    free_vars: Vec<(String, PlumType)>,
}

/// Walks a function body (maintaining an evolving type env, exactly like `Collector`)
/// to find every closure literal and determine its concrete signature and captured
/// free variables. A closure passed directly as a `fn(...)`-typed call argument takes
/// its signature from that declared param type; any other closure (e.g. one assigned to
/// a local) falls back to the checker's inference of the closure expression itself.
struct ClosureWalker<'a, 'c> {
    env: TypeEnv,
    cctx: plum_checker::CheckCtx<'c>,
    fn_decls: &'a HashMap<String, &'a ast::Fn>,
    found: Vec<RawClosure<'a>>,
    /// Local names bound in the function currently being walked (params, assign
    /// targets, for-loop vars) — used to tell a local variable reference apart from a
    /// bare reference to a top-level function name (see `named_fn_refs`).
    locals: HashSet<String>,
    /// Top-level (non-method) function names referenced as a bare value (e.g.
    /// `each(double)`) rather than called directly (`double(x)`, which compiles via
    /// `Expr::FnCall` and never reaches here). Each one needs a zero-capture
    /// "trampoline" closure so it can be used wherever a `fn(...)`-typed value is
    /// expected.
    named_fn_refs: HashSet<String>,
}

impl<'a, 'c> ClosureWalker<'a, 'c> {
    fn walkBlock(&mut self, block: &'a ast::Block) {
        for s in &block.stmts {
            self.walkStmt(s);
        }
    }

    fn walkStmt(&mut self, stmt: &'a ast::Stmt) {
        match stmt {
            ast::Stmt::Assign(a) => {
                for (target, value) in a.targets.iter().zip(a.values.iter()) {
                    self.walkExpr(value, None);
                    match target {
                        ast::AssignTarget::Var(name) => {
                            let ty = plum_checker::inferExpr(value, &self.env, &self.cctx).unwrap_or(PlumType::TInt);
                            self.env.insert(name.clone(), TypeScheme::mono(ty));
                            self.locals.insert(name.clone());
                        }
                        ast::AssignTarget::Field(object, _) => {
                            self.walkExpr(object, None);
                        }
                    }
                }
            }
            ast::Stmt::Return(Some(e)) => self.walkExpr(e, None),
            ast::Stmt::Return(None) => {}
            ast::Stmt::If(i) => {
                self.walkExpr(&i.condition, None);
                self.walkBlock(&i.body);
                for ei in &i.else_ifs {
                    self.walkExpr(&ei.condition, None);
                    self.walkBlock(&ei.body);
                }
                if let Some(e) = &i.else_ {
                    self.walkBlock(e);
                }
            }
            ast::Stmt::While(w) => {
                self.walkExpr(&w.condition, None);
                self.walkBlock(&w.body);
            }
            ast::Stmt::For(f) => {
                self.walkExpr(&f.iter, None);
                let elem_ty = match plum_checker::inferExpr(&f.iter, &self.env, &self.cctx) {
                    Ok(PlumType::TVariadic(elem)) => *elem,
                    _ => PlumType::TInt,
                };
                for v in &f.vars {
                    self.env.insert(v.clone(), TypeScheme::mono(elem_ty.clone()));
                    self.locals.insert(v.clone());
                }
                self.walkBlock(&f.body);
            }
            ast::Stmt::Expr(e) => self.walkExpr(e, None),
            ast::Stmt::Assert(e) => self.walkExpr(e, None),
            ast::Stmt::Match(m) => {
                for s in &m.subjects {
                    self.walkExpr(s, None);
                }
                for case in &m.cases {
                    self.walkBlock(&case.body);
                }
            }
            ast::Stmt::Break | ast::Stmt::Continue | ast::Stmt::Todo => {}
        }
    }

    /// `expected_fn` carries the declared `fn(params) -> ret` type when this expression is
    /// a call argument in a `fn`-typed parameter position, giving a closure literal its
    /// concrete signature.
    fn walkExpr(&mut self, expr: &'a ast::Expr, expected_fn: Option<&'a ast::ParamType>) {
        match expr {
            ast::Expr::Closure(cl) => self.recordClosure(cl, expected_fn),
            ast::Expr::Binary(b) => { self.walkExpr(&b.left, None); self.walkExpr(&b.right, None); }
            ast::Expr::Bool(b) => { self.walkExpr(&b.left, None); self.walkExpr(&b.right, None); }
            ast::Expr::Compare(c) => { self.walkExpr(&c.left, None); self.walkExpr(&c.right, None); }
            ast::Expr::Not(inner) => self.walkExpr(inner, None),
            ast::Expr::Unary(u) => self.walkExpr(&u.operand, None),
            ast::Expr::Paren(inner) => self.walkExpr(inner, None),
            ast::Expr::Ternary(t) => {
                self.walkExpr(&t.condition, None);
                self.walkExpr(&t.then, None);
                self.walkExpr(&t.else_, None);
            }
            ast::Expr::FnCall(call) => {
                let callee = self.fn_decls.get(&call.name).copied();
                for (i, arg) in call.args.iter().enumerate() {
                    let arg_expr = argExprOf(arg);
                    let expected = callee.and_then(|f| f.params.get(i)).map(|p| &p.ty)
                        .filter(|pt| matches!(pt, ast::ParamType::Fn(_, _)));
                    self.walkExpr(arg_expr, expected);
                }
            }
            ast::Expr::ClassCall(call) => {
                for fa in &call.fields {
                    self.walkExpr(&fa.value, None);
                }
            }
            ast::Expr::Attribute(a) => {
                self.walkExpr(&a.object, None);
                if let ast::AttrKind::Method(call) = &a.attr {
                    for arg in &call.args {
                        self.walkExpr(argExprOf(arg), None);
                    }
                }
            }
            ast::Expr::Var(name) => {
                // A bare name that isn't a local in this scope but does name a
                // top-level (non-method) function is a reference to that function as
                // a value (e.g. `each(double)`), not a direct call — a direct call
                // compiles via `Expr::FnCall` and never reaches this arm.
                if !self.locals.contains(name) {
                    if let Some(f) = self.fn_decls.get(name) {
                        if f.type_param.is_none() {
                            self.named_fn_refs.insert(name.clone());
                        }
                    }
                }
            }
            ast::Expr::Int(_)
            | ast::Expr::Float(_)
            | ast::Expr::String(_)
            | ast::Expr::Self_
            | ast::Expr::TypeName(_) => {}
        }
    }

    fn recordClosure(&mut self, cl: &'a ast::Closure, expected_fn: Option<&'a ast::ParamType>) {
        let (param_vts, param_ptypes, ret_vt) = match expected_fn {
            Some(ast::ParamType::Fn(params, ret)) => {
                let param_vts = params.iter().map(|t| astTypeToWasm(&t.name).unwrap_or(ValType::I32)).collect();
                let param_ptypes = params.iter().map(plum_checker::plumTypeFromAst).collect();
                let ret_vt = ret.as_ref().and_then(|t| astTypeToWasm(&t.name));
                (param_vts, param_ptypes, ret_vt)
            }
            _ => {
                // Not a direct `fn`-typed call argument (e.g. assigned to a local
                // first, then called/passed on later): the checker's own closure
                // inference (`plum_checker::inferExpr` on `Expr::Closure`) gives
                // each param a fresh `TVar` and never actually unifies it against
                // how the param is used in the body — `unify` is a no-op for any
                // `TVar` — so a param that's genuinely Float or a class/pointer
                // silently comes back as an unresolved `TVar`, which
                // `plumTypeToValtype` then defaults to `Int`. If the closure is
                // later called at its real (non-Int) type, the wasm function actually
                // compiled for its body (built from this wrong, Int-assumed signature)
                // won't match the `call_indirect` type the real call site expects —
                // a mismatch `call_indirect` only traps on at runtime, not at compile
                // or validation time.
                //
                // Fix: resolve each param's type from a direct usage in the body
                // first (see `resolveClosureParamTypesFromUsage`) — e.g. `cb = |v|
                // x + v` with a captured `Float` `x` resolves `v` to `Float` from that
                // `Binary` op — then infer the return type against an env where the
                // params are already bound concretely (bypassing the checker's
                // Closure-inference arm entirely, since it always re-binds params to
                // fresh TVars regardless of what's already in the env passed to it).
                let resolved_params = resolveClosureParamTypesFromUsage(cl, &self.env, &self.cctx);
                let param_ptypes: Vec<PlumType> = cl.params.iter()
                    .map(|p| resolved_params.get(p).cloned().unwrap_or(PlumType::TInt))
                    .collect();
                let param_vts: Vec<ValType> = param_ptypes.iter().map(plumTypeToValtype).collect();

                let mut body_env = self.env.clone();
                for (p, ty) in cl.params.iter().zip(param_ptypes.iter()) {
                    body_env.insert(p.clone(), TypeScheme::mono(ty.clone()));
                }
                let ret_ty = match cl.body.stmts.last() {
                    Some(ast::Stmt::Expr(e)) => plum_checker::inferExpr(e, &body_env, &self.cctx).ok(),
                    Some(ast::Stmt::Return(Some(e))) => plum_checker::inferExpr(e, &body_env, &self.cctx).ok(),
                    _ => Some(PlumType::TUnit),
                };
                let ret_vt = match ret_ty {
                    Some(PlumType::TUnit) => None,
                    Some(PlumType::TVar(_)) | None => Some(ValType::I64), // unresolved: preserve prior Int-default behavior
                    Some(other) => Some(plumTypeToValtype(&other)),
                };
                (param_vts, param_ptypes, ret_vt)
            }
        };
        let free_vars = collectFreeVars(cl, &self.env, self.fn_decls);
        self.found.push(RawClosure {
            ptr: cl as *const ast::Closure as usize,
            closure: cl,
            param_vts,
            param_ptypes: param_ptypes.clone(),
            ret_vt,
            free_vars,
        });

        // Recurse into this closure's own body to find any closure literals nested
        // inside it (`|v| |w| v + w`, or a closure literal used inside a `match`/`if`
        // within this one's body). Each nested closure gets registered exactly like a
        // top-level one, seeing this closure's own params as locals in scope — which
        // is also what makes its free-variable analysis correctly capture a name from
        // *this* closure's scope (rather than silently missing it): once discovery
        // finishes, `ctx.closures` holds every closure at every depth before any body
        // is compiled, so the single fixed "compile each registered closure" pass in
        // `compileSource` already handles arbitrary nesting with no further changes.
        let saved_env = self.env.clone();
        let saved_locals = self.locals.clone();
        for (p, ty) in cl.params.iter().zip(param_ptypes.iter()) {
            self.env.insert(p.clone(), TypeScheme::mono(ty.clone()));
            self.locals.insert(p.clone());
        }
        self.walkBlock(&cl.body);
        self.env = saved_env;
        self.locals = saved_locals;
    }
}

fn argExprOf(arg: &ast::Arg) -> &ast::Expr {
    match arg {
        ast::Arg::Positional(e) => e,
        ast::Arg::Keyword { value, .. } => value,
        ast::Arg::Pair { value, .. } => value,
    }
}

/// Resolves as many of a closure's param types as possible from how they're actually
/// used in its body — e.g. `|v| x + v` with a captured `Float` `x` resolves `v` to
/// `Float` from that `Binary` op, or `|v| helper(v)` where `helper`'s declared param
/// type is concrete resolves `v` to that. A param never used in a way that pins down
/// a concrete type simply doesn't appear in the returned map (callers fall back to
/// `Int`, matching the prior default). This is intentionally a shallow, best-effort
/// scan — not full unification — scoped to fixing the specific `call_indirect`
/// signature-mismatch gap this exists for, not replacing the checker's inference.
fn resolveClosureParamTypesFromUsage(
    cl: &ast::Closure,
    env: &TypeEnv,
    cctx: &plum_checker::CheckCtx,
) -> HashMap<String, PlumType> {
    let params: std::collections::HashSet<String> = cl.params.iter().cloned().collect();
    let mut resolved: HashMap<String, PlumType> = HashMap::new();
    for stmt in &cl.body.stmts {
        scanStmtForParamTypes(stmt, &params, env, cctx, &mut resolved);
    }
    resolved
}

fn scanStmtForParamTypes(
    stmt: &ast::Stmt,
    params: &std::collections::HashSet<String>,
    env: &TypeEnv,
    cctx: &plum_checker::CheckCtx,
    resolved: &mut HashMap<String, PlumType>,
) {
    match stmt {
        ast::Stmt::Assign(a) => {
            for v in &a.values {
                scanExprForParamTypes(v, params, env, cctx, resolved);
            }
        }
        ast::Stmt::Return(Some(e)) | ast::Stmt::Expr(e) | ast::Stmt::Assert(e) => {
            scanExprForParamTypes(e, params, env, cctx, resolved);
        }
        ast::Stmt::If(i) => {
            scanExprForParamTypes(&i.condition, params, env, cctx, resolved);
            for s in &i.body.stmts {
                scanStmtForParamTypes(s, params, env, cctx, resolved);
            }
            for ei in &i.else_ifs {
                scanExprForParamTypes(&ei.condition, params, env, cctx, resolved);
                for s in &ei.body.stmts {
                    scanStmtForParamTypes(s, params, env, cctx, resolved);
                }
            }
            if let Some(e) = &i.else_ {
                for s in &e.stmts {
                    scanStmtForParamTypes(s, params, env, cctx, resolved);
                }
            }
        }
        ast::Stmt::While(w) => {
            scanExprForParamTypes(&w.condition, params, env, cctx, resolved);
            for s in &w.body.stmts {
                scanStmtForParamTypes(s, params, env, cctx, resolved);
            }
        }
        ast::Stmt::For(f) => {
            scanExprForParamTypes(&f.iter, params, env, cctx, resolved);
            for s in &f.body.stmts {
                scanStmtForParamTypes(s, params, env, cctx, resolved);
            }
        }
        _ => {}
    }
}

fn scanExprForParamTypes(
    expr: &ast::Expr,
    params: &std::collections::HashSet<String>,
    env: &TypeEnv,
    cctx: &plum_checker::CheckCtx,
    resolved: &mut HashMap<String, PlumType>,
) {
    match expr {
        ast::Expr::Binary(b) => {
            tryResolveParamFromPair(&b.left, &b.right, params, env, cctx, resolved);
            scanExprForParamTypes(&b.left, params, env, cctx, resolved);
            scanExprForParamTypes(&b.right, params, env, cctx, resolved);
        }
        ast::Expr::Compare(c) => {
            tryResolveParamFromPair(&c.left, &c.right, params, env, cctx, resolved);
            scanExprForParamTypes(&c.left, params, env, cctx, resolved);
            scanExprForParamTypes(&c.right, params, env, cctx, resolved);
        }
        ast::Expr::Bool(b) => {
            scanExprForParamTypes(&b.left, params, env, cctx, resolved);
            scanExprForParamTypes(&b.right, params, env, cctx, resolved);
        }
        ast::Expr::Not(inner) => scanExprForParamTypes(inner, params, env, cctx, resolved),
        ast::Expr::Unary(u) => scanExprForParamTypes(&u.operand, params, env, cctx, resolved),
        ast::Expr::Paren(inner) => scanExprForParamTypes(inner, params, env, cctx, resolved),
        ast::Expr::Ternary(t) => {
            scanExprForParamTypes(&t.condition, params, env, cctx, resolved);
            scanExprForParamTypes(&t.then, params, env, cctx, resolved);
            scanExprForParamTypes(&t.else_, params, env, cctx, resolved);
        }
        ast::Expr::FnCall(call) => {
            if let Ok(PlumType::TFun(param_types, _)) = plum_checker::lookup(env, &call.name) {
                for (arg, expected) in call.args.iter().zip(param_types.iter()) {
                    let arg_expr = argExprOf(arg);
                    if let ast::Expr::Var(n) = arg_expr {
                        if params.contains(n) && !resolved.contains_key(n) && !matches!(expected, PlumType::TVar(_)) {
                            resolved.insert(n.clone(), expected.clone());
                        }
                    }
                }
            }
            for arg in &call.args {
                scanExprForParamTypes(argExprOf(arg), params, env, cctx, resolved);
            }
        }
        ast::Expr::ClassCall(call) => {
            for fa in &call.fields {
                scanExprForParamTypes(&fa.value, params, env, cctx, resolved);
            }
        }
        ast::Expr::Attribute(a) => {
            // `c.field` on a bare, unresolved param implies `c`'s type is whichever
            // class declares that field name — ambiguous if more than one class has
            // a field by that name, but resolvable in the common case.
            if let ast::AttrKind::Field(field_name) = &a.attr {
                if let ast::Expr::Var(n) = &a.object {
                    if params.contains(n) && !resolved.contains_key(n) {
                        let mut matches = cctx.classes.iter().filter(|(_, fields)| fields.iter().any(|(fname, _)| fname == field_name));
                        if let (Some((class_name, _)), None) = (matches.next(), matches.next()) {
                            resolved.insert(n.clone(), PlumType::TNamed(class_name.clone()));
                        }
                    }
                }
            }
            scanExprForParamTypes(&a.object, params, env, cctx, resolved);
            if let ast::AttrKind::Method(call) = &a.attr {
                for arg in &call.args {
                    scanExprForParamTypes(argExprOf(arg), params, env, cctx, resolved);
                }
            }
        }
        _ => {}
    }
}

/// If either side of a `Binary`/`Compare` operand pair is a bare reference to an
/// unresolved param and the *other* side has a concrete (non-`TVar`) inferred type,
/// binds the param to that type.
fn tryResolveParamFromPair(
    left: &ast::Expr,
    right: &ast::Expr,
    params: &std::collections::HashSet<String>,
    env: &TypeEnv,
    cctx: &plum_checker::CheckCtx,
    resolved: &mut HashMap<String, PlumType>,
) {
    if let ast::Expr::Var(n) = left {
        if params.contains(n) && !resolved.contains_key(n) {
            if let Ok(ty) = plum_checker::inferExpr(right, env, cctx) {
                if !matches!(ty, PlumType::TVar(_)) {
                    resolved.insert(n.clone(), ty);
                }
            }
        }
    }
    if let ast::Expr::Var(n) = right {
        if params.contains(n) && !resolved.contains_key(n) {
            if let Ok(ty) = plum_checker::inferExpr(left, env, cctx) {
                if !matches!(ty, PlumType::TVar(_)) {
                    resolved.insert(n.clone(), ty);
                }
            }
        }
    }
}

/// Determines a closure's captured free variables: every `Var` referenced in its body
/// that is neither one of the closure's own params nor assigned locally inside the body,
/// in first-appearance order. Each free variable's type is looked up in the *enclosing*
/// scope's type env.
fn collectFreeVars(
    cl: &ast::Closure,
    env: &TypeEnv,
    fn_decls: &HashMap<String, &ast::Fn>,
) -> Vec<(String, PlumType)> {
    let mut bound: std::collections::HashSet<String> = cl.params.iter().cloned().collect();
    fvCollectBoundBlock(&cl.body, &mut bound);

    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
    let mut free: Vec<(String, PlumType)> = Vec::new();
    fvCollectRefsBlock(&cl.body, &bound, &mut seen, &mut free, env, fn_decls);
    free
}

fn fvCollectBoundBlock(block: &ast::Block, bound: &mut std::collections::HashSet<String>) {
    for s in &block.stmts {
        match s {
            ast::Stmt::Assign(a) => {
                for t in &a.targets {
                    if let ast::AssignTarget::Var(name) = t {
                        bound.insert(name.clone());
                    }
                }
            }
            ast::Stmt::For(f) => {
                for v in &f.vars {
                    bound.insert(v.clone());
                }
                fvCollectBoundBlock(&f.body, bound);
            }
            ast::Stmt::If(i) => {
                fvCollectBoundBlock(&i.body, bound);
                for ei in &i.else_ifs {
                    fvCollectBoundBlock(&ei.body, bound);
                }
                if let Some(e) = &i.else_ {
                    fvCollectBoundBlock(e, bound);
                }
            }
            ast::Stmt::While(w) => fvCollectBoundBlock(&w.body, bound),
            ast::Stmt::Match(m) => {
                for case in &m.cases {
                    for p in &case.patterns {
                        fvCollectPatternBindings(p, bound);
                    }
                    fvCollectBoundBlock(&case.body, bound);
                }
            }
            _ => {}
        }
    }
}

fn fvCollectPatternBindings(pat: &ast::CasePattern, bound: &mut std::collections::HashSet<String>) {
    match pat {
        ast::CasePattern::Name(n) => { bound.insert(n.clone()); }
        ast::CasePattern::Class { fields, .. } => {
            for f in fields {
                fvCollectPatternBindings(f, bound);
            }
        }
        _ => {}
    }
}

fn fvCollectRefsBlock(
    block: &ast::Block,
    bound: &std::collections::HashSet<String>,
    seen: &mut std::collections::HashSet<String>,
    free: &mut Vec<(String, PlumType)>,
    env: &TypeEnv,
    fn_decls: &HashMap<String, &ast::Fn>,
) {
    for s in &block.stmts {
        match s {
            ast::Stmt::Assign(a) => {
                for v in &a.values {
                    fvCollectRefsExpr(v, bound, seen, free, env, fn_decls);
                }
                for t in &a.targets {
                    if let ast::AssignTarget::Field(object, _) = t {
                        fvCollectRefsExpr(object, bound, seen, free, env, fn_decls);
                    }
                }
            }
            ast::Stmt::Return(Some(e)) | ast::Stmt::Expr(e) | ast::Stmt::Assert(e) => {
                fvCollectRefsExpr(e, bound, seen, free, env, fn_decls);
            }
            ast::Stmt::If(i) => {
                fvCollectRefsExpr(&i.condition, bound, seen, free, env, fn_decls);
                fvCollectRefsBlock(&i.body, bound, seen, free, env, fn_decls);
                for ei in &i.else_ifs {
                    fvCollectRefsExpr(&ei.condition, bound, seen, free, env, fn_decls);
                    fvCollectRefsBlock(&ei.body, bound, seen, free, env, fn_decls);
                }
                if let Some(e) = &i.else_ {
                    fvCollectRefsBlock(e, bound, seen, free, env, fn_decls);
                }
            }
            ast::Stmt::While(w) => {
                fvCollectRefsExpr(&w.condition, bound, seen, free, env, fn_decls);
                fvCollectRefsBlock(&w.body, bound, seen, free, env, fn_decls);
            }
            ast::Stmt::For(f) => {
                fvCollectRefsExpr(&f.iter, bound, seen, free, env, fn_decls);
                fvCollectRefsBlock(&f.body, bound, seen, free, env, fn_decls);
            }
            ast::Stmt::Match(m) => {
                for subj in &m.subjects {
                    fvCollectRefsExpr(subj, bound, seen, free, env, fn_decls);
                }
                for case in &m.cases {
                    fvCollectRefsBlock(&case.body, bound, seen, free, env, fn_decls);
                }
            }
            _ => {}
        }
    }
}

fn fvCollectRefsExpr(
    expr: &ast::Expr,
    bound: &std::collections::HashSet<String>,
    seen: &mut std::collections::HashSet<String>,
    free: &mut Vec<(String, PlumType)>,
    env: &TypeEnv,
    fn_decls: &HashMap<String, &ast::Fn>,
) {
    match expr {
        ast::Expr::Var(name) => {
            // A top-level (non-method) function referenced bare (e.g. `each(double)`)
            // is not a captured variable — it's compiled as a static trampoline
            // reference (see `named_fn_values`), not loaded from an enclosing local.
            let is_named_fn_ref = fn_decls.get(name).is_some_and(|f| f.type_param.is_none());
            if !is_named_fn_ref && !bound.contains(name) && seen.insert(name.clone()) {
                let ty = plum_checker::lookup(env, name).unwrap_or(PlumType::TInt);
                free.push((name.clone(), ty));
            }
        }
        ast::Expr::Binary(b) => { fvCollectRefsExpr(&b.left, bound, seen, free, env, fn_decls); fvCollectRefsExpr(&b.right, bound, seen, free, env, fn_decls); }
        ast::Expr::Bool(b) => { fvCollectRefsExpr(&b.left, bound, seen, free, env, fn_decls); fvCollectRefsExpr(&b.right, bound, seen, free, env, fn_decls); }
        ast::Expr::Compare(c) => { fvCollectRefsExpr(&c.left, bound, seen, free, env, fn_decls); fvCollectRefsExpr(&c.right, bound, seen, free, env, fn_decls); }
        ast::Expr::Not(inner) => fvCollectRefsExpr(inner, bound, seen, free, env, fn_decls),
        ast::Expr::Unary(u) => fvCollectRefsExpr(&u.operand, bound, seen, free, env, fn_decls),
        ast::Expr::Paren(inner) => fvCollectRefsExpr(inner, bound, seen, free, env, fn_decls),
        ast::Expr::Ternary(t) => {
            fvCollectRefsExpr(&t.condition, bound, seen, free, env, fn_decls);
            fvCollectRefsExpr(&t.then, bound, seen, free, env, fn_decls);
            fvCollectRefsExpr(&t.else_, bound, seen, free, env, fn_decls);
        }
        ast::Expr::FnCall(call) => {
            for arg in &call.args {
                fvCollectRefsExpr(argExprOf(arg), bound, seen, free, env, fn_decls);
            }
        }
        ast::Expr::ClassCall(call) => {
            for fa in &call.fields {
                fvCollectRefsExpr(&fa.value, bound, seen, free, env, fn_decls);
            }
        }
        ast::Expr::Attribute(a) => {
            fvCollectRefsExpr(&a.object, bound, seen, free, env, fn_decls);
            if let ast::AttrKind::Method(call) = &a.attr {
                for arg in &call.args {
                    fvCollectRefsExpr(argExprOf(arg), bound, seen, free, env, fn_decls);
                }
            }
        }
        ast::Expr::Closure(inner) => {
            // A name the *inner* closure references that isn't bound by the inner
            // itself (its own params/locals) and isn't bound by *this* (outer)
            // closure either is a genuine multi-level capture: this outer closure
            // also needs to capture it from its own enclosing scope, in order to
            // pass it down when it later constructs the inner closure. A name the
            // inner references that the outer already binds (e.g. one of the
            // outer's own params) needs no such propagation — the outer's compiled
            // body can already reference it as an ordinary local when snapshotting
            // the inner closure's env, so it's deliberately excluded here by
            // unioning `bound` (outer) with the inner's own bound set below, rather
            // than passing the inner's bound set alone.
            let mut inner_bound = bound.clone();
            for p in &inner.params {
                inner_bound.insert(p.clone());
            }
            fvCollectBoundBlock(&inner.body, &mut inner_bound);
            fvCollectRefsBlock(&inner.body, &inner_bound, seen, free, env, fn_decls);
        }
        _ => {}
    }
}

/// Walks a function body once to determine: (1) every locally-assigned/bound name and
/// its inferred type, (2) how many `ClassCall` scratch temporaries it needs, and (3)
/// the subject type for every `match` statement (for its own scratch temporary).
struct Collector<'a> {
    env: TypeEnv,
    cctx: plum_checker::CheckCtx<'a>,
    named: Vec<(String, PlumType)>,
    named_set: std::collections::HashSet<String>,
    /// One scratch-local type per subject (usually one, more for `match a, b, ...`).
    match_scratch: HashMap<usize, Vec<PlumType>>,
    /// `CasePattern::Class` identity -> its scratch local. Covers EVERY constructor
    /// pattern, including top-level ones — under wasm-gc, matching `Some(v)` needs a
    /// `ref.cast` from the subject's static supertype down to the concrete variant
    /// type before any `struct.get` on it validates, and that narrowed value needs
    /// its OWN local (declared with the concrete variant's ref type) distinct from
    /// the original wide-typed subject local, which stays declared at the
    /// supertype's type for the whole function. (Before wasm-gc, this only covered
    /// patterns nested inside another constructor pattern's fields, depth >= 1,
    /// since a plain i32 pointer needed no per-pattern static type at all.)
    nested_class_scratch: HashMap<usize, u32>,
    /// Slot number (the `u32` values in `nested_class_scratch`) -> the variant name
    /// it narrows to, so its scratch local can be declared with that variant's exact
    /// concrete ref type instead of a uniform placeholder type.
    nested_class_scratch_types: Vec<String>,
    next_nested_class_slot: u32,
    /// `For` stmt identity (pointer address) -> a slot number; each slot reserves 2
    /// consecutive `i32` scratch locals for variadic iteration (`for v in nums`):
    /// [count, loop index]. Only `for` statements whose iterable is a `TVariadic`
    /// use this — an ordinary range `for` reuses its own loop var as the counter
    /// and needs no extra scratch locals.
    variadic_for_scratch: HashMap<usize, u32>,
    next_variadic_for_slot: u32,
}

impl<'a> Collector<'a> {
    fn bind(&mut self, name: &str, ty: PlumType) {
        if self.named_set.insert(name.to_string()) {
            self.named.push((name.to_string(), ty.clone()));
        }
        self.env.insert(name.to_string(), TypeScheme::mono(ty));
    }

    /// Binds every `Name` sub-pattern anywhere inside `pat` (at any nesting depth) to
    /// its correct field type, and reserves a scratch local for every `Class`
    /// sub-pattern found *nested* inside another constructor pattern's fields (the
    /// outermost, per-subject pattern doesn't need one — see `nested_class_scratch`).
    fn collectPattern(&mut self, pat: &ast::CasePattern, ty: &PlumType) {
        match pat {
            ast::CasePattern::Name(n) => {
                let is_variant = n.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
                    && self.cctx.enum_variants.contains_key(n);
                if !is_variant {
                    self.bind(n, ty.clone());
                }
            }
            ast::CasePattern::Class { name, fields } => {
                let key = pat as *const ast::CasePattern as usize;
                let slot = self.next_nested_class_slot;
                self.next_nested_class_slot += 1;
                self.nested_class_scratch.insert(key, slot);
                self.nested_class_scratch_types.push(name.clone());
                if let Some(info) = self.cctx.enum_variants.get(name) {
                    let field_types = info.field_types.clone();
                    for (f, fty) in fields.iter().zip(field_types.iter()) {
                        self.collectPattern(f, fty);
                    }
                }
            }
            _ => {}
        }
    }

    fn walkBlock(&mut self, block: &ast::Block) {
        for s in &block.stmts {
            self.walkStmt(s);
        }
    }

    fn walkStmt(&mut self, stmt: &ast::Stmt) {
        match stmt {
            ast::Stmt::Assign(a) => {
                for (target, value) in a.targets.iter().zip(a.values.iter()) {
                    self.walkExpr(value);
                    match target {
                        ast::AssignTarget::Var(name) => {
                            let ty = if matches!(value, ast::Expr::Closure(_)) {
                                // The checker's own closure inference (`inferExpr` on
                                // `Expr::Closure`) infers the return type by recursively
                                // inferring the body's tail expression with each param bound
                                // to a fresh, unconstrained `TVar` — e.g. a captured/param
                                // attribute access (`c.age`) on a `TVar`-typed object isn't a
                                // known class, so it errors out entirely, and this call site
                                // then silently defaults to `TInt` — the *wrong* wasm local
                                // width for what's actually always an `i32` pointer. All that
                                // actually matters here is the local's wasm width, and every
                                // closure value is an i32 pointer regardless of its
                                // parameter/return types, so skip inference entirely.
                                PlumType::TFun(Vec::new(), Box::new(PlumType::TUnit))
                            } else {
                                plum_checker::inferExpr(value, &self.env, &self.cctx).unwrap_or(PlumType::TInt)
                            };
                            self.bind(name, ty);
                        }
                        ast::AssignTarget::Field(object, _) => {
                            self.walkExpr(object);
                        }
                    }
                }
            }
            ast::Stmt::Return(Some(e)) => self.walkExpr(e),
            ast::Stmt::Return(None) => {}
            ast::Stmt::If(i) => {
                self.walkExpr(&i.condition);
                self.walkBlock(&i.body);
                for ei in &i.else_ifs {
                    self.walkExpr(&ei.condition);
                    self.walkBlock(&ei.body);
                }
                if let Some(e) = &i.else_ {
                    self.walkBlock(e);
                }
            }
            ast::Stmt::While(w) => {
                self.walkExpr(&w.condition);
                self.walkBlock(&w.body);
            }
            ast::Stmt::For(f) => {
                self.walkExpr(&f.iter);
                let iter_ty = plum_checker::inferExpr(&f.iter, &self.env, &self.cctx).unwrap_or(PlumType::TInt);
                if let PlumType::TVariadic(elem) = &iter_ty {
                    let idx = self.next_variadic_for_slot;
                    self.next_variadic_for_slot += 1;
                    self.variadic_for_scratch.insert(f as *const ast::For as usize, idx);
                    for v in &f.vars {
                        self.bind(v, (**elem).clone());
                    }
                } else {
                    for v in &f.vars {
                        self.bind(v, PlumType::TInt);
                    }
                }
                self.walkBlock(&f.body);
            }
            ast::Stmt::Expr(e) => self.walkExpr(e),
            ast::Stmt::Assert(e) => self.walkExpr(e),
            ast::Stmt::Match(m) => {
                let subject_types: Vec<PlumType> = m.subjects.iter().map(|s| {
                    self.walkExpr(s);
                    plum_checker::inferExpr(s, &self.env, &self.cctx).unwrap_or(PlumType::TInt)
                }).collect();
                self.match_scratch.insert(m as *const ast::Match as usize, subject_types.clone());
                for case in &m.cases {
                    let saved = self.env.clone();
                    for (pat, subject_ty) in case.patterns.iter().zip(subject_types.iter()) {
                        self.collectPattern(pat, subject_ty);
                    }
                    self.walkBlock(&case.body);
                    self.env = saved;
                }
            }
            ast::Stmt::Break | ast::Stmt::Continue | ast::Stmt::Todo => {}
        }
    }

    fn walkExpr(&mut self, expr: &ast::Expr) {
        match expr {
            ast::Expr::ClassCall(call) => {
                for fa in &call.fields {
                    self.walkExpr(&fa.value);
                }
            }
            ast::Expr::Binary(b) => {
                self.walkExpr(&b.left);
                self.walkExpr(&b.right);
            }
            ast::Expr::Bool(b) => {
                self.walkExpr(&b.left);
                self.walkExpr(&b.right);
            }
            ast::Expr::Compare(c) => {
                self.walkExpr(&c.left);
                self.walkExpr(&c.right);
            }
            ast::Expr::Not(inner) => self.walkExpr(inner),
            ast::Expr::Unary(u) => self.walkExpr(&u.operand),
            ast::Expr::Paren(inner) => self.walkExpr(inner),
            ast::Expr::Ternary(t) => {
                self.walkExpr(&t.condition);
                self.walkExpr(&t.then);
                self.walkExpr(&t.else_);
            }
            ast::Expr::FnCall(call) => {
                for arg in &call.args {
                    self.walkArg(arg);
                }
            }
            ast::Expr::Attribute(a) => {
                self.walkExpr(&a.object);
                if let ast::AttrKind::Method(call) = &a.attr {
                    for arg in &call.args {
                        self.walkArg(arg);
                    }
                }
            }
            ast::Expr::TypeName(_) => {}
            ast::Expr::Int(_)
            | ast::Expr::Float(_)
            | ast::Expr::String(_)
            | ast::Expr::Self_
            | ast::Expr::Var(_) => {}
            // A closure literal's body has its own locals, belonging to the separate
            // closure function it compiles to — nothing to recurse into here.
            ast::Expr::Closure(_) => {}
        }
    }

    fn walkArg(&mut self, arg: &ast::Arg) {
        match arg {
            ast::Arg::Positional(e) => self.walkExpr(e),
            ast::Arg::Keyword { value, .. } => self.walkExpr(value),
            ast::Arg::Pair { value, .. } => self.walkExpr(value),
        }
    }
}

fn compileFnBody(f: &ast::Fn, ctx: &CompileCtx, state: &mut ModuleState) -> Result<Vec<u8>, String> {
    let mut body = Vec::new();

    let mut base_env = ctx.global_env.clone();
    if let Some(recv) = &f.type_param {
        base_env.insert("self".to_string(), TypeScheme::mono(plum_checker::plumTypeFromName(recv)));
    }
    for p in &f.params {
        let ty = match &p.ty {
            ast::ParamType::Type(t) => plum_checker::plumTypeFromAst(t),
            ast::ParamType::Variadic(t) => PlumType::TVariadic(Box::new(plum_checker::plumTypeFromAst(t))),
            ast::ParamType::Fn(params, ret) => {
                let param_types = params.iter().map(plum_checker::plumTypeFromAst).collect();
                let ret_ty = ret.as_ref().map(|r| plum_checker::plumTypeFromAst(r)).unwrap_or(PlumType::TUnit);
                PlumType::TFun(param_types, Box::new(ret_ty))
            }
        };
        base_env.insert(p.name.clone(), TypeScheme::mono(ty));
    }

    let mut collector = Collector {
        env: base_env.clone(),
        cctx: checkCtxOf(&ctx.classes, &ctx.methods, &ctx.enum_variants, &ctx.enum_params),
        named: Vec::new(),
        named_set: Default::default(),
        match_scratch: HashMap::new(),
        nested_class_scratch: HashMap::new(),
        nested_class_scratch_types: Vec::new(),
        next_nested_class_slot: 0,
        variadic_for_scratch: HashMap::new(),
        next_variadic_for_slot: 0,
    };
    if let ast::FnBody::Block(block) = &f.body {
        collector.walkBlock(block);
    } else if let ast::FnBody::Expr(e) = &f.body {
        // An expression-bodied fn can still contain a closure literal (e.g.
        // `main() -> Int = each(|v| v)`), which needs construction scratch slots.
        collector.walkExpr(e);
    }
    // ---- assign local indices: [self?][params][named...][classcall scratch...][match scratch...] ----
    let mut locals: HashMap<String, u32> = HashMap::new();
    let mut groups: Vec<ValType> = Vec::new();
    let mut idx = 0u32;

    if f.type_param.is_some() {
        locals.insert("self".to_string(), idx);
        idx += 1;
    }
    for p in &f.params {
        locals.insert(p.name.clone(), idx);
        idx += 1;
    }
    for (name, ty) in &collector.named {
        let vt = plumTypeToValtype(ty);
        locals.insert(name.clone(), idx);
        groups.push(vt);
        idx += 1;
    }

    let match_scratch_base = idx;
    let mut match_scratch_index: HashMap<usize, u32> = HashMap::new();
    for (ptr, types) in collector.match_scratch.iter() {
        match_scratch_index.insert(*ptr, idx - match_scratch_base);
        for ty in types {
            groups.push(plumTypeToValtype(ty));
            idx += 1;
        }
    }

    let nested_class_scratch_base = idx;
    // Each slot is declared with its OWN concrete variant ref type (not a uniform
    // placeholder) — `struct.get` on a constructor-pattern match requires the local
    // holding the narrowed (`ref.cast`) value to be statically typed as that exact
    // variant, and different slots very likely narrow to different variants.
    for vname in &collector.nested_class_scratch_types {
        let variant_idx = withGcTypes(|r| *r.variant_type_idx.get(vname)
            .unwrap_or_else(|| panic!("internal codegen error: variant '{}' missing from the GC type registry", vname)));
        groups.push(gcRef(variant_idx));
        idx += 1;
    }

    let variadic_for_scratch_base = idx;
    let variadic_for_scratch_count = collector.variadic_for_scratch.values().copied().max().map(|m| m + 1).unwrap_or(0);
    for _ in 0..variadic_for_scratch_count {
        groups.push(ValType::I32); // count
        groups.push(ValType::I32); // loop index
        idx += 2;
    }

    if groups.is_empty() {
        body.push(0);
    } else {
        body.extend(encodeLeb128U32(groups.len() as u32));
        for g in &groups {
            body.extend(encodeLeb128U32(1));
            g.encode(&mut body);
        }
    }

    let local_ctx = LocalCtx {
        locals,
        match_scratch_base,
        match_scratch_index,
        nested_class_scratch_base,
        nested_class_scratch: collector.nested_class_scratch,
        variadic_for_scratch_base,
        variadic_for_scratch: collector.variadic_for_scratch,
        func_ids: &ctx.func_ids,
        func_sigs: &ctx.func_sigs,
        closures: &ctx.closures,
        closure_call_types: &ctx.closure_call_types,
        named_fn_values: &ctx.named_fn_values,
        string_concat_func: ctx.string_concat_func,
        int_to_string_func: ctx.int_to_string_func,
        classes: &ctx.classes,
        methods: &ctx.methods,
        enum_variants: &ctx.enum_variants,
        enum_params: &ctx.enum_params,
        gc_types: &ctx.gc_types,
        singleton_globals: &ctx.singleton_globals,
        type_env: RefCell::new(base_env),
        closure_local_sigs: RefCell::new(HashMap::new()),
    };

    let result_vt = retTypeToWasm(f.returns.as_ref());

    match &f.body {
        ast::FnBody::Expr(e) => {
            compileExpr(e, &mut body, &local_ctx, state)?;
        }
        ast::FnBody::Block(block) => {
            compileBlockAsFnBody(block, &mut body, &local_ctx, state, result_vt)?;
        }
        // `fns` excludes every `extern fun` (no body to compile).
        ast::FnBody::Extern => unreachable!("extern fns are excluded from `fns`"),
    }

    Instruction::End.encode(&mut body);
    Ok(body)
}

fn compileBlock(block: &ast::Block, body: &mut Vec<u8>, ctx: &LocalCtx, state: &mut ModuleState) -> Result<(), String> {
    for stmt in &block.stmts {
        compileStmt(stmt, body, ctx, state)?;
    }
    Ok(())
}

/// Compiles a case/branch body either as an ordinary statement block (`result_vt: None`)
/// or, when in value position, via `compileBlockInValuePosition` so its own tail
/// statement propagates a value instead of being dropped.
fn compileCaseBody(
    block: &ast::Block,
    result_vt: Option<ValType>,
    body: &mut Vec<u8>,
    ctx: &LocalCtx,
    state: &mut ModuleState,
) -> Result<(), String> {
    match result_vt {
        Some(vt) => compileBlockInValuePosition(block, vt, body, ctx, state),
        None => compileBlock(block, body, ctx, state),
    }
}

/// Compiles a block whose value must be produced when control reaches its end — every
/// statement except the last compiles normally; the last is compiled via
/// `compileStmtInValuePosition`.
fn compileBlockInValuePosition(
    block: &ast::Block,
    result_vt: ValType,
    body: &mut Vec<u8>,
    ctx: &LocalCtx,
    state: &mut ModuleState,
) -> Result<(), String> {
    let (last, rest) = block.stmts.split_last().ok_or_else(|| {
        "codegen: function has a control-flow path that doesn't produce a return value (empty branch)".to_string()
    })?;
    for stmt in rest {
        compileStmt(stmt, body, ctx, state)?;
    }
    compileStmtInValuePosition(last, result_vt, body, ctx, state)
}

/// Compiles a single statement in value position: a bare expression is left on the stack
/// (not dropped); `return`/`todo` compile normally (both are stack-polymorphic in wasm —
/// control never falls through past them, so no value is needed on this path); `if`/`match`
/// recurse so every arm/branch resolves the same way. Any other statement kind can't
/// produce a value, so this returns a clear error instead of ever emitting wasm that
/// would fail validation.
fn compileStmtInValuePosition(
    stmt: &ast::Stmt,
    result_vt: ValType,
    body: &mut Vec<u8>,
    ctx: &LocalCtx,
    state: &mut ModuleState,
) -> Result<(), String> {
    match stmt {
        ast::Stmt::Expr(e) => compileExpr(e, body, ctx, state),
        ast::Stmt::Return(_) | ast::Stmt::Todo => compileStmt(stmt, body, ctx, state),
        ast::Stmt::If(if_) => compileIf(if_, Some(result_vt), body, ctx, state),
        ast::Stmt::Match(m) => compileMatch(m, body, ctx, state, Some(result_vt)),
        _ => Err(
            "codegen: function has a control-flow path that doesn't produce a return value".to_string(),
        ),
    }
}

/// Compiles an `if`/`else if`/`else` chain. `result_vt` is `None` for an ordinary statement
/// (each branch is `BlockType::Empty`, nothing left on the stack) or `Some(vt)` when this
/// `if` is in value position — every branch must then leave a `vt` value on the stack, which
/// requires an `else` (a value can't be produced on a path that doesn't exist).
/// Compiles a `Bool`-typed expression, then a `ref.test` against the `True` variant's
/// concrete type, leaving a plain `i32` (1/0) on the stack. Every place a `Bool` value
/// drives wasm's OWN native control flow (`if`/`br_if`, which require a raw `i32`
/// condition, not a `ref`) goes through this — see this migration plan's Decision 1
/// (Bool is a full wasm-gc struct, no special-casing).
fn compileBoolConditionAsI32(expr: &ast::Expr, body: &mut Vec<u8>, ctx: &LocalCtx, state: &mut ModuleState) -> Result<(), String> {
    // `&&`/`||` short-circuit: the right operand must not even be COMPILED (let
    // alone executed) unless the left side's result already needs it — guarding
    // `i < len && s.byteAt(i) != ...` on the length check only works if `byteAt`
    // is never reached once `i < len` is false. Handled here (not just as a
    // generic `Expr::Bool` case in `compileExpr` below) so a boolean used
    // directly as an `if`/`while` condition never pays for constructing a real
    // `Bool` ref just to immediately `ref.test` it back into an `i32`.
    if let ast::Expr::Bool(b) = expr {
        return compileShortCircuitBoolI32(b, body, ctx, state);
    }
    compileExpr(expr, body, ctx, state)?;
    let true_idx = *ctx.gc_types.variant_type_idx.get("True")
        .expect("internal codegen error: True must be registered in the GC type registry");
    Instruction::RefTestNonNull(HeapType::Concrete(true_idx)).encode(body);
    Ok(())
}

/// Leaves a short-circuited `i32` (1/0) on the stack for `b.left op b.right`:
/// `b.right` is compiled inside a wasm `if` guarded by `b.left`'s result, so for
/// `&&` it's skipped entirely once the left side is already false (and for `||`,
/// once the left side is already true) — exactly like every source language's
/// `&&`/`||`, but requiring real branching since wasm has no lazy operand
/// evaluation of its own.
fn compileShortCircuitBoolI32(b: &ast::BoolExpr, body: &mut Vec<u8>, ctx: &LocalCtx, state: &mut ModuleState) -> Result<(), String> {
    compileBoolConditionAsI32(&b.left, body, ctx, state)?;
    Instruction::If(BlockType::Result(ValType::I32)).encode(body);
    match b.op {
        ast::BoolOp::And => compileBoolConditionAsI32(&b.right, body, ctx, state)?,
        ast::BoolOp::Or => Instruction::I32Const(1).encode(body),
    }
    Instruction::Else.encode(body);
    match b.op {
        ast::BoolOp::And => Instruction::I32Const(0).encode(body),
        ast::BoolOp::Or => compileBoolConditionAsI32(&b.right, body, ctx, state)?,
    }
    Instruction::End.encode(body);
    Ok(())
}

/// Given an `i32` boolean (1/0) already on the stack, converts it into a `Bool` ref by
/// selecting the pre-allocated `True`/`False` singleton (this migration plan's
/// Decision 2) — the reverse of `compileBoolConditionAsI32`. Used wherever a native
/// wasm comparison/logical-op instruction just left a raw `i32` predicate on the
/// stack that needs to become a proper `Bool` value.
fn pushBoolRefFromI32Flag(body: &mut Vec<u8>, ctx: &LocalCtx) {
    let bool_ref_ty = plumTypeToValtype(&PlumType::TBool);
    let true_global = *ctx.singleton_globals.get("True").expect("internal codegen error: True singleton global missing");
    let false_global = *ctx.singleton_globals.get("False").expect("internal codegen error: False singleton global missing");
    Instruction::If(BlockType::Result(bool_ref_ty)).encode(body);
    Instruction::GlobalGet(true_global).encode(body);
    Instruction::Else.encode(body);
    Instruction::GlobalGet(false_global).encode(body);
    Instruction::End.encode(body);
}

fn compileIf(
    if_: &ast::If,
    result_vt: Option<ValType>,
    body: &mut Vec<u8>,
    ctx: &LocalCtx,
    state: &mut ModuleState,
) -> Result<(), String> {
    if result_vt.is_some() && if_.else_.is_none() {
        return Err(
            "codegen: function has a control-flow path that doesn't produce a return value (if without else)".to_string(),
        );
    }
    let bt = blockTypeFor(result_vt);
    compileBoolConditionAsI32(&if_.condition, body, ctx, state)?;
    Instruction::If(bt).encode(body);
    compileCaseBody(&if_.body, result_vt, body, ctx, state)?;
    if !if_.else_ifs.is_empty() || if_.else_.is_some() {
        Instruction::Else.encode(body);
        for ei in &if_.else_ifs {
            compileBoolConditionAsI32(&ei.condition, body, ctx, state)?;
            Instruction::If(bt).encode(body);
            compileCaseBody(&ei.body, result_vt, body, ctx, state)?;
            Instruction::Else.encode(body);
        }
        if let Some(else_block) = &if_.else_ {
            compileCaseBody(else_block, result_vt, body, ctx, state)?;
        }
        for _ in &if_.else_ifs {
            Instruction::End.encode(body);
        }
    }
    Instruction::End.encode(body);
    Ok(())
}

/// Compiles a block that is the body of a function. If the function returns a value,
/// its tail statement is compiled in value position (see `compileStmtInValuePosition`)
/// so a bare expression, or an `if`/`match` whose arms resolve to one, propagates that
/// value instead of being dropped.
fn compileBlockAsFnBody(
    block: &ast::Block,
    body: &mut Vec<u8>,
    ctx: &LocalCtx,
    state: &mut ModuleState,
    result_vt: Option<ValType>,
) -> Result<(), String> {
    match result_vt {
        Some(vt) => compileBlockInValuePosition(block, vt, body, ctx, state),
        None => compileBlock(block, body, ctx, state),
    }
}

fn compileStmt(stmt: &ast::Stmt, body: &mut Vec<u8>, ctx: &LocalCtx, state: &mut ModuleState) -> Result<(), String> {
    match stmt {
        ast::Stmt::Assign(a) => {
            for (target, value) in a.targets.iter().zip(a.values.iter()) {
                match target {
                    ast::AssignTarget::Var(name) => {
                        // See the matching comment in `Collector::walkStmt`: the checker's
                        // closure inference is unreliable (can error out entirely depending
                        // on the body), but every closure value is an i32 pointer regardless
                        // of its real signature, so don't bother inferring it at all here.
                        let vty = if matches!(value, ast::Expr::Closure(_)) {
                            PlumType::TFun(Vec::new(), Box::new(PlumType::TUnit))
                        } else {
                            inferLocalType(value, ctx)
                        };
                        compileExpr(value, body, ctx, state)?;
                        let idx = ctx
                            .locals
                            .get(name)
                            .copied()
                            .ok_or_else(|| format!("undeclared local '{}'", name))?;
                        Instruction::LocalSet(idx).encode(body);
                        ctx.type_env.borrow_mut().insert(name.clone(), TypeScheme::mono(vty));
                        // If this assigns a closure *literal*, remember its exact, already-
                        // correct signature (computed by the discovery pass) so a later call
                        // to it doesn't have to re-derive one — see `closure_local_sigs`.
                        if let ast::Expr::Closure(cl) = value {
                            let key = cl.as_ref() as *const ast::Closure as usize;
                            if let Some(info) = ctx.closures.get(&key) {
                                let mut sig_params = vec![ValType::Ref(RefType::ANYREF)];
                                sig_params.extend(info.param_vts.iter().copied());
                                ctx.closure_local_sigs.borrow_mut().insert(name.clone(), (sig_params, info.ret_vt));
                            }
                        }
                    }
                    ast::AssignTarget::Field(object, field_name) => {
                        let obj_ty = inferLocalType(object, ctx);
                        let class_name = match &obj_ty {
                            PlumType::TNamed(n) => n.clone(),
                            other => return Err(format!("codegen: cannot assign field '{}' on non-class type {}", field_name, other)),
                        };
                        let fields = ctx
                            .classes
                            .get(&class_name)
                            .ok_or_else(|| format!("codegen: unknown class '{}'", class_name))?;
                        let field_idx = fields
                            .iter()
                            .position(|(n, _)| n == field_name)
                            .ok_or_else(|| format!("codegen: no field '{}' on class '{}'", field_name, class_name))?;
                        let class_type_idx = *ctx.gc_types.class_type_idx.get(&class_name)
                            .ok_or_else(|| format!("codegen: class '{}' missing from the GC type registry", class_name))?;
                        // struct.set expects [(ref null $t) value] on the stack (ref
                        // pushed first/deeper, value second/on top) — same push order
                        // this already used for the old memory store.
                        compileExpr(object, body, ctx, state)?;
                        compileExpr(value, body, ctx, state)?;
                        Instruction::StructSet { struct_type_index: class_type_idx, field_index: field_idx as u32 }.encode(body);
                    }
                }
            }
        }
        ast::Stmt::Return(Some(e)) => {
            compileExpr(e, body, ctx, state)?;
            Instruction::Return.encode(body);
        }
        ast::Stmt::Return(None) => {
            Instruction::Return.encode(body);
        }
        ast::Stmt::If(if_) => {
            compileIf(if_, None, body, ctx, state)?;
        }
        ast::Stmt::While(w) => {
            Instruction::Block(BlockType::Empty).encode(body);
            Instruction::Loop(BlockType::Empty).encode(body);
            compileBoolConditionAsI32(&w.condition, body, ctx, state)?;
            Instruction::I32Eqz.encode(body);
            Instruction::BrIf(1).encode(body);
            compileBlock(&w.body, body, ctx, state)?;
            Instruction::Br(0).encode(body);
            Instruction::End.encode(body);
            Instruction::End.encode(body);
        }
        ast::Stmt::For(f) => {
            // `for i := range n` (`n: Int`) — Go-1.22-style counting loop over
            // `0..n` (exclusive), with no separate range-literal syntax needed.
            if matches!(inferLocalType(&f.iter, ctx), PlumType::TInt) && f.vars.len() == 1 {
                let var_name = &f.vars[0];
                let var_idx = ctx
                    .locals
                    .get(var_name)
                    .copied()
                    .ok_or_else(|| format!("undeclared loop var '{}'", var_name))?;
                ctx.type_env.borrow_mut().insert(var_name.clone(), TypeScheme::mono(PlumType::TInt));
                Instruction::I64Const(0).encode(body);
                Instruction::LocalSet(var_idx).encode(body);
                Instruction::Block(BlockType::Empty).encode(body);
                Instruction::Loop(BlockType::Empty).encode(body);
                Instruction::LocalGet(var_idx).encode(body);
                compileExpr(&f.iter, body, ctx, state)?;
                Instruction::I64GeS.encode(body);
                Instruction::BrIf(1).encode(body);
                compileBlock(&f.body, body, ctx, state)?;
                Instruction::LocalGet(var_idx).encode(body);
                Instruction::I64Const(1).encode(body);
                Instruction::I64Add.encode(body);
                Instruction::LocalSet(var_idx).encode(body);
                Instruction::Br(0).encode(body);
                Instruction::End.encode(body);
                Instruction::End.encode(body);
                return Ok(());
            }
            if let PlumType::TVariadic(elem_ty) = inferLocalType(&f.iter, ctx) {
                if f.vars.len() != 1 {
                    return Err("codegen: for-loop over a variadic param must bind exactly one variable".to_string());
                }
                let var_name = &f.vars[0];
                let var_idx = ctx
                    .locals
                    .get(var_name)
                    .copied()
                    .ok_or_else(|| format!("undeclared loop var '{}'", var_name))?;
                ctx.type_env.borrow_mut().insert(var_name.clone(), TypeScheme::mono((*elem_ty).clone()));

                let scratch_key = f as *const ast::For as usize;
                let slot = *ctx
                    .variadic_for_scratch
                    .get(&scratch_key)
                    .ok_or_else(|| "internal codegen error: missing variadic-for scratch slot".to_string())?;
                let count_local = ctx.variadic_for_scratch_base + slot * 2;
                let index_local = count_local + 1;
                let elem_vt = plumTypeToValtype(&elem_ty);
                let array_type_idx = *ctx
                    .gc_types
                    .variadic_array_type_idx
                    .get(&elem_vt)
                    .ok_or_else(|| "internal codegen error: no variadic array type registered for this elem type".to_string())?;

                // count_local = array.len(iter)
                compileExpr(&f.iter, body, ctx, state)?;
                Instruction::ArrayLen.encode(body);
                Instruction::LocalSet(count_local).encode(body);

                // index_local = 0
                Instruction::I32Const(0).encode(body);
                Instruction::LocalSet(index_local).encode(body);

                Instruction::Block(BlockType::Empty).encode(body);
                Instruction::Loop(BlockType::Empty).encode(body);
                Instruction::LocalGet(index_local).encode(body);
                Instruction::LocalGet(count_local).encode(body);
                Instruction::I32GeS.encode(body);
                Instruction::BrIf(1).encode(body);

                // var = array.get(iter, index)
                compileExpr(&f.iter, body, ctx, state)?;
                Instruction::LocalGet(index_local).encode(body);
                Instruction::ArrayGet(array_type_idx).encode(body);
                Instruction::LocalSet(var_idx).encode(body);

                compileBlock(&f.body, body, ctx, state)?;

                Instruction::LocalGet(index_local).encode(body);
                Instruction::I32Const(1).encode(body);
                Instruction::I32Add.encode(body);
                Instruction::LocalSet(index_local).encode(body);
                Instruction::Br(0).encode(body);
                Instruction::End.encode(body);
                Instruction::End.encode(body);
                return Ok(());
            }
            compileExpr(&f.iter, body, ctx, state)?;
            Instruction::Drop.encode(body);
        }
        ast::Stmt::Expr(e) => {
            let has_result = exprHasResult(e, ctx);
            compileExpr(e, body, ctx, state)?;
            if has_result {
                Instruction::Drop.encode(body);
            }
        }
        ast::Stmt::Break => {
            Instruction::Br(1).encode(body);
        }
        ast::Stmt::Continue => {
            Instruction::Br(0).encode(body);
        }
        ast::Stmt::Match(m) => {
            compileMatch(m, body, ctx, state, None)?;
        }
        ast::Stmt::Assert(e) => {
            compileBoolConditionAsI32(e, body, ctx, state)?;
            Instruction::I32Eqz.encode(body);
            Instruction::If(BlockType::Empty).encode(body);
            Instruction::Unreachable.encode(body);
            Instruction::End.encode(body);
        }
        ast::Stmt::Todo => {
            // Marks an unimplemented body — trap rather than silently continuing.
            Instruction::Unreachable.encode(body);
        }
    }
    Ok(())
}

/// Returns true if the expression leaves a value on the wasm stack.
fn exprHasResult(expr: &ast::Expr, ctx: &LocalCtx) -> bool {
    match expr {
        ast::Expr::FnCall(call) => {
            if ctx.locals.contains_key(&call.name) {
                if let PlumType::TFun(_, ret) = inferLocalType(&ast::Expr::Var(call.name.clone()), ctx) {
                    return !matches!(*ret, PlumType::TUnit);
                }
            }
            ctx.func_sigs.get(&call.name).map(|s| s.ret.is_some()).unwrap_or(true)
        }
        ast::Expr::Attribute(attr) => match &attr.attr {
            ast::AttrKind::Method(call) => {
                // `methodReceiverName` (not a bare `TNamed` match) so this also
                // covers a Unit-returning method called in statement position on
                // a BUILTIN primitive receiver (`Int`/`Float`/`Bool`/`Str`/`Byte`/
                // `[]Byte`) — e.g. `self.data.set(...)` on a `[]Byte` field — not
                // just an ordinary class. Without this, such a call was wrongly
                // assumed to leave a value on the stack, emitting a `Drop` with
                // nothing to drop.
                if let Some(class_name) = plum_checker::methodReceiverName(&inferLocalType(&attr.object, ctx)) {
                    let key = format!("{}::{}", class_name, call.name);
                    ctx.func_sigs.get(&key).map(|s| s.ret.is_some()).unwrap_or(true)
                } else {
                    true
                }
            }
            ast::AttrKind::Field(_) => true,
        },
        _ => true,
    }
}

/// True if `cases` consists solely of enum-tag patterns (bare variant names or
/// constructor patterns, no wildcard/binding/int/etc.) that between them cover every
/// variant of a single enum type. When that holds, a match compiled in value position
/// can never actually fall through past the last arm at runtime — even though the
/// patterns don't include an explicit wildcard/binding catch-all — so the "ran out of
/// patterns" fallback in `compile_match_arms` is provably unreachable code, not a real
/// gap. `compileMatch` uses this to append a synthetic trap-and-never-fall-through
/// wildcard arm (rather than let the arms recursion hit its non-exhaustive-match error)
/// so previously-working exhaustive enum matches (e.g. `Some`/`None`, `True`/`False`)
/// keep compiling even without a trailing wildcard, while a genuinely non-exhaustive
/// match (an `Int` match, or an enum match missing a variant) still gets a clear error.
/// True if `cases` already covers every combination of enum variants across all
/// subject positions (by explicit tag/constructor patterns only — no binding or
/// wildcard in any position), i.e. the match is exhaustive at runtime even though
/// `compileMatchArmsMulti` can't see that from the remaining-cases slice alone.
/// For a single subject this is "every variant of its enum is named somewhere";
/// for `match a, b, ...` it's the full cross product (e.g. `Bool, Bool` needs all
/// 4 combinations named, matching `libs/std/bool.plum`'s `and`/`or`).
fn matchCoversEveryEnumVariant(cases: &[ast::Case], subject_vts: &[ValType], ctx: &LocalCtx) -> bool {
    if subject_vts.is_empty() || subject_vts.iter().any(|vt| !matches!(vt, ValType::Ref(_))) {
        return false;
    }
    let n = subject_vts.len();
    let mut enum_names: Vec<Option<String>> = vec![None; n];
    let mut tuples_seen: std::collections::BTreeSet<Vec<i32>> = std::collections::BTreeSet::new();
    for case in cases {
        if case.patterns.len() != n {
            return false;
        }
        let mut tuple = Vec::with_capacity(n);
        for (i, pat) in case.patterns.iter().enumerate() {
            let variant_name = match pat {
                ast::CasePattern::Name(nm)
                    if nm.chars().next().map(|c| c.is_uppercase()).unwrap_or(false) && ctx.enum_variants.contains_key(nm) =>
                {
                    nm.as_str()
                }
                ast::CasePattern::Class { name, .. } => name.as_str(),
                // A binding or wildcard at any position could match variants we
                // haven't otherwise named, so we can't prove full coverage this way.
                _ => return false,
            };
            let info = match ctx.enum_variants.get(variant_name) {
                Some(info) => info,
                None => return false,
            };
            match &enum_names[i] {
                Some(en) if en != &info.enum_name => return false,
                Some(_) => {}
                None => enum_names[i] = Some(info.enum_name.clone()),
            }
            tuple.push(info.tag);
        }
        tuples_seen.insert(tuple);
    }
    let mut total_combinations: usize = 1;
    for en in &enum_names {
        match en {
            Some(name) => {
                let count = ctx.enum_variants.values().filter(|v| &v.enum_name == name).count();
                total_combinations = match total_combinations.checked_mul(count) {
                    Some(t) => t,
                    None => return false,
                };
            }
            None => return false,
        }
    }
    !tuples_seen.is_empty() && tuples_seen.len() == total_combinations
}

fn compileMatch(
    m: &ast::Match,
    body: &mut Vec<u8>,
    ctx: &LocalCtx,
    state: &mut ModuleState,
    result_vt: Option<ValType>,
) -> Result<(), String> {
    let key = m as *const ast::Match as usize;
    let base_slot = *ctx
        .match_scratch_index
        .get(&key)
        .ok_or_else(|| "internal codegen error: missing match scratch slot".to_string())?;

    // Evaluate every subject into its own consecutive scratch local (one per
    // subject, in `match a, b, ...` order) before checking any pattern.
    let mut all_subjects: Vec<(ValType, u32)> = Vec::with_capacity(m.subjects.len());
    for (i, subject) in m.subjects.iter().enumerate() {
        let subject_ty = inferLocalType(subject, ctx);
        let subject_vt = plumTypeToValtype(&subject_ty);
        let scratch_local = ctx.match_scratch_base + base_slot + i as u32;
        compileExpr(subject, body, ctx, state)?;
        Instruction::LocalSet(scratch_local).encode(body);
        all_subjects.push((subject_vt, scratch_local));
    }

    // A match in value position whose arms already cover every combination of
    // enum variants across all subjects (by explicit tag/constructor patterns, no
    // wildcard) is exhaustive at runtime even though `compileMatchArmsMulti` can't
    // see that from the remaining-cases slice alone. `exhaustive_fallback` tells it to
    // compile the "ran out of cases" path as an (unreachable, but valid) trap instead
    // of a spurious non-exhaustive-match error — threaded through as a flag, rather
    // than appending a synthetic wildcard case by cloning `m.cases`, because cloning
    // would reallocate every nested `CasePattern::Class` node at a new address and
    // break `nested_class_scratch`'s pointer-identity-keyed lookup.
    let subject_vts: Vec<ValType> = all_subjects.iter().map(|(vt, _)| *vt).collect();
    let exhaustive_fallback = result_vt.is_some() && matchCoversEveryEnumVariant(&m.cases, &subject_vts, ctx);

    compileMatchArmsMulti(&m.cases, &all_subjects, result_vt, exhaustive_fallback, body, ctx, state)
}

/// Tries each case in turn (in source order); a case that fails to match falls
/// through to the next one. `all_subjects` is the full `(valtype, scratch_local)`
/// list for every subject of the enclosing `match`, shared unchanged across every
/// case (each case's own pattern list is checked position-by-position against it
/// via `compileCasePositions`). `exhaustive_fallback` (see `compileMatch`) says
/// what to do once `cases` runs out: trap (proven exhaustive) or report a
/// non-exhaustive-match error.
fn compileMatchArmsMulti(
    cases: &[ast::Case],
    all_subjects: &[(ValType, u32)],
    result_vt: Option<ValType>,
    exhaustive_fallback: bool,
    body: &mut Vec<u8>,
    ctx: &LocalCtx,
    state: &mut ModuleState,
) -> Result<(), String> {
    let (case, rest) = match cases.split_first() {
        None => {
            return match result_vt {
                Some(_) if exhaustive_fallback => {
                    Instruction::Unreachable.encode(body);
                    Ok(())
                }
                Some(_) => Err(
                    "codegen: function has a control-flow path that doesn't produce a return value (non-exhaustive match)".to_string(),
                ),
                None => Ok(()),
            };
        }
        Some(pair) => pair,
    };
    if case.patterns.len() != all_subjects.len() {
        return Err(format!(
            "codegen: match case has {} pattern(s), expected {} (one per subject)",
            case.patterns.len(), all_subjects.len()
        ));
    }
    compileCasePositions(case, 0, all_subjects, rest, result_vt, exhaustive_fallback, body, ctx, state)
}

/// Checks `case.patterns[pos]` against `all_subjects[pos]`; on success, recurses to
/// `pos + 1` (or, once every position has matched, compiles the case body). On
/// failure at any position, falls through to `compileMatchArmsMulti(rest, ...)`
/// — i.e. the *entire next case*, restarting from its own position 0, not the next
/// position of this case. This is what gives `match a, b` its "all positions must
/// match" (AND) semantics while still trying cases in order.
#[allow(clippy::too_many_arguments)]
fn compileCasePositions(
    case: &ast::Case,
    pos: usize,
    all_subjects: &[(ValType, u32)],
    rest: &[ast::Case],
    result_vt: Option<ValType>,
    exhaustive_fallback: bool,
    body: &mut Vec<u8>,
    ctx: &LocalCtx,
    state: &mut ModuleState,
) -> Result<(), String> {
    if pos == case.patterns.len() {
        // Every position matched.
        return compileCaseBody(&case.body, result_vt, body, ctx, state);
    }
    let pat = &case.patterns[pos];
    let (subject_vt, scratch_local) = all_subjects[pos];
    match pat {
        ast::CasePattern::Wildcard => {
            // Always matches this position; move on to the next one.
            compileCasePositions(case, pos + 1, all_subjects, rest, result_vt, exhaustive_fallback, body, ctx, state)
        }
        ast::CasePattern::Name(n) => {
            let is_variant = n.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
                && ctx.enum_variants.contains_key(n);
            if is_variant {
                compileVariantEqArm(n, subject_vt, scratch_local, case, pos, all_subjects, rest, result_vt, exhaustive_fallback, body, ctx, state)
            } else {
                let idx = ctx
                    .locals
                    .get(n)
                    .copied()
                    .ok_or_else(|| format!("internal codegen error: missing binding local '{}'", n))?;
                Instruction::LocalGet(scratch_local).encode(body);
                Instruction::LocalSet(idx).encode(body);
                ctx.type_env.borrow_mut().insert(n.clone(), TypeScheme::mono(plumTypeFromValtypeHint(subject_vt)));
                // A binding always matches this position; move on to the next one.
                compileCasePositions(case, pos + 1, all_subjects, rest, result_vt, exhaustive_fallback, body, ctx, state)
            }
        }
        ast::CasePattern::Int(n) => {
            if subject_vt != ValType::I64 {
                return Err("codegen: integer match pattern against a non-Int subject".to_string());
            }
            Instruction::LocalGet(scratch_local).encode(body);
            Instruction::I64Const(*n).encode(body);
            Instruction::I64Eq.encode(body);
            Instruction::If(blockTypeFor(result_vt)).encode(body);
            compileCasePositions(case, pos + 1, all_subjects, rest, result_vt, exhaustive_fallback, body, ctx, state)?;
            Instruction::Else.encode(body);
            compileMatchArmsMulti(rest, all_subjects, result_vt, exhaustive_fallback, body, ctx, state)?;
            Instruction::End.encode(body);
            Ok(())
        }
        ast::CasePattern::String(_) => Err("codegen: string match patterns are not yet supported".to_string()),
        ast::CasePattern::Float(_) => Err("codegen: float match patterns are not yet supported".to_string()),
        ast::CasePattern::Class { name, fields } => {
            compileVariantConstructorArm(pat, name, fields, subject_vt, scratch_local, case, pos, all_subjects, rest, result_vt, exhaustive_fallback, body, ctx, state)
        }
    }
}

#[allow(clippy::too_many_arguments)]
fn compileVariantEqArm(
    name: &str,
    subject_vt: ValType,
    scratch_local: u32,
    case: &ast::Case,
    pos: usize,
    all_subjects: &[(ValType, u32)],
    rest: &[ast::Case],
    result_vt: Option<ValType>,
    exhaustive_fallback: bool,
    body: &mut Vec<u8>,
    ctx: &LocalCtx,
    state: &mut ModuleState,
) -> Result<(), String> {
    ctx.enum_variants
        .get(name)
        .ok_or_else(|| format!("codegen: unknown enum variant '{}'", name))?;
    if !matches!(subject_vt, ValType::Ref(_)) {
        return Err(format!("codegen: enum tag pattern '{}' against a non-enum subject", name));
    }
    let variant_idx = *ctx.gc_types.variant_type_idx.get(name)
        .ok_or_else(|| format!("codegen: variant '{}' missing from the GC type registry", name))?;
    // A single ref.test against the variant's exact concrete type replaces the old
    // "range-check against HEAP_BASE, then conditionally load+compare a tag" dance —
    // there's no tag to load at all anymore, the type itself IS the discriminant.
    Instruction::LocalGet(scratch_local).encode(body);
    Instruction::RefTestNonNull(HeapType::Concrete(variant_idx)).encode(body);
    Instruction::If(blockTypeFor(result_vt)).encode(body);
    compileCasePositions(case, pos + 1, all_subjects, rest, result_vt, exhaustive_fallback, body, ctx, state)?;
    Instruction::Else.encode(body);
    compileMatchArmsMulti(rest, all_subjects, result_vt, exhaustive_fallback, body, ctx, state)?;
    Instruction::End.encode(body);
    Ok(())
}

#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments)]
fn compileVariantConstructorArm(
    pat: &ast::CasePattern,
    name: &str,
    fields: &[ast::CasePattern],
    subject_vt: ValType,
    scratch_local: u32,
    case: &ast::Case,
    pos: usize,
    all_subjects: &[(ValType, u32)],
    rest: &[ast::Case],
    result_vt: Option<ValType>,
    exhaustive_fallback: bool,
    body: &mut Vec<u8>,
    ctx: &LocalCtx,
    state: &mut ModuleState,
) -> Result<(), String> {
    let info = ctx
        .enum_variants
        .get(name)
        .ok_or_else(|| format!("codegen: unknown enum variant '{}'", name))?;
    if !matches!(subject_vt, ValType::Ref(_)) {
        return Err(format!("codegen: constructor pattern '{}' against a non-enum subject", name));
    }
    if fields.len() != info.field_types.len() {
        return Err(format!(
            "codegen: constructor pattern '{}' expects {} field(s), got {}",
            name, info.field_types.len(), fields.len()
        ));
    }
    let variant_idx = *ctx.gc_types.variant_type_idx.get(name)
        .ok_or_else(|| format!("codegen: variant '{}' missing from the GC type registry", name))?;
    let field_types = info.field_types.clone();

    // This pattern's own narrowly-typed scratch local (declared with variant's exact
    // concrete ref type — see `nested_class_scratch_types`), distinct from
    // `scratch_local` (which stays declared at the subject's wide supertype type for
    // the whole function). `struct.get` on the fields below requires this narrowed
    // static type; the old bump-allocator version needed no such narrowing since
    // every heap reference was a uniformly-typed, untyped-at-the-wasm-level i32.
    let narrow_key = pat as *const ast::CasePattern as usize;
    let narrow_slot = *ctx.nested_class_scratch.get(&narrow_key)
        .ok_or_else(|| "internal codegen error: missing constructor-pattern scratch slot".to_string())?;
    let narrow_local = ctx.nested_class_scratch_base + narrow_slot;

    // A single ref.test against the variant's exact concrete type replaces the old
    // "range-check against HEAP_BASE, then conditionally load+compare a tag" dance.
    Instruction::LocalGet(scratch_local).encode(body);
    Instruction::RefTestNonNull(HeapType::Concrete(variant_idx)).encode(body);
    Instruction::If(blockTypeFor(result_vt)).encode(body);
    // Narrow the subject down to this variant's concrete type before destructuring
    // its fields — valid here specifically because the ref.test just above proved it.
    Instruction::LocalGet(scratch_local).encode(body);
    Instruction::RefCastNonNull(HeapType::Concrete(variant_idx)).encode(body);
    Instruction::LocalSet(narrow_local).encode(body);
    compileFieldPatterns(
        fields, &field_types, 0, narrow_local, variant_idx, rest, all_subjects, result_vt, exhaustive_fallback, body, ctx, state,
        &mut |body, state| compileCasePositions(case, pos + 1, all_subjects, rest, result_vt, exhaustive_fallback, body, ctx, state),
    )?;
    Instruction::Else.encode(body);
    compileMatchArmsMulti(rest, all_subjects, result_vt, exhaustive_fallback, body, ctx, state)?;
    Instruction::End.encode(body);
    Ok(())
}

/// Checks `fields[fpos..]` (a constructor pattern's own sub-patterns, e.g. the `v` in
/// `Some(v)`, or — recursively — the `Some(v)` in `Wrap(Some(v))`) against the
/// already-loaded value in `container_local`, one field at a time. Once every field
/// has matched, calls `on_match` (typically: proceed to the next top-level subject
/// position). A mismatch at any field — at any nesting depth — falls through to
/// `compileMatchArmsMulti(rest, ...)`, exactly like a top-level pattern mismatch.
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments)]
fn compileFieldPatterns(
    fields: &[ast::CasePattern],
    field_types: &[PlumType],
    fpos: usize,
    container_local: u32,
    container_type_idx: u32,
    rest: &[ast::Case],
    all_subjects: &[(ValType, u32)],
    result_vt: Option<ValType>,
    exhaustive_fallback: bool,
    body: &mut Vec<u8>,
    ctx: &LocalCtx,
    state: &mut ModuleState,
    on_match: &mut dyn FnMut(&mut Vec<u8>, &mut ModuleState) -> Result<(), String>,
) -> Result<(), String> {
    if fpos == fields.len() {
        return on_match(body, state);
    }
    let pat = &fields[fpos];
    let field_ty = &field_types[fpos];

    match pat {
        ast::CasePattern::Wildcard => {
            compileFieldPatterns(fields, field_types, fpos + 1, container_local, container_type_idx, rest, all_subjects, result_vt, exhaustive_fallback, body, ctx, state, on_match)
        }
        ast::CasePattern::Name(n) if !(n.chars().next().map(|c| c.is_uppercase()).unwrap_or(false) && ctx.enum_variants.contains_key(n)) => {
            // A plain binding always matches this field; load it straight into its
            // binding local and move on to the next field.
            let idx = ctx
                .locals
                .get(n)
                .copied()
                .ok_or_else(|| format!("internal codegen error: missing binding local '{}'", n))?;
            Instruction::LocalGet(container_local).encode(body);
            Instruction::StructGet { struct_type_index: container_type_idx, field_index: fpos as u32 }.encode(body);
            Instruction::LocalSet(idx).encode(body);
            ctx.type_env.borrow_mut().insert(n.to_string(), TypeScheme::mono(field_ty.clone()));
            compileFieldPatterns(fields, field_types, fpos + 1, container_local, container_type_idx, rest, all_subjects, result_vt, exhaustive_fallback, body, ctx, state, on_match)
        }
        ast::CasePattern::Name(n) => {
            // An uppercase, payload-free variant name used as a field pattern (e.g.
            // matching a nested `None` rather than binding a name to it).
            ctx.enum_variants
                .get(n)
                .ok_or_else(|| format!("codegen: unknown enum variant '{}'", n))?;
            if !matches!(plumTypeToValtype(field_ty), ValType::Ref(_)) {
                return Err(format!("codegen: enum tag pattern '{}' against a non-enum field", n));
            }
            let variant_idx = *ctx.gc_types.variant_type_idx.get(n)
                .ok_or_else(|| format!("codegen: variant '{}' missing from the GC type registry", n))?;
            Instruction::LocalGet(container_local).encode(body);
            Instruction::StructGet { struct_type_index: container_type_idx, field_index: fpos as u32 }.encode(body);
            Instruction::RefTestNonNull(HeapType::Concrete(variant_idx)).encode(body);
            Instruction::If(blockTypeFor(result_vt)).encode(body);
            compileFieldPatterns(fields, field_types, fpos + 1, container_local, container_type_idx, rest, all_subjects, result_vt, exhaustive_fallback, body, ctx, state, on_match)?;
            Instruction::Else.encode(body);
            compileMatchArmsMulti(rest, all_subjects, result_vt, exhaustive_fallback, body, ctx, state)?;
            Instruction::End.encode(body);
            Ok(())
        }
        ast::CasePattern::Int(n) => {
            if plumTypeToValtype(field_ty) != ValType::I64 {
                return Err("codegen: integer match pattern against a non-Int field".to_string());
            }
            Instruction::LocalGet(container_local).encode(body);
            Instruction::StructGet { struct_type_index: container_type_idx, field_index: fpos as u32 }.encode(body);
            Instruction::I64Const(*n).encode(body);
            Instruction::I64Eq.encode(body);
            Instruction::If(blockTypeFor(result_vt)).encode(body);
            compileFieldPatterns(fields, field_types, fpos + 1, container_local, container_type_idx, rest, all_subjects, result_vt, exhaustive_fallback, body, ctx, state, on_match)?;
            Instruction::Else.encode(body);
            compileMatchArmsMulti(rest, all_subjects, result_vt, exhaustive_fallback, body, ctx, state)?;
            Instruction::End.encode(body);
            Ok(())
        }
        ast::CasePattern::String(_) => Err("codegen: string match patterns are not yet supported".to_string()),
        ast::CasePattern::Float(_) => Err("codegen: float match patterns are not yet supported".to_string()),
        ast::CasePattern::Class { name, fields: inner_fields } => {
            let info = ctx
                .enum_variants
                .get(name)
                .ok_or_else(|| format!("codegen: unknown enum variant '{}'", name))?;
            if !matches!(plumTypeToValtype(field_ty), ValType::Ref(_)) {
                return Err(format!("codegen: constructor pattern '{}' against a non-enum field", name));
            }
            if inner_fields.len() != info.field_types.len() {
                return Err(format!(
                    "codegen: constructor pattern '{}' expects {} field(s), got {}",
                    name, info.field_types.len(), inner_fields.len()
                ));
            }
            let inner_variant_idx = *ctx.gc_types.variant_type_idx.get(name)
                .ok_or_else(|| format!("codegen: variant '{}' missing from the GC type registry", name))?;
            let key = pat as *const ast::CasePattern as usize;
            let slot = *ctx
                .nested_class_scratch
                .get(&key)
                .ok_or_else(|| "internal codegen error: missing nested constructor pattern scratch slot".to_string())?;
            let nested_local = ctx.nested_class_scratch_base + slot;
            let inner_field_types = info.field_types.clone();

            Instruction::LocalGet(container_local).encode(body);
            Instruction::StructGet { struct_type_index: container_type_idx, field_index: fpos as u32 }.encode(body);
            Instruction::RefTestNonNull(HeapType::Concrete(inner_variant_idx)).encode(body);
            Instruction::If(blockTypeFor(result_vt)).encode(body);
            // Narrow into `nested_local` now that ref.test just above proved it's safe.
            Instruction::LocalGet(container_local).encode(body);
            Instruction::StructGet { struct_type_index: container_type_idx, field_index: fpos as u32 }.encode(body);
            Instruction::RefCastNonNull(HeapType::Concrete(inner_variant_idx)).encode(body);
            Instruction::LocalSet(nested_local).encode(body);
            compileFieldPatterns(inner_fields, &inner_field_types, 0, nested_local, inner_variant_idx, rest, all_subjects, result_vt, exhaustive_fallback, body, ctx, state, &mut |body, state| {
                compileFieldPatterns(fields, field_types, fpos + 1, container_local, container_type_idx, rest, all_subjects, result_vt, exhaustive_fallback, body, ctx, state, on_match)
            })?;
            Instruction::Else.encode(body);
            compileMatchArmsMulti(rest, all_subjects, result_vt, exhaustive_fallback, body, ctx, state)?;
            Instruction::End.encode(body);
            Ok(())
        }
    }
}

fn plumTypeFromValtypeHint(vt: ValType) -> PlumType {
    match vt {
        ValType::I64 => PlumType::TInt,
        ValType::F64 => PlumType::TFloat,
        _ => PlumType::TVar("_".to_string()),
    }
}

fn compileExpr(expr: &ast::Expr, body: &mut Vec<u8>, ctx: &LocalCtx, state: &mut ModuleState) -> Result<(), String> {
    match expr {
        ast::Expr::Int(n) => {
            Instruction::I64Const(*n).encode(body);
        }
        ast::Expr::Float(f) => {
            Instruction::F64Const(*f).encode(body);
        }
        ast::Expr::Var(name) => {
            match ctx.locals.get(name.as_str()) {
                Some(idx) => Instruction::LocalGet(*idx).encode(body),
                // Not a local: a bare reference to a top-level function used as a
                // value (e.g. `each(double)`) — push its zero-capture trampoline
                // closure's (pre-built by the `start` function) global.
                None => {
                    let global_idx = *ctx
                        .named_fn_values
                        .get(name.as_str())
                        .ok_or_else(|| format!("undeclared variable '{}'", name))?;
                    Instruction::GlobalGet(global_idx).encode(body);
                }
            }
        }
        ast::Expr::Paren(inner) => {
            compileExpr(inner, body, ctx, state)?;
        }
        ast::Expr::Unary(u) => match u.op {
            ast::UnOp::Neg => {
                if matches!(inferLocalType(&u.operand, ctx), PlumType::TFloat) {
                    compileExpr(&u.operand, body, ctx, state)?;
                    Instruction::F64Neg.encode(body);
                } else {
                    // WASM has no i64.neg; use 0 - operand.
                    Instruction::I64Const(0).encode(body);
                    compileExpr(&u.operand, body, ctx, state)?;
                    Instruction::I64Sub.encode(body);
                }
            }
            ast::UnOp::Pos => {
                compileExpr(&u.operand, body, ctx, state)?;
            }
        },
        ast::Expr::Binary(b) => {
            let left_ty = inferLocalType(&b.left, ctx);
            let is_float = matches!(left_ty, PlumType::TFloat);
            let is_str = matches!(left_ty, PlumType::TStr);
            compileExpr(&b.left, body, ctx, state)?;
            compileExpr(&b.right, body, ctx, state)?;
            match b.op {
                // `Str + Str` (e.g. `libs/std/str.plum`'s `concat`) allocates a new
                // array holding both operands' bytes via the same runtime helper
                // string interpolation uses — there's no native wasm "add" for a
                // GC ref.
                ast::BinOp::Add if is_str => Instruction::Call(ctx.string_concat_func).encode(body),
                ast::BinOp::Add => if is_float { Instruction::F64Add } else { Instruction::I64Add }.encode(body),
                ast::BinOp::Sub => if is_float { Instruction::F64Sub } else { Instruction::I64Sub }.encode(body),
                ast::BinOp::Mul => if is_float { Instruction::F64Mul } else { Instruction::I64Mul }.encode(body),
                ast::BinOp::Div => if is_float { Instruction::F64Div } else { Instruction::I64DivS }.encode(body),
                ast::BinOp::Mod => Instruction::I64RemS.encode(body),
                ast::BinOp::BitOr => Instruction::I64Or.encode(body),
                ast::BinOp::BitAnd => Instruction::I64And.encode(body),
                ast::BinOp::Xor => Instruction::I64Xor.encode(body),
                ast::BinOp::Shl => Instruction::I64Shl.encode(body),
                ast::BinOp::Shr => Instruction::I64ShrS.encode(body),
            }
        }
        ast::Expr::Bool(b) => {
            compileShortCircuitBoolI32(b, body, ctx, state)?;
            pushBoolRefFromI32Flag(body, ctx);
        }
        ast::Expr::Not(inner) => {
            compileBoolConditionAsI32(inner, body, ctx, state)?;
            Instruction::I32Eqz.encode(body);
            pushBoolRefFromI32Flag(body, ctx);
        }
        ast::Expr::Compare(c) => {
            let left_ty = inferLocalType(&c.left, ctx);
            compileExpr(&c.left, body, ctx, state)?;
            compileExpr(&c.right, body, ctx, state)?;
            match left_ty {
                PlumType::TFloat => {
                    match c.op {
                        ast::CmpOp::Lt => Instruction::F64Lt,
                        ast::CmpOp::Lte => Instruction::F64Le,
                        ast::CmpOp::Eq => Instruction::F64Eq,
                        ast::CmpOp::Neq | ast::CmpOp::NotEq2 => Instruction::F64Ne,
                        ast::CmpOp::Gte => Instruction::F64Ge,
                        ast::CmpOp::Gt => Instruction::F64Gt,
                    }
                    .encode(body);
                }
                // `TVar`/`TUnit` share `Int`'s `i64` wasm representation (see
                // `plumTypeToValtype`) — an unresolved generic defaults the same way.
                PlumType::TInt | PlumType::TVar(_) | PlumType::TUnit => {
                    match c.op {
                        ast::CmpOp::Lt => Instruction::I64LtS,
                        ast::CmpOp::Lte => Instruction::I64LeS,
                        ast::CmpOp::Eq => Instruction::I64Eq,
                        ast::CmpOp::Neq | ast::CmpOp::NotEq2 => Instruction::I64Ne,
                        ast::CmpOp::Gte => Instruction::I64GeS,
                        ast::CmpOp::Gt => Instruction::I64GtS,
                    }
                    .encode(body);
                }
                // Bool/Str/class/enum values are wasm-gc refs — `==`/`!=` compares
                // reference identity via `ref.eq`. That's exactly right for a
                // payload-free singleton (`None`/`True`/`False`, this migration
                // plan's Decision 2) and for class-instance identity; it's NOT a
                // deep/structural comparison (two distinct `Str` values holding
                // equal text compare unequal) — the same caveat this codegen
                // already had pre-wasm-gc, when it was an i32 POINTER comparison.
                // Ordering a ref type has no meaning and was never valid.
                _ => match &c.op {
                    ast::CmpOp::Eq => Instruction::RefEq.encode(body),
                    ast::CmpOp::Neq | ast::CmpOp::NotEq2 => {
                        Instruction::RefEq.encode(body);
                        Instruction::I32Eqz.encode(body);
                    }
                    other => return Err(format!("codegen: '{:?}' is not supported between reference-typed values", other)),
                },
            }
            pushBoolRefFromI32Flag(body, ctx);
        }
        ast::Expr::Ternary(t) => {
            let result_vt = plumTypeToValtype(&inferLocalType(&t.then, ctx));
            compileBoolConditionAsI32(&t.condition, body, ctx, state)?;
            Instruction::If(BlockType::Result(result_vt)).encode(body);
            compileExpr(&t.then, body, ctx, state)?;
            Instruction::Else.encode(body);
            compileExpr(&t.else_, body, ctx, state)?;
            Instruction::End.encode(body);
        }
        ast::Expr::FnCall(call) => {
            // A call whose callee name is a *local* of function type is a closure call,
            // dispatched via `call_indirect` — not a direct `Call` to a named function.
            let is_closure_call = ctx.locals.contains_key(&call.name)
                && matches!(inferLocalType(&ast::Expr::Var(call.name.clone()), ctx), PlumType::TFun(_, _));
            if (call.name == "Int" || call.name == "Float" || call.name == "Byte") && call.args.len() == 1 && !ctx.func_ids.contains_key(&call.name) {
                let arg_expr = match &call.args[0] {
                    ast::Arg::Positional(e) => e,
                    ast::Arg::Keyword { value, .. } => value,
                    ast::Arg::Pair { value, .. } => value,
                };
                let arg_ty = inferLocalType(arg_expr, ctx);
                compileExpr(arg_expr, body, ctx, state)?;
                match (call.name.as_str(), &arg_ty) {
                    ("Float", PlumType::TInt) => Instruction::F64ConvertI64S.encode(body),
                    ("Int", PlumType::TFloat) => Instruction::I64TruncSatF64S.encode(body),
                    // `Byte(intExpr)` truncates to the low 32 bits then masks to a
                    // single byte (0-255) — an `Int` outside that range wraps, matching
                    // Go's `byte(x)` conversion semantics rather than trapping.
                    ("Byte", PlumType::TInt) => {
                        Instruction::I32WrapI64.encode(body);
                        Instruction::I32Const(0xFF).encode(body);
                        Instruction::I32And.encode(body);
                    }
                    ("Int", PlumType::TByte) => Instruction::I64ExtendI32U.encode(body),
                    // Same-type conversion (`Int(intExpr)`/`Float(floatExpr)`/`Byte(byteExpr)`) is a no-op.
                    _ => {}
                }
            } else if is_closure_call {
                compileClosureCall(call, body, ctx, state)?;
            } else if let Some(info) = ctx.enum_variants.get(&call.name) {
                compileVariantConstruction(info, call, expr, body, ctx, state)?;
            } else {
                fn argExprOf(arg: &ast::Arg) -> &ast::Expr {
                    match arg {
                        ast::Arg::Positional(e) => e,
                        ast::Arg::Keyword { value, .. } => value,
                        ast::Arg::Pair { value, .. } => value,
                    }
                }
                let callee_sig = inferLocalType(&ast::Expr::Var(call.name.clone()), ctx);
                let variadic_split = match &callee_sig {
                    PlumType::TFun(params, _) => match params.last() {
                        Some(PlumType::TVariadic(elem)) => Some(((**elem).clone(), params.len() - 1)),
                        _ => None,
                    },
                    _ => None,
                };
                match variadic_split {
                    Some((elem_ty, fixed_count)) => {
                        for arg in call.args.iter().take(fixed_count) {
                            compileExpr(argExprOf(arg), body, ctx, state)?;
                        }
                        let trailing: Vec<&ast::Expr> = call.args.iter().skip(fixed_count).map(argExprOf).collect();
                        let elem_vt = plumTypeToValtype(&elem_ty);
                        let array_type_idx = *ctx
                            .gc_types
                            .variadic_array_type_idx
                            .get(&elem_vt)
                            .ok_or_else(|| "internal codegen error: no variadic array type registered for this elem type".to_string())?;
                        for arg_expr in &trailing {
                            compileExpr(arg_expr, body, ctx, state)?;
                        }
                        Instruction::ArrayNewFixed { array_type_index: array_type_idx, array_size: trailing.len() as u32 }.encode(body);

                        let func_idx = ctx
                            .func_ids
                            .get(&call.name)
                            .ok_or_else(|| format!("unknown function '{}'", call.name))?;
                        Instruction::Call(*func_idx).encode(body);
                    }
                    None => {
                        for arg in &call.args {
                            compileExpr(argExprOf(arg), body, ctx, state)?;
                        }
                        let func_idx = ctx
                            .func_ids
                            .get(&call.name)
                            .ok_or_else(|| format!("unknown function '{}'", call.name))?;
                        Instruction::Call(*func_idx).encode(body);
                    }
                }
            }
        }
        ast::Expr::Self_ => {
            let idx = ctx
                .locals
                .get("self")
                .copied()
                .ok_or_else(|| "codegen: 'self' used outside a method".to_string())?;
            Instruction::LocalGet(idx).encode(body);
        }
        ast::Expr::TypeName(n) => match ctx.enum_variants.get(n) {
            Some(info) if info.field_types.is_empty() => {
                let global_idx = *ctx.singleton_globals.get(n)
                    .unwrap_or_else(|| panic!("internal codegen error: payload-free variant '{}' has no singleton global", n));
                Instruction::GlobalGet(global_idx).encode(body);
            }
            Some(info) if !info.values.is_empty() => {
                // A discriminant variant's own declared literal values ARE its
                // construction arguments — there is no call site to take them from, so
                // build one synthetically and reuse the existing payload-variant path.
                let synthetic_call = ast::FnCall {
                    name: n.clone(),
                    args: info.values.iter().cloned().map(ast::Arg::Positional).collect(),
                };
                compileVariantConstruction(info, &synthetic_call, expr, body, ctx, state)?;
            }
            Some(_) => return Err(format!("codegen: '{}' carries a payload — construct it with '{}(...)'", n, n)),
            None => {
                let const_value = CURRENT_CONSTS.with(|c| c.borrow().get(n).cloned());
                match const_value {
                    Some(value) => compileExpr(&value, body, ctx, state)?,
                    None => return Err(format!("codegen: type name '{}' is not yet supported as a value", n)),
                }
            }
        },
        ast::Expr::ClassCall(call) => {
            // struct.new needs every field value pushed in DECLARATION order (not
            // `call.fields`'s written order) immediately before the single
            // construction instruction — no intermediate scratch pointer needed at
            // all, unlike the old bump-pointer-then-store approach.
            let fields = ctx
                .classes
                .get(&call.type_name)
                .ok_or_else(|| format!("codegen: unknown class '{}'", call.type_name))?
                .clone();
            let class_type_idx = *ctx.gc_types.class_type_idx.get(&call.type_name)
                .ok_or_else(|| format!("codegen: class '{}' missing from the GC type registry", call.type_name))?;

            for (field_name, _) in &fields {
                let fa = call.fields.iter().find(|fa| &fa.name == field_name)
                    .ok_or_else(|| format!("codegen: class '{}' missing field '{}'", call.type_name, field_name))?;
                compileExpr(&fa.value, body, ctx, state)?;
            }
            Instruction::StructNew(class_type_idx).encode(body);
        }
        ast::Expr::Attribute(attr) => {
            let obj_ty = inferLocalType(&attr.object, ctx);
            match &attr.attr {
                ast::AttrKind::Field(field_name) => {
                    let class_name = match &obj_ty {
                        PlumType::TNamed(n) => n.clone(),
                        other => return Err(format!("codegen: cannot access field '{}' on non-class type {}", field_name, other)),
                    };
                    match ctx.classes.get(&class_name) {
                        Some(fields) => {
                            let field_idx = fields
                                .iter()
                                .position(|(n, _)| n == field_name)
                                .ok_or_else(|| format!("codegen: no field '{}' on class '{}'", field_name, class_name))?;
                            let class_type_idx = *ctx.gc_types.class_type_idx.get(&class_name)
                                .ok_or_else(|| format!("codegen: class '{}' missing from the GC type registry", class_name))?;
                            compileExpr(&attr.object, body, ctx, state)?;
                            Instruction::StructGet { struct_type_index: class_type_idx, field_index: field_idx as u32 }.encode(body);
                        }
                        // Not a class: fall back to a discriminant enum's shared params,
                        // declared directly on the enum's SUPERTYPE (see
                        // `buildGcTypeRegistry`'s `EnumSuper` arm) — no `ref.cast` to any
                        // particular variant needed, since every variant has the exact
                        // same field list as the supertype itself.
                        None => {
                            let params = ctx
                                .enum_params
                                .get(&class_name)
                                .ok_or_else(|| format!("codegen: unknown class '{}'", class_name))?;
                            let field_idx = params
                                .iter()
                                .position(|(n, _)| n == field_name)
                                .ok_or_else(|| format!("codegen: no field '{}' on enum '{}'", field_name, class_name))?;
                            let super_type_idx = *ctx.gc_types.enum_super_type_idx.get(&class_name)
                                .ok_or_else(|| format!("codegen: enum '{}' missing from the GC type registry", class_name))?;
                            compileExpr(&attr.object, body, ctx, state)?;
                            Instruction::StructGet { struct_type_index: super_type_idx, field_index: field_idx as u32 }.encode(body);
                        }
                    }
                }
                ast::AttrKind::Method(call) => {
                    let class_name = match &obj_ty {
                        PlumType::TNamed(n) => n.clone(),
                        // Builtin primitive types (`Int`/`Float`/`Bool`/`Str`) declare
                        // methods the same way classes do (`type Int = fun ...` in
                        // `libs/std`) — they're just never `TNamed`, so map them back
                        // to the receiver name `ctx.func_ids`/`ctx.methods` use.
                        PlumType::TInt => "Int".to_string(),
                        PlumType::TFloat => "Float".to_string(),
                        PlumType::TBool => "Bool".to_string(),
                        PlumType::TStr => "Str".to_string(),
                        PlumType::TByte => "Byte".to_string(),
                        PlumType::TByteSlice => "ByteSlice".to_string(),
                        other => return Err(format!("codegen: cannot call method '{}' on non-class type {}", call.name, other)),
                    };
                    let key = format!("{}::{}", class_name, call.name);
                    let func_idx = *ctx
                        .func_ids
                        .get(&key)
                        .ok_or_else(|| format!("codegen: unknown method '{}.{}'", class_name, call.name))?;
                    // A "static"-style call (`Bool.parse("true")`, `Float.fromStr("3.14")`)
                    // on a self-less method declared inside a `type`/`enum` body — its
                    // receiver is a bare reference to the type's own name, not a real
                    // value. `attr.object`'s only possible resolution to exactly
                    // `TNamed(class_name)` via a bare `TypeName` is this pattern (a real
                    // enum-variant/const reference resolves to some OTHER concrete type,
                    // per the checker's `inferExpr`). Every method still reserves a
                    // leading self slot in its wasm signature regardless of whether its
                    // Plum source declares a `self` param (see `fnWasmParamTypes`), so
                    // something of the right type must still be pushed — the body simply
                    // never reads it (no `self` binding exists for it to read).
                    let is_static_call = matches!(&attr.object, ast::Expr::TypeName(n) if *n == class_name);
                    if is_static_call {
                        // Use `class_name`'s wasm type (via `astTypeToWasm`, exactly
                        // like `fnWasmParamTypes` computed the callee's actual self
                        // slot type), NOT `obj_ty` — for a builtin primitive receiver
                        // (`Int.fromStr`), `obj_ty` is `TNamed("Int")` (the checker's
                        // bare-`TypeName` fallback doesn't know about primitives), whose
                        // `plumTypeToValtype` would wrongly resolve to a GC ref instead
                        // of `i64`.
                        let vt = astTypeToWasm(&class_name).unwrap_or(ValType::I32);
                        pushSelfPlaceholder(vt, body);
                    } else {
                        compileExpr(&attr.object, body, ctx, state)?; // push self
                    }

                    fn argExprOf(arg: &ast::Arg) -> &ast::Expr {
                        match arg {
                            ast::Arg::Positional(e) => e,
                            ast::Arg::Keyword { value, .. } => value,
                            ast::Arg::Pair { value, .. } => value,
                        }
                    }
                    // A trailing `...T` param (e.g. `add(self, values: ...T)`) packs its
                    // trailing args into one GC array, exactly like a plain function call
                    // (see the `Expr::FnCall` variadic-call arm) — `call.args` doesn't
                    // include `self`, matching `ctx.methods`' own param list.
                    let variadic_split = match ctx.methods.get(&(class_name.clone(), call.name.clone())) {
                        Some(PlumType::TFun(params, _)) => match params.last() {
                            Some(PlumType::TVariadic(elem)) => Some(((**elem).clone(), params.len() - 1)),
                            _ => None,
                        },
                        _ => None,
                    };
                    match variadic_split {
                        Some((elem_ty, fixed_count)) => {
                            for arg in call.args.iter().take(fixed_count) {
                                compileExpr(argExprOf(arg), body, ctx, state)?;
                            }
                            let trailing: Vec<&ast::Expr> = call.args.iter().skip(fixed_count).map(argExprOf).collect();
                            let elem_vt = plumTypeToValtype(&elem_ty);
                            let array_type_idx = *ctx
                                .gc_types
                                .variadic_array_type_idx
                                .get(&elem_vt)
                                .ok_or_else(|| "internal codegen error: no variadic array type registered for this elem type".to_string())?;
                            for arg_expr in &trailing {
                                compileExpr(arg_expr, body, ctx, state)?;
                            }
                            Instruction::ArrayNewFixed { array_type_index: array_type_idx, array_size: trailing.len() as u32 }.encode(body);
                        }
                        None => {
                            for arg in &call.args {
                                compileExpr(argExprOf(arg), body, ctx, state)?;
                            }
                        }
                    }
                    Instruction::Call(func_idx).encode(body);
                }
            }
        }
        ast::Expr::String(s) => {
            let has_interp = s.parts.iter().any(|p| matches!(p, ast::StringPart::Interp(_)));
            if !has_interp {
                // Fast path: every part is static text, so the whole literal is one
                // fixed byte blob known at compile time — no runtime work at all.
                let mut text = String::new();
                for part in &s.parts {
                    if let ast::StringPart::Text(t) = part {
                        text.push_str(t);
                    }
                }
                compileStaticString(&text, body, state);
            } else {
                compileInterpolatedString(s, body, ctx, state)?;
            }
        }
        ast::Expr::Closure(cl) => {
            compileClosureLiteral(cl, body, ctx, state)?;
        }
    }
    Ok(())
}

/// Compiles a variant-construction call. A payload-free variant (`None`, called as
/// `None()` rather than used bare) is its pre-allocated singleton global. A payload
/// variant pushes its field values in order and does `struct.new` into its own
/// concrete variant type.
fn compileVariantConstruction(
    info: &EnumVariantInfo,
    call: &ast::FnCall,
    _expr: &ast::Expr,
    body: &mut Vec<u8>,
    ctx: &LocalCtx,
    state: &mut ModuleState,
) -> Result<(), String> {
    if call.args.len() != info.field_types.len() {
        return Err(format!(
            "codegen: variant '{}' expects {} arg(s), got {}",
            call.name, info.field_types.len(), call.args.len()
        ));
    }
    let variant_type_idx = *ctx.gc_types.variant_type_idx.get(&call.name)
        .ok_or_else(|| format!("codegen: variant '{}' missing from the GC type registry", call.name))?;
    if info.field_types.is_empty() {
        // Pre-allocated singleton (this migration plan's Decision 2) — not a fresh
        // struct.new per reference.
        let global_idx = *ctx.singleton_globals.get(&call.name)
            .ok_or_else(|| format!("codegen: payload-free variant '{}' has no singleton global", call.name))?;
        Instruction::GlobalGet(global_idx).encode(body);
        return Ok(());
    }

    // struct.new needs every field value pushed, in order, immediately before the
    // single construction instruction — positional args already match field
    // declaration order (unlike named class-field construction), so no reordering
    // is needed here.
    for arg in &call.args {
        let arg_expr = match arg {
            ast::Arg::Positional(e) => e,
            ast::Arg::Keyword { value, .. } => value,
            ast::Arg::Pair { value, .. } => value,
        };
        compileExpr(arg_expr, body, ctx, state)?;
    }
    Instruction::StructNew(variant_type_idx).encode(body);
    Ok(())
}

/// Emits a static (compile-time-known) string literal as a fresh passive data
/// segment, pushing a `Str` array built from it via `array.new_data`. `Str`'s wasm-gc
/// representation is a plain `array<i8>` (see Decision 4 of the wasm-gc migration
/// plan) — no length prefix needed, unlike the old bump-allocator layout.
fn compileStaticString(text: &str, body: &mut Vec<u8>, state: &mut ModuleState) {
    let bytes = text.as_bytes();
    let data_index = state.passive_segments.len() as u32;
    state.passive_segments.push(bytes.to_vec());
    let str_type_idx = withGcTypes(|r| r.str_type_idx);
    Instruction::I32Const(0).encode(body);
    Instruction::I32Const(bytes.len() as i32).encode(body);
    Instruction::ArrayNewData { array_type_index: str_type_idx, array_data_index: data_index }.encode(body);
}

/// Lowers a string literal that contains at least one `{expr}` interpolation.
/// Every part becomes a string-pointer-valued expression (static text via
/// `compileStaticString`; `Str`/`Int`/`Bool` interpolated values converted at
/// runtime), then all parts are left-folded together with the `__string_concat`
/// runtime helper.
fn compileInterpolatedString(
    s: &ast::StringExpr,
    body: &mut Vec<u8>,
    ctx: &LocalCtx,
    state: &mut ModuleState,
) -> Result<(), String> {
    let mut first = true;
    for part in &s.parts {
        match part {
            ast::StringPart::Text(t) => {
                compileStaticString(t, body, state);
            }
            ast::StringPart::Interp(expr) => {
                let ty = inferLocalType(expr, ctx);
                match ty {
                    PlumType::TStr => {
                        compileExpr(expr, body, ctx, state)?;
                    }
                    PlumType::TInt => {
                        compileExpr(expr, body, ctx, state)?;
                        Instruction::Call(ctx.int_to_string_func).encode(body);
                    }
                    PlumType::TBool => {
                        let str_ref = withGcTypes(|r| gcRef(r.str_type_idx));
                        compileBoolConditionAsI32(expr, body, ctx, state)?;
                        Instruction::If(BlockType::Result(str_ref)).encode(body);
                        compileStaticString("True", body, state);
                        Instruction::Else.encode(body);
                        compileStaticString("False", body, state);
                        Instruction::End.encode(body);
                    }
                    PlumType::TFloat => {
                        return Err("codegen: interpolating a Float value is not yet supported".to_string());
                    }
                    other => {
                        return Err(format!(
                            "codegen: interpolating a value of type {} is not yet supported",
                            other
                        ));
                    }
                }
            }
        }
        if !first {
            Instruction::Call(ctx.string_concat_func).encode(body);
        }
        first = false;
    }
    Ok(())
}

/// Compiles a closure *literal* at its construction site. Snapshots each captured
/// free variable's CURRENT value into a fresh env struct (`struct.new`), then wraps
/// it with the closure's table index into the shared `{table_idx, env}` closure
/// struct and leaves its ref on the stack.
fn compileClosureLiteral(
    cl: &ast::Closure,
    body: &mut Vec<u8>,
    ctx: &LocalCtx,
    _state: &mut ModuleState,
) -> Result<(), String> {
    let key = cl as *const ast::Closure as usize;
    let info = ctx
        .closures
        .get(&key)
        .ok_or_else(|| "internal codegen error: closure literal was not discovered by the discovery pre-pass".to_string())?;

    // Build the closure struct directly on the stack: push table_idx, then build the
    // env struct (snapshotting each captured free variable's current value from the
    // enclosing function's local), then wrap both into the shared closure struct.
    Instruction::I32Const(info.table_idx as i32).encode(body);
    for (name, _) in &info.free_vars {
        let local_idx = *ctx
            .locals
            .get(name)
            .ok_or_else(|| format!("codegen: captured variable '{}' is not a local in the enclosing scope", name))?;
        Instruction::LocalGet(local_idx).encode(body);
    }
    Instruction::StructNew(info.env_type_idx).encode(body);
    Instruction::StructNew(ctx.gc_types.closure_type_idx).encode(body);
    Ok(())
}

/// Compiles a call to a closure-typed local via `call_indirect`. The local is
/// statically typed `anyref` (closures share that placeholder type — see
/// `plumTypeToValtype`), so every read of its fields `ref.cast`s down to the shared
/// concrete closure struct type first. Stack order matches the closure function's
/// signature `(env, ...args)`: push the env, then each argument, then the table
/// index (the `call_indirect` operand).
fn compileClosureCall(
    call: &ast::FnCall,
    body: &mut Vec<u8>,
    ctx: &LocalCtx,
    state: &mut ModuleState,
) -> Result<(), String> {
    let closure_local = *ctx
        .locals
        .get(&call.name)
        .ok_or_else(|| format!("codegen: closure '{}' is not a local", call.name))?;
    let closure_type_idx = ctx.gc_types.closure_type_idx;

    // Prefer the exact signature recorded when this local was assigned a closure
    // *literal* (see `closure_local_sigs`) — it's already correct. Otherwise (e.g.
    // `call.name` is a `fn(...)`-typed parameter, whose declared type is reliable on
    // its own) fall back to re-deriving it from the type env.
    let sig_key: ClosureSigKey = match ctx.closure_local_sigs.borrow().get(&call.name) {
        Some(key) => key.clone(),
        None => {
            let (param_ptypes, ret_ptype) = match inferLocalType(&ast::Expr::Var(call.name.clone()), ctx) {
                PlumType::TFun(p, r) => (p, *r),
                other => return Err(format!("codegen: '{}' is not callable (type {:?})", call.name, other)),
            };
            let mut sig_params = vec![ValType::Ref(RefType::ANYREF)]; // env pointer
            for p in &param_ptypes {
                sig_params.push(plumTypeToValtype(p));
            }
            let ret_vt = match ret_ptype {
                PlumType::TUnit => None,
                other => Some(plumTypeToValtype(&other)),
            };
            (sig_params, ret_vt)
        }
    };
    let type_index = *ctx
        .closure_call_types
        .get(&sig_key)
        .ok_or_else(|| format!("internal codegen error: no call_indirect type for closure '{}'", call.name))?;

    // env (closure struct field 1)
    Instruction::LocalGet(closure_local).encode(body);
    Instruction::RefCastNonNull(HeapType::Concrete(closure_type_idx)).encode(body);
    Instruction::StructGet { struct_type_index: closure_type_idx, field_index: 1 }.encode(body);
    // real arguments
    for arg in &call.args {
        compileExpr(argExprOf(arg), body, ctx, state)?;
    }
    // table index (closure struct field 0) — the call_indirect operand
    Instruction::LocalGet(closure_local).encode(body);
    Instruction::RefCastNonNull(HeapType::Concrete(closure_type_idx)).encode(body);
    Instruction::StructGet { struct_type_index: closure_type_idx, field_index: 0 }.encode(body);
    Instruction::CallIndirect { type_index, table_index: 0 }.encode(body);
    Ok(())
}

/// Compiles a closure literal's own body into a standalone wasm function. Local 0 is the
/// implicit env pointer; the closure's params follow; then each captured free variable
/// gets a local loaded from the env struct at function entry (restoring the snapshot).
fn compileClosureBody(
    cl: &ast::Closure,
    info: &ClosureInfo,
    ctx: &CompileCtx,
    state: &mut ModuleState,
) -> Result<Vec<u8>, String> {
    let mut body = Vec::new();

    // Base type env: globals + captured free vars + closure params.
    let mut base_env = ctx.global_env.clone();
    for (name, ty) in &info.free_vars {
        base_env.insert(name.clone(), TypeScheme::mono(ty.clone()));
    }
    for (name, pty) in cl.params.iter().zip(info.param_ptypes.iter()) {
        base_env.insert(name.clone(), TypeScheme::mono(pty.clone()));
    }

    let mut collector = Collector {
        env: base_env.clone(),
        cctx: checkCtxOf(&ctx.classes, &ctx.methods, &ctx.enum_variants, &ctx.enum_params),
        named: Vec::new(),
        named_set: Default::default(),
        match_scratch: HashMap::new(),
        nested_class_scratch: HashMap::new(),
        nested_class_scratch_types: Vec::new(),
        next_nested_class_slot: 0,
        variadic_for_scratch: HashMap::new(),
        next_variadic_for_slot: 0,
    };
    collector.walkBlock(&cl.body);

    // ---- local index layout ----
    // [env_ptr][closure params][free-var locals][named...][classcall][match][closure scratch]
    let mut locals: HashMap<String, u32> = HashMap::new();
    let mut groups: Vec<ValType> = Vec::new();
    let mut idx = 0u32;

    idx += 1; // local 0 = env pointer (a param, so not declared below)
    for name in &cl.params {
        locals.insert(name.clone(), idx);
        idx += 1;
    }
    for (name, ty) in &info.free_vars {
        locals.insert(name.clone(), idx);
        groups.push(plumTypeToValtype(ty));
        idx += 1;
    }
    for (name, ty) in &collector.named {
        if locals.contains_key(name) {
            continue;
        }
        locals.insert(name.clone(), idx);
        groups.push(plumTypeToValtype(ty));
        idx += 1;
    }

    let match_scratch_base = idx;
    let mut match_scratch_index: HashMap<usize, u32> = HashMap::new();
    for (ptr, types) in collector.match_scratch.iter() {
        match_scratch_index.insert(*ptr, idx - match_scratch_base);
        for ty in types {
            groups.push(plumTypeToValtype(ty));
            idx += 1;
        }
    }

    let nested_class_scratch_base = idx;
    // Each slot is declared with its OWN concrete variant ref type (not a uniform
    // placeholder) — `struct.get` on a constructor-pattern match requires the local
    // holding the narrowed (`ref.cast`) value to be statically typed as that exact
    // variant, and different slots very likely narrow to different variants.
    for vname in &collector.nested_class_scratch_types {
        let variant_idx = withGcTypes(|r| *r.variant_type_idx.get(vname)
            .unwrap_or_else(|| panic!("internal codegen error: variant '{}' missing from the GC type registry", vname)));
        groups.push(gcRef(variant_idx));
        idx += 1;
    }

    let variadic_for_scratch_base = idx;
    let variadic_for_scratch_count = collector.variadic_for_scratch.values().copied().max().map(|m| m + 1).unwrap_or(0);
    for _ in 0..variadic_for_scratch_count {
        groups.push(ValType::I32); // count
        groups.push(ValType::I32); // loop index
        idx += 2;
    }

    if groups.is_empty() {
        body.push(0);
    } else {
        body.extend(encodeLeb128U32(groups.len() as u32));
        for g in &groups {
            body.extend(encodeLeb128U32(1));
            g.encode(&mut body);
        }
    }

    // Restore each captured free variable from the env struct (local 0, statically
    // `anyref` — `ref.cast` down to THIS closure's own concrete env type) at entry.
    for (i, (name, _)) in info.free_vars.iter().enumerate() {
        let local_idx = *locals.get(name).expect("free var local was assigned above");
        Instruction::LocalGet(0).encode(&mut body); // env pointer
        Instruction::RefCastNonNull(HeapType::Concrete(info.env_type_idx)).encode(&mut body);
        Instruction::StructGet { struct_type_index: info.env_type_idx, field_index: i as u32 }.encode(&mut body);
        Instruction::LocalSet(local_idx).encode(&mut body);
    }

    let local_ctx = LocalCtx {
        locals,
        match_scratch_base,
        match_scratch_index,
        nested_class_scratch_base,
        nested_class_scratch: collector.nested_class_scratch,
        variadic_for_scratch_base,
        variadic_for_scratch: collector.variadic_for_scratch,
        func_ids: &ctx.func_ids,
        func_sigs: &ctx.func_sigs,
        closures: &ctx.closures,
        closure_call_types: &ctx.closure_call_types,
        named_fn_values: &ctx.named_fn_values,
        string_concat_func: ctx.string_concat_func,
        int_to_string_func: ctx.int_to_string_func,
        classes: &ctx.classes,
        methods: &ctx.methods,
        enum_variants: &ctx.enum_variants,
        enum_params: &ctx.enum_params,
        gc_types: &ctx.gc_types,
        singleton_globals: &ctx.singleton_globals,
        type_env: RefCell::new(base_env),
        closure_local_sigs: RefCell::new(HashMap::new()),
    };

    compileBlockAsFnBody(&cl.body, &mut body, &local_ctx, state, info.ret_vt)?;

    Instruction::End.encode(&mut body);
    Ok(body)
}