Skip to main content

elly_core/
eval.rs

1//! Naive tree-walking evaluator for the first Elly subset.
2//!
3//! Uses call-by-value evaluation. Environments are persistent linked lists of
4//! bindings shared via `Rc` for efficient closure capture.
5
6use alloc::boxed::Box;
7use alloc::rc::Rc;
8use alloc::vec::Vec;
9
10use rpds::{HashTrieMap, Vector};
11
12use crate::ast::{recur_body, Captures, Expr, ModuleData, Pattern, Text};
13use crate::builtins;
14
15pub use crate::builtins::Builtin;
16pub use crate::eval_env::{Ctx, Env, EnvNode, EnvRef};
17use crate::eval_match::match_pattern;
18pub use crate::value::{HostCall, HostFn, ObjectData, Value};
19
20/// Evaluates the `__main` body of a module instance in its own environment.
21/// Returns `Ok(None)` if no body is declared (e.g., a library).
22///
23/// This implements "runnable" module semantics: [`crate::load_module`] instantiates
24/// modules without evaluating their bodies, ensuring `__main` is only executed
25/// when explicitly targeted by a runner.
26///
27/// The body executes in the instance's [`Module`](EnvNode::Module) terminal.
28/// As it is not a member, it has no reserved ID. Non-module instances raise
29/// `.not_a_module`. Result interpretation is handled by the runner; see
30/// `docs/done/2026-08-14_elly-run.md`.
31pub fn eval_main(instance: &Value, ctx: &Ctx) -> Result<Option<Value>, Raised> {
32    let Value::Module(home) = instance else {
33        return Err(Raised::symbol("not_a_module"));
34    };
35    let EnvNode::Module { data, .. } = &**home else {
36        unreachable!("a module value always holds a module terminal")
37    };
38    let Some(body) = data.body() else {
39        return Ok(None);
40    };
41    let result = eval_env(body, &Env::from_ref(home.clone()), ctx)?;
42    Ok(Some(result))
43}
44
45/// Value unwinding through the single error channel (see `docs/elly-spec.md`).
46///
47/// - `Error(v)`: Genuine failure (host abort or `__Err.raise v`). Propagates
48///   past `__match`.
49/// - `NoMatch(v)`: Pattern refutation from `match_pattern`. Only `__match` catches
50///   this, falling through to its fallback. Other boundaries treat it as `Error(v)`,
51///   surfacing the original error `v`.
52///
53/// Kinds are distinguished by construction; user code can only raise `Error`,
54/// preventing forged `NoMatch` signals.
55#[derive(Debug, Clone)]
56pub enum Raised {
57    /// A genuine error, propagated by `__match`.
58    Error(Value),
59    /// A pattern refutation carrying the original error it decays to when uncaught.
60    NoMatch(Value),
61}
62
63impl Raised {
64    /// carrying `tag` (stored without its dot) as the *original* error it stands for.
65    /// `__match` catches it and falls through; uncaught, it decays to `.tag`.
66    pub(crate) fn no_match(tag: &'static str) -> Raised {
67        Raised::NoMatch(Value::Symbol(Text::from_static(tag)))
68    }
69
70    /// Raise one of the host-failure symbol tags (stored without its leading dot) as
71    /// a genuine `Error`. The tag is a `'static` literal, so it becomes a
72    /// zero-cost borrowed [`Text`] via `from_static`.
73    pub(crate) fn symbol(tag: &'static str) -> Raised {
74        Raised::Error(Value::Symbol(Text::from_static(tag)))
75    }
76
77    /// Returns the carried payload regardless of the `Raised` kind.
78    pub fn value(&self) -> &Value {
79        match self {
80            Raised::Error(v) | Raised::NoMatch(v) => v,
81        }
82    }
83
84    /// Consumes the `Raised` value into its payload.
85    pub fn into_value(self) -> Value {
86        match self {
87            Raised::Error(v) | Raised::NoMatch(v) => v,
88        }
89    }
90}
91
92/// Evaluate an expression in the empty environment.
93///
94/// The [`Ctx`] threaded through evaluation carries the per-runtime **unique-id
95/// source**: each `&`-abstraction stamps its closure with the next id, giving
96/// callables an identity for equality/order.
97pub fn eval(expr: &Expr) -> Result<Value, Raised> {
98    let ctx = Ctx::new();
99    eval_env(expr, &Env::EMPTY, &ctx)
100}
101
102/// Apply a value to a single argument — the public entry point behind a host's
103/// "call this value" surface (e.g. the Python bindings' `elly.Fun.__call__`).
104///
105/// This is the same application step the evaluator uses internally (`apply_n`
106/// with one argument): a closure call, list projection, or one step toward
107/// saturating a builtin. Since values are `'static`-owned, `f` and `arg` may come
108/// from different programs. A fresh [`Ctx`] is used, mirroring [`eval`], so any
109/// closures minted while running the application are stamped from its id source.
110/// Use [`apply_with`] to apply under a context whose ids continue an earlier
111/// one's.
112pub fn apply(f: &Value, arg: Value) -> Result<Value, Raised> {
113    apply_with(f, arg, &Ctx::new())
114}
115
116/// Applies a value to a single argument using a caller-supplied [`Ctx`].
117/// This ensures identities minted by the call continue the provided context's
118/// ID source rather than restarting at zero. Required for hosts that manage
119/// callables across multiple `apply` calls to avoid ID collisions.
120pub fn apply_with(f: &Value, arg: Value, ctx: &Ctx) -> Result<Value, Raised> {
121    apply1(f.clone(), arg, ctx)
122}
123
124/// Evaluates an expression in a given environment and context.
125/// Public entry point for host evaluation (e.g., `elly.Env.eval`).
126pub fn eval_env(expr: &Expr, env: &Env, ctx: &Ctx) -> Result<Value, Raised> {
127    match expr {
128        // Resolved reference: look up value by index.
129        Expr::Local { index, .. } => Ok(env.lookup(*index)),
130        // Resolver ensures all names are rewritten to `Expr::Local` before evaluation.
131        Expr::Name(n) => unreachable!("unresolved name {:?} reached eval", n),
132        // Module sibling reference (see [`mod_item`]).
133        Expr::ModItem { index, depth, .. } => mod_item(env, *index, *depth, ctx),
134        // Home-module leaf (see [`home_leaf`]).
135        Expr::Home { leaf, depth } => env.home_leaf(*leaf, *depth),
136        // Recursive binding group (see [`recur`]).
137        Expr::Recur(r) => recur(r, env, ctx),
138        // Block evaluation (see [`eval_block`]). Outlined to optimize I-cache.
139        Expr::Block(clauses) => eval_block(clauses, env, ctx),
140        // Builtin operation. 0-arity builtins execute immediately; others return
141        // a `Builtin` awaiting arguments.
142        Expr::Builtin(op) => {
143            if op.arity() == 0 {
144                builtins::invoke(*op, &[], ctx)
145            } else {
146                // Avoid `with_capacity` to prevent unnecessary allocations during
147                // curried application cloning.
148                Ok(Value::Builtin {
149                    op: *op,
150                    args: Vec::new(),
151                })
152            }
153        }
154        Expr::Int(n) => Ok(Value::from_bigint_ref(n)),
155        Expr::Symbol(s) => Ok(Value::Symbol(s.clone())),
156        // String handles are shared and pre-decoded.
157        Expr::Str(s) => Ok(Value::Str(s.clone())),
158        Expr::Unit => Ok(Value::Unit),
159        Expr::List(elems) => {
160            let mut vs = Vector::new();
161            for e in elems {
162                let elem = eval_env(e, env, ctx)?;
163                vs.push_back_mut(elem);
164            }
165            Ok(Value::List(vs))
166        }
167        // Map literal: evaluate keys and values in source order. Later entries
168        // with the same key overwrite previous ones.
169        Expr::Map(entries) => {
170            let mut m = HashTrieMap::new();
171            for (k, v) in entries {
172                let kv = eval_env(k, env, ctx)?;
173                let vv = eval_env(v, env, ctx)?;
174                m.insert_mut(kv, vv);
175            }
176            Ok(Value::Map(m))
177        }
178        // Closure creation: captures free variables according to the resolution plan.
179        // `Captures::Chain` shares the environment; `Captures::Frame` creates a new frame.
180        Expr::Abs(code) => {
181            let env = match &code.captures {
182                Captures::Chain => env.clone(),
183                Captures::Frame(plan) => env.capture_env(plan, ctx),
184                Captures::Unresolved => unreachable!("unresolved abstraction reached eval"),
185            };
186            Ok(Value::Closure {
187                id: ctx.next_id(),
188                code: Rc::clone(code),
189                applied: 0,
190                env,
191            })
192        }
193        // Call-by-value application. Callee and arguments are evaluated strictly
194        // left-to-right. Small arities (≤ 4) are handled on the stack to avoid
195        // heap allocation for the argument buffer.
196        Expr::App(items) => {
197            let callee = eval_env(&items[0], env, ctx)?;
198            let arg_exprs = &items[1..];
199            match arg_exprs {
200                [a] => {
201                    let arg = eval_env(a, env, ctx)?;
202                    apply1(callee, arg, ctx)
203                }
204                [a, b] => {
205                    let va = eval_env(a, env, ctx)?;
206                    let vb = eval_env(b, env, ctx)?;
207                    apply_n(callee, &[va, vb], ctx)
208                }
209                [a, b, c] => {
210                    let va = eval_env(a, env, ctx)?;
211                    let vb = eval_env(b, env, ctx)?;
212                    let vc = eval_env(c, env, ctx)?;
213                    apply_n(callee, &[va, vb, vc], ctx)
214                }
215                [a, b, c, d] => {
216                    let va = eval_env(a, env, ctx)?;
217                    let vb = eval_env(b, env, ctx)?;
218                    let vc = eval_env(c, env, ctx)?;
219                    let vd = eval_env(d, env, ctx)?;
220                    apply_n(callee, &[va, vb, vc, vd], ctx)
221                }
222                _ => {
223                    let mut argv: Vec<Value> = Vec::with_capacity(arg_exprs.len());
224                    for a in arg_exprs {
225                        argv.push(eval_env(a, env, ctx)?);
226                    }
227                    apply_n(callee, &argv, ctx)
228                }
229            }
230        }
231        // Subject is evaluated once, then arms are tried in order.
232        Expr::Case { subject, cases } => {
233            let subj = eval_env(subject, env, ctx)?;
234            run_arms(&subj, cases, env, ctx)
235        }
236    }
237}
238
239/// Tries case arms in order. `NoMatch` refutations fall through; other errors propagate.
240/// If no arms match, `.no_match` is raised.
241#[inline(never)]
242fn run_arms(
243    subj: &Value,
244    cases: &[(Pattern, Expr)],
245    env: &Env,
246    ctx: &Ctx,
247) -> Result<Value, Raised> {
248    for (pat, body) in cases {
249        match match_pattern(pat, subj, env.clone(), ctx) {
250            // Not released explicitly: only 0.05% of node deaths happen here, and
251            // holding the result across a release grows this recursive frame.
252            Ok(arm_env) => return eval_env(body, &arm_env, ctx),
253            Err(Raised::NoMatch(_)) => continue,
254            Err(e) => return Err(e),
255        }
256    }
257    Err(Raised::no_match("no_match"))
258}
259
260/// Specialized path for single-argument application. Used by the call spine,
261/// fold builtins, and match clauses. Binds one parameter, produces a partial
262/// closure, gathers a builtin argument, or projects a list.
263pub(crate) fn apply1(fv: Value, av: Value, ctx: &Ctx) -> Result<Value, Raised> {
264    match fv {
265        Value::Closure {
266            code, applied, env, ..
267        } => {
268            let start = applied as usize;
269            // Bind argument and extend environment.
270            let call_env = match_pattern(&code.head[start], &av, env, ctx)?;
271            if start + 1 < code.head.len() {
272                // Return partial closure with updated applied count and environment.
273                return Ok(Value::Closure {
274                    id: ctx.next_id(),
275                    code,
276                    applied: applied + 1,
277                    env: call_env,
278                });
279            }
280            let result = eval_env(&code.body, &call_env, ctx);
281            ctx.release(call_env);
282            result
283        }
284        Value::Builtin {
285            op,
286            args: mut gathered,
287        } => {
288            // Unary builtins saturate from the stack.
289            if gathered.is_empty() && op.arity() == 1 {
290                return builtins::invoke(op, &[av], ctx);
291            }
292            gathered.push(av);
293            if gathered.len() == op.arity() {
294                builtins::invoke(op, &gathered, ctx)
295            } else {
296                Ok(Value::Builtin { op, args: gathered })
297            }
298        }
299        Value::HostFn {
300            code,
301            args: gathered,
302        } => {
303            if gathered.is_empty() && code.arity == 1 {
304                return (code.f)(&[av], ctx);
305            }
306            let gathered = extended(&gathered, &[av]);
307            if gathered.len() == code.arity {
308                (code.f)(&gathered, ctx)
309            } else {
310                Ok(Value::HostFn {
311                    code,
312                    args: gathered,
313                })
314            }
315        }
316        // Materialize item when a module is applied to a symbol.
317        Value::Module(home) => match av {
318            Value::Symbol(s) => materialize(&Env::from_ref(home), &s, ctx),
319            _ => Err(Raised::symbol("not_applicable")),
320        },
321        // Dispatch via prototype for all other types.
322        fv => receiver(fv, &av, &[], ctx),
323    }
324}
325
326/// Applies a value to multiple arguments. Handles closures (multi-parameter
327/// matching), builtins (gather until saturation), and list projections.
328/// Over-application results loop onto the returned value.
329///
330/// Arguments are evaluated strictly left-to-right before matching patterns
331/// left-to-right, ensuring evaluation errors surface before pattern refutations.
332/// See `docs/done/2026-08-02_perf-less-currying.md` §3.
333fn apply_n(mut fv: Value, mut args: &[Value], ctx: &Ctx) -> Result<Value, Raised> {
334    while !args.is_empty() {
335        match fv {
336            // Matches arguments against remaining patterns left-to-right.
337            // If arguments are exhausted, returns a partial closure with a fresh ID.
338            // Otherwise, executes the body and loops leftover arguments onto the result.
339            // Pattern misses raise `NoMatch`.
340            Value::Closure {
341                code, applied, env, ..
342            } => {
343                let start = applied as usize;
344                let remaining = code.head.len() - start;
345                let take = remaining.min(args.len());
346                // Thread the env through each parameter match, consing bindings
347                // onto the closure's captured (or partially-applied) env.
348                let mut call_env = env;
349                for (pat, arg) in code.head[start..start + take].iter().zip(&args[..take]) {
350                    call_env = match_pattern(pat, arg, call_env, ctx)?;
351                }
352                if take < remaining {
353                    // Partial: no body runs yet; mint a fresh identity.
354                    return Ok(Value::Closure {
355                        id: ctx.next_id(),
356                        code,
357                        applied: applied + take as u8,
358                        env: call_env,
359                    });
360                }
361                let result = eval_env(&code.body, &call_env, ctx);
362                ctx.release(call_env);
363                fv = result?;
364                args = &args[take..];
365            }
366            // Gathers arguments and invokes once saturated, looping leftovers onto the result.
367            // If the argument spine satisfies the arity immediately, it invokes directly
368            // from the slice to avoid `Value::Builtin` allocation.
369            Value::Builtin {
370                op,
371                args: mut gathered,
372            } => {
373                let arity = op.arity();
374                if gathered.is_empty() && args.len() >= arity {
375                    fv = builtins::invoke(op, &args[..arity], ctx)?;
376                    args = &args[arity..];
377                } else {
378                    let take = (arity - gathered.len()).min(args.len());
379                    gathered.extend(args[..take].iter().cloned());
380                    if gathered.len() < arity {
381                        return Ok(Value::Builtin { op, args: gathered });
382                    }
383                    fv = builtins::invoke(op, &gathered, ctx)?;
384                    args = &args[take..];
385                }
386            }
387            // Similar gather-and-fire loop as builtins, dispatching via the host callback.
388            Value::HostFn {
389                code,
390                args: gathered,
391            } => {
392                let arity = code.arity;
393                if gathered.is_empty() && args.len() >= arity {
394                    fv = (code.f)(&args[..arity], ctx)?;
395                    args = &args[arity..];
396                } else {
397                    let take = (arity - gathered.len()).min(args.len());
398                    let gathered = extended(&gathered, &args[..take]);
399                    if gathered.len() < arity {
400                        return Ok(Value::HostFn {
401                            code,
402                            args: gathered,
403                        });
404                    }
405                    fv = (code.f)(&gathered, ctx)?;
406                    args = &args[take..];
407                }
408            }
409            // Materializes an item when applied to a symbol; leftovers loop onto the result.
410            Value::Module(home) => {
411                match &args[0] {
412                    Value::Symbol(s) => fv = materialize(&Env::from_ref(home), s, ctx)?,
413                    _ => return Err(Raised::symbol("not_applicable")),
414                }
415                args = &args[1..];
416            }
417            // Dispatches by passing the entire remaining argument spine to the
418            // method in one call (see [`receiver`]).
419            fv => return receiver(fv, &args[0], &args[1..], ctx),
420        }
421    }
422    Ok(fv)
423}
424
425/// Concatenates `prefix` and `more` into a single allocation.
426/// Used by [`Value::HostFn`] for efficient argument gathering during partial application.
427fn extended(prefix: &[Value], more: &[Value]) -> Box<[Value]> {
428    let mut all = Vec::with_capacity(prefix.len() + more.len());
429    all.extend_from_slice(prefix);
430    all.extend_from_slice(more);
431    all.into_boxed_slice()
432}
433
434/// Returns the prototype of a value. Objects use their own prototype; others
435/// use the corresponding builtin module, providing a nominal type for all values.
436///
437/// Iotas resolve to their declaring module, allowing them to be treated as
438/// objects of that module. Callables (closures, builtins, host functions)
439/// resolve to `__Fun`.
440pub(crate) fn proto_of(v: &Value, ctx: &Ctx) -> Value {
441    match v {
442        Value::Object(obj) => obj.proto(),
443        Value::Iota { home, .. } => Value::Module(home.clone()),
444        Value::I64(_) | Value::BigInt(_) => ctx.builtin_module(Builtin::Int),
445        Value::Symbol(_) => ctx.builtin_module(Builtin::Sym),
446        Value::Str(_) => ctx.builtin_module(Builtin::Str),
447        Value::Unit => ctx.builtin_module(Builtin::Unit),
448        Value::List(_) => ctx.builtin_module(Builtin::List),
449        Value::Map(_) => ctx.builtin_module(Builtin::Map),
450        Value::Closure { .. } | Value::Builtin { .. } | Value::HostFn { .. } => {
451            ctx.builtin_module(Builtin::Fun)
452        }
453        Value::Module(_) => ctx.builtin_module(Builtin::Mod),
454    }
455}
456
457/// Whether `value`'s prototype is the module `expr` names — the general form of an
458/// `as <Proto>` pattern, where [`ProtoRef::Kind`] is the compiled one. A reference
459/// that is not a module raises `.not_a_module`: it is a mistake about the pattern
460/// rather than about the subject, so it is a real error and not a refutation.
461///
462/// Outlined for the reason [`receiver`] is: `match_pattern` is on every call's
463/// path, and its arms pay for each other's code size.
464#[inline(never)]
465pub(crate) fn proto_is(expr: &Expr, value: &Value, env: &Env, ctx: &Ctx) -> Result<bool, Raised> {
466    let expected = eval_env(expr, env, ctx)?;
467    if !matches!(expected, Value::Module(_)) {
468        return Err(Raised::symbol("not_a_module"));
469    }
470    Ok(proto_of(value, ctx) == expected)
471}
472
473/// Resolves a module sibling reference by walking to the home module and
474/// materializing the item at `index`.
475///
476/// The index is assigned during resolution against the module's sorted table,
477/// enabling direct slice access. Only module-item bodies contain `ModItem`,
478/// and they always run under a `Module` home terminal, which is found by
479/// walking through captured frames.
480///
481/// Outlined to maintain I-cache residency of `eval_env`.
482///
483/// If the index is past the item table, it is treated as an iota, which is
484/// derived as a pair of the terminal and position.
485///
486/// `depth` specifies how many terminals to skip to find the owning module.
487/// A `recur` group nested in a module body adds a terminal to the chain, requiring
488/// a skip for the enclosing module's items.
489#[inline(never)]
490fn mod_item(env: &Env, index: u32, depth: u32, ctx: &Ctx) -> Result<Value, Raised> {
491    let (env, home, data, id_base) = env
492        .find_module(depth)
493        .ok_or_else(|| Raised::symbol("no_module"))?;
494    let Some(body) = data.item(index) else {
495        // Handle iotas: derived from terminal identity and index.
496        let index = index - data.len() as u32;
497        if data.iota(index).is_none() {
498            return Err(Raised::symbol("missing_property"));
499        }
500        return Ok(Value::Iota {
501            home: home.clone(),
502            index,
503        });
504    };
505    materialize_body(body, env, id_base, index, ctx)
506}
507
508/// Evaluates a `recur` group by creating a `Module` terminal over the
509/// current environment and executing the body.
510///
511/// This is instantiation with a cons-shaped frame, avoiding copies by
512/// sharing the lexical environment. ID blocks are reserved per evaluation,
513/// ensuring different activations of the same `recur` group have distinct
514/// item identities. Bindings are materialized on reference.
515#[inline(never)]
516fn recur(group: &Rc<ModuleData>, env: &Env, ctx: &Ctx) -> Result<Value, Raised> {
517    let home = EnvRef::new(EnvNode::Module {
518        id_base: ctx.reserve_ids(group.len() as u64),
519        data: Rc::clone(group),
520        frame: env.clone(),
521    });
522    eval_env(recur_body(group), &Env::from_ref(home), ctx)
523}
524
525/// Evaluates a block by threading the environment through clauses.
526/// Bindings extend the environment for subsequent clauses; other clauses
527/// execute for effect. The value of the final clause is returned.
528fn eval_block(clauses: &[(Option<Pattern>, Expr)], env: &Env, ctx: &Ctx) -> Result<Value, Raised> {
529    let Some((last, leading)) = clauses.split_last() else {
530        return Ok(Value::Unit);
531    };
532    let mut env = env.clone();
533    for clause in leading {
534        match clause {
535            (Some(pat), value) => {
536                let v = eval_env(value, &env, ctx)?;
537                env = match_pattern(pat, &v, env, ctx)?;
538            }
539            (None, value) => {
540                eval_env(value, &env, ctx)?;
541            }
542        }
543    }
544    match last {
545        (None, value) => {
546            let result = eval_env(value, &env, ctx);
547            ctx.release(env);
548            result
549        }
550        (Some(pat), value) => {
551            let v = eval_env(value, &env, ctx)?;
552            ctx.release(match_pattern(pat, &v, env, ctx)?);
553            Ok(v)
554        }
555    }
556}
557
558/// Instantiates a compiled module, creating its [`Module`](EnvNode::Module)
559/// terminal and returning it as a [`Value::Module`].
560///
561/// This assigns the module an identity (fresh `Rc`) and reserves its ID block
562/// so items can be stamped with stable IDs (see `materialize_body`).
563///
564/// `frame` provides outer values against [`ModuleData::frame_names`]. A length
565/// mismatch raises `.module_arity`. The frame is stored as a flat [`Frame`](EnvNode::Frame),
566/// though `recur` may build the terminal directly.
567pub fn instantiate(data: Rc<ModuleData>, frame: &[Value], ctx: &Ctx) -> Result<Value, Raised> {
568    if frame.len() != data.frame_names().len() {
569        return Err(Raised::symbol("module_arity"));
570    }
571    let id_base = ctx.reserve_ids(data.len() as u64);
572    let frame = if frame.is_empty() {
573        Env::EMPTY
574    } else {
575        Env::EMPTY.frame(Box::from(frame))
576    };
577    Ok(Value::Module(EnvRef::new(EnvNode::Module {
578        data,
579        id_base,
580        frame,
581    })))
582}
583
584/// Evaluates an item's body in its module terminal. This is the common
585/// path for [`mod_item`] and [`materialize`].
586///
587/// Literal abstraction bodies are built directly with the item's reserved
588/// ID (`id_base + index`), ensuring multiple accesses produce equal closures.
589/// This avoids `eval_env` dispatch and `Ctx` counter writes for performance.
590///
591/// Only function definitions claim the item's identity. Forwarded callables
592/// retain their original identity, while data items or nested closures are
593/// processed via `eval_env`.
594#[inline]
595fn materialize_body(
596    body: &Expr,
597    env: &Env,
598    id_base: u64,
599    index: u32,
600    ctx: &Ctx,
601) -> Result<Value, Raised> {
602    if let Expr::Abs(code) = body {
603        let env1 = match &code.captures {
604            Captures::Chain => env.clone(),
605            Captures::Frame(plan) => env.capture_env(plan, ctx),
606            Captures::Unresolved => unreachable!("unresolved abstraction reached eval"),
607        };
608        return Ok(Value::Closure {
609            id: id_base + index as u64,
610            code: Rc::clone(code),
611            applied: 0,
612            env: env1,
613        });
614    }
615    eval_env(body, env, ctx)
616}
617
618/// Materializes the item named `name` from the module object `home`.
619/// Binary-searches for the index and evaluates the body in the terminal.
620/// Uses the object's own node as the terminal to avoid environment allocation.
621/// Raises `.missing_property` if the name is absent.
622fn materialize(env: &Env, name: &Text, ctx: &Ctx) -> Result<Value, Raised> {
623    let (moddata, id_base) = env
624        .as_module()
625        .expect("a module object always holds a module terminal");
626    let index = moddata
627        .index_of(name)
628        .ok_or_else(|| Raised::symbol("missing_property"))?;
629    let body = moddata.item(index).expect("index_of returned a live index");
630    materialize_body(body, env, id_base, index, ctx)
631}
632
633/// Applies a receiver to a symbol. This is the dispatch path for all non-callable,
634/// non-module values. The symbol identifies a member of the receiver's prototype,
635/// and the receiver is passed as an argument. [`proto_of`] ensures this is total.
636///
637/// List projection takes precedence: numeric symbols on lists project indices.
638/// Other symbols dispatch to `__List`.
639///
640/// Non-symbol arguments raise `.not_applicable`.
641///
642/// Outlined to maintain I-cache residency in `apply1` and `apply_n`.
643#[inline(never)]
644fn receiver(this: Value, name: &Value, rest: &[Value], ctx: &Ctx) -> Result<Value, Raised> {
645    let Value::Symbol(s) = name else {
646        return Err(Raised::symbol("not_applicable"));
647    };
648    if let Value::List(elems) = &this {
649        if is_index(s) {
650            let projected = project(elems, s)?;
651            return apply_n(projected, rest, ctx);
652        }
653    }
654    let proto = if let Value::Object(obj) = &this {
655        obj.proto.clone()
656    } else if let Value::Module(home) = proto_of(&this, ctx) {
657        home
658    } else {
659        unreachable!("a prototype is always a module value")
660    };
661    dispatch(proto, this, s, rest, ctx)
662}
663
664/// Dispatches a method by materializing `name` from `proto` and applying it
665/// to the receiver and any remaining arguments.
666///
667/// Example: `x .m a b` is equivalent to `(materialize (proto of x) .m) x a b`.
668///
669/// The lookup is a binary search over the item table, ensuring locals are private.
670/// Arguments are handled similarly to the call spine in `eval_env`, with small
671/// arities processed on the stack.
672fn dispatch(
673    proto: EnvRef,
674    this: Value,
675    name: &Text,
676    rest: &[Value],
677    ctx: &Ctx,
678) -> Result<Value, Raised> {
679    let method = materialize(&Env::from_ref(proto), name, ctx)?;
680    match rest {
681        [] => apply1(method, this, ctx),
682        [a] => apply_n(method, &[this, a.clone()], ctx),
683        [a, b] => apply_n(method, &[this, a.clone(), b.clone()], ctx),
684        [a, b, c] => apply_n(method, &[this, a.clone(), b.clone(), c.clone()], ctx),
685        _ => {
686            let mut all: Vec<Value> = Vec::with_capacity(rest.len() + 1);
687            all.push(this);
688            all.extend_from_slice(rest);
689            apply_n(method, &all, ctx)
690        }
691    }
692}
693
694/// Whether `sym` (a symbol's text, stored without its dot) is a **projection**
695/// index — all ASCII digits, so `.0` and `.12` are projections while `.get` and
696/// `.size` are member names. The two spaces are disjoint by shape, which is what
697/// lets a list carry both (see [`receiver`]).
698fn is_index(sym: &str) -> bool {
699    !sym.is_empty() && sym.bytes().all(|b| b.is_ascii_digit())
700}
701
702/// Projects the element at index `sym` from a list.
703fn project(elems: &Vector<Value>, sym: &str) -> Result<Value, Raised> {
704    let idx: usize = sym.parse().map_err(|_| Raised::symbol("bad_projection"))?;
705    elems
706        .get(idx)
707        .cloned()
708        .ok_or_else(|| Raised::symbol("projection_out_of_range"))
709}