3.3 自定义命令

优质
小牛编辑
117浏览
2023-12-01

3.3 自定义命令

命令语法

定义命令与定义映射的用法其实很相似:

:command {lhs} {rhs}

只不过在使用自定义命令时,{lhs} 是直接输入到命令行中的,当你按下回车时,vim 就将 {lhs} 替换为 {rhs} 再执行。所以这在形式上与下面这个映射等效:

: nnoremap :{lhs}<CR> :{rhs}<CR>

当然,由于 :command 所支持的参数与 :map 大相径庭,并不期望你真的按这方式将 自定义命令改成映射。实际上,Vim 的帮助文档中这样描述自定义命令的语法的:

:command {cmd} {rep}

:command! 加个叹号修饰则表示重新定义命令 {cmd},否则若之前已定义 {cmd} 命令,:command 原版会报错。这是为了保护已定义不被覆盖,当你确实要覆盖时,请 加 ! 后缀。在实践中,一般都是在脚本中定义命令,建议只用 ! 即可,尤其是在开 发阶段需要调试脚本时,加上 ! 方便很多。

大部分命令的 ! 修饰版都是表示强制执行,忽略错误的意思。但上一节介绍的 :map! 的意义太奇葩,建议直接忘记 :map! 的用法。

:command 命令的退化用法是一致的:

  • :command {cmd} 列出以 {cmd} 开头的自定义命令;
  • :command 列出所有自定义命令;

Vim 的内置命令都是小写的(除了 :Next:X :Print),所以要求自定义命令 名 {cmd} 只能以大写字母开头,其后就类似 VimL 变量名的要求了。然而也不建议在 命令名中使用数字,因为这可能与数字参数混淆。

内置命令可以缩写(这与上节的缩写映射不是同个东西),在没有歧义时,只要输入命令 名的前几个字母就可以了。自定义命令 {cmd} 同样可获得此基本福利。不过内置命令 还有更好的福利,就是钦定的缩写,比如 s 是替换命令 substitute 的缩写,但它 不会与 set 发生歧义,而 set 的缩写是 se。自定义命令却无此特性,只能按基 本规则,输入尽可能多的前缀字符来达到唯一确定命令名的目的。不过缩写只建议在命令 行中使用,在脚本中尽量使用全名。

命令属性

在自定义命令时,可支持多种属性,就像 :map 的特殊参数(用 <> 括起来的)。但 是在 :command 中,以一个 - 引导一个属性(更像 shell 命令行的选项)。所有属 性必须出现在命令名 {cmd} 之前。

  • -buffer 局部命令,只能用于当前 buffer。
  • -bang 该自定义命令允许有 ! 后缀修饰。
  • -register 第一个参数允许是寄存器名。
  • -bar 该自定义命令后面允许用 | 分隔,接续另一个命令。在这种情况下,{rep} 参数内就不能有 | 了,否则会出现解析歧义。

以上这几个属性,只有是 -buffer 是常用的,并且建议能局部化时尽量局部化。其他 的属性则较少用到。-bang-register 只相当于某种特殊参数,而在同一行中用 | 使用多个语句(命令)的骚操作,能不用尽量不用。

然后,命令还支持几个复杂的属性,用 -attribute=value 表示,允许为属性指定值, 要注意的是等号前后没有空格,而将整体当作 :command 命令的一个参数。

  • 参数个数,自定义命令 {cmd} 允许多少个参数:
    • -nargs=0 这是默认行为,不指定该属性就表示命令不接受参数;
    • -nargs=1 仅接受一个参数;
    • -nargs=* 接受 0 或多个参数;
    • -nargs=? 接受 0 或 1 个参数;
    • -nargs=+ 接受 1 或多个参数。

