坑边闲话:我使用 Vim 的 logo 作为头像已经接近十年了,这表达了我对 Vim 编辑器的热爱,同时也表达了我对设计优秀的工具的敬佩。但是我一直不敢做与 Vim 相关的教程,因为 Vim 的配置太繁琐了,而且我对有些功能也只是一知半解,并不能称为 Vim 专家。正逢我把所有配置迁移到 Lua,就借此机会详细说明我的 Vim 配置,以及我对 Vim 的个人理解。

后续会出一些更加详细的配置教程,但本文专注于 Vim 的基础概念与操作。

1. NeoVim 还是 Vim·

这个问题很关键。我的答案是 NeoVim. 尽管现在 Vim 9.0 已经发布,有些棘手的问题也解决了七七八八,但是我已经用 NeoVim 很多年了,再换回去比较可惜。

我对新手的建议也是 NeoVim. 主要原因是网络上参考资料比较多,解决问题的成本较低。

  • 无需担心软件是否是“名门正派”的问题,NeoVim 本身就是 Vim 的一个分支 (fork),所以他们是同根的软件,NeoVim 就是根正苗红的 Vim.
  • 无需担心没有大版本发布的问题,目前 NeoVim 早就很成熟了,版本一直维持在 0.x 只是一个习惯,并不代表现在的不稳定。版本号和稳定性没有太大关系。

如果你选择了 Vim,那么官方会提供安装包。但是如果你选择 NeoVim,在安装上可能会遇到一些困惑,其实原理非常简单。编辑器是一个广泛被使用的软件,因此维护所有平台的安装格式比较费劲,所以 NeoVim 提供了一个基于 AppImage 技术的分发格式。直接下载 AppImage 并以此为可执行文件,便可运行 nvim (NeoVim 的启动执行命令)。

Vim or VSCode

编辑器作为程序员最重要的产出工具,不同流派的信徒之间往往产生圣战一般的纷争。不得不承认,在延迟 100ms 以内,Vim 的体验很良好。然而,Vim 并非通吃所有场景。VSCode 在很多场景下要绝对胜过 Vim. 比如在高延迟的编辑场景,囿于 SSH 会话的限制,Vim 非常卡顿。笔者从英国通过 IP 直连的方式登录到北京的服务器,发现 SSH 体验很差,ping 测试显示 RTT 高达 330ms. SSH 会话要求字符和光标要在两地进行实时同步,因此高延迟就是其致命杀手。然而,VSCode 的 Remote Dev 套件却能充分利用本地编辑器 Cache 进行体验优化。我猜测当前 VSCode 打开的文件会在本地形成一份缓存,本地编辑操作全部落在缓存上,然后另一个线程会异步地将本地缓存和远程文件进行同步。如此一来,VSCode 的编辑体验就要胜过 Vim 很多。

此外,VSCode 的插件市场非常广阔,而且也提供了非常好用的 Snippet 模块。目前除非有特殊需求,我一律使用 VSCode 进行文件编辑

2. Vim 基础·

这一部分内容真的太复杂了,而且需要长时间的训练。如果不能用 Vim 做开发,那么说明你的 Vim 技能还没有学到家。

提醒

如果你在使用 Vim 的过程中经常想换回 VSCode 或其他编辑器,那一定要克制,反而要想一想如何用 Vim 实现类似的功能。大部分情况下 Vim 都有解决方案,只是用户还没找到。

我所理解的 Vim 的学习策略是:

  1. 先了解 Vim 的基本知识,比如知晓 T/tF/f, N/n 这种大小写字母 pair 指的是方向相反的操作,小写向后,大写向前。
  2. 理解 ^, $ 等特殊字符指代的意义。
  3. 知道 Vim 的快捷键语法,如 ciw 这种 动词 + 介词 + 名词 结构。
  4. 知道 <leader> 键是什么。在 Vim 配置中,必然会用到大量的快捷键,而 Vim 本身也支持巨量的快捷键,因此如何防止快捷键发生冲突就尤为关键,这在配置 Vim 的过程中非常令人痛苦。有时候几乎所有的键都被分配了,这时候该怎么办呢?这时可以使用 <leader> 键再进行一遍映射。比如 inormal 模式下可用来进入插入模式,如果我想复用一遍 i 键,可以在输入 i 之前加入一个修饰键,这样 i 就有了新的含义。这个自定义的修饰键就是 <leader>. Vim 默认的 <leader>\,但是反斜线很难触发,主要是位置太尴尬,所以很多人把 <leader> 设置为空格键。

