Markdown & Math in Neovim
I’ve recently started doing all my development work and note-taking in Neovim. It’s been great so far, but I’ve really missed my setup for Fast Typesetting in Markdown with Obsidian LaTeX Suite. With summer classes starting today, I decided to stop procrastinating and finally get my snippets up and running in Neovim. But the process wasn’t as easy as I hoped. In this blog post, I’ll give a brief overview of the problems I faced and how I eventually overcame them.
Introduction
I take all of my course notes in Markdown. I’m a mathematics major, so these notes contain a lot of formulas and equations. To typeset these equations, I inline LaTeX into my Markdown files. In the raw Markdown text files, equations look like this:
$$
5x^2 + 3x + \int_{0}^{\infty} \frac{1}{x}\,dx
$$But when rendered, the equations look like this:
$$ 5x^2 + 3x + \int_{0}^{\infty} \frac{1}{x}\,dx $$
Inlining LaTeX in Markdown has a lot of advantages. First, Markdown is much more pleasant to write than LaTeX. So, by using LaTeX in Markdown (instead of straight LaTeX) you get the lightweight headers, styles and code blocks from Markdown instead of their heavier LaTeX equivalents. Markdown is also widely used by static site generators, GitHub, and communication tools (e.g. Slack, Discord), so using Markdown makes it easy to share work.
But a LaTeX in Markdown setup also comes with a lot of technical challenges:
- The user needs a way to preview the rendered equations.
- Special syntax highlighting rules must be set up for inline LaTeX code.
- Snippets (like these ones I set up in Obsidian) need to be aware of when the user is editing regular Markdown text and when the user is editing LaTeX in Markdown.
Part 1: Previewing
I am using selimacerbas/markdown-preview.nvim with the default configuration to render Markdown and LaTeX in my browser. I prefer this plugin to the other Markdown preview plugins because it doesn’t require any external dependencies like NodeJS.
Part 2: Syntax Highlighting
Unlike Vim, Neovim has built-in support for
treesitter. However, we still need a way to manage which treesitter
parsers are installed and a way to enable/disable them. For this, I use
romus204/tree-sitter-manager.nvim.
I’ve configured <leader>tm to open the TUI using the
following Lua code:
vim.keymap.set(
"n",
"<leader>tm",
":TSManager<CR>",
{ desc = "Open tree-sitter-manager" }
)In order to get proper syntax highlighting for both Markdown and inline LaTeX, the treesitter grammars for both Markdown and LaTeX must be installed. While this could be done through the tree-sitter-manager TUI, I have opted to configure tree-sitter-manager to guarantee that the Markdown and latex parsers are installed:
require("tree-sitter-manager").setup({
ensure_installed = {
"markdown",
"latex"
}
})In order to confirm that both treesitter grammars are working, you
can open a new Markdown file and add inline LaTeX
(e.g. $x^2$). Then, run :InspectTree. If the
LaTeX parser is working, you should see a node called
inline_formula.
Part 3: Snippets
A snippet is simply a sequence of keystrokes that is automatically
expanded by Neovim. For example, when I type int (as in
“integral”) within a LaTeX block, I have a snippet to insert the full
LaTeX integral command with bounds:
\int_{<>}^{<>} <>. The
<> characters are tabstops – when I press tab, Neovim
moves my cursor to the next tabstop.
I use L3MON4D3/LuaSnip to
manage my snippets (documentation).
This plugin is very powerful, but more complicated than a plugin like SirVer/UltiSnips, which
defines snippets using simple .snippets files. The snippet
engine (LuaSnip or UltiSnips) also needs a completion engine. I’ve been
using Saghen/blink.cmp
with the default configuration.
Detecting Math Mode
When I’m editing Markdown, I only want my math-related snippets to
activate when I’m editing LaTeX. This is especially important because I
make extensive use of LuaSnip’s autosnippets feature, which expands
snippets immediately after the trigger is typed. So, if I didn’t have a
mechanism to conditionally expand snippets, the text
\int_{}^{} would be added to my prose every time I typed a
word starting with “int”, for example.
To check if I’m in a LaTeX block, I use the following Lua function:
local function in_math()
-- Get the treesitter node under the cursor
local node = vim.treesitter.get_node({ignore_injections = false})
if not node then
return false
end
-- This is a recursive function that traverses up through the AST
while node do
local t = node:type()
-- The node type inline_formula means we are in inline LaTeX ($...$)
-- The node type displayed_equation means we are in display LaTeX ($$...$$)
if t == "inline_formula" or t == "displayed_equation" then
return true
end
-- The text_mode node type means we are inside LaTeX but inside a \text{}
-- tag. We want to be able to type freely, so we disable snippets
if t == "text_mode" then
return false
end
node = node:parent()
end
return false
endShorthand for Simpler Snippets
Another thing that I’ve noticed about the LuaSnip syntax is that it
tends to be quite verbose. For my needs, I just need to define the
snippet trigger, the tabstops, and in exceptional cases, the snippet
priority (so that if one snippet is a substring of another, I can
control which one activates). To help make my snippet writing experience
more straightforward, I wrote three helper functions: _m
(math-mode autosnippet), _a (autosnippet), and
_r (regular snippet).
local function md_snippet(trig, type, str, ins, cond, prio)
prio = prio or 0
local nodes = {}
for idx, val in ipairs(ins) do
nodes[idx] = i(val, "")
end
opts = { trig = trig, snippetType = type, wordTrig = false, priority = prio }
return s(opts, fmta(str, nodes), cond)
end
local function _m(trig, str, ins, prio)
return md_snippet(trig, "autosnippet", str, ins, { condition = in_math }, prio)
end
local function _a(trig, str, ins)
return md_snippet(trig, "autosnippet", str, ins, {})
end
local function _r(trig, str, ins)
return md_snippet(trig, "snippet", str, ins, {})
endThese helper functions make my snippet definitions much more concise. For example,
-- create a markdown table
_r("table", "| <> | <> |\n|--|--|\n| <> | <> |", {1, 2, 3, 4}),
-- automatically expand a display math environment
_a("dm", "$$\n<>\n$$\n", {1}),
-- write an integral
_m("int", "\\int_{<>}^{<>} <>\\,d", {1, 2, 3}),Conclusion
I hope you found this interesting and helpful! See my dotfiles for the complete setup.