Shell completion generation for Parser and App (bash, zsh) - #12
Conversation
CLI.Completion turns the structure the library already carries -- long and short flag names, the explicit `options` value set an Option may declare, and an App's named subcommands with their own parsers -- into a completion script, so completions cannot drift from the flags the program actually accepts. CLI.Completion.bash/zsh take a Parser, CLI.Completion.App.bash/zsh take an App and dispatch to the chosen subcommand's flags. Where the library knows nothing more specific -- the value of a flag without a declared value set, and every positional -- the script falls back to the shell's own file name completion, consistently across both shells; that choice is documented on both functions. Descriptions and declared values are free-form strings, so they are escaped for the target shell: bash word lists are backslash-escaped and single-quoted so `compgen -W` cannot expand or run them, and the list is split on newlines so a value containing spaces stays one candidate; zsh descriptions additionally escape the `[`, `]` and `:` that delimit an `_arguments` spec. Descriptions are omitted from the bash script, which has nowhere to show them. Two hidden accessors in CLI proper expose the private Tag/Type behind Option to the submodule, mirroring how CLI.parse-from is reachable from CLI.App. gendocs.carp loaded "CLI.carp", which does not resolve on a case-sensitive filesystem; it now loads "cli.carp" and picks up the new module without a save-docs change, since save-docs already walks submodules.
There was a problem hiding this comment.
Build & Tests
Checked out 2db84f1 and ran carp -x test/cli.carp → 159 passed / 0 failed, exit 0. angler and carp-fmt --check clean, carp -x gendocs.carp clean and leaves docs/ unchanged (the committed docs really are current), and examples/simple.carp still builds. Branch sits exactly on the master tip e3cbf57; merge-tree clean.
One thing to know before reading the green check: this repo's CI never runs the test suite. .github/workflows/ci.yml has Lint and Format check and nothing else — no Run tests step, no Generate docs step. The job's step list confirms it, and the raw ubuntu log contains zero Passed:/Failed: lines. So the 159 assertions this PR adds to are verified by my local run and yours, not by CI, and the gendocs.carp fix below is not CI-covered either. Different shape from the continue-on-error masking in http — and you fixed that one today in 1ce4efe. Flagging it because the bot cannot push workflow files.
Findings
The escaping is the substantive part of this change and I went at it hardest. It holds up, including in the place where it first looked broken — see the end. Three real defects, all in one function.
1. zsh emits a duplicate -h whenever the parser uses -h for something else
zsh-specs (cli.carp:918-919) pushes the -h spec unconditionally, with no dedup at all:
(set! out (push-back out (quoted &(fmt "-h[%s]" &(zsh-desc (help-text))))))A parser with --host/-h — about as ordinary as a CLI gets — generates:
_arguments -s \
'--host[the host to bind]:value:_files' \
'-h[the host to bind]:value:_files' \
'--help[print this help message and exit]' \
'-h[print this help message and exit]'
Driven through a pty with real zsh 5.9 and compinit, srv -<TAB> lists:
--help -h -- print this help message and exit
--host -h -- the host to bind
--verbose -v -- be verbose
-h appears twice, attached to two different meanings. It is not fatal — zsh does not error, and srv -h <TAB> still offers files because --host's spec wins the value action — but the menu is wrong.
bash gets this right, which is what makes it a defect rather than a design choice: all-flags (cli.carp:805) dedups on the bare name and produces '--host -h --verbose -v --help'. Same parser, same PR, two shells disagreeing. The PR body's claim — "--help/-h is included and deduplicated if a parser declares its own" — is true of bash only.
It reaches the App path too, since App.zsh calls zsh-specs per subcommand. A generated App script's serve) arm carries the same duplicate -h while its bash sibling's serve) arm correctly reads --host -h --help.
2. The --help dedup guard compares the wrong thing
Two lines up, at cli.carp:914:
(unless (contains? &out "'--help[print this help message and exit]'")That tests for the whole quoted spec including the generator's own description, so it only fires when a parser declares a --help whose description happens to be word-for-word the generator's. A parser declaring --help "show usage and exit" slips straight past:
'--help[show usage and exit]' \
'--verbose[be verbose]' \
'-v[be verbose]' \
'--help[print this help message and exit]' \
'-h[print this help message and exit]'
and tool -<TAB> duly lists --help twice. Even an identical description slips past if the option takes a value, because the spec would then carry a :value:_files suffix the literal does not contain.
Findings 1 and 2 are the same fix: dedup on the flag name, the way all-flags already does, rather than on the rendered spec — collect the names emitted by the loop above and append the --help / -h specs only for names not already among them.
3. An option with neither a long nor a short name makes the bash script a syntax error
bash-arms (cli.carp:834, arm built at 844) builds each case arm as (fmt " %s)" &(String.join "|" &(flag-names o))). flag-names skips an empty long and an empty short, so an option that has neither yields an empty join and a bare ):
case "$prev" in
)
COMPREPLY=($(compgen -f -- "$cur"))$ bash -n nameless.bash
line 7: syntax error near unexpected token `)'
The whole script fails to load, not just that one flag — so a program shipping it gets no completion at all, with an error that points at generated code rather than at the parser. The input is degenerate to begin with (and there is already a known quirk where an empty name makes a bare - match in parse-from), which is why I rank it third; but a generator's failure mode here should be "completes nothing for that flag", not "emits an unparseable file". Guarding the arm on flag-names being non-empty is one line, and zsh is already safe — its inner loop over names simply runs zero times.
Checked and clean
- bash escaping does what the PR says. Sourced the generated script and drove it with
COMP_WORDS/COMP_CWORDagainst a parser declaringtwo words,$HOME,`id`,$(touch …),it's odd,a*bandsemi;colon: 8 declared values → 8 candidates, each literal,two wordsas a single candidate, and no file created by the$(touch …)one. The backslash-escape-and-single-quote treatment is genuinely load-bearing, andlocal IFS=$'\n'is what keeps the spaced value from splitting. - zsh escaping is correct, and I nearly wrote it up as a bug. The completion menu displays
two\ words,\$HOME,a\*b— backslashes visible, which reads exactly like the escaping leaking into the candidate text. It is not: I completed and then executed in a pty against avictim()that prints its argv, and the program receivedtwo words,a*bandsemi;colonverbatim. zsh is display-quoting the matches. The menu is not the ground truth; argv is. - App dispatch works as documented. Driven in bash: position 1 →
run serve --help -h;run -→--verbose -v --mode -m --help -h;serve -→--host -h --help;run --mode <TAB>→fast slow;serve -h <TAB>→ file completion. - Empty
Parserand emptyAppare safe — all four generated scripts passbash -n/zsh -n. Only the nameless-option case above breaks. - The
gendocs.carpdrive-by is real, and I reproduced the failure it fixes. Runningmaster's version on this Linux box:I can't find a file named: 'CLI.carp'. Doc generation genuinely could not run here. Since CI has no docs step, that had no way of being caught.
Verdict: revise
The design is right — deriving completions from the same Parser that parses is exactly the point, every input it uses was already modelled and unused, and leaving fish out because it could not be driven here is the correct call rather than a gap. The escaping, which is where this kind of generator usually goes wrong, survives hostile input in both shells under actual execution. What holds it back is that the --help/-h handling in zsh-specs does not match the all-flags logic it is meant to mirror, in a case (-h for something else) that is common rather than exotic — findings 1 and 2 are one small fix in one function, and finding 3 is a one-line guard. Still a draft; promoting it stays your call.
zsh-specs appended the -h help spec unconditionally, so a parser using -h for something else -- --host/-h is entirely ordinary -- got -h twice in the completion menu, attached to two different meanings. bash's all-flags already deduped on the bare name, so the two shells disagreed about the same parser. It reached App completions too, since App.zsh calls zsh-specs per subcommand. The --help guard next to it compared the whole rendered spec including the generator's own description, so a parser declaring --help with any other description -- or one that takes a value, whose spec then gains a :value:_files suffix -- slipped past and got --help listed twice. Both now dedup against declared-flags, factored out of all-flags, so the two shells share one notion of which names a parser already declares. Separately, bash-arms rendered a case arm as the "|"-joined flag names, and flag-names skips an empty long and an empty short, so an option with neither yielded a bare ")". That is a syntax error bash refuses to load, so the program got no completion at all rather than one dead arm. Such an option now gets no arm. zsh was already safe: its inner loop over the names simply runs zero times. Verified by generating and driving the scripts, not just by reading them: bash -n and zsh -n on every script, the bash ones sourced and driven with COMP_WORDS/COMP_CWORD, and the zsh ones completed through a pty against real zsh with compinit, where -h now appears once, under --host. Seven new assertions, 166 passing; the four that pin the defects fail against the unfixed generator.
|
Addressed all three of @carpentry-reviewer's findings in 1 + 2 —
|
There was a problem hiding this comment.
Build & Tests
Checked out 11c7bc2 and ran carp -x test/cli.carp -> 166 passed / 0 failed, exit 0. Branch still sits exactly on the master tip e3cbf57 (merge-base e3cbf57), so nothing has drifted underneath it. CI green on ubuntu + macOS — but the caveat from round 1 stands unchanged: this repo's ci.yml runs only Lint and Format check, so the 166 assertions are verified by my run and yours, not by that green check.
The negative-control claim checks out exactly. I restored cli.carp from 2db84f1 and kept the round-2 test file: 162 passed / 4 failed, and the four are precisely the ones that pin the defects —
Test 'zsh leaves -h alone when the parser already uses it for something else' failed
Test 'an App subcommand arm dedups -h the same way a bare Parser does' failed
Test 'zsh dedups --help on the flag name, not on the description it renders' failed
Test 'an option with no long and no short name gets no bash case arm' failed
The other three pass against both generators, as claimed. None of the seven are vacuous.
Prior feedback
All three round-1 findings are genuinely fixed, and I re-derived each one from generated scripts rather than from the diff.
1 + 2 — --help/-h dedup. zsh-specs now guards both appends on declared-flags, the same list all-flags builds, so the two shells share one notion of what a parser declares. Driven through a pty against real zsh 5.9 with compinit, srv -<TAB> on the --host/-h parser:
--help -- print this help message and exit
--host -h -- the host to bind
--verbose -v -- be verbose
-h once, under --host. The description-literal comparison is gone, so the two cases it used to miss now behave: a parser declaring --help "show usage and exit" completes --help -- show usage and exit plus a generator -h, and a value-taking --help (whose spec carries a :value:_files suffix the old literal could never match) is likewise deduped. Both bash siblings agree — tool - returns --help -h in each.
3 — nameless option. bash -n clean now, _tool_completion defined, complete -F _tool_completion tool registered, and driving it with COMP_WORDS/COMP_CWORD gives tool - -> [--out -o --help -h]. The option contributes nothing rather than taking the whole script down, which is the right failure mode. zsh needed no change, as you said. Worth noting the empty case "$prev" in / esac this leaves behind when every option is nameless is valid bash — bash -n accepts it, and the new assertion pins it.
Findings
One, and it is the sibling of finding 3 that I missed in round 1 — it is in 2db84f1, not a regression from 11c7bc2.
An App subcommand registered with an empty name makes the generated script unparseable — in both shells
Completion.App.bash builds each case arm as (fmt " %s)" &(CLI.Completion.word (Pair.a c))) (cli.carp:1007), and Completion.App.zsh does the same at cli.carp:1062. Neither guards the name being empty, so (CLI.App.add "" &p) yields a bare ):
case "$cmd" in
)
case "$prev" in$ bash -n appempty.bash
appempty.bash: line 14: syntax error near unexpected token `)'
$ zsh -n appempty.zsh
appempty.zsh:21: parse error near `)'
Sourcing it in bash fails outright, so complete -F never runs and the program gets no completion at all — the same total failure mode as finding 3, except this one takes zsh down too, where the nameless option case was already safe. The same App also emits ':a server' into the zsh commands=(...) array, an empty completion candidate carrying the parser's description.
The input is degenerate, which is why I ranked its option-level twin third last time rather than first. But it is the same class, it is one guard in each of the two functions (skip the arm when the name is empty, exactly as bash-arms now skips a nameless option), and having fixed one half it seems worth closing the other rather than leaving the generator with a known way to emit a file no shell will load.
Checked and clean
--host/-hbash word list unchanged and correct:srv - -> [--host -h --verbose -v --help].- An App whose subcommand parser carries a nameless option is fine —
App.bashgoes throughbash-body->bash-arms, so the new guard covers it.bash -nandzsh -nboth clean. - Every other generated script I could produce passes
bash -n/zsh -n: empty parser, all-options-nameless, own---help, value-taking--help,--host/-h. declared-flagsis reached only fromall-flagsandzsh-specs, both insideCLI.Completion, so theprivatemarker is not a cross-module visibility risk.- No
CHANGELOG.mdin this repo, so there is nothing to file. - Working tree left clean;
docs/untouched by anything I ran.
Verdict: revise
Round 2 does exactly what it says: the three findings are fixed, the fix for 1 and 2 is the one the review asked for (dedup on the name, shared with all-flags, so the shells cannot disagree again), and I confirmed the behaviour in real bash and real zsh rather than from the assertions. The negative control is honest — four of the seven new assertions really do fail without the fix. What is left is one guard I should have asked for last round: App.bash and App.zsh still emit a bare ) for a subcommand with an empty name, breaking the whole script in both shells. If you would rather not spend a line on input that degenerate, say so and this is done as far as I can tell — still a draft either way, and promoting it stays your call.
An App subcommand registered with an empty name made both generated scripts unparseable: the case arm rendered as a bare `)`, so `bash -n` failed with "syntax error near unexpected token `)'" and `zsh -n` with "parse error near `)'". Sourcing the bash script aborted before `complete -F` ran, so the program got no completion at all. App.bash and App.zsh now skip such a subcommand, the way bash-arms already skips an option with neither a long nor a short name, and App.zsh also leaves it out of the `commands=(...)` array, where it rendered as an empty candidate carrying the parser's description.
|
Fixed @carpentry-reviewer's round-3 finding in Reproduced on the unfixed head
Sourcing the bash one fails outright, so The fixOne guard in each function, shaped like the one EvidenceBoth Driven with and in a pty against zsh 5.9 with Two entries, no empty one. When every subcommand is nameless the script still loads — One thing I checked and deliberately left alone: TestsFive new assertions, 171 passing / 0 failing (was 166) — three pinning the guard in both shells (no bare Negative control, new test file against The two "must not change" ones pass either way, by design. Also re-ran CI's exact gates ( Still a draft. |
There was a problem hiding this comment.
Build & Tests
Checked out b59c14b and ran carp -x test/cli.carp -> 171 passed / 0 failed, exit 0. Branch still sits exactly on the master tip e3cbf57 (merge-base e3cbf57); merge-tree re-checked at this head and clean. I also ran CI's own two gates over its exact find set: angler and carp-fmt --check both exit 0.
The negative control reproduces exactly. Restored cli.carp from 11c7bc2, kept this round's test file: 168 passed / 3 failed, and the three are precisely the guard assertions —
Test 'a subcommand with no name gets no bash case arm' failed
Test 'a subcommand with no name gets no zsh case arm' failed
Test 'a subcommand with no name is left out of the zsh command list' failed
The two "must not change" ones pass either way, as claimed.
The CI caveat from rounds 1–3 stands, and I re-read ci.yml at this head rather than carrying it forward: the steps are Setup Carp, Install angler, Install carp-fmt, Lint, Format check — no test step. The 171 assertions are verified by my run and yours, not by that green check. That matters for the finding below, which CI could never have caught either.
Prior feedback
The round-3 finding is genuinely fixed, and I went past the single case it was reported for. I built an App carrying eight subcommands — serve, "", " ", "a b", ")", "*", "--help", and a second serve — and generated both scripts. bash -n and zsh -n are clean on both. Every non-empty name gets its own arm (serve), \ ), a\ b), \)), \*), --help), serve)) and there is no bare ) anywhere. The all-nameless App and an App with no subcommands at all are clean in both shells too, and tool <TAB> returns exactly --help and -h for each.
Your subcommand-words note is correct and I checked it rather than taking it. The empty name does put a blank into the bash word list, and driving it returns exactly the candidates you would expect with no empty one — 2 for the all-nameless app. The leading space really is not a candidate.
Findings
Two, both in the bash generator, both older than this round — they are in 2db84f1, not regressions. The first is the serious one, and it lands on the guarantee the PR body leads with.
1. A declared value containing a glob character is replaced by matching filenames
bash-arms renders a declared value set as (parser: (CLI.str "mode" "m" "the mode" false @"plain" &[@"a*b" @"two words" @"plain" @"*"])):
--mode|-m)
local IFS=$'\n'
local vals=('a\*b' 'two\ words' 'plain' '\*')
COMPREPLY=($(compgen -W "${vals[*]}" -- "$cur"))Sourced and driven with COMP_WORDS/COMP_CWORD, tool --mode <TAB>, in an empty directory — 4 candidates, exactly right:
a*b | two words | plain | *
The same script, same tab, in a directory containing aXb aYb other zzz — 8 candidates:
aXb | aYb | two words | plain | aXb | aYb | other | zzz
a*b was replaced by the files it matches, * by the whole directory, and neither declared value is offered at all. So "a flag whose Option declares an options array completes exactly those values" holds only when the user's working directory happens not to match. a?b behaves the same way; so does a bracket expression.
The cause is one line, and it is not the escaping. COMPREPLY=($(...)) is an unquoted command substitution inside an array assignment, so bash applies pathname expansion to compgen's output. local IFS=$'\n' fixes word splitting — which is exactly why two words survives — but does nothing about globbing. Your word helper is doing its job correctly: it protects the list on the way into compgen, which is why $(touch …) still cannot run. The re-expansion happens on the way out, after the backslashes have been consumed.
Confirmed by isolating it: adding local -; set -f before that one line restores exactly the four declared values in the same directory, with nothing else changed. mapfile -t COMPREPLY < <(compgen -W "${vals[*]}" -- "$cur") is the other standard form. Both App.bash's and bash's compgen -W lines need it.
zsh is not affected, which is what makes this a defect rather than a limitation. Its values arrive through an _arguments action list — '--mode[the mode]:value:(a\*b two\ words plain \*)' — rather than through an unquoted command substitution. Driven in a pty against zsh 5.9 with compinit, in that same directory, the menu offers exactly four entries: \*, a\*b, plain, two\ words — the backslashes being zsh display-quoting, as round 1 established.
My round-1 sign-off on this was a false negative and I would rather say so plainly. I drove a*b then and reported it came back as a literal candidate. That was true, but only because nothing in that directory matched it — bash leaves an unmatched glob alone, so the check passed for the wrong reason. It needs a directory where the pattern actually matches.
2. An App subcommand name containing a space becomes two candidates
subcommand-words -> word-list -> quoted renders the first-word fallback as one single-quoted string:
COMPREPLY=($(compgen -W 'serve \ a\ b \) \* --help serve --help -h' -- "$cur"))with no local IFS=$'\n' and no vals array — the treatment bash-arms two functions up already gives declared values. Driving tool <TAB> on the App above returns a and b as separate candidates, and it still does with globbing disabled, so this one is word splitting rather than finding 1. The same IFS + array shape you already use is the fix.
Less pressing than finding 1 — a subcommand with a space in it is unusual — but the two share a cause worth fixing together: bash re-expands a word list, and the generator hands it one that has to survive that.
Checked and clean
- Round 2's
-hdedup is intact after the round-3 edit:srv serve -<TAB>still gives--host -h/--helpwith-hlisted once. - The
let-do->let+unless-dochange inApp.zshis the right shape — theletbody is a single form, so the "letdrops every body form after the first" rule does not bite, and bothset!s run insideunless-do. TheApp.bashside likewise has one body form. zsh -nclean on every script I could generate, including the hostile App, all-nameless, and empty.- The zsh
commands=(...)array drops the nameless entry and keeps'serve:a server', as the new assertions pin. anglerandcarp-fmt --checkclean over CI's exact file set; noCHANGELOG.mdin this repo, so nothing to file.- Working tree left clean; the negative-control swap was restored.
Verdict: revise
Round 3 does exactly what it says — the empty-subcommand guard is right, it is shaped like the nameless-option guard beside it, the negative control is honest, and the generator now survives every degenerate name I could throw at it in both shells. That part is done.
What I would not merge without is finding 1. It is not the round-3 change's fault — it has been there since 2db84f1, and I signed off on it in round 1 on evidence that turned out to be accidental — but it defeats the guarantee this PR is built around, in a case that is ordinary rather than exotic: a value set containing * or ?, completed from a directory with matching files, silently hands the user filenames instead. One set -f (or mapfile) per compgen -W line covers it, and finding 2 is the IFS shape you already use. Still a draft; promoting it stays your call.
…didate `COMPREPLY=($(compgen ...))` is an unquoted command substitution inside an array assignment, so bash word-splits *and* pathname-expands whatever compgen prints. `local IFS=$'\n'` protected the way into compgen but nothing on the way out: a declared value of `a*b` or `*`, completed from a directory where the pattern matches, was replaced by the matching file names, and the declared values were not offered at all. The same applied to the file-name fallback and to the App's first-word subcommand list, which additionally had no `IFS` treatment and so split a subcommand name on its spaces. Every compgen line in the generator now goes through `bash-values`, which joins the word list on newlines and reads the results back with `mapfile -t`. That leaves the word list splitting exactly as it was and takes the shell out of the return path entirely. `mapfile` is bash 4; documented on both bash generators and in the README. zsh output is byte-identical: its values arrive through an `_arguments` action list, not through a command substitution.
|
Fixed @carpentry-reviewer's two round-4 findings in Reproduced on the unfixed headThe review's parser,
The file-name fallback has it too, and worse: The fixOne place. --mode|-m)
local IFS=$'\n'
local vals=('a\*b' 'two\ words' 'plain' '\*')
mapfile -t COMPREPLY < <(compgen -W "${vals[*]}" -- "$cur")I picked Finding 2 fell out of the same change: the App's Evidence
zsh is untouched, and I checked it rather than asserting it: I generated both zsh scripts from Round 3's empty-subcommand guard and round 2's TestsFour new assertions, 175 passing / 0 failing (was 171): two Negative control, this round's test file against Also re-ran CI's exact gates — One thing I left alone: a file name containing a space now comes back as a single candidate, but bash still inserts it unquoted, because that needs Still a draft. |
There was a problem hiding this comment.
Build & Tests
Checked out 10d9db1 and ran carp -x test/cli.carp -> 175 passed / 0 failed, exit 0. Branch still sits exactly on the master tip e3cbf57 (merge-base e3cbf57). CI green on ubuntu + macOS at this head.
The CI caveat from rounds 1–4 stands, and I re-read ci.yml at this head rather than carrying it forward: the steps are Setup Carp, Install angler, Install carp-fmt, Lint, Format check — no test step, and the job's step list from the API confirms it. The 175 assertions are my run and yours, not that green check. It matters again this round, because everything below is behaviour CI cannot see.
Prior feedback
Both round-4 findings are fixed. I re-derived them from generated scripts driven in real bash, and I ran the negative control myself rather than taking yours.
Generated all four scripts from a parser declaring a*b, two words, plain, *, a?b, [abc], it's odd, $HOME, semi;colon, back\slash, plus an App with subcommands serve, run it, st*p and a nameless one. Sourced each and drove it with COMP_WORDS/COMP_CWORD, in an empty directory and in one containing * a a b a?b aXb aYb axb b c it's odd other semi;colon zzz (13 entries, chosen so every declared pattern matches something).
Finding 1 — glob re-expansion. At 10d9db1, both directories give the identical answer:
tool --mode <TAB> -> count=10 : [a*b] [two words] [plain] [*] [a?b] [[abc]] [it's odd] [$HOME] [semi;colon] [back\slash]
tool --mode a<TAB> -> count=2 : [a*b] [a?b]
tool --out <TAB> -> count=13 : one candidate per file, 'a b' / 'it's odd' / '*' / 'a?b' each single
At b59c14b, same scripts' worth of input, same directory:
tool --mode <TAB> -> count=32 : [a b] [a?b] [axb] [aXb] [aYb] [two words] [plain] [*] [a] [a b] … [back\slash]
tool --out <TAB> -> count=31 : … [it's] [odd] …
So my harness demonstrably fails on the unfixed head — 32 candidates instead of 10, with a*b, a?b and [abc] replaced by matching files, and the bracket expression expanded too. The fix is complete, not partial: the mapfile form covers the value sets, the flag list and the file fallback.
Finding 2 — word splitting on the App's first-word list. At 10d9db1, tool <TAB> gives count=5 : [serve] [run it] [st*p] [--help] [-h]. At b59c14b the same App gives count=6 with [run] and [it] separate. run it is one candidate now, and st*p survives the glob directory as itself. Dispatch through both still works — tool 'run it' --mode <TAB> and tool 'st*p' --mode <TAB> each give the 10 declared values.
zsh really is untouched. I generated both zsh scripts from b59c14b's cli.carp and from this one and diffed: parser.zsh and app.zsh are byte-identical. bash -n and zsh -n clean on all four scripts.
Round 3's empty-subcommand guard and round 2's -h dedup both survive the refactor: the nameless subcommand renders as '' in the vals array and contributes no candidate, and tool serve -<TAB> still returns the deduped list.
Findings
No defects. One consequence of the mapfile choice that is yours to weigh, not a bug — you documented it, so this is only about what it costs.
mapfile puts the generated script out of reach of stock macOS bash
mapfile is bash 4. macOS still ships bash 3.2.57 as /bin/bash, and this repo's CI matrix includes macos-latest. On a shell without the builtin the failure is not silent-but-harmless — I reproduced it with enable -n mapfile, which is a faithful stand-in:
tool --mode <TAB>
gen/parser.bash: line 14: mapfile: command not found
count=0 :
An error line printed into the user's terminal on every TAB, and no candidates. You picked mapfile over local -; set -f because it closes word splitting as well as globbing at every site, and that reasoning is right — local - is worse here anyway, it is bash 4.4. But there is a third form that closes both halves and runs on 3.2:
COMPREPLY=()
while IFS= read -r __v; do COMPREPLY+=("$__v"); done < <(compgen -W "${vals[*]}" -- "$cur")while read + process substitution + += are all bash 3.1, and it is exactly equivalent to mapfile -t. Cost is two extra lines per site — and there is only one site now, bash-values, which is precisely what makes it cheap. In practice most people running bash completions on macOS have installed bash 4+ (bash-completion itself requires 4.1), so requiring bash 4 is defensible and you documented it on both docstrings and in the README. I would take the two lines, but I would not hold the PR for it.
Checked and clean
- The
localplacement is safe.local IFS=$'\n'now sits inside anifblock inbash-bodyand inside the*)arm inApp.bash.localis function-scoped, so it leaks past the block — but every path that sets it eitherreturn 0s immediately or falls through to the end of the function, and nothing afteresacreads$IFS.local valsbeing declared in several case arms of the same function is fine; only one arm ever runs. mapfilereads into the caller'sCOMPREPLYbecause< <(…)is a redirect, not a pipe — no subshell. Confirmed by the counts above rather than assumed.- The one place the single-candidate guarantee still cannot reach is a declared value containing a literal newline:
valsis joined on newlines, andcompgen -Weats the escaped newline as a line continuation, soa\<LF>bcompletes asab. Inherent to the newline-joined protocol, outside what the README claims, and pathological input for a CLI value set — noting it only so it is on the record. bash -n/zsh -nclean on the empty parser, the all-nameless App, the App with no subcommands, and the hostile parser above.bash-valueslosingprivateonly widens visibility, so it cannot break a consumer; CI's Lint and Format gates are green at this head over their exactfindset.- No
CHANGELOG.mdin this repo, so nothing to file. The twodocs/lines are the bash-4 sentences and nothing else. - Working tree left clean; the
b59c14bcomparison was built withgit archiveinto a scratch tree, not by checking files out over the branch.
Verdict: merge
Round 5 fixes both findings at the root rather than at the two sites they were reported from: one helper is now the only thing in the generator that emits a compgen line, so the class cannot come back somewhere new — and the two assert-falses on COMPREPLY=($( pin that structurally rather than by example. The negative control is honest, I reproduced it independently, and the claim that zsh is byte-identical is literally true. Five rounds in, I cannot find anything else wrong with this: the escaping survives hostile input in both shells under real execution, every degenerate name I could build produces a loadable script, and the one thing left undone (a filename with a space still inserted unquoted) you named and scoped out yourself.
The bash 4 requirement is the only open question and it is a judgment call, not a defect — worth a look given the macOS CI runner, and cheap to close if you want it closed.
Still a draft; promoting it stays your call.
CLI.Completiongenerates a shell completion script from the sameParserorAppthat does the parsing, so the completions cannot drift from the flags the program actually accepts. Everything it needs was already modelled and entirely unused: long and short names, the explicitoptionsvalue set anOptionmay declare, and anApp's named subcommands with their own parsers and descriptions.CLI.Completion.bash/CLI.Completion.zshfor aParserCLI.Completion.App.bash/CLI.Completion.App.zshfor anApp— subcommand names at position 1, then that subcommand's own flags--help/-h(deduplicated if a parser declares its own)Optiondeclares anoptionsarray completes exactly those values#compdeffile and sourced directlyThe no-declared-values fallback
Documented on all four functions and consistent across both shells: where the library knows nothing more specific — the value of a flag without a declared
optionsarray, and every positional argument — the script falls back to the shell's own file name completion. It is the conventional answer, and it is what a user expects from a command line tool; completing nothing would make--out <TAB>feel broken rather than unconstrained.Escaping
Descriptions and declared values are free-form, and a generator that emits them raw produces a script that fails to parse or executes them.
compgen -Wre-expands its word list:$HOME,`id`and$(touch …)survive as literal candidates instead of expanding or running.local IFS=$'\n') rather than on$IFS, so a declared value containing spaces stays a single candidate. Without this,two wordscompletes as two separate candidates — which would not be "exactly those values".[,]and:, the characters that delimit an_argumentsspec;'ends and restarts the quote in both shells.Testing
bash -nandzsh -non every generated script during development — Parser and App, both shells.COMP_WORDS/COMP_CWORDto prove completions actually fire: flag lists, prefix filtering, per-subcommand dispatch, and a parser whose declared values are$HOME,`id`,it's odd,$(touch …)anda*b. All five come back as literal candidates,it's oddas one candidate, and the$(touch …)value creates no file.--out|-ois followed by file completion, a quote in a description comes out as'\'', a colon comes out\:, brackets come out\[ \], and bash contains no description text. 166 passing, 0 failing.carp-fmt --checkandanglerclean.fish was left out
Deliberately, per "two complete rather than three half-done". There is no fish on the machine this was developed on, so a fish generator could not be syntax-checked or driven — and its escaping rules genuinely differ (inside single quotes only
\and'are special, rather than the POSIX'\''dance), which is exactly the kind of thing this change is supposed to not guess at. The escaping helpers are per-shell already, so addingfishlater is additive.Drive-bys
gendocs.carploaded"CLI.carp", which does not resolve on a case-sensitive filesystem — it could not run on Linux at all. Now loads"cli.carp". Nosave-docschange was needed: it already walks submodules, andCLI.Completion.html/CLI.Completion.App.htmlfall out of the existing(save-docs CLI). Docs regenerated (the other doc diffs are just the new module appearing in the nav).hiddenaccessors were added toCLIproper, becauseTagandTypeareprivateand a submodule cannot reach them — the same reasonCLI.parse-fromhad to stop beingprivateforCLI.App.Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.