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
71 changes: 61 additions & 10 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6174,19 +6174,53 @@ impl Interpreter {
result.push_str(&sliced.join(" "));
}
}
WordPart::IndirectExpansion(name) => {
WordPart::IndirectExpansion {
name,
operator,
operand,
colon_variant,
} => {
let nameref_key = format!("_NAMEREF_{}", name);
if let Some(target) = self.variables.get(&nameref_key).cloned() {
result.push_str(&target);
let is_nameref = self.variables.contains_key(&nameref_key);

if is_nameref && operator.is_none() {
// Nameref without operator: ${!ref} returns the
// name the nameref points to (original behavior).
if let Some(target) = self.variables.get(&nameref_key).cloned() {
result.push_str(&target);
}
} else {
let var_name = self.expand_variable(name);
if let Some(arr) = self.arrays.get(&var_name) {
if let Some(first) = arr.get(&0) {
result.push_str(first);
}
// Resolve the indirect target variable name
let resolved_name =
if let Some(target) = self.variables.get(&nameref_key).cloned() {
target
} else {
self.expand_variable(name)
};

if let Some(op) = operator {
// Indirect + operator: resolve indirect, then
// apply op to the target variable
let (is_set, value) = self.resolve_param_expansion_name(&resolved_name);
let expanded = self.apply_parameter_op(
&value,
&resolved_name,
op,
operand,
*colon_variant,
is_set,
);
result.push_str(&expanded);
} else {
let value = self.expand_variable(&var_name);
result.push_str(&value);
// Plain indirect expansion (no operator)
if let Some(arr) = self.arrays.get(&resolved_name) {
if let Some(first) = arr.get(&0) {
result.push_str(first);
}
} else {
let value = self.expand_variable(&resolved_name);
result.push_str(&value);
}
}
}
}
Expand Down Expand Up @@ -10310,6 +10344,23 @@ echo "count=$COUNT"
assert_eq!(result.stdout.trim(), "a");
}

#[tokio::test]
async fn test_indirect_expansion_with_default() {
// Issue #937: ${!var:-default} should compose indirect + default
let result =
run_script(r#"name="TARGET"; TARGET="value"; echo "${!name:-fallback}""#).await;
assert_eq!(result.stdout.trim(), "value");

let result = run_script(r#"name="MISSING"; echo "${!name:-fallback}""#).await;
assert_eq!(result.stdout.trim(), "fallback");

let result = run_script(r#"name="EMPTY"; EMPTY=""; echo "${!name:-fallback}""#).await;
assert_eq!(result.stdout.trim(), "fallback");

let result = run_script(r#"name="UNSET"; echo "${!name:=assigned}""#).await;
assert_eq!(result.stdout.trim(), "assigned");
}

#[tokio::test]
async fn test_noclobber_clobber_override() {
let result = run_script(
Expand Down
36 changes: 33 additions & 3 deletions crates/bashkit/src/parser/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,26 @@ impl fmt::Display for Word {
write!(f, "${{{}[@]:{}}}", name, offset)?
}
}
WordPart::IndirectExpansion(name) => write!(f, "${{!{}}}", name)?,
WordPart::IndirectExpansion {
name,
operator,
operand,
colon_variant,
} => {
if let Some(op) = operator {
let c = if *colon_variant { ":" } else { "" };
let op_char = match op {
ParameterOp::UseDefault => "-",
ParameterOp::AssignDefault => "=",
ParameterOp::UseReplacement => "+",
ParameterOp::Error => "?",
_ => "",
};
write!(f, "${{!{}{}{}{}}}", name, c, op_char, operand)?
} else {
write!(f, "${{!{}}}", name)?
}
}
WordPart::PrefixMatch(prefix) => write!(f, "${{!{}*}}", prefix)?,
WordPart::ProcessSubstitution { commands, is_input } => {
let prefix = if *is_input { "<" } else { ">" };
Expand Down Expand Up @@ -402,7 +421,13 @@ pub enum WordPart {
length: Option<String>,
},
/// Indirect expansion `${!var}` - expands to value of variable named by var's value
IndirectExpansion(String),
/// Optionally composed with an operator: `${!var:-default}`, `${!var:=val}`, etc.
IndirectExpansion {
name: String,
operator: Option<ParameterOp>,
operand: String,
colon_variant: bool,
},
/// Prefix matching `${!prefix*}` or `${!prefix@}` - names of variables with given prefix
PrefixMatch(String),
/// Process substitution <(cmd) or >(cmd)
Expand Down Expand Up @@ -658,7 +683,12 @@ mod tests {
#[test]
fn word_display_indirect_expansion() {
let w = Word {
parts: vec![WordPart::IndirectExpansion("ref".into())],
parts: vec![WordPart::IndirectExpansion {
name: "ref".into(),
operator: None,
operand: String::new(),
colon_variant: false,
}],
quoted: false,
};
assert_eq!(format!("{w}"), "${!ref}");
Expand Down
76 changes: 73 additions & 3 deletions crates/bashkit/src/parser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2756,7 +2756,16 @@ impl<'a> Parser<'a> {
chars.next(); // consume '!'
let mut var_name = String::new();
while let Some(&c) = chars.peek() {
if c == '}' || c == '[' || c == '*' || c == '@' {
if c == '}'
|| c == '['
|| c == '*'
|| c == '@'
|| c == ':'
|| c == '-'
|| c == '='
|| c == '+'
|| c == '?'
{
break;
}
var_name.push(chars.next().unwrap());
Expand All @@ -2783,9 +2792,70 @@ impl<'a> Parser<'a> {
parts.push(WordPart::Variable(format!("!{}[{}]", var_name, index)));
}
} else if chars.peek() == Some(&'}') {
// ${!var} - indirect expansion
// ${!var} - indirect expansion (no operator)
chars.next(); // consume '}'
parts.push(WordPart::IndirectExpansion(var_name));
parts.push(WordPart::IndirectExpansion {
name: var_name,
operator: None,
operand: String::new(),
colon_variant: false,
});
} else if chars.peek() == Some(&':') {
// ${!var:op} - indirect expansion with colon operator
let mut lookahead = chars.clone();
lookahead.next(); // skip ':'
if matches!(
lookahead.peek(),
Some(&'-') | Some(&'=') | Some(&'+') | Some(&'?')
) {
chars.next(); // consume ':'
let op_char = chars.next().unwrap();
let operand = self.read_brace_operand(&mut chars);
let operator = match op_char {
'-' => ParameterOp::UseDefault,
'=' => ParameterOp::AssignDefault,
'+' => ParameterOp::UseReplacement,
'?' => ParameterOp::Error,
_ => unreachable!(),
};
parts.push(WordPart::IndirectExpansion {
name: var_name,
operator: Some(operator),
operand,
colon_variant: true,
});
} else {
// Not a param op after ':', treat as prefix match fallback
let mut suffix = String::new();
while let Some(&c) = chars.peek() {
if c == '}' {
chars.next();
break;
}
suffix.push(chars.next().unwrap());
}
parts.push(WordPart::Variable(format!("!{}{}", var_name, suffix)));
}
} else if matches!(
chars.peek(),
Some(&'-') | Some(&'=') | Some(&'+') | Some(&'?')
) {
// ${!var-op} - indirect expansion with non-colon operator
let op_char = chars.next().unwrap();
let operand = self.read_brace_operand(&mut chars);
let operator = match op_char {
'-' => ParameterOp::UseDefault,
'=' => ParameterOp::AssignDefault,
'+' => ParameterOp::UseReplacement,
'?' => ParameterOp::Error,
_ => unreachable!(),
};
parts.push(WordPart::IndirectExpansion {
name: var_name,
operator: Some(operator),
operand,
colon_variant: false,
});
} else {
// ${!prefix*} or ${!prefix@} - prefix matching
let mut suffix = String::new();
Expand Down
31 changes: 31 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/indirect-expansion.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
### indirect_expansion_with_default_operator
# ${!var:-} should resolve indirect then apply default
name="TARGET"; export TARGET="value"
echo "${!name:-fallback}"
### expect
value
### end

### indirect_expansion_with_default_unset
# ${!var:-default} when target is unset should return default
name="MISSING_VAR"
echo "${!name:-fallback}"
### expect
fallback
### end

### indirect_expansion_with_default_empty
# ${!var:-default} when target is empty should return default
name="EMPTY_VAR"; export EMPTY_VAR=""
echo "${!name:-fallback}"
### expect
fallback
### end

### indirect_expansion_with_assign_operator
# ${!var:=default} should also work with indirect
name="UNSET_TARGET"
echo "${!name:=assigned}"
### expect
assigned
### end
8 changes: 8 additions & 0 deletions supply-chain/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -1414,10 +1414,18 @@ criteria = "safe-to-deploy"
version = "1.50.0"
criteria = "safe-to-deploy"

[[exemptions.tokio]]
version = "1.51.0"
criteria = "safe-to-deploy"

[[exemptions.tokio-macros]]
version = "2.6.1"
criteria = "safe-to-deploy"

[[exemptions.tokio-macros]]
version = "2.7.0"
criteria = "safe-to-deploy"

[[exemptions.tokio-rustls]]
version = "0.26.4"
criteria = "safe-to-deploy"
Expand Down
Loading