Skip to content

Commit

Permalink
Merge pull request #11 from tucksaun/feat/autocomplete
Browse files Browse the repository at this point in the history
Implement generic autocompletion
  • Loading branch information
fabpot authored Jun 12, 2024
2 parents 4351e24 + d0f9af8 commit 3b6715d
Show file tree
Hide file tree
Showing 18 changed files with 834 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
26 changes: 26 additions & 0 deletions binary.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,29 @@ 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, err := CurrentBinaryPath()
if err != nil {
panic(err)
}

return path
}

func (c *Context) CurrentBinaryInvocation() string {
invocation, err := CurrentBinaryInvocation()
if err != nil {
panic(err)
}

return invocation
}
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 3b6715d

Please sign in to comment.