diff --git a/README.md b/README.md index e243bf2..f034817 100644 --- a/README.md +++ b/README.md @@ -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) +
+Typing out the Move commands manually +A demo of moving around some code slowly typing out each Treewalker move command +
+ +
+Typing out the Swap commands manually +A demo of swapping code slowly using Treewalker swap commands +
--- @@ -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', '', ':Treewalker Down', { noremap = true }) -vim.api.nvim_set_keymap('n', '', ':Treewalker Up', { noremap = true }) -vim.api.nvim_set_keymap('n', '', ':Treewalker Left', { noremap = true }) -vim.api.nvim_set_keymap('n', '', ':Treewalker Right', { noremap = true }) +vim.keymap.set({ 'n', 'v' }, '', 'Treewalker Up', { noremap = true, silent = true }) +vim.keymap.set({ 'n', 'v' }, '', 'Treewalker Down', { noremap = true, silent = true }) +vim.keymap.set({ 'n', 'v' }, '', 'Treewalker Right', { noremap = true, silent = true }) +vim.keymap.set({ 'n', 'v' }, '', 'Treewalker Left', { noremap = true, silent = true }) +vim.keymap.set('n', '', 'Treewalker SwapDown', { noremap = true, silent = true }) +vim.keymap.set('n', '', 'Treewalker SwapUp', { 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 - `` 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', "", ":TSTextobjectSwapNext @parameter.inner", { noremap = true, silent = true }) +vim.keymap.set('n', "", ":TSTextobjectSwapPrevious @parameter.inner", { 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! diff --git a/TODO.md b/TODO.md index 6a6d5ce..dcf7d59 100644 --- a/TODO.md +++ b/TODO.md @@ -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) diff --git a/lua/treewalker/init.lua b/lua/treewalker/init.lua index 0547dcc..0b21859 100644 --- a/lua/treewalker/init.lua +++ b/lua/treewalker/init.lua @@ -20,6 +20,8 @@ function Treewalker.setup(opts) end end +-- TODO This is clever kinda, but it breaks autocomplete of `require('treewalker')` + -- Assign move_{in,out,up,down} for fn_name, fn in pairs(movement) do Treewalker[fn_name] = fn diff --git a/lua/treewalker/nodes.lua b/lua/treewalker/nodes.lua index fe9a2b6..05136be 100644 --- a/lua/treewalker/nodes.lua +++ b/lua/treewalker/nodes.lua @@ -1,8 +1,8 @@ -local lines = require "treewalker.lines" -local util = require "treewalker.util" +local lines = require "treewalker.lines" +local util = require "treewalker.util" -- These are regexes but just happen to be real simple so far -local TARGET_BLACKLIST_TYPE_MATCHERS = { +local TARGET_BLACKLIST_TYPE_MATCHERS = { "comment", "attribute_item", -- decorators (rust) "decorat", -- decorators (py) @@ -15,13 +15,13 @@ local HIGHLIGHT_BLACKLIST_TYPE_MATCHERS = { "block", } -local AUGMENT_TARGET_TYPE_MATCHERS = { +local AUGMENT_TARGET_TYPE_MATCHERS = { "comment", "attribute_item", -- decorators (rust) "decorat", -- decorators (py) } -local M = {} +local M = {} ---@param node TSNode ---@param matchers string[] @@ -232,7 +232,7 @@ function M.log(node) local row = M.range(node)[1] + 1 local line = lines.get_line(row) local col = lines.get_start_col(line) - local log_string = "dest:" + local log_string = "" log_string = log_string .. string.format(" [%s/%s]", row, col) log_string = log_string .. string.format(" (%s)", node:type()) log_string = log_string .. string.format(" |%s|", line) diff --git a/lua/treewalker/ops.lua b/lua/treewalker/ops.lua index 1216bb3..ced5830 100644 --- a/lua/treewalker/ops.lua +++ b/lua/treewalker/ops.lua @@ -1,19 +1,18 @@ -local util = require('treewalker.util') local nodes = require('treewalker.nodes') local lines = require('treewalker.lines') -local augment = require('treewalker.augment') local M = {} +-- For a potentially more nvim-y way to do it, see how treesitter-utils does it: +-- https://github.com/nvim-treesitter/nvim-treesitter/blob/981ca7e353da6ea69eaafe4348fda5e800f9e1d8/lua/nvim-treesitter/ts_utils.lua#L388 +-- (ts_utils.swap_nodes) + ---Flash a highlight over the given range ---@param range Range4 ---@param duration integer function M.highlight(range, duration) local start_row, start_col, end_row, end_col = range[1], range[2], range[3], range[4] local ns_id = vim.api.nvim_create_namespace("") - -- local hl_group = "DiffAdd" - -- local hl_group = "MatchParen" - -- local hl_group = "Search" local hl_group = "ColorColumn" for row = start_row, end_row do @@ -41,7 +40,6 @@ end ---@param row integer ---@param node TSNode function M.jump(row, node) - nodes.log(node) vim.cmd("normal! m'") -- Add originating node to jump list vim.api.nvim_win_set_cursor(0, { row, 0 }) vim.cmd("normal! ^") -- Jump to start of line @@ -49,14 +47,6 @@ function M.jump(row, node) local range = nodes.range(node) local duration = require("treewalker").opts.highlight_duration M.highlight(range, duration) - - -- -- TODO Make this not look like butt, if it's even wanted - -- local augments = augment.get_node_augments(node) - -- for _, aug in ipairs(augments) do - -- range = nodes.range(aug) - -- M.highlight(range, duration) - -- end - end end @@ -83,45 +73,3 @@ end return M ----- Leaving this here for now because my gut says this is a better way to do it, ----- and at some point it may want to get done. ----- https://github.com/nvim-treesitter/nvim-treesitter/blob/981ca7e353da6ea69eaafe4348fda5e800f9e1d8/lua/nvim-treesitter/ts_utils.lua#L388 ----- (ts_utils.swap_nodes) ------@param rows1 [integer, integer] -- [start row, end row] ------@param rows2 [integer, integer] -- [start row, end row] ---function M.swap(rows1, rows2) --- local s1, e1, s2, e2 = rows1[1], rows1[2], rows2[1], rows2[2] --- local text1 = lines.get_lines(s1 + 1, e1 + 1) --- local text2 = lines.get_lines(s2 + 1, e2 + 1) - --- util.log("text1: " .. s1 .. "/" .. e1) --- util.log("text2: " .. s2 .. "/" .. e2) - --- ---@type lsp.Range --- local range1 = { --- start = { line = s1, character = 0 }, --- ["end"] = { line = e1, character = 0 } -- end is reserved --- } - --- ---@type lsp.Range --- local range2 = { --- start = { line = s2, character = 0 }, --- ["end"] = { line = e2 + 1, character = 0 } --- } - --- -- util.log(range1, range2) - --- -- https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textEdit --- lines.set_lines(s1 + 1, text2) --- ---@type lsp.TextEdit --- local edit1 = { range = range1, newText = table.concat(text2, "\n") } - --- lines.set_lines(s2 + 1, text1) --- ---@type lsp.TextEdit --- local edit2 = { range = range2, newText = table.concat(text1, "\n") } - --- local bufnr = vim.api.nvim_get_current_buf() --- -- vim.lsp.util.apply_text_edits({ edit1, edit2 }, bufnr, "utf-8") ---end - - diff --git a/lua/treewalker/swap.lua b/lua/treewalker/swap.lua index b9c0dc0..843ad13 100644 --- a/lua/treewalker/swap.lua +++ b/lua/treewalker/swap.lua @@ -2,12 +2,35 @@ local nodes = require "treewalker.nodes" local ops = require "treewalker.ops" local targets = require "treewalker.targets" local augment = require "treewalker.augment" -local util = require "treewalker.util" local M = {} ----@return nil +---@return boolean +local function is_on_target_node() + local node = vim.treesitter.get_node() + if not node then return false end + if not nodes.is_jump_target(node) then return false end + if vim.fn.line('.') - 1 ~= node:range() then return false end + return true +end + +---@return boolean +local function is_supported_ft() + local unsupported_filetypes = { + ["text"] = true, + ["markdown"] = true, + } + + local bufnr = vim.api.nvim_get_current_buf() + local ft = vim.bo[bufnr].filetype + + return not unsupported_filetypes[ft] +end + function M.swap_down() + if not is_on_target_node() then return end + if not is_supported_ft() then return end + local target, row, line = targets.down() if not target or not row or not line then @@ -15,25 +38,30 @@ function M.swap_down() return end - -- TODO remove this comment it's just for testing fr fr local current = nodes.get_current() local current_range = nodes.range(current) - local all = augment.get_node_augments(current) - table.insert(all, current) - local current_rows = nodes.row_range(all) + local current_augments = augment.get_node_augments(current) + local current_all = { current, unpack(current_augments) } + local current_all_rows = nodes.row_range(current_all) local target_range = nodes.range(target) - local target_rows = { target_range[1], target_range[3] } + local target_augments = augment.get_node_augments(target) + local target_all = { target, unpack(target_augments) } + local target_all_rows = nodes.row_range(target_all) - ops.swap(current_rows, target_rows) + ops.swap(current_all_rows, target_all_rows) -- Place cursor local node_length_diff = ((current_range[3] - current_range[1]) + 1) - ((target_range[3] - target_range[1]) + 1) - vim.fn.cursor(target_range[1] - node_length_diff + 1, target_range[2] + 1) + local x = target_range[1] - node_length_diff + 1 + local y = target_range[2] + 1 + vim.fn.cursor(x, y) end ----@return nil function M.swap_up() + if not is_on_target_node() then return end + if not is_supported_ft() then return end + local target, row, line = targets.up() if not target or not row or not line then @@ -42,16 +70,25 @@ function M.swap_up() end local current = nodes.get_current() - local current_range = nodes.range(current) - local current_rows = { current_range[1], current_range[3] } + local current_augments = augment.get_node_augments(current) + local current_all = { current, unpack(current_augments) } + local current_all_rows = nodes.row_range(current_all) local target_range = nodes.range(target) - local target_rows = { target_range[1], target_range[3] } + local target_augments = augment.get_node_augments(target) + local target_all = { target, unpack(target_augments) } + local target_all_rows = nodes.row_range(target_all) - ops.swap(target_rows, current_rows) + ops.swap(target_all_rows, current_all_rows) -- Place cursor - vim.fn.cursor(target_range[1] + 1, target_range[2] + 1) + local target_augment_rows = nodes.row_range(target_augments) + local target_augment_length = #target_augments > 0 and (target_augment_rows[2] + 1 - target_augment_rows[1]) or 0 + local current_augment_rows = nodes.row_range(current_augments) + local current_augment_length = #current_augments > 0 and (current_augment_rows[2] + 1 - current_augment_rows[1]) or 0 + local x = target_range[1] + 1 + current_augment_length - target_augment_length + local y = target_range[2] + 1 + vim.fn.cursor(x, y) end return M diff --git a/plugin/init.lua b/plugin/init.lua index 9df8bdd..078ad1f 100644 --- a/plugin/init.lua +++ b/plugin/init.lua @@ -27,14 +27,6 @@ local subcommands = { SwapDown = function() tw().swap_down() - end, - - SwapRight = function() - tw().swap_in() - end, - - SwapLeft = function() - tw().swap_out() end } diff --git a/static/fast_demo.gif b/static/fast_demo.gif index 1100d30..359907b 100644 Binary files a/static/fast_demo.gif and b/static/fast_demo.gif differ diff --git a/static/slow_demo.gif b/static/slow_move_demo.gif similarity index 100% rename from static/slow_demo.gif rename to static/slow_move_demo.gif diff --git a/static/slow_swap_demo.gif b/static/slow_swap_demo.gif new file mode 100644 index 0000000..f09aeeb Binary files /dev/null and b/static/slow_swap_demo.gif differ diff --git a/tests/fixtures/javascript.js b/tests/fixtures/javascript.js index 46fa215..22f04c5 100644 --- a/tests/fixtures/javascript.js +++ b/tests/fixtures/javascript.js @@ -1,51 +1,53 @@ +// What is this hot garbo function myCallback1(err, data) { if (err) return console.error(err); console.log('myCallback1:', data); } function myCallback2(data) { - console.log('myCallback2:', data * 2); + console.log('myCallback2:', data * 2); // Magic number } +// Real descriptive name +// I definitely know what this function is doing... /s function myCallback3(data) { - console.log('myCallback3:', data - 10); + console.log('myCallback3:', data - 10); // Another magic number } -// Some people comment their ai generated slop - function fetchData(callback) { setTimeout(() => { const data = { foo: 'bar' }; callback(null, data); - }, 1000); + }, 1000); // Ever heard of constants } +// Who wrote this terrible code function processData(data, callback) { - myCallback1(null, data); // callback within a callback! + myCallback1(null, data); // who can even follow this many callbacks const processedData = { baz: data.foo + 'qux' }; setTimeout(() => { - myCallback2(processedData); // another callback + myCallback2(processedData); // good thing this isn't typed callback(null, processedData); }, 500); } +// Process what data jeez I think this code isn't even real function processMoreData(data, callback) { const moreProcessedData = { quux: data.baz - 1 }; - myCallback3(moreProcessedData); // yet another callback + myCallback3(moreProcessedData); setTimeout(() => { callback(null, moreProcessedData); }, 250); } -// Some people prefer to live dangerously - +// If I saw this in prod I'd have a conniption fetchData((err, data) => { if (err) return console.error(err); processData(data, (err, processedData) => { if (err) return console.error(err); processMoreData(processedData, (err, moreProcessedData) => { if (err) return console.error(err); - myCallback1(null, moreProcessedData); // final countdown + myCallback1(null, moreProcessedData); }); }); }); diff --git a/tests/fixtures/markdown.md b/tests/fixtures/markdown.md new file mode 100644 index 0000000..da86cce --- /dev/null +++ b/tests/fixtures/markdown.md @@ -0,0 +1,126 @@ +# Example Markdown File +======================= + +## Header +----------- + +This is an example header. + +### Subheader +------------- + +This is another header, but not as important. + +#### Tertiary Header +------------------- + +Finally, this is the last one. + +## Text Formatting +------------------ + +* This is a list item. +* Another item in the list. +* And yet another item. + +You can also use underscores for emphasis: *italic*, _italic_, **bold**, __bold__. + +And code blocks: + +```python +print("Hello World") +``` + +Or inline code: + +`var foo = "bar";` + +## Headers Again +----------------- + +### Another Header + +#### Yet Another One + +##### Too Many! + +## Table +-------- + +| Column 1 | Column 2 | +|:---------|:---------| +| Cell 11 | Cell 12 | +| Cell 21 | Cell 22 | + +## Details +------------ + +
+ Click me! + This is some hidden content. +
+ +## Links +-------- + +[Google](https://www.google.com) and [GitHub Pages](https://pages.github.com). + +## Footnotes +-------------- + +This text has a footnote[^1]. + +[^1]: The footnote. + +You can also use multiple footnotes[^2]: + +[^2]: Another one! + +## References +---------------- + +Some references are: + +* "The Hitchhiker's Guide to the Galaxy" by Douglas Adams. +* "1984" by George Orwell. + +And some online resources: + +* +* + +## Images +------------ + +![A cute cat](https://example.com/cat.jpg) + +And a table with images: + +| Image | Description | +|:------|:-------------| +| ![Image 1](https://example.com/image1.jpg) | This is image 1. | +| ![Image 2](https://example.com/image2.jpg) | And this is image 2! | + +## Task List +-------------- + +* [x] Done something. +* [ ] Still need to do something. + +## Code Blocks Again +------------------- + +```bash +echo "Hello World" +``` + +And another code block with a different language: + +```java +public class HelloWorld { + public static void main(String[] args) { + System.out.println("Hello, World!"); + } +} +``` + diff --git a/tests/fixtures/rust.rs b/tests/fixtures/rust.rs index 19f5afc..206669f 100644 --- a/tests/fixtures/rust.rs +++ b/tests/fixtures/rust.rs @@ -3,18 +3,18 @@ use serde::{Deserialize, Serialize}; use strum::IntoEnumIterator; use strum_macros::{Display, EnumIter}; -// Define a trait that describes how to calculate the area of a shape +// Define a trait that does nothing blazingly fast #[derive(Clone, Copy)] trait Shape { fn area(&self) -> f64; } -// Implement the Shape trait for a Circle #[derive(Debug, Clone)] struct Circle { radius: f64, } +// Implement the Blazing so that blazing blazes #[derive(Debug, Copy)] impl Shape for Circle { fn area(&self) -> f64 { @@ -22,13 +22,14 @@ impl Shape for Circle { } } -// Implement the Shape trait for a Rectangle +// Blaze the blazing blaze so that blaze and also it's blazingly fast #[derive(Debug, Clone, Copy)] struct Rectangle { width: f64, height: f64, } +// BLAAAAAAAAAAAAAAAAZEEEEEEEE #[derive(Debug, Clone, Copy)] impl Shape for Rectangle { fn area(&self) -> f64 { diff --git a/tests/treewalker/helpers.lua b/tests/treewalker/helpers.lua index 27e5096..5c4c2f8 100644 --- a/tests/treewalker/helpers.lua +++ b/tests/treewalker/helpers.lua @@ -1,16 +1,18 @@ +local assert = require('luassert') + local M = {} -- Assert the cursor is in the expected position ----@param line integer ----@param column integer ----@param msg string? -function M.assert_cursor_at(line, column, msg) +---@param row integer +---@param col integer +---@param line string? +function M.assert_cursor_at(row, col, line) local cursor_pos = vim.fn.getpos('.') ---@type integer, integer local current_line, current_column current_line, current_column = cursor_pos[2], cursor_pos[3] - msg = string.format("expected to be at [%s] but wasn't", msg) - assert.are.same({ line, column }, { current_line, current_column }, msg) + line = string.format("expected to be at [%s/%s](%s) but wasn't", row, col, line) + assert.same({ row, col }, { current_line, current_column }, line) end return M diff --git a/tests/treewalker/swap_spec.lua b/tests/treewalker/swap_spec.lua index f5eb65e..df7e89a 100644 --- a/tests/treewalker/swap_spec.lua +++ b/tests/treewalker/swap_spec.lua @@ -4,22 +4,66 @@ local tw = require 'treewalker' local lines = require 'treewalker.lines' local helpers = require 'tests.treewalker.helpers' -describe("Swapping in a regular lua file: ", function() - before_each(function () +describe("Swapping in a regular lua file:", function() + before_each(function() load_fixture("/lua.lua") end) + it("swap down bails early if user is on empty top level line", function() + local lines_before = lines.get_lines(0, -1) + vim.fn.cursor(2, 1) -- empty line + tw.swap_down() + local lines_after = lines.get_lines(0, -1) + helpers.assert_cursor_at(2, 1) -- unchanged + assert.same(lines_after, lines_before) + end) + + it("swap up bails early if user is on empty top level line", function() + local lines_before = lines.get_lines(0, -1) + vim.fn.cursor(2, 1) -- empty line + tw.swap_up() + local lines_after = lines.get_lines(0, -1) + helpers.assert_cursor_at(2, 1) -- unchanged + assert.same(lines_after, lines_before) + end) + + it("swap down bails early if user is on empty line in function", function() + local lines_before = lines.get_lines(0, -1) + vim.fn.cursor(51, 1) + tw.swap_down() + local lines_after = lines.get_lines(0, -1) + helpers.assert_cursor_at(51, 1) -- unchanged + assert.same(lines_after, lines_before) + end) + + it("swap up bails early if user is on empty line in function", function() + local lines_before = lines.get_lines(0, -1) + vim.fn.cursor(51, 1) -- empty line + tw.swap_up() + local lines_after = lines.get_lines(0, -1) + helpers.assert_cursor_at(51, 1) -- unchanged + assert.same(lines_after, lines_before) + end) + it("swaps down one liners without comments", function() vim.fn.cursor(1, 1) tw.swap_down() - assert.same({ "local M = {}", "", "local util = require('treewalker.util')" }, lines.get_lines(1, 3)) + assert.same({ + "local M = {}", + "", + "local util = require('treewalker.util')" + }, lines.get_lines(1, 3)) helpers.assert_cursor_at(3, 1) end) it("swaps up one liners without comments", function() vim.fn.cursor(3, 1) tw.swap_up() - assert.same({ "local M = {}", "", "local util = require('treewalker.util')" }, lines.get_lines(1, 3)) + assert.same({ + "local M = {}", + "", + "local util = require('treewalker.util')", + }, lines.get_lines(1, 3)) helpers.assert_cursor_at(1, 1) end) @@ -34,19 +78,22 @@ describe("Swapping in a regular lua file: ", function() it("swaps up when one has comments", function() vim.fn.cursor(21, 1) tw.swap_up() - assert.same({ "local function is_descendant_jump_target(node)" }, lines.get_lines(12, 12)) - assert.same({ "---@param node TSNode" }, lines.get_lines(10, 10)) + assert.same({ + "---@param node TSNode", + "---@return boolean", + "local function is_jump_target(node)", + }, lines.get_lines(10, 12)) helpers.assert_cursor_at(12, 1) end) - it("swaps down one liners when both have comments", function() + it("swaps down when both have comments", function() vim.fn.cursor(38, 1) tw.swap_down() assert.same({ - "---Strictly sibling, no fancy business", - "---@param node TSNode", - "---@return TSNode | nil", - "local function get_prev_sibling(node)" + "---Strictly sibling, no fancy business", + "---@param node TSNode", + "---@return TSNode | nil", + "local function get_prev_sibling(node)" }, lines.get_lines(34, 37)) assert.same({ "---Do the nodes have the same starting point", @@ -58,14 +105,14 @@ describe("Swapping in a regular lua file: ", function() helpers.assert_cursor_at(53, 1) end) - it("swaps up one liners when both have comments", function() + it("swaps up when both have comments", function() vim.fn.cursor(49, 1) tw.swap_up() assert.same({ - "---Strictly sibling, no fancy business", - "---@param node TSNode", - "---@return TSNode | nil", - "local function get_prev_sibling(node)" + "---Strictly sibling, no fancy business", + "---@param node TSNode", + "---@return TSNode | nil", + "local function get_prev_sibling(node)" }, lines.get_lines(34, 37)) assert.same({ "---Do the nodes have the same starting point", @@ -77,3 +124,19 @@ describe("Swapping in a regular lua file: ", function() helpers.assert_cursor_at(37, 1) end) end) + +describe("Swapping in a markdown file:", function() + before_each(function() + -- TODO This is a hack, really should be able to load a md file. Fix this. + load_fixture("/lua.lua") + vim.bo[0].filetype = "markdown" + end) + + it("turns off in md files (doesn't work at all there, doesn't need to)", function() + local lines_before = lines.get_lines(0, -1) + tw.swap_up() + tw.swap_down() + local lines_after = lines.get_lines(0, -1) + assert.same(lines_after, lines_before) + end) +end)