开发一个 Vim 文件路径搜索插件

2018-10-24 ⏳1.4分钟(0.5千字)

如果要我只能推荐一款 vim 插件,那我选 CtrlP。大约在 2013 年,我最早接触到这个插件(使用别人的 vimrc 配置)。当时还不知道它叫 CtrlP,只知道按一下 Ctrl + P 就可以按照文件路径搜索,而且是模糊匹配的,非常方便。后来我逐行研究当时用的 vimrc 文件,终于知道了 CtrlP 的大名。从此,再也没有离开过它。

CtrlP 是用纯 VimL 实现的,文件一多查询就会很慢。后来我偶然发现了 fzf。fzf 是 Go 语言开发的,速度极快,而且 fzf 在搜索的时候是边搜索边匹配边显示,所以显得更快。而且 fzf 自带了 vim 插件,当即放弃了 CtrlP。

fzf 自带的 fzf.vim 有八百多行,本文将用不到五十行的代码重新打造一个 fzf.vim。如果你对开发 vim 插件还不太了解,请先阅读这篇入门文章

terminal 特性最早由 neovim 引入的,后来 vim 也实现了这个特性,却不跟 neovim 兼容,也不知道是不是故意的。兼容性问题体现在以下几个方面:

  1. 启动函数不同。neovim 使用 termopen(),vim 使用 term_start()
  2. 启动参数不同。退出回调参数在 neovim 上是 on_exit,在 vim 上是 exit_cb
  3. 启动行为不同。neovim 使当前窗口展示终端,默认处于 Normal 模式,得切换到 Insert 模式才能输入命令;vim 会新开一个窗口,打开就能输入命令。
  4. 读取方式不同。neovim 的 on_exit 回调可以直接使用 getline() 函数读取 terminal 内容;vim 的 exit_cb 回调则需要使用专门的 term_getline() 函数读取。关于 terminal 的更多信息请移步 :h terminal

理论讲完了,下面说一下实现思路。在底部打开一个新窗口,并在新窗口中启动 terminal,然后执行 fzf 命令。fzf 搜索完成后会将结果输出到 terminal。terminal 在 fzf 退出后会调用 on_exit/exit_cb 指定的回调函数。我们需要在回调中读取文件路径、关闭 terminal 窗口、打开匹配到的文件。下面上代码:

function! fzf#Open()
  " 此处在底部新开一个窗口,高度为 9 行。
  " keepalt 大家可以自己研究一下,有用,但不加问题也不大。
  keepalt below 9 new

  if has('nvim')
    let options = {'on_exit': 'OpenFile'}
    call termopen('fzf', options)
    startinsert " 切换到 insert 模式
  else
    " vim 默认自己打开新窗口,加上 curwin 参数,指定使用当前窗口
    " vim 还支持 term_name 参数用来设置 terminal 窗口的标题,挺好。
    let options = {'term_name':'FZF','curwin':1,'exit_cb':'OpenFile'}
    " b: 表示当前 buffer 可见,是一类介于全局变量和局部变量之间的变量
    " term_start() 的返回值 term_getline() 要用到
    let b:term_buf = term_start('fzf', options)
  endif
endfunction

" 回调函数
function OpenFile(...)
  " 获取 terminal 的当前工作目录。需要在 close 之前,请思考为什么。
  let root = getcwd()

  " 读取 fzf 返回结果
  if has('nvim')
    " neovim 中可以直接使用 getline()
    let path = getline(1)
  else
    " vim 需要使用专门的函数
    let path = term_getline(b:term_buf, 1)
  endif

  " 此处使用 close 关闭窗口,也可以使用 bdelete 删除 buffer
  " 之前的 new 其实是创建了一个 buffer、一个 window、并将 buffer 和 window 关联。
  " 删除 buffer 会关闭 window,关闭 window 也会删除 buffer。
  silent close

  if filereadable(path)
    " 打开匹配的文件,execute 类似 js 的 eval 函数
    execute 'edit '.root.'/'.path
  endif
endfunction

fzf 默认只搜索当前目录。有时我们希望搜索整个项目,最简单的办法是在执行 fzf 前临时将工作目录切换到项目根路径

function s:findRoot()
  " 获取 git 仓库根目录
  let result = system('git rev-parse --show-toplevel')
  if v:shell_error == 0
    return substitute(result, '\n*$', '', 'g')
  endif

  return "."
endfunction

function! fzf#Open()
  keepalt below 9 new

  let root = s:findRoot()
  if root != '.'
    " 执行 lcd 只改变当前 buffer 的工作目录
    execute 'lcd '.root
  endif

  " ...
endfunction

本文基于 terminal 特性实现了一个迷你的 fzf 插件,对有志于学习的 vim 插件开发的朋友有一定的参考价值。