wandres.dev
KEYMAPS Y COMANDOS · extender la interfaz

Operadores propios: operatorfunc y la gramática extendida

La opción operatorfunc y la tecla g@ te permiten inventar verbos nuevos que aceptan cualquier movimiento y cualquier objeto de texto, igual que los nativos. El protocolo de invocación, cómo leer la región con las marcas de corchete, el tratamiento de char, line y block, y por qué el patrón expr regala la repetición con el punto.

⏱ 20 min

Vim tiene una docena escasa de operadores: d, c, y, gu, gU, >, <, =, !. Todos comparten una propiedad que parecía privilegio del núcleo: aceptan cualquier movimiento y cualquier objeto de texto sin conocerlos de antemano. operatorfunc rompe ese privilegio y te deja añadir verbos al idioma, con dominio completo sobre la gramática y con repetición mediante el punto incluida.

🎯 Al terminar esta lección sabrás
  • Describir el protocolo de g@: quién espera el movimiento, cuándo se invoca la función y con qué argumento.
  • Leer la región afectada mediante las marcas de corchete y decidir según el tipo char, line o block.
  • Aplicar el patrón expr que fija operatorfunc y devuelve g@ en la misma función.
  • Ofrecer la variante visual del operador y explicar por qué la repetición con el punto sale gratis.

El protocolo de g@

g@ es una tecla que se comporta como un operador: al pulsarla, Neovim entra en modo operator-pending y espera un movimiento o un objeto de texto. Cuando lo recibe, calcula la región afectada, coloca las marcas '[ y '] en sus extremos, e invoca la función indicada en la opción operatorfunc pasándole un único argumento: la cadena char, line o block según cómo se determinó la región.

Es decir, tú no analizas movimientos ni objetos de texto. Neovim resuelve toda la gramática y te entrega el resultado ya masticado: dos marcas y un tipo. Tu función solo tiene que actuar sobre esa región.

sequenceDiagram
participant U as Usuario
participant N as Neovim
participant F as Tu funcion
U->>N: pulsa g arroba
N->>N: entra en operator pending
U->>N: teclea un movimiento o un objeto
N->>N: calcula la region y fija las marcas de corchete
N->>F: invoca operatorfunc con char line o block
F->>N: lee las marcas y modifica el buffer
N->>U: guarda la accion para el punto

Escribir el operador

El patrón idiomático mete dos responsabilidades en la misma función y distingue entre ellas por el argumento. Si llega nil, es que la estás invocando desde el mapeo: fijas operatorfunc y devuelves g@. Si llega una cadena, es Neovim quien te llama tras resolver el movimiento, y toca trabajar.

-- lua/dios/ops.lua
local M = {}

local function ordenar_rango(ini, fin)
  local lineas = vim.api.nvim_buf_get_lines(0, ini - 1, fin, false)
  table.sort(lineas)
  vim.api.nvim_buf_set_lines(0, ini - 1, fin, false, lineas)
end

function M.ordenar(kind)
  if kind == nil then
    vim.o.operatorfunc = "v:lua.require'dios.ops'.ordenar"
    return 'g@'
  end
  ordenar_rango(
    vim.api.nvim_buf_get_mark(0, '[')[1],
    vim.api.nvim_buf_get_mark(0, ']')[1]
  )
end

function M.ordenar_visual()
  ordenar_rango(
    vim.api.nvim_buf_get_mark(0, '<')[1],
    vim.api.nvim_buf_get_mark(0, '>')[1]
  )
end

return M

La cadena v:lua.require'dios.ops'.ordenar es una forma especial que Neovim reconoce: permite apuntar operatorfunc a un módulo sin ensuciar el espacio global. Como operatorfunc es una opción global, esta indirección evita que dos operadores tuyos se pisen entre sí.

local ops = require('dios.ops')

vim.keymap.set('n', 'gs', ops.ordenar, { expr = true, desc = 'Ordenar las líneas del movimiento' })
vim.keymap.set('n', 'gss', function() return ops.ordenar() .. '_' end,
  { expr = true, desc = 'Ordenar la línea actual' })
vim.keymap.set('x', 'gs', ':<C-u>lua require("dios.ops").ordenar_visual()<CR>',
  { silent = true, desc = 'Ordenar la selección' })

Tres detalles con mucha densidad. El primero: gss devuelve g@_, y _ es el movimiento lineal que abarca la línea actual, replicando la convención nativa de que duplicar el operador lo aplica a la línea. El segundo: la variante visual usa dos puntos con <C-u> porque al abandonar el modo visual se fijan las marcas '< y '>, y <C-u> limpia el rango que Neovim inserta solo. El tercero: el mapeo normal es expr porque la función devuelve teclas, no ejecuta el efecto.

Leer la región según el tipo

Para un operador lineal basta con el número de línea de las marcas, como en el ejemplo anterior. Para uno que actúa carácter a carácter necesitas también las columnas, y ahí entra getregion, que resuelve de una vez el trabajo sucio de recortar la primera y la última línea.

