diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index c123a562..5accf2ee 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -1818,13 +1818,20 @@ impl Interpreter { /// Returns exit code 0 if result is non-zero, 1 if result is zero /// Execute a [[ conditional expression ]] async fn execute_conditional(&mut self, words: &[Word]) -> Result { - // Expand all words - let mut expanded = Vec::new(); - for word in words { - expanded.push(self.expand_word(word).await?); + // Evaluate with lazy expansion to support short-circuit semantics. + // In `[[ -n "${X:-}" && "$X" != "off" ]]`, if the left side is false, + // the right side must NOT be expanded (to avoid set -u errors). + let result = self.evaluate_conditional_words(words).await; + // If a nounset error occurred during evaluation, propagate it. + if let Some(err_msg) = self.nounset_error.take() { + self.last_exit_code = 1; + return Ok(ExecResult { + stderr: err_msg, + exit_code: 1, + control_flow: ControlFlow::Return(1), + ..Default::default() + }); } - - let result = self.evaluate_conditional(&expanded).await; let exit_code = if result { 0 } else { 1 }; self.last_exit_code = exit_code; @@ -1837,6 +1844,72 @@ impl Interpreter { }) } + /// Evaluate [[ ]] from raw words with lazy expansion for short-circuit. + fn evaluate_conditional_words<'a>( + &'a mut self, + words: &'a [Word], + ) -> std::pin::Pin + Send + 'a>> { + Box::pin(async move { + if words.is_empty() { + return false; + } + + // Helper: get literal text of a word (for operators like &&, ||, !, (, )) + let word_literal = |w: &Word| -> Option { + if w.parts.len() == 1 + && let WordPart::Literal(s) = &w.parts[0] + { + return Some(s.clone()); + } + None + }; + + // Handle negation + if word_literal(&words[0]).as_deref() == Some("!") { + return !self.evaluate_conditional_words(&words[1..]).await; + } + + // Handle parentheses + if word_literal(&words[0]).as_deref() == Some("(") + && word_literal(&words[words.len() - 1]).as_deref() == Some(")") + { + return self + .evaluate_conditional_words(&words[1..words.len() - 1]) + .await; + } + + // Look for || (lowest precedence), then && — scan right to left + for i in (0..words.len()).rev() { + if word_literal(&words[i]).as_deref() == Some("||") && i > 0 { + let left = self.evaluate_conditional_words(&words[..i]).await; + if left { + return true; // short-circuit: skip right side + } + return self.evaluate_conditional_words(&words[i + 1..]).await; + } + } + for i in (0..words.len()).rev() { + if word_literal(&words[i]).as_deref() == Some("&&") && i > 0 { + let left = self.evaluate_conditional_words(&words[..i]).await; + if !left { + return false; // short-circuit: skip right side + } + return self.evaluate_conditional_words(&words[i + 1..]).await; + } + } + + // Leaf: expand words and evaluate as a simple condition + let mut expanded = Vec::new(); + for word in words { + match self.expand_word(word).await { + Ok(s) => expanded.push(s), + Err(_) => return false, + } + } + self.evaluate_conditional(&expanded).await + }) + } + /// Evaluate a [[ ]] conditional expression from expanded words. fn evaluate_conditional<'a>( &'a mut self, diff --git a/crates/bashkit/tests/spec_cases/bash/conditional-short-circuit.test.sh b/crates/bashkit/tests/spec_cases/bash/conditional-short-circuit.test.sh new file mode 100644 index 00000000..5aebe555 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/conditional-short-circuit.test.sh @@ -0,0 +1,35 @@ +### conditional_and_short_circuit_set_u +# [[ false && unset_ref ]] should not evaluate right side +set -u +[[ -n "${UNSET_SC_VAR:-}" && "${UNSET_SC_VAR}" != "off" ]] +echo $? +### expect +1 +### end + +### conditional_or_short_circuit_set_u +# [[ true || unset_ref ]] should not evaluate right side +set -u +[[ -z "${UNSET_SC_VAR2:-}" || "${UNSET_SC_VAR2}" == "x" ]] +echo $? +### expect +0 +### end + +### conditional_and_short_circuit_passes +# [[ true && check ]] should evaluate both sides +set -u +export SC_SET_VAR="active" +[[ -n "${SC_SET_VAR:-}" && "${SC_SET_VAR}" != "off" ]] +echo $? +### expect +0 +### end + +### conditional_nested_short_circuit +# Nested && || should respect short-circuit +set -u +[[ -n "${UNSET_NSC:-}" && "${UNSET_NSC}" == "x" ]] || echo "safe" +### expect +safe +### end