#!/usr/bin/env bash # BARG - Bash Argument Parser & CLI Framework # Requires Bash 4.3+ if [ "${BASH_VERSINFO:-0}" -lt 4 ]; then echo "Error: Requires Bash 4.0 or higher (current: $BASH_VERSION)" >&2 exit 1 fi # --- globals --- BARG_VAR_SUFFIX="_arg" declare -A BARG_PARSED_VALUES declare -a BARG_PARSED_COMMANDS declare -A BARG_SCHEMA declare -A BARG_ALIASES BARG_HELP_ONLY=false # -------------------------------------------------------------------- # Parse CLI args according to spec # -------------------------------------------------------------------- parse_arguments() { local -a spec_input=() local -a args=() local seen_sep=false for item in "$@"; do if [[ $seen_sep == false && "$item" == "--" ]]; then seen_sep=true continue fi if [[ $seen_sep == false ]]; then spec_input+=("$item") else args+=("$item") fi done local -A schema=() local -A aliases=() local -a tokens=() # ---------------- Tokenizer ---------------- tokenize() { local -a in=("$@") tokens=() tokens+=("root::root") local i=0 while [[ $i -lt ${#in[@]} ]]; do local token="${in[$i]}" if [[ "$token" == "--" ]]; then ((i++)) local rest_args=() while [[ $i -lt ${#in[@]} ]]; do rest_args+=("${in[$i]}") ((i++)) done [[ ${#rest_args[@]} -gt 0 ]] && tokens+=("rest::${rest_args[*]}") break fi if [[ "$token" =~ ^--(.+)$ ]]; then local full="${BASH_REMATCH[1]}" if [[ "$full" =~ ^([^=]+)=(.*)$ ]]; then tokens+=("narg::${BASH_REMATCH[1]}") tokens+=("value::${BASH_REMATCH[2]}") else tokens+=("narg::${full}") fi ((i++)) continue fi if [[ "$token" =~ ^-(.+)$ ]]; then local full="${BASH_REMATCH[1]}" if [[ "$full" =~ ^([^=]+)=(.*)$ ]]; then local flags="${BASH_REMATCH[1]}" local value="${BASH_REMATCH[2]}" for ((j = 0; j < ${#flags}; j++)); do tokens+=("narg::${flags:$j:1}") done tokens+=("value::${value}") else for ((j = 0; j < ${#full}; j++)); do tokens+=("narg::${full:$j:1}") done fi ((i++)) continue fi tokens+=("unk::${token}") ((i++)) done } # ---------------- Schema builder ---------------- generate_schema() { local -a spec_array=("$@") local entry_id=100 local level=-1 local -a cmd_stack=() local -a pos_stack=() for entry in "${spec_array[@]}"; do IFS=';' read -ra elements <<<"$entry" local entry_type="${elements[0]}" case "$entry_type" in command) local id="$entry_id" local aliases_str="${elements[1]}" local help_text="${elements[2]}" schema["${id}.entryType"]="command" schema["${id}.name"]="${aliases_str%%,*}" schema["${id}.aliases"]="$aliases_str" schema["${id}.help"]="$help_text" schema["${id}.args"]="" schema["${id}.cmds"]="" if [[ ${#cmd_stack[@]} -eq 0 ]]; then aliases["cmd::root"]="$id" else IFS=',' read -ra alias_list <<<"$aliases_str" for alias in "${alias_list[@]}"; do local parent="${cmd_stack[-1]}" aliases["${parent}.cmd::${alias}"]="$id" done local parent="${cmd_stack[-1]}" local parent_cmds="${schema["${parent}.cmds"]}" schema["${parent}.cmds"]="${parent_cmds:+$parent_cmds,}$id" fi ((level++)) cmd_stack+=("$id") pos_stack+=(0) ((entry_id++)) ;; end) ((level--)) unset 'cmd_stack[-1]' unset 'pos_stack[-1]' ;; argument) local id="$entry_id" local aliases_str="${elements[1]}" local arg_name="${aliases_str%%,*}" schema["${id}.entryType"]="argument" schema["${id}.name"]="$arg_name" schema["${id}.dest"]="$arg_name" schema["${id}.required"]="false" schema["${id}.type"]="positional" for ((i = 2; i < ${#elements[@]}; i++)); do local element="${elements[$i]}" if [[ "$element" =~ ^([^:]+):(.*)$ ]]; then local attr_key="${BASH_REMATCH[1]}" local attr_value="${BASH_REMATCH[2]}" if [[ "$attr_key" == "default" ]]; then schema["${id}.value"]="$attr_value" else schema["${id}.${attr_key}"]="$attr_value" fi else schema["${id}.${element}"]="true" fi done local parent="${cmd_stack[-1]}" local cmd_args="${schema["${parent}.args"]}" [[ -n "$cmd_args" ]] && schema["${parent}.args"]="${cmd_args},${id}" || schema["${parent}.args"]="$id" local arg_type="${schema["${id}.type"]}" case "$arg_type" in positional) local pos="${pos_stack[-1]}" aliases["${parent}.pos::${pos}"]="$id" ((pos_stack[-1] = pos + 1)) ;; rest) aliases["${parent}.rest"]="$id" ;; option | flag) IFS=',' read -ra alias_list <<<"$aliases_str" for alias in "${alias_list[@]}"; do aliases["${parent}.narg::${alias}"]="$id" done ;; esac ((entry_id++)) ;; note) local id="$entry_id" local text="${elements[1]}" schema["${id}.entryType"]="note" schema["${id}.text"]="$text" # attach note to the current command local parent="${cmd_stack[-1]}" local notes="${schema["${parent}.notes"]}" schema["${parent}.notes"]="${notes:+$notes,}$id" ((entry_id++)) ;; *) echo "Error: Invalid entry type: $entry_type" >&2 return 1 ;; esac done } # ---------------- Token processor ---------------- process_tokens() { local -a cmd_stack=() local -a pos_stack=() local token_idx=0 local root_id="${aliases["cmd::root"]}" cmd_stack+=("$root_id") pos_stack+=(0) BARG_HELP_ONLY=false local help_present=false other_narg=false for t in "${tokens[@]}"; do local tag="${t%%::*}" local tok="${t#*::}" if [[ "$tag" == "narg" ]]; then if [[ "$tok" == "h" || "$tok" == "help" ]]; then help_present=true else other_narg=true fi fi done if [[ "$help_present" == true && "$other_narg" == false ]]; then BARG_HELP_ONLY=true fi while [[ $token_idx -lt ${#tokens[@]} ]]; do local tagged_token="${tokens[$token_idx]}" local token_tag="${tagged_token%%::*}" local token="${tagged_token#*::}" if [[ "$token_tag" == "root" ]]; then schema["${root_id}.name"]="$token" ((token_idx++)) continue fi local level for ((level = ${#cmd_stack[@]} - 1; level >= 0; level--)); do local entry_id="" local current_cmd="${cmd_stack[$level]}" case "$token_tag" in rest) entry_id="${aliases["${current_cmd}.rest"]}" ;; narg) entry_id="${aliases["${current_cmd}.narg::${token}"]}" ;; unk) entry_id="${aliases["${current_cmd}.cmd::${token}"]}" if [[ -z "$entry_id" && $level -eq $((${#cmd_stack[@]} - 1)) ]]; then local pos="${pos_stack[$level]}" entry_id="${aliases["${current_cmd}.pos::${pos}"]}" [[ -n "$entry_id" ]] && ((pos_stack[level] = pos + 1)) fi ;; esac [[ -z "$entry_id" ]] && continue local entry_type="${schema["${entry_id}.entryType"]}" case "$entry_type" in command) cmd_stack+=("$entry_id") pos_stack+=(0) ;; argument) local arg_type="${schema["${entry_id}.type"]}" local value="" case "$arg_type" in rest | positional) value="$token" ;; flag) value="true" ;; option) local next_idx=$((token_idx + 1)) ((next_idx >= ${#tokens[@]})) && { echo "Error: No value for option: $token" >&2 return 1 } local next_tagged="${tokens[$next_idx]}" local next_tag="${next_tagged%%::*}" local next_token="${next_tagged#*::}" [[ "$next_tag" =~ ^(value|unk)$ ]] || { echo "Error: Expected value for option $token" >&2 return 1 } value="$next_token" ((token_idx++)) ;; esac schema["${entry_id}.value"]="$value" ;; esac break done ((token_idx++)) done BARG_PARSED_COMMANDS=() BARG_PARSED_VALUES=() for cmd_id in "${cmd_stack[@]}"; do local cmd_name="${schema["${cmd_id}.name"]}" local cmd_args="${schema["${cmd_id}.args"]}" BARG_PARSED_COMMANDS+=("$cmd_name") [[ -z "$cmd_args" ]] && continue IFS=',' read -ra arg_ids <<<"$cmd_args" for arg_id in "${arg_ids[@]}"; do local dest="${schema["${arg_id}.dest"]}" local value="${schema["${arg_id}.value"]}" local dest_val="${BARG_PARSED_VALUES[$dest]}" local required="${schema["${arg_id}.required"]}" local atype="${schema["${arg_id}.type"]}" if [[ -z "$value" && -z "$dest_val" && "$required" == "true" && "$BARG_HELP_ONLY" != "true" ]]; then echo "Error: Argument \"$dest\" required" >&2 return 1 fi if [[ "$atype" == "flag" && ("$value" == "false" || "$value" == "0") ]]; then continue fi if [[ -n "$value" ]]; then BARG_PARSED_VALUES["$dest"]="$value" fi done done for key in "${!schema[@]}"; do BARG_SCHEMA["$key"]="${schema[$key]}"; done for key in "${!aliases[@]}"; do BARG_ALIASES["$key"]="${aliases[$key]}"; done } tokenize "${args[@]}" generate_schema "${spec_input[@]}" || return 1 process_tokens } # -------------------------------------------------------------------- # Usage # -------------------------------------------------------------------- barg_usage() { local -a cmd_path=("$@") local cmd_id="${BARG_ALIASES["cmd::root"]}" if [[ ${#cmd_path[@]} -gt 0 ]]; then for cmd_name in "${cmd_path[@]}"; do local next_id="${BARG_ALIASES["${cmd_id}.cmd::${cmd_name}"]}" if [[ -z "$next_id" ]]; then echo "Error: Unknown command: $cmd_name" >&2 return 1 fi cmd_id="$next_id" done fi local help_text="${BARG_SCHEMA["${cmd_id}.help"]}" echo "Usage: ${cmd_path[*]} [OPTIONS] [COMMANDS]" [[ -n "$help_text" ]] && { echo "" echo "$help_text" } local cmd_notes="${BARG_SCHEMA["${cmd_id}.notes"]}" if [[ -n "$cmd_notes" ]]; then IFS=',' read -ra note_ids <<<"$cmd_notes" for nid in "${note_ids[@]}"; do echo " ${BARG_SCHEMA["${nid}.text"]}" done fi local cmd_args="${BARG_SCHEMA["${cmd_id}.args"]}" if [[ -n "$cmd_args" ]]; then echo "" echo "Arguments:" IFS=',' read -ra arg_ids <<<"$cmd_args" for arg_id in "${arg_ids[@]}"; do local arg_name="${BARG_SCHEMA["${arg_id}.name"]}" local arg_type="${BARG_SCHEMA["${arg_id}.type"]}" local arg_help="${BARG_SCHEMA["${arg_id}.help"]}" local required="${BARG_SCHEMA["${arg_id}.required"]}" local default="${BARG_SCHEMA["${arg_id}.value"]}" local prefix=" " case "$arg_type" in flag | option) prefix=" --${arg_name}" ;; positional) prefix=" <${arg_name}>" ;; rest) prefix=" -- <${arg_name}...>" ;; esac local suffix="" [[ "$required" == "true" ]] && suffix=" (required)" [[ -n "$default" ]] && suffix="${suffix} [default: $default]" echo "${prefix}${suffix}" [[ -n "$arg_help" ]] && echo " ${arg_help}" done fi local cmd_children="${BARG_SCHEMA["${cmd_id}.cmds"]}" if [[ -n "$cmd_children" ]]; then echo "" echo "Commands:" IFS=',' read -ra child_ids <<<"$cmd_children" for child_id in "${child_ids[@]}"; do local sub_name="${BARG_SCHEMA["${child_id}.name"]}" local sub_help="${BARG_SCHEMA["${child_id}.help"]}" local sub_aliases="${BARG_SCHEMA["${child_id}.aliases"]}" # split aliases and remove duplicates IFS=',' read -ra alias_list <<<"$sub_aliases" local alias_display="" if ((${#alias_list[@]} > 1)); then alias_display=" (aliases: ${alias_list[*]:1})" fi echo " ${sub_name}${alias_display}" [[ -n "$sub_help" ]] && echo " ${sub_help}" done fi } # -------------------------------------------------------------------- # Export parsed vars # -------------------------------------------------------------------- barg_export_vars() { local k dest : "${BARG_VAR_SUFFIX:=}" for k in "${!BARG_PARSED_VALUES[@]}"; do dest="${k}${BARG_VAR_SUFFIX}" declare -g "${dest}=${BARG_PARSED_VALUES[$k]}" done } # -------------------------------------------------------------------- # Dispatch # -------------------------------------------------------------------- barg_dispatch() { local func="cmd" for ((i = 1; i < ${#BARG_PARSED_COMMANDS[@]}; i++)); do func="${func}_${BARG_PARSED_COMMANDS[$i]}" done if declare -f "$func" >/dev/null 2>&1; then barg_export_vars "$func" else echo "Error: Handler not found ($func)" >&2 barg_usage "${BARG_PARSED_COMMANDS[@]:1}" fi } # -------------------------------------------------------------------- # Entry point # -------------------------------------------------------------------- barg_run() { local -a spec=("${!1}") shift local -a input=("$@") if ! parse_arguments "${spec[@]}" -- "${input[@]}" 2>&1; then return 1 fi if [[ "$BARG_HELP_ONLY" == "true" || "${BARG_PARSED_VALUES[help]}" == "true" ]]; then barg_usage "${BARG_PARSED_COMMANDS[@]:1}" return 0 fi barg_dispatch } # -------------------------------------------------------------------- # Access helpers # -------------------------------------------------------------------- barg_get() { echo "${BARG_PARSED_VALUES[$1]}"; } barg_has() { local val="${BARG_PARSED_VALUES[$1]}" [[ "$val" == "true" || "$val" == "1" ]] }