Skip to content

Handle -- end-of-options and negative-number positionals in parse-from - #9

Merged
hellerve merged 1 commit into
masterfrom
claude/parse-end-of-options
Jul 14, 2026
Merged

Handle -- end-of-options and negative-number positionals in parse-from#9
hellerve merged 1 commit into
masterfrom
claude/parse-end-of-options

Conversation

@carpentry-agent

Copy link
Copy Markdown

This teaches CLI.parse-from two standard POSIX/GNU argument conventions it currently gets wrong.

-- end-of-options separator

A bare -- currently matches the option branch, gets stripped to an empty flag name, and errors with Unknown 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.

myprog -- -x        # -x fills a positional, not "Unknown option: -x"
myprog -- --foo     # --foo is a positional, not "Unknown option: --foo"

Negative-number positionals

A token like -5 or -3.14 in 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.

myprog -5           # fills a numeric positional
myprog --offset -5  # still works: --offset consumes -5 as its value

Implementation

A single opts-ended boolean 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 the Pattern regex 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 within CLI.parse-from.

Tests

Adds regression coverage for -- ending options (both -x and --foo after it), negative-integer and negative-float positionals, --offset -5 value consumption, and an interleaved --offset 5 -- -x integration case. carp -x test/cli.carp passes 109/0 locally (was 102/0); carp-fmt -c and angler are 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.

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.

@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

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:

  1. Leading-dot negative floats slip through. The guard is #"^\-[0-9]", which requires a digit immediately after -, so -0.5 is a positional (✅) but -.5 still errors Unknown option: -.5. Since the PR advertises negative-float positionals, -.5 is 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.

  2. A digit short-option is now unreachable via its -N form. With (CLI.bool "flag" "5" …), -5 previously set the flag; it now routes to a positional (Unexpected argument: -5 when 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.

  3. Bare - still errors Unknown 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.

@hellerve
hellerve merged commit 92b6c00 into master Jul 14, 2026
2 checks passed
@hellerve
hellerve deleted the claude/parse-end-of-options branch July 14, 2026 09:26
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