按常规用法,多个参数用空格分隔(或制表符)。但如果只有一个参数,末尾的空格会被 认为是参数的一部分。否则若要参数中包含空格,请用 \ 转义。

  • 范围数字释义,是否允许在命令之前加上一个或两个(以逗号分隔)数字:

    • -range 允许两个地址参数或一个数字参数。不加该属性时,自定义命令默认不接 收数字或地址参数。但这只是允许,可选加或不加,也不提供默认数字或地址。
    • -range=% 允许地址参数,且默认是全 buffer,相当于 1,$
    • -range=N 允许一个数字参数,默认是 N,只能用在命令名之前。
    • -count=N-range=N 类似,不过数字参数不仅可以出现在命令名之前,也可 以出现在命令名之后(相当于第一个参数)。-count-count=0 等效。不过 注意,-range 属性与 -count 属性是互斥的,最好只用其中一个属性。
  • 特殊地址. $ % 所表示的范围(在允许 -range 时):

    • -addr=lines 这也是默认行为,取当前 buffer 文本行的范围。
    • -addr=arguments 指打开 vim 时命令行的文件名参数(其实也可以更改)。
    • -addr=buffers 指所有打开过的 buffer。
    • -addr=loaded_buffers 仅指当前加载的 buffer,在某个窗口中显示的 buffer。
    • -addr=windows 取所有窗口列表的范围,仅限当前标签页。
    • -addr=tabs 取所有标签页范围。

注意,-addr 属性必须要与 -range 联用才有意义。它要说明的是当命令的地址参数 使用 .(当前)$(最后)%(所有)是参照什么集合而言的。例如定义如下命令:

: command -range CmdA {rhs}
: command -range=% -addr=buffers CmdB {rhs}
: command -range=% -addr=tabs CmdT {rhs}

则使用命令时,:.,$CmdA 表示用命令 CmdA 处理当前 buffer 内当前行到最后一行 之间的文本行。:CmdB 表示处理所有 buffer,因为 -range 的默认范围是 % 表示 所有,而 -addr 表示所有的集合是指所有 buffer。同样,:.,$CmdT 表示处理从当 前标签页到最后一个标签页,虽然 -range=% 表示默认所有,但使用时可以自己加个特 定的地址参数呀。

命令补全

自定义命令还有个最复杂的属性,是有关补全特性的。值得单独拿出来讨论。

Vimer 初学者倾向于使用映射,可能较少用到自定义命令。但是随着对 Vim 深入使用与 理解,可能就会发觉键盘的映射资源是有限的,尤其是要有规律地组织许多容易记住的映 射会有瓶颈。这时不妨将眼光投入到自定义命令中。虽然使用命令没有映射那么快,但只 不过多加冒号与回车,就几乎有了几限的扩展可能。而且,在命令行中,不仅命令名可以 补全,命令参数也可以补全,这就大大减少了记忆负担。

-complete 属性就是用于指定命令如何补全参数的,其取值范围非常广,这里仅介绍几 种主要的补全行为,全部列表请参考 :help :command-complete

  • -complete=file 按文件(包含目录)补全,就像 :edit 命令按 <Tab> 后会补 全文件名那样。
  • -complete=option 补全选项名。
  • -complete=help 补全帮助主题。
  • -complete=shellcmd 补全外部 shell 可用的命令。
  • -complete=tag 补全标签,类似 :tag 所需的参数。
  • -complete=filetype 补全文件类型名。

总之,如果自定义命令期望它的参数是某一类意义上的参数,就可以指定 -complete 属性为相应的值,以方便输入参数。当然,如果你定义的某个命令要实现比较复杂的功能 ,vim 预设提供的补全行为都不满足要求的话,还可以指定一个函数来实现补全。

  • -complete=custom,{func}
  • -complete=customlist,{func}

这也叫做自定义补全。要注意的是,=, 前后都没有空格,在 custom,customlist,后直接接一个函数名。

-complete 属性值是 custom 时,函数要求返回一个以回车 \n 分隔的字符串 ,每一行是一个候选补全项。且 vim 会自动匹配比较光标前已经输入的部分参数前缀, 进行一些过滤。

-complte 属性值是 customlist 时,函数要求返回一个列表,每个元素是候选补 全项。但 Vim 不会自动对参数前缀过滤,可能要求用户自己在函数中过滤。

