Lines
91.33 %
Functions
41.76 %
Branches
100 %
//! Closure parameter-type inference from body usage.
//!
//! A value-position `(lambda …)` lifted to a closure needs user-visible param
//! types. Defaulting every param to `Ratio` mis-types a list accumulator (a
//! param used as a `(cons CAR param)` cdr is a pair, not a scalar), so FOLD /
//! MAP over such a closure rejected it. This module infers each required
//! param's `WasmType` from how the body uses it; the closure's result type then
//! follows from compiling the body under those param types.
//! Today's single inferred shape: a param used as the CDR of `(cons CAR param)`
//! → `PairRef(<car cell element>)`. A param with no such use stays `Ratio`
//! (identical to the old default — no existing scalar closure regresses). The
//! scan is SCOPE-AWARE: a binder (`lambda`/`let`/`let*`/`do`/`do*`/`dolist`)
//! that rebinds the name shadows the param in its BODY, so a cons-cdr use of the
//! inner variable must not retype the outer param — but the binder's INIT
//! expressions (and a `dolist` list-expr) are in the OUTER scope and ARE
//! scanned.
use crate::ast::{Expr, LambdaParams, PairElement, WasmType};
/// Infers each required param's user-visible `WasmType` from `body`.
pub(super) fn infer_param_types(params: &LambdaParams, body: &Expr) -> Vec<WasmType> {
params
.required
.iter()
.map(|name| pinned_param_type(body, name).unwrap_or(WasmType::Ratio))
.collect()
}
/// The `WasmType` `name` is pinned to by a type-constraining use in `body`,
/// anywhere `name` still refers to the searched param (not inside a scope that
/// rebinds it). Two pins today:
/// - CDR of a `(cons CAR name)` → `PairRef(<car cell element>)` (list accumulator);
/// - argument to a string native (`string=`, `upcase-string`) → `StringRef`.
///
/// `None` if no pinning use is found (caller defaults to `Ratio`).
fn pinned_param_type(body: &Expr, name: &str) -> Option<WasmType> {
let Expr::List(elems) = body else {
return None;
};
if let [Expr::Symbol(head), car, Expr::Symbol(cdr)] = elems.as_slice()
&& head.eq_ignore_ascii_case("cons")
&& cdr == name
{
return Some(WasmType::PairRef(car_cell_element(car)));
if let Some(Expr::Symbol(head)) = elems.first()
&& is_string_native(head)
&& elems[1..]
.any(|a| matches!(a, Expr::Symbol(s) if s == name))
return Some(WasmType::StringRef);
match head_upper(elems).as_deref() {
// Quoted forms are data, not evaluated — a param mentioned inside must
// not pin its type. (The `Expr::Quote`/`Expr::Quasiquote` AST variants
// already return `None` above by not being a `List`; this guards the
// `(quote …)` / `(quasiquote …)` list spellings.)
Some("QUOTE") | Some("QUASIQUOTE") => None,
Some("LET") => scan_let(elems, name, false),
Some("LET*") => scan_let(elems, name, true),
Some("DO") | Some("DO*") => scan_do(elems, name),
Some("DOLIST") => scan_dolist(elems, name),
Some("LAMBDA") => {
// The body is shadowed iff `name` is one of the lambda's params; a
// param list carries no scannable outer-scope subform.
if lambda_binds(elems, name) {
None
} else {
scan_each(tail(elems, 1), name)
_ => scan_each(tail(elems, 1), name),
/// Natives that REQUIRE a `StringRef` argument, so a param passed to one is a
/// string. (Only these two exist today; extend alongside new string natives.)
fn is_string_native(head: &str) -> bool {
matches!(
head.to_ascii_uppercase().as_str(),
"STRING=" | "UPCASE-STRING"
)
/// The `PairElement` a car expression contributes. Literal cars pin their slot;
/// a sibling-param / captured / computed car has no statically-known cell type,
/// so it widens to `AnyRef` — keeping the cdr param's pair element consistent
/// with whatever `compile_cons_to_stack` actually produces for that car.
fn car_cell_element(car: &Expr) -> PairElement {
match car {
Expr::Number(_) => PairElement::Ratio,
Expr::Bool(_) => PairElement::Bool,
Expr::String(_) => PairElement::StringRef,
_ => PairElement::AnyRef,
fn head_upper(elems: &[Expr]) -> Option<String> {
match elems.first() {
Some(Expr::Symbol(s)) => Some(s.to_ascii_uppercase()),
_ => None,
fn scan_each(forms: &[Expr], name: &str) -> Option<WasmType> {
forms.iter().find_map(|e| pinned_param_type(e, name))
/// `forms[from..]`, but never panics on a malformed/short form: a list with
/// fewer than `from` elements (e.g. a bare `()`, `(let)`, `(do)`) yields an
/// empty slice rather than an out-of-range slice index. Param-type inference is
/// a best-effort hint, so a too-short form simply contributes no pin; the
/// malformed form is rejected (or compiled) by the real path later.
fn tail(forms: &[Expr], from: usize) -> &[Expr] {
forms.get(from..).unwrap_or(&[])
/// `(let ((v init)…) body…)` / `(let* …)`. Binding INITs are in the outer scope
/// (for LET* up to the binding that introduces `name`); the body is shadowed
/// once any binding (re)binds `name`.
fn scan_let(elems: &[Expr], name: &str, sequential: bool) -> Option<WasmType> {
let mut shadowed = false;
if let Some(Expr::List(bindings)) = elems.get(1) {
for binding in bindings {
let Expr::List(parts) = binding else { continue };
let binds_here = matches!(parts.first(), Some(Expr::Symbol(s)) if s == name);
// For LET* a binding shadows `name` for SUBSEQUENT inits; for LET
// every init is in the outer scope regardless.
if !(sequential && shadowed)
&& let Some(init) = parts.get(1)
&& let Some(ty) = pinned_param_type(init, name)
return Some(ty);
if binds_here {
shadowed = true;
if shadowed {
scan_each(tail(elems, 2), name)
/// `(do ((v init step)…) (end res…) body…)`. Inits are outer scope; steps, the
/// end clause, and the body see the loop vars, so they're shadowed once any var
/// is `name`.
fn scan_do(elems: &[Expr], name: &str) -> Option<WasmType> {
if let Some(Expr::List(vars)) = elems.get(1) {
for var in vars {
let Expr::List(parts) = var else { continue };
if let Some(init) = parts.get(1)
if matches!(parts.first(), Some(Expr::Symbol(s)) if s == name) {
/// `(dolist (var list-expr [result]) body…)`. The list-expr is outer scope; the
/// result form and body see the loop var.
fn scan_dolist(elems: &[Expr], name: &str) -> Option<WasmType> {
let Some(Expr::List(spec)) = elems.get(1) else {
return scan_each(tail(elems, 1), name);
if let Some(list_expr) = spec.get(1)
&& let Some(ty) = pinned_param_type(list_expr, name)
let var_is_name = matches!(spec.first(), Some(Expr::Symbol(s)) if s == name);
if var_is_name {
fn lambda_binds(elems: &[Expr], name: &str) -> bool {
matches!(elems.get(1), Some(Expr::List(params))
if params.iter().any(|p| matches!(p, Expr::Symbol(s) if s == name)))
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::Fraction;
fn sym(s: &str) -> Expr {
Expr::Symbol(s.to_string())
fn list(items: Vec<Expr>) -> Expr {
Expr::List(items)
fn num(n: i64) -> Expr {
Expr::Number(Fraction::from_integer(n))
fn pair(elem: PairElement) -> Option<WasmType> {
Some(WasmType::PairRef(elem))
#[test]
fn cons_cdr_literal_car_is_that_cell() {
let body = list(vec![sym("CONS"), num(1), sym("acc")]);
assert_eq!(pinned_param_type(&body, "acc"), pair(PairElement::Ratio));
fn cons_cdr_nonliteral_car_widens_to_anyref() {
let body = list(vec![sym("CONS"), sym("x"), sym("acc")]);
assert_eq!(pinned_param_type(&body, "acc"), pair(PairElement::AnyRef));
assert_eq!(pinned_param_type(&body, "x"), None);
fn no_cons_cdr_use_is_unconstrained() {
let body = list(vec![sym("*"), sym("x"), num(2)]);
fn string_native_arg_pins_stringref() {
// (string= s "x") → s is a string.
let body = list(vec![sym("STRING="), sym("s"), Expr::String("x".into())]);
assert_eq!(pinned_param_type(&body, "s"), Some(WasmType::StringRef));
fn quoted_string_native_does_not_pin() {
// (quote (string= x "a")) — quoted data must NOT pin `x` to StringRef.
let body = list(vec![
sym("quote"),
list(vec![sym("string="), sym("x"), Expr::String("a".into())]),
]);
fn quasiquoted_string_native_does_not_pin() {
sym("quasiquote"),
fn shadowing_let_body_does_not_retype_outer_param() {
// (let ((x nil)) (cons 1 x)) — cdr is the INNER `x`; outer stays None.
let inner = list(vec![
sym("LET"),
list(vec![list(vec![sym("x"), Expr::Nil])]),
list(vec![sym("CONS"), num(1), sym("x")]),
assert_eq!(pinned_param_type(&inner, "x"), None);
fn let_initializer_cons_cdr_is_still_outer_scope() {
// (let ((acc (cons 1 acc))) acc) — the init `(cons 1 acc)` references the
// OUTER `acc` (the binding isn't in scope for its own init), so `acc` IS
// a list accumulator. Scanning must reach the init even though the let
// rebinds `acc`.
let form = list(vec![
list(vec![list(vec![
sym("acc"),
list(vec![sym("CONS"), num(1), sym("acc")]),
])]),
assert_eq!(pinned_param_type(&form, "acc"), pair(PairElement::Ratio));
fn dolist_list_expr_cons_cdr_is_outer_scope() {
// (dolist (e (cons 1 acc)) e) — the list expr references outer `acc`.
sym("DOLIST"),
list(vec![sym("e"), list(vec![sym("CONS"), num(1), sym("acc")])]),
sym("e"),
fn lambda_binder_shadows_outer_param() {
list(vec![
sym("LAMBDA"),
list(vec![sym("x")]),
]),
sym("y"),
fn short_binder_body_does_not_panic_on_slice() {
// Regression (AFL): a too-short binder form — `(do)`, `(let)`,
// `(lambda)` — used to index `&elems[2..]` / `&elems[1..]` out of range
// and panic. Param inference is best-effort, so a malformed form must
// contribute no pin (return None), never crash.
for head in ["DO", "DO*", "LET", "LET*", "DOLIST", "LAMBDA"] {
let bare = list(vec![sym(head)]);
assert_eq!(pinned_param_type(&bare, "x"), None, "bare ({head})");
// Nested under a lambda body, mirroring the fuzz-found shape
// `(lambda (s) (do))`.
let nested = list(vec![sym("DO")]);
assert_eq!(pinned_param_type(&nested, "s"), None);
fn literal_car_cells_map_to_their_slots() {
assert_eq!(car_cell_element(&num(3)), PairElement::Ratio);
assert_eq!(car_cell_element(&Expr::Bool(true)), PairElement::Bool);
assert_eq!(
car_cell_element(&Expr::String("s".into())),
PairElement::StringRef
);
assert_eq!(car_cell_element(&sym("v")), PairElement::AnyRef);