Skip to content

Instantly share code, notes, and snippets.

@OleksandrKucherenko
Created September 12, 2023 12:49
Show Gist options
  • Save OleksandrKucherenko/0473b10ea3fac797bd3a66cc77b55357 to your computer and use it in GitHub Desktop.
Save OleksandrKucherenko/0473b10ea3fac797bd3a66cc77b55357 to your computer and use it in GitHub Desktop.
Parse BASH script input arguments in a simplest way
#!/usr/bin/env bash
# shellcheck disable=SC2155,SC2034,SC2059
# get script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# is allowed to use macOS extensions (script can be executed in *nix environment)
use_macos_extensions=false
if [[ "$OSTYPE" == "darwin"* ]]; then use_macos_extensions=true; fi
# colors
export cl_reset=$(tput sgr0)
export cl_red=$(tput setaf 1)
export cl_green=$(tput setaf 2)
export cl_yellow=$(tput setaf 3)
export cl_blue=$(tput setaf 4)
export cl_purple=$(tput setaf 5)
export cl_cyan=$(tput setaf 6)
export cl_white=$(tput setaf 7)
export cl_grey=$(tput setaf 8)
export cl_lred=$(tput setaf 9)
export cl_lgreen=$(tput setaf 10)
export cl_lyellow=$(tput setaf 11)
export cl_lblue=$(tput setaf 12)
export cl_lpurple=$(tput setaf 13)
export cl_lcyan=$(tput setaf 14)
export cl_lwhite=$(tput setaf 15)
export cl_black=$(tput setaf 16)
# shellcheck disable=SC1090 source=_logger.sh
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_logger.sh"
# register own logger
logger common "$@"
function now() {
echo "$EPOCHREALTIME" # <~ bash 5.0
#python -c 'import datetime; print datetime.datetime.now().strftime("%s.%f")'
}
# shellcheck disable=SC2155,SC2086
function print_time_diff() {
local diff="$(now) - $1"
bc <<<$diff
}
# shellcheck disable=SC2086
function validate_input() {
local variable=$1
local default=${2:-""}
local prompt=${3:-""}
local user_in=""
# Ctrl+C during read operation force error exit
trap 'exit 1' SIGINT
# execute at least once
while :; do
# allow macOs read command extension usage (default value -i)
if $use_macos_extensions; then
[[ -z "${prompt// /}" ]] || read -e -i "${default}" -p "${cl_purple}? ${cl_reset}${prompt}${cl_blue}" -r user_in
[[ -n "${prompt// /}" ]] || read -e -i "${default}" -r user_in
else
[[ -z "${prompt// /}" ]] || echo "${cl_purple}? ${cl_reset}${prompt}${cl_blue}"
read -r user_in
fi
printf "${cl_reset}"
[[ -z "${user_in// /}" ]] || break
done
local __resultvar=$variable
eval $__resultvar="'$user_in'"
}
# shellcheck disable=SC2086,SC2059
function validate_yn_input() {
local variable=$1
local default=${2:-""}
local prompt=${3:-""}
local user_in=false
while true; do
if $use_macos_extensions; then
[[ -z "${prompt// /}" ]] || read -e -i "${default}" -p "${cl_purple}? ${cl_reset}${prompt}${cl_blue}" -r yn
[[ -n "${prompt// /}" ]] || read -e -i "${default}" -r yn
else
[[ -z "${prompt// /}" ]] || echo "${cl_purple}? ${cl_reset}${prompt}${cl_blue}"
read -r yn
fi
printf "${cl_reset}"
case $yn in
[Yy]*)
user_in=true
break
;;
[Nn]*)
user_in=false
break
;;
*)
user_in=false
break
;;
esac
done
local __resultvar=$variable
eval $__resultvar="$user_in"
}
# shellcheck disable=SC2086
function env_variable_or_secret_file() {
#
# Usage:
# env_variable_or_secret_file "new_value" \
# "GITLAB_CI_INTEGRATION_TEST" \
# ".secrets/gitlab_ci_integration_test" \
# "{user friendly message}"
#
local name=$1
local variable=$2
local file=$3
local fallback=${4:-"No hints, check the documentation"}
local __result=$name
if [[ -z "${!variable}" ]]; then
if [[ ! -f "$file" ]]; then
echo ""
echo "${cl_red}ERROR:${cl_reset} shell environment variable '\$$variable' or file '$file' should be provided"
echo ""
echo "Hint:"
echo " $fallback"
exit 1
else
echo "Using file: ${cl_green}$file${cl_reset} ~> $name"
eval $__result="'$(cat $file)'"
fi
else
echo "Using var : ${cl_green}\$$variable${cl_reset} ~> $name"
eval $__result="'${!variable}'"
fi
}
# shellcheck disable=SC2086
function optional_env_variable_or_secret_file() {
#
# Usage:
# optional_env_variable_or_secret_file "new_value" \
# "GITLAB_CI_INTEGRATION_TEST" \
# ".secrets/gitlab_ci_integration_test"
#
local name=$1
local variable=$2
local file=$3
local __result=$name
if [[ -z "${!variable}" ]]; then
if [[ ! -f "$file" ]]; then
# NO variable, NO file
echo "${cl_yellow}Note:${cl_reset} shell environment variable '\$$variable' or file '$file' can be provided."
return 0
else
echo "Using file: ${cl_green}$file${cl_reset} ~> $name"
eval $__result="'$(cat $file)'"
return 2
fi
else
echo "Using var : ${cl_green}\$$variable${cl_reset} ~> $name"
eval $__result="'${!variable}'"
return 1
fi
}
function isHelp() {
local args=("$@")
if [[ "${args[*]}" =~ "--help" ]]; then echo true; else echo false; fi
}
# --cookies -> "cookies||0",
# --cookies=: -> "cookies||0"
# --cookies=first -> "first||0",
# --cookies=first: -> "first||0"
# --cookies=::1 -> "cookies||1",
# --cookies=:default:1 -> "cookies|default|1"
# --cookies=first::1 -> "first||1"
# --cookies=first:default -> "first|default|0"
# --cookies=first:default:1 -> "first|default|1"
function extract_output_definition() {
local definition=$1
# extract output variable name, examples:
local name=${definition%%=*}
local name_as_value=${name//-/}
local output=${definition##*=}
local variable=""
local default="1"
local args_qt="0"
# extract variable name
if [[ "$output" == "$definition" ]]; then # simplest: --cookies
variable=$name_as_value
elif [[ "$output" == *:* ]]; then # extended: --cookies=first:*, --cookies=first:default:1, --cookies=::1, --cookies=:, --cookies=first:
local tmp=${output%%:*} && variable=${tmp:-"$name_as_value"}
else # extended: --cookies=first
variable=$output
fi
# extract default value
if [[ "$output" == *:* ]]; then default=${output#*:} && default=${default%:*}; fi
# extract arguments quantity
if [[ "$output" == *:*:* ]]; then args_qt=${output##*:}; fi
echo "$variable|$default|$args_qt"
}
# pattern: "{argument},-{short},--{alias}={output}:{init_value}:{args_quantity}"
if [ -z "$ARGS_DEFINITION" ]; then export ARGS_DEFINITION="-h,--help -v,--version=:1.0.0 --debug=DEBUG:*"; fi
function parse_arguments() {
local args=("$@")
# TODO (olku): trim whitespaces in $ARGS_DEFINITION, not spaces in begining or end, no double spaces
echoCommon "${cl_grey}Definition: $ARGS_DEFINITION${cl_reset}" >&2
# extract definition of each argument, separated by space, remove last empty element
readarray -td ' ' definitions <<<"$ARGS_DEFINITION " && unset 'definitions[-1]'
# build lookup map of arguments, extract the longest name of each argument
declare -A lookup_arguments && lookup_arguments=() # key-to-index_of_definition. e.g. -c -> 0, --cookies -> 0
declare -A index_to_outputs && index_to_outputs=() # index-to-variable_name, e.g. -c,--cookies -> 0=cookies
declare -A index_to_args_qt && index_to_args_qt=() # index-to-argument_qauntity, e.g. -c,--cookies -> 0="0"
declare -A index_to_default && index_to_default=() # index-to-argument_default, e.g. -c,--cookies -> 0="", -c=:default:1 -> 0="default"
# build parameters mapping
for i in "${!definitions[@]}"; do
# TODO (olku): validate the pattern format, otherwise throw an error
# shellcheck disable=SC2206
local keys=(${definitions[i]//,/ })
for key in "${keys[@]}"; do
local name=${key%%=*} # extract clean key name, e.g. --cookies=first -> --cookies
local helper=$(extract_output_definition "$key")
# do the mapping
lookup_arguments[$name]=$i
index_to_outputs[$i]=$(echo "$helper" | awk -F'|' '{print $1}')
index_to_args_qt[$i]=$(echo "$helper" | awk -F'|' '{print $3}')
index_to_default[$i]=$(echo "$helper" | awk -F'|' '{print $2}')
done
done
local index=1 # indexed input arguments without pre-flag
local skip_next_counter=0 # how many argument to skip from processing
local skip_aggregated="" # all skipped arguments placed into one array
local last_processed="" # last processed argument
local separator="" # separator between aggregated arguments
# parse the script arguments and resolve them to output variables
for i in "${!args[@]}"; do
local argument=${args[i]}
local value=""
# extract key and value from argument, if used format `--key=value`
# shellcheck disable=SC2206
if [[ "$argument" == *=* ]]; then local tmp=(${argument//=/ }) && value=${tmp[1]} && argument=${tmp[0]}; fi
# accumulate arguments that reserved by last processed argument
if [ "$skip_next_counter" -gt 0 ]; then
skip_next_counter=$((skip_next_counter - 1))
skip_aggregated="${skip_aggregated}${separator}${argument}"
separator=" "
continue
fi
# if skipped aggregated var contains value assign it to the last processed argument
if [ ${#skip_aggregated} -gt 0 ]; then
value="$skip_aggregated" && skip_aggregated="" && separator=""
# assign value to output variable
local tmp_index=${lookup_arguments[$last_processed]}
eval "export ${index_to_outputs[$tmp_index]}=\"$value\""
fi
# process flags
if [ ${lookup_arguments[$argument]+_} ]; then
last_processed=$argument
local tmp_index=${lookup_arguments[$argument]}
local expected=${index_to_args_qt[$tmp_index]}
# if expected more arguments than provided, configure skip_next_counter
if [ "$expected" -gt 0 ]; then
skip_next_counter=$expected
skip_aggregated="$value" # assign current value to the skip_aggregated
continue
else
# assign default value to the output variable first
eval "export ${index_to_outputs[$tmp_index]}=\"${index_to_default[$tmp_index]}\""
# default value is re-assigned by provided value
if [ -n "$value" ]; then
eval "export ${index_to_outputs[$tmp_index]}=\"$value\""
fi
fi
else
local by_index="\$$index"
# process plain unnamed arguments
case $argument in
-*) echoCommon "${cl_grey}ignored: $argument ($value)${cl_reset}" >&2 ;;
*)
if [ ${lookup_arguments[$by_index]+_} ]; then
last_processed=$by_index
local tmp_index=${lookup_arguments[$by_index]}
eval "export ${index_to_outputs[$tmp_index]}=\"$argument\""
else
echoCommon "${cl_grey}ignored: $argument [$by_index]${cl_reset}" >&2
fi
index=$((index + 1))
;;
esac
fi
done
# if aggregated var contains somenthing, raise error "too little arguments provided"
if [ ${#skip_aggregated} -gt 0 ]; then
if [ "$skip_next_counter" -gt 0 ]; then
echo "Too little arguments provided"
exit 1
else
local value="$skip_aggregated" && skip_aggregated="" && separator=""
local tmp_index=${lookup_arguments[$last_processed]}
eval "export ${index_to_outputs[$tmp_index]}=\"$value\""
fi
fi
# debug output
echoCommon "definition to output index:"
printfCommon '%s\n' "${!definitions[@]}" "${definitions[@]}" | pr -2t
echoCommon "'index', 'output variable name', 'args quantity', 'defaults':"
printfCommon '\"%s\"\n' "${!index_to_outputs[@]}" "${index_to_outputs[@]}" "${index_to_args_qt[@]}" "${index_to_default[@]}" | pr -4t | sort
for variable in "${index_to_outputs[@]}"; do
declare -n var_ref=$variable
echoCommon "${cl_grey}extracted: $variable=$var_ref${cl_reset}"
done
}
# array of script arguments cleaned from flags (e.g. --help)
export ARGS_NO_FLAGS=()
function exclude_flags_from_args() {
local args=("$@")
# remove all flags from call
for i in "${!args[@]}"; do
if [[ ${args[i]} == --* ]]; then unset 'args[i]'; fi
done
echoCommon "${cl_grey}Filtered args:" "$@" "~>" "${args[*]}" "$cl_reset" >&2
# shellcheck disable=SC2207,SC2116
ARGS_NO_FLAGS=($(echo "${args[*]}"))
}
exclude_flags_from_args "$@"
parse_arguments "$@"
# shellcheck disable=SC2154
# echo "help:$help version:$version debug:$DEBUG first:$FIRST second:$SECOND servers:$servers"
@OleksandrKucherenko
Copy link
Author

OleksandrKucherenko commented Sep 12, 2023

Usage:

# pattern: "{\$argument_number},-{short},--{alias}={output_variable}:{value_if_found}:{expected_args_quantity}"
export ARGS_DEFINITION="\$1,-t,--transaction=args_transaction \$2,-c,--cookies=args_cookies -h,--help -v,--version=:1.0.0"

# shellcheck disable=SC1091 source=_commons.sh
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_commons.sh"

# custom logger
logger calls "$@"

echoCalls "Some debug messages that will be printed if DEBUG=calls or DEBUG=*, or --debug"

# is any help requested
if [ -n "$help" ]; then
  print_usage && exit 0
fi

# is version requested
if [ -n "$version" ]; then
  echo "version: $version" && exit 0
fi

run code with --debug flag if you want to details output

> myscript.sh --version --debug

Filtered args: --version --debug ~>  
Definition: $1,-t,--transaction=args_transaction $2,-c,--cookies=args_cookies -h,--help -v,--version=:1.0.0
ignored: --debug ()
definition to output index:
0                                   $1,-t,--transaction=args_transactio
1                                   $2,-c,--cookies=args_cookies
2                                   -h,--help
3                                   -v,--version=:1.0.0
'index', 'output variable name', 'args quantity', 'defaults':
"0"               "args_transaction "0"               "1"
"1"               "args_cookies"    "0"               "1"
"2"               "help"            "0"               "1"
"3"               "version"         "0"               "1.0.0"
extracted: version=1.0.0
extracted: help=
extracted: args_cookies=
extracted: args_transaction=

@OleksandrKucherenko
Copy link
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment