From 2d9496f9e767d17bf5aad4c4165c9943dc5201f6 Mon Sep 17 00:00:00 2001 From: Illia Shkroba Date: Tue, 25 Aug 2026 22:30:31 +0200 Subject: [PATCH] fix(repeatable_move): handle *forced-motion* `v` --- .../repeatable_move.lua | 32 +++++++-- tests/repeatable_move/common.lua | 68 +++++++++++++++++++ tests/repeatable_move/python_spec.lua | 4 ++ 3 files changed, 98 insertions(+), 6 deletions(-) diff --git a/lua/nvim-treesitter-textobjects/repeatable_move.lua b/lua/nvim-treesitter-textobjects/repeatable_move.lua index 12169fab..44562120 100644 --- a/lua/nvim-treesitter-textobjects/repeatable_move.lua +++ b/lua/nvim-treesitter-textobjects/repeatable_move.lua @@ -53,21 +53,41 @@ local function repeat_last_move_fFtT(opts) motion = opts.forward and ',' or ';' end + local mode = vim.api.nvim_get_mode().mode + local operator_pending = mode:sub(1, 2) == 'no' + -- *forced-motion* character typed by the user before the `;` or `,` motion: `v`, `V`, `CTRL-V` + -- or an empty string when the motion wasn't forced. + local forced = mode:sub(3) + -- This changes operator-pending (no) mode to operator-pending-visual (nov) mode to include last -- character in the region when going forward. In other words, going forward will include current -- cursor and found character. - local inclusive = (opts.forward and vim.api.nvim_get_mode().mode == 'no') and 'v' or '' + local inclusive = (opts.forward and operator_pending and forced == '') 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. + if vim.deep_equal(cursor_before, cursor_after) then + -- 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' then + vim.cmd([[normal! ]] .. inclusive) + end + + return + end + + -- Handle a *forced-motion* (`:h forced-motion`) typed by the user, like in `yfn` and `yv;`. -- - -- 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) + -- The `;` and `,` motions above are performed by `normal!`, so Neovim applies the force to a + -- plain cursor movement (exclusive) instead of an `fFtT` repeat (inclusive when going forward). + -- The force can't be undone with another `v` so shrink the region by a single character instead + -- to make the forward motion exclusive. + if opts.forward and operator_pending and forced == 'v' then + vim.api.nvim_win_set_cursor(0, { cursor_after[1], cursor_after[2] - 1 }) end end diff --git a/tests/repeatable_move/common.lua b/tests/repeatable_move/common.lua index 230bbbe2..a0c1359a 100644 --- a/tests/repeatable_move/common.lua +++ b/tests/repeatable_move/common.lua @@ -144,6 +144,66 @@ function M.run_builtin_find_test(file, spec) vim.cmd('edit!') end +-- Test the *forced-motion* (`:h forced-motion`) `v` typed before a `;` or `,` repeat in +-- operator-pending mode, like in `yfn` and `yv;`. +-- +-- The `v` inverts the inclusive/exclusive behavior of the repeated fFtT motion. +function M.run_forced_find_test(file, spec) + assert.are.same(1, vim.fn.filereadable(file), string.format('File "%s" not readable', file)) + + -- load reference file + vim.cmd(string.format('edit %s', file)) + + vim.api.nvim_win_set_cursor(0, { spec.row, 0 }) + local line = vim.api.nvim_get_current_line() + local num_cols = #line + + for col = 0, num_cols - 1 do + for _, cmd in pairs({ 'f', 'F', 't', 'T' }) do + for _, repeat_cmd in pairs({ ';', ',' }) do + for _, count in pairs({ '', '2' }) do + -- Check whether the repeat finds anything at all, using vim's built-in search and repeat + vim.api.nvim_win_set_cursor(0, { spec.row, col }) + vim.cmd([[normal! ]] .. cmd .. spec.char) + local found_from = vim.fn.col('.') + vim.cmd([[normal! ]] .. count .. repeat_cmd) + local found_to = vim.fn.col('.') + + if found_from ~= found_to then + -- Get ground truth using vim's built-in search and repeat (operator-pending mode) + vim.api.nvim_win_set_cursor(0, { spec.row, col }) + vim.cmd([[normal! ]] .. cmd .. spec.char) + vim.fn.setreg('0', '') + vim.cmd([[normal! yv]] .. count .. repeat_cmd) + local gt_reg = vim.fn.getreg('0') + + -- test using tstextobj repeatable_move.lua (operator-pending mode) + vim.api.nvim_win_set_cursor(0, { spec.row, col }) + vim.cmd([[normal ]] .. cmd .. spec.char) + vim.fn.setreg('0', '') + vim.cmd([[normal yv]] .. count .. repeat_cmd) + local ts_reg = vim.fn.getreg('0') + + assert.are.same( + gt_reg, + ts_reg, + string.format( + "Command %s with forced repeat v%s%s works differently than vim's built-in find, col: %d", + cmd, + count, + repeat_cmd, + col + ) + ) + end + end + end + end + end + -- clear any changes to avoid 'No write since last change (add ! to override)' + vim.cmd('edit!') +end + local Runner = {} Runner.__index = Runner @@ -167,6 +227,14 @@ function Runner:builtin_find(file, spec, title) end) end +function Runner:forced_find(file, spec, title) + title = title and title or tostring(spec.row) + self.it(string.format('%s[%s]', file, title), function() + local path = vim.fs.joinpath(self.base_dir, file) + M.run_forced_find_test(path, spec) + end) +end + M.Runner = Runner return M diff --git a/tests/repeatable_move/python_spec.lua b/tests/repeatable_move/python_spec.lua index 3437c4f4..47acd217 100644 --- a/tests/repeatable_move/python_spec.lua +++ b/tests/repeatable_move/python_spec.lua @@ -11,4 +11,8 @@ describe('builtin find Python:', function() describe('repeat:', function() run:builtin_find('aligned_indent.py', { row = 1, char = 'n' }) end) + + describe('forced repeat:', function() + run:forced_find('aligned_indent.py', { row = 1, char = 'n' }) + end) end)