From f1f02854fc9734ef7d7f7c45cd2b7d90159eec87 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:50:41 +0000 Subject: [PATCH 1/4] feat(go): write an answer the way each shell reads it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `RenderAnswer` turns candidates into the protocol a shell's 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. Checked against what `usage complete-word --shell zsh` already emits. Three rules that look like details and are not: 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. A description is collapsed onto one line rather than truncated at the first break, so a two-line description still says both halves. The protocols are line-based and a break would look like another candidate — which is why the one-lining moved here from `describe`, where it was throwing the second half away. `Candidates` now carries the whole text and the renderer decides how to fit it. And "paths belong here too" is 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. `RenderAnswer` rather than `Render`, which already belongs to failures: two things in one package turning a value into text for a terminal is reason enough to say which. Co-Authored-By: Claude Opus 5 --- go/README.md | 5 ++ go/argv/complete.go | 16 ++-- go/argv/complete_shell.go | 144 +++++++++++++++++++++++++++++++++ go/argv/complete_shell_test.go | 107 ++++++++++++++++++++++++ go/argv/complete_test.go | 16 ++-- 5 files changed, 274 insertions(+), 14 deletions(-) create mode 100644 go/argv/complete_shell.go create mode 100644 go/argv/complete_shell_test.go 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..0f77d581c --- /dev/null +++ b/go/argv/complete_shell.go @@ -0,0 +1,144 @@ +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. + described := false + for _, c := range a.Candidates { + if c.Describe != "" { + described = true + break + } + } + + for _, c := range a.Candidates { + description := oneLine(c.Describe) + switch shell { + case Bash: + out.WriteString(c.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(c.Value + "\t" + description + "\t" + zshQuote(c.Value)) + default: + out.WriteString(c.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() +} + +// oneLine collapses a description onto one line, because the protocols are +// line-based: a break inside a description would look like another candidate. +// +// 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. +func oneLine(text string) string { + var out strings.Builder + spaced := false + for _, r := range text { + if r == '\n' || r == '\r' || r == '\t' { + 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..fc88c67c6 --- /dev/null +++ b/go/argv/complete_shell_test.go @@ -0,0 +1,107 @@ +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) + } +} 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) } } } From 50ad8ec1d9021bb445599a8810b02c65729e5e12 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:22:00 +0000 Subject: [PATCH 2/4] fix(go): keep a candidate from rearranging the protocol it travels in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A candidate carrying a tab or a newline was written straight into the output. Both protocols are lines with tab-separated fields, so such a value reads as extra rows or extra columns — zsh in particular would take the text after the tab as the description and the text after that as what to insert. A candidate normally comes from a spec and contains neither. A `complete` script can produce anything, though, and a completion that rearranges the protocol is a worse failure than a missing candidate: the shell inserts something nobody offered. Values go through the same one-lining descriptions already did, and the test asserts the field and row counts each shell expects rather than the text, since that is what the protocol is. Co-Authored-By: Claude Opus 5 --- go/argv/complete_shell.go | 17 ++++++++++++----- go/argv/complete_shell_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/go/argv/complete_shell.go b/go/argv/complete_shell.go index 0f77d581c..a2fef260b 100644 --- a/go/argv/complete_shell.go +++ b/go/argv/complete_shell.go @@ -71,16 +71,22 @@ func RenderAnswer(a Answer, shell Shell) string { } 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, and a completion that rearranges the protocol is a + // worse failure than a missing candidate. + value := oneLine(c.Value) description := oneLine(c.Describe) switch shell { case Bash: - out.WriteString(c.Value) + 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(c.Value + "\t" + description + "\t" + zshQuote(c.Value)) + out.WriteString(value + "\t" + description + "\t" + zshQuote(value)) default: - out.WriteString(c.Value) + out.WriteString(value) if described { out.WriteString("\t" + description) } @@ -97,8 +103,9 @@ func RenderAnswer(a Answer, shell Shell) string { return out.String() } -// oneLine collapses a description onto one line, because the protocols are -// line-based: a break inside a description would look like another candidate. +// 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. diff --git a/go/argv/complete_shell_test.go b/go/argv/complete_shell_test.go index fc88c67c6..46174db13 100644 --- a/go/argv/complete_shell_test.go +++ b/go/argv/complete_shell_test.go @@ -105,3 +105,30 @@ func TestFilesAreAskedForOnTheirOwnLine(t *testing.T) { t.Errorf("no marker where paths do not belong: %q", got) } } + +// A candidate carrying a tab or a newline would be read as more fields or more +// rows. Values normally come from a spec and contain neither, but a `complete` +// script can produce anything, and rearranging the protocol is a worse failure +// than a missing candidate. +func TestACandidateCannotRearrangeTheProtocol(t *testing.T) { + a := Answer{Candidates: []Candidate{ + {Value: "one\ttwo\nthree", Describe: "a\tb"}, + }} + for _, shell := range []Shell{Bash, Zsh, Fish, Nu, PowerShell} { + got := RenderAnswer(a, shell) + if strings.Count(got, "\n") != 1 { + t.Errorf("%v: one candidate is one row, got %q", shell, got) + } + fields := strings.Count(strings.TrimRight(got, "\n"), "\t") + want := 0 + switch shell { + case Zsh: + want = 2 + case Fish, Nu, PowerShell: + want = 1 + } + if fields != want { + t.Errorf("%v: want %d tabs, got %d in %q", shell, want, fields, got) + } + } +} From 29f21e495d144218b2e44150f9ff8bd0ba3414f9 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:17:45 +0000 Subject: [PATCH 3/4] fix(go): drop a candidate that cannot travel rather than altering it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit's parent collapsed a tab or a break inside a candidate onto a space so that it could not be read as another field or another row. That protects the protocol and breaks the candidate: a value is the text that 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. Dropping it is the honest failure. The user types the value themselves and it works, and the file's own reasoning already said as much: a missing candidate is the lesser of the two. Every control character, not just the three that delimit. The marker lines that say "paths belong here too" open with `\x01`, so a value beginning with one was read as a marker. Descriptions still collapse. They are prose, nothing is typed from them, and a two-line help should still say both halves. Co-Authored-By: Claude Opus 5 --- go/argv/complete_shell.go | 37 +++++++++++++++++++++++++---- go/argv/complete_shell_test.go | 43 +++++++++++++++++++++------------- 2 files changed, 60 insertions(+), 20 deletions(-) diff --git a/go/argv/complete_shell.go b/go/argv/complete_shell.go index a2fef260b..17ca7beaa 100644 --- a/go/argv/complete_shell.go +++ b/go/argv/complete_shell.go @@ -74,9 +74,20 @@ func RenderAnswer(a Answer, shell Shell) string { // 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, and a completion that rearranges the protocol is a - // worse failure than a missing candidate. - value := oneLine(c.Value) + // 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: @@ -103,17 +114,35 @@ func RenderAnswer(a Answer, shell Shell) string { 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 == '\n' || r == '\r' || r == '\t' { + if r < 0x20 || r == 0x7f { if !spaced && out.Len() > 0 { out.WriteByte(' ') spaced = true diff --git a/go/argv/complete_shell_test.go b/go/argv/complete_shell_test.go index 46174db13..2c8bbcda2 100644 --- a/go/argv/complete_shell_test.go +++ b/go/argv/complete_shell_test.go @@ -106,29 +106,40 @@ func TestFilesAreAskedForOnTheirOwnLine(t *testing.T) { } } -// A candidate carrying a tab or a newline would be read as more fields or more -// rows. Values normally come from a spec and contain neither, but a `complete` -// script can produce anything, and rearranging the protocol is a worse failure -// than a missing candidate. -func TestACandidateCannotRearrangeTheProtocol(t *testing.T) { +// 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", Describe: "a\tb"}, + {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: one candidate is one row, got %q", shell, got) + t.Errorf("%v: only the one that travels is offered, got %q", shell, got) } - fields := strings.Count(strings.TrimRight(got, "\n"), "\t") - want := 0 - switch shell { - case Zsh: - want = 2 - case Fish, Nu, PowerShell: - want = 1 + if !strings.HasPrefix(got, "plain") { + t.Errorf("%v: the candidate that travels should survive, got %q", shell, got) } - if fields != want { - t.Errorf("%v: want %d tabs, got %d in %q", shell, want, fields, 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) } } } From f37999a9ad5ca932d042f4887c2ebd8617ae6981 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:14:45 +0000 Subject: [PATCH 4/4] fix(go): decide the description column over the rows that survive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Descriptions are all-or-nothing per answer, and the decision was made over every candidate — including the ones dropped a few lines below for carrying something that cannot travel. A description on a dropped row turned the column on for everyone else, putting an empty field on every surviving row: the rule broken by the answer it was deciding for. Co-Authored-By: Claude Opus 5 --- go/argv/complete_shell.go | 7 ++++++- go/argv/complete_shell_test.go | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/go/argv/complete_shell.go b/go/argv/complete_shell.go index 17ca7beaa..96fc0cf2e 100644 --- a/go/argv/complete_shell.go +++ b/go/argv/complete_shell.go @@ -62,9 +62,14 @@ func RenderAnswer(a Answer, shell Shell) string { // 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 != "" { + if c.Describe != "" && travels(c.Value) { described = true break } diff --git a/go/argv/complete_shell_test.go b/go/argv/complete_shell_test.go index 2c8bbcda2..dd30c1b69 100644 --- a/go/argv/complete_shell_test.go +++ b/go/argv/complete_shell_test.go @@ -129,6 +129,23 @@ func TestAValueThatCannotTravelIsNotOffered(t *testing.T) { } } +// 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) {