Adding Bash completion to my tool

This is the second of two parts on Bash completion. Part one is here.
A reminder of what we’re trying to achieve

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.

The ‘zz’ completion function

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 zz

Setting 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

Completing command names

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:

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)end

Returning false if this ‘type’ of completion isn’t relevant

The #complete_option method is more complicated but uses the same idea.

Completing option arguments

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) }  endend

Selecting 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(",") }end

An option that completes comma-separated lists

A few ‘gotchas’

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:

$ zz provision --only vim,ruby<TAB><TAB>vim,ruby,aws   vim,ruby,chrome   vim,ruby,dock   # ...many more

Noisy 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.

Fighting back

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

Closing remarks

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>