Skip to content

Commit 5f72ac0

Browse files
authored
fix(interpreter): compose indirect expansion with default operator
## Summary - Fix `${!var:-default}` and other indirect+operator combinations that returned empty - Parser now stops var name scanning at operator chars (`:`, `-`, `=`, `+`, `?`) - Interpreter composes indirect resolution with `apply_parameter_op` ## Test plan - [x] Unit test: `test_indirect_expansion_with_default` (set, unset, empty, `:=`) - [x] Spec tests: `indirect-expansion.test.sh` with 4 cases - [x] Full test suite passes Closes #937
1 parent b44ca86 commit 5f72ac0

File tree

5 files changed

+206
-16
lines changed

5 files changed

+206
-16
lines changed

crates/bashkit/src/interpreter/mod.rs

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6174,19 +6174,53 @@ impl Interpreter {
61746174
result.push_str(&sliced.join(" "));
61756175
}
61766176
}
6177-
WordPart::IndirectExpansion(name) => {
6177+
WordPart::IndirectExpansion {
6178+
name,
6179+
operator,
6180+
operand,
6181+
colon_variant,
6182+
} => {
61786183
let nameref_key = format!("_NAMEREF_{}", name);
6179-
if let Some(target) = self.variables.get(&nameref_key).cloned() {
6180-
result.push_str(&target);
6184+
let is_nameref = self.variables.contains_key(&nameref_key);
6185+
6186+
if is_nameref && operator.is_none() {
6187+
// Nameref without operator: ${!ref} returns the
6188+
// name the nameref points to (original behavior).
6189+
if let Some(target) = self.variables.get(&nameref_key).cloned() {
6190+
result.push_str(&target);
6191+
}
61816192
} else {
6182-
let var_name = self.expand_variable(name);
6183-
if let Some(arr) = self.arrays.get(&var_name) {
6184-
if let Some(first) = arr.get(&0) {
6185-
result.push_str(first);
6186-
}
6193+
// Resolve the indirect target variable name
6194+
let resolved_name =
6195+
if let Some(target) = self.variables.get(&nameref_key).cloned() {
6196+
target
6197+
} else {
6198+
self.expand_variable(name)
6199+
};
6200+
6201+
if let Some(op) = operator {
6202+
// Indirect + operator: resolve indirect, then
6203+
// apply op to the target variable
6204+
let (is_set, value) = self.resolve_param_expansion_name(&resolved_name);
6205+
let expanded = self.apply_parameter_op(
6206+
&value,
6207+
&resolved_name,
6208+
op,
6209+
operand,
6210+
*colon_variant,
6211+
is_set,
6212+
);
6213+
result.push_str(&expanded);
61876214
} else {
6188-
let value = self.expand_variable(&var_name);
6189-
result.push_str(&value);
6215+
// Plain indirect expansion (no operator)
6216+
if let Some(arr) = self.arrays.get(&resolved_name) {
6217+
if let Some(first) = arr.get(&0) {
6218+
result.push_str(first);
6219+
}
6220+
} else {
6221+
let value = self.expand_variable(&resolved_name);
6222+
result.push_str(&value);
6223+
}
61906224
}
61916225
}
61926226
}
@@ -10310,6 +10344,23 @@ echo "count=$COUNT"
1031010344
assert_eq!(result.stdout.trim(), "a");
1031110345
}
1031210346

10347+
#[tokio::test]
10348+
async fn test_indirect_expansion_with_default() {
10349+
// Issue #937: ${!var:-default} should compose indirect + default
10350+
let result =
10351+
run_script(r#"name="TARGET"; TARGET="value"; echo "${!name:-fallback}""#).await;
10352+
assert_eq!(result.stdout.trim(), "value");
10353+
10354+
let result = run_script(r#"name="MISSING"; echo "${!name:-fallback}""#).await;
10355+
assert_eq!(result.stdout.trim(), "fallback");
10356+
10357+
let result = run_script(r#"name="EMPTY"; EMPTY=""; echo "${!name:-fallback}""#).await;
10358+
assert_eq!(result.stdout.trim(), "fallback");
10359+
10360+
let result = run_script(r#"name="UNSET"; echo "${!name:=assigned}""#).await;
10361+
assert_eq!(result.stdout.trim(), "assigned");
10362+
}
10363+
1031310364
#[tokio::test]
1031410365
async fn test_noclobber_clobber_override() {
1031510366
let result = run_script(

crates/bashkit/src/parser/ast.rs

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,26 @@ impl fmt::Display for Word {
347347
write!(f, "${{{}[@]:{}}}", name, offset)?
348348
}
349349
}
350-
WordPart::IndirectExpansion(name) => write!(f, "${{!{}}}", name)?,
350+
WordPart::IndirectExpansion {
351+
name,
352+
operator,
353+
operand,
354+
colon_variant,
355+
} => {
356+
if let Some(op) = operator {
357+
let c = if *colon_variant { ":" } else { "" };
358+
let op_char = match op {
359+
ParameterOp::UseDefault => "-",
360+
ParameterOp::AssignDefault => "=",
361+
ParameterOp::UseReplacement => "+",
362+
ParameterOp::Error => "?",
363+
_ => "",
364+
};
365+
write!(f, "${{!{}{}{}{}}}", name, c, op_char, operand)?
366+
} else {
367+
write!(f, "${{!{}}}", name)?
368+
}
369+
}
351370
WordPart::PrefixMatch(prefix) => write!(f, "${{!{}*}}", prefix)?,
352371
WordPart::ProcessSubstitution { commands, is_input } => {
353372
let prefix = if *is_input { "<" } else { ">" };
@@ -402,7 +421,13 @@ pub enum WordPart {
402421
length: Option<String>,
403422
},
404423
/// Indirect expansion `${!var}` - expands to value of variable named by var's value
405-
IndirectExpansion(String),
424+
/// Optionally composed with an operator: `${!var:-default}`, `${!var:=val}`, etc.
425+
IndirectExpansion {
426+
name: String,
427+
operator: Option<ParameterOp>,
428+
operand: String,
429+
colon_variant: bool,
430+
},
406431
/// Prefix matching `${!prefix*}` or `${!prefix@}` - names of variables with given prefix
407432
PrefixMatch(String),
408433
/// Process substitution <(cmd) or >(cmd)
@@ -658,7 +683,12 @@ mod tests {
658683
#[test]
659684
fn word_display_indirect_expansion() {
660685
let w = Word {
661-
parts: vec![WordPart::IndirectExpansion("ref".into())],
686+
parts: vec![WordPart::IndirectExpansion {
687+
name: "ref".into(),
688+
operator: None,
689+
operand: String::new(),
690+
colon_variant: false,
691+
}],
662692
quoted: false,
663693
};
664694
assert_eq!(format!("{w}"), "${!ref}");

crates/bashkit/src/parser/mod.rs

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2756,7 +2756,16 @@ impl<'a> Parser<'a> {
27562756
chars.next(); // consume '!'
27572757
let mut var_name = String::new();
27582758
while let Some(&c) = chars.peek() {
2759-
if c == '}' || c == '[' || c == '*' || c == '@' {
2759+
if c == '}'
2760+
|| c == '['
2761+
|| c == '*'
2762+
|| c == '@'
2763+
|| c == ':'
2764+
|| c == '-'
2765+
|| c == '='
2766+
|| c == '+'
2767+
|| c == '?'
2768+
{
27602769
break;
27612770
}
27622771
var_name.push(chars.next().unwrap());
@@ -2783,9 +2792,70 @@ impl<'a> Parser<'a> {
27832792
parts.push(WordPart::Variable(format!("!{}[{}]", var_name, index)));
27842793
}
27852794
} else if chars.peek() == Some(&'}') {
2786-
// ${!var} - indirect expansion
2795+
// ${!var} - indirect expansion (no operator)
27872796
chars.next(); // consume '}'
2788-
parts.push(WordPart::IndirectExpansion(var_name));
2797+
parts.push(WordPart::IndirectExpansion {
2798+
name: var_name,
2799+
operator: None,
2800+
operand: String::new(),
2801+
colon_variant: false,
2802+
});
2803+
} else if chars.peek() == Some(&':') {
2804+
// ${!var:op} - indirect expansion with colon operator
2805+
let mut lookahead = chars.clone();
2806+
lookahead.next(); // skip ':'
2807+
if matches!(
2808+
lookahead.peek(),
2809+
Some(&'-') | Some(&'=') | Some(&'+') | Some(&'?')
2810+
) {
2811+
chars.next(); // consume ':'
2812+
let op_char = chars.next().unwrap();
2813+
let operand = self.read_brace_operand(&mut chars);
2814+
let operator = match op_char {
2815+
'-' => ParameterOp::UseDefault,
2816+
'=' => ParameterOp::AssignDefault,
2817+
'+' => ParameterOp::UseReplacement,
2818+
'?' => ParameterOp::Error,
2819+
_ => unreachable!(),
2820+
};
2821+
parts.push(WordPart::IndirectExpansion {
2822+
name: var_name,
2823+
operator: Some(operator),
2824+
operand,
2825+
colon_variant: true,
2826+
});
2827+
} else {
2828+
// Not a param op after ':', treat as prefix match fallback
2829+
let mut suffix = String::new();
2830+
while let Some(&c) = chars.peek() {
2831+
if c == '}' {
2832+
chars.next();
2833+
break;
2834+
}
2835+
suffix.push(chars.next().unwrap());
2836+
}
2837+
parts.push(WordPart::Variable(format!("!{}{}", var_name, suffix)));
2838+
}
2839+
} else if matches!(
2840+
chars.peek(),
2841+
Some(&'-') | Some(&'=') | Some(&'+') | Some(&'?')
2842+
) {
2843+
// ${!var-op} - indirect expansion with non-colon operator
2844+
let op_char = chars.next().unwrap();
2845+
let operand = self.read_brace_operand(&mut chars);
2846+
let operator = match op_char {
2847+
'-' => ParameterOp::UseDefault,
2848+
'=' => ParameterOp::AssignDefault,
2849+
'+' => ParameterOp::UseReplacement,
2850+
'?' => ParameterOp::Error,
2851+
_ => unreachable!(),
2852+
};
2853+
parts.push(WordPart::IndirectExpansion {
2854+
name: var_name,
2855+
operator: Some(operator),
2856+
operand,
2857+
colon_variant: false,
2858+
});
27892859
} else {
27902860
// ${!prefix*} or ${!prefix@} - prefix matching
27912861
let mut suffix = String::new();
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
### indirect_expansion_with_default_operator
2+
# ${!var:-} should resolve indirect then apply default
3+
name="TARGET"; export TARGET="value"
4+
echo "${!name:-fallback}"
5+
### expect
6+
value
7+
### end
8+
9+
### indirect_expansion_with_default_unset
10+
# ${!var:-default} when target is unset should return default
11+
name="MISSING_VAR"
12+
echo "${!name:-fallback}"
13+
### expect
14+
fallback
15+
### end
16+
17+
### indirect_expansion_with_default_empty
18+
# ${!var:-default} when target is empty should return default
19+
name="EMPTY_VAR"; export EMPTY_VAR=""
20+
echo "${!name:-fallback}"
21+
### expect
22+
fallback
23+
### end
24+
25+
### indirect_expansion_with_assign_operator
26+
# ${!var:=default} should also work with indirect
27+
name="UNSET_TARGET"
28+
echo "${!name:=assigned}"
29+
### expect
30+
assigned
31+
### end

supply-chain/config.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1414,10 +1414,18 @@ criteria = "safe-to-deploy"
14141414
version = "1.50.0"
14151415
criteria = "safe-to-deploy"
14161416

1417+
[[exemptions.tokio]]
1418+
version = "1.51.0"
1419+
criteria = "safe-to-deploy"
1420+
14171421
[[exemptions.tokio-macros]]
14181422
version = "2.6.1"
14191423
criteria = "safe-to-deploy"
14201424

1425+
[[exemptions.tokio-macros]]
1426+
version = "2.7.0"
1427+
criteria = "safe-to-deploy"
1428+
14211429
[[exemptions.tokio-rustls]]
14221430
version = "0.26.4"
14231431
criteria = "safe-to-deploy"

0 commit comments

Comments
 (0)