From 11d8b1165711d8e526628e6ac42d48ad0baab03c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Feb 2026 17:05:16 +0000 Subject: [PATCH] feat: string ops, read -r, heredoc tests, prefix/suffix replace - 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, no backslash interpretation) and -p flag parsing - Add 31 new bash spec tests: - heredoc.test.sh (9): variable expansion, quoted delimiters, file redirects, command/arithmetic substitution - string-ops.test.sh (15): prefix/suffix replace, :?, :+, case conversion, indirect expansion, negative substring - read-builtin.test.sh (7): IFS splitting, -r flag, here-string - Bash spec tests: 741 total, 735 pass, 6 skip (100% pass rate) - Bash comparison: 668/668 match real bash (100%) https://claude.ai/code/session_012rzB3FRw7yoQWCG1mxyW7J --- crates/bashkit/src/builtins/read.rs | 48 ++++++++- crates/bashkit/src/interpreter/mod.rs | 44 ++++++++- crates/bashkit/src/parser/mod.rs | 14 ++- .../tests/spec_cases/bash/heredoc.test.sh | 84 ++++++++++++++++ .../spec_cases/bash/read-builtin.test.sh | 44 +++++++++ .../tests/spec_cases/bash/string-ops.test.sh | 99 +++++++++++++++++++ specs/009-implementation-status.md | 9 +- 7 files changed, 333 insertions(+), 9 deletions(-) create mode 100644 crates/bashkit/tests/spec_cases/bash/heredoc.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/read-builtin.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/string-ops.test.sh diff --git a/crates/bashkit/src/builtins/read.rs b/crates/bashkit/src/builtins/read.rs index 81a6a543..cc11195c 100644 --- a/crates/bashkit/src/builtins/read.rs +++ b/crates/bashkit/src/builtins/read.rs @@ -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::; // -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()) diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index dc62b07b..53d22652 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -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() @@ -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 diff --git a/crates/bashkit/src/parser/mod.rs b/crates/bashkit/src/parser/mod.rs index d7154f6c..62fec285 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -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 diff --git a/crates/bashkit/tests/spec_cases/bash/heredoc.test.sh b/crates/bashkit/tests/spec_cases/bash/heredoc.test.sh new file mode 100644 index 00000000..6a33b30b --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/heredoc.test.sh @@ -0,0 +1,84 @@ +### heredoc_basic +cat < /tmp/heredoc_out.txt <${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 diff --git a/crates/bashkit/tests/spec_cases/bash/string-ops.test.sh b/crates/bashkit/tests/spec_cases/bash/string-ops.test.sh new file mode 100644 index 00000000..4015e00e --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/string-ops.test.sh @@ -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 diff --git a/specs/009-implementation-status.md b/specs/009-implementation-status.md index 63774117..a3d93b3f 100644 --- a/specs/009-implementation-status.md +++ b/specs/009-implementation-status.md @@ -107,17 +107,17 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See ## Spec Test Coverage -**Total spec test cases:** 1060 (1038 pass, 22 skip) +**Total spec test cases:** 1144 (1090 pass, 54 skip) | Category | Cases | In CI | Pass | Skip | Notes | |----------|-------|-------|------|------|-------| -| Bash (core) | 711 | Yes | 705 | 6 | `bash_spec_tests` in CI | +| Bash (core) | 741 | Yes | 735 | 6 | `bash_spec_tests` in CI | | AWK | 90 | Yes | 73 | 17 | loops, arrays, -v, ternary, field assign | | Grep | 82 | Yes | 79 | 3 | now with -z, -r, -a, -b, -H, -h, -f, -P, --include, --exclude | | Sed | 65 | Yes | 53 | 12 | hold space, change, regex ranges, -E | | JQ | 108 | Yes | 100 | 8 | reduce, walk, regex funcs, --arg/--argjson, combined flags | | Python | 58 | Yes | 50 | 8 | **Experimental.** VFS bridging, pathlib, env vars | -| **Total** | **1087** | **Yes** | **1034** | **53** | | +| **Total** | **1144** | **Yes** | **1090** | **54** | | ### Bash Spec Tests Breakdown @@ -168,6 +168,9 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See | ln.test.sh | 5 | `ln -s`, `-f`, symlink creation | | eval-bugs.test.sh | 4 | regression tests for eval/script bugs | | script-exec.test.sh | 10 | script execution by path, $PATH search, exit codes | +| heredoc.test.sh | 9 | heredoc variable expansion, quoted delimiters, file redirects | +| string-ops.test.sh | 15 | string replacement (prefix/suffix anchored), `${var:?}`, case conversion | +| read-builtin.test.sh | 7 | `read` builtin, IFS splitting, `-r` flag, here-string input | ## Shell Features