Optional option argument with getopts

Viewed 82001
while getopts "hd:R:" arg; do
  case $arg in
    h)
      echo "usage" 
      ;;
    d)
      dir=$OPTARG
      ;;
    R)
      if [[ $OPTARG =~ ^[0-9]+$ ]];then
        level=$OPTARG
      else
        level=1
      fi
      ;;
    \?)
      echo "WRONG" >&2
      ;;
  esac
done
  • level refers to the parameter of -R, dir refers to parameters of -d

  • when I input ./count.sh -R 1 -d test/ it works correctly

  • when I input ./count.sh -d test/ -R 1 it works correctly

  • but I want to have it work when I input ./count.sh -d test/ -R or ./count.sh -R -d test/

This means that I want -R to have a default value and for the sequence of commands to be more flexible.

13 Answers

Inspired in @calandoa's answer (the only one that actually works!), I've made a simple function that can make it easy to be used multiple times.

getopts_get_optional_argument() {
  eval next_token=\${$OPTIND}
  if [[ -n $next_token && $next_token != -* ]]; then
    OPTIND=$((OPTIND + 1))
    OPTARG=$next_token
  else
    OPTARG=""
  fi
}

An example usage:

while getopts "hdR" option; do
  case $option in
  d)
    getopts_get_optional_argument $@
    dir=${OPTARG}
    ;;
  R)
    getopts_get_optional_argument $@
    level=${OPTARG:-1}
    ;;
  h)
    show_usage && exit 0
    ;;
  \?)
    show_usage && exit 1
    ;;
  esac
done

This gives us a practical way to get "that missing feature" in getopts :)

NOTE that nevertheless command-line options with optional args seems to be discouraged explicitly

Guideline 7: Option-arguments should not be optional.

but I have no intuitive way to implement my case without this: I have 2 modes that are activated by either using one flag or other, and those have both an argument with a clear default. Introducing a third flag just to disambiguate makes it look a bad CLI style.

I've tested this with many combinations, including all in @aaron-sua's answer and works well.

All solutions presented so far put code in case ... in ... esac, but in my opinion, it would be much more natural to have a modified getopts command, thus I wrote this function:

EDIT:

Now, you can specify the type of an optional arg (see usage info).

Furthermore, instead of testing if $nextArg "looks like" an option(s) arg, the function now checks if $nextArg contains a letter from $optstring. This way, an option letter not contained in $optstring can be used as optional arg, as with getopts' mandatory args.

Latest changes:

  • Fixed test if $nextArg is an option arg:
    Test if $nextArg begins with a dash.
    Without this test, optional args that contain a letter from $optstring are not recognised as such.
  • Added regexp type specifier (see usage info).
  • Fixed: 0 not recognised as optional arg specified to be an int.
  • Simplified test if $nextArg is an int.
  • Type specifier ::/.../: Use perl to test if $nextArg matches the regexp.
    This way, you benefit from (almost (*)) the full power of Perl regexps.
    (*): See last paragraph of usage info.
  • Fixed: Doesn't work with more than one regexp type specifier:
    Use perl instead of grep/sed constructs because non-greedy matching is needed.

Usage:

Invocation: getopts-plus optstring name "$@"

optstring: Like normal getopts, but you may specify options with optional argument by appending :: to the option letter.

However, if your script supports an invocation with an option with optional argument as the only option argument, followed by a non-option argument, the non-option argument will be considered to be the argument for the option.

If you're lucky and the optional argument is expected to be an integer, whereas the non-option argument is a string or vice versa, you may specify the type by appending :::i for an integer or :::s for a string to solve that issue.

If that doesn't apply, you may specify a Perl regexp for the optional arg by appending ::/.../ to the option letter.
See here for an introduction to Perl regexps: https://perldoc.perl.org/perlretut
Please note: ATM, only /.../ will be recognised as a regexp after ::, i. e. neither other delimiters, nor modifiers may be used, so e. g. m#...#a will not be recognised.
If there is a non-option argument after the option with optional argument, it will be considered to be the optional argument only if it matches the regexp.
To be clear: ::/.../ is not meant for argument validation but solely to discriminate between arguments for options with optional argument and non-option arguments.

#!/bin/bash

# Invocation: getopts-plus optstring name "$@"\
# \
# optstring: Like normal getopts, but you may specify options with optional argument
# by appending :: to the option letter.\
# \
# However, if your script supports an invocation with an option with optional
# argument as the only option argument, followed by a non-option argument,
# the non-option argument will be considered to be the argument for the option.\
# \
# If you're lucky and the optional argument is expected to be an integer, whereas
# the non-option argument is a string or vice versa, you may specify the type by
# appending :::i for an integer or :::s for a string to solve that issue.\
# \
# If that doesn't apply, you may specify a Perl regexp for the optional arg by appending
# ::/.../ to the option letter.\
# See here for an introduction to Perl regexps: https://perldoc.perl.org/perlretut
# Please note: ATM, only /.../ will be recognised as a regexp after ::,\
# i. e. neither other delimiters, nor modifiers may be used, so e. g. m#...#a will
# not be recognised.\
# If there is a non-option argument after the option with optional argument, it will
# be considered to be the optional argument only if it matches the regexp.\
# To be clear: ::/.../ is not meant for argument validation but solely to discriminate
# between arguments for options with optional argument and non-option arguments.
function getopts-plus
{
    local optstring=$1
    local -n name=$2

    shift 2

    local optionalArgSuffixRE='::(?::[si]|/.*?/)?'
    local optionalArgTypeCaptureRE=':::([si])|::(/.*?/)'

    # If we pass 'opt' for 'name' (as I always do when using getopts) and there is
    # also a local variable 'opt', the "outer" 'opt' will always be empty.
    # I don't understand why a local variable interferes with caller's variable with
    # same name in this case; however, we can easily circumvent this.
    local opt_

    # Extract options with optional arg

    local -A isOptWithOptionalArg

    while read opt_; do
        # Using an associative array as set
        isOptWithOptionalArg[$opt_]=1
    done <<<$(perlGetCaptures "$optstring" "([a-zA-Z])$optionalArgSuffixRE")

    # Extract all option letters (used to weed out possible optional args that are option args)
    local optLetters=$(perlGetCaptures "$optstring" "([a-zA-Z])(?:$optionalArgSuffixRE|:)?")

    # Save original optstring, then remove our suffix(es)
    local optstringOrg=$optstring
    optstring=$(perl -pe "s#$optionalArgSuffixRE##g" <<<$optstring)

    getopts $optstring name "$@" || return # Return value is getopts' exit value.

    # If current option is an option with optional arg and if an arg has been provided,
    # check if that arg is not an option and if it isn't, check if that arg matches(*)
    # the specified type, if any, and if it does or no type has been specified,
    # assign it to OPTARG and inc OPTIND.
    #
    # (*) We detect an int because it's easy, but we assume a string if it's not an int
    # because detecting a string would be complicated.
    # So it sounds strange to call it a match if we know that the optional arg is specified
    # to be a string, but merely that the provided arg is not an int, but in this context,
    # "not an int" is equivalent to "string". At least I think so, but I might be wrong.

    if ((isOptWithOptionalArg[$name])) && [[ ${!OPTIND} ]]; then
        local nextArg=${!OPTIND} foundOpt=0

        # Test if $nextArg is an option arg
        if [[ $nextArg == -* ]]; then
            # Check if $nextArg contains a letter from $optLetters.
            # This way, an option not contained in $optstring can be
            # used as optional arg, as with getopts' mandatory args.

            local i

            # Start at char 1 to skip the leading dash
            for ((i = 1; i < ${#nextArg}; i++)); do
                while read opt_; do
                    [[ ${nextArg:i:1} == $opt_ ]] && foundOpt=1 && break 2
                done <<<$optLetters
            done

            ((foundOpt)) && return
        fi

        # Extract type of optional arg if specified
        local optArgType=$(perlGetCaptures "$optstringOrg" "$name(?:$optionalArgTypeCaptureRE)" '$1$2')

        local nextArgIsOptArg=0

        case $optArgType in
            /*/) # Check if $nextArg matches regexp
                perlMatch "$nextArg" "$optArgType" && nextArgIsOptArg=1
                ;;
            [si]) # Check if $nextArg is an int
                local nextArgIsInt=0

                [[ $nextArg =~ ^[0-9]+$ ]] && nextArgIsInt=1

                # Test if specified type and arg type match (see (*) above).
                # N.B.: We need command groups since && and || between commands have same precedence.
                { [[ $optArgType == i ]] && ((nextArgIsInt)) || { [[ $optArgType == s ]] && ((! nextArgIsInt)); }; } && nextArgIsOptArg=1
                ;;
            '') # No type or regexp specified => Assume $nextArg is optional arg.
                nextArgIsOptArg=1
                ;;
        esac

        if ((nextArgIsOptArg)); then
            OPTARG=$nextArg && ((OPTIND++))
        fi
    fi
}

# Uses perl to match \<string\> against \<regexp\>.\
# Returns with code 0 on a match and 1 otherwise.
function perlMatch # Args: <string> <regexp>
{
    perl -e 'q('"$1"') =~ '"$2"' and exit 0; exit 1;'
}

# Uses perl to match \<string\> against \<regexp\>
# and prints each capture on a separate line.\
# If \<regexp\> contains more than one capture group,
# you must specify the \<line format\> which is an
# arbitrary Perl string containing your desired backrefs.\
# By default, merely $1 will be printed.
function perlGetCaptures # Args: <string> <regexp> [<line format>]
{
    local lineFmt=${3:-\$1}

    # Matching repeatedly with g option gives one set of captures at a time.
    perl -e 'while (q('"$1"') =~ m#'"$2"'#g) { print(qq('"$lineFmt"') . "\n"); }'
}

The same script without comments inside function bodies in case you don't need them:

#!/bin/bash

# Invocation: getopts-plus optstring name "$@"\
# \
# optstring: Like normal getopts, but you may specify options with optional argument
# by appending :: to the option letter.\
# \
# However, if your script supports an invocation with an option with optional
# argument as the only option argument, followed by a non-option argument,
# the non-option argument will be considered to be the argument for the option.\
# \
# If you're lucky and the optional argument is expected to be an integer, whereas
# the non-option argument is a string or vice versa, you may specify the type by
# appending :::i for an integer or :::s for a string to solve that issue.\
# \
# If that doesn't apply, you may specify a Perl regexp for the optional arg by appending
# ::/.../ to the option letter.\
# See here for an introduction to Perl regexps: https://perldoc.perl.org/perlretut
# Please note: ATM, only /.../ will be recognised as a regexp after ::,\
# i. e. neither other delimiters, nor modifiers may be used, so e. g. m#...#a will
# not be recognised.\
# If there is a non-option argument after the option with optional argument, it will
# be considered to be the optional argument only if it matches the regexp.\
# To be clear: ::/.../ is not meant for argument validation but solely to discriminate
# between arguments for options with optional argument and non-option arguments.
function getopts-plus
{
    local optstring=$1
    local -n name=$2

    shift 2

    local optionalArgSuffixRE='::(?::[si]|/.*?/)?'
    local optionalArgTypeCaptureRE=':::([si])|::(/.*?/)'

    local opt_

    local -A isOptWithOptionalArg

    while read opt_; do
        isOptWithOptionalArg[$opt_]=1
    done <<<$(perlGetCaptures "$optstring" "([a-zA-Z])$optionalArgSuffixRE")

    local optLetters=$(perlGetCaptures "$optstring" "([a-zA-Z])(?:$optionalArgSuffixRE|:)?")

    local optstringOrg=$optstring
    optstring=$(perl -pe "s#$optionalArgSuffixRE##g" <<<$optstring)

    getopts $optstring name "$@" || return

    if ((isOptWithOptionalArg[$name])) && [[ ${!OPTIND} ]]; then
        local nextArg=${!OPTIND} foundOpt=0

        if [[ $nextArg == -* ]]; then
            local i

            for ((i = 1; i < ${#nextArg}; i++)); do
                while read opt_; do
                    [[ ${nextArg:i:1} == $opt_ ]] && foundOpt=1 && break 2
                done <<<$optLetters
            done

            ((foundOpt)) && return
        fi

        local optArgType=$(perlGetCaptures "$optstringOrg" "$name(?:$optionalArgTypeCaptureRE)" '$1$2')

        local nextArgIsOptArg=0

        case $optArgType in
            /*/)
                perlMatch "$nextArg" "$optArgType" && nextArgIsOptArg=1
                ;;
            [si])
                local nextArgIsInt=0

                [[ $nextArg =~ ^[0-9]+$ ]] && nextArgIsInt=1

                { [[ $optArgType == i ]] && ((nextArgIsInt)) || { [[ $optArgType == s ]] && ((! nextArgIsInt)); }; } && nextArgIsOptArg=1
                ;;
            '')
                nextArgIsOptArg=1
                ;;
        esac

        if ((nextArgIsOptArg)); then
            OPTARG=$nextArg && ((OPTIND++))
        fi
    fi
}

# Uses perl to match \<string\> against \<regexp\>.\
# Returns with code 0 on a match and 1 otherwise.
function perlMatch # Args: <string> <regexp>
{
    perl -e 'q('"$1"') =~ '"$2"' and exit 0; exit 1;'
}

# Uses perl to match \<string\> against \<regexp\>
# and prints each capture on a separate line.\
# If \<regexp\> contains more than one capture group,
# you must specify the \<line format\> which is an
# arbitrary Perl string containing your desired backrefs.\
# By default, merely $1 will be printed.
function perlGetCaptures # Args: <string> <regexp> [<line format>]
{
    local lineFmt=${3:-\$1}

    perl -e 'while (q('"$1"') =~ m#'"$2"'#g) { print(qq('"$lineFmt"') . "\n"); }'
}

Some tests using the latest version:

Optional arg type of -g specified as integer, no int passed but followed by a non-option string arg.

$ . ./getopts-plus.sh
$ while getopts-plus 'b:c::de::f::g:::ia' opt -ab 99 -c 11 -def 55 -g "hello you"; do e opt OPTARG; echo; printf "%.0s-" $(seq 1 25); echo -e "\n"; done

opt == 'a'

OPTARG == ''

-------------------------

opt == 'b'

OPTARG == '99'

-------------------------

