Skip to main content

harn_vm/
vm.rs

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