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 #!/usr/bin/env bash
join() { # Bash Argument Parser (BARG)
local out="" sep="" # Requires Bash 4.3 or higher for associative arrays
for x in "$@"; do
if [[ -n "$x" ]]; then
out+="${sep}${x}"
sep="."
fi
done
printf '%s' "$out"
}
tokenize_argv() { # Check Bash version
shift if [ "${BASH_VERSINFO:-0}" -lt 4 ]; then
TOKENS=("root::dev") echo "Error: This script requires Bash 4.0 or higher (current: $BASH_VERSION)" >&2
local -a argv=("$@") exit 1
fi
while ((${#argv[@]})); do # Main parsing function - all logic contained here
local token=${argv[0]} parse_arguments() {
argv=("${argv[@]:1}") 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 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 break
fi fi
# Named Argument + Value: Option or Flag (long form) # Long option: --key or --key=value
if [[ "$token" == --* ]]; then if [[ "$token" =~ ^--(.+)$ ]]; then
local key=${token#--} local full="${BASH_REMATCH[1]}"
TOKENS+=("narg::${key%%=*}") if [[ "$full" =~ ^([^=]+)=(.*)$ ]]; then
[[ "$key" == *"="* ]] && TOKENS+=("value::${key#*=}") tokens+=("narg::${BASH_REMATCH[1]}")
tokens+=("value::${BASH_REMATCH[2]}")
else
tokens+=("narg::${full}")
fi
((i++))
continue continue
fi fi
# Named Argument + Value: Option or Flag (short form) # Short option: -k or -abc or -k=value
if [[ "$token" == -* ]]; then if [[ "$token" =~ ^-(.+)$ ]]; then
local key=${token#-} local full="${BASH_REMATCH[1]}"
local flags=${key%%=*} if [[ "$full" =~ ^([^=]+)=(.*)$ ]]; then
local value= local flags="${BASH_REMATCH[1]}"
[[ "$key" == *"="* ]] && value=${key#*=} local value="${BASH_REMATCH[2]}"
# Split flags into individual characters
local index flag for ((j = 0; j < ${#flags}; j++)); do
for ((index = 0; index < ${#flags}; index++)); do tokens+=("narg::${flags:$j:1}")
flag=${flags:index:1}
TOKENS+=("narg::${flag}")
done done
tokens+=("value::${value}")
[[ -n "$value" ]] && TOKENS+=("value::${value}") else
# Split all flags
for ((j = 0; j < ${#full}; j++)); do
tokens+=("narg::${full:$j:1}")
done
fi
((i++))
continue continue
fi fi
# Unknown: Command, Positional or Value # Unknown: could be command, positional, or value
TOKENS+=("unk::${token}") tokens+=("unk::${token}")
((i++))
done 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() { for entry in "${spec_array[@]}"; do
S=() # Split entry by semicolons
A=() IFS=';' read -ra elements <<<"$entry"
local spec_name=$1 local entry_type="${elements[0]}"
local -n SPEC_REF="$spec_name"
local _id=100 case "$entry_type" in
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
command) command)
local id=$_id local id="$entry_id"
((_id++)) local aliases_str="${elements[1]}"
IFS=',' read -r -a aliases <<<"${elements[1]}" local help_text="${elements[2]}"
# Schema # Schema entries
S["$id.entryType"]="command" schema["${id}.entryType"]="command"
S["$id.name"]="${aliases[0]}" schema["${id}.name"]="${aliases_str%%,*}" # First alias
S["$id.help"]="${elements[2]}" schema["${id}.help"]="$help_text"
S["$id.args"]="" schema["${id}.args"]=""
# Aliases # Register aliases
if ((${#cmds[@]} == 0)); then if [[ ${#cmd_stack[@]} -eq 0 ]]; then
A["cmd::root"]=$id aliases["cmd::root"]="$id"
else else
local last=$((${#cmds[@]} - 1)) IFS=',' read -ra alias_list <<<"$aliases_str"
for alias in "${aliases[@]}"; do for alias in "${alias_list[@]}"; do
A["$(join "${cmds[$last]}" "cmd::${alias}")"]=$id local parent="${cmd_stack[-1]}"
aliases["${parent}.cmd::${alias}"]="$id"
done done
fi fi
# Control # Push to stacks
cmds+=("$id") ((level++))
pos+=(0) cmd_stack+=("$id")
pos_stack+=(0)
# inject -h/--help only on root ((entry_id++))
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) end)
if ((${#cmds[@]})); then cmds=("${cmds[@]:0:${#cmds[@]}-1}"); fi ((level--))
if ((${#pos[@]})); then pos=("${pos[@]:0:${#pos[@]}-1}"); fi unset 'cmd_stack[-1]'
unset 'pos_stack[-1]'
;; ;;
argument) argument)
local id=$_id local id="$entry_id"
((_id++)) local aliases_str="${elements[1]}"
IFS=',' read -r -a aliases <<<"${elements[1]}" local arg_name="${aliases_str%%,*}"
local name="${aliases[0]}"
local dest="$name" required="false" atype="positional" value="" help="" # Default attributes
local i kv k v 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 for ((i = 2; i < ${#elements[@]}; i++)); do
kv=${elements[i]} local element="${elements[$i]}"
if [[ "$kv" == *":"* ]]; then if [[ "$element" =~ ^([^:]+):(.*)$ ]]; then
k=${kv%%:*} local attr_key="${BASH_REMATCH[1]}"
v=${kv#*:} local attr_value="${BASH_REMATCH[2]}"
if [[ "$attr_key" == "default" ]]; then
schema["${id}.value"]="$attr_value"
else else
k=$kv schema["${id}.${attr_key}"]="$attr_value"
v="true" fi
elif [[ "$element" =~ ^([^:]+)$ ]]; then
local attr_key="${BASH_REMATCH[1]}"
schema["${id}.${attr_key}"]="true"
fi 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 done
S["$id.entryType"]="argument" # Add to parent command's args list
S["$id.name"]="$name" local parent="${cmd_stack[-1]}"
S["$id.dest"]="$dest" local cmd_args="${schema["${parent}.args"]}"
S["$id.required"]="$required" if [[ -n "$cmd_args" ]]; then
S["$id.type"]="$atype" schema["${parent}.args"]="${cmd_args},${id}"
[[ -n "$help" ]] && S["$id.help"]="$help" else
[[ -n "$value" ]] && S["$id.value"]="$value" schema["${parent}.args"]="$id"
fi
local last=$((${#cmds[@]} - 1)) # Register aliases based on type
local cmdArgsKey local arg_type="${schema["${id}.type"]}"
cmdArgsKey="$(join "${cmds[$last]}" "args")" case "$arg_type" in
S["$cmdArgsKey"]="${S[$cmdArgsKey]:+${S[$cmdArgsKey]},}$id"
case "$atype" in
positional) positional)
A["$(join "${cmds[$last]}" "pos::${pos[$last]}")"]=$id local pos="${pos_stack[-1]}"
pos[$last]=$((pos[$last] + 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) 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 esac
((entry_id++))
;; ;;
*) *)
printf 'Error: Invalid entry type: "%s"\n' "${elements[0]}" >&2 echo "Error: Invalid entry type: $entry_type" >&2
return 1 return 1
;; ;;
esac esac
done done
} }
_parse_tokens() { # Parse tokens based on schema
# outputs: COMMANDS[], VALUES[] process_tokens() {
local -a cmds=() pos=() local -a cmd_stack=()
COMMANDS=() local -a pos_stack=()
declare -gA VALUES=() local token_idx=0
while ((${#TOKENS[@]})); do # Process each token
local tagged=${TOKENS[0]} while [[ $token_idx -lt ${#tokens[@]} ]]; do
TOKENS=("${TOKENS[@]:1}") local tagged_token="${tokens[$token_idx]}"
local tokenTag=${tagged%%::*} local token_tag="${tagged_token%%::*}"
local token=${tagged#*::} local token="${tagged_token#*::}"
local found=0
local start_level
if ((${#cmds[@]})); then start_level=$((${#cmds[@]} - 1)); else start_level=0; fi
local found=false
local level local level
for ((level = start_level; level >= 0; level--)); do
local entryId="" # Special handling for root - must be processed first
case "$tokenTag" in if [[ "$token_tag" == "root" ]]; then
root) entryId="${A["cmd::root"]}" ;; local root_id="${aliases["cmd::root"]}"
rest) ((${#cmds[@]})) && entryId="${A["$(join "${cmds[$level]}" "rest")"]}" ;; if [[ -n "$root_id" ]]; then
narg) ((${#cmds[@]})) && entryId="${A["$(join "${cmds[$level]}" "$tagged")"]}" ;; schema["${root_id}.name"]="$token"
unk) cmd_stack+=("$root_id")
if ((${#cmds[@]})); then pos_stack+=(0)
local id="${A["$(join "${cmds[$level]}" "cmd::${token}")"]}" found=true
if [[ -z "$id" && $level -eq $((${#pos[@]} - 1)) ]]; then fi
id="${A["$(join "${cmds[$level]}" "pos::${pos[$level]}")"]}" ((token_idx++))
[[ -n "$id" ]] && pos[$level]=$((pos[$level] + 1)) 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 fi
entryId="$id"
fi fi
;; ;;
esac esac
[[ -z "$entryId" ]] && continue
local entryType=${S["$entryId.entryType"]} # No match at this level, try parent
if [[ "$entryType" == "command" ]]; then if [[ -z "$entry_id" ]]; then
cmds+=("$entryId") continue
pos+=(0) fi
[[ "$tokenTag" == "root" ]] && S["$entryId.name"]="$token"
else # Found match - process it
local atype=${S["$entryId.type"]} local entry_type="${schema["${entry_id}.entryType"]}"
local val=""
case "$atype" in case "$entry_type" in
rest | positional) val="$token" ;; command)
flag) val="true" ;; 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) option)
if ((${#TOKENS[@]} == 0)); then # Next token should be the value
printf 'Error: No value for option "%s"\n' "$token" >&2 local next_idx=$((token_idx + 1))
if [[ $next_idx -ge ${#tokens[@]} ]]; then
echo "Error: No value provided for option: $token" >&2
return 1 return 1
fi fi
local nTagged=${TOKENS[0]}
local nTag=${nTagged%%::*} local next_tagged="${tokens[$next_idx]}"
local nTok=${nTagged#*::} local next_tag="${next_tagged%%::*}"
if [[ "$nTag" != "value" && "$nTag" != "unk" ]]; then local next_token="${next_tagged#*::}"
printf 'Error: Expected option value, got "%s" (%s)\n' "$nTok" "$nTag" >&2
else if [[ "$next_tag" != "value" && "$next_tag" != "unk" ]]; then
TOKENS=("${TOKENS[@]:1}") echo "Error: Expected value for option, got: $next_token ($next_tag)" >&2
val="$nTok" return 1
fi
;;
esac
S["$entryId.value"]="$val"
fi fi
found=1 value="$next_token"
# Skip next token
((token_idx++))
;;
esac
schema["${entry_id}.value"]="$value"
;;
esac
found=true
break break
done 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 done
# finalize # Extract and output results
local cmdId local -a commands=()
for cmdId in "${cmds[@]}"; do local -A values
local cmdName=${S["$cmdId.name"]}
COMMANDS+=("$cmdName") for cmd_id in "${cmd_stack[@]}"; do
local cmdArgs=${S["$cmdId.args"]} local cmd_name="${schema["${cmd_id}.name"]}"
[[ -z "$cmdArgs" ]] && continue local cmd_args="${schema["${cmd_id}.args"]}"
IFS=',' read -r -a argIds <<<"$cmdArgs" commands+=("$cmd_name")
local argId
for argId in "${argIds[@]}"; do if [[ -z "$cmd_args" ]]; then
local name=${S["$argId.name"]} continue
local dest=${S["$argId.dest"]} fi
local value=${S["$argId.value"]}
local required=${S["$argId.required"]} IFS=',' read -ra arg_ids <<<"$cmd_args"
if [[ "$required" == "true" && -z "$value" ]]; then for arg_id in "${arg_ids[@]}"; do
printf 'Error: Argument "%s" is required for "%s"\n' "$name" "$cmdName" >&2 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 return 1
fi fi
if [[ -n "${VALUES[$dest]+x}" ]]; then VALUES["${cmdName}_${dest}"]="$value"; else VALUES["$dest"]="$value"; fi
done
done
}
# --- usage printer ----------------------------------------------------------- # Handle duplicate destinations
_barg_aliases_for() { # <cmdId> <argId> -> prints "-x --xyz " list if [[ -n "${values[$dest]}" ]]; then
local cmdId=$1 argId=$2 k v a out=() values["${cmd_name}_${dest}"]="$value"
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 else
printf 'Error: command handler not found: %s\n' "$fn" >&2 values["$dest"]="$value"
local rootId=${A["cmd::root"]}
_barg_usage_for "$rootId" "$rootId"
return 1
fi 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