Skip to content

Commit

Permalink
WIP: Core logging module
Browse files Browse the repository at this point in the history
This enables scripts that invoke `. $_GO_USE_MODULES 'log'` to access
the following functions:

  @go.log
  @go.log_command
  @go.critical_section_begin
  @go.critical_section_end
  @go.add_or_update_log_level
  @go.setup_project
  • Loading branch information
mbland committed Oct 25, 2016
1 parent 5728a9f commit 1984558
Showing 1 changed file with 377 additions and 0 deletions.
377 changes: 377 additions & 0 deletions lib/log
Original file line number Diff line number Diff line change
@@ -0,0 +1,377 @@
#! /bin/bash
#
# Logging utility functions
#
# Exports:
# @go.log
# Outputs a log line; supports terminal formatting and format stripping
#
# @go.log_command
# Logs a command and its outcome
#
# @go.critical_section_begin
# Causes @go.log_command to log FATAL on error
#
# @go.critical_section_end
# Cause @go.log_command to log ERROR on error
#
# @go.add_or_update_log_level
# Modifies existing log level labels, formatting, and file descriptors
#
# @go.setup_project
# Runs the project's 'setup' script and logs the result
#
# See the comments for each of the above for further documentation.

# Set this if you want to force terminal-formatted output from @go.log.
#
# @go.log will remove terminal formatting codes by default when the output file
# descriptor is not a terminal. Can be set either in the script or on the
# command line.
declare _GO_LOG_FORMATTING="$_GO_LOG_FORMATTING"

# If not empty, @go.log_command will only print the command and not execute it.
#
# Can be set either in the script or on the command line.
declare _GO_DRY_RUN="$_GO_DRY_RUN"

# Default log level labels
#
# DO NOT UPDATE DIRECTLY: Use @go.add_or_update_log_level instead.
declare _GO_LOG_LEVELS=(
'INFO'
'RUN'
'WARN'
'ERROR'
'FATAL'
'START'
'FINISH'
)

# Default log level terminal format codes
declare __GO_LOG_LEVELS_FORMAT_CODES=(
'\e[1m\e[36m'
'\e[1m\e[35m'
'\e[1m\e[33m'
'\e[1m\e[31m'
'\e[1m\e[31m'
'\e[1m\e[32m'
'\e[1m\e[32m'
)

# Default log level output file descriptors
#
# '1' is standard output and '2' is standard error.
declare __GO_LOG_LEVELS_FILE_DESCRIPTORS=(
'1'
'1'
'1'
'2'
'2'
'1'
'1'
)

# DO NOT EDIT: Initialized by @go.log
declare __GO_LOG_LEVELS_FORMATTED=()

# Set by @go.critical_section_{begin,end}
declare _GO_CRITICAL_SECTION=

# Outputs a single log line that may contain terminal control characters.
#
# Usage:
#
# @go.log <log-level> args...
# @go.log <ERROR|FATAL> <exit-status|''> args...
#
# Where:
#
# <log-level> A label from _GO_LOG_LEVELS
# <exit-status> The exit status number to return from an ERROR or FATAL call
# args... Arguments comprising the log record text
#
# Will automatically format the '<log-level>' label if writing to the terminal
# or _GO_LOG_FORMATTING is set. Will automatically strip format codes from the
# remaining arguments if not writing to the terminal and _GO_LOG_FORMATTING is
# empty.
#
# If the first argument is ERROR or FATAL, the second argument is the exit
# status, and the remainder of the arguments comprise the log record. The exit
# status will be appended to the log record if it is not the empty string.
#
# ERROR will cause @go.log to return the exit status; FATAL will exit the
# process with the exit status. If the exit status is the empty string, it will
# default to 1.
#
# If you want to add a custom log level, or change an existing log level, do so
# using @go.add_or_update_log_level before the first call to @go.log, most
# likely in your ./go script.
#
# Arguments:
# $1: log level label; will be converted to all-uppercase
# $2: exit status if $1 is ERROR or FATAL; first log record element otherwise
# $3..$#: remainder of the log record
@go.log() {
local args=("$@")
local log_level="${args[0]^^}"
local formatted_log_level
local level_fd=1
local exit_status=0
local close_code='\e[0m'
local echo_mode='-e'

unset 'args[0]'
_@go.log_init

local __go_log_level_index=0
if ! _@go.log_level_index "$log_level"; then
@go.log ERROR '' "Unknown log level $log_level; defaulting to WARN"
@go.log WARN "${args[@]}"
return 1
fi

formatted_log_level="${__GO_LOG_LEVELS_FORMATTED[$__go_log_level_index]}"
level_fd="${__GO_LOG_LEVELS_FILE_DESCRIPTORS[$__go_log_level_index]}"

if [[ "$log_level" =~ ERROR|FATAL ]]; then
exit_status="${args[1]}"
unset 'args[1]'

if [[ -n "$exit_status" ]]; then
args+=("(exit status $exit_status)")
else
exit_status=1
fi
fi

if [[ ! -t "$level_fd" && -z "$_GO_LOG_FORMATTING" ]]; then
echo_mode='-E'
args=("${args[@]//\\e\[[0-9]m}")
args=("${args[@]//\\e\[[0-9][0-9]m}")
args=("${args[@]//\\e\[[0-9][0-9][0-9]m}")
close_code=''
fi

echo "$echo_mode" "$formatted_log_level ${args[*]}$close_code" >&"$level_fd"

if [[ "$log_level" == FATAL ]]; then
exit "$exit_status"
fi
return "$exit_status"
}

# Sets @go.log_command to log FATAL when its command exits with an error status.
@go.critical_section_begin() {
_GO_CRITICAL_SECTION='true'
}

# Sets @go.log_command to log ERROR when its command exits with an error status.
@go.critical_section_end() {
_GO_CRITICAL_SECTION=
}

# Logs the specified command and its outcome.
#
# By default it will log ERROR and return the command's exit status on failure.
# In between calls to @go.critical_section_begin and @go.critical_section_end,
# it will instead log FATAL and exit the process with the command's status code.
#
# Arguments:
# The command and its arguments to log and execute
@go.log_command() {
local args=("$@")
local cmd_string="${args[*]}"
local err_msg
local exit_status

if [[ "${args[0]}" == '@go' ]]; then
cmd_string="$_GO_CMD ${args[*]:1}"
fi
@go.log RUN "$cmd_string"

if [[ -n "$_GO_DRY_RUN" ]]; then
return
fi

"${args[@]}"
exit_status="$?"

if [[ "$exit_status" -ne '0' ]]; then
if [[ -n "$_GO_CRITICAL_SECTION" ]]; then
@go.log FATAL "$exit_status" "$err_msg"
fi
@go.log ERROR "$exit_status" "$err_msg"
return "$exit_status"
fi
}

# See @go.add_or_update_log_level in go-core.bash for description.
# Adds a new log level or updates an existing one.
#
# If you wish to keep the existing format code or file descriptor, specify
# 'keep' as the second argument or third argument, respectively.
#
# Arguments:
# $1: The log level label; will be converted to all-uppercase
# $2: The terminal format code that should precede the label
# $3: The file descriptor to which to output level messages (defaults to 1)
@go.add_or_update_log_level() {
local log_level="${1^^}"
local format_code="$2"
local level_fd="$3"
if [[ -z "$level_fd" ]]; then
level_fd=1
fi

if [[ -n "$__GO_LOG_INIT" ]]; then
@go.log 'FATAL' '' "Can't set logging level $log_level; already intialized"
fi

local __go_log_level_index=0
if ! _@go.log_level_index "$log_level"; then
if [[ "$format_code" == 'keep' || "$level_fd" == 'keep' ]]; then
echo "Can't keep defaults for nonexistent log level: $log_level" >&2
exit 1
fi
__go_log_level_index="${#_GO_LOG_LEVELS[@]}"
__GO_LOG_LEVELS+=("$log_level")
__GO_LOG_LEVELS_FORMAT_CODES+=("$format_code")
__GO_LOG_LEVELS_FILE_DESCRIPTORS+=("$level_fd")
return
fi

__GO_LOG_LEVELS[$__go_log_level_index]="$log_level"

if [[ "$format_code" != 'keep' ]]; then
__GO_LOG_LEVELS_FORMAT_CODES[$__go_log_level_index]="$format_code"
fi
if [[ "$level_fd" != 'keep' ]]; then
__GO_LOG_LEVELS_FILE_DESCRIPTORS[$__go_log_level_index]="$level_fd"
fi
}

