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
19 changes: 14 additions & 5 deletions crates/bashkit/src/builtins/generated/format_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,14 +104,17 @@ fn shell_quote(s: &str) -> String {
return "''".to_string();
}

let needs_quoting = s
.chars()
.any(|c| !c.is_ascii_alphanumeric() && !"_/.:-=+@,%^".contains(c));
// Under UTF-8, bash leaves printable non-ASCII characters as they are
// (`printf %q 'com espaço'` is `com\ espaço`).
let safe = |c: char| {
c.is_ascii_alphanumeric() || "_/.:-=+@,%^".contains(c) || (!c.is_ascii() && !c.is_control())
};
let needs_quoting = s.chars().any(|c| !safe(c));
if !needs_quoting {
return s.to_string();
}

let has_control = s.chars().any(|c| (c as u32) < 32 || c as u32 == 127);
let has_control = s.chars().any(char::is_control);
if has_control {
let mut out = String::from("$'");
for ch in s.chars() {
Expand All @@ -124,6 +127,12 @@ fn shell_quote(s: &str) -> String {
c if (c as u32) < 32 || c as u32 == 127 => {
out.push_str(&format!("\\x{:02x}", c as u32));
}
c if c.is_control() => {
let mut buf = [0u8; 4];
for b in c.encode_utf8(&mut buf).bytes() {
out.push_str(&format!("\\{b:03o}"));
}
}
c => out.push(c),
}
}
Expand All @@ -133,7 +142,7 @@ fn shell_quote(s: &str) -> String {

let mut out = String::new();
for ch in s.chars() {
if ch.is_ascii_alphanumeric() || "_/.:-=+@,%^".contains(ch) {
if safe(ch) {
out.push(ch);
} else {
out.push('\\');
Expand Down
5 changes: 4 additions & 1 deletion crates/bashkit/src/builtins/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -536,7 +536,10 @@ impl XargsOptions {
SubCommand {
name,
args: cmd,
stdin: None,
// GNU xargs gives each command /dev/null as stdin: the
// items are already consumed (`printf '' | xargs wc -l`
// prints 0).
stdin: Some(crate::StreamData::new()),
assignments: match self.process_slot_var {
Some(ref var) => vec![(var.clone(), (idx % slot_count).to_string())],
None => Vec::new(),
Expand Down
6 changes: 3 additions & 3 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7227,7 +7227,9 @@ impl Interpreter {
let stdin = if stdin.is_some() {
stdin
} else if let Some(ref ps) = self.pipeline_stdin {
if !ps.is_empty() {
// An empty pipe is still stdin at EOF: `printf '' | { wc -l; }`
// prints 0, not nothing.
{
if name == "read" {
// Consume one record (line, `-d` delimiter, `-n` count,
// `\<newline>` continuation) from pipeline stdin.
Expand All @@ -7239,8 +7241,6 @@ impl Interpreter {
} else {
Some(ps.clone())
}
} else {
None
}
} else {
None
Expand Down
8 changes: 8 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/bash-stdin-pipe.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,11 @@ echo "test input" | bash /tmp/read_test.sh
### expect
line: test input
### end

### empty_pipe_is_stdin_at_eof_for_compound_commands
printf '' | { wc -l; }
f() { wc -l; }; printf '' | f
### expect
0
0
### end
7 changes: 7 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/printf.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -282,3 +282,10 @@ printf '\xff\xfe' | od -An -tx1
### expect
ff fe
### end

### printf_q_keeps_printable_non_ascii
printf '%q\n' 'com espaço' 'a€b'
### expect
com\ espaço
a€b
### end
10 changes: 10 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/xargs.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,13 @@ printf "x\n" | xargs -r echo got
### expect
got x
### end

### xargs_empty_input_runs_command_once_with_empty_stdin
# GNU xargs without -r runs the command once; its stdin is at EOF
printf '' | xargs wc -l
printf '' | xargs -r wc -l
echo done
### expect
0
done
### end
1 change: 1 addition & 0 deletions knowledge/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
* **Fix**: Declaration builtins and local scope match bash. `declare`/`typeset`/`local`/`export`/`readonly` share one engine with bash's option sets, errors and `declare -p` format (quoting, assoc hash order); locals use shallow binding saved in the call frame, so nested functions, `unset` of a caller's local, `declare -g` past a local, and `local -n` behave as in bash. Compound operands stay one `CompoundAssignment` word part (keyed `[k]="v w"` and `` [k]=`cmd` `` elements lex as one word). Readonly assignment and circular namerefs abandon the line. See [Builtins](foundations/builtins.md#declaration-builtins-and-variable-scope).
* **Fix**: Arithmetic follows bash's grammar and error model. New tokenizer + precedence-climbing evaluator (`interpreter/arithmetic.rs`): comma lists, chained/compound assignment, `**` right-assoc, `base#n` up to base 64, `noeval` short-circuit. `$((1/0))`, syntax errors and bad subscripts now print bash's message and abort the rest of the line (status 1) instead of yielding 0; `((...))` and `let` return 1. `((z <<= 65))` is read as raw text, not a heredoc. Bounds unchanged (`MAX_ARITHMETIC_DEPTH`, fuel; TM-DOS-026). See [Parser](foundations/parser.md#arithmetic-expressions).
* **Fix**: Named file descriptors follow bash. `{var}>file` (and `<`, `>>`, `<>`, `<<<`, `<<`, `>&N`, `<&N`) allocates the lowest free fd >= 10 into `var` (local, array element; readonly fails) on any command, simple or compound, and the fd stays open as bash never undoes `{var}` redirections; `{var}>&-` closes it and a closed fd is reused. `>&N`/`<&N` to a closed fd is `Bad file descriptor` (status 1), a dup to a word is `ambiguous redirect`. The lexer accepts multi-digit fds (`10>x`), `<>` and `N<<<w`; `exec` applies its redirects in order and no longer re-applies them. Descriptors stay virtual per interpreter (no host fds); `cmd {v}>f` in a loop is capped by `max_file_descriptors` (TM-DOS-063 tests updated: a prior `$v` value is ignored). Gaps in [Limitations](operations/limitations.md) ("Named fds / `<>`").
* **Fix**: xargs runs its command with stdin at EOF (`printf '' | xargs wc -l` prints 0); an empty pipe now reaches compound commands and functions as stdin at EOF; `printf %q` leaves printable non-ASCII unescaped under UTF-8.

## 2026-10-06

Expand Down
Loading