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. Qué hace replace_keycodes y cuándo entra vim.keycode, el patrón de la tecla ambivalente que hace lo correcto según el contexto, las restricciones reales del textlock — E565, sin :normal, sin cambiar de buffer — y las dos salidas limpias: vim.schedule y devolver un Cmd.
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. Ya usaste uno sin saberlo: el <Tab> que navega el menú de completado en la lección 3.2 es exactamente esto.
- Explicar qué ocurre con el valor de retorno cuando
exprestá activo y por dónde vuelve a entrar en el editor. - Usar
replace_keycodescon criterio y saber cuándo hace faltavim.keycode. - Construir el patrón de la tecla ambivalente: un
lhscon dos comportamientos según el contexto. - Enumerar las restricciones reales del textlock y diagnosticar el error
E565. - Resolver los efectos colaterales con
vim.scheduleo con un retorno<Cmd>…<CR>.
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 conteovim.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 relativos. Un mapeo estático te obliga a elegir; el expresivo decide en cada pulsación. Fíjate en que aquí se usa vim.v.count y no vim.v.count1: la pregunta es «¿hubo conteo?», y count responde 0 cuando no lo hubo mientras que count1 respondería 1 y te dejaría ciego ante la diferencia.
Dos sutilezas del mecanismo que conviene fijar ya. La primera: 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 alcance un mapeo <Plug>, necesitas remap = true además de expr = true. La segunda viene avisada en :h map-expression y sorprende a todo el mundo: la expresión puede evaluarse mientras Neovim busca typeahead, es decir, antes de que el comando anterior haya terminado de ejecutarse. Si tu función consulta el estado del editor — la columna del cursor, el modo — puede leer el estado previo al comando que la precede en una secuencia. Es una razón más para mantener estas funciones pequeñas y puras.
replace_keycodes y la trampa de los termcodes
Una cadena como "<C-n>" son cinco caracteres literales, no la pulsación de control-ene. Traducir la notación humana al código interno del terminal es un paso explícito, y aquí vim.keymap.set vuelve a elegir bien el valor por defecto: cuando el rhs es una función de Lua y expr está activo, la opción replace_keycodes vale true y la traducción es automática.
-- Devolver notación legible funciona: replace_keycodes ya está activo.-- Este es el mapeo de la lección 3.2, que ahora entiendes por dentro:vim.keymap.set('i', '<Tab>', function() return vim.fn.pumvisible() == 1 and '<C-n>' or '<Tab>'end, { expr = true, desc = 'Completado: siguiente' })Si desactivas replace_keycodes, o si construyes teclas fuera de un mapeo — para nvim_feedkeys, para comparar con vim.fn.mode() —, la herramienta moderna es vim.keycode, que sustituye al verboso nvim_replace_termcodes con sus tres banderas:
local esc = vim.keycode('<Esc>') -- el byte de escape reallocal cv = vim.keycode('<C-v>') -- p. ej. para detectar el visual de bloque en mode()Añade silent a los mapeos expresivos con dos puntos a la vista
La evaluación de un expr puede provocar ecos en la línea de estado y redibujados a medias, sobre todo si la función llama a vim.fn. 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 sangra primerovim.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; "_cc rehace la línea con la sangría correcta y usa el registro agujero negro para no ensuciar lo copiado. 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. Fíjate en que los tres mapeos incluyen el modo o: así dn sigue funcionando como operación de borrado hasta la coincidencia, con la dirección ya corregida.
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, hubo conteo 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 lo que ahorras en pulsaciones. En ese caso, dos teclas distintas son mejor diseño.
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 editor cambie bajo sus pies. La ayuda (:h map-expression) enumera las restricciones con precisión, y merece conocerlas tal cual son:
- Cambiar el texto del buffer está bloqueado por el textlock y aborta con
E565. - Editar otro buffer está bloqueado.
- El comando
:normalestá bloqueado. - Mover el cursor está permitido, pero se restaura al terminar la evaluación.
- Si cambias la línea de comandos, el texto y la posición anteriores se restauran.
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 eventosvim.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ónvim.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 — la ayuda lo dice tal cual: si quieres que el mapeo haga algo de lo prohibido, «deja que lo hagan los caracteres devueltos». Y <Ignore> es la forma canónica de decir «no hagas nada con el flujo de entrada»: superior a devolver la cadena vacía cuando además quieres consumir el conteo pendiente o forzar un redibujado. vim.schedule, por su parte, es la primera aparición de la regla de oro que la lección 12.3 desarrolla a fondo: cuando el contexto te prohíbe actuar, apunta la acción para el siguiente ciclo del bucle de eventos.
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 el mismo salto que separa una macro de texto de un procedimiento, y trae 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 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 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.
vim.v.count vale 0 cuando no hubo conteo: es la condición observable que separa «navegar leyendo» de «saltar contando». Con count1 no podrías distinguir los dos casos.
<C-y> es «aceptar» en el menú nativo, como viste en la 3.2. La misma tecla, dos significados, cero decisiones tuyas: el menú está abierto o no lo está.
vim.v.searchforward recuerda la dirección de la última búsqueda. Mapea también en o para que dn borre hasta la siguiente coincidencia con la dirección ya corregida.
<Ignore> consume la pulsación, el conteo pendiente y fuerza el paso por el bucle principal (útil si tu función abrió algo que necesita redibujado). La cadena vacía también «no hace nada», pero deja más flecos.
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, traduce con vim.keycode. Añade silent = true para evitar ecos. 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: sin cambiar texto, sin cambiar de buffer, sin :normal, cursor restaurado; difiere con vim.schedule o devuelve un <Cmd>…<CR> que actúe después. Y recuerda que la evaluación puede ocurrir durante el typeahead: funciones pequeñas y puras.
Teclas que piensan
- Implementa
jyksensibles al conteo y comprueba la diferencia:ja secas navega por línea visual en un párrafo con ajuste, y5jbaja exactamente las cinco líneas que anuncia la columna de números relativos. - Escribe el
<CR>ambivalente del drill y verifica las dos ramas con el menú de completado nativo de la 3.2 abierto y cerrado. - Construye una tecla ambivalente propia: que
<leader>qcierre la ventana si hay más de una y descargue el buffer si es la única. Decide qué rama es la dominante y justifícalo. - Provoca deliberadamente un
E565modificando el buffer dentro de un mapeo expresivo, y arréglalo dos veces: primero convim.schedule, después devolviendo un<Cmd>…<CR>. - Desactiva
replace_keycodesen un mapeo de prueba, observa los caracteres literales insertados y repáralo convim.keycodesin reactivar la opción.