wandres.dev
KEYMAPS Y COMANDOS · extender la interfaz

vim.keymap.set a fondo: modos, callbacks y opciones

La primitiva sobre la que se apoya todo tu vocabulario. Los cuatro argumentos y la asimetría histórica que corrige, la diferencia real entre los modos v, x, s y o, por qué un callback de Lua es estructuralmente superior a una cadena de teclas, y qué modifica exactamente cada opción: desc, silent, expr y buffer.

⏱ 18 min

Cada atajo que añades a tu configuración pasa por la misma puerta. vim.keymap.set no es azúcar sintáctico sobre nvim_set_keymap: invierte un valor por defecto heredado de Vimscript, acepta funciones de Lua donde la API solo admitía cadenas y normaliza la notación de modos. Conocer sus cuatro argumentos con precisión quirúrgica es la diferencia entre coleccionar atajos sueltos y diseñar un lenguaje.

🎯 Al terminar esta lección sabrás
  • Leer la firma vim.keymap.set(modo, lhs, rhs, opts) y explicar por qué remap es falso por defecto.
  • Elegir el modo correcto entre n, i, x, s, v, o, t y c, evitando la trampa clásica de v.
  • Sustituir cadenas de teclas por callbacks de Lua y saber cuándo su valor de retorno importa.
  • Aplicar desc, silent, expr y buffer sabiendo qué campo alteran en la tabla interna de mapeos.

La firma y la asimetría que corrige

La función toma cuatro argumentos: el modo (una cadena o una lista de cadenas), el lhs o secuencia de teclas que pulsas, el rhs o comportamiento resultante, y una tabla de opciones. En Vimscript, :map es recursivo por definición histórica: el resultado vuelve a pasar por la tabla de mapeos, y por eso existía :noremap. nvim_set_keymap heredó ese defecto y exige noremap = true de forma explícita. vim.keymap.set invierte la carga de la prueba: nunca remapea salvo que se lo pidas con remap = true.

-- API cruda: heredera de Vimscript, recursiva salvo que digas lo contrario
vim.api.nvim_set_keymap('n', '<leader>w', ':write<CR>', { noremap = true, silent = true })

-- Envoltorio de Lua: no recursivo por defecto
vim.keymap.set('n', '<leader>w', '<Cmd>write<CR>', { silent = true, desc = 'Guardar' })

-- La excepción legítima: encadenar con un mapeo <Plug> de un plugin exige recursión
vim.keymap.set('n', 'ys', '<Plug>(nvim-surround-normal)', { remap = true, desc = 'Envolver' })

Un mapeo <Plug> es, por construcción, inalcanzable desde el teclado: nadie puede teclear <Plug>. Solo existe para ser el destino de otro mapeo, y llegar hasta él requiere recursión. Ese es prácticamente el único caso donde remap = true está justificado.

Fíjate también en <Cmd>write<CR> frente a :write<CR>. Con dos puntos entras de verdad en la línea de comandos: cambias de modo, disparas CmdlineEnter y CmdlineLeave, y en modo visual Neovim te inserta el rango '<,'> automáticamente. Con <Cmd> el comando se ejecuta sin abandonar el modo actual y sin efectos colaterales. Salvo que quieras justo ese rango automático, <Cmd> es siempre la opción correcta.

Modos: la tabla que casi todos usan mal

Cadena Modos que abarca
n Normal
i Insertar
x Visual (solo visual)
s Selección
v Visual y selección
o Operator-pending
t Terminal
c Línea de comandos
"" Normal, visual, selección y operator-pending
"!" Insertar y línea de comandos

El error más extendido de toda la comunidad es escribir v cuando se quiere decir x. El modo selección existe para que, al saltar a un hueco de un snippet, cualquier tecla imprimible sustituya el texto seleccionado, tal como haría un editor convencional. Si mapeas p en modo v, ese mapeo también se instala en selección y romperás la escritura sobre los huecos del snippet. La regla es tajante: mapea siempre en x salvo que sepas explícitamente que quieres tocar el modo selección.

El modo o es el otro gran desaprovechado. Un mapeo en operator-pending define un nuevo complemento gramatical: si mapeas ir en o y en x, acabas de crear un objeto de texto que funciona con todos los verbos nativos a la vez, y dir, cir e yir pasan a existir sin escribir una línea más.

-- Un objeto de texto nuevo: el contenido íntegro del buffer
vim.keymap.set({ 'o', 'x' }, 'ie', function()
  vim.cmd('normal! ggVG')
end, { desc = 'Objeto de texto: el archivo entero' })

Con esas cuatro líneas, die, yie, cie y =ie existen de golpe. Esa es la aritmética de la gramática: los mapeos suman, los complementos y los operadores multiplican.

flowchart TB
T[Tecla pulsada] --> L[Existe mapeo local de buffer]
L -->|si| E[Se ejecuta ese y gana siempre]
L -->|no| G[Existe mapeo global]
G -->|si| E
G -->|no| D[Comportamiento nativo de Neovim]
style E fill:#a6e3a1,color:#11111b
style D fill:#89b4fa,color:#11111b
style T fill:#cba6f7,color:#11111b

Callbacks de Lua en lugar de cadenas

Cuando el rhs es una función, Neovim no guarda ninguna secuencia de teclas: almacena la referencia en el campo callback de la tabla de mapeos, y nvim_get_keymap devolverá rhs vacío. Esto tiene consecuencias que van mucho más allá de la comodidad. Puedes cerrar sobre variables locales, no tienes que escapar termcodes, no atraviesas la frontera Lua-Vimscript-Lua que impone <Cmd>lua ...<CR>, y un error te da una traza legible en lugar de un E5108 desnudo.

