# barg - Bash argument parser + tiny CLI framework # Bash 5+ # --- helpers ----------------------------------------------------------------- join() { local out="" sep="" for x in "$@"; do [[ -n "$x" ]] && { out+="${sep}${x}" sep="." }; done printf '%s' "$out" } # --- tokenizer --------------------------------------------------------------- tokenize_argv() { local prog=$1 shift TOKENS=() TOKENS+=("root::${prog}") local -a args=("$@") while ((${#args[@]})); do local t=${args[0]} args=("${args[@]:1}") if [[ "$t" == "--" ]]; then TOKENS+=("rest::${args[*]}") break fi if [[ "$t" == --* ]]; then local kv=${t#--} local key=${kv%%=*} TOKENS+=("narg::${key}") [[ "$kv" == *"="* ]] && TOKENS+=("value::${kv#*=}") continue fi if [[ "$t" == -* ]]; then local sv=${t#-} local flags=${sv%%=*} local val= [[ "$sv" == *"="* ]] && val=${sv#*=} local i ch for ((i = 0; i < ${#flags}; i++)); do ch=${flags:i:1} TOKENS+=("narg::${ch}") done [[ -n "$val" ]] && TOKENS+=("value::${val}") continue fi TOKENS+=("unk::${t}") done } # --- schema + parse ---------------------------------------------------------- declare -A S A _generate_schema() { S=() A=() local spec_name=$1 local -n SPEC_REF="$spec_name" local _id=100 local -a pos=() cmds=() local root_added_help=0 for entry in "${SPEC_REF[@]}"; do IFS=';' read -r -a parts <<<"$entry" case "${parts[0]}" in command) local id=$_id ((_id++)) IFS=',' read -r -a aliases <<<"${parts[1]}" S["$id.entryType"]="command" S["$id.name"]="${aliases[0]}" S["$id.help"]="${parts[2]}" S["$id.args"]="" if ((${#cmds[@]} == 0)); then A["cmd::root"]=$id else local last=$((${#cmds[@]} - 1)) for alias in "${aliases[@]}"; do A["$(join "${cmds[$last]}" "cmd::${alias}")"]=$id; done fi cmds+=("$id") pos+=(0) # inject -h/--help only on root if ((root_added_help == 0)); then root_added_help=1 local hid=$_id ((_id++)) S["$hid.entryType"]="argument" S["$hid.name"]="help" S["$hid.dest"]="help" S["$hid.required"]="false" S["$hid.type"]="flag" S["$hid.help"]="Show help" local cmdArgsKey cmdArgsKey="$(join "${cmds[0]}" "args")" S["$cmdArgsKey"]="${S[$cmdArgsKey]:+${S[$cmdArgsKey]},}$hid" A["$(join "${cmds[0]}" "narg::help")"]=$hid A["$(join "${cmds[0]}" "narg::h")"]=$hid fi ;; end) if ((${#cmds[@]})); then cmds=("${cmds[@]:0:${#cmds[@]}-1}"); fi if ((${#pos[@]})); then pos=("${pos[@]:0:${#pos[@]}-1}"); fi ;; argument) local id=$_id ((_id++)) IFS=',' read -r -a aliases <<<"${parts[1]}" local name="${aliases[0]}" local dest="$name" required="false" atype="positional" value="" help="" local i kv k v for ((i = 2; i < ${#parts[@]}; i++)); do kv=${parts[i]} if [[ "$kv" == *":"* ]]; then k=${kv%%:*} v=${kv#*:} else k=$kv v="true" fi case "$k" in dest) dest="$v" ;; required) required="$v" ;; type) atype="$v" ;; help) help="$v" ;; default) value="$v" ;; repeatable) S["$id.repeatable"]="$v" ;; # not implemented esac done S["$id.entryType"]="argument" S["$id.name"]="$name" S["$id.dest"]="$dest" S["$id.required"]="$required" S["$id.type"]="$atype" [[ -n "$help" ]] && S["$id.help"]="$help" [[ -n "$value" ]] && S["$id.value"]="$value" local last=$((${#cmds[@]} - 1)) local cmdArgsKey cmdArgsKey="$(join "${cmds[$last]}" "args")" S["$cmdArgsKey"]="${S[$cmdArgsKey]:+${S[$cmdArgsKey]},}$id" case "$atype" in positional) A["$(join "${cmds[$last]}" "pos::${pos[$last]}")"]=$id pos[$last]=$((pos[$last] + 1)) ;; rest) A["$(join "${cmds[$last]}" "rest")"]=$id ;; option | flag) for alias in "${aliases[@]}"; do A["$(join "${cmds[$last]}" "narg::${alias}")"]=$id; done ;; esac ;; *) printf 'Error: Invalid entry type: "%s"\n' "${parts[0]}" >&2 return 1 ;; esac done } _parse_tokens() { # outputs: COMMANDS[], VALUES[] local -a cmds=() pos=() COMMANDS=() declare -gA VALUES=() while ((${#TOKENS[@]})); do local tagged=${TOKENS[0]} TOKENS=("${TOKENS[@]:1}") local tokenTag=${tagged%%::*} local token=${tagged#*::} local found=0 local start_level if ((${#cmds[@]})); then start_level=$((${#cmds[@]} - 1)); else start_level=0; fi local level for ((level = start_level; level >= 0; level--)); do local entryId="" case "$tokenTag" in root) entryId="${A["cmd::root"]}" ;; rest) ((${#cmds[@]})) && entryId="${A["$(join "${cmds[$level]}" "rest")"]}" ;; narg) ((${#cmds[@]})) && entryId="${A["$(join "${cmds[$level]}" "$tagged")"]}" ;; unk) if ((${#cmds[@]})); then local id="${A["$(join "${cmds[$level]}" "cmd::${token}")"]}" if [[ -z "$id" && $level -eq $((${#pos[@]} - 1)) ]]; then id="${A["$(join "${cmds[$level]}" "pos::${pos[$level]}")"]}" [[ -n "$id" ]] && pos[$level]=$((pos[$level] + 1)) fi entryId="$id" fi ;; esac [[ -z "$entryId" ]] && continue local entryType=${S["$entryId.entryType"]} if [[ "$entryType" == "command" ]]; then cmds+=("$entryId") pos+=(0) [[ "$tokenTag" == "root" ]] && S["$entryId.name"]="$token" else local atype=${S["$entryId.type"]} local val="" case "$atype" in rest | positional) val="$token" ;; flag) val="true" ;; option) if ((${#TOKENS[@]} == 0)); then printf 'Error: No value for option "%s"\n' "$token" >&2 return 1 fi local nTagged=${TOKENS[0]} local nTag=${nTagged%%::*} local nTok=${nTagged#*::} if [[ "$nTag" != "value" && "$nTag" != "unk" ]]; then printf 'Error: Expected option value, got "%s" (%s)\n' "$nTok" "$nTag" >&2 else TOKENS=("${TOKENS[@]:1}") val="$nTok" fi ;; esac S["$entryId.value"]="$val" fi found=1 break done ((found == 0)) && printf 'Invalid argument: "%s" (%s). Skipping...\n' "${tagged#*::}" "$tokenTag" done # finalize local cmdId for cmdId in "${cmds[@]}"; do local cmdName=${S["$cmdId.name"]} COMMANDS+=("$cmdName") local cmdArgs=${S["$cmdId.args"]} [[ -z "$cmdArgs" ]] && continue IFS=',' read -r -a argIds <<<"$cmdArgs" local argId for argId in "${argIds[@]}"; do local name=${S["$argId.name"]} local dest=${S["$argId.dest"]} local value=${S["$argId.value"]} local required=${S["$argId.required"]} if [[ "$required" == "true" && -z "$value" ]]; then printf 'Error: Argument "%s" is required for "%s"\n' "$name" "$cmdName" >&2 return 1 fi if [[ -n "${VALUES[$dest]+x}" ]]; then VALUES["${cmdName}_${dest}"]="$value"; else VALUES["$dest"]="$value"; fi done done } # --- usage printer ----------------------------------------------------------- _barg_aliases_for() { # -> prints "-x --xyz " list local cmdId=$1 argId=$2 k v a out=() for k in "${!A[@]}"; do [[ $k == "$cmdId.narg::"* ]] || continue v=${A[$k]} # ← fixed bracket [[ "$v" != "$argId" ]] && continue a=${k##*::} if ((${#a} > 1)); then out+=("--$a"); else out+=("-$a"); fi done printf '%s ' "${out[@]}" } _barg_children_of() { # -> "idnamehelp" local cmdId=$1 k childId declare -A seen=() for k in "${!A[@]}"; do [[ $k == "$cmdId.cmd::"* ]] || continue childId=${A[$k]} [[ -n "${seen[$childId]}" ]] && continue seen[$childId]=1 printf '%s\t%s\t%s\n' "$childId" "${S["$childId.name"]}" "${S["$childId.help"]}" done } _barg_usage_for() { # [] local rootId=$1 showId=${2:-$1} local prog=${S["$rootId.name"]} printf 'Usage: %s [args]\n' "$prog" printf '\nCommands:\n' local name help while IFS=$'\t' read -r _ name help; do printf ' %-12s %s\n' "$name" "$help" done < <(_barg_children_of "$rootId") printf '\nArgs for "%s":\n' "${S["$showId.name"]}" local args=${S["$showId.args"]} [[ -z "$args" ]] && { printf ' (none)\n' return } IFS=',' read -r -a argIds <<<"$args" local aid atype req nm dest desc aliases for aid in "${argIds[@]}"; do atype=${S["$aid.type"]} req=${S["$aid.required"]} nm=${S["$aid.name"]} dest=${S["$aid.dest"]} desc=${S["$aid.help"]} aliases="$(_barg_aliases_for "$showId" "$aid")" case "$atype" in option) printf ' %-16s -- %s (dest=%s)%s\n' "$aliases" "${desc:-option}" "$dest" "$([[ $req == true ]] && echo ' [required]')" ;; flag) printf ' %-16s -- %s (flag)\n' "$aliases" "${desc:-flag}" ;; positional) printf ' %-16s -- %s (positional dest=%s)%s\n' "$nm" "${desc:-positional}" "$dest" "$([[ $req == true ]] && echo ' [required]')" ;; rest) printf ' %-16s -- %s (-- terminator)\n' "$nm" "${desc:-rest}" ;; esac done } # --- public API -------------------------------------------------------------- usage() { local rootId=${A["cmd::root"]} _barg_usage_for "$rootId" "$rootId" } barg::dispatch() { local spec_name=$1 shift local prog=${BARG_PROG:-$(basename "$0")} tokenize_argv "$prog" "$@" _generate_schema "$spec_name" || return 1 _parse_tokens || return 1 # Help or no subcommand -> usage of deepest reachable context if [[ "${VALUES[help]}" == "true" || ${#COMMANDS[@]} -le 1 ]]; then local rootId=${A["cmd::root"]} local showId=$rootId parent=$rootId i for ((i = 1; i < ${#COMMANDS[@]}; i++)); do local name=${COMMANDS[i]} local key key="$(join "$parent" "cmd::${name}")" local child=${A[$key]} [[ -n "$child" ]] && { showId=$child parent=$child } done _barg_usage_for "$rootId" "$showId" return 0 fi # Export parsed values as plain vars local k for k in "${!VALUES[@]}"; do local var=${k//[^a-zA-Z0-9_]/_} printf -v "$var" '%s' "${VALUES[$k]}" done # Call deepest command function local last=$((${#COMMANDS[@]} - 1)) local target=${COMMANDS[$last]} local fn="cmd_${target}" if [[ $(type -t "$fn") == "function" ]]; then "$fn" else printf 'Error: command handler not found: %s\n' "$fn" >&2 local rootId=${A["cmd::root"]} _barg_usage_for "$rootId" "$rootId" return 1 fi }