8000 This PR provides generic autocompletion for every application using S… · symfony-cli/console@8ee5e6b · GitHub
[go: up one dir, main page]

Skip to content

Commit 8ee5e6b

Browse files
committed
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.
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)
1 parent 4351e24 commit 8ee5e6b

18 files changed

+814
-134
lines changed

application.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,8 @@ func (a *Application) setup() {
312312
a.prependFlag(HelpFlag)
313313
}
314314

315+
registerAutocompleteCommands(a)
316+
315317
for _, c := range a.Commands {
316318
if c.HelpName == "" {
317319
c.HelpName = fmt.Sprintf("%s %s", a.HelpName, c.FullName())

binary.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,9 @@ func CurrentBinaryPath() (string, error) {
4242
}
4343
return argv0, nil
4444
}
45+
46+
func (c *Context) CurrentBinaryPath() string {
47+
path, _ := CurrentBinaryPath()
48+
49+
return path
50+
}

command.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ type Command struct {
4848
DescriptionFunc DescriptionFunc
4949
// The category the command is part of
5050
Category string
51+
// The function to call when checking for shell command completions
52+
ShellComplete ShellCompleteFunc
5153
// An action to execute before any sub-subcommands are run, but after the context is ready
5254
// If a non-nil error is returned, no sub-subcommands are run
5355
Before BeforeFunc

completion.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
//go:build darwin || linux || freebsd || openbsd
2+
3+
package console
4+
5+
import (
6+
"fmt"
7+
"os"
8+
"runtime/debug"
9+
10+
"github.com/posener/complete/v2"
11+
)
12+
13+
func init() {
14+
for _, key := range []string{"COMP_LINE", "COMP_POINT", "COMP_DEBUG"} {
15+
if _, hasEnv := os.LookupEnv(key); hasEnv {
16+
// Disable Garbage collection for faster autocompletion
17+
debug.SetGCPercent(-1)
18+
return
19+
}
20+
}
21+
}
22+
23+
var autoCompleteCommand = &Command{
24+
Category: "self",
25+
Name: "autocomplete",
26+
Description: "Internal command to provide shell completion suggestions",
27+
Hidden: Hide,
28+
FlagParsing: FlagParsingSkippedAfterFirstArg,
29+
Args: ArgDefinition{
30+
&Arg{
31+
Slice: true,
32+
Optional: true,
33+
},
34+
},
35+
Action: AutocompleteAppAction,
36+
}
37+
38+
func registerAutocompleteCommands(a *Application) {
39+
if IsGoRun() {
40+
return
41+
}
42+
43+
a.Commands = append(
44+
[]*Command{shellAutoCompleteInstallCommand, autoCompleteCommand},
45+
a.Commands...,
46+
)
47+
}
48+
49+
func AutocompleteAppAction(c *Context) error {
50+
cmd := complete.Command{
51+
Flags: map[string]complete.Predictor{},
52+
Sub: map[string]*complete.Command{},
53+
}
54+
55+
// transpose registered commands and flags to posener/complete equivalence
56+
for _, command := range c.App.VisibleCommands() {
57+
subCmd := command.convertToPosenerCompleteCommand(c)
58+
59+
for _, name := range command.Names() {
60+
cmd.Sub[name] = &subCmd
61+
}
62+
}
63+
64+
for _, f := range c.App.VisibleFlags() {
65+
if vf, ok := f.(*verbosityFlag); ok {
66+
vf.addToPosenerFlags(c, cmd.Flags)
67+
continue
68+
}
69+
70+
predictor := ContextPredictor{f, c}
71+
72+
for _, name := range f.Names() {
73+
name = fmt.Sprintf("%s%s", prefixFor(name), name)
74+
cmd.Flags[name] = predictor
75+
}
76+
}
77+
78+
cmd.Complete(c.App.HelpName)
79+
return nil
80+
}
81+
82+
func (c *Command) convertToPosenerCompleteCommand(ctx *Context) complete.Command {
83+
command := complete.Command{
84+
Flags: map[string]complete.Predictor{},
85+
}
86+
87+
for _, f := range c.VisibleFlags() {
88+
for _, name := range f.Names() {
89+
name = fmt.Sprintf("%s%s", prefixFor(name), name)
90+
command.Flags[name] = ContextPredictor{f, ctx}
91+
}
92+
}
93+
94+
if len(c.Args) > 0 || c.ShellComplete != nil {
95+
command.Args = ContextPredictor{c, ctx}
96+
}
97+
98+
return command
99+
}
100+
101+
func (c *Command) PredictArgs(ctx *Context, prefix string) []string {
102+
if c.ShellComplete != nil {
103+
return c.ShellComplete(ctx, prefix)
104+
}
105+
106+
return nil
107+
}
108+
109+
type Predictor interface {
110+
PredictArgs(*Context, string) []string
111+
}
112+
113+
// ContextPredictor determines what terms can follow a command or a flag
114+
// It is used for autocompletion, given the last word in the already completed
115+
// command line, what words can complete it.
116+
type ContextPredictor struct {
117+
predictor Predictor
118+
ctx *Context
119+
}
120+
121+
// Predict invokes the predict function and implements the Predictor interface
122+
func (p ContextPredictor) Predict(prefix string) []string {
123+
return p.predictor.PredictArgs(p.ctx, prefix)
124+
}

completion_installer.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
//go:build darwin || linux || freebsd || openbsd
2+
3+
package console
4+
5+
import (
6+
"bytes"
7+
"embed"
8+
"fmt"
9+
"os"
10+
"path"
11+
"strings"
12+
"text/template"
13+
14+
"github.com/pkg/errors"
15+
"github.com/symfony-cli/terminal"
16+
)
17+
18+
// completionTemplates holds our shell completions templates.
19+
//
20+
//go:embed resources/completion.*
21+
var completionTemplates embed.FS
22+
23+
var shellAutoCompleteInstallCommand = &Command{
24+
Category: "self",
25+
Name: "completion",
26+
Aliases: []*Alias{
27+
{Name: "completion"},
28+
},
29+
Usage: "Dumps the completion script for the current shell",
30+
ShellComplete: func(*Context, string) []string {
31+
return []string{"bash", "zsh", "fish"}
32+
},
33+
Description: `The <info>{{.HelpName}}</> command dumps the shell completion script required
34+
to use shell autocompletion (currently, bash, zsh and fish completion are supported).
35+
36+
<comment>Static installation
37+
-------------------</>
38+
39+
Dump the script to a global completion file and restart your shell:
40+
41+
<info>{{.HelpName}} {{ call .Shell }} | sudo tee {{ call .CompletionFile }}</>
42+
43+
Or dump the script to a local file and source it:
44+
45+
<info>{{.HelpName}} {{ call .Shell }} > completion.sh</>
46+
47+
<comment># source the file whenever you use the project</>
48+
<info>source completion.sh</>
49+
50+
<comment># or add this line at the end of your "{{ call .RcFile }}" file:</>
51+
<info>source /path/to/completion.sh</>
52+
53+
<comment>Dynamic installation
54+
--------------------</>
55+
56+
Add this to the end of your shell configuration file (e.g. <info>"{{ call .RcFile }}"</>):
57+
58+
<info>eval "$({{.HelpName}} {{ call .Shell }})"</>`,
59+
DescriptionFunc: func(command *Command, application *Application) string {
60+
var buf bytes.Buffer
61+
62+
tpl := template.Must(template.New("description").Parse(command.Description))
63+
64+
if err := tpl.Execute(&buf, struct {
65+
// allows to directly access any field from the command inside the template
66+
*Command
67+
Shell func() string
68+
RcFile func() string
69+
CompletionFile func() string
70+
}{
71+
Command: command,
72+
Shell: guessShell,
73+
RcFile: func() string {
74+
switch guessShell() {
75+
case "fish":
76+
return "~/.config/fish/config.fish"
77+
case "zsh":
78+
return "~/.zshrc"
79+
default:
80+
return "~/.bashrc"
81+
}
82+
},
83+
CompletionFile: func() string {
84+
switch guessShell() {
85+
case "fish":
86+
return fmt.Sprintf("/etc/fish/completions/%s.fish", application.HelpName)
87+
case "zsh":
88+
return fmt.Sprintf("$fpath[1]/_%s", application.HelpName)
89+
default:
90+
return fmt.Sprintf("/etc/bash_completion.d/%s", application.HelpName)
91+
}
92+
},
93+
}); err != nil {
94+
panic(err)
95+
}
96+
97+
return buf.String()
98+
},
99+
Args: []*Arg{
100+
{
101+
Name: "shell",
102+
Description: `The shell type (e.g. "bash"), the value of the "$SHELL" env var will be used if this is not given`,
103+
Optional: true,
104+
},
105+
},
106+
Action: func(c *Context) error {
107+
shell := c.Args().Get("shell")
108+
if shell == "" {
109+
shell = guessShell()
110+
}
111+
112+
templates, err := template.ParseFS(completionTemplates, "resources/*")
113+
if err != nil {
114+
return errors.WithStack(err)
115+
}
116+
117+
if tpl := templates.Lookup(fmt.Sprintf("completion.%s", shell)); tpl != nil {
118+
return errors.WithStack(tpl.Execute(terminal.Stdout, c))
119+
}
120+
121+
var supportedShell []string
122+
123+
for _, tmpl := range templates.Templates() {
124+
if tmpl.Tree == nil || tmpl.Root == nil {
125+
continue
126+
}
127+
supportedShell = append(supportedShell, strings.TrimLeft(path.Ext(tmpl.Name()), "."))
128+
}
129+
130+
if shell == "" {
131+
return errors.Errorf(`shell not detected, supported shells: "%s"`, strings.Join(supportedShell, ", "))
132+
}
133+
134+
return errors.Errorf(`shell "%s" is not supported, supported shells: "%s"`, shell, strings.Join(supportedShell, ", "))
135+
},
136+
}
137+
138+
func guessShell() string {
139+
return path.Base(os.Getenv("SHELL"))
140+
}

completion_others.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
//go:build !darwin && !linux && !freebsd && !openbsd
2+
// +build !darwin,!linux,!freebsd,!openbsd
3+
4+
package console
5+
6+
const HasAutocompleteSupport = false
7+
8+
func IsAutocomplete(c *Command) bool {
9+
return false
10+
}

completion_unix.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
//go:build darwin || linux || freebsd || openbsd
2+
// +build darwin linux freebsd openbsd
3+
4+
package console
5+
6+
const SupportsAutocomplete = true
7+
8+
func IsAutocomplete(c *Command) bool {
9+
return c == autoCompleteCommand
10+
}

flag.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ func (f FlagsByName) Swap(i, j int) {
9292
// this interface be implemented.
9393
type Flag interface {
9494
fmt.Stringer
95+
96+
PredictArgs(*Context, string) []string
9597
Validate(*Context) error
9698
// Apply Flag settings to the given flag set
9799
Apply(*flag.FlagSet)

0 commit comments

Comments
 (0)
0