Skip to main content

relon_eval_api/
scope.rs

1//! Lexical and runtime context shared across evaluation steps.
2//!
3//! `Scope` is the single carrier of evaluator state: it walks down through the
4//! AST, threads imported bindings, anchors `&root`/`&sibling` lookups, and
5//! holds the lazy-evaluation thunk table. It is wrapped in `Arc` everywhere so
6//! children can be derived cheaply via [`Scope::child`] and friends.
7
8use crate::value::Value;
9use relon_parser::Node;
10use std::collections::HashMap;
11use std::sync::{Arc, Mutex, MutexGuard};
12
13/// Iteration context for `&prev` / `&next` / `&index` references inside a list.
14///
15/// Serves two scenarios:
16/// 1. Plain list-literal iteration (`&prev` / `&next` / `&index`) — built by
17///    [`Scope::with_list_context`]. Each element runs under its own child
18///    scope with a fixed `index`; `iter_binding` is always `None`.
19/// 2. Comprehension hot loop (`for x in xs`) — built once by
20///    [`Scope::with_iter_loop`], then the body reuses the same `Arc<Scope>`
21///    across all iterations. [`Scope::set_iter_binding`] refreshes
22///    `iter_binding` + `index` in place per element, eliminating the
23///    `Arc::new(Self {...})` per element flagged by the P1-B diagnostic
24///    correction (48 MB / 200 K blocks). `iter_binding` is `Mutex`-wrapped
25///    so `Scope` stays `Send + Sync` without unsafe interior mutability.
26///
27/// P2-1: thunks shared via `Arc<[Arc<Thunk>]>` so `Expr::List`'s
28/// per-element `with_list_context(i, elements)` only bumps an Arc
29/// instead of cloning the whole `Vec<Arc<Thunk>>` (O(N²) → O(N)).
30pub struct ListContext {
31    pub index: Mutex<usize>,
32    /// P2-1: `Arc<[Arc<Thunk>]>` lets `with_list_context` share the
33    /// thunks slice across every element's scope via a refcount bump,
34    /// instead of `Vec::clone`'ing all N entries per element (the prior
35    /// shape paid O(N²) Arc bumps over an N-element list).
36    pub elements: Arc<[Arc<Thunk>]>,
37    /// Current named-iteration binding `(name, value)`. `None` for plain
38    /// list iteration. Comprehension hot loops fill this in once per
39    /// element via a lock + write (no allocation, no frame rebuild).
40    ///
41    /// Semantic invariant: when a `Closure` value is constructed inside a
42    /// loop body, the constructor MUST snapshot the current binding into
43    /// `captured_env` (see the `Expr::Closure` branch in `eval.rs`).
44    /// Otherwise a later iteration would mutate the binding the closure
45    /// was meant to capture, breaking lexical-snapshot semantics.
46    pub iter_binding: Mutex<Option<(Arc<str>, Value)>>,
47}
48
49impl ListContext {
50    /// Plain `&prev` / `&next` / `&index` scenario: fixed index, no
51    /// iter binding.
52    pub fn fixed(index: usize, elements: Arc<[Arc<Thunk>]>) -> Self {
53        Self {
54            index: Mutex::new(index),
55            elements,
56            iter_binding: Mutex::new(None),
57        }
58    }
59
60    /// Comprehension hot loop entry: starts at index 0 with an empty
61    /// binding slot. [`Scope::set_iter_binding`] fills it per element.
62    pub fn empty(elements: Arc<[Arc<Thunk>]>) -> Self {
63        Self {
64            index: Mutex::new(0),
65            elements,
66            iter_binding: Mutex::new(None),
67        }
68    }
69
70    /// Read the current iteration index — used by `&index` / `&prev` /
71    /// `&next`.
72    pub fn current_index(&self) -> usize {
73        *self.index.lock().unwrap()
74    }
75}
76
77/// Anchor for `&root` lookups in a [`Scope`].
78///
79/// The three fields move as a unit because they all describe one root —
80/// previously they lived as three loose `Option<Arc<…>>` fields on `Scope`
81/// (`reference_root` / `reference_root_scope` / `reference_root_parent`)
82/// whose relationship was only documented in prose. Bundling them here
83/// makes the invariant structural: `node` is always present once a root
84/// is set, and the synthesized-vs-pinned distinction is encoded in
85/// `scope` being `Some` or `None`.
86#[derive(Clone)]
87pub struct RootRef {
88    /// AST node that `&root` resolves against.
89    pub node: Arc<Node>,
90    /// Pre-built scope already pinned at `node` — set by the dict branch
91    /// of the evaluator's main walk once the dict being evaluated *is*
92    /// `node`. When `None`, reference resolution synthesizes a transient
93    /// root scope on demand whose parent is `parent_fallback`.
94    pub scope: Option<Arc<Scope>>,
95    /// Parent fallback used when synthesizing the transient root scope.
96    /// Today this is the closure-body bindings scope so `&root` inside a
97    /// closure body still sees the caller's bindings.
98    pub parent_fallback: Option<Arc<Scope>>,
99}
100
101impl RootRef {
102    /// Build a root anchor that has only the AST identity — no pinned
103    /// scope, no parent fallback. The dict branch of the evaluator's
104    /// main walk will fill `scope` later if/when it enters the matching
105    /// dict.
106    pub fn new(node: Arc<Node>) -> Self {
107        Self {
108            node,
109            scope: None,
110            parent_fallback: None,
111        }
112    }
113}
114
115/// Type alias for the bindings table. Wrapped in `Mutex` so concurrent
116/// host evaluators sharing a `Scope` across threads stay safe; the
117/// inner `HashMap` starts empty (zero-capacity, no heap allocation)
118/// and only grows when something actually writes a binding.
119///
120/// Keys are stored as `Arc<str>` so that the comprehension / closure
121/// hot loops can rebind the same identifier on every iteration via an
122/// `Arc::clone` (refcount bump, no heap touch) instead of a full
123/// `String::clone` (malloc + memcpy). `HashMap` lookup still accepts
124/// `&str` queries through the standard `Borrow<str>` impl on
125/// `Arc<str>`, so read sites are unchanged.
126pub type Locals = Mutex<HashMap<Arc<str>, Value>>;
127
128/// Type alias for the thunks table — same shape as [`Locals`].
129pub type Thunks = Mutex<HashMap<Arc<str>, Arc<Thunk>>>;
130
131/// Single environment frame. Cheap to derive (every field is either
132/// `Clone` or `Arc`-shared) and wrapped in `Arc<Scope>` at every call
133/// site so backtracking through `parent` stays copy-free.
134///
135/// `locals` and `thunks` are kept as `Mutex<HashMap>` rather than
136/// raw `HashMap` so multiple evaluator threads can share an embedder
137/// scope (e.g. the empty root scope) without external locking. The
138/// inner `HashMap` is constructed empty (`HashMap::new()` does not
139/// allocate until first insert), so hot-loop children that never
140/// register a binding pay no heap cost for these fields beyond the
141/// inline mutex word and the Scope's own `Arc` allocation.
142#[derive(Default)]
143pub struct Scope {
144    /// Enclosing scope. `None` only at the document root.
145    pub parent: Option<Arc<Scope>>,
146    /// Most-recent path segment opened by [`Scope::with_path`] /
147    /// [`Scope::with_list_context`]; `&sibling` / `&uncle` peel these off when
148    /// rebuilding the relative target path.
149    pub path_node: Option<Arc<str>>,
150    /// Bindings introduced inside this frame (closure params, comprehension
151    /// loop vars, `where` clauses, imported aliases).
152    pub locals: Locals,
153    /// Working directory used when resolving relative `#import` paths.
154    /// Wrapped in `Arc<str>` so child-scope construction (which clones
155    /// the field on every list / dict / closure descent) is a refcount
156    /// bump rather than a heap String alloc.
157    pub current_dir: Arc<str>,
158    /// Stable namespace for the path cache; usually the canonical id of the
159    /// surrounding module so different modules can't collide on identical
160    /// paths. Same `Arc<str>` reasoning as `current_dir`.
161    pub cache_namespace: Arc<str>,
162    /// `&root` anchor. `None` only at scopes that haven't yet acquired one
163    /// (typically just the pre-eval root scope before the evaluator's
164    /// `eval_root` stamps it). See [`RootRef`] for invariants.
165    pub root_ref: Option<RootRef>,
166    /// Active list iteration, if any.
167    pub list_context: Option<Arc<ListContext>>,
168    /// Lazily-resolved bindings for the dict that owns this scope.
169    /// Backends register and force entries through the public helpers
170    /// below ([`Scope::thunks_for_write`] / [`Scope::get_thunk`]).
171    pub thunks: Thunks,
172}
173
174impl std::fmt::Debug for Scope {
175    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176        f.debug_struct("Scope")
177            .field("path_node", &self.path_node)
178            .field("current_dir", &self.current_dir)
179            .field("cache_namespace", &self.cache_namespace)
180            .field("has_root_ref", &self.root_ref.is_some())
181            .field(
182                "index",
183                &self.list_context.as_ref().map(|c| c.current_index()),
184            )
185            .finish()
186    }
187}
188
189impl Clone for Scope {
190    fn clone(&self) -> Self {
191        Self {
192            parent: self.parent.clone(),
193            path_node: self.path_node.clone(),
194            locals: Mutex::new(self.locals.lock().unwrap().clone()),
195            current_dir: self.current_dir.clone(),
196            cache_namespace: self.cache_namespace.clone(),
197            root_ref: self.root_ref.clone(),
198            list_context: self.list_context.clone(),
199            thunks: Mutex::new(self.thunks.lock().unwrap().clone()),
200        }
201    }
202}
203
204impl Scope {
205    /// Look up `name` in this scope's locals, walking up `parent` chain.
206    ///
207    /// Hot path optimization: before the HashMap lookup, peek at
208    /// `list_context.iter_binding`. Comprehension hot loops park the
209    /// active `for x in xs` binding there (no HashMap allocation per
210    /// element), so `x` resolves with a single Mutex peek instead of
211    /// going through `locals`.
212    pub fn get_local(&self, name: &str) -> Option<Value> {
213        if let Some(ctx) = &self.list_context {
214            if let Some((bound_name, bound_val)) = ctx.iter_binding.lock().unwrap().as_ref() {
215                if bound_name.as_ref() == name {
216                    return Some(bound_val.clone());
217                }
218            }
219        }
220        if let Some(v) = self.locals.lock().unwrap().get(name) {
221            Some(v.clone())
222        } else if let Some(parent) = &self.parent {
223            parent.get_local(name)
224        } else {
225            None
226        }
227    }
228
229    /// Snapshot the active iter binding, if any, into a `(name, value)`
230    /// pair. Closure construction calls this so the captured environment
231    /// holds a value, not a pointer into a mutable iter slot that a
232    /// later iteration would clobber.
233    pub fn current_iter_binding(&self) -> Option<(Arc<str>, Value)> {
234        self.list_context
235            .as_ref()
236            .and_then(|c| c.iter_binding.lock().unwrap().clone())
237    }
238
239    /// Look up a lazy thunk in this scope's own table, walking up the
240    /// `parent` chain on miss.
241    pub fn get_thunk(&self, name: &str) -> Option<Arc<Thunk>> {
242        if let Some(thunk) = self.thunks.lock().unwrap().get(name) {
243            Some(Arc::clone(thunk))
244        } else if let Some(parent) = &self.parent {
245            parent.get_thunk(name)
246        } else {
247            None
248        }
249    }
250
251    /// Look up a lazy thunk in this scope's own table only (no parent
252    /// chain). Used by the evaluator when validating that a key belongs
253    /// to the immediate dict frame.
254    pub fn get_own_thunk(&self, name: &str) -> Option<Arc<Thunk>> {
255        self.thunks.lock().unwrap().get(name).map(Arc::clone)
256    }
257
258    /// Acquire a write lock on this scope's locals.
259    ///
260    /// Centralizes every write-side `scope.locals.lock().unwrap()` so
261    /// the locking strategy can evolve (e.g. swap to a lock-free or
262    /// thread-local representation under a feature) without touching
263    /// every call site. Read paths still go through
264    /// [`Scope::get_local`] which walks the parent chain.
265    ///
266    /// Keys are `Arc<str>`; callers writing a `String` should hand it
267    /// over via `Arc::<str>::from(name)` so the buffer moves into the
268    /// `Arc` allocation once, instead of paying a `String::clone` on
269    /// every rebind.
270    pub fn locals_for_write(&self) -> MutexGuard<'_, HashMap<Arc<str>, Value>> {
271        self.locals.lock().unwrap()
272    }
273
274    /// Same as [`Scope::locals_for_write`] but for the dict thunks table.
275    pub fn thunks_for_write(&self) -> MutexGuard<'_, HashMap<Arc<str>, Arc<Thunk>>> {
276        self.thunks.lock().unwrap()
277    }
278
279    /// Reconstruct the path from the document root to the current scope by
280    /// walking `parent` pointers and collecting `path_node` segments.
281    pub fn full_path(&self) -> Vec<String> {
282        let mut path = Vec::new();
283        let mut current = Some(self);
284        while let Some(scope) = current {
285            if let Some(node) = &scope.path_node {
286                path.push(node.to_string());
287            }
288            current = scope.parent.as_deref();
289        }
290        path.reverse();
291        path
292    }
293
294    /// Build a stable cache key for `path` under this scope's namespace.
295    pub fn path_cache_key(&self, path: &[String]) -> String {
296        let namespace = if self.cache_namespace.is_empty() {
297            &self.current_dir
298        } else {
299            &self.cache_namespace
300        };
301        let encoded_path = path
302            .iter()
303            .map(|s| format!("{}:{}", s.len(), s))
304            .collect::<Vec<_>>()
305            .join("/");
306        format!("{namespace}::{encoded_path}")
307    }
308
309    /// Open a fresh child frame inheriting flow-state fields (`current_dir`,
310    /// `cache_namespace`, `root_ref`, `list_context`) from `self` but
311    /// with empty `locals`/`thunks` and no `path_node`.
312    ///
313    /// This is the workhorse for every new lexical block — Dict body,
314    /// comprehension iteration, closure body. The `with_*` methods below all
315    /// build on top of it and only differ by which field they override.
316    /// The empty `Mutex<HashMap>` pair doesn't touch the heap until
317    /// somebody actually inserts a binding (zero-capacity HashMap +
318    /// inline mutex), so the comprehension hot loop only pays for the
319    /// Scope's own `Arc` allocation.
320    pub fn child(self: &Arc<Self>) -> Arc<Self> {
321        Arc::new(Self {
322            parent: Some(Arc::clone(self)),
323            path_node: None,
324            locals: Mutex::new(HashMap::new()),
325            current_dir: self.current_dir.clone(),
326            cache_namespace: self.cache_namespace.clone(),
327            root_ref: self.root_ref.clone(),
328            list_context: self.list_context.clone(),
329            thunks: Mutex::new(HashMap::new()),
330        })
331    }
332
333    /// Bind a single `name -> val` in a fresh child frame.
334    ///
335    /// Accepts anything that can be cheaply turned into an `Arc<str>`
336    /// (a `String`, a `&str`, or an already-shared `Arc<str>`) so hot
337    /// paths can hand the key over without an extra clone. Callers
338    /// that already hold an `Arc<str>` should pass `Arc::clone(&id)`
339    /// to skip the heap copy entirely.
340    pub fn with_local(self: &Arc<Self>, name: impl Into<Arc<str>>, val: Value) -> Arc<Self> {
341        let child = self.child();
342        child.locals_for_write().insert(name.into(), val);
343        child
344    }
345
346    pub fn with_locals(self: &Arc<Self>, new_locals: HashMap<Arc<str>, Value>) -> Arc<Self> {
347        let mut child = self.child();
348        // `child()` returned an Arc with no other strong references yet,
349        // so swap the freshly-built empty mutex for one already
350        // seeded with `new_locals`. Skips the lock+insert round-trip
351        // that a `with_local`-style write would pay on every
352        // comprehension iteration.
353        Arc::get_mut(&mut child)
354            .expect("freshly built child has no aliases")
355            .locals = Mutex::new(new_locals);
356        child
357    }
358
359    pub fn with_path(self: &Arc<Self>, node: impl Into<Arc<str>>) -> Arc<Self> {
360        let mut child = self.child();
361        // `child()` returns Arc with no other strong references yet, so this
362        // get_mut is safe.
363        Arc::get_mut(&mut child)
364            .expect("freshly built child has no aliases")
365            .path_node = Some(node.into());
366        child
367    }
368
369    pub fn with_list_context(
370        self: &Arc<Self>,
371        index: usize,
372        elements: Arc<[Arc<Thunk>]>,
373    ) -> Arc<Self> {
374        let mut child = self.child();
375        let unique = Arc::get_mut(&mut child).expect("freshly built child has no aliases");
376        unique.path_node = Some(Arc::<str>::from(index.to_string()));
377        unique.list_context = Some(Arc::new(ListContext::fixed(index, elements)));
378        child
379    }
380
381    /// Open the outer frame for a comprehension's hot loop.
382    ///
383    /// Unlike [`Scope::with_list_context`], this builds the frame *once*
384    /// per comprehension. The caller then drives the loop with
385    /// [`Scope::set_iter_binding`] which updates the binding + index in
386    /// place — no per-element `Arc<Scope>` allocation, eliminating the
387    /// 48 MB / 200 K-block hot site flagged by P1-B.
388    ///
389    /// Note: the returned `Arc<Scope>` is mutated through the inner
390    /// `Mutex`es on `ListContext`. Callers must NOT alias this scope as
391    /// a closure's captured environment without first snapshotting the
392    /// binding via [`Scope::current_iter_binding`] (see the `Expr::Closure`
393    /// branch in `eval.rs`).
394    pub fn with_iter_loop(self: &Arc<Self>, elements: Arc<[Arc<Thunk>]>) -> Arc<Self> {
395        let mut child = self.child();
396        let unique = Arc::get_mut(&mut child).expect("freshly built child has no aliases");
397        // `path_node` is set per-iteration by `set_iter_binding` so the
398        // synthesized full_path() still reads the right segment for any
399        // host-side debug formatting that walks it mid-iteration.
400        unique.list_context = Some(Arc::new(ListContext::empty(elements)));
401        child
402    }
403
404    /// Refresh the named iter binding + index on a comprehension outer
405    /// frame previously built by [`Scope::with_iter_loop`].
406    ///
407    /// O(1): one Mutex lock and one `Value` clone — no heap allocation
408    /// for the scope frame itself. The path node is rewritten too so
409    /// `full_path()` reports the right segment if the body asks for it.
410    pub fn set_iter_binding(&self, name: Arc<str>, value: Value, index: usize) {
411        let ctx = self
412            .list_context
413            .as_ref()
414            .expect("set_iter_binding called on a scope without an iter loop");
415        *ctx.iter_binding.lock().unwrap() = Some((name, value));
416        *ctx.index.lock().unwrap() = index;
417    }
418}
419
420/// A lazily-evaluated dict entry. The first access through
421/// `Evaluator::force_thunk` parses + evaluates `node` against `scope` and
422/// caches the result in `value`; later accesses return the cached value.
423///
424/// All fields are `pub` so that backend crates (currently
425/// `relon-evaluator`) can read the captured node / scope when forcing the
426/// thunk — they live in a distinct crate from `Thunk`, so a `pub(crate)`
427/// visibility would block them.
428pub struct Thunk {
429    pub node: Node,
430    pub scope: Arc<Scope>,
431    pub path: Vec<String>,
432    pub cache_key: String,
433    pub value: Mutex<Option<Value>>,
434}
435
436impl Thunk {
437    pub fn new(node: Node, scope: Arc<Scope>, path: Vec<String>, cache_key: String) -> Self {
438        Self {
439            node,
440            scope,
441            path,
442            cache_key,
443            value: Mutex::new(None),
444        }
445    }
446}