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
8 changes: 5 additions & 3 deletions go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,8 @@ 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.
- **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.
- **The shell scripts themselves.** A CLI can now answer a completion request —
`mycli __complete_word__ --shell zsh --line "…"` — in the text each shell reads.
What is missing is the handful of lines that register that callback with bash,
zsh, fish, nushell and PowerShell, and running the `run=` scripts a spec can
declare, which needs a subprocess this package has no business starting.
14 changes: 10 additions & 4 deletions go/argv/complete.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,10 +191,16 @@ func Candidates(pos Position, partial string, help HelpTable, meta Metadata) []C
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 {
// Flags, only where one could still be typed *and* the user has started typing
// one. The reference offers no flags for a bare cursor — `ex ⌶` lists
// subcommands, `ex -⌶` lists both forms, `ex --⌶` the longs — and checked
// against `usage complete-word` rather than assumed. Offering them anyway made
// every position look answered, which is also what decides whether the shell
// should fall back to paths.
//
// The prefix filter below would narrow them to the same set; what this changes
// is the empty prefix, where it would not.
if pos.FlagsPossible && strings.HasPrefix(partial, "-") {
for _, s := range flagsInScope(pos.Chain) {
if h := help.Lookup(s.flag.Key); h != nil && h.Hide {
continue
Expand Down
29 changes: 29 additions & 0 deletions go/argv/complete_shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,35 @@ const (
PowerShell
)

// doublesQuotes reports whether a quote inside a quoted string is written by
// doubling it, which is PowerShell's rule and nobody else's.
func (s Shell) doublesQuotes() bool { return s == PowerShell }

// backtickEscapes reports whether an escape is written with a backtick rather
// than a backslash — PowerShell again.
func (s Shell) backtickEscapes() bool { return s == PowerShell }

// ShellNamed is the shell a `--shell` argument names, and whether it named one.
//
// A completion request comes from a script this package wrote, so the name is one
// of five — but it arrives as text off a command line, and a shell that sends
// something else should get an answer rather than a crash.
func ShellNamed(name string) (Shell, bool) {
switch name {
case "bash":
return Bash, true
case "zsh":
return Zsh, true
case "fish":
return Fish, true
case "nu", "nushell":
return Nu, true
case "powershell", "pwsh":
return PowerShell, true
}
return Bash, false
}

// Files says whether paths belong at this position as well as the candidates.
type Files uint8

Expand Down
45 changes: 33 additions & 12 deletions go/argv/complete_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,24 @@ func complete(words []string, partial string) []string {
}

func TestCompletionOffersCommandsFlagsAndAliases(t *testing.T) {
// A bare cursor is asking which command to run. The reference offers no flags
// there — checked against `usage complete-word`, which answers `install` and
// `run` for a spec whose root also has `-v --verbose`.
got := complete(nil, "")
for _, want := range []string{"run", "list", "r", "--verbose", "-v", "--color", "--no-color"} {
for _, want := range []string{"run", "list", "r"} {
if !offered(got, want) {
t.Errorf("want %q offered, got %v", want, got)
}
}
for _, unwanted := range []string{"--verbose", "-v"} {
if offered(got, unwanted) {
t.Errorf("a bare cursor is not asking for flags: %v", got)
}
}

// A dash is: a lone one offers both forms.
got = complete(nil, "-")
for _, want := range []string{"--verbose", "-v", "--color", "--no-color"} {
if !offered(got, want) {
t.Errorf("want %q offered, got %v", want, got)
}
Expand All @@ -87,7 +103,7 @@ func TestCompletionFiltersByThePartialWord(t *testing.T) {

// A global is offered inside a subcommand, because the parser accepts it there.
func TestAGlobalIsOfferedInsideASubcommand(t *testing.T) {
got := complete([]string{"run"}, "")
got := complete([]string{"run"}, "-")
if !offered(got, "--verbose") {
t.Errorf("an inherited global should be offered: %v", got)
}
Expand Down Expand Up @@ -213,7 +229,7 @@ func TestOnlyTheClaimedSpellingIsWithdrawn(t *testing.T) {
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))
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)
Expand Down Expand Up @@ -264,7 +280,7 @@ func TestANegationLosesToALongOfTheSameSpelling(t *testing.T) {
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))
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)
Expand All @@ -288,7 +304,7 @@ func TestAnInheritedNegationSurvivesItsFlagsOtherSpellings(t *testing.T) {
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))
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)
}
Expand Down Expand Up @@ -339,7 +355,7 @@ func TestANegationSpelledLikeItsOwnLongIsOfferedOnce(t *testing.T) {
help := HelpTable{{Key: 1}, {Key: 2}}
meta := Metadata{{Key: 1}, {Key: 2}}

got := values(Candidates(Walk(root, nil), "", help, meta))
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)
}
Expand Down Expand Up @@ -368,19 +384,24 @@ func TestAVariadicStillCollectingOffersFlagsToo(t *testing.T) {
}

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)
}
// And once a dash is typed, the flags that would end the collection — the
// same rule as anywhere else, which is the point: this position is not the
// exclusive one a flag owed its value is.
dashed := values(Candidates(Walk(root, []string{"--tools", "a"}), "-", help, meta))
if !offered(dashed, "--force") {
t.Errorf("a flag ends the collection and binds, so it belongs here: %v", dashed)
}
// 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))
// And a flag still owed its first value keeps the position to itself, dash or
// no dash: the parser refuses a flag-like token there.
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)
}
Expand All @@ -403,7 +424,7 @@ func TestANearerFlagTakesTheSpellingFromAnInheritedNegation(t *testing.T) {
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))
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)
}
Expand Down
10 changes: 10 additions & 0 deletions go/argv/post.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ type Meta struct {
// between them can name a different flag entirely. Empty for an argument,
// which is typed as its value rather than as a form.
Spelling string
// ValueName is what a flag's value is called — the `DIR` of `--into <DIR>` —
// and empty where the entry is an argument, which is named by its value
// already. Read by completion rather than by any rule here: what a value is
// called is what says whether a path belongs there.
ValueName string
// CompleteType is the type a spec's `complete` block names for this entry,
// where it names one. Also completion's, and carried for the same reason: an
// author who wrote `complete "input" type="file"` said what the position
// takes, and the alternative is inferring it from a name they did not choose.
CompleteType string
// Flag distinguishes a missing flag from a missing argument, which the
// grammar reports as different classes.
Flag bool
Expand Down
Loading