Skip to main content

wasmsh_vm/
lib.rs

1//! Cooperative virtual machine for the wasmsh executor subset.
2//!
3//! The full shell still runs primarily in `wasmsh-runtime`. This VM is
4//! used for the lowered IR subset and provides the shared budgeting,
5//! cancellation, diagnostics, and output accounting primitives that the
6//! runtime also relies on for resumable execution.
7
8pub mod pipe;
9
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::Arc;
12
13use wasmsh_ast::Word;
14use wasmsh_builtins::{BuiltinContext, BuiltinRegistry, VecSink as BuiltinSink};
15use wasmsh_ir::{Ir, IrProgram, IrRedirection};
16use wasmsh_state::ShellState;
17
18/// Outcome of VM execution.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum StepResult {
21    /// The program completed with the given exit code.
22    Done(i32),
23    /// The step budget was exhausted; the caller should yield.
24    Yield,
25    /// Execution was cancelled externally.
26    Cancelled,
27    /// Output byte limit was exceeded.
28    OutputLimitExceeded,
29}
30
31/// Configurable execution limits.
32#[derive(Debug, Clone, Default)]
33pub struct ExecutionLimits {
34    /// Maximum VM steps (0 = unlimited).
35    pub step_limit: u64,
36    /// Maximum bytes of combined stdout+stderr output (0 = unlimited).
37    pub output_byte_limit: u64,
38    /// Maximum bytes buffered in pipes/streaming buffers (0 = unlimited).
39    pub pipe_byte_limit: u64,
40    /// Maximum nested execution depth (0 = unlimited).
41    pub recursion_limit: u32,
42}
43
44/// A structured diagnostic event emitted during execution.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct DiagnosticEvent {
47    pub level: DiagLevel,
48    pub category: DiagCategory,
49    pub message: String,
50}
51
52/// Diagnostic severity.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum DiagLevel {
55    Trace,
56    Info,
57    Warning,
58    Error,
59}
60
61/// Category of diagnostic event.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum DiagCategory {
64    Parse,
65    Expansion,
66    Runtime,
67    Filesystem,
68    Builtin,
69    Budget,
70}
71
72/// Structured budget category tracked during execution.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum BudgetCategory {
75    Steps,
76    VisibleOutputBytes,
77    PipeBytes,
78    RecursionDepth,
79}
80
81/// Stable exhaustion reason for a specific tracked budget.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct ExhaustionReason {
84    pub category: BudgetCategory,
85    pub used: u64,
86    pub limit: u64,
87}
88
89impl ExhaustionReason {
90    #[must_use]
91    pub fn diagnostic_message(&self) -> String {
92        match self.category {
93            BudgetCategory::Steps => {
94                format!(
95                    "step budget exhausted: {} steps (limit: {})",
96                    self.used, self.limit
97                )
98            }
99            BudgetCategory::VisibleOutputBytes => format!(
100                "output limit exceeded: {} bytes (limit: {})",
101                self.used, self.limit
102            ),
103            BudgetCategory::PipeBytes => format!(
104                "pipe buffer limit exceeded: {} bytes (limit: {})",
105                self.used, self.limit
106            ),
107            BudgetCategory::RecursionDepth => format!(
108                "maximum recursion depth exceeded: {} frames (limit: {})",
109                self.used, self.limit
110            ),
111        }
112    }
113}
114
115/// Structured stop reason for the current execution.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum StopReason {
118    Exhausted(ExhaustionReason),
119    Cancelled,
120}
121
122/// Shared budget accounting across the VM and runtime layers.
123#[derive(Debug, Clone, Default)]
124pub struct BudgetTracker {
125    pub steps: u64,
126    pub visible_output_bytes: u64,
127    pub pipe_bytes: u64,
128    pub recursion_depth: u32,
129    stop_reason: Option<StopReason>,
130}
131
132impl BudgetTracker {
133    #[must_use]
134    pub fn stop_reason(&self) -> Option<&StopReason> {
135        self.stop_reason.as_ref()
136    }
137
138    pub fn clear_stop_reason(&mut self) {
139        self.stop_reason = None;
140    }
141
142    fn exhaust(&mut self, reason: ExhaustionReason) -> ExhaustionReason {
143        self.stop_reason = Some(StopReason::Exhausted(reason.clone()));
144        reason
145    }
146
147    pub fn note_cancelled(&mut self) {
148        self.stop_reason = Some(StopReason::Cancelled);
149    }
150
151    pub fn begin_step(&mut self, limit: u64) -> Result<(), ExhaustionReason> {
152        if limit > 0 && self.steps >= limit {
153            return Err(self.exhaust(ExhaustionReason {
154                category: BudgetCategory::Steps,
155                used: self.steps,
156                limit,
157            }));
158        }
159        self.steps += 1;
160        Ok(())
161    }
162
163    pub fn track_visible_output(&mut self, bytes: u64, limit: u64) -> Result<(), ExhaustionReason> {
164        self.visible_output_bytes = self.visible_output_bytes.saturating_add(bytes);
165        if limit > 0 && self.visible_output_bytes > limit {
166            return Err(self.exhaust(ExhaustionReason {
167                category: BudgetCategory::VisibleOutputBytes,
168                used: self.visible_output_bytes,
169                limit,
170            }));
171        }
172        Ok(())
173    }
174
175    pub fn set_pipe_bytes(&mut self, bytes: u64, limit: u64) -> Result<(), ExhaustionReason> {
176        self.pipe_bytes = bytes;
177        if limit > 0 && self.pipe_bytes > limit {
178            return Err(self.exhaust(ExhaustionReason {
179                category: BudgetCategory::PipeBytes,
180                used: self.pipe_bytes,
181                limit,
182            }));
183        }
184        Ok(())
185    }
186
187    pub fn enter_recursion(&mut self, limit: u32) -> Result<(), ExhaustionReason> {
188        self.recursion_depth = self.recursion_depth.saturating_add(1);
189        if limit > 0 && self.recursion_depth > limit {
190            return Err(self.exhaust(ExhaustionReason {
191                category: BudgetCategory::RecursionDepth,
192                used: self.recursion_depth as u64,
193                limit: limit as u64,
194            }));
195        }
196        Ok(())
197    }
198
199    pub fn exit_recursion(&mut self) {
200        self.recursion_depth = self.recursion_depth.saturating_sub(1);
201    }
202}
203
204/// A cancellation token that can be shared across threads.
205#[derive(Debug, Clone)]
206pub struct CancellationToken {
207    flag: Arc<AtomicBool>,
208}
209
210impl CancellationToken {
211    #[must_use]
212    pub fn new() -> Self {
213        Self {
214            flag: Arc::new(AtomicBool::new(false)),
215        }
216    }
217
218    /// Signal cancellation.
219    pub fn cancel(&self) {
220        self.flag.store(true, Ordering::Relaxed);
221    }
222
223    /// Check whether cancellation was requested.
224    #[must_use]
225    pub fn is_cancelled(&self) -> bool {
226        self.flag.load(Ordering::Relaxed)
227    }
228
229    /// Reset the cancellation flag.
230    pub fn reset(&self) {
231        self.flag.store(false, Ordering::Relaxed);
232    }
233}
234
235impl Default for CancellationToken {
236    fn default() -> Self {
237        Self::new()
238    }
239}
240
241/// The shell virtual machine.
242#[allow(missing_debug_implementations)]
243pub struct Vm {
244    /// Shell state (variables, params, cwd, etc.).
245    pub state: ShellState,
246    /// Number of steps executed so far.
247    pub steps: u64,
248    /// Execution limits.
249    pub limits: ExecutionLimits,
250    /// Bytes of output produced so far.
251    pub output_bytes: u64,
252    /// Shared budget accounting and stable stop reasons.
253    pub budget: BudgetTracker,
254    /// Cancellation token.
255    cancel: CancellationToken,
256    /// Collected diagnostic events.
257    pub diagnostics: Vec<DiagnosticEvent>,
258    /// Builtin command registry.
259    builtins: BuiltinRegistry,
260    /// Collected stdout output from command execution.
261    pub stdout: Vec<u8>,
262    /// Collected stderr output from command execution.
263    pub stderr: Vec<u8>,
264}
265
266pub trait VmExecutor {
267    fn assign(&mut self, vm: &mut Vm, name: &str, value: Option<&Word>);
268
269    fn execute_builtin(
270        &mut self,
271        vm: &mut Vm,
272        name: &str,
273        argv: &[Word],
274        redirections: &[IrRedirection],
275    ) -> i32;
276}
277
278struct BuiltinVmExecutor {
279    builtins: BuiltinRegistry,
280}
281
282impl VmExecutor for BuiltinVmExecutor {
283    fn assign(&mut self, vm: &mut Vm, name: &str, value: Option<&Word>) {
284        let value = value.map_or_else(String::new, |word| {
285            wasmsh_expand::expand_word(word, &mut vm.state)
286        });
287        vm.state.set_var(name.into(), value.into());
288        vm.state.last_status = 0;
289    }
290
291    fn execute_builtin(
292        &mut self,
293        vm: &mut Vm,
294        name: &str,
295        argv: &[Word],
296        _redirections: &[IrRedirection],
297    ) -> i32 {
298        let Some(builtin_fn) = self.builtins.get(name) else {
299            vm.emit_diagnostic(
300                DiagLevel::Error,
301                DiagCategory::Builtin,
302                format!("unknown builtin: {name}"),
303            );
304            vm.state.last_status = 127;
305            return 127;
306        };
307
308        let expanded: Vec<String> = argv
309            .iter()
310            .map(|word| wasmsh_expand::expand_word(word, &mut vm.state))
311            .collect();
312        let argv_refs: Vec<&str> = expanded.iter().map(String::as_str).collect();
313        let mut sink = BuiltinSink::default();
314        let status = {
315            let mut ctx = BuiltinContext {
316                state: &mut vm.state,
317                output: &mut sink,
318                fs: None,
319                stdin: None,
320            };
321            builtin_fn(&mut ctx, &argv_refs)
322        };
323        vm.write_streams(&sink.stdout, &sink.stderr);
324        vm.state.last_status = status;
325        status
326    }
327}
328
329impl Vm {
330    /// Create a new VM with the given state and limits.
331    #[must_use]
332    pub fn new(state: ShellState, step_budget: u64) -> Self {
333        Self {
334            state,
335            steps: 0,
336            limits: ExecutionLimits {
337                step_limit: step_budget,
338                ..ExecutionLimits::default()
339            },
340            output_bytes: 0,
341            budget: BudgetTracker::default(),
342            cancel: CancellationToken::new(),
343            diagnostics: Vec::new(),
344            builtins: BuiltinRegistry::new(),
345            stdout: Vec::new(),
346            stderr: Vec::new(),
347        }
348    }
349
350    /// Create a VM with full execution limits.
351    #[must_use]
352    pub fn with_limits(state: ShellState, limits: ExecutionLimits) -> Self {
353        Self {
354            state,
355            steps: 0,
356            limits,
357            output_bytes: 0,
358            budget: BudgetTracker::default(),
359            cancel: CancellationToken::new(),
360            diagnostics: Vec::new(),
361            builtins: BuiltinRegistry::new(),
362            stdout: Vec::new(),
363            stderr: Vec::new(),
364        }
365    }
366
367    /// Emit a diagnostic event.
368    pub fn emit_diagnostic(&mut self, level: DiagLevel, category: DiagCategory, message: String) {
369        self.diagnostics.push(DiagnosticEvent {
370            level,
371            category,
372            message,
373        });
374    }
375
376    fn budget_stop(&mut self, result: StepResult, message: String) -> StepResult {
377        self.emit_diagnostic(DiagLevel::Error, DiagCategory::Budget, message);
378        result
379    }
380
381    #[must_use]
382    pub fn stop_reason(&self) -> Option<&StopReason> {
383        self.budget.stop_reason()
384    }
385
386    /// Track output bytes and check the limit. Returns true if within limits.
387    pub fn track_output(&mut self, bytes: u64) -> bool {
388        self.output_bytes += bytes;
389        self.budget
390            .track_visible_output(bytes, self.limits.output_byte_limit)
391            .is_ok()
392    }
393
394    /// Append stdout bytes and update output accounting.
395    pub fn write_stdout(&mut self, data: &[u8]) {
396        self.stdout.extend_from_slice(data);
397        self.track_output(data.len() as u64);
398    }
399
400    /// Append stderr bytes and update output accounting.
401    pub fn write_stderr(&mut self, data: &[u8]) {
402        self.stderr.extend_from_slice(data);
403        self.track_output(data.len() as u64);
404    }
405
406    /// Append both stdout and stderr bytes and update output accounting once.
407    pub fn write_streams(&mut self, stdout: &[u8], stderr: &[u8]) {
408        self.stdout.extend_from_slice(stdout);
409        self.stderr.extend_from_slice(stderr);
410        self.track_output((stdout.len() + stderr.len()) as u64);
411    }
412
413    /// Check whether the accumulated output has exceeded the configured limit.
414    pub fn check_output_limit(&mut self) -> Result<(), StepResult> {
415        if let Some(StopReason::Exhausted(reason)) = self.stop_reason() {
416            if reason.category == BudgetCategory::VisibleOutputBytes {
417                return Err(
418                    self.budget_stop(StepResult::OutputLimitExceeded, reason.diagnostic_message())
419                );
420            }
421        }
422        Ok(())
423    }
424
425    /// Consume one execution step using the VM's shared budget/cancel semantics.
426    pub fn begin_step(&mut self) -> Result<(), StepResult> {
427        if self.cancel.is_cancelled() {
428            self.budget.note_cancelled();
429            return Err(self.budget_stop(StepResult::Cancelled, "execution cancelled".to_string()));
430        }
431        self.check_output_limit()?;
432        if let Err(reason) = self.budget.begin_step(self.limits.step_limit) {
433            self.steps = self.budget.steps;
434            return Err(self.budget_stop(StepResult::Yield, reason.diagnostic_message()));
435        }
436        self.steps = self.budget.steps;
437        Ok(())
438    }
439
440    /// Get the cancellation token (can be cloned and shared).
441    #[must_use]
442    pub fn cancellation_token(&self) -> CancellationToken {
443        self.cancel.clone()
444    }
445
446    /// Execute an IR program to completion (or until yield/cancel).
447    pub fn run(&mut self, program: &IrProgram) -> StepResult {
448        let builtins = std::mem::take(&mut self.builtins);
449        let mut executor = BuiltinVmExecutor { builtins };
450        let result = self.run_with_executor(program, &mut executor);
451        self.builtins = executor.builtins;
452        result
453    }
454
455    pub fn run_with_executor<E: VmExecutor>(
456        &mut self,
457        program: &IrProgram,
458        executor: &mut E,
459    ) -> StepResult {
460        let mut pc = 0;
461        let instructions = &program.instructions;
462
463        while pc < instructions.len() {
464            if let Err(stop) = self.begin_step() {
465                return stop;
466            }
467
468            match &instructions[pc] {
469                Ir::Assign { name, value } => {
470                    executor.assign(self, name.as_str(), value.as_ref());
471                }
472                Ir::ExecuteBuiltin {
473                    name,
474                    argv,
475                    redirections,
476                } => {
477                    let status = executor.execute_builtin(self, name, argv, redirections);
478                    self.state.last_status = status;
479                }
480                Ir::JumpIfFailure { target } => {
481                    if self.state.last_status != 0 {
482                        pc = *target;
483                        continue;
484                    }
485                }
486                Ir::JumpIfSuccess { target } => {
487                    if self.state.last_status == 0 {
488                        pc = *target;
489                        continue;
490                    }
491                }
492                Ir::ReturnLastStatus => {
493                    return StepResult::Done(self.state.last_status);
494                }
495                Ir::Return { status } => {
496                    self.state.last_status = *status;
497                    return StepResult::Done(*status);
498                }
499                Ir::Nop => {}
500            }
501
502            pc += 1;
503        }
504
505        StepResult::Done(self.state.last_status)
506    }
507}
508
509impl Default for Vm {
510    fn default() -> Self {
511        Self::new(ShellState::new(), 0)
512    }
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518    use wasmsh_ast::{RedirectionOp, Span, WordPart};
519
520    #[derive(Default)]
521    struct TestExecutor {
522        seen_redirections: Vec<Vec<IrRedirection>>,
523    }
524
525    impl VmExecutor for TestExecutor {
526        fn assign(&mut self, vm: &mut Vm, name: &str, value: Option<&Word>) {
527            let value = value.map_or_else(String::new, |word| {
528                wasmsh_expand::expand_word(word, &mut vm.state)
529            });
530            vm.state.set_var(name.into(), value.into());
531            vm.state.last_status = 0;
532        }
533
534        fn execute_builtin(
535            &mut self,
536            vm: &mut Vm,
537            name: &str,
538            argv: &[Word],
539            redirections: &[IrRedirection],
540        ) -> i32 {
541            self.seen_redirections.push(redirections.to_vec());
542            let expanded: Vec<String> = argv
543                .iter()
544                .map(|word| wasmsh_expand::expand_word(word, &mut vm.state))
545                .collect();
546            let status = match name {
547                "echo" => {
548                    let text = expanded[1..].join(" ");
549                    vm.write_stdout(format!("{text}\n").as_bytes());
550                    0
551                }
552                "true" => 0,
553                "false" => 1,
554                _ => 127,
555            };
556            vm.state.last_status = status;
557            status
558        }
559    }
560
561    #[test]
562    fn run_empty_program() {
563        let mut vm = Vm::default();
564        let prog = IrProgram::new(vec![]);
565        assert_eq!(vm.run(&prog), StepResult::Done(0));
566    }
567
568    #[test]
569    fn run_return() {
570        let mut vm = Vm::default();
571        let prog = IrProgram::new(vec![Ir::Return { status: 42 }]);
572        assert_eq!(vm.run(&prog), StepResult::Done(42));
573        assert_eq!(vm.state.last_status, 42);
574    }
575
576    #[test]
577    fn run_set_var() {
578        let mut vm = Vm::default();
579        let prog = IrProgram::new(vec![
580            Ir::Assign {
581                name: "FOO".into(),
582                value: Some(literal_word("bar")),
583            },
584            Ir::Return { status: 0 },
585        ]);
586        assert_eq!(vm.run(&prog), StepResult::Done(0));
587        assert_eq!(vm.state.get_var("FOO").unwrap(), "bar");
588    }
589
590    #[test]
591    fn run_builtin_placeholder() {
592        let mut vm = Vm::default();
593        let prog = IrProgram::new(vec![
594            Ir::ExecuteBuiltin {
595                name: "echo".into(),
596                argv: vec![literal_word("echo"), literal_word("hello")],
597                redirections: Vec::new(),
598            },
599            Ir::Return { status: 0 },
600        ]);
601        assert_eq!(vm.run(&prog), StepResult::Done(0));
602        assert_eq!(String::from_utf8(vm.stdout).unwrap(), "hello\n");
603    }
604
605    #[test]
606    fn step_counting() {
607        let mut vm = Vm::default();
608        let prog = IrProgram::new(vec![Ir::Nop, Ir::Nop, Ir::Nop]);
609        vm.run(&prog);
610        assert_eq!(vm.steps, 3);
611    }
612
613    #[test]
614    fn step_budget_yield() {
615        let mut vm = Vm::new(ShellState::new(), 2);
616        let prog = IrProgram::new(vec![Ir::Nop, Ir::Nop, Ir::Nop, Ir::Nop]);
617        assert_eq!(vm.run(&prog), StepResult::Yield);
618        assert_eq!(vm.steps, 2);
619    }
620
621    #[test]
622    fn output_limit() {
623        let mut vm = Vm::with_limits(
624            ShellState::new(),
625            ExecutionLimits {
626                step_limit: 0,
627                output_byte_limit: 10,
628                ..ExecutionLimits::default()
629            },
630        );
631        assert!(vm.track_output(5));
632        assert!(vm.track_output(5));
633        assert!(!vm.track_output(1));
634    }
635
636    #[test]
637    fn diagnostics_collected() {
638        let mut vm = Vm::default();
639        vm.emit_diagnostic(
640            DiagLevel::Warning,
641            DiagCategory::Budget,
642            "step limit approaching".into(),
643        );
644        assert_eq!(vm.diagnostics.len(), 1);
645        assert_eq!(vm.diagnostics[0].level, DiagLevel::Warning);
646        assert_eq!(vm.diagnostics[0].category, DiagCategory::Budget);
647    }
648
649    #[test]
650    fn cancellation() {
651        let mut vm = Vm::default();
652        let token = vm.cancellation_token();
653        token.cancel();
654        let prog = IrProgram::new(vec![Ir::Nop]);
655        assert_eq!(vm.run(&prog), StepResult::Cancelled);
656        assert!(vm
657            .diagnostics
658            .iter()
659            .any(|d| d.message.contains("execution cancelled")));
660    }
661
662    #[test]
663    fn cancellation_token_reset() {
664        let token = CancellationToken::new();
665        assert!(!token.is_cancelled());
666        token.cancel();
667        assert!(token.is_cancelled());
668        token.reset();
669        assert!(!token.is_cancelled());
670    }
671
672    #[test]
673    fn status_propagation() {
674        let mut vm = Vm::default();
675        let prog = IrProgram::new(vec![
676            Ir::Assign {
677                name: "X".into(),
678                value: Some(literal_word("1")),
679            },
680            Ir::Return { status: 7 },
681        ]);
682        vm.run(&prog);
683        assert_eq!(vm.state.last_status, 7);
684        assert_eq!(vm.state.get_var("?").unwrap(), "7");
685        assert_eq!(vm.state.get_var("X").unwrap(), "1");
686    }
687
688    #[test]
689    fn begin_step_matches_vm_budget_semantics() {
690        let mut vm = Vm::new(ShellState::new(), 1);
691        assert_eq!(vm.begin_step(), Ok(()));
692        assert_eq!(vm.steps, 1);
693        assert_eq!(vm.begin_step(), Err(StepResult::Yield));
694        assert!(vm
695            .diagnostics
696            .iter()
697            .any(|d| d.message.contains("step budget exhausted")));
698    }
699
700    #[test]
701    fn output_limit_is_reported_through_begin_step() {
702        let mut vm = Vm::with_limits(
703            ShellState::new(),
704            ExecutionLimits {
705                step_limit: 0,
706                output_byte_limit: 3,
707                ..ExecutionLimits::default()
708            },
709        );
710        vm.write_stdout(b"four");
711        assert_eq!(vm.begin_step(), Err(StepResult::OutputLimitExceeded));
712        assert!(vm
713            .diagnostics
714            .iter()
715            .any(|d| d.message.contains("output limit exceeded")));
716    }
717
718    #[test]
719    fn step_limit_exposes_structured_stop_reason() {
720        let mut vm = Vm::new(ShellState::new(), 1);
721        assert_eq!(vm.begin_step(), Ok(()));
722        assert_eq!(vm.begin_step(), Err(StepResult::Yield));
723        assert_eq!(
724            vm.stop_reason(),
725            Some(&StopReason::Exhausted(ExhaustionReason {
726                category: BudgetCategory::Steps,
727                used: 1,
728                limit: 1,
729            }))
730        );
731    }
732
733    #[test]
734    fn cancellation_remains_distinct_from_budget_exhaustion() {
735        let mut vm = Vm::default();
736        vm.cancellation_token().cancel();
737        assert_eq!(vm.begin_step(), Err(StepResult::Cancelled));
738        assert_eq!(vm.stop_reason(), Some(&StopReason::Cancelled));
739    }
740
741    #[test]
742    fn budget_tracker_tracks_pipe_and_recursion_limits() {
743        let mut tracker = BudgetTracker::default();
744        let pipe = tracker.set_pipe_bytes(9, 8).unwrap_err();
745        assert_eq!(pipe.category, BudgetCategory::PipeBytes);
746        assert_eq!(pipe.limit, 8);
747
748        let mut tracker = BudgetTracker::default();
749        tracker.enter_recursion(2).unwrap();
750        tracker.enter_recursion(2).unwrap();
751        let recursion = tracker.enter_recursion(2).unwrap_err();
752        assert_eq!(recursion.category, BudgetCategory::RecursionDepth);
753        assert_eq!(recursion.used, 3);
754    }
755
756    #[test]
757    fn run_assignment_and_expanding_builtin_with_executor() {
758        let mut vm = Vm::default();
759        let mut executor = TestExecutor::default();
760        let prog = IrProgram::new(vec![
761            Ir::Assign {
762                name: "FOO".into(),
763                value: Some(literal_word("bar")),
764            },
765            Ir::ExecuteBuiltin {
766                name: "echo".into(),
767                argv: vec![literal_word("echo"), parameter_word("FOO")],
768                redirections: Vec::new(),
769            },
770            Ir::ReturnLastStatus,
771        ]);
772        assert_eq!(
773            vm.run_with_executor(&prog, &mut executor),
774            StepResult::Done(0)
775        );
776        assert_eq!(vm.state.get_var("FOO").unwrap(), "bar");
777        assert_eq!(String::from_utf8(vm.stdout).unwrap(), "bar\n");
778    }
779
780    #[test]
781    fn jump_if_failure_skips_rhs_of_and_list() {
782        let mut vm = Vm::default();
783        let mut executor = TestExecutor::default();
784        let prog = IrProgram::new(vec![
785            Ir::ExecuteBuiltin {
786                name: "false".into(),
787                argv: vec![literal_word("false")],
788                redirections: Vec::new(),
789            },
790            Ir::JumpIfFailure { target: 3 },
791            Ir::ExecuteBuiltin {
792                name: "echo".into(),
793                argv: vec![literal_word("echo"), literal_word("nope")],
794                redirections: Vec::new(),
795            },
796            Ir::ReturnLastStatus,
797        ]);
798        assert_eq!(
799            vm.run_with_executor(&prog, &mut executor),
800            StepResult::Done(1)
801        );
802        assert!(vm.stdout.is_empty());
803    }
804
805    #[test]
806    fn jump_if_success_skips_rhs_of_or_list() {
807        let mut vm = Vm::default();
808        let mut executor = TestExecutor::default();
809        let prog = IrProgram::new(vec![
810            Ir::ExecuteBuiltin {
811                name: "true".into(),
812                argv: vec![literal_word("true")],
813                redirections: Vec::new(),
814            },
815            Ir::JumpIfSuccess { target: 3 },
816            Ir::ExecuteBuiltin {
817                name: "echo".into(),
818                argv: vec![literal_word("echo"), literal_word("nope")],
819                redirections: Vec::new(),
820            },
821            Ir::ReturnLastStatus,
822        ]);
823        assert_eq!(
824            vm.run_with_executor(&prog, &mut executor),
825            StepResult::Done(0)
826        );
827        assert!(vm.stdout.is_empty());
828    }
829
830    #[test]
831    fn executor_receives_redirection_plan() {
832        let mut vm = Vm::default();
833        let mut executor = TestExecutor::default();
834        let prog = IrProgram::new(vec![
835            Ir::ExecuteBuiltin {
836                name: "echo".into(),
837                argv: vec![literal_word("echo"), literal_word("hello")],
838                redirections: vec![IrRedirection {
839                    fd: None,
840                    op: RedirectionOp::Output,
841                    target: literal_word("/out.txt"),
842                    here_doc_body: None,
843                }],
844            },
845            Ir::ReturnLastStatus,
846        ]);
847        assert_eq!(
848            vm.run_with_executor(&prog, &mut executor),
849            StepResult::Done(0)
850        );
851        assert_eq!(executor.seen_redirections.len(), 1);
852        assert_eq!(executor.seen_redirections[0][0].op, RedirectionOp::Output);
853    }
854
855    fn literal_word(text: &str) -> Word {
856        Word {
857            parts: vec![WordPart::Literal(text.into())],
858            span: Span { start: 0, end: 0 },
859        }
860    }
861
862    fn parameter_word(name: &str) -> Word {
863        Word {
864            parts: vec![WordPart::Parameter(name.into())],
865            span: Span { start: 0, end: 0 },
866        }
867    }
868}