4

I'm writing a simple bash script and I would like it to accept parameters from the command line in any order.

I've browsed around the web and wrote a simple function with a case statement in a while loop. Right now, the 'any order' part works - but it only picks up the first parameter I set. I'm certainly doing something wrong but scripting is quite new to me and I hadn't been able to figure it out - your help would be greatly appreciated. The flags part of the script is as follows:

#Parameters - source,destination,credentials,bandwidth,timeout,port,help
flags () {
        while test $# -gt 0; do
                case "$1" in
                        -s|--source)
                                shift
                                if test $# -gt 0; then
                                        export SOURCE=$1
                                else
                                        echo "No source directory specified!"
                                        exit 1
                                fi
                                ;;
                        -d|--destination)
                                shift
                                if test $# -gt 0; then
                                        export DESTINATION=$1
                                fi
                                ;;
                        -c|--credentials)
                                shift
                                if test $# -gt 0; then
                                        export CREDENTIALS=$1
                                fi
                                ;;
                        -b|--bandwidth)
                                shift
                                if test $# -gt 0; then
                                        export BANDWIDTH=$1
                                fi
                                ;;
                        -t|--timeout)
                                shift
                                if test $# -gt 0; then
                                        export TIMEOUT=$1
                                fi
                                ;;
                        -p|--port)
                                shift
                                if test $# -gt 0; then
                                        export PORT=$1
                                fi
                                ;;
                        -h|--help)
                                shift
                                if test $# -gt 0; then
                                        echo "Help goes here"
                                fi
                                ;;
                        -l|--compression-level)
                                shift
                                if test $# -gt 0; then
                                        export COMPRESS_LEVEL=$1
                                fi
                                ;;
                        *)
                                break
                                ;;
                esac
        done
}
flags "$@"
echo "source is $SOURCE, destination is $DESTINATION, credentials are $CREDENTIALS, bandwidth is $BANDWIDTH, timeout is $TIMEOUT, port is $PORT"

Ideally, some of those parameters would be mandatory, and others optional - but that's not a must.

How can I fix this script to accept any of those parameters (both long and short forms, ideally) in any order?

Jonathan Leffler
  • 730,956
  • 141
  • 904
  • 1,278
