17

After several days of research, I still can't figure out the best method for parsing cmdline args in a .sh script. According to my references the getopts cmd is the way to go since it "extracts and checks switches without disturbing the positional parameter variables.Unexpected switches, or switches that are missing arguments, are recognized and reportedas errors."

Positional params(Ex. 2 - $@, $#, etc) apparently don't work well when spaces are involved but can recognize regular and long parameters(-p and --longparam). I noticed that both methods fail when passing parameters with nested quotes ("this is an Ex. of ""quotes""."). Which one of these three code samples best illustrates the way to deal with cmdline args? The getopt function is not recommended by gurus, so I'm trying to avoid it!

Example 1:

#!/bin/bash
for i in "$@"
do
case $i in
    -p=*|--prefix=*)
    PREFIX=`echo $i | sed 's/[-a-zA-Z0-9]*=//'`

    ;;
    -s=*|--searchpath=*)
    SEARCHPATH=`echo $i | sed 's/[-a-zA-Z0-9]*=//'`
    ;;
    -l=*|--lib=*)
    DIR=`echo $i | sed 's/[-a-zA-Z0-9]*=//'`
    ;;
    --default)
    DEFAULT=YES
    ;;
    *)
            # unknown option
    ;;
esac
done
exit 0

Example 2:

#!/bin/bash
echo ‘number of arguments’
echo "\$#: $#"
echo ”

