Skip to content

Commit b361960

Browse files
authored
Bash completion V2 with completion descriptions (#1146)
* Bash completion v2 This v2 version of bash completion is based on Go completions. It also supports descriptions like the other shells. Signed-off-by: Marc Khouzam <[email protected]> * Only consider matching completions for formatting Signed-off-by: Marc Khouzam <[email protected]> * Use bash compV2 for the default completion command Signed-off-by: Marc Khouzam <[email protected]> * Update comments that still referred to bash completion Signed-off-by: Marc Khouzam <[email protected]>
1 parent d0f318d commit b361960

File tree

6 files changed

+346
-17
lines changed

6 files changed

+346
-17
lines changed

bash_completions.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ Please refer to [Shell Completions](shell_completions.md) for details.
66

77
For backward compatibility, Cobra still supports its legacy dynamic completion solution (described below). Unlike the `ValidArgsFunction` solution, the legacy solution will only work for Bash shell-completion and not for other shells. This legacy solution can be used along-side `ValidArgsFunction` and `RegisterFlagCompletionFunc()`, as long as both solutions are not used for the same command. This provides a path to gradually migrate from the legacy solution to the new solution.
88

9+
**Note**: Cobra's default `completion` command uses bash completion V2. If you are currently using Cobra's legacy dynamic completion solution, you should not use the default `completion` command but continue using your own.
10+
911
The legacy solution allows you to inject bash functions into the bash completion script. Those bash functions are responsible for providing the completion choices for your own completions.
1012

1113
Some code that works in kubernetes:

bash_completionsV2.go

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
package cobra
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"io"
7+
"os"
8+
)
9+
10+
func (c *Command) genBashCompletion(w io.Writer, includeDesc bool) error {
11+
buf := new(bytes.Buffer)
12+
genBashComp(buf, c.Name(), includeDesc)
13+
_, err := buf.WriteTo(w)
14+
return err
15+
}
16+
17+
func genBashComp(buf io.StringWriter, name string, includeDesc bool) {
18+
compCmd := ShellCompRequestCmd
19+
if !includeDesc {
20+
compCmd = ShellCompNoDescRequestCmd
21+
}
22+
23+
WriteStringAndCheck(buf, fmt.Sprintf(`# bash completion V2 for %-36[1]s -*- shell-script -*-
24+
25+
__%[1]s_debug()
26+
{
27+
if [[ -n ${BASH_COMP_DEBUG_FILE:-} ]]; then
28+
echo "$*" >> "${BASH_COMP_DEBUG_FILE}"
29+
fi
30+
}
31+
32+
# Macs have bash3 for which the bash-completion package doesn't include
33+
# _init_completion. This is a minimal version of that function.
34+
__%[1]s_init_completion()
35+
{
36+
COMPREPLY=()
37+
_get_comp_words_by_ref "$@" cur prev words cword
38+
}
39+
40+
# This function calls the %[1]s program to obtain the completion
41+
# results and the directive. It fills the 'out' and 'directive' vars.
42+
__%[1]s_get_completion_results() {
43+
local requestComp lastParam lastChar args
44+
45+
# Prepare the command to request completions for the program.
46+
# Calling ${words[0]} instead of directly %[1]s allows to handle aliases
47+
args=("${words[@]:1}")
48+
requestComp="${words[0]} %[2]s ${args[*]}"
49+
50+
lastParam=${words[$((${#words[@]}-1))]}
51+
lastChar=${lastParam:$((${#lastParam}-1)):1}
52+
__%[1]s_debug "lastParam ${lastParam}, lastChar ${lastChar}"
53+
54+
if [ -z "${cur}" ] && [ "${lastChar}" != "=" ]; then
55+
# If the last parameter is complete (there is a space following it)
56+
# We add an extra empty parameter so we can indicate this to the go method.
57+
__%[1]s_debug "Adding extra empty parameter"
58+
requestComp="${requestComp} ''"
59+
fi
60+
61+
# When completing a flag with an = (e.g., %[1]s -n=<TAB>)
62+
# bash focuses on the part after the =, so we need to remove
63+
# the flag part from $cur
64+
if [[ "${cur}" == -*=* ]]; then
65+
cur="${cur#*=}"
66+
fi
67+
68+
__%[1]s_debug "Calling ${requestComp}"
69+
# Use eval to handle any environment variables and such
70+
out=$(eval "${requestComp}" 2>/dev/null)
71+
72+
# Extract the directive integer at the very end of the output following a colon (:)
73+
directive=${out##*:}
74+
# Remove the directive
75+
out=${out%%:*}
76+
if [ "${directive}" = "${out}" ]; then
77+
# There is not directive specified
78+
directive=0
79+
fi
80+
__%[1]s_debug "The completion directive is: ${directive}"
81+
__%[1]s_debug "The completions are: ${out[*]}"
82+
}
83+
84+
__%[1]s_process_completion_results() {
85+
local shellCompDirectiveError=%[3]d
86+
local shellCompDirectiveNoSpace=%[4]d
87+
local shellCompDirectiveNoFileComp=%[5]d
88+
local shellCompDirectiveFilterFileExt=%[6]d
89+
local shellCompDirectiveFilterDirs=%[7]d
90+
91+
if [ $((directive & shellCompDirectiveError)) -ne 0 ]; then
92+
# Error code. No completion.
93+
__%[1]s_debug "Received error from custom completion go code"
94+
return
95+
else
96+
if [ $((directive & shellCompDirectiveNoSpace)) -ne 0 ]; then
97+
if [[ $(type -t compopt) = "builtin" ]]; then
98+
__%[1]s_debug "Activating no space"
99+
compopt -o nospace
100+
else
101+
__%[1]s_debug "No space directive not supported in this version of bash"
102+
fi
103+
fi
104+
if [ $((directive & shellCompDirectiveNoFileComp)) -ne 0 ]; then
105+
if [[ $(type -t compopt) = "builtin" ]]; then
106+
__%[1]s_debug "Activating no file completion"
107+
compopt +o default
108+
else
109+
__%[1]s_debug "No file completion directive not supported in this version of bash"
110+
fi
111+
fi
112+
fi
113+
114+
if [ $((directive & shellCompDirectiveFilterFileExt)) -ne 0 ]; then
115+
# File extension filtering
116+
local fullFilter filter filteringCmd
117+
118+
# Do not use quotes around the $out variable or else newline
119+
# characters will be kept.
120+
for filter in ${out[*]}; do
121+
fullFilter+="$filter|"
122+
done
123+
124+
filteringCmd="_filedir $fullFilter"
125+
__%[1]s_debug "File filtering command: $filteringCmd"
126+
$filteringCmd
127+
elif [ $((directive & shellCompDirectiveFilterDirs)) -ne 0 ]; then
128+
# File completion for directories only
129+
130+
# Use printf to strip any trailing newline
131+
local subdir
132+
subdir=$(printf "%%s" "${out[0]}")
133+
if [ -n "$subdir" ]; then
134+
__%[1]s_debug "Listing directories in $subdir"
135+
pushd "$subdir" >/dev/null 2>&1 && _filedir -d && popd >/dev/null 2>&1 || return
136+
else
137+
__%[1]s_debug "Listing directories in ."
138+
_filedir -d
139+
fi
140+
else
141+
__%[1]s_handle_standard_completion_case
142+
fi
143+
144+
__%[1]s_handle_special_char "$cur" :
145+
__%[1]s_handle_special_char "$cur" =
146+
}
147+
148+
__%[1]s_handle_standard_completion_case() {
149+
local tab comp
150+
tab=$(printf '\t')
151+
152+
local longest=0
153+
# Look for the longest completion so that we can format things nicely
154+
while IFS='' read -r comp; do
155+
# Strip any description before checking the length
156+
comp=${comp%%%%$tab*}
157+
# Only consider the completions that match
158+
comp=$(compgen -W "$comp" -- "$cur")
159+
if ((${#comp}>longest)); then
160+
longest=${#comp}
161+
fi
162+
done < <(printf "%%s\n" "${out[@]}")
163+
164+
local completions=()
165+
while IFS='' read -r comp; do
166+
if [ -z "$comp" ]; then
167+
continue
168+
fi
169+
170+
__%[1]s_debug "Original comp: $comp"
171+
comp="$(__%[1]s_format_comp_descriptions "$comp" "$longest")"
172+
__%[1]s_debug "Final comp: $comp"
173+
completions+=("$comp")
174+
done < <(printf "%%s\n" "${out[@]}")
175+
176+
while IFS='' read -r comp; do
177+
COMPREPLY+=("$comp")
178+
done < <(compgen -W "${completions[*]}" -- "$cur")
179+
180+
# If there is a single completion left, remove the description text
181+
if [ ${#COMPREPLY[*]} -eq 1 ]; then
182+
__%[1]s_debug "COMPREPLY[0]: ${COMPREPLY[0]}"
183+
comp="${COMPREPLY[0]%%%% *}"
184+
__%[1]s_debug "Removed description from single completion, which is now: ${comp}"
185+
COMPREPLY=()
186+
COMPREPLY+=("$comp")
187+
fi
188+
}
189+
190+
__%[1]s_handle_special_char()
191+
{
192+
local comp="$1"
193+
local char=$2
194+
if [[ "$comp" == *${char}* && "$COMP_WORDBREAKS" == *${char}* ]]; then
195+
local word=${comp%%"${comp##*${char}}"}
196+
local idx=${#COMPREPLY[*]}
197+
while [[ $((--idx)) -ge 0 ]]; do
198+
COMPREPLY[$idx]=${COMPREPLY[$idx]#"$word"}
199+
done
200+
fi
201+
}
202+
203+
__%[1]s_format_comp_descriptions()
204+
{
205+
local tab
206+
tab=$(printf '\t')
207+
local comp="$1"
208+
local longest=$2
209+
210+
# Properly format the description string which follows a tab character if there is one
211+
if [[ "$comp" == *$tab* ]]; then
212+
desc=${comp#*$tab}
213+
comp=${comp%%%%$tab*}
214+
215+
# $COLUMNS stores the current shell width.
216+
# Remove an extra 4 because we add 2 spaces and 2 parentheses.
217+
maxdesclength=$(( COLUMNS - longest - 4 ))
218+
219+
# Make sure we can fit a description of at least 8 characters
220+
# if we are to align the descriptions.
221+
if [[ $maxdesclength -gt 8 ]]; then
222+
# Add the proper number of spaces to align the descriptions
223+
for ((i = ${#comp} ; i < longest ; i++)); do
224+
comp+=" "
225+
done
226+
else
227+
# Don't pad the descriptions so we can fit more text after the completion
228+
maxdesclength=$(( COLUMNS - ${#comp} - 4 ))
229+
fi
230+
231+
# If there is enough space for any description text,
232+
# truncate the descriptions that are too long for the shell width
233+
if [ $maxdesclength -gt 0 ]; then
234+
if [ ${#desc} -gt $maxdesclength ]; then
235+
desc=${desc:0:$(( maxdesclength - 1 ))}
236+
desc+="…"
237+
fi
238+
comp+=" ($desc)"
239+
fi
240+
fi
241+
242+
# Must use printf to escape all special characters
243+
printf "%%q" "${comp}"
244+
}
245+
246+
__start_%[1]s()
247+
{
248+
local cur prev words cword split
249+
250+
COMPREPLY=()
251+
252+
# Call _init_completion from the bash-completion package
253+
# to prepare the arguments properly
254+
if declare -F _init_completion >/dev/null 2>&1; then
255+
_init_completion -n "=:" || return
256+
else
257+
__%[1]s_init_completion -n "=:" || return
258+
fi
259+
260+
__%[1]s_debug
261+
__%[1]s_debug "========= starting completion logic =========="
262+
__%[1]s_debug "cur is ${cur}, words[*] is ${words[*]}, #words[@] is ${#words[@]}, cword is $cword"
263+
264+
# The user could have moved the cursor backwards on the command-line.
265+
# We need to trigger completion from the $cword location, so we need
266+
# to truncate the command-line ($words) up to the $cword location.
267+
words=("${words[@]:0:$cword+1}")
268+
__%[1]s_debug "Truncated words[*]: ${words[*]},"
269+
270+
local out directive
271+
__%[1]s_get_completion_results
272+
__%[1]s_process_completion_results
273+
}
274+
275+
if [[ $(type -t compopt) = "builtin" ]]; then
276+
complete -o default -F __start_%[1]s %[1]s
277+
else
278+
complete -o default -o nospace -F __start_%[1]s %[1]s
279+
fi
280+
281+
# ex: ts=4 sw=4 et filetype=sh
282+
`, name, compCmd,
283+
ShellCompDirectiveError, ShellCompDirectiveNoSpace, ShellCompDirectiveNoFileComp,
284+
ShellCompDirectiveFilterFileExt, ShellCompDirectiveFilterDirs))
285+
}
286+
287+
// GenBashCompletionFileV2 generates Bash completion version 2.
288+
func (c *Command) GenBashCompletionFileV2(filename string, includeDesc bool) error {
289+
outFile, err := os.Create(filename)
290+
if err != nil {
291+
return err
292+
}
293+
defer outFile.Close()
294+
295+
return c.GenBashCompletionV2(outFile, includeDesc)
296+
}
297+
298+
// GenBashCompletionV2 generates Bash completion file version 2
299+
// and writes it to the passed writer.
300+
func (c *Command) GenBashCompletionV2(w io.Writer, includeDesc bool) error {
301+
return c.genBashCompletion(w, includeDesc)
302+
}

