Skip to content

Commit 7a6dce2

Browse files
goduclaude
andcommitted
Escape bash matches per quoting context and fix the word-break boundary
A third review round, again verified by driving real TAB presses through a pty and executing the resulting line, found the bash helper still corrupted values in two situations. The word-break scan treated every COMP_WORDBREAKS character in the word as readline's replacement boundary, but readline does not split on one that is backslash-escaped or inside quotes. Measuring what readline actually replaces confirms it: `node:` and `a=b` keep a head, while `\(w`, `semi\;c`, `a\:b` and any quoted word are replaced whole. Trimming a head readline was not going to keep silently dropped the front of the value, so `deploy --mode \(w<TAB>` ran with `whoami)`. The scan now tracks escaping and quoting and only counts a boundary outside both. Inside a quote the user opened, the match was inserted with no escaping for that context, so `"$H<TAB>` produced a `$HOME` the shell then expanded, and `'it<TAB>` produced an unterminated quote. Matches are now escaped for the enclosing quote: the expandable characters in a double-quoted context, and the `'\''` splice in a single-quoted one, since a single quote cannot be escaped inside single quotes. The splice has to be built with a bare assignment — within double quotes `\'` is not an escape, which double-escaped it and left the shell at a continuation prompt. Also make the helper name collision-proof. `sanitizeFunctionName` maps any character to `_`, so a subcommand named `-choices` produced exactly `_deploy__choices` and overwrote the helper; the name is now chosen against the set of function names the script actually emits. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0b31b88 commit 7a6dce2

2 files changed

Lines changed: 94 additions & 28 deletions

File tree

packages/effect/src/unstable/cli/internal/completions/bash.ts

