Contents

A Quick Guide on Writing LaTeX in Neovim

Series - Editors

In the previous post, I shared a configuration for LaTeX Workshop in VS Code. Here I will explain how to build a LaTeX writing environment in Neovim, with particular attention to the snippets I use for mathematics.

The examples below come from my nvim-math-snippets collection. This revision updates the original guide to the current LuaSnip setup and uses blink.cmp for completion. The main pieces have different jobs:

Tool Job
VimTeX Compile the document, open the PDF, and navigate LaTeX source
LuaSnip Expand snippets and move through their editable fields
nvim-math-snippets Supply the snippets and their math/text conditions
Tree-sitter Parse the document so snippets can recognize the surrounding syntax
texlab Supply language-server features, including labels and citation completion
blink.cmp Display completion candidates in Neovim

I use macOS; the editor configuration also applies to Linux with a different PDF viewer. You will need a TeX distribution, latexmk, Neovim, and texlab. On macOS, with Homebrew installed:

brew install neovim texlab tree-sitter-cli
brew install --cask mactex-no-gui skim

MacTeX without GUI applications supplies the TeX tools, and Skim is the PDF viewer used below. Restart your terminal after installing MacTeX so its executables are on PATH. On Linux, install TeX Live, latexmk, and the other tools using your distribution’s packages. LuaSnip’s jsregexp build and Tree-sitter parser installation also need a C compiler; on macOS, install the command-line developer tools with xcode-select --install if needed.

Use Neovim 0.12.4 or later for the current plugin versions in this guide. The snippet collection itself uses native APIs available since Neovim 0.11, but VimTeX’s requirements and the Tree-sitter installer requirements can be higher. The current Tree-sitter installer also requires tree-sitter-cli 0.26.1 or later. Check your installation with:

nvim --version
latexmk -v
texlab --version
tree-sitter --version

This guide assumes you know how to enter Insert mode, save a file, and return to Normal mode. If Neovim is new to you, start with :Tutor.

The plugin examples use lazy.nvim’s structured setup: your init.lua loads config.lazy, and lazy.nvim imports the files in lua/plugins/. Each plugin block below is a complete file in that directory. If you already have these plugins, merge the options into their existing specifications.

Before calling require("lazy").setup(...), set the local leader:

vim.g.maplocalleader = "\\"

VimTeX uses this prefix for its commands. In the following examples, \ll means pressing backslash and then l twice in Normal mode.

VimTeX connects the source file, compiler, and PDF viewer. Save the following as lua/plugins/vimtex.lua:

return {
    {
        "lervag/vimtex",
        lazy = false,
        init = function()
            vim.g.vimtex_compiler_method = "latexmk"
            vim.g.vimtex_view_method = "skim"
        end,
    },
}

VimTeX’s installation instructions recommend loading it at startup; its own filetype support handles when the LaTeX features are needed. On Linux, a viewer such as Zathura needs its corresponding vimtex_view_method instead.

Open a .tex file and try these commands:

Keys Action
\ll Start or stop continuous compilation with latexmk
\lv Open the PDF and jump to the source position using forward search
\le Open the quickfix list of compilation errors
\li Show information about the current TeX project
\lt Open the table of contents

Once compilation is running, saving the source triggers another build. For a project split across files, put a root directive at the top of each included file, with a path relative to that file:

% !TeX root = ../main.tex

Forward search goes from source to PDF; inverse search goes from PDF to source. VimTeX’s default latexmk options enable SyncTeX. For Skim, open its Sync preferences, choose the Custom preset, and set the command to the full path printed by command -v nvim. Set the arguments to:

--headless -c "VimtexInverseSearch %line '%file'"

Shift–Command–click in the PDF then performs inverse search. See :help vimtex-view-skim or the VimTeX manual for viewer-specific details.

LuaSnip is the snippet engine; my collection supplies the actual LaTeX snippets. For example, typing mk in text creates \(\), and typing ;a inside it produces \alpha. The context matters: ;a should not turn into a Greek letter while I am writing prose.

The collection detects math using Neovim’s native Tree-sitter API. It does not need VimTeX’s syntax tests or an active Tree-sitter highlighter. One way to install the parsers is to save this as lua/plugins/treesitter.lua:

return {
    {
        "nvim-treesitter/nvim-treesitter",
        branch = "main",
        lazy = false,
        build = ":TSUpdate",
        config = function()
            require("nvim-treesitter").install({ "latex", "markdown", "markdown_inline" })
        end,
    },
}

This uses the current main branch API. Parser installation is asynchronous, so let it finish before testing the snippets. The latex parser is sufficient for TeX files; the Markdown parsers are useful for injected math. If your package manager already supplies the parsers and queries, this extra installer is unnecessary.

Save this as lua/plugins/snippets.lua:

