1use smol_str::SmolStr;
10use wasmsh_ast::{HereDocBody, RedirectionOp, Word, WordPart};
11use wasmsh_hir::{HirAndOr, HirAndOrOp, HirCommand, HirPipeline, HirRedirection};
12
13#[derive(Debug, Clone, PartialEq)]
15pub enum Ir {
16 Assign { name: SmolStr, value: Option<Word> },
18 ExecuteBuiltin {
20 name: SmolStr,
21 argv: Vec<Word>,
22 redirections: Vec<IrRedirection>,
23 },
24 JumpIfFailure { target: usize },
26 JumpIfSuccess { target: usize },
28 ReturnLastStatus,
30 Return { status: i32 },
32 Nop,
34}
35
36#[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#[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#[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}