Skip to main content

wasmsh_ir/
lib.rs

1//! Linear instruction representation for the wasmsh VM subset.
2//!
3//! `wasmsh-runtime` still executes the full shell by interpreting HIR
4//! directly. This crate currently models only the supported subset that
5//! can be lowered into `wasmsh-vm`: scalar assignments, builtin command
6//! execution, selected redirections, and top-level `&&` / `||`
7//! short-circuiting.
8
9use smol_str::SmolStr;
10use wasmsh_ast::{HereDocBody, RedirectionOp, Word, WordPart};
11use wasmsh_hir::{HirAndOr, HirAndOrOp, HirCommand, HirPipeline, HirRedirection};
12
13/// A single IR instruction for the VM.
14#[derive(Debug, Clone, PartialEq)]
15pub enum Ir {
16    /// Set a shell variable from a shell word.
17    Assign { name: SmolStr, value: Option<Word> },
18    /// Invoke a builtin command with its argv and redirection plan.
19    ExecuteBuiltin {
20        name: SmolStr,
21        argv: Vec<Word>,
22        redirections: Vec<IrRedirection>,
23    },
24    /// Skip the following pipeline when the previous one failed.
25    JumpIfFailure { target: usize },
26    /// Skip the following pipeline when the previous one succeeded.
27    JumpIfSuccess { target: usize },
28    /// Return the current shell status.
29    ReturnLastStatus,
30    /// Set exit status and halt.
31    Return { status: i32 },
32    /// No operation (used for padding / debugging).
33    Nop,
34}
35
36/// A compiled program: a sequence of IR instructions.
37#[derive(Debug, Clone, PartialEq)]
38pub struct IrProgram {
39    pub instructions: Vec<Ir>,
40}
41
42impl IrProgram {
43    #[must_use]
44    pub fn new(instructions: Vec<Ir>) -> Self {
45        Self { instructions }
46    }
47}
48
49/// Redirection plan attached to an IR builtin command.
50#[derive(Debug, Clone, PartialEq)]
51pub struct IrRedirection {
52    pub fd: Option<u32>,
53    pub op: RedirectionOp,
54    pub target: Word,
55    pub here_doc_body: Option<HereDocBody>,
56}
57
58/// Explicit reason why HIR cannot be lowered into the current VM subset.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum LoweringError {
61    Unsupported(&'static str),
62}
63
64impl std::fmt::Display for LoweringError {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        match self {
67            Self::Unsupported(reason) => write!(f, "{reason}"),
68        }
69    }
70}
71
72impl std::error::Error for LoweringError {}
73
74pub fn lower_supported_and_or(and_or: &HirAndOr) -> Result<IrProgram, LoweringError> {
75    let mut instructions = Vec::new();
76    lower_supported_pipeline(&and_or.first, &mut instructions)?;
77
78    for (op, pipeline) in &and_or.rest {
79        let jump_index = instructions.len();
80        instructions.push(match op {
81            HirAndOrOp::And => Ir::JumpIfFailure { target: usize::MAX },
82            HirAndOrOp::Or => Ir::JumpIfSuccess { target: usize::MAX },
83        });
84        lower_supported_pipeline(pipeline, &mut instructions)?;
85        let target = instructions.len();
86        match &mut instructions[jump_index] {
87            Ir::JumpIfFailure { target: patched } | Ir::JumpIfSuccess { target: patched } => {
88                *patched = target;
89            }
90            _ => unreachable!("jump placeholder must remain a jump"),
91        }
92    }
93
94    instructions.push(Ir::ReturnLastStatus);
95    Ok(IrProgram::new(instructions))
96}
97
98fn lower_supported_pipeline(
99    pipeline: &HirPipeline,
100    instructions: &mut Vec<Ir>,
101) -> Result<(), LoweringError> {
102    if pipeline.negated {
103        return Err(LoweringError::Unsupported(
104            "negated pipelines are outside the VM subset",
105        ));
106    }
107    if pipeline.commands.len() != 1 {
108        return Err(LoweringError::Unsupported(
109            "multi-stage pipelines are outside the VM subset",
110        ));
111    }
112
113    lower_supported_command(&pipeline.commands[0], instructions)
114}
115
116fn lower_supported_command(
117    cmd: &HirCommand,
118    instructions: &mut Vec<Ir>,
119) -> Result<(), LoweringError> {
120    match cmd {
121        HirCommand::Assign(assign) => {
122            if !assign.redirections.is_empty() {
123                return Err(LoweringError::Unsupported(
124                    "assignment redirections are outside the VM subset",
125                ));
126            }
127            for assignment in &assign.assignments {
128                instructions.push(Ir::Assign {
129                    name: assignment.name.clone(),
130                    value: assignment.value.clone(),
131                });
132            }
133            Ok(())
134        }
135        HirCommand::Exec(exec) => {
136            if !exec.env.is_empty() {
137                return Err(LoweringError::Unsupported(
138                    "command env prefixes are outside the VM subset",
139                ));
140            }
141            let Some(name) = literal_word_text(exec.argv.first()) else {
142                return Err(LoweringError::Unsupported(
143                    "builtin name must be a literal word in the VM subset",
144                ));
145            };
146            instructions.push(Ir::ExecuteBuiltin {
147                name,
148                argv: exec.argv.clone(),
149                redirections: exec.redirections.iter().map(IrRedirection::from).collect(),
150            });
151            Ok(())
152        }
153        _ => Err(LoweringError::Unsupported(
154            "command kind is outside the VM subset",
155        )),
156    }
157}
158
159fn literal_word_text(word: Option<&Word>) -> Option<SmolStr> {
160    let word = word?;
161    let mut text = String::new();
162    for part in &word.parts {
163        append_literal_part(part, &mut text)?;
164    }
165    Some(text.into())
166}
167
168fn append_literal_part(part: &WordPart, text: &mut String) -> Option<()> {
169    match part {
170        WordPart::Literal(segment) | WordPart::SingleQuoted(segment) => {
171            text.push_str(segment);
172            Some(())
173        }
174        WordPart::DoubleQuoted(parts) => {
175            for inner in parts {
176                append_literal_part(inner, text)?;
177            }
178            Some(())
179        }
180        _ => None,
181    }
182}
183
184impl From<&HirRedirection> for IrRedirection {
185    fn from(value: &HirRedirection) -> Self {
186        Self {
187            fd: value.fd,
188            op: value.op,
189            target: value.target.clone(),
190            here_doc_body: value.here_doc_body.clone(),
191        }
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use wasmsh_ast::{Span, WordPart};
199    use wasmsh_hir::lower;
200
201    #[test]
202    fn ir_program_construction() {
203        let prog = IrProgram::new(vec![
204            Ir::ExecuteBuiltin {
205                name: "echo".into(),
206                argv: vec![literal_word("echo"), literal_word("hello")],
207                redirections: Vec::new(),
208            },
209            Ir::ReturnLastStatus,
210        ]);
211        assert_eq!(prog.instructions.len(), 2);
212    }
213
214    #[test]
215    fn lowers_assignment_then_builtin_exec() {
216        let program = lower_supported_and_or(&first_and_or("FOO=bar; echo hello"))
217            .expect("simple subset should lower");
218        assert!(matches!(
219            program.instructions.as_slice(),
220            [Ir::Assign { name, value: Some(word) }, Ir::ReturnLastStatus]
221                if name == "FOO" && word_text(word) == "bar"
222        ));
223        let program = lower_supported_and_or(&second_and_or("FOO=bar; echo hello"))
224            .expect("simple subset should lower");
225        assert!(matches!(
226            program.instructions.as_slice(),
227            [Ir::ExecuteBuiltin {
228                name,
229                argv,
230                redirections
231            }, Ir::ReturnLastStatus]
232                if name == "echo"
233                    && redirections.is_empty()
234                    && argv.iter().map(word_text).collect::<Vec<_>>() == vec!["echo", "hello"]
235        ));
236    }
237
238    #[test]
239    fn lowers_short_circuit_chain() {
240        let program = lower_supported_and_or(&first_and_or("true && echo ok"))
241            .expect("and/or subset should lower");
242        assert!(matches!(
243            program.instructions.as_slice(),
244            [
245                Ir::ExecuteBuiltin {
246                    name: first_name,
247                    argv: first_argv,
248                    redirections: first_redirections
249                },
250                Ir::JumpIfFailure { target: 3 },
251                Ir::ExecuteBuiltin {
252                    name: second_name,
253                    argv: second_argv,
254                    redirections: second_redirections
255                },
256                Ir::ReturnLastStatus
257            ]
258                if first_name == "true"
259                    && first_redirections.is_empty()
260                    && first_argv.iter().map(word_text).collect::<Vec<_>>() == vec!["true"]
261                    && second_name == "echo"
262                    && second_redirections.is_empty()
263                    && second_argv.iter().map(word_text).collect::<Vec<_>>() == vec!["echo", "ok"]
264        ));
265    }
266
267    #[test]
268    fn lowers_builtin_with_stdout_redirection() {
269        let program = lower_supported_and_or(&first_and_or("echo hello > /out.txt"))
270            .expect("redirected builtin should lower");
271        assert!(matches!(
272            program.instructions.as_slice(),
273            [Ir::ExecuteBuiltin {
274                name,
275                argv,
276                redirections
277            }, Ir::ReturnLastStatus]
278                if name == "echo"
279                    && argv.iter().map(word_text).collect::<Vec<_>>() == vec!["echo", "hello"]
280                    && redirections.len() == 1
281                    && redirections[0].op == RedirectionOp::Output
282                    && word_text(&redirections[0].target) == "/out.txt"
283        ));
284    }
285
286    #[test]
287    fn rejects_multi_stage_pipeline_in_vm_subset() {
288        let err = lower_supported_and_or(&first_and_or("echo hello | cat")).unwrap_err();
289        assert_eq!(
290            err,
291            LoweringError::Unsupported("multi-stage pipelines are outside the VM subset")
292        );
293    }
294
295    fn first_and_or(source: &str) -> HirAndOr {
296        let ast = wasmsh_parse::parse(source).unwrap();
297        let hir = lower(&ast);
298        hir.items[0].list[0].clone()
299    }
300
301    fn second_and_or(source: &str) -> HirAndOr {
302        let ast = wasmsh_parse::parse(source).unwrap();
303        let hir = lower(&ast);
304        hir.items[0].list[1].clone()
305    }
306
307    fn literal_word(text: &str) -> Word {
308        Word {
309            parts: vec![WordPart::Literal(text.into())],
310            span: Span { start: 0, end: 0 },
311        }
312    }
313
314    fn word_text(word: &Word) -> String {
315        word.parts
316            .iter()
317            .map(|part| match part {
318                WordPart::Literal(text) => text.as_str(),
319                _ => panic!("expected literal word part"),
320            })
321            .collect()
322    }
323}