iTranslated by AI
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
bufferordescis 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.
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 executesA). -
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")orvim.keymap.set("jj", "<Esc>").
- I occasionally found myself writing things like
-
High psychological cost of adding options like
bufferordesc-
While
buffer = trueis an important option indicating that a specific keymap is buffer-local, in thevim.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. -
descis effective for improving readability when the right-hand side is afunctiontype. However, adding adescfield once you've written something likevim.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
descfield.
-
-
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 thelhs(user input) and which is therhs(the converted command). -
When reading a mapping definition, the way you interpret the right-hand side changes significantly depending on whether the
exproption is present. Therefore, it's easier to read if theexproption is declared before the definition of the right-hand side, but that's not howvim.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.
- Any location that is automatically loaded as a Lua module is fine. I place it in
-
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 asiorn. Composite modes likenxooriccan 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 asvim.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 therhs.
- The element in the
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.
Discussion