如何编写一个 zsh 补全脚本

本文对 zsh 补全系统进行了简单的介绍,然后分析了一个完整的示例,该示例可以作为一个新的补全脚本的起点。剩余内容对示例补全脚本进行了简要的分析和介绍。

zsh completion system

zsh completion system(compsys)是 zsh 的重要组成部分,当我们在 shell 中输入命令时可以通过制表符(tab 键)进行补全。我们可以在此处找到完整的文档,也可以查看源代码。这里,_main_complete 函数非常关键,由于它比较冗长,这里我会简单进行介绍一下。

补全系统需要激活。如果你使用了 oh-my-zsh,那么它已经被激活了,否则需要在 ~/.zshrc 中增加以下代码来进行激活。

1
2
autoload -U compinit  
compinit

当我们在 shell 中输入 foobar <tab> 时,zsh 会调用针对 foobar 的补全函数。补全函数通过调用一系列 compsys 内建函数来为 zsh 提供了补全项。

补全函数可以通过直接调用 compdef 函数来手动进行注册,如:compdef <function-name> <program>。更为常规方法是将补全函数定义在一个独立的文件(也称 补全脚本)中。按照惯例,定义了补全函数的文件的命名通常是以下划线 "_" 为前缀,拼接目标程序的名称。当通过 compinit 初始化补全系统时,zsh 会查找 fpath 变量指定路径下的所有文件,并读取第一行。因此,我们只需要将补全脚本放在 fpath 变量所指定的路径下即可,当然,还需要确保文件的第一行包含了 compdef 命令,如:#compdef _foobar foobar.

fpath 变量类似于 PATH 变量,指定了一系列的路径。zsh 根据 fpath 来查找函数。我们可以通过 echo $fpath 来查找 fpath 变量所指定的值。如果想要新增一个路径,只需要重新设置 fpath 变量即可,如:fpath=($fpath <path-to-folder>)

补全脚本示例

假设我们有一个程序 hello,其调用接口如下所示:

1
2
3
hello -h | --help
hello quietly [--slient] <message>
hello loudly [--repeat=<number>] <message>

hello 有两个子命令 quietlyloudly,两者各自有着不同的参数。理想情况下,当没有提供任何命令时,我们希望补全脚本能够补全 -h--helpquietlyloudly。一旦输入 quietlyloudly,补全脚本会根据上下文提供特定的补全项。

如下所示的补全脚本实现了上述的补全功能。本文的其余部分,我将对该补全脚本进行解释,并深入探讨其他一些内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#compdef _hello hello

function _hello {
local line

_arguments -C \
"-h[Show help information]" \
"--h[Show help information]" \
"1: :(quietly loudly)" \
"*::arg:->args"

case $line[1] in
loudly)
_hello_loudly
;;
quietly)
_hello_quietly
;;
esac
}

function _hello_quietly {
_arguments \
"--silent[Dont output anything]"
}

function _hello_loudly {
_arguments \
"--repeat=[Repat the <message> any number of times]"
}

这里有几个需要注意的地方,特别是传递给 _arguments 函数的参数,以及 local 的使用。不过,让我们先来看一下补全脚本的整体结构。

整体结构

事实上,zsh 补全脚本并没有什么特别的。它只不过是一个使用了 #compdef <function> <program> 命令来将自己注册给 program 的普通 zsh 脚本,因此我们可以自由选择合适的脚本结构,不过我发现以下的结构很有帮助。

定义一个函数,并将其命名为 _<program>,提供默认的补全项。对于每一个子命令,为其定义一个 _<program>_<sub-command> 函数,提供子命令的补全项。以我的实践经验来看,这样写会非常直观。

_arguments 的使用

脚本通过调用 _arguments 函数来向 zsh 提供可能的补全项。当然,zsh 还提供了很多其他函数可以达到该目的。查看更多

在上述这个例子中,关于 _arguments 函数的使用,有两个有趣的地方。字符串参数被称为 specs,当我们第一次用时会觉得有点捉摸不透——在 zsh 中我们没有太多抽象方式,因此所有有点复杂的内容都在字符串中进行编码,这使得我们能够在更小的范围内学习这种 DSL。在上述这个例子中,specs 使用了两种形式:

  • option specs:OPT[DESCRIPTION]:MESSAGE:ACTION
  • command specs:N:MESSAGE:ACTIONN 表示第 N 个参数。

ACTION 部分还是其自己的 DSL。在官方文档中搜索 specs: overview 可以查看有全部的内容。

-C 标志位和 spec 为 "*::arg:->args" 的 ACTION 进行组合也很有趣。下面是文档中关于 -C 标志位的描述:

在这种格式中,_arguments 处理参数和选项,用参数更新状态以表示处理完毕,然后返回控制流返回给调用者(函数);然后,调用者自行生成补全项。

这里面涉及到的参数包括:

1
2
local context state state_descr line
typeset -A opt_args

我们可以认为是 _arguments 函数放回了多个值——事实上,_arguments 函数只是修改了全局变量,但由于使用了 typeset -A 和局部变量 local,因此只是在当前作用域中进行了修改。typeset-A 选项告诉 zsh 参数是一个关联数组。

因此,-C 标志位使得我们能够检查补全状态,并根据用户提供特定于上下文的补全项。在上述这个例子中,我们使用 switch 语句来匹配 line 变量输入的子命令,然后调用对应的补全函数来为子命令提供补全项。

参考

  1. Writing zsh completion scripts
  2. Completion System
  3. _main_complete