在这两种情况,补全函数的定义都是类似的,它应该接收三个参数:

  1. a:ArgLead 光标之前的部分参数前缀,
  2. a:CmdLine 整个命令行文本,
  3. a:CursorPos 当前光标在命令行的位置(按字节计,从1开始)。

当用户按下补全键(一般是<Tab>),Vim 会自动将这三个参数传给自定义补全函数。 用户在这个函数实现可利用这三个参数所提供的信息(也许不一定要用到全部),返回合 适的候选补全项。

命令实现

我们将自定义名之后的 {rep} 参数部分称为命令实现。它可以是一串简单的替换文本 ,但真正有趣的是它可用一些特殊标记来表示特殊的或动态的内容。这里的特殊标记也用 尖括号 <> 括起,所支持的有意义的标记可能依赖于前面的的命令属性。

  • <line1> <line2> 分别表示地址参数的两个数字(一般是第一行与最后一行)。含 -range 属性的命令才能接收这两个参数。
  • <count> 就是由 -count 属性提供的数字参数。
  • <bang> 支持 -bang 属性的命令,如果使用时加了 ! 修饰,则在 {rep} 中的 <bang> 标记转换为 ! 字符,否则就没任何效果。
  • <register> 或简写为 <reg>,支持 -register 属性的命令,表示可选的寄存器参 数;否则也没任何效果(加上引号 "<reg>" 才表示空字符串)。
  • <lt> 代表左尖括号 <,避免尖括号的特殊意义。比如想在 {rep} 中字面地呈现 <bang> 这几个字符串,而不是转化为 ! 字符,就可用 <lt>bang>

先举个简单的例子,我们已经知道 :map! 命令是列出某类映射。虽然上文说过应该忘 记这个命令,不过正因为它安全无害,不妨再拿来作为演示讲解。首先定义这个命令:

: command! MAP map

这个自定义命令似乎很无趣,不过用大写版的 :MAP 代替内置的 :map。请试试在命 令中输入 :MAP 并回车执行,其结果与直接使用 :map 是一样的。试试 :MAP! 呢 ?Vim 会报错,说这个命令不支持 !。那么重定义一下这个命令:

: command! -bang MAP map<bang>

现在,应该 :MAP:MAP! 命令都可以使用了,并且分别与 :map:map! 等 价。这就是 <bang> 用于命令实现参数 {rep} 中的代表意义。同时,如果你没有定 义其他以 MA 开头的命令,那么我们这个自定义命令简写成 :MA:MA! 也是可 以的。

由于这个自定义没有加 -nargs 属性,默认是不能接收参数的,所以若试图用 :MAP lhs rhs 来定义映射会失败。但是,加了参数属性后,又如何在 {rep} 中使用相应的 参数呢?这就是 <args> 标记的用途,同时这有多个变种:

  • <args> 将用户在自定义命令后输入的参数原样替换到 {rep} 中。不过若命令还有 -count-register 属性的话,前面的属性应该由 <count><reg> 捕获 ,而 <args> 只表示剩余的参数。
  • <q-args><args> 一样,先捕获所有参数,然后将所有参数用引号括起来作为 一个字符串表达式参数。如果没有参数,这将是一个空字符串(包含引号如 "")。
  • <f-args> 也与 <q-args> 一样,只不过将捕获的参数分隔成适用于函数调用时小括 号内的参数列表,所以是将每个参数分别引起,并用逗号分隔。这在 {rep} 实现中 调用一个函数中非常有的。如果没有参数,则所调用函数的小括号内也没有任何东西, 即以空参数调用。

现在继续来改造我们的自定义命令 MAP

: command! -bang -nargs=* MAP map<bang> <args>

这样,:MAP:MAP! 可以继续用,而且也可以用它来定义映射了,例如:

: MAP <buffer> x dd

这里,用自己的 :MAP 来定义一个映射,将 x 删除一个字符的功能改为删一行 。不过由于只为试验,所以加 <buffer> 定义成局部映射(注意区别,定义局部命令用 -buffer 语法)。

