From 9240e7812464c1eb07c6a4b6c4459d5d3b1aef01 Mon Sep 17 00:00:00 2001 From: Marc Khouzam Date: Tue, 31 Dec 2019 08:42:12 -0500 Subject: [PATCH 1/9] feat(comp): Dynamic completion of arguments in Go Signed-off-by: Marc Khouzam --- cmd/helm/get_all.go | 9 ++ cmd/helm/get_hooks.go | 9 ++ cmd/helm/get_manifest.go | 9 ++ cmd/helm/get_notes.go | 9 ++ cmd/helm/get_values.go | 9 ++ cmd/helm/history.go | 9 ++ cmd/helm/install.go | 14 ++ cmd/helm/list.go | 24 ++++ cmd/helm/plugin_list.go | 15 ++ cmd/helm/plugin_uninstall.go | 11 ++ cmd/helm/plugin_update.go | 11 ++ cmd/helm/pull.go | 9 ++ cmd/helm/release_testing.go | 9 ++ cmd/helm/repo_list.go | 16 +++ cmd/helm/repo_remove.go | 10 ++ cmd/helm/rollback.go | 9 ++ cmd/helm/root.go | 247 ++++++-------------------------- cmd/helm/search_repo.go | 114 +++++++++++++++ cmd/helm/show.go | 12 ++ cmd/helm/status.go | 9 ++ cmd/helm/template.go | 6 + cmd/helm/uninstall.go | 9 ++ cmd/helm/upgrade.go | 12 ++ internal/completion/complete.go | 201 ++++++++++++++++++++++++++ 24 files changed, 589 insertions(+), 203 deletions(-) create mode 100644 internal/completion/complete.go diff --git a/cmd/helm/get_all.go b/cmd/helm/get_all.go index 8e9ab4d6b..678b85f25 100644 --- a/cmd/helm/get_all.go +++ b/cmd/helm/get_all.go @@ -22,6 +22,7 @@ import ( "github.com/spf13/cobra" "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/internal/completion" "helm.sh/helm/v3/pkg/action" "helm.sh/helm/v3/pkg/cli/output" ) @@ -56,6 +57,14 @@ func newGetAllCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { }, } + // Function providing dynamic auto-completion + completion.RegisterValidArgsFunc(cmd, func(cmd *cobra.Command, args []string, toComplete string) ([]string, completion.BashCompDirective) { + if len(args) != 0 { + return nil, completion.BashCompDirectiveNoFileComp + } + return compListReleases(toComplete, cfg) + }) + f := cmd.Flags() f.IntVar(&client.Version, "revision", 0, "get the named release with revision") f.StringVar(&template, "template", "", "go template for formatting the output, eg: {{.Release.Name}}") diff --git a/cmd/helm/get_hooks.go b/cmd/helm/get_hooks.go index 0c50c8833..84ef0c1fc 100644 --- a/cmd/helm/get_hooks.go +++ b/cmd/helm/get_hooks.go @@ -23,6 +23,7 @@ import ( "github.com/spf13/cobra" "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/internal/completion" "helm.sh/helm/v3/pkg/action" ) @@ -52,6 +53,14 @@ func newGetHooksCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { }, } + // Function providing dynamic auto-completion + completion.RegisterValidArgsFunc(cmd, func(cmd *cobra.Command, args []string, toComplete string) ([]string, completion.BashCompDirective) { + if len(args) != 0 { + return nil, completion.BashCompDirectiveNoFileComp + } + return compListReleases(toComplete, cfg) + }) + cmd.Flags().IntVar(&client.Version, "revision", 0, "get the named release with revision") return cmd diff --git a/cmd/helm/get_manifest.go b/cmd/helm/get_manifest.go index d8fcd2e2c..1860025cd 100644 --- a/cmd/helm/get_manifest.go +++ b/cmd/helm/get_manifest.go @@ -23,6 +23,7 @@ import ( "github.com/spf13/cobra" "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/internal/completion" "helm.sh/helm/v3/pkg/action" ) @@ -52,6 +53,14 @@ func newGetManifestCmd(cfg *action.Configuration, out io.Writer) *cobra.Command }, } + // Function providing dynamic auto-completion + completion.RegisterValidArgsFunc(cmd, func(cmd *cobra.Command, args []string, toComplete string) ([]string, completion.BashCompDirective) { + if len(args) != 0 { + return nil, completion.BashCompDirectiveNoFileComp + } + return compListReleases(toComplete, cfg) + }) + cmd.Flags().IntVar(&client.Version, "revision", 0, "get the named release with revision") return cmd diff --git a/cmd/helm/get_notes.go b/cmd/helm/get_notes.go index 1b0128989..de3adf498 100644 --- a/cmd/helm/get_notes.go +++ b/cmd/helm/get_notes.go @@ -23,6 +23,7 @@ import ( "github.com/spf13/cobra" "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/internal/completion" "helm.sh/helm/v3/pkg/action" ) @@ -50,6 +51,14 @@ func newGetNotesCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { }, } + // Function providing dynamic auto-completion + completion.RegisterValidArgsFunc(cmd, func(cmd *cobra.Command, args []string, toComplete string) ([]string, completion.BashCompDirective) { + if len(args) != 0 { + return nil, completion.BashCompDirectiveNoFileComp + } + return compListReleases(toComplete, cfg) + }) + f := cmd.Flags() f.IntVar(&client.Version, "revision", 0, "get the named release with revision") diff --git a/cmd/helm/get_values.go b/cmd/helm/get_values.go index 2cccaeace..898db8929 100644 --- a/cmd/helm/get_values.go +++ b/cmd/helm/get_values.go @@ -23,6 +23,7 @@ import ( "github.com/spf13/cobra" "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/internal/completion" "helm.sh/helm/v3/pkg/action" "helm.sh/helm/v3/pkg/cli/output" ) @@ -54,6 +55,14 @@ func newGetValuesCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { }, } + // Function providing dynamic auto-completion + completion.RegisterValidArgsFunc(cmd, func(cmd *cobra.Command, args []string, toComplete string) ([]string, completion.BashCompDirective) { + if len(args) != 0 { + return nil, completion.BashCompDirectiveNoFileComp + } + return compListReleases(toComplete, cfg) + }) + f := cmd.Flags() f.IntVar(&client.Version, "revision", 0, "get the named release with revision") f.BoolVarP(&client.AllValues, "all", "a", false, "dump all (computed) values") diff --git a/cmd/helm/history.go b/cmd/helm/history.go index aa873db1e..739848c1c 100644 --- a/cmd/helm/history.go +++ b/cmd/helm/history.go @@ -25,6 +25,7 @@ import ( "github.com/spf13/cobra" "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/internal/completion" "helm.sh/helm/v3/pkg/action" "helm.sh/helm/v3/pkg/chart" "helm.sh/helm/v3/pkg/cli/output" @@ -69,6 +70,14 @@ func newHistoryCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { }, } + // Function providing dynamic auto-completion + completion.RegisterValidArgsFunc(cmd, func(cmd *cobra.Command, args []string, toComplete string) ([]string, completion.BashCompDirective) { + if len(args) != 0 { + return nil, completion.BashCompDirectiveNoFileComp + } + return compListReleases(toComplete, cfg) + }) + f := cmd.Flags() f.IntVar(&client.Max, "max", 256, "maximum number of revision to include in history") bindOutputFlag(cmd, &outfmt) diff --git a/cmd/helm/install.go b/cmd/helm/install.go index b75dfce74..701048151 100644 --- a/cmd/helm/install.go +++ b/cmd/helm/install.go @@ -26,6 +26,7 @@ import ( "github.com/spf13/pflag" "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/internal/completion" "helm.sh/helm/v3/pkg/action" "helm.sh/helm/v3/pkg/chart" "helm.sh/helm/v3/pkg/chart/loader" @@ -122,6 +123,11 @@ func newInstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { }, } + // Function providing dynamic auto-completion + completion.RegisterValidArgsFunc(cmd, func(cmd *cobra.Command, args []string, toComplete string) ([]string, completion.BashCompDirective) { + return compInstall(args, toComplete) + }) + addInstallFlags(cmd.Flags(), client, valueOpts) bindOutputFlag(cmd, &outfmt) @@ -225,3 +231,11 @@ func isChartInstallable(ch *chart.Chart) (bool, error) { } return false, errors.Errorf("%s charts are not installable", ch.Metadata.Type) } + +// Provide dynamic auto-completion for the install and template commands +func compInstall(args []string, toComplete string) ([]string, completion.BashCompDirective) { + if len(args) == 1 { + return compListCharts(toComplete, true) + } + return nil, completion.BashCompDirectiveNoFileComp +} diff --git a/cmd/helm/list.go b/cmd/helm/list.go index 57fc4be3c..4b652088d 100644 --- a/cmd/helm/list.go +++ b/cmd/helm/list.go @@ -26,6 +26,7 @@ import ( "github.com/spf13/cobra" "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/internal/completion" "helm.sh/helm/v3/pkg/action" "helm.sh/helm/v3/pkg/cli/output" "helm.sh/helm/v3/pkg/release" @@ -164,3 +165,26 @@ func (r *releaseListWriter) WriteJSON(out io.Writer) error { func (r *releaseListWriter) WriteYAML(out io.Writer) error { return output.EncodeYAML(out, r.releases) } + +// Provide dynamic auto-completion for release names +func compListReleases(toComplete string, cfg *action.Configuration) ([]string, completion.BashCompDirective) { + completion.CompDebugln(fmt.Sprintf("compListReleases with toComplete %s", toComplete)) + + client := action.NewList(cfg) + client.All = true + client.Limit = 0 + client.Filter = fmt.Sprintf("^%s", toComplete) + + client.SetStateMask() + results, err := client.Run() + if err != nil { + return nil, completion.BashCompDirectiveDefault + } + + var choices []string + for _, res := range results { + choices = append(choices, res.Name) + } + + return choices, completion.BashCompDirectiveNoFileComp +} diff --git a/cmd/helm/plugin_list.go b/cmd/helm/plugin_list.go index 2f37a8028..0440b0b5e 100644 --- a/cmd/helm/plugin_list.go +++ b/cmd/helm/plugin_list.go @@ -18,6 +18,7 @@ package main import ( "fmt" "io" + "strings" "github.com/gosuri/uitable" "github.com/spf13/cobra" @@ -46,3 +47,17 @@ func newPluginListCmd(out io.Writer) *cobra.Command { } return cmd } + +// Provide dynamic auto-completion for plugin names +func compListPlugins(toComplete string) []string { + var pNames []string + plugins, err := findPlugins(settings.PluginsDirectory) + if err == nil { + for _, p := range plugins { + if strings.HasPrefix(p.Metadata.Name, toComplete) { + pNames = append(pNames, p.Metadata.Name) + } + } + } + return pNames +} diff --git a/cmd/helm/plugin_uninstall.go b/cmd/helm/plugin_uninstall.go index 30f9bc91d..f703ddcfb 100644 --- a/cmd/helm/plugin_uninstall.go +++ b/cmd/helm/plugin_uninstall.go @@ -24,6 +24,7 @@ import ( "github.com/pkg/errors" "github.com/spf13/cobra" + "helm.sh/helm/v3/internal/completion" "helm.sh/helm/v3/pkg/plugin" ) @@ -33,6 +34,7 @@ type pluginUninstallOptions struct { func newPluginUninstallCmd(out io.Writer) *cobra.Command { o := &pluginUninstallOptions{} + cmd := &cobra.Command{ Use: "uninstall ...", Aliases: []string{"rm", "remove"}, @@ -44,6 +46,15 @@ func newPluginUninstallCmd(out io.Writer) *cobra.Command { return o.run(out) }, } + + // Function providing dynamic auto-completion + completion.RegisterValidArgsFunc(cmd, func(cmd *cobra.Command, args []string, toComplete string) ([]string, completion.BashCompDirective) { + if len(args) != 0 { + return nil, completion.BashCompDirectiveNoFileComp + } + return compListPlugins(toComplete), completion.BashCompDirectiveNoFileComp + }) + return cmd } diff --git a/cmd/helm/plugin_update.go b/cmd/helm/plugin_update.go index 64e8bd6c7..a24e80518 100644 --- a/cmd/helm/plugin_update.go +++ b/cmd/helm/plugin_update.go @@ -24,6 +24,7 @@ import ( "github.com/pkg/errors" "github.com/spf13/cobra" + "helm.sh/helm/v3/internal/completion" "helm.sh/helm/v3/pkg/plugin" "helm.sh/helm/v3/pkg/plugin/installer" ) @@ -34,6 +35,7 @@ type pluginUpdateOptions struct { func newPluginUpdateCmd(out io.Writer) *cobra.Command { o := &pluginUpdateOptions{} + cmd := &cobra.Command{ Use: "update ...", Aliases: []string{"up"}, @@ -45,6 +47,15 @@ func newPluginUpdateCmd(out io.Writer) *cobra.Command { return o.run(out) }, } + + // Function providing dynamic auto-completion + completion.RegisterValidArgsFunc(cmd, func(cmd *cobra.Command, args []string, toComplete string) ([]string, completion.BashCompDirective) { + if len(args) != 0 { + return nil, completion.BashCompDirectiveNoFileComp + } + return compListPlugins(toComplete), completion.BashCompDirectiveNoFileComp + }) + return cmd } diff --git a/cmd/helm/pull.go b/cmd/helm/pull.go index 3b00e9bca..16cd10467 100644 --- a/cmd/helm/pull.go +++ b/cmd/helm/pull.go @@ -23,6 +23,7 @@ import ( "github.com/spf13/cobra" "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/internal/completion" "helm.sh/helm/v3/pkg/action" ) @@ -68,6 +69,14 @@ func newPullCmd(out io.Writer) *cobra.Command { }, } + // Function providing dynamic auto-completion + completion.RegisterValidArgsFunc(cmd, func(cmd *cobra.Command, args []string, toComplete string) ([]string, completion.BashCompDirective) { + if len(args) != 0 { + return nil, completion.BashCompDirectiveNoFileComp + } + return compListCharts(toComplete, false) + }) + f := cmd.Flags() f.BoolVar(&client.Devel, "devel", false, "use development versions, too. Equivalent to version '>0.0.0-0'. If --version is set, this is ignored.") f.BoolVar(&client.Untar, "untar", false, "if set to true, will untar the chart after downloading it") diff --git a/cmd/helm/release_testing.go b/cmd/helm/release_testing.go index 7190ec736..e4690b9d4 100644 --- a/cmd/helm/release_testing.go +++ b/cmd/helm/release_testing.go @@ -24,6 +24,7 @@ import ( "github.com/spf13/cobra" "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/internal/completion" "helm.sh/helm/v3/pkg/action" "helm.sh/helm/v3/pkg/cli/output" ) @@ -71,6 +72,14 @@ func newReleaseTestCmd(cfg *action.Configuration, out io.Writer) *cobra.Command }, } + // Function providing dynamic auto-completion + completion.RegisterValidArgsFunc(cmd, func(cmd *cobra.Command, args []string, toComplete string) ([]string, completion.BashCompDirective) { + if len(args) != 0 { + return nil, completion.BashCompDirectiveNoFileComp + } + return compListReleases(toComplete, cfg) + }) + f := cmd.Flags() f.DurationVar(&client.Timeout, "timeout", 300*time.Second, "time to wait for any individual Kubernetes operation (like Jobs for hooks)") f.BoolVar(&outputLogs, "logs", false, "Dump the logs from test pods (this runs after all tests are complete, but before any cleanup)") diff --git a/cmd/helm/repo_list.go b/cmd/helm/repo_list.go index 2ff6162d1..25316bafc 100644 --- a/cmd/helm/repo_list.go +++ b/cmd/helm/repo_list.go @@ -18,6 +18,7 @@ package main import ( "io" + "strings" "github.com/gosuri/uitable" "github.com/pkg/errors" @@ -95,3 +96,18 @@ func (r *repoListWriter) encodeByFormat(out io.Writer, format output.Format) err // WriteJSON and WriteYAML, we shouldn't get invalid types return nil } + +// Provide dynamic auto-completion for repo names +func compListRepos(prefix string) []string { + var rNames []string + + f, err := repo.LoadFile(settings.RepositoryConfig) + if err == nil && len(f.Repositories) > 0 { + for _, repo := range f.Repositories { + if strings.HasPrefix(repo.Name, prefix) { + rNames = append(rNames, repo.Name) + } + } + } + return rNames +} diff --git a/cmd/helm/repo_remove.go b/cmd/helm/repo_remove.go index ea7a5db24..e8c0ec027 100644 --- a/cmd/helm/repo_remove.go +++ b/cmd/helm/repo_remove.go @@ -26,6 +26,7 @@ import ( "github.com/spf13/cobra" "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/internal/completion" "helm.sh/helm/v3/pkg/helmpath" "helm.sh/helm/v3/pkg/repo" ) @@ -38,6 +39,7 @@ type repoRemoveOptions struct { func newRepoRemoveCmd(out io.Writer) *cobra.Command { o := &repoRemoveOptions{} + cmd := &cobra.Command{ Use: "remove [NAME]", Aliases: []string{"rm"}, @@ -51,6 +53,14 @@ func newRepoRemoveCmd(out io.Writer) *cobra.Command { }, } + // Function providing dynamic auto-completion + completion.RegisterValidArgsFunc(cmd, func(cmd *cobra.Command, args []string, toComplete string) ([]string, completion.BashCompDirective) { + if len(args) != 0 { + return nil, completion.BashCompDirectiveNoFileComp + } + return compListRepos(toComplete), completion.BashCompDirectiveNoFileComp + }) + return cmd } diff --git a/cmd/helm/rollback.go b/cmd/helm/rollback.go index d44ef14f4..745e910b2 100644 --- a/cmd/helm/rollback.go +++ b/cmd/helm/rollback.go @@ -25,6 +25,7 @@ import ( "github.com/spf13/cobra" "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/internal/completion" "helm.sh/helm/v3/pkg/action" ) @@ -64,6 +65,14 @@ func newRollbackCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { }, } + // Function providing dynamic auto-completion + completion.RegisterValidArgsFunc(cmd, func(cmd *cobra.Command, args []string, toComplete string) ([]string, completion.BashCompDirective) { + if len(args) != 0 { + return nil, completion.BashCompDirectiveNoFileComp + } + return compListReleases(toComplete, cfg) + }) + f := cmd.Flags() f.BoolVar(&client.DryRun, "dry-run", false, "simulate a rollback") f.BoolVar(&client.Recreate, "recreate-pods", false, "performs pods restart for the resource if applicable") diff --git a/cmd/helm/root.go b/cmd/helm/root.go index bba3ff643..e02723a6f 100644 --- a/cmd/helm/root.go +++ b/cmd/helm/root.go @@ -24,6 +24,7 @@ import ( "github.com/spf13/cobra" "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/internal/completion" "helm.sh/helm/v3/internal/experimental/registry" "helm.sh/helm/v3/pkg/action" "helm.sh/helm/v3/pkg/cli/output" @@ -67,11 +68,6 @@ __helm_override_flags_to_kubectl_flags() echo "$1" | \sed s/kube-context/context/ } -__helm_get_repos() -{ - eval $(__helm_binary_name) repo list 2>/dev/null | \tail -n +2 | \cut -f1 -} - __helm_get_contexts() { __helm_debug "${FUNCNAME[0]}: c is $c words[c] is ${words[c]}" @@ -103,209 +99,45 @@ __helm_output_options() COMPREPLY+=( $( compgen -W "%[1]s" -- "$cur" ) ) } -__helm_binary_name() +__helm_custom_func() { - local helm_binary - helm_binary="${words[0]}" - __helm_debug "${FUNCNAME[0]}: helm_binary is ${helm_binary}" - echo ${helm_binary} -} - -# This function prevents the zsh shell from adding a space after -# a completion by adding a second, fake completion -__helm_zsh_comp_nospace() { - __helm_debug "${FUNCNAME[0]}: in is ${in[*]}" + __helm_debug "${FUNCNAME[0]}: c is $c, words[@] is ${words[@]}, #words[@] is ${#words[@]}" + __helm_debug "${FUNCNAME[0]}: cur is ${cur}, cword is ${cword}, words is ${words}" - local out in=("$@") + local out requestComp + requestComp="${words[0]} %[2]s ${words[@]:1}" - # The shell will normally add a space after these completions. - # To avoid that we should use "compopt -o nospace". However, it is not - # available in zsh. - # Instead, we trick the shell by pretending there is a second, longer match. - # We only do this if there is a single choice left for completion - # to reduce the times the user could be presented with the fake completion choice. - - out=($(echo ${in[*]} | \tr " " "\n" | \grep "^${cur}")) - __helm_debug "${FUNCNAME[0]}: out is ${out[*]}" - - [ ${#out[*]} -eq 1 ] && out+=("${out}.") - - __helm_debug "${FUNCNAME[0]}: out is now ${out[*]}" - - echo "${out[*]}" -} + if [ -z "${cur}" ]; 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. + __helm_debug "${FUNCNAME[0]}: Adding extra empty parameter" + requestComp="${requestComp} \"\"" + fi -# $1 = 1 if the completion should include local charts (which means file completion) -__helm_list_charts() -{ - __helm_debug "${FUNCNAME[0]}: c is $c words[c] is ${words[c]}" - local prefix chart repo url file out=() nospace=0 wantFiles=$1 - - # Handle completions for repos - for repo in $(__helm_get_repos); do - if [[ "${cur}" =~ ^${repo}/.* ]]; then - # We are doing completion from within a repo - local cacheFile=$(eval $(__helm_binary_name) env 2>/dev/null | \grep HELM_REPOSITORY_CACHE | \cut -d= -f2 | \sed s/\"//g)/${repo}-charts.txt - if [ -f "$cacheFile" ]; then - # Get the list of charts from the cached file - prefix=${cur#${repo}/} - for chart in $(\grep ^$prefix $cacheFile); do - out+=(${repo}/${chart}) - done - else - # If there is no cached list file, fallback to helm search, which is much slower - # This will happen after the caching feature is first installed but before the user - # does a 'helm repo update' to generate the cached list file. - out=$(eval $(__helm_binary_name) search repo ${cur} 2>/dev/null | \cut -f1 | \grep ^${cur}) + __helm_debug "${FUNCNAME[0]}: calling ${requestComp}" + # Use eval to handle any environment variables and such + out=$(eval ${requestComp} 2>/dev/null) + directive=$? + + if [ $((${directive} & %[3]d)) -ne 0 ]; then + __helm_debug "${FUNCNAME[0]}: received error, completion failed" + else + if [ $((${directive} & %[4]d)) -ne 0 ]; then + if [[ $(type -t compopt) = "builtin" ]]; then + compopt -o nospace fi - nospace=0 - elif [[ ${repo} =~ ^${cur}.* ]]; then - # We are completing a repo name - out+=(${repo}/) - nospace=1 - fi - done - __helm_debug "${FUNCNAME[0]}: out after repos is ${out[*]}" - - # Handle completions for url prefixes - for url in https:// http:// file://; do - if [[ "${cur}" =~ ^${url}.* ]]; then - # The user already put in the full url prefix. Return it - # back as a completion to avoid the shell doing path completion - out="${cur}" - nospace=1 - elif [[ ${url} =~ ^${cur}.* ]]; then - # We are completing a url prefix - out+=(${url}) - nospace=1 fi - done - __helm_debug "${FUNCNAME[0]}: out after urls is ${out[*]}" - - # Handle completion for files. - # We only do this if: - # 1- There are other completions found (if there are no completions, - # the shell will do file completion itself) - # 2- If there is some input from the user (or else we will end up - # listing the entire content of the current directory which will - # be too many choices for the user to find the real repos) - if [ $wantFiles -eq 1 ] && [ -n "${out[*]}" ] && [ -n "${cur}" ]; then - for file in $(\ls); do - if [[ ${file} =~ ^${cur}.* ]]; then - # We are completing a file prefix - out+=(${file}) - nospace=1 + if [ $((${directive} & %[5]d)) -ne 0 ]; then + if [[ $(type -t compopt) = "builtin" ]]; then + compopt +o default fi - done - fi - __helm_debug "${FUNCNAME[0]}: out after files is ${out[*]}" - - # If the user didn't provide any input to completion, - # we provide a hint that a path can also be used - [ $wantFiles -eq 1 ] && [ -z "${cur}" ] && out+=(./ /) - - __helm_debug "${FUNCNAME[0]}: out after checking empty input is ${out[*]}" - - if [ $nospace -eq 1 ]; then - if [[ -n "${ZSH_VERSION}" ]]; then - # Don't let the shell add a space after the completion - local tmpout=$(__helm_zsh_comp_nospace "${out[@]}") - unset out - out=$tmpout - elif [[ $(type -t compopt) = "builtin" ]]; then - compopt -o nospace fi - fi - - __helm_debug "${FUNCNAME[0]}: final out is ${out[*]}" - COMPREPLY=( $( compgen -W "${out[*]}" -- "$cur" ) ) -} -__helm_list_releases() -{ - __helm_debug "${FUNCNAME[0]}: c is $c words[c] is ${words[c]}" - local out filter - # Use ^ to map from the start of the release name - filter="^${words[c]}" - # Use eval in case helm_binary_name or __helm_override_flags contains a variable (e.g., $HOME/bin/h3) - if out=$(eval $(__helm_binary_name) list $(__helm_override_flags) -a -q -m 1000 -f ${filter} 2>/dev/null); then - COMPREPLY=( $( compgen -W "${out[*]}" -- "$cur" ) ) + while IFS='' read -r comp; do + COMPREPLY+=("$comp") + done < <(compgen -W "${out[*]}" -- "$cur") fi } - -__helm_list_repos() -{ - __helm_debug "${FUNCNAME[0]}: c is $c words[c] is ${words[c]}" - local out - # Use eval in case helm_binary_name contains a variable (e.g., $HOME/bin/h3) - if out=$(__helm_get_repos); then - COMPREPLY=( $( compgen -W "${out[*]}" -- "$cur" ) ) - fi -} - -__helm_list_plugins() -{ - __helm_debug "${FUNCNAME[0]}: c is $c words[c] is ${words[c]}" - local out - # Use eval in case helm_binary_name contains a variable (e.g., $HOME/bin/h3) - if out=$(eval $(__helm_binary_name) plugin list 2>/dev/null | \tail -n +2 | \cut -f1); then - COMPREPLY=( $( compgen -W "${out[*]}" -- "$cur" ) ) - fi -} - -__helm_list_charts_after_name() { - __helm_debug "${FUNCNAME[0]}: last_command is $last_command" - if [[ ${#nouns[@]} -eq 1 ]]; then - __helm_list_charts 1 - fi -} - -__helm_list_releases_then_charts() { - __helm_debug "${FUNCNAME[0]}: last_command is $last_command" - if [[ ${#nouns[@]} -eq 0 ]]; then - __helm_list_releases - elif [[ ${#nouns[@]} -eq 1 ]]; then - __helm_list_charts 1 - fi -} - -__helm_custom_func() -{ - __helm_debug "${FUNCNAME[0]}: last_command is $last_command" - case ${last_command} in - helm_pull) - __helm_list_charts 0 - return - ;; - helm_show_*) - __helm_list_charts 1 - return - ;; - helm_install | helm_template) - __helm_list_charts_after_name - return - ;; - helm_upgrade) - __helm_list_releases_then_charts - return - ;; - helm_uninstall | helm_history | helm_status | helm_test |\ - helm_rollback | helm_get_*) - __helm_list_releases - return - ;; - helm_repo_remove) - __helm_list_repos - return - ;; - helm_plugin_uninstall | helm_plugin_update) - __helm_list_plugins - return - ;; - *) - ;; - esac -} ` ) @@ -359,12 +191,18 @@ By default, the default directories depend on the Operating System. The defaults func newRootCmd(actionConfig *action.Configuration, out io.Writer, args []string) *cobra.Command { cmd := &cobra.Command{ - Use: "helm", - Short: "The Helm package manager for Kubernetes.", - Long: globalUsage, - SilenceUsage: true, - Args: require.NoArgs, - BashCompletionFunction: fmt.Sprintf(bashCompletionFunc, strings.Join(output.Formats(), " ")), + Use: "helm", + Short: "The Helm package manager for Kubernetes.", + Long: globalUsage, + SilenceUsage: true, + Args: require.NoArgs, + BashCompletionFunction: fmt.Sprintf( + bashCompletionFunc, + strings.Join(output.Formats(), " "), + completion.CompRequestCmd, + completion.BashCompDirectiveError, + completion.BashCompDirectiveNoSpace, + completion.BashCompDirectiveNoFileComp), } flags := cmd.PersistentFlags() @@ -409,6 +247,9 @@ func newRootCmd(actionConfig *action.Configuration, out io.Writer, args []string // Hidden documentation generator command: 'helm docs' newDocsCmd(out), + + // Setup the special hidden __complete command to allow for dynamic auto-completion + completion.NewCompleteCmd(settings), ) // Add annotation to flags for which we can generate completion choices diff --git a/cmd/helm/search_repo.go b/cmd/helm/search_repo.go index 063a72a27..a57ad135b 100644 --- a/cmd/helm/search_repo.go +++ b/cmd/helm/search_repo.go @@ -19,6 +19,7 @@ package main import ( "fmt" "io" + "io/ioutil" "path/filepath" "strings" @@ -28,6 +29,7 @@ import ( "github.com/spf13/cobra" "helm.sh/helm/v3/cmd/helm/search" + "helm.sh/helm/v3/internal/completion" "helm.sh/helm/v3/pkg/cli/output" "helm.sh/helm/v3/pkg/helmpath" "helm.sh/helm/v3/pkg/repo" @@ -246,3 +248,115 @@ func (r *repoSearchWriter) encodeByFormat(out io.Writer, format output.Format) e // WriteJSON and WriteYAML, we shouldn't get invalid types return nil } + +// Provides the list of charts that are part of the specified repo, and that starts with 'prefix'. +func compListChartsOfRepo(repoName string, prefix string) []string { + f := filepath.Join(settings.RepositoryCache, helmpath.CacheIndexFile(repoName)) + var charts []string + if indexFile, err := repo.LoadIndexFile(f); err == nil { + for name := range indexFile.Entries { + fullName := fmt.Sprintf("%s/%s", repoName, name) + if strings.HasPrefix(fullName, prefix) { + charts = append(charts, fullName) + } + } + } + return charts +} + +// Provide dynamic auto-completion for commands that operate on charts (e.g., helm show) +// When true, the includeFiles argument indicates that completion should include local files (e.g., local charts) +func compListCharts(toComplete string, includeFiles bool) ([]string, completion.BashCompDirective) { + completion.CompDebugln(fmt.Sprintf("compListCharts with toComplete %s", toComplete)) + + noSpace := false + noFile := false + var completions []string + + // First check completions for repos + repos := compListRepos("") + for _, repo := range repos { + repoWithSlash := fmt.Sprintf("%s/", repo) + if strings.HasPrefix(toComplete, repoWithSlash) { + // Must complete with charts within the specified repo + completions = append(completions, compListChartsOfRepo(repo, toComplete)...) + noSpace = false + break + } else if strings.HasPrefix(repo, toComplete) { + // Must complete the repo name + completions = append(completions, repoWithSlash) + noSpace = true + } + } + completion.CompDebugln(fmt.Sprintf("Completions after repos: %v", completions)) + + // Now handle completions for url prefixes + for _, url := range []string{"https://", "http://", "file://"} { + if strings.HasPrefix(toComplete, url) { + // The user already put in the full url prefix; we don't have + // anything to add, but make sure the shell does not default + // to file completion since we could be returning an empty array. + noFile = true + noSpace = true + } else if strings.HasPrefix(url, toComplete) { + // We are completing a url prefix + completions = append(completions, url) + noSpace = true + } + } + completion.CompDebugln(fmt.Sprintf("Completions after urls: %v", completions)) + + // Finally, provide file completion if we need to. + // We only do this if: + // 1- There are other completions found (if there are no completions, + // the shell will do file completion itself) + // 2- If there is some input from the user (or else we will end up + // listing the entire content of the current directory which will + // be too many choices for the user to find the real repos) + if includeFiles && len(completions) > 0 && len(toComplete) > 0 { + if files, err := ioutil.ReadDir("."); err == nil { + for _, file := range files { + if strings.HasPrefix(file.Name(), toComplete) { + // We are completing a file prefix + completions = append(completions, file.Name()) + } + } + } + } + completion.CompDebugln(fmt.Sprintf("Completions after files: %v", completions)) + + // If the user didn't provide any input to completion, + // we provide a hint that a path can also be used + if includeFiles && len(toComplete) == 0 { + completions = append(completions, "./", "/") + } + completion.CompDebugln(fmt.Sprintf("Completions after checking empty input: %v", completions)) + + directive := completion.BashCompDirectiveDefault + if noFile { + directive = directive | completion.BashCompDirectiveNoFileComp + } + if noSpace { + directive = directive | completion.BashCompDirectiveNoSpace + // The completion.BashCompDirective flags do not work for zsh right now. + // We handle it ourselves instead. + completions = compEnforceNoSpace(completions) + } + return completions, directive +} + +// This function prevents the shell from adding a space after +// a completion by adding a second, fake completion. +// It is only needed for zsh, but we cannot tell which shell +// is being used here, so we do the fake completion all the time; +// there are no real downsides to doing this for bash as well. +func compEnforceNoSpace(completions []string) []string { + // To prevent the shell from adding space after the completion, + // we trick it by pretending there is a second, longer match. + // We only do this if there is a single choice for completion. + if len(completions) == 1 { + completions = append(completions, completions[0]+".") + completion.CompDebugln(fmt.Sprintf("compEnforceNoSpace: completions now are %v", completions)) + } + return completions +} diff --git a/cmd/helm/show.go b/cmd/helm/show.go index e9e9cca8b..a82ad2777 100644 --- a/cmd/helm/show.go +++ b/cmd/helm/show.go @@ -23,6 +23,7 @@ import ( "github.com/spf13/cobra" "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/internal/completion" "helm.sh/helm/v3/pkg/action" ) @@ -61,6 +62,14 @@ func newShowCmd(out io.Writer) *cobra.Command { Args: require.NoArgs, } + // Function providing dynamic auto-completion + validArgsFunc := func(cmd *cobra.Command, args []string, toComplete string) ([]string, completion.BashCompDirective) { + if len(args) != 0 { + return nil, completion.BashCompDirectiveNoFileComp + } + return compListCharts(toComplete, true) + } + all := &cobra.Command{ Use: "all [CHART]", Short: "shows all information of the chart", @@ -145,6 +154,9 @@ func newShowCmd(out io.Writer) *cobra.Command { for _, subCmd := range cmds { addChartPathOptionsFlags(subCmd.Flags(), &client.ChartPathOptions) showCommand.AddCommand(subCmd) + + // Register the completion function for each subcommand + completion.RegisterValidArgsFunc(subCmd, validArgsFunc) } return showCommand diff --git a/cmd/helm/status.go b/cmd/helm/status.go index 92a947c26..8f172f66a 100644 --- a/cmd/helm/status.go +++ b/cmd/helm/status.go @@ -25,6 +25,7 @@ import ( "github.com/spf13/cobra" "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/internal/completion" "helm.sh/helm/v3/pkg/action" "helm.sh/helm/v3/pkg/chartutil" "helm.sh/helm/v3/pkg/cli/output" @@ -65,6 +66,14 @@ func newStatusCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { }, } + // Function providing dynamic auto-completion + completion.RegisterValidArgsFunc(cmd, func(cmd *cobra.Command, args []string, toComplete string) ([]string, completion.BashCompDirective) { + if len(args) != 0 { + return nil, completion.BashCompDirectiveNoFileComp + } + return compListReleases(toComplete, cfg) + }) + f := cmd.PersistentFlags() f.IntVar(&client.Version, "revision", 0, "if set, display the status of the named release with revision") bindOutputFlag(cmd, &outfmt) diff --git a/cmd/helm/template.go b/cmd/helm/template.go index a47631c0d..fbd6f57e2 100644 --- a/cmd/helm/template.go +++ b/cmd/helm/template.go @@ -29,6 +29,7 @@ import ( "github.com/spf13/cobra" "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/internal/completion" "helm.sh/helm/v3/pkg/action" "helm.sh/helm/v3/pkg/chartutil" "helm.sh/helm/v3/pkg/cli/values" @@ -123,6 +124,11 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { }, } + // Function providing dynamic auto-completion + completion.RegisterValidArgsFunc(cmd, func(cmd *cobra.Command, args []string, toComplete string) ([]string, completion.BashCompDirective) { + return compInstall(args, toComplete) + }) + f := cmd.Flags() addInstallFlags(f, client, valueOpts) f.StringArrayVarP(&showFiles, "show-only", "s", []string{}, "only show manifests rendered from the given templates") diff --git a/cmd/helm/uninstall.go b/cmd/helm/uninstall.go index 7096d7873..85fa822bd 100644 --- a/cmd/helm/uninstall.go +++ b/cmd/helm/uninstall.go @@ -24,6 +24,7 @@ import ( "github.com/spf13/cobra" "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/internal/completion" "helm.sh/helm/v3/pkg/action" ) @@ -64,6 +65,14 @@ func newUninstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { }, } + // Function providing dynamic auto-completion + completion.RegisterValidArgsFunc(cmd, func(cmd *cobra.Command, args []string, toComplete string) ([]string, completion.BashCompDirective) { + if len(args) != 0 { + return nil, completion.BashCompDirectiveNoFileComp + } + return compListReleases(toComplete, cfg) + }) + f := cmd.Flags() f.BoolVar(&client.DryRun, "dry-run", false, "simulate a uninstall") f.BoolVar(&client.DisableHooks, "no-hooks", false, "prevent hooks from running during uninstallation") diff --git a/cmd/helm/upgrade.go b/cmd/helm/upgrade.go index acfc23198..6c967b796 100644 --- a/cmd/helm/upgrade.go +++ b/cmd/helm/upgrade.go @@ -25,6 +25,7 @@ import ( "github.com/spf13/cobra" "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/internal/completion" "helm.sh/helm/v3/pkg/action" "helm.sh/helm/v3/pkg/chart/loader" "helm.sh/helm/v3/pkg/cli/output" @@ -144,6 +145,17 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { }, } + // Function providing dynamic auto-completion + completion.RegisterValidArgsFunc(cmd, func(cmd *cobra.Command, args []string, toComplete string) ([]string, completion.BashCompDirective) { + if len(args) == 0 { + return compListReleases(toComplete, cfg) + } + if len(args) == 1 { + return compListCharts(toComplete, true) + } + return nil, completion.BashCompDirectiveNoFileComp + }) + f := cmd.Flags() f.BoolVarP(&client.Install, "install", "i", false, "if a release by this name doesn't already exist, run an install") f.BoolVar(&client.Devel, "devel", false, "use development versions, too. Equivalent to version '>0.0.0-0'. If --version is set, this is ignored") diff --git a/internal/completion/complete.go b/internal/completion/complete.go new file mode 100644 index 000000000..435fdcc23 --- /dev/null +++ b/internal/completion/complete.go @@ -0,0 +1,201 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package completion + +import ( + "fmt" + "log" + "os" + "strings" + + "github.com/spf13/cobra" + + "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/pkg/cli" +) + +// ================================================================================== +// The below code supports dynamic shell completion in Go. +// This should ultimately be pushed down into Cobra. +// ================================================================================== + +// CompRequestCmd Hidden command to request completion results from the program. +// Used by the shell completion script. +const CompRequestCmd = "__complete" + +// Global map allowing to find completion functions for commands. +var validArgsFunctions = map[*cobra.Command]func(cmd *cobra.Command, args []string, toComplete string) ([]string, BashCompDirective){} + +// BashCompDirective is a bit map representing the different behaviors the shell +// can be instructed to have once completions have been provided. +type BashCompDirective int + +const ( + // BashCompDirectiveError indicates an error occurred and completions should be ignored. + BashCompDirectiveError BashCompDirective = 1 << iota + + // BashCompDirectiveNoSpace indicates that the shell should not add a space + // after the completion even if there is a single completion provided. + BashCompDirectiveNoSpace + + // BashCompDirectiveNoFileComp indicates that the shell should not provide + // file completion even when no completion is provided. + // This currently does not work for zsh or bash < 4 + BashCompDirectiveNoFileComp + + // BashCompDirectiveDefault indicates to let the shell perform its default + // behavior after completions have been provided. + BashCompDirectiveDefault BashCompDirective = 0 +) + +// RegisterValidArgsFunc should be called to register a function to provide argument completion for a command +func RegisterValidArgsFunc(cmd *cobra.Command, f func(cmd *cobra.Command, args []string, toComplete string) ([]string, BashCompDirective)) { + if _, exists := validArgsFunctions[cmd]; exists { + log.Fatal(fmt.Sprintf("RegisterValidArgsFunc: command '%s' already registered", cmd.Name())) + } + validArgsFunctions[cmd] = f +} + +var debug = true + +// Returns a string listing the different directive enabled in the specified parameter +func (d BashCompDirective) string() string { + var directives []string + if d&BashCompDirectiveError != 0 { + directives = append(directives, "BashCompDirectiveError") + } + if d&BashCompDirectiveNoSpace != 0 { + directives = append(directives, "BashCompDirectiveNoSpace") + } + if d&BashCompDirectiveNoFileComp != 0 { + directives = append(directives, "BashCompDirectiveNoFileComp") + } + if len(directives) == 0 { + directives = append(directives, "BashCompDirectiveDefault") + } + + if d > BashCompDirectiveError+BashCompDirectiveNoSpace+BashCompDirectiveNoFileComp { + return fmt.Sprintf("ERROR: unexpected BashCompDirective value: %d", d) + } + return strings.Join(directives, ", ") +} + +// NewCompleteCmd add a special hidden command that an be used to request completions +func NewCompleteCmd(settings *cli.EnvSettings) *cobra.Command { + debug = settings.Debug + return &cobra.Command{ + Use: fmt.Sprintf("%s [command-line]", CompRequestCmd), + DisableFlagsInUseLine: true, + Hidden: true, + DisableFlagParsing: true, + Args: require.MinimumNArgs(2), + Short: "Request shell completion choices for the specified command-line", + Long: fmt.Sprintf("%s is a special command that is used by the shell completion logic\n%s", + CompRequestCmd, "to request completion choices for the specified command-line."), + Run: func(cmd *cobra.Command, args []string) { + CompDebugln(fmt.Sprintf("%s was called with args %v", cmd.Name(), args)) + + trimmedArgs := args[:len(args)-1] + toComplete := args[len(args)-1] + + // Find the real command for which completion must be performed + finalCmd, finalArgs, err := cmd.Root().Find(trimmedArgs) + if err != nil { + // Unable to find the real command. E.g., helm invalidCmd + os.Exit(int(BashCompDirectiveError)) + } + + CompDebugln(fmt.Sprintf("Found final command '%s', with finalArgs %v", finalCmd.Name(), finalArgs)) + + // Parse the flags and extract the arguments to prepare for calling the completion function + if err = finalCmd.ParseFlags(finalArgs); err != nil { + CompErrorln(fmt.Sprintf("Error while parsing flags from args %v: %s", finalArgs, err.Error())) + return + } + argsWoFlags := finalCmd.Flags().Args() + CompDebugln(fmt.Sprintf("Args without flags are '%v' with length %d", argsWoFlags, len(argsWoFlags))) + + // Find completion function for the command + completionFn, ok := validArgsFunctions[finalCmd] + if !ok { + CompErrorln(fmt.Sprintf("Dynamic completion not supported/needed for flag or command: %s", finalCmd.Name())) + return + } + + CompDebugln(fmt.Sprintf("Calling completion method for subcommand '%s' with args '%v' and toComplete '%s'", finalCmd.Name(), argsWoFlags, toComplete)) + completions, directive := completionFn(finalCmd, argsWoFlags, toComplete) + for _, comp := range completions { + // Print each possible completion to stdout for the completion script to consume. + fmt.Println(comp) + } + + // Print some helpful info to stderr for the user to see. + // Output from stderr should be ignored from the completion script. + fmt.Fprintf(os.Stderr, "Completion ended with directive: %s\n", directive.string()) + os.Exit(int(directive)) + }, + } +} + +// CompDebug prints the specified string to the same file as where the +// completion script prints its logs. +// Note that completion printouts should never be on stdout as they would +// be wrongly interpreted as actual completion choices by the completion script. +func CompDebug(msg string) { + msg = fmt.Sprintf("[Debug] %s", msg) + + // Such logs are only printed when the user has set the environment + // variable BASH_COMP_DEBUG_FILE to the path of some file to be used. + if path := os.Getenv("BASH_COMP_DEBUG_FILE"); path != "" { + f, err := os.OpenFile(path, + os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err == nil { + defer f.Close() + f.WriteString(msg) + } + } + + if debug { + // Must print to stderr for this not to be read by the completion script. + fmt.Fprintf(os.Stderr, msg) + } +} + +// CompDebugln prints the specified string with a newline at the end +// to the same file as where the completion script prints its logs. +// Such logs are only printed when the user has set the environment +// variable BASH_COMP_DEBUG_FILE to the path of some file to be used. +func CompDebugln(msg string) { + CompDebug(fmt.Sprintf("%s\n", msg)) +} + +// CompError prints the specified completion message to stderr. +func CompError(msg string) { + msg = fmt.Sprintf("[Error] %s", msg) + + CompDebug(msg) + + // If not already printed by the call to CompDebug(). + if !debug { + // Must print to stderr for this not to be read by the completion script. + fmt.Fprintf(os.Stderr, msg) + } +} + +// CompErrorln prints the specified completion message to stderr with a newline at the end. +func CompErrorln(msg string) { + CompError(fmt.Sprintf("%s\n", msg)) +} From 62c9c34d493a63f80c06337c60871858df60ea3a Mon Sep 17 00:00:00 2001 From: Marc Khouzam Date: Tue, 31 Dec 2019 08:54:20 -0500 Subject: [PATCH 2/9] feat(comp): Dynamic completion of flags in Go Conflicts: cmd/helm/completion/complete.go cmd/helm/root.go Conflicts: cmd/helm/root.go Signed-off-by: Marc Khouzam --- cmd/helm/flags.go | 17 ++++- cmd/helm/root.go | 103 +++++++++---------------- internal/completion/complete.go | 131 ++++++++++++++++++++++++++++++-- 3 files changed, 174 insertions(+), 77 deletions(-) diff --git a/cmd/helm/flags.go b/cmd/helm/flags.go index aa22603f4..b9473a5a7 100644 --- a/cmd/helm/flags.go +++ b/cmd/helm/flags.go @@ -23,6 +23,7 @@ import ( "github.com/spf13/cobra" "github.com/spf13/pflag" + "helm.sh/helm/v3/internal/completion" "helm.sh/helm/v3/pkg/action" "helm.sh/helm/v3/pkg/cli/output" "helm.sh/helm/v3/pkg/cli/values" @@ -52,10 +53,20 @@ func addChartPathOptionsFlags(f *pflag.FlagSet, c *action.ChartPathOptions) { // bindOutputFlag will add the output flag to the given command and bind the // value to the given format pointer func bindOutputFlag(cmd *cobra.Command, varRef *output.Format) { - cmd.Flags().VarP(newOutputValue(output.Table, varRef), outputFlag, "o", + f := cmd.Flags() + f.VarP(newOutputValue(output.Table, varRef), outputFlag, "o", fmt.Sprintf("prints the output in the specified format. Allowed values: %s", strings.Join(output.Formats(), ", "))) - // Setup shell completion for the flag - cmd.MarkFlagCustom(outputFlag, "__helm_output_options") + + flag := f.Lookup(outputFlag) + completion.RegisterFlagCompletionFunc(flag, func(cmd *cobra.Command, args []string, toComplete string) ([]string, completion.BashCompDirective) { + var formatNames []string + for _, format := range output.Formats() { + if strings.HasPrefix(format, toComplete) { + formatNames = append(formatNames, format) + } + } + return formatNames, completion.BashCompDirectiveDefault + }) } type outputValue output.Format diff --git a/cmd/helm/root.go b/cmd/helm/root.go index e02723a6f..ab7b01c90 100644 --- a/cmd/helm/root.go +++ b/cmd/helm/root.go @@ -23,51 +23,16 @@ import ( "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "helm.sh/helm/v3/cmd/helm/require" "helm.sh/helm/v3/internal/completion" "helm.sh/helm/v3/internal/experimental/registry" "helm.sh/helm/v3/pkg/action" - "helm.sh/helm/v3/pkg/cli/output" ) const ( bashCompletionFunc = ` -__helm_override_flag_list=(--kubeconfig --kube-context --namespace -n) -__helm_override_flags() -{ - local ${__helm_override_flag_list[*]##*-} two_word_of of var - for w in "${words[@]}"; do - if [ -n "${two_word_of}" ]; then - eval "${two_word_of##*-}=\"${two_word_of}=\${w}\"" - two_word_of= - continue - fi - for of in "${__helm_override_flag_list[@]}"; do - case "${w}" in - ${of}=*) - eval "${of##*-}=\"${w}\"" - ;; - ${of}) - two_word_of="${of}" - ;; - esac - done - done - for var in "${__helm_override_flag_list[@]##*-}"; do - if eval "test -n \"\$${var}\""; then - eval "echo \${${var}}" - fi - done -} - -__helm_override_flags_to_kubectl_flags() -{ - # --kubeconfig, -n, --namespace stay the same for kubectl - # --kube-context becomes --context for kubectl - __helm_debug "${FUNCNAME[0]}: flags to convert: $1" - echo "$1" | \sed s/kube-context/context/ -} - __helm_get_contexts() { __helm_debug "${FUNCNAME[0]}: c is $c words[c] is ${words[c]}" @@ -78,36 +43,19 @@ __helm_get_contexts() fi } -__helm_get_namespaces() -{ - __helm_debug "${FUNCNAME[0]}: c is $c words[c] is ${words[c]}" - local template out - template="{{ range .items }}{{ .metadata.name }} {{ end }}" - - flags=$(__helm_override_flags_to_kubectl_flags "$(__helm_override_flags)") - __helm_debug "${FUNCNAME[0]}: override flags for kubectl are: $flags" - - # Must use eval in case the flags contain a variable such as $HOME - if out=$(eval kubectl get ${flags} -o template --template=\"${template}\" namespace 2>/dev/null); then - COMPREPLY+=( $( compgen -W "${out[*]}" -- "$cur" ) ) - fi -} - -__helm_output_options() -{ - __helm_debug "${FUNCNAME[0]}: c is $c words[c] is ${words[c]}" - COMPREPLY+=( $( compgen -W "%[1]s" -- "$cur" ) ) -} - __helm_custom_func() { __helm_debug "${FUNCNAME[0]}: c is $c, words[@] is ${words[@]}, #words[@] is ${#words[@]}" __helm_debug "${FUNCNAME[0]}: cur is ${cur}, cword is ${cword}, words is ${words}" - local out requestComp - requestComp="${words[0]} %[2]s ${words[@]:1}" + local out requestComp lastParam lastChar + requestComp="${words[0]} %[1]s ${words[@]:1}" - if [ -z "${cur}" ]; then + lastParam=${words[$((${#words[@]}-1))]} + lastChar=${lastParam:$((${#lastParam}-1)):1} + __helm_debug "${FUNCNAME[0]}: 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. __helm_debug "${FUNCNAME[0]}: Adding extra empty parameter" @@ -119,15 +67,15 @@ __helm_custom_func() out=$(eval ${requestComp} 2>/dev/null) directive=$? - if [ $((${directive} & %[3]d)) -ne 0 ]; then + if [ $((${directive} & %[2]d)) -ne 0 ]; then __helm_debug "${FUNCNAME[0]}: received error, completion failed" else - if [ $((${directive} & %[4]d)) -ne 0 ]; then + if [ $((${directive} & %[3]d)) -ne 0 ]; then if [[ $(type -t compopt) = "builtin" ]]; then compopt -o nospace fi fi - if [ $((${directive} & %[5]d)) -ne 0 ]; then + if [ $((${directive} & %[4]d)) -ne 0 ]; then if [[ $(type -t compopt) = "builtin" ]]; then compopt +o default fi @@ -145,7 +93,8 @@ var ( // Mapping of global flags that can have dynamic completion and the // completion function to be used. bashCompletionFlags = map[string]string{ - "namespace": "__helm_get_namespaces", + // Cannot convert the kube-context flag to Go completion yet because + // an incomplete kube-context will make actionConfig.Init() fail at the very start "kube-context": "__helm_get_contexts", } ) @@ -198,7 +147,6 @@ func newRootCmd(actionConfig *action.Configuration, out io.Writer, args []string Args: require.NoArgs, BashCompletionFunction: fmt.Sprintf( bashCompletionFunc, - strings.Join(output.Formats(), " "), completion.CompRequestCmd, completion.BashCompDirectiveError, completion.BashCompDirectiveNoSpace, @@ -208,6 +156,29 @@ func newRootCmd(actionConfig *action.Configuration, out io.Writer, args []string settings.AddFlags(flags) + flag := flags.Lookup("namespace") + // Setup shell completion for the namespace flag + completion.RegisterFlagCompletionFunc(flag, func(cmd *cobra.Command, args []string, toComplete string) ([]string, completion.BashCompDirective) { + if client, err := actionConfig.KubernetesClientSet(); err == nil { + // Choose a long enough timeout that the user notices somethings is not working + // but short enough that the user is not made to wait very long + to := int64(3) + completion.CompDebugln(fmt.Sprintf("About to call kube client for namespaces with timeout of: %d", to)) + + nsNames := []string{} + // TODO can we request only the namespace with request.prefix? + if namespaces, err := client.CoreV1().Namespaces().List(metav1.ListOptions{TimeoutSeconds: &to}); err == nil { + for _, ns := range namespaces.Items { + if strings.HasPrefix(ns.Name, toComplete) { + nsNames = append(nsNames, ns.Name) + } + } + return nsNames, completion.BashCompDirectiveNoFileComp + } + } + return nil, completion.BashCompDirectiveDefault + }) + // We can safely ignore any errors that flags.Parse encounters since // those errors will be caught later during the call to cmd.Execution. // This call is required to gather configuration information prior to diff --git a/internal/completion/complete.go b/internal/completion/complete.go index 435fdcc23..c0fabda1b 100644 --- a/internal/completion/complete.go +++ b/internal/completion/complete.go @@ -22,6 +22,7 @@ import ( "strings" "github.com/spf13/cobra" + "github.com/spf13/pflag" "helm.sh/helm/v3/cmd/helm/require" "helm.sh/helm/v3/pkg/cli" @@ -36,8 +37,8 @@ import ( // Used by the shell completion script. const CompRequestCmd = "__complete" -// Global map allowing to find completion functions for commands. -var validArgsFunctions = map[*cobra.Command]func(cmd *cobra.Command, args []string, toComplete string) ([]string, BashCompDirective){} +// Global map allowing to find completion functions for commands or flags. +var validArgsFunctions = map[interface{}]func(cmd *cobra.Command, args []string, toComplete string) ([]string, BashCompDirective){} // BashCompDirective is a bit map representing the different behaviors the shell // can be instructed to have once completions have been provided. @@ -69,6 +70,21 @@ func RegisterValidArgsFunc(cmd *cobra.Command, f func(cmd *cobra.Command, args [ validArgsFunctions[cmd] = f } +// RegisterFlagCompletionFunc should be called to register a function to provide completion for a flag +func RegisterFlagCompletionFunc(flag *pflag.Flag, f func(cmd *cobra.Command, args []string, toComplete string) ([]string, BashCompDirective)) { + if _, exists := validArgsFunctions[flag]; exists { + log.Fatal(fmt.Sprintf("RegisterFlagCompletionFunc: flag '%s' already registered", flag.Name)) + } + validArgsFunctions[flag] = f + + // Make sure the completion script call the __helm_custom_func for the registered flag. + // This is essential to make the = form work. E.g., helm -n= or helm status --output= + if flag.Annotations == nil { + flag.Annotations = map[string][]string{} + } + flag.Annotations[cobra.BashCompCustom] = []string{"__helm_custom_func"} +} + var debug = true // Returns a string listing the different directive enabled in the specified parameter @@ -101,15 +117,14 @@ func NewCompleteCmd(settings *cli.EnvSettings) *cobra.Command { DisableFlagsInUseLine: true, Hidden: true, DisableFlagParsing: true, - Args: require.MinimumNArgs(2), + Args: require.MinimumNArgs(1), Short: "Request shell completion choices for the specified command-line", Long: fmt.Sprintf("%s is a special command that is used by the shell completion logic\n%s", CompRequestCmd, "to request completion choices for the specified command-line."), Run: func(cmd *cobra.Command, args []string) { CompDebugln(fmt.Sprintf("%s was called with args %v", cmd.Name(), args)) - trimmedArgs := args[:len(args)-1] - toComplete := args[len(args)-1] + flag, trimmedArgs, toComplete := checkIfFlagCompletion(cmd.Root(), args[:len(args)-1], args[len(args)-1]) // Find the real command for which completion must be performed finalCmd, finalArgs, err := cmd.Root().Find(trimmedArgs) @@ -128,10 +143,20 @@ func NewCompleteCmd(settings *cli.EnvSettings) *cobra.Command { argsWoFlags := finalCmd.Flags().Args() CompDebugln(fmt.Sprintf("Args without flags are '%v' with length %d", argsWoFlags, len(argsWoFlags))) - // Find completion function for the command - completionFn, ok := validArgsFunctions[finalCmd] + var key interface{} + var keyStr string + if flag != nil { + key = flag + keyStr = flag.Name + } else { + key = finalCmd + keyStr = finalCmd.Name() + } + + // Find completion function for the flag or command + completionFn, ok := validArgsFunctions[key] if !ok { - CompErrorln(fmt.Sprintf("Dynamic completion not supported/needed for flag or command: %s", finalCmd.Name())) + CompErrorln(fmt.Sprintf("Dynamic completion not supported/needed for flag or command: %s", keyStr)) return } @@ -150,6 +175,96 @@ func NewCompleteCmd(settings *cli.EnvSettings) *cobra.Command { } } +func isFlag(arg string) bool { + return len(arg) > 0 && arg[0] == '-' +} + +func checkIfFlagCompletion(rootCmd *cobra.Command, args []string, lastArg string) (*pflag.Flag, []string, string) { + var flagName string + trimmedArgs := args + flagWithEqual := false + if isFlag(lastArg) { + if index := strings.Index(lastArg, "="); index >= 0 { + flagName = strings.TrimLeft(lastArg[:index], "-") + lastArg = lastArg[index+1:] + flagWithEqual = true + } else { + CompErrorln("Unexpected completion request for flag") + os.Exit(int(BashCompDirectiveError)) + } + } + + if len(flagName) == 0 { + if len(args) > 0 { + prevArg := args[len(args)-1] + if isFlag(prevArg) { + // If the flag contains an = it means it has already been fully processed + if index := strings.Index(prevArg, "="); index < 0 { + flagName = strings.TrimLeft(prevArg, "-") + + // Remove the uncompleted flag or else Cobra could complain about + // an invalid value for that flag e.g., helm status --output j + trimmedArgs = args[:len(args)-1] + } + } + } + } + + if len(flagName) == 0 { + // Not doing flag completion + return nil, trimmedArgs, lastArg + } + + // Find the real command for which completion must be performed + finalCmd, _, err := rootCmd.Find(trimmedArgs) + if err != nil { + // Unable to find the real command. E.g., helm invalidCmd + os.Exit(int(BashCompDirectiveError)) + } + + CompDebugln(fmt.Sprintf("checkIfFlagCompletion: found final command '%s'", finalCmd.Name())) + + flag := findFlag(finalCmd, flagName) + if flag == nil { + // Flag not supported by this command, nothing to complete + CompDebugln(fmt.Sprintf("Subcommand '%s' does not support flag '%s'", finalCmd.Name(), flagName)) + os.Exit(int(BashCompDirectiveNoFileComp)) + } + + if !flagWithEqual { + if len(flag.NoOptDefVal) != 0 { + // We had assumed dealing with a two-word flag but the flag is a boolean flag. + // In that case, there is no value following it, so we are not really doing flag completion. + // Reset everything to do argument completion. + trimmedArgs = args + flag = nil + } + } + + return flag, trimmedArgs, lastArg +} + +func findFlag(cmd *cobra.Command, name string) *pflag.Flag { + flagSet := cmd.Flags() + if len(name) == 1 { + // First convert the short flag into a long flag + // as the cmd.Flag() search only accepts long flags + if short := flagSet.ShorthandLookup(name); short != nil { + CompDebugln(fmt.Sprintf("checkIfFlagCompletion: found flag '%s' which we will change to '%s'", name, short.Name)) + name = short.Name + } else { + set := cmd.InheritedFlags() + if short = set.ShorthandLookup(name); short != nil { + CompDebugln(fmt.Sprintf("checkIfFlagCompletion: found inherited flag '%s' which we will change to '%s'", name, short.Name)) + name = short.Name + } else { + return nil + } + } + } + return cmd.Flag(name) +} + // CompDebug prints the specified string to the same file as where the // completion script prints its logs. // Note that completion printouts should never be on stdout as they would From de1ca6784afe52762880d74bfd3732437399ac7b Mon Sep 17 00:00:00 2001 From: Marc Khouzam Date: Tue, 31 Dec 2019 08:59:10 -0500 Subject: [PATCH 3/9] feat(comp): Support --generate-name in completion Signed-off-by: Marc Khouzam --- cmd/helm/install.go | 10 +++++++--- cmd/helm/template.go | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/cmd/helm/install.go b/cmd/helm/install.go index 701048151..0f548c90c 100644 --- a/cmd/helm/install.go +++ b/cmd/helm/install.go @@ -125,7 +125,7 @@ func newInstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { // Function providing dynamic auto-completion completion.RegisterValidArgsFunc(cmd, func(cmd *cobra.Command, args []string, toComplete string) ([]string, completion.BashCompDirective) { - return compInstall(args, toComplete) + return compInstall(args, toComplete, client) }) addInstallFlags(cmd.Flags(), client, valueOpts) @@ -233,8 +233,12 @@ func isChartInstallable(ch *chart.Chart) (bool, error) { } // Provide dynamic auto-completion for the install and template commands -func compInstall(args []string, toComplete string) ([]string, completion.BashCompDirective) { - if len(args) == 1 { +func compInstall(args []string, toComplete string, client *action.Install) ([]string, completion.BashCompDirective) { + requiredArgs := 1 + if client.GenerateName { + requiredArgs = 0 + } + if len(args) == requiredArgs { return compListCharts(toComplete, true) } return nil, completion.BashCompDirectiveNoFileComp diff --git a/cmd/helm/template.go b/cmd/helm/template.go index fbd6f57e2..1c34d7245 100644 --- a/cmd/helm/template.go +++ b/cmd/helm/template.go @@ -126,7 +126,7 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { // Function providing dynamic auto-completion completion.RegisterValidArgsFunc(cmd, func(cmd *cobra.Command, args []string, toComplete string) ([]string, completion.BashCompDirective) { - return compInstall(args, toComplete) + return compInstall(args, toComplete, client) }) f := cmd.Flags() From d5d741dfed59d82f2b350bdb0c97d05f83f4abd1 Mon Sep 17 00:00:00 2001 From: Marc Khouzam Date: Wed, 1 Jan 2020 15:57:51 -0500 Subject: [PATCH 4/9] feat(comp): Support completion for --revision flag Signed-off-by: Marc Khouzam --- cmd/helm/get_all.go | 8 ++++++++ cmd/helm/get_hooks.go | 10 +++++++++- cmd/helm/get_manifest.go | 10 +++++++++- cmd/helm/get_notes.go | 7 +++++++ cmd/helm/get_values.go | 7 +++++++ cmd/helm/history.go | 14 ++++++++++++++ cmd/helm/status.go | 9 +++++++++ 7 files changed, 63 insertions(+), 2 deletions(-) diff --git a/cmd/helm/get_all.go b/cmd/helm/get_all.go index 678b85f25..7d893d7e0 100644 --- a/cmd/helm/get_all.go +++ b/cmd/helm/get_all.go @@ -67,6 +67,14 @@ func newGetAllCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { f := cmd.Flags() f.IntVar(&client.Version, "revision", 0, "get the named release with revision") + flag := f.Lookup("revision") + completion.RegisterFlagCompletionFunc(flag, func(cmd *cobra.Command, args []string, toComplete string) ([]string, completion.BashCompDirective) { + if len(args) == 1 { + return compListRevisions(cfg, args[0]) + } + return nil, completion.BashCompDirectiveNoFileComp + }) + f.StringVar(&template, "template", "", "go template for formatting the output, eg: {{.Release.Name}}") return cmd diff --git a/cmd/helm/get_hooks.go b/cmd/helm/get_hooks.go index 84ef0c1fc..c2087b1ba 100644 --- a/cmd/helm/get_hooks.go +++ b/cmd/helm/get_hooks.go @@ -61,7 +61,15 @@ func newGetHooksCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { return compListReleases(toComplete, cfg) }) - cmd.Flags().IntVar(&client.Version, "revision", 0, "get the named release with revision") + f := cmd.Flags() + f.IntVar(&client.Version, "revision", 0, "get the named release with revision") + flag := f.Lookup("revision") + completion.RegisterFlagCompletionFunc(flag, func(cmd *cobra.Command, args []string, toComplete string) ([]string, completion.BashCompDirective) { + if len(args) == 1 { + return compListRevisions(cfg, args[0]) + } + return nil, completion.BashCompDirectiveNoFileComp + }) return cmd } diff --git a/cmd/helm/get_manifest.go b/cmd/helm/get_manifest.go index 1860025cd..f332befd9 100644 --- a/cmd/helm/get_manifest.go +++ b/cmd/helm/get_manifest.go @@ -61,7 +61,15 @@ func newGetManifestCmd(cfg *action.Configuration, out io.Writer) *cobra.Command return compListReleases(toComplete, cfg) }) - cmd.Flags().IntVar(&client.Version, "revision", 0, "get the named release with revision") + f := cmd.Flags() + f.IntVar(&client.Version, "revision", 0, "get the named release with revision") + flag := f.Lookup("revision") + completion.RegisterFlagCompletionFunc(flag, func(cmd *cobra.Command, args []string, toComplete string) ([]string, completion.BashCompDirective) { + if len(args) == 1 { + return compListRevisions(cfg, args[0]) + } + return nil, completion.BashCompDirectiveNoFileComp + }) return cmd } diff --git a/cmd/helm/get_notes.go b/cmd/helm/get_notes.go index de3adf498..4491bd9ba 100644 --- a/cmd/helm/get_notes.go +++ b/cmd/helm/get_notes.go @@ -61,6 +61,13 @@ func newGetNotesCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { f := cmd.Flags() f.IntVar(&client.Version, "revision", 0, "get the named release with revision") + flag := f.Lookup("revision") + completion.RegisterFlagCompletionFunc(flag, func(cmd *cobra.Command, args []string, toComplete string) ([]string, completion.BashCompDirective) { + if len(args) == 1 { + return compListRevisions(cfg, args[0]) + } + return nil, completion.BashCompDirectiveNoFileComp + }) return cmd } diff --git a/cmd/helm/get_values.go b/cmd/helm/get_values.go index 898db8929..a8c5acc5e 100644 --- a/cmd/helm/get_values.go +++ b/cmd/helm/get_values.go @@ -65,6 +65,13 @@ func newGetValuesCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { f := cmd.Flags() f.IntVar(&client.Version, "revision", 0, "get the named release with revision") + flag := f.Lookup("revision") + completion.RegisterFlagCompletionFunc(flag, func(cmd *cobra.Command, args []string, toComplete string) ([]string, completion.BashCompDirective) { + if len(args) == 1 { + return compListRevisions(cfg, args[0]) + } + return nil, completion.BashCompDirectiveNoFileComp + }) f.BoolVarP(&client.AllValues, "all", "a", false, "dump all (computed) values") bindOutputFlag(cmd, &outfmt) diff --git a/cmd/helm/history.go b/cmd/helm/history.go index 739848c1c..3ef542e58 100644 --- a/cmd/helm/history.go +++ b/cmd/helm/history.go @@ -19,6 +19,7 @@ package main import ( "fmt" "io" + "strconv" "time" "github.com/gosuri/uitable" @@ -185,3 +186,16 @@ func min(x, y int) int { } return y } + +func compListRevisions(cfg *action.Configuration, releaseName string) ([]string, completion.BashCompDirective) { + client := action.NewHistory(cfg) + + var revisions []string + if hist, err := client.Run(releaseName); err == nil { + for _, release := range hist { + revisions = append(revisions, strconv.Itoa(release.Version)) + } + return revisions, completion.BashCompDirectiveDefault + } + return nil, completion.BashCompDirectiveError +} diff --git a/cmd/helm/status.go b/cmd/helm/status.go index 8f172f66a..34543c6cb 100644 --- a/cmd/helm/status.go +++ b/cmd/helm/status.go @@ -75,7 +75,16 @@ func newStatusCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { }) f := cmd.PersistentFlags() + f.IntVar(&client.Version, "revision", 0, "if set, display the status of the named release with revision") + flag := f.Lookup("revision") + completion.RegisterFlagCompletionFunc(flag, func(cmd *cobra.Command, args []string, toComplete string) ([]string, completion.BashCompDirective) { + if len(args) == 1 { + return compListRevisions(cfg, args[0]) + } + return nil, completion.BashCompDirectiveNoFileComp + }) + bindOutputFlag(cmd, &outfmt) return cmd From 90548c591d9f2662c2db303b4cb09feaab33a02d Mon Sep 17 00:00:00 2001 From: Marc Khouzam Date: Thu, 9 Jan 2020 20:28:46 -0500 Subject: [PATCH 5/9] feat(comp): Don't use error codes for completion To use error codes to indicate completion directive to the completion script had us use os.Exit() in the __complete command. This prevented go tests calling the __complete command from succeeding. Another option was to return an error containing an error code (like is done for helm plugins) instead of calling os.Exit(). However such an approach requires a change in how helm handles the returned error of a Cobra command; although we can do this for Helm, it would be an annoying requirement for other programs if we ever push this completion logic into Cobra. The chosen solution instead is to printout the directive at the end of the list of completions, and have the completion script extract it. Note that we print both the completions and directive to stdout. It would have been interesting to print the completions to stdout and the directive to stderr; however, it is very complicated for the completion script to extract both stdout and stderr to different variables, and even if possible, such code would not be portable to many shells. Printing both to stdout is much simpler. Signed-off-by: Marc Khouzam --- cmd/helm/root.go | 14 ++++++++++++- internal/completion/complete.go | 35 +++++++++++++++++++++------------ 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/cmd/helm/root.go b/cmd/helm/root.go index ab7b01c90..bf98e72fe 100644 --- a/cmd/helm/root.go +++ b/cmd/helm/root.go @@ -65,18 +65,30 @@ __helm_custom_func() __helm_debug "${FUNCNAME[0]}: calling ${requestComp}" # Use eval to handle any environment variables and such out=$(eval ${requestComp} 2>/dev/null) - directive=$? + + # Extract the directive int at the very end of the output following a : + directive=${out##*:} + # Remove the directive + out=${out%%:*} + if [ "${directive}" = "${out}" ]; then + # There is not directive specified + directive=0 + fi + __helm_debug "${FUNCNAME[0]}: the completion directive is: ${directive}" + __helm_debug "${FUNCNAME[0]}: the completions are: ${out[*]}" if [ $((${directive} & %[2]d)) -ne 0 ]; then __helm_debug "${FUNCNAME[0]}: received error, completion failed" else if [ $((${directive} & %[3]d)) -ne 0 ]; then if [[ $(type -t compopt) = "builtin" ]]; then + __helm_debug "${FUNCNAME[0]}: activating no space" compopt -o nospace fi fi if [ $((${directive} & %[4]d)) -ne 0 ]; then if [[ $(type -t compopt) = "builtin" ]]; then + __helm_debug "${FUNCNAME[0]}: activating no file completion" compopt +o default fi fi diff --git a/internal/completion/complete.go b/internal/completion/complete.go index c0fabda1b..d13c7f5bd 100644 --- a/internal/completion/complete.go +++ b/internal/completion/complete.go @@ -16,6 +16,7 @@ limitations under the License. package completion import ( + "errors" "fmt" "log" "os" @@ -124,13 +125,17 @@ func NewCompleteCmd(settings *cli.EnvSettings) *cobra.Command { Run: func(cmd *cobra.Command, args []string) { CompDebugln(fmt.Sprintf("%s was called with args %v", cmd.Name(), args)) - flag, trimmedArgs, toComplete := checkIfFlagCompletion(cmd.Root(), args[:len(args)-1], args[len(args)-1]) - + flag, trimmedArgs, toComplete, err := checkIfFlagCompletion(cmd.Root(), args[:len(args)-1], args[len(args)-1]) + if err != nil { + // Error while attempting to parse flags + CompErrorln(err.Error()) + return + } // Find the real command for which completion must be performed finalCmd, finalArgs, err := cmd.Root().Find(trimmedArgs) if err != nil { // Unable to find the real command. E.g., helm invalidCmd - os.Exit(int(BashCompDirectiveError)) + return } CompDebugln(fmt.Sprintf("Found final command '%s', with finalArgs %v", finalCmd.Name(), finalArgs)) @@ -167,10 +172,15 @@ func NewCompleteCmd(settings *cli.EnvSettings) *cobra.Command { fmt.Println(comp) } - // Print some helpful info to stderr for the user to see. + // As the last printout, print the completion directive for the + // completion script to parse. + // The directive integer must be that last character following a single : + // The completion script expects :directive + fmt.Printf("\n:%d\n", directive) + + // Print some helpful info to stderr for the user to understand. // Output from stderr should be ignored from the completion script. fmt.Fprintf(os.Stderr, "Completion ended with directive: %s\n", directive.string()) - os.Exit(int(directive)) }, } } @@ -179,7 +189,7 @@ func isFlag(arg string) bool { return len(arg) > 0 && arg[0] == '-' } -func checkIfFlagCompletion(rootCmd *cobra.Command, args []string, lastArg string) (*pflag.Flag, []string, string) { +func checkIfFlagCompletion(rootCmd *cobra.Command, args []string, lastArg string) (*pflag.Flag, []string, string, error) { var flagName string trimmedArgs := args flagWithEqual := false @@ -189,8 +199,7 @@ func checkIfFlagCompletion(rootCmd *cobra.Command, args []string, lastArg string lastArg = lastArg[index+1:] flagWithEqual = true } else { - CompErrorln("Unexpected completion request for flag") - os.Exit(int(BashCompDirectiveError)) + return nil, nil, "", errors.New("Unexpected completion request for flag") } } @@ -212,14 +221,14 @@ func checkIfFlagCompletion(rootCmd *cobra.Command, args []string, lastArg string if len(flagName) == 0 { // Not doing flag completion - return nil, trimmedArgs, lastArg + return nil, trimmedArgs, lastArg, nil } // Find the real command for which completion must be performed finalCmd, _, err := rootCmd.Find(trimmedArgs) if err != nil { // Unable to find the real command. E.g., helm invalidCmd - os.Exit(int(BashCompDirectiveError)) + return nil, nil, "", errors.New("Unable to find final command for completion") } CompDebugln(fmt.Sprintf("checkIfFlagCompletion: found final command '%s'", finalCmd.Name())) @@ -227,8 +236,8 @@ func checkIfFlagCompletion(rootCmd *cobra.Command, args []string, lastArg string flag := findFlag(finalCmd, flagName) if flag == nil { // Flag not supported by this command, nothing to complete - CompDebugln(fmt.Sprintf("Subcommand '%s' does not support flag '%s'", finalCmd.Name(), flagName)) - os.Exit(int(BashCompDirectiveNoFileComp)) + err = fmt.Errorf("Subcommand '%s' does not support flag '%s'", finalCmd.Name(), flagName) + return nil, nil, "", err } if !flagWithEqual { @@ -241,7 +250,7 @@ func checkIfFlagCompletion(rootCmd *cobra.Command, args []string, lastArg string } } - return flag, trimmedArgs, lastArg + return flag, trimmedArgs, lastArg, nil } func findFlag(cmd *cobra.Command, name string) *pflag.Flag { From 0095e320012b606d70f0fa52f5bb4084275a2ffa Mon Sep 17 00:00:00 2001 From: Marc Khouzam Date: Wed, 8 Jan 2020 15:04:07 -0500 Subject: [PATCH 6/9] feat(test): add some completion tests Signed-off-by: Marc Khouzam --- cmd/helm/flags_test.go | 88 +++++++++++++++++++ cmd/helm/get_all_test.go | 4 + cmd/helm/get_hooks_test.go | 4 + cmd/helm/get_manifest_test.go | 4 + cmd/helm/get_notes_test.go | 4 + cmd/helm/get_values_test.go | 8 ++ cmd/helm/helm_test.go | 29 ++++++ cmd/helm/history_test.go | 40 +++++++++ cmd/helm/install_test.go | 4 + cmd/helm/list_test.go | 4 + cmd/helm/repo_list_test.go | 25 ++++++ cmd/helm/root.go | 2 +- cmd/helm/search_hub_test.go | 3 + cmd/helm/search_repo_test.go | 4 + cmd/helm/status_test.go | 63 +++++++++++++ cmd/helm/testdata/output/output-comp.txt | 4 + cmd/helm/testdata/output/revision-comp.txt | 5 ++ .../output/revision-wrong-args-comp.txt | 1 + cmd/helm/testdata/output/status-comp.txt | 3 + .../output/status-wrong-args-comp.txt | 1 + cmd/helm/upgrade_test.go | 4 + internal/completion/complete.go | 7 +- 22 files changed, 307 insertions(+), 4 deletions(-) create mode 100644 cmd/helm/flags_test.go create mode 100644 cmd/helm/repo_list_test.go create mode 100644 cmd/helm/testdata/output/output-comp.txt create mode 100644 cmd/helm/testdata/output/revision-comp.txt create mode 100644 cmd/helm/testdata/output/revision-wrong-args-comp.txt create mode 100644 cmd/helm/testdata/output/status-comp.txt create mode 100644 cmd/helm/testdata/output/status-wrong-args-comp.txt diff --git a/cmd/helm/flags_test.go b/cmd/helm/flags_test.go new file mode 100644 index 000000000..d5576fe9f --- /dev/null +++ b/cmd/helm/flags_test.go @@ -0,0 +1,88 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "fmt" + "testing" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/release" + helmtime "helm.sh/helm/v3/pkg/time" +) + +func outputFlagCompletionTest(t *testing.T, cmdName string) { + releasesMockWithStatus := func(info *release.Info, hooks ...*release.Hook) []*release.Release { + info.LastDeployed = helmtime.Unix(1452902400, 0).UTC() + return []*release.Release{{ + Name: "athos", + Namespace: "default", + Info: info, + Chart: &chart.Chart{}, + Hooks: hooks, + }, { + Name: "porthos", + Namespace: "default", + Info: info, + Chart: &chart.Chart{}, + Hooks: hooks, + }, { + Name: "aramis", + Namespace: "default", + Info: info, + Chart: &chart.Chart{}, + Hooks: hooks, + }, { + Name: "dartagnan", + Namespace: "gascony", + Info: info, + Chart: &chart.Chart{}, + Hooks: hooks, + }} + } + + tests := []cmdTestCase{{ + name: "completion for output flag long and before arg", + cmd: fmt.Sprintf("__complete %s --output ''", cmdName), + golden: "output/output-comp.txt", + rels: releasesMockWithStatus(&release.Info{ + Status: release.StatusDeployed, + }), + }, { + name: "completion for output flag long and after arg", + cmd: fmt.Sprintf("__complete %s aramis --output ''", cmdName), + golden: "output/output-comp.txt", + rels: releasesMockWithStatus(&release.Info{ + Status: release.StatusDeployed, + }), + }, { + name: "completion for output flag short and before arg", + cmd: fmt.Sprintf("__complete %s -o ''", cmdName), + golden: "output/output-comp.txt", + rels: releasesMockWithStatus(&release.Info{ + Status: release.StatusDeployed, + }), + }, { + name: "completion for output flag short and after arg", + cmd: fmt.Sprintf("__complete %s aramis -o ''", cmdName), + golden: "output/output-comp.txt", + rels: releasesMockWithStatus(&release.Info{ + Status: release.StatusDeployed, + }), + }} + runTestCmd(t, tests) +} diff --git a/cmd/helm/get_all_test.go b/cmd/helm/get_all_test.go index 0b026fca4..a213ac583 100644 --- a/cmd/helm/get_all_test.go +++ b/cmd/helm/get_all_test.go @@ -41,3 +41,7 @@ func TestGetCmd(t *testing.T) { }} runTestCmd(t, tests) } + +func TestGetAllRevisionCompletion(t *testing.T) { + revisionFlagCompletionTest(t, "get all") +} diff --git a/cmd/helm/get_hooks_test.go b/cmd/helm/get_hooks_test.go index f843f7d59..7b9142d12 100644 --- a/cmd/helm/get_hooks_test.go +++ b/cmd/helm/get_hooks_test.go @@ -36,3 +36,7 @@ func TestGetHooks(t *testing.T) { }} runTestCmd(t, tests) } + +func TestGetHooksRevisionCompletion(t *testing.T) { + revisionFlagCompletionTest(t, "get hooks") +} diff --git a/cmd/helm/get_manifest_test.go b/cmd/helm/get_manifest_test.go index be54d4a5b..bd0ffc5d6 100644 --- a/cmd/helm/get_manifest_test.go +++ b/cmd/helm/get_manifest_test.go @@ -36,3 +36,7 @@ func TestGetManifest(t *testing.T) { }} runTestCmd(t, tests) } + +func TestGetManifestRevisionCompletion(t *testing.T) { + revisionFlagCompletionTest(t, "get manifest") +} diff --git a/cmd/helm/get_notes_test.go b/cmd/helm/get_notes_test.go index a3ddea9a7..a59120b77 100644 --- a/cmd/helm/get_notes_test.go +++ b/cmd/helm/get_notes_test.go @@ -36,3 +36,7 @@ func TestGetNotesCmd(t *testing.T) { }} runTestCmd(t, tests) } + +func TestGetNotesRevisionCompletion(t *testing.T) { + revisionFlagCompletionTest(t, "get notes") +} diff --git a/cmd/helm/get_values_test.go b/cmd/helm/get_values_test.go index da3cc9e39..ecd92d354 100644 --- a/cmd/helm/get_values_test.go +++ b/cmd/helm/get_values_test.go @@ -52,3 +52,11 @@ func TestGetValuesCmd(t *testing.T) { }} runTestCmd(t, tests) } + +func TestGetValuesRevisionCompletion(t *testing.T) { + revisionFlagCompletionTest(t, "get values") +} + +func TestGetValuesOutputCompletion(t *testing.T) { + outputFlagCompletionTest(t, "get values") +} diff --git a/cmd/helm/helm_test.go b/cmd/helm/helm_test.go index b7156daf3..5f9d80a3a 100644 --- a/cmd/helm/helm_test.go +++ b/cmd/helm/helm_test.go @@ -18,6 +18,7 @@ package main import ( "bytes" + "fmt" "io/ioutil" "os" "os/exec" @@ -96,11 +97,39 @@ func storageFixture() *storage.Storage { return storage.Init(driver.NewMemory()) } +// go-shellwords does not handle empty arguments properly +// https://github.com/mattn/go-shellwords/issues/5#issuecomment-573431458 +// +// This method checks if the last argument was an empty one, +// and if go-shellwords missed it, we add it ourselves. +// +// This is important for completion tests as completion often +// uses an empty last parameter. +func checkLastEmpty(in string, out []string) []string { + lastIndex := len(in) - 1 + + if lastIndex >= 1 && (in[lastIndex] == '"' && in[lastIndex-1] == '"' || + in[lastIndex] == '\'' && in[lastIndex-1] == '\'') { + // The last parameter of 'in' was empty ("" or ''), let's make sure it was detected. + if len(out) > 0 && out[len(out)-1] != "" { + // Bug from go-shellwords: + // 'out' does not have the empty parameter. We add it ourselves as a workaround. + out = append(out, "") + } else { + fmt.Println("WARNING: go-shellwords seems to have been fixed. This workaround can be removed.") + } + } + return out +} + func executeActionCommandC(store *storage.Storage, cmd string) (*cobra.Command, string, error) { args, err := shellwords.Parse(cmd) if err != nil { return nil, "", err } + // Workaround the bug in shellwords + args = checkLastEmpty(cmd, args) + buf := new(bytes.Buffer) actionConfig := &action.Configuration{ diff --git a/cmd/helm/history_test.go b/cmd/helm/history_test.go index 3e750cefc..c9bfb648e 100644 --- a/cmd/helm/history_test.go +++ b/cmd/helm/history_test.go @@ -17,6 +17,7 @@ limitations under the License. package main import ( + "fmt" "testing" "helm.sh/helm/v3/pkg/release" @@ -68,3 +69,42 @@ func TestHistoryCmd(t *testing.T) { }} runTestCmd(t, tests) } + +func TestHistoryOutputCompletion(t *testing.T) { + outputFlagCompletionTest(t, "history") +} + +func revisionFlagCompletionTest(t *testing.T, cmdName string) { + mk := func(name string, vers int, status release.Status) *release.Release { + return release.Mock(&release.MockReleaseOptions{ + Name: name, + Version: vers, + Status: status, + }) + } + + releases := []*release.Release{ + mk("musketeers", 11, release.StatusDeployed), + mk("musketeers", 10, release.StatusSuperseded), + mk("musketeers", 9, release.StatusSuperseded), + mk("musketeers", 8, release.StatusSuperseded), + } + + tests := []cmdTestCase{{ + name: "completion for revision flag", + cmd: fmt.Sprintf("__complete %s musketeers --revision ''", cmdName), + rels: releases, + golden: "output/revision-comp.txt", + }, { + name: "completion for revision flag with too few args", + cmd: fmt.Sprintf("__complete %s --revision ''", cmdName), + rels: releases, + golden: "output/revision-wrong-args-comp.txt", + }, { + name: "completion for revision flag with too many args", + cmd: fmt.Sprintf("__complete %s three musketeers --revision ''", cmdName), + rels: releases, + golden: "output/revision-wrong-args-comp.txt", + }} + runTestCmd(t, tests) +} diff --git a/cmd/helm/install_test.go b/cmd/helm/install_test.go index e8b573dfc..57972024f 100644 --- a/cmd/helm/install_test.go +++ b/cmd/helm/install_test.go @@ -193,3 +193,7 @@ func TestInstall(t *testing.T) { runTestActionCmd(t, tests) } + +func TestInstallOutputCompletion(t *testing.T) { + outputFlagCompletionTest(t, "install") +} diff --git a/cmd/helm/list_test.go b/cmd/helm/list_test.go index b5833fd72..127a8a980 100644 --- a/cmd/helm/list_test.go +++ b/cmd/helm/list_test.go @@ -206,3 +206,7 @@ func TestListCmd(t *testing.T) { }} runTestCmd(t, tests) } + +func TestListOutputCompletion(t *testing.T) { + outputFlagCompletionTest(t, "list") +} diff --git a/cmd/helm/repo_list_test.go b/cmd/helm/repo_list_test.go new file mode 100644 index 000000000..f371452f2 --- /dev/null +++ b/cmd/helm/repo_list_test.go @@ -0,0 +1,25 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "testing" +) + +func TestRepoListOutputCompletion(t *testing.T) { + outputFlagCompletionTest(t, "repo list") +} diff --git a/cmd/helm/root.go b/cmd/helm/root.go index bf98e72fe..c6c055993 100644 --- a/cmd/helm/root.go +++ b/cmd/helm/root.go @@ -232,7 +232,7 @@ func newRootCmd(actionConfig *action.Configuration, out io.Writer, args []string newDocsCmd(out), // Setup the special hidden __complete command to allow for dynamic auto-completion - completion.NewCompleteCmd(settings), + completion.NewCompleteCmd(settings, out), ) // Add annotation to flags for which we can generate completion choices diff --git a/cmd/helm/search_hub_test.go b/cmd/helm/search_hub_test.go index dfe0cacc2..7b0f3a389 100644 --- a/cmd/helm/search_hub_test.go +++ b/cmd/helm/search_hub_test.go @@ -49,5 +49,8 @@ func TestSearchHubCmd(t *testing.T) { t.Log(out) t.Log(expected) } +} +func TestSearchHubOutputCompletion(t *testing.T) { + outputFlagCompletionTest(t, "search hub") } diff --git a/cmd/helm/search_repo_test.go b/cmd/helm/search_repo_test.go index 6ece55505..402ef2970 100644 --- a/cmd/helm/search_repo_test.go +++ b/cmd/helm/search_repo_test.go @@ -83,3 +83,7 @@ func TestSearchRepositoriesCmd(t *testing.T) { } runTestCmd(t, tests) } + +func TestSearchRepoOutputCompletion(t *testing.T) { + outputFlagCompletionTest(t, "search repo") +} diff --git a/cmd/helm/status_test.go b/cmd/helm/status_test.go index 91a008e5a..0d2500e65 100644 --- a/cmd/helm/status_test.go +++ b/cmd/helm/status_test.go @@ -108,3 +108,66 @@ func mustParseTime(t string) helmtime.Time { res, _ := helmtime.Parse(time.RFC3339, t) return res } + +func TestStatusCompletion(t *testing.T) { + releasesMockWithStatus := func(info *release.Info, hooks ...*release.Hook) []*release.Release { + info.LastDeployed = helmtime.Unix(1452902400, 0).UTC() + return []*release.Release{{ + Name: "athos", + Namespace: "default", + Info: info, + Chart: &chart.Chart{}, + Hooks: hooks, + }, { + Name: "porthos", + Namespace: "default", + Info: info, + Chart: &chart.Chart{}, + Hooks: hooks, + }, { + Name: "aramis", + Namespace: "default", + Info: info, + Chart: &chart.Chart{}, + Hooks: hooks, + }, { + Name: "dartagnan", + Namespace: "gascony", + Info: info, + Chart: &chart.Chart{}, + Hooks: hooks, + }} + } + + tests := []cmdTestCase{{ + name: "completion for status", + cmd: "__complete status a", + golden: "output/status-comp.txt", + rels: releasesMockWithStatus(&release.Info{ + Status: release.StatusDeployed, + }), + }, { + name: "completion for status with too many arguments", + cmd: "__complete status dartagnan ''", + golden: "output/status-wrong-args-comp.txt", + rels: releasesMockWithStatus(&release.Info{ + Status: release.StatusDeployed, + }), + }, { + name: "completion for status with too many arguments", + cmd: "__complete status --debug a", + golden: "output/status-comp.txt", + rels: releasesMockWithStatus(&release.Info{ + Status: release.StatusDeployed, + }), + }} + runTestCmd(t, tests) +} + +func TestStatusRevisionCompletion(t *testing.T) { + revisionFlagCompletionTest(t, "status") +} + +func TestStatusOutputCompletion(t *testing.T) { + outputFlagCompletionTest(t, "status") +} diff --git a/cmd/helm/testdata/output/output-comp.txt b/cmd/helm/testdata/output/output-comp.txt new file mode 100644 index 000000000..de5f16f1d --- /dev/null +++ b/cmd/helm/testdata/output/output-comp.txt @@ -0,0 +1,4 @@ +table +json +yaml +:0 diff --git a/cmd/helm/testdata/output/revision-comp.txt b/cmd/helm/testdata/output/revision-comp.txt new file mode 100644 index 000000000..b4799f059 --- /dev/null +++ b/cmd/helm/testdata/output/revision-comp.txt @@ -0,0 +1,5 @@ +8 +9 +10 +11 +:0 diff --git a/cmd/helm/testdata/output/revision-wrong-args-comp.txt b/cmd/helm/testdata/output/revision-wrong-args-comp.txt new file mode 100644 index 000000000..b6f867176 --- /dev/null +++ b/cmd/helm/testdata/output/revision-wrong-args-comp.txt @@ -0,0 +1 @@ +:4 diff --git a/cmd/helm/testdata/output/status-comp.txt b/cmd/helm/testdata/output/status-comp.txt new file mode 100644 index 000000000..c97882964 --- /dev/null +++ b/cmd/helm/testdata/output/status-comp.txt @@ -0,0 +1,3 @@ +aramis +athos +:4 diff --git a/cmd/helm/testdata/output/status-wrong-args-comp.txt b/cmd/helm/testdata/output/status-wrong-args-comp.txt new file mode 100644 index 000000000..b6f867176 --- /dev/null +++ b/cmd/helm/testdata/output/status-wrong-args-comp.txt @@ -0,0 +1 @@ +:4 diff --git a/cmd/helm/upgrade_test.go b/cmd/helm/upgrade_test.go index bd1ccec35..3cecbe6d3 100644 --- a/cmd/helm/upgrade_test.go +++ b/cmd/helm/upgrade_test.go @@ -258,3 +258,7 @@ func prepareMockRelease(releaseName string, t *testing.T) (func(n string, v int, return relMock, ch, chartPath } + +func TestUpgradeOutputCompletion(t *testing.T) { + outputFlagCompletionTest(t, "upgrade") +} diff --git a/internal/completion/complete.go b/internal/completion/complete.go index d13c7f5bd..ccc868a59 100644 --- a/internal/completion/complete.go +++ b/internal/completion/complete.go @@ -18,6 +18,7 @@ package completion import ( "errors" "fmt" + "io" "log" "os" "strings" @@ -111,7 +112,7 @@ func (d BashCompDirective) string() string { } // NewCompleteCmd add a special hidden command that an be used to request completions -func NewCompleteCmd(settings *cli.EnvSettings) *cobra.Command { +func NewCompleteCmd(settings *cli.EnvSettings, out io.Writer) *cobra.Command { debug = settings.Debug return &cobra.Command{ Use: fmt.Sprintf("%s [command-line]", CompRequestCmd), @@ -169,14 +170,14 @@ func NewCompleteCmd(settings *cli.EnvSettings) *cobra.Command { completions, directive := completionFn(finalCmd, argsWoFlags, toComplete) for _, comp := range completions { // Print each possible completion to stdout for the completion script to consume. - fmt.Println(comp) + fmt.Fprintln(out, comp) } // As the last printout, print the completion directive for the // completion script to parse. // The directive integer must be that last character following a single : // The completion script expects :directive - fmt.Printf("\n:%d\n", directive) + fmt.Fprintln(out, fmt.Sprintf(":%d", directive)) // Print some helpful info to stderr for the user to understand. // Output from stderr should be ignored from the completion script. From fc618b4b6e579ba5582f50ceb7f9c9e14735ec78 Mon Sep 17 00:00:00 2001 From: Marc Khouzam Date: Wed, 15 Jan 2020 19:01:54 -0500 Subject: [PATCH 7/9] feat(comp): Use cached charts file for speed Signed-off-by: Marc Khouzam --- cmd/helm/search_repo.go | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/cmd/helm/search_repo.go b/cmd/helm/search_repo.go index a57ad135b..9f5af1e3c 100644 --- a/cmd/helm/search_repo.go +++ b/cmd/helm/search_repo.go @@ -17,6 +17,8 @@ limitations under the License. package main import ( + "bufio" + "bytes" "fmt" "io" "io/ioutil" @@ -251,17 +253,39 @@ func (r *repoSearchWriter) encodeByFormat(out io.Writer, format output.Format) e // Provides the list of charts that are part of the specified repo, and that starts with 'prefix'. func compListChartsOfRepo(repoName string, prefix string) []string { - f := filepath.Join(settings.RepositoryCache, helmpath.CacheIndexFile(repoName)) var charts []string - if indexFile, err := repo.LoadIndexFile(f); err == nil { - for name := range indexFile.Entries { - fullName := fmt.Sprintf("%s/%s", repoName, name) + + path := filepath.Join(settings.RepositoryCache, helmpath.CacheChartsFile(repoName)) + content, err := ioutil.ReadFile(path) + if err == nil { + scanner := bufio.NewScanner(bytes.NewReader(content)) + for scanner.Scan() { + fullName := fmt.Sprintf("%s/%s", repoName, scanner.Text()) if strings.HasPrefix(fullName, prefix) { charts = append(charts, fullName) } } + return charts } - return charts + + if isNotExist(err) { + // If there is no cached charts file, fallback to the full index file. + // This is much slower but can happen after the caching feature is first + // installed but before the user does a 'helm repo update' to generate the + // first cached charts file. + path = filepath.Join(settings.RepositoryCache, helmpath.CacheIndexFile(repoName)) + if indexFile, err := repo.LoadIndexFile(path); err == nil { + for name := range indexFile.Entries { + fullName := fmt.Sprintf("%s/%s", repoName, name) + if strings.HasPrefix(fullName, prefix) { + charts = append(charts, fullName) + } + } + return charts + } + } + + return []string{} } // Provide dynamic auto-completion for commands that operate on charts (e.g., helm show) From a8369db802d47f0b60f7d84602da85d28bc79be4 Mon Sep 17 00:00:00 2001 From: Marc Khouzam Date: Wed, 15 Jan 2020 19:30:04 -0500 Subject: [PATCH 8/9] feat(comp): Isolate go completion framework better Signed-off-by: Marc Khouzam --- cmd/helm/root.go | 75 +++------------------------------ internal/completion/complete.go | 70 ++++++++++++++++++++++++++++-- 2 files changed, 73 insertions(+), 72 deletions(-) diff --git a/cmd/helm/root.go b/cmd/helm/root.go index c6c055993..a3e4da746 100644 --- a/cmd/helm/root.go +++ b/cmd/helm/root.go @@ -32,7 +32,7 @@ import ( ) const ( - bashCompletionFunc = ` + contextCompFunc = ` __helm_get_contexts() { __helm_debug "${FUNCNAME[0]}: c is $c words[c] is ${words[c]}" @@ -42,62 +42,6 @@ __helm_get_contexts() COMPREPLY=( $( compgen -W "${out[*]}" -- "$cur" ) ) fi } - -__helm_custom_func() -{ - __helm_debug "${FUNCNAME[0]}: c is $c, words[@] is ${words[@]}, #words[@] is ${#words[@]}" - __helm_debug "${FUNCNAME[0]}: cur is ${cur}, cword is ${cword}, words is ${words}" - - local out requestComp lastParam lastChar - requestComp="${words[0]} %[1]s ${words[@]:1}" - - lastParam=${words[$((${#words[@]}-1))]} - lastChar=${lastParam:$((${#lastParam}-1)):1} - __helm_debug "${FUNCNAME[0]}: 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. - __helm_debug "${FUNCNAME[0]}: Adding extra empty parameter" - requestComp="${requestComp} \"\"" - fi - - __helm_debug "${FUNCNAME[0]}: calling ${requestComp}" - # Use eval to handle any environment variables and such - out=$(eval ${requestComp} 2>/dev/null) - - # Extract the directive int at the very end of the output following a : - directive=${out##*:} - # Remove the directive - out=${out%%:*} - if [ "${directive}" = "${out}" ]; then - # There is not directive specified - directive=0 - fi - __helm_debug "${FUNCNAME[0]}: the completion directive is: ${directive}" - __helm_debug "${FUNCNAME[0]}: the completions are: ${out[*]}" - - if [ $((${directive} & %[2]d)) -ne 0 ]; then - __helm_debug "${FUNCNAME[0]}: received error, completion failed" - else - if [ $((${directive} & %[3]d)) -ne 0 ]; then - if [[ $(type -t compopt) = "builtin" ]]; then - __helm_debug "${FUNCNAME[0]}: activating no space" - compopt -o nospace - fi - fi - if [ $((${directive} & %[4]d)) -ne 0 ]; then - if [[ $(type -t compopt) = "builtin" ]]; then - __helm_debug "${FUNCNAME[0]}: activating no file completion" - compopt +o default - fi - fi - - while IFS='' read -r comp; do - COMPREPLY+=("$comp") - done < <(compgen -W "${out[*]}" -- "$cur") - fi -} ` ) @@ -152,17 +96,12 @@ By default, the default directories depend on the Operating System. The defaults func newRootCmd(actionConfig *action.Configuration, out io.Writer, args []string) *cobra.Command { cmd := &cobra.Command{ - Use: "helm", - Short: "The Helm package manager for Kubernetes.", - Long: globalUsage, - SilenceUsage: true, - Args: require.NoArgs, - BashCompletionFunction: fmt.Sprintf( - bashCompletionFunc, - completion.CompRequestCmd, - completion.BashCompDirectiveError, - completion.BashCompDirectiveNoSpace, - completion.BashCompDirectiveNoFileComp), + Use: "helm", + Short: "The Helm package manager for Kubernetes.", + Long: globalUsage, + SilenceUsage: true, + Args: require.NoArgs, + BashCompletionFunction: fmt.Sprintf("%s%s", contextCompFunc, completion.GetBashCustomFunction()), } flags := cmd.PersistentFlags() diff --git a/internal/completion/complete.go b/internal/completion/complete.go index ccc868a59..a24390fc0 100644 --- a/internal/completion/complete.go +++ b/internal/completion/complete.go @@ -35,9 +35,9 @@ import ( // This should ultimately be pushed down into Cobra. // ================================================================================== -// CompRequestCmd Hidden command to request completion results from the program. +// compRequestCmd Hidden command to request completion results from the program. // Used by the shell completion script. -const CompRequestCmd = "__complete" +const compRequestCmd = "__complete" // Global map allowing to find completion functions for commands or flags. var validArgsFunctions = map[interface{}]func(cmd *cobra.Command, args []string, toComplete string) ([]string, BashCompDirective){} @@ -64,6 +64,68 @@ const ( BashCompDirectiveDefault BashCompDirective = 0 ) +// GetBashCustomFunction returns the bash code to handle custom go completion +// This should eventually be provided by Cobra +func GetBashCustomFunction() string { + return fmt.Sprintf(` +__helm_custom_func() +{ + __helm_debug "${FUNCNAME[0]}: c is $c, words[@] is ${words[@]}, #words[@] is ${#words[@]}" + __helm_debug "${FUNCNAME[0]}: cur is ${cur}, cword is ${cword}, words is ${words}" + + local out requestComp lastParam lastChar + requestComp="${words[0]} %[1]s ${words[@]:1}" + + lastParam=${words[$((${#words[@]}-1))]} + lastChar=${lastParam:$((${#lastParam}-1)):1} + __helm_debug "${FUNCNAME[0]}: 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. + __helm_debug "${FUNCNAME[0]}: Adding extra empty parameter" + requestComp="${requestComp} \"\"" + fi + + __helm_debug "${FUNCNAME[0]}: calling ${requestComp}" + # Use eval to handle any environment variables and such + out=$(eval ${requestComp} 2>/dev/null) + + # Extract the directive int at the very end of the output following a : + directive=${out##*:} + # Remove the directive + out=${out%%:*} + if [ "${directive}" = "${out}" ]; then + # There is not directive specified + directive=0 + fi + __helm_debug "${FUNCNAME[0]}: the completion directive is: ${directive}" + __helm_debug "${FUNCNAME[0]}: the completions are: ${out[*]}" + + if [ $((${directive} & %[2]d)) -ne 0 ]; then + __helm_debug "${FUNCNAME[0]}: received error, completion failed" + else + if [ $((${directive} & %[3]d)) -ne 0 ]; then + if [[ $(type -t compopt) = "builtin" ]]; then + __helm_debug "${FUNCNAME[0]}: activating no space" + compopt -o nospace + fi + fi + if [ $((${directive} & %[4]d)) -ne 0 ]; then + if [[ $(type -t compopt) = "builtin" ]]; then + __helm_debug "${FUNCNAME[0]}: activating no file completion" + compopt +o default + fi + fi + + while IFS='' read -r comp; do + COMPREPLY+=("$comp") + done < <(compgen -W "${out[*]}" -- "$cur") + fi +} +`, compRequestCmd, BashCompDirectiveError, BashCompDirectiveNoSpace, BashCompDirectiveNoFileComp) +} + // RegisterValidArgsFunc should be called to register a function to provide argument completion for a command func RegisterValidArgsFunc(cmd *cobra.Command, f func(cmd *cobra.Command, args []string, toComplete string) ([]string, BashCompDirective)) { if _, exists := validArgsFunctions[cmd]; exists { @@ -115,14 +177,14 @@ func (d BashCompDirective) string() string { func NewCompleteCmd(settings *cli.EnvSettings, out io.Writer) *cobra.Command { debug = settings.Debug return &cobra.Command{ - Use: fmt.Sprintf("%s [command-line]", CompRequestCmd), + Use: fmt.Sprintf("%s [command-line]", compRequestCmd), DisableFlagsInUseLine: true, Hidden: true, DisableFlagParsing: true, Args: require.MinimumNArgs(1), Short: "Request shell completion choices for the specified command-line", Long: fmt.Sprintf("%s is a special command that is used by the shell completion logic\n%s", - CompRequestCmd, "to request completion choices for the specified command-line."), + compRequestCmd, "to request completion choices for the specified command-line."), Run: func(cmd *cobra.Command, args []string) { CompDebugln(fmt.Sprintf("%s was called with args %v", cmd.Name(), args)) From 77b900106f923bf1d9b787f5e1d2c6c85be9722f Mon Sep 17 00:00:00 2001 From: Marc Khouzam Date: Thu, 16 Jan 2020 21:29:30 -0500 Subject: [PATCH 9/9] fix(comp): Update based on review comments Signed-off-by: Marc Khouzam --- cmd/helm/flags.go | 3 +-- cmd/helm/root.go | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/cmd/helm/flags.go b/cmd/helm/flags.go index b9473a5a7..65575a5c1 100644 --- a/cmd/helm/flags.go +++ b/cmd/helm/flags.go @@ -54,10 +54,9 @@ func addChartPathOptionsFlags(f *pflag.FlagSet, c *action.ChartPathOptions) { // value to the given format pointer func bindOutputFlag(cmd *cobra.Command, varRef *output.Format) { f := cmd.Flags() - f.VarP(newOutputValue(output.Table, varRef), outputFlag, "o", + flag := f.VarPF(newOutputValue(output.Table, varRef), outputFlag, "o", fmt.Sprintf("prints the output in the specified format. Allowed values: %s", strings.Join(output.Formats(), ", "))) - flag := f.Lookup(outputFlag) completion.RegisterFlagCompletionFunc(flag, func(cmd *cobra.Command, args []string, toComplete string) ([]string, completion.BashCompDirective) { var formatNames []string for _, format := range output.Formats() { diff --git a/cmd/helm/root.go b/cmd/helm/root.go index a3e4da746..6ce1dcbf4 100644 --- a/cmd/helm/root.go +++ b/cmd/helm/root.go @@ -117,7 +117,6 @@ func newRootCmd(actionConfig *action.Configuration, out io.Writer, args []string completion.CompDebugln(fmt.Sprintf("About to call kube client for namespaces with timeout of: %d", to)) nsNames := []string{} - // TODO can we request only the namespace with request.prefix? if namespaces, err := client.CoreV1().Namespaces().List(metav1.ListOptions{TimeoutSeconds: &to}); err == nil { for _, ns := range namespaces.Items { if strings.HasPrefix(ns.Name, toComplete) {