开发一个 Vim 最近文件列表插件

2018-10-23 ⏳5.6分钟(2.2千字)

MRUMost Recently Used 的缩写。使用 MRU 可以很方便地在几个文件之间切换。

MRU 的功能小众,代码总共也才一百来行。但麻雀虽小,五脏俱全,MRU 囊括了开发 vim 插件的大部分知识点。开发 MRU 插件需要解决以下几个问题:

  1. 如何写一个最小的 vim 插件
  2. 如何记录最近使用文件列表
  3. 如何展示最近使用文件列表
  4. 如何打开列表中的特定文件
  5. 如何保存最近使用文件列表

如何写一个最小的 vim 插件

千里之行,始于足下。咱们先来一个 Hello, world!。创建 ~/.vim/plugin/mru.vim 文件(如果是 NeoVim,就创建 ~/.config/nvim/plugin/mru.vim,以下不再单独针对 NeoVim 作说明)。在 mru.vim 中添加:

echo "Hello, world!"

重新打开 vim,你会看到

➜  ~ vim
Hello, world!
Press ENTER or type command to continue

这是一个最简单的插件。vim 有一个 runtimepath 配置。这是一个逗号分割的文件夹路径列表。vim 启动的时候会自动执行每个文件夹下的 plugin 文件夹下的所有的 vim 文件。而 runtimepath 默认包含了 ~/.vim/,所以 vim 会执行我们的 mru.vim 并输出了Hello, world!

如何记录最近使用文件列表

首先,我们需一个列表。vim 为我们准备了 list 对象。list 的常用操作如下:

let a = [1, 2, 3] " 赋值使用 let,注释以双引号开头

call add(a, 4)
call insert(a, 0) " 直接调函数需要使用 call 关键字
echo len(a) " 如果是表达式的一部分则不用 call 关键字
echo a " 输出 [0, 1, 2, 3, 4]。注意 add() 与 insert() 的区别

" 提取 list 的一部分(也叫 slice)
echo a[2:3] " 输出 [2, 3]
echo a[2:] " 输出 [2, 3, 4]
echo a[:3] " 输出 [0, 1, 2, 3]
echo a[1] " 输出 1

以上操作用来记录最近使用文件列表已经足够了。思路很简单,创建一个空 list,每次将一个文件路径插入到 list 的最前面,然后跟据预先设定的长度截取 list 的前 n 个元素。另外还要去掉重复的元素。代码如下:

let g:mru_files = [] " 以 g: 开头,表示全局变量,是 vim 特色
let g:mru_files_len = 10

" 以 s: 开头,表示函数定义仅在当前源文件可见
function s:MruAdd(path)
  " 以 a: 开头,表示函数参数,绝对的 vim 特色。经常忘记!!!
  let idx = index(g:mru_files, a:path) " 查询是否已经存在
  if idx >= 0
    call remove(g:mru_files, idx) " 移除老元素
  endif
  call insert(g:mru_files, a:path) " 插入新元素
  let g:mru_files = g:mru_files[:g:mru_files_len-1]
endfunction " 注意结尾的 end

有了列表,接下来要解决的问题是如何在切换文件的时候自动更新g:mru_files。vim 在工作过程中会触发很多事件。我们可以注册相应的回调函数来执行一些操作。vim 支持的事件列表可以通过:h autocmd-events查看。而我们只需要用到以下几个:

这里简单说一下 bufferwindow。vim 编辑文件的时候会将内容加载到内存,叫作一个 buffer。大家看到的 vim 窗口,一般是一个 window,一个 GUI 窗口可以展示多个 window。一个 window 可以关联到一个 buffer,这样大家才能看到 buffer 的内容。一个 buffer 也可以不关联 window,这时候称其为 hidden buffer。我们修改的其实是 buffer 的内容。只有执行 :w 后 vim 才会将 buffer 的内容写入磁盘。

所以,以上三个事件就比较容易理解了。BufWinEnter 表示一个 buffer 关联到一个 window 的时候触发的。关联到 window,意味着我们能看到 buffer 内容。创建新文件、打开旧文件、切换到 hidden buffer 都会触发 BufWinEnter 事件。BufWinLeave 则与 BufWinEnter 相反。发生 BufWinEnter 意味着当前 buffer 失去了关联的 window,不再可见。BufWritePost 则在 buffer 写入磁盘之后触发。

终于讲完枯燥的理论了。接下来我们要注册事件回调函数了。

autocmd BufWinLeave,BufWritePost * call s:MruAdd(expand('%:p'))

注册回调使用 autocmd 关键字,多个事件可以使用逗号分割。星号表示匹配所有文件路径。再后面就是普通的 VimL 代码。此处的意思是调用 s:MruAdd() 函数。这里的入参是 expand() 函数的返回值。这个 expand() 函数很常用。通过它可以获取很多信息。百分号表示获取当前 buffer 对应的文件路径。后面的 :p 表示获取绝对路径。关于 expand() 的更多功能请参考 :h expand(),此处不展开。这里的回调函数的完整意思就是:当 vim 将要切换到另一个文件或者保存退出的时候,提取切换前或者已保存的文件的绝对路径并保存到 g:mru_files 列表中。

