Kevin in The Office once said ‘Why waste time say lot word when few word do trick’. These are words to live by, and this is why I love aliases. Lately though I’ve been getting really tired of writing out stuff like:

  kubectl get pods -n network

I was saying too many words. Step one was to start using contexts with kubectl which let’s me omit the -n network part. I used fzf to make a handy little function that would let me switch contexts quickly. But I’ve got a full post coming on more of these nice functions so let’s save that for later.

But my trouble began when I created the alias for getting pods

  alias kgp='kubectl get pods'

Since I’ve got auto completion setup for kubectl naturally I wanted that for kgp too. But this wasn’t trivial. I tried looking at the helper script shipped with kubectl to see if I could tap into that, but it’s using functions that are shipped with bash to extract the word under the cursor for instance. And since kgp isn’t something the default completion script would understand this was a dead end.

A helpful redditor suggested that I look at how sudo does this, since it’s able to autocomplete commands it knows nothing about. Unfortunately sudo just checks if it should complete any sudo specific things and if not it passes the ball to the registered completion function for the command, which again doesn’t work because the word we are completing kgp is unknown to that function.

After reading through a lot of these magic completion functions being used I noticed that the truth is written in these variables:

  COMP_WORDS=([0]="kgp" [1]="")
  COMP_LINE="kgp "
  COMP_CWORD=1
  COMP_POINT=4

So the COMP_WORDS is just an array of a command and it’s arguments. COMP_LINE is simply the full line being completed. COMP_CWORD is the index in the words array of the work currently being completed, and finally COMP_POINT is the index in the string where our cursor is at.

I created a simple function which takes an alias name and returns the exact command the alias would execute:

  _expand_alias() {
    local alias_name alias_definition
    alias_name=$1
    [[ -z $alias_name ]] && return 1
    type "$alias_name" &>/dev/null || return 1
    alias_definition=$(alias "$alias_name")
    dequote "${alias_definition//alias ${alias_name}=}"
  }

If the alias name is empty or the alias doesn’t exist we just return, but if it does we use the alias command to get it’s definition and some variable expansion along with dequote to extract only the command.

Next we need to update the COMP_WORDS array with our expanded alias:

  _update_comp_words() {
    local alias_name alias_value
    alias_name=$1
    alias_value=$2
    [[ -z $alias_name || -z $alias_value ]] && return 1

    local alias_value_array
    read -r -a alias_value_array <<< "$alias_value"
    local comp_words=()

    for word in "${COMP_WORDS[@]}"; do
      if [[ $word == "$alias_name" ]]; then
        comp_words+=("${alias_value_array[@]}")
      else
        comp_words+=("$word")
      fi
    done

    COMP_WORDS=("${comp_words[@]}")
  }

This function parses our expanded alias into an array and simply substitutes the alias with it’s expanded form and updates the COMP_WORDS array directly. I would have preferred to use a subshell for this but passing arrays around isn’t very nice, and in this case speed is probably more important than readability.

With these two functions we basically just need to update the rest of the COMP_* variables. This is handled by the full wrapper script which also uses the two functions above:

  function _alias_completion_wrapper() {
    local alias_name alias_definition alias_value
    alias_name=${COMP_WORDS[0]}
    alias_value="$(_expand_alias "$alias_name")"
    [[ -z $alias_value ]] && return 1

    _update_comp_words "$alias_name" "$alias_value"
    # Update other COMP variables
    COMP_LINE=${COMP_LINE//${alias_name}/${alias_value}}
    COMP_CWORD=$(( ${#COMP_WORDS[@]} - 1 ))
    COMP_POINT=${#COMP_LINE}

    local previous_word current_word
    current_word=${COMP_WORDS[$COMP_CWORD]}
    if [[ ${#COMP_WORDS[@]} -ge 2 ]]; then
      previous_word=${COMP_WORDS[$(( COMP_CWORD - 1 ))]}
    fi
    local command=${COMP_WORDS[0]}
    comp_definition=$(complete -p "$command")
    comp_function=$(sed -n "s/^complete .* -F \(.*\) ${command}/\1/p" <<< "$comp_definition")

    # Call the original completion script with our expanded alias
    "$comp_function" "${command}" "${current_word}" "${previous_word}"
  }

So to set this up all we need is to add the completion hook:

  alias kgp='kubectl get pods'
  complete -o default -F _alias_completion_wrapper kgp

Now when bash wants to provide completion options for our alias it will first call _alias_completion_wrapper which expands the alias and updates the values of COMP_WORDS. Then we update the other COMP_* variables with their new values. Finally we find the completion function for our original command and we then execute that function passing along the current and previous word to get our proper auto completion.

Disclaimer: This is probably not how the completion features are meant to be used, so expect dragons.

Discussion: reddit.com/r/bash - post