#!/usr/bin/env bash # BetterOpts - a pure Bash runtime library for declarative CLI argument parsing. # # See DESIGN.MD for the full specification. Where the spec leaves a detail # unstated, the choice made here is noted inline. # # Public API: summary, description, flag, option, argument, betteropts_parse. # Everything else (anything prefixed `_bo_`) is a private implementation detail. # --------------------------------------------------------------------------- # Public API # # Runs the full execution lifecycle from DESIGN.MD: finalize the schema, # handle built-in commands (-h/--help, --usage, --__complete), parse, # validate, apply defaults, populate variables, return. Every other # function in this file returns non-zero on failure instead of exiting, so # this is the only place that calls `exit`. # --------------------------------------------------------------------------- betteropts_parse() { _bo_command_name="$(basename "$0")" _bo_finalize_schema || exit 1 local tok for tok in "$@"; do [[ "$tok" == "--" ]] && break case "$tok" in -h | --help) printf '%s\n' "$(_bo_help_text)" exit 0 ;; --usage) printf '%s\n' "$(_bo_usage_text)" exit 0 ;; *) ;; esac done if [[ "${1:-}" == "--__complete" ]]; then shift _bo_complete "$@" exit 0 fi _bo_parse "$@" || exit 1 _bo_assign_positionals || exit 1 _bo_validate || exit 1 _bo_apply_defaults _bo_populate } # --------------------------------------------------------------------------- # Schema # # The CLI schema is the single source of truth consumed by the parser, # validator, help/usage generators, and completion engine. Storage is an # implementation detail: ordered name lists plus a "name.field" associative # array, reached only through the _bo_meta_* accessor functions below. # --------------------------------------------------------------------------- _bo_summary="" _bo_description="" _bo_flags=() _bo_options=() _bo_flags_and_options=() _bo_arguments=() declare -gA _bo_meta=() _bo_meta_set() { local name="$1" field="$2" value="$3" _bo_meta["${name}.${field}"]="$value" } _bo_meta_get() { local name="$1" field="$2" printf '%s' "${_bo_meta["${name}.${field}"]:-}" } _bo_meta_has() { local name="$1" field="$2" [[ -n "${_bo_meta["${name}.${field}"]+set}" ]] } summary() { _bo_summary="$1" } description() { _bo_description="$1" } # Whether `key` is a recognized key=value attribute for a declaration of the # given kind, per README's per-kind modifier tables. _bo_key_allowed() { local kind="$1" key="$2" case "$kind" in flag) case "$key" in help | var) return 0 ;; *) return 1 ;; esac ;; option) case "$key" in help | type | choices | default | var | metavar) return 0 ;; *) return 1 ;; esac ;; argument) case "$key" in help | type | choices | default | var) return 0 ;; *) return 1 ;; esac ;; *) return 1 ;; esac } # Shared declaration-token parser for flag/option/argument. # # Recognizes: # --xxx -> long flag name # -x -> short flag name # key=value -> attribute (must be in _bo_key_allowed's list for this # kind; otherwise recorded as bad_key for # _bo_finalize_schema to reject) # bareword -> cardinality keyword (required/optional/variadic/ # passthrough), multi, or, for options, the metavar (first # non-keyword bareword). A keyword not valid for this kind, # or a bareword beyond an option's single metavar, is # recorded as bad_keyword for _bo_finalize_schema to reject. _bo_declare() { local kind="$1" name="$2" shift 2 _bo_meta_set "$name" var "$name" _bo_meta_set "$name" short "" _bo_meta_set "$name" long "" _bo_meta_set "$name" required "false" while [[ $# -gt 0 ]]; do local tok="$1" case "$tok" in --*) _bo_meta_set "$name" long "$tok" ;; -?*) _bo_meta_set "$name" short "$tok" ;; *=*) local key="${tok%%=*}" if _bo_key_allowed "$kind" "$key"; then _bo_meta_set "$name" "$key" "${tok#*=}" else _bo_meta_set "$name" bad_key "$key" fi ;; required|optional|variadic|passthrough) if [[ "$kind" == "argument" ]]; then _bo_meta_set "$name" cardinality "$tok" _bo_meta_set "$name" cardinality_count "$(( $(_bo_meta_get "$name" cardinality_count) + 1 ))" elif [[ "$kind" == "option" && "$tok" == "required" ]]; then _bo_meta_set "$name" required "true" else _bo_meta_set "$name" bad_keyword "$tok" fi ;; multi) if [[ "$kind" == "option" ]]; then _bo_meta_set "$name" multi "true" else _bo_meta_set "$name" bad_keyword "$tok" fi ;; *) if [[ "$kind" == "option" ]]; then if _bo_meta_has "$name" metavar; then _bo_meta_set "$name" bad_keyword "$tok" else _bo_meta_set "$name" metavar "$tok" fi else _bo_meta_set "$name" bad_keyword "$tok" fi ;; esac shift done } flag() { local name="$1" shift _bo_declare flag "$name" "$@" _bo_meta_set "$name" kind "flag" _bo_flags+=("$name") _bo_flags_and_options+=("$name") } option() { local name="$1" shift _bo_declare option "$name" "$@" _bo_meta_set "$name" kind "option" _bo_options+=("$name") _bo_flags_and_options+=("$name") } argument() { local name="$1" shift _bo_declare argument "$name" "$@" _bo_meta_set "$name" kind "argument" _bo_arguments+=("$name") } # Validates the schema itself (not user input). Called once at the start of # betteropts_parse. Returns non-zero and prints to stderr on a broken schema. _bo_finalize_schema() { local name kind cardinality count collects_rest_seen=false optional_seen=false i last_index=$(( ${#_bo_arguments[@]} - 1 )) for name in "${_bo_flags_and_options[@]}" "${_bo_arguments[@]}"; do kind="$(_bo_meta_get "$name" kind)" if _bo_meta_has "$name" bad_key; then echo "'$(_bo_meta_get "$name" bad_key)' is not a recognized attribute for $kind '$name'." >&2 return 1 fi if _bo_meta_has "$name" bad_keyword; then echo "'$(_bo_meta_get "$name" bad_keyword)' is not a valid $kind modifier for '$name'." >&2 return 1 fi done for name in "${_bo_options[@]}"; do if [[ "$(_bo_meta_get "$name" multi)" == "true" ]] && _bo_meta_has "$name" default; then echo "A multi option cannot declare a default." >&2 return 1 fi done for i in "${!_bo_arguments[@]}"; do name="${_bo_arguments[$i]}" cardinality="$(_bo_meta_get "$name" cardinality)" count="$(_bo_meta_get "$name" cardinality_count)" count="${count:-0}" if [[ "$count" -eq 0 ]]; then echo "Argument '$name' must declare exactly one of required, optional, variadic, or passthrough (none given)." >&2 return 1 elif [[ "$count" -gt 1 ]]; then echo "Argument '$name' declares more than one of required, optional, variadic, or passthrough (only one is allowed)." >&2 return 1 fi if [[ "$cardinality" == "required" ]] && _bo_meta_has "$name" default; then echo "A required argument cannot declare a default." >&2 return 1 fi if [[ "$cardinality" == "required" && "$optional_seen" == "true" ]]; then echo "Argument '$name' is required but declared after an optional argument (required arguments must come before optional ones)." >&2 return 1 fi if [[ "$cardinality" == "optional" ]]; then optional_seen=true fi if [[ "$cardinality" == "variadic" || "$cardinality" == "passthrough" ]]; then if [[ "$collects_rest_seen" == "true" ]]; then echo "Only one variadic or passthrough argument is allowed." >&2 return 1 fi collects_rest_seen=true if [[ "$i" -ne "$last_index" ]]; then echo "The variadic or passthrough argument must be the last declared argument." >&2 return 1 fi fi done return 0 } # --------------------------------------------------------------------------- # Error Handling # # Internal functions never call `exit`; they print to stderr and return # non-zero. Only the public betteropts_parse translates a failure into a # process exit. This keeps every internal function unit-testable in-process. # --------------------------------------------------------------------------- # Prints "