Skip to content

feat(go): turn bound text into the type a field wants - #978

Merged
jdx merged 6 commits into
go/errorsfrom
go/typed
Aug 17, 2026
Merged

feat(go): turn bound text into the type a field wants#978
jdx merged 6 commits into
go/errorsfrom
go/typed

Conversation

@jdx

@jdx jdx commented Aug 17, 2026

Copy link
Copy Markdown
Owner

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


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.

Reviewed by Cursor Bugbot for commit 668ad12. 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: ac8453ff-aa14-431c-8851-9b961a89da42

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 typed conversion helpers for bound Go argv values and actionable invalid-value errors.

  • Adds integer, unsigned integer, float, boolean, duration, and slice conversion helpers.
  • Adds invalid-value error rendering with safe escaping of rejected input.
  • Documents typed conversion behavior and tests conversion compatibility and first-failure semantics.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
go/argv/value.go Adds typed conversion helpers without trimming preserved input and returns structured errors on conversion failures.
go/argv/argv.go Adds the invalid-value error code and safely escapes rejected values in the standard error representation.
go/argv/render.go Adds actionable invalid-value diagnostics while routing user-provided values through the existing control-character escaping helper.
go/argv/value_test.go Covers conversion behavior, cross-language edge cases, structured failures, first-failure semantics, and safe rendering.
go/argv/render_test.go Extends regression coverage to ensure invalid values cannot emit raw terminal controls through Error().

Reviews (9): Last reviewed commit: "fix(go): keep the value where Go complai..." | Re-trigger Greptile

Comment thread go/argv/value.go
Comment thread go/argv/render.go Outdated
Comment thread go/argv/render.go
@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                      197       200       205       210  ns
argh: argv -> struct                          274       282       288       296  ns
clap: build tree + parse -> struct         487294    489263    494067    500775  ns
bpaf: build parser + parse -> struct      1598238   1598238   1603583   1625809  ns

usage: argv -> struct                             225 ns      0.22 µs
clap: build tree + parse -> struct             520207 ns    520.21 µs
clap: parse -> struct, tree reused              25083 ns     25.08 µs
clap: build tree only                          316629 ns    316.63 µs

668ad12db7bb vs c5d9ad605c95 · measured on the runner, not pushed to the history.

Comment thread go/argv/argv.go Outdated
Comment thread go/argv/value.go
Comment thread go/argv/value.go

@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 1 potential issue.

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 fc3f88a. Configure here.

Comment thread go/argv/value.go
jdx and others added 6 commits August 17, 2026 20:27
Binding collects text on purpose: the grammar decides which token becomes which
flag or argument, 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.

Separate functions rather than one generic `Convert`, because 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 carries the text that would not convert *and* the type it was going
to. "Invalid value" alone makes a user guess which of their words was wrong, and
the whole reason the parser keeps the original bytes around is so that something
downstream can show them back.

`Each` exists so that 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.

