编写Bash自动补全脚本


Bash 上的自动补全极为适合于写一些基于命令行的小程序

编写Bash补全脚本


1. 自动补全工具

主要讲述自动补全命令的用处

  • 我们日常在使用 bash 命令行的时候,常常会在提示符下输入某个命令的前面几个字符, 然后使用 TAB 键,之后命令行就会列出以这几个字符开头的命令。这时,我们就可以根据实际情况进行选择使用了。 同时,不仅可以补全命令,而且还可以进行参数补全,但只限于文件参数。当输入到参数部分时,按 TAB 键就会列出以这个参数开头的文件路径供我们选择。在 bash 中内置提供了一个 complete 命令,用来规定参数的自动补全方式,我们这里主要就是对其进行讲解和说明。
# 可以看出complete命令是内置命令
[email protected]:/opt$ type complete
complete is a shell builtin
  • 在我们学习自动补签的技巧之前,首先需要学习 complete 命令的基本参数已经使用方式,该命令是自动补全的基石。
# complete命令的帮助信息
[email protected]:/opt$ complete --help
complete: complete [-abcdefgjksuv] [-pr] [-DE] [-o option] [-A action] [-G globpat] [-W wordlist]  [-F function] [-C command] [-X filterpat] [-P prefix] [-S suffix] [name ...]
    Specify how arguments are to be completed by Readline.

    For each NAME, specify how arguments are to be completed.  If no options
    are supplied, existing completion specifications are printed in a way that
    allows them to be reused as input.

    Options:
      -p    print existing completion specifications in a reusable format
      -r    remove a completion specification for each NAME, or, if no
            NAMEs are supplied, all completion specifications
      -D    apply the completions and actions as the default for commands
            without any specific completion defined
      -E    apply the completions and actions to "empty" commands --
            completion attempted on a blank line

    When completion is attempted, the actions are applied in the order the
    uppercase-letter options are listed above.  The -D option takes
    precedence over -E.

    Exit Status:
    Returns success unless an invalid option is supplied or an error occurs.
编号 内置 complete 命令参数 参数作用解释
1 -W wordlist 指定候选的单词列表
2 -F function 指定一个补全函数
3 -o comp-option 内置多种参数类型
4 -A action 内置多种参数类型
# 使用函数来匹配完成自动补全(man complete)
[email protected]:/opt$ complete
complete -F _longopt ls
complete -F _longopt tail
complete -F _service /etc/init.d/rsyslog
complete -F _service /etc/init.d/hwclock.sh
complete -o filenames -F _grub_set_entry grub-reboot
complete -o filenames -F _grub_mkconfig grub-mkconfig
complete -u w
complete -u slay
complete -c type
complete -c which
complete -v unset
complete -A setopt set
complete -A helptopic help
complete -b builtin
complete -j -P '"%' -S '"' fg
  • bash-completion 这个包的安装位置因不同的发行版会有所区别,但是大致上启用的原理是类似的,一般会有一个名为 bash_completion 的脚本,这个脚本会在 shell 初始化时加载。对于 Ubuntu 的操作系统来说,这个脚本位于 /usr/share/bash-completion/bash_completion,而该脚本会由 /etc/profile.d/bash_completion.sh 中导入。
# /etc/profile.d/bash_completion.sh
[email protected]:/opt$ cat /etc/profile.d/bash_completion.sh
# Check for interactive bash and that we haven't already been sourced.
[ -z "$BASH_VERSION" -o -z "$PS1" -o -n "$BASH_COMPLETION_COMPAT_DIR" ] && return

