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}