Handle -- end-of-options and negative-number positionals in parse-from - #9
Conversation
A bare -- now terminates option parsing: it is consumed and every later token is routed to a positional even if it begins with -. A token shaped like a negative number (- followed by a digit, e.g. -5 or -3.14) is likewise treated as a positional rather than an unknown option, since CLI options are always named. Value-taking options still consume a following negative number as their value. All prior behavior is preserved; adds regression tests for both fixes.
There was a problem hiding this comment.
Build & Tests
Checked out the branch and ran carp -x test/cli.carp: 109 pass / 0 fail (was 102/0). carp-fmt -c clean; both CI jobs (ubuntu, macos) pass. The 7 new tests are well-targeted and cover the advertised behavior directly.
I read the whole parse-from loop (cli.carp:390-500) and then tried hard to break it by driving flat-parse with adversarial argument vectors. Everything the PR claims holds up:
| args | result | |
|---|---|---|
-- -x / -- --foo |
token becomes a positional | ✅ |
-- -- x |
second -- is a literal operand |
✅ |
-5, -3.14, -0, -0.5 |
fill positionals | ✅ |
--offset -5 and --offset=-5 |
-5 consumed as the value |
✅ |
-5 with no positional slot |
Unexpected argument: -5 |
✅ better than the old Unknown option |
-- alone, required positional |
Required argument missing |
✅ -- consumed, not stored |
The boolean speculative-consume/give-back path interacts correctly with negatives too (a boolean flag followed by -5 gives the token back, and -5 then lands as a positional). No regressions in the stated scope.
Findings
No correctness bugs in what the PR sets out to do. Three non-blocking boundary observations, ranked by how close they sit to the advertised feature:
-
Leading-dot negative floats slip through. The guard is
#"^\-[0-9]", which requires a digit immediately after-, so-0.5is a positional (✅) but-.5still errorsUnknown option: -.5. Since the PR advertises negative-float positionals,-.5is arguably in scope. Widening to#"^\-\.?[0-9]"would cover it. Minor — most people write-0.5— but it's the one gap directly on the feature boundary. -
A digit short-option is now unreachable via its
-Nform. With(CLI.bool "flag" "5" …),-5previously set the flag; it now routes to a positional (Unexpected argument: -5when there's no slot). This is the conventional, defensible tradeoff — digit short-flags are inherently ambiguous with negative numbers, which is exactly why well-behaved parsers reserve them — so I'd call it intended, not a bug. Flagging only so the behavior change is a conscious one; a line in the docstring noting digits can't be short options would make it explicit. -
Bare
-still errorsUnknown option: -. Pre-existing (not touched or regressed by this PR), but it's in the same family: conventionally a lone-means stdin/stdout and should fill a positional. Natural follow-up if you want the dash-token handling to be complete, out of scope here.
Incidental nicety: dropping the redundant (or (starts-with? x "--") (starts-with? x "-")) to a single starts-with? is a clean simplification (the first disjunct always implied the second).
No CHANGELOG in this repo, so nothing to update.
Verdict: merge
Two correct, well-tested POSIX/GNU conventions with no regressions to their scope, green locally and in CI. The three notes above are edge cases at the feature boundary; #1 (-.5) is the only one I'd genuinely consider folding in, and none block merging.
This teaches
CLI.parse-fromtwo standard POSIX/GNU argument conventions it currently gets wrong.--end-of-options separatorA bare
--currently matches the option branch, gets stripped to an empty flag name, and errors withUnknown option: --. Now a bare--terminates option parsing: it is consumed (not stored) and every token after it is treated as a positional/operand, even if it begins with-. A second--after the first is a literal operand, per POSIX.Negative-number positionals
A token like
-5or-3.14in positional position was misread as an unknown flag (Unknown option: -5). Since options are always named (--long/-letter), a token shaped like a negative number (-followed by a digit) cannot be an option, so it now fills a positional.Implementation
A single
opts-endedboolean threads through the main arg loop. When it is set (by a bare--), every token routes to the positional path. Otherwise a--sets it, a negative-number-shaped token (matched with thePatternregex already used a few lines up) routes to the positional path, and everything else parses as an option exactly as before.All existing behavior is preserved:
--k=v,--k v, boolean flags (including the speculative-consume/give-back logic),-h/--help, and every required/invalid/overflow check are unchanged.App.parse-from’s deliberate rejection of a leading option before a subcommand is untouched — the fix is entirely withinCLI.parse-from.Tests
Adds regression coverage for
--ending options (both-xand--fooafter it), negative-integer and negative-float positionals,--offset -5value consumption, and an interleaved--offset 5 -- -xintegration case.carp -x test/cli.carppasses 109/0 locally (was 102/0);carp-fmt -candanglerare clean.Bundled short flags (
-abc) were considered but left out: they are ambiguous without per-flag type knowledge and would add risk, so this PR stays focused on the two unambiguous correctness wins.Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.