diff --git a/README.md b/README.md index a2ae5f4..09b9ef5 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,9 @@ Requires Bash 4.2+. On macOS, use Homebrew Bash instead of the system `/bin/bash - [`lib/bash/str/lib_str.sh`](lib/bash/str/README.md) String helpers built on the stdlib for case conversion, trimming, predicates, splitting, joining, and array membership checks. +- [`lib/bash/arg/lib_arg.sh`](lib/bash/arg/README.md) + Argument parsing helpers built on the stdlib for exact flag and value + options without hidden parser globals. See [`lib/bash/README.md`](lib/bash/README.md) for the package layout. @@ -62,6 +65,7 @@ Load companion libraries with absolute imports from the same package: import "$base_bash_libs_prefix/libexec/lib/bash/file/lib_file.sh" import "$base_bash_libs_prefix/libexec/lib/bash/git/lib_git.sh" import "$base_bash_libs_prefix/libexec/lib/bash/str/lib_str.sh" +import "$base_bash_libs_prefix/libexec/lib/bash/arg/lib_arg.sh" ``` ### Source Checkout @@ -88,6 +92,7 @@ Load companion libraries with absolute imports from the same checkout: import "$base_bash_libs_dir/lib/bash/file/lib_file.sh" import "$base_bash_libs_dir/lib/bash/git/lib_git.sh" import "$base_bash_libs_dir/lib/bash/str/lib_str.sh" +import "$base_bash_libs_dir/lib/bash/arg/lib_arg.sh" ``` ### Vendored or Submodule Layout @@ -103,6 +108,7 @@ source "$base_bash_libs_dir/lib/bash/std/lib_std.sh" import "$base_bash_libs_dir/lib/bash/file/lib_file.sh" import "$base_bash_libs_dir/lib/bash/git/lib_git.sh" import "$base_bash_libs_dir/lib/bash/str/lib_str.sh" +import "$base_bash_libs_dir/lib/bash/arg/lib_arg.sh" ``` After `lib_std.sh` is sourced, `BASE_BASH_LIBS_VERSION` contains the package diff --git a/STANDARDS.md b/STANDARDS.md index 56556ed..7b9f309 100644 --- a/STANDARDS.md +++ b/STANDARDS.md @@ -15,6 +15,7 @@ physical `.sh` file at its library boundary: - `lib/bash/file/lib_file.sh` - `lib/bash/git/lib_git.sh` - `lib/bash/str/lib_str.sh` +- `lib/bash/arg/lib_arg.sh` Do not split one library into internal concern files such as separate logging, path, string, prompt, or command-runner fragments. That kind of split adds a diff --git a/lib/bash/README.md b/lib/bash/README.md index c52f14c..926c1c9 100644 --- a/lib/bash/README.md +++ b/lib/bash/README.md @@ -13,6 +13,8 @@ Reusable Bash libraries for command wrappers and other Bash tooling. File-editing helpers built on top of the stdlib. - `str/` String helpers built on top of the stdlib. +- `arg/` + Argument parsing helpers built on top of the stdlib. - `tests/` Common BATS helpers for Bash library test suites. diff --git a/lib/bash/arg/README.md b/lib/bash/arg/README.md new file mode 100644 index 0000000..f1f69d4 --- /dev/null +++ b/lib/bash/arg/README.md @@ -0,0 +1,47 @@ +# `lib_arg.sh` + +Argument and option parsing helpers for Base-style Bash scripts. + +## Dependency + +Source `lib/bash/std/lib_std.sh` before this library so validation and logging +helpers are available. + +## Public API + +- `arg_parse` + Parse exact flag and value options into caller-owned arrays. + +## Usage + +```bash +source "/absolute/path/to/lib/bash/std/lib_std.sh" +source "/absolute/path/to/lib/bash/arg/lib_arg.sh" + +declare -A options=() +declare -a positionals=() +specs=( + "verbose|flag|--verbose|-v" + "output|value|--output|-o" +) + +arg_parse options positionals specs -- "$@" || exit $? + +if [[ "${options[verbose]-}" == "1" ]]; then + set_log_level DEBUG +fi +``` + +Spec entries use `name|kind|token[|token...]`: + +- `name` is the associative-array key populated in the options result. +- `kind` is either `flag` or `value`. +- each `token` is an exact option token, such as `--verbose` or `-v`. + +The parser supports `--option value`, `--option=value`, repeated options where +the last value wins, and `--` to stop option parsing. Unknown options, +malformed specs, and missing values return status `2`. + +## Tests + +BATS coverage lives in `tests/lib_arg.bats`. diff --git a/lib/bash/arg/lib_arg.sh b/lib/bash/arg/lib_arg.sh new file mode 100644 index 0000000..f270b94 --- /dev/null +++ b/lib/bash/arg/lib_arg.sh @@ -0,0 +1,177 @@ +# shellcheck shell=bash +# +# lib_arg.sh - Bash helpers for conservative option parsing. +# + +[[ -n "${__lib_arg_sourced__:-}" ]] && return 0 +if [[ "${BASE_BASH_LIBS_STDLIB_LOADED:-}" != "1" ]]; then + printf '%s\n' "Error: lib_arg.sh requires lib_std.sh to be sourced first." >&2 + return 1 2>/dev/null || exit 1 +fi +readonly __lib_arg_sourced__=1 + +__arg_declares_array_kind__() { + local variable_name="${1-}" array_kind="${2-}" declaration + + declaration="$(declare -p "$variable_name" 2>/dev/null)" || return 1 + [[ "$declaration" == declare\ -*"$array_kind"* ]] +} + +__arg_set_assoc_value__() { + local array_name="$1" key="$2" value="$3" + + # The variable name is validated before callers reach this helper. + # shellcheck disable=SC1087 + printf -v "$array_name[$key]" '%s' "$value" +} + +__arg_parse_specs__() { + local specs_name="$1" + local __arg_token_kind_name="$2" __arg_token_name_name="$3" + local -a __arg_specs=() __arg_tokens=() + local __arg_spec __arg_remainder __arg_name __arg_kind __arg_tokens_part __arg_token + local __arg_name_re='^[A-Za-z_][A-Za-z0-9_]*$' + + eval "__arg_specs=(\"\${${specs_name}[@]}\")" + + for __arg_spec in "${__arg_specs[@]}"; do + __arg_name="${__arg_spec%%|*}" + __arg_remainder="${__arg_spec#*|}" + __arg_kind="${__arg_remainder%%|*}" + __arg_tokens_part="${__arg_remainder#*|}" + + if [[ "$__arg_spec" == "$__arg_remainder" || "$__arg_remainder" == "$__arg_tokens_part" || + -z "$__arg_name" || -z "$__arg_kind" || -z "$__arg_tokens_part" ]]; then + log_error "arg_parse: malformed option spec '$__arg_spec'." + return 2 + fi + if ! [[ "$__arg_name" =~ $__arg_name_re ]]; then + log_error "arg_parse: option spec name must be a valid Bash identifier." + return 2 + fi + if [[ "$__arg_kind" != "flag" && "$__arg_kind" != "value" ]]; then + log_error "arg_parse: option spec '$__arg_name' must use kind 'flag' or 'value'." + return 2 + fi + + IFS='|' read -r -a __arg_tokens <<<"$__arg_tokens_part" + for __arg_token in "${__arg_tokens[@]}"; do + if [[ -z "$__arg_token" || "$__arg_token" != -* ]]; then + log_error "arg_parse: option spec '$__arg_name' has an invalid option token." + return 2 + fi + __arg_set_assoc_value__ "$__arg_token_kind_name" "$__arg_token" "$__arg_kind" + __arg_set_assoc_value__ "$__arg_token_name_name" "$__arg_token" "$__arg_name" + done + done + + return 0 +} + +# +# arg_parse - Parses simple flags and value options into caller-owned variables. +# +# Spec entries use: name|kind|token[|token...] +# - name: valid Bash identifier used as the associative-array key +# - kind: "flag" or "value" +# - token: exact option token, such as --verbose or -v +# +# Usage: +# declare -A options=() +# declare -a positionals=() +# specs=("verbose|flag|--verbose|-v" "output|value|--output|-o") +# arg_parse options positionals specs -- "$@" +# +arg_parse() { + local options_name="${1-}" positionals_name="${2-}" specs_name="${3-}" + local __arg_current __arg_option_token __arg_option_value __arg_option_name __arg_option_kind + local -a __arg_positionals=() + local -A __arg_token_kind=() __arg_token_name=() + local __arg_parse_options=1 + + if (($# < 4)) || [[ "${4-}" != "--" ]]; then + log_error "arg_parse: usage: arg_parse -- [args...]" + return 2 + fi + + assert_variable_name "$options_name" "$positionals_name" "$specs_name" + + if ! __arg_declares_array_kind__ "$options_name" "A"; then + log_error "arg_parse: options variable must be an associative array declared by the caller." + return 2 + fi + if ! __arg_declares_array_kind__ "$positionals_name" "a"; then + log_error "arg_parse: positionals variable must be an indexed array declared by the caller." + return 2 + fi + if ! __arg_declares_array_kind__ "$specs_name" "a"; then + log_error "arg_parse: specs variable must be an indexed array declared by the caller." + return 2 + fi + + __arg_parse_specs__ "$specs_name" __arg_token_kind __arg_token_name || return $? + + eval "$options_name=()" + eval "$positionals_name=()" + shift 4 + + while (($# > 0)); do + __arg_current="$1" + shift + + if ((__arg_parse_options)) && [[ "$__arg_current" == "--" ]]; then + __arg_parse_options=0 + continue + fi + + if ((__arg_parse_options)) && [[ "$__arg_current" == --*=* ]]; then + __arg_option_token="${__arg_current%%=*}" + __arg_option_value="${__arg_current#*=}" + __arg_option_kind="${__arg_token_kind[$__arg_option_token]-}" + __arg_option_name="${__arg_token_name[$__arg_option_token]-}" + + if [[ -z "$__arg_option_kind" ]]; then + log_error "arg_parse: unknown option '$__arg_option_token'." + return 2 + fi + if [[ "$__arg_option_kind" != "value" ]]; then + log_error "arg_parse: option '$__arg_option_token' does not accept a value." + return 2 + fi + + __arg_set_assoc_value__ "$options_name" "$__arg_option_name" "$__arg_option_value" + continue + fi + + if ((__arg_parse_options)) && [[ "$__arg_current" == -* && "$__arg_current" != "-" ]]; then + __arg_option_token="$__arg_current" + __arg_option_kind="${__arg_token_kind[$__arg_option_token]-}" + __arg_option_name="${__arg_token_name[$__arg_option_token]-}" + + if [[ -z "$__arg_option_kind" ]]; then + log_error "arg_parse: unknown option '$__arg_option_token'." + return 2 + fi + + if [[ "$__arg_option_kind" == "flag" ]]; then + __arg_set_assoc_value__ "$options_name" "$__arg_option_name" "1" + continue + fi + + if (($# == 0)) || [[ "${1-}" == "--" ]]; then + log_error "arg_parse: option '$__arg_option_token' requires a value." + return 2 + fi + + __arg_option_value="$1" + shift + __arg_set_assoc_value__ "$options_name" "$__arg_option_name" "$__arg_option_value" + continue + fi + + __arg_positionals+=("$__arg_current") + done + + eval "$positionals_name=(\"\${__arg_positionals[@]}\")" + return 0 +} diff --git a/lib/bash/arg/tests/lib_arg.bats b/lib/bash/arg/tests/lib_arg.bats new file mode 100644 index 0000000..e3201a1 --- /dev/null +++ b/lib/bash/arg/tests/lib_arg.bats @@ -0,0 +1,124 @@ +#!/usr/bin/env bats + +load ../../tests/test_helper.sh + +setup() { + setup_test_tmpdir + source "$BASE_BASH_DIR/std/lib_std.sh" + source "$BASE_BASH_DIR/arg/lib_arg.sh" +} + +create_script() { + local script_path="$1" + cat > "$script_path" + chmod +x "$script_path" +} + +@test "lib_arg can be sourced more than once" { + source "$BASE_BASH_DIR/arg/lib_arg.sh" + + [ "$(type -t arg_parse)" = "function" ] +} + +@test "lib_arg fails clearly when sourced without stdlib" { + bats_run bash -c 'source "$1"; rc=$?; printf "source-rc=%s\n" "$rc"; exit "$rc"' bash "$BASE_BASH_DIR/arg/lib_arg.sh" + + [ "$status" -eq 1 ] + [[ "$output" == *"lib_arg.sh requires lib_std.sh to be sourced first"* ]] + [[ "$output" == *"source-rc=1"* ]] + [[ "$output" != *"command not found"* ]] +} + +@test "lib_arg requires the stdlib loaded marker" { + bats_run bash -c 'log_error() { :; }; log_debug() { :; }; source "$1"; rc=$?; printf "source-rc=%s\n" "$rc"; exit "$rc"' bash "$BASE_BASH_DIR/arg/lib_arg.sh" + + [ "$status" -eq 1 ] + [[ "$output" == *"lib_arg.sh requires lib_std.sh to be sourced first"* ]] + [[ "$output" == *"source-rc=1"* ]] +} + +@test "arg_parse stores flags values and positionals" { + local -a specs=( + "verbose|flag|--verbose|-v" + "output|value|--output|-o" + ) + local -A options=() + local -a positionals=() + + arg_parse options positionals specs -- --verbose -o "build result.txt" alpha -- beta gamma + + [ "${options[verbose]}" = "1" ] + [ "${options[output]}" = "build result.txt" ] + [ "${#positionals[@]}" -eq 3 ] + [ "${positionals[0]}" = "alpha" ] + [ "${positionals[1]}" = "beta" ] + [ "${positionals[2]}" = "gamma" ] +} + +@test "arg_parse accepts long option equals values and repeated options" { + local -a specs=( + "verbose|flag|--verbose|-v" + "output|value|--output|-o" + ) + local -A options=() + local -a positionals=() + + arg_parse options positionals specs -- --output=first.txt --output second.txt -v -v item + + [ "${options[verbose]}" = "1" ] + [ "${options[output]}" = "second.txt" ] + [ "${#positionals[@]}" -eq 1 ] + [ "${positionals[0]}" = "item" ] +} + +@test "arg_parse returns usage status for unknown options" { + local -a specs=("verbose|flag|--verbose|-v") + local -A options=() + local -a positionals=() + local parse_status=0 + + arg_parse options positionals specs -- --unknown || parse_status=$? + + [ "$parse_status" -eq 2 ] +} + +@test "arg_parse returns usage status when option values are missing" { + local -a specs=("output|value|--output|-o") + local -A options=() + local -a positionals=() + local parse_status=0 + + arg_parse options positionals specs -- --output || parse_status=$? + + [ "$parse_status" -eq 2 ] +} + +@test "arg_parse rejects invalid variable names without echoing values" { + local script="$TEST_TMPDIR/arg-invalid-vars.sh" + + create_script "$script" </dev/null