Skip to content

Instantly share code, notes, and snippets.

@kylechui
Last active April 8, 2024 08:20
Show Gist options
  • Star 58 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save kylechui/a5c1258cd2d86755f97b10fc921315c3 to your computer and use it in GitHub Desktop.
Save kylechui/a5c1258cd2d86755f97b10fc921315c3 to your computer and use it in GitHub Desktop.
A basic overview of how to manage dot-repeating in your Neovim plugin, as well as manipulate it to "force" what action is repeated.

Adding dot-repeat to your Neovim plugin

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.

The Basics

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".

Using operatorfunc

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.

Examples

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.

Final Thoughts

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 💖

@chrisgrieser
Copy link

Thank you for this example!

Could you also explain how to make text objects dot repeatable? The example works fine for normal mode, but I can't figure out how to get custom text objects being being repeated correctly.

@kylechui
Copy link
Author

kylechui commented Jan 8, 2023

@chrisgrieser Since the dot-repeat command can reproduce g@[motion] commands, you would utilize that in order to dot-repeat any sort of motion. Thus you would work within the scope of your opfunc in order to get the intended behavior. For example, if opfunc=v:lua.callback, then if you did g@iw it would call callback with the appropriate marks set. Then if you moved the cursor and hit ., it would call the callback function again, but this time with the marks set for the word that the cursor is sitting on.

@chrisgrieser
Copy link

chrisgrieser commented Jan 9, 2023

I don't really get it :(
how exactly would an implementation look like? So take for example this implementation of a simple text object:

function linewiseRestOfParagraph()
	vim.cmd.normal { "V}k", bang = true }
end

vim.keymap.set({ "o", "x" }, "r", linewiseRestOfParagraph)

The text obejct works fine, but when dr deletes 3 lines, and I repeat it with ., it deletes 3 lines again (the result of the text object), even if the second time the rest of the paragraph is 5 lines instead of 3. Therefore the need to set up dot-repeatability.


So I following the example you provided in this gist, I tried this:

function dotrepeat()
	g.my_operator = vim.v.operator
	vim.o.operatorfunc = "v:lua.linewiseRestOfParagraph"
	return "g@"
end

function linewiseRestOfParagraph()
	vim.cmd.normal { g.my_operator .. "V}k", bang = true }
end

vim.keymap.set({ "o", "x" }, "r", dotrepeat, { expr = true })

which seems to do nothing. I assume it's because it uses g@ as object and not as operator, but I am not sure how to implement the text object repetition without creating my own delete/change/etc. functions?

@kylechui
Copy link
Author

I'm not entirely sure why your original code doesn't work, but try this for your particular issue; the dot-repeating I described above is primarily for if you want to do some "special" operations on a motion:

vim.keymap.set({ "o", "x" }, "r", ":<C-U>normal! V}k<CR>")

@chrisgrieser
Copy link

chrisgrieser commented Jan 10, 2023

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:

function linewiseRestOfParagraph()
	vim.cmd([[normal! V}k]])
end

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:

function someTextobj()
	-- ... (calculate selectionStart and selectionEnd)
	setCursor(0, selectionStart)
	vim.cmd.normal { "v", bang = true }
	setCursor(0, selectionEnd)
end

vim.keymap.set({ "o", "x" }, "r", someTextobj)

where using everything as :normal expression isn't really feasible, I think?

@kylechui
Copy link
Author

To be honest, I'm not actually 100% sure how omap works; I don't know if you need to use opfunc for this at all. I would have assumed that once you had the keymap set up, dot-repeating would "just work properly". The little blurb that I originally wrote was supposed to try and help with the problem of dot-repeating a custom action over some motion, rather than define a custom motion.

@chrisgrieser
Copy link

Meh, too bad. Thanks for the effort anyway! Hope I'll find info on it somewhere else

@xsh005
Copy link

xsh005 commented Jan 17, 2024

A wonderful gem! Thank you for sharing this :D

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment