1
\$\begingroup\$

I've always found the boilerplate needed to use getopts correctly fairly fiddly and verbose, and was motivated to try to abstract it away. I wanted to try dynamically writing the parsed arguments into local variables accessible to the calling function, and I came up with this:

main() {
  local _usage='foo [-a] [-b] [-f val] [-v val] [args ...]'
  eval "$(parse_opts 'f:v:ab')"
  echo "f=$f v=$v a=$a b=$b -- $#: $*"
}

main "$@"

Yes, it uses eval, but given the alternative (a crufty while loop and case statement), I think it's still an improvement.

I'm looking for any general feedback about this approach and implementation. Can I reduce the amount of code being evaled? Anything I can simplify or improve? Any edge cases or bugs I've missed?

Relevant code/docs:

# Helper utility to simplify using Bash's getopts utility, and make it
# usable with functions.
#
# Example usage:
# foo() {
#   local _usage=...               # optional usage string
#   eval "$(parse_opts 'f:v:ab')"  # provide a standard getopts string
#   echo "f is $f"                 # opts are now local variables
#   if (( a )); then               # check boolean flags with (( ... ))
#     echo "Saw -a"
#   fi
# }
#
# This script eliminates the gruntwork and verbosity of using getopts,
# allowing you to focus on actual behavior rather than argument parsing.
# It handles looping over getopts, parsing each result, and reporting
# errors. Each parsed option is set as a local variable in the calling
# function, enabling easy and safe access to all values.
#
# No-arg options are set to 0 by default and 1 if passed as an
# argument, allowing concise testing with (( ... )).
#
# Options that accept an argument are set to the empty string by
# default, and otherwise set to the value passed as an argument.
# To check if a (non-empty) argument was passed use [[ -n "$..." ]].
#
# All parsed arguments are shift-ed out of $@, leaving any
# subsequent positional arguments in-place. A -- argument can be
# used to halt option parsing early, e.g. `-a -- -b` will only
# parse -a and leave -b as an argument.
#
# Parsing errors cause the calling function to return with exit
# code 2. If a _usage variable is set before argument parsing
# its contents will be included in the error message.
#


# Actual parser implementation; assumes all variables it sets
# are local, which parse_opts sets up. Do not call directly.
_parse_opts_helper() {
  local OPTARG opt optstring=${1:?optstring}; shift
  # ensure string _is_ prefixed with :
  while getopts ":${optstring#:}" opt; do
    case "${opt}" in
      [?:])
        case "${opt}" in
          :) echo "Option '-${OPTARG}' requires an argument" >&2 ;;
          [?]) echo "Unknown option '-${OPTARG}'" >&2 ;;
        esac
        if [[ -n "$_usage" ]]; then
          echo "Usage: $_usage" >&2
        fi
        return 2
        ;;
      *)
        if [[ "$optstring" != *"${opt}:"* ]]; then
          OPTARG=1
        fi
        printf -v "$opt" '%s' "$OPTARG"
        ;;
    esac
  done
}

# The output of this function is intended to be passed to eval,
# therefore we try to minimize what it prints and delegate most
# of the heavy lifting to a separate function.
# Expected output:
#   local OPTIND=1 a b c=0 # vars are dynamically parsed
#   _parse_opts_helper a:b:c "$@" || return
#   shift $((OPTIND - 1))
#   OPTIND=1
parse_opts() {
  local i char last_char vars=() optstring=${1:?optstring}
  optstring="${optstring#:}" # ensure string is not prefixed with :
  if ! [[ "$optstring" =~ ^[a-zA-Z:]*$ ]] || [[ "$optstring" == *::* ]]; then
    echo "Invalid optstring: $optstring" >&2
    return 2
  fi
  for (( i=${#optstring}-1 ; i >= 0 ; i-- )); do
    char=${optstring:i:1}
    if [[ "$char" != ":" ]]; then
      if [[ "$last_char" == ":" ]]; then
        vars+=("$char")
      else
        vars+=("${char}=0")
      fi
    fi
    last_char=$char
  done
  echo "local OPTIND=1 ${vars[*]}"
  printf '_parse_opts_helper %q "$@" || return\n' "$optstring"
  echo 'shift $((OPTIND - 1))'
  echo 'OPTIND=1'
}

Pulled from this gist which will likely be kept more up-to-date than the code pasted here.

\$\endgroup\$

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Browse other questions tagged or ask your own question.