#!/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. # Disable plugin by uncommenting the following line # return ###### 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 # 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:]])\(' 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" printf %s "$_control_op" unset -f _control_op } # shellcheck disable=SC2155 declare _control_op="$(_control_op)" # TODO use bash 5.3 `${}` command substitution syntax to avoid subshells _redir_or_assign_word () { # shellcheck disable=SC2016 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='[^[:blank:]<>&;()|'\''"`$]+' \ _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))" # shellcheck disable=SC1087 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)[[: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 _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 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. # 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:]]*} # Move point forward to right after the word. Keep mark where it is. point=$((point+${#trailing_part_of_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:]] ]] && # 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:]]} && # Ensure that word does not contain either < or > [[ ! $word_at_point =~ [\<\>] ]] && # 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 ]] || { # 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 ]] && # 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 # 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"} =~ $is_first_word_prefix$ ]]; then # Prevent infinite recursion _expanded_aliases[$word_at_point]=1 # Account for inhibited self-insertion [[ ${replacement::1} =~ [-$] ]] && self_insertion= # 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 # 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" # 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:]]*} 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# } # Strip the prepended space again fi # 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} # 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 eval "$new_line"'=${line_until_point}${self_insertion}${line:point}' fi # 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))' # NB: READLINE_ARGUMENT is deliberately ignored. } _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). # 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) declare -gA _expanded_aliases=() # 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 unset -v _expanded_aliases } bash_abbr () { # 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. 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. # 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"' } 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. # 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