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..2d25428c0 --- /dev/null +++ b/go/argv/complete.go @@ -0,0 +1,323 @@ +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 + // 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, 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 + // 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: + // 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} + } + } + + 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: awaiting, + Collecting: p.Collecting(), + FlagsPossible: !p.FlagsStopped(), + SubcommandsPossible: p.SubcommandsPossible(), + 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, because the parser refuses + // a flag-like token there. + if pos.AwaitingValue != nil { + for _, c := range choicesFor(pos.AwaitingValue.Key, meta) { + add(CandidateValue, c, "") + } + return out + } + + // 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)) + if h != nil { + for _, alias := range h.VisibleAliases { + add(CandidateCommand, alias, describe(sub.Key, help)) + } + } + } + } + + // A help topic is a question, not an invocation: only command names belong. + if pos.HelpTopic { + commands() + 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 && !collecting { + 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 _, s := range flagsInScope(pos.Chain) { + 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)) + } + } + } + + // 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 && !collecting && + !(pos.NextArg.DoubleDash == DoubleDashRequired && !pos.SeparatorSeen) { + for _, c := range choicesFor(pos.NextArg.Key, meta) { + add(CandidateValue, c, "") + } + } + return out +} + +// 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 + } + everyForm := everyFormInScope(chain) + var taken, takenNegations []string + var out []inScope + + // 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) { + 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. + // + // 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. + // `!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) + } + // 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 { + offer(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 +} diff --git a/go/argv/complete_test.go b/go/argv/complete_test.go new file mode 100644 index 000000000..40c1ae1c2 --- /dev/null +++ b/go/argv/complete_test.go @@ -0,0 +1,414 @@ +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} + buried := &Command{Name: "buried", Key: 9} + root := &Command{Name: "ex", Key: 1, + Flags: []*Flag{verbose, color, hidden}, + 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: 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: 9}, + } + 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) + } + } +} + +// 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) + } + } +} + +// 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) + } +} + +// 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) + } +} + +// 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 { + if s == want { + n++ + } + } + return n +} 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 } 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 {