From b868e7078c32fa71a1dfbc7aa5bcd9077a7dbaf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn?= Date: Tue, 9 Apr 2019 20:26:25 +0200 Subject: [PATCH] Bash and zsh auto completions (#37) Bash custom completions for the run command. When cobra fails to find completions for the run command the bash function '__shuttle_custom_func' is called. The function looks up available scripts to run through shuttle it self (shuttle ls) and available arguments for the chosen script (shuttle run script --help). zsh completion is handled by wrapping the bash completions. This is copied by the way kubectl handles this. Closes #23. --- cmd/completion.go | 246 +++++++++++++++++++++++++++++++++++++--------- cmd/ls.go | 13 ++- cmd/root.go | 50 ++++++++++ go.mod | 2 +- go.sum | 2 + 5 files changed, 264 insertions(+), 49 deletions(-) diff --git a/cmd/completion.go b/cmd/completion.go index d2118c9..aa4ab8b 100644 --- a/cmd/completion.go +++ b/cmd/completion.go @@ -15,12 +15,12 @@ package cmd import ( - "os" - + "bytes" "fmt" + "io" + "os" "github.com/lunarway/shuttle/pkg/ui" - "github.com/pkg/errors" "github.com/spf13/cobra" ) @@ -29,45 +29,57 @@ var shell string // completionCmd represents the completion command var completionCmd = &cobra.Command{ Use: "completion ", - Short: "Generates shell completion scripts", - Long: `Output shell completion code for the specified shell (bash or zsh). The shell code must be evaluated to provide -interactive completion of shuttle commands. This can be done by sourcing it from the .bash _profile. - -Detailed instructions on how to do this are available here: -https://kubernetes.io/docs/tasks/tools/install-shuttle/#enabling-shell-autocompletion - -Note for zsh users: [1] zsh completions are only supported in versions of zsh >= 5.2 - -Examples: - # Installing bash completion on macOS using homebrew - ## If running Bash 3.2 included with macOS - brew install bash-completion - ## or, if running Bash 4.1+ - brew install bash-completion@2 - ## If shuttle is installed via homebrew, this should start working immediately. - ## If you've installed via other means, you may need add the completion to your completion directory - shuttle completion bash > $(brew --prefix)/etc/bash_completion.d/shuttle - - - # Installing bash completion on Linux - ## Load the shuttle completion code for bash into the current shell - source <(shuttle completion bash) - ## Write bash completion code to a file and source if from .bash_profile - shuttle completion bash > ~/.kube/completion.bash.inc - printf " - # Kubectl shell completion - source '$HOME/.kube/completion.bash.inc' - " >> $HOME/.bash_profile - source $HOME/.bash_profile - - # Load the shuttle completion code for zsh[1] into the current shell - source <(shuttle completion zsh) - # Set the shuttle completion code for zsh[1] to autoload on startup - shuttle completion zsh > "${fpath[1]}/_kubectl"`, + Short: `Output shell completion code`, + Long: `Output shell completion code for the specified shell (bash or zsh). +The shell code must be evaluated to provide interactive +completion of shuttle commands. This can be done by sourcing it from +the .bash_profile. + +Note for zsh users: zsh completions are only supported in versions of zsh >= 5.2 + +Installing bash completion on macOS using homebrew + + If running Bash 3.2 included with macOS + + brew install bash-completion + + If running Bash 4.1+ + + brew install bash-completion@2 + + You may need add the completion to your completion directory + + shuttle completion bash > $(brew --prefix)/etc/bash_completion.d/shuttle + +Installing bash completion on Linux + + If bash-completion is not installed on Linux, please install the 'bash-completion' package + via your distribution's package manager. + + Load the shuttle completion code for bash into the current shell + + source <(shuttle completion bash) + + Write bash completion code to a file and source if from .bash_profile + + shuttle completion bash > ~/.shuttle/completion.bash.inc + printf " + # shuttle shell completion + source '$HOME/.shuttle/completion.bash.inc' + " >> $HOME/.bash_profile + source $HOME/.bash_profile + + Load the shuttle completion code for zsh[1] into the current shell + + source <(shuttle completion zsh) + + Set the shuttle completion code for zsh[1] to autoload on startup + + shuttle completion zsh > "${fpath[1]}/_shuttle"`, ValidArgs: []string{"bash", "zsh"}, Args: func(cmd *cobra.Command, args []string) error { if cobra.ExactArgs(1)(cmd, args) != nil || cobra.OnlyValidArgs(cmd, args) != nil { - return errors.New(fmt.Sprintf("Only %v arguments are allowed", cmd.ValidArgs)) + return fmt.Errorf("only %v arguments are allowed", cmd.ValidArgs) } return nil }, @@ -75,7 +87,7 @@ Examples: uii = uii.SetContext(ui.LevelSilent) switch args[0] { case "zsh": - rootCmd.GenZshCompletion(os.Stdout) + runCompletionZsh(os.Stdout, rootCmd) case "bash": rootCmd.GenBashCompletion(os.Stdout) default: @@ -85,13 +97,153 @@ Examples: func init() { rootCmd.AddCommand(completionCmd) - // Here you will define your flags and configuration settings. +} - // Cobra supports Persistent Flags which will work for this command - // and all subcommands, e.g.: - // completionCmd.PersistentFlags().String("foo", "", "A help for foo") +// this writes a zsh completion script that wraps the bash completion script. +// +// Copied from kubectl: https://github.com/kubernetes/kubernetes/blob/9c2df998af9eb565f11d42725dc77e9266483ffc/pkg/kubectl/cmd/completion/completion.go#L145 +func runCompletionZsh(out io.Writer, shuttle *cobra.Command) error { + zshHead := "#compdef shuttle\n" - // Cobra supports local flags which will only run when this command - // is called directly, e.g.: - // completionCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") + out.Write([]byte(zshHead)) + + zshInitialization := ` +__shuttle_bash_source() { + alias shopt=':' + alias _expand=_bash_expand + alias _complete=_bash_comp + emulate -L sh + setopt kshglob noshglob braceexpand + source "$@" +} +__shuttle_type() { + # -t is not supported by zsh + if [ "$1" == "-t" ]; then + shift + # fake Bash 4 to disable "complete -o nospace". Instead + # "compopt +-o nospace" is used in the code to toggle trailing + # spaces. We don't support that, but leave trailing spaces on + # all the time + if [ "$1" = "__shuttle_compopt" ]; then + echo builtin + return 0 + fi + fi + type "$@" +} +__shuttle_compgen() { + local completions w + completions=( $(compgen "$@") ) || return $? + # filter by given word as prefix + while [[ "$1" = -* && "$1" != -- ]]; do + shift + shift + done + if [[ "$1" == -- ]]; then + shift + fi + for w in "${completions[@]}"; do + if [[ "${w}" = "$1"* ]]; then + echo "${w}" + fi + done +} +__shuttle_compopt() { + true # don't do anything. Not supported by bashcompinit in zsh +} +__shuttle_ltrim_colon_completions() +{ + if [[ "$1" == *:* && "$COMP_WORDBREAKS" == *:* ]]; then + # Remove colon-word prefix from COMPREPLY items + local colon_word=${1%${1##*:}} + local i=${#COMPREPLY[*]} + while [[ $((--i)) -ge 0 ]]; do + COMPREPLY[$i]=${COMPREPLY[$i]#"$colon_word"} + done + fi +} +__shuttle_get_comp_words_by_ref() { + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[${COMP_CWORD}-1]}" + words=("${COMP_WORDS[@]}") + cword=("${COMP_CWORD[@]}") +} +__shuttle_filedir() { + local RET OLD_IFS w qw + __shuttle_debug "_filedir $@ cur=$cur" + if [[ "$1" = \~* ]]; then + # somehow does not work. Maybe, zsh does not call this at all + eval echo "$1" + return 0 + fi + OLD_IFS="$IFS" + IFS=$'\n' + if [ "$1" = "-d" ]; then + shift + RET=( $(compgen -d) ) + else + RET=( $(compgen -f) ) + fi + IFS="$OLD_IFS" + IFS="," __shuttle_debug "RET=${RET[@]} len=${#RET[@]}" + for w in ${RET[@]}; do + if [[ ! "${w}" = "${cur}"* ]]; then + continue + fi + if eval "[[ \"\${w}\" = *.$1 || -d \"\${w}\" ]]"; then + qw="$(__shuttle_quote "${w}")" + if [ -d "${w}" ]; then + COMPREPLY+=("${qw}/") + else + COMPREPLY+=("${qw}") + fi + fi + done +} +__shuttle_quote() { + if [[ $1 == \'* || $1 == \"* ]]; then + # Leave out first character + printf %q "${1:1}" + else + printf %q "$1" + fi +} +autoload -U +X bashcompinit && bashcompinit +# use word boundary patterns for BSD or GNU sed +LWORD='[[:<:]]' +RWORD='[[:>:]]' +if sed --help 2>&1 | grep -q GNU; then + LWORD='\<' + RWORD='\>' +fi +__shuttle_convert_bash_to_zsh() { + sed \ + -e 's/declare -F/whence -w/' \ + -e 's/_get_comp_words_by_ref "\$@"/_get_comp_words_by_ref "\$*"/' \ + -e 's/local \([a-zA-Z0-9_]*\)=/local \1; \1=/' \ + -e 's/flags+=("\(--.*\)=")/flags+=("\1"); two_word_flags+=("\1")/' \ + -e 's/must_have_one_flag+=("\(--.*\)=")/must_have_one_flag+=("\1")/' \ + -e "s/${LWORD}_filedir${RWORD}/__shuttle_filedir/g" \ + -e "s/${LWORD}_get_comp_words_by_ref${RWORD}/__shuttle_get_comp_words_by_ref/g" \ + -e "s/${LWORD}__ltrim_colon_completions${RWORD}/__shuttle_ltrim_colon_completions/g" \ + -e "s/${LWORD}compgen${RWORD}/__shuttle_compgen/g" \ + -e "s/${LWORD}compopt${RWORD}/__shuttle_compopt/g" \ + -e "s/${LWORD}declare${RWORD}/builtin declare/g" \ + -e "s/\\\$(type${RWORD}/\$(__shuttle_type/g" \ + <<'BASH_COMPLETION_EOF' +` + out.Write([]byte(zshInitialization)) + + buf := new(bytes.Buffer) + shuttle.GenBashCompletion(buf) + out.Write(buf.Bytes()) + + zshTail := ` +BASH_COMPLETION_EOF +} +__shuttle_bash_source <(__shuttle_convert_bash_to_zsh) +_complete shuttle 2>/dev/null +` + out.Write([]byte(zshTail)) + return nil } diff --git a/cmd/ls.go b/cmd/ls.go index 5c43eda..e31ec11 100644 --- a/cmd/ls.go +++ b/cmd/ls.go @@ -8,7 +8,7 @@ import ( "github.com/spf13/cobra" ) -const templ = ` +const lsDefaultTempl = ` {{- $max := .Max -}} Available Scripts: {{- range $key, $value := .Scripts}} @@ -16,6 +16,10 @@ Available Scripts: {{- end}} ` +var ( + lsFlagTemplate string +) + type templData struct { Scripts map[string]config.ShuttlePlanScript Max int @@ -26,6 +30,12 @@ var lsCmd = &cobra.Command{ Short: "List possible commands", Run: func(cmd *cobra.Command, args []string) { context := getProjectContext() + var templ string + if lsFlagTemplate != "" { + templ = lsFlagTemplate + } else { + templ = lsDefaultTempl + } err := ui.Template(os.Stdout, "ls", templ, templData{ Scripts: context.Scripts, Max: calculateRightPadForKeys(context.Scripts), @@ -35,6 +45,7 @@ var lsCmd = &cobra.Command{ } func init() { + lsCmd.Flags().StringVar(&lsFlagTemplate, "template", "", "Template string to use. The template format is golang templates [http://golang.org/pkg/text/template/#pkg-overview].") rootCmd.AddCommand(lsCmd) } diff --git a/cmd/root.go b/cmd/root.go index 3002aae..53140cb 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -23,6 +23,55 @@ var ( commit = "" ) +const rootCmdCompletion = ` +__shuttle_run_script_args() { + local cur prev args_output args + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + + template=$'{{ range $i, $arg := .Args }}{{ $arg.Name }}\n{{ end }}' + if args_output=$(shuttle --skip-pull run "$1" --help --template "$template" 2>/dev/null); then + args=($(echo "${args_output}")) + COMPREPLY=( $( compgen -W "${args[*]}" -- "$cur" ) ) + compopt -o nospace + fi +} + +# find available scripts to run +__shuttle_run_scripts() { + local cur prev scripts currentScript + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + currentScript="${COMP_WORDS[2]}" + + if [[ ! "${prev}" = "run" ]]; then + __shuttle_run_script_args $currentScript + return 0 + fi + + template=$'{{ range $name, $script := .Scripts }}{{ $name }}\n{{ end }}' + if scripts_output=$(shuttle --skip-pull ls --template "$template" 2>/dev/null); then + scripts=($(echo "${scripts_output}")) + COMPREPLY=( $( compgen -W "${scripts[*]}" -- "$cur" ) ) + fi + return 0 +} + +# called when the build in completion fails to match +__shuttle_custom_func() { + case ${last_command} in + shuttle_run) + __shuttle_run_scripts + return + ;; + *) + ;; + esac +} +` + // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ Use: "shuttle", @@ -43,6 +92,7 @@ Read more about shuttle at https://github.com/lunarway/shuttle`, version), uii.Verboseln("- commit: %s", commit) uii.Verboseln("- project-path: %s", projectPath) }, + BashCompletionFunction: rootCmdCompletion, } func Execute() { diff --git a/go.mod b/go.mod index 8dc6611..51d0dfb 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/inconshreveable/mousetrap v1.0.0 github.com/pkg/errors v0.8.1 github.com/pmezard/go-difflib v1.0.0 - github.com/spf13/cobra v0.0.3 + github.com/spf13/cobra v0.0.4-0.20181127133106-d2d81d9a96e2 github.com/spf13/pflag v1.0.1 github.com/stretchr/testify v1.2.2 golang.org/x/crypto v0.0.0-20180621125126-a49355c7e3f8 diff --git a/go.sum b/go.sum index 0e8712f..8210aa6 100644 --- a/go.sum +++ b/go.sum @@ -23,6 +23,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/spf13/cobra v0.0.3 h1:ZlrZ4XsMRm04Fr5pSFxBgfND2EBVa1nLpiy1stUsX/8= github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= +github.com/spf13/cobra v0.0.4-0.20181127133106-d2d81d9a96e2 h1:dbzpGP6pkdSI3VOfc8uluYyw5tsFMHTJ3m7s1HpOXuU= +github.com/spf13/cobra v0.0.4-0.20181127133106-d2d81d9a96e2/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= github.com/spf13/pflag v1.0.1 h1:aCvUg6QPl3ibpQUxyLkrEkCHtPqYJL4x9AuhqVqFis4= github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=