Skip to content

Commit 3305f42

Browse files
committed
Add APIs for external precmd/preexec integrations
* Rename public functions and variables as "bash_preexec_*" * Remove the compatibility variable name "__bp_install_string" * Do not prefix local varnames with underscores * Make "bash_preexec_invoke_pre{cmd,exec}_functions" return the last non-zero exit status * Add a note on the "trace" function attribute * Preserve the previous exit status and argument * Test the installation of convenience functions * Test "bash_preexec_uninstall" * Test "bash_preexec_invoke_pre{cmd,exec}_functions"
1 parent 1f77dc0 commit 3305f42

File tree

2 files changed

+218
-22
lines changed

2 files changed

+218
-22
lines changed

bash-preexec.sh

Lines changed: 96 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,11 @@ __bp_inside_precmd=0
7171
__bp_inside_preexec=0
7272

7373
# Initial PROMPT_COMMAND string that is removed from PROMPT_COMMAND post __bp_install
74-
__bp_install_string=$'__bp_trap_string="$(trap -p DEBUG)"\ntrap - DEBUG\n__bp_install'
74+
bash_preexec_install_string=$'__bp_trap_string="$(trap -p DEBUG)"\ntrap - DEBUG\n__bp_install'
75+
76+
# The command string that is registered to the DEBUG trap.
77+
# shellcheck disable=SC2016
78+
bash_preexec_trapdebug_string='__bp_preexec_invoke_exec "$_"'
7579

7680
# Fails if any of the given variables are readonly
7781
# Reference https://stackoverflow.com/a/4441178
@@ -157,21 +161,38 @@ __bp_precmd_invoke_cmd() {
157161
return
158162
fi
159163
local __bp_inside_precmd=1
164+
bash_preexec_invoke_precmd_functions "$__bp_last_ret_value" "$__bp_last_argument_prev_command"
165+
166+
__bp_set_ret_value "$__bp_last_ret_value" "$__bp_last_argument_prev_command"
167+
}
160168

169+
# This function invokes every function defined in our function array
170+
# "precmd_function". This function receives the arguments $1 and $2 for $? and
171+
# $_, respectively, that will be set for the precmd functions. This function
172+
# returns the last non-zero exit status of the hook functions. If there is no
173+
# error, this function returns 0.
174+
bash_preexec_invoke_precmd_functions() {
175+
local lastexit=$1 lastarg=$2
161176
# Invoke every function defined in our function array.
162177
local precmd_function
178+
local precmd_function_ret_value
179+
local precmd_ret_value=0
163180
for precmd_function in "${precmd_functions[@]}"; do
164181

165182
# Only execute this function if it actually exists.
166183
# Test existence of functions with: declare -[Ff]
167184
if type -t "$precmd_function" 1>/dev/null; then
168-
__bp_set_ret_value "$__bp_last_ret_value" "$__bp_last_argument_prev_command"
185+
__bp_set_ret_value "$lastexit" "$lastarg"
169186
# Quote our function invocation to prevent issues with IFS
170187
"$precmd_function"
188+
precmd_function_ret_value=$?
189+
if [[ "$precmd_function_ret_value" != 0 ]]; then
190+
precmd_ret_value="$precmd_function_ret_value"
191+
fi
171192
fi
172193
done
173194

174-
__bp_set_ret_value "$__bp_last_ret_value"
195+
__bp_set_ret_value "$precmd_ret_value"
175196
}
176197

