Skip to content

Commit 4807416

Browse files
chaliyclaude
andauthored
feat(bash): arithmetic exponentiation, base literals, mapfile (#241)
## Summary - Implement arithmetic `**` (exponentiation) operator with correct precedence over `*` - Add base#value literal support (`16#ff`, `2#1010`, `8#77`) and `0x`/`077` prefix literals - Add unary operators (`!`, `~`, `-`) in arithmetic expressions - Implement `mapfile`/`readarray` builtin with `-t` flag and custom array names - Fix `expand_arithmetic_vars` to track numeric literal context, preventing hex digits from being expanded as variable names ## Test plan - [x] 10 new arithmetic spec tests (exponentiation, base literals, hex/octal, unary ops) - [x] 3 new mapfile spec tests (basic, readarray alias, default MAPFILE name) - [x] All 757 bash spec tests pass (752 pass, 5 skip) - [x] Bash comparison tests pass (mapfile pipe tests marked `bash_diff`) - [x] Full test suite passes (`cargo test --all-features`) - [x] Clippy clean --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fd9a260 commit 4807416

File tree

18 files changed

+1291
-106
lines changed

18 files changed

+1291
-106
lines changed

crates/bashkit/src/builtins/printf.rs

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,23 +19,44 @@ impl Builtin for Printf {
1919
return Ok(ExecResult::ok(String::new()));
2020
}
2121

22-
let format = &ctx.args[0];
23-
let args = &ctx.args[1..];
22+
let mut args_iter = ctx.args.iter();
23+
let mut var_name: Option<String> = None;
24+
25+
// Check for -v varname flag
26+
let format = loop {
27+
match args_iter.next() {
28+
Some(arg) if arg == "-v" => {
29+
if let Some(vname) = args_iter.next() {
30+
var_name = Some(vname.clone());
31+
}
32+
}
33+
Some(arg) => break arg.clone(),
34+
None => return Ok(ExecResult::ok(String::new())),
35+
}
36+
};
37+
38+
let args: Vec<String> = args_iter.cloned().collect();
2439
let mut arg_index = 0;
2540
let mut output = String::new();
2641

2742
// Bash printf repeats the format string until all args are consumed
2843
loop {
2944
let start_index = arg_index;
30-
output.push_str(&format_string(format, args, &mut arg_index));
45+
output.push_str(&format_string(&format, &args, &mut arg_index));
3146

3247
// If no args were consumed or we've used all args, stop
3348
if arg_index == start_index || arg_index >= args.len() {
3449
break;
3550
}
3651
}
3752

38-
Ok(ExecResult::ok(output))
53+
if let Some(name) = var_name {
54+
// -v: assign to variable instead of printing
55+
ctx.variables.insert(name, output);
56+
Ok(ExecResult::ok(String::new()))
57+
} else {
58+
Ok(ExecResult::ok(output))
59+
}
3960
}
4061
}
4162

crates/bashkit/src/builtins/read.rs