One deliberate inconsistency, documented where both live: `Bool` takes Go's
spellings — `1`, `t`, `T`, `true`, `TRUE`, `True` — while `EnvTruth` takes only
four. They answer different questions. `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 because a spec's meaning
should not change with the language reading it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The converters trimmed surrounding whitespace, which is leniency neither the
grammar nor the sibling has. Checked rather than argued:

    " 8 ".parse::<i64>()      -> false
    " 1.5 ".parse::<f64>()    -> false
    " true ".parse::<bool>()  -> false

The parser goes out of its way to hand over the bytes the operating system gave
it — it does not re-split a token, and it keeps a value that is not valid UTF-8
rather than mangling it — so a converter quietly tidying them would mean a quoted
argument means one thing in Go and another in Rust from the same spec. `--jobs
" 8 "` is now refused on both sides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`CodeInvalidValue` embedded the user's text raw while the two messages beside it
— unknown flag and unexpected argument — already went through `safe`. It is the
likeliest of the three to carry something strange, since it exists precisely
because the text was not what the target type expected, so a crafted value could
push escape sequences to stderr through `Render`.

Escaped now, and tested the same way as the others: a value carrying an escape,
a carriage return and a newline comes out legible with none of them intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The same rule the tokens follow, for the field this commit's parent added: a
value the converters refused is text off the command line, and `Error()` handed
it to whatever prints or logs the error without escaping it.

Of the three fields quoted back, this is the likeliest to hold something
strange — it is here precisely because the text was not what the type expected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two places where Go's standard library and Rust's disagree, and the same spec
would have accepted different values depending on which side compiled it.

`+8` is a non-negative whole number, and `"+8".parse::<u64>()` says 8.
`strconv.ParseUint` refuses a sign outright, so the Go binding rejected it — and
said it was not a non-negative whole number, about a value that is plainly both.

`strconv.ParseFloat` takes digit separators and hexadecimal floats; `f64` takes
neither, so `1_5` and `0x1.8p0` converted in Go and failed in Rust. Everything
else agrees — `inf`, `NaN`, a bare `.5` — which is why this is a check on two
characters rather than a grammar of its own.

Both were confirmed by running the two standard libraries side by side, as the
trimming rule above was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two more edges where the same spec would have converted differently depending on
which language compiled it, both confirmed by running the two standard libraries
side by side.

A number too large to hold is `inf` in Rust. Go returns the same ±Inf and calls
it a range error, and treating every error as a refusal threw the value away. An
underflow is zero on both sides, and Go does not complain about that one at all.

A signed NaN — `+nan`, `-NaN` — parses in Rust and not in Go. The sign means
nothing either way.

The spellings that already agreed are pinned in the same test, so the special
cases cannot quietly grow into a grammar of their own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jdx
jdx merged commit e671ed8 into main Aug 17, 2026
11 checks passed
@jdx
jdx deleted the go/typed branch August 17, 2026 22:42
jdx added a commit that referenced this pull request Aug 17, 2026
`argv.Walk` reads the words before the cursor and reports what it is
standing in; `argv.Candidates` says what could go there — subcommands
and their aliases, the flags in scope, and the values a `choices` list
allows.

Stacked on #978.

## Asking the parser rather than re-deriving it

That is the whole design. A completion advertising a flag the parser
would refuse is worse than no completion, so the scope a candidate comes
from is the scope a token would be resolved in:

- a global is offered inside a subcommand
- a subcommand redeclaring an inherited name offers **its own**
- a hidden flag binds without being advertised
- past a `--`, nothing is offered — there is no flag of this CLI to type
there

## Errors are not failures here

A line being completed is unfinished by definition, so a parse error
means "the grammar runs out here" — which is the position being asked
about. `missing_flag_value` says the cursor is standing in a flag's
value, and that flag's choices take the position entirely. `help` says
the cursor is naming a command to *read about*, where nothing else
belongs.

## Not included

Turning candidates into the text each shell expects, and running the
`complete` scripts a spec can declare. The first is per-shell
formatting, the second runs subprocesses, and neither belongs in a
package whose claim is that a parse does not allocate. Both are noted in
the README.

## One note from writing the tests

The first fixture numbered its keys 10–14 in an eight-entry table. Both
cold tables are indexed by key, so every lookup missed and the
assertions about hiding and choices passed **for the wrong reason** —
they only started failing once the keys were dense. The invariant is
documented on `Metadata`; worth knowing that breaking it fails quietly
rather than loudly.

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

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> New completion surface area mirrors subtle parser/help scope rules;
behavior is heavily tested but mistakes would mislead users without
breaking parses.
> 
> **Overview**
> Adds **parser-driven tab completion** in `argv`: **`Walk`** parses
words before the cursor into a **`Position`** (command chain, whether
flags/subcommands still apply, awaiting flag value, variadic collection,
pending arg, `help` topic mode), and **`Candidates`** returns
subcommands/aliases, in-scope flag spellings, and **`choices`** values
filtered by a partial prefix.
> 
> Completion reuses the same scope rules as help via **`flagsInScope`**
(per-spelling shadowing of inherited globals, negation vs long-form
precedence) and refactors **`everyFormInScope`** /
**`negationSurvives`** in `scope.go` so help and completions stay
aligned. **`Parser.SubcommandsPossible`** exposes when descent into
subcommands has stopped.
> 
> README documents the completion API and notes that per-shell
formatting and `complete` scripts are still out of scope.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
84b303e. 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
#990)

The front door. An author calls `Parse` and gets a value with fields
rather than a loop over events — binding, the post-binding rules and the
three tables are unchanged underneath.

```go
cli, err := mycli.Parse(os.Args[1:])
if cli.Run != nil {
    fmt.Println(cli.Run.Task, cli.Run.Args)
}
```

Stacked on #989.

## Fields are strings and bools, on purpose

That is what a usage spec knows: it says what a value is *called* and
never what type it is. Turning `"8"` into an `int` stays the caller's
business — the conversions in #978 exist for exactly that, and inferring
a type from an argument's name would be guessing.

## Two things mise found that a small fixture could not

**Field names collide within a struct.** A command can declare a
`--shell` flag beside a `shell` subcommand, and mise does it with
`shell`, `version`, `command`, `env` and `tool`. The *kind*
disambiguates — `Shell` and `ShellCmd` — because that says which one it
is where `Shell2` would say only that there were two. The assignment is
worked out once and shared by the declarations and by `Parse`, so the
two cannot disagree about where a value goes.

**A subcommand's defaults were dropped.** The fallback assignment
started as a function taking the root struct, which cannot reach a
subcommand's — that lives in a local of `Parse`. mise's `bootstrap
packages import --manager` defaults to `brew` and came back empty. It is
inline now, where the variables are; only the keys of commands the words
selected are in scope, so the variable is never nil when its key is.

Both are tested against mise's real command lines, including the
`[ARGS]… [-- ARGS_LAST]…` split through the generated structs.

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

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Changes are documentation, a thin env helper, and shadow integration
tests; generated `Parse` behavior is exercised on real mise argv but
does not alter the core parser’s hot path.
> 
> **Overview**
> **Go CLI authors are steered toward generated `Parse` instead of
hand-rolling `argv.New` event loops.** `go/README.md` now shows
`mycli.Parse(os.Args[1:])`, nested command structs with nil pointers for
unselected branches, and notes that binding, post-rules, env/default
fill, and validation all happen inside `Parse` with `string` / `bool` /
`[]string` fields.
> 
> **`argv.LookupEnv` is added** as the process-environment hook that
generated `Parse` passes into `Fill`, keeping tests injectable while
real CLIs read `os.LookupEnv`.
> 
> **The mise shadow package gains integration tests** for the generated
front door: struct fill (`use -g node@20`), `--` arg splitting on `tasks
run`, subcommand defaults (`bootstrap packages import` → `brew`), and
choice validation (`--log-level chatty` → `invalid_choice`).
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
2fedce2. 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