Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 90 additions & 27 deletions bash-preexec.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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="$?"
Expand All @@ -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"'
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
77 changes: 75 additions & 2 deletions test/bash-preexec.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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'
Expand Down