Skip to content

Instantly share code, notes, and snippets.

@adamhotep
Last active November 29, 2023 23:22
Show Gist options
  • Save adamhotep/895cebf290e95e613c006afbffef09d7 to your computer and use it in GitHub Desktop.
Save adamhotep/895cebf290e95e613c006afbffef09d7 to your computer and use it in GitHub Desktop.
POSIX shell: support long options by converting them to short options
# a refinement of https://stackoverflow.com/a/5255468/519360
# see also my non-translating version at https://stackoverflow.com/a/28466267/519360
# translate long options to short
reset=true stopped=""
for opt in "$@"; do
if [ -n "$reset" ]; then
unset reset
set -- # reset the "$@" array so we can rebuild it
fi
case "$opt" in # --option=argument -> opt='--option' optarg='argument'
--?*=* ) optarg="${opt#*=}" opt="${opt%%=*}" ;;
* ) unset optarg ;;
esac
case "$stopped$opt" in
-- ) stopped=true; set -- "$@" -- ;;
--help ) set -- "$@" -h ;;
--verbose ) set -- "$@" -v ;;
--config ) set -- "$@" -c ${optarg+"$optarg"} ;;
--long-only ) DEMO_LONG_ONLY_FLAG=true ;;
# pass anything else through, including spaced arguments
* ) set -- "$@" "$opt" ;;
esac
done
# now we can process with getopt
while getopts ":hvc:" opt; do
case $opt in
h ) usage ;;
v ) VERBOSE=true ;;
c ) source $OPTARG ;;
\? ) usage ;;
: )
echo "option -$OPTARG requires an argument"
usage
;;
esac
done
shift $((OPTIND-1))
@adamhotep
Copy link
Author

Today's edits add support for --option=argument without as much ugliness as previously anticipated. If you don't want that, remove the first case stanza and the ${optarg:+"$optarg"} part of --config (though leaving them in is harmless).

This code uses a some parameter substitutions. The first one, ${opt#*=}, takes the value of $opt without the first = and the non-equals-sign characters that precede it (aka s/^[^=]*=//). The second one, ${opt%%=*}, pulls greedily from the end, removing the first = and everything that follows it (aka s/=.*$//).

The third subsitution, ${optarg+"$optarg"}, ensures we only add the argument when it was actually defined. If we used "$optarg" instead, we'd be adding an empty string as the argument and --config foo.conf would become -c '' foo.conf which will run source '' (resulting in sh: 31: source: not found) and getopts will terminate given the standalone foo.conf even if more options follow.

This is a little tricky. If we used ${optarg:+"$optarg"} instead, that extra colon changes the logic given an empty assignment. Consider:

unset test # $test is not defined
set -- a ${test+"$test"}
echo $#    # there is ONE parameter
set -- a ${test:+"$test"}
echo $#    # there is ONE parameter

test=""    # $test is defined but empty
set -- a ${test+"$test"}
echo $#    # there are TWO parameters
set -- a ${test:+"$test"}
echo $#    # there is ONE parameter

@adamhotep
Copy link
Author

Aside from needing a preprocessing loop, this approach has a flaw in that it loses the long option name; if you trigger that : clause (meaning you've forgotten an option's argument), the complaint uses $opt (which getopts has converted to $OPTARG), e.g. -c in place of --config.

Working around that is only a little ugly: Add "--$opt" after each set -- "$@" in the second case of the for loop excluding the * clause. Before that final clause, add a new -* ) set -- "$@" "--$opt" "$opt" ;; clause. Add -: to the getopts optstring. Add - ) param="$OPTARG" ;; to the getopts loop's case stanza, and then refer to $param instead of -$OPTARG.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment