Writing Your Own Simple Tab-Completions for Bash and Zsh
Shell tab-completions can be very handy, but setting them up is complicated by the fact that half your users would be using Bash-on-Linux, while the other half will be using Zsh-on-OSX, each of which has different tab-completion APIs. Furthermore, most users exploring an unfamiliar CLI tool using tab completion appreciate showing a description along with each completion so they can read what it is, but that’s normally only available on Zsh and not on Bash. But with some work, you can make your tab-completions work on both shells, including nice quality-of-life features like completion descriptions.
Based on our recent experience implementing this in the Mill build tool version 1.0.3, providing the great tab-completion experience you see below in a way that works across both common shells. Hopefully based on this, you will know enough and have enough reference examples to set up Bash and Zsh completions for your own command-line tooling.
The Basic Way Tab-Completion Works
The basic way tab-completion works in shells like Bash or Zsh is to register a handler function that is called when a user presses
This information, along with it generates a list of strings that are possible completions for the word at that index, and return it to the shell.
At a glance, this looks something like:
_generate_foo_completions is a dummy function used for demonstration purposes that prints out a hardcoded set of completions, but in a real scenario would be the logic that generates completions for your specific app or CLI tool. _complete_foo_bash and _complete_foo_zsh are the shell-specific completion functions that pass the current word to _generate_foo_completions and wire up the results to each shell’s unique completion APIs.
Setting Up Tab-Completions in Bash
Bash completion functions need to set the COMPREPLY environment variable, while Zsh completion functions need to call compadd (or one of the other similar functions).
This example snippet would typically be put (or sourced) in your ~/.bashrc, ~/.bash_profile, and ~/.zshrc so the if/elif/fi block at the bottom registers the relevant hooks when the shell starts.
These hook into tab-completion whenever foo is the first word at the prompt. For example, the Mill build tool provides a ./mill mill.tabcomplete/install builtin that automatically updates these files and instructs the user to restart the shell or source the relevant script to begin using completions:
if [ -f ~/.bashrc ]; then eval "$(cat ~/.bashrc)" fiif [ -f ~/.zshrc ]; then eval "$(cat ~/.zshrc)" fi
Completing Multiple Differing Word-Completions
In real usage, "foo" would be the name of the command the user would invoke your CLI program with (e.g. mill), and _generate_foo_completions would be your bespoke logic to print out a line-separated list of completions.
This could be a hard-coded list for programs that change infrequently, or it could actually invoke your binary and ask it what completions are available for the given input (what mill does).
While this example only looks up words[idx] to try and find a prefix match for the current word, the completer is allowed to use the entirety of words to decide what completions to offer, e.g. based on what flags or command-names are present in that array.
Ambiguity and Description
When you register completion hooks for foo in Bash and Zsh, they apply to commands like ./foo as well. This is handy for programs like Mill, Maven, or Gradle which typically use a ./mill Bootstrap Script to run:
# In ~/.bashrc if [ -f ~/bin/mill ]; then source ~/bin/mill fi# In ~/.zshrc if [ -f ~/bin/mill ]; then source ~/bin/mill fi
Trimming Description and Showing Ambiguous Completions
The completions above work and provide a basic level of assistance for users of your CLI, but it would be nice for users if they could also see a description of each command they could complete in the terminal, as is done in the Mill build tool:
if [ -n "${completions[0]}" ]; then local trimmed_completion=${completions[0]} local raw_completion=${completions[1]}if [ ${#raw_completion} -gt ${#trimmed_completion} ]; then # Remove trailing colon from description local desc=${raw_completion##*:} if [ ! -z "$desc" ]; then trimmed_completion+=":"$desc fi
# Show ambiguous completions for Zsh local raw=$(printf "%s\n%s" "${raw_completion}" "${trimmed_completion}") local trimmed=$(printf "%s\n%s" "${trimmed_completion}" "") if [ ${#completions[@]} -eq 1 ]; then compadd -d "$raw" "$trimmed" else compadd -d "$raw" -A -d "$trimmed" "" fi else # Show non-ambiguous completion for Bash local trimmed_completion=${completions[0]} local raw_completion=${completions[1]}
if [ ! -z "${raw_completion}" ]; then compadd -d "$raw_completion" else compadd -d "" fi fi
Tabbing on Already-Completed Word
The last quality of life feature we will add is the ability to show completion descriptions when tabbing on an already-completed word:
# In _complete_foo_bash if [ -n "${completions[0]}" ]; then local trimmed_completion=${completions[0]} if [ ! -z "$trimmed_completion" ]; then # Add dummy completion for Bash local raw=$trimmed_completion compadd -d "$raw" else # Show non-ambiguous completion for Bash local trimmed_completion=${completions[0]} compadd -d "" fiif [ ${#completions[@]} -eq 1 ]; then # Add extra word-only completion with description for Zsh local raw=$trimmed_completion local desc="" if [ ! -z "${raw_completion}" ]; then desc=${raw##*:} else desc="description" fi compadd -d "$raw" -A -d "$desc" fi
# Add extra word-only completion with description for Bash local trimmed_completion=$trimmed_completion local raw="" if [ ! -z "${completions[1]}" ]; then desc=${completions[1]} else desc="description" fi compadd -d "$raw" ""
The Final Code
And can be used in both Bash or Zsh to provide an identical user experience:
complete -F _generate_foo_completions foo complete -F _complete_foo_bash bar
This blog post just aims to provide the simplest working example that works in both Bash and Zsh, so hopefully you can understand it well enough to integrate into your own projects.
Conclusion
Tab-completion is a common way to explore unfamiliar APIs, and just because someone finished writing a flat or command doesn’t mean they aren’t curious about what it does!
By following this blog post, you can set up Bash and Zsh tab-completions for your own command-line tooling that work together seamlessly.