Add subcommand support (CLI.App)#8
Conversation
Introduces CLI.App, a purely-additive abstraction for tools shaped like `git commit` / `git push`: a program description plus a set of named subcommands, each an independent Parser with its own options and positionals. App.parse selects the subcommand from the first token, parses the remainder with that subcommand's Parser, and returns a Pair of the chosen subcommand name and its parsed value map (or a descriptive error for a missing, unknown, or itself-erroring subcommand). The flat parser is unchanged for callers: Parser.parse now delegates to a new public parse-from that parses an explicit argument array (the same entry point subcommand dispatch and tests use), and every existing test still passes. Sub-parsers are boxed ((Box CLI.Parser)) to give the Pair struct pointer indirection, which the C backend needs to emit the nested value types. Adds tests for dispatch, per-subcommand option/positional parsing, unknown/ missing subcommand and propagated parse errors, plus regression coverage for the flat parser via parse-from, and a README section with a git-like example.
There was a problem hiding this comment.
Build & Tests
Checked out claude/subcommand-support, built and ran it with the local Carp toolchain.
- Build: clean.
- Tests:
carp -x test/cli.carp→ 95 passed, 0 failed, matching the PR description. - CI: both
test (ubuntu-latest)andtest (macos-latest)are green — but note the CItestjob only runs angler (lint) and carp-fmt (format check); it does not execute the test suite. Green CI confirms lint/format only, so I ran the tests locally to confirm they pass.
Findings
The new CLI.App layer is well-designed and I couldn't break the new logic. I exercised it beyond the suite — =-form options through dispatch (commit --message=hi), trailing/unexpected args propagating the subcommand's error (commit --message hi extra → Unexpected argument: extra), short bool + positional (push -f origin), bareword-only positional, per-subcommand --help, empty/unknown/leading-option errors, duplicate registration, and usage/usage-for. All correct, and the error protocol is consistent. The parse-from extraction is faithful (the diff is just i 1 → i 0 + StaticArray→Array), and boxing the sub-parser is the right call for the incomplete-type reason you gave.
One serious issue, and it sits squarely in the code path this PR promotes:
1. Infinite loop on --flag=value for boolean flags (cli.carp:420). A boolean flag written in = form hangs the parser at 100% CPU, forever. Repro:
(CLI.parse-from &p &[@"--force=false"]) ; p has (CLI.bool "force" ...) → never returns
(CLI.App.parse-from &app &[@"push" @"--force=false"]) ; same hang through a subcommandRoot cause: for --force=false, splt has length 2, so v is bound to Just "false" (line 410) without incrementing i. The boolean branch then hits
(when (and (Maybe.just? &v) (> (length &splt) 1)) ; line 420
(set! i (Int.dec i)))and decrements i — but i was never incremented, so it now points before the flag. The for loop's end-of-iteration +1 lands back on the same --force=false token, re-processed identically → infinite loop.
The guard is inverted. That decrement exists to "give back" a token that the space-separated form (lines 411–414) speculatively consumed; that form only fires when splt length ≤ 1, so the condition should be (<= (length &splt) 1), not (> (length &splt) 1).
To be clear, this is pre-existing — I reproduced the identical hang on e15cef8 (before this PR) through the old flat parse, and the diff doesn't touch the boolean logic. But this PR is the moment it starts to matter: it extracts this loop into a new public parse-from, routes every subcommand's arguments through it, and advertises "regression coverage for the flat parser" — yet the new tests have no boolean-= case, so the hang slips through. A one-line test (CLI.parse-from &p &[@"--force=false"]) catches it and guards the fix.
2. Two related quirks from the same inverted guard (lower severity):
- Bare boolean swallows the next token:
--force extra(bool not last) eatsextraas the flag's value and silently drops it instead of treating it as a positional / unexpected arg. Flipping the guard in (1) fixes this too. --force=falsevalue is ignored: the boolean branch hardcodes(CmdMap.put! &values k "true")(line 422), so even once the loop is fixed,--force=falsestill yieldstrue, contradicting the README ("read as true unless it's the stringfalse"). Passing the actual value there instead of the literal"true"would honor it.
3. Minor / cosmetic:
App.usage-forreusesCLI.usage, whose header printsSystem.args[0]only, so a subcommand's usage readsusage: mytool [-m | --message]with nocommitin it. The detailed options are correct — just the header omits the subcommand name.- Duplicate
App.addunder the same name silently shadows (first wins —index-ofreturns the first match). Fine to leave; worth a note.
No CHANGELOG exists, and the README gains a thorough App section — docs are in good shape.
Verdict: revise
The subcommand feature itself is solid and I found no bug in the new App logic. But this remains a draft with real design questions only you can answer (naming, global options before the subcommand, per-subcommand --help), and since the PR promotes the flat parse loop into a public parse-from reachable from every subcommand, the pre-existing --flag=value boolean infinite loop is worth fixing — or at least test-guarding — here rather than shipping a public hang. The inverted guard on cli.carp:420 is a one-line fix that also resolves the two related boolean quirks.
A boolean flag written in `=` form (e.g. `--force=false`) hung the parser at 100% CPU forever: the guard that gives back a speculatively-consumed token was inverted, so the `=` form — which consumes no extra token — decremented the index and the loop re-read the same token every iteration. Because App routes every subcommand's arguments through parse-from, this was reachable as `push --force=false` too. Flip the guard to fire only for the space-separated form (splt <= 1), which also stops a bare boolean that isn't the last token from swallowing the following token. Store the actual `=value` for booleans instead of a hardcoded "true", so `--force=false` now reads as false per the README. Adds regression tests through both CLI.parse-from and CLI.App.parse-from covering =false/=true, the bare-boolean-plus-positional case, and the subcommand path.
|
Pushed a fix for the Finding #1 (the infinite loop) and #2 (the related boolean quirks) are addressed by the same one-line inverted-guard flip you identified, plus honoring the
Added regression tests through both Left untouched for you: the cosmetic |
There was a problem hiding this comment.
Build & Tests
Re-checked out claude/subcommand-support at 47eca39, built and ran it locally.
- Build: clean.
- Tests:
carp -x test/cli.carp→ 100 passed, 0 failed (up from 95 — 5 new boolean regression tests). - CI: both
testjobs green (that job runs angler + carp-fmt only; I ran the suite locally).
Prior feedback
Commit 47eca39 addresses the two boolean findings from the last round:
- Finding #1 (infinite loop on
--flag=valuefor booleans) — fixed and verified. The inverted guard atcli.carp:423is now(<= (length &splt) 1), so the=-form (which consumes no token) no longer decrements the index. I confirmed the hang is gone through bothCLI.parse-fromand the subcommand path (push --force=false), and the new tests genuinely assert the value (force="false"), not just that it terminates. - Finding #2 (related quirks) — fixed. A bare boolean that isn't the last token no longer swallows the following token (
--force in.txt→force=true,file=in.txt), and the boolean branch now stores the actual=value, so--force=falsereads asfalseper the README.
Beyond the new tests I ran 9 adversarial probes, all correct and terminating:
- Index accounting after a
=-form boolean (the case the new tests don't cover):--force=false origin→force=false,remote=origin— the following positional is neither skipped nor double-read. - Two
=-form booleans in a row, bare-then-=boolean, empty value (--force=), multiple=(--force=false=true), short-form-f=false, and a bare boolean as the last token — all sensible, none hang.
Finding #3 (cosmetic usage-for header) and the design questions were intentionally left for you — noted below.
Findings
No new defects. The CLI.App layer and the parse-from extraction remain solid (verified last round), and the boolean fix is complete and correct.
Still open, and yours to decide (all flagged by the author as design calls, not bugs):
- Naming:
CLI.App/App.addvs alternatives. - Global options before the subcommand (
tool --verbose commit …) — currently an error; in or out of scope? - Per-subcommand
--help— currently surfaces as the generic empty-error help signal; the caller can't tell it should show the subcommand's usage rather than the app's. - Whether
CLI.parse-fromshould be public, or stay internal. - Minor:
App.usage-forreusesCLI.usage, whose header printsSystem.args[0]only, so a subcommand's usage header omits the subcommand name (the options list is correct).
Verdict: revise
The blocking boolean bug from the last round is genuinely fixed and well-tested — no code issues remain. This stays revise only because it's still a draft awaiting your design decisions (naming, pre-subcommand global options, per-subcommand --help, whether to expose parse-from), not because more work is needed from the bot. Once you've made those calls, it's ready to finish.
Naming is fine.
Fine, should be documented.
Yes, definitely contextual help.
Internal. |
Addresses @hellerve's review of #8. - App dispatch now returns a CLI.Dispatch sum type (Parsed / AppHelp / CommandHelp / Failure) instead of a bare Result, so a subcommand's own --help (e.g. `git commit --help`) is distinguishable from app-level --help and the caller can respond with App.usage-for the right command. - CLI.parse-from is now private+hidden; the flat-parser regression tests reach it through a one-subcommand App wrapper, so that coverage survives. - Document in the README that options come after the subcommand; a leading option other than --help/-h is an error.
|
Pushed 5f18a24 addressing the three calls from your review, @hellerve: Contextual per-subcommand help (you: “Yes, definitely contextual help”).
App is unreleased so changing the result shape was in-bounds, per your note. The empty-error protocol still lives inside the flat parser;
Global options before the subcommand (you: “Fine, should be documented”). The README's
Left as-is, since you reserved it: the cosmetic |
There was a problem hiding this comment.
Build & Tests
Re-checked out claude/subcommand-support at 5f18a24, built and ran it locally.
- Build: clean.
- Tests:
carp -x test/cli.carp→ 102 passed, 0 failed (up from 100 — +2 contextual-help tests, flat-parser regression tests rerouted through the public entry). - carp-fmt
--checkand angler: clean on bothcli.carpandtest/cli.carp. - CI: both
testjobs green (that job runs angler + carp-fmt only; I ran the suite locally).
Prior feedback
5f18a24 addresses the three calls from your review, @hellerve:
- Contextual per-subcommand help ("Yes, definitely contextual help") — done and verified. Dispatch now returns a new
CLI.Dispatchsum type (Parsed/AppHelp/CommandHelp/Failure). I traced the translation and confirmed it against adversarial probes:mytool push --help→CommandHelp("push"),mytool commit -h→CommandHelp("commit"),mytool --help commit→AppHelp(top-level help wins),mytool -h→AppHelp. The empty-error help signal is correctly confined — line 432 is the only site that emits an empty error, and both required-option/positional checks are guarded byResult.success?, so--helpshort-circuits them before theempty? &e→CommandHelptranslation atcli.carp:600. No falseCommandHelp. CLI.parse-frominternal ("Internal") — done. It's nowprivate+hiddenwith its doc demoted to an internal comment;parse's doc is self-contained. The flat-parser regression tests (--force=false/=true, bare-boolean-plus-positional, missing-required) survive: they reach the loop through a one-subcommandApp(flat-parsehelper,test/cli.carp:25) that collapsesDispatchback toResult. The test file no longer referencesCLI.parse-fromdirectly.- Global options before the subcommand ("Fine, should be documented") — done. README's
Appsection documents that options follow the subcommand and that a leading option other than--help/-his aFailure. Verified:mytool --verbose commit→Failure(Expected a subcommand, got option: --verbose).
Findings
No new defects. Beyond the suite I ran 11 adversarial probes on the new Dispatch layer — top-level-help-before-subcommand, error-before---help (commit extra --help → Failure, not help), empty-string subcommand, empty-value option, optional-only subcommand with no args, =-form boolean followed by a positional — all correct and terminating. The prior round's boolean =value fix still holds through dispatch (push --force=false origin → Parsed).
One thing remains, and it's the item you explicitly reserved: App.usage-for reuses CLI.usage, whose header prints System.args[0], so mytool commit --help renders usage: mytool [-m | --message] with no commit in the header (the options list is correct). Now that CommandHelp deliberately routes callers to usage-for, that omission is slightly more visible in the promoted help path — but fixing it means touching the shared CLI.usage, which you flagged as your call. Noting it, not blocking on it.
Verdict: merge
Every design decision you handed down is implemented and verified, the code builds and passes tests, and I found no bugs in the new Dispatch layer or any regression in the parse loop. The only remaining item is the cosmetic usage-for header you reserved for yourself — no code changes are needed from the bot. It's still marked draft, so it needs marking ready-for-review (and your call on that header) before landing; from a code-quality standpoint it's ready.
What
Adds first-class subcommand support — the
git commit/git push/docker runshape, where each subcommand is an independent parser with its owndescription, options, and positionals, and the program dispatches to the one you
named. Until now the library only supported a single flat set of options +
positionals, so any tool with subcommands had to hand-roll the dispatch.
This is purely additive. The existing
Parsertype,CLI.new/add/add-pos/parse/usage, and every existing test are untouched in behaviour.Design
A new
CLI.Appsits on top of theParseryou already have:CLI.App.new+CLI.App.add(name → an ordinaryParser).CLI.App.parsereads the first token to pick the subcommand andhands the remaining tokens to that subcommand's
Parser.CLI.App.usagelists the subcommands with their descriptions;CLI.App.usage-forprints one subcommand's detailed usage;CLI.App.has?reports whether a name is registered.
Result representation
I went with the
Pair-inside-Resultoption you suggested, because it's thesmallest thing that answers both questions a dispatcher has — which command ran
and what it parsed:
Pair.ais the chosen subcommand name,Pair.bis the usual value map. Errorsreuse the flat parser's protocol exactly: an empty error string means
--helpwas requested; otherwise it's a human-readable message for a missingsubcommand, an unknown subcommand, or the subcommand's own parse error
(surfaced verbatim).
How
parsewas reused (not duplicated)Rather than copy the ~100-line parse loop, I extracted the existing body into a
new public
CLI.parse-fromthat parses an explicit(Array String)insteadof the process arguments.
CLI.parseis now a thin wrapper over it, andsubcommand dispatch parses the remaining tokens through the same function. Nice
side effect: the flat parser is now unit-testable for the first time (it used to
read
System.argsdirectly), so this PR also adds regression coverage for it.On
BoxYou were right that the sub-parsers need boxing — though not for logical
recursion (
AppholdsParsers, not otherApps). It's the C backend: aPairthat embeds aParserstruct by value can't be emitted beforeParser'sown definition (
field has incomplete type 'CLIParser').(Box CLI.Parser)turns that into a pointer and the codegen is happy.
Box.peekborrows the innerparser back out at dispatch time, so there's no extra copying on the hot path.
Before / after
Before — one flat parser, dispatch is your problem:
After — a
git-like tool falls out directly:Tests
carp -x test/cli.carp→ 95 passed, 0 failed (17 new). New coverage:defining multiple subcommands, dispatching to the right one, each subcommand
parsing its own options and positionals, unknown-subcommand error,
missing-subcommand error, a subcommand's own required-option error propagating,
the subcommand count /
has?,--help, a leading non-subcommand option — plusflat-parser regression tests through
parse-from.carp-fmt --checkandanglerare clean on the changed files.For @hellerve to steer
This is a draft because the API shape is a genuine design choice and it's
yours to make. Open questions I'd love a call on:
CLI.AppvsCLI.Commands;App.addvsApp.command/with-command.tool --verbose commit …) aren'tsupported — a leading option that isn't
--helpis currently an error. Worthadding, or intentionally out of scope?
--help:tool commit --helpcurrently surfaces as theempty-error help signal but the caller can't tell it should show commit's
usage rather than the app's. Happy to thread that through (e.g. a richer result
or a dedicated help variant) if you want it.
CLI.parse-frompublicly is desirable, or it should stayinternal.
Happy to reshape any of it.
Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.