feat: autocompletion installer

Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com>
This commit is contained in:
Alano Terblanche 2024-04-30 15:16:37 +02:00
parent 9861ce90fd
commit f6018613db
No known key found for this signature in database
GPG Key ID: 0E8FACD1BA98DE27
7 changed files with 1485 additions and 0 deletions

View File

@ -6,6 +6,7 @@ import (
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/command/builder"
"github.com/docker/cli/cli/command/checkpoint"
"github.com/docker/cli/cli/command/completion"
"github.com/docker/cli/cli/command/config"
"github.com/docker/cli/cli/command/container"
"github.com/docker/cli/cli/command/context"
@ -63,6 +64,9 @@ func AddCommands(cmd *cobra.Command, dockerCli command.Cli) {
stack.NewStackCommand(dockerCli),
swarm.NewSwarmCommand(dockerCli),
// completion command
completion.NewCompletionCommand(dockerCli),
// legacy commands may be hidden
hide(container.NewAttachCommand(dockerCli)),
hide(container.NewCommitCommand(dockerCli)),

View File

@ -0,0 +1,239 @@
package completion
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
type shellCompletionSetup interface {
// Generate completions for the Docker CLI based on the provided shell.
DockerCompletion(ctx context.Context, shell supportedCompletionShell) ([]byte, error)
// Set up completions for the Docker CLI for the provided shell.
//
// For zsh completions, this function should also configure the user's
// .zshrc file to load the completions correctly.
// Please see https://zsh.sourceforge.io/Doc/Release/Completion-System.html
// for more information.
InstallCompletions(ctx context.Context, shell supportedCompletionShell) error
// Get the completion directory for the provided shell.
GetCompletionDir(shell supportedCompletionShell) string
// Get the manual instructions for the provided shell.
GetManualInstructions(shell supportedCompletionShell) string
}
type supportedCompletionShell string
const (
bash supportedCompletionShell = "bash"
fish supportedCompletionShell = "fish"
zsh supportedCompletionShell = "zsh"
powershell supportedCompletionShell = "powershell"
)
func (s supportedCompletionShell) FileName() string {
switch s {
case zsh:
return "_docker"
case bash:
return "docker"
case fish:
return "docker.fish"
}
return ""
}
const (
zshrc = ".zshrc"
zshCompletionDir = ".docker/completions"
fishCompletionDir = ".config/fish/completions"
bashCompletionDir = ".local/share/bash-completion/completions"
)
// TODO: file permissions are difficult.
// Wondering if this should be 0644 or 0750
// From stackoverflow most mention a sane default for the home directory
// is 0755/0751.
const filePerm = 0755
type common struct {
command func(ctx context.Context, name string, arg ...string) *exec.Cmd
homeDirectory string
dockerCliBinary string
}
type unixShellSetup struct {
zshrc string
zshCompletionDir string
fishCompletionDir string
bashCompletionDir string
hasOhMyZsh bool
common
}
func unixDefaultShell() (supportedCompletionShell, error) {
currentShell := os.Getenv("SHELL")
if len(currentShell) == 0 {
return "", errors.New("SHELL environment variable not set")
}
t := strings.Split(currentShell, "/")
switch t[len(t)-1] {
case "bash":
return bash, nil
case "zsh":
return zsh, nil
case "fish":
return fish, nil
}
return "", errors.New("unsupported shell")
}
var _ shellCompletionSetup = &unixShellSetup{}
func NewUnixShellSetup(homeDirectory string, dockerCliBinary string) shellCompletionSetup {
zshrcFile := filepath.Join(homeDirectory, zshrc)
// override the default directory if ZDOTDIR is set
// if this is set, we assume the user has set up their own zshrc
// and we should append to that file instead
if zshroot := os.Getenv("ZDOTDIR"); zshroot != "" {
zshrcFile = filepath.Join(zshroot, zshrc)
}
var hasOhMyZsh bool
zshCompletionDir := filepath.Join(homeDirectory, zshCompletionDir)
// overide the default zsh completions directory if oh-my-zsh is installed
if ohmyzsh := os.Getenv("ZSH"); ohmyzsh != "" {
// ensure that the oh-my-zsh completions directory exists
if _, err := os.Stat(ohmyzsh); err == nil {
zshCompletionDir = filepath.Join(ohmyzsh, "completions")
hasOhMyZsh = true
}
}
return &unixShellSetup{
zshrc: zshrcFile,
zshCompletionDir: zshCompletionDir,
fishCompletionDir: filepath.Join(homeDirectory, fishCompletionDir),
bashCompletionDir: filepath.Join(homeDirectory, bashCompletionDir),
hasOhMyZsh: hasOhMyZsh,
common: common{
homeDirectory: homeDirectory,
dockerCliBinary: dockerCliBinary,
command: exec.CommandContext,
},
}
}
func (u *unixShellSetup) DockerCompletion(ctx context.Context, shell supportedCompletionShell) ([]byte, error) {
dockerCmd := u.command(ctx, u.dockerCliBinary, "completion", string(shell))
out, err := dockerCmd.Output()
if err != nil {
return nil, err
}
return out, nil
}
func (u *unixShellSetup) GetCompletionDir(shell supportedCompletionShell) string {
switch shell {
case zsh:
return u.zshCompletionDir
case fish:
return u.fishCompletionDir
case bash:
return u.bashCompletionDir
}
return ""
}
func (u *unixShellSetup) GetManualInstructions(shell supportedCompletionShell) string {
completionDir := u.GetCompletionDir(shell)
completionsFile := filepath.Join(completionDir, shell.FileName())
instructions := fmt.Sprintf(`mkdir -p %s && docker completion %s > %s`, completionDir, shell, completionsFile)
if shell == zsh && !u.hasOhMyZsh {
instructions += "\n"
instructions += fmt.Sprintf("cat <<EOT >> %s\n"+
"# The following lines have been added by Docker to enable Docker CLI completions.\n"+
"fpath=(%s $fpath)\n"+
"autoload -Uz compinit\n"+
"compinit\n"+
"EOT\n"+
"# End of Docker Completions", u.zshrc, completionsFile)
}
return instructions
}
func (u *unixShellSetup) InstallCompletions(ctx context.Context, shell supportedCompletionShell) error {
completionDir := u.GetCompletionDir(shell)
if err := os.MkdirAll(completionDir, filePerm); err != nil {
return err
}
completionFile := filepath.Join(completionDir, shell.FileName())
_ = os.Remove(completionFile)
completions, err := u.DockerCompletion(ctx, shell)
if err != nil {
return err
}
f, err := os.OpenFile(completionFile, os.O_CREATE|os.O_WRONLY, filePerm)
if err != nil {
return err
}
defer f.Close()
if _, err = f.Write(completions); err != nil {
return err
}
// only configure fpath for zsh if oh-my-zsh is not installed
if shell == zsh && !u.hasOhMyZsh {
// This should error if it does not exist.
zshrcContent, err := os.ReadFile(u.zshrc)
if err != nil {
// TODO: what should we do here? The error message might not be too helpful.
return fmt.Errorf("could not open %s. Please ensure that your .zshrc file is set up correctly before continuing.", u.zshrc)
}
fpath := fmt.Sprintf("fpath=(%s $fpath)", completionDir)
autoload := "autoload -Uz compinit"
compinit := "compinit"
// if fpath is already in the .zshrc file, we don't need to add it again
if strings.Contains(string(zshrcContent), fpath) {
return nil
}
// Only append to .zshrc when it exists.
f, err = os.OpenFile(u.zshrc, os.O_APPEND|os.O_WRONLY, os.ModeAppend)
if err != nil {
return err
}
defer f.Close()
zshrcFpath := fmt.Sprintf("# The following lines have been added by Docker Desktop to enable Docker CLI completions.\n"+
"%s\n"+
"%s\n"+
"%s\n"+
"# End of Docker CLI completions\n", fpath, autoload, compinit)
_, err = f.WriteString(zshrcFpath)
if err != nil {
return err
}
}
return nil
}

View File

@ -0,0 +1,411 @@
package completion
import (
"context"
"embed"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
)
type testFuncs string
const (
testDockerCompletions testFuncs = "TestDockerCompletions"
)
//go:embed testdata
var fixtures embed.FS
// fakeExecCommand is a helper function that hooks
// the current test binary into an os/exec cmd.Run() call
// allowing us to mock out third party dependencies called through os/exec.
//
// testBinary is the current test binary that is running, can be accessed through os.Args[0]
// funcName is the name of the function you want to run as a sub-process of the current test binary
//
// The call path is as follows:
// - Register the function you want to run through TestMain
// - Call the cmd.Run() function from the returned exec.Cmd
// - TestMain will execute the function as a sub-process of the current test binary
func fakeExecCommand(t *testing.T, testBinary string, funcName testFuncs) func(ctx context.Context, command string, args ...string) *exec.Cmd {
t.Helper()
return func(ctx context.Context, command string, args ...string) *exec.Cmd {
cmd := exec.Command(testBinary, append([]string{command}, args...)...)
cmd.Env = append(os.Environ(), "TEST_MAIN_FUNC="+string(funcName))
return cmd
}
}
// TestMain is setup here to act as a dispatcher
// for functions hooked into the test binary through
// fakeExecCommand.
func TestMain(m *testing.M) {
switch testFuncs(os.Getenv("TEST_MAIN_FUNC")) {
case testDockerCompletions:
FakeDockerCompletionsProcess()
default:
os.Exit(m.Run())
}
}
// this is a test function that will only be run when
// fakeExecCommand is hooked into a cmd.Run()/cmd.Output call
// with the funcName as "TestDockerCompletions"
// TestMain executes this function as a sub-process of the current
// test binary.
func FakeDockerCompletionsProcess() {
s := supportedCompletionShell(os.Args[3])
if s == "" {
panic("shell not provided")
}
completions, err := fixtures.ReadFile("testdata/docker." + string(s))
if err != nil {
panic(err)
}
_, err = fmt.Fprint(os.Stdout, string(completions))
if err != nil {
panic(err)
}
os.Exit(0)
}
func TestDockerCompletion(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
ds := NewUnixShellSetup("", "docker").(*unixShellSetup)
ds.command = fakeExecCommand(t, os.Args[0], testDockerCompletions)
t.Run("zsh completion", func(t *testing.T) {
completions, err := ds.DockerCompletion(ctx, zsh)
assert.NilError(t, err, "expected docker completions to not error, got %s", err)
assert.Check(t, len(completions) > 0, "expected docker completions to not be empty")
expected, err := fixtures.ReadFile("testdata/docker.zsh")
assert.NilError(t, err)
assert.Equal(t, string(expected), string(completions), "docker.zsh fixture did not match docker completion output")
})
t.Run("bash completion", func(t *testing.T) {
completions, err := ds.DockerCompletion(ctx, bash)
assert.NilError(t, err)
assert.Check(t, len(completions) > 0, "expected docker completions to not be empty")
expected, err := fixtures.ReadFile("testdata/docker.bash")
assert.NilError(t, err)
assert.Equal(t, string(expected), string(completions), "docker.bash fixtures did not match docker completion output")
})
t.Run("fish completion", func(t *testing.T) {
completions, err := ds.DockerCompletion(ctx, fish)
assert.NilError(t, err)
assert.Check(t, len(completions) > 0, "expected docker completions to not be empty")
expected, err := fixtures.ReadFile("testdata/docker.fish")
assert.NilError(t, err)
assert.Equal(t, string(expected), string(completions), "docker.fish fixtures did not match docker completion output")
})
}
func TestUnixDefaultShell(t *testing.T) {
for _, tc := range []struct {
desc string
path string
expected supportedCompletionShell
expectedErr string
}{
{
desc: "bash",
path: "/bin/bash",
expected: bash,
expectedErr: "",
},
{
desc: "zsh",
path: "/bin/zsh",
expected: zsh,
expectedErr: "",
},
{
desc: "fish",
path: "/bin/fish",
expected: fish,
expectedErr: "",
},
{
desc: "homebrew bash",
path: "/opt/homebrew/bin/bash",
expected: bash,
expectedErr: "",
},
{
desc: "homebrew zsh",
path: "/opt/homebrew/bin/zsh",
expected: zsh,
expectedErr: "",
},
{
desc: "homebrew fish",
path: "/opt/homebrew/bin/fish",
expected: fish,
expectedErr: "",
},
{
desc: "unsupported shell",
path: "/bin/unsupported",
expected: "",
expectedErr: "unsupported shell",
},
{
desc: "empty shell",
path: "",
expected: "",
expectedErr: "SHELL environment variable not set",
},
} {
t.Run(tc.desc, func(t *testing.T) {
t.Setenv("SHELL", tc.path)
s, err := unixDefaultShell()
if tc.expectedErr != "" {
assert.Check(t, is.ErrorContains(err, tc.expectedErr))
}
assert.Equal(t, tc.expected, s)
})
}
}
func TestInstallCompletions(t *testing.T) {
zshSetup := func(t *testing.T, ds *unixShellSetup) *os.File {
t.Helper()
zshrcFile, err := os.OpenFile(ds.zshrc, os.O_RDWR|os.O_CREATE, filePerm)
assert.NilError(t, err)
t.Cleanup(func() {
zshrcFile.Close()
})
_, err = os.Stat(ds.zshrc)
assert.NilError(t, err, "expected zshrc file to exist")
return zshrcFile
}
hasZshCompletions := func(t *testing.T, ds *unixShellSetup) {
t.Helper()
zshrcContent, err := os.ReadFile(ds.zshrc)
assert.NilError(t, err)
assert.Check(t, is.Contains(string(zshrcContent), fmt.Sprintf("fpath=(%s $fpath)", ds.zshCompletionDir)))
_, err = os.Stat(filepath.Join(ds.zshCompletionDir, zsh.FileName()))
assert.NilError(t, err, "expected zsh completions directory to exist")
completions, err := os.ReadFile(filepath.Join(ds.zshCompletionDir, zsh.FileName()))
assert.NilError(t, err)
zshFixture, err := fixtures.ReadFile("testdata/docker." + string(zsh))
assert.NilError(t, err)
assert.Equal(t, string(zshFixture), string(completions))
}
setup := func(t *testing.T) *unixShellSetup {
t.Helper()
tmphome := t.TempDir()
ds := NewUnixShellSetup(tmphome, "docker").(*unixShellSetup)
ds.command = fakeExecCommand(t, os.Args[0], testDockerCompletions)
return ds
}
testcases := []struct {
shell supportedCompletionShell
desc string
setupFunc func(t *testing.T) *unixShellSetup
assertFunc func(t *testing.T, ds *unixShellSetup)
}{
{
shell: zsh,
desc: "zsh completions",
setupFunc: func(t *testing.T) *unixShellSetup {
t.Helper()
ds := setup(t)
zshSetup(t, ds)
return ds
},
assertFunc: hasZshCompletions,
},
{
shell: zsh,
desc: "zsh completions with ZDOTDIR",
setupFunc: func(t *testing.T) *unixShellSetup {
t.Helper()
zdotdir := filepath.Join(t.TempDir(), "zdotdir")
assert.NilError(t, os.MkdirAll(zdotdir, filePerm))
t.Setenv("ZDOTDIR", zdotdir)
ds := setup(t)
zshSetup(t, ds)
return ds
},
assertFunc: func(t *testing.T, ds *unixShellSetup) {
t.Helper()
hasZshCompletions(t, ds)
assert.Check(t, is.Contains(os.Getenv("ZDOTDIR"), "zdotdir"))
assert.Equal(t, ds.zshrc, filepath.Join(os.Getenv("ZDOTDIR"), ".zshrc"))
},
},
{
shell: zsh,
desc: "existing fpath in zshrc",
setupFunc: func(t *testing.T) *unixShellSetup {
t.Helper()
ds := setup(t)
zshrcFile := zshSetup(t, ds)
_, err := fmt.Fprintf(zshrcFile, "fpath=(%s $fpath)", ds.zshCompletionDir)
assert.NilError(t, err)
return ds
},
assertFunc: func(t *testing.T, ds *unixShellSetup) {
t.Helper()
hasZshCompletions(t, ds)
zshrcFile, err := os.ReadFile(ds.zshrc)
assert.NilError(t, err)
assert.Equal(t, 1, strings.Count(string(zshrcFile), fmt.Sprintf("fpath=(%s $fpath)", ds.zshCompletionDir)))
},
},
{
shell: bash,
desc: "bash completions",
setupFunc: setup,
assertFunc: func(t *testing.T, ds *unixShellSetup) {
t.Helper()
_, err := os.Stat(filepath.Join(ds.bashCompletionDir, bash.FileName()))
assert.NilError(t, err)
completions, err := os.ReadFile(filepath.Join(ds.bashCompletionDir, bash.FileName()))
assert.NilError(t, err)
bashFixture, err := fixtures.ReadFile("testdata/docker." + string(bash))
assert.NilError(t, err)
assert.Equal(t, string(bashFixture), string(completions))
},
},
{
shell: fish,
desc: "fish completions",
setupFunc: setup,
assertFunc: func(t *testing.T, ds *unixShellSetup) {
t.Helper()
_, err := os.Stat(filepath.Join(ds.fishCompletionDir, fish.FileName()))
assert.NilError(t, err)
completions, err := os.ReadFile(filepath.Join(ds.fishCompletionDir, fish.FileName()))
assert.NilError(t, err)
fishFixture, err := fixtures.ReadFile("testdata/docker." + string(fish))
assert.NilError(t, err)
assert.Equal(t, string(fishFixture), string(completions))
},
},
{
shell: zsh,
desc: "zsh with oh-my-zsh",
setupFunc: func(t *testing.T) *unixShellSetup {
t.Helper()
tmphome := t.TempDir()
ohmyzsh := filepath.Join(tmphome, ".oh-my-zsh")
assert.NilError(t, os.MkdirAll(ohmyzsh, filePerm))
t.Setenv("ZSH", ohmyzsh)
ds := NewUnixShellSetup(tmphome, "docker").(*unixShellSetup)
ds.command = fakeExecCommand(t, os.Args[0], testDockerCompletions)
zshSetup(t, ds)
return ds
},
assertFunc: func(t *testing.T, ds *unixShellSetup) {
t.Helper()
assert.Equal(t, filepath.Join(ds.homeDirectory, ".oh-my-zsh/completions"), ds.zshCompletionDir)
_, err := os.Stat(filepath.Join(ds.zshCompletionDir, zsh.FileName()))
assert.NilError(t, err)
completions, err := os.ReadFile(filepath.Join(ds.zshCompletionDir, zsh.FileName()))
assert.NilError(t, err)
zshFixture, err := fixtures.ReadFile("testdata/docker." + string(zsh))
assert.NilError(t, err)
assert.Equal(t, string(zshFixture), string(completions))
},
},
{
shell: zsh,
desc: "should fallback to zsh when oh-my-zsh directory does not exist",
setupFunc: func(t *testing.T) *unixShellSetup {
t.Helper()
tmphome := t.TempDir()
t.Setenv("ZSH", filepath.Join(tmphome, ".oh-my-zsh"))
ds := NewUnixShellSetup(tmphome, "docker").(*unixShellSetup)
ds.command = fakeExecCommand(t, os.Args[0], testDockerCompletions)
zshSetup(t, ds)
return ds
},
assertFunc: func(t *testing.T, ds *unixShellSetup) {
t.Helper()
assert.Check(t, !strings.Contains(ds.zshCompletionDir, ".oh-my-zsh"))
hasZshCompletions(t, ds)
},
},
}
for _, tc := range testcases {
t.Run(tc.desc, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
ds := tc.setupFunc(t)
assert.NilError(t, ds.InstallCompletions(ctx, tc.shell))
tc.assertFunc(t, ds)
})
}
}
func TestGetCompletionDir(t *testing.T) {
t.Run("standard shells", func(t *testing.T) {
tmphome := t.TempDir()
ds := NewUnixShellSetup(tmphome, "docker")
assert.Equal(t, filepath.Join(tmphome, zshCompletionDir), ds.GetCompletionDir(zsh))
assert.Equal(t, filepath.Join(tmphome, fishCompletionDir), ds.GetCompletionDir(fish))
assert.Equal(t, filepath.Join(tmphome, bashCompletionDir), ds.GetCompletionDir(bash))
assert.Equal(t, "", ds.GetCompletionDir(supportedCompletionShell("unsupported")))
})
t.Run("oh-my-zsh", func(t *testing.T) {
tmphome := t.TempDir()
ohMyZshTmpDir := filepath.Join(tmphome, ".oh-my-zsh")
assert.NilError(t, os.MkdirAll(ohMyZshTmpDir, filePerm))
t.Setenv("ZSH", ohMyZshTmpDir)
ds := NewUnixShellSetup(tmphome, "docker")
assert.Equal(t, filepath.Join(ohMyZshTmpDir, "completions"), ds.GetCompletionDir(zsh))
})
}

View File

@ -0,0 +1,46 @@
package completion
import (
"fmt"
"os"
"github.com/docker/cli/cli"
"github.com/docker/cli/cli/command"
"github.com/spf13/cobra"
)
func NewCompletionCommand(dockerCli command.Cli) *cobra.Command {
cmd := &cobra.Command{
Use: "completion [bash|zsh|fish|powershell|install]",
Short: "Output shell completion code for the specified shell (bash or zsh)",
Args: cli.RequiresMaxArgs(1),
ValidArgs: []string{"bash", "zsh", "fish", "powershell", "install"},
DisableFlagParsing: false,
RunE: func(cmd *cobra.Command, args []string) error {
shellSetup := NewUnixShellSetup("", "docker")
if cmd.Flag("manual").Changed {
_, _ = fmt.Fprint(dockerCli.Out(), shellSetup.GetManualInstructions(supportedCompletionShell(args[0])))
return nil
}
switch args[0] {
case "install":
return shellSetup.InstallCompletions(cmd.Context(), supportedCompletionShell(os.Getenv("SHELL")))
case "bash":
return cmd.GenBashCompletionV2(dockerCli.Out(), true)
case "zsh":
return cmd.GenZshCompletion(dockerCli.Out())
case "fish":
return cmd.GenFishCompletion(dockerCli.Out(), true)
default:
return command.ShowHelp(dockerCli.Err())(cmd, args)
}
},
}
cmd.PersistentFlags().Bool("manual", false, "Display instructions for installing autocompletion")
return cmd
}

View File

@ -0,0 +1,338 @@
# bash completion V2 for docker -*- shell-script -*-
__docker_debug()
{
if [[ -n ${BASH_COMP_DEBUG_FILE-} ]]; then
echo "$*" >> "${BASH_COMP_DEBUG_FILE}"
fi
}
# Macs have bash3 for which the bash-completion package doesn't include
# _init_completion. This is a minimal version of that function.
__docker_init_completion()
{
COMPREPLY=()
_get_comp_words_by_ref "$@" cur prev words cword
}
# This function calls the docker program to obtain the completion
# results and the directive. It fills the 'out' and 'directive' vars.
__docker_get_completion_results() {
local requestComp lastParam lastChar args
# Prepare the command to request completions for the program.
# Calling ${words[0]} instead of directly docker allows handling aliases
args=("${words[@]:1}")
requestComp="${words[0]} __completeNoDesc ${args[*]}"
lastParam=${words[$((${#words[@]}-1))]}
lastChar=${lastParam:$((${#lastParam}-1)):1}
__docker_debug "lastParam ${lastParam}, lastChar ${lastChar}"
if [[ -z ${cur} && ${lastChar} != = ]]; then
# If the last parameter is complete (there is a space following it)
# We add an extra empty parameter so we can indicate this to the go method.
__docker_debug "Adding extra empty parameter"
requestComp="${requestComp} ''"
fi
# When completing a flag with an = (e.g., docker -n=<TAB>)
# bash focuses on the part after the =, so we need to remove
# the flag part from $cur
if [[ ${cur} == -*=* ]]; then
cur="${cur#*=}"
fi
__docker_debug "Calling ${requestComp}"
# Use eval to handle any environment variables and such
out=$(eval "${requestComp}" 2>/dev/null)
# Extract the directive integer at the very end of the output following a colon (:)
directive=${out##*:}
# Remove the directive
out=${out%:*}
if [[ ${directive} == "${out}" ]]; then
# There is not directive specified
directive=0
fi
__docker_debug "The completion directive is: ${directive}"
__docker_debug "The completions are: ${out}"
}
__docker_process_completion_results() {
local shellCompDirectiveError=1
local shellCompDirectiveNoSpace=2
local shellCompDirectiveNoFileComp=4
local shellCompDirectiveFilterFileExt=8
local shellCompDirectiveFilterDirs=16
local shellCompDirectiveKeepOrder=32
if (((directive & shellCompDirectiveError) != 0)); then
# Error code. No completion.
__docker_debug "Received error from custom completion go code"
return
else
if (((directive & shellCompDirectiveNoSpace) != 0)); then
if [[ $(type -t compopt) == builtin ]]; then
__docker_debug "Activating no space"
compopt -o nospace
else
__docker_debug "No space directive not supported in this version of bash"
fi
fi
if (((directive & shellCompDirectiveKeepOrder) != 0)); then
if [[ $(type -t compopt) == builtin ]]; then
# no sort isn't supported for bash less than < 4.4
if [[ ${BASH_VERSINFO[0]} -lt 4 || ( ${BASH_VERSINFO[0]} -eq 4 && ${BASH_VERSINFO[1]} -lt 4 ) ]]; then
__docker_debug "No sort directive not supported in this version of bash"
else
__docker_debug "Activating keep order"
compopt -o nosort
fi
else
__docker_debug "No sort directive not supported in this version of bash"
fi
fi
if (((directive & shellCompDirectiveNoFileComp) != 0)); then
if [[ $(type -t compopt) == builtin ]]; then
__docker_debug "Activating no file completion"
compopt +o default
else
__docker_debug "No file completion directive not supported in this version of bash"
fi
fi
fi
# Separate activeHelp from normal completions
local completions=()
local activeHelp=()
__docker_extract_activeHelp
if (((directive & shellCompDirectiveFilterFileExt) != 0)); then
# File extension filtering
local fullFilter filter filteringCmd
# Do not use quotes around the $completions variable or else newline
# characters will be kept.
for filter in ${completions[*]}; do
fullFilter+="$filter|"
done
filteringCmd="_filedir $fullFilter"
__docker_debug "File filtering command: $filteringCmd"
$filteringCmd
elif (((directive & shellCompDirectiveFilterDirs) != 0)); then
# File completion for directories only
local subdir
subdir=${completions[0]}
if [[ -n $subdir ]]; then
__docker_debug "Listing directories in $subdir"
pushd "$subdir" >/dev/null 2>&1 && _filedir -d && popd >/dev/null 2>&1 || return
else
__docker_debug "Listing directories in ."
_filedir -d
fi
else
__docker_handle_completion_types
fi
__docker_handle_special_char "$cur" :
__docker_handle_special_char "$cur" =
# Print the activeHelp statements before we finish
if ((${#activeHelp[*]} != 0)); then
printf "\n";
printf "%s\n" "${activeHelp[@]}"
printf "\n"
# The prompt format is only available from bash 4.4.
# We test if it is available before using it.
if (x=${PS1@P}) 2> /dev/null; then
printf "%s" "${PS1@P}${COMP_LINE[@]}"
else
# Can't print the prompt. Just print the
# text the user had typed, it is workable enough.
printf "%s" "${COMP_LINE[@]}"
fi
fi
}
# Separate activeHelp lines from real completions.
# Fills the $activeHelp and $completions arrays.
__docker_extract_activeHelp() {
local activeHelpMarker="_activeHelp_ "
local endIndex=${#activeHelpMarker}
while IFS='' read -r comp; do
if [[ ${comp:0:endIndex} == $activeHelpMarker ]]; then
comp=${comp:endIndex}
__docker_debug "ActiveHelp found: $comp"
if [[ -n $comp ]]; then
activeHelp+=("$comp")
fi
else
# Not an activeHelp line but a normal completion
completions+=("$comp")
fi
done <<<"${out}"
}
__docker_handle_completion_types() {
__docker_debug "__docker_handle_completion_types: COMP_TYPE is $COMP_TYPE"
case $COMP_TYPE in
37|42)
# Type: menu-complete/menu-complete-backward and insert-completions
# If the user requested inserting one completion at a time, or all
# completions at once on the command-line we must remove the descriptions.
# https://github.com/spf13/cobra/issues/1508
local tab=$'\t' comp
while IFS='' read -r comp; do
[[ -z $comp ]] && continue
# Strip any description
comp=${comp%%$tab*}
# Only consider the completions that match
if [[ $comp == "$cur"* ]]; then
COMPREPLY+=("$comp")
fi
done < <(printf "%s\n" "${completions[@]}")
;;
*)
# Type: complete (normal completion)
__docker_handle_standard_completion_case
;;
esac
}
__docker_handle_standard_completion_case() {
local tab=$'\t' comp
# Short circuit to optimize if we don't have descriptions
if [[ "${completions[*]}" != *$tab* ]]; then
IFS=$'\n' read -ra COMPREPLY -d '' < <(compgen -W "${completions[*]}" -- "$cur")
return 0
fi
local longest=0
local compline
# Look for the longest completion so that we can format things nicely
while IFS='' read -r compline; do
[[ -z $compline ]] && continue
# Strip any description before checking the length
comp=${compline%%$tab*}
# Only consider the completions that match
[[ $comp == "$cur"* ]] || continue
COMPREPLY+=("$compline")
if ((${#comp}>longest)); then
longest=${#comp}
fi
done < <(printf "%s\n" "${completions[@]}")
# If there is a single completion left, remove the description text
if ((${#COMPREPLY[*]} == 1)); then
__docker_debug "COMPREPLY[0]: ${COMPREPLY[0]}"
comp="${COMPREPLY[0]%%$tab*}"
__docker_debug "Removed description from single completion, which is now: ${comp}"
COMPREPLY[0]=$comp
else # Format the descriptions
__docker_format_comp_descriptions $longest
fi
}
__docker_handle_special_char()
{
local comp="$1"
local char=$2
if [[ "$comp" == *${char}* && "$COMP_WORDBREAKS" == *${char}* ]]; then
local word=${comp%"${comp##*${char}}"}
local idx=${#COMPREPLY[*]}
while ((--idx >= 0)); do
COMPREPLY[idx]=${COMPREPLY[idx]#"$word"}
done
fi
}
__docker_format_comp_descriptions()
{
local tab=$'\t'
local comp desc maxdesclength
local longest=$1
local i ci
for ci in ${!COMPREPLY[*]}; do
comp=${COMPREPLY[ci]}
# Properly format the description string which follows a tab character if there is one
if [[ "$comp" == *$tab* ]]; then
__docker_debug "Original comp: $comp"
desc=${comp#*$tab}
comp=${comp%%$tab*}
# $COLUMNS stores the current shell width.
# Remove an extra 4 because we add 2 spaces and 2 parentheses.
maxdesclength=$(( COLUMNS - longest - 4 ))
# Make sure we can fit a description of at least 8 characters
# if we are to align the descriptions.
if ((maxdesclength > 8)); then
# Add the proper number of spaces to align the descriptions
for ((i = ${#comp} ; i < longest ; i++)); do
comp+=" "
done
else
# Don't pad the descriptions so we can fit more text after the completion
maxdesclength=$(( COLUMNS - ${#comp} - 4 ))
fi
# If there is enough space for any description text,
# truncate the descriptions that are too long for the shell width
if ((maxdesclength > 0)); then
if ((${#desc} > maxdesclength)); then
desc=${desc:0:$(( maxdesclength - 1 ))}
desc+="…"
fi
comp+=" ($desc)"
fi
COMPREPLY[ci]=$comp
__docker_debug "Final comp: $comp"
fi
done
}
__start_docker()
{
local cur prev words cword split
COMPREPLY=()
# Call _init_completion from the bash-completion package
# to prepare the arguments properly
if declare -F _init_completion >/dev/null 2>&1; then
_init_completion -n =: || return
else
__docker_init_completion -n =: || return
fi
__docker_debug
__docker_debug "========= starting completion logic =========="
__docker_debug "cur is ${cur}, words[*] is ${words[*]}, #words[@] is ${#words[@]}, cword is $cword"
# The user could have moved the cursor backwards on the command-line.
# We need to trigger completion from the $cword location, so we need
# to truncate the command-line ($words) up to the $cword location.
words=("${words[@]:0:$cword+1}")
__docker_debug "Truncated words[*]: ${words[*]},"
local out directive
__docker_get_completion_results
__docker_process_completion_results
}
if [[ $(type -t compopt) = "builtin" ]]; then
complete -o default -F __start_docker docker
else
complete -o default -o nospace -F __start_docker docker
fi
# ex: ts=4 sw=4 et filetype=sh

View File

@ -0,0 +1,235 @@
# fish completion for docker -*- shell-script -*-
function __docker_debug
set -l file "$BASH_COMP_DEBUG_FILE"
if test -n "$file"
echo "$argv" >> $file
end
end
function __docker_perform_completion
__docker_debug "Starting __docker_perform_completion"
# Extract all args except the last one
set -l args (commandline -opc)
# Extract the last arg and escape it in case it is a space
set -l lastArg (string escape -- (commandline -ct))
__docker_debug "args: $args"
__docker_debug "last arg: $lastArg"
# Disable ActiveHelp which is not supported for fish shell
set -l requestComp "DOCKER_ACTIVE_HELP=0 $args[1] __completeNoDesc $args[2..-1] $lastArg"
__docker_debug "Calling $requestComp"
set -l results (eval $requestComp 2> /dev/null)
# Some programs may output extra empty lines after the directive.
# Let's ignore them or else it will break completion.
# Ref: https://github.com/spf13/cobra/issues/1279
for line in $results[-1..1]
if test (string trim -- $line) = ""
# Found an empty line, remove it
set results $results[1..-2]
else
# Found non-empty line, we have our proper output
break
end
end
set -l comps $results[1..-2]
set -l directiveLine $results[-1]
# For Fish, when completing a flag with an = (e.g., <program> -n=<TAB>)
# completions must be prefixed with the flag
set -l flagPrefix (string match -r -- '-.*=' "$lastArg")
__docker_debug "Comps: $comps"
__docker_debug "DirectiveLine: $directiveLine"
__docker_debug "flagPrefix: $flagPrefix"
for comp in $comps
printf "%s%s\n" "$flagPrefix" "$comp"
end
printf "%s\n" "$directiveLine"
end
# this function limits calls to __docker_perform_completion, by caching the result behind $__docker_perform_completion_once_result
function __docker_perform_completion_once
__docker_debug "Starting __docker_perform_completion_once"
if test -n "$__docker_perform_completion_once_result"
__docker_debug "Seems like a valid result already exists, skipping __docker_perform_completion"
return 0
end
set --global __docker_perform_completion_once_result (__docker_perform_completion)
if test -z "$__docker_perform_completion_once_result"
__docker_debug "No completions, probably due to a failure"
return 1
end
__docker_debug "Performed completions and set __docker_perform_completion_once_result"
return 0
end
# this function is used to clear the $__docker_perform_completion_once_result variable after completions are run
function __docker_clear_perform_completion_once_result
__docker_debug ""
__docker_debug "========= clearing previously set __docker_perform_completion_once_result variable =========="
set --erase __docker_perform_completion_once_result
__docker_debug "Successfully erased the variable __docker_perform_completion_once_result"
end
function __docker_requires_order_preservation
__docker_debug ""
__docker_debug "========= checking if order preservation is required =========="
__docker_perform_completion_once
if test -z "$__docker_perform_completion_once_result"
__docker_debug "Error determining if order preservation is required"
return 1
end
set -l directive (string sub --start 2 $__docker_perform_completion_once_result[-1])
__docker_debug "Directive is: $directive"
set -l shellCompDirectiveKeepOrder 32
set -l keeporder (math (math --scale 0 $directive / $shellCompDirectiveKeepOrder) % 2)
__docker_debug "Keeporder is: $keeporder"
if test $keeporder -ne 0
__docker_debug "This does require order preservation"
return 0
end
__docker_debug "This doesn't require order preservation"
return 1
end
# This function does two things:
# - Obtain the completions and store them in the global __docker_comp_results
# - Return false if file completion should be performed
function __docker_prepare_completions
__docker_debug ""
__docker_debug "========= starting completion logic =========="
# Start fresh
set --erase __docker_comp_results
__docker_perform_completion_once
__docker_debug "Completion results: $__docker_perform_completion_once_result"
if test -z "$__docker_perform_completion_once_result"
__docker_debug "No completion, probably due to a failure"
# Might as well do file completion, in case it helps
return 1
end
set -l directive (string sub --start 2 $__docker_perform_completion_once_result[-1])
set --global __docker_comp_results $__docker_perform_completion_once_result[1..-2]
__docker_debug "Completions are: $__docker_comp_results"
__docker_debug "Directive is: $directive"
set -l shellCompDirectiveError 1
set -l shellCompDirectiveNoSpace 2
set -l shellCompDirectiveNoFileComp 4
set -l shellCompDirectiveFilterFileExt 8
set -l shellCompDirectiveFilterDirs 16
if test -z "$directive"
set directive 0
end
set -l compErr (math (math --scale 0 $directive / $shellCompDirectiveError) % 2)
if test $compErr -eq 1
__docker_debug "Received error directive: aborting."
# Might as well do file completion, in case it helps
return 1
end
set -l filefilter (math (math --scale 0 $directive / $shellCompDirectiveFilterFileExt) % 2)
set -l dirfilter (math (math --scale 0 $directive / $shellCompDirectiveFilterDirs) % 2)
if test $filefilter -eq 1; or test $dirfilter -eq 1
__docker_debug "File extension filtering or directory filtering not supported"
# Do full file completion instead
return 1
end
set -l nospace (math (math --scale 0 $directive / $shellCompDirectiveNoSpace) % 2)
set -l nofiles (math (math --scale 0 $directive / $shellCompDirectiveNoFileComp) % 2)
__docker_debug "nospace: $nospace, nofiles: $nofiles"
# If we want to prevent a space, or if file completion is NOT disabled,
# we need to count the number of valid completions.
# To do so, we will filter on prefix as the completions we have received
# may not already be filtered so as to allow fish to match on different
# criteria than the prefix.
if test $nospace -ne 0; or test $nofiles -eq 0
set -l prefix (commandline -t | string escape --style=regex)
__docker_debug "prefix: $prefix"
set -l completions (string match -r -- "^$prefix.*" $__docker_comp_results)
set --global __docker_comp_results $completions
__docker_debug "Filtered completions are: $__docker_comp_results"
# Important not to quote the variable for count to work
set -l numComps (count $__docker_comp_results)
__docker_debug "numComps: $numComps"
if test $numComps -eq 1; and test $nospace -ne 0
# We must first split on \t to get rid of the descriptions to be
# able to check what the actual completion will be.
# We don't need descriptions anyway since there is only a single
# real completion which the shell will expand immediately.
set -l split (string split --max 1 \t $__docker_comp_results[1])
# Fish won't add a space if the completion ends with any
# of the following characters: @=/:.,
set -l lastChar (string sub -s -1 -- $split)
if not string match -r -q "[@=/:.,]" -- "$lastChar"
# In other cases, to support the "nospace" directive we trick the shell
# by outputting an extra, longer completion.
__docker_debug "Adding second completion to perform nospace directive"
set --global __docker_comp_results $split[1] $split[1].
__docker_debug "Completions are now: $__docker_comp_results"
end
end
if test $numComps -eq 0; and test $nofiles -eq 0
# To be consistent with bash and zsh, we only trigger file
# completion when there are no other completions
__docker_debug "Requesting file completion"
return 1
end
end
return 0
end
# Since Fish completions are only loaded once the user triggers them, we trigger them ourselves
# so we can properly delete any completions provided by another script.
# Only do this if the program can be found, or else fish may print some errors; besides,
# the existing completions will only be loaded if the program can be found.
if type -q "docker"
# The space after the program name is essential to trigger completion for the program
# and not completion of the program name itself.
# Also, we use '> /dev/null 2>&1' since '&>' is not supported in older versions of fish.
complete --do-complete "docker " > /dev/null 2>&1
end
# Remove any pre-existing completions for the program since we will be handling all of them.
complete -c docker -e
# this will get called after the two calls below and clear the $__docker_perform_completion_once_result global
complete -c docker -n '__docker_clear_perform_completion_once_result'
# The call to __docker_prepare_completions will setup __docker_comp_results
# which provides the program's completion choices.
# If this doesn't require order preservation, we don't use the -k flag
complete -c docker -n 'not __docker_requires_order_preservation && __docker_prepare_completions' -f -a '$__docker_comp_results'
# otherwise we use the -k flag
complete -k -c docker -n '__docker_requires_order_preservation && __docker_prepare_completions' -f -a '$__docker_comp_results'

View File

@ -0,0 +1,212 @@
#compdef docker
compdef _docker docker
# zsh completion for docker -*- shell-script -*-
__docker_debug()
{
local file="$BASH_COMP_DEBUG_FILE"
if [[ -n ${file} ]]; then
echo "$*" >> "${file}"
fi
}
_docker()
{
local shellCompDirectiveError=1
local shellCompDirectiveNoSpace=2
local shellCompDirectiveNoFileComp=4
local shellCompDirectiveFilterFileExt=8
local shellCompDirectiveFilterDirs=16
local shellCompDirectiveKeepOrder=32
local lastParam lastChar flagPrefix requestComp out directive comp lastComp noSpace keepOrder
local -a completions
__docker_debug "\n========= starting completion logic =========="
__docker_debug "CURRENT: ${CURRENT}, words[*]: ${words[*]}"
# The user could have moved the cursor backwards on the command-line.
# We need to trigger completion from the $CURRENT location, so we need
# to truncate the command-line ($words) up to the $CURRENT location.
# (We cannot use $CURSOR as its value does not work when a command is an alias.)
words=("${=words[1,CURRENT]}")
__docker_debug "Truncated words[*]: ${words[*]},"
lastParam=${words[-1]}
lastChar=${lastParam[-1]}
__docker_debug "lastParam: ${lastParam}, lastChar: ${lastChar}"
# For zsh, when completing a flag with an = (e.g., docker -n=<TAB>)
# completions must be prefixed with the flag
setopt local_options BASH_REMATCH
if [[ "${lastParam}" =~ '-.*=' ]]; then
# We are dealing with a flag with an =
flagPrefix="-P ${BASH_REMATCH}"
fi
# Prepare the command to obtain completions
requestComp="${words[1]} __completeNoDesc ${words[2,-1]}"
if [ "${lastChar}" = "" ]; then
# If the last parameter is complete (there is a space following it)
# We add an extra empty parameter so we can indicate this to the go completion code.
__docker_debug "Adding extra empty parameter"
requestComp="${requestComp} \"\""
fi
__docker_debug "About to call: eval ${requestComp}"
# Use eval to handle any environment variables and such
out=$(eval ${requestComp} 2>/dev/null)
__docker_debug "completion output: ${out}"
# Extract the directive integer following a : from the last line
local lastLine
while IFS='\n' read -r line; do
lastLine=${line}
done < <(printf "%s\n" "${out[@]}")
__docker_debug "last line: ${lastLine}"
if [ "${lastLine[1]}" = : ]; then
directive=${lastLine[2,-1]}
# Remove the directive including the : and the newline
local suffix
(( suffix=${#lastLine}+2))
out=${out[1,-$suffix]}
else
# There is no directive specified. Leave $out as is.
__docker_debug "No directive found. Setting do default"
directive=0
fi
__docker_debug "directive: ${directive}"
__docker_debug "completions: ${out}"
__docker_debug "flagPrefix: ${flagPrefix}"
if [ $((directive & shellCompDirectiveError)) -ne 0 ]; then
__docker_debug "Completion received error. Ignoring completions."
return
fi
local activeHelpMarker="_activeHelp_ "
local endIndex=${#activeHelpMarker}
local startIndex=$((${#activeHelpMarker}+1))
local hasActiveHelp=0
while IFS='\n' read -r comp; do
# Check if this is an activeHelp statement (i.e., prefixed with $activeHelpMarker)
if [ "${comp[1,$endIndex]}" = "$activeHelpMarker" ];then
__docker_debug "ActiveHelp found: $comp"
comp="${comp[$startIndex,-1]}"
if [ -n "$comp" ]; then
compadd -x "${comp}"
__docker_debug "ActiveHelp will need delimiter"
hasActiveHelp=1
fi
continue
fi
if [ -n "$comp" ]; then
# If requested, completions are returned with a description.
# The description is preceded by a TAB character.
# For zsh's _describe, we need to use a : instead of a TAB.
# We first need to escape any : as part of the completion itself.
comp=${comp//:/\\:}
local tab="$(printf '\t')"
comp=${comp//$tab/:}
__docker_debug "Adding completion: ${comp}"
completions+=${comp}
lastComp=$comp
fi
done < <(printf "%s\n" "${out[@]}")
# Add a delimiter after the activeHelp statements, but only if:
# - there are completions following the activeHelp statements, or
# - file completion will be performed (so there will be choices after the activeHelp)
if [ $hasActiveHelp -eq 1 ]; then
if [ ${#completions} -ne 0 ] || [ $((directive & shellCompDirectiveNoFileComp)) -eq 0 ]; then
__docker_debug "Adding activeHelp delimiter"
compadd -x "--"
hasActiveHelp=0
fi
fi
if [ $((directive & shellCompDirectiveNoSpace)) -ne 0 ]; then
__docker_debug "Activating nospace."
noSpace="-S ''"
fi
if [ $((directive & shellCompDirectiveKeepOrder)) -ne 0 ]; then
__docker_debug "Activating keep order."
keepOrder="-V"
fi
if [ $((directive & shellCompDirectiveFilterFileExt)) -ne 0 ]; then
# File extension filtering
local filteringCmd
filteringCmd='_files'
for filter in ${completions[@]}; do
if [ ${filter[1]} != '*' ]; then
# zsh requires a glob pattern to do file filtering
filter="\*.$filter"
fi
filteringCmd+=" -g $filter"
done
filteringCmd+=" ${flagPrefix}"
__docker_debug "File filtering command: $filteringCmd"
_arguments '*:filename:'"$filteringCmd"
elif [ $((directive & shellCompDirectiveFilterDirs)) -ne 0 ]; then
# File completion for directories only
local subdir
subdir="${completions[1]}"
if [ -n "$subdir" ]; then
__docker_debug "Listing directories in $subdir"
pushd "${subdir}" >/dev/null 2>&1
else
__docker_debug "Listing directories in ."
fi
local result
_arguments '*:dirname:_files -/'" ${flagPrefix}"
result=$?
if [ -n "$subdir" ]; then
popd >/dev/null 2>&1
fi
return $result
else
__docker_debug "Calling _describe"
if eval _describe $keepOrder "completions" completions $flagPrefix $noSpace; then
__docker_debug "_describe found some completions"
# Return the success of having called _describe
return 0
else
__docker_debug "_describe did not find completions."
__docker_debug "Checking if we should do file completion."
if [ $((directive & shellCompDirectiveNoFileComp)) -ne 0 ]; then
__docker_debug "deactivating file completion"
# We must return an error code here to let zsh know that there were no
# completions found by _describe; this is what will trigger other
# matching algorithms to attempt to find completions.
# For example zsh can match letters in the middle of words.
return 1
else
# Perform file completion
__docker_debug "Activating file completion"
# We must return the result of this command, so it must be the
# last command, or else we must store its result to return it.
_arguments '*:filename:_files'" ${flagPrefix}"
fi
fi
fi
}
# don't run the completion function when being source-ed or eval-ed
if [ "$funcstack[1]" = "_docker" ]; then
_docker
fi