From 091a5b0aa254f25a96ae44c7c9d54cc18d3f1939 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Tue, 3 Nov 2026 02:48:16 +0000 Subject: [PATCH 1/7] feat(go): refuse undeclared environment variables in generated Parse A spec's root-level unknown_env now reaches generated Go. The generator collects every variable the spec declares (env, env_fallback and deprecated_env across the whole tree, clauses included, config props, and allow) into UnknownEnvKnown, and Parse calls argv.RefuseUnknownEnv after help and version and before requirements. The error is CodeUnknownEnv with the prefix and every offender, sorted; case-insensitive on Windows. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude AI-Assisted: Tool: Claude Code; model: unavailable; version: 2.1.309 Entire-Checkpoint: 01M6FMY95QGBAKKGERMMQW9SMX --- docs/go/parser.md | 23 +++++ go/argv/argv.go | 20 ++++ go/argv/env.go | 99 ++++++++++++++++++++ go/argv/env_test.go | 38 ++++++++ go/argv/render.go | 7 ++ go/conformance/generated_unknown_env_test.go | 67 +++++++++++++ lib/src/go/mod.rs | 80 +++++++++++++++- lib/src/go/structs.rs | 20 +++- 8 files changed, 351 insertions(+), 3 deletions(-) create mode 100644 go/argv/env.go create mode 100644 go/argv/env_test.go create mode 100644 go/conformance/generated_unknown_env_test.go diff --git a/docs/go/parser.md b/docs/go/parser.md index 24eceef5f..5c71bd803 100644 --- a/docs/go/parser.md +++ b/docs/go/parser.md @@ -69,6 +69,29 @@ parse with `CodeHelp` and `Error.Cmd` set to the command asked about. `Error.Lon Counting flags need nothing from the parser — each occurrence is its own event, and the caller tallies (generated code does `field++`). +## Unknown environment variables + +A spec with a root-level `unknown_env` makes generated `Parse` refuse environment variables +under that prefix that nothing reads: + +```kdl +unknown_env prefix="MOQ_" { + allow "MOQ_VAAPI_DEVICE" +} +``` + +A variable is known when any flag or argument anywhere in the command tree, clauses included, +names it as `env`, `env_fallback` or `deprecated_env`, when a `config` property reads it, or +when `allow` lists it. The generator works that list out once and emits it as +`UnknownEnvKnown`, beside `UnknownEnvPrefix`. After help and version have been answered, and +before any requirement is checked, `Parse` fails with `CodeUnknownEnv`: `Error.Name` is the +prefix and `Error.Names` every offending variable, sorted. On Windows the comparison is +case-insensitive. + +The check is `argv.RefuseUnknownEnv(prefix, known, argv.EnvNames())`, and `argv.UnknownEnv` +answers the same question over any list of names, which is how to test it without touching the +process environment. + ## Limits - `argv.MaxDepth` is 16; deeper command trees fail with `CodeTooDeep`. (mise is 4 deep.) diff --git a/go/argv/argv.go b/go/argv/argv.go index 3f0e6b3fb..89a865df1 100644 --- a/go/argv/argv.go +++ b/go/argv/argv.go @@ -27,6 +27,8 @@ // [conformance corpus]: https://github.com/jdx/usage/tree/main/corpus package argv +import "strings" + // MaxDepth is how deep a command tree the parser will descend. // // The ancestor chain lives in a fixed-size array so that a parse allocates @@ -422,6 +424,10 @@ const ( // CodeDuplicateFlag means a strict command received a single-valued flag // more than once. CodeDuplicateFlag + // CodeUnknownEnv means the environment holds variables under the spec's + // `unknown_env` prefix that nothing declares. Name is the prefix, Names the + // variables. Raised by [RefuseUnknownEnv], after help and version. + CodeUnknownEnv ) var codeNames = [...]string{ @@ -441,6 +447,7 @@ var codeNames = [...]string{ CodeConflictingFlags: "conflicting_flags", CodeInvalidValue: "invalid_value", CodeDuplicateFlag: "duplicate_flag", + CodeUnknownEnv: "unknown_env", } // String gives the code the corpus spells it with. @@ -509,6 +516,8 @@ type Error struct { // Both are carried because either alone reads as a puzzle: which flag is // unwelcome depends on what else was given. Other string + // Names is every offending variable for CodeUnknownEnv, sorted. + Names []string } // Error is the message Go's own error interface asks for. @@ -556,6 +565,17 @@ func (e *Error) Error() string { message += ": " + e.Reason } return message + case CodeUnknownEnv: + // Through `safe` because the environment is as much outside input as argv. + var message strings.Builder + message.WriteString("unknown environment " + pluralWord(len(e.Names), "variable") + " under " + e.Name + ": ") + for i, name := range e.Names { + if i > 0 { + message.WriteString(", ") + } + message.WriteString(safe(name)) + } + return message.String() } return "parse error" } diff --git a/go/argv/env.go b/go/argv/env.go new file mode 100644 index 000000000..f5a096e4e --- /dev/null +++ b/go/argv/env.go @@ -0,0 +1,99 @@ +package argv + +import ( + "os" + "runtime" + "slices" + "strings" +) + +// UnknownEnv is the variables in names that start with prefix and are not in +// known, sorted and each once. +// +// A spec's `unknown_env` asks for this: a misspelled or stale variable under the +// CLI's own prefix is read by nobody, and the program runs on its defaults. known +// is every name the spec declares anywhere in its command tree, and its allow +// list; the generator collects it, since the whole tree counts and not only the +// command that ran — a variable meant for `serve` is not a typo when the user runs +// `publish`. +// +// names is passed in rather than read from the process for the reason [Fill] +// takes its lookup: a test of a parse should not be a test of the machine it runs +// on. [EnvNames] reads the process. On Windows, where variable names are +// case-insensitive, so is the comparison. +func UnknownEnv(prefix string, known, names []string) []string { + var unknown []string + for _, name := range names { + if len(name) < len(prefix) || !sameEnv(name[:len(prefix)], prefix) { + continue + } + if slices.ContainsFunc(known, func(k string) bool { return sameEnv(k, name) }) { + continue + } + unknown = append(unknown, name) + } + slices.Sort(unknown) + return slices.Compact(unknown) +} + +// RefuseUnknownEnv is [UnknownEnv] as the error a parse returns, with every +// offending variable named at once: a deployment with two typos should not need +// two restarts to find them. It returns nil when there are none. +func RefuseUnknownEnv(prefix string, known, names []string) error { + unknown := UnknownEnv(prefix, known, names) + if len(unknown) == 0 { + return nil + } + return &Error{Code: CodeUnknownEnv, Name: prefix, Names: unknown} +} + +// EnvNames is the names of the process environment, for callers that want it. +func EnvNames() []string { + environ := os.Environ() + names := make([]string, 0, len(environ)) + for _, entry := range environ { + // Windows keeps per-drive working directories as `=C:=C:\dir`, so the + // separator is the first `=` after the name's first byte. + if i := strings.IndexByte(entry[min(1, len(entry)):], '='); i >= 0 { + entry = entry[:i+min(1, len(entry))] + } + names = append(names, entry) + } + return names +} + +func sameEnv(a, b string) bool { + if runtime.GOOS == "windows" { + return asciiEqualFold(a, b) + } + return a == b +} + +// asciiEqualFold folds ASCII only, as Windows and usage-lib do: strings.EqualFold +// would also match the Kelvin sign to K. +func asciiEqualFold(a, b string) bool { + if len(a) != len(b) { + return false + } + for i := range len(a) { + x, y := a[i], b[i] + if 'A' <= x && x <= 'Z' { + x += 'a' - 'A' + } + if 'A' <= y && y <= 'Z' { + y += 'a' - 'A' + } + if x != y { + return false + } + } + return true +} + +// pluralWord is the noun alone, without [plural]'s count. +func pluralWord(n int, noun string) string { + if n == 1 { + return noun + } + return noun + "s" +} diff --git a/go/argv/env_test.go b/go/argv/env_test.go new file mode 100644 index 000000000..ba3982045 --- /dev/null +++ b/go/argv/env_test.go @@ -0,0 +1,38 @@ +package argv + +import ( + "reflect" + "runtime" + "testing" +) + +func TestUnknownEnv(t *testing.T) { + known := []string{"EX_PORT", "EX_LIBRARY"} + names := []string{"EX_TSL", "HOME", "EX_PORT", "EX_", "EX", "EX_PROT", "EX_TSL", "EXTRA"} + got := UnknownEnv("EX_", known, names) + if want := []string{"EX_", "EX_PROT", "EX_TSL"}; !reflect.DeepEqual(got, want) { + t.Fatalf("got %q, want %q", got, want) + } + if err := RefuseUnknownEnv("EX_", known, []string{"EX_PORT"}); err != nil { + t.Fatalf("nothing unknown should be a nil error, got %#v", err) + } + // Windows names are case-insensitive; elsewhere a different case is a different variable. + got = UnknownEnv("EX_", known, []string{"EX_port", "Ex_Port", "ex_other"}) + want := []string{"EX_port"} + if runtime.GOOS == "windows" { + want = []string{"ex_other"} + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("case: got %q, want %q", got, want) + } +} + +func TestUnknownEnvRendering(t *testing.T) { + err := &Error{Code: CodeUnknownEnv, Name: "EX_", Names: []string{"EX_PROT"}} + if got, want := err.Error(), "unknown environment variable under EX_: EX_PROT"; got != want { + t.Fatalf("Error(): %q", got) + } + if got, want := Render(err, nil, nil, nil), "error: unknown environment variable under `EX_`: `EX_PROT`\n\nFor more information, try `--help`.\n"; got != want { + t.Fatalf("Render: %q", got) + } +} diff --git a/go/argv/render.go b/go/argv/render.go index b0ea7c82c..be01db290 100644 --- a/go/argv/render.go +++ b/go/argv/render.go @@ -123,6 +123,13 @@ func explain(err *Error, help HelpTable) string { typedAs(err.OtherSpelling, other) + "` cannot be given together" case CodeDuplicateFlag: return "`" + typedAs(err.Spelling, err.Name) + "` cannot be used multiple times" + case CodeUnknownEnv: + names := make([]string, len(err.Names)) + for i, name := range err.Names { + names[i] = "`" + safe(name) + "`" + } + return "unknown environment " + pluralWord(len(names), "variable") + " under `" + err.Name + "`: " + + strings.Join(names, ", ") } return "the command line could not be parsed" } diff --git a/go/conformance/generated_unknown_env_test.go b/go/conformance/generated_unknown_env_test.go new file mode 100644 index 000000000..b1895b99a --- /dev/null +++ b/go/conformance/generated_unknown_env_test.go @@ -0,0 +1,67 @@ +package conformance + +import "testing" + +// TestGeneratedUnknownEnv runs a generated Parse against the real process +// environment, which is what `unknown_env` judges. +func TestGeneratedUnknownEnv(t *testing.T) { + runGeneratedFixture(t, `name "example" +bin "example" +version "1.0" +unknown_env prefix="EX_" { + allow "EX_LIBRARY" +} +flag "--port " env="EX_PORT" +cmd "serve" { + flag "--tls" deprecated_env="EX_SSL" +} +cmd "publish" { + flag "--name " required=#true env="EX_NAME" +} +config { + prop "jobs" env="EX_JOBS" +} +`, `package generated +import ( + "errors" + "os" + "reflect" + "strings" + "testing" + "github.com/jdx/usage/go/argv" +) +func clearEx(t *testing.T) { + for _, entry := range os.Environ() { + if name, _, _ := strings.Cut(entry, "="); strings.HasPrefix(name, "EX_") { + t.Setenv(name, "") + os.Unsetenv(name) + } + } +} +func TestUnknownEnv(t *testing.T) { + clearEx(t) + // Declared by another command, deprecated, read by config, allowed, or outside the prefix. + for _, name := range []string{"EX_PORT", "EX_SSL", "EX_JOBS", "EX_LIBRARY", "EXTRA"} { t.Setenv(name, "1") } + t.Setenv("EX_NAME", "n") + if _, err := Parse([]string{"publish"}); err != nil { t.Fatalf("declared names: %v", err) } + + t.Setenv("EX_TSL", "1") + t.Setenv("EX_PROT", "1") + _, err := Parse([]string{"serve"}); var req *argv.Error + if !errors.As(err, &req) || req.Code != argv.CodeUnknownEnv { t.Fatalf("typos: %v", err) } + if req.Name != "EX_" || !reflect.DeepEqual(req.Names, []string{"EX_PROT", "EX_TSL"}) { t.Fatalf("every offender, sorted: %#v", req) } + if got, want := err.Error(), "unknown environment variables under EX_: EX_PROT, EX_TSL"; got != want { t.Fatalf("message: %q", got) } + + // Reported ahead of the requirement it may be the cause of. + os.Unsetenv("EX_NAME") + _, err = Parse([]string{"publish"}) + if !errors.As(err, &req) || req.Code != argv.CodeUnknownEnv { t.Fatalf("before requirements: %v", err) } + + // Help and version still answer. + for _, args := range [][]string{{"--help"}, {"help", "serve"}, {"--version"}} { + _, err = Parse(args) + if !errors.As(err, &req) || (req.Code != argv.CodeHelp && req.Code != argv.CodeVersion) { t.Fatalf("%v: %v", args, err) } + } +} +`, "unknown-env") +} diff --git a/lib/src/go/mod.rs b/lib/src/go/mod.rs index 715a5d94f..763814b0e 100644 --- a/lib/src/go/mod.rs +++ b/lib/src/go/mod.rs @@ -187,7 +187,13 @@ impl<'a> Emitter<'a> { self.tables(&commands); self.metadata(&commands); self.help_table(&commands); - structs::emit(&mut self.out, &commands, &self.field_types); + self.unknown_env(); + structs::emit( + &mut self.out, + &commands, + &self.field_types, + self.spec.unknown_env.is_some(), + ); // Each command is followed by a blank line, which leaves one at the end of // the file. gofmt strips it, and a generated file that is not gofmt-clean @@ -198,6 +204,35 @@ impl<'a> Emitter<'a> { self.out } + /// The spec's `unknown_env`, as the two values `Parse` hands `argv.RefuseUnknownEnv`. + /// + /// The known names are worked out here rather than at run time because a generated + /// package carries tables, not a spec: the `config` properties that also read + /// variables are not in them. + fn unknown_env(&mut self) { + let Some(decl) = &self.spec.unknown_env else { + return; + }; + let mut known: Vec = crate::spec::unknown_env::declared_env_names(self.spec) + .into_iter() + .map(str::to_string) + .chain(decl.allow.iter().cloned()) + .collect(); + known.sort(); + known.dedup(); + let _ = writeln!( + self.out, + "// UnknownEnvPrefix scopes the spec's unknown_env check: a variable under it that\n\ + // nothing declares fails the parse.\n\ + const UnknownEnvPrefix = {}\n\n\ + // UnknownEnvKnown is every variable the spec declares, anywhere in the command\n\ + // tree or its config, and every one it allows.\n\ + var UnknownEnvKnown = {}\n", + go_string(&decl.prefix), + string_slice(&known) + ); + } + /// Walk the tree, naming everything, so that emission is a second pass with no /// lookaheads. fn collect(&mut self, cmd: &SpecCommand, path: &[&str], root: bool, out: &mut Vec) { @@ -1927,6 +1962,49 @@ cmd "exec" unknown_flags="value" { insta::assert_snapshot!(out); } + /// The known names cover every way a spec reads a variable, from every command and + /// clause and the config, because generated Go has only the tables to go on. + #[test] + fn unknown_env_reaches_generated_go_with_every_declared_name() { + let out = go(r#" +name "ex" +bin "ex" +unknown_env prefix="EX_" { + allow "EX_LIBRARY" +} +flag "--port " env="EX_PORT" env_fallback="EX_OLD_PORT" +cmd "serve" { + flag "--tls" deprecated_env="EX_SSL" + clause "route" { + flag "--path " env="EX_ROUTE_PATH" + arg "" + } +} +cmd "publish" { + arg "[dir]" env="EX_DIR" +} +config { + prop "jobs" env="EX_JOBS" +} +"#); + assert!(out.contains("const UnknownEnvPrefix = \"EX_\""), "{out}"); + assert!( + out.contains( + "var UnknownEnvKnown = []string{\"EX_DIR\", \"EX_JOBS\", \"EX_LIBRARY\", \"EX_OLD_PORT\", \"EX_PORT\", \"EX_ROUTE_PATH\", \"EX_SSL\"}" + ), + "{out}" + ); + assert!( + out.contains( + "argv.RefuseUnknownEnv(UnknownEnvPrefix, UnknownEnvKnown, argv.EnvNames())" + ), + "{out}" + ); + + let without = go("name \"ex\"\nbin \"ex\"\nflag \"--port \" env=\"EX_PORT\"\n"); + assert!(!without.contains("UnknownEnv"), "{without}"); + } + /// mise declares both a `macos-defaults` command and a `macos defaults` path, /// and both want the same Go identifier. #[test] diff --git a/lib/src/go/structs.rs b/lib/src/go/structs.rs index 0bcaaca18..57c3f429d 100644 --- a/lib/src/go/structs.rs +++ b/lib/src/go/structs.rs @@ -30,7 +30,12 @@ struct ClauseFields { type Clauses = HashMap; /// Write every command's struct, then `Parse`. -pub(super) fn emit(out: &mut String, commands: &[Emitted], types: &BTreeMap) { +pub(super) fn emit( + out: &mut String, + commands: &[Emitted], + types: &BTreeMap, + unknown_env: bool, +) { let mut assigned: Fields = HashMap::new(); let mut clauses: Clauses = HashMap::new(); for e in commands { @@ -177,7 +182,7 @@ pub(super) fn emit(out: &mut String, commands: &[Emitted], types: &BTreeMap, + unknown_env: bool, ) { let root = &commands[0]; let mut strict_keys = commands @@ -536,6 +542,15 @@ fn parse_fn( } let negated_arg = if needs_negated { "negated" } else { "nil" }; + // After help and version, so the user asking how to fix the typo gets the answer, + // and before the requirements, since a misspelled variable is the likelier story + // behind a required flag that turns up missing. + let unknown_env = if unknown_env { + "\tif err := argv.RefuseUnknownEnv(UnknownEnvPrefix, UnknownEnvKnown, argv.EnvNames()); err != nil {\n\ + \t\treturn nil, err\n\t}\n" + } else { + "" + }; let _ = writeln!( out, "\t\t\t}}\n{clause_separator_event}\t\t}}\n\t}}\n\ @@ -543,6 +558,7 @@ fn parse_fn( \tif err := p.Err(); err != nil {{\n\t\treturn nil, err\n\t}}\n\ \tif p.Command().ArgRequiredElseHelp && p.CommandStart() == len(args) {{\n\ \t\treturn nil, &argv.Error{{Code: argv.CodeHelp, Cmd: p.Command()}}\n\t}}\n\ + {unknown_env}\ \n\t// Only the commands the words actually selected are judged: a required\n\ \t// flag on a command nobody ran is not missing.\n\ \tvar scope []uint64\n\ From f75438e62157cd58772bf1855436951624fd515a Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Tue, 3 Nov 2026 03:10:51 +0000 Subject: [PATCH 2/7] fix(go): match usage-lib's Windows case folding and empty prefix in unknown_env Fold with Unicode rules, report a variable set in two cases once, and let an empty prefix own nothing, as usage-lib and usage-argv now do. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude AI-Assisted: Tool: Claude Code; model: unavailable; version: 2.1.309 Entire-Checkpoint: 01M6FP6RQDYHV78EGH92SXAYG1 --- go/argv/env.go | 46 ++++++++++++++++++++++----------------------- go/argv/env_test.go | 3 +++ 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/go/argv/env.go b/go/argv/env.go index f5a096e4e..9cb841d5d 100644 --- a/go/argv/env.go +++ b/go/argv/env.go @@ -20,11 +20,17 @@ import ( // names is passed in rather than read from the process for the reason [Fill] // takes its lookup: a test of a parse should not be a test of the machine it runs // on. [EnvNames] reads the process. On Windows, where variable names are -// case-insensitive, so is the comparison. +// case-insensitive, so is the comparison, and a variable set in two cases is +// reported once. func UnknownEnv(prefix string, known, names []string) []string { + // An empty prefix owns nothing: the spec refuses one, and a hand-built call + // that passes one must not make PATH an error. + if prefix == "" { + return nil + } var unknown []string for _, name := range names { - if len(name) < len(prefix) || !sameEnv(name[:len(prefix)], prefix) { + if !hasEnvPrefix(name, prefix) { continue } if slices.ContainsFunc(known, func(k string) bool { return sameEnv(k, name) }) { @@ -32,8 +38,14 @@ func UnknownEnv(prefix string, known, names []string) []string { } unknown = append(unknown, name) } - slices.Sort(unknown) - return slices.Compact(unknown) + // On Windows EX_TYPO and ex_typo are one variable, and listing it twice would + // read as two mistakes. + if runtime.GOOS == "windows" { + slices.SortStableFunc(unknown, func(a, b string) int { return strings.Compare(strings.ToUpper(a), strings.ToUpper(b)) }) + } else { + slices.Sort(unknown) + } + return slices.CompactFunc(unknown, sameEnv) } // RefuseUnknownEnv is [UnknownEnv] as the error a parse returns, with every @@ -62,32 +74,20 @@ func EnvNames() []string { return names } +// sameEnv folds case on Windows with Unicode rules, as usage-lib does, because +// Windows folds ä and Ä together too. func sameEnv(a, b string) bool { if runtime.GOOS == "windows" { - return asciiEqualFold(a, b) + return strings.EqualFold(a, b) } return a == b } -// asciiEqualFold folds ASCII only, as Windows and usage-lib do: strings.EqualFold -// would also match the Kelvin sign to K. -func asciiEqualFold(a, b string) bool { - if len(a) != len(b) { - return false - } - for i := range len(a) { - x, y := a[i], b[i] - if 'A' <= x && x <= 'Z' { - x += 'a' - 'A' - } - if 'A' <= y && y <= 'Z' { - y += 'a' - 'A' - } - if x != y { - return false - } +func hasEnvPrefix(name, prefix string) bool { + if runtime.GOOS == "windows" { + return strings.HasPrefix(strings.ToUpper(name), strings.ToUpper(prefix)) } - return true + return strings.HasPrefix(name, prefix) } // pluralWord is the noun alone, without [plural]'s count. diff --git a/go/argv/env_test.go b/go/argv/env_test.go index ba3982045..fc1830658 100644 --- a/go/argv/env_test.go +++ b/go/argv/env_test.go @@ -25,6 +25,9 @@ func TestUnknownEnv(t *testing.T) { if !reflect.DeepEqual(got, want) { t.Fatalf("case: got %q, want %q", got, want) } + if got := UnknownEnv("", known, []string{"PATH"}); got != nil { + t.Fatalf("an empty prefix owns nothing, got %q", got) + } } func TestUnknownEnvRendering(t *testing.T) { From 04c1c1cf08cfc7b0002fd10cf6b6eb9ffbd7ca9e Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Tue, 3 Nov 2026 03:24:27 +0000 Subject: [PATCH 3/7] fix(go): sort and dedup unknown_env names by one Windows case key sameEnv had become strings.EqualFold while the sort keyed on ToUpper. The two disagree on the Kelvin sign, so a name could be listed twice with another sorted between. Both now use envKey (ToUpper, as usage-lib does), and the Windows rules are tested on every platform. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude AI-Assisted: Tool: Claude Code; model: unavailable; version: 2.1.309 Entire-Checkpoint: 01M6FPZTM0RDGGPVCXMJJRH5BX --- go/argv/env.go | 47 ++++++++++++++++++++++----------------------- go/argv/env_test.go | 7 +++++++ 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/go/argv/env.go b/go/argv/env.go index 9cb841d5d..befdf9188 100644 --- a/go/argv/env.go +++ b/go/argv/env.go @@ -23,6 +23,18 @@ import ( // case-insensitive, so is the comparison, and a variable set in two cases is // reported once. func UnknownEnv(prefix string, known, names []string) []string { + return unknownEnv(prefix, known, names, runtime.GOOS == "windows") +} + +// unknownEnv is [UnknownEnv] with the platform as a parameter, so the Windows +// rules can be tested anywhere. +func unknownEnv(prefix string, known, names []string, fold bool) []string { + key := func(name string) string { + if fold { + return envKey(name) + } + return name + } // An empty prefix owns nothing: the spec refuses one, and a hand-built call // that passes one must not make PATH an error. if prefix == "" { @@ -30,22 +42,19 @@ func UnknownEnv(prefix string, known, names []string) []string { } var unknown []string for _, name := range names { - if !hasEnvPrefix(name, prefix) { + if !strings.HasPrefix(key(name), key(prefix)) { continue } - if slices.ContainsFunc(known, func(k string) bool { return sameEnv(k, name) }) { + if slices.ContainsFunc(known, func(k string) bool { return key(k) == key(name) }) { continue } unknown = append(unknown, name) } // On Windows EX_TYPO and ex_typo are one variable, and listing it twice would - // read as two mistakes. - if runtime.GOOS == "windows" { - slices.SortStableFunc(unknown, func(a, b string) int { return strings.Compare(strings.ToUpper(a), strings.ToUpper(b)) }) - } else { - slices.Sort(unknown) - } - return slices.CompactFunc(unknown, sameEnv) + // read as two mistakes. Sorted and compacted by the same key, so the names it + // calls equal are always adjacent. + slices.SortStableFunc(unknown, func(a, b string) int { return strings.Compare(key(a), key(b)) }) + return slices.CompactFunc(unknown, func(a, b string) bool { return key(a) == key(b) }) } // RefuseUnknownEnv is [UnknownEnv] as the error a parse returns, with every @@ -74,21 +83,11 @@ func EnvNames() []string { return names } -// sameEnv folds case on Windows with Unicode rules, as usage-lib does, because -// Windows folds ä and Ä together too. -func sameEnv(a, b string) bool { - if runtime.GOOS == "windows" { - return strings.EqualFold(a, b) - } - return a == b -} - -func hasEnvPrefix(name, prefix string) bool { - if runtime.GOOS == "windows" { - return strings.HasPrefix(strings.ToUpper(name), strings.ToUpper(prefix)) - } - return strings.HasPrefix(name, prefix) -} +// envKey is a Windows variable name as compared: uppercased with Unicode rules, +// as usage-lib does, because Windows folds ä and Ä together too. Not +// strings.EqualFold, which also folds the Kelvin sign to K where usage-lib and +// this key do not, and which no sort order can agree with. +func envKey(name string) string { return strings.ToUpper(name) } // pluralWord is the noun alone, without [plural]'s count. func pluralWord(n int, noun string) string { diff --git a/go/argv/env_test.go b/go/argv/env_test.go index fc1830658..b83c9b8d6 100644 --- a/go/argv/env_test.go +++ b/go/argv/env_test.go @@ -25,6 +25,13 @@ func TestUnknownEnv(t *testing.T) { if !reflect.DeepEqual(got, want) { t.Fatalf("case: got %q, want %q", got, want) } + // The Windows rules, wherever this runs: one variable set in two cases is + // reported once, and the Kelvin sign is not K, so EX_K is its own name + // and stays apart from EX_K even with EX_Z sorted between them. + got = unknownEnv("EX_", nil, []string{"EX_K", "EX_Z", "EX_K", "ex_k", "EX_ä", "EX_Ä"}, true) + if want := []string{"EX_K", "EX_Z", "EX_ä", "EX_K"}; !reflect.DeepEqual(got, want) { + t.Fatalf("windows: got %q, want %q", got, want) + } if got := UnknownEnv("", known, []string{"PATH"}); got != nil { t.Fatalf("an empty prefix owns nothing, got %q", got) } From bcc299c76dde5b91874e608ae68fd0eeee8ef8eb Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:13:07 +0000 Subject: [PATCH 4/7] test(go): fold case clearing EX_ variables and check exact request codes Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude AI-Assisted: Tool: Claude Code; model: unavailable; version: 2.1.309 Entire-Checkpoint: 01M6GN7N310AP4JAKRC101CYWP --- go/conformance/generated_unknown_env_test.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/go/conformance/generated_unknown_env_test.go b/go/conformance/generated_unknown_env_test.go index b1895b99a..bac8af6a7 100644 --- a/go/conformance/generated_unknown_env_test.go +++ b/go/conformance/generated_unknown_env_test.go @@ -32,7 +32,8 @@ import ( ) func clearEx(t *testing.T) { for _, entry := range os.Environ() { - if name, _, _ := strings.Cut(entry, "="); strings.HasPrefix(name, "EX_") { + // Upper-cased, since on Windows an inherited ex_typo is under EX_ too. + if name, _, _ := strings.Cut(entry, "="); strings.HasPrefix(strings.ToUpper(name), "EX_") { t.Setenv(name, "") os.Unsetenv(name) } @@ -58,9 +59,9 @@ func TestUnknownEnv(t *testing.T) { if !errors.As(err, &req) || req.Code != argv.CodeUnknownEnv { t.Fatalf("before requirements: %v", err) } // Help and version still answer. - for _, args := range [][]string{{"--help"}, {"help", "serve"}, {"--version"}} { - _, err = Parse(args) - if !errors.As(err, &req) || (req.Code != argv.CodeHelp && req.Code != argv.CodeVersion) { t.Fatalf("%v: %v", args, err) } + for _, tc := range []struct{ args []string; code argv.Code }{{[]string{"--help"}, argv.CodeHelp}, {[]string{"help", "serve"}, argv.CodeHelp}, {[]string{"--version"}, argv.CodeVersion}} { + _, err = Parse(tc.args) + if !errors.As(err, &req) || req.Code != tc.code { t.Fatalf("%v: %v", tc.args, err) } } } `, "unknown-env") From 581fad9c2abd5b59ccf325674604ec381d2fa963 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:39:06 +0000 Subject: [PATCH 5/7] fix(go): enforce unknown_env instead of refusing it, and match lib on non-UTF-8 names #1609 refuses unknown_env in Go generation until the Go parser enforces it; this PR does, so the guard and its test go. The Go check now also follows usage-lib on names that are not UTF-8: the prefix is matched on the valid head, such a name is never a declared one, and two are one only byte for byte. Ties in the folded sort fall back to the raw bytes, as in lib. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude AI-Assisted: Tool: Claude Code; model: unavailable; version: 2.1.309 Entire-Checkpoint: 01M6GPRPFF725RZT3XZNQ2MX3V --- go/argv/env.go | 40 +++++++++++++++++++++++++++++++++++----- go/argv/env_test.go | 18 +++++++++++++++++- lib/src/go/mod.rs | 11 ----------- 3 files changed, 52 insertions(+), 17 deletions(-) diff --git a/go/argv/env.go b/go/argv/env.go index befdf9188..8a11fa3d6 100644 --- a/go/argv/env.go +++ b/go/argv/env.go @@ -5,6 +5,7 @@ import ( "runtime" "slices" "strings" + "unicode/utf8" ) // UnknownEnv is the variables in names that start with prefix and are not in @@ -42,19 +43,48 @@ func unknownEnv(prefix string, known, names []string, fold bool) []string { } var unknown []string for _, name := range names { - if !strings.HasPrefix(key(name), key(prefix)) { + // The prefix is matched against the valid UTF-8 head of the name, as + // usage-lib does: a prefix ending in U+FFFD must not claim a name whose + // invalid byte only reads as one. + if !strings.HasPrefix(key(validHead(name)), key(prefix)) { continue } - if slices.ContainsFunc(known, func(k string) bool { return key(k) == key(name) }) { + // A name that is not UTF-8 is never a declared one. + if utf8.ValidString(name) && slices.ContainsFunc(known, func(k string) bool { return key(k) == key(name) }) { continue } unknown = append(unknown, name) } // On Windows EX_TYPO and ex_typo are one variable, and listing it twice would - // read as two mistakes. Sorted and compacted by the same key, so the names it + // read as two mistakes. Names that are not UTF-8 are the same only byte for + // byte, since folding would turn every invalid byte into U+FFFD. + same := func(a, b string) bool { + if utf8.ValidString(a) && utf8.ValidString(b) { + return key(a) == key(b) + } + return a == b + } + // Sorted by the folded spelling and then the raw bytes, so the names same // calls equal are always adjacent. - slices.SortStableFunc(unknown, func(a, b string) int { return strings.Compare(key(a), key(b)) }) - return slices.CompactFunc(unknown, func(a, b string) bool { return key(a) == key(b) }) + slices.SortFunc(unknown, func(a, b string) int { + if c := strings.Compare(key(strings.ToValidUTF8(a, "�")), key(strings.ToValidUTF8(b, "�"))); c != 0 { + return c + } + return strings.Compare(a, b) + }) + return slices.CompactFunc(unknown, same) +} + +// validHead is name up to its first byte that is not valid UTF-8. +func validHead(name string) string { + for i, r := range name { + if r == utf8.RuneError { + if _, size := utf8.DecodeRuneInString(name[i:]); size == 1 { + return name[:i] + } + } + } + return name } // RefuseUnknownEnv is [UnknownEnv] as the error a parse returns, with every diff --git a/go/argv/env_test.go b/go/argv/env_test.go index b83c9b8d6..2f7f5ab51 100644 --- a/go/argv/env_test.go +++ b/go/argv/env_test.go @@ -29,9 +29,25 @@ func TestUnknownEnv(t *testing.T) { // reported once, and the Kelvin sign is not K, so EX_K is its own name // and stays apart from EX_K even with EX_Z sorted between them. got = unknownEnv("EX_", nil, []string{"EX_K", "EX_Z", "EX_K", "ex_k", "EX_ä", "EX_Ä"}, true) - if want := []string{"EX_K", "EX_Z", "EX_ä", "EX_K"}; !reflect.DeepEqual(got, want) { + if want := []string{"EX_K", "EX_Z", "EX_Ä", "EX_K"}; !reflect.DeepEqual(got, want) { t.Fatalf("windows: got %q, want %q", got, want) } + // ß has no one-letter uppercase, so usage-lib keeps it as it is; so must this. + if got := envKey("EX_ß"); got != "EX_ß" { + t.Fatalf("ß folded to %q", got) + } + // Names that are not UTF-8: the prefix is matched on the valid head, so a + // prefix ending in U+FFFD does not claim an invalid byte; such a name is never + // a declared one; and two of them are one only when their bytes are. + for _, fold := range []bool{false, true} { + if got := unknownEnv("EX_�", nil, []string{"EX_\xff"}, fold); got != nil { + t.Fatalf("fold=%v: U+FFFD prefix claimed an invalid byte: %q", fold, got) + } + got := unknownEnv("EX_", []string{"EX_�"}, []string{"EX_\xfe", "EX_\xff", "EX_\xff"}, fold) + if want := []string{"EX_\xfe", "EX_\xff"}; !reflect.DeepEqual(got, want) { + t.Fatalf("fold=%v: non-UTF-8 names: got %q, want %q", fold, got, want) + } + } if got := UnknownEnv("", known, []string{"PATH"}); got != nil { t.Fatalf("an empty prefix owns nothing, got %q", got) } diff --git a/lib/src/go/mod.rs b/lib/src/go/mod.rs index 763814b0e..ffa121a15 100644 --- a/lib/src/go/mod.rs +++ b/lib/src/go/mod.rs @@ -72,10 +72,6 @@ pub fn generate_with_types( opts: &GoOptions, field_types: &BTreeMap, ) -> Result { - // A generated parser that ignored the policy would accept the very typos it names. - if spec.unknown_env.is_some() { - return Err("unknown_env is not supported by the Go generator yet".to_string()); - } if field_types.is_empty() { return Ok(generate(spec, opts)); } @@ -3010,11 +3006,4 @@ flag "--into " { // Rust would spell this `\u{7f}`, which Go rejects. assert_eq!(go_string("\u{7f}"), r#""\x7f""#); } - - #[test] - fn unknown_env_is_refused_until_the_go_parser_enforces_it() { - let spec: Spec = "name \"ex\"\nunknown_env prefix=\"EX_\"\n".parse().unwrap(); - let err = generate_with_types(&spec, &GoOptions::default(), &BTreeMap::new()).unwrap_err(); - assert!(err.contains("unknown_env"), "{err}"); - } } From ea159ff92ee059b5d47b6175dd4b82047c62d168 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:03:42 +0000 Subject: [PATCH 6/7] fix(go): refuse unknown_env with a mount in Go generation The Go generator does not emit mounted commands, so their variables could not reach UnknownEnvKnown and the generated parser would refuse what usage-lib accepts. Fail generation for the combination instead. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude AI-Assisted: Tool: Claude Code; model: unavailable; version: 2.1.309 Entire-Checkpoint: 01M6GZ1S6JJ2KRFNJMT9NG7W37 --- docs/go/parser.md | 3 +++ lib/src/go/mod.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/docs/go/parser.md b/docs/go/parser.md index 5c71bd803..7f111fef9 100644 --- a/docs/go/parser.md +++ b/docs/go/parser.md @@ -92,6 +92,9 @@ The check is `argv.RefuseUnknownEnv(prefix, known, argv.EnvNames())`, and `argv. answers the same question over any list of names, which is how to test it without touching the process environment. +A spec that has both `unknown_env` and a `mount` is refused: the generator does not emit mounted +commands, so it cannot know the variables they declare. + ## Limits - `argv.MaxDepth` is 16; deeper command trees fail with `CodeTooDeep`. (mise is 4 deep.) diff --git a/lib/src/go/mod.rs b/lib/src/go/mod.rs index ffa121a15..6b2903226 100644 --- a/lib/src/go/mod.rs +++ b/lib/src/go/mod.rs @@ -63,6 +63,10 @@ pub fn generate(spec: &Spec, opts: &GoOptions) -> String { Emitter::new(spec, opts).run() } +fn has_mount(cmd: &SpecCommand) -> bool { + !cmd.mounts.is_empty() || cmd.subcommands.values().any(has_mount) +} + /// Generate explicitly typed fields, keyed by the generated flag/argument constants. /// /// Unlike [`generate`], this rejects unknown keys and entries without text values. @@ -72,6 +76,16 @@ pub fn generate_with_types( opts: &GoOptions, field_types: &BTreeMap, ) -> Result { + // A mount's commands arrive at run time and this generator does not emit them, so + // `UnknownEnvKnown` could not hold their variables and the generated parser would + // refuse what usage-lib, which resolves mounts, accepts. + if spec.unknown_env.is_some() && has_mount(&spec.cmd) { + return Err( + "unknown_env cannot be combined with mount in the Go generator: the variables a \ + mounted command declares are not known at generation time" + .to_string(), + ); + } if field_types.is_empty() { return Ok(generate(spec, opts)); } @@ -2001,6 +2015,23 @@ config { assert!(!without.contains("UnknownEnv"), "{without}"); } + /// A mounted command's variables are not known when the tables are generated, so + /// the combination is refused rather than emitted as a parser that rejects them. + #[test] + fn unknown_env_with_a_mount_is_refused() { + let spec: Spec = "name \"ex\"\nbin \"ex\"\nunknown_env prefix=\"EX_\"\ncmd \"plugin\" {\n mount run=\"ex-plugin --usage\"\n}\n" + .parse() + .unwrap(); + let err = generate_with_types(&spec, &GoOptions::default(), &BTreeMap::new()).unwrap_err(); + assert!(err.contains("mount"), "{err}"); + + let spec: Spec = + "name \"ex\"\nbin \"ex\"\ncmd \"plugin\" {\n mount run=\"ex-plugin --usage\"\n}\n" + .parse() + .unwrap(); + assert!(generate_with_types(&spec, &GoOptions::default(), &BTreeMap::new()).is_ok()); + } + /// mise declares both a `macos-defaults` command and a `macos defaults` path, /// and both want the same Go identifier. #[test] From ca65f6e03333fa415aaf8f18862c878583618806 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:10:19 +0000 Subject: [PATCH 7/7] docs(go): say generate skips the unknown_env and mount check, and plan its Result for v8 Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude AI-Assisted: Tool: Claude Code; model: unavailable; version: 2.1.309 Entire-Checkpoint: 01M6GZCX779BBV4MN077A1RK73 --- V8.md | 13 +++++++++++++ lib/src/go/mod.rs | 5 +++++ 2 files changed, 18 insertions(+) diff --git a/V8.md b/V8.md index 8a6f903c9..fe885838f 100644 --- a/V8.md +++ b/V8.md @@ -88,3 +88,16 @@ derive keeps explicit names, and for a short-only flag it holds the short letter and the redeclaration check read. A saving here would drop `name` only where it equals `longs[0]` and keep a fallback for every other flag. `ArgMeta` (456 bytes) embeds its rarely used fields inline instead of behind a shared extra record, the way `FlagMeta` uses `FlagExtra`. + +## Go generation that can fail + +`usage::go::generate` returns a `String`, so it has no way to refuse a spec it cannot generate +correctly. A spec with both `unknown_env` and a `mount` is the first such case: the Go generator +does not emit mounted commands, so it cannot know their variables, and the generated parser +would refuse variables usage-lib accepts. `generate_with_types`, which the CLI calls, already +returns an error for it. + +- **What changes:** `generate` returns `Result` and refuses what + `generate_with_types` refuses. +- **Why it breaks:** the return type of a public function changes. +- **What adopters do:** handle the error, or call `generate_with_types` with an empty map today. diff --git a/lib/src/go/mod.rs b/lib/src/go/mod.rs index 6b2903226..e6f2af0b5 100644 --- a/lib/src/go/mod.rs +++ b/lib/src/go/mod.rs @@ -59,6 +59,11 @@ pub struct GoOptions { } /// Turn a spec into a Go source file declaring its parse tables. +/// +/// This does not check the spec: it cannot fail, so a spec with both `unknown_env` and a +/// `mount` is emitted with a parser that refuses the mounted commands' variables. +/// [`generate_with_types`] refuses that combination and is what `usage generate go` calls. +/// Returning a `Result` here waits for v8; see `V8.md`. pub fn generate(spec: &Spec, opts: &GoOptions) -> String { Emitter::new(spec, opts).run() }