你好,我是吴咏炜。

学到今天,我们已经看到了很多的 Vim 脚本,只是还没有正式地把它作为一门语言来介绍。今天,我就正式向你介绍把 Vim 的功能粘合到一起的语言——Vim 脚本(Vim script)。掌握 Vim 脚本的基本语法之后,你就可以得心应手地定制你的 Vim 环境啦。

语法概要

首先,我们需要知道,通过命令行模式执行的命令就是 Vim 脚本。它是一种图灵完全的脚本语言:图灵完全,说明它的功能够强大,理论上可以完成任何计算任务;脚本语言,说明它不需要编译,可以直接通过解释方式来执行。

当然,这并没有说出 Vim 脚本的真正特点。下面,我们就通过各个不同的角度,进行了解,把 Vim 脚本这头“大象”的基本形状完整地摸出来。

在这一讲里,我们改变一下惯例,除非明确说“正常模式命令”,否则用代码方式显示的都是脚本文件里的代码或者命令行模式命令,也就是说,它们前面都不会加 :。毕竟我们这一讲介绍的全是 Vim 脚本,而不是正常模式的快捷操作。

打印输出和字符串

学习任何一门语言,我们常常以“Hello world!”开始。对于 Vim 脚本,我们不妨也这样——毕竟,打印是一种重要的调试方式,尤其对于没有专门调试器的脚本语言来说。

Vim 脚本的“Hello world!”是下面这样的:

echo ‘Hello world!’

echo 是 Vim 用来显示信息的内置命令,而 'Hello world!' 是一个字符串字面量。Vim 里也可以使用 " 来引起一个字符串。'" 的区别和在 shell 里比较相似,前者里面不允许有任何转义字符,而后者则可以使用常见的转义字符序列,如 \n\u.... 等。和 shell 不同的是,我们可以在 ' 括起的字符里把 ' 重复一次来得到这个字符本身,即 'It''s' 相当于 "It's"。不过,在这个例子里,显然还是后者更清晰了。

因为 " 还有开始注释的作用,一般情况下我推荐在 Vim 脚本里使用 ',除非你需要转义字符序列或者需要把 ' 本身放到字符串里。

字符串可以用 . 运算符来拼接。由于字典访问也可以用 . ,为了避免歧义,Bram 推荐开发者在新的 Vim 脚本中使用 .. 来拼接。但要注意,这个写法在 Vim 7 及之前的版本里不支持。我目前仍暂时使用 . 进行字符串拼接,并和其他大部分运算符一样,前后空一格。这样跟不空格的字典用法比起来,差异就相当明显了。

除了 echo,Vim 还可以用 echomsg(缩写 echom)命令,来显示一条消息。跟 echo 不同的是,这条消息不仅会显示在屏幕上,还会保留在消息历史里,可以在之后用 message 命令查看。

变量

跟大部分语言一样,Vim 脚本里有变量。变量可以用 let 命令来赋值,如下所示:

let answer = 42

然后你当然就可以使用 answer 这个变量了,如:

echo ‘The meaning of life, the universe and everything is ’ . answer

Vim 的变量可以手工取消,需要的命令是 unlet。在你写了 unlet answer 之后,你就不能再读取 answer 这个变量了。

数字

上面的赋值语句用到了整数。Vim 脚本里的数字支持整数和浮点数,在大部分平台上,两者都是 64 位的有符号数字类型,基本对应于大部分 C 语言环境里的 int64_tdouble。表示方式也和 C 里面差不多:整数可以使用 0(八进制)、0b(二进制)和 0x(十六进制)前缀;浮点数含小数点(不可省略),可选使用科学计数法。

复杂数据结构

Vim 脚本内置支持的复杂数据结构是列表(list)和字典(dictionary)。这两者都和 Python 里的对应数据结构一样。对于 C++ 的程序员来说,列表基本上就是数组 /array/vector,但大小可变,而且可以直接使用方括号表达式来初始化,如:

let primes = [2, 3, 5, 7, 11, 13, 17, 19]

然后你可以用下标访问,比如用 primes[0] 就可以得到 2

字典基本上就是 map,可以使用花括号来初始化,如:

let int_squares = {
\0: 0,
\1: 1,
\2: 4,
\3: 9,
\4: 16,
}