opt == 'c'

OPTARG == '11'

-------------------------

opt == 'd'

OPTARG == ''

-------------------------

opt == 'e'

OPTARG == ''

-------------------------

opt == 'f'

OPTARG == '55'

-------------------------

opt == 'g'

OPTARG == '' <-- Empty because "hello you" is not an int

Like above, but with int arg.

$ OPTIND=1
$ while getopts-plus 'b:c::de::f::g:::ia' opt -ab 99 -c 11 -def 55 -g 7 "hello you"; do e opt OPTARG; echo; printf "%.0s-" $(seq 1 25); echo -e "\n"; done

opt == 'a'

OPTARG == ''

-------------------------

opt == 'b'

OPTARG == '99'

-------------------------

opt == 'c'

OPTARG == '11'

-------------------------

opt == 'd'

OPTARG == ''

-------------------------

opt == 'e'

OPTARG == ''

-------------------------

opt == 'f'

OPTARG == '55'

-------------------------

opt == 'g'

OPTARG == '7' <-- The passed int

Added optional option -h with regexp /^(a|b|ab|ba)$/, no arg passed.

$ OPTIND=1
$ while getopts-plus 'b:c::de::f::g:::ih::/^(a|b|ab|ba)$/a' opt -ab 99 -c 11 -def 55 -gh "hello you"; do e opt OPTARG; echo; printf "%.0s-" $(seq 1 25); echo -e "\n"; done

opt == 'a'

OPTARG == ''

-------------------------

opt == 'b'

OPTARG == '99'

-------------------------

opt == 'c'

OPTARG == '11'

-------------------------

opt == 'd'

OPTARG == ''

-------------------------

opt == 'e'

OPTARG == ''

-------------------------

opt == 'f'

OPTARG == '55'

-------------------------

opt == 'g'

OPTARG == ''

-------------------------

opt == 'h'

OPTARG == '' <-- Empty because "hello you" does not match the regexp

Like above, but with an arg matching the regexp.

$ OPTIND=1
$ while getopts-plus 'b:c::de::f::g:::ih::/^(a|b|ab|ba)$/a' opt -ab 99 -c 11 -def 55 -gh ab "hello you"; do e opt OPTARG; echo; printf "%.0s-" $(seq 1 25); echo -e "\n"; done

opt == 'a'

OPTARG == ''

-------------------------

opt == 'b'

OPTARG == '99'

-------------------------

opt == 'c'

OPTARG == '11'

-------------------------

opt == 'd'

OPTARG == ''

-------------------------

opt == 'e'

OPTARG == ''

-------------------------

opt == 'f'

OPTARG == '55'

-------------------------

opt == 'g'

OPTARG == ''

-------------------------

opt == 'h'

OPTARG == 'ab' <-- The arg that matches the regexp

Added another regexp-typed optional option -i with regexp /^\w+$/ (using the Perl token \w which means alphanumeric or underscore), no arg passed.

$ OPTIND=1
$ while getopts-plus 'b:c::de::f::g:::ih::/^(a|b|ab|ba)$/ai::/^\w+$/' opt -ab 99 -c 11 -def 55 -gh ab -i "hello you"; do e opt OPTARG; echo; printf "%.0s-" $(seq 1 25); echo -e "\n"; done
[23:10:49]

opt == 'a'

OPTARG == ''

-------------------------

opt == 'b'

OPTARG == '99'

-------------------------

opt == 'c'

OPTARG == '11'

-------------------------

opt == 'd'

OPTARG == ''

-------------------------

opt == 'e'

OPTARG == ''

-------------------------

opt == 'f'

OPTARG == '55'

-------------------------

opt == 'g'

OPTARG == ''

-------------------------

opt == 'h'

OPTARG == 'ab'

-------------------------

opt == 'i'

OPTARG == '' <-- Empty because "hello you" contains a space.

Like above, but with an arg matching the regexp.

$ OPTIND=1
$ while getopts-plus 'b:c::de::f::g:::ih::/^(a|b|ab|ba)$/ai::/^\w+$/' opt -ab 99 -c 11 -def 55 -gh ab -i foo_Bar_1 "hello you"; do e opt OPTARG; echo; printf "%.0s-" $(seq 1 25); echo -e "\n"; done
[23:15:23]

opt == 'a'

OPTARG == ''

-------------------------

opt == 'b'

OPTARG == '99'

-------------------------

opt == 'c'

OPTARG == '11'

-------------------------

opt == 'd'

OPTARG == ''

-------------------------

opt == 'e'

OPTARG == ''

-------------------------

opt == 'f'

OPTARG == '55'

-------------------------

opt == 'g'

OPTARG == ''

-------------------------

opt == 'h'

OPTARG == 'ab'

-------------------------

opt == 'i'

OPTARG == 'foo_Bar_1' <-- Matched because it contains only alphanumeric chars and underscores.

Try:

while getopts "hd:R:" arg; do
  case $arg in
    h)
      echo "usage" 
    ;;
    d)
      dir=$OPTARG
    ;;
    R)
      if [[ $OPTARG =~ ^[0-9]+$ ]];then
        level=$OPTARG
      elif [[ $OPTARG =~ ^-. ]];then
        level=1
        let OPTIND=$OPTIND-1
      else
        level=1
      fi          
    ;;
    \?)
      echo "WRONG" >&2
    ;;
  esac
done

I think the above code will work for your purposes while still using getopts. I've added the following three lines to your code when getopts encounters -R:

      elif [[ $OPTARG =~ ^-. ]];then
        level=1
        let OPTIND=$OPTIND-1

If -R is encountered and the first argument looks like another getopts parameter, level is set to the default value of 1, and then the $OPTIND variable is reduced by one. The next time getopts goes to grab an argument, it will grab the correct argument instead of skipping it.


Here is similar example based on the code from Jan Schampera's comment at this tutorial:

#!/bin/bash
while getopts :abc: opt; do
  case $opt in
    a)
      echo "option a"
    ;;
    b)
      echo "option b"
    ;;
    c)
      echo "option c"

      if [[ $OPTARG = -* ]]; then
        ((OPTIND--))
        continue
      fi

      echo "(c) argument $OPTARG"
    ;;
    \?)
      echo "WTF!"
      exit 1
    ;;
  esac
done

When you discover that OPTARG von -c is something beginning with a hyphen, then reset OPTIND and re-run getopts (continue the while loop). Oh, of course, this isn't perfect and needs some more robustness. It's just an example.

I just ran into this myself and felt that none of the existing solutions were really clean. After working on it a bit and trying various things, I found that leveraging getopts SILENT mode with :) ... appears to have done the trick along with keeping OPTIND in sync.


usage: test.sh [-abst] [-r [DEPTH]] filename
*NOTE: -r (recursive) with no depth given means full recursion

#!/usr/bin/env bash

depth='-d 1'

while getopts ':abr:st' opt; do
    case "${opt}" in
        a) echo a;;
        b) echo b;;
        r) if [[ "${OPTARG}" =~ ^[0-9]+$ ]]; then
               depth="-d ${OPTARG}"
           else
               depth=
               (( OPTIND-- ))
           fi
           ;;
        s) echo s;;
        t) echo t;;
        :) [[ "${OPTARG}" = 'r' ]] && depth=;;
        *) echo >&2 "Invalid option: ${opt}"; exit 1;;
    esac
done
shift $(( OPTIND - 1 ))

filename="$1"
...

I think there are two way.

First is calandoa's answer, Using OPTIND and no silent mode.

Second is Using OPTIND and silent mode.

while getopts ":Rr:" name; do
    case ${name} in
        R)
            eval nextArg=\${$OPTIND}
            # check option followed by nothing or other option.
            if [[ -z ${nextArg} || $nextArg =~ ^-.* ]]; then
                level=1
            elif [[ $nextArg =~ ^[0-9]+$ ]]; then
                level=$nextArg
                OPTIND=$((OPTIND + 1))
            else
                level=1
            fi
            ;;
        r)
            # check option followed by other option.
            if [[ $OPTARG =~ ^-.* ]]; then
                OPTIND=$((OPTIND - 1))
                level2=2
            elif [[ $OPTARG =~ ^[0-9]+$ ]]; then
                level2="$OPTARG"
            else
                level2=2
            fi
            ;;
        :)
            # check no argument
            case $OPTARG in
                r)
                    level2=2
                    ;;
            esac
    esac
done

echo "Level 1 : $level"
echo "Level 2 : $level2"

Old thread, but figured I'd share what I did anyway (which is also mostly older than this thread). I got fed up with trying to make getopt do what I wanted, and in a fit of frustration did this so that both short and long opts with optional arguments are supported. It's the long long way, somebody is sure to laugh, but it works exactly how I want it to - overly commented examples of all three cases are below:

#!/usr/bin/bash
# Begin testme.sh

shopt -s extglob;
VERSION="1.0"

function get_args(){
  while test -n "${1}" ; do
    case "${1}" in
      -a | --all)
         # dumb single argument example
         PROCESS_ALL="yes"
         shift 1
      ;;
      -b | --buildnum)
         # requires a second argument so use check_arg() below
         check_arg $1 $2
         BUILD_NUM=${2}
         shift 2
      ;;
      -c | --cache)
        # Example where argument is not required, don't use check_arg()
        if [ echo ${2} | grep -q "^-" ]; then
          # no argument given, use default cache value
          CACHEDIR=~/mycachedir
          # Note: this could have been set upon entering the script
          #       and used the negative above as well
          shift 1
        else
          cache=${2}
          shift 2
        fi
      ;;
      -h | --help)
        showhelp
        exit 0
      ;;
      -v | --version)
        echo -e "$(basename ${0}) ${VERSION}\n"
        exit 0
      ;;
      # Handle getopt style short args (reason for shopts above)
      -+([a-z,A-Z]))
        # split up the arguments and call recursively with trailing break
        arg="${1}"
        newargs=$( echo ${1} | sed 's@-@@' | \
                               sed 's/.\{1\}/& /g' | \
                               sed 's/[^ ]* */-&/g')
        newargs="${newargs} $(echo ${@} | sed "s@${arg}@@")"
        get_args ${newargs}
        break;
      ;;
      *)
        echo -e "Invalid argument ${1}!\n\n"
        showhelp
        exit 1
      ;;
    esac
  done
}

# Super lazy, but I didn't want an if/then/else for every required arg
function check_arg(){
  if [ echo "${2}" | grep -q "^-" ]; then
    echo "Error:  $1 requires a valid argument."
    exit 1
  fi
}

function showhelp(){
  echo ""
  echo "`basename ${0}` is a utility to..."
}

# Process command line arguments
get_args $@
...
# End testme.sh

I've never run into it, but I suppose there might be a case where I'd need the second argument to begin with a '-' character, in which case, I'd strip it out before calling get_args(). I have used with with fixed position arguments as well, and in that case they are at the end, but same solution. Also, I suppose a portable version could just handle the combined short args in *), but I figure if bash is too heavy a requirement, the you are on your own.

Related