Skip to content

feat(go): render a failure as something a person can act on - #977

Merged
jdx merged 7 commits into
go/help-longfrom
go/errors
Aug 17, 2026
Merged

feat(go): render a failure as something a person can act on#977
jdx merged 7 commits into
go/help-longfrom
go/errors

Conversation

@jdx

@jdx jdx commented Aug 17, 2026

Copy link
Copy Markdown
Owner

argv.Render turns a binding failure into what a CLI prints to stderr:

error: unknown flag `--wat`

Usage: ex run [-f --force]

For more information, try `--help`.

Stacked on #975.

The one part with no reference to match

Every other renderer in this module is measured against usage-lib byte for byte. This one cannot be, and saying so plainly is the point: usage-lib prints a one-line message inside miette's frame, usage-argv renders through miette too with the offending token underlined, and neither travels — miette is a Rust library, and a Go CLI drawing the same ASCII art would be imitating a diagnostic format rather than sharing one.

So it is judged on a different standard — does the message say what went wrong, where, and what to try — and tested by asserting those three rather than by comparing bytes.

What the tests actually check

  • Every code renders something specific. A failure falling through to "could not be parsed" tells a user nothing, so the test is that none of the eleven does.
  • The usage line names the command in scope, not the program. On a nested command that is the difference between an actionable message and a confusing one.
  • Help and version render nothing. They arrive as errors because a parse that stops to print a page has produced no value, but a caller rendering one of them as a failure has made two mistakes at once.

missing_flag_value says the likeliest cause out loud, because the rule behind it is not guessable: a flag-like token after a flag is refused as its value, and the attached form (--flag=-x) is how to force it.

🤖 Generated with Claude Code


Note

Low Risk
User-facing CLI error text and terminal escaping only; parsing semantics unchanged aside from richer error metadata on missing flag values.

Overview
Adds argv.Render to format binding failures like clap: an error: line, a Usage line for the command the user was in, and a pointer to --help. Help/version codes render nothing.

Error quality and safety: Post-binding and parser failures now carry Spelling / OtherSpelling on Error and Meta (emitted from spec/Go gen) so messages quote flags as typed (--workers vs -j, conflict pairs) instead of guessing from names. Missing detached flag values record the token form that bound. safe() escapes control characters in user tokens for both Render and Error().

README documents the errors story; render_test.go asserts content and spelling behavior rather than byte-matching Rust diagnostics.

Reviewed by Cursor Bugbot for commit 2e3c1b5. Bugbot is set up for automated code reviews on this repo. Configure here.

@coderabbitai

coderabbitai Bot commented Aug 17, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

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

Review profile: CHILL

Plan: Pro Plus

Run ID: 41d1f2ff-4e59-421d-a10b-839551f45468

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

@greptile-apps

greptile-apps Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds actionable Go parse-error rendering and carries exact flag spellings through parser and post-binding metadata.

  • Formats failures with an explanation, scoped usage line, and help guidance.
  • Preserves the invoked alias when suggesting attached flag values.
  • Generates flag spellings for required, choice, variadic, and conflict diagnostics.
  • Escapes control characters in reflected command-line tokens.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the exact invoked alias is preserved on both missing-value parser paths, and generated post-binding metadata distinguishes short-only flags from one-character long flags.

Important Files Changed

Filename Overview
go/argv/render.go Adds failure rendering and now uses the parser-carried spelling for missing-value guidance; the previously reported short-only and one-character-long cases are corrected.
go/argv/parser.go Both long and short missing-value paths preserve the exact invoked spelling, including inherited secondary aliases.
go/argv/post.go Propagates generated flag spelling through all relevant post-binding failures.
go/argv/relationships.go Carries independently resolved spellings for both sides of conflict errors.
go/internal/spec/spec.go Emits metadata spelling by preferring the first long form and otherwise the first short form.
lib/src/go/mod.rs Keeps Rust-driven Go metadata generation aligned with the Go-side spelling selection.
go/argv/render_test.go Covers short-only flags, one-character long flags, shadowed aliases, post-binding errors, and terminal-safe escaping.

Reviews (9): Last reviewed commit: "fix(go): advise the form that was typed,..." | Re-trigger Greptile

Comment thread go/argv/render.go Outdated
Comment thread go/argv/render.go Outdated
Comment thread go/argv/render.go Outdated
greptile-apps[bot]
greptile-apps Bot previously approved these changes Aug 17, 2026
@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Instruction counts

Nothing was compared, and so nothing was gated. No series appears on both sides: either the base has no measurements recorded, or the two were measured on different runner classes, which are deliberately not comparable — counts shift between machine types by more than a real regression does.

New, nothing to compare against: markdown on bamboo-v2-ubuntu24.04-x64-30vcpu-24gb-rust1.97.1, startup on bamboo-v2-ubuntu24.04-x64-30vcpu-24gb-rust1.97.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.

framework instructions, cold parse vs usage
usage 4161
argh 6292 1.5x
clap 5893608 1416x
bpaf 21917930 5267x
                                              min       p01       p10    median
usage-rs: argv -> struct                      195       197       202       209  ns
argh: argv -> struct                          289       294       300       308  ns
clap: build tree + parse -> struct         510822    511766    517700    523491  ns
bpaf: build parser + parse -> struct      1668078   1668078   1687646   1716883  ns

usage: argv -> struct                             222 ns      0.22 µs
clap: build tree + parse -> struct             505402 ns    505.40 µs
clap: parse -> struct, tree reused              23491 ns     23.49 µs
clap: build tree only                          309362 ns    309.36 µs

2e3c1b50d9cd vs c5d9ad605c95 · measured on the runner, not pushed to the history.

@greptile-apps
greptile-apps Bot dismissed their stale review August 17, 2026 17:19

Dismissed because a newer commit was pushed; Greptile will re-review the current head.

Comment thread go/argv/render.go Outdated

jdx commented Aug 17, 2026

Copy link
Copy Markdown
Owner Author

Real, and worse than cosmetic: a long-only --a is one character too, so the heuristic rendered it -a — a form that does not exist, and one that may belong to a different flag. Advice naming the wrong flag is worse than advice naming none.

The guess is gone. Meta carries the spelling now, worked out where the forms are visible — first long, else first short — by the table builder and the emitter alike, and the failures that judge an entry carry it through. Both sides of a conflict get their own, since they can be spelled differently. Where nothing is carried the bare name is printed rather than dressed as something the user cannot type.

--a  (long only)   -> `--a`
-a   (short only)  -> `-a`
nothing carried    -> `a`

Two notes from getting there, both about tests rather than code:

  • The emitter change silently missed the first time. The pattern was written at the wrong indentation, matched nothing, and the build still passed. What caught it was counting Spelling entries in mise's regenerated tables — 711 now, 0 then. Third time this session a whitespace-sensitive edit has failed quietly.
  • Five emitter tests broke, all asserting a whole rendered row. A row grows a field every time the cold table learns something, so they assert the part each test is actually about now.

This comment was generated by Claude Code.

@cursor cursor 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.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 64779f4. Configure here.

Comment thread go/argv/post.go
Comment thread lib/src/go/mod.rs Outdated
jdx and others added 5 commits August 17, 2026 19:54
`argv.Render` turns a binding failure into what a CLI prints to stderr: what went
wrong, the usage line for the command the user was actually in, and what to try
next.

    error: unknown flag `--wat`

    Usage: ex run [-f --force]

    For more information, try `--help`.

This is the one part of the module with **no reference to match**, and saying so
is the point. usage-lib prints a one-line message inside miette's frame;
usage-argv renders through miette as well, underlining the offending token in the
command line. Neither travels — miette is a Rust library, and a Go CLI drawing
the same ASCII art would be imitating a diagnostic format rather than sharing
one. Every other renderer here is measured against usage-lib byte for byte, and
this one cannot be, so it is judged on a different standard: does the message say
what went wrong, where, and what to do about it.

Tested that way too. Every code renders something specific — a failure falling
through to "could not be parsed" tells a user nothing, so the test is that none
of them does — and the usage line names the command in scope rather than the
program, which is the part that makes the message actionable on a nested command.

`missing_flag_value` says the likeliest cause out loud, because the rule behind
it is not guessable: a flag-like token after a flag is refused as its value, and
the attached form is how to force it.

Help and version render nothing. They arrive as errors because a parse that stops
to print a page has produced no value, but a caller that renders one of them as a
failure has made two mistakes at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…reach the terminal

Two review findings on the error text, both about output a user cannot act on.

A short-only flag was named `--f`. `primaryLong` fell back to the flag's *name*
and everything prefixed `--`, so a flag reachable only as `-f` was reported as
something nobody can enter — and the advice that comes with a missing value was
worse than useless, telling someone with `-j` to write `--j=-1`, which is an
unknown flag. Flags are spelled from their forms now: first long, else first
short, and the attachment example follows the same spelling.

The post-binding failures carry only a name, because they judge an entry rather
than a token and no flag reaches them. A one-character name is taken to be a
short, which is a guess and the right one nearly always: a flag's name is its
long form wherever it has one. Printing `--f` for something typeable only as `-f`
is the alternative.

And a value quoted back into an error is escaped. What the user typed can contain
control characters, and an escape sequence in an argument would otherwise reach
the terminal through the message — where it can recolour the output, move the
cursor, or forge lines that look like the program wrote them. Rendering a
rejected value is not a reason to execute it. The escaping keeps the text
legible rather than dropping it, so the user still sees what was refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The missing-value message advised attaching a dash-prefixed value and showed
`--jobs=-1`. A negative number is the one dash-prefixed token the parser takes
*detached*: `--jobs -1` binds, and `--jobs -x` is the failure being explained. So
the example demonstrated the case exempt from the rule it was illustrating —
advice that contradicts itself.

`-x` now, which is a token that actually triggers it. Checked both ways round
rather than reasoned about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`spellName` read a one-character name as a short flag, reasoning that a name is a
long form wherever there is one. A long-only `--a` is one character too, so it
was rendered `-a` — a form that does not exist, and one that may belong to a
*different* flag. Advice naming the wrong flag is worse than advice naming none.

