From 79f40aedb89637e46d60b192d40d5d29d1075688 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 13:15:28 +0000 Subject: [PATCH] fix(parser): keep backslash-escaped $ literal in unquoted words Claude-Session: https://claude.ai/code/session_019aFikmptPc91Fj4N2iDXQA --- crates/bashkit/src/parser/lexer.rs | 7 +++- .../spec_cases/bash/escaped-dollar.test.sh | 36 +++++++++++++++++++ knowledge/log.md | 1 + 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 crates/bashkit/tests/spec_cases/bash/escaped-dollar.test.sh diff --git a/crates/bashkit/src/parser/lexer.rs b/crates/bashkit/src/parser/lexer.rs index 5053f7cdd..b6c94e564 100644 --- a/crates/bashkit/src/parser/lexer.rs +++ b/crates/bashkit/src/parser/lexer.rs @@ -925,7 +925,12 @@ impl<'a> Lexer<'a> { self.advance(); } else { // Escaped character: backslash quotes the next char - // (quote removal — only the literal char survives) + // (quote removal — only the literal char survives). + // `\$` and `\`` must stay literal through parse_word, so + // they carry the NUL sentinel like quoted `\$` does. + if matches!(next, '$' | '`') { + word.push('\x00'); + } word.push(next); self.advance(); } diff --git a/crates/bashkit/tests/spec_cases/bash/escaped-dollar.test.sh b/crates/bashkit/tests/spec_cases/bash/escaped-dollar.test.sh new file mode 100644 index 000000000..864aba4b1 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/escaped-dollar.test.sh @@ -0,0 +1,36 @@ +# A backslash outside quotes makes the next character literal: `\$` is a +# dollar sign, not the start of a parameter or command substitution. + +### escaped_dollar_not_expanded +HOME=/h +echo \$HOME +echo a\$HOME +echo \${HOME} +x=\$HOME; echo "$x" +### expect +$HOME +a$HOME +${HOME} +$HOME +### end + +### escaped_dollar_paren_not_substituted +echo \$\(echo hi\) +echo a\$\(b\) +### expect +$(echo hi) +a$(b) +### end + +### escaped_backtick_literal +echo \`echo hi\` +### expect +`echo hi` +### end + +### escaped_dollar_printf_args +printf '%s\n' a\$1 \$PATH +### expect +a$1 +$PATH +### end diff --git a/knowledge/log.md b/knowledge/log.md index 56693d240..fe2655019 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +2,7 @@ ## 2026-10-07 +* **Fix**: A backslash outside quotes keeps `$` and `` ` `` literal. `echo \$HOME` printed the home directory; it now prints `$HOME`, as do `a\$x`, `\${x}` and `x=\$y` (the lexer emits the same NUL literal marker as quoted `"\$"`). Found while working gap telemetry. * **Fix**: `$'\x1e'` and `$'\x1f'` bytes (record/unit separators) are kept as data. They collided with the lexer's quote-boundary markers and were silently dropped, which broke the common `SEP=$'\x1f'` composite-key idiom for associative arrays (`v["$section"$'\x1f'"$key"]`). Decoded ANSI-C content now NUL-escapes those bytes like `$`. Found by gap telemetry (`config_ini_merge` replay printed nothing). * **Fix**: A usage error in a bundled builtin no longer aborts the script. `grep -E '('`, `grep -P` with a bad pattern, `rg` with a bad regex, an awk syntax error, `grep` with no pattern and `grep -f missing` now fail that one command with exit 2 and the message on stderr, and the script continues (bash semantics; `set -e` still stops). `Err` from custom builtins (`BashBuilder::builtin`, registry, resolver) keeps aborting, per the public contract. See foundations/builtins "Errors: Failed Command vs Aborted Execution". * **Feature**: `gawk`, `mawk` and `nawk` run the awk builtin (same engine, including gawk extensions it already had such as `gensub`, `toupper`). Gap telemetry showed agents probing `gawk` by name. `--version` still identifies as bashkit awk.