Saltar al contenido

Operadores propios: operatorfunc y la gramática extendida

La opción operatorfunc y la tecla g@ te permiten inventar verbos que aceptan cualquier movimiento y cualquier objeto de texto, igual que los nativos. El contrato completo: los tipos char, line y block, las marcas de corchete, el patrón expr que fija la opción y devuelve g@, la variante de línea con g@_, la visual con el mismo mapeo, y por qué la repetición con el punto sale gratis.

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 de la lección 0.4, con dominio completo sobre la gramática y con la repetición mediante el punto incluida de serie.

  • 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 '[ y '] y decidir según el tipo char, line o block.
  • Aplicar el patrón expr que fija vim.go.operatorfunc y devuelve g@ desde la misma función.
  • Añadir la variante de línea con g@_ y la visual reutilizando el mismo mapeo.
  • Explicar por qué la repetición con el punto y los conteos salen 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 — el inicio y el último carácter incluido —, 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 masticado: dos marcas y un tipo. Tu función solo actúa sobre esa región.

Escribir el operador

Antes de escribir una línea hay que elegir la tecla, y esa es una decisión de vocabulario — la 11.5 — tomada por adelantado. El sitio natural para operadores propios son las parejas bajo g, pero en este curso gs pertenece a mini.surround y todo gr al LSP. Usaremos gz — libre de fábrica y sin dueño en nuestra configuración — para un operador de ordenación: de la A a la Z.

El patrón idiomático mete dos responsabilidades en la misma función y distingue por el argumento. Si llega nil, 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.luaLUA
local M = {}function M.ordenar(kind)  if kind == nil then    vim.go.operatorfunc = "v:lua.require'dios.ops'.ordenar"    return 'g@'  end  local ini = vim.api.nvim_buf_get_mark(0, '[')[1]  local fin = vim.api.nvim_buf_get_mark(0, ']')[1]  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)endreturn M

La cadena v:lua.require'dios.ops'.ordenar es una forma especial que Neovim reconoce: permite apuntar una opción de Vimscript a una función de un módulo de Lua — los del Nivel 7 — sin ensuciar el espacio global. Como operatorfunc es una opción global (de ahí vim.go, el ámbito que viste en la lección 8.2), esta indirección con nombres completos evita que dos operadores tuyos se pisen entre sí.

LUA
local ops = require('dios.ops')-- El mismo mapeo sirve para normal y para visualvim.keymap.set({ 'n', 'x' }, 'gz', ops.ordenar,  { expr = true, desc = 'Ordenar las líneas del movimiento o la selección' })-- Duplicar el operador lo aplica a la línea actual, como dd o yyvim.keymap.set('n', 'gzz', function() return ops.ordenar() .. '_' end,  { expr = true, desc = 'Ordenar la línea actual' })

Tres detalles con mucha densidad. El primero: gzz devuelve g@_, y _ es el movimiento lineal que abarca la línea actual — es la convención nativa de que doblar el operador lo aplica a la línea, calcada del ejemplo oficial de :h map-operator. Que gz sea a la vez mapeo y prefijo de gzz no viola la regla anti-retardo de la 11.5: la espera desemboca en operator-pending, donde ibas a estar esperando de todos modos. El segundo: el mismo mapeo en x resuelve la variante visual, porque g@ también funciona como operador en modo visual — se aplica a la selección, las marcas de corchete se colocan en sus extremos y tu función recibe char, line o block según fuera v, V o <C-v>. Nada de trucos con :<C-u>: así lo hace el ejemplo canónico de la ayuda. El tercero: el mapeo es expr porque la función devuelve teclas en lugar de ejecutar el efecto, y la ayuda explica el porqué — así el operador captura el conteo y el registro antepuestos, y no pasa por la línea de comandos, que dispararía CmdlineEnter y CmdlineLeave.

Leer la región según el tipo

