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 zzprovision
.
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
command:
My philosophy with these things is to ‘get the hell out of <OBSCURE THING>
’
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
another 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 ~/.profile
. I’m
actually using 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
to 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
complete to zz provision --list
and zz provision --<TAB><TAB>
would
remind me of options for the provision
command.
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:
The #complete_option
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 secret
command
yet, but I can still do some ground work for completion now. This
commit
implements #complete_option_arg
which delegates to a Ruby proc
that can be
set on an Option
.
We can now implement completion for options
by providing them with blocks. For example, here’s how we do that for template--type
:
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
selects ruby
and rust
.
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,ruby
or vim,
now displays the full list of
recipes.
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.
Happy completi<TAB><TAB>