Bash Completion Part 2 - Word Lists and Completing from a Specific Directory

Bash Completion Part 1


I've always found understanding and writing Bash Completion scripts difficult. Maybe because I've never clearly stated what it does. It's hard to solve a problem - or even understand other people's solutions to a problem - if you can't clearly state what the problem is.

"Given a command and a partial word that follows that command, provide an array of all possible words that could complete the given partial word given the context." That "context" thing can get fairly complicated given things like what folder the user is in, what option(s) have already been specified, and many other things. But let's make this simpler, reduce it to a fairly basic request: we have a partial word, we need to write a function that provides a list of words - we don't have to do the matching, Bash already provides a utility to do that.

The simplest form of completion occurs when you write a simple wrapper script - the wrapper needs to complete exactly the same as the script it wraps. We'll get to that shortly. The second simplest is a completion from a short list of words (that doesn't vary with circumstance):

_dog () {
    COMPREPLY=( $( compgen -W "sit beg walk run wag" "${COMP_WORDS[COMP_CWORD]}" ) )

complete -F _dog dog

If you run this at the command line, you should find that completions work as expected between the choice of words given in the script (it doesn't matter that dog isn't a valid command: completions will work anyway). Common practise is to name the completion function the same as the function it's completing for - except with a leading underscore. There are several assumptions and bits of knowledge already embedded in this: during the completion process, ${COMP_WORDS[COMP_CWORD]} contains the partial command we're attempting to match, and $COMPREPLY is the array of possible matching replies we're trying to generate. compgen is the Bash builtin that generates lists: you give it a list and a piece of text to match, and it returns a list of possible matches. By wrapping that in ( and ) we turn it into a Bash array. Finally, the -W option tells compgen to complete from a plain wordlist.

And now we can get to the actual simplest form, which is when you create a wrapper script or something similar that needs only to complete from the same list as another command:

complete -F _dog hamster

Again, it doesn't matter that the hamster command doesn't exist: if you type it at the command line, Bash will complete it. Exactly as it completes the dog command.

Dynamically Generating the Word List

Bash Completion Part 1 generated a word list with a code snippet. If you use this command, you'll find the output of complete contains this:

complete -W '
    soma-u80s' radio

This works fine, but has a couple problems. The more minor one is that this is inelegant, looks lousy, and takes up space in your shell environment. (It works ... personally I don't care much.) But if it's a really long list, and especially if the list can change during the duration of the shell session, it's far better if the list is generated on the fly. I wrote a script that looks at installed Fedora packages and tells me what packages they rely on and what packages rely on them. Because the package manager is called dnf, I called the script dnfwhy. This script accepts the names of installed packages and nothing else. To get a list of installed packages on an RPM-based system, you can run: repoquery --installed | sed -e 's/-[0-9][0-9]*:.*//' - the trailing sed command strips away the unneeded (and unwanted) long and verbose version numbers. To feed this back into our command, we create its completion companion function:

_dnfwhy ()
    local curr_arg;
    COMPREPLY=($(compgen -W "\$(repoquery --installed | sed -e 's/-[0-9][0-9]*:.*//' )" -- ${curr_arg} ))

The only real difference from Bash Completion Part 1 is the backslash before the $(...) that provides the list. Without the backslash, the list is populated when the shell starts; with the backslash, the list is executed on the fly each time _dnfwhy is called. But ... there's a major caveat: repoquery is a slow command that takes about a half second every time the <TAB> key is pressed during completion on an i7 processor! It's a trade-off: you can populate a huge static list, or you can use a dynamic list that significantly slows the tab completion process. Or ... you could come up with a faster method. I can think of ways of doing it, like having repoquery generate a static list on a cron job every ten minutes, and then using grep in the tab completion to search that list ... but that's getting complicated (and fragile). I'm okay with the implementation shown above.

Another thing I needed was the ability to complete on filenames in a specific directory (when I'm not in that directory). Two examples: I use vimwiki heavily, and I have a bunch of named Bash Prompts in ~/.bashprompt/prompts/. I have a command to edit specific vimwiki files called vw and another to load a prompt called promnow, so I wanted tab completion for each of these. The completion is essentially identical for each:

export _vw_dir="${HOME}/vimwiki"

    local cur="${2}"
    local _cur compreply
    mapfile compreply < <( compgen -f "$_cur" )
    COMPREPLY=( ${compreply[@]#$_vw_dir/} )

complete -o nospace -F _vw vw

The second parameter passed to the completion function (${2}) is the same value as ${COMP_WORDS[COMP_CWORD]} used in the previous example. And I'm using Bash builtin mapfile to create an array rather than surrounding a command with parentheses as previously.

Note the double substitution: we add the target directory before testing completions, and then remove it again before displaying the completions ... (Most of the credit for this design goes to the Stackoverflow post linked below). I constructed an almost identical completion (for a different target directory) for the prompts.