-- 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 }) ------------------------------------------------------------------------------------------------------------------------ -- 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 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 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 ------------------------------------------------------------------------------------------------------------------------ -- Snippet completion source ------------------------------------------------------------------------------------------------------------------------ -- Exposes `vim.b.snippets` (trigger word -> LSP snippet body) as a 'complete' function source. A filetype opts in from -- its ftplugin by setting `vim.b.snippets`, pointing 'completefunc' at this function, and appending "F" to 'complete'. -- 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 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, })