Search code examples
bashgetopts

bash - getopts only parses the first argument if operands are required


Once a bash program is executed while processing options in getops, the loop exits.

As a short example, I have the following bash script:

#!/usr/bin/env bash

while getopts ":a:l:" opt; do
  case ${opt} in
    a)
      ls -a $2
      ;;
    l)
      ls -l $2
      ;;
    \?)
      echo "Invalid option: -$OPTARG" >&2
      exit 1
      ;;
    :)
      echo "Option -$OPTARG requires an argument" >&2
      exit 1
      ;;
  esac
done

echo -e "\nTerminated"

If the script is called test.sh, when I execute the script with this command, I get the following output, where only the -a flag is processed, and -l is ignored:

$ ./test.sh -al .
.  ..  file1.txt  file2.txt  test.sh

Terminated

However, if I remove the colons after each argument, indicating that operands are not required for each argument, then the script does as intended. If the while loop is changed to:

while getopts ":al" opt; do

Then, running my script gives the following output (with both -a and -l processed):

$ ./test.sh -al .
.  ..  file1.txt  file2.txt  test.sh
total 161
-rwxrwxrwx 1 root root   0 Nov 24 22:31 file1.txt
-rwxrwxrwx 1 root root   0 Nov 24 22:32 file2.txt
-rwxrwxrwx 1 root root 318 Nov 24 22:36 test.sh

Terminated

Additionally, adding something like OPTIND=1 to the end of my loop only causes an infinite loop of the script executing the first argument.

How can I get getopts to parse multiple arguments with option arguments (: after each argument)?


Solution

  • Speaking about short options only, there is no need for a space between an option and its argument, so -o something equals to -osomething. Although it's very common to separate them, there are some exceptions like: cut -d: -f1.

    Just like @AlexP said, if you use while getopts ":a:l:" opt, then options -a and -l are expected to have an argument. When you pass -al to your script and you make the option -a to require an argument, getopts looks for it and basically sees this: -a l which is why it ignores the -l option, because -a "ate it".

    Your code is a bit messy and as @cdarke suggested, it doesn't use the means provided by getopts, such as $OPTARG. You might want to check this getopts tutorial.

    If I understand correctly, your main goal is to check that a file/folder has been passed to the script for ls. You will achieve this not by making the options require an argument, but by checking whether there is a file/folder after all the options. You can do that using this:

    #!/usr/bin/env bash
    
    while getopts ":al" opt; do
      case ${opt} in
        a) a=1 ;;
        l) l=1 ;;
        \?) echo "Invalid option: -$OPTARG" >&2; exit 1 ;;
        :) echo "Option -$OPTARG requires an argument" >&2; exit 1 ;;
      esac
    done
    
    shift $(( OPTIND - 1 ));
    
    [[ "$#" == 0 ]] && { echo "No input" >&2; exit 2; }
    
    input=("$@")
    
    [[ "$a" == 1 ]] && ls -a "${input[@]}"
    [[ "$l" == 1 ]] && ls -l "${input[@]}"
    
    echo Done
    

    This solution saves your choices triggered by options to variables (you can use an array instead) and later on decide based on those variables. Saving to variables/array gives you more flexibility as you can use them anywhere within the script.

    After all the options are processed, shift $(( OPTIND - 1 )); discards all options and associated arguments and leaves only arguments that do not belong to any options = your files/folders. If there aren't any files/folders, you detect that with [[ "$#" == 0 ]] and exit. If there are, you save them to an array input=("$@") and use this array later when deciding upon your variables:

    [[ "$a" == 1 ]] && ls -a "${input[@]}"
    [[ "$l" == 1 ]] && ls -l "${input[@]}"
    

    Also, unlike ls -a $2, using an array ls -a "${input[@]}" gives you the possibility to pass more than just one file/folder: ./test.sh -la . "$HOME".