From c103f5e68a34f56e4c225b33fc10323d99835cf6 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Fri, 15 Mar 2024 17:00:23 -0500 Subject: [PATCH 01/15] Add some very basic scaffolding for auto completion --- completion/README.md | 29 +++++++ completion/all.go | 151 +++++++++++++++++++++++++++++++++++ completion/bash.go | 52 ++++++++++++ completion/fish.go | 44 ++++++++++ example/completetest/main.go | 71 ++++++++++++++++ 5 files changed, 347 insertions(+) create mode 100644 completion/README.md create mode 100644 completion/all.go create mode 100644 completion/bash.go create mode 100644 completion/fish.go create mode 100644 example/completetest/main.go diff --git a/completion/README.md b/completion/README.md new file mode 100644 index 0000000..80583bf --- /dev/null +++ b/completion/README.md @@ -0,0 +1,29 @@ +# completion + +The `completion` package extends `serpent` to allow applications to generate rich auto-completions. + + +## Protocol + +The completion scripts call out to the serpent command to generate +completions. The convention is to pass the exact args and flags (or +cmdline) of the in-progress command with a `COMPLETION_MODE=1` environment variable. That environment variable lets the command know to generate completions instead of running the command. + + + +Because of this, the middleware must be installed on every command. +For example: + +```go + inv := cmd.Invoke().WithOS() + if completion.IsCompletionMode(inv) { + cmd.Walk( + func(cmd *serpent.Command) { + // Do not want to waste compute or error on flags. + cmd.RawArgs = true + cmd.Handler = completion.Middleware(nil)(cmd.Handler) + }, + ) + } + err := inv.Run() +``` \ No newline at end of file diff --git a/completion/all.go b/completion/all.go new file mode 100644 index 0000000..a43d4bb --- /dev/null +++ b/completion/all.go @@ -0,0 +1,151 @@ +package completion + +import ( + "fmt" + "os" + "os/user" + "path/filepath" + "strings" + + "github.com/coder/serpent" +) + +func getUserShell() (string, error) { + // Attempt to get the SHELL environment variable first + if shell := os.Getenv("SHELL"); shell != "" { + return filepath.Base(shell), nil + } + + // Fallback: Look up the current user and parse /etc/passwd + currentUser, err := user.Current() + if err != nil { + return "", err + } + + // Open and parse /etc/passwd + passwdFile, err := os.ReadFile("/etc/passwd") + if err != nil { + return "", err + } + + lines := strings.Split(string(passwdFile), "\n") + for _, line := range lines { + if strings.HasPrefix(line, currentUser.Username+":") { + parts := strings.Split(line, ":") + if len(parts) > 6 { + return filepath.Base(parts[6]), nil // The shell is typically the 7th field + } + } + } + + return "", fmt.Errorf("default shell not found") +} + +func IsCompletionMode(inv *serpent.Invocation) bool { + _, ok := inv.Environ.Lookup("COMPLETION_MODE") + return ok +} + +// Request is a completion request from the shell. +type Request struct { + Words []string +} + +// Middleware returns a serpent middleware function that +// hijacks completion requests and generates completion. +// +// Commands may use the "extra" function to provide additional +// completion words based on the current request. +func Middleware( + extra func(*Request, *serpent.Invocation) []string, +) serpent.MiddlewareFunc { + return func(next serpent.HandlerFunc) serpent.HandlerFunc { + return func(inv *serpent.Invocation) error { + if !IsCompletionMode(inv) { + return next(inv) + } + + r := &Request{} + + r.Words = inv.Args + + var curWord string + if len(r.Words) > 0 { + curWord = r.Words[len(r.Words)-1] + } + + var allResps []string + for _, cmd := range inv.Command.Children { + allResps = append(allResps, cmd.Name()) + } + + for _, opt := range inv.Command.Options { + allResps = append(allResps, "--"+opt.Flag) + } + + if extra != nil { + allResps = append(allResps, extra(r, inv)...) + } + + // fmt.Fprintf( + // inv.Stderr, "%v: osArgs: %v, words: %v, curWord: %s\n", + // inv.Command.Name(), + // os.Args, r.Words, curWord, + // ) + + for _, resp := range allResps { + if !strings.HasPrefix(resp, curWord) { + continue + } + + fmt.Fprintf(inv.Stdout, "%s\n", resp) + } + return nil + } + } +} + +func rootCommand(cmd *serpent.Command) *serpent.Command { + for cmd.Parent != nil { + cmd = cmd.Parent + } + return cmd +} + +// InstallCommand returns a serpent command that helps +// a user configure their shell to use serpent's completion. +func InstallCommand() *serpent.Command { + defaultShell, err := getUserShell() + if err != nil { + defaultShell = "bash" + } + + var shell string + return &serpent.Command{ + Use: "completion", + Short: "Generate completion scripts for the given shell.", + Handler: func(inv *serpent.Invocation) error { + switch shell { + case "bash": + return GenerateBashCompletion(inv.Stdout, rootCommand(inv.Command)) + case "fish": + return GenerateFishCompletion(inv.Stdout, rootCommand(inv.Command)) + default: + return fmt.Errorf("unsupported shell: %s", shell) + } + }, + Options: serpent.OptionSet{ + { + Flag: "shell", + FlagShorthand: "s", + Default: defaultShell, + Description: "The shell to generate a completion script for.", + Value: serpent.EnumOf( + &shell, + "bash", + "fish", + ), + }, + }, + } +} diff --git a/completion/bash.go b/completion/bash.go new file mode 100644 index 0000000..76135ea --- /dev/null +++ b/completion/bash.go @@ -0,0 +1,52 @@ +package completion + +import ( + "fmt" + "io" + "text/template" + + "github.com/coder/serpent" +) + +const bashCompletionTemplate = ` +_generate_{{.Name}}_completions() { + # Capture the full command line as an array, excluding the first element (the command itself) + local args=("${COMP_WORDS[@]:1}") + + # Set COMPLETION_MODE and call the command with the arguments, capturing the output + local completions=$(COMPLETION_MODE=1 "{{.Name}}" "${args[@]}") + + # Use the command's output to generate completions for the current word + COMPREPLY=($(compgen -W "$completions" -- "${COMP_WORDS[COMP_CWORD]}")) + + # Ensure no files are shown, even if there are no matches + if [ ${#COMPREPLY[@]} -eq 0 ]; then + COMPREPLY=() + fi +} + +# Setup Bash to use the function for completions for '{{.Name}}' +complete -F _generate_{{.Name}}_completions {{.Name}} +` + +func GenerateBashCompletion( + w io.Writer, + rootCmd *serpent.Command, +) error { + tmpl, err := template.New("bash").Parse(bashCompletionTemplate) + if err != nil { + return fmt.Errorf("parse template: %w", err) + } + + err = tmpl.Execute( + w, + map[string]string{ + "Name": rootCmd.Name(), + }, + ) + if err != nil { + return fmt.Errorf("execute template: %w", err) + } + + return nil +} diff --git a/completion/fish.go b/completion/fish.go new file mode 100644 index 0000000..9dd7a8f --- /dev/null +++ b/completion/fish.go @@ -0,0 +1,44 @@ +package completion + +import ( + "fmt" + "io" + "text/template" + + "github.com/coder/serpent" +) + +const fishCompletionTemplate = ` +function _{{.Name}}_completions + # Capture the full command line as an array + set -l args (commandline -o) + + COMPLETION_MODE=1 $args +end + +# Setup Fish to use the function for completions for '{{.Name}}' +complete -c {{.Name}} -f -a '(_{{.Name}}_completions)' + +` + +func GenerateFishCompletion( + w io.Writer, + rootCmd *serpent.Command, +) error { + tmpl, err := template.New("fish").Parse(fishCompletionTemplate) + if err != nil { + return fmt.Errorf("parse template: %w", err) + } + + err = tmpl.Execute( + w, + map[string]string{ + "Name": rootCmd.Name(), + }, + ) + if err != nil { + return fmt.Errorf("execute template: %w", err) + } + + return nil +} diff --git a/example/completetest/main.go b/example/completetest/main.go new file mode 100644 index 0000000..1d854b8 --- /dev/null +++ b/example/completetest/main.go @@ -0,0 +1,71 @@ +package main + +import ( + "os" + "strings" + + "github.com/coder/serpent" + "github.com/coder/serpent/completion" +) + +func main() { + var upper bool + cmd := serpent.Command{ + Use: "completetest ", + Short: "Prints the given text to the console.", + Options: serpent.OptionSet{ + { + Name: "upper", + Value: serpent.BoolOf(&upper), + Flag: "upper", + Description: "Prints the text in upper case.", + }, + }, + Handler: func(inv *serpent.Invocation) error { + if len(inv.Args) == 0 { + inv.Stderr.Write([]byte("error: missing text\n")) + os.Exit(1) + } + + text := inv.Args[0] + if upper { + text = strings.ToUpper(text) + } + + inv.Stdout.Write([]byte(text)) + return nil + }, + Children: []*serpent.Command{ + { + Use: "sub", + Short: "A subcommand", + Handler: func(inv *serpent.Invocation) error { + inv.Stdout.Write([]byte("subcommand")) + return nil + }, + Options: serpent.OptionSet{ + { + Name: "upper", + Value: serpent.BoolOf(&upper), + Flag: "upper", + Description: "Prints the text in upper case.", + }, + }, + }, + completion.InstallCommand(), + }, + } + + inv := cmd.Invoke().WithOS() + if completion.IsCompletionMode(inv) { + cmd.Walk( + func(cmd *serpent.Command) { + cmd.Handler = completion.Middleware(nil)(cmd.Handler) + }, + ) + } + err := inv.Run() + if err != nil { + panic(err) + } +} From b7749c30da5148be97a4d98a697debc934dc96be Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Sun, 17 Mar 2024 23:01:05 -0500 Subject: [PATCH 02/15] Move completion code into root package So we can have a first-class CompletionHandler --- command.go | 35 +++++++++++++++++--- completion.go | 45 +++++++++++++++++++++++++ completion/all.go | 64 ------------------------------------ completion/fish.go | 4 ++- example/completetest/main.go | 8 +---- 5 files changed, 79 insertions(+), 77 deletions(-) create mode 100644 completion.go diff --git a/command.go b/command.go index 8b99e26..fa9e7dd 100644 --- a/command.go +++ b/command.go @@ -59,6 +59,13 @@ type Command struct { Middleware MiddlewareFunc Handler HandlerFunc HelpHandler HandlerFunc + // CompletionHandler is called when the command is run is completion + // mode. The HandlerFunc should emit new-line separated suggestions to + // stdout. + // + // Flag and option parsing is best-effort in this mode, so even if an Option + // is "required" it may not be set. + CompletionHandler HandlerFunc } // AddSubcommands adds the given subcommands, setting their @@ -193,15 +200,22 @@ type Invocation struct { ctx context.Context Command *Command parsedFlags *pflag.FlagSet - Args []string + + // Args is reduced into the remaining arguments after parsing flags + // during Run. + Args []string + // Environ is a list of environment variables. Use EnvsWithPrefix to parse // os.Environ. Environ Environ Stdout io.Writer Stderr io.Writer Stdin io.Reader - Logger slog.Logger - Net Net + + // Deprecated + Logger slog.Logger + // Deprecated + Net Net // testing signalNotifyContext func(parent context.Context, signals ...os.Signal) (ctx context.Context, stop context.CancelFunc) @@ -378,8 +392,10 @@ func (inv *Invocation) run(state *runState) error { } } + ignoreFlagParseErrors := inv.Command.RawArgs || inv.IsCompletionMode() + // Flag parse errors are irrelevant for raw args commands. - if !inv.Command.RawArgs && state.flagParseErr != nil && !errors.Is(state.flagParseErr, pflag.ErrHelp) { + if !ignoreFlagParseErrors && state.flagParseErr != nil && !errors.Is(state.flagParseErr, pflag.ErrHelp) { return xerrors.Errorf( "parsing flags (%v) for %q: %w", state.allArgs, @@ -396,7 +412,7 @@ func (inv *Invocation) run(state *runState) error { } } // Don't error for missing flags if `--help` was supplied. - if len(missing) > 0 && !errors.Is(state.flagParseErr, pflag.ErrHelp) { + if len(missing) > 0 && !inv.IsCompletionMode() && !errors.Is(state.flagParseErr, pflag.ErrHelp) { return xerrors.Errorf("Missing values for the required flags: %s", strings.Join(missing, ", ")) } @@ -438,6 +454,13 @@ func (inv *Invocation) run(state *runState) error { return inv.Command.HelpHandler(inv) } + if inv.IsCompletionMode() { + if inv.Command.CompletionHandler == nil { + return DefaultCompletionHandler(NopHandler)(inv) + } + return inv.Command.CompletionHandler(inv) + } + err = mw(inv.Command.Handler)(inv) if err != nil { return &RunCommandError{ @@ -637,3 +660,5 @@ func RequireRangeArgs(start, end int) MiddlewareFunc { // HandlerFunc handles an Invocation of a command. type HandlerFunc func(i *Invocation) error + +var NopHandler HandlerFunc = func(i *Invocation) error { return nil } diff --git a/completion.go b/completion.go new file mode 100644 index 0000000..abd63fb --- /dev/null +++ b/completion.go @@ -0,0 +1,45 @@ +package serpent + +import ( + "fmt" + "strings" +) + +// CompletionModeEnv is a special environment variable that is +// set when the command is being run in completion mode. +const CompletionModeEnv = "COMPLETION_MODE" + +// IsCompletionMode returns true if the command is being run in completion mode. +func (inv *Invocation) IsCompletionMode() bool { + _, ok := inv.Environ.Lookup(CompletionModeEnv) + return ok +} + +func DefaultCompletionHandler(next HandlerFunc) HandlerFunc { + return func(inv *Invocation) error { + words := inv.Args + + var curWord string + if len(words) > 0 { + curWord = words[len(words)-1] + } + + var allResps []string + for _, cmd := range inv.Command.Children { + allResps = append(allResps, cmd.Name()) + } + + for _, opt := range inv.Command.Options { + allResps = append(allResps, "--"+opt.Flag) + } + + for _, resp := range allResps { + if !strings.HasPrefix(resp, curWord) { + continue + } + + fmt.Fprintf(inv.Stdout, "%s\n", resp) + } + return nil + } +} diff --git a/completion/all.go b/completion/all.go index a43d4bb..51f1c0d 100644 --- a/completion/all.go +++ b/completion/all.go @@ -41,70 +41,6 @@ func getUserShell() (string, error) { return "", fmt.Errorf("default shell not found") } -func IsCompletionMode(inv *serpent.Invocation) bool { - _, ok := inv.Environ.Lookup("COMPLETION_MODE") - return ok -} - -// Request is a completion request from the shell. -type Request struct { - Words []string -} - -// Middleware returns a serpent middleware function that -// hijacks completion requests and generates completion. -// -// Commands may use the "extra" function to provide additional -// completion words based on the current request. -func Middleware( - extra func(*Request, *serpent.Invocation) []string, -) serpent.MiddlewareFunc { - return func(next serpent.HandlerFunc) serpent.HandlerFunc { - return func(inv *serpent.Invocation) error { - if !IsCompletionMode(inv) { - return next(inv) - } - - r := &Request{} - - r.Words = inv.Args - - var curWord string - if len(r.Words) > 0 { - curWord = r.Words[len(r.Words)-1] - } - - var allResps []string - for _, cmd := range inv.Command.Children { - allResps = append(allResps, cmd.Name()) - } - - for _, opt := range inv.Command.Options { - allResps = append(allResps, "--"+opt.Flag) - } - - if extra != nil { - allResps = append(allResps, extra(r, inv)...) - } - - // fmt.Fprintf( - // inv.Stderr, "%v: osArgs: %v, words: %v, curWord: %s\n", - // inv.Command.Name(), - // os.Args, r.Words, curWord, - // ) - - for _, resp := range allResps { - if !strings.HasPrefix(resp, curWord) { - continue - } - - fmt.Fprintf(inv.Stdout, "%s\n", resp) - } - return nil - } - } -} - func rootCommand(cmd *serpent.Command) *serpent.Command { for cmd.Parent != nil { cmd = cmd.Parent diff --git a/completion/fish.go b/completion/fish.go index 9dd7a8f..16a0e7b 100644 --- a/completion/fish.go +++ b/completion/fish.go @@ -13,7 +13,9 @@ function _{{.Name}}_completions # Capture the full command line as an array set -l args (commandline -o) - COMPLETION_MODE=1 $args + set -l cursor_pos (commandline -C) + + COMPLETION_MODE=1 CURSOR_POS=$cursor_pos $args end # Setup Fish to use the function for completions for '{{.Name}}' diff --git a/example/completetest/main.go b/example/completetest/main.go index 1d854b8..7a7edf1 100644 --- a/example/completetest/main.go +++ b/example/completetest/main.go @@ -57,13 +57,7 @@ func main() { } inv := cmd.Invoke().WithOS() - if completion.IsCompletionMode(inv) { - cmd.Walk( - func(cmd *serpent.Command) { - cmd.Handler = completion.Middleware(nil)(cmd.Handler) - }, - ) - } + err := inv.Run() if err != nil { panic(err) From ebea56994198e102f1363fe486f8be258ccb748d Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Mon, 1 Apr 2024 15:21:18 -0500 Subject: [PATCH 03/15] Play around --- completion.go | 21 +++++++++--------- completion/file.go | 53 ++++++++++++++++++++++++++++++++++++++++++++++ completion_test.go | 7 ++++++ 3 files changed, 71 insertions(+), 10 deletions(-) create mode 100644 completion/file.go create mode 100644 completion_test.go diff --git a/completion.go b/completion.go index abd63fb..e69304a 100644 --- a/completion.go +++ b/completion.go @@ -2,7 +2,6 @@ package serpent import ( "fmt" - "strings" ) // CompletionModeEnv is a special environment variable that is @@ -15,14 +14,16 @@ func (inv *Invocation) IsCompletionMode() bool { return ok } +// DefaultCompletionHandler returns a handler that prints all +// known flags and subcommands. func DefaultCompletionHandler(next HandlerFunc) HandlerFunc { return func(inv *Invocation) error { - words := inv.Args + // words := inv.Args - var curWord string - if len(words) > 0 { - curWord = words[len(words)-1] - } + // var curWord string + // if len(words) > 0 { + // curWord = words[len(words)-1] + // } var allResps []string for _, cmd := range inv.Command.Children { @@ -34,12 +35,12 @@ func DefaultCompletionHandler(next HandlerFunc) HandlerFunc { } for _, resp := range allResps { - if !strings.HasPrefix(resp, curWord) { - continue - } + // if !strings.HasPrefix(resp, curWord) { + // continue + // } fmt.Fprintf(inv.Stdout, "%s\n", resp) } - return nil + return next(inv) } } diff --git a/completion/file.go b/completion/file.go new file mode 100644 index 0000000..67baef9 --- /dev/null +++ b/completion/file.go @@ -0,0 +1,53 @@ +package completion + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/coder/serpent" +) + +// FileHandler returns a handler that completes files, using the +// given filter func, which may be nil. +func FileHandler(next serpent.HandlerFunc, filter func(info *os.FileInfo) bool) serpent.HandlerFunc { + return func(inv *serpent.Invocation) error { + words := inv.Args + + var curWord string + if len(words) > 0 { + curWord = words[len(words)-1] + } + + dir := filepath.Dir(curWord) + if dir == "" { + dir = "." + } + + f, err := os.Open(dir) + if err != nil { + return err + } + defer f.Close() + + infos, err := f.Readdir(0) + if err != nil { + return err + } + + for _, info := range infos { + if filter != nil && !filter(&info) { + continue + } + + if !strings.HasPrefix(info.Name(), curWord) { + continue + } + + fmt.Fprintf(inv.Stdout, "%s\n", info.Name()) + } + + return next(inv) + } +} diff --git a/completion_test.go b/completion_test.go new file mode 100644 index 0000000..39e2180 --- /dev/null +++ b/completion_test.go @@ -0,0 +1,7 @@ +package serpent + +import "testing" + +func TestCompletion(t *testing.T) { + t.Parallel() +} From d910feccc6d2dbe5f268a770ca7d90876510b174 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 3 Jul 2024 12:37:48 +0000 Subject: [PATCH 04/15] mvp + tests --- command.go | 83 +++++++++++++-- command_test.go | 192 +++++++++++++++++++---------------- completion.go | 38 ++----- completion/README.md | 20 +--- completion/all.go | 75 ++++++-------- completion/bash.go | 6 +- completion/file.go | 53 ---------- completion/fish.go | 15 +-- completion/handlers.go | 60 +++++++++++ completion_test.go | 143 +++++++++++++++++++++++++- example/completetest/main.go | 68 ++++++++++++- option.go | 12 +++ 12 files changed, 502 insertions(+), 263 deletions(-) delete mode 100644 completion/file.go create mode 100644 completion/handlers.go diff --git a/command.go b/command.go index fa9e7dd..f42a632 100644 --- a/command.go +++ b/command.go @@ -60,12 +60,11 @@ type Command struct { Handler HandlerFunc HelpHandler HandlerFunc // CompletionHandler is called when the command is run is completion - // mode. The HandlerFunc should emit new-line separated suggestions to - // stdout. + // mode. If nil, only the default completion handler is used. // // Flag and option parsing is best-effort in this mode, so even if an Option // is "required" it may not be set. - CompletionHandler HandlerFunc + CompletionHandler CompletionHandlerFunc } // AddSubcommands adds the given subcommands, setting their @@ -188,6 +187,7 @@ func (c *Command) Invoke(args ...string) *Invocation { return &Invocation{ Command: c, Args: args, + AllArgs: args, Stdout: io.Discard, Stderr: io.Discard, Stdin: strings.NewReader(""), @@ -204,6 +204,11 @@ type Invocation struct { // Args is reduced into the remaining arguments after parsing flags // during Run. Args []string + // AllArgs is the original arguments passed to the command, including flags. + // When invoked `WithOS`, this includes argv[0], otherwise it is the same as Args. + AllArgs []string + // CurWord is the word the terminal cursor is currently in + CurWord string // Environ is a list of environment variables. Use EnvsWithPrefix to parse // os.Environ. @@ -228,6 +233,7 @@ func (inv *Invocation) WithOS() *Invocation { i.Stdout = os.Stdout i.Stderr = os.Stderr i.Stdin = os.Stdin + i.AllArgs = os.Args i.Args = os.Args[1:] i.Environ = ParseEnviron(os.Environ(), "") i.Net = osNet{} @@ -296,6 +302,17 @@ func copyFlagSetWithout(fs *pflag.FlagSet, without string) *pflag.FlagSet { return fs2 } +func (inv *Invocation) GetCurWords() (prev string, cur string) { + if len(inv.AllArgs) == 1 { + cur = inv.AllArgs[0] + prev = "" + } else { + cur = inv.AllArgs[len(inv.AllArgs)-1] + prev = inv.AllArgs[len(inv.AllArgs)-2] + } + return +} + // run recursively executes the command and its children. // allArgs is wired through the stack so that global flags can be accepted // anywhere in the command invocation. @@ -447,6 +464,36 @@ func (inv *Invocation) run(state *runState) error { defer cancel() inv = inv.WithContext(ctx) + if inv.IsCompletionMode() { + prev, cur := inv.GetCurWords() + inv.CurWord = cur + if prev != "" { + // If the previous word is a flag, we use it's handler + if strings.HasPrefix(prev, "--") { + opt := inv.Command.Options.ByFlag(prev[2:]) + if opt != nil && opt.CompletionHandler != nil { + for _, e := range opt.CompletionHandler(inv) { + fmt.Fprintf(inv.Stdout, "%s\n", e) + } + return nil + } + } + } + if inv.Command.Name() == inv.CurWord { + fmt.Fprintf(inv.Stdout, "%s\n", inv.Command.Name()) + return nil + } + if inv.Command.CompletionHandler != nil { + for _, e := range inv.Command.CompletionHandler(inv) { + fmt.Fprintf(inv.Stdout, "%s\n", e) + } + } + for _, e := range DefaultCompletionHandler(inv) { + fmt.Fprintf(inv.Stdout, "%s\n", e) + } + return nil + } + if inv.Command.Handler == nil || errors.Is(state.flagParseErr, pflag.ErrHelp) { if inv.Command.HelpHandler == nil { return defaultHelpFn()(inv) @@ -454,13 +501,6 @@ func (inv *Invocation) run(state *runState) error { return inv.Command.HelpHandler(inv) } - if inv.IsCompletionMode() { - if inv.Command.CompletionHandler == nil { - return DefaultCompletionHandler(NopHandler)(inv) - } - return inv.Command.CompletionHandler(inv) - } - err = mw(inv.Command.Handler)(inv) if err != nil { return &RunCommandError{ @@ -523,6 +563,27 @@ func findArg(want string, args []string, fs *pflag.FlagSet) (int, error) { return -1, xerrors.Errorf("arg %s not found", want) } +// // findArgByPos returns the index of first full word before the given cursor position in the arguments +// // list. If the cursor is at the end of the line, the last word is returned. +// func findArgByPos(pos int, args []string) int { +// if pos == 0 { +// return -1 +// } +// if len(args) == 0 { +// return -1 +// } +// curChar := 0 +// for i, arg := range args { +// next := curChar + len(arg) +// if pos <= next { +// return i +// } +// curChar = next + 1 +// } +// // Otherwise, must be the last word +// return len(args) +// } + // Run executes the command. // If two command share a flag name, the first command wins. // @@ -661,4 +722,6 @@ func RequireRangeArgs(start, end int) MiddlewareFunc { // HandlerFunc handles an Invocation of a command. type HandlerFunc func(i *Invocation) error +type CompletionHandlerFunc func(i *Invocation) []string + var NopHandler HandlerFunc = func(i *Invocation) error { return nil } diff --git a/command_test.go b/command_test.go index f6a20a2..6d78859 100644 --- a/command_test.go +++ b/command_test.go @@ -12,6 +12,7 @@ import ( "golang.org/x/xerrors" serpent "github.com/coder/serpent" + "github.com/coder/serpent/completion" ) // ioBufs is the standard input, output, and error for a command. @@ -30,100 +31,121 @@ func fakeIO(i *serpent.Invocation) *ioBufs { return &b } -func TestCommand(t *testing.T) { - t.Parallel() - - cmd := func() *serpent.Command { - var ( - verbose bool - lower bool - prefix string - reqBool bool - reqStr string - ) - return &serpent.Command{ - Use: "root [subcommand]", - Options: serpent.OptionSet{ - serpent.Option{ - Name: "verbose", - Flag: "verbose", - Value: serpent.BoolOf(&verbose), - }, - serpent.Option{ - Name: "prefix", - Flag: "prefix", - Value: serpent.StringOf(&prefix), - }, +func SampleCommand(t *testing.T) *serpent.Command { + t.Helper() + var ( + verbose bool + lower bool + prefix string + reqBool bool + reqStr string + enumStr string + ) + enumChoices := []string{"foo", "bar", "qux"} + return &serpent.Command{ + Use: "root [subcommand]", + Options: serpent.OptionSet{ + serpent.Option{ + Name: "verbose", + Flag: "verbose", + Value: serpent.BoolOf(&verbose), }, - Children: []*serpent.Command{ - { - Use: "required-flag --req-bool=true --req-string=foo", - Short: "Example with required flags", - Options: serpent.OptionSet{ - serpent.Option{ - Name: "req-bool", - Flag: "req-bool", - Value: serpent.BoolOf(&reqBool), - Required: true, - }, - serpent.Option{ - Name: "req-string", - Flag: "req-string", - Value: serpent.Validate(serpent.StringOf(&reqStr), func(value *serpent.String) error { - ok := strings.Contains(value.String(), " ") - if !ok { - return xerrors.Errorf("string must contain a space") - } - return nil - }), - Required: true, - }, + serpent.Option{ + Name: "prefix", + Flag: "prefix", + Value: serpent.StringOf(&prefix), + }, + }, + Children: []*serpent.Command{ + { + Use: "required-flag --req-bool=true --req-string=foo", + Short: "Example with required flags", + Options: serpent.OptionSet{ + serpent.Option{ + Name: "req-bool", + Flag: "req-bool", + Value: serpent.BoolOf(&reqBool), + Required: true, }, - HelpHandler: func(i *serpent.Invocation) error { - _, _ = i.Stdout.Write([]byte("help text.png")) - return nil + serpent.Option{ + Name: "req-string", + Flag: "req-string", + Value: serpent.Validate(serpent.StringOf(&reqStr), func(value *serpent.String) error { + ok := strings.Contains(value.String(), " ") + if !ok { + return xerrors.Errorf("string must contain a space") + } + return nil + }), + Required: true, }, - Handler: func(i *serpent.Invocation) error { - _, _ = i.Stdout.Write([]byte(fmt.Sprintf("%s-%t", reqStr, reqBool))) - return nil + serpent.Option{ + Name: "req-enum", + Flag: "req-enum", + Value: serpent.EnumOf(&enumStr, enumChoices...), + CompletionHandler: completion.EnumHandler(enumChoices...), }, }, - { - Use: "toupper [word]", - Short: "Converts a word to upper case", - Middleware: serpent.Chain( - serpent.RequireNArgs(1), - ), - Aliases: []string{"up"}, - Options: serpent.OptionSet{ - serpent.Option{ - Name: "lower", - Flag: "lower", - Value: serpent.BoolOf(&lower), - }, - }, - Handler: func(i *serpent.Invocation) error { - _, _ = i.Stdout.Write([]byte(prefix)) - w := i.Args[0] - if lower { - w = strings.ToLower(w) - } else { - w = strings.ToUpper(w) - } - _, _ = i.Stdout.Write( - []byte( - w, - ), - ) - if verbose { - _, _ = i.Stdout.Write([]byte("!!!")) - } - return nil + HelpHandler: func(i *serpent.Invocation) error { + _, _ = i.Stdout.Write([]byte("help text.png")) + return nil + }, + Handler: func(i *serpent.Invocation) error { + _, _ = i.Stdout.Write([]byte(fmt.Sprintf("%s-%t", reqStr, reqBool))) + return nil + }, + }, + { + Use: "toupper [word]", + Short: "Converts a word to upper case", + Middleware: serpent.Chain( + serpent.RequireNArgs(1), + ), + Aliases: []string{"up"}, + Options: serpent.OptionSet{ + serpent.Option{ + Name: "lower", + Flag: "lower", + Value: serpent.BoolOf(&lower), }, }, + Handler: func(i *serpent.Invocation) error { + _, _ = i.Stdout.Write([]byte(prefix)) + w := i.Args[0] + if lower { + w = strings.ToLower(w) + } else { + w = strings.ToUpper(w) + } + _, _ = i.Stdout.Write( + []byte( + w, + ), + ) + if verbose { + _, _ = i.Stdout.Write([]byte("!!!")) + } + return nil + }, }, - } + { + Use: "file ", + Handler: func(inv *serpent.Invocation) error { + return nil + }, + CompletionHandler: completion.FileHandler(func(info os.FileInfo) bool { + return true + }), + Middleware: serpent.RequireNArgs(1), + }, + }, } +} + +func TestCommand(t *testing.T) { + t.Parallel() + + cmd := func() *serpent.Command { return SampleCommand(t) } t.Run("SimpleOK", func(t *testing.T) { t.Parallel() diff --git a/completion.go b/completion.go index e69304a..69ec902 100644 --- a/completion.go +++ b/completion.go @@ -1,9 +1,5 @@ package serpent -import ( - "fmt" -) - // CompletionModeEnv is a special environment variable that is // set when the command is being run in completion mode. const CompletionModeEnv = "COMPLETION_MODE" @@ -15,32 +11,16 @@ func (inv *Invocation) IsCompletionMode() bool { } // DefaultCompletionHandler returns a handler that prints all -// known flags and subcommands. -func DefaultCompletionHandler(next HandlerFunc) HandlerFunc { - return func(inv *Invocation) error { - // words := inv.Args - - // var curWord string - // if len(words) > 0 { - // curWord = words[len(words)-1] - // } - - var allResps []string - for _, cmd := range inv.Command.Children { - allResps = append(allResps, cmd.Name()) - } - - for _, opt := range inv.Command.Options { +// known flags and subcommands that haven't already been set to valid values. +func DefaultCompletionHandler(inv *Invocation) []string { + var allResps []string + for _, cmd := range inv.Command.Children { + allResps = append(allResps, cmd.Name()) + } + for _, opt := range inv.Command.Options { + if opt.ValueSource == ValueSourceNone { allResps = append(allResps, "--"+opt.Flag) } - - for _, resp := range allResps { - // if !strings.HasPrefix(resp, curWord) { - // continue - // } - - fmt.Fprintf(inv.Stdout, "%s\n", resp) - } - return next(inv) } + return allResps } diff --git a/completion/README.md b/completion/README.md index 80583bf..d7021ed 100644 --- a/completion/README.md +++ b/completion/README.md @@ -8,22 +8,4 @@ The `completion` package extends `serpent` to allow applications to generate ric The completion scripts call out to the serpent command to generate completions. The convention is to pass the exact args and flags (or cmdline) of the in-progress command with a `COMPLETION_MODE=1` environment variable. That environment variable lets the command know to generate completions instead of running the command. - - - -Because of this, the middleware must be installed on every command. -For example: - -```go - inv := cmd.Invoke().WithOS() - if completion.IsCompletionMode(inv) { - cmd.Walk( - func(cmd *serpent.Command) { - // Do not want to waste compute or error on flags. - cmd.RawArgs = true - cmd.Handler = completion.Middleware(nil)(cmd.Handler) - }, - ) - } - err := inv.Run() -``` \ No newline at end of file +By default, completions will be generated based on available flags and subcommands. Additional completions can be added by supplying a `CompletionHandlerFunc` on an Option or Command. \ No newline at end of file diff --git a/completion/all.go b/completion/all.go index 51f1c0d..a6614c4 100644 --- a/completion/all.go +++ b/completion/all.go @@ -2,6 +2,7 @@ package completion import ( "fmt" + "io" "os" "os/user" "path/filepath" @@ -10,7 +11,34 @@ import ( "github.com/coder/serpent" ) -func getUserShell() (string, error) { +const ( + BashShell string = "bash" + FishShell string = "fish" +) + +var shellCompletionByName = map[string]func(io.Writer, string) error{ + BashShell: GenerateBashCompletion, + FishShell: GenerateFishCompletion, +} + +func ShellOptions(choice *string) *serpent.Enum { + return serpent.EnumOf(choice, BashShell, FishShell) +} + +func ShellHandler() serpent.CompletionHandlerFunc { + return EnumHandler(BashShell, FishShell) +} + +func GetCompletion(writer io.Writer, shell string, cmdName string) error { + fn, ok := shellCompletionByName[shell] + if !ok { + return fmt.Errorf("unknown shell %q", shell) + } + fn(writer, cmdName) + return nil +} + +func GetUserShell() (string, error) { // Attempt to get the SHELL environment variable first if shell := os.Getenv("SHELL"); shell != "" { return filepath.Base(shell), nil @@ -40,48 +68,3 @@ func getUserShell() (string, error) { return "", fmt.Errorf("default shell not found") } - -func rootCommand(cmd *serpent.Command) *serpent.Command { - for cmd.Parent != nil { - cmd = cmd.Parent - } - return cmd -} - -// InstallCommand returns a serpent command that helps -// a user configure their shell to use serpent's completion. -func InstallCommand() *serpent.Command { - defaultShell, err := getUserShell() - if err != nil { - defaultShell = "bash" - } - - var shell string - return &serpent.Command{ - Use: "completion", - Short: "Generate completion scripts for the given shell.", - Handler: func(inv *serpent.Invocation) error { - switch shell { - case "bash": - return GenerateBashCompletion(inv.Stdout, rootCommand(inv.Command)) - case "fish": - return GenerateFishCompletion(inv.Stdout, rootCommand(inv.Command)) - default: - return fmt.Errorf("unsupported shell: %s", shell) - } - }, - Options: serpent.OptionSet{ - { - Flag: "shell", - FlagShorthand: "s", - Default: defaultShell, - Description: "The shell to generate a completion script for.", - Value: serpent.EnumOf( - &shell, - "bash", - "fish", - ), - }, - }, - } -} diff --git a/completion/bash.go b/completion/bash.go index 76135ea..c805927 100644 --- a/completion/bash.go +++ b/completion/bash.go @@ -4,8 +4,6 @@ import ( "fmt" "io" "text/template" - - "github.com/coder/serpent" ) const bashCompletionTemplate = ` @@ -31,7 +29,7 @@ complete -F _generate_{{.Name}}_completions {{.Name}} func GenerateBashCompletion( w io.Writer, - rootCmd *serpent.Command, + rootCmdName string, ) error { tmpl, err := template.New("bash").Parse(bashCompletionTemplate) if err != nil { @@ -41,7 +39,7 @@ func GenerateBashCompletion( err = tmpl.Execute( w, map[string]string{ - "Name": rootCmd.Name(), + "Name": rootCmdName, }, ) if err != nil { diff --git a/completion/file.go b/completion/file.go deleted file mode 100644 index 67baef9..0000000 --- a/completion/file.go +++ /dev/null @@ -1,53 +0,0 @@ -package completion - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/coder/serpent" -) - -// FileHandler returns a handler that completes files, using the -// given filter func, which may be nil. -func FileHandler(next serpent.HandlerFunc, filter func(info *os.FileInfo) bool) serpent.HandlerFunc { - return func(inv *serpent.Invocation) error { - words := inv.Args - - var curWord string - if len(words) > 0 { - curWord = words[len(words)-1] - } - - dir := filepath.Dir(curWord) - if dir == "" { - dir = "." - } - - f, err := os.Open(dir) - if err != nil { - return err - } - defer f.Close() - - infos, err := f.Readdir(0) - if err != nil { - return err - } - - for _, info := range infos { - if filter != nil && !filter(&info) { - continue - } - - if !strings.HasPrefix(info.Name(), curWord) { - continue - } - - fmt.Fprintf(inv.Stdout, "%s\n", info.Name()) - } - - return next(inv) - } -} diff --git a/completion/fish.go b/completion/fish.go index 16a0e7b..c498bff 100644 --- a/completion/fish.go +++ b/completion/fish.go @@ -4,28 +4,23 @@ import ( "fmt" "io" "text/template" - - "github.com/coder/serpent" ) const fishCompletionTemplate = ` function _{{.Name}}_completions # Capture the full command line as an array - set -l args (commandline -o) - - set -l cursor_pos (commandline -C) - - COMPLETION_MODE=1 CURSOR_POS=$cursor_pos $args + set -l args (commandline -opc) + set -l current (commandline -ct) + COMPLETION_MODE=1 $args $current end # Setup Fish to use the function for completions for '{{.Name}}' complete -c {{.Name}} -f -a '(_{{.Name}}_completions)' - ` func GenerateFishCompletion( w io.Writer, - rootCmd *serpent.Command, + rootCmdName string, ) error { tmpl, err := template.New("fish").Parse(fishCompletionTemplate) if err != nil { @@ -35,7 +30,7 @@ func GenerateFishCompletion( err = tmpl.Execute( w, map[string]string{ - "Name": rootCmd.Name(), + "Name": rootCmdName, }, ) if err != nil { diff --git a/completion/handlers.go b/completion/handlers.go new file mode 100644 index 0000000..0f5be86 --- /dev/null +++ b/completion/handlers.go @@ -0,0 +1,60 @@ +package completion + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/coder/serpent" +) + +func EnumHandler(choices ...string) serpent.CompletionHandlerFunc { + return func(inv *serpent.Invocation) []string { + return choices + } +} + +// FileHandler returns a handler that completes files, using the +// given filter func, which may be nil. +func FileHandler(filter func(info os.FileInfo) bool) serpent.CompletionHandlerFunc { + return func(inv *serpent.Invocation) []string { + out := make([]string, 0, 32) + curWord := inv.CurWord + dir, _ := filepath.Split(curWord) + if dir == "" { + dir = "." + } + f, err := os.Open(dir) + if err != nil { + return out + } + defer f.Close() + if dir == "." { + dir = "" + } + + infos, err := f.Readdir(0) + if err != nil { + return out + } + + for _, info := range infos { + if filter != nil && !filter(info) { + continue + } + + var cur string + if info.IsDir() { + cur = fmt.Sprintf("%s%s/", dir, info.Name()) + } else { + cur = fmt.Sprintf("%s%s", dir, info.Name()) + } + + if strings.HasPrefix(cur, curWord) { + out = append(out, cur) + } + } + return out + } +} diff --git a/completion_test.go b/completion_test.go index 39e2180..d6c7f57 100644 --- a/completion_test.go +++ b/completion_test.go @@ -1,7 +1,146 @@ -package serpent +package serpent_test -import "testing" +import ( + "os" + "runtime" + "strings" + "testing" + + serpent "github.com/coder/serpent" + "github.com/stretchr/testify/require" +) func TestCompletion(t *testing.T) { t.Parallel() + + cmd := func() *serpent.Command { return SampleCommand(t) } + + t.Run("SubcommandList", func(t *testing.T) { + t.Parallel() + i := cmd().Invoke("") + i.Environ.Set(serpent.CompletionModeEnv, "1") + io := fakeIO(i) + err := i.Run() + require.NoError(t, err) + require.Equal(t, "file\nrequired-flag\ntoupper\n--prefix\n--verbose\n", io.Stdout.String()) + }) + + t.Run("SubcommandComplete", func(t *testing.T) { + t.Parallel() + i := cmd().Invoke("required-flag") + i.Environ.Set(serpent.CompletionModeEnv, "1") + io := fakeIO(i) + err := i.Run() + require.NoError(t, err) + require.Equal(t, "required-flag\n", io.Stdout.String()) + }) + + t.Run("ListFlags", func(t *testing.T) { + t.Parallel() + i := cmd().Invoke("required-flag", "") + i.Environ.Set(serpent.CompletionModeEnv, "1") + io := fakeIO(i) + err := i.Run() + require.NoError(t, err) + require.Equal(t, "--req-bool\n--req-enum\n--req-string\n", io.Stdout.String()) + }) + + t.Run("FlagExhaustive", func(t *testing.T) { + t.Parallel() + i := cmd().Invoke("required-flag", "--req-bool", "--req-string", "foo bar") + i.Environ.Set(serpent.CompletionModeEnv, "1") + io := fakeIO(i) + err := i.Run() + require.NoError(t, err) + require.Equal(t, "--req-enum\n", io.Stdout.String()) + }) + + t.Run("EnumOK", func(t *testing.T) { + t.Parallel() + i := cmd().Invoke("required-flag", "--req-enum", "") + i.Environ.Set(serpent.CompletionModeEnv, "1") + io := fakeIO(i) + err := i.Run() + require.NoError(t, err) + require.Equal(t, "foo\nbar\nqux\n", io.Stdout.String()) + }) +} + +func TestFileCompletion(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("Skipping test on Windows") + } + + cmd := func() *serpent.Command { return SampleCommand(t) } + + t.Run("DirOK", func(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + i := cmd().Invoke("file", tempDir) + i.Environ.Set(serpent.CompletionModeEnv, "1") + io := fakeIO(i) + err := i.Run() + require.NoError(t, err) + require.Equal(t, tempDir+"/\n", io.Stdout.String()) + }) + + t.Run("EmptyDirOK", func(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + "/" + i := cmd().Invoke("file", tempDir) + i.Environ.Set(serpent.CompletionModeEnv, "1") + io := fakeIO(i) + err := i.Run() + require.NoError(t, err) + require.Equal(t, "", io.Stdout.String()) + }) + + cases := []struct { + name string + realPath string + paths []string + }{ + { + name: "CurDirOK", + realPath: ".", + paths: []string{"", "./", "././"}, + }, + { + name: "PrevDirOK", + realPath: "..", + paths: []string{"../", ".././"}, + }, + { + name: "RootOK", + realPath: "/", + paths: []string{"/", "/././"}, + }, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + for _, path := range tc.paths { + i := cmd().Invoke("file", path) + i.Environ.Set(serpent.CompletionModeEnv, "1") + io := fakeIO(i) + err := i.Run() + require.NoError(t, err) + output := strings.Split(io.Stdout.String(), "\n") + output = output[:len(output)-1] + for _, str := range output { + if strings.HasSuffix(str, "/") { + require.DirExists(t, str) + } else { + require.FileExists(t, str) + } + } + files, err := os.ReadDir(tc.realPath) + require.NoError(t, err) + require.Equal(t, len(files), len(output)) + } + }) + } } diff --git a/example/completetest/main.go b/example/completetest/main.go index 7a7edf1..faaaa49 100644 --- a/example/completetest/main.go +++ b/example/completetest/main.go @@ -8,17 +8,50 @@ import ( "github.com/coder/serpent/completion" ) +// InstallCommand returns a serpent command that helps +// a user configure their shell to use serpent's completion. +func InstallCommand() *serpent.Command { + defaultShell, err := completion.GetUserShell() + if err != nil { + defaultShell = "bash" + } + + var shell string + return &serpent.Command{ + Use: "completion", + Short: "Generate completion scripts for the given shell.", + Handler: func(inv *serpent.Invocation) error { + completion.GetCompletion(inv.Stdout, shell, inv.Command.Parent.Name()) + return nil + }, + Options: serpent.OptionSet{ + { + Flag: "shell", + FlagShorthand: "s", + Default: defaultShell, + Description: "The shell to generate a completion script for.", + Value: completion.ShellOptions(&shell), + CompletionHandler: completion.ShellHandler(), + }, + }, + } +} + func main() { - var upper bool + var ( + print bool + upper bool + fileType string + ) cmd := serpent.Command{ Use: "completetest ", Short: "Prints the given text to the console.", Options: serpent.OptionSet{ { - Name: "upper", + Name: "different", Value: serpent.BoolOf(&upper), - Flag: "upper", - Description: "Prints the text in upper case.", + Flag: "different", + Description: "Do the command differently.", }, }, Handler: func(inv *serpent.Invocation) error { @@ -52,7 +85,32 @@ func main() { }, }, }, - completion.InstallCommand(), + { + Use: "file ", + Handler: func(inv *serpent.Invocation) error { + return nil + }, + Options: serpent.OptionSet{ + { + Name: "print", + Value: serpent.BoolOf(&print), + Flag: "print", + Description: "Print the file.", + }, + { + Name: "type", + Value: serpent.EnumOf(&fileType, "binary", "text"), + Flag: "type", + Description: "The type of file.", + CompletionHandler: completion.EnumHandler("binary", "text"), + }, + }, + CompletionHandler: completion.FileHandler(func(info os.FileInfo) bool { + return true + }), + Middleware: serpent.RequireNArgs(1), + }, + InstallCommand(), }, } diff --git a/option.go b/option.go index 5545d07..199a9a1 100644 --- a/option.go +++ b/option.go @@ -65,6 +65,8 @@ type Option struct { Hidden bool `json:"hidden,omitempty"` ValueSource ValueSource `json:"value_source,omitempty"` + + CompletionHandler CompletionHandlerFunc `json:"-"` } // optionNoMethods is just a wrapper around Option so we can defer to the @@ -344,3 +346,13 @@ func (optSet *OptionSet) ByName(name string) *Option { } return nil } + +func (optSet *OptionSet) ByFlag(flag string) *Option { + for i := range *optSet { + opt := &(*optSet)[i] + if opt.Flag == flag { + return opt + } + } + return nil +} From 707afa6a274e791b4e486a802bfc6b4397ff113a Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 4 Jul 2024 04:04:15 +0000 Subject: [PATCH 05/15] fixup + mid-line bash complete --- command.go | 21 --------------------- completion/bash.go | 4 ++-- option.go | 15 +++++++-------- 3 files changed, 9 insertions(+), 31 deletions(-) diff --git a/command.go b/command.go index f42a632..e77b928 100644 --- a/command.go +++ b/command.go @@ -563,27 +563,6 @@ func findArg(want string, args []string, fs *pflag.FlagSet) (int, error) { return -1, xerrors.Errorf("arg %s not found", want) } -// // findArgByPos returns the index of first full word before the given cursor position in the arguments -// // list. If the cursor is at the end of the line, the last word is returned. -// func findArgByPos(pos int, args []string) int { -// if pos == 0 { -// return -1 -// } -// if len(args) == 0 { -// return -1 -// } -// curChar := 0 -// for i, arg := range args { -// next := curChar + len(arg) -// if pos <= next { -// return i -// } -// curChar = next + 1 -// } -// // Otherwise, must be the last word -// return len(args) -// } - // Run executes the command. // If two command share a flag name, the first command wins. // diff --git a/completion/bash.go b/completion/bash.go index c805927..3c9aeed 100644 --- a/completion/bash.go +++ b/completion/bash.go @@ -8,8 +8,8 @@ import ( const bashCompletionTemplate = ` _generate_{{.Name}}_completions() { - # Capture the full command line as an array, excluding the first element (the command itself) - local args=("${COMP_WORDS[@]:1}") + # Capture the line excluding the command, and everything after the current word + local args=("${COMP_WORDS[@]:1:COMP_CWORD}") # Set COMPLETION_MODE and call the command with the arguments, capturing the output local completions=$(COMPLETION_MODE=1 "{{.Name}}" "${args[@]}") diff --git a/option.go b/option.go index 199a9a1..36f9174 100644 --- a/option.go +++ b/option.go @@ -337,19 +337,18 @@ func (optSet *OptionSet) SetDefaults() error { // ByName returns the Option with the given name, or nil if no such option // exists. -func (optSet *OptionSet) ByName(name string) *Option { - for i := range *optSet { - opt := &(*optSet)[i] - if opt.Name == name { - return opt +func (optSet OptionSet) ByName(name string) *Option { + for i := range optSet { + if optSet[i].Name == name { + return &optSet[i] } } return nil } -func (optSet *OptionSet) ByFlag(flag string) *Option { - for i := range *optSet { - opt := &(*optSet)[i] +func (optSet OptionSet) ByFlag(flag string) *Option { + for i := range optSet { + opt := &optSet[i] if opt.Flag == flag { return opt } From 91ccb9aedd9537e6d91167f49256ea826d2e1d21 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 10 Jul 2024 07:14:58 +0000 Subject: [PATCH 06/15] zsh support + file list completion handler --- command_test.go | 22 ++++++++++ completion.go | 2 +- completion/all.go | 6 ++- completion/handlers.go | 81 +++++++++++++++++++++++------------- completion/zsh.go | 40 ++++++++++++++++++ completion_test.go | 33 +++++++++++++-- example/completetest/main.go | 16 +++++-- 7 files changed, 160 insertions(+), 40 deletions(-) create mode 100644 completion/zsh.go diff --git a/command_test.go b/command_test.go index 6d78859..fa0225c 100644 --- a/command_test.go +++ b/command_test.go @@ -39,6 +39,8 @@ func SampleCommand(t *testing.T) *serpent.Command { prefix string reqBool bool reqStr string + reqArr []string + fileArr []string enumStr string ) enumChoices := []string{"foo", "bar", "qux"} @@ -85,6 +87,11 @@ func SampleCommand(t *testing.T) *serpent.Command { Value: serpent.EnumOf(&enumStr, enumChoices...), CompletionHandler: completion.EnumHandler(enumChoices...), }, + serpent.Option{ + Name: "req-array", + Flag: "req-array", + Value: serpent.StringArrayOf(&reqArr), + }, }, HelpHandler: func(i *serpent.Invocation) error { _, _ = i.Stdout.Write([]byte("help text.png")) @@ -138,6 +145,21 @@ func SampleCommand(t *testing.T) *serpent.Command { }), Middleware: serpent.RequireNArgs(1), }, + { + Use: "altfile", + Handler: func(inv *serpent.Invocation) error { + return nil + }, + Options: serpent.OptionSet{ + { + Name: "extra", + Flag: "extra", + Description: "Extra files.", + Value: serpent.StringArrayOf(&fileArr), + CompletionHandler: completion.FileListHandler(nil), + }, + }, + }, }, } } diff --git a/completion.go b/completion.go index 69ec902..3158f3c 100644 --- a/completion.go +++ b/completion.go @@ -18,7 +18,7 @@ func DefaultCompletionHandler(inv *Invocation) []string { allResps = append(allResps, cmd.Name()) } for _, opt := range inv.Command.Options { - if opt.ValueSource == ValueSourceNone { + if opt.ValueSource == ValueSourceNone || opt.Value.Type() == "string-array" { allResps = append(allResps, "--"+opt.Flag) } } diff --git a/completion/all.go b/completion/all.go index a6614c4..0027c9e 100644 --- a/completion/all.go +++ b/completion/all.go @@ -14,19 +14,21 @@ import ( const ( BashShell string = "bash" FishShell string = "fish" + ZShell string = "zsh" ) var shellCompletionByName = map[string]func(io.Writer, string) error{ BashShell: GenerateBashCompletion, FishShell: GenerateFishCompletion, + ZShell: GenerateZshCompletion, } func ShellOptions(choice *string) *serpent.Enum { - return serpent.EnumOf(choice, BashShell, FishShell) + return serpent.EnumOf(choice, BashShell, FishShell, ZShell) } func ShellHandler() serpent.CompletionHandlerFunc { - return EnumHandler(BashShell, FishShell) + return EnumHandler(BashShell, FishShell, ZShell) } func GetCompletion(writer io.Writer, shell string, cmdName string) error { diff --git a/completion/handlers.go b/completion/handlers.go index 0f5be86..5d801f1 100644 --- a/completion/handlers.go +++ b/completion/handlers.go @@ -19,42 +19,63 @@ func EnumHandler(choices ...string) serpent.CompletionHandlerFunc { // given filter func, which may be nil. func FileHandler(filter func(info os.FileInfo) bool) serpent.CompletionHandlerFunc { return func(inv *serpent.Invocation) []string { - out := make([]string, 0, 32) - curWord := inv.CurWord - dir, _ := filepath.Split(curWord) - if dir == "" { - dir = "." + return ListFiles(inv.CurWord, filter) + } +} + +func FileListHandler(filter func(info os.FileInfo) bool) serpent.CompletionHandlerFunc { + return func(inv *serpent.Invocation) []string { + curWord := strings.TrimLeft(inv.CurWord, `"`) + if curWord == "" { + return ListFiles("", filter) } - f, err := os.Open(dir) - if err != nil { - return out + parts := strings.Split(curWord, ",") + out := ListFiles(parts[len(parts)-1], filter) + // prepend := strings.Join(parts[:len(parts)-1], ",") + for i, s := range out { + parts[len(parts)-1] = s + out[i] = strings.Join(parts, ",") } - defer f.Close() - if dir == "." { - dir = "" + return out + } +} + +func ListFiles(word string, filter func(info os.FileInfo) bool) []string { + out := make([]string, 0, 32) + + dir, _ := filepath.Split(word) + if dir == "" { + dir = "." + } + f, err := os.Open(dir) + if err != nil { + return out + } + defer f.Close() + if dir == "." { + dir = "" + } + + infos, err := f.Readdir(0) + if err != nil { + return out + } + + for _, info := range infos { + if filter != nil && !filter(info) { + continue } - infos, err := f.Readdir(0) - if err != nil { - return out + var cur string + if info.IsDir() { + cur = fmt.Sprintf("%s%s/", dir, info.Name()) + } else { + cur = fmt.Sprintf("%s%s", dir, info.Name()) } - for _, info := range infos { - if filter != nil && !filter(info) { - continue - } - - var cur string - if info.IsDir() { - cur = fmt.Sprintf("%s%s/", dir, info.Name()) - } else { - cur = fmt.Sprintf("%s%s", dir, info.Name()) - } - - if strings.HasPrefix(cur, curWord) { - out = append(out, cur) - } + if strings.HasPrefix(cur, word) { + out = append(out, cur) } - return out } + return out } diff --git a/completion/zsh.go b/completion/zsh.go new file mode 100644 index 0000000..1abaaf9 --- /dev/null +++ b/completion/zsh.go @@ -0,0 +1,40 @@ +package completion + +import ( + "fmt" + "io" + "text/template" +) + +const zshCompletionTemplate = ` +_{{.Name}}_completions() { + local -a args completions + args=("${words[@]:1:$#words}") + completions=($(COMPLETION_MODE=1 "{{.Name}}" "${args[@]}")) + compadd -a completions +} + +compdef _{{.Name}}_completions {{.Name}} +` + +func GenerateZshCompletion( + w io.Writer, + rootCmdName string, +) error { + tmpl, err := template.New("zsh").Parse(zshCompletionTemplate) + if err != nil { + return fmt.Errorf("parse template: %w", err) + } + + err = tmpl.Execute( + w, + map[string]string{ + "Name": rootCmdName, + }, + ) + if err != nil { + return fmt.Errorf("execute template: %w", err) + } + + return nil +} diff --git a/completion_test.go b/completion_test.go index d6c7f57..7325087 100644 --- a/completion_test.go +++ b/completion_test.go @@ -1,6 +1,7 @@ package serpent_test import ( + "fmt" "os" "runtime" "strings" @@ -42,17 +43,17 @@ func TestCompletion(t *testing.T) { io := fakeIO(i) err := i.Run() require.NoError(t, err) - require.Equal(t, "--req-bool\n--req-enum\n--req-string\n", io.Stdout.String()) + require.Equal(t, "--req-array\n--req-bool\n--req-enum\n--req-string\n", io.Stdout.String()) }) t.Run("FlagExhaustive", func(t *testing.T) { t.Parallel() - i := cmd().Invoke("required-flag", "--req-bool", "--req-string", "foo bar") + i := cmd().Invoke("required-flag", "--req-bool", "--req-string", "foo bar", "--req-array", "asdf") i.Environ.Set(serpent.CompletionModeEnv, "1") io := fakeIO(i) err := i.Run() require.NoError(t, err) - require.Equal(t, "--req-enum\n", io.Stdout.String()) + require.Equal(t, "--req-array\n--req-enum\n", io.Stdout.String()) }) t.Run("EnumOK", func(t *testing.T) { @@ -142,5 +143,31 @@ func TestFileCompletion(t *testing.T) { require.Equal(t, len(files), len(output)) } }) + t.Run(tc.name+"/List", func(t *testing.T) { + t.Parallel() + for _, path := range tc.paths { + i := cmd().Invoke("altfile", "--extra", fmt.Sprintf(`"example.go,%s`, path)) + i.Environ.Set(serpent.CompletionModeEnv, "1") + io := fakeIO(i) + err := i.Run() + require.NoError(t, err) + output := strings.Split(io.Stdout.String(), "\n") + output = output[:len(output)-1] + for _, str := range output { + parts := strings.Split(str, ",") + require.Len(t, parts, 2) + require.Equal(t, parts[0], "example.go") + fileComp := parts[1] + if strings.HasSuffix(fileComp, "/") { + require.DirExists(t, fileComp) + } else { + require.FileExists(t, fileComp) + } + } + files, err := os.ReadDir(tc.realPath) + require.NoError(t, err) + require.Equal(t, len(files), len(output)) + } + }) } } diff --git a/example/completetest/main.go b/example/completetest/main.go index faaaa49..08445b3 100644 --- a/example/completetest/main.go +++ b/example/completetest/main.go @@ -42,6 +42,7 @@ func main() { print bool upper bool fileType string + fileArr []string ) cmd := serpent.Command{ Use: "completetest ", @@ -104,11 +105,18 @@ func main() { Description: "The type of file.", CompletionHandler: completion.EnumHandler("binary", "text"), }, + { + Name: "extra", + Flag: "extra", + Description: "Extra files.", + Value: serpent.StringArrayOf(&fileArr), + CompletionHandler: completion.FileListHandler(func(info os.FileInfo) bool { + return !info.IsDir() + }), + }, }, - CompletionHandler: completion.FileHandler(func(info os.FileInfo) bool { - return true - }), - Middleware: serpent.RequireNArgs(1), + CompletionHandler: completion.FileHandler(nil), + Middleware: serpent.RequireNArgs(1), }, InstallCommand(), }, From 6db6e6cc418e47636deb0948a3066568e63258db Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 10 Jul 2024 07:16:01 +0000 Subject: [PATCH 07/15] fixup --- completion_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/completion_test.go b/completion_test.go index 7325087..1c7281b 100644 --- a/completion_test.go +++ b/completion_test.go @@ -23,7 +23,7 @@ func TestCompletion(t *testing.T) { io := fakeIO(i) err := i.Run() require.NoError(t, err) - require.Equal(t, "file\nrequired-flag\ntoupper\n--prefix\n--verbose\n", io.Stdout.String()) + require.Equal(t, "altfile\nfile\nrequired-flag\ntoupper\n--prefix\n--verbose\n", io.Stdout.String()) }) t.Run("SubcommandComplete", func(t *testing.T) { From 3a591bf62699785370138312bbc478a2702f617f Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 10 Jul 2024 12:27:27 +0000 Subject: [PATCH 08/15] equals flags --- command.go | 53 +++++++++++++++++++++++++++++++++++++--------- completion_test.go | 10 +++++++++ 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/command.go b/command.go index e77b928..cc81425 100644 --- a/command.go +++ b/command.go @@ -464,19 +464,22 @@ func (inv *Invocation) run(state *runState) error { defer cancel() inv = inv.WithContext(ctx) + // Outputted completions are not filtered based on the word under the cursor, as every shell we support does this already. + // We only look at the current word to figure out handler to run, or what directory to inspect. if inv.IsCompletionMode() { prev, cur := inv.GetCurWords() inv.CurWord = cur - if prev != "" { - // If the previous word is a flag, we use it's handler - if strings.HasPrefix(prev, "--") { - opt := inv.Command.Options.ByFlag(prev[2:]) - if opt != nil && opt.CompletionHandler != nil { - for _, e := range opt.CompletionHandler(inv) { - fmt.Fprintf(inv.Stdout, "%s\n", e) - } - return nil - } + // If the current word is a flag set using `=`, use it's handler + if strings.HasPrefix(cur, "--") && strings.Contains(cur, "=") { + if inv.equalsFlagHandler(cur) { + return nil + } + } + // If the previous word is a flag, then we're writing it's value + // and we should check it's handler + if strings.HasPrefix(prev, "--") { + if inv.flagHandler(prev) { + return nil } } if inv.Command.Name() == inv.CurWord { @@ -616,6 +619,36 @@ func (inv *Invocation) with(fn func(*Invocation)) *Invocation { return &i2 } +func (inv *Invocation) flagHandler(word string) bool { + opt := inv.Command.Options.ByFlag(word[2:]) + if opt != nil && opt.CompletionHandler != nil { + for _, e := range opt.CompletionHandler(inv) { + fmt.Fprintf(inv.Stdout, "%s\n", e) + } + return true + } + return false +} + +func (inv *Invocation) equalsFlagHandler(word string) bool { + words := strings.Split(word, "=") + word = words[0] + if len(words) > 1 { + inv.CurWord = words[1] + } else { + inv.CurWord = "" + } + outPrefix := word + "=" + opt := inv.Command.Options.ByFlag(word[2:]) + if opt != nil && opt.CompletionHandler != nil { + for _, e := range opt.CompletionHandler(inv) { + fmt.Fprintf(inv.Stdout, "%s%s\n", outPrefix, e) + } + return true + } + return false +} + // MiddlewareFunc returns the next handler in the chain, // or nil if there are no more. type MiddlewareFunc func(next HandlerFunc) HandlerFunc diff --git a/completion_test.go b/completion_test.go index 1c7281b..3382772 100644 --- a/completion_test.go +++ b/completion_test.go @@ -65,6 +65,16 @@ func TestCompletion(t *testing.T) { require.NoError(t, err) require.Equal(t, "foo\nbar\nqux\n", io.Stdout.String()) }) + + t.Run("EnumEqualsOK", func(t *testing.T) { + t.Parallel() + i := cmd().Invoke("required-flag", "--req-enum", "--req-enum=") + i.Environ.Set(serpent.CompletionModeEnv, "1") + io := fakeIO(i) + err := i.Run() + require.NoError(t, err) + require.Equal(t, "--req-enum=foo\n--req-enum=bar\n--req-enum=qux\n", io.Stdout.String()) + }) } func TestFileCompletion(t *testing.T) { From 7b640ff9321fd80c103582ad129ca9cb2a40a5d7 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 11 Jul 2024 07:47:09 +0000 Subject: [PATCH 09/15] powershell --- completion/all.go | 18 +++++----- completion/handlers.go | 2 +- completion/powershell.go | 71 ++++++++++++++++++++++++++++++++++++++++ completion_test.go | 13 +++----- 4 files changed, 86 insertions(+), 18 deletions(-) create mode 100644 completion/powershell.go diff --git a/completion/all.go b/completion/all.go index 0027c9e..89bf52f 100644 --- a/completion/all.go +++ b/completion/all.go @@ -12,23 +12,25 @@ import ( ) const ( - BashShell string = "bash" - FishShell string = "fish" - ZShell string = "zsh" + BashShell string = "bash" + FishShell string = "fish" + ZShell string = "zsh" + Powershell string = "powershell" ) var shellCompletionByName = map[string]func(io.Writer, string) error{ - BashShell: GenerateBashCompletion, - FishShell: GenerateFishCompletion, - ZShell: GenerateZshCompletion, + BashShell: GenerateBashCompletion, + FishShell: GenerateFishCompletion, + ZShell: GenerateZshCompletion, + Powershell: GeneratePowershellCompletion, } func ShellOptions(choice *string) *serpent.Enum { - return serpent.EnumOf(choice, BashShell, FishShell, ZShell) + return serpent.EnumOf(choice, BashShell, FishShell, ZShell, Powershell) } func ShellHandler() serpent.CompletionHandlerFunc { - return EnumHandler(BashShell, FishShell, ZShell) + return EnumHandler(BashShell, FishShell, ZShell, Powershell) } func GetCompletion(writer io.Writer, shell string, cmdName string) error { diff --git a/completion/handlers.go b/completion/handlers.go index 5d801f1..a4e6b1a 100644 --- a/completion/handlers.go +++ b/completion/handlers.go @@ -68,7 +68,7 @@ func ListFiles(word string, filter func(info os.FileInfo) bool) []string { var cur string if info.IsDir() { - cur = fmt.Sprintf("%s%s/", dir, info.Name()) + cur = fmt.Sprintf("%s%s%c", dir, info.Name(), os.PathSeparator) } else { cur = fmt.Sprintf("%s%s", dir, info.Name()) } diff --git a/completion/powershell.go b/completion/powershell.go new file mode 100644 index 0000000..c1f29ec --- /dev/null +++ b/completion/powershell.go @@ -0,0 +1,71 @@ +package completion + +import ( + "fmt" + "io" + "text/template" +) + +const pshCompletionTemplate = ` + +# Escaping output sourced from: +# https://github.com/spf13/cobra/blob/e94f6d0dd9a5e5738dca6bce03c4b1207ffbc0ec/powershell_completions.go#L47 +filter _{{.Name}}_escapeStringWithSpecialChars { +` + " $_ -replace '\\s|#|@|\\$|;|,|''|\\{|\\}|\\(|\\)|\"|`|\\||<|>|&','`$&'" + ` +} + +$_{{.Name}}_completions = { + param( + $wordToComplete, + $commandAst, + $cursorPosition + ) + # Legacy space handling sourced from: + # https://github.com/spf13/cobra/blob/e94f6d0dd9a5e5738dca6bce03c4b1207ffbc0ec/powershell_completions.go#L107 + if ($PSVersionTable.PsVersion -lt [version]'7.2.0' -or + ($PSVersionTable.PsVersion -lt [version]'7.3.0' -and -not [ExperimentalFeature]::IsEnabled("PSNativeCommandArgumentPassing")) -or + (($PSVersionTable.PsVersion -ge [version]'7.3.0' -or [ExperimentalFeature]::IsEnabled("PSNativeCommandArgumentPassing")) -and + $PSNativeCommandArgumentPassing -eq 'Legacy')) { + $Space =` + "' `\"`\"'" + ` + } else { + $Space = ' ""' + } + $Command = $commandAst.ToString().Substring(0, $cursorPosition - 1) + if ($wordToComplete -ne "" ) { + $wordToComplete = $Command.Split(" ")[-1] + } else { + $Command = $Command + $Space + } + # Get completions by calling the command with the COMPLETION_MODE environment variable set to 1 + "$Command" | Out-File -Append -FilePath "out.log" + $env:COMPLETION_MODE = 1 + Invoke-Expression $Command | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + "$_" | _{{.Name}}_escapeStringWithSpecialChars + } + rm env:COMPLETION_MODE +} + +Register-ArgumentCompleter -CommandName {{.Name}} -ScriptBlock $_{{.Name}}_completions +` + +func GeneratePowershellCompletion( + w io.Writer, + rootCmdName string, +) error { + tmpl, err := template.New("powershell").Parse(pshCompletionTemplate) + if err != nil { + return fmt.Errorf("parse template: %w", err) + } + + err = tmpl.Execute( + w, + map[string]string{ + "Name": rootCmdName, + }, + ) + if err != nil { + return fmt.Errorf("execute template: %w", err) + } + + return nil +} diff --git a/completion_test.go b/completion_test.go index 3382772..4d24f59 100644 --- a/completion_test.go +++ b/completion_test.go @@ -3,7 +3,6 @@ package serpent_test import ( "fmt" "os" - "runtime" "strings" "testing" @@ -80,10 +79,6 @@ func TestCompletion(t *testing.T) { func TestFileCompletion(t *testing.T) { t.Parallel() - if runtime.GOOS == "windows" { - t.Skip("Skipping test on Windows") - } - cmd := func() *serpent.Command { return SampleCommand(t) } t.Run("DirOK", func(t *testing.T) { @@ -94,12 +89,12 @@ func TestFileCompletion(t *testing.T) { io := fakeIO(i) err := i.Run() require.NoError(t, err) - require.Equal(t, tempDir+"/\n", io.Stdout.String()) + require.Equal(t, fmt.Sprintf("%s%c\n", tempDir, os.PathSeparator), io.Stdout.String()) }) t.Run("EmptyDirOK", func(t *testing.T) { t.Parallel() - tempDir := t.TempDir() + "/" + tempDir := t.TempDir() + string(os.PathSeparator) i := cmd().Invoke("file", tempDir) i.Environ.Set(serpent.CompletionModeEnv, "1") io := fakeIO(i) @@ -142,7 +137,7 @@ func TestFileCompletion(t *testing.T) { output := strings.Split(io.Stdout.String(), "\n") output = output[:len(output)-1] for _, str := range output { - if strings.HasSuffix(str, "/") { + if strings.HasSuffix(str, string(os.PathSeparator)) { require.DirExists(t, str) } else { require.FileExists(t, str) @@ -168,7 +163,7 @@ func TestFileCompletion(t *testing.T) { require.Len(t, parts, 2) require.Equal(t, parts[0], "example.go") fileComp := parts[1] - if strings.HasSuffix(fileComp, "/") { + if strings.HasSuffix(fileComp, string(os.PathSeparator)) { require.DirExists(t, fileComp) } else { require.FileExists(t, fileComp) From 5ae2c90388e991076c7cb24a61e52445d5ed88ea Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 11 Jul 2024 08:03:12 +0000 Subject: [PATCH 10/15] refactor --- completion.go | 2 +- completion/all.go | 32 ++++++++++++++++++++++++++++---- completion/bash.go | 28 ---------------------------- completion/fish.go | 28 ---------------------------- completion/powershell.go | 29 ----------------------------- completion/zsh.go | 28 ---------------------------- 6 files changed, 29 insertions(+), 118 deletions(-) diff --git a/completion.go b/completion.go index 3158f3c..a0fb779 100644 --- a/completion.go +++ b/completion.go @@ -18,7 +18,7 @@ func DefaultCompletionHandler(inv *Invocation) []string { allResps = append(allResps, cmd.Name()) } for _, opt := range inv.Command.Options { - if opt.ValueSource == ValueSourceNone || opt.Value.Type() == "string-array" { + if opt.ValueSource == ValueSourceNone || opt.ValueSource == ValueSourceDefault || opt.Value.Type() == "string-array" { allResps = append(allResps, "--"+opt.Flag) } } diff --git a/completion/all.go b/completion/all.go index 89bf52f..9abb83e 100644 --- a/completion/all.go +++ b/completion/all.go @@ -7,6 +7,7 @@ import ( "os/user" "path/filepath" "strings" + "text/template" "github.com/coder/serpent" ) @@ -19,10 +20,10 @@ const ( ) var shellCompletionByName = map[string]func(io.Writer, string) error{ - BashShell: GenerateBashCompletion, - FishShell: GenerateFishCompletion, - ZShell: GenerateZshCompletion, - Powershell: GeneratePowershellCompletion, + BashShell: GenerateCompletion(bashCompletionTemplate), + FishShell: GenerateCompletion(fishCompletionTemplate), + ZShell: GenerateCompletion(zshCompletionTemplate), + Powershell: GenerateCompletion(pshCompletionTemplate), } func ShellOptions(choice *string) *serpent.Enum { @@ -72,3 +73,26 @@ func GetUserShell() (string, error) { return "", fmt.Errorf("default shell not found") } + +func GenerateCompletion( + scriptTemplate string, +) func(io.Writer, string) error { + return func(w io.Writer, rootCmdName string) error { + tmpl, err := template.New("script").Parse(scriptTemplate) + if err != nil { + return fmt.Errorf("parse template: %w", err) + } + + err = tmpl.Execute( + w, + map[string]string{ + "Name": rootCmdName, + }, + ) + if err != nil { + return fmt.Errorf("execute template: %w", err) + } + + return nil + } +} diff --git a/completion/bash.go b/completion/bash.go index 3c9aeed..fad4069 100644 --- a/completion/bash.go +++ b/completion/bash.go @@ -1,11 +1,5 @@ package completion -import ( - "fmt" - "io" - "text/template" -) - const bashCompletionTemplate = ` _generate_{{.Name}}_completions() { # Capture the line excluding the command, and everything after the current word @@ -26,25 +20,3 @@ _generate_{{.Name}}_completions() { # Setup Bash to use the function for completions for '{{.Name}}' complete -F _generate_{{.Name}}_completions {{.Name}} ` - -func GenerateBashCompletion( - w io.Writer, - rootCmdName string, -) error { - tmpl, err := template.New("bash").Parse(bashCompletionTemplate) - if err != nil { - return fmt.Errorf("parse template: %w", err) - } - - err = tmpl.Execute( - w, - map[string]string{ - "Name": rootCmdName, - }, - ) - if err != nil { - return fmt.Errorf("execute template: %w", err) - } - - return nil -} diff --git a/completion/fish.go b/completion/fish.go index c498bff..f9b2793 100644 --- a/completion/fish.go +++ b/completion/fish.go @@ -1,11 +1,5 @@ package completion -import ( - "fmt" - "io" - "text/template" -) - const fishCompletionTemplate = ` function _{{.Name}}_completions # Capture the full command line as an array @@ -17,25 +11,3 @@ end # Setup Fish to use the function for completions for '{{.Name}}' complete -c {{.Name}} -f -a '(_{{.Name}}_completions)' ` - -func GenerateFishCompletion( - w io.Writer, - rootCmdName string, -) error { - tmpl, err := template.New("fish").Parse(fishCompletionTemplate) - if err != nil { - return fmt.Errorf("parse template: %w", err) - } - - err = tmpl.Execute( - w, - map[string]string{ - "Name": rootCmdName, - }, - ) - if err != nil { - return fmt.Errorf("execute template: %w", err) - } - - return nil -} diff --git a/completion/powershell.go b/completion/powershell.go index c1f29ec..e30c61b 100644 --- a/completion/powershell.go +++ b/completion/powershell.go @@ -1,11 +1,5 @@ package completion -import ( - "fmt" - "io" - "text/template" -) - const pshCompletionTemplate = ` # Escaping output sourced from: @@ -37,7 +31,6 @@ $_{{.Name}}_completions = { $Command = $Command + $Space } # Get completions by calling the command with the COMPLETION_MODE environment variable set to 1 - "$Command" | Out-File -Append -FilePath "out.log" $env:COMPLETION_MODE = 1 Invoke-Expression $Command | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { "$_" | _{{.Name}}_escapeStringWithSpecialChars @@ -47,25 +40,3 @@ $_{{.Name}}_completions = { Register-ArgumentCompleter -CommandName {{.Name}} -ScriptBlock $_{{.Name}}_completions ` - -func GeneratePowershellCompletion( - w io.Writer, - rootCmdName string, -) error { - tmpl, err := template.New("powershell").Parse(pshCompletionTemplate) - if err != nil { - return fmt.Errorf("parse template: %w", err) - } - - err = tmpl.Execute( - w, - map[string]string{ - "Name": rootCmdName, - }, - ) - if err != nil { - return fmt.Errorf("execute template: %w", err) - } - - return nil -} diff --git a/completion/zsh.go b/completion/zsh.go index 1abaaf9..a8ee4a8 100644 --- a/completion/zsh.go +++ b/completion/zsh.go @@ -1,11 +1,5 @@ package completion -import ( - "fmt" - "io" - "text/template" -) - const zshCompletionTemplate = ` _{{.Name}}_completions() { local -a args completions @@ -16,25 +10,3 @@ _{{.Name}}_completions() { compdef _{{.Name}}_completions {{.Name}} ` - -func GenerateZshCompletion( - w io.Writer, - rootCmdName string, -) error { - tmpl, err := template.New("zsh").Parse(zshCompletionTemplate) - if err != nil { - return fmt.Errorf("parse template: %w", err) - } - - err = tmpl.Execute( - w, - map[string]string{ - "Name": rootCmdName, - }, - ) - if err != nil { - return fmt.Errorf("execute template: %w", err) - } - - return nil -} From cff5dbbaa695aea493b1e832770ee8f54100e8a7 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 12 Jul 2024 10:30:02 +0000 Subject: [PATCH 11/15] shorthand flags + cleanup --- command.go | 32 ++++++++++++++++++++------------ command_test.go | 28 +++++++++++++++------------- completion/all.go | 4 ---- completion/handlers.go | 6 ------ completion_test.go | 23 ++++++++++++++++++++++- example/completetest/main.go | 20 +++++++++----------- option.go | 2 +- 7 files changed, 67 insertions(+), 48 deletions(-) diff --git a/command.go b/command.go index cc81425..34bbdc2 100644 --- a/command.go +++ b/command.go @@ -620,14 +620,7 @@ func (inv *Invocation) with(fn func(*Invocation)) *Invocation { } func (inv *Invocation) flagHandler(word string) bool { - opt := inv.Command.Options.ByFlag(word[2:]) - if opt != nil && opt.CompletionHandler != nil { - for _, e := range opt.CompletionHandler(inv) { - fmt.Fprintf(inv.Stdout, "%s\n", e) - } - return true - } - return false + return inv.doFlagCompletion("", word) } func (inv *Invocation) equalsFlagHandler(word string) bool { @@ -638,11 +631,26 @@ func (inv *Invocation) equalsFlagHandler(word string) bool { } else { inv.CurWord = "" } - outPrefix := word + "=" + prefix := word + "=" + return inv.doFlagCompletion(prefix, word) +} + +func (inv *Invocation) doFlagCompletion(prefix, word string) bool { opt := inv.Command.Options.ByFlag(word[2:]) - if opt != nil && opt.CompletionHandler != nil { - for _, e := range opt.CompletionHandler(inv) { - fmt.Fprintf(inv.Stdout, "%s%s\n", outPrefix, e) + if opt == nil { + return false + } + if opt.CompletionHandler != nil { + completions := opt.CompletionHandler(inv) + for _, completion := range completions { + fmt.Fprintf(inv.Stdout, "%s%s\n", prefix, completion) + } + return true + } + val, ok := opt.Value.(*Enum) + if ok { + for _, choice := range val.Choices { + fmt.Fprintf(inv.Stdout, "%s%s\n", prefix, choice) } return true } diff --git a/command_test.go b/command_test.go index fa0225c..f509cfb 100644 --- a/command_test.go +++ b/command_test.go @@ -64,14 +64,16 @@ func SampleCommand(t *testing.T) *serpent.Command { Short: "Example with required flags", Options: serpent.OptionSet{ serpent.Option{ - Name: "req-bool", - Flag: "req-bool", - Value: serpent.BoolOf(&reqBool), - Required: true, + Name: "req-bool", + Flag: "req-bool", + FlagShorthand: "b", + Value: serpent.BoolOf(&reqBool), + Required: true, }, serpent.Option{ - Name: "req-string", - Flag: "req-string", + Name: "req-string", + Flag: "req-string", + FlagShorthand: "s", Value: serpent.Validate(serpent.StringOf(&reqStr), func(value *serpent.String) error { ok := strings.Contains(value.String(), " ") if !ok { @@ -82,15 +84,15 @@ func SampleCommand(t *testing.T) *serpent.Command { Required: true, }, serpent.Option{ - Name: "req-enum", - Flag: "req-enum", - Value: serpent.EnumOf(&enumStr, enumChoices...), - CompletionHandler: completion.EnumHandler(enumChoices...), + Name: "req-enum", + Flag: "req-enum", + Value: serpent.EnumOf(&enumStr, enumChoices...), }, serpent.Option{ - Name: "req-array", - Flag: "req-array", - Value: serpent.StringArrayOf(&reqArr), + Name: "req-array", + Flag: "req-array", + FlagShorthand: "a", + Value: serpent.StringArrayOf(&reqArr), }, }, HelpHandler: func(i *serpent.Invocation) error { diff --git a/completion/all.go b/completion/all.go index 9abb83e..a0f9315 100644 --- a/completion/all.go +++ b/completion/all.go @@ -30,10 +30,6 @@ func ShellOptions(choice *string) *serpent.Enum { return serpent.EnumOf(choice, BashShell, FishShell, ZShell, Powershell) } -func ShellHandler() serpent.CompletionHandlerFunc { - return EnumHandler(BashShell, FishShell, ZShell, Powershell) -} - func GetCompletion(writer io.Writer, shell string, cmdName string) error { fn, ok := shellCompletionByName[shell] if !ok { diff --git a/completion/handlers.go b/completion/handlers.go index a4e6b1a..7a6bddf 100644 --- a/completion/handlers.go +++ b/completion/handlers.go @@ -9,12 +9,6 @@ import ( "github.com/coder/serpent" ) -func EnumHandler(choices ...string) serpent.CompletionHandlerFunc { - return func(inv *serpent.Invocation) []string { - return choices - } -} - // FileHandler returns a handler that completes files, using the // given filter func, which may be nil. func FileHandler(filter func(info os.FileInfo) bool) serpent.CompletionHandlerFunc { diff --git a/completion_test.go b/completion_test.go index 4d24f59..0f03cb8 100644 --- a/completion_test.go +++ b/completion_test.go @@ -25,6 +25,16 @@ func TestCompletion(t *testing.T) { require.Equal(t, "altfile\nfile\nrequired-flag\ntoupper\n--prefix\n--verbose\n", io.Stdout.String()) }) + t.Run("SubcommandNoPartial", func(t *testing.T) { + t.Parallel() + i := cmd().Invoke("f") + i.Environ.Set(serpent.CompletionModeEnv, "1") + io := fakeIO(i) + err := i.Run() + require.NoError(t, err) + require.Equal(t, "altfile\nfile\nrequired-flag\ntoupper\n--prefix\n--verbose\n", io.Stdout.String()) + }) + t.Run("SubcommandComplete", func(t *testing.T) { t.Parallel() i := cmd().Invoke("required-flag") @@ -47,7 +57,17 @@ func TestCompletion(t *testing.T) { t.Run("FlagExhaustive", func(t *testing.T) { t.Parallel() - i := cmd().Invoke("required-flag", "--req-bool", "--req-string", "foo bar", "--req-array", "asdf") + i := cmd().Invoke("required-flag", "--req-bool", "--req-string", "foo bar", "--req-array", "asdf", "--req-array", "qwerty") + i.Environ.Set(serpent.CompletionModeEnv, "1") + io := fakeIO(i) + err := i.Run() + require.NoError(t, err) + require.Equal(t, "--req-array\n--req-enum\n", io.Stdout.String()) + }) + + t.Run("FlagShorthand", func(t *testing.T) { + t.Parallel() + i := cmd().Invoke("required-flag", "-b", "-s", "foo bar", "-a", "asdf") i.Environ.Set(serpent.CompletionModeEnv, "1") io := fakeIO(i) err := i.Run() @@ -74,6 +94,7 @@ func TestCompletion(t *testing.T) { require.NoError(t, err) require.Equal(t, "--req-enum=foo\n--req-enum=bar\n--req-enum=qux\n", io.Stdout.String()) }) + } func TestFileCompletion(t *testing.T) { diff --git a/example/completetest/main.go b/example/completetest/main.go index 08445b3..f78ed28 100644 --- a/example/completetest/main.go +++ b/example/completetest/main.go @@ -26,12 +26,11 @@ func InstallCommand() *serpent.Command { }, Options: serpent.OptionSet{ { - Flag: "shell", - FlagShorthand: "s", - Default: defaultShell, - Description: "The shell to generate a completion script for.", - Value: completion.ShellOptions(&shell), - CompletionHandler: completion.ShellHandler(), + Flag: "shell", + FlagShorthand: "s", + Default: defaultShell, + Description: "The shell to generate a completion script for.", + Value: completion.ShellOptions(&shell), }, }, } @@ -99,11 +98,10 @@ func main() { Description: "Print the file.", }, { - Name: "type", - Value: serpent.EnumOf(&fileType, "binary", "text"), - Flag: "type", - Description: "The type of file.", - CompletionHandler: completion.EnumHandler("binary", "text"), + Name: "type", + Value: serpent.EnumOf(&fileType, "binary", "text"), + Flag: "type", + Description: "The type of file.", }, { Name: "extra", diff --git a/option.go b/option.go index 36f9174..8af2df9 100644 --- a/option.go +++ b/option.go @@ -349,7 +349,7 @@ func (optSet OptionSet) ByName(name string) *Option { func (optSet OptionSet) ByFlag(flag string) *Option { for i := range optSet { opt := &optSet[i] - if opt.Flag == flag { + if opt.Flag == flag || opt.FlagShorthand == flag { return opt } } From aa6a8d378e29c84f3a323b84200cdd0d8ff71ddc Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 15 Jul 2024 08:44:25 +0000 Subject: [PATCH 12/15] review feedback p1 --- command.go | 86 ++++++++++++++++-------------------- command_test.go | 4 +- completion/all.go | 14 +++--- completion/handlers.go | 13 +++--- completion_test.go | 4 +- example/completetest/main.go | 10 ++--- 6 files changed, 62 insertions(+), 69 deletions(-) diff --git a/command.go b/command.go index 34bbdc2..d413faf 100644 --- a/command.go +++ b/command.go @@ -59,7 +59,7 @@ type Command struct { Middleware MiddlewareFunc Handler HandlerFunc HelpHandler HandlerFunc - // CompletionHandler is called when the command is run is completion + // CompletionHandler is called when the command is run in completion // mode. If nil, only the default completion handler is used. // // Flag and option parsing is best-effort in this mode, so even if an Option @@ -187,7 +187,6 @@ func (c *Command) Invoke(args ...string) *Invocation { return &Invocation{ Command: c, Args: args, - AllArgs: args, Stdout: io.Discard, Stderr: io.Discard, Stdin: strings.NewReader(""), @@ -204,9 +203,6 @@ type Invocation struct { // Args is reduced into the remaining arguments after parsing flags // during Run. Args []string - // AllArgs is the original arguments passed to the command, including flags. - // When invoked `WithOS`, this includes argv[0], otherwise it is the same as Args. - AllArgs []string // CurWord is the word the terminal cursor is currently in CurWord string @@ -233,7 +229,6 @@ func (inv *Invocation) WithOS() *Invocation { i.Stdout = os.Stdout i.Stderr = os.Stderr i.Stdin = os.Stdin - i.AllArgs = os.Args i.Args = os.Args[1:] i.Environ = ParseEnviron(os.Environ(), "") i.Net = osNet{} @@ -302,13 +297,13 @@ func copyFlagSetWithout(fs *pflag.FlagSet, without string) *pflag.FlagSet { return fs2 } -func (inv *Invocation) GetCurWords() (prev string, cur string) { - if len(inv.AllArgs) == 1 { - cur = inv.AllArgs[0] +func (inv *Invocation) curWords() (prev string, cur string) { + if len(inv.Args) == 1 { + cur = inv.Args[0] prev = "" } else { - cur = inv.AllArgs[len(inv.AllArgs)-1] - prev = inv.AllArgs[len(inv.AllArgs)-2] + cur = inv.Args[len(inv.Args)-1] + prev = inv.Args[len(inv.Args)-2] } return } @@ -409,7 +404,39 @@ func (inv *Invocation) run(state *runState) error { } } - ignoreFlagParseErrors := inv.Command.RawArgs || inv.IsCompletionMode() + // Outputted completions are not filtered based on the word under the cursor, as every shell we support does this already. + // We only look at the current word to figure out handler to run, or what directory to inspect. + if inv.IsCompletionMode() { + prev, cur := inv.curWords() + inv.CurWord = cur + // If the current word is a flag set using `=`, use it's handler + if strings.HasPrefix(cur, "--") && strings.Contains(cur, "=") { + if inv.equalsFlagHandler(cur) { + return nil + } + } + // If the previous word is a flag, then we're writing it's value + // and we should check it's handler + if strings.HasPrefix(prev, "--") { + if inv.flagHandler(prev) { + return nil + } + } + // If the current word is the command, auto-complete it so the shell moves the cursor + if inv.Command.Name() == inv.CurWord { + fmt.Fprintf(inv.Stdout, "%s\n", inv.Command.Name()) + return nil + } + if inv.Command.CompletionHandler == nil { + inv.Command.CompletionHandler = DefaultCompletionHandler + } + for _, e := range inv.Command.CompletionHandler(inv) { + fmt.Fprintf(inv.Stdout, "%s\n", e) + } + return nil + } + + ignoreFlagParseErrors := inv.Command.RawArgs // Flag parse errors are irrelevant for raw args commands. if !ignoreFlagParseErrors && state.flagParseErr != nil && !errors.Is(state.flagParseErr, pflag.ErrHelp) { @@ -464,39 +491,6 @@ func (inv *Invocation) run(state *runState) error { defer cancel() inv = inv.WithContext(ctx) - // Outputted completions are not filtered based on the word under the cursor, as every shell we support does this already. - // We only look at the current word to figure out handler to run, or what directory to inspect. - if inv.IsCompletionMode() { - prev, cur := inv.GetCurWords() - inv.CurWord = cur - // If the current word is a flag set using `=`, use it's handler - if strings.HasPrefix(cur, "--") && strings.Contains(cur, "=") { - if inv.equalsFlagHandler(cur) { - return nil - } - } - // If the previous word is a flag, then we're writing it's value - // and we should check it's handler - if strings.HasPrefix(prev, "--") { - if inv.flagHandler(prev) { - return nil - } - } - if inv.Command.Name() == inv.CurWord { - fmt.Fprintf(inv.Stdout, "%s\n", inv.Command.Name()) - return nil - } - if inv.Command.CompletionHandler != nil { - for _, e := range inv.Command.CompletionHandler(inv) { - fmt.Fprintf(inv.Stdout, "%s\n", e) - } - } - for _, e := range DefaultCompletionHandler(inv) { - fmt.Fprintf(inv.Stdout, "%s\n", e) - } - return nil - } - if inv.Command.Handler == nil || errors.Is(state.flagParseErr, pflag.ErrHelp) { if inv.Command.HelpHandler == nil { return defaultHelpFn()(inv) @@ -743,5 +737,3 @@ func RequireRangeArgs(start, end int) MiddlewareFunc { type HandlerFunc func(i *Invocation) error type CompletionHandlerFunc func(i *Invocation) []string - -var NopHandler HandlerFunc = func(i *Invocation) error { return nil } diff --git a/command_test.go b/command_test.go index f509cfb..3c12b82 100644 --- a/command_test.go +++ b/command_test.go @@ -31,7 +31,7 @@ func fakeIO(i *serpent.Invocation) *ioBufs { return &b } -func SampleCommand(t *testing.T) *serpent.Command { +func sampleCommand(t *testing.T) *serpent.Command { t.Helper() var ( verbose bool @@ -169,7 +169,7 @@ func SampleCommand(t *testing.T) *serpent.Command { func TestCommand(t *testing.T) { t.Parallel() - cmd := func() *serpent.Command { return SampleCommand(t) } + cmd := func() *serpent.Command { return sampleCommand(t) } t.Run("SimpleOK", func(t *testing.T) { t.Parallel() diff --git a/completion/all.go b/completion/all.go index a0f9315..b20c254 100644 --- a/completion/all.go +++ b/completion/all.go @@ -20,17 +20,17 @@ const ( ) var shellCompletionByName = map[string]func(io.Writer, string) error{ - BashShell: GenerateCompletion(bashCompletionTemplate), - FishShell: GenerateCompletion(fishCompletionTemplate), - ZShell: GenerateCompletion(zshCompletionTemplate), - Powershell: GenerateCompletion(pshCompletionTemplate), + BashShell: generateCompletion(bashCompletionTemplate), + FishShell: generateCompletion(fishCompletionTemplate), + ZShell: generateCompletion(zshCompletionTemplate), + Powershell: generateCompletion(pshCompletionTemplate), } func ShellOptions(choice *string) *serpent.Enum { return serpent.EnumOf(choice, BashShell, FishShell, ZShell, Powershell) } -func GetCompletion(writer io.Writer, shell string, cmdName string) error { +func WriteCompletion(writer io.Writer, shell string, cmdName string) error { fn, ok := shellCompletionByName[shell] if !ok { return fmt.Errorf("unknown shell %q", shell) @@ -39,7 +39,7 @@ func GetCompletion(writer io.Writer, shell string, cmdName string) error { return nil } -func GetUserShell() (string, error) { +func DetectUserShell() (string, error) { // Attempt to get the SHELL environment variable first if shell := os.Getenv("SHELL"); shell != "" { return filepath.Base(shell), nil @@ -70,7 +70,7 @@ func GetUserShell() (string, error) { return "", fmt.Errorf("default shell not found") } -func GenerateCompletion( +func generateCompletion( scriptTemplate string, ) func(io.Writer, string) error { return func(w io.Writer, rootCmdName string) error { diff --git a/completion/handlers.go b/completion/handlers.go index 7a6bddf..3ef3240 100644 --- a/completion/handlers.go +++ b/completion/handlers.go @@ -9,23 +9,24 @@ import ( "github.com/coder/serpent" ) -// FileHandler returns a handler that completes files, using the +// FileHandler returns a handler that completes file names, using the // given filter func, which may be nil. func FileHandler(filter func(info os.FileInfo) bool) serpent.CompletionHandlerFunc { return func(inv *serpent.Invocation) []string { - return ListFiles(inv.CurWord, filter) + return listFiles(inv.CurWord, filter) } } +// FileListHandler returns a handler that completes a list of comma-separated, +// file names, using the given filter func, which may be nil. func FileListHandler(filter func(info os.FileInfo) bool) serpent.CompletionHandlerFunc { return func(inv *serpent.Invocation) []string { curWord := strings.TrimLeft(inv.CurWord, `"`) if curWord == "" { - return ListFiles("", filter) + return listFiles("", filter) } parts := strings.Split(curWord, ",") - out := ListFiles(parts[len(parts)-1], filter) - // prepend := strings.Join(parts[:len(parts)-1], ",") + out := listFiles(parts[len(parts)-1], filter) for i, s := range out { parts[len(parts)-1] = s out[i] = strings.Join(parts, ",") @@ -34,7 +35,7 @@ func FileListHandler(filter func(info os.FileInfo) bool) serpent.CompletionHandl } } -func ListFiles(word string, filter func(info os.FileInfo) bool) []string { +func listFiles(word string, filter func(info os.FileInfo) bool) []string { out := make([]string, 0, 32) dir, _ := filepath.Split(word) diff --git a/completion_test.go b/completion_test.go index 0f03cb8..ef245d8 100644 --- a/completion_test.go +++ b/completion_test.go @@ -13,7 +13,7 @@ import ( func TestCompletion(t *testing.T) { t.Parallel() - cmd := func() *serpent.Command { return SampleCommand(t) } + cmd := func() *serpent.Command { return sampleCommand(t) } t.Run("SubcommandList", func(t *testing.T) { t.Parallel() @@ -100,7 +100,7 @@ func TestCompletion(t *testing.T) { func TestFileCompletion(t *testing.T) { t.Parallel() - cmd := func() *serpent.Command { return SampleCommand(t) } + cmd := func() *serpent.Command { return sampleCommand(t) } t.Run("DirOK", func(t *testing.T) { t.Parallel() diff --git a/example/completetest/main.go b/example/completetest/main.go index f78ed28..c810819 100644 --- a/example/completetest/main.go +++ b/example/completetest/main.go @@ -8,10 +8,10 @@ import ( "github.com/coder/serpent/completion" ) -// InstallCommand returns a serpent command that helps +// installCommand returns a serpent command that helps // a user configure their shell to use serpent's completion. -func InstallCommand() *serpent.Command { - defaultShell, err := completion.GetUserShell() +func installCommand() *serpent.Command { + defaultShell, err := completion.DetectUserShell() if err != nil { defaultShell = "bash" } @@ -21,7 +21,7 @@ func InstallCommand() *serpent.Command { Use: "completion", Short: "Generate completion scripts for the given shell.", Handler: func(inv *serpent.Invocation) error { - completion.GetCompletion(inv.Stdout, shell, inv.Command.Parent.Name()) + completion.WriteCompletion(inv.Stdout, shell, inv.Command.Parent.Name()) return nil }, Options: serpent.OptionSet{ @@ -116,7 +116,7 @@ func main() { CompletionHandler: completion.FileHandler(nil), Middleware: serpent.RequireNArgs(1), }, - InstallCommand(), + installCommand(), }, } From 15ad85ca5685cdeb719b428aab519cb17392c229 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 15 Jul 2024 12:30:57 +0000 Subject: [PATCH 13/15] review p2 --- command.go | 85 ++++++++++++++++++++++++------------------ command_test.go | 3 ++ completion/handlers.go | 3 +- completion_test.go | 10 +++++ 4 files changed, 63 insertions(+), 38 deletions(-) diff --git a/command.go b/command.go index d413faf..6c5ddfa 100644 --- a/command.go +++ b/command.go @@ -407,31 +407,8 @@ func (inv *Invocation) run(state *runState) error { // Outputted completions are not filtered based on the word under the cursor, as every shell we support does this already. // We only look at the current word to figure out handler to run, or what directory to inspect. if inv.IsCompletionMode() { - prev, cur := inv.curWords() - inv.CurWord = cur - // If the current word is a flag set using `=`, use it's handler - if strings.HasPrefix(cur, "--") && strings.Contains(cur, "=") { - if inv.equalsFlagHandler(cur) { - return nil - } - } - // If the previous word is a flag, then we're writing it's value - // and we should check it's handler - if strings.HasPrefix(prev, "--") { - if inv.flagHandler(prev) { - return nil - } - } - // If the current word is the command, auto-complete it so the shell moves the cursor - if inv.Command.Name() == inv.CurWord { - fmt.Fprintf(inv.Stdout, "%s\n", inv.Command.Name()) - return nil - } - if inv.Command.CompletionHandler == nil { - inv.Command.CompletionHandler = DefaultCompletionHandler - } - for _, e := range inv.Command.CompletionHandler(inv) { - fmt.Fprintf(inv.Stdout, "%s\n", e) + for _, e := range inv.doCompletions() { + fmt.Fprintln(inv.Stdout, e) } return nil } @@ -613,11 +590,42 @@ func (inv *Invocation) with(fn func(*Invocation)) *Invocation { return &i2 } -func (inv *Invocation) flagHandler(word string) bool { - return inv.doFlagCompletion("", word) +func (inv *Invocation) doCompletions() []string { + prev, cur := inv.curWords() + inv.CurWord = cur + // If the current word is a flag set using `=`, use it's handler + if strings.HasPrefix(cur, "--") && strings.Contains(cur, "=") { + if out := inv.equalsFlagCompletions(cur); out != nil { + return out + } + } + // If the previous word is a flag, then we're writing it's value + // and we should check it's handler + if strings.HasPrefix(prev, "--") { + if out := inv.flagCompletions(prev); out != nil { + return out + } + } + // If the current word is the command, auto-complete it so the shell moves the cursor + if inv.Command.Name() == inv.CurWord { + return []string{inv.Command.Name()} + } + var completions []string + + if inv.Command.CompletionHandler != nil { + completions = append(completions, inv.Command.CompletionHandler(inv)...) + } + + completions = append(completions, DefaultCompletionHandler(inv)...) + + return completions } -func (inv *Invocation) equalsFlagHandler(word string) bool { +func (inv *Invocation) flagCompletions(word string) []string { + return inv.doFlagCompletions("", word) +} + +func (inv *Invocation) equalsFlagCompletions(word string) []string { words := strings.Split(word, "=") word = words[0] if len(words) > 1 { @@ -626,29 +634,32 @@ func (inv *Invocation) equalsFlagHandler(word string) bool { inv.CurWord = "" } prefix := word + "=" - return inv.doFlagCompletion(prefix, word) + return inv.doFlagCompletions(prefix, word) } -func (inv *Invocation) doFlagCompletion(prefix, word string) bool { +func (inv *Invocation) doFlagCompletions(prefix, word string) []string { opt := inv.Command.Options.ByFlag(word[2:]) if opt == nil { - return false + return nil } if opt.CompletionHandler != nil { completions := opt.CompletionHandler(inv) + out := make([]string, 0, len(completions)) for _, completion := range completions { - fmt.Fprintf(inv.Stdout, "%s%s\n", prefix, completion) + out = append(out, fmt.Sprintf("%s%s", prefix, completion)) } - return true + return out } val, ok := opt.Value.(*Enum) if ok { - for _, choice := range val.Choices { - fmt.Fprintf(inv.Stdout, "%s%s\n", prefix, choice) + completions := val.Choices + out := make([]string, 0, len(completions)) + for _, choice := range completions { + out = append(out, fmt.Sprintf("%s%s", prefix, choice)) } - return true + return out } - return false + return nil } // MiddlewareFunc returns the next handler in the chain, diff --git a/command_test.go b/command_test.go index 3c12b82..ab0c9bf 100644 --- a/command_test.go +++ b/command_test.go @@ -161,6 +161,9 @@ func sampleCommand(t *testing.T) *serpent.Command { CompletionHandler: completion.FileListHandler(nil), }, }, + CompletionHandler: func(i *serpent.Invocation) []string { + return []string{"doesntexist.go"} + }, }, }, } diff --git a/completion/handlers.go b/completion/handlers.go index 3ef3240..97ff1be 100644 --- a/completion/handlers.go +++ b/completion/handlers.go @@ -36,7 +36,8 @@ func FileListHandler(filter func(info os.FileInfo) bool) serpent.CompletionHandl } func listFiles(word string, filter func(info os.FileInfo) bool) []string { - out := make([]string, 0, 32) + // Avoid reallocating for each of the first few files we see. + out := make([]string, 0, 16) dir, _ := filepath.Split(word) if dir == "" { diff --git a/completion_test.go b/completion_test.go index ef245d8..3220b2f 100644 --- a/completion_test.go +++ b/completion_test.go @@ -55,6 +55,16 @@ func TestCompletion(t *testing.T) { require.Equal(t, "--req-array\n--req-bool\n--req-enum\n--req-string\n", io.Stdout.String()) }) + t.Run("ListFlagsAfterArg", func(t *testing.T) { + t.Parallel() + i := cmd().Invoke("altfile", "") + i.Environ.Set(serpent.CompletionModeEnv, "1") + io := fakeIO(i) + err := i.Run() + require.NoError(t, err) + require.Equal(t, "doesntexist.go\n--extra\n", io.Stdout.String()) + }) + t.Run("FlagExhaustive", func(t *testing.T) { t.Parallel() i := cmd().Invoke("required-flag", "--req-bool", "--req-string", "foo bar", "--req-array", "asdf", "--req-array", "qwerty") From 3fcf037611532a747eb093d7c8c82429d0778621 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 25 Jul 2024 12:18:03 +0000 Subject: [PATCH 14/15] review --- command.go | 72 ++++++++++++------------------- command_test.go | 9 ++-- completion/handlers.go | 82 +++++++++++++----------------------- completion_test.go | 46 +++++++++----------- example/completetest/main.go | 3 -- option.go | 3 ++ 6 files changed, 85 insertions(+), 130 deletions(-) diff --git a/command.go b/command.go index 6c5ddfa..2144c3b 100644 --- a/command.go +++ b/command.go @@ -203,8 +203,6 @@ type Invocation struct { // Args is reduced into the remaining arguments after parsing flags // during Run. Args []string - // CurWord is the word the terminal cursor is currently in - CurWord string // Environ is a list of environment variables. Use EnvsWithPrefix to parse // os.Environ. @@ -297,7 +295,7 @@ func copyFlagSetWithout(fs *pflag.FlagSet, without string) *pflag.FlagSet { return fs2 } -func (inv *Invocation) curWords() (prev string, cur string) { +func (inv *Invocation) CurWords() (prev string, cur string) { if len(inv.Args) == 1 { cur = inv.Args[0] prev = "" @@ -407,7 +405,7 @@ func (inv *Invocation) run(state *runState) error { // Outputted completions are not filtered based on the word under the cursor, as every shell we support does this already. // We only look at the current word to figure out handler to run, or what directory to inspect. if inv.IsCompletionMode() { - for _, e := range inv.doCompletions() { + for _, e := range inv.complete() { fmt.Fprintln(inv.Stdout, e) } return nil @@ -590,24 +588,36 @@ func (inv *Invocation) with(fn func(*Invocation)) *Invocation { return &i2 } -func (inv *Invocation) doCompletions() []string { - prev, cur := inv.curWords() - inv.CurWord = cur - // If the current word is a flag set using `=`, use it's handler - if strings.HasPrefix(cur, "--") && strings.Contains(cur, "=") { - if out := inv.equalsFlagCompletions(cur); out != nil { - return out +func (inv *Invocation) complete() []string { + prev, cur := inv.CurWords() + + if strings.HasPrefix(cur, "--") { + // If the current word is a flag set using `=`, use it's handler + if strings.Contains(cur, "=") { + words := strings.Split(cur, "=") + flagName := words[0][2:] + if out := inv.completeFlag(flagName); out != nil { + for i, o := range out { + out[i] = fmt.Sprintf("--%s=%s", flagName, o) + } + return out + } + } else if out := inv.Command.Options.ByFlag(cur[2:]); out != nil { + // If the current word is a complete flag, auto-complete it so the + // shell moves the cursor + return []string{cur} } } // If the previous word is a flag, then we're writing it's value // and we should check it's handler if strings.HasPrefix(prev, "--") { - if out := inv.flagCompletions(prev); out != nil { + word := prev[2:] + if out := inv.completeFlag(word); out != nil { return out } } - // If the current word is the command, auto-complete it so the shell moves the cursor - if inv.Command.Name() == inv.CurWord { + // If the current word is the command, move the shell cursor + if inv.Command.Name() == cur { return []string{inv.Command.Name()} } var completions []string @@ -621,43 +631,17 @@ func (inv *Invocation) doCompletions() []string { return completions } -func (inv *Invocation) flagCompletions(word string) []string { - return inv.doFlagCompletions("", word) -} - -func (inv *Invocation) equalsFlagCompletions(word string) []string { - words := strings.Split(word, "=") - word = words[0] - if len(words) > 1 { - inv.CurWord = words[1] - } else { - inv.CurWord = "" - } - prefix := word + "=" - return inv.doFlagCompletions(prefix, word) -} - -func (inv *Invocation) doFlagCompletions(prefix, word string) []string { - opt := inv.Command.Options.ByFlag(word[2:]) +func (inv *Invocation) completeFlag(word string) []string { + opt := inv.Command.Options.ByFlag(word) if opt == nil { return nil } if opt.CompletionHandler != nil { - completions := opt.CompletionHandler(inv) - out := make([]string, 0, len(completions)) - for _, completion := range completions { - out = append(out, fmt.Sprintf("%s%s", prefix, completion)) - } - return out + return opt.CompletionHandler(inv) } val, ok := opt.Value.(*Enum) if ok { - completions := val.Choices - out := make([]string, 0, len(completions)) - for _, choice := range completions { - out = append(out, fmt.Sprintf("%s%s", prefix, choice)) - } - return out + return val.Choices } return nil } diff --git a/command_test.go b/command_test.go index ab0c9bf..de6c12d 100644 --- a/command_test.go +++ b/command_test.go @@ -154,11 +154,10 @@ func sampleCommand(t *testing.T) *serpent.Command { }, Options: serpent.OptionSet{ { - Name: "extra", - Flag: "extra", - Description: "Extra files.", - Value: serpent.StringArrayOf(&fileArr), - CompletionHandler: completion.FileListHandler(nil), + Name: "extra", + Flag: "extra", + Description: "Extra files.", + Value: serpent.StringArrayOf(&fileArr), }, }, CompletionHandler: func(i *serpent.Invocation) []string { diff --git a/completion/handlers.go b/completion/handlers.go index 97ff1be..848bb06 100644 --- a/completion/handlers.go +++ b/completion/handlers.go @@ -13,65 +13,43 @@ import ( // given filter func, which may be nil. func FileHandler(filter func(info os.FileInfo) bool) serpent.CompletionHandlerFunc { return func(inv *serpent.Invocation) []string { - return listFiles(inv.CurWord, filter) - } -} + var out []string + _, word := inv.CurWords() -// FileListHandler returns a handler that completes a list of comma-separated, -// file names, using the given filter func, which may be nil. -func FileListHandler(filter func(info os.FileInfo) bool) serpent.CompletionHandlerFunc { - return func(inv *serpent.Invocation) []string { - curWord := strings.TrimLeft(inv.CurWord, `"`) - if curWord == "" { - return listFiles("", filter) + dir, _ := filepath.Split(word) + if dir == "" { + dir = "." } - parts := strings.Split(curWord, ",") - out := listFiles(parts[len(parts)-1], filter) - for i, s := range out { - parts[len(parts)-1] = s - out[i] = strings.Join(parts, ",") + f, err := os.Open(dir) + if err != nil { + return out } - return out - } -} - -func listFiles(word string, filter func(info os.FileInfo) bool) []string { - // Avoid reallocating for each of the first few files we see. - out := make([]string, 0, 16) - - dir, _ := filepath.Split(word) - if dir == "" { - dir = "." - } - f, err := os.Open(dir) - if err != nil { - return out - } - defer f.Close() - if dir == "." { - dir = "" - } - - infos, err := f.Readdir(0) - if err != nil { - return out - } - - for _, info := range infos { - if filter != nil && !filter(info) { - continue + defer f.Close() + if dir == "." { + dir = "" } - var cur string - if info.IsDir() { - cur = fmt.Sprintf("%s%s%c", dir, info.Name(), os.PathSeparator) - } else { - cur = fmt.Sprintf("%s%s", dir, info.Name()) + infos, err := f.Readdir(0) + if err != nil { + return out } - if strings.HasPrefix(cur, word) { - out = append(out, cur) + for _, info := range infos { + if filter != nil && !filter(info) { + continue + } + + var cur string + if info.IsDir() { + cur = fmt.Sprintf("%s%s%c", dir, info.Name(), os.PathSeparator) + } else { + cur = fmt.Sprintf("%s%s", dir, info.Name()) + } + + if strings.HasPrefix(cur, word) { + out = append(out, cur) + } } + return out } - return out } diff --git a/completion_test.go b/completion_test.go index 3220b2f..5ca160a 100644 --- a/completion_test.go +++ b/completion_test.go @@ -85,6 +85,16 @@ func TestCompletion(t *testing.T) { require.Equal(t, "--req-array\n--req-enum\n", io.Stdout.String()) }) + t.Run("NoOptDefValueFlag", func(t *testing.T) { + t.Parallel() + i := cmd().Invoke("--verbose", "") + i.Environ.Set(serpent.CompletionModeEnv, "1") + io := fakeIO(i) + err := i.Run() + require.NoError(t, err) + require.Equal(t, "altfile\nfile\nrequired-flag\ntoupper\n--prefix\n", io.Stdout.String()) + }) + t.Run("EnumOK", func(t *testing.T) { t.Parallel() i := cmd().Invoke("required-flag", "--req-enum", "") @@ -105,6 +115,16 @@ func TestCompletion(t *testing.T) { require.Equal(t, "--req-enum=foo\n--req-enum=bar\n--req-enum=qux\n", io.Stdout.String()) }) + t.Run("EnumEqualsBeginQuotesOK", func(t *testing.T) { + t.Parallel() + i := cmd().Invoke("required-flag", "--req-enum", "--req-enum=\"") + i.Environ.Set(serpent.CompletionModeEnv, "1") + io := fakeIO(i) + err := i.Run() + require.NoError(t, err) + require.Equal(t, "--req-enum=foo\n--req-enum=bar\n--req-enum=qux\n", io.Stdout.String()) + }) + } func TestFileCompletion(t *testing.T) { @@ -179,31 +199,5 @@ func TestFileCompletion(t *testing.T) { require.Equal(t, len(files), len(output)) } }) - t.Run(tc.name+"/List", func(t *testing.T) { - t.Parallel() - for _, path := range tc.paths { - i := cmd().Invoke("altfile", "--extra", fmt.Sprintf(`"example.go,%s`, path)) - i.Environ.Set(serpent.CompletionModeEnv, "1") - io := fakeIO(i) - err := i.Run() - require.NoError(t, err) - output := strings.Split(io.Stdout.String(), "\n") - output = output[:len(output)-1] - for _, str := range output { - parts := strings.Split(str, ",") - require.Len(t, parts, 2) - require.Equal(t, parts[0], "example.go") - fileComp := parts[1] - if strings.HasSuffix(fileComp, string(os.PathSeparator)) { - require.DirExists(t, fileComp) - } else { - require.FileExists(t, fileComp) - } - } - files, err := os.ReadDir(tc.realPath) - require.NoError(t, err) - require.Equal(t, len(files), len(output)) - } - }) } } diff --git a/example/completetest/main.go b/example/completetest/main.go index c810819..920e705 100644 --- a/example/completetest/main.go +++ b/example/completetest/main.go @@ -108,9 +108,6 @@ func main() { Flag: "extra", Description: "Extra files.", Value: serpent.StringArrayOf(&fileArr), - CompletionHandler: completion.FileListHandler(func(info os.FileInfo) bool { - return !info.IsDir() - }), }, }, CompletionHandler: completion.FileHandler(nil), diff --git a/option.go b/option.go index 8af2df9..fccc67e 100644 --- a/option.go +++ b/option.go @@ -347,6 +347,9 @@ func (optSet OptionSet) ByName(name string) *Option { } func (optSet OptionSet) ByFlag(flag string) *Option { + if flag == "" { + return nil + } for i := range optSet { opt := &optSet[i] if opt.Flag == flag || opt.FlagShorthand == flag { From c3654952c67b3813e4fa30700f9e21a9d88e68d6 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 25 Jul 2024 12:35:32 +0000 Subject: [PATCH 15/15] fixup --- command.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/command.go b/command.go index 2144c3b..2a089ba 100644 --- a/command.go +++ b/command.go @@ -591,19 +591,20 @@ func (inv *Invocation) with(fn func(*Invocation)) *Invocation { func (inv *Invocation) complete() []string { prev, cur := inv.CurWords() + // If the current word is a flag if strings.HasPrefix(cur, "--") { - // If the current word is a flag set using `=`, use it's handler - if strings.Contains(cur, "=") { - words := strings.Split(cur, "=") - flagName := words[0][2:] + flagParts := strings.Split(cur, "=") + flagName := flagParts[0][2:] + // If it's an equals flag + if len(flagParts) == 2 { if out := inv.completeFlag(flagName); out != nil { for i, o := range out { out[i] = fmt.Sprintf("--%s=%s", flagName, o) } return out } - } else if out := inv.Command.Options.ByFlag(cur[2:]); out != nil { - // If the current word is a complete flag, auto-complete it so the + } else if out := inv.Command.Options.ByFlag(flagName); out != nil { + // If the current word is a valid flag, auto-complete it so the // shell moves the cursor return []string{cur} }