The guess is gone. `Meta` carries the spelling, worked out where the forms are
visible — first long, else first short — by the table builder and the emitter
alike, and the failures that judge an entry carry it through. Both sides of a
conflict get their own, since they can be spelled differently. Where nothing is
carried the bare name is printed rather than dressed as something the user cannot
type.

Two notes on the way here, both about tests rather than code.

The emitter change silently missed the first time: the pattern was written at the
wrong indentation and matched nothing, and the build still passed. The check that
caught it was counting `Spelling` entries in mise's regenerated tables — 711 now,
0 then.

And five emitter tests broke, all of them asserting a whole rendered row. A row
grows a field every time the cold table learns something, so they now assert the
part each test is about. That is what they meant all along.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Render escapes what the user typed before showing it back. `Error()` did not,
and it is the string a caller gets from Go's own error interface — logged,
wrapped, printed by code that never calls Render. An argument carrying an escape
sequence reached the terminal through it, where it can recolour the output or
forge a line that looks like the program's.

Only the two fields that come from the command line. A flag's name and an
argument's are the spec's, written by the author and held in the tables.

`safe` returns its input untouched where there is nothing to escape, which is
every real failure: a message that allocates once should not also allocate a
copy of each token in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The spelling was carried onto the metadata so that the rules judging an entry
after binding could name it the way a user types it — they never see a `*Flag`.
Three of the four codes did not pass it on, so an invalid choice, too few values
or too many occurrences reported a short-only flag as `j`: not something anyone
can type, which is the whole thing this was meant to stop printing.

An argument keeps its bare name, because that is what it is called.

The emitter's relationship tests are tightened while here. They asserted a name
and a `Conflicts` slice as two substrings of the whole file, which passes when
the relationship is attached to a different flag entirely — the regression those
tests exist to catch. Both halves now come from the same row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread go/argv/render.go
A flag answers to several spellings, and the first is not always the one in front
of the user. An inherited `--jobs --workers` whose `--jobs` a nearer command has
taken still binds through `--workers` — and the advice for a missing value said
`--jobs=-x`, which sets the other flag or fails again.

The parser knows which form matched, so it carries it. For a long that costs
nothing: with no attached value the token *is* the spelling, and a slice of it is
free on a path that must not allocate. The short form is built in the failure
branch rather than by the caller, for the same reason — the allocation test
caught that on the first attempt.

Where nothing was carried the flag's own first form is used, which at least
exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jdx
jdx merged commit 16bf880 into main Aug 17, 2026
11 checks passed
@jdx
jdx deleted the go/errors branch August 17, 2026 22:42
jdx added a commit that referenced this pull request Aug 17, 2026
Binding collects text on purpose — the grammar decides which token
becomes which flag, not what it means — so `"8"` stays a string until
something that knows the target type asks. `Int`, `Uint`, `Float`,
`Bool`, `Duration` and `Each` are where the asking happens.

Stacked on #977.

## Why separate functions rather than one generic `Convert`

The set of types a CLI wants is small and closed, and each has its own
idea of what it accepts: `1h30m` is a duration and not a number, `yes`
is neither. Generated code calls the one matching its field.

## Every failure names the word and the type

```
error: `jobs` does not accept `lots` (expected a whole number)
```

"Invalid value" alone makes a user guess which of their words was wrong
— and the whole reason the parser keeps the original bytes is so
something downstream can show them back.

`Each` exists so callers do not each write the same loop and get the
early return wrong: it reports the **first** value that will not
convert, because reporting the last sends the reader to the wrong word.
That is a test rather than a comment.

## One deliberate inconsistency

`Bool` takes Go's spellings (`1`, `t`, `T`, `true`, `TRUE`, `True`);
`EnvTruth` takes only four. They answer different questions, and both
are documented where they live:

- `Bool` converts a value somebody **typed**.
- `EnvTruth` decides whether an environment variable counts as **setting
a value-less flag**, and there it matches usage-lib exactly — a spec's
meaning should not change with the language reading it.

A test asserts they stay different widths, so nobody "fixes" one into
the other.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> New optional conversion helpers and error rendering paths; no changes
to core binding grammar or security-sensitive flows beyond safer display
of bad user input.
> 
> **Overview**
> Adds **post-binding converters** in `argv` (`Int`, `Uint`, `Float`,
`Bool`, `Duration`, `Each`) so bound flag/arg strings become typed
values, with failures surfaced as **`CodeInvalidValue`** carrying the
offending text and expected type.
> 
> **Float/uint edge cases** are aligned with the Rust port (e.g. `+8`
for unsigned, rejecting Go-only float spellings, overflow/underflow and
signed-NaN behavior) and **inputs are not trimmed**, matching
cross-language spec behavior. **`Render`** / **`Error()`** quote
rejected values through **`safe`**, and the README documents typed
values while reframing the roadmap gap as a **generated typed struct**
rather than missing converters.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
668ad12. 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>
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