From 269f57e8c05fb39b57e82c4d34eff793b03d74b8 Mon Sep 17 00:00:00 2001 From: tsi Date: Mon, 20 Oct 2025 17:31:32 +0200 Subject: [PATCH 01/29] #1: Add initial version of bash-abbr --- bash-abbr | 145 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 bash-abbr diff --git a/bash-abbr b/bash-abbr new file mode 100644 index 0000000..effbf2f --- /dev/null +++ b/bash-abbr @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# Shebang merely for syntax highlighting. +# This file should be pasted directly into ~/.bashrc or +# put into the bashrc drop-in directory ~/.bashrc.d/ +# and then sourced from ~/.bashrc. + +# WARNING: This plug-in creates a new readline binding: '\e\C-a' +# and overwrites the binding for '\C-m', ' ', '|', '&', ';', '(', ')', '<', '>' +# Therefore, it might interfere with other plug-ins or custom bindings. +# You may change the '\e\C-a' binding to whatever you want as it's only used +# as a hook inside another readline macro. The bindings '|', '&', ';', '(', ')', '<', '>' +# may also be dropped if you don't need them. + +# Regexes for (heuristically) determining whether we're abbreviating the first word. +# Construction put into functions to prevent global namespace pollution +# and increase readability. +_control_op () { + declare -r _newline=$'\n' \ + _bg_proc_or_and_and='[^&;]&&?' \ + _pipe_or_or_or='[^&|;]\|\|?' \ + _semicolon_or_case_operators='[^;|];;?&?' \ + _opening_paren='[^[:alnum:]]\(' \ + _closing_paren='\)' + declare _control_op="^" + _control_op+="|$_newline" + _control_op+="|$_bg_proc_or_and_and" + _control_op+="|$_pipe_or_or_or" + _control_op+="|$_semicolon_or_case_operators" + _control_op+="|$_opening_paren" + _control_op+="|$_closing_paren" + printf %s "$_control_op" +} +_control_op="$(_control_op)" +unset -f _control_op + +_redir_or_assign_word () { + declare -r _varredir_or_number='\{[[:alpha:]_][[:alnum:]_]*\}|[[:digit:]]*' \ + _redir_in_or_out='>|<' \ + _close_or_move_fd='&[[:digit:]]*-?' \ + _redir_or_append_out_and_err='(&)(>>?)' \ + _heredoc_herestring_in_out_etc='>>?|<(>|<(-?|<))?' \ + _identifier_equals='[[:alpha:]_][[:alnum:]]*=' \ + _simple_unquoted_word='[^[:space:]<>&;()|'\''"`$]+' \ + _double_quoted_string='\$?"([^"]|\\")*"' \ + _single_quoted_string="\\\$?'[^']*'" \ + _backtick_string='`([^`]|\\`)*`' \ + _simple_variable='\$[[:alpha:]_][[:alnum:]]*' \ + _advanced_variable='\$\{([[:alnum:]]+|[#-_$!*#])[^{}]*\}' \ + _deprecated_arithmetic_eval='\$\[[^][]*\]' \ + _cmd_subst_or_arithmetic_eval='\$\([^()]*\)' \ + _process_substition='<\([^()]*\)' \ + _escaped_char='\\.' + declare _part="$_simple_unquoted_word" + _part+="|$_double_quoted_string" + _part+="|$_single_quoted_string" + _part+="|$_backtick_string" + _part+="|$_simple_variable" + _part+="|$_advanced_variable" + _part+="|$_deprecated_arithmetic_eval" + _part+="|$_cmd_subst_or_arithmetic_eval" + _part+="|$_process_substition" + _part+="|$_escaped_char" + declare -r _word="($_part)+" \ + _close_redirection="($_varredir_or_number)($_redir_in_or_out)($_close_or_move_fd)" \ + _generic_redirection="($_redir_or_append_out_and_err|($_varredir_or_number)($_heredoc_herestring_in_out_etc))" + declare -r _word_redirection_or_assignment="($_generic_redirection[[:space:]]*|$_identifier_equals)($_word)" + printf %s "$_close_redirection|$_word_redirection_or_assignment" +} +_redir_or_assign_word="$(_redir_or_assign_word)" +unset -f _redir_or_assign_word + +_try_abbrev () { + # For use within bind -x. + # Perform abbreviation lookup and interactive expansion on the shell-word left to the cursor (no quote parsing). + # Takes a single argument which is the character to self-insert (usually space or another shell metacharacter). + + declare -r line_until_point=${READLINE_LINE::READLINE_POINT} + + if + # Check if the character at point is a shell metacharacter or EOL + [[ $READLINE_POINT -eq ${#READLINE_LINE} || "${READLINE_LINE:READLINE_POINT:1}" = [\<\>\&\;\(\)\|[:space:]] ]] && + + # Determine word to complete. + # Uses the same word separators as bash (| & ; ( ) space tab newline) (aka "shell metacharacters") + # But < and > are ignored, since they always expect a file/delimiter/string afterwards and not a command + declare -r word_at_point=${line_until_point##*[&;()|[:space:]]} && + + # Select replacement (if any). + declare replacement=${BASH_ALIASES["${word_at_point:-__nonexistent}"]-} && + + # Determine if it's the first word of a simple command, unless we're expanding a global alias. + # Uses the same command separators as bash (|| & && ; ;; ;& ;;& ( ) | |& newline) (aka "control operators"). + # Due to complexity and performance reasons, we cannot reliably know + # whether the cursor position (aka "point" in readline terms) is in a quoted/escaped + # shell context or not (we would have to implement a full-blown bash parser) or + # whether there are any redirections/assignment words before the first word. + # The only heuristic we try is to check whether we're at the beginning of the line or if + # there's a control operator immediately to the left of the word (ignoring whitespace) + # and that there's no character/metacharacter prefixing the control operator + # s.t. there's a syntax error. We also do some rudimentary redirection and assignment word + # checking (including quoting) after the control operator. If the syntax is invalid (as far as + # the regex can tell), then we bail and no alias expansion will happen. + [[ ${replacement::1} = "!" || ${line_until_point%"$word_at_point"} =~ ($_control_op)[[:space:]]*((($_redir_or_assign_word)[[:space:]])*)$ ]] && + # If the regex leads to problems/performance issues, you may try this simpler regex + # that does no redirection/assignment word checking: + # [[ ${replacement::1} = "!" || ${line_until_point%"$word_at_point"} =~ ($_control_op)[[:space:]]*$ ]] && + [[ $replacement ]]; then + + # Account for global aliases (strip "!" prefix and disable self-insertion) + [[ ${replacement::1} = "!" ]] && replacement=${replacement#!} && set -- '' + + # Calculate point and mark offset. + declare -ir offset=${#replacement}-${#word_at_point}+${#1} + + # Update line. + READLINE_LINE=${line_until_point%"$word_at_point"}$replacement${1-}${READLINE_LINE:READLINE_POINT} + else + declare -ir offset=${#1} + + # Self-insert by default + READLINE_LINE=${line_until_point}${1-}${READLINE_LINE:READLINE_POINT} + fi + + # Advance mark if it's right of (or on) the point. + [[ $READLINE_MARK && $READLINE_POINT -le "$READLINE_MARK" ]] && READLINE_MARK=$((READLINE_MARK+offset)) + + # Advance point. + READLINE_POINT=$((READLINE_POINT+offset)) + + # NB: READLINE_ARGUMENT is deliberately ignored. +} + +# From the bash man page: +# metacharacter +# A character that, when unquoted, separates words. One of the following: +# | & ; ( ) < > space tab newline +for ch in ' ' '|' '&' ';' '(' ')' '<' '>'; do + bind -x '"'"$ch"'": _try_abbrev "'"$ch"'"' # Note: double quote should be escaped, if added to the list of metacharacters +done + +# Newline must be treated specially by creating a new binding which is called +# as a readline macro when pressing enter (C-m). After that is finished, the macro +# continues with a regular C-j, which accepts (=executes) the line as you would expect. +bind -x '"\e\C-a": _try_abbrev ""' +bind '"\C-m": "\e\C-a\e^\C-j"' From da63123229abcff228566b4a2c0812f7e9f596a6 Mon Sep 17 00:00:00 2001 From: tsi Date: Tue, 21 Oct 2025 00:40:36 +0200 Subject: [PATCH 02/29] #1: Remove _closing_paren regex as it doesn't make sense --- bash-abbr | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bash-abbr b/bash-abbr index effbf2f..975b791 100644 --- a/bash-abbr +++ b/bash-abbr @@ -19,15 +19,13 @@ _control_op () { _bg_proc_or_and_and='[^&;]&&?' \ _pipe_or_or_or='[^&|;]\|\|?' \ _semicolon_or_case_operators='[^;|];;?&?' \ - _opening_paren='[^[:alnum:]]\(' \ - _closing_paren='\)' + _opening_paren='[^[:alnum:]]\(' declare _control_op="^" _control_op+="|$_newline" _control_op+="|$_bg_proc_or_and_and" _control_op+="|$_pipe_or_or_or" _control_op+="|$_semicolon_or_case_operators" _control_op+="|$_opening_paren" - _control_op+="|$_closing_paren" printf %s "$_control_op" } _control_op="$(_control_op)" From bc3f414ab89da36c429647af3253e1cf8313e851 Mon Sep 17 00:00:00 2001 From: tsi Date: Tue, 21 Oct 2025 00:40:59 +0200 Subject: [PATCH 03/29] #1: Allow _opening_paren control operator at the beginning of a line --- bash-abbr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bash-abbr b/bash-abbr index 975b791..5a15319 100644 --- a/bash-abbr +++ b/bash-abbr @@ -19,7 +19,7 @@ _control_op () { _bg_proc_or_and_and='[^&;]&&?' \ _pipe_or_or_or='[^&|;]\|\|?' \ _semicolon_or_case_operators='[^;|];;?&?' \ - _opening_paren='[^[:alnum:]]\(' + _opening_paren='(^|[^[:alnum:]])\(' declare _control_op="^" _control_op+="|$_newline" _control_op+="|$_bg_proc_or_and_and" From 91600446c6fc2f9101b279a4c6d4e9ce57388f78 Mon Sep 17 00:00:00 2001 From: tsi Date: Tue, 21 Oct 2025 00:45:06 +0200 Subject: [PATCH 04/29] #1: Allow history expansion on metacharacter insertion (currently disabled because it moves the point) --- bash-abbr | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/bash-abbr b/bash-abbr index 5a15319..e3dc6fa 100644 --- a/bash-abbr +++ b/bash-abbr @@ -4,12 +4,13 @@ # put into the bashrc drop-in directory ~/.bashrc.d/ # and then sourced from ~/.bashrc. -# WARNING: This plug-in creates a new readline binding: '\e\C-a' -# and overwrites the binding for '\C-m', ' ', '|', '&', ';', '(', ')', '<', '>' +# WARNING: This plug-in overwrites the readling bindings for ' ', '|', '&', ';', '(', ')', '<', '>' +# It also creates new bindings '\e\C-a' where `` is one of the characters above. +# Additionally, it overwrites the binding for '\C-m' and creates a corresponding binding '\e\C-an'. # Therefore, it might interfere with other plug-ins or custom bindings. -# You may change the '\e\C-a' binding to whatever you want as it's only used -# as a hook inside another readline macro. The bindings '|', '&', ';', '(', ')', '<', '>' -# may also be dropped if you don't need them. +# You may change the '\e\C-a' binding-"prefix" to whatever you want as it's only used +# as a hook inside other readline macros. The bindings '|', '&', ';', '(', ')', '<', '>' (and their +# corresponding '\e\C-a' bindings) may also be dropped if you don't need them. # Regexes for (heuristically) determining whether we're abbreviating the first word. # Construction put into functions to prevent global namespace pollution @@ -132,12 +133,14 @@ _try_abbrev () { # metacharacter # A character that, when unquoted, separates words. One of the following: # | & ; ( ) < > space tab newline +# Note: double quote should be escaped, if added to the list of metacharacters for ch in ' ' '|' '&' ';' '(' ')' '<' '>'; do - bind -x '"'"$ch"'": _try_abbrev "'"$ch"'"' # Note: double quote should be escaped, if added to the list of metacharacters + bind -x '"\e\C-a'"$ch"'": _try_abbrev "'"$ch"'"' + bind '"'"$ch"'": "\e\C-a'"$ch"'"' + # bind '"'"$ch"'": "\e\C-a'"$ch"'\e^"' # Moves point weirdly done -# Newline must be treated specially by creating a new binding which is called -# as a readline macro when pressing enter (C-m). After that is finished, the macro -# continues with a regular C-j, which accepts (=executes) the line as you would expect. -bind -x '"\e\C-a": _try_abbrev ""' -bind '"\C-m": "\e\C-a\e^\C-j"' +# Newline must be treated specially, because it doesn't self-insert +# and has the extra functionality of accepting a line. +bind -x '"\e\C-an": _try_abbrev ""' +bind '"\C-m": "\e\C-an\e^\C-j"' From 3780b38734d0723a4c25fe210e6795b3e69770e3 Mon Sep 17 00:00:00 2001 From: tsi Date: Wed, 22 Oct 2025 17:17:57 +0200 Subject: [PATCH 05/29] #1: Recursively expand aliases and support 3 new syntaxes for alias definition & remove the ! syntax --- bash-abbr | 122 +++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 85 insertions(+), 37 deletions(-) diff --git a/bash-abbr b/bash-abbr index e3dc6fa..2590e90 100644 --- a/bash-abbr +++ b/bash-abbr @@ -29,7 +29,7 @@ _control_op () { _control_op+="|$_opening_paren" printf %s "$_control_op" } -_control_op="$(_control_op)" +declare _control_op="$(_control_op)" unset -f _control_op _redir_or_assign_word () { @@ -65,19 +65,32 @@ _redir_or_assign_word () { declare -r _word_redirection_or_assignment="($_generic_redirection[[:space:]]*|$_identifier_equals)($_word)" printf %s "$_close_redirection|$_word_redirection_or_assignment" } -_redir_or_assign_word="$(_redir_or_assign_word)" +declare -r _redir_or_assign_word="$(_redir_or_assign_word)" unset -f _redir_or_assign_word -_try_abbrev () { - # For use within bind -x. - # Perform abbreviation lookup and interactive expansion on the shell-word left to the cursor (no quote parsing). - # Takes a single argument which is the character to self-insert (usually space or another shell metacharacter). +declare -r is_first_word_prefix="($_control_op)[[:space:]]*((($_redir_or_assign_word)[[:space:]])*)" +# If the regex leads to problems/performance issues, you may try this simpler regex +# that does no redirection/assignment word checking: +# declare -r is_first_word_prefix="($_control_op)[[:space:]]*" - declare -r line_until_point=${READLINE_LINE::READLINE_POINT} +unset -v _control_op + +_try_abbrev_internal () { + # Recursively perform abbrevation on the line defined by $1, taking $2 as the point and $3 as the mark. + # Set the vars defined by $4, $5 and $6 to the updated values (line, point and mark). + # $7 is the character/string to self-insert. + declare -r line=$1 point=$2 mark=$3 new_line=$4 new_point=$5 new_mark=$6 \ + self_insertion=$7 \ + level=${#FUNCNAME[@]} + # `level` is needed to keep track of the recursion, because we don't have a return value but rather + # set a variable defined in the calling context. This level is just appended to the identifier to make it unique + # across all the recursive calls. + + declare -r line_until_point=${line::point} if # Check if the character at point is a shell metacharacter or EOL - [[ $READLINE_POINT -eq ${#READLINE_LINE} || "${READLINE_LINE:READLINE_POINT:1}" = [\<\>\&\;\(\)\|[:space:]] ]] && + [[ $point -eq ${#line} || "${line:point:1}" = [\<\>\&\;\(\)\|[:space:]] ]] && # Determine word to complete. # Uses the same word separators as bash (| & ; ( ) space tab newline) (aka "shell metacharacters") @@ -87,7 +100,9 @@ _try_abbrev () { # Select replacement (if any). declare replacement=${BASH_ALIASES["${word_at_point:-__nonexistent}"]-} && - # Determine if it's the first word of a simple command, unless we're expanding a global alias. + [[ $replacement ]] && + + # Determine if it's the first word of a simple command, unless we're expanding a global alias (prefix: "*" or "$"). # Uses the same command separators as bash (|| & && ; ;; ;& ;;& ( ) | |& newline) (aka "control operators"). # Due to complexity and performance reasons, we cannot reliably know # whether the cursor position (aka "point" in readline terms) is in a quoted/escaped @@ -99,48 +114,81 @@ _try_abbrev () { # s.t. there's a syntax error. We also do some rudimentary redirection and assignment word # checking (including quoting) after the control operator. If the syntax is invalid (as far as # the regex can tell), then we bail and no alias expansion will happen. - [[ ${replacement::1} = "!" || ${line_until_point%"$word_at_point"} =~ ($_control_op)[[:space:]]*((($_redir_or_assign_word)[[:space:]])*)$ ]] && - # If the regex leads to problems/performance issues, you may try this simpler regex - # that does no redirection/assignment word checking: - # [[ ${replacement::1} = "!" || ${line_until_point%"$word_at_point"} =~ ($_control_op)[[:space:]]*$ ]] && - [[ $replacement ]]; then + [[ ${replacement::1} =~ [*$] || ${line_until_point%"$word_at_point"} =~ $is_first_word_prefix$ ]]; then - # Account for global aliases (strip "!" prefix and disable self-insertion) - [[ ${replacement::1} = "!" ]] && replacement=${replacement#!} && set -- '' + _expanded_aliases[$word_at_point]=1 + + # Account for inhibited self-insertion + [[ ${replacement::1} =~ [-$] ]] && set -- '' + + # Strip special flags (or space) from the beginning of the replacement + replacement=${replacement#[*$ -]} + + # Recursively expand replacement if we haven't expanded it already + declare tmp=" $replacement" + [[ $tmp =~ ^([[:space:]]*$is_first_word_prefix) ]] && tmp=${tmp#"${BASH_REMATCH[1]}"} || echo NO + declare -r word_to_expand=${tmp%%[<>&;()|[:space:]]*} + if [[ ! ${_expanded_aliases[$word_to_expand]-} ]]; then + declare -r point_for_replacement=$((${#BASH_REMATCH[1]}+${#word_to_expand})) + declare -g "ret_$level" + declare -n recursively_expanded_replacement="ret_$level" + _try_abbrev_internal " $replacement" "$point_for_replacement" "" "ret_$level" "_" "_" "" + replacement=${recursively_expanded_replacement# } + fi # Calculate point and mark offset. - declare -ir offset=${#replacement}-${#word_at_point}+${#1} + declare -ir offset=${#replacement}-${#word_at_point}+${#self_insertion} # Update line. - READLINE_LINE=${line_until_point%"$word_at_point"}$replacement${1-}${READLINE_LINE:READLINE_POINT} + eval "$new_line"'=${line_until_point%"$word_at_point"}$replacement${self_insertion}${line:point}' else - declare -ir offset=${#1} + declare -ir offset=${#self_insertion} # Self-insert by default - READLINE_LINE=${line_until_point}${1-}${READLINE_LINE:READLINE_POINT} + eval "$new_line"'=${line_until_point}${self_insertion}${line:point}' fi # Advance mark if it's right of (or on) the point. - [[ $READLINE_MARK && $READLINE_POINT -le "$READLINE_MARK" ]] && READLINE_MARK=$((READLINE_MARK+offset)) + [[ $mark && $mark -ge "$point" ]] && eval "$new_mark"'=$((mark+offset))' # Advance point. - READLINE_POINT=$((READLINE_POINT+offset)) - + eval "$new_point"'=$((point+offset))' # NB: READLINE_ARGUMENT is deliberately ignored. } +_try_abbrev () { + # For use within bind -x. + # Perform abbreviation lookup and interactive expansion on the shell-word left to the cursor (no quote parsing). + # Takes a single argument which is the character to self-insert (usually space or another shell metacharacter). -# From the bash man page: -# metacharacter -# A character that, when unquoted, separates words. One of the following: -# | & ; ( ) < > space tab newline -# Note: double quote should be escaped, if added to the list of metacharacters -for ch in ' ' '|' '&' ';' '(' ')' '<' '>'; do - bind -x '"\e\C-a'"$ch"'": _try_abbrev "'"$ch"'"' - bind '"'"$ch"'": "\e\C-a'"$ch"'"' - # bind '"'"$ch"'": "\e\C-a'"$ch"'\e^"' # Moves point weirdly -done + # To prevent infinite recursion, we need to keep track + # of which aliases have been expanded. This function re-initializes the associative array (used as a hash set) + declare -gA _expanded_aliases=() -# Newline must be treated specially, because it doesn't self-insert -# and has the extra functionality of accepting a line. -bind -x '"\e\C-an": _try_abbrev ""' -bind '"\C-m": "\e\C-an\e^\C-j"' + # Calls the implementation and pass necessary arguments + _try_abbrev_internal "$READLINE_LINE" "$READLINE_POINT" "$READLINE_MARK" READLINE_LINE READLINE_POINT READLINE_MARK "$@" + + # Don't pollute global namespace + unset -v _expanded_aliases +} + +main () { + # From the bash man page: + # metacharacter + # A character that, when unquoted, separates words. One of the following: + # | & ; ( ) < > space tab newline + # Note: double quote should be escaped, if added to the list of metacharacters + declare ch + for ch in ' ' '|' '&' ';' '(' ')' '<' '>'; do + bind -x '"\e\C-a'"$ch"'": _try_abbrev "'"$ch"'"' + bind '"'"$ch"'": "\e\C-a'"$ch"'"' + # bind '"'"$ch"'": "\e\C-a'"$ch"'\e^"' # Moves point weirdly + done + + # Newline must be treated specially, because it doesn't self-insert + # and has the extra functionality of accepting a line. + bind -x '"\e\C-an": _try_abbrev ""' + bind '"\C-m": "\e\C-an\e^\C-j"' +} + +# Disable plugin by commenting this line +main From 19d7d0fa60af59fb2520a0c9b8c81732e747a6dc Mon Sep 17 00:00:00 2001 From: tsi Date: Thu, 23 Oct 2025 01:06:46 +0200 Subject: [PATCH 06/29] #1: Clean up comments, function name, variable names --- bash-abbr | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/bash-abbr b/bash-abbr index 2590e90..78d0507 100644 --- a/bash-abbr +++ b/bash-abbr @@ -4,13 +4,9 @@ # put into the bashrc drop-in directory ~/.bashrc.d/ # and then sourced from ~/.bashrc. -# WARNING: This plug-in overwrites the readling bindings for ' ', '|', '&', ';', '(', ')', '<', '>' -# It also creates new bindings '\e\C-a' where `` is one of the characters above. -# Additionally, it overwrites the binding for '\C-m' and creates a corresponding binding '\e\C-an'. -# Therefore, it might interfere with other plug-ins or custom bindings. -# You may change the '\e\C-a' binding-"prefix" to whatever you want as it's only used -# as a hook inside other readline macros. The bindings '|', '&', ';', '(', ')', '<', '>' (and their -# corresponding '\e\C-a' bindings) may also be dropped if you don't need them. +# WARNING: This plugin creates and overwrites various readline bindings. +# Therefore, it might interfere with other plugins/custom bindings. +# Some bindings may be changed (e.g. \e\C-a...) while others should not (e.g. \C-m) # Regexes for (heuristically) determining whether we're abbreviating the first word. # Construction put into functions to prevent global namespace pollution @@ -28,9 +24,9 @@ _control_op () { _control_op+="|$_semicolon_or_case_operators" _control_op+="|$_opening_paren" printf %s "$_control_op" + unset -f _control_op } declare _control_op="$(_control_op)" -unset -f _control_op _redir_or_assign_word () { declare -r _varredir_or_number='\{[[:alpha:]_][[:alnum:]_]*\}|[[:digit:]]*' \ @@ -64,9 +60,9 @@ _redir_or_assign_word () { _generic_redirection="($_redir_or_append_out_and_err|($_varredir_or_number)($_heredoc_herestring_in_out_etc))" declare -r _word_redirection_or_assignment="($_generic_redirection[[:space:]]*|$_identifier_equals)($_word)" printf %s "$_close_redirection|$_word_redirection_or_assignment" + unset -f _redir_or_assign_word } declare -r _redir_or_assign_word="$(_redir_or_assign_word)" -unset -f _redir_or_assign_word declare -r is_first_word_prefix="($_control_op)[[:space:]]*((($_redir_or_assign_word)[[:space:]])*)" # If the regex leads to problems/performance issues, you may try this simpler regex @@ -80,8 +76,8 @@ _try_abbrev_internal () { # Set the vars defined by $4, $5 and $6 to the updated values (line, point and mark). # $7 is the character/string to self-insert. declare -r line=$1 point=$2 mark=$3 new_line=$4 new_point=$5 new_mark=$6 \ - self_insertion=$7 \ level=${#FUNCNAME[@]} + declare self_insertion=$7 # `level` is needed to keep track of the recursion, because we don't have a return value but rather # set a variable defined in the calling context. This level is just appended to the identifier to make it unique # across all the recursive calls. @@ -116,10 +112,11 @@ _try_abbrev_internal () { # the regex can tell), then we bail and no alias expansion will happen. [[ ${replacement::1} =~ [*$] || ${line_until_point%"$word_at_point"} =~ $is_first_word_prefix$ ]]; then + # Prevent infinite recursion _expanded_aliases[$word_at_point]=1 # Account for inhibited self-insertion - [[ ${replacement::1} =~ [-$] ]] && set -- '' + [[ ${replacement::1} =~ [-$] ]] && self_insertion= # Strip special flags (or space) from the beginning of the replacement replacement=${replacement#[*$ -]} @@ -171,11 +168,11 @@ _try_abbrev () { unset -v _expanded_aliases } -main () { +bash_abbr () { # From the bash man page: # metacharacter # A character that, when unquoted, separates words. One of the following: - # | & ; ( ) < > space tab newline + # | & ; ( ) < > space tab newline # Note: double quote should be escaped, if added to the list of metacharacters declare ch for ch in ' ' '|' '&' ';' '(' ')' '<' '>'; do @@ -184,11 +181,14 @@ main () { # bind '"'"$ch"'": "\e\C-a'"$ch"'\e^"' # Moves point weirdly done - # Newline must be treated specially, because it doesn't self-insert - # and has the extra functionality of accepting a line. bind -x '"\e\C-an": _try_abbrev ""' bind '"\C-m": "\e\C-an\e^\C-j"' } -# Disable plugin by commenting this line -main +# Disable plugin by commenting this line and uncommenting the line below that +# This function name shall never change, so that script that source +# this plugin can detect for it. I.e., in your .bashrc, you may do +# `type bash_abbr &>/dev/null && shopt -u expand_aliases` +# after sourcing this plugin. +bash_abbr +# unset -f bash_abbr From 8508aa15fb12e99e2a58993e713bcd4984cc001c Mon Sep 17 00:00:00 2001 From: tsi Date: Thu, 23 Oct 2025 01:07:13 +0200 Subject: [PATCH 07/29] #1: Allow expansion on \C-j --- bash-abbr | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bash-abbr b/bash-abbr index 78d0507..7e4ccdf 100644 --- a/bash-abbr +++ b/bash-abbr @@ -182,7 +182,9 @@ bash_abbr () { done bind -x '"\e\C-an": _try_abbrev ""' - bind '"\C-m": "\e\C-an\e^\C-j"' + bind '"\e\C-j": accept-line' + bind '"\C-m": "\e\C-an\e^\e\C-j"' + bind '"\C-j": "\C-m"' } # Disable plugin by commenting this line and uncommenting the line below that From 03302fd77092806ca17354f7442dc4be988b2392 Mon Sep 17 00:00:00 2001 From: tsi Date: Thu, 23 Oct 2025 01:12:21 +0200 Subject: [PATCH 08/29] #1: Add TODO comment about approaching a perfect implementation --- bash-abbr | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bash-abbr b/bash-abbr index 7e4ccdf..183ca7b 100644 --- a/bash-abbr +++ b/bash-abbr @@ -181,6 +181,10 @@ bash_abbr () { # bind '"'"$ch"'": "\e\C-a'"$ch"'\e^"' # Moves point weirdly done + # TODO To get it fully working in all edge cases (as far as we can parse), + # we need to implement recursive alias expansion on the entire line + # like bash does. Basically something like alias-expand-line but + # called as many times as possible before recursion would happen. bind -x '"\e\C-an": _try_abbrev ""' bind '"\e\C-j": accept-line' bind '"\C-m": "\e\C-an\e^\e\C-j"' From 2d6f7ff2abedd6ce9721adf45d5cb4beacd1ae79 Mon Sep 17 00:00:00 2001 From: tsi Date: Sun, 26 Oct 2025 18:42:18 +0100 Subject: [PATCH 09/29] Add support for file extension aliases --- bash-abbr | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/bash-abbr b/bash-abbr index 183ca7b..1582a50 100644 --- a/bash-abbr +++ b/bash-abbr @@ -88,14 +88,18 @@ _try_abbrev_internal () { # Check if the character at point is a shell metacharacter or EOL [[ $point -eq ${#line} || "${line:point:1}" = [\<\>\&\;\(\)\|[:space:]] ]] && - # Determine word to complete. + # Determine word to complete # Uses the same word separators as bash (| & ; ( ) space tab newline) (aka "shell metacharacters") # But < and > are ignored, since they always expect a file/delimiter/string afterwards and not a command declare -r word_at_point=${line_until_point##*[&;()|[:space:]]} && - # Select replacement (if any). + # Select replacement (if any) declare replacement=${BASH_ALIASES["${word_at_point:-__nonexistent}"]-} && + # Account for file extension aliases. Normal aliases take precedence + declare file_extension_replacement=${BASH_ALIASES[".${word_at_point##*.}"]-} && + [[ $replacement ]] || { replacement=${file_extension_replacement:+${file_extension_replacement%!} $word_at_point${file_extension_replacement: -1}} && replacement=${replacement%[^!]}; } && + [[ $replacement ]] && # Determine if it's the first word of a simple command, unless we're expanding a global alias (prefix: "*" or "$"). @@ -123,7 +127,7 @@ _try_abbrev_internal () { # Recursively expand replacement if we haven't expanded it already declare tmp=" $replacement" - [[ $tmp =~ ^([[:space:]]*$is_first_word_prefix) ]] && tmp=${tmp#"${BASH_REMATCH[1]}"} || echo NO + [[ $tmp =~ ^([[:space:]]*$is_first_word_prefix) ]] && tmp=${tmp#"${BASH_REMATCH[1]}"} declare -r word_to_expand=${tmp%%[<>&;()|[:space:]]*} if [[ ! ${_expanded_aliases[$word_to_expand]-} ]]; then declare -r point_for_replacement=$((${#BASH_REMATCH[1]}+${#word_to_expand})) From 17e66a1126e1aef460df2860b023172b3847e275 Mon Sep 17 00:00:00 2001 From: tsi Date: Sun, 26 Oct 2025 18:43:01 +0100 Subject: [PATCH 10/29] Add support for pre/post alias evaluation --- bash-abbr | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bash-abbr b/bash-abbr index 1582a50..e2647c0 100644 --- a/bash-abbr +++ b/bash-abbr @@ -125,6 +125,10 @@ _try_abbrev_internal () { # Strip special flags (or space) from the beginning of the replacement replacement=${replacement#[*$ -]} + [[ ${replacement: -1} = "!" ]] && declare -r eval_expansion=true || declare -r eval_expansion=false + + [[ ${replacement: -1} = "$" ]] && replacement=$(eval "${replacement%$}") + # Recursively expand replacement if we haven't expanded it already declare tmp=" $replacement" [[ $tmp =~ ^([[:space:]]*$is_first_word_prefix) ]] && tmp=${tmp#"${BASH_REMATCH[1]}"} @@ -136,6 +140,8 @@ _try_abbrev_internal () { _try_abbrev_internal " $replacement" "$point_for_replacement" "" "ret_$level" "_" "_" "" replacement=${recursively_expanded_replacement# } fi + # Eval if it has a trailing exclamation point and strip trailing \n and \r + [[ $eval_expansion = true ]] && replacement=$(eval "${replacement%!}") # Calculate point and mark offset. declare -ir offset=${#replacement}-${#word_at_point}+${#self_insertion} From 4a48b1440a8eee49fded17282bfa8660c184273c Mon Sep 17 00:00:00 2001 From: tsi Date: Sun, 26 Oct 2025 18:45:16 +0100 Subject: [PATCH 11/29] Pass shellcheck --- bash-abbr | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bash-abbr b/bash-abbr index e2647c0..599c3d7 100644 --- a/bash-abbr +++ b/bash-abbr @@ -26,9 +26,11 @@ _control_op () { printf %s "$_control_op" unset -f _control_op } +# shellcheck disable=SC2155 declare _control_op="$(_control_op)" _redir_or_assign_word () { + # shellcheck disable=SC2016 declare -r _varredir_or_number='\{[[:alpha:]_][[:alnum:]_]*\}|[[:digit:]]*' \ _redir_in_or_out='>|<' \ _close_or_move_fd='&[[:digit:]]*-?' \ @@ -58,10 +60,12 @@ _redir_or_assign_word () { declare -r _word="($_part)+" \ _close_redirection="($_varredir_or_number)($_redir_in_or_out)($_close_or_move_fd)" \ _generic_redirection="($_redir_or_append_out_and_err|($_varredir_or_number)($_heredoc_herestring_in_out_etc))" + # shellcheck disable=SC1087 declare -r _word_redirection_or_assignment="($_generic_redirection[[:space:]]*|$_identifier_equals)($_word)" printf %s "$_close_redirection|$_word_redirection_or_assignment" unset -f _redir_or_assign_word } +# shellcheck disable=SC2155 declare -r _redir_or_assign_word="$(_redir_or_assign_word)" declare -r is_first_word_prefix="($_control_op)[[:space:]]*((($_redir_or_assign_word)[[:space:]])*)" @@ -125,6 +129,7 @@ _try_abbrev_internal () { # Strip special flags (or space) from the beginning of the replacement replacement=${replacement#[*$ -]} + # shellcheck disable=SC2015 [[ ${replacement: -1} = "!" ]] && declare -r eval_expansion=true || declare -r eval_expansion=false [[ ${replacement: -1} = "$" ]] && replacement=$(eval "${replacement%$}") @@ -149,6 +154,7 @@ _try_abbrev_internal () { # Update line. eval "$new_line"'=${line_until_point%"$word_at_point"}$replacement${self_insertion}${line:point}' else + # shellcheck disable=SC2034 declare -ir offset=${#self_insertion} # Self-insert by default From 94f8211b87b78be4f35b7adabd09fbd834a3125f Mon Sep 17 00:00:00 2001 From: tsi Date: Thu, 25 Dec 2025 18:23:39 +0100 Subject: [PATCH 12/29] Improve and add comments for various things --- bash-abbr | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/bash-abbr b/bash-abbr index 599c3d7..a0c8b78 100644 --- a/bash-abbr +++ b/bash-abbr @@ -4,13 +4,16 @@ # put into the bashrc drop-in directory ~/.bashrc.d/ # and then sourced from ~/.bashrc. -# WARNING: This plugin creates and overwrites various readline bindings. -# Therefore, it might interfere with other plugins/custom bindings. -# Some bindings may be changed (e.g. \e\C-a...) while others should not (e.g. \C-m) +# Disable plugin by uncommenting this line +# return -# Regexes for (heuristically) determining whether we're abbreviating the first word. +# WARNING 1: This plugin creates and overwrites various readline bindings. +# Therefore, it might interfere with other plugins/custom bindings. +# Some bindings may be changed (e.g. \e\C-a...) while others should not (e.g. \C-m) + +# Regexes for heuristically determining whether we're abbreviating the first word. # Construction put into functions to prevent global namespace pollution -# and increase readability. +# and to increase readability. _control_op () { declare -r _newline=$'\n' \ _bg_proc_or_and_and='[^&;]&&?' \ @@ -27,7 +30,7 @@ _control_op () { unset -f _control_op } # shellcheck disable=SC2155 -declare _control_op="$(_control_op)" +declare _control_op="$(_control_op)" # TODO use bash 5.3 `${}` command substitution syntax to avoid subshells _redir_or_assign_word () { # shellcheck disable=SC2016 @@ -66,7 +69,7 @@ _redir_or_assign_word () { unset -f _redir_or_assign_word } # shellcheck disable=SC2155 -declare -r _redir_or_assign_word="$(_redir_or_assign_word)" +declare -r _redir_or_assign_word="$(_redir_or_assign_word)" # TODO use bash 5.3 `${}` command substitution syntax to avoid subshells declare -r is_first_word_prefix="($_control_op)[[:space:]]*((($_redir_or_assign_word)[[:space:]])*)" # If the regex leads to problems/performance issues, you may try this simpler regex @@ -170,14 +173,14 @@ _try_abbrev_internal () { } _try_abbrev () { # For use within bind -x. - # Perform abbreviation lookup and interactive expansion on the shell-word left to the cursor (no quote parsing). + # Perform abbreviation lookup and interactive expansion on the word left to the cursor (no quote parsing). # Takes a single argument which is the character to self-insert (usually space or another shell metacharacter). # To prevent infinite recursion, we need to keep track - # of which aliases have been expanded. This function re-initializes the associative array (used as a hash set) + # of which aliases have been expanded. This function re-initializes the global associative array (used as a hash set) declare -gA _expanded_aliases=() - # Calls the implementation and pass necessary arguments + # Calls the implementation and passes necessary arguments _try_abbrev_internal "$READLINE_LINE" "$READLINE_POINT" "$READLINE_MARK" READLINE_LINE READLINE_POINT READLINE_MARK "$@" # Don't pollute global namespace @@ -201,16 +204,16 @@ bash_abbr () { # we need to implement recursive alias expansion on the entire line # like bash does. Basically something like alias-expand-line but # called as many times as possible before recursion would happen. + # This is pretty hard, and surely even harder to do efficiently, + # so it will probably never be implemented. bind -x '"\e\C-an": _try_abbrev ""' bind '"\e\C-j": accept-line' bind '"\C-m": "\e\C-an\e^\e\C-j"' bind '"\C-j": "\C-m"' } -# Disable plugin by commenting this line and uncommenting the line below that -# This function name shall never change, so that script that source -# this plugin can detect for it. I.e., in your .bashrc, you may do -# `type bash_abbr &>/dev/null && shopt -u expand_aliases` -# after sourcing this plugin. bash_abbr -# unset -f bash_abbr + +# If the plugin is enabled, we usually don't want regular alias expansion, +# since it can lead to undesirable "double expansions" if an alias is defined recursively. +type bash_abbr &>/dev/null && shopt -u expand_aliases From fb37d2b89ecc6bce62c8a8acdfaa22a957229675 Mon Sep 17 00:00:00 2001 From: tsi Date: Thu, 25 Dec 2025 18:24:57 +0100 Subject: [PATCH 13/29] Expand aliases when point is on word The self-insertion character has to be empty for that to work This is usually the case when pressing enter --- bash-abbr | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/bash-abbr b/bash-abbr index a0c8b78..68dcb90 100644 --- a/bash-abbr +++ b/bash-abbr @@ -10,6 +10,8 @@ # WARNING 1: This plugin creates and overwrites various readline bindings. # Therefore, it might interfere with other plugins/custom bindings. # Some bindings may be changed (e.g. \e\C-a...) while others should not (e.g. \C-m) +# WARNING 2: This plugin needs and enables extended globbing. +shopt -s extglob # Regexes for heuristically determining whether we're abbreviating the first word. # Construction put into functions to prevent global namespace pollution @@ -82,18 +84,28 @@ _try_abbrev_internal () { # Recursively perform abbrevation on the line defined by $1, taking $2 as the point and $3 as the mark. # Set the vars defined by $4, $5 and $6 to the updated values (line, point and mark). # $7 is the character/string to self-insert. - declare -r line=$1 point=$2 mark=$3 new_line=$4 new_point=$5 new_mark=$6 \ - level=${#FUNCNAME[@]} + declare -r line=$1 mark=$3 new_line=$4 new_point=$5 new_mark=$6 \ + level=${#FUNCNAME[@]} + declare point=$2 declare self_insertion=$7 # `level` is needed to keep track of the recursion, because we don't have a return value but rather # set a variable defined in the calling context. This level is just appended to the identifier to make it unique # across all the recursive calls. - declare -r line_until_point=${line::point} + # If the self-insertion character is null (i.e. when pressing enter/accepting a line), + # then we also want to expand aliases if the cursor/point is *on* the word instead of just *after* the word. + if [[ -z $self_insertion ]]; then + line_from_point=${line:point} + line_from_point_without_partial_word=${line_from_point##*([^<>&;()|[:space:]])} # extglob must be enabled! + # Move point forward to right after the word. Keep mark where it is. + point=${#line}-${#line_from_point_without_partial_word} + fi + declare -r point line_until_point=${line::point} if # Check if the character at point is a shell metacharacter or EOL - [[ $point -eq ${#line} || "${line:point:1}" = [\<\>\&\;\(\)\|[:space:]] ]] && + # [[ $point -eq ${#line} || "${line:point:1}" = [\<\>\&\;\(\)\|[:space:]] ]] && + # NOTE: Above line commented out, it's actually not that useful. # Determine word to complete # Uses the same word separators as bash (| & ; ( ) space tab newline) (aka "shell metacharacters") @@ -141,7 +153,7 @@ _try_abbrev_internal () { declare tmp=" $replacement" [[ $tmp =~ ^([[:space:]]*$is_first_word_prefix) ]] && tmp=${tmp#"${BASH_REMATCH[1]}"} declare -r word_to_expand=${tmp%%[<>&;()|[:space:]]*} - if [[ ! ${_expanded_aliases[$word_to_expand]-} ]]; then + if [[ -z ${_expanded_aliases[$word_to_expand]-} ]]; then declare -r point_for_replacement=$((${#BASH_REMATCH[1]}+${#word_to_expand})) declare -g "ret_$level" declare -n recursively_expanded_replacement="ret_$level" @@ -149,7 +161,7 @@ _try_abbrev_internal () { replacement=${recursively_expanded_replacement# } fi # Eval if it has a trailing exclamation point and strip trailing \n and \r - [[ $eval_expansion = true ]] && replacement=$(eval "${replacement%!}") + "$eval_expansion" && replacement=$(eval "${replacement%!}") # Calculate point and mark offset. declare -ir offset=${#replacement}-${#word_at_point}+${#self_insertion} @@ -164,8 +176,8 @@ _try_abbrev_internal () { eval "$new_line"'=${line_until_point}${self_insertion}${line:point}' fi - # Advance mark if it's right of (or on) the point. - [[ $mark && $mark -ge "$point" ]] && eval "$new_mark"'=$((mark+offset))' + # Advance mark if it's set and right of (or on) the point. + [[ $mark && mark -ge point ]] && eval "$new_mark"'=$((mark+offset))' # Advance point. eval "$new_point"'=$((point+offset))' From d241bfda6a5be60e05ae8feef521563b06dae99c Mon Sep 17 00:00:00 2001 From: tsi Date: Thu, 25 Dec 2025 20:25:38 +0100 Subject: [PATCH 14/29] Add extensive documentation and fix 2 bugs Bug 1: Allow trailing space for inhibiting recognition of evaluating aliases Bug 2: Preserve pre-evaluation aliases in file extension aliases --- bash-abbr | 147 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 130 insertions(+), 17 deletions(-) diff --git a/bash-abbr b/bash-abbr index 68dcb90..087eb4a 100644 --- a/bash-abbr +++ b/bash-abbr @@ -4,14 +4,112 @@ # put into the bashrc drop-in directory ~/.bashrc.d/ # and then sourced from ~/.bashrc. -# Disable plugin by uncommenting this line +# Disable plugin by uncommenting the following line # return -# WARNING 1: This plugin creates and overwrites various readline bindings. -# Therefore, it might interfere with other plugins/custom bindings. -# Some bindings may be changed (e.g. \e\C-a...) while others should not (e.g. \C-m) -# WARNING 2: This plugin needs and enables extended globbing. -shopt -s extglob +###### bash-abbr: fish like abbreviations for bash, and more ###### +# +# After sourcing this plugin and in an interactive bash shell +# where --noediting was not supplied during invocation +# (readline must be enabled), regular bash aliases will be +# expanded as you type. This is different from normal alias +# behavior, where they are only expanded internally, invisible +# to the user. This is useful when sharing the screen, for +# making edits to the expanded version of an alias, or for +# personal reassurance that you're executing the correct command. +# +# Regular, internal alias expansion will be disabled for this in order +# to not exhibit unexpected behavior. See the end of this file for an explanation. +# +# Besides the expected behavior, there are a few extra features +# that are usually useful. +# +## Global Aliases (can be combined with other alias types): +# These aliases expand anywhere on the command line. +# Syntax: +# - alias [alias-name]=*[alias-value] +# - alias [alias-name]=$[alias-value] (identical but self-insertion is inhibited, see below) +# Examples: +# - alias G='*| grep' +# Typing `G` and pressing space will expand the string after the asterisk +# and self-insert the space. +# +## Post Evaluating Aliases: +# These aliases expand to the evaluation of the *recursively expanded* alias-value. +# Syntax: +# - alias [alias-name]=[alias-value]! +# Examples: +# - alias g=git gph='printf "g push %q HEAD" "$(git remote | head -1)"!' +# Typing `gph` and pressing enter might evaluate to something like +# `git push origin HEAD`. The `origin` part is dynamic, it will +# expand to whatever the first configured git remote is. Because this +# is the post-evaluating form of the evaluating aliases, the `g` was first +# expanded to `git`, only after which it was evaluated. +# +## Pre Evaluating Aliases: +# These aliases expand to the evaluation of the initial alias-value, after +# which recursive alias expansion continues. +# Syntax: +# - alias [alias-name]=[alias-value]$ +# Examples: +# - alias c='*wl-paste$' +# Typing `c` anywhere on the command line will immediately expand to the +# clipboard contents on wayland based systems. The expansion is subject +# to further recursive alias expansion. Note that +# - alias c='*wl-paste!' +# makes less sense, because copying a command that contains an alias would +# not expand in this case, leading to a "command not found" +# +## File Extension Aliases: +# These aliases expand words on the command line that end with a file extension +# (dot + suffix) to a custom command invocation that opens files of that file type. +# Similar to xdg-open and Freedesktop MIME types, but for the command line. +# Syntax: +# - alias .[file-extension-without-dot]=[program-handler] +# Examples: +# - alias .txt=v .js=v .bashrc=v +# Typing `test.txt`, `~/.bashrc` or `index.js` and pressing enter will expand to +# `v test.txt` (or the others respectively), which might in turn expand to `vim test.txt` +# if there is an alias like `alias v=vim`. +# +## Aliases with inhibited self-insertion: +# Upon expanding these aliases, there won't be any self-insertion at all. +# Syntax: +# - alias [alias-name]=-[alias-value] +# - alias [alias-name]=$[alias-value] (global alias) +# Examples: +# - alias gssh='$git@github.com:' +# Typing `gssh` and pressing space will expand to `git@github.com:` without any trailing space. +# This is useful, because you usually enter a GitHub username directly after the colon without +# any characters in between. The `$` form was chosen because this alias can usually appear +# anywhere on the command line. +# - alias cuhs='-curl https://' +# Typing `cuhs` and pressing space will expand to `curl https://` without any trailing space. +# This is similar to the example above, only that the `-` form was chose because this alias +# usually doesn't make sense anywhere on the command line. +# +## Preventing alias types from being recognized: +# If the alias-value is supposed to start with a $, * or !, then you can prefix it with a single space. +# If the alias-value is supposed to start with a space, prefix it with another space. +# If the alias-value is supposed to end with $ or !, suffix it with a space. +# If the alias-value is supposed to end with a space, suffix it with another space. +# +## Combing alias types: +# Every alias type can be combined with any other alias type, as far as the syntax +# allows it (only one trailing character, only one leading character). +# Therefore, you can create an alias like this: +# - alias c=cat .txt='$c!' +# And then use it on the command line like this: +# - echo a.txt| +# `|` represents the cursor. Assuming the file a.txt contains the text `hey`, +# pressing space now would lead to the following result: +# - echo hey| +# Therefore, a global alias without self-insertion ($) was used on a file extension alias (.txt) +# and the result recursively expanded (c -> cat) and finally evaluated (post-evaluation, !). + +# WARNING: This plugin creates and overwrites various readline bindings. +# Therefore, it might interfere with other plugins/custom bindings. +# Some bindings may be changed (e.g. \e\C-a...) while others should not (e.g. \C-m) # Regexes for heuristically determining whether we're abbreviating the first word. # Construction put into functions to prevent global namespace pollution @@ -96,9 +194,9 @@ _try_abbrev_internal () { # then we also want to expand aliases if the cursor/point is *on* the word instead of just *after* the word. if [[ -z $self_insertion ]]; then line_from_point=${line:point} - line_from_point_without_partial_word=${line_from_point##*([^<>&;()|[:space:]])} # extglob must be enabled! + trailing_part_of_word=${line_from_point%%[<>&;()|[:space:]]*} # Move point forward to right after the word. Keep mark where it is. - point=${#line}-${#line_from_point_without_partial_word} + point=$((point+${#trailing_part_of_word})) fi declare -r point line_until_point=${line::point} @@ -117,7 +215,12 @@ _try_abbrev_internal () { # Account for file extension aliases. Normal aliases take precedence declare file_extension_replacement=${BASH_ALIASES[".${word_at_point##*.}"]-} && - [[ $replacement ]] || { replacement=${file_extension_replacement:+${file_extension_replacement%!} $word_at_point${file_extension_replacement: -1}} && replacement=${replacement%[^!]}; } && + [[ $replacement ]] || + { # The logic below preserved trailing exclamation points (`!`) or dollar signs (`$`) at the end, + # which are special characters used for evaluating aliases. + replacement=${file_extension_replacement:+${file_extension_replacement%[$\!]} $word_at_point${file_extension_replacement: -1}} && + replacement=${replacement%[^$\!]} + } && [[ $replacement ]] && @@ -144,24 +247,29 @@ _try_abbrev_internal () { # Strip special flags (or space) from the beginning of the replacement replacement=${replacement#[*$ -]} + # Account for Post Evaluating Aliases # shellcheck disable=SC2015 [[ ${replacement: -1} = "!" ]] && declare -r eval_expansion=true || declare -r eval_expansion=false - [[ ${replacement: -1} = "$" ]] && replacement=$(eval "${replacement%$}") + # Account for and potentially perform Pre Evaluating Alias expansion + [[ ${replacement: -1} = "$" ]] && replacement=$(eval "${replacement%$}") # TODO use bash 5.3 `${}` command substitution syntax to avoid subshells # Recursively expand replacement if we haven't expanded it already - declare tmp=" $replacement" - [[ $tmp =~ ^([[:space:]]*$is_first_word_prefix) ]] && tmp=${tmp#"${BASH_REMATCH[1]}"} + declare tmp=" $replacement" # Prepend space, makes the logic easier + [[ $tmp =~ ^([[:space:]]*$is_first_word_prefix) ]] && tmp=${tmp#"${BASH_REMATCH[1]}"} # Strip matching prefix declare -r word_to_expand=${tmp%%[<>&;()|[:space:]]*} if [[ -z ${_expanded_aliases[$word_to_expand]-} ]]; then declare -r point_for_replacement=$((${#BASH_REMATCH[1]}+${#word_to_expand})) declare -g "ret_$level" declare -n recursively_expanded_replacement="ret_$level" _try_abbrev_internal " $replacement" "$point_for_replacement" "" "ret_$level" "_" "_" "" - replacement=${recursively_expanded_replacement# } + replacement=${recursively_expanded_replacement# } # Strip the prepended space again fi - # Eval if it has a trailing exclamation point and strip trailing \n and \r - "$eval_expansion" && replacement=$(eval "${replacement%!}") + # Perform Post Evaluating Alias expansion + "$eval_expansion" && replacement=$(eval "${replacement%!}") # TODO use bash 5.3 `${}` command substitution syntax to avoid subshells + + # Strip trailing space that might've been added to prevent evaluating aliases from being recognized + replacement=${replacement% } # Calculate point and mark offset. declare -ir offset=${#replacement}-${#word_at_point}+${#self_insertion} @@ -209,7 +317,7 @@ bash_abbr () { for ch in ' ' '|' '&' ';' '(' ')' '<' '>'; do bind -x '"\e\C-a'"$ch"'": _try_abbrev "'"$ch"'"' bind '"'"$ch"'": "\e\C-a'"$ch"'"' - # bind '"'"$ch"'": "\e\C-a'"$ch"'\e^"' # Moves point weirdly + # bind '"'"$ch"'": "\e\C-a'"$ch"'\e^"' # TODO fix. Moves point weirdly atm. done # TODO To get it fully working in all edge cases (as far as we can parse), @@ -228,4 +336,9 @@ bash_abbr # If the plugin is enabled, we usually don't want regular alias expansion, # since it can lead to undesirable "double expansions" if an alias is defined recursively. -type bash_abbr &>/dev/null && shopt -u expand_aliases +# fish solves this by treating aliases differently from abbreviations. +# This, however, leads to the invonvenience of defining aliases/abbrevations twice, +# or give up one of the two. This plugin prefers the disabling of normal aliases, since +# it's more transparent to the user. The final command line is exactly what will be +# executed, no hidden aliases that might add hidden flags to some of your commands. +[[ $(type -t bash_abbr) = function ]] && shopt -u expand_aliases # TODO use bash 5.3 `${}` command substitution syntax to avoid subshells From 30540f0bbfab9f08b598a1f553d6ba317beb96d9 Mon Sep 17 00:00:00 2001 From: tsi Date: Thu, 25 Dec 2025 20:43:59 +0100 Subject: [PATCH 15/29] Fix bug where alias could contain < or > --- bash-abbr | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bash-abbr b/bash-abbr index 087eb4a..bba3d29 100644 --- a/bash-abbr +++ b/bash-abbr @@ -210,6 +210,9 @@ _try_abbrev_internal () { # But < and > are ignored, since they always expect a file/delimiter/string afterwards and not a command declare -r word_at_point=${line_until_point##*[&;()|[:space:]]} && + # Ensure that word does not contain either < or > + [[ ! $word_at_point =~ [\<\>] ]] && + # Select replacement (if any) declare replacement=${BASH_ALIASES["${word_at_point:-__nonexistent}"]-} && From 31ad7d7c28b5852359446d21abaf44d400079a60 Mon Sep 17 00:00:00 2001 From: tsi Date: Thu, 25 Dec 2025 20:48:22 +0100 Subject: [PATCH 16/29] Use [:blank:] when appropriate --- bash-abbr | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bash-abbr b/bash-abbr index bba3d29..fcfbf80 100644 --- a/bash-abbr +++ b/bash-abbr @@ -119,7 +119,7 @@ _control_op () { _bg_proc_or_and_and='[^&;]&&?' \ _pipe_or_or_or='[^&|;]\|\|?' \ _semicolon_or_case_operators='[^;|];;?&?' \ - _opening_paren='(^|[^[:alnum:]])\(' + _opening_paren='(^|[^[:alnum:]])\(' declare _control_op="^" _control_op+="|$_newline" _control_op+="|$_bg_proc_or_and_and" @@ -140,7 +140,7 @@ _redir_or_assign_word () { _redir_or_append_out_and_err='(&)(>>?)' \ _heredoc_herestring_in_out_etc='>>?|<(>|<(-?|<))?' \ _identifier_equals='[[:alpha:]_][[:alnum:]]*=' \ - _simple_unquoted_word='[^[:space:]<>&;()|'\''"`$]+' \ + _simple_unquoted_word='[^[:blank:]<>&;()|'\''"`$]+' \ _double_quoted_string='\$?"([^"]|\\")*"' \ _single_quoted_string="\\\$?'[^']*'" \ _backtick_string='`([^`]|\\`)*`' \ @@ -164,17 +164,17 @@ _redir_or_assign_word () { _close_redirection="($_varredir_or_number)($_redir_in_or_out)($_close_or_move_fd)" \ _generic_redirection="($_redir_or_append_out_and_err|($_varredir_or_number)($_heredoc_herestring_in_out_etc))" # shellcheck disable=SC1087 - declare -r _word_redirection_or_assignment="($_generic_redirection[[:space:]]*|$_identifier_equals)($_word)" + declare -r _word_redirection_or_assignment="($_generic_redirection[[:blank:]]*|$_identifier_equals)($_word)" printf %s "$_close_redirection|$_word_redirection_or_assignment" unset -f _redir_or_assign_word } # shellcheck disable=SC2155 declare -r _redir_or_assign_word="$(_redir_or_assign_word)" # TODO use bash 5.3 `${}` command substitution syntax to avoid subshells -declare -r is_first_word_prefix="($_control_op)[[:space:]]*((($_redir_or_assign_word)[[:space:]])*)" +declare -r is_first_word_prefix="($_control_op)[[:blank:]]*((($_redir_or_assign_word)[[:blank:]])*)" # If the regex leads to problems/performance issues, you may try this simpler regex # that does no redirection/assignment word checking: -# declare -r is_first_word_prefix="($_control_op)[[:space:]]*" +# declare -r is_first_word_prefix="($_control_op)[[:blank:]]*" unset -v _control_op @@ -259,7 +259,7 @@ _try_abbrev_internal () { # Recursively expand replacement if we haven't expanded it already declare tmp=" $replacement" # Prepend space, makes the logic easier - [[ $tmp =~ ^([[:space:]]*$is_first_word_prefix) ]] && tmp=${tmp#"${BASH_REMATCH[1]}"} # Strip matching prefix + [[ $tmp =~ ^([[:blank:]]*$is_first_word_prefix) ]] && tmp=${tmp#"${BASH_REMATCH[1]}"} # Strip matching prefix declare -r word_to_expand=${tmp%%[<>&;()|[:space:]]*} if [[ -z ${_expanded_aliases[$word_to_expand]-} ]]; then declare -r point_for_replacement=$((${#BASH_REMATCH[1]}+${#word_to_expand})) From b2639827d713b707581b58ab275d31975dbc1210 Mon Sep 17 00:00:00 2001 From: tsi Date: Sat, 3 Jan 2026 01:21:57 +0100 Subject: [PATCH 17/29] Allow an even number of backslashes before control operators and use variables for shell metacharacters --- bash-abbr | 40 ++++++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/bash-abbr b/bash-abbr index fcfbf80..14f7136 100644 --- a/bash-abbr +++ b/bash-abbr @@ -111,15 +111,21 @@ # Therefore, it might interfere with other plugins/custom bindings. # Some bindings may be changed (e.g. \e\C-a...) while others should not (e.g. \C-m) -# Regexes for heuristically determining whether we're abbreviating the first word. +# Setting IFS to null so we can use "${var[*]}" syntax to make a string out of the meta char array +_bash_base_meta_chars=('|' '&' ';' '(' ')') +_bash_meta_chars_without_left_right_angle_brackets=("${_bash_base_meta_chars[@]}" ' ' $'\t' $'\n') +_bash_meta_chars_without_tab_and_nl=("${_bash_base_meta_chars[@]}" '<' '>' ' ') +_bash_meta_chars=("${_bash_base_meta_chars[@]}" '<' '>' ' ' $'\t' $'\n') + +# Regexes for heuristically determining whether we're doing expansion on the first word. # Construction put into functions to prevent global namespace pollution # and to increase readability. _control_op () { declare -r _newline=$'\n' \ - _bg_proc_or_and_and='[^&;]&&?' \ - _pipe_or_or_or='[^&|;]\|\|?' \ - _semicolon_or_case_operators='[^;|];;?&?' \ - _opening_paren='(^|[^[:alnum:]])\(' + _bg_proc_or_and_and='[^\\](\\\\)*&&?' \ + _pipe_or_or_or='[^\\](\\\\)*\|\|?' \ + _semicolon_or_case_operators='[^\\](\\\\)*;;?&?' \ + _opening_paren='(^|[^[:alnum:]\\])(\\\\)*\(' declare _control_op="^" _control_op+="|$_newline" _control_op+="|$_bg_proc_or_and_and" @@ -190,11 +196,14 @@ _try_abbrev_internal () { # set a variable defined in the calling context. This level is just appended to the identifier to make it unique # across all the recursive calls. + # IFS must be null so "${var[*]}" concatenates without space + declare -r IFS= + # If the self-insertion character is null (i.e. when pressing enter/accepting a line), # then we also want to expand aliases if the cursor/point is *on* the word instead of just *after* the word. if [[ -z $self_insertion ]]; then line_from_point=${line:point} - trailing_part_of_word=${line_from_point%%[<>&;()|[:space:]]*} + trailing_part_of_word=${line_from_point%%["${_bash_meta_chars[*]}"]*} # Move point forward to right after the word. Keep mark where it is. point=$((point+${#trailing_part_of_word})) fi @@ -202,13 +211,13 @@ _try_abbrev_internal () { if # Check if the character at point is a shell metacharacter or EOL - # [[ $point -eq ${#line} || "${line:point:1}" = [\<\>\&\;\(\)\|[:space:]] ]] && + # [[ $point -eq ${#line} || "${line:point:1}" = ["${_bash_meta_chars[*]}"] ]] && # NOTE: Above line commented out, it's actually not that useful. # Determine word to complete # Uses the same word separators as bash (| & ; ( ) space tab newline) (aka "shell metacharacters") # But < and > are ignored, since they always expect a file/delimiter/string afterwards and not a command - declare -r word_at_point=${line_until_point##*[&;()|[:space:]]} && + declare -r word_at_point=${line_until_point##*["${_bash_meta_chars_without_left_right_angle_brackets[*]}"]} && # Ensure that word does not contain either < or > [[ ! $word_at_point =~ [\<\>] ]] && @@ -260,7 +269,7 @@ _try_abbrev_internal () { # Recursively expand replacement if we haven't expanded it already declare tmp=" $replacement" # Prepend space, makes the logic easier [[ $tmp =~ ^([[:blank:]]*$is_first_word_prefix) ]] && tmp=${tmp#"${BASH_REMATCH[1]}"} # Strip matching prefix - declare -r word_to_expand=${tmp%%[<>&;()|[:space:]]*} + declare -r word_to_expand=${tmp%%["${_bash_meta_chars[*]}"]*} if [[ -z ${_expanded_aliases[$word_to_expand]-} ]]; then declare -r point_for_replacement=$((${#BASH_REMATCH[1]}+${#word_to_expand})) declare -g "ret_$level" @@ -307,20 +316,23 @@ _try_abbrev () { _try_abbrev_internal "$READLINE_LINE" "$READLINE_POINT" "$READLINE_MARK" READLINE_LINE READLINE_POINT READLINE_MARK "$@" # Don't pollute global namespace - unset -v _expanded_aliases + declare -gA _expanded_aliases=() } bash_abbr () { + # IFS must be null so "${var[*]}" concatenates without space + declare -r IFS= + # From the bash man page: # metacharacter # A character that, when unquoted, separates words. One of the following: # | & ; ( ) < > space tab newline # Note: double quote should be escaped, if added to the list of metacharacters declare ch - for ch in ' ' '|' '&' ';' '(' ')' '<' '>'; do - bind -x '"\e\C-a'"$ch"'": _try_abbrev "'"$ch"'"' - bind '"'"$ch"'": "\e\C-a'"$ch"'"' - # bind '"'"$ch"'": "\e\C-a'"$ch"'\e^"' # TODO fix. Moves point weirdly atm. + for ch in "${_bash_meta_chars_without_tab_and_nl[@]}"; do + bind_if -x '"\e\C-a'"$ch"'": _try_abbrev "'"$ch"'"' + bind_if '"'"$ch"'": "\e\C-a'"$ch"'"' + # bind_if '"'"$ch"'": "\e\C-a'"$ch"'\e^"' # TODO fix. Moves point weirdly atm. done # TODO To get it fully working in all edge cases (as far as we can parse), From 43f68fefd32aa1843ca9dde6e8dedab87dcbe275 Mon Sep 17 00:00:00 2001 From: tsi Date: Sat, 3 Jan 2026 01:22:28 +0100 Subject: [PATCH 18/29] Add clobbering (>|) output redirection --- bash-abbr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bash-abbr b/bash-abbr index 14f7136..c1b0c0a 100644 --- a/bash-abbr +++ b/bash-abbr @@ -144,7 +144,7 @@ _redir_or_assign_word () { _redir_in_or_out='>|<' \ _close_or_move_fd='&[[:digit:]]*-?' \ _redir_or_append_out_and_err='(&)(>>?)' \ - _heredoc_herestring_in_out_etc='>>?|<(>|<(-?|<))?' \ + _heredoc_herestring_in_out_etc='>(>|\|)?|<(>|<(-?|<))?' \ _identifier_equals='[[:alpha:]_][[:alnum:]]*=' \ _simple_unquoted_word='[^[:blank:]<>&;()|'\''"`$]+' \ _double_quoted_string='\$?"([^"]|\\")*"' \ From c2bf5cec363c292ac694d3bf8739315d682872b1 Mon Sep 17 00:00:00 2001 From: tsi Date: Sat, 3 Jan 2026 01:23:43 +0100 Subject: [PATCH 19/29] Improve regex for simple and braced variable detection --- bash-abbr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bash-abbr b/bash-abbr index c1b0c0a..d0bbb37 100644 --- a/bash-abbr +++ b/bash-abbr @@ -150,8 +150,8 @@ _redir_or_assign_word () { _double_quoted_string='\$?"([^"]|\\")*"' \ _single_quoted_string="\\\$?'[^']*'" \ _backtick_string='`([^`]|\\`)*`' \ - _simple_variable='\$[[:alpha:]_][[:alnum:]]*' \ - _advanced_variable='\$\{([[:alnum:]]+|[#-_$!*#])[^{}]*\}' \ + _simple_variable='\$([[:alpha:]_][[:alnum:]]*|[-_$!*#[:digit:]])' \ + _advanced_variable='\$\{([[:alpha:]_][[:alnum:]]*|[-_$!*#[:digit:]])[^}]*\}' \ _deprecated_arithmetic_eval='\$\[[^][]*\]' \ _cmd_subst_or_arithmetic_eval='\$\([^()]*\)' \ _process_substition='<\([^()]*\)' \ From 275a1ccd843e10abf655e1daa50c8279468573b6 Mon Sep 17 00:00:00 2001 From: tsi Date: Sat, 3 Jan 2026 01:24:05 +0100 Subject: [PATCH 20/29] Allow arithmetic expansions --- bash-abbr | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bash-abbr b/bash-abbr index d0bbb37..8d27df8 100644 --- a/bash-abbr +++ b/bash-abbr @@ -153,7 +153,8 @@ _redir_or_assign_word () { _simple_variable='\$([[:alpha:]_][[:alnum:]]*|[-_$!*#[:digit:]])' \ _advanced_variable='\$\{([[:alpha:]_][[:alnum:]]*|[-_$!*#[:digit:]])[^}]*\}' \ _deprecated_arithmetic_eval='\$\[[^][]*\]' \ - _cmd_subst_or_arithmetic_eval='\$\([^()]*\)' \ + _cmd_subst='\$\([^()]*\)' \ + _arithmetic_eval='\$\(\([^()]*\)\)' \ _process_substition='<\([^()]*\)' \ _escaped_char='\\.' declare _part="$_simple_unquoted_word" @@ -163,7 +164,8 @@ _redir_or_assign_word () { _part+="|$_simple_variable" _part+="|$_advanced_variable" _part+="|$_deprecated_arithmetic_eval" - _part+="|$_cmd_subst_or_arithmetic_eval" + _part+="|$_cmd_subst" + _part+="|$_arithmetic_eval" _part+="|$_process_substition" _part+="|$_escaped_char" declare -r _word="($_part)+" \ From 1cb02b03344845eced28fbbc66808bdc0b35df3d Mon Sep 17 00:00:00 2001 From: tsi Date: Sat, 3 Jan 2026 01:24:21 +0100 Subject: [PATCH 21/29] Allow null (empty) assignment words --- bash-abbr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bash-abbr b/bash-abbr index 8d27df8..3c859cb 100644 --- a/bash-abbr +++ b/bash-abbr @@ -168,7 +168,7 @@ _redir_or_assign_word () { _part+="|$_arithmetic_eval" _part+="|$_process_substition" _part+="|$_escaped_char" - declare -r _word="($_part)+" \ + declare -r _word="($_part)*" \ _close_redirection="($_varredir_or_number)($_redir_in_or_out)($_close_or_move_fd)" \ _generic_redirection="($_redir_or_append_out_and_err|($_varredir_or_number)($_heredoc_herestring_in_out_etc))" # shellcheck disable=SC1087 From 855772503ad017b7de2107da4d74b005f3afbb17 Mon Sep 17 00:00:00 2001 From: tsi Date: Sat, 3 Jan 2026 01:24:46 +0100 Subject: [PATCH 22/29] Unset _redir_or_assign_word --- bash-abbr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bash-abbr b/bash-abbr index 3c859cb..1056501 100644 --- a/bash-abbr +++ b/bash-abbr @@ -177,14 +177,14 @@ _redir_or_assign_word () { unset -f _redir_or_assign_word } # shellcheck disable=SC2155 -declare -r _redir_or_assign_word="$(_redir_or_assign_word)" # TODO use bash 5.3 `${}` command substitution syntax to avoid subshells +declare _redir_or_assign_word="$(_redir_or_assign_word)" # TODO use bash 5.3 `${}` command substitution syntax to avoid subshells declare -r is_first_word_prefix="($_control_op)[[:blank:]]*((($_redir_or_assign_word)[[:blank:]])*)" # If the regex leads to problems/performance issues, you may try this simpler regex # that does no redirection/assignment word checking: # declare -r is_first_word_prefix="($_control_op)[[:blank:]]*" -unset -v _control_op +unset -v _control_op _redir_or_assign_word _try_abbrev_internal () { # Recursively perform abbrevation on the line defined by $1, taking $2 as the point and $3 as the mark. From f6c4e87af284c9208b0f1fc6e86a773113a0463f Mon Sep 17 00:00:00 2001 From: tsi Date: Sat, 3 Jan 2026 01:25:09 +0100 Subject: [PATCH 23/29] Make is_first_word_prefix regex a global var if bash-abbr is sourced in a function --- bash-abbr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bash-abbr b/bash-abbr index 1056501..423f7eb 100644 --- a/bash-abbr +++ b/bash-abbr @@ -179,7 +179,7 @@ _redir_or_assign_word () { # shellcheck disable=SC2155 declare _redir_or_assign_word="$(_redir_or_assign_word)" # TODO use bash 5.3 `${}` command substitution syntax to avoid subshells -declare -r is_first_word_prefix="($_control_op)[[:blank:]]*((($_redir_or_assign_word)[[:blank:]])*)" +declare -gr is_first_word_prefix="($_control_op)[[:blank:]]*((($_redir_or_assign_word)[[:blank:]]+)*)" # If the regex leads to problems/performance issues, you may try this simpler regex # that does no redirection/assignment word checking: # declare -r is_first_word_prefix="($_control_op)[[:blank:]]*" From 9f4875ebcc1a21e3c1a26a5cbbb708cad1734993 Mon Sep 17 00:00:00 2001 From: tsi Date: Sat, 3 Jan 2026 01:26:20 +0100 Subject: [PATCH 24/29] Use 'interactive alias expansion' rather than 'abbreviation' --- bash-abbr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bash-abbr b/bash-abbr index 423f7eb..c4df275 100644 --- a/bash-abbr +++ b/bash-abbr @@ -187,7 +187,7 @@ declare -gr is_first_word_prefix="($_control_op)[[:blank:]]*((($_redir_or_assign unset -v _control_op _redir_or_assign_word _try_abbrev_internal () { - # Recursively perform abbrevation on the line defined by $1, taking $2 as the point and $3 as the mark. + # Recursively perform interactive alias expansion on the line defined by $1, taking $2 as the point and $3 as the mark. # Set the vars defined by $4, $5 and $6 to the updated values (line, point and mark). # $7 is the character/string to self-insert. declare -r line=$1 mark=$3 new_line=$4 new_point=$5 new_mark=$6 \ From 0593b06dafb23cf2221f4b7622870f2bbb99ddbe Mon Sep 17 00:00:00 2001 From: tsi Date: Sat, 3 Jan 2026 01:29:07 +0100 Subject: [PATCH 25/29] Only try expansion when the self-insertion character is a shell meta-char --- bash-abbr | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bash-abbr b/bash-abbr index c4df275..7791ace 100644 --- a/bash-abbr +++ b/bash-abbr @@ -212,6 +212,13 @@ _try_abbrev_internal () { declare -r point line_until_point=${line::point} if + # Check if the self-insertion character is a shell metacharacter, except tab and newline (we don't want + # interactive alias expansion on tab and newline is represented as the empty self-insertion character) + case "$self_insertion" in + (["${_bash_meta_chars_without_tab_and_nl[*]}"]|'') ;; + (*) false + esac && + # Check if the character at point is a shell metacharacter or EOL # [[ $point -eq ${#line} || "${line:point:1}" = ["${_bash_meta_chars[*]}"] ]] && # NOTE: Above line commented out, it's actually not that useful. From 342d792a9da1025faf5db13d4fb37090ad9b41f3 Mon Sep 17 00:00:00 2001 From: tsi Date: Sat, 3 Jan 2026 01:29:15 +0100 Subject: [PATCH 26/29] Improve comments --- bash-abbr | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/bash-abbr b/bash-abbr index 7791ace..a939959 100644 --- a/bash-abbr +++ b/bash-abbr @@ -223,7 +223,7 @@ _try_abbrev_internal () { # [[ $point -eq ${#line} || "${line:point:1}" = ["${_bash_meta_chars[*]}"] ]] && # NOTE: Above line commented out, it's actually not that useful. - # Determine word to complete + # Determine word to complete by stripping leading shell metacharacters # Uses the same word separators as bash (| & ; ( ) space tab newline) (aka "shell metacharacters") # But < and > are ignored, since they always expect a file/delimiter/string afterwards and not a command declare -r word_at_point=${line_until_point##*["${_bash_meta_chars_without_left_right_angle_brackets[*]}"]} && @@ -306,16 +306,19 @@ _try_abbrev_internal () { fi # Advance mark if it's set and right of (or on) the point. - [[ $mark && mark -ge point ]] && eval "$new_mark"'=$((mark+offset))' + [[ $mark && mark -gt point ]] && eval "$new_mark"'=$((mark+offset))' + [[ -v "$new_mark" ]] || eval "$new_mark"'=$mark' # Advance point. eval "$new_point"'=$((point+offset))' - # NB: READLINE_ARGUMENT is deliberately ignored. + # NB: READLINE_ARGUMENT is deliberately ignored, as it doesn't make sense for interactive alias expansion. } + _try_abbrev () { - # For use within bind -x. - # Perform abbreviation lookup and interactive expansion on the word left to the cursor (no quote parsing). - # Takes a single argument which is the character to self-insert (usually space or another shell metacharacter). + # For use within bash's bind -x builtin. + # Perform interactive alias expansion on the word left to the cursor (no quote parsing) and also *on* the cursor if the + # self-insertion character is empty (which represents pressing enter). + # Takes a single argument which is the character to self-insert (usually space or another shell metacharacter, or empty for newline/enter). # To prevent infinite recursion, we need to keep track # of which aliases have been expanded. This function re-initializes the global associative array (used as a hash set) From 16032699054ddfef6a05eedbd3ff9c30be00092f Mon Sep 17 00:00:00 2001 From: tsi Date: Sat, 3 Jan 2026 01:29:33 +0100 Subject: [PATCH 27/29] Only bind when in line editing is enabled --- bash-abbr | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/bash-abbr b/bash-abbr index a939959..036b444 100644 --- a/bash-abbr +++ b/bash-abbr @@ -353,10 +353,18 @@ bash_abbr () { # called as many times as possible before recursion would happen. # This is pretty hard, and surely even harder to do efficiently, # so it will probably never be implemented. - bind -x '"\e\C-an": _try_abbrev ""' - bind '"\e\C-j": accept-line' - bind '"\C-m": "\e\C-an\e^\e\C-j"' - bind '"\C-j": "\C-m"' + bind_if -x '"\e\C-an": _try_abbrev ""' + bind_if '"\e\C-j": accept-line' + bind_if '"\C-m": "\e\C-an\e^\e\C-j"' + bind_if '"\C-j": "\C-m"' +} + +has_line_editing () { + shopt -qo emacs || shopt -qo vi +} + +bind_if () { + has_line_editing && builtin bind "$@" || true } bash_abbr From 7f7c386439265cca9d9f121cdf34fd87845548a4 Mon Sep 17 00:00:00 2001 From: tsi Date: Sat, 3 Jan 2026 01:32:34 +0100 Subject: [PATCH 28/29] Add unit tests and testing framework --- tests | 271 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100755 tests diff --git a/tests b/tests new file mode 100755 index 0000000..bc98b78 --- /dev/null +++ b/tests @@ -0,0 +1,271 @@ +#!/usr/bin/env bash + +printf_if_clr () { + [[ -v NO_COLOR ]] && return + [[ -v FORCE_COLOR ]] || [[ -v CLIFORCE_COLOR ]] && { printf "$@"; return; } + [[ -v CLICOLOR ]] || [[ -v TERM ]] && [[ -t 1 ]] && { printf "$@"; return; } + + # Fallback behavior + [[ -t 1 ]] && printf "$@" + +} +log () { + clr=$1 + shift + case "$clr" in + (white) printf_if_clr '\033[97m' ;; + (grey) printf_if_clr '\033[90m' ;; + (green) printf_if_clr '\033[92m' ;; + (red) printf_if_clr '\033[91m' ;; + (*) printf_if_clr '\033[m' + esac + printf "$@" + printf_if_clr '\033[m' +} +white () { log white "$@"; } +grey () { log grey "$@"; } +green () { log green "$@"; } +red () { log red "$@"; } + +init () { + white '## Unit tests for bash-abbr ##\n' + white '# Running on bash version %s system type %s\n' "${BASH_VERSION@Q}" "${MACHTYPE@Q}" + . bash-abbr || { red 'Failed to source bash-abbr\n'; exit 1; } + unalias -a + declare -gi tests_passed=0 tests_failed=0 tests_total=0 + [[ $1 ]] && declare -gi isolate_test=$1 || declare -gi isolate_test=0 +} + +# inherits all variables from test_abbr function +check () { + [[ ! $new_test_line = "$expected_line" ]] && { red 'Test %s "%s" failed: Line %s did not expand to expected line %s, but %s\n' "$tests_total" "$test_name" "${test_line@Q}" "${expected_line@Q}" "${new_test_line@Q}"; return 1; } + [[ ! $new_test_point = "$expected_point" ]] && { red 'Test %s "%s" failed: Point was %s and did not update to %s, but %s\n' "$tests_total" "$test_name" "${test_point@Q}" "${expected_point@Q}" "${new_test_point@Q}"; return 2; } + [[ ! $new_test_mark = "$expected_mark" ]] && { red 'Test %s "%s" failed: Mark was %s and did not update to %s, but %s\n' "$tests_total" "$test_name" "${test_mark@Q}" "${expected_mark@Q}" "${new_test_mark@Q}"; return 3; } + return 0; +} + +test_abbr () { + tests_total+=1 + [[ isolate_test -gt 0 && isolate_test -ne tests_total ]] && return + + declare test_name=$1 + declare test_line=$2 new_test_line + declare test_point=$3 new_test_point + declare test_mark=$4 new_test_mark + declare expected_line=$5 + declare expected_point=$6 + declare expected_mark=$7 + declare insertion_char=$8 + shift 8 + + unalias -a + alias "$@" + + declare -gA _expanded_aliases=() + _try_abbrev_internal "$test_line" "$test_point" "$test_mark" new_test_line new_test_point new_test_mark "$insertion_char" + unset -v _expanded_aliases + + check && tests_passed+=1 || tests_failed+=1 +} + +summary () { + white 'Total number of tests: %q\n' "$tests_total" + grey 'Tests skipped: %q\n' "$((tests_total - tests_passed - tests_failed))" + green 'Tests passed: %q\n' "$tests_passed" + red 'Tests failed: %q\n' "$tests_failed" +} + +init "$@" + +# empty insertion char means a newline (or a similar accept-line command) was used + +test_abbr "expand regular alias when followed by a space at EOL" 'foo' '3' '0' 'barr ' '5' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when followed by a tab at EOL" 'foo' '3' '0' 'barr ' '5' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when followed by a newline at EOL" 'foo' '3' '0' 'barr' '4' '0' '' 'foo=barr' +test_abbr "expand regular alias when followed by a ')' at EOL" 'foo' '3' '0' 'barr ' '5' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when followed by a '(' at EOL" 'foo' '3' '0' 'barr ' '5' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when followed by a '<' at EOL" 'foo' '3' '0' 'barr ' '5' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when followed by a '>' at EOL" 'foo' '3' '0' 'barr ' '5' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when followed by a '&' at EOL" 'foo' '3' '0' 'barr ' '5' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when followed by a ';' at EOL" 'foo' '3' '0' 'barr ' '5' '0' ' ' 'foo=barr' + +test_abbr "don't expand regular alias when followed by a ']' at EOL" 'foo' '3' '0' 'foo]' '4' '0' ']' 'foo=barr' +test_abbr "don't expand regular alias when followed by a '[' at EOL" 'foo' '3' '0' 'foo[' '4' '0' '[' 'foo=barr' +test_abbr "don't expand regular alias when followed by a '}' at EOL" 'foo' '3' '0' 'foo}' '4' '0' '}' 'foo=barr' +test_abbr "don't expand regular alias when followed by a '.' at EOL" 'foo' '3' '0' 'foo.' '4' '0' '.' 'foo=barr' +test_abbr "don't expand regular alias when followed by a 'b' at EOL" 'foo' '3' '0' 'foob' '4' '0' 'b' 'foo=barr' + +test_abbr "expand regular alias when followed by a space inside word" 'foobar' '3' '0' 'barr bar' '5' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when followed by a tab inside word" 'foobar' '3' '0' 'barr bar' '5' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when followed by a ) inside word" 'foobar' '3' '0' 'barr bar' '5' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when followed by a ( inside word" 'foobar' '3' '0' 'barr bar' '5' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when followed by a < inside word" 'foobar' '3' '0' 'barr bar' '5' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when followed by a > inside word" 'foobar' '3' '0' 'barr bar' '5' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when followed by a & inside word" 'foobar' '3' '0' 'barr bar' '5' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when followed by a ; inside word" 'foobar' '3' '0' 'barr bar' '5' '0' ' ' 'foo=barr' + +test_abbr "don't expand regular alias when followed by a newline inside word" 'foobar' '3' '0' 'foobar' '6' '0' '' 'foo=barr' + +test_abbr "don't expand regular alias when space is entered at the beginning" 'foo' '0' '0' ' foo' '1' '0' ' ' 'foo=barr' +test_abbr "don't expand regular alias when space is entered after 1st character inside alias" 'foo' '1' '0' 'f oo' '2' '0' ' ' 'foo=barr' +test_abbr "don't expand regular alias when space is entered before last character inside alias" 'foo' '2' '0' 'fo o' '3' '0' ' ' 'foo=barr' + +test_abbr "expand regular alias when newline is entered at the beginning" 'foo' '0' '0' 'barr' '4' '0' '' 'foo=barr' +test_abbr "expand regular alias when newline is entered after 1st character inside alias" 'foo' '1' '0' 'barr' '4' '0' '' 'foo=barr' +test_abbr "expand regular alias when newline is entered before last character inside alias" 'foo' '2' '0' 'barr' '4' '0' '' 'foo=barr' + +test_abbr "recursively expand regular alias once" 'foo' '3' '0' 'bazqux ' '7' '0' ' ' 'foo=bar' 'bar=bazqux' +test_abbr "recursively expand regular alias twice" 'foo' '3' '0' 'wowitworks ' '11' '0' ' ' 'foo=bar' 'bar=bazqux' 'bazqux=wowitworks' +test_abbr "recursively expand regular alias thrice with a shorter final alias" 'foo' '3' '0' 'shorter ' '8' '0' ' ' 'foo=bar' 'bar=bazqux' 'bazqux=wowitworks' 'wowitworks=shorter' + +test_abbr "don't recursively expand regular alias infinitely (0 indirections)" 'foo' '3' '0' 'foo ' '4' '0' ' ' 'foo=foo' +test_abbr "don't recursively expand regular alias infinitely (1 indirection)" 'foo' '3' '0' 'foo ' '4' '0' ' ' 'foo=bar' 'bar=foo' +test_abbr "don't recursively expand regular alias infinitely (2 indirections)" 'foo' '3' '0' 'foo ' '4' '0' ' ' 'foo=bar' 'bar=baz' 'baz=foo' +test_abbr "don't recursively expand regular alias infinitely (3 indirections)" 'foo' '3' '0' 'foo ' '4' '0' ' ' 'foo=bar' 'bar=baz' 'baz=qux' 'qux=foo' + +test_abbr "don't recursively expand regular alias infinitely (1 indirection, different lengths)" 'foo' '3' '0' 'foo ' '4' '0' ' ' 'foo=b' 'b=foo' +test_abbr "don't recursively expand regular alias infinitely (2 indirections, different lengths)" 'foo' '3' '0' 'foo ' '4' '0' ' ' 'foo=ba' 'ba=bazz' 'bazz=foo' +test_abbr "don't recursively expand regular alias infinitely (3 indirections, different lengths)" 'foo' '3' '0' 'foo ' '4' '0' ' ' 'foo=barst' 'barst=b' 'b=quxio' 'quxio=foo' + +# Note that there are two spaces after the `foo=` because a single spaces is used as an escape mechanism for different alias flags like * or $. See later test cases. +test_abbr "recursively expand regular alias when alias value has leading spaces before the first command word" 'foo' '3' '0' ' cool ' '7' '0' ' ' 'foo= bar' 'bar=cool' +test_abbr "recursively expand regular alias when alias value has leading tabs before the first command word" 'foo' '3' '0' $'\t\tcool ' '7' '0' ' ' $'foo=\t\tbar' 'bar=cool' +test_abbr "recursively expand regular alias when alias value has leading spaces and assignment words before the first command word" 'foo' '3' '0' ' a=1 b=2 cool ' '14' '0' ' ' 'foo= a=1 b=2 bar' 'bar=cool' +test_abbr "recursively expand regular alias when alias value has leading spaces and redirections before the first command word" 'foo' '3' '0' ' 3>/dev/null 2>&1- cool ' '24' '0' ' ' 'foo= 3>/dev/null 2>&1- bar' 'bar=cool' + +test_abbr "recursively expand regular alias when entering a newline mid-word" 'foo' '2' '0' 'cool' '4' '0' '' 'foo=bar' 'bar=cool' + +test_abbr "don't expand regular alias when value is empty" 'foo' '3' '0' 'foo ' '4' '0' ' ' 'foo=' + +test_abbr "expand regular alias when it has a leading assignment word with spaces" 'A=1 foo' '7' '0' 'A=1 barr ' '9' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has leading assignment words with spaces" 'A=1 B=2 C=3 foo' '20' '0' 'A=1 B=2 C=3 barr ' '22' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has leading assignment words with spaces and tabs" $'A=1 \t B=2\t C=3\tfoo' '19' '0' $'A=1 \t B=2\t C=3\tbarr ' '21' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has a leading null assignment word" 'A= foo' '6' '0' 'A= barr ' '8' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has two leading null assignment words" 'A= B= foo' '9' '0' 'A= B= barr ' '11' '0' ' ' 'foo=barr' + +test_abbr "expand regular alias when it has a leading single-quoted assignment word without spaces" "A='whatever' foo" '16' '0' "A='whatever' barr " '18' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has a leading single-quoted assignment word with spaces" "A='what ever' foo" '17' '0' "A='what ever' barr " '19' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has a leading dollar single-quoted assignment word with spaces" "A=$'what ever' foo" '18' '0' "A=$'what ever' barr " '20' '0' ' ' 'foo=barr' + +test_abbr "expand regular alias when it has a leading double-quoted assignment word without spaces" 'A="whatever" foo' '16' '0' 'A="whatever" barr ' '18' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has a leading double-quoted assignment word with spaces" 'A="what ever" foo' '17' '0' 'A="what ever" barr ' '19' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has a leading double-quoted assignment word with spaces and an escaped double quote" 'A="what \"ever" foo' '19' '0' 'A="what \"ever" barr ' '21' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has a leading dollar double-quoted assignment word with spaces" 'A=$"what ever" foo' '18' '0' 'A=$"what ever" barr ' '20' '0' ' ' 'foo=barr' + +test_abbr "expand regular alias when it has a leading assignment word with backtick command substitution with spaces" 'A=`what ever` foo' '17' '0' 'A=`what ever` barr ' '19' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has a leading assignment word with normal command substitution with spaces" 'A=$(echo hi; arbitrary commands; no nesting allowed!; literally anyhing '\'' " > @ [ ] { } ;) foo' '97' '0' 'A=$(echo hi; arbitrary commands; no nesting allowed!; literally anyhing '\'' " > @ [ ] { } ;) barr ' '99' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has a leading assignment word with deprecated arithmetic expansion with spaces" 'A=$[1 + 1, 2 ** 4#2] foo' '24' '0' 'A=$[1 + 1, 2 ** 4#2] barr ' '26' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has a leading assignment word with normal arithmetic expansion with spaces" 'A=$((1 + 1, 2 ** 4#2)) foo' '26' '0' 'A=$((1 + 1, 2 ** 4#2)) barr ' '28' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has a leading assignment word with simple variable expansion" 'A=$FOO foo' '10' '0' 'A=$FOO barr ' '12' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has a leading assignment word with special variable expansion" 'A=$- foo' '8' '0' 'A=$- barr ' '10' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has a leading assignment word with simple braced variable expansion" 'A=${FOO} foo' '12' '0' 'A=${FOO} barr ' '14' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has a leading assignment word with special braced variable expansion" 'A=${-} foo' '12' '0' 'A=${-} barr ' '14' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has a leading assignment word with advanced braced variable expansion" 'A=${FOO: -1:2} foo' '18' '0' 'A=${FOO: -1:2} barr ' '20' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has a leading assignment word with advanced special braced variable expansion" 'A=${-: -1:2} foo' '16' '0' 'A=${-: -1:2} barr ' '18' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has a leading assignment word with process substitution with spaces" 'A=<(echo bla) foo' '17' '0' 'A=<(echo bla) barr ' '19' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has a leading backslash quoted assignment word with spaces" 'A=what\ ever foo' '16' '0' 'A=what\ ever barr ' '18' '0' ' ' 'foo=barr' + +test_abbr "expand regular alias when it has multiple leading complex assignment words separated by multiple spaces or tabs" \ + $'_A= B=2 \t \t C=<(echo bla; whatever)test$(bla bla bla)` hey`\'quoted string\'"another \\"one\\""$(( 1+1 ))$[ 2**2 ]$\'and another one\'$"translated \\"string"\\.\\ \\c\\o\\o\\l$foo${bar}${baz%%hey}$-$1${10}\tD=cool foo' '207' '0' \ + $'_A= B=2 \t \t C=<(echo bla; whatever)test$(bla bla bla)` hey`\'quoted string\'"another \\"one\\""$(( 1+1 ))$[ 2**2 ]$\'and another one\'$"translated \\"string"\\.\\ \\c\\o\\o\\l$foo${bar}${baz%%hey}$-$1${10}\tD=cool barr ' '209' '0' \ + ' ' 'foo=barr' + +test_abbr "don't expand regular alias when there's an invalid assignment word (0what=1)" '0what=1 foo' '11' '0' '0what=1 foo ' '12' '0' ' ' 'foo=barr' +test_abbr "don't expand regular alias when there's an invalid assignment word (what=\$[[hey]])" 'what=$[[hey]] foo' '17' '0' 'what=$[[hey]] foo ' '18' '0' ' ' 'foo=barr' + +test_abbr "expand regular alias when it has a leading output redirection" '>/dev/null foo' '14' '0' '>/dev/null barr ' '16' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has a leading output redirection with tabs and spaces" $'> \t /dev/null foo' '17' '0' $'> \t /dev/null barr ' '19' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has a leading input redirection" '>hey foo' '9' '0' '>>hey barr ' '11' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has a leading noclobber output redirection" '>|hey foo' '9' '0' '>|hey barr ' '11' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has a leading output redirection with fd" '5>hey foo' '9' '0' '5>hey barr ' '11' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has a leading output redirection with a {varname}" '{bla}>hey foo' '13' '0' '{bla}>hey barr ' '15' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has a leading read-write redirection" '<>file foo' '10' '0' '<>file barr ' '12' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has a leading file descriptor close redirection" '>&- foo' '7' '0' '>&- barr ' '9' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has a leading file descriptor move redirection" '>&5- foo' '8' '0' '>&5- barr ' '10' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has a leading file descriptor move redirection with fd" '6>&5- foo' '9' '0' '6>&5- barr ' '11' '0' ' ' 'foo=barr' + +test_abbr "expand regular alias when it has multiple leading redirections" '>/dev/null 2>&1 2>/dev/null 3<>file 3>&4- foo' '45' '0' '>/dev/null 2>&1 2>/dev/null 3<>file 3>&4- barr ' '47' '0' ' ' 'foo=barr' + +test_abbr "expand regular alias when it has multiple leading complex redirections separated by multiple spaces or tabs" \ + $'<|../log &- 3<&- 4>&1- >/dev/null 2>&3 \t \t 5<><(echo bla; whatever)test$(bla bla bla)` hey`\'quoted string\'"another \\"one\\""$(( 1+1 ))$[ 2**2 ]$\'and another one\'$"translated \\"string"\\.\\ \\c\\o\\o\\l$foo${bar}${baz%%hey}$-$1${10}\t<<|../log &- 3<&- 4>&1- >/dev/null 2>&3 \t \t 5<><(echo bla; whatever)test$(bla bla bla)` hey`\'quoted string\'"another \\"one\\""$(( 1+1 ))$[ 2**2 ]$\'and another one\'$"translated \\"string"\\.\\ \\c\\o\\o\\l$foo${bar}${baz%%hey}$-$1${10}\t<</dev/null A=1 1>&3- <>file \t _B=$(hello world)"cool"$VAR << <(test) foo' '74' '0' \ + $' >/dev/null A=1 1>&3- <>file \t _B=$(hello world)"cool"$VAR << <(test) barr ' '76' '0' \ + ' ' 'foo=barr' + +test_abbr "don't expand regular alias when there's an invalid redirection operator (6<>&5-)" '6<>&5- foo' '10' '0' '6<>&5- foo ' '11' '0' ' ' 'foo=barr' +test_abbr "don't expand regular alias when there's an invalid redirection operator (>>>)" '>>> foo' '7' '0' '>>> foo ' '8' '0' ' ' 'foo=barr' +test_abbr "don't expand regular alias when there's an invalid redirection operator ({0fail}>file)" '{0fail}>file foo' '16' '0' '{0fail}>file foo ' '17' '0' ' ' 'foo=barr' + +test_abbr "expand regular alias when it has one leading control operator (&&)" ' && foo' '7' '0' ' && barr ' '9' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has one leading control operator (||)" ' || foo' '7' '0' ' || barr ' '9' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has one leading control operator (|)" ' | foo' '6' '0' ' | barr ' '8' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has one leading control operator (;)" ' ; foo' '6' '0' ' ; barr ' '8' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has one leading control operator (;;)" ' ;; foo' '7' '0' ' ;; barr ' '9' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has one leading control operator (;&)" ' ;& foo' '7' '0' ' ;& barr ' '9' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has one leading control operator (;;&)" ' ;;& foo' '8' '0' ' ;;& barr ' '10' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has one leading control operator (&)" ' & foo' '6' '0' ' & barr ' '8' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has one leading control operator (newline)" $'\nfoo' '4' '0' $'\nbarr ' '6' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has one leading control operator (opening parenthesis)" '(foo' '4' '0' '(barr ' '6' '0' ' ' 'foo=barr' + +test_abbr "don't expand regular alias when there's a closing parenthesis" ' ) foo' '7' '0' ' ) foo ' '8' '0' ' ' 'foo=barr' + +test_abbr "expand regular alias when it has two leading control operators" ' && echo hi && foo' '18' '0' ' && echo hi && barr ' '20' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when it has two leading and one trailing control operator(s)" ' && echo hi (foo&& okay)' '16' '0' ' && echo hi (barr && okay)' '18' '0' ' ' 'foo=barr' + +test_abbr "expand regular alias when it has leading and trailing control operators, assignment words and redirections" $'\necho hi&&a=1 echo ho || 2>/dev/null echo bla & {var}<>file another ;;& a=1 2>/dev/null 3>&4- foo; >/dev/null cool <>file' '97' '0' $'\necho hi&&a=1 echo ho || 2>/dev/null echo bla & {var}<>file another ;;& a=1 2>/dev/null 3>&4- barr ; >/dev/null cool <>file' '99' '0' ' ' 'foo=barr' + +test_abbr "don't expand regular alias when it's not the first word" 'hey foo' '7' '0' 'hey foo ' '8' '0' ' ' 'foo=barr' + +test_abbr "don't expand regular alias when there's a double quoted ';' before it" 'echo ";" foo' '12' '0' 'echo ";" foo ' '13' '0' ' ' 'foo=barr' +test_abbr "don't expand regular alias when there is 1 backslash before a ';' before the alias" 'echo \; foo' '11' '0' 'echo \; foo ' '12' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when there are 2 backslashes before a ';' before the alias" 'echo \\; foo' '12' '0' 'echo \\; barr ' '14' '0' ' ' 'foo=barr' +test_abbr "don't expand regular alias when there are 3 backslashes before a ';' before the alias" 'echo \\\; foo' '13' '0' 'echo \\\; foo ' '14' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when there are 4 backslashes before a ';' before the alias" 'echo \\\\; foo' '14' '0' 'echo \\\\; barr ' '16' '0' ' ' 'foo=barr' +test_abbr "don't expand regular alias when there are 5 backslashes before a ';' before the alias" 'echo \\\\\; foo' '15' '0' 'echo \\\\\; foo ' '16' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when there are 6 backslashes before a ';' before the alias" 'echo \\\\\\; foo' '16' '0' 'echo \\\\\\; barr ' '18' '0' ' ' 'foo=barr' + +test_abbr "don't expand regular alias when there is 1 backslash before a & before the alias" 'echo \& foo' '11' '0' 'echo \& foo ' '12' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when there are 2 backslashes before a & before the alias" 'echo \\& foo' '12' '0' 'echo \\& barr ' '14' '0' ' ' 'foo=barr' +test_abbr "don't expand regular alias when there are 3 backslashes before a & before the alias" 'echo \\\& foo' '13' '0' 'echo \\\& foo ' '14' '0' ' ' 'foo=barr' + +test_abbr "don't expand regular alias when there is 1 backslash before a | before the alias" 'echo \| foo' '11' '0' 'echo \| foo ' '12' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when there are 2 backslashes before a | before the alias" 'echo \\| foo' '12' '0' 'echo \\| barr ' '14' '0' ' ' 'foo=barr' +test_abbr "don't expand regular alias when there are 3 backslashes before a | before the alias" 'echo \\\| foo' '13' '0' 'echo \\\| foo ' '14' '0' ' ' 'foo=barr' + +test_abbr "expand regular alias when there is 1 backslash before a ||" 'echo \|| foo' '12' '0' 'echo \|| barr ' '14' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when there are 2 backslashes before a ||" 'echo \\|| foo' '13' '0' 'echo \\|| barr ' '15' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when there are 3 backslashes before a ||" 'echo \\\|| foo' '14' '0' 'echo \\\|| barr ' '16' '0' ' ' 'foo=barr' + +test_abbr "expand regular alias when there is 1 backslash before a &&" 'echo \&& foo' '12' '0' 'echo \&& barr ' '14' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when there are 2 backslashes before a &&" 'echo \\&& foo' '13' '0' 'echo \\&& barr ' '15' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when there are 3 backslashes before a &&" 'echo \\\&& foo' '14' '0' 'echo \\\&& barr ' '16' '0' ' ' 'foo=barr' + +test_abbr "expand regular alias when there is 1 backslash before a ;;" 'echo \;; foo' '12' '0' 'echo \;; barr ' '14' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when there are 2 backslashes before a ;;" 'echo \\;; foo' '13' '0' 'echo \\;; barr ' '15' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when there are 3 backslashes before a ;;" 'echo \\\;; foo' '14' '0' 'echo \\\;; barr ' '16' '0' ' ' 'foo=barr' + +test_abbr "expand regular alias when there is 1 backslash before a ;;&" 'echo \;;& foo' '13' '0' 'echo \;;& barr ' '15' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when there are 2 backslashes before a ;;&" 'echo \\;;& foo' '14' '0' 'echo \\;;& barr ' '16' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when there are 3 backslashes before a ;;&" 'echo \\\;;& foo' '15' '0' 'echo \\\;;& barr ' '17' '0' ' ' 'foo=barr' + +test_abbr "expand regular alias when there is 1 backslash before a ;&" 'echo \;& foo' '12' '0' 'echo \;& barr ' '14' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when there are 2 backslashes before a ;&" 'echo \\;& foo' '13' '0' 'echo \\;& barr ' '15' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when there are 3 backslashes before a ;&" 'echo \\\;& foo' '14' '0' 'echo \\\;& barr ' '16' '0' ' ' 'foo=barr' + +test_abbr "don't expand regular alias when there is 1 backslash before a (" 'echo \( foo' '11' '0' 'echo \( foo ' '12' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when there are 2 backslashes before a (" 'echo \\( foo' '12' '0' 'echo \\( barr ' '14' '0' ' ' 'foo=barr' +test_abbr "expand regular alias when there are 3 backslashes before a (" 'echo \\\( foo' '13' '0' 'echo \\\( foo ' '14' '0' ' ' 'foo=barr' + +test_abbr "don't expand regular alias when newline is entered not after or on the alias" 'foo ' '4' '0' 'foo ' '4' '0' '' 'foo=bar' +test_abbr "don't expand regular alias when space is entered not after or on the alias" 'foo ' '4' '0' 'foo ' '5' '0' ' ' 'foo=bar' + +summary From d7e0edebf899043b08423cfcf7eb6db40b57a0df Mon Sep 17 00:00:00 2001 From: tsi Date: Sat, 3 Jan 2026 01:32:34 +0100 Subject: [PATCH 29/29] Add unit tests and testing framework --- bash-abbr | 1 + 1 file changed, 1 insertion(+) diff --git a/bash-abbr b/bash-abbr index 036b444..3fd1f25 100644 --- a/bash-abbr +++ b/bash-abbr @@ -1,5 +1,6 @@ #!/usr/bin/env bash # Shebang merely for syntax highlighting. + # This file should be pasted directly into ~/.bashrc or # put into the bashrc drop-in directory ~/.bashrc.d/ # and then sourced from ~/.bashrc.