预判你的预判

你可能会问,CtrlShiftAlt 这三个键及其组合还不够修饰吗?其实不然,在终端环境里,有些键是不能使用的,因此自定义一个 <leader> 非常重要。此外,<leader> 的引入是防止用户自定义的快捷键与 Vim 内置的快捷键起冲突。使用 <leader> 修饰之后,你的快捷键作用空间就是你自己的私人空间,与 Vim 不会冲突。

2.1 跳转方式·

Vim 的跳转方式可以认为是非常全面的,这得益于 Normal 模式的键位丰富性。像 VSCode 这种不区分 Insert 模式、Normal 模式的编辑器,可以调用的跳转快捷键很有限,因此跳转能力偏弱。许多在 VSCode 里添加 Vim 插件的人无非也是看重了 Vim 的跳转能力。

这里并非是贬低 VSCode,毕竟 VSCode 支持鼠标,直接用鼠标跳转可谓是降维打击。

不过好在 Vim 也是支持鼠标的。

抛开 Vim 不谈,我们应该认为,一个良好的编辑器要具有以下跳转能力:

  • 跳转到逻辑行首、逻辑行尾
  • 跳转到显示行首、显示行尾 (逻辑行、显示行分别指的是按照 \n 换行和按照屏幕显示长度限制换行)
  • 往前后方向分别以字符为单位进行移动
  • 往前后方向分别以“单词”为单位进行移动(单词即以空白符、连字符分割的语素)
  • 往上下方向以自然段为单位进行跳转
  • 在编程中,以单双引号、{}[]() 为分块,进行块首、尾跳转

Vim 的模式:

  • n: 普通模式,Normal Mode
  • i: 插入模式,Insert Mode
  • v: 字符可视模式,Visual Mode
  • x: 可视模式,一般是用于块模式,也叫做 Visual Block Mode
  • s: 选择模式,Select Mode
  • o: 操作符等待模式,Operator-Pending Mode
  • c: 命令行模式,Command Mode

2.2 Vim 寄存器调用·

Register 这个概念在原生中文语境里缺乏对应单词,寄存可能是比较好的一个对应动词,当然,“暂存”或许也是可行的。它实际的含义就是把某个值存储在某个位置,过一段时间还会回来取用。因此寄存器和程序语言中的变量具有类似的含义,只是变量更为抽象一些。

2.2.1 常见的寄存器·

Vim 定义了许多寄存器,比如在 Normal 模式输入 dd 命令将会删除当前行,然而实际上 Vim 只是将这一行移除并寄存到 " 寄存器里。比较常见的寄存器名字都是用单个符号表示,比如 0-9 分别代表了十个寄存器,字母 a-zA-Z 也代表 52 个不同的通用寄存器。有些寄存器是通用的,有些寄存器有特殊用途;有些寄存器是可读写的,有些是只读的。常见的寄存器列举如下:

  • 未命名寄存器:",仅用来存储上次复制、删除的文本。
  • 数字寄存器:0-9,文本复制、删除的历史记录。会依次更新,但是连续赋值同一个文本、段落并不会覆盖历史记录。
  • 行内删除寄存器:-,删除少于一行的寄存器。
  • 命名寄存器:a-zA-Z,存放普通文本
  • 只读寄存器
    • %: 当前文件名
    • .: 最近插入的文本
    • :: 最近执行的命令行
  • 轮换缓冲区寄存器:#,交替文件的名字
  • 表达式寄存器:=,返回表达式结果

Vim 的寄存器可以在 Normal 模式下引用,比如输入 "ap 会执行如下操作:

  • "a: Vim 知道你要将接下来的 IO 都定向到寄存器 a
  • p: 读取寄存器到光标所在位置。

同理:

  • "ayy 是将当前行复制(yank)到寄存器 a,同时,yy 命令也会将当前行复制到寄存器 " 里。
  • "bdiw 是将当前词(以空格分隔)剪切到寄存器 b

所以,Vim 的寄存器组相当于 Windows 上的 ditto 软件。ditto 能扩展系统剪贴板,使得系统有了带历史记录的剪切板。简单对比一下两者的优劣:

  • ditto 能实现几乎无限的暂存操作,而且能存储照片、格式文本等,但是 ditto 的自动化不如 Vim;
  • Vim 寄存器能实现高度的自动化,但是寄存数量有限,而且只能存储纯文本。

现代程序员当然是“我全都要”!

2.2.2 Insert 模式调用寄存器·

