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 有 compgen 和 complete 两个内置补全命令。
编号 | 内置补全命令 | 命令作用解释 |
---|---|---|
1 | compgen | 根据不同的参数生成匹配单词的候选补全列表 |
2 | complete | 在命令行中根据不用的前缀补全对应参数候选补全列表 |
- 其中 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 自带的一些补全脚本来学习,理解之后,基本就是改吧改吧就可以使用了。
- 一般补全函数都会定义 cur 和 prev 这两个变量,其中 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
- 从上面的命令我们肯定很疑问,为什么自动补全 Bash 和 Zsh 的脚本会要区别呢?主要是因为 Bash 和 Zsh 的自动补全实现细节不同,当然不同的操作系统对应的脚本也是有差异的。在 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. 参考链接地址
授人玫瑰,手有余香!
我们要写一个命令行的客户端,支持对命令进行自动补全。我们不会从头开始写这个命令行客户端的输入输出,而是选择使用 IPython 和 mycli/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 commandn") 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: %sn" % (operator(var1, var2))) elif vars.get("operator2"): print("Operator 2") except EOFError: pass