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