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