Add tab autocomplete for your commandline apps

Deepjyoti Barman @deepjyoti30
Nov 20, 2020 2:08 PM UTC
Post cover

It so happens a lot of times that we don't notice small things that help us a lot in our day to day life. Since I am most of the time in front of the terminal, one feature that helps me a lot is the ability to autocomplete.

I know it's a simple, sweet feature but it is something I use a lot in my everyday usage of the Terminal. So I started wondering, how do I add something like that to ytmdl.

I came across this article. It is pretty detailed for bash. However I also wanted to add support for zsh since that's the shell I use.

How does it work?

So how does this feature exactly work? It is simple. We create a file with a chunk of code (written in shell) and we save it in a particular location. From this location the file will be automatically picked up by the shell and it will accordingly start showing autocomplete suggestions.

Before writing the file

So in our chunk of code, we will keep a list of options and from there the shell will be automatically able to suggest options. But it would be pretty tedious if we have to manually update the shell file with options that our commandline app would support everytime.

Thus we will add a new option to our commandline app. When this option (or flag) will be passed, our app will just print a space seperated list of options to the console and exit. This way, we will be able to use this command in our shell file whenever we want to build it.

In my case, I added a flag named --get-opts to my app (ytmdl). I then made sure that this flag is hidden by the argument parser so that users do not see it directly.

This can be done in the following way for Python with the argparse module

parser.add_argument('--get-opts', action="store_true", help=argparse.SUPPRESS)

# And later in the code, check for the option and accordingly generate a list of
# options
if args.get_opts:
    print(" ".join(("--{}".format(opt.replace("_", "-")) for opt in vars(args))))
    return

Writing the completion file for Bash

Even though the file will be pretty much same for all the shells, there are certain differences which needs to be maintained.

The file should be written in the following way:

_ytmdl_complete()
{
    local cur_word prev_word type_list

    # COMP_WORDS is an array of words in the current command line.
    # COMP_CWORD is the index of the current word (the one the cursor is
    # in). So COMP_WORDS[COMP_CWORD] is the current word; we also record
    # the previous word here, although this specific script doesn't
    # use it yet.
    cur_word="${COMP_WORDS[COMP_CWORD]}"
    prev_word="${COMP_WORDS[COMP_CWORD-1]}"

    # Ask ytmdl to generate a list of types it supports
    type_list=`ytmdl --get-opts`

    # Only perform completion if the current word starts with a dash ('-'),
    # meaning that the user is trying to complete an option.
    if [[ ${cur_word} == -* ]] ; then
        # COMPREPLY is the array of possible completions, generated with
        # the compgen builtin.
        COMPREPLY=( $(compgen -W "${type_list}" -- ${cur_word}) )
    else
        COMPREPLY=( "${type_list}" )
    fi
    return 0
}

# Register _ytmdl_complete to provide completion for the following commands
complete -F _ytmdl_complete ytmdl

The code pretty self explanatory. I have defined a function. This function uses the current word in order to check if it starts with a -. If it does, it means we need to suggest the options. In that case we are generating related words by using the compgen command. This command will return all the options that start with the given current word.

If the current word does not start with a - we are suggesting all the possible options.

The last line complete ... basically tells the shell (bash in this case) to call the function for the command ytmdl. It can be followed by more commands where this function should be called for completions.

For example, to complete the command ytmdl and ytm with the suggestions, we can do something like this.

complete -F _ytmdl_complete ytmdl ytm

Once we have the file ready, we need to place it in a directory from where it can be recognized by the shell.

For bash this directory is:

**/etc/bash_completion.d** or **/usr/share/bash-completion/completions/**.

Just place the file in either of the above directories and it try to run the command. Click tab after typing the command and you will see your suggestions.

Writing the completion file for zsh

In zsh, the logic remains same. We have a file that will be put in a specific directory and will be picked up by the shell. Just the syntax differs a bit.

The file for ZSH will be the following:

#compdef ytmdl

__ytmdl() {
    local curcontext="$curcontext" cur_word
    typeset -A opt_args

    cur_word=$words[CURRENT]
    type_list=`ytmdl --get-opts`

    # Only perform completion if the current word starts with a dash ('-'),
    # meaning that the user is trying to complete an option.
    if [[ ${cur_word} == -* ]] ; then
        # COMPREPLY is the array of possible completions, generated with
        # the compgen builtin.
        _arguments '*: :( $(compgen -W "${type_list}" -- ${cur_word}) )'
    else
        _arguments '*: :( "${type_list}" )'
    fi
    return 0
}

__ytmdl

In this file, we have the __ytmdl function which is called in order to generate the suggestions.

NOTE: In zsh completion file, the first line should be as above. It should be compdef <command_name>.

In this the command (that is to be completed) is passed in the first line of the file and rest of the code is just normal shell code. We have a function which is called to generate the options. The options are then passed to zsh by using the _arguments variable which is automatically picked up by zsh BTW.

For zsh, the directory is:

**/usr/share/zsh/site-functions/** or **/usr/share/zsh/functions/Completion/Unix/**

After putting the file in the above directory, to make sure the file is loaded the following can be done:

# source the rc file
source ~/.zshrc

# Load the function
autoload -U __ytmdl  # Here the function name is __ytmdl, replace accordingly.

For more details about writing zsh completion files, check this GitHub doc. It is pretty detailed and useful if you want to dive deep into writing a nice optimized completion file.

Tips

Okay, now that you have written your completion file, what next? Well, there's one thing else that you can still do in order to optimize the file. That is, since in the completion file the options are loaded by running the command ytmdl --get-opts. This means everytime the user will click on the tab button twice, your app (in this case ytmdl) will be called. That is not necessary and will be slow since we can call the function once and hardcode the options when the file is built.

Here's how we can do it. First of all, replace the command in the completion file with a replaceable content, something like the following:

# type_list=`ytmdl --get-opts`
# Replace the above line with the below line
type_list="{{static_opts}}"

Once you have done that, we can write a simple python script (you can write in whatever language you want) to fill that static variable.

The idea is to read the contents of the file, find this static variable and replace it with the options and write the output to a file. This final file will be the completion file that can be copied to the necessary directory.

# Lets say the template file is zsh-completion.in
# and the output file is ytmdl.zsh
TEMPLATE = "zsh-completion.in"
FINAL_FILE = "ytmdl.zsh"

opts = "" # string of options seperated by space.

content = open(TEMPLATE).read()
content = content.replace("{{static_opts}}", opts)

# Write the file now
with open(FINAL_FILE, "w") as w:
    w.write(content)

Above script will fill the {{static_opts}} with the proper options and then the final file can be copied to the directory.

Thanks to youtube-dl for the idea of dynamic filling using a python script.

Discussion