Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 42 additions & 12 deletions lua/nvim-treesitter-textobjects/repeatable_move.lua
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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

Expand All @@ -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))
Expand Down
81 changes: 78 additions & 3 deletions tests/repeatable_move/common.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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
Expand Down