Un caso real: un plugin que anota mientras escribes
Construccion completa de un plugin de anotacion inline con extmarks: namespace propio, dibujado acotado al rango visible, autocomandos locales al buffer, amortiguacion con un temporizador de vim.uv, reutilizacion de ids para evitar parpadeo, y un comando para encender y apagar sin dejar marcas huerfanas.
Todo lo anterior se junta aquí. Vamos a escribir un plugin pequeño y honesto: marca las líneas que superan el ancho recomendado, pone un símbolo en el margen y anuncia al final de la línea cuántas columnas sobran, y se mantiene al día mientras el usuario escribe. Es deliberadamente modesto en funcionalidad y exigente en ingeniería, porque los problemas difíciles de un plugin de anotación no están en el cálculo: están en cuándo recalcular, cuánto repintar y cómo apagarse sin dejar rastro. Quien resuelve esos tres, ha resuelto la categoría entera.
- Estructurar un módulo con
namespacepropio y una función de dibujado idempotente. - Acotar el trabajo al rango visible en lugar de recorrer el buffer entero.
- Amortiguar los eventos de edición con un temporizador de
vim.uv. - Encender y apagar por buffer sin dejar autocomandos ni marcas huérfanas.
El dibujado: limpiar, calcular, marcar
La función de dibujado es el corazón y debe cumplir una regla: llamarla dos veces seguidas tiene que dejar exactamente el mismo resultado que llamarla una vez. Se consigue limpiando el namespace en el rango antes de repintarlo, nunca añadiendo sobre lo que ya había.
local M = {}
local ns = vim.api.nvim_create_namespace("anchoaviso.inline")
local LIMITE = 80
local function pintar(bufnr, desde, hasta)
vim.api.nvim_buf_clear_namespace(bufnr, ns, desde, hasta)
local lineas = vim.api.nvim_buf_get_lines(bufnr, desde, hasta, false)
for i, linea in ipairs(lineas) do
local ancho = vim.fn.strdisplaywidth(linea)
if ancho > LIMITE then
vim.api.nvim_buf_set_extmark(bufnr, ns, desde + i - 1, 0, {
sign_text = "▸",
sign_hl_group = "DiagnosticSignWarn",
virt_text = { { " +" .. (ancho - LIMITE) .. " col", "DiagnosticVirtualTextWarn" } },
virt_text_pos = "eol",
priority = 120,
})
end
end
end
Tres decisiones merecen defensa. Medimos con strdisplaywidth y no con el tamaño en bytes, porque lo que le importa al usuario son celdas de pantalla y un ideograma ocupa dos. Elegimos prioridad 120, por debajo de los diagnósticos: si hay un error en la línea, que gane el error. Y usamos eol en lugar de inline, porque nuestra información es un comentario marginal y no merece desplazar el código.
Aquí limpiamos y recreamos porque el conjunto de líneas afectadas cambia por completo entre pulsaciones. Si tu plugin mantiene una anotación estable por línea —una pista de tipo, por ejemplo—, es mejor guardar el id y volver a llamar a nvim_buf_set_extmark pasándolo: mover una marca no parpadea, y borrarla y recrearla sí puede hacerlo en el instante del redibujado.
Solo lo visible, y no en cada tecla
Recorrer un archivo de veinte mil líneas en cada pulsación es inviable, y además es innecesario: el usuario solo ve una ventana. Calculamos el rango visible con nvim_win_get_cursor y las funciones de línea de la ventana, ampliamos un poco por arriba y por abajo para cubrir el desplazamiento inmediato, y pintamos solo eso.
La segunda mitad del problema es la frecuencia. TextChangedI se dispara en cada carácter que escribes, así que engancharle trabajo directamente convierte el tecleo en una sucesión de pequeños atascos. La solución estándar es amortiguar: cada evento reinicia un temporizador corto y el dibujado real ocurre solo cuando el usuario deja de escribir unos milisegundos.
local temporizador = nil
local function programar(bufnr)
if temporizador then
temporizador:stop()
temporizador:close()
end
temporizador = vim.uv.new_timer()
temporizador:start(80, 0, vim.schedule_wrap(function()
if vim.api.nvim_buf_is_valid(bufnr) then
local win = vim.fn.bufwinid(bufnr)
if win ~= -1 then
local alto = vim.api.nvim_win_get_height(win)
local arriba = math.max(0, vim.fn.line("w0", win) - 1 - alto)
local abajo = vim.fn.line("w$", win) + alto
pintar(bufnr, arriba, abajo)
end
end
end))
end
El vim.schedule_wrap no es decorativo: el temporizador de vim.uv se ejecuta en el bucle de eventos, fuera del hilo principal del editor, y llamar a la API de Neovim desde ahí es un error. schedule_wrap difiere la función al momento seguro siguiente. Es el error más frecuente de quien escribe su primer plugin asíncrono, y se manifiesta como un fallo intermitente e imposible de reproducir.
flowchart LR
e[TextChanged o TextChangedI] --> t[Reinicia el temporizador de 80 ms]
t --> q{Sigue escribiendo}
q -->|si| t
q -->|no| s[schedule_wrap al hilo principal]
s --> r[Calcula rango visible]
r --> p[clear_namespace y repinta]
style t fill:#89b4fa,color:#11111b
style p fill:#a6e3a1,color:#11111bEncender, apagar y no filtrar
Lo último es el ciclo de vida. Los autocomandos se crean en un grupo local al buffer, de modo que apagar el plugin en un archivo no afecta a los demás; y al apagar hay que hacer las dos cosas, borrar los autocomandos y limpiar el namespace, porque olvidar la segunda deja anotaciones congeladas que ya nadie actualizará.
local grupos = {}
function M.activar(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
if grupos[bufnr] then return end
local grupo = vim.api.nvim_create_augroup("anchoaviso." .. bufnr, { clear = true })
grupos[bufnr] = grupo
vim.api.nvim_create_autocmd(
{ "TextChanged", "TextChangedI", "WinScrolled", "BufEnter" },
{ group = grupo, buffer = bufnr, callback = function() programar(bufnr) end }
)
vim.api.nvim_create_autocmd("BufWipeout", {
group = grupo, buffer = bufnr, callback = function() M.desactivar(bufnr) end,
})
programar(bufnr)
end
function M.desactivar(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
if grupos[bufnr] then
vim.api.nvim_del_augroup_by_id(grupos[bufnr])
grupos[bufnr] = nil
end
if vim.api.nvim_buf_is_valid(bufnr) then
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
end
end
vim.api.nvim_create_user_command("AnchoAviso", function()
local b = vim.api.nvim_get_current_buf()
if grupos[b] then M.desactivar(b) else M.activar(b) end
end, { desc = "Alterna el aviso de lineas largas" })
return M
El autocomando BufWipeout es la pieza que la mayoría olvida: sin él, la tabla grupos conserva entradas de buffers que ya no existen y el plugin va acumulando estado muerto durante toda la sesión. Y WinScrolled está en la lista porque, al pintar solo el rango visible, desplazarse es tan generador de trabajo como escribir.
Mira la proporción de lo que acabas de escribir. La lógica que de verdad hace algo —medir el ancho de una línea y compararlo con un número— cabe en dos líneas. Todo lo demás es gestión de cuándo: cuándo recalcular, sobre qué rango, en qué hilo, con qué frecuencia y hasta cuándo. Esa desproporción no es un defecto de este ejemplo, es la naturaleza del problema, y reconocerla temprano es lo que separa a quien publica un plugin que la gente mantiene instalado de quien publica uno que se desinstala a la semana. La razón de fondo es que un plugin de anotación no es una función, es un proceso continuo acoplado a otro proceso continuo que no controlas: el usuario escribiendo. Y todo acoplamiento entre procesos que corren a ritmos distintos plantea la misma familia de preguntas que ya conoces de los sistemas distribuidos, solo que aquí a escala de milisegundos y dentro de un mismo editor. La amortiguación con temporizador es exactamente una política de contrapresión: renuncias a estar siempre correcto para no cobrarle al usuario el coste de estarlo, y aceptas conscientemente una ventana de ochenta milisegundos en la que lo que muestras es una mentira reciente. El dibujado acotado al rango visible es una estrategia de evaluación perezosa: no calculas lo que nadie va a mirar. La idempotencia de la función de pintado es lo que te permite reintentar sin miedo, igual que en cualquier sistema donde los mensajes pueden llegar duplicados. Y la limpieza en el evento de destrucción del buffer es simple gestión de recursos con dueño explícito. Ninguna de esas ideas es de Neovim; todas son las mismas que aplicarías en un servidor, y aparecen aquí porque el problema es estructuralmente el mismo. Las extmarks te regalan la parte que sí es específica del editor —que la anotación no se despegue del texto— y a cambio te dejan a solas con la parte que es ingeniería de siempre. Por eso, cuando revises un plugin ajeno, no mires primero qué dibuja: mira a qué eventos se engancha, qué rango recalcula y qué hace cuando lo apagan. Ahí está escrito si el autor entendió el problema.
Un plugin de anotación se sostiene sobre cuatro piezas: una función de pintado idempotente que limpia su namespace en el rango antes de marcar, un cálculo acotado al rango visible ampliado, una amortiguación con temporizador de vim.uv envuelta en vim.schedule_wrap para volver al hilo principal, y un ciclo de vida por buffer con autocomandos en grupo propio que se borran junto con las marcas al desactivar o al destruir el buffer.
- Copia el módulo, actívalo con el comando y comprueba que las anotaciones aparecen y desaparecen mientras escribes en una línea larga.
- Sustituye el límite fijo por el valor de la opción
textwidthdel buffer, con reserva a 80 si vale cero. - Sube la prioridad a 4096 y abre un archivo con errores del servidor de lenguaje; describe qué información has tapado y devuélvela a 120.
- Elimina el
vim.schedule_wrapy observa el fallo que aparece; anota el mensaje exacto y explica por qué ocurre. - Quita el autocomando
BufWipeout, abre y cierra veinte buffers e inspecciona la tablagrupos; luego devuélvelo y verifica que queda vacía.