Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,17 @@ rules drift, so both are run over mise's real spec and compared — the same che
Two implementations checked against one oracle beats two checked against each
other.

## Typed values

Binding collects text, deliberately — the grammar decides which token becomes
which flag, not what it means. `argv.Int`, `Uint`, `Float`, `Bool`, `Duration`
and `Each` are where that text becomes a value, each failure carrying the word
that would not convert and the type it was going to.

Note `Bool` and `EnvTruth` are different widths on purpose: `Bool` takes Go's
spellings for a value somebody typed, `EnvTruth` is the narrower allow-list
usage-lib uses to decide whether a variable sets a value-less flag at all.

## Errors

`argv.Render` turns a failure into what a CLI should print to stderr:
Expand Down Expand Up @@ -207,9 +218,8 @@ claim is measured at real scale rather than against a fixture with four flags:

## What is missing

- **Typed values.** Binding collects text. Something still has to turn `"8"` into
an `int` and `"1m"` into a `time.Duration`, and report the ones that will not
convert.
- **A typed front door.** The conversions exist; what is missing is generated
code that calls them, so a CLI author gets a struct rather than events.
- **Completions.** The Rust side serves these from the parser's own scope rules so
that what is offered and what is accepted cannot disagree; the hooks for it
(`Collecting`, `PendingArg`, `FlagsInScope`, `CommandStart`) are already here.
15 changes: 15 additions & 0 deletions go/argv/argv.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,9 @@ const (
CodeVarTooMany
// CodeConflictingFlags means two flags declared to conflict were both given.
CodeConflictingFlags
// CodeInvalidValue means a value was given that the target type could not be
// built from.
CodeInvalidValue
)

