From 32bbb92fa8b46734aca44293ea7199631127ff68 Mon Sep 17 00:00:00 2001 From: Tugdual Saunier Date: Thu, 11 Apr 2024 16:15:49 +0200 Subject: [PATCH 1/2] This PR provides generic autocompletion for every application using Symfony CLI's Console for Bash, ZSH, and Fish. To make it work locally one can use the instructions provided by `symfony help completion`. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This autocompletion works on commands but also on flags. The flag autocompletion is fully automatic for some basic flag types (boolean and verbosity flags) and can be customized for any flag instance by specifying the `ArgsPredictor` property: ```golang var myCommand = &Command{ Name: "foo", Flags: []Flag{ &StringFlag{ Name: "plan", ArgsPredictor: func(*Context, complete.Args) []string { return []string{"free", "basic", "business", "enterprise"} }, }, }, } ``` One can also implement argument autocompletion for a command: ```golang var myCommand = &Command{ Name: "foo", ShellComplete: func(context *Context, c complete.Args) []string { return []string{"foo", "bar", "baz"} }, } ``` Importantly, this PR also implements autocompletion forwarding to external commands we wrap such as `console` 😎 [![asciicast](https://asciinema.org/a/lxPJuBAQr4NY2tFnB1co1PDu3.svg)](https://asciinema.org/a/lxPJuBAQr4NY2tFnB1co1PDu3) (opening as a draft for now because there are a couple of things I want to discuss and we also need to figure out the patch required on Symfony's side) --- application.go | 2 + binary.go | 6 + command.go | 2 + completion.go | 124 +++++++++++++ completion_installer.go | 140 ++++++++++++++ completion_others.go | 10 + completion_unix.go | 10 + flag.go | 2 + flags.go | 380 ++++++++++++++++++++++++-------------- funcs.go | 3 + go.go | 36 ++++ go.mod | 2 + go.sum | 12 ++ logging_flags.go | 29 +++ output_flags.go | 10 + resources/completion.bash | 58 ++++++ resources/completion.fish | 42 +++++ resources/completion.zsh | 80 ++++++++ 18 files changed, 814 insertions(+), 134 deletions(-) create mode 100644 completion.go create mode 100644 completion_installer.go create mode 100644 completion_others.go create mode 100644 completion_unix.go create mode 100644 go.go create mode 100644 resources/completion.bash create mode 100644 resources/completion.fish create mode 100644 resources/completion.zsh diff --git a/application.go b/application.go index 5b1a9ce..0d07738 100644 --- a/application.go +++ b/application.go @@ -312,6 +312,8 @@ func (a *Application) setup() { a.prependFlag(HelpFlag) } + registerAutocompleteCommands(a) + for _, c := range a.Commands { if c.HelpName == "" { c.HelpName = fmt.Sprintf("%s %s", a.HelpName, c.FullName()) diff --git a/binary.go b/binary.go index 422ea9c..71ef295 100644 --- a/binary.go +++ b/binary.go @@ -42,3 +42,9 @@ func CurrentBinaryPath() (string, error) { } return argv0, nil } + +func (c *Context) CurrentBinaryPath() string { + path, _ := CurrentBinaryPath() + + return path +} diff --git a/command.go b/command.go index f68555b..20a4fd9 100644 --- a/command.go +++ b/command.go @@ -48,6 +48,8 @@ type Command struct { DescriptionFunc DescriptionFunc // The category the command is part of Category string + // The function to call when checking for shell command completions + ShellComplete ShellCompleteFunc // An action to execute before any sub-subcommands are run, but after the context is ready // If a non-nil error is returned, no sub-subcommands are run Before BeforeFunc diff --git a/completion.go b/completion.go new file mode 100644 index 0000000..9428d6b --- /dev/null +++ b/completion.go @@ -0,0 +1,124 @@ +//go:build darwin || linux || freebsd || openbsd + +package console + +import ( + "fmt" + "os" + "runtime/debug" + + "github.com/posener/complete/v2" +) + +func init() { + for _, key := range []string{"COMP_LINE", "COMP_POINT", "COMP_DEBUG"} { + if _, hasEnv := os.LookupEnv(key); hasEnv { + // Disable Garbage collection for faster autocompletion + debug.SetGCPercent(-1) + return + } + } +} + +var autoCompleteCommand = &Command{ + Category: "self", + Name: "autocomplete", + Description: "Internal command to provide shell completion suggestions", + Hidden: Hide, + FlagParsing: FlagParsingSkippedAfterFirstArg, + Args: ArgDefinition{ + &Arg{ + Slice: true, + Optional: true, + }, + }, + Action: AutocompleteAppAction, +} + +func registerAutocompleteCommands(a *Application) { + if IsGoRun() { + return + } + + a.Commands = append( + []*Command{shellAutoCompleteInstallCommand, autoCompleteCommand}, + a.Commands..., + ) +} + +func AutocompleteAppAction(c *Context) error { + cmd := complete.Command{ + Flags: map[string]complete.Predictor{}, + Sub: map[string]*complete.Command{}, + } + + // transpose registered commands and flags to posener/complete equivalence + for _, command := range c.App.VisibleCommands() { + subCmd := command.convertToPosenerCompleteCommand(c) + + for _, name := range command.Names() { + cmd.Sub[name] = &subCmd + } + } + + for _, f := range c.App.VisibleFlags() { + if vf, ok := f.(*verbosityFlag); ok { + vf.addToPosenerFlags(c, cmd.Flags) + continue + } + + predictor := ContextPredictor{f, c} + + for _, name := range f.Names() { + name = fmt.Sprintf("%s%s", prefixFor(name), name) + cmd.Flags[name] = predictor + } + } + + cmd.Complete(c.App.HelpName) + return nil +} + +func (c *Command) convertToPosenerCompleteCommand(ctx *Context) complete.Command { + command := complete.Command{ + Flags: map[string]complete.Predictor{}, + } + + for _, f := range c.VisibleFlags() { + for _, name := range f.Names() { + name = fmt.Sprintf("%s%s", prefixFor(name), name) + command.Flags[name] = ContextPredictor{f, ctx} + } + } + + if len(c.Args) > 0 || c.ShellComplete != nil { + command.Args = ContextPredictor{c, ctx} + } + + return command +} + +func (c *Command) PredictArgs(ctx *Context, prefix string) []string { + if c.ShellComplete != nil { + return c.ShellComplete(ctx, prefix) + } + + return nil +} + +type Predictor interface { + PredictArgs(*Context, string) []string +} + +// ContextPredictor determines what terms can follow a command or a flag +// It is used for autocompletion, given the last word in the already completed +// command line, what words can complete it. +type ContextPredictor struct { + predictor Predictor + ctx *Context +} + +// Predict invokes the predict function and implements the Predictor interface +func (p ContextPredictor) Predict(prefix string) []string { + return p.predictor.PredictArgs(p.ctx, prefix) +} diff --git a/completion_installer.go b/completion_installer.go new file mode 100644 index 0000000..d77f842 --- /dev/null +++ b/completion_installer.go @@ -0,0 +1,140 @@ +//go:build darwin || linux || freebsd || openbsd + +package console + +import ( + "bytes" + "embed" + "fmt" + "os" + "path" + "strings" + "text/template" + + "github.com/pkg/errors" + "github.com/symfony-cli/terminal" +) + +// completionTemplates holds our shell completions templates. +// +//go:embed resources/completion.* +var completionTemplates embed.FS + +var shellAutoCompleteInstallCommand = &Command{ + Category: "self", + Name: "completion", + Aliases: []*Alias{ + {Name: "completion"}, + }, + Usage: "Dumps the completion script for the current shell", + ShellComplete: func(*Context, string) []string { + return []string{"bash", "zsh", "fish"} + }, + Description: `The {{.HelpName}} command dumps the shell completion script required +to use shell autocompletion (currently, bash, zsh and fish completion are supported). + +Static installation +------------------- + +Dump the script to a global completion file and restart your shell: + + {{.HelpName}} {{ call .Shell }} | sudo tee {{ call .CompletionFile }} + +Or dump the script to a local file and source it: + + {{.HelpName}} {{ call .Shell }} > completion.sh + + # source the file whenever you use the project + source completion.sh + + # or add this line at the end of your "{{ call .RcFile }}" file: + source /path/to/completion.sh + +Dynamic installation +-------------------- + +Add this to the end of your shell configuration file (e.g. "{{ call .RcFile }}"): + + eval "$({{.HelpName}} {{ call .Shell }})"`, + DescriptionFunc: func(command *Command, application *Application) string { + var buf bytes.Buffer + + tpl := template.Must(template.New("description").Parse(command.Description)) + + if err := tpl.Execute(&buf, struct { + // allows to directly access any field from the command inside the template + *Command + Shell func() string + RcFile func() string + CompletionFile func() string + }{ + Command: command, + Shell: guessShell, + RcFile: func() string { + switch guessShell() { + case "fish": + return "~/.config/fish/config.fish" + case "zsh": + return "~/.zshrc" + default: + return "~/.bashrc" + } + }, + CompletionFile: func() string { + switch guessShell() { + case "fish": + return fmt.Sprintf("/etc/fish/completions/%s.fish", application.HelpName) + case "zsh": + return fmt.Sprintf("$fpath[1]/_%s", application.HelpName) + default: + return fmt.Sprintf("/etc/bash_completion.d/%s", application.HelpName) + } + }, + }); err != nil { + panic(err) + } + + return buf.String() + }, + Args: []*Arg{ + { + Name: "shell", + Description: `The shell type (e.g. "bash"), the value of the "$SHELL" env var will be used if this is not given`, + Optional: true, + }, + }, + Action: func(c *Context) error { + shell := c.Args().Get("shell") + if shell == "" { + shell = guessShell() + } + + templates, err := template.ParseFS(completionTemplates, "resources/*") + if err != nil { + return errors.WithStack(err) + } + + if tpl := templates.Lookup(fmt.Sprintf("completion.%s", shell)); tpl != nil { + return errors.WithStack(tpl.Execute(terminal.Stdout, c)) + } + + var supportedShell []string + + for _, tmpl := range templates.Templates() { + if tmpl.Tree == nil || tmpl.Root == nil { + continue + } + supportedShell = append(supportedShell, strings.TrimLeft(path.Ext(tmpl.Name()), ".")) + } + + if shell == "" { + return errors.Errorf(`shell not detected, supported shells: "%s"`, strings.Join(supportedShell, ", ")) + } + + return errors.Errorf(`shell "%s" is not supported, supported shells: "%s"`, shell, strings.Join(supportedShell, ", ")) + }, +} + +func guessShell() string { + return path.Base(os.Getenv("SHELL")) +} diff --git a/completion_others.go b/completion_others.go new file mode 100644 index 0000000..2c83efc --- /dev/null +++ b/completion_others.go @@ -0,0 +1,10 @@ +//go:build !darwin && !linux && !freebsd && !openbsd +// +build !darwin,!linux,!freebsd,!openbsd + +package console + +const HasAutocompleteSupport = false + +func IsAutocomplete(c *Command) bool { + return false +} diff --git a/completion_unix.go b/completion_unix.go new file mode 100644 index 0000000..edc260d --- /dev/null +++ b/completion_unix.go @@ -0,0 +1,10 @@ +//go:build darwin || linux || freebsd || openbsd +// +build darwin linux freebsd openbsd + +package console + +const SupportsAutocomplete = true + +func IsAutocomplete(c *Command) bool { + return c == autoCompleteCommand +} diff --git a/flag.go b/flag.go index 95ec721..b4cf7cc 100644 --- a/flag.go +++ b/flag.go @@ -92,6 +92,8 @@ func (f FlagsByName) Swap(i, j int) { // this interface be implemented. type Flag interface { fmt.Stringer + + PredictArgs(*Context, string) []string Validate(*Context) error // Apply Flag settings to the given flag set Apply(*flag.FlagSet) diff --git a/flags.go b/flags.go index 0915055..90f72f8 100644 --- a/flags.go +++ b/flags.go @@ -27,16 +27,17 @@ import ( // BoolFlag is a flag with type bool type BoolFlag struct { - Name string - Aliases []string - Usage string - EnvVars []string - Hidden bool - DefaultValue bool - DefaultText string - Required bool - Validator func(*Context, bool) error - Destination *bool + Name string + Aliases []string + Usage string + EnvVars []string + Hidden bool + DefaultValue bool + DefaultText string + Required bool + ArgsPredictor func(*Context, string) []string + Validator func(*Context, bool) error + Destination *bool } // String returns a readable representation of this value @@ -45,6 +46,13 @@ func (f *BoolFlag) String() string { return FlagStringer(f) } +func (f *BoolFlag) PredictArgs(c *Context, prefix string) []string { + if f.ArgsPredictor != nil { + return f.ArgsPredictor(c, prefix) + } + return []string{"true", "false"} +} + func (f *BoolFlag) Validate(c *Context) error { if f.Validator != nil { return f.Validator(c, c.Bool(f.Name)) @@ -80,16 +88,17 @@ func lookupBool(name string, f *flag.Flag) bool { // DurationFlag is a flag with type time.Duration (see https://golang.org/pkg/time/#ParseDuration) type DurationFlag struct { - Name string - Aliases []string - Usage string - EnvVars []string - Hidden bool - DefaultValue time.Duration - DefaultText string - Required bool - Validator func(*Context, time.Duration) error - Destination *time.Duration + Name string + Aliases []string + Usage string + EnvVars []string + Hidden bool + DefaultValue time.Duration + DefaultText string + Required bool + ArgsPredictor func(*Context, string) []string + Validator func(*Context, time.Duration) error + Destination *time.Duration } // String returns a readable representation of this value @@ -98,6 +107,13 @@ func (f *DurationFlag) String() string { return FlagStringer(f) } +func (f *DurationFlag) PredictArgs(c *Context, prefix string) []string { + if f.ArgsPredictor != nil { + return f.ArgsPredictor(c, prefix) + } + return []string{} +} + func (f *DurationFlag) Validate(c *Context) error { if f.Validator != nil { return f.Validator(c, c.Duration(f.Name)) @@ -133,16 +149,17 @@ func lookupDuration(name string, f *flag.Flag) time.Duration { // Float64Flag is a flag with type float64 type Float64Flag struct { - Name string - Aliases []string - Usage string - EnvVars []string - Hidden bool - DefaultValue float64 - DefaultText string - Required bool - Validator func(*Context, float64) error - Destination *float64 + Name string + Aliases []string + Usage string + EnvVars []string + Hidden bool + DefaultValue float64 + DefaultText string + Required bool + ArgsPredictor func(*Context, string) []string + Validator func(*Context, float64) error + Destination *float64 } // String returns a readable representation of this value @@ -151,6 +168,13 @@ func (f *Float64Flag) String() string { return FlagStringer(f) } +func (f *Float64Flag) PredictArgs(c *Context, prefix string) []string { + if f.ArgsPredictor != nil { + return f.ArgsPredictor(c, prefix) + } + return []string{} +} + func (f *Float64Flag) Validate(c *Context) error { if f.Validator != nil { return f.Validator(c, c.Float64(f.Name)) @@ -186,15 +210,16 @@ func lookupFloat64(name string, f *flag.Flag) float64 { // GenericFlag is a flag with type Generic type GenericFlag struct { - Name string - Aliases []string - Usage string - EnvVars []string - Hidden bool - DefaultText string - Required bool - Validator func(*Context, interface{}) error - Destination Generic + Name string + Aliases []string + Usage string + EnvVars []string + Hidden bool + DefaultText string + Required bool + ArgsPredictor func(*Context, string) []string + Validator func(*Context, interface{}) error + Destination Generic } // String returns a readable representation of this value @@ -203,6 +228,13 @@ func (f *GenericFlag) String() string { return FlagStringer(f) } +func (f *GenericFlag) PredictArgs(c *Context, prefix string) []string { + if f.ArgsPredictor != nil { + return f.ArgsPredictor(c, prefix) + } + return []string{} +} + func (f *GenericFlag) Validate(c *Context) error { if f.Validator != nil { return f.Validator(c, c.Generic(f.Name)) @@ -238,16 +270,17 @@ func lookupGeneric(name string, f *flag.Flag) interface{} { // Int64Flag is a flag with type int64 type Int64Flag struct { - Name string - Aliases []string - Usage string - EnvVars []string - Hidden bool - DefaultValue int64 - DefaultText string - Required bool - Validator func(*Context, int64) error - Destination *int64 + Name string + Aliases []string + Usage string + EnvVars []string + Hidden bool + DefaultValue int64 + DefaultText string + Required bool + ArgsPredictor func(*Context, string) []string + Validator func(*Context, int64) error + Destination *int64 } // String returns a readable representation of this value @@ -256,6 +289,13 @@ func (f *Int64Flag) String() string { return FlagStringer(f) } +func (f *Int64Flag) PredictArgs(c *Context, prefix string) []string { + if f.ArgsPredictor != nil { + return f.ArgsPredictor(c, prefix) + } + return []string{} +} + func (f *Int64Flag) Validate(c *Context) error { if f.Validator != nil { return f.Validator(c, c.Int64(f.Name)) @@ -291,16 +331,17 @@ func lookupInt64(name string, f *flag.Flag) int64 { // IntFlag is a flag with type int type IntFlag struct { - Name string - Aliases []string - Usage string - EnvVars []string - Hidden bool - DefaultValue int - DefaultText string - Required bool - Validator func(*Context, int) error - Destination *int + Name string + Aliases []string + Usage string + EnvVars []string + Hidden bool + DefaultValue int + DefaultText string + Required bool + ArgsPredictor func(*Context, string) []string + Validator func(*Context, int) error + Destination *int } // String returns a readable representation of this value @@ -309,6 +350,13 @@ func (f *IntFlag) String() string { return FlagStringer(f) } +func (f *IntFlag) PredictArgs(c *Context, prefix string) []string { + if f.ArgsPredictor != nil { + return f.ArgsPredictor(c, prefix) + } + return []string{} +} + func (f *IntFlag) Validate(c *Context) error { if f.Validator != nil { return f.Validator(c, c.Int(f.Name)) @@ -344,15 +392,16 @@ func lookupInt(name string, f *flag.Flag) int { // IntSliceFlag is a flag with type *IntSlice type IntSliceFlag struct { - Name string - Aliases []string - Usage string - EnvVars []string - Hidden bool - DefaultText string - Required bool - Validator func(*Context, []int) error - Destination *IntSlice + Name string + Aliases []string + Usage string + EnvVars []string + Hidden bool + DefaultText string + Required bool + ArgsPredictor func(*Context, string) []string + Validator func(*Context, []int) error + Destination *IntSlice } // String returns a readable representation of this value @@ -361,6 +410,13 @@ func (f *IntSliceFlag) String() string { return FlagStringer(f) } +func (f *IntSliceFlag) PredictArgs(c *Context, prefix string) []string { + if f.ArgsPredictor != nil { + return f.ArgsPredictor(c, prefix) + } + return []string{} +} + func (f *IntSliceFlag) Validate(c *Context) error { if f.Validator != nil { return f.Validator(c, c.IntSlice(f.Name)) @@ -398,15 +454,16 @@ func lookupIntSlice(name string, f *flag.Flag) []int { // Int64SliceFlag is a flag with type *Int64Slice type Int64SliceFlag struct { - Name string - Aliases []string - Usage string - EnvVars []string - Hidden bool - DefaultText string - Required bool - Validator func(*Context, []int64) error - Destination *Int64Slice + Name string + Aliases []string + Usage string + EnvVars []string + Hidden bool + DefaultText string + Required bool + ArgsPredictor func(*Context, string) []string + Validator func(*Context, []int64) error + Destination *Int64Slice } // String returns a readable representation of this value @@ -415,6 +472,13 @@ func (f *Int64SliceFlag) String() string { return FlagStringer(f) } +func (f *Int64SliceFlag) PredictArgs(c *Context, prefix string) []string { + if f.ArgsPredictor != nil { + return f.ArgsPredictor(c, prefix) + } + return []string{} +} + func (f *Int64SliceFlag) Validate(c *Context) error { if f.Validator != nil { return f.Validator(c, c.Int64Slice(f.Name)) @@ -452,15 +516,16 @@ func lookupInt64Slice(name string, f *flag.Flag) []int64 { // Float64SliceFlag is a flag with type *Float64Slice type Float64SliceFlag struct { - Name string - Aliases []string - Usage string - EnvVars []string - Hidden bool - DefaultText string - Required bool - Validator func(*Context, []float64) error - Destination *Float64Slice + Name string + Aliases []string + Usage string + EnvVars []string + Hidden bool + DefaultText string + Required bool + ArgsPredictor func(*Context, string) []string + Validator func(*Context, []float64) error + Destination *Float64Slice } // String returns a readable representation of this value @@ -469,6 +534,13 @@ func (f *Float64SliceFlag) String() string { return FlagStringer(f) } +func (f *Float64SliceFlag) PredictArgs(c *Context, prefix string) []string { + if f.ArgsPredictor != nil { + return f.ArgsPredictor(c, prefix) + } + return []string{} +} + func (f *Float64SliceFlag) Validate(c *Context) error { if f.Validator != nil { return f.Validator(c, c.Float64Slice(f.Name)) @@ -506,16 +578,17 @@ func lookupFloat64Slice(name string, f *flag.Flag) []float64 { // StringFlag is a flag with type string type StringFlag struct { - Name string - Aliases []string - Usage string - EnvVars []string - Hidden bool - DefaultValue string - DefaultText string - Required bool - Validator func(*Context, string) error - Destination *string + Name string + Aliases []string + Usage string + EnvVars []string + Hidden bool + DefaultValue string + DefaultText string + Required bool + ArgsPredictor func(*Context, string) []string + Validator func(*Context, string) error + Destination *string } // String returns a readable representation of this value @@ -524,6 +597,13 @@ func (f *StringFlag) String() string { return FlagStringer(f) } +func (f *StringFlag) PredictArgs(c *Context, prefix string) []string { + if f.ArgsPredictor != nil { + return f.ArgsPredictor(c, prefix) + } + return []string{} +} + func (f *StringFlag) Validate(c *Context) error { if f.Validator != nil { return f.Validator(c, c.String(f.Name)) @@ -559,15 +639,16 @@ func lookupString(name string, f *flag.Flag) string { // StringSliceFlag is a flag with type *StringSlice type StringSliceFlag struct { - Name string - Aliases []string - Usage string - EnvVars []string - Hidden bool - DefaultText string - Required bool - Validator func(*Context, []string) error - Destination *StringSlice + Name string + Aliases []string + Usage string + EnvVars []string + Hidden bool + DefaultText string + Required bool + ArgsPredictor func(*Context, string) []string + Validator func(*Context, []string) error + Destination *StringSlice } // String returns a readable representation of this value @@ -576,6 +657,13 @@ func (f *StringSliceFlag) String() string { return FlagStringer(f) } +func (f *StringSliceFlag) PredictArgs(c *Context, prefix string) []string { + if f.ArgsPredictor != nil { + return f.ArgsPredictor(c, prefix) + } + return []string{} +} + func (f *StringSliceFlag) Validate(c *Context) error { if f.Validator != nil { return f.Validator(c, c.StringSlice(f.Name)) @@ -613,15 +701,16 @@ func lookupStringSlice(name string, f *flag.Flag) []string { // StringMapFlag is a flag with type *StringMap type StringMapFlag struct { - Name string - Aliases []string - Usage string - EnvVars []string - Hidden bool - DefaultText string - Required bool - Validator func(*Context, map[string]string) error - Destination *StringMap + Name string + Aliases []string + Usage string + EnvVars []string + Hidden bool + DefaultText string + Required bool + ArgsPredictor func(*Context, string) []string + Validator func(*Context, map[string]string) error + Destination *StringMap } // String returns a readable representation of this value @@ -630,6 +719,13 @@ func (f *StringMapFlag) String() string { return FlagStringer(f) } +func (f *StringMapFlag) PredictArgs(c *Context, prefix string) []string { + if f.ArgsPredictor != nil { + return f.ArgsPredictor(c, prefix) + } + return []string{} +} + func (f *StringMapFlag) Validate(c *Context) error { if f.Validator != nil { return f.Validator(c, c.StringMap(f.Name)) @@ -667,16 +763,17 @@ func lookupStringMap(name string, f *flag.Flag) map[string]string { // Uint64Flag is a flag with type uint64 type Uint64Flag struct { - Name string - Aliases []string - Usage string - EnvVars []string - Hidden bool - DefaultValue uint64 - DefaultText string - Required bool - Validator func(*Context, uint64) error - Destination *uint64 + Name string + Aliases []string + Usage string + EnvVars []string + Hidden bool + DefaultValue uint64 + DefaultText string + Required bool + ArgsPredictor func(*Context, string) []string + Validator func(*Context, uint64) error + Destination *uint64 } // String returns a readable representation of this value @@ -685,6 +782,13 @@ func (f *Uint64Flag) String() string { return FlagStringer(f) } +func (f *Uint64Flag) PredictArgs(c *Context, prefix string) []string { + if f.ArgsPredictor != nil { + return f.ArgsPredictor(c, prefix) + } + return []string{} +} + func (f *Uint64Flag) Validate(c *Context) error { if f.Validator != nil { return f.Validator(c, c.Uint64(f.Name)) @@ -720,16 +824,17 @@ func lookupUint64(name string, f *flag.Flag) uint64 { // UintFlag is a flag with type uint type UintFlag struct { - Name string - Aliases []string - Usage string - EnvVars []string - Hidden bool - DefaultValue uint - DefaultText string - Required bool - Validator func(*Context, uint) error - Destination *uint + Name string + Aliases []string + Usage string + EnvVars []string + Hidden bool + DefaultValue uint + DefaultText string + Required bool + ArgsPredictor func(*Context, string) []string + Validator func(*Context, uint) error + Destination *uint } // String returns a readable representation of this value @@ -738,6 +843,13 @@ func (f *UintFlag) String() string { return FlagStringer(f) } +func (f *UintFlag) PredictArgs(c *Context, prefix string) []string { + if f.ArgsPredictor != nil { + return f.ArgsPredictor(c, prefix) + } + return []string{} +} + func (f *UintFlag) Validate(c *Context) error { if f.Validator != nil { return f.Validator(c, c.Uint(f.Name)) diff --git a/funcs.go b/funcs.go index ae8e818..e8b5672 100644 --- a/funcs.go +++ b/funcs.go @@ -19,6 +19,9 @@ package console +// ShellCompleteFunc is an action to execute when the shell completion flag is set +type ShellCompleteFunc func(*Context, string) []string + // BeforeFunc is an action to execute before any subcommands are run, but after // the context is ready if a non-nil error is returned, no subcommands are run type BeforeFunc func(*Context) error diff --git a/go.go b/go.go new file mode 100644 index 0000000..f3a1e88 --- /dev/null +++ b/go.go @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2021-present Fabien Potencier + * + * This file is part of Symfony CLI project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package console + +import ( + "os" + "path/filepath" + "strings" +) + +func IsGoRun() bool { + // Unfortunately, Golang does not expose that we are currently using go run + // So we detect the main binary is (or used to be ;)) "go" and then the + // current binary is within a temp "go-build" directory. + _, exe := filepath.Split(os.Getenv("_")) + argv0, _ := os.Executable() + + return exe == "go" && strings.Contains(argv0, "go-build") +} diff --git a/go.mod b/go.mod index 7fee3d9..cc40aa1 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/agext/levenshtein v1.2.3 github.com/mitchellh/go-homedir v1.1.0 github.com/pkg/errors v0.9.1 + github.com/posener/complete/v2 v2.1.0 github.com/rs/zerolog v1.32.0 github.com/symfony-cli/terminal v1.0.7 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c @@ -16,6 +17,7 @@ require ( github.com/kr/text v0.2.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/posener/script v1.2.0 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect golang.org/x/sys v0.20.0 // indirect golang.org/x/term v0.19.0 // indirect diff --git a/go.sum b/go.sum index b63e641..56aac3a 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7l github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -21,12 +23,20 @@ github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrk github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete/v2 v2.1.0 h1:IpAWxMyiJ6zDSoq+QmEBF0thpOramC0kYuEFBTcQeTI= +github.com/posener/complete/v2 v2.1.0/go.mod h1:AkzsSVGx4ysH/4OhZf57dr4yszGXgFmXsP/VNwlaW7U= +github.com/posener/script v1.2.0 h1:DrZz0qFT8lCLkYNi1PleLDANFnKxJ2VmlNPJbAkVLsE= +github.com/posener/script v1.2.0/go.mod h1:s4sVvRXtdc/1aK6otTSeW2BVXndO8MsoOVUwK74zcg4= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0= github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/symfony-cli/terminal v1.0.7 h1:57L9PUTE2cHfQtP8Ti8dyiiPEYlQ1NBIDpMJ3RPEGPc= github.com/symfony-cli/terminal v1.0.7/go.mod h1:Etv22IyeGiMoIQPPj51hX31j7xuYl1njyuAFkrvybqU= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -38,3 +48,5 @@ golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q= golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/logging_flags.go b/logging_flags.go index 1b084f2..441a107 100644 --- a/logging_flags.go +++ b/logging_flags.go @@ -26,6 +26,7 @@ import ( "strings" "github.com/pkg/errors" + "github.com/posener/complete/v2" "github.com/symfony-cli/terminal" ) @@ -99,6 +100,10 @@ func VerbosityFlag(name, alias, shortAlias string) *verbosityFlag { } } +func (f *verbosityFlag) PredictArgs(c *Context, prefix string) []string { + return []string{"1", "2", "3", "4"} +} + func (f *verbosityFlag) Validate(c *Context) error { return nil } @@ -161,3 +166,27 @@ func (f *verbosityFlag) String() string { return fmt.Sprintf("%s\t%s", names, strings.TrimSpace(usage)) } + +func (f *verbosityFlag) addToPosenerFlags(c *Context, flags map[string]complete.Predictor) { + for i, n := 1, len(terminal.LogLevels)-2; i <= n; i++ { + name := prefixFor(f.ShortAlias) + name += strings.Repeat(f.ShortAlias, i) + flags[name] = complete.PredictFunc(func(prefix string) []string { + return f.PredictArgs(c, prefix) + }) + } + + for _, alias := range f.Aliases { + if alias != "" { + flags[prefixFor(alias)+alias] = complete.PredictFunc(func(prefix string) []string { + return f.PredictArgs(c, prefix) + }) + } + } + + if f.Name != "" { + flags[prefixFor(f.Name)+f.Name] = complete.PredictFunc(func(prefix string) []string { + return f.PredictArgs(c, prefix) + }) + } +} diff --git a/output_flags.go b/output_flags.go index 6d1c073..67a61e3 100644 --- a/output_flags.go +++ b/output_flags.go @@ -95,6 +95,10 @@ func (f *quietFlag) ForApp(app *Application) *quietFlag { } } +func (f *quietFlag) PredictArgs(*Context, string) []string { + return []string{"true", "false", ""} +} + func (f *quietFlag) Validate(c *Context) error { return nil } @@ -129,6 +133,12 @@ var ( ) func (app *Application) configureIO(c *Context) { + if IsAutocomplete(c.Command) { + terminal.DefaultStdout.SetDecorated(false) + terminal.Stdin.SetInteractive(false) + return + } + if c.IsSet(AnsiFlag.Name) { terminal.DefaultStdout.SetDecorated(c.Bool(AnsiFlag.Name)) } else if c.IsSet(NoAnsiFlag.Name) { diff --git a/resources/completion.bash b/resources/completion.bash new file mode 100644 index 0000000..f497515 --- /dev/null +++ b/resources/completion.bash @@ -0,0 +1,58 @@ +# Copyright (c) 2021-present Fabien Potencier +# +# This file is part of Symfony CLI project +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# Bash completions for the CLI binary +# +# References: +# - https://github.com/symfony/symfony/blob/6.4/src/Symfony/Component/Console/Resources/completion.bash +# - https://github.com/posener/complete/blob/master/install/bash.go +# - https://github.com/scop/bash-completion/blob/master/completions/sudo +# + +# this wrapper function allows us to let Symfony knows how to call the +# `bin/console` using the Symfony CLI binary (to ensure the right env and PHP +# versions are used) +_{{ .App.HelpName }}_console() { + # shellcheck disable=SC2068 + {{ .CurrentBinaryPath }} console $@ +} + +_complete_{{ .App.HelpName }}() { + + # Use the default completion for shell redirect operators. + for w in '>' '>>' '&>' '<'; do + if [[ $w = "${COMP_WORDS[COMP_CWORD-1]}" ]]; then + compopt -o filenames + COMPREPLY=($(compgen -f -- "${COMP_WORDS[COMP_CWORD]}")) + return 0 + fi + done + + for (( i=1; i <= COMP_CWORD; i++ )); do + if [[ "${COMP_WORDS[i]}" != -* ]]; then + case "${COMP_WORDS[i]}" in + console|php|pecl|composer|run|local:run) + _SF_CMD="_{{ .App.HelpName }}_console" _command_offset $i + return + esac; + fi + done + + IFS=$'\n' COMPREPLY=( $(COMP_LINE="${COMP_LINE}" COMP_POINT="${COMP_POINT}" COMP_DEBUG="$COMP_DEBUG" {{ .CurrentBinaryPath }} self:autocomplete) ) +} + +complete -F _complete_{{ .App.HelpName }} {{ .App.HelpName }} diff --git a/resources/completion.fish b/resources/completion.fish new file mode 100644 index 0000000..7c3a8b6 --- /dev/null +++ b/resources/completion.fish @@ -0,0 +1,42 @@ +# Copyright (c) 2021-present Fabien Potencier +# +# This file is part of Symfony CLI project +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# Fish completions for the CLI binary +# +# References: +# - https://github.com/symfony/symfony/blob/6.4/src/Symfony/Component/Console/Resources/completion.fish +# - https://github.com/posener/complete/blob/master/install/fish.go +# - https://github.com/fish-shell/fish-shell/blob/master/share/completions/sudo.fish +# + +function __complete_{{ .App.HelpName }} + set -lx COMP_LINE (commandline -cp) + test -z (commandline -ct) + and set COMP_LINE "$COMP_LINE " + {{ .CurrentBinaryPath }} self:autocomplete +end + +# this wrapper function allows us to call Symfony autocompletion letting it +# knows how to call the `bin/console` using the Symfony CLI binary (to ensure +# the right env and PHP versions are used) +function __complete_{{ .App.HelpName }}_console + set -x _SF_CMD "{{ .CurrentBinaryPath }}" "console" + _sf_console +end + +complete -f -c '{{ .App.HelpName }}' -n "__fish_seen_subcommand_from console" -a '(__complete_{{ .App.HelpName }}_console)' -f +complete -f -c '{{ .App.HelpName }}' -n "not __fish_seen_subcommand_from console php pecl composer run local:run" -a '(__complete_{{ .App.HelpName }})' diff --git a/resources/completion.zsh b/resources/completion.zsh new file mode 100644 index 0000000..4624b48 --- /dev/null +++ b/resources/completion.zsh @@ -0,0 +1,80 @@ +#compdef {{ .App.HelpName }} + +# Copyright (c) 2021-present Fabien Potencier +# +# This file is part of Symfony CLI project +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +# +# zsh completions for {{ .App.HelpName }} +# +# References: +# - https://github.com/symfony/symfony/blob/6.4/src/Symfony/Component/Console/Resources/completion.zsh +# - https://github.com/posener/complete/blob/master/install/zsh.go +# - https://stackoverflow.com/a/13547531 +# + +# this wrapper function allows us to let Symfony knows how to call the +# `bin/console` using the Symfony CLI binary (to ensure the right env and PHP +# versions are used) +_{{ .App.HelpName }}_console() { + # shellcheck disable=SC2068 + {{ .CurrentBinaryPath }} console $@ +} + +_complete_{{ .App.HelpName }}() { + local lastParam flagPrefix requestComp out comp + local -a completions + + # The user could have moved the cursor backwards on the command-line. + # We need to trigger completion from the $CURRENT location, so we need + # to truncate the command-line ($words) up to the $CURRENT location. + # (We cannot use $CURSOR as its value does not work when a command is an alias.) + words=("${=words[1,CURRENT]}") lastParam=${words[-1]} + + # For zsh, when completing a flag with an = (e.g., {{ .App.HelpName }} -n=) + # completions must be prefixed with the flag + setopt local_options BASH_REMATCH + if [[ "${lastParam}" =~ '-.*=' ]]; then + # We are dealing with a flag with an = + flagPrefix="-P ${BASH_REMATCH}" + fi + + # detect if we are in a wrapper command and need to "forward" completion to it + for ((i = 1; i <= $#words; i++)); do + if [[ "${words[i]}" != -* ]]; then + case "${words[i]}" in + console|php|pecl|composer|run|local:run) + shift words + (( CURRENT-- )) + _SF_CMD="_{{ .App.HelpName }}_console" _normal + return + esac; + fi + done + + while IFS='\n' read -r comp; do + if [ -n "$comp" ]; then + # We first need to escape any : as part of the completion itself. + comp=${comp//:/\\:} + completions+=${comp} + fi + done < <(COMP_LINE="$words" ${words[0]} ${_SF_CMD:-${words[1]}} self:autocomplete) + + # Let inbuilt _describe handle completions + eval _describe "completions" completions $flagPrefix +} + +compdef _complete_{{ .App.HelpName }} {{ .App.HelpName }} From d0f9af8346cfcbbcbb9da441dd573fa5156cf7fe Mon Sep 17 00:00:00 2001 From: Tugdual Saunier Date: Tue, 11 Jun 2024 17:46:37 +0200 Subject: [PATCH 2/2] Automatically use the way the binary is invoked to generate autocompletion script --- binary.go | 22 +++++++++++++++++++++- resources/completion.bash | 2 +- resources/completion.fish | 4 ++-- resources/completion.zsh | 2 +- 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/binary.go b/binary.go index 71ef295..f643ef2 100644 --- a/binary.go +++ b/binary.go @@ -43,8 +43,28 @@ func CurrentBinaryPath() (string, error) { return argv0, nil } +func CurrentBinaryInvocation() (string, error) { + if len(os.Args) == 0 || os.Args[0] == "" { + return "", errors.New("no binary invokation found") + } + + return os.Args[0], nil +} + func (c *Context) CurrentBinaryPath() string { - path, _ := CurrentBinaryPath() + path, err := CurrentBinaryPath() + if err != nil { + panic(err) + } return path } + +func (c *Context) CurrentBinaryInvocation() string { + invocation, err := CurrentBinaryInvocation() + if err != nil { + panic(err) + } + + return invocation +} diff --git a/resources/completion.bash b/resources/completion.bash index f497515..e1a17c2 100644 --- a/resources/completion.bash +++ b/resources/completion.bash @@ -28,7 +28,7 @@ # versions are used) _{{ .App.HelpName }}_console() { # shellcheck disable=SC2068 - {{ .CurrentBinaryPath }} console $@ + {{ .CurrentBinaryInvocation }} console $@ } _complete_{{ .App.HelpName }}() { diff --git a/resources/completion.fish b/resources/completion.fish index 7c3a8b6..c82fa9e 100644 --- a/resources/completion.fish +++ b/resources/completion.fish @@ -27,14 +27,14 @@ function __complete_{{ .App.HelpName }} set -lx COMP_LINE (commandline -cp) test -z (commandline -ct) and set COMP_LINE "$COMP_LINE " - {{ .CurrentBinaryPath }} self:autocomplete + {{ .CurrentBinaryInvocation }} self:autocomplete end # this wrapper function allows us to call Symfony autocompletion letting it # knows how to call the `bin/console` using the Symfony CLI binary (to ensure # the right env and PHP versions are used) function __complete_{{ .App.HelpName }}_console - set -x _SF_CMD "{{ .CurrentBinaryPath }}" "console" + set -x _SF_CMD "{{ .CurrentBinaryInvocation }}" "console" _sf_console end diff --git a/resources/completion.zsh b/resources/completion.zsh index 4624b48..9abfeae 100644 --- a/resources/completion.zsh +++ b/resources/completion.zsh @@ -31,7 +31,7 @@ # versions are used) _{{ .App.HelpName }}_console() { # shellcheck disable=SC2068 - {{ .CurrentBinaryPath }} console $@ + {{ .CurrentBinaryInvocation }} console $@ } _complete_{{ .App.HelpName }}() {