由于我们在定义 MAP 时允许它接收任意个参数 -nargs=*。所以在 :MAP <buffer> x dd 这个使用场合下,:MAP 的所有参数 <buffer> x dd 替换在定义 MAP<args> 的位置上,也就相当于执行 :map <buffer> x dd。可以试下执行完,再按 x 是不是实现了预期效果,同时也可以用 :MAP x:map x 查看下将 x 定义 成啥样的映射了。

在这个示例中,如果将定义 MAP 时的 <args> 改成 <q-args><f-args> 的 话,结果就不正确了,不能仿拟 :map 命令了。在实现复杂命令时,后两个参数变种标 记才更有用,作为函数调用的参数。不过这较为复杂,留待下一小再论。这里先探讨一下 <register> 参数的使用,假设继续为 MAP 命令添加这个属性:

: command! -bang -register -nargs=* MAP <register>map<bang> <args>

先将原来定义的 x 映射删除::unmap <buffer> x。然后再用新的 :MAP 命令定义 x 映射,不过在参数 <buffer> 前额外加个参数 n

: MAP n <buffer> x dd

结果是相当于只定义了普通模式下的映射 :nmap <buffer> x dd。你可以用 :map x 查看一下 x 的映射定义确认。并且对比一下 :MAP <buffer> X dd 不加 n 的用法 。

结论就是 <register> 不过是捕获了第一个参数,<args> 捕获其他参数。而 MAP 的定义 <register>map<bang> <args> 表明是将第一个参数直接拼在 map 之前作为 映射命令的模式前缀限定,而将其他参数用空格分开后作为 :map 命令的参数了。

这样看来,<register> 似乎很名符实呀。那么我们再尝试下将 un 作为 :MAP 的 第一个参数,看它会不会变成 :unmap 用于删除映射:

: MAP un <buffer> x
: MAP un <buffer> X

然而,这次 vim 报错了,提示 umap n <buffer> x 不是一个命令。由些可见, <register> 只捕获的第一个字母 u,然后将剩余的东西都当成 <args> 了。因为 寄存器名都是一个字母啊。

vim 有些内置命令如 :del :yank :put 支持后面接一个寄存器名(比如 a), 表示对相应的寄存器操作,相当于普通模式的命令 "ad "ay "ap。自定义命令就可 用 <regsiter> 实现类似的特性,使得自定义命令能像内置命令一样使用。只不过, <register> 只能捕获参数中的第一个字母,把它当成是寄成器名,传给 {rep} 实现 部分,却无法控制 {rep} 如何处理这个字母。因为 :map 命令的模式前缀限定恰好 也只是一个字母,所以我们的 :MAP 就可以用 <register> 进行伪装了。你可以自行 尝试 :MAP i :MAP c 等用法应该也是有效的。

上一节也提前,使用映射命令,尽量使用更安全的 :noremap,所以再重定义命令:

: command! -bang -register -nargs=* MAP <register>noremap<bang> <args>

要测试这个命令是否有效,可定义如下映射:

: MAP n <buffer> x xx

再按 x 看看是否能正确只删除两个字符,还是会发生无尽循环故障(如果有这问题, 按 <Ctrl-c> 中断即可)。

再次提醒:这里讨论不断“优化” :MAP 命令,只为说明 :command 自定义命令的用法 与机制。正常使用 vim 下,应该没必要定义这么个命令呀。

自定义命令调用函数

除了很简单的命令,可以调用 vim 既有的内置命令(可能进行必要的包装修饰)外,大 多实用的自定义命令,都是通过调用函数来实现命令要求的功能。这并仅可以实现很复杂 的功能,也容易扩展,还使得用法简明易记,因为它一般如下的形式结构之一:

:command! {cmd} call WorkFunc(<f-args>)
:command! {cmd} call WorkFunc(<q-args>)

