Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 7 additions & 9 deletions go/argv/complete.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
}
185 changes: 185 additions & 0 deletions go/argv/complete_shell.go
Original file line number Diff line number Diff line change
@@ -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
}
}
Comment thread
cursor[bot] marked this conversation as resolved.

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 {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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, "'", `'\''`) + "'"
}
162 changes: 162 additions & 0 deletions go/argv/complete_shell_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading