Skip to main content

harn_vm/
vm.rs

1mod format;
2mod imports;
3pub mod iter;
4mod methods;
5mod ops;
6
7use std::cell::RefCell;
8use std::collections::{BTreeMap, HashSet};
9use std::future::Future;
10use std::pin::Pin;
11use std::rc::Rc;
12use std::time::Instant;
13
14use crate::chunk::{Chunk, CompiledFunction, Constant};
15use crate::value::{
16    ErrorCategory, ModuleFunctionRegistry, VmAsyncBuiltinFn, VmBuiltinFn, VmClosure, VmEnv,
17    VmError, VmTaskHandle, VmValue,
18};
19
20thread_local! {
21    static CURRENT_ASYNC_BUILTIN_CHILD_VM: RefCell<Vec<Vm>> = const { RefCell::new(Vec::new()) };
22}
23
24/// RAII guard that starts a tracing span on creation and ends it on drop.
25struct ScopeSpan(u64);
26
27impl ScopeSpan {
28    fn new(kind: crate::tracing::SpanKind, name: String) -> Self {
29        Self(crate::tracing::span_start(kind, name))
30    }
31}
32
33impl Drop for ScopeSpan {
34    fn drop(&mut self) {
35        crate::tracing::span_end(self.0);
36    }
37}
38
39/// Call frame for function execution.
40pub(crate) struct CallFrame {
41    pub(crate) chunk: Chunk,
42    pub(crate) ip: usize,
43    pub(crate) stack_base: usize,
44    pub(crate) saved_env: VmEnv,
45    /// Env snapshot captured at call-time, *after* argument binding. Used
46    /// by the debugger's `restartFrame` to rewind this frame to its
47    /// entry state (re-binding args from the original values) without
48    /// re-entering the call site. Cheap to clone because `VmEnv` is
49    /// already cloned into `saved_env` on every call. `None` for
50    /// scratch frames (evaluate, import init) where restart isn't
51    /// meaningful.
52    pub(crate) initial_env: Option<VmEnv>,
53    /// Iterator stack depth to restore when this frame unwinds.
54    pub(crate) saved_iterator_depth: usize,
55    /// Function name for stack traces (empty for top-level pipeline).
56    pub(crate) fn_name: String,
57    /// Number of arguments actually passed by the caller (for default arg support).
58    pub(crate) argc: usize,
59    /// Saved VM_SOURCE_DIR to restore when this frame is popped.
60    /// Set when entering a closure that originated from an imported module.
61    pub(crate) saved_source_dir: Option<std::path::PathBuf>,
62    /// Module-local named functions available to symbolic calls within this frame.
63    pub(crate) module_functions: Option<ModuleFunctionRegistry>,
64    /// Shared module-level env for top-level `var` / `let` bindings of
65    /// this frame's originating module. Looked up after `self.env` and
66    /// before `self.globals` by `GetVar` / `SetVar`, giving each module
67    /// its own live static state that persists across calls. See the
68    /// `module_state` field on `VmClosure` for the full rationale.
69    pub(crate) module_state: Option<crate::value::ModuleState>,
70}
71
72/// Exception handler for try/catch.
73pub(crate) struct ExceptionHandler {
74    pub(crate) catch_ip: usize,
75    pub(crate) stack_depth: usize,
76    pub(crate) frame_depth: usize,
77    pub(crate) env_scope_depth: usize,
78    /// If non-empty, this catch only handles errors whose enum_name matches.
79    pub(crate) error_type: String,
80}
81
82/// Debug action returned by the debug hook.
83#[derive(Debug, Clone, PartialEq)]
84pub enum DebugAction {
85    /// Continue execution normally.
86    Continue,
87    /// Stop (breakpoint hit, step complete).
88    Stop,
89}
90
91/// Information about current execution state for the debugger.
92#[derive(Debug, Clone)]
93pub struct DebugState {
94    pub line: usize,
95    pub variables: BTreeMap<String, VmValue>,
96    pub frame_name: String,
97    pub frame_depth: usize,
98}
99
100type DebugHook = dyn FnMut(&DebugState) -> DebugAction;
101
102/// Iterator state for for-in loops: either a pre-collected vec, an async channel, or a generator.
103pub(crate) enum IterState {
104    Vec {
105        items: Vec<VmValue>,
106        idx: usize,
107    },
108    Channel {
109        receiver: std::sync::Arc<tokio::sync::Mutex<tokio::sync::mpsc::Receiver<VmValue>>>,
110        closed: std::sync::Arc<std::sync::atomic::AtomicBool>,
111    },
112    Generator {
113        gen: crate::value::VmGenerator,
114    },
115    /// Step through a lazy range without materializing a Vec.
116    /// `next` holds the value to emit on the next IterNext; `stop` is
117    /// the first value that terminates the iteration (one past the end).
118    Range {
119        next: i64,
120        stop: i64,
121    },
122    VmIter {
123        handle: std::rc::Rc<std::cell::RefCell<crate::vm::iter::VmIter>>,
124    },
125}
126
127#[derive(Clone)]
128pub(crate) struct LoadedModule {
129    pub(crate) functions: BTreeMap<String, Rc<VmClosure>>,
130    pub(crate) public_names: HashSet<String>,
131}
132
133/// The Harn bytecode virtual machine.
134pub struct Vm {
135    pub(crate) stack: Vec<VmValue>,
136    pub(crate) env: VmEnv,
137    pub(crate) output: String,
138    pub(crate) builtins: BTreeMap<String, VmBuiltinFn>,
139    pub(crate) async_builtins: BTreeMap<String, VmAsyncBuiltinFn>,
140    /// Iterator state for for-in loops.
141    pub(crate) iterators: Vec<IterState>,
142    /// Call frame stack.
143    pub(crate) frames: Vec<CallFrame>,
144    /// Exception handler stack.
145    pub(crate) exception_handlers: Vec<ExceptionHandler>,
146    /// Spawned async task handles.
147    pub(crate) spawned_tasks: BTreeMap<String, VmTaskHandle>,
148    /// Counter for generating unique task IDs.
149    pub(crate) task_counter: u64,
150    /// Active deadline stack: (deadline_instant, frame_depth).
151    pub(crate) deadlines: Vec<(Instant, usize)>,
152    /// Breakpoints, keyed by source-file path so a breakpoint at line N
153    /// in `auto.harn` doesn't also fire when execution hits line N in an
154    /// imported lib. The empty-string key is a wildcard used by callers
155    /// that don't track source paths (legacy `set_breakpoints` API).
156    pub(crate) breakpoints: BTreeMap<String, std::collections::BTreeSet<usize>>,
157    /// Function-name breakpoints. Any closure call whose
158    /// `CompiledFunction.name` matches an entry here raises a stop on
159    /// entry, regardless of the call site's file or line. Lets the IDE
160    /// break on `llm_call` / `host_run_pipeline` / any user pipeline
161    /// function without pinning down a source location first.
162    pub(crate) function_breakpoints: std::collections::BTreeSet<String>,
163    /// Latched on `push_closure_frame` when the callee's name matches
164    /// `function_breakpoints`; consumed by the next step so the stop is
165    /// reported with reason="function breakpoint" and the breakpoint
166    /// name available for the DAP `stopped` event.
167    pub(crate) pending_function_bp: Option<String>,
168    /// Whether the VM is in step mode.
169    pub(crate) step_mode: bool,
170    /// The frame depth at which stepping started (for step-over).
171    pub(crate) step_frame_depth: usize,
172    /// Whether the VM is currently stopped at a debug point.
173    pub(crate) stopped: bool,
174    /// Last source line executed (to detect line changes).
175    pub(crate) last_line: usize,
176    /// Source directory for resolving imports.
177    pub(crate) source_dir: Option<std::path::PathBuf>,
178    /// Modules currently being imported (cycle prevention).
179    pub(crate) imported_paths: Vec<std::path::PathBuf>,
180    /// Loaded module cache keyed by canonical or synthetic module path.
181    pub(crate) module_cache: BTreeMap<std::path::PathBuf, LoadedModule>,
182    /// Source file path for error reporting.
183    pub(crate) source_file: Option<String>,
184    /// Source text for error reporting.
185    pub(crate) source_text: Option<String>,
186    /// Optional bridge for delegating unknown builtins in bridge mode.
187    pub(crate) bridge: Option<Rc<crate::bridge::HostBridge>>,
188    /// Builtins denied by sandbox mode (`--deny` / `--allow` flags).
189    pub(crate) denied_builtins: HashSet<String>,
190    /// Cancellation token for cooperative graceful shutdown (set by parent).
191    pub(crate) cancel_token: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
192    /// Captured stack trace from the most recent error (fn_name, line, col).
193    pub(crate) error_stack_trace: Vec<(String, usize, usize, Option<String>)>,
194    /// Yield channel sender for generator execution. When set, `Op::Yield`
195    /// sends values through this channel instead of being a no-op.
196    pub(crate) yield_sender: Option<tokio::sync::mpsc::Sender<VmValue>>,
197    /// Project root directory (detected via harn.toml).
198    /// Used as base directory for metadata, store, and checkpoint operations.
199    pub(crate) project_root: Option<std::path::PathBuf>,
200    /// Global constants (e.g. `pi`, `e`). Checked as a fallback in `GetVar`
201    /// after the environment, so user-defined variables can shadow them.
202    pub(crate) globals: BTreeMap<String, VmValue>,
203    /// Optional debugger hook invoked when execution advances to a new source line.
204    pub(crate) debug_hook: Option<Box<DebugHook>>,
205}
206
207impl Vm {
208    pub fn new() -> Self {
209        Self {
210            stack: Vec::with_capacity(256),
211            env: VmEnv::new(),
212            output: String::new(),
213            builtins: BTreeMap::new(),
214            async_builtins: BTreeMap::new(),
215            iterators: Vec::new(),
216            frames: Vec::new(),
217            exception_handlers: Vec::new(),
218            spawned_tasks: BTreeMap::new(),
219            task_counter: 0,
220            deadlines: Vec::new(),
221            breakpoints: BTreeMap::new(),
222            function_breakpoints: std::collections::BTreeSet::new(),
223            pending_function_bp: None,
224            step_mode: false,
225            step_frame_depth: 0,
226            stopped: false,
227            last_line: 0,
228            source_dir: None,
229            imported_paths: Vec::new(),
230            module_cache: BTreeMap::new(),
231            source_file: None,
232            source_text: None,
233            bridge: None,
234            denied_builtins: HashSet::new(),
235            cancel_token: None,
236            error_stack_trace: Vec::new(),
237            yield_sender: None,
238            project_root: None,
239            globals: BTreeMap::new(),
240            debug_hook: None,
241        }
242    }
243
244    /// Set the bridge for delegating unknown builtins in bridge mode.
245    pub fn set_bridge(&mut self, bridge: Rc<crate::bridge::HostBridge>) {
246        self.bridge = Some(bridge);
247    }
248
249    /// Set builtins that are denied in sandbox mode.
250    /// When called, the given builtin names will produce a permission error.
251    pub fn set_denied_builtins(&mut self, denied: HashSet<String>) {
252        self.denied_builtins = denied;
253    }
254
255    /// Set source info for error reporting (file path and source text).
256    pub fn set_source_info(&mut self, file: &str, text: &str) {
257        self.source_file = Some(file.to_string());
258        self.source_text = Some(text.to_string());
259    }
260
261    /// Replace breakpoints for a single source file. Pass an empty string
262    /// (or call `set_breakpoints` for the wildcard equivalent) to install
263    /// breakpoints that match every file — useful for ad-hoc CLI runs
264    /// where the embedder doesn't track per-file source paths.
265    pub fn set_breakpoints_for_file(&mut self, file: &str, lines: Vec<usize>) {
266        if lines.is_empty() {
267            self.breakpoints.remove(file);
268            return;
269        }
270        self.breakpoints
271            .insert(file.to_string(), lines.into_iter().collect());
272    }
273
274    /// Backwards-compatible wildcard form. Stores all lines under the
275    /// empty-string key, which matches *any* source file at the check
276    /// site. Existing embedders that don't track file scoping still work.
277    pub fn set_breakpoints(&mut self, lines: Vec<usize>) {
278        self.set_breakpoints_for_file("", lines);
279    }
280
281    /// Replace the function-breakpoint set. Every subsequent closure
282    /// call whose name matches one of the provided strings will pause
283    /// on entry. Empty vec clears the set.
284    pub fn set_function_breakpoints(&mut self, names: Vec<String>) {
285        self.function_breakpoints = names.into_iter().collect();
286        // Clear any pending latch so a stale entry from the previous
287        // configuration doesn't fire once.
288        self.pending_function_bp = None;
289    }
290
291    /// Returns the current function-breakpoint name set. Used by the
292    /// DAP adapter to build the `setFunctionBreakpoints` response with
293    /// verified=true per registered name.
294    pub fn function_breakpoint_names(&self) -> Vec<String> {
295        self.function_breakpoints.iter().cloned().collect()
296    }
297
298    /// Drain any pending function-breakpoint name latched by the most
299    /// recent closure entry. Returns `Some(name)` exactly once per hit
300    /// so the caller can emit a single `stopped` event.
301    pub fn take_pending_function_bp(&mut self) -> Option<String> {
302        self.pending_function_bp.take()
303    }
304
305    /// Source file path of the currently executing frame, if known.
306    pub(crate) fn current_source_file(&self) -> Option<&str> {
307        self.frames
308            .last()
309            .and_then(|f| f.chunk.source_file.as_deref())
310    }
311
312    /// True when a breakpoint at `line` is set for the current frame's
313    /// source file (or the wildcard set covers it).
314    pub(crate) fn breakpoint_matches(&self, line: usize) -> bool {
315        if let Some(wild) = self.breakpoints.get("") {
316            if wild.contains(&line) {
317                return true;
318            }
319        }
320        if let Some(file) = self.current_source_file() {
321            if let Some(set) = self.breakpoints.get(file) {
322                if set.contains(&line) {
323                    return true;
324                }
325            }
326            // Some callers send a relative or differently-prefixed path
327            // than the chunk records; fall back to suffix comparison so
328            // foo.harn matches /abs/path/foo.harn and vice-versa.
329            for (key, set) in &self.breakpoints {
330                if key.is_empty() {
331                    continue;
332                }
333                if (file.ends_with(key.as_str()) || key.ends_with(file)) && set.contains(&line) {
334                    return true;
335                }
336            }
337        }
338        false
339    }
340
341    /// Enable step mode (stop at the next source line regardless of
342    /// frame depth — i.e. step-in semantics, descending into calls).
343    pub fn set_step_mode(&mut self, step: bool) {
344        self.step_mode = step;
345        self.step_frame_depth = usize::MAX;
346    }
347
348    /// Enable step-over mode (stop at the next source line in the current
349    /// frame or a shallower one, skipping past any nested calls).
350    pub fn set_step_over(&mut self) {
351        self.step_mode = true;
352        self.step_frame_depth = self.frames.len();
353    }
354
355    /// Register a debug hook invoked whenever execution advances to a new source line.
356    pub fn set_debug_hook<F>(&mut self, hook: F)
357    where
358        F: FnMut(&DebugState) -> DebugAction + 'static,
359    {
360        self.debug_hook = Some(Box::new(hook));
361    }
362
363    /// Clear the current debug hook.
364    pub fn clear_debug_hook(&mut self) {
365        self.debug_hook = None;
366    }
367
368    /// Enable step-out mode (stop at the next source line *after* the
369    /// current frame has returned — strictly shallower than where the
370    /// user requested the step-out).
371    pub fn set_step_out(&mut self) {
372        self.step_mode = true;
373        // Condition site compares `frames.len() <= step_frame_depth`, so
374        // storing N-1 makes the stop fire only after the current frame
375        // pops (frames.len() drops from N to N-1 or less). Clamp to 0 for
376        // the top frame — caller handles that via the usize::MAX sentinel
377        // if they wanted step-in semantics.
378        self.step_frame_depth = self.frames.len().saturating_sub(1);
379    }
380
381    /// Check if the VM is stopped at a debug point.
382    pub fn is_stopped(&self) -> bool {
383        self.stopped
384    }
385
386    /// Get the current debug state (variables, line, etc.).
387    pub fn debug_state(&self) -> DebugState {
388        let line = self.current_line();
389        let variables = self.env.all_variables();
390        let frame_name = if self.frames.len() > 1 {
391            format!("frame_{}", self.frames.len() - 1)
392        } else {
393            "pipeline".to_string()
394        };
395        DebugState {
396            line,
397            variables,
398            frame_name,
399            frame_depth: self.frames.len(),
400        }
401    }
402
403    /// Call sites (name + ip) on `line` within the current frame's
404    /// chunk — drives DAP `stepInTargets` (#112). Walks the chunk's
405    /// parallel lines array, surfaces every Call / MethodCall /
406    /// CallSpread and pairs it with the name of the constant or
407    /// identifier preceding the call when we can derive it cheaply.
408    pub fn call_sites_on_line(&self, line: u32) -> Vec<(u32, String)> {
409        let Some(frame) = self.frames.last() else {
410            return Vec::new();
411        };
412        let chunk = &frame.chunk;
413        let mut out = Vec::new();
414        let code = &chunk.code;
415        let lines = &chunk.lines;
416        let mut ip: usize = 0;
417        while ip < code.len() {
418            let op = code[ip];
419            if ip < lines.len() && lines[ip] == line {
420                // 0x00 .. 0x99 covers the opcode space the compiler
421                // emits for calls. Rather than decode every op, we
422                // pattern-match on the Call-family opcodes via
423                // their numeric tag — stable because harn-vm locks
424                // opcodes with pin tests.
425                if matches!(op, 0x40..=0x44) {
426                    // Best-effort label: take the most recent
427                    // LoadConst / LoadGlobal constant value.
428                    let label = Self::label_preceding_call(chunk, ip);
429                    out.push((ip as u32, label));
430                }
431            }
432            ip += 1;
433        }
434        out
435    }
436
437    fn label_preceding_call(chunk: &crate::chunk::Chunk, call_ip: usize) -> String {
438        // Walk backwards a few instructions to find a LoadConst that
439        // resolves to a string (the callee name). Good enough for
440        // the IDE menu; deep callee resolution can land later if
441        // needed.
442        let mut back = call_ip.saturating_sub(6);
443        while back < call_ip {
444            let op = chunk.code[back];
445            // LoadConst opcodes (range covers the two-byte tag) —
446            // fall back to "call" when none found.
447            if (op == 0x01 || op == 0x02) && back + 2 < chunk.code.len() {
448                let idx = (u16::from(chunk.code[back + 1]) << 8) | u16::from(chunk.code[back + 2]);
449                if let Some(crate::chunk::Constant::String(s)) = chunk.constants.get(idx as usize) {
450                    return s.clone();
451                }
452            }
453            back += 1;
454        }
455        "call".to_string()
456    }
457
458    /// Install (or replace) the cooperative cancellation token on
459    /// this VM. Callers (DAP adapter, embedded host) flip the
460    /// wrapped AtomicBool to request graceful shutdown; the step
461    /// loop checks `is_cancel_requested()` at every instruction and
462    /// exits with `VmError::Cancelled` when set.
463    pub fn install_cancel_token(&mut self, token: std::sync::Arc<std::sync::atomic::AtomicBool>) {
464        self.cancel_token = Some(token);
465    }
466
467    /// Signal cooperative cancellation on this VM — the step loop
468    /// unwinds on its next instruction check. Lazily allocates a
469    /// fresh token when none is installed so hosts don't need to
470    /// pre-plumb it on every launch. Returns the Arc so the caller
471    /// can hold onto it and re-signal later if needed.
472    pub fn signal_cancel(&mut self) -> std::sync::Arc<std::sync::atomic::AtomicBool> {
473        let token = self.cancel_token.clone().unwrap_or_else(|| {
474            let t = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
475            self.cancel_token = Some(t.clone());
476            t
477        });
478        token.store(true, std::sync::atomic::Ordering::SeqCst);
479        token
480    }
481
482    /// True when cooperative cancellation has been requested.
483    pub fn is_cancel_requested(&self) -> bool {
484        self.cancel_token
485            .as_ref()
486            .map(|t| t.load(std::sync::atomic::Ordering::SeqCst))
487            .unwrap_or(false)
488    }
489
490    /// Identifiers visible at the given frame's scope — locals plus
491    /// every registered builtin + async builtin. Drives DAP
492    /// `completions` (#109) so the REPL autocomplete surfaces
493    /// everything the unified evaluator can reach.
494    pub fn identifiers_in_scope(&self, _frame_id: usize) -> Vec<String> {
495        let mut out: Vec<String> = self.env.all_variables().keys().cloned().collect();
496        out.extend(self.builtins.keys().cloned());
497        out.extend(self.async_builtins.keys().cloned());
498        out.sort();
499        out.dedup();
500        out
501    }
502
503    /// Get all stack frames for the debugger.
504    pub fn debug_stack_frames(&self) -> Vec<(String, usize)> {
505        let mut frames = Vec::new();
506        for (i, frame) in self.frames.iter().enumerate() {
507            let line = if frame.ip > 0 && frame.ip - 1 < frame.chunk.lines.len() {
508                frame.chunk.lines[frame.ip - 1] as usize
509            } else {
510                0
511            };
512            let name = if frame.fn_name.is_empty() {
513                if i == 0 {
514                    "pipeline".to_string()
515                } else {
516                    format!("fn_{}", i)
517                }
518            } else {
519                frame.fn_name.clone()
520            };
521            frames.push((name, line));
522        }
523        frames
524    }
525
526    /// Get the current source line.
527    fn current_line(&self) -> usize {
528        if let Some(frame) = self.frames.last() {
529            let ip = if frame.ip > 0 { frame.ip - 1 } else { 0 };
530            if ip < frame.chunk.lines.len() {
531                return frame.chunk.lines[ip] as usize;
532            }
533        }
534        0
535    }
536
537    /// Execute one instruction, returning whether to stop (breakpoint/step).
538    /// Returns Ok(None) to continue, Ok(Some(val)) on program end, Err on error.
539    ///
540    /// Line-change detection reads the line of the instruction we're
541    /// *about to execute* (`lines[ip]`) rather than the byte before
542    /// `ip`. After a jump, `ip-1` still points into the skipped region,
543    /// which previously reported phantom stops on the tail of a
544    /// not-taken branch (e.g. `host_metadata_save()` highlighted even
545    /// though `any_stale` was false). Using `lines[ip]` — combined with
546    /// cleanup ops emitted at line 0 after branch/loop exits — keeps
547    /// the debugger aligned with what's actually going to run.
548    pub async fn step_execute(&mut self) -> Result<Option<(VmValue, bool)>, VmError> {
549        // Cooperative cancellation (#108): the DAP adapter flips the
550        // shared flag when the IDE presses the Stop pill. Check here
551        // before any instruction work so the loop unwinds promptly
552        // on the next tick.
553        if self.is_cancel_requested() {
554            return Err(VmError::Thrown(VmValue::String(std::rc::Rc::from(
555                "kind:cancelled:VM cancelled by host",
556            ))));
557        }
558        let current_line = self.upcoming_line();
559        let line_changed = current_line != self.last_line && current_line > 0;
560
561        if line_changed {
562            self.last_line = current_line;
563
564            let state = self.debug_state();
565            if let Some(hook) = self.debug_hook.as_mut() {
566                if matches!(hook(&state), DebugAction::Stop) {
567                    self.stopped = true;
568                    return Ok(Some((VmValue::Nil, true)));
569                }
570            }
571
572            if self.breakpoint_matches(current_line) {
573                self.stopped = true;
574                return Ok(Some((VmValue::Nil, true)));
575            }
576
577            // Function-breakpoint latch: set by push_closure_frame when
578            // the callee's name is in `function_breakpoints`. Stop with
579            // the same shape as a line BP so the DAP adapter's
580            // classify_breakpoint_hit emits a standard stopped event.
581            if self.pending_function_bp.is_some() {
582                self.stopped = true;
583                return Ok(Some((VmValue::Nil, true)));
584            }
585
586            // step_frame_depth is the deepest frame count at which a stop
587            // is acceptable. set_step_mode uses usize::MAX (any depth,
588            // step-in), set_step_over uses N (same frame or shallower),
589            // set_step_out uses N-1 (strictly shallower than where the
590            // step-out was requested).
591            if self.step_mode && self.frames.len() <= self.step_frame_depth {
592                self.step_mode = false;
593                self.stopped = true;
594                return Ok(Some((VmValue::Nil, true)));
595            }
596        }
597
598        self.stopped = false;
599        self.execute_one_cycle().await
600    }
601
602    /// Line of the instruction *about to execute* — used by the
603    /// debugger for line-change detection so the first cycle after a
604    /// jump doesn't report a stale line from the skipped region.
605    fn upcoming_line(&self) -> usize {
606        if let Some(frame) = self.frames.last() {
607            if frame.ip < frame.chunk.lines.len() {
608                return frame.chunk.lines[frame.ip] as usize;
609            }
610        }
611        0
612    }
613
614    /// Number of live call frames. Used by the DAP adapter to
615    /// translate stackTrace ids (1-based, innermost first) back to
616    /// the VM's 0-based outermost-first index when processing
617    /// `restartFrame`.
618    pub fn frame_count(&self) -> usize {
619        self.frames.len()
620    }
621
622    /// Rewind the given frame to its entry state so stepping resumes
623    /// from the first instruction of the function with the original
624    /// arguments re-bound. Higher frames above `frame_id` are dropped.
625    /// Returns an error if the frame has no captured `initial_env`
626    /// (scratch / evaluator frames don't) or if the id is out of range.
627    ///
628    /// Side effects already performed by the restarted frame (tool
629    /// calls, file writes, host_call round-trips) are *not* rolled
630    /// back — DAP leaves that to the adapter's discretion. The IDE
631    /// should warn on frames whose source text contains obvious
632    /// side-effectful calls before invoking restartFrame.
633    pub fn restart_frame(&mut self, frame_id: usize) -> Result<(), VmError> {
634        if frame_id >= self.frames.len() {
635            return Err(VmError::Runtime(format!(
636                "restartFrame: frame id {frame_id} out of range (have {} frames)",
637                self.frames.len()
638            )));
639        }
640        let Some(initial_env) = self.frames[frame_id].initial_env.clone() else {
641            return Err(VmError::Runtime(
642                "restartFrame: target frame was not captured for restart (scratch / evaluator frame)".into(),
643            ));
644        };
645        // Drop every frame above the target. Each pop restores its
646        // saved_iterator_depth into `self.iterators` so iterator state
647        // unwinds consistently.
648        while self.frames.len() > frame_id + 1 {
649            let popped = self.frames.pop().expect("bounds checked above");
650            self.iterators.truncate(popped.saved_iterator_depth);
651        }
652        // Rewind the target frame.
653        let frame = self
654            .frames
655            .last_mut()
656            .expect("frame_id within bounds guarantees a frame");
657        frame.ip = 0;
658        let stack_base = frame.stack_base;
659        let saved_iter_depth = frame.saved_iterator_depth;
660        self.stack.truncate(stack_base);
661        self.iterators.truncate(saved_iter_depth);
662        self.env = initial_env;
663        self.last_line = 0;
664        self.stopped = false;
665        Ok(())
666    }
667
668    /// Assign a new value to a named binding in the paused VM's env.
669    /// Returns the value that was actually stored (after coercion, if
670    /// the VM performed any) so the caller can echo it back to the
671    /// DAP client. Fails if the name does not resolve to a mutable
672    /// binding in any live scope.
673    ///
674    /// The provided `value_expr` goes through the unified evaluator so
675    /// callers can type expressions like `plan.tasks.len() + 1` in the
676    /// Locals inline-edit field, not just literals.
677    pub async fn set_variable_in_frame(
678        &mut self,
679        name: &str,
680        value_expr: &str,
681        frame_id: usize,
682    ) -> Result<VmValue, VmError> {
683        let value = self.evaluate_in_frame(value_expr, frame_id).await?;
684        // Debug-specific assign: bypasses the `let` immutability gate
685        // because the user is explicitly editing in the IDE, and
686        // almost every pipeline binding is `let`. The underlying
687        // binding's mutability flag is preserved so runtime behavior
688        // after the override is unchanged.
689        self.env
690            .assign_debug(name, value.clone())
691            .map_err(|e| match e {
692                VmError::UndefinedVariable(n) => {
693                    VmError::Runtime(format!("setVariable: '{n}' is not in the current scope"))
694                }
695                other => other,
696            })?;
697        Ok(value)
698    }
699
700    /// Evaluate a Harn expression against the currently paused frame's
701    /// scope and return its value. This is the single evaluation path
702    /// used by hover tips, watch expressions, conditional breakpoints,
703    /// logpoint interpolation, and `setVariable` / `setExpression`
704    /// before we had a unified evaluator there were four separate
705    /// mini-parsers, each with its own rough edges (see burin-code #85).
706    ///
707    /// The expression is wrapped as `let __r = (<expr>)` so arbitrary
708    /// infix chains, ternaries, and access paths parse uniformly. A
709    /// scratch `CallFrame` runs the wrapped bytecode with `saved_env`
710    /// pointing at the caller's env, so the compiled expression sees
711    /// every local in scope. When the scratch frame pops, the caller's
712    /// env is automatically restored.
713    ///
714    /// A fixed instruction budget guards against runaway expressions
715    /// (infinite loops, accidental recursion) wedging the debugger.
716    /// Side effects — including `llm_call`, `host_*`, and file mutators
717    /// — are not blocked here; callers that invoke this for read-only
718    /// surfaces (hover, watch) should reject obviously-side-effectful
719    /// expressions before calling.
720    pub async fn evaluate_in_frame(
721        &mut self,
722        expr: &str,
723        _frame_id: usize,
724    ) -> Result<VmValue, VmError> {
725        let trimmed = expr.trim();
726        if trimmed.is_empty() {
727            return Err(VmError::Runtime("evaluate: empty expression".into()));
728        }
729
730        // Wrap as a pipeline whose body *returns* the expression. The
731        // explicit `return` compiles to `push value + Op::Return`, and
732        // Op::Return's frame-exit path pushes that value onto the
733        // caller's stack — which is where we read it from below.
734        // Avoids the script-mode compile path that trails a Pop+Nil
735        // sequence after every expression statement, which would
736        // clobber the result before we could capture it.
737        let wrapped = format!("pipeline default() {{\n  return ({trimmed})\n}}\n");
738        let program = harn_parser::check_source_strict(&wrapped)
739            .map_err(|e| VmError::Runtime(format!("evaluate: parse error: {e}")))?;
740        let mut chunk = crate::compiler::Compiler::new()
741            .compile(&program)
742            .map_err(|e| VmError::Runtime(format!("evaluate: compile error: {e}")))?;
743        // Inherit the current frame's source file so any runtime error
744        // enriched with `(line N)` attributes cleanly.
745        if let Some(current) = self.frames.last() {
746            chunk.source_file = current.chunk.source_file.clone();
747        }
748
749        // Snapshot every piece of VM state the scratch frame could
750        // perturb. Evaluation MUST be transparent: step state, scope
751        // depth, iterator depth, and the line-change baseline all
752        // restore on exit so the paused session continues exactly as
753        // before the user typed an expression into the REPL.
754        let saved_stack_len = self.stack.len();
755        let saved_frame_count = self.frames.len();
756        let saved_iter_depth = self.iterators.len();
757        let saved_scope_depth = self.env.scope_depth();
758        let saved_last_line = self.last_line;
759        let saved_step_mode = self.step_mode;
760        let saved_step_frame_depth = self.step_frame_depth;
761        let saved_stopped = self.stopped;
762        let saved_env = self.env.clone();
763
764        // Disable stepping during evaluation; otherwise the debug hook
765        // would fire on every synthetic line and block the pause UI.
766        self.step_mode = false;
767        self.stopped = false;
768
769        self.frames.push(CallFrame {
770            chunk,
771            ip: 0,
772            stack_base: saved_stack_len,
773            saved_env,
774            // Scratch evaluator frames never accept restartFrame — the
775            // REPL/watch user expects read-only inspection semantics,
776            // not replay — so skip the clone.
777            initial_env: None,
778            saved_iterator_depth: saved_iter_depth,
779            fn_name: "<eval>".to_string(),
780            argc: 0,
781            saved_source_dir: self.source_dir.clone(),
782            module_functions: None,
783            module_state: None,
784        });
785
786        // Drive one op at a time with a fixed budget. A pure expression
787        // is typically < 20 instructions; 10k gives plenty of headroom
788        // for e.g. a list comprehension without letting a bad loop
789        // hang the debugger forever.
790        const MAX_EVAL_STEPS: usize = 10_000;
791        let mut err: Option<VmError> = None;
792        for _ in 0..MAX_EVAL_STEPS {
793            if self.frames.len() <= saved_frame_count {
794                break;
795            }
796            match self.execute_one_cycle().await {
797                Ok(_) => {
798                    if self.frames.len() <= saved_frame_count {
799                        break;
800                    }
801                }
802                Err(e) => {
803                    err = Some(e);
804                    break;
805                }
806            }
807        }
808
809        // Read the result before restoring the stack — frame exit
810        // pushes the last-computed value onto the caller's stack, so
811        // it sits at `saved_stack_len` if execution completed cleanly.
812        let result = if self.stack.len() > saved_stack_len {
813            Some(self.stack[saved_stack_len].clone())
814        } else {
815            None
816        };
817
818        // Unconditional cleanup so a mid-execution error doesn't leak
819        // scratch state into the live session.
820        self.frames.truncate(saved_frame_count);
821        self.stack.truncate(saved_stack_len);
822        self.iterators.truncate(saved_iter_depth);
823        self.env.truncate_scopes(saved_scope_depth);
824        self.last_line = saved_last_line;
825        self.step_mode = saved_step_mode;
826        self.step_frame_depth = saved_step_frame_depth;
827        self.stopped = saved_stopped;
828
829        if let Some(e) = err {
830            return Err(e);
831        }
832        result.ok_or_else(|| {
833            VmError::Runtime(
834                "evaluate: step budget exceeded before the expression produced a value".into(),
835            )
836        })
837    }
838
839    async fn execute_one_cycle(&mut self) -> Result<Option<(VmValue, bool)>, VmError> {
840        if let Some(&(deadline, _)) = self.deadlines.last() {
841            if Instant::now() > deadline {
842                self.deadlines.pop();
843                let err = VmError::Thrown(VmValue::String(Rc::from("Deadline exceeded")));
844                match self.handle_error(err) {
845                    Ok(None) => return Ok(None),
846                    Ok(Some(val)) => return Ok(Some((val, false))),
847                    Err(e) => return Err(e),
848                }
849            }
850        }
851
852        let frame = match self.frames.last_mut() {
853            Some(f) => f,
854            None => {
855                let val = self.stack.pop().unwrap_or(VmValue::Nil);
856                return Ok(Some((val, false)));
857            }
858        };
859
860        if frame.ip >= frame.chunk.code.len() {
861            let val = self.stack.pop().unwrap_or(VmValue::Nil);
862            let popped_frame = self.frames.pop().unwrap();
863            if self.frames.is_empty() {
864                return Ok(Some((val, false)));
865            } else {
866                self.iterators.truncate(popped_frame.saved_iterator_depth);
867                self.env = popped_frame.saved_env;
868                self.stack.truncate(popped_frame.stack_base);
869                self.stack.push(val);
870                return Ok(None);
871            }
872        }
873
874        let op = frame.chunk.code[frame.ip];
875        frame.ip += 1;
876
877        match self.execute_op(op).await {
878            Ok(Some(val)) => Ok(Some((val, false))),
879            Ok(None) => Ok(None),
880            Err(VmError::Return(val)) => {
881                if let Some(popped_frame) = self.frames.pop() {
882                    if let Some(ref dir) = popped_frame.saved_source_dir {
883                        crate::stdlib::set_thread_source_dir(dir);
884                    }
885                    let current_depth = self.frames.len();
886                    self.exception_handlers
887                        .retain(|h| h.frame_depth <= current_depth);
888                    if self.frames.is_empty() {
889                        return Ok(Some((val, false)));
890                    }
891                    self.iterators.truncate(popped_frame.saved_iterator_depth);
892                    self.env = popped_frame.saved_env;
893                    self.stack.truncate(popped_frame.stack_base);
894                    self.stack.push(val);
895                    Ok(None)
896                } else {
897                    Ok(Some((val, false)))
898                }
899            }
900            Err(e) => {
901                if self.error_stack_trace.is_empty() {
902                    self.error_stack_trace = self.capture_stack_trace();
903                }
904                match self.handle_error(e) {
905                    Ok(None) => {
906                        self.error_stack_trace.clear();
907                        Ok(None)
908                    }
909                    Ok(Some(val)) => Ok(Some((val, false))),
910                    Err(e) => Err(self.enrich_error_with_line(e)),
911                }
912            }
913        }
914    }
915
916    /// Initialize execution (push the initial frame).
917    pub fn start(&mut self, chunk: &Chunk) {
918        let initial_env = self.env.clone();
919        self.frames.push(CallFrame {
920            chunk: chunk.clone(),
921            ip: 0,
922            stack_base: self.stack.len(),
923            saved_env: self.env.clone(),
924            // The top-level pipeline frame captures env at start so
925            // restartFrame on the outermost frame rewinds to the
926            // pre-pipeline state — basically "restart session" in
927            // debugger terms.
928            initial_env: Some(initial_env),
929            saved_iterator_depth: self.iterators.len(),
930            fn_name: String::new(),
931            argc: 0,
932            saved_source_dir: None,
933            module_functions: None,
934            module_state: None,
935        });
936    }
937
938    /// Register a sync builtin function.
939    pub fn register_builtin<F>(&mut self, name: &str, f: F)
940    where
941        F: Fn(&[VmValue], &mut String) -> Result<VmValue, VmError> + 'static,
942    {
943        self.builtins.insert(name.to_string(), Rc::new(f));
944    }
945
946    /// Remove a sync builtin (so an async version can take precedence).
947    pub fn unregister_builtin(&mut self, name: &str) {
948        self.builtins.remove(name);
949    }
950
951    /// Register an async builtin function.
952    pub fn register_async_builtin<F, Fut>(&mut self, name: &str, f: F)
953    where
954        F: Fn(Vec<VmValue>) -> Fut + 'static,
955        Fut: Future<Output = Result<VmValue, VmError>> + 'static,
956    {
957        self.async_builtins
958            .insert(name.to_string(), Rc::new(move |args| Box::pin(f(args))));
959    }
960
961    /// Create a child VM that shares builtins and env but has fresh execution state.
962    /// Used for parallel/spawn to fork the VM for concurrent tasks.
963    fn child_vm(&self) -> Vm {
964        Vm {
965            stack: Vec::with_capacity(64),
966            env: self.env.clone(),
967            output: String::new(),
968            builtins: self.builtins.clone(),
969            async_builtins: self.async_builtins.clone(),
970            iterators: Vec::new(),
971            frames: Vec::new(),
972            exception_handlers: Vec::new(),
973            spawned_tasks: BTreeMap::new(),
974            task_counter: 0,
975            deadlines: self.deadlines.clone(),
976            breakpoints: BTreeMap::new(),
977            function_breakpoints: std::collections::BTreeSet::new(),
978            pending_function_bp: None,
979            step_mode: false,
980            step_frame_depth: 0,
981            stopped: false,
982            last_line: 0,
983            source_dir: self.source_dir.clone(),
984            imported_paths: Vec::new(),
985            module_cache: self.module_cache.clone(),
986            source_file: self.source_file.clone(),
987            source_text: self.source_text.clone(),
988            bridge: self.bridge.clone(),
989            denied_builtins: self.denied_builtins.clone(),
990            cancel_token: None,
991            error_stack_trace: Vec::new(),
992            yield_sender: None,
993            project_root: self.project_root.clone(),
994            globals: self.globals.clone(),
995            debug_hook: None,
996        }
997    }
998
999    /// Create a child VM for external adapters that need to invoke Harn
1000    /// closures while sharing the parent's builtins, globals, and module state.
1001    pub(crate) fn child_vm_for_host(&self) -> Vm {
1002        self.child_vm()
1003    }
1004
1005    /// Set the source directory for import resolution and introspection.
1006    /// Also auto-detects the project root if not already set.
1007    pub fn set_source_dir(&mut self, dir: &std::path::Path) {
1008        self.source_dir = Some(dir.to_path_buf());
1009        crate::stdlib::set_thread_source_dir(dir);
1010        // Auto-detect project root if not explicitly set.
1011        if self.project_root.is_none() {
1012            self.project_root = crate::stdlib::process::find_project_root(dir);
1013        }
1014    }
1015
1016    /// Explicitly set the project root directory.
1017    /// Used by ACP/CLI to override auto-detection.
1018    pub fn set_project_root(&mut self, root: &std::path::Path) {
1019        self.project_root = Some(root.to_path_buf());
1020    }
1021
1022    /// Get the project root directory, falling back to source_dir.
1023    pub fn project_root(&self) -> Option<&std::path::Path> {
1024        self.project_root.as_deref().or(self.source_dir.as_deref())
1025    }
1026
1027    /// Return all registered builtin names (sync + async).
1028    pub fn builtin_names(&self) -> Vec<String> {
1029        let mut names: Vec<String> = self.builtins.keys().cloned().collect();
1030        names.extend(self.async_builtins.keys().cloned());
1031        names
1032    }
1033
1034    /// Set a global constant (e.g. `pi`, `e`).
1035    /// Stored separately from the environment so user-defined variables can shadow them.
1036    pub fn set_global(&mut self, name: &str, value: VmValue) {
1037        self.globals.insert(name.to_string(), value);
1038    }
1039
1040    /// Get the captured output.
1041    pub fn output(&self) -> &str {
1042        &self.output
1043    }
1044
1045    /// Execute a compiled chunk.
1046    pub async fn execute(&mut self, chunk: &Chunk) -> Result<VmValue, VmError> {
1047        let span_id = crate::tracing::span_start(crate::tracing::SpanKind::Pipeline, "main".into());
1048        let result = self.run_chunk(chunk).await;
1049        crate::tracing::span_end(span_id);
1050        result
1051    }
1052
1053    /// Convert a VmError into either a handled exception (returning Ok) or a propagated error.
1054    fn handle_error(&mut self, error: VmError) -> Result<Option<VmValue>, VmError> {
1055        let thrown_value = match &error {
1056            VmError::Thrown(v) => v.clone(),
1057            other => VmValue::String(Rc::from(other.to_string())),
1058        };
1059
1060        if let Some(handler) = self.exception_handlers.pop() {
1061            if !handler.error_type.is_empty() {
1062                // Typed catch: only match when the thrown enum's type equals the declared type.
1063                let matches = match &thrown_value {
1064                    VmValue::EnumVariant { enum_name, .. } => *enum_name == handler.error_type,
1065                    _ => false,
1066                };
1067                if !matches {
1068                    return self.handle_error(error);
1069                }
1070            }
1071
1072            while self.frames.len() > handler.frame_depth {
1073                if let Some(frame) = self.frames.pop() {
1074                    if let Some(ref dir) = frame.saved_source_dir {
1075                        crate::stdlib::set_thread_source_dir(dir);
1076                    }
1077                    self.iterators.truncate(frame.saved_iterator_depth);
1078                    self.env = frame.saved_env;
1079                }
1080            }
1081
1082            // Drop deadlines that belonged to unwound frames.
1083            while self
1084                .deadlines
1085                .last()
1086                .is_some_and(|d| d.1 > handler.frame_depth)
1087            {
1088                self.deadlines.pop();
1089            }
1090
1091            self.env.truncate_scopes(handler.env_scope_depth);
1092
1093            self.stack.truncate(handler.stack_depth);
1094            self.stack.push(thrown_value);
1095
1096            if let Some(frame) = self.frames.last_mut() {
1097                frame.ip = handler.catch_ip;
1098            }
1099
1100            Ok(None)
1101        } else {
1102            Err(error)
1103        }
1104    }
1105
1106    async fn run_chunk(&mut self, chunk: &Chunk) -> Result<VmValue, VmError> {
1107        self.run_chunk_entry(chunk, 0, None, None, None).await
1108    }
1109
1110    async fn run_chunk_entry(
1111        &mut self,
1112        chunk: &Chunk,
1113        argc: usize,
1114        saved_source_dir: Option<std::path::PathBuf>,
1115        module_functions: Option<ModuleFunctionRegistry>,
1116        module_state: Option<crate::value::ModuleState>,
1117    ) -> Result<VmValue, VmError> {
1118        let initial_env = self.env.clone();
1119        self.frames.push(CallFrame {
1120            chunk: chunk.clone(),
1121            ip: 0,
1122            stack_base: self.stack.len(),
1123            saved_env: self.env.clone(),
1124            initial_env: Some(initial_env),
1125            saved_iterator_depth: self.iterators.len(),
1126            fn_name: String::new(),
1127            argc,
1128            saved_source_dir,
1129            module_functions,
1130            module_state,
1131        });
1132
1133        loop {
1134            if let Some(&(deadline, _)) = self.deadlines.last() {
1135                if Instant::now() > deadline {
1136                    self.deadlines.pop();
1137                    let err = VmError::Thrown(VmValue::String(Rc::from("Deadline exceeded")));
1138                    match self.handle_error(err) {
1139                        Ok(None) => continue,
1140                        Ok(Some(val)) => return Ok(val),
1141                        Err(e) => return Err(e),
1142                    }
1143                }
1144            }
1145
1146            let frame = match self.frames.last_mut() {
1147                Some(f) => f,
1148                None => return Ok(self.stack.pop().unwrap_or(VmValue::Nil)),
1149            };
1150
1151            if frame.ip >= frame.chunk.code.len() {
1152                let val = self.stack.pop().unwrap_or(VmValue::Nil);
1153                let popped_frame = self.frames.pop().unwrap();
1154                if let Some(ref dir) = popped_frame.saved_source_dir {
1155                    crate::stdlib::set_thread_source_dir(dir);
1156                }
1157
1158                if self.frames.is_empty() {
1159                    return Ok(val);
1160                } else {
1161                    self.iterators.truncate(popped_frame.saved_iterator_depth);
1162                    self.env = popped_frame.saved_env;
1163                    self.stack.truncate(popped_frame.stack_base);
1164                    self.stack.push(val);
1165                    continue;
1166                }
1167            }
1168
1169            let op = frame.chunk.code[frame.ip];
1170            frame.ip += 1;
1171
1172            match self.execute_op(op).await {
1173                Ok(Some(val)) => return Ok(val),
1174                Ok(None) => continue,
1175                Err(VmError::Return(val)) => {
1176                    if let Some(popped_frame) = self.frames.pop() {
1177                        if let Some(ref dir) = popped_frame.saved_source_dir {
1178                            crate::stdlib::set_thread_source_dir(dir);
1179                        }
1180                        let current_depth = self.frames.len();
1181                        self.exception_handlers
1182                            .retain(|h| h.frame_depth <= current_depth);
1183
1184                        if self.frames.is_empty() {
1185                            return Ok(val);
1186                        }
1187                        self.iterators.truncate(popped_frame.saved_iterator_depth);
1188                        self.env = popped_frame.saved_env;
1189                        self.stack.truncate(popped_frame.stack_base);
1190                        self.stack.push(val);
1191                    } else {
1192                        return Ok(val);
1193                    }
1194                }
1195                Err(e) => {
1196                    // Capture stack trace before error handling unwinds frames.
1197                    if self.error_stack_trace.is_empty() {
1198                        self.error_stack_trace = self.capture_stack_trace();
1199                    }
1200                    match self.handle_error(e) {
1201                        Ok(None) => {
1202                            self.error_stack_trace.clear();
1203                            continue;
1204                        }
1205                        Ok(Some(val)) => return Ok(val),
1206                        Err(e) => return Err(self.enrich_error_with_line(e)),
1207                    }
1208                }
1209            }
1210        }
1211    }
1212
1213    /// Capture the current call stack as (fn_name, line, col, source_file) tuples.
1214    fn capture_stack_trace(&self) -> Vec<(String, usize, usize, Option<String>)> {
1215        self.frames
1216            .iter()
1217            .map(|f| {
1218                let idx = if f.ip > 0 { f.ip - 1 } else { 0 };
1219                let line = f.chunk.lines.get(idx).copied().unwrap_or(0) as usize;
1220                let col = f.chunk.columns.get(idx).copied().unwrap_or(0) as usize;
1221                (f.fn_name.clone(), line, col, f.chunk.source_file.clone())
1222            })
1223            .collect()
1224    }
1225
1226    /// Enrich a VmError with source line information from the captured stack
1227    /// trace. Appends ` (line N)` to error variants whose messages don't
1228    /// already carry location context.
1229    fn enrich_error_with_line(&self, error: VmError) -> VmError {
1230        // Determine the line from the captured stack trace (innermost frame).
1231        let line = self
1232            .error_stack_trace
1233            .last()
1234            .map(|(_, l, _, _)| *l)
1235            .unwrap_or_else(|| self.current_line());
1236        if line == 0 {
1237            return error;
1238        }
1239        let suffix = format!(" (line {line})");
1240        match error {
1241            VmError::Runtime(msg) => VmError::Runtime(format!("{msg}{suffix}")),
1242            VmError::TypeError(msg) => VmError::TypeError(format!("{msg}{suffix}")),
1243            VmError::DivisionByZero => VmError::Runtime(format!("Division by zero{suffix}")),
1244            VmError::UndefinedVariable(name) => {
1245                VmError::Runtime(format!("Undefined variable: {name}{suffix}"))
1246            }
1247            VmError::UndefinedBuiltin(name) => {
1248                VmError::Runtime(format!("Undefined builtin: {name}{suffix}"))
1249            }
1250            VmError::ImmutableAssignment(name) => VmError::Runtime(format!(
1251                "Cannot assign to immutable binding: {name}{suffix}"
1252            )),
1253            VmError::StackOverflow => {
1254                VmError::Runtime(format!("Stack overflow: too many nested calls{suffix}"))
1255            }
1256            // Leave these untouched:
1257            // - Thrown: user-thrown errors should not be silently modified
1258            // - CategorizedError: structured errors for agent orchestration
1259            // - Return: control flow, not a real error
1260            // - StackUnderflow / InvalidInstruction: internal VM bugs
1261            other => other,
1262        }
1263    }
1264
1265    const MAX_FRAMES: usize = 512;
1266
1267    /// Build the call-time env for a closure invocation.
1268    ///
1269    /// Harn is **lexically scoped for data**: a closure sees exactly the
1270    /// data names it captured at creation time, plus its parameters,
1271    /// plus names from its originating module's `module_state`, plus
1272    /// the module-function registry. The caller's *data* locals are
1273    /// intentionally not visible — that would be dynamic scoping, which
1274    /// is neither what Harn's TS-flavored surface suggests to users nor
1275    /// something real stdlib code relies on.
1276    ///
1277    /// **Exception: closure-typed bindings.** Function *names* are
1278    /// late-bound, Python-`LOAD_GLOBAL`-style. When a local recursive
1279    /// fn is declared in a pipeline body (or inside another function),
1280    /// the closure is created BEFORE its own name is defined in the
1281    /// enclosing scope, so `closure.env` captures a snapshot that is
1282    /// missing the self-reference. To make `fn fact(n) { fact(n-1) }`
1283    /// work without a letrec trick, we merge closure-typed entries
1284    /// from the caller's scope stack — but only closure-typed ones.
1285    /// Data locals are never leaked across call boundaries, so the
1286    /// surprising "caller's variable magically visible in callee"
1287    /// semantic is ruled out.
1288    ///
1289    /// Imported module closures have `module_state` set, at which
1290    /// point the full lexical environment is already available via
1291    /// `closure.env` + `module_state`, and we skip the closure merge
1292    /// entirely as a fast path. This is the hot path for context-
1293    /// builder workloads (~65% of VM CPU before this optimization).
1294    fn closure_call_env(caller_env: &VmEnv, closure: &VmClosure) -> VmEnv {
1295        if closure.module_state.is_some() {
1296            return closure.env.clone();
1297        }
1298        let mut call_env = closure.env.clone();
1299        // Late-bind only closure-typed names from the caller — enough
1300        // for local recursive / mutually-recursive fns to self-reference
1301        // without leaking caller-local data into the callee.
1302        for scope in &caller_env.scopes {
1303            for (name, (val, mutable)) in &scope.vars {
1304                if matches!(val, VmValue::Closure(_)) && call_env.get(name).is_none() {
1305                    let _ = call_env.define(name, val.clone(), *mutable);
1306                }
1307            }
1308        }
1309        call_env
1310    }
1311
1312    fn resolve_named_closure(&self, name: &str) -> Option<Rc<VmClosure>> {
1313        if let Some(VmValue::Closure(closure)) = self.env.get(name) {
1314            return Some(closure);
1315        }
1316        self.frames
1317            .last()
1318            .and_then(|frame| frame.module_functions.as_ref())
1319            .and_then(|registry| registry.borrow().get(name).cloned())
1320    }
1321
1322    /// Push a new call frame for a closure invocation.
1323    fn push_closure_frame(
1324        &mut self,
1325        closure: &VmClosure,
1326        args: &[VmValue],
1327        _parent_functions: &[CompiledFunction],
1328    ) -> Result<(), VmError> {
1329        if self.frames.len() >= Self::MAX_FRAMES {
1330            return Err(VmError::StackOverflow);
1331        }
1332        let saved_env = self.env.clone();
1333
1334        // If this closure originated from an imported module, switch
1335        // the thread-local source dir so that render() and other
1336        // source-relative builtins resolve relative to the module.
1337        let saved_source_dir = if let Some(ref dir) = closure.source_dir {
1338            let prev = crate::stdlib::process::VM_SOURCE_DIR.with(|sd| sd.borrow().clone());
1339            crate::stdlib::set_thread_source_dir(dir);
1340            prev
1341        } else {
1342            None
1343        };
1344
1345        let mut call_env = Self::closure_call_env(&saved_env, closure);
1346        call_env.push_scope();
1347
1348        let default_start = closure
1349            .func
1350            .default_start
1351            .unwrap_or(closure.func.params.len());
1352        let param_count = closure.func.params.len();
1353        for (i, param) in closure.func.params.iter().enumerate() {
1354            if closure.func.has_rest_param && i == param_count - 1 {
1355                // Rest parameter: collect remaining args into a list
1356                let rest_args = if i < args.len() {
1357                    args[i..].to_vec()
1358                } else {
1359                    Vec::new()
1360                };
1361                let _ = call_env.define(param, VmValue::List(std::rc::Rc::new(rest_args)), false);
1362            } else if i < args.len() {
1363                let _ = call_env.define(param, args[i].clone(), false);
1364            } else if i < default_start {
1365                let _ = call_env.define(param, VmValue::Nil, false);
1366            }
1367        }
1368
1369        // Snapshot the env *after* argument binding so restartFrame
1370        // can rewind this function to its entry state with the same
1371        // args re-applied. Cheap relative to the call itself.
1372        let initial_env = call_env.clone();
1373        self.env = call_env;
1374
1375        // Function-name breakpoint latch: record the name so the step
1376        // loop can raise a single "function breakpoint" stop on the
1377        // next cycle. We latch instead of stopping inline because
1378        // push_closure_frame is called from deep inside the call
1379        // dispatcher — the cleanest place for the debugger to observe
1380        // a consistent state is at the next line-change check.
1381        if self.function_breakpoints.contains(&closure.func.name) {
1382            self.pending_function_bp = Some(closure.func.name.clone());
1383        }
1384
1385        self.frames.push(CallFrame {
1386            chunk: closure.func.chunk.clone(),
1387            ip: 0,
1388            stack_base: self.stack.len(),
1389            saved_env,
1390            initial_env: Some(initial_env),
1391            saved_iterator_depth: self.iterators.len(),
1392            fn_name: closure.func.name.clone(),
1393            argc: args.len(),
1394            saved_source_dir,
1395            module_functions: closure.module_functions.clone(),
1396            module_state: closure.module_state.clone(),
1397        });
1398
1399        Ok(())
1400    }
1401
1402    /// Create a generator value by spawning the closure body as an async task.
1403    /// The generator body communicates yielded values through an mpsc channel.
1404    pub(crate) fn create_generator(&self, closure: &VmClosure, args: &[VmValue]) -> VmValue {
1405        use crate::value::VmGenerator;
1406
1407        // Buffer size of 1: the generator produces one value at a time.
1408        let (tx, rx) = tokio::sync::mpsc::channel::<VmValue>(1);
1409
1410        let mut child = self.child_vm();
1411        child.yield_sender = Some(tx);
1412
1413        // Set up the environment for the generator body. The generator
1414        // body runs in its own child VM; closure_call_env walks the
1415        // current (parent) env so locally-defined generator closures
1416        // can self-reference via the narrow closure-only merge. See
1417        // `Vm::closure_call_env`.
1418        let parent_env = self.env.clone();
1419        let mut call_env = Self::closure_call_env(&parent_env, closure);
1420        call_env.push_scope();
1421
1422        let default_start = closure
1423            .func
1424            .default_start
1425            .unwrap_or(closure.func.params.len());
1426        let param_count = closure.func.params.len();
1427        for (i, param) in closure.func.params.iter().enumerate() {
1428            if closure.func.has_rest_param && i == param_count - 1 {
1429                let rest_args = if i < args.len() {
1430                    args[i..].to_vec()
1431                } else {
1432                    Vec::new()
1433                };
1434                let _ = call_env.define(param, VmValue::List(std::rc::Rc::new(rest_args)), false);
1435            } else if i < args.len() {
1436                let _ = call_env.define(param, args[i].clone(), false);
1437            } else if i < default_start {
1438                let _ = call_env.define(param, VmValue::Nil, false);
1439            }
1440        }
1441        child.env = call_env;
1442
1443        let chunk = closure.func.chunk.clone();
1444        let saved_source_dir = if let Some(ref dir) = closure.source_dir {
1445            let prev = crate::stdlib::process::VM_SOURCE_DIR.with(|sd| sd.borrow().clone());
1446            crate::stdlib::set_thread_source_dir(dir);
1447            prev
1448        } else {
1449            None
1450        };
1451        let module_functions = closure.module_functions.clone();
1452        let module_state = closure.module_state.clone();
1453        let argc = args.len();
1454        // Spawn the generator body as an async task.
1455        // The task will execute until return, sending yielded values through the channel.
1456        tokio::task::spawn_local(async move {
1457            let _ = child
1458                .run_chunk_entry(
1459                    &chunk,
1460                    argc,
1461                    saved_source_dir,
1462                    module_functions,
1463                    module_state,
1464                )
1465                .await;
1466            // When the generator body finishes (return or fall-through),
1467            // the sender is dropped, signaling completion to the receiver.
1468        });
1469
1470        VmValue::Generator(VmGenerator {
1471            done: Rc::new(std::cell::Cell::new(false)),
1472            receiver: Rc::new(tokio::sync::Mutex::new(rx)),
1473        })
1474    }
1475
1476    fn pop(&mut self) -> Result<VmValue, VmError> {
1477        self.stack.pop().ok_or(VmError::StackUnderflow)
1478    }
1479
1480    fn peek(&self) -> Result<&VmValue, VmError> {
1481        self.stack.last().ok_or(VmError::StackUnderflow)
1482    }
1483
1484    fn const_string(c: &Constant) -> Result<String, VmError> {
1485        match c {
1486            Constant::String(s) => Ok(s.clone()),
1487            _ => Err(VmError::TypeError("expected string constant".into())),
1488        }
1489    }
1490
1491    /// Call a closure (used by method calls like .map/.filter etc.)
1492    /// Uses recursive execution for simplicity in method dispatch.
1493    fn call_closure<'a>(
1494        &'a mut self,
1495        closure: &'a VmClosure,
1496        args: &'a [VmValue],
1497        _parent_functions: &'a [CompiledFunction],
1498    ) -> Pin<Box<dyn Future<Output = Result<VmValue, VmError>> + 'a>> {
1499        Box::pin(async move {
1500            let saved_env = self.env.clone();
1501            let saved_frames = std::mem::take(&mut self.frames);
1502            let saved_handlers = std::mem::take(&mut self.exception_handlers);
1503            let saved_iterators = std::mem::take(&mut self.iterators);
1504            let saved_deadlines = std::mem::take(&mut self.deadlines);
1505
1506            let mut call_env = Self::closure_call_env(&saved_env, closure);
1507            call_env.push_scope();
1508
1509            let default_start = closure
1510                .func
1511                .default_start
1512                .unwrap_or(closure.func.params.len());
1513            let param_count = closure.func.params.len();
1514            for (i, param) in closure.func.params.iter().enumerate() {
1515                if closure.func.has_rest_param && i == param_count - 1 {
1516                    let rest_args = if i < args.len() {
1517                        args[i..].to_vec()
1518                    } else {
1519                        Vec::new()
1520                    };
1521                    let _ =
1522                        call_env.define(param, VmValue::List(std::rc::Rc::new(rest_args)), false);
1523                } else if i < args.len() {
1524                    let _ = call_env.define(param, args[i].clone(), false);
1525                } else if i < default_start {
1526                    let _ = call_env.define(param, VmValue::Nil, false);
1527                }
1528            }
1529
1530            self.env = call_env;
1531            let argc = args.len();
1532            let saved_source_dir = if let Some(ref dir) = closure.source_dir {
1533                let prev = crate::stdlib::process::VM_SOURCE_DIR.with(|sd| sd.borrow().clone());
1534                crate::stdlib::set_thread_source_dir(dir);
1535                prev
1536            } else {
1537                None
1538            };
1539            let result = self
1540                .run_chunk_entry(
1541                    &closure.func.chunk,
1542                    argc,
1543                    saved_source_dir,
1544                    closure.module_functions.clone(),
1545                    closure.module_state.clone(),
1546                )
1547                .await;
1548
1549            self.env = saved_env;
1550            self.frames = saved_frames;
1551            self.exception_handlers = saved_handlers;
1552            self.iterators = saved_iterators;
1553            self.deadlines = saved_deadlines;
1554
1555            result
1556        })
1557    }
1558
1559    /// Invoke a value as a callable. Supports `VmValue::Closure` and
1560    /// `VmValue::BuiltinRef`, so builtin names passed by reference (e.g.
1561    /// `dict.rekey(snake_to_camel)`) dispatch through the same code path as
1562    /// user-defined closures.
1563    #[allow(clippy::manual_async_fn)]
1564    fn call_callable_value<'a>(
1565        &'a mut self,
1566        callable: &'a VmValue,
1567        args: &'a [VmValue],
1568        functions: &'a [CompiledFunction],
1569    ) -> Pin<Box<dyn Future<Output = Result<VmValue, VmError>> + 'a>> {
1570        Box::pin(async move {
1571            match callable {
1572                VmValue::Closure(closure) => self.call_closure(closure, args, functions).await,
1573                VmValue::BuiltinRef(name) => {
1574                    let name_owned = name.to_string();
1575                    self.call_named_builtin(&name_owned, args.to_vec()).await
1576                }
1577                other => Err(VmError::TypeError(format!(
1578                    "expected callable, got {}",
1579                    other.type_name()
1580                ))),
1581            }
1582        })
1583    }
1584
1585    /// Returns true if `v` is callable via `call_callable_value`.
1586    fn is_callable_value(v: &VmValue) -> bool {
1587        matches!(v, VmValue::Closure(_) | VmValue::BuiltinRef(_))
1588    }
1589
1590    /// Public wrapper for `call_closure`, used by the MCP server to invoke
1591    /// tool handler closures from outside the VM execution loop.
1592    pub async fn call_closure_pub(
1593        &mut self,
1594        closure: &VmClosure,
1595        args: &[VmValue],
1596        functions: &[CompiledFunction],
1597    ) -> Result<VmValue, VmError> {
1598        self.call_closure(closure, args, functions).await
1599    }
1600
1601    /// Resolve a named builtin: sync builtins → async builtins → bridge → error.
1602    /// Used by Call, TailCall, and Pipe handlers to avoid duplicating this lookup.
1603    async fn call_named_builtin(
1604        &mut self,
1605        name: &str,
1606        args: Vec<VmValue>,
1607    ) -> Result<VmValue, VmError> {
1608        // Auto-trace LLM calls and tool calls.
1609        let span_kind = match name {
1610            "llm_call" | "llm_stream" | "agent_loop" => Some(crate::tracing::SpanKind::LlmCall),
1611            "mcp_call" => Some(crate::tracing::SpanKind::ToolCall),
1612            _ => None,
1613        };
1614        let _span = span_kind.map(|kind| ScopeSpan::new(kind, name.to_string()));
1615
1616        // Sandbox check: deny builtins blocked by --deny/--allow flags.
1617        if self.denied_builtins.contains(name) {
1618            return Err(VmError::CategorizedError {
1619                message: format!("Tool '{}' is not permitted.", name),
1620                category: ErrorCategory::ToolRejected,
1621            });
1622        }
1623        crate::orchestration::enforce_current_policy_for_builtin(name, &args)?;
1624        if let Some(builtin) = self.builtins.get(name).cloned() {
1625            builtin(&args, &mut self.output)
1626        } else if let Some(async_builtin) = self.async_builtins.get(name).cloned() {
1627            CURRENT_ASYNC_BUILTIN_CHILD_VM.with(|slot| {
1628                slot.borrow_mut().push(self.child_vm());
1629            });
1630            let result = async_builtin(args).await;
1631            CURRENT_ASYNC_BUILTIN_CHILD_VM.with(|slot| {
1632                slot.borrow_mut().pop();
1633            });
1634            result
1635        } else if let Some(bridge) = &self.bridge {
1636            crate::orchestration::enforce_current_policy_for_bridge_builtin(name)?;
1637            let args_json: Vec<serde_json::Value> =
1638                args.iter().map(crate::llm::vm_value_to_json).collect();
1639            let result = bridge
1640                .call(
1641                    "builtin_call",
1642                    serde_json::json!({"name": name, "args": args_json}),
1643                )
1644                .await?;
1645            Ok(crate::bridge::json_result_to_vm_value(&result))
1646        } else {
1647            let all_builtins = self
1648                .builtins
1649                .keys()
1650                .chain(self.async_builtins.keys())
1651                .map(|s| s.as_str());
1652            if let Some(suggestion) = crate::value::closest_match(name, all_builtins) {
1653                return Err(VmError::Runtime(format!(
1654                    "Undefined builtin: {name} (did you mean `{suggestion}`?)"
1655                )));
1656            }
1657            Err(VmError::UndefinedBuiltin(name.to_string()))
1658        }
1659    }
1660}
1661
1662/// Clone the VM at the top of the async-builtin child VM stack, returning a
1663/// fresh `Vm` instance the caller owns. Enables concurrent tool-handler
1664/// execution within a single agent_loop iteration — the VM shares its heavy
1665/// state (env, builtins, bridge, module_cache) via `Arc`/`Rc`, so cloning is
1666/// cheap and each handler gets its own execution context.
1667///
1668/// Returns `None` if no parent VM is currently pushed on the stack.
1669pub fn clone_async_builtin_child_vm() -> Option<Vm> {
1670    CURRENT_ASYNC_BUILTIN_CHILD_VM.with(|slot| slot.borrow().last().map(|vm| vm.child_vm()))
1671}
1672
1673/// Legacy API preserved for out-of-tree callers; new code should use
1674/// `clone_async_builtin_child_vm()`. `take/restore` serialized concurrent
1675/// callers because only one could hold the popped value at a time.
1676#[deprecated(
1677    note = "use clone_async_builtin_child_vm() — take/restore serialized concurrent callers"
1678)]
1679pub fn take_async_builtin_child_vm() -> Option<Vm> {
1680    clone_async_builtin_child_vm()
1681}
1682
1683/// Legacy no-op retained for backward compatibility.
1684#[deprecated(note = "clone_async_builtin_child_vm does not need a matching restore call")]
1685pub fn restore_async_builtin_child_vm(_vm: Vm) {
1686    CURRENT_ASYNC_BUILTIN_CHILD_VM.with(|slot| {
1687        let _ = slot;
1688    });
1689}
1690
1691impl Default for Vm {
1692    fn default() -> Self {
1693        Self::new()
1694    }
1695}
1696
1697#[cfg(test)]
1698mod tests {
1699    use super::*;
1700    use crate::compiler::Compiler;
1701    use crate::stdlib::register_vm_stdlib;
1702    use crate::values_equal;
1703    use harn_lexer::Lexer;
1704    use harn_parser::Parser;
1705
1706    fn run_harn(source: &str) -> (String, VmValue) {
1707        let rt = tokio::runtime::Builder::new_current_thread()
1708            .enable_all()
1709            .build()
1710            .unwrap();
1711        rt.block_on(async {
1712            let local = tokio::task::LocalSet::new();
1713            local
1714                .run_until(async {
1715                    let mut lexer = Lexer::new(source);
1716                    let tokens = lexer.tokenize().unwrap();
1717                    let mut parser = Parser::new(tokens);
1718                    let program = parser.parse().unwrap();
1719                    let chunk = Compiler::new().compile(&program).unwrap();
1720
1721                    let mut vm = Vm::new();
1722                    register_vm_stdlib(&mut vm);
1723                    let result = vm.execute(&chunk).await.unwrap();
1724                    (vm.output().to_string(), result)
1725                })
1726                .await
1727        })
1728    }
1729
1730    fn run_output(source: &str) -> String {
1731        run_harn(source).0.trim_end().to_string()
1732    }
1733
1734    fn run_harn_result(source: &str) -> Result<(String, VmValue), VmError> {
1735        let rt = tokio::runtime::Builder::new_current_thread()
1736            .enable_all()
1737            .build()
1738            .unwrap();
1739        rt.block_on(async {
1740            let local = tokio::task::LocalSet::new();
1741            local
1742                .run_until(async {
1743                    let mut lexer = Lexer::new(source);
1744                    let tokens = lexer.tokenize().unwrap();
1745                    let mut parser = Parser::new(tokens);
1746                    let program = parser.parse().unwrap();
1747                    let chunk = Compiler::new().compile(&program).unwrap();
1748
1749                    let mut vm = Vm::new();
1750                    register_vm_stdlib(&mut vm);
1751                    let result = vm.execute(&chunk).await?;
1752                    Ok((vm.output().to_string(), result))
1753                })
1754                .await
1755        })
1756    }
1757
1758    /// Drive the VM forward from a `start()`ed chunk until it reaches
1759    /// the first breakpoint (or exhausts the step budget). Returns the
1760    /// VM positioned in whatever frame the breakpoint lives in. Used
1761    /// by the `evaluate_in_frame` tests below so we can inspect a paused
1762    /// scope without wiring a full DAP session.
1763    fn run_until_paused(vm: &mut Vm, chunk: &Chunk) {
1764        vm.start(chunk);
1765        let rt = tokio::runtime::Builder::new_current_thread()
1766            .enable_all()
1767            .build()
1768            .unwrap();
1769        rt.block_on(async {
1770            let local = tokio::task::LocalSet::new();
1771            local
1772                .run_until(async {
1773                    for _ in 0..10_000 {
1774                        if vm.is_stopped() {
1775                            return;
1776                        }
1777                        match vm.step_execute().await {
1778                            Ok(Some((_, true))) => return,
1779                            Ok(_) => continue,
1780                            Err(e) => panic!("step_execute failed: {e}"),
1781                        }
1782                    }
1783                    panic!("run_until_paused: step budget exceeded");
1784                })
1785                .await
1786        })
1787    }
1788
1789    /// Synchronously evaluate an expression on an already-paused VM.
1790    /// Mirrors what harn-dap's `handle_evaluate` will do on the async
1791    /// runtime it already owns.
1792    fn eval(vm: &mut Vm, expr: &str) -> Result<VmValue, VmError> {
1793        let rt = tokio::runtime::Builder::new_current_thread()
1794            .enable_all()
1795            .build()
1796            .unwrap();
1797        rt.block_on(async {
1798            let local = tokio::task::LocalSet::new();
1799            local.run_until(vm.evaluate_in_frame(expr, 0)).await
1800        })
1801    }
1802
1803    #[test]
1804    fn test_evaluate_in_frame_literal() {
1805        // Need a live frame for evaluate_in_frame, even for a pure
1806        // expression, because the scratch chunk inherits source info
1807        // from the top frame. Seed one by compiling & starting an empty
1808        // pipeline that just waits on a breakpoint.
1809        let mut vm = Vm::new();
1810        register_vm_stdlib(&mut vm);
1811        vm.set_breakpoints(vec![2]);
1812        let chunk = crate::compile_source("let __seed__: int = 0\nlog(__seed__)\n").unwrap();
1813        run_until_paused(&mut vm, &chunk);
1814
1815        assert!(values_equal(
1816            &eval(&mut vm, "1 + 2").unwrap(),
1817            &VmValue::Int(3)
1818        ));
1819        assert!(values_equal(
1820            &eval(&mut vm, "\"hi\" + \" there\"").unwrap(),
1821            &VmValue::String(Rc::from("hi there"))
1822        ));
1823        assert!(values_equal(
1824            &eval(&mut vm, "5 > 3 && 2 < 4").unwrap(),
1825            &VmValue::Bool(true)
1826        ));
1827    }
1828
1829    #[test]
1830    fn test_evaluate_in_frame_sees_locals() {
1831        let mut vm = Vm::new();
1832        register_vm_stdlib(&mut vm);
1833        vm.set_breakpoints(vec![3]);
1834        let chunk = crate::compile_source(
1835            "let user: string = \"alice\"\nlet count: int = 42\nlog(count)\n",
1836        )
1837        .unwrap();
1838        run_until_paused(&mut vm, &chunk);
1839
1840        assert!(values_equal(
1841            &eval(&mut vm, "user").unwrap(),
1842            &VmValue::String(Rc::from("alice"))
1843        ));
1844        assert!(values_equal(
1845            &eval(&mut vm, "count * 2").unwrap(),
1846            &VmValue::Int(84)
1847        ));
1848        assert!(values_equal(
1849            &eval(&mut vm, "user + \" has \" + to_string(count)").unwrap(),
1850            &VmValue::String(Rc::from("alice has 42"))
1851        ));
1852    }
1853
1854    #[test]
1855    fn test_evaluate_in_frame_does_not_leak_state() {
1856        // Evaluation must be transparent to the live session — no
1857        // scope leftovers, no stack residue, no step-mode drift.
1858        let mut vm = Vm::new();
1859        register_vm_stdlib(&mut vm);
1860        vm.set_breakpoints(vec![2]);
1861        let chunk = crate::compile_source("let x: int = 7\nlog(x)\n").unwrap();
1862        run_until_paused(&mut vm, &chunk);
1863
1864        let pre_stack = vm.stack.len();
1865        let pre_frames = vm.frames.len();
1866        let pre_scope = vm.env.scope_depth();
1867        let _ = eval(&mut vm, "x + 100").unwrap();
1868        let _ = eval(&mut vm, "x * x").unwrap();
1869        assert_eq!(vm.stack.len(), pre_stack);
1870        assert_eq!(vm.frames.len(), pre_frames);
1871        assert_eq!(vm.env.scope_depth(), pre_scope);
1872        // The synthetic `__burin_eval_result__` binding must not linger
1873        // in the paused scope.
1874        assert!(vm.env.get("__burin_eval_result__").is_none());
1875    }
1876
1877    #[test]
1878    fn test_set_variable_in_frame_updates_let_binding() {
1879        // Pipeline authors overwhelmingly use `let`; the debug
1880        // setVariable path must bypass immutability or it's useless.
1881        let mut vm = Vm::new();
1882        register_vm_stdlib(&mut vm);
1883        vm.set_breakpoints(vec![3]);
1884        let chunk = crate::compile_source(
1885            "let count: int = 7\nlet label: string = \"before\"\nlog(count)\n",
1886        )
1887        .unwrap();
1888        run_until_paused(&mut vm, &chunk);
1889
1890        let rt = tokio::runtime::Builder::new_current_thread()
1891            .enable_all()
1892            .build()
1893            .unwrap();
1894        let stored = rt.block_on(async {
1895            let local = tokio::task::LocalSet::new();
1896            local
1897                .run_until(vm.set_variable_in_frame("count", "42", 0))
1898                .await
1899        });
1900        assert!(values_equal(&stored.unwrap(), &VmValue::Int(42)));
1901        assert!(values_equal(
1902            &eval(&mut vm, "count").unwrap(),
1903            &VmValue::Int(42)
1904        ));
1905
1906        // Expression RHS — not just literals.
1907        let rt = tokio::runtime::Builder::new_current_thread()
1908            .enable_all()
1909            .build()
1910            .unwrap();
1911        rt.block_on(async {
1912            let local = tokio::task::LocalSet::new();
1913            local
1914                .run_until(vm.set_variable_in_frame("label", "\"x\" + to_string(count)", 0))
1915                .await
1916                .unwrap()
1917        });
1918        assert!(values_equal(
1919            &eval(&mut vm, "label").unwrap(),
1920            &VmValue::String(Rc::from("x42"))
1921        ));
1922    }
1923
1924    #[test]
1925    fn test_set_variable_in_frame_rejects_undefined() {
1926        let mut vm = Vm::new();
1927        register_vm_stdlib(&mut vm);
1928        vm.set_breakpoints(vec![2]);
1929        let chunk = crate::compile_source("let x: int = 1\nlog(x)\n").unwrap();
1930        run_until_paused(&mut vm, &chunk);
1931
1932        let rt = tokio::runtime::Builder::new_current_thread()
1933            .enable_all()
1934            .build()
1935            .unwrap();
1936        let err = rt
1937            .block_on(async {
1938                let local = tokio::task::LocalSet::new();
1939                local
1940                    .run_until(vm.set_variable_in_frame("ghost", "0", 0))
1941                    .await
1942            })
1943            .unwrap_err();
1944        let msg = err.to_string();
1945        assert!(
1946            msg.contains("ghost"),
1947            "expected 'ghost' in error, got {msg}"
1948        );
1949    }
1950
1951    #[test]
1952    fn test_restart_frame_rewinds_ip_and_rebinds_args() {
1953        // Pause inside a function, mutate a local, restart the frame
1954        // — the mutation must vanish and execution must resume from
1955        // the top of the function with the original args.
1956        let mut vm = Vm::new();
1957        register_vm_stdlib(&mut vm);
1958        vm.set_breakpoints(vec![3]);
1959        let chunk = crate::compile_source(
1960            "fn inner(n: int) -> int { \n  let doubled: int = n * 2\n  log(doubled)\n  return doubled\n}\nlog(inner(21))\n",
1961        )
1962        .unwrap();
1963        run_until_paused(&mut vm, &chunk);
1964
1965        // We're paused at line 3 inside `inner`. Mutate the local so
1966        // we can assert the restart wiped it.
1967        let rt = tokio::runtime::Builder::new_current_thread()
1968            .enable_all()
1969            .build()
1970            .unwrap();
1971        rt.block_on(async {
1972            let local = tokio::task::LocalSet::new();
1973            local
1974                .run_until(vm.set_variable_in_frame("doubled", "999", 0))
1975                .await
1976                .unwrap()
1977        });
1978        assert!(values_equal(
1979            &eval(&mut vm, "doubled").unwrap(),
1980            &VmValue::Int(999)
1981        ));
1982
1983        // restart_frame(top_frame_index) rewinds `inner` to entry.
1984        let top = vm.frame_count() - 1;
1985        vm.restart_frame(top).unwrap();
1986
1987        // `doubled` no longer exists because the function's scope was
1988        // blown away, but `n` should still be bound from the re-applied
1989        // arg.
1990        assert!(values_equal(
1991            &eval(&mut vm, "n").unwrap(),
1992            &VmValue::Int(21)
1993        ));
1994    }
1995
1996    #[test]
1997    fn test_restart_frame_rejects_scratch_frames() {
1998        let mut vm = Vm::new();
1999        register_vm_stdlib(&mut vm);
2000        vm.set_breakpoints(vec![2]);
2001        let chunk = crate::compile_source("let x: int = 1\nlog(x)\n").unwrap();
2002        run_until_paused(&mut vm, &chunk);
2003        // The top-level pipeline frame has `initial_env: Some(_)` so
2004        // restartFrame *is* valid there — our script has no inner
2005        // function yet. Push a synthetic scratch frame via
2006        // evaluate_in_frame (which leaves no live frame when done),
2007        // then attempt restart on an out-of-range id.
2008        let err = vm.restart_frame(99).unwrap_err();
2009        assert!(err.to_string().contains("out of range"));
2010    }
2011
2012    #[test]
2013    fn test_signal_cancel_unwinds_step_loop() {
2014        let mut vm = Vm::new();
2015        register_vm_stdlib(&mut vm);
2016        // A busy-looping pipeline that would never terminate under a
2017        // normal run; signal cancel before stepping so the first
2018        // instruction check throws VmError::Thrown with the
2019        // cancelled kind.
2020        let chunk = crate::compile_source(
2021            "pipeline t(task) { var i = 0\n while i < 1000000 { i = i + 1 } }\n",
2022        )
2023        .unwrap();
2024        vm.start(&chunk);
2025        vm.signal_cancel();
2026        let rt = tokio::runtime::Builder::new_current_thread()
2027            .enable_all()
2028            .build()
2029            .unwrap();
2030        let result = rt.block_on(async {
2031            let local = tokio::task::LocalSet::new();
2032            local.run_until(vm.step_execute()).await
2033        });
2034        match result {
2035            Err(VmError::Thrown(VmValue::String(s))) => {
2036                assert!(
2037                    s.contains("kind:cancelled:"),
2038                    "cancellation must surface as a kind-tagged Thrown error"
2039                );
2040            }
2041            other => panic!("expected cancelled Thrown, got {other:?}"),
2042        }
2043    }
2044
2045    #[test]
2046    fn test_function_breakpoint_stops_on_entry() {
2047        let mut vm = Vm::new();
2048        register_vm_stdlib(&mut vm);
2049        vm.set_function_breakpoints(vec!["do_work".to_string()]);
2050        let chunk = crate::compile_source(
2051            "fn do_work(n: int) -> int { return n + 1 }\npipeline t(task) { let x = do_work(41)\nlog(x) }\n",
2052        )
2053        .unwrap();
2054        run_until_paused(&mut vm, &chunk);
2055        // The latch must identify the matching function and get
2056        // drained exactly once.
2057        let hit = vm.take_pending_function_bp().expect("must latch a hit");
2058        assert_eq!(hit, "do_work");
2059        assert!(vm.take_pending_function_bp().is_none(), "one-shot");
2060
2061        // The top frame should be `do_work` at entry.
2062        let frames = vm.debug_stack_frames();
2063        let top = frames.last().expect("callee frame on stack");
2064        assert_eq!(top.0, "do_work");
2065    }
2066
2067    #[test]
2068    fn test_function_breakpoint_unknown_name_does_not_fire() {
2069        let mut vm = Vm::new();
2070        register_vm_stdlib(&mut vm);
2071        vm.set_function_breakpoints(vec!["nonexistent".to_string()]);
2072        let chunk = crate::compile_source("pipeline t(task) { let x = 1\nlog(x) }\n").unwrap();
2073        // With no matching callee, the program runs to completion
2074        // without latching a hit; run_until_paused would have panicked
2075        // with "step budget exceeded" if the VM idled, so wrap with a
2076        // finite run of step_execute until a natural terminate.
2077        vm.start(&chunk);
2078        let rt = tokio::runtime::Builder::new_current_thread()
2079            .enable_all()
2080            .build()
2081            .unwrap();
2082        rt.block_on(async {
2083            let local = tokio::task::LocalSet::new();
2084            local
2085                .run_until(async {
2086                    for _ in 0..10_000 {
2087                        match vm.step_execute().await {
2088                            Ok(Some((_, false))) => return,
2089                            Ok(_) => continue,
2090                            Err(e) => panic!("step_execute failed: {e}"),
2091                        }
2092                    }
2093                    panic!("step budget exceeded");
2094                })
2095                .await
2096        });
2097        assert!(vm.take_pending_function_bp().is_none());
2098    }
2099
2100    #[test]
2101    fn test_evaluate_in_frame_parse_error_is_surfaced_standalone() {
2102        let mut vm = Vm::new();
2103        register_vm_stdlib(&mut vm);
2104        vm.set_breakpoints(vec![1]);
2105        let chunk = crate::compile_source("log(0)\n").unwrap();
2106        run_until_paused(&mut vm, &chunk);
2107
2108        let err = eval(&mut vm, "(\"unterminated").unwrap_err();
2109        let msg = err.to_string();
2110        assert!(
2111            msg.contains("evaluate:"),
2112            "expected evaluate error prefix, got: {msg}"
2113        );
2114    }
2115
2116    #[test]
2117    fn test_breakpoints_wildcard_matches_any_file() {
2118        let mut vm = Vm::new();
2119        vm.set_breakpoints(vec![3, 7]);
2120        assert!(vm.breakpoint_matches(3));
2121        assert!(vm.breakpoint_matches(7));
2122        assert!(!vm.breakpoint_matches(4));
2123    }
2124
2125    #[test]
2126    fn test_breakpoints_per_file_does_not_leak_to_wildcard() {
2127        let mut vm = Vm::new();
2128        vm.set_breakpoints_for_file("auto.harn", vec![10]);
2129        // Without an active frame, only the empty-string key matches; a
2130        // file-scoped breakpoint must NOT fire when no frame is active.
2131        assert!(!vm.breakpoint_matches(10));
2132    }
2133
2134    #[test]
2135    fn test_breakpoints_per_file_clear_on_empty() {
2136        let mut vm = Vm::new();
2137        vm.set_breakpoints_for_file("a.harn", vec![1, 2]);
2138        vm.set_breakpoints_for_file("a.harn", vec![]);
2139        assert!(!vm.breakpoints.contains_key("a.harn"));
2140    }
2141
2142    #[test]
2143    fn test_arithmetic() {
2144        let out =
2145            run_output("pipeline t(task) { log(2 + 3)\nlog(10 - 4)\nlog(3 * 5)\nlog(10 / 3) }");
2146        assert_eq!(out, "[harn] 5\n[harn] 6\n[harn] 15\n[harn] 3");
2147    }
2148
2149    #[test]
2150    fn test_mixed_arithmetic() {
2151        let out = run_output("pipeline t(task) { log(3 + 1.5)\nlog(10 - 2.5) }");
2152        assert_eq!(out, "[harn] 4.5\n[harn] 7.5");
2153    }
2154
2155    #[test]
2156    fn test_exponentiation() {
2157        let out = run_output(
2158            "pipeline t(task) { log(2 ** 8)\nlog(2 * 3 ** 2)\nlog(2 ** 3 ** 2)\nlog(2 ** -1) }",
2159        );
2160        assert_eq!(out, "[harn] 256\n[harn] 18\n[harn] 512\n[harn] 0.5");
2161    }
2162
2163    #[test]
2164    fn test_comparisons() {
2165        let out =
2166            run_output("pipeline t(task) { log(1 < 2)\nlog(2 > 3)\nlog(1 == 1)\nlog(1 != 2) }");
2167        assert_eq!(out, "[harn] true\n[harn] false\n[harn] true\n[harn] true");
2168    }
2169
2170    #[test]
2171    fn test_let_var() {
2172        let out = run_output("pipeline t(task) { let x = 42\nlog(x)\nvar y = 1\ny = 2\nlog(y) }");
2173        assert_eq!(out, "[harn] 42\n[harn] 2");
2174    }
2175
2176    #[test]
2177    fn test_if_else() {
2178        let out = run_output(
2179            r#"pipeline t(task) { if true { log("yes") } if false { log("wrong") } else { log("no") } }"#,
2180        );
2181        assert_eq!(out, "[harn] yes\n[harn] no");
2182    }
2183
2184    #[test]
2185    fn test_while_loop() {
2186        let out = run_output("pipeline t(task) { var i = 0\n while i < 5 { i = i + 1 }\n log(i) }");
2187        assert_eq!(out, "[harn] 5");
2188    }
2189
2190    #[test]
2191    fn test_for_in() {
2192        let out = run_output("pipeline t(task) { for item in [1, 2, 3] { log(item) } }");
2193        assert_eq!(out, "[harn] 1\n[harn] 2\n[harn] 3");
2194    }
2195
2196    #[test]
2197    fn test_inner_for_return_does_not_leak_iterator_into_caller() {
2198        let out = run_output(
2199            r#"pipeline t(task) {
2200  fn first_match() {
2201    for pattern in ["a", "b"] {
2202      return pattern
2203    }
2204    return ""
2205  }
2206
2207  var seen = []
2208  for path in ["outer"] {
2209    seen = seen + [path + ":" + first_match()]
2210  }
2211  log(join(seen, ","))
2212}"#,
2213        );
2214        assert_eq!(out, "[harn] outer:a");
2215    }
2216
2217    #[test]
2218    fn test_fn_decl_and_call() {
2219        let out = run_output("pipeline t(task) { fn add(a, b) { return a + b }\nlog(add(3, 4)) }");
2220        assert_eq!(out, "[harn] 7");
2221    }
2222
2223    #[test]
2224    fn test_closure() {
2225        let out = run_output("pipeline t(task) { let double = { x -> x * 2 }\nlog(double(5)) }");
2226        assert_eq!(out, "[harn] 10");
2227    }
2228
2229    #[test]
2230    fn test_closure_capture() {
2231        let out = run_output(
2232            "pipeline t(task) { let base = 10\nfn offset(x) { return x + base }\nlog(offset(5)) }",
2233        );
2234        assert_eq!(out, "[harn] 15");
2235    }
2236
2237    #[test]
2238    fn test_string_concat() {
2239        let out = run_output(
2240            r#"pipeline t(task) { let a = "hello" + " " + "world"
2241log(a) }"#,
2242        );
2243        assert_eq!(out, "[harn] hello world");
2244    }
2245
2246    #[test]
2247    fn test_list_map() {
2248        let out = run_output(
2249            "pipeline t(task) { let doubled = [1, 2, 3].map({ x -> x * 2 })\nlog(doubled) }",
2250        );
2251        assert_eq!(out, "[harn] [2, 4, 6]");
2252    }
2253
2254    #[test]
2255    fn test_list_filter() {
2256        let out = run_output(
2257            "pipeline t(task) { let big = [1, 2, 3, 4, 5].filter({ x -> x > 3 })\nlog(big) }",
2258        );
2259        assert_eq!(out, "[harn] [4, 5]");
2260    }
2261
2262    #[test]
2263    fn test_list_reduce() {
2264        let out = run_output(
2265            "pipeline t(task) { let sum = [1, 2, 3, 4].reduce(0, { acc, x -> acc + x })\nlog(sum) }",
2266        );
2267        assert_eq!(out, "[harn] 10");
2268    }
2269
2270    #[test]
2271    fn test_dict_access() {
2272        let out = run_output(
2273            r#"pipeline t(task) { let d = {name: "test", value: 42}
2274log(d.name)
2275log(d.value) }"#,
2276        );
2277        assert_eq!(out, "[harn] test\n[harn] 42");
2278    }
2279
2280    #[test]
2281    fn test_dict_methods() {
2282        let out = run_output(
2283            r#"pipeline t(task) { let d = {a: 1, b: 2}
2284log(d.keys())
2285log(d.values())
2286log(d.has("a"))
2287log(d.has("z")) }"#,
2288        );
2289        assert_eq!(
2290            out,
2291            "[harn] [a, b]\n[harn] [1, 2]\n[harn] true\n[harn] false"
2292        );
2293    }
2294
2295    #[test]
2296    fn test_pipe_operator() {
2297        let out = run_output(
2298            "pipeline t(task) { fn double(x) { return x * 2 }\nlet r = 5 |> double\nlog(r) }",
2299        );
2300        assert_eq!(out, "[harn] 10");
2301    }
2302
2303    #[test]
2304    fn test_pipe_with_closure() {
2305        let out = run_output(
2306            r#"pipeline t(task) { let r = "hello world" |> { s -> s.split(" ") }
2307log(r) }"#,
2308        );
2309        assert_eq!(out, "[harn] [hello, world]");
2310    }
2311
2312    #[test]
2313    fn test_nil_coalescing() {
2314        let out = run_output(
2315            r#"pipeline t(task) { let a = nil ?? "fallback"
2316log(a)
2317let b = "present" ?? "fallback"
2318log(b) }"#,
2319        );
2320        assert_eq!(out, "[harn] fallback\n[harn] present");
2321    }
2322
2323    #[test]
2324    fn test_logical_operators() {
2325        let out =
2326            run_output("pipeline t(task) { log(true && false)\nlog(true || false)\nlog(!true) }");
2327        assert_eq!(out, "[harn] false\n[harn] true\n[harn] false");
2328    }
2329
2330    #[test]
2331    fn test_match() {
2332        let out = run_output(
2333            r#"pipeline t(task) { let x = "b"
2334match x { "a" -> { log("first") } "b" -> { log("second") } "c" -> { log("third") } } }"#,
2335        );
2336        assert_eq!(out, "[harn] second");
2337    }
2338
2339    #[test]
2340    fn test_subscript() {
2341        let out = run_output("pipeline t(task) { let arr = [10, 20, 30]\nlog(arr[1]) }");
2342        assert_eq!(out, "[harn] 20");
2343    }
2344
2345    #[test]
2346    fn test_string_methods() {
2347        let out = run_output(
2348            r#"pipeline t(task) { log("hello world".replace("world", "harn"))
2349log("a,b,c".split(","))
2350log("  hello  ".trim())
2351log("hello".starts_with("hel"))
2352log("hello".ends_with("lo"))
2353log("hello".substring(1, 3)) }"#,
2354        );
2355        assert_eq!(
2356            out,
2357            "[harn] hello harn\n[harn] [a, b, c]\n[harn] hello\n[harn] true\n[harn] true\n[harn] el"
2358        );
2359    }
2360
2361    #[test]
2362    fn test_list_properties() {
2363        let out = run_output(
2364            "pipeline t(task) { let list = [1, 2, 3]\nlog(list.count)\nlog(list.empty)\nlog(list.first)\nlog(list.last) }",
2365        );
2366        assert_eq!(out, "[harn] 3\n[harn] false\n[harn] 1\n[harn] 3");
2367    }
2368
2369    #[test]
2370    fn test_recursive_function() {
2371        let out = run_output(
2372            "pipeline t(task) { fn fib(n) { if n <= 1 { return n } return fib(n - 1) + fib(n - 2) }\nlog(fib(10)) }",
2373        );
2374        assert_eq!(out, "[harn] 55");
2375    }
2376
2377    #[test]
2378    fn test_ternary() {
2379        let out = run_output(
2380            r#"pipeline t(task) { let x = 5
2381let r = x > 0 ? "positive" : "non-positive"
2382log(r) }"#,
2383        );
2384        assert_eq!(out, "[harn] positive");
2385    }
2386
2387    #[test]
2388    fn test_for_in_dict() {
2389        let out = run_output(
2390            "pipeline t(task) { let d = {a: 1, b: 2}\nfor entry in d { log(entry.key) } }",
2391        );
2392        assert_eq!(out, "[harn] a\n[harn] b");
2393    }
2394
2395    #[test]
2396    fn test_list_any_all() {
2397        let out = run_output(
2398            "pipeline t(task) { let nums = [2, 4, 6]\nlog(nums.any({ x -> x > 5 }))\nlog(nums.all({ x -> x > 0 }))\nlog(nums.all({ x -> x > 3 })) }",
2399        );
2400        assert_eq!(out, "[harn] true\n[harn] true\n[harn] false");
2401    }
2402
2403    #[test]
2404    fn test_disassembly() {
2405        let mut lexer = Lexer::new("pipeline t(task) { log(2 + 3) }");
2406        let tokens = lexer.tokenize().unwrap();
2407        let mut parser = Parser::new(tokens);
2408        let program = parser.parse().unwrap();
2409        let chunk = Compiler::new().compile(&program).unwrap();
2410        let disasm = chunk.disassemble("test");
2411        assert!(disasm.contains("CONSTANT"));
2412        assert!(disasm.contains("ADD"));
2413        assert!(disasm.contains("CALL"));
2414    }
2415
2416    // --- Error handling tests ---
2417
2418    #[test]
2419    fn test_try_catch_basic() {
2420        let out = run_output(
2421            r#"pipeline t(task) { try { throw "oops" } catch(e) { log("caught: " + e) } }"#,
2422        );
2423        assert_eq!(out, "[harn] caught: oops");
2424    }
2425
2426    #[test]
2427    fn test_try_no_error() {
2428        let out = run_output(
2429            r#"pipeline t(task) {
2430var result = 0
2431try { result = 42 } catch(e) { result = 0 }
2432log(result)
2433}"#,
2434        );
2435        assert_eq!(out, "[harn] 42");
2436    }
2437
2438    #[test]
2439    fn test_throw_uncaught() {
2440        let result = run_harn_result(r#"pipeline t(task) { throw "boom" }"#);
2441        assert!(result.is_err());
2442    }
2443
2444    // --- Additional test coverage ---
2445
2446    fn run_vm(source: &str) -> String {
2447        let rt = tokio::runtime::Builder::new_current_thread()
2448            .enable_all()
2449            .build()
2450            .unwrap();
2451        rt.block_on(async {
2452            let local = tokio::task::LocalSet::new();
2453            local
2454                .run_until(async {
2455                    let mut lexer = Lexer::new(source);
2456                    let tokens = lexer.tokenize().unwrap();
2457                    let mut parser = Parser::new(tokens);
2458                    let program = parser.parse().unwrap();
2459                    let chunk = Compiler::new().compile(&program).unwrap();
2460                    let mut vm = Vm::new();
2461                    register_vm_stdlib(&mut vm);
2462                    vm.execute(&chunk).await.unwrap();
2463                    vm.output().to_string()
2464                })
2465                .await
2466        })
2467    }
2468
2469    fn run_vm_err(source: &str) -> String {
2470        let rt = tokio::runtime::Builder::new_current_thread()
2471            .enable_all()
2472            .build()
2473            .unwrap();
2474        rt.block_on(async {
2475            let local = tokio::task::LocalSet::new();
2476            local
2477                .run_until(async {
2478                    let mut lexer = Lexer::new(source);
2479                    let tokens = lexer.tokenize().unwrap();
2480                    let mut parser = Parser::new(tokens);
2481                    let program = parser.parse().unwrap();
2482                    let chunk = Compiler::new().compile(&program).unwrap();
2483                    let mut vm = Vm::new();
2484                    register_vm_stdlib(&mut vm);
2485                    match vm.execute(&chunk).await {
2486                        Err(e) => format!("{}", e),
2487                        Ok(_) => panic!("Expected error"),
2488                    }
2489                })
2490                .await
2491        })
2492    }
2493
2494    #[test]
2495    fn test_hello_world() {
2496        let out = run_vm(r#"pipeline default(task) { log("hello") }"#);
2497        assert_eq!(out, "[harn] hello\n");
2498    }
2499
2500    #[test]
2501    fn test_arithmetic_new() {
2502        let out = run_vm("pipeline default(task) { log(2 + 3) }");
2503        assert_eq!(out, "[harn] 5\n");
2504    }
2505
2506    #[test]
2507    fn test_string_concat_new() {
2508        let out = run_vm(r#"pipeline default(task) { log("a" + "b") }"#);
2509        assert_eq!(out, "[harn] ab\n");
2510    }
2511
2512    #[test]
2513    fn test_if_else_new() {
2514        let out = run_vm("pipeline default(task) { if true { log(1) } else { log(2) } }");
2515        assert_eq!(out, "[harn] 1\n");
2516    }
2517
2518    #[test]
2519    fn test_for_loop_new() {
2520        let out = run_vm("pipeline default(task) { for i in [1, 2, 3] { log(i) } }");
2521        assert_eq!(out, "[harn] 1\n[harn] 2\n[harn] 3\n");
2522    }
2523
2524    #[test]
2525    fn test_while_loop_new() {
2526        let out = run_vm("pipeline default(task) { var i = 0\nwhile i < 3 { log(i)\ni = i + 1 } }");
2527        assert_eq!(out, "[harn] 0\n[harn] 1\n[harn] 2\n");
2528    }
2529
2530    #[test]
2531    fn test_function_call_new() {
2532        let out =
2533            run_vm("pipeline default(task) { fn add(a, b) { return a + b }\nlog(add(2, 3)) }");
2534        assert_eq!(out, "[harn] 5\n");
2535    }
2536
2537    #[test]
2538    fn test_closure_new() {
2539        let out = run_vm("pipeline default(task) { let f = { x -> x * 2 }\nlog(f(5)) }");
2540        assert_eq!(out, "[harn] 10\n");
2541    }
2542
2543    #[test]
2544    fn test_recursion() {
2545        let out = run_vm("pipeline default(task) { fn fact(n) { if n <= 1 { return 1 }\nreturn n * fact(n - 1) }\nlog(fact(5)) }");
2546        assert_eq!(out, "[harn] 120\n");
2547    }
2548
2549    #[test]
2550    fn test_try_catch_new() {
2551        let out = run_vm(r#"pipeline default(task) { try { throw "err" } catch (e) { log(e) } }"#);
2552        assert_eq!(out, "[harn] err\n");
2553    }
2554
2555    #[test]
2556    fn test_try_no_error_new() {
2557        let out = run_vm("pipeline default(task) { try { log(1) } catch (e) { log(2) } }");
2558        assert_eq!(out, "[harn] 1\n");
2559    }
2560
2561    #[test]
2562    fn test_list_map_new() {
2563        let out =
2564            run_vm("pipeline default(task) { let r = [1, 2, 3].map({ x -> x * 2 })\nlog(r) }");
2565        assert_eq!(out, "[harn] [2, 4, 6]\n");
2566    }
2567
2568    #[test]
2569    fn test_list_filter_new() {
2570        let out = run_vm(
2571            "pipeline default(task) { let r = [1, 2, 3, 4].filter({ x -> x > 2 })\nlog(r) }",
2572        );
2573        assert_eq!(out, "[harn] [3, 4]\n");
2574    }
2575
2576    #[test]
2577    fn test_dict_access_new() {
2578        let out = run_vm("pipeline default(task) { let d = {name: \"Alice\"}\nlog(d.name) }");
2579        assert_eq!(out, "[harn] Alice\n");
2580    }
2581
2582    #[test]
2583    fn test_string_interpolation() {
2584        let out = run_vm("pipeline default(task) { let x = 42\nlog(\"val=${x}\") }");
2585        assert_eq!(out, "[harn] val=42\n");
2586    }
2587
2588    #[test]
2589    fn test_match_new() {
2590        let out = run_vm(
2591            "pipeline default(task) { let x = \"b\"\nmatch x { \"a\" -> { log(1) } \"b\" -> { log(2) } } }",
2592        );
2593        assert_eq!(out, "[harn] 2\n");
2594    }
2595
2596    #[test]
2597    fn test_json_roundtrip() {
2598        let out = run_vm("pipeline default(task) { let s = json_stringify({a: 1})\nlog(s) }");
2599        assert!(out.contains("\"a\""));
2600        assert!(out.contains("1"));
2601    }
2602
2603    #[test]
2604    fn test_type_of() {
2605        let out = run_vm("pipeline default(task) { log(type_of(42))\nlog(type_of(\"hi\")) }");
2606        assert_eq!(out, "[harn] int\n[harn] string\n");
2607    }
2608
2609    #[test]
2610    fn test_stack_overflow() {
2611        let err = run_vm_err("pipeline default(task) { fn f() { f() }\nf() }");
2612        assert!(
2613            err.contains("stack") || err.contains("overflow") || err.contains("recursion"),
2614            "Expected stack overflow error, got: {}",
2615            err
2616        );
2617    }
2618
2619    #[test]
2620    fn test_division_by_zero() {
2621        let err = run_vm_err("pipeline default(task) { log(1 / 0) }");
2622        assert!(
2623            err.contains("Division by zero") || err.contains("division"),
2624            "Expected division by zero error, got: {}",
2625            err
2626        );
2627    }
2628
2629    #[test]
2630    fn test_float_division_by_zero_uses_ieee_values() {
2631        let out = run_vm(
2632            "pipeline default(task) { log(is_nan(0.0 / 0.0))\nlog(is_infinite(1.0 / 0.0))\nlog(is_infinite(-1.0 / 0.0)) }",
2633        );
2634        assert_eq!(out, "[harn] true\n[harn] true\n[harn] true\n");
2635    }
2636
2637    #[test]
2638    fn test_reusing_catch_binding_name_in_same_block() {
2639        let out = run_vm(
2640            r#"pipeline default(task) {
2641try {
2642    throw "a"
2643} catch e {
2644    log(e)
2645}
2646try {
2647    throw "b"
2648} catch e {
2649    log(e)
2650}
2651}"#,
2652        );
2653        assert_eq!(out, "[harn] a\n[harn] b\n");
2654    }
2655
2656    #[test]
2657    fn test_try_catch_nested() {
2658        let out = run_output(
2659            r#"pipeline t(task) {
2660try {
2661    try {
2662        throw "inner"
2663    } catch(e) {
2664        log("inner caught: " + e)
2665        throw "outer"
2666    }
2667} catch(e2) {
2668    log("outer caught: " + e2)
2669}
2670}"#,
2671        );
2672        assert_eq!(
2673            out,
2674            "[harn] inner caught: inner\n[harn] outer caught: outer"
2675        );
2676    }
2677
2678    // --- Concurrency tests ---
2679
2680    #[test]
2681    fn test_parallel_basic() {
2682        let out = run_output(
2683            "pipeline t(task) { let results = parallel(3) { i -> i * 10 }\nlog(results) }",
2684        );
2685        assert_eq!(out, "[harn] [0, 10, 20]");
2686    }
2687
2688    #[test]
2689    fn test_parallel_no_variable() {
2690        let out = run_output("pipeline t(task) { let results = parallel(3) { 42 }\nlog(results) }");
2691        assert_eq!(out, "[harn] [42, 42, 42]");
2692    }
2693
2694    #[test]
2695    fn test_parallel_each_basic() {
2696        let out = run_output(
2697            "pipeline t(task) { let results = parallel each [1, 2, 3] { x -> x * x }\nlog(results) }",
2698        );
2699        assert_eq!(out, "[harn] [1, 4, 9]");
2700    }
2701
2702    #[test]
2703    fn test_spawn_await() {
2704        let out = run_output(
2705            r#"pipeline t(task) {
2706let handle = spawn { log("spawned") }
2707let result = await(handle)
2708log("done")
2709}"#,
2710        );
2711        assert_eq!(out, "[harn] spawned\n[harn] done");
2712    }
2713
2714    #[test]
2715    fn test_spawn_cancel() {
2716        let out = run_output(
2717            r#"pipeline t(task) {
2718let handle = spawn { log("should be cancelled") }
2719cancel(handle)
2720log("cancelled")
2721}"#,
2722        );
2723        assert_eq!(out, "[harn] cancelled");
2724    }
2725
2726    #[test]
2727    fn test_spawn_returns_value() {
2728        let out = run_output("pipeline t(task) { let h = spawn { 42 }\nlet r = await(h)\nlog(r) }");
2729        assert_eq!(out, "[harn] 42");
2730    }
2731
2732    // --- Deadline tests ---
2733
2734    #[test]
2735    fn test_deadline_success() {
2736        let out = run_output(
2737            r#"pipeline t(task) {
2738let result = deadline 5s { log("within deadline")
273942 }
2740log(result)
2741}"#,
2742        );
2743        assert_eq!(out, "[harn] within deadline\n[harn] 42");
2744    }
2745
2746    #[test]
2747    fn test_deadline_exceeded() {
2748        let result = run_harn_result(
2749            r#"pipeline t(task) {
2750deadline 1ms {
2751  var i = 0
2752  while i < 1000000 { i = i + 1 }
2753}
2754}"#,
2755        );
2756        assert!(result.is_err());
2757    }
2758
2759    #[test]
2760    fn test_deadline_caught_by_try() {
2761        let out = run_output(
2762            r#"pipeline t(task) {
2763try {
2764  deadline 1ms {
2765    var i = 0
2766    while i < 1000000 { i = i + 1 }
2767  }
2768} catch(e) {
2769  log("caught")
2770}
2771}"#,
2772        );
2773        assert_eq!(out, "[harn] caught");
2774    }
2775
2776    /// Helper that runs Harn source with a set of denied builtins.
2777    fn run_harn_with_denied(
2778        source: &str,
2779        denied: HashSet<String>,
2780    ) -> Result<(String, VmValue), VmError> {
2781        let rt = tokio::runtime::Builder::new_current_thread()
2782            .enable_all()
2783            .build()
2784            .unwrap();
2785        rt.block_on(async {
2786            let local = tokio::task::LocalSet::new();
2787            local
2788                .run_until(async {
2789                    let mut lexer = Lexer::new(source);
2790                    let tokens = lexer.tokenize().unwrap();
2791                    let mut parser = Parser::new(tokens);
2792                    let program = parser.parse().unwrap();
2793                    let chunk = Compiler::new().compile(&program).unwrap();
2794
2795                    let mut vm = Vm::new();
2796                    register_vm_stdlib(&mut vm);
2797                    vm.set_denied_builtins(denied);
2798                    let result = vm.execute(&chunk).await?;
2799                    Ok((vm.output().to_string(), result))
2800                })
2801                .await
2802        })
2803    }
2804
2805    #[test]
2806    fn test_sandbox_deny_builtin() {
2807        let denied: HashSet<String> = ["push".to_string()].into_iter().collect();
2808        let result = run_harn_with_denied(
2809            r#"pipeline t(task) {
2810let xs = [1, 2]
2811push(xs, 3)
2812}"#,
2813            denied,
2814        );
2815        let err = result.unwrap_err();
2816        let msg = format!("{err}");
2817        assert!(
2818            msg.contains("not permitted"),
2819            "expected not permitted, got: {msg}"
2820        );
2821        assert!(
2822            msg.contains("push"),
2823            "expected builtin name in error, got: {msg}"
2824        );
2825    }
2826
2827    #[test]
2828    fn test_sandbox_allowed_builtin_works() {
2829        // Denying "push" should not block "log"
2830        let denied: HashSet<String> = ["push".to_string()].into_iter().collect();
2831        let result = run_harn_with_denied(r#"pipeline t(task) { log("hello") }"#, denied);
2832        let (output, _) = result.unwrap();
2833        assert_eq!(output.trim(), "[harn] hello");
2834    }
2835
2836    #[test]
2837    fn test_sandbox_empty_denied_set() {
2838        // With an empty denied set, everything should work.
2839        let result = run_harn_with_denied(r#"pipeline t(task) { log("ok") }"#, HashSet::new());
2840        let (output, _) = result.unwrap();
2841        assert_eq!(output.trim(), "[harn] ok");
2842    }
2843
2844    #[test]
2845    fn test_sandbox_propagates_to_spawn() {
2846        // Denied builtins should propagate to spawned VMs.
2847        let denied: HashSet<String> = ["push".to_string()].into_iter().collect();
2848        let result = run_harn_with_denied(
2849            r#"pipeline t(task) {
2850let handle = spawn {
2851  let xs = [1, 2]
2852  push(xs, 3)
2853}
2854await(handle)
2855}"#,
2856            denied,
2857        );
2858        let err = result.unwrap_err();
2859        let msg = format!("{err}");
2860        assert!(
2861            msg.contains("not permitted"),
2862            "expected not permitted in spawned VM, got: {msg}"
2863        );
2864    }
2865
2866    #[test]
2867    fn test_sandbox_propagates_to_parallel() {
2868        // Denied builtins should propagate to parallel VMs.
2869        let denied: HashSet<String> = ["push".to_string()].into_iter().collect();
2870        let result = run_harn_with_denied(
2871            r#"pipeline t(task) {
2872let results = parallel(2) { i ->
2873  let xs = [1, 2]
2874  push(xs, 3)
2875}
2876}"#,
2877            denied,
2878        );
2879        let err = result.unwrap_err();
2880        let msg = format!("{err}");
2881        assert!(
2882            msg.contains("not permitted"),
2883            "expected not permitted in parallel VM, got: {msg}"
2884        );
2885    }
2886
2887    #[test]
2888    fn test_if_else_has_lexical_block_scope() {
2889        let out = run_output(
2890            r#"pipeline t(task) {
2891let x = "outer"
2892if true {
2893  let x = "inner"
2894  log(x)
2895} else {
2896  let x = "other"
2897  log(x)
2898}
2899log(x)
2900}"#,
2901        );
2902        assert_eq!(out, "[harn] inner\n[harn] outer");
2903    }
2904
2905    #[test]
2906    fn test_loop_and_catch_bindings_are_block_scoped() {
2907        let out = run_output(
2908            r#"pipeline t(task) {
2909let label = "outer"
2910for item in [1, 2] {
2911  let label = "loop ${item}"
2912  log(label)
2913}
2914try {
2915  throw("boom")
2916} catch (label) {
2917  log(label)
2918}
2919log(label)
2920}"#,
2921        );
2922        assert_eq!(
2923            out,
2924            "[harn] loop 1\n[harn] loop 2\n[harn] boom\n[harn] outer"
2925        );
2926    }
2927}