-
Notifications
You must be signed in to change notification settings - Fork 2.9k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Bash completion V2 with completion descriptions #1146
Changes from all commits
98cc4b0
6d86d91
5359c17
b8fd1be
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,302 @@ | ||
package cobra | ||
|
||
import ( | ||
"bytes" | ||
"fmt" | ||
"io" | ||
"os" | ||
) | ||
|
||
func (c *Command) genBashCompletion(w io.Writer, includeDesc bool) error { | ||
buf := new(bytes.Buffer) | ||
genBashComp(buf, c.Name(), includeDesc) | ||
_, err := buf.WriteTo(w) | ||
return err | ||
} | ||
|
||
func genBashComp(buf io.StringWriter, name string, includeDesc bool) { | ||
compCmd := ShellCompRequestCmd | ||
if !includeDesc { | ||
compCmd = ShellCompNoDescRequestCmd | ||
} | ||
|
||
WriteStringAndCheck(buf, fmt.Sprintf(`# bash completion V2 for %-36[1]s -*- shell-script -*- | ||
|
||
__%[1]s_debug() | ||
{ | ||
if [[ -n ${BASH_COMP_DEBUG_FILE:-} ]]; then | ||
echo "$*" >> "${BASH_COMP_DEBUG_FILE}" | ||
fi | ||
} | ||
|
||
# Macs have bash3 for which the bash-completion package doesn't include | ||
# _init_completion. This is a minimal version of that function. | ||
__%[1]s_init_completion() | ||
{ | ||
COMPREPLY=() | ||
_get_comp_words_by_ref "$@" cur prev words cword | ||
} | ||
|
||
# This function calls the %[1]s program to obtain the completion | ||
# results and the directive. It fills the 'out' and 'directive' vars. | ||
__%[1]s_get_completion_results() { | ||
local requestComp lastParam lastChar args | ||
|
||
# Prepare the command to request completions for the program. | ||
# Calling ${words[0]} instead of directly %[1]s allows to handle aliases | ||
args=("${words[@]:1}") | ||
requestComp="${words[0]} %[2]s ${args[*]}" | ||
|
||
lastParam=${words[$((${#words[@]}-1))]} | ||
lastChar=${lastParam:$((${#lastParam}-1)):1} | ||
__%[1]s_debug "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. | ||
__%[1]s_debug "Adding extra empty parameter" | ||
requestComp="${requestComp} ''" | ||
fi | ||
|
||
# When completing a flag with an = (e.g., %[1]s -n=<TAB>) | ||
# bash focuses on the part after the =, so we need to remove | ||
# the flag part from $cur | ||
if [[ "${cur}" == -*=* ]]; then | ||
cur="${cur#*=}" | ||
fi | ||
|
||
__%[1]s_debug "Calling ${requestComp}" | ||
# Use eval to handle any environment variables and such | ||
out=$(eval "${requestComp}" 2>/dev/null) | ||
|
||
# Extract the directive integer at the very end of the output following a colon (:) | ||
directive=${out##*:} | ||
# Remove the directive | ||
out=${out%%:*} | ||
if [ "${directive}" = "${out}" ]; then | ||
# There is not directive specified | ||
directive=0 | ||
fi | ||
__%[1]s_debug "The completion directive is: ${directive}" | ||
__%[1]s_debug "The completions are: ${out[*]}" | ||
} | ||
|
||
__%[1]s_process_completion_results() { | ||
local shellCompDirectiveError=%[3]d | ||
local shellCompDirectiveNoSpace=%[4]d | ||
local shellCompDirectiveNoFileComp=%[5]d | ||
local shellCompDirectiveFilterFileExt=%[6]d | ||
local shellCompDirectiveFilterDirs=%[7]d | ||
|
||
if [ $((directive & shellCompDirectiveError)) -ne 0 ]; then | ||
# Error code. No completion. | ||
__%[1]s_debug "Received error from custom completion go code" | ||
return | ||
else | ||
if [ $((directive & shellCompDirectiveNoSpace)) -ne 0 ]; then | ||
if [[ $(type -t compopt) = "builtin" ]]; then | ||
__%[1]s_debug "Activating no space" | ||
compopt -o nospace | ||
else | ||
__%[1]s_debug "No space directive not supported in this version of bash" | ||
fi | ||
fi | ||
if [ $((directive & shellCompDirectiveNoFileComp)) -ne 0 ]; then | ||
if [[ $(type -t compopt) = "builtin" ]]; then | ||
__%[1]s_debug "Activating no file completion" | ||
compopt +o default | ||
else | ||
__%[1]s_debug "No file completion directive not supported in this version of bash" | ||
fi | ||
fi | ||
fi | ||
|
||
if [ $((directive & shellCompDirectiveFilterFileExt)) -ne 0 ]; then | ||
# File extension filtering | ||
local fullFilter filter filteringCmd | ||
|
||
# Do not use quotes around the $out variable or else newline | ||
# characters will be kept. | ||
for filter in ${out[*]}; do | ||
fullFilter+="$filter|" | ||
done | ||
|
||
filteringCmd="_filedir $fullFilter" | ||
__%[1]s_debug "File filtering command: $filteringCmd" | ||
$filteringCmd | ||
elif [ $((directive & shellCompDirectiveFilterDirs)) -ne 0 ]; then | ||
# File completion for directories only | ||
|
||
# Use printf to strip any trailing newline | ||
local subdir | ||
subdir=$(printf "%%s" "${out[0]}") | ||
if [ -n "$subdir" ]; then | ||
__%[1]s_debug "Listing directories in $subdir" | ||
pushd "$subdir" >/dev/null 2>&1 && _filedir -d && popd >/dev/null 2>&1 || return | ||
else | ||
__%[1]s_debug "Listing directories in ." | ||
_filedir -d | ||
fi | ||
else | ||
__%[1]s_handle_standard_completion_case | ||
fi | ||
|
||
__%[1]s_handle_special_char "$cur" : | ||
__%[1]s_handle_special_char "$cur" = | ||
} | ||
|
||
__%[1]s_handle_standard_completion_case() { | ||
local tab comp | ||
tab=$(printf '\t') | ||
|
||
local longest=0 | ||
# Look for the longest completion so that we can format things nicely | ||
while IFS='' read -r comp; do | ||
# Strip any description before checking the length | ||
comp=${comp%%%%$tab*} | ||
# Only consider the completions that match | ||
comp=$(compgen -W "$comp" -- "$cur") | ||
if ((${#comp}>longest)); then | ||
longest=${#comp} | ||
fi | ||
done < <(printf "%%s\n" "${out[@]}") | ||
|
||
local completions=() | ||
while IFS='' read -r comp; do | ||
if [ -z "$comp" ]; then | ||
continue | ||
fi | ||
|
||
__%[1]s_debug "Original comp: $comp" | ||
comp="$(__%[1]s_format_comp_descriptions "$comp" "$longest")" | ||
__%[1]s_debug "Final comp: $comp" | ||
completions+=("$comp") | ||
done < <(printf "%%s\n" "${out[@]}") | ||
|
||
while IFS='' read -r comp; do | ||
COMPREPLY+=("$comp") | ||
done < <(compgen -W "${completions[*]}" -- "$cur") | ||
|
||
# If there is a single completion left, remove the description text | ||
if [ ${#COMPREPLY[*]} -eq 1 ]; then | ||
__%[1]s_debug "COMPREPLY[0]: ${COMPREPLY[0]}" | ||
comp="${COMPREPLY[0]%%%% *}" | ||
__%[1]s_debug "Removed description from single completion, which is now: ${comp}" | ||
COMPREPLY=() | ||
COMPREPLY+=("$comp") | ||
fi | ||
} | ||
|
||
__%[1]s_handle_special_char() | ||
{ | ||
local comp="$1" | ||
local char=$2 | ||
if [[ "$comp" == *${char}* && "$COMP_WORDBREAKS" == *${char}* ]]; then | ||
local word=${comp%%"${comp##*${char}}"} | ||
local idx=${#COMPREPLY[*]} | ||
while [[ $((--idx)) -ge 0 ]]; do | ||
COMPREPLY[$idx]=${COMPREPLY[$idx]#"$word"} | ||
done | ||
fi | ||
} | ||
|
||
__%[1]s_format_comp_descriptions() | ||
{ | ||
local tab | ||
tab=$(printf '\t') | ||
local comp="$1" | ||
local longest=$2 | ||
|
||
# Properly format the description string which follows a tab character if there is one | ||
if [[ "$comp" == *$tab* ]]; then | ||
desc=${comp#*$tab} | ||
comp=${comp%%%%$tab*} | ||
|
||
# $COLUMNS stores the current shell width. | ||
# Remove an extra 4 because we add 2 spaces and 2 parentheses. | ||
maxdesclength=$(( COLUMNS - longest - 4 )) | ||
|
||
# Make sure we can fit a description of at least 8 characters | ||
# if we are to align the descriptions. | ||
if [[ $maxdesclength -gt 8 ]]; then | ||
# Add the proper number of spaces to align the descriptions | ||
for ((i = ${#comp} ; i < longest ; i++)); do | ||
comp+=" " | ||
done | ||
else | ||
# Don't pad the descriptions so we can fit more text after the completion | ||
maxdesclength=$(( COLUMNS - ${#comp} - 4 )) | ||
fi | ||
|
||
# If there is enough space for any description text, | ||
# truncate the descriptions that are too long for the shell width | ||
if [ $maxdesclength -gt 0 ]; then | ||
if [ ${#desc} -gt $maxdesclength ]; then | ||
desc=${desc:0:$(( maxdesclength - 1 ))} | ||
desc+="…" | ||
fi | ||
comp+=" ($desc)" | ||
fi | ||
fi | ||
|
||
# Must use printf to escape all special characters | ||
printf "%%q" "${comp}" | ||
} | ||
|
||
__start_%[1]s() | ||
{ | ||
local cur prev words cword split | ||
|
||
COMPREPLY=() | ||
|
||
# Call _init_completion from the bash-completion package | ||
# to prepare the arguments properly | ||
if declare -F _init_completion >/dev/null 2>&1; then | ||
_init_completion -n "=:" || return | ||
else | ||
__%[1]s_init_completion -n "=:" || return | ||
fi | ||
|
||
__%[1]s_debug | ||
__%[1]s_debug "========= starting completion logic ==========" | ||
__%[1]s_debug "cur is ${cur}, words[*] is ${words[*]}, #words[@] is ${#words[@]}, cword is $cword" | ||
|
||
# The user could have moved the cursor backwards on the command-line. | ||
# We need to trigger completion from the $cword location, so we need | ||
# to truncate the command-line ($words) up to the $cword location. | ||
words=("${words[@]:0:$cword+1}") | ||
__%[1]s_debug "Truncated words[*]: ${words[*]}," | ||
|
||
local out directive | ||
__%[1]s_get_completion_results | ||
__%[1]s_process_completion_results | ||
} | ||
|
||
if [[ $(type -t compopt) = "builtin" ]]; then | ||
complete -o default -F __start_%[1]s %[1]s | ||
else | ||
complete -o default -o nospace -F __start_%[1]s %[1]s | ||
fi | ||
|
||
# ex: ts=4 sw=4 et filetype=sh | ||
`, name, compCmd, | ||
ShellCompDirectiveError, ShellCompDirectiveNoSpace, ShellCompDirectiveNoFileComp, | ||
ShellCompDirectiveFilterFileExt, ShellCompDirectiveFilterDirs)) | ||
} | ||
|
||
// GenBashCompletionFileV2 generates Bash completion version 2. | ||
func (c *Command) GenBashCompletionFileV2(filename string, includeDesc bool) error { | ||
outFile, err := os.Create(filename) | ||
if err != nil { | ||
return err | ||
} | ||
defer outFile.Close() | ||
|
||
return c.GenBashCompletionV2(outFile, includeDesc) | ||
} | ||
|
||
// GenBashCompletionV2 generates Bash completion file version 2 | ||
// and writes it to the passed writer. | ||
func (c *Command) GenBashCompletionV2(w io.Writer, includeDesc bool) error { | ||
return c.genBashCompletion(w, includeDesc) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -34,7 +34,6 @@ const ( | |
|
||
// ShellCompDirectiveNoFileComp 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 | ||
ShellCompDirectiveNoFileComp | ||
|
||
// ShellCompDirectiveFilterFileExt indicates that the provided completions | ||
|
@@ -592,9 +591,12 @@ You will need to start a new shell for this setup to take effect. | |
DisableFlagsInUseLine: true, | ||
ValidArgsFunction: NoFileCompletions, | ||
RunE: func(cmd *Command, args []string) error { | ||
return cmd.Root().GenBashCompletion(out) | ||
return cmd.Root().GenBashCompletionV2(out, !noDesc) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just so I understand correctly, we are now by default using the new v2 version of Bash completions? Can users expect any kind of changes in behavior with this new version when using the generated completion command? I think I've mentioned this before, but I just want to be extra careful about shipping breaking changes. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So the default completion command will use bash completion v2, but any projects that already provide their own 'completion' command will keep using V1 until they choose to change it themselves. The main differences between v1 and v2 are:
One special case will be projects that provide completion through a different command name (say a command named "complete"). Those projects will continue to use v1 for their own command but will also provide cobra's implicit "completion" command which will use v2, unless of course, they take the time to disable the default "completion" command. That special situation would benefit from a mention in the release notes, I think. |
||
}, | ||
} | ||
if haveNoDescFlag { | ||
bash.Flags().BoolVar(&noDesc, compCmdNoDescFlagName, compCmdNoDescFlagDefault, compCmdNoDescFlagDesc) | ||
} | ||
|
||
zsh := &Command{ | ||
Use: "zsh", | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This seems to fix the second problem described here: #1146 (comment).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@Luap99 It does not seem to fix it for me...
This does not seem limited to flags.
helm c[TAB] zsh
works, but
helm [TAB] zsh
does not. But also, if you add an extra space before
zsh
, thenhelm [TAB]<2 spaces>zsh
works. Looking at the debug printouts, it seems the
_init_completion
function does not differentiate between the cursor being before or after the last argument (zsh
in this example), or at least there is no difference in thecur prev words cword
variables. This needs more investigation.But as you mentioned, it probably should not block this PR from moving forward.