#!/usr/bin/env bash # Bash 5+ # TODO (NOT IMPLEMENTED): # - repeatable attribute # ---------- helpers ---------- join() { local out="" sep="" x for x in "$@"; do [[ -n "$x" ]] || continue out+="${sep}${x}" sep="." done printf '%s' "$out" } # ---------- tokenizer ---------- tokenize() { local input=$1 TOKENS=() read -r -a args <<<"$input" local root=${args[0]} TOKENS+=("root::${root}") args=("${args[@]:1}") while ((${#args[@]})); do local token=${args[0]} args=("${args[@]:1}") if [[ "$token" == "--" ]]; then TOKENS+=("rest::${args[*]}") break fi if [[ "$token" == --* ]]; then local kv=${token#--} local key=${kv%%=*} TOKENS+=("narg::${key}") if [[ "$kv" == *"="* ]]; then local value=${kv#*=} TOKENS+=("value::${value}") fi continue fi if [[ "$token" == -* ]]; then local sv=${token#-} local flags=${sv%%=*} local value= if [[ "$sv" == *"="* ]]; then value=${sv#*=}; fi local i ch for ((i = 0; i < ${#flags}; i++)); do ch=${flags:i:1} TOKENS+=("narg::${ch}") done [[ -n "$value" ]] && TOKENS+=("value::${value}") continue fi TOKENS+=("unk::${token}") done } # ---------- parser ---------- declare -A S A generate_schema() { S=() A=() local spec_name=$1 local -n SPEC_REF="$spec_name" local _id=100 local level=-1 local -a pos=() local -a cmds=() for entry in "${SPEC_REF[@]}"; do IFS=';' read -r -a parts <<<"$entry" local type=${parts[0]} case "$type" in command) local id=$_id _id=$((_id + 1)) IFS=',' read -r -a aliases <<<"${parts[1]}" local help="${parts[2]}" S["$id.entryType"]="command" S["$id.name"]="${aliases[0]}" S["$id.help"]="$help" S["$id.args"]="" 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 level=$((level + 1)) cmds+=("$id") pos+=(0) ;; end) level=$((level - 1)) if ((${#cmds[@]} > 0)); then cmds=("${cmds[@]:0:${#cmds[@]}-1}"); fi if ((${#pos[@]} > 0)); then pos=("${pos[@]:0:${#pos[@]}-1}"); fi ;; argument) local id=$_id _id=$((_id + 1)) IFS=',' read -r -a aliases <<<"${parts[1]}" local name="${aliases[0]}" local dest="$name" local required="false" local atype="positional" local value="" local help="" local i kv k v for ((i = 2; i < ${#parts[@]}; i++)); do kv=${parts[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" ;; 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")" if [[ -z "${S[$cmdArgsKey]}" ]]; then S["$cmdArgsKey"]="$id" else S["$cmdArgsKey"]+=",${id}" fi 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' "$type" >&2 return 1 ;; esac done } print_result() { local -a COMMANDS=("${!1}") declare -n VALUES_REF="$2" printf 'commands:' local c for c in "${COMMANDS[@]}"; do printf ' %s' "$c"; done printf '\n' printf 'values:\n' local k v for k in "${!VALUES_REF[@]}"; do v=${VALUES_REF[$k]} if [[ "$v" == *" "* ]]; then printf ' %s="%s"\n' "$k" "$v" else printf ' %s=%s\n' "$k" "$v" fi done } parse_input() { local spec_name=$1 local input=$2 tokenize "$input" generate_schema "$spec_name" || return 1 local -a cmds=() local -a pos=() local tagged token tokenTag while ((${#TOKENS[@]})); do tagged=${TOKENS[0]} TOKENS=("${TOKENS[@]:1}") tokenTag=${tagged%%::*} token=${tagged#*::} local found=0 local level start_level entryId id start_level=$((${#cmds[@]} ? ${#cmds[@]} - 1 : 0)) for ((level = start_level; level >= 0; level--)); do entryId="" case "$tokenTag" in root) entryId="${A["cmd::root"]}" ;; rest) ((${#cmds[@]})) || { entryId="" break } entryId="${A["$(join "${cmds[$level]}" "rest")"]}" ;; narg) ((${#cmds[@]})) || { entryId="" break } entryId="${A["$(join "${cmds[$level]}" "$tagged")"]}" ;; unk) ((${#cmds[@]})) || { entryId=""; } # cannot be positional before a command if [[ -z "$entryId" && ${#cmds[@]} -gt 0 ]]; then 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"]} case "$entryType" in command) cmds+=("$entryId") pos+=(0) if [[ "$tokenTag" == "root" ]]; then S["$entryId.name"]="$token" fi ;; argument) 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 provided for option argument: "%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 argument, but got: "%s" (%s). Skipping...\n' "$nTok" "$nTag" >&2 else TOKENS=("${TOKENS[@]:1}") val="$nTok" fi ;; esac S["$entryId.value"]="$val" ;; esac found=1 break done if ((found == 0)); then printf 'Invalid argument: "%s" (%s). Skipping...\n' "$token" "$tokenTag" fi done # finalize local -a COMMANDS=() declare -A VALUES=() local cmdId cmdName cmdArgs argId name dest value required for cmdId in "${cmds[@]}"; do cmdName=${S["$cmdId.name"]} COMMANDS+=("$cmdName") cmdArgs=${S["$cmdId.args"]} [[ -z "$cmdArgs" ]] && continue IFS=',' read -r -a argIds <<<"$cmdArgs" for argId in "${argIds[@]}"; do name=${S["$argId.name"]} dest=${S["$argId.dest"]} value=${S["$argId.value"]} required=${S["$argId.required"]} if [[ "$required" == "true" && -z "$value" ]]; then printf 'Error: Argument "%s" is required in command "%s"\n' "$name" "$cmdName" >&2 return 1 fi if [[ -n "${VALUES[$dest]+x}" ]]; then VALUES["${cmdName}_${dest}"]="$value" else VALUES["$dest"]="$value" fi done done print_result COMMANDS[@] VALUES } parse_input_wrap() { local spec_name=$1 local input=$2 parse_input "$spec_name" "$input" || printf 'Error: Failed to parse input\n' } # ---------- spec and tests ---------- 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" ) 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" ) for input in "${INPUTS[@]}"; do printf "\n\n------------------------\n\n> %s\n" "$input" parse_input_wrap SPEC "$input" done