-
-
Notifications
You must be signed in to change notification settings - Fork 51
feat(go): write an answer the way each shell reads it #989
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
f1f0285
feat(go): write an answer the way each shell reads it
jdx 50ad8ec
fix(go): keep a candidate from rearranging the protocol it travels in
jdx 29f21e4
fix(go): drop a candidate that cannot travel rather than altering it
jdx f37999a
fix(go): decide the description column over the rows that survive
jdx File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
| } | ||
|
|
||
| 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 { | ||
|
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, "'", `'\''`) + "'" | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.