Skip to main content

harn_vm/value/
env.rs

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