var codeNames = [...]string{
Expand All @@ -276,6 +279,7 @@ var codeNames = [...]string{
CodeVarTooFew: "var_too_few",
CodeVarTooMany: "var_too_many",
CodeConflictingFlags: "conflicting_flags",
CodeInvalidValue: "invalid_value",
}

// String gives the code the corpus spells it with.
Expand Down Expand Up @@ -330,6 +334,11 @@ type Error struct {
// two var codes.
Bound uint32
Got int
// Value is the text that would not convert, and Want the type it was being
// converted to, for CodeInvalidValue. The text is carried because the whole
// point of the error is to show it back.
Value string
Want string
// Other is the flag [Name] cannot be given with, for CodeConflictingFlags.
// Both are carried because either alone reads as a puzzle: which flag is
// unwelcome depends on what else was given.
Expand Down Expand Up @@ -369,6 +378,12 @@ func (e *Error) Error() string {
return "too many occurrences of " + e.Name
case CodeConflictingFlags:
return e.Name + " cannot be given with " + e.Other
case CodeInvalidValue:
// Through `safe` for the same reason the tokens are: the rejected text came
// off the command line, and this one is likelier than most to hold
// something strange — it exists because the text was not what the type
// expected.
return "invalid value for " + e.Name + ": " + safe(e.Value)
}
return "parse error"
}
Expand Down
9 changes: 9 additions & 0 deletions go/argv/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,15 @@ func explain(err *Error, help HelpTable) string {
return "`" + typedAs(err.Spelling, err.Name) + "` accepts at most " +
plural(int(err.Bound), "time") +
", given " + itoa(err.Got)
case CodeInvalidValue:
// Through `safe` like the other two that quote what the user typed. This
// one is the likeliest of the three to carry something strange: it exists
// precisely because the text was not what the target type expected.
msg := "`" + err.Name + "` does not accept `" + safe(err.Value) + "`"
if err.Want != "" {
msg += " (expected " + err.Want + ")"
}
return msg
Comment thread
cursor[bot] marked this conversation as resolved.
case CodeConflictingFlags:
other := err.Other
if other == "" {
Expand Down
1 change: 1 addition & 0 deletions go/argv/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ func TestAnErrorValueIsSafeToPrintToo(t *testing.T) {
for _, e := range []*Error{
{Code: CodeUnknownFlag, Token: "--x\x1b[31m\r\nerror: forged"},
{Code: CodeUnexpectedArg, Token: "wat\x1b[31m\r\nerror: forged"},
{Code: CodeInvalidValue, Name: "jobs", Value: "8\x1b[31m\r\nerror: forged"},
} {
got := e.Error()
for _, forbidden := range []string{"\x1b", "\r", "\n"} {
Expand Down
147 changes: 147 additions & 0 deletions go/argv/value.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package argv

import (
"errors"
"math"
"strconv"
"strings"
"time"
)

// Turning bound text into the type a field wants.
//
// Binding collects text, deliberately: 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. This is where that asking happens.
//
// The functions are separate rather than one generic `Convert`, because the set
// of types a CLI wants is small and closed, and each one has its own idea of what
// it accepts — `1h30m` is a duration and not a number, `yes` is neither. A
// generated struct calls the one matching its field.
//
// Every failure carries the text that would not convert and the type it was being
// converted to. A message that says only "invalid value" makes the user guess
// which of their words was wrong.
//
// Nothing is trimmed on the way in. `" 8 "` is refused, as `" 8 ".parse::<i64>()`
// is on the Rust side — checked rather than assumed. The parser goes out of its
// way to hand over the bytes the operating system gave it, and a converter
// quietly tidying them would mean a quoted argument means one thing in Go and
// another in Rust from the same spec.

// Int converts a bound value, naming the entry in any failure.
func Int(name, value string) (int64, *Error) {
n, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return 0, invalid(name, value, "a whole number")
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
return n, nil
}

// Uint is [Int] for a value that may not be negative.
//
// A leading `+` is a sign, not a digit, and Rust takes it: `"+8".parse::<u64>()`
// is 8, while `strconv.ParseUint` refuses the string outright. Refusing `+8` here
// would have the same spec accept a value in Rust and reject it in Go — and the
// message would have said it was not a non-negative whole number, about a value
// that is plainly both.
func Uint(name, value string) (uint64, *Error) {
digits := value
if rest, found := strings.CutPrefix(digits, "+"); found {
digits = rest
}
n, err := strconv.ParseUint(digits, 10, 64)
if err != nil {
return 0, invalid(name, value, "a whole number, not negative")
}
return n, nil
}
Comment thread
cursor[bot] marked this conversation as resolved.

// Float converts a bound value to a float.
//
// The two standard libraries disagree at three edges, and each one is settled the
// way Rust settles it — a spec that means one thing compiled through the derive
// and another through this is the failure mode the whole port is written against.
//
// - Go takes digit separators (`1_5`) and hexadecimal floats (`0x1.8p0`);
// `f64::from_str` takes neither, so they are refused before parsing. Neither
// `_` nor `x` appears in any float Rust accepts, which is why this is a check
// on two characters rather than a grammar of its own.
// - A number too large to hold is `inf` in Rust and a range error in Go, which
// hands back the same ±Inf beside it. The value is kept and the error is not.
// - A signed NaN — `+nan`, `-NaN` — parses in Rust and not in Go. The sign
// means nothing on either side.
//
// Everything else agrees: `inf`, `infinity`, a bare `.5` or `5.`, and an
// underflow to zero.
func Float(name, value string) (float64, *Error) {
if strings.ContainsAny(value, "_xX") {
return 0, invalid(name, value, "a number")
}
n, err := strconv.ParseFloat(value, 64)
switch {
case err == nil:
return n, nil
case errors.Is(err, strconv.ErrRange):
// ±Inf for an overflow, zero for an underflow — the value Go returns is
// the one Rust would have.
return n, nil
case signedNaN(value):
return math.NaN(), nil
}
return 0, invalid(name, value, "a number")
}

// signedNaN reports the one spelling Rust's f64 takes that Go's does not.
func signedNaN(value string) bool {
return len(value) > 1 && (value[0] == '+' || value[0] == '-') &&
strings.EqualFold(value[1:], "nan")
}
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.

// Bool converts a bound value to a bool.
//
// The spellings are Go's own, which are also the ones `strconv.ParseBool` takes:
// `1`, `t`, `T`, `true`, `TRUE`, `True` and their false counterparts. Note this
// is *wider* than [EnvTruth], which an environment variable setting a value-less
// flag goes through — that one is an allow-list matching usage-lib, and the two
// answer different questions: this converts a value somebody typed, that one
// decides whether a variable counts as setting a flag at all.
func Bool(name, value string) (bool, *Error) {
b, err := strconv.ParseBool(value)
if err != nil {
return false, invalid(name, value, "true or false")
}
return b, nil
}

// Duration converts a bound value to a duration, in Go's notation: `1h30m`,
// `250ms`, `2s`.
func Duration(name, value string) (time.Duration, *Error) {
d, err := time.ParseDuration(value)
if err != nil {
return 0, invalid(name, value, "a duration such as 30s or 1h30m")
}
return d, nil
}

// Each maps a conversion over the values a variadic or repeatable entry
// collected, stopping at the first that will not convert.
//
// Written out because the alternative is every caller writing the same loop, and
// getting the early return wrong in a way that reports the last failure instead
// of the first.
func Each[T any](name string, values []string, convert func(string, string) (T, *Error)) ([]T, *Error) {
out := make([]T, 0, len(values))
for _, v := range values {
converted, err := convert(name, v)
if err != nil {
return nil, err
}
out = append(out, converted)
}
return out, nil
}

func invalid(name, value, want string) *Error {
return &Error{Code: CodeInvalidValue, Name: name, Value: value, Want: want}
}
Loading