Vim Tips Wiki

Although there are many (fuzzy)-finder plugins around—most notably CtrlP—you may find them overkill for your own use cases, too slow, too complicated to extend, or with too many requirements (like Vim compiled with Ruby or Python), etc… Or you would just like to avoid installing yet another plugin. Well, it turns out that implementing your own finder à la CtrlP is pretty easy. If you think “fuzzy == imprecise” then you may like this solution. The core idea is extremely simple:

  1. Populate a new scratch buffer with the items to be filtered, one item per line;
  2. Use  :global!  to filter out items as the user types new characters;
  3. Use Vim undo functionality to restore a previous state when the user presses backspace.

The (fully functional and self-contained!) code below illustrates the idea. It allows you to interactively filter a list of items as you type, and return the selected item. You may use CTRL-K and CTRL-J to move up and down through the list; CTRL-L to clear the filter; ENTER to accept the current line; ESC to cancel. You may anchor the pattern at the start or at the end with ^ and $ respectively, and you can match any single character with . and any string with .*. For instance, ^v.*m will match Vim and vrooom!. The parameters are as follows:

input: either a shell command that sends its output, one item per line, to stdout, or a List of items to be filtered.

prompt: a String to be displayed at the command prompt.

Dealing with a multiple selection is left as an exercise to the reader :-)

   fun! FilterClose(bufnr)
      wincmd p
      execute "bwipe" a:bufnr
      echo "\r"
      return []
   fun! Finder(input, prompt) abort
     let l:prompt = a:prompt . '>'
     let l:filter = ""
     let l:undoseq = []
     botright 10new +setlocal\ buftype=nofile\ bufhidden=wipe\
       \ nobuflisted\ nonumber\ norelativenumber\ noswapfile\ nowrap\
       \ foldmethod=manual\ nofoldenable\ modifiable\ noreadonly
     let l:cur_buf = bufnr('%')
     if type(a:input) ==# v:t_string
       let l:input = systemlist(a:input)
       call setline(1, l:input)
     else " Assume List
       call setline(1, a:input)
     setlocal cursorline
     echo l:prompt . " "
     while 1
       let l:error = 0 " Set to 1 when pattern is invalid
         let ch = getchar()
       catch /^Vim:Interrupt$/  " CTRL-C
         return FilterClose(l:cur_buf)
       if ch ==# "\<bs>" " Backspace
         let l:filter = l:filter[:-2]
         let l:undo = empty(l:undoseq) ? 0 : remove(l:undoseq, -1)
         if l:undo
           silent norm u
       elseif ch >=# 0x20 " Printable character
         let l:filter .= nr2char(ch)
         let l:seq_old = get(undotree(), 'seq_cur', 0)
         try " Ignore invalid regexps
           execute 'silent keepp g!:\m' . escape(l:filter, '~\[:') . ':norm "_dd'
         catch /^Vim\%((\a\+)\)\=:E/
           let l:error = 1
         let l:seq_new = get(undotree(), 'seq_cur', 0)
         " seq_new != seq_old iff the buffer has changed
         call add(l:undoseq, l:seq_new != l:seq_old)
       elseif ch ==# 0x1B " Escape
         return FilterClose(l:cur_buf)
       elseif ch ==# 0x0D " Enter
         let l:result = empty(getline('.')) ? [] : [getline('.')]
         call FilterClose(l:cur_buf)
         return l:result
       elseif ch ==# 0x0C " CTRL-L (clear)
         call setline(1, type(a:input) ==# v:t_string ? l:input : a:input)
         let l:undoseq = []
         let l:filter = ""
       elseif ch ==# 0x0B " CTRL-K
         norm k
       elseif ch ==# 0x0A " CTRL-J
         norm j
       echo (l:error ? "[Invalid pattern] " : "").l:prompt l:filter

The code can be used as follows:

   let items = Finder(['one','two','three','four','five'], 'Choose')
   echo "You have chosen: " empty(items) ? 'nothing' : items[0]


Here are a few possible uses:

Buffer switcher:

   let buffers = split(execute('ls'), "\n")
   let choice = Finder(buffers, 'Switch to buffer')
   if !empty(choice)
     execute "buffer" split(choice[0], '\s\+')[0]

Edit most recently used files (MRU):

   let paths = Finder(v:oldfiles, 'Choose file')
   if !empty(paths)
     execute "args" join(map(paths, 'fnameescape(v:val)'))

Choose colorscheme:

   let colorschemes = map(globpath(&runtimepath, "colors/*.vim", 0, 1),
     \                'fnamemodify(v:val, ":t:r")')
   let colorschemes += map(globpath(&packpath,
     \                "pack/*/{opt,start}/*/colors/*.vim", 0, 1),
     \                'fnamemodify(v:val, ":t:r")')
   let choice = Finder(colorschemes, 'Choose colorscheme')
   if !empty(choice)
       execute "colorscheme" choice[0]

Find files in the current directory using find:

     let choice = Finder('find . -type f', "Choose file")
     if !empty(choice)
       execute "edit" choice[0]

Find files in the current directory using rg:

     let choice = Finder('rg --files .', "Choose file")
     if !empty(choice)
       execute "edit" choice[0]

Filter quickfix list and jump to item:

    let qfentry = Finder(split(execute('clist'), "\n"), 'Choose qf entry')
    if !empty(qfentry)
      execute "crewind" matchstr(qfentry[0], '^\s*\d\+', )

Find tag in current buffer (requires Exuberant Ctags):

    let s:tags = Finder(systemlist('ctags -f - --sort=no --excmd=number '
         \ . '--fields= --extra= --file-scope=yes '
         \ . shellescape(expand('%'))), 'Choose tag')
    if !empty(tag)
        let [s:tag, s:bufname, s:line] = split(s:tags[0], '\s\+')
        execute "buffer" "+".s:line s:bufname