Lines changed: 72 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,24 @@ const escapeForBash = (s: string): string => s.replace(/'/g, "'\\''")
1616

1717
const sanitizeFunctionName = (s: string): string => s.replace(/[^a-zA-Z0-9_]/g, "_")
1818

19+
/**
20+
* Every function name `generateFunction` will emit. `sanitizeFunctionName` maps
21+
* any character to `_`, so a subcommand can produce any name — the shared helper
22+
* has to pick one that is provably not among them.
23+
*/
24+
const emittedFunctionNames = (
25+
descriptor: Completions.CommandDescriptor,
26+
parentPath: ReadonlyArray<string>,
27+
names: Set<string>
28+
): Set<string> => {
29+
const currentPath = [...parentPath, descriptor.name]
30+
names.add(`_${currentPath.map(sanitizeFunctionName).join("_")}`)
31+
for (const sub of descriptor.subcommands) {
32+
emittedFunctionNames(sub, currentPath, names)
33+
}
34+
return names
35+
}
36+
1937
const flagNamesForWordlist = (flag: Completions.FlagDescriptor): Array<string> => {
2038
const names: Array<string> = [`--${flag.name}`]
2139
for (const alias of flag.aliases) {
@@ -65,14 +83,19 @@ const buildFlagGroupDeclarations = (
6583
*
6684
* `compgen -W` re-expands every word of its list, which mangles values holding
6785
* quotes, spaces or glob characters, so matches are filtered from an explicitly
68-
* quoted list instead. Two further readline details are handled here:
86+
* quoted list instead. The rest of the helper deals with how readline inserts
87+
* the match:
6988
*
70-
* - Bash inserts COMPREPLY entries verbatim when the word is unquoted, so each
71-
* match is requoted with `printf %q`. When the user has opened a quote, bash
72-
* quotes the entry itself and requoting would double-escape it.
73-
* - Readline only replaces the text after the last COMP_WORDBREAKS character
74-
* (`:` among them), so that head has to be trimmed off every match or a value
75-
* like `node:20` is appended to what was typed rather than replacing it.
89+
* - An unquoted word is replaced verbatim, so the match is escaped with
90+
* `printf %q`. Inside a quote the user opened, bash closes the quote for us
91+
* but escapes nothing, so the match is escaped for that quote context —
92+
* including the `\'` splice, since a single quote cannot be escaped within
93+
* single quotes.
94+
* - Readline replaces only the text after the last COMP_WORDBREAKS character,
95+
* so that head is trimmed from every match; otherwise a value like `node:20`
96+
* is appended to what was typed rather than replacing it. Wordbreaks that are
97+
* backslash-escaped or inside quotes do not split the word, so they must not
98+
* be treated as the boundary.
7699
*
77100
* Prefix matching uses the dequoted word. Dequoting is best effort: it strips
78101
* one opening quote and any backslash escapes, so a value whose own text
@@ -83,26 +106,50 @@ const choicesHelper = (helperName: string, lines: Array<string>): void => {
83106
lines.push(`{`)
84107
lines.push(` local _cur="$1"; shift`)
85108
lines.push(` local _prefix="\${_cur#[\\"\\']}"; _prefix="\${_prefix//\\\\/}"`)
86-
lines.push(` local _quoted_word=""`)
87-
lines.push(` [[ "$_cur" == [\\"\\']* ]] && _quoted_word=1`)
109+
lines.push(` local _open=""`)
110+
lines.push(` [[ "$_cur" == [\\"\\']* ]] && _open="\${_cur:0:1}"`)
111+
lines.push(``)
88112
lines.push(` COMPREPLY=()`)
89-
lines.push(` local _choice _quoted`)
113+
lines.push(` local _choice _match`)
90114
lines.push(` for _choice in "$@"; do`)
91115
lines.push(` [[ "$_choice" == "$_prefix"* ]] || continue`)
92-
lines.push(` if [[ -n "$_quoted_word" ]]; then`)
93-
lines.push(` COMPREPLY+=("$_choice")`)
94-
lines.push(` else`)
95-
lines.push(` printf -v _quoted '%q' "$_choice"`)
96-
lines.push(` COMPREPLY+=("$_quoted")`)
97-
lines.push(` fi`)
116+
lines.push(` case "$_open" in`)
117+
lines.push(` '"')`)
118+
lines.push(` _match="\${_choice//\\\\/\\\\\\\\}"`)
119+
lines.push(` _match="\${_match//\\$/\\\\$}"`)
120+
lines.push(` _match="\${_match//\\\`/\\\\\\\`}"`)
121+
lines.push(` _match="\${_match//\\"/\\\\\\"}"`)
122+
lines.push(` ;;`)
123+
lines.push(` "'")`)
124+
lines.push(` # bare assignment: inside double quotes \\' is not an escape`)
125+
lines.push(` _match=\${_choice//\\'/\\'\\\\\\'\\'}`)
126+
lines.push(` ;;`)
127+
lines.push(` *)`)
128+
lines.push(` printf -v _match '%q' "$_choice"`)
129+
lines.push(` ;;`)
130+
lines.push(` esac`)
131+
lines.push(` COMPREPLY+=("$_match")`)
98132
lines.push(` done`)
99-
lines.push(` local _head="$_cur"`)
100-
lines.push(` while [[ -n "$_head" ]]; do`)
101-
lines.push(` [[ "$COMP_WORDBREAKS" == *"\${_head: -1}"* ]] && break`)
102-
lines.push(` _head="\${_head%?}"`)
133+
lines.push(``)
134+
lines.push(` # Boundary = last wordbreak character that is neither escaped nor quoted`)
135+
lines.push(` local _i _c _quote="" _escaped=0 _cut=0`)
136+
lines.push(` for ((_i = 0; _i < \${#_cur}; _i++)); do`)
137+
lines.push(` _c="\${_cur:_i:1}"`)
138+
lines.push(` if ((_escaped)); then _escaped=0; continue; fi`)
139+
lines.push(` case "$_c" in`)
140+
lines.push(` \\\\) _escaped=1 ;;`)
141+
lines.push(` \\"|\\')`)
142+
lines.push(` if [[ -z "$_quote" ]]; then _quote="$_c"`)
143+
lines.push(` elif [[ "$_quote" == "$_c" ]]; then _quote=""`)
144+
lines.push(` fi`)
145+
lines.push(` ;;`)
146+
lines.push(` *)`)
147+
lines.push(` if [[ -z "$_quote" && "$COMP_WORDBREAKS" == *"$_c"* ]]; then _cut=$((_i + 1)); fi`)
148+
lines.push(` ;;`)
149+
lines.push(` esac`)
103150
lines.push(` done`)
104-
lines.push(` if [[ -n "$_head" ]]; then`)
105-
lines.push(` local _i`)
151+
lines.push(` if ((_cut > 0)); then`)
152+
lines.push(` local _head="\${_cur:0:_cut}"`)
106153
lines.push(` for ((_i = 0; _i < \${#COMPREPLY[@]}; _i++)); do`)
107154
lines.push(` COMPREPLY[_i]="\${COMPREPLY[_i]#"$_head"}"`)
108155
lines.push(` done`)
@@ -293,7 +340,9 @@ export const generate = (
293340
): string => {
294341
const lines: Array<string> = []
295342
const safeName = sanitizeFunctionName(executableName)
296-
const helperName = `_${safeName}__choices`
343+
const taken = emittedFunctionNames(descriptor, [], new Set())
344+
let helperName = `_${safeName}__choices`
345+
while (taken.has(helperName)) helperName += "_"
297346

298347
lines.push(`###-begin-${escapeForBash(executableName)}-completions-###`)
299348
lines.push(`#`)

packages/effect/test/unstable/cli/completions/completions.test.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -235,21 +235,38 @@ describe("Bash completions", () => {
235235
assert.notInclude(script, `compgen -W 'it`)
236236
})
237237

238-
it("requotes matches for insertion but not inside a quote the user opened", () => {
238+
it("escapes matches for the quoting context of the word being completed", () => {
239239
const desc = fromCommand(withTrickyChoices)
240240
const script = Bash.generate("deploy", desc)
241241
assert.include(script, `local _prefix="\${_cur#[\\"\\']}"; _prefix="\${_prefix//\\\\/}"`)
242-
assert.include(script, `[[ "$_cur" == [\\"\\']* ]] && _quoted_word=1`)
243-
assert.include(script, `printf -v _quoted '%q' "$_choice"`)
242+
assert.include(script, `[[ "$_cur" == [\\"\\']* ]] && _open="\${_cur:0:1}"`)
243+
// unquoted: %q. Double quotes: escape what the shell still expands there.
244+
// Single quotes: splice, since a quote cannot be escaped inside them.
245+
assert.include(script, `printf -v _match '%q' "$_choice"`)
246+
assert.include(script, `_match="\${_match//\\$/\\\\$}"`)
247+
assert.include(script, `_match=\${_choice//\\'/\\'\\\\\\'\\'}`)
244248
})
245249

246-
it("trims the COMP_WORDBREAKS head so colon values replace the typed word", () => {
250+
it("treats only unescaped, unquoted word-break characters as the replacement boundary", () => {
247251
const desc = fromCommand(withTrickyChoices)
248252
const script = Bash.generate("deploy", desc)
249-
assert.include(script, `[[ "$COMP_WORDBREAKS" == *"\${_head: -1}"* ]] && break`)
253+
assert.include(script, `if [[ -z "$_quote" && "$COMP_WORDBREAKS" == *"$_c"* ]]; then _cut=$((_i + 1)); fi`)
250254
assert.include(script, `COMPREPLY[_i]="\${COMPREPLY[_i]#"$_head"}"`)
251255
})
252256

257+
it("picks a choices-helper name that no generated command function can collide with", () => {
258+
const collidingName = Command.make("deploy", {
259+
mode: Flag.choice("mode", ["a"])
260+
}).pipe(
261+
Command.withSubcommands([Command.make("-choices", { m: Flag.choice("m", ["b"]) })])
262+
)
263+
const script = Bash.generate("deploy", fromCommand(collidingName))
264+
assert.include(script, "_deploy__choices()")
265+
assert.include(script, "_deploy__choices_()")
266+
assert.include(script, `_deploy__choices_ "$cur" 'a'`)
267+
assert.notInclude(script, `_deploy__choices "$cur"`)
268+
})
269+
253270
it("generates separate functions for nested subcommands", () => {
254271
const desc = fromCommand(withSubcommands)
255272
const script = Bash.generate("server", desc)

0 commit comments

Comments
 (0)