Lines
88.61 %
Functions
20 %
Branches
100 %
//! `IF` special form — eval, effect-compile, and stack-compile paths.
//! Constant-folds when the test resolves at compile time; emits a
//! WASM `if/else` block when the test is a runtime value. The
//! stack variant carries the result type so call sites composing
//! `(if ...)` with arithmetic see the right WasmType.
use wasm_encoder::BlockType;
use crate::ast::{Expr, WasmType};
use crate::compiler::context::CompileContext;
use crate::compiler::emit::FunctionEmitter;
use crate::compiler::expr::{
compile_expr, compile_for_stack, compile_for_stack_as, compile_nil, eval_value,
serialize_stack_to_output,
};
use crate::error::{Error, Result};
use crate::runtime::SymbolTable;
use super::is_truthy;
pub(super) fn compile_if(
ctx: &mut CompileContext,
emit: &mut FunctionEmitter,
symbols: &mut SymbolTable,
args: &[Expr],
) -> Result<()> {
if args.len() < 2 || args.len() > 3 {
return Err(Error::Compile(
"IF requires a test, a then-form, and an optional else-form".to_string(),
));
}
// Classify the test on a CLONE: its compile-time side effects (setf /
// macro expansion) must reach the live table exactly once, via the single
// emit below — never twice (classify + emit).
let test = eval_value(&mut symbols.clone(), &args[0])?;
let test_diverges = super::block_exits::form_diverges(&mut symbols.clone(), &args[0])?;
super::reject_non_boolean_runtime_test(&test, test_diverges)?;
if test_diverges {
// The test transfers control before yielding a condition (`(error …)`,
// `(return-from …)`). Compile it for effect so the exit actually
// fires; both branches are then dead. Const-folding past it (the bug
// this guards) would silently drop the exit and pick a branch.
return crate::compiler::expr::compile_for_effect(ctx, emit, symbols, &args[0]);
if super::is_runtime_test(&test) {
// Runtime test: emit ONE merged `if (result T)` block via the stack
// path, then serialize the single result. Serializing each branch
// separately (via `compile_expr`) double-advances the compile-time
// output cursor — both branches bake an entity header, but only one
// runs, so the decoder reads a garbage entity slot ("unknown value
// type"). The stack path collapses both arms to one value.
let ty = compile_if_for_stack(ctx, emit, symbols, args)?;
return serialize_stack_to_output(ctx, emit, ty);
// Const test: apply its effects to the live table exactly once.
let test = eval_value(symbols, &args[0])?;
if is_truthy(&test) {
compile_expr(ctx, emit, symbols, &args[1])
} else if args.len() == 3 {
compile_expr(ctx, emit, symbols, &args[2])
} else {
compile_nil(ctx, emit);
Ok(())
pub(super) fn compile_if_for_stack(
) -> Result<WasmType> {
// Classify on a CLONE so the test's compile-time effects reach the live
// table only through the single `compile_for_stack` emit below.
// The test exits before producing a condition; compiling it for the
// stack emits its terminating control flow (`br`/`throw`), leaving a
// polymorphic stack that satisfies any declared result type. Both
// branches are dead. Report I32 as a nominal type for callers.
compile_for_stack(ctx, emit, symbols, &args[0])?;
return Ok(WasmType::I32);
return compile_runtime_if(ctx, emit, symbols, args);
// Const test: apply its effects to the live table once, then the live arm.
compile_for_stack(ctx, emit, symbols, &args[1])
compile_for_stack(ctx, emit, symbols, &args[2])
// Const-false IF with no else ≡ nil — falsy i31, typed `Bool` so it
// serializes as Nil and matches the eval mirror's `Expr::Nil`.
emit.i32_const(0);
Ok(WasmType::Bool)
/// Emit the runtime `if (result T)` block, with the i32 condition already on
/// the stack. Each branch compiles into its own scratch seeded at the
/// branch's wasm depth (`parent + 1`) — `compile_for_stack` lowers a
/// recursive self-call to `call $idx` and terminates, where an `eval_value`
/// peek would inline the body and recurse forever. The real branch types are
/// then unified (divergence-aware, widen-to-AnyRef on disagreement), each
/// scratch is coerced to `T`, and both are spliced into the live block — so
/// each branch is compiled exactly once.
fn compile_runtime_if(
let has_else = args.len() == 3;
let then_diverges = super::block_exits::form_diverges(&mut symbols.clone(), &args[1])?;
let else_diverges =
has_else && super::block_exits::form_diverges(&mut symbols.clone(), &args[2])?;
let branch_depth = emit.block_depth() + 1;
// A numeric-literal arm is dimension-flexible: defer compiling it until the
// reconciled result type is known, so it is emitted exactly once at the
// right type (no compile-then-discard, which would double-apply a
// number-resolving arm's compile-time effects). Its type is peeked (no emit).
let then_lit = is_number_literal(symbols, &args[1]);
let else_lit = has_else && is_number_literal(symbols, &args[2]);
let mut then_scratch = FunctionEmitter::new_seeded(branch_depth);
let then_ty = if then_diverges {
compile_for_stack(ctx, &mut then_scratch, symbols, &args[1])?;
None
} else if then_lit {
Some(peek_stack_type(&mut symbols.clone(), &args[1])?)
Some(compile_for_stack(
ctx,
&mut then_scratch,
symbols,
&args[1],
)?)
let mut else_scratch = FunctionEmitter::new_seeded(branch_depth);
let else_ty = if !has_else {
// Missing else ≡ `nil`. Type it to mirror an i32-repr then-branch so
// the block stays homogeneous: an I32 then (a count) keeps Number
// fidelity, a Bool then keeps Nil/Bool fidelity. A ref-typed then
// widens to AnyRef either way (its nil is `ref.null`).
Some(missing_else_type(then_ty))
} else if else_diverges {
compile_for_stack(ctx, &mut else_scratch, symbols, &args[2])?;
} else if else_lit {
Some(peek_stack_type(&mut symbols.clone(), &args[2])?)
&mut else_scratch,
&args[2],
let result_ty = reconciled_if_type(then_ty, else_ty, then_lit, else_lit);
emit_if_arm(
then_lit,
then_ty,
result_ty,
)?;
if has_else {
else_lit,
else_ty,
emit_typed_nil(&mut else_scratch, result_ty);
emit.if_block(BlockType::Result(ctx.wasm_val_type(result_ty)));
emit.splice(&then_scratch.take_bytes());
emit.else_block();
emit.splice(&else_scratch.take_bytes());
emit.block_end();
Ok(result_ty)
/// Emit one IF arm into its `scratch` at the unified `result_ty`. A deferred
/// numeric-literal arm is compiled here exactly once: coerced across
/// Index↔Scalar when `result_ty` is numeric, else compiled naturally and boxed
/// to ride an AnyRef block. A non-literal arm was already compiled into
/// `scratch`; it only needs the i32→AnyRef box.
fn emit_if_arm(
scratch: &mut FunctionEmitter,
arg: &Expr,
is_lit: bool,
arm_ty: Option<WasmType>,
result_ty: WasmType,
if is_lit {
if crosses_to(result_ty) {
compile_for_stack_as(ctx, scratch, symbols, arg, result_ty)?;
let raw = compile_for_stack(ctx, scratch, symbols, arg)?;
coerce_scratch(scratch, Some(raw), result_ty);
coerce_scratch(scratch, arm_ty, result_ty);
/// Type the `nil` of a runtime IF's missing else so the block stays
/// homogeneous with an i32-repr then-branch. A plain `I32` then is a COUNT —
/// its missing-else nil must also be `I32` so the result keeps Number
/// fidelity (else `unify_if_type` widens I32-vs-Bool to AnyRef and the count
/// is lost behind a marker). Every other then-type takes `Bool` (the natural
/// truth-typed nil): a `Bool` then stays `Bool` (Nil/Bool fidelity), a
/// ref-typed or diverging then widens to AnyRef either way.
fn missing_else_type(then_ty: Option<WasmType>) -> WasmType {
match then_ty {
Some(WasmType::I32) => WasmType::I32,
_ => WasmType::Bool,
/// Unify the two arm types of a runtime IF. `None` is a diverging arm
/// (contributes no value). Equal types pass through; genuinely different
/// types widen to `AnyRef` — the only common supertype that lets a
/// heterogeneous IF (e.g. a no-else `(if t ratio)` whose else is `nil`) ride
/// one wasm block result. AnyRef-valued IFs serialize at marker fidelity
/// (the documented heterogeneous-value limitation, shared with catch-each
/// cells); homogeneous IFs keep full value fidelity.
fn unify_if_type(then_ty: Option<WasmType>, else_ty: Option<WasmType>) -> WasmType {
match (then_ty, else_ty) {
(Some(a), Some(b)) if a == b => a,
(Some(a), None) | (None, Some(a)) => a,
(None, None) => WasmType::I32,
(Some(_), Some(_)) => WasmType::AnyRef,
/// Index and Scalar are the only strata a numeric literal can cross into; a
/// literal can never become Money (it has no currency).
fn crosses_to(ty: WasmType) -> bool {
matches!(ty, WasmType::I32 | WasmType::Ratio)
/// Whether `expr` resolves to a numeric literal (a dimension-flexible token).
/// Probes on a clone so it applies no live mutation.
fn is_number_literal(symbols: &SymbolTable, expr: &Expr) -> bool {
matches!(eval_value(&mut symbols.clone(), expr), Ok(Expr::Number(_)))
/// Unify two IF-arm types, reconciling a numeric-LITERAL arm with the other arm
/// by coercing the literal across the Index↔Scalar boundary instead of widening
/// to `AnyRef` (ADR-0028). `(if c 1 ratio)` is a Ratio IF; `(if c 1 2)` an Index
/// IF; `(if c 1 1/2)` joins to Scalar. A literal next to Money (or a non-numeric),
/// and two runtime numerics of different strata, still widen to AnyRef (the
/// escape hatch) — a literal cannot become Money.
fn reconciled_if_type(
then_ty: Option<WasmType>,
else_ty: Option<WasmType>,
then_lit: bool,
else_lit: bool,
) -> WasmType {
let base = unify_if_type(then_ty, else_ty);
if base != WasmType::AnyRef {
return base;
let (Some(t), Some(e)) = (then_ty, else_ty) else {
if !(crosses_to(t) && crosses_to(e)) {
match (then_lit, else_lit) {
(true, false) => e,
(false, true) => t,
(true, true) => WasmType::Ratio,
(false, false) => base,
/// Box a compiled branch scratch to the unified `target`. A diverging branch
/// (`branch_ty == None`) already ended in terminating control flow — its
/// stack is polymorphic, so it needs no value. Only i32 → AnyRef needs a
/// `ref.i31` box; every other type is already an anyref subtype.
fn coerce_scratch(scratch: &mut FunctionEmitter, branch_ty: Option<WasmType>, target: WasmType) {
// The i32-repr value types (I32 / Bool) box into `(ref i31)` to ride an
// AnyRef block; every other type is already an anyref subtype.
if target == WasmType::AnyRef && matches!(branch_ty, Some(WasmType::I32 | WasmType::Bool)) {
scratch.ref_i31();
/// Push the `nil` value of a runtime IF's missing else, typed to match the
/// unified block result: `ref.null any` for an AnyRef block, i32 `0`
/// otherwise (a missing else only widens to AnyRef when the then-branch is a
/// non-i32 ref type).
fn emit_typed_nil(emit: &mut FunctionEmitter, target: WasmType) {
match target {
WasmType::AnyRef => emit.ref_null_any(),
_ => emit.i32_const(0),
/// Classify an `Expr` by the `WasmType` it would push if `compile_for_stack`
/// emitted it. Mirrors the literal lowerings in
/// `crate::compiler::expr::stack::compile_for_stack` — `Number → Ratio`,
/// `Bool / Nil → Bool`, `String → StringRef`, runtime placeholders carry
/// their declared type — so the IF / COND BlockType peek can be honest
/// about what each branch will deposit on the stack. Anything not
/// statically classifiable is left to surface as a real codegen error
/// downstream rather than masked as I32 here.
fn peek_stack_type(symbols: &mut SymbolTable, expr: &Expr) -> Result<WasmType> {
let resolved = eval_value(symbols, expr)?;
Ok(crate::compiler::expr::classify_stack_type(&resolved).unwrap_or(WasmType::I32))
pub(super) fn if_form(symbols: &mut SymbolTable, args: &[Expr]) -> Result<Expr> {
// Classify on a CLONE — branch peeks below must not apply runtime
// branches' side effects to the live table (they execute at runtime, in
// one branch only). A const test re-evals on the live table once below.
// The test exits before yielding a condition; its value is whatever
// the diverging form evaluates to (the branches are dead). This is the
// single live application of the test's effects.
return eval_value(symbols, &args[0]);
// A runtime-test IF emits a wasm `if (result T)` block whose T the
// codegen path (`compile_if_for_stack`) derives by unifying both
// branches' *stack* types. Eval-time typing MUST agree, or a binder
// that sizes a local from this eval type (let / defvar / lambda arg)
// mismatches the value codegen actually pushes. Mirror the exact
// unification (divergence-aware, widen-to-AnyRef on disagreement).
// Peek each branch on a CLONE so branch side effects don't leak.
let else_ty = match (has_else, else_diverges) {
(false, _) => Some(missing_else_type(then_ty)), // missing else ≡ nil
(true, true) => None,
(true, false) => Some(peek_stack_type(&mut symbols.clone(), &args[2])?),
return Ok(Expr::WasmRuntime(reconciled_if_type(
then_ty, else_ty, then_lit, else_lit,
)));
eval_value(symbols, &args[1])
eval_value(symbols, &args[2])
Ok(Expr::Nil)