diff --git a/.gitignore b/.gitignore index 0b6b4b1..3ca794f 100644 --- a/.gitignore +++ b/.gitignore @@ -161,3 +161,5 @@ Network Trash Folder Temporary Items .apdisk +g.log +g.log* diff --git a/example.bash b/example.bash new file mode 100755 index 0000000..c12b364 --- /dev/null +++ b/example.bash @@ -0,0 +1,17 @@ +#!/bin/bash + +source src/g.bash + +g::log::enable file g.log +g::log::setLevel internal + +g::info "Starting try-catch..." + +try { + ls -lah baddir +} catch { + declare local error="$?" + g::error "Error code: $error" +} + +g::info "After try-catch..." diff --git a/src/g.bash b/src/g.bash new file mode 100644 index 0000000..0771c65 --- /dev/null +++ b/src/g.bash @@ -0,0 +1,650 @@ +#!/bin/bash -e + +set -u +set +o histexpand +shopt -qs failglob +shopt -s expand_aliases + +## ====================== GLOBALS ====================== ## +export g__EXISTS=true + +# The global logging level. Lower the number, higher the priority. +g__LOGGING_LEVEL=2 + +# True if we should log messages to stdout +g__LOGGING_TO_STDOUT=true + +# True if we should log messages to stderr +g__LOGGING_TO_STDERR=false + +# True if we should log messages to a file +g__LOGGING_TO_FILE=false + +# If file logging is enabled, the absolute path to the file +g__LOGGING_FILE='g.log' + + + + +## ====================== UTILITIES ====================== ## + +# +# Check if an input value is numeric. (Supports decimals.) +# +# @param value - the value to check +# @return 0 if numeric, 1 otherwise +# +function g::util::isNumeric() { + local value="$1" + + local rex='^[+-]?[0-9]+([.][0-9]+)?$' + + if [[ "$value" =~ $rex ]]; then + return 0 # true + else + return 1 # false + fi +} + +# +# Returns true if the given array contains a value. +# +# e.g. `g::arr::includes 'value' "${array[@]}"` +# +# @param value - the value to search for +# @param array - the array, by value +# @return 0 if included, 1 otherwise +# +function g::arr::includes() { + local value="$1" + shift + local array=("$@") + + for element in "${array[@]}"; do + if [[ $element == "$value" ]]; then + return 0 + fi + done + + return 1 +} + +# +# Canonical true return value. +# @return true +# +function g::util::true() { + return 0; +} + +# +# Canonical false return value. +# @return false +# +function g::util::false() { + return 1; +} + +# +# Cast the return code of the command to a boolean echo output. +# +# @param ...cmd - the command to execute +# @echo 1 if return is true, 0 otherwise +# +function g::util::returnToEcho() { + local cmd="$@" + + if eval "$cmd"; then + echo 1 + else + echo 0 + fi +} + +# +# Append the input string to a given file. +# +# @param filename +# @param string +# +function g::file::appendString() { + local filename="$1" + local string="$2" + + echo "$string" >> "$filename" +} + +function g::file::exists() { + local filename="$1" + + if [[ -f "$filename" ]]; then + return $(g::util::true) + else + return $(g::util::false) + fi +} + +function g::file::directoryExists() { + local filename="$1" + + if [[ -d "$filename" ]]; then + return $(g::util::true) + else + return $(g::util::false) + fi +} + +function g::file::touch() { + local filename="$1" + echo '' >> "$filename" +} + +function g::file::truncate() { + local filename="$1" + if g::file::exists "$filename"; then + echo '' > "$filename" + fi +} + +# +# Throw a program error. Exits. +# @todo move to g::err +# +function g::error::throw() { + local message="$1" + echo "$message" 1>&2 + exit 1 +} + +# +# Get the current date in UTC format. +# +# @echo the date string +# +function g::now() { + date -u +"%Y-%m-%dT%H:%M:%SZ" +} + +# +# Get a stack trace. +# +# @param message - optional message to include +# @echo the stack trace +# +function g::util::trace() { + local stack="" + local i message="${1:-""}" + local stack_size=${#FUNCNAME[@]} + + # to avoid noise we start with 1 to skip the trace function + for (( i=1; i<$stack_size; i++ )); do + local func="${FUNCNAME[$i]}" + + [ x$func = x ] && func=MAIN + + local linen="${BASH_LINENO[$(( i - 1 ))]}" + local src="${BASH_SOURCE[$i]}" + + [ x"$src" = x ] && src=non_file_source + + stack+=$'\n'" at: "$func" "$src" "$linen + done + + stack="${message}${stack}" + echo "${stack}" +} + + + + +## ====================== STRINGS ====================== ## + +function g::str::quote() { + local string="$1" + echo "'$string'" +} + +function g::str::quote::eval() { + local cmd="$@" + local str="$(eval "$cmd")" + echo "$(g::str::quote "$str")" +} + +function g::str::length() { + local string="$1" + echo "$(expr length "$string")" +} + +function g::str::padLeft() { + local string="$1" + local padToLength="$2" + local padCharacter="${3:- }" + + local length="$(expr length "$string")" + + while [[ "$length" -lt "$padToLength" ]]; do + string="${padCharacter}${string}" + length="$(expr length "$string")" + done + + echo "$string" +} + +function g::str::padRight() { + local string="$1" + local padToLength="$2" + local padCharacter="${3:- }" + + local length="$(expr length "$string")" + + while [[ "$length" -lt "$padToLength" ]]; do + string="${string}${padCharacter}" + length="$(expr length "$string")" + done + + echo "$string" +} + +function g::str::padCenter() { + local string="$1" + local padToLength="$2" + local padCharacter="${3:- }" + + local length="$(expr length "$string")" + local leftSide=1 + + while [[ "$length" -lt "$padToLength" ]]; do + if [[ "$leftSide" -eq "1" ]]; then + string="${padCharacter}${string}" + leftSide=0 + else + string="${string}${padCharacter}" + leftSide=1 + fi + + length="$(expr length "$string")" + done + + echo "$string" +} + +function g::str::substring() { + local string="$1" + local startAt="$2" + local length="$3" + + echo "${string:$startAt:$length}" +} + +function g::str::reverse() { + local input="$1" + + local output="" + local length="$(g::str::length "$input")" + + for (( i=$length; i>=0; i-- )); do + output="${output}${input:$i:1}" + done + + echo "${output}" +} + +function g::str::startsWith() { + local string="$1" + local startsWith="$2" + + local startsLength="$(g::str::length "$startsWith")" + local substring="$(g::str::substring "$string" 0 $startsLength)" + + if [[ "$substring" == "$startsWith" ]]; then + return $(g::util::true) + else + return $(g::util::false) + fi +} + +function g::str::endsWith() { + local string="$1" + local endsWith="$2" + + local revString="$(g::str::reverse "$string")" + local revEndsWith="$(g::str::reverse "$endsWith")" + + if g::str::startsWith "$revString" "$revEndsWith"; then + return $(g::util::true) + else + return $(g::util::false) + fi +} + +# string helpers + # string split + + + + +## ====================== LOGGING ====================== ## + +# output functions and logging + # capture output + # silence output + # colors + # powerline + # tables + # width-fill + +# +# Convert a string-form logging level to its numeric form. +# +# @param name +# @echo the numeric form +# +function g::log::stringNameToNumber() { + local name="$1" + + if [ "$name" = "error" ]; then + echo 1 + elif [ "$name" = "warn" ]; then + echo 2 + elif [ "$name" = "info" ]; then + echo 3 + elif [ "$name" = "debug" ]; then + echo 4 + elif [ "$name" = "verbose" ]; then + echo 5 + elif [ "$name" = "internal" ]; then + echo 6 + else + echo 0 + fi +} + +# +# Convert the numeric logging level to its string name. +# +# @param level +# @echo the string name +# +function g::log::numberToStringName() { + local level="$1" + + if [ "$level" = "1" ]; then + echo error + elif [ "$level" = "2" ]; then + echo warn + elif [ "$level" = "3" ]; then + echo info + elif [ "$level" = "4" ]; then + echo debug + elif [ "$level" = "5" ]; then + echo verbose + elif [ "$level" = "6" ]; then + echo internal + else + echo none + fi +} + +# +# Normalize a value that may be either a string- or numeric-form +# logging level to a numeric form. +# +# @param level +# @echo the numeric form +# +function g::log::normalizeLevelToNumber() { + local level="$1" + + if g::util::isNumeric "$level"; then + echo $level + else + echo $(g::log::stringNameToNumber $level) + fi +} + +# +# Get the current logging level, as a string. +# +# @echo the string name +# +function g::log::getLevel() { + echo $(g::log::numberToStringName $g__LOGGING_LEVEL) +} + +# +# Set the logging level by string name. +# +# @param name +# +function g::log::setLevel() { + local name="$1" + + local level=$(g::log::stringNameToNumber "$name") + + g__LOGGING_LEVEL=$level +} + +# +# Enable a particular logging format. +# +# @param target - stdout | stderr | file +# @param param - if target is "file", the path of the file to log to +# @throws if the target is invalid +# +function g::log::enable() { + local target="$1" + local param="$2" + + if [[ $target == 'stdout' ]]; then + g__LOGGING_TO_STDOUT=true + elif [[ $target == 'stderr' ]]; then + g__LOGGING_TO_STDERR=true + elif [[ $target == 'file' ]]; then + g__LOGGING_TO_FILE=true + g__LOGGING_FILE="$(realpath "${param:-$g__LOGGING_FILE}")" + + g::file::appendString "$g__LOGGING_FILE" "$(g::log::format 3 "====== Logging started. ======")" + else + g::error::throw "Invalid logging target: ${target}" + fi +} + +# +# Disable a particular logging format. +# +# @param target - stdout | stderr | file +# @throws if the target is invalid +# +function g::log::disable() { + local target="$1" + + if [[ $target == 'stdout' ]]; then + g__LOGGING_TO_STDOUT=false + elif [[ $target == 'stderr' ]]; then + g__LOGGING_TO_STDERR=false + elif [[ $target == 'file' ]]; then + g__LOGGING_TO_FILE=false + else + g::error::throw "Invalid logging target: ${target}" + fi +} + +# +# Write to standard output. +# +# @param output +# +function g::log::stdout() { + local output="$1" + echo "$output" +} + +# +# Write to standard error. +# +# @param output +# +function g::log::stderr() { + local output="$1" + echo "$output" 1>&2 +} + +# +# Write to the log, on all configured formats. +# +# @param output +# +function g::log::write() { + local output="$1" + + if $g__LOGGING_TO_STDOUT; then + g::log::stdout "$output" + fi + + if $g__LOGGING_TO_STDERR; then + g::log::stderr "$output" + fi + + if $g__LOGGING_TO_FILE; then + g::file::appendString "$g__LOGGING_FILE" "$output" + fi +} + +# +# Format an output string with logging information like log level and date. +# +# @param level - the numeric logging level +# @param output +# +function g::log::format() { + local level="$1" + local output="$2" + + local levelDisplay="[$(g::log::numberToStringName $level)]" + local paddedLevelDisplay="$(g::str::padLeft "$levelDisplay" 10)" + echo "$paddedLevelDisplay [$(g::now)] $output" +} + +# +# Log a message. +# +# @param inputLevel +# @param output +# +function g::log() { + local inputLevel="$1" + local output="$2" + + local level="$(g::log::normalizeLevelToNumber $inputLevel)" + + if [ $level -le $g__LOGGING_LEVEL ]; then + g::log::write "$(g::log::format $level "$output")" + fi +} + +# +# Log an error message. +# +# @param output +# +function g::error() { + local output="$1" + g::log error "$output" +} + +# +# Log a warning message. +# +# @param output +# +function g::warn() { + local output="$1" + g::log warn "$output" +} + +# +# Log an informational message. +# +# @param output +# +function g::info() { + local output="$1" + g::log info "$output" +} + +# +# Log a debugging message. +# +# @param output +# +function g::debug() { + local output="$1" + g::log debug "$output" +} + +# +# Log a verbose message. +# +# @param output +# +function g::internal() { + local output="$1" + g::log verbose "$output" +} + +# +# Log a verbose message. +# +# @param output +# +function g::internal() { + local output="$1" + g::log internal "$output" +} + +function g::log::rotate() { + local logfile="$g__LOGGING_FILE" + if g::file::exists "$logfile"; then + cp "$logfile" "${logfile}-$(g::now)" + g::file::truncate "$logfile" + fi +} + + + + +## ====================== ERROR HANDLING ====================== ## + +declare -ig g__INSIDE_TRY_CATCH=0 +declare -g g__PRESET_SHELL_OPTS="$-" + +alias try='[[ $g__INSIDE_TRY_CATCH -eq 0 ]] || g__PRESET_SHELL_OPTS="$(echo $- | sed 's/[is]//g')"; g__INSIDE_TRY_CATCH+=1; set +e; ( set -e; true; ' +alias catch='); declare g__TRY_RESULT=$?; g__INSIDE_TRY_CATCH+=-1; [[ $g__INSIDE_TRY_CATCH -lt 1 ]] || set -${g__PRESET_SHELL_OPTS:-e} && g::err::parse $g__TRY_RESULT || ' + +function g::err::parse() { + local tryCode="$1" + unset g__TRY_RESULT; + + if [[ $tryCode -gt 0 ]]; then + local IFS=$'\n' + return $tryCode + else + return 0 + fi +} + + +# math helpers + +# dependency declarations +# package/script imports & singleton requires + +# output helpers + +# argument parsing + +# try-catch & exception handling + +# named parameters & arrays/maps + +# JSON parsing, INI parsing, rich data types