#!/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 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.
_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
}
declare _control_op="$(_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"
    unset -f _redir_or_assign_word
}
declare -r _redir_or_assign_word="$(_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
# that does no redirection/assignment word checking:
# declare -r is_first_word_prefix="($_control_op)[[:space:]]*"

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 \
                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.

    declare -r 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:]] ]] &&

        # 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}"]-} &&

        [[ $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#[*$ -]}

        # 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}+${#self_insertion}

        # Update line.
        eval "$new_line"'=${line_until_point%"$word_at_point"}$replacement${self_insertion}${line:point}'
    else
        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 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 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).

    # 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=()

    # 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
}

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^"' # Moves point weirdly
    done

    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
