diff --git a/.github/workflows/panvimdoc.yml b/.github/workflows/panvimdoc.yml new file mode 100644 index 0000000..0945f4d --- /dev/null +++ b/.github/workflows/panvimdoc.yml @@ -0,0 +1,20 @@ +name: panvimdoc + +on: [push] + +jobs: + docs: + runs-on: ubuntu-latest + name: pandoc to vimdoc + steps: + - uses: actions/checkout@v2 + - name: panvimdoc + uses: kdheepak/panvimdoc@main + with: + vimdoc: substitute-nvim + description: Neovim plugin introducing a new operator motions to quickly replace text. + version: 'NVIM v0.6.0' + - uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: 'chore: auto generate docs' + branch: ${{ github.head_ref }} diff --git a/doc/substitute-nvim.txt b/doc/substitute-nvim.txt new file mode 100644 index 0000000..a580be9 --- /dev/null +++ b/doc/substitute-nvim.txt @@ -0,0 +1,245 @@ +*substitute-nvim.txt*Neovim plugin introducing a new operator motions to quickly replace text. + +============================================================================== +Table of Contents *substitute-nvim-table-of-contents* + +1. substitute.nvim |substitute-nvim-substitute.nvim| + - Usage |substitute-nvim-usage| + - Substitute operator |substitute-nvim-substitute-operator| + - Substitute over range motion|substitute-nvim-substitute-over-range-motion| + - Credits |substitute-nvim-credits| + +============================================================================== +1. substitute.nvim *substitute-nvim-substitute.nvim* + + + + +`substitute.nvim` aim is to provide new operator motions to make it very easy +to perform quick substitutions. + +If you are familiar with svermeulen/vim-subversive +, this plugin does almost the +same but rewritten in `lua` (and I hope this will be more maintainable, +readable and efficient). + +This is a beta version, expect bugs ;) (but I use it daily). + +USAGE *substitute-nvim-usage* + +Requires neovim > 0.6.0. + +Using https://github.com/wbthomason/packer.nvim: + +> + use({ + "gbprod/substitute.nvim", + config = function() + require("substitute").setup() + end + }) +< + + +SUBSTITUTE OPERATOR *substitute-nvim-substitute-operator* + +It contains no default mappings and will have no effect until you add your own +maps to it. + +> + vim.api.nvim_set_keymap("n", "s", "lua require('substitute').operator()", { noremap = true }) + vim.api.nvim_set_keymap("n", "ss", "lua require('substitute').line()", { noremap = true }) + vim.api.nvim_set_keymap("n", "S", "lua require('substitute').eol()", { noremap = true }) + vim.api.nvim_set_keymap("x", "s", "lua require('substitute').visual()", { noremap = true }) +< + + +Or + +> + nnoremap s lua require('substitute').operator() + nnoremap ss lua require('substitute').line() + nnoremap S lua require('substitute').eol() + xnoremap s lua require('substitute').visual() +< + + +Then you can then execute `s` to substitute the text object provided by +the motion with the contents of the default register (or an explicit register +if provided). For example, you could execute siw to replace the current word +under the cursor with the current yank, or sip to replace the paragraph, etc. + +This action is dot-repeatable. + +Note: in this case you will be shadowing the change character key `s` so you +will have to use the longer form `cl`. + +CONFIGURATION ~ + + *substitute-nvim-`on_substitute`* + +`on_substitute` Default : `nil` + + +Function that will be called each times a substitution is made. This function +takes a `param` argument that contains the `register` used for substitution. + + *substitute-nvim-`yank_substitued_text`* + +`yank_substitued_text` Default : `false` + + +If `true`, when performing a substitution, substitued text is pushed into the +default register. + +INTEGRATION ~ + +svermeulen/vim-yoink ~ + +To enable vim-yoink swap when +performing a substitution, you can add this to your setup: + +> + require("substitute").setup({ + on_substitute = function(_) + vim.cmd("call yoink#startUndoRepeatSwap()") + end, + }) +< + + +vim-yoink does not support swapping +when doing paste in visual mode. With this plugin, you can add thoss mappings +to enable it : + +> + vim.api.nvim_set_keymap("x", "p", "lua require('substitute').visual()", {}) + vim.api.nvim_set_keymap("x", "P", "lua require('substitute').visual()", {}) +< + + +or + +> + xmap p lua require('substitute').visual() + xmap P lua require('substitute').visual() +< + + +SUBSTITUTE OVER RANGE MOTION *substitute-nvim-substitute-over-range-motion* + +Another operator provided allows specifying both the text to replace and the +line range over which to apply the change by using multiple consecutive +motions. + +> + vim.api.nvim_set_keymap("n", "s", "lua require('substitute.range').operator()", { noremap = true }) + vim.api.nvim_set_keymap("x", "s", "lua require('substitute.range').visual()") + vim.api.nvim_set_keymap("n", "ss", "lua require('substitute.range').word()") +< + + +or + +> + nmap s lua require('substitute.range').operator() + xmap s lua require('substitute.range').visual() + nmap ss lua require('substitute.range').word() +< + + +After adding this map, if you execute `s` then the +command line will be filled with a substitute command that allow to replace the +text given by `motion1` by the text will enter in the command line for each +line provided by `motion2`. + +Alternatively, we can also select `motion1` in visual mode and then hit +`s` for the same effect. + +For convenience, `ss` can be used to select complete word +under the cursor as motion1 (complete word means that `complete_word` options +is override to `true` so is different from siwip which will not require +that there be word boundaries on each match). + +You can override any default configuration (described later) by passing this to +the operator function. By example, this will use `S` as prefix of the +substitution command (and use tpope/vim-abolish +): + +> + nmap S lua require('substitute.range').operator({ prefix = 'S' }) +< + + +CONFIGURATION ~ + + *substitute-nvim-`range.prefix`* + +`range.prefix` Default : `s` + + +Substitution command that will be used (set it to `S` to use tpope/vim-abolish + substitution by default). + + *substitute-nvim-`range.prompt_current_text`* + +`range.prompt_current_text` Default : `false` + + +Substitution command replace part will be set to the current text. Eg. instead +of `s/pattern//g` you will have `s/pattern/pattern/g`. + + *substitute-nvim-`range.confirm`* + +`range.confirm` Default : `false` + + +Will ask for confirmation for each substitutions. + + *substitute-nvim-`range.complete_word`* + +`range.complete_word` Default : `false` + + +Will require that there be word boundaries on each match (eg: `\` +instead of `word`). + +INTEGRATION ~ + +tpope/vim-abolish ~ + +You can use tpope/vim-abolish +substitution by default. + +> + require("substitute").setup({ + range = { + prefix = "S", + } + }) +< + + +CONFIGURATION ~ + + *substitute-nvim-`range.prefix`* + +`range.prefix` Default : `s` + + +Function that will be called each times a substitution is made. This function +takes a `param` argument that contains the `register` used for substitution. + +CREDITS *substitute-nvim-credits* + +This plugin is a lua version of svermeulen/vim-subversive + awesome plugin. + +Thanks to m00qek lua plugin template +. + +Generated by panvimdoc + +vim:tw=78:ts=8:noet:ft=help:norl: diff --git a/doc/substitute.nvim.txt b/doc/substitute.nvim.txt new file mode 100644 index 0000000..e69de29 diff --git a/lua/substitute.lua b/lua/substitute.lua index ac94377..dac62fa 100644 --- a/lua/substitute.lua +++ b/lua/substitute.lua @@ -17,17 +17,21 @@ function substitute.operator() vim.api.nvim_feedkeys("g@", "i", false) end -local function do_substitution(start_row, start_col, end_row, end_col, register) +local function do_substitution(regions, register, vmode) local replacement = vim.fn.getreg(register) if config.options.yank_substitued_text then vim.fn.setreg( utils.get_default_register(), - table.concat(utils.nvim_buf_get_text(start_row, start_col, end_row, end_col), "\n") + table.concat(utils.get_text(regions), "\n"), + utils.get_register_type(vmode) ) end - vim.api.nvim_buf_set_text(0, start_row, start_col, end_row, end_col, vim.split(replacement:gsub("\n$", ""), "\n")) + local text = vim.split(replacement:gsub("\n$", ""), "\n") + for _, region in ipairs(regions) do + vim.api.nvim_buf_set_text(0, region.start_row - 1, region.start_col, region.end_row - 1, region.end_col + 1, text) + end if config.options.on_substitute ~= nil then config.options.on_substitute({ @@ -37,14 +41,8 @@ local function do_substitution(start_row, start_col, end_row, end_col, register) end function substitute.operator_callback(vmode) - local region = utils.get_region(vmode) - do_substitution( - region.start_row - 1, - region.start_col, - region.end_row - 1, - region.end_col + 1, - substitute.state.register - ) + local regions = utils.get_regions(vmode) + do_substitution(regions, substitute.state.register, vmode) end function substitute.line() @@ -67,8 +65,8 @@ end function substitute.visual() substitute.state.register = vim.v.register - vim.o.operatorfunc = "v:lua.require'substitute'.operator_callback" - vim.api.nvim_feedkeys("g@`>", "i", false) + vim.cmd([[execute "normal! \"]]) + substitute.operator_callback(vim.fn.visualmode()) end return substitute diff --git a/lua/substitute/range.lua b/lua/substitute/range.lua index 323f94b..64664e1 100644 --- a/lua/substitute/range.lua +++ b/lua/substitute/range.lua @@ -53,14 +53,14 @@ function range.clear_match() end function range.operator_callback(vmode) - local region = utils.get_region(vmode) - if region.start_row ~= region.end_row then + local regions = utils.get_regions(vmode) + if vim.tbl_count(regions) ~= 1 or regions[1].start_row ~= regions[1].end_row then vim.notify("Multiline is not supported by SubstituteRange", vim.log.levels.INFO) return end - local line = vim.api.nvim_buf_get_lines(0, region.start_row - 1, region.end_row, true) - range.state.subject = string.sub(line[1], region.start_col + 1, region.end_col + 1) + local line = vim.api.nvim_buf_get_lines(0, regions[1].start_row - 1, regions[1].end_row, true) + range.state.subject = string.sub(line[1], regions[1].start_col + 1, regions[1].end_col + 1) create_match() diff --git a/lua/substitute/utils.lua b/lua/substitute/utils.lua index b270c68..7919c92 100644 --- a/lua/substitute/utils.lua +++ b/lua/substitute/utils.lua @@ -1,30 +1,58 @@ local utils = {} -function utils.get_region(vmode) - local sln, eln - if vmode:match("[vV]") then - sln = vim.api.nvim_buf_get_mark(0, "<") - eln = vim.api.nvim_buf_get_mark(0, ">") - else - sln = vim.api.nvim_buf_get_mark(0, "[") - eln = vim.api.nvim_buf_get_mark(0, "]") +function utils.get_regions(vmode) + if vmode == vim.api.nvim_replace_termcodes("", true, false, true) then + local start = vim.api.nvim_buf_get_mark(0, "<") + local finish = vim.api.nvim_buf_get_mark(0, ">") + + local regions = {} + + for row = start[1], finish[1], 1 do + local current_row_len = vim.fn.getline(row):len() - 1 + + table.insert(regions, { + start_row = row, + start_col = start[2], + end_row = row, + end_col = current_row_len >= finish[2] and finish[2] or current_row_len, + }) + end + + return regions end + local start_mark, end_mark = "[", "]" + if vmode:match("[vV]") then + start_mark, end_mark = "<", ">" + end + + local start = vim.api.nvim_buf_get_mark(0, start_mark) + local finish = vim.api.nvim_buf_get_mark(0, end_mark) + local end_row_len = vim.fn.getline(finish[1]):len() - 1 + return { - start_row = sln[1], - start_col = sln[2], - end_row = eln[1], - end_col = math.min(eln[2], vim.fn.getline(eln[1]):len() - 1), + { + start_row = start[1], + start_col = start[2], + end_row = finish[1], + end_col = end_row_len >= finish[2] and finish[2] or end_row_len, + }, } end -function utils.nvim_buf_get_text(start_row, start_col, end_row, end_col) - local lines = vim.api.nvim_buf_get_lines(0, start_row, end_row + 1, true) +function utils.get_text(regions) + local all_lines = {} + for _, region in ipairs(regions) do + local lines = vim.api.nvim_buf_get_lines(0, region.start_row - 1, region.end_row, true) + lines[vim.tbl_count(lines)] = string.sub(lines[vim.tbl_count(lines)], 0, region.end_col + 1) + lines[1] = string.sub(lines[1], region.start_col + 1) - lines[vim.tbl_count(lines)] = string.sub(lines[vim.tbl_count(lines)], 0, end_col) - lines[1] = string.sub(lines[1], start_col + 1) + for _, line in ipairs(lines) do + table.insert(all_lines, line) + end + end - return lines + return all_lines end function utils.get_default_register() @@ -41,4 +69,16 @@ function utils.get_default_register() return '"' end +function utils.get_register_type(vmode) + if vmode == vim.api.nvim_replace_termcodes("", true, false, true) then + return "b" + end + + if vmode == "V" then + return "l" + end + + return "c" +end + return utils diff --git a/spec/spec.vim b/spec/spec.vim index 3ae3339..a2a0691 100644 --- a/spec/spec.vim +++ b/spec/spec.vim @@ -5,3 +5,12 @@ runtime plugin/plenary.vim runtime ../plugin/substitute.vim lua require('plenary.busted') + +lua vim.api.nvim_set_keymap("n", "ss", "lua require('substitute').line()", { noremap = true }) +lua vim.api.nvim_set_keymap("n", "S", "lua require('substitute').eol()", { noremap = true }) +lua vim.api.nvim_set_keymap("x", "s", "lua require('substitute').visual()", { noremap = true }) +lua vim.api.nvim_set_keymap("n", "s", "lua require('substitute').operator()", { noremap = true }) + +lua vim.api.nvim_set_keymap("n", "s", "lua require('substitute.range').operator()", { noremap = true, }) + + diff --git a/spec/substitute/range_spec.lua b/spec/substitute/range_spec.lua index 484af39..dca864b 100644 --- a/spec/substitute/range_spec.lua +++ b/spec/substitute/range_spec.lua @@ -11,10 +11,6 @@ describe("Substitute range", function() local buf = vim.api.nvim_create_buf(false, true) vim.api.nvim_command("buffer " .. buf) - vim.api.nvim_set_keymap("n", "s", "lua require('substitute.range').operator()", { - noremap = true, - }) - vim.api.nvim_buf_set_lines(0, 0, -1, true, { "Lorem", "ipsum", diff --git a/spec/substitute/utils_spec.lua b/spec/substitute/utils_spec.lua index 20af1c0..26ca7d5 100644 --- a/spec/substitute/utils_spec.lua +++ b/spec/substitute/utils_spec.lua @@ -1,37 +1,207 @@ local utils = require("substitute.utils") -describe("Test get region", function() - it("should find region", function() - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_command("buffer " .. buf) - vim.api.nvim_buf_set_lines(0, 0, -1, true, { - "Lorem", - "Ipsum", - }) +local function execute_keys(feedkeys) + local keys = vim.api.nvim_replace_termcodes(feedkeys, true, false, true) + vim.api.nvim_feedkeys(keys, "x", false) +end + +local function create_test_buffer() + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_command("buffer " .. buf) + + vim.api.nvim_buf_set_lines(0, 0, -1, true, { + "Lorem ipsum dolor sit amet,", + "consectetur adipiscing elit.", + "Nulla malesuada lacus at ornare accumsan.", + }) +end - vim.api.nvim_buf_set_mark(buf, "[", 1, 1, {}) - vim.api.nvim_buf_set_mark(buf, "]", 2, 2, {}) +describe("Get regions in operatorfunc", function() + before_each(create_test_buffer) - local region = utils.get_region("char") + it("should select word", function() + local region + _G.callback = function() + region = utils.get_regions(vim.fn.visualmode()) + end - assert(region.start_col == 1) - assert(region.start_row == 1) - assert(region.end_col == 2) - assert(region.end_row == 2) + vim.o.operatorfunc = "v:lua.callback" + execute_keys("g@iw") + + assert.are.same({ { start_row = 1, start_col = 0, end_row = 1, end_col = 4 } }, region) end) - it("should get text", function() - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_command("buffer " .. buf) - vim.api.nvim_buf_set_lines(0, 0, -1, true, { - "Lorem", - "Ipsum", - }) + it("should select to end of the line", function() + local region + _G.callback = function() + region = utils.get_regions(vim.fn.visualmode()) + end + + execute_keys("2w") + vim.o.operatorfunc = "v:lua.callback" + execute_keys("g@$") + + assert.are.same({ { start_row = 1, start_col = 12, end_row = 1, end_col = 26 } }, region) + end) + + it("should select many lines", function() + local region + _G.callback = function() + region = utils.get_regions(vim.fn.visualmode()) + end + + execute_keys("2w") + vim.o.operatorfunc = "v:lua.callback" + execute_keys("g@5w") + + assert.are.same({ { start_row = 1, start_col = 12, end_row = 2, end_col = 11 } }, region) + end) + + it("should select to end of file", function() + local region + _G.callback = function() + region = utils.get_regions(vim.fn.visualmode()) + end + + execute_keys("w") + vim.o.operatorfunc = "v:lua.callback" + execute_keys("g@G") + + assert.are.same({ { start_row = 1, start_col = 6, end_row = 3, end_col = 6 } }, region) + end) +end) - local text = utils.nvim_buf_get_text(0, 1, 1, 4) - assert.are.same({ "orem", "Ipsu" }, text) +describe("Get regions in visual mode", function() + before_each(create_test_buffer) + + it("should select word", function() + execute_keys("ve") + local region = utils.get_regions(vim.fn.visualmode()) + + assert.are.same({ { start_row = 1, start_col = 0, end_row = 1, end_col = 4 } }, region) + end) + + it("should select to end of the line", function() + execute_keys("2w") + execute_keys("v$") + local region = utils.get_regions(vim.fn.visualmode()) + + assert.are.same({ { start_row = 1, start_col = 12, end_row = 1, end_col = 26 } }, region) + end) + + it("should select many lines", function() + execute_keys("2w") + execute_keys("v5w") + local region = utils.get_regions(vim.fn.visualmode()) + + assert.are.same({ { start_row = 1, start_col = 12, end_row = 2, end_col = 12 } }, region) + end) + + it("should select to end of file", function() + execute_keys("w") + execute_keys("vG") + local region = utils.get_regions(vim.fn.visualmode()) + + assert.are.same({ { start_row = 1, start_col = 6, end_row = 3, end_col = 6 } }, region) + end) +end) + +describe("Get regions in VISUAL mode", function() + before_each(create_test_buffer) + + it("should select line", function() + execute_keys("w") + execute_keys("V") + local region = utils.get_regions(vim.fn.visualmode()) + + assert.are.same({ { start_row = 1, start_col = 0, end_row = 1, end_col = 26 } }, region) + end) + + it("should select multiple lines", function() + execute_keys("wj") + execute_keys("Vj") + local region = utils.get_regions(vim.fn.visualmode()) + + assert.are.same({ { start_row = 2, start_col = 0, end_row = 3, end_col = 40 } }, region) + end) +end) + +describe("Get regions in CTRL-V mode", function() + before_each(create_test_buffer) + + it("should select word", function() + execute_keys("w") + local region = utils.get_regions(vim.fn.visualmode()) + + assert.are.same({ { start_row = 1, start_col = 0, end_row = 1, end_col = 6 } }, region) + end) + + it("should select on 2 lines", function() + execute_keys("wj") + local region = utils.get_regions(vim.fn.visualmode()) + + assert.are.same({ + { start_row = 1, start_col = 0, end_row = 1, end_col = 6 }, + { start_row = 2, start_col = 0, end_row = 2, end_col = 6 }, + }, region) + end) + + it("should select to the end of lines", function() + execute_keys("wj") + execute_keys("j$") + local region = utils.get_regions(vim.fn.visualmode()) + + assert.are.same({ + { start_row = 2, start_col = 6, end_row = 2, end_col = 27 }, + { start_row = 3, start_col = 6, end_row = 3, end_col = 40 }, + }, region) + end) + + it("should select from the beginning of lines", function() + execute_keys("jjl") + local region = utils.get_regions(vim.fn.visualmode()) + + assert.are.same({ + { start_row = 1, start_col = 0, end_row = 1, end_col = 1 }, + { start_row = 2, start_col = 0, end_row = 2, end_col = 1 }, + { start_row = 3, start_col = 0, end_row = 3, end_col = 1 }, + }, region) + end) +end) + +describe("Get text", function() + before_each(create_test_buffer) + + it("should get one word at the beginning", function() + local text = utils.get_text({ { start_row = 1, start_col = 0, end_row = 1, end_col = 4 } }) + + assert.are.same({ "Lorem" }, text) + end) + + it("should get one word", function() + local text = utils.get_text({ { start_row = 1, start_col = 6, end_row = 1, end_col = 10 } }) + + assert.are.same({ "ipsum" }, text) + end) + + it("should get one word at the end", function() + local text = utils.get_text({ { start_row = 2, start_col = 23, end_row = 2, end_col = 27 } }) + + assert.are.same({ "elit." }, text) + end) + + it("should get text on 2 lines", function() + local text = utils.get_text({ { start_row = 1, start_col = 6, end_row = 2, end_col = 21 } }) + + assert.are.same({ "ipsum dolor sit amet,", "consectetur adipiscing" }, text) + end) + + it("should get text on 2 regions", function() + local text = utils.get_text({ + { start_row = 1, start_col = 6, end_row = 1, end_col = 10 }, + { start_row = 2, start_col = 23, end_row = 2, end_col = 27 }, + }) - text = utils.nvim_buf_get_text(0, 1, 0, 4) - assert.are.same({ "ore" }, text) + assert.are.same({ "ipsum", "elit." }, text) end) end) diff --git a/spec/substitute_spec.lua b/spec/substitute_spec.lua index b597df6..9633717 100644 --- a/spec/substitute_spec.lua +++ b/spec/substitute_spec.lua @@ -17,17 +17,7 @@ describe("Substitute", function() local buf = vim.api.nvim_create_buf(false, true) vim.api.nvim_command("buffer " .. buf) - vim.api.nvim_set_keymap("n", "ss", "lua require('substitute').line()", { noremap = true }) - vim.api.nvim_set_keymap("n", "S", "lua require('substitute').eol()", { noremap = true }) - vim.api.nvim_set_keymap("n", "s", "lua require('substitute').operator()", { noremap = true }) - - vim.api.nvim_buf_set_lines(0, 0, -1, true, { - "Lorem", - "ipsum", - "dolor", - "sit", - "amet", - }) + vim.api.nvim_buf_set_lines(0, 0, -1, true, { "Lorem", "ipsum", "dolor", "sit", "amet" }) end) it("should substitute line", function() @@ -112,6 +102,14 @@ describe("Substitute", function() assert.are.same({ "Lorem", "Lorem", "amet" }, get_buf_lines()) end) + + it("should substitute in visual mode", function() + execute_keys("yw") + execute_keys("jv$") + execute_keys("s") + + assert.are.same({ "Lorem", "Lorem", "dolor", "sit", "amet" }, get_buf_lines()) + end) end) describe("On substitute option", function() @@ -129,3 +127,47 @@ describe("On substitute option", function() assert(called) end) end) + +describe("When yank_substitued_text is set", function() + before_each(function() + substitute.setup() + + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_command("buffer " .. buf) + + vim.api.nvim_buf_set_lines(0, 0, -1, true, { "Lorem", "ipsum", "dolor", "sit", "amet" }) + end) + + it("should yank in default register", function() + substitute.setup({ yank_substitued_text = true }) + + execute_keys("yw") + execute_keys("j") + execute_keys("sw") + + assert.are.same("ipsum", vim.fn.getreg()) + assert.are.same("v", vim.fn.getregtype()) + end) + + it("should yank in default register in visual mode", function() + substitute.setup({ yank_substitued_text = true }) + + execute_keys("ywj") + execute_keys("vjj") + execute_keys("s") + + assert.are.same("ipsum\ndolor\ns", vim.fn.getreg()) + assert.are.same("v", vim.fn.getregtype()) + end) + + it("should yank in default register in ctrl-v mode", function() + substitute.setup({ yank_substitued_text = true }) + + execute_keys("ywj") + execute_keys("jjl") + execute_keys("s") + + assert.are.same("ip\ndo\nsi", vim.fn.getreg()) + assert.are.same(vim.api.nvim_replace_termcodes("2", true, false, true), vim.fn.getregtype()) + end) +end)