Skip to content

Commit 8fb607a

Browse files
authored
fix(interpreter): expand ${...} syntax in arithmetic contexts (#601)
## Summary - Add `expand_brace_expr_in_arithmetic` to handle `${...}` syntax inside arithmetic expressions - Supports `${#arr[@]}`, `${#var}`, `${arr[idx]}`, and `${var}` patterns - Add `--max-commands` CLI flag to override the default 10k command limit - Add 5 end-to-end integration tests covering the full fix chain (lexer + parser + interpreter) Depends on #602 (lexer) and #603 (parser). ## Test plan - [x] `array_access_with_nested_array_length` — `${names[$RANDOM % ${#names[@]}]}` - [x] `assignment_with_nested_array_length_in_subscript` — assignment variant - [x] `nested_array_subscript_in_arithmetic` — inside `$(())` - [x] `multiple_nested_subscripts_in_loop` — in a loop with multiple arrays - [x] `string_length_in_arithmetic` — `${#var}` in arithmetic - [x] Full 1000-file JSON generator script runs successfully with `--max-commands 500000` - [x] Full test suite passes (0 new failures) Closes #601
1 parent e72ef96 commit 8fb607a

3 files changed

Lines changed: 183 additions & 15 deletions

File tree

crates/bashkit-cli/src/main.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ struct Args {
6868
#[cfg_attr(feature = "realfs", arg(long, value_name = "PATH"))]
6969
mount_rw: Vec<String>,
7070

71+
/// Maximum number of commands to execute (default: 10000)
72+
#[arg(long)]
73+
max_commands: Option<usize>,
74+
7175
#[command(subcommand)]
7276
subcommand: Option<SubCmd>,
7377
}
@@ -114,6 +118,10 @@ fn build_bash(args: &Args) -> bashkit::Bash {
114118
builder = apply_real_mounts(builder, &args.mount_ro, &args.mount_rw);
115119
}
116120

121+
if let Some(max_cmds) = args.max_commands {
122+
builder = builder.limits(bashkit::ExecutionLimits::new().max_commands(max_cmds));
123+
}
124+
117125
builder.build()
118126
}
119127

crates/bashkit/src/interpreter/mod.rs

