barg-parser/barg.sh

525 lines
15 KiB
Bash
Executable File

#!/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 ---
declare -A BARG_PARSED_VALUES
declare -a BARG_PARSED_COMMANDS
declare -A BARG_SCHEMA
declare -A BARG_ALIASES
BARG_HELP_ONLY=false
BARG_GENERATE_VARS=false # new flag to control var file generation
# --- Auto-source generated ShellCheck stub vars ---
_barg_self="${BASH_SOURCE[0]}"
_barg_dir="$(cd "$(dirname "$_barg_self")" && pwd)"
_barg_stub="${_barg_dir}/$(basename "${_barg_self%.*}").vars.generated.sh"
if [[ -f "$_barg_stub" ]]; then
# shellcheck source=/dev/null
source "$_barg_stub"
fi
unset _barg_self _barg_dir _barg_stub
# --------------------------------------------------------------------
# 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}.help"]="$help_text"
schema["${id}.args"]=""
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
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++))
;;
*)
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
# extract
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 required="${schema["${arg_id}.required"]}"
local atype="${schema["${arg_id}.type"]}"
if [[ -z "$value" && "$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
if [[ -n "${BARG_PARSED_VALUES[$dest]}" ]]; then
BARG_PARSED_VALUES["${cmd_name}_${dest}"]="$value"
else
BARG_PARSED_VALUES["$dest"]="$value"
fi
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"]}"
# resolve command path
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"
}
# --- list arguments ---
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
# --- list subcommands ---
local has_subcommands=false
for key in "${!BARG_ALIASES[@]}"; do
if [[ "$key" =~ ^${cmd_id}\.cmd::(.+)$ ]]; then
if [[ "$has_subcommands" == "false" ]]; then
echo ""
echo "Commands:"
has_subcommands=true
fi
local sub_cmd="${BASH_REMATCH[1]}"
local sub_id="${BARG_ALIASES[$key]}"
local sub_help="${BARG_SCHEMA["${sub_id}.help"]}"
echo " ${sub_cmd}"
[[ -n "$sub_help" ]] && echo " ${sub_help}"
fi
done
}
# --------------------------------------------------------------------
# Export parsed vars
# --------------------------------------------------------------------
barg_export_vars() {
local k base
for k in "${!BARG_PARSED_VALUES[@]}"; do
declare -g "${k}=${BARG_PARSED_VALUES[$k]}"
if [[ "$k" =~ ^[^_]+_(.+)$ ]]; then
base="${BASH_REMATCH[1]}"
[[ -z "${!base+x}" ]] && declare -g "${base}=${BARG_PARSED_VALUES[$k]}"
fi
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
}
# --------------------------------------------------------------------
# Helper: collect all .dest and command_dest variants
# --------------------------------------------------------------------
_barg_collect_all_dests() {
local -n _schema=$1
local -A seen=()
for key in "${!_schema[@]}"; do
[[ $key =~ \.dest$ ]] || continue
local dest="${_schema[$key]}"
[[ -z "$dest" ]] && continue
seen[$dest]=1
done
for key in "${!_schema[@]}"; do
[[ $key =~ ^([0-9]+)\.name$ ]] || continue
local cmd_id="${BASH_REMATCH[1]}"
local cmd="${_schema[$key]}"
local args_str="${_schema[${cmd_id}.args]}"
[[ -z "$args_str" ]] && continue
IFS=',' read -r -a arg_ids <<<"$args_str"
for a in "${arg_ids[@]}"; do
local d="${_schema[${a}.dest]}"
[[ -n "$d" ]] && seen["${cmd}_${d}"]=1
done
done
printf '%s\n' "${!seen[@]}" | sort
}
# --------------------------------------------------------------------
# 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
local -a caller_stack=("${BASH_SOURCE[@]}")
local caller="${caller_stack[1]}"
local out_file="${caller%.*}.vars.generated.sh"
# Only generate when explicitly requested
if [[ "$BARG_GENERATE_VARS" == "true" || "$BARG_REGEN_VARS" == "1" ]]; then
mapfile -t vars < <(_barg_collect_all_dests BARG_SCHEMA)
{
echo "# ---- autogenerated: BARG vars for ShellCheck ----"
echo "# generated from $(basename "$caller")"
echo "# regenerate with: BARG_REGEN_VARS=1 ./$(basename "$caller") ..."
echo "# shellcheck disable=SC2034"
echo "if false; then"
echo " CLI_SPEC=()"
printf ' %s=\n' "${vars[@]}" | paste -sd' ' -
echo "fi"
} >"$out_file"
fi
# Source file if it already exists (not required to generate)
if [[ -f "$out_file" ]]; then
# shellcheck source=/dev/null
source "$out_file"
fi
barg_dispatch
}
# --------------------------------------------------------------------
# Access helpers
# --------------------------------------------------------------------
barg_get() { echo "${BARG_PARSED_VALUES[$1]}"; }
barg_has() {
local val="${BARG_PARSED_VALUES[$1]}"
[[ "$val" == "true" || "$val" == "1" ]]
}