diff --git a/src/shell-integration/bash/bash-preexec.sh b/src/shell-integration/bash/bash-preexec.sh index f20ece179..ebf79d560 100644 --- a/src/shell-integration/bash/bash-preexec.sh +++ b/src/shell-integration/bash/bash-preexec.sh @@ -9,7 +9,7 @@ # Author: Ryan Caloras (ryan@bashhub.com) # Forked from Original Author: Glyph Lefkowitz # -# V0.6.0 +# V0.7.0 # # General Usage: @@ -71,7 +71,8 @@ __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' +# shellcheck disable=SC2016 +__bp_install_string='__bp_install "$_"' # Fails if any of the given variables are readonly # Reference https://stackoverflow.com/a/4441178 @@ -106,9 +107,11 @@ __bp_adjust_histcontrol() { # and unset as soon as the trace hook is run. __bp_preexec_interactive_mode="" -# These arrays are used to add functions to be run before, or after, prompts. -declare -a precmd_functions -declare -a preexec_functions +# These global arrays are used to add functions to be run before, or after, +# prompts. Note that Bash < 4.2 does not have the "-g" option of the "declare" +# builtin. We actually do not need to explicitly initialize these arrays. +#declare -ga precmd_functions +#declare -ga preexec_functions # Trims leading and trailing whitespace from $2 and writes it to the variable # name passed as $1 @@ -121,21 +124,96 @@ __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 nl=$'\n' + while + # Note: 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'. + # Note: We use ?(+([[:blank:]])) instead of *([[:blank:]]) to work + # around a bug of Bash 3.2 that *(...) is not properly processed as + # extglob at the beginning of the pattern in ${var//pat/rep}. + tmp="${sanitized//?(+([[:blank:]]))[";$nl"]*([[:blank:]]):*([[:blank:]])[";$nl"]*([[:blank:]])/$nl}" + [[ "$tmp" != "$sanitized" ]] + do + sanitized="$tmp" + done + sanitized="${sanitized#:*([[:blank:]])[";$nl"]}" + sanitized="${sanitized%[";$nl"]*([[:blank:]]):}" + __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" } @@ -143,35 +221,69 @@ __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 # recursion. if (( __bp_inside_precmd > 0 )); then - return + return "$__bp_last_ret_value" fi - local __bp_inside_precmd=1 + # 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; then + if [[ "${1-}" != "force" && ! "${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 "$__bp_last_ret_value" + fi + 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 +312,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 +367,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 +386,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"' @@ -307,11 +441,11 @@ __bp_install() { # Adjust our HISTCONTROL Variable if needed. # - # GHOSTTY: Don't modify HISTCONTROL. This hack is only needed to improve the - # accuracy of the command argument passed to the preexec functions, and we - # don't use that argument in our bash shell integration script (and nor does - # the __bp_original_debug_trap function above, which is the only other active - # preexec function). + # GHOSTTY: Don't modify HISTCONTROL (#2269). This only improves the + # accuracy of the command passed to preexec functions when the user has + # ignorespace set. Our bash >= 4.4 integration reads `history 1` without + # adjusting HISTCONTROL and accepts the same inaccuracy, so keep the + # legacy path consistent with it and respect the user's setting. #__bp_adjust_histcontrol # Issue #25. Setting debug trap for subshells causes sessions to exit for @@ -324,29 +458,36 @@ __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. + # 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. @@ -354,8 +495,50 @@ __bp_install() { preexec_functions+=(preexec) # Invoke our two functions manually that were added to $PROMPT_COMMAND - __bp_precmd_invoke_cmd - __bp_interactive_mode + __bp_set_ret_value "$lastexit" "$lastarg" + __bp_precmd_invoke_cmd force + __bp_interactive_mode force +} + +# 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 + +# 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'* && "$prompt_command" == *$'\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 @@ -365,15 +548,22 @@ __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 - - local sanitized_prompt_command - __bp_sanitize_string sanitized_prompt_command "${PROMPT_COMMAND:-}" - if [[ -n "$sanitized_prompt_command" ]]; then - # shellcheck disable=SC2178 # PROMPT_COMMAND is not an array in bash <= 5.0 - PROMPT_COMMAND=${sanitized_prompt_command}$'\n' + if [[ $__bp_hook_preexec_proc == '__bp_hook_preexec_into_ps0' ]]; then + __bp_require_not_readonly PS0 || return + fi + + if __bp_use_array_prompt_command; then + PROMPT_COMMAND+=("${__bp_install_string}") + else + local sanitized_prompt_command + __bp_sanitize_string sanitized_prompt_command "${PROMPT_COMMAND:-}" + if [[ -n "$sanitized_prompt_command" ]]; then + # shellcheck disable=SC2178 # PROMPT_COMMAND is not an array in bash <= 5.0 + PROMPT_COMMAND=${sanitized_prompt_command}$'\n' + fi + # shellcheck disable=SC2179 # PROMPT_COMMAND is not an array in bash <= 5.0 + PROMPT_COMMAND+=${__bp_install_string} fi - # shellcheck disable=SC2179 # PROMPT_COMMAND is not an array in bash <= 5.0 - PROMPT_COMMAND+=${__bp_install_string} } # Run our install so long as we're not delaying it.