Zsh: parse command-line options with zparseopts

Sometimes you want your shell scripts to take options, like --verbose or --help, along with any positional arguments. Handling options is fiddly to do by hand, with the traditional portable approach being a large block of code using a while loop, a case statement, and some shifts. Bash has the getopts builtin, but it only handles single-letter options, like -v. Zsh also has getopts, for compatibility, with the same limitation.
Zsh has a better tool: zparseopts. It handles both short and long options, in one line of declarations.
A simple example
Here’s a script called caw, which prints a crow’s call. It takes one on/off option, -l or --loud, to capitalize the message:
#!/usr/bin/env zsh
# Caw like a crow.
zparseopts -D -E -F -- \
{l,-loud}=flag_loud \
|| exit 1
if (( $#flag_loud )); then
message="CAW!"
else
message="Caw!"
fi
print $message
Without the option, it caws normally:
$ ./caw
Caw!
With the option, it caws loudly:
$ ./caw --loud
CAW!
And unknown options are rejected:
$ ./caw --lodu
./caw:zparseopts:4: bad option: --lodu
Let’s break down how it works.
Specifications
After the --, each argument to zparseopts specifies an option, in the form name=array. When zparseopts finds an option, it appends it to the named array. For example, l=flag_loud means that if -l is passed, it ends up in the array flag_loud.
Long option names start with an extra -, so -loud=flag_loud handles --loud. The script uses brace expansion to declare both short and long forms at once: {l,-loud}=flag_loud expands to l=flag_loud -loud=flag_loud.
After parsing caw --loud, the array contains the option:
$ typeset -p flag_loud
typeset -a flag_loud=( --loud )
If the option isn’t passed, the array is empty. So the script checks for the option with (( $#flag_loud )), which tests whether the array is non-empty.
The flags
The above script also passes three flags to zparseopts before the specifications, which I’d recommend for most uses:
-Ddeletes parsed options from the positional parameters, so$@contains only the remaining arguments.cawdoesn’t take any other arguments, but most scripts do, such as filenames to process. Without-D, those scripts would see options mixed in with their arguments.-Eallows options to appear anywhere, rather than only before the first non-option argument. This allows commands likescript file.txt --loudto work.-Fmakeszparseoptsfail on unknown options, printing an error message. Without it, typos like--loduwould be silently ignored. On failure,zparseoptsreturns a non-zero exit status, which|| exit 1uses to stop the script.
A bigger example
Here’s caw extended with two more options, -h / --help to print usage, and -n / --number to caw multiple times:
#!/usr/bin/env zsh
# Caw like a crow.
usage() {
# Keep in sync with zparseopts specifications below.
print "usage: caw [-h] [-l] [-n NUMBER]"
}
zparseopts -D -E -F -- \
{h,-help}=flag_help \
{l,-loud}=flag_loud \
{n,-number}:=arg_number \
|| { usage >&2; exit 1 }
if (( $#flag_help )); then
usage
exit
fi
if (( $#flag_loud )); then
message="CAW!"
else
message="Caw!"
fi
repeat ${arg_number[-1]:-1} print $message
For example:
$ ./caw --help
usage: caw [-h] [-l] [-n NUMBER]
$ ./caw --loud -n 3
CAW!
CAW!
CAW!
--help is an on/off option, like --loud. The new parts are the usage message and --number, which takes an argument.
Usage message
The script defines a usage function, which it calls for --help, and when zparseopts fails. On failure, || { usage >&2; exit 1 } prints the usage message to standard error after the error message, then exits:
$ ./caw --lodu
./caw:zparseopts:9: bad option: --lodu
usage: caw [-h] [-l] [-n NUMBER]
Unfortunately, zparseopts can’t generate a usage message for you, so you need to keep it in sync with the specifications by hand.
: for an option with an argument
A : after the name means the option takes an argument, which is also appended to the array. For example, after parsing --number 3, the arg_number array contains the option and its argument:
$ typeset -p arg_number
typeset -a arg_number=( --number 3 )
Short options also accept their argument attached, like -n3.
If the argument is missing, -F makes zparseopts fail:
$ ./caw -n
./caw:zparseopts:9: missing argument for option: -n
usage: caw [-h] [-l] [-n NUMBER]
The script reads the argument with ${arg_number[-1]:-1}. This takes the last array element, which is the argument for the last --number passed, or 1 if the option wasn’t passed at all. Using the last element means repeated options override earlier ones, like -n 2 -n 4 giving 4.
The script then uses Zsh’s repeat loop to print the message that many times.
+ for a repeatable option
By default, the array only stores an option once, even if it’s repeated. A + after the name makes the option accumulate instead. For example, {l,-loud}+=flag_loud would let -l -l mean “extra loud”, with $#flag_loud as the level.
<name>=<value> is not supported
Many programs, like Git, accept long options with an = to separate the value, like --number=3. Unfortunately, zparseopts doesn’t treat = specially, so the = ends up in the argument:
$ typeset -p arg_number
typeset -a arg_number=( --number '=3' )
So this usage of caw fails:
$ ./caw --number=3
./caw:26: bad math expression: operand expected at `=3'
If you want to support this form, you’ll need to strip a leading = with the ${name#pattern} parameter expansion, for each argument you read:
repeat ${${arg_number[-1]#=}:-1} print $message
…that’s probably not worth it for most scripts.
Read my book Boost Your Git DX to Git better.
One summary email a week, no spam, I pinky promise.
Related posts:
- Zsh: manipulate filenames with modifiers like
:r(root) and:e(extension) - Zsh: set aside a command with “Escape Q” /
push-line - Zsh: tab complete glob qualifiers
Tags: zsh