Skip to content

Commit a7dbdfd

Browse files
chaliyclaude
andauthored
feat: string ops, read -r, heredoc tests (#237)
## Summary - Implement `${s/#pat/repl}` prefix-anchored and `${s/%pat/repl}` suffix-anchored pattern replacement - Implement `${var:?"msg"}` error expansion with exit code 1 - Handle `\/` escape in pattern replacement parsing - Add `read` builtin `-r` flag (raw mode) and `-p` flag parsing - Add 31 new bash spec tests across 3 new test files (heredoc, string-ops, read-builtin) - Bash spec tests: 741 total, 735 pass, 6 skip (100% pass rate) - Bash comparison: 668/668 match real bash (100%) ## Test plan - [x] `cargo test --all-features` passes - [x] `cargo clippy --all-targets --all-features -- -D warnings` clean - [x] `cargo fmt --check` clean - [x] Bash spec tests: 741 total, 0 failures - [x] Bash comparison: 668/668 match (100%) Co-authored-by: Claude <noreply@anthropic.com>
1 parent a17d25a commit a7dbdfd

File tree

7 files changed

+333
-9
lines changed

7 files changed

+333
-9
lines changed

crates/bashkit/src/builtins/read.rs

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,61 @@ impl Builtin for Read {
1818
None => return Ok(ExecResult::err("", 1)),
1919
};
2020

21+
// Parse flags
22+
let mut raw_mode = false; // -r: don't interpret backslashes
23+
let mut prompt = None::<String>; // -p prompt
24+
let mut var_args = Vec::new();
25+
let mut args_iter = ctx.args.iter();
26+
while let Some(arg) = args_iter.next() {
27+
if arg.starts_with('-') && arg.len() > 1 {
28+
for flag in arg[1..].chars() {
29+
match flag {
30+
'r' => raw_mode = true,
31+
'p' => {
32+
// -p takes next arg as prompt
33+
if let Some(p) = args_iter.next() {
34+
prompt = Some(p.clone());
35+
}
36+
}
37+
_ => {} // ignore unknown flags
38+
}
39+
}
40+
} else {
41+
var_args.push(arg.as_str());
42+
}
43+
}
44+
let _ = prompt; // prompt is for interactive use, ignored in non-interactive
45+
2146
// Get first line
22-
let line = input.lines().next().unwrap_or("");
47+
let line = if raw_mode {
48+
// -r: treat backslashes literally
49+
input.lines().next().unwrap_or("").to_string()
50+
} else {
51+
// Without -r: handle backslash line continuation
52+
let mut result = String::new();
53+
for l in input.lines() {
54+
if let Some(stripped) = l.strip_suffix('\\') {
55+
result.push_str(stripped);
56+
} else {
57+
result.push_str(l);
58+
break;
59+
}
60+
}
61+
result
62+
};
2363

2464
// If no variable names given, use REPLY
25-
let var_names: Vec<&str> = if ctx.args.is_empty() {
65+
let var_names: Vec<&str> = if var_args.is_empty() {
2666
vec!["REPLY"]
2767
} else {
28-
ctx.args.iter().map(|s| s.as_str()).collect()
68+
var_args
2969
};
3070

3171
// Split line by IFS (default: space, tab, newline)
3272
let ifs = ctx.env.get("IFS").map(|s| s.as_str()).unwrap_or(" \t\n");
3373
let words: Vec<&str> = if ifs.is_empty() {
3474
// Empty IFS means no word splitting
35-
vec![line]
75+
vec![&line]
3676
} else {
3777
line.split(|c: char| ifs.contains(c))
3878
.filter(|s| !s.is_empty())

crates/bashkit/src/interpreter/mod.rs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4076,7 +4076,12 @@ impl Interpreter {
40764076
ParameterOp::Error => {
40774077
// ${var:?error} - error if unset/empty
40784078
if value.is_empty() {
4079-
// In real bash this would exit, we just return empty
4079+
let msg = if operand.is_empty() {
4080+
format!("bash: {}: parameter null or not set\n", name)
4081+
} else {
4082+
format!("bash: {}: {}\n", name, operand)
4083+
};
4084+
self.nounset_error = Some(msg);
40804085
String::new()
40814086
} else {
40824087
value.to_string()
@@ -4151,6 +4156,43 @@ impl Interpreter {
41514156
return value.to_string();
41524157
}
41534158

4159+
// Handle # prefix anchor (match at start only)
4160+
if let Some(rest) = pattern.strip_prefix('#') {
4161+
if rest.is_empty() {
4162+
return value.to_string();
4163+
}
4164+
if let Some(stripped) = value.strip_prefix(rest) {
4165+
return format!("{}{}", replacement, stripped);
4166+
}
4167+
// Try glob match at prefix
4168+
if rest.contains('*') {
4169+
let matched = self.remove_pattern(value, rest, true, false);
4170+
if matched != value {
4171+
let prefix_len = value.len() - matched.len();
4172+
return format!("{}{}", replacement, &value[prefix_len..]);
4173+
}
4174+
}
4175+
return value.to_string();
4176+
}
4177+
4178+
// Handle % suffix anchor (match at end only)
4179+
if let Some(rest) = pattern.strip_prefix('%') {
4180+
if rest.is_empty() {
4181+
return value.to_string();
4182+
}
4183+
if let Some(stripped) = value.strip_suffix(rest) {
4184+
return format!("{}{}", stripped, replacement);
4185+
}
4186+
// Try glob match at suffix
4187+
if rest.contains('*') {
4188+
let matched = self.remove_pattern(value, rest, false, false);
4189+
if matched != value {
4190+
return format!("{}{}", matched, replacement);
4191+
}
4192+
}
4193+
return value.to_string();
4194+
}
4195+
41544196
// Handle glob pattern with *
41554197
if pattern.contains('*') {
41564198
// Convert glob to regex-like behavior

crates/bashkit/src/parser/mod.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2231,12 +2231,24 @@ impl<'a> Parser<'a> {
22312231
} else {
22322232
false
22332233
};
2234-
// Read pattern until /
2234+
// Read pattern until / (handle \/ as escaped /)
22352235
let mut pattern = String::new();
22362236
while let Some(&ch) = chars.peek() {
22372237
if ch == '/' || ch == '}' {
22382238
break;
22392239
}
2240+
if ch == '\\' {
2241+
chars.next(); // consume backslash
2242+
if let Some(&next) = chars.peek() {
2243+
if next == '/' {
2244+
// \/ -> literal /
2245+
pattern.push(chars.next().unwrap());
2246+
continue;
2247+
}
2248+
}
2249+
pattern.push('\\');
2250+
continue;
2251+
}
22402252
pattern.push(chars.next().unwrap());
22412253
}
22422254
// Read replacement if present
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
### heredoc_basic
2+
cat <<EOF
3+
hello world
4+
EOF
5+
### expect
6+
hello world
7+
### end
8+
9+
### heredoc_variable
10+
NAME=world
11+
cat <<EOF
12+
hello $NAME
13+
EOF
14+
### expect
15+
hello world
16+
### end
17+
18+
### heredoc_braced_variable
19+
NAME=world
20+
cat <<EOF
21+
hello ${NAME}!
22+
EOF
23+
### expect
24+
hello world!
25+
### end
26+
27+
### heredoc_command_subst
28+
cat <<EOF
29+
$(echo hello from cmd)
30+
EOF
31+
### expect
32+
hello from cmd
33+
### end
34+
35+
### heredoc_arithmetic
36+
cat <<EOF
37+
result is $((2 + 3))
38+
EOF
39+
### expect
40+
result is 5
41+
### end
42+
43+
### heredoc_quoted_delimiter
44+
NAME=world
45+
cat <<'EOF'
46+
hello $NAME
47+
EOF
48+
### expect
49+
hello $NAME
50+
### end
51+
52+
### heredoc_multiline
53+
A=foo
54+
B=bar
55+
cat <<EOF
56+
first: $A
57+
second: $B
58+
third: literal
59+
EOF
60+
### expect
61+
first: foo
62+
second: bar
63+
third: literal
64+
### end
65+
66+
### heredoc_to_file
67+
cat > /tmp/heredoc_out.txt <<EOF
68+
line one
69+
line two
70+
EOF
71+
cat /tmp/heredoc_out.txt
72+
### expect
73+
line one
74+
line two
75+
### end
76+
77+
### heredoc_mixed_expansion
78+
X=42
79+
cat <<EOF
80+
value: $X, cmd: $(echo hi), math: $((X * 2))
81+
EOF
82+
### expect
83+
value: 42, cmd: hi, math: 84
84+
### end
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
### read_basic
2+
### bash_diff
3+
echo "hello" | read var; echo "$var"
4+
### expect
5+
hello
6+
### end
7+
8+
### read_multiple_vars
9+
echo "one two three" | { read a b c; echo "$a $b $c"; }
10+
### expect
11+
one two three
12+
### end
13+
14+
### read_ifs
15+
echo "a:b:c" | { IFS=: read x y z; echo "$x $y $z"; }
16+
### expect
17+
a b c
18+
### end
19+
20+
### read_herestring
21+
read var <<< "from herestring"
22+
echo "$var"
23+
### expect
24+
from herestring
25+
### end
26+
27+
### read_empty_input
28+
echo "" | { read var; echo ">${var}<"; }
29+
### expect
30+
><
31+
### end
32+
33+
### read_r_flag
34+
read -r var <<< "hello\nworld"
35+
echo "$var"
36+
### expect
37+
hello\nworld
38+
### end
39+
40+
### read_leftover
41+
echo "one two three four" | { read a b; echo "$a|$b"; }
42+
### expect
43+
one|two three four
44+
### end
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
### string_replace_prefix
2+
s="/usr/local/bin"
3+
echo "${s/#\/usr/PREFIX}"
4+
### expect
5+
PREFIX/local/bin
6+
### end
7+
8+
### string_replace_suffix
9+
s="hello.txt"
10+
echo "${s/%.txt/.md}"
11+
### expect
12+
hello.md
13+
### end
14+
15+
### string_default_colon
16+
echo "${UNSET_VAR:-fallback}"
17+
### expect
18+
fallback
19+
### end
20+
21+
### string_default_empty
22+
X=""
23+
echo "${X:-fallback}"
24+
### expect
25+
fallback
26+
### end
27+
28+
### string_error_message
29+
### bash_diff
30+
### exit_code:1
31+
${UNSET_VAR:?"variable not set"}
32+
### expect
33+
### end
34+
35+
### string_use_replacement
36+
X="present"
37+
echo "${X:+replacement}"
38+
### expect
39+
replacement
40+
### end
41+
42+
### string_use_replacement_empty
43+
EMPTY=""
44+
result="${EMPTY:+replacement}"
45+
echo ">${result}<"
46+
### expect
47+
><
48+
### end
49+
50+
### string_length_unicode
51+
X="hello"
52+
echo "${#X}"
53+
### expect
54+
5
55+
### end
56+
57+
### string_nested_expansion
58+
A="world"
59+
B="A"
60+
echo "${!B}"
61+
### expect
62+
world
63+
### end
64+
65+
### string_concatenation
66+
A="hello"
67+
B="world"
68+
echo "${A} ${B}"
69+
### expect
70+
hello world
71+
### end
72+
73+
### string_uppercase_pattern
74+
X="hello world"
75+
echo "${X^^}"
76+
### expect
77+
HELLO WORLD
78+
### end
79+
80+
### string_lowercase_pattern
81+
X="HELLO WORLD"
82+
echo "${X,,}"
83+
### expect
84+
hello world
85+
### end
86+
87+
### var_negative_substring
88+
X="hello world"
89+
echo "${X: -5}"
90+
### expect
91+
world
92+
### end
93+
94+
### var_substring_length
95+
X="hello world"
96+
echo "${X:0:5}"
97+
### expect
98+
hello
99+
### end

0 commit comments

Comments
 (0)