feat(go): bind a command line against static tables, as usage-argv does for Rust - #921
Conversation
…es for Rust The first piece of a Go CLI framework in the usage family, built in the order usage-argv was: the binder first, and nothing above it until this is right. Every Go CLI framework builds a model of the CLI at run time. cobra constructs a `cobra.Command` per subcommand with a flag set each; kong walks a struct with reflection. Both pay for the whole CLI on every invocation, including the two hundred commands the user did not type. Measured against a shadow of mise's committed spec — 211 commands, 711 flags, 128 positionals — parsing `mise use -g node@20` costs cobra 2,008,880 instructions, urfave/cli 5,591,321, and kong 57,889,084 before the first token is read. This reads static tables instead, and binding the same command line costs about 2,700 instructions. Three properties hold it up, and each is tested rather than asserted: Nothing is built before `main`. The tables are package-level `var` holding plain data, so the Go linker lays them out — `go tool nm` reports them as type D and neither the table's package nor this one has an init function. Go has no `const` for this, and for tables of plain data it does not need one. A parse allocates nothing. The parser holds its state, its ancestor chain and its error inline, and a bound value is a slice of the argv string rather than a copy. `TestParseAllocatesNothing` measures that with `testing.AllocsPerRun`, on the failure paths as well as the success ones — the half that usually gets away with allocating because nobody looks. A mise-sized binding runs in 57ns. Binding only. Events go out one at a time and everything needing a value's type — required, choices, env fallback, defaults, var_min, overrides — is left to the layer that owns the target struct, exactly as in Rust. The grammar is docs/spec/argv.md, ported rule for rule: scope running only downward, a short bundle rejected whole rather than part-applied, unknown flags becoming words, the narrow number rule that keeps `-1e5` a value and `-1e` a flag, and the default-subcommand rewind. What proves it is the conformance corpus, which is the next commit in this stack. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Warning Review limit reached
Next review available in: 3 minutes Limit details: You’ve used all 4 included reviews currently available under your plan. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Central YAML (base), Organization UI (inherited) Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (6)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Greptile SummaryThe PR adds a dependency-free Go argv binder modeled on the existing Rust parser and wires Go formatting, vetting, and tests into repository tasks and CI.
Confidence Score: 4/5The PR appears safe to merge, with a non-blocking need to connect the Go implementation to the shared conformance corpus. The checked parser behavior mirrors the established Rust implementation, but CI currently cannot detect future Go-only divergence across several complex grammar branches because it runs only the smaller hand-written suite. Files Needing Attention: go/argv/parser_test.go Important Files Changed
Reviews (1): Last reviewed commit: "feat(go): bind a command line against st..." | Re-trigger Greptile |
| cases := []struct { | ||
| name string | ||
| cmd *Command | ||
| argv []string |
There was a problem hiding this comment.
Run the shared conformance corpus
The new Go tests cover only a hand-written subset of the grammar and do not execute the shared conformance corpus, so regressions in complex branches such as double-dash modes and bounded variadics can pass CI while the Go parser diverges from the Rust implementation.
Knowledge Base Used: Compiled argv parsing and derives
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
Deliberate, and it is the next PR in this stack rather than a gap: #922 adds go/conformance, which runs the shared corpus and passes all 101 binding vectors (the 22 post-binding ones are skipped per-vector from the layer field).
Splitting the stack this way means the bottom layer is incomplete by construction. What #921 does carry is the test:go and lint:go tasks and the CI step, so the hand-written cases here are at least exercised on their own — I checked that by stashing everything in #922 and running the binder alone. The double-dash modes and bounded variadics you name are exactly the branches the corpus covers and the hand-written table does not, which is why the corpus is a PR rather than a follow-up issue.
This comment was generated by Claude Code.
There was a problem hiding this comment.
That's a clear and reasonable split — landing the binder first with hand-written coverage, then layering the full corpus on top in #922, is exactly how you'd want to structure a stack like this. The fact that the corpus specifically targets double-dash modes and bounded variadics (the gaps I flagged) makes #922 a natural completion rather than a follow-up. No action needed here.
Instruction counts
No instruction-count regression above 1%. Only instruction counts gate. Wall clock is shown for context — on identical hardware it moves 4-20% run to run. Measured by tak — instruction-counted CLI benchmarks, stored in this repository's git notes. Shadow comparisonParsing
|
… for (#922) The corpus has said from the start that it is plain JSON so an implementation in any language can run it, and that passing it is what "compatible" means. Nothing had taken that up: both runners were Rust, so the format's portability was a claim rather than a measurement. This is a second implementation, in a second language, measured against the same vectors. **101 binding vectors pass.** ``` $ mise run test:go ok github.com/jdx/usage/go/argv ok github.com/jdx/usage/go/conformance 123 vectors: 101 binding, 22 post-binding left to the layer above ``` The 22 post-binding vectors are skipped with the reason recorded, which is the same line usage-argv draws: they need to know a value's type, so they belong to the layer that owns the target struct. Skipping is per-vector from the `layer` field rather than inferred from the spec, so the exempt set cannot quietly grow. ## The three that failed first, and why none was the parser Some vectors write `"cmd": []` where others omit `cmd`, and the harness was treating absent and empty as different answers. The corpus is the definition of correct about bindings, not about which spelling of "nothing" a JSON decoder produces, so the harness normalizes both. ## No KDL parser in the Go module, on purpose A vector's spec is KDL, and `go/internal/spec` deliberately cannot read it — `usage generate json` does the lowering. That is the same split an adopter gets: tables are generated once at build time by a maintainer who has the usage CLI, and the shipped binary never sees a spec. Putting a KDL parser in this module would add a dependency to every adopter's binary to solve a problem none of them have. It is also why `test:go` now depends on `build`, and why the CI step runs in the job that already built the CLI. The suite fails rather than skips when it cannot find that CLI. A conformance suite that quietly passes because it could not locate its oracle is worse than one that fails. ## Missing on purpose Listed in `go/README.md`: the generator that emits tables from a spec, the typed layer that would answer those 22 vectors, help rendering, and completions. The hooks completions need — `Collecting`, `PendingArg`, `FlagsInScope`, `CommandStart` — are already on the parser from #921, since the whole point of them is that what is offered and what is accepted read the same scope rules. --- <sub>Stack created with <a href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub> 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Test harness, docs, and CI task wiring only; no changes to Rust parsing or production CLI behavior beyond requiring a built `usage` binary for Go tests. > > **Overview** > Adds a **Go conformance runner** so the shared JSON corpus is exercised outside Rust: `go/conformance` loads corpus files, lowers each vector’s KDL spec via `usage generate json`, builds tables through **`go/internal/spec`**, and compares binding results to the expected JSON. **101 binding vectors pass**; **22 `post-binding` vectors are skipped** explicitly via the corpus `layer` field (same split as usage-argv). > > The harness is strict about oracle and schema drift: it **fails if the `usage` CLI is missing** (prefers freshly built debug binary), **rejects unknown JSON fields**, and **normalizes empty vs omitted `cmd`/maps** so decoder spelling differences do not fail binding checks. > > **CI and tasks** wire this in: `mise run test:go` now **depends on `build`**, and the main test workflow runs `test:go` in the job that already built the CLI (with a comment explaining why). Docs (`corpus/README.md`, `docs/spec/argv.md`, new `go/README.md`) document usage-go as the first non-Rust corpus consumer. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 30bdae0. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ead of a derive (#931) Go has no macros. What a Rust CLI gets from `#[derive(Cli)]` at compile time, a Go CLI has to get from a generator at build time — so this is the same milestone for usage-go that usage-derive is for the Rust side: the point at which a spec, rather than a hand-written table, is what a CLI is declared in. ```go //go:generate usage generate go -f mycli.usage.kdl -o tables.go ``` Follows #921 and #922, which landed the binder and the conformance suite. ## Binding tables only Help text, choices, defaults and `env` are absent, for the same reason they are absent from the Rust hot path: a successful parse never reads them, and mise's several hundred kilobytes of help strings do not belong in front of a parser. A cold table is separate work. ## The output is gofmt-clean as it comes out This took some care and is worth it — the alternative is every adopter running a formatter before they can commit, and this repo's own CI failing `gofmt -l` on the table #932 checks in. gofmt pads within runs of consecutive single-line entries and starts a new run after anything spanning lines, so the emitter models a literal as a list of fields and blocks rather than printing strings. Verified against mise's spec, usage's own, and the examples spec: `gofmt -w` changes nothing in any of them. ## What the author gets beyond speed The key constants. An event carries a `Key`, so generated code dispatches on `mise.FlagUseGlobal` rather than comparing strings — and a flag renamed in the spec then fails to compile instead of silently never matching. Three things are resolved at generation time so the parser reads one field per command instead of walking: `unknown_flags` inheritance, `default_subcommand` into a pointer at the node it names, and hidden aliases folded in beside visible ones (hiding is a help-output concern; binding never reads it). Identifier collisions are ordinary rather than exotic, and are handled: mise declares both a `macos-defaults` command and a `macos defaults` path, and both want to be spelled `CmdMacosDefaults`. ## Checked end to end The fixture that keeps this honest in CI is #932, but before committing I confirmed the generated mise tables compile, parse `mise use -g node@20`, resolve `x` to `exec` through its hidden alias, split `tasks run build extra --dry-run -- --verbose` across `ARGS` and `ARGS_LAST`, and produce a package with **no init function** whose `Root` is a type `D` symbol — 211 commands and 711 flags that cost nothing before `main`. --- <sub>Stack created with <a href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub> 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Large new code generator with many spec edge cases, but it is additive build-time output; mistakes would break adopters' generated Go compile/parse behavior rather than runtime security. > > **Overview** > Introduces **`usage generate go`**, the Go analogue of compile-time Rust derives: specs become check-in-ready Go source for **`github.com/jdx/usage/go/argv`**, typically via `//go:generate usage generate go -f mycli.usage.kdl -o tables.go`. > > The new **`usage::go`** emitter writes **binding-only** tables (commands, flags, args, keys, aliases)—not help, choices, or defaults—using package-level `var` trees consumable by the linker without `init`. Output is **gofmt-aligned** (const blocks and struct field runs). Generation resolves **`unknown_flags` inheritance**, **`default_subcommand`** against root direct children only (avoids wrong deep matches), folds **hidden aliases** into bindings, and deduplicates **Go identifier collisions** with suffixed names. > > The CLI accepts **`-f`/`--spec`**, **`-o`**, and **`-p`/`--package`**; invalid explicit package names error instead of silent mangling. Spec metadata, man page, Fig completion, docs, and command **read/write** effects are updated for the new subcommand. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 858445b. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added `generate go` to generate Go source code from a CLI usage specification. * Supports specification files or inline input, custom output files, and Go package names. * Generated output includes commands, flags, arguments, aliases, subcommands, and related parsing behavior. * Validates package names and safely handles generated identifiers and special characters. * **Documentation** * Added CLI help, reference documentation, usage examples, and shell completion support for the new command. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The first piece of a Go CLI framework in the usage family, built in the order usage-argv was: the binder first, and nothing above it until this is right.
Why
Every Go CLI framework builds a model of the CLI at run time. cobra constructs a
cobra.Commandper subcommand with a flag set each; kong walks a struct with reflection. Both pay for the whole CLI on every invocation, including the two hundred commands the user did not type.Measured against a shadow of mise's committed spec — 211 commands, 711 flags, 128 positionals — parsing
mise use -g node@20:Counts are cachegrind, one cold construct-and-parse in a fresh process (
PARSE_N=1minusPARSE_N=0) — the methodtasks/perf-shadow.shalready uses for the Rust shadows. This one's figure is amortized over 1,000 parses because a single one sits below the Go runtime's own startup jitter: ±80,000 instructions run to run, thirty times the whole parse.Two things worth reading off that honestly. The win against cobra is real — about 40% of process startup — but bounded: 0.95 ms of the 1.1 ms is Go runtime startup no parser can touch. And the framework that gives Go the ergonomics people actually want, kong's struct tags, costs 29× cobra to do it, because reflection is the only way to get them without a build step. Generated tables are how to have both.
Three properties, tested rather than asserted
Nothing is built before
main. The tables are package-levelvarholding plain data, so the Go linker lays them out —go tool nmreports them as typeD, and neither the table's package norargvhas an init function. Go has noconstfor this, and for tables of plain data it does not need one.A parse allocates nothing. The parser holds its state, its ancestor chain and its error inline, and a bound value is a slice of the argv string rather than a copy.
TestParseAllocatesNothingmeasures that withtesting.AllocsPerRun, on the failure paths as well as the success ones — the half that usually gets away with allocating because nobody looks. A mise-sized binding runs in 57 ns.Binding only. Events go out one at a time, and everything needing a value's type (
required,choices,envfallback, defaults,var_min,overrides) is left to the layer that owns the target struct, exactly as in Rust.What was ported
docs/spec/argv.md, rule for rule: scope running only downward, a short bundle rejected whole rather than part-applied, unknown flags becoming words, the narrow number rule that keeps-1e5a value and-1ea flag, and the default-subcommand rewind.--helpand--versionare reported as ordinary flag events rather than failures, as usage-argv has them — whether asking for help ends the parse is a decision for the layer above.What proves all of it is the conformance corpus, which is #922 on top of this.
Stack created with GitHub Stacks CLI • Give Feedback 💬
🤖 Generated with Claude Code