Skip to content

Commit 6b5719b

Browse files
chaliyclaude
andauthored
fix(parser): support output redirection on compound commands (#195)
## Summary - Extend `Command::Compound` AST node to carry `Vec<Redirect>` - Parse trailing redirections (`>`, `>>`, `2>`, `&>`, etc.) after `)`, `}`, `fi`, `done` - Apply redirections when executing compound commands - Enables `(echo foo) > file`, `{ cmd; } 2>/dev/null`, `if ...; fi > out` ## Test plan - [x] All bash spec tests pass, new `subshell_redirect` test added - [x] `cargo check` clean Co-authored-by: Claude <noreply@anthropic.com>
1 parent 780ef1f commit 6b5719b

4 files changed

Lines changed: 132 additions & 17 deletions

File tree

crates/bashkit/src/interpreter/mod.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,7 @@ impl Interpreter {
378378
Command::Simple(c) => c.span.line(),
379379
Command::Pipeline(c) => c.span.line(),
380380
Command::List(c) => c.span.line(),
381-
Command::Compound(c) => match c {
381+
Command::Compound(c, _) => match c {
382382
CompoundCommand::If(cmd) => cmd.span.line(),
383383
CompoundCommand::For(cmd) => cmd.span.line(),
384384
CompoundCommand::ArithmeticFor(cmd) => cmd.span.line(),
@@ -433,7 +433,14 @@ impl Interpreter {
433433
Command::Simple(simple) => self.execute_simple_command(simple, None).await,
434434
Command::Pipeline(pipeline) => self.execute_pipeline(pipeline).await,
435435
Command::List(list) => self.execute_list(list).await,
436-
Command::Compound(compound) => self.execute_compound(compound).await,
436+
Command::Compound(compound, redirects) => {
437+
let result = self.execute_compound(compound).await?;
438+
if redirects.is_empty() {
439+
Ok(result)
440+
} else {
441+
self.apply_redirections(result, redirects).await
442+
}
443+
}
437444
Command::Function(func_def) => {
438445
// Store the function definition
439446
self.functions

crates/bashkit/src/parser/ast.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ pub enum Command {
2828
/// A command list (e.g., `a && b || c`)
2929
List(CommandList),
3030

31-
/// A compound command (if, for, while, case, etc.)
32-
Compound(CompoundCommand),
31+
/// A compound command (if, for, while, case, etc.) with optional redirections
32+
Compound(CompoundCommand, Vec<Redirect>),
3333

3434
/// A function definition
3535
Function(FunctionDef),

crates/bashkit/src/parser/mod.rs

Lines changed: 114 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,109 @@ impl<'a> Parser<'a> {
311311
}
312312
}
313313

314+
/// Parse redirections that follow a compound command (>, >>, 2>, etc.)
315+
fn parse_trailing_redirects(&mut self) -> Vec<Redirect> {
316+
let mut redirects = Vec::new();
317+
loop {
318+
match &self.current_token {
319+
Some(tokens::Token::RedirectOut) => {
320+
self.advance();
321+
if let Ok(target) = self.expect_word() {
322+
redirects.push(Redirect {
323+
fd: None,
324+
kind: RedirectKind::Output,
325+
target,
326+
});
327+
}
328+
}
329+
Some(tokens::Token::RedirectAppend) => {
330+
self.advance();
331+
if let Ok(target) = self.expect_word() {
332+
redirects.push(Redirect {
333+
fd: None,
334+
kind: RedirectKind::Append,
335+
target,
336+
});
337+
}
338+
}
339+
Some(tokens::Token::RedirectIn) => {
340+
self.advance();
341+
if let Ok(target) = self.expect_word() {
342+
redirects.push(Redirect {
343+
fd: None,
344+
kind: RedirectKind::Input,
345+
target,
346+
});
347+
}
348+
}
349+
Some(tokens::Token::RedirectBoth) => {
350+
self.advance();
351+
if let Ok(target) = self.expect_word() {
352+
redirects.push(Redirect {
353+
fd: None,
354+
kind: RedirectKind::OutputBoth,
355+
target,
356+
});
357+
}
358+
}
359+
Some(tokens::Token::DupOutput) => {
360+
self.advance();
361+
if let Ok(target) = self.expect_word() {
362+
redirects.push(Redirect {
363+
fd: Some(1),
364+
kind: RedirectKind::DupOutput,
365+
target,
366+
});
367+
}
368+
}
369+
Some(tokens::Token::RedirectFd(fd)) => {
370+
let fd = *fd;
371+
self.advance();
372+
if let Ok(target) = self.expect_word() {
373+
redirects.push(Redirect {
374+
fd: Some(fd),
375+
kind: RedirectKind::Output,
376+
target,
377+
});
378+
}
379+
}
380+
Some(tokens::Token::RedirectFdAppend(fd)) => {
381+
let fd = *fd;
382+
self.advance();
383+
if let Ok(target) = self.expect_word() {
384+
redirects.push(Redirect {
385+
fd: Some(fd),
386+
kind: RedirectKind::Append,
387+
target,
388+
});
389+
}
390+
}
391+
Some(tokens::Token::DupFd(src_fd, dst_fd)) => {
392+
let src_fd = *src_fd;
393+
let dst_fd = *dst_fd;
394+
self.advance();
395+
redirects.push(Redirect {
396+
fd: Some(src_fd),
397+
kind: RedirectKind::DupOutput,
398+
target: Word::literal(dst_fd.to_string()),
399+
});
400+
}
401+
_ => break,
402+
}
403+
}
404+
redirects
405+
}
406+
407+
/// Parse a compound command and any trailing redirections
408+
fn parse_compound_with_redirects(
409+
&mut self,
410+
parser: impl FnOnce(&mut Self) -> Result<CompoundCommand>,
411+
) -> Result<Option<Command>> {
412+
let compound = parser(self)?;
413+
let redirects = self.parse_trailing_redirects();
414+
Ok(Some(Command::Compound(compound, redirects)))
415+
}
416+
314417
/// Parse a single command (simple or compound)
315418
fn parse_command(&mut self) -> Result<Option<Command>> {
316419
self.skip_newlines()?;
@@ -319,12 +422,12 @@ impl<'a> Parser<'a> {
319422
if let Some(tokens::Token::Word(w)) = &self.current_token {
320423
let word = w.clone();
321424
match word.as_str() {
322-
"if" => return self.parse_if().map(|c| Some(Command::Compound(c))),
323-
"for" => return self.parse_for().map(|c| Some(Command::Compound(c))),
324-
"while" => return self.parse_while().map(|c| Some(Command::Compound(c))),
325-
"until" => return self.parse_until().map(|c| Some(Command::Compound(c))),
326-
"case" => return self.parse_case().map(|c| Some(Command::Compound(c))),
327-
"time" => return self.parse_time().map(|c| Some(Command::Compound(c))),
425+
"if" => return self.parse_compound_with_redirects(|s| s.parse_if()),
426+
"for" => return self.parse_compound_with_redirects(|s| s.parse_for()),
427+
"while" => return self.parse_compound_with_redirects(|s| s.parse_while()),
428+
"until" => return self.parse_compound_with_redirects(|s| s.parse_until()),
429+
"case" => return self.parse_compound_with_redirects(|s| s.parse_case()),
430+
"time" => return self.parse_compound_with_redirects(|s| s.parse_time()),
328431
"function" => return self.parse_function_keyword().map(Some),
329432
_ => {
330433
// Check for POSIX-style function: name() { body }
@@ -340,19 +443,17 @@ impl<'a> Parser<'a> {
340443

341444
// Check for arithmetic command ((expression))
342445
if matches!(self.current_token, Some(tokens::Token::DoubleLeftParen)) {
343-
return self
344-
.parse_arithmetic_command()
345-
.map(|c| Some(Command::Compound(c)));
446+
return self.parse_compound_with_redirects(|s| s.parse_arithmetic_command());
346447
}
347448

348449
// Check for subshell
349450
if matches!(self.current_token, Some(tokens::Token::LeftParen)) {
350-
return self.parse_subshell().map(|c| Some(Command::Compound(c)));
451+
return self.parse_compound_with_redirects(|s| s.parse_subshell());
351452
}
352453

353454
// Check for brace group
354455
if matches!(self.current_token, Some(tokens::Token::LeftBrace)) {
355-
return self.parse_brace_group().map(|c| Some(Command::Compound(c)));
456+
return self.parse_compound_with_redirects(|s| s.parse_brace_group());
356457
}
357458

358459
// Default to simple command
@@ -1009,7 +1110,7 @@ impl<'a> Parser<'a> {
10091110

10101111
Ok(Command::Function(FunctionDef {
10111112
name,
1012-
body: Box::new(Command::Compound(body)),
1113+
body: Box::new(Command::Compound(body, Vec::new())),
10131114
span: start_span.merge(self.current_span),
10141115
}))
10151116
}
@@ -1046,7 +1147,7 @@ impl<'a> Parser<'a> {
10461147

10471148
Ok(Command::Function(FunctionDef {
10481149
name,
1049-
body: Box::new(Command::Compound(body)),
1150+
body: Box::new(Command::Compound(body, Vec::new())),
10501151
span: start_span.merge(self.current_span),
10511152
}))
10521153
}

crates/bashkit/tests/spec_cases/bash/control-flow.test.sh

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,13 @@ three
216216
hello
217217
### end
218218

219+
### subshell_redirect
220+
# Subshell with output redirection
221+
(echo redirected) > /tmp/subshell_out.txt && cat /tmp/subshell_out.txt
222+
### expect
223+
redirected
224+
### end
225+
219226
### brace_group
220227
# Brace group
221228
{ echo hello; }

0 commit comments

Comments
 (0)