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:
#!/bin/bashfunction _zz () { COMPREPLY=($(zz complete $COMP_LINE));};complete -F _zz zzSetting a Bash function to complete zz
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)endI 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:
def complete_command(args) commands = filter_commands(args) return false if args.size > 2 return false if exact_command_match?(commands, args) commands.map(&:name)endReturning false if this ‘type’ of completion isn’t relevant
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:
def type_option help = "sets the type of template (e.g. ruby)" @type_option ||= Option.new("t", "type", 1, help) do |args| types.select { |t| t.start_with?(args.last) } endendSelecting all types that start with the prefix
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.
def auto_complete_recipe_list(args) *head, prefix = args.last.downcase.split(",") list = recipes.select { |r| r.start_with?(prefix || "") } return recipes if list == [prefix] list.map { |l| (head + [l]).join(",") }endAn option that completes comma-separated lists
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,rustHowever, this can be noisy if we have lots of completion options:
$ zz provision --only vim,ruby<TAB><TAB>vim,ruby,aws vim,ruby,chrome vim,ruby,dock # ...many moreNoisy output: the comma-separated list is duplicated
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:
$ zz provision --only vim,ruby<TAB><TAB>aws chrome dock drive dropbox ffmpeg # ...Less noisy output: without the duplication
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:
expect(complete.call([""])).to include("git", "ruby", "vim")expect(complete.call(["ru"])).to eq ["ruby", "rust"]expect(complete.call(["VI"])).to eq ["vim", "virtualbox"]expect(complete.call(["x"])).to eq []expect(complete.call(["ruby"])).to include("git", "ruby", "vim")# Although this exact-matches, there are other options:expect(complete.call(["Chrome"])).to eq ["chrome", "chromedriver"]# ...Unit tests for provision --only option completion
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>