barg-parser/barg
2025-10-30 10:18:48 +02:00

373 lines
11 KiB
Plaintext

# 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() { # <cmdId> <argId> -> 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() { # <cmdId> -> "id<TAB>name<TAB>help"
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() { # <rootId> [<cmdId>]
local rootId=$1 showId=${2:-$1}
local prog=${S["$rootId.name"]}
printf 'Usage: %s <command> [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
}