- Rust 71.4%
- Lua 26.7%
- Nix 1.9%
|
All checks were successful
Test / test (push) Successful in 21m50s
The plugin's closure is 274 MiB instead of 1.13 GiB, and building it no longer fetches mupdf and SDL. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|---|---|---|
| .forgejo/workflows | ||
| bench | ||
| dev | ||
| docs | ||
| examples | ||
| lua/tex | ||
| nix | ||
| plugin | ||
| src | ||
| tests | ||
| .envrc | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| devenv.lock | ||
| devenv.nix | ||
| devenv.yaml | ||
| flake.lock | ||
| flake.nix | ||
| LICENSE | ||
| README.md | ||
| stylua.toml | ||
tex.nvim
Write LaTeX in Neovim and see the page as you type. Every keystroke is
typeset at once — only from where you typed, with
TeXpresso's XeTeX — and the page
you're on is redrawn next to your source, with what changed since your last
save marked. Errors and warnings show up as diagnostics; jump from the
source to the page and back with SyncTeX. :Tex build writes the PDF with
Tectonic.
Warning
This project is vibe-coded. I need LaTeX that works, every day, in my own editor — and I don't have the time to implement it, maintain it and fix its issues myself. Vibe-coding is what makes it exist at all. If that's something that's triggering you, then in words of Edsel Dope: I don't give a fuck about you, or your point of view, or your stupid selfish attittude. Cause it seems, to be, that you never really gave a fuck about me.
What you get
- The page as you type. The editor's text is typeset as it changes, saved or not: a key typed shows up on the page in about 20 ms, on a 10-page document as on a 100-page one (see Performance). TeX doesn't start over: it goes on from just before what changed.
:Tex buildwrites the PDF with tectonic, next to the main file (or inbuild/for aTectonic.tomlproject, built withtectonic -X build).- Diagnostics from the build: TeX errors, overfull and underfull boxes,
and LaTeX warnings such as undefined references and citations, each on the
file and line it comes from — included files too. A problem in an
included file also shows on the
\input(\include,\subfile,\import…) line that pulls it in, all the way up to the main file. - A preview of the pages in a vertical split, drawn to fill the window,
shown while you're in a TeX file (and hidden when you're not), on the page
you're typing on, with how long the last change took to typeset and draw
in its bar, in your colour scheme's colours (
c: as printed).j/k(andh/l) change pages. Search its text with/, and follow its links (click, or<Tab>and<CR>). - The log in the preview when you save a document with errors: the
problems (
<CR>goes to one) and what TeX printed. - Changes marked. The lines that changed since your last save are tinted, with a bar in the margin, and removed lines get a marker. Text that only moved down the page isn't marked.
- SyncTeX both ways. The preview follows your cursor: it shows the line
you're on; a whole environment on its
\beginor\end; where a file begins on its\inputor\includeline; and, in a.bibfile, the entry under the cursor in the bibliography. Double-click (or<CR>) in the preview to jump to the source. - References and citations settle by themselves: the
.auxis read back, and BibTeX runs when the citations or the.bibfiles change (as they are in the editor). - texlab next to it, set up for you when it's installed (recommended): completion of labels, citations, commands, environments and file names, hover, go to definition, references, rename, symbols and formatting (with tex-fmt when it's installed). tex.nvim leaves all that to texlab. TeX buffers are indented with 2 spaces.
- Progress of
:Tex buildthrough the language server, so fidget.nvim and other progress plugins show it, a spinner in the preview, and a statusline component.
Screenshots
Taken in Rio with Neovim 0.13 and the
ayu colorscheme — the dev setup
of this repository, recorded by media (see Development).
Getting started
You need Neovim 0.12 or newer (0.13 draws the preview with the built-in
vim.ui.img), TeXpresso's texpresso-xetex (it typesets), tectonic
(its packages, BibTeX and :Tex build), Linux or macOS, and a terminal that
shows images with the kitty graphics protocol (kitty, Ghostty, Rio,
WezTerm). The language server is written in Rust and built with the plugin.
If you install with Nix, all of that except the terminal is included.
We recommend installing texlab too (and tex-fmt, which it formats with): tex.nvim registers and enables it by itself, with settings that fit (see texlab), so completion, hover, rename and formatting work without more configuration.
With Nix (flake):
{
inputs.tex-nvim.url = "git+https://forge.wvffle.net/wvffle/tex.nvim";
# Either add the overlay and use pkgs.vimPlugins.tex-nvim …
nixpkgs.overlays = [ inputs.tex-nvim.overlays.default ];
# … or add the package directly, e.g. with home-manager
programs.neovim.plugins = [
inputs.tex-nvim.packages.${pkgs.stdenv.hostPlatform.system}.default
];
}
With nixvim:
{
imports = [ inputs.tex-nvim.nixvimModules.default ];
plugins.tex = {
enable = true;
# passed to require("tex").setup(), see Configuration
settings = {
indent = 4;
outdir = "build";
};
# texlab and tex-fmt are installed too; `texlab = false;` leaves them out
};
}
With lazy.nvim (needs cargo to build the server):
{
url = "https://forge.wvffle.net/wvffle/tex.nvim",
build = "cargo build --release",
ft = { "tex", "plaintex", "bib" },
opts = {},
}
Then open a .tex file and the preview (<localleader>lp). Run
:checkhealth tex if something doesn't work — it checks the server,
texpresso-xetex, tectonic, texlab and image support.
Using it
The keys below are local to TeX buffers and to the preview, and can be
changed (see Configuration). <localleader> is your
maplocalleader, \ unless you've set it.
In the source
| key | what it does |
|---|---|
<localleader>lb |
build now (saving builds too) |
<localleader>lp |
open / close the preview |
<localleader>lf |
show the cursor's line in the preview |
With texlab, the usual language server keys work too: completion, K,
grn (rename everywhere), grr (where it's used), <C-]> (where it's
defined), gO (symbols).
In the preview
| key | what it does |
|---|---|
j / l |
next page |
k / h |
previous page |
gg / G |
first / last page |
]c / [c |
next / previous page with changes |
<CR>, double-click |
go to the source of what's under the cursor / mouse |
| click | follow the link under the mouse |
<Tab> / <S-Tab> |
select the next / previous link on the page (<CR> follows it) |
<C-o> |
back to where you followed a link from |
/ |
search the text of the PDF |
n / N |
next / previous match |
<Esc> |
clear the search and the selected link |
+ / - / = |
zoom in / out / fit the page again |
w |
fit the page width (scroll for the rest) / the whole page |
<C-d> / <C-u> |
scroll; at the bottom or top, go to the next or previous page |
d |
show / hide changes |
p |
pin: compare against the version shown now until you unpin |
c |
the page in your colour scheme's colours / as printed |
e |
the build log / the PDF (after a failed build) |
q |
close |
The bar above the preview shows the page, the last build (✓ 1.2s,
⠹ building, ✗ 2 errors), the pages with changes (Δ 3,4), whether the
comparison is pinned, the search (/needle 2/5) and where the selected link
goes.
When a build fails, the preview shows what went wrong instead of the page:
the problems first (<CR> on one goes there), then tectonic's output. e
switches to the page and back; saving the fixed document brings the page
back.
Search is case-insensitive unless the search has capitals, and finds text across line breaks and hyphenated words.
The page is drawn in your colour scheme's colours: the paper in Normal's
background and the ink in its foreground, exactly; other colours keep their
hue. c (or :Tex colors) shows it as printed and back. To use other
colours, give them: preview.page_colors = { bg = "#0c0e13" }. Some
terminals draw text cells a shade off from the colour asked for (Rio, for
instance), while images are shown as they are: the page then looks a shade
off from the editor around it, and giving the shade the terminal shows
matches them.
The preview follows you: it shows the page with the line under your cursor,
marked in blue, as you move (preview.follow_cursor) and after each save
(preview.forward_on_save). On an \input or \include line it shows where
that file begins, on \begin{…} or \end{…} all of the environment, on
\bibliography the bibliography, and in a .bib file the entry the cursor
is in (when it's cited). It closes when the last window showing the document
closes.
Changes
The page is compared with what it was when you last saved (or when the
document was first typeset), so the marks show what you changed since. To see everything that changed since some point instead,
pin the comparison with p (or :Tex pin) and unpin it when you're
done.
The comparison works on the rendered page, line by line: text that moved down because something was inserted above it isn't marked, a changed word in an otherwise unchanged line is marked on its own, and pages whose content didn't change at all are skipped.
Commands
:Tex <command>: build, preview, close, toggle, forward,
diff, colors, log, search [text], pin, unpin, format (texlab)
and info.
texlab
tex.nvim typesets, previews, reports TeX's errors and builds; everything a
language server does with the source itself is texlab's. When texlab is
installed, tex.nvim registers it with vim.lsp.config("texlab") and enables
it, with these settings:
- formatting with tex-fmt (
latexFormatterandbibtexFormatter), whentex-fmtis installed; atex-fmt.tomlnext to your document (or in a parent directory, or~/.config/tex-fmt/tex-fmt.toml) sets how it formats, e.g.tabsize = 4(texlab doesn't pass the buffer's indent on); - no build of its own (
build.onSave = false,forwardSearchAfter = false): tex.nvim typesets as you type and:Tex buildwrites the PDF.
Settings you give texlab yourself, before or after setup() (or that
nvim-lspconfig gives it), win. texlab = false leaves texlab alone; a table
is more configuration for it, e.g. texlab = { settings = { texlab = { chktex = { onEdit = true } } } }.
vim.lsp.buf.format() (or :Tex format) formats with texlab, and so does
conform.nvim when it falls back to language servers
(default_format_opts = { lsp_format = "fallback" }).
Configuration
Call setup only for what you want to change. These are the defaults:
require("tex").setup({
server = nil, -- the tex-nvim server (Nix and `cargo build` set this up)
engine = nil, -- texpresso-xetex (default: on $PATH; Nix sets it up)
tectonic = nil, -- the tectonic executable (default: tectonic on $PATH)
fsync = nil, -- false: don't fsync TeX files on write (see Good to know)
fast_bundle = true, -- :Tex build reads tectonic's cached files directly (~70 ms a build)
cache_dir = nil, -- where :Tex build and BibTeX keep their files (default: stdpath("cache")/tex.nvim)
extra_args = {}, -- more arguments for tectonic -X compile / -X build (:Tex build)
outdir = nil, -- where :Tex build puts PDFs of single documents, relative to the main file
main = nil, -- main file of single documents (default: detected, see below)
output = nil, -- which [[output]] of Tectonic.toml to build (default: the first PDF)
keep_logs = true, -- keep tectonic's .log, for LaTeX warnings
enable_synctex = true, -- add `synctex = true` to Tectonic.toml when it's missing
filetypes = { "tex", "plaintex", "bib" },
indent = 2, -- spaces in TeX buffers (false: leave your settings)
texlab = true, -- register and enable texlab when it's installed (see texlab)
keymaps = { -- set any of these to false to turn it off
build = "<localleader>lb",
preview = "<localleader>lp",
forward = "<localleader>lf",
},
preview = {
width = 0.5, -- columns, or a fraction of the editor width
fit = "page", -- "page" or "width"
diff = true, -- mark what changed since the last save
auto_open = true, -- shown while you're in a TeX file, hidden when not (until you close it)
forward_on_save = true, -- after a save, show the cursor's line
follow_cursor = true, -- … and as the cursor moves (an \input line: where that file begins)
focus = false, -- move into the preview when it opens
page_colors = "theme", -- "theme": Normal's colours, "original": as printed, or { bg = …, fg = … }
show_log = true, -- show the log when you save a document with errors
backend = "auto", -- "auto", "ui.img" or "kitty"
colors = {
added = "#3fb950",
removed = "#f85149",
mark = "#58a6ff", -- the cursor's line, the selected link, the current match
search = "#d29922", -- the other matches
alpha = 0.22, -- opacity of the tint over changed lines
},
},
preview_keymaps = { -- a key, a list of keys, or false
next_page = { "j", "l" },
prev_page = { "k", "h" },
first_page = "gg",
last_page = "G",
next_change = "]c",
prev_change = "[c",
zoom_in = "+",
zoom_out = "-",
zoom_reset = "=",
fit_width = "w",
scroll_down = "<C-d>",
scroll_up = "<C-u>",
toggle_diff = "d",
pin = "p",
colors = "c",
log = "e",
search = "/",
next_match = "n",
prev_match = "N",
clear = "<Esc>",
next_link = "<Tab>",
prev_link = "<S-Tab>",
back = "<C-o>",
backward = { "<CR>", "<2-LeftMouse>" }, -- follows a selected link first
click = "<LeftMouse>",
close = "q",
},
})
The server is registered with vim.lsp.config() under the name tex,
so you can also change how it starts there, e.g.
vim.lsp.config("tex", { root_markers = { ".git" } }).
To show the state of the document in your statusline:
vim.o.statusline = "%f %m%=%{v:lua.require'tex'.status()} "
-- shows e.g. "✓ 12 ms · 3 warnings", "⟳ typesetting", "✗ 1 error"
Good to know
Main files. Without a Tectonic.toml, the document a file belongs to is
the file itself if it has a \documentclass; the file named in a
% !TEX root = ../main.tex comment at its top; or a .tex file with a
\documentclass in the same directory (or up to two above) that mentions it.
Set main to choose yourself.
SyncTeX in Tectonic.toml projects works in the preview always; for the
PDF :Tex build writes, it has to be turned on in the project, with
synctex = true in the [[output]]. When it isn't there, the first build
adds it (and says so); the rest of the file is left as it is. Set
enable_synctex = false to keep your file untouched.
Images need a terminal that speaks the kitty graphics protocol. Inside
tmux, enable set -g allow-passthrough on. Other terminals get a message in
the preview instead. A floating window over the preview hides the image
while it's there (with vim.ui.img), so the float stays readable.
Errors don't stop the page. TeX goes on after an error, as with
-interaction=nonstopmode, so the page keeps updating while what you're
typing is half-written; the errors are diagnostics. Saving a document with
errors shows the log in the preview (e goes back to the page).
Saving doesn't typeset anything — that happened as you typed — it only
marks the changes from there on, and shows the cursor's line (or the log).
By default Neovim fsyncs the file it writes, which takes 10–25 ms on some
file systems (btrfs); fsync = false skips that for TeX files only (your
'fsync' is put back right after the write), at the price of what fsync
protects against: losing the file in a power cut right after saving.
Where the files go. Nowhere, while you type: TeX reads the editor's
text through the server, and what it writes (.aux, .log, the pages)
stays in memory. Your directory only gets the PDF and its SyncTeX file, when
you run :Tex build: next to the main file, or in build/ for
Tectonic.toml projects.
Packages come from tectonic's bundle. The first time a document uses a package tectonic hasn't downloaded yet, TeX waits while it does.
Completion comes from the language server, so it shows up in whatever
you use for completion: Neovim's own (vim.lsp.completion.enable()),
blink.cmp, nvim-cmp… Labels and citation keys are collected from the whole
document — the files the main file includes, and its .bib files — using
the text of open buffers, so new labels can be referenced before saving.
How it works
Neovim tex-nvim (Rust)
┌────────────────────────┐ LSP (stdio) ┌─────────────────────────────────┐
│ .tex buffers │ ── didChange ──▶ │ session: texpresso-xetex, │
│ diagnostics │ ◀─────────────── │ snapshots, rollback │
│ │ │ XDV page → image (vello_cpu) │
│ preview split │ tex/render ────▶ │ + diff with the saved version │
│ kitty placeholders / │ ◀─ buildFinished │ SyncTeX, search, links │
│ vim.ui.img │ │ tectonic (:Tex build) │
└────────────────────────┘ └─────────────────────────────────┘
- The server is a language server written in Rust (
src/). Neovim starts it like any other (vim.lsp.enable("tex")), and sends it the text of every change. - TeX runs as you type, from where you typed. Each document has a
session running TeXpresso's XeTeX (
texpresso-xetex), which reads every file through the server and writes everything to it (src/engine, a port of TeXpresso's own driver): the server serves the editor's text, and keeps the pages, the log and the.auxin memory. When asked, the engine forks: the parent stops where it is — a snapshot — and the child goes on. The server knows how far each snapshot had read each file, so a change goes on from the last snapshot that hadn't read the changed bytes yet. A change on page 50 doesn't typeset pages 1 to 49 again. - Snapshots are placed for typing. A fork costs a few milliseconds (the
engine maps ~150 MB), so there's none on the way to the page you're
waiting for, except one right before the change — the next key there
starts from it. There are snapshots before the main file and at
\begin{document}(a change in the body never goes through the preamble again), a few spaced in time over the rest, and when the cursor moves to another line, the server makes one right before it while you're not typing, so the first key there is as quick as the next ones. - The page you're on comes first. As soon as the page the preview shows
is typeset again, it's drawn and sent to Neovim; the rest of the document
follows (the preview keeps the previous version of the pages after it
meanwhile). Then the
.aux,.toc& co. are read back, BibTeX runs if the citations changed, and TeX goes on from where they were read until they settle; the diagnostics come from the log. - Pages are drawn from the XDV — what XeTeX writes, before it becomes a
PDF — with vello_cpu: glyph runs of
OpenType fonts, the Computer Modern & co. math fonts (TFM metrics and
Type1 outlines through
pdftex.map), rules, colours — and graphics: XeTeX's drivers write them as dvipdfmx specials, which the server follows like dvipdfmx would — PDF drawing operators (TikZ, pgf), with their transformations, clips, opacity and shadings, and images (\includegraphicsof PNG, JPEG and PDF files; anything else shows as a grey box). A page of text is drawn in about 2 ms; it's drawn once at the window's size and kept, so scrolling and zooming only crop it again. The image is padded to whole terminal cells so the terminal never stretches it. - The diff splits both renders of a page into blocks of ink (usually
lines of text) and lines them up, like
diffdoes with lines. Blocks are compared by their column profile (ink per column), which barely changes when a line moves by a fraction of a pixel. Unmatched blocks are marked; matched blocks at the same place are compared column by column to mark just the changed words. Which pages changed comes from the pages' XDV, without drawing them. - SyncTeX data comes from the engine too: forward search finds the boxes typeset from a line (or the nearest line after it), backward search the innermost line box under the click, and the closest position in it.
- Search reads the text back from the glyphs (the fonts' character maps and glyph names); TeX doesn't draw spaces, so they're put back from the gaps between glyphs, and a hyphen at the end of a line joins the word across it. Links are hyperref's annotations and destinations, read from the XDV.
:Tex buildruns tectonic in a mirror of the project where the files open in Neovim are as they are in the editor, with its downloaded files read straight from its cache and a reduced font map (both save tectonic time on every run).- texlab runs next to it (completion, hover, rename, formatting…); the two don't offer the same things.
- Images are drawn with
vim.ui.imgon Neovim 0.13, or with kitty's Unicode placeholders on 0.12.
Performance
bench (nvim --headless --clean -l bench/run.lua [pages]) generates
documents (a section with text, an equation and a list per page), opens them
with the plugin and times what you do, through the real server. It runs
Neovim with a 240x70 UI attached, so the preview is 120 columns wide (cells
of 9x19 px: an image of about 1080x1250 px) and images really go out to the
UI, as they would to a terminal; the terminal decoding them isn't included.
Times are medians, measured on a laptop (Ryzen 5 7640U, on mains power, the
"balanced" profile, a browser running) with Neovim 0.13. The page is drawn in
the colour scheme's colours, as by default.
| 10 pages | 100 pages | |
|---|---|---|
| keystroke → page on screen (same line) | 24.3 ms | 35.1 ms |
| keystroke → page typeset (same line) | 10.1 ms | 22.0 ms |
| keystroke → page on screen (first key elsewhere) | 28.3 ms | 41.3 ms |
| keystroke → page typeset (first key elsewhere) | 5.8 ms | 21.6 ms |
| typeset from scratch | 258.8 ms | 659.9 ms |
| open the preview | 7.7 ms | 6.0 ms |
| next page (not rendered yet) | 8.0 ms | 7.4 ms |
| previous page (rendered before) | 6.1 ms | 5.7 ms |
| scroll (fit width) | 6.1 ms | 6.2 ms |
| :Tex build (tectonic, the whole PDF) | 198.1 ms | 391.1 ms |
| forward search | 0.3 ms | 1.3 ms |
| backward search | 0.1 ms | 0.1 ms |
The keystroke rows time a key typed in the middle of the document, with the preview on that page, until the new page is on screen with the changes marked — typesetting included; the "typeset" rows stop when the server has the page. A key that changes nothing on the page (a space at the end of a line) isn't counted: there's nothing to show. "Same line" is typing one key after another; "first key elsewhere" is the first key after the cursor moved to another page (and the preview followed). The size of the document doesn't matter: only what comes after the change, up to the end of its page, is typeset. Before this, a save cost a tectonic build and then a render: 188 ms (10 pages) and 399 ms (100 pages), and 517 ms and 897 ms before that work began.
What keeps these low:
- TeX goes on from just before the change (see How it works), and stops caring about the rest of the document until the page you're on is shown.
- No fork on the way. A snapshot costs a fork of a ~150 MB process (a few ms): the page you wait for gets at most one, right before the change. A rollback kills the snapshot's child first and the others once TeX runs again: killing them all at once made the resumed one wait ~35 ms for its child to exit.
- Ready where the cursor is. When the cursor moves to another line, the server gets TeX there while you're not typing, and leaves it waiting: the first key goes on from there without a rollback.
- Pages are drawn from the XDV, without making a PDF (~2 ms a page).
- Pages are rendered once per size. Scrolling and zooming back crop a cached page; the comparison of a page with its previous version is cached too. While the preview is idle, the pages before and after the one shown are rendered ahead.
- Rendering uses a few threads, with one rendering context kept for the life of the server (starting one is what costs most).
- Little to send. The PNG is encoded with a fast filter and comes with the notification; Neovim sends it to the terminal in one write and only points the placeholder text at the new image.
What was tried and left out: a precompiled preamble (a format with the document's preamble dumped) can't work with XeTeX, which can't dump the OpenType fonts LaTeX loads in the preamble — and starting XeTeX and loading its format, not the preamble, was most of a build anyway; TeXpresso's own snapshot policy (a fork every page or so, 16 fences behind a change) spent most of its time forking.
To profile the server, run bench with
TEX_NVIM_SERVER=<a wrapper running perf record … target/release/tex-nvim>;
the render results carry a breakdown of their time (timings).
TEX_NVIM_ENGINE_LOG=<file> keeps what the engine prints.
Development
The development environment comes from devenv: run
devenv shell --no-tui or direnv allow. It has Neovim 0.13 nightly (and
0.12 as nvim-stable), Rust, texpresso-xetex, tectonic, texlab, tex-fmt,
the formatters, the LaTeX treesitter parser and the ayu colorscheme.
dev [file] # build the server, open the manual (copied to dev/workdir) with the dev config
unit [filter] # Rust tests, then the Neovim tests on Neovim nightly
unit-stable # … the Neovim tests on Neovim 0.12
bench [pages] # time typing, the preview, SyncTeX and formatting (default: 10,100 pages)
media # record the demo and the screenshots (Rio, in a headless compositor)
nix build # build the plugin
nix flake check # build it, with the Rust tests
dev copies examples/ to dev/workdir/ (examples it hasn't copied yet),
so you can edit there without touching the repository; rm -rf dev/workdir
to start over. It opens examples/manual: this plugin's manual, as a LaTeX
project that uses what it describes (chapters \included from the main
file, a bibliography, a screenshot, TikZ pictures, tables).
examples/project is a Tectonic.toml project.
media runs Rio (with its config in docs/rio.toml: no title or tab bar)
in cage with a headless 2560x1440
output at scale 2 (sharp when zoomed; nothing shows on your screen), where
Neovim plays
docs/record.lua on a copy of examples/demo: it types, saves, flips
pages, searches… and takes the screenshots (grim) and the recording
(wf-recorder, lossless), which end up in docs/screenshots as lossless
WebP.
The Neovim tests are plain Lua files in tests/, run against a real server
and tectonic. Tectonic downloads its bundle on first use, so they need the
network. CI runs the format checks and both test suites on every push.








