From fce60205bb419be69902a2e3359588b159ecdec9 Mon Sep 17 00:00:00 2001 From: Illia Shkroba Date: Sat, 24 Jan 2026 15:02:14 +0100 Subject: [PATCH 1/3] fix(repeatable_move): `repeat_last_move` always uses count 1 when used in operator-pending (no) mode. --- lua/nvim-treesitter-textobjects/repeatable_move.lua | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lua/nvim-treesitter-textobjects/repeatable_move.lua b/lua/nvim-treesitter-textobjects/repeatable_move.lua index e3958c4a..8bc6f042 100644 --- a/lua/nvim-treesitter-textobjects/repeatable_move.lua +++ b/lua/nvim-treesitter-textobjects/repeatable_move.lua @@ -43,12 +43,16 @@ M.repeat_last_move = function(opts_extend) return end local opts = vim.tbl_deep_extend('force', M.last_move.opts, opts_extend or {}) + -- The call to `normal` in `force_operator_pending_visual_mode` resets `vim.v.count1` to 1 while in + -- operator-pending (no) mode. Meaning, that the `vim.v.count1` must be saved before the + -- `force_operator_pending_visual_mode` call and used later on instead of new `vim.v.count1`. + local count1 = vim.v.count1 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 ',')) + vim.cmd([[normal! ]] .. count1 .. (opts.forward and ';' or ',')) 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 ';')) + vim.cmd([[normal! ]] .. count1 .. (opts.forward and ',' or ';')) else -- we assume other textobjects (move) already handle operator-pending mode correctly M.last_move.func(opts, unpack(M.last_move.additional_args)) From 70d4bae7459e832b93e70a26564c8308ce0a4ca8 Mon Sep 17 00:00:00 2001 From: Illia Shkroba Date: Mon, 26 Jan 2026 23:26:17 +0100 Subject: [PATCH 2/3] fix(repeatable_move): ensures `repeat_last_move` handles inclusive/exclusive behavior of the `;` and `,` motions used after fFtT motions as in plain NeoVim --- .../repeatable_move.lua | 58 ++++++++++++++----- 1 file changed, 42 insertions(+), 16 deletions(-) diff --git a/lua/nvim-treesitter-textobjects/repeatable_move.lua b/lua/nvim-treesitter-textobjects/repeatable_move.lua index 8bc6f042..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 @@ -43,16 +75,10 @@ M.repeat_last_move = function(opts_extend) return end local opts = vim.tbl_deep_extend('force', M.last_move.opts, opts_extend or {}) - -- The call to `normal` in `force_operator_pending_visual_mode` resets `vim.v.count1` to 1 while in - -- operator-pending (no) mode. Meaning, that the `vim.v.count1` must be saved before the - -- `force_operator_pending_visual_mode` call and used later on instead of new `vim.v.count1`. - local count1 = vim.v.count1 if M.last_move.func == 'f' or M.last_move.func == 't' then - force_operator_pending_visual_mode() - vim.cmd([[normal! ]] .. 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! ]] .. 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)) From 674539ce5fae25c5d2635caf4ecb46e3ab78cb38 Mon Sep 17 00:00:00 2001 From: Illia Shkroba Date: Sun, 25 Jan 2026 22:37:09 +0100 Subject: [PATCH 3/3] test(repeatable_move): add operator-pending mode test cases --- tests/repeatable_move/common.lua | 81 ++++++++++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 3 deletions(-) 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