到此,大家可以通过 :e path/to/file.txt 来回打开几个文件,然后通过 :echo g:mru_files 查看一下这个列表的内容,看看是否符使预期。

如何展示最近使用文件列表

好了,我们已经解决了列表的记录问题,接下来要解决列表的展示问题了。展示列表也没有什么魔法,说白了就是开一个新窗口和 buffer,将列表内容依次写入新 buffer。

function s:MruList()
  " 首先,开一个新窗口
  let rows = len(g:mru_files)
  " 在窗中底部新开一个 rows 行的窗口并生成空 buffer
  execute 'below  '.rows.' new'
  setlocal buftype=nofile " 设成 nofile 表示当前 buffer 没有对应的真实文件
  setlocal filetype=MRU " 将 filetype 设置成 MRU 备用

  " 然后,写入列表元素
  let n = len(g:mru_files)
  let i = 0
  while i < n
    call setline(i + 1, g:mru_files[i])
    let i += 1
  endwhile
endfunction

command MruOpen call s:MruList() " 注册 MruOpen 命令

execute 关键字类似 js 的 eval() 函数。当要需要执行的动态代码,就要用到它。我们在这里需要计算 g:mru_files 的长度来确定新开窗口的行数,所以也得使用 execute。如果行数是确定的,则可去掉 execute 关键字。比如,below 3 new 会打开一个只有三行的新窗口。更多信息请移步 :h execute, :h below, :h new。另外,我们需要通过 setline() 函数将内容写入 buffer。setline() 的第一个参数为行号,编号从开始。

这样,大家就可以通过 :MruOpen 查看 g:mru_files 的内容了。

如何打开列表中的特定文件

看到了列表,接着就要解决如何打开对应文件的问题。方案也很简单,就是将回车映射到一个函数,在函数里获取当前行内容(即文件绝对路径),关闭文件列表,打开选中文件。上代码:

function mru#OpenFile()
  let path = getline('.')
  bdelete
  execute 'edit '.path
endfunction

autocmd FileType MRU nmap <buffer> <cr> :call mru#OpenFile()<cr>

依然用到 autocmd 指令,但这次是配合 FileType 使用。autocmd FileType MRU xxx 意思是每当打开 filetype 为 MRU 的 buffer 的时候执行 xxx。而这里的 xxx 就是重新映射回车键的功能。nmap 表示 normal map,意为只在 normal 映射。<cr> 表示回车。:call mru#OpenFile()<cr> 表示在命令模式下执行 mru#OpenFile() 函数。最后 <buffer> 表示该映射只在当前buffer 有效。所以,当你在 MRU 窗口按下回车键的时候,vim 就会执行 mru#OpenFile()

再说说这个 mru#OpenFile() 函数。看名字,以 mru# 开头,表示全局可见。因为我们要在命令模式直接调用,所以不能以 s: 开头。又因为文件名是 mru.vim,所以只能以 mru# 开头,这是 vim 的规矩。大家这里先记着就行。

vim 在执行 VimL 时并不会绑定到特定 buffer 或者 window。我们调用 getline('.') 获取当前光标所在的行的内容,也就是文件路径。接着,执行 bdelete,会删除当前 buffer 并关闭对应的 window。然后,vim 会切换到启动 MRU 窗口之前的窗口,并执行 execute ‘edit’.path,打开对应的文件。

最后,再来一个映射,大家看看是什么功能。

autocmd FileType MRU nmap <buffer> <Esc> :bdelete<cr>

对,就是按 esc 关闭 MRU 窗口。

如何保存最近使用文件列表

还剩下一个功能,就是如何保存最近使用文件列表。这个 g:mrufiles 是个变量,只保存在内存里,vim 退出后就消失了。那如何保存以供下次打开的时候使用呢?最直接的方法就是写文件。我们可以注册特定事件的回调函数,在 vim 退出的时候将 g:mru_files 写入文件,在 vim 启动的时候重新读取该文件就好了。

但是这样做比较麻烦。对于我们这种简单的场景,vim 还提供了更简单 viminfo 机制(NeoVim 改成 shada 了)。简而言之,vim 在退出时会根据 viminfo 配置项将一些状态信息写入文件(默认为 ~/.viminfo)。vim 启动的时候会自动从 ~/.viminfo 文件读取并恢复上次退出时的状态。更多细节,请参阅 :h viminfo。上代码:

if has('nvim') " NeoVim 跟 vim 不一样
  rsh " 从 shada 恢复状态信息
else
  set viminfo+=! " 关键,开启自动保存全局变量
  if filereadable('~/.viminfo') " 判断 ~/.viminfo 是否存在,不存在会报错的
    rv " 从 viminfo 恢复状态信息
  endif
endif

vim 默认不保存全局变量,所以需要通过 set viminfo+=! 开启(就是这个叹号)。即便是开启了,也不是所有的全局变量都会保存。viminfo 只会保存变量名中没有小写字母的全局变量。为了达到目的,我们需要将所有的 g:mru_files 改成 g:MRU_FILES

结语

本文分析了 MRU 的实现原理,介绍了开发 vim 插件基础知识,对有志于学习 vim 插件开发的朋友有一定的参考价值。