It is possible to combine positional arguments (in the way we've been using them before this chapter) with options and option arguments. There are some things to consider in this scenario:
- By default, Bash recognizes flags such as -f as positional parameters
- Just as there is an order to flags and flag arguments, there is an order for flags and positional parameters
When dealing with a mix of getopts and positional arguments, the flags and flag options should always be provided before the positional arguments! This is because we want to parse and handle all flags and flag arguments before we get to the positional parameters. This is a fairly typical scenario for both scripts and command-line tools, but it is still something we have to consider.
All of the preceding points are best illustrated with an example, as always. We're going to create a simple script that serves as a wrapper for common file operations. With this script, file-tool.sh, we will be able to do the following things:
- List a file (default behavior)
- Delete a file (with the -d option)
- Empty a file (with the -e option)
- Rename a file (with the -m option, which includes another filename)
- Call the help function (with -h)
Take a look at the script:
reader@ubuntu:~/scripts/chapter_15$ vim file-tool.sh
reader@ubuntu:~/scripts/chapter_15$ cat file-tool.sh
#!/bin/bash
#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-12-14
# Description: A tool which allows us to manipulate files.
# Usage: ./file-tool.sh [flags] <file-name>
#####################################
print_help() {
echo "Usage: $0 [flags] <file-name>"
echo "Flags:"
echo "No flags for file listing."
echo "-d to delete the file."
echo "-e to empty the file."
echo "-m <new-file-name> to rename the file."
echo "-h for help."
}
command="ls -l" # Default command, can be overridden.
optstring=":dem:h" # The m option contains an option argument.
while getopts ${optstring} options; do
case ${options} in
d)
command="rm -f";;
e)
command="cp /dev/null";;
m)
new_filename=${OPTARG}; command="mv";;
h)
print_help; exit 0;;
:)
echo "-${OPTARG} requires an argument."; exit 1;;
?)
echo "Invalid option: -${OPTARG}." exit 1;;
esac
done
# Remove the parsed flags from the arguments array with shift.
shift $(( ${OPTIND} - 1 )) # -1 so the file-name is not shifted away.
filename=$1
# Make sure the user supplied a writable file to manipulate.
if [[ $# -ne 1 || ! -w ${filename} ]]; then
echo "Supply a writable file to manipulate! Exiting script."
exit 1
fi
# Everything should be fine, execute the operation.
if [[ -n ${new_filename} ]]; then # Only set for -m.
${command} ${filename} $(dirname ${filename})/${new_filename}
else # Everything besides -m.
${command} ${filename}
fi
That's a big one, isn't it? We've shortened it just a little bit by compacting multiple lines into single lines (within the case statement), but it's still not a short script. While it might seem intimidating at first, we're sure that with your exposure up until now, and the comments in the script, this should be understandable to you. If it is not fully understandable just yet, don't worry—we're going to explain all the new and interesting lines now.
We're skipping the header, the print_help() function, and the default command of ls -l. The first interesting bit will be the optstring, which now contains options with and without option arguments:
optstring=":dem:h" # The m option contains an option argument.
When we get to the m) block, we save the option argument in the new_filename variable for later use.
When we're done with the case statement for getopts, we run into a command that we've briefly seen before: shift. This command allows us to move our positional arguments around: if we do shift 2, the argument $4 becomes $2, the argument $3 becomes $1, and the old $1 and $2 are removed.
When dealing with a positional parameter behind flags, all flags and flag arguments are seen as positional arguments as well. In this case, if we call the script as file-tool.sh -m newfile /tmp/oldfile, the following will be interpreted by Bash:
- $1: Interpreted as -m
- $2: Interpreted as a new file
- $3: Interpreted as /tmp/oldfile
Fortunately, getopts saves the options (and option arguments) it has processed in a variable: $OPTIND (from options index). To be even more precise, after it has parsed an option, it sets $OPTIND to the next possible option or option argument: it starts at 1 and ends when it finds the first non-option argument passed to the script.
In our example, once getopts reaches our positional parameter of /tmp/oldfile, the $OPTIND variable will be 3. Since we just need to shift everything before that point away, we subtract 1 from the $OPTIND, as follows:
shift $(( ${OPTIND} - 1 )) # -1 so the file-name is not shifted away.
Remember, $(( ... )) is shorthand for arithmetic; the resulting number is used in the shift command. The rest of the script is pretty straightforward: we'll do some checks to ensure we only have one positional parameter left (the filename of the file that we want to manipulate), and whether we have write permissions on that file.
Next, depending on which operations we have selected, we'll either do a complex one for the mv, or a simple one for all the others. For the rename command, we'll use a bit of command substitution to determine the directory name of the original filename, which we will then reuse in the rename.
If we did our tests like we should, the script should be fully functional with all the requirements we set out. We encourage you to give it a try.
Even better, see if you can come up with a situation that we have not thought of that breaks the script's functionality. If you do find something (spoiler alert: we know of a few shortcomings!), try to fix them yourself.