1. 引言
当我们输入ls
再按下TAB时, 会自动列出当前路径下所有的文件;
当我们输入ls a
再按下TAB时, 会自动列出当前路径下所有以a开头的文件; 若只有一个以a开头的文件, 将会自动补全;
当我们输入type
再按下TAB时, 会自动列出全所有可执行的命令;
当我们输入docker rmi
再按下TAB时, 会自动列出所有镜像名;
一个显示文件, 一个显示命令, 一个显示容器名, 这是怎么做到的?
本文将带你一探究竟, 并以docker为例, 实现一个简单的docker自动补全规则
2. complete命令
上述功能, 是 Bash 2.05 版本新增的功能, 叫做自动补全. 自动补全允许我们对命令和选项设置补全规则, 按下TAB之后, 会根据我们设置的规则返回补全列表, 当补全列表只有一个元素时, 就会自动补全.
bash自动补全用到最主要的命令就是complete
, 这是一个Bash的内置命令(builtin), 用于指定某个命令的补全规则, complete语法如下:
complete [-abcdefgjksuv] [-o comp-option] [-DEI] [-A action] [-G globpat] [-W wordlist] [-F function] [-C command] [-X filterpat] [-P prefix] [-S suffix] [name …] | |
complete -pr [-DEI] [name …] | |
选项: | |
-o comp-option | |
定义一些补全的行为, 可以使用的行为如下: | |
nospace 补全后不在最后添加空格 | |
nosort 对于补全列表不要按字母排序 | |
-A action | |
使用预设的补全规则, 可使用的补全规则如下: | |
alias 补全列表设置为所有已定义的别名. 等同于-a | |
builtin 补全列表设置为所有shell内置命令. 等同于-b | |
command 补全列表设置为所有可执行命令. 等同于-c | |
directory 补全列表设置为当前路径下所有目录. 等同于-d, | |
也就是说 complete -d xxx 与 complete -A directory xxx 等价, 只是写法不一样 | |
export 补全列表设置为所有环境变量名. 等同于-e | |
file 补全列表设置为当前路径下所有文件. 等同于-f | |
function 补全列表设置为所有函数名 | |
signal 补全列表设置为所有信号名 | |
user 补全列表设置为所有用户名. 等同于-u | |
variable 补全列表设置为所有变量名. 等同于-v | |
-F function | |
用函数来定义补全规则, 函数运行后 COMPREPLY 变量做为补全列表 | |
-W wordlist | |
用一个字符串来做为补全列表 | |
-p name | |
显示某个命令的补全规则, 如果 name 为空的话则显示所有命令的补全规则 | |
-r | |
移除某个命令的补全规则 |
ls
命令默认的补全列表是当前路径下所有文件, 现在, 我们改变其补全规则, 让其补全列表变为所有可执行命令
$ cd / | |
# 先测试下 ls 默认的补全规则 | |
$ ls<TAB> | |
bin/ boot/ dev/ etc/ home/ lib/ lib32/ lib64/ libx32/ media/ mnt/ opt/ proc/ root/ run/ sbin/ srv/ sys/ tmp/ usr/ var/ | |
# 修改 ls 的补全规则, 让所有可执行命令作为其补全列表 | |
$ complete -c ls | |
# 测试修改补全规则后的 ls | |
$ ls who<TAB> | |
who whoami whoopsie whoopsie-preferences |
提示: 上述改变的补全规则只在当前shell有效, 即不会影响到其他用户, 重新登录后也会失效. 所以想要恢复ls
命令的补全规则的话, 只需要退出再重新登录服务器就好了. 至于如何永久改变补全规则, 请看后文.
我们再来看下type
命令预设的补全规则, 发现type
命令设置的补全列表是所有可执行命令
$ complete -p type | |
complete -c type |
至此, 我们应该知道引言中所提出的问题, 为什么ls
命令会文件而type
命令会列出命令
3. 自定义补全列表
尽管Bash预设了很多补全规则, 但是很明显, 如果我们自己想给docker
命令写补全规则的话, 预设的补全规则显然是不能满足我们需求的. 所以, 我们可以用-W
选项来自定义补全列表.
假设我们自己写了个mydocker
命令, 可以使用的功能有mydocker rm
, mydocker rmi
, mydocker stop
, mydocker start
, 显然, mydocker
的补全列表为rm rmi stop start
, 我们可以使用下面的命令来设置补全规则
# 将 rm rmi stop start 设置为 mydocker 的补全列表 | |
$ complete -W 'rm rmi stop start' mydocker | |
$ mydocker <TAB> | |
rm rmi start stop | |
$ mydocker st<TAB> | |
start stop |
注意: mydocker命令本身没有自动补全, 需要手动完整输入
到这一步, 我们已经能给相当一部分的命令来定义补全规则了. 但是, 上述的'-W'选项, 是静态的补全规则, 不会随着某些条件的改变而变化; docker rmi <TAB>
所有显示的镜像名, 会随着镜像的增删而改变; docker rm <TAB>
所有显示的容器名, 会随着容器的增删而改变; 是动态的补全规则, 这是如何做到的呢?
我们直接通过-p
选项来查看docker预设的补全规则就好了, 发现docker命令是通过-F _docker
来指定补全规则; 再通过type _docker
来查看_docker
是什么玩意, 发现_docker
是一个非常复杂的函数
$ complete -p docker | |
complete -F _docker docker | |
$ type _docker | |
_docker is a function | |
_docker () | |
{ | |
...... | |
} |
接下来, 我们来好好聊一聊-F
这个选项
4. 动态补全列表
-F
选项会指定一个函数做为补全规则, 每次按下TAB时, 就会调用这个函数, 并且将COMPREPLY
的值做为补全列表, 所以我们需要在函数中处理COMPREPLY
变量
除了COMPREPLY
变量外, Bash还提供了一些变量来方便我们获取当前的输入
变量名 | 类型 | 说明 |
---|---|---|
COMP_LINE | 字符串 | 当前的命令行输入的所有内容 |
COMP_WORDS | 数组 | 当前的命令行输入的所有内容, 和COMP_LINE 不同的是, 这个变量是一个数组 |
COMP_CWORD | 整数 | 当前的命令行输入的内容位于COMP_WORDS 数组中的索引 |
COMPREPLY | 数组 | 补全列表 |
接下来我们编写一个补全脚本来测试这些变量, 脚本名字可以随便取, 暂且叫做 test.sh, 文件内容如下:
_complete_test() { | |
echo | |
echo "COMP_LINE: $COMP_LINE" # 当前的命令行输入的所有内容(字符串) | |
echo "COMP_WORDS: ${COMP_WORDS[@]}" # 当前的命令行输入的所有内容(数组) | |
echo "COMP_CWORD: $COMP_CWORD" # 数组的索引 | |
echo "last_word: ${COMP_WORDS[COMP_CWORD]}" # 最后一个输入的单词 | |
echo "COMPREPLY: $COMPREPLY" # 补全列表 | |
} | |
complete -F _complete_test mydocker |
我们通过执行source test.sh
来使脚本生效, 然后来测试脚本
$ source test.sh | |
$ mydocker <TAB> | |
COMP_LINE: mydocker # 当前的命令行输入的所有内容(字符串) | |
COMP_WORDS: mydocker # 当前的命令行输入的所有内容(数组) | |
COMP_CWORD: 1 # 数组的索引 | |
last_word: # 最后一个输入的单词 | |
COMPREPLY: # 补全列表 | |
$ mydocker xy<TAB> | |
COMP_LINE: mydocker xy # 当前的命令行输入的所有内容(字符串) | |
COMP_WORDS: mydocker xy # 当前的命令行输入的所有内容(数组) | |
COMP_CWORD: 1 # 数组的索引 | |
last_word: xy # 最后一个输入的单词 | |
COMPREPLY: # 补全列表 |
我们理解了上述的变量之后, 我们是不是可以这样做: 获取当前输入的内容, 如果是mydocker
的话, 将补全列表设置为rm rmi stop start
; 如果是mydocker rm
的话, 查询出所有的容器名, 并将补全列表设置为所有的容器名, start
和stop
同理; 如果是mydocker rmi
的话, 补全列表设置为所有的镜像名. 因为每次自动补全都会执行我们的函数, 所以我们的补全列表就是动态的了
在修改test.sh脚本之前, 我们造一点测试数据, 拉取两个镜像并运行这两个镜像
$ docker pull redis | |
$ docker pull redmine |
接下来将test.sh脚本修改为如下内容:
_complete_mydocker() { | |
local prev | |
prev="${COMP_WORDS[COMP_CWORD-1]}" | |
case "${prev}" in | |
rm) COMPREPLY=( $(docker ps -a | tail -n +2 | awk '{print $NF}') ) ;; | |
rmi) COMPREPLY=( $(docker images | tail -n +2 | awk '{print $1}') );; | |
mydocker) COMPREPLY=( rm rmi stop start ) ;; | |
esac | |
} | |
complete -F _complete_mydocker mydocker |
注意: case语句中判断的是倒数第二个输入的单词, 因为当我们运行mydocker r<TAB>
时, 最后一个单词是r
, 倒数第二个单词是mydocker
, 显然此时我们需要的是mydocker
的补全列表
修改完脚本后, 要再次执行source test.sh
才能使脚本生效. 然后来测试脚本
$ mydocker <TAB> | |
rm rmi start stop | |
# 貌似有点问题? | |
$ mydocker rm<TAB> | |
rm rmi start stop | |
$ mydocker rmi <TAB> | |
redis redmine | |
# 貌似又有问题? | |
$ mydocker rmi redi<TAB> | |
redis redmine |
目前的补全脚本还是存在一些问题, 其实也很容易发现问题, 无论我们输入mydocker rmi re
还是mydocker rmi redi
, 都会匹配到补全脚本中的rmi) COMPREPLY=( $(docker images | tail -n +2 | awk '{print $1}') );;
, 我们返回的补全列表COMPLETE
都是同样的结果, 补全列表并没有变, 补全列表返回的都是redis redmine
. 然而, 我们想要的是, 输入mydocker rmi re
返回redis redmine
, 输入mydocker rmi redi
返回redis
, 这就需要compgen命令出场了
Tips: 可能有些读者会有疑问, 为什么设置同样的候选列表, 使用-W
就和预期一样而使用-F
就会出现上述问题, 因为-W
已经帮我们实现了类似compgen的功能, 而-F
需要我们手动处理才行
5. compgen命令
compgen也是一个Bash内置命令, 其选项几乎和complete是通用的, 其作用就是筛选, 看几个例子大家就明白怎么用了
# -W指定补全列表, 并返回与st相匹配的值 | |
$ compgen -W 'rm rmi start stop' -- st | |
start | |
stop | |
# -W指定补全列表, 并返回与sto相匹配的值 | |
$ compgen -W 'rm rmi start stop' -- sto | |
stop | |
# -b指定补全列表为Bash内置命令, 并返回与c相匹配的值 | |
$ compgen -b -- c | |
caller | |
cd | |
command | |
compgen | |
complete | |
compopt | |
continue |
学会了compgen命令, 我们再来修改脚本, 将COMPREPLY=( rm rmi stop start )
修改为COMPREPLY=( $(compgen -W "rm rmi stop start" -- 最后一个单词) )
就可以动态修改补全列表了
最后将脚本修改如下:
_complete_mydocker() { | |
local cur prev mydocker_opts images contains | |
cur="${COMP_WORDS[COMP_CWORD]}" | |
prev="${COMP_WORDS[COMP_CWORD-1]}" | |
mydocker_opts="rm rmi stop start" | |
images=$(docker images | tail -n +2 | awk '{print $1}') | |
contains=$(docker ps -a | tail -n +2 | awk '{print $NF}') | |
case "${prev}" in | |
rm) COMPREPLY=( $(compgen -W "${contains}" -- ${cur}) ) ;; | |
rmi) COMPREPLY=( $(compgen -W "${images}" -- ${cur}) );; | |
mydocker) COMPREPLY=( $(compgen -W "${mydocker_opts}" -- ${cur}) ) ;; | |
esac | |
} | |
complete -F _complete_mydocker mydocker |
执行脚本后再次测试脚本, 已经能达到我们想要的效果了
$ mydocker <TAB> | |
rm rmi start stop | |
$ mydocker rm<TAB> | |
rm rmi | |
$ mydocker rmi <TAB> | |
redis redmine | |
$ mydocker rmi re<TAB> | |
redis redmine | |
# 这里就会自动补全了 | |
$ mydocker rmi redi<TAB> |
6. 别名的自动补全
笔者用docker相关的命令用的比较多, 不想每次敲这么长, 所以直接执行alias d=docker
把d
设置为docker
的别名, 设置后方是方便了很多, 但是用不了自动补全
没关系, 既然docker有自动补全, 那么d也必须有自动补全. 通过执行complete
命令发现, docker的补全规则是_docker
函数提供的
$ complete -p docker | |
complete -F _docker docker |
那我们只需要执行complete -F _docker d
, 将d
的补全规则设置为_docker
, 这样d
也可使用自动补全了
$ d <TAB> | |
build cp events help images inspect login network plugin pull restart run secret start swarm top version | |
commit create exec history import kill logout node port push rm save service stats system unpause volume | |
container diff export image info load logs pause ps rename rmi search stack stop tag update wait |
7. 补全规则永久生效
上述例子中, 我们执行补全规则脚本, 使用的是. completion_script
或者source completion_script
的形式来执行, 而不是通过./completion_script
或bash completion_script
的形式来执行, 是因为: 前者的作用范围是当前shell; 而后者会在子shell中执行, 不会影响到当前shell, 看起来就和没执行一样. 子shell是另外一个很重要的概念, 感兴趣的读者可自行了解.
由于source completion_script
的作用范围是当前shell, 所以我们设置的补全规则不会影响到其他用户, 同时也会在重新登录后失效. 要使补全规则永久生效, 我们将source completion_script
本添加到 ~/.bashrc 或者 ~/.profile 文件中即可. 因为这两个文件是Bash的初始化文件, 每次登录Bash都会执行初始化文件, 所以就可以达到永久生效的效果.
8. 自动加载
最后提一下自动补全脚本是如何自动加载的. 入口是 /etc/bash.bashrc 这个文件, 其会调用 /usr/share/bash-completion/bash_completion 或 /etc/bash_completion
$ cat /etc/bash.bashrc | |
...... | |
...... | |
if ! shopt -oq posix; then | |
if [ -f /usr/share/bash-completion/bash_completion ]; then | |
. /usr/share/bash-completion/bash_completion | |
elif [ -f /etc/bash_completion ]; then | |
. /etc/bash_completion | |
fi | |
fi |
查看 /etc/bash_completion 得知, 无论调用哪个文件, 最后实际上调用的都是 /usr/share/bash-completion/bash_completion
$ cat /etc/bash_completion | |
. /usr/share/bash-completion/bash_completion |
打开 /usr/share/bash-completion/bash_completion 文件, 在2151行左右, 有以下一段代码, 大概意思就是会执行 /etc/bash_completion.d 中的每个文件, 所以, 我们将自动补全脚本放在这个路径下, 并设置好读权限, 每次登录系统就会自动加载, 也可以达到永久生效的效果.
$ cat /usr/share/bash-completion/bash_completion | |
...... | |
...... | |
compat_dir=${BASH_COMPLETION_COMPAT_DIR:-/etc/bash_completion.d} | |
if [[ -d $compat_dir && -r $compat_dir && -x $compat_dir ]]; then | |
for i in "$compat_dir"/*; do | |
[[ ${i##*/} != @($_backup_glob|Makefile*|$_blacklist_glob) \ | |
&& -f $i && -r $i ]] && . "$i" | |
done | |
fi |
实际上, Ubuntu中一般的自动补全脚本一般放在 /usr/share/bash-completion/completions/, 也会自动加载, 入口是 /etc/bash_completion.d 的2132行左右写道了complete -D -F _completion_loader
, 这里就不展开讲了.