iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🏃

A DSL for More Convenient Neovim Keymap Configuration

に公開

Introduction

The standard way to set up keymaps in Neovim is using the vim.keymap.set() function. For example, if you want to move the cursor left and right in insert mode like in Emacs, you can write it simply like this:

vim.keymap.set("i", "<C-b>", "<C-g>U<Left>")
vim.keymap.set("i", "<C-f>", "<C-g>U<Right>")

While vim.keymap.set() is convenient, I started noticing some minor frustrations as I used it over time:

  • I often forget to write the first argument (the mode)
  • The psychological cost of adding options like buffer or desc is high, leading to forgetting or skipping them
  • It can be hard to read, sometimes because three string literals are lined up

Therefore, I decided to create my own interface to improve and solve these issues and use it in my own configuration files.

Reviewing the vim.keymap.set() function

vim.keymap.set() is a function that takes 3 to 4 arguments as follows:

-- «options» is optional
vim.keymap.set(«modes», «lhs», «rhs», «options»)

Each argument has the following meaning:

Argument Type Meaning
«modes» string | string[] Target mode(s) (e.g., "n", {"n", "x", "o"})
«lhs» string Key sequence entered by the user (left-hand side)
«rhs» string | function Action to be executed (right-hand side)
«options» table | nil Keymap options (e.g., buffer, desc, expr)

Let's look at some examples.

Example 1: Simple Keymap

vim.keymap.set("i", "<C-b>", "<C-g>U<Left>")

This is the simplest example of a keymap definition mentioned earlier. Evaluating this function call sets a mapping where:

  • In insert mode ("i")

  • Pressing the CTRL key + B ("<C-b>")

  • Executes the command sequence <C-g>U<Left> [1]

Example 2: Setting for Multiple Modes

vim.keymap.set({"n", "x", "o"}, "m)", "])")

If you provide a table as the first argument, the mapping will be defined for multiple modes at once. In this example, to define a keymap corresponding to a motion (cursor movement), the keymap is defined across three modes: normal mode ("n"), visual mode ("x"), and operator-pending mode ("o").

By the way, ]) is a useful motion that moves to the end of the brackets containing the cursor. It's also introduced in the following article, so please read it if you're interested.

https://zenn.dev/vim_jp/articles/2024-06-05-vim-middle-class-features

Example 3: Using Lua Functions

If you specify a Lua function for the right-hand side, the specified function will be executed when the key is triggered.

vim.keymap.set(
    "o",
    "U",
    function()
        for _ = 1, vim.v.count1, 1 do
            vim.fn.search("[A-Z]", "", vim.fn.line("."))
        end
    end,
    {
        desc = [[Select up to just before the next uppercase letter in camelCase]],
    }
)

By specifying a function, you can easily define keymaps with a very high degree of freedom. However, when defining keymaps with Lua functions, it becomes difficult to understand the content of the keymap using the :map command or similar. Therefore, it's recommended to add a natural language description in the desc field of the fourth argument.

Example 4: expr Mapping

With expr mapping, you can dynamically switch the command sequence to be executed based on the conditions when the key is triggered. In expr mapping, you specify a "Lua function that returns a string" or a "string that can be interpreted as a Vim script expression" for the right-hand side.

vim.keymap.set(
    "i",
    "A",
    function()
        if vim.fn.mode(0) == "V" then
            return "<C-v>0o$A"
        else
            return "A"
        end
    end,
    {
        expr = true,
        desc = [[A that allows inserting across multiple lines even in line-wise visual mode]],
    }
)

In this example, when A is pressed in visual mode, the following happens:

  • In line-wise visual mode, it executes <C-v>0o$A (switches to block-wise visual mode and executes A).

  • Otherwise, it executes A (same as default behavior).

Example 5: buffer-local Mapping

By specifying the buffer option, you can specify a mapping that is only valid in a specific buffer.

vim.keymap.set("n", "q", "<Cmd>quit<CR>", {buffer = true})

This example is a slightly aggressive mapping that closes the current window just by pressing q. However, it's not unusual if it's defined only for something like a floating window that is displayed temporarily. Such mappings are often defined for specific file types or special windows.

As seen here, buffer-local mappings are often aggressive, and in that sense, whether the buffer option is present is very important. What would happen if a keymap like "close buffer when q is pressed" set for a terminal buffer were triggered accidentally while coding? Forgetting the buffer option can significantly impair a comfortable editing experience. It's good to be aware that there is an anti-pattern where a mapping that should be buffer-local is set globally.

Challenges with vim.keymap.set()

