Configuring Forward and Inverse Search in Neovim for LaTeX (Texlab)

2023/04/17

Tags: Neovim LSP LaTeX

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 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 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!


  1. The code is modified from here. If you want to use this version, please note that the commands option is going to be removed in the future (see :h lspconfig-configurations). ↩︎

  2. Depending on your system, you might use a different path. ↩︎