# $ $Revision: 1.15 $ $Date: 2025/12/21 20:49:08 $  vim: set expandtab ts=2 ai

#--------------------------------------------------------------------------
# Carp.bash -- Bash functions similar to Perl's die, warn, and Carp routines,
# with backtraces.  Jim Avera 1/23/2012, r/18/2018.  
# Released into the Public Domain.
#--------------------------------------------------------------------------
# SYNOPSIS: 
#        source /path/to/Carp.bash
#        function myfunc() {
#          if [ some error condition ] ; then
#             confess "Oops!"  # abort with backtrace to stderr
#          fi
#        }
#
#        trap_command_failures
#        cat /wrong/file/path  # aborts with backtrace showing location
#--------------------------------------------------------------------------

Carp_fd=2
Carp_maxframes=32

# (internal function) split Bash built-in 'caller' into array of three 
# elements: (lno funcname sourcepath)
# USAGE: get_caller varname caller_level
function get_caller() {
  local -n result="$1" # nameref
  local level="$2"; ((level++)) # compensate for the call to us
  # Fortunately the first two can never contain embedded spaces, so we can
  # assume any spaces after the second space are part of the sourcepath
  local -a words=($(caller $level))
  result=(${words[0]} ${words[1]} "${words[*]:2}")
}

# (internal function) backtrace [skipframes maxframes]
#   Print a call trace to $Carp_fd, omitting the top <skipframes> frames and 
#   including at most <maxframes> (default limit is $Carp_maxframes).
#   As a sanity check, <skipframes> is ignored if no frames would be shown.
function backtrace() {
  local nskip=${1:-0} nmax=${2:-${bt_nmaxrames:-100}}
  local saved=$- ; set +e # undo set -e

  [[ nskip -lt ${#BASH_LINENO[@]}-1 ]] || { # Show all rather than nothing
    # Arrgh! bash omits the outermost frame if the script is piped on stdin.
    # So $nskip == ${#BASH_LINENO[@]}-1 may happen, i.e. no output
    ##echo "[bug: nskip=$nskip but #BASH_LINENO[@]=${#BASH_LINENO[@]}]" >&$Carp_fd
    nskip=0
    nmax=${Carp_maxframes:-9999}
  }
  local showpwd=""

  local level
  for ((level=nskip; level<${#BASH_LINENO[@]}-1; level++)) ; do
    ((level < nskip+nmax)) || { 
      # Warn of omitted frames if the call did not explicitly specify a limit
      [ -n "$2" ] || echo "(additional frames not shown)" >&$Carp_fd; 
      break; 
    }

    [ $level -eq $nskip ] || echo -n "  called from " >&$Carp_fd; 
    local -a ccc; get_caller ccc $level; echo "${ccc[2]}:${ccc[0]} (in '${ccc[1]}')";

    if [ "${BASH_SOURCE[$level]}" != "$(realpath "${BASH_SOURCE[$level]}")" ] ; then
      showpwd=yes
    fi
  done
  [ -z "$showpwd" ] || echo "(PWD is $PWD)" >&$Carp_fd
  case "$saved" in *e*) set -e ;; esac # restore if necessary
}

# call a function with args after interpolating \-escapes in the args
# $skip_levels is incr/decremented +/-2 around the call.
function call_with_escapes() {
  declare -a args
  local a
  for a in "$@" ; do
    local str # must printf to non-array (bug in bash 4.2.10)
    printf -v str "%b" "$a"
    #bug?? args=(${args[0]+"${args[@]}"} "$str")
    args+=("$str")
  done
  declare -i skip_levels=${skip_levels:-0}+2  # local, i.e., undone at return
  "${args[@]}"
}

# The "regular" warn, etc. interpolate embedded \t \n etc. in their arguments
# similar to "echo -e", except that multiple arguments are concatenated without
# intervening spaces.  The *q versions (e.g. warnq) do not interpolate.
function warn()    { call_with_escapes warnq    ${1+"$@"}; }
function die()     { call_with_escapes dieq     ${1+"$@"}; }
function carp()    { call_with_escapes carpq    ${1+"$@"}; }
function cluck()   { call_with_escapes cluckq   ${1+"$@"}; }
function confess() { call_with_escapes confessq ${1+"$@"}; }

# 'warn' -- Print a message to stderr, interpolating \-escapes.  
# If the message does not end with a newline, then the location of the
# call is appended to the message followed by a newline.
# 
# 'warnq' is like warn but does not interpolate \-escapes.
# (similarly for dieq, cluckq, confessq, etc.)
function warnq() {
  declare -i nskip=1+${skip_levels:-0}
  { (IFS=""; echo -n -E "$*") >&$Carp_fd  # concat without spaces
    [[ "$*" == *"
"   ]] || { echo -n " at line " >&$Carp_fd; backtrace $nskip 1; }
  } >&2
}

# 'die' is like 'warn' but is fatal!
function dieq() {
  declare -i skip_levels=${skip_levels:-0}+1
  warnq ${1+"$@"}
  trap - EXIT; exit 255;
}

# 'carp' is like 'warn' but appends the location of -your- caller, or, 
# if $carp_levels is set, that many additional levels back in stack.
# (Unlike 'warn', the location is unconditionally appended).
function carpq() {
  declare -i nskip=${skip_levels:-0}+${carp_level:-0}+2
  [[ nskip -lt ${#BASH_LINENO[@]} ]] || ((nskip--)) # called from main?
  { echo -n -E "$* at line " >&$Carp_fd; backtrace $nskip 1; } >&2
}

# 'cluck' is Like 'carp' but with a full call trace.
function cluckq() {
  declare -i nskip=${skip_levels:-0}+${carp_level:-0}+2
  [[ nskip -lt ${#BASH_LINENO[@]} ]] || ((nskip--)) # called from main?
  { echo -n -E "$* at line " >&$Carp_fd; backtrace $nskip;   } >&2
}

# 'confess' aborts with a message and full call trace.
# Unlike Perl's Carp::confess, the trace always includes every frame
# (there is no "croak").
function confessq() { 
  [ -n "${1+*}" ] || set -- "Fatal error"
  #declare -i carp_level=${carp_level:-0}+1 # to omit confess' caller
  cluckq ${1+"$@"}
  trap - EXIT
  exit 255 
}

# Arrange to abort if any command fails, telling you where it happened.
#  Note: The ERR trap and set -e do not work inside (subshells expression) 
#  which are invoked in a conditional context e.g. "(cause error) || something"
function trap_command_failures() {
  eval "set -o errexit -o errtrace"
  trap 'e=$?; cluckq "Uncaught error"; trap - EXIT; exit $e' ERR
}

# Un-do the effect of trap_command_failures
function dont_trap_command_failures() {
  eval "set +o errexit +o errtrace"
  trap ERR
}

##!/usr/bin/env bash
## TESTER
#set -u  # abort if an unset variable is interpolated
#set -e  # abort if any command fails outside of a conditional test
#source $HOME/lib/Carp.bash
#
#warnq "t1 warnq from main with ending newline (should OMIT loc)
#";
#warn "t2 warn from main with ending backslash-n (should OMIT loc)\n";
#warn "t3 warn from main with ending newline (should OMIT loc)
#";
#warnq "t4 warnq\f\tfrom main (should show line $LINENO)"
#warn "t5 warn\f\t from main (should show ff,tab and line $LINENO)"
#warn "t6 warn from main (should show line $LINENO)"
#function failed_to_abort() {
#  echo -e "EXPECTED ABORT DID NOT OCCUR AT OR ABOVE LINE \c"; caller 0
#  exit 1;
#}
#function inner() {
#  warnq "Entered inner (should show line $LINENO)"
#  carp "carp-from-inner (should show call in outer)"
#  cluck "cluck-from-inner (should show call in outer)"
#  (confess "confess-from-inner (should show line $LINENO)") \
#    && failed_to_abort || true
#  warn "Leaving inner (should show line $LINENO)"
#}
#function outer() {
#  warn "Entered outer (should show line $LINENO)"
#  inner ${1+"$@"}
#  (confess "confess-from-outer (should show line $LINENO)") && failed_to_abort || true
#  warn "Leaving outer"
#}
#function cause_error() {
#  echo "> cat /cause/an/error" >&2
#  cat /cause/an/error
#}
#outer "t7 arg 1" "arg 2"
#
#carpq "t8 carpq from main (should show line $LINENO)"
#carp "t9 carp from main (should show line $LINENO)"
#cluck "t10 cluck from main (should show line $LINENO)"
#
#(die "t11 die-from-main (should show line $LINENO)") \
#  && failed_to_abort || true
#
#(confess "t12 confess-from-main (should show line $LINENO)") \
#  && failed_to_abort || true
#
## A "feature" (IMO bug) in bash prevents set -e from working in 
## a subshell which is part of a conditional, e.g. "(...) && failed_to_abort ..."
##
#trap 'e=$?; set +e; set -x; [ -z "$tfile" ] || rm "$tfile"; exit $e' 0 1 2 15
#tfile=$(mktemp /tmp/Carpbash_XXXXX)
#set +e
#((expected_line=$LINENO + 1))
#(trap_command_failures; sh -c 'exit 42') >"$tfile" 2>&1
#status=$?
#set -e
#if [ $status -eq 0 ] ; then
#  echo "BUG1: trap_command_failures failed to trap uncaught error" >&2
#  (set -x; cat $tfile) 2>&1
#  exit 22
#elif [ $status -ne 42 ] ; then
#  echo "BUG2: trap_command_failures did not propagate exit status" >&2
#  (set -x; cat $tfile) 2>&1
#  exit 33
#fi
#if ! grep "Uncaught error.*[^a-zA-Z_]$expected_line" "$tfile" >/dev/null ; then
#  echo "BUG3: trap_command_failures - unexpected message/line number != $expected_line" >&2
#  (set -x; cat $tfile) 2>&1
#  exit 44
#fi
#(echo "trap_command_failures worked correctly:"; cat $tfile) >&$Carp_fd
#rm $tfile
#trap 0
#
#warn "End of tests!\n"