# Check for recent enough version of bash.
bash=${BASH_VERSION%.*}; bmajor=${bash%.*}; bminor=${bash#*.}
if [ $bmajor -gt 4 ] || [ $bmajor -eq 4 -a $bminor -ge 1 ]; then
    [ -r "${XDG_CONFIG_HOME:-$HOME/.config}/bash_completion" ] && \
        . "${XDG_CONFIG_HOME:-$HOME/.config}/bash_completion"
    if shopt -q progcomp && [ -r /usr/share/bash-completion/bash_completion ]; then
        # Source completion code.
        . /usr/share/bash-completion/bash_completion
    fi
fi
unset bash bmajor bminor
  • 而在 bash_completion 脚本中会加载 /etc/bash_completion.d 下面的补全脚本。
if [[ $BASH_COMPLETION_DIR != $BASH_COMPLETION_COMPAT_DIR && \
    -d $BASH_COMPLETION_DIR && -r $BASH_COMPLETION_DIR && \
    -x $BASH_COMPLETION_DIR ]]; then
    for i in $(LC_ALL=C command ls "$BASH_COMPLETION_DIR"); do
        i=$BASH_COMPLETION_DIR/$i
        [[ ${i##*/} != @(*~|*.bak|*.swp|\#*\#|*.dpkg*|*[email protected](orig|new|save)|Makefile*) \
            && -f $i && -r $i ]] && . "$i"
    done
fi
unset i

2. 内置补全命令

命都没了,还提什么被打扰?

编写Bash补全脚本

  • Bashcompgencomplete 两个内置补全命令。
编号 内置补全命令 命令作用解释
1 compgen 根据不同的参数生成匹配单词的候选补全列表
2 complete 在命令行中根据不用的前缀补全对应参数候选补全列表

编写Bash补全脚本

  • 其中 compgen 命令会根据不同的参数生成匹配单词的候选补全列表,最常用的选项是 -W 参数,通过 -W 参数指定以空格分隔的单词列表。从下面例子中输入 h 单词后输出候选的匹配列表。
# 配置以h开头的单词
[email protected]:/opt$ compgen -W 'hi hello how world' h
hi
hello
how

# 配置以w开头的单词
[email protected]:/opt$ compgen -W 'hi hello how world' w
world
  • complete 命令的有点类似 compgen 命令,不过它的作用是说明命令如何进行补全。例如使用 -W 参数指定候选的单词列表,还可以通过 -F 参数指定一个补全函数。
# 设置候选参数
[email protected]:/opt$ complete -W 'word1 word2 word3 hello' foo

# Tab补全
[email protected]:/opt$ foo <TAB><TAB>
hello  word1  word2  word3
[email protected]:/opt$ foo word<Tab>
word1  word2  word3

3. 补全内置变量

内置变量在编写脚本时常常会被使用到

除了上面两个补全命令以外,Bash 还有几个内置的变量用来辅助补全功能,我们这里主要介绍其中三个。

编号 内置变量 变量作用解释
1 COMP_WORDS 类型为数组;存放当前命令行中输入的所有单词
2 COMP_CWORD 类型为整数;当前光标下输入的单词位于 COMP_WORDS 数组中的索引
3 COMP_LINE 类型为字符串;表示当前的命令行输入
4 COMP_WORDBREAKS 类型为字符串;表示单词之间的分隔符
5 COMPREPLY 类型为数组;候选的补全结果
# 定义一个补全函数(_foo)
function _foo() {
    declare -p COMP_WORDS
    declare -p COMP_CWORD
    declare -p COMP_LINE
    declare -p COMP_WORDBREAKS
}

# 设置命令补全(foo)
complete -F _foo foo

# 理解这几个变量的含义
$ foo b<TAB>
declare -a COMP_WORDS='([0]="foo" [1]="b")'
declare -- COMP_CWORD="1"
declare -- COMP_LINE="foo b"
declare -- COMP_WORDBREAKS="
\"'@><=;|&(:"

4. 编写补全脚本

光说不练假把式

编写一个自动补全的脚本或者说是一个自动补全的工具,主要分为两个部分,即编写一个补全函数和使用 complete 命令应用补全函数。使用 complete 命令非常简单,主要的难点在于补全函数的编写,这里我们可以借鉴 bash-completion 自带的一些补全脚本来学习,理解之后,基本就是改吧改吧就可以使用了。

  • 一般补全函数都会定义 curprev 这两个变量,其中 cur 表示当前光标下的单词,而 prev 则表示对应上一个单词。初始化相应的变量之后,我们需要定义命令的补全行为,即输入什么的情况下补全什么内容。例如,当输入 - 开头的选项的时候,我们将所有的选项作为候选的补全结果。需要注意的是给 COMPREPLY 赋值之前,最好将它重置清空,避免被其它补全函数干扰。
# /etc/bash_completion.d/fcoemon
have fcoemon && _fcoemon_options() {
    local cur prev prev_prev opts
    COMPREPLY=()
    cur="${COMP_WORDS[COMP_CWORD]}"
    prev="${COMP_WORDS[COMP_CWORD-1]}"
    opts="-f --foreground -d --debug -s --syslog -v --version -h --help"

    case "${cur}" in
        *)
            COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
            return 0
            ;;
    esac

    return 0
}
complete -F _fcoemon_options fcoemon
# 自动补齐
[email protected]:/opt$ fcoemon -<TAB>
-d            --debug       -f
  • 心思的你可能会发现,似乎我们这里的例子没有用到 prev 变量。用好 prev 变量可以让补全的结果更加完整,例如当我们输入 --file 之后,我们希望补全特殊的文件,例如以.sh 结尾的文件。
# 自己构建的一个自动补全的函数
_foo() {
    local cur prev prev_prev opts
    COMPREPLY=()
    cur="${COMP_WORDS[COMP_CWORD]}"
    prev="${COMP_WORDS[COMP_CWORD-1]}"
    opts="-f --foreground -d --debug -s --syslog -v --version -h --help"

    case "${prev}" in
        -f|--file)
            COMPREPLY=( $(compgen -o filenames -W "`ls *.sh`" -- ${cur}) )
            return 0
            ;;
    esac

    return 0
}
complete -F _fcoemon_options fcoemon
# 自动补齐
$ foo --file<Tab>
a.sh b.sh c.sh

