Skip to content

Commit 3cca974

Browse files
chaliyclaude
andauthored
fix(interpreter): write heredoc content when redirected to file (#370)
## Summary - Fix heredoc redirect to file (`cat <<EOF > file`) which previously wrote to stdout instead of the file - Lexer now captures rest-of-line text after heredoc delimiter instead of discarding it - Parser creates a sub-parser to extract additional redirects from the rest-of-line Closes #345 ## Test plan - [x] Added 3 new spec tests: `heredoc_redirect_after`, `heredoc_redirect_after_with_vars`, `heredoc_redirect_after_multiline` - [x] Added lexer unit test `test_read_heredoc_with_redirect` - [x] All 1308 bash spec tests pass (100% pass rate) - [x] Existing `heredoc_to_file` test (`cat > file <<EOF` syntax) still passes - [x] `cargo fmt --check` clean - [x] `cargo clippy -- -D warnings` clean --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6994214 commit 3cca974

File tree

3 files changed

+114
-6
lines changed

3 files changed

+114
-6
lines changed

crates/bashkit/src/parser/lexer.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ pub struct Lexer<'a> {
1919
/// Current position in the input
2020
position: Position,
2121
chars: std::iter::Peekable<std::str::Chars<'a>>,
22+
/// Rest-of-line text captured during heredoc parsing.
23+
/// In `cat <<EOF > file`, this holds ` > file`.
24+
pub heredoc_rest_of_line: String,
2225
}
2326

2427
impl<'a> Lexer<'a> {
@@ -28,6 +31,7 @@ impl<'a> Lexer<'a> {
2831
input,
2932
position: Position::new(),
3033
chars: input.chars().peekable(),
34+
heredoc_rest_of_line: String::new(),
3135
}
3236
}
3337

