Skip to main content

elly_core/
eval_env.rs

1//! Evaluation environment and context: the persistent [`EnvNode`] chain, the
2//! [`Ctx`] threading the unique-id source and builtin-module cache, and [`instantiate`],
3//! which builds a module instance's environment terminal. The tree-walker in `eval.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;
10use core::cell::{Cell, OnceCell, RefCell};
11
12use crate::ast::{Capture, Expr, ModuleData, Text};
13use crate::{HomeLeaf, Raised};
14
15use crate::builtins::NMODULES;
16use crate::eval::{self, Builtin};
17use crate::load::{Instances, ModuleResolver};
18use crate::value::Value;
19
20/// A shared, persistent environment: a nameless cons list of the running
21/// activation's bindings (innermost first) on top of the closure's captured
22/// [`Frame`](EnvNode::Frame). A resolved reference ([`Expr::Local`](crate::Expr))
23/// reaches its value by walking its de Bruijn `index` over `next` links and
24/// cloning the cell's value — a pointer chase without name comparison or
25/// per-link cloning. Indices beyond the consed cells land in the frame array.
26/// The resolver (`resolve.rs`) assigns each reference the exact number of cells
27/// between it and its binder.
28#[derive(Debug)]
29#[cfg_attr(not(feature = "stats-env"), derive(Clone))]
30pub struct Env(Option<EnvRef>);
31
32/// With `stats-env`, cloning passes the caller's location on to [`EnvRef`]'s clone,
33/// which a derived impl (through `Option::clone`) would lose.
34#[cfg(feature = "stats-env")]
35impl Clone for Env {
36    #[track_caller]
37    #[inline]
38    fn clone(&self) -> Self {
39        match &self.0 {
40            Some(node) => Env(Some(node.clone())),
41            None => Env(None),
42        }
43    }
44}
45
46impl Env {
47    pub const EMPTY: Env = Env(None);
48
49    /// Cons one binding of `val` onto `env`.
50    pub fn bind(self, val: Value) -> Self {
51        Self(Some(EnvRef::new(EnvNode::Cons { val, next: self })))
52    }
53
54    /// [`bind`](Self::bind) in the evaluator: the cell comes from `ctx`'s free
55    /// list when it has one.
56    #[inline]
57    pub(crate) fn bind_in(self, val: Value, ctx: &Ctx) -> Self {
58        Self(Some(ctx.alloc(EnvNode::Cons { val, next: self })))
59    }
60
61    /// Cons one binding of `val` onto `env`.
62    pub(crate) fn frame(self, vals: Box<[Value]>) -> Self {
63        Self(Some(EnvRef::new(EnvNode::Frame { vals, home: self })))
64    }
65
66    pub(crate) fn from_ref(node: EnvRef) -> Self {
67        Self(Some(node))
68    }
69
70    pub fn clone_ref(&self) -> Option<EnvRef> {
71        self.0.clone()
72    }
73
74    pub(crate) fn as_module(&self) -> Option<(&Rc<ModuleData>, u64)> {
75        match self.0.as_deref() {
76            Some(EnvNode::Module { data, id_base, .. }) => Some((data, *id_base)),
77            _ => None,
78        }
79    }
80
81    /// Resolves a de Bruijn `index` to a cloned value by walking `next` links.
82    /// If the walk reaches the [`Frame`], the remaining index selects `vals[rest]`.
83    /// The resolver ensures the index lands on a live cell or frame slot.
84    ///
85    /// [`Module`](EnvNode::Module) terminals are passed through uncounted; a module
86    /// body's reference to an import or prelude value is a `Local` whose index runs
87    /// into the frame.
88    ///
89    /// [`Frame`]: EnvNode::Frame
90    pub(crate) fn lookup(&self, index: u32) -> Value {
91        let mut cur = self;
92        let mut rest = index as usize;
93        loop {
94            match cur
95                .0
96                .as_deref()
97                .expect("resolved index walked past the environment")
98            {
99                EnvNode::Cons { val, next } => {
100                    if rest == 0 {
101                        return val.clone();
102                    }
103                    rest -= 1;
104                    cur = next;
105                }
106                EnvNode::Frame { vals, .. } => match vals.get(rest) {
107                    Some(val) => return val.clone(),
108                    None => unreachable!("resolved index walked past the captured frame"),
109                },
110                EnvNode::Module { frame, .. } => cur = frame,
111            }
112        }
113    }
114
115    /// Returns the environment's terminal: either `None` or the [`Module`](EnvNode::Module)
116    /// node. A closure built here carries it as its frame's `home`, ensuring a module
117    /// item's body can find its module without new allocations.
118    fn find_home(&self) -> Env {
119        let mut cur = self;
120        loop {
121            match cur.0.as_deref() {
122                Some(EnvNode::Cons { next, .. }) => cur = next,
123                Some(EnvNode::Frame { home, .. }) => return home.clone(),
124                Some(EnvNode::Module { .. }) => return cur.clone(),
125                None => return Env::EMPTY,
126            }
127        }
128    }
129
130    /// Walks to the module terminal at `depth` and returns a home-module leaf.
131    ///
132    /// Leaf addresses the terminal itself rather than a member. The constructor `#Self()`
133    /// and `.Self` (unwrapping inner data) are object builtins pre-applied to the module,
134    /// returning a partial application that takes one remaining argument.
135    #[inline(never)]
136    pub(crate) fn home_leaf(&self, leaf: HomeLeaf, depth: u32) -> Result<Value, eval::Raised> {
137        let (_, home, _, _) = self
138            .find_module(depth)
139            .ok_or_else(|| Raised::no_match("no_module"))?;
140        let home = Value::Module(home.clone());
141        Ok(match leaf {
142            HomeLeaf::Module => home,
143            HomeLeaf::New => Value::Builtin {
144                op: Builtin::ObjNew,
145                args: alloc::vec![home],
146            },
147            HomeLeaf::Value => Value::Builtin {
148                op: Builtin::ObjValue,
149                args: alloc::vec![home],
150            },
151        })
152    }
153
154    pub(crate) fn find_module(
155        &self,
156        mut depth: u32,
157    ) -> Option<(&Env, &EnvRef, &Rc<ModuleData>, u64)> {
158        let mut cur = self;
159        while let Some(node) = &cur.0 {
160            match &**node {
161                EnvNode::Cons { next, .. } => cur = next,
162                EnvNode::Frame { home, .. } => cur = home,
163                EnvNode::Module {
164                    data,
165                    id_base,
166                    frame,
167                } => {
168                    if depth == 0 {
169                        return Some((cur, node, data, *id_base));
170                    }
171                    depth -= 1;
172                    cur = frame;
173                }
174            }
175        }
176        None
177    }
178
179    /// Builds the environment for a capturing closure from its resolved `plan`.
180    /// Outlined from `eval_env` to optimize instruction cache for deep-recursion
181    /// benchmarks.
182    #[inline(never)]
183    pub(crate) fn capture_env(&self, plan: &[Capture], ctx: &Ctx) -> Self {
184        match plan {
185            [] => self.find_home(),
186            // A one-value frame *is* a `Cons` onto the terminal: index 0 is the capture
187            // and the walk ends there either way. Reusing the node the environment
188            // already has costs one allocation instead of two (node + values), and
189            // retains exactly the one value — worth a case of its own because a single
190            // capture is the common escaping closure (`&v (x x) v` in a Z-combinator
191            // recursion). Two or more go through a frame: the values then share one
192            // allocation and one walk, which conses cannot.
193            [c] => {
194                let (val, next) = lookup_and_home(self, c.outer);
195                next.bind_in(val, ctx)
196            }
197            _ => {
198                let (vals, home) = build_frame_and_home(self, plan);
199                home.frame(vals)
200            }
201        }
202    }
203}
204
205/// A shared reference to an [`EnvNode`]: every handle on a node — an [`Env`] link,
206/// a [`Value::Module`], an iota's or object's prototype — goes through it.
207///
208/// Today it is a bare `Rc`, but as the one place nodes are allocated, cloned and
209/// compared, it is where to hook instrumentation (see [`stats_env`]) or to try
210/// another representation (a plain `Box`, a pool). It must stay one non-null word
211/// so `Option<EnvRef>` keeps the niche.
212#[derive(Debug)]
213#[cfg_attr(not(feature = "stats-env"), derive(Clone))]
214#[repr(transparent)]
215pub struct EnvRef(Rc<Shared>);
216
217/// What an [`EnvRef`] points at: the node itself, or with `stats-env` the node
218/// plus the highest reference count it has reached.
219#[cfg(not(feature = "stats-env"))]
220type Shared = EnvNode;
221#[cfg(feature = "stats-env")]
222type Shared = stats_env::Tracked;
223
224impl EnvRef {
225    #[inline]
226    pub(crate) fn new(node: EnvNode) -> Self {
227        #[cfg(feature = "stats-env")]
228        let node = stats_env::Tracked::new(node);
229        Self(Rc::new(node))
230    }
231
232    /// Whether `a` and `b` are the same node: the identity of a module instance.
233    #[inline]
234    pub fn ptr_eq(a: &Self, b: &Self) -> bool {
235        Rc::ptr_eq(&a.0, &b.0)
236    }
237
238    /// The node's address, for hashing by identity.
239    #[inline]
240    pub fn as_ptr(this: &Self) -> *const EnvNode {
241        &**this
242    }
243
244    /// Whether this is the node's only reference, so it dies with this handle.
245    #[inline]
246    fn is_unique(&self) -> bool {
247        Rc::strong_count(&self.0) == 1 && Rc::weak_count(&self.0) == 0
248    }
249
250    /// The node, if this is its only reference: it can then be rewritten in
251    /// place, which is how [`Ctx`] recycles the allocation.
252    #[inline]
253    fn unique(&mut self) -> Option<&mut EnvNode> {
254        #[cfg(feature = "stats-env")]
255        return Rc::get_mut(&mut self.0).map(|t| &mut t.node);
256        #[cfg(not(feature = "stats-env"))]
257        Rc::get_mut(&mut self.0)
258    }
259}
260
261impl core::ops::Deref for EnvRef {
262    type Target = EnvNode;
263
264    #[inline]
265    fn deref(&self) -> &EnvNode {
266        #[cfg(feature = "stats-env")]
267        return &self.0.node;
268        #[cfg(not(feature = "stats-env"))]
269        &self.0
270    }
271}
272
273#[cfg(feature = "stats-env")]
274impl Clone for EnvRef {
275    #[track_caller]
276    #[inline]
277    fn clone(&self) -> Self {
278        let rc = Rc::clone(&self.0);
279        stats_env::cloned(
280            &rc.max,
281            Rc::strong_count(&rc),
282            core::panic::Location::caller(),
283        );
284        Self(rc)
285    }
286}
287
288#[cfg(feature = "stats-env")]
289impl Drop for EnvRef {
290    #[inline]
291    fn drop(&mut self) {
292        // A free cell (peak 0) was counted when it was recycled.
293        if Rc::strong_count(&self.0) == 1 && self.0.max.get() != 0 {
294            // A death under `Ctx::release` is explicit; any other is implicit.
295            if !stats_env::freed(self.0.max.get()) {
296                #[cfg(feature = "stats-env-drops")]
297                stats_env::died();
298            }
299        }
300    }
301}
302
303/// Per-thread environment statistics, built only with the `stats-env` feature:
304/// the [`EnvNode`]s allocated by kind, how often an [`EnvRef`] is cloned, and a
305/// histogram of the highest reference count each node reached before it was freed.
306///
307/// The reference count includes clones the evaluator holds only on the Rust stack
308/// for the length of a call (a block's or match arm's copy of its environment, a
309/// module turned into a temporary `Env`), so a count of 2 or 3 is partly borrow-like.
310/// Nodes still alive when the counts are read are not in the histogram.
311///
312/// Clones are also counted by call site ([`sites`]), passed down with
313/// `#[track_caller]` through [`Env`](super::Env)'s and [`EnvRef`](super::EnvRef)'s
314/// `clone`. A clone made by cloning a [`Value`](crate::Value) that holds an
315/// environment (a closure, a module) stops at `Value`'s derived `Clone`, so all of
316/// those share one site in `value.rs`.
317///
318/// With `stats-env-drops`, deaths are also counted by where they happen
319/// ([`drop_sites`](stats_env::drop_sites)): `Drop` has no caller location, so each
320/// death walks the stack, and the report skips the drop glue to name the function
321/// whose scope ended.
322///
323/// Tracking the maximum adds a word to every node's allocation.
324#[cfg(feature = "stats-env")]
325pub mod stats_env {
326    use super::EnvNode;
327    use core::cell::{Cell, RefCell};
328    use core::panic::Location;
329    use std::collections::BTreeMap;
330    use std::vec::Vec;
331
332    /// Histogram buckets: bucket `0` holds nodes whose count never exceeded 1, and
333    /// bucket `b > 0` those whose maximum was in `2^(b-1) + 1 ..= 2^b`; the last
334    /// bucket takes everything above.
335    pub const BUCKETS: usize = 24;
336
337    /// A snapshot of the counters since the last [`reset`].
338    #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
339    pub struct Counts {
340        pub cons: u64,
341        pub frame: u64,
342        pub module: u64,
343        /// [`EnvRef`](super::EnvRef) clones, of any node.
344        pub clones: u64,
345        /// Freed nodes by the highest reference count they reached (see [`BUCKETS`]).
346        pub max_shared: [u64; BUCKETS],
347        /// Of the freed nodes, those that died inside
348        /// [`Ctx::release`](super::Ctx::release); the rest died implicitly.
349        pub released: u64,
350        /// Of the allocated nodes, those placed in a recycled free cell.
351        pub reused: u64,
352    }
353
354    /// A node and the highest reference count it has reached.
355    #[derive(Debug)]
356    pub(super) struct Tracked {
357        pub(super) node: EnvNode,
358        pub(super) max: Cell<u32>,
359    }
360
361    impl Tracked {
362        #[inline]
363        pub(super) fn new(node: EnvNode) -> Self {
364            allocated(&node);
365            Tracked {
366                node,
367                max: Cell::new(1),
368            }
369        }
370
371        /// The node was rewritten as a free cell: it died here (inside
372        /// [`Ctx::release`](super::Ctx::release)), and a peak of 0 marks it free.
373        #[inline]
374        pub(super) fn recycled(&self) {
375            freed(self.max.get());
376            self.max.set(0);
377        }
378
379        /// A free cell now holds `self.node`, allocated without the allocator.
380        #[inline]
381        pub(super) fn reused(&self) {
382            allocated(&self.node);
383            self.max.set(1);
384            STATS.with(|s| s.reused.set(s.reused.get() + 1));
385        }
386    }
387
388    /// Counts a node by kind, whether newly allocated or reusing a free cell.
389    #[inline]
390    fn allocated(node: &EnvNode) {
391        STATS.with(|s| {
392            let kind = match node {
393                EnvNode::Cons { .. } => &s.cons,
394                EnvNode::Frame { .. } => &s.frame,
395                EnvNode::Module { .. } => &s.module,
396            };
397            kind.set(kind.get() + 1);
398        });
399    }
400
401    /// The clones made at one call site.
402    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
403    pub struct Site {
404        pub file: &'static str,
405        pub line: u32,
406        pub column: u32,
407        pub clones: u64,
408        /// Clones that raised their node's highest count: the site that set the
409        /// maximum, as opposed to a copy taken while another reference was already
410        /// counting.
411        pub raised_max: u64,
412    }
413
414    type SiteKey = (&'static str, u32, u32);
415
416    struct Stats {
417        cons: Cell<u64>,
418        frame: Cell<u64>,
419        module: Cell<u64>,
420        clones: Cell<u64>,
421        max_shared: [Cell<u64>; BUCKETS],
422        released: Cell<u64>,
423        reused: Cell<u64>,
424        /// Set while [`Ctx::release`](super::Ctx::release) drops an environment.
425        releasing: Cell<bool>,
426        /// `(clones, raised_max)` by call site.
427        sites: RefCell<BTreeMap<SiteKey, (u64, u64)>>,
428        /// Deaths by the return addresses on the stack when they happened.
429        #[cfg(feature = "stats-env-drops")]
430        deaths: RefCell<drops::Deaths>,
431    }
432
433    std::thread_local! {
434        static STATS: Stats = const {
435            Stats {
436                cons: Cell::new(0),
437                frame: Cell::new(0),
438                module: Cell::new(0),
439                clones: Cell::new(0),
440                max_shared: [const { Cell::new(0) }; BUCKETS],
441                released: Cell::new(0),
442                reused: Cell::new(0),
443                releasing: Cell::new(false),
444                sites: RefCell::new(BTreeMap::new()),
445                #[cfg(feature = "stats-env-drops")]
446                deaths: RefCell::new(drops::Deaths::with_hasher(
447                    std::hash::BuildHasherDefault::new(),
448                )),
449            }
450        };
451    }
452
453    /// A clone at `at` brought the count to `count`.
454    #[inline]
455    pub(super) fn cloned(max: &Cell<u32>, count: usize, at: &'static Location<'static>) {
456        let count = u32::try_from(count).unwrap_or(u32::MAX);
457        let raised = count > max.get();
458        if raised {
459            max.set(count);
460        }
461        STATS.with(|s| {
462            s.clones.set(s.clones.get() + 1);
463            let mut sites = s.sites.borrow_mut();
464            let site = sites
465                .entry((at.file(), at.line(), at.column()))
466                .or_default();
467            site.0 += 1;
468            site.1 += u64::from(raised);
469        });
470    }
471
472    /// The last reference to a node whose count peaked at `max` is going away.
473    /// Returns whether it dies inside [`Ctx::release`](super::Ctx::release).
474    #[inline]
475    pub(super) fn freed(max: u32) -> bool {
476        let bucket = ((u32::BITS - (max.max(1) - 1).leading_zeros()) as usize).min(BUCKETS - 1);
477        STATS.with(|s| {
478            let slot = &s.max_shared[bucket];
479            slot.set(slot.get() + 1);
480            let releasing = s.releasing.get();
481            if releasing {
482                s.released.set(s.released.get() + 1);
483            }
484            releasing
485        })
486    }
487
488    /// Runs `f` with every node death counted as released, cascades included.
489    #[inline]
490    pub(super) fn releasing(f: impl FnOnce()) {
491        STATS.with(|s| s.releasing.set(true));
492        f();
493        STATS.with(|s| s.releasing.set(false));
494    }
495
496    /// This thread's counts so far.
497    pub fn get() -> Counts {
498        STATS.with(|s| Counts {
499            cons: s.cons.get(),
500            frame: s.frame.get(),
501            module: s.module.get(),
502            clones: s.clones.get(),
503            max_shared: core::array::from_fn(|b| s.max_shared[b].get()),
504            released: s.released.get(),
505            reused: s.reused.get(),
506        })
507    }
508
509    /// This thread's clones by call site, most clones first.
510    pub fn sites() -> Vec<Site> {
511        let mut out: Vec<Site> = STATS.with(|s| {
512            s.sites
513                .borrow()
514                .iter()
515                .map(|(&(file, line, column), &(clones, raised_max))| Site {
516                    file,
517                    line,
518                    column,
519                    clones,
520                    raised_max,
521                })
522                .collect()
523        });
524        out.sort_by_key(|s| core::cmp::Reverse(s.clones));
525        out
526    }
527
528    /// Zeroes this thread's counts, per-site ones included, and returns what the
529    /// totals were.
530    pub fn reset() -> Counts {
531        let counts = get();
532        STATS.with(|s| {
533            for c in [
534                &s.cons,
535                &s.frame,
536                &s.module,
537                &s.clones,
538                &s.released,
539                &s.reused,
540            ] {
541                c.set(0);
542            }
543            for c in &s.max_shared {
544                c.set(0);
545            }
546            s.sites.borrow_mut().clear();
547            #[cfg(feature = "stats-env-drops")]
548            s.deaths.borrow_mut().clear();
549        });
550        counts
551    }
552
553    /// The last reference to a node is going away implicitly: record the stack.
554    #[cfg(feature = "stats-env-drops")]
555    #[inline(never)]
556    pub(super) fn died() {
557        let stack = drops::capture();
558        STATS.with(|s| *s.deaths.borrow_mut().entry(stack).or_default() += 1);
559    }
560
561    /// Where nodes died since the last [`reset`], most deaths first.
562    #[cfg(feature = "stats-env-drops")]
563    pub fn drop_sites() -> Vec<drops::DropSite> {
564        STATS.with(|s| drops::summarize(&s.deaths.borrow()))
565    }
566
567    /// Stack capture and its symbolization for [`drop_sites`].
568    #[cfg(feature = "stats-env-drops")]
569    pub mod drops {
570        use core::cell::RefCell;
571        use std::collections::HashMap;
572        use std::hash::{BuildHasherDefault, DefaultHasher};
573        use std::string::{String, ToString};
574        use std::vec::Vec;
575
576        /// Frames kept per death: enough to climb out of the drop glue of a
577        /// node that dies because its parent did, a few levels deep.
578        const DEPTH: usize = 32;
579
580        /// Return addresses, innermost first, zero-padded.
581        pub(in super::super) type Stack = [usize; DEPTH];
582
583        /// Deaths by stack; a fixed hasher so the map can start in a `const`.
584        pub(in super::super) type Deaths = HashMap<Stack, u64, BuildHasherDefault<DefaultHasher>>;
585
586        /// What was being destroyed when the node died, innermost first.
587        #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
588        pub enum Via {
589            /// The handle itself went out of scope or was overwritten.
590            Direct,
591            /// A [`Value`](crate::Value) holding it (a closure, a module, a list
592            /// of those) was destroyed.
593            Value,
594            /// An [`EnvNode`](crate::EnvNode) holding it died first: the cascade
595            /// down a chain.
596            Node,
597        }
598
599        /// Nodes that died at one place.
600        #[derive(Debug, Clone, PartialEq, Eq)]
601        pub struct DropSite {
602            /// `function (file:line)` of the first frame outside drop glue, or
603            /// `?` when every captured frame was glue.
604            pub site: String,
605            pub via: Via,
606            pub deaths: u64,
607        }
608
609        /// Walks the frame-pointer chain, which the Apple arm64 ABI mandates:
610        /// two loads per frame, where unwinding tables cost tens of microseconds
611        /// a death.
612        #[cfg(all(target_arch = "aarch64", target_vendor = "apple"))]
613        #[inline(never)]
614        pub(in super::super) fn capture() -> Stack {
615            let mut stack = [0; DEPTH];
616            let mut fp: usize;
617            // SAFETY: reads this frame's own frame pointer.
618            unsafe { core::arch::asm!("mov {}, x29", out(reg) fp) };
619            for slot in &mut stack {
620                if fp == 0 || !fp.is_multiple_of(16) {
621                    break;
622                }
623                // SAFETY: a frame record is `[previous fp, return address]`, and
624                // the chain ends at a null or non-ascending pointer.
625                let (next, ret) = unsafe { (*(fp as *const usize), *((fp + 8) as *const usize)) };
626                *slot = ret;
627                if next <= fp {
628                    break;
629                }
630                fp = next;
631            }
632            stack
633        }
634
635        #[cfg(not(all(target_arch = "aarch64", target_vendor = "apple")))]
636        pub(in super::super) fn capture() -> Stack {
637            let mut stack = [0; DEPTH];
638            let mut i = 0;
639            // SAFETY: the stats are per thread and the capture does not unwind.
640            unsafe {
641                backtrace::trace_unsynchronized(|frame| {
642                    stack[i] = frame.ip() as usize;
643                    i += 1;
644                    i < DEPTH
645                });
646            }
647            stack
648        }
649
650        /// One symbol at an address; an inlined call contributes several,
651        /// innermost first.
652        #[derive(Clone)]
653        struct Sym {
654            name: String,
655            at: String,
656        }
657
658        std::thread_local! {
659            /// Symbolization is slow and addresses repeat across benches.
660            static RESOLVED: RefCell<HashMap<usize, Vec<Sym>>> = RefCell::new(HashMap::new());
661        }
662
663        fn resolve(ip: usize) -> Vec<Sym> {
664            RESOLVED.with(|r| {
665                r.borrow_mut()
666                    .entry(ip)
667                    .or_insert_with(|| {
668                        let mut syms = Vec::new();
669                        // A return address points past the call; step back into it.
670                        let pc = ip.saturating_sub(1) as *mut core::ffi::c_void;
671                        backtrace::resolve(pc, |sym| {
672                            let name = sym
673                                .name()
674                                .map_or_else(|| "?".to_string(), |n| std::format!("{n:#}"));
675                            let file = sym
676                                .filename()
677                                .map(|f| f.to_string_lossy().into_owned())
678                                .unwrap_or_default();
679                            let file = file.find("src/").map_or(&file[..], |i| &file[i..]);
680                            let at = match sym.lineno() {
681                                Some(line) => std::format!("{file}:{line}"),
682                                None => file.to_string(),
683                            };
684                            syms.push(Sym { name, at });
685                        });
686                        syms
687                    })
688                    .clone()
689            })
690        }
691
692        /// The compiler's drop glue for a type (`drop_glue` in newer toolchains).
693        fn is_drop_glue(name: &str) -> bool {
694            name.contains("drop_in_place") || name.contains("drop_glue")
695        }
696
697        /// Frames that only carry the destruction along: drop glue, `Drop`
698        /// impls, `mem::drop`, and this instrumentation.
699        fn is_glue(name: &str) -> bool {
700            is_drop_glue(name)
701                || name.contains("as core::ops::drop::Drop>::drop")
702                || name.contains("drop_slow")
703                || name.starts_with("core::mem::drop")
704                || name.contains("stats_env")
705                || name.contains("backtrace")
706        }
707
708        /// The container a drop-glue frame is destroying, if it is one of ours.
709        fn container(name: &str) -> Option<Via> {
710            if !is_drop_glue(name) {
711                None
712            } else if name.contains("eval_env::EnvNode") || name.contains("stats_env::Tracked") {
713                Some(Via::Node)
714            } else if name.contains("value::Value") {
715                Some(Via::Value)
716            } else {
717                None
718            }
719        }
720
721        fn classify(stack: &Stack) -> (String, Via) {
722            let mut via = None;
723            for &ip in stack.iter().take_while(|&&ip| ip != 0) {
724                for sym in resolve(ip) {
725                    if is_glue(&sym.name) || sym.at.contains("/backtrace-") {
726                        via = via.or_else(|| container(&sym.name));
727                        continue;
728                    }
729                    return (
730                        std::format!("{} ({})", sym.name, sym.at),
731                        via.unwrap_or(Via::Direct),
732                    );
733                }
734            }
735            ("?".to_string(), via.unwrap_or(Via::Direct))
736        }
737
738        pub(in super::super) fn summarize(deaths: &Deaths) -> Vec<DropSite> {
739            let mut by_site: HashMap<(String, Via), u64> = HashMap::new();
740            for (stack, &n) in deaths {
741                *by_site.entry(classify(stack)).or_default() += n;
742            }
743            let mut out: Vec<DropSite> = by_site
744                .into_iter()
745                .map(|((site, via), deaths)| DropSite { site, via, deaths })
746                .collect();
747            out.sort_by(|a, b| b.deaths.cmp(&a.deaths).then_with(|| a.site.cmp(&b.site)));
748            out
749        }
750    }
751}
752
753/// One link of the environment list: a single binding carrying its `val` inline,
754/// or the frame that terminates the walk. The empty environment is `None`.
755/// [`Env`] is an `Option<EnvRef>`, utilizing the null-pointer niche to remain
756/// one word wide, avoiding allocation and refcount traffic when ending a chain.
757/// Lambda activations cons one `Cons` per bound name (zero for binder-less heads);
758/// partial application environments share the consed prefix via `Rc`.
759/// References are resolved to de Bruijn indices against this list's shape.
760#[derive(Debug)]
761pub enum EnvNode {
762    Cons {
763        val: Value,
764        next: Env,
765    },
766    /// Module instance terminal: carries the item's module instead of the
767    /// empty terminal, allowing [`Expr::ModItem`] sibling references to resolve
768    /// by walking out (see [`crate::eval::eval_env`]). It binds no name and is not
769    /// counted by de Bruijn `Local` indices. Materialized closures capture an
770    /// environment ending in this node to keep the module alive.
771    ///
772    /// This node is the module instance; [`Value::Module`] is a handle to it,
773    /// created by `instantiate`. `M.name` runs the body in this environment.
774    ///
775    /// `id_base` is the module's reserved ID block: item `i` stamps its closure
776    /// with `id_base + i`, ensuring `M.f == M.f` (see `materialize_body`).
777    ///
778    /// `frame` is the environment the module was instantiated against (imports,
779    /// host prelude, and identities), with slot order fixed by
780    /// [`ModuleData::frame_names`]. De Bruijn indices walk through this node into
781    /// `frame` without counting the node itself. The frame can be a flat
782    /// [`Frame`](EnvNode::Frame) or a cons chain (`recur`).
783    ///
784    /// This structure ensures temporal acyclicity: a frame holds only values
785    /// existing before the frame, and immutability prevents them from pointing
786    /// back to the frame.
787    Module {
788        data: Rc<ModuleData>,
789        id_base: u64,
790        frame: Env,
791    },
792    /// A closure's captured frame: values of free variables used by the body,
793    /// copied at creation in the order fixed by `Lambda::captures`. This terminates
794    /// the de Bruijn walk: an index beyond the consed cells selects `vals[remaining]`
795    /// (see `lookup`), allowing references to enclosing scopes to avoid walking
796    /// the entire program.
797    ///
798    /// `home` stores the environment terminal (`None` or the [`Module`](EnvNode::Module)
799    /// node) beyond the frame, so `ModItem` references in the body can still find
800    /// the module without new allocations.
801    ///
802    /// Values are stored inline behind the environment's `Rc`, making the frame
803    /// a single allocation.
804    Frame {
805        vals: Box<[Value]>,
806        home: Env,
807    },
808}
809
810impl EnvNode {}
811
812/// The long-lived state of an evaluation, the equivalent of a JS realm: the
813/// unique-id source for closure identities, the builtin module cache, the
814/// environment free list, and the module loader — a resolver plus the cache of
815/// modules loaded through it ([`load`](Self::load)).
816///
817/// A host creates one per program run, per Python realm object or per
818/// playground session, and passes `&Ctx` to every call. Imports are still
819/// resolved before evaluation, and loaded modules are `Rc`-held values. See
820/// `docs/done/2026-10-02_elly-host.md`.
821pub struct Ctx {
822    ids: Cell<u64>,
823    /// The instantiated [builtin modules](MODULES), one slot each, built on first
824    /// reference. Caching them here is what gives `__Int` an identity: every
825    /// reference in one evaluation yields the *same* module value, so
826    /// `__eq __Int Int` holds. Two different contexts hold non-identical `__Int`s —
827    /// v0's "two loads are unrelated modules" caveat, which a content-addressed
828    /// module brand would retire.
829    modules: [OnceCell<Value>; NMODULES],
830    /// Environment cells recycled by [`release`](Self::release), linked through
831    /// their own `next`, and how many there are (at most [`FREE_CAP`]).
832    free: Cell<Env>,
833    free_len: Cell<u32>,
834    /// The modules loaded through [`load`](Self::load) and
835    /// [`load_source`](Self::load_source), by canonical name. Keeping them here
836    /// is what makes two loads in one realm share an import.
837    pub(crate) instances: RefCell<Instances>,
838    /// Where an import spec's source comes from. `None` means every import fails
839    /// with [`LoadError::NotFound`](crate::LoadError::NotFound).
840    pub(crate) resolver: Option<Box<dyn ModuleResolver>>,
841}
842
843/// The most free cells a [`Ctx`] keeps. Beyond it, released nodes go back to the
844/// allocator; the cap bounds what an evaluation holds on to after a deep
845/// recursion unwinds.
846const FREE_CAP: u32 = 4096;
847
848impl Ctx {
849    /// A context with a fresh id source and no module resolver, so every import
850    /// fails. Used by [`crate::eval::eval`] and [`crate::eval::apply`].
851    pub fn new() -> Self {
852        Self::build(0, None)
853    }
854
855    /// A context whose imports are served by `resolver`.
856    pub fn with_resolver(resolver: Box<dyn ModuleResolver>) -> Self {
857        Self::build(0, Some(resolver))
858    }
859
860    /// A context resuming an id source at `ids` so identities continue from
861    /// an earlier context. Needed only when values outlive the `Ctx` that made
862    /// them and a fresh one runs them: without it, a module's reserved id block
863    /// and later closure ids could alias, and `__eq` would call them equal.
864    ///
865    /// Hosts keep one long-lived `Ctx` instead. The module bench is the
866    /// remaining user: it wants a fresh context per timed pass, as `run_eval`
867    /// gets one.
868    pub fn with_ids(ids: u64) -> Self {
869        Self::build(ids, None)
870    }
871
872    fn build(ids: u64, resolver: Option<Box<dyn ModuleResolver>>) -> Self {
873        Ctx {
874            ids: Cell::new(ids),
875            modules: core::array::from_fn(|_| OnceCell::new()),
876            free: Cell::new(Env::EMPTY),
877            free_len: Cell::new(0),
878            instances: RefCell::new(Instances::new()),
879            resolver,
880        }
881    }
882
883    /// Returns the current id source value to seed a successor context
884    /// (see [`with_ids`](Self::with_ids)).
885    pub fn ids_used(&self) -> u64 {
886        self.ids.get()
887    }
888
889    /// Mints the next unique id for a closure's identity, used for equality
890    /// and ordering.
891    pub(crate) fn next_id(&self) -> u64 {
892        let n = self.ids.get();
893        self.ids.set(n + 1);
894        n
895    }
896
897    /// Hands back an environment whose scope ends here. The hot scope ends of
898    /// activation environments (a closure body's, a block's) call this instead of
899    /// dropping implicitly, so that the nodes dying with it become free cells for
900    /// [`alloc`](Self::alloc). Other drops stay implicit and go to the allocator.
901    /// See `docs/todo/elly-perf-env-stats.md`.
902    #[inline]
903    pub(crate) fn release(&self, env: Env) {
904        #[cfg(feature = "stats-env")]
905        stats_env::releasing(|| self.release_inner(env));
906        #[cfg(not(feature = "stats-env"))]
907        self.release_inner(env);
908    }
909
910    #[inline]
911    fn release_inner(&self, env: Env) {
912        // A shared node only loses a reference: nothing to recycle.
913        if let Some(node) = env.0 {
914            if node.is_unique() {
915                self.recycle(node);
916            }
917        }
918    }
919
920    /// Walks down from `node` while each node dies with the reference in hand,
921    /// rewriting it in place as a free cell (every variant shares the one `Rc`
922    /// allocation) and following its link: `next`, a frame's `home`, a module's
923    /// `frame`. The rest of a node (its value, a frame's slice, a module's data)
924    /// is dropped normally. Stops at a shared node or a full list, which then
925    /// drops as usual.
926    #[inline(never)]
927    fn recycle(&self, node: EnvRef) {
928        let mut free = self.free.replace(Env::EMPTY);
929        let mut len = self.free_len.get();
930        let mut cur = Some(node);
931        while let Some(mut node) = cur {
932            if len >= FREE_CAP {
933                break;
934            }
935            let Some(slot) = node.unique() else { break };
936            let free_cell = EnvNode::Cons {
937                val: Value::Unit,
938                next: free,
939            };
940            let old = core::mem::replace(slot, free_cell);
941            #[cfg(feature = "stats-env")]
942            node.0.recycled();
943            free = Env(Some(node));
944            len += 1;
945            cur = match old {
946                EnvNode::Cons { val, next } => {
947                    drop(val);
948                    next.0
949                }
950                EnvNode::Frame { vals, home } => {
951                    drop(vals);
952                    home.0
953                }
954                EnvNode::Module { data, frame, .. } => {
955                    drop(data);
956                    frame.0
957                }
958            };
959        }
960        self.free.set(free);
961        self.free_len.set(len);
962    }
963
964    /// A node holding `node`: a free cell rewritten in place if there is one,
965    /// otherwise a new allocation.
966    #[inline]
967    pub(crate) fn alloc(&self, node: EnvNode) -> EnvRef {
968        let Some(mut cell) = self.free.replace(Env::EMPTY).0 else {
969            return EnvRef::new(node);
970        };
971        let slot = cell.unique().expect("a free cell has one reference");
972        let EnvNode::Cons { next, .. } = core::mem::replace(slot, node) else {
973            unreachable!("a free cell is a `Cons`")
974        };
975        self.free.set(next);
976        self.free_len.set(self.free_len.get() - 1);
977        #[cfg(feature = "stats-env")]
978        cell.0.reused();
979        cell
980    }
981
982    /// Reserves a block of `n` consecutive ids and returns the base. Used by
983    /// [`instantiate`] to give module items stable identities, preventing alias
984    /// with individually minted ids.
985    pub(crate) fn reserve_ids(&self, n: u64) -> u64 {
986        let base = self.ids.get();
987        self.ids.set(base + n);
988        base
989    }
990
991    /// Gets a builtin module (`__Int`, `__Map`, etc.), instantiated on first
992    /// reference and cached.
993    ///
994    /// Builtin modules are [`ModuleData`] with [`Expr::Builtin`] bodies. A written
995    /// `__Int.add` is folded to its member at resolve, but this method handles
996    /// remaining dynamic uses of the module as a value.
997    pub(crate) fn builtin_module(&self, op: Builtin) -> Value {
998        let slot = op.module_slot().expect("a builtin module");
999        self.modules[slot]
1000            .get_or_init(|| {
1001                let items = op.items().expect("a builtin module has items");
1002                let bindings = items
1003                    .iter()
1004                    .map(|(name, op)| (Text::from_static(name), Expr::Builtin(*op)))
1005                    .collect();
1006                // The module's `const` declarations, name-sorted (the table already
1007                // is). Only `__Bool` has any; they populate the iota table an
1008                // iota's `<const .name>` rendering reads. The re-exporting `true` /
1009                // `false` items above forward to these by way of their arity-0
1010                // builtins, so the two spellings land on the same iota.
1011                let iotas = op
1012                    .iotas()
1013                    .expect("a builtin module has an iota list")
1014                    .iter()
1015                    .map(|n| Text::from_static(n))
1016                    .collect();
1017                // A builtin module is named after itself, so `__Mod.name __Int`
1018                // answers `"__Int"` — the same question a loaded module answers
1019                // with the name its source was resolved under.
1020                let data = Rc::new(ModuleData::from_bindings(
1021                    bindings,
1022                    Vec::new(),
1023                    iotas,
1024                    Box::new([]),
1025                    Box::new([]),
1026                    // A builtin module is a table of operations and nothing else:
1027                    // there is no source to have written a `__main` in.
1028                    None,
1029                    Some(Text::from_static(op.name())),
1030                ));
1031                eval::instantiate(data, &[], self).expect("a builtin module has no frame")
1032            })
1033            .clone()
1034    }
1035}
1036
1037/// Frees the cells iteratively: dropping the list whole would recurse once per
1038/// cell.
1039impl Drop for Ctx {
1040    fn drop(&mut self) {
1041        let mut cur = self.free.replace(Env::EMPTY).0;
1042        while let Some(mut cell) = cur {
1043            cur = match cell.unique() {
1044                Some(EnvNode::Cons { next, .. }) => core::mem::replace(next, Env::EMPTY).0,
1045                _ => None,
1046            };
1047        }
1048    }
1049}
1050
1051impl Default for Ctx {
1052    fn default() -> Self {
1053        Self::new()
1054    }
1055}
1056
1057/// Performs a single walk to retrieve both the value at `index` and the
1058/// environment terminal. Resuming from the landing cell avoids re-walking links.
1059///
1060/// If the walk passes a [`Module`](EnvNode::Module) node, that node is returned
1061/// as the home, ensuring the resulting environment doesn't unexpectedly shift to
1062/// the module's own frame terminal.
1063fn lookup_and_home(env: &Env, index: u32) -> (Value, Env) {
1064    let mut cur = env;
1065    let mut rest = index as usize;
1066    let mut passed: Option<&Env> = None;
1067    let val = loop {
1068        match cur
1069            .0
1070            .as_deref()
1071            .expect("resolved index walked past the environment")
1072        {
1073            EnvNode::Cons { val, next } => {
1074                if rest == 0 {
1075                    break val.clone();
1076                }
1077                rest -= 1;
1078                cur = next;
1079            }
1080            EnvNode::Frame { vals, .. } => match vals.get(rest) {
1081                Some(val) => break val.clone(),
1082                None => unreachable!("resolved index walked past the captured frame"),
1083            },
1084            EnvNode::Module { frame, .. } => {
1085                if passed.is_none() {
1086                    passed = Some(cur);
1087                }
1088                cur = frame;
1089            }
1090        }
1091    };
1092    let home = match passed {
1093        Some(home) => home.clone(),
1094        None => cur.find_home(),
1095    };
1096    (val, home)
1097}
1098
1099/// Builds a closure's captured frame from its resolved `plan` in one walk of
1100/// the defining environment. The plan is sorted by `outer`, so the walk only
1101/// moves forward, advancing to each capture's cell and writing the value to the
1102/// slot. Captures reaching the enclosing [`Frame`](EnvNode::Frame) use direct
1103/// indexing (`outer - depth`).
1104///
1105/// The frame is filled out of order (slot order is fixed at first use). The walk
1106/// also retrieves the chain's terminal for the frame's `home`, remembering any
1107/// [`Module`](EnvNode::Module) nodes passed.
1108fn build_frame_and_home(env: &Env, plan: &[Capture]) -> (Box<[Value]>, Env) {
1109    let mut vals: Vec<Value> = alloc::vec![Value::I64(0); plan.len()];
1110    let mut cur = env;
1111    let mut depth = 0u32;
1112    let mut passed: Option<&Env> = None;
1113    for cap in plan {
1114        loop {
1115            match cur
1116                .0
1117                .as_deref()
1118                .expect("capture index walked past the environment")
1119            {
1120                EnvNode::Cons { val, next } => {
1121                    if depth == cap.outer {
1122                        vals[cap.slot as usize] = val.clone();
1123                        break;
1124                    }
1125                    depth += 1;
1126                    cur = next;
1127                }
1128                EnvNode::Frame { vals: outer, .. } => {
1129                    match outer.get((cap.outer - depth) as usize) {
1130                        Some(val) => vals[cap.slot as usize] = val.clone(),
1131                        None => unreachable!("capture index walked past the captured frame"),
1132                    }
1133                    break;
1134                }
1135                // The terminal binds no name, so it is crossed without advancing
1136                // `depth`: a capture of a module's frame value counts only the
1137                // lexical cells between it and the reference.
1138                EnvNode::Module { frame, .. } => {
1139                    if passed.is_none() {
1140                        passed = Some(cur);
1141                    }
1142                    cur = frame;
1143                }
1144            }
1145        }
1146    }
1147    let home = match passed {
1148        Some(home) => home.clone(),
1149        None => cur.find_home(),
1150    };
1151    (vals.into_boxed_slice(), home)
1152}