首发于Think In Vim

基于 fzf 开发自己的 CtrlP

如果要我只能推荐一款 vim 插件的话,那我选 CtrlP 。大约在 2013 年,我最早接触到这个插件(使用别人的 vimrc 配置)。当时还不知道它叫 CtrlP,只知道按一下 Ctrl + P 就可以按照文件路径搜索,而且是模糊匹配的,非常方便。大家可以到CtrlP 官网查看更详细的信息。

CtrlP 是纯 VimL 实现的,文件一多查询就会变得很慢。后来我偶然发现了 fzf。fzf 是 Go 语言开发的,速度很快,而且是异步的。这个异步特性很关键。如果当前目录文件很多,fzf 并不是等到所有路径都匹配完成才显示结果,而是边搜索边匹配边显示,这样就会显得更快。fzf 自带了 vim 插件,所以立即放弃 CtrlP 转用 fzf 了。


fzf 自带的 fzf.vim 有八百多行,今天我们用不到五十行的代码重新打造一个 fzf.vim,顺便学习一下 vim 和 neovim 的 terminal 特性。如果你对开发 vim 插件还不太了解,请先阅读这篇入门文章。附上使用效果视频。

fzf 插件使用效果https://www.zhihu.com/video/1038359610616205312

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

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

好了。说一下实现思路。在底部打开一个新窗口。在新窗口中启动 terminal 并执行 fzf 命令。fzf 搜索完成后会将结果输出一 terminal。terminal 在 fzf 退出后调用 exit 回调。我们在 exit 回调中读取文件路径、闭 terminal 窗口、打开匹配到的文件。下面上代码。

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

  if has('nvim')
    let options = {'on_exit': 'OpenFile'}
    call termopen('fzf', options)
    startinsert
  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()

  if has('nvim')
    " neovim 中可以直接使用 getline()
    let path = getline(1)
  else
    " vim 区别对待 terminal
    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 '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 插件开发的朋友有一定的参考价值。文中介绍的 fzf 插件也可以放心使用。

编辑于 2018-10-24

文章被以下专栏收录