Skip to content

Commit

Permalink
feat: text-objects and working swap (#1421)
Browse files Browse the repository at this point in the history
  • Loading branch information
benlubas authored May 17, 2024
1 parent cb4f25b commit 49a3c64
Show file tree
Hide file tree
Showing 3 changed files with 189 additions and 67 deletions.
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
97 changes: 97 additions & 0 deletions lua/neorg/modules/core/integrations/treesitter/module.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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", "<up>", "core.text-objects.item_up")
keybinds.remap_event("norg", "n", "<down>", "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 = {
Expand All @@ -63,20 +108,20 @@ 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

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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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

0 comments on commit 49a3c64

Please sign in to comment.