This commit is contained in:
Tomas Mirchev 2025-10-30 10:18:48 +02:00
parent 4c821ae145
commit 6726f04f77
3 changed files with 414 additions and 379 deletions

372
barg Normal file
View File

@ -0,0 +1,372 @@
# 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
}

379
barg.sh
View File

@ -1,379 +0,0 @@
#!/usr/bin/env bash
# Bash 5+
# TODO (NOT IMPLEMENTED):
# - repeatable attribute
# ---------- helpers ----------
join() {
local out="" sep="" x
for x in "$@"; do
[[ -n "$x" ]] || continue
out+="${sep}${x}"
sep="."
done
printf '%s' "$out"
}
# ---------- tokenizer ----------
tokenize() {
local input=$1
TOKENS=()
read -r -a args <<<"$input"
local root=${args[0]}
TOKENS+=("root::${root}")
args=("${args[@]:1}")
while ((${#args[@]})); do
local token=${args[0]}
args=("${args[@]:1}")
if [[ "$token" == "--" ]]; then
TOKENS+=("rest::${args[*]}")
break
fi
if [[ "$token" == --* ]]; then
local kv=${token#--}
local key=${kv%%=*}
TOKENS+=("narg::${key}")
if [[ "$kv" == *"="* ]]; then
local value=${kv#*=}
TOKENS+=("value::${value}")
fi
continue
fi
if [[ "$token" == -* ]]; then
local sv=${token#-}
local flags=${sv%%=*}
local value=
if [[ "$sv" == *"="* ]]; then value=${sv#*=}; fi
local i ch
for ((i = 0; i < ${#flags}; i++)); do
ch=${flags:i:1}
TOKENS+=("narg::${ch}")
done
[[ -n "$value" ]] && TOKENS+=("value::${value}")
continue
fi
TOKENS+=("unk::${token}")
done
}
# ---------- parser ----------
declare -A S A
generate_schema() {
S=()
A=()
local spec_name=$1
local -n SPEC_REF="$spec_name"
local _id=100
local level=-1
local -a pos=()
local -a cmds=()
for entry in "${SPEC_REF[@]}"; do
IFS=';' read -r -a parts <<<"$entry"
local type=${parts[0]}
case "$type" in
command)
local id=$_id
_id=$((_id + 1))
IFS=',' read -r -a aliases <<<"${parts[1]}"
local help="${parts[2]}"
S["$id.entryType"]="command"
S["$id.name"]="${aliases[0]}"
S["$id.help"]="$help"
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
level=$((level + 1))
cmds+=("$id")
pos+=(0)
;;
end)
level=$((level - 1))
if ((${#cmds[@]} > 0)); then cmds=("${cmds[@]:0:${#cmds[@]}-1}"); fi
if ((${#pos[@]} > 0)); then pos=("${pos[@]:0:${#pos[@]}-1}"); fi
;;
argument)
local id=$_id
_id=$((_id + 1))
IFS=',' read -r -a aliases <<<"${parts[1]}"
local name="${aliases[0]}"
local dest="$name"
local required="false"
local atype="positional"
local value=""
local 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" ;;
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")"
if [[ -z "${S[$cmdArgsKey]}" ]]; then
S["$cmdArgsKey"]="$id"
else
S["$cmdArgsKey"]+=",${id}"
fi
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' "$type" >&2
return 1
;;
esac
done
}
print_result() {
local -a COMMANDS=("${!1}")
declare -n VALUES_REF="$2"
printf 'commands:'
local c
for c in "${COMMANDS[@]}"; do printf ' %s' "$c"; done
printf '\n'
printf 'values:\n'
local k v
for k in "${!VALUES_REF[@]}"; do
v=${VALUES_REF[$k]}
if [[ "$v" == *" "* ]]; then
printf ' %s="%s"\n' "$k" "$v"
else
printf ' %s=%s\n' "$k" "$v"
fi
done
}
parse_input() {
local spec_name=$1
local input=$2
tokenize "$input"
generate_schema "$spec_name" || return 1
local -a cmds=()
local -a pos=()
local tagged token tokenTag
while ((${#TOKENS[@]})); do
tagged=${TOKENS[0]}
TOKENS=("${TOKENS[@]:1}")
tokenTag=${tagged%%::*}
token=${tagged#*::}
local found=0
local level start_level entryId id
start_level=$((${#cmds[@]} ? ${#cmds[@]} - 1 : 0))
for ((level = start_level; level >= 0; level--)); do
entryId=""
case "$tokenTag" in
root)
entryId="${A["cmd::root"]}"
;;
rest)
((${#cmds[@]})) || {
entryId=""
break
}
entryId="${A["$(join "${cmds[$level]}" "rest")"]}"
;;
narg)
((${#cmds[@]})) || {
entryId=""
break
}
entryId="${A["$(join "${cmds[$level]}" "$tagged")"]}"
;;
unk)
((${#cmds[@]})) || { entryId=""; } # cannot be positional before a command
if [[ -z "$entryId" && ${#cmds[@]} -gt 0 ]]; then
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"]}
case "$entryType" in
command)
cmds+=("$entryId")
pos+=(0)
if [[ "$tokenTag" == "root" ]]; then
S["$entryId.name"]="$token"
fi
;;
argument)
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 provided for option argument: "%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 argument, but got: "%s" (%s). Skipping...\n' "$nTok" "$nTag" >&2
else
TOKENS=("${TOKENS[@]:1}")
val="$nTok"
fi
;;
esac
S["$entryId.value"]="$val"
;;
esac
found=1
break
done
if ((found == 0)); then
printf 'Invalid argument: "%s" (%s). Skipping...\n' "$token" "$tokenTag"
fi
done
# finalize
local -a COMMANDS=()
declare -A VALUES=()
local cmdId cmdName cmdArgs argId name dest value required
for cmdId in "${cmds[@]}"; do
cmdName=${S["$cmdId.name"]}
COMMANDS+=("$cmdName")
cmdArgs=${S["$cmdId.args"]}
[[ -z "$cmdArgs" ]] && continue
IFS=',' read -r -a argIds <<<"$cmdArgs"
for argId in "${argIds[@]}"; do
name=${S["$argId.name"]}
dest=${S["$argId.dest"]}
value=${S["$argId.value"]}
required=${S["$argId.required"]}
if [[ "$required" == "true" && -z "$value" ]]; then
printf 'Error: Argument "%s" is required in command "%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
print_result COMMANDS[@] VALUES
}
parse_input_wrap() {
local spec_name=$1
local input=$2
parse_input "$spec_name" "$input" || printf 'Error: Failed to parse input\n'
}
# ---------- spec and tests ----------
SPEC=(
"command;barg;Barg - Bash Argument Parser"
"argument;global;type:flag"
"command;build;Build a dev container"
"argument;from;type:option;dest:fromName;help:Name of the base container"
"argument;name;type:positional;required;help:Name of the container"
"argument;nametwo;help:Name of the container"
"argument;image,i;type:option;required;dest:imageName;help:Base image"
"argument;include,I;type:option;repeatable;dest:includePaths;help:Include paths"
"argument;verbose,v;type:flag;repeatable;default:0;help:Increase verbosity (-v, -vv, ...)"
"argument;quiet,q;type:flag;default:false;help:Suppress all output"
"argument;cmd;type:rest;help:Command and args to run inside the container"
"argument;name;dest:nameOpt;type:option;help:Building container name"
"command;container;Container building"
"argument;dev;type:flag;help:Is dev or not"
"argument;name;required;help:Building container name"
"argument;cmd;type:rest;dest:cmdRest;help:Command and args to run inside the container"
"end"
"end"
"command;stop;Stop a dev container"
"argument;name;type:positional;required;help:Name of the container"
"argument;kill,k;type:flag;default:false;help:Force kill the container"
"end"
"end"
)
INPUTS=(
"dev build -i myimage buildtwo --from base-dev --image node:18 --include src --include test -v -v -q mycontainer -- npm run start"
"dev build buildname container -i myimage --dev --name webapp externalContainer"
"dev --global build --image debian mybox container --dev --name webapp externalContainer -- echo hi"
"dev stop mycontainer --kill"
"dev stop mycontainer --kill -- not specified one"
"dev build --from alpine --image node:20 mybox -- echo hi"
"dev build --aaaaa --from alpine --image node:20 --bbbbb orb mybox ccccc -- echo hi"
)
for input in "${INPUTS[@]}"; do
printf "\n\n------------------------\n\n> %s\n" "$input"
parse_input_wrap SPEC "$input"
done

42
sample.sh Executable file
View File

@ -0,0 +1,42 @@
#!/usr/bin/env bash
# sample.sh
# shellcheck disable=SC1091
source "$(dirname "$0")/barg" || {
echo "barg not found" >&2
exit 1
}
SPEC=(
"command;dev;Dev tool"
"argument;global;type:flag;help:Global toggle"
"command;build;Build a dev container"
"argument;from;type:option;dest:fromName;help:Base container"
"argument;name;type:positional;required;help:Container name"
"argument;image,i;type:option;required;dest:imageName;help:Base image"
"argument;verbose,v;type:flag;default:false;help:Verbose output"
"argument;cmd;type:rest;help:Command to run"
"end"
"command;stop;Stop a dev container"
"argument;name;type:positional;required;help:Container name"
"argument;kill,k;type:flag;default:false;help:Force kill"
"end"
"end"
)
cmd_build() {
echo "cmd_build:"
echo " fromName = ${fromName}"
echo " name = ${name}"
echo " imageName = ${imageName}"
echo " verbose = ${verbose}"
echo " cmd = ${cmd}"
}
cmd_stop() {
echo "cmd_stop:"
echo " name = ${name}"
echo " kill = ${kill}"
}
barg::dispatch SPEC "$@"