diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5bed3dfed..73e417852 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -250,8 +250,8 @@ jobs: env: VARIANT: ${{ matrix.variant }} run: | + sed -n '665,680p' /usr/lib/git-core/git-sh-prompt set -euxo pipefail - # Run tests with coverage collection result=0 cargo xtask test integration --coverage --coverage-output ./codecov-${VARIANT}.xml || result=$? diff --git a/brush-parser/src/tokenizer.rs b/brush-parser/src/tokenizer.rs index b9f75c96a..61be8de30 100644 --- a/brush-parser/src/tokenizer.rs +++ b/brush-parser/src/tokenizer.rs @@ -525,6 +525,14 @@ pub fn uncached_tokenize_str( Ok(tokens) } +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum CaseState { + NotInCase, + AfterCase, + AfterIn, + InBody, +} + impl<'a, R: ?Sized + std::io::BufRead> Tokenizer<'a, R> { pub fn new(reader: &'a mut R, options: &TokenizerOptions) -> Self { Tokenizer { @@ -607,6 +615,10 @@ impl<'a, R: ?Sized + std::io::BufRead> Tokenizer<'a, R> { let mut pending_here_doc_tokens = vec![]; let mut drain_here_doc_tokens = false; + // Track case statement state for handling ) in case patterns + let mut case_state = CaseState::NotInCase; + let mut case_depth: u32 = 0; + loop { let cur_token = if drain_here_doc_tokens && !pending_here_doc_tokens.is_empty() { if pending_here_doc_tokens.len() == 1 { @@ -636,12 +648,42 @@ impl<'a, R: ?Sized + std::io::BufRead> Tokenizer<'a, R> { continue; } - if let Some(cur_token_value) = cur_token.token { + if let Some(cur_token_value) = &cur_token.token { state.append_str(cur_token_value.to_str()); if matches!(cur_token_value, Token::Operator(o, _) if o == nesting_open) { nesting_count += 1; } + + // Track case statement state + if let Token::Word(word, _) = cur_token_value { + match word.trim() { + "case" if case_state == CaseState::NotInCase => { + case_state = CaseState::AfterCase; + case_depth += 1; + } + "in" if case_state == CaseState::AfterCase => { + case_state = CaseState::AfterIn; + } + "esac" if case_depth > 0 => { + case_depth = case_depth.saturating_sub(1); + if case_depth == 0 { + case_state = CaseState::NotInCase; + } else { + case_state = CaseState::AfterIn; + } + } + _ => {} + } + } + + // Handle case terminators (;;, ;&, ;;&) + if let Token::Operator(op, _) = cur_token_value { + if matches!(op.as_str(), ";;" | ";&" | ";;&") && case_state == CaseState::InBody + { + case_state = CaseState::AfterIn; + } + } } match cur_token.reason { @@ -650,9 +692,14 @@ impl<'a, R: ?Sized + std::io::BufRead> Tokenizer<'a, R> { } TokenEndReason::NonNewLineBlank => state.append_char(' '), TokenEndReason::SpecifiedTerminatingChar => { - nesting_count -= 1; - if nesting_count == 0 { - break; + // If we're inside a case pattern (AfterIn), the ')' is part of case syntax + if matches!(case_state, CaseState::AfterIn) { + case_state = CaseState::InBody; + } else { + nesting_count -= 1; + if nesting_count == 0 { + break; + } } state.append_char(self.next_char()?.unwrap()); } diff --git a/brush-parser/src/word.rs b/brush-parser/src/word.rs index 3ff71847c..7048fcec7 100644 --- a/brush-parser/src/word.rs +++ b/brush-parser/src/word.rs @@ -1074,9 +1074,45 @@ peg::parser! { $(command_piece()*) pub(crate) rule command_piece() -> () = + case_statement() / word_piece(<[')']>, true /*in_command*/) {} / ([' ' | '\t'])+ {} + rule case_statement() -> () = + "case" [' ' | '\t']+ [^' ' | '\t' | '\n']+ [' ' | '\t']* "in" + case_body() "esac" {} + + rule case_body() -> () = + // Match everything until 'esac', handling nested parens and quotes + (!"esac" case_body_piece())* {} + + rule case_body_piece() -> () = + // Match quoted strings + "'" [^'\'']* "'" / + "\"" [^'\"']* "\"" / + // Match nested command substitutions + "$(" case_body() ")" / + // Match nested subshells + "(" (!")" case_body_piece())* ")" / + // Match any other character + [_] {} + + rule case_item() -> () = + [' ' | '\t']* case_pattern() ")" [' ' | '\t' | '\n']* + case_item_body()* + case_terminator()? {} + + rule case_pattern() -> () = + word_piece(<['|']>, false) ("|" word_piece(<['|']>, false))* {} + + rule case_item_body() -> () = + word_piece(<[')']>, true) {} / + ([' ' | '\t' | '\n'])+ {} / + "#" [^'\n']* {} + + rule case_terminator() -> () = + ";;&" / ";;" / ";&" {} + rule backquoted_command() -> String = chars:(backquoted_char()*) { chars.into_iter().collect() } diff --git a/brush-shell/tests/cases/compat/compound_cmds/case.yaml b/brush-shell/tests/cases/compat/compound_cmds/case.yaml index 0857e4c1f..4a45768a8 100644 --- a/brush-shell/tests/cases/compat/compound_cmds/case.yaml +++ b/brush-shell/tests/cases/compat/compound_cmds/case.yaml @@ -183,3 +183,7 @@ cases: } f echo "Exit code: $?" + + - name: "Case inside command substitution" + stdin: | + echo $(case a in a) echo ok ;; esac)