When working with Bash scripts, it is often useful to make them more flexible by using parameters. In a previous blog post, I demonstrated how to add named parameters to a single script. If you want to apply this feature to multiple scripts, it makes sense to move the parser to a separate library and source it where needed. Let's see how.
Initial setup
To get started, create ./lib/arguments.sh and test.sh. Only test.sh needs to be executable:
mkdir -p ./lib
touch ./lib/arguments.sh ./test.sh
chmod +x ./test.shNext, open test.sh in your preferred text editor and set it up as follows:
#!/usr/bin/env bash
set -euo pipefail
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)
source "$script_dir/lib/arguments.sh"arguments.sh is sourced into the current shell. It does not need a shebang or executable permissions, and the calling script remains responsible for configuring shell options.
Named arguments function
First, add the library helpers to ./lib/arguments.sh. They validate names against the caller's whitelist, check optional functions, and report errors consistently:
function_exists() {
declare -F "$1" >/dev/null
}
die() {
printf "Script failed: %s\n" "$1" >&2
exit 1
}
is_allowed_argument() {
local candidate=$1
local allowed
for allowed in "${allowed_arguments[@]}"; do
if [[ $candidate == "$allowed" ]]; then
declare -p -- "$candidate" &>/dev/null
return
fi
done
return 1
}Next, add parse_input_args. It accepts only variables that the caller has both declared and listed in allowed_arguments. Because the parser runs inside a function, it uses printf -v to update the existing caller variable:
parse_input_args() {
while [ $# -gt 0 ]; do
if [[ $1 == "--help" || $1 == "-h" ]]; then
function_exists usage || die "Function usage is not defined"
usage
exit 0
elif [[ $1 == "--version" || $1 == "-v" ]]; then
function_exists version || die "Function version is not defined"
version
exit 0
elif [[ $1 == "--"* ]]; then
if ! is_allowed_argument "${1#--}"; then
die "Unknown parameter: $1"
fi
if (( $# < 2 )); then
die "Missing value for $1"
fi
if [[ $2 == "--"* ]]; then
die "Missing value for $1"
fi
printf -v "${1#--}" "%s" "$2"
shift 2
else
die "Unexpected positional argument: $1"
fi
done
}The caller declares regular variables with their defaults and lists the supported names in a readonly allowed_arguments array. An argument is accepted only when both conditions are met. Values cannot start with --.
Let's declare two variables in test.sh, whitelist their names, and see how it works:
declare env="" tag="nothing"
declare -ar allowed_arguments=(env tag)
parse_input_args "$@"
printf "Env: %s\n" "$env"
printf "Tag: %s\n" "$tag"Now we can test the defaults and override them with named arguments:
$ ./test.sh
Env:
Tag: nothing
$ ./test.sh --env dev --tag hello
Env: dev
Tag: hello
$ ./test.sh --help
Script failed: Function usage is not definedThe last command fails deliberately: supporting --help requires the calling script to provide a usage function. We'll add one in the elaborate example.
Required parameter validation
Some parameters may need to be non-empty. Add the following helper to ./lib/arguments.sh:
ensure_required_input_arg() {
local name=$1
local value=$2
if [[ -z $value ]]; then
function_exists usage && usage
die "Missing parameter $name"
fi
}To make both arguments required, initialize them as empty strings and validate them before using their values:
declare env="" tag=""
declare -ar allowed_arguments=(env tag)
parse_input_args "$@"
ensure_required_input_arg "--env" "$env"
ensure_required_input_arg "--tag" "$tag"
printf "Env: %s\n" "$env"
printf "Tag: %s\n" "$tag"Missing options and missing values now fail at the appropriate stage:
$ ./test.sh --env dev
Script failed: Missing parameter --tag
$ ./test.sh --env dev --tag
Script failed: Missing value for --tag
$ ./test.sh --env dev --tag hello
Env: dev
Tag: helloYou can also add a dedicated usage function that explains which input the script requires.
Final arguments.sh file
Our complete ./lib/arguments.sh file now looks like this:
function_exists() {
declare -F "$1" >/dev/null
}
die() {
printf "Script failed: %s\n" "$1" >&2
exit 1
}
is_allowed_argument() {
local candidate=$1
local allowed
for allowed in "${allowed_arguments[@]}"; do
if [[ $candidate == "$allowed" ]]; then
declare -p -- "$candidate" &>/dev/null
return
fi
done
return 1
}
parse_input_args() {
while [ $# -gt 0 ]; do
if [[ $1 == "--help" || $1 == "-h" ]]; then
function_exists usage || die "Function usage is not defined"
usage
exit 0
elif [[ $1 == "--version" || $1 == "-v" ]]; then
function_exists version || die "Function version is not defined"
version
exit 0
elif [[ $1 == "--"* ]]; then
if ! is_allowed_argument "${1#--}"; then
die "Unknown parameter: $1"
fi
if (( $# < 2 )); then
die "Missing value for $1"
fi
if [[ $2 == "--"* ]]; then
die "Missing value for $1"
fi
printf -v "${1#--}" "%s" "$2"
shift 2
else
die "Unexpected positional argument: $1"
fi
done
}
ensure_required_input_arg() {
local name=$1
local value=$2
if [[ -z $value ]]; then
function_exists usage && usage
die "Missing parameter $name"
fi
}A more elaborate example
Now that the library is in place, let's create a larger script with usage and version functions and two required parameters:
#!/usr/bin/env bash
set -euo pipefail
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)
source "$script_dir/lib/arguments.sh"
declare repo="" branch=""
declare -ar allowed_arguments=(repo branch)
program_version="0.0.1"
usage() {
echo ""
echo "Performs a shallow clone of a GitHub repository with a GitHub app."
echo ""
echo "usage: $0 --repo string --branch string [--help] [--version]"
echo ""
echo " --repo string name of the repository"
echo " (example: KeesCBakker/keestalkstech-code-gallery)"
echo " --branch string branch to clone"
echo " (example: main)"
echo " --version show the version of the script"
echo " --help show this help"
echo ""
}
version() {
echo "$program_version"
}
parse_input_args "$@"
ensure_required_input_arg "--repo" "$repo"
ensure_required_input_arg "--branch" "$branch"
printf "Repository: %s\n" "$repo"
printf "Branch: %s\n" "$branch"That's it! The caller controls defaults with regular variables and explicitly whitelists which names the library may update.
Changelog
- Restricted parsing to declared variables in an explicit caller whitelist, added missing-value validation, and clarified sourced-library behavior.
- Added a better example and support for
--version,-v, and-h. - Initial article.