Having touched upon the features of vim.keymap.set(), let's dive deeper into the challenges I find with it.

  • Often forgetting the first argument (mode)

    • I occasionally found myself writing things like vim.keymap.set("j", "gj") or vim.keymap.set("jj", "<Esc>").
  • High psychological cost of adding options like buffer or desc

    • While buffer = true is an important option indicating that a specific keymap is buffer-local, in the vim.keymap.set() API, it is just one of the optional fields in the fourth argument, and forgetting it doesn't result in an error. It's easy to forget if you're not paying attention.

    • desc is effective for improving readability when the right-hand side is a function type. However, adding a desc field once you've written something like

      vim.keymap.set(
         "o",
         "U",
         function()
             ...
         end
       )
      

      is a bit tedious. You need to add a comma at the end of the function definition and create a new table. Because of this "slight hassle," I've often skipped adding the desc field.

  • Overall script readability

    • In the arguments for vim.keymap.set(), three string literals often line up, and it can be confusing for a moment to distinguish which is the lhs (user input) and which is the rhs (the converted command).

    • When reading a mapping definition, the way you interpret the right-hand side changes significantly depending on whether the expr option is present. Therefore, it's easier to read if the expr option is declared before the definition of the right-hand side, but that's not how vim.keymap.set() is structured.

The Devised mapset Interface

mapset is a wrapper for vim.keymap.set(), and it can be used by doing the following in your configuration files:

  • Place the implementation somewhere in your Neovim configuration directory (implementation described later).

    • Any location that is automatically loaded as a Lua module is fine. I place it in $XDG_CONFIG_HOME/nvim/lua/monaqa/shorthand.lua.
  • Load it at the beginning of your keymap configuration file.

    -- Change the content of require depending on where you placed the implementation
    local mapset = require("monaqa.shorthand").mapset
    

That's all for the preparation. Let's look at some actual examples of keymap settings using mapset.

-- Example 1: Simple keymap
mapset.i("<C-b>") { "<C-g>U<Left>" }

-- Example 2: Setting for multiple modes
mapset.nxo("m)") { "])" }

-- Example 3: Using Lua functions
mapset.o("U") {
    desc = [[Select up to just before the next uppercase letter in camelCase]],
    function()
        for _ = 1, vim.v.count1, 1 do
            vim.fn.search("[A-Z]", "", vim.fn.line("."))
        end
    end,
}

-- Example 4: expr mapping
mapset.x("A") {
    desc = [[A that allows inserting across multiple lines even in line-wise visual mode]],
    expr = true,
    function()
        return logic.ifexpr(vim.fn.mode(0) == "V", "<C-v>0o$A", "A")
    end,
}

As you might have already guessed from the examples, mapset has the following interface:

mapset.«mode» («lhs») {«rhs + options»}

It looks like a strange syntax, but it's 100% valid Lua syntax. You could call it a Domain Specific Language (DSL) using Lua's syntax.

  • «mode» is the field to set the target mode(s), such as i or n. Composite modes like nxo or ic can also be set.
  • «lhs» is where you specify the left-hand side. Usually, a string literal is used.
  • «rhs + options»: A table that combines the right-hand side and options. The same options as vim.keymap.set() can be specified.
    • The element in the [1] field (i.e., the first element if the field name is omitted) is automatically treated as the rhs.

mapset Implementation

---@alias map_body string | fun():nil|string

---@class mapset_opts: vim.keymap.set.Opts
---@field [1] map_body
---@alias mapset_inner fun(t: mapset_opts):nil

--- Shorthand for keymap definitions.
---@param mode string | string[]
---@param buffer_local boolean?
---@return fun(string): mapset_inner
local function mapset_with_mode(mode, buffer_local)
    ---@param lhs string
    ---@return mapset_inner
    return function(lhs)
        ---@param t mapset_opts
        return function(t)
            local body = t[1]
            t[1] = nil
            if t.buffer == nil then
                t.buffer = buffer_local
            end
            vim.keymap.set(mode, lhs, body, t)
        end
    end
end

M.mapset = {
    --- Define keymaps for NORMAL mode.
    n = mapset_with_mode("n"),
    --- Define keymaps for VISUAL mode.
    x = mapset_with_mode("x"),
    --- Define keymaps for OPERATOR-PENDING mode.
    o = mapset_with_mode("o"),
    --- Define keymaps for INSERT mode.
    i = mapset_with_mode("i"),
    --- Define keymaps for COMMAND-LINE mode.
    c = mapset_with_mode("c"),
    --- Define keymaps for SELECT mode.
    s = mapset_with_mode("x"),
    --- Define keymaps for TERMINAL mode.
    t = mapset_with_mode("t"),

    --- NORMAL / VISUAL keymaps (operators, motions, etc.)
    nx = mapset_with_mode { "n", "x" },
    --- VISUAL / SELECT keymaps (VISUAL keymaps using control characters, etc.)
    xs = mapset_with_mode { "x", "s" },
    --- VISUAL / OPERATOR-PENDING keymaps (motions, text objects, etc.)
    xo = mapset_with_mode { "x", "o" },

    --- NORMAL-like mode keymaps (motions, etc.)
    nxo = mapset_with_mode { "n", "x", "o" },
    --- INSERT-like mode keymaps (character input, etc.)
    ic = mapset_with_mode { "i", "c" },

    --- Define iabbrev.
    ia = mapset_with_mode("ia"),
    --- Define cabbrev.
    ca = mapset_with_mode("ca"),

    --- Define keymaps by specifying the mode as a string.
    with_mode = mapset_with_mode,
}

M.mapset_local = {
    --- Define buffer-local keymaps for NORMAL mode.
    n = mapset_with_mode("n", true),
    --- Define buffer-local keymaps for VISUAL mode.
    x = mapset_with_mode("x", true),
    --- Define buffer-local keymaps for OPERATOR-PENDING mode.
    o = mapset_with_mode("o", true),
    --- Define buffer-local keymaps for INSERT mode.
    i = mapset_with_mode("i", true),
    --- Define buffer-local keymaps for COMMAND-LINE mode.
    c = mapset_with_mode("c", true),
    --- Define buffer-local keymaps for SELECT mode.
    s = mapset_with_mode("s", true),
    --- Define buffer-local keymaps for TERMINAL mode.
    t = mapset_with_mode("t", true),

    --- NORMAL / VISUAL buffer-local keymaps.
    nx = mapset_with_mode({ "n", "x" }, true),
    --- VISUAL / SELECT buffer-local keymaps.
    xs = mapset_with_mode({ "x", "s" }, true),
    --- VISUAL / OPERATOR-PENDING buffer-local keymaps.
    xo = mapset_with_mode({ "x", "o" }, true),

    --- NORMAL-like mode buffer-local keymaps.
    nxo = mapset_with_mode({ "n", "x", "o" }, true),
    --- INSERT-like mode buffer-local keymaps.
    ic = mapset_with_mode({ "i", "c" }, true),

    --- Define buffer-local iabbrev.
    ia = mapset_with_mode("ia", true),
    --- Define buffer-local cabbrev.
    ca = mapset_with_mode("ca", true),
}

return M

While it is possible to keep the definition of the mapset table shorter using Lua metatables, I intentionally listed the fields explicitly to allow LSP completion and type checking to work effectively.

Benefits of mapset

mapset is merely a wrapper for the original vim.keymap.set(), and its capabilities are the same. Nevertheless, it offers several advantages over the original.

Preventing forgotten or incorrect mode specifications

By adopting the mapset.«mode» format, you'll never forget the mode name. Since specifying a non-existent mode results in a type error, you can also quickly notice typos.

Furthermore, I intentionally do not provide mapset.v in mapset. Specifying v (equivalent to the :vmap command) is a command to "define keymaps for both VISUAL and SELECT modes[2]"; specifying it when you only intend to define it for VISUAL mode can lead to unexpected side effects. By using the xs field instead of v, it clearly indicates that both VISUAL and SELECT modes are the targets.

Flexible and comfortable option specification

By providing options in the same table as the right-hand side, specifying options has become flexible and easy. Options can now be written before the right-hand side, improving readability—especially when using desc or expr fields, which are often things you want to check before reading the function.

As for the desc field, the psychological hurdle to adding it to an existing keymap has also decreased. For example, if you have a definition like this:

mapset.o ("U") {
  function()
      ...
  end
}

To add a desc to this definition, you only need to add one line:

 mapset.o ("U") {
+  desc = [[Select up to just before the next uppercase letter in camelCase]],
   function()
       ...
   end
 }

Preventing forgotten buffer options

mapset also includes a mechanism for this. When you want to define a buffer-local mapping, simply load mapset_local instead of mapset at the beginning.

local mapset = require("monaqa.shorthand").mapset_local

-- Example 5: buffer-local mapping
mapset.n ("q") { "\u003cCmd\u003equit\u003cCR\u003e" }

Consequently, all keymap definitions using mapset in the following lines will be buffer-local. Since buffer-local keymap settings are usually grouped under ftplugins/ and similar directories, as long as you pay attention to the definition of the mapset variable at the top of such files, you can automatically prevent forgetting the buffer option from then on.

Overall description becomes concise and easy to understand

In the mapset interface, the three blocks—mode, lhs, and rhs + options—are separated by different types of brackets, like mapset.«mode» ("«lhs»") { «rhs + options» }. Therefore, compared to a notation that simply lists function arguments, it has a structure that makes it intuitively easy to understand which part plays which role.

Conclusion

The mapset introduced above is simply the result of pursuing an interface that suits my personal preferences, and it may not be optimal for everyone. Configuration files are for your own benefit. They are a place where you can stick to your preferred notation without worrying about what others think. I encourage everyone to try thinking of and creating the best DSL, refined specifically for yourself.

脚注
  1. I will omit the explanation of the command sequence 003cC-g003eU003cLeft003e. See :h i_CTRL-G_U and :h i_003cLeft003e for details. ↩︎

  2. A mode entered during snippet expansion, etc. ↩︎

Discussion