Bash Script with a Library for Named Arguments

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.sh

Next, 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 defined

The 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: hello

You 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.
expand_less brightness_auto