若只能在 Normal 使用寄存器,则其应用范围有限。那么我们如何在 Insert 下使用呢?答案就是 <C-r> 快捷键。

在 Vim 配置中,<C-r> 指的是 Ctrl r.

虽然键盘上的字母都是大写,但为了区分有无 Shift 修饰,我们用小写字母表示仅按下字母键而不加 Shift 修饰,用大写字母表示按下 Shift 和对应的字母。如 C-R 就是 Ctrl Shift r

通过 <C-r>,系统会知道你要输出某个寄存器里的值,随后直接输入寄存器的名字即可。比如 <C-r>b 就是在 Insert 模式下打出寄存器 b 的值。

此外,在输入命令时,也可以通过 <C-r> 调用寄存器。

最佳实践

在搜索时,我们经常要把正文中的较长的一个词替换为另一个词,此时可以使用寄存器进行暂存,在输入替换命令时,直接从寄存器里取用。查看当下寄存器内容的命令是 :registers.

2.3 Vim 窗口管理与 Tab 管理·

许多对 Vim 不熟悉的人会以为 Vim 一次智能编辑同一个文件。这是一个很大的误区。此外,很多人没有进行高级命令行编程的体验,因此会误以为终端编程很难实现 GUI 编程里的常见功能。这其实亦非也。

Vim 的窗口管理非常完备,至少 VSCode 能做的,Vim 全都能做到,正确配置之后使用起来并不麻烦。

Vim 有着完善的窗口和 Tab 概念。在这里又遇到了一个很难翻译成中文的英文单词 Tab,我们姑且翻译成桌面。如果你对 macOS 很熟悉,那你肯定就明白 Vim 里 Tab 和 Window 之间的关系了。

  • Tab 是一个桌面,这是一个很大的概念,因为桌面上可以充满各种 Window
  • Window 是一个小的概念

一般来说我个人很少使用多个 Tab 编辑文件,所以这里简单介绍与 Tab 相关的命令,此后就略过了。

  • :tabedit<CR>
    • 新建一个 Tab
  • :tab split<CR>
    • 在一个新的标签页中打开当前的缓冲区内容,基本上是当前查看的文件的另一个视图。
  • :-tabnext<CR>
    • 导航到当前标签页左边的标签页。与 :tabprev:tabprevious 命令相同。
  • :+tabnext<CR>
    • 导航到当前标签页右边的标签页。与 :tabnext 命令相同。
  • :-tabmove<CR>
    • 将当前标签页移动到它左边的位置,即与前一个标签页交换位置。
  • :+tabmove<CR>
    • 将当前标签页移动到它右边的位置,即与后一个标签页交换位置。

与 Tab 操作相关的是 Window 操作,即在一个 Tab 里操作多个 Window,让它们显示同一个文件的不同部分或者不同的文件。接下来介绍相关内容。

2.3.1 切分窗口·

  • 水平切分:这是默认的,因此无需声明是“水平”
    • 命令::sp filename
    • 快捷键:<C-w>s
  • 垂直切分
    • 命令::vsp filename
    • 快捷键:<C-w>v

2.3.2 关闭窗口·

  • 关闭当前窗口
    • 命令::clo 或者 :quit
    • 快捷键:<C-w>c
  • 关闭其他窗口
    • 命令::on
    • 快捷键:<C-w>o

2.3.3 切换窗口·

  • 窗口之间循环切换
    • 快捷键:<C-w>w
  • 二维窗口上下左右切换
    • 快捷键:<C-w>h 切换到左窗口,hijk 中的其他方向以此类推。

将窗口操作熟练记在心间并形成肌肉记忆,可以极大提升编辑体验。

Vim 的多窗口操作,颇有 Tmux 的味道。

2.4 Vim 编辑设置·