return {
    {
        "L3MON4D3/LuaSnip",
        lazy = false,
        build = "make install_jsregexp",
        dependencies = { "mathjiajia/nvim-math-snippets" },
        config = function()
            local ls = require("luasnip")
            ls.setup({
                update_events = "TextChanged,TextChangedI",
                enable_autosnippets = true,
                cut_selection_keys = "<Tab>",
            })
            require("luasnip.loaders.from_lua").lazy_load()

            vim.keymap.set({ "i", "s" }, "<C-j>", function()
                if ls.expand_or_locally_jumpable() then
                    ls.expand_or_jump()
                end
            end, { desc = "Expand snippet or jump forward" })

            vim.keymap.set({ "i", "s" }, "<C-k>", function()
                if ls.locally_jumpable(-1) then
                    ls.jump(-1)
                end
            end, { desc = "Jump backward in snippet" })

            vim.keymap.set({ "i", "s" }, "<C-l>", function()
                if ls.choice_active() then
                    ls.change_choice(1)
                end
            end, { desc = "Change snippet choice" })
        end,
    },
}

The Lua loader discovers the collection’s luasnippets/ directory on Neovim’s runtime path. enable_autosnippets allows expansion while typing, and update_events keeps repeated fields in sync as you edit them. The jsregexp build is needed for the collection’s ECMAScript triggers, including guards that prevent adding a second backslash to an existing command.

With these mappings, Ctrl-j expands a normal snippet or moves to the next field, Ctrl-k moves back, and Ctrl-l cycles the alternatives in a choice field. These are choices made by this configuration, not built-in LuaSnip key bindings.

There are two kinds of snippets: automatic snippets expand as soon as their trigger matches, while normal snippets wait for an explicit expansion key.

mk is automatic. In prose, type mk, enter a formula, then press Ctrl-j to leave the formula. The collection also attaches a small callback to this snippet: when LuaSnip leaves it, the next inserted character in that buffer gets a preceding space if it matches Lua’s %a letter class. Thus, continuing with a word gives \(x\) is, while typing a comma gives \(x\),.

For a fraction, type // in math mode. The result has a numerator, a denominator, and an exit position:

\frac{<1>}{<2>}<0>

Here <1> and <2> stand for editable fields; <0> is the final cursor position. They are explanatory placeholders, not literal output. Fill the numerator, press Ctrl-j, fill the denominator, and press Ctrl-j again. A single / is a normal snippet for the same structure and needs Ctrl-j to expand.

These are examples from the current collection. Except for the final two rows, they expand automatically. Trailing spaces in some symbol expansions are omitted from the table.

Trigger Result Context
mk \(<1>\)<0> Text
dm A \[ ... \] display Text
;a, ;vf \alpha, \varphi Math
RR, CC \mathbb{R}, \mathbb{C} Math
x1 x_1 Math
xii x_{i} Math
// \frac{<1>}{<2>}<0> Math
sin \sin Math, without an existing backslash
lrp \left( <1>\right)<0> Math
Xmbb \mathbb{X} Math; postfix formatting
== & = An alignment environment
beg A linked \begin{...} / \end{...} pair Start of a line
cf + Ctrl-j \cite[<1>]{<2>}<0> Text
bmat_2x_2r + Ctrl-j A two-by-two bmatrix Start of a line in math

For postfix formatting, the command comes after the object: Xmbb wraps X in \mathbb{...}. There are similar triggers for calligraphic and script letters, hats, and overlines. These patterns match a limited run of characters, so they are best suited to short objects.

Several snippets can also wrap a visual selection. With cut_selection_keys above, select a+b in an existing formula and press Tab. LuaSnip cuts and remembers the selection and enters Insert mode. Typing // then uses a+b as the numerator, ready for a denominator.

At the start of a line, beg creates a generic environment. Edit the opening name and LuaSnip updates the closing name for you. The bal trigger creates an align* environment; Ctrl-l in its first field cycles through align*, align, and aligned. Use aligned inside an existing math environment.

Inside display math, on a fresh line, bmat_2x_2r followed by Ctrl-j generates four matrix cells. The initial letter chooses the delimiters: b for brackets, p for parentheses, and so on. The two numbers specify rows and columns; the final r selects the ordinary matrix form. Likewise, cases creates two rows automatically, and 3cases creates three rows. Ctrl-j moves through their fields.

The collection reflects my own LaTeX preamble. For example, beq inserts an equation with \zlabel{eq:...}, and zref inserts \zcref{...}; these use zref-clever. The thm, lem, and related triggers assume that the corresponding theorem environments are already defined. Some other snippets emit custom commands such as \abs or \cNE. Add the necessary definitions to your preamble or adapt those snippets to your notation.

The helper require("math-snippets.latex") exposes five conditions:

Condition Meaning
in_math Inside a recognized formula or math environment
in_text The complement of in_math
in_align Math in an alignment, array, matrix, cases, or split environment
in_bullets Text in an itemize, enumerate, or description environment
in_tikzcd Math in a tikzcd environment

