From 3bca6d82c46c45d9a4741bd0a3ed69c89d6789bc Mon Sep 17 00:00:00 2001 From: Soifou Date: Wed, 5 Nov 2025 17:16:54 +0100 Subject: [PATCH] fix(snippets): filter out incompatible builtin snippets Skip early incompatible builtin snippets using LSP grammar. --- .../cmp/sources/snippets/default/registry.lua | 6 +- lua/blink/cmp/sources/snippets/utils.lua | 68 +++++++++++++------ 2 files changed, 50 insertions(+), 24 deletions(-) diff --git a/lua/blink/cmp/sources/snippets/default/registry.lua b/lua/blink/cmp/sources/snippets/default/registry.lua index e52b8e435..6ac6437f0 100644 --- a/lua/blink/cmp/sources/snippets/default/registry.lua +++ b/lua/blink/cmp/sources/snippets/default/registry.lua @@ -11,10 +11,11 @@ local registry = { builtin_vars = require('blink.cmp.sources.snippets.default.builtin'), } +local user_config = vim.fn.stdpath('config') local utils = require('blink.cmp.sources.snippets.utils') local default_config = { friendly_snippets = true, - search_paths = { vim.fn.stdpath('config') .. '/snippets' }, + search_paths = { user_config .. '/snippets' }, global_snippets = { 'all' }, extended_filetypes = {}, --- @type string? @@ -62,9 +63,10 @@ function registry:get_snippets_for_ft(filetype) for _, f in ipairs(files) do local contents = utils.read_file(f) if contents then + local is_user_snippet = vim.startswith(f, user_config) local snippets = utils.parse_json_with_error_msg(f, contents) for _, key in ipairs(vim.tbl_keys(snippets)) do - local snippet = utils.read_snippet(snippets[key], key) + local snippet = utils.read_snippet(snippets[key], key, filetype, is_user_snippet) for _, snippet_def in pairs(snippet) do table.insert(loaded_snippets, snippet_def) end diff --git a/lua/blink/cmp/sources/snippets/utils.lua b/lua/blink/cmp/sources/snippets/utils.lua index 7cbae4ee1..78a666631 100644 --- a/lua/blink/cmp/sources/snippets/utils.lua +++ b/lua/blink/cmp/sources/snippets/utils.lua @@ -1,3 +1,4 @@ +local lsp_snippet_grammar = require('vim.lsp._snippet_grammar') local utils = { parse_cache = {}, } @@ -31,20 +32,21 @@ end function utils.safe_parse(input) if utils.parse_cache[input] then return utils.parse_cache[input] end - local safe, parsed = pcall(vim.lsp._snippet_grammar.parse, input) + local safe, parsed = pcall(lsp_snippet_grammar.parse, input) if not safe then return nil end utils.parse_cache[input] = parsed return parsed end ----@type fun(snippet: blink.cmp.Snippet, fallback: string): table -function utils.read_snippet(snippet, fallback) - local snippets = {} +---@type fun(snippet: blink.cmp.Snippet, fallback: string, filetype: string, is_user_snippet: boolean): table +function utils.read_snippet(snippet, fallback, filetype, is_user_snippet) local prefix = snippet.prefix or fallback - local description = snippet.description or fallback - local body = snippet.body + local body = utils.validate_body(snippet.body, prefix, filetype, is_user_snippet) + if body == nil then return {} end + local snippets = {} + local description = snippet.description or fallback if type(description) == 'table' then description = vim.fn.join(description, '') end if type(prefix) == 'table' then @@ -90,25 +92,47 @@ function utils.add_current_line_indentation(text) return table.concat(lines, '\n') end -function utils.get_tab_stops(snippet) - local expanded_snippet = require('blink.cmp.sources.snippets.utils').safe_parse(snippet) - if not expanded_snippet then return end - - local tabstops = {} - local grammar = require('vim.lsp._snippet_grammar') - local line = 1 - local character = 1 - for _, child in ipairs(expanded_snippet.data.children) do - local lines = tostring(child) == '' and {} or vim.split(tostring(child), '\n') - line = line + math.max(#lines - 1, 0) - character = #lines == 0 and character or #lines > 1 and #lines[#lines] or (character + #lines[#lines]) - if child.type == grammar.NodeType.Placeholder or child.type == grammar.NodeType.Tabstop then - table.insert(tabstops, { index = child.data.tabstop, line = line, character = character }) +---@param body string|string[] +---@param prefix string +---@param filetype string +---@param is_user_snippet boolean +---@return string|string[]|nil +function utils.validate_body(body, prefix, filetype, is_user_snippet) + if type(body) == 'table' then body = table.concat(body, '\n') end + + -- Fix snippet from friendly snippets source, whenever possible + -- stylua: ignore + if not is_user_snippet then + body = body + :gsub(':\\${', ':${') -- unescape :${ + :gsub(':${(%w)\\}', ':${%1}') -- unescape :${..\\} + :gsub('\\}}', '}}') -- unescape }} + :gsub('\\([%(%))])', '%1') -- unescape parentheses + :gsub(' \\([%(%))])\\', ' %1\\') -- unescape parens before backslash + :gsub('([%s{%(%[])%$%${', '%1\\$${') -- escape $$ after whitespace/brackets + :gsub('$: ', '\\$: ') -- escape $ before colon-space + :gsub('(".*%w)%$(")', '%1\\$%2') -- escape dollar sign, e.g. "foo$" -> "foo\$" + :gsub('$\\{', '\\${') -- wrong backslash position + :gsub('(\\?)%$%W*(%$[%w{]+)%W*%$', function(e, a) return (e == '\\' and e or '\\') .. '$' .. a end) + :gsub('(%${%d+|)([^}]+)(|})', function(s, o, e) return s .. o:gsub('\\', '\\\\') .. e end) -- Escape \ in options, e.g. \Huge -> \\Huge + + if filetype == 'terraform' then + body = body + :gsub('= "\\${', '= "${') + :gsub('= %["\\${', '= ["${') + :gsub('(%${[^}]+})', function(e) return e:gsub('[%.%[%]-]', '_') end) -- replace all dots/brackets/dash in placeholders (not allowed) + end + end + + if not utils.safe_parse(body) then + if is_user_snippet then + prefix = type(prefix) == 'table' and table.concat(prefix, ',') or prefix + vim.print(('[blink.cmp] Discard user snippet `%s` (%s), parsing failed!'):format(prefix, filetype)) end + return nil end - table.sort(tabstops, function(a, b) return a.index < b.index end) - return tabstops + return body:find('\n') and vim.split(body, '\n', { plain = true }) or body end return utils