☰
Bash可编程补全完全指南:从Tab键到compgen
2026/10/10 6:32:26 网站建设 项目流程

我敢打赌,你每天在终端里按 Tab 键的次数,比你按回车键还要多。Bash 的默认补全只能给你补路径、补命令名、补变量名,但只要你装过现代 Linux 发行版,大概率已经享受过那种"输入 git che 再按一次 Tab,直接给你列出 checkout、cherry-pick、clean"的体验。这就是 Bash 可编程补全在做的事。这一章我们从 Command Line Editing 里跳出来,专门把 Programmable Completion 这个概念从里到外拆一遍,讲清楚它背后的内置命令、环境变量、补全函数长什么样,以及你自己怎么写一个能用、可维护的补全脚本。

这节内容适合所有想彻底搞懂 Tab 补全的人,不管你是刚学 Bash 的初学者,还是已经在写运维脚本的老手,只要受够了"每次都补全到一半还要自己手动敲完"的日子,这篇文章都值得看下去。我会把 complete、compgen、compopt 这三个内置命令逐一讲透,再结合几个我实际用过的补全场景,逐个步骤还原整个实现过程。

1. 从默认补全到可编程补全:Tab 背后的两级系统

1.1 Bash 默认补全到底做了什么

在不做任何配置的情况下,Bash 默认补全遵循一套固定的规则。你敲命令名的位置,它从 PATH 路径里找可执行文件;敲参数的位置,它按文件路径补全;敲 $ 符号后面,它补变量名;敲 ~ 后面则补用户名。这套逻辑简单、稳定,但有个很明显的缺点:它不关心你要执行的命令到底期待什么参数。

比如你输入apt install ngin再按 Tab,默认补全只会去当前目录里找以 ngin 开头的文件,而不是从 apt 的软件包索引里找 nginx。这种"无差别补全"在绝大多数场景下能用,但效率不高。可编程补全就是在这个基础上,允许你给不同命令单独定义一套补全规则,告诉 Bash:这个命令的某个位置应该补什么内容、从那一段命令行里取上下文、候选词如何生成。

1.2 可编程补全的组成模块

Bash 的可编程补全由三个层面构成。最上层是补全规范(completion spec),由complete命令定义,指定某个命令使用什么样的补全方式。中间层是补全函数,它是一个普通 Bash 函数,Bash 在响应 Tab 时调用它,函数通过检查命令行内容,把候选结果写入COMPREPLY数组。最底层是候选词的生成工具,最核心的就是compgen命令,它根据类型、前缀、条件生成匹配的单词列表。

实操中最常见的是把这三者串起来:complete -F _my_command mycmd,其中_my_command是补全函数,mycmd是用户实际输入的命令。Bash 遇到mycmd在命令位置时,就会调用_my_command,函数运行完以后把结果放进COMPREPLY,Bash 再根据这个数组做 Tab 替换。如果你想看系统里已经注册了哪些补全规则,直接执行complete -p就能列出一大堆。

2. 三个核心内置命令:complete、compgen、compopt

2.1 complete:定义补全规则的入口

complete命令的语法比大多数人想象中复杂,但核心选项并不难记。-F跟一个函数名,表示使用该函数生成补全候选;-A跟一个动作类型,比如-A command表示补全命令名、-A file表示补全文件名、-A user补全用户名;-o用来设置补全行为选项,最常用的是-o filenames,它告诉 Bash 把候选词按文件名处理,自动给它加合适的转义和斜杠;-o nosort则表示不要对候选词排序,保持compgen输出的原始顺序;-o bashdefault要求在没有匹配结果时回退到默认补全。

删除一条规则用-r,比如complete -r mycmd就把 mycmd 的补全规则移除。用得少但值得知道的是-D和-E:-D给"默认命令"指定补全规则,所有没有显式定义规则且没有默认 Bash 补全的命令都会落到它头上;-E则是给空命令输入时(直接按 Tab)定义规则。调试的时候-p加命令名可以单独查看某条规则,比如complete -p git。

2.2 compgen:补全候选词的生成器

compgen是补全函数里真正干活的命令。它根据选项从系统里提取候选词,然后按第二个参数作为前缀过滤。举个例子,你在终端敲compgen -c,它会输出当前 PATH 下所有可执行命令名;compgen -c git则只输出以 git 开头的命令。-c补命令名、-d补目录名、-f补文件名、-u补用户名、-v补变量名、-W接一个用空格分隔的单词列表,这是最常用的,因为它让你可以自由定义候选集合。

