Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 44 additions & 4 deletions crates/bashkit/src/builtins/read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,61 @@ impl Builtin for Read {
None => return Ok(ExecResult::err("", 1)),
};

// Parse flags
let mut raw_mode = false; // -r: don't interpret backslashes
let mut prompt = None::<String>; // -p prompt
let mut var_args = Vec::new();
let mut args_iter = ctx.args.iter();
while let Some(arg) = args_iter.next() {
if arg.starts_with('-') && arg.len() > 1 {
for flag in arg[1..].chars() {
match flag {
'r' => raw_mode = true,
'p' => {
// -p takes next arg as prompt
if let Some(p) = args_iter.next() {
prompt = Some(p.clone());
}
}
_ => {} // ignore unknown flags
}
}
} else {
var_args.push(arg.as_str());
}
}
let _ = prompt; // prompt is for interactive use, ignored in non-interactive

// Get first line
let line = input.lines().next().unwrap_or("");
let line = if raw_mode {
// -r: treat backslashes literally
input.lines().next().unwrap_or("").to_string()
} else {
// Without -r: handle backslash line continuation
let mut result = String::new();
for l in input.lines() {
if let Some(stripped) = l.strip_suffix('\\') {
result.push_str(stripped);
} else {
result.push_str(l);
break;
}
}
result
};

