13

绝大部分日常使用Linux和OS X的程序员都会选择zsh作为自己的shell环境,毕竟对比于bash,zsh的便利性/可玩性要胜出很多,同时它又能兼容bash大多数的语法。不过相对而言,zsh补全脚本要比bash补全脚本要难写。zsh提供了非常多的补全的API,而且这些API功能有不少重叠的地方,掌握起来并不容易。不像bash,你只需记住三个API(compgencompletecompopt)就能实现整个补全脚本。

这篇的任务跟上一篇的一样,需要实现一个针对pandoc的补全脚本,囊括下面三个目标:

  1. 支持主选项(General options)
  2. 支持子选项(Reader options/General writer options)
  3. 支持给选项提供参数值来源

何处安放脚本

在开始之前,需要说明下放置zsh脚本的地方,这样我们才能让接下来写的补全脚本发挥效力。
zsh在启动时会加载$fpath路径下的脚本文件。试试echo $fpath来看看这个变量的值。接下来我们可以把补全脚本放到$fpath的路径下,或者创建一个新的在$fpath路径中的目录:

  1. mkdir ~/.fpath
  2. ~/.zshrc中添加fpath=($HOME/.fpath $fpath)
  3. 重启zsh

当我们把自己写的补全脚本放好后,每次zsh一启动,就会加载它。不过总不能每次修改完脚本后,都重启一次zsh吧。如果只是单纯更新补全脚本,可以执行unfunction _pandoc && autoload -U _pandoc,zsh就会重新加载补全脚本了。(其中_pandoc是补全脚本的名字)

支持主选项

还是跟上一篇一样,先解释一个实现第一个目标的程序,带各位入门:

zsh#compdef pandoc

# 把它命名为_pandoc,保存在$fpath路径下
_arguments \
    {-f,-r}'[-f FORMAT, -r FORMAT, Specify input format]' \
    {-t,-w}'[-t FORMAT, -w FORMAT, Specify output format]' \
    {-o,--output}'[-o FILE, --output=FILE, Write output to FILE instead of stdout]' \
    {-h,--help}'[Show usage message]' \
    {-v,--version}'[Print version]' \
    '*:files:_files'

就像bash的complete,zsh也有一个相对的表示补全的API,就是compdef。zsh补全脚本以#compdef tools开头,表示该文件是针对tools的补全脚本。当然你也可以像bash一样,直接compdef _function tools来指定tools的补全函数。

zsh补全API的第一梯队是_alternative_arguments_describe_gnu_generic_regex_arguments。它们直接提供补全的来源。这些API的概述见https://github.com/zsh-users/zsh-completions/blob/master/zsh-completio...。由于_describe能做的_arguments也能做,_gnu_generic是为GNU拓展的命令参数准备的,_regex_arguments就是正则匹配版的_arguments,所以只要记住_arguments_alternative就够用了。

_arguments接受一连串的选项字符串,每个字符串代表一个选项。另外你还可以通过一些选项指定补全上的细节。举-s为例:假设你的工具支持-a -b两个选项,也支持-ab的方式来同时指定两个选项。如果没给_arguments提供-s的选项,那么zsh是不会补全出-ab,因为并不存在选项-ab。而提供了-s后,_arguments才允许你在已经输入-a的情况下,补全出-ab

选项字符串的格式是这样的:-x[description]:message:action。你也可以写做{-x,-y}[description]:message:action形式,表示-x-y是等价的写法。

  1. -x是选项的名字
  2. [description]是该选项的描述,可选
  3. message这一项我也不知道是什么意义……不过它是可选的,除非你需要指定action
  4. action用于生成复杂的补全。在这里你可以使用许多补全语法。一个常见的例子是使用辅助函数,比如_files表示补全当前路径下的文件名。详见:

    1. https://github.com/zsh-users/zsh-completions/blob/master/zsh-completio...
    2. https://github.com/zsh-users/zsh-completions/blob/master/zsh-completio...

最后一行'*:files:_files'表示,如果找不到匹配的候选词,就补全文件名。
到目前为止,实现第一阶段目标的脚本所需的知识点已经讲解完毕。

_arguments有一个限制,它要求选项的名字符合某些特殊格式,比如以-+=等字符开头(所以才叫_arguments嘛)。如果你的工具接受addremove之类的子命令,就需要用到_alternative

_alternative支持的选项字符串格式跟_arguments很像,比如

_arguments \
    {-t,-w}'[-t FORMAT, -w FORMAT, Specify output format]'

等价于

_alternative \
    'writer:writer options:((-t\:"-t FORMAT, -w FORMAT, Specify output format" -w\:"-t FORMAT, -w FORMAT, Specify output format"))'

支持子选项

所谓的支持子选项,就是在某些选项存在的情况下,增加多一些选项。所以,我们所要做的,就是检查当前输入的命令行参数中是否存在某些参数,如果存在,增加新的选项。这一步可以分解成两个步骤,第一个是检查某些参数是否存在,第二个是增加新的选项。

之前写bash补全脚本的时候,是通过遍历某个存储有当前输入的常量数组,来检查某些参数是否存在。在网上搜索一番后,我发现zsh也有同样的常量数组,就叫做words,正好是bash那个的小写哈。那么接下来就是zsh的语法知识了:

