Merge pull request #143 from akinomyoga/adjust-PROMPT_COMMAND

Re-adjust `PROMPT_COMMAND` when `PROMPT_COMMAND` is modified elsewhere
This commit is contained in:
Koichi Murase 2026-04-07 12:23:45 +09:00 committed by GitHub
commit 23011bb11b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 187 additions and 25 deletions

View file

@ -71,6 +71,7 @@ __bp_inside_precmd=0
__bp_inside_preexec=0
# Initial PROMPT_COMMAND string that is removed from PROMPT_COMMAND post __bp_install
# shellcheck disable=SC2016
__bp_install_string='__bp_install "$_"'
# Fails if any of the given variables are readonly
@ -121,21 +122,93 @@ __bp_trim_whitespace() {
# Trims whitespace and removes any leading or trailing semicolons from $2 and
# writes the resulting string to the variable name passed as $1. Used for
# manipulating substrings in PROMPT_COMMAND
# writes the resulting string to the variable name passed as $1. This also
# removes the no-op colons, which are converted from the hooks to remove. Used
# for manipulating substrings in PROMPT_COMMAND
__bp_sanitize_string() {
local var=${1:?} text=${2:-} sanitized
__bp_trim_whitespace sanitized "$text"
local var=${1:?} sanitized=${2:-}
local unset_extglob=
if ! shopt -q extglob; then
unset_extglob=yes
shopt -s extglob
fi
# We specify newline character through the variable `nl' because $'\n'
# inside "${var//...}" is treated literally as "\$'\\n'" when `extquote' is
# unset (shopt -u extquote). (Note: Bash 5.2's extquote seems to be buggy.)
local tmp sp=$' \t' nl=$'\n'
while
# Quoting parameter expansions $nl in PAT of ${var//PAT/REP} is
# required by shellcheck. On the other hand, we should not quote the
# parameter expansions $nl in REP because the quotes will remain in the
# replaced result with `shopt -s compat42'.
tmp="${sanitized//[";$nl"]*(["$sp"]):*(["$sp"])[";$nl"]/$nl}"
[[ "$tmp" != "$sanitized" ]]
do
sanitized="$tmp"
done
sanitized="${sanitized#:*(["$sp"])[";$nl"]}"
sanitized="${sanitized%[";$nl"]*(["$sp"]):}"
__bp_trim_whitespace sanitized "$sanitized"
sanitized=${sanitized%;}
sanitized=${sanitized#;}
__bp_trim_whitespace sanitized "$sanitized"
if [[ "$sanitized" == ":" ]]; then
sanitized=
fi
printf -v "$var" '%s' "$sanitized"
if [[ -n "$unset_extglob" ]]; then
shopt -u extglob
fi
}
# Bash >= 5.1 supports the array version of PROMPT_COMMAND.
__bp_use_array_prompt_command() {
(( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 1) ))
}
# Remove $1 and sanitize each elements of PROMPT_COMMAND. We want to keep
# PROMPT_COMMAND scalar in bash < 5.1 because some configuration tests the
# support for the array PROMPT_COMMAND by checking the array attribute of
# PROMPT_COMMAND.
__bp_remove_command_from_prompt_command() {
local removed_command="${1-}"
if __bp_use_array_prompt_command; then
local i sanitized_prompt_command
for i in "${!PROMPT_COMMAND[@]}"; do
sanitized_prompt_command="${PROMPT_COMMAND[i]:-}"
sanitized_prompt_command="${sanitized_prompt_command//"$removed_command"/:}"
__bp_sanitize_string sanitized_prompt_command "$sanitized_prompt_command"
if [[ -n "$sanitized_prompt_command" ]]; then
PROMPT_COMMAND[i]="$sanitized_prompt_command"
else
unset -v 'PROMPT_COMMAND[i]'
fi
done
else
local sanitized_prompt_command="${PROMPT_COMMAND:-}"
sanitized_prompt_command="${sanitized_prompt_command//"$removed_command"/:}" # no-op
__bp_sanitize_string PROMPT_COMMAND "$sanitized_prompt_command"
fi
}
# This function is installed as part of the PROMPT_COMMAND;
# It sets a variable to indicate that the prompt was just displayed,
# to allow the DEBUG trap to know that the next command is likely interactive.
__bp_interactive_mode() {
if [[ "${1-}" != "force" && ! "${BATS_VERSION-}" ]] && (( ${#FUNCNAME[*]} > 1 )); then
# When this function is not called from the top level, the current
# function call is probably performed via PROMPT_COMMAND saved by
# another framework (e.g., starship). In this case, we do not want to
# turn on the "interactive mode" here.
return 0
fi
__bp_preexec_interactive_mode="on"
}
@ -158,6 +231,19 @@ __bp_precmd_invoke_cmd() {
if (( __bp_inside_precmd > 0 )); then
return
fi
# Check and adjust PROMPT_COMMAND to make sure that PROMPT_COMMAND has the
# form "__bp_precmd_invoke_cmd; ...; __bp_interactive_mode"
if ! __bp_install_prompt_command && [[ ! "${BATS_VERSION-}" ]] && (( ${#FUNCNAME[*]} > 1 )); then
# When PROMPT_COMMAND is already properly set up but this function is
# not called from the top level, the current function call is probably
# performed via PROMPT_COMMAND saved by another framework (e.g.,
# starship). In this case, we do not need to invoke precmd because it
# is supposed to be already processed by the top-level
# __bp_precmd_invoke_cmd.
return 0
fi
local __bp_inside_precmd=1
__bp_invoke_precmd_functions "$__bp_last_ret_value" "$__bp_last_argument_prev_command"
@ -378,34 +464,17 @@ fi
__bp_install() {
local lastexit=$? lastarg=$_
# Exit if we already have this installed.
# shellcheck disable=SC2016
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
existing_prompt_command="${PROMPT_COMMAND:-}"
# Edge case of appending to PROMPT_COMMAND
existing_prompt_command="${existing_prompt_command//$__bp_install_string/:}" # no-op
existing_prompt_command="${existing_prompt_command//$'\n':$'\n'/$'\n'}" # remove known-token only
existing_prompt_command="${existing_prompt_command//$'\n':;/$'\n'}" # remove known-token only
__bp_sanitize_string existing_prompt_command "$existing_prompt_command"
if [[ "${existing_prompt_command:-:}" == ":" ]]; then
existing_prompt_command=
fi
__bp_remove_command_from_prompt_command "$__bp_install_string"
# 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+=${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')
else
# shellcheck disable=SC2179 # PROMPT_COMMAND is not an array in bash <= 5.0
PROMPT_COMMAND+=$'\n__bp_interactive_mode'
fi
__bp_install_prompt_command || true
# Add two functions to our arrays for convenience
# of definition.
@ -415,7 +484,7 @@ __bp_install() {
# Invoke our two functions manually that were added to $PROMPT_COMMAND
__bp_set_ret_value "$lastexit" "$lastarg"
__bp_precmd_invoke_cmd
__bp_interactive_mode
__bp_interactive_mode force
}
# Note: We need to add the "trace" attribute to these functions so that "trap
@ -423,6 +492,42 @@ __bp_install() {
# effect even when there is an existing DEBUG trap.
declare -ft __bp_install __bp_hook_preexec_into_debug
# Encloses PROMPT_COMMAND hooks within __bp_precmd_invoke_cmd and
# __bp_interactive_mode. If all the PROMPT_COMMAND hooks are already surrounded
# by __bp_precmd_invoke_cmd and __bp_interactive_mode, the function exits with
# status 1.
__bp_install_prompt_command() {
local prompt_command="${PROMPT_COMMAND:-}"
if __bp_use_array_prompt_command; then
local IFS=$'\n'
prompt_command="${PROMPT_COMMAND[*]:-}"
IFS=$' \t\n'
fi
# Exit if we already have a properly set-up hooks in PROMPT_COMMAND
# shellcheck disable=SC2016
local prologue='__bp_precmd_invoke_cmd "$_"'
local epilogue='__bp_interactive_mode'
if [[ "$prompt_command" == "$prologue"$'\n'*$'\n'"$epilogue" ]]; then
return 1
fi
__bp_remove_command_from_prompt_command "$prologue"
__bp_remove_command_from_prompt_command "$epilogue"
# Install our hooks in PROMPT_COMMAND to allow our trap to know when we've
# actually entered something.
# shellcheck disable=SC2128,SC2178 # PROMPT_COMMAND is not an array in bash <= 5.0
PROMPT_COMMAND=$prologue${PROMPT_COMMAND:+$'\n'$PROMPT_COMMAND}
if __bp_use_array_prompt_command; then
PROMPT_COMMAND+=("$epilogue")
else
# shellcheck disable=SC2179 # PROMPT_COMMAND is not an array in bash <= 5.0
PROMPT_COMMAND+=$'\n'$epilogue
fi
return 0
}
# 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.

View file

@ -159,6 +159,30 @@ set_exit_code_and_run_precmd() {
fi
}
@test "__bp_install_prompt_command should adjust modified PROMPT_COMMAND" {
unset -v PROMPT_COMMAND
PROMPT_COMMAND="echo PREHOOK"
# First install
__bp_install_prompt_command
expected_result=$'__bp_precmd_invoke_cmd "$_"\necho PREHOOK\n__bp_interactive_mode'
[ "$(join_PROMPT_COMMAND)" == "$expected_result" ]
# User modification
if __bp_use_array_prompt_command; then
PROMPT_COMMAND+=('echo POSTHOOK')
else
PROMPT_COMMAND+=$'\necho POSTHOOK'
fi
expected_result=$'__bp_precmd_invoke_cmd "$_"\necho PREHOOK\n__bp_interactive_mode\necho POSTHOOK'
[ "$(join_PROMPT_COMMAND)" == "$expected_result" ]
# Re-adjust
__bp_install_prompt_command
expected_result=$'__bp_precmd_invoke_cmd "$_"\necho PREHOOK\necho POSTHOOK\n__bp_interactive_mode'
[ "$(join_PROMPT_COMMAND)" == "$expected_result" ]
}
@test "__bp_sanitize_string should remove semicolons and trim space" {
__bp_sanitize_string output " true1; "$'\n'
@ -172,6 +196,39 @@ set_exit_code_and_run_precmd() {
}
@test "__bp_sanitize_string should remove no-op colons" {
__bp_sanitize_string output ':'
[ "$output" == "" ]
__bp_sanitize_string output $':\n:'
[ "$output" == "" ]
__bp_sanitize_string output $':\n:;echo USER1'
[ "$output" == "echo USER1" ]
__bp_sanitize_string output $'echo USER2\n:\necho USER3'
expected_result=$'echo USER2\necho USER3'
[ "$output" == "$expected_result" ]
__bp_sanitize_string output $'echo USER4;:;echo USER5'
expected_result=$'echo USER4\necho USER5'
[ "$output" == "$expected_result" ]
__bp_sanitize_string output $'echo USER6;:\necho USER7'
expected_result=$'echo USER6\necho USER7'
[ "$output" == "$expected_result" ]
__bp_sanitize_string output $':\n: ; echo USER8'
[ "$output" == "echo USER8" ]
__bp_sanitize_string output $':\n: ; echo USER9'
[ "$output" == "echo USER9" ]
__bp_sanitize_string output $'echo USER10 ; :\n: ; echo USER11'
expected_result=$'echo USER10 \n echo USER11'
[ "$output" == "$expected_result" ]
}
@test "Appending to PROMPT_COMMAND should work after bp_install" {
bp_install