jahed.dev

Parsing Optional Arguments in Bash

When writing a Bash script, there are times when it can grow to cover multiple scenarios. For example, maptiles is a script I use to convert an image into tiles to use with Leaflet. The image can be a different shape and I might want a different image format for the map tiles.

Here's the command I used to have:

# ./maptiles <input> <output> [<format>]
./maptiles image.png ./tiles jpg

Very simple. So I wanted to add the option to "square" the <input> since map tiles need to be square images. Previously, I squared the image as a separate command before passing it through, but it was a common usecase and I'd rather have it in one script. How would I go about it?

# ./maptiles <input> <output> [<format>] [square]
./maptiles image.png ./tiles jpg square

This approach is okay, but what happens if I don't want to set a <format>? Well, it messes up the argument order so my script won't easily be able to tell what square is intended for.

There's a common solution to this: flags.

# ./maptiles <input> [--format <format>] [--square] <output>
./maptiles image.png --format jpg --square ./tiles

Since each argument here is flagged, there's no ambiguity. Remove --format <format> and it's still obvious --square is its own flag.

Most languages provide libraries to parse these flags into key-value maps. However in Bash, it's not as simple. Since one of the appeals of Bash is its portability, introducing an external dependency has a higher bar and should be avoided where possible.

There is a standard getopts command which can parse single-letter flags, but I'm not a huge fan of those as it affects readability for long-term scripts.

So I looked around and found a StackOverflow answer which is almost perfect. Essentially the solution is to iterate through all of your arguments and handle them accordingly until there's none left. Here's what I got:

POSITIONAL=()
while [[ $# -gt 0 ]]; do
  key="$1"
  case $key in
    -f|--format)
      format="$2"
      shift
      shift
      ;;
    -s|--square)
      square="true"
      shift
      ;;
    -*)
      failure "unknown argument $1"
      exit 1
      ;;
    *)
      POSITIONAL+=("$1")
      shift
      ;;
  esac
done
set -- "${POSITIONAL[@]}"

input="${1}"
output="${2}"

if [ -z ${format} ]; then # if format is not set
  format='png' # default to 'png'
fi

if [ ! -z ${square} ]; then # if square is set
  # run squaring command
fi

Let's go through it.

POSITIONAL is used to store any arguments that aren't flagged. In my case the input and output. Their order is maintained so they can be used later in the script.

The flags are used for conditional logic. All flags are assumed to start with at least one -, so if any unknown flags are used, they're caught by the -* matcher and the script fails.

After one argument is handled, it's removed from the arguments array using shift, which removes the first element of the argument list. If it also handles a value, such as jpg in --format jpg, that will also be removed with a second shift. This way on each iteration $1 will always point to the next argument and $2 will be a value, if any.

$# is the length of the array, so once all the arguments are handled and removed, the parsing stops.

Finally set -- replaces the now empty arguments array with the POSITIONAL array so that those values can be used as normal ($1, $2, etc.).

Conclusion

This is pretty much a perfect solution. It's easy to understand, the process is easy to remember and it's all using simple Bash.

Sure it's a bit error prone; needing to use shift a specific number of times is prone to human error. But that can easily be fixed with some small functions tailored of specific use cases.

Thanks for reading.