Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ jobs:
check_bash_version
' bash "$PWD/lib/bash/std/lib_std.sh"

# The exact supported lower bound runs in the bash-42-logging job. This
# The exact supported lower bound runs in the compatibility job. This
# macOS smoke exercises the known unsupported system Bash 3.2 path when
# it is available.
- name: Smoke unsupported macOS system Bash
Expand Down Expand Up @@ -93,4 +93,5 @@ jobs:
set -e
bash tests/bash-42-logging-smoke.sh 4 2 53
bash tests/bash-42-release-smoke.sh 4 2 53
bash tests/bash-option-contract.sh 4 2 53
'
42 changes: 38 additions & 4 deletions lib/bash/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,43 @@ The Base runtime shell files and Base version helpers remain in
`basefoundry/base`. This repository carries only sourceable reusable library
modules.

## Caller Runtime Contract

All public modules support Bash 4.2 or newer with every combination of caller-
selected `errexit`, `nounset`, and `pipefail`. Sourcing a module does not change
those settings, any other `set` or `shopt` option, `IFS`, `OPTIND`, the working
directory, the umask, traps, or ordinary positional arguments. The stdlib's
documented wrapper flags are the exception: its initializer removes recognized
wrapper flags and publishes the filtered positional arguments.

Public API calls preserve the same process state unless their documented
purpose is to change it. Examples of intentional mutation include PATH helpers,
`safe_cd`, caller-owned output variables, file-editing helpers, and cleanup
registrations while a hook or path remains active. Transient internal cleanup
registrations restore the caller's preexisting `EXIT` trap when the operation
finishes.

Required arity is checked before a public helper expands a required positional
parameter, so a usage error remains diagnosable with caller `nounset` enabled.
Predicates and recoverable failures intentionally return nonzero; callers using
`errexit` should invoke expected nonzero results in `if`, `while`, `&&`, or
another Bash conditional context.

The standalone `tests/bash-option-contract.sh` matrix sources every module and
exercises success, usage, predicate, and recoverable-failure paths in all eight
option combinations. CI runs that matrix on the current macOS and Ubuntu Bash
runtimes and in the digest-pinned, networkless Bash 4.2.53 compatibility image.

## Naming Contract

Public helpers that write through caller-supplied variable or array names
reserve the `__` prefix for library-internal state. Passing an output name that
begins with `__` fails before the helper changes caller state. Use a regular
Bash variable name for public output values and arrays.
Public helpers that accept caller-supplied variable or array names reserve the
`__` prefix for library-internal state. Passing a caller-owned source or result
name that begins with `__` fails before the helper changes caller state. Use a
regular Bash variable name for public input and output values and arrays.
`assert_variable_name` is the syntax-only exception: it validates whether any
identifier is legal Bash syntax, including names in the reserved namespace,
without reading or writing the named variable.

When one API accepts multiple caller-owned inputs or outputs, names that would
alias an input with an output are rejected before mutation. The module README
for that API documents the required distinct-name relationships.
10 changes: 7 additions & 3 deletions lib/bash/arg/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@ helpers are available.

- `arg_parse <options_array> <positionals_array> <specs_array> -- [args...]`
Parse exact flag, value, and repeatable options into caller-owned arrays.
Returns `0` on success and `2` for malformed specs, unknown options, or
missing values; caller-owned outputs are published only after a successful
parse.
Returns `0` on success, `1` for invalid caller-owned variable contracts, and
`2` for malformed specs, unknown options, or missing values; caller-owned
outputs are published only after a successful parse.

The options, positionals, and specs arrays must have distinct names. Every
repeatable option's output array must also be distinct from those three arrays.
Aliasing is rejected before any caller-owned output is changed.

## Usage

Expand Down
95 changes: 75 additions & 20 deletions lib/bash/arg/lib_arg.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,52 @@ __arg_set_assoc_value__() {
printf -v "$__arg_assoc_name[$__arg_assoc_key]" '%s' "$__arg_assoc_value"
}

