DIY Neovim fzy search

There are plenty of fuzzy search solutions for Neovim, most notably Telescope, but sometimes you just want something fast and simple.

Enter fzy, a fast command line program with a slick search algorithm. It is a good unix citizen, operating on newline delimited lists passed through stdin, making it easy to integrate into all sorts of tools, including editors.

It’s own documentation shows an example integration with Vim. However, that implementation relies on the system() function to display the fuzzy finder, which no longer works for interactive commands in Neovim.

Yes, there is a fzy plugin for neovim, but why not take the opportunity to learn some Neovim Lua, and write an implementation ourselves.

Along the way, we’ll learn how to load and test Lua files, invoke floating windows, handle interactive terminal inputs, create flexible functions, and add mappings.

This guide assumes some familiarity with Vim/Neovim, as well as a basic understanding of Lua. If you’re unfamiliar with Lua, I’d recommend reading Learn Lua in 15 minutes before starting. If that sounds fun, fire up your terminal and follow along. Otherwise, skip to the end for the final script.

Setup

Neovim picks up Lua files to include in the lua folder, so we’ll create a file there called fuzzy-search.lua.

mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/nvim/lua"
nvim "${XDG_CONFIG_HOME:-$HOME/.config}/nvim/lua/fuzzy-search.lua"

We’ll need a function for our fuzzy searching, so let’s add one with a debug value to test. We need to access this function from anywhere, so we’ll make it global by omitting the local keyword. By convention, global variables in Lua start with an uppercase letter.

FuzzySearch = function()
    print('Hello, search!')
end

Neovim provides some handy methods for loading Lua files and functions. We’ll use luafile to load our fuzzy-search.lua into Neovim’s memory, and the lua command to then call our newly added FuzzySearch command while we’re testing.

:luafile % " Interpret the current file as lua.
:lua FuzzySearch() " Should print 'Hello, search!' in the message area.

We’ll need to re-run those two commands every time we make a change to see their effects.

Summoning the floating window

We can no longer use the system() hack to interact with terminal programs inside Neovim, but we have access to something better: floating windows! We could make it a split buffer, but since a search interface is an ephemeral UI component that is fine to overlap existing content and should be dismissed the moment a selection is made, a floating window seems ideal.

To do this, Neovim provides the nvim_open_win() API method, which we can access from the vim.api Lua table. This method takes 3 arguments:

  1. {buffer}, for which buffer to display, by buffer ID.
  2. {enter}, boolean for whether to enter the window or not.
  3. {config}, a table of options.

For {buffer}, we ultimately want to display a new terminal buffer with the search, so we’ll need to create one here. We’ll use the nvim_create_buf API method to create a fresh buffer, and we’ll start a terminal session inside it in a later step. nvim_create_buf returns the ID of the buffer it just created, so it can be passed to nvim_open_win() directly. It has 2 boolean arguments; the first for whether the buffer will be “listed” by commands like :ls, and the second for if it should be treated as a “scratch” buffer, which sets some options common to throw-away work. Since this is a temporary window, we’ll want to set this to unlisted and scratch.

For {enter}, we want to start typing our search as soon as the popup window is invoked, without having to do C-w C-l or whatever, so we’ll set this to true.

So far, our function should now look like this:

FuzzySearch = function()
    vim.api.nvim_open_win(
        vim.api.nvim_create_buf(false, true),
        true,
        {}
    )
end

Finally, for {config}, we’ll be setting several options here, largely to position the window. There are five required properties, relative/external, width, height, col, and row, so let’s set them first.

Every Neovim window requires either the relative or external key to be set. external is only relevant for external GUI applications, so we’ll keep it simple and only set relative. relative controls where the window is positioned relative to, aka, where it’s x/y position originates from. Our window can be relative to the editor, the current window, or the cursor position. This is a global search, so we’ll set relative to editor. This means that our new window’s 0/0 x and y position starts at the 0/0 x and y values of the entire editor.

Width and height are simple: how many rows, for height, and columns, for width, does our window occupy? Let’s keep this straight forward for now, and set width to 10 and height to 5.

col and row control where on the grid the window should appear from. This is our starting x and y values. Again, let’s keep this simple and set each to 0.

Our function should now look like this.

FuzzySearch = function()
    vim.api.nvim_open_win(
        vim.api.nvim_create_buf(false, true),
        true,
        {
            relative = 'editor',
            width = 10,
            height = 5,
            col = 0,
            row = 0,
        }
    )