在编辑里有些设置需要用户自行设置,而且是每个编辑器都会有类似的选项。比如:

  • 编辑器界面应该以几个空格宽度显示一个制表符
  • 在插入模式(如果有的话)下,当用户按下 <Tab>,应该
    • 插入制表符,还是
    • 若干个空格(数量一般为 248

在 Vim 中,有以下变量控制着上述行为:

  • expandtab,缩写 et:顾名思义,该选项会将制表符“扩展”为空格。
    • 当该值被设置时,按下 <Tab> 会插入空格,而非制表符,具体插入几个空格由 tabstop 或者 softtabstop 控制;
    • 当该值没有被设置时,按下 <Tab> 会插入一个真正的制表符。
  • tabstop,缩写 ts:用于定义制表符在屏幕上显示的宽度。注意,这个值只控制显示方式,不影响文本存储的内容。
    • 该值很关键,因为有些程序语言以缩进作为语义,所以设置错误的缩进数量,可能导致程序出错。
    • 如果 expandtab 被设置,则按下 <Tab> 会输入 tabstop 个空格。
  • softtabstop,简称 sts:控制按下 <Tab> 时输入或者按下 <Backspace> 时删除的空格数量。
    • 该值与 tabstop 不冲突。设置 <softtabstop> 不影响文件中既有的 <Tab> 的显示宽度,只影响新按下 <Tab> 时软件的行为。
  • shiftwidth,简称 sw:定义当执行缩进或者反缩进时应该使用多少个空格宽度。
    • 自动缩进:如换行时与上一行对齐
    • 手动缩进:使用 >><< 命令进行缩进
    • 自动格式化

3. Vim 插件·

Vim 的插件系统有很多,但是我们得首先明白,什么是 Vim 的一个插件。

Vim 插件一般是可以通过与 Vim 的 API 进行关联,然后提供某种功能的工具。现在的 Vim 插件可以通过 Lua 编写,也可以通过 VimScript 编写。但是 VimScript 语法实在是太过阴间,用 Lua 配置是个好的习惯。其实,无论是 VimScript 还是 Lua,它们都是使用 Vim 提供的接口进行 Vim 调用,所以原理上是没有任何区别的,唯一的区别就是语言能力,因为 Lua 是一门实实在在的图灵完备的语言,而且语法设计比较优秀(至少能学会),所以现在倾向于使用 Lua.

那么 Vim 的插件管理器是如何定位插件的呢?一般我们认为,插件管理器或者包管理器都会有一个“应用市场”,开发者需要和管理器的维护人员进行交互,从而提交上架自己的软件。但是 Vim 是一个开放的自由软件,因此没有人维护一个中心化的插件市场。Vim 的插件一般都是遵循某种规范的 GitHub 仓库,比如 John 开发了一个 C++ 高亮的插件,那么 John 就可以创建一个名为 cpphlt.nvim 的 GitHub 仓库,然后设置为 public 状态。这时候你在某个插件管理器(比如 Lazy.nvim)里输入一些命令(:Lazy install cpphlt.nvim)就可以通过 git clone 的方式进行拉取。因为 Git 自带 pull 功能,因而可以借此实现插件更新。

本质上讲,Vim 插件管理器就是一个 git 魔改版,只是可以将指定的插件下载到统一的目录进行管理。

先决安装:

因为这是我个人的配置,所以里面有很多依赖的 Debian 系统包都需要安装。

1
sudo apt install composer luarocks ruby default-jdk ripgrep bat golang

随后,安装 pynvim Python 包。NeoVim 提供了 msgpack-rpc 类型的 API,pynvim 利用并封装该 API 为 Python 库,使得普通 Python 程序员能够方便地与 NeoVim 通信并控制 NeoVim. 简而言之,有了 pynvim,就可以使用 Python 语言为 NeoVim 开发插件。

1
sudo pip install -U pynvim

Go 语言的 Language Server 名为 gopls,它是用 Go 实现的。Go 本身自带包管理器,所以可以先通过 Go 安装 gopls.

1
go install golang.org/x/tools/gopls@latest

注意

Language Server 可以通过 Mason 插件进行管理,所以这里可以先跳过 gopls 的安装。后面在 Vim 里通过 MasonInstall gopls 也可以实现类似的效果。

3.1 LSP 类插件·

LSP (Language Server Protocol,语言服务器协议) 是现代编辑器、IDE 依赖的底层技术。一个高级的代码编辑器需要提供

  • 语法高亮
  • 代码提示
  • 自动补全,如根据上下文推断将要输入的变量名
  • 自动跳转,如跳转到定义、跳转到引用等

等功能。LSP 的主要优点是

  1. 解耦:编辑器和语言的实现是分离的,为编辑器添加语言支持只需要实现一个 LSP 即可,无需改动编辑器的核心代码。
  2. 可重用:一旦为某种语言实现了 LSP,则所有支持 LSP 的编辑器均可使用之。
  3. 一致性:不同编辑器可以为同一种语言提供相同的功能、用户体验。

注意

这里的 Server 一般指的是编辑器所在机器上的一个进程,并非是一个对外提供服务的机器。

Vim 能提供良好的代码编辑体验,主要得益于 LSP.

3.1.1 不得不提的 nvim.treesitter·

tree-sitter 是一个强大的多语言解析系统。语言的编译器设计分为前端和后端两部分,前端将高级语言转换到抽象语法树、中间代码 IR,做完了可有可无的 IR 优化,就需要后端程序将 IR 映射到特定的机器代码。tree-sitter 可以为多种语言提供前端解析功能,以往要实现这种功能需要安装所需语言的编译器,存储代价很大且有很多功能不必要。

tree-sitter 提供的语言 parser 能将高级语言中的 token 对应到具体的语义,因此可以实现更精准、更深刻的语法高亮。许多玩具级别的语法高亮仅仅是用关键字匹配、正则表达式做了代码着色,实际效果惨不忍睹。

tree-sitter 不是 LSP,这一点非常关键。LSP 的功能非常强大,但因为需要编辑器和 LSP 通过 JSON-RPC 通信所以性能开销颇大。LSP 能提供基于语义的代码着色,比如分析逻辑错误、类型不匹配、未定义的变量警告等,而 tree-sitter 做不到这一点。然而我认为 tree-sitter 已经够用了,而且 tree-sitter 的效率更高,无疑更令人青睐。

1
int foo = bar()

对于上述赋值,tree-sitter 可以精准识别到 foo 是整型,bar 是一个函数。这对于代码着色已经足够了。至于 bar() 函数是否已经定义,tree-sitter 表示无能为力。这时候只能靠 LSP 出马。尽管 LSP 和 tree-sitter 功能有重复,但是 LSP 启动速度可能会很慢(十几秒甚至几十秒)。

最好的做法当然是“我全都要”!

3.1.2 masonlspconfig·

前面提到,由微软研发的 LSP 能将语言特性和编辑器实现解耦。因此,不同开发者可以提供不同的 Language Server 实现。既然如此,NeoVim 需要两个功能:

  1. 内置 LSP 客户端,与第三方的 Language Server 交互;
  2. 支持管理不同的 Language Server,这类似于包管理器。

上述第一点已经满足,NeoVim 本身就支持 Language Server Client,至于第二点,笔者采用 Mason 作为安装与管理 LSP Server 的工具(vim 插件)。此外,Mason 不局限于管理 LSP Server,它还具备管理

  • DAP Server: Debug Adapter Server,调试适配器服务器,微软尝试分离 Debugger 和开发工具,还是采用 LSP 的思想。然而,现有的 Debugger 并不支持这样的协议。所以微软设计了一个适配器,用以连接编辑器和 Debugger
  • Linter:这个词很难翻译,主要作用是用来标记编程错误、漏洞、风格错误等。(由此可见,英语与汉语在某些词汇上很难完成语义映射!)
  • Formatter:即格式化工具,可以根据你的喜好(配置文件)自动对代码进行格式化。
1
2
3
4
5
6
7
8
9
DAP-Client ----- Debug Adapter ------- Debugger ------ Debugee
(nvim-dap) | (per language) | (per language) (your app)
| |
| Implementation specific communication
| Debug adapter and debugger could be the same process
|
Communication via the Debug Adapter Protocol

图:DAP 原理

除了 Mason,LSP 领域还有一个绕不开的项目是 lspconfig,它提供了一组 LSP 的配置,方便用户直接使用。

图 1. lspconfig 官方 Github 项目对用户的提醒。

那么什么是 LSP 的配置呢?

3.2 编辑体验类插件·

Vim 的 tree-sitter、LSP 插件均是为了写代码而添加,没有这些插件,基本无法搞定大型项目,而且编辑体验几乎为零。

接下来介绍的编辑体验提升类插件,以锦上添花为主。虽可有可无,但是确实有一定价值。

3.2.1 vim-snippets·

Snippet 在计算机语境里指的是代码片段,比如我们经常写 include <stdio.h>,那么可以把这段代码视为一个 snippet,当我们需要时直接挪过来即可。

笔者在写博客的时候,经常要创建这样的背景为蓝色的提示框。然而,这种提示框是 Hexo Butterfly 特有的自定义片段,要输入的字符数量还真不少。于是我将它定义为一种带占位符的 snippet,通过提示词和快捷键进行呼出。这极大地提升了编辑效率。

目前依赖的两个 NeoVim 插件是:

  • honza/vim-snippets,为多种语言提供了一些常用的 snippets;
  • SirVer/ultisnips,是一种 snippet 执行引擎,可以完成 snippet 的解析、补全。

安装完之后,即可在 ~/.config/nvim/UltiSnips 目录里自定义自己喜欢的代码片段,甚至你可以用占位符语法定制比较复杂的代码片段。

3.3 文件管理类插件·