diff options
| author | Thomas Vanbesien <tvanbesi@proton.me> | 2026-06-24 20:22:19 +0200 |
|---|---|---|
| committer | Thomas Vanbesien <tvanbesi@proton.me> | 2026-06-24 20:22:19 +0200 |
| commit | b393c59ad0e3d85616eeb5be3a12042d6f3dbe63 (patch) | |
| tree | 0aa4f39aa7ffdd4d7d034dea7626cd2516cdfd02 /lua | |
| parent | c30ca8b6556673e836e312b555f9a63216ec9660 (diff) | |
| download | nvim-config-b393c59ad0e3d85616eeb5be3a12042d6f3dbe63.tar.gz nvim-config-b393c59ad0e3d85616eeb5be3a12042d6f3dbe63.zip | |
refactor(nvim): rewrite the snippet plugin
Diffstat (limited to 'lua')
| -rw-r--r-- | lua/dotfiles/snippet.lua | 194 |
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 |
