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