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
61 changes: 61 additions & 0 deletions crates/bashkit/src/builtins/printf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,10 @@ fn format_string(format: &str, args: &[String], arg_index: &mut usize) -> String
// String with escape sequences
output.push_str(&expand_escapes(arg));
}
'q' => {
// Shell-quoted string safe for reuse
output.push_str(&shell_quote(arg));
}
_ => {
// Unknown format - output literally
output.push('%');
Expand All @@ -363,6 +367,63 @@ fn format_string(format: &str, args: &[String], arg_index: &mut usize) -> String
output
}

/// Quote a string for safe shell reuse (printf %q behavior).
///
/// Matches bash behavior:
/// - Empty string → `''`
/// - Safe strings (only alnum/`_`/`.`/`-`/`:`/`=`/`+`/`@`/`,`/`%`/`^`/`/`) → unquoted
/// - Strings with control chars (tab, newline, etc.) → `$'...'` quoting
/// - Other strings → backslash-escape individual special characters
fn shell_quote(s: &str) -> String {
if s.is_empty() {
return "''".to_string();
}

// Check if the string needs quoting at all
let needs_quoting = s
.chars()
.any(|c| !c.is_ascii_alphanumeric() && !"_/.:-=+@,%^".contains(c));

if !needs_quoting {
return s.to_string();
}

// Check for control characters that require $'...' quoting
let has_control = s.chars().any(|c| (c as u32) < 32 || c as u32 == 127);

if has_control {
// Use $'...' quoting
let mut out = String::from("$'");
for ch in s.chars() {
match ch {
'\'' => out.push_str("\\'"),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\t' => out.push_str("\\t"),
'\r' => out.push_str("\\r"),
c if (c as u32) < 32 || c as u32 == 127 => {
out.push_str(&format!("\\x{:02x}", c as u32));
}
c => out.push(c),
}
}
out.push('\'');
out
} else {
// Backslash-escape individual special characters
let mut out = String::new();
for ch in s.chars() {
if ch.is_ascii_alphanumeric() || "_/.:-=+@,%^".contains(ch) {
out.push(ch);
} else {
out.push('\\');
out.push(ch);
}
}
out
}
}

/// Expand escape sequences in a string
fn expand_escapes(s: &str) -> String {
let mut output = String::new();
Expand Down
44 changes: 44 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/printf.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,47 @@ printf -v padded "%05d" 42; echo "$padded"
### expect
00042
### end

### printf_q_space
# printf %q escapes spaces
printf '%q\n' 'hello world'
### expect
hello\ world
### end

### printf_q_simple
# printf %q leaves safe strings unquoted
printf '%q\n' 'simple'
### expect
simple
### end

### printf_q_empty
# printf %q quotes empty string
printf '%q\n' ''
### expect
''
### end

### printf_q_special_chars
# printf %q escapes special shell chars
printf '%q\n' 'a"b'
### expect
a\"b
### end

### printf_q_tab
# printf %q uses $'...' for control chars
### bash_diff
x=$(printf 'hello\tworld')
printf '%q\n' "$x"
### expect
$'hello\tworld'
### end

### printf_q_single_quote
# printf %q escapes single quotes
printf '%q\n' "it's"
### expect
it\'s
### end
8 changes: 4 additions & 4 deletions specs/009-implementation-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,17 +103,17 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See

## Spec Test Coverage

**Total spec test cases:** 1359 (1354 pass, 5 skip)
**Total spec test cases:** 1365 (1360 pass, 5 skip)

| Category | Cases | In CI | Pass | Skip | Notes |
|----------|-------|-------|------|------|-------|
| Bash (core) | 941 | Yes | 936 | 5 | `bash_spec_tests` in CI |
| Bash (core) | 947 | Yes | 942 | 5 | `bash_spec_tests` in CI |
| AWK | 96 | Yes | 96 | 0 | loops, arrays, -v, ternary, field assign, getline, %.6g |
| Grep | 76 | Yes | 76 | 0 | -z, -r, -a, -b, -H, -h, -f, -P, --include, --exclude, binary detect |
| Sed | 75 | Yes | 75 | 0 | hold space, change, regex ranges, -E |
| JQ | 114 | Yes | 114 | 0 | reduce, walk, regex funcs, --arg/--argjson, combined flags, input/inputs, env |
| Python | 57 | Yes | 57 | 0 | embedded Python (Monty) |
| **Total** | **1359** | **Yes** | **1354** | **5** | |
| **Total** | **1365** | **Yes** | **1360** | **5** | |

### Bash Spec Tests Breakdown

Expand Down Expand Up @@ -149,7 +149,7 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See
| paste.test.sh | 4 | line merging with `-s` serial and `-d` delimiter |
| path.test.sh | 14 | |
| pipes-redirects.test.sh | 19 | includes stderr redirects |
| printf.test.sh | 26 | format specifiers, array expansion, `-v` variable assignment |
| printf.test.sh | 32 | format specifiers, array expansion, `-v` variable assignment, `%q` shell quoting |
| procsub.test.sh | 6 | |
| sleep.test.sh | 6 | |
| sortuniq.test.sh | 32 | sort and uniq, `-z` zero-terminated, `-m` merge |
Expand Down
Loading