Lines
86.83 %
Functions
14.44 %
Branches
100 %
//! Lambda body emit — production-side path that lifts a value-position
//! `(lambda ...)` / `(function ...)` from compile-time stringification
//! to a real wasm function plus a `$closure_<sig>` GC-struct value
//! pushed onto the stack.
//!
//! Scope of this slice (Tier 1.5 v1, ADR-0027): required-only params
//! typed as Ratio, no keyword/optional/rest/aux. Free-variable captures
//! are now lowered: each captured name with a stable local storage
//! location rides through a per-scope `$env_<id>` GC struct that the
//! helper-fn body unpacks in its prologue. Globally-resolvable names
//! (host-fn placeholders, special forms) need no env transport — they
//! resolve identically inside the helper through the cloned symbol
//! table.
//! The caller observes a `WasmType::Closure(sig)` on the stack — sig'd
//! through the per-context `ClosureRegistry` so distinct lambdas of the
//! same shape share `$fn_<sig>` and `$closure_<sig>`. Each capturing
//! lambda still gets its own `$env_<id>` so two closures with disjoint
//! captures don't collide.
use crate::ast::{ClosureSigId, Expr, LambdaParams, WasmType};
use crate::compiler::context::CompileContext;
use crate::compiler::context::closure::{EnvField, EnvStructLayout};
use crate::compiler::emit::FunctionEmitter;
use crate::compiler::expr::compile_for_stack;
use crate::error::{Error, Result};
use crate::runtime::{Symbol, SymbolKind, SymbolTable};
use super::captures::{CaptureSet, compute_captures};
/// Wire-level param types for the closure body fn. Catch-each (and other
/// future host-iteration callers) pass items as raw anyref values, so
/// the body fn must accept anyref params and downcast inside the
/// prologue to the declared user-visible param type. The lambda value
/// path (FUNCALL/APPLY/etc.) keeps the original typed signature so the
/// `call_ref` site doesn't need any boxing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(in crate::compiler) enum CallingConvention {
/// Wasm-side typed call_ref: param wasm types match user-visible types.
Typed,
/// Host-side anyref call: every user param is wired as anyref + downcast
/// in the prologue to the user-visible type.
HostAnyref,
}
/// Emits a real wasm function for `params`/`body` and pushes a
/// `(ref $closure_<sig>)` value onto the caller's stack. Returns the
/// signature id so the caller can record `WasmType::Closure(sig)` for
/// the stack arm.
pub(super) fn emit_lambda_value(
ctx: &mut CompileContext,
emit: &mut FunctionEmitter,
symbols: &SymbolTable,
params: &LambdaParams,
body: &Expr,
) -> Result<ClosureSigId> {
let user_param_types = super::param_infer::infer_param_types(params, body);
emit_lambda_value_typed(
ctx,
emit,
symbols,
params,
body,
&user_param_types,
CallingConvention::Typed,
)
/// Variant that lets the caller declare the user-visible param types
/// (defaults are Ratio). With `HostAnyref` calling convention every
/// param is wired as anyref on the wasm side and the prologue downcasts
/// to the declared user-visible type before the body runs.
pub(in crate::compiler) fn emit_lambda_value_typed(
user_param_types: &[WasmType],
cc: CallingConvention,
if !has_v1_param_shape(params) {
return Err(Error::Compile(
"lambda value with &optional/&rest/&key/&aux not yet supported as a \
first-class wasm closure; rewrite the lambda to take only required \
parameters or invoke it inline (the const-fold path still handles \
keyword args)"
.to_string(),
));
if user_param_types.len() != params.required.len() {
return Err(Error::Compile(format!(
"lambda emit: param-type count {} does not match required-param count {}",
user_param_types.len(),
params.required.len()
)));
let captures = compute_captures(symbols, params, body);
let env_fields = collect_env_fields(symbols, &captures)?;
let (result_ty, helper_func_idx, env_layout) = compile_body_into_helper(
¶ms.required,
user_param_types,
&env_fields,
cc,
)?;
let wire_param_types: Vec<WasmType> = match cc {
CallingConvention::Typed => user_param_types.to_vec(),
CallingConvention::HostAnyref => {
user_param_types.iter().map(|_| WasmType::AnyRef).collect()
};
let wire_result_ty = match cc {
CallingConvention::Typed => result_ty,
CallingConvention::HostAnyref => WasmType::AnyRef,
let sig = ctx.intern_closure_signature(&wire_param_types, wire_result_ty)?;
push_closure_value(
sig,
helper_func_idx,
env_layout.as_ref(),
Ok(sig)
/// Predicate: whether `params` / `body` fit the Tier 1.5 v1 emit slice.
/// V1 covers required-only params; captures of stable locals are
/// supported. Lambdas with keyword/optional/rest/aux still fall back
/// to stringification.
pub(super) fn is_v1_eligible(_symbols: &SymbolTable, params: &LambdaParams, _body: &Expr) -> bool {
has_v1_param_shape(params)
fn has_v1_param_shape(params: &LambdaParams) -> bool {
params.optional.is_empty()
&& params.rest.is_none()
&& params.key.is_empty()
&& params.aux.is_empty()
/// Walks `captures` against `symbols`, keeping only names that resolve
/// to a `WasmLocal(idx, ty)` — those have a stable outer-scope storage
/// location to copy from. `WasmRuntime` placeholders (e.g. registered
/// host-fn symbols) are globally resolvable and need no env transport;
/// the helper's cloned symbol table sees them unchanged.
fn collect_env_fields(symbols: &SymbolTable, captures: &CaptureSet) -> Result<Vec<EnvField>> {
let mut fields = Vec::new();
for name in captures.iter() {
let sym = symbols.lookup(name).ok_or_else(|| {
Error::Compile(format!(
"lambda body references undefined name {name:?} during capture analysis"
))
})?;
if let Some(Expr::WasmLocal(_, ty)) = sym.value() {
fields.push(EnvField {
name: name.to_string(),
ty: *ty,
});
Ok(fields)
/// Body emit pipeline:
/// 1. Snapshot caller's local-pool state.
/// 2. Build a fresh `FunctionEmitter`, bind params as `WasmLocal`s
/// (slot 0 = env anyref, 1..=N = user params).
/// 3. If the body captures anything: register an `$env_<id>` struct,
/// allocate one helper-side local per field, emit the prologue
/// that downcasts param-0 and unpacks the env into those locals,
/// rebind captured names in the helper's local symbol table.
/// 4. Walk body via `compile_for_stack` to produce the value the helper
/// returns.
/// 5. End the body, drop the locals declaration, register the helper
/// fn (reserves a stable wasm fn idx), queue the body into
/// `pending_helpers`.
/// 6. Restore caller's local-pool snapshot.
fn compile_body_into_helper(
param_names: &[String],
env_fields: &[EnvField],
) -> Result<(WasmType, u32, Option<EnvStructLayout>)> {
let env_anyref = ctx.anyref();
let mut helper_param_vts = Vec::with_capacity(user_param_types.len() + 1);
helper_param_vts.push(env_anyref);
for &ty in user_param_types {
let wire_ty = match cc {
CallingConvention::Typed => ty,
helper_param_vts.push(ctx.wasm_val_type(wire_ty));
let param_count = u32::try_from(helper_param_vts.len())
.map_err(|_| Error::Compile("lambda parameter count exceeds u32 range".to_string()))?;
let env_layout = if env_fields.is_empty() {
None
} else {
Some(ctx.intern_env_struct(env_fields)?)
let snapshot = ctx.take_local_pool(param_count);
let mut local_symbols = symbols.clone();
let mut helper_emit = FunctionEmitter::new();
bind_user_params(
&mut helper_emit,
&mut local_symbols,
param_names,
if let Some(layout) = env_layout.as_ref() {
emit_env_unpack_prologue(ctx, &mut helper_emit, &mut local_symbols, layout)?;
// A `HostAnyref` callback is host-invoked (catch-each), so an
// uncaught `(error)` throw inside it must be bridged to `__nomi_raise`
// at this boundary — wrap the body in the Tier 3 boundary `try_table`
// (ADR-0026). The wire result is always anyref here, known up-front.
// `Typed` lambdas are in-module calls: NO wrapper, so an enclosing
// `(handler-case)` can still catch their throws.
let result_ty = match cc {
CallingConvention::Typed => {
match compile_for_stack(ctx, &mut helper_emit, &mut local_symbols, body) {
Ok(ty) => ty,
Err(e) => {
ctx.restore_local_pool(snapshot);
return Err(e);
let wrap =
ctx.emit_boundary_wrapper(&mut helper_emit, Some(WasmType::AnyRef), |ctx, emit| {
let ty = compile_for_stack(ctx, emit, &mut local_symbols, body)?;
promote_result_to_anyref(ctx, emit, ty)?;
Ok(())
if let Err(e) = wrap {
WasmType::AnyRef
helper_emit.end();
let helper_locals = ctx.build_helper_locals(param_count);
let helper_fn = helper_emit.finish(&helper_locals);
let result_vt = ctx.wasm_val_type(result_ty);
let helper_name = ctx.next_lambda_helper_name()?;
let func_idx = ctx.register_function(&helper_name, &helper_param_vts, &[result_vt])?;
ctx.declare_funcref(func_idx);
ctx.queue_helper(helper_fn);
Ok((result_ty, func_idx, env_layout))
/// Binds each user-facing param symbol so the body sees a `WasmLocal`
/// of the declared user type. With `Typed` the wasm param already holds
/// a typed value — bind directly. With `HostAnyref` the wasm param is
/// anyref; allocate a fresh helper-side typed local, downcast the
/// anyref into it, and bind the user name to that local.
fn bind_user_params(
helper: &mut FunctionEmitter,
local_symbols: &mut SymbolTable,
) -> Result<()> {
for (idx, (name, ty)) in param_names.iter().zip(user_param_types.iter()).enumerate() {
let wire_local_idx = u32::try_from(idx + 1)
let bound_idx = match cc {
CallingConvention::Typed => wire_local_idx,
let typed_local = ctx.alloc_local(*ty)?;
helper.local_get(wire_local_idx);
emit_anyref_to_typed_downcast(ctx, helper, *ty)?;
helper.local_set(typed_local);
typed_local
local_symbols.define(
Symbol::new(name, SymbolKind::Variable).with_value(Expr::WasmLocal(bound_idx, *ty)),
);
/// Emits the wasm to convert an `anyref` on the stack to the declared
/// user-visible type. Reference-typed values ride a `ref.cast` to the
/// concrete type; nothing else is allowed — `I32` (i31-boxed integer)
/// and `Closure` deliberately fail here so the catch-each body can't
/// silently coerce small ints into a Ratio / Commodity slot or vice
/// versa. Cross-type bridging (int ↔ ratio ↔ commodity) is the
/// caller's job at the script level (ADR-0014).
fn emit_anyref_to_typed_downcast(
ctx: &CompileContext,
ty: WasmType,
match ty {
WasmType::Ratio => helper.ref_cast(ctx.ids.ty_ratio),
WasmType::Commodity => helper.ref_cast(ctx.ids.ty_commodity),
WasmType::PairRef(_) => helper.ref_cast(ctx.ids.ty_pair),
WasmType::StringRef => helper.ref_cast(ctx.ids.ty_i8_array),
WasmType::EntityRef(kind) => helper.ref_cast(ctx.ids.entity_type(kind)),
WasmType::AnyRef => {}
WasmType::I32 | WasmType::Bool | WasmType::Closure(_) => {
"host-anyref calling convention cannot downcast to {ty}; \
the iteration variable's element type must be a reference-typed \
value (Ratio / Commodity / Pair / String / Entity / AnyRef). \
Cross-type bridging (e.g. integer literal lists into a Ratio \
body) is forbidden — adjust the items list to the type the \
body expects, or rebox via an explicit conversion native"
/// Wraps a typed value on the stack as an anyref so the host fn can
/// receive it via the funcref's anyref result slot. I32 here means the
/// body terminated in `unreachable` (`(error ...)`), so wasm's
/// stack-polymorphism after the trap covers the typing — no real value
/// will ever be returned.
fn promote_result_to_anyref(
WasmType::AnyRef => Ok(()),
WasmType::Bool => {
// A boolean body result is a value type (i32-repr), so box it into
// an `(ref i31)` to ride the anyref result slot — the same boxing
// the host-anyref convention uses for small ints elsewhere.
let _ = ctx;
helper.ref_i31();
WasmType::I32 => {
// Body lowered through (error ...) which ended in `unreachable`.
// Drop the dummy I32 the unreachable left on the synthetic
// polymorphic stack; nothing actually flows past this point.
let _ = helper;
WasmType::Ratio
| WasmType::Commodity
| WasmType::PairRef(_)
| WasmType::StringRef
| WasmType::EntityRef(_) => {
// Refs widen to anyref freely (subtyping); no instruction needed.
WasmType::Closure(_) => Err(Error::Compile(format!(
"host-anyref calling convention cannot widen body return type {ty} to anyref"
))),
/// Emits the helper-fn prologue that unpacks an env-struct into fresh
/// helper-side locals. For each captured field: downcast param-0
/// (`(ref null any)`) to the concrete env-struct type, read the field,
/// stash it in a freshly allocated helper-side local, then rebind the
/// captured name in `local_symbols` to that local so the body's
/// `compile_for_stack` walks resolve captures via `local.get`.
fn emit_env_unpack_prologue(
layout: &EnvStructLayout,
for (field_idx, field) in layout.fields.iter().enumerate() {
let local_idx = ctx.alloc_local(field.ty)?;
let field_idx_u32 = u32::try_from(field_idx)
.map_err(|_| Error::Compile("env-struct field count exceeds u32 range".to_string()))?;
helper.local_get(0);
helper.ref_cast(layout.type_idx);
helper.struct_get(layout.type_idx, field_idx_u32);
helper.local_set(local_idx);
Symbol::new(&field.name, SymbolKind::Variable)
.with_value(Expr::WasmLocal(local_idx, field.ty)),
/// Emits the closure construction sequence at the caller's stack:
/// `ref.func $helper` + (env-struct construction or `ref.null any`) +
/// `struct.new $closure_<sig>`. Captures are loaded from the caller's
/// scope via `local.get` on each captured name's outer-scope local
/// index, then bundled into a `$env_<id>` struct whose ref slots into
/// the closure's nullable env field.
fn push_closure_value(
sig: ClosureSigId,
func_idx: u32,
env_layout: Option<&EnvStructLayout>,
let closure_type_idx = ctx.closure_sig(sig).closure_type_idx;
emit.ref_func(func_idx);
match env_layout {
Some(layout) => emit_env_struct_construction(emit, symbols, layout)?,
None => emit.ref_null_any(),
emit.struct_new(closure_type_idx);
fn emit_env_struct_construction(
for field in &layout.fields {
let sym = symbols.lookup(&field.name).ok_or_else(|| {
"captured name {:?} disappeared from symbol table before env-struct \
construction",
field.name
let Some(Expr::WasmLocal(idx, _)) = sym.value() else {
"captured name {:?} no longer resolves to a local at env-struct \
construction time",
emit.local_get(*idx);
emit.struct_new(layout.type_idx);