rec/hooks/
bash_preexec.rs1pub 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 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}