-- 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" }, "", function() return vim.snippet.active({ direction = 1 }) and "lua vim.snippet.jump(1)" or "" end, { expr = true }) vim.keymap.set({ "i", "s" }, "", function() return vim.snippet.active({ direction = -1 }) and "lua vim.snippet.jump(-1)" or "" 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