Skip to main content

elly_core/eval/
env.rs

1//! Evaluation environment: the persistent [`EnvNode`] chain, and the realm's
2//! free list of environment cells, which [`Ctx::release`] fills and
3//! [`Ctx::alloc`] reuses. The tree-walker in `eval/mod.rs`
4//! reads bindings via [`lookup`], conses them with [`bind`], and captures closures
5//! via [`capture_env`].
6
7use alloc::boxed::Box;
8use alloc::rc::Rc;
9use alloc::vec::Vec;
10
11use crate::builtins::Builtin;
12use crate::code::{Capture, ChunkId, ModId};
13use crate::sym::Sym;
14use crate::value::Value;
15use crate::{Ctx, HomeLeaf, Raised};
16
17/// A shared, persistent environment: a nameless cons list of the running
18/// activation's bindings (innermost first) on top of the closure's captured
19/// [`Frame`](EnvNode::Frame). A resolved reference ([`Node::Local`](crate::code::Node::Local))
20/// reaches its value by walking its de Bruijn `index` over `next` links and
21/// cloning the cell's value — a pointer chase without name comparison or
22/// per-link cloning. Indices beyond the consed cells land in the frame array.
23/// The resolver (`resolve.rs`) assigns each reference the exact number of cells
24/// between it and its binder.
25#[derive(Debug)]
26#[cfg_attr(not(feature = "stats-env"), derive(Clone))]
27pub struct Env(Option<EnvRef>);
28
29/// With `stats-env`, cloning passes the caller's location on to [`EnvRef`]'s clone,
30/// which a derived impl (through `Option::clone`) would lose.
31#[cfg(feature = "stats-env")]
32impl Clone for Env {
33    #[track_caller]
34    #[inline]
35    fn clone(&self) -> Self {
36        match &self.0 {
37            Some(node) => Env(Some(node.clone())),
38            None => Env(None),
39        }
40    }
41}
42
43impl Env {
44    pub const EMPTY: Env = Env(None);
45
46    /// Cons one binding of `val` onto `env`.
47    pub fn bind(self, val: Value) -> Self {
48        Self(Some(EnvRef::new(EnvNode::Cons { val, next: self })))
49    }
50
51    /// [`bind`](Self::bind) in the evaluator: the cell comes from `ctx`'s free
52    /// list when it has one.
53    #[inline]
54    pub(crate) fn bind_in(self, val: Value, ctx: &Ctx) -> Self {
55        Self(Some(ctx.alloc(EnvNode::Cons { val, next: self })))
56    }
57
58    /// Cons one binding of `val` onto `env`.
59    pub(crate) fn frame(self, vals: Box<[Value]>) -> Self {
60        Self(Some(EnvRef::new(EnvNode::Frame { vals, home: self })))
61    }
62
63    pub(crate) fn from_ref(node: EnvRef) -> Self {
64        Self(Some(node))
65    }
66
67    pub fn clone_ref(&self) -> Option<EnvRef> {
68        self.0.clone()
69    }
70
71    /// The chunk and module of a [`Module`](EnvNode::Module) node, and its id
72    /// block's base.
73    pub(crate) fn as_module(&self) -> Option<(ChunkId, ModId, u64)> {
74        match self.0.as_deref() {
75            Some(EnvNode::Module {
76                chunk,
77                module,
78                id_base,
79                ..
80            }) => Some((*chunk, *module, *id_base)),
81            _ => None,
82        }
83    }
84
85    /// Resolves a de Bruijn `index` to a cloned value by walking `next` links.
86    /// If the walk reaches the [`Frame`], the remaining index selects `vals[rest]`.
87    /// The resolver ensures the index lands on a live cell or frame slot.
88    ///
89    /// [`Module`](EnvNode::Module) terminals are passed through uncounted; a module
90    /// body's reference to an import or prelude value is a `Local` whose index runs
91    /// into the frame.
92    ///
93    /// [`Frame`]: EnvNode::Frame
94    pub(crate) fn lookup(&self, index: u32) -> Value {
95        let mut cur = self;
96        let mut rest = index as usize;
97        loop {
98            match cur
99                .0
100                .as_deref()
101                .expect("resolved index walked past the environment")
102            {
103                EnvNode::Cons { val, next } => {
104                    if rest == 0 {
105                        return val.clone();
106                    }
107                    rest -= 1;
108                    cur = next;
109                }
110                EnvNode::Frame { vals, .. } => match vals.get(rest) {
111                    Some(val) => return val.clone(),
112                    None => unreachable!("resolved index walked past the captured frame"),
113                },
114                EnvNode::Module { frame, .. } => cur = frame,
115            }
116        }
117    }
118
119    /// Returns the environment's terminal: either `None` or the [`Module`](EnvNode::Module)
120    /// node. A closure built here carries it as its frame's `home`, ensuring a module
121    /// item's body can find its module without new allocations.
122    fn find_home(&self) -> Env {
123        let mut cur = self;
124        loop {
125            match cur.0.as_deref() {
126                Some(EnvNode::Cons { next, .. }) => cur = next,
127                Some(EnvNode::Frame { home, .. }) => return home.clone(),
128                Some(EnvNode::Module { .. }) => return cur.clone(),
129                None => return Env::EMPTY,
130            }
131        }
132    }
133
134    /// Walks to the module terminal at `depth` and returns a home-module leaf.
135    ///
136    /// Leaf addresses the terminal itself rather than a member. The constructor `#Self()`
137    /// and `.Self` (unwrapping inner data) are object builtins pre-applied to the module,
138    /// returning a partial application that takes one remaining argument.
139    #[inline(never)]
140    pub(crate) fn home_leaf(&self, leaf: HomeLeaf, depth: u32) -> Result<Value, Raised> {
141        let (_, home, _) = self
142            .find_module(depth)
143            .ok_or_else(|| Raised::no_match(Sym::NO_MODULE))?;
144        let home = Value::Module(home.clone());
145        Ok(match leaf {
146            HomeLeaf::Module => home,
147            HomeLeaf::New => Value::Builtin {
148                op: Builtin::ObjNew,
149                args: alloc::vec![home],
150            },
151            HomeLeaf::Value => Value::Builtin {
152                op: Builtin::ObjValue,
153                args: alloc::vec![home],
154            },
155        })
156    }
157
158    /// The module terminal `depth` terminals out: the environment that ends in
159    /// it, the node itself, and its chunk, module and id block's base.
160    pub(crate) fn find_module(
161        &self,
162        mut depth: u32,
163    ) -> Option<(&Env, &EnvRef, (ChunkId, ModId, u64))> {
164        let mut cur = self;
165        while let Some(node) = &cur.0 {
166            match &**node {
167                EnvNode::Cons { next, .. } => cur = next,
168                EnvNode::Frame { home, .. } => cur = home,
169                EnvNode::Module {
170                    chunk,
171                    module,
172                    id_base,
173                    frame,
174                } => {
175                    if depth == 0 {
176                        return Some((cur, node, (*chunk, *module, *id_base)));
177                    }
178                    depth -= 1;
179                    cur = frame;
180                }
181            }
182        }
183        None
184    }
185
186    /// Builds the environment for a capturing closure from its resolved `plan`.
187    /// Outlined from `eval_env` to optimize instruction cache for deep-recursion
188    /// benchmarks.
189    #[inline(never)]
190    pub(crate) fn capture_env(&self, plan: &[Capture], ctx: &Ctx) -> Self {
191        match plan {
192            [] => self.find_home(),
193            // A one-value frame *is* a `Cons` onto the terminal: index 0 is the capture
194            // and the walk ends there either way. Reusing the node the environment
195            // already has costs one allocation instead of two (node + values), and
196            // retains exactly the one value — worth a case of its own because a single
197            // capture is the common escaping closure (`&v (x x) v` in a Z-combinator
198            // recursion). Two or more go through a frame: the values then share one
199            // allocation and one walk, which conses cannot.
200            [c] => {
201                let (val, next) = lookup_and_home(self, c.outer);
202                next.bind_in(val, ctx)
203            }
204            _ => {
205                let (vals, home) = build_frame_and_home(self, plan);
206                home.frame(vals)
207            }
208        }
209    }
210}
211
212/// A shared reference to an [`EnvNode`]: every handle on a node — an [`Env`] link,
213/// a [`Value::Module`], an iota's or object's prototype — goes through it.
214///
215/// Today it is a bare `Rc`, but as the one place nodes are allocated, cloned and
216/// compared, it is where to hook instrumentation (see `env_stats`) or to try
217/// another representation (a plain `Box`, a pool). It must stay one non-null word
218/// so `Option<EnvRef>` keeps the niche.
219#[derive(Debug)]
220#[cfg_attr(not(feature = "stats-env"), derive(Clone))]
221#[repr(transparent)]
222pub struct EnvRef(Rc<Shared>);
223
224/// What an [`EnvRef`] points at: the node itself, or with `stats-env` the node
225/// plus the highest reference count it has reached.
226#[cfg(not(feature = "stats-env"))]
227type Shared = EnvNode;
228#[cfg(feature = "stats-env")]
229type Shared = crate::eval::env_stats::Tracked;
230
231impl EnvRef {
232    #[inline]
233    pub(crate) fn new(node: EnvNode) -> Self {
234        #[cfg(feature = "stats-env")]
235        let node = crate::eval::env_stats::Tracked::new(node);
236        Self(Rc::new(node))
237    }
238
239    /// Whether `a` and `b` are the same node: the identity of a module instance.
240    #[inline]
241    pub fn ptr_eq(a: &Self, b: &Self) -> bool {
242        Rc::ptr_eq(&a.0, &b.0)
243    }
244
245    /// The node's address, for hashing by identity.
246    #[inline]
247    pub fn as_ptr(this: &Self) -> *const EnvNode {
248        &**this
249    }
250
251    /// Whether this is the node's only reference, so it dies with this handle.
252    #[inline]
253    fn is_unique(&self) -> bool {
254        Rc::strong_count(&self.0) == 1 && Rc::weak_count(&self.0) == 0
255    }
256
257    /// The node, if this is its only reference: it can then be rewritten in
258    /// place, which is how [`Ctx`] recycles the allocation.
259    #[inline]
260    fn unique(&mut self) -> Option<&mut EnvNode> {
261        #[cfg(feature = "stats-env")]
262        return Rc::get_mut(&mut self.0).map(|t| &mut t.node);
263        #[cfg(not(feature = "stats-env"))]
264        Rc::get_mut(&mut self.0)
265    }
266}
267
268impl core::ops::Deref for EnvRef {
269    type Target = EnvNode;
270
271    #[inline]
272    fn deref(&self) -> &EnvNode {
273        #[cfg(feature = "stats-env")]
274        return &self.0.node;
275        #[cfg(not(feature = "stats-env"))]
276        &self.0
277    }
278}
279
280#[cfg(feature = "stats-env")]
281impl Clone for EnvRef {
282    #[track_caller]
283    #[inline]
284    fn clone(&self) -> Self {
285        let rc = Rc::clone(&self.0);
286        crate::eval::env_stats::cloned(
287            &rc.max,
288            Rc::strong_count(&rc),
289            core::panic::Location::caller(),
290        );
291        Self(rc)
292    }
293}
294
295#[cfg(feature = "stats-env")]
296impl Drop for EnvRef {
297    #[inline]
298    fn drop(&mut self) {
299        // A free cell (peak 0) was counted when it was recycled.
300        if Rc::strong_count(&self.0) == 1 && self.0.max.get() != 0 {
301            // A death under `Ctx::release` is explicit; any other is implicit.
302            if !crate::eval::env_stats::freed(self.0.max.get()) {
303                #[cfg(feature = "stats-env-drops")]
304                crate::eval::env_stats::died();
305            }
306        }
307    }
308}
309
310/// One link of the environment list: a single binding carrying its `val` inline,
311/// or the frame that terminates the walk. The empty environment is `None`.
312/// [`Env`] is an `Option<EnvRef>`, utilizing the null-pointer niche to remain
313/// one word wide, avoiding allocation and refcount traffic when ending a chain.
314/// Lambda activations cons one `Cons` per bound name (zero for binder-less heads);
315/// partial application environments share the consed prefix via `Rc`.
316/// References are resolved to de Bruijn indices against this list's shape.
317#[derive(Debug)]
318pub enum EnvNode {
319    Cons {
320        val: Value,
321        next: Env,
322    },
323    /// Module instance terminal: carries the item's module instead of the
324    /// empty terminal, allowing [`Node::ModItem`](crate::code::Node::ModItem) sibling references to resolve
325    /// by walking out (see [`crate::eval::eval_env`]). It binds no name and is not
326    /// counted by de Bruijn `Local` indices. Materialized closures capture an
327    /// environment ending in this node to keep the module alive.
328    ///
329    /// This node is the module instance; [`Value::Module`] is a handle to it,
330    /// created by `instantiate`. `M.name` runs the body in this environment.
331    ///
332    /// `id_base` is the module's reserved ID block: item `i` stamps its closure
333    /// with `id_base + i`, ensuring `M.f == M.f` (see `materialize_body`).
334    ///
335    /// `chunk` and `module` locate the module's code in the realm.
336    ///
337    /// `frame` is the environment the module was instantiated against (imports,
338    /// host prelude, and identities), with slot order fixed by the module's
339    /// frame names. De Bruijn indices walk through this node into
340    /// `frame` without counting the node itself. The frame can be a flat
341    /// [`Frame`](EnvNode::Frame) or a cons chain (`recur`).
342    ///
343    /// This structure ensures temporal acyclicity: a frame holds only values
344    /// existing before the frame, and immutability prevents them from pointing
345    /// back to the frame.
346    Module {
347        chunk: ChunkId,
348        module: ModId,
349        id_base: u64,
350        frame: Env,
351    },
352    /// A closure's captured frame: values of free variables used by the body,
353    /// copied at creation in the order fixed by `Lambda::captures`. This terminates
354    /// the de Bruijn walk: an index beyond the consed cells selects `vals[remaining]`
355    /// (see `lookup`), allowing references to enclosing scopes to avoid walking
356    /// the entire program.
357    ///
358    /// `home` stores the environment terminal (`None` or the [`Module`](EnvNode::Module)
359    /// node) beyond the frame, so `ModItem` references in the body can still find
360    /// the module without new allocations.
361    ///
362    /// Values are stored inline behind the environment's `Rc`, making the frame
363    /// a single allocation.
364    Frame {
365        vals: Box<[Value]>,
366        home: Env,
367    },
368}
369
370impl EnvNode {}
371
372// ---------------------------------------------------------------------------
373// The realm's free list of environment cells
374// ---------------------------------------------------------------------------
375
376/// The most free cells a [`Ctx`] keeps. Beyond it, released nodes go back to the
377/// allocator; the cap bounds what an evaluation holds on to after a deep
378/// recursion unwinds.
379const FREE_CAP: u32 = 4096;
380
381impl Ctx {
382    /// Hands back an environment whose scope ends here. The hot scope ends of
383    /// activation environments (a closure body's, a block's) call this instead of
384    /// dropping implicitly, so that the nodes dying with it become free cells for
385    /// [`alloc`](Self::alloc). Other drops stay implicit and go to the allocator.
386    /// See `docs/done/2026-09-29_elly-perf-env-stats.md`.
387    #[inline]
388    pub(crate) fn release(&self, env: Env) {
389        #[cfg(feature = "stats-env")]
390        crate::eval::env_stats::releasing(|| self.release_inner(env));
391        #[cfg(not(feature = "stats-env"))]
392        self.release_inner(env);
393    }
394
395    #[inline]
396    fn release_inner(&self, env: Env) {
397        // A shared node only loses a reference: nothing to recycle.
398        if let Some(node) = env.0 {
399            if node.is_unique() {
400                self.recycle(node);
401            }
402        }
403    }
404
405    /// Walks down from `node` while each node dies with the reference in hand,
406    /// rewriting it in place as a free cell (every variant shares the one `Rc`
407    /// allocation) and following its link: `next`, a frame's `home`, a module's
408    /// `frame`. The rest of a node (its value, a frame's slice, a module's data)
409    /// is dropped normally. Stops at a shared node or a full list, which then
410    /// drops as usual.
411    #[inline(never)]
412    fn recycle(&self, node: EnvRef) {
413        let mut free = self.free.replace(Env::EMPTY);
414        let mut len = self.free_len.get();
415        let mut cur = Some(node);
416        while let Some(mut node) = cur {
417            if len >= FREE_CAP {
418                break;
419            }
420            let Some(slot) = node.unique() else { break };
421            let free_cell = EnvNode::Cons {
422                val: Value::Unit,
423                next: free,
424            };
425            let old = core::mem::replace(slot, free_cell);
426            #[cfg(feature = "stats-env")]
427            node.0.recycled();
428            free = Env(Some(node));
429            len += 1;
430            cur = match old {
431                EnvNode::Cons { val, next } => {
432                    drop(val);
433                    next.0
434                }
435                EnvNode::Frame { vals, home } => {
436                    drop(vals);
437                    home.0
438                }
439                EnvNode::Module { frame, .. } => frame.0,
440            };
441        }
442        self.free.set(free);
443        self.free_len.set(len);
444    }
445
446    /// A node holding `node`: a free cell rewritten in place if there is one,
447    /// otherwise a new allocation.
448    #[inline]
449    pub(crate) fn alloc(&self, node: EnvNode) -> EnvRef {
450        let Some(mut cell) = self.free.replace(Env::EMPTY).0 else {
451            return EnvRef::new(node);
452        };
453        let slot = cell.unique().expect("a free cell has one reference");
454        let EnvNode::Cons { next, .. } = core::mem::replace(slot, node) else {
455            unreachable!("a free cell is a `Cons`")
456        };
457        self.free.set(next);
458        self.free_len.set(self.free_len.get() - 1);
459        #[cfg(feature = "stats-env")]
460        cell.0.reused();
461        cell
462    }
463
464    /// Frees the cells of the free list iteratively, as the realm drops:
465    /// dropping the list whole would recurse once per cell.
466    pub(crate) fn free_cells(&self) {
467        let mut cur = self.free.replace(Env::EMPTY).0;
468        while let Some(mut cell) = cur {
469            cur = match cell.unique() {
470                Some(EnvNode::Cons { next, .. }) => core::mem::replace(next, Env::EMPTY).0,
471                _ => None,
472            };
473        }
474    }
475}
476
477/// Performs a single walk to retrieve both the value at `index` and the
478/// environment terminal. Resuming from the landing cell avoids re-walking links.
479///
480/// If the walk passes a [`Module`](EnvNode::Module) node, that node is returned
481/// as the home, ensuring the resulting environment doesn't unexpectedly shift to
482/// the module's own frame terminal.
483fn lookup_and_home(env: &Env, index: u32) -> (Value, Env) {
484    let mut cur = env;
485    let mut rest = index as usize;
486    let mut passed: Option<&Env> = None;
487    let val = loop {
488        match cur
489            .0
490            .as_deref()
491            .expect("resolved index walked past the environment")
492        {
493            EnvNode::Cons { val, next } => {
494                if rest == 0 {
495                    break val.clone();
496                }
497                rest -= 1;
498                cur = next;
499            }
500            EnvNode::Frame { vals, .. } => match vals.get(rest) {
501                Some(val) => break val.clone(),
502                None => unreachable!("resolved index walked past the captured frame"),
503            },
504            EnvNode::Module { frame, .. } => {
505                if passed.is_none() {
506                    passed = Some(cur);
507                }
508                cur = frame;
509            }
510        }
511    };
512    let home = match passed {
513        Some(home) => home.clone(),
514        None => cur.find_home(),
515    };
516    (val, home)
517}
518
519/// Builds a closure's captured frame from its resolved `plan` in one walk of
520/// the defining environment. The plan is sorted by `outer`, so the walk only
521/// moves forward, advancing to each capture's cell and writing the value to the
522/// slot. Captures reaching the enclosing [`Frame`](EnvNode::Frame) use direct
523/// indexing (`outer - depth`).
524///
525/// The frame is filled out of order (slot order is fixed at first use). The walk
526/// also retrieves the chain's terminal for the frame's `home`, remembering any
527/// [`Module`](EnvNode::Module) nodes passed.
528fn build_frame_and_home(env: &Env, plan: &[Capture]) -> (Box<[Value]>, Env) {
529    let mut vals: Vec<Value> = alloc::vec![Value::I64(0); plan.len()];
530    let mut cur = env;
531    let mut depth = 0u32;
532    let mut passed: Option<&Env> = None;
533    for cap in plan {
534        loop {
535            match cur
536                .0
537                .as_deref()
538                .expect("capture index walked past the environment")
539            {
540                EnvNode::Cons { val, next } => {
541                    if depth == cap.outer {
542                        vals[cap.slot as usize] = val.clone();
543                        break;
544                    }
545                    depth += 1;
546                    cur = next;
547                }
548                EnvNode::Frame { vals: outer, .. } => {
549                    match outer.get((cap.outer - depth) as usize) {
550                        Some(val) => vals[cap.slot as usize] = val.clone(),
551                        None => unreachable!("capture index walked past the captured frame"),
552                    }
553                    break;
554                }
555                // The terminal binds no name, so it is crossed without advancing
556                // `depth`: a capture of a module's frame value counts only the
557                // lexical cells between it and the reference.
558                EnvNode::Module { frame, .. } => {
559                    if passed.is_none() {
560                        passed = Some(cur);
561                    }
562                    cur = frame;
563                }
564            }
565        }
566    }
567    let home = match passed {
568        Some(home) => home.clone(),
569        None => cur.find_home(),
570    };
571    (vals.into_boxed_slice(), home)
572}