wandres.dev
KEYMAPS Y COMANDOS · extender la interfaz

Mapeos expresivos: decidir la tecla en tiempo real

Con expr el lado derecho deja de ser un comportamiento y pasa a ser un generador de teclas evaluado en cada pulsación. Cómo funciona replace_keycodes, el patrón de la tecla ambivalente que hace dos cosas según el contexto, y por qué el textlock prohíbe que un mapeo expresivo tenga efectos colaterales.

⏱ 17 min

Un mapeo normal es una constante: la misma tecla produce siempre la misma cosa. Un mapeo expr es una función del estado del editor a una secuencia de teclas, evaluada en el instante exacto de la pulsación. Ese cambio de categoría, de valor a función, es lo que permite que una sola tecla haga lo correcto en cada contexto sin que tú tengas que recordar cuál era el contexto.

🎯 Al terminar esta lección sabrás
  • Explicar qué ocurre con el valor de retorno cuando expr está activo y por dónde vuelve a entrar en el editor.
  • Usar replace_keycodes con criterio y saber cuándo hace falta vim.keycode.
  • Construir el patrón de la tecla ambivalente: un lhs con dos comportamientos según el contexto.
  • Diagnosticar el error E565 y resolverlo con vim.schedule o con un retorno <Cmd>.

Qué hace exactamente expr

Con expr = true, Neovim no ejecuta el rhs: lo evalúa y toma su resultado como si lo hubieras tecleado tú. La cadena devuelta vuelve a entrar por el flujo de entrada y se interpreta con las reglas normales del modo actual. Es una indirección de un solo salto, pero cambia por completo lo que puedes expresar.

-- j y k se mueven por líneas visuales... salvo que haya un conteo
vim.keymap.set({ 'n', 'x' }, 'j', function()
  return vim.v.count > 0 and 'j' or 'gj'
end, { expr = true, silent = true, desc = 'Bajar por línea visual salvo con conteo' })

vim.keymap.set({ 'n', 'x' }, 'k', function()
  return vim.v.count > 0 and 'k' or 'gk'
end, { expr = true, silent = true, desc = 'Subir por línea visual salvo con conteo' })

Este ejemplo, tan modesto en apariencia, resuelve un conflicto real. Sin conteo quieres navegar por lo que ves, así que gj es lo correcto en un párrafo largo con ajuste de línea. Con conteo quieres líneas reales, porque 5j debe coincidir con lo que muestra la columna de números y porque 10j entra en la lista de saltos. Un mapeo estático te obliga a elegir; el expresivo decide por ti en cada pulsación.

Hay una sutileza que causa desconcierto: el resultado obedece la bandera de recursión del propio mapeo. Como vim.keymap.set no remapea por defecto, las teclas devueltas se interpretan de forma nativa. Si quieres que el resultado sea un mapeo <Plug>, necesitas remap = true además de expr = true.

replace_keycodes y la trampa de los termcodes

Una cadena como "<C-n>" son seis caracteres literales, no la pulsación de control-ene. Traducir la notación humana al código interno de terminal es trabajo de nvim_replace_termcodes. La buena noticia: cuando el rhs es una función de Lua y expr está activo, replace_keycodes vale true por defecto y la traducción es automática.

-- Devolver notación legible funciona: replace_keycodes ya está activo
vim.keymap.set('i', '<Tab>', function()
  return vim.fn.pumvisible() == 1 and '<C-n>' or '<Tab>'
end, { expr = true, desc = 'Tab navega el menú de completado si está abierto' })

Si desactivas replace_keycodes, o si el rhs es una cadena de Vimscript en lugar de una función, la traducción deja de ser automática y debes producir tú los bytes correctos. La herramienta moderna es vim.keycode, que sustituye al viejo y verboso nvim_replace_termcodes con tres banderas.

local esc = vim.keycode('<Esc>')             -- un único byte de escape real
local ctrl_v = vim.keycode('<C-v>')          -- útil para comparar con vim.fn.mode()
💡
Añade siempre silent a un mapeo expresivo

La evaluación de un expr puede provocar ecos en la línea de estado y redibujados a medias, sobre todo si tu función llama a vim.fn. Poner silent = true no es cosmética: elimina una fuente clásica de parpadeos y de mensajes fantasma.

El patrón de la tecla ambivalente

Aquí está el verdadero rendimiento del expr. Una tecla ambivalente es aquella cuyo significado se ramifica según una condición del editor, de forma que el usuario no tiene que decidir qué versión quiere: siempre pulsa la misma.

-- i inserta normalmente, pero en una línea vacía la sangra primero
vim.keymap.set('n', 'i', function()
  if vim.api.nvim_get_current_line():match('^%s*$') then
    return '"_cc'
  end
  return 'i'
end, { expr = true, desc = 'Insertar respetando la sangría en líneas vacías' })

-- n y N siempre avanzan y retroceden, sin importar si buscaste con / o con ?
vim.keymap.set({ 'n', 'x', 'o' }, 'n', function()
  return vim.v.searchforward == 1 and 'n' or 'N'
end, { expr = true, desc = 'Siguiente coincidencia, siempre hacia adelante' })

vim.keymap.set({ 'n', 'x', 'o' }, 'N', function()
  return vim.v.searchforward == 1 and 'N' or 'n'
end, { expr = true, desc = 'Coincidencia anterior, siempre hacia atrás' })

El primero elimina un micro-rozamiento diario: pulsar i en una línea en blanco te dejaba en la columna uno, obligándote a tabular a mano. El segundo corrige una asimetría real de Vim, donde el sentido de n depende de si buscaste con / o con ?, algo que tu memoria muscular no puede rastrear. En ambos casos el criterio de diseño es idéntico: la tecla se adapta a la intención más probable en ese contexto, y el caso raro sigue siendo alcanzable.

