From 2eb30e8b83849b5fd48fa94c47de550e74e115c6 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:34:53 +0000 Subject: [PATCH 1/5] feat(go): answer what could go where the cursor is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Walk` reads the words before the cursor and reports what it is standing in; `Candidates` says what could go there — subcommands and their aliases, the flags in scope, and the values a `choices` list allows. Both ask the parser rather than re-deriving its rules, and 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, and past a `--` nothing is offered because 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 the choices for that flag take the position entirely. `help` says the cursor is naming a command to read about, where nothing else belongs. Not included, and listed in the README: turning candidates into the text each shell expects, and running the `complete` scripts a spec can declare. The first is per-shell formatting and the second runs subprocesses; neither belongs in a package whose claim is that a parse does not allocate. One note from writing the tests. The first fixture numbered its keys 10 to 14 in an eight-entry table, so every cold-table lookup missed and the assertions about hiding and choices passed for the wrong reason — the tests only started failing once the keys were dense. The invariant is documented on `Metadata` and it is worth knowing that breaking it fails quietly rather than loudly. Co-Authored-By: Claude Opus 5 --- go/README.md | 18 ++- go/argv/complete.go | 254 +++++++++++++++++++++++++++++++++++++++ go/argv/complete_test.go | 157 ++++++++++++++++++++++++ 3 files changed, 426 insertions(+), 3 deletions(-) create mode 100644 go/argv/complete.go create mode 100644 go/argv/complete_test.go diff --git a/go/README.md b/go/README.md index 24aa701e5..374d7d270 100644 --- a/go/README.md +++ b/go/README.md @@ -167,6 +167,18 @@ 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. +## Completions + +`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. + +Both ask the parser rather than re-deriving its rules, which is the point: a +completion advertising a flag the parser would refuse is worse than no completion. +So a global is offered inside a subcommand, a redeclared name shadows the +inherited one, a hidden flag binds without being advertised, and past a `--` +nothing is offered at all. + ## Errors `argv.Render` turns a failure into what a CLI should print to stderr: @@ -220,6 +232,6 @@ claim is measured at real scale rather than against a fixture with four flags: - **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. +- **Per-shell completion output.** `Walk` and `Candidates` answer _what_ could go + at the cursor; turning that into the text bash, zsh, fish or PowerShell expect + is still to do, as is running the `complete` scripts a spec can declare. diff --git a/go/argv/complete.go b/go/argv/complete.go new file mode 100644 index 000000000..1b3b461e1 --- /dev/null +++ b/go/argv/complete.go @@ -0,0 +1,254 @@ +package argv + +import "strings" + +// What could go where the cursor is. +// +// A completion is a parse of an unfinished command line, which is why it lives +// beside the parser rather than on top of it: the words before the cursor decide +// what may follow, and the rules that decide are the binding rules. Asking the +// parser rather than re-deriving them is what keeps what is *offered* and what is +// *accepted* from disagreeing — a completion advertising a flag the parser would +// refuse is worse than no completion at all. +// +// This is the position and the candidates. Turning candidates into the format a +// particular shell wants, and running the `complete` scripts a spec can declare, +// are separate jobs: the first is per-shell text, the second runs subprocesses, +// and neither belongs in a package whose whole claim is that it does not allocate. + +// Position is what the cursor is standing in, after the words before it. +type Position struct { + // Cmd is the command in scope: the deepest one the words selected. + Cmd *Command + // Chain is the commands the words passed through, root first, which is what + // [ShortHelp] and the scope rules want. + Chain []*Command + // FlagsPossible is whether a dash-prefixed word here would still be read as a + // flag. False past a `--`, and past the first value of an `automatic` + // argument — there is no flag of *this* CLI to offer in either place. + FlagsPossible bool + // AwaitingValue is a flag whose value the cursor is standing in, if the last + // word was one that takes a value or a variadic still claiming words. + AwaitingValue *Flag + // NextArg is the positional a word here would fill, if any are left. + NextArg *Arg + // SeparatorSeen is whether a `--` has been typed. Narrower than + // FlagsPossible, and what an argument requiring a separator is asking about. + SeparatorSeen bool + // HelpTopic is whether the word here names a command to *read about* rather + // than one to run — after `help`, where nothing else belongs. + HelpTopic bool +} + +// Walk reads the words before the cursor and reports what the cursor is at. +// +// Errors are not failures here. A line being completed is by definition +// unfinished — a flag with no value yet, a word that names nothing yet — so a +// parse error means "the grammar runs out here", which is exactly the position +// being asked about. The walk stops at the first one and reports the state it +// reached, where a real parse must discard everything. +func Walk(root *Command, words []string) Position { + p := New(root, words) + chain := []*Command{root} + var awaiting *Flag + + for p.Next() { + if ev := p.Event(); ev.Kind == KindCommand { + chain = append(chain, ev.Command) + } + } + if err, ok := p.Err().(*Error); ok && err != nil { + switch err.Code { + // The one failure that says something about the cursor rather than about + // the line: the last word was a flag that takes a value, so the cursor is + // standing in it. + case CodeMissingFlagValue: + awaiting = err.Flag + // `ex help config ⌶` asks which command to read about, and the answer is a + // command under `config` — the one the help request already resolved. The + // parser never descended into it, on purpose, so the position comes from + // the request. Nothing else can be typed there: a topic takes no flags and + // fills no argument. + case CodeHelp: + return Position{Cmd: err.Cmd, Chain: chain, HelpTopic: true} + } + } + + return Position{ + Cmd: p.Command(), + Chain: chain, + // A variadic flag still claiming words stands in the same place as a flag + // waiting for its first value: the next word belongs to it, not to the + // positional after it. + AwaitingValue: firstFlag(awaiting, p.Collecting()), + FlagsPossible: !p.FlagsStopped(), + NextArg: p.PendingArg(), + SeparatorSeen: p.DoubleDashSeen(), + } +} + +// Kind of thing a candidate is, so a shell can decorate or filter them. +type CandidateKind uint8 + +const ( + // CandidateCommand is a subcommand name or alias. + CandidateCommand CandidateKind = iota + // CandidateFlag is a flag spelling. + CandidateFlag + // CandidateValue is one of a declared `choices` list. + CandidateValue +) + +// Candidate is one thing that could be typed where the cursor is. +type Candidate struct { + Kind CandidateKind + // Value is the text to insert. + Value string + // Describe is the one-line help, where there is any. A shell that can show a + // description beside a completion uses it; one that cannot ignores it. + Describe string +} + +// Candidates is everything that could go at a position, given a partial word. +// +// `partial` is what the user has typed of the current word, and filtering happens +// here rather than in the shell so that every shell agrees about what matches. +func Candidates(pos Position, partial string, help HelpTable, meta Metadata) []Candidate { + var out []Candidate + add := func(kind CandidateKind, value, describe string) { + if strings.HasPrefix(value, partial) { + out = append(out, Candidate{Kind: kind, Value: value, Describe: describe}) + } + } + + // A value the cursor is standing in takes the position entirely: nothing else + // belongs where a flag is waiting for its argument. + if pos.AwaitingValue != nil { + for _, c := range choicesFor(pos.AwaitingValue.Key, meta) { + add(CandidateValue, c, "") + } + return out + } + + // A help topic is a question, not an invocation: only command names belong. + if pos.HelpTopic { + for _, sub := range subcommandsOf(pos.Cmd) { + add(CandidateCommand, sub.Name, describe(sub.Key, help)) + } + return out + } + + if pos.Cmd != nil { + for _, sub := range subcommandsOf(pos.Cmd) { + add(CandidateCommand, sub.Name, describe(sub.Key, help)) + // Aliases too: a completion that hides them makes them undiscoverable, + // and the parser accepts them either way. Hidden ones stay hidden. + if h := help.Lookup(sub.Key); h != nil { + for _, alias := range h.VisibleAliases { + add(CandidateCommand, alias, describe(sub.Key, help)) + } + } + } + } + + // Flags, only where one could still be typed, and taken from the parser's own + // scope so that shadowing is respected: a subcommand redeclaring an inherited + // name offers its own. + if pos.FlagsPossible { + for _, f := range flagsInScope(pos.Chain) { + if h := help.Lookup(f.Key); h != nil && h.Hide { + continue + } + for _, long := range f.Longs { + add(CandidateFlag, "--"+long, describe(f.Key, help)) + } + for _, short := range f.Shorts { + add(CandidateFlag, "-"+string(short), describe(f.Key, help)) + } + if f.Negate != "" { + add(CandidateFlag, "--"+f.Negate, describe(f.Key, help)) + } + } + } + + // And the values a positional will only accept. + if pos.NextArg != nil { + for _, c := range choicesFor(pos.NextArg.Key, meta) { + add(CandidateValue, c, "") + } + } + return out +} + +// flagsInScope is this command's own flags, then any ancestor's globals — the +// order the parser looks in, so a redeclared name shadows the inherited one. +func flagsInScope(chain []*Command) []*Flag { + if len(chain) == 0 { + return nil + } + here := chain[len(chain)-1] + out := append([]*Flag{}, here.Flags...) + seen := map[string]bool{} + for _, f := range here.Flags { + for _, form := range formsOf(f) { + seen[form] = true + } + } + for i := len(chain) - 2; i >= 0; i-- { + for _, f := range chain[i].Flags { + if !f.Global { + continue + } + taken := false + for _, form := range formsOf(f) { + if seen[form] { + taken = true + } + } + if taken { + continue + } + for _, form := range formsOf(f) { + seen[form] = true + } + out = append(out, f) + } + } + return out +} + +func subcommandsOf(cmd *Command) []*Command { + if cmd == nil { + return nil + } + return cmd.Subcommands +} + +func choicesFor(key uint64, meta Metadata) []string { + if m := meta.Lookup(key); m != nil { + return m.Choices + } + return nil +} + +func describe(key uint64, help HelpTable) string { + h := help.Lookup(key) + if h == nil { + return "" + } + // The first line only: a shell shows one line beside a candidate, and a + // description that wraps turns a completion menu into a wall. + if at := strings.IndexByte(h.Short, '\n'); at >= 0 { + return h.Short[:at] + } + return h.Short +} + +func firstFlag(flags ...*Flag) *Flag { + for _, f := range flags { + if f != nil { + return f + } + } + return nil +} diff --git a/go/argv/complete_test.go b/go/argv/complete_test.go new file mode 100644 index 000000000..beff08913 --- /dev/null +++ b/go/argv/complete_test.go @@ -0,0 +1,157 @@ +package argv + +import ( + "strings" + "testing" +) + +// A CLI with the shapes completion has to get right: a global, a subcommand that +// shadows it, a flag with choices, and an argument with choices. +func completionFixture() (*Command, HelpTable, Metadata) { + // Keys dense from 1, because both cold tables are indexed by them — a sparse + // fixture looks up nothing and every assertion about hiding or choices passes + // for the wrong reason. Which is exactly what the first draft of this did. + verbose := &Flag{Key: 4, Name: "verbose", Longs: []string{"verbose"}, Shorts: []byte{'v'}, Global: true} + color := &Flag{Key: 5, Name: "color", Longs: []string{"color"}, Negate: "no-color"} + shell := &Flag{Key: 6, Name: "shell", Longs: []string{"shell"}, TakesValue: true} + hidden := &Flag{Key: 7, Name: "secret", Longs: []string{"secret"}} + mode := &Arg{Key: 8, Name: "MODE"} + + run := &Command{Name: "run", Key: 2, Flags: []*Flag{shell}, Args: []*Arg{mode}} + list := &Command{Name: "list", Key: 3} + root := &Command{Name: "ex", Key: 1, + Flags: []*Flag{verbose, color, hidden}, + Subcommands: []*Command{run, list}, + } + + help := HelpTable{ + {Key: 1}, {Key: 2, Short: "run it", VisibleAliases: []string{"r"}}, + {Key: 3, Short: "list them"}, {Key: 4, Short: "be loud"}, {Key: 5}, + {Key: 6}, {Key: 7, Hide: true}, {Key: 8}, + } + meta := Metadata{ + {Key: 1}, {Key: 2}, {Key: 3}, {Key: 4}, {Key: 5}, + {Key: 6, Name: "shell", Flag: true, Choices: []string{"bash", "zsh"}}, + {Key: 7}, {Key: 8, Name: "MODE", Choices: []string{"fast", "slow"}}, + } + return root, help, meta +} + +func values(cs []Candidate) []string { + out := make([]string, len(cs)) + for i, c := range cs { + out[i] = c.Value + } + return out +} + +func offered(list []string, want string) bool { + for _, s := range list { + if s == want { + return true + } + } + return false +} + +func complete(words []string, partial string) []string { + root, help, meta := completionFixture() + return values(Candidates(Walk(root, words), partial, help, meta)) +} + +func TestCompletionOffersCommandsFlagsAndAliases(t *testing.T) { + got := complete(nil, "") + for _, want := range []string{"run", "list", "r", "--verbose", "-v", "--color", "--no-color"} { + if !offered(got, want) { + t.Errorf("want %q offered, got %v", want, got) + } + } + // A hidden flag binds and is not advertised. + if offered(got, "--secret") { + t.Errorf("a hidden flag should not be offered: %v", got) + } +} + +func TestCompletionFiltersByThePartialWord(t *testing.T) { + got := complete(nil, "--co") + if !offered(got, "--color") { + t.Errorf("want --color, got %v", got) + } + for _, unwanted := range []string{"run", "--verbose"} { + if offered(got, unwanted) { + t.Errorf("%q does not start with the partial word: %v", unwanted, got) + } + } +} + +// A global is offered inside a subcommand, because the parser accepts it there. +func TestAGlobalIsOfferedInsideASubcommand(t *testing.T) { + got := complete([]string{"run"}, "") + if !offered(got, "--verbose") { + t.Errorf("an inherited global should be offered: %v", got) + } + // And the subcommand's own. + if !offered(got, "--shell") { + t.Errorf("the command's own flags should be offered: %v", got) + } + // A flag declared only on the root and not global is not in scope here. + if offered(got, "--color") { + t.Errorf("a non-global root flag is not accepted here, so should not be offered: %v", got) + } +} + +// A flag waiting for its value takes the position entirely. +func TestAWaitingValueOffersItsChoicesAndNothingElse(t *testing.T) { + got := complete([]string{"run", "--shell"}, "") + for _, want := range []string{"bash", "zsh"} { + if !offered(got, want) { + t.Errorf("want %q, got %v", want, got) + } + } + for _, unwanted := range []string{"--verbose", "list"} { + if offered(got, unwanted) { + t.Errorf("nothing else belongs where a value is expected: %v", got) + } + } +} + +func TestAPositionalOffersItsChoices(t *testing.T) { + if got := complete([]string{"run"}, ""); !offered(got, "fast") { + t.Errorf("the pending argument's choices should be offered: %v", got) + } +} + +// Past a `--` there is no flag of this CLI to offer. +func TestPastASeparatorNoFlagsAreOffered(t *testing.T) { + got := complete([]string{"run", "--"}, "-") + for _, unwanted := range []string{"--verbose", "--shell"} { + if offered(got, unwanted) { + t.Errorf("flag interpretation has stopped: %v", got) + } + } +} + +// `help` asks which command to read about; nothing else belongs there. +func TestAHelpTopicOffersOnlyCommands(t *testing.T) { + got := complete([]string{"help"}, "") + if !offered(got, "run") || !offered(got, "list") { + t.Errorf("want the commands, got %v", got) + } + for _, unwanted := range []string{"--verbose", "--color"} { + if offered(got, unwanted) { + t.Errorf("a topic takes no flags: %v", got) + } + } +} + +// A description is one line: a shell shows one line beside a candidate, and a +// wrapped description turns a completion menu into a wall. +func TestDescriptionsAreOneLine(t *testing.T) { + root, help, meta := completionFixture() + help[1].Short = "run it\nand keep running it" + for _, c := range Candidates(Walk(root, nil), "run", help, meta) { + if strings.Contains(c.Describe, "\n") { + t.Errorf("description should be one line: %q", c.Describe) + } + } +} From 2cfe73248888f0f0c5c3b86ef08527252dfdc754 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:21:16 +0000 Subject: [PATCH 2/5] fix(go): offer only what the parser would accept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings on the completion candidates, all of them the same failure in different places: advertising something the parser refuses, which is precisely what this design was meant to rule out. A hidden command was offered. Flags were filtered by `hide` and commands were not, so `hide` kept a command off its parent's help page while tab-completion listed it — including after `help`, where it would have been most discoverable. A help topic offered no aliases. `findNamed` resolves a topic by name or alias exactly as it resolves a command to run, so hiding them there made accepted spellings undiscoverable in the one place someone is looking for a name. An argument that reads only after a `--` had its choices offered before one. Those words come back as `arg_requires_double_dash` — a completion producing a command line the grammar refuses, which is the failure mode the whole approach exists to avoid. It waits for the separator now. And an inherited global was dropped whole when a nearer flag took one of its spellings. A flag answers to several, and a subcommand reclaiming `--jobs` leaves an inherited `-j` and `--workers` binding; withdrawing all three hid two the parser still accepts. Masking is per spelling now, which is the same rule the help pages follow and for the same reason. A spelling is claimed whether or not anything is left of the flag, so something farther away cannot pick it up either. Co-Authored-By: Claude Opus 5 --- go/argv/complete.go | 102 +++++++++++++++++++++++++-------------- go/argv/complete_test.go | 80 ++++++++++++++++++++++++++++-- 2 files changed, 142 insertions(+), 40 deletions(-) diff --git a/go/argv/complete.go b/go/argv/complete.go index 1b3b461e1..462a68ed4 100644 --- a/go/argv/complete.go +++ b/go/argv/complete.go @@ -130,20 +130,21 @@ func Candidates(pos Position, partial string, help HelpTable, meta Metadata) []C return out } - // A help topic is a question, not an invocation: only command names belong. - if pos.HelpTopic { - for _, sub := range subcommandsOf(pos.Cmd) { - add(CandidateCommand, sub.Name, describe(sub.Key, help)) - } - return out - } - - if pos.Cmd != nil { + // Commands, and their aliases: the parser accepts either, and a completion + // that hides an alias makes it undiscoverable. A hidden command binds and is + // not advertised — the same rule the help pages follow, and the reason `hide` + // exists at all. + // + // The same list after `help`, because `findNamed` resolves a topic by name or + // alias exactly as it resolves a command to run. + commands := func() { for _, sub := range subcommandsOf(pos.Cmd) { + h := help.Lookup(sub.Key) + if h != nil && h.Hide { + continue + } add(CandidateCommand, sub.Name, describe(sub.Key, help)) - // Aliases too: a completion that hides them makes them undiscoverable, - // and the parser accepts them either way. Hidden ones stay hidden. - if h := help.Lookup(sub.Key); h != nil { + if h != nil { for _, alias := range h.VisibleAliases { add(CandidateCommand, alias, describe(sub.Key, help)) } @@ -151,28 +152,41 @@ func Candidates(pos Position, partial string, help HelpTable, meta Metadata) []C } } + // A help topic is a question, not an invocation: only command names belong. + if pos.HelpTopic { + commands() + return out + } + + commands() + // Flags, only where one could still be typed, and taken from the parser's own // scope so that shadowing is respected: a subcommand redeclaring an inherited // name offers its own. if pos.FlagsPossible { - for _, f := range flagsInScope(pos.Chain) { - if h := help.Lookup(f.Key); h != nil && h.Hide { + for _, s := range flagsInScope(pos.Chain) { + if h := help.Lookup(s.flag.Key); h != nil && h.Hide { continue } - for _, long := range f.Longs { - add(CandidateFlag, "--"+long, describe(f.Key, help)) - } - for _, short := range f.Shorts { - add(CandidateFlag, "-"+string(short), describe(f.Key, help)) + for _, form := range s.forms { + add(CandidateFlag, form, describe(s.flag.Key, help)) } - if f.Negate != "" { - add(CandidateFlag, "--"+f.Negate, describe(f.Key, help)) + // A negation is a spelling like any other, and it is claimed the same + // way — but a long anywhere in scope beats it, which is what the + // parser does and what `taken` already records. + if s.flag.Negate != "" { + add(CandidateFlag, "--"+s.flag.Negate, describe(s.flag.Key, help)) } } } - // And the values a positional will only accept. - if pos.NextArg != nil { + // And the values a positional will only accept — unless it is one that reads + // only after a `--` and no separator has been typed. Offering them there + // produces a command line the parser answers with + // `arg_requires_double_dash`, which is the exact failure this design exists + // to prevent. + if pos.NextArg != nil && + !(pos.NextArg.DoubleDash == DoubleDashRequired && !pos.SeparatorSeen) { for _, c := range choicesFor(pos.NextArg.Key, meta) { add(CandidateValue, c, "") } @@ -180,18 +194,31 @@ func Candidates(pos Position, partial string, help HelpTable, meta Metadata) []C return out } -// flagsInScope is this command's own flags, then any ancestor's globals — the -// order the parser looks in, so a redeclared name shadows the inherited one. -func flagsInScope(chain []*Command) []*Flag { +// inScope is a flag a page or a completion may offer, and the spellings still +// left to it. +type inScope struct { + flag *Flag + forms []string +} + +// flagsInScope is this command's own flags, then any ancestor's globals, each +// with the spellings nothing nearer has taken. +// +// Per spelling, not per flag. A flag answers to several forms, and a nearer +// command reclaiming `--jobs` leaves an inherited `-j` and `--workers` binding — +// dropping the whole inherited flag would hide spellings the parser still +// accepts. That is the same rule the help pages follow, for the same reason. +func flagsInScope(chain []*Command) []inScope { if len(chain) == 0 { return nil } here := chain[len(chain)-1] - out := append([]*Flag{}, here.Flags...) - seen := map[string]bool{} + taken := map[string]bool{} + var out []inScope for _, f := range here.Flags { + out = append(out, inScope{flag: f, forms: formsOf(f)}) for _, form := range formsOf(f) { - seen[form] = true + taken[form] = true } } for i := len(chain) - 2; i >= 0; i-- { @@ -199,19 +226,20 @@ func flagsInScope(chain []*Command) []*Flag { if !f.Global { continue } - taken := false + var left []string for _, form := range formsOf(f) { - if seen[form] { - taken = true + if !taken[form] { + left = append(left, form) } } - if taken { - continue - } + // Claimed whether or not anything is left: a spelling this flag + // answers to is not available to something farther away either. for _, form := range formsOf(f) { - seen[form] = true + taken[form] = true + } + if len(left) > 0 { + out = append(out, inScope{flag: f, forms: left}) } - out = append(out, f) } } return out diff --git a/go/argv/complete_test.go b/go/argv/complete_test.go index beff08913..df3ab413d 100644 --- a/go/argv/complete_test.go +++ b/go/argv/complete_test.go @@ -19,20 +19,21 @@ func completionFixture() (*Command, HelpTable, Metadata) { run := &Command{Name: "run", Key: 2, Flags: []*Flag{shell}, Args: []*Arg{mode}} list := &Command{Name: "list", Key: 3} + buried := &Command{Name: "buried", Key: 9} root := &Command{Name: "ex", Key: 1, Flags: []*Flag{verbose, color, hidden}, - Subcommands: []*Command{run, list}, + Subcommands: []*Command{run, list, buried}, } help := HelpTable{ {Key: 1}, {Key: 2, Short: "run it", VisibleAliases: []string{"r"}}, {Key: 3, Short: "list them"}, {Key: 4, Short: "be loud"}, {Key: 5}, - {Key: 6}, {Key: 7, Hide: true}, {Key: 8}, + {Key: 6}, {Key: 7, Hide: true}, {Key: 8}, {Key: 9, Hide: true}, } meta := Metadata{ {Key: 1}, {Key: 2}, {Key: 3}, {Key: 4}, {Key: 5}, {Key: 6, Name: "shell", Flag: true, Choices: []string{"bash", "zsh"}}, - {Key: 7}, {Key: 8, Name: "MODE", Choices: []string{"fast", "slow"}}, + {Key: 7}, {Key: 8, Name: "MODE", Choices: []string{"fast", "slow"}}, {Key: 9}, } return root, help, meta } @@ -155,3 +156,76 @@ func TestDescriptionsAreOneLine(t *testing.T) { } } } + +// A hidden command binds and is not advertised — the rule `hide` exists for, and +// the one the help pages already follow. +func TestAHiddenCommandIsNotOffered(t *testing.T) { + if got := complete(nil, ""); offered(got, "buried") { + t.Errorf("a hidden command should not be offered: %v", got) + } + // Including after `help`, where it would otherwise be most discoverable. + if got := complete([]string{"help"}, ""); offered(got, "buried") { + t.Errorf("a hidden command is not a topic either: %v", got) + } +} + +// `findNamed` resolves a topic by name or alias, so a topic completion that hides +// aliases makes accepted spellings undiscoverable where they are most useful. +func TestAHelpTopicOffersAliasesToo(t *testing.T) { + got := complete([]string{"help"}, "") + if !offered(got, "r") { + t.Errorf("`run`'s alias should be a topic too: %v", got) + } +} + +// An argument that reads only after a `--` must not be advertised before one: +// those words come back as `arg_requires_double_dash`, which is the exact failure +// this design exists to prevent. +func TestAnArgumentNeedingASeparatorWaitsForIt(t *testing.T) { + after := &Arg{Key: 2, Name: "REST", DoubleDash: DoubleDashRequired} + root := &Command{Name: "ex", Key: 1, Args: []*Arg{after}} + help := HelpTable{{Key: 1}, {Key: 2}} + meta := Metadata{{Key: 1}, {Key: 2, Name: "REST", Choices: []string{"one", "two"}}} + + if got := values(Candidates(Walk(root, nil), "", help, meta)); offered(got, "one") { + t.Errorf("nothing should be offered before the separator: %v", got) + } + if got := values(Candidates(Walk(root, []string{"--"}), "", help, meta)); !offered(got, "one") { + t.Errorf("after the separator it is the argument's turn: %v", got) + } +} + +// A nearer flag reclaiming one spelling leaves the inherited flag's others +// binding, so they stay offered. Dropping the whole flag hid spellings the parser +// still accepts. +func TestOnlyTheClaimedSpellingIsWithdrawn(t *testing.T) { + global := &Flag{Key: 3, Name: "jobs", Longs: []string{"jobs", "workers"}, + Shorts: []byte{'j'}, Global: true} + local := &Flag{Key: 4, Name: "jobs", Longs: []string{"jobs"}} + sub := &Command{Name: "run", Key: 2, Flags: []*Flag{local}} + root := &Command{Name: "ex", Key: 1, Flags: []*Flag{global}, Subcommands: []*Command{sub}} + help := HelpTable{{Key: 1}, {Key: 2}, {Key: 3}, {Key: 4}} + meta := Metadata{{Key: 1}, {Key: 2}, {Key: 3}, {Key: 4}} + + got := values(Candidates(Walk(root, []string{"run"}), "", help, meta)) + // `--jobs` is the subcommand's now, and still offered once. + if n := count(got, "--jobs"); n != 1 { + t.Errorf("--jobs should appear once, got %d: %v", n, got) + } + // The spellings the nearer flag did not take still bind, so they stay. + for _, want := range []string{"--workers", "-j"} { + if !offered(got, want) { + t.Errorf("%s still binds, so it should be offered: %v", want, got) + } + } +} + +func count(list []string, want string) int { + n := 0 + for _, s := range list { + if s == want { + n++ + } + } + return n +} From 8760af9526a3e6ca5510ccaa2358729ccbf4a178 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:16:18 +0000 Subject: [PATCH 3/5] fix(go): let a negation follow the same scope rules as every other spelling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Candidates` appended a flag's negation whatever else was in scope, and `flagsInScope` dropped a flag whose primary forms were all taken. Both are the same mistake from opposite sides: a negation was not treated as a spelling. An ancestor's literal `--no-color` beats a nearer flag's negation, because the parser asks for every long across the whole scope before it asks for any negation — so the word was offered twice, described two different ways, one of them wrong. And a nearer command reclaiming `--color` left the inherited flag with nothing but its negation, which still binds and was never offered. The pages already had this rule. It now lives in one place and both halves call it, because a spelling offered by one and not the other is the two of them disagreeing about what the parser does. The tests ask the parser which flag binds the word before asserting who offers it: the claim here is that what is offered is what would be accepted, and only the parser can answer the second half. Co-Authored-By: Claude Opus 5 --- go/argv/complete.go | 60 ++++++++++++++++++--------------- go/argv/complete_test.go | 71 ++++++++++++++++++++++++++++++++++++++++ go/argv/scope.go | 53 ++++++++++++++++++++---------- 3 files changed, 140 insertions(+), 44 deletions(-) diff --git a/go/argv/complete.go b/go/argv/complete.go index 462a68ed4..b0b2ea6b8 100644 --- a/go/argv/complete.go +++ b/go/argv/complete.go @@ -168,15 +168,11 @@ func Candidates(pos Position, partial string, help HelpTable, meta Metadata) []C if h := help.Lookup(s.flag.Key); h != nil && h.Hide { continue } + // Negations included: flagsInScope works out which spellings are still + // this flag's, and a negation is one of them. for _, form := range s.forms { add(CandidateFlag, form, describe(s.flag.Key, help)) } - // A negation is a spelling like any other, and it is claimed the same - // way — but a long anywhere in scope beats it, which is what the - // parser does and what `taken` already records. - if s.flag.Negate != "" { - add(CandidateFlag, "--"+s.flag.Negate, describe(s.flag.Key, help)) - } } } @@ -212,33 +208,43 @@ func flagsInScope(chain []*Command) []inScope { if len(chain) == 0 { return nil } - here := chain[len(chain)-1] - taken := map[string]bool{} + everyForm := everyFormInScope(chain) + var taken, takenNegations []string var out []inScope - for _, f := range here.Flags { - out = append(out, inScope{flag: f, forms: formsOf(f)}) + + // Nearest first, which is the order the parser resolves in, so that "nothing + // nearer has taken it" is just "not seen yet". + offer := func(f *Flag) { + var left []string for _, form := range formsOf(f) { - taken[form] = true + if !has(taken, form) { + left = append(left, form) + } + } + // A negation is a spelling like any other, and it loses to a long anywhere + // in scope rather than only to a nearer one — see negationSurvives, which + // the pages use for the same decision. + if n := negationOf(f); n != "" && negationSurvives(f, n, takenNegations, everyForm) { + left = append(left, n) + } + // Claimed whether or not anything is left: a spelling this flag answers to + // is not available to something farther away either. + taken = append(taken, formsOf(f)...) + if n := negationOf(f); n != "" { + takenNegations = append(takenNegations, n) + } + if len(left) > 0 { + out = append(out, inScope{flag: f, forms: left}) } } + + for _, f := range chain[len(chain)-1].Flags { + offer(f) + } for i := len(chain) - 2; i >= 0; i-- { for _, f := range chain[i].Flags { - if !f.Global { - continue - } - var left []string - for _, form := range formsOf(f) { - if !taken[form] { - left = append(left, form) - } - } - // Claimed whether or not anything is left: a spelling this flag - // answers to is not available to something farther away either. - for _, form := range formsOf(f) { - taken[form] = true - } - if len(left) > 0 { - out = append(out, inScope{flag: f, forms: left}) + if f.Global { + offer(f) } } } diff --git a/go/argv/complete_test.go b/go/argv/complete_test.go index df3ab413d..6caaf08fa 100644 --- a/go/argv/complete_test.go +++ b/go/argv/complete_test.go @@ -220,6 +220,77 @@ func TestOnlyTheClaimedSpellingIsWithdrawn(t *testing.T) { } } +// Whichever flag the parser binds a spelling to is the flag that offers it. +// +// `binds` asks the parser, rather than asserting what the scope rules ought to +// do: the whole claim of this file is that what is offered is what would be +// accepted, and the only authority on the second half is the parser. +func binds(t *testing.T, root *Command, words []string) *Flag { + t.Helper() + p := New(root, words) + var last *Flag + for p.Next() { + if ev := p.Event(); ev.Kind == KindFlag { + last = ev.Flag + } + } + if err := p.Err(); err != nil { + t.Fatalf("%v should bind: %v", words, err) + } + return last +} + +// A negation loses to a long form anywhere in scope, so it is not offered twice. +// +// An ancestor's `--no-color` is a literal long, and the parser asks for every +// long across the whole scope before it asks for any negation — so typing it +// binds the ancestor's flag, not the nearer flag's negation. Offering it under +// both put the same word in the list twice, described two different ways, one of +// them wrong. +func TestANegationLosesToALongOfTheSameSpelling(t *testing.T) { + global := &Flag{Key: 3, Name: "no-color", Longs: []string{"no-color"}, Global: true} + local := &Flag{Key: 4, Name: "color", Longs: []string{"color"}, Negate: "no-color"} + sub := &Command{Name: "run", Key: 2, Flags: []*Flag{local}} + root := &Command{Name: "ex", Key: 1, Flags: []*Flag{global}, Subcommands: []*Command{sub}} + help := HelpTable{{Key: 1}, {Key: 2}, {Key: 3}, {Key: 4}} + meta := Metadata{{Key: 1}, {Key: 2}, {Key: 3}, {Key: 4}} + + if f := binds(t, root, []string{"run", "--no-color"}); f != global { + t.Fatalf("the parser binds --no-color to %v, so the premise is wrong", f) + } + got := values(Candidates(Walk(root, []string{"run"}), "", help, meta)) + if n := count(got, "--no-color"); n != 1 { + t.Errorf("--no-color should be offered once, by the flag that binds it, got %d: %v", + n, got) + } +} + +// And a negation is offered where it is all that is left of an inherited flag. +// +// The nearer command reclaims `--color`, so nothing of the global's own spellings +// survives — but `--no-color` still binds to the global, and dropping the flag +// for having no primary form left hid it. +func TestAnInheritedNegationSurvivesItsFlagsOtherSpellings(t *testing.T) { + global := &Flag{Key: 3, Name: "color", Longs: []string{"color"}, + Negate: "no-color", Global: true} + local := &Flag{Key: 4, Name: "color", Longs: []string{"color"}} + sub := &Command{Name: "run", Key: 2, Flags: []*Flag{local}} + root := &Command{Name: "ex", Key: 1, Flags: []*Flag{global}, Subcommands: []*Command{sub}} + help := HelpTable{{Key: 1}, {Key: 2}, {Key: 3}, {Key: 4}} + meta := Metadata{{Key: 1}, {Key: 2}, {Key: 3}, {Key: 4}} + + if f := binds(t, root, []string{"run", "--no-color"}); f != global { + t.Fatalf("the parser binds --no-color to %v, so the premise is wrong", f) + } + got := values(Candidates(Walk(root, []string{"run"}), "", help, meta)) + if !offered(got, "--no-color") { + t.Errorf("--no-color still binds, so it should be offered: %v", got) + } + if n := count(got, "--color"); n != 1 { + t.Errorf("--color is the subcommand's now, and offered once, got %d: %v", n, got) + } +} + func count(list []string, want string) int { n := 0 for _, s := range list { diff --git a/go/argv/scope.go b/go/argv/scope.go index c0bb76c77..24c357d9d 100644 --- a/go/argv/scope.go +++ b/go/argv/scope.go @@ -81,6 +81,40 @@ func has(list []string, s string) bool { return false } +// everyFormInScope is every long and short anything in scope answers to, near or +// far. +// +// One of these always beats a negation — the parser asks for every long form +// across the whole scope before it asks for any negation — so a negation +// survives only where none of them is the same word. Both the pages and the +// completions need it, and they need the same one: a spelling offered by one and +// not the other is the two halves disagreeing about what the parser does. +func everyFormInScope(chain []*Command) []string { + if len(chain) == 0 { + return nil + } + var out []string + for _, f := range chain[len(chain)-1].Flags { + out = append(out, formsOf(f)...) + } + for _, a := range chain[:len(chain)-1] { + for _, f := range a.Flags { + if f.Global { + out = append(out, formsOf(f)...) + } + } + } + return out +} + +// negationSurvives reports whether a flag's negation is still its own to offer: +// nothing nearer has claimed the spelling, and no long anywhere in scope — this +// flag's own excepted — is the same word. +func negationSurvives(f *Flag, negation string, takenNegations, everyForm []string) bool { + return !has(takenNegations, negation) && + (!has(everyForm, negation) || has(formsOf(f), negation)) +} + // surviving is the spellings left to a flag once everything nearer has taken // what it answers to. func surviving(f *Flag, taken, takenNegations, everyForm []string) shown { @@ -98,9 +132,7 @@ func surviving(f *Flag, taken, takenNegations, everyForm []string) shown { } } if n := negationOf(f); n != "" { - // A long anywhere in scope wins over a negation — this flag's own - // excepted — because the parser asks for every long before any negation. - out.negate = !has(takenNegations, n) && (!has(everyForm, n) || has(formsOf(f), n)) + out.negate = negationSurvives(f, n, takenNegations, everyForm) } return out } @@ -113,20 +145,7 @@ func ownAndGlobal(chain []*Command, help HelpTable) (own, inherited []shownFlag) } here, ancestors := chain[len(chain)-1], chain[:len(chain)-1] - // Every long and short anything in scope answers to, near or far: one of these - // always beats a negation, so a negation survives only where none of them is - // the same word. - var everyForm []string - for _, f := range here.Flags { - everyForm = append(everyForm, formsOf(f)...) - } - for _, a := range ancestors { - for _, f := range a.Flags { - if f.Global { - everyForm = append(everyForm, formsOf(f)...) - } - } - } + everyForm := everyFormInScope(chain) var taken, takenNegations []string for _, f := range here.Flags { From 9d7060dd1979db8abac6fa559ca544be8452aaca Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:09:15 +0000 Subject: [PATCH 4/5] fix(go): offer a subcommand only where one would still bind, and each spelling once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more places where the completion advertised something the parser would not take. Descent stops once a positional of the command has taken a word — `word()` asks `!argFilled && !flagsStopped` before it looks for a subcommand — so after that a name matching a subcommand is a value or a failure. `Position` carried the flag half of that rule and nothing for the command half, so the names were still offered. The parser now says so directly, and the test asks it whether the word still binds before asserting that it is not offered. And a flag may spell its negation the same as its own long form: `flag "--no-color" negate="--no-color"`. That parses, and it was offered twice. usage-lib prints such a flag as `--no-color / --no-color`, so the page saying it twice is the reference's own behaviour and stays; a completion is a list of things to type, where the same thing twice is a repeated row. Co-Authored-By: Claude Opus 5 --- go/argv/complete.go | 32 ++++++++++++++++++++++----- go/argv/complete_test.go | 48 ++++++++++++++++++++++++++++++++++++++++ go/argv/parser.go | 9 ++++++++ 3 files changed, 83 insertions(+), 6 deletions(-) diff --git a/go/argv/complete.go b/go/argv/complete.go index b0b2ea6b8..eb728d76e 100644 --- a/go/argv/complete.go +++ b/go/argv/complete.go @@ -27,6 +27,10 @@ type Position struct { // flag. False past a `--`, and past the first value of an `automatic` // argument — there is no flag of *this* CLI to offer in either place. FlagsPossible bool + // SubcommandsPossible is whether a word here could still name a subcommand. + // False once a positional of this command has taken a word: the parser stops + // descending there, so a later word matching a subcommand name is a value. + SubcommandsPossible bool // AwaitingValue is a flag whose value the cursor is standing in, if the last // word was one that takes a value or a variadic still claiming words. AwaitingValue *Flag @@ -70,6 +74,8 @@ func Walk(root *Command, words []string) Position { // the request. Nothing else can be typed there: a topic takes no flags and // fills no argument. case CodeHelp: + // SubcommandsPossible stays false: a topic is not descended into, and + // the commands under it are offered by HelpTopic instead. return Position{Cmd: err.Cmd, Chain: chain, HelpTopic: true} } } @@ -80,10 +86,11 @@ func Walk(root *Command, words []string) Position { // A variadic flag still claiming words stands in the same place as a flag // waiting for its first value: the next word belongs to it, not to the // positional after it. - AwaitingValue: firstFlag(awaiting, p.Collecting()), - FlagsPossible: !p.FlagsStopped(), - NextArg: p.PendingArg(), - SeparatorSeen: p.DoubleDashSeen(), + AwaitingValue: firstFlag(awaiting, p.Collecting()), + FlagsPossible: !p.FlagsStopped(), + SubcommandsPossible: p.SubcommandsPossible(), + NextArg: p.PendingArg(), + SeparatorSeen: p.DoubleDashSeen(), } } @@ -158,7 +165,12 @@ func Candidates(pos Position, partial string, help HelpTable, meta Metadata) []C return out } - commands() + // Only while descent is still possible. Once a positional has taken a word the + // parser stops matching subcommands, and a name offered there would be bound + // as a value or refused outright. + if pos.SubcommandsPossible { + commands() + } // Flags, only where one could still be typed, and taken from the parser's own // scope so that shadowing is respected: a subcommand redeclaring an inherited @@ -224,7 +236,15 @@ func flagsInScope(chain []*Command) []inScope { // A negation is a spelling like any other, and it loses to a long anywhere // in scope rather than only to a nearer one — see negationSurvives, which // the pages use for the same decision. - if n := negationOf(f); n != "" && negationSurvives(f, n, takenNegations, everyForm) { + // + // Not twice, though. A flag may spell its negation the same as its own long + // — `flag "--no-color" negate="--no-color"` — and then it is already in the + // list. usage-lib prints that flag as `--no-color / --no-color`, so the page + // says it twice on purpose and matching the reference means keeping that; + // a completion is a list of things to type, and the same thing twice is + // just a repeated row. + if n := negationOf(f); n != "" && !has(left, n) && + negationSurvives(f, n, takenNegations, everyForm) { left = append(left, n) } // Claimed whether or not anything is left: a spelling this flag answers to diff --git a/go/argv/complete_test.go b/go/argv/complete_test.go index 6caaf08fa..287f79c72 100644 --- a/go/argv/complete_test.go +++ b/go/argv/complete_test.go @@ -291,6 +291,54 @@ func TestAnInheritedNegationSurvivesItsFlagsOtherSpellings(t *testing.T) { } } +// A subcommand is offered only while the parser would still descend. +// +// Descent stops once a positional of this command has taken a word — after that a +// word matching a subcommand name is a value, or a failure. Offering one there is +// the same mistake as offering a flag past a `--`. +func TestASubcommandIsNotOfferedOnceAPositionalIsFilled(t *testing.T) { + sub := &Command{Name: "run", Key: 2} + root := &Command{Name: "ex", Key: 1, + Args: []*Arg{{Key: 3, Name: "file"}}, + Subcommands: []*Command{sub}} + help := HelpTable{{Key: 1}, {Key: 2}, {Key: 3}} + meta := Metadata{{Key: 1}, {Key: 2}, {Key: 3}} + + // The premise, from the parser: with the positional filled, `run` is not a + // command any more. + p := New(root, []string{"a.txt", "run"}) + for p.Next() { + if ev := p.Event(); ev.Kind == KindCommand { + t.Fatalf("the parser still descends into %q, so the premise is wrong", ev.Command.Name) + } + } + + if got := values(Candidates(Walk(root, nil), "", help, meta)); !offered(got, "run") { + t.Errorf("nothing has been typed yet, so run should be offered: %v", got) + } + if got := values(Candidates(Walk(root, []string{"a.txt"}), "", help, meta)); offered(got, "run") { + t.Errorf("the positional is filled, so run would not bind: %v", got) + } +} + +// A negation spelled the same as its own long form is offered once. +// +// `flag "--no-color" negate="--no-color"` is odd and it parses, and usage-lib +// prints it as `--no-color / --no-color` — so the page says it twice by design, +// and the check for that lives in the page tests. A completion is a list of +// things to type, where the same thing twice is a repeated row. +func TestANegationSpelledLikeItsOwnLongIsOfferedOnce(t *testing.T) { + flag := &Flag{Key: 2, Name: "no-color", Longs: []string{"no-color"}, Negate: "no-color"} + root := &Command{Name: "ex", Key: 1, Flags: []*Flag{flag}} + help := HelpTable{{Key: 1}, {Key: 2}} + meta := Metadata{{Key: 1}, {Key: 2}} + + got := values(Candidates(Walk(root, nil), "", help, meta)) + if n := count(got, "--no-color"); n != 1 { + t.Errorf("--no-color should be offered once, got %d: %v", n, got) + } +} + func count(list []string, want string) int { n := 0 for _, s := range list { diff --git a/go/argv/parser.go b/go/argv/parser.go index 4747f8351..68293c887 100644 --- a/go/argv/parser.go +++ b/go/argv/parser.go @@ -140,6 +140,15 @@ func (p *Parser) DoubleDashSeen() bool { return p.separatorSeen } // word is a value, so there is no flag there to offer. func (p *Parser) FlagsStopped() bool { return p.flagsStopped } +// SubcommandsPossible reports whether a word here could still name a subcommand. +// +// The other half of the rule [Parser.FlagsStopped] answers for flags: descent +// stops once a positional of this command has taken a word, so a later word that +// happens to equal a subcommand name is just a value. A completion that offered +// one there would be advertising a word the parser no longer accepts as a +// command. +func (p *Parser) SubcommandsPossible() bool { return !p.argFilled && !p.flagsStopped } + // CommandStart is where the command in scope began: the index in argv just after // its name. argv[CommandStart():] is what that command was given. func (p *Parser) CommandStart() int { return p.cmdStart } From 84b303e0bbd4cd8fb2796a80260173b562334db1 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:13:41 +0000 Subject: [PATCH 5/5] fix(go): a variadic still collecting is a weaker claim than a value that is owed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two positions were being treated as one. A flag that has not been given its value refuses a flag-like token, so nothing but its values belongs there. A variadic that already has one *stops* collecting when it meets a flag, and that flag binds — so `--tools a ⌶` was offering the tool names and hiding every flag that works there. Offered now: the variadic's own values, and the flags. Not a subcommand name or the positional behind it, because a plain word would be collected rather than bound. And the exemption that lets a flag spelled `--x` still offer a negation spelled `--x` was reaching past a nearer flag that had claimed the word. The parser binds `--x` to the child; the inherited global was offering it a second time. Both tests ask the parser what binds before asserting who offers it, and both fail on the code above them. Co-Authored-By: Claude Opus 5 --- go/argv/complete.go | 47 +++++++++++++++++++---------- go/argv/complete_test.go | 64 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 16 deletions(-) diff --git a/go/argv/complete.go b/go/argv/complete.go index eb728d76e..2d25428c0 100644 --- a/go/argv/complete.go +++ b/go/argv/complete.go @@ -31,9 +31,14 @@ type Position struct { // False once a positional of this command has taken a word: the parser stops // descending there, so a later word matching a subcommand name is a value. SubcommandsPossible bool - // AwaitingValue is a flag whose value the cursor is standing in, if the last - // word was one that takes a value or a variadic still claiming words. + // AwaitingValue is a flag whose value the cursor is standing in, because the + // last word was a flag that takes one and has not been given it. Nothing else + // belongs here: the parser refuses a flag-like token in that place. AwaitingValue *Flag + // Collecting is a variadic flag still claiming words. The next word would be + // another of its values — so the positional after it is not offered — but a + // flag-like token ends the collection and binds, so flags are. + Collecting *Flag // NextArg is the positional a word here would fill, if any are left. NextArg *Arg // SeparatorSeen is whether a `--` has been typed. Narrower than @@ -86,7 +91,8 @@ func Walk(root *Command, words []string) Position { // A variadic flag still claiming words stands in the same place as a flag // waiting for its first value: the next word belongs to it, not to the // positional after it. - AwaitingValue: firstFlag(awaiting, p.Collecting()), + AwaitingValue: awaiting, + Collecting: p.Collecting(), FlagsPossible: !p.FlagsStopped(), SubcommandsPossible: p.SubcommandsPossible(), NextArg: p.PendingArg(), @@ -129,7 +135,8 @@ func Candidates(pos Position, partial string, help HelpTable, meta Metadata) []C } // A value the cursor is standing in takes the position entirely: nothing else - // belongs where a flag is waiting for its argument. + // belongs where a flag is waiting for its argument, because the parser refuses + // a flag-like token there. if pos.AwaitingValue != nil { for _, c := range choicesFor(pos.AwaitingValue.Key, meta) { add(CandidateValue, c, "") @@ -165,10 +172,22 @@ func Candidates(pos Position, partial string, help HelpTable, meta Metadata) []C return out } + // A variadic flag that has already taken a value is a weaker claim than a flag + // waiting for its first: another word goes to the variadic, but a flag-like + // one ends the collection and binds. So its values are offered here *and* the + // flags below are — what is not offered is anything a plain word could not be: + // a subcommand, or the positional the variadic is standing in front of. + collecting := pos.Collecting != nil + if collecting { + for _, c := range choicesFor(pos.Collecting.Key, meta) { + add(CandidateValue, c, "") + } + } + // Only while descent is still possible. Once a positional has taken a word the // parser stops matching subcommands, and a name offered there would be bound // as a value or refused outright. - if pos.SubcommandsPossible { + if pos.SubcommandsPossible && !collecting { commands() } @@ -193,7 +212,7 @@ func Candidates(pos Position, partial string, help HelpTable, meta Metadata) []C // produces a command line the parser answers with // `arg_requires_double_dash`, which is the exact failure this design exists // to prevent. - if pos.NextArg != nil && + if pos.NextArg != nil && !collecting && !(pos.NextArg.DoubleDash == DoubleDashRequired && !pos.SeparatorSeen) { for _, c := range choicesFor(pos.NextArg.Key, meta) { add(CandidateValue, c, "") @@ -243,7 +262,12 @@ func flagsInScope(chain []*Command) []inScope { // says it twice on purpose and matching the reference means keeping that; // a completion is a list of things to type, and the same thing twice is // just a repeated row. - if n := negationOf(f); n != "" && !has(left, n) && + // `!has(taken, n)` before the exemption inside negationSurvives: that + // exemption is for a flag whose *own* long is spelled like its negation, + // and it must not reach past something nearer that has claimed the word. + // A child declaring `--x` takes it from an inherited global that answers to + // `--x` both ways. + if n := negationOf(f); n != "" && !has(left, n) && !has(taken, n) && negationSurvives(f, n, takenNegations, everyForm) { left = append(left, n) } @@ -297,12 +321,3 @@ func describe(key uint64, help HelpTable) string { } return h.Short } - -func firstFlag(flags ...*Flag) *Flag { - for _, f := range flags { - if f != nil { - return f - } - } - return nil -} diff --git a/go/argv/complete_test.go b/go/argv/complete_test.go index 287f79c72..40c1ae1c2 100644 --- a/go/argv/complete_test.go +++ b/go/argv/complete_test.go @@ -339,6 +339,70 @@ func TestANegationSpelledLikeItsOwnLongIsOfferedOnce(t *testing.T) { } } +// A variadic still collecting does not hide the flags that would end it. +// +// `--tools a ⌶` is not the same position as `--tools ⌶`: the parser refuses a +// flag-like token where a value is owed, but a variadic that already has one +// stops collecting when it meets a flag, and that flag binds. Treating the two +// the same offered only the variadic's values, so the flags were invisible in a +// place they still work. +func TestAVariadicStillCollectingOffersFlagsToo(t *testing.T) { + tools := &Flag{Key: 2, Name: "tools", Longs: []string{"tools"}, + TakesValue: true, Variadic: true} + force := &Flag{Key: 3, Name: "force", Longs: []string{"force"}} + sub := &Command{Name: "run", Key: 4} + root := &Command{Name: "ex", Key: 1, Flags: []*Flag{tools, force}, + Subcommands: []*Command{sub}} + help := HelpTable{{Key: 1}, {Key: 2}, {Key: 3}, {Key: 4}} + meta := Metadata{{Key: 1}, {Key: 2, Choices: []string{"node", "python"}}, {Key: 3}, {Key: 4}} + + // The premise: the flag binds after a value has been collected. + if f := binds(t, root, []string{"--tools", "a", "--force"}); f != force { + t.Fatalf("--force should bind after a collected value, got %v", f) + } + + got := values(Candidates(Walk(root, []string{"--tools", "a"}), "", help, meta)) + if !offered(got, "--force") { + t.Errorf("a flag ends the collection and binds, so it belongs here: %v", got) + } + if !offered(got, "node") { + t.Errorf("the variadic's own values belong here too: %v", got) + } + // A plain word goes to the variadic, so nothing a plain word cannot be. + if offered(got, "run") { + t.Errorf("a subcommand name would be collected as a value, not bound: %v", got) + } + + // And a flag still owed its first value keeps the position to itself. + owed := values(Candidates(Walk(root, []string{"--tools"}), "", help, meta)) + if offered(owed, "--force") { + t.Errorf("a flag-like token is refused where a value is owed: %v", owed) + } +} + +// A nearer flag claiming a spelling takes it from an inherited negation as well. +// +// The exemption that lets a flag spelled `--x` still offer a negation spelled +// `--x` is about its *own* forms; it must not reach past a child that has taken +// the word. The parser binds `--x` to the child, and the global was offering it +// again. +func TestANearerFlagTakesTheSpellingFromAnInheritedNegation(t *testing.T) { + global := &Flag{Key: 3, Name: "x", Longs: []string{"x"}, Negate: "x", Global: true} + local := &Flag{Key: 4, Name: "x", Longs: []string{"x"}} + sub := &Command{Name: "run", Key: 2, Flags: []*Flag{local}} + root := &Command{Name: "ex", Key: 1, Flags: []*Flag{global}, Subcommands: []*Command{sub}} + help := HelpTable{{Key: 1}, {Key: 2}, {Key: 3}, {Key: 4}} + meta := Metadata{{Key: 1}, {Key: 2}, {Key: 3}, {Key: 4}} + + if f := binds(t, root, []string{"run", "--x"}); f != local { + t.Fatalf("the parser binds --x to the nearer flag, got %v", f) + } + got := values(Candidates(Walk(root, []string{"run"}), "", help, meta)) + if n := count(got, "--x"); n != 1 { + t.Errorf("--x should be offered once, by the flag that binds it, got %d: %v", n, got) + } +} + func count(list []string, want string) int { n := 0 for _, s := range list {