键会自动转换成字符串,而值会保留其类型。上面也用到了 Vim 脚本的续行——下一行的第一个非空白字符如果是 \,则表示这一行跟上一行在逻辑上是同一行,这一点和大部分其他语言是不同的。

访问字典里的某一个元素可以用方括号(跟大部分语言一样),如 int_squares['2'];或使用 .,如 int_squares.2

表达式

跟大部分编程语言类似,Vim 脚本的表达式里可以使用括号,可以调用函数(形如 func(…)),支持加(+)、减(-)、乘(*)、除(/)和取模(%),支持逻辑操作(&&||!),还支持三元条件表达式(a ? b : c)。前面我们已经学过,可以使用 [] 访问列表成员,可以使用 []. 访问字典的成员,也可以使用 ... 进行字符串拼接。==!= 运算符对所有类型都有效,而 <>= 等运算符对整数、浮点数和字符串都有效。

对于文本处理,常见的情况是我们使用 =~!~ 进行正则表达式匹配,前者表示匹配的判断,后者表示不匹配的判断。比较操作符可以后面添加 #? 来强制进行大小写敏感或不敏感的匹配(缺省受 Vim 选项 ignorecase 影响)。表达式的左侧是待匹配的字符串,右侧则是用来匹配的正则表达式。

注意表达式不是一个合法的 Vim 命令或脚本语句。在表达式的左侧,需要有 echo 这样的命令。如果你只想调用一个函数,而不需要使用其返回的结果,则应使用 call func(…) 这样的写法。

此外,我们在插入模式和命令行模式下都可以使用按键 <C-R>=(两个键)后面跟一个表达式来使用表达式的结果。在替换命令中,我们在 \= 后面也同样可以跟一个表达式,来表示使用该表达式的结果。比如,下面的命令可以在当前编辑文件的每一行前面插入行号和空格:

:%s/^/=line(’.’) . ’ ‘/

line 是 Vim 的一个内置函数,line('.') 表示“当前”行的行号,剩下部分你应该直接就明白了吧?

控制结构

作为一门完整的编程语言,标准的控制结构当然也少不了。Vim 支持标准的 ifwhilefor 语句。语法上,Vim 的写法有点老派,跟当前的主流语言不太一样,每种结构都要用一个对应的 endifendwhileendfor 来结束,如下面所示:

" 简单条件语句
if 表达式
语句
endif

" 有 else 分支的条件语句
if 表达式
语句
else
语句
endif

" 更复杂的条件语句
if 表达式
语句
elseif 表达式
语句
else
语句
endif

" 循环语句
while 表达式
语句
endwhile

whilefor 循环语句里,你可以使用 break 来退出循环,也可以使用 continue 来跳过循环体内的其他语句。作为一个程序员,理解它们肯定没有任何困难。

Vim 脚本的 for 语句跟 Python 非常相似,形式是:

for var in object
这儿可以使用 var
endfor

表示遍历 object(通常是个列表)对象里面的所有元素。

哦,跟 Python 一样,Vim 脚本也没有 switch/case 语句。

函数和匿名函数

为了方便开发,函数肯定也是少不了的。Vim 脚本里定义函数使用下面的语法:

function 函数名 (参数 1, 参数 2, …)
函数内容
endfunction

Vim 里用户自定义函数必须首字母大写(和内置函数相区别),或者使用 s: 表示该函数只在当前脚本文件有效。... 可以出现在参数列表的结尾,表示可以传递额外的无名参数。使用有名字的参数时,你需要加上 a: 前缀。要访问额外参数,则需要使用 a:1a:2 这样的形式。特殊名字 a:0 表示额外参数的数量,a:000 表示把额外参数当成列表来使用,因而 a:000[0] 就相当于 a:1

在函数里面,跟大部分语言一样,你可以使用 return 命令返回一个结果,或提前结束函数的执行。

Vim 脚本里允许匿名函数,形式是 {逗号分隔开的参数 -> 表达式}。如果你对函数式编程完全没有概念,你可以跳过匿名函数。如果你喜欢函数式编程,那你应该会很欣喜地看到,在 Vim 脚本里可以使用类似下面的语句:

echo map(range(1, 5), {idx, val -> val * val})

