Skip to content

Commit

Permalink
Bash and zsh auto completions (#37)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Crevil committed Apr 9, 2019
1 parent 8f58f27 commit b868e70
Show file tree
Hide file tree
Showing 5 changed files with 264 additions and 49 deletions.
246 changes: 199 additions & 47 deletions cmd/completion.go
Expand Up @@ -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"
)

Expand All @@ -29,53 +29,65 @@ var shell string
// completionCmd represents the completion command
var completionCmd = &cobra.Command{
Use: "completion <shell>",
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
},
Run: func(cmd *cobra.Command, args []string) {
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:
Expand All @@ -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
}
13 changes: 12 additions & 1 deletion cmd/ls.go
Expand Up @@ -8,14 +8,18 @@ import (
"github.com/spf13/cobra"
)

const templ = `
const lsDefaultTempl = `
{{- $max := .Max -}}
Available Scripts:
{{- range $key, $value := .Scripts}}
{{rightPad $key $max }} {{upperFirst $value.Description}}
{{- end}}
`

var (
lsFlagTemplate string
)

type templData struct {
Scripts map[string]config.ShuttlePlanScript
Max int
Expand All @@ -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),
Expand All @@ -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)
}

Expand Down
50 changes: 50 additions & 0 deletions cmd/root.go
Expand Up @@ -23,6 +23,55 @@ var (
commit = "<unspecified-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",
Expand All @@ -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() {
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Expand Up @@ -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
Expand Down

0 comments on commit b868e70

Please sign in to comment.