diff --git a/barg b/barg index 66fa9e0..0ed9128 100755 --- a/barg +++ b/barg @@ -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 - if [[ "$token" == "--" ]]; then - TOKENS+=("rest::${argv[*]}") - break - fi + # Local storage (no globals!) + local -A schema + local -A aliases + local -a tokens - # Named Argument + Value: Option or Flag (long form) - if [[ "$token" == --* ]]; then - local key=${token#--} - TOKENS+=("narg::${key%%=*}") - [[ "$key" == *"="* ]] && TOKENS+=("value::${key#*=}") - continue - fi + # Tokenize input into tagged tokens (format: tag::value) + tokenize() { + local input_str="$1" + local -a args + tokens=() - # Named Argument + Value: Option or Flag (short form) - if [[ "$token" == -* ]]; then - local key=${token#-} - local flags=${key%%=*} - local value= - [[ "$key" == *"="* ]] && value=${key#*=} + # Split input into array + read -ra args <<<"$input_str" - local index flag - for ((index = 0; index < ${#flags}; index++)); do - flag=${flags:index:1} - TOKENS+=("narg::${flag}") - done + # Root command + local root="${args[0]}" + tokens+=("root::${root}") - [[ -n "$value" ]] && TOKENS+=("value::${value}") - continue - fi + local i=1 + while [[ $i -lt ${#args[@]} ]]; do + local token="${args[$i]}" - # Unknown: Command, Positional or Value - TOKENS+=("unk::${token}") - done -} - -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 elements <<<"$entry" - case "${elements[0]}" in - command) - local id=$_id - ((_id++)) - IFS=',' read -r -a aliases <<<"${elements[1]}" - - # Schema - S["$id.entryType"]="command" - S["$id.name"]="${aliases[0]}" - S["$id.help"]="${elements[2]}" - S["$id.args"]="" - - # Aliases - 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 - - # 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 - ;; - 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 <<<"${elements[1]}" - local name="${aliases[0]}" - local dest="$name" required="false" atype="positional" value="" help="" - local i kv k v - for ((i = 2; i < ${#elements[@]}; i++)); do - kv=${elements[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' "${elements[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 + # Rest arguments (everything after --) + if [[ "$token" == "--" ]]; then + ((i++)) + local rest_args="" + while [[ $i -lt ${#args[@]} ]]; do + if [[ -n "$rest_args" ]]; then + rest_args="${rest_args} ${args[$i]}" else - TOKENS=("${TOKENS[@]:1}") - val="$nTok" + rest_args="${args[$i]}" + fi + ((i++)) + done + if [[ -n "$rest_args" ]]; then + tokens+=("rest::${rest_args}") + fi + break + fi + + # 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 + + # 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 + 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: could be command, positional, or value + tokens+=("unk::${token}") + ((i++)) + done + } + + # 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=() + + for entry in "${spec_array[@]}"; do + # Split entry by semicolons + 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 entries + schema["${id}.entryType"]="command" + schema["${id}.name"]="${aliases_str%%,*}" # First alias + schema["${id}.help"]="$help_text" + schema["${id}.args"]="" + + # Register aliases + 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 + + # Push to stacks + ((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%%,*}" + + # 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 + 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 + elif [[ "$element" =~ ^([^:]+)$ ]]; then + local attr_key="${BASH_REMATCH[1]}" + schema["${id}.${attr_key}"]="true" + fi + done + + # 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 + + # Register aliases based on type + 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 + } + + # Parse tokens based on schema + process_tokens() { + local -a cmd_stack=() + local -a pos_stack=() + local token_idx=0 + + # 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 + + # 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 fi ;; esac - S["$entryId.value"]="$val" + + # 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) + # 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 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 + + value="$next_token" + # Skip next token + ((token_idx++)) + ;; + esac + + schema["${entry_id}.value"]="$value" + ;; + esac + + found=true + break + done + + if [[ "$found" == "false" ]]; then + echo "Warning: Invalid argument: \"$token\" ($token_tag). Skipping..." >&2 fi - found=1 - break + ((token_idx++)) done - ((found == 0)) && printf 'Invalid argument: "%s" (%s). Skipping...\n' "${tagged#*::}" "$tokenTag" - done + # Extract and output results + local -a commands=() + local -A values - # 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 + 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 - if [[ -n "${VALUES[$dest]+x}" ]]; then VALUES["${cmdName}_${dest}"]="$value"; else VALUES["$dest"]="$value"; 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 + + # Handle duplicate destinations + if [[ -n "${values[$dest]}" ]]; then + values["${cmd_name}_${dest}"]="$value" + else + values["$dest"]="$value" + fi + done done - done -} -# --- usage printer ----------------------------------------------------------- -_barg_aliases_for() { # -> 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() { # -> "idnamehelp" - 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() { # [] - local rootId=$1 showId=${2:-$1} - local prog=${S["$rootId.name"]} - printf 'Usage: %s [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 + # Output results + echo "Commands: ${commands[*]}" + echo "Values:" + for key in "${!values[@]}"; do + echo " ${key}=${values[$key]}" + done } - 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 + + # Execute the pipeline + tokenize "$input" + generate_schema "${spec_input[@]}" || return 1 + process_tokens } -# --- public API -------------------------------------------------------------- -usage() { - local rootId=${A["cmd::root"]} - _barg_usage_for "$rootId" "$rootId" +# Wrapper to handle errors gracefully +parse_input_wrap() { + parse_arguments "$@" 2>&1 } -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 +# 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" +) - # 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 +# 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" +) - # 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 -} +# Run tests +for input in "${INPUTS[@]}"; do + echo "" + echo "------------------------" + echo "> $input" + parse_input_wrap "${SPEC[@]}" "$input" +done