bash 下更方便的补齐选择方法

1 酷酷的 zsh 补齐机制

有一天有个同事过来告诉我,他用的是 zsh,这个 shell 下的补齐非常友好,允许你用方向键在各个候选项之间上下左右移动,然后问我 system-config 下能不能也加一个这样的功能。

他说的 zsh 的补齐是这样的:

zsh-completion.gif

我看完就心动了,这个功能好酷,我也想要这样的功能!所以我答应他我好好考虑一下。

2 system-config 下的解决方案

但是在 bash 下实现这样的功能真的比较困难,我又不想成为一个 bash 的核心开发者(想要一模一样的实现 zsh 的那种选择功能的话,必须修改 bash 的 c 代码),所以一直拖着没有开始干。

拖了几个月之后,这个同事突然有一天变成前同事了,这让我很惭愧,觉得实在不能再拖下去了,于是硬着头皮用 bash 脚本写了一个类似的机制。

这是我改出来的补齐方法(写完了之后才发现,好像比 zsh 的补齐更好用呢):

sc-complete.gif

这里解释一下我执行的操作:

  1. 输入“git ”,然后按了一下 F1 快捷键

    补齐脚本提示了所有 git 的常用子命令供我选择,

  2. 我输入了“br<回车>”,脚本进一步缩小了匹配的常用 git 子命令的范围
  3. 我按下回车,直接选中第一个匹配的候选项,branch
  4. 我输入了 “--”,然后又按了一下 F1,脚本列出所有 git branch 命令允许的参数选项,我又输入了 mer<回车>,细化了一下匹配项,然后直接回车,选中了 --merged
  5. 我直接按了一下 F1,脚本调用 bash 的自定义补齐机制,计算出此处可以提供的补齐是当前仓库的所有分支,于是列出这些分支让我选择
  6. 我输入了 ema<回车> 极大地细化了一下候选项,然后输入 2<回车> 选中了第 2 个选项。

跟 zsh 的机制比起来,我的脚本在有巨多的候选项的情况下,允许进一步过滤细分、然后用序号选中;如果是 zsh 的话,即使用上下左右的移动,可能也要找很久。

当然,缺点也有很多:

  1. 界面比较简陋
  2. 毕竟不是 bash 内置的功能,在处理所有带空格等特殊字符的目录名补齐的时候,只能补齐一次,后面就无法正确的分词了。

    比如在我的 ${HOME} 目录下,有一个名为 VirtualBox VMs 的目录,我如果输入 cd vir 然后按 F1 补齐的话,它能给我正确的补上 cd VirtualBox\ VMs/,连空格前的反斜杠都给加上了,但你再按 F1 的话,虽然下面还有一个 Debian 目录,但因为分词的问题,我的脚本只能用 VMs 去算下一步的补齐,结果就补不出来了。如果按 Tab 的话,bash 自己的分词会用整个 VirtualBox\ VMs/ 去补齐,结果就是正确的。

    不过这个问题其实也没什么,因为在处理特殊字符的时候,bash 自己也是问题很多的。比如你可以试试输入 echo $(git br 然后按 Tab 键,bash 会出错的。后续我计划让我的脚本能处理这种补齐。

3 用法

  1. 升级一下 system-config(用 git pull 同步一下代码)
  2. 新开一个终端窗口(否则快捷键无法生效,见下面的说明),直接按一下 F1 键,会列出系统 $PATH 路径里所有命令(包括 bash 的内置命令、alias 别名以及函数)

    有些终端窗口下 F1 可能已经有其他用途,比如打开当前终端窗口应用的帮助——这种情况下你可以考虑在它的设置里找找看能不能关闭这个快捷键。

    如果你希望使用其他快捷键,比如 F2,请在你的 ~/.bashrc 的最后添加如下两行(然后再 新开一个终端窗口 才能生效。或者也可以直接在当前窗口下运行下面这两条命令):

    . sc-complete
    bind -x '"\eOQ": sc-complete'
    

    这里 \eOQ 是通过 bash 终端读出来的 F2 的终端编码,你可以在 bash 下通过先按一下 C-v,然后再按一下 F2 来读到这个代码,bash 会显示你输入了 ^[OQ,其中 ^[ 是一个特殊字符,其实就是 Escape 键的意思,在 bash bind 命令里用 \e 表示,你可以通过按 Escape 键输入它,也可以用 Ctrl-[ 键输入。基本上所有的功能键 F1~F12 都有一个对应的终端编码,比如在我的系统上,在终端里按 F1 的效果,和按 Escape、O、P 这三个键的效果是完全一样的。

4 开发笔记

里面最坑的地方是我使用的 select-args 这个脚本,是 system-config 里很久以前就有的,这次在被用于补齐选择的时候,发现我输入的文字不会被终端回显。我像个瞎子一样摸索了很久,最后发现是 tty 终端的设置的问题,bash 在输入命令的过程中关闭了回显(所谓回显是指比如你按了个 c 的话,终端就会显示一个 c;关闭终端回显一般用于输入密码的情况下;bash 输入命令时你输入了一个 c,确实终端上也会显示一个 c,但是这个 c 是 bash 代码显示的,不是通过终端回显机制显示的!)

4.1 Org-mode Literate Programming

主要解决的痛点是在 bash 有很多补齐候选项的时候,我可以自己定义如何进行这些选项的选择。目前市面上已有的做法是:

  1. bash,先提示你一下,有很多候选项,你要不要全部显示?然后你选 Yes,显示全部候选项之后,你只能自己继续打字输入候选词、再按几下 Tab 键或干脆改成用鼠标选中、拷贝、粘贴。

    这种方法导致很多 Linux 命令行用户养成一个不管有没有用,下意识地按几下 Tab 键的习惯。

  2. zsh,我自己没有用过,但之前有个同事给我演示过,允许把所有选项列出来,然后你可以用方向键上下左右移动高亮某选项,然后回车选中它。

我希望把它改成这样子:我按一个键后,马上调用我的 my-select (或 select-args 等)脚本,把所有选项列出来让我选,选完了之后直接上屏。

4.2 最终的版本:

代码是用 Emacs org-mode 的 Literate Programming 写的(跟这篇博客在同一个.org文件里)。可执行脚本的位置在 https://github.com/baohaojun/system-config/raw/master/bin/sc-complete

emacs-complete() {
    txt=~/.cache/system-config/ec.txt
    tmux capture-pane -p > $txt
    echo "$READLINE_LINE" >> $txt
    ew $txt:$(wc -l $txt | pn 1):$((READLINE_POINT + 1)) >/dev/null 2>&1
    find-or-exec konsole >/dev/null 2>&1

    READLINE_LINE=$(cat $txt|tail -n 1)
    READLINE_POINT=${#READLINE_LINE}
}
emacs-complete() {
    txt=~/.cache/system-config/ec.txt
    tmux capture-pane -p > $txt
    echo "$READLINE_LINE" >> $txt
    ew $txt:$(wc -l $txt | pn 1):$((READLINE_POINT + 1)) >/dev/null 2>&1
    find-or-exec konsole >/dev/null 2>&1

    READLINE_LINE=$(cat $txt|tail -n 1)
    READLINE_POINT=${#READLINE_LINE}
}
function sc-complete() {
    declare -x COMP_LINE=$READLINE_LINE
    declare -x COMP_POINT=$READLINE_POINT

    declare sc_line_before_point=${READLINE_LINE:0:$READLINE_POINT}
    declare sc_line_after_point=${READLINE_LINE:$READLINE_POINT}

    declare OLDIFS=$IFS
    IFS=$COMP_WORDBREAKS
    declare -a sc_comp_words_before_point=(
        $sc_line_before_point
    )

    declare -a sc_comp_words_after_point=(
        $sc_line_after_point
    )

    IFS=$OLDIFS

    declare sc_last_word_before_point

    if test ${#sc_comp_words_before_point[@]} -gt 0; then
        sc_last_word_before_point=${sc_comp_words_before_point[${#sc_comp_words_before_point[@]} - 1]}
    else
        sc_last_word_before_point=""
    fi


    if test "${sc_line_before_point:${#sc_line_before_point}-${#sc_last_word_before_point}}" != "${sc_last_word_before_point}"; then
        # There are other ``blank'' chars before the point, so there should be an empty WORD
        sc_comp_words_before_point=(
            "${sc_comp_words_before_point[@]}"
            ""
        )
    fi

    declare -x COMP_WORDS=(
        "${sc_comp_words_before_point[@]}"
        "${sc_comp_words_after_point[@]}"
    )

    declare -x COMP_CWORD=$((${#sc_comp_words_before_point[@]} - 1)) || true
    COMP_CWORD=$((COMP_CWORD < 0 ? 0 : COMP_CWORD))
    declare current_word=${COMP_WORDS[$COMP_CWORD]}
    declare -x COMP_KEY=9
    declare -x COMP_TYPE=9

    declare first_word=${COMP_WORDS[0]}
    declare cword_minus_1=$((COMP_CWORD > 0 ? COMP_CWORD - 1 : 0))

    declare comp_call_args=(
        "$first_word"
        "${COMP_WORDS[$COMP_CWORD]}"
        "${COMP_WORDS[$cword_minus_1]}"
    )



    declare complete_spec=
    declare -a COMPREPLY
    local IFS=$'\n'
    declare -A sc_comp_options

    if test "${#sc_comp_words_before_point[@]}" -le 1; then
        COMPREPLY=(
            $(compgen -c "$first_word")
        )
    else
        complete_spec=$(complete -p ${first_word} 2>/dev/null)
        if test -z "${complete_spec}"; then
            declare default_loader=$(complete -p -D | perl -ne 'print $1 if m/ (?:-F|-C) (\w+)/')
            if test "${default_loader}"; then
                ${default_loader} "${comp_call_args[@]}"
            fi
            complete_spec=$(complete -p ${first_word} 2>/dev/null)
        fi

        if test "${complete_spec}"; then
            compopt() {
                while test $# != 0; do
                    if test $1 = -o; then
                        sc_comp_options[${2:-unknown}]=1
                        shift 2
                    else
                        shift 1
                    fi
                done
            }

            declare complete_action
            if echo "$complete_spec" | grep -P -q -e " -[FC] "; then
                complete_action=$(echo "${complete_spec}" | perl -ne 'print $1 if m/ (?:-F|-C) (\w+)/')
            else
                complete_action=$(echo "${complete_spec% ${first_word}}"|perl -pe 's,^complete ,compgen ,')
            fi

            if test "$(type -t "${complete_action}")" = function; then
                ${complete_action} 2>/dev/null
            else
                COMPREPLY=(
                    $( eval ${complete_action} 2>/dev/null)
                )
            fi
            unset -f compopt
        fi
    fi

    if test "${#COMPREPLY[@]}" = 0 && (
            test -z "${complete_spec}" ||
                [[ $complete_spec =~ '-o default' ]]
        ); then
        COMPREPLY=(
            $(
                for x in "${COMP_WORDS[$COMP_CWORD]}"*; do
                    if test "${x}" != "${COMP_WORDS[$COMP_CWORD]}"\*; then
                        echo "${x}"
                    fi
                done
            )
        )
    fi
    if test "${#COMPREPLY[@]}" = 0; then
        return
    fi
    declare comp_ans=$(. atexit stty -echo; stty echo; select-args -p "请选择你要哪个补齐?" -- "${COMPREPLY[@]}")
    if test "${sc_comp_options[filenames]}"; then
        if test -d "${comp_ans}"; then
            comp_ans=$comp_ans/
        fi
        comp_ans=$(printf %q "$comp_ans")
    fi
    READLINE_LINE=${sc_line_before_point%${current_word}}${comp_ans}
    READLINE_POINT=${#READLINE_LINE}
    READLINE_LINE=${READLINE_LINE}${sc_line_after_point}
}

bind -x '"\eOP": sc-complete'
bind -x '"\eOQ": emacs-complete'
# Local Variables: #
# eval: (read-only-mode 1) #
# End: #