Banner of Snippets as Code: LuaSnip, Dynamic Expansion, and File‑Based Snippet Injection

Snippets as Code: LuaSnip, Dynamic Expansion, and File‑Based Snippet Injection


Category: vim

📅 May 15, 2026   |   👁️ Views: 1

Author:   mosaid

Snippets Should Be Logic, Not Just Text

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(''), i(2, '), 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.


← The Toolbelt: Keymaps, User Commands, and File‑Operation Pipelines Per‑Filetype Engineering: Tailoring Neovim for Python, LaTeX, HTML, and Markdown →