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    /// Iterator stack depth to restore when this frame unwinds.
46    pub(crate) saved_iterator_depth: usize,
47    /// Function name for stack traces (empty for top-level pipeline).
48    pub(crate) fn_name: String,
49    /// Number of arguments actually passed by the caller (for default arg support).
50    pub(crate) argc: usize,
51    /// Saved VM_SOURCE_DIR to restore when this frame is popped.
52    /// Set when entering a closure that originated from an imported module.
53    pub(crate) saved_source_dir: Option<std::path::PathBuf>,
54    /// Module-local named functions available to symbolic calls within this frame.
55    pub(crate) module_functions: Option<ModuleFunctionRegistry>,
56    /// Shared module-level env for top-level `var` / `let` bindings of
57    /// this frame's originating module. Looked up after `self.env` and
58    /// before `self.globals` by `GetVar` / `SetVar`, giving each module
59    /// its own live static state that persists across calls. See the
60    /// `module_state` field on `VmClosure` for the full rationale.
61    pub(crate) module_state: Option<crate::value::ModuleState>,
62}
63
64/// Exception handler for try/catch.
65pub(crate) struct ExceptionHandler {
66    pub(crate) catch_ip: usize,
67    pub(crate) stack_depth: usize,
68    pub(crate) frame_depth: usize,
69    pub(crate) env_scope_depth: usize,
70    /// If non-empty, this catch only handles errors whose enum_name matches.
71    pub(crate) error_type: String,
72}
73
74/// Debug action returned by the debug hook.
75#[derive(Debug, Clone, PartialEq)]
76pub enum DebugAction {
77    /// Continue execution normally.
78    Continue,
79    /// Stop (breakpoint hit, step complete).
80    Stop,
81}
82
83/// Information about current execution state for the debugger.
84#[derive(Debug, Clone)]
85pub struct DebugState {
86    pub line: usize,
87    pub variables: BTreeMap<String, VmValue>,
88    pub frame_name: String,
89    pub frame_depth: usize,
90}
91
92type DebugHook = dyn FnMut(&DebugState) -> DebugAction;
93
94/// Iterator state for for-in loops: either a pre-collected vec, an async channel, or a generator.
95pub(crate) enum IterState {
96    Vec {
97        items: Vec<VmValue>,
98        idx: usize,
99    },
100    Channel {
101        receiver: std::sync::Arc<tokio::sync::Mutex<tokio::sync::mpsc::Receiver<VmValue>>>,
102        closed: std::sync::Arc<std::sync::atomic::AtomicBool>,
103    },
104    Generator {
105        gen: crate::value::VmGenerator,
106    },
107    /// Step through a lazy range without materializing a Vec.
108    /// `next` holds the value to emit on the next IterNext; `stop` is
109    /// the first value that terminates the iteration (one past the end).
110    Range {
111        next: i64,
112        stop: i64,
113    },
114    VmIter {
115        handle: std::rc::Rc<std::cell::RefCell<crate::vm::iter::VmIter>>,
116    },
117}
118
119#[derive(Clone)]
120pub(crate) struct LoadedModule {
121    pub(crate) functions: BTreeMap<String, Rc<VmClosure>>,
122    pub(crate) public_names: HashSet<String>,
123}
124
125/// The Harn bytecode virtual machine.
126pub struct Vm {
127    pub(crate) stack: Vec<VmValue>,
128    pub(crate) env: VmEnv,
129    pub(crate) output: String,
130    pub(crate) builtins: BTreeMap<String, VmBuiltinFn>,
131    pub(crate) async_builtins: BTreeMap<String, VmAsyncBuiltinFn>,
132    /// Iterator state for for-in loops.
133    pub(crate) iterators: Vec<IterState>,
134    /// Call frame stack.
135    pub(crate) frames: Vec<CallFrame>,
136    /// Exception handler stack.
137    pub(crate) exception_handlers: Vec<ExceptionHandler>,
138    /// Spawned async task handles.
139    pub(crate) spawned_tasks: BTreeMap<String, VmTaskHandle>,
140    /// Counter for generating unique task IDs.
141    pub(crate) task_counter: u64,
142    /// Active deadline stack: (deadline_instant, frame_depth).
143    pub(crate) deadlines: Vec<(Instant, usize)>,
144    /// Breakpoints, keyed by source-file path so a breakpoint at line N
145    /// in `auto.harn` doesn't also fire when execution hits line N in an
146    /// imported lib. The empty-string key is a wildcard used by callers
147    /// that don't track source paths (legacy `set_breakpoints` API).
148    pub(crate) breakpoints: BTreeMap<String, std::collections::BTreeSet<usize>>,
149    /// Whether the VM is in step mode.
150    pub(crate) step_mode: bool,
151    /// The frame depth at which stepping started (for step-over).
152    pub(crate) step_frame_depth: usize,
153    /// Whether the VM is currently stopped at a debug point.
154    pub(crate) stopped: bool,
155    /// Last source line executed (to detect line changes).
156    pub(crate) last_line: usize,
157    /// Source directory for resolving imports.
158    pub(crate) source_dir: Option<std::path::PathBuf>,
159    /// Modules currently being imported (cycle prevention).
160    pub(crate) imported_paths: Vec<std::path::PathBuf>,
161    /// Loaded module cache keyed by canonical or synthetic module path.
162    pub(crate) module_cache: BTreeMap<std::path::PathBuf, LoadedModule>,
163    /// Source file path for error reporting.
164    pub(crate) source_file: Option<String>,
165    /// Source text for error reporting.
166    pub(crate) source_text: Option<String>,
167    /// Optional bridge for delegating unknown builtins in bridge mode.
168    pub(crate) bridge: Option<Rc<crate::bridge::HostBridge>>,
169    /// Builtins denied by sandbox mode (`--deny` / `--allow` flags).
170    pub(crate) denied_builtins: HashSet<String>,
171    /// Cancellation token for cooperative graceful shutdown (set by parent).
172    pub(crate) cancel_token: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
173    /// Captured stack trace from the most recent error (fn_name, line, col).
174    pub(crate) error_stack_trace: Vec<(String, usize, usize, Option<String>)>,
175    /// Yield channel sender for generator execution. When set, `Op::Yield`
176    /// sends values through this channel instead of being a no-op.
177    pub(crate) yield_sender: Option<tokio::sync::mpsc::Sender<VmValue>>,
178    /// Project root directory (detected via harn.toml).
179    /// Used as base directory for metadata, store, and checkpoint operations.
180    pub(crate) project_root: Option<std::path::PathBuf>,
181    /// Global constants (e.g. `pi`, `e`). Checked as a fallback in `GetVar`
182    /// after the environment, so user-defined variables can shadow them.
183    pub(crate) globals: BTreeMap<String, VmValue>,
184    /// Optional debugger hook invoked when execution advances to a new source line.
185    pub(crate) debug_hook: Option<Box<DebugHook>>,
186}
187
188impl Vm {
189    pub fn new() -> Self {
190        Self {
191            stack: Vec::with_capacity(256),
192            env: VmEnv::new(),
193            output: String::new(),
194            builtins: BTreeMap::new(),
195            async_builtins: BTreeMap::new(),
196            iterators: Vec::new(),
197            frames: Vec::new(),
198            exception_handlers: Vec::new(),
199            spawned_tasks: BTreeMap::new(),
200            task_counter: 0,
201            deadlines: Vec::new(),
202            breakpoints: BTreeMap::new(),
203            step_mode: false,
204            step_frame_depth: 0,
205            stopped: false,
206            last_line: 0,
207            source_dir: None,
208            imported_paths: Vec::new(),
209            module_cache: BTreeMap::new(),
210            source_file: None,
211            source_text: None,
212            bridge: None,
213            denied_builtins: HashSet::new(),
214            cancel_token: None,
215            error_stack_trace: Vec::new(),
216            yield_sender: None,
217            project_root: None,
218            globals: BTreeMap::new(),
219            debug_hook: None,
220        }
221    }
222
223    /// Set the bridge for delegating unknown builtins in bridge mode.
224    pub fn set_bridge(&mut self, bridge: Rc<crate::bridge::HostBridge>) {
225        self.bridge = Some(bridge);
226    }
227
228    /// Set builtins that are denied in sandbox mode.
229    /// When called, the given builtin names will produce a permission error.
230    pub fn set_denied_builtins(&mut self, denied: HashSet<String>) {
231        self.denied_builtins = denied;
232    }
233
234    /// Set source info for error reporting (file path and source text).
235    pub fn set_source_info(&mut self, file: &str, text: &str) {
236        self.source_file = Some(file.to_string());
237        self.source_text = Some(text.to_string());
238    }
239
240    /// Replace breakpoints for a single source file. Pass an empty string
241    /// (or call `set_breakpoints` for the wildcard equivalent) to install
242    /// breakpoints that match every file — useful for ad-hoc CLI runs
243    /// where the embedder doesn't track per-file source paths.
244    pub fn set_breakpoints_for_file(&mut self, file: &str, lines: Vec<usize>) {
245        if lines.is_empty() {
246            self.breakpoints.remove(file);
247            return;
248        }
249        self.breakpoints
250            .insert(file.to_string(), lines.into_iter().collect());
251    }
252
253    /// Backwards-compatible wildcard form. Stores all lines under the
254    /// empty-string key, which matches *any* source file at the check
255    /// site. Existing embedders that don't track file scoping still work.
256    pub fn set_breakpoints(&mut self, lines: Vec<usize>) {
257        self.set_breakpoints_for_file("", lines);
258    }
259
260    /// Source file path of the currently executing frame, if known.
261    pub(crate) fn current_source_file(&self) -> Option<&str> {
262        self.frames
263            .last()
264            .and_then(|f| f.chunk.source_file.as_deref())
265    }
266
267    /// True when a breakpoint at `line` is set for the current frame's
268    /// source file (or the wildcard set covers it).
269    pub(crate) fn breakpoint_matches(&self, line: usize) -> bool {
270        if let Some(wild) = self.breakpoints.get("") {
271            if wild.contains(&line) {
272                return true;
273            }
274        }
275        if let Some(file) = self.current_source_file() {
276            if let Some(set) = self.breakpoints.get(file) {
277                if set.contains(&line) {
278                    return true;
279                }
280            }
281            // Some callers send a relative or differently-prefixed path
282            // than the chunk records; fall back to suffix comparison so
283            // foo.harn matches /abs/path/foo.harn and vice-versa.
284            for (key, set) in &self.breakpoints {
285                if key.is_empty() {
286                    continue;
287                }
288                if (file.ends_with(key.as_str()) || key.ends_with(file)) && set.contains(&line) {
289                    return true;
290                }
291            }
292        }
293        false
294    }
295
296    /// Enable step mode (stop at next line).
297    pub fn set_step_mode(&mut self, step: bool) {
298        self.step_mode = step;
299        self.step_frame_depth = self.frames.len();
300    }
301
302    /// Enable step-over mode (stop at next line at same or lower frame depth).
303    pub fn set_step_over(&mut self) {
304        self.step_mode = true;
305        self.step_frame_depth = self.frames.len();
306    }
307
308    /// Register a debug hook invoked whenever execution advances to a new source line.
309    pub fn set_debug_hook<F>(&mut self, hook: F)
310    where
311        F: FnMut(&DebugState) -> DebugAction + 'static,
312    {
313        self.debug_hook = Some(Box::new(hook));
314    }
315
316    /// Clear the current debug hook.
317    pub fn clear_debug_hook(&mut self) {
318        self.debug_hook = None;
319    }
320
321    /// Enable step-out mode (stop when returning from current frame).
322    pub fn set_step_out(&mut self) {
323        self.step_mode = true;
324        self.step_frame_depth = self.frames.len().saturating_sub(1);
325    }
326
327    /// Check if the VM is stopped at a debug point.
328    pub fn is_stopped(&self) -> bool {
329        self.stopped
330    }
331
332    /// Get the current debug state (variables, line, etc.).
333    pub fn debug_state(&self) -> DebugState {
334        let line = self.current_line();
335        let variables = self.env.all_variables();
336        let frame_name = if self.frames.len() > 1 {
337            format!("frame_{}", self.frames.len() - 1)
338        } else {
339            "pipeline".to_string()
340        };
341        DebugState {
342            line,
343            variables,
344            frame_name,
345            frame_depth: self.frames.len(),
346        }
347    }
348
349    /// Get all stack frames for the debugger.
350    pub fn debug_stack_frames(&self) -> Vec<(String, usize)> {
351        let mut frames = Vec::new();
352        for (i, frame) in self.frames.iter().enumerate() {
353            let line = if frame.ip > 0 && frame.ip - 1 < frame.chunk.lines.len() {
354                frame.chunk.lines[frame.ip - 1] as usize
355            } else {
356                0
357            };
358            let name = if frame.fn_name.is_empty() {
359                if i == 0 {
360                    "pipeline".to_string()
361                } else {
362                    format!("fn_{}", i)
363                }
364            } else {
365                frame.fn_name.clone()
366            };
367            frames.push((name, line));
368        }
369        frames
370    }
371
372    /// Get the current source line.
373    fn current_line(&self) -> usize {
374        if let Some(frame) = self.frames.last() {
375            let ip = if frame.ip > 0 { frame.ip - 1 } else { 0 };
376            if ip < frame.chunk.lines.len() {
377                return frame.chunk.lines[ip] as usize;
378            }
379        }
380        0
381    }
382
383    /// Execute one instruction, returning whether to stop (breakpoint/step).
384    /// Returns Ok(None) to continue, Ok(Some(val)) on program end, Err on error.
385    pub async fn step_execute(&mut self) -> Result<Option<(VmValue, bool)>, VmError> {
386        let current_line = self.current_line();
387        let line_changed = current_line != self.last_line && current_line > 0;
388
389        if line_changed {
390            self.last_line = current_line;
391
392            let state = self.debug_state();
393            if let Some(hook) = self.debug_hook.as_mut() {
394                if matches!(hook(&state), DebugAction::Stop) {
395                    self.stopped = true;
396                    return Ok(Some((VmValue::Nil, true)));
397                }
398            }
399
400            if self.breakpoint_matches(current_line) {
401                self.stopped = true;
402                return Ok(Some((VmValue::Nil, true)));
403            }
404
405            if self.step_mode && self.frames.len() <= self.step_frame_depth + 1 {
406                self.step_mode = false;
407                self.stopped = true;
408                return Ok(Some((VmValue::Nil, true)));
409            }
410        }
411
412        self.stopped = false;
413        self.execute_one_cycle().await
414    }
415
416    async fn execute_one_cycle(&mut self) -> Result<Option<(VmValue, bool)>, VmError> {
417        if let Some(&(deadline, _)) = self.deadlines.last() {
418            if Instant::now() > deadline {
419                self.deadlines.pop();
420                let err = VmError::Thrown(VmValue::String(Rc::from("Deadline exceeded")));
421                match self.handle_error(err) {
422                    Ok(None) => return Ok(None),
423                    Ok(Some(val)) => return Ok(Some((val, false))),
424                    Err(e) => return Err(e),
425                }
426            }
427        }
428
429        let frame = match self.frames.last_mut() {
430            Some(f) => f,
431            None => {
432                let val = self.stack.pop().unwrap_or(VmValue::Nil);
433                return Ok(Some((val, false)));
434            }
435        };
436
437        if frame.ip >= frame.chunk.code.len() {
438            let val = self.stack.pop().unwrap_or(VmValue::Nil);
439            let popped_frame = self.frames.pop().unwrap();
440            if self.frames.is_empty() {
441                return Ok(Some((val, false)));
442            } else {
443                self.iterators.truncate(popped_frame.saved_iterator_depth);
444                self.env = popped_frame.saved_env;
445                self.stack.truncate(popped_frame.stack_base);
446                self.stack.push(val);
447                return Ok(None);
448            }
449        }
450
451        let op = frame.chunk.code[frame.ip];
452        frame.ip += 1;
453
454        match self.execute_op(op).await {
455            Ok(Some(val)) => Ok(Some((val, false))),
456            Ok(None) => Ok(None),
457            Err(VmError::Return(val)) => {
458                if let Some(popped_frame) = self.frames.pop() {
459                    if let Some(ref dir) = popped_frame.saved_source_dir {
460                        crate::stdlib::set_thread_source_dir(dir);
461                    }
462                    let current_depth = self.frames.len();
463                    self.exception_handlers
464                        .retain(|h| h.frame_depth <= current_depth);
465                    if self.frames.is_empty() {
466                        return Ok(Some((val, false)));
467                    }
468                    self.iterators.truncate(popped_frame.saved_iterator_depth);
469                    self.env = popped_frame.saved_env;
470                    self.stack.truncate(popped_frame.stack_base);
471                    self.stack.push(val);
472                    Ok(None)
473                } else {
474                    Ok(Some((val, false)))
475                }
476            }
477            Err(e) => {
478                if self.error_stack_trace.is_empty() {
479                    self.error_stack_trace = self.capture_stack_trace();
480                }
481                match self.handle_error(e) {
482                    Ok(None) => {
483                        self.error_stack_trace.clear();
484                        Ok(None)
485                    }
486                    Ok(Some(val)) => Ok(Some((val, false))),
487                    Err(e) => Err(self.enrich_error_with_line(e)),
488                }
489            }
490        }
491    }
492
493    /// Initialize execution (push the initial frame).
494    pub fn start(&mut self, chunk: &Chunk) {
495        self.frames.push(CallFrame {
496            chunk: chunk.clone(),
497            ip: 0,
498            stack_base: self.stack.len(),
499            saved_env: self.env.clone(),
500            saved_iterator_depth: self.iterators.len(),
501            fn_name: String::new(),
502            argc: 0,
503            saved_source_dir: None,
504            module_functions: None,
505            module_state: None,
506        });
507    }
508
509    /// Register a sync builtin function.
510    pub fn register_builtin<F>(&mut self, name: &str, f: F)
511    where
512        F: Fn(&[VmValue], &mut String) -> Result<VmValue, VmError> + 'static,
513    {
514        self.builtins.insert(name.to_string(), Rc::new(f));
515    }
516
517    /// Remove a sync builtin (so an async version can take precedence).
518    pub fn unregister_builtin(&mut self, name: &str) {
519        self.builtins.remove(name);
520    }
521
522    /// Register an async builtin function.
523    pub fn register_async_builtin<F, Fut>(&mut self, name: &str, f: F)
524    where
525        F: Fn(Vec<VmValue>) -> Fut + 'static,
526        Fut: Future<Output = Result<VmValue, VmError>> + 'static,
527    {
528        self.async_builtins
529            .insert(name.to_string(), Rc::new(move |args| Box::pin(f(args))));
530    }
531
532    /// Create a child VM that shares builtins and env but has fresh execution state.
533    /// Used for parallel/spawn to fork the VM for concurrent tasks.
534    fn child_vm(&self) -> Vm {
535        Vm {
536            stack: Vec::with_capacity(64),
537            env: self.env.clone(),
538            output: String::new(),
539            builtins: self.builtins.clone(),
540            async_builtins: self.async_builtins.clone(),
541            iterators: Vec::new(),
542            frames: Vec::new(),
543            exception_handlers: Vec::new(),
544            spawned_tasks: BTreeMap::new(),
545            task_counter: 0,
546            deadlines: self.deadlines.clone(),
547            breakpoints: BTreeMap::new(),
548            step_mode: false,
549            step_frame_depth: 0,
550            stopped: false,
551            last_line: 0,
552            source_dir: self.source_dir.clone(),
553            imported_paths: Vec::new(),
554            module_cache: self.module_cache.clone(),
555            source_file: self.source_file.clone(),
556            source_text: self.source_text.clone(),
557            bridge: self.bridge.clone(),
558            denied_builtins: self.denied_builtins.clone(),
559            cancel_token: None,
560            error_stack_trace: Vec::new(),
561            yield_sender: None,
562            project_root: self.project_root.clone(),
563            globals: self.globals.clone(),
564            debug_hook: None,
565        }
566    }
567
568    /// Set the source directory for import resolution and introspection.
569    /// Also auto-detects the project root if not already set.
570    pub fn set_source_dir(&mut self, dir: &std::path::Path) {
571        self.source_dir = Some(dir.to_path_buf());
572        crate::stdlib::set_thread_source_dir(dir);
573        // Auto-detect project root if not explicitly set.
574        if self.project_root.is_none() {
575            self.project_root = crate::stdlib::process::find_project_root(dir);
576        }
577    }
578
579    /// Explicitly set the project root directory.
580    /// Used by ACP/CLI to override auto-detection.
581    pub fn set_project_root(&mut self, root: &std::path::Path) {
582        self.project_root = Some(root.to_path_buf());
583    }
584
585    /// Get the project root directory, falling back to source_dir.
586    pub fn project_root(&self) -> Option<&std::path::Path> {
587        self.project_root.as_deref().or(self.source_dir.as_deref())
588    }
589
590    /// Return all registered builtin names (sync + async).
591    pub fn builtin_names(&self) -> Vec<String> {
592        let mut names: Vec<String> = self.builtins.keys().cloned().collect();
593        names.extend(self.async_builtins.keys().cloned());
594        names
595    }
596
597    /// Set a global constant (e.g. `pi`, `e`).
598    /// Stored separately from the environment so user-defined variables can shadow them.
599    pub fn set_global(&mut self, name: &str, value: VmValue) {
600        self.globals.insert(name.to_string(), value);
601    }
602
603    /// Get the captured output.
604    pub fn output(&self) -> &str {
605        &self.output
606    }
607
608    /// Execute a compiled chunk.
609    pub async fn execute(&mut self, chunk: &Chunk) -> Result<VmValue, VmError> {
610        let span_id = crate::tracing::span_start(crate::tracing::SpanKind::Pipeline, "main".into());
611        let result = self.run_chunk(chunk).await;
612        crate::tracing::span_end(span_id);
613        result
614    }
615
616    /// Convert a VmError into either a handled exception (returning Ok) or a propagated error.
617    fn handle_error(&mut self, error: VmError) -> Result<Option<VmValue>, VmError> {
618        let thrown_value = match &error {
619            VmError::Thrown(v) => v.clone(),
620            other => VmValue::String(Rc::from(other.to_string())),
621        };
622
623        if let Some(handler) = self.exception_handlers.pop() {
624            if !handler.error_type.is_empty() {
625                // Typed catch: only match when the thrown enum's type equals the declared type.
626                let matches = match &thrown_value {
627                    VmValue::EnumVariant { enum_name, .. } => *enum_name == handler.error_type,
628                    _ => false,
629                };
630                if !matches {
631                    return self.handle_error(error);
632                }
633            }
634
635            while self.frames.len() > handler.frame_depth {
636                if let Some(frame) = self.frames.pop() {
637                    if let Some(ref dir) = frame.saved_source_dir {
638                        crate::stdlib::set_thread_source_dir(dir);
639                    }
640                    self.iterators.truncate(frame.saved_iterator_depth);
641                    self.env = frame.saved_env;
642                }
643            }
644
645            // Drop deadlines that belonged to unwound frames.
646            while self
647                .deadlines
648                .last()
649                .is_some_and(|d| d.1 > handler.frame_depth)
650            {
651                self.deadlines.pop();
652            }
653
654            self.env.truncate_scopes(handler.env_scope_depth);
655
656            self.stack.truncate(handler.stack_depth);
657            self.stack.push(thrown_value);
658
659            if let Some(frame) = self.frames.last_mut() {
660                frame.ip = handler.catch_ip;
661            }
662
663            Ok(None)
664        } else {
665            Err(error)
666        }
667    }
668
669    async fn run_chunk(&mut self, chunk: &Chunk) -> Result<VmValue, VmError> {
670        self.run_chunk_entry(chunk, 0, None, None, None).await
671    }
672
673    async fn run_chunk_entry(
674        &mut self,
675        chunk: &Chunk,
676        argc: usize,
677        saved_source_dir: Option<std::path::PathBuf>,
678        module_functions: Option<ModuleFunctionRegistry>,
679        module_state: Option<crate::value::ModuleState>,
680    ) -> Result<VmValue, VmError> {
681        self.frames.push(CallFrame {
682            chunk: chunk.clone(),
683            ip: 0,
684            stack_base: self.stack.len(),
685            saved_env: self.env.clone(),
686            saved_iterator_depth: self.iterators.len(),
687            fn_name: String::new(),
688            argc,
689            saved_source_dir,
690            module_functions,
691            module_state,
692        });
693
694        loop {
695            if let Some(&(deadline, _)) = self.deadlines.last() {
696                if Instant::now() > deadline {
697                    self.deadlines.pop();
698                    let err = VmError::Thrown(VmValue::String(Rc::from("Deadline exceeded")));
699                    match self.handle_error(err) {
700                        Ok(None) => continue,
701                        Ok(Some(val)) => return Ok(val),
702                        Err(e) => return Err(e),
703                    }
704                }
705            }
706
707            let frame = match self.frames.last_mut() {
708                Some(f) => f,
709                None => return Ok(self.stack.pop().unwrap_or(VmValue::Nil)),
710            };
711
712            if frame.ip >= frame.chunk.code.len() {
713                let val = self.stack.pop().unwrap_or(VmValue::Nil);
714                let popped_frame = self.frames.pop().unwrap();
715                if let Some(ref dir) = popped_frame.saved_source_dir {
716                    crate::stdlib::set_thread_source_dir(dir);
717                }
718
719                if self.frames.is_empty() {
720                    return Ok(val);
721                } else {
722                    self.iterators.truncate(popped_frame.saved_iterator_depth);
723                    self.env = popped_frame.saved_env;
724                    self.stack.truncate(popped_frame.stack_base);
725                    self.stack.push(val);
726                    continue;
727                }
728            }
729
730            let op = frame.chunk.code[frame.ip];
731            frame.ip += 1;
732
733            match self.execute_op(op).await {
734                Ok(Some(val)) => return Ok(val),
735                Ok(None) => continue,
736                Err(VmError::Return(val)) => {
737                    if let Some(popped_frame) = self.frames.pop() {
738                        if let Some(ref dir) = popped_frame.saved_source_dir {
739                            crate::stdlib::set_thread_source_dir(dir);
740                        }
741                        let current_depth = self.frames.len();
742                        self.exception_handlers
743                            .retain(|h| h.frame_depth <= current_depth);
744
745                        if self.frames.is_empty() {
746                            return Ok(val);
747                        }
748                        self.iterators.truncate(popped_frame.saved_iterator_depth);
749                        self.env = popped_frame.saved_env;
750                        self.stack.truncate(popped_frame.stack_base);
751                        self.stack.push(val);
752                    } else {
753                        return Ok(val);
754                    }
755                }
756                Err(e) => {
757                    // Capture stack trace before error handling unwinds frames.
758                    if self.error_stack_trace.is_empty() {
759                        self.error_stack_trace = self.capture_stack_trace();
760                    }
761                    match self.handle_error(e) {
762                        Ok(None) => {
763                            self.error_stack_trace.clear();
764                            continue;
765                        }
766                        Ok(Some(val)) => return Ok(val),
767                        Err(e) => return Err(self.enrich_error_with_line(e)),
768                    }
769                }
770            }
771        }
772    }
773
774    /// Capture the current call stack as (fn_name, line, col, source_file) tuples.
775    fn capture_stack_trace(&self) -> Vec<(String, usize, usize, Option<String>)> {
776        self.frames
777            .iter()
778            .map(|f| {
779                let idx = if f.ip > 0 { f.ip - 1 } else { 0 };
780                let line = f.chunk.lines.get(idx).copied().unwrap_or(0) as usize;
781                let col = f.chunk.columns.get(idx).copied().unwrap_or(0) as usize;
782                (f.fn_name.clone(), line, col, f.chunk.source_file.clone())
783            })
784            .collect()
785    }
786
787    /// Enrich a VmError with source line information from the captured stack
788    /// trace. Appends ` (line N)` to error variants whose messages don't
789    /// already carry location context.
790    fn enrich_error_with_line(&self, error: VmError) -> VmError {
791        // Determine the line from the captured stack trace (innermost frame).
792        let line = self
793            .error_stack_trace
794            .last()
795            .map(|(_, l, _, _)| *l)
796            .unwrap_or_else(|| self.current_line());
797        if line == 0 {
798            return error;
799        }
800        let suffix = format!(" (line {line})");
801        match error {
802            VmError::Runtime(msg) => VmError::Runtime(format!("{msg}{suffix}")),
803            VmError::TypeError(msg) => VmError::TypeError(format!("{msg}{suffix}")),
804            VmError::DivisionByZero => VmError::Runtime(format!("Division by zero{suffix}")),
805            VmError::UndefinedVariable(name) => {
806                VmError::Runtime(format!("Undefined variable: {name}{suffix}"))
807            }
808            VmError::UndefinedBuiltin(name) => {
809                VmError::Runtime(format!("Undefined builtin: {name}{suffix}"))
810            }
811            VmError::ImmutableAssignment(name) => VmError::Runtime(format!(
812                "Cannot assign to immutable binding: {name}{suffix}"
813            )),
814            VmError::StackOverflow => {
815                VmError::Runtime(format!("Stack overflow: too many nested calls{suffix}"))
816            }
817            // Leave these untouched:
818            // - Thrown: user-thrown errors should not be silently modified
819            // - CategorizedError: structured errors for agent orchestration
820            // - Return: control flow, not a real error
821            // - StackUnderflow / InvalidInstruction: internal VM bugs
822            other => other,
823        }
824    }
825
826    const MAX_FRAMES: usize = 512;
827
828    /// Build the call-time env for a closure invocation.
829    ///
830    /// Harn is **lexically scoped for data**: a closure sees exactly the
831    /// data names it captured at creation time, plus its parameters,
832    /// plus names from its originating module's `module_state`, plus
833    /// the module-function registry. The caller's *data* locals are
834    /// intentionally not visible — that would be dynamic scoping, which
835    /// is neither what Harn's TS-flavored surface suggests to users nor
836    /// something real stdlib code relies on.
837    ///
838    /// **Exception: closure-typed bindings.** Function *names* are
839    /// late-bound, Python-`LOAD_GLOBAL`-style. When a local recursive
840    /// fn is declared in a pipeline body (or inside another function),
841    /// the closure is created BEFORE its own name is defined in the
842    /// enclosing scope, so `closure.env` captures a snapshot that is
843    /// missing the self-reference. To make `fn fact(n) { fact(n-1) }`
844    /// work without a letrec trick, we merge closure-typed entries
845    /// from the caller's scope stack — but only closure-typed ones.
846    /// Data locals are never leaked across call boundaries, so the
847    /// surprising "caller's variable magically visible in callee"
848    /// semantic is ruled out.
849    ///
850    /// Imported module closures have `module_state` set, at which
851    /// point the full lexical environment is already available via
852    /// `closure.env` + `module_state`, and we skip the closure merge
853    /// entirely as a fast path. This is the hot path for context-
854    /// builder workloads (~65% of VM CPU before this optimization).
855    fn closure_call_env(caller_env: &VmEnv, closure: &VmClosure) -> VmEnv {
856        if closure.module_state.is_some() {
857            return closure.env.clone();
858        }
859        let mut call_env = closure.env.clone();
860        // Late-bind only closure-typed names from the caller — enough
861        // for local recursive / mutually-recursive fns to self-reference
862        // without leaking caller-local data into the callee.
863        for scope in &caller_env.scopes {
864            for (name, (val, mutable)) in &scope.vars {
865                if matches!(val, VmValue::Closure(_)) && call_env.get(name).is_none() {
866                    let _ = call_env.define(name, val.clone(), *mutable);
867                }
868            }
869        }
870        call_env
871    }
872
873    fn resolve_named_closure(&self, name: &str) -> Option<Rc<VmClosure>> {
874        if let Some(VmValue::Closure(closure)) = self.env.get(name) {
875            return Some(closure);
876        }
877        self.frames
878            .last()
879            .and_then(|frame| frame.module_functions.as_ref())
880            .and_then(|registry| registry.borrow().get(name).cloned())
881    }
882
883    /// Push a new call frame for a closure invocation.
884    fn push_closure_frame(
885        &mut self,
886        closure: &VmClosure,
887        args: &[VmValue],
888        _parent_functions: &[CompiledFunction],
889    ) -> Result<(), VmError> {
890        if self.frames.len() >= Self::MAX_FRAMES {
891            return Err(VmError::StackOverflow);
892        }
893        let saved_env = self.env.clone();
894
895        // If this closure originated from an imported module, switch
896        // the thread-local source dir so that render() and other
897        // source-relative builtins resolve relative to the module.
898        let saved_source_dir = if let Some(ref dir) = closure.source_dir {
899            let prev = crate::stdlib::process::VM_SOURCE_DIR.with(|sd| sd.borrow().clone());
900            crate::stdlib::set_thread_source_dir(dir);
901            prev
902        } else {
903            None
904        };
905
906        let mut call_env = Self::closure_call_env(&saved_env, closure);
907        call_env.push_scope();
908
909        let default_start = closure
910            .func
911            .default_start
912            .unwrap_or(closure.func.params.len());
913        let param_count = closure.func.params.len();
914        for (i, param) in closure.func.params.iter().enumerate() {
915            if closure.func.has_rest_param && i == param_count - 1 {
916                // Rest parameter: collect remaining args into a list
917                let rest_args = if i < args.len() {
918                    args[i..].to_vec()
919                } else {
920                    Vec::new()
921                };
922                let _ = call_env.define(param, VmValue::List(std::rc::Rc::new(rest_args)), false);
923            } else if i < args.len() {
924                let _ = call_env.define(param, args[i].clone(), false);
925            } else if i < default_start {
926                let _ = call_env.define(param, VmValue::Nil, false);
927            }
928        }
929
930        self.env = call_env;
931
932        self.frames.push(CallFrame {
933            chunk: closure.func.chunk.clone(),
934            ip: 0,
935            stack_base: self.stack.len(),
936            saved_env,
937            saved_iterator_depth: self.iterators.len(),
938            fn_name: closure.func.name.clone(),
939            argc: args.len(),
940            saved_source_dir,
941            module_functions: closure.module_functions.clone(),
942            module_state: closure.module_state.clone(),
943        });
944
945        Ok(())
946    }
947
948    /// Create a generator value by spawning the closure body as an async task.
949    /// The generator body communicates yielded values through an mpsc channel.
950    pub(crate) fn create_generator(&self, closure: &VmClosure, args: &[VmValue]) -> VmValue {
951        use crate::value::VmGenerator;
952
953        // Buffer size of 1: the generator produces one value at a time.
954        let (tx, rx) = tokio::sync::mpsc::channel::<VmValue>(1);
955
956        let mut child = self.child_vm();
957        child.yield_sender = Some(tx);
958
959        // Set up the environment for the generator body. The generator
960        // body runs in its own child VM; closure_call_env walks the
961        // current (parent) env so locally-defined generator closures
962        // can self-reference via the narrow closure-only merge. See
963        // `Vm::closure_call_env`.
964        let parent_env = self.env.clone();
965        let mut call_env = Self::closure_call_env(&parent_env, closure);
966        call_env.push_scope();
967
968        let default_start = closure
969            .func
970            .default_start
971            .unwrap_or(closure.func.params.len());
972        let param_count = closure.func.params.len();
973        for (i, param) in closure.func.params.iter().enumerate() {
974            if closure.func.has_rest_param && i == param_count - 1 {
975                let rest_args = if i < args.len() {
976                    args[i..].to_vec()
977                } else {
978                    Vec::new()
979                };
980                let _ = call_env.define(param, VmValue::List(std::rc::Rc::new(rest_args)), false);
981            } else if i < args.len() {
982                let _ = call_env.define(param, args[i].clone(), false);
983            } else if i < default_start {
984                let _ = call_env.define(param, VmValue::Nil, false);
985            }
986        }
987        child.env = call_env;
988
989        let chunk = closure.func.chunk.clone();
990        let saved_source_dir = if let Some(ref dir) = closure.source_dir {
991            let prev = crate::stdlib::process::VM_SOURCE_DIR.with(|sd| sd.borrow().clone());
992            crate::stdlib::set_thread_source_dir(dir);
993            prev
994        } else {
995            None
996        };
997        let module_functions = closure.module_functions.clone();
998        let module_state = closure.module_state.clone();
999        let argc = args.len();
1000        // Spawn the generator body as an async task.
1001        // The task will execute until return, sending yielded values through the channel.
1002        tokio::task::spawn_local(async move {
1003            let _ = child
1004                .run_chunk_entry(
1005                    &chunk,
1006                    argc,
1007                    saved_source_dir,
1008                    module_functions,
1009                    module_state,
1010                )
1011                .await;
1012            // When the generator body finishes (return or fall-through),
1013            // the sender is dropped, signaling completion to the receiver.
1014        });
1015
1016        VmValue::Generator(VmGenerator {
1017            done: Rc::new(std::cell::Cell::new(false)),
1018            receiver: Rc::new(tokio::sync::Mutex::new(rx)),
1019        })
1020    }
1021
1022    fn pop(&mut self) -> Result<VmValue, VmError> {
1023        self.stack.pop().ok_or(VmError::StackUnderflow)
1024    }
1025
1026    fn peek(&self) -> Result<&VmValue, VmError> {
1027        self.stack.last().ok_or(VmError::StackUnderflow)
1028    }
1029
1030    fn const_string(c: &Constant) -> Result<String, VmError> {
1031        match c {
1032            Constant::String(s) => Ok(s.clone()),
1033            _ => Err(VmError::TypeError("expected string constant".into())),
1034        }
1035    }
1036
1037    /// Call a closure (used by method calls like .map/.filter etc.)
1038    /// Uses recursive execution for simplicity in method dispatch.
1039    fn call_closure<'a>(
1040        &'a mut self,
1041        closure: &'a VmClosure,
1042        args: &'a [VmValue],
1043        _parent_functions: &'a [CompiledFunction],
1044    ) -> Pin<Box<dyn Future<Output = Result<VmValue, VmError>> + 'a>> {
1045        Box::pin(async move {
1046            let saved_env = self.env.clone();
1047            let saved_frames = std::mem::take(&mut self.frames);
1048            let saved_handlers = std::mem::take(&mut self.exception_handlers);
1049            let saved_iterators = std::mem::take(&mut self.iterators);
1050            let saved_deadlines = std::mem::take(&mut self.deadlines);
1051
1052            let mut call_env = Self::closure_call_env(&saved_env, closure);
1053            call_env.push_scope();
1054
1055            let default_start = closure
1056                .func
1057                .default_start
1058                .unwrap_or(closure.func.params.len());
1059            let param_count = closure.func.params.len();
1060            for (i, param) in closure.func.params.iter().enumerate() {
1061                if closure.func.has_rest_param && i == param_count - 1 {
1062                    let rest_args = if i < args.len() {
1063                        args[i..].to_vec()
1064                    } else {
1065                        Vec::new()
1066                    };
1067                    let _ =
1068                        call_env.define(param, VmValue::List(std::rc::Rc::new(rest_args)), false);
1069                } else if i < args.len() {
1070                    let _ = call_env.define(param, args[i].clone(), false);
1071                } else if i < default_start {
1072                    let _ = call_env.define(param, VmValue::Nil, false);
1073                }
1074            }
1075
1076            self.env = call_env;
1077            let argc = args.len();
1078            let saved_source_dir = if let Some(ref dir) = closure.source_dir {
1079                let prev = crate::stdlib::process::VM_SOURCE_DIR.with(|sd| sd.borrow().clone());
1080                crate::stdlib::set_thread_source_dir(dir);
1081                prev
1082            } else {
1083                None
1084            };
1085            let result = self
1086                .run_chunk_entry(
1087                    &closure.func.chunk,
1088                    argc,
1089                    saved_source_dir,
1090                    closure.module_functions.clone(),
1091                    closure.module_state.clone(),
1092                )
1093                .await;
1094
1095            self.env = saved_env;
1096            self.frames = saved_frames;
1097            self.exception_handlers = saved_handlers;
1098            self.iterators = saved_iterators;
1099            self.deadlines = saved_deadlines;
1100
1101            result
1102        })
1103    }
1104
1105    /// Invoke a value as a callable. Supports `VmValue::Closure` and
1106    /// `VmValue::BuiltinRef`, so builtin names passed by reference (e.g.
1107    /// `dict.rekey(snake_to_camel)`) dispatch through the same code path as
1108    /// user-defined closures.
1109    #[allow(clippy::manual_async_fn)]
1110    fn call_callable_value<'a>(
1111        &'a mut self,
1112        callable: &'a VmValue,
1113        args: &'a [VmValue],
1114        functions: &'a [CompiledFunction],
1115    ) -> Pin<Box<dyn Future<Output = Result<VmValue, VmError>> + 'a>> {
1116        Box::pin(async move {
1117            match callable {
1118                VmValue::Closure(closure) => self.call_closure(closure, args, functions).await,
1119                VmValue::BuiltinRef(name) => {
1120                    let name_owned = name.to_string();
1121                    self.call_named_builtin(&name_owned, args.to_vec()).await
1122                }
1123                other => Err(VmError::TypeError(format!(
1124                    "expected callable, got {}",
1125                    other.type_name()
1126                ))),
1127            }
1128        })
1129    }
1130
1131    /// Returns true if `v` is callable via `call_callable_value`.
1132    fn is_callable_value(v: &VmValue) -> bool {
1133        matches!(v, VmValue::Closure(_) | VmValue::BuiltinRef(_))
1134    }
1135
1136    /// Public wrapper for `call_closure`, used by the MCP server to invoke
1137    /// tool handler closures from outside the VM execution loop.
1138    pub async fn call_closure_pub(
1139        &mut self,
1140        closure: &VmClosure,
1141        args: &[VmValue],
1142        functions: &[CompiledFunction],
1143    ) -> Result<VmValue, VmError> {
1144        self.call_closure(closure, args, functions).await
1145    }
1146
1147    /// Resolve a named builtin: sync builtins → async builtins → bridge → error.
1148    /// Used by Call, TailCall, and Pipe handlers to avoid duplicating this lookup.
1149    async fn call_named_builtin(
1150        &mut self,
1151        name: &str,
1152        args: Vec<VmValue>,
1153    ) -> Result<VmValue, VmError> {
1154        // Auto-trace LLM calls and tool calls.
1155        let span_kind = match name {
1156            "llm_call" | "llm_stream" | "agent_loop" => Some(crate::tracing::SpanKind::LlmCall),
1157            "mcp_call" => Some(crate::tracing::SpanKind::ToolCall),
1158            _ => None,
1159        };
1160        let _span = span_kind.map(|kind| ScopeSpan::new(kind, name.to_string()));
1161
1162        // Sandbox check: deny builtins blocked by --deny/--allow flags.
1163        if self.denied_builtins.contains(name) {
1164            return Err(VmError::CategorizedError {
1165                message: format!("Tool '{}' is not permitted.", name),
1166                category: ErrorCategory::ToolRejected,
1167            });
1168        }
1169        crate::orchestration::enforce_current_policy_for_builtin(name, &args)?;
1170        if let Some(builtin) = self.builtins.get(name).cloned() {
1171            builtin(&args, &mut self.output)
1172        } else if let Some(async_builtin) = self.async_builtins.get(name).cloned() {
1173            CURRENT_ASYNC_BUILTIN_CHILD_VM.with(|slot| {
1174                slot.borrow_mut().push(self.child_vm());
1175            });
1176            let result = async_builtin(args).await;
1177            CURRENT_ASYNC_BUILTIN_CHILD_VM.with(|slot| {
1178                slot.borrow_mut().pop();
1179            });
1180            result
1181        } else if let Some(bridge) = &self.bridge {
1182            crate::orchestration::enforce_current_policy_for_bridge_builtin(name)?;
1183            let args_json: Vec<serde_json::Value> =
1184                args.iter().map(crate::llm::vm_value_to_json).collect();
1185            let result = bridge
1186                .call(
1187                    "builtin_call",
1188                    serde_json::json!({"name": name, "args": args_json}),
1189                )
1190                .await?;
1191            Ok(crate::bridge::json_result_to_vm_value(&result))
1192        } else {
1193            let all_builtins = self
1194                .builtins
1195                .keys()
1196                .chain(self.async_builtins.keys())
1197                .map(|s| s.as_str());
1198            if let Some(suggestion) = crate::value::closest_match(name, all_builtins) {
1199                return Err(VmError::Runtime(format!(
1200                    "Undefined builtin: {name} (did you mean `{suggestion}`?)"
1201                )));
1202            }
1203            Err(VmError::UndefinedBuiltin(name.to_string()))
1204        }
1205    }
1206}
1207
1208/// Clone the VM at the top of the async-builtin child VM stack, returning a
1209/// fresh `Vm` instance the caller owns. Enables concurrent tool-handler
1210/// execution within a single agent_loop iteration — the VM shares its heavy
1211/// state (env, builtins, bridge, module_cache) via `Arc`/`Rc`, so cloning is
1212/// cheap and each handler gets its own execution context.
1213///
1214/// Returns `None` if no parent VM is currently pushed on the stack.
1215pub fn clone_async_builtin_child_vm() -> Option<Vm> {
1216    CURRENT_ASYNC_BUILTIN_CHILD_VM.with(|slot| slot.borrow().last().map(|vm| vm.child_vm()))
1217}
1218
1219/// Legacy API preserved for out-of-tree callers; new code should use
1220/// `clone_async_builtin_child_vm()`. `take/restore` serialized concurrent
1221/// callers because only one could hold the popped value at a time.
1222#[deprecated(
1223    note = "use clone_async_builtin_child_vm() — take/restore serialized concurrent callers"
1224)]
1225pub fn take_async_builtin_child_vm() -> Option<Vm> {
1226    clone_async_builtin_child_vm()
1227}
1228
1229/// Legacy no-op retained for backward compatibility.
1230#[deprecated(note = "clone_async_builtin_child_vm does not need a matching restore call")]
1231pub fn restore_async_builtin_child_vm(_vm: Vm) {
1232    CURRENT_ASYNC_BUILTIN_CHILD_VM.with(|slot| {
1233        let _ = slot;
1234    });
1235}
1236
1237impl Default for Vm {
1238    fn default() -> Self {
1239        Self::new()
1240    }
1241}
1242
1243#[cfg(test)]
1244mod tests {
1245    use super::*;
1246    use crate::compiler::Compiler;
1247    use crate::stdlib::register_vm_stdlib;
1248    use harn_lexer::Lexer;
1249    use harn_parser::Parser;
1250
1251    fn run_harn(source: &str) -> (String, VmValue) {
1252        let rt = tokio::runtime::Builder::new_current_thread()
1253            .enable_all()
1254            .build()
1255            .unwrap();
1256        rt.block_on(async {
1257            let local = tokio::task::LocalSet::new();
1258            local
1259                .run_until(async {
1260                    let mut lexer = Lexer::new(source);
1261                    let tokens = lexer.tokenize().unwrap();
1262                    let mut parser = Parser::new(tokens);
1263                    let program = parser.parse().unwrap();
1264                    let chunk = Compiler::new().compile(&program).unwrap();
1265
1266                    let mut vm = Vm::new();
1267                    register_vm_stdlib(&mut vm);
1268                    let result = vm.execute(&chunk).await.unwrap();
1269                    (vm.output().to_string(), result)
1270                })
1271                .await
1272        })
1273    }
1274
1275    fn run_output(source: &str) -> String {
1276        run_harn(source).0.trim_end().to_string()
1277    }
1278
1279    fn run_harn_result(source: &str) -> Result<(String, VmValue), VmError> {
1280        let rt = tokio::runtime::Builder::new_current_thread()
1281            .enable_all()
1282            .build()
1283            .unwrap();
1284        rt.block_on(async {
1285            let local = tokio::task::LocalSet::new();
1286            local
1287                .run_until(async {
1288                    let mut lexer = Lexer::new(source);
1289                    let tokens = lexer.tokenize().unwrap();
1290                    let mut parser = Parser::new(tokens);
1291                    let program = parser.parse().unwrap();
1292                    let chunk = Compiler::new().compile(&program).unwrap();
1293
1294                    let mut vm = Vm::new();
1295                    register_vm_stdlib(&mut vm);
1296                    let result = vm.execute(&chunk).await?;
1297                    Ok((vm.output().to_string(), result))
1298                })
1299                .await
1300        })
1301    }
1302
1303    #[test]
1304    fn test_breakpoints_wildcard_matches_any_file() {
1305        let mut vm = Vm::new();
1306        vm.set_breakpoints(vec![3, 7]);
1307        assert!(vm.breakpoint_matches(3));
1308        assert!(vm.breakpoint_matches(7));
1309        assert!(!vm.breakpoint_matches(4));
1310    }
1311
1312    #[test]
1313    fn test_breakpoints_per_file_does_not_leak_to_wildcard() {
1314        let mut vm = Vm::new();
1315        vm.set_breakpoints_for_file("auto.harn", vec![10]);
1316        // Without an active frame, only the empty-string key matches; a
1317        // file-scoped breakpoint must NOT fire when no frame is active.
1318        assert!(!vm.breakpoint_matches(10));
1319    }
1320
1321    #[test]
1322    fn test_breakpoints_per_file_clear_on_empty() {
1323        let mut vm = Vm::new();
1324        vm.set_breakpoints_for_file("a.harn", vec![1, 2]);
1325        vm.set_breakpoints_for_file("a.harn", vec![]);
1326        assert!(!vm.breakpoints.contains_key("a.harn"));
1327    }
1328
1329    #[test]
1330    fn test_arithmetic() {
1331        let out =
1332            run_output("pipeline t(task) { log(2 + 3)\nlog(10 - 4)\nlog(3 * 5)\nlog(10 / 3) }");
1333        assert_eq!(out, "[harn] 5\n[harn] 6\n[harn] 15\n[harn] 3");
1334    }
1335
1336    #[test]
1337    fn test_mixed_arithmetic() {
1338        let out = run_output("pipeline t(task) { log(3 + 1.5)\nlog(10 - 2.5) }");
1339        assert_eq!(out, "[harn] 4.5\n[harn] 7.5");
1340    }
1341
1342    #[test]
1343    fn test_exponentiation() {
1344        let out = run_output(
1345            "pipeline t(task) { log(2 ** 8)\nlog(2 * 3 ** 2)\nlog(2 ** 3 ** 2)\nlog(2 ** -1) }",
1346        );
1347        assert_eq!(out, "[harn] 256\n[harn] 18\n[harn] 512\n[harn] 0.5");
1348    }
1349
1350    #[test]
1351    fn test_comparisons() {
1352        let out =
1353            run_output("pipeline t(task) { log(1 < 2)\nlog(2 > 3)\nlog(1 == 1)\nlog(1 != 2) }");
1354        assert_eq!(out, "[harn] true\n[harn] false\n[harn] true\n[harn] true");
1355    }
1356
1357    #[test]
1358    fn test_let_var() {
1359        let out = run_output("pipeline t(task) { let x = 42\nlog(x)\nvar y = 1\ny = 2\nlog(y) }");
1360        assert_eq!(out, "[harn] 42\n[harn] 2");
1361    }
1362
1363    #[test]
1364    fn test_if_else() {
1365        let out = run_output(
1366            r#"pipeline t(task) { if true { log("yes") } if false { log("wrong") } else { log("no") } }"#,
1367        );
1368        assert_eq!(out, "[harn] yes\n[harn] no");
1369    }
1370
1371    #[test]
1372    fn test_while_loop() {
1373        let out = run_output("pipeline t(task) { var i = 0\n while i < 5 { i = i + 1 }\n log(i) }");
1374        assert_eq!(out, "[harn] 5");
1375    }
1376
1377    #[test]
1378    fn test_for_in() {
1379        let out = run_output("pipeline t(task) { for item in [1, 2, 3] { log(item) } }");
1380        assert_eq!(out, "[harn] 1\n[harn] 2\n[harn] 3");
1381    }
1382
1383    #[test]
1384    fn test_inner_for_return_does_not_leak_iterator_into_caller() {
1385        let out = run_output(
1386            r#"pipeline t(task) {
1387  fn first_match() {
1388    for pattern in ["a", "b"] {
1389      return pattern
1390    }
1391    return ""
1392  }
1393
1394  var seen = []
1395  for path in ["outer"] {
1396    seen = seen + [path + ":" + first_match()]
1397  }
1398  log(join(seen, ","))
1399}"#,
1400        );
1401        assert_eq!(out, "[harn] outer:a");
1402    }
1403
1404    #[test]
1405    fn test_fn_decl_and_call() {
1406        let out = run_output("pipeline t(task) { fn add(a, b) { return a + b }\nlog(add(3, 4)) }");
1407        assert_eq!(out, "[harn] 7");
1408    }
1409
1410    #[test]
1411    fn test_closure() {
1412        let out = run_output("pipeline t(task) { let double = { x -> x * 2 }\nlog(double(5)) }");
1413        assert_eq!(out, "[harn] 10");
1414    }
1415
1416    #[test]
1417    fn test_closure_capture() {
1418        let out = run_output(
1419            "pipeline t(task) { let base = 10\nfn offset(x) { return x + base }\nlog(offset(5)) }",
1420        );
1421        assert_eq!(out, "[harn] 15");
1422    }
1423
1424    #[test]
1425    fn test_string_concat() {
1426        let out = run_output(
1427            r#"pipeline t(task) { let a = "hello" + " " + "world"
1428log(a) }"#,
1429        );
1430        assert_eq!(out, "[harn] hello world");
1431    }
1432
1433    #[test]
1434    fn test_list_map() {
1435        let out = run_output(
1436            "pipeline t(task) { let doubled = [1, 2, 3].map({ x -> x * 2 })\nlog(doubled) }",
1437        );
1438        assert_eq!(out, "[harn] [2, 4, 6]");
1439    }
1440
1441    #[test]
1442    fn test_list_filter() {
1443        let out = run_output(
1444            "pipeline t(task) { let big = [1, 2, 3, 4, 5].filter({ x -> x > 3 })\nlog(big) }",
1445        );
1446        assert_eq!(out, "[harn] [4, 5]");
1447    }
1448
1449    #[test]
1450    fn test_list_reduce() {
1451        let out = run_output(
1452            "pipeline t(task) { let sum = [1, 2, 3, 4].reduce(0, { acc, x -> acc + x })\nlog(sum) }",
1453        );
1454        assert_eq!(out, "[harn] 10");
1455    }
1456
1457    #[test]
1458    fn test_dict_access() {
1459        let out = run_output(
1460            r#"pipeline t(task) { let d = {name: "test", value: 42}
1461log(d.name)
1462log(d.value) }"#,
1463        );
1464        assert_eq!(out, "[harn] test\n[harn] 42");
1465    }
1466
1467    #[test]
1468    fn test_dict_methods() {
1469        let out = run_output(
1470            r#"pipeline t(task) { let d = {a: 1, b: 2}
1471log(d.keys())
1472log(d.values())
1473log(d.has("a"))
1474log(d.has("z")) }"#,
1475        );
1476        assert_eq!(
1477            out,
1478            "[harn] [a, b]\n[harn] [1, 2]\n[harn] true\n[harn] false"
1479        );
1480    }
1481
1482    #[test]
1483    fn test_pipe_operator() {
1484        let out = run_output(
1485            "pipeline t(task) { fn double(x) { return x * 2 }\nlet r = 5 |> double\nlog(r) }",
1486        );
1487        assert_eq!(out, "[harn] 10");
1488    }
1489
1490    #[test]
1491    fn test_pipe_with_closure() {
1492        let out = run_output(
1493            r#"pipeline t(task) { let r = "hello world" |> { s -> s.split(" ") }
1494log(r) }"#,
1495        );
1496        assert_eq!(out, "[harn] [hello, world]");
1497    }
1498
1499    #[test]
1500    fn test_nil_coalescing() {
1501        let out = run_output(
1502            r#"pipeline t(task) { let a = nil ?? "fallback"
1503log(a)
1504let b = "present" ?? "fallback"
1505log(b) }"#,
1506        );
1507        assert_eq!(out, "[harn] fallback\n[harn] present");
1508    }
1509
1510    #[test]
1511    fn test_logical_operators() {
1512        let out =
1513            run_output("pipeline t(task) { log(true && false)\nlog(true || false)\nlog(!true) }");
1514        assert_eq!(out, "[harn] false\n[harn] true\n[harn] false");
1515    }
1516
1517    #[test]
1518    fn test_match() {
1519        let out = run_output(
1520            r#"pipeline t(task) { let x = "b"
1521match x { "a" -> { log("first") } "b" -> { log("second") } "c" -> { log("third") } } }"#,
1522        );
1523        assert_eq!(out, "[harn] second");
1524    }
1525
1526    #[test]
1527    fn test_subscript() {
1528        let out = run_output("pipeline t(task) { let arr = [10, 20, 30]\nlog(arr[1]) }");
1529        assert_eq!(out, "[harn] 20");
1530    }
1531
1532    #[test]
1533    fn test_string_methods() {
1534        let out = run_output(
1535            r#"pipeline t(task) { log("hello world".replace("world", "harn"))
1536log("a,b,c".split(","))
1537log("  hello  ".trim())
1538log("hello".starts_with("hel"))
1539log("hello".ends_with("lo"))
1540log("hello".substring(1, 3)) }"#,
1541        );
1542        assert_eq!(
1543            out,
1544            "[harn] hello harn\n[harn] [a, b, c]\n[harn] hello\n[harn] true\n[harn] true\n[harn] el"
1545        );
1546    }
1547
1548    #[test]
1549    fn test_list_properties() {
1550        let out = run_output(
1551            "pipeline t(task) { let list = [1, 2, 3]\nlog(list.count)\nlog(list.empty)\nlog(list.first)\nlog(list.last) }",
1552        );
1553        assert_eq!(out, "[harn] 3\n[harn] false\n[harn] 1\n[harn] 3");
1554    }
1555
1556    #[test]
1557    fn test_recursive_function() {
1558        let out = run_output(
1559            "pipeline t(task) { fn fib(n) { if n <= 1 { return n } return fib(n - 1) + fib(n - 2) }\nlog(fib(10)) }",
1560        );
1561        assert_eq!(out, "[harn] 55");
1562    }
1563
1564    #[test]
1565    fn test_ternary() {
1566        let out = run_output(
1567            r#"pipeline t(task) { let x = 5
1568let r = x > 0 ? "positive" : "non-positive"
1569log(r) }"#,
1570        );
1571        assert_eq!(out, "[harn] positive");
1572    }
1573
1574    #[test]
1575    fn test_for_in_dict() {
1576        let out = run_output(
1577            "pipeline t(task) { let d = {a: 1, b: 2}\nfor entry in d { log(entry.key) } }",
1578        );
1579        assert_eq!(out, "[harn] a\n[harn] b");
1580    }
1581
1582    #[test]
1583    fn test_list_any_all() {
1584        let out = run_output(
1585            "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 })) }",
1586        );
1587        assert_eq!(out, "[harn] true\n[harn] true\n[harn] false");
1588    }
1589
1590    #[test]
1591    fn test_disassembly() {
1592        let mut lexer = Lexer::new("pipeline t(task) { log(2 + 3) }");
1593        let tokens = lexer.tokenize().unwrap();
1594        let mut parser = Parser::new(tokens);
1595        let program = parser.parse().unwrap();
1596        let chunk = Compiler::new().compile(&program).unwrap();
1597        let disasm = chunk.disassemble("test");
1598        assert!(disasm.contains("CONSTANT"));
1599        assert!(disasm.contains("ADD"));
1600        assert!(disasm.contains("CALL"));
1601    }
1602
1603    // --- Error handling tests ---
1604
1605    #[test]
1606    fn test_try_catch_basic() {
1607        let out = run_output(
1608            r#"pipeline t(task) { try { throw "oops" } catch(e) { log("caught: " + e) } }"#,
1609        );
1610        assert_eq!(out, "[harn] caught: oops");
1611    }
1612
1613    #[test]
1614    fn test_try_no_error() {
1615        let out = run_output(
1616            r#"pipeline t(task) {
1617var result = 0
1618try { result = 42 } catch(e) { result = 0 }
1619log(result)
1620}"#,
1621        );
1622        assert_eq!(out, "[harn] 42");
1623    }
1624
1625    #[test]
1626    fn test_throw_uncaught() {
1627        let result = run_harn_result(r#"pipeline t(task) { throw "boom" }"#);
1628        assert!(result.is_err());
1629    }
1630
1631    // --- Additional test coverage ---
1632
1633    fn run_vm(source: &str) -> String {
1634        let rt = tokio::runtime::Builder::new_current_thread()
1635            .enable_all()
1636            .build()
1637            .unwrap();
1638        rt.block_on(async {
1639            let local = tokio::task::LocalSet::new();
1640            local
1641                .run_until(async {
1642                    let mut lexer = Lexer::new(source);
1643                    let tokens = lexer.tokenize().unwrap();
1644                    let mut parser = Parser::new(tokens);
1645                    let program = parser.parse().unwrap();
1646                    let chunk = Compiler::new().compile(&program).unwrap();
1647                    let mut vm = Vm::new();
1648                    register_vm_stdlib(&mut vm);
1649                    vm.execute(&chunk).await.unwrap();
1650                    vm.output().to_string()
1651                })
1652                .await
1653        })
1654    }
1655
1656    fn run_vm_err(source: &str) -> String {
1657        let rt = tokio::runtime::Builder::new_current_thread()
1658            .enable_all()
1659            .build()
1660            .unwrap();
1661        rt.block_on(async {
1662            let local = tokio::task::LocalSet::new();
1663            local
1664                .run_until(async {
1665                    let mut lexer = Lexer::new(source);
1666                    let tokens = lexer.tokenize().unwrap();
1667                    let mut parser = Parser::new(tokens);
1668                    let program = parser.parse().unwrap();
1669                    let chunk = Compiler::new().compile(&program).unwrap();
1670                    let mut vm = Vm::new();
1671                    register_vm_stdlib(&mut vm);
1672                    match vm.execute(&chunk).await {
1673                        Err(e) => format!("{}", e),
1674                        Ok(_) => panic!("Expected error"),
1675                    }
1676                })
1677                .await
1678        })
1679    }
1680
1681    #[test]
1682    fn test_hello_world() {
1683        let out = run_vm(r#"pipeline default(task) { log("hello") }"#);
1684        assert_eq!(out, "[harn] hello\n");
1685    }
1686
1687    #[test]
1688    fn test_arithmetic_new() {
1689        let out = run_vm("pipeline default(task) { log(2 + 3) }");
1690        assert_eq!(out, "[harn] 5\n");
1691    }
1692
1693    #[test]
1694    fn test_string_concat_new() {
1695        let out = run_vm(r#"pipeline default(task) { log("a" + "b") }"#);
1696        assert_eq!(out, "[harn] ab\n");
1697    }
1698
1699    #[test]
1700    fn test_if_else_new() {
1701        let out = run_vm("pipeline default(task) { if true { log(1) } else { log(2) } }");
1702        assert_eq!(out, "[harn] 1\n");
1703    }
1704
1705    #[test]
1706    fn test_for_loop_new() {
1707        let out = run_vm("pipeline default(task) { for i in [1, 2, 3] { log(i) } }");
1708        assert_eq!(out, "[harn] 1\n[harn] 2\n[harn] 3\n");
1709    }
1710
1711    #[test]
1712    fn test_while_loop_new() {
1713        let out = run_vm("pipeline default(task) { var i = 0\nwhile i < 3 { log(i)\ni = i + 1 } }");
1714        assert_eq!(out, "[harn] 0\n[harn] 1\n[harn] 2\n");
1715    }
1716
1717    #[test]
1718    fn test_function_call_new() {
1719        let out =
1720            run_vm("pipeline default(task) { fn add(a, b) { return a + b }\nlog(add(2, 3)) }");
1721        assert_eq!(out, "[harn] 5\n");
1722    }
1723
1724    #[test]
1725    fn test_closure_new() {
1726        let out = run_vm("pipeline default(task) { let f = { x -> x * 2 }\nlog(f(5)) }");
1727        assert_eq!(out, "[harn] 10\n");
1728    }
1729
1730    #[test]
1731    fn test_recursion() {
1732        let out = run_vm("pipeline default(task) { fn fact(n) { if n <= 1 { return 1 }\nreturn n * fact(n - 1) }\nlog(fact(5)) }");
1733        assert_eq!(out, "[harn] 120\n");
1734    }
1735
1736    #[test]
1737    fn test_try_catch_new() {
1738        let out = run_vm(r#"pipeline default(task) { try { throw "err" } catch (e) { log(e) } }"#);
1739        assert_eq!(out, "[harn] err\n");
1740    }
1741
1742    #[test]
1743    fn test_try_no_error_new() {
1744        let out = run_vm("pipeline default(task) { try { log(1) } catch (e) { log(2) } }");
1745        assert_eq!(out, "[harn] 1\n");
1746    }
1747
1748    #[test]
1749    fn test_list_map_new() {
1750        let out =
1751            run_vm("pipeline default(task) { let r = [1, 2, 3].map({ x -> x * 2 })\nlog(r) }");
1752        assert_eq!(out, "[harn] [2, 4, 6]\n");
1753    }
1754
1755    #[test]
1756    fn test_list_filter_new() {
1757        let out = run_vm(
1758            "pipeline default(task) { let r = [1, 2, 3, 4].filter({ x -> x > 2 })\nlog(r) }",
1759        );
1760        assert_eq!(out, "[harn] [3, 4]\n");
1761    }
1762
1763    #[test]
1764    fn test_dict_access_new() {
1765        let out = run_vm("pipeline default(task) { let d = {name: \"Alice\"}\nlog(d.name) }");
1766        assert_eq!(out, "[harn] Alice\n");
1767    }
1768
1769    #[test]
1770    fn test_string_interpolation() {
1771        let out = run_vm("pipeline default(task) { let x = 42\nlog(\"val=${x}\") }");
1772        assert_eq!(out, "[harn] val=42\n");
1773    }
1774
1775    #[test]
1776    fn test_match_new() {
1777        let out = run_vm(
1778            "pipeline default(task) { let x = \"b\"\nmatch x { \"a\" -> { log(1) } \"b\" -> { log(2) } } }",
1779        );
1780        assert_eq!(out, "[harn] 2\n");
1781    }
1782
1783    #[test]
1784    fn test_json_roundtrip() {
1785        let out = run_vm("pipeline default(task) { let s = json_stringify({a: 1})\nlog(s) }");
1786        assert!(out.contains("\"a\""));
1787        assert!(out.contains("1"));
1788    }
1789
1790    #[test]
1791    fn test_type_of() {
1792        let out = run_vm("pipeline default(task) { log(type_of(42))\nlog(type_of(\"hi\")) }");
1793        assert_eq!(out, "[harn] int\n[harn] string\n");
1794    }
1795
1796    #[test]
1797    fn test_stack_overflow() {
1798        let err = run_vm_err("pipeline default(task) { fn f() { f() }\nf() }");
1799        assert!(
1800            err.contains("stack") || err.contains("overflow") || err.contains("recursion"),
1801            "Expected stack overflow error, got: {}",
1802            err
1803        );
1804    }
1805
1806    #[test]
1807    fn test_division_by_zero() {
1808        let err = run_vm_err("pipeline default(task) { log(1 / 0) }");
1809        assert!(
1810            err.contains("Division by zero") || err.contains("division"),
1811            "Expected division by zero error, got: {}",
1812            err
1813        );
1814    }
1815
1816    #[test]
1817    fn test_float_division_by_zero_uses_ieee_values() {
1818        let out = run_vm(
1819            "pipeline default(task) { log(is_nan(0.0 / 0.0))\nlog(is_infinite(1.0 / 0.0))\nlog(is_infinite(-1.0 / 0.0)) }",
1820        );
1821        assert_eq!(out, "[harn] true\n[harn] true\n[harn] true\n");
1822    }
1823
1824    #[test]
1825    fn test_reusing_catch_binding_name_in_same_block() {
1826        let out = run_vm(
1827            r#"pipeline default(task) {
1828try {
1829    throw "a"
1830} catch e {
1831    log(e)
1832}
1833try {
1834    throw "b"
1835} catch e {
1836    log(e)
1837}
1838}"#,
1839        );
1840        assert_eq!(out, "[harn] a\n[harn] b\n");
1841    }
1842
1843    #[test]
1844    fn test_try_catch_nested() {
1845        let out = run_output(
1846            r#"pipeline t(task) {
1847try {
1848    try {
1849        throw "inner"
1850    } catch(e) {
1851        log("inner caught: " + e)
1852        throw "outer"
1853    }
1854} catch(e2) {
1855    log("outer caught: " + e2)
1856}
1857}"#,
1858        );
1859        assert_eq!(
1860            out,
1861            "[harn] inner caught: inner\n[harn] outer caught: outer"
1862        );
1863    }
1864
1865    // --- Concurrency tests ---
1866
1867    #[test]
1868    fn test_parallel_basic() {
1869        let out = run_output(
1870            "pipeline t(task) { let results = parallel(3) { i -> i * 10 }\nlog(results) }",
1871        );
1872        assert_eq!(out, "[harn] [0, 10, 20]");
1873    }
1874
1875    #[test]
1876    fn test_parallel_no_variable() {
1877        let out = run_output("pipeline t(task) { let results = parallel(3) { 42 }\nlog(results) }");
1878        assert_eq!(out, "[harn] [42, 42, 42]");
1879    }
1880
1881    #[test]
1882    fn test_parallel_each_basic() {
1883        let out = run_output(
1884            "pipeline t(task) { let results = parallel each [1, 2, 3] { x -> x * x }\nlog(results) }",
1885        );
1886        assert_eq!(out, "[harn] [1, 4, 9]");
1887    }
1888
1889    #[test]
1890    fn test_spawn_await() {
1891        let out = run_output(
1892            r#"pipeline t(task) {
1893let handle = spawn { log("spawned") }
1894let result = await(handle)
1895log("done")
1896}"#,
1897        );
1898        assert_eq!(out, "[harn] spawned\n[harn] done");
1899    }
1900
1901    #[test]
1902    fn test_spawn_cancel() {
1903        let out = run_output(
1904            r#"pipeline t(task) {
1905let handle = spawn { log("should be cancelled") }
1906cancel(handle)
1907log("cancelled")
1908}"#,
1909        );
1910        assert_eq!(out, "[harn] cancelled");
1911    }
1912
1913    #[test]
1914    fn test_spawn_returns_value() {
1915        let out = run_output("pipeline t(task) { let h = spawn { 42 }\nlet r = await(h)\nlog(r) }");
1916        assert_eq!(out, "[harn] 42");
1917    }
1918
1919    // --- Deadline tests ---
1920
1921    #[test]
1922    fn test_deadline_success() {
1923        let out = run_output(
1924            r#"pipeline t(task) {
1925let result = deadline 5s { log("within deadline")
192642 }
1927log(result)
1928}"#,
1929        );
1930        assert_eq!(out, "[harn] within deadline\n[harn] 42");
1931    }
1932
1933    #[test]
1934    fn test_deadline_exceeded() {
1935        let result = run_harn_result(
1936            r#"pipeline t(task) {
1937deadline 1ms {
1938  var i = 0
1939  while i < 1000000 { i = i + 1 }
1940}
1941}"#,
1942        );
1943        assert!(result.is_err());
1944    }
1945
1946    #[test]
1947    fn test_deadline_caught_by_try() {
1948        let out = run_output(
1949            r#"pipeline t(task) {
1950try {
1951  deadline 1ms {
1952    var i = 0
1953    while i < 1000000 { i = i + 1 }
1954  }
1955} catch(e) {
1956  log("caught")
1957}
1958}"#,
1959        );
1960        assert_eq!(out, "[harn] caught");
1961    }
1962
1963    /// Helper that runs Harn source with a set of denied builtins.
1964    fn run_harn_with_denied(
1965        source: &str,
1966        denied: HashSet<String>,
1967    ) -> Result<(String, VmValue), VmError> {
1968        let rt = tokio::runtime::Builder::new_current_thread()
1969            .enable_all()
1970            .build()
1971            .unwrap();
1972        rt.block_on(async {
1973            let local = tokio::task::LocalSet::new();
1974            local
1975                .run_until(async {
1976                    let mut lexer = Lexer::new(source);
1977                    let tokens = lexer.tokenize().unwrap();
1978                    let mut parser = Parser::new(tokens);
1979                    let program = parser.parse().unwrap();
1980                    let chunk = Compiler::new().compile(&program).unwrap();
1981
1982                    let mut vm = Vm::new();
1983                    register_vm_stdlib(&mut vm);
1984                    vm.set_denied_builtins(denied);
1985                    let result = vm.execute(&chunk).await?;
1986                    Ok((vm.output().to_string(), result))
1987                })
1988                .await
1989        })
1990    }
1991
1992    #[test]
1993    fn test_sandbox_deny_builtin() {
1994        let denied: HashSet<String> = ["push".to_string()].into_iter().collect();
1995        let result = run_harn_with_denied(
1996            r#"pipeline t(task) {
1997let xs = [1, 2]
1998push(xs, 3)
1999}"#,
2000            denied,
2001        );
2002        let err = result.unwrap_err();
2003        let msg = format!("{err}");
2004        assert!(
2005            msg.contains("not permitted"),
2006            "expected not permitted, got: {msg}"
2007        );
2008        assert!(
2009            msg.contains("push"),
2010            "expected builtin name in error, got: {msg}"
2011        );
2012    }
2013
2014    #[test]
2015    fn test_sandbox_allowed_builtin_works() {
2016        // Denying "push" should not block "log"
2017        let denied: HashSet<String> = ["push".to_string()].into_iter().collect();
2018        let result = run_harn_with_denied(r#"pipeline t(task) { log("hello") }"#, denied);
2019        let (output, _) = result.unwrap();
2020        assert_eq!(output.trim(), "[harn] hello");
2021    }
2022
2023    #[test]
2024    fn test_sandbox_empty_denied_set() {
2025        // With an empty denied set, everything should work.
2026        let result = run_harn_with_denied(r#"pipeline t(task) { log("ok") }"#, HashSet::new());
2027        let (output, _) = result.unwrap();
2028        assert_eq!(output.trim(), "[harn] ok");
2029    }
2030
2031    #[test]
2032    fn test_sandbox_propagates_to_spawn() {
2033        // Denied builtins should propagate to spawned VMs.
2034        let denied: HashSet<String> = ["push".to_string()].into_iter().collect();
2035        let result = run_harn_with_denied(
2036            r#"pipeline t(task) {
2037let handle = spawn {
2038  let xs = [1, 2]
2039  push(xs, 3)
2040}
2041await(handle)
2042}"#,
2043            denied,
2044        );
2045        let err = result.unwrap_err();
2046        let msg = format!("{err}");
2047        assert!(
2048            msg.contains("not permitted"),
2049            "expected not permitted in spawned VM, got: {msg}"
2050        );
2051    }
2052
2053    #[test]
2054    fn test_sandbox_propagates_to_parallel() {
2055        // Denied builtins should propagate to parallel VMs.
2056        let denied: HashSet<String> = ["push".to_string()].into_iter().collect();
2057        let result = run_harn_with_denied(
2058            r#"pipeline t(task) {
2059let results = parallel(2) { i ->
2060  let xs = [1, 2]
2061  push(xs, 3)
2062}
2063}"#,
2064            denied,
2065        );
2066        let err = result.unwrap_err();
2067        let msg = format!("{err}");
2068        assert!(
2069            msg.contains("not permitted"),
2070            "expected not permitted in parallel VM, got: {msg}"
2071        );
2072    }
2073
2074    #[test]
2075    fn test_if_else_has_lexical_block_scope() {
2076        let out = run_output(
2077            r#"pipeline t(task) {
2078let x = "outer"
2079if true {
2080  let x = "inner"
2081  log(x)
2082} else {
2083  let x = "other"
2084  log(x)
2085}
2086log(x)
2087}"#,
2088        );
2089        assert_eq!(out, "[harn] inner\n[harn] outer");
2090    }
2091
2092    #[test]
2093    fn test_loop_and_catch_bindings_are_block_scoped() {
2094        let out = run_output(
2095            r#"pipeline t(task) {
2096let label = "outer"
2097for item in [1, 2] {
2098  let label = "loop ${item}"
2099  log(label)
2100}
2101try {
2102  throw("boom")
2103} catch (label) {
2104  log(label)
2105}
2106log(label)
2107}"#,
2108        );
2109        assert_eq!(
2110            out,
2111            "[harn] loop 1\n[harn] loop 2\n[harn] boom\n[harn] outer"
2112        );
2113    }
2114}