How can script command line arguments be parsed portably?

67 views Asked by At

I have a script that I use to take screenshots. It runs maim in the background and provides a more convenient interface than just running the command by itself (much less to type, simpler, mnemonic options, saves screenshots to a directory with unique names). Basic usage of this script (assuming that it is in $PATH) is: screenshot <opts> <screenshot_type>.

#!/bin/dash

# Set the right default permissions for files
umask 077

# Directory where screenshots are saved
screenshots_directory="$HOME"/pictures/screenshots

# Notifications (default none)
notifications=0

# Error messages
directory_error_message="$screenshots_directory is not properly configured. Halting."
flag_error_message="contains an invalid flag. Halting."
type_error_message="is an invalid screenshot type. Halting."
screenshot_error_message="Failed to take the screenshot."

# Exit handler (exit status, stdout, stderr, notifications)
exit_handler () {
    exit_code=$1
    exit_notification_title="$2"
    message="$3"
    notify_opts=' '
    if [ "${exit_code}" -gt 0 ]; then
        notify_opts='--urgency=critical --expire-time=7000'
        echo "${message}" 1>&2
    else
        echo "${message}"
    fi
    shift 3
    [ "$notifications" -eq 1 ] && notify-send $notify_opts "${exit_notification_title}" "${*:-$message}"
    exit "$exit_code"
}

# Parse optional flags, save them and remove them from the positional parameters
opts=''
curropt=''
optgroup=''
virtoptgroup=''
while getopts "bcdn" curropt; do
    paramgroup="$1"
    case "$curropt" in
        b) flag="--capturebackground" ;;
        c) flag="--hidecursor" ;;
        d) flag="--delay=0.2 --quiet" ;;
        n) notifications=1 ;;
        *) exit_handler 1 "Error" "'${1}' $flag_error_message" ;;
    esac
    virtoptgroup="${virtoptgroup}${curropt}"
    optgroup="${optgroup} ${flag}"
    if [ "-${virtoptgroup}" = "$paramgroup" ]; then
        opts="${opts}${optgroup}"
        shift 1
        virtoptgroup=''
        optgroup=''
    fi
done

echo "$#"

This part of the script is supposed to parse the arguments and remove them from the positional parameters or exit the script with an error if invalid arguments are provided. An echo statement that prints the number of positional parameters has been added for illustration purposes. In order to reproduce the issue, copy it into a script and run it as follows: your_script_name -b -c full. It runs correctly with dash and it outputs 1. Change the shebang to #!/bin/bash, #!/bin/zsh or #!/bin/ksh and run the same command. The output will be greater than 1, meaning that the arguments were not properly parsed; this is indicative of an error that will occur in the latter part of the script.

This behavior has been tested on Arch Linux with the following versions of the shells: dash 0.5.12-1 bash 5.1.016-4 zsh 5.9-4 ksh 202.0.0-3

In short, the way the script works is that it gets the command line arguments it is given with getopts, it creates a set of flags for maim based on those and it removes them from the positional parameters with shift, so that, after it's done with the options, $1 is the screenshot type that it should take. This seemed to me like it was a good solution when I wrote the script. I didn't want it to be a cobbled-together hack, but a script that I can both learn from and extend/reuse parts of in the future. A benefit of my implementation is that options don't necessarily have to be separated by spaces (so something like screenshot -cn full works just like screenshot -c -n full).

I aim for portability and POSIX compatibility in my scripts, so I only use POSIX invocations of commands, I check my scripts with shellcheck and have /bin/sh set to dash. Initially, I thought that I had achieved that, but, after testing the script with bash, zsh and ksh, I found that the way I parse command line arguments only works in dash (so if the shebang is #!/bin/sh and /bin/sh is dash or if the shebang is #!/bin/dash).

Given all of the above, how should I go about solving the problem and making my script portable? Am I parsing the arguments slightly wrong or should I approach the problem in a completely different way?

0

There are 0 answers