In part one we looked at the basic mechanics of Bash completion. In this part we’ll walk through its implementation for my automation tool.
Recap: My tool is called
zz and I use it every day for routine tasks like
provisioning my machine and generating project templates. The animation above
shows some example use cases of Bash completion, e.g. completing
zz pr to
I added Bash completion support in this commit.
First, I register a completion function that passes the entire command line
through to another
zz command. Then
$COMPREPLY is set to the result of that
My philosophy with these things is to ‘get the hell out of
as soon as possible and handle the result in a more conventional way. In this case, I want to use my trusty
friend Ruby to determine the list of completions. This may as well be just
zz command since I’ll need access to the tool’s context.
The file is written to
/usr/local/bin/zz-completion.bash and I source it in my
zz provision to ensure this file exists and that it’s sourced.
Now on to the tool-specific behaviour…
My first goal is to implement completion for
commands and options. The idea being, if I type
zz prov that should complete
zz provision and if I type
zz <TAB><TAB>, it should provide a friendly
reminder of all available commands.
The same goes for options. If I type
zz provision --li<TAB>, I’d like that to
zz provision --list and
zz provision --<TAB><TAB> would
remind me of options for the
This behaviour is implemented in this commit.
It gets complicated and I could have simplified further. I’m happy it’s tested and can be refactored later if needs be. I decided to split the code up by the ‘type’ of completion, i.e. whether it’s the name of a command, an option, or its arguments.
For each type of completion, I defined a method that takes
args which is the
full command-line typed by the user as an array of words. The method returns
strings which are fed back as the completion options:
def complete_command(args) commands = filter_commands(args) # ... commands.map(&:name)end
I made it so these methods can return
false if this type of completion isn’t
relevant. For example, if the user has already typed
provision (a command), it
doesn’t make sense to complete the name of a command since we already have that:
method is more complicated but uses the same idea.
The final piece of the puzzle is to do contextual completion for options.
My original use-case was to complete
zz secret --read amaz to
zz secret--read amazon/ which means we need to produce different completions per option.
Conceptually, it would be nice if
--read were ‘responsible’ for
determining its list of completions. Perhaps it looks at the file system or
Hashicorp’s Vault to decide.
I haven’t implemented the
yet, but I can still do some ground work for completion now. This
#complete_option_arg which delegates to a Ruby
proc that can be
set on an
We can now implement completion for options
by providing them with blocks. For example, here’s how we do that for
The block receives all command-line arguments and returns an array of completion
strings. In this case, we find all template types that start with the last
argument on the command-line, so if the user types
template --type ru, it
A more complicated example is the
provision --only option which accepts a list
of Chef recipes. For example, you might say
provision --only vim,ruby.
Bash sometimes tries to be helpful which can often lead to surprising behaviour. For example, if all available completions start with the same prefix, Bash replaces the word at the end of the command-line with that prefix.
That’s why we have to copy the list into each completion:
$ zz provision --only vim,ru<TAB><TAB>vim,ruby vim,rust
However, this can be noisy if we have lots of completion options:
Ideally, we want it to just return the names of available recipes without all the noise but we can’t do that because Bash will replace our list by the common prefix. In this situation, Bash’s helpfulness has backfired and results in more noise than we’d like.
Knowledge is power and it so happens there’s this one weird trick we can use to get around the problem. Since we know Bash will only do the ‘wrong’ thing if there’s a common prefix, we can check for that and return a clutter-free list when there isn’t:
That’s what this lines is for:
return recipes if list == [prefix]
My implementation’s a bit simpler in that it just checks for an exact match
between the prefix and the filtered list of recipes. This has the desired
effect because completing
vim, now displays the full list of
This could be smarter and check for a ‘common prefix’ more directly, but it’s already complicated. Here are some unit test for this behaviour:
It’s been fun to understand the mechanics of feature we often don’t think about. Bash completion adds a friendly touch that can make our command-line tools easier to use through back-and-forth interactions with the user.
I think we should consider completion features in the context of user experience, accessibility and interface design. We should strive to build completion behaviours that help users navigate our tools and discover features.
For those implementing this behaviour, it’s helpful to know we can ‘escape’ out to our favourite programming languages without too much difficulty. This blog post has been about Bash because that’s what I use, but the concepts should be applicable wherever it’s implemented - though the mechanics will be different.
Finally, I’d be interested to hear if you come up with some novel way to use completion. Tweet at me if you write your own choose-your-own adventure game with it, or some wacky chess program.