From 58773de5aebd9034b987e5ebe5f8225bbc8ac24d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Feb 2026 05:31:03 +0000 Subject: [PATCH] feat(builtins): implement printf %q format specifier for shell quoting Matches bash behavior: backslash-escapes printable special chars, uses $'...' quoting for control characters, leaves safe strings unquoted, and quotes empty strings as ''. https://claude.ai/code/session_012rzB3FRw7yoQWCG1mxyW7J --- crates/bashkit/src/builtins/printf.rs | 61 +++++++++++++++++++ .../tests/spec_cases/bash/printf.test.sh | 44 +++++++++++++ specs/009-implementation-status.md | 8 +-- 3 files changed, 109 insertions(+), 4 deletions(-) diff --git a/crates/bashkit/src/builtins/printf.rs b/crates/bashkit/src/builtins/printf.rs index addbbfbb..22bba165 100644 --- a/crates/bashkit/src/builtins/printf.rs +++ b/crates/bashkit/src/builtins/printf.rs @@ -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('%'); @@ -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(); diff --git a/crates/bashkit/tests/spec_cases/bash/printf.test.sh b/crates/bashkit/tests/spec_cases/bash/printf.test.sh index b2c0932c..6abdc1b9 100644 --- a/crates/bashkit/tests/spec_cases/bash/printf.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/printf.test.sh @@ -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 diff --git a/specs/009-implementation-status.md b/specs/009-implementation-status.md index 46bf42b4..73952239 100644 --- a/specs/009-implementation-status.md +++ b/specs/009-implementation-status.md @@ -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 @@ -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 |