Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions crates/bashkit/docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,9 @@ Chrono's validated strftime implementation, plus GNU `%N`, `%3N`, `%6N`, and
| `${var^^}` | ✅ | `${s^^}` | Uppercase all |
| `${var,}` | ✅ | `${s,}` | Lowercase first |
| `${var,,}` | ✅ | `${s,,}` | Lowercase all |
| `${var^^pat}` | ✅ | `${s^^[aeiou]}` | Case change only for characters matching `pat` (also `^`, `,`, `,,`) |
| `${var@op}` | ✅ | `${s@Q}` | Transform: `Q` quote, `E` escapes, `U`/`u`/`L` case, `A` assignment, `a` attributes |
| `${arr[@]op}` | ✅ | `${a[@]/x/y}`, `${a[@]^^}`, `${a[@]@Q}` | Pattern, case and transform operators apply per element (also `${a[i]op}`, `$@`, `$*`) |

### Prefix Environment Assignments

Expand Down
205 changes: 153 additions & 52 deletions crates/bashkit/src/interpreter/expansion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,16 +66,31 @@ impl Interpreter {

/// Apply a `${var@operator}` transformation.
pub(super) fn apply_transformation(&self, name: &str, operator: char) -> String {
let value = self.expand_variable(name);
// `${a[@]@Q}`, `${@@U}`: value transforms apply to each element.
if matches!(operator, 'Q' | 'E' | 'P' | 'u' | 'U' | 'L')
&& let Some(elems) = self.resolve_param_expansion_elements(name)
{
// THREAT[TM-DOS]: same output cap as per-element pattern ops.
let mut out = String::new();
for v in &elems {
let t = Self::transform_value(v, operator);
if out.len().saturating_add(t.len() + 1) > Self::MAX_EXPANSION_RESULT_BYTES {
return elems.join(" ");
}
if !out.is_empty() {
out.push(' ');
}
out.push_str(&t);
}
return out;
}
let value = if name.contains('[') {
self.resolve_param_expansion_name(name).1
} else {
self.expand_variable(name)
};
match operator {
'Q' => format!("'{}'", value.replace('\'', "'\\''")),
'E' => value
.replace("\\n", "\n")
.replace("\\t", "\t")
.replace("\\\\", "\\"),
'P' => value.clone(),
'A' => format!("{}='{}'", name, value.replace('\'', "'\\''")),
'K' => value.clone(),
'a' => {
let mut attrs = String::new();
if self.is_var_readonly(name) {
Expand All @@ -86,19 +101,28 @@ impl Interpreter {
}
attrs
}
'u' | 'U' => {
if operator == 'U' {
value.to_uppercase()
} else {
let mut chars = value.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
_ => Self::transform_value(&value, operator),
}
}

/// The `${v@op}` transforms that depend only on the value.
fn transform_value(value: &str, operator: char) -> String {
match operator {
'Q' => format!("'{}'", value.replace('\'', "'\\''")),
'E' => value
.replace("\\n", "\n")
.replace("\\t", "\t")
.replace("\\\\", "\\"),
'U' => value.to_uppercase(),
'u' => {
let mut chars = value.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
'L' => value.to_lowercase(),
_ => value.clone(),
_ => value.to_string(),
}
}

Expand Down Expand Up @@ -596,6 +620,20 @@ impl Interpreter {
}
return Ok(Vec::new());
}
// "${a[@]/x/y}", "${@^}", "${a[@]@Q}": one field per element.
if let Some((elems, star)) = self.elementwise_fields(&word.parts[0]) {
if word.quoted {
if star {
return Ok(vec![elems.join(&self.get_ifs_separator())]);
}
return Ok(elems);
}
let mut fields = Vec::new();
for e in &elems {
fields.extend(self.ifs_split(e)?);
}
return Ok(fields);
}
// "${!arr[@]}" - array keys/indices as separate fields
if let WordPart::ArrayIndices(name) = &word.parts[0] {
let resolved = self.resolve_nameref(name);
Expand Down Expand Up @@ -1280,16 +1318,62 @@ impl Interpreter {
///
/// Extracted from the async `expand_word_inner` path to keep `Vec<String>`
/// locals off the async state machine (prevents stack overflow at depth 32).
pub(super) fn apply_param_op_maybe_per_element(
&mut self,
value: &str,
name: &str,
operator: &ParameterOp,
operand: &str,
colon_variant: bool,
is_set: bool,
) -> String {
let needs_per_element = matches!(
/// Per-element results of a pattern, case or transform operator applied
/// to `@`, `*`, `a[@]` or `a[*]`, plus whether the subscript was `*`.
/// Sync so the async field expansion keeps its small state machine.
fn elementwise_fields(&mut self, part: &WordPart) -> Option<(Vec<String>, bool)> {
let (name, results) = match part {
WordPart::ParameterExpansion {
name,
operator,
operand,
colon_variant,
} if Self::is_elementwise_op(operator) => {
let elems = self.resolve_param_expansion_elements(name)?;
let (is_set, _) = self.resolve_param_expansion_name(name);
let mut out = Vec::with_capacity(elems.len());
let mut total = 0usize;
for elem in &elems {
let r = self.apply_parameter_op(
elem,
name,
operator,
operand,
*colon_variant,
is_set,
);
// THREAT[TM-DOS]: same cap as the joined per-element path.
total = total.saturating_add(r.len() + 1);
if total > Self::MAX_EXPANSION_RESULT_BYTES {
return None;
}
out.push(r);
}
(name, out)
}
WordPart::Transformation { name, operator }
if matches!(operator, 'Q' | 'E' | 'P' | 'u' | 'U' | 'L') =>
{
let elems = self.resolve_param_expansion_elements(name)?;
let mut out = Vec::with_capacity(elems.len());
let mut total = 0usize;
for elem in &elems {
let r = Self::transform_value(elem, *operator);
total = total.saturating_add(r.len() + 1);
if total > Self::MAX_EXPANSION_RESULT_BYTES {
return None;
}
out.push(r);
}
(name, out)
}
_ => return None,
};
Some((results, name == "*" || name.ends_with("[*]")))
}

fn is_elementwise_op(operator: &ParameterOp) -> bool {
matches!(
operator,
ParameterOp::RemovePrefixShort
| ParameterOp::RemovePrefixLong
Expand All @@ -1301,7 +1385,19 @@ impl Interpreter {
| ParameterOp::UpperAll
| ParameterOp::LowerFirst
| ParameterOp::LowerAll
);
)
}

pub(super) fn apply_param_op_maybe_per_element(
&mut self,
value: &str,
name: &str,
operator: &ParameterOp,
operand: &str,
colon_variant: bool,
is_set: bool,
) -> String {
let needs_per_element = Self::is_elementwise_op(operator);
if needs_per_element && let Some(elems) = self.resolve_param_expansion_elements(name) {
let mut result = String::new();
for elem in &elems {
Expand Down Expand Up @@ -1428,31 +1524,36 @@ impl Interpreter {
let expanded_pat = self.expand_replace_pattern(pattern);
self.replace_pattern(value, &expanded_pat, &expanded_rep, true)
}
ParameterOp::UpperFirst => {
// ${var^} - uppercase first character
let mut chars = value.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
ParameterOp::UpperAll => {
// ${var^^} - uppercase all characters
value.to_uppercase()
}
ParameterOp::LowerFirst => {
// ${var,} - lowercase first character
let mut chars = value.chars();
match chars.next() {
Some(first) => first.to_lowercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
ParameterOp::LowerAll => {
// ${var,,} - lowercase all characters
value.to_lowercase()
ParameterOp::UpperFirst => self.change_case(value, operand, true, false),
ParameterOp::UpperAll => self.change_case(value, operand, true, true),
ParameterOp::LowerFirst => self.change_case(value, operand, false, false),
ParameterOp::LowerAll => self.change_case(value, operand, false, true),
}
}

/// `${v^pat}`, `${v^^pat}`, `${v,pat}`, `${v,,pat}`: change the case of
/// the first (or every) character that matches `pat` on its own. An empty
/// pattern matches any character, as in bash.
fn change_case(&mut self, value: &str, operand: &str, upper: bool, all: bool) -> String {
let pattern = if operand.is_empty() {
String::new()
} else {
self.expand_pattern_operand(operand)
};
let mut out = String::with_capacity(value.len());
let mut buf = [0u8; 4];
for (i, ch) in value.chars().enumerate() {
let selected = (all || i == 0)
&& (pattern.is_empty() || self.pattern_matches(ch.encode_utf8(&mut buf), &pattern));
if !selected {
out.push(ch);
} else if upper {
out.extend(ch.to_uppercase());
} else {
out.extend(ch.to_lowercase());
}
}
out
}

/// Replace pattern in value
Expand Down
8 changes: 4 additions & 4 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8445,10 +8445,10 @@ impl Interpreter {
pattern,
replacement,
} => out.push_str(&format!("${{{}//{}/{}}}", name, pattern, replacement)),
ParameterOp::UpperFirst => out.push_str(&format!("${{{}^}}", name)),
ParameterOp::UpperAll => out.push_str(&format!("${{{}^^}}", name)),
ParameterOp::LowerFirst => out.push_str(&format!("${{{},}}", name)),
ParameterOp::LowerAll => out.push_str(&format!("${{{},,}}", name)),
ParameterOp::UpperFirst => out.push_str(&format!("${{{}^{}}}", name, operand)),
ParameterOp::UpperAll => out.push_str(&format!("${{{}^^{}}}", name, operand)),
ParameterOp::LowerFirst => out.push_str(&format!("${{{},{}}}", name, operand)),
ParameterOp::LowerAll => out.push_str(&format!("${{{},,{}}}", name, operand)),
},
WordPart::Length(name) => out.push_str(&format!("${{#{}}}", name)),
WordPart::ArrayAccess { name, index } => {
Expand Down
9 changes: 5 additions & 4 deletions crates/bashkit/src/parser/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -349,10 +349,10 @@ impl fmt::Display for Word {
pattern,
replacement,
} => write!(f, "${{{}///{}/{}}}", name, pattern, replacement)?,
ParameterOp::UpperFirst => write!(f, "${{{}^}}", name)?,
ParameterOp::UpperAll => write!(f, "${{{}^^}}", name)?,
ParameterOp::LowerFirst => write!(f, "{}{{,}}", name)?,
ParameterOp::LowerAll => write!(f, "${{{},,}}", name)?,
ParameterOp::UpperFirst => write!(f, "${{{}^{}}}", name, operand)?,
ParameterOp::UpperAll => write!(f, "${{{}^^{}}}", name, operand)?,
ParameterOp::LowerFirst => write!(f, "${{{},{}}}", name, operand)?,
ParameterOp::LowerAll => write!(f, "${{{},,{}}}", name, operand)?,
},
WordPart::Length(name) => write!(f, "${{#{}}}", name)?,
WordPart::ArrayAccess { name, index } => write!(f, "${{{}[{}]}}", name, index)?,
Expand Down Expand Up @@ -982,6 +982,7 @@ mod tests {
};
check(ParameterOp::UpperFirst, "${var^}");
check(ParameterOp::UpperAll, "${var^^}");
check(ParameterOp::LowerFirst, "${var,}");
check(ParameterOp::LowerAll, "${var,,}");
}

Expand Down
34 changes: 21 additions & 13 deletions crates/bashkit/src/parser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3478,6 +3478,10 @@ impl<'a> Parser<'a> {
}

// Check for array access ${arr[index]} or ${arr[@]:offset:length}
// `${arr[i]OP...}` with a pattern/case/transform OP reuses the
// scalar operator parsing below on the name `arr[i]`.
let mut subscript_op = false;
let had_subscript = chars.peek() == Some(&'[');
if chars.peek() == Some(&'[') {
chars.next(); // consume '['
let mut index = String::new();
Expand Down Expand Up @@ -3572,7 +3576,7 @@ impl<'a> Parser<'a> {
chars.next();
}
push_part!(WordPart::ArraySlice {
name: var_name,
name: std::mem::take(&mut var_name),
offset,
length,
});
Expand All @@ -3595,23 +3599,29 @@ impl<'a> Parser<'a> {
operand,
colon_variant: false,
});
} else if matches!(next_c, '#' | '%' | '/' | '^' | ',' | '@') {
var_name = format!("{}[{}]", var_name, index);
subscript_op = true;
} else {
// Plain array access ${arr[index]}
if chars.peek() == Some(&'}') {
chars.next();
}
push_part!(WordPart::ArrayAccess {
name: var_name,
name: std::mem::take(&mut var_name),
index,
});
}
} else {
push_part!(WordPart::ArrayAccess {
name: var_name,
name: std::mem::take(&mut var_name),
index,
});
}
} else if let Some(&c) = chars.peek() {
}
if (!had_subscript || subscript_op)
&& let Some(&c) = chars.peek()
{
// Check for operator
match c {
':' => {
Expand Down Expand Up @@ -3824,13 +3834,12 @@ impl<'a> Parser<'a> {
} else {
ParameterOp::UpperFirst
};
if chars.peek() == Some(&'}') {
chars.next();
}
// `${v^^pat}`: only characters matching `pat`.
let operand = self.read_brace_operand(&mut chars);
push_part!(WordPart::ParameterExpansion {
name: var_name,
operator: op,
operand: String::new(),
operand,
colon_variant: false,
});
}
Expand All @@ -3842,13 +3851,12 @@ impl<'a> Parser<'a> {
} else {
ParameterOp::LowerFirst
};
if chars.peek() == Some(&'}') {
chars.next();
}
// `${v^^pat}`: only characters matching `pat`.
let operand = self.read_brace_operand(&mut chars);
push_part!(WordPart::ParameterExpansion {
name: var_name,
operator: op,
operand: String::new(),
operand,
colon_variant: false,
});
}
Expand Down Expand Up @@ -3889,7 +3897,7 @@ impl<'a> Parser<'a> {
}
}
}
} else if !var_name.is_empty() {
} else if !had_subscript && !var_name.is_empty() {
push_part!(WordPart::Variable(var_name));
}
}
Expand Down
Loading
Loading