ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Bash可编程补全实战:让Tab键理解你的命令

Bash可编程补全实战:让Tab键理解你的命令 你有没有经历过这样的场景别人按 Tab 键命令名、子命令、参数、服务名一口气全部跳出来光标行云流水你按 Tab 键只有路径在那兜兜转转偶尔还把文件名补得乱七八糟。这不是手速的问题也不是终端软件的差距大多数情况下是因为没把 Bash 的 Programmable Completion 机制用起来。Command Line Editing 这一章前面讲的是 Readline 如何处理光标移动、历史搜索、文本插入这些行编辑操作而到了第 6-8 节重点转向了一个更有意思的话题让补全本身可以编程让 Tab 键能理解你正在输入的命令到底是什么。这篇文章我打算抛开手册里那种干巴巴的条目式说明直接站在实操角度把 Programmable Completion 的机制、配置、常见坑和自定义方法全部拆开讲一遍。无论你只是想把终端用得更顺手还是想给自己写的脚本或工具加上一套“官方级”的 Tab 补全都应该能从里面拿到能直接复制走的东西。1. Command Line Editing 到底在解决什么问题1.1 终端里的“打字编辑器”很多人以为在终端里敲命令就是“把字符输进去回车执行”这么简单但实际上现代 Bash 里你面对的是一个完整的行编辑系统。读取输入、处理光标前后移动、删除单词、在历史记录里翻找、把某段文本重新拼接这些功能并不是终端自带的而是由 Readline 这个库在背后撑着的。Command Line Editing 这一整章其实就是围绕 Readline 的能力在讲。你可以做个小实验打开一个终端什么都不输直接按Ctrl-r。会发现进入了一个“反向搜索历史”模式输入几个字符就能把很久之前敲过的命令翻出来。这其实就是 Readline 在起作用。再比如按Alt-b按单词回跳、Ctrl-a跳到行首这些在平时编辑长命令时非常管用的快捷键本质上都属于“行编辑”范畴。Programmable Completion 并不是和这些并列的另一个东西而是行编辑能力的一部分——你在行内输入内容时Tab 键触发的补全本质上就是一次“文本生成”操作。1.2 默认补全和可编程补全的分界线Bash 出厂就带了一套默认补全规则输入命令名时按 Tab 会去找 PATH 里的可执行文件输入参数时按 Tab 会去找当前目录下的文件。但这些规则有一个根本性的局限——它根本不理解你现在敲的命令是什么更不可能知道你敲的这个命令支持哪些选项。打个比方默认补全像一个只会认脸的门卫他只知道“有人来了就放行”至于这个人是来送快递还是来开会他不管。而 Programmable Completion 相当于你给门卫发了一本《常访客习惯手册》里面写着不同的人进来该引到哪个会议室、该告诉他哪些楼层信息、该给他哪把钥匙。手册里的内容就是补全函数而你给某条命令“发手册”的动作就是用complete命令去注册一个补全规范compspec。从这一章的章节安排看第 6-8 节之所以把可编程补全单独拎出来讲是因为它是整个 Command Line Editing 里唯一面向“程序化扩展”的部分。Readline 的按键绑定是静态配置而补全函数是动态执行的——你按一次 TabBash 就调用一次函数根据函数当时的返回值来决定显示哪些候选词这个执行模型决定了它能做到非常灵活。1.3 为什么叫“可编程”“可编程”三个字听起来玄乎但落到实际就是两个能力一是补全的结果可以由函数逻辑动态生成而不只是读文件列表二是补全函数能拿到当前命令行的上下文信息包括已经输入的命令名、当前光标处的单词、光标位置之前所有的单词。有了这两个输入函数就可以做到类似“这个选项后面只能跟数字”“那个子命令后面只能跟某个文件类型”“前面已经用过某个参数就不再提示第二次”这类智能行为。我在实际使用中有一个很直观的感受补全做得好的工具使用体验完全不亚于一个图形界面的下拉菜单。你可以连续按 Tab 在候选词之间切换按两下 Tab 列出所有可能的后续输入。对于要频繁在终端里操作的人来说一套好的补全配置节省下来的时间远比想象中要多。2. 环境准备先让我看到补全规则的存在2.1 确认当前 Bash 与 Readline 的状态在看任何自定义补全之前第一步是搞清楚你当前环境支持到什么程度。Bash 的可编程补全从 2.04 版本开始引入到 4.x、5.x 时期已经相当成熟。绝大多数现代发行版默认都是 Bash 5.x所以门槛并不高。想确认版本直接运行bash --version | head -1如果版本低于 4.0我建议你先想办法升级一下因为后面要讲的COMP_WORDS、COMP_CWORD这些数组变量在旧版本里行为不太一致排查问题时会平白多出很多麻烦。接着可以看看当前系统里已经注册了哪些补全规则complete -p这条命令会打印出所有 registered 的补全规范。如果你从来没动过配置大概率会看到一些默认规则。如果输出很多说明你已经装上了某个全局补全框架或者某些软件包在安装时自动注册了它们的补全脚本。如果你的系统里这条命令输出几乎为空那说明你的 Bash 还处于“裸奔”状态只能依赖默认路径补全。2.2 全局补全框架与自定义文件的加载顺序很多发行版都自带一个叫 bash-completion 的框架里面收集了大量常见命令的补全定义。它通常放在/usr/share/bash-completion/bash_completion这个路径下然后由/etc/profile.d/bash_completion.sh在登录时加载。这个框架值得装尤其是当你想参考它里面那些成熟补全函数是怎么写的时候它就是最好的学习材料。不过我更想强调的是加载顺序。很多人喜欢把自定义补全写到~/.bashrc末尾结果发现不生效。常见原因有两个一是全局框架在.bashrc之后加载覆盖了你的定义二是你自己在.bashrc里写了一个函数名字和框架里的某个函数重了加载顺序不同导致行为不一致。我的建议是单独建一个文件来放自定义补全比如~/.bash_completion然后在.bashrc里显式加载它if [ -f ~/.bash_completion ]; then . ~/.bash_completion fi这样只要保证你这行代码放在全局框架加载之后自定义的补全规则就会成为最终生效的版本。如果你和我一样喜欢把所有配置文件都整理清楚可以把~/.bash_completion里的内容按命令拆成多个小文件放在~/.bash_completion.d/目录下用一个循环加载for f in ~/.bash_completion.d/*; do . $f done这样做的好处是后续想禁用某个补全规则直接删掉对应文件即可不用在几百行的.bashrc里大海捞针。2.3 最小可运行的补全文件骨架在动手写复杂逻辑之前先搭一个能立刻跑起来的最小框架会对理解整个体系有很大帮助。比如我想给一个虚构的命令deploy写个最简单的补全让它只能补出start stop restart status这四个子命令那文件里只需要这些内容_deploy() { local cur cur${COMP_WORDS[COMP_CWORD]} COMPREPLY( $(compgen -W start stop restart status -- $cur) ) } complete -F _deploy deploy然后执行source ~/.bash_completion再手动输入deploy st[TAB]你会看到start和stop被列出来补全。别看代码少这里面已经包含了完整的三要素补全函数_deploy、关键字生成工具compgen、规则注册命令complete。后续所有复杂的补全逻辑都是在这个骨架上做文章。3. 核心机制从 complete 到 COMPREPLY3.1 补全规范compspec到底是什么一个补全规范简单说就是“当某个命令触发补全时Bash 该怎么做”的一系列设定。它被注册到当前 Bash 进程的内存里用complete -p能看到用complete -r 命令名可以删除。规范的来源有两种一种是你手动用complete命令注册的另一种是程序在安装时自动写入补全脚本然后被 source 的。complete这个命令支持的选项非常多但实际工作里高频用的其实就这几个选项含义-F 函数名调用指定函数来生成补全候选词最灵活、最常用-W 词列表直接指定一组单词作为候选适合简单场景-A 类型按类型补全比如命令名、文件名、用户名、宿主名-C 命令在子 shell 中执行外部命令用它的输出作为候选-o 选项控制补全行为比如default、filenames、bashdefault、nosort我们可以把它理解成一个配置对象。当你按下 Tab 时Bash 首先看当前输入的命令名有没有对应的 compspec如果有就按上面的设定执行如果没有会退回到默认补全逻辑。有一个需要特别注意的细节-A类型和-F函数可以同时存在并且 Bash 会先把用-F或-C生成的候选词和-W生成的候选词合并然后过滤掉与当前前缀不匹配的内容。这个过滤行为是自动完成的也就是说函数里生成候选词时不需要自己去判断前缀匹配交给 Bash 就行。但如果你用的是-F函数函数内部的COMPREPLY仍然是可以把不该出现的词塞进去的所以除非你想玩一些特殊逻辑最好还是在函数内部用compgen提前过滤一遍。3.2 内置变量与函数约定的逐项说明-F指定的函数不能随便写Bash 会按照一套固定约定来调用它。函数被调用时不需要你传任何参数但 Bash 会设置好一系列环境变量让你读取变量作用COMP_WORDS当前命令行已经被分词后的单词数组COMP_CWORD光标所在单词在这个数组中的索引从 0 开始COMPREPLY数组函数把生成的候选词放进去Bash 就会拿来显示COMP_LINE当前命令行完整内容字符串形式COMP_POINT光标位置在当前行字符串中的字符索引COMP_TYPE补全触发类型区分单次 Tab、两次 Tab、Tab ?等情况COMP_KEY触发补全的按键通常是 9TabCOMP_WORDS和COMP_CWORD是我平时用得最多的两个变量。理解它们的一种简单方式是把整行命令想象成一个数组deploy start --force会被拆成COMP_WORDS[0]deploy、COMP_WORDS[1]start、COMP_WORDS[2]--force如果光标在--force这个单词上COMP_CWORD的值就是 2。另一个关键问题是补全函数应该如何“返回结果”答案是赋值给COMPREPLY数组而不是用echo打印。新手很容易在这个地方踩坑因为他们在函数里用echo输出了候选词结果发现按 Tab 后什么都没发生。其实这不是 Bash 没有调用函数而是函数的 stdout 没有被 Bash 当作候选词来源。Bash 只认COMPREPLY这个数组变量。这一点我想反复强调因为后台经常有人问“为什么我的补全函数不生效”。3.3 第一个补全函数逐行拆解回到上面那个_deploy函数把每一行掰开看_deploy() { local cur cur${COMP_WORDS[COMP_CWORD]} COMPREPLY( $(compgen -W start stop restart status -- $cur) ) }第一行的local cur是声明一个局部变量避免污染全局命名空间。补全函数里尽量不要用全局变量存临时值因为用户的 shell 里可能已经有同名变量。第二行取当前光标处的单词也就是用户正在输入的那一段。假如用户输入的是deploy st此时cur就是st。注意这里是在取“光标所在”的单词而不是“最后一个单词”两者的区别在处理光标位于行中间的编辑场景时非常重要。比如你已经输入了deploy stop restart然后移动光标回到stop这个位置按 Tab 补全时COMP_WORDS[COMP_CWORD]取到的就是stop而不是restart补全逻辑能感知到这一点是有意设计的。第三行分了两层。内层的compgen -W start stop restart status -- $cur表示从给定的词列表里筛选出以cur开头的候选词并输出。当cur是st时输出start和stop两行。外层通过命令替换的方式把这些输出按行填入COMPREPLY数组。这里有一个一直有人问的问题-- $cur里的双横线到底是什么意思它的作用是告诉compgen后面的内容是要被用作过滤依据的参数而不是选项。即使cur以-开头也不会被误认为是compgen自己的选项比如你想补全一个以-开头的长选项时就会遇到这种情况。4. 自定义补全实操为任意命令写补全4.1 需求与函数框架设计理论知识说够了现在进入实操环节。假设我自己有一个脚本工具叫qlog用来查询日志文件。它的使用方式大概是这样的qlog --servicenginx --levelerror --tail 100 /var/log/nginx/error.log我希望用户在敲qlog的时候Tab 键能做到下面这些事第一层能补全--service、--level、--tail这几个选项--service后面能补全服务名从配置文件里读取--level后面能补全debug info warn error这几个级别遇到不是选项的位置能自动补全文件路径。这个需求看起来复杂但拆开之后就是把前面那个最小骨架扩展成多个分支而已。先写框架_qlog() { local cur prev cur${COMP_WORDS[COMP_CWORD]} prev${COMP_WORDS[COMP_CWORD-1]} case $prev in --service*) COMPREPLY( $(compgen -W $(cat /etc/qlog/services) -- ${cur#*}) ) return 0 ;; --level*) COMPREPLY( $(compgen -W debug info warn error -- ${cur#*}) ) return 0 ;; esac COMPREPLY( $(compgen -W --service --level --tail -- $cur) ) } complete -F _qlog qlog这里出现了一个新技巧取前一个单词prev来判断用户当前处于什么位置。当用户输入qlog --level时光标所在的整个单词是--level但它的“前面一个单词”仍然是qlog。所以分支判断不能只看prev还要看当前单词是不是已经带了等号的长选项。因此我用了--service*这样的通配符模式。4.2 处理子命令与参数分支真实工具很少只有一层参数像git这类工具的子命令和参数组合非常复杂补全函数自然也得做成多级状态机。以我常用的一个内部命令svc为例它管理几个服务的启停语法是svc 服务名 start|stop|status。补全逻辑需要分两层判断_svc() { local cur prev cur${COMP_WORDS[COMP_CWORD]} prev${COMP_WORDS[COMP_CWORD-1]} if [ $COMP_CWORD -eq 1 ]; then COMPREPLY( $(compgen -W nginx mysql redis -- $cur) ) return fi if [ $COMP_CWORD -eq 2 ]; then COMPREPLY( $(compgen -W start stop status -- $cur) ) return fi COMPREPLY( $(compgen -f -- $cur) ) } complete -F _svc svc用COMP_CWORD的值来判断当前处于第几个参数位置是最直观、也最不易出错的做法。这里有个细节当用户输入svc nginx st时COMP_CWORD的值是 2所以会命中第二个分支从start stop status里过滤出st开头的候选最终补全成start。但如果你想做更复杂的规则比如某些服务只能 start 不能 stop那COMP_CWORD这一层判断就不够了你还得把前面的单词都拿来看。这时候可以这样写if [ $COMP_CWORD -ge 2 ] [ ${COMP_WORDS[1]} nginx ]; then COMPREPLY( $(compgen -W start status -- $cur) ) fi这种基于已输入参数做分支的能力正是“可编程”二字的意义所在。普通补全做不到的事情函数里想怎么判断就怎么判断。4.3 空输入、路径补全与返回值约定的边界在写自定义补全的时候有一个细节经常让人困惑当光标所在的单词是空字符串时应该怎么办。比如用户输入qlog --level后光标在等号后面此时cur的值其实是空字符串${cur#*}也是空。compgen -W debug info warn error -- 会返回全部候选词这正符合预期。那如果compgen返回空Bash 会怎样默认情况下如果补全函数设置了COMPREPLY为空数组Bash 不会做任何额外补全。但如果你在complete命令里加了-o default那一旦函数生成为空Bash 会退回执行系统默认的文件名补全。这个特性有时很方便比如我希望上面的qlog除了补全选项之外在用户输入一个尚未匹配任何选项的单词时也能补全文件路径那我就可以在注册时加上complete -F _qlog -o default qlog但要注意副作用某些时候你本来想提示“这里没有可选值”结果它还是给你补了一堆文件名体验反而奇怪。所以要不要加-o default取决于你对这个命令补全的控制程度。如果函数自己已经处理了所有分支我建议不用加让行为更可控。还有一个关于函数返回值的约定需要说明补全函数里如果没有显式return返回状态是最后一条命令的退出状态如果这个状态非零Bash 会认为补全失败可能会清空你已经设好的COMPREPLY。所以多分支函数里最好每个处理完的分支都写一个return 0。你看我上面所有分支都加了这个不是习惯问题是防坑。5. 常用场景速查与扩展技巧5.1 处理以-开头的长选项自定义补全最常遇到的一类场景就是给命令补全--service、--verbose这类带短横线的选项。单独看候选词很简单就是把选项写进compgen -W的列表里但有一个问题经常让人卡住用户输入了一个-之后cur的值变成了-compgen -W --service --level --tail -- -能不能正确过滤答案是可以因为--已经告诉compgen后面跟的是过滤条件而不是选项。这也是前面我坚持在compgen命令里加--的原因。如果你漏掉了--compgen会把-或--service误认为自己的语法选项轻则过滤结果不对重则直接报错。另外长选项通常会有形式这种选项后面跟值的情况最佳处理方式是把“选项值”当作一个整体候选词然后用${cur#}把过滤目标切到等号后面的部分。你在前面_qlog例子里已经见过这个写法仔细体会一下cur和${cur#} 的区别就知道了。5.2 类型补全命令名、用户名、服务等的快速实现有时候不需要写复杂函数直接用complete -A就能完成功能。比如给某个命令补全另一个命令的名字complete -A command runhelper这样当你输入runhelper git[TAB]时Bash 会从PATH里已有的命令中筛选出git开头的结果。类似地还有类型作用command可执行命令名builtinBash 内建命令名file文件名directory目录名user用户名hostname/etc/hosts里的主机名service系统服务名signal信号名这些内置类型的补全配合-o default、-o filenames使用在很多简单场景下可以完全替代函数。但它们的粒度比较粗一旦你需要在选项和类型之间做组合判断还是得回到-F函数。5.3 如何在自己的脚本里内置补全如果你是脚本或小工具的作者给使用者提供一份补全脚本是非常提升体验的一件事。具体做法是把补全函数和complete注册命令写在一个独立的脚本文件里让用户 source 它。我习惯把补全脚本和主程序放在同一个目录下命名方式类似qlog.completion然后在 README 里写清楚source /path/to/qlog.completion这里有一个重要的设计原则补全脚本里不应该执行主程序的业务逻辑也不应该有输出。它只是注册了补全函数函数内部可以读取配置文件、执行轻量命令但绝不能打印任何东西到 stdout。否则每次按 Tab 都会把那些输出带出来不光难看还会把COMPREPLY的内容弄乱。我还喜欢在补全脚本开头加一层保护避免重复加载if [ -n $_QLOG_COMPLETION_LOADED ]; then return fi _QLOG_COMPLETION_LOADED1这个写法在用户反复 source 同一个脚本时能避免重复定义函数和重复注册规则算是一个小而实用的习惯。6. 常见问题与排查技巧实录6.1 五个高频踩坑点我自己在学习和给其他同事排查补全问题的过程中总结了五个出现频率最高的坑。先把它们列成一张表再逐个详细说明。现象常见原因Tab 按了没反应complete未注册、函数未被 source、函数名拼错能补路径但补不出子命令函数里只写了默认分支没处理子命令位置空词时候选列表消失函数在cur为空时直接返回空数组没有输出所有候选候选词被意外拆分成多个命令替换把包含空格的文件名拆碎了补全结果排序混乱忘了加-o nosort或者依赖外部命令输出顺序第一个坑最常见也最隐蔽。很多人写完函数之后直接在当前终端里运行complete -F _foo foo看起来注册成功了但一按 Tab 还是老样子。这时候先看函数名是否和complete里写的完全一致。Bash 是区分大小写的_foo和_Foo完全是两个符号。第二个坑也很好理解就是函数只写了类似COMPREPLY( $(compgen -f -- $cur) )这样的默认分支没有判断当前处于第几个参数位置。结果是无论你在哪个位置按 Tab它都只补文件路径子命令永远不出现。第三个坑最值得展开讲。假设你只想在用户输入第一个参数时补全子命令于是你这样写if [ $COMP_CWORD -eq 1 ]; then COMPREPLY( $(compgen -W start stop -- $cur) ) else COMPREPLY() fi当用户输入mycmd [TAB]此时cur为空字符串第一个分支会执行一切正常。但当用户输入mycmd start [TAB]COMP_CWORD变成 2函数走了COMPREPLY()分支于是 Tab 没有任何输出。如果这个命令带了默认文件名补全你会发现它转而去补文件了。如果你希望“第二个参数开始只补文件”那没问题如果你希望“第二个参数什么也不补”那确实也会这样。关键是你要清楚这个分支行为别等到实际使用时才发现与预期不一致。第四个坑与文件名中的空格有关。$(compgen -f -- $cur)这种写法会用空格分词假设目录里有个文件叫my file.txt那么补全结果会被拆成my和file.txt两个候选词。解决方案是改用mapfile或直接读取进数组mapfile -t COMPREPLY (compgen -f -- $cur)用进程替换而不是命令替换可以保证带空格的文件名不被分词。我建议从第一天写补全函数开始就养成这个习惯因为迟早你会遇到带空格的文件。第五个坑候选排序。默认情况下 Bash 会对补全候选做排序但如果你用compgen配合外部命令输出时外部命令的输出顺序可能会被完全打乱。如果你希望保持自己提供的顺序可以在complete时加-o nosort但要注意这样也会影响其它补全行为使用前想清楚。6.2 补全函数调试的三步法排查补全问题我一般会按三步走。第一步确认函数本身没有语法错误。在 shell 里执行source ~/.bash_completion declare -f _qlog如果函数定义能正常打印出来说明 source 阶段没问题。如果这一步就报错通常是函数体里有什么语法问题例如case分支没写;;。第二步确认complete注册是否生效complete -p qlog如果输出为空说明qlog没有对应补全规范自然按 Tab 也不会有反应。如果输出了-F _qlog说明注册成功。第三步把函数当成普通脚本一样手动调试。补全函数本身就是一个函数你可以直接调用它。方法是在当前 shell 里先手动模拟 Bash 会设置的变量COMP_WORDS(qlog --service) COMP_CWORD1 _qlog declare -p COMPREPLY这样你就能直接看到COMPREPLY最终被设置成了什么。如果结果显示为空那问题就出在函数逻辑上进一步可以用bash -x -c source ~/.bash_completion; COMP_WORDS(qlog --); COMP_CWORD1; _qlog; declare -p COMPREPLY-x会输出函数内部每条命令的执行过程能非常直观地看到是哪一个分支出了问题。6.3 性能问题与缓存思路补全函数是在你每次按 Tab 时同步执行的所以如果函数内部跑了一些比较重的命令比如git status、find /、cat 一个大文件终端会明显卡顿。这在真实场景中是会被严重诟病的问题尤其是在大型项目目录下用户几乎每敲一个字母都会按一下 Tab。我对性能问题的处理思路是缓存。举个例子如果qlog的服务名列表来自/etc/qlog/services这个文件不太会频繁变化那我可以在函数里做一个带时间戳的缓存_QLOG_CACHE_FILE/tmp/qlog_services_cache _QLOG_CACHE_MAX_AGE60 _qlog_read_services() { if [ -f $_QLOG_CACHE_FILE ] [ $(( $(date %s) - $(stat -c %Y $_QLOG_CACHE_FILE) )) -lt $_QLOG_CACHE_MAX_AGE ]; then cat $_QLOG_CACHE_FILE else cat /etc/qlog/services $_QLOG_CACHE_FILE cat $_QLOG_CACHE_FILE fi }这样 60 秒内多次按 Tab 都只会读一次缓存文件感知上的速度会快很多。如果服务名会动态变化可以把缓存时间设短一些或者干脆不做缓存。关键是要清楚这个权衡补全函数的响应时间直接影响用户的使用幸福感这个比很多人想象的要重要得多。7. 进阶让补全更贴近实际业务7.1 根据已有参数过滤候选词当用户的命令参数越来越多时补全逻辑就不能只看当前光标处的单词了还需要参考整条命令行已经出现的参数。比如qlog --servicenginx已经指定了服务名再补--service就没有意义。为了避免这种情况可以用一个辅助函数判断某个单词是否已经出现在COMP_WORDS里_qlog_has_arg() { local word for word in ${COMP_WORDS[]}; do if [ $word $1 ]; then return 0 fi done return 1 }然后在生成候选词时过滤掉已经使用过的选项local -a options(--service --level --tail) if _qlog_has_arg --service; then # 从数组里移除 --service fiBash 数组的操作有点啰嗦但思路很简单先把候选词列表准备好再根据当前命令行的状态做减法。这样补全出来的内容会越来越“懂你”也更不容易给你提供无意义的候选。7.2 动态生成候选词静态的选项列表最多只能算“封装过的帮助文档”真正体现补全价值的是动态生成候选词。比如svc命令的可用服务列表来自另一个服务的状态可能每过一段时间都会变。那补全函数里就不应该把服务名硬编码成数组而是每次按 Tab 时从数据源读取_svc() { local cur cur${COMP_WORDS[COMP_CWORD]} if [ $COMP_CWORD -eq 1 ]; then local -a services mapfile -t services (svc list --names-only) COMPREPLY( $(compgen -W ${services[*]} -- $cur) ) return fi ... }这段代码里我用了svc list --names-only来获取服务名。这里有两个细节值得说一下。第一个细节是命令输出的安全性。若外部命令输出里混入了非法字符比如换行、引号、通配符COMPREPLY的结果可能会被误解。这不仅是补全功能的问题严重时还可能在补全函数里触发意外行为。所以动态生成候选词时最好先对输出做一次清理只保留符合规范的字符。第二个细节是性能。动态获取意味着每次 Tab 都会执行一次外部命令如果这个命令本身较重该怎么办可以把输出缓存到变量里在进程生命周期内复用但要注意进程是多长时间这里说的“进程”是指你的当前 shell。如果数据源本身更新不频繁缓存到 shell 启动后第一次按 Tab 时生成一次就够了后面直接用缓存值。7.3 让补全脚本具备自解释能力最后想聊一个不算技术但能明显提升体验的点补全脚本的用户友好性。很多补全函数虽然候选词正确但用户并不知道当前按 Tab 能补出什么。假如我输入svc [TAB][TAB]系统只把候选词排列出来放完之后没有提示用户还得逐个猜。这种情况可以在函数里根据COMP_TYPE变量做一点区分。COMP_TYPE为 9 或 63 分别代表不同触发类型如果是连按两次 Tab函数可以额外打印一条帮助信息到 stderrif [ ${COMP_TYPE:0:1} 63 ]; then echo 可用服务: nginx mysql redis 2 fi打印到 stderr 是因为 stdout 会被 Bash 吞掉而 stderr 可以直接显示在终端上。这样用户连按两次 Tab 时除了候选词列表外还能看到一行解释体验会友好不少。这个技巧在团队内部共享补全脚本时尤其好用。新人不熟悉命令结构补全功能如果还能顺带给出一点提示几乎等于把命令的 mini 文档做进了终端里。我自己在维护一套内部小工具的补全脚本时最深的一个体会是补全函数写到后期真正的复杂度其实不在 Bash 语法本身而在于你要把用户使用命令的每一种场景都想象出来。给脚本设计补全本质上和给软件设计 UI 是一样的——你需要理解用户的输入习惯、预判他们下一步想要什么然后把这些状态全部映射到函数分支里。刚开始写的时候你会觉得这个机制很繁琐但一旦把一个命令的补全打磨顺了那种“按完 Tab 直接回车全程不用看帮助文档”的流畅感是会上瘾的。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进