From f1775da07fe48da629468bcfcc2a8a6c4c3f40ed Mon Sep 17 00:00:00 2001 From: Maria José Solano Date: Fri, 20 Oct 2023 23:51:26 -0700 Subject: feat(lsp): add snippet API (#25301) --- runtime/lua/vim/snippet.lua | 535 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 535 insertions(+) create mode 100644 runtime/lua/vim/snippet.lua (limited to 'runtime/lua/vim/snippet.lua') diff --git a/runtime/lua/vim/snippet.lua b/runtime/lua/vim/snippet.lua new file mode 100644 index 0000000000..3d739bd3af --- /dev/null +++ b/runtime/lua/vim/snippet.lua @@ -0,0 +1,535 @@ +local G = require('vim.lsp._snippet_grammar') +local snippet_group = vim.api.nvim_create_augroup('vim/snippet', {}) +local snippet_ns = vim.api.nvim_create_namespace('vim/snippet') + +--- Returns the 0-based cursor position. +--- +--- @return integer, integer +local function cursor_pos() + local cursor = vim.api.nvim_win_get_cursor(0) + return cursor[1] - 1, cursor[2] +end + +--- 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 + -- Not using '%:t:r' since we want to remove all extensions. + local filename_base = expand_or_default('%:t'):gsub('%.[^%.]*$', '') + return filename_base + 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 + +--- 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 + +--- Computes the 0-based position of a tabstop located at the end of `snippet` and spanning +--- `placeholder` (if given). +--- +--- @param snippet string[] +--- @param placeholder string? +--- @return Range4 +local function compute_tabstop_range(snippet, placeholder) + local cursor_row, cursor_col = cursor_pos() + local snippet_text = text_to_lines(snippet) + local placeholder_text = text_to_lines(placeholder or '') + local start_row = cursor_row + #snippet_text - 1 + local start_col = #(snippet_text[#snippet_text] or '') + + -- Add the cursor's column offset to the first line. + if start_row == cursor_row then + start_col = start_col + cursor_col + end + + local end_row = start_row + #placeholder_text - 1 + local end_col = (start_row == end_row and start_col or 0) + + #(placeholder_text[#placeholder_text] or '') + + return { start_row, start_col, end_row, end_col } +end + +--- @class vim.snippet.Tabstop +--- @field extmark_id integer +--- @field index integer +--- @field bufnr integer +local Tabstop = {} + +--- Creates a new tabstop. +--- +--- @package +--- @param index integer +--- @param bufnr integer +--- @param range Range4 +--- @return vim.snippet.Tabstop +function Tabstop.new(index, bufnr, range) + local extmark_id = vim.api.nvim_buf_set_extmark(bufnr, snippet_ns, range[1], range[2], { + right_gravity = false, + end_right_gravity = true, + end_line = range[3], + end_col = range[4], + hl_group = 'SnippetTabstop', + }) + + local self = setmetatable( + { index = index, bufnr = bufnr, extmark_id = extmark_id }, + { __index = Tabstop } + ) + + return self +end + +--- Returns the tabstop's range. +--- +--- @package +--- @return Range4 +function Tabstop:get_range() + local mark = + vim.api.nvim_buf_get_extmark_by_id(self.bufnr, snippet_ns, self.extmark_id, { details = true }) + + --- @diagnostic disable-next-line: undefined-field + return { mark[1], mark[2], mark[3].end_row, mark[3].end_col } +end + +--- Returns the text spanned by the tabstop. +--- +--- @package +--- @return string +function Tabstop:get_text() + local range = self:get_range() + return table.concat( + vim.api.nvim_buf_get_text(self.bufnr, range[1], range[2], range[3], range[4], {}), + '\n' + ) +end + +--- Sets the tabstop's text. +--- +--- @package +--- @param text string +function Tabstop:set_text(text) + local range = self:get_range() + vim.api.nvim_buf_set_text(self.bufnr, range[1], range[2], range[3], range[4], text_to_lines(text)) +end + +--- @class vim.snippet.Session +--- @field bufnr integer +--- @field tabstops table +--- @field current_tabstop vim.snippet.Tabstop +local Session = {} + +--- Creates a new snippet session in the current buffer. +--- +--- @package +--- @return vim.snippet.Session +function Session.new() + local bufnr = vim.api.nvim_get_current_buf() + local self = setmetatable({ + bufnr = bufnr, + tabstops = {}, + current_tabstop = Tabstop.new(0, bufnr, { 0, 0, 0, 0 }), + }, { __index = Session }) + + return self +end + +--- Creates the session tabstops. +--- +--- @package +--- @param tabstop_ranges table +function Session:set_tabstops(tabstop_ranges) + for index, ranges in pairs(tabstop_ranges) do + for _, range in ipairs(ranges) do + self.tabstops[index] = self.tabstops[index] or {} + table.insert(self.tabstops[index], Tabstop.new(index, self.bufnr, range)) + end + end +end + +--- Returns the destination tabstop index when jumping in the given direction. +--- +--- @package +--- @param direction vim.snippet.Direction +--- @return integer? +function Session:get_dest_index(direction) + local tabstop_indexes = vim.tbl_keys(self.tabstops) --- @type integer[] + table.sort(tabstop_indexes) + for i, index in ipairs(tabstop_indexes) do + if index == self.current_tabstop.index then + local dest_index = tabstop_indexes[i + direction] --- @type integer? + -- When jumping forwards, $0 is the last tabstop. + if not dest_index and direction == 1 then + dest_index = 0 + end + -- When jumping backwards, make sure we don't think that $0 is the first tabstop. + if dest_index == 0 and direction == -1 then + dest_index = nil + end + return dest_index + end + end +end + +--- @class vim.snippet.Snippet +--- @field private _session? vim.snippet.Session +local M = { session = nil } + +--- Select the given tabstop range. +--- +--- @param tabstop vim.snippet.Tabstop +local function select_tabstop(tabstop) + --- @param keys string + local function feedkeys(keys) + keys = vim.api.nvim_replace_termcodes(keys, true, false, true) + vim.api.nvim_feedkeys(keys, 'n', true) + end + + --- NOTE: We don't use `vim.api.nvim_win_set_cursor` here because it causes the cursor to end + --- at the end of the selection instead of the start. + --- + --- @param row integer + --- @param col integer + local function move_cursor_to(row, col) + local line = vim.fn.getline(row) --[[ @as string ]] + col = math.max(vim.fn.strchars(line:sub(1, col)) - 1, 0) + feedkeys(string.format('%sG0%s', row, string.rep('', col))) + end + + local range = tabstop:get_range() + local mode = vim.fn.mode() + + -- Move the cursor to the start of the tabstop. + vim.api.nvim_win_set_cursor(0, { range[1] + 1, range[2] }) + + -- For empty and the final tabstop, start insert mode at the end of the range. + if tabstop.index == 0 or (range[1] == range[3] and range[2] == range[4]) then + if mode ~= 'i' then + if mode == 's' then + feedkeys('') + end + vim.cmd.startinsert({ bang = range[4] >= #vim.api.nvim_get_current_line() }) + end + else + -- Else, select the tabstop's text. + if mode ~= 'n' then + feedkeys('') + end + move_cursor_to(range[1] + 1, range[2] + 1) + feedkeys('v') + move_cursor_to(range[3] + 1, range[4]) + feedkeys('o') + end +end + +--- Sets up the necessary autocommands for snippet expansion. +--- +--- @param bufnr integer +local function setup_autocmds(bufnr) + vim.api.nvim_create_autocmd({ 'CursorMoved', 'CursorMovedI' }, { + group = snippet_group, + desc = 'Update snippet state when the cursor moves', + buffer = bufnr, + callback = function() + -- Just update the tabstop in insert and select modes. + if not vim.fn.mode():match('^[isS]') then + return + end + + -- Update the current tabstop to be the one containing the cursor. + local cursor_row, cursor_col = cursor_pos() + for tabstop_index, tabstops in pairs(M._session.tabstops) do + for _, tabstop in ipairs(tabstops) do + local range = tabstop:get_range() + if + (cursor_row > range[1] or (cursor_row == range[1] and cursor_col >= range[2])) + and (cursor_row < range[3] or (cursor_row == range[3] and cursor_col <= range[4])) + then + M._session.current_tabstop = tabstop + if tabstop_index ~= 0 then + return + end + end + end + end + + -- The cursor is either not on a tabstop or we reached the end, so exit the session. + M.exit() + return true + end, + }) + + vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, { + group = snippet_group, + desc = 'Update active tabstops when buffer text changes', + buffer = bufnr, + callback = function() + if not M.active() then + return true + end + + -- Sync the tabstops in the current group. + local current_tabstop = M._session.current_tabstop + local current_text = current_tabstop:get_text() + for _, tabstop in ipairs(M._session.tabstops[current_tabstop.index]) do + if tabstop.extmark_id ~= current_tabstop.extmark_id then + tabstop:set_text(current_text) + end + end + end, + }) +end + +--- Expands the given snippet text. +--- Refer to https://microsoft.github.io/language-server-protocol/specification/#snippet_syntax +--- for the specification of valid input. +--- +--- Tabstops are highlighted with hl-SnippetTabstop. +--- +--- @param input string +function M.expand(input) + local snippet = G.parse(input) + local snippet_text = {} + + M._session = Session.new() + + -- 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 == G.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 + + -- Keep track of tabstop nodes during expansion. + --- @type table + local tabstop_ranges = {} + + --- @param index integer + --- @param placeholder string? + local function add_tabstop(index, placeholder) + tabstop_ranges[index] = tabstop_ranges[index] or {} + table.insert(tabstop_ranges[index], compute_tabstop_range(snippet_text, placeholder)) + end + + --- Appends the given text to the snippet, taking care of indentation. + --- + --- @param text string|string[] + local function append_to_snippet(text) + -- Get the base indentation based on the current line and the last line of the snippet. + local base_indent = vim.api.nvim_get_current_line():match('^%s*') or '' + if #snippet_text > 0 then + base_indent = base_indent .. (snippet_text[#snippet_text]:match('^%s*') or '') --- @type string + end + + local lines = vim.iter.map(function(i, line) + -- Replace tabs by spaces. + if vim.o.expandtab then + line = line:gsub('\t', (' '):rep(vim.fn.shiftwidth())) --- @type string + end + -- Add the base indentation. + if i > 1 then + line = base_indent .. line + end + return line + end, ipairs(text_to_lines(text))) + + 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 == G.NodeType.Tabstop then + --- @cast data vim.snippet.TabstopData + local placeholder = placeholders[data.tabstop] + add_tabstop(data.tabstop, placeholder) + if placeholder then + append_to_snippet(placeholder) + end + elseif type == G.NodeType.Placeholder then + --- @cast data vim.snippet.PlaceholderData + local value = placeholders[data.tabstop] + add_tabstop(data.tabstop, value) + append_to_snippet(value) + elseif type == G.NodeType.Choice then + --- @cast data vim.snippet.ChoiceData + append_to_snippet(data.values[1]) + elseif type == G.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 not value then + -- Unknown variable, make this a tabstop and use the variable name as a placeholder. + value = data.name + local tabstop_indexes = vim.tbl_keys(tabstop_ranges) + local index = math.max(unpack((#tabstop_indexes == 0 and { 0 }) or tabstop_indexes)) + 1 + add_tabstop(index, value) + end + append_to_snippet(value) + elseif type == G.NodeType.Text then + --- @cast data vim.snippet.TextData + append_to_snippet(data.text) + end + end + + -- $0, which defaults to the end of the snippet, defines the final cursor position. + -- Make sure the snippet has exactly one of these. + if vim.tbl_contains(vim.tbl_keys(tabstop_ranges), 0) then + assert(#tabstop_ranges[0] == 1, 'Snippet has multiple $0 tabstops') + else + add_tabstop(0) + end + + -- Insert the snippet text. + local cursor_row, cursor_col = cursor_pos() + vim.api.nvim_buf_set_text( + M._session.bufnr, + cursor_row, + cursor_col, + cursor_row, + cursor_col, + text_to_lines(snippet_text) + ) + + -- Create the tabstops. + M._session:set_tabstops(tabstop_ranges) + + -- Jump to the first tabstop. + M.jump(1) +end + +--- @alias vim.snippet.Direction -1 | 1 + +--- Returns `true` if there is an active snippet which can be jumped in the given direction. +--- You can use this function to navigate a snippet as follows: +--- +--- ```lua +--- vim.keymap.set({ 'i', 's' }, '', function() +--- if vim.snippet.jumpable(1) then +--- return 'lua vim.snippet.jump(1)' +--- else +--- return '' +--- end +--- end, { expr = true }) +--- ``` +--- +--- @param direction (vim.snippet.Direction) Navigation direction. -1 for previous, 1 for next. +--- @return boolean +function M.jumpable(direction) + if not M.active() then + return false + end + + return M._session:get_dest_index(direction) ~= nil +end + +--- Jumps within the active snippet in the given direction. +--- If the jump isn't possible, the function call does nothing. +--- +--- You can use this function to navigate a snippet as follows: +--- +--- ```lua +--- vim.keymap.set({ 'i', 's' }, '', function() +--- if vim.snippet.jumpable(1) then +--- return 'lua vim.snippet.jump(1)' +--- else +--- return '' +--- end +--- end, { expr = true }) +--- ``` +--- +--- @param direction (vim.snippet.Direction) Navigation direction. -1 for previous, 1 for next. +function M.jump(direction) + -- Get the tabstop index to jump to. + local dest_index = M._session and M._session:get_dest_index(direction) + if not dest_index then + return + end + + -- Find the tabstop with the lowest range. + local tabstops = M._session.tabstops[dest_index] + local dest = tabstops[1] + for _, tabstop in ipairs(tabstops) do + local dest_range, range = dest:get_range(), tabstop:get_range() + if (range[1] < dest_range[1]) or (range[1] == dest_range[1] and range[2] < dest_range[2]) then + dest = tabstop + end + end + + -- Clear the autocommands so that we can move the cursor freely while selecting the tabstop. + vim.api.nvim_clear_autocmds({ group = snippet_group, buffer = M._session.bufnr }) + + M._session.current_tabstop = dest + select_tabstop(dest) + + -- Restore the autocommands. + setup_autocmds(M._session.bufnr) +end + +--- Returns `true` if there's an active snippet in the current buffer. +--- +--- @return boolean +function M.active() + return M._session ~= nil and M._session.bufnr == vim.api.nvim_get_current_buf() +end + +--- Exits the current snippet. +function M.exit() + if not M.active() then + return + end + + vim.api.nvim_clear_autocmds({ group = snippet_group, buffer = M._session.bufnr }) + vim.api.nvim_buf_clear_namespace(M._session.bufnr, snippet_ns, 0, -1) + + M._session = nil +end + +return M -- cgit From 370232dbefb91b7ee773ee9a61a9b1ad77d7f1af Mon Sep 17 00:00:00 2001 From: Maria José Solano Date: Sun, 22 Oct 2023 21:21:02 -0700 Subject: fix(lsp): track snippet deletion --- runtime/lua/vim/snippet.lua | 70 ++++++++++++++++++++++++++++----------------- 1 file changed, 43 insertions(+), 27 deletions(-) (limited to 'runtime/lua/vim/snippet.lua') diff --git a/runtime/lua/vim/snippet.lua b/runtime/lua/vim/snippet.lua index 3d739bd3af..3fe8ab2d48 100644 --- a/runtime/lua/vim/snippet.lua +++ b/runtime/lua/vim/snippet.lua @@ -90,6 +90,18 @@ local function compute_tabstop_range(snippet, placeholder) return { start_row, start_col, end_row, end_col } end +--- Returns the range spanned by the respective extmark. +--- +--- @param bufnr integer +--- @param extmark_id integer +--- @return Range4 +local function get_extmark_range(bufnr, extmark_id) + local mark = vim.api.nvim_buf_get_extmark_by_id(bufnr, snippet_ns, extmark_id, { details = true }) + + --- @diagnostic disable-next-line: undefined-field + return { mark[1], mark[2], mark[3].end_row, mark[3].end_col } +end + --- @class vim.snippet.Tabstop --- @field extmark_id integer --- @field index integer @@ -125,11 +137,7 @@ end --- @package --- @return Range4 function Tabstop:get_range() - local mark = - vim.api.nvim_buf_get_extmark_by_id(self.bufnr, snippet_ns, self.extmark_id, { details = true }) - - --- @diagnostic disable-next-line: undefined-field - return { mark[1], mark[2], mark[3].end_row, mark[3].end_col } + return get_extmark_range(self.bufnr, self.extmark_id) end --- Returns the text spanned by the tabstop. @@ -155,6 +163,7 @@ end --- @class vim.snippet.Session --- @field bufnr integer +--- @field extmark_id integer --- @field tabstops table --- @field current_tabstop vim.snippet.Tabstop local Session = {} @@ -162,29 +171,27 @@ local Session = {} --- Creates a new snippet session in the current buffer. --- --- @package +--- @param bufnr integer +--- @param snippet_extmark integer +--- @param tabstop_ranges table --- @return vim.snippet.Session -function Session.new() - local bufnr = vim.api.nvim_get_current_buf() +function Session.new(bufnr, snippet_extmark, tabstop_ranges) local self = setmetatable({ bufnr = bufnr, + extmark_id = snippet_extmark, tabstops = {}, current_tabstop = Tabstop.new(0, bufnr, { 0, 0, 0, 0 }), }, { __index = Session }) - return self -end - ---- Creates the session tabstops. ---- ---- @package ---- @param tabstop_ranges table -function Session:set_tabstops(tabstop_ranges) + -- Create the tabstops. for index, ranges in pairs(tabstop_ranges) do for _, range in ipairs(ranges) do self.tabstops[index] = self.tabstops[index] or {} table.insert(self.tabstops[index], Tabstop.new(index, self.bufnr, range)) end end + + return self end --- Returns the destination tabstop index when jumping in the given direction. @@ -304,6 +311,15 @@ local function setup_autocmds(bufnr) desc = 'Update active tabstops when buffer text changes', buffer = bufnr, callback = function() + -- Check that the snippet hasn't been deleted. + local snippet_range = get_extmark_range(M._session.bufnr, M._session.extmark_id) + if + (snippet_range[1] == snippet_range[3] and snippet_range[2] == snippet_range[4]) + or snippet_range[3] + 1 > vim.fn.line('$') + then + M.exit() + end + if not M.active() then return true end @@ -331,8 +347,6 @@ function M.expand(input) local snippet = G.parse(input) local snippet_text = {} - M._session = Session.new() - -- Get the placeholders we should use for each tabstop index. --- @type table local placeholders = {} @@ -427,19 +441,21 @@ function M.expand(input) add_tabstop(0) end + snippet_text = text_to_lines(snippet_text) + -- Insert the snippet text. + local bufnr = vim.api.nvim_get_current_buf() local cursor_row, cursor_col = cursor_pos() - vim.api.nvim_buf_set_text( - M._session.bufnr, - cursor_row, - cursor_col, - cursor_row, - cursor_col, - text_to_lines(snippet_text) - ) + vim.api.nvim_buf_set_text(bufnr, cursor_row, cursor_col, cursor_row, cursor_col, snippet_text) - -- Create the tabstops. - M._session:set_tabstops(tabstop_ranges) + -- Create the session. + local snippet_extmark = vim.api.nvim_buf_set_extmark(bufnr, snippet_ns, cursor_row, cursor_col, { + end_line = cursor_row + #snippet_text - 1, + end_col = #snippet_text > 1 and #snippet_text[#snippet_text] or cursor_col + #snippet_text[1], + right_gravity = false, + end_right_gravity = true, + }) + M._session = Session.new(bufnr, snippet_extmark, tabstop_ranges) -- Jump to the first tabstop. M.jump(1) -- cgit From 94127cb5df0a513e66777d18a2c7fa6219404280 Mon Sep 17 00:00:00 2001 From: Maria José Solano Date: Sun, 22 Oct 2023 22:38:11 -0700 Subject: fix(lsp): do not add extra indentation --- runtime/lua/vim/snippet.lua | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) (limited to 'runtime/lua/vim/snippet.lua') diff --git a/runtime/lua/vim/snippet.lua b/runtime/lua/vim/snippet.lua index 3fe8ab2d48..7680d2e216 100644 --- a/runtime/lua/vim/snippet.lua +++ b/runtime/lua/vim/snippet.lua @@ -346,6 +346,7 @@ end function M.expand(input) local snippet = G.parse(input) local snippet_text = {} + local base_indent = vim.api.nvim_get_current_line():match('^%s*') or '' -- Get the placeholders we should use for each tabstop index. --- @type table @@ -377,10 +378,10 @@ function M.expand(input) --- --- @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. - local base_indent = vim.api.nvim_get_current_line():match('^%s*') or '' - if #snippet_text > 0 then - base_indent = base_indent .. (snippet_text[#snippet_text]:match('^%s*') or '') --- @type string + if #snippet_lines > 0 then + base_indent = base_indent .. (snippet_lines[#snippet_lines]:match('(^%s*)%S') or '') --- @type string end local lines = vim.iter.map(function(i, line) -- cgit From 15983cf2c64c527fc13681925d0d00c070c30640 Mon Sep 17 00:00:00 2001 From: Maria José Solano Date: Wed, 25 Oct 2023 22:29:05 -0700 Subject: fix(lsp): cancel session when leaving snippet region (#25762) --- runtime/lua/vim/snippet.lua | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) (limited to 'runtime/lua/vim/snippet.lua') diff --git a/runtime/lua/vim/snippet.lua b/runtime/lua/vim/snippet.lua index 7680d2e216..14f73bff5c 100644 --- a/runtime/lua/vim/snippet.lua +++ b/runtime/lua/vim/snippet.lua @@ -278,13 +278,26 @@ local function setup_autocmds(bufnr) desc = 'Update snippet state when the cursor moves', buffer = bufnr, callback = function() + local cursor_row, cursor_col = cursor_pos() + + -- The cursor left the snippet region. + local snippet_range = get_extmark_range(bufnr, M._session.extmark_id) + if + cursor_row < snippet_range[1] + or (cursor_row == snippet_range[1] and cursor_col < snippet_range[2]) + or cursor_row > snippet_range[3] + or (cursor_row == snippet_range[3] and cursor_col > snippet_range[4]) + then + M.exit() + return true + end + -- Just update the tabstop in insert and select modes. if not vim.fn.mode():match('^[isS]') then return end -- Update the current tabstop to be the one containing the cursor. - local cursor_row, cursor_col = cursor_pos() for tabstop_index, tabstops in pairs(M._session.tabstops) do for _, tabstop in ipairs(tabstops) do local range = tabstop:get_range() -- cgit From 0fe0cf5adaab06b92250eb350306de63c4d4f36f Mon Sep 17 00:00:00 2001 From: Maria José Solano Date: Mon, 30 Oct 2023 04:58:28 -0700 Subject: fix(lsp): do not cancel snippet when selecting placeholder (#25835) --- runtime/lua/vim/snippet.lua | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) (limited to 'runtime/lua/vim/snippet.lua') diff --git a/runtime/lua/vim/snippet.lua b/runtime/lua/vim/snippet.lua index 14f73bff5c..94c69795a4 100644 --- a/runtime/lua/vim/snippet.lua +++ b/runtime/lua/vim/snippet.lua @@ -278,6 +278,11 @@ local function setup_autocmds(bufnr) desc = 'Update snippet state when the cursor moves', buffer = bufnr, callback = function() + -- Just update the tabstop in insert and select modes. + if not vim.fn.mode():match('^[isS]') then + return + end + local cursor_row, cursor_col = cursor_pos() -- The cursor left the snippet region. @@ -292,11 +297,6 @@ local function setup_autocmds(bufnr) return true end - -- Just update the tabstop in insert and select modes. - if not vim.fn.mode():match('^[isS]') then - return - end - -- Update the current tabstop to be the one containing the cursor. for tabstop_index, tabstops in pairs(M._session.tabstops) do for _, tabstop in ipairs(tabstops) do -- cgit From 7e36c8e972f0b2e07c6186aa5dca2f70d95a77f2 Mon Sep 17 00:00:00 2001 From: Maria José Solano Date: Thu, 26 Oct 2023 22:53:38 -0700 Subject: feat(lsp): support for choice snippet nodes --- runtime/lua/vim/snippet.lua | 72 ++++++++++++++++++++++++++++++--------------- 1 file changed, 49 insertions(+), 23 deletions(-) (limited to 'runtime/lua/vim/snippet.lua') diff --git a/runtime/lua/vim/snippet.lua b/runtime/lua/vim/snippet.lua index 94c69795a4..32a8ea0b0d 100644 --- a/runtime/lua/vim/snippet.lua +++ b/runtime/lua/vim/snippet.lua @@ -104,8 +104,9 @@ end --- @class vim.snippet.Tabstop --- @field extmark_id integer ---- @field index integer --- @field bufnr integer +--- @field index integer +--- @field choices? string[] local Tabstop = {} --- Creates a new tabstop. @@ -114,8 +115,9 @@ local Tabstop = {} --- @param index integer --- @param bufnr integer --- @param range Range4 +--- @param choices? string[] --- @return vim.snippet.Tabstop -function Tabstop.new(index, bufnr, range) +function Tabstop.new(index, bufnr, range, choices) local extmark_id = vim.api.nvim_buf_set_extmark(bufnr, snippet_ns, range[1], range[2], { right_gravity = false, end_right_gravity = true, @@ -125,7 +127,7 @@ function Tabstop.new(index, bufnr, range) }) local self = setmetatable( - { index = index, bufnr = bufnr, extmark_id = extmark_id }, + { extmark_id = extmark_id, bufnr = bufnr, index = index, choices = choices }, { __index = Tabstop } ) @@ -173,9 +175,9 @@ local Session = {} --- @package --- @param bufnr integer --- @param snippet_extmark integer ---- @param tabstop_ranges table +--- @param tabstop_data table --- @return vim.snippet.Session -function Session.new(bufnr, snippet_extmark, tabstop_ranges) +function Session.new(bufnr, snippet_extmark, tabstop_data) local self = setmetatable({ bufnr = bufnr, extmark_id = snippet_extmark, @@ -184,10 +186,10 @@ function Session.new(bufnr, snippet_extmark, tabstop_ranges) }, { __index = Session }) -- Create the tabstops. - for index, ranges in pairs(tabstop_ranges) do - for _, range in ipairs(ranges) do + for index, ranges in pairs(tabstop_data) do + for _, data in ipairs(ranges) do self.tabstops[index] = self.tabstops[index] or {} - table.insert(self.tabstops[index], Tabstop.new(index, self.bufnr, range)) + table.insert(self.tabstops[index], Tabstop.new(index, self.bufnr, data.range, data.choices)) end end @@ -222,6 +224,22 @@ end --- @field private _session? vim.snippet.Session local M = { session = nil } +--- Displays the choices for the given tabstop as completion items. +--- +--- @param tabstop vim.snippet.Tabstop +local function display_choices(tabstop) + assert(tabstop.choices, 'Tabstop has no choices') + + local start_col = tabstop:get_range()[2] + 1 + local matches = vim.iter.map(function(choice) + return { word = choice } + end, tabstop.choices) + + vim.defer_fn(function() + vim.fn.complete(start_col, matches) + end, 100) +end + --- Select the given tabstop range. --- --- @param tabstop vim.snippet.Tabstop @@ -246,17 +264,25 @@ local function select_tabstop(tabstop) local range = tabstop:get_range() local mode = vim.fn.mode() + if vim.fn.pumvisible() ~= 0 then + -- Close the choice completion menu if open. + vim.fn.complete(vim.fn.col('.'), {}) + end + -- Move the cursor to the start of the tabstop. vim.api.nvim_win_set_cursor(0, { range[1] + 1, range[2] }) - -- For empty and the final tabstop, start insert mode at the end of the range. - if tabstop.index == 0 or (range[1] == range[3] and range[2] == range[4]) then + -- For empty, choice and the final tabstops, start insert mode at the end of the range. + if tabstop.choices or tabstop.index == 0 or (range[1] == range[3] and range[2] == range[4]) then if mode ~= 'i' then if mode == 's' then feedkeys('') end vim.cmd.startinsert({ bang = range[4] >= #vim.api.nvim_get_current_line() }) end + if tabstop.choices then + display_choices(tabstop) + end else -- Else, select the tabstop's text. if mode ~= 'n' then @@ -297,7 +323,6 @@ local function setup_autocmds(bufnr) return true end - -- Update the current tabstop to be the one containing the cursor. for tabstop_index, tabstops in pairs(M._session.tabstops) do for _, tabstop in ipairs(tabstops) do local range = tabstop:get_range() @@ -305,7 +330,6 @@ local function setup_autocmds(bufnr) (cursor_row > range[1] or (cursor_row == range[1] and cursor_col >= range[2])) and (cursor_row < range[3] or (cursor_row == range[3] and cursor_col <= range[4])) then - M._session.current_tabstop = tabstop if tabstop_index ~= 0 then return end @@ -377,14 +401,16 @@ function M.expand(input) end -- Keep track of tabstop nodes during expansion. - --- @type table - local tabstop_ranges = {} + --- @type table + local tabstop_data = {} --- @param index integer - --- @param placeholder string? - local function add_tabstop(index, placeholder) - tabstop_ranges[index] = tabstop_ranges[index] or {} - table.insert(tabstop_ranges[index], compute_tabstop_range(snippet_text, placeholder)) + --- @param placeholder? string + --- @param choices? string[] + local function add_tabstop(index, placeholder, choices) + tabstop_data[index] = tabstop_data[index] or {} + local range = compute_tabstop_range(snippet_text, placeholder) + table.insert(tabstop_data[index], { range = range, choices = choices }) end --- Appends the given text to the snippet, taking care of indentation. @@ -428,7 +454,7 @@ function M.expand(input) append_to_snippet(value) elseif type == G.NodeType.Choice then --- @cast data vim.snippet.ChoiceData - append_to_snippet(data.values[1]) + add_tabstop(data.tabstop, nil, data.values) elseif type == G.NodeType.Variable then --- @cast data vim.snippet.VariableData -- Try to get the variable's value. @@ -436,7 +462,7 @@ function M.expand(input) if not value then -- Unknown variable, make this a tabstop and use the variable name as a placeholder. value = data.name - local tabstop_indexes = vim.tbl_keys(tabstop_ranges) + local tabstop_indexes = vim.tbl_keys(tabstop_data) local index = math.max(unpack((#tabstop_indexes == 0 and { 0 }) or tabstop_indexes)) + 1 add_tabstop(index, value) end @@ -449,8 +475,8 @@ function M.expand(input) -- $0, which defaults to the end of the snippet, defines the final cursor position. -- Make sure the snippet has exactly one of these. - if vim.tbl_contains(vim.tbl_keys(tabstop_ranges), 0) then - assert(#tabstop_ranges[0] == 1, 'Snippet has multiple $0 tabstops') + if vim.tbl_contains(vim.tbl_keys(tabstop_data), 0) then + assert(#tabstop_data[0] == 1, 'Snippet has multiple $0 tabstops') else add_tabstop(0) end @@ -469,7 +495,7 @@ function M.expand(input) right_gravity = false, end_right_gravity = true, }) - M._session = Session.new(bufnr, snippet_extmark, tabstop_ranges) + M._session = Session.new(bufnr, snippet_extmark, tabstop_data) -- Jump to the first tabstop. M.jump(1) -- cgit