Lines changed: 98 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7312,26 +7312,54 @@ impl Interpreter {
73127312
while let Some(ch) = chars.next() {
73137313
if ch == '$' {
73147314
in_numeric_literal = false;
7315-
// Handle $var syntax (common in arithmetic)
7316-
let mut name = String::new();
7317-
while let Some(&c) = chars.peek() {
7318-
if c.is_ascii_alphanumeric() || c == '_' {
7319-
name.push(chars.next().unwrap());
7320-
} else {
7321-
break;
7315+
if chars.peek() == Some(&'{') {
7316+
// Handle ${...} syntax inside arithmetic
7317+
chars.next(); // consume '{'
7318+
let mut brace_content = String::new();
7319+
let mut brace_depth = 1i32;
7320+
while let Some(&c) = chars.peek() {
7321+
chars.next();
7322+
if c == '{' {
7323+
brace_depth += 1;
7324+
brace_content.push(c);
7325+
} else if c == '}' {
7326+
brace_depth -= 1;
7327+
if brace_depth == 0 {
7328+
break;
7329+
}
7330+
brace_content.push(c);
7331+
} else {
7332+
brace_content.push(c);
7333+
}
73227334
}
7323-
}
7324-
if !name.is_empty() {
7325-
// $var is direct text substitution — no recursive arithmetic eval.
7326-
// Only bare names (without $) get recursive resolution.
7327-
let value = self.expand_variable(&name);
7328-
if value.is_empty() {
7335+
let expanded = self.expand_brace_expr_in_arithmetic(&brace_content);
7336+
if expanded.is_empty() {
73297337
result.push('0');
73307338
} else {
7331-
result.push_str(&value);
7339+
result.push_str(&expanded);
73327340
}
73337341
} else {
7334-
result.push(ch);
7342+
// Handle $var syntax (common in arithmetic)
7343+
let mut name = String::new();
7344+
while let Some(&c) = chars.peek() {
7345+
if c.is_ascii_alphanumeric() || c == '_' {
7346+
name.push(chars.next().unwrap());
7347+
} else {
7348+
break;
7349+
}
7350+
}
7351+
if !name.is_empty() {
7352+
// $var is direct text substitution — no recursive arithmetic eval.
7353+
// Only bare names (without $) get recursive resolution.
7354+
let value = self.expand_variable(&name);
7355+
if value.is_empty() {
7356+
result.push('0');
7357+
} else {
7358+
result.push_str(&value);
7359+
}
7360+
} else {
7361+
result.push(ch);
7362+
}
73357363
}
73367364
} else if ch == '#' {
73377365
// base#value syntax: digits before # are base, chars after are literal digits
@@ -7412,6 +7440,61 @@ impl Interpreter {
74127440
result
74137441
}
74147442

7443+
/// Expand a `${...}` expression encountered inside arithmetic context.
7444+
/// Handles: `${#arr[@]}`, `${#arr[*]}`, `${#var}`, `${arr[idx]}`, `${var}`.
7445+
fn expand_brace_expr_in_arithmetic(&self, inner: &str) -> String {
7446+
// ${#arr[@]} or ${#arr[*]} — array length
7447+
if let Some(rest) = inner.strip_prefix('#') {
7448+
if let Some(bracket) = rest.find('[') {
7449+
let arr_name = &rest[..bracket];
7450+
let idx = &rest[bracket + 1..rest.len().saturating_sub(1)];
7451+
if idx == "@" || idx == "*" {
7452+
if let Some(arr) = self.arrays.get(arr_name) {
7453+
return arr.len().to_string();
7454+
}
7455+
if let Some(arr) = self.assoc_arrays.get(arr_name) {
7456+
return arr.len().to_string();
7457+
}
7458+
return "0".to_string();
7459+
}
7460+
// ${#arr[n]} — length of element
7461+
let idx_val = self.evaluate_arithmetic(idx);
7462+
let idx_usize: usize = idx_val.try_into().unwrap_or(0);
7463+
if let Some(arr) = self.arrays.get(arr_name) {
7464+
return arr
7465+
.get(&idx_usize)
7466+
.map(|v| v.len().to_string())
7467+
.unwrap_or_else(|| "0".to_string());
7468+
}
7469+
return "0".to_string();
7470+
}
7471+
// ${#var} — string length
7472+
let val = self.expand_variable(rest);
7473+
return val.len().to_string();
7474+
}
7475+
7476+
// ${arr[idx]} — array access
7477+
if let Some(bracket) = inner.find('[')
7478+
&& inner.ends_with(']')
7479+
{
7480+
let arr_name = &inner[..bracket];
7481+
let idx_str = &inner[bracket + 1..inner.len() - 1];
7482+
if let Some(arr) = self.assoc_arrays.get(arr_name) {
7483+
let key = self.expand_variable_or_literal(idx_str);
7484+
return arr.get(&key).cloned().unwrap_or_default();
7485+
}
7486+
if let Some(arr) = self.arrays.get(arr_name) {
7487+
let idx_val = self.evaluate_arithmetic(idx_str);
7488+
let idx_usize: usize = idx_val.try_into().unwrap_or(0);
7489+
return arr.get(&idx_usize).cloned().unwrap_or_default();
7490+
}
7491+
return String::new();
7492+
}
7493+
7494+
// ${var} — plain variable
7495+
self.expand_variable(inner)
7496+
}
7497+
74157498
/// Parse and evaluate a simple arithmetic expression with depth tracking.
74167499
/// THREAT[TM-DOS-026]: `arith_depth` prevents stack overflow from deeply nested expressions.
74177500
fn parse_arithmetic_impl(&self, expr: &str, arith_depth: usize) -> i64 {
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// End-to-end regression tests for nested ${...} inside array subscripts.
2+
// Requires fixes from #599 (lexer), #600 (parser), #601 (interpreter).
3+
4+
use bashkit::Bash;
5+
6+
#[tokio::test]
7+
async fn array_access_with_nested_array_length() {
8+
let mut bash = Bash::new();
9+
let result = bash
10+
.exec("names=(Ava Liam Noah)\necho ${names[$RANDOM % ${#names[@]}]}")
11+
.await
12+
.unwrap();
13+
let out = result.stdout.trim();
14+
assert!(
15+
out == "Ava" || out == "Liam" || out == "Noah",
16+
"expected one of Ava/Liam/Noah, got: {out:?}"
17+
);
18+
}
19+
20+
#[tokio::test]
21+
async fn assignment_with_nested_array_length_in_subscript() {
22+
let mut bash = Bash::new();
23+
let result = bash
24+
.exec("colors=(red blue green)\ncolor=${colors[$RANDOM % ${#colors[@]}]}\necho \"$color\"")
25+
.await
26+
.unwrap();
27+
let out = result.stdout.trim();
28+
assert!(
29+
out == "red" || out == "blue" || out == "green",
30+
"expected one of red/blue/green, got: {out:?}"
31+
);
32+
}
33+
34+
#[tokio::test]
35+
async fn nested_array_subscript_in_arithmetic() {
36+
let mut bash = Bash::new();
37+
let result = bash
38+
.exec("arr=(10 20 30 40 50)\nidx=$((${arr[$RANDOM % ${#arr[@]}]} + 1))\necho \"$idx\"")
39+
.await
40+
.unwrap();
41+
let val: i64 = result.stdout.trim().parse().expect("should be a number");
42+
assert!(
43+
[11, 21, 31, 41, 51].contains(&val),
44+
"expected 11/21/31/41/51, got: {val}"
45+
);
46+
}
47+
48+
#[tokio::test]
49+
async fn multiple_nested_subscripts_in_loop() {
50+
let mut bash = Bash::new();
51+
let script = "names=(Ava Liam Noah Emma)\ncolors=(red blue green)\nfor i in 1 2 3; do\n name=${names[$RANDOM % ${#names[@]}]}\n color=${colors[$RANDOM % ${#colors[@]}]}\n echo \"$name:$color\"\ndone";
52+
let result = bash.exec(script).await.unwrap();
53+
let lines: Vec<&str> = result.stdout.trim().lines().collect();
54+
assert_eq!(lines.len(), 3, "expected 3 lines");
55+
for line in &lines {
56+
let parts: Vec<&str> = line.split(':').collect();
57+
assert_eq!(parts.len(), 2);
58+
assert!(
59+
["Ava", "Liam", "Noah", "Emma"].contains(&parts[0]),
60+
"unexpected name: {}",
61+
parts[0]
62+
);
63+
assert!(
64+
["red", "blue", "green"].contains(&parts[1]),
65+
"unexpected color: {}",
66+
parts[1]
67+
);
68+
}
69+
}
70+
71+
/// ${#var} in arithmetic context
72+
#[tokio::test]
73+
async fn string_length_in_arithmetic() {
74+
let mut bash = Bash::new();
75+
let result = bash.exec("x=hello\necho $((${#x} + 1))").await.unwrap();
76+
assert_eq!(result.stdout.trim(), "6");
77+
}

0 commit comments

Comments
 (0)