zshif [[ ${words[(i)-f]} -le ${#words} ]] || [[ ${words[(i)-r]} -le ${#words} ]]
then
    # 修改补全候选列表
fi

if [[ ${words[(i)-t]} -le ${#words} ]] || [[ ${words[(i)-w]} -le ${#words} ]]
then
    # 修改补全候选列表
fi

这里用到一点zsh特有的下标语法,相当于index()

那么下面是第二步,该怎么修改补全候选列表呢?如果直接用_arguments指定新的补全列表,会覆盖掉前面指定的补全列表。当然也可以把前面的补全列表复制一份,并添加新的选项,用它覆盖掉原来的补全列表。不过这么一来代码就不好看了。

想来zsh应该提供了对应的API的。果不其然,有一个_values可以用来干这事。_values功能跟_arguments差不多,而且它接受的选项列表是添加到原有的选项列表中的,而不是覆盖。所以最后的代码是这样的:

zshif [[ ${words[(i)-f]} -le ${#words} ]] || [[ ${words[(i)-r]} -le ${#words} ]]
then
    _values 'reader options' \
        '-R[Parse untranslatable HTML codes and LaTeX as raw]' \
        '-S[Produce typographically correct output]' \
        '--filter[Specify an executable to be used as a filter]' \
        '-p[Preserve tabs instead of converting them to spaces]'
fi

if [[ ${words[(i)-t]} -le ${#words} ]] || [[ ${words[(i)-w]} -le ${#words} ]]
then
    _values 'writer options' \
        '-s[Produce output with an appropriate  header  and  footer]' \
        '--template[Use FILE as a custom template for the generated document]' \
        '--toc[Include an automatically generated table of contents]'
fi

支持给选项提供参数值来源

最后一步是给-f-r这两个选项提供读操作支持的FORMAT参数,给-t-w这两个选项提供写操作支持的FORMAT参数。

在Bash篇的实现中,我们检查上一个词的值,如果它是-f-r,那么对当前词补全读操作的FORMAT参数。对写操作的选项也同理。
在zsh中,我们可以用一个特殊的Action:->VALUE来实现。

->VALUE这样的Action会把$state变量设置成VALUE,接下来靠一个case语句块就能根据当前陷入的状态进行对应的参数补全。

那么该如何补全FORMAT参数列表呢?这里可以用上_multi_parts
_multi_parts第一个参数是分隔符,之后接受一组候选词或一个候选词数组作为候选词列表。例如_multi_parts , a,b,c,就会生成a b c这个补全候选列表。

这里的FORMAT变量直接使用上一章的$READ_FORMAT$WRITE_FORMAT
我试了一下,如果把FORMAT变量当做字符串传递过去的话,其间的空格会被转义,导致无法分隔开来,于是就把它们改写成数组的形式。

另外,由于补全FORMAT参数时,不再需要补全选项了。所以把补全FORMAT参数的部分提到补全子选项的前面,并在补全后直接退出程序的执行。

最终完成的代码如下:

zsh#compdef pandoc

local READ_FORMAT WRITE_FORMAT
READ_FORMAT='(native json markdown markdown_strict markdown_phpextra 
markdown_github textile rst html docbook opml mediawiki haddock latex)'
WRITE_FORMAT='(native json plain markdown markdown_strict 
markdown_phpextra markdown_github rst html html5 latex beamer context 
man mediawiki textileorg textinfo opml docbook opendocument odt docx 
rtf epub epub3 fb2 asciidoc slidy slideous dzslides revealjs s5)'

_arguments \
    {-f,-r}'[-f FORMAT, -r FORMAT, Specify input format]: :->reader' \
    {-t,-w}'[-t FORMAT, -w FORMAT, Specify output format]: :->writer' \
    {-o,--output}'[-o FILE, --output=FILE, Write output to FILE instead of stdout]' \
    {-h,--help}'[Show usage message]' \
    {-v,--version}'[Print version]' \
    '*:files:_files'

case "$state" in
    reader )
        _multi_parts ' ' $READ_FORMAT && return 0
        ;;
    writer )
        _multi_parts ' ' $WRITE_FORMAT && return 0
esac

if [[ ${words[(i)-f]} -le ${#words} ]] || [[ ${words[(i)-r]} -le ${#words} ]]
then
    _values 'reader options' \
        '-R[Parse untranslatable HTML codes and LaTeX as raw]' \
        '-S[Produce typographically correct output]' \
        '--filter[Specify an executable to be used as a filter]' \
        '-p[Preserve tabs instead of converting them to spaces]'
fi

if [[ ${words[(i)-t]} -le ${#words} ]] || [[ ${words[(i)-w]} -le ${#words} ]]
then
    _values 'writer options' \
        '-s[Produce output with an appropriate  header  and  footer]' \
        '--template[Use FILE as a custom template for the generated document]' \
        '--toc[Include an automatically generated table of contents]'
fi

后话

由于zsh的补全功能实在强大,而这篇文章只是简略地讲讲如何写出一个zsh补全脚本,有许多zsh的补全机制都没能提到。所以补充一些写zsh补全脚本的资料,如果对这方面有兴趣可以继续跳坑:

  1. zsh-completions项目上的教程。这是我见过的最详尽的zsh补全脚本教程。
  2. 官方文档
  3. /usr/share/zsh/functions/Completion 也许你能从相似的命令的补全脚本中汲取灵感。

顺便一提,在查找资料的时候发现有人写了一个完整的pandoc的zsh补全脚本,感兴趣的话可以看一下:
https://github.com/srijanshetty/zsh-pandoc-completion/blob/master/_pan...


spacewander
5.6k 声望1.5k 粉丝

make building blocks that people can understand and use easily, and people will work together to solve the very largest problems.