结果是 [1, 4, 9, 16, 25]。跟常见的 map 函数不同,Vim 会传过去两个参数,分别是列表索引和值;同时,它会修改列表的内容。不想修改的话,要把列表复制一份,如 copy(mylist)

Vim 特性

上面描述的只是一般性的编程语言语法,但 Vim 脚本如果只当作通用编程语言来用的话,就没啥意义了。我们使用 Vim 脚本,肯定是为了和 Vim 进行交互。下面我们就来仔细检查一下 Vim 脚本里的 Vim 特性。

变量的前缀

我们上面已经提到了变量的 a: 前缀。变量的前缀实际上有更多,通用编程概念上很容易理解的是下面四个:

  1. a: 表示这个变量是函数参数,只能在函数内使用。
  2. g: 表示这个变量是全局变量,可以在任何地方访问。
  3. l: 表示这个变量是本地变量,但一般这个前缀不需要使用,除非你跟系统的某个名字发生了冲突。
  4. s: 表示这个变量(或函数,它也能用在函数上)只能用于当前脚本,有点像 C 里面的 static 变量和函数,只在当前脚本文件有效,因而不会影响其他脚本文件里定义的有冲突的名字。

一般编程语言里没有的,是下面这些前缀:

  1. b: 表示这个变量是当前缓冲区的,不同的缓冲区可以有同名的 b: 变量。比如,在 Vim 里,b:current_syntax 这个变量表示当前缓冲区使用的语法名字。
  2. w: 表示这个变量是当前窗口的,不同的窗口可以有同名的 w: 变量。
  3. t: 表示这个变量是当前标签页的,不同的标签页可以有同名的 t: 变量。
  4. v: 表示这个变量是特殊的 Vim 内置变量,如 v:version 是 Vim 的版本号,等等(详见 :help v:var)。

还有下面这些前缀,可以让我们像使用变量一样使用环境变量和 Vim 选项:

  1. $ 表示紧接着的名字是一个环境变量。注意,一些环境变量是由 Vim 自己设置的,如 $VIMRUNTIME
  2. & 表示紧接着的名字是一个选项,比如, echo &filetypeset filetype? 效果相似,都能用来显示当前缓冲区的文件类型。
  3. &g: 表示访问一个选项的全局(global)值。对于有本地值的选项,如 tabstop,我们用 &tabstop 直接读到的是本地值了,要访问全局值就必须使用 &g:tabstop
  4. &l: 表示访问一个选项的本地(local)值。对于有本地值的选项,如 tabstop,我们用 &tabstop 直接读到的已经是本地值了,但修改则和 set 一样,同时修改本地值和全局值。使用 &l: 前缀可以允许我们仅修改本地值,像 setlocal 命令一样。

你可能要问,什么时候我们会需要用变量形式来访问选项,而不是使用 setsetlocal 这样的命令呢?答案是,当我们需要计算出选项值的时候。set filetype=cpp 基本上和 let &filetype = 'cpp' 等效,我们需要注意到后者里面 cpp 是个字符串,可以是通过某种方式算出来的。光使用 set,就不方便做到这样的灵活性了。

重要命令

Vim 里有很多命令,很多我们已经介绍过,或者直接在 vimrc 配置文件里使用了。这节里我们会介绍跟 Vim 脚本相关性比较大的一些命令。

首先是 execute(缩写 exe),它能用来把后面跟的字符串当成命令来解释。跟上一节使用选项还是 & 变量一样,这样做可以增加脚本的灵活性。除此之外,它还有两种常见特殊用法:

  1. 在使用键盘映射等场合、需要在一行里放多个命令时,一般可以使用 | 来分隔,但某些命令会把 | 当成命令的一部分(如 !commandnmap 和用户自定义命令),这种时候就可以使用 execute 把这样的命令包起来,如:exe '!ls' | echo 'See file list above'
  2. normal 命令把后面跟的字符直接当成正常模式命令解释,但如果其中包含有特殊字符时就不方便了。这时可以用 execute 命令,然后在 " 里可以使用转义字符。我们上面讲字符串时没说的是,按键也可以这样转义,比如,"\<C-W>" 就代表 Ctrl-W 这个按键。所以,如果你想在脚本中控制切换到下一个窗口,可以写成:exe "normal \<C-W>w"