compgen还可以用-G按 glob 模式生成文件名候选,用-X排除匹配指定模式的候选词。组合起来威力很大,比如compgen -f -X "*.tmp"表示补全文件名但排除所有取 tmp 结尾的文件。在补全函数里,一般先调用compgen生成候选列表,再循环过滤写入COMPREPLY。理解compgen的工作方式,是理解可编程补全的必经之路。

提示:compgen本身也是一个普通命令,可以在命令行直接运行做测试。这是调试补全规则最方便的手段,比反复按 Tab 去猜要高效得多。

2.3 compopt:运行时修改补全选项

compopt这个命令相对小众,但它在补全函数内部非常有用。它允许函数在运行过程中动态开关补全选项,比如compopt -o filenames就是临时把当前补全结果按文件名处理,compopt +o filenames则是取消这个行为。还有一个常用场景是compopt +o default,用来禁用 Bash 的默认补全回退,避免出现"自定义补全没有输出结果时,Bash 又自动去补文件路径"的尴尬情况。

我自己的建议是:在写稍复杂一点的补全函数时,先仔细想清楚要不要让 Bash 在函数没结果时回退到默认补全。很多个 undefined 行为的 bug 都来自这个细节。如果你明确希望"没有匹配就不给任何候选",那就在函数里显式执行compopt +o default;如果希望"自定义候选为空时,至少还能补文件名",那就不碰这个选项。

3. 补全函数内部:那些 COMP_ 开头的变量

3.1 关键环境变量逐个说

Bash 在调用补全函数之前会设置一批 COMP_ 变量。COMP_WORDS是一个数组,保存当前命令行按空格切分后的所有单词,需要特别注意的是 COMP_WORDS 用的是 Bash 的单词切分规则,引号和转义符的处理有时会和你直觉不一样。COMP_CWORD是当前光标所在处单词在 COMP_WORDS 里的下标,从 0 开始算,比如你输入git che,COMP_WORDS 就是('git', 'che'),COMP_CWORD 就是 1。这两个变量是补全函数里最常用的上下文依据。

另外还有COMP_LINE,保存整行命令行文本;COMP_POINT是光标位置在这行文本中的下标,COMP_LINE 从下标 0 到 COMP_POINT 正好就是光标左边的内容,这个在处理引号内的补全时特别有用。还有COMPREPLY数组,这是你唯一需要写结果的变量。函数做完所有判断、过滤、生成之后,把候选词逐个放进 COMPREPLY,比如COMPREPLY=()清空它,再COMPREPLY+=(...)追加元素。Bash 看到 COMPREPLY 非空,就会接管后续的 Tab 替换逻辑。

3.2 一个最小可用的补全函数

我先让你感受一下函数的整体形状。下面这个函数给mytool命令补全一堆固定选项:

_mytool_completion() { local cur prev cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" local commands="start stop restart status" COMPREPLY=( $(compgen -W "${commands}" -- "${cur}") ) } complete -F _mytool_completion mytool

COMPREPLY=( $(compgen -W "${commands}" -- "${cur}") )这一行是核心,compgen -W从给定列表里筛出以$cur开头的结果,--是防止$cur以短划线开头时被当成选项处理。local cur prev声明局部变量,避免污染全局环境,这是补全函数的良好习惯。定义完函数,再用complete -F把它挂到 mytool 命令上。敲mytool st再加 Tab,Bash 就会列出 start 和 status。

注意:补全函数会运行在 Bash 的当前环境里,但它里面设置的普通变量不会自动变成局部变量。凡是只在这个函数内部使用的变量,一律用 local 声明,否则下次调用时可能出现变量残留的诡异问题。

4. 实战案例:为真实场景编写补全脚本

4.1 场景一:给自定义命令补解析参数

假设你自己维护了一个叫deploy.sh的部署脚本,用法是deploy.sh <env> <action>,env 可选 dev、staging、prod,action 可选 build、test、release。一个很自然的补全需求:第一个参数补 env,第二个参数补 action,其他位置不补全。我们可以用一个函数加一个计数器实现:

_deploy_completion() { local cur cur="${COMP_WORDS[COMP_CWORD]}" local envs="dev staging prod" local actions="build test release" if [[ COMP_CWORD -eq 1 ]]; then COMPREPLY=( $(compgen -W "${envs}" -- "${cur}") ) elif [[ COMP_CWORD -eq 2 ]]; then COMPREPLY=( $(compgen -W "${actions}" -- "${cur}") ) else COMPREPLY=() fi } complete -F _deploy_completion deploy.sh

这里[[ COMP_CWORD -eq 1 ]]判断当前输入的是第一个参数,依此类推。实际工作中脚本往往不止两个参数,你可以用 case 语句细分每个参数位置的含义。这种写法的好处是逻辑直白、容易维护,缺点是参数一多,case 分支会很臃肿,这时就要往"按前一个参数决定当前候选"的方向演进。

再进阶一点,可以让补全结果受前一个参数影响。比如deploy.sh prod之后只应该出现 build 和 release,而deploy.sh dev之后可以出现所有 action。实现方式就是判断prev:

if [[ COMP_CWORD -eq 2 ]]; then prev="${COMP_WORDS[COMP_CWORD-1]}" case "${prev}" in prod) COMPREPLY=( $(compgen -W "build release" -- "${cur}") ) ;; *) COMPREPLY=( $(compgen -W "${actions}" -- "${cur}") ) ;; esac fi

