diff options
author | Josh Rahm <joshuarahm@gmail.com> | 2023-11-30 20:35:25 +0000 |
---|---|---|
committer | Josh Rahm <joshuarahm@gmail.com> | 2023-11-30 20:35:25 +0000 |
commit | 1b7b916b7631ddf73c38e3a0070d64e4636cb2f3 (patch) | |
tree | cd08258054db80bb9a11b1061bb091c70b76926a /runtime/lua/vim/snippet.lua | |
parent | eaa89c11d0f8aefbb512de769c6c82f61a8baca3 (diff) | |
parent | 4a8bf24ac690004aedf5540fa440e788459e5e34 (diff) | |
download | rneovim-1b7b916b7631ddf73c38e3a0070d64e4636cb2f3.tar.gz rneovim-1b7b916b7631ddf73c38e3a0070d64e4636cb2f3.tar.bz2 rneovim-1b7b916b7631ddf73c38e3a0070d64e4636cb2f3.zip |
Merge remote-tracking branch 'upstream/master' into aucmd_textputpostaucmd_textputpost
Diffstat (limited to 'runtime/lua/vim/snippet.lua')
-rw-r--r-- | runtime/lua/vim/snippet.lua | 591 |
1 files changed, 591 insertions, 0 deletions
diff --git a/runtime/lua/vim/snippet.lua b/runtime/lua/vim/snippet.lua new file mode 100644 index 0000000000..32a8ea0b0d --- /dev/null +++ b/runtime/lua/vim/snippet.lua @@ -0,0 +1,591 @@ +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('<cword>') + 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 + +--- 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 bufnr integer +--- @field index integer +--- @field choices? string[] +local Tabstop = {} + +--- Creates a new tabstop. +--- +--- @package +--- @param index integer +--- @param bufnr integer +--- @param range Range4 +--- @param choices? string[] +--- @return vim.snippet.Tabstop +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, + end_line = range[3], + end_col = range[4], + hl_group = 'SnippetTabstop', + }) + + local self = setmetatable( + { extmark_id = extmark_id, bufnr = bufnr, index = index, choices = choices }, + { __index = Tabstop } + ) + + return self +end + +--- Returns the tabstop's range. +--- +--- @package +--- @return Range4 +function Tabstop:get_range() + return get_extmark_range(self.bufnr, self.extmark_id) +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 extmark_id integer +--- @field tabstops table<integer, vim.snippet.Tabstop[]> +--- @field current_tabstop vim.snippet.Tabstop +local Session = {} + +--- Creates a new snippet session in the current buffer. +--- +--- @package +--- @param bufnr integer +--- @param snippet_extmark integer +--- @param tabstop_data table<integer, { range: Range4, choices?: string[] }[]> +--- @return vim.snippet.Session +function Session.new(bufnr, snippet_extmark, tabstop_data) + local self = setmetatable({ + bufnr = bufnr, + extmark_id = snippet_extmark, + tabstops = {}, + current_tabstop = Tabstop.new(0, bufnr, { 0, 0, 0, 0 }), + }, { __index = Session }) + + -- Create the tabstops. + 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, data.range, data.choices)) + end + end + + return self +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 } + +--- 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 +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('<Right>', col))) + end + + 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, 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('<Esc>') + 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 + feedkeys('<Esc>') + end + move_cursor_to(range[1] + 1, range[2] + 1) + feedkeys('v') + move_cursor_to(range[3] + 1, range[4]) + feedkeys('o<c-g>') + 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 + + 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 + + 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 + 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() + -- 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 + + -- 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 = {} + local base_indent = vim.api.nvim_get_current_line():match('^%s*') or '' + + -- Get the placeholders we should use for each tabstop index. + --- @type table<integer, string> + 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<integer, { range: Range4, choices?: string[] }[]> + local tabstop_data = {} + + --- @param index integer + --- @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. + --- + --- @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 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 + 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. + 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_data) + 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_data), 0) then + assert(#tabstop_data[0] == 1, 'Snippet has multiple $0 tabstops') + else + 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(bufnr, cursor_row, cursor_col, cursor_row, cursor_col, snippet_text) + + -- 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_data) + + -- 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' }, '<Tab>', function() +--- if vim.snippet.jumpable(1) then +--- return '<cmd>lua vim.snippet.jump(1)<cr>' +--- else +--- return '<Tab>' +--- 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' }, '<Tab>', function() +--- if vim.snippet.jumpable(1) then +--- return '<cmd>lua vim.snippet.jump(1)<cr>' +--- else +--- return '<Tab>' +--- 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 |