Snippet 0x0B: Bash completion with sub-commands and dynamic options
Contents
Every system administrator, most programmers and countless of command line surfing Linux/Mac users use it every day without thinking twice. Hitting the tab key twice, [TAB][TAB], has become the most common thing in the world. Bash completion is the magic behind the tab key. It’s easy to use, but it’s a pain to write. This tiny post demonstrates how to write scripts for bash completion, with sub-commands and dynamic parameters. A working script is embedded in my open source file sync software Syncany.
1. Full code
As always, the full working code (embedded in a my open source file sync software Syncany) is available on GitHub. Feel free to use it, or to leave feedback in the comments.
2. Using sy as an example
Bash completion isn’t complicated, but I don’t think there are many good guides/examples out there. I don’t have the know-how to write a good guide, but I can show you how I did it. The Syncany command sy is very powerful because it has several sub-commands and very dynamic options (similar to git).
2.1. Video example
To make it easier to understand how much logic a bash completion script must actually handle, the following short video shows the different possibilities that the script must be able to handle. Among others, that includes the distinction between long and short options, enums, options that require a file completion, options that trigger other sub-options, sub commands, sub sub commands, dynamic options, and so on:
2.2. Text example
Here’s the video output in text form – in case it gets lost or the video is a bit too fast:
# Long and short options
$ sy -[TAB][TAB]
-d -h -l --log --print -vv
--debug --help --localdir --loglevel -v
# Long options only
$ sy --[TAB][TAB]
--debug --help --localdir --log --loglevel --print
# Directories
$ sy --log [TAB][TAB]
config/ db/ log/
# Option-specific enum
$ sy --loglevel [TAB][TAB]
ALL FINE FINER FINEST INFO OFF SEVERE WARNING
# Sub-commands
$ sy [TAB][TAB]
cleanup daemon genlink log ls-remote restore up watch
connect down init ls plugin status update
# Sub-command specific enums
$ sy init --plugin [TAB][TAB]
dropbox ftp local raid0 s3 samba sftp swift webdav
# Sub-command specific option enum (FTP plugin specific)
$ sy init --plugin ftp --plugin-option [TAB][TAB]
hostname= password= path= port= username=
# Option-dependent dynamic options (RAID0 plugin consists of two other plugins)
$ sy init --plugin raid0 --plugin-option storage[TAB][TAB]
storage1:type=dropbox storage1:type=samba storage2:type=ftp storage2:type=sftp
storage1:type=ftp storage1:type=sftp storage2:type=local storage2:type=swift
storage1:type=local storage1:type=swift storage2:type=raid0 storage2:type=webdav
storage1:type=raid0 storage1:type=webdav storage2:type=s3
storage1:type=s3 storage2:type=dropbox storage2:type=samba
# Option-dependent dynamic sub-options (After 'storage1:type' is chosen as 'ftp', show 'ftp' options for 'storage1.')
$ sy init --plugin raid0 --plugin-option storage1:type=ftp --plugin-option storage1.[TAB][TAB]
storage1.hostname= storage1.path= storage1.username=
storage1.password= storage1.port=
# Sub-sub-commands
$ sy plugin [TAB][TAB]
install list remove update
# Sub-sub-command specific options
$ sy plugin list -[TAB][TAB]
-a -L -R -s
--api-endpoint --local-only --remote-only --snapshots
3. Writing a bash completion script
You’ve certainly stumbled across the Programmable Completion guide, and probably also its second page, the Programmable Completion Builtins. While those are good resources, they don’t quite tell you the most important parts.
Let me try to do it better:
A bash completion script is a normal bash script containing one or many calls to the bash builtin command complete. This command declares certain functions to handle completions, e.g. complete -F _sy sy to declare completions for sy in the bash function _sy – as in the Syncany bash completion script.
The script is called whenever you press [TAB][TAB] and it is located at /etc/bash_completion.d/yourscript. When this file is changed, you need to open a new window/bash for the changes to take effect!
The main script input are an array called COMP_WORDS (all “words”) and an int COMP_CWORD (current “word” index). ${COMP_WORDS[COMP_CWORD]} gives you the current “word”, ${COMP_WORDS[COMP_CWORD-1]} the previous “word”. The main script output is an an array called COMPREPLY. It defines what suggestions to output to the user.
The input array COMP_WORDS contains all “words” typed into the console, including options starting with a dash. To distinguish between options and words, you must write your own helper function.
The compgen builtin command can be used to fill the COMPREPLY array from a list of words (comgen -W), or a function (compgen -F), and many others. Note that compgen takes the same options as complete. compgen is usally used like this: COMPREPLY=($(compgen -W "init connect log list ls" -- $cur)) (where $cur is ${COMP_WORDS[COMP_CWORD]}, or the actual current word) to fill COMPREPLY with the options that match. If $cur is “l”, for instance, COMPREPLY will contain “log”, “list” and “ls”.
Another useful builtin command is compopt. It can define the behavior of the completion. For instance, compopt -o nospace tells bash not to input a space after the completion. This is particularly useful for long-options with an equals-sign, e.g. --log-file=[TAB] should not add a space.
4. Example script (for Syncany)
The following is the current bash completion script for Syncany (this version on GitHub). It works very nicely for Syncany. It might evolve in the future. Please check out the current script and go from there.
#!bash
# Bash completion support for [Syncany](https://www.syncany.org/).
shopt -s progcomp
_sy() {
local cur prev firstword lastword complete_words complete_options
# Don't break words at : and =, see [1] and [2]
COMP_WORDBREAKS=${COMP_WORDBREAKS//[:=]}
cur=${COMP_WORDS[COMP_CWORD]}
prev=${COMP_WORDS[COMP_CWORD-1]}
firstword=$(_sy_get_firstword)
lastword=$(_sy_get_lastword)
GLOBAL_COMMANDS="\
cleanup\
connect\
daemon\
down\
genlink\
init\
log\
ls\
ls-remote\
plugin\
restore\
status\
up\
update\
watch"
GLOBAL_OPTIONS="\
-l --localdir\
-d --debug\
-h --help\
-v -vv\
--log\
--loglevel\
--print"
GLOBAL_LOGLEVELS="\
OFF\
SEVERE\
WARNING\
INFO\
FINE\
FINER\
FINEST\
ALL"
CONNECT_OPTIONS="\
-P --plugin\
-o --plugin-option\
-n --add-daemon\
--password"
DOWN_CONFLICT_STRATEGIES="\
ask\
rename"
DOWN_OPTIONS="\
-C --conflict-strategy\
-A --no-apply"
DAEMON_COMMANDS="\
force-stop\
reload\
restart\
start\
status\
stop\
list\
add\
remove"
GENLINK_OPTIONS="\
-s --short\
-m --machine-readable"
INIT_OPTIONS="\
-P --plugin\
-o --plugin-option\
-E --no-encryption\
-G --no-compression\
-t --create-target\
-a --advanced\
-n --add-daemon\
-s --short\
--password"
LOG_OPTIONS="\
-n --database-count\
-s --database-start\
-f --file-count\
-x --exclude-empty"
LS_OPTIONS="\
-V --versions\
-t --types\
-D --date\
-r --recursive\
-q --deleted\
-f --full-checksums\
-g --group\
-H --file-history"
LS_TYPES="\
t\
d\
f"
PLUGIN_COMMANDS="\
list\
install\
update\
remove"
PLUGIN_IDS_TRANSFER="\
dropbox\
ftp\
local\
raid0\
s3\
samba\
sftp\
swift\
webdav"
PLUGIN_IDS_ALL="\
$PLUGIN_IDS_TRANSFER\
flickr\
gui"
PLUGIN_LIST_OPTIONS="\
-R --remote-only\
-L --local-only\
-s --snapshots\
-a --api-endpoint"
PLUGIN_INSTALL_OPTIONS="\
-s --snapshot\
-m --minimal-output"
PLUGIN_DROPBOX_OPTIONS="\
authToken=\
path="
PLUGIN_FTP_OPTIONS="\
hostname=\
username=\
password=\
path=\
port="
PLUGIN_LOCAL_OPTIONS="\
path="
PLUGIN_S3_OPTIONS="\
accessKey=\
secretKey=\
bucket=\
location="
PLUGIN_SAMBA_OPTIONS="\
hostname=\
username=\
password=\
share=\
path="
PLUGIN_SFTP_OPTIONS="\
hostname=\
username=\
privatekey=\
password=\
path=\
port="
PLUGIN_SWIFT_OPTIONS="\
authUrl=\
username=\
password="
PLUGIN_WEBDAV_OPTIONS="\
url=\
username=\
password="
RESTORE_OPTIONS="\
-r --revision\
-t --target"
STATUS_OPTIONS="\
-f --force-checksum\
-D --no-delete"
UP_OPTIONS="\
-R --no-resume\
$STATUS_OPTIONS"
UPDATE_COMMANDS="\
check"
UPDATE_CHECK_OPTIONS="\
-a --api-endpoint\
-s --snapshots"
WATCH_OPTIONS="\
-i --interval\
-s --delay\
-W --no-watcher\
-a --announce\
-N --no-announcements\
$UP_OPTIONS\
$DOWN_OPTIONS"
CLEANUP_OPTIONS="\
-F --force\
-o --delete-older-than\
-I --no-delete-interval\
-O --no-delete-older-than\
-T --no-temp-removal\
$STATUS_OPTIONS"
# Un-comment this for debug purposes:
# echo -e "\nprev = $prev, cur = $cur, firstword = $firstword, lastword = $lastword\n"
case "${firstword}" in
cleanup)
complete_options="$CLEANUP_OPTIONS"
;;
connect)
case "${prev}" in
--plugin|-P)
complete_words="$PLUGIN_IDS_TRANSFER"
;;
--plugin-option|-o)
complete_words=$(_sy_plugin_option_words)
if [[ ${#COMPREPLY[@]} != 1 || $complete_words = *= ]]; then
compopt -o nospace
fi
;;
*)
complete_options="$CONNECT_OPTIONS"
;;
esac
;;
down)
case "${prev}" in
--conflict-strategy|-C)
complete_words="$DOWN_CONFLICT_STRATEGIES"
;;
*)
complete_options="$DOWN_OPTIONS"
;;
esac
;;
daemon)
case "${lastword}" in
force-stop|reload|restart|start|status|stop|list)
;;
add|remove)
# Special handling: return directories, no space at the end
compopt -o nospace
COMPREPLY=( $( compgen -d -S "/" -- $cur ) )
return 0
;;
*)
complete_words="$DAEMON_COMMANDS"
;;
esac
;;
genlink)
complete_options="$GENLINK_OPTIONS"
;;
init)
case "${prev}" in
--plugin|-P)
complete_words="$PLUGIN_IDS_TRANSFER"
;;
--plugin-option|-o)
complete_words=$(_sy_plugin_option_words)
if [[ ${#COMPREPLY[@]} != 1 || $complete_words = *= ]]; then
compopt -o nospace
fi
;;
*)
complete_options="$INIT_OPTIONS"
;;
esac
;;
log)
complete_options="$LOG_OPTIONS"
;;
ls)
case "${prev}" in
--types|-t)
complete_words="$LS_TYPES"
;;
*)
case "${cur}" in
-*)
complete_options="$LS_OPTIONS"
;;
*)
# Special handling: return directories, no space at the end
compopt -o nospace
COMPREPLY=( $( compgen -d -S "/" -- $cur ) )
return 0
;;
esac
;;
esac
;;
ls-remote)
;;
plugin)
case "${lastword}" in
list)
complete_options="$PLUGIN_LIST_OPTIONS"
;;
install)
complete_words="$PLUGIN_IDS_ALL"
complete_options="$PLUGIN_INSTALL_OPTIONS"
;;
update)
complete_words="$PLUGIN_IDS_ALL"
;;
remove)
complete_words="$PLUGIN_IDS_ALL"
;;
*)
complete_words="$PLUGIN_COMMANDS"
;;
esac
;;
restore)
case "${prev}" in
--target|-t)
# Special handling: Return file names
COMPREPLY=( $( compgen -f -- $cur ) )
return 0
;;
*)
complete_options="$RESTORE_OPTIONS"
;;
esac
;;
status)
complete_options="$STATUS_OPTIONS"
;;
up)
complete_options="$UP_OPTIONS"
;;
update)
case "${lastword}" in
check)
complete_options="$UPDATE_CHECK_OPTIONS"
;;
*)
complete_words="$UPDATE_COMMANDS"
;;
esac
;;
watch)
case "${prev}" in
--conflict-strategy|-C)
complete_words="$DOWN_CONFLICT_STRATEGIES"
;;
*)
complete_options="$WATCH_OPTIONS"
;;
esac
;;
*)
case "${prev}" in
--log|--localdir|-l)
# Special handling: return directories, no space at the end
compopt -o nospace
COMPREPLY=( $( compgen -d -S "/" -- $cur ) )
return 0
;;
--loglevel)
complete_words="$GLOBAL_LOGLEVELS"
;;
*)
complete_words="$GLOBAL_COMMANDS"
complete_options="$GLOBAL_OPTIONS"
;;
esac
;;
esac
# Either display words or options, depending on the user input
if [[ $cur == -* ]]; then
COMPREPLY=( $( compgen -W "$complete_options" -- $cur ))
else
COMPREPLY=( $( compgen -W "$complete_words" -- $cur ))
fi
return 0
}
## Helper functions ###
# Finds plugin identifier and determines possible plugin-specific settings
_sy_plugin_option_words() {
local plugin_id i
plugin_id=
for ((i = 1; i < ${#COMP_WORDS[@]}; ++i)); do
if [[ ${COMP_WORDS[i]} == "-P" ]] || [[ ${COMP_WORDS[i]} == "--plugin" ]]; then
plugin_id=${COMP_WORDS[i+1]}
break
fi
done
# Output completions without prefix
echo $(_sy_plugin_option_words_for_plugin "$plugin_id" "")
}
# Determines the plugin-specific settings set by --plugin-option or -o for a specific plugin
# Parameters: $1 is plugin ID (e.g. "ftp"), $2 is plugin prefix (e.g. "storage1.")
_sy_plugin_option_words_for_plugin() {
local plugin_id="$1"
local plugin_prefix="$2"
local plugin_completions=""
case "${plugin_id}" in
dropbox)
plugin_completions="$PLUGIN_DROPBOX_OPTIONS"
;;
ftp)
plugin_completions="$PLUGIN_FTP_OPTIONS"
;;
local)
plugin_completions="$PLUGIN_LOCAL_OPTIONS"
;;
raid0)
plugin_completions=$(_sy_plugin_option_words_raid0)
;;
s3)
plugin_completions="$PLUGIN_S3_OPTIONS"
;;
samba)
plugin_completions="$PLUGIN_SAMBA_OPTIONS"
;;
sftp)
plugin_completions="$PLUGIN_SFTP_OPTIONS"
;;
swift)
plugin_completions="$PLUGIN_SWIFT_OPTIONS"
;;
webdav)
plugin_completions="$PLUGIN_WEBDAV_OPTIONS"
;;
*)
;;
esac
# Output with prefix
for plugin_option in $plugin_completions; do
echo "${plugin_prefix}${plugin_option}"
done
}
# Determines the current plugin optons for RAID0 plugin,
# including sub-options of other plugins
_sy_plugin_option_words_raid0() {
local storage1_type storage2_type i
storage1_type=
storage2_type=
for ((i = 1; i < ${#COMP_WORDS[@]}; ++i)); do
if [[ $cur != ${COMP_WORDS[i]} && ${COMP_WORDS[i]} == storage1:type=* ]]; then
storage1_type=${COMP_WORDS[i]/storage1:type=}
fi
if [[ $cur != ${COMP_WORDS[i]} && ${COMP_WORDS[i]} == storage2:type=* ]]; then
storage2_type=${COMP_WORDS[i]/storage2:type=}
fi
done
if [[ $storage1_type != "" && $storage1_type != "raid0" ]]; then
echo $(_sy_plugin_option_words_for_plugin "$storage1_type" "storage1.")
else
for plugin_id in $PLUGIN_IDS_TRANSFER; do
echo "storage1:type=$plugin_id"
done
fi
if [[ $storage2_type != "" && $storage2_type != "raid0" ]]; then
echo $(_sy_plugin_option_words_for_plugin "$storage2_type" "storage2.")
else
for plugin_id in $PLUGIN_IDS_TRANSFER; do
echo "storage2:type=$plugin_id"
done
fi
}
# Determines the first non-option word of the command line. This
# is usually the command
_sy_get_firstword() {
local firstword i
firstword=
for ((i = 1; i < ${#COMP_WORDS[@]}; ++i)); do
if [[ ${COMP_WORDS[i]} != -* ]]; then
firstword=${COMP_WORDS[i]}
break
fi
done
echo $firstword
}
# Determines the last non-option word of the command line. This
# is usally a sub-command
_sy_get_lastword() {
local lastword i
lastword=
for ((i = 1; i < ${#COMP_WORDS[@]}; ++i)); do
if [[ ${COMP_WORDS[i]} != -* ]] && [[ -n ${COMP_WORDS[i]} ]] && [[ ${COMP_WORDS[i]} != $cur ]]; then
lastword=${COMP_WORDS[i]}
fi
done
echo $lastword
}
## Define bash completions ###
complete -F _sy sy
complete -F _sy syncany
Let me know if you have any questions or corrections. I’d love to hear your feedback!
A. About this post
I’m trying a new section for my blog. I call it Code Snippets. It’ll be very short, code-focused posts of things I recently discovered or find fascinating or helpful. I hope this helps.