Skip to content

Commit aeb21c1

Browse files
authored
fix(interpreter): short-circuit && and || inside [[ ]] for set -u (#1035)
## Summary - Add `evaluate_conditional_words` that operates on raw `Word` tokens with lazy expansion - `&&` short-circuits: if left is false, right side is never expanded - `||` short-circuits: if left is true, right side is never expanded - Prevents spurious "unbound variable" errors under `set -u` ## Test plan - [x] Spec tests: 4 cases covering &&/|| short-circuit with set -u - [x] Full test suite passes (2220 tests) Closes #939
1 parent 183077a commit aeb21c1

File tree

2 files changed

+114
-6
lines changed

2 files changed

+114
-6
lines changed

crates/bashkit/src/interpreter/mod.rs

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1822,13 +1822,20 @@ impl Interpreter {
18221822
/// Returns exit code 0 if result is non-zero, 1 if result is zero
18231823
/// Execute a [[ conditional expression ]]
18241824
async fn execute_conditional(&mut self, words: &[Word]) -> Result<ExecResult> {
1825-
// Expand all words
1826-
let mut expanded = Vec::new();
1827-
for word in words {
1828-
expanded.push(self.expand_word(word).await?);
1825+
// Evaluate with lazy expansion to support short-circuit semantics.
1826+
// In `[[ -n "${X:-}" && "$X" != "off" ]]`, if the left side is false,
1827+
// the right side must NOT be expanded (to avoid set -u errors).
1828+
let result = self.evaluate_conditional_words(words).await;
1829+
// If a nounset error occurred during evaluation, propagate it.
1830+
if let Some(err_msg) = self.nounset_error.take() {
1831+
self.last_exit_code = 1;
1832+
return Ok(ExecResult {
1833+
stderr: err_msg,
1834+
exit_code: 1,
1835+
control_flow: ControlFlow::Return(1),
1836+
..Default::default()
1837+
});
18291838
}
1830-
1831-
let result = self.evaluate_conditional(&expanded).await;
18321839
let exit_code = if result { 0 } else { 1 };
18331840
self.last_exit_code = exit_code;
18341841

@@ -1841,6 +1848,72 @@ impl Interpreter {
18411848
})
18421849
}
18431850

1851+
/// Evaluate [[ ]] from raw words with lazy expansion for short-circuit.
1852+
fn evaluate_conditional_words<'a>(
1853+
&'a mut self,
1854+
words: &'a [Word],
1855+
) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send + 'a>> {
1856+
Box::pin(async move {
1857+
if words.is_empty() {
1858+
return false;
1859+
}
1860+
1861+
// Helper: get literal text of a word (for operators like &&, ||, !, (, ))
1862+
let word_literal = |w: &Word| -> Option<String> {
1863+
if w.parts.len() == 1
1864+
&& let WordPart::Literal(s) = &w.parts[0]
1865+
{
1866+
return Some(s.clone());
1867+
}
1868+
None
1869+
};
1870+
1871+
// Handle negation
1872+
if word_literal(&words[0]).as_deref() == Some("!") {
1873+
return !self.evaluate_conditional_words(&words[1..]).await;
1874+
}
1875+
1876+
// Handle parentheses
1877+
if word_literal(&words[0]).as_deref() == Some("(")
1878+
&& word_literal(&words[words.len() - 1]).as_deref() == Some(")")
1879+
{
1880+
return self
1881+
.evaluate_conditional_words(&words[1..words.len() - 1])
1882+
.await;
1883+
}
1884+
1885+
// Look for || (lowest precedence), then && — scan right to left
1886+
for i in (0..words.len()).rev() {
1887+
if word_literal(&words[i]).as_deref() == Some("||") && i > 0 {
1888+
let left = self.evaluate_conditional_words(&words[..i]).await;
1889+
if left {
1890+
return true; // short-circuit: skip right side
1891+
}
1892+
return self.evaluate_conditional_words(&words[i + 1..]).await;
1893+
}
1894+
}
1895+
for i in (0..words.len()).rev() {
1896+
if word_literal(&words[i]).as_deref() == Some("&&") && i > 0 {
1897+
let left = self.evaluate_conditional_words(&words[..i]).await;
1898+
if !left {
1899+
return false; // short-circuit: skip right side
1900+
}
1901+
return self.evaluate_conditional_words(&words[i + 1..]).await;
1902+
}
1903+
}
1904+
1905+
// Leaf: expand words and evaluate as a simple condition
1906+
let mut expanded = Vec::new();
1907+
for word in words {
1908+
match self.expand_word(word).await {
1909+
Ok(s) => expanded.push(s),
1910+
Err(_) => return false,
1911+
}
1912+
}
1913+
self.evaluate_conditional(&expanded).await
1914+
})
1915+
}
1916+
18441917
/// Evaluate a [[ ]] conditional expression from expanded words.
18451918
fn evaluate_conditional<'a>(
18461919
&'a mut self,
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
### conditional_and_short_circuit_set_u
2+
# [[ false && unset_ref ]] should not evaluate right side
3+
set -u
4+
[[ -n "${UNSET_SC_VAR:-}" && "${UNSET_SC_VAR}" != "off" ]]
5+
echo $?
6+
### expect
7+
1
8+
### end
9+
10+
### conditional_or_short_circuit_set_u
11+
# [[ true || unset_ref ]] should not evaluate right side
12+
set -u
13+
[[ -z "${UNSET_SC_VAR2:-}" || "${UNSET_SC_VAR2}" == "x" ]]
14+
echo $?
15+
### expect
16+
0
17+
### end
18+
19+
### conditional_and_short_circuit_passes
20+
# [[ true && check ]] should evaluate both sides
21+
set -u
22+
export SC_SET_VAR="active"
23+
[[ -n "${SC_SET_VAR:-}" && "${SC_SET_VAR}" != "off" ]]
24+
echo $?
25+
### expect
26+
0
27+
### end
28+
29+
### conditional_nested_short_circuit
30+
# Nested && || should respect short-circuit
31+
set -u
32+
[[ -n "${UNSET_NSC:-}" && "${UNSET_NSC}" == "x" ]] || echo "safe"
33+
### expect
34+
safe
35+
### end

0 commit comments

Comments
 (0)