summaryrefslogtreecommitdiffstats
path: root/lua
diff options
context:
space:
mode:
authorThomas Vanbesien <tvanbesi@proton.me>2026-06-24 20:22:19 +0200
committerThomas Vanbesien <tvanbesi@proton.me>2026-06-24 20:22:19 +0200
commitb393c59ad0e3d85616eeb5be3a12042d6f3dbe63 (patch)
tree0aa4f39aa7ffdd4d7d034dea7626cd2516cdfd02 /lua
parentc30ca8b6556673e836e312b555f9a63216ec9660 (diff)
downloadnvim-config-b393c59ad0e3d85616eeb5be3a12042d6f3dbe63.tar.gz
nvim-config-b393c59ad0e3d85616eeb5be3a12042d6f3dbe63.zip
refactor(nvim): rewrite the snippet plugin
Diffstat (limited to 'lua')
-rw-r--r--lua/dotfiles/snippet.lua194
1 files changed, 194 insertions, 0 deletions
diff --git a/lua/dotfiles/snippet.lua b/lua/dotfiles/snippet.lua
new file mode 100644
index 0000000..8f6225e
--- /dev/null
+++ b/lua/dotfiles/snippet.lua
@@ -0,0 +1,194 @@
+-- Snippet engine. Owns the always-on machinery (expansion fallback, the completion source, the
+-- placeholder-jump keymaps) and turns snippets on per filetype: `add(ft, definitions)` registers a
+-- FileType autocmd that exposes the definitions to the completion source. Definitions live in
+-- plugin/50-snippet.lua.
+--
+-- API:
+-- add(filetype, definitions) — enable `definitions` (trigger word -> LSP snippet body) for
+-- `filetype` (a string or list of filetypes)
+
+local M = {}
+
+----------------------------------------------------------------------------------------------------
+-- Snippet expansion fallback
+----------------------------------------------------------------------------------------------------
+-- Work around Neovim's snippet grammar rejecting placeholder defaults that mix text with nested
+-- tabstops (e.g. lua_ls's `${1:pairs(${2:t})}`): the `any_or_text` rule matches a single node, not
+-- a `(any + text)^1` sequence. On parse failure, strip the snippet markup and insert it as plain
+-- text instead of throwing. Remove once fixed upstream.
+-- The `@as table` cast retypes the module here so overriding `expand` isn't flagged as a duplicate
+-- field set.
+local snippet = vim.snippet --[[@as table]]
+local orig_expand = snippet.expand
+
+-- Reduce LSP snippet syntax to plain text: keep placeholder/variable defaults, the first choice,
+-- drop bare tabstops.
+local function strip_snippet(s)
+ local prev
+ repeat
+ prev = s
+ s = s:gsub("%$(%b{})", function(group)
+ local body = group:sub(2, -2)
+ local choice = body:match("^%d+|(.*)|$")
+ if choice then
+ return (choice:gsub(",.*$", "")) -- first choice
+ end
+ local default = body:match("^[%w_]+:(.*)$")
+ if default then
+ return default -- ${n:default} / ${VAR:default}
+ end
+ return "" -- bare ${n} / ${VAR}
+ end)
+ until s == prev
+ s = s:gsub("%$[%w_]+", "") -- bare $n / $VAR
+ s = s:gsub("\\([%$}{|,\\])", "%1") -- unescape
+ return s
+end
+
+-- Insert `text` at the cursor as literal lines, re-indenting continuation lines to the current
+-- indent, and leave the cursor at the end of the inserted text.
+local function insert_plain(text)
+ local win = vim.api.nvim_get_current_win()
+ local row, col = unpack(vim.api.nvim_win_get_cursor(win))
+ local indent = vim.api.nvim_get_current_line():match("^%s*") or ""
+ local lines = vim.split(text, "\n", { plain = true })
+ for i = 2, #lines do
+ lines[i] = indent .. lines[i]
+ end
+ vim.api.nvim_buf_set_text(0, row - 1, col, row - 1, col, lines)
+ local last = #lines
+ local new_row = row - 1 + (last - 1)
+ local new_col = last == 1 and col + #lines[1] or #lines[last]
+ vim.api.nvim_win_set_cursor(win, { new_row + 1, new_col })
+end
+
+-- Expand normally; on a parse failure fall back to inserting the stripped snippet as plain text.
+snippet.expand = function(input)
+ local ok = pcall(orig_expand, input)
+ if not ok then
+ insert_plain(strip_snippet(input))
+ vim.notify(
+ "Snippet parse failed, inserted as plain text: " .. vim.inspect(input),
+ vim.log.levels.WARN
+ )
+ end
+end
+
+----------------------------------------------------------------------------------------------------
+-- Completion source
+----------------------------------------------------------------------------------------------------
+-- Exposes `vim.b.snippets` (trigger word -> LSP snippet body) as a 'complete' function source. A
+-- filetype opts in through `add()` below, which sets `vim.b.snippets`, points 'completefunc' here,
+-- and appends "F" to 'complete'. `dotfiles_snippet_source` must be a global so `v:lua` can reach
+-- it; see `:help complete-functions` and `:help v:lua-call`.
+
+-- Render a snippet to preview text: resolve placeholders and their mirrors to the placeholder's
+-- default value.
+local function render_snippet(s)
+ local defaults = {}
+ for n, d in s:gmatch("%${(%d+):([^{}]*)}") do
+ defaults[n] = d
+ end
+ local prev
+ repeat
+ prev = s
+ s = s:gsub("%$(%b{})", function(group)
+ local body = group:sub(2, -2)
+ local choice = body:match("^%d+|(.*)|$")
+ if choice then
+ return (choice:gsub(",.*$", "")) -- first choice
+ end
+ local default = body:match("^[%w_]+:(.*)$")
+ if default then
+ return default -- ${n:default} / ${VAR:default}
+ end
+ return defaults[body] or "" -- ${n} / ${VAR}
+ end)
+ until s == prev
+ s = s:gsub("%$(%d+)", function(n)
+ return defaults[n] or "" -- $n mirror
+ end)
+ s = s:gsub("%$[%w_]+", "") -- bare $VAR
+ s = s:gsub("\\([%$}{|,\\])", "%1") -- unescape
+ return s
+end
+
+-- 'completefunc' source: report the replaced range on the first pass, then the matching snippet
+-- triggers (with rendered preview and the body stashed in user_data for CompleteDone).
+function _G.dotfiles_snippet_source(findstart, base)
+ local snippets = vim.b.snippets or {}
+ local line = vim.api.nvim_get_current_line()
+ local col = vim.api.nvim_win_get_cursor(0)[2]
+ if findstart == 1 then
+ -- Walk back over trigger characters to find the range the match replaces.
+ local start = col
+ while start > 0 and line:sub(start, start):match("[%w_]") do
+ start = start - 1
+ end
+ return start
+ end
+ local items = {}
+ for trigger, body in pairs(snippets) do
+ if vim.startswith(trigger, base) then
+ items[#items + 1] = {
+ word = trigger,
+ kind = "Snippet",
+ menu = "[snippet]",
+ info = render_snippet(body),
+ user_data = { snippet = body },
+ }
+ end
+ end
+ return items
+end
+
+-- Expand the snippet once one of the items above is accepted (its trigger word is inserted first,
+-- so remove it).
+vim.api.nvim_create_autocmd("CompleteDone", {
+ desc = "Expand accepted snippet completion",
+ group = vim.g.dotfiles.augroup,
+ callback = function()
+ local item = vim.v.completed_item
+ local data = item.user_data
+ if type(data) ~= "table" or not data.snippet then
+ return
+ end
+ local row, col = unpack(vim.api.nvim_win_get_cursor(0))
+ vim.api.nvim_buf_set_text(0, row - 1, col - #item.word, row - 1, col, { "" })
+ vim.snippet.expand(data.snippet)
+ end,
+})
+
+----------------------------------------------------------------------------------------------------
+-- Placeholder jumping
+----------------------------------------------------------------------------------------------------
+
+-- Jump between snippet placeholders, falling back to native behavior when no snippet is active.
+vim.keymap.set({ "i", "s" }, "<C-n>", function()
+ return vim.snippet.active({ direction = 1 }) and "<Cmd>lua vim.snippet.jump(1)<CR>" or "<C-n>"
+end, { expr = true })
+vim.keymap.set({ "i", "s" }, "<C-p>", function()
+ return vim.snippet.active({ direction = -1 }) and "<Cmd>lua vim.snippet.jump(-1)<CR>" or "<C-p>"
+end, { expr = true })
+
+----------------------------------------------------------------------------------------------------
+-- Registration
+----------------------------------------------------------------------------------------------------
+
+-- Enable `definitions` for `filetype` (a string or list): on FileType, stash them in
+-- `vim.b.snippets`, point 'completefunc' at the source, and add "F" to 'complete' so they surface
+-- in the completion menu.
+function M.add(filetype, definitions)
+ vim.api.nvim_create_autocmd("FileType", {
+ desc = "Enable snippets",
+ group = vim.g.dotfiles.augroup,
+ pattern = filetype,
+ callback = function()
+ vim.b.snippets = definitions
+ vim.bo.completefunc = "v:lua.dotfiles_snippet_source"
+ vim.opt_local.complete:append("F")
+ end,
+ })
+end
+
+return M