function M.contar(kind)
  if kind == nil then
    vim.o.operatorfunc = "v:lua.require'dios.ops'.contar"
    return 'g@'
  end
  local tipos = { char = 'v', line = 'V', block = vim.keycode('<C-v>') }
  local trozos = vim.fn.getregion(vim.fn.getpos("'["), vim.fn.getpos("']"), { type = tipos[kind] })
  local texto = table.concat(trozos, '\n')
  local palabras = select(2, texto:gsub('%S+', ''))
  vim.notify(('%d líneas, %d palabras, %d bytes'):format(#trozos, palabras, #texto))
end

El tipo block merece atención aparte. Con una selección rectangular, las marcas de corchete señalan esquinas opuestas, no principio y fin de un texto continuo, y getregion devuelve una lista con un fragmento por línea. Si tu operador no tiene sentido en bloque, lo honesto es detectarlo y avisar en lugar de producir un resultado silenciosamente incorrecto.

⌨️

char

La región va de una columna a otra y puede empezar y terminar a media línea. Necesitas las cuatro coordenadas de las marcas, no solo los números de línea.

⌨️

line

La región abarca líneas completas. Basta con el primer elemento de cada marca y puedes trabajar cómodamente con nvim_buf_get_lines.

⌨️

block

Rectángulo definido por dos esquinas. Cada línea aporta un fragmento independiente y el ancho puede variar si alguna línea es más corta que el bloque.

⚠️
La marca de cierre es inclusiva y apunta a bytes

La marca '] señala el último byte incluido, no la posición siguiente. Al traducirla a los índices exclusivos que espera nvim_buf_set_text hay que sumar uno, y con texto multibyte ese uno debe ser la longitud completa del carácter, no un byte suelto: partir un carácter UTF-8 por la mitad produce un buffer corrupto. Si puedes, delega en getregion y getregionpos, que ya manejan esa aritmética.

La repetición sale gratis

Aquí está el dividendo que justifica todo el rodeo. Cuando Neovim ejecuta un g@, registra la secuencia completa —operador más movimiento— en el mecanismo de repetición. Eso significa que . vuelve a aplicar tu operador con el mismo movimiento, y que 3. lo repite tres veces, sin que hayas escrito una línea de código para ello. Un mapeo corriente que llamase directamente a tu función perdería esa propiedad y necesitaría muletas externas.

La segunda propiedad heredada es la composición con conteos y con toda la gramática. Como Neovim resuelve el movimiento antes de llamarte, tu operador funciona automáticamente con 2j, con ap, con i(, con t, y con cualquier objeto de texto que instale un plugin después de que tú escribieras el operador. No hay acoplamiento: tú declaras el verbo, otros declaran los complementos, y la gramática los combina.

Un operador propio no añade un atajo: multiplica tu vocabulario por el número de movimientos que existen

La aritmética de la gramática de Vim explica por qué un operador vale infinitamente más que un mapeo, y conviene hacerla explícita. Si tienes doce operadores y unos sesenta complementos entre movimientos y objetos de texto, no dispones de setenta y dos acciones sino de setecientas veinte, porque la gramática es un producto cartesiano y no una suma. Cuando añades un mapeo corriente, sumas uno. Cuando añades un operador, sumas una fila entera de la tabla: sesenta acciones nuevas que ni siquiera has enumerado, y que seguirán creciendo cada vez que instales un plugin de objetos de texto, sin que tú toques nada. Esa asimetría multiplicativa es la razón por la que merece la pena el coste conceptual de operatorfunc, y también la razón por la que los plugins que más han perdurado en el ecosistema —envolver con delimitadores, comentar, intercambiar, reemplazar con registro— son todos operadores y no colecciones de atajos. Hay además una consecuencia arquitectónica que se suele pasar por alto: al delegar la resolución del movimiento en el núcleo, tu código queda desacoplado por completo del catálogo de complementos, de modo que un objeto de texto escrito por otra persona el año que viene funcionará con tu verbo de hoy sin que nadie coordine nada. Es exactamente el mismo principio de diseño que hace valiosa una interfaz frente a una implementación cerrada: no defines lo que se puede combinar, defines cómo se combina y dejas que el espacio de combinaciones crezca por su cuenta. Y por si eso fuera poco, el protocolo te devuelve gratis la repetición con el punto, la aceptación de conteos y la integración con deshacer, tres cosas que un mapeo que invoca una función directamente tiene que emular con esfuerzo y siempre acaba emulando mal. Cuando entiendes esto, dejas de preguntarte qué tecla libre te queda y empiezas a preguntarte qué verbo le falta a tu idioma.

📝
Lo esencial

g@ espera un movimiento, fija las marcas '[ y '] e invoca operatorfunc con char, line o block. El patrón canónico usa una sola función que, si recibe nil, fija la opción y devuelve g@ desde un mapeo expr, y si recibe un tipo, actúa sobre la región. Apunta operatorfunc a un módulo con la forma especial de v:lua para evitar colisiones, duplica el operador devolviendo g@_, y ofrece la variante visual leyendo '< y '> tras salir del modo visual. La marca de cierre es inclusiva y en bytes: prefiere getregion. A cambio obtienes repetición con el punto, conteos y composición con todos los objetos de texto existentes y futuros.

⚔️ Inventa un verbo
  1. Implementa el operador gs de ordenación con su variante gss y su variante visual, y comprueba que gsap ordena el párrafo.
  2. Pulsa . después de gsap sobre otro párrafo y verifica que la repetición funciona sin código adicional tuyo.
  3. Escribe un operador que informe del tipo recibido con vim.notify y ejecútalo con un movimiento carácter a carácter, con uno lineal y con una selección en bloque.
  4. Construye un operador que envuelva la región entre delimitadores usando getregionpos y nvim_buf_set_text, y pruébalo sobre texto con acentos para observar el problema de los bytes.
  5. Explica por qué el mapeo normal debe ser expr y qué ocurre exactamente si lo declaras sin esa opción.