In this tutorial, I will explain how to set up forward search in Neovim using lspconfig only. As an example, I will be configuring Zathura with Neovim, but the process is similar for other PDF viewers.
Forward Search
Forward search enables you to request the PDF viewer to jump to the corresponding
location indicated by Neovim.
As explained by the Texlab
wiki page, a forward
search request is represented by a customized LSP request called
textDocument/forwardSearch
. Fortunately, Neovim has all the necessary tools
built-in for this process. Let’s configure the Texlab LSP as this1:
-- file: texlab.lua
local util = require 'lspconfig.util'
local texlab_client = nil
local texlab_build_status = vim.tbl_add_reverse_lookup {
Success = 0,
Error = 1,
Failure = 2,
Cancelled = 3,
}
local texlab_forward_status = vim.tbl_add_reverse_lookup {
Success = 0,
Error = 1,
Failure = 2,
Unconfigured = 3,
}
local function buf_search()
local bufnr = vim.api.nvim_get_current_buf()
local params = {
textDocument = { uri = vim.uri_from_bufnr(bufnr) },
position = { line = vim.fn.line '.' - 1, character = vim.fn.col '.' },
}
if texlab_client then
texlab_client.request('textDocument/forwardSearch', params, function(err, result)
if err then
error(tostring(err))
end
end, bufnr)
else
print 'method textDocument/forwardSearch is not supported by any servers active on the current buffer'
end
end
local function my_on_attach(client_id, bufnr)
texlab_client = client_id
vim.api.nvim_create_user_command("TexlabView", buf_search, { desc = 'TexlabView' })
local keymap = vim.api.nvim_buf_set_keymap
keymap(bufnr, "n", "<leader>Lv", ":TexlabView<CR>", { noremap = true, silent = true, desc = { "TexlabView" } })
end
In the above code, the buf_search()
function is defined to issue
a textDocument/forwardSearch
request. It grabs the current buffer id and the
cursor position and sends it to the server.
The my_on_attach()
function stores Texlab client id and creates a buffer-local keymap that calls the buf_search()
function.
Let’s complete the configuration:
-- File: texlab.lua
local M = {}
M.on_attach = my_on_attach
M.server_conf = {
-- Regular Texlab configuration
}
return M
Finally, we setup lspconfig like this:
-- File: lsp_setup.lua
local lspconfig = require('lspconfig')
local opts = {
on_attach = function(client, bufnr)
-- attach our customized function
require('user.lsp.texlab').on_attach(client, bufnr)
end,
-- Regular LSP setup
}
lspconfig['texlab'].setup(opts)
In this code block, we set up the LSP configuration by requiring the lspconfig
module and defining the opts table. The on_attach
function is assigned to
call our customized texlab.on_attach
function, which would register the client id and our keymap.
After setting up the configuration as described, opening a LaTeX file in Neovim and invoking the command should cause Zathura to jump to the corresponding location in the PDF file.
Bonus: Issuing Compilation Requests
Now that we know how to create and attach customized requests, we can also create a request to let Texlab build the LaTeX file:
-- File: texlab.lua
local function buf_build()
local bufnr = vim.api.nvim_get_current_buf()
local params = {
textDocument = { uri = vim.uri_from_bufnr(bufnr) },
}
if texlab_client then
texlab_client.request('textDocument/build', params, function(err, result)
if err then
error(tostring(err))
end
end, bufnr)
else
print('Method textDocument/build is not supported by any servers active on the current buffer.')
end
end
We can create a user-defined command for this in my_on_attach
similarly as previous section.
Inverse Search
Inverse search is a bit tricky. It involves letting the PDF editor issue a request and the editor (in our case, Neovim) responds accordingly. To start, we need to configure Zathura:
set synctex-editor-command ' nvim --headless -c "lua TexlabInverseSearch(\"%{input}\", %{line})" '
This configuration tells Zathura to run the nvim --headless ...
command
whenever the user requests an inverse search (which is triggered by <Ctrl + left-click> in Zathura). The {input}
and {line}
placeholders will be
automatically replaced by Zathura.
The idea is to create a headless Neovim instance that will call
TexlabInverseSearch
, which broadcast the inverse search request to all Neovim
instances:
-- File: texlab.lua
-- Note that the function has to be public.
function TexlabInverseSearch(filename, line)
local serverlists = vim.fn.system("find ${XDG_RUNTIME_DIR:-${TMPDIR}nvim.${USER}}/nvim* -type s")
local servers = vim.split(serverlists, "\n")
local cmd = string.format(":lua TexlabPerformInverseSearch(\"%s\", %d)", filename, line)
for _, server in ipairs(servers) do
local ok, socket = pcall(vim.fn.sockconnect, 'pipe', server, { rpc = 1 })
if ok then
vim.fn.rpcnotify(socket, 'nvim_command', cmd)
end
end
end
This helper function finds all available sockets in the system 2 and issues the
TexlabPerformInverseSearch
request to each of them with the requested filename
and line number.
So now we have a way of letting the PDF viewer notify (all) Neovim instances about the reverse search command. Let’s actually handle it:
-- File: texlab.lua
-- Helper function to find a window that contains the target buffer in a given tabpage.
local function find_window_in_tab(tab, buffer)
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(tab)) do
if vim.api.nvim_win_get_buf(win) == buffer then
return win
end
end
return nil
end
-- Note that the function has to be public.
function TexlabPerformInverseSearch(filename, line)
-- Check if Texlab is running in this instance.
if not texlab_client then return end
filename = vim.fn.resolve(filename)
local buf = vim.fn.bufnr(filename)
-- If the buffer is not loaded, load it and open it in the current window.
if not vim.api.nvim_buf_is_loaded(buf) then
buf = vim.fn.bufadd(filename)
vim.fn.bufload(buf)
vim.api.nvim_win_set_buf(0, buf)
end
-- Search buffer, starting from the current tab.
local target_win;
local target_tab = vim.api.nvim_get_current_tabpage()
target_win = find_window_in_tab(target_tab, buf)
if target_win == nil then
-- Search all tabs and use the first one.
for _, tab in ipairs(vim.api.nvim_list_tabpages()) do
target_win = find_window_in_tab(tab, buf)
if target_win ~= nil then
target_tab = tab
break
end
end
end
-- Switch to target tab, window, and set cursor.
vim.api.nvim_set_current_tabpage(target_tab)
vim.api.nvim_set_current_win(target_win)
vim.api.nvim_win_set_cursor(target_win, { line, 0 })
end
The code is mostly self-explanatory. We make sure Texlab is running, search the requested file in the buffer, open one if it does not exist, and finally set up the tabpage, window, and cursor to move to the correct place.
The only downside of this is that if you have more than one Neovim instances with Texlab running, then the inverse search would be accepted and handled by all those instances. However, having multiple Neovim with Texlab running should be an extremely rare use case. In fact, we can avoid this by ignoring the request if the buffer is not shown in the current instance. However, there are more common use cases where you want to inverse search into a subfile that was not shown in the buffer, such as a TikZ standalone.
End
In this tutorial, I showed how to set up forward and inverse search in Neovim
for LaTeX files. With the help of lspconfig
and a few lines of Lua code, we
were able to customize the requests sent to our PDF viewer and editor, allowing
us to seamlessly navigate between source code and the corresponding compiled
document. While the setup may require a bit of configuration and tinkering, the
benefits of having a smooth workflow can be immense for anyone working with
LaTeX. Happy Editing!