This commit is contained in:
Tomas Mirchev 2025-10-31 22:08:08 +02:00
parent dacb3aeb09
commit c46260d048

672
barg
View File

@ -1,386 +1,432 @@
#!/usr/bin/env bash
join() {
local out="" sep=""
for x in "$@"; do
if [[ -n "$x" ]]; then
out+="${sep}${x}"
sep="."
fi
done
printf '%s' "$out"
}
# Bash Argument Parser (BARG)
# Requires Bash 4.3 or higher for associative arrays
tokenize_argv() {
shift
TOKENS=("root::dev")
local -a argv=("$@")
# Check Bash version
if [ "${BASH_VERSINFO:-0}" -lt 4 ]; then
echo "Error: This script requires Bash 4.0 or higher (current: $BASH_VERSION)" >&2
exit 1
fi
while ((${#argv[@]})); do
local token=${argv[0]}
argv=("${argv[@]:1}")
# Main parsing function - all logic contained here
parse_arguments() {
local -a spec_input=("$@")
local input="${spec_input[-1]}"
unset 'spec_input[-1]'
# Rest
# Local storage (no globals!)
local -A schema
local -A aliases
local -a tokens
# Tokenize input into tagged tokens (format: tag::value)
tokenize() {
local input_str="$1"
local -a args
tokens=()
# Split input into array
read -ra args <<<"$input_str"
# Root command
local root="${args[0]}"
tokens+=("root::${root}")
local i=1
while [[ $i -lt ${#args[@]} ]]; do
local token="${args[$i]}"
# Rest arguments (everything after --)
if [[ "$token" == "--" ]]; then
TOKENS+=("rest::${argv[*]}")
((i++))
local rest_args=""
while [[ $i -lt ${#args[@]} ]]; do
if [[ -n "$rest_args" ]]; then
rest_args="${rest_args} ${args[$i]}"
else
rest_args="${args[$i]}"
fi
((i++))
done
if [[ -n "$rest_args" ]]; then
tokens+=("rest::${rest_args}")
fi
break
fi
# Named Argument + Value: Option or Flag (long form)
if [[ "$token" == --* ]]; then
local key=${token#--}
TOKENS+=("narg::${key%%=*}")
[[ "$key" == *"="* ]] && TOKENS+=("value::${key#*=}")
# Long option: --key or --key=value
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
# Named Argument + Value: Option or Flag (short form)
if [[ "$token" == -* ]]; then
local key=${token#-}
local flags=${key%%=*}
local value=
[[ "$key" == *"="* ]] && value=${key#*=}
local index flag
for ((index = 0; index < ${#flags}; index++)); do
flag=${flags:index:1}
TOKENS+=("narg::${flag}")
# Short option: -k or -abc or -k=value
if [[ "$token" =~ ^-(.+)$ ]]; then
local full="${BASH_REMATCH[1]}"
if [[ "$full" =~ ^([^=]+)=(.*)$ ]]; then
local flags="${BASH_REMATCH[1]}"
local value="${BASH_REMATCH[2]}"
# Split flags into individual characters
for ((j = 0; j < ${#flags}; j++)); do
tokens+=("narg::${flags:$j:1}")
done
[[ -n "$value" ]] && TOKENS+=("value::${value}")
tokens+=("value::${value}")
else
# Split all flags
for ((j = 0; j < ${#full}; j++)); do
tokens+=("narg::${full:$j:1}")
done
fi
((i++))
continue
fi
# Unknown: Command, Positional or Value
TOKENS+=("unk::${token}")
# Unknown: could be command, positional, or value
tokens+=("unk::${token}")
((i++))
done
}
}
declare -A S A
# Generate schema from spec
generate_schema() {
local -a spec_array=("$@")
local entry_id=100
local level=-1
local -a cmd_stack=()
local -a pos_stack=()
_generate_schema() {
S=()
A=()
local spec_name=$1
local -n SPEC_REF="$spec_name"
for entry in "${spec_array[@]}"; do
# Split entry by semicolons
IFS=';' read -ra elements <<<"$entry"
local entry_type="${elements[0]}"
local _id=100
local -a pos=() cmds=()
local root_added_help=0
for entry in "${SPEC_REF[@]}"; do
IFS=';' read -r -a elements <<<"$entry"
case "${elements[0]}" in
case "$entry_type" in
command)
local id=$_id
((_id++))
IFS=',' read -r -a aliases <<<"${elements[1]}"
local id="$entry_id"
local aliases_str="${elements[1]}"
local help_text="${elements[2]}"
# Schema
S["$id.entryType"]="command"
S["$id.name"]="${aliases[0]}"
S["$id.help"]="${elements[2]}"
S["$id.args"]=""
# Schema entries
schema["${id}.entryType"]="command"
schema["${id}.name"]="${aliases_str%%,*}" # First alias
schema["${id}.help"]="$help_text"
schema["${id}.args"]=""
# Aliases
if ((${#cmds[@]} == 0)); then
A["cmd::root"]=$id
# Register aliases
if [[ ${#cmd_stack[@]} -eq 0 ]]; then
aliases["cmd::root"]="$id"
else
local last=$((${#cmds[@]} - 1))
for alias in "${aliases[@]}"; do
A["$(join "${cmds[$last]}" "cmd::${alias}")"]=$id
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
# Control
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
# Push to stacks
((level++))
cmd_stack+=("$id")
pos_stack+=(0)
((entry_id++))
;;
end)
if ((${#cmds[@]})); then cmds=("${cmds[@]:0:${#cmds[@]}-1}"); fi
if ((${#pos[@]})); then pos=("${pos[@]:0:${#pos[@]}-1}"); fi
((level--))
unset 'cmd_stack[-1]'
unset 'pos_stack[-1]'
;;
argument)
local id=$_id
((_id++))
IFS=',' read -r -a aliases <<<"${elements[1]}"
local name="${aliases[0]}"
local dest="$name" required="false" atype="positional" value="" help=""
local i kv k v
local id="$entry_id"
local aliases_str="${elements[1]}"
local arg_name="${aliases_str%%,*}"
# Default attributes
schema["${id}.entryType"]="argument"
schema["${id}.name"]="$arg_name"
schema["${id}.dest"]="$arg_name"
schema["${id}.required"]="false"
schema["${id}.type"]="positional"
# Parse additional attributes
for ((i = 2; i < ${#elements[@]}; i++)); do
kv=${elements[i]}
if [[ "$kv" == *":"* ]]; then
k=${kv%%:*}
v=${kv#*:}
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
k=$kv
v="true"
schema["${id}.${attr_key}"]="$attr_value"
fi
elif [[ "$element" =~ ^([^:]+)$ ]]; then
local attr_key="${BASH_REMATCH[1]}"
schema["${id}.${attr_key}"]="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"
# Add to parent command's args list
local parent="${cmd_stack[-1]}"
local cmd_args="${schema["${parent}.args"]}"
if [[ -n "$cmd_args" ]]; then
schema["${parent}.args"]="${cmd_args},${id}"
else
schema["${parent}.args"]="$id"
fi
local last=$((${#cmds[@]} - 1))
local cmdArgsKey
cmdArgsKey="$(join "${cmds[$last]}" "args")"
S["$cmdArgsKey"]="${S[$cmdArgsKey]:+${S[$cmdArgsKey]},}$id"
case "$atype" in
# Register aliases based on type
local arg_type="${schema["${id}.type"]}"
case "$arg_type" in
positional)
A["$(join "${cmds[$last]}" "pos::${pos[$last]}")"]=$id
pos[$last]=$((pos[$last] + 1))
local pos="${pos_stack[-1]}"
aliases["${parent}.pos::${pos}"]="$id"
pos_stack[-1]=$((pos + 1))
;;
rest)
aliases["${parent}.rest"]="$id"
;;
rest) A["$(join "${cmds[$last]}" "rest")"]=$id ;;
option | flag)
for alias in "${aliases[@]}"; do A["$(join "${cmds[$last]}" "narg::${alias}")"]=$id; done
IFS=',' read -ra alias_list <<<"$aliases_str"
for alias in "${alias_list[@]}"; do
aliases["${parent}.narg::${alias}"]="$id"
done
;;
esac
((entry_id++))
;;
*)
printf 'Error: Invalid entry type: "%s"\n' "${elements[0]}" >&2
echo "Error: Invalid entry type: $entry_type" >&2
return 1
;;
esac
done
}
}
_parse_tokens() {
# outputs: COMMANDS[], VALUES[]
local -a cmds=() pos=()
COMMANDS=()
declare -gA VALUES=()
# Parse tokens based on schema
process_tokens() {
local -a cmd_stack=()
local -a pos_stack=()
local token_idx=0
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
# Process each token
while [[ $token_idx -lt ${#tokens[@]} ]]; do
local tagged_token="${tokens[$token_idx]}"
local token_tag="${tagged_token%%::*}"
local token="${tagged_token#*::}"
local found=false
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))
# Special handling for root - must be processed first
if [[ "$token_tag" == "root" ]]; then
local root_id="${aliases["cmd::root"]}"
if [[ -n "$root_id" ]]; then
schema["${root_id}.name"]="$token"
cmd_stack+=("$root_id")
pos_stack+=(0)
found=true
fi
((token_idx++))
continue
fi
# Bubble up: try matching from current level to root
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)
# Try as command
entry_id="${aliases["${current_cmd}.cmd::${token}"]}"
# Try as positional (only on last level)
if [[ -z "$entry_id" && $level -eq $((${#cmd_stack[@]} - 1)) ]]; then
local pos="${pos_stack[$level]}"
entry_id="${aliases["${current_cmd}.pos::${pos}"]}"
if [[ -n "$entry_id" ]]; then
pos_stack[$level]=$((pos + 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" ;;
# No match at this level, try parent
if [[ -z "$entry_id" ]]; then
continue
fi
# Found match - process it
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)
if ((${#TOKENS[@]} == 0)); then
printf 'Error: No value for option "%s"\n' "$token" >&2
# Next token should be the value
local next_idx=$((token_idx + 1))
if [[ $next_idx -ge ${#tokens[@]} ]]; then
echo "Error: No value provided for option: $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"
local next_tagged="${tokens[$next_idx]}"
local next_tag="${next_tagged%%::*}"
local next_token="${next_tagged#*::}"
if [[ "$next_tag" != "value" && "$next_tag" != "unk" ]]; then
echo "Error: Expected value for option, got: $next_token ($next_tag)" >&2
return 1
fi
found=1
value="$next_token"
# Skip next token
((token_idx++))
;;
esac
schema["${entry_id}.value"]="$value"
;;
esac
found=true
break
done
((found == 0)) && printf 'Invalid argument: "%s" (%s). Skipping...\n' "${tagged#*::}" "$tokenTag"
if [[ "$found" == "false" ]]; then
echo "Warning: Invalid argument: \"$token\" ($token_tag). Skipping..." >&2
fi
((token_idx++))
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
# Extract and output results
local -a commands=()
local -A values
for cmd_id in "${cmd_stack[@]}"; do
local cmd_name="${schema["${cmd_id}.name"]}"
local cmd_args="${schema["${cmd_id}.args"]}"
commands+=("$cmd_name")
if [[ -z "$cmd_args" ]]; then
continue
fi
IFS=',' read -ra arg_ids <<<"$cmd_args"
for arg_id in "${arg_ids[@]}"; do
local name="${schema["${arg_id}.name"]}"
local dest="${schema["${arg_id}.dest"]}"
local value="${schema["${arg_id}.value"]}"
local required="${schema["${arg_id}.required"]}"
# Check required
if [[ -z "$value" && "$required" == "true" ]]; then
echo "Error: Argument \"$name\" is required in command \"$cmd_name\"" >&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"
# Handle duplicate destinations
if [[ -n "${values[$dest]}" ]]; then
values["${cmd_name}_${dest}"]="$value"
else
printf 'Error: command handler not found: %s\n' "$fn" >&2
local rootId=${A["cmd::root"]}
_barg_usage_for "$rootId" "$rootId"
return 1
values["$dest"]="$value"
fi
done
done
# Output results
echo "Commands: ${commands[*]}"
echo "Values:"
for key in "${!values[@]}"; do
echo " ${key}=${values[$key]}"
done
}
# Execute the pipeline
tokenize "$input"
generate_schema "${spec_input[@]}" || return 1
process_tokens
}
# Wrapper to handle errors gracefully
parse_input_wrap() {
parse_arguments "$@" 2>&1
}
# Example spec
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"
)
# Test inputs
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"
)
# Run tests
for input in "${INPUTS[@]}"; do
echo ""
echo "------------------------"
echo "> $input"
parse_input_wrap "${SPEC[@]}" "$input"
done