__arg_assert_distinct_names__() {
local -a __arg_distinct_names=("$@")
local __arg_left_index __arg_right_index

for ((__arg_left_index = 0; __arg_left_index < ${#__arg_distinct_names[@]}; __arg_left_index++)); do
for ((__arg_right_index = __arg_left_index + 1;
__arg_right_index < ${#__arg_distinct_names[@]};
__arg_right_index++)); do
if [[ "${__arg_distinct_names[__arg_left_index]}" == "${__arg_distinct_names[__arg_right_index]}" ]]; then
log_error -l base_bash_libs.arg \
"arg_parse: caller-owned variables must be distinct; '${__arg_distinct_names[__arg_left_index]}' was provided more than once."
return 1
fi
done
done
return 0
}

__arg_preflight_repeatable_names__() {
(($# == 1)) || return 0
[[ "${1-}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || return 0
eval "if [[ -n \"\${${1}[@]+set}\" ]]; then set -- \"\${${1}[@]}\"; else set --; fi"

while (($#)); do
if [[ "${1#*|}" == repeatable\|* ]]; then
__std_assert_public_variable_names__ arg_parse "${1%%|*}" || return 1
fi
shift
done
return 0
}

__arg_parse_specs__() {
local __arg_specs_name="$1"
local __arg_token_kind_name="$2" __arg_token_name_name="$3"
local __arg_repeatable_names_name="${4-}"
local __arg_options_name="${5-}" __arg_positionals_name="${6-}" __arg_caller_specs_name="${7-}"
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_]*$'
local __arg_token_re='^--?[[:alnum:]_][[:alnum:]_-]*$'
local -A __arg_seen_names=() __arg_seen_tokens=()

eval "__arg_specs=(\"\${${__arg_specs_name}[@]}\")"
eval "if [[ -n \"\${${__arg_specs_name}[@]+set}\" ]]; then __arg_specs=(\"\${${__arg_specs_name}[@]}\"); fi"

for __arg_spec in "${__arg_specs[@]}"; do
for __arg_spec in "${__arg_specs[@]+"${__arg_specs[@]}"}"; do
__arg_name="${__arg_spec%%|*}"
__arg_remainder="${__arg_spec#*|}"
__arg_kind="${__arg_remainder%%|*}"
Expand Down Expand Up @@ -62,6 +95,9 @@ __arg_parse_specs__() {
log_error -l base_bash_libs.arg "arg_parse: repeatable option spec '$__arg_name' requires an output array contract."
return 2
fi
__std_assert_public_variable_names__ arg_parse "$__arg_name" || return 1
__arg_assert_distinct_names__ \
"$__arg_options_name" "$__arg_positionals_name" "$__arg_caller_specs_name" "$__arg_name" || return 1
if ! __std_declares_array_kind__ "$__arg_name" "a"; then
log_error -l base_bash_libs.arg "arg_parse: repeatable option '$__arg_name' requires a caller-declared indexed array."
return 2
Expand All @@ -76,7 +112,7 @@ __arg_parse_specs__() {
return 2
fi
IFS='|' read -r -a __arg_tokens <<<"$__arg_tokens_part"
for __arg_token in "${__arg_tokens[@]}"; do
for __arg_token in "${__arg_tokens[@]+"${__arg_tokens[@]}"}"; do
if ! [[ "$__arg_token" =~ $__arg_token_re ]] || [[ "$__arg_token" == *"="* ]]; then
log_error -l base_bash_libs.arg "arg_parse: option spec '$__arg_spec' has invalid option token '$__arg_token'."
return 2
Expand Down Expand Up @@ -113,25 +149,30 @@ __arg_parse_specs__() {
# arg_parse options positionals specs -- "$@"
#
arg_parse() {
local __arg_options_name="${1-}" __arg_positionals_name="${2-}" __arg_specs_name="${3-}"
if (($# < 4)) || [[ "${4-}" != "--" ]]; then
log_error -l base_bash_libs.arg "arg_parse: usage: arg_parse <options_assoc> <positionals_array> <specs_array> -- [args...]"
return 2
fi
__std_assert_public_variable_names__ arg_parse "${1-}" "${2-}" "${3-}" || return 1
__arg_preflight_repeatable_names__ "$3" || return 1

local __arg_options_name="$1" __arg_positionals_name="$2" __arg_specs_name="$3"
local __arg_current __arg_option_token __arg_option_value __arg_option_name __arg_option_kind
local __arg_repeatable_name __arg_repeatable_index __arg_repeatable_value
local -a __arg_positionals=() __arg_repeatable_names=() __arg_repeatable_values=()
local -a __arg_publish_values=()
local -A __arg_options=() __arg_token_kind=() __arg_token_name=()
local __arg_parse_options=1

if (($# < 4)) || [[ "${4-}" != "--" ]]; then
log_error -l base_bash_libs.arg "arg_parse: usage: arg_parse <options_assoc> <positionals_array> <specs_array> -- [args...]"
return 2
fi

assert_variable_name "$__arg_options_name" "$__arg_positionals_name" "$__arg_specs_name"
__arg_assert_distinct_names__ "$__arg_options_name" "$__arg_positionals_name" "$__arg_specs_name" || return 1
assert_associative_array "$__arg_options_name"
assert_indexed_array "$__arg_positionals_name" "$__arg_specs_name"
__std_assert_writable_output__ arg_parse "$__arg_options_name" || return 1
__std_assert_writable_output__ arg_parse "$__arg_positionals_name" || return 1

__arg_parse_specs__ "$__arg_specs_name" __arg_token_kind __arg_token_name __arg_repeatable_names || return $?
__arg_parse_specs__ "$__arg_specs_name" __arg_token_kind __arg_token_name __arg_repeatable_names \
"$__arg_options_name" "$__arg_positionals_name" "$__arg_specs_name" || return $?

shift 4

Expand Down Expand Up @@ -209,20 +250,34 @@ arg_parse() {
done

eval "$__arg_options_name=()"
for __arg_option_name in "${!__arg_options[@]}"; do
__arg_set_assoc_value__ "$__arg_options_name" "$__arg_option_name" "${__arg_options[$__arg_option_name]}"
# shellcheck disable=SC2199 # The + expansion safely detects Bash 4.2 empty arrays under nounset.
if [[ -n "${__arg_options[@]+set}" ]]; then
for __arg_option_name in "${!__arg_options[@]}"; do
__arg_set_assoc_value__ "$__arg_options_name" "$__arg_option_name" "${__arg_options[$__arg_option_name]}"
done
fi
eval "$__arg_positionals_name=()"
for __arg_current in "${__arg_positionals[@]+"${__arg_positionals[@]}"}"; do
eval "$__arg_positionals_name+=(\"\$__arg_current\")"
done
eval "$__arg_positionals_name=(\"\${__arg_positionals[@]}\")"

for __arg_repeatable_name in "${__arg_repeatable_names[@]}"; do
for __arg_repeatable_name in "${__arg_repeatable_names[@]+"${__arg_repeatable_names[@]}"}"; do
__arg_publish_values=()
for ((__arg_repeatable_index = 0; __arg_repeatable_index < ${#__arg_repeatable_values[@]}; __arg_repeatable_index += 2)); do
if [[ "${__arg_repeatable_values[__arg_repeatable_index]}" == "$__arg_repeatable_name" ]]; then
__arg_repeatable_value="${__arg_repeatable_values[__arg_repeatable_index + 1]}"
__arg_publish_values+=("$__arg_repeatable_value")
fi
# shellcheck disable=SC2199 # The + expansion safely detects Bash 4.2 empty arrays under nounset.
if [[ -n "${__arg_repeatable_values[@]+set}" ]]; then
for ((__arg_repeatable_index = 0;
__arg_repeatable_index < ${#__arg_repeatable_values[@]};
__arg_repeatable_index += 2)); do
if [[ "${__arg_repeatable_values[__arg_repeatable_index]}" == "$__arg_repeatable_name" ]]; then
__arg_repeatable_value="${__arg_repeatable_values[__arg_repeatable_index + 1]}"
__arg_publish_values+=("$__arg_repeatable_value")
fi
done
fi
eval "$__arg_repeatable_name=()"
for __arg_repeatable_value in "${__arg_publish_values[@]+"${__arg_publish_values[@]}"}"; do
eval "$__arg_repeatable_name+=(\"\$__arg_repeatable_value\")"
done
eval "$__arg_repeatable_name=(\"\${__arg_publish_values[@]}\")"
done
return 0
}
138 changes: 138 additions & 0 deletions lib/bash/arg/tests/lib_arg.bats
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,27 @@ create_script() {
[[ "$output" == *"source-rc=1"* ]]
}

@test "arg_parse returns usage without nounset aborts under every caller option combination" {
local mode

for mode in off e u p eu ep up eup; do
bats_run "$BASH" -c '
mode="$1"
case "$mode" in *e*) set -e ;; esac
case "$mode" in *u*) set -u ;; esac
case "$mode" in *p*) set -o pipefail ;; esac
source "$2"
source "$3"
arg_parse
exit $?
' bash "$mode" "$BASE_BASH_DIR/std/lib_std.sh" "$BASE_BASH_DIR/arg/lib_arg.sh"

[ "$status" -eq 2 ]
[[ "$output" == *"arg_parse: usage:"* ]]
[[ "$output" != *"unbound variable"* ]]
done
}

@test "arg_parse stores flags values and positionals" {
local -a specs=(
"verbose|flag|--verbose|-v"
Expand Down Expand Up @@ -109,6 +130,102 @@ EOF
[[ "$(cat "$stderr_file")" == *"uses the reserved '__' internal namespace"* ]]
}

@test "arg_parse rejects exact internal holder and repeatable names before locals or mutation" {
local -r __arg_options_name=actual_options
local -A actual_options=([sentinel]="keep")
local -a positionals=(old)
local -a specs=("verbose|flag|--verbose")
local -ar __arg_repeatable_name=(saved)
local -a repeatable_specs=("__arg_repeatable_name|repeatable|--include")
local stderr_file="$TEST_TMPDIR/arg-internal-holder.err"
local rc

if arg_parse __arg_options_name positionals specs -- --verbose 2>"$stderr_file"; then
rc=0
else
rc=$?
fi
[ "$rc" -eq 1 ]
[ "${actual_options[sentinel]}" = "keep" ]
[ "${positionals[0]}" = "old" ]
[[ "$(cat "$stderr_file")" == *"uses the reserved '__' internal namespace"* ]]
[[ "$(cat "$stderr_file")" != *"readonly variable"* ]]

if arg_parse actual_options positionals repeatable_specs -- --include new 2>"$stderr_file"; then
rc=0
else
rc=$?
fi
[ "$rc" -eq 1 ]
[ "${actual_options[sentinel]}" = "keep" ]
[ "${positionals[0]}" = "old" ]
[ "${__arg_repeatable_name[0]}" = "saved" ]
[[ "$(cat "$stderr_file")" == *"uses the reserved '__' internal namespace"* ]]
[[ "$(cat "$stderr_file")" != *"readonly variable"* ]]
}

@test "arg_parse rejects aliases among its primary caller-owned arrays before mutation" {
local -A options=([existing]="keep")
local -a positionals=(old)
local -a specs=("verbose|flag|--verbose|-v")
local rc

if arg_parse options options specs -- --verbose 2>/dev/null; then
rc=0
else
rc=$?
fi
[ "$rc" -eq 1 ]
[ "${options[existing]}" = "keep" ]

if arg_parse options positionals options -- --verbose 2>/dev/null; then
rc=0
else
rc=$?
fi
[ "$rc" -eq 1 ]
[ "${options[existing]}" = "keep" ]
[ "${positionals[0]}" = "old" ]

if arg_parse options positionals positionals -- --verbose 2>/dev/null; then
rc=0
else
rc=$?
fi
[ "$rc" -eq 1 ]
[ "${options[existing]}" = "keep" ]
[ "${positionals[0]}" = "old" ]
[ "${specs[0]}" = "verbose|flag|--verbose|-v" ]
}

@test "arg_parse rejects repeatable-output aliases before mutation" {
local -A options=([existing]="keep")
local -a positionals=(old)
local -a specs=("include|repeatable|--include")
local -a include=(saved)
local rc

if arg_parse options include specs -- --include new 2>/dev/null; then
rc=0
else
rc=$?
fi
[ "$rc" -eq 1 ]
[ "${options[existing]}" = "keep" ]
[ "${include[0]}" = "saved" ]

include=("include|repeatable|--include")
if arg_parse options positionals include -- --include new 2>/dev/null; then
rc=0
else
rc=$?
fi
[ "$rc" -eq 1 ]
[ "${options[existing]}" = "keep" ]
[ "${positionals[0]}" = "old" ]
[ "${include[0]}" = "include|repeatable|--include" ]
}

@test "arg_parse accepts long option equals values and repeated options" {
local -a specs=(
"verbose|flag|--verbose|-v"
Expand Down Expand Up @@ -165,6 +282,27 @@ EOF
[ -z "${options[include]+set}" ]
}

@test "arg_parse handles declared-empty arrays under nounset" {
local script="$TEST_TMPDIR/arg-empty-nounset.sh"

create_script "$script" <<EOF
#!/usr/bin/env bash
set -u
source "$BASE_BASH_DIR/std/lib_std.sh"
source "$BASE_BASH_DIR/arg/lib_arg.sh"
declare -A options=()
declare -a positionals=()
declare -a specs=()
arg_parse options positionals specs --
[[ -z "\${options[*]-}" ]]
[[ -z "\${positionals[*]-}" ]]
EOF

bats_run bash "$script"

[ "$status" -eq 0 ]
}

@test "arg_parse returns usage status for unknown options" {
local -a specs=("verbose|flag|--verbose|-v")
local -A options=()
Expand Down
Loading
Loading