177198
# Sets a return value in $?. We may want to get access to the $? variable in our
@@ -260,7 +281,27 @@ __bp_preexec_invoke_exec() {
260281
return
261282
fi
262283

263-
# Invoke every function defined in our function array.
284+
bash_preexec_invoke_preexec_functions "${__bp_last_ret_value:-}" "$__bp_last_argument_prev_command" "$this_command"
285+
local preexec_ret_value=$?
286+
287+
# Restore the last argument of the last executed command, and set the return
288+
# value of the DEBUG trap to be the return code of the last preexec function
289+
# to return an error.
290+
# If `extdebug` is enabled a non-zero return value from any preexec function
291+
# will cause the user's command not to execute.
292+
# Run `shopt -s extdebug` to enable
293+
__bp_set_ret_value "$preexec_ret_value" "$__bp_last_argument_prev_command"
294+
}
295+
296+
# This function invokes every function defined in our function array
297+
# "preexec_function". This function receives the arguments $1 and $2 for $?
298+
# and $_, respectively, that will be set for the preexec functions. The third
299+
# argument $3 specifies the user command that is going to be executed
300+
# (corresponding to BASH_COMMAND in the DEBUG trap). This function returns the
301+
# last non-zero exit status from the preexec functions. If there is no error,
302+
# this function returns `0`.
303+
bash_preexec_invoke_preexec_functions() {
304+
local lastexit=$1 lastarg=$2 this_command=$3
264305
local preexec_function
265306
local preexec_function_ret_value
266307
local preexec_ret_value=0
@@ -269,7 +310,7 @@ __bp_preexec_invoke_exec() {
269310
# Only execute each function if it actually exists.
270311
# Test existence of function with: declare -[fF]
271312
if type -t "$preexec_function" 1>/dev/null; then
272-
__bp_set_ret_value "${__bp_last_ret_value:-}"
313+
__bp_set_ret_value "$lastexit" "$lastarg"
273314
# Quote our function invocation to prevent issues with IFS
274315
"$preexec_function" "$this_command"
275316
preexec_function_ret_value="$?"
@@ -278,14 +319,7 @@ __bp_preexec_invoke_exec() {
278319
fi
279320
fi
280321
done
281-
282-
# Restore the last argument of the last executed command, and set the return
283-
# value of the DEBUG trap to be the return code of the last preexec function
284-
# to return an error.
285-
# If `extdebug` is enabled a non-zero return value from any preexec function
286-
# will cause the user's command not to execute.
287-
# Run `shopt -s extdebug` to enable
288-
__bp_set_ret_value "$preexec_ret_value" "$__bp_last_argument_prev_command"
322+
__bp_set_ret_value "$preexec_ret_value"
289323
}
290324

291325
__bp_install() {
@@ -294,7 +328,8 @@ __bp_install() {
294328
return 1;
295329
fi
296330

297-
trap '__bp_preexec_invoke_exec "$_"' DEBUG
331+
# shellcheck disable=SC2064
332+
trap "$bash_preexec_trapdebug_string" DEBUG
298333

299334
# Preserve any prior DEBUG trap as a preexec function
300335
local prior_trap
@@ -327,7 +362,7 @@ __bp_install() {
327362
# Remove setting our trap install string and sanitize the existing prompt command string
328363
existing_prompt_command="${PROMPT_COMMAND:-}"
329364
# Edge case of appending to PROMPT_COMMAND
330-
existing_prompt_command="${existing_prompt_command//$__bp_install_string/:}" # no-op
365+
existing_prompt_command="${existing_prompt_command//$bash_preexec_install_string/:}" # no-op
331366
existing_prompt_command="${existing_prompt_command//$'\n':$'\n'/$'\n'}" # remove known-token only
332367
existing_prompt_command="${existing_prompt_command//$'\n':;/$'\n'}" # remove known-token only
333368
__bp_sanitize_string existing_prompt_command "$existing_prompt_command"
@@ -346,10 +381,13 @@ __bp_install() {
346381
PROMPT_COMMAND+=$'\n__bp_interactive_mode'
347382
fi
348383

349-
# Add two functions to our arrays for convenience
350-
# of definition.
351-
precmd_functions+=(precmd)
352-
preexec_functions+=(preexec)
384+
# Add two functions to our arrays for convenience of definition only when
385+
# the functions have not yet added.
386+
if [[ ! ${__bp_installed_convenience_functions-} ]]; then
387+
__bp_installed_convenience_functions=1
388+
precmd_functions+=(precmd)
389+
preexec_functions+=(preexec)
390+
fi
353391

354392
# Invoke our two functions manually that were added to $PROMPT_COMMAND
355393
__bp_precmd_invoke_cmd
@@ -371,8 +409,46 @@ __bp_install_after_session_init() {
371409
PROMPT_COMMAND=${sanitized_prompt_command}$'\n'
372410
fi;
373411
# shellcheck disable=SC2179 # PROMPT_COMMAND is not an array in bash <= 5.0
374-
PROMPT_COMMAND+=${__bp_install_string}
412+
PROMPT_COMMAND+=${bash_preexec_install_string}
413+
}
414+
415+
# Remove hooks installed in the DEBUG trap and PROMPT_COMMAND.
416+
bash_preexec_uninstall() {
417+
# Remove __bp_install hook from PROMPT_COMMAND
418+
# shellcheck disable=SC2178 # PROMPT_COMMAND is not an array in bash <= 5.0
419+
if [[ ${PROMPT_COMMAND-} == *"$bash_preexec_install_string"* ]]; then
420+
PROMPT_COMMAND="${PROMPT_COMMAND//${bash_preexec_install_string}[;$'\n']}" # Edge case of appending to PROMPT_COMMAND
421+
PROMPT_COMMAND="${PROMPT_COMMAND//$bash_preexec_install_string}"
422+
fi
423+
424+
# Remove precmd hook from PROMPT_COMMAND
425+
local i prompt_command
426+
for i in "${!PROMPT_COMMAND[@]}"; do
427+
prompt_command=${PROMPT_COMMAND[i]}
428+
case $prompt_command in
429+
__bp_precmd_invoke_cmd | __bp_interactive_mode)
430+
prompt_command= ;;
431+
*)
432+
prompt_command=${prompt_command/#$'__bp_precmd_invoke_cmd\n'/$'\n'}
433+
prompt_command=${prompt_command%$'\n__bp_interactive_mode'}
434+
prompt_command=${prompt_command#$'\n'}
435+
esac
436+
PROMPT_COMMAND[i]=$prompt_command
437+
done
438+
439+
# Remove preexec hook in the DEBUG trap
440+
local q="'" Q="'\''"
441+
if [[ $(trap -p DEBUG) == "trap -- '${bash_preexec_trapdebug_string//$q/$Q}' DEBUG" ]]; then
442+
if [[ ${__bp_trap_string-} ]]; then
443+
eval -- "$__bp_trap_string"
444+
else
445+
trap - DEBUG
446+
fi
447+
fi
375448
}
449+
# Note: We need to add "trace" attribute to the function so that "trap - DEBUG"
450+
# inside the function takes an effect.
451+
declare -ft bash_preexec_uninstall
376452

377453
# Run our install so long as we're not delaying it.
378454
if [[ -z "${__bp_delay_install:-}" ]]; then

test/bash-preexec.bats

Lines changed: 122 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,11 @@ set_exit_code_and_run_precmd() {
7676

7777
# Assert that before running, the command contains the install string, and
7878
# afterwards it does not
79-
[[ "$PROMPT_COMMAND" == *"$__bp_install_string"* ]] || return 1
79+
[[ "$PROMPT_COMMAND" == *"$bash_preexec_install_string"* ]] || return 1
8080

8181
eval_PROMPT_COMMAND
8282

83-
[[ "$PROMPT_COMMAND" != *"$__bp_install_string"* ]] || return 1
83+
[[ "$PROMPT_COMMAND" != *"$bash_preexec_install_string"* ]] || return 1
8484
}
8585

8686
@test "__bp_install should preserve an existing DEBUG trap" {
@@ -103,6 +103,56 @@ set_exit_code_and_run_precmd() {
103103
(( trap_count_snapshot < trap_invoked_count ))
104104
}
105105

106+
@test "__bp_install should register convenience functions \"preexec\" and \"precmd\" only once" {
107+
precmd_functions=()
108+
preexec_functions=()
109+
__bp_install
110+
bash_preexec_uninstall
111+
__bp_install
112+
113+
count=0
114+
for hook in "${precmd_functions[@]}"; do
115+
if [[ "$hook" == precmd ]] ; then
116+
count=$((count+1))
117+
fi
118+
done
119+
[ "$count" == 1 ]
120+
121+
count=0
122+
for hook in "${preexec_functions[@]}"; do
123+
if [[ "$hook" == preexec ]] ; then
124+
count=$((count+1))
125+
fi
126+
done
127+
[ "$count" == 1 ]
128+
}
129+
130+
@test "bash_preexec_uninstall should remove the hooks in DEBUG and PROMPT_COMMAND" {
131+
__bp_install
132+
133+
q="'" Q="'\''"
134+
[[ "$(join_PROMPT_COMMAND)" == *"__bp_precmd_invoke_cmd"* ]] || return 1
135+
[[ "$(join_PROMPT_COMMAND)" == *"__bp_interactive_mode"* ]] || return 1
136+
[ "$(trap -p DEBUG)" == "trap -- '${bash_preexec_trapdebug_string//$q/$Q}' DEBUG" ]
137+
138+
bash_preexec_uninstall
139+
140+
q="'" Q="'\''"
141+
[[ "$(join_PROMPT_COMMAND)" != *"__bp_precmd_invoke_cmd"* ]] || return 1
142+
[[ "$(join_PROMPT_COMMAND)" != *"__bp_interactive_mode"* ]] || return 1
143+
[ "$(trap -p DEBUG)" != "trap -- '${bash_preexec_trapdebug_string//$q/$Q}' DEBUG" ]
144+
}
145+
146+
@test "bash_preexec_uninstall should remove the unprocessed __bp_install hook in PROMPT_COMMAND" {
147+
__bp_install_after_session_init
148+
149+
[[ "$PROMPT_COMMAND" == *"$bash_preexec_install_string"* ]]
150+
151+
bash_preexec_uninstall
152+
153+
[[ "$PROMPT_COMMAND" != *"$bash_preexec_install_string"* ]]
154+
}
155+
106156
@test "__bp_sanitize_string should remove semicolons and trim space" {
107157

108158
__bp_sanitize_string output " true1; "$'\n'
@@ -308,6 +358,76 @@ set_exit_code_and_run_precmd() {
308358
[ $status -eq 1 ]
309359
}
310360

361+
@test "bash_preexec_invoke_precmd_functions should be transparent for \$? and \$_" {
362+
tester1() { test1_lastexit=$? test1_lastarg=$_; }
363+
tester2() { test2_lastexit=$? test2_lastarg=$_; }
364+
precmd_functions=(tester1 tester2)
365+
trap - DEBUG # remove the Bats stack-trace trap so $_ doesn't get overwritten
366+
bash_preexec_invoke_precmd_functions 111 'vxxJlwNx9VPJDA' || true
367+
368+
[ "$test1_lastexit" == 111 ]
369+
[ "$test1_lastarg" == 'vxxJlwNx9VPJDA' ]
370+
[ "$test2_lastexit" == 111 ]
371+
[ "$test2_lastarg" == 'vxxJlwNx9VPJDA' ]
372+
}
373+
374+
@test "bash_preexec_invoke_precmd_functions returns the last non-zero exit status" {
375+
tester1() { return 91; }
376+
tester2() { return 38; }
377+
tester3() { return 0; }
378+
precmd_functions=(tester1 tester2 tester3)
379+
status=0
380+
bash_preexec_invoke_precmd_functions 1 'lastarg' || status=$?
381+
382+
[ "$status" == 38 ]
383+
384+
precmd_functions=(tester3)
385+
status=0
386+
bash_preexec_invoke_precmd_functions 1 'lastarg' || status=$?
387+
388+
[ "$status" == 0 ]
389+
}
390+
391+
@test "bash_preexec_invoke_preexec_functions should be transparent for \$? and \$_" {
392+
tester1() { test1_lastexit=$? test1_lastarg=$_; }
393+
tester2() { test2_lastexit=$? test2_lastarg=$_; }
394+
preexec_functions=(tester1 tester2)
395+
trap - DEBUG # remove the Bats stack-trace trap so $_ doesn't get overwritten
396+
bash_preexec_invoke_preexec_functions 87 'ehQrzHTHtE2E7Q' 'command' || true
397+
398+
[ "$test1_lastexit" == 87 ]
399+
[ "$test1_lastarg" == 'ehQrzHTHtE2E7Q' ]
400+
[ "$test2_lastexit" == 87 ]
401+
[ "$test2_lastarg" == 'ehQrzHTHtE2E7Q' ]
402+
}
403+
404+
@test "bash_preexec_invoke_preexec_functions returns the last non-zero exit status" {
405+
tester1() { return 52; }
406+
tester2() { return 112; }
407+
tester3() { return 0; }
408+
preexec_functions=(tester1 tester2 tester3)
409+
status=0
410+
bash_preexec_invoke_preexec_functions 1 'lastarg' 'command' || status=$?
411+
412+
[ "$status" == 112 ]
413+
414+
preexec_functions=(tester3)
415+
status=0
416+
bash_preexec_invoke_preexec_functions 1 'lastarg' 'command' || status=$?
417+
418+
[ "$status" == 0 ]
419+
}
420+
421+
@test "bash_preexec_invoke_preexec_functions should supply a current command in the first argument" {
422+
tester1() { test1_bash_command=$1; }
423+
tester2() { test2_bash_command=$1; }
424+
preexec_functions=(tester1 tester2)
425+
bash_preexec_invoke_preexec_functions 1 'lastarg' 'UEVkErELArSwjA' || true
426+
427+
[ "$test1_bash_command" == 'UEVkErELArSwjA' ]
428+
[ "$test2_bash_command" == 'UEVkErELArSwjA' ]
429+
}
430+
311431
@test "in_prompt_command should detect if a command is part of PROMPT_COMMAND" {
312432

313433
PROMPT_COMMAND=$'precmd_invoke_cmd\n something; echo yo\n __bp_interactive_mode'

0 commit comments

Comments
 (0)