Merge pull request #166 from akinomyoga/PS0-funsub

Use a function substitution in `$PS0` for preexec in Bash >= 5.3
This commit is contained in:
Koichi Murase 2026-03-30 22:51:26 +09:00 committed by GitHub
commit aab0589add
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 234 additions and 54 deletions

View file

@ -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
@ -143,12 +143,14 @@ __bp_interactive_mode() {
# This function is installed as part of the PROMPT_COMMAND.
# It will invoke any functions defined in the precmd_functions array.
__bp_precmd_invoke_cmd() {
# Save the returned value from our last command, and from each process in
# its pipeline. Note: this MUST be the first thing done in this function.
# Save the returned value and the last argument from our last command, and
# the returned value from each process in its pipeline. Note: this MUST be
# the first thing done in this function.
# BP_PIPESTATUS may be unused, ignore
# shellcheck disable=SC2034
__bp_last_ret_value="$?" __bp_last_argument_prev_command="$_" \
BP_PIPESTATUS=("${PIPESTATUS[@]}")
__bp_last_ret_value="$?" BP_PIPESTATUS=("${PIPESTATUS[@]}")
# Don't invoke precmds if we are inside an execution of an "original
# prompt command" by another precmd execution loop. This avoids infinite
@ -157,21 +159,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 the "precmd_functions" array.
# 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,15 +219,21 @@ __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
# interactively, and invoke 'preexec' if so.
__bp_preexec_invoke_exec() {
local lastarg=$_
# Save the contents of $_ so that it can be restored later on.
# https://stackoverflow.com/questions/40944532/bash-preserve-in-a-debug-trap#40944702
__bp_last_argument_prev_command="${1:-}"
# Don't invoke preexecs if we are inside of another preexec.
if (( __bp_inside_preexec > 0 )); then
return
@ -249,33 +274,15 @@ __bp_preexec_invoke_exec() {
return
fi
# Save the contents of $_ so that it can be restored later on.
# https://stackoverflow.com/questions/40944532/bash-preserve-in-a-debug-trap#40944702
__bp_last_argument_prev_command=$lastarg
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
# Invoke every function defined in our function array.
local preexec_function
local preexec_function_ret_value
local preexec_ret_value=0
for preexec_function in "${preexec_functions[@]:-}"; do
# 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:-}"
# Quote our function invocation to prevent issues with IFS
"$preexec_function" "$this_command"
preexec_function_ret_value="$?"
if [[ "$preexec_function_ret_value" != 0 ]]; then
preexec_ret_value="$preexec_function_ret_value"
fi
fi
done
__bp_invoke_preexec_functions "${__bp_last_ret_value:-}" "$__bp_last_argument_prev_command" "$this_command"
local preexec_ret_value=$?
# 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
@ -286,18 +293,52 @@ __bp_preexec_invoke_exec() {
__bp_set_ret_value "$preexec_ret_value" "$__bp_last_argument_prev_command"
}
__bp_install() {
# Exit if we already have this installed.
if [[ "${PROMPT_COMMAND[*]:-}" == *"__bp_precmd_invoke_cmd"* ]]; then
return 1
fi
__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 the "preexec_functions"
# array. 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
for preexec_function in "${preexec_functions[@]:-}"; do
# 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 "$lastexit" "$lastarg"
# Quote our function invocation to prevent issues with IFS
"$preexec_function" "$this_command"
preexec_function_ret_value="$?"
if [[ "$preexec_function_ret_value" != 0 ]]; then
preexec_ret_value="$preexec_function_ret_value"
fi
fi
done
__bp_set_ret_value "$preexec_ret_value"
}
__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 +359,30 @@ __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 "$_" >&2; }'
# Adjust our HISTCONTROL Variable if needed.
__bp_adjust_histcontrol
}
if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 3) )); then
__bp_hook_preexec_proc=__bp_hook_preexec_into_ps0
else
__bp_hook_preexec_proc=__bp_hook_preexec_into_debug
fi
__bp_install() {
local lastexit=$? lastarg=$_
# Exit if we already have this installed.
if [[ "${PROMPT_COMMAND[*]:-}" == *'__bp_precmd_invoke_cmd "$_"'* ]]; then
return 1
fi
"$__bp_hook_preexec_proc"
local existing_prompt_command
# Remove setting our trap install string and sanitize the existing prompt command string
@ -333,7 +398,7 @@ __bp_install() {
# Install our hooks in PROMPT_COMMAND to allow our trap to know when we've
# actually entered something.
PROMPT_COMMAND='__bp_precmd_invoke_cmd'
PROMPT_COMMAND='__bp_precmd_invoke_cmd "$_"'
PROMPT_COMMAND+=${existing_prompt_command:+$'\n'$existing_prompt_command}
if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 1) )); then
PROMPT_COMMAND+=('__bp_interactive_mode')
@ -348,10 +413,16 @@ __bp_install() {
preexec_functions+=(preexec)
# Invoke our two functions manually that were added to $PROMPT_COMMAND
__bp_set_ret_value "$lastexit" "$lastarg"
__bp_precmd_invoke_cmd
__bp_interactive_mode
}
# Note: We need to add the "trace" attribute to these functions so that "trap
# ... DEBUG" inside "__bp_install" and "__bp_hook_preexec_into_debug" takes
# effect even when there is 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.
@ -359,6 +430,9 @@ __bp_install_after_session_init() {
# bash-preexec needs to modify these variables in order to work correctly
# if it can't, just stop the installation
__bp_require_not_readonly PROMPT_COMMAND HISTCONTROL HISTTIMEFORMAT || return
if [[ $__bp_hook_preexec_proc == '__bp_hook_preexec_into_ps0' ]]; then
__bp_require_not_readonly PS0 || return
fi
local sanitized_prompt_command
__bp_sanitize_string sanitized_prompt_command "${PROMPT_COMMAND:-}"

View file

@ -41,7 +41,7 @@ return_exit_code() {
}
set_exit_code_and_run_precmd() {
return_exit_code ${1:-0}
return_exit_code "${1:-0}" "${2-$_}"
__bp_precmd_invoke_cmd
}
@ -89,13 +89,21 @@ set_exit_code_and_run_precmd() {
# note setting this causes BATS to mis-report the failure line when this test fails
trap foo DEBUG
[ "$(trap -p DEBUG | cut -d' ' -f3)" == "'foo'" ]
original_debug_trap=$(trap -p DEBUG)
[ "$(cut -d' ' -f3 <<< "$original_debug_trap")" == "'foo'" ]
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 [[ $__bp_hook_preexec_proc == '__bp_hook_preexec_into_debug' ]]; then
# We override the DEBUG trap with the DEBUG approach to preexec
[ "$(trap -p DEBUG | cut -d' ' -f3)" == "'__bp_preexec_invoke_exec" ]
[[ "${preexec_functions[*]}" == *"__bp_original_debug_trap"* ]] || return 1
[[ $(declare -f __bp_original_debug_trap) == *$'\n'" foo"$'\n'* ]] || return 1
else
# We do not modify the DEBUG trap in the other approaches
[ "$(trap -p DEBUG)" == "$original_debug_trap" ]
fi
__bp_interactive_mode # triggers the DEBUG trap
@ -107,15 +115,27 @@ set_exit_code_and_run_precmd() {
trap_invoked_count=0
foo() { (( trap_invoked_count += 1 )); }
# constants
single_quote="'" single_quote_escape="'\''"
original_trap_command="foo && echo 'hello' > /dev/null"
# note setting this causes BATS to mis-report the failure line when this test fails
trap "foo && echo 'hello' >/dev/null" debug
[ "$(trap -p DEBUG | cut -d' ' -f3-7)" == "'foo && echo '\''hello'\'' >/dev/null'" ]
trap "$original_trap_command" debug
original_debug_trap=$(trap -p DEBUG)
[ "$original_debug_trap" == "trap -- '${original_trap_command//$single_quote/$single_quote_escape}' DEBUG" ]
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 [[ $__bp_hook_preexec_proc == '__bp_hook_preexec_into_debug' ]]; then
# We override the DEBUG trap with the DEBUG approach to preexec
[ "$(trap -p DEBUG | cut -d' ' -f3)" == "'__bp_preexec_invoke_exec" ]
[[ "${preexec_functions[*]}" == *"__bp_original_debug_trap"* ]] || return 1
[[ $(declare -f __bp_original_debug_trap) == *$'\n'" $original_trap_command"$'\n'* ]] || return 1
else
# We do not modify the DEBUG trap in the other approaches
[ "$(trap -p DEBUG)" == "$original_debug_trap" ]
fi
__bp_interactive_mode # triggers the DEBUG trap
@ -123,6 +143,22 @@ set_exit_code_and_run_precmd() {
(( trap_count_snapshot < trap_invoked_count ))
}
@test "__bp_install should preserve an existing PS0" {
original_PS0=${PS0-}
bp_install
if [[ $__bp_hook_preexec_proc == '__bp_hook_preexec_into_ps0' ]]; then
# We modify PS0 with the PS0 approach to preexec, but the original contents
# of PS0 should be preserved.
[ "${PS0-}" != "$original_PS0" ]
[[ ${PS0-} == *"$original_PS0"* ]] || return 1
else
# We do not modify PS0 in the other approaches
[ "${PS0-}" == "$original_PS0" ]
fi
}
@test "__bp_sanitize_string should remove semicolons and trim space" {
__bp_sanitize_string output " true1; "$'\n'
@ -168,7 +204,7 @@ set_exit_code_and_run_precmd() {
eval_PROMPT_COMMAND
expected_result=$'__bp_precmd_invoke_cmd\necho after2; echo before; echo before2\n echo after\n__bp_interactive_mode'
expected_result=$'__bp_precmd_invoke_cmd "$_"\necho after2; echo before; echo before2\n echo after\n__bp_interactive_mode'
[ "$(join_PROMPT_COMMAND)" == "$expected_result" ]
}
@ -179,7 +215,7 @@ set_exit_code_and_run_precmd() {
eval_PROMPT_COMMAND
expected_result=$'__bp_precmd_invoke_cmd\necho before\n echo after\n__bp_interactive_mode'
expected_result=$'__bp_precmd_invoke_cmd "$_"\necho before\n echo after\n__bp_interactive_mode'
[ "$(join_PROMPT_COMMAND)" == "$expected_result" ]
}
@ -251,9 +287,9 @@ set_exit_code_and_run_precmd() {
bats_trap=$(trap -p DEBUG)
trap DEBUG # remove the Bats stack-trace trap so $_ doesn't get overwritten
: "last-arg"
__bp_preexec_invoke_exec "$_"
__bp_preexec_interactive_mode=1 __bp_preexec_invoke_exec "$_"
eval "$bats_trap" # Restore trap
run set_exit_code_and_run_precmd
run set_exit_code_and_run_precmd 0 "$__bp_last_argument_prev_command"
[ $status -eq 0 ]
[ "$output" == "last-arg" ]
}
@ -328,6 +364,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'