In Neovim, the .
character repeats "the most recent action"; however, this is not always respected by plugin actions. Here we will explore how to build dot-repeat support directly into your plugin, bypassing the requirement of dependencies like repeat.vim.
When some buffer-modifying action is performed, Neovim implicitly remembers the operator (e.g. d
), motion (e.g. iw
), and some other miscellaneous information. When the dot-repeat command is called, Neovim repeats that operator-motion combination. For example, if we type ci"text<Esc>
, then we replace the inner contents of some double quotes with text
, i.e. "hello world"
→ "text"
. Dot-repeating from here will do the same, i.e. "more samples"
→ "text"
.
The trick is that this also applies to operatorfunc
, which is a special type of operator that calls a user-defined function whenever g@
is run in normal mode. Whenever the g@
operator is called with some motion, the [
and ]
marks are set to the beginning/end of that motion, and whatever function is stored in operatorfunc
gets called.
Note: Plugin-provided functions can be accessed via the v:lua
variable.
Make sure to omit parentheses when requiring the module, e.g.
vim.go.operatorfunc = "v:lua.require'nvim-surround'.normal_callback"
Then dot-repeating the action will use g@[motion]
, re-calling your function with the corresponding motion.
Note: If you're not concerned with the actual motion itself, I would
recommend calling the operatorfunc
with g@l
, as it keeps the cursor in-place
while calling the function.
Understanding this might be difficult, so let's take a look at some examples!
_G.my_count = 0
_G.main_func = function()
my_count = 0
vim.go.operatorfunc = "v:lua.callback"
return "g@l"
end
_G.callback = function()
my_count = my_count + 1
print("Count: " .. my_count)
end
vim.keymap.set("n", "<CR>", main_func, { expr = true })
In the above example, pressing <CR>
in normal mode will reset the counter and call the callback function, printing Count: 1
every time. However, dot-repeating the action will directly call the callback function, skipping the reset. This allows us to differentiate between manually calling the function and calling it by dot-repeating, which can be very useful.
Consider the following example that caches user input and uses it when dot-repeating, querying the user otherwise:
_G.my_name = nil
_G.main_func = function(name)
if not name then
my_name = nil
vim.go.operatorfunc = "v:lua.callback"
return "g@l"
end
print("Your name is: " .. my_name)
end
_G.callback = function()
if not my_name then
my_name = vim.fn.input("Enter your name: ")
end
main_func(my_name)
end
vim.keymap.set("n", "<CR>", main_func, { expr = true })
This is a slightly more complicated example that makes use of a "cache" variable, my_name
. The new control flow is now:
- User hits
<CR>
main_func
is called with no arguments- The cache is cleared
callback
is called- Since there is no cache, the user is queried for input
main_func
is re-called with the name, and it prints
- User hits
.
callback
is called directly- Since the cache hasn't been cleared, user input is skipped
main_func
is re-called with the name, and the old name is printed
Furthermore, if the motion doesn't matter or is known, e.g. g@l
, then we can actually call literally anything in between, including setting/calling other operatorfuncs, and restore dot-repeat capabilities after. All we need to do is reset operatorfunc
to the desired callback function, and then reset the motion. Consider this snippet from my plugin nvim-surround
. It first sets the most recently used action to g@l
(which calls a NOOP function), then sets operatorfunc
to the desired function.
If you liked this gist and/or found it helpful, leave it a ⭐ to let me know! Also feel free to leave any questions, suggestions, or mistakes in the comments below 💖
wow, that one works, though I don't really understand how? The
<C-u>
actually is not even necessary. Moreover, this one does not have proper repeatability either:So it seems there is a difference between
vim.cmd.normal
and:normal
as an expression?while for whatever reason this does work, that only helps me with this simplified example though. For the real use case, my textobject plugin, the actual text objects look something like this:
where using everything as
:normal
expression isn't really feasible, I think?