diff --git a/lua/nvim-treesitter-textobjects/repeatable_move.lua b/lua/nvim-treesitter-textobjects/repeatable_move.lua index e3958c4a..584da07b 100644 --- a/lua/nvim-treesitter-textobjects/repeatable_move.lua +++ b/lua/nvim-treesitter-textobjects/repeatable_move.lua @@ -1,5 +1,9 @@ local M = {} +---@class TSTextObjects.MovefFtTOpts +---@field forward boolean If true, move forward, and false is for backward. +---@field is_lower boolean If true, assume last move was f or t; otherwise, assume last move was F or T. + ---@class TSTextObjects.MoveOpts ---@field forward boolean If true, move forward, and false is for backward. ---@field start? boolean If true, choose the start of the node, and false is for the end. @@ -26,14 +30,42 @@ M.make_repeatable_move = function(move_fn) end end ---- Enter visual mode (nov) if operator-pending (no) mode (fixes #699) ---- Why? According to https://learnvimscriptthehardway.stevelosh.com/chapters/15.html ---- If your operator-pending mapping ends with some text visually selected, Vim will operate on that text. ---- Otherwise, Vim will operate on the text between the original cursor position and the new position. -local function force_operator_pending_visual_mode() - local mode = vim.api.nvim_get_mode() - if mode.mode == 'no' then - vim.cmd.normal({ 'v', bang = true }) +--- Handle inclusive/exclusive behavior of the `;` and `,` motions used after fFtT motions. +--- +--- Meaning, that the following operator-pending calls (with `y` operator in this case) behave +--- exactly like in plain NeoVim: +--- +--- - `yfn` and `y;` - inclusive. +--- - `yfn` and `y,` - exclusive. +--- - `yFn` and `y;` - exclusive. +--- - `yFn` and `y,` - inclusive. +--- - `ytn` and `y;` - inclusive. +--- - `ytn` and `y,` - exclusive. +--- - `yTn` and `y;` - exclusive. +--- - `yTn` and `y,` - inclusive. +---@param opts TSTextObjects.MovefFtTOpts +---@return nil +local function repeat_last_move_fFtT(opts) + local motion = '' + + if opts.is_lower then + motion = opts.forward and ';' or ',' + else + motion = opts.forward and ',' or ';' + end + + local inclusive = (opts.forward and vim.api.nvim_get_mode().mode == 'no') and 'v' or '' + + local cursor_before = vim.api.nvim_win_get_cursor(0) + vim.cmd([[normal! ]] .. inclusive .. vim.v.count1 .. motion) + local cursor_after = vim.api.nvim_win_get_cursor(0) + + -- Handle a use case when a motion in an operator-pending doesn't visually selects any text + -- region. Without "turning off" the `v` a single character at the cursor's position is selected. + -- + -- For example: `yfn` and `y2;` at the end of the line. + if inclusive == 'v' and vim.deep_equal(cursor_before, cursor_after) then + vim.cmd([[normal! ]] .. inclusive) end end @@ -44,11 +76,9 @@ M.repeat_last_move = function(opts_extend) end local opts = vim.tbl_deep_extend('force', M.last_move.opts, opts_extend or {}) if M.last_move.func == 'f' or M.last_move.func == 't' then - force_operator_pending_visual_mode() - vim.cmd([[normal! ]] .. vim.v.count1 .. (opts.forward and ';' or ',')) + repeat_last_move_fFtT({ forward = opts.forward, is_lower = true }) elseif M.last_move.func == 'F' or M.last_move.func == 'T' then - force_operator_pending_visual_mode() - vim.cmd([[normal! ]] .. vim.v.count1 .. (opts.forward and ',' or ';')) + repeat_last_move_fFtT({ forward = opts.forward, is_lower = false }) else -- we assume other textobjects (move) already handle operator-pending mode correctly M.last_move.func(opts, unpack(M.last_move.additional_args)) diff --git a/tests/repeatable_move/common.lua b/tests/repeatable_move/common.lua index 2d825ed8..2f2df9d9 100644 --- a/tests/repeatable_move/common.lua +++ b/tests/repeatable_move/common.lua @@ -20,7 +20,7 @@ function M.run_builtin_find_test(file, spec) for col = 0, num_cols - 1 do for _, cmd in pairs({ 'f', 'F', 't', 'T' }) do for _, repeat_cmd in pairs({ ';', ',' }) do - -- Get ground truth using vim's built-in search and repeat + -- Get ground truth using vim's built-in search and repeat (normal mode) vim.api.nvim_win_set_cursor(0, { spec.row, col }) local gt_cols = {} vim.cmd([[normal! ]] .. cmd .. spec.char) @@ -36,7 +36,7 @@ function M.run_builtin_find_test(file, spec) vim.cmd([[normal! 2]] .. cmd .. spec.char) gt_cols[#gt_cols + 1] = vim.fn.col('.') - -- test using tstextobj repeatable_move.lua + -- test using tstextobj repeatable_move.lua (normal mode) vim.api.nvim_win_set_cursor(0, { spec.row, col }) local ts_cols = {} vim.cmd([[normal ]] .. cmd .. spec.char) @@ -61,7 +61,82 @@ function M.run_builtin_find_test(file, spec) assert.are.same( gt_cols, ts_cols, - string.format("Command %s works differently than vim's built-in find, col: %d", cmd, col) + string.format( + "Command %s with repeat %s works differently than vim's built-in find, col: %d", + cmd, + repeat_cmd, + col + ) + ) + + -- Get ground truth using vim's built-in search and repeat (operator-pending mode) + vim.api.nvim_win_set_cursor(0, { spec.row, col }) + local gt_regs = {} + vim.fn.setreg('0', '') + vim.cmd([[normal! y]] .. cmd .. spec.char) + gt_regs[#gt_regs + 1] = vim.fn.getreg('0') + vim.fn.setreg('0', '') + vim.cmd([[normal! y]] .. repeat_cmd) + gt_regs[#gt_regs + 1] = vim.fn.getreg('0') + vim.fn.setreg('0', '') + vim.cmd([[normal! y2]] .. repeat_cmd) + gt_regs[#gt_regs + 1] = vim.fn.getreg('0') + vim.fn.setreg('0', '') + vim.cmd([[normal! l]] .. repeat_cmd) + vim.fn.setreg('0', '') + vim.cmd([[normal! y2]] .. repeat_cmd) + gt_regs[#gt_regs + 1] = vim.fn.getreg('0') + vim.fn.setreg('0', '') + vim.cmd([[normal! h]] .. repeat_cmd) + vim.fn.setreg('0', '') + vim.cmd([[normal! y2]] .. repeat_cmd) + gt_regs[#gt_regs + 1] = vim.fn.getreg('0') + vim.fn.setreg('0', '') + vim.cmd([[normal! 2y]] .. cmd .. spec.char) + gt_regs[#gt_regs + 1] = vim.fn.getreg('0') + + -- test using tstextobj repeatable_move.lua (operator-pending mode) + vim.api.nvim_win_set_cursor(0, { spec.row, col }) + local ts_regs = {} + vim.fn.setreg('0', '') + vim.cmd([[normal y]] .. cmd .. spec.char) + ts_regs[#ts_regs + 1] = vim.fn.getreg('0') + assert.are.same(spec.row, vim.fn.line('.'), "Command shouldn't move cursor over rows") + vim.fn.setreg('0', '') + vim.cmd([[normal y]] .. repeat_cmd) + ts_regs[#ts_regs + 1] = vim.fn.getreg('0') + assert.are.same(spec.row, vim.fn.line('.'), "Command shouldn't move cursor over rows") + vim.fn.setreg('0', '') + vim.cmd([[normal y2]] .. repeat_cmd) + ts_regs[#ts_regs + 1] = vim.fn.getreg('0') + assert.are.same(spec.row, vim.fn.line('.'), "Command shouldn't move cursor over rows") + vim.fn.setreg('0', '') + vim.cmd([[normal l]] .. repeat_cmd) + assert.are.same(spec.row, vim.fn.line('.'), "Command shouldn't move cursor over rows") + vim.fn.setreg('0', '') + vim.cmd([[normal y2]] .. repeat_cmd) + ts_regs[#ts_regs + 1] = vim.fn.getreg('0') + assert.are.same(spec.row, vim.fn.line('.'), "Command shouldn't move cursor over rows") + vim.fn.setreg('0', '') + vim.cmd([[normal h]] .. repeat_cmd) + assert.are.same(spec.row, vim.fn.line('.'), "Command shouldn't move cursor over rows") + vim.fn.setreg('0', '') + vim.cmd([[normal y2]] .. repeat_cmd) + ts_regs[#ts_regs + 1] = vim.fn.getreg('0') + assert.are.same(spec.row, vim.fn.line('.'), "Command shouldn't move cursor over rows") + vim.fn.setreg('0', '') + vim.cmd([[normal 2y]] .. cmd .. spec.char) + ts_regs[#ts_regs + 1] = vim.fn.getreg('0') + + assert.are.same( + gt_regs, + ts_regs, + string.format( + "Command %s with repeat %s works differently than vim's built-in find, col: %d", + cmd, + repeat_cmd, + col + ) ) end end