diff --git a/bash-preexec.sh b/bash-preexec.sh index e0d2fa0..6b199ba 100644 --- a/bash-preexec.sh +++ b/bash-preexec.sh @@ -71,7 +71,7 @@ __bp_inside_precmd=0 __bp_inside_preexec=0 # Initial PROMPT_COMMAND string that is removed from PROMPT_COMMAND post __bp_install -__bp_install_string=$'__bp_trap_string="$(trap -p DEBUG)"\ntrap - DEBUG\n__bp_install' +__bp_install_string='__bp_install' # Fails if any of the given variables are readonly # Reference https://stackoverflow.com/a/4441178 @@ -157,21 +157,38 @@ __bp_precmd_invoke_cmd() { return fi local __bp_inside_precmd=1 + __bp_invoke_precmd_functions "$__bp_last_ret_value" "$__bp_last_argument_prev_command" + __bp_set_ret_value "$__bp_last_ret_value" "$__bp_last_argument_prev_command" +} + +# This function invokes every function defined in our function array +# "precmd_function". This function receives the arguments $1 and $2 for $? and +# $_, respectively, which will be set for each precmd function. This function +# returns the last non-zero exit status of the hook functions. If there is no +# error, this function returns 0. +__bp_invoke_precmd_functions() { + local lastexit=$1 lastarg=$2 # Invoke every function defined in our function array. local precmd_function + local precmd_function_ret_value + local precmd_ret_value=0 for precmd_function in "${precmd_functions[@]}"; do # Only execute this function if it actually exists. # Test existence of functions with: declare -[Ff] if type -t "$precmd_function" 1>/dev/null; then - __bp_set_ret_value "$__bp_last_ret_value" "$__bp_last_argument_prev_command" + __bp_set_ret_value "$lastexit" "$lastarg" # Quote our function invocation to prevent issues with IFS "$precmd_function" + precmd_function_ret_value=$? + if [[ "$precmd_function_ret_value" != 0 ]]; then + precmd_ret_value="$precmd_function_ret_value" + fi fi done - __bp_set_ret_value "$__bp_last_ret_value" + __bp_set_ret_value "$precmd_ret_value" } # Sets a return value in $?. We may want to get access to the $? variable in our @@ -200,6 +217,14 @@ __bp_in_prompt_command() { return 1 } +__bp_load_this_command_from_history() { + this_command=$(LC_ALL=C HISTTIMEFORMAT='' builtin history 1) + this_command="${this_command#*[[:digit:]][* ] }" + + # Sanity check to make sure we have something to invoke our function with. + [[ -n "$this_command" ]] +} + # This function is installed as the DEBUG trap. It is invoked before each # interactive prompt display. Its purpose is to inspect the current # environment to attempt to detect if the current command is being invoked @@ -250,15 +275,38 @@ __bp_preexec_invoke_exec() { fi local this_command - this_command=$(LC_ALL=C HISTTIMEFORMAT='' builtin history 1) - this_command="${this_command#*[[:digit:]][* ] }" + __bp_load_this_command_from_history || return - # Sanity check to make sure we have something to invoke our function with. - if [[ -z "$this_command" ]]; then - return - fi + __bp_invoke_preexec_functions "${__bp_last_ret_value:-}" "$__bp_last_argument_prev_command" "$this_command" + local preexec_ret_value=$? - # Invoke every function defined in our function array. + # Restore the last argument of the last executed command, and set the return + # value of the DEBUG trap to be the return code of the last preexec function + # to return an error. + # If `extdebug` is enabled a non-zero return value from any preexec function + # will cause the user's command not to execute. + # Run `shopt -s extdebug` to enable + __bp_set_ret_value "$preexec_ret_value" "$__bp_last_argument_prev_command" +} + +__bp_invoke_preexec_from_ps0() { + __bp_last_argument_prev_command="${1:-}" + + local this_command + __bp_load_this_command_from_history || return + + __bp_invoke_preexec_functions "${__bp_last_ret_value:-}" "$__bp_last_argument_prev_command" "$this_command" +} + +# This function invokes every function defined in our function array +# "preexec_function". This function receives the arguments $1 and $2 for $? +# and $_, respectively, which will be set for each preexec function. The third +# argument $3 specifies the user command that is going to be executed +# (corresponding to BASH_COMMAND in the DEBUG trap). This function returns the +# last non-zero exit status from the preexec functions. If there is no error, +# this function returns `0`. +__bp_invoke_preexec_functions() { + local lastexit=$1 lastarg=$2 this_command=$3 local preexec_function local preexec_function_ret_value local preexec_ret_value=0 @@ -267,7 +315,7 @@ __bp_preexec_invoke_exec() { # Only execute each function if it actually exists. # Test existence of function with: declare -[fF] if type -t "$preexec_function" 1>/dev/null; then - __bp_set_ret_value "${__bp_last_ret_value:-}" + __bp_set_ret_value "$lastexit" "$lastarg" # Quote our function invocation to prevent issues with IFS "$preexec_function" "$this_command" preexec_function_ret_value="$?" @@ -276,28 +324,17 @@ __bp_preexec_invoke_exec() { fi fi done - - # Restore the last argument of the last executed command, and set the return - # value of the DEBUG trap to be the return code of the last preexec function - # to return an error. - # If `extdebug` is enabled a non-zero return value from any preexec function - # will cause the user's command not to execute. - # Run `shopt -s extdebug` to enable - __bp_set_ret_value "$preexec_ret_value" "$__bp_last_argument_prev_command" + __bp_set_ret_value "$preexec_ret_value" } -__bp_install() { - # Exit if we already have this installed. - if [[ "${PROMPT_COMMAND[*]:-}" == *"__bp_precmd_invoke_cmd"* ]]; then - return 1 - fi - +__bp_hook_preexec_into_debug() { + local trap_string + trap_string=$(trap -p DEBUG) trap '__bp_preexec_invoke_exec "$_"' DEBUG # Preserve any prior DEBUG trap as a preexec function - eval "local trap_argv=(${__bp_trap_string:-})" + eval "local trap_argv=(${trap_string:-})" local prior_trap=${trap_argv[2]:-} - unset __bp_trap_string if [[ -n "$prior_trap" ]]; then eval '__bp_original_debug_trap() { '"$prior_trap"' @@ -318,6 +355,27 @@ __bp_install() { set -o functrace > /dev/null 2>&1 shopt -s extdebug > /dev/null 2>&1 fi +} + +__bp_hook_preexec_into_ps0() { + # shellcheck disable=SC2016 + PS0=${PS0-}'${ __bp_invoke_preexec_from_ps0 "$_"; }' + + # Adjust our HISTCONTROL Variable if needed. + __bp_adjust_histcontrol +} + +__bp_install() { + # Exit if we already have this installed. + if [[ "${PROMPT_COMMAND[*]:-}" == *"__bp_precmd_invoke_cmd"* ]]; then + return 1 + fi + + if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 3) )); then + __bp_hook_preexec_into_ps0 + else + __bp_hook_preexec_into_debug + fi local existing_prompt_command # Remove setting our trap install string and sanitize the existing prompt command string @@ -352,6 +410,11 @@ __bp_install() { __bp_interactive_mode } +# Note: We need to add "trace" attribute to the function so that "trap +# ... DEBUG" inside "__bp_install" takes an effect even when there was an +# existing DEBUG trap. +declare -ft __bp_install __bp_hook_preexec_into_debug + # Sets an installation string as part of our PROMPT_COMMAND to install # after our session has started. This allows bash-preexec to be included # at any point in our bash profile. diff --git a/test/bash-preexec.bats b/test/bash-preexec.bats index 7f2ed8b..c2d8065 100644 --- a/test/bash-preexec.bats +++ b/test/bash-preexec.bats @@ -94,8 +94,11 @@ set_exit_code_and_run_precmd() { bp_install trap_count_snapshot=$trap_invoked_count - [ "$(trap -p DEBUG | cut -d' ' -f3)" == "'__bp_preexec_invoke_exec" ] - [[ "${preexec_functions[*]}" == *"__bp_original_debug_trap"* ]] || return 1 + if (( BASH_VERSINFO[0] < 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] < 3) )); then + # We override the DEBUG trap in Bash < 5.3 + [ "$(trap -p DEBUG | cut -d' ' -f3)" == "'__bp_preexec_invoke_exec" ] + [[ "${preexec_functions[*]}" == *"__bp_original_debug_trap"* ]] || return 1 + fi __bp_interactive_mode # triggers the DEBUG trap @@ -328,6 +331,76 @@ set_exit_code_and_run_precmd() { [ $status -eq 1 ] } +@test "__bp_invoke_precmd_functions should be transparent for \$? and \$_" { + tester1() { test1_lastexit=$? test1_lastarg=$_; } + tester2() { test2_lastexit=$? test2_lastarg=$_; } + precmd_functions=(tester1 tester2) + trap - DEBUG # remove the Bats stack-trace trap so $_ doesn't get overwritten + __bp_invoke_precmd_functions 111 'vxxJlwNx9VPJDA' || true + + [ "$test1_lastexit" == 111 ] + [ "$test1_lastarg" == 'vxxJlwNx9VPJDA' ] + [ "$test2_lastexit" == 111 ] + [ "$test2_lastarg" == 'vxxJlwNx9VPJDA' ] +} + +@test "__bp_invoke_precmd_functions returns the last non-zero exit status" { + tester1() { return 91; } + tester2() { return 38; } + tester3() { return 0; } + precmd_functions=(tester1 tester2 tester3) + status=0 + __bp_invoke_precmd_functions 1 'lastarg' || status=$? + + [ "$status" == 38 ] + + precmd_functions=(tester3) + status=0 + __bp_invoke_precmd_functions 1 'lastarg' || status=$? + + [ "$status" == 0 ] +} + +@test "__bp_invoke_preexec_functions should be transparent for \$? and \$_" { + tester1() { test1_lastexit=$? test1_lastarg=$_; } + tester2() { test2_lastexit=$? test2_lastarg=$_; } + preexec_functions=(tester1 tester2) + trap - DEBUG # remove the Bats stack-trace trap so $_ doesn't get overwritten + __bp_invoke_preexec_functions 87 'ehQrzHTHtE2E7Q' 'command' || true + + [ "$test1_lastexit" == 87 ] + [ "$test1_lastarg" == 'ehQrzHTHtE2E7Q' ] + [ "$test2_lastexit" == 87 ] + [ "$test2_lastarg" == 'ehQrzHTHtE2E7Q' ] +} + +@test "__bp_invoke_preexec_functions returns the last non-zero exit status" { + tester1() { return 52; } + tester2() { return 112; } + tester3() { return 0; } + preexec_functions=(tester1 tester2 tester3) + status=0 + __bp_invoke_preexec_functions 1 'lastarg' 'command' || status=$? + + [ "$status" == 112 ] + + preexec_functions=(tester3) + status=0 + __bp_invoke_preexec_functions 1 'lastarg' 'command' || status=$? + + [ "$status" == 0 ] +} + +@test "__bp_invoke_preexec_functions should supply a current command in the first argument" { + tester1() { test1_bash_command=$1; } + tester2() { test2_bash_command=$1; } + preexec_functions=(tester1 tester2) + __bp_invoke_preexec_functions 1 'lastarg' 'UEVkErELArSwjA' || true + + [ "$test1_bash_command" == 'UEVkErELArSwjA' ] + [ "$test2_bash_command" == 'UEVkErELArSwjA' ] +} + @test "in_prompt_command should detect if a command is part of PROMPT_COMMAND" { PROMPT_COMMAND=$'precmd_invoke_cmd\n something; echo yo\n __bp_interactive_mode'