From 67edfbe2b997359eb29f74d82ec992fbd2d44549 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:23:03 +0000 Subject: [PATCH 1/6] feat(go): turn bound text into the type a field wants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- go/README.md | 16 +++++-- go/argv/argv.go | 11 +++++ go/argv/render.go | 6 +++ go/argv/value.go | 97 +++++++++++++++++++++++++++++++++++++++++++ go/argv/value_test.go | 95 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 go/argv/value.go create mode 100644 go/argv/value_test.go diff --git a/go/README.md b/go/README.md index 2dee4f860..24aa701e5 100644 --- a/go/README.md +++ b/go/README.md @@ -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: @@ -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. diff --git a/go/argv/argv.go b/go/argv/argv.go index 0cfe7e97a..f0feec5c5 100644 --- a/go/argv/argv.go +++ b/go/argv/argv.go @@ -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{ @@ -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. @@ -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. @@ -369,6 +378,8 @@ 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: + return "invalid value for " + e.Name + ": " + e.Value } return "parse error" } diff --git a/go/argv/render.go b/go/argv/render.go index 1daf1ac8f..5ff1f7afc 100644 --- a/go/argv/render.go +++ b/go/argv/render.go @@ -101,6 +101,12 @@ 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: + msg := "`" + err.Name + "` does not accept `" + err.Value + "`" + if err.Want != "" { + msg += " (expected " + err.Want + ")" + } + return msg case CodeConflictingFlags: other := err.Other if other == "" { diff --git a/go/argv/value.go b/go/argv/value.go new file mode 100644 index 000000000..2e01e6196 --- /dev/null +++ b/go/argv/value.go @@ -0,0 +1,97 @@ +package argv + +import ( + "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. + +// Int converts a bound value, naming the entry in any failure. +func Int(name, value string) (int64, *Error) { + n, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + if err != nil { + return 0, invalid(name, value, "a whole number") + } + return n, nil +} + +// Uint is [Int] for a value that may not be negative. +func Uint(name, value string) (uint64, *Error) { + n, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64) + if err != nil { + return 0, invalid(name, value, "a whole number, not negative") + } + return n, nil +} + +// Float converts a bound value to a float. +func Float(name, value string) (float64, *Error) { + n, err := strconv.ParseFloat(strings.TrimSpace(value), 64) + if err != nil { + return 0, invalid(name, value, "a number") + } + return n, nil +} + +// 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(strings.TrimSpace(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(strings.TrimSpace(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} +} diff --git a/go/argv/value_test.go b/go/argv/value_test.go new file mode 100644 index 000000000..77232e4b9 --- /dev/null +++ b/go/argv/value_test.go @@ -0,0 +1,95 @@ +package argv + +import ( + "strings" + "testing" + "time" +) + +func TestConversions(t *testing.T) { + if n, err := Int("jobs", "8"); err != nil || n != 8 { + t.Errorf("want 8, got %v %v", n, err) + } + if n, err := Uint("jobs", "8"); err != nil || n != 8 { + t.Errorf("want 8, got %v %v", n, err) + } + if f, err := Float("ratio", "1.5"); err != nil || f != 1.5 { + t.Errorf("want 1.5, got %v %v", f, err) + } + if b, err := Bool("force", "true"); err != nil || !b { + t.Errorf("want true, got %v %v", b, err) + } + if d, err := Duration("wait", "1h30m"); err != nil || d != 90*time.Minute { + t.Errorf("want 1h30m, got %v %v", d, err) + } + // Surrounding space is the shell's leftovers, not the user's intent. + if n, err := Int("jobs", " 8 "); err != nil || n != 8 { + t.Errorf("want 8 from padded text, got %v %v", n, err) + } +} + +// A failure carries the text that would not convert and the type it was going +// to: a message saying only "invalid value" makes the user guess which of their +// words was wrong. +func TestAFailureNamesTheValueAndTheType(t *testing.T) { + cases := []struct { + what func() *Error + want string + }{ + {func() *Error { _, e := Int("jobs", "lots"); return e }, "whole number"}, + {func() *Error { _, e := Uint("jobs", "-1"); return e }, "not negative"}, + {func() *Error { _, e := Float("ratio", "half"); return e }, "a number"}, + {func() *Error { _, e := Bool("force", "yes"); return e }, "true or false"}, + {func() *Error { _, e := Duration("wait", "soon"); return e }, "duration"}, + } + for _, c := range cases { + err := c.what() + if err == nil { + t.Fatalf("want a failure for %q", c.want) + } + if err.Code != CodeInvalidValue { + t.Errorf("want invalid_value, got %q", err.Code) + } + if err.Value == "" || err.Name == "" { + t.Errorf("the failure should name both the entry and the value: %+v", err) + } + if !strings.Contains(err.Want, strings.Fields(c.want)[0]) { + t.Errorf("want %q described, got %q", c.want, err.Want) + } + // And it renders as something a person can act on. + if msg := explain(err, nil); !strings.Contains(msg, err.Value) { + t.Errorf("the rendered message should show the value: %q", msg) + } + } +} + +// `yes` is a bool to some CLIs and not to Go. The two truthiness rules in this +// package answer different questions and are deliberately different widths. +func TestBoolIsWiderThanEnvTruth(t *testing.T) { + if _, err := Bool("force", "yes"); err == nil { + t.Error("Go's spellings do not include `yes`") + } + if b, err := Bool("force", "T"); err != nil || !b { + t.Errorf("Go's spellings do include `T`: %v %v", b, err) + } + // EnvTruth is an allow-list matching usage-lib, and narrower. + if EnvTruth("T") { + t.Error("EnvTruth takes only 1, true, True and TRUE") + } +} + +func TestEachStopsAtTheFirstFailure(t *testing.T) { + got, err := Each("jobs", []string{"1", "2", "3"}, Int) + if err != nil || len(got) != 3 || got[2] != 3 { + t.Errorf("want [1 2 3], got %v %v", got, err) + } + _, err = Each("jobs", []string{"1", "two", "three"}, Int) + if err == nil { + t.Fatal("want a failure") + } + // The first, not the last: reporting `three` would send the user to the wrong + // word. + if err.Value != "two" { + t.Errorf("want the first failure, got %q", err.Value) + } +} From ac31a60c910defba8ded0ae2a269f315883fa3f9 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:30:35 +0000 Subject: [PATCH 2/6] fix(go): refuse a padded value, as the Rust sibling does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The converters trimmed surrounding whitespace, which is leniency neither the grammar nor the sibling has. Checked rather than argued: " 8 ".parse::() -> false " 1.5 ".parse::() -> false " true ".parse::() -> 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 --- go/argv/value.go | 17 +++++++++++------ go/argv/value_test.go | 8 +++++--- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/go/argv/value.go b/go/argv/value.go index 2e01e6196..ac0d6c145 100644 --- a/go/argv/value.go +++ b/go/argv/value.go @@ -2,7 +2,6 @@ package argv import ( "strconv" - "strings" "time" ) @@ -20,10 +19,16 @@ import ( // 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::()` +// 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(strings.TrimSpace(value), 10, 64) + n, err := strconv.ParseInt(value, 10, 64) if err != nil { return 0, invalid(name, value, "a whole number") } @@ -32,7 +37,7 @@ func Int(name, value string) (int64, *Error) { // Uint is [Int] for a value that may not be negative. func Uint(name, value string) (uint64, *Error) { - n, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64) + n, err := strconv.ParseUint(value, 10, 64) if err != nil { return 0, invalid(name, value, "a whole number, not negative") } @@ -41,7 +46,7 @@ func Uint(name, value string) (uint64, *Error) { // Float converts a bound value to a float. func Float(name, value string) (float64, *Error) { - n, err := strconv.ParseFloat(strings.TrimSpace(value), 64) + n, err := strconv.ParseFloat(value, 64) if err != nil { return 0, invalid(name, value, "a number") } @@ -57,7 +62,7 @@ func Float(name, value string) (float64, *Error) { // 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(strings.TrimSpace(value)) + b, err := strconv.ParseBool(value) if err != nil { return false, invalid(name, value, "true or false") } @@ -67,7 +72,7 @@ func Bool(name, value string) (bool, *Error) { // 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(strings.TrimSpace(value)) + d, err := time.ParseDuration(value) if err != nil { return 0, invalid(name, value, "a duration such as 30s or 1h30m") } diff --git a/go/argv/value_test.go b/go/argv/value_test.go index 77232e4b9..074f3b2f7 100644 --- a/go/argv/value_test.go +++ b/go/argv/value_test.go @@ -22,9 +22,11 @@ func TestConversions(t *testing.T) { if d, err := Duration("wait", "1h30m"); err != nil || d != 90*time.Minute { t.Errorf("want 1h30m, got %v %v", d, err) } - // Surrounding space is the shell's leftovers, not the user's intent. - if n, err := Int("jobs", " 8 "); err != nil || n != 8 { - t.Errorf("want 8 from padded text, got %v %v", n, err) + // And nothing is trimmed: `" 8 "` is a value the user quoted, and the Rust + // sibling refuses it too. Verified against `" 8 ".parse::()`, which is + // false. + if _, err := Int("jobs", " 8 "); err == nil { + t.Error("padded text should be refused, as it is in Rust") } } From 6b84c448946431d01caa8efc678e752a6b00de34 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:19:55 +0000 Subject: [PATCH 3/6] fix(go): escape the rejected value before showing it back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- go/argv/render.go | 5 ++++- go/argv/value_test.go | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/go/argv/render.go b/go/argv/render.go index 5ff1f7afc..2917e6fa8 100644 --- a/go/argv/render.go +++ b/go/argv/render.go @@ -102,7 +102,10 @@ func explain(err *Error, help HelpTable) string { plural(int(err.Bound), "time") + ", given " + itoa(err.Got) case CodeInvalidValue: - msg := "`" + err.Name + "` does not accept `" + err.Value + "`" + // 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 + ")" } diff --git a/go/argv/value_test.go b/go/argv/value_test.go index 074f3b2f7..0d4593ac0 100644 --- a/go/argv/value_test.go +++ b/go/argv/value_test.go @@ -95,3 +95,22 @@ func TestEachStopsAtTheFirstFailure(t *testing.T) { t.Errorf("want the first failure, got %q", err.Value) } } + +// The rejected value is quoted back, so it goes through the same escaping as the +// other messages that echo what the user typed — and it is the likeliest of them +// to carry something strange, since it exists because the text was unexpected. +func TestARejectedValueIsEscapedBeforeItIsShown(t *testing.T) { + _, err := Int("jobs", "\x1b[31m8\r\nerror: forged") + if err == nil { + t.Fatal("want a failure") + } + msg := explain(err, nil) + for _, forbidden := range []string{"\x1b", "\r", "\n"} { + if strings.Contains(msg, forbidden) { + t.Errorf("a control character survived into %q", msg) + } + } + if !strings.Contains(msg, `\x1b`) || !strings.Contains(msg, "forged") { + t.Errorf("the value should still be legible: %q", msg) + } +} From 0e1d8203cb642ecd53e4f06038ee3b78952e0c47 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:13:59 +0000 Subject: [PATCH 4/6] fix(go): escape the rejected value in the error value too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- go/argv/argv.go | 6 +++++- go/argv/render_test.go | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/go/argv/argv.go b/go/argv/argv.go index f0feec5c5..5a2907824 100644 --- a/go/argv/argv.go +++ b/go/argv/argv.go @@ -379,7 +379,11 @@ func (e *Error) Error() string { case CodeConflictingFlags: return e.Name + " cannot be given with " + e.Other case CodeInvalidValue: - return "invalid value for " + e.Name + ": " + e.Value + // 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" } diff --git a/go/argv/render_test.go b/go/argv/render_test.go index 8b72d37db..115e01e57 100644 --- a/go/argv/render_test.go +++ b/go/argv/render_test.go @@ -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"} { From 47b7c535850891edc56340debf45ecdfede2d242 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:10:51 +0000 Subject: [PATCH 5/6] fix(go): take the values Rust takes, and refuse the ones it refuses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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::()` 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 --- go/argv/value.go | 22 +++++++++++++++++++++- go/argv/value_test.go | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/go/argv/value.go b/go/argv/value.go index ac0d6c145..7b18f7458 100644 --- a/go/argv/value.go +++ b/go/argv/value.go @@ -2,6 +2,7 @@ package argv import ( "strconv" + "strings" "time" ) @@ -36,8 +37,18 @@ func Int(name, value string) (int64, *Error) { } // 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::()` +// 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) { - n, err := strconv.ParseUint(value, 10, 64) + 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") } @@ -45,7 +56,16 @@ func Uint(name, value string) (uint64, *Error) { } // Float converts a bound value to a float. +// +// Two of Go's spellings are refused first, because Rust has neither: +// `strconv.ParseFloat` takes digit separators (`1_5`) and hexadecimal floats +// (`0x1.8p0`), and `f64::from_str` takes neither. The rest agree, `inf` and `NaN` +// and a bare `.5` included, which is why this is a check on two characters rather +// than a reimplementation of the grammar. 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) if err != nil { return 0, invalid(name, value, "a number") diff --git a/go/argv/value_test.go b/go/argv/value_test.go index 0d4593ac0..aca00127c 100644 --- a/go/argv/value_test.go +++ b/go/argv/value_test.go @@ -30,6 +30,40 @@ func TestConversions(t *testing.T) { } } +// Where Go's conversions and Rust's disagree, the spec wins the same way in both. +// +// Every case here was checked by running both — the standard libraries do not +// agree by default, and the same spec compiled two ways would otherwise accept +// different values. +func TestTheConvertersAgreeWithRustAtTheEdges(t *testing.T) { + // A leading `+` is a sign, and Rust's u64 takes one. Go's ParseUint refuses + // the string outright. + if n, err := Uint("jobs", "+8"); err != nil || n != 8 { + t.Errorf("`+8` is 8 in Rust, got %v %v", n, err) + } + if _, err := Uint("jobs", "++8"); err == nil { + t.Error("one sign, not two") + } + if _, err := Uint("jobs", "+"); err == nil { + t.Error("a sign with no digits is not a number") + } + + // Go's ParseFloat takes digit separators and hexadecimal floats. Rust's f64 + // takes neither. + for _, v := range []string{"1_5", "0x1.8p0", "0x10"} { + if _, err := Float("ratio", v); err == nil { + t.Errorf("%q parses in Go and not in Rust, so it is refused here", v) + } + } + // The spellings both do take, so the check above is two characters rather + // than a grammar of its own. + for _, v := range []string{"1e3", ".5", "5.", "inf", "-inf", "NaN", "infinity"} { + if _, err := Float("ratio", v); err != nil { + t.Errorf("%q parses in Rust, so it should here: %v", v, err) + } + } +} + // A failure carries the text that would not convert and the type it was going // to: a message saying only "invalid value" makes the user guess which of their // words was wrong. From 668ad12db7bb2f07e064e9835162d9b0dd2c1a44 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:29:07 +0000 Subject: [PATCH 6/6] fix(go): keep the value where Go complains and Rust does not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- go/argv/value.go | 41 +++++++++++++++++++++++++++++++++-------- go/argv/value_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/go/argv/value.go b/go/argv/value.go index 7b18f7458..2f5cd4be5 100644 --- a/go/argv/value.go +++ b/go/argv/value.go @@ -1,6 +1,8 @@ package argv import ( + "errors" + "math" "strconv" "strings" "time" @@ -57,20 +59,43 @@ func Uint(name, value string) (uint64, *Error) { // Float converts a bound value to a float. // -// Two of Go's spellings are refused first, because Rust has neither: -// `strconv.ParseFloat` takes digit separators (`1_5`) and hexadecimal floats -// (`0x1.8p0`), and `f64::from_str` takes neither. The rest agree, `inf` and `NaN` -// and a bare `.5` included, which is why this is a check on two characters rather -// than a reimplementation of the grammar. +// 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) - if err != nil { - return 0, invalid(name, value, "a number") + 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 n, 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") } // Bool converts a bound value to a bool. diff --git a/go/argv/value_test.go b/go/argv/value_test.go index aca00127c..8da4e646a 100644 --- a/go/argv/value_test.go +++ b/go/argv/value_test.go @@ -1,6 +1,7 @@ package argv import ( + "math" "strings" "testing" "time" @@ -62,6 +63,33 @@ func TestTheConvertersAgreeWithRustAtTheEdges(t *testing.T) { t.Errorf("%q parses in Rust, so it should here: %v", v, err) } } + + // Too large to hold is `inf` in Rust; Go returns the same value beside a range + // error, so the value is kept and the error is not. + if f, err := Float("ratio", "1e1000"); err != nil || !math.IsInf(f, 1) { + t.Errorf("an overflow is +Inf in Rust, got %v %v", f, err) + } + if f, err := Float("ratio", "-1e1000"); err != nil || !math.IsInf(f, -1) { + t.Errorf("an overflow is -Inf in Rust, got %v %v", f, err) + } + // And an underflow is zero on both sides, without a complaint on either. + if f, err := Float("ratio", "1e-1000"); err != nil || f != 0 { + t.Errorf("an underflow is 0, got %v %v", f, err) + } + + // A signed NaN parses in Rust and not in Go. The sign means nothing either + // way. + for _, v := range []string{"+nan", "-nan", "+NaN"} { + if f, err := Float("ratio", v); err != nil || !math.IsNaN(f) { + t.Errorf("%q is NaN in Rust, got %v %v", v, f, err) + } + } + // Not a licence for anything else wearing a sign. + for _, v := range []string{"+n", "-nano", "+"} { + if _, err := Float("ratio", v); err == nil { + t.Errorf("%q is not a number in either language", v) + } + } } // A failure carries the text that would not convert and the type it was going