vim.keymap.set('n', '<leader>bd', function()
  local buf = vim.api.nvim_get_current_buf()
  if vim.bo[buf].modified then
    vim.notify('El buffer tiene cambios sin guardar', vim.log.levels.WARN)
    return
  end
  vim.api.nvim_buf_delete(buf, {})
end, { desc = 'Cerrar el buffer si está guardado' })

Dentro del callback tienes acceso al contexto completo de la pulsación: vim.v.count1 te da el conteo que el usuario tecleó antes de la tecla (con 1 como valor por defecto), vim.v.register el registro que precedía con comillas, y vim.fn.mode() el modo exacto. El valor de retorno se ignora por completo salvo que la opción expr esté activa; es la fuente de errores más común al empezar.

⚠️
Textlock: no todo se puede hacer en cualquier momento

Un callback invocado bajo textlock no puede modificar el texto ni cambiar de ventana, y aborta con E565. Ocurre en mapeos expr, dentro de ciertos autocomandos y en callbacks de la interfaz. La salida canónica es vim.schedule(function() ... end), que difiere el efecto al siguiente ciclo del bucle de eventos, cuando el bloqueo ya se ha levantado.

Las opciones que cambian el comportamiento

⌨️

desc

Una frase en lenguaje natural. Aparece en :map, la consume which-key y la lee nvim_get_keymap. Convierte tu tabla de mapeos en una base de datos consultable: sin desc tus atajos son opacos incluso para ti.

⌨️

silent

Equivale a prefijar la ejecución con :silent. Evita el eco del comando en la línea de estado, pero no silencia los errores. Imprescindible en mapeos que ejecutan comandos con dos puntos.

⌨️

expr

El rhs deja de ser el comportamiento y pasa a ser un generador de teclas: se evalúa y su resultado se inyecta como si lo hubieras tecleado. Es el tema de la lección siguiente.

⌨️

buffer

Restringe el mapeo a un buffer concreto, con 0 para el actual. Los mapeos locales tienen prioridad absoluta sobre los globales, lo que los hace ideales para atajos que solo tienen sentido en un lenguaje o en una ventana efímera.

Quedan cuatro opciones menores pero decisivas. nowait impide que Neovim espere más teclas cuando tu lhs es prefijo de otro mapeo. unique provoca un error E227 si el mapeo ya existía, y colocarlo en tu configuración convierte cualquier colisión silenciosa en un fallo ruidoso al arrancar. replace_keycodes solo tiene sentido junto a expr. Y vim.keymap.del retira un mapeo, incluidos los que Neovim instala de fábrica.

-- Renunciar a un mapeo nativo que quieres reservar para otra cosa
vim.keymap.del('n', 'grn')

-- Mapeo local instalado cuando un cliente LSP se engancha al buffer
vim.api.nvim_create_autocmd('LspAttach', {
  callback = function(ev)
    vim.keymap.set('n', '<leader>cf', function()
      vim.lsp.buf.format({ async = true })
    end, { buffer = ev.buf, desc = 'Formatear con el LSP' })
  end,
})
Un mapeo no es un atajo: es una entrada en una base de datos viva

El salto conceptual que separa a quien acumula atajos de quien diseña un editor es entender que la tabla de mapeos es datos consultables en tiempo de ejecución, no una lista muerta de asignaciones. vim.api.nvim_get_keymap('n') te devuelve, ahora mismo, todos los mapeos normales como una tabla de Lua con lhs, rhs, callback, desc, buffer, expr, silent y noremap. Eso significa que tu configuración puede razonar sobre sí misma: generar una chuleta, detectar colisiones antes de que te muerdan, comprobar que ningún mapeo carece de desc, o construir un menú dinámico. Which-key no hace magia; simplemente lee esa tabla y ordena lo que encuentra, y por eso un desc ausente se traduce en un hueco en su ventana. La segunda mitad de la idea es la introspección al revés: :verbose nmap <leader>f te dice el archivo y la línea exactos donde se fijó ese mapeo por última vez, lo que resuelve en dos segundos el misterio de por qué un plugin te ha robado una tecla. Cuando interiorizas que cada llamada a vim.keymap.set inserta un registro estructurado en una base de datos que tú puedes leer, auditar y transformar, dejas de configurar Neovim y empiezas a programarlo. La disciplina que sigue es evidente: desc en absolutamente todos los mapeos, unique mientras diseñas, y una función que vuelque la tabla cuando quieras entender el estado real de tu editor en lugar de recordarlo de memoria.

📝
Lo esencial

vim.keymap.set(modo, lhs, rhs, opts) no remapea por defecto, al contrario que la API cruda, y remap = true solo se justifica para alcanzar mapeos <Plug>. Usa x en lugar de v salvo que quieras tocar el modo selección, y recuerda que o te permite inventar objetos de texto. Un rhs de función se guarda como callback y su retorno se ignora salvo con expr. desc documenta, silent calla el eco, buffer acota el alcance con prioridad sobre lo global, y unique convierte las colisiones en errores visibles. Prefiere <Cmd> frente a los dos puntos para no cambiar de modo.

⚔️ Domina la primitiva
  1. Escribe un mapeo <leader>w que guarde el buffer, con desc y silent, y comprueba con :verbose nmap <leader>w que la traza apunta a tu archivo.
  2. Mapea p en modo x y después en modo v; expande un snippet, salta a un hueco y describe qué diferencia observas al teclear.
  3. Convierte un mapeo tuyo de cadena a callback de Lua y haz que respete vim.v.count1, por ejemplo bajando esa cantidad de líneas.
  4. Instala un mapeo con unique = true que colisione a propósito con uno existente y lee el error E227 que aparece al arrancar.
  5. Recorre vim.api.nvim_get_keymap('n') y lista por consola todos los mapeos que empiezan por tu <leader> y no tienen desc.