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