// If no variable names given, use REPLY
let var_names: Vec<&str> = if ctx.args.is_empty() {
let var_names: Vec<&str> = if var_args.is_empty() {
vec!["REPLY"]
} else {
ctx.args.iter().map(|s| s.as_str()).collect()
var_args
};

// Split line by IFS (default: space, tab, newline)
let ifs = ctx.env.get("IFS").map(|s| s.as_str()).unwrap_or(" \t\n");
let words: Vec<&str> = if ifs.is_empty() {
// Empty IFS means no word splitting
vec![line]
vec![&line]
} else {
line.split(|c: char| ifs.contains(c))
.filter(|s| !s.is_empty())
Expand Down
44 changes: 43 additions & 1 deletion crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4076,7 +4076,12 @@ impl Interpreter {
ParameterOp::Error => {
// ${var:?error} - error if unset/empty
if value.is_empty() {
// In real bash this would exit, we just return empty
let msg = if operand.is_empty() {
format!("bash: {}: parameter null or not set\n", name)
} else {
format!("bash: {}: {}\n", name, operand)
};
self.nounset_error = Some(msg);
String::new()
} else {
value.to_string()
Expand Down Expand Up @@ -4151,6 +4156,43 @@ impl Interpreter {
return value.to_string();
}

// Handle # prefix anchor (match at start only)
if let Some(rest) = pattern.strip_prefix('#') {
if rest.is_empty() {
return value.to_string();
}
if let Some(stripped) = value.strip_prefix(rest) {
return format!("{}{}", replacement, stripped);
}
// Try glob match at prefix
if rest.contains('*') {
let matched = self.remove_pattern(value, rest, true, false);
if matched != value {
let prefix_len = value.len() - matched.len();
return format!("{}{}", replacement, &value[prefix_len..]);
}
}
return value.to_string();
}

// Handle % suffix anchor (match at end only)
if let Some(rest) = pattern.strip_prefix('%') {
if rest.is_empty() {
return value.to_string();
}
if let Some(stripped) = value.strip_suffix(rest) {
return format!("{}{}", stripped, replacement);
}
// Try glob match at suffix
if rest.contains('*') {
let matched = self.remove_pattern(value, rest, false, false);
if matched != value {
return format!("{}{}", matched, replacement);
}
}
return value.to_string();
}

// Handle glob pattern with *
if pattern.contains('*') {
// Convert glob to regex-like behavior
Expand Down
14 changes: 13 additions & 1 deletion crates/bashkit/src/parser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2231,12 +2231,24 @@ impl<'a> Parser<'a> {
} else {
false
};
// Read pattern until /
// Read pattern until / (handle \/ as escaped /)
let mut pattern = String::new();
while let Some(&ch) = chars.peek() {
if ch == '/' || ch == '}' {
break;
}
if ch == '\\' {
chars.next(); // consume backslash
if let Some(&next) = chars.peek() {
if next == '/' {
// \/ -> literal /
pattern.push(chars.next().unwrap());
continue;
}
}
pattern.push('\\');
continue;
}
pattern.push(chars.next().unwrap());
}
// Read replacement if present
Expand Down
84 changes: 84 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/heredoc.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
### heredoc_basic
cat <<EOF
hello world
EOF
### expect
hello world
### end

### heredoc_variable
NAME=world
cat <<EOF
hello $NAME
EOF
### expect
hello world
### end

### heredoc_braced_variable
NAME=world
cat <<EOF
hello ${NAME}!
EOF
### expect
hello world!
### end

### heredoc_command_subst
cat <<EOF
$(echo hello from cmd)
EOF
### expect
hello from cmd
### end

### heredoc_arithmetic
cat <<EOF
result is $((2 + 3))
EOF
### expect
result is 5
### end

### heredoc_quoted_delimiter
NAME=world
cat <<'EOF'
hello $NAME
EOF
### expect
hello $NAME
### end

### heredoc_multiline
A=foo
B=bar
cat <<EOF
first: $A
second: $B
third: literal
EOF
### expect
first: foo
second: bar
third: literal
### end

### heredoc_to_file
cat > /tmp/heredoc_out.txt <<EOF
line one
line two
EOF
cat /tmp/heredoc_out.txt
### expect
line one
line two
### end

### heredoc_mixed_expansion
X=42
cat <<EOF
value: $X, cmd: $(echo hi), math: $((X * 2))
EOF
### expect
value: 42, cmd: hi, math: 84
### end
44 changes: 44 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/read-builtin.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
### read_basic
### bash_diff
echo "hello" | read var; echo "$var"
### expect
hello
### end

### read_multiple_vars
echo "one two three" | { read a b c; echo "$a $b $c"; }
### expect
one two three
### end

### read_ifs
echo "a:b:c" | { IFS=: read x y z; echo "$x $y $z"; }
### expect
a b c
### end

### read_herestring
read var <<< "from herestring"
echo "$var"
### expect
from herestring
### end

### read_empty_input
echo "" | { read var; echo ">${var}<"; }
### expect
><
### end

### read_r_flag
read -r var <<< "hello\nworld"
echo "$var"
### expect
hello\nworld
### end

### read_leftover
echo "one two three four" | { read a b; echo "$a|$b"; }
### expect
one|two three four
### end
99 changes: 99 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/string-ops.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
### string_replace_prefix
s="/usr/local/bin"
echo "${s/#\/usr/PREFIX}"
### expect
PREFIX/local/bin
### end

### string_replace_suffix
s="hello.txt"
echo "${s/%.txt/.md}"
### expect
hello.md
### end

### string_default_colon
echo "${UNSET_VAR:-fallback}"
### expect
fallback
### end

### string_default_empty
X=""
echo "${X:-fallback}"
### expect
fallback
### end

### string_error_message
### bash_diff
### exit_code:1
${UNSET_VAR:?"variable not set"}
### expect
### end

### string_use_replacement
X="present"
echo "${X:+replacement}"
### expect
replacement
### end

### string_use_replacement_empty
EMPTY=""
result="${EMPTY:+replacement}"
echo ">${result}<"
### expect
><
### end

### string_length_unicode
X="hello"
echo "${#X}"
### expect
5
### end

### string_nested_expansion
A="world"
B="A"
echo "${!B}"
### expect
world
### end

### string_concatenation
A="hello"
B="world"
echo "${A} ${B}"
### expect
hello world
### end

### string_uppercase_pattern
X="hello world"
echo "${X^^}"
### expect
HELLO WORLD
### end

### string_lowercase_pattern
X="HELLO WORLD"
echo "${X,,}"
### expect
hello world
### end

### var_negative_substring
X="hello world"
echo "${X: -5}"
### expect
world
### end

### var_substring_length
X="hello world"
echo "${X:0:5}"
### expect
hello
### end
Loading
Loading