⌨️

Cuándo sí

Cuando el contexto es observable sin ambigüedad y una de las dos ramas es claramente la intención dominante: hay menú de completado abierto o no, la línea está vacía o no, el buffer es modificable o no.

⌨️

Cuándo no

Cuando las dos ramas son igual de probables, porque entonces la tecla se vuelve impredecible y pagas más en desconfianza de la que ahorras en pulsaciones. En ese caso, dos teclas distintas son mejor diseño.

flowchart TB
P[Pulsas la tecla] --> F[Se evalua la funcion del rhs]
F --> C[Se inspecciona el contexto actual]
C -->|condicion cierta| A[Devuelve la secuencia A]
C -->|condicion falsa| B[Devuelve la secuencia B]
A --> R[El resultado vuelve al flujo de entrada]
B --> R
R --> X[Neovim lo interpreta como teclas normales]
style F fill:#cba6f7,color:#11111b
style R fill:#89b4fa,color:#11111b
style X fill:#a6e3a1,color:#11111b

Textlock: un expr no puede tener efectos

Durante la evaluación de un mapeo expresivo, Neovim está a medio procesar una pulsación y no puede permitir que el texto o la disposición de ventanas cambien bajo sus pies. Por eso el expr se ejecuta bajo textlock, y cualquier intento de modificar el buffer, abrir una ventana o cambiar de buffer aborta con E565: Not allowed to change text or change window.

La restricción no es un defecto: es lo que garantiza que la función sea pura en el sentido que importa, un cálculo que produce teclas y nada más. Cuando de verdad necesitas un efecto, tienes dos salidas limpias.

-- Salida 1: diferir el efecto al siguiente ciclo del bucle de eventos
vim.keymap.set('n', '<leader>x', function()
  vim.schedule(function()
    vim.api.nvim_buf_set_lines(0, 0, 0, false, { '-- generado --' })
  end)
  return '<Ignore>'
end, { expr = true, desc = 'Efecto diferido desde un mapeo expresivo' })

-- Salida 2: devolver teclas que ejecuten el efecto después de la evaluación
vim.keymap.set('n', '<leader>y', function()
  return '<Cmd>lua require("dios.acciones").generar()<CR>'
end, { expr = true, desc = 'El efecto ocurre al interpretar las teclas devueltas' })

La segunda salida es más elegante de lo que parece: <Cmd> se ejecuta después de que la evaluación haya terminado y el textlock se haya levantado, así que el efecto ocurre en un contexto plenamente legal. Y <Ignore> es la forma canónica de decir “no hagas nada con el flujo de entrada”, superior a devolver una cadena vacía cuando quieres además consumir el conteo pendiente.

Convertir una tecla en una función del contexto es dejar de programar el editor y empezar a programar la intención

Todo el poder del expr cabe en un cambio de tipo: un mapeo corriente es un valor del tipo secuencia de teclas, y un mapeo expresivo es un valor del tipo estado del editor hacia secuencia de teclas. Esa promoción de constante a función es exactamente el mismo salto que separa una macro de texto de un procedimiento, y trae consigo las mismas consecuencias. La primera es la composicionalidad: como el resultado vuelve a entrar por el flujo de entrada en lugar de ejecutarse aparte, sigue estando sujeto a conteos, a registros, a la repetición con el punto y al operador pendiente. Un expr que devuelve cc es indistinguible, para el resto del sistema, de haber tecleado cc, de modo que la maquinaria de deshacer, la de repetición y la gramática de operadores siguen funcionando gratis. Esa es la razón profunda por la que el expr produce atajos que se sienten nativos mientras que un plugin que ejecuta funciones directamente produce atajos que se sienten pegados con cinta. La segunda consecuencia es epistemológica: al mover la decisión del usuario al editor, reduces la carga cognitiva de recordar en qué estado estás. No decides si toca gj o j, si toca n o N, si toca i o cc; declaras una vez la regla y el editor la aplica sin que vuelvas a pensarla. Y la tercera es la disciplina que el textlock te impone: como no puedes tener efectos, te obliga a separar limpiamente el cálculo de la decisión de la ejecución del efecto, que es justo la higiene que un editor extensible necesita para no volverse impredecible. Un expr bien escrito no añade una tecla a tu vocabulario: le añade contexto al vocabulario que ya tenías.

📝
Lo esencial

Con expr = true el rhs se evalúa y su resultado se inyecta como teclas, obedeciendo la bandera de recursión del propio mapeo. Con una función de Lua, replace_keycodes está activo y puedes devolver notación legible como <C-n>; en otro caso usa vim.keycode. Añade silent = true para evitar ecos y parpadeos. El patrón de la tecla ambivalente ramifica el significado según el contexto para que el usuario nunca elija. La evaluación ocurre bajo textlock, así que no puedes modificar texto ni ventanas: difiere con vim.schedule o devuelve un <Cmd> que actúe después.

⚔️ Teclas que piensan
  1. Implementa j y k sensibles al conteo y comprueba que 5j sigue registrando un salto en :jumps mientras que j a secas navega por línea visual.
  2. Escribe un mapeo de <CR> en insertar que acepte el elemento del menú de completado si está visible y, si no, inserte un salto de línea normal.
  3. Construye una tecla ambivalente propia: que <leader>q cierre la ventana si hay más de una y descargue el buffer si es la única.
  4. Provoca deliberadamente un E565 modificando el buffer dentro de un mapeo expresivo, y arréglalo primero con vim.schedule y después devolviendo un <Cmd>.
  5. Desactiva replace_keycodes en tu mapeo de <Tab>, observa cómo se insertan los caracteres literales y repáralo con vim.keycode.