5. 补全使用方式

即编写好脚本之后如何使之生效

  • 现在很多 Linux 下的命令工具都可以使用自带的工具生成对应补全脚本,我们只需要在启动或执行对应的脚本,即可实现该命令行工具的自动补签,比如 kubectl 工具等。
#  生成bash对应的自动补签脚本
$ kubectl completion bash

# 一种做法是写入~/.bashrc文件
$ source < (kubectl completion bash)

# 另一种做法是bash_completion.d目录中
$ kubectl completion bash > /etc/bash_completion.d/kubectl
#  生成zsh对应的自动补签脚本
$ kubectl completion zsh

# 一种做法是写入~/.zshrc文件
$ source < (kubectl completion zsh)

# 必须启用compdef内置功能(~/.zshrc文件)
autoload -Uz compinit
compinit
  • 从上面的命令我们肯定很疑问,为什么自动补全 BashZsh 的脚本会要区别呢?主要是因为 BashZsh 的自动补全实现细节不同,当然不同的操作系统对应的脚本也是有差异的。在 Bash 的自动补全主要依赖于 bash-completion 项目,而 Zsh 的自动补全脚本没有任何依赖项,所以不需要安装其他工具。
# CentOS
$ sudo yum install bash-completion

# Ubuntu
$ sudo apt-get install bash-completion

6. 使用实例展示

官方实例和有趣的脚本

  • [1] Real-World
_revdep_rebuild() {
    local cur prev opts
    COMPREPLY=()
    cur="${COMP_WORDS[COMP_CWORD]}"
    prev="${COMP_WORDS[COMP_CWORD-1]}"
    opts="-X --package-names --soname --soname-regexp -q --quiet"

    if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] || \
       [[ ${prev} == @(-q|--quiet) ]] ; then
        COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
        return 0
    fi

    case "${prev}" in
        -X|--package-names)
            _pkgname -I ${cur}
            ;;
        --soname)
            local sonames=$(for x in /lib/*.so?(.)* /usr/lib*/*.so\?(.)* ; do \
                        echo ${x##*/} ; \
                        done)
            COMPREPLY=( $(compgen -W "${sonames}" -- ${cur}) )
            ;;
        --soname-regexp)
            COMPREPLY=()
            ;;
        *)
            if [[ ${COMP_LINE} == *" "@(-X|--package-names)* ]] ; then
                _pkgname -I ${cur}
                COMPREPLY=(${COMPREPLY[@]} $(compgen -W "${opts}"))
            else
                COMPREPLY=($(compgen -W "${opts} -- ${cur}"))
            fi
        ;;
    esac
}
complete -F _revdep_rebuild revdep-rebuild
  • [2] FizzBuzz
