Most snippet systems treat expansions as static templates: you type a trigger, you get a fixed
block of text. LuaSnip in Neovim allows something far more powerful – snippets can run Lua
functions, read external files, and adapt to the current file or directory. This turns snippet
expansion into a programmable, context‑aware automation layer.
In this article we open the two snippet configuration files that power LaTeX, HTML, and Markdown
editing: lua/config/tex_snippets.lua and lua/config/html_snippets.lua.
We’ll examine how they replace a legacy directory of static snippet files, how they integrate
with nvim-cmp, and how they enable everything from auto‑numbered exercises to
inline HTML figure injection.
The Legacy Problem
Before LuaSnip, Neovim relied on snippet engines like UltiSnips or external files stored in
~/.vim/snippets/. Those files were plain text, loaded once, and had no runtime
intelligence. This configuration migrated to LuaSnip for three reasons:
Reproducibility: everything lives in Lua, no external directories to sync.
Dynamic behaviour: snippets can count existing environments, query the current
directory, and insert content based on context.
Integration with native completion: LuaSnip plugs directly into
nvim-cmp as a source, so snippets appear in the autocompletion menu.
The old snippet files still exist (in ~/.vim/snippets/latex/) but are now consumed
by LuaSnip functions that read their content on demand, preserving the original text blocks
while adding programmability.
The html_snippets.lua: Straightforward but Complete
HTML and Markdown share three core snippets: anchor links, figure/image blocks, and code blocks.
Each is defined using LuaSnip’s node types: s() for the snippet, i()
for insert‑node placeholders, t() for text, and f() for function nodes
that compute values at expansion time.
The code block snippet is particularly clever: it uses a function node to duplicate the language
name so it appears both in the <pre class="language-…"> and the
<code class="language-…"> tags, avoiding manual repetition. The snippet also
wraps the body in … tags – a nod to Jinja2 templates used in
static site generation – to prevent Liquid/Jinja syntax from being parsed inside code blocks.
local ls = require("luasnip")
local s = ls.snippet
local i = ls.insert_node
local t = ls.text_node
local f = ls.function_node
-- Define snippets once
local snippets = {
-- Anchor link
s("a", {
t(''), i(2, "link text"), t(""), i(0),
}),
-- Figure with img and figcaption
s("fig", {
t(""),
t({ "", " " }),
t(''),
t({ "", " " }),
t(""), i(3, "caption"), t(""),
t({ "", "" }),
i(0),
}),
-- Enable for both HTML and Markdown files
ls.add_snippets("html", snippets)
ls.add_snippets("markdown", snippets)
The tex_snippets.lua: A Snippet Engine for LaTeX
LaTeX editing demands a much richer set of snippets. The tex_snippets.lua
file exports two things: a table of functions for direct insertion (called by the LaTeX
ftplugin), and a collection of LuaSnip snippet definitions. It uses three design patterns:
Environment snippets with auto‑closing:env_snip() creates a
snippet that inserts \begin{…} and \end{…} with the
environment name mirrored via a function node.
Auto‑numbered exercises: the exe snippet counts how many
\begin{exe} environments already exist in the buffer and inserts the next
number automatically.
External file injection:file_snippet() reads a file from
~/.vim/snippets/latex/ and inserts its content as the snippet body,
bridging the legacy snippet directory.
The curr_file_base_name() helper reads the numeric prefix of the current working
directory, used by cse and similar snippets to auto‑label sections (e.g.,
\csection[01-exer]{…}). This is a deterministic system: the same directory
always produces the same numbering.
local ls = require("luasnip")
local s = ls.snippet
local i = ls.insert_node
local t = ls.text_node
local f = ls.function_node
local M = {}
-- Helper to create environment snippet with jumpable placeholder
local function env_snip(trigger, default_env)
return s(trigger, {
t({"\\begin{"}), i(1, default_env), t({"}", " <++>"}), -- insert <++> as literal text
t({"", "\\end{"}), f(function(args) return args[1][1] end, {1}), t("}")
})
end
-- Helper to get the first part of current directory name
local function curr_file_base_name()
local cwd = vim.fn.system("basename $(pwd) | cut -d'-' -f1")
return cwd:gsub("\n", "")
end
-- =============================
-- Native LuaSnip LaTeX snippets
-- =============================
ls.add_snippets("tex", {
env_snip("beg", "tabular"),
env_snip("eq", "equation"),
env_snip("mat", "bmatrix"),
-- Sections
s("sec", { t("\\section{"), i(1,"Title"), t({"", " <++>"}), t("}") }),
s("ssec", { t("\\subsection{"), i(1,"Subtitle"), t({"", " <++>"}), t("}") }),
-- Items and math
s("itm", { t("\\item "), t("<++>") }),
s("dollar", { t("$"), t("<++>"), t("$") }),
s("frac", {
t("\\frac{"), i(1), t("}{"), i(2), t("}"), i(0)
}),
s("sum", { t("\\sum_{"), t("<++>"), t("}^{"), t("<++>"), t("} "), t("<++>") }),
-- =============================
-- Exercise environment with auto-number
-- =============================
s("exe", {
t("\\begin{exe}{"), i(1, "<++>"), t("}"),
t({"", " "}), f(function()
local count = 0
for _, line in ipairs(vim.api.nvim_buf_get_lines(0, 0, -1, false)) do
if line:match("\\begin{exe}") then count = count + 1 end
end
return tostring(count)
end, {}),
t({"", " <++>", "\\end{exe}"}),
}),
-- Sexe environment
s("sex", {
t({"\\begin{sexe}{", ""}), i(1, "<++>"), t({"", " <++>", "\\end{sexe}"})
}),
-- Minipage snippet
s("mini", {
t({"\\begin{minipage}[t]{"}), i(1, "<++>"), t("\\textwidth}"), t({"", " <++>", "\\end{minipage}", "\\hspace*{0.1cm}", "\\vl{0}{5}", "\\hspace*{0.1cm}", "\\begin{minipage}[t]{"}),
i(2, "<++>"), t("\\textwidth}"), t({"", " <++>", "\\end{minipage}"})
}),
-- Standalone document
s("stand", {
t({"\\documentclass{standalone}", "\\standaloneconfig{margin=5mm}"})
}),
})
-- Section snippets using the directory prefix
ls.add_snippets("tex", {
-- csection
s("cse", {
t({"\\vspace*{0.3cm}", "\\csection["}),
t(curr_file_base_name() .. "-"), i(1, "exer"),
t({"]{"}),
f(function(args) return args[1] end, {1}),
t({"}\\\\", "\\vspace*{0.3cm}"})
}),
-- cssection
s("csse", {
t({"\\vspace*{0.3cm}", "\\cssection["}),
t(curr_file_base_name() .. "-"), i(1, "exer"),
t({"]{"}),
f(function(args) return args[1] end, {1}),
t({"}\\\\", "\\vspace*{0.3cm}"})
}),
-- csssection
s("cssse", {
t({"\\vspace*{0.3cm}", "\\csssection["}),
t(curr_file_base_name() .. "-"), i(1, "exer"),
t({"]{"}),
f(function(args) return args[1] end, {1}),
t({"}\\\\", "\\vspace*{0.3cm}"})
}),
})
-- =============================
-- Helper to load old snippet files dynamically
-- =============================
local function read_file(path)
local lines = {}
for line in io.lines(path) do
table.insert(lines, line)
end
return lines
end
local function file_snippet(trigger, filepath)
return s(trigger, f(function() return read_file(filepath) end, {}))
end
-- =============================
-- Old external snippet files as LuaSnip snippets
-- =============================
ls.add_snippets("tex", {
file_snippet("tcb", "/home/mosaid/.vim/snippets/latex/tcb"),
file_snippet("ds", "/home/mosaid/.vim/snippets/latex/ds"),
file_snippet("st", "/home/mosaid/.vim/snippets/latex/st"),
file_snippet("ar", "/home/mosaid/.vim/snippets/latex/ar"),
file_snippet("gg", "/home/mosaid/.vim/snippets/latex/gg"),
file_snippet("mtab","/home/mosaid/.vim/snippets/latex/mtab"),
file_snippet("d1", "/home/mosaid/.vim/snippets/latex/d1"),
file_snippet("li", "/home/mosaid/.vim/snippets/latex/li"),
file_snippet("tight","/home/mosaid/.vim/snippets/latex/tight"),
file_snippet("enum","/home/mosaid/.vim/snippets/latex/enum"),
})
-- Define the functions that tex.lua is trying to call
M.tcb = function()
local lines = read_file("/home/mosaid/.vim/snippets/latex/tcb")
local cursor = vim.api.nvim_win_get_cursor(0)
vim.api.nvim_buf_set_lines(0, cursor[1] - 1, cursor[1] - 1, false, lines)
end
M.ds = function()
local lines = read_file("/home/mosaid/.vim/snippets/latex/ds")
local cursor = vim.api.nvim_win_get_cursor(0)
vim.api.nvim_buf_set_lines(0, cursor[1] - 1, cursor[1] - 1, false, lines)
end
M.st = function()
local lines = read_file("/home/mosaid/.vim/snippets/latex/st")
local cursor = vim.api.nvim_win_get_cursor(0)
vim.api.nvim_buf_set_lines(0, cursor[1] - 1, cursor[1] - 1, false, lines)
end
M.ar = function()
local lines = read_file("/home/mosaid/.vim/snippets/latex/ar")
local cursor = vim.api.nvim_win_get_cursor(0)
vim.api.nvim_buf_set_lines(0, cursor[1] - 1, cursor[1] - 1, false, lines)
end
M.gg = function()
local lines = read_file("/home/mosaid/.vim/snippets/latex/gg")
local cursor = vim.api.nvim_win_get_cursor(0)
vim.api.nvim_buf_set_lines(0, cursor[1] - 1, cursor[1] - 1, false, lines)
end
M.mtab = function()
local lines = read_file("/home/mosaid/.vim/snippets/latex/mtab")
local cursor = vim.api.nvim_win_get_cursor(0)
vim.api.nvim_buf_set_lines(0, cursor[1] - 1, cursor[1] - 1, false, lines)
end
M.d1 = function()
local lines = read_file("/home/mosaid/.vim/snippets/latex/d1")
local cursor = vim.api.nvim_win_get_cursor(0)
vim.api.nvim_buf_set_lines(0, cursor[1] - 1, cursor[1] - 1, false, lines)
end
M.li = function()
local lines = read_file("/home/mosaid/.vim/snippets/latex/li")
local cursor = vim.api.nvim_win_get_cursor(0)
vim.api.nvim_buf_set_lines(0, cursor[1] - 1, cursor[1] - 1, false, lines)
end
M.tight = function()
local lines = read_file("/home/mosaid/.vim/snippets/latex/tight")
local cursor = vim.api.nvim_win_get_cursor(0)
vim.api.nvim_buf_set_lines(0, cursor[1] - 1, cursor[1] - 1, false, lines)
end
return M
The Dual Nature: Snippets and Functions
Notice that tex_snippets.lua defines both LuaSnip snippets and a set
of functions (M.tcb(), M.ds(), etc.). The LaTeX ftplugin (from
Article #2) calls these functions directly via insert‑mode mappings like
<ESC>:lua require("config.tex_snippets").tcb()<CR>. This hybrid
approach gives the user two ways to trigger an expansion:
Through the built‑in completion menu (LuaSnip integration with nvim-cmp).
Through muscle‑memory insert‑mode shortcuts that bypass the completion menu entirely.
The external file snippets are loaded lazily – they aren’t read from disk until the trigger is
activated, keeping startup fast. The file_snippet() wrapper ensures that the
content stays up‑to‑date with any external edits, making the transition from static snippet files
seamless.
Integration with nvim-cmp
Snippets are useless without a good completion interface. The plugins/core.lua
configures nvim-cmp to use luasnip as a source:
sources = cmp.config.sources({
{ name = 'nvim_lsp' },
{ name = 'luasnip' },
{ name = 'buffer' },
{ name = 'path' },
}),
The luasnip source is automatically populated with all snippets defined for the
current filetype. Additionally, the cmp mapping for <Tab>
prioritises snippet expansion over completion navigation – if a snippet is expandable, it
expands; otherwise it cycles to the next completion item.
This design ensures that snippets feel like a native part of the editor, not an external
bolted‑on feature.
Determinism and Portability
Every snippet in this configuration is defined in Lua, with no external dependencies except the
legacy snippet files (which are themselves plain text). The auto‑numbering relies on scanning
the current buffer, not on any global state. The directory‑based prefix uses a shell command
that is explicitly deterministic.
This means you can clone the Neovim configuration to a new machine, run
:Lazy sync, and immediately have the same snippet behaviour. No manual copying
of snippet directories, no version mismatches.
In the next article, we’ll examine the visual stack: how themes, transparency, and custom
highlights are applied with the same deterministic philosophy.