#!/usr/bin/env bash # Bash Argument Parser (BARG) # Requires Bash 4.3 or higher for associative arrays # 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 # Main parsing function - all logic contained here parse_arguments() { local -a spec_input=("$@") local input="${spec_input[-1]}" unset 'spec_input[-1]' # 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 ((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 # 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 # Bootstrap: Pre-populate with root command local root_id="${aliases["cmd::root"]}" cmd_stack+=("$root_id") pos_stack+=(0) # Process each token - all follow same bubble-up pattern while [[ $token_idx -lt ${#tokens[@]} ]]; do local tagged_token="${tokens[$token_idx]}" local token_tag="${tagged_token%%::*}" local token="${tagged_token#*::}" # Special case: root token just sets the name, skip matching if [[ "$token_tag" == "root" ]]; then schema["${root_id}.name"]="$token" ((token_idx++)) continue fi local found=false local level # 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 # 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 ((token_idx++)) done # 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 # Only collect if value was actually set if [[ -n "$value" ]]; then # Handle duplicate destinations if [[ -n "${values[$dest]}" ]]; then values["${cmd_name}_${dest}"]="$value" else values["$dest"]="$value" 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" "dev build --global --aaaaa --from alpine -qvvi node:20 --bbbbb orb mybox ccccc " ) # Run tests for input in "${INPUTS[@]}"; do echo "" echo "------------------------" echo "> $input" parse_input_wrap "${SPEC[@]}" "$input" done