diff --git a/go/README.md b/go/README.md index 374d7d270..b2bffc36a 100644 --- a/go/README.md +++ b/go/README.md @@ -179,6 +179,11 @@ 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. +`argv.RenderAnswer` writes the result in the protocol each shell reads — bash +takes values, fish, nu and PowerShell take a description after a tab, and zsh +takes a third field with the text to insert, because what it displays and what it +types are not always the same string. + ## Errors `argv.Render` turns a failure into what a CLI should print to stderr: diff --git a/go/argv/complete.go b/go/argv/complete.go index 2d25428c0..e7be2f945 100644 --- a/go/argv/complete.go +++ b/go/argv/complete.go @@ -310,14 +310,12 @@ func choicesFor(key uint64, meta Metadata) []string { } func describe(key uint64, help HelpTable) string { - h := help.Lookup(key) - if h == nil { - return "" + if h := help.Lookup(key); h != nil { + // Whole, breaks included. Putting a description on one line is the + // renderer's job — see oneLine — because it is the line-based protocols + // that need it, and collapsing there keeps both halves of a two-line + // description instead of dropping the second. + return h.Short } - // 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 + return "" } diff --git a/go/argv/complete_shell.go b/go/argv/complete_shell.go new file mode 100644 index 000000000..96fc0cf2e --- /dev/null +++ b/go/argv/complete_shell.go @@ -0,0 +1,185 @@ +package argv + +import "strings" + +// Writing an answer the way a shell reads it. +// +// One line per candidate, in the shape the shell's own completion machinery +// expects — which is where the five differ. bash reads values only; fish, nu and +// PowerShell take a description after a tab; zsh takes a third field, the text to +// insert, because what it displays and what it types are not always the same +// string. + +// Shell is a completion protocol, named for the shell that reads it. +type Shell uint8 + +const ( + Bash Shell = iota + Zsh + Fish + Nu + PowerShell +) + +// Files says whether paths belong at this position as well as the candidates. +type Files uint8 + +const ( + // NoFiles means the position takes only what the CLI named. + NoFiles Files = iota + // AnyFile means files, directories, whatever the shell shows for a path. + AnyFile + // Dirs means directories only. + Dirs +) + +// The line a shell reads to mean "paths belong here too". +// +// A whole line rather than a flag on the protocol, because every one of the five +// shells can already split output into lines and look at the last one. `\x01` +// opens it because no candidate can contain a control character — the parser's +// values are escaped before they are rendered anywhere — so it cannot be mistaken +// for one. +const ( + FilesMarker = "\x01files" + DirsMarker = "\x01dirs" +) + +// Answer is everything a shell needs to resolve one Tab. +type Answer struct { + Candidates []Candidate + Files Files +} + +// RenderAnswer writes an answer in the protocol `shell` reads. +// +// Named for what it renders rather than just `Render`, because [Render] already +// belongs to failures. Two things in one package both turning a value into text +// for a terminal is reason enough to say which. +func RenderAnswer(a Answer, shell Shell) string { + var out strings.Builder + + // Descriptions are all-or-nothing per answer: a column that appears on some + // rows and not others reads as missing data rather than as an absent + // description. + // + // Over the rows that will actually be written, not over every candidate. A + // description sitting on a row that gets dropped below would otherwise put an + // empty column on all the survivors — the rule broken by the answer it was + // deciding for. + described := false + for _, c := range a.Candidates { + if c.Describe != "" && travels(c.Value) { + described = true + break + } + } + + for _, c := range a.Candidates { + // The protocols are lines with tab-separated fields, so a value carrying + // either would be read as more rows or more fields. A candidate normally + // comes from a spec and contains neither, but a `complete` script can + // produce anything. + // + // Such a candidate is dropped rather than repaired. A value is the text + // that gets typed onto the command line, so collapsing a tab inside it + // would insert an argument nobody offered — the shell would report success + // while the CLI received something else, which is the confusing half of + // the two. A missing candidate is the honest failure: the user types the + // value themselves and it works. + if !travels(c.Value) { + continue + } + value := c.Value + // The description is prose, and collapsing prose onto one line is what a + // one-line protocol asks for. Nothing is typed from it. + description := oneLine(c.Describe) + switch shell { + case Bash: + out.WriteString(value) + case Zsh: + // Display, then description, then what to type: a candidate containing + // a space or a quote has to reach the command line intact. + out.WriteString(value + "\t" + description + "\t" + zshQuote(value)) + default: + out.WriteString(value) + if described { + out.WriteString("\t" + description) + } + } + out.WriteString("\n") + } + + switch a.Files { + case AnyFile: + out.WriteString(FilesMarker + "\n") + case Dirs: + out.WriteString(DirsMarker + "\n") + } + return out.String() +} + +// travels reports whether a value can be written into these protocols as itself. +// +// Every control character, not only the three that delimit: the marker lines +// that say "files belong here too" open with `\x01`, and a candidate beginning +// with one would be read as a marker rather than as a candidate. The comment on +// FilesMarker says no candidate can contain a control character; this is what +// makes that true. +func travels(value string) bool { + for _, r := range value { + if r < 0x20 || r == 0x7f { + return false + } + } + return true +} + +// oneLine collapses text onto one line, because the protocols are line-based +// with tab-separated fields: a break or a tab inside either field would be read +// as another row or another column. +// +// Collapsed rather than truncated, so a two-line description still says both +// halves. A run of breaks becomes one space, and never a leading or trailing one. +// Every other control character goes the same way: a description is displayed by +// the shell, and an escape sequence displayed is an escape sequence run. +func oneLine(text string) string { + var out strings.Builder + spaced := false + for _, r := range text { + if r < 0x20 || r == 0x7f { + if !spaced && out.Len() > 0 { + out.WriteByte(' ') + spaced = true + } + continue + } + out.WriteRune(r) + spaced = false + } + return strings.TrimRight(out.String(), " ") +} + +// zshQuote makes a value safe to insert on the command line. +func zshQuote(value string) string { + safe := func(r rune) bool { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + return true + } + return strings.ContainsRune("_-./:@+=%,", r) + } + if value != "" { + all := true + for _, r := range value { + if !safe(r) { + all = false + break + } + } + if all { + return value + } + } + return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'" +} diff --git a/go/argv/complete_shell_test.go b/go/argv/complete_shell_test.go new file mode 100644 index 000000000..dd30c1b69 --- /dev/null +++ b/go/argv/complete_shell_test.go @@ -0,0 +1,162 @@ +package argv + +import ( + "strings" + "testing" +) + +func answer() Answer { + return Answer{Candidates: []Candidate{ + {Kind: CandidateCommand, Value: "use", Describe: "Installs a tool"}, + {Kind: CandidateFlag, Value: "--global"}, + }} +} + +// Each shell reads a different shape, and the differences are the whole point of +// having five. +func TestEachShellGetsItsOwnShape(t *testing.T) { + // bash reads values only. + if got := RenderAnswer(answer(), Bash); got != "use\n--global\n" { + t.Errorf("bash: got %q", got) + } + + // zsh takes display, description, and the text to insert. + got := RenderAnswer(answer(), Zsh) + if !strings.HasPrefix(got, "use\tInstalls a tool\tuse\n") { + t.Errorf("zsh: got %q", got) + } + + // fish, nu and PowerShell take a description after a tab. + for _, shell := range []Shell{Fish, Nu, PowerShell} { + got := RenderAnswer(answer(), shell) + if !strings.HasPrefix(got, "use\tInstalls a tool\n") { + t.Errorf("%v: got %q", shell, got) + } + } +} + +// A column that appears on some rows and not others reads as missing data rather +// than as an absent description, so it is all-or-nothing per answer. +func TestDescriptionsAreAllOrNothing(t *testing.T) { + // One candidate has a description, so the other still gets the column. + got := RenderAnswer(answer(), Fish) + if !strings.Contains(got, "--global\t") && !strings.HasSuffix(got, "--global\t\n") { + if !strings.Contains(got, "--global\t\n") { + t.Errorf("the column should be present for every row: %q", got) + } + } + // None has one, so no row gets it. + bare := Answer{Candidates: []Candidate{{Value: "a"}, {Value: "b"}}} + if got := RenderAnswer(bare, Fish); got != "a\nb\n" { + t.Errorf("no descriptions means no column: %q", got) + } +} + +// A description is collapsed onto one line rather than truncated, so a two-line +// description still says both halves — the protocols are line-based, and a break +// would look like another candidate. +func TestADescriptionIsCollapsedNotTruncated(t *testing.T) { + a := Answer{Candidates: []Candidate{ + {Value: "x", Describe: "first half\nsecond half"}, + }} + got := RenderAnswer(a, Fish) + if strings.Count(got, "\n") != 1 { + t.Errorf("a candidate is one line: %q", got) + } + for _, want := range []string{"first half", "second half"} { + if !strings.Contains(got, want) { + t.Errorf("want %q kept: %q", want, got) + } + } + // A run of breaks is one space, with none left at either end. + if got := oneLine("\n\na\n\n\nb\n\n"); got != "a b" { + t.Errorf("want %q, got %q", "a b", got) + } +} + +// A candidate containing a space or a quote has to reach the command line intact. +func TestZshQuotesWhatItMust(t *testing.T) { + for _, c := range []struct{ in, want string }{ + {"use", "use"}, + {"a/b-c.d:e@f+g=h%i,j_k", "a/b-c.d:e@f+g=h%i,j_k"}, + {"two words", "'two words'"}, + {"it's", `'it'\''s'`}, + {"", "''"}, + } { + if got := zshQuote(c.in); got != c.want { + t.Errorf("zshQuote(%q): want %q, got %q", c.in, c.want, got) + } + } +} + +// The marker is a line because every shell can already look at the last one. +func TestFilesAreAskedForOnTheirOwnLine(t *testing.T) { + a := answer() + a.Files = AnyFile + if got := RenderAnswer(a, Bash); !strings.HasSuffix(got, FilesMarker+"\n") { + t.Errorf("want the files marker last: %q", got) + } + a.Files = Dirs + if got := RenderAnswer(a, Bash); !strings.HasSuffix(got, DirsMarker+"\n") { + t.Errorf("want the dirs marker last: %q", got) + } + a.Files = NoFiles + if got := RenderAnswer(a, Bash); strings.Contains(got, "\x01") { + t.Errorf("no marker where paths do not belong: %q", got) + } +} + +// A value carrying a tab or a newline would be read as more fields or more rows. +// +// Dropped rather than collapsed: a value is what gets typed onto the command +// line, so a repaired one inserts an argument nobody offered — the shell reports +// success and the CLI receives something else. Values normally come from a spec +// and contain none of this, but a `complete` script can produce anything. +func TestAValueThatCannotTravelIsNotOffered(t *testing.T) { + a := Answer{Candidates: []Candidate{ + {Value: "one\ttwo\nthree"}, + {Value: "\x01files"}, // would be read as the marker line + {Value: "plain"}, + }} + for _, shell := range []Shell{Bash, Zsh, Fish, Nu, PowerShell} { + got := RenderAnswer(a, shell) + if strings.Count(got, "\n") != 1 { + t.Errorf("%v: only the one that travels is offered, got %q", shell, got) + } + if !strings.HasPrefix(got, "plain") { + t.Errorf("%v: the candidate that travels should survive, got %q", shell, got) + } + } +} + +// The description column is decided over the rows that survive. +// +// A description on a candidate that gets dropped would otherwise turn the column +// on for everyone else, leaving an empty field on every row — the all-or-nothing +// rule broken by the answer it was deciding for. +func TestADroppedRowDoesNotTurnOnTheDescriptionColumn(t *testing.T) { + a := Answer{Candidates: []Candidate{ + {Value: "bad\tvalue", Describe: "the only description"}, + {Value: "plain"}, + }} + for _, shell := range []Shell{Fish, Nu, PowerShell} { + if got := RenderAnswer(a, shell); got != "plain\n" { + t.Errorf("%v: no column where nothing written has a description: %q", shell, got) + } + } +} + +// A description is prose, and prose collapses: nothing is typed from it, and a +// two-line help still says both halves on one line. +func TestADescriptionIsCollapsedRatherThanDropped(t *testing.T) { + a := Answer{Candidates: []Candidate{{Value: "run", Describe: "does a thing\nand another"}}} + got := RenderAnswer(a, Zsh) + if strings.Count(got, "\n") != 1 { + t.Errorf("one candidate is one row, got %q", got) + } + for _, want := range []string{"does a thing and another", "run"} { + if !strings.Contains(got, want) { + t.Errorf("want %q kept in %q", want, got) + } + } +} diff --git a/go/argv/complete_test.go b/go/argv/complete_test.go index 40c1ae1c2..12b9a0c31 100644 --- a/go/argv/complete_test.go +++ b/go/argv/complete_test.go @@ -145,14 +145,20 @@ func TestAHelpTopicOffersOnlyCommands(t *testing.T) { } } -// 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) { +// A candidate carries the whole description; putting it on one line is the +// renderer's job. +// +// Collapsing there rather than truncating here keeps both halves of a two-line +// description — see TestADescriptionIsCollapsedNotTruncated. +func TestACandidateCarriesTheWholeDescription(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) + if c.Value != "run" { + continue + } + if !strings.Contains(c.Describe, "and keep running it") { + t.Errorf("the second half should survive: %q", c.Describe) } } }