diff --git a/lua/luasnip/_types.lua b/lua/luasnip/_types.lua index 435b367c0..e179c8257 100644 --- a/lua/luasnip/_types.lua +++ b/lua/luasnip/_types.lua @@ -1,7 +1,6 @@ ---@alias LuaSnip.Cursor {[1]: number, [2]: number} ---- 0-based region within a single line ----@class LuaSnip.MatchRegion +---@class LuaSnip.MatchRegion 0-based region within a single line ---@field row integer 0-based row ---@field col_range { [1]: integer, [2]: integer } 0-based column range, from-in, to-exclusive diff --git a/lua/luasnip/config.lua b/lua/luasnip/config.lua index c650d1aac..c9eb9f6df 100644 --- a/lua/luasnip/config.lua +++ b/lua/luasnip/config.lua @@ -101,10 +101,14 @@ c = { require("luasnip").unlink_current_if_deleted ) end - ls_autocmd( - session.config.update_events, - require("luasnip").active_update_dependents - ) + ls_autocmd(session.config.update_events, function() + -- don't update due to events if an update due to luasnip is pending anyway. + -- (Also, this would be bad because luasnip may not be in an + -- consistent state whenever an autocommand is triggered) + if not session.jump_active then + require("luasnip").active_update_dependents() + end + end) if session.config.region_check_events ~= nil then ls_autocmd(session.config.region_check_events, function() require("luasnip").exit_out_of_region( diff --git a/lua/luasnip/default_config.lua b/lua/luasnip/default_config.lua index 6198d3e4e..885064e95 100644 --- a/lua/luasnip/default_config.lua +++ b/lua/luasnip/default_config.lua @@ -53,6 +53,9 @@ local lazy_snip_env = { k = function() return require("luasnip.nodes.key_indexer").new_key end, + opt = function() + return require("luasnip.nodes.optional_arg").new_opt + end, ai = function() return require("luasnip.nodes.absolute_indexer") end, diff --git a/lua/luasnip/extras/select_choice.lua b/lua/luasnip/extras/select_choice.lua index adca3a2ad..0b969fb63 100644 --- a/lua/luasnip/extras/select_choice.lua +++ b/lua/luasnip/extras/select_choice.lua @@ -1,13 +1,20 @@ local session = require("luasnip.session") local ls = require("luasnip") +local node_util = require("luasnip.nodes.util") +local feedkeys = require("luasnip.util.feedkeys") -local function set_choice_callback(_, indx) - if not indx then - return +-- in this procedure, make sure that api_leave is called before +-- set_choice_callback exits. +local function set_choice_callback(data) + return function(_, indx) + if not indx then + ls._api_leave() + return + end + -- set_choice restores cursor from before. + ls._set_choice(indx, { cursor_restore_data = data, skip_update = true }) + ls._api_leave() end - -- feed+immediately execute i to enter INSERT after vim.ui.input closes. - vim.api.nvim_feedkeys("i", "x", false) - ls.set_choice(indx) end local function select_choice() @@ -15,11 +22,31 @@ local function select_choice() session.active_choice_nodes[vim.api.nvim_get_current_buf()], "No active choiceNode" ) - vim.ui.select( - ls.get_current_choices(), - { kind = "luasnip" }, - set_choice_callback + local active = session.current_nodes[vim.api.nvim_get_current_buf()] + + ls._api_enter() + + ls._active_update_dependents() + + if not session.active_choice_nodes[vim.api.nvim_get_current_buf()] then + print("Active choice was removed while updating a dynamicNode.") + return + end + + local restore_data = node_util.store_cursor_node_relative( + active, + { place_cursor_mark = true } ) + + -- make sure all movements are done, otherwise the movements may be put into + -- the select-dialog. + feedkeys.enqueue_action(function() + vim.ui.select( + ls.get_current_choices(), + { kind = "luasnip" }, + set_choice_callback(restore_data) + ) + end) end return select_choice diff --git a/lua/luasnip/init.lua b/lua/luasnip/init.lua index 4dd94b5ce..734c84b51 100644 --- a/lua/luasnip/init.lua +++ b/lua/luasnip/init.lua @@ -2,6 +2,8 @@ local util = require("luasnip.util.util") local lazy_table = require("luasnip.util.lazy_table") local types = require("luasnip.util.types") local node_util = require("luasnip.nodes.util") +local tbl_util = require("luasnip.util.table") +local feedkeys = require("luasnip.util.feedkeys") local session = require("luasnip.session") local snippet_collection = require("luasnip.session.snippet_collection") @@ -33,6 +35,41 @@ function API.get_active_snip() return node end +local luasnip_changedtick = 0 +local function api_enter() + session.jump_active = true + if session.luasnip_changedtick ~= nil then + log.error( + [[ +api_enter called while luasnip_changedtick was non-nil. This +may be to a previous error, or due to unexpected control-flow. Check the +traceback and consider reporting this. Traceback: %s +]], + debug.traceback() + ) + end + session.luasnip_changedtick = luasnip_changedtick + luasnip_changedtick = luasnip_changedtick + 1 +end +local function api_leave() + -- once all movements and text-modifications (and autocommands triggered by + -- these) are done, we can set jump_active false, and allow the various + -- autocommands to change luasnip-state again. + feedkeys.enqueue_action(function() + session.jump_active = false + end) + session.luasnip_changedtick = nil +end + +local function api_do(fn, ...) + api_enter() + + local fn_res = fn(...) + api_leave() + + return fn_res +end + -- returns matching snippet (needs to be copied before usage!) and its expand- -- parameters(trigger and captures). params are returned here because there's -- no need to recalculate them. @@ -163,18 +200,20 @@ function API.unlink_current() unlink_set_adjacent_as_current_no_log(current.parent.snippet) end --- return next active node. -local function safe_jump_current(dir, no_move, dry_run) - local node = session.current_nodes[vim.api.nvim_get_current_buf()] - if not node then - return nil - end +local function node_update_dependents_preserve_position(node, current, opts) + -- set luasnip_changedtick so that static_text is preserved when possible. + local restore_data = opts.cursor_restore_data + or node_util.store_cursor_node_relative( + current, + { place_cursor_mark = true } + ) - local ok, res = pcall(node.jump_from, node, dir, no_move, dry_run) - if ok then - return res - else - local snip = node.parent.snippet + -- update all nodes that depend on this one. + local ok, res = + pcall(node.update_dependents, node, { own = true, parents = true }) + + if not ok then + local snip = node:get_snippet() unlink_set_adjacent_as_current( snip, @@ -182,17 +221,183 @@ local function safe_jump_current(dir, no_move, dry_run) snip.trigger, res ) - return session.current_nodes[vim.api.nvim_get_current_buf()] + return { + jump_done = false, + new_current = session.current_nodes[vim.api.nvim_get_current_buf()], + } + end + + -- update successful => check if the current node is still visible. + if current.visible then + if not opts.no_move and opts.restore_position then + -- node is visible: restore position. + local active_snippet = current:get_snippet() + node_util.restore_cursor_pos_relative( + current, + restore_data[active_snippet.node_store_id] + ) + end + + return { jump_done = false, new_current = current } + else + -- node not visible => need to find a new node to set as current. + + -- first, find leafmost (starting at node) visible node. + local active_snippet = node:get_snippet() + while not active_snippet.visible do + local parent_node = active_snippet.parent_node + if not parent_node then + -- very unlikely/not possible: all snippets are exited. + return { jump_done = false, new_current = nil } + end + active_snippet = parent_node:get_snippet() + end + + -- have found first visible snippet => look for visible dynamicNode, + -- starting from which we can try to find a new active node. + local node_parent = + restore_data[active_snippet.node_store_id].node.parent + + -- find visible dynamicNode that contained the (now-inactive) insertNode. + -- since the node was no longer visible after an update, it must have + -- been contained in a dynamicNode, and we don't have to handle the + -- case that we can't find it. + while + node_parent.dynamicNode == nil + or node_parent.dynamicNode.visible == false + do + node_parent = node_parent.parent + end + local d = node_parent.dynamicNode + assert( + d.active, + "Visible dynamicNode that was a parent of the current node is not active after the update!! If you get this message, please open an issue with LuaSnip!" + ) + + local found_nodes = {} + d:subtree_do({ + pre = function(sd_node) + if sd_node.key then + -- any snippet we encounter here was generated before, and + -- if sd_node has the correct key, its snippet has a + -- node_store_id that corresponds to it. + local snip_node_store_id = + sd_node.parent.snippet.node_store_id + -- make sure that the key we found belongs to this + -- snippets' active node. + -- Also use the first valid node, and not the second one. + -- Doesn't really matter (ambiguous keys -> undefined + -- behaviour), but we should just use the first one, as + -- that seems more like what would be expected. + if + snip_node_store_id + and restore_data[snip_node_store_id] + and sd_node.key == restore_data[snip_node_store_id].key + and not found_nodes[snip_node_store_id] + then + found_nodes[snip_node_store_id] = sd_node + end + elseif + sd_node.store_id + and restore_data[sd_node.store_id] + and not found_nodes[sd_node.store_id] + then + found_nodes[sd_node.store_id] = sd_node + end + end, + post = util.nop, + do_child_snippets = true, + }) + + local new_current + for _, store_id in ipairs(restore_data.store_ids) do + if found_nodes[store_id] then + new_current = found_nodes[store_id] + break + end + end + + if new_current then + node_util.refocus(d, new_current) + + if not opts.no_move and opts.restore_position then + -- node is visible: restore position + node_util.restore_cursor_pos_relative( + new_current, + restore_data[new_current.parent.snippet.node_store_id] + ) + end + + return { jump_done = false, new_current = new_current } + else + -- could not find corresponding node -> just jump into the + -- dynamicNode that should have generated it. + return { + jump_done = true, + new_current = d:jump_into_snippet(opts.no_move), + } + end end end ---- Jump forwards or backwards ----@param dir 1|-1 Jump forward for 1, backward for -1. ----@return boolean _ `true` if a jump was performed, `false` otherwise. -function API.jump(dir) +local function update_dependents(node, opts) + local active = session.current_nodes[vim.api.nvim_get_current_buf()] + -- don't update if a jump/change_choice is in progress, or if we don't have + -- an active node. + if active ~= nil then + local upd_res = node_update_dependents_preserve_position(node, active, { + no_move = false, + restore_position = true, + cursor_restore_data = opts and opts.cursor_restore_data, + }) + if upd_res.new_current then + upd_res.new_current:focus() + session.current_nodes[vim.api.nvim_get_current_buf()] = + upd_res.new_current + end + end +end + +local function _active_update_dependents(opts) + update_dependents( + session.current_nodes[vim.api.nvim_get_current_buf()], + opts + ) +end + +-- return next active node. +local function safe_jump_current(dir, no_move, dry_run) + local node = session.current_nodes[vim.api.nvim_get_current_buf()] + if not node then + return nil + end + + -- don't update for -1-node. + if not dry_run and node.pos >= 0 then + local upd_res = node_update_dependents_preserve_position( + node, + node, + { no_move = no_move, restore_position = false } + ) + if upd_res.jump_done then + return upd_res.new_current + else + node = upd_res.new_current + end + end + + if node then + local ok, res = pcall(node.jump_from, node, dir, no_move, dry_run) + if ok then + return res + end + end +end + +local function _jump(dir) local current = session.current_nodes[vim.api.nvim_get_current_buf()] if current then - local next_node = util.no_region_check_wrap(safe_jump_current, dir) + local next_node = safe_jump_current(dir) if next_node == nil then session.current_nodes[vim.api.nvim_get_current_buf()] = nil return true @@ -210,6 +415,13 @@ function API.jump(dir) end end +--- Jump forwards or backwards +---@param dir 1|-1 Jump forward for 1, backward for -1. +---@return boolean _ `true` if a jump was performed, `false` otherwise. +function API.jump(dir) + return api_do(_jump, dir) +end + --- Find the node the next jump will end up at. This will not work always, --- because we will not update the node before jumping, so if the jump would --- e.g. insert a new node between this node and its pre-update jump target, @@ -296,11 +508,9 @@ function API.locally_jumpable(dir) end local function _jump_into_default(snippet) - return util.no_region_check_wrap(snippet.jump_into, snippet, 1) + return snippet:jump_into(1) end --- opts.clear_region: table, keys `from` and `to`, both (0,0)-indexed. - ---@class LuaSnip.Opts.SnipExpandExpandParams ---@field trigger? string What to set as the expanded snippets' trigger --- (Defaults to `snip.trigger`). @@ -380,12 +590,7 @@ end --- } --- ``` ---- Expand a snippet in the current buffer. ----@param snippet LuaSnip.Snippet The snippet. ----@param opts? LuaSnip.Opts.SnipExpand Optional additional arguments. ----@return LuaSnip.ExpandedSnippet _ The snippet that was inserted into the ---- buffer. -function API.snip_expand(snippet, opts) +local function _snip_expand(snippet, opts) local snip = snippet:copy() opts = opts or {} @@ -461,14 +666,25 @@ function API.snip_expand(snippet, opts) -- -1 to disable count. vim.cmd([[silent! call repeat#set("\luasnip-expand-repeat", -1)]]) + _active_update_dependents() + return snip end ---- Find a snippet whose trigger matches the text before the cursor and expand ---- it. ----@param opts? LuaSnip.Opts.Expand Subset of opts accepted by `snip_expand`. ----@return boolean _ Whether a snippet was expanded. -function API.expand(opts) +--- Expand a snippet in the current buffer. +---@param snippet LuaSnip.Snippet The snippet. +---@param opts? LuaSnip.Opts.SnipExpand Optional additional arguments. +---@return LuaSnip.ExpandedSnippet _ The snippet that was inserted into the +--- buffer. +function API.snip_expand(snippet, opts) + return api_do(_snip_expand, snippet, opts) +end + +---Find a snippet matching the current cursor-position. +---@param opts table: may contain: +--- - `jump_into_func`: passed through to `snip_expand`. +---@return boolean: whether a snippet was expanded. +local function _expand(opts) local expand_params local snip -- find snip via next_expand (set from previous expandable()) or manual matching. @@ -498,7 +714,7 @@ function API.expand(opts) } -- override snip with expanded copy. - snip = API.snip_expand(snip, { + snip = _snip_expand(snip, { expand_params = expand_params, -- clear trigger-text. clear_region = clear_region, @@ -510,48 +726,61 @@ function API.expand(opts) return false end +--- Find a snippet whose trigger matches the text before the cursor and expand +--- it. +---@param opts? LuaSnip.Opts.Expand Subset of opts accepted by `snip_expand`. +---@return boolean _ Whether a snippet was expanded. +function API.expand(opts) + return api_do(_expand, opts) +end + --- Find an autosnippet matching the text at the cursor-position and expand it. function API.expand_auto() - local snip, expand_params = - match_snippet(util.get_current_line_to_cursor(), "autosnippets") - if snip then - assert(expand_params) -- hint lsp type checker - local cursor = util.get_cursor_0ind() - local clear_region = expand_params.clear_region - or { - from = { - cursor[1], - cursor[2] - #expand_params.trigger, - }, - to = cursor, - } - snip = API.snip_expand(snip, { - expand_params = expand_params, - -- clear trigger-text. - clear_region = clear_region, - }) - end + api_do(function() + local snip, expand_params = + match_snippet(util.get_current_line_to_cursor(), "autosnippets") + if snip then + local cursor = util.get_cursor_0ind() + local clear_region = expand_params.clear_region + or { + from = { + cursor[1], + cursor[2] - #expand_params.trigger, + }, + to = cursor, + } + snip = _snip_expand(snip, { + expand_params = expand_params, + -- clear trigger-text. + clear_region = clear_region, + }) + end + end) end --- Repeat the last performed `snip_expand`. Useful for dot-repeat. function API.expand_repeat() - -- prevent clearing text with repeated expand. - session.last_expand_opts.clear_region = nil - session.last_expand_opts.pos = nil + api_do(function() + -- prevent clearing text with repeated expand. + session.last_expand_opts.clear_region = nil + session.last_expand_opts.pos = nil - API.snip_expand(session.last_expand_snip, session.last_expand_opts) + _snip_expand(session.last_expand_snip, session.last_expand_opts) + end) end --- Expand at the cursor, or jump forward. ---@return boolean _ Whether an action was performed. function API.expand_or_jump() - if API.expand() then - return true - end - if API.jump(1) then - return true - end - return false + return api_do(function() + if _expand() then + return true + end + if _jump(1) then + return true + end + return false + end) end --- Expand a snippet specified in lsp-style. @@ -561,7 +790,8 @@ end --- `snip_expand`. function API.lsp_expand(body, opts) -- expand snippet as-is. - API.snip_expand( + api_do( + _snip_expand, ls.parser.parse_snippet( "", body, @@ -597,40 +827,98 @@ local function safe_choice_action(snip, ...) end end ---- Change the currently active choice. ----@param val 1|-1 Move one choice forward or backward. -function API.change_choice(val) +local function _change_choice(val, opts) local active_choice = session.active_choice_nodes[vim.api.nvim_get_current_buf()] - assert(active_choice, "No active choiceNode") - local new_active = util.no_region_check_wrap( - safe_choice_action, + + -- make sure we update completely, there may have been changes to the + -- buffer since the last update. + if not opts.skip_update then + assert(active_choice, "No active choiceNode") + + _active_update_dependents({ + cursor_restore_data = opts.cursor_restore_data, + }) + + active_choice = + session.active_choice_nodes[vim.api.nvim_get_current_buf()] + if not active_choice then + print("Active choice was removed while updating a dynamicNode.") + return + end + end + + -- if the active choice exists current_node still does. + local current_node = session.current_nodes[vim.api.nvim_get_current_buf()] + + local new_active = safe_choice_action( active_choice.parent.snippet, active_choice.change_choice, active_choice, val, - session.current_nodes[vim.api.nvim_get_current_buf()] + session.current_nodes[vim.api.nvim_get_current_buf()], + opts.skip_update and opts.cursor_restore_data + or node_util.store_cursor_node_relative( + current_node, + { place_cursor_mark = false } + ) ) session.current_nodes[vim.api.nvim_get_current_buf()] = new_active + _active_update_dependents() end ---- Set the currently active choice. ----@param choice_indx integer Index of the choice to switch to. -function API.set_choice(choice_indx) +--- Change the currently active choice. +---@param val 1|-1 Move one choice forward or backward. +function API.change_choice(val) + api_do(_change_choice, val, {}) +end + +local function _set_choice(choice_indx, opts) local active_choice = session.active_choice_nodes[vim.api.nvim_get_current_buf()] - assert(active_choice, "No active choiceNode") + + if not opts.skip_update then + assert(active_choice, "No active choiceNode") + + _active_update_dependents({ + cursor_restore_data = opts.cursor_restore_data, + }) + + active_choice = + session.active_choice_nodes[vim.api.nvim_get_current_buf()] + if not active_choice then + print("Active choice was removed while updating a dynamicNode.") + return + end + end + + local current_node = session.current_nodes[vim.api.nvim_get_current_buf()] + local choice = active_choice.choices[choice_indx] assert(choice, "Invalid Choice") - local new_active = util.no_region_check_wrap( - safe_choice_action, + + local new_active = safe_choice_action( active_choice.parent.snippet, active_choice.set_choice, active_choice, choice, - session.current_nodes[vim.api.nvim_get_current_buf()] + current_node, + -- if the update was skipped, we have to use the cursor_restore_data + -- here. + opts.skip_update and opts.cursor_restore_data + or node_util.store_cursor_node_relative( + current_node, + { place_cursor_mark = false } + ) ) session.current_nodes[vim.api.nvim_get_current_buf()] = new_active + _active_update_dependents() +end + +--- Set the currently active choice. +---@param choice_indx integer Index of the choice to switch to. +function API.set_choice(choice_indx) + api_do(_set_choice, choice_indx, {}) end --- Get a string-representation of all the current choiceNode's choices. @@ -652,54 +940,7 @@ end --- Update all nodes that depend on the currently-active node. function API.active_update_dependents() - local active = session.current_nodes[vim.api.nvim_get_current_buf()] - -- special case for startNode, cannot focus on those (and they can't - -- have dependents) - -- don't update if a jump/change_choice is in progress. - if not session.jump_active and active and active.pos > 0 then - -- Save cursor-pos to restore later. - local cur = util.get_cursor_0ind() - local cur_mark = vim.api.nvim_buf_set_extmark( - 0, - session.ns_id, - cur[1], - cur[2], - { right_gravity = false } - ) - - local ok, err = pcall(active.update_dependents, active) - if not ok then - unlink_set_adjacent_as_current( - active.parent.snippet, - "Error while updating dependents for snippet %s due to error %s", - active.parent.snippet.trigger, - err - ) - return - end - - -- 'restore' orientation of extmarks, may have been changed by some set_text or similar. - ok, err = pcall(active.focus, active) - if not ok then - unlink_set_adjacent_as_current( - active.parent.snippet, - "Error while entering node in snippet %s: %s", - active.parent.snippet.trigger, - err - ) - - return - end - - -- Don't account for utf, nvim_win_set_cursor doesn't either. - cur = vim.api.nvim_buf_get_extmark_by_id( - 0, - session.ns_id, - cur_mark, - { details = false } - ) - util.set_cursor_0ind(cur) - end + api_do(_active_update_dependents) end --- Generate and store the docstrings for a list of snippets as generated by @@ -1197,4 +1438,12 @@ API.log = require("luasnip.util.log") ---@class LuaSnip: LuaSnip.API, LuaSnip.LazyAPI ls = lazy_table(API, ls_lazy) + +-- undocumented, internally-used, exported functions. +ls._active_update_dependents = _active_update_dependents +ls._api_do = api_do +ls._api_enter = api_enter +ls._api_leave = api_leave +ls._set_choice = _set_choice + return ls diff --git a/lua/luasnip/nodes/choiceNode.lua b/lua/luasnip/nodes/choiceNode.lua index 58dac2b2a..1331e644c 100644 --- a/lua/luasnip/nodes/choiceNode.lua +++ b/lua/luasnip/nodes/choiceNode.lua @@ -7,6 +7,8 @@ local mark = require("luasnip.util.mark").mark local session = require("luasnip.session") local sNode = require("luasnip.nodes.snippet").SN local extend_decorator = require("luasnip.util.extend_decorator") +local feedkeys = require("luasnip.util.feedkeys") +local log = require("luasnip.util.log").new("choice") ---@class LuaSnip.ChoiceNode.ItemNode: LuaSnip.Node @@ -22,24 +24,6 @@ function ChoiceNode:init_nodes() -- forward values for unknown keys from choiceNode. choice.choice = self - local node_mt = getmetatable(choice) - setmetatable(choice, { - __index = function(node, key) - return node_mt[key] or node.choice[key] - end, - }) - - -- replace nodes' original update_dependents with function that also - -- calls this choiceNodes' update_dependents. - -- - -- cannot define as `function node:update_dependents()` as _this_ - -- choiceNode would be `self`. - -- Also rely on node.choice, as using `self` there wouldn't be caught - -- by copy and the wrong node would be updated. - choice.update_dependents = function(node) - node:_update_dependents() - node.choice:update_dependents() - end choice.next_choice = self.choices[i + 1] choice.prev_choice = self.choices[i - 1] @@ -125,6 +109,13 @@ end extend_decorator.register(ChoiceNode.C, { arg_indx = 3 }) function ChoiceNode:subsnip_init() + for _, choice in ipairs(self.choices) do + choice.parent = self.parent + -- only insertNode needs this. + if choice.type == 2 or choice.type == 1 or choice.type == 3 then + choice.pos = self.pos + end + end node_util.subsnip_init_children(self.parent, self.choices) end @@ -194,6 +185,7 @@ function ChoiceNode:input_enter(_, dry_run) session.active_choice_nodes[vim.api.nvim_get_current_buf()] = self self.visited = true self.active = true + self.input_active = true self:event(events.enter) end @@ -204,10 +196,12 @@ function ChoiceNode:input_leave(_, dry_run) return end + self.input_active = false + self:event(events.leave) self.mark:update_opts(self:get_passive_ext_opts()) - self:update_dependents() + session.active_choice_nodes[vim.api.nvim_get_current_buf()] = self.prev_choice_node self.active = false @@ -223,10 +217,7 @@ function ChoiceNode:get_static_text() end function ChoiceNode:get_docstring() - return util.string_wrap( - self.choices[1]:get_docstring(), - rawget(self, "pos") - ) + return util.string_wrap(self.choices[1]:get_docstring(), self.pos) end function ChoiceNode:jump_into(dir, no_move, dry_run) @@ -267,12 +258,12 @@ end function ChoiceNode:setup_choice_jumps() end -function ChoiceNode:find_node(predicate) +function ChoiceNode:find_node(predicate, opts) if self.active_choice then if predicate(self.active_choice) then return self.active_choice else - return self.active_choice:find_node(predicate) + return self.active_choice:find_node(predicate, opts) end end return nil @@ -281,22 +272,18 @@ end -- used to uniquely identify this change-choice-action. local change_choice_id = 0 -function ChoiceNode:set_choice(choice, current_node) +function ChoiceNode:set_choice(choice, current_node, cursor_restore_data) change_choice_id = change_choice_id + 1 -- to uniquely identify this node later (storing the pointer isn't enough -- because this is supposed to work with restoreNodes, which are copied). current_node.change_choice_id = change_choice_id - local insert_pre_cc = vim.fn.mode() == "i" - -- is byte-indexed! Doesn't matter here, but important to be aware of. - local cursor_pos_pre_relative = - util.pos_sub(util.get_cursor_0ind(), current_node.mark:pos_begin_raw()) - self.active_choice:store() -- tear down current choice. - -- leave all so the choice (could be a snippet) is in the correct state for the next enter. - node_util.leave_nodes_between(self.active_choice, current_node) + -- leave all so the choice (could be a snippet) is in the correct state for + -- the next enter. + node_util.refocus(current_node, self.active_choice) self.active_choice:exit() @@ -304,9 +291,9 @@ function ChoiceNode:set_choice(choice, current_node) -- -- active_choice has to be disabled (nilled?) to prevent reading from -- cleared mark in set_mark_rgrav (which will be called in - -- self:set_text({""}) a few lines below). + -- self:set_text_raw({""}) a few lines below). self.active_choice = nil - self:set_text({ "" }) + self:set_text_raw({ "" }) self.active_choice = choice @@ -327,35 +314,26 @@ function ChoiceNode:set_choice(choice, current_node) self.active_choice:subtree_set_pos_rgrav(to, -1, true) self.active_choice:update_restore() - self.active_choice:update_all_dependents() - self:update_dependents() + -- update outside dependents later, in init.lua:set_choice! - -- Another node may have been entered in update_dependents. - self:focus() self:event(events.change_choice) if self.restore_cursor then local target_node = self:find_node(function(test_node) return test_node.change_choice_id == change_choice_id - end) + end, { find_in_child_snippets = true }) if target_node then - -- the node that the cursor was in when changeChoice was called exists - -- in the active choice! Enter it and all nodes between it and this choiceNode, - -- then set the cursor. - -- Pass no_move=true, we will set the cursor ourselves. - node_util.enter_nodes_between(self, target_node, true) - - if insert_pre_cc then - util.set_cursor_0ind( - util.pos_add( - target_node.mark:pos_begin_raw(), - cursor_pos_pre_relative - ) - ) - else - node_util.select_node(target_node) - end + -- the node that the cursor was in when changeChoice was called + -- exists in the active choice! Enter it and all nodes between it + -- and this choiceNode, then set the cursor. + + node_util.refocus(self, target_node) + node_util.restore_cursor_pos_relative( + target_node, + cursor_restore_data[target_node.parent.snippet.node_store_id] + ) + return target_node end end @@ -363,12 +341,13 @@ function ChoiceNode:set_choice(choice, current_node) return self.active_choice:jump_into(1) end -function ChoiceNode:change_choice(dir, current_node) +function ChoiceNode:change_choice(dir, current_node, cursor_restore_data) -- stylua: ignore return self:set_choice( dir == 1 and self.active_choice.next_choice or self.active_choice.prev_choice, - current_node ) + current_node, + cursor_restore_data) end function ChoiceNode:copy() @@ -429,20 +408,6 @@ function ChoiceNode:set_argnodes(dict) end end -function ChoiceNode:update_all_dependents() - -- call the version that only updates this node. - self:_update_dependents() - - self.active_choice:update_all_dependents() -end - -function ChoiceNode:update_all_dependents_static() - -- call the version that only updates this node. - self:_update_dependents_static() - - self.active_choice:update_all_dependents_static() -end - function ChoiceNode:resolve_position(position) return self.choices[position] end @@ -470,6 +435,19 @@ function ChoiceNode:extmarks_valid() return node_util.generic_extmarks_valid(self, self.active_choice) end +function ChoiceNode:subtree_do(opts) + opts.pre(self) + self.active_choice:subtree_do(opts) + opts.post(self) +end + +function ChoiceNode:subtree_leave_entered() + if self.input_active then + self.active_choice:subtree_leave_entered() + self:input_leave() + end +end + return { C = ChoiceNode.C, } diff --git a/lua/luasnip/nodes/dynamicNode.lua b/lua/luasnip/nodes/dynamicNode.lua index 0daad6f20..3543b8342 100644 --- a/lua/luasnip/nodes/dynamicNode.lua +++ b/lua/luasnip/nodes/dynamicNode.lua @@ -7,6 +7,7 @@ local events = require("luasnip.util.events") local FunctionNode = require("luasnip.nodes.functionNode").FunctionNode local SnippetNode = require("luasnip.nodes.snippet").SN local extend_decorator = require("luasnip.util.extend_decorator") +local mark = require("luasnip.util.mark").mark local function D(pos, fn, args, opts) opts = opts or {} @@ -18,6 +19,7 @@ local function D(pos, fn, args, opts) type = types.dynamicNode, mark = nil, user_args = opts.user_args or {}, + snippetstring_args = opts.snippetstring_args or false, dependents = {}, active = false, }, opts) @@ -44,7 +46,6 @@ function DynamicNode:input_leave(_, dry_run) end self:event(events.leave) - self:update_dependents() self.active = false self.mark:update_opts(self:get_passive_ext_opts()) end @@ -66,6 +67,8 @@ function DynamicNode:get_docstring() if not self.docstring then if self.static_snip then self.docstring = self.static_snip:get_docstring() + elseif self.snip then + self.docstring = self.snip:get_docstring() else self.docstring = { "" } end @@ -74,7 +77,32 @@ function DynamicNode:get_docstring() end -- DynamicNode's don't have static text, only set as visible. -function DynamicNode:put_initial(_) +function DynamicNode:put_initial(pos) + -- if we generated a snippet before, insert it into the buffer now. This + -- can happen if this dynamicNode was removed (eg. because of a + -- change_choice or an update to a dynamicNode), and is then reinserted due + -- to a restoreNode or snippetstring_args. + -- + -- This procedure is necessary to keep + if self.snip then + -- position might (will probably!!) still have changed, so update it + -- here too (as opposed to only in update). + self.snip:init_positions(self.snip_absolute_position) + self.snip:init_insert_positions(self.snip_absolute_insert_position) + + self.snip:make_args_absolute() + + self.snip:set_dependents() + self.snip:set_argnodes(self.parent.snippet.dependents_dict) + + local old_pos = vim.deepcopy(pos) + self.snip:put_initial(pos) + local mark_opts = vim.tbl_extend("keep", { + right_gravity = false, + end_right_gravity = false, + }, self.snip:get_passive_ext_opts()) + self.snip.mark = mark(old_pos, pos, mark_opts) + end self.visible = true end @@ -112,9 +140,17 @@ function DynamicNode:jump_into(dir, no_move, dry_run) end end +function DynamicNode:jump_into_snippet(no_move) + self.active = false + return self:jump_into(1, no_move, false) +end + function DynamicNode:update() local args = self:get_args() - if vim.deep_equal(self.last_args, args) then + local str_args = node_util.str_args(args) + local effective_args = self.snippetstring_args and args or str_args + + if vim.deep_equal(self.last_args, str_args) then -- no update, the args still match. return end @@ -130,29 +166,46 @@ function DynamicNode:update() return end + -- make sure all nodes store their up-to-date content. + -- This is relevant if an argnode contains a snippet which contains a + -- restoreNode: the snippet will be copied and the `self.snip:exit` + -- will cause a store for the original snippet, but not the copy that + -- may be inserted into `tmp` by `self.fn`. + self.snip:store() + self.snip:subtree_leave_entered() + -- build new snippet before exiting, markers may be needed for construncting. tmp = self.fn( - args, + effective_args, self.parent, self.snip.old_state, unpack(self.user_args) ) + self.snip:exit() self.snip = nil -- focuses node. - self:set_text({ "" }) + self:set_text_raw({ "" }) else self:focus() if not args then - -- no snippet exists, set an empty one. + -- not all args are available => set to empty snippet. tmp = SnippetNode(nil, {}) else -- also enter node here. - tmp = self.fn(args, self.parent, nil, unpack(self.user_args)) + tmp = self.fn( + effective_args, + self.parent, + nil, + unpack(self.user_args) + ) end end - self.last_args = args + + -- make sure update only when text changed, not if there was just some kind + -- of metadata-modification of one of the snippets. + self.last_args = str_args -- act as if snip is directly inside parent. tmp.parent = self.parent @@ -167,13 +220,7 @@ function DynamicNode:update() tmp:resolve_node_ext_opts() tmp:subsnip_init() - tmp.mark = - self.mark:copy_pos_gravs(vim.deepcopy(tmp:get_passive_ext_opts())) tmp.dynamicNode = self - tmp.update_dependents = function(node) - node:_update_dependents() - node.dynamicNode:update_dependents() - end tmp:init_positions(self.snip_absolute_position) tmp:init_insert_positions(self.snip_absolute_insert_position) @@ -189,7 +236,13 @@ function DynamicNode:update() tmp:indent(self.parent.indentstr) -- sets own extmarks false,true + -- focus and then set snippetNode-gravity => make sure that + -- snippetNode-extmark is shifted correctly. self:focus() + + tmp.mark = + self.mark:copy_pos_gravs(vim.deepcopy(tmp:get_passive_ext_opts())) + local from, to = self.mark:pos_begin_end_raw() -- inserts nodes with extmarks false,false tmp:put_initial(from) @@ -203,10 +256,13 @@ function DynamicNode:update() -- Both are needed, because -- - a node could only depend on nodes outside of tmp -- - a node outside of tmp could depend on one inside of tmp - tmp:update() - tmp:update_all_dependents() + tmp:update_restore() - self:update_dependents() + -- update nodes that depend on this dynamicNode, nodes that are parents + -- (and thus have changed text after this update), and all of the + -- children's depedents (since they may have dependents outside this + -- dynamicNode, who have not yet been updated) + self:update_dependents({ own = true, children = true, parents = true }) end local update_errorstring = [[ @@ -216,7 +272,10 @@ Error while evaluating dynamicNode@%d for snippet '%s': :h luasnip-docstring for more info]] function DynamicNode:update_static() local args = self:get_static_args() - if vim.deep_equal(self.last_static_args, args) then + local str_args = node_util.str_args(args) + local effective_args = self.snippetstring_args and args or str_args + + if vim.deep_equal(self.last_static_args, str_args) then -- no update, the args still match. return end @@ -231,9 +290,9 @@ function DynamicNode:update_static() -- build new snippet before exiting, markers may be needed for construncting. ok, tmp = pcall( self.fn, - args, + effective_args, self.parent, - self.snip.old_state, + self.static_snip.old_state, unpack(self.user_args) ) else @@ -242,8 +301,13 @@ function DynamicNode:update_static() tmp = SnippetNode(nil, {}) else -- also enter node here. - ok, tmp = - pcall(self.fn, args, self.parent, nil, unpack(self.user_args)) + ok, tmp = pcall( + self.fn, + effective_args, + self.parent, + nil, + unpack(self.user_args) + ) end end if not ok then @@ -253,12 +317,12 @@ function DynamicNode:update_static() -- set empty snippet on failure tmp = SnippetNode(nil, {}) end - self.last_static_args = args + self.last_static_args = str_args -- act as if snip is directly inside parent. tmp.parent = self.parent tmp.indx = self.indx - tmp.pos = rawget(self, "pos") + tmp.pos = self.pos tmp.next = self tmp.prev = self @@ -268,10 +332,6 @@ function DynamicNode:update_static() tmp.snippet = self.parent.snippet tmp.dynamicNode = self - tmp.update_dependents_static = function(node) - node:_update_dependents_static() - node.dynamicNode:update_dependents_static() - end tmp:resolve_child_ext_opts() tmp:resolve_node_ext_opts() @@ -295,13 +355,15 @@ function DynamicNode:update_static() tmp:static_init() - tmp:update_static() - -- updates dependents in tmp. - tmp:update_all_dependents_static() - self.static_snip = tmp + + tmp:update_static() -- updates own dependents. - self:update_dependents_static() + self:update_dependents_static({ + own = true, + parents = true, + children = true, + }) end function DynamicNode:exit() @@ -312,8 +374,6 @@ function DynamicNode:exit() if self.snip then self.snip:exit() end - self.stored_snip = self.snip - self.snip = nil self.active = false end @@ -321,7 +381,7 @@ function DynamicNode:set_ext_opts(name) Node.set_ext_opts(self, name) -- might not have been generated (missing nodes). - if self.snip then + if self.snip and self.snip.visible then self.snip:set_ext_opts(name) end end @@ -334,11 +394,16 @@ end function DynamicNode:update_restore() -- only restore snippet if arg-values still match. - if self.stored_snip and vim.deep_equal(self:get_args(), self.last_args) then - local tmp = self.stored_snip + local args = self:get_args() + local str_args = node_util.str_args(args) - tmp.mark = - self.mark:copy_pos_gravs(vim.deepcopy(tmp:get_passive_ext_opts())) + -- only insert snip if it is not currently visible! + if + self.snip + and not self.snip.visible + and vim.deep_equal(str_args, self.last_args) + then + local tmp = self.snip -- position might (will probably!!) still have changed, so update it -- here too (as opposed to only in update). @@ -350,9 +415,11 @@ function DynamicNode:update_restore() tmp:set_dependents() tmp:set_argnodes(self.parent.snippet.dependents_dict) - -- sets own extmarks false,true - self:focus() - -- inserts nodes with extmarks false,false + -- also focuses node, and sets own extmarks false,true + self:set_text_raw({ "" }) + tmp.mark = + self.mark:copy_pos_gravs(vim.deepcopy(tmp:get_passive_ext_opts())) + local from, to = self.mark:pos_begin_end_raw() tmp:put_initial(from) -- adjust gravity in left side of snippet, such that it matches the current @@ -371,12 +438,12 @@ function DynamicNode:update_restore() end end -function DynamicNode:find_node(predicate) +function DynamicNode:find_node(predicate, opts) if self.snip then if predicate(self.snip) then return self.snip else - return self.snip:find_node(predicate) + return self.snip:find_node(predicate, opts) end end return nil @@ -408,32 +475,57 @@ end DynamicNode.make_args_absolute = FunctionNode.make_args_absolute DynamicNode.set_dependents = FunctionNode.set_dependents -function DynamicNode:resolve_position(position) +function DynamicNode:resolve_position(position, static) -- position must be 0, there are no other options. - return self.snip + if static then + return self.static_snip + else + return self.snip + end end function DynamicNode:subtree_set_pos_rgrav(pos, direction, rgrav) self.mark:set_rgrav(-direction, rgrav) - if self.snip then + if self.snip and self.snip.visible then self.snip:subtree_set_pos_rgrav(pos, direction, rgrav) end end function DynamicNode:subtree_set_rgrav(rgrav) self.mark:set_rgravs(rgrav, rgrav) - if self.snip then + if self.snip and self.snip.visible then self.snip:subtree_set_rgrav(rgrav) end end function DynamicNode:extmarks_valid() - if self.snip then + if self.snip and self.snip.visible then return node_util.generic_extmarks_valid(self, self.snip) end return true end +function DynamicNode:subtree_do(opts) + opts.pre(self) + if opts.static then + if self.static_snip then + self.static_snip:subtree_do(opts) + end + else + if self.snip then + self.snip:subtree_do(opts) + end + end + opts.post(self) +end + +function DynamicNode:subtree_leave_entered() + if self.active then + self.snip:subtree_leave_entered() + self:input_leave() + end +end + return { D = D, } diff --git a/lua/luasnip/nodes/functionNode.lua b/lua/luasnip/nodes/functionNode.lua index cb1a77ae7..f9224712c 100644 --- a/lua/luasnip/nodes/functionNode.lua +++ b/lua/luasnip/nodes/functionNode.lua @@ -6,6 +6,8 @@ local types = require("luasnip.util.types") local tNode = require("luasnip.nodes.textNode").textNode local extend_decorator = require("luasnip.util.extend_decorator") local key_indexer = require("luasnip.nodes.key_indexer") +local opt_args = require("luasnip.nodes.optional_arg") +local snippet_string = require("luasnip.nodes.util.snippet_string") local function F(fn, args, opts) opts = opts or {} @@ -35,7 +37,7 @@ end FunctionNode.get_docstring = FunctionNode.get_static_text function FunctionNode:update() - local args = self:get_args() + local args = node_util.str_args(self:get_args()) -- skip this update if -- - not all nodes are available. -- - the args haven't changed. @@ -55,8 +57,12 @@ function FunctionNode:update() end -- don't expand tabs in parent.indentstr, use it as-is. - self:set_text(util.indent(text, self.parent.indentstr)) - self:update_dependents() + self:set_text_raw(util.indent(text, self.parent.indentstr)) + self.static_text = text + + -- assume that functionNode can't have a parent as its dependent, there is + -- no use for that I think. + self:update_dependents({ own = true, parents = true }) end local update_errorstring = [[ @@ -65,7 +71,8 @@ Error while evaluating functionNode@%d for snippet '%s': :h luasnip-docstring for more info]] function FunctionNode:update_static() - local args = self:get_static_args() + local args = node_util.str_args(self:get_static_args()) + -- skip this update if -- - not all nodes are available. -- - the args haven't changed. @@ -93,9 +100,10 @@ function FunctionNode:update_static() end function FunctionNode:update_restore() + local args = node_util.str_args(self:get_args()) -- only if args still match. - if self.static_text and vim.deep_equal(self:get_args(), self.last_args) then - self:set_text(self.static_text) + if self.static_text and vim.deep_equal(args, self.last_args) then + self:set_text_raw(self.static_text) else self:update() end @@ -122,6 +130,9 @@ function FunctionNode:set_dependents() append_list[#append_list + 1] = "dependent" for _, arg in ipairs(self.args_absolute) do + if opt_args.is_opt(arg) then + arg = arg.ref + end -- if arg is a luasnip-node, just insert it as the key. -- important!! rawget, because indexing absolute_indexer with some key -- appends the key. diff --git a/lua/luasnip/nodes/insertNode.lua b/lua/luasnip/nodes/insertNode.lua index c5575d292..a379e4ba3 100644 --- a/lua/luasnip/nodes/insertNode.lua +++ b/lua/luasnip/nodes/insertNode.lua @@ -7,30 +7,44 @@ local types = require("luasnip.util.types") local events = require("luasnip.util.events") local extend_decorator = require("luasnip.util.extend_decorator") local feedkeys = require("luasnip.util.feedkeys") +local snippet_string = require("luasnip.nodes.util.snippet_string") +local str_util = require("luasnip.util.str") +local log = require("luasnip.util.log").new("insertNode") +local session = require("luasnip.session") local function I(pos, static_text, opts) - static_text = util.to_string_table(static_text) + if not snippet_string.isinstance(static_text) then + static_text = snippet_string.new(util.to_string_table(static_text)) + end + local node if pos == 0 then - return ExitNode:new({ + node = ExitNode:new({ pos = pos, - static_text = static_text, mark = nil, dependents = {}, type = types.exitNode, -- will only be needed for 0-node, -1-node isn't set with this. ext_gravities_active = { false, false }, + inner_active = false, + input_active = false, }, opts) else - return InsertNode:new({ + node = InsertNode:new({ pos = pos, - static_text = static_text, mark = nil, dependents = {}, type = types.insertNode, inner_active = false, + input_active = false, }, opts) end + + -- make static text owned by this insertNode. + -- This includes copying it so that it is separate from the snippets that + -- were potentially captured in `get_args`. + node.static_text = static_text:copy() + return node end extend_decorator.register(I, { arg_indx = 3 }) @@ -80,6 +94,8 @@ function ExitNode:input_leave(no_move, dry_run) return end + self.input_active = false + if self.pos == 0 then InsertNode.input_leave(self, no_move, dry_run) else @@ -87,13 +103,6 @@ function ExitNode:input_leave(no_move, dry_run) end end -function ExitNode:_update_dependents() end -function ExitNode:update_dependents() end -function ExitNode:update_all_dependents() end - -function ExitNode:_update_dependents_static() end -function ExitNode:update_dependents_static() end -function ExitNode:update_all_dependents_static() end function ExitNode:is_interactive() return true end @@ -104,6 +113,7 @@ function InsertNode:input_enter(no_move, dry_run) end self.visited = true + self.input_active = true self.mark:update_opts(self.ext_opts.active) -- no_move only prevents moving the cursor, but the active node should @@ -237,9 +247,9 @@ function InsertNode:input_leave(_, dry_run) return end + self.input_active = false self:event(events.leave) - self:update_dependents() self.mark:update_opts(self:get_passive_ext_opts()) end @@ -248,16 +258,18 @@ function InsertNode:exit() snip:remove_from_jumplist() end + -- reset runtime-acquired values. self.visible = false self.inner_first = nil self.inner_last = nil self.inner_active = false + self.input_active = false self.mark:clear() end function InsertNode:get_docstring() -- copy as to not in-place-modify static text. - return util.string_wrap(self.static_text, rawget(self, "pos")) + return util.string_wrap(self:get_static_text(), self.pos) end function InsertNode:is_interactive() @@ -307,6 +319,203 @@ function InsertNode:subtree_set_rgrav(rgrav) end end +function InsertNode:subtree_leave_entered() + if not self.input_active then + -- is not directly active, and does not contain an active child. + return + else + -- first leave children, if they're active, then self. + if self.inner_active then + local nested_snippets = self:child_snippets() + for _, snippet in ipairs(nested_snippets) do + snippet:subtree_leave_entered() + end + self:input_leave_children() + end + self:input_leave() + end +end + +function InsertNode:get_snippetstring() + if not self.visible then + return nil + end + + -- in order to accurately capture all the nodes inside eventual snippets, + -- call :store(), so these are up-to-date in the snippetString. + for _, snip in ipairs(self:child_snippets()) do + snip:store() + end + + local self_from, self_to = self.mark:pos_begin_end_raw() + -- only do one get_text, and establish relative offsets partition this + -- text. + local ok, text = pcall( + vim.api.nvim_buf_get_text, + 0, + self_from[1], + self_from[2], + self_to[1], + self_to[2], + {} + ) + + local snippetstring = snippet_string.new( + nil, + { luasnip_changedtick = session.luasnip_changedtick } + ) + + if not ok then + log.warn("Failure while getting text of insertNode: " .. text) + -- return empty in case of failure. + return snippetstring + end + + local current = { 0, 0 } + for _, snip in ipairs(self:child_snippets()) do + -- it's possible that we first encounter a snippet with broken extmarks + -- in this loop, and those may cause errors when passed to + -- multiline_substr. + -- For now, simply treat it like regular text (`current` does not + -- advance -> the next append_text will include the text of the + -- snippet). + if snip:extmarks_valid() then + local snip_from, snip_to = snip.mark:pos_begin_end_raw() + local snip_from_base_rel = util.pos_offset(self_from, snip_from) + local snip_to_base_rel = util.pos_offset(self_from, snip_to) + + snippetstring:append_text( + str_util.multiline_substr(text, current, snip_from_base_rel) + ) + snippetstring:append_snip( + snip, + str_util.multiline_substr( + text, + snip_from_base_rel, + snip_to_base_rel + ) + ) + current = snip_to_base_rel + end + end + snippetstring:append_text( + str_util.multiline_substr( + text, + current, + util.pos_offset(self_from, self_to) + ) + ) + + return snippetstring +end +function InsertNode:get_static_snippetstring() + return self.static_text +end + +function InsertNode:expand_tabs(tabwidth, indentstrlen) + self.static_text:expand_tabs(tabwidth, indentstrlen) +end + +function InsertNode:indent(indentstr) + self.static_text:indent(indentstr) +end + +-- generate and cache text of this node when used as an argnode. +function InsertNode:store() + if + session.luasnip_changedtick + and self.static_text.metadata + and self.static_text.metadata.luasnip_changedtick + == session.luasnip_changedtick + then + -- stored data is up-to-date, just return the static text. + return + end + + -- get_snippetstring calls store for all child-snippets. + self.static_text = self:get_snippetstring() +end + +function InsertNode:argnode_text() + -- store caches its text, which is exactly what we want here! + self:store() + return self.static_text +end + +function InsertNode:put_initial(pos) + self.static_text:put(pos) + self.visible = true + local _, child_snippet_idx = node_util.binarysearch_pos( + self.parent.snippet.child_snippets, + pos, + -- we are always focused on this node when this is called (I'm pretty + -- sure at least), so we should follow the gravity when finding this + -- index. + true, + -- don't enter snippets, we want to find the position of this node. + node_util.binarysearch_preference.outside + ) + + for snip in self.static_text:iter_snippets() do + -- don't have to pass a current_node, we don't need it since we can + -- certainly link the snippet into this insertNode. + snip:insert_into_jumplist( + nil, + self, + self.parent.snippet.child_snippets, + child_snippet_idx + ) + + child_snippet_idx = child_snippet_idx + 1 + end +end + +function InsertNode:get_static_text() + return vim.split(self.static_text:str(), "\n") +end + +function InsertNode:set_text(text) + local text_indented = util.indent(text, self.parent.indentstr) + + if self:get_snippet().___static_expanded then + self.static_text = snippet_string.new(text_indented) + self:update_dependents_static({ own = true, parents = true }) + else + if self.visible then + self:set_text_raw(text_indented) + self:update_dependents({ own = true, parents = true }) + end + end +end + +function InsertNode:find_node(predicate, opts) + if opts and opts.find_in_child_snippets then + for _, snip in ipairs(self:child_snippets()) do + local node_in_child = snip:find_node(predicate, opts) + if node_in_child then + return node_in_child + end + end + end + return nil +end + +function InsertNode:update_restore() + for _, snip in pairs(self:child_snippets()) do + snip:update_restore() + end +end + +function InsertNode:subtree_do(opts) + opts.pre(self) + if opts.do_child_snippets then + for _, snip in ipairs(self:child_snippets()) do + snip:subtree_do(opts) + end + end + opts.post(self) +end + return { I = I, } diff --git a/lua/luasnip/nodes/node.lua b/lua/luasnip/nodes/node.lua index 64f1279a1..8a5dc8d98 100644 --- a/lua/luasnip/nodes/node.lua +++ b/lua/luasnip/nodes/node.lua @@ -5,8 +5,17 @@ local ext_util = require("luasnip.util.ext_opts") local events = require("luasnip.util.events") local key_indexer = require("luasnip.nodes.key_indexer") local types = require("luasnip.util.types") +local opt_args = require("luasnip.nodes.optional_arg") +local snippet_string = require("luasnip.nodes.util.snippet_string") ---@class LuaSnip.Node +---@field key? any Key to identify the node with. +---@field store_id? number May be set when the node is used to store/restore. +---A generic node. +---@field mark? LuaSnip.Mark The mark associated with this node. +---@field type number Identifies the type of the snippet. +---@field next LuaSnip.Node Link to the next node in jump-order. +---@field prev LuaSnip.Node Link to the previous node in jump-order. local Node = {} ---@alias LuaSnip.NodeExtOpts {["active"|"passive"|"visited"|"unvisited"|"snippet_passive"]: vim.api.keyset.set_extmark} @@ -52,28 +61,6 @@ function Node:new(o, opts) end function Node:get_static_text() - -- return nil if not visible. - -- This will prevent updates if not all nodes are visible during - -- docstring/static_text-generation. (One example that would otherwise fail - -- is the following snippet: - -- - -- s("trig", { - -- i(1, "cccc"), - -- t" ", - -- c(2, { - -- t"aaaa", - -- i(nil, "bbbb") - -- }), - -- f(function(args) return args[1][1]..args[2][1] end, {ai[2][2], 1} ) - -- }) - -- - -- ) - -- By also allowing visible, and not only static_visible, the docstrings - -- generated during `get_current_choices` (ie. without having the whole - -- snippet `static_init`ed) get better. - if not self.visible and not self.static_visible then - return nil - end return self.static_text end @@ -174,6 +161,16 @@ function Node:get_text() return ok and text or { "" } end +function Node:get_snippetstring() + -- if this is not overridden, get_text returns a multiline string. + return snippet_string.new(self:get_text()) +end + +function Node:get_static_snippetstring() + -- if this is not overridden, get_static_text() is a multiline string. + return snippet_string.new(self:get_static_text()) +end + function Node:set_old_text() self.old_text = self:get_text() end @@ -202,84 +199,12 @@ end function Node:input_leave_children() end function Node:input_enter_children() end -local function find_dependents(self, position_self, dict) - local nodes = {} - - -- this might also be called from a node which does not possess a position! - -- (for example, a functionNode may be depended upon via its key) - if position_self then - position_self[#position_self + 1] = "dependents" - vim.list_extend(nodes, dict:find_all(position_self, "dependent") or {}) - position_self[#position_self] = nil - end - - vim.list_extend( - nodes, - dict:find_all({ self, "dependents" }, "dependent") or {} - ) - - if self.key then - vim.list_extend( - nodes, - dict:find_all({ "key", self.key, "dependents" }, "dependent") or {} - ) - end - - return nodes -end - -function Node:_update_dependents() - local dependent_nodes = find_dependents( - self, - self.absolute_insert_position, - self.parent.snippet.dependents_dict - ) - if #dependent_nodes == 0 then - return - end - for _, node in ipairs(dependent_nodes) do - if node.visible then - node:update() - end - end -end - --- _update_dependents is the function to update the nodes' dependents, --- update_dependents is what will actually be called. --- This allows overriding update_dependents in a parent-node (eg. snippetNode) --- while still having access to the original function (for subsequent overrides). -Node.update_dependents = Node._update_dependents --- update_all_dependents is used to update all nodes' dependents in a --- snippet-tree. Necessary in eg. set_choice (especially since nodes may have --- dependencies outside the tree itself, so update_all_dependents should take --- care of those too.) -Node.update_all_dependents = Node._update_dependents - -function Node:_update_dependents_static() - local dependent_nodes = find_dependents( - self, - self.absolute_insert_position, - self.parent.snippet.dependents_dict - ) - if #dependent_nodes == 0 then - return - end - for _, node in ipairs(dependent_nodes) do - if node.static_visible then - node:update_static() - end - end -end - -Node.update_dependents_static = Node._update_dependents_static -Node.update_all_dependents_static = Node._update_dependents_static - function Node:update() end function Node:update_static() end -function Node:expand_tabs(tabwidth, indentstr) - util.expand_tabs(self.static_text, tabwidth, indentstr) +function Node:expand_tabs(tabwidth, indentstrlen) + util.expand_tabs(self.static_text, tabwidth, indentstrlen) end function Node:indent(indentstr) @@ -322,9 +247,14 @@ function Node:event(event) }) end -local function get_args(node, get_text_func_name) +local function get_args(node, get_text_func_name, static) local argnodes_text = {} - for _, arg in ipairs(node.args_absolute) do + for key, arg in ipairs(node.args_absolute) do + local is_optional = opt_args.is_opt(arg) + if is_optional then + arg = arg.ref + end + local argnode if key_indexer.is_key(arg) then argnode = node.parent.snippet.dependents_dict:get({ @@ -349,28 +279,43 @@ local function get_args(node, get_text_func_name) dict_key[#dict_key] = nil end -- maybe the node is part of a dynamicNode and not yet generated. - if not argnode then - return nil - end - - local argnode_text = argnode[get_text_func_name](argnode) - -- can only occur with `get_text`. If one returns nil, the argnode - -- isn't visible or some other error occured. Either way, return nil - -- to signify that not all argnodes are available. - if not argnode_text then - return nil + -- also handle the argnode as not-present if + -- * we are doing a regular update and it is not visible, or + -- * we are doing a static update and it is not static_visible or + -- visible (this second condition is to allow the docstring-generation + -- to be improved by data provided after the expansion) + if + argnode + and ( + (static and (argnode.static_visible or argnode.visible)) + or (not static and argnode.visible) + ) + then + local argnode_text = argnode[get_text_func_name](argnode) + -- can only occur with `get_text`. If one returns nil, the argnode + -- isn't visible or some other error occured. Either way, return nil + -- to signify that not all argnodes are available. + if not argnode_text then + return nil + end + argnodes_text[key] = argnode_text + else + if is_optional then + argnodes_text[key] = nil + else + return nil + end end - table.insert(argnodes_text, argnode_text) end return argnodes_text end function Node:get_args() - return get_args(self, "get_text") + return get_args(self, "argnode_text", false) end function Node:get_static_args() - return get_args(self, "get_static_text") + return get_args(self, "get_static_snippetstring", true) end function Node:get_jump_index() @@ -386,10 +331,8 @@ function Node:set_ext_opts(name) end end --- for insert,functionNode. -function Node:store() - self.static_text = self:get_text() -end +-- default impl. for textNode and functionNode (fNode stores after an update). +function Node:store() end function Node:update_restore() end @@ -415,8 +358,8 @@ function Node:set_argnodes(dict) dict:set(self.absolute_insert_position, self) self.absolute_insert_position[#self.absolute_insert_position] = nil end - if rawget(self, "key") then - dict:set({ "key", rawget(self, "key"), "node" }, self) + if self.key then + dict:set({ "key", self.key, "node" }, self) end end @@ -613,6 +556,20 @@ function Node:focus() end function Node:set_text(text) + local text_indented = util.indent(text, self.parent.indentstr) + + if self:get_snippet().___static_expanded then + self.static_text = text_indented + self:update_dependents_static({ own = true, parents = true }) + else + if self.visible then + self:set_text_raw(text_indented) + self:update_dependents({ own = true, parents = true }) + end + end +end + +function Node:set_text_raw(text) self:focus() local node_from, node_to = self.mark:pos_begin_end_raw() @@ -640,25 +597,69 @@ end function Node:linkable() -- linkable if insert or exitNode. - return vim.tbl_contains( - { types.insertNode, types.exitNode }, - rawget(self, "type") - ) + return vim.tbl_contains({ types.insertNode, types.exitNode }, self.type) end function Node:interactive() -- interactive if immediately inside choiceNode. - return vim.tbl_contains( - { types.insertNode, types.exitNode }, - rawget(self, "type") - ) or rawget(self, "choice") ~= nil + return vim.tbl_contains({ types.insertNode, types.exitNode }, self.type) + or self.choice ~= nil end function Node:leaf() return vim.tbl_contains( { types.textNode, types.functionNode, types.insertNode, types.exitNode }, - rawget(self, "type") + self.type ) end +function Node:parent_of(node) + for i = 1, #self.absolute_position do + if self.absolute_position[i] ~= node.absolute_position[i] then + return false + end + end + + return true +end + +-- self has to be visible/in the buffer. +-- none of the node's ancestors may contain self. +function Node:update_dependents(which) + -- false: don't set static + local dependents = node_util.collect_dependents(self, which, false) + for _, node in ipairs(dependents) do + if node.visible then + node:update_restore() + end + end +end + +function Node:update_dependents_static(which) + -- true: set static + local dependents = node_util.collect_dependents(self, which, true) + for _, node in ipairs(dependents) do + if node.static_visible then + node:update_static() + end + end +end + +function Node:subtree_do(opts) + opts.pre(self) + opts.post(self) +end + +function Node:get_snippet() + return self.parent.snippet +end + +-- all nodes that can be entered have an override, only need to nop this for +-- those that don't. +function Node:subtree_leave_entered() end + +function Node:argnode_text() + return self:get_snippetstring() +end + return { Node = Node, focus_node = focus_node, diff --git a/lua/luasnip/nodes/optional_arg.lua b/lua/luasnip/nodes/optional_arg.lua new file mode 100644 index 000000000..fd54fa195 --- /dev/null +++ b/lua/luasnip/nodes/optional_arg.lua @@ -0,0 +1,12 @@ +local M = {} + +local opt_mt = {} +function M.new_opt(ref) + return setmetatable({ ref = ref }, opt_mt) +end + +function M.is_opt(t) + return getmetatable(t) == opt_mt +end + +return M diff --git a/lua/luasnip/nodes/restoreNode.lua b/lua/luasnip/nodes/restoreNode.lua index 48c8448af..530b82a40 100644 --- a/lua/luasnip/nodes/restoreNode.lua +++ b/lua/luasnip/nodes/restoreNode.lua @@ -36,12 +36,10 @@ function RestoreNode:exit() self.visible = false self.mark:clear() - -- snip should exist if exit is called. - self.snip:store() + -- will be copied on restore, no need to copy here too. self.parent.snippet.stored[self.key] = self.snip self.snip:exit() - self.snip = nil self.active = false end @@ -66,7 +64,6 @@ function RestoreNode:input_leave(_, dry_run) self:event(events.leave) - self:update_dependents() self.active = false self.mark:update_opts(self:get_passive_ext_opts()) @@ -102,11 +99,6 @@ function RestoreNode:put_initial(pos) tmp.snippet = self.parent.snippet tmp.restore_node = self - tmp.update_dependents = function(node) - node:_update_dependents() - -- self is restoreNode. - node.restore_node:update_dependents() - end tmp:resolve_child_ext_opts() tmp:resolve_node_ext_opts() @@ -182,7 +174,7 @@ local function snip_init(self, snip) snip.snippet = self.parent.snippet -- pos should be nil if the restoreNode is inside a choiceNode. - snip.pos = rawget(self, "pos") + snip.pos = self.pos snip:resolve_child_ext_opts() snip:resolve_node_ext_opts() @@ -221,19 +213,23 @@ function RestoreNode:get_docstring() return self.docstring end -function RestoreNode:store() end +function RestoreNode:store() + if self.snip then + self.snip:store() + end +end -- will be restored through other means. function RestoreNode:update_restore() self.snip:update_restore() end -function RestoreNode:find_node(predicate) +function RestoreNode:find_node(predicate, opts) if self.snip then if predicate(self.snip) then return self.snip else - return self.snip:find_node(predicate) + return self.snip:find_node(predicate, opts) end end return nil @@ -247,16 +243,6 @@ function RestoreNode:insert_to_node_absolute(position) return self.snip and self.snip:insert_to_node_absolute(position) end -function RestoreNode:update_all_dependents() - self:_update_dependents() - self.snip:update_all_dependents() -end - -function RestoreNode:update_all_dependents_static() - self:_update_dependents_static() - self.parent.snippet.stored[self.key]:_update_dependents_static() -end - function RestoreNode:init_insert_positions(position_so_far) Node.init_insert_positions(self, position_so_far) self.snip_absolute_insert_position = @@ -286,14 +272,14 @@ end function RestoreNode:subtree_set_pos_rgrav(pos, direction, rgrav) self.mark:set_rgrav(-direction, rgrav) - if self.snip then + if self.snip and self.snip.visible then self.snip:subtree_set_pos_rgrav(pos, direction, rgrav) end end function RestoreNode:subtree_set_rgrav(rgrav) self.mark:set_rgravs(rgrav, rgrav) - if self.snip then + if self.snip and self.snip.visible then self.snip:subtree_set_rgrav(rgrav) end end @@ -302,6 +288,29 @@ function RestoreNode:extmarks_valid() return node_util.generic_extmarks_valid(self, self.snip) end +function RestoreNode:subtree_do(opts) + opts.pre(self) + if self.snip then + self.snip:subtree_do(opts) + else + if opts.static then + -- try using stored snippet for recursion when static and regular + -- snip does not exist. + self.parent.snippet.stored[self.key]:subtree_do(opts) + end + end + opts.post(self) +end + +function RestoreNode:subtree_leave_entered() + if self.active then + if self.snip then + self.snip:subtree_leave_entered() + end + self:input_leave() + end +end + return { R = R, } diff --git a/lua/luasnip/nodes/snippet.lua b/lua/luasnip/nodes/snippet.lua index 9d9fad278..0da7e6f4d 100644 --- a/lua/luasnip/nodes/snippet.lua +++ b/lua/luasnip/nodes/snippet.lua @@ -120,11 +120,6 @@ function Snippet:init_nodes() insert_nodes[node.pos] = node end end - - node.update_dependents = function(node) - node:_update_dependents() - node.parent:update_dependents() - end end if insert_nodes[1] then @@ -375,16 +370,6 @@ local function _S(snip, nodes, opts) -- is propagated to all subsnippets, used to quickly find the outer snippet snip.snippet = snip - -- if the snippet is expanded inside another snippet (can be recognized by - -- non-nil parent_node), the node of the snippet this one is inside has to - -- update its dependents. - function snip:_update_dependents() - if self.parent_node then - self.parent_node:update_dependents() - end - end - snip.update_dependents = snip._update_dependents - snip:init_nodes() if not snip.insert_nodes[0] then @@ -502,6 +487,10 @@ function Snippet:remove_from_jumplist() -- nxt is snippet. local nxt = self.next.next + -- the advantage of remove_from_jumplist over exit is that the former + -- modifies its parents child_snippets, or the root-snippet-list. + -- Since the owners of this snippets' child_snippets are invalid anyway, we + -- don't bother modifying them. self:exit() local sibling_list = self.parent_node ~= nil @@ -542,18 +531,23 @@ function Snippet:remove_from_jumplist() end end -local function insert_into_jumplist( - snippet, - start_node, +function Snippet:insert_into_jumplist( current_node, parent_node, sibling_snippets, own_indx ) + -- this is always the case. + local start_node = self.prev + local prev_snippet = sibling_snippets[own_indx - 1] -- have not yet inserted self!! local next_snippet = sibling_snippets[own_indx] + -- can set this immediately + -- parent_node is nil if the snippet is toplevel. + self.parent_node = parent_node + -- only consider sibling-snippets with the same parent-node as -- previous/next snippet for linking-purposes. -- They are siblings because they are expanded in the same snippet, not @@ -582,13 +576,13 @@ local function insert_into_jumplist( -- in all cases if link_children and prev ~= nil then -- if we have a previous snippet we can link to, just do that. - prev.next.next = snippet + prev.next.next = self start_node.prev = prev.insert_nodes[0] else -- only jump from parent to child if link_children is set. if link_children then -- prev is nil, but we can link up using the parent. - parent_node.inner_first = snippet + parent_node.inner_first = self end -- make sure we can jump back to the parent. start_node.prev = parent_node @@ -597,14 +591,14 @@ local function insert_into_jumplist( -- exact same reasoning here as in prev-case above, omitting comments. if link_children and next ~= nil then -- jump from next snippets start_node to $0. - next.prev.prev = snippet.insert_nodes[0] + next.prev.prev = self.insert_nodes[0] -- jump from $0 to next snippet (skip its start_node) - snippet.insert_nodes[0].next = next + self.insert_nodes[0].next = next else if link_children then - parent_node.inner_last = snippet.insert_nodes[0] + parent_node.inner_last = self.insert_nodes[0] end - snippet.insert_nodes[0].next = parent_node + self.insert_nodes[0].next = parent_node end else -- naively, even if the parent is linkable, there might be snippets @@ -623,23 +617,23 @@ local function insert_into_jumplist( -- previous history, and we don't mess up whatever jumps -- are set up around current_node) start_node.prev = current_node - snippet.insert_nodes[0].next = current_node + self.insert_nodes[0].next = current_node end -- don't link different root-nodes for unlinked_roots. elseif link_roots then -- inserted into top-level snippet-forest, just hook up with prev, next. -- prev and next have to be snippets or nil, in this case. if prev ~= nil then - prev.next.next = snippet + prev.next.next = self start_node.prev = prev.insert_nodes[0] end if next ~= nil then - snippet.insert_nodes[0].next = next - next.prev.prev = snippet.insert_nodes[0] + self.insert_nodes[0].next = next + next.prev.prev = self.insert_nodes[0] end end - table.insert(sibling_snippets, own_indx, snippet) + table.insert(sibling_snippets, own_indx, self) end function Snippet:trigger_expand(current_node, pos_id, env, indent_nodes) @@ -772,25 +766,11 @@ function Snippet:trigger_expand(current_node, pos_id, env, indent_nodes) end local start_node = iNode.I(0) - - local old_pos = vim.deepcopy(pos) - self:put_initial(pos) - - local mark_opts = vim.tbl_extend("keep", { - right_gravity = false, - end_right_gravity = false, - }, self:get_passive_ext_opts()) - self.mark = mark(old_pos, pos, mark_opts) - - self:update() - self:update_all_dependents() - - -- Marks should stay at the beginning of the snippet, only the first mark is needed. - start_node.mark = self.nodes[1].mark start_node.pos = -1 -- needed for querying node-path from snippet to this node. start_node.absolute_position = { -1 } start_node.parent = self + start_node.visible = true -- hook up i0 and start_node, and then the snippet itself. -- they are outside, not inside the snippet. @@ -802,12 +782,12 @@ function Snippet:trigger_expand(current_node, pos_id, env, indent_nodes) self.insert_nodes[0].prev = self self.next = self.insert_nodes[0] - -- parent_node is nil if the snippet is toplevel. - self.parent_node = parent_node + self:put(pos) - insert_into_jumplist( - self, - start_node, + self:update() + self:update_dependents({ children = true }) + + self:insert_into_jumplist( current_node, parent_node, sibling_snippets, @@ -965,6 +945,8 @@ function Snippet:fake_expand(opts) self:indent("") + self.___static_expanded = true + -- ext_opts don't matter here, just use convenient values. self.effective_child_ext_opts = self.child_ext_opts self.ext_opts = self.node_ext_opts @@ -1045,7 +1027,7 @@ function Snippet:get_docstring() -- function/dynamicNodes. -- if not outer snippet, wrap it in ${}. self.docstring = self.type == types.snippet and docstring - or util.string_wrap(docstring, rawget(self, "pos")) + or util.string_wrap(docstring, self.pos) return self.docstring end @@ -1132,7 +1114,6 @@ function Snippet:input_leave(_, dry_run) end self:event(events.leave) - self:update_dependents() -- set own ext_opts to snippet-passive, there is no passive for snippets. self.mark:update_opts(self.ext_opts.snippet_passive) @@ -1178,12 +1159,12 @@ end function Snippet:exit() if self.type == types.snippet then - -- if exit is called, this will not be visited again. - -- Thus, also clean up the child-snippets, which will also not be - -- visited again, since they can only be visited through self. - for _, child in ipairs(self.child_snippets) do - child:exit() + -- insertNode also call exit for their child_snippets, but if we + -- :exit() the whole snippet we can just remove all of them here. + for _, snip in ipairs(self.child_snippets) do + snip:exit() end + self.child_snippets = {} end self.visible = false @@ -1297,17 +1278,20 @@ function Snippet:get_pattern_expand_helper() return self.expand_helper_snippet end -function Snippet:find_node(predicate) +function Snippet:find_node(predicate, opts) for _, node in ipairs(self.nodes) do if predicate(node) then return node else - local node_in_child = node:find_node(predicate) + local node_in_child = node:find_node(predicate, opts) if node_in_child then return node_in_child end end end + if predicate(self.prev) then + return self.prev + end return nil end @@ -1332,23 +1316,6 @@ function Snippet:set_argnodes(dict) end end -function Snippet:update_all_dependents() - -- call the version that only updates this node. - self:_update_dependents() - -- only for insertnodes, others will not have dependents. - for _, node in ipairs(self.insert_nodes) do - node:update_all_dependents() - end -end -function Snippet:update_all_dependents_static() - -- call the version that only updates this node. - self:_update_dependents_static() - -- only for insertnodes, others will not have dependents. - for _, node in ipairs(self.insert_nodes) do - node:update_all_dependents_static() - end -end - function Snippet:resolve_position(position) -- only snippets have -1-node. if position == -1 and self.type == types.snippet then @@ -1559,7 +1526,8 @@ function Snippet:extmarks_valid() return false end - -- below code does not work correctly if the snippet(Node) does not have any children. + -- the following code assumes that the snippet(Node) has at least one child, + -- if it doesn't, it's valid anyway. if #self.nodes == 0 then return true end @@ -1569,11 +1537,12 @@ function Snippet:extmarks_valid() pcall(node.mark.pos_begin_end_raw, node.mark) -- this snippet is invalid if: -- - we can't get the position of some node - -- - the positions aren't contiguous or don't completely fill the parent, or + -- - the positions aren't contiguous, don't completely fill the parent, or the `to` is before the `from`, or -- - any child of this node violates these rules. if not ok_ or util.pos_cmp(current_from, node_from) ~= 0 + or util.pos_cmp(node_from, node_to) > 0 or not node:extmarks_valid() then return false @@ -1587,6 +1556,55 @@ function Snippet:extmarks_valid() return true end +function Snippet:subtree_do(opts) + opts.pre(self) + for _, child in ipairs(self.nodes) do + child:subtree_do(opts) + end + opts.post(self) +end + +function Snippet:get_snippet() + if self.type == types.snippet then + return self + else + return self.parent.snippet + end +end + +-- affect all children nested into this snippet. +function Snippet:subtree_leave_entered() + if self.active then + for _, node in ipairs(self.nodes) do + node:subtree_leave_entered() + end + self:input_leave() + else + if self.type ~= types.snippetNode then + -- the exit-nodes (-1 and 0) may be active if the snippet itself is + -- not; just do these two calls, no hurt if they're not active. + self.prev:subtree_leave_entered() + self.insert_nodes[0]:subtree_leave_entered() + end + end +end + +function Snippet:put(pos) + --- Put text-content of snippet into buffer and set marks. + local old_pos = vim.deepcopy(pos) + self:put_initial(pos) + + local mark_opts = vim.tbl_extend("keep", { + right_gravity = false, + end_right_gravity = false, + }, self:get_passive_ext_opts()) + self.mark = mark(old_pos, pos, mark_opts) + + -- The start_nodes' marks should stay at the beginning of the snippet, only + -- the first mark is needed. + self.prev.mark = self.nodes[1].mark +end + return { Snippet = Snippet, S = S, diff --git a/lua/luasnip/nodes/textNode.lua b/lua/luasnip/nodes/textNode.lua index 602226907..3856620ec 100644 --- a/lua/luasnip/nodes/textNode.lua +++ b/lua/luasnip/nodes/textNode.lua @@ -32,8 +32,6 @@ function TextNode:input_enter(no_move, dry_run) self:event(events.enter, no_move) end -function TextNode:update_all_dependents() end - function TextNode:is_interactive() -- a resounding false. return false diff --git a/lua/luasnip/nodes/util.lua b/lua/luasnip/nodes/util.lua index 03c9e331a..cb27d9ffd 100644 --- a/lua/luasnip/nodes/util.lua +++ b/lua/luasnip/nodes/util.lua @@ -1,9 +1,12 @@ local util = require("luasnip.util.util") +local str_util = require("luasnip.util.str") +local tbl_util = require("luasnip.util.table") local ext_util = require("luasnip.util.ext_opts") local types = require("luasnip.util.types") local key_indexer = require("luasnip.nodes.key_indexer") local session = require("luasnip.session") local feedkeys = require("luasnip.util.feedkeys") +local snippet_string = require("luasnip.nodes.util.snippet_string") local function subsnip_init_children(parent, children) for _, child in ipairs(children) do @@ -65,7 +68,7 @@ local function wrap_args(args) end -- includes child, does not include parent. -local function get_nodes_between(parent, child) +local function get_nodes_between(parent, child, static) local nodes = {} -- special case for nodes without absolute_position (which is only @@ -81,7 +84,7 @@ local function get_nodes_between(parent, child) local indx = #parent.absolute_position + 1 local prev = parent while child_pos[indx] do - local next = prev:resolve_position(child_pos[indx]) + local next = prev:resolve_position(child_pos[indx], static) nodes[#nodes + 1] = next prev = next indx = indx + 1 @@ -183,10 +186,7 @@ end local function linkable_node(node) -- node.type has to be one of insertNode, exitNode. - return vim.tbl_contains( - { types.insertNode, types.exitNode }, - rawget(node, "type") - ) + return vim.tbl_contains({ types.insertNode, types.exitNode }, node.type) end -- mainly used internally, by binarysearch_pos. @@ -196,10 +196,7 @@ end -- feel appropriate (higher runtime), most cases should be served well by this -- heuristic. local function non_linkable_node(node) - return vim.tbl_contains( - { types.textNode, types.functionNode }, - rawget(node, "type") - ) + return vim.tbl_contains({ types.textNode, types.functionNode }, node.type) end -- return whether a node is certainly (not) interactive. -- Coincindentially, the same nodes as (non-)linkable ones, but since there is a @@ -678,6 +675,8 @@ local function snippettree_find_undamaged_node(pos, opts) -- The position of the offending snippet is returned in child_indx, -- and we can remove it here. prev_parent_children[child_indx]:remove_from_jumplist() + -- remove_from_jumplist modified prev_parent_children, don't need + -- to re-assign since we have a pointer to that table. elseif found_parent ~= nil and not found_parent:extmarks_valid() then -- found snippet damaged (the idea to sidestep the damaged snippet, -- even if no error occurred _right now_, is to ensure that we can @@ -704,12 +703,19 @@ local function snippettree_find_undamaged_node(pos, opts) return prev_parent, prev_parent_children, child_indx, node end -local function root_path(node) +local function root_path(node, static) local path = {} while node do - local node_snippet = node.parent.snippet - local snippet_node_path = get_nodes_between(node_snippet, node) + local node_snippet + if node.parent == nil then + -- node is snippet. + node_snippet = node + else + node_snippet = node.parent.snippet + end + + local snippet_node_path = get_nodes_between(node_snippet, node, static) -- get_nodes_between gives parent -> node, but we need -- node -> parent => insert back to front. for i = #snippet_node_path, 1, -1 do @@ -765,6 +771,290 @@ local function nodelist_adjust_rgravs( end end +local function find_node_dependents(node) + local node_position = node.absolute_insert_position + local dict = node:get_snippet().dependents_dict + local nodes = {} + + -- this might also be called from a node which does not possess a position! + -- (for example, a functionNode may be depended upon via its key) + if node_position then + node_position[#node_position + 1] = "dependents" + vim.list_extend(nodes, dict:find_all(node_position, "dependent") or {}) + node_position[#node_position] = nil + end + + vim.list_extend( + nodes, + dict:find_all({ node, "dependents" }, "dependent") or {} + ) + + if node.key then + vim.list_extend( + nodes, + dict:find_all({ "key", node.key, "dependents" }, "dependent") or {} + ) + end + + return nodes +end + +local function node_subtree_do(node, opts) + -- provide default-values. + if not opts.pre then + opts.pre = util.nop + end + if not opts.post then + opts.post = util.nop + end + + node:subtree_do(opts) +end + +local function collect_dependents(node, which, static) + local dependents_set = {} + + if which.own then + for _, dep in ipairs(find_node_dependents(node)) do + dependents_set[dep] = true + end + end + if which.parents then + -- find dependents of all ancestors without duplicates. + local path_to_root = root_path(node, static) + -- remove `node` from path (its dependents are included if `which.own` + -- is set) + table.remove(path_to_root, 1) + for _, ancestor in ipairs(path_to_root) do + for _, dep in ipairs(find_node_dependents(ancestor)) do + dependents_set[dep] = true + end + end + end + if which.children then + -- only collects children in same snippet as node. + node_subtree_do(node, { + pre = function(st_node) + -- don't update for self. + if st_node == node then + return + end + + for _, dep in ipairs(find_node_dependents(st_node)) do + dependents_set[dep] = true + end + end, + static = static, + }) + end + + return tbl_util.set_to_list(dependents_set) +end + +local function str_args(args) + return args + and vim.tbl_map(function(arg) + return snippet_string.isinstance(arg) and vim.split(arg:str(), "\n") + or arg + end, args) +end + +---@class LuaSnip.SnippetCursorRestoreData +---This class holds data about the current position of the cursor in a snippet. +---@field key string key of the current node. +---@field store_id number uniquely identifies the data associated with this +---store-restore cycle. +---This is necessary because eg. the snippetStrings may contain cursor-positions +---of more than one restore data, and the correct ones can be identified via +---store_id. +---@field node LuaSnip.Node The node the cursor will be stored relative to. +---@field cursor_start_relative LuaSnip.BytecolBufferPosition The position of +---the cursor, or beginning of selected area, relative to the beginning of +---`node`. +---@field selection_end_start_relative LuaSnip.BytecolBufferPosition The +---position of the cursor, or end of selected area, relative to the beginning of +---`node`. The column is one beyond the byte where the selection ends. +---@field mode string The first character (see `vim.fn.mode()`) of the mode at +---the time of `store`. + +---@alias LuaSnip.CursorRestoreData table +---Represents the position of the cursor relative to all snippets the cursor was +---inside. +---Maps a `store_id` to the data needed to restore the cursor relative to the +---stored node of that snippet. +---We need the data relative to all parent-snippets of some node because the +---first 1,2,... snippets may disappear when a choice is changed. + +---@class LuaSnip.StoreCursorNodeRelativeOpts +---@field place_cursor_mark boolean? Whether to, if possible, place a mark in +---snippetText. + +local store_id = 0 +---@param node LuaSnip.Node The node to store the cursor relative to. +---@param opts LuaSnip.StoreCursorNodeRelativeOpts +local function store_cursor_node_relative(node, opts) + local data = {} + + local snippet_current_node = node + + local cursor_state = feedkeys.last_state() + local store_ids = {} + + -- store for each snippet! + -- the innermost snippet may be destroyed, and we would have to restore the + -- cursor in a snippet above that. + while snippet_current_node do + local snip = snippet_current_node:get_snippet() + + local snip_data = {} + + snip_data.key = snippet_current_node.key + snippet_current_node.store_id = store_id + snip.node_store_id = store_id + + snip_data.store_id = store_id + + snip_data.node = snippet_current_node + + -- from low to high + table.insert(store_ids, store_id) + + snip_data.cursor_start_relative = util.pos_offset( + snippet_current_node.mark:get_endpoint(-1), + cursor_state.pos + ) + + snip_data.mode = cursor_state.mode + + if cursor_state.pos_v then + snip_data.selection_end_start_relative = util.pos_offset( + snippet_current_node.mark:get_endpoint(-1), + cursor_state.pos_v + ) + end + + if + snippet_current_node.type == types.insertNode + and opts.place_cursor_mark + then + -- if the snippet_current_node is not an insertNode, the cursor + -- should always be exactly at the beginning if the node is entered + -- (which, btw, can only happen if a text or functionNode is + -- immediately nested inside a choiceNode), which means that + -- storing the cursor-position relative to the beginning of the + -- node is sufficient for restoring it in all usecases (now, a user + -- may have triggered the update while not in this position, but I + -- think it's fine to not restore the cursor 100% correctly in that + -- case. + -- + -- When the node is an insertNode, the cursor may be somewhere + -- inside the node, and while it will be restored correctly if the + -- text does not change, if the update inserts some characters or a + -- line at the beginning of the node (but still reaches + -- equilibrium), the cursor will have moved relative to the + -- immediately surrounding text. + -- + -- A solution to this is to simply place some kind if extmark (but + -- for regular strings, not for nvim-buffers) in the node (in the + -- text that is passed to some dynamicNode, to be precise), which + -- we then recover in the restore-function, and use to set the + -- cursor correctly :) + snippet_current_node:store() + + if + snip_data.cursor_start_relative[1] >= 0 + and snip_data.cursor_start_relative[2] >= 0 + then + -- we also have this in static_text, but recomputing the text + -- exactly is rather expensive -> text is still in buffer, yank + -- it. + local str = snippet_current_node:get_text() --[=[@as string[] ]=] + local pos_byte_offset = str_util.multiline_to_byte_offset( + str, + snip_data.cursor_start_relative + ) + if pos_byte_offset then + snippet_current_node.static_text:add_mark( + store_id .. "pos", + pos_byte_offset, + false + ) + if + snip_data.selection_end_start_relative + and snip_data.selection_end_start_relative[1] >= 0 + and snip_data.selection_end_start_relative[2] >= 0 + then + local pos_v_byte_offset = + str_util.multiline_to_byte_offset( + str, + snip_data.selection_end_start_relative + ) + if pos_v_byte_offset then + -- set rgrav of endpoint of selection true. + -- This means if the selection is replaced, it would still + -- be selected, which seems like a nice property. + snippet_current_node.static_text:add_mark( + store_id .. "pos_v", + pos_v_byte_offset, + true + ) + end + end + end + end + end + + data[snip] = snippet_current_node + data[store_id] = snip_data + + snippet_current_node = snip.parent_node + + store_id = store_id + 1 + end + data.store_ids = store_ids + + return data +end + +local function restore_cursor_pos_relative(node, data) + local cursor_pos = data.cursor_start_relative + local cursor_pos_v = data.selection_end_start_relative + if node.type == types.insertNode then + local mark_pos = node.static_text:get_mark_pos(data.store_id .. "pos") + if mark_pos then + local str = node:get_text() + local mark_pos_offset = + str_util.byte_to_multiline_offset(str, mark_pos) + cursor_pos = mark_pos_offset and mark_pos_offset or cursor_pos + + local mark_pos_v = + node.static_text:get_mark_pos(data.store_id .. "pos_v") + if mark_pos_v then + local mark_pos_v_offset = + str_util.byte_to_multiline_offset(str, mark_pos_v) + cursor_pos_v = mark_pos_v_offset + end + end + end + + if data.mode == "i" then + feedkeys.insert_at( + util.pos_from_offset(node.mark:get_endpoint(-1), cursor_pos) + ) + elseif data.mode == "s" then + -- is a selection => restore it. + local selection_from = + util.pos_from_offset(node.mark:get_endpoint(-1), cursor_pos) + local selection_to = + util.pos_from_offset(node.mark:get_endpoint(-1), cursor_pos_v) + feedkeys.select_range(selection_from, selection_to) + else + feedkeys.move_to_normal( + util.pos_from_offset(node.mark:get_endpoint(-1), cursor_pos) + ) + end +end + return { subsnip_init_children = subsnip_init_children, init_child_positions_func = init_child_positions_func, @@ -787,4 +1077,10 @@ return { interactive_node = interactive_node, root_path = root_path, nodelist_adjust_rgravs = nodelist_adjust_rgravs, + find_node_dependents = find_node_dependents, + collect_dependents = collect_dependents, + node_subtree_do = node_subtree_do, + str_args = str_args, + store_cursor_node_relative = store_cursor_node_relative, + restore_cursor_pos_relative = restore_cursor_pos_relative, } diff --git a/lua/luasnip/nodes/util/snippet_string.lua b/lua/luasnip/nodes/util/snippet_string.lua new file mode 100644 index 000000000..57ad9aba6 --- /dev/null +++ b/lua/luasnip/nodes/util/snippet_string.lua @@ -0,0 +1,593 @@ +local str_util = require("luasnip.util.str") +local util = require("luasnip.util.util") + +---@class SnippetString +local SnippetString = {} +local SnippetString_mt = { + __index = SnippetString, + + -- __concat and __tostring will be set later on. +} + +local M = {} + +---Create new SnippetString. +---@param initial_str string[]?, optional initial multiline string. +---@return SnippetString +function M.new(initial_str, metadata) + local o = { + initial_str and table.concat(initial_str, "\n"), + marks = {}, + metadata = metadata, + } + return setmetatable(o, SnippetString_mt) +end + +function M.isinstance(o) + return getmetatable(o) == SnippetString_mt +end + +function SnippetString:append_snip(snip) + table.insert(self, { snip = snip }) +end +function SnippetString:append_text(str) + table.insert(self, table.concat(str, "\n")) +end + +-- compute table mapping +-- * each snippet in this snipstr (including nested) to its string-content +-- * each component in the snippet_string (including nested) to the text-index +-- of its first character. +-- * the string of each nested snippetString. +local function gen_snipstr_map(self, map, from_offset) + map[self] = {} + + local str = "" + for i, v in ipairs(self) do + map[self][i] = from_offset + #str + if v.snip then + local snip_str = "" + v.snip:subtree_do({ + pre = function(node) + if node.static_text then + if M.isinstance(node.static_text) then + local nested_str = gen_snipstr_map( + node.static_text, + map, + from_offset + #str + #snip_str + ) + snip_str = snip_str .. nested_str + else + snip_str = snip_str + .. table.concat(node.static_text, "\n") + end + end + end, + post = util.nop, + }) + map[v.snip] = snip_str + str = str .. snip_str + else + str = str .. v + end + end + map[self].str = str + return str +end + +function SnippetString:str() + -- if too slow, generate another version of that function without the + -- snipstr_map-calls. + return gen_snipstr_map(self, {}, 1) +end +SnippetString_mt.__tostring = SnippetString.str + +function SnippetString:indent(indentstr) + for k, snipstr_or_str in ipairs(self) do + if snipstr_or_str.snip then + snipstr_or_str.snip:indent(indentstr) + else + local str_tmp = vim.split(snipstr_or_str, "\n") + util.indent(str_tmp, indentstr) + self[k] = table.concat(str_tmp, "\n") + end + end +end + +function SnippetString:expand_tabs(tabwidth, indenstrlen) + for k, snipstr_or_str in ipairs(self) do + if snipstr_or_str.snip then + snipstr_or_str.snip:expand_tabs(tabwidth, indenstrlen) + else + local str_tmp = vim.split(snipstr_or_str, "\n") + util.expand_tabs(str_tmp, tabwidth, indenstrlen) + self[k] = table.concat(str_tmp, "\n") + end + end +end + +function SnippetString:iter_snippets() + local i = 1 + return function() + -- find the next snippet. + while self[i] and not self[i].snip do + i = i + 1 + end + local res = self[i] and self[i].snip + i = i + 1 + return res + end +end + +-- pos is modified to reflect the new cursor-position! +function SnippetString:put(pos) + for _, snipstr_or_str in ipairs(self) do + if snipstr_or_str.snip then + snipstr_or_str.snip:put(pos) + else + util.put(vim.split(snipstr_or_str, "\n"), pos) + end + end +end + +function SnippetString:copy() + -- on 0.7 vim.deepcopy does not behave correctly on snippets => have to manually copy. + return setmetatable( + vim.tbl_map(function(snipstr_or_str) + if snipstr_or_str.snip then + local snip = snipstr_or_str.snip + + -- remove associations with objects beyond this snippet. + -- This is so we can easily deepcopy it without copying too much data. + -- We could also do this copy in + local prevprev = snip.prev.prev + local i0next = snip.insert_nodes[0].next + local parentnode = snip.parent_node + + snip.prev.prev = nil + snip.insert_nodes[0].next = nil + snip.parent_node = nil + + local snipcop = snip:copy() + + snip.prev.prev = prevprev + snip.insert_nodes[0].next = i0next + snip.parent_node = parentnode + + -- bring into inactive mode, so that we will jump into it correctly when it + -- is expanded again. + snipcop:subtree_do({ + pre = function(node) + node.mark:invalidate() + end, + post = util.nop, + do_child_snippets = true, + }) + -- snippet may have been active (for example if captured as an + -- argnode), so finally exit here (so we can put_initial it again!) + snipcop:exit() + + return { snip = snipcop } + else + -- handles raw strings and marks and metadata + return vim.deepcopy(snipstr_or_str) + end + end, self), + SnippetString_mt + ) +end + +-- copy without copying snippets. +function SnippetString:flatcopy() + local res = {} + for i, v in ipairs(self) do + res[i] = util.shallow_copy(v) + end + -- we simply copy marks including their id's. + res.marks = vim.deepcopy(self.marks) + res.metadata = vim.deepcopy(self.metadata) + return setmetatable(res, SnippetString_mt) +end + +-- where o is string, string[] or SnippetString. +local function to_snippetstring(o) + if type(o) == "string" then + return M.new({ o }) + elseif getmetatable(o) == SnippetString_mt then + return o + else + return M.new(o) + end +end + +function SnippetString.concat(a, b) + a = to_snippetstring(a):flatcopy() + b = to_snippetstring(b):flatcopy() + vim.list_extend(a, b) + + -- now, this means we may have duplicated mark-ids. + -- I think this is okay because we will simply always return the first + -- occurence of some id. + -- + -- An alternative would be to modify the mark-ids to be non-overlapping, but + -- then we may not be able to retrieve all marks. + for _, mark in ipairs(b.marks) do + -- bit wasteful to compute a:str here. + -- Think about caching the total length of the snippetString. + mark.pos = mark.pos + #a:str() + end + + vim.list_extend(a.marks, b.marks) + + -- overwrite metadata from a. + -- I don't think this will be a problem for the usecase of storing the + -- luasnip_changedtick, since all snippetStrings present in some + -- dynamicNode will have the same changedtick. + for k, v in pairs(b.metadata) do + a.metadata[k] = v + end + + return a +end +SnippetString_mt.__concat = SnippetString.concat + +-- for generic string-operations: we can apply them _and_ keep the snippet as +-- long as a change to the string does not span over extmarks! We need to verify +-- this somehow, and can do this by storing the positions where one extmark ends +-- and another begins in some list or table which is quickly queried. +-- Since all string-operations work with simple strings and not the +-- string-tables we have here usually, we should also convert {"a", "b"} to +-- "a\nb". This also simplifies storing the positions where some node ends, and +-- is much better than converting all the time when a string-operation is +-- involved. + +-- only call after it's clear that char_i is contained in self. +local function find(self, start_i, i_inc, char_i, snipstr_map) + local i = start_i + while true do + local v = self[i] + local current_str_from = snipstr_map[self][i] + if not v then + -- leave in for now, no endless loops while testing :D + error("huh??") + end + local v_str + if v.snip then + v_str = snipstr_map[v.snip] + else + v_str = v + end + + local current_str_to = current_str_from + #v_str - 1 + if char_i >= current_str_from and char_i <= current_str_to then + return i + end + + i = i + i_inc + end +end + +local function nodetext_len(node, snipstr_map) + if not node.static_text then + return 0 + end + + if M.isinstance(node.static_text) then + return #snipstr_map[node.static_text].str + else + -- +1 for each newline. + local len = #node.static_text - 1 + for _, v in ipairs(node.static_text) do + len = len + #v + end + return len + end +end + +-- replacements may not be zero-width! +local function _replace(self, replacements, snipstr_map) + -- first character of currently-looked-at text. + local v_i_search_from = #self + + for i = #replacements, 1, -1 do + local repl = replacements[i] + + local v_i_to = find(self, v_i_search_from, -1, repl.to, snipstr_map) + local v_i_from = find(self, v_i_to, -1, repl.from, snipstr_map) + + -- next range may begin in v_i_from, before the currently inserted + -- one. + v_i_search_from = v_i_from + + -- first characters of v_from and v_to respectively. + local v_from_from = snipstr_map[self][v_i_from] + local v_to_from = snipstr_map[self][v_i_to] + local _, repl_in_node = nil, false + + if v_i_from == v_i_to and self[v_i_from].snip then + local snip = self[v_i_from].snip + local node_from = v_from_from + + -- will probably always error, res is true if the substitution + -- could be done, false if repl spans multiple nodes. + _, repl_in_node = pcall(snip.subtree_do, snip, { + pre = function(node) + local node_len = nodetext_len(node, snipstr_map) + if node_len > 0 then + local node_relative_repl_from = repl.from + - node_from + + 1 + local node_relative_repl_to = repl.to - node_from + 1 + + if + node_relative_repl_from >= 1 + and node_relative_repl_from <= node_len + then + if node_relative_repl_to <= node_len then + if M.isinstance(node.static_text) then + -- node contains a snippetString, recurse! + -- since we only check string-positions via + -- snipstr_map, we don't even have to + -- modify repl to be defined based on the + -- other snippetString. (ie. shift from and to) + _replace( + node.static_text, + { repl }, + snipstr_map + ) + else + -- simply manipulate the node-static-text + -- manually. + -- + -- we don't need to update the snipstr_map + -- because even if this same node or same + -- snippet contains another range (which is + -- the only data in snipstr_map we may + -- access that is inaccurate), the queries + -- will still be answered correctly. + local str = + table.concat(node.static_text, "\n") + node.static_text = vim.split( + str:sub(1, node_relative_repl_from - 1) + .. repl.str + .. str:sub( + node_relative_repl_to + 1 + ), + "\n" + ) + end + -- update string in snipstr_map. + snipstr_map[snip] = snipstr_map[snip]:sub( + 1, + repl.from - v_from_from - 1 + ) .. repl.str .. snipstr_map[snip]:sub( + repl.to - v_to_from + 1 + ) + error(true) + else + -- range begins in, but ends outside this node + -- => snippet cannot be preserved. + -- Replace it with its static text and do the + -- replacement on that. + error(false) + end + end + node_from = node_from + node_len + end + end, + post = util.nop, + }) + end + -- in lieu of `continue`, we need this bool to check whether we did a replacement yet. + if not repl_in_node then + local from_str = self[v_i_from].snip + and snipstr_map[self[v_i_from].snip] + or self[v_i_from] + local to_str = self[v_i_to].snip and snipstr_map[self[v_i_to].snip] + or self[v_i_to] + + -- +1 to get the char of to, +1 to start beyond it. + self[v_i_from] = from_str:sub(1, repl.from - v_from_from) + .. repl.str + .. to_str:sub(repl.to - v_to_from + 1 + 1) + -- start-position of string has to be updated. + snipstr_map[self][v_i_from] = v_from_from + end + + -- update marks. + -- take note that repl_from and repl_to are given wrt. the outermost + -- snippet_string, and mark.pos is relative to self. + -- So, these have to be converted to and from. + local self_offset = snipstr_map[self][1] - 1 + for _, mark in ipairs(self.marks) do + if repl.to < mark.pos + self_offset then + -- mark shifted to the right. + mark.pos = mark.pos - (repl.to - repl.from + 1) + #repl.str + elseif repl.from < mark.pos + self_offset then + -- we already know that repl.to >= mark.pos. + -- This means that the marker is inside the deleted region, and + -- we have to somehow find a sensible new position. + + -- For now, shift the mark to the beginning or end of the newly + -- inserted text, depending on rgrav. + mark.pos = (mark.rgrav and repl.to + 1 or repl.from) + - self_offset + end + -- in this case the replacement is completely behind the marks + -- position, don't have to change it. + end + end +end + +-- replacements may not be zero-width! +local function replace(self, replacements) + local snipstr_map = {} + gen_snipstr_map(self, snipstr_map, 1) + _replace(self, replacements, snipstr_map) +end + +local function upper(self) + for i, v in ipairs(self) do + if v.snip then + v.snip:subtree_do({ + pre = function(node) + if node.static_text then + if M.isinstance(node.static_text) then + node.static_text:_upper() + else + str_util.multiline_upper(node.static_text) + end + end + end, + post = util.nop, + }) + else + self[i] = v:upper() + end + end +end + +local function lower(self) + for i, v in ipairs(self) do + if v.snip then + v.snip:subtree_do({ + pre = function(node) + if node.static_text then + if M.isinstance(node.static_text) then + node.static_text:_lower() + else + str_util.multiline_lower(node.static_text) + end + end + end, + post = util.nop, + }) + else + self[i] = v:lower() + end + end +end + +function SnippetString:lower() + local cop = self:copy() + lower(cop) + return cop +end +function SnippetString:upper() + local cop = self:copy() + upper(cop) + return cop +end + +-- gsub will preserve snippets as long as a substituted region does not overlap +-- more than one node. +-- gsub will ignore zero-length matches. In these cases, it becomes less easy +-- to define the association of new string -> static_text it should be +-- associated with, so these are ignored (until a sensible behaviour is clear +-- (maybe respect rgrav behaviour? does not seem useful)). +-- Also, it should be straightforward to circumvent this by doing something +-- like :gsub("(.)", "%1_") or :gsub("(.)", "_%1") to choose the "side" where a +-- new char is inserted, +function SnippetString:gsub(pattern, repl) + self = self:copy() + + local find_from = 1 + local str = self:str() + local replacements = {} + while true do + local match_from, match_to = str:find(pattern, find_from) + if not match_from then + break + end + -- only allow matches that are not empty. + if match_from <= match_to then + table.insert(replacements, { + from = match_from, + to = match_to, + str = str:sub(match_from, match_to):gsub(pattern, repl), + }) + end + find_from = match_to + 1 + end + replace(self, replacements) + + return self +end + +function SnippetString:sub(from, to) + self = self:copy() + + local snipstr_map = {} + local str = gen_snipstr_map(self, snipstr_map, 1) + + to = to or #str + + -- negative -> positive + if from < 0 then + from = #str + from + 1 + end + if to < 0 then + to = #str + to + 1 + end + + -- empty range => return empty snippetString. + if from > #str or to < from or to < 1 then + return M.new({ "" }) + end + + from = math.max(from, 1) + to = math.min(to, #str) + + local replacements = {} + -- from <= 1 => don't need to remove from beginning. + if from > 1 then + table.insert(replacements, { from = 1, to = from - 1, str = "" }) + end + -- to >= #str => don't need to remove from end. + if to < #str then + table.insert(replacements, { from = to + 1, to = #str, str = "" }) + end + + _replace(self, replacements, snipstr_map) + return self +end + +-- add a kind-of extmark to the text in this buffer. It moves with inserted +-- text, and has a gravity to control into which direction it shifts. +-- pos is 1-based and refers to one character in the string, rgrav = true can be +-- understood as the mark being incident with the characters right edge (replace +-- character at pos with multiple characters => mark will move to the right of +-- the newly inserted chars), and rgrav = false with the left edge (replace char +-- with multiple chars => mark stays at char). +-- If the edge is in the middle of multiple characters (for example rgrav=true, +-- and chars at pos and pos+1 are replaced), the mark is removed. +function SnippetString:add_mark(id, pos, rgrav) + -- I'd expect there to be at most 0-2 marks in any given static_text, which + -- are those set to track the cursor-position. + -- We can thus use a flat array in favor of more complicated data + -- structures. + -- Internally, treat all marks as sticking to the left edge of their + -- respective character, and simply +1 or -1 them to match gravity + -- (rgrav=true @ pos === rgrav=false @ pos+1). + -- gravity still has to be stored to correctly return the marks position + -- when it is retrieved. + table.insert(self.marks, { + id = id, + pos = pos + (rgrav and 1 or 0), + rgrav = rgrav, + }) +end + +function SnippetString:get_mark_pos(id) + for _, mark in ipairs(self.marks) do + if mark.id == id then + return mark.pos - (mark.rgrav and 1 or 0) + end + end +end + +function SnippetString:clear_marks() + self.marks = {} +end + +return M diff --git a/lua/luasnip/session/init.lua b/lua/luasnip/session/init.lua index ad1d6dfa4..3522e2c41 100644 --- a/lua/luasnip/session/init.lua +++ b/lua/luasnip/session/init.lua @@ -32,12 +32,18 @@ M.latest_load_ft = nil M.last_expand_snip = nil M.last_expand_opts = nil --- jump_active is set while luasnip moves the cursor, prevents --- (for example) updating dependents or deleting a snippet via --- exit_out_of_region while jumping. --- init with false, it will be set by (eg.) ls.jump(). +-- jump_active is set while luasnip moves the cursor (or is just generally +-- currently modifying the buffer), and prevents (for example) updating +-- dependents or deleting a snippet via exit_out_of_region while jumping (or +-- while any other state-modifying operation is being executed, and other +-- should therefore be prevented). init with false, it will be set by (eg.) +-- ls.jump(). M.jump_active = false +-- this is non-nil while a luasnip-api-call is active, and allows us to reuse +-- certain data that we just set without resorting to querying the buffer. +M.luasnip_changedtick = nil + -- initial value, might be overwritten immediately. -- No danger of overwriting user-config, since this has to be loaded to allow -- overwriting. diff --git a/lua/luasnip/util/feedkeys.lua b/lua/luasnip/util/feedkeys.lua index 7181eff42..0b5a362d0 100644 --- a/lua/luasnip/util/feedkeys.lua +++ b/lua/luasnip/util/feedkeys.lua @@ -23,7 +23,12 @@ local executing_id = nil -- contains functions which take exactly one argument, the id. local enqueued_actions = {} +local enqueued_cursor_state +---Inserts keys into the beginning of the typeahead buffer and add a `confirm` +---after them. +---@param id number Id from next_id() +---@param keys string Keys to insert local function _feedkeys_insert(id, keys) executing_id = id vim.api.nvim_feedkeys( @@ -43,11 +48,11 @@ local function _feedkeys_insert(id, keys) ) end -local function enqueue_action(fn) - -- get unique id and increment global. - local keys_id = current_id +local function next_id() current_id = current_id + 1 - + return current_id - 1 +end +local function enqueue_action(fn, keys_id) -- if there is nothing from luasnip currently executing, we may just insert -- into the typeahead if executing_id == nil then @@ -57,27 +62,54 @@ local function enqueue_action(fn) end end +function M.enqueue_action(fn) + enqueue_action(function(id) + fn() + M.confirm(id) + end, next_id()) +end + function M.feedkeys_insert(keys) enqueue_action(function(id) _feedkeys_insert(id, keys) - end) + end, next_id()) end -- pos: (0,0)-indexed. local function cursor_set_keys(pos, before) if before then if pos[2] == 0 then - pos[1] = pos[1] - 1 - -- pos2 is set to last columnt of previous line. - -- # counts bytes, but win_set_cursor expects bytes, so all's good. - pos[2] = - #vim.api.nvim_buf_get_lines(0, pos[1], pos[1] + 1, false)[1] + local prev_line_str = + vim.api.nvim_buf_get_lines(0, pos[1] - 1, pos[1], false)[1] + if prev_line_str then + -- set onto last column of previous line, if possible. + pos[1] = pos[1] - 1 + -- # counts bytes, but win_set_cursor expects bytes, so all's good. + pos[2] = #prev_line_str + end else pos[2] = pos[2] - 1 end end - return "lua vim.api.nvim_win_set_cursor(0,{" + -- since cursor-movements may happen asynchronously to other operations, + -- like deleting text, it's possible that we initiate a cursor movement, and + -- subsequently delete text, but the text is deleted before the cursor is + -- actually moved, which may (in the worst case) cause an error here. + -- This can be reproduced with the `session: position is restored correctly + -- after change_choice.`-test, which calls change_choice, in which + -- 1. active_update_dependents re-selects the currently active insertNode + -- 2. the immediately following change_choice removes the text associated + -- with the insertNode + -- -> the above, and an error here. + -- + -- I think a simple pcall is an appropriate solution, since removing the + -- text is very certainly done due to some other luasnip-operation, which + -- will also conclude with a cursor-movement. + -- Note that the cursor-store for that last movement may look into the + -- enqueued_cursor_state-variable, and thus has the correct position, even + -- if this move has not yet completed. + return "lua pcall(vim.api.nvim_win_set_cursor, 0,{" -- +1, win_set_cursor starts at 1. .. pos[1] + 1 .. "," @@ -88,7 +120,10 @@ local function cursor_set_keys(pos, before) end function M.select_range(b, e) - enqueue_action(function(id) + local id = next_id() + enqueued_cursor_state = + { pos = vim.deepcopy(b), pos_v = vim.deepcopy(e), mode = "s", id = id } + enqueue_action(function() -- stylua: ignore _feedkeys_insert(id, -- this esc -> movement sometimes leads to a slight flicker @@ -114,12 +149,15 @@ function M.select_range(b, e) -- set before cursor_set_keys(e, true)) .. "o_" ) - end) + end, id) end -- move the cursor to a position and enter insert-mode (or stay in it). function M.insert_at(pos) - enqueue_action(function(id) + local id = next_id() + enqueued_cursor_state = { pos = pos, mode = "i", id = id } + + enqueue_action(function() -- if current and target mode is INSERT, there's no reason to leave it. if vim.fn.mode() == "i" then -- can skip feedkeys here, we can complete this command from lua. @@ -133,16 +171,73 @@ function M.insert_at(pos) -- mode might be VISUAL or something else => to know we're in NORMAL. _feedkeys_insert(id, "i" .. cursor_set_keys(pos)) end - end) + end, id) +end + +-- move, without changing mode. +function M.move_to_normal(pos) + local id = next_id() + -- preserve mode. + enqueued_cursor_state = { pos = pos, mode = "n", id = id } + + enqueue_action(function() + if vim.fn.mode():sub(1, 1) == "n" then + util.set_cursor_0ind(pos) + M.confirm(id) + else + _feedkeys_insert(id, "" .. cursor_set_keys(pos)) + end + end, id) end function M.confirm(id) executing_id = nil + enqueued_actions[id] = nil + + if enqueued_cursor_state and enqueued_cursor_state.id == id then + -- only clear state if set by this action. + enqueued_cursor_state = nil + end if enqueued_actions[id + 1] then enqueued_actions[id + 1](id + 1) - enqueued_actions[id + 1] = nil end end +---@class LuaSnip.Feedkeys.LastState +---@field pos LuaSnip.BytecolBufferPosition Position of the cursor or beginning of visual +---area. +---@field pos_v LuaSnip.BytecolBufferPosition Position of the cursor or end of visual +---area. +---@field mode string Represents the current mode. Only the first character of +---`vim.fn.mode()`, so not completely exact. + +---if there are some operations that move the cursor enqueued, retrieve their +---target-state, otherwise return the current cursor state. +---@return LuaSnip.Feedkeys.LastState +function M.last_state() + if enqueued_cursor_state then + local state = vim.deepcopy(enqueued_cursor_state) + -- remove internal data. + state.id = nil + return state + end + + local state = {} + + local getposdot = vim.fn.getpos(".") + state.pos = { getposdot[2] - 1, getposdot[3] - 1 } + + local getposv = vim.fn.getpos("v") + -- store selection-range with end-position one column after the cursor + -- at the end (so -1 to make getpos-position 0-based, +1 to move it one + -- beyond the last character of the range) + state.pos_v = { getposv[2] - 1, getposv[3] } + + -- only store first component. + state.mode = vim.fn.mode():sub(1, 1) + + return state +end + return M diff --git a/lua/luasnip/util/mark.lua b/lua/luasnip/util/mark.lua index 445888ccd..ab4e539f7 100644 --- a/lua/luasnip/util/mark.lua +++ b/lua/luasnip/util/mark.lua @@ -1,6 +1,7 @@ local session = require("luasnip.session") local util = require("luasnip.util.util") +---@class LuaSnip.Mark local Mark = {} function Mark:new(o) @@ -198,8 +199,15 @@ function Mark:update_opts(opts) self:set_opts(opts_cp) end +-- invalidate this mark object only, leave the underlying extmark alone. +function Mark:invalidate() + self.id = nil +end + function Mark:clear() - vim.api.nvim_buf_del_extmark(0, session.ns_id, self.id) + if self.id then + vim.api.nvim_buf_del_extmark(0, session.ns_id, self.id) + end end return { diff --git a/lua/luasnip/util/str.lua b/lua/luasnip/util/str.lua index 047772a58..8f8a29ec8 100644 --- a/lua/luasnip/util/str.lua +++ b/lua/luasnip/util/str.lua @@ -157,6 +157,102 @@ function M.sanitize(str) return str:gsub("%\r", "") end +-- requires that from and to are within the region of str. +-- str is treated as a 0,0-indexed, and the character at `to` is excluded from +-- the result. +-- `from` may not be before `to`. +function M.multiline_substr(str, from, to) + local res = {} + + -- include all rows + for i = from[1], to[1] do + table.insert(res, str[i + 1]) + end + + -- trim text before from and after to. + -- First trim from behind, that way this works correctly if from and to are + -- on the same line. If res[1] was trimmed first, we'd have to adjust the + -- trim-point of `to`. + res[#res] = res[#res]:sub(1, to[2]) + res[1] = res[1]:sub(from[2] + 1) + + return res +end + +function M.multiline_upper(str) + for i, s in ipairs(str) do + str[i] = s:upper() + end +end +function M.multiline_lower(str) + for i, s in ipairs(str) do + str[i] = s:lower() + end +end + +-- modifies strmod +function M.multiline_append(strmod, strappend) + strmod[#strmod] = strmod[#strmod] .. strappend[1] + for i = 2, #strappend do + table.insert(strmod, strappend[i]) + end +end + +-- turn a row+col-offset for a multiline-string (string[]) (where the column is +-- given in bytes and 0-based) into an offset (in bytes, 1-based) for +-- the \n-concatenated version of that string. +--- +---@param str string[], a multiline string +---@param pos LuaSnip.ApiPosition, an api-position relative to the start of str. +function M.multiline_to_byte_offset(str, pos) + if pos[1] < 0 or pos[1] + 1 > #str or pos[2] < 0 then + -- pos is trivially (row negative or beyond str, or col negative) + -- outside of str, can't represent position in str. + -- col-wise outside will be determined later, but we want this + -- precondition for following code. + return nil + end + + local byte_pos = 0 + for i = 1, pos[1] do + -- increase index by full lines, don't forget +1 for \n. + byte_pos = byte_pos + #str[i] + 1 + end + + -- allow positions one beyond the last character for all lines (even the + -- last line). + if pos[2] >= #str[pos[1] + 1] + 1 then + -- in this case, pos is outside of the multiline-region. + return nil + end + + -- I think we can always assume utf-8? + byte_pos = byte_pos + pos[2] + + -- 0- to 1-based columns. + return byte_pos + 1 +end + +-- inverse of multiline_to_byte_offset, 1-based byte to 0,0-based row,column. +---@param str string[], the multiline string +---@param byte_pos number, a 1-based index into the \n-concatenated `str`. +function M.byte_to_multiline_offset(str, byte_pos) + if byte_pos < 0 then + return nil + end + + local byte_pos_so_far = 0 + for i, line in ipairs(str) do + -- line-length + \n. + local line_i_end = byte_pos_so_far + #line + 1 + if byte_pos <= line_i_end then + -- byte is in this line, return it. + return { i - 1, byte_pos - byte_pos_so_far - 1 } + end + byte_pos_so_far = line_i_end + end +end + -- string-operations implemented according to -- https://github.com/microsoft/vscode/blob/71c221c532996c9976405f62bb888283c0cf6545/src/vs/editor/contrib/snippet/browser/snippetParser.ts#L372-L415 -- such that they can be used for snippet-transformations in vscode-snippets. @@ -171,6 +267,7 @@ local function pascalcase(str) end return pascalcased end + M.vscode_string_modifiers = { upcase = string.upper, downcase = string.lower, diff --git a/lua/luasnip/util/util.lua b/lua/luasnip/util/util.lua index a4445b729..144dc71d2 100644 --- a/lua/luasnip/util/util.lua +++ b/lua/luasnip/util/util.lua @@ -346,15 +346,6 @@ local function key_sorted_pairs(t) end end -local function no_region_check_wrap(fn, ...) - session.jump_active = true - -- will run on next tick, after autocommands (especially CursorMoved) for this are done. - vim.schedule(function() - session.jump_active = false - end) - return fn(...) -end - local function id(a) return a end @@ -422,6 +413,37 @@ local function default_tbl_get(default, t, ...) return default end +-- compute offset of `pos` into multiline string starting at `base_pos`. +-- This is different from pos_sub because here the column-offset starts at zero +-- when `pos` is on a line different from `base_pos`. +-- Assumption: `pos` occurs after `base_pos`. +local function pos_offset(base_pos, pos) + local row_offset = pos[1] - base_pos[1] + return { row_offset, row_offset == 0 and pos[2] - base_pos[2] or pos[2] } +end + +-- compute offset of `pos` into multiline string starting at `base_pos`. +-- This is different from pos_sub because here the column-offset starts at zero +-- when `pos` is on a line different from `base_pos`. +-- Assumption: `pos` occurs after `base_pos`. +local function pos_from_offset(base_pos, offset) + return { + base_pos[1] + offset[1], + offset[1] == 0 and base_pos[2] + offset[2] or offset[2], + } +end + +local function shallow_copy(t) + if type(t) == "table" then + local res = {} + for k, v in pairs(t) do + res[k] = v + end + return res + end + return t +end + return { get_cursor_0ind = get_cursor_0ind, set_cursor_0ind = set_cursor_0ind, @@ -453,7 +475,6 @@ return { deduplicate = deduplicate, pop_front = pop_front, key_sorted_pairs = key_sorted_pairs, - no_region_check_wrap = no_region_check_wrap, id = id, no = no, yes = yes, @@ -465,4 +486,7 @@ return { validate = validate, str_utf32index = str_utf32index, default_tbl_get = default_tbl_get, + pos_offset = pos_offset, + pos_from_offset = pos_from_offset, + shallow_copy = shallow_copy, } diff --git a/tests/helpers.lua b/tests/helpers.lua index 25175ea34..5fdd11e64 100644 --- a/tests/helpers.lua +++ b/tests/helpers.lua @@ -202,6 +202,7 @@ function M.session_setup_luasnip(opts) sp = require("luasnip.nodes.snippetProxy") pf = require("luasnip.extras.postfix").postfix k = require("luasnip.nodes.key_indexer").new_key + opt = require("luasnip.nodes.optional_arg").new_opt ]]) end diff --git a/tests/integration/choice_spec.lua b/tests/integration/choice_spec.lua index 704192461..b4f944181 100644 --- a/tests/integration/choice_spec.lua +++ b/tests/integration/choice_spec.lua @@ -208,7 +208,7 @@ describe("ChoiceNode", function() {2:-- SELECT --} |]], }) assert.are.same(exec_lua("return ls.get_current_choices()"), { - "${${1:a}}", + "${${1:b}}", "none", }) @@ -308,4 +308,401 @@ describe("ChoiceNode", function() {2:-- INSERT --} |]], }) end) + + it("correctly gives current content of choices.", function() + assert.are.same( + { "${1:asdf}", "qwer" }, + exec_lua([[ + ls.snip_expand(s("trig", { + c(1, { + i(1, "asdf"), + t"qwer" + }) + })) + ls.change_choice() + return ls.get_current_choices() + ]]) + ) + end) + + it("correctly restores the generated node of a dynamicNode.", function() + assert.are.same( + { "${1:${${1:aaa}${2:${1:aaa}}}}$0" }, + exec_lua([[ + snip = s("trig", { + c(1, { + r(nil, "restore_key", { + i(1, "aaa"), d(2, function(args) return sn(nil, {i(1, args[1])}) end, {1}, {snippetstring_args = true}) + }), + { + t"a", + r(1, "restore_key"), + t"a" + } + }) + }) + return snip:get_docstring() + ]]) + ) + exec_lua("ls.snip_expand(snip)") + feed("qwer") + exec_lua("ls.jump(1)") + screen:expect({ + grid = [[ + qwer^q{3:wer} | + {0:~ }| + {2:-- SELECT --} | + ]], + }) + exec_lua("ls.change_choice(1)") + screen:expect({ + grid = [[ + a^q{3:wer}qwera | + {0:~ }| + {2:-- SELECT --} | + ]], + }) + end) + + it("cursor is correctly restored after change", function() + screen:detach() + + ls_helpers.clear() + ls_helpers.session_setup_luasnip() + + screen = ls_helpers.new_screen(50, 7) + screen:set_default_attr_ids({ + [0] = { bold = true, foreground = Screen.colors.Blue }, + [1] = { bold = true, foreground = Screen.colors.Brown }, + [2] = { bold = true }, + [3] = { background = Screen.colors.LightGray }, + }) + + exec_lua([=[ + ls.snip_expand(s("trig", { + c(1, { + fmt([[ + local {} = function() + {} + end + ]], {r(1, "name", i(1, "fname")), sn(2, {t{"aaaa", "bbbb"},r(1, "body", i(1, "fbody"))}) }), + fmt([[ + local function {}() + {} + end + ]], {r(1, "name", i(1, "fname")), r(2, "body", i(1, "fbody"))}) + }, {restore_cursor = true}) + })) + ]=]) + exec_lua("vim.wait(10, function() end)") + + exec_lua("ls.jump(1)") + feed("asdfasdfqweraaaa") + screen:expect({ + grid = [[ + local fname = function() | + aaaa | + bbbbasdf | + asdf | + qwer | + aa^aa | + {2:-- INSERT --} | + ]], + }) + exec_lua("ls.change_choice(1)") + screen:expect({ + grid = [[ + local function fname() | + asdf | + asdf | + qwer | + aa^aa | + end | + {2:-- INSERT --} | + ]], + }) + exec_lua("ls.jump(-1)") + exec_lua("ls.jump(1)") + screen:expect({ + grid = [[ + local function fname() | + ^a{3:sdf} | + {3:asdf} | + {3:qwer} | + {3: aaaa} | + end | + {2:-- SELECT --} | + ]], + }) + exec_lua("ls.change_choice(1)") + screen:expect({ + grid = [[ + aaaa | + bbbb^a{3:sdf} | + {3:asdf} | + {3:qwer} | + {3: aaaa} | + end | + {2:-- SELECT --} | + ]], + }) + feed("i") + exec_lua("ls.change_choice(1)") + exec_lua([=[ + ls.snip_expand(s("for", { + t"for ", c(1, { + sn(nil, {i(1, "k"), t", ", i(2, "v"), t" in ", c(3, {{t"pairs(",i(1),t")"}, {t"ipairs(",i(1),t")"}, i(nil)}, {restore_cursor = true}) }), + sn(nil, {i(1, "val"), t" in ", i(2) }), + sn(nil, {i(1, "i"), t" = ", i(2), t", ", i(3) }), + fmt([[{} in vim.gsplit({})]], {i(1, "str"), i(2)}) + }, {restore_cursor = true}), t{" do", "\t"}, isn(2, {dl(1, l.LS_SELECT_DEDENT)}, "$PARENT_INDENT\t"), t{"", "end"} + })) + ]=]) + screen:expect({ + grid = [[ + local function fname() | + for ^k, v in pairs() do | + | + endi | + end | + {0:~ }| + {2:-- SELECT --} | + ]], + }) + exec_lua("ls.change_choice(1)") + screen:expect({ + grid = [[ + local function fname() | + for ^v{3:al} in do | + | + endi | + end | + {0:~ }| + {2:-- SELECT --} | + ]], + }) + exec_lua("ls.jump(1)") + exec_lua("ls.jump(1)") + screen:expect({ + grid = [[ + local function fname() | + for val in do | + ^ | + endi | + end | + {0:~ }| + {2:-- INSERT --} | + ]], + }) + exec_lua("ls.change_choice(1)") + screen:expect({ + grid = [[ + local fname = function() | + aaaa | + bbbbfor val in do | + ^ | + endi | + end | + {2:-- INSERT --} | + ]], + }) + end) + + it("select_choice works.", function() + exec_lua([=[ + ls.snip_expand(s("for", { + t"for ", c(1, { + sn(nil, {i(1, "k"), t", ", i(2, "v"), t" in ", c(3, {{t"pairs(",i(1),t")"}, {t"ipairs(",i(1),t")"}, i(nil)}, {restore_cursor = true}) }), + sn(nil, {i(1, "val"), t" in ", i(2) }), + sn(nil, {i(1, "i"), t" = ", i(2), t", ", i(3) }), + fmt([[{} in vim.gsplit({})]], {i(1, "str"), i(2)}) + }, {restore_cursor = true}), t{" do", "\t"}, isn(2, {dl(1, l.LS_SELECT_DEDENT)}, "$PARENT_INDENT\t"), t{"", "end"} + })) + ]=]) + feed("lua require('luasnip.extras.select_choice')()2") + screen:expect({ + grid = [[ + for ^v{3:al} in do | + | + {2:-- SELECT --} | + ]], + }) + feed("aa") + -- simulate vim.ui.select that modifies the cursor. + -- Can happen in the wild with plugins like dressing.nvim (although + -- those usually just leave INSERT), and we would like to prevent it. + exec_lua([[ + vim.ui.select = function(_,_,cb) + vim.api.nvim_feedkeys( + vim.api.nvim_replace_termcodes( + "", + true, + false, + true + ), + "nix", + true) + + cb(nil, 2) + end + ]]) + -- re-selecting correctly highlights text again (test by editing so the test does not pass immediately, without any changes!) + exec_lua("require('luasnip.extras.select_choice')()") + screen:expect({ + grid = [[ + for a^a in do | + | + {2:-- INSERT --} | + ]], + }) + end) + + it("updates the active node before changing choice.", function() + exec_lua([[ + ls.setup({ + link_children = true + }) + ls.snip_expand(s("trig", { + t":", + c(1, { + {r(1, "key", d(1, function(args) + if not args[1] then + return sn(nil, {i(1, "aa", {key = "i"})}) + else + return sn(nil, {i(1, "cc"), i(2, args[1]:gsub("a", "ee"), {key = "i"})}) + end + end, { opt(k("i")) }, {snippetstring_args = true}))}, + {t".", r(1, "key"), t"."} + }, {restore_cursor = true}), + t":" + })) + ]]) + exec_lua("ls.jump(1)") + feed("i aa ") + screen:expect({ + grid = [[ + :ccee a^a ee: | + {0:~ }| + {2:-- INSERT --} | + ]], + }) + -- if we wouldn't update before the change_choice, the last_args of the + -- restored dynamicNode would not fit its current content, and we'd + -- lose the text inserted until now due to the update (as opposed to + -- a proper restore of dynamicNode.snip, which should occur in a + -- restoreNode). + exec_lua("ls.change_choice(1)") + screen:expect({ + grid = [[ + :.ccee ee^ee ee.: | + {0:~ }| + {2:-- INSERT --} | + ]], + }) + exec_lua("ls.set_choice(2)") + screen:expect({ unchanged = true }) + + -- test some more wild stuff, just because. + feed("") + exec_lua([[ + ls.snip_expand(s("trig", { + t":", + c(1, { + {r(1, "key", d(1, function(args) + if not args[1] then + return sn(nil, {i(1, "aa", {key = "i"})}) + else + return sn(nil, {i(1, "cc"), i(2, args[1]:gsub("a", "ee"), {key = "i"})}) + end + end, { opt(k("i")) }, {snippetstring_args = true}))}, + {t".", r(1, "key"), t"."} + }, {restore_cursor = true}), + t":" + })) + ]]) + + screen:expect([[ + :.ccee ee :^c{3:c}eeee: ee ee.: | + {0:~ }| + {2:-- SELECT --} | +]]) + exec_lua("ls.jump(1)") + feed("i aa ") + exec_lua("ls.set_choice(2)") + + screen:expect([[ + :.ccee ee :.ccee ee^ee ee.: ee ee.: | + {0:~ }| + {2:-- INSERT --} | +]]) + + -- reselect outer choiceNode + exec_lua("ls.jump(-1)") + exec_lua("ls.jump(-1)") + exec_lua("ls.jump(-1)") + exec_lua("ls.jump(1)") + screen:expect([[ + :.cc^e{3:e ee :.ccee eeee ee.: ee ee}.: | + {0:~ }| + {2:-- SELECT --} | +]]) + exec_lua("ls.change_choice(1)") + screen:expect([[ + :cc^e{3:e ee :.ccee eeee ee.: ee ee}: | + {0:~ }| + {2:-- SELECT --} | +]]) + exec_lua("ls.jump(1)") + exec_lua("ls.jump(1)") + screen:expect([[ + :ccee ee :.cc^e{3:e eeee ee}.: ee ee: | + {0:~ }| + {2:-- SELECT --} | +]]) + end) + + it("correctly handles unicode when storing and restoring.", function() + exec_lua([=[ + ls.snip_expand( + s("choice", { + c(1, { + {t"a ", r(1, "k", i(1)), t" a"}, + {t"bb ", r(1, "k"), t" bb"} + }, {restore_cursor = true}) + })) + ]=]) + screen:expect([[ + a ^ a | + {0:~ }| + {2:-- INSERT --} | +]]) + feed("a a") + exec_lua([=[ + ls.snip_expand(s("bad", {i(1, "i…i")})) + ]=]) + screen:expect([[ + a a ^i{3:…i} a a | + {0:~ }| + {2:-- SELECT --} | +]]) + + exec_lua("ls.change_choice(1)") + screen:expect([[ + bb a ^i{3:…i} a bb | + {0:~ }| + {2:-- SELECT --} | +]]) + feed("la") + screen:expect([[ + bb a i…^i a bb | + {0:~ }| + {2:-- INSERT --} | +]]) + exec_lua("ls.change_choice(1)") + screen:expect([[ + a a i…^i a a | + {0:~ }| + {2:-- INSERT --} | +]]) + end) end) diff --git a/tests/integration/dynamic_spec.lua b/tests/integration/dynamic_spec.lua index ba074531c..6bbbccc07 100644 --- a/tests/integration/dynamic_spec.lua +++ b/tests/integration/dynamic_spec.lua @@ -366,4 +366,101 @@ describe("DynamicNode", function() ) end ) + + it("dynamicNode can depend on itself.", function() + exec_lua([[ + ls.setup({ + update_events = "TextChangedI" + }) + ls.snip_expand(s("trig", { + d(1, function(args) + if not args[1] then + return sn(nil, {i(1, "asdf", {key = "ins"})}) + else + return sn(nil, {i(1, args[1][1]:gsub("a", "e"), {key = "ins"})}) + end + end, {opt(k("ins"))}) + })) + ]]) + screen:expect({ + grid = [[ + ^e{3:sdf} | + {0:~ }| + {2:-- SELECT --} | + ]], + }) + feed("aaaaa") + screen:expect({ + grid = [[ + eeeee^ | + {0:~ }| + {2:-- INSERT --} | + ]], + }) + end) + + it( + "selected text is selected again after updating (when possible).", + function() + assert.are.same( + { "${1:${1:esdf}}$0" }, + exec_lua([[ + snip = s("trig", { + d(1, function(args) + if not args[1] then + return sn(nil, {i(1, "asdf", {key = "ins"})}) + else + return sn(nil, {i(1, args[1]:gsub("a", "e"), {key = "ins"})}) + end + end, {opt(k("ins"))}, {snippetstring_args = true}) + }) + return snip:get_docstring() + ]]) + ) + exec_lua([[ + ls.snip_expand(snip) + ]]) + feed("a") + exec_lua("ls.lsp_expand('${1:asdf}')") + screen:expect({ + grid = [[ + e^e{3:sdf}sdf | + {0:~ }| + {2:-- SELECT --} | + ]], + }) + end + ) + + it("cursor-position is moved with text-manipulations.", function() + exec_lua([[ + ls.snip_expand(s("trig", { + d(1, function(args) + if not args[1] then + return sn(nil, {i(1, "asdf", {key = "ins"})}) + else + return sn(nil, {i(1, args[1]:gsub("a", "ee"), {key = "ins"})}) + end + end, {opt(k("ins"))}, {snippetstring_args = true}) + })) + ]]) + + screen:expect({ + grid = [[ + ^e{3:esdf} | + {0:~ }| + {2:-- SELECT --} | + ]], + }) + feed("aaaaaa") + screen:expect({ + grid = [[ + eeeeee^eeeeee | + {0:~ }| + | + ]], + }) + end) + + it("") end) diff --git a/tests/integration/function_spec.lua b/tests/integration/function_spec.lua index c9e66998d..84f4d772f 100644 --- a/tests/integration/function_spec.lua +++ b/tests/integration/function_spec.lua @@ -179,8 +179,8 @@ describe("FunctionNode", function() }) ]] assert.are.same( - exec_lua("return " .. snip .. ":get_static_text()"), - { "cccc aaaa" } + { "cccc aaaa" }, + exec_lua("return " .. snip .. ":get_static_text()") ) -- the functionNode shouldn't be evaluated after expansion, the ai[2][2] isn't available. exec_lua("ls.snip_expand(" .. snip .. ")") diff --git a/tests/integration/restore_spec.lua b/tests/integration/restore_spec.lua index 5a8f31c12..2e42c0ec4 100644 --- a/tests/integration/restore_spec.lua +++ b/tests/integration/restore_spec.lua @@ -353,4 +353,196 @@ describe("RestoreNode", function() {2:-- SELECT --} |]], }) end) + + it("correctly restores snippets (1).", function() + exec_lua([[ + ls.snip_expand(s("trig", { + c(1, { + sn(nil, {t"a: ", r(1, "key", i(1, "asdf"))}), + sn(nil, {t"b: ", r(1, "key")}), + }, {restore_cursor = true}) + })) + ]]) + + feed(". .") + exec_lua("ls.lsp_expand('($1)')") + screen:expect({ + grid = [[ + a: . (^) . | + {0:~ }| + {2:-- INSERT --} | + ]], + }) + exec_lua("ls.change_choice(1)") + screen:expect({ + grid = [[ + b: . (^) . | + {0:~ }| + {2:-- INSERT --} | + ]], + }) + end) + + it("correctly restores snippets (2).", function() + exec_lua([[ + ls.setup({link_children = true}) + ls.snip_expand(s("trig", { + i(1, "asdf"), t" ", d(2, function(args) + return sn(nil, { + r(1, "key", i(1, "qq")), + i(2, args[1]) + }) + end, {1}) + })) + ]]) + exec_lua([[ls.jump(1)]]) + feed(". .") + exec_lua("ls.lsp_expand('($1)')") + feed("i") + screen:expect({ + grid = [[ + asdf . (i^) .asdf | + {0:~ }| + {2:-- INSERT --} | + ]], + }) + exec_lua("ls.jump(-1) ls.jump(-1)") + feed("qwer") + exec_lua("ls.jump(1) ls.jump(1)") + screen:expect({ + grid = [[ + qwer . (^i) .qwer | + {0:~ }| + {2:-- SELECT --} | + ]], + }) + end) + + -- make sure store and update_restore propagate. + it("correctly restores snippets (3).", function() + exec_lua([[ + ls.setup({link_children = true}) + ls.snip_expand(s("trig", { + i(1, "asdf"), t" ", d(2, function(args) + return sn(nil, { + r(1, "key", i(1, "qq")), + i(2, args[1]) + }) + end, {1}) + })) + ]]) + exec_lua([[ls.jump(1)]]) + feed(". .") + exec_lua([[ + ls.snip_expand(s("trig", { + t("("), r(1, "inside_pairs", dl(1, l.LS_SELECT_DEDENT)), t(")") + })) + ]]) + feed("i") + screen:expect({ + grid = [[ + asdf . (i^) .asdf | + {0:~ }| + {2:-- INSERT --} | + ]], + }) + exec_lua("ls.jump(-1) ls.jump(-1)") + feed("qwer") + exec_lua("ls.jump(1) ls.jump(1)") + screen:expect({ + grid = [[ + qwer . (^i) .qwer | + {0:~ }| + {2:-- SELECT --} | + ]], + }) + end) + + -- make sure store and update_restore propagate. + it("correctly restores snippets (4).", function() + exec_lua([[ + ls.setup({link_children = true}) + ls.snip_expand(s("trig", { + i(1, "asdf"), t" ", d(2, function(args) + return sn(nil, { + r(1, "key", i(1)), + i(2, args[1]) + }) + end, {1}) + })) + ]]) + exec_lua([[ls.jump(1)]]) + + local function exp() + exec_lua([[ + ls.snip_expand(s("trig", { + t("("), r(1, "inside_pairs", dl(1, l.LS_SELECT_DEDENT)), t(")") + })) + ]]) + feed("i") + end + + exp() + exec_lua("ls.jump(1)") + exp() + exec_lua("ls.jump(1)") + exp() + feed("i") + exp() + exp() + exp() + screen:expect({ + grid = [[ + asdf (i)(i)(i (i(i(i^))) i)asdf | + {0:~ }| + {2:-- INSERT --} | + ]], + }) + -- 11x to get back to the i1. + exec_lua("ls.jump(-1) ls.jump(-1) ls.jump(-1)") + exec_lua("ls.jump(-1) ls.jump(-1) ls.jump(-1)") + exec_lua("ls.jump(-1) ls.jump(-1) ls.jump(-1)") + exec_lua("ls.jump(-1) ls.jump(-1)") + feed("qwer") + exec_lua("ls.jump(1)") + screen:expect({ + grid = [[ + qwer ^({3:i)(i)(i (i(i(i))) i)}qwer | + {0:~ }| + {2:-- SELECT --} | + ]], + }) + exec_lua("ls.jump(1) ls.jump(1) ls.jump(1)") + screen:expect({ + grid = [[ + qwer (i)(^i)(i (i(i(i))) i)qwer | + {0:~ }| + {2:-- SELECT --} | + ]], + }) + exec_lua("ls.jump(1) ls.jump(1) ls.jump(1)") + screen:expect({ + grid = [[ + qwer (i)(i)(i (^i{3:(i(i))}) i)qwer | + {0:~ }| + {2:-- SELECT --} | + ]], + }) + exec_lua("ls.jump(1) ls.jump(1) ls.jump(1)") + screen:expect({ + grid = [[ + qwer (i)(i)(i (i(i(i)^)) i)qwer | + {0:~ }| + {2:-- INSERT --} | + ]], + }) + exec_lua("ls.jump(1) ls.jump(1) ls.jump(1) ls.jump(1)") + screen:expect({ + grid = [[ + qwer (i)(i)(i (i(i(i))) i)^q{3:wer} | + {0:~ }| + {2:-- SELECT --} | + ]], + }) + end) end) diff --git a/tests/integration/session_spec.lua b/tests/integration/session_spec.lua index 81d21ea71..6bf096707 100644 --- a/tests/integration/session_spec.lua +++ b/tests/integration/session_spec.lua @@ -379,8 +379,17 @@ describe("session", function() }) -- delete whole buffer. feed("ggVGd") - -- should not cause an error. - jump(1) + -- another jump should not cause an error. + -- for some reason this hangs indefinitely on nvim0.7, but not 0.9 or master. + -- I assume that something is just weird in the test-suite (why would + -- this fail only here specifically (IIRC there are enough tests that + -- do something similar)), and since it's fine on 0.9 and master (which + -- matter much more) there shouldn't be an issue in practice. + exec_lua([[ + if require("luasnip.util.vimversion").ge(0,8,0) then + ls.jump(1) + end + ]]) end) it("Deleting nested snippet only removes it.", function() feed("ofn") @@ -2031,12 +2040,15 @@ describe("session", function() {2:-- INSERT --} |]], }) - -- delete snippet-text while an update for the dynamicNode is pending - -- => when the dynamicNode is left during `refocus`, the deletion will - -- be detected, and snippet removed from the jumplist. - feed("kkkVjjjjjd") + -- delete extmark manually of current node manually, to simulate an + -- issue with it. + -- => when the dynamicNode is left during `refocus`, the deletion + -- will be detected, and snippet removed from the jumplist. + exec_lua( + [[vim.api.nvim_buf_del_extmark(0, ls.session.ns_id, ls.session.current_nodes[1].mark.id)]] + ) - feed("jifn") + feed("Gofn") expand() -- make sure the snippet-roots-list is still an array, and we did not @@ -2249,7 +2261,7 @@ describe("session", function() * | * @return | * | - * @throws | + * @throws cc | */ | private aa bb() {4:●} | throws cc { | @@ -2265,4 +2277,53 @@ describe("session", function() |]], }) end) + + it("position is restored correctly after change_choice.", function() + feed("ifn") + expand() + jump(1) + jump(1) + jump(1) + jump(1) + change(1) + feed("asdf") + change(1) + change(1) + change(1) + -- currently wrong! + screen:expect({ + grid = [[ + /** | + * A short Description | + */ | + public void myFunc()^ { {4:●} | + | + } | + {0:~ }| + {0:~ }| + {0:~ }| + {0:~ }| + {0:~ }| + {0:~ }| + {0:~ }| + {0:~ }| + {0:~ }| + {0:~ }| + {0:~ }| + {0:~ }| + {0:~ }| + {0:~ }| + {0:~ }| + {0:~ }| + {0:~ }| + {0:~ }| + {0:~ }| + {0:~ }| + {0:~ }| + {0:~ }| + {0:~ }| + {2:-- INSERT --} | + ]], + }) + end) end) diff --git a/tests/integration/snippet_basics_spec.lua b/tests/integration/snippet_basics_spec.lua index d16e4cd1c..94ee3e93a 100644 --- a/tests/integration/snippet_basics_spec.lua +++ b/tests/integration/snippet_basics_spec.lua @@ -60,7 +60,7 @@ describe("snippets_basic", function() ls.expand({ jump_into_func = function(snip) izero = snip.insert_nodes[0] - require("luasnip.util.util").no_region_check_wrap(izero.jump_into, izero, 1) + izero:jump_into(1) end }) ]]) @@ -1402,6 +1402,7 @@ describe("snippets_basic", function() } ]]) exec_lua([[ls.lsp_expand("a$1$1a")]]) + exec_lua("vim.wait(10, function() end)") exec_lua([[ls.lsp_expand("b$1")]]) feed("ccc") exec_lua([[ls.active_update_dependents()]]) diff --git a/tests/unit/str_spec.lua b/tests/unit/str_spec.lua index f98c038c2..e39060898 100644 --- a/tests/unit/str_spec.lua +++ b/tests/unit/str_spec.lua @@ -195,3 +195,157 @@ describe("str.convert_indent", function() assert.are.same(expected, result) end) end) + +describe("str.multiline_substr", function() + -- apparently clear() needs to run before anything else... + ls_helpers.clear() + ls_helpers.exec("set rtp+=" .. os.getenv("LUASNIP_SOURCE")) + + local function check(dscr, str, from, to, expected) + it(dscr, function() + assert.are.same( + expected, + exec_lua( + [[ + local str, from, to = ... + return require("luasnip.util.str").multiline_substr(str, from, to) + ]], + str, + from, + to + ) + ) + end) + end + + check( + "entire range", + { "asdf", "qwer" }, + { 0, 0 }, + { 1, 4 }, + { "asdf", "qwer" } + ) + check( + "partial range", + { "asdf", "qwer" }, + { 0, 3 }, + { 1, 2 }, + { "f", "qw" } + ) + check( + "another partial range", + { "asdf", "qwer" }, + { 1, 2 }, + { 1, 3 }, + { "e" } + ) + check( + "one last partial range", + { "asdf", "qwer", "zxcv" }, + { 0, 2 }, + { 2, 4 }, + { "df", "qwer", "zxcv" } + ) + check("empty range", { "asdf", "qwer", "zxcv" }, { 0, 2 }, { 0, 2 }, { "" }) +end) + +describe("str.multiline_to_byte_offset", function() + -- apparently clear() needs to run before anything else... + ls_helpers.clear() + ls_helpers.exec("set rtp+=" .. os.getenv("LUASNIP_SOURCE")) + + local function check(dscr, str, multiline_pos, byte_pos) + it(dscr, function() + assert.are.same( + byte_pos, + exec_lua( + [[ + local str, multiline_pos = ... + return require("luasnip.util.str").multiline_to_byte_offset(str, multiline_pos) + ]], + str, + multiline_pos + ) + ) + end) + end + local function check_is_nil(dscr, str, multiline_pos, byte_pos) + it(dscr, function() + assert(exec_lua( + [[ + local str, multiline_pos = ... + return require("luasnip.util.str").multiline_to_byte_offset(str, multiline_pos) == nil + ]], + str, + multiline_pos + )) + end) + end + + check("single line begin", { "asdf" }, { 0, 0 }, 1) + check("single line middle", { "asdf" }, { 0, 2 }, 3) + check("single line end", { "asdf" }, { 0, 3 }, 4) + check("single line, on \n", { "asdf" }, { 0, 4 }, 5) + check_is_nil("single line, outside of range", { "asdf" }, { 0, 5 }) + check("multiple lines", { "asdf", "qwer" }, { 1, 0 }, 6) + check("multiple lines middle", { "asdf", "qwer" }, { 1, 3 }, 9) + check_is_nil( + "multiple lines outside of range row", + { "asdf", "qwer" }, + { 2, 0 } + ) + check("on linebreak", { "asdf", "qwer" }, { 0, 4 }, 5) + check("on linebreak of last line", { "asdf", "qwer" }, { 1, 4 }, 10) + check_is_nil("negative row", { "asdf", "qwer" }, { -1, 0 }) + check_is_nil("negative col", { "asdf", "qwer" }, { 0, -2 }) + check("unicode1", { "aa … aa" }, { 0, 6 }, 7) + check("unicode2", { "aa …a… aa" }, { 0, 6 }, 7) + check("unicode3", { "aa …a… aa", "aa …a… aa" }, { 1, 6 }, 21) +end) + +describe("byte_to_multiline_offset", function() + -- apparently clear() needs to run before anything else... + ls_helpers.clear() + ls_helpers.exec("set rtp+=" .. os.getenv("LUASNIP_SOURCE")) + + local function check(dscr, str, byte_pos, multiline_pos) + it(dscr, function() + assert.are.same( + multiline_pos, + exec_lua( + [[ + local str, byte_pos = ... + return require("luasnip.util.str").byte_to_multiline_offset(str, byte_pos) + ]], + str, + byte_pos + ) + ) + end) + end + local function check_is_nil(dscr, str, byte_pos, multiline_pos) + it(dscr, function() + assert(exec_lua( + [[ + local str, byte_pos = ... + return require("luasnip.util.str").byte_to_multiline_offset(str, byte_pos) == nil + ]], + str, + byte_pos + )) + end) + end + + check("single line begin", { "asdf" }, 1, { 0, 0 }) + check("single line middle", { "asdf" }, 3, { 0, 2 }) + check("single line end", { "asdf" }, 4, { 0, 3 }) + check("single line on linebreak", { "asdf" }, 5, { 0, 4 }) + check("multiple lines", { "asdf", "qwer" }, 6, { 1, 0 }) + check("multiple lines middle", { "asdf", "qwer" }, 9, { 1, 3 }) + check("multiple lines middle linebreak", { "asdf", "qwer" }, 10, { 1, 4 }) + check_is_nil("before string", { "asdf", "qwer" }, -1) + check_is_nil("multiple lines behind string", { "asdf", "qwer" }, 11) + check("unicode1", { "aa … aa" }, 7, { 0, 6 }) + check("unicode2", { "aa …a… aa" }, 7, { 0, 6 }) + check("unicode3", { "aa …a… aa", "aa …a… aa" }, 21, { 1, 6 }) +end)