Short flag bundling: -av, -n5, -avn 5 - #11
Conversation
`-av`, `-n5` and `-avn 5` were all rejected as unknown options, so the invocation style every POSIX tool accepts did not work even though the library advertises short flags. A single-dash token is now decomposed into single-character short options when — and only when — it matches no option exactly. That exact lookup still runs first, so multi-character short names, long names given with a single dash, `--long=value` and `-n=7` are untouched, and `--` end-of-options plus negative-number positionals never reach the flag branch at all. Booleans continue a bundle; the first value-taking option ends it and takes the rest of the token, or the next token, as its value; an unknown letter rejects the whole token without applying any of it.
There was a problem hiding this comment.
Build & Tests
carp -x test/cli.carp on 9202845: 140 passed / 0 failed, matching the PR body. CI is green on ubuntu and macOS, and the run's head_sha is 9202845 — it ran on the state under review, not an earlier one.
I checked the differential claim rather than taking it: with cli.carp reverted to master and the new tests left in place, 13 fail (-av, -alv, -avn5, -avn 5, -n5, -avn with no value, -ah help, -ta, -amb, -am b, both option-set assertions, and the subcommand bundle). The tests exercise the new path rather than passing vacuously.
The branch is not behind master, so nothing is being measured against a stale base.
Findings
I hand-probed the paths the suite doesn't reach. Clean on all of them: give-back with a trailing positional (-av target, -n5 target, -avn 5 target all leave target as the positional); -n-5 → num = -5; repeated letters (-aa, -aavv); a 200-character all-boolean bundle and a 200-character bundle ending in n5; a tab in the token. Multi-byte shorts are rejected rather than misread — -aä, -ä and -avän5 each come back Unknown option: … with no crash, which is the thing to check given the walk slices bytes.
Two notes, neither blocking:
= anywhere in a bundle rejects the whole token (cli.carp:462). The branch is gated on (<= (length &splt) 1), so -n=5 works (exact match) but -an=5 is Unknown option: -an=5. getopt would read that as -a plus n = "=5". The behaviour is defensible and the PR body mentions "no = in it", but the README's bundling section doesn't — and given -n=5 works, the asymmetry is the kind of thing someone hits and files. One sentence in the README would cover it.
bundle-stop classifies with the strict short? but decides and writes with the loose type?/put!. CmdMap.short? (new, cli.carp:179) matches short names only, while type? and put! match long or short. With a one-character long name that collides with another option's short name — (CLI.bool "a" "A") plus (CLI.str "zeta" "a" … ) — the letter a in a bundle is classified boolean because the other option's long name is a, and put! then writes to that other option:
-Aa x => ERR Unexpected argument: x (both letters set `a`; `zeta` never set)
This is not a regression: un-bundled -a x gives the identical wrong result on master, because the long/short conflation is baked into CmdMap (the comment at cli.carp:141-143 owns up to it). Worth recording only so that if contains?/put! are ever tightened, bundle-stop gets the same treatment.
For completeness: -avn -l sets num = 0 by swallowing -l as the value, and -avn -- eats the end-of-options marker. Both are exactly what -n -l and -n -- already do on master, and getopt behaves the same way, so I don't think either is actionable.
Verdict: merge
The parse ordering is what makes this safe — the whole token is looked up first, so every token the decomposition can reach is one that errored before, and the +368/−0 diff is honest. The implementation is correct and I could not break it. The three choices you flagged (single-dash only, -h before decomposition, value-option-in-the-middle swallows the rest) all match getopt, so from a correctness standpoint there is nothing to settle — undraft whenever you're happy with them. Left as a draft, untouched.
ls -la,tar -xzf,grep -rn— the way short options are actually typed — did not work. Onmaster, with(CLI.bool "all" "a"),(CLI.bool "verbose" "v")and(CLI.int "num" "n" … false):-a -v-avUnknown option: -av-n5Unknown option: -n5-avn 5Unknown option: -avn-avn5Unknown option: -avn5Every token this affects errored before, so nothing that worked can regress.
How it decides
CLI.parse-fromlooks the whole token up first, exactly as it always did. Only a token that matches nothing — no=in it, single dash, non-empty — is decomposed into single characters. Each character must be the short name of a declared option, and a short name is only usable in a bundle if it is one character long.That ordering is what keeps this backward compatible:
CLI.strtakes an arbitrary short string andCmdMap.contains?matches long or short, so-thingand-thmay already be legitimate exact hits today, and they still win.Walking the token: a boolean is set and the walk continues; the first option that takes a value ends the bundle and reads the rest of the token as its value (
-n5), or the next argument if nothing is left (-avn 5), or fails withNo value for: n. A value-taking option in the middle swallows the remainder —-ambis-a -m b— which is what getopt does.Three deliberate choices worth a look, since none of them are forced:
--avstaysUnknown option: --av. Bundling is a single-dash idiom, and decomposing a long-option-shaped token would be surprising. This is stricter than strictly necessary.-h/-helpare still checked before decomposition. So they keep signalling help even if some option declares a value-takinghshort — where getopt would read-helpash = "elp". Inside a bundle,-ahdoes raise the help signal, as-hdoes.Unknown option: -avq) and applies nothing from it — the letters are classified before any of them is set. Since any parse error discards the value map, partial application is not observable through the API; the tests pin the message and the implementation makes the guarantee real.Untouched:
--end-of-options, negative-number positionals (-5,-3.14),--long=value,-n=7, a bare-, long names given with a single dash, and the boolean space-separated give-back. Required-option and option-set validation run on bundled values like any other.CLI.Approutes through the same function, so bundles work after a subcommand.Testing
109 → 140 assertions, all passing (
carp -x test/cli.carp). With the implementation stashed but the tests in place, 13 fail — the new tests do exercise the new behaviour rather than passing vacuously.Beyond the cases above they cover: three-boolean bundles, unknown letter leading and mid-token,
-avnwith no value left,-h/-ah, a bundle after--(positional, no flags set),-5still a positional with short flags declared, an exact multi-character short beating decomposition, a bare-,--av, required-option and option-set validation through a bundle, and a bundle after a subcommand.Hand-probed for crashes as well, since the walk indexes bytes: 200-character bundles, multi-byte UTF-8 shorts (
-ästill an exact hit;-aärejected, not misread), tabs,-=,-a=, and attached values that begin with a dash (-n-5→-5).carp-fmt --checkandanglerare clean on both files. Docs regenerated withgendocs.carp(its(load "CLI.carp")only resolves on a case-insensitive filesystem, so I generated through a temporary copy — regenerating from unmodifiedmasterreproduces the committed HTML byte for byte, so the only diff is the new paragraph). No CHANGELOG in this repo, so none added.Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.