4.2 场景二:SSH 主机名补全

很多人不知道可以给别人写过 SSH 的 ~/.ssh/config 自动补全主机名。这个需求非常典型:你连的服务器多了,Host 别名一长串,每次都手敲太折磨。补全函数只需要从 config 文件里把 Host 行抠出来:

_ssh_completion() { local cur cur="${COMP_WORDS[COMP_CWORD]}" local hosts if [[ -f ~/.ssh/config ]]; then hosts=$(awk '/^Host / {for (i = 2; i <= NF; i++) print $i}' ~/.ssh/config) fi COMPREPLY=( $(compgen -W "${hosts}" -- "${cur}") ) compopt -o filenames } complete -F _ssh_completion ssh scp

awk 命令提取 config 里每一行以 Host 开头后面跟着的所有字段,这是 Host 别名可以写多个的基本语法。函数末尾的compopt -o filenames是技巧性的:SSH 补全时如果当前目录有同名文件,Bash 的默认行为会把结果当作文件处理,在候选词末尾加斜杠,这个行为通常不是我们想要的,加上 filenames 是要保证候选词被当作普通词处理。严格来说这个场景更常见的写法是不加-o filenames直接作为单词候选,但我实践中发现加上能避免很多路径转义意外。

这个函数还能继续扩展:从 known_hosts 里解析历史连接过的主机名,从 inventory 文件里解析 CMDB 里的主机名,从云厂商 API 里拉取机器列表。把补全函数当作一个读数据的入口,你能想象的空间就打开了。

4.3 场景三:模仿 git 风格的子命令补全

git 的补全脚本是 Bash 可编程补全教科书级别的案例。它虽然不是每个细节都值得照搬,但它的结构值得借鉴:先判断当前是在子命令位置、选项位置还是子命令参数位置,然后分别处理。我们简化一个版本,给一个假想的mygit命令写补全,支持 start、stop、status、branch 四个子命令,并且 branch 子命令后面跟本地分支名:

