last updated: August 30, 2025
8 minute read
Running fzf natively in Neovim
Recently, I've been experimenting with running fzf natively in Neovim. By natively, I mean without
using fzf.vim,
fzf-lua, or even fzf's
built-in vim helpers. Just a terminal
buffer in Neovim with a command piped to fzf. This approach has some cool benefits:
- You can write a working proof of concept in ~50 LOC
- It's performant -
fzfopens immediately with results streaming in over time - It's consistent with
fzfin the terminal - options are specified exactly as they are when usingfzfin the shell
There's downsides too, of course - aside from what's already included in fzf, you'll get nothing
for free. If you like a feature-full picker, you should stick with fzf-lua. Even if you just want
a set of barebones fzf functions, you should probably stick with fzf's builtin helpers -
1000 LOC for a robust set of
primitives.
But if you're interested in how you could natively integrate fzf with Neovim yourself, read on.
The core of our approach will be as follows:
- Open a terminal buffer in Neovim
- Run a terminal command and output the result to
fzf- The command will either be a traditional terminal command or a command to execute a standalone
lua script. For the latter, we'll execute the lua script in a headless
nviminstance that can communicate with the primarynviminstance using RPC.
- The command will either be a traditional terminal command or a command to execute a standalone
lua script. For the latter, we'll execute the lua script in a headless
- Interact with
fzfin the terminal buffer to select the result(s) - Redirect
fzf's stdout to a temporary file and read it to access the selected items
We'll start at the top and work our way down.
Part 1: Running fzf in a terminal buffer
First, we need to open a terminal buffer in Neovim (which itself is probably running in a terminal
😅). This can be done with vim.fn.jobstart(), which takes in two args: the cmd string to run,
and a table of opts.
When opts is passed term = true, the command will be spawned in a terminal buffer connected to
the current buffer. To create a dedicated buffer for the terminal, we can use nvim_create_buf and
nvim_set_current_buf:
local listed = falselocal scratch = truelocal term_bufnr = vim.api.nvim_create_buf(listed, scratch)vim.api.nvim_set_current_buf(term_bufnr)vim.fn.jobstart("fd", { term = true, })
In this code, fd is executed and its stdout is output in the terminal buffer as it would be in a
regular terminal.
Opening the terminal buffer in the current window works fine, but I prefer a dedicated window to
create a floating effect. Using nvim_open_win, this isn't too hard.
local listed = falselocal scratch = truelocal term_bufnr = vim.api.nvim_create_buf(listed, scratch)local editor_height = vim.o.lines - 1local border_height = 2local enter = truevim.api.nvim_open_win(term_bufnr, enter, {relative = "editor",row = editor_height,col = 0,width = vim.o.columns,height = math.floor(editor_height / 2 - border_height),border = "rounded",title = "Fzf term",})vim.fn.jobstart("fd", { term = true, })
Looks good! Now that we can run a fd command in a terminal buffer, how can we start fuzzy
filtering the output with fzf?
Just like in a regular terminal, we can pipe the fd command to fzf:
-- ... open the windowvim.fn.jobstart("fd | fzf", {term = true,on_exit = function()local force = truevim.api.nvim_win_close(term_winnr, force)end,})vim.cmd "startinsert"
By default, opening a terminal buffer puts us in normal mode where we can navigate the terminal like
its any other buffer. In our case though, we want to interact with the fzf process in insert mode
:startinsertgets us there.
We also added the on_exit callback, which does what it sounds like. Currently, we're using the
callback to close the window when the fzf process exits - i.e. when selecting an item. But how do
we access the item that was selected? A simple approach is to create a temporary file, pipe the
output of fzf to the temp file, and read from the file in on_exit as well:
local tempname = vim.fn.tempname()-- ... open the windowlocal cmd = ("fd | fzf > %s"):format(tempname)vim.fn.jobstart(cmd, {term = true,on_exit = function()local force = truevim.api.nvim_win_close(term_winnr, force)local fzf_content = vim.fn.readfile(tempname)if #fzf_content > 0 thenvim.cmd("edit " .. fzf_content[1])endvim.fn.delete(tempname)end,})vim.cmd "startinsert"
vim.fn.readfile returns a list where each item is a line in the file that's passed to it - very
convenient.
To apply options to our fzf process, we can simply pass them along to the terminal command:
--- ... open the windowlocal fzf_opts = {"--cycle",[[--bind='ctrl-d:preview-page-down']],[[--bind='ctrl-u:preview-page-up']],[[--preview='bat --style=numbers --color=always {1}']],}local formatted_opts = table.concat(fzf_opts, " ")local cmd = ("fd | fzf %s > %s"):format(formatted_opts, tempname)vim.fn.jobstart(cmd, {term = true,on_exit = function()local force = truevim.api.nvim_win_close(term_winnr, force)local fzf_content = vim.fn.readfile(tempname)if #fzf_content > 0 thenvim.cmd("edit " .. fzf_content[1])endvim.fn.delete(tempname)end,})vim.cmd "startinsert"
This right here is the core of our functionality. Let's make it a bit nicer:
--- @class FzfOpts--- @field source string--- @field options? string[]--- @field sink? fun(entry: string)--- @field sinklist? fun(entry:string[])--- @param opts FzfOptslocal function fzf(opts)opts.options = opts.options or {}local tempname = vim.fn.tempname()local editor_height = vim.o.lines - 1local border_height = 2local listed = falselocal scratch = truelocal term_bufnr = vim.api.nvim_create_buf(listed, scratch)local term_winnr = vim.api.nvim_open_win(term_bufnr, true, {relative = "editor",row = editor_height,col = 0,width = vim.o.columns,height = math.floor(editor_height / 2 - border_height),border = "rounded",title = "Fzf term",})local cmd = ("%s | fzf %s > %s"):format(opts.source, table.concat(opts.options, " "), tempname)vim.fn.jobstart(cmd, {term = true,on_exit = function()vim.api.nvim_win_close(term_winnr, true)local temp_content = vim.fn.readfile(tempname)if #temp_content > 0 thenif opts.sink thenopts.sink(temp_content[1])elseif opts.sinklist thenopts.sinklist(temp_content)endendvim.fn.delete(tempname)end,})vim.cmd "startinsert"endfzf {source = "fd",options = {"--cycle",[[--bind='ctrl-d:preview-page-down']],[[--bind='ctrl-u:preview-page-up']],[[--preview='bat --style=numbers --color=always {1}']],},sink = function(entry)vim.cmd("edit " .. entry)end,}
Part 2: Creating sources with headless nvim scripts
The function we have works great for simple sources like fd, but how can we populate fzf with
something Neovim-specific? Personally, I like use global marks in my workflow - let's use that as an
example. How can we write a script that outputs the current global marks so it can be piped to
fzf? One approach is to run a lua script in a headless nvim instance which communicates with the
main nvim instance using RPC.
Let's start small: to execute a lua script with Neovim, we can use the nvim command as follows:
nvim --headless -l path/to/lua_script.lua
An exercise for the reader: look into
--clean,-u NONE, and--nopluginoptions
--headless does what it sounds like, and -l executes the next argument as a lua script.
Like our fd command, we want the nvim --headless -l path/to/lua_script.lua command to print to
stdout so it can be piped to fzf. This can be achieved with io.write:
-- path/to/lua_script.luafor _, item in pairs { "one", "two", "three", } doio.write(item .. "\n")end-- main nvim configlocal source = table.concat({ "nvim", "--clean", "--headless", "-l", path_to_lua_script, }, " ")fzf {source = source,sink = function(entry)vim.cmd("edit " .. entry)end,}
This is a great foundation for using lua to generate and pipe items to fzf. One benefit to this
approach is performance: since we're piping a terminal command to fzf, fzf will open immediately
and the items can filter in over time as they become available - no coroutines necessary. Try this
out:
-- path/to/lua_script.luafor _, item in pairs { "one", "two", "three", } dovim.cmd "sleep"io.write(item .. "\n")end
fzf even adds a little loading spinner as the results stream in.
Back to our goal of populating fzf with the current global marks. In order for the headless nvim
instance to access state from the currently-open nvim instance, we need to use RPC. In short, RPC
allows the headless nvim to run a function in the context of the main nvim instance and receive
its return value. For example:
-- path/to/get_marks.lualocal servername = 'TODO'local chan = vim.fn.sockconnect("pipe", servername, { rpc = true, })local mark_list = vim.rpcrequest(chan, "nvim_call_function", "getmarklist", {})vim.fn.chanclose(chan)
This code, run in a headless nvim instance, sets mark_list to the result of calling
vim.fn.getmarklist() in the main nvim instance.
In order to connect to the main nvim instance's RPC server, we need the name of the server to use.
We can simply pass this along as an argument to the lua script:
-- main configlocal source = table.concat({ "nvim", "--clean", "--headless", "-l", path_to_lua_script, vim.v.servername, }, " ")
Using this info, we can write our get_marks.lua script:
-- path/to/get_marks.luaassert(arg[1], "Missing arg1: `servername`")local servername = arg[1]local chan = vim.fn.sockconnect("pipe", servername, { rpc = true, })--- @class MarkListEntry--- @field file string--- @field mark string--- @type MarkListEntry[] | nillocal mark_list = vim.rpcrequest(chan, "nvim_call_function", "getmarklist", {})vim.fn.chanclose(chan)if mark_list == nil then return endfor _, mark_entry in pairs(mark_list) do-- marks are prefixed with 'local name = mark_entry.mark:sub(2, 2)if not name:match('[A-Z]') then goto continue endlocal formatted_mark = ("%s|%s"):format(name, vim.fs.normalize(mark_entry.file))io.write(formatted_mark .. "\n")::continue::end-- main configlocal source = table.concat({ "nvim", "--headless", "-l", path_to_lua_script, vim.v.servername, }, " ")fzf {source = source,options = {"--delimiter='|'",[[--bind='ctrl-d:preview-page-down']],[[--bind='ctrl-u:preview-page-up']],[[--preview='bat --style=numbers --color=always {2}']],},sink = function(entry)local filename = vim.split(entry, "|")[2]vim.cmd("edit " .. filename)end,}
Note that our lua script formats the mark entry as A|path/to/file, so we need to update some fzf
flags as well: --delimiter='|' and --preview to use {2} instead of {1}. With --delimiter
set, fzf will split a result by the passed delimiter, meaning {2} - the arg passed to bat -
will be the filename.
We can use a similar RPC approach for fzf actions as well, such as a keybinding to delete a mark:
-- path/to/delete_mark.luaassert(arg[1], "Missing arg1: `servername`")assert(arg[2], "Missing arg2: `selected_mark`")local servername = arg[1]local selected_mark = arg[2]local chan = vim.fn.sockconnect("pipe", servername, { rpc = true, })vim.rpcrequest(chan, "nvim_del_mark", selected_mark)vim.fn.chanclose(chan)-- main configlocal get_marks_source = table.concat({"nvim","--clean","--headless","-l",path_to_get_marks_script,vim.v.servername,}, " ")local delete_mark_source = table.concat({"nvim","--clean","--headless","-l",path_to_delete_mark_script,vim.v.servername,}, " ")fzf {source = get_marks_source,options = {"--delimiter='|'","--cycle",[[--bind='ctrl-d:preview-page-down']],[[--bind='ctrl-u:preview-page-up']],[[--preview='bat --style=numbers --color=always {2}']],([[--bind='ctrl-x:execute(%s {1})+reload(%s)']]):format(delete_mark_source, get_marks_source),},sink = function(entry)vim.cmd("edit " .. entry)end,}
Conclusion
If you've used fzf's builtin vim functions (i.e. fzf#run), the arguments to our homegrown fzf
lua function might look familiar. That's no accident - this article came about because I was
experimenting with fzf#run, and I was curious how it worked internally. I was pleasantly surprised
by how seamless it is to connect fzf to Neovim with a terminal buffer - even with as little code
as I have in this article.
The RPC pattern in this article is pretty flexible - using this approach, you can likely build out all the classic pickers with standalone lua scripts. I'll leave that as an exercise for the reader. Thanks for reading.
you might also like:
Conditional Props in React Using Type Discrimination
November 11, 2024
Type your component to expect different props based on a key discriminator