Skip to content

Commit

Permalink
This PR provides generic autocompletion for every application using S…
Browse files Browse the repository at this point in the history
…ymfony CLI's Console for Bash, ZSH, and Fish. To make it work locally one can use the instructions provided by `symfony help completion`.

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)
  • Loading branch information
tucksaun committed Jun 10, 2024
1 parent 4351e24 commit 8ee5e6b
Show file tree
Hide file tree
Showing 18 changed files with 814 additions and 134 deletions.
2 changes: 2 additions & 0 deletions application.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
6 changes: 6 additions & 0 deletions binary.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,9 @@ func CurrentBinaryPath() (string, error) {
}
return argv0, nil
}

func (c *Context) CurrentBinaryPath() string {
path, _ := CurrentBinaryPath()

return path
}
2 changes: 2 additions & 0 deletions command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
124 changes: 124 additions & 0 deletions completion.go
Original file line number Diff line number Diff line change
@@ -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)
}
140 changes: 140 additions & 0 deletions completion_installer.go
Original file line number Diff line number Diff line change
@@ -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 <info>{{.HelpName}}</> command dumps the shell completion script required
to use shell autocompletion (currently, bash, zsh and fish completion are supported).
<comment>Static installation
-------------------</>
Dump the script to a global completion file and restart your shell:
<info>{{.HelpName}} {{ call .Shell }} | sudo tee {{ call .CompletionFile }}</>
Or dump the script to a local file and source it:
<info>{{.HelpName}} {{ call .Shell }} > completion.sh</>
<comment># source the file whenever you use the project</>
<info>source completion.sh</>
<comment># or add this line at the end of your "{{ call .RcFile }}" file:</>
<info>source /path/to/completion.sh</>
<comment>Dynamic installation
--------------------</>
Add this to the end of your shell configuration file (e.g. <info>"{{ call .RcFile }}"</>):
<info>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"))
}
10 changes: 10 additions & 0 deletions completion_others.go
Original file line number Diff line number Diff line change
@@ -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
}
10 changes: 10 additions & 0 deletions completion_unix.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions flag.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading

0 comments on commit 8ee5e6b

Please sign in to comment.