end

Now, if you run luafile on your fuzzy-search.lua file again, and then lua FuzzySearch(), our floating window should appear over the top right of your editor!

Our basic floating window

Type :bd to close it.

Great, we have a floating window, but it’s not going to be very helpful looking like a postage stamp in the upper left. Let’s adjust the size, and center the window.

Centering the window

To center the window, we’ll need to calculate the mid-point for our window’s horizontal and vertical edge based on the window size and the size of Neovim itself, with our good friend Math.

We can get the width of the editor via the columns global option, exposed in the vim.o options table, and the height via lines, exposed in the same.

Let’s start with the width. Our formula is pretty simple: subtract the width of the popup from the total columns in the editor (the width), and divide that by two to get the midway point. We need to subtract the popup’s width, since it would be pushed too far to the right without compensating for the space it takes up. We’ll finish by wrapping the whole expression in the Lua built-in math.min, since col expects whole numbers.

math.min((vim.o.columns - 10) / 2)

We’ll do something almost identical for row (aka height), but instead of using vim.o.columns, we’ll use vim.o.lines.

math.min((vim.o.lines - 5) / 2 - 1)

Notice that we’re also adding an extra subtraction by one. This is because vim.o.lines returns the total lines in the current window, including the status line and the message area. That’s an extra two lines to account for. Since we want to center the popup vertically, to find how much to compensate by, we divide the extra lines by two, giving us one to subtract.

Our function should now look like this.

FuzzySearch = function()
    vim.api.nvim_open_win(
        vim.api.nvim_create_buf(false, true),
        true,
        {
            relative = 'editor',
            width = 10,
            height = 5,
            col = math.min((vim.o.columns - 10) / 2),
            row = math.min((vim.o.lines - 5) / 2 - 1),
        }
    )
end

Looking over this code, there’s some repetition causing maintenance overhead: we’re writing literals for the width and height twice. We’ll need to change these values soon, so let’s refactor to use local variables for these values. Add a variable for width and height at the top of the FuzzySearch function, since we’ll want them to be available throughout the scope. Our code should now look like this:

FuzzySearch = function()
    local width = 10
    local height = 5
    vim.api.nvim_open_win(
        vim.api.nvim_create_buf(false, true),
        true,
        {
            relative = 'editor',
            width = width,
            height = height,
            col = math.min((vim.o.columns - width) / 2),
            row = math.min((vim.o.lines - height) / 2 - 1),
        }
    )
end

If you test this code, you’ll get something like this.

Basic centered floating window

Not much to look at, but at least it’s centered. But why is it only one line high, instead of five? Well, it actually is five lines high, but we can’t tell because our window has no outline style or contents. Let’s fix the former, then move on to the latter.

Styling the window

border has several built in options, as well as an option to define your own border characters (this is what Telescope does). Feel free to play around with the options, but for the purpose of this guide we’ll be using "shadow". I like this style because it’s visually uncluttered, and makes clear that this window is “above” others.

While it’s not styling, let’s take a moment here to set the noautocmd option to true. This disables buffer events for the window, since we won’t be using them and it’s a good practice to limit the scope of our programs as much as sensible. Feel free to set this to false later if you do end up using these methods.

Our function should now look like this.

FuzzySearch = function()
    local width = 10
    local height = 5
    vim.api.nvim_open_win(
        vim.api.nvim_create_buf(false, true),
        true,
        {
            relative = 'editor',
            style = 'minimal',
            border = 'shadow',
            noautocmd = true,
            width = width,
            height = height,
            col = math.min((vim.o.columns - width) / 2),
            row = math.min((vim.o.lines - height) / 2 - 1),
        }
    )
end

Test this code and you should get something like this.

Styled floating window

Looking good. Or, at least like a stylish postage stamp. Alright, let’s move on to the contents of the window.

Launching a fzy terminal

There are several ways Neovim offers for creating a new terminal instance, but we’ll be using the termopen() function, since it offers the most API control.

We can ask it to provide a “standard” interactive terminal session, or to launch running a specific command. We’ll call it after our floating window setup code, using a basic command to gather files for fzy to search, taken from their documentation, that should work on most systems.

vim.fn.termopen('find . -type f | fzy')

The find command will grab every regular file in your current directory tree, and pass it to fzy. Testing this code will produce a result similar to this.

Cramped results from fzy

Hooray! You should be able to search for a file, move up and down in the list via C-n and C-p, and select a file with Enter. However, you may be noticing some slight issues.

  1. The window is too small to see the results properly.
  2. Depending on your settings, you might not be in terminal mode automatically when you enter the window, causing you to have to type i before you can search.
  3. Selecting a file produces a [Process exited 0] message, making you press Enter again before continuing.
  4. Selecting a result does not open it!

Solving the second issue is dead simple: we call startinsert before running termopen() via nvim_command.

vim.api.nvim_command('startinsert')

We’ll address each of the other issues, but let’s tackle the window size first, so we can better see what we’re doing.

Dynamic width and height

Alright, back to window sizing. We can improve the display by taking full advantage of the amount of space we have available to us. Since we already re-factored our width and height to single variables, we simply modify them where they are declared.

Wouldn’t it be nice to stretch the width of the popup window to however large the Neovim instance is? Easy. We change the width variable to equal vim.o.columns, minus four. The number four is arbitrary; it gives two columns of space between the edge of the Neovim instance and the popup window, which feels right to me. Feel free to experiment with your own values.

local width = vim.o.columns - 4

For setting the height, we want to show all the results that fzy shows, or, in other words, we want our popup window to be as tall as the fzy output. fzy defaults to displaying ten search results at a time. This number can be controlled via the --lines option, but changing that will be left as an exorcise for the reader. For now, we’ll redefine height to be equal to 11, which is the default 10 results fzy displays, plus an extra line for the search prompt.

local height = 11

We now have an adaptive display window that shows our searches more clearly.

Dynamic window width

But what happens on very large screens? Our window will stretch all the way across, packing the results at the left, and wasting space on the right. We can spend a moment fixing this by setting a max width for the window. The window will still center, so the eye won’t have to travel all the way to the edge to see results. The standard max line length for Vim is a sensible 80 columns, so we’ll stick to that for our window.

Since we’re subtracting four from the total width, and we want to trigger the max after we would naturally reach 80 columns, we’ll set the width at 85 columns.

After our local variable declarations, we’ll add our conditional.

if (vim.o.columns >= 85) then
    width = 80
end

Now the entirety of our function should look like this.

FuzzySearch = function()
    local width = vim.o.columns - 4
    local height = 11
    if (vim.o.columns >= 85) then
        width = 80
    end
    vim.api.nvim_open_win(
        vim.api.nvim_create_buf(false, true),
        true,
        {
            relative = 'editor',
            style = 'minimal',
            border = 'shadow',
            noautocmd = true,
            width = width,
            height = height,
            col = math.min((vim.o.columns - width) / 2),
            row = math.min((vim.o.lines - height) / 2 - 1),
        }
    )
    vim.fn.termopen('find . -type f | fzy')
end

Let’s move on to solving the third and fourth problems mentioned above—not actually being able to open the file searched for!

Opening the searched for file

We want to perform an action—edit a file—when the terminal process for fzy exits, which happens after the file is selected. We know from the fzy man page that on exit the currently selected item is printed to stdout, which is how we can detect which file is selected.

The termopen() function takes a table of event-driven callbacks as it’s second argument. We’ll be using the appropriately named on_exit.

vim.fn.termopen('find . -type f', {on_exit = function()
    -- code goes here.
end})

Let’s get rid of the extra Enter press. Inside the on_exit callback, we’ll call bdelete, meaning that once the terminal process exits, we’ll automatically delete the buffer. We’ll add the ! option, which will delete the buffer even if there are changes to it. This buffer should never have meaningful changes, so we never want that safety (otherwise, if there were changes, bdelete would produce an error).

vim.api.nvim_command('bdelete!')

If you test the function, the popup window should immediately dismiss after a file is selected. Excellent!

Now we can move on to opening the file searched for. We know that fzy prints the path to the selected file to {stdout}. Maybe there’s an argument that Neovim passes {stdout} to the terminal event callbacks? However, the on_exit callback only receives the job id, the exit code, and the event type, which in this case is always “exit”.

There must be a better way to solve this, but how I’ve figured it out is to write the contents of {stdout} to a file as part of the fzy pipeline, then read the file contents back in the on_exit function. If you know of a better method, hit me up on Twitter.

Since the file we’re creating is totally throw-away, you could say temporary, we’ll use Neovim’s tempname() function to generate a unique temporary file name in a clean path.

local file = vim.fn.tempname()

Then we can save the output fzy (which is {stdout}) to our file with simple Unix redirection and Lua concatenation.

'find . -type f | fzy > ' .. file

Back inside our on_exit callback function, and after our bdelete call, is where we can access the file we wrote. Lua provides a robust filesystem API which we can use to open a stream to the file and read the contents into a variable. We’ll open the file stream as read only, keeping the principle of only asking for what we need.

local f = io.open(file, 'r')
local stdout = f:read('*all')

We should also clean up after ourselves, removing the temporary file from disk and closing the file stream.

f:close()
os.remove(file)

Now we have the file path stored in the stdout variable; we can use the nvim_command Neovim API method to :edit it!

vim.api.nvim_command('edit ' .. stdout)

Our whole function should now look like this.

FuzzySearch = function()
    local width = vim.o.columns - 4
    local height = 11
    if (vim.o.columns >= 85) then
        width = 80
    end
    vim.api.nvim_open_win(
        vim.api.nvim_create_buf(false, true),
        true,
        {
            relative = 'editor',
            style = 'minimal',
            border = 'shadow',
            noautocmd = true,
            width = width,
            height = height,
            col = math.min((vim.o.columns - width) / 2),
            row = math.min((vim.o.lines - height) / 2 - 1),
        }
    )
    local file = vim.fn.tempname()
    vim.fn.termopen('find . -type f | fzy > ' .. file, {on_exit = function()
        vim.api.nvim_command('bdelete!')
        local f = io.open(file, 'r')
        local stdout = f:read('*all')
        f:close()
        os.remove(file)
        vim.api.nvim_command('edit ' .. stdout)
    end})
end

Test the function; selecting a file should open it. Yay! We have a fully working solution.

Implementing the search command

Wouldn’t it be nice to be able to access our function outside of our fuzzy-search.lua file? Say, in our init.vim or init.lua file?

Lua includes a simple yet powerful module system, which we can leverage with only a few changes to our file.

All we need to do is return our function, and that will expose it to require statements. However, to make it possible to add further exportable functions to this file in the future, and to adhere to convention, we’ll add our function to a table.

local M = {}
M.FuzzySearch = function()
    -- all our code.
end
return M

We name the returned variable M, again, to follow convention.

This adds fuzzy-search as a module to the Neovim environment. In a Lua file within the Neovim context, we could add our function to the environment with:

local fs = require'fuzzy-search'
fs.FuzzySearch()

Notice there’s no .lua extension or leading lua directory name in the require—Neovim/Lua handles this for us so we don’t have to type all that.

Now, in our init.vim or init.lua file, we can create a mapping to this function by requiring our search file inline, and parsing it with the built-in lua command.

Say we wanted to map <leader>f, we would add, for init.vim:

nnoremap <leader>f <cmd>lua require'fuzzy-search'.FuzzySearch()<CR>

Or for init.lua:

vim.api.nvim_set_keymap('n', '<leader>f', '<cmd>lua require"fuzzy-search".FuzzySearch()<CR>')

The final script

We did it. Here’s our completed code.

-- ~/.config/nvim/lua/fuzzy-search.lua
local M = {}
M.FuzzySearch = function()
    local width = vim.o.columns - 4
    local height = 11
    if (vim.o.columns >= 85) then
        width = 80
    end
    vim.api.nvim_open_win(
        vim.api.nvim_create_buf(false, true),
        true,
        {
            relative = 'editor',
            style = 'minimal',
            border = 'shadow',
            noautocmd = true,
            width = width,
            height = height,
            col = math.min((vim.o.columns - width) / 2),
            row = math.min((vim.o.lines - height) / 2 - 1),
        }
    )
    local file = vim.fn.tempname()
    vim.fn.termopen('find . -type f | fzy > ' .. file, {on_exit = function()
        vim.api.nvim_command('bdelete!')
        local f = io.open(file, 'r')
        local stdout = f:read('*all')
        f:close()
        os.remove(file)
        vim.api.nvim_command('edit ' .. stdout)
    end})
end
return M

Download the search code.

Improvement ideas

This script is just a starting point. Here’s some ideas for improvements.

  • Allow mappings to specify how they want to open the file (split/vsplit/tab/etc).
  • Allow mappings to change the file gathering command, e.g. fd or my personal favoriate, git ls-files.
  • Allow mappings to change the number of results returned.
  • Get rid of that file write!

I implemented some of these in my own dotfiles.

Wrap up

That’s it! Thanks for reading.

Syndicated to: