Zsh: parse command-line options with zparseopts

A beautiful shell!

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:

  • -D deletes parsed options from the positional parameters, so $@ contains only the remaining arguments. caw doesn’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.
  • -E allows options to appear anywhere, rather than only before the first non-option argument. This allows commands like script file.txt --loud to work.
  • -F makes zparseopts fail on unknown options, printing an error message. Without it, typos like --lodu would be silently ignored. On failure, zparseopts returns a non-zero exit status, which || exit 1 uses 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.

Fin

May your scripts be short, your commands commendable, and your parsing code pass,

—Adam


Read my book Boost Your Git DX to Git better.


Subscribe via RSS, Twitter, Mastodon, or email:

One summary email a week, no spam, I pinky promise.

Related posts:

Tags: