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

2018-10-24 ⏳1.5分钟(0.6千字) 🕸️

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

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

fzf 自带的 fzf.vim 有八百多行,本文将用不到五十行的代码4重新打造一个 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 插件开发的朋友有一定的参考价值。


  1. https://github.com/ctrlpvim/ctrlp.vim↩︎

  2. https://github.com/junegunn/fzf↩︎

  3. https://github.com/junegunn/fzf.vim↩︎

  4. https://github.com/taoso/fzf/blob/master/autoload/fzf.vim↩︎