command.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,9 @@ type Command struct {
6363
// Example is examples of how to use the command.
6464
Example string
6565

66-
// ValidArgs is list of all valid non-flag arguments that are accepted in bash completions
66+
// ValidArgs is list of all valid non-flag arguments that are accepted in shell completions
6767
ValidArgs []string
68-
// ValidArgsFunction is an optional function that provides valid non-flag arguments for bash completion.
68+
// ValidArgsFunction is an optional function that provides valid non-flag arguments for shell completion.
6969
// It is a dynamic version of using ValidArgs.
7070
// Only one of ValidArgs and ValidArgsFunction can be used for a command.
7171
ValidArgsFunction func(cmd *Command, args []string, toComplete string) ([]string, ShellCompDirective)
@@ -74,11 +74,12 @@ type Command struct {
7474
Args PositionalArgs
7575

7676
// ArgAliases is List of aliases for ValidArgs.
77-
// These are not suggested to the user in the bash completion,
77+
// These are not suggested to the user in the shell completion,
7878
// but accepted if entered manually.
7979
ArgAliases []string
8080

81-
// BashCompletionFunction is custom functions used by the bash autocompletion generator.
81+
// BashCompletionFunction is custom bash functions used by the legacy bash autocompletion generator.
82+
// For portability with other shells, it is recommended to instead use ValidArgsFunction
8283
BashCompletionFunction string
8384

8485
// Deprecated defines, if this command is deprecated and should print this string when used.
@@ -938,7 +939,7 @@ func (c *Command) ExecuteC() (cmd *Command, err error) {
938939
args = os.Args[1:]
939940
}
940941

941-
// initialize the hidden command to be used for bash completion
942+
// initialize the hidden command to be used for shell completion
942943
c.initCompleteCmd(args)
943944

944945
var flags []string

completions.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ const (
3434

3535
// ShellCompDirectiveNoFileComp indicates that the shell should not provide
3636
// file completion even when no completion is provided.
37-
// This currently does not work for zsh or bash < 4
3837
ShellCompDirectiveNoFileComp
3938

4039
// ShellCompDirectiveFilterFileExt indicates that the provided completions
@@ -592,9 +591,12 @@ You will need to start a new shell for this setup to take effect.
592591
DisableFlagsInUseLine: true,
593592
ValidArgsFunction: NoFileCompletions,
594593
RunE: func(cmd *Command, args []string) error {
595-
return cmd.Root().GenBashCompletion(out)
594+
return cmd.Root().GenBashCompletionV2(out, !noDesc)
596595
},
597596
}
597+
if haveNoDescFlag {
598+
bash.Flags().BoolVar(&noDesc, compCmdNoDescFlagName, compCmdNoDescFlagDefault, compCmdNoDescFlagDesc)
599+
}
598600

599601
zsh := &Command{
600602
Use: "zsh",

completions_test.go

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2097,24 +2097,16 @@ func TestDefaultCompletionCmd(t *testing.T) {
20972097
removeCompCmd(rootCmd)
20982098

20992099
var compCmd *Command
2100-
// Test that the --no-descriptions flag is present for the relevant shells only
2100+
// Test that the --no-descriptions flag is present on all shells
21012101
assertNoErr(t, rootCmd.Execute())
2102-
for _, shell := range []string{"fish", "powershell", "zsh"} {
2102+
for _, shell := range []string{"bash", "fish", "powershell", "zsh"} {
21032103
if compCmd, _, err = rootCmd.Find([]string{compCmdName, shell}); err != nil {
21042104
t.Errorf("Unexpected error: %v", err)
21052105
}
21062106
if flag := compCmd.Flags().Lookup(compCmdNoDescFlagName); flag == nil {
21072107
t.Errorf("Missing --%s flag for %s shell", compCmdNoDescFlagName, shell)
21082108
}
21092109
}
2110-
for _, shell := range []string{"bash"} {
2111-
if compCmd, _, err = rootCmd.Find([]string{compCmdName, shell}); err != nil {
2112-
t.Errorf("Unexpected error: %v", err)
2113-
}
2114-
if flag := compCmd.Flags().Lookup(compCmdNoDescFlagName); flag != nil {
2115-
t.Errorf("Unexpected --%s flag for %s shell", compCmdNoDescFlagName, shell)
2116-
}
2117-
}
21182110
// Remove completion command for the next test
21192111
removeCompCmd(rootCmd)
21202112

0 commit comments

Comments
 (0)