然后,我要介绍一下 source(缩写 so)命令。它用来载入一个 Vim 脚本文件,并执行其中的内容。我们已经多次在 vimrc 配置文件中使用它来载入系统提供的 Vim 脚本了,如:

source $VIMRUNTIME/vimrc_example.vim

command! PackUpdate packadd minpac | source $MYVIMRC | call minpac#update(’’, {‘do’: ‘call minpac#status()’})

这里要注意的地方是,要允许一个文件被 source 多次,是需要一些特殊处理的。我目前给出的 vimrc 配置文件由于需要被载入多次,进行了下面的特殊处理:

  1. 清除缺省自动命令组里当前的所有命令,以免定义的自动命令被执行超过一次
  2. 使用 command! 来定义命令,避免重复命令定义的错误
  3. 使用 function! 来定义函数,避免重复函数定义的错误
  4. 没有手工设置 set nocompatible,因为该设置可能会有较多的副作用(在 defaults.vim 里会确保只设置该选项一次)

上面我已经展示了一个 command 命令的例子。这个命令允许我们自定义 Vim 的命令,并允许用户来定制自动完成之类的效果(详见 :help user-commands)。注意这个命令的定义要写在一行里,所以如果命令很长,或者中间出现会吞掉 | 的命令的话,我们就会需要用上 execute 命令了。

最后,我再说明一下我们用过的 map 系列键映射命令(详见 :help key-mapping)。这些命令的主干是 map,然后前面可以插入 nore 表示键映射的结果不再重新映射,最前面用 nvi 等字母表示适用的 Vim 模式。在绝大部分情况下,我们都会使用带 nore 这种方式,表示结果不再进行映射(排除偶尔偷懒的情况)。但是,如果我们的 map 命令的右侧用到了已有的(如某个插件带来的)键映射,我们就必须使用没有 nore 的版本了。

事件

和用户主动发起的命令相对应,Vim 里的自动处理依赖于 Vim 里的事件。迄今为止,我们已经遇到了下面这些事件:

  1. BufNewFile 事件在创建一个新文件时触发
  2. BufRead(跟 BufReadPost 相同)事件在读入一个文件后触发
  3. BufWritePost 事件在把整个缓冲区写回到文件之后触发
  4. FileType 事件在设置文件类型(filetype 选项)时被触发

Vim 里的事件还有很多(详见 :help autocmd-events-abc),我们就不一一介绍了。上面这些是我们最常用的,你应该了解它们的意义。

内置函数

Vim 里内置了很多函数(列表见 :help function-list),可以实现编程语言所需要的基本功能。我们目前用得比较多的是下面这两个:

  1. exists 用来检测某一符号(变量、函数等)是否已经存在。在 Vim 脚本里最常见的用途是检测某一变量是否已经被定义。
  2. has 用来检测某一 Vim 特性(列表见 :help feature-list)是否存在。帮助文档里已经描述得很清楚,我就不详细介绍了。你可以对照看一下我们的 vimrc 配置文件里的用法,应该就明白了。

Vim 的内置函数真的很多,我也没法一一介绍。你可以稍作浏览,了解其大概,然后在使用中根据需要查询。别忘了,在看 Vim 脚本时,在关键字上按下 K 就可以查看这个关键字的帮助,如下图所示:

在 Vim 脚本里使用 K 键查看帮助

风格指南

结束 Vim 脚本的介绍之前,我向你推荐一下 Google 出品的 Vim 脚本风格指南,Google Vimscript Style Guide。写一种语言,有一个风格指南肯定是会有帮助的,尤其对于初学者而言。

Python 集成(选学)

Vim 脚本功能再强大,也还是一种小众的编程语言。所以,Vim 里内置了跟多种脚本语言的集成,包括:

  1. Python
  2. Perl
  3. Tcl
  4. Ruby
  5. Lua
  6. MzScheme

由于 Python 的高流行度,目前 Vim 插件里常常见到对 Python 的要求——至少我还没有用过哪个插件要求有其他语言的支持。所以,在这儿我就以 Python 为例,简单介绍一下 Vim 对其他脚本语言的支持。各个语言当然有不同的特性,但支持的方式非常相似,可以说是大同小异。