当使用自定义命令 {cmd} 时,它后面的命令行参数就会传入实际工作的函数 WorkFunc() 中。<f-args> 按空格分隔多个参数,然后分别引为字符串参数传入,如 果要在参数中包含空格,要用 \ 转义,要传入 \ 就要用两个反斜杠即 \\。而 <q-args> 则简单粗暴,将 {cmd} 的所有参数,也就是其后跟着的所有内容当空一个 字符串参数传入。在 {cmd} 之后没有任何参数时,<q-args> 也至少传入一个空字符 串参数(WorkFunc("")),但 <f-args> 就不传入任何参数了(WorkFunc())。

注意:传入 WorkFunc() 的参数必定是字符串类型,但由于 VimL 弱类型与自动转换, 如果一个参数像数字,那么在函数体内将它当作数字处理也完全没有问题。

<f-args> 方式调用函数更为常见。<q-args> 可能只用于比较特殊的需要,然后 要自己在函数体内解析字符串参数。另外,<f-args> 只适用于函数调用参数,用在其 他地方的意义不明显,且易出错。而 <q-args> 用于函数参数之外也可能是有意义的。 本小节暂时不讨论 <q-args> 的使用。

使用 range

首先我们需要一个工作函数。不妨复用在 2.4 节讲述函数时使用的给文本行编号的示例 函数吧,取那个支持 range 特性的版本,并改名为 NumberLine 重贴于下:

" File: ~/.vim/vimllearn/fcommand.vim
function! NumberLine() abort range
    for l:line in range(a:firstline, a:lastline)
        let l:sLine = getline(l:line)
        let l:sLine = l:line . ' ' . l:sLine
        call setline(l:line, l:sLine)
    endfor
endfunction

然后定义一个命令也叫 NumberLine,用以调用该函数,命令名与函数不需要相同,只 是懒得另起名字,同时也想说明,命令与函数重名完全没问题,因为它们是完全不是同类 概念:

: command! -range=% NumberLine <line1>,<line2>call NumberLine()

注意到 NumberLine() 函数不支持显式参数,但可接收隐式的地址参数。而命令 :NumberLine 正好定义为支持 -range 属性,这就要将捕获的地址参数 <line1>,<line2> 放在 call 之前,由 call 把地址参数传给 NumberLine() 函 数的 a:firstline:lastline

现在我们就可以来试用这个自定义命令了。如果直接在命令行输入 :NumberLine 回车 执行,它会对当前 buffer 的所有文本行编号。因为 -buffer 属性的默认值 % 就表 示所有行,相当于 1,$。如果我们按行可视模式 V 选择几行,再按 :NumberLine ,命令行中实际输入的是 :'<,'>NumberLine ,它就只会对选择的行进行编号。

使用 count

接着讨论下与 -range 相似但互斥的 -count 属性。<count> 只有一个数字参数, 即可放在命令之前,也可以放在命令之后(甚至对是否有空格分隔不敏感)。很多 vim 内置命令的数字表示重复次数,不过在自定义命令中,<count> 只负责捕获传递这个数 字参数,并无法控制后续命令如何使用这个数字,就如 <register> 一样。

我们另外写个函数,用于对当前行及后面若干行进行相对编号,即当前行号是 0,下一 行是 1 等(类似 :set relativenumber)。

function! NumberRelate(count) abort
    let l:cursor = line('.')
    let l:eof = line('$')
    for l:count in range(0, a:count)
        let l:line  = l:cursor + l:count
        if l:line > l:eof
            break
        endif
        let l:sLine = getline(l:line)
        let l:sLine = l:count . ' ' . l:sLine
        call setline(l:line, l:sLine)
    endfor
endfunction

command! -count NumberRelate call NumberRelate(<count>)