The nearest math/text scope wins. For instance, \text{...} inside a formula disables math snippets, but explicit math nested inside that text enables them again. gather and multline count as math, but not as alignment contexts for snippets that insert &.

Comments, verbatim regions, citation keys, labels, and environment names suppress the positive conditions such as in_math. However, in_text only means “not recognized as math”; it can still be true in a comment or when the parser is missing. It is not a general test that the cursor is in prose. There are also intentionally unconditional snippets, including ^^ and __.

The detector recognizes syntax and environment-name families; it does not expand TeX macros. An unfamiliar custom math environment or incomplete syntax may therefore need adjustment. The collection’s README describes the supported contexts and limitations.

Create ~/.config/nvim/luasnippets/tex.lua with this example:

local ls = require("luasnip")
local s, t, i = ls.snippet, ls.text_node, ls.insert_node
local tex = require("math-snippets.latex")

return {}, {
    s({ trig = "mname", name = "named math operator" }, {
        t("\\operatorname{"),
        i(1),
        t("}"),
        i(0),
    }, {
        condition = tex.in_math,
        show_condition = tex.in_math,
    }),
}

The first returned table holds normal snippets; the second holds automatic snippets. Here t inserts fixed text, i(1) creates the editable operator name, and i(0) marks the exit position. condition controls expansion, while show_condition controls whether a completion integration should offer the snippet. After reloading your configuration, mname in math becomes \operatorname{<1>}<0>. See the LuaSnip manual for the other node types and loader options.

Snippets handle structures I type repeatedly. Completion helps with names I want to look up, especially labels and bibliography keys. Here texlab supplies the candidates and blink.cmp displays them.

Save this as lua/plugins/completion.lua:

return {
    {
        "saghen/blink.cmp",
        version = "1.*",
        dependencies = { "L3MON4D3/LuaSnip" },
        opts = {
            keymap = {
                preset = "default",
                ["<C-k>"] = false,
            },
            snippets = { preset = "luasnip" },
            sources = { default = { "lsp", "path", "snippets", "buffer" } },
        },
    },
    {
        "neovim/nvim-lspconfig",
        dependencies = { "saghen/blink.cmp" },
        config = function()
            vim.lsp.config("texlab", {
                settings = {
                    texlab = {
                        build = { onSave = false },
                    },
                },
            })
            vim.lsp.enable("texlab")
        end,
    },
}

This uses Neovim’s vim.lsp.config and vim.lsp.enable APIs, with the server definition provided by nvim-lspconfig. The texlab executable must be installed separately and available on PATH. VimTeX handles continuous compilation here, so texlab’s build-on-save is disabled. The Blink configuration selects LuaSnip as the snippet engine.

With Blink’s default keymap, Ctrl-Space opens the completion menu, Ctrl-n / Ctrl-p select candidates, and Ctrl-y accepts one. We disable Blink’s Ctrl-k binding so it remains available for moving backward in a snippet.

For a citation, type cf in prose and press Ctrl-j. Fill the optional note in \cite[<1>]{<2>}, then press Ctrl-j to reach the key field. Use completion there to choose a bibliography entry known to texlab. The document must reference its bibliography, for example through \bibliography{references} or \addbibresource{references.bib}, according to the bibliography system you use. This citation snippet does not open a Telescope picker.

For an equation reference, the automatic trigger eqref inserts \eqref{eq:<1>}. When you enter its field, the collection asks Blink to show LSP candidates if Blink is available. Blink is optional for the snippet collection itself.

Create a small main.tex:

\documentclass{article}
\usepackage{amsmath,amssymb}
\begin{document}

Let \(x\in\mathbb{R}\).
\[
    \frac{x_1}{x_2}
\]

\end{document}

Open it in Neovim, press \ll, and save to check compilation. Then try entering the inline formula with mk, and a fraction with // inside the display. Use Ctrl-j to move through the fields and \lv to find the result in the PDF. These examples only require the packages already shown in the preamble.

If something does not work, check the layer responsible:

Symptom First check
The PDF does not build Run latexmk -pdf main.tex in a terminal; inspect \le and :VimtexInfo
LuaSnip cannot find the snippets Check :set filetype?, the collection dependency, and the Lua loader
mk works but math snippets do not In a TeX buffer, run :lua vim.treesitter.get_parser(0, "latex"):parse() and check for a parser error
Automatic snippets never expand Check enable_autosnippets = true
Command triggers such as sin fail Check that make install_jsregexp succeeded
Labels or citations do not complete Run :checkhealth vim.lsp and check that texlab is attached and sees the project
PDF jumps fail Check the viewer’s SyncTeX configuration and :help vimtex-view-skim

Start with a few triggers that match what you write most often. For me, inline math, fractions, subscripts, and environments are enough to make a substantial difference; the rest of the collection can be adapted as needed.

Related Content