diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 58aa7079..c123a562 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -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); + } } } } @@ -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( diff --git a/crates/bashkit/src/parser/ast.rs b/crates/bashkit/src/parser/ast.rs index 15da7f0e..2c7917f6 100644 --- a/crates/bashkit/src/parser/ast.rs +++ b/crates/bashkit/src/parser/ast.rs @@ -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 { ">" }; @@ -402,7 +421,13 @@ pub enum WordPart { length: Option, }, /// 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, + operand: String, + colon_variant: bool, + }, /// Prefix matching `${!prefix*}` or `${!prefix@}` - names of variables with given prefix PrefixMatch(String), /// Process substitution <(cmd) or >(cmd) @@ -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}"); diff --git a/crates/bashkit/src/parser/mod.rs b/crates/bashkit/src/parser/mod.rs index 93ae5581..50395cd3 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -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()); @@ -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(); diff --git a/crates/bashkit/tests/spec_cases/bash/indirect-expansion.test.sh b/crates/bashkit/tests/spec_cases/bash/indirect-expansion.test.sh new file mode 100644 index 00000000..a8cfd9b5 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/indirect-expansion.test.sh @@ -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 diff --git a/supply-chain/config.toml b/supply-chain/config.toml index b096cc89..85b89d82 100644 --- a/supply-chain/config.toml +++ b/supply-chain/config.toml @@ -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"