Skip to content
2 changes: 1 addition & 1 deletion crates/bashkit/docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ Chrono's validated strftime implementation, plus GNU `%N`, `%3N`, `%6N`, and
| Syntax | Status | Example |
|--------|--------|---------|
| `$(cmd)` | ✅ | `x=$(pwd)` |
| `` `cmd` `` | ✅ | Backticks (deprecated but supported) |
| `` `cmd` `` | ✅ | Backticks, including after quoted word segments; unquoted output splits and globs |

### Arithmetic

Expand Down
124 changes: 94 additions & 30 deletions crates/bashkit/src/interpreter/expansion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
//! operand contributes its own quoted/unquoted parts. A lone expansion
//! keeps the cheaper whole-string `ifs_split` path.
//!
//! Important decision: mixed glob words escape expansion output per part's
//! quote flag; unquoted parts stay active patterns. The substitution byte
//! charge uses the same decision before allocating the escaped output.
//!
//! Important decision: tilde expansion is decided per unquoted literal part
//! at expansion time (`tilde_expand_word_literal`): a prefix that runs into
//! another part (`~"x"`, `~$v`) is not a tilde prefix. Assignment values
Expand Down Expand Up @@ -290,8 +294,8 @@ impl Interpreter {

/// How many bytes `append_expansion_for_word` will append for `value`,
/// counted before anything is allocated so the budget is charged first.
fn expansion_appended_len(word: &Word, value: &str) -> usize {
if word.quoted && word.has_unquoted_glob {
fn expansion_appended_len(word: &Word, part_quoted: bool, value: &str) -> usize {
if word.has_unquoted_glob && part_quoted {
value
.chars()
.map(|ch| ch.len_utf8() + usize::from(Self::needs_glob_escape(ch)))
Expand All @@ -304,9 +308,10 @@ impl Interpreter {
fn append_expansion_for_word(
result: &mut BudgetedString,
word: &Word,
part_quoted: bool,
value: &str,
) -> Result<()> {
if word.quoted && word.has_unquoted_glob {
if word.has_unquoted_glob && part_quoted {
for ch in value.chars() {
if Self::needs_glob_escape(ch) {
result.try_push('\\')?;
Expand Down Expand Up @@ -393,7 +398,15 @@ impl Interpreter {
// words own their buffer charge, so temporary output charges end at copy.
let mut substitution_leases = Vec::new();

// Check the metadata once: many unquoted expansions before a quoted
// suffix must not rescan the whole word for every output append.
let has_part_quotes = word.part_quoted.iter().any(|q| *q);
for (idx, part) in word.parts.iter().enumerate() {
let part_quoted = if has_part_quotes {
word.part_quoted.get(idx).copied().unwrap_or(false)
} else {
word.quoted
};
match part {
WordPart::BadSubstitution(text) => {
return Err(crate::error::Error::LineAbort(
Expand All @@ -416,7 +429,7 @@ impl Interpreter {
WordPart::Literal(s) => {
// Tilde expansion (`~`, `~/x`, `x=~:~`); quoted literals
// keep their tilde.
match (!Self::part_is_quoted(word, idx) && s.contains('~'))
match (!part_quoted && s.contains('~'))
.then(|| self.tilde_expand_word_literal(word, idx, s))
.flatten()
{
Expand All @@ -428,7 +441,7 @@ impl Interpreter {
if self.is_nounset() && !self.is_variable_set(name) {
self.nounset_error = Some(self.unbound_variable_diag(name));
}
if name == "*" && word.quoted {
if name == "*" && part_quoted {
let positional = self
.call_stack
.last()
Expand All @@ -442,11 +455,17 @@ impl Interpreter {
.unwrap_or_default(),
None => " ".to_string(),
};
Self::append_expansion_for_word(&mut result, word, &positional.join(&sep))?;
Self::append_expansion_for_word(
&mut result,
word,
part_quoted,
&positional.join(&sep),
)?;
} else {
Self::append_expansion_for_word(
&mut result,
word,
part_quoted,
&self.expand_variable_cow(name),
)?;
}
Expand All @@ -464,10 +483,10 @@ impl Interpreter {
let output_bytes = if leased {
trimmed.capacity()
} else {
Self::expansion_appended_len(word, &trimmed)
Self::expansion_appended_len(word, part_quoted, &trimmed)
};
let output_lease = self.execution_budget.lease_bytes(output_bytes)?;
Self::append_expansion_for_word(&mut result, word, &trimmed)?;
Self::append_expansion_for_word(&mut result, word, part_quoted, &trimmed)?;
if !leased {
substitution_leases.push(output_lease);
}
Expand All @@ -482,7 +501,12 @@ impl Interpreter {
let value = self
.try_evaluate_arithmetic_with_assign(&expanded_expr)
.map_err(|msg| crate::error::Error::LineAbort(self.arith_diag("", &msg)))?;
Self::append_expansion_for_word(&mut result, word, &value.to_string())?;
Self::append_expansion_for_word(
&mut result,
word,
part_quoted,
&value.to_string(),
)?;
}
WordPart::Length(name) => {
let value = if let Some(bracket_pos) = name.find('[') {
Expand Down Expand Up @@ -566,7 +590,7 @@ impl Interpreter {
// Delegate to sync helper to avoid bloating the async state
// machine with Vec<String> locals (causes stack overflow at
// depth 32 in debug builds — see stack_overflow_regression_tests).
self.operand_outer_unquoted = !Self::part_is_quoted(word, idx);
self.operand_outer_unquoted = !part_quoted;
let colon_variant = self.star_colon_variant(
name,
&value,
Expand All @@ -582,7 +606,7 @@ impl Interpreter {
is_set,
);
self.operand_outer_unquoted = false;
Self::append_expansion_for_word(&mut result, word, &expanded)?;
Self::append_expansion_for_word(&mut result, word, part_quoted, &expanded)?;
}
WordPart::ArrayAccess { name, index } => {
// `${a[$(cmd)]}` in the source: the substitution runs
Expand All @@ -601,7 +625,7 @@ impl Interpreter {
} else {
self.expand_array_access_part(name, index)
};
Self::append_expansion_for_word(&mut result, word, &value)?;
Self::append_expansion_for_word(&mut result, word, part_quoted, &value)?;
}
WordPart::ArrayIndices { name, star } => {
let keys = self.array_keys(name);
Expand All @@ -610,7 +634,12 @@ impl Interpreter {
} else {
" ".to_string()
};
Self::append_expansion_for_word(&mut result, word, &keys.join(&sep))?;
Self::append_expansion_for_word(
&mut result,
word,
part_quoted,
&keys.join(&sep),
)?;
}
WordPart::Substring {
name,
Expand All @@ -621,7 +650,7 @@ impl Interpreter {
let value = self
.substring_part(name, offset, length.as_deref())
.map_err(crate::error::Error::LineAbort)?;
Self::append_expansion_for_word(&mut result, word, &value)?;
Self::append_expansion_for_word(&mut result, word, part_quoted, &value)?;
}
WordPart::IndirectExpansion {
name,
Expand All @@ -636,7 +665,7 @@ impl Interpreter {
// Nameref without operator: ${!ref} is the name
// at the end of the nameref chain.
let target = self.resolve_nameref(name).to_string();
Self::append_expansion_for_word(&mut result, word, &target)?;
Self::append_expansion_for_word(&mut result, word, part_quoted, &target)?;
} else {
// Resolve the indirect target variable name
let resolved_name = if let Some(target) = nameref_target {
Expand All @@ -657,16 +686,31 @@ impl Interpreter {
*colon_variant,
is_set,
);
Self::append_expansion_for_word(&mut result, word, &expanded)?;
Self::append_expansion_for_word(
&mut result,
word,
part_quoted,
&expanded,
)?;
} else {
// Plain indirect expansion (no operator)
if let Some(arr) = self.scoped.arrays.get(&resolved_name) {
if let Some(first) = arr.get(&0) {
Self::append_expansion_for_word(&mut result, word, first)?;
Self::append_expansion_for_word(
&mut result,
word,
part_quoted,
first,
)?;
}
} else {
let value = self.expand_variable(&resolved_name);
Self::append_expansion_for_word(&mut result, word, &value)?;
Self::append_expansion_for_word(
&mut result,
word,
part_quoted,
&value,
)?;
}
}
}
Expand All @@ -684,7 +728,12 @@ impl Interpreter {
} else {
" ".to_string()
};
Self::append_expansion_for_word(&mut result, word, &names.join(&sep))?;
Self::append_expansion_for_word(
&mut result,
word,
part_quoted,
&names.join(&sep),
)?;
}
WordPart::ArrayLength(name) => {
let resolved = self.resolve_nameref(name);
Expand All @@ -701,14 +750,14 @@ impl Interpreter {
let expanded = self
.expand_process_substitution(commands, *is_input)
.await?;
Self::append_expansion_for_word(&mut result, word, &expanded)?;
Self::append_expansion_for_word(&mut result, word, part_quoted, &expanded)?;
}
WordPart::Transformation { name, operator } => {
let value = match prompts.as_mut().and_then(Vec::pop) {
Some(decoded) if *operator == 'P' => decoded,
_ => self.transformation_part(name, *operator),
};
Self::append_expansion_for_word(&mut result, word, &value)?;
Self::append_expansion_for_word(&mut result, word, part_quoted, &value)?;
}
}
}
Expand Down Expand Up @@ -3716,16 +3765,31 @@ mod expansion_charge_tests {
];
for quoted in [false, true] {
for has_unquoted_glob in [false, true] {
let w = word(quoted, has_unquoted_glob);
for value in values {
let mut appended = BudgetedString::new(None).unwrap();
Interpreter::append_expansion_for_word(&mut appended, &w, value).unwrap();
assert_eq!(
Interpreter::expansion_appended_len(&w, value),
appended.len(),
"charge != appended for {value:?} \
for part_quoted in [Vec::new(), vec![true, false], vec![false, true]] {
let mut w = word(quoted, has_unquoted_glob);
w.part_quoted = part_quoted;
for idx in 0..2 {
for value in values {
let mut appended = BudgetedString::new(None).unwrap();
Interpreter::append_expansion_for_word(
&mut appended,
&w,
Interpreter::part_is_quoted(&w, idx),
value,
)
.unwrap();
assert_eq!(
Interpreter::expansion_appended_len(
&w,
Interpreter::part_is_quoted(&w, idx),
value
),
appended.len(),
"charge != appended for {value:?} \
(quoted={quoted}, has_unquoted_glob={has_unquoted_glob})"
);
);
}
}
}
}
}
Expand Down
Loading
Loading