Skip to content

Short flag bundling: -av, -n5, -avn 5 - #11

Merged
hellerve merged 1 commit into
masterfrom
claude/short-flag-bundling
Jul 27, 2026
Merged

Short flag bundling: -av, -n5, -avn 5#11
hellerve merged 1 commit into
masterfrom
claude/short-flag-bundling

Conversation

@carpentry-agent

Copy link
Copy Markdown

ls -la, tar -xzf, grep -rn — the way short options are actually typed — did not work. On master, with (CLI.bool "all" "a"), (CLI.bool "verbose" "v") and (CLI.int "num" "n" … false):

token before after
-a -v all, verbose unchanged
-av Unknown option: -av all, verbose
-n5 Unknown option: -n5 num = 5
-avn 5 Unknown option: -avn all, verbose, num = 5
-avn5 Unknown option: -avn5 all, verbose, num = 5

Every token this affects errored before, so nothing that worked can regress.

How it decides

CLI.parse-from looks 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.str takes an arbitrary short string and CmdMap.contains? matches long or short, so -thing and -th may 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 with No value for: n. A value-taking option in the middle swallows the remainder — -amb is -a -m b — which is what getopt does.

Three deliberate choices worth a look, since none of them are forced:

  • Single dash only. --av stays Unknown 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/-help are still checked before decomposition. So they keep signalling help even if some option declares a value-taking h short — where getopt would read -help as h = "elp". Inside a bundle, -ah does raise the help signal, as -h does.
  • All-or-nothing. An unknown letter fails the whole token with today's wording (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.App routes 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, -avn with no value left, -h/-ah, a bundle after -- (positional, no flags set), -5 still 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 --check and angler are clean on both files. Docs regenerated with gendocs.carp (its (load "CLI.carp") only resolves on a case-insensitive filesystem, so I generated through a temporary copy — regenerating from unmodified master reproduces 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.

`-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.

@carpentry-reviewer carpentry-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-5num = -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.

@hellerve
hellerve marked this pull request as ready for review July 27, 2026 19:50
@hellerve
hellerve merged commit e3cbf57 into master Jul 27, 2026
2 checks passed
@hellerve
hellerve deleted the claude/short-flag-bundling branch July 27, 2026 19:51
@carpentry-agent carpentry-agent Bot mentioned this pull request Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant