diff --git a/README.md b/README.md index 5547deb..1c0a29a 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ require("nvim-paredit").setup({ ["o"] = { paredit.api.raise_form, "Raise form" }, ["O"] = { paredit.api.raise_element, "Raise element" }, - ["E"] = { + ["E"] = { paredit.api.move_to_next_element, "Jump to next element tail", -- by default all keybindings are dot repeatable @@ -77,7 +77,7 @@ require("nvim-paredit").setup({ mode = { "n", "x", "o", "v" }, }, ["B"] = { - paredit.api.move_to_prev_element, + paredit.api.move_to_prev_element, "Jump to previous element head", repeatable = false, mode = { "n", "x", "o", "v" }, @@ -180,6 +180,73 @@ paredit.api.slurp_forwards() - **`move_to_next_element`** - **`move_to_prev_element`** +Form/element wrap api is in `paredit.wrap` module: + +- **`wrap_element_under_cursor`** - accepts prefix and suffix, returns wrapped `TSNode` +- **`wrap_enclosing_form_under_cursor`** - accepts prefix and suffix, returns wrapped `TSNode` + +Cursor api `paredit.cursor` + +- **`place_cursor`** - accepts `TSNode`, and following options: + - `placement` - enumeration `left_edge`,`inner_start`,`inner_end`,`right_edge` + - `mode` - currently only `insert` is supported, defaults to `normal` + +## API usage recipes + +### `vim-sexp` wrap form (head/tail) replication + +Require api module: +```lua +local paredit = require("nvim-paredit.api") +``` +Add following keybindings to config: +```lua +["w"] = { + function() + -- place cursor and set mode to `insert` + paredit.cursor.place_cursor( + -- wrap element under cursor with `( ` and `)` + paredit.wrap.wrap_element_under_cursor("( ", ")"), + -- cursor placement opts + { placement = "inner_start", mode = "insert" } + ) + end, + "Wrap element insert head", +}, + +["W"] = { + function() + paredit.cursor.place_cursor( + paredit.wrap.wrap_element_under_cursor("(", ")"), + { placement = "inner_end", mode = "insert" } + ) + end, + "Wrap element insert tail", +}, + +-- same as above but for enclosing form +["i"] = { + function() + paredit.cursor.place_cursor( + paredit.wrap.wrap_enclosing_form_under_cursor("( ", ")"), + { placement = "inner_start", mode = "insert" } + ) + end, + "Wrap form insert head", +}, + +["I"] = { + function() + paredit.cursor.place_cursor( + paredit.wrap.wrap_enclosing_form_under_cursor("(", ")"), + { placement = "inner_end", mode = "insert" } + ) + end, + "Wrap form insert tail", +} +``` +Same approach can be used for other `vim-sexp` keybindings (e.g. `e[`) with cursor placement or without. + ## Prior Art ### [vim-sexp](https://github.com/guns/vim-sexp) @@ -188,10 +255,10 @@ Currently the de-facto s-expression editing plugin with the most extensive set o The main reasons you might want to consider `nvim-paredit` instead are: -+ Easier configuration and an exposed lua API -+ Control over how the cursor is moved during slurp/barf. (For example if you don't want the cursor to always be moved) -+ Recursive slurp/barf operations. If your cursor is in a nested form you can still slurp from the forms parent(s) -+ Subjectively better out-of-the-box keybindings +- Easier configuration and an exposed lua API +- Control over how the cursor is moved during slurp/barf. (For example if you don't want the cursor to always be moved) +- Recursive slurp/barf operations. If your cursor is in a nested form you can still slurp from the forms parent(s) +- Subjectively better out-of-the-box keybindings ### [vim-sexp-mappings-for-regular-people](https://github.com/tpope/vim-sexp-mappings-for-regular-people) diff --git a/lua/nvim-paredit/api/cursor.lua b/lua/nvim-paredit/api/cursor.lua new file mode 100644 index 0000000..8862d5b --- /dev/null +++ b/lua/nvim-paredit/api/cursor.lua @@ -0,0 +1,30 @@ +local M = {} + +function M.insert_mode() + vim.api.nvim_feedkeys("i", "n", true) +end + +function M.place_cursor(form, opts) + if not form then + return + end + + local range = { form:range() } + local cursor_pos + if opts.placement == "left_edge" then + cursor_pos = { range[1] + 1, range[2] } + elseif opts.placement == "inner_start" then + cursor_pos = { range[1] + 1, range[2] + 1 } + elseif opts.placement == "inned_end" then + cursor_pos = { range[3] + 1, range[4] - 2 } + else + cursor_pos = { range[3] + 1, range[4] - 1 } + end + vim.api.nvim_win_set_cursor(0, cursor_pos) + + if opts.mode == "insert" then + M.insert_mode() + end +end + +return M diff --git a/lua/nvim-paredit/api/motions.lua b/lua/nvim-paredit/api/motions.lua index fe1c121..a72fab1 100644 --- a/lua/nvim-paredit/api/motions.lua +++ b/lua/nvim-paredit/api/motions.lua @@ -5,8 +5,6 @@ local langs = require("nvim-paredit.lang") local M = {} -local default_whitespace_chars = { " ", "," } - -- When the cursor is placed on whitespace within a form then the node returned by -- the treesitter `get_node_at_cursor` fn is the outer form and not a child within -- the form. @@ -24,13 +22,7 @@ local function get_next_node_from_cursor(lang, reversed) local cursor = vim.api.nvim_win_get_cursor(0) cursor = { cursor[1] - 1, cursor[2] } - local char_under_cursor = vim.api.nvim_buf_get_text(0, cursor[1], cursor[2], cursor[1], cursor[2] + 1, {}) - local char_is_whitespace = common.included_in_table( - lang.whitespace_chars or default_whitespace_chars, - char_under_cursor[1] - ) or char_under_cursor[1] == "" - - if not (lang.node_is_form(current_node) and char_is_whitespace) then + if not (lang.node_is_form(current_node) and common.is_whitespace_under_cursor(lang)) then return lang.get_node_root(current_node) end diff --git a/lua/nvim-paredit/api/wrap.lua b/lua/nvim-paredit/api/wrap.lua new file mode 100644 index 0000000..537e490 --- /dev/null +++ b/lua/nvim-paredit/api/wrap.lua @@ -0,0 +1,102 @@ +local traversal = require("nvim-paredit.utils.traversal") +local common = require("nvim-paredit.utils.common") +local ts = require("nvim-treesitter.ts_utils") +local langs = require("nvim-paredit.lang") + +local M = {} + +local function reparse(buf) + local parser = vim.treesitter.get_parser(buf, vim.bo.filetype) + parser:parse() +end + +function M.find_element_under_cursor(lang) + local node = ts.get_node_at_cursor() + return lang.get_node_root(node) +end + +function M.find_form(element, lang) + return traversal.find_nearest_form(element, { lang = lang, use_source = false }) +end + +function M.find_parend_form(element, lang) + local nearest_form = M.find_form(element, lang) + + if not nearest_form then + return element + end + + local parent = nearest_form + + if nearest_form:equal(element) then + parent = nearest_form:parent() + end + + if parent and parent:type() ~= "source" then + return M.find_form(parent, lang) + end + return nearest_form +end + +function M.wrap_element(buf, element, prefix, suffix) + prefix = prefix or "" + suffix = suffix or "" + + local range = { element:range() } + vim.api.nvim_buf_set_text(buf, range[3], range[4], range[3], range[4], { suffix }) + vim.api.nvim_buf_set_text(buf, range[1], range[2], range[1], range[2], { prefix }) +end + +function M.wrap_element_under_cursor(prefix, suffix) + local buf = vim.api.nvim_get_current_buf() + local lang = langs.get_language_api() + local current_element = M.find_element_under_cursor(lang) + + if not current_element then + return + end + if lang.node_is_comment(current_element) then + return + end + if common.is_whitespace_under_cursor(lang) then + return + end + + M.wrap_element(buf, current_element, prefix, suffix) + + reparse(buf) + + current_element = lang.get_node_root(ts.get_node_at_cursor()) + return M.find_form(current_element, lang) +end + +function M.wrap_enclosing_form_under_cursor(prefix, suffix) + local buf = vim.api.nvim_get_current_buf() + local lang = langs.get_language_api() + local current_element = M.find_element_under_cursor(lang) + + if not current_element then + return + end + + local use_direct_parent = common.is_whitespace_under_cursor(lang) or lang.node_is_comment(ts.get_node_at_cursor()) + + local form = M.find_form(current_element, lang) + if not use_direct_parent and form:type() ~= "source" then + form = M.find_parend_form(current_element, lang) + end + + M.wrap_element(buf, form, prefix, suffix) + + reparse(buf) + + current_element = M.find_element_under_cursor(lang) + if use_direct_parent then + form = current_element + else + form = M.find_parend_form(current_element, lang) + end + return M.find_parend_form(form, lang) +end + +return M diff --git a/lua/nvim-paredit/init.lua b/lua/nvim-paredit/init.lua index 3da9860..81d6d2a 100644 --- a/lua/nvim-paredit/init.lua +++ b/lua/nvim-paredit/init.lua @@ -6,6 +6,8 @@ local lang = require("nvim-paredit.lang") local M = { api = require("nvim-paredit.api"), + wrap = require("nvim-paredit.api.wrap"), + cursor = require("nvim-paredit.api.cursor"), } local function setup_keybingings(filetype, buf) diff --git a/lua/nvim-paredit/lang/init.lua b/lua/nvim-paredit/lang/init.lua index 0df882b..2b0c960 100644 --- a/lua/nvim-paredit/lang/init.lua +++ b/lua/nvim-paredit/lang/init.lua @@ -1,7 +1,11 @@ +local common = require("nvim-paredit.utils.common") + local langs = { clojure = require("nvim-paredit.lang.clojure"), } +local M = {} + local function keys(tbl) local result = {} for k, _ in pairs(tbl) do @@ -10,16 +14,16 @@ local function keys(tbl) return result end -return { - get_language_api = function() - return langs[vim.bo.filetype] - end, +function M.get_language_api() + return langs[vim.bo.filetype] +end - add_language_extension = function(filetype, api) - langs[filetype] = api - end, +function M.add_language_extension(filetype, api) + langs[filetype] = api +end - filetypes = function() - return keys(langs) - end, -} +function M.filetypes() + return keys(langs) +end + +return M diff --git a/lua/nvim-paredit/utils/common.lua b/lua/nvim-paredit/utils/common.lua index 8e49b13..ea297e3 100644 --- a/lua/nvim-paredit/utils/common.lua +++ b/lua/nvim-paredit/utils/common.lua @@ -65,5 +65,18 @@ function M.ensure_visual_mode() end end +M.default_whitespace_chars = { " " } + +function M.is_whitespace_under_cursor(lang) + local cursor = vim.api.nvim_win_get_cursor(0) + cursor = { cursor[1] - 1, cursor[2] } + + local char_under_cursor = vim.api.nvim_buf_get_text(0, cursor[1], cursor[2], cursor[1], cursor[2] + 1, {}) + return M.included_in_table( + lang.whitespace_chars or M.default_whitespace_chars, + char_under_cursor[1] + ) or char_under_cursor[1] == "" +end + return M diff --git a/tests/nvim-paredit/form_and_element_wrap_spec.lua b/tests/nvim-paredit/form_and_element_wrap_spec.lua new file mode 100644 index 0000000..95a6d05 --- /dev/null +++ b/tests/nvim-paredit/form_and_element_wrap_spec.lua @@ -0,0 +1,105 @@ +local paredit = require("nvim-paredit") +local prepare_buffer = require("tests.nvim-paredit.utils").prepare_buffer +local expect = require("tests.nvim-paredit.utils").expect + +describe("element and form wrap", function() + vim.api.nvim_buf_set_option(0, "filetype", "clojure") + + it("should not wrap if cursor is whitespace", function() + prepare_buffer({ + content = { "(+ 2 :foo/bar)" }, + cursor = { 1, 4 }, + }) + + paredit.wrap.wrap_element_under_cursor("(", ")") + expect({ + content = { "(+ 2 :foo/bar)" }, + }) + end) + + it("should wrap namespaced keyword", function() + prepare_buffer({ + content = { "(+ 2 :foo/bar)" }, + cursor = { 1, 7 }, + }) + + paredit.wrap.wrap_element_under_cursor("(", ")") + expect({ + content = { "(+ 2 (:foo/bar))" }, + }) + end) + + it("should wrap top level form", function() + prepare_buffer({ + content = { "(+ 2 :foo/bar)" }, + cursor = { 1, 0 }, + }) + + paredit.wrap.wrap_element_under_cursor("(", ")") + expect({ + content = { "((+ 2 :foo/bar))" }, + }) + end) + + it("should wrap namespaced keyword", function() + prepare_buffer({ + content = { '(+ 2 "lol")' }, + cursor = { 1, 7 }, + }) + + paredit.wrap.wrap_element_under_cursor("(", ")") + expect({ + content = { '(+ 2 ("lol"))' }, + }) + end) + + it("should wrap enclosing form", function() + prepare_buffer({ + content = { + "(+ 2", + " :foo/bar)", + }, + cursor = { 2, 4 }, + }) + + paredit.wrap.wrap_enclosing_form_under_cursor("(", ")") + expect({ + content = { + "((+ 2", + " :foo/bar))", + }, + }) + end) + + it("should fallback to current form if parent is source", function() + prepare_buffer({ + content = { "(+ 2 :foo/bar)" }, + cursor = { 1, 0 }, + }) + + paredit.wrap.wrap_enclosing_form_under_cursor("(", ")") + expect({ + content = { "((+ 2 :foo/bar))" }, + }) + end) + + it("should wrap enclosing form if cursor is whitespace/comment", function() + prepare_buffer({ + content = { + "(+ 2", + ";; foo", + " :foo/bar)", + }, + cursor = { 2, 4 }, + }) + + paredit.wrap.wrap_enclosing_form_under_cursor("(", ")") + expect({ + content = { + "((+ 2", + ";; foo", + " :foo/bar))", + }, + }) + end) +end)