From 322c7625f90bfed7ba2c44f22994f0506bd7ed3e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 06:14:39 +0000 Subject: [PATCH 1/2] fix(interpreter): write heredoc content when redirected to file Closes #345 The parser's heredoc handling broke out of the simple-command loop immediately after consuming the heredoc body, which discarded any tokens on the same line after the delimiter (e.g. `> file` in `cat < file`). The lexer also skipped the rest-of-line text. Fix: the lexer now saves the rest-of-line text after the heredoc delimiter into `heredoc_rest_of_line`. The parser reads this field, creates a sub-parser to extract additional redirects, and adds them to the command's redirect list before breaking. https://claude.ai/code/session_018RLVEEcJBFYsxQKT5wYyNu --- crates/bashkit/src/parser/lexer.rs | 24 +++++++++++- crates/bashkit/src/parser/mod.rs | 22 ++++++++--- .../tests/spec_cases/bash/heredoc.test.sh | 38 +++++++++++++++++++ 3 files changed, 78 insertions(+), 6 deletions(-) diff --git a/crates/bashkit/src/parser/lexer.rs b/crates/bashkit/src/parser/lexer.rs index 29f88d07..5a466868 100644 --- a/crates/bashkit/src/parser/lexer.rs +++ b/crates/bashkit/src/parser/lexer.rs @@ -19,6 +19,9 @@ pub struct Lexer<'a> { /// Current position in the input position: Position, chars: std::iter::Peekable>, + /// Rest-of-line text captured during heredoc parsing. + /// In `cat < file`, this holds ` > file`. + pub heredoc_rest_of_line: String, } impl<'a> Lexer<'a> { @@ -28,6 +31,7 @@ impl<'a> Lexer<'a> { input, position: Position::new(), chars: input.chars().peekable(), + heredoc_rest_of_line: String::new(), } } @@ -1208,12 +1212,16 @@ impl<'a> Lexer<'a> { let mut content = String::new(); let mut current_line = String::new(); - // Skip to end of current line first (after the delimiter on command line) + // Collect the rest of the command line after the heredoc delimiter. + // In bash, `cat < file` means `> file` is still part of + // the command and should be parsed for redirections. + self.heredoc_rest_of_line.clear(); while let Some(ch) = self.peek_char() { self.advance(); if ch == '\n' { break; } + self.heredoc_rest_of_line.push(ch); } // Read lines until we find the delimiter @@ -1362,6 +1370,7 @@ mod tests { let mut lexer = Lexer::new("\nhello\nworld\nEOF"); let content = lexer.read_heredoc("EOF"); assert_eq!(content, "hello\nworld\n"); + assert_eq!(lexer.heredoc_rest_of_line.trim(), ""); } #[test] @@ -1369,6 +1378,7 @@ mod tests { let mut lexer = Lexer::new("\ntest\nEOF"); let content = lexer.read_heredoc("EOF"); assert_eq!(content, "test\n"); + assert_eq!(lexer.heredoc_rest_of_line.trim(), ""); } #[test] @@ -1384,6 +1394,18 @@ mod tests { // Now read heredoc content let content = lexer.read_heredoc("EOF"); assert_eq!(content, "hello\nworld\n"); + assert_eq!(lexer.heredoc_rest_of_line.trim(), ""); + } + + #[test] + fn test_read_heredoc_with_redirect() { + let mut lexer = Lexer::new("cat < file.txt\nhello\nEOF"); + assert_eq!(lexer.next_token(), Some(Token::Word("cat".to_string()))); + assert_eq!(lexer.next_token(), Some(Token::HereDoc)); + assert_eq!(lexer.next_token(), Some(Token::Word("EOF".to_string()))); + let content = lexer.read_heredoc("EOF"); + assert_eq!(content, "hello\n"); + assert_eq!(lexer.heredoc_rest_of_line.trim(), "> file.txt"); } #[test] diff --git a/crates/bashkit/src/parser/mod.rs b/crates/bashkit/src/parser/mod.rs index b1276bad..7c171f5b 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -1768,15 +1768,18 @@ impl<'a> Parser<'a> { }; // Don't advance - let read_heredoc consume directly from lexer position - // Read the here document content (reads until delimiter line) + // Read the here document content (reads until delimiter line). + // Also captures rest-of-line text (e.g. `> file` in + // `cat < file`) into lexer.heredoc_rest_of_line. let content = self.lexer.read_heredoc(&delimiter); + let rest_of_line = std::mem::take(&mut self.lexer.heredoc_rest_of_line); // Strip leading tabs for <<- let content = if strip_tabs { let had_trailing_newline = content.ends_with('\n'); let mut stripped: String = content .lines() - .map(|l| l.trim_start_matches('\t')) + .map(|l: &str| l.trim_start_matches('\t')) .collect::>() .join("\n"); if had_trailing_newline { @@ -1787,9 +1790,6 @@ impl<'a> Parser<'a> { content }; - // Now advance to get the next token after the heredoc - self.advance(); - // If delimiter was quoted, content is literal (no expansion) // Otherwise, parse for variable expansion let target = if quoted { @@ -1810,6 +1810,18 @@ impl<'a> Parser<'a> { target, }); + // Parse rest-of-line for additional redirects + // (e.g. `> file` in `cat < file`). + if !rest_of_line.trim().is_empty() { + let mut sub = Parser::new(&rest_of_line); + if let Ok(Some(sub_cmd)) = sub.parse_simple_command() { + redirects.extend(sub_cmd.redirects); + } + } + + // Now advance past the heredoc body + self.advance(); + // Heredoc body consumed subsequent lines from input. // Stop parsing this command - next tokens belong to new commands. break; diff --git a/crates/bashkit/tests/spec_cases/bash/heredoc.test.sh b/crates/bashkit/tests/spec_cases/bash/heredoc.test.sh index aafdd656..04363d6b 100644 --- a/crates/bashkit/tests/spec_cases/bash/heredoc.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/heredoc.test.sh @@ -83,6 +83,44 @@ EOF value: 42, cmd: hi, math: 84 ### end +### heredoc_redirect_after +# cat < file should write heredoc content to file, not stdout +cat < /tmp/heredoc_redirect.txt +line one +line two +EOF +cat /tmp/heredoc_redirect.txt +### expect +line one +line two +### end + +### heredoc_redirect_after_with_vars +# cat < file with variable expansion +NAME=world +cat < /tmp/heredoc_vars.txt +hello $NAME +EOF +cat /tmp/heredoc_vars.txt +### expect +hello world +### end + +### heredoc_redirect_after_multiline +# cat < file with multiline YAML-like content (issue #345) +mkdir -p /etc/app +cat < /etc/app/config.yaml +app: + name: myservice + port: 8080 +EOF +cat /etc/app/config.yaml +### expect +app: + name: myservice + port: 8080 +### end + ### heredoc_tab_strip # <<- strips leading tabs from content and delimiter cat <<-EOF From e8dc2174c71e52f1a0b439a111b52e7810c14f10 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 16:54:14 +0000 Subject: [PATCH 2/2] fix(parser): extract heredoc rest-of-line redirects via direct token parsing The sub-parser approach using parse_simple_command() returned None for redirect-only input (e.g. ` > file` with no command word), silently dropping the output redirect. Parse redirect tokens directly from the rest-of-line instead. Also change heredoc_redirect_after_multiline test to use /tmp/app/ instead of /etc/app/ which requires root in CI. https://claude.ai/code/session_01QbjrsMFJbHy5XfHCzA6TjM --- crates/bashkit/src/parser/mod.rs | 40 ++++++++++++++++++- .../tests/spec_cases/bash/heredoc.test.sh | 6 +-- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/crates/bashkit/src/parser/mod.rs b/crates/bashkit/src/parser/mod.rs index 7c171f5b..ad87adfa 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -1812,10 +1812,46 @@ impl<'a> Parser<'a> { // Parse rest-of-line for additional redirects // (e.g. `> file` in `cat < file`). + // We parse tokens directly instead of using parse_simple_command + // because that method returns None for redirect-only input + // (no command word), dropping the redirects we need. if !rest_of_line.trim().is_empty() { let mut sub = Parser::new(&rest_of_line); - if let Ok(Some(sub_cmd)) = sub.parse_simple_command() { - redirects.extend(sub_cmd.redirects); + loop { + match &sub.current_token { + Some(tokens::Token::RedirectOut) => { + sub.advance(); + if let Ok(target) = sub.expect_word() { + redirects.push(Redirect { + fd: None, + kind: RedirectKind::Output, + target, + }); + } + } + Some(tokens::Token::RedirectAppend) => { + sub.advance(); + if let Ok(target) = sub.expect_word() { + redirects.push(Redirect { + fd: None, + kind: RedirectKind::Append, + target, + }); + } + } + Some(tokens::Token::RedirectFd(fd)) => { + let fd = *fd; + sub.advance(); + if let Ok(target) = sub.expect_word() { + redirects.push(Redirect { + fd: Some(fd), + kind: RedirectKind::Output, + target, + }); + } + } + _ => break, + } } } diff --git a/crates/bashkit/tests/spec_cases/bash/heredoc.test.sh b/crates/bashkit/tests/spec_cases/bash/heredoc.test.sh index 04363d6b..5e08b196 100644 --- a/crates/bashkit/tests/spec_cases/bash/heredoc.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/heredoc.test.sh @@ -108,13 +108,13 @@ hello world ### heredoc_redirect_after_multiline # cat < file with multiline YAML-like content (issue #345) -mkdir -p /etc/app -cat < /etc/app/config.yaml +mkdir -p /tmp/app +cat < /tmp/app/config.yaml app: name: myservice port: 8080 EOF -cat /etc/app/config.yaml +cat /tmp/app/config.yaml ### expect app: name: myservice