Skip to main content

rec/hooks/
bash_preexec.rs

1/// Bundled bash-preexec library for Bash shell hook support.
2///
3/// Source: <https://github.com/rcaloras/bash-preexec>
4/// Version: 0.6.0
5/// License: MIT
6///
7/// This library provides preexec/precmd hooks for Bash, similar to Zsh's
8/// native functionality. It's required because Bash doesn't have native
9/// preexec hooks (only DEBUG trap, which has edge cases with pipes/subshells).
10///
11/// The library is embedded as a const string and output by `rec init bash`
12/// before the rec-specific hook script, so that `bash_preexec_imported` is
13/// set when our hooks register with `preexec_functions` and `precmd_functions`.
14pub const BASH_PREEXEC: &str = r#"
15# bash-preexec.sh -- Bash support for ZSH-like 'preexec' and 'precmd' functions.
16# https://github.com/rcaloras/bash-preexec
17#
18#
19# 'preexec' functions are executed before each interactive command is
20# executed, with the interactive command as its argument. The 'precmd'
21# function is executed before each prompt is displayed.
22#
23# Author: Ryan Caloras (ryan@bashhub.com)
24# Forked from Original Author: Glyph Lefkowitz
25#
26# V0.6.0
27#
28
29# General Usage:
30#
31#  1. Source this file at the end of your bash profile so as not to interfere
32#     with anything else that's using PROMPT_COMMAND.
33#
34#  2. Add any precmd or preexec functions by appending them to their arrays:
35#       e.g.
36#       precmd_functions+=(my_precmd_function)
37#       precmd_functions+=(some_other_precmd_function)
38#
39#       preexec_functions+=(my_preexec_function)
40#
41#  3. Consider changing anything using the DEBUG trap or PROMPT_COMMAND
42#     to use preexec and precmd instead. Preexisting usages will be
43#     preserved, but doing so manually may be less surprising.
44#
45#  Note: This module requires two Bash features which you must not otherwise be
46#  using: the "DEBUG" trap, and the "PROMPT_COMMAND" variable. If you override
47#  either of these after bash-preexec has been installed it will most likely break.
48
49# Tell shellcheck what kind of file this is.
50# shellcheck shell=bash
51
52# Make sure this is bash that's running and return otherwise.
53# Use POSIX syntax for this line:
54if [ -z "${BASH_VERSION-}" ]; then
55    return 1
56fi
57
58# We only support Bash 3.1+.
59# Note: BASH_VERSINFO is first available in Bash-2.0.
60if [[ -z "${BASH_VERSINFO-}" ]] || (( BASH_VERSINFO[0] < 3 || (BASH_VERSINFO[0] == 3 && BASH_VERSINFO[1] < 1) )); then
61    return 1
62fi
63
64# Avoid duplicate inclusion
65if [[ -n "${bash_preexec_imported:-}" || -n "${__bp_imported:-}" ]]; then
66    return 0
67fi
68bash_preexec_imported="defined"
69
70# WARNING: This variable is no longer used and should not be relied upon.
71# Use ${bash_preexec_imported} instead.
72# shellcheck disable=SC2034
73__bp_imported="${bash_preexec_imported}"
74
75# Should be available to each precmd and preexec
76# functions, should they want it. $? and $_ are available as $? and $_, but
77# $PIPESTATUS is available only in a copy, $BP_PIPESTATUS.
78# TODO: Figure out how to restore PIPESTATUS before each precmd or preexec
79# function.
80__bp_last_ret_value="$?"
81BP_PIPESTATUS=("${PIPESTATUS[@]}")
82__bp_last_argument_prev_command="$_"
83
84__bp_inside_precmd=0
85__bp_inside_preexec=0
86
87# Initial PROMPT_COMMAND string that is removed from PROMPT_COMMAND post __bp_install
88__bp_install_string=$'__bp_trap_string="$(trap -p DEBUG)"\ntrap - DEBUG\n__bp_install'
89
90# Fails if any of the given variables are readonly
91# Reference https://stackoverflow.com/a/4441178
92__bp_require_not_readonly() {
93    local var
94    for var; do
95        if ! ( unset "$var" 2> /dev/null ); then
96            echo "bash-preexec requires write access to ${var}" >&2
97            return 1
98        fi
99    done
100}
101
102# Remove ignorespace and or replace ignoreboth from HISTCONTROL
103# so we can accurately invoke preexec with a command from our
104# history even if it starts with a space.
105__bp_adjust_histcontrol() {
106    local histcontrol
107    histcontrol="${HISTCONTROL:-}"
108    histcontrol="${histcontrol//ignorespace}"
109    # Replace ignoreboth with ignoredups
110    if [[ "$histcontrol" == *"ignoreboth"* ]]; then
111        histcontrol="ignoredups:${histcontrol//ignoreboth}"
112    fi
113    export HISTCONTROL="$histcontrol"
114}
115
116# This variable describes whether we are currently in "interactive mode";
117# i.e. whether this shell has just executed a prompt and is waiting for user
118# input.  It documents whether the current command invoked by the trace hook is
119# run interactively by the user; it's set immediately after the prompt hook,
120# and unset as soon as the trace hook is run.
121__bp_preexec_interactive_mode=""
122
123# These arrays are used to add functions to be run before, or after, prompts.
124declare -a precmd_functions
125declare -a preexec_functions
126
127# Trims leading and trailing whitespace from $2 and writes it to the variable
128# name passed as $1
129__bp_trim_whitespace() {
130    local var=${1:?} text=${2:-}
131    text="${text#"${text%%[![:space:]]*}"}"   # remove leading whitespace characters
132    text="${text%"${text##*[![:space:]]}"}"   # remove trailing whitespace characters
133    printf -v "$var" '%s' "$text"
134}
135
136
137# Trims whitespace and removes any leading or trailing semicolons from $2 and
138# writes the resulting string to the variable name passed as $1. Used for
139# manipulating substrings in PROMPT_COMMAND
140__bp_sanitize_string() {
141    local var=${1:?} text=${2:-} sanitized
142    __bp_trim_whitespace sanitized "$text"
143    sanitized=${sanitized%;}
144    sanitized=${sanitized#;}
145    __bp_trim_whitespace sanitized "$sanitized"
146    printf -v "$var" '%s' "$sanitized"
147}
148
149# This function is installed as part of the PROMPT_COMMAND;
150# It sets a variable to indicate that the prompt was just displayed,
151# to allow the DEBUG trap to know that the next command is likely interactive.
152__bp_interactive_mode() {
153    __bp_preexec_interactive_mode="on"
154}
155
156
157# This function is installed as part of the PROMPT_COMMAND.
158# It will invoke any functions defined in the precmd_functions array.
159__bp_precmd_invoke_cmd() {
160    # Save the returned value from our last command, and from each process in
161    # its pipeline. Note: this MUST be the first thing done in this function.
162    # BP_PIPESTATUS may be unused, ignore
163    # shellcheck disable=SC2034
164
165    __bp_last_ret_value="$?" BP_PIPESTATUS=("${PIPESTATUS[@]}")
166
167    # Don't invoke precmds if we are inside an execution of an "original
168    # prompt command" by another precmd execution loop. This avoids infinite
169    # recursion.
170    if (( __bp_inside_precmd > 0 )); then
171        return
172    fi
173    local __bp_inside_precmd=1
174
175    # Invoke every function defined in our function array.
176    local precmd_function
177    for precmd_function in "${precmd_functions[@]}"; do
178
179        # Only execute this function if it actually exists.
180        # Test existence of functions with: declare -[Ff]
181        if type -t "$precmd_function" 1>/dev/null; then
182            __bp_set_ret_value "$__bp_last_ret_value" "$__bp_last_argument_prev_command"
183            # Quote our function invocation to prevent issues with IFS
184            "$precmd_function"
185        fi
186    done
187
188    __bp_set_ret_value "$__bp_last_ret_value"
189}
190
191# Sets a return value in $?. We may want to get access to the $? variable in our
192# precmd functions. This is available for instance in zsh. We can simulate it in bash
193# by setting the value here.
194__bp_set_ret_value() {
195    return ${1:+"$1"}
196}
197
198__bp_in_prompt_command() {
199
200    local prompt_command_array IFS=$'\n;'
201    read -rd '' -a prompt_command_array <<< "${PROMPT_COMMAND[*]:-}"
202
203    local trimmed_arg
204    __bp_trim_whitespace trimmed_arg "${1:-}"
205
206    local command trimmed_command
207    for command in "${prompt_command_array[@]:-}"; do
208        __bp_trim_whitespace trimmed_command "$command"
209        if [[ "$trimmed_command" == "$trimmed_arg" ]]; then
210            return 0
211        fi
212    done
213
214    return 1
215}
216
217# This function is installed as the DEBUG trap.  It is invoked before each
218# interactive prompt display.  Its purpose is to inspect the current
219# environment to attempt to detect if the current command is being invoked
220# interactively, and invoke 'preexec' if so.
221__bp_preexec_invoke_exec() {
222
223    # Save the contents of $_ so that it can be restored later on.
224    # https://stackoverflow.com/questions/40944532/bash-preserve-in-a-debug-trap#40944702
225    __bp_last_argument_prev_command="${1:-}"
226    # Don't invoke preexecs if we are inside of another preexec.
227    if (( __bp_inside_preexec > 0 )); then
228        return
229    fi
230    local __bp_inside_preexec=1
231
232    # Checks if the file descriptor is not standard out (i.e. '1')
233    # __bp_delay_install checks if we're in test. Needed for bats to run.
234    # Prevents preexec from being invoked for functions in PS1
235    if [[ ! -t 1 && -z "${__bp_delay_install:-}" ]]; then
236        return
237    fi
238
239    if [[ -n "${COMP_POINT:-}" || -n "${READLINE_POINT:-}" ]]; then
240        # We're in the middle of a completer or a keybinding set up by "bind
241        # -x".  This obviously can't be an interactively issued command.
242        return
243    fi
244    if [[ -z "${__bp_preexec_interactive_mode:-}" ]]; then
245        # We're doing something related to displaying the prompt.  Let the
246        # prompt set the title instead of me.
247        return
248    else
249        # If we're in a subshell, then the prompt won't be re-displayed to put
250        # us back into interactive mode, so let's not set the variable back.
251        # In other words, if you have a subshell like
252        #   (sleep 1; sleep 2)
253        # You want to see the 'sleep 2' as a set_command_title as well.
254        if [[ 0 -eq "${BASH_SUBSHELL:-}" ]]; then
255            __bp_preexec_interactive_mode=""
256        fi
257    fi
258
259    if  __bp_in_prompt_command "${BASH_COMMAND:-}"; then
260        # If we're executing something inside our prompt_command then we don't
261        # want to call preexec. Bash prior to 3.1 can't detect this at all :/
262        __bp_preexec_interactive_mode=""
263        return
264    fi
265
266    local this_command
267    this_command=$(LC_ALL=C HISTTIMEFORMAT='' builtin history 1)
268    this_command="${this_command#*[[:digit:]][* ] }"
269
270    # Sanity check to make sure we have something to invoke our function with.
271    if [[ -z "$this_command" ]]; then
272        return
273    fi
274
275    # Invoke every function defined in our function array.
276    local preexec_function
277    local preexec_function_ret_value
278    local preexec_ret_value=0
279    for preexec_function in "${preexec_functions[@]:-}"; do
280
281        # Only execute each function if it actually exists.
282        # Test existence of function with: declare -[fF]
283        if type -t "$preexec_function" 1>/dev/null; then
284            __bp_set_ret_value "${__bp_last_ret_value:-}"
285            # Quote our function invocation to prevent issues with IFS
286            "$preexec_function" "$this_command"
287            preexec_function_ret_value="$?"
288            if [[ "$preexec_function_ret_value" != 0 ]]; then
289                preexec_ret_value="$preexec_function_ret_value"
290            fi
291        fi
292    done
293
294    # Restore the last argument of the last executed command, and set the return
295    # value of the DEBUG trap to be the return code of the last preexec function
296    # to return an error.
297    # If `extdebug` is enabled a non-zero return value from any preexec function
298    # will cause the user's command not to execute.
299    # Run `shopt -s extdebug` to enable
300    __bp_set_ret_value "$preexec_ret_value" "$__bp_last_argument_prev_command"
301}
302
303__bp_install() {
304    # Exit if we already have this installed.
305    if [[ "${PROMPT_COMMAND[*]:-}" == *"__bp_precmd_invoke_cmd"* ]]; then
306        return 1
307    fi
308
309    trap '__bp_preexec_invoke_exec "$_"' DEBUG
310
311    # Preserve any prior DEBUG trap as a preexec function
312    eval "local trap_argv=(${__bp_trap_string:-})"
313    local prior_trap=${trap_argv[2]:-}
314    unset __bp_trap_string
315    if [[ -n "$prior_trap" ]]; then
316        eval '__bp_original_debug_trap() {
317            '"$prior_trap"'
318        }'
319        preexec_functions+=(__bp_original_debug_trap)
320    fi
321
322    # Adjust our HISTCONTROL Variable if needed.
323    __bp_adjust_histcontrol
324
325    # Issue #25. Setting debug trap for subshells causes sessions to exit for
326    # backgrounded subshell commands (e.g. (pwd)& ). Believe this is a bug in Bash.
327    #
328    # Disabling this by default. It can be enabled by setting this variable.
329    if [[ -n "${__bp_enable_subshells:-}" ]]; then
330
331        # Set so debug trap will work be invoked in subshells.
332        set -o functrace > /dev/null 2>&1
333        shopt -s extdebug > /dev/null 2>&1
334    fi
335
336    local existing_prompt_command
337    # Remove setting our trap install string and sanitize the existing prompt command string
338    existing_prompt_command="${PROMPT_COMMAND:-}"
339    # Edge case of appending to PROMPT_COMMAND
340    existing_prompt_command="${existing_prompt_command//$__bp_install_string/:}" # no-op
341    existing_prompt_command="${existing_prompt_command//$'\n':$'\n'/$'\n'}" # remove known-token only
342    existing_prompt_command="${existing_prompt_command//$'\n':;/$'\n'}" # remove known-token only
343    __bp_sanitize_string existing_prompt_command "$existing_prompt_command"
344    if [[ "${existing_prompt_command:-:}" == ":" ]]; then
345        existing_prompt_command=
346    fi
347
348    # Install our hooks in PROMPT_COMMAND to allow our trap to know when we've
349    # actually entered something.
350    PROMPT_COMMAND='__bp_precmd_invoke_cmd'
351    PROMPT_COMMAND+=${existing_prompt_command:+$'\n'$existing_prompt_command}
352    if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 1) )); then
353        PROMPT_COMMAND+=('__bp_interactive_mode')
354    else
355        # shellcheck disable=SC2179 # PROMPT_COMMAND is not an array in bash <= 5.0
356        PROMPT_COMMAND+=$'\n__bp_interactive_mode'
357    fi
358
359    # Add two functions to our arrays for convenience
360    # of definition.
361    precmd_functions+=(precmd)
362    preexec_functions+=(preexec)
363
364    # Invoke our two functions manually that were added to $PROMPT_COMMAND
365    __bp_precmd_invoke_cmd
366    __bp_interactive_mode
367}
368
369# Sets an installation string as part of our PROMPT_COMMAND to install
370# after our session has started. This allows bash-preexec to be included
371# at any point in our bash profile.
372__bp_install_after_session_init() {
373    # bash-preexec needs to modify these variables in order to work correctly
374    # if it can't, just stop the installation
375    __bp_require_not_readonly PROMPT_COMMAND HISTCONTROL HISTTIMEFORMAT || return
376
377    local sanitized_prompt_command
378    __bp_sanitize_string sanitized_prompt_command "${PROMPT_COMMAND:-}"
379    if [[ -n "$sanitized_prompt_command" ]]; then
380        # shellcheck disable=SC2178 # PROMPT_COMMAND is not an array in bash <= 5.0
381        PROMPT_COMMAND=${sanitized_prompt_command}$'\n'
382    fi
383    # shellcheck disable=SC2179 # PROMPT_COMMAND is not an array in bash <= 5.0
384    PROMPT_COMMAND+=${__bp_install_string}
385}
386
387# Run our install so long as we're not delaying it.
388if [[ -z "${__bp_delay_install:-}" ]]; then
389    __bp_install_after_session_init
390fi
391"#;
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    #[test]
398    fn test_bash_preexec_is_not_empty() {
399        assert!(!BASH_PREEXEC.is_empty());
400    }
401
402    #[test]
403    fn test_bash_preexec_contains_key_markers() {
404        assert!(BASH_PREEXEC.contains("bash_preexec_imported"));
405        assert!(BASH_PREEXEC.contains("preexec_functions"));
406        assert!(BASH_PREEXEC.contains("precmd_functions"));
407    }
408
409    #[test]
410    fn test_bash_preexec_has_substantial_content() {
411        // The library should be ~300+ lines
412        let line_count = BASH_PREEXEC.lines().count();
413        assert!(line_count > 200, "Expected 200+ lines, got {line_count}");
414    }
415
416    #[test]
417    fn test_bash_preexec_contains_install_function() {
418        assert!(BASH_PREEXEC.contains("__bp_install()"));
419        assert!(BASH_PREEXEC.contains("__bp_install_after_session_init"));
420    }
421}