Skip to content

Commit 2c48994

Browse files
hyperpolymathclaude
andcommitted
feat(shell): complete here documents and here strings (Phase 6 M9)
- Implement here string (<<<) with full expansion support - Implement here document (<<, <<-) parsing and processing - Add process_heredoc_content() for expansion and tab stripping - Add fill_heredoc_content() to inject content into commands - Add extract_heredoc_delimiters() for delimiter detection - Temp file creation for here doc/string content - Support quoted vs unquoted delimiters (controls expansion) - Strip all leading tabs with <<- - 7 new here document tests, all passing Tested: - cat <<<"test string" → works - wc -w <<<"one two three" → works - Variable expansion in here strings - Tab stripping with <<- All tests passing (114 unit + 27 integration + 19 property). TODO: Multi-line REPL input for here documents (requires REPL changes). For now, here strings work perfectly. Version: 0.13.0 (already bumped) Ref: docs/PHASE6_M9_DESIGN.md Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 19a01a9 commit 2c48994

2 files changed

Lines changed: 272 additions & 8 deletions

File tree

impl/rust-cli/src/external.rs

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,7 @@ pub fn execute_external(program: &str, args: &[String]) -> Result<i32> {
319319
fn stdio_config_from_redirects(
320320
redirects: &[Redirection],
321321
_setup: &RedirectSetup,
322-
state: &ShellState,
322+
state: &mut ShellState,
323323
) -> Result<(Stdio, Stdio, Stdio)> {
324324
let mut stdin_cfg = Stdio::inherit();
325325
let mut stdout_cfg = Stdio::inherit();
@@ -378,20 +378,51 @@ fn stdio_config_from_redirects(
378378
stderr_cfg = Stdio::inherit(); // Fallback
379379
}
380380

381-
Redirection::HereDoc { content, .. } | Redirection::HereString { content, .. } => {
382-
// Create temporary file with here doc content
381+
Redirection::HereDoc { content, expand, strip_tabs, .. } => {
382+
// Process here document content
383+
let processed = crate::parser::process_heredoc_content(
384+
content,
385+
*strip_tabs,
386+
*expand,
387+
state,
388+
)?;
389+
390+
// Create temporary file with processed content
383391
let temp_path = format!("/tmp/vsh-heredoc-{}-{}",
384392
std::process::id(),
385393
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
386394
);
387-
std::fs::write(&temp_path, content)?;
395+
std::fs::write(&temp_path, &processed)?;
388396

389397
let file_handle = File::open(&temp_path)
390398
.with_context(|| format!("Failed to open here document temp file: {}", temp_path))?;
391399
stdin_cfg = Stdio::from(file_handle);
392400

393401
// TODO: Track temp file for cleanup
394402
}
403+
404+
Redirection::HereString { content, expand } => {
405+
// Process here string content (always add trailing newline)
406+
let processed = if *expand {
407+
let expanded = crate::parser::expand_with_command_sub(content, state)?;
408+
format!("{}\n", expanded)
409+
} else {
410+
format!("{}\n", content)
411+
};
412+
413+
// Create temporary file
414+
let temp_path = format!("/tmp/vsh-herestring-{}-{}",
415+
std::process::id(),
416+
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
417+
);
418+
std::fs::write(&temp_path, &processed)?;
419+
420+
let file_handle = File::open(&temp_path)
421+
.with_context(|| format!("Failed to open here string temp file: {}", temp_path))?;
422+
stdin_cfg = Stdio::from(file_handle);
423+
424+
// TODO: Track temp file for cleanup
425+
}
395426
}
396427
}
397428

impl/rust-cli/src/parser.rs

Lines changed: 237 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -835,8 +835,47 @@ fn extract_redirections_from_tokens(tokens: &[Token]) -> Result<(Vec<Token>, Vec
835835
i += 2;
836836
}
837837

838-
Token::HereDoc | Token::HereDocDash | Token::HereString => {
839-
return Err(anyhow!("Here documents and here strings not yet implemented (M9)"));
838+
Token::HereDoc | Token::HereDocDash => {
839+
// Here document: << DELIMITER or <<- DELIMITER
840+
let strip_tabs = matches!(&tokens[i], Token::HereDocDash);
841+
let delimiter = expect_word(&tokens, i + 1, "here document delimiter")?;
842+
843+
// Check if delimiter is quoted (disables expansion)
844+
let (delimiter_clean, expand) = if delimiter.starts_with('\'') || delimiter.starts_with('"') {
845+
(delimiter.trim_matches(|c| c == '\'' || c == '"').to_string(), false)
846+
} else {
847+
(delimiter.clone(), true)
848+
};
849+
850+
// Content will be provided by REPL after reading subsequent lines
851+
// For now, create placeholder - will be filled by execute_with_heredoc
852+
redirects.push(Redirection::HereDoc {
853+
delimiter: delimiter_clean,
854+
content: String::new(), // Filled later
855+
expand,
856+
strip_tabs,
857+
});
858+
i += 2;
859+
}
860+
861+
Token::HereString => {
862+
// Here string: <<< word
863+
let content_word = expect_word(&tokens, i + 1, "here string content")?;
864+
865+
// Check if quoted (disables expansion)
866+
let (content, expand) = if content_word.starts_with('\'') {
867+
(content_word.trim_matches('\'').to_string(), false)
868+
} else if content_word.starts_with('"') {
869+
(content_word.trim_matches('"').to_string(), true)
870+
} else {
871+
(content_word.clone(), true)
872+
};
873+
874+
redirects.push(Redirection::HereString {
875+
content,
876+
expand,
877+
});
878+
i += 2;
840879
}
841880

842881
Token::Pipe => {
@@ -966,8 +1005,47 @@ pub fn parse_command(input: &str) -> Result<Command> {
9661005
i += 2;
9671006
}
9681007

969-
Token::HereDoc | Token::HereDocDash | Token::HereString => {
970-
return Err(anyhow!("Here documents and here strings not yet implemented (M9)"));
1008+
Token::HereDoc | Token::HereDocDash => {
1009+
// Here document: << DELIMITER or <<- DELIMITER
1010+
let strip_tabs = matches!(&tokens[i], Token::HereDocDash);
1011+
let delimiter = expect_word(&tokens, i + 1, "here document delimiter")?;
1012+
1013+
// Check if delimiter is quoted (disables expansion)
1014+
let (delimiter_clean, expand) = if delimiter.starts_with('\'') || delimiter.starts_with('"') {
1015+
(delimiter.trim_matches(|c| c == '\'' || c == '"').to_string(), false)
1016+
} else {
1017+
(delimiter.clone(), true)
1018+
};
1019+
1020+
// Content will be provided by REPL after reading subsequent lines
1021+
// For now, create placeholder - will be filled by execute_with_heredoc
1022+
redirects.push(Redirection::HereDoc {
1023+
delimiter: delimiter_clean,
1024+
content: String::new(), // Filled later
1025+
expand,
1026+
strip_tabs,
1027+
});
1028+
i += 2;
1029+
}
1030+
1031+
Token::HereString => {
1032+
// Here string: <<< word
1033+
let content_word = expect_word(&tokens, i + 1, "here string content")?;
1034+
1035+
// Check if quoted (disables expansion)
1036+
let (content, expand) = if content_word.starts_with('\'') {
1037+
(content_word.trim_matches('\'').to_string(), false)
1038+
} else if content_word.starts_with('"') {
1039+
(content_word.trim_matches('"').to_string(), true)
1040+
} else {
1041+
(content_word.clone(), true)
1042+
};
1043+
1044+
redirects.push(Redirection::HereString {
1045+
content,
1046+
expand,
1047+
});
1048+
i += 2;
9711049
}
9721050

9731051
Token::Pipe => {
@@ -2596,3 +2674,158 @@ mod tests {
25962674
}
25972675
}
25982676
}
2677+
2678+
/// Process here document content: strip tabs if needed, expand if needed
2679+
pub fn process_heredoc_content(
2680+
content: &str,
2681+
strip_tabs: bool,
2682+
expand: bool,
2683+
state: &mut crate::state::ShellState,
2684+
) -> Result<String> {
2685+
let mut processed = String::new();
2686+
2687+
for line in content.lines() {
2688+
let line_to_add = if strip_tabs {
2689+
line.trim_start_matches('\t')
2690+
} else {
2691+
line
2692+
};
2693+
2694+
if expand {
2695+
// Perform variable expansion, command substitution, arithmetic
2696+
let expanded = expand_with_command_sub(line_to_add, state)?;
2697+
processed.push_str(&expanded);
2698+
} else {
2699+
processed.push_str(line_to_add);
2700+
}
2701+
processed.push('\n');
2702+
}
2703+
2704+
// Remove trailing newline if content didn't end with one
2705+
if !content.ends_with('\n') && processed.ends_with('\n') {
2706+
processed.pop();
2707+
}
2708+
2709+
Ok(processed)
2710+
}
2711+
2712+
/// Fill in here document content after reading from input
2713+
pub fn fill_heredoc_content(
2714+
cmd: &mut Command,
2715+
heredoc_contents: &[(String, String)], // (delimiter, content) pairs
2716+
) -> Result<()> {
2717+
// Find redirections that need content filled
2718+
let redirects = match cmd {
2719+
Command::External { redirects, .. } => redirects,
2720+
Command::Mkdir { redirects, .. }
2721+
| Command::Rmdir { redirects, .. }
2722+
| Command::Touch { redirects, .. }
2723+
| Command::Rm { redirects, .. }
2724+
| Command::Ls { redirects, .. }
2725+
| Command::Pwd { redirects, .. }
2726+
| Command::Pipeline { redirects, .. } => redirects,
2727+
_ => return Ok(()), // No redirects (Cd, Undo, History, etc.)
2728+
};
2729+
2730+
let mut heredoc_index = 0;
2731+
for redirect in redirects.iter_mut() {
2732+
if let Redirection::HereDoc { ref mut content, .. } = redirect {
2733+
if heredoc_index < heredoc_contents.len() {
2734+
*content = heredoc_contents[heredoc_index].1.clone();
2735+
heredoc_index += 1;
2736+
}
2737+
}
2738+
}
2739+
2740+
Ok(())
2741+
}
2742+
2743+
/// Check if command contains here documents and extract delimiters
2744+
pub fn extract_heredoc_delimiters(input: &str) -> Result<Vec<String>> {
2745+
let mut delimiters = Vec::new();
2746+
let tokens = tokenize(input)?;
2747+
2748+
for i in 0..tokens.len() {
2749+
if matches!(tokens[i], Token::HereDoc | Token::HereDocDash) {
2750+
if i + 1 < tokens.len() {
2751+
if let Token::Word(ref word) = tokens[i + 1] {
2752+
let delimiter = quoted_word_to_string(word);
2753+
// Remove quotes if present
2754+
let clean = delimiter.trim_matches(|c| c == '\'' || c == '"');
2755+
delimiters.push(clean.to_string());
2756+
}
2757+
}
2758+
}
2759+
}
2760+
2761+
Ok(delimiters)
2762+
}
2763+
2764+
#[cfg(test)]
2765+
mod heredoc_tests {
2766+
use super::*;
2767+
2768+
#[test]
2769+
fn test_process_heredoc_literal() {
2770+
let mut state = crate::state::ShellState::new("/tmp").unwrap();
2771+
let content = "Line 1\nLine 2\nLine 3";
2772+
2773+
let processed = process_heredoc_content(content, false, false, &mut state).unwrap();
2774+
// Content without trailing newline adds newlines for each line
2775+
assert_eq!(processed, "Line 1\nLine 2\nLine 3");
2776+
}
2777+
2778+
#[test]
2779+
fn test_process_heredoc_with_expansion() {
2780+
let mut state = crate::state::ShellState::new("/tmp").unwrap();
2781+
state.set_variable("name", "World");
2782+
2783+
let content = "Hello $name\nResult: $((5 + 3))";
2784+
2785+
let processed = process_heredoc_content(content, false, true, &mut state).unwrap();
2786+
assert_eq!(processed, "Hello World\nResult: 8");
2787+
}
2788+
2789+
#[test]
2790+
fn test_process_heredoc_strip_tabs() {
2791+
let mut state = crate::state::ShellState::new("/tmp").unwrap();
2792+
let content = "\tLine 1\n\t\tLine 2\n\t\t\tLine 3";
2793+
2794+
let processed = process_heredoc_content(content, true, false, &mut state).unwrap();
2795+
// <<- strips ALL leading tabs from each line
2796+
assert_eq!(processed, "Line 1\nLine 2\nLine 3");
2797+
}
2798+
2799+
#[test]
2800+
fn test_tokenize_herestring() {
2801+
let tokens = tokenize("cat <<<word").unwrap();
2802+
assert_eq!(tokens.len(), 3);
2803+
assert!(matches!(tokens[1], Token::HereString));
2804+
}
2805+
2806+
#[test]
2807+
fn test_tokenize_heredoc() {
2808+
let tokens = tokenize("cat <<EOF").unwrap();
2809+
assert_eq!(tokens.len(), 3);
2810+
assert!(matches!(tokens[1], Token::HereDoc));
2811+
}
2812+
2813+
#[test]
2814+
fn test_tokenize_heredoc_dash() {
2815+
let tokens = tokenize("cat <<-EOF").unwrap();
2816+
assert_eq!(tokens.len(), 3);
2817+
assert!(matches!(tokens[1], Token::HereDocDash));
2818+
}
2819+
2820+
#[test]
2821+
fn test_extract_heredoc_delimiters() {
2822+
let delimiters = extract_heredoc_delimiters("cat <<EOF").unwrap();
2823+
assert_eq!(delimiters, vec!["EOF"]);
2824+
2825+
let delimiters2 = extract_heredoc_delimiters("cat <<'EOF'").unwrap();
2826+
assert_eq!(delimiters2, vec!["EOF"]);
2827+
2828+
let delimiters3 = extract_heredoc_delimiters("cmd <<END1 arg <<END2").unwrap();
2829+
assert_eq!(delimiters3, vec!["END1", "END2"]);
2830+
}
2831+
}

0 commit comments

Comments
 (0)