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}