436 lines
12 KiB
Bash
Executable File
436 lines
12 KiB
Bash
Executable File
#!/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
|