@@ -1208,12 +1212,16 @@ impl<'a> Lexer<'a> {
12081212
let mut content = String::new();
12091213
let mut current_line = String::new();
12101214

1211-
// Skip to end of current line first (after the delimiter on command line)
1215+
// Collect the rest of the command line after the heredoc delimiter.
1216+
// In bash, `cat <<EOF > file` means `> file` is still part of
1217+
// the command and should be parsed for redirections.
1218+
self.heredoc_rest_of_line.clear();
12121219
while let Some(ch) = self.peek_char() {
12131220
self.advance();
12141221
if ch == '\n' {
12151222
break;
12161223
}
1224+
self.heredoc_rest_of_line.push(ch);
12171225
}
12181226

12191227
// Read lines until we find the delimiter
@@ -1362,13 +1370,15 @@ mod tests {
13621370
let mut lexer = Lexer::new("\nhello\nworld\nEOF");
13631371
let content = lexer.read_heredoc("EOF");
13641372
assert_eq!(content, "hello\nworld\n");
1373+
assert_eq!(lexer.heredoc_rest_of_line.trim(), "");
13651374
}
13661375

13671376
#[test]
13681377
fn test_read_heredoc_single_line() {
13691378
let mut lexer = Lexer::new("\ntest\nEOF");
13701379
let content = lexer.read_heredoc("EOF");
13711380
assert_eq!(content, "test\n");
1381+
assert_eq!(lexer.heredoc_rest_of_line.trim(), "");
13721382
}
13731383

13741384
#[test]
@@ -1384,6 +1394,18 @@ mod tests {
13841394
// Now read heredoc content
13851395
let content = lexer.read_heredoc("EOF");
13861396
assert_eq!(content, "hello\nworld\n");
1397+
assert_eq!(lexer.heredoc_rest_of_line.trim(), "");
1398+
}
1399+
1400+
#[test]
1401+
fn test_read_heredoc_with_redirect() {
1402+
let mut lexer = Lexer::new("cat <<EOF > file.txt\nhello\nEOF");
1403+
assert_eq!(lexer.next_token(), Some(Token::Word("cat".to_string())));
1404+
assert_eq!(lexer.next_token(), Some(Token::HereDoc));
1405+
assert_eq!(lexer.next_token(), Some(Token::Word("EOF".to_string())));
1406+
let content = lexer.read_heredoc("EOF");
1407+
assert_eq!(content, "hello\n");
1408+
assert_eq!(lexer.heredoc_rest_of_line.trim(), "> file.txt");
13871409
}
13881410

13891411
#[test]

crates/bashkit/src/parser/mod.rs

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1768,15 +1768,18 @@ impl<'a> Parser<'a> {
17681768
};
17691769
// Don't advance - let read_heredoc consume directly from lexer position
17701770

1771-
// Read the here document content (reads until delimiter line)
1771+
// Read the here document content (reads until delimiter line).
1772+
// Also captures rest-of-line text (e.g. `> file` in
1773+
// `cat <<EOF > file`) into lexer.heredoc_rest_of_line.
17721774
let content = self.lexer.read_heredoc(&delimiter);
1775+
let rest_of_line = std::mem::take(&mut self.lexer.heredoc_rest_of_line);
17731776

17741777
// Strip leading tabs for <<-
17751778
let content = if strip_tabs {
17761779
let had_trailing_newline = content.ends_with('\n');
17771780
let mut stripped: String = content
17781781
.lines()
1779-
.map(|l| l.trim_start_matches('\t'))
1782+
.map(|l: &str| l.trim_start_matches('\t'))
17801783
.collect::<Vec<_>>()
17811784
.join("\n");
17821785
if had_trailing_newline {
@@ -1787,9 +1790,6 @@ impl<'a> Parser<'a> {
17871790
content
17881791
};
17891792

1790-
// Now advance to get the next token after the heredoc
1791-
self.advance();
1792-
17931793
// If delimiter was quoted, content is literal (no expansion)
17941794
// Otherwise, parse for variable expansion
17951795
let target = if quoted {
@@ -1810,6 +1810,54 @@ impl<'a> Parser<'a> {
18101810
target,
18111811
});
18121812

1813+
// Parse rest-of-line for additional redirects
1814+
// (e.g. `> file` in `cat <<EOF > file`).
1815+
// We parse tokens directly instead of using parse_simple_command
1816+
// because that method returns None for redirect-only input
1817+
// (no command word), dropping the redirects we need.
1818+
if !rest_of_line.trim().is_empty() {
1819+
let mut sub = Parser::new(&rest_of_line);
1820+
loop {
1821+
match &sub.current_token {
1822+
Some(tokens::Token::RedirectOut) => {
1823+
sub.advance();
1824+
if let Ok(target) = sub.expect_word() {
1825+
redirects.push(Redirect {
1826+
fd: None,
1827+
kind: RedirectKind::Output,
1828+
target,
1829+
});
1830+
}
1831+
}
1832+
Some(tokens::Token::RedirectAppend) => {
1833+
sub.advance();
1834+
if let Ok(target) = sub.expect_word() {
1835+
redirects.push(Redirect {
1836+
fd: None,
1837+
kind: RedirectKind::Append,
1838+
target,
1839+
});
1840+
}
1841+
}
1842+
Some(tokens::Token::RedirectFd(fd)) => {
1843+
let fd = *fd;
1844+
sub.advance();
1845+
if let Ok(target) = sub.expect_word() {
1846+
redirects.push(Redirect {
1847+
fd: Some(fd),
1848+
kind: RedirectKind::Output,
1849+
target,
1850+
});
1851+
}
1852+
}
1853+
_ => break,
1854+
}
1855+
}
1856+
}
1857+
1858+
// Now advance past the heredoc body
1859+
self.advance();
1860+
18131861
// Heredoc body consumed subsequent lines from input.
18141862
// Stop parsing this command - next tokens belong to new commands.
18151863
break;

crates/bashkit/tests/spec_cases/bash/heredoc.test.sh

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,44 @@ EOF
8383
value: 42, cmd: hi, math: 84
8484
### end
8585

86+
### heredoc_redirect_after
87+
# cat <<EOF > file should write heredoc content to file, not stdout
88+
cat <<EOF > /tmp/heredoc_redirect.txt
89+
line one
90+
line two
91+
EOF
92+
cat /tmp/heredoc_redirect.txt
93+
### expect
94+
line one
95+
line two
96+
### end
97+
98+
### heredoc_redirect_after_with_vars
99+
# cat <<EOF > file with variable expansion
100+
NAME=world
101+
cat <<EOF > /tmp/heredoc_vars.txt
102+
hello $NAME
103+
EOF
104+
cat /tmp/heredoc_vars.txt
105+
### expect
106+
hello world
107+
### end
108+
109+
### heredoc_redirect_after_multiline
110+
# cat <<EOF > file with multiline YAML-like content (issue #345)
111+
mkdir -p /tmp/app
112+
cat <<EOF > /tmp/app/config.yaml
113+
app:
114+
name: myservice
115+
port: 8080
116+
EOF
117+
cat /tmp/app/config.yaml
118+
### expect
119+
app:
120+
name: myservice
121+
port: 8080
122+
### end
123+
86124
### heredoc_tab_strip
87125
# <<- strips leading tabs from content and delimiter
88126
cat <<-EOF

0 commit comments

Comments
 (0)