1pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum StepResult {
21 Done(i32),
23 Yield,
25 Cancelled,
27 OutputLimitExceeded,
29}
30
31#[derive(Debug, Clone, Default)]
33pub struct ExecutionLimits {
34 pub step_limit: u64,
36 pub output_byte_limit: u64,
38 pub pipe_byte_limit: u64,
40 pub recursion_limit: u32,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct DiagnosticEvent {
47 pub level: DiagLevel,
48 pub category: DiagCategory,
49 pub message: String,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum DiagLevel {
55 Trace,
56 Info,
57 Warning,
58 Error,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum DiagCategory {
64 Parse,
65 Expansion,
66 Runtime,
67 Filesystem,
68 Builtin,
69 Budget,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum BudgetCategory {
75 Steps,
76 VisibleOutputBytes,
77 PipeBytes,
78 RecursionDepth,
79}
80
81#[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#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum StopReason {
118 Exhausted(ExhaustionReason),
119 Cancelled,
120}
121
122#[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#[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 pub fn cancel(&self) {
220 self.flag.store(true, Ordering::Relaxed);
221 }
222
223 #[must_use]
225 pub fn is_cancelled(&self) -> bool {
226 self.flag.load(Ordering::Relaxed)
227 }
228
229 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#[allow(missing_debug_implementations)]
243pub struct Vm {
244 pub state: ShellState,
246 pub steps: u64,
248 pub limits: ExecutionLimits,
250 pub output_bytes: u64,
252 pub budget: BudgetTracker,
254 cancel: CancellationToken,
256 pub diagnostics: Vec<DiagnosticEvent>,
258 builtins: BuiltinRegistry,
260 pub stdout: Vec<u8>,
262 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 #[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 #[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 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 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 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 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 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 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 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 #[must_use]
442 pub fn cancellation_token(&self) -> CancellationToken {
443 self.cancel.clone()
444 }
445
446 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}