Skip to main content

harn_vm/value/
env.rs

1use std::collections::BTreeMap;
2use std::rc::Rc;
3use std::{cell::RefCell, path::PathBuf};
4
5use crate::chunk::CompiledFunctionRef;
6
7use super::{VmError, 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<ModuleFunctionRegistry>,
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 `Rc<RefCell<VmEnv>>`, 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<ModuleState>,
35}
36
37pub type ModuleFunctionRegistry = Rc<RefCell<BTreeMap<String, Rc<VmClosure>>>>;
38pub type ModuleState = Rc<RefCell<VmEnv>>;
39
40/// VM environment for variable storage.
41///
42/// `Scope::vars` is wrapped in `Rc` so that `VmEnv::clone()` is cheap
43/// (Rc bump per scope) instead of a deep walk of every BTreeMap. The
44/// VM saves and restores `env` snapshots on every function call, and
45/// the call hot path dominates orchestration-heavy workloads (e.g.
46/// burin-code's PreToolUse hooks). With `Rc<BTreeMap<..>>` the
47/// per-scope clone collapses to an atomic-less refcount bump, and
48/// `Rc::make_mut` only does a deep copy when the scope is still shared
49/// with a saved snapshot — which is exactly the case where the caller
50/// would have needed an isolated copy anyway. Reads still go through
51/// the `BTreeMap` directly via `Deref`.
52#[derive(Debug, Clone)]
53pub struct VmEnv {
54    pub(crate) scopes: Vec<Scope>,
55}
56
57#[derive(Debug, Clone)]
58pub(crate) struct Scope {
59    pub(crate) vars: Rc<BTreeMap<String, (VmValue, bool)>>, // (value, mutable)
60}
61
62impl Scope {
63    #[inline]
64    fn empty() -> Self {
65        Self {
66            vars: Rc::new(BTreeMap::new()),
67        }
68    }
69}
70
71impl Default for VmEnv {
72    fn default() -> Self {
73        Self::new()
74    }
75}
76
77impl VmEnv {
78    pub fn new() -> Self {
79        Self {
80            scopes: vec![Scope::empty()],
81        }
82    }
83
84    pub fn push_scope(&mut self) {
85        self.scopes.push(Scope::empty());
86    }
87
88    pub fn pop_scope(&mut self) {
89        if self.scopes.len() > 1 {
90            self.scopes.pop();
91        }
92    }
93
94    pub fn scope_depth(&self) -> usize {
95        self.scopes.len()
96    }
97
98    pub fn truncate_scopes(&mut self, target_depth: usize) {
99        let min_depth = target_depth.max(1);
100        while self.scopes.len() > min_depth {
101            self.scopes.pop();
102        }
103    }
104
105    pub fn get(&self, name: &str) -> Option<VmValue> {
106        for scope in self.scopes.iter().rev() {
107            if let Some((val, _)) = scope.vars.get(name) {
108                return Some(val.clone());
109            }
110        }
111        None
112    }
113
114    pub(crate) fn contains(&self, name: &str) -> bool {
115        self.scopes
116            .iter()
117            .rev()
118            .any(|scope| scope.vars.contains_key(name))
119    }
120
121    pub fn define(&mut self, name: &str, value: VmValue, mutable: bool) -> Result<(), VmError> {
122        if let Some(scope) = self.scopes.last_mut() {
123            if let Some((_, existing_mutable)) = scope.vars.get(name) {
124                if !existing_mutable && !mutable {
125                    return Err(VmError::Runtime(format!(
126                        "Cannot redeclare immutable variable '{name}' in the same scope (use 'var' for mutable bindings)"
127                    )));
128                }
129            }
130            Rc::make_mut(&mut scope.vars).insert(name.to_string(), (value, mutable));
131        }
132        Ok(())
133    }
134
135    pub fn all_variables(&self) -> BTreeMap<String, VmValue> {
136        let mut vars = BTreeMap::new();
137        for scope in &self.scopes {
138            for (name, (value, _)) in scope.vars.iter() {
139                vars.insert(name.clone(), value.clone());
140            }
141        }
142        vars
143    }
144
145    pub fn assign(&mut self, name: &str, value: VmValue) -> Result<(), VmError> {
146        for scope in self.scopes.iter_mut().rev() {
147            if let Some((_, mutable)) = scope.vars.get(name) {
148                if !mutable {
149                    return Err(VmError::ImmutableAssignment(name.to_string()));
150                }
151                Rc::make_mut(&mut scope.vars).insert(name.to_string(), (value, true));
152                return Ok(());
153            }
154        }
155        Err(VmError::UndefinedVariable(name.to_string()))
156    }
157
158    /// Debugger-only variant of `assign` that rebinds the name even if
159    /// the existing binding was declared with `let`. Pipeline authors
160    /// overwhelmingly use `let`, so a strict mutability check would
161    /// make the DAP `setVariable` request useless for "what-if"
162    /// iteration — which is the whole point of the feature. Preserves
163    /// the original mutability flag so the VM's runtime behavior is
164    /// unchanged after the debugger overrides.
165    pub fn assign_debug(&mut self, name: &str, value: VmValue) -> Result<(), VmError> {
166        for scope in self.scopes.iter_mut().rev() {
167            if let Some((_, mutable)) = scope.vars.get(name) {
168                let mutable = *mutable;
169                Rc::make_mut(&mut scope.vars).insert(name.to_string(), (value, mutable));
170                return Ok(());
171            }
172        }
173        Err(VmError::UndefinedVariable(name.to_string()))
174    }
175}
176
177/// Compute Levenshtein edit distance between two strings.
178fn levenshtein(a: &str, b: &str) -> usize {
179    let a: Vec<char> = a.chars().collect();
180    let b: Vec<char> = b.chars().collect();
181    let (m, n) = (a.len(), b.len());
182    let mut prev = (0..=n).collect::<Vec<_>>();
183    let mut curr = vec![0; n + 1];
184    for i in 1..=m {
185        curr[0] = i;
186        for j in 1..=n {
187            let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
188            curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
189        }
190        std::mem::swap(&mut prev, &mut curr);
191    }
192    prev[n]
193}
194
195/// Find the closest match from a list of candidates using Levenshtein distance.
196/// Returns `Some(suggestion)` if a candidate is within `max_dist` edits.
197pub fn closest_match<'a>(name: &str, candidates: impl Iterator<Item = &'a str>) -> Option<String> {
198    let max_dist = match name.len() {
199        0..=2 => 1,
200        3..=5 => 2,
201        _ => 3,
202    };
203    candidates
204        .filter(|c| *c != name && !c.starts_with("__"))
205        .map(|c| (c, levenshtein(name, c)))
206        .filter(|(_, d)| *d <= max_dist)
207        // Prefer smallest distance, then closest length to original, then alphabetical
208        .min_by(|(a, da), (b, db)| {
209            da.cmp(db)
210                .then_with(|| {
211                    let a_diff = (a.len() as isize - name.len() as isize).unsigned_abs();
212                    let b_diff = (b.len() as isize - name.len() as isize).unsigned_abs();
213                    a_diff.cmp(&b_diff)
214                })
215                .then_with(|| a.cmp(b))
216        })
217        .map(|(c, _)| c.to_string())
218}