Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement generic autocompletion #11

Merged
merged 2 commits into from
Jun 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading