Skip to content

Instantly share code, notes, and snippets.

@kylechui
Last active November 19, 2025 06:00
Show Gist options
  • Select an option

  • Save kylechui/a5c1258cd2d86755f97b10fc921315c3 to your computer and use it in GitHub Desktop.

Select an option

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 💖

@numEricL
Copy link

numEricL commented Nov 19, 2025

This gist doesn't quite let anything be repeated since <expr> map limits what may be executed. As @chrisgrieser points out, executing the normal command here is illegal. The <expr> map should therefore only be used to capture the initial state and the callback be used for all other functionality. For full generality a second callback can be used where the first callback is used for the first execution, and the second callback is used for dot repeat. Just have the first callback set operatorfunc to the second callback right before it exits.

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