Skip to content

Commit

Permalink
Implement node swapping
Browse files Browse the repository at this point in the history
Treewalker's node swapping is unique in that it carries comments and
annotations / decorators around with it. Hopefully this makes for very
convenient swapping.

Does not work in md files.
  • Loading branch information
aaronik committed Dec 23, 2024
1 parent 7b1dfd1 commit f72fbc4
Show file tree
Hide file tree
Showing 27 changed files with 1,005 additions and 310 deletions.
44 changes: 36 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,27 @@

Treewalker is a plugin that gives you the ability to **move around your code in a syntax tree aware manner**.
It uses [Treesitter](https://github.com/tree-sitter/tree-sitter) under the hood for syntax tree awareness.
It offers four subcommands: Up, Down, Right, and Left. Each command moves through the syntax tree
in an intuitive way.
It offers six subcommands: Up, Down, Right, and Left for movement, and SwapUp and SwapDown for intelligent node swapping.

Each movement command moves you through the syntax tree in an intuitive way.

* **Up/Down** - Moves up or down to the next neighbor node
* **Right** - Finds the next good child node
* **Left** - Finds the next good parent node

The swap commands intelligently swap nodes, including comments and attributes/decorators.

---

Moving slowly, showing each command
![A demo of moving around some code slowly typing out each command](static/slow_demo.gif)
<details>
<summary>Typing out the Move commands manually</summary>
<img src="static/slow_move_demo.gif" alt="A demo of moving around some code slowly typing out each Treewalker move command">
</details>

<details>
<summary>Typing out the Swap commands manually</summary>
<img src="static/slow_swap_demo.gif" alt="A demo of swapping code slowly using Treewalker swap commands">
</details>

---

Expand All @@ -42,8 +52,26 @@ Moving slowly, showing each command
This is how I have mine mapped; in `init.lua`:

```lua
vim.api.nvim_set_keymap('n', '<C-j>', ':Treewalker Down<CR>', { noremap = true })
vim.api.nvim_set_keymap('n', '<C-k>', ':Treewalker Up<CR>', { noremap = true })
vim.api.nvim_set_keymap('n', '<C-h>', ':Treewalker Left<CR>', { noremap = true })
vim.api.nvim_set_keymap('n', '<C-l>', ':Treewalker Right<CR>', { noremap = true })
vim.keymap.set({ 'n', 'v' }, '<C-k>', '<cmd>Treewalker Up<cr>', { noremap = true, silent = true })
vim.keymap.set({ 'n', 'v' }, '<C-j>', '<cmd>Treewalker Down<cr>', { noremap = true, silent = true })
vim.keymap.set({ 'n', 'v' }, '<C-l>', '<cmd>Treewalker Right<cr>', { noremap = true, silent = true })
vim.keymap.set({ 'n', 'v' }, '<C-h>', '<cmd>Treewalker Left<cr>', { noremap = true, silent = true })
vim.keymap.set('n', '<C-S-j>', '<cmd>Treewalker SwapDown<cr>', { noremap = true, silent = true })
vim.keymap.set('n', '<C-S-k>', '<cmd>Treewalker SwapUp<cr>', { noremap = true, silent = true })
```

I also utilize some
[nvim-treesitter-textobjects](https://github.com/nvim-treesitter/nvim-treesitter-textobjects?tab=readme-ov-file#text-objects-swap)
commands to round out the swap commands - `<C-S-{j,k,l,h}>` just feel really good to me, so might as well get the
lateral swapping as well. (This is not something that `Treewalker` needs to do as it already exists from other libraries)

```lua
vim.keymap.set('n', "<C-S-l>", ":TSTextobjectSwapNext @parameter.inner<CR>", { noremap = true, silent = true })
vim.keymap.set('n', "<C-S-h>", ":TSTextobjectSwapPrevious @parameter.inner<CR>", { noremap = true, silent = true })
```

The above can also be accomplished with
[nvim-treesitter](https://github.com/nvim-treesitter/nvim-treesitter) using
[ts_utils](https://github.com/nvim-treesitter/nvim-treesitter?tab=readme-ov-file#utilities).
See [this PR](https://github.com/aaronik/treewalker.nvim/pull/10/files) for
an example of that!
3 changes: 0 additions & 3 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
# TODO

* Check for errant util.log or util.R usages in CI/local
* Swapping
* Jumplist
* Get more languages into movement/highlight/swap specs
* :help treesitter-parsers
* Python decorators (tough b/c of the identifier node underneath)
29 changes: 29 additions & 0 deletions lua/treewalker/augment.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
local nodes = require "treewalker.nodes"

local M = {}

-- Gets the "augment" nodes that exist above a given node
-- These are nodes that are, like, kind of attached to the provided node.
-- Think comments, decorators, annotations, etc. Stuff that wants to stay
-- with the given node. This was originally implemented to aid with swapping,
-- because if you swap a node that has a comment description, comment types,
-- annotations, etc, those should move along with the node.
---@param node TSNode
---@return TSNode[]
function M.get_node_augments(node)
local augments = {}
local row = nodes.range(node)[1] + 1
while true do
local candidate = nodes.get_from_neighboring_line(row, "up")
if candidate and nodes.is_augment_target(candidate) then
table.insert(augments, candidate)
row = row - 1
else
break
end
end

return augments
end

return M
88 changes: 9 additions & 79 deletions lua/treewalker/init.lua
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
local nodes = require('treewalker.nodes')
local util = require('treewalker.util')
local ops = require('treewalker.ops')
local lines = require('treewalker.lines')
local strategies = require('treewalker.strategies')
local movement = require('treewalker.movement')
local swap = require('treewalker.swap')

local Treewalker = {}

Expand All @@ -22,84 +20,16 @@ function Treewalker.setup(opts)
end
end

---@return nil
function Treewalker.move_out()
local node = nodes.get_current()
local target = strategies.get_first_ancestor_with_diff_scol(node)
if not target then return end
local row = target:range()
row = row + 1
ops.jump(row, target)
end

---@return nil
function Treewalker.move_in()
local current_row = vim.fn.line(".")
local current_line = lines.get_line(current_row)
local current_col = lines.get_start_col(current_line)

--- Go down and in
local candidate, candidate_row, candidate_line =
strategies.get_down_and_in(current_row, current_col)
-- TODO This is clever kinda, but it breaks autocomplete of `require('treewalker')`

-- Ultimate failure
if not candidate_row or not candidate_line or not candidate then
return --util.log("no in candidate")
end

ops.jump(candidate_row, candidate)
-- Assign move_{in,out,up,down}
for fn_name, fn in pairs(movement) do
Treewalker[fn_name] = fn
end

---@return nil
function Treewalker.move_up()
local current_row = vim.fn.line(".")
local current_line = lines.get_line(current_row)
local current_col = lines.get_start_col(current_line)

-- Get next target if we're on an empty line
local candidate, candidate_row, candidate_line =
strategies.get_prev_if_on_empty_line(current_row, current_line)

if candidate_row and candidate_line and candidate then
return ops.jump(candidate_row, candidate)
end

--- Get next target at the same column
candidate, candidate_row, candidate_line =
strategies.get_neighbor_at_same_col("up", current_row, current_col)

if candidate_row and candidate_line and candidate then
return ops.jump(candidate_row, candidate)
end

-- Ultimate failure
-- return util.log("no up candidate")
end

---@return nil
function Treewalker.move_down()
local current_row = vim.fn.line(".")
local current_line = lines.get_line(current_row)
local current_col = lines.get_start_col(current_line)

-- Get next target if we're on an empty line
local candidate, candidate_row, candidate_line =
strategies.get_next_if_on_empty_line(current_row, current_line)

if candidate_row and candidate_line and candidate then
return ops.jump(candidate_row, candidate)
end

--- Get next target, if one is found
candidate, candidate_row, candidate_line =
strategies.get_neighbor_at_same_col("down", current_row, current_col)

if candidate_row and candidate_line and candidate then
return ops.jump(candidate_row, candidate)
end

-- Ultimate failure
-- return util.log("no down candidate")
-- Assign swap_{up,down}
for fn_name, fn in pairs(swap) do
Treewalker[fn_name] = fn
end

return Treewalker
38 changes: 38 additions & 0 deletions lua/treewalker/lines.lua
Original file line number Diff line number Diff line change
@@ -1,10 +1,48 @@
local M = {}

---@param row integer
---@param line string
function M.set_line(row, line)
vim.api.nvim_buf_set_lines(0, row - 1, row, false, { line })
end

-- Insert an arbitrary number of lines into the doc, without overwriting any
---@param start integer
---@param lines string[]
function M.insert_lines(start, lines)
for i, line in ipairs(lines) do
vim.api.nvim_buf_set_lines(0, start + i - 1, start + i - 1, false, { line })
end
end

---@param start integer
---@param lines string[]
function M.set_lines(start, lines)
local fin = start + #lines - 1
vim.api.nvim_buf_set_lines(0, start - 1, fin, false, lines)
end

---@param row integer
function M.get_line(row)
return vim.api.nvim_buf_get_lines(0, row - 1, row, false)[1]
end

---@param start integer
---@param fin integer
function M.get_lines(start, fin)
local lines = {}
for row = start, fin, 1 do
table.insert(lines, M.get_line(row))
end
return lines
end

---@param start integer
---@param fin integer
function M.delete_lines(start, fin)
return vim.api.nvim_buf_set_lines(0, start - 1, fin, false, {})
end

---@param line string
---@return integer
function M.get_start_col(line)
Expand Down
46 changes: 46 additions & 0 deletions lua/treewalker/movement.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
local ops = require "treewalker.ops"
local targets = require "treewalker.targets"

local M = {}

---@return nil
function M.move_out()
local target, row, line = targets.out()
if target and row and line then
--util.log("no out candidate")
ops.jump(row, target)
return
end
end

---@return nil
function M.move_in()
local target, row, line = targets.inn()

if target and row and line then
--util.log("no in candidate")
ops.jump(row, target)
end
end

---@return nil
function M.move_up()
local target, row, line = targets.up()

if target and row and line then
--util.log("no up candidate")
ops.jump(row, target)
end
end

---@return nil
function M.move_down()
local target, row, line = targets.down()

if target and row and line then
--util.log("no down candidate")
ops.jump(row, target)
end
end

return M
Loading

0 comments on commit f72fbc4

Please sign in to comment.