同时也定义一个相应的命令。试试效果?如果直接运行 :NumberRelate ,由于 -count 的默认值是 0,所以只对当前行编号为 0。如果对选区运行 :'<,'>NumberRalate,给命令提供了两个地址参数?但该命令只接收一个数字参数啊, vim 只会将后面那个地址参数 '> 当作数字参数 <count> 传给函数 NumberRelate() 的参数。同时也可以手动输入数字如 :3NumberRelate:NumberRelate3 都会对当前行及后面3行编号。其中 NumberRelate3 的写法可能会 有歧义,如果恰好还有个自定义命名叫叫 NumberRelate3。所以最好用 :NumberRelate 3 来调用。也正是这个原因,不建议在命令名中混入数字。

至于 Vim 为什么允许命令与数字参数粘在一起使用,主要是因为要快捷输入。很多最常 用的命令都是有单字母缩写的,而与数字参数的组合使用又极频繁。在这种情况情况下多 敲一个空格的性价比太低了(我的命令才一个字母呢),所以就把空格吃了吧。

这个示例也说明,自定义命令调用函数时,参数不一定要用 <f-args><q-args> ,混入其他任何特殊标记也是可以的,只要展开替换后符号函数调用语法即可。再比如, call WorkFunc(<bang>) 是非法的,因为展开是 call WorkFunc(!),但 call WorkFunc("<bang>") 是合法的,因为展开后是 call WorkFunc("!")。而 <count> (其实也包括 <line1> <line2>)可直接放入函数括号内,是因为它们会展开成一个 数字。

使用 f-args

前面两例所用的函数都不接收参数,如果函数要求参数,就用 <f-args> 传入吧。假设 更改为文本行编号的需求,在数字编号后还允许加个后缀字符,像 1. 1) 之类的, 同时可以定制分隔编号与原文本之间的空格数量。我们重写 NumberLine 函数,让它接 收两个参数:

function! NumberLine(postfix, count) abort range
    let l:sep = repeat(' ', a:count) " 生成含 count 个空格的字符串
    for l:line in range(a:firstline, a:lastline)
        let l:sLine = getline(l:line)
        let l:sLine = l:line . a:postfix . l:sep . l:sLine
        call setline(l:line, l:sLine)
    endfor
endfunction

command! -range=% -nargs=+ NumberLine <line1>,<line2>call NumberLine(<f-args>)

然后也重定义命令 :NumberLine,为其增加 -nargs 属性,然后用 <f-args> 传给 函数调用。注意虽然可以用 -nargs=1 限定允许一个参数,但不支持 -nargs=2 限定 恰好两个参数,只能用不定数量的 -nargs=*-nargs=+。此时若只用 :NumberLine 命令执行,会报错说参数太少,加上两个命令行参数后如 :NumberLine ) 4 就能正常工作了,这表示编号样式为 1) 然后接 4 个空格。

注意到 NumberLine() 函数虽然也有个 count 参数。但与上例不同,不能用 -count 属性与 <count> 参数。首先是因为 -count-range 属性只能用一个 ,不能共存。其次这里的 count 参数与大多 vim 内置命令对数字参数的解释很有些不 同,只是恰好用了这个形参名而已。因此不要滥用 <count> 参数,能直接用 <f-args> 是最简洁明了的。

如果工作函数 WorkFunc() 没有 range 属性,不处理地址范围的话,那么自定义命 令时,也不要加 -range 属性,而后面的调用函数写法也更加简单。

另外,如果工作函数是脚本作用域的函数,如 s:WorkFunc(),则在 {rep} 部分中调 用写成 <SID>WorkFunc(),高版本的 vim 也可以直接用 s:WorkFunc()。不过上节的 映射命令 :map,却只能用 <SID> 而不能用 s:

*微命令实例

本节内容所用的命令示例,主要为阐述概念,也许并无实用性。我在大量使用映射后,也 开始对命令有所偏爱了。为了使命令输入尽可能方便,我将常用命令也定义很短的几个大 写字母,并称之为“微命令”。实现脚本放在了 github 上,有兴趣的可以参考,传送门在 此:https://github.com/lymslive/autoplug/tree/master/autoload/microcmd

如果命令名较长,输入不便时,也可以继续使用映射来触发命令,甚至可以将最常用的命 令参数也一并包含在映射中。