Improve your Bash-fu with Command-line Completion

I am a Java developer and I am used to certain level of comfort when using all the tools Java ecosystem provides. All the modern IDEs contains code completion feature for example. I use Bash a lot and I use it mostly for git manipulation, because I find all the IDE plugins kind of lame. Fortunately, all the git packages provide also the bash completion, so if I can’t recall a certain option/switch, instead of “RTFM”, I can type in just:

[jkremser@jk ~ ]$ git grep --[TAB]

and I get

[jkremser@jk ~ ]$ git grep --
--all-match       --count
--and             --extended-regexp
--basic-regexp    --files-with-matches
--cached          --files-without-match
--fixed-strings   --line-number       --or
--full-name       --max-depth         --perl-regexp
--ignore-case     --name-only         --text
--invert-match    --not               --word-regexp

That certainly saves some time. If you were wondering how this is done or want to implement it for your tool, keep reading.

Bash Completion in Detail

Actually, it is pretty easy. It requires to install (or ensure it is installed) the package called bash-completion. Now, let’s stick with the git example. If you type in:

[jkremser@jk ~ ]$ declare -F

It’ll write all the functions defined in the current shell. If the git is installed, you should be able to find functions starting with _git prefix. Function responsible for completing the "git grep --[TAB]" (as shown above) is called _git_grep, so you can see, how it looks like by:

declare -f _git_grep
_git_grep ()
{
    __git_has_doubledash && return;
    case "$cur" in
        --*)
            __gitcomp "
			--cached
			--text --ignore-case --word-regexp --invert-match
			--full-name --line-number
			--extended-regexp --basic-regexp --fixed-strings
			--perl-regexp
			--files-with-matches --name-only
			--files-without-match
			--max-depth
			--count
			--and --or --not --all-match
			";
            return
        ;;
    esac;
    case "$cword,$prev" in
        2,* | *,-*)
            if test -r tags; then
                __gitcomp_nl "$(__git_match_ctag "$cur" tags)";
                return;
            fi
        ;;
    esac;
    __gitcomp_nl "$(__git_refs)"
}

So far no rocket science, right? I am not going to go into detail with the git completion, because it uses a lot of helper functions that are good for making the code DRY, but not so much for demonstrating the concepts. I’ll show how one can write it’s own bash function for the bash completion. Say, you’ve written a CLI tool in your favorite language and you would like to let the terminal to whisper you the right options on the right places.

Let’s assume the program is written in Java and it is packed as a jar file and the valid options are foo, bar and baz.

public class Cli {
  public static void main(String... args) {
    if (args.length != 1) {
      System.err.println("invalid usage: number of params");
    } else if ("foo".equals(args[0])) {
      System.out.println("Hello foo");
    } else if ("bar".equals(args[0])) {
      System.out.println("Hello bar");
    } else if ("baz".equals(args[0])) {
      System.out.println("Hello baz");
    } else {
      System.err.println("invalid usage: unknown param " + args[0]);
    }
  }
}

Create the jar file for this simple example: (or use IDE magic)

$ javac Cli.java && jar cfev cli.jar Cli Cli.class

Now, instead of calling it by

$ java -jar cli.jar {foo|bar|baz}

Let’s create a bash script and place it on $PATH. Make sure you replace the path to the cli with the right path.

$ su --
$ echo -e '#!/bin/bash\njava -jar /home/jkremser/blog/cli.jar $@' > /usr/bin/cli
$ chmod +x /usr/bin/cli

Allright, now we can call just cli from anywhere, but the completion still doesn’t work, let’s fix it.

Create following bash script, call it cli-completion.sh, mark it as executable and place it to the /etc/bash_completion.d directory.

#!/bin/bash

_cli() {
    COMPREPLY=()
    local cur _opts
    _cur="${COMP_WORDS[COMP_CWORD]}"
    _opts="foo bar baz"
    COMPREPLY=( $(compgen -W "${_opts}" -- ${_cur}) )
}

complete -F _cli cli

Now, if you run a new shell you should be able to get the suggestions when hitting TAB key.

[jkremser@jk ~ ]$ cli [TAB]
bar  baz  foo

Bash Completion for RHQ

Using the described steps, I’ve created a bash completion script for rhqctl that serves as an command line based entry point to RHQ. Instead of creating a bash script it’s only purpose was to redirect the call to java -jar something + passing the arguments, I’ve used simple bash alias feature, both options are viable. All the allowed options and switchs are described on our wikipage. Here is the code completion script:

#!/bin/bash

# completion function for rhqctl command
# to auto load this script move this script to /etc/bash_completion.d/

