plum
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, ®istry)),
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, ®istry)),
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, ®istry)),
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(¶m_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(¶m_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, ¶ms, 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 ¶m_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)
}