Skip to main content

harn_vm/value/
env.rs

1use std::collections::BTreeMap;
2use std::path::PathBuf;
3use std::sync::{Arc, Weak};
4
5use crate::chunk::CompiledFunctionRef;
6
7use super::{VmError, VmMutex, VmValue};
8
9/// A compiled closure value.
10#[derive(Debug, Clone)]
11pub struct VmClosure {
12    pub func: CompiledFunctionRef,
13    pub env: VmEnv,
14    /// Source directory for this closure's originating module.
15    /// When set, `render()` and other source-relative builtins resolve
16    /// paths relative to this directory instead of the entry pipeline.
17    pub source_dir: Option<PathBuf>,
18    /// Module-local named functions that should resolve before builtin fallback.
19    /// This lets selectively imported functions keep private sibling helpers
20    /// without exporting them into the caller's environment.
21    pub module_functions: Option<WeakModuleFunctionRegistry>,
22    /// Shared, mutable module-level env: holds top-level `var` / `let`
23    /// bindings declared at the module root (caches, counters, lazily
24    /// initialized registries). All closures created from the same
25    /// module import point at the same shared mutable env, so a
26    /// mutation inside one function is visible to every other function
27    /// in that module on subsequent calls. `closure.env` still holds
28    /// the per-closure lexical snapshot (captured function args from
29    /// enclosing scopes, etc.) and is unchanged by this — `module_state`
30    /// is a separate lookup layer consulted after the local env and
31    /// before globals. Created in `import_declarations` after the
32    /// module's init chunk runs, so the initial values from `var x = ...`
33    /// land in it.
34    pub module_state: Option<WeakModuleState>,
35    /// Strong owners of this closure's module scope, pinned only when the
36    /// closure is stored in a process/thread-local registry that outlives the
37    /// VM that created it (reminder providers, session/lifecycle hooks). See
38    /// [`RetainedModuleScope`] and [`VmClosure::retained_for_host_registry`].
39    /// `None` for the overwhelmingly common short-lived closure, whose module
40    /// scope stays alive through the live VM's `module_cache`.
41    pub retained_module_scope: Option<Arc<RetainedModuleScope>>,
42}
43
44/// A VM function that is either already resolved or can be resolved from a
45/// module export against the VM that will invoke it.
46#[derive(Clone, Debug)]
47pub enum VmCallable {
48    Eager(Arc<VmClosure>),
49    Lazy(LazyVmCallable),
50}
51
52/// Module/export coordinates for a callable whose import graph should not be
53/// instantiated until it is actually invoked.
54#[derive(Clone, Debug, PartialEq, Eq)]
55pub struct LazyVmCallable {
56    pub(crate) module_path: PathBuf,
57    pub(crate) function_name: String,
58}
59
60impl LazyVmCallable {
61    pub fn new(module_path: PathBuf, function_name: impl Into<String>) -> Self {
62        Self {
63            module_path,
64            function_name: function_name.into(),
65        }
66    }
67}
68
69pub type ModuleFunctionRegistry = Arc<VmMutex<BTreeMap<String, Arc<VmClosure>>>>;
70pub type WeakModuleFunctionRegistry = Weak<VmMutex<BTreeMap<String, Arc<VmClosure>>>>;
71pub type ModuleState = Arc<VmMutex<VmEnv>>;
72pub type WeakModuleState = Weak<VmMutex<VmEnv>>;
73
74/// Strong owners of a closure's module function table and module-level state.
75///
76/// A [`VmClosure`] resolves sibling module `pub fn`s through its module's
77/// function registry, which it references only via a [`Weak`]
78/// ([`VmClosure::module_functions`] / [`module_state`](VmClosure::module_state)).
79/// The sole strong owner of that registry is normally the registering VM's
80/// `module_cache`. When a closure is registered into a process/thread-local
81/// registry (reminder providers, session/lifecycle hooks) it outlives that VM;
82/// once the VM tears down, the `Weak` dangles and a sibling-fn call inside the
83/// invoked closure falls through name resolution to host-bridge dispatch. This
84/// pins strong owners so the `Weak` stays upgradeable for the closure's whole
85/// retained lifetime.
86///
87/// The fields are intentionally unread — their sole purpose is to keep the
88/// referenced `Arc`s alive.
89#[derive(Debug)]
90pub struct RetainedModuleScope {
91    _functions: Option<ModuleFunctionRegistry>,
92    _state: Option<ModuleState>,
93}
94
95impl VmClosure {
96    pub(crate) fn module_functions(&self) -> Option<ModuleFunctionRegistry> {
97        self.module_functions
98            .as_ref()
99            .and_then(WeakModuleFunctionRegistry::upgrade)
100    }
101
102    pub(crate) fn module_state(&self) -> Option<ModuleState> {
103        self.module_state
104            .as_ref()
105            .and_then(WeakModuleState::upgrade)
106    }
107
108    /// Return a clone of this closure suitable for storage in a process- or
109    /// thread-local registry that outlives the VM that created it (reminder
110    /// providers, session/lifecycle hooks). The clone pins strong owners of
111    /// this closure's module function table and module-level state
112    /// ([`RetainedModuleScope`]), so its body still resolves sibling module
113    /// `pub fn`s after the registering VM — the only other strong owner, via
114    /// `module_cache` — is dropped.
115    ///
116    /// The owners are pinned on a *clone* (a fresh `Arc<VmClosure>` that is
117    /// never itself a member of any function registry), so retaining a closure
118    /// that IS a module `pub fn` cannot form an `Arc` cycle with its registry.
119    ///
120    /// A no-op refcount bump when there is nothing to pin: the closure is
121    /// already pinned, or its `Weak`s do not upgrade — e.g. an entry-chunk
122    /// closure whose sibling functions live in captured `env` rather than a
123    /// module registry, which resolves without this.
124    pub(crate) fn retained_for_host_registry(self: &Arc<Self>) -> Arc<Self> {
125        if self.retained_module_scope.is_some() {
126            return Arc::clone(self);
127        }
128        let functions = self.module_functions();
129        let state = self.module_state();
130        if functions.is_none() && state.is_none() {
131            return Arc::clone(self);
132        }
133        let mut pinned = (**self).clone();
134        pinned.retained_module_scope = Some(Arc::new(RetainedModuleScope {
135            _functions: functions,
136            _state: state,
137        }));
138        Arc::new(pinned)
139    }
140}
141
142/// VM environment for variable storage.
143///
144/// `Scope::vars` is wrapped in `Arc` so that `VmEnv::clone()` is cheap
145/// (Arc bump per scope) instead of a deep walk of every BTreeMap. The
146/// VM saves and restores `env` snapshots on every function call, and
147/// the call hot path dominates orchestration-heavy workloads. With
148/// `Arc<BTreeMap<..>>`, the per-scope clone collapses to a refcount
149/// bump, and `Arc::make_mut` only does a deep copy when the scope is
150/// still shared with a saved snapshot — which is exactly the case where
151/// the caller would have needed an isolated copy anyway. Reads still go
152/// through the `BTreeMap` directly via `Deref`.
153#[derive(Debug, Clone)]
154pub struct VmEnv {
155    pub(crate) scopes: Vec<Scope>,
156}
157
158/// A shared, mutable cell backing a captured binding.
159///
160/// A local that a nested closure captures is stored behind a `Cell` instead of
161/// inline. Cloning a [`Scope`] (which happens on every call and every closure
162/// mint) refcount-bumps this `Arc`, so the defining frame and every closure
163/// that captured the binding all point at the *same* cell — a write through any
164/// of them is observed by all of them. This is what makes closure capture
165/// **by reference** (JS/Python/Swift semantics) while keeping distinct
166/// variables independent (`let b = a` still copies the value out of `a`'s
167/// cell into `b`'s binding). See `docs/design/closure-reference-capture.md`.
168pub(crate) type BindingCell = Arc<VmMutex<VmValue>>;
169
170/// One name's binding in a [`Scope`].
171///
172/// `Value` is the ordinary, unshared binding — a read clones the value out and
173/// a write replaces it (copy-on-assignment), exactly as before. `Cell` is a
174/// binding captured by a nested closure: the value lives behind a shared
175/// [`BindingCell`] so reads clone the inner value out (value semantics for
176/// reads is preserved) and writes go *through* the cell rather than replacing
177/// the map entry — which also sidesteps the scope-map copy-on-write, so shared
178/// mutation survives the per-call env clone.
179#[derive(Debug, Clone)]
180pub(crate) enum Binding {
181    Value { value: VmValue, mutable: bool },
182    Cell { cell: BindingCell, mutable: bool },
183}
184
185impl Binding {
186    #[inline]
187    pub(crate) fn mutable(&self) -> bool {
188        match self {
189            Binding::Value { mutable, .. } | Binding::Cell { mutable, .. } => *mutable,
190        }
191    }
192
193    /// The current value of this binding, cloned out. Reads never expose the
194    /// cell itself — value semantics for reads is identical for both variants.
195    #[inline]
196    pub(crate) fn read(&self) -> VmValue {
197        match self {
198            Binding::Value { value, .. } => value.clone(),
199            Binding::Cell { cell, .. } => cell.lock().clone(),
200        }
201    }
202
203    /// Ownership-taking accessor for the iterative teardown paths. A `Value`
204    /// yields its inner value directly. A `Cell` yields its inner value only
205    /// when this binding holds the *last* reference to the shared cell; a
206    /// still-shared cell yields `None` and is left for its own `Arc` drop to
207    /// reclaim once the final closure releases it.
208    #[inline]
209    pub(crate) fn into_teardown_value(self) -> Option<VmValue> {
210        match self {
211            Binding::Value { value, .. } => Some(value),
212            Binding::Cell { cell, .. } => Arc::into_inner(cell).map(VmMutex::into_inner),
213        }
214    }
215
216    /// Whether this binding *uniquely* owns a deeply-nested container that the
217    /// default recursive drop could overflow the native stack on. A `Value`
218    /// checks its container directly. A `Cell` only qualifies when unshared
219    /// (`strong_count == 1`) — a cell still held by a live closure must not be
220    /// torn down from here — and is peeked with `try_lock` so a drop never
221    /// blocks.
222    #[inline]
223    fn owns_recursive_container(&self) -> bool {
224        match self {
225            Binding::Value { value, .. } => super::recursion::is_recursive_container(value),
226            Binding::Cell { cell, .. } => {
227                Arc::strong_count(cell) == 1
228                    && cell
229                        .try_lock()
230                        .map(|v| super::recursion::is_recursive_container(&v))
231                        .unwrap_or(false)
232            }
233        }
234    }
235}
236
237#[derive(Debug, Clone)]
238pub(crate) struct Scope {
239    pub(crate) vars: Arc<BTreeMap<String, Binding>>,
240}
241
242/// Process-wide shared empty binding map.
243///
244/// Every block entry pushes a fresh [`Scope`], but inside a function body its
245/// bindings compile to local slots (`DefLocalSlot`) rather than env writes, so
246/// the pushed scope is overwhelmingly *empty* — a hot loop whose body is a
247/// block would otherwise `Arc::new(BTreeMap::new())`-allocate (and free) one
248/// map per iteration. Sharing a single immutable empty map makes
249/// [`Scope::empty`] a refcount bump instead; the first real `define`/`assign`
250/// copies-on-write away from this shared map via `Arc::make_mut` (the insert
251/// paths already do), so a scope that never binds anything never allocates.
252static EMPTY_SCOPE_VARS: std::sync::LazyLock<Arc<BTreeMap<String, Binding>>> =
253    std::sync::LazyLock::new(|| Arc::new(BTreeMap::new()));
254
255impl Scope {
256    #[inline]
257    fn empty() -> Self {
258        Self {
259            vars: Arc::clone(&EMPTY_SCOPE_VARS),
260        }
261    }
262}
263
264impl Drop for Scope {
265    fn drop(&mut self) {
266        // Deeply nested script values (e.g. `x = [x]` built in a loop, which
267        // adds no VM call frames and so never trips `max_vm_frames`) live in
268        // scope bindings. Their default recursive drop would overflow the
269        // native stack and abort the whole process — an uncatchable failure.
270        // When this scope holds the last reference to its bindings and any
271        // value is a nested container, tear the bindings down iteratively
272        // instead. `Arc::get_mut` succeeds only for a uniquely-owned scope, so
273        // shared snapshots fall through to the cheap default drop and the real
274        // teardown happens later at the last owner (also a `Scope`).
275        //
276        // A still-shared `Cell` may outlive this scope (a live closure holds
277        // it), so its `Arc` refcount — not this map's — governs when its inner
278        // value drops. `into_teardown_value` therefore only reclaims a cell we
279        // uniquely own; shared cells fall through to their own `Arc` drop.
280        if let Some(map) = Arc::get_mut(&mut self.vars) {
281            if map.values().any(Binding::owns_recursive_container) {
282                let bindings = std::mem::take(map);
283                super::recursion::dismantle_values(
284                    bindings
285                        .into_values()
286                        .filter_map(Binding::into_teardown_value),
287                );
288            }
289        }
290    }
291}
292
293impl Default for VmEnv {
294    fn default() -> Self {
295        Self::new()
296    }
297}
298
299impl VmEnv {
300    pub fn new() -> Self {
301        Self {
302            scopes: vec![Scope::empty()],
303        }
304    }
305
306    pub fn push_scope(&mut self) {
307        self.scopes.push(Scope::empty());
308    }
309
310    /// Clone the scope stack for a fresh call frame, reserving room for the
311    /// one empty scope every invocation pushes for the callee's body.
312    ///
313    /// `Vec::clone` allocates at exactly `len` capacity, so the `push_scope`
314    /// that immediately follows on the call hot path would otherwise force a
315    /// reallocation and copy of the whole scope stack. Reserving the extra
316    /// slot up front folds those two allocations into one. When a caller does
317    /// not end up pushing (no path currently does, but it stays correct if one
318    /// is added), the only cost is a single unused `Scope` slot of capacity.
319    pub(crate) fn cloned_for_call(&self) -> VmEnv {
320        let mut scopes = Vec::with_capacity(self.scopes.len() + 1);
321        scopes.extend(self.scopes.iter().cloned());
322        VmEnv { scopes }
323    }
324
325    pub fn pop_scope(&mut self) {
326        if self.scopes.len() > 1 {
327            self.scopes.pop();
328        }
329    }
330
331    pub fn scope_depth(&self) -> usize {
332        self.scopes.len()
333    }
334
335    pub fn truncate_scopes(&mut self, target_depth: usize) {
336        let min_depth = target_depth.max(1);
337        while self.scopes.len() > min_depth {
338            self.scopes.pop();
339        }
340    }
341
342    pub fn get(&self, name: &str) -> Option<VmValue> {
343        for scope in self.scopes.iter().rev() {
344            if let Some(binding) = scope.vars.get(name) {
345                return Some(binding.read());
346            }
347        }
348        None
349    }
350
351    pub(crate) fn contains(&self, name: &str) -> bool {
352        self.scopes
353            .iter()
354            .rev()
355            .any(|scope| scope.vars.contains_key(name))
356    }
357
358    pub fn define(&mut self, name: &str, value: VmValue, mutable: bool) -> Result<(), VmError> {
359        self.define_binding(name, Binding::Value { value, mutable })
360    }
361
362    /// Define `name` as a **captured** binding: a fresh shared cell holding
363    /// `value`. Emitted for a local that a nested closure captures. A closure
364    /// minted after this point clones the enclosing env (refcount-bumping the
365    /// cell), so its reads and writes of `name` flow through the same cell as
366    /// the defining frame. Called once per activation, so each activation gets
367    /// a distinct cell (per-iteration loop captures stay independent).
368    pub(crate) fn define_cell(
369        &mut self,
370        name: &str,
371        value: VmValue,
372        mutable: bool,
373    ) -> Result<(), VmError> {
374        self.define_binding(
375            name,
376            Binding::Cell {
377                cell: Arc::new(VmMutex::new(value)),
378                mutable,
379            },
380        )
381    }
382
383    fn define_binding(&mut self, name: &str, binding: Binding) -> Result<(), VmError> {
384        if let Some(scope) = self.scopes.last_mut() {
385            if let Some(existing) = scope.vars.get(name) {
386                if !existing.mutable() && !binding.mutable() {
387                    return Err(VmError::Runtime(format!(
388                        "Cannot redeclare immutable variable '{name}' in the same scope (use 'let' for mutable bindings)"
389                    )));
390                }
391            }
392            if let Some(Binding::Value { value, .. }) =
393                Arc::make_mut(&mut scope.vars).insert(name.to_string(), binding)
394            {
395                super::recursion::dismantle(value);
396            }
397        }
398        Ok(())
399    }
400
401    pub fn all_variables(&self) -> crate::value::DictMap {
402        let mut vars = crate::value::DictMap::new();
403        for scope in &self.scopes {
404            for (name, binding) in scope.vars.iter() {
405                vars.insert(crate::value::intern_key(name), binding.read());
406            }
407        }
408        vars
409    }
410
411    pub fn assign(&mut self, name: &str, value: VmValue) -> Result<(), VmError> {
412        for scope in self.scopes.iter_mut().rev() {
413            let Some(existing) = scope.vars.get(name) else {
414                continue;
415            };
416            if !existing.mutable() {
417                return Err(VmError::ImmutableAssignment(name.to_string()));
418            }
419            match existing {
420                // Write *through* the shared cell: the entry is not replaced,
421                // so the scope-map copy-on-write is sidestepped and every
422                // holder of this cell (the defining frame, sibling closures)
423                // observes the update.
424                Binding::Cell { cell, .. } => {
425                    let previous = std::mem::replace(&mut *cell.lock(), value);
426                    super::recursion::dismantle(previous);
427                }
428                Binding::Value { .. } => {
429                    // Iterative teardown so overwriting a deeply nested binding
430                    // cannot overflow the stack on drop (scalars are a no-op).
431                    // The prior binding here is always a `Value` (a name is
432                    // either always boxed or never — see the compiler's capture
433                    // pre-pass), so only that arm needs draining.
434                    if let Some(Binding::Value { value, .. }) = Arc::make_mut(&mut scope.vars)
435                        .insert(
436                            name.to_string(),
437                            Binding::Value {
438                                value,
439                                mutable: true,
440                            },
441                        )
442                    {
443                        super::recursion::dismantle(value);
444                    }
445                }
446            }
447            return Ok(());
448        }
449        Err(VmError::UndefinedVariable(name.to_string()))
450    }
451
452    /// Debugger-only variant of `assign` that rebinds the name even if
453    /// the existing binding was declared with `let`. Pipeline authors
454    /// overwhelmingly use `let`, so a strict mutability check would
455    /// make the DAP `setVariable` request useless for "what-if"
456    /// iteration — which is the whole point of the feature. Preserves
457    /// the original mutability flag so the VM's runtime behavior is
458    /// unchanged after the debugger overrides.
459    pub fn assign_debug(&mut self, name: &str, value: VmValue) -> Result<(), VmError> {
460        for scope in self.scopes.iter_mut().rev() {
461            let Some(existing) = scope.vars.get(name) else {
462                continue;
463            };
464            match existing {
465                // Preserve the shared-cell identity so a debugger override of a
466                // captured binding is still observed by the closures holding it.
467                Binding::Cell { cell, .. } => {
468                    *cell.lock() = value;
469                }
470                Binding::Value { mutable, .. } => {
471                    let mutable = *mutable;
472                    Arc::make_mut(&mut scope.vars)
473                        .insert(name.to_string(), Binding::Value { value, mutable });
474                }
475            }
476            return Ok(());
477        }
478        Err(VmError::UndefinedVariable(name.to_string()))
479    }
480}
481
482/// Compute Levenshtein edit distance between two strings.
483fn levenshtein(a: &str, b: &str) -> usize {
484    let a: Vec<char> = a.chars().collect();
485    let b: Vec<char> = b.chars().collect();
486    let (m, n) = (a.len(), b.len());
487    let mut prev = (0..=n).collect::<Vec<_>>();
488    let mut curr = vec![0; n + 1];
489    for i in 1..=m {
490        curr[0] = i;
491        for j in 1..=n {
492            let cost = usize::from(a[i - 1] != b[j - 1]);
493            curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
494        }
495        std::mem::swap(&mut prev, &mut curr);
496    }
497    prev[n]
498}
499
500/// Find the closest match from a list of candidates using Levenshtein distance.
501/// Returns `Some(suggestion)` if a candidate is within `max_dist` edits.
502pub fn closest_match<'a>(name: &str, candidates: impl Iterator<Item = &'a str>) -> Option<String> {
503    let max_dist = match name.len() {
504        0..=2 => 1,
505        3..=5 => 2,
506        _ => 3,
507    };
508    candidates
509        .filter(|c| *c != name && !c.starts_with("__"))
510        .map(|c| (c, levenshtein(name, c)))
511        .filter(|(_, d)| *d <= max_dist)
512        // Prefer smallest distance, then closest length to original, then alphabetical
513        .min_by(|(a, da), (b, db)| {
514            da.cmp(db)
515                .then_with(|| {
516                    let a_diff = (a.len() as isize - name.len() as isize).unsigned_abs();
517                    let b_diff = (b.len() as isize - name.len() as isize).unsigned_abs();
518                    a_diff.cmp(&b_diff)
519                })
520                .then_with(|| a.cmp(b))
521        })
522        .map(|(c, _)| c.to_string())
523}
524
525#[cfg(test)]
526mod scope_alloc_tests {
527    use super::*;
528
529    #[test]
530    fn empty_scopes_share_one_backing_map() {
531        // Pushing block scopes (the per-iteration cost in a loop body) must not
532        // allocate: every empty scope shares the process-wide empty map.
533        let mut env = VmEnv::new();
534        env.push_scope();
535        env.push_scope();
536        for scope in &env.scopes {
537            assert!(Arc::ptr_eq(&scope.vars, &EMPTY_SCOPE_VARS));
538        }
539    }
540
541    #[test]
542    fn define_copies_on_write_without_disturbing_siblings() {
543        let mut env = VmEnv::new();
544        env.push_scope(); // shares EMPTY
545        env.define("x", VmValue::Int(1), true).unwrap();
546        // The bound scope copied on write away from the shared empty map...
547        let top = env.scopes.last().unwrap();
548        assert!(!Arc::ptr_eq(&top.vars, &EMPTY_SCOPE_VARS));
549        // ...while the root scope (untouched) still shares it.
550        assert!(Arc::ptr_eq(&env.scopes[0].vars, &EMPTY_SCOPE_VARS));
551        assert!(matches!(env.get("x"), Some(VmValue::Int(1))));
552        // Popping the scope drops the binding entirely.
553        env.pop_scope();
554        assert!(env.get("x").is_none());
555    }
556}