Using Local OPTIND With Bash getopts

Typically when using writing a Bash script that uses getopts for option parsing, you'll have some boilerplate near the top of the script that looks something like this:

# parse options
while getopts ":f:hx" opt; do
  case $opt in
    f) foo="$OPTARG" ;;
    h) echo "$0 [-h] [-f FOO] [-x] FILE..."; exit;;
    x) set -x ;;
    \?) echo "Invalid option: -$OPTARG" >&2 ;;
  esac
done

# shift so that $@, $1, etc. refer to the non-option arguments
shift "$((OPTIND-1))"

In this example we have a script that accepts the flags -h and -x, and also accepts the option -f with an argument. The script also takes any number of positional arguments. To access the positional arguments, we call shift "$((OPTIND-1))" which ensures that $@ and so forth refer to the positional arguments and not the option arguments. This is pretty standard stuff, which you'll find in nearly any getopts tutorial.

Using Getopts In Functions

The above boilerplate works fine for standalone scripts. But things are tricky if you want to use getopts in your Bash functions. If you follow the pattern listed above, you'll run into all kinds of trouble!

To motivate this problem, here's an adapted example from my personal dot files. First we define a function named sourcefile that simply wraps the source shell builtin. I won't provide the full code for sourcefile here for simplicity, but just the rest of the function just prints verbose error/notice messages when then the -v flag is passed:

# XXX: Incorrect version
sourcefile() {
  local verbose=0
  while getopts ":hv" opt; do
    case $opt in
      h) echo "$0: [-h] [-v] FILE"; return 1;;
      v) verbose=1;;
      \?) echo "Invalid option: -$OPTARG" >&2 ;;
    esac
  done
  shift "$((OPTIND-1))"

  # implementation of sourcefile here...
}

Next there's a method named sourceall, which sources all of the arguments its given. This method also has an option to ignore a given file. The implementation of sourceall uses the sourcefile function we just defined:

# XXX: Incorrect version
sourceall() {
  local ignore
  while getopts ":hi:" opt; do
    case $opt in
      h) echo "usage: $(basename "$0") [-h] [-i IGNOREFILE] FILE..."; return;;
      i) ignore="$OPTARG";;
      \?) echo "Invalid option: -$OPTARG" >&2 ;;
    esac
  done
  shift "$((OPTIND-1))"

  local f
  for f in "$@"; do
    if [[ -n "$ignore" ]] && [[ "$(basename "$f")" != "$ignore" ]]; then
      sourcefile -v "$f"
    fi
  done
}

This code looks straightforward, and even passes ShellCheck without any errors or warnings. But it's not right. What's the issue?

Declaring Local OPTIND

The problem with the previous example is that OPTIND is a global variable. After sourceall calls getopts, the value of OPTIND will be updated to reflect the final option index. Later in the method, when sourceall invokes sourcefile, the value of OPTIND will still be set from the initial getopts call in sourceall. This will cause the getopts invocation in sourcefile to start at the wrong position!

One solution to this is to manually set OPTIND=1 before calls to getopts. I found a few examples of people recommending this pattern, e.g. in this recursive getopts example. This might work, but there's an even better way to do it: you can declare OPTIND to be a local variable. In the examples above, we would just adapt the functions like so:

# Correct version, declaring local OPTIND
sourcefile() {
  local OPTIND verbose=0
  # rest of this method stays the same...
}

# Correct version, declaring local OPTIND
sourceall() {
  local OPTIND ignore
  # rest of this method stays the same...
}

I recommend making a habit of declaring OPTIND as a local variable whenever using getopts in a Bash function.