_mygit_completion() { local cur prev commands subcommands cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" commands="start stop status branch" case "${prev}" in branch) local branches branches=$(git branch --format='%(refname:short)' 2>/dev/null) COMPREPLY=( $(compgen -W "${branches}" -- "${cur}") ) return 0 ;; esac if [[ COMP_CWORD -eq 1 ]]; then COMPREPLY=( $(compgen -W "${commands}" -- "${cur}") ) else COMPREPLY=() fi } complete -F _mygit_completion mygit

这个例子展示了按前一个单词分流的思想。你输入mygit branch feat再按 Tab,函数发现前一个单词是 branch,就去调 git branch 读取实际分支名做候选。这种"把命令的权威数据源作为补全依据"的思路,正是很多复杂补全脚本的通用套路。生产环境里的 git 补全脚本也是这个思路,只不过它把子命令列表、选项列表、文件和分支补全整合在了一个巨大的状态机里。

4.4 动态生成候选词:从文件、命令输出和全局配置

补全函数的数据来源不只是写死的字符串列表。最常用的三种:从文件内容读取,比如上一个 SSH 例子;从命令输出读取,比如从getent passwd里提取用户名、从某个 CMDB 接口的 curl 结果里提取主机名;从全局配置文件读取,比如场景一里把 env 列表放到一个全局变量或单独配置文件里,需要更新部署环境时只需要改一处。

从命令输出读取时有个性能陷阱要小心:补全函数每按一次 Tab 都会执行一遍,如果里面跑了一个需要几秒的远程调用,那体验会非常糟糕。我的做法是缓存:把结果写入一个临时文件,设定一个过期时间,比如 5 分钟内的调用直接用缓存,超过时效再重新获取。另一个缓解方案是异步预加载,在 shell 启动时就把数据拉到临时文件,补全函数只读文件,不主动发起计算。对大项目来说,这两个方案我都实践过,缓存方案实现成本更低,大多数场景够用。

5. 调试与排查:补全脚本出问题怎么办

5.1 常见问题速查表

我在写补全函数的过程中踩过的坑、问过别人的问题,整理成了一张速查表,先说最常见的几个:

症状原因排查方向
按 Tab 没反应complete 规则没注册,或者函数里没输出 COMPREPLY执行complete -p 命令名看规则是否存在
候选词带转义斜杠Bash 默认把结果当文件名处理函数末尾加compopt +o filenames
候选词排序不对Bash 默认按 locale 排序加-o nosort保留原始顺序
补全结果出现文件路径函数无结果又没关闭默认补全compopt +o default禁止回退
每次 Tab 都很慢函数里跑了耗时命令缓存输出,或者缩小数据源范围
COMPREPLY 赋值语法错数组赋值和分词混淆用COMPREPLY=( ... )数组语法,别用普通字符串

排查的第一条永远是用complete -p确认规则存在,不要上来就改函数。规则注册后,在函数第一行加一句printf '%s\n' "${COMP_WORDS[*]}" > /tmp/comp_debug,然后打开另一个终端按 Tab,看输出文件里的内容。这个调试手段虽然原始,但非常可靠。

5.2 用 compgen 单独验证候选词

很多时候问题不在函数逻辑,而在候选词本身。比如你写了compgen -W "${commands}" -- "${cur}",怀疑$commands变量没展开,那可以直接在命令行验证。因为compgen是普通外部命令,Bash 里可以直接执行:

$ commands="start stop status" $ cur="st" $ compgen -W "${commands}" -- "${cur}" start stop status

如果命令行下输出正常,但通过 Tab 补全就不正常,问题基本能锁定在函数上下文或者 COMP_CWORD 判断上。反过来,如果 compgen 本身输出的内容就不对,那优先修数据生成部分。这个"分而治之"的排查思路在写补全脚本时非常高效,我几乎每次都这么用。

5.3 面向复杂补全的调试增强

当你的补全函数逻辑复杂起来以后,printf 到文件这种手段就会嫌不够直观。我的做法是在函数里设置一个不需要每次修改的开关,只在特定条件下打印上下文信息:

_debug_file=/tmp/mytool_completion.debug _debug_enabled=0 _mytool_completion() { if (( _debug_enabled )); then { printf 'cur=%q\n' "${COMP_WORDS[COMP_CWORD]}" printf 'cword=%s\n' "$COMP_CWORD" printf 'words:' printf ' <%s>' "${COMP_WORDS[@]}" printf '\n' printf 'COMPREPLY before:' printf ' <%s>' "${COMPREPLY[@]}" printf '\n' } >> "$_debug_file" fi # 原有逻辑 # 函数末尾再打印 after COMPREPLY }

这个写法让你在任何时候都能把_my_debug 打开,按一次 Tab 看一次运行轨迹,不需要改动函数本身。等排查完再把开关关掉。另外建议把补全函数直接改为set -x来追踪执行,但那样输出会非常混乱,只是在实在定位不到问题时才用。

5.4 加载时机与 PATH 的影响

补全脚本的加载时机很容易被忽略。很多发行版把补全脚本放在/etc/bash_completion.d/,Bash 启动时由/etc/bash_completion这个脚本统一 source。自己做开发时,通常把补全函数写在~/.bashrc或单独的~/.bash_completion文件里。要注意的是,complete -F注册的函数可以写在函数定义之前吗?技术上可以,因为函数和规则是两回事,但 Bash 在第一次按 Tab 时才查找函数,那时候函数必须已经存在,否则什么都不补。我的习惯是把函数定义放在 complete 注册之前,逻辑顺序清晰,也不容易出问题。

PATH 的影响更隐蔽。complete按命令名注册规则,但如果你在 PATH 里有两个同名的命令,Bash 只按名字匹配,不区分路径。比如系统自带的 foo 在 /usr/bin/foo,你自己编译的 foo 在 /opt/foo/bin/foo,命令行输入 foo 按 Tab,Bash 不会关心你 actually 要跑哪个 foo。如果需要区分,别无他法,只能给其中一个改名,或者用完整路径调用,这是补全规则本身的一个限制。

6. 让补全脚本稳定落地:从单文件到系统级配置

6.1 bash_completion 的组织方式

我现在还没提大型 Bash 补全生态的部署方式,其实大多数 Linux 发行版都装了一个叫 bash-completion 的软件包,它包含一组基础补全脚本,覆盖 git、systemctl、apt 等大量常见命令。装了这个包以后,你会发现 /etc/bash_completion.d/ 和 /usr/share/bash-completion/completions/ 下有很多脚本,Bash 启动时会在需要时动态加载它们。这个"按需加载"机制值得自己实现一遍,原则是:不在 shell 启动时把所有补全脚本全部加载,而是在第一次按 Tab 遇到某个命令时才加载对应的补全文件。具体实现通常借助complete -D作为兜底规则,在兜底函数里按命令名去查找补全文件并 source。

自己管理补全脚本时,我不建议把函数写进 .bashrc,因为 .bashrc 会被子 shell 继承,污染所有交互 shell 的环境。更干净的做法是新建一个~/.bash_completion文件,在 .bashrc 末尾加一行:

[[ -f ~/.bash_completion ]] && source ~/.bash_completion

然后用一个独立脚本管理每个命令的补全文件,类似于:

# ~/.bash_completion.d/deploy _deploy_completion() { ... } complete -F _deploy_completion deploy.sh

这样每添加一个新命令的补全,就丢一个文件到目录里,不用反复改 .bashrc。对团队协作也有好处,配置文件能按命令分开做版本管理。

6.2 从零做一个补全脚本的完整流程

我给你梳理一套可以直接照着做的流程。第一步,确认要补全的命令的用法,把每一个参数位置的候选词范围写清楚,遇到不确定的空位可以先留空。第二步,在终端用 compgen 手动测试候选词生成逻辑,确认孤立的候选列表没问题。第三步,写补全函数框架,先只处理第一个参数位置,其他位置全部返回空数组。第四步,把 complete 注册和函数定义放到 ~/.bash_completion.d/ 下对应文件里,重新 source。第五步,交互验证每个参数位置,发现不合理的分支就逐步修正。第六步,跑一遍边界情况:空输入按 Tab、输入一半按 Tab、输入不存在的值按 Tab,这些场景最容易暴露逻辑漏洞。

我个人的经验是:新手最容易在第 4 到第 5 步之间卡住,因为补全函数是在按 Tab 时才执行的,你没有直接调用函数的入口,感觉像在黑盒里调试。解决办法就是前面提到的 printf 日志或set -x,把函数当普通函数来调试,效率会高非常多。

6.3 避免过度工程化

写补全脚本最大的风险是过度设计。有人会把补全函数写成几百行,处理几十种子命令组合,结果维护成本比被补全的命令本身还高。我见过生产环境里有一个补全文件上千行的项目,后来没人敢动它。我的建议是:优先满足使用频率最高的 3 到 5 个参数位置,其余位置保持简单甚至是空的。补全的目标是减少出错概率、提升输入效率,而不是做出一个功能完整的交互式解析器。

如果发现某个补全逻辑特别复杂,多想想能不能从命令本身得到支持。比如很多命令自带--list或--help的稳定文本输出,可以解析它生成候选词,比手写一个完整的参数表更可靠。或者直接改成用 shell 的case集中管理参数选项,少写一些花哨的通用框架,多写一些直接的、可读的分支判断。

7. 最后再分享两个我在实践中验证过的小细节

第一个细节:COMP_WORDS 对引号的处理并不总是你预期的那样。COMP_WORDS[COMP_CWORD]拿到的 cur 是 Bash 切分后的单词,如果你输入一个含空格的文件名并把它用引号括起来,Bash 切分时会把引号处理掉。所以我自己在补全函数里需要判断当前输入是否带引号时,会读 COMP_LINE 而不是 COMP_WORDS,用 COMP_POINT 截取光标前的文本,再手工处理引号边界。这个细节在补全带空格路径名时很关键。

第二个细节:complete -F的函数在设置 COMPREPLY 时,候选词要尽量保持"全词",而不是只补后缀。什么意思?你输入deploy pr时,补全函数给出的候选应该是prod,而不是od。compgen -W ... -- "${cur}"本来就会把完整匹配项返回,但如果你从某个文件里截取了不完整片段,最后 COMPREPLY 里的内容就可能是缺失前缀的半截词,导致 Tab 出来的结果完全不对。所以我每次从外部数据源抓文本时,都会确认最终放进 COMPREPLY 的每一项都是完整的候选值。

补全脚本这个东西,说难不算难,说简单也绕不过那些细节。希望这一章把这些细节讲透之后,你下次再遇到某个命令的补全不顺手,能直接打开编辑器写一个自己的补全函数,而不是忍受默认补全的低效。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询