function _fizzbuzz () {
    length=${#COMP_WORDS[@]}
    number=$((length - 1))
    if ! ((number % 15)); then
        COMPREPLY=(fizzbuzz)
    elif ! ((number % 3)); then
        COMPREPLY=(fizz)
    elif ! ((number % 5)); then
        COMPREPLY=(buzz)
    else
        COMPREPLY=($number)
    fi
}
complete -F _fizzbuzz fizzbuzz
  • [3] redefine_filedir
_filedir() {
    local i IFS=$'\n' xspec

    _tilde "$cur" || return 0

    local -a toks
    local quoted x tmp

    _quote_readline_by_ref "$cur" quoted
    x=$( compgen -d -- "$quoted" ) &&
    while read -r tmp; do
        toks+=( "$tmp" )
    done <<< "$x"

    if [[ "$1" != -d ]]; then
        # Munge xspec to contain uppercase version too
        # http://thread.gmane.org/gmane.comp.shells.bash.bugs/15294/focus=15306
        xspec=${1:+"!*[email protected]($1|${1^^})"}
        x=$( compgen -f -X "$xspec" -- $quoted ) &&
        while read -r tmp; do
            toks+=( "$tmp" )
        done <<< "$x"
    fi

    # If the filter failed to produce anything, try without it if configured to
    [[ -n ${COMP_FILEDIR_FALLBACK:-} && \
        -n "$1" && "$1" != -d && ${#toks[@]} -lt 1 ]] && \
        x=$( compgen -f -- $quoted ) &&
        while read -r tmp; do
            toks+=( "$tmp" )
        done <<< "$x"


    if [[ ${#toks[@]} -ne 0 ]]; then
        # 2>/dev/null for direct invocation, e.g. in the _filedir unit test
        compopt -o filenames 2>/dev/null
        COMPREPLY+=( "${toks[@]}" )
    fi
}
_filedir()

7. 参考链接地址

授人玫瑰,手有余香!

我们要写一个命令行的客户端,支持对命令进行自动补全。我们不会从头开始写这个命令行客户端的输入输出,而是选择使用 IPythonmycli/pgcli 都用的一个库 prompt_toolkit。它是用了 Python 的正则表达式的 named group 功能,用正则表达式来验证用户输入的命令是否合法,从输入的内容中抓出来 token,然后对这些 token 进行自动补全。随着开发和支持的命令越来越多,这个正则表达式已经膨胀到几百行了,编译速度也令人难以忍受。这就很恐怖了!

解决方法就是即时编译,既然启动的时候编译那么多正则表达式很慢,那么我为什么要一开始就全部编译好呢?用到哪一个再编译哪一个不就好了吗?这样,当用户输入了一个命令参数的时候,即时编译该参数的对应正则表达式,然后进行匹配。如果命令参数比较复杂的时候,会感觉到一点卡顿,可以加个一个装饰器 @lru_cache,将编译结果直接缓存下来,这样只在第一次输入的时候会卡,后面就好多了。

import math

from prompt_toolkit import prompt
from prompt_toolkit.completion import WordCompleter
from prompt_toolkit.contrib.regular_languages.compiler import compile
from prompt_toolkit.contrib.regular_languages.completion import GrammarCompleter
from prompt_toolkit.contrib.regular_languages.lexer import GrammarLexer
from prompt_toolkit.lexers import SimpleLexer
from prompt_toolkit.styles import Style

operators1 = ["add", "sub", "div", "mul"]
operators2 = ["cos", "sin"]


def create_grammar():
    return compile(
        r"""
        (\s*  (?P<operator1>[a-z]+)   \s+   (?P<var1>[0-9.]+)   \s+   (?P<var2>[0-9.]+)   \s*) |
        (\s*  (?P<operator2>[a-z]+)   \s+   (?P<var1>[0-9.]+)   \s*)
    """
    )


example_style = Style.from_dict(
    {
        "operator": "#33aa33 bold",
        "number": "#ff0000 bold",
        "trailing-input": "bg:#662222 #ffffff",
    }
)


if __name__ == "__main__":
    g = create_grammar()

    lexer = GrammarLexer(
        g,
        lexers={
            "operator1": SimpleLexer("class:operator"),
            "operator2": SimpleLexer("class:operator"),
            "var1": SimpleLexer("class:number"),
            "var2": SimpleLexer("class:number"),
        },
    )

    completer = GrammarCompleter(
        g,
        {
            "operator1": WordCompleter(operators1),
            "operator2": WordCompleter(operators2),
        },
    )

    try:
        # REPL loop.
        while True:
            # Read input and parse the result.
            text = prompt(
                "Calculate: ", lexer=lexer, completer=completer, style=example_style
            )
            m = g.match(text)
            if m:
                vars = m.variables()
            else:
                print("Invalid command\n")
                continue

            print(vars)
            if vars.get("operator1") or vars.get("operator2"):
                try:
                    var1 = float(vars.get("var1", 0))
                    var2 = float(vars.get("var2", 0))
                except ValueError:
                    print("Invalid command (2)\n")
                    continue

                # Turn the operator string into a function.
                operator = {
                    "add": (lambda a, b: a + b),
                    "sub": (lambda a, b: a - b),
                    "mul": (lambda a, b: a * b),
                    "div": (lambda a, b: a / b),
                    "sin": (lambda a, b: math.sin(a)),
                    "cos": (lambda a, b: math.cos(a)),
                }[vars.get("operator1") or vars.get("operator2")]

                # Execute and print the result.
                print("Result: %s\n" % (operator(var1, var2)))

            elif vars.get("operator2"):
                print("Operator 2")

    except EOFError:
        pass

文章作者: Escape
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Escape !
  目录