这部分作为选学提供,相当于本讲内部的一个小加餐。Python 程序员一定要把这部分读完,其他同学则可以选择跳到内容小结。

Vim 很早就支持了 Python 2,Vim 的命令 python(缩写 py)就是用来执行 Python 2 的代码的。后来,Vim 也支持了 Python 3,使用 python3(缩写 py3)来执行 Python 3 的代码。鉴于 Python 的代码还是有不少是 2、3 兼容的,Vim 还有命令 pythonx(缩写 pyx)可以自动选择一个可用的 Python 版本来执行。

我在拓展 3 里给出了一段代码,用 Python 来检测当前目录是不是在一个 Git 库里。我们先用 pythonx 命令定义了一个 Python 函数,然后用 pyxeval 函数来调用该函数。这就是一种典型的使用方式:在 Python 里定义某个功能,然后在 Vim 脚本里调用该功能。这种情况下,Python 部分的代码一般不需要对 Vim 有任何特殊处理,只是简单实现某个特定功能。

下面是另一个小例子,通过 Python 来获得当前时区和协调世界时的时间差值(对于中国,应当返回 ␣+0800):

function! Timezone()
if has(‘pythonx’)
pythonx « EOF
import time

def my_timezone():
is_dst = time.daylight and time.localtime().tm_isdst
offset = time.altzone if is_dst else time.timezone
(hours, seconds) = divmod(abs(offset), 3600)
if offset > 0: hours = -hours
minutes = seconds // 60
return ‘{:+03d}{:02d}’.format(hours, minutes)
EOF
return ’ ’ . pyxeval(‘my_timezone()’)
else
return ’’
endif
endfunction

pythonx << EOFEOF,中间是 Python 代码,定义了一个叫 my_timezone 的函数,我们然后调用该函数来获得结果。对于不支持 Python 的情况,我们就直接返回一个空字符串了。

另一种更复杂的情况是,我们的主干处理逻辑就放在 Python 里。这种情况下,我们就需要在 Python 里调用 Vim 的功能了。在 Vim 调用 Python 代码时,Python 可以访问 vim 模块,其中提供多个 Vim 的专门方法和对象,如:

  1. vim.command 可以执行 Vim 的命令
  2. vim.eval 可以对表达式进行估值
  3. vim.buffers 代表 Vim 里的缓冲区
  4. vim.windows 代表当前标签页里的 Vim 窗口
  5. vim.tabpages 代表 Vim 里的标签页
  6. vim.current 代表各种 Vim 的“当前”对象(详见 :help python-current),包括行、缓冲区、窗口等

此外,在拓展 2 里我们给出的使用 pyxf 来执行一个 Python 脚本文件,也是一种在 Vim 里调用 Python 的方式(详见 :help pyxfile)。那段 clang-format 的代码,总体上也就是访问 vim.current.buffer 对象,调用外部命令格式化指定行,然后把修改的内容写回到 Vim 缓冲区里。

内容小结

好了,我们的 Vim 脚本介绍就到这里了。这一讲和大部分其他讲不同,只是给了你一个 Vim 脚本的概览,目的是让你全面了解一下 Vim 脚本,能够读懂一般的 Vim 脚本,而不是真正教会你如何去写脚本。这讲的主要知识点是:

  1. Vim 脚本的基本语法,包括变量、数字、字符串、复杂数据结构、表达式、控制结构和函数
  2. Vim 的专门特性,包括变量的前缀、脚本相关命令、Vim 里的事件和内置函数
  3. Vim 脚本风格指南
  4. Vim 对 Python 等其他脚本语言的支持

作为一门编程语言,只有在实践中不断操练,才能真正学会它的使用。如果你对 Vim 脚本有兴趣的话,我们下一讲会剖析几个 Vim 脚本来分析一下,让你有更深入的体会。

课后练习

请查看几个现有的 Vim 脚本来仔细分析一下,理解各行的意义。建议可以从我们在 vimrc 配置文件中包含的 vimrc_example.vim 开始,然后查看其中使用的 defaults.vim。别忘了,我们可以使用普通模式快捷键 gf<C-W>f 直接跳转到光标下的文件里。

如果遇到什么问题,欢迎留言和我讨论。我们下一讲再见!