Lines changed: 70 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,21 +20,60 @@ impl Builtin for Read {
2020

2121
// Parse flags
2222
let mut raw_mode = false; // -r: don't interpret backslashes
23+
let mut array_mode = false; // -a: read into array
24+
let mut delimiter = None::<char>; // -d: custom delimiter
25+
let mut nchars = None::<usize>; // -n: read N chars
2326
let mut prompt = None::<String>; // -p prompt
2427
let mut var_args = Vec::new();
2528
let mut args_iter = ctx.args.iter();
2629
while let Some(arg) = args_iter.next() {
2730
if arg.starts_with('-') && arg.len() > 1 {
28-
for flag in arg[1..].chars() {
31+
let mut chars = arg[1..].chars();
32+
while let Some(flag) = chars.next() {
2933
match flag {
3034
'r' => raw_mode = true,
35+
'a' => array_mode = true,
36+
'd' => {
37+
// -d delim: use first char of next arg as delimiter
38+
let rest: String = chars.collect();
39+
let delim_str = if rest.is_empty() {
40+
args_iter.next().map(|s| s.as_str()).unwrap_or("")
41+
} else {
42+
&rest
43+
};
44+
delimiter = delim_str.chars().next();
45+
break;
46+
}
47+
'n' => {
48+
let rest: String = chars.collect();
49+
let n_str = if rest.is_empty() {
50+
args_iter.next().map(|s| s.as_str()).unwrap_or("0")
51+
} else {
52+
&rest
53+
};
54+
nchars = n_str.parse().ok();
55+
break;
56+
}
3157
'p' => {
32-
// -p takes next arg as prompt
33-
if let Some(p) = args_iter.next() {
34-
prompt = Some(p.clone());
58+
let rest: String = chars.collect();
59+
prompt = Some(if rest.is_empty() {
60+
args_iter.next().cloned().unwrap_or_default()
61+
} else {
62+
rest
63+
});
64+
break;
65+
}
66+
't' | 's' | 'u' | 'e' | 'i' => {
67+
// -t timeout, -s silent, -u fd: accept and ignore
68+
if matches!(flag, 't' | 'u') {
69+
let rest: String = chars.collect();
70+
if rest.is_empty() {
71+
args_iter.next();
72+
}
73+
break;
3574
}
3675
}
37-
_ => {} // ignore unknown flags
76+
_ => {}
3877
}
3978
}
4079
} else {
@@ -43,8 +82,14 @@ impl Builtin for Read {
4382
}
4483
let _ = prompt; // prompt is for interactive use, ignored in non-interactive
4584

46-
// Get first line
47-
let line = if raw_mode {
85+
// Extract input based on delimiter or nchars
86+
let line = if let Some(n) = nchars {
87+
// -n N: read at most N chars
88+
input.chars().take(n).collect::<String>()
89+
} else if let Some(delim) = delimiter {
90+
// -d delim: read until delimiter
91+
input.split(delim).next().unwrap_or("").to_string()
92+
} else if raw_mode {
4893
// -r: treat backslashes literally
4994
input.lines().next().unwrap_or("").to_string()
5095
} else {
@@ -61,13 +106,6 @@ impl Builtin for Read {
61106
result
62107
};
63108

64-
// If no variable names given, use REPLY
65-
let var_names: Vec<&str> = if var_args.is_empty() {
66-
vec!["REPLY"]
67-
} else {
68-
var_args
69-
};
70-
71109
// Split line by IFS (default: space, tab, newline)
72110
let ifs = ctx.env.get("IFS").map(|s| s.as_str()).unwrap_or(" \t\n");
73111
let words: Vec<&str> = if ifs.is_empty() {
@@ -79,6 +117,24 @@ impl Builtin for Read {
79117
.collect()
80118
};
81119

120+
if array_mode {
121+
// -a: read all words into array variable
122+
let arr_name = var_args.first().copied().unwrap_or("REPLY");
123+
// Store as _ARRAY_<name>_<idx> for the interpreter to pick up
124+
ctx.variables.insert(
125+
format!("_ARRAY_READ_{}", arr_name),
126+
words.join("\x1F"), // unit separator as delimiter
127+
);
128+
return Ok(ExecResult::ok(String::new()));
129+
}
130+
131+
// If no variable names given, use REPLY
132+
let var_names: Vec<&str> = if var_args.is_empty() {
133+
vec!["REPLY"]
134+
} else {
135+
var_args
136+
};
137+
82138
// Assign words to variables
83139
for (i, var_name) in var_names.iter().enumerate() {
84140
if i == var_names.len() - 1 {

crates/bashkit/src/builtins/vars.rs

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,29 @@ impl Builtin for Unset {
2525

2626
/// set builtin - set/display shell options and positional parameters
2727
///
28-
/// Currently supports:
29-
/// - `set -e` - exit on error (stored but not enforced yet)
30-
/// - `set -x` - trace mode (stored but not enforced yet)
28+
/// Supports:
29+
/// - `set -e` / `set +e` - errexit
30+
/// - `set -u` / `set +u` - nounset
31+
/// - `set -x` / `set +x` - xtrace
32+
/// - `set -o option` / `set +o option` - long option names
3133
/// - `set --` - set positional parameters
3234
pub struct Set;
3335

36+
/// Map long option names to their SHOPT_* variable names
37+
fn option_name_to_var(name: &str) -> Option<&'static str> {
38+
match name {
39+
"errexit" => Some("SHOPT_e"),
40+
"nounset" => Some("SHOPT_u"),
41+
"xtrace" => Some("SHOPT_x"),
42+
"verbose" => Some("SHOPT_v"),
43+
"pipefail" => Some("SHOPT_pipefail"),
44+
"noclobber" => Some("SHOPT_C"),
45+
"noglob" => Some("SHOPT_f"),
46+
"noexec" => Some("SHOPT_n"),
47+
_ => None,
48+
}
49+
}
50+
3451
#[async_trait]
3552
impl Builtin for Set {
3653
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
@@ -43,20 +60,33 @@ impl Builtin for Set {
4360
return Ok(ExecResult::ok(output));
4461
}
4562

46-
for arg in ctx.args.iter() {
63+
let mut i = 0;
64+
while i < ctx.args.len() {
65+
let arg = &ctx.args[i];
4766
if arg == "--" {
48-
// Set positional parameters (would need call stack access)
49-
// For now, just consume remaining args
5067
break;
68+
} else if (arg.starts_with('-') || arg.starts_with('+'))
69+
&& arg.len() > 1
70+
&& (arg.as_bytes()[1] == b'o' && arg.len() == 2)
71+
{
72+
// -o option_name / +o option_name
73+
let enable = arg.starts_with('-');
74+
i += 1;
75+
if i < ctx.args.len() {
76+
if let Some(var) = option_name_to_var(&ctx.args[i]) {
77+
ctx.variables
78+
.insert(var.to_string(), if enable { "1" } else { "0" }.to_string());
79+
}
80+
}
5181
} else if arg.starts_with('-') || arg.starts_with('+') {
52-
// Shell options - store in variables for now
5382
let enable = arg.starts_with('-');
5483
for opt in arg.chars().skip(1) {
5584
let opt_name = format!("SHOPT_{}", opt);
5685
ctx.variables
5786
.insert(opt_name, if enable { "1" } else { "0" }.to_string());
5887
}
5988
}
89+
i += 1;
6090
}
6191

6292
Ok(ExecResult::ok(String::new()))

0 commit comments

Comments
 (0)