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.