Para un operador lineal como el de arriba basta con los números de línea de las marcas. Para uno que actúa carácter a carácter necesitas también las columnas, y ahí conviene delegar en vim.fn.getregion, que resuelve de una vez el trabajo sucio de recortar la primera y la última línea.

lua/dios/ops.lua (continuación)LUA
function M.contar(kind)  if kind == nil then    vim.go.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 el principio y el 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 con vim.notify 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 el primer elemento de cada marca y trabajas cómodo con nvim_buf_get_lines y nvim_buf_set_lines.

block

Rectángulo definido por dos esquinas. Cada línea aporta un fragmento independiente y el ancho efectivo 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: partir un carácter UTF-8 por la mitad corrompe la línea. Si puedes, delega en getregion y getregionpos, que ya hacen esa aritmética por ti — es la misma trampa de bytes que muerde con las extmarks en el Nivel 14.

La repetición sale gratis

Aquí está el dividendo que justifica el rodeo. Cuando Neovim ejecuta un g@, registra la secuencia completa — operador más movimiento — en el mecanismo de repetición que entrenaste en el Nivel 6. Eso significa que . vuelve a aplicar tu operador con el mismo movimiento, sin que hayas escrito una línea para ello. Un mapeo corriente que llamase a tu función directamente perdería esa propiedad, y tendría que emularla con muletas externas — y siempre se emula mal.

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 con 2j, con ap, con i(, con it y con cualquier objeto de texto que un plugin instale después de que tú escribieras el verbo — incluido el ie que definiste en la 11.1. Puedes incluso forzar el tipo, como con los operadores nativos: gzVj fuerza el tratamiento lineal del movimiento (:h forced-motion).

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 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 fuera poco, el protocolo te devuelve gratis la repetición con el punto, la aceptación de conteos y registros y la integración con deshacer, tres cosas que un mapeo que invoca una función directamente tiene que emular con esfuerzo. Cuando entiendes esto, dejas de preguntarte qué tecla libre te queda y empiezas a preguntarte qué verbo le falta a tu idioma.

Verbo tuyo, objeto nativo. Neovim resuelve ip, deja las marcas de corchete en los extremos del párrafo y llama a tu función con line.

El operador doblado: el mapeo devuelve g@_, y _ es el movimiento lineal de la línea actual. La misma convención que dd, yy y cc, aplicada a tu verbo.

El dot-repeat que entrenaste en el Nivel 6 funciona con tu operador porque g@ entra en el mecanismo de repetición del núcleo. Sin plugins, sin código extra.

Con la selección de bloque, el argumento es block y las marcas señalan esquinas opuestas. Si tu operador no sabe qué significa eso para su caso, avisa y aborta: es mejor que un resultado corrupto.

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 vim.go.operatorfunc con la forma v:lua.require'…' y devuelve g@ desde un mapeo expr; si recibe un tipo, actúa sobre la región. El mismo mapeo en x cubre la variante visual, y g@_ la de línea. La marca de cierre es inclusiva y en bytes: prefiere getregion y getregionpos. A cambio obtienes el punto, los conteos y la composición con todos los objetos de texto existentes y futuros. Y elige la tecla con el mapa de la 11.5 delante: aquí fue gz porque gs y gr ya tienen dueño en este curso.

Inventa un verbo

  1. Implementa el operador gz de ordenación con su variante gzz y comprueba que gzip ordena el párrafo y gz sobre una selección visual ordena la selección — con el mismo mapeo.
  2. Pulsa . después de gzip sobre otro párrafo y verifica que la repetición funciona sin código adicional. Prueba también 3..
  3. Mapea M.contar en una tecla temporal y ejecútalo con un movimiento de caracteres (e), uno lineal (2j) y una selección en bloque; anota qué argumento llegó en cada caso.
  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 ver en vivo el problema de los bytes.
  5. Declara el mapeo sin expr a propósito, ejecuta gzip, y explica con lo aprendido en la 11.1 por qué no ocurre nada visible. Vuelve a ponerlo.