diff --git a/lua/blink/cmp/completion/windows/ghost_text.lua b/lua/blink/cmp/completion/windows/ghost_text.lua index 2869e951..551696b1 100644 --- a/lua/blink/cmp/completion/windows/ghost_text.lua +++ b/lua/blink/cmp/completion/windows/ghost_text.lua @@ -71,7 +71,8 @@ function ghost_text.draw_preview(bufnr) if ghost_text.selected_item.insertTextFormat == vim.lsp.protocol.InsertTextFormat.Snippet then local expanded_snippet = snippets_utils.safe_parse(text_edit.newText) - text_edit.newText = expanded_snippet and tostring(expanded_snippet) or text_edit.newText + text_edit.newText = expanded_snippet and table.concat(snippets_utils.to_static_text(expanded_snippet), '\n') + or text_edit.newText end local display_lines = vim.split(get_still_untyped_text(text_edit), '\n', { plain = true }) or {} diff --git a/lua/blink/cmp/sources/snippets/utils.lua b/lua/blink/cmp/sources/snippets/utils.lua index f0903b77..f6a50a37 100644 --- a/lua/blink/cmp/sources/snippets/utils.lua +++ b/lua/blink/cmp/sources/snippets/utils.lua @@ -65,6 +65,137 @@ function utils.read_snippet(snippet, fallback) return snippets end +--@see https://github.com/neovim/neovim/blob/9afa1fd35510c5fe485f4a1dfdabf94e5f051a1c/runtime/lua/vim/snippet.lua#L59 + +--- Transforms the given text into an array of lines (so no line contains `\n`). +--- +--- @param text string|string[] +--- @return string[] +local function text_to_lines(text) + text = type(text) == 'string' and { text } or text + --- @cast text string[] + return vim.split(table.concat(text), '\n', { plain = true }) +end + +--@see https://github.com/neovim/neovim/blob/b67fcd0488746b079a3b721ae4800af94cd126e1/runtime/lua/vim/snippet.lua#L26 +--- Resolves variables (like `$name` or `${name:default}`) as follows: +--- - When a variable is unknown (i.e.: its name is not recognized in any of the cases below), return `nil`. +--- - When a variable isn't set, return its default (if any) or an empty string. +--- +--- Note that in some cases, the default is ignored since it's not clear how to distinguish an empty +--- value from an unset value (e.g.: `TM_CURRENT_LINE`). +--- +--- @param var string +--- @param default string +--- @return string? +local function resolve_variable(var, default) + --- @param str string + --- @return string + local function expand_or_default(str) + local expansion = vim.fn.expand(str) --[[@as string]] + return expansion == '' and default or expansion + end + + if var == 'TM_SELECTED_TEXT' then + -- Snippets are expanded in insert mode only, so there's no selection. + return default + elseif var == 'TM_CURRENT_LINE' then + return vim.api.nvim_get_current_line() + elseif var == 'TM_CURRENT_WORD' then + return expand_or_default('') + elseif var == 'TM_LINE_INDEX' then + return tostring(vim.fn.line('.') - 1) + elseif var == 'TM_LINE_NUMBER' then + return tostring(vim.fn.line('.')) + elseif var == 'TM_FILENAME' then + return expand_or_default('%:t') + elseif var == 'TM_FILENAME_BASE' then + return expand_or_default('%:t:r') + elseif var == 'TM_DIRECTORY' then + return expand_or_default('%:p:h:t') + elseif var == 'TM_FILEPATH' then + return expand_or_default('%:p') + end + + -- Unknown variable. + return nil +end + +--@see https://github.com/neovim/neovim/blob/b67fcd0488746b079a3b721ae4800af94cd126e1/runtime/lua/vim/snippet.lua#L477 +function utils.to_static_text(node) + local snippet = node + local snippet_text = {} + local base_indent = vim.api.nvim_get_current_line():match('^%s*') or '' + + local grammar = require('vim.lsp._snippet_grammar') + -- Get the placeholders we should use for each tabstop index. + --- @type table + local placeholders = {} + for _, child in ipairs(snippet.data.children) do + local type, data = child.type, child.data + if type == grammar.NodeType.Placeholder then + --- @cast data vim.snippet.PlaceholderData + local tabstop, value = data.tabstop, tostring(data.value) + if placeholders[tabstop] and placeholders[tabstop] ~= value then + error('Snippet has multiple placeholders for tabstop $' .. tabstop) + end + placeholders[tabstop] = value + end + end + + --- Appends the given text to the snippet, taking care of indentation. + --- + --- @param text string|string[] + local function append_to_snippet(text) + local snippet_lines = text_to_lines(snippet_text) + -- Get the base indentation based on the current line and the last line of the snippet. + if #snippet_lines > 0 then + base_indent = base_indent .. (snippet_lines[#snippet_lines]:match('(^%s+)%S') or '') --- @type string + end + + local shiftwidth = vim.fn.shiftwidth() + local curbuf = vim.api.nvim_get_current_buf() + local expandtab = vim.bo[curbuf].expandtab + + local lines = {} --- @type string[] + for i, line in ipairs(text_to_lines(text)) do + -- Replace tabs by spaces. + if expandtab then + line = line:gsub('\t', (' '):rep(shiftwidth)) --- @type string + end + -- Add the base indentation. + if i > 1 then line = base_indent .. line end + lines[#lines + 1] = line + end + + table.insert(snippet_text, table.concat(lines, '\n')) + end + + for _, child in ipairs(snippet.data.children) do + local type, data = child.type, child.data + if type == grammar.NodeType.Tabstop then + --- @cast data vim.snippet.TabstopData + local placeholder = placeholders[data.tabstop] + if placeholder then append_to_snippet(placeholder) end + elseif type == grammar.NodeType.Placeholder then + --- @cast data vim.snippet.PlaceholderData + local value = placeholders[data.tabstop] + append_to_snippet(value) + elseif type == grammar.NodeType.Variable then + --- @cast data vim.snippet.VariableData + -- Try to get the variable's value. + local value = resolve_variable(data.name, data.default and tostring(data.default) or '') + if value ~= nil then append_to_snippet(value) end + elseif type == grammar.NodeType.Text then + --- @cast data vim.snippet.TextData + append_to_snippet(data.text) + end + end + + snippet_text = text_to_lines(snippet_text) + return snippet_text +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