_rhqctl() {
    local cur prev opts agentServerStorage agentServerStorageStart serverStorage dataMingratorSubopts \
          storageInstallSubopts agentInstallSubopts serverInstallSubopts upgradeSubopts booleanSubopts trueFalse first second
    COMPREPLY=()

    cur="${COMP_WORDS[COMP_CWORD]}"
    prev="${COMP_WORDS[COMP_CWORD-1]}"
    opts="console install restart start status stop upgrade --help"
    agentServerStorage="--agent --server --storage"
    agentServerStorageStart="--agent --server --storage --start"
    serverStorage="--server --storage"
    dataMigratorSubopts="none estimate print-command do-it"

    # the spaces in the beginning and in the end are important here
    storageInstallSubopts=" --storage-data-root-dir "
    agentInstallSubopts=" --agent-config --agent-preference "
    #serverInstallSubopts=" --server-config "
    upgradeSubopts=" --from-agent-dir --from-server-dir --start --run-data-migrator --storage-config --storage-data-root-dir --use-remote-storage-node "
    booleanSubopts=" --use-remote-storage-node "
    trueFalse="true false"

    if [[ "${COMP_LINE}" =~ ^\.?rhqctl[[:space:]]*$ ]] ; then
      COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
      return 0
    fi

    first="${COMP_WORDS[1]}"

    if [[ "x${first}" == "xstart" ]] || [[ "x${first}" == "xstop" ]] || [[ "x${first}" == "xstatus" ]] || [[ "x${first}" == "xrestart" ]]; then
        COMPREPLY=( $(compgen -W "${agentServerStorage}" -- ${cur}) )
        return 0
    elif  [[ "x${first}" == "xconsole" ]] ; then
        COMPREPLY=( $(compgen -W "${serverStorage}" -- ${cur}) )
        return 0
    elif [[ "x${first}" == "xupgrade" ]] ; then
        if [[ "${upgradeSubopts}" == *" ${prev} "* ]] ; then
          if [[ "x${prev}" == "x--run-data-migrator" ]] ; then
            COMPREPLY=( $(compgen -W "${dataMigratorSubopts}" -- ${cur}) )
            return 0
          elif [[ "x${prev}" == "x--start" ]] ; then
            COMPREPLY=( $(compgen -W "${upgradeSubopts}" -- ${cur}) )
            return 0
          else
            checkForBooleanSubopt ${prev} && return 0
            completePath ${cur}
            return 0
          fi
        fi
        COMPREPLY=( $(compgen -W "${upgradeSubopts}" -- ${cur}) )
        return 0
    elif [[ "x${first}" == "xinstall" ]] ; then
        second="${COMP_WORDS[2]}"
        if [[ "x$second" == "x" ]] || [[ "x${second}" == "x--" ]]; then
            COMPREPLY=( $(compgen -W "${agentServerStorageStart}" -- ${cur}) )
            return 0
        fi
        if [[ "x${second}" == "x--agent" ]] ; then
            if [[ "${agentInstallSubopts}" == *" ${prev} "* ]] ; then
                completePath ${cur}
                return 0
            fi
            COMPREPLY=( $(compgen -W "${agentInstallSubopts} --start" -- ${cur}) )
            return 0
        elif [[ "x${second}" == "x--storage" ]] ; then
            if [[ "${storageInstallSubopts}" == *" ${prev} "* ]] ; then
                completePath ${cur}
                return 0
            fi
            COMPREPLY=( $(compgen -W "${storageInstallSubopts} --start" -- ${cur}) )
            return 0
        fi
        COMPREPLY=( $(compgen -W "${agentServerStorageStart}" -- ${cur}) )
        return 0
    # fallback
    elif [[ "${cur}" == * ]] ; then
        COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
        return 0
    fi
}

function completePath(){
  compopt -o nospace
  COMPREPLY=( $(compgen -f "${cur}") )
}

function checkForBooleanSubopt(){
  if [[ "${booleanSubopts}" == *" ${prev} "* ]] ; then
    COMPREPLY=( $(compgen -W "${trueFalse}" -- ${cur} ) )
    return 0
  else
    return 1
  fi
}

complete -F _rhqctl rhqctl

That is it!

That is it

I hope you find this blog post helpful. I am not a Bash ninja so bare with my bad habits. I would appreciate any suggestions for improvements in the comments. Hopefuly, it won’t be my last post for next 2 years 😉

Advertisements

Red Hat software engineer working on RHQ project, Android user, chess player, juggler, geek

Posted in RHQ, Tools

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: