From edcc798484a136dfc7bc2a05045b3f64eb78eda8 Mon Sep 17 00:00:00 2001 From: Marc Khouzam Date: Mon, 25 Nov 2019 22:45:58 -0500 Subject: [PATCH] feat(comp): Dynamic completion for plugins If the completion script determines there are no known completions it will look for a plugin that matches the command line. If found, the completion script will call the plugin through helm with the special __complete__ parameter. Helm will detect that special parameter and then call an executable called plugin.complete which is optionally provided by the plugin in its root directory. Helm will set the plugin environment variable and pass the entire current command line to that script file. That executable file should output the possible completions based on the command-line it received. Signed-off-by: Marc Khouzam --- cmd/helm/load_plugins.go | 24 +++++++++++++- cmd/helm/plugin_test.go | 28 +++++++++++++++++ cmd/helm/root.go | 31 +++++++++++++++++++ .../helmhome/helm/plugins/env/plugin.complete | 3 ++ 4 files changed, 85 insertions(+), 1 deletion(-) create mode 100755 cmd/helm/testdata/helmhome/helm/plugins/env/plugin.complete diff --git a/cmd/helm/load_plugins.go b/cmd/helm/load_plugins.go index 5eb9f40b7..36ef0aec3 100644 --- a/cmd/helm/load_plugins.go +++ b/cmd/helm/load_plugins.go @@ -33,7 +33,11 @@ import ( "helm.sh/helm/v3/pkg/plugin" ) -const pluginStaticCompletionFile = "completion.yaml" +const ( + pluginStaticCompletionFile = "completion.yaml" + pluginDynamicCompletionExecutable = "plugin.complete" + pluginDynamicCompletionIndicator = "__complete__" +) type pluginError struct { error @@ -106,6 +110,9 @@ func loadPlugins(baseCmd *cobra.Command, out io.Writer) { env = append(env, fmt.Sprintf("%s=%s", k, v)) } + // Check if the plugin has been called to provide completions + main, argv = checkForCompletionRequest(plug, main, argv) + prog := exec.Command(main, argv...) prog.Env = env prog.Stdin = os.Stdin @@ -305,3 +312,18 @@ func loadFile(path string) (*pluginCommand, error) { err = yaml.Unmarshal(b, cmds) return cmds, err } + +func checkForCompletionRequest(plug *plugin.Plugin, main string, args []string) (string, []string) { + if len(args) > 0 && args[len(args)-1] == pluginDynamicCompletionIndicator { + // The plugin was called to request completion choices. + // Call the dynamic completion script of the plugin instead of the main plugin script + newMain := strings.Join([]string{plug.Dir, pluginDynamicCompletionExecutable}, string(filepath.Separator)) + // Remove the temporary pluginDynamicCompletionIndicator parameter + newArgs := args[:len(args)-1] + if settings.Debug { + log.Output(2, fmt.Sprintf("[info] calling %s with args %v\n", newMain, newArgs)) + } + return newMain, newArgs + } + return main, args +} diff --git a/cmd/helm/plugin_test.go b/cmd/helm/plugin_test.go index 2d9a24ba9..acc154739 100644 --- a/cmd/helm/plugin_test.go +++ b/cmd/helm/plugin_test.go @@ -242,6 +242,34 @@ func checkCommand(t *testing.T, plugins []*cobra.Command, tests []staticCompleti } } +func TestExecuteCompletionCall(t *testing.T) { + settings.PluginsDirectory = "testdata/helmhome/helm/plugins" + + var ( + out bytes.Buffer + cmd cobra.Command + ) + loadPlugins(&cmd, &out) + + pluginCmd, _, err := cmd.Find([]string{"env"}) + if err != nil { + t.Errorf("Unexpected error %s", err) + } + + // Currently, plugins assume a Linux subsystem. Skip the execution + // tests until this is fixed + if runtime.GOOS != "windows" { + if err := pluginCmd.RunE(pluginCmd, []string{pluginDynamicCompletionIndicator}); err == nil { + expected := "plugin.complete was called\n" + if out.String() != expected { + t.Errorf("Expected %s to output:\n%s\ngot\n%s", pluginCmd.Name(), expected, out.String()) + } + } else { + t.Errorf("Unexpected error %s", err.Error()) + } + } +} + func TestLoadPlugins_HelmNoPlugins(t *testing.T) { settings.PluginsDirectory = "testdata/helmhome/helm/plugins" settings.RepositoryConfig = "testdata/helmhome/helm/repository" diff --git a/cmd/helm/root.go b/cmd/helm/root.go index f0a034d45..ef21b1be3 100644 --- a/cmd/helm/root.go +++ b/cmd/helm/root.go @@ -257,6 +257,35 @@ __helm_list_releases_then_charts() { fi } +__helm_call_plugin_completion() +{ + __helm_debug "${FUNCNAME[0]}: c is $c #words is ${#words[@]} words[@] is ${words[@]}" + __helm_debug "${FUNCNAME[0]}: last_command is $last_command" + + # Need to split the following into two operations for bash 3 + local pluginName=${last_command#*_} + pluginName=${pluginName%%%%_*} + + # We double cut. The first is to separate by tabs, and the second trims trailing whitespaces. + if ! eval $(__helm_binary_name) plugin list 2>/dev/null | tail +2 | cut -f1 | cut -f1 -d" " | \grep -q ^${pluginName}$; then + # Not a plugin. + return + fi + + # If the last parameter is complete (there is a space following it), we + # add an extra fake parameter at the end to indicate this to the plugin. + # For example "helm 2to3 convert " should complete differently than "helm 2to3 con" + extraParam="__complete__" + [ $c -eq ${#words[@]} ] && extraParam="_ $extraParam" + + toExec="${words[@]} $extraParam" + __helm_debug "${FUNCNAME[0]}: Executing the following to get plugin completions: $toExec" + if out=$(eval $toExec 2>/dev/null); then + __helm_debug "${FUNCNAME[0]}: plugin returned: ${out[*]}" + COMPREPLY+=( $( compgen -W "${out[*]}" -- "$cur" ) ) + fi +} + __helm_custom_func() { __helm_debug "${FUNCNAME[0]}: last_command is $last_command" @@ -291,6 +320,8 @@ __helm_custom_func() return ;; *) + __helm_call_plugin_completion + return ;; esac } diff --git a/cmd/helm/testdata/helmhome/helm/plugins/env/plugin.complete b/cmd/helm/testdata/helmhome/helm/plugins/env/plugin.complete new file mode 100755 index 000000000..33fe6c3c1 --- /dev/null +++ b/cmd/helm/testdata/helmhome/helm/plugins/env/plugin.complete @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +echo "plugin.complete was called"