首发于Think In Vim

vim 插件开发入门之 MRU

MRU 是 Most Recently Used 的缩写,表示最近使用的意思。MRU 会记录你最近打开过的文件列表。执行 :Mru 命令的时候打开一个文件类型为 MRU 的窗口展示最近使用文件列表。你在 MRU 窗口中可以按 Esc 或者 Ctrl-c 关闭窗口;可按回车打开光标所在行的文件。视频胜千言,大家可以看一下。如果想亲自体验,请移步 github.com/lvht/mru。如果你想学习 MRU 源码以及如何开发 vim 插件,请继续阅读下一节。

MRU 插件使用示例https://www.zhihu.com/video/1037741916418445312

MRU 的功能小众,代码总共也就一百来行。但是,麻雀虽小,五脏俱全。开发 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

function s:MruAdd(path) " s: 开头,表示函数定义仅在当前源文件可见
  " 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 查看。而我们只需要用到以下几个:

  1. BufWinEnter
  2. BufWinLeave
  3. BufWritePost

这里简单说一下 buffer 和 window。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 的内容了。


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

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

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) 有效。所以,你在 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>

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

还剩下一个功能,就是如何保存最近使用文件列表。这个 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 插件开发的朋友有一定的参考价值。文中提到的 MRU 代码为突出重点只列出了关键逻辑,而功能并不完整。如果大家想继续研究或者体验,请参考github.com/lvht/mru

MRU 不完全代码如下:

set viminfo+=!
if filereadable('~/.viminfo')
  rv
endif

if !exists('g:MRU_FILES')
  let g:MRU_FILES = []
endif

let g:mru_files_len = 10

function s:MruAdd(path)
  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

function s:MruList()
  let rows = len(g:MRU_FILES)

  execute 'below  '.rows.' new'
  setlocal buftype=nofile
  setlocal 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

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

autocmd BufWinLeave,BufWritePost * call s:MruAdd(expand('%:p'))
autocmd FileType MRU nmap <silent> <buffer> <cr> :call mru#OpenFile()<cr>
autocmd FileType MRU nmap <buffer> <Esc> :bdelete<cr>
command MruOpen call s:MruList()
编辑于 2018-10-23

文章被以下专栏收录