Skip to content

Commit 3b6715d

Browse files
authored
Merge pull request #11 from tucksaun/feat/autocomplete
Implement generic autocompletion
2 parents 4351e24 + d0f9af8 commit 3b6715d

18 files changed

+834
-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: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,29 @@ func CurrentBinaryPath() (string, error) {
4242
}
4343
return argv0, nil
4444
}
45+
46+
func CurrentBinaryInvocation() (string, error) {
47+
if len(os.Args) == 0 || os.Args[0] == "" {
48+
return "", errors.New("no binary invokation found")
49+
}
50+
51+
return os.Args[0], nil
52+
}
53+
54+
func (c *Context) CurrentBinaryPath() string {
55+
path, err := CurrentBinaryPath()
56+
if err != nil {
57+
panic(err)
58+
}
59+
60+
return path
61+
}
62+
63+
func (c *Context) CurrentBinaryInvocation() string {
64+
invocation, err := CurrentBinaryInvocation()
65+
if err != nil {
66+
panic(err)
67+
}
68+
69+
return invocation
70+
}

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)