diff --git a/crates/bashkit/src/parser/mod.rs b/crates/bashkit/src/parser/mod.rs index 085ae9b71..40b6c1010 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -826,11 +826,14 @@ impl<'a> Parser<'a> { let words = if self.is_keyword("in") { self.advance(); // consume 'in' - // Parse word list until do/newline/; + // Parse word list until a list terminator (newline/;) let mut words = Vec::new(); loop { match &self.current_token { - Some(tokens::Token::Word(w)) if w == "do" => break, + // `do`/`done` are reserved words only in command position. + // Inside the `in` list they are ordinary words until a list + // terminator (`;`/newline), matching bash: `for a in do; do + // echo $a; done` iterates over the single word `do`. Some(tokens::Token::Word(w)) | Some(tokens::Token::QuotedWord(w)) | Some(tokens::Token::QuotedGlobWord(w)) => { @@ -929,11 +932,13 @@ impl<'a> Parser<'a> { } self.advance(); // consume 'in' - // Parse word list until do/newline/; + // Parse word list until a list terminator (newline/;) let mut words = Vec::new(); loop { match &self.current_token { - Some(tokens::Token::Word(w)) if w == "do" => break, + // `do`/`done` are reserved words only in command position. + // Inside the `in` list they are ordinary words until a list + // terminator (`;`/newline), matching bash. Some(tokens::Token::Word(w)) | Some(tokens::Token::QuotedWord(w)) | Some(tokens::Token::QuotedGlobWord(w)) => { diff --git a/crates/bashkit/tests/integration/for_in_reserved_word_tests.rs b/crates/bashkit/tests/integration/for_in_reserved_word_tests.rs new file mode 100644 index 000000000..0753facf0 --- /dev/null +++ b/crates/bashkit/tests/integration/for_in_reserved_word_tests.rs @@ -0,0 +1,39 @@ +//! Regression: reserved words (`do`, `done`, `in`, `then`, ...) are ordinary +//! words inside a `for`/`select` `in` list until a list terminator (`;` or +//! newline). They only become keywords in command position. +//! +//! Found by the nightly differential proptest: +//! `for a in do; do echo $a; done` must print `do`, not nothing. + +use bashkit::Bash; + +#[tokio::test] +async fn for_in_do_word_iterates() { + let mut bash = Bash::new(); + let result = bash.exec("for a in do; do echo $a; done").await.unwrap(); + assert_eq!(result.stdout, "do\n"); +} + +#[tokio::test] +async fn for_in_multiple_reserved_words() { + let mut bash = Bash::new(); + let result = bash + .exec("for a in do done then in; do echo $a; done") + .await + .unwrap(); + assert_eq!(result.stdout, "do\ndone\nthen\nin\n"); +} + +#[tokio::test] +async fn for_in_reserved_word_newline_terminator() { + let mut bash = Bash::new(); + let result = bash.exec("for a in done\ndo echo $a; done").await.unwrap(); + assert_eq!(result.stdout, "done\n"); +} + +#[tokio::test] +async fn for_in_normal_words_still_work() { + let mut bash = Bash::new(); + let result = bash.exec("for a in 1 2 3; do echo $a; done").await.unwrap(); + assert_eq!(result.stdout, "1\n2\n3\n"); +} diff --git a/crates/bashkit/tests/integration/main.rs b/crates/bashkit/tests/integration/main.rs index c562128cd..1ae97990d 100644 --- a/crates/bashkit/tests/integration/main.rs +++ b/crates/bashkit/tests/integration/main.rs @@ -39,6 +39,7 @@ pub mod dev_null_tests; pub mod exec_options_tests; pub mod final_env_tests; pub mod find_multi_path_tests; +pub mod for_in_reserved_word_tests; pub mod git_advanced_tests; pub mod git_inspection_tests; pub mod git_integration_tests;