# Runs the project's 'setup' script and logs the result.
#
# Helps with automating first-time setup upon running the script in a
# freshly-cloned repository for the first time.
#
# You must have an executable script called 'setup' in your top-level script
# directory before calling this function. If the script returns an error, the
# process will exit with the status code returned by the script.
#
# For example, in a Node.js project, your ./go script may include the following:
#
# export PATH="node_modules/.bin:$PATH"
# if [[ ! -d 'node_modules' ]]; then
# @go.setup_project
# else
# @go "$@"
# fi
#
# And your 'setup' script may include (assuming your own 'test' script as well):
#
# @go.critical_section_begin
# @go.log_command npm install
# @go.log_command @go test
# @go.critical_section_end
#
# Arguments:
# Any arguments to pass through to the 'setup' script
@go.setup_project() {
local setup_script="$_GO_SCRIPTS_DIR/setup"
local setup_status

@go.log START Project setup in "$_GO_ROOTDIR"

if [[ ! -f "$setup_script" ]]; then
@go.log FATAL 1 "Create $setup_script before invoking $FUNCNAME."
elif [[ ! -x "$setup_script" ]]; then
@go.log FATAL 1 "$setup_script is not executable."
fi

@go.log RUN "${setup_script#$_GO_ROOTDIR/}" "$@"
_@go.run_command_script "$setup_script" "$@"
setup_status="$?"

if [[ "$setup_status" -ne '0' ]]; then
@go.log FATAL "$setup_status" "Project setup failed"
fi

@go.log FINISH Project setup successful
@go.log INFO "Run \`$0 help\` to see the available commands."

if [[ "$_GO_CMD" == "$0" ]]; then
@go.log INFO \
"Run \`$0 help env\` to see how to set up your shell environment" \
'for this project.'
fi
}

_@go.log_init() {
if [[ -z "$__GO_LOG_INIT" ]]; then
_@go.log_format_level_labels
readonly __GO_LOG_INIT='done'
fi
}

_@go.log_format_level_labels() {
local num_levels="${#_GO_LOG_LEVELS[@]}"
local label_length
local longest_label_length
local padding=''
local log_level
local padding_len
local level_var
local level_fd
local i

for ((i=0; i != num_levels; ++i)); do
label_length="${#_GO_LOG_LEVELS[$i]}"
if [[ "$label_length" -gt "$longest_label_length" ]]; then
longest_label_length="$label_length"
fi
done

for ((i=0; i != longest_label_length; ++i)); do
padding+=' '
done

for ((i=0; i != num_levels; ++i)); do
log_level="${_GO_LOG_LEVELS[$i]}"
padding_len="$((${#padding} - ${#log_level}))"
level_fd="${__GO_LOG_LEVELS_FILE_DESCRIPTORS[$i]}"

if [[ -n "$_GO_LOG_FORMATTING" || -t "$level_fd" ]]; then
log_level="${__GO_LOG_LEVELS_FORMAT_CODES[$i]}$log_level\e[0m"
fi
__GO_LOG_LEVELS_FORMATTED[$i]="${log_level}${padding:0:$padding_len}"
done
}

_@go.log_level_index() {
local i
for ((i=0; i != "${#_GO_LOG_LEVELS[@]}"; ++i)); do
if [[ "${_GO_LOG_LEVELS[$i]}" == "$1" ]]; then
__go_log_level_index="$i"
return
fi
done
return 1
}

_@go.log_load() {
local num_levels="${#_GO_LOG_LEVELS[@]}"

if [[ "${#__GO_LOG_LEVELS_FORMAT_CODES[@]}" != "$num_levels" ]]; then
echo "Should have $num_levels log level format codes, " \
"only have ${#__GO_LOG_LEVELS_FORMAT_CODES[@]}" >&2
exit 1
elif [[ "${#__GO_LOG_LEVELS_FILE_DESCRIPTORS[@]}" != "$num_levels" ]]; then
echo "Should have $num_levels log level file descriptors, " \
"only have ${#__GO_LOG_LEVELS_FILE_DESCRIPTORS[@]}" >&2
exit 1
fi
}

_@go.log_load

0 comments on commit 1984558

Please sign in to comment.