context: add shell-completion for context-names

For now, these are not exported and included in the cli/commands/contexts
package; a copy of this also lives in cmd/docker, but we need to find a
good place for these completions, as some of them bring in additional
dependencies.

Commands that accept multiple arguments provide completion, but removing
duplicates:

    docker context inspect<TAB>
    default  desktop-linux  (current)  production  tcd

    docker context inspec default<TAB>
    desktop-linux  (current)  production  tcd

    docker context inspect default tcd<TAB>
    desktop-linux  (current)  production

For "context export", we provide completion for the first argument, after
which file-completion is provided:

    # provides context names completion for the first argument
    docker context export production<TAB>
    default  desktop-linux  (current)  production  tcd

    # then provides completion for filenames
    docker context export desktop-linux<TAB>
    build/           man/                TESTING.md
    cli/             docker.Makefile     go.mod
    ...

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
This commit is contained in:
Sebastiaan van Stijn 2025-04-16 17:57:53 +02:00
parent b8857225a0
commit 6fd72c6333
No known key found for this signature in database
GPG Key ID: 76698F39D527CE8C
8 changed files with 147 additions and 13 deletions

View File

@ -0,0 +1,46 @@
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
//go:build go1.22
package context
import (
"slices"
"github.com/docker/cli/cli/context/store"
"github.com/spf13/cobra"
)
type contextProvider interface {
ContextStore() store.Store
CurrentContext() string
}
// completeContextNames implements shell completion for context-names.
//
// FIXME(thaJeztah): export, and remove duplicate of this function in cmd/docker.
func completeContextNames(dockerCLI contextProvider, limit int, withFileComp bool) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if limit > 0 && len(args) >= limit {
if withFileComp {
// Provide file/path completion after context name (for "docker context export")
return nil, cobra.ShellCompDirectiveDefault
}
return nil, cobra.ShellCompDirectiveNoFileComp
}
// TODO(thaJeztah): implement function similar to [store.Names] to (also) include descriptions.
names, _ := store.Names(dockerCLI.ContextStore())
out := make([]string, 0, len(names))
for _, name := range names {
if slices.Contains(args, name) {
// Already completed
continue
}
if name == dockerCLI.CurrentContext() {
name += "\tcurrent"
}
out = append(out, name)
}
return out, cobra.ShellCompDirectiveNoFileComp
}
}

View File

@ -0,0 +1,80 @@
package context
import (
"testing"
"github.com/docker/cli/cli/context/store"
"github.com/spf13/cobra"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
)
type fakeContextProvider struct {
contextStore store.Store
}
func (c *fakeContextProvider) ContextStore() store.Store {
return c.contextStore
}
func (*fakeContextProvider) CurrentContext() string {
return "default"
}
type fakeContextStore struct {
store.Store
names []string
}
func (f fakeContextStore) List() (c []store.Metadata, _ error) {
for _, name := range f.names {
c = append(c, store.Metadata{Name: name})
}
return c, nil
}
func TestCompleteContextNames(t *testing.T) {
allNames := []string{"context-b", "context-c", "context-a"}
cli := &fakeContextProvider{
contextStore: fakeContextStore{
names: allNames,
},
}
t.Run("with limit", func(t *testing.T) {
compFunc := completeContextNames(cli, 1, false)
values, directives := compFunc(nil, nil, "")
assert.Check(t, is.Equal(directives, cobra.ShellCompDirectiveNoFileComp))
assert.Check(t, is.DeepEqual(values, allNames))
values, directives = compFunc(nil, []string{"context-c"}, "")
assert.Check(t, is.Equal(directives, cobra.ShellCompDirectiveNoFileComp))
assert.Check(t, is.Len(values, 0))
})
t.Run("with limit and file completion", func(t *testing.T) {
compFunc := completeContextNames(cli, 1, true)
values, directives := compFunc(nil, nil, "")
assert.Check(t, is.Equal(directives, cobra.ShellCompDirectiveNoFileComp))
assert.Check(t, is.DeepEqual(values, allNames))
values, directives = compFunc(nil, []string{"context-c"}, "")
assert.Check(t, is.Equal(directives, cobra.ShellCompDirectiveDefault), "should provide filenames completion after limit")
assert.Check(t, is.Len(values, 0))
})
t.Run("without limits", func(t *testing.T) {
compFunc := completeContextNames(cli, -1, false)
values, directives := compFunc(nil, []string{"context-c"}, "")
assert.Check(t, is.Equal(directives, cobra.ShellCompDirectiveNoFileComp))
assert.Check(t, is.DeepEqual(values, []string{"context-b", "context-a"}), "should not contain already completed")
values, directives = compFunc(nil, []string{"context-c", "context-a"}, "")
assert.Check(t, is.Equal(directives, cobra.ShellCompDirectiveNoFileComp))
assert.Check(t, is.DeepEqual(values, []string{"context-b"}), "should not contain already completed")
values, directives = compFunc(nil, []string{"context-c", "context-a", "context-b"}, "")
assert.Check(t, is.Equal(directives, cobra.ShellCompDirectiveNoFileComp), "should provide filenames completion after limit")
assert.Check(t, is.Len(values, 0))
})
}

View File

@ -18,7 +18,7 @@ type ExportOptions struct {
Dest string
}
func newExportCommand(dockerCli command.Cli) *cobra.Command {
func newExportCommand(dockerCLI command.Cli) *cobra.Command {
return &cobra.Command{
Use: "export [OPTIONS] CONTEXT [FILE|-]",
Short: "Export a context to a tar archive FILE or a tar stream on STDOUT.",
@ -32,8 +32,9 @@ func newExportCommand(dockerCli command.Cli) *cobra.Command {
} else {
opts.Dest = opts.ContextName + ".dockercontext"
}
return RunExport(dockerCli, opts)
return RunExport(dockerCLI, opts)
},
ValidArgsFunction: completeContextNames(dockerCLI, 1, true),
}
}

View File

@ -7,6 +7,7 @@ import (
"github.com/docker/cli/cli"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/command/completion"
"github.com/docker/cli/cli/context/store"
"github.com/spf13/cobra"
)
@ -19,6 +20,8 @@ func newImportCommand(dockerCli command.Cli) *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
return RunImport(dockerCli, args[0], args[1])
},
// TODO(thaJeztah): this should also include "-"
ValidArgsFunction: completion.FileNames,
}
return cmd
}

View File

@ -19,7 +19,7 @@ type inspectOptions struct {
}
// newInspectCommand creates a new cobra.Command for `docker context inspect`
func newInspectCommand(dockerCli command.Cli) *cobra.Command {
func newInspectCommand(dockerCLI command.Cli) *cobra.Command {
var opts inspectOptions
cmd := &cobra.Command{
@ -28,13 +28,14 @@ func newInspectCommand(dockerCli command.Cli) *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
opts.refs = args
if len(opts.refs) == 0 {
if dockerCli.CurrentContext() == "" {
if dockerCLI.CurrentContext() == "" {
return errors.New("no context specified")
}
opts.refs = []string{dockerCli.CurrentContext()}
opts.refs = []string{dockerCLI.CurrentContext()}
}
return runInspect(dockerCli, opts)
return runInspect(dockerCLI, opts)
},
ValidArgsFunction: completeContextNames(dockerCLI, -1, false),
}
flags := cmd.Flags()

View File

@ -16,7 +16,7 @@ type RemoveOptions struct {
Force bool
}
func newRemoveCommand(dockerCli command.Cli) *cobra.Command {
func newRemoveCommand(dockerCLI command.Cli) *cobra.Command {
var opts RemoveOptions
cmd := &cobra.Command{
Use: "rm CONTEXT [CONTEXT...]",
@ -24,8 +24,9 @@ func newRemoveCommand(dockerCli command.Cli) *cobra.Command {
Short: "Remove one or more contexts",
Args: cli.RequiresMinArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return RunRemove(dockerCli, opts, args)
return RunRemove(dockerCLI, opts, args)
},
ValidArgsFunction: completeContextNames(dockerCLI, -1, false),
}
cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Force the removal of a context in use")
return cmd

View File

@ -33,7 +33,7 @@ func longUpdateDescription() string {
return buf.String()
}
func newUpdateCommand(dockerCli command.Cli) *cobra.Command {
func newUpdateCommand(dockerCLI command.Cli) *cobra.Command {
opts := &UpdateOptions{}
cmd := &cobra.Command{
Use: "update [OPTIONS] CONTEXT",
@ -41,9 +41,10 @@ func newUpdateCommand(dockerCli command.Cli) *cobra.Command {
Args: cli.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
opts.Name = args[0]
return RunUpdate(dockerCli, opts)
return RunUpdate(dockerCLI, opts)
},
Long: longUpdateDescription(),
ValidArgsFunction: completeContextNames(dockerCLI, 1, false),
}
flags := cmd.Flags()
flags.StringVar(&opts.Description, "description", "", "Description of the context")

View File

@ -10,15 +10,16 @@ import (
"github.com/spf13/cobra"
)
func newUseCommand(dockerCli command.Cli) *cobra.Command {
func newUseCommand(dockerCLI command.Cli) *cobra.Command {
cmd := &cobra.Command{
Use: "use CONTEXT",
Short: "Set the current docker context",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
return RunUse(dockerCli, name)
return RunUse(dockerCLI, name)
},
ValidArgsFunction: completeContextNames(dockerCLI, 1, false),
}
return cmd
}