Skip to content

feat(go): bind a command line against static tables, as usage-argv does for Rust - #921

Merged
jdx merged 1 commit into
mainfrom
go/argv-binder
Aug 16, 2026
Merged

jdx merged 1 commit into
mainfrom
go/argv-binder

Conversation

@jdx

@jdx jdx commented Aug 16, 2026

Copy link
Copy Markdown
Owner

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

instructions, cold wall, whole process binary
a do-nothing Go process 0.95 ms 2.31 MB
this ~2,700 1.1 ms 2.37 MB
cobra 2,008,880 1.8 ms 3.87 MB
urfave/cli v3 5,591,321 1.7 ms 5.74 MB
kong 57,889,084 6.1 ms 5.34 MB

Counts are cachegrind, one cold construct-and-parse in a fresh process (PARSE_N=1 minus PARSE_N=0) — the method tasks/perf-shadow.sh already 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-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 argv 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 57 ns.

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.

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 -1e5 a value and -1e a flag, and the default-subcommand rewind. --help and --version are 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 CLIGive Feedback 💬

🤖 Generated with Claude Code

…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>
@coderabbitai

coderabbitai Bot commented Aug 16, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@jdx, you've reached your PR review limit, so we couldn't start this review.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: d03fbc53-2a8c-4260-ae08-2ea6082ed1b7

📥 Commits

Reviewing files that changed from the base of the PR and between 81b1ee7 and 5b4bef0.

⛔ Files ignored due to path filters (1)
  • mise.lock is excluded by !**/*.lock
📒 Files selected for processing (6)
  • .github/workflows/test.yml
  • go/argv/argv.go
  • go/argv/parser.go
  • go/argv/parser_test.go
  • go/go.mod
  • mise.toml

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@jdx jdx changed the title go/argv binder feat(go): bind a command line against static tables, as usage-argv does for Rust Aug 16, 2026
@greptile-apps

greptile-apps Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

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

  • Adds static command, flag, argument, event, and error types.
  • Implements single-pass parsing for flags, positionals, subcommands, variadics, and separator modes.
  • Adds Go module/tool configuration and a hand-written parser test suite.

Confidence Score: 4/5

The 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

Filename Overview
go/argv/parser.go Adds the parser state machine; checked flag, descent, positional, and separator behavior closely matches the established Rust implementation.
go/argv/argv.go Defines the public static-table and event API, including parser modes and diagnostics.
go/argv/parser_test.go Covers core binding and allocation behavior but does not run the shared conformance corpus or directly cover several complex modes.
mise.toml Adds Go tooling plus test, format, and vet tasks.
.github/workflows/test.yml Adds the Go test task to the existing test workflow.

Fix all with Greploop

Fix All in Claude Code

Reviews (1): Last reviewed commit: "feat(go): bind a command line against st..." | Re-trigger Greptile

Comment thread go/argv/parser_test.go
cases := []struct {
name string
cmd *Command
argv []string

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 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!

Fix in Claude Code

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@github-actions

Copy link
Copy Markdown
Contributor

Instruction counts

benchmark trend instructions Δ wall (min) Δ
markdown ▁▁▁▃▄▄▃▃███ 180,287,329 → 180,229,843 -0.03% 16.15 → 17.84ms +10.45%
startup ▁▁▁▁▁▁▁▁█▃▆ 1,222,732 → 1,224,020 +0.11% 0.95 → 1.03ms +8.09%

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 comparison

Parsing mise use -g node@20 against a shadow of mise's committed spec.
Reported, not gated: the shadow grows as the derive learns to express more, so
what to watch is the ratio rather than either column.

usage clap ratio
instructions, cold parse 71984 5895248 81x
usage: argv -> struct                            1254 ns      1.25 µs
clap: build tree + parse -> struct             499999 ns    500.00 µs
clap: parse -> struct, tree reused              23940 ns     23.94 µs
clap: build tree only                          321339 ns    321.34 µs

5b4bef0998f0 vs 81b1ee788d23 · measured on the runner, not pushed to the history.

@jdx
jdx merged commit ac59312 into main Aug 16, 2026
10 checks passed
@jdx
jdx deleted the go/argv-binder branch August 16, 2026 23:44
jdx added a commit that referenced this pull request Aug 16, 2026
… 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>
jdx added a commit that referenced this pull request Aug 17, 2026
…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>
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