echo ‘using $num’
echo "\$0: $0"
if [ $# -ge 1 ];then echo "\$1: $1"; fi
if [ $# -ge 2 ];then echo "\$2: $2"; fi
if [ $# -ge 3 ];then echo "\$3: $3"; fi
if [ $# -ge 4 ];then echo "\$4: $4"; fi
if [ $# -ge 5 ];then echo "\$5: $5"; fi
echo ”

echo ‘using $@’
let i=1
for x in $@; do
echo "$i: $x"
let i=$i+1
done
echo ”

echo ‘using $*’
let i=1
for x in $*; do
echo "$i: $x"
let i=$i+1
done
echo ”

let i=1
echo ‘using shift’
while [ $# -gt 0 ]
do
echo "$i: $1"
let i=$i+1
shift
done

[/bash]

output:

bash> commandLineArguments.bash
number of arguments
$#: 0

using $num
$0: ./commandLineArguments.bash

using $@

using $*

using shift
#bash> commandLineArguments.bash  "abc def" g h i j*

Example 3:

#!/bin/bash

while getopts ":a:" opt; do
  case $opt in
    a)
      echo "-a was triggered, Parameter: $OPTARG" >&2
      ;;
    \?)
      echo "Invalid option: -$OPTARG" >&2
      exit 1
      ;;
    :)
      echo "Option -$OPTARG requires an argument." >&2
      exit 1
      ;;
  esac
done

exit 0
2
  • 2
    possible duplicate of How do I parse command line arguments in bash? Commented Nov 12, 2013 at 12:45
  • Your second example would work better if you replaced for x in $@; do with for x do (or equivalently for x in "$@"; do). This would mean that (at least for that loop) any arguments with spaces and punctuation would be processed correctly. And get rid of for x in $*; do entirely; that's almost always wrong, and should be avoided unless you're sure you're dealing with one of the rare exceptions. Commented Feb 12 at 4:30

3 Answers 3

28

I find the use of getopt to be the easiest. It provides correct handling of arguments which is tricky otherwise. For example, getopt will know how to handle arguments to a long option specified on the command line as --arg=option or --arg option.

What is useful in parsing any input passed to a shell script is the use of the "$@" variables. See the bash man page for how this differs from $@. It ensures that you can process arguments that include spaces.

Here's an example of how I might write s script to parse some simple command line arguments:

#!/bin/bash

args=$(getopt -l "searchpath:" -o "s:h" -- "$@")

eval set -- "$args"

while [ $# -ge 1 ]; do
        case "$1" in
                --)
                    # No more options left.
                    shift
                    break
                   ;;
                -s|--searchpath)
                        searchpath="$2"
                        shift
                        ;;
                -h)
                        echo "Display some help"
                        exit 0
                        ;;
        esac

        shift
done

echo "searchpath: $searchpath"
echo "remaining args: $*"

And used like this to show that spaces and quotes are preserved:

user@machine:~/bin$ ./getopt_test --searchpath "File with spaces and \"quotes\"."
searchpath: File with spaces and "quotes".
remaining args: other args

Some basic information about the use of getopt can be found here

Sign up to request clarification or add additional context in comments.

1 Comment

That's a good example of getopt Austin. This particular topic has been discussed extensively on stackoverflow. The difference is plain old getopt isn't as robust as getopts and doesn't work on older systems. Getopts can parse long switches just as long as there's a short version of the same switch, so it requires a bit of tweaking. I'll stick w/ getopts. I much rather use a built in function rather than and old exec (getopt) for professional scripts.
13

If you want to avoid using getopt you can use this nice quick approach:

  • Defining help with all options as ## comments (customise as you wish).
  • Define for each option a function with same name.
  • Copy the last five lines of this script to your script (the magic).
  • For MacOS you may want to use ggrep (GNU grep) to support -P (or adding the alias command -v ggrep > /dev/null && alias grep=ggrep somewhere before the while)

Example script: log.sh

#!/bin/sh
## $PROG 1.0 - Print logs [2017-10-01]
## Compatible with bash and dash/POSIX
## 
## Usage: $PROG [OPTION...] [COMMAND]...
## Options:
##   -i, --log-info         Set log level to info (default)
##   -q, --log-quiet        Set log level to quiet
##   -l, --log MESSAGE      Log a message
## Commands:
##   -h, --help             Displays this help and exists
##   -v, --version          Displays output version and exists
## Examples:
##   $PROG -l yep -q -l nop -i -l yes
##   $PROG --log yep --log-quiet --log nop -log-info --log yes
PROG=${0##*/}
LOG=info

log_info() {
  LOG=info
}

log_quiet() {
  LOG=quiet
}

log() {
  [ $LOG = info ] && echo "$1"; return 1 ## number of args used
}

help() {
  sed -E -e '/^##/!d' -e 's/^## ?//' -e "s/[$]PROG/$PROG/g" "$0"; exit 0
}

version() {
  help | head -1
}

[ $# = 0 ] && help
while [ $# -gt 0 ]; do
  CMD=$(grep -m 1 -Po "^## *$1, --\K[^= ]*|^##.* --\K${1#--}(?:[= ])" "$0" | sed -e "s/-/_/g")
  if [ -z "$CMD" ]; then echo "ERROR: Command '$1' not supported" >&2; exit 1; fi
  shift; $CMD "$@" || shift $? 2> /dev/null
done

Testing

Running this command:

./log.sh --log yep --log-quiet -l nop -i -l yes

Produces this output:

yep
yes

By the way: It's compatible with !

3 Comments

Really useful script, except arguments will not be produced properly when using quotes, example: ./log.sh --log "string in quotes" outputs string. you'll probably want to change this line:shift; eval "$CMD" $@ || shift $? 2> /dev/null to shift; $CMD "$@" || shift $? 2> /dev/null for proper quote handling, the change seems to function properly.
Another small cosmetic improvement to go together with proper quote handling in "$@" mentioned by @R.StackUser above: bash help() { grep "^##" "$0" | sed -e "s/^##.//" -e "s/^##$//" -e "s/\$PROG/$PROG/g"; exit ${0:-0} }
Added small improvement in the answer to use only sed for help operation taking care when the line only contains "##". Thanks for both R. StackUser and @Juarez Rudsatz for your feedback.
0

I wrote a CLI parser generator that supports bash/C/C++/js/python from a common command line argument definition file, see climeta. Allows you to change language to another language without re-doing the work. It also supports collapsed flags etc. without getopts dependency. This is an example of output:

# Usage function
usage() {
    echo "Usage: $0 [options]"
    echo "  input INPUT                : Input TOML file (required)"
    echo "  -o OUTPUT, --output OUTPUT : Output file (default cli_args)"
    echo "  -l LANG, --lang LANG       : Language for the generated code (required)"
    echo "Goes at the end"
    exit 1
}

# check if a valid argument follows
check_valid_arg() {
    case "$2" in
      -*|'') echo "Error: $1 requires a value." >&2
           usage
    esac
}

# Argument parsing function
parse_args() {
    # split --a=xx -b=yy -cde into --a xx -b yy -c -d -e
    # for more unified processing later on
    local i ch arg new_args=""
    for arg in "$@"; do
        case "$arg" in
            --*=*) # convert --aa=xx into --aa xx
                right=${arg#*=}
                left=${arg%="$right"}
                new_args="$new_args $left $right" ;;
            --*)
                new_args="$new_args $arg" ;;
            -*) # convert -abc=yy into -a -b -c yy
                i=1
                while [ "$i" -lt "${#arg}" ]; do
                    ch=$(expr "$arg" : "^.\{$i\}\(.\)")
                    case "${ch}" in
                        =) rest=$(expr "$arg" : "^..\{$i\}\(.*\)")
                           new_args="$new_args $rest"; break ;;
                        *) new_args="$new_args -${ch}" ;;
                    esac
                    i=$((i+1))
                done ;;
            *)  new_args="$new_args $arg" ;;
        esac
    done
    # shellcheck disable=SC2086
    set -- $new_args

    remaining_args=""
    local positional_idx=0
    while [ "$#" -gt 0 ]; do
        case "$1" in
            --output|-o)
                check_valid_arg "$1" "$2";
                output="$2"
                shift ;;
            --lang|-l)
                check_valid_arg "$1" "$2";
                lang="$2"
                shift ;;
            --help|-h)
                usage;;
            --) shift;
                remaining_args="$*"
                break;;
            -*) echo "Unknown option: $1" >&2; usage ;;
            *) # handle positional arguments
                if [ $positional_idx -eq 0 ]; then
                    input="$1"
                else
                    echo "Unexpected positional argument: $1" >&2
                    usage
                fi
                positional_idx=$(( positional_idx + 1 ))
        esac
        shift
    done
}

# Validate arguments
validate_args() {
    if [ -z "$input" ]; then
        echo "Error: input is required" >&2
        usage
    fi
    if [ -z "$lang" ]; then
        echo "Error: --lang is required" >&2
        usage
    fi
    if [ $(expr "|python|bash|" : ".*|$lang|") -eq 0 ]; then
        echo "Error: --lang must be one of: python, bash (got '$lang')" >&2
        usage
    fi
}

# Dump argument values for debug
dump_args() {
    echo "Parsed arguments:"
    echo "input: $input"
    echo "output: $output"
    echo "lang: $lang"
    echo "remaining_args: ${remaining_args[*]}"
}

# Main entry point, parse CLI
get_cli_args() {
    # set defaults
    output=cli_args
    parse_args "$@"
    validate_args
}

# Example of use:
get_cli_args "$@"
dump_args

1 Comment

In an ideal world, the people who down-voted this (because it was link-only) would now reverse their downvotes. But in practice that's unlikely to happen. You might be better off simply posting it as a new answer; then at least it starts with a score of 0. (Personally I think this is a great answer; the generated code is easy to read and well structured, even if they don't wind up using the generator.)

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.