From 49a3c64c06dae55b5424b218dc7c6e6b643fd4f5 Mon Sep 17 00:00:00 2001 From: Ben Lubas <56943754+benlubas@users.noreply.github.com> Date: Fri, 17 May 2024 06:51:47 -0400 Subject: [PATCH] feat: text-objects and working swap (#1421) --- ROADMAP.md | 2 +- .../core/integrations/treesitter/module.lua | 97 +++++++++++ .../{manoeuvre => text-objects}/module.lua | 157 ++++++++++-------- 3 files changed, 189 insertions(+), 67 deletions(-) rename lua/neorg/modules/core/{manoeuvre => text-objects}/module.lua (53%) diff --git a/ROADMAP.md b/ROADMAP.md index b41ea9ed2..f3fe5ee6a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -16,7 +16,7 @@ It will be switched to a `.norg` file when possible. ## Miscellaneous - [ ] Make `core.clipboard.code-blocks` work with a visual selection. -- [ ] Reimplement the `core.maneouvre` module, which has been deprecated since `1.0`. +- [x] Reimplement the `core.maneouvre` module, which has been deprecated since `1.0`. - [ ] The `a` and `b` commands in the hop module are not implemented. - [ ] Readd colouring to TODO items. diff --git a/lua/neorg/modules/core/integrations/treesitter/module.lua b/lua/neorg/modules/core/integrations/treesitter/module.lua index f15c1f943..4736f674b 100644 --- a/lua/neorg/modules/core/integrations/treesitter/module.lua +++ b/lua/neorg/modules/core/integrations/treesitter/module.lua @@ -308,6 +308,103 @@ module.public = { return table.concat(lines, "\n") end, + + --- Get the range of a TSNode as an LspRange + ---@param node TSNode + ---@return lsp.range + node_to_lsp_range = function(node) + local start_line, start_col, end_line, end_col = node:range() + return { + start = { line = start_line, character = start_col }, + ["end"] = { line = end_line, character = end_col }, + } + end, + + --- Swap two nodes in the buffer. Ignores newlines at the end of the node + ---@param node1 TSNode + ---@param node2 TSNode + ---@param bufnr number + ---@param cursor_to_second boolean move the cursor to the start of the second node (default false) + swap_nodes = function(node1, node2, bufnr, cursor_to_second) + if not node1 or not node2 then + return + end + local range1 = module.public.node_to_lsp_range(node1) + local range2 = module.public.node_to_lsp_range(node2) + + local text1 = module.public.get_node_text(node1, bufnr) + local text2 = module.public.get_node_text(node2, bufnr) + + if not text1 or not text2 then return end + + text1 = vim.split(text1, "\n") + text2 = vim.split(text2, "\n") + + ---remove trailing blank lines from the text, and update the corresponding range appropriately + ---@param text string[] + ---@param range table + local function remove_trailing_blank_lines(text, range) + local end_line_offset = 0 + while text[#text] == "" do + text[#text] = nil + end_line_offset = end_line_offset + 1 + end + range["end"] = { + character = string.len(text[#text]), + line = range["end"].line - end_line_offset, + } + if #text == 1 then -- ie. start and end lines are equal + range["end"].character = range["end"].character + range.start.character + end + end + + remove_trailing_blank_lines(text1, range1) + remove_trailing_blank_lines(text2, range2) + + local edit1 = { range = range1, newText = table.concat(text2, "\n") } + local edit2 = { range = range2, newText = table.concat(text1, "\n") } + + vim.lsp.util.apply_text_edits({ edit1, edit2 }, bufnr, "utf-8") + + if cursor_to_second then + -- set jump location + vim.cmd "normal! m'" + + local char_delta = 0 + local line_delta = 0 + if + range1["end"].line < range2.start.line + or (range1["end"].line == range2.start.line and range1["end"].character <= range2.start.character) + then + line_delta = #text2 - #text1 + end + + if range1["end"].line == range2.start.line and range1["end"].character <= range2.start.character then + if line_delta ~= 0 then + --- why? + --correction_after_line_change = -range2.start.character + --text_now_before_range2 = #(text2[#text2]) + --space_between_ranges = range2.start.character - range1["end"].character + --char_delta = correction_after_line_change + text_now_before_range2 + space_between_ranges + --- Equivalent to: + char_delta = #text2[#text2] - range1["end"].character + + -- add range1.start.character if last line of range1 (now text2) does not start at 0 + if range1.start.line == range2.start.line + line_delta then + char_delta = char_delta + range1.start.character + end + else + char_delta = #text2[#text2] - #text1[#text1] + end + end + + vim.api.nvim_win_set_cursor( + vim.api.nvim_get_current_win(), + { range2.start.line + 1 + line_delta, range2.start.character + char_delta } + ) + end + end, + --- Returns the first node of given type if present ---@param type string #The type of node to search for ---@param buf number #The buffer to search in diff --git a/lua/neorg/modules/core/manoeuvre/module.lua b/lua/neorg/modules/core/text-objects/module.lua similarity index 53% rename from lua/neorg/modules/core/manoeuvre/module.lua rename to lua/neorg/modules/core/text-objects/module.lua index 9799056ff..a10eb476a 100644 --- a/lua/neorg/modules/core/manoeuvre/module.lua +++ b/lua/neorg/modules/core/text-objects/module.lua @@ -1,43 +1,88 @@ --[[ - file: Norg-Manoeuvre - title: Move around elements easily - summary: A Neorg module for moving around different elements up and down. + file: Norg-Text-Objects + title: Navigation, Selection, and Swapping + summary: A Neorg module for moving and selecting elements of the document. --- -### WARNING: This module is deprecated! -There is no available successor to this module yet. ---]] +**WARNING:** Requires nvim 0.10+ + +- Easily move items up and down in the document +- Provides text objects for headings, tags, and lists + +## Usage + +Users can create keybinds for some or all of the different events this module exposes. Those are: + +those events are: + +- `core.text-objects.item_up` - Moves the current "item" up +- `core.text-objects.item_down` - same but down +- `core.text-objects.textobject.heading.outer` +- `core.text-objects.textobject.heading.inner` +- `core.text-objects.textobject.tag.inner` +- `core.text-objects.textobject.tag.outer` +- `core.text-objects.textobject.list.outer` - around the entire list + +_Movable "items" include headings, and list items (ordered/unordered/todo)_ + +### Example + +Example keybinds that would go in your Neorg configuration: + +```lua +["core.keybinds"] = { + config = { + hook = function(keybinds) + -- Binds to move items up or down + keybinds.remap_event("norg", "n", "", "core.text-objects.item_up") + keybinds.remap_event("norg", "n", "", "core.text-objects.item_down") + + -- text objects, these binds are available as `vaH` to "visual select around a header" or + -- `diH` to "delete inside a header" + keybinds.remap_event("norg", { "o", "x" }, "iH", "core.text-objects.textobject.heading.inner") + keybinds.remap_event("norg", { "o", "x" }, "aH", "core.text-objects.textobject.heading.outer") + end, + }, +}, +``` --- NOTE(vhyrro): This module is obsolete! There is no successor module to this yet, although --- we hope to implement one with the module rewrite of 0.2. +--]] local neorg = require("neorg.core") local utils, log, modules = neorg.utils, neorg.log, neorg.modules +local ts -local module = modules.create("core.manoeuvre") +local module = modules.create("core.text-objects") module.setup = function() - if not utils.is_minimum_version(0, 7, 0) then - log.error("This module requires at least Neovim 0.7 to run!") + if not utils.is_minimum_version(0, 10, 0) then + log.error("This module requires at least Neovim 0.10 to run!") return { success = false, } end - return { success = true, requires = { "core.keybinds", "core.integrations.treesitter" } } + return { + success = true, + requires = { "core.keybinds", "core.integrations.treesitter" }, + } end +-- TODO: what's a better name for this? +local tags = { + "item_up", + "item_down", + "textobject.heading.outer", + "textobject.heading.inner", + "textobject.tag.inner", + "textobject.tag.outer", + "textobject.list.outer", +} + module.load = function() - module.required["core.keybinds"].register_keybinds(module.name, { - "item_up", - "item_down", - "textobject.around-heading", - "textobject.inner-heading", - "textobject.around-tag", - "textobject.inner-tag", - "textobject.around-whole-list", - }) + module.required["core.keybinds"].register_keybinds(module.name, tags) + ts = module.required["core.integrations.treesitter"] end module.config.public = { @@ -63,12 +108,12 @@ module.config.public = { }, } ----@class core.manoeuvre +---@class core.text-objects module.public = { get_element_from_cursor = function(node_pattern) - local node_at_cursor = module.required["core.integrations.treesitter"].get_ts_utils().get_node_at_cursor() + local node_at_cursor = vim.treesitter.get_node() - if not node_at_cursor:parent():type():match(node_pattern) then + if not node_at_cursor or not node_at_cursor:parent():type():match(node_pattern) then log.trace(string.format("Could not find element of pattern '%s' under the cursor", node_pattern)) return end @@ -76,7 +121,7 @@ module.public = { return node_at_cursor:parent() end, - move_item_down = function(pattern, expected_sibling_name) + move_item_down = function(pattern, expected_sibling_name, buffer) local element = module.public.get_element_from_cursor(pattern) if not element then @@ -87,24 +132,19 @@ module.public = { if type(expected_sibling_name) == "string" then if next_element and next_element:type():match(expected_sibling_name) then - -- TODO: This is a bit buggy and doesn't always set the cursor position to where you'd expect - module.required["core.integrations.treesitter"] - .get_ts_utils() - .swap_nodes(element, next_element, 0, true) + ts.swap_nodes(element, next_element, buffer, true) end else for _, expected in ipairs(expected_sibling_name) do if next_element and next_element:type():match(expected) then - module.required["core.integrations.treesitter"] - .get_ts_utils() - .swap_nodes(element, next_element, 0, true) + ts.swap_nodes(element, next_element, buffer, true) return end end end end, - move_item_up = function(pattern, expected_sibling_name) + move_item_up = function(pattern, expected_sibling_name, buffer) local element = module.public.get_element_from_cursor(pattern) if not element then @@ -115,16 +155,12 @@ module.public = { if type(expected_sibling_name) == "string" then if prev_element and prev_element:type():match(expected_sibling_name) then - module.required["core.integrations.treesitter"] - .get_ts_utils() - .swap_nodes(element, prev_element, 0, true) + ts.swap_nodes(element, prev_element, buffer, true) end else for _, expected in ipairs(expected_sibling_name) do if prev_element and prev_element:type():match(expected) then - module.required["core.integrations.treesitter"] - .get_ts_utils() - .swap_nodes(element, prev_element, 0, true) + ts.swap_nodes(element, prev_element, buffer, true) return end end @@ -170,35 +206,37 @@ end module.config.private = { textobjects = { - ["around-heading"] = function(node) + ["heading.outer"] = function(node) return highlight_node(find(node, "^heading%d+$")) end, - ["inner-heading"] = function(node) + ["heading.inner"] = function(node) return highlight_node(find_content(node, "^heading%d+$")) end, - ["around-tag"] = function(node) + ["tag.outer"] = function(node) return highlight_node(find(node, "ranged_tag$")) end, - ["inner-tag"] = function(node) + ["tag.inner"] = function(node) -- TODO: Fix Treesitter, this is currently buggy return highlight_node(find_content(node, "ranged_tag$")) end, - ["around-whole-list"] = function(node) + ["list.outer"] = function(node) return highlight_node(find(node, "generic_list")) end, }, } +---Handle events +---@param event neorg.event module.on_event = function(event) local config = module.config.public.moveables - if event.split_type[2] == "core.manoeuvre.item_down" then + if event.split_type[2] == "core.text-objects.item_down" then for _, data in pairs(config) do - module.public.move_item_down(data[1], data[2]) + module.public.move_item_down(data[1], data[2], event.buffer) end - elseif event.split_type[2] == "core.manoeuvre.item_up" then + elseif event.split_type[2] == "core.text-objects.item_up" then for _, data in pairs(config) do - module.public.move_item_up(data[1], data[2]) + module.public.move_item_up(data[1], data[2], event.buffer) end else local textobj = event.split_type[2]:find("textobject") @@ -208,28 +246,15 @@ module.on_event = function(event) local textobj_lookup = module.config.private.textobjects[textobject_type] if textobj_lookup then - return textobj_lookup( - module.required["core.integrations.treesitter"].get_ts_utils().get_node_at_cursor() - ) + return textobj_lookup(vim.treesitter.get_node()) end end end end -module.events.subscribed = { - ["core.keybinds"] = { - [module.name .. ".item_down"] = true, - [module.name .. ".item_up"] = true, - - -- TODO(vhyrro): Automate the creation of these - [module.name .. ".textobject.around-heading"] = true, - [module.name .. ".textobject.inner-heading"] = true, - - [module.name .. ".textobject.around-tag"] = true, - [module.name .. ".textobject.inner-tag"] = true, - - [module.name .. ".textobject.around-whole-list"] = true, - }, -} +module.events.subscribed = { ["core.keybinds"] = {} } +for _, name in ipairs(tags) do + module.events.subscribed["core.keybinds"][("%s.%s"):format(module.name, name)] = true +end return module