dkd6
  • 65
  • 1
  • 6
  • After you consume the argument (for example for credentials), you need another shift. You should be consistent in your error reporting for non-existent arguments. If you get `-h` or `--help`, you should simply print the help and exit; you should not test for more arguments. If help is requested, you give it and do nothing else. You should also echo errors to standard error: `echo "message" >&2`. Your messages should be prefixed with the script/program name: `arg0=$(basename "$0" .sh)` and `echo "$arg0: message" >&2` etc. – Jonathan Leffler Oct 08 '20 at 07:01
  • 1
    Also check out the GNU [`getopt(1)`](http://man7.org/linux/man-pages/man1/getopt.1.html) command. It will probably make life easier for you; it handles long options. In most (all?) shells, the built-in [`getopts`](http://pubs.opengroup.org/onlinepubs/9699919799/utilities/getopts.html) commands do not, especially if they're POSIX compliant. – Jonathan Leffler Oct 08 '20 at 07:08
  • Thanks! the extra shift solved it. I hadn't done any work on the rest as this is really early stages - all great tips and I'll keep them in mind. Thank you! I'll check out getopt as well. – dkd6 Oct 08 '20 at 07:09
  • https://www.owsiak.org/argument-parsing-in-bash/ – Oo.oO Oct 08 '20 at 07:41
  • [BashFAQ #35](https://mywiki.wooledge.org/BashFAQ/035) – Charles Duffy Oct 08 '20 at 17:02
  • BTW, a big part of the decision to close this as a duplicate is because it's asking the same broad, big-picture "how do I do X?" question that the other instance did, instead of being narrowly scoped to ask a "why does this specific code not work?" question with a truly minimal [mre] (there's no reason to pass in 8 different arguments if you can demonstrate the problem with only one) narrowly focused on the specific problem encountered. – Charles Duffy Oct 08 '20 at 17:06
  • @CharlesDuffy: I've added a [comment](https://stackoverflow.com/questions/192249/how-do-i-parse-command-line-arguments-in-bash#comment113672714_192249) to the duplicate linking back to this question. – Jonathan Leffler Oct 09 '20 at 15:23

1 Answers1

4

As noted in the comments, after you consume the argument (for example for credentials), you need another shift. You should be consistent in your error reporting for non-existent arguments. If you get -h or --help, you should simply print the help and exit; you should not test for more arguments. If help is requested, you give it and do nothing else. You should also echo errors to standard error: echo "message" >&2. Your messages should be prefixed with the script/program name: arg0=$(basename "$0" .sh) and echo "$arg0: message" >&2 etc.

Putting the changes together, you might come up with a script like this:

#!/bin/sh

arg0=$(basename "$0" .sh)
blnk=$(echo "$arg0" | sed 's/./ /g')

usage_info()
{
    echo "Usage: $arg0 [{-s|--source} source] [{-d|--destination} destination] \\"
    echo "       $blnk [{-c|--credentials} credentials] [{-b|--bandwidth} bandwidth] \\"
    echo "       $blnk [{-t|--timeout} timeout] [{-p|--port} port] \\"
    echo "       $blnk [-h|--help] [{-l|--compression-level} level]"
}

usage()
{
    exec 1>2   # Send standard output to standard error
    usage_info
    exit 1
}

error()
{
    echo "$arg0: $*" >&2
    exit 1
}

help()
{
    usage_info
    echo
    echo "  {-s|--source} source            -- Set source directory (default: .)"
    echo "  {-d|--destination} destination  -- Set destination"
    echo "  {-c|--credentials} credentials  -- Set credentials"
    echo "  {-b|--bandwidth} bandwidth      -- Set maximum bandwidth"
    echo "  {-t|--timeout} timeout          -- Set timeout (default: 60s)"
    echo "  {-p|--port} port                -- Set port number (default: 1234)"
    echo "  {-l|--compression-level} level  -- Set compression level (default: 1)"
    echo "  {-h|--help}                     -- Print this help message and exit"
#   echo "  {-V|--version}                  -- Print version information and exit"
    exit 0
}

flags()
{
    while test $# -gt 0
    do
        case "$1" in
        (-s|--source)
            shift
            [ $# = 0 ] && error "No source directory specified"
            export SOURCE="$1"
            shift;;
        (-d|--destination)
            shift
            [ $# = 0 ] && error "No destination specified"
            export DESTINATION="$1"
            shift;;
        (-c|--credentials)
            shift
            [ $# = 0 ] && error "No credentials specified"
            export CREDENTIALS="$1"
            shift;;
        (-b|--bandwidth)
            shift
            [ $# = 0 ] && error "No bandwidth specified"
            export BANDWIDTH="$1"
            shift;;
        (-t|--timeout)
            shift
            [ $# = 0 ] && error "No timeout specified"
            export TIMEOUT="$1"
            shift;;
        (-p|--port)
            shift
            [ $# = 0 ] && error "No port specified"
            export PORT="$1"
            shift;;
        (-l|--compression-level)
            shift
            [ $# = 0 ] && error "No compression level specified"
            export COMPRESS_LEVEL="$1"
            shift;;
        (-h|--help)
            help;;
#       (-V|--version)
#           version_info;;
        (*) usage;;
        esac
    done
}

flags "$@"

echo "source is $SOURCE"
echo "destination is $DESTINATION"
echo "credentials are $CREDENTIALS"
echo "bandwidth is $BANDWIDTH"
echo "timeout is $TIMEOUT"
echo "port is $PORT"

Sample run (script name: flags53.sh):

$ sh flags53.sh -c XYZ -d PQR -s 123 -l 4 -t 99 -b 12 -p 56789
source is 123
destination is PQR
credentials are XYZ
bandwidth is 12
timeout is 99
port is 56789
$ sh flags53.sh -c XYZ --destination PQR -s 123 -l 4 --timeout 99 -b 12 --port 56789
source is 123
destination is PQR
credentials are XYZ
bandwidth is 12
timeout is 99
port is 56789
$ sh flags53.sh -c XYZ -h
Usage: flags53 [{-s|--source} source] [{-d|--destination} destination] \
               [{-c|--credentials} credentials] [{-b|--bandwidth} bandwidth] \
               [{-t|--timeout} timeout] [{-p|--port} port] \
               [-h|--help] [{-l|--compression-level} level]

  {-s|--source} source            -- Set source directory (default: .)
  {-d|--destination} destination  -- Set destination
  {-c|--credentials} credentials  -- Set credentials
  {-b|--bandwidth} bandwidth      -- Set maximum bandwidth
  {-t|--timeout} timeout          -- Set timeout (default: 60s)
  {-p|--port} port                -- Set port number (default: 1234)
  {-l|--compression-level} level  -- Set compression level (default: 1)
  {-h|--help}                     -- Print this help message and exit
$

Note that requested help can go to standard output instead of standard error, though sending the help to standard error would not be an egregious crime. The help gets the usage message and extra information about the meaning of each option. Noting defaults (and setting them) is a good idea too. It may not be necessary to export the settings — you could simply set the variables without an explicit export. You should really set the variables to their defaults before calling the flags function, or at the start of the flags function. This avoids accidentally inheriting exported values (environment variables). Unless, of course, you want to accept environment variables, but then your names should probably be given a systematic prefix appropriate for the script name. Most programs should have a --version or -V option (use -v for 'verbose', not for version). If the command does not accept any non-option (file name) arguments, add a check after the parsing loop and complain about unwanted arguments. If the command must have at least one non-option argument, check that instead. Do not report an error on receiving -- as an argument; terminate the checking loop and treat any remaining arguments as non-option arguments.

One residual problem — the shifts in the function affect the function's argument list, not the global "$@". You'd have to work out how to deal with that from this skeleton. I think I'd probably create an analogue to $OPTIND that reports how many arguments to shift to get to the non-option arguments. The code in the flags function should keep track of how many arguments it shifts.

That leads to the revised code:

#!/bin/sh

arg0=$(basename "$0" .sh)
blnk=$(echo "$arg0" | sed 's/./ /g')

usage_info()
{
    echo "Usage: $arg0 [{-s|--source} source] [{-d|--destination} destination] \\"
    echo "       $blnk [{-c|--credentials} credentials] [{-b|--bandwidth} bandwidth] \\"
    echo "       $blnk [{-t|--timeout} timeout] [{-p|--port} port] \\"
    echo "       $blnk [-h|--help] [{-l|--compression-level} level]"
}

usage()
{
    exec 1>2   # Send standard output to standard error
    usage_info
    exit 1
}

error()
{
    echo "$arg0: $*" >&2
    exit 1
}

help()
{
    usage_info
    echo
    echo "  {-s|--source} source            -- Set source directory (default: .)"
    echo "  {-d|--destination} destination  -- Set destination"
    echo "  {-c|--credentials} credentials  -- Set credentials"
    echo "  {-b|--bandwidth} bandwidth      -- Set maximum bandwidth"
    echo "  {-t|--timeout} timeout          -- Set timeout (default: 60s)"
    echo "  {-p|--port} port                -- Set port number (default: 1234)"
    echo "  {-l|--compression-level} level  -- Set compression level (default: 1)"
    echo "  {-h|--help}                     -- Print this help message and exit"
#   echo "  {-V|--version}                  -- Print version information and exit"
    exit 0
}

flags()
{
    OPTCOUNT=0
    while test $# -gt 0
    do
        case "$1" in
        (-s|--source)
            shift
            [ $# = 0 ] && error "No source directory specified"
            export SOURCE="$1"
            shift
            OPTCOUNT=$(($OPTCOUNT + 2));;
        (-d|--destination)
            shift
            [ $# = 0 ] && error "No destination specified"
            export DESTINATION=$1
            shift
            OPTCOUNT=$(($OPTCOUNT + 2));;
        (-c|--credentials)
            shift
            [ $# = 0 ] && error "No credentials specified"
            export CREDENTIALS=$1
            shift
            OPTCOUNT=$(($OPTCOUNT + 2));;
        (-b|--bandwidth)
            shift
            [ $# = 0 ] && error "No bandwidth specified"
            export BANDWIDTH=$1
            shift
            OPTCOUNT=$(($OPTCOUNT + 2));;
        (-t|--timeout)
            shift
            [ $# = 0 ] && error "No timeout specified"
            export TIMEOUT="$1"
            shift
            OPTCOUNT=$(($OPTCOUNT + 2));;
        (-p|--port)
            shift
            [ $# = 0 ] && error "No port specified"
            export PORT=$1
            shift
            OPTCOUNT=$(($OPTCOUNT + 2));;
        (-l|--compression-level)
            shift
            [ $# = 0 ] && error "No compression level specified"
            export COMPRESS_LEVEL="$1"
            shift
            OPTCOUNT=$(($OPTCOUNT + 2));;
        (-h|--help)
            help;;
#       (-V|--version)
#           version_info;;
        (--)
            shift
            OPTCOUNT=$(($OPTCOUNT + 1))
            break;;
        (*) usage;;
        esac
    done
    echo "DEBUG-1: [$*]" >&2
    echo "OPTCOUNT=$OPTCOUNT" >&2
}

flags "$@"
echo "DEBUG-2: [$*]" >&2
echo "OPTCOUNT=$OPTCOUNT" >&2
shift $OPTCOUNT
echo "DEBUG-3: [$*]" >&2


echo "source is $SOURCE"
echo "destination is $DESTINATION"
echo "credentials are $CREDENTIALS"
echo "bandwidth is $BANDWIDTH"
echo "timeout is $TIMEOUT"
echo "port is $PORT"

There are other ways of writing the arithmetic if you wish to experiment. Don't use expr though.

Jonathan Leffler
  • 730,956
  • 141
  • 904
  • 1,278
  • One residual problem — the shifts in the function affect the function's argument list, not the global `"$@"`. You'd have to work out how to deal with that from this skeleton. I think I'd probably create an analogue to $OPTIND that reports how many arguments to shift to get to the non-option arguments. The code in the `flags` function should keep track of how many arguments it shifts. – Jonathan Leffler Oct 08 '20 at 14:44
  • 1
    A lot of work went into this answer -- maybe migrate it to the canonical instance of the question at https://stackoverflow.com/questions/192249/how-do-i-parse-command-line-arguments-in-bash if it contains content that isn't already present there? – Charles Duffy Oct 08 '20 at 17:04
  • Thanks, @CharlesDuffy. Almost all of what I've done is more or less in the top answers over there. I think that my error reporting and related code is better (more complete) than those, but there are 44 answers and some with around a thousand up-votes. I won't bother moving this. The OP's code was actually a pretty good basis, so it didn't take long to hack it into shape — it's something I've done many times over the years. – Jonathan Leffler Oct 08 '20 at 17:28
  • Hi, thank you so much for your detailed answer! There's a lot in there I didn't have in mind and will now take into consideration. I clearly have much to aspire to :) – dkd6 Oct 09 '20 at 11:04