wandres.dev
EXTMARKS · anotar el texto

Namespaces: agrupar marcas para poder borrarlas

Un namespace es la etiqueta que hace desechables a tus extmarks: agrupa las marcas de un plugin para consultarlas y limpiarlas en bloque con clear_namespace. Creacion idempotente con nvim_create_namespace, el patron de un namespace por plugin, subdivision por tarea y la relacion con las prioridades de dibujo.

⏱ 18 min

Colocar extmarks es fácil; el problema real de un plugin es quitarlas. Si redibujas anotaciones en cada pulsación y no tienes forma de decir borra todo lo que puse yo y nada más, acabarás llevando a mano una lista de identificadores, se te escapará alguno y el buffer terminará lleno de fantasmas de marcas anteriores. El namespace existe justamente para eso: es la etiqueta que agrupa tus marcas y las convierte en desechables en bloque, sin tocar las de nadie más. Es una pieza aburrida y es, en la práctica, la que decide si tu plugin es limpio o es una fuga.

🎯 Al terminar esta lección sabrás
  • Crear un namespace con nvim_create_namespace y entender que la operación es idempotente.
  • Borrar en bloque con nvim_buf_clear_namespace acotando por rango de líneas.
  • Consultar solo tus marcas con nvim_buf_get_extmarks y el filtro por namespace.
  • Diseñar la granularidad correcta: uno por plugin o uno por tarea independiente.

Un nombre que devuelve un número

Un namespace es un entero opaco que Neovim te entrega a cambio de una cadena. La función es vim.api.nvim_create_namespace y su propiedad más importante es que no crea nada nuevo si el nombre ya existe: llamarla mil veces con la misma cadena devuelve mil veces el mismo entero. Eso la vuelve segura de invocar en la carga del módulo, en cada llamada de una función o dentro de un autocomando, sin acumular basura ni necesitar memorizar el valor en una variable global.

-- Idempotente: la misma cadena siempre devuelve el mismo id
local ns = vim.api.nvim_create_namespace("miplugin.inline")

vim.api.nvim_buf_set_extmark(0, ns, 3, 0, { hl_group = "Comment" })
vim.api.nvim_buf_set_extmark(0, ns, 7, 0, { hl_group = "Comment" })

-- Y en cualquier otro archivo del proyecto:
local mismo = vim.api.nvim_create_namespace("miplugin.inline")
assert(mismo == ns)

Pasar 0 como namespace está permitido y significa ninguno: la marca se crea anónima, no se puede filtrar y no se puede limpiar en grupo. Sirve para experimentar en la línea de comandos y para casi nada más. En código de producción, una extmark sin namespace es una fuga de memoria visual que solo puedes cerrar cerrando el buffer.

💡
Nombra con prefijo de plugin y punto

La convención en el ecosistema es nombre_del_plugin.tarea, como gitsigns.blame o miplugin.inline. No hay registro central ni comprobación de colisiones: si dos autores eligen la cadena signs, comparten namespace sin enterarse y el uno borrará las marcas del otro. El prefijo es toda tu protección, así que hazlo específico.

Borrar en bloque

La contrapartida de agrupar es la limpieza. vim.api.nvim_buf_clear_namespace recibe el buffer, el namespace y un rango de líneas, y elimina de golpe todas las marcas de ese grupo que caigan dentro. Con 0 y -1 como límites, el rango es el buffer entero.

-- Todo el buffer
vim.api.nvim_buf_clear_namespace(0, ns, 0, -1)

-- Solo las lineas 10 a 19: redibujado parcial y barato
vim.api.nvim_buf_clear_namespace(0, ns, 10, 20)

El rango acotado no es un detalle menor: es la diferencia entre un plugin que recalcula el archivo entero en cada tecla y uno que solo rehace la ventana visible. Cuando un autocomando de cambio te dice qué líneas se tocaron, limpiar y repintar solo ese tramo mantiene el coste proporcional a la edición y no al tamaño del archivo.

Hay que subrayar qué es lo que no borra esta función: no toca las marcas de otros namespaces, ni siquiera las que estén en la misma línea o cubran el mismo carácter. Esa garantía de aislamiento es la razón de que decenas de plugins puedan anotar el mismo buffer simultáneamente sin coordinarse, y es también la razón de que el namespace sea obligatorio en la práctica aunque la API lo acepte a cero.

Para inspeccionar en vez de destruir está nvim_buf_get_extmarks, que acepta el mismo namespace como filtro, un rango, un límite de resultados y una opción details que devuelve además toda la configuración de cada marca. Es la herramienta de depuración imprescindible del nivel: si no ves lo que esperabas en pantalla, enumera tus marcas antes de suponer nada.

local marcas = vim.api.nvim_buf_get_extmarks(0, ns, 0, -1, { details = true })
for _, m in ipairs(marcas) do
  local id, fila, col, detalle = m[1], m[2], m[3], m[4]
  print(id, fila, col, vim.inspect(detalle))
end
flowchart LR
a[Evento de cambio en el buffer] --> b[clear_namespace en el rango tocado]
b --> c[Recalcula la informacion de esas lineas]
c --> d[set_extmark para cada anotacion nueva]
d --> a
style b fill:#f38ba8,color:#11111b
style d fill:#a6e3a1,color:#11111b

Conviene enunciar el ciclo como un invariante y no como una receta: en todo momento, el conjunto de marcas de tu namespace en un rango debe ser función únicamente del contenido actual de ese rango. Si tu código cumple esa propiedad, da igual cuántas veces se dispare el redibujado, en qué orden lleguen los eventos o si dos autocomandos se solapan: el resultado converge siempre al mismo estado. Si no la cumple, tendrás fallos que solo aparecen al escribir rápido y que no sabrás reproducir.

Ese ciclo de cuatro pasos —limpiar, recalcular, repintar, esperar— es el esqueleto de prácticamente todo plugin de anotación que existe. La disciplina consiste en que la limpieza y el pintado usen siempre el mismo namespace y el mismo rango, porque en cuanto ambos se desalinean aparecen anotaciones duplicadas o huecos sin repintar.

Hay una asimetría que conviene tener presente. nvim_buf_clear_namespace borra las marcas que empiezan dentro del rango que le das; una marca de rango que arranca antes del límite inferior y lo cruza sobrevive a la limpieza. Cuando tus anotaciones son puntuales eso no importa nunca, pero si trabajas con rangos largos —un bloque resaltado de treinta líneas— limpiar solo la ventana visible puede dejarte marcas viejas colgando desde arriba. La solución es ampliar el rango de limpieza hasta el inicio de la anotación más larga posible, o llevar rangos que nunca crucen la frontera que uses para repintar.

-- Cuidado: una marca que empieza en la linea 3 y acaba en la 40
-- NO se borra con esta llamada, porque su inicio queda fuera.
vim.api.nvim_buf_clear_namespace(0, ns, 10, 20)

Tampoco existe una operación para renombrar, fusionar ni enumerar namespaces por buffer. Sí puedes listar los existentes en la sesión con nvim_get_namespaces, que devuelve una tabla de nombre a identificador; es útil para depurar quién está pintando en un buffer cuando sospechas de un conflicto entre plugins, pero no es una API de gestión: los namespaces se crean y ya está, no se destruyen.

Cuántos namespaces necesitas

La respuesta corta es: uno por cada cosa que quieras poder apagar por separado. Si tu plugin muestra pistas de tipo al final de la línea y además resalta la variable bajo el cursor, y el usuario puede desactivar solo lo primero, esos son dos grupos. Si todo se enciende y se apaga junto, uno basta.

🏷️

Uno por plugin

El caso por defecto. Toda la funcionalidad se limpia con una sola llamada. Simple, difícil de romper y suficiente mientras las anotaciones tengan el mismo ciclo de vida.

🧩

Uno por tarea

Necesario cuando dos anotaciones se recalculan a ritmos distintos: unas en cada tecla y otras solo al guardar. Separarlas evita que el repintado barato tire abajo el caro.

La pregunta de control para decidirlo es una sola: si el usuario desactiva esta funcionalidad, ¿qué marcas deben desaparecer? Si la respuesta agrupa limpiamente un subconjunto, ese subconjunto merece su propio grupo. Si la respuesta es todas, no dividas.

Lo que casi nunca conviene es un namespace por buffer o por línea. El identificador es global al proceso y no se recicla, así que fabricar nombres dinámicos como miplugin.buf.42 hace crecer la tabla interna sin límite durante toda la sesión, además de dejarte sin la ventaja principal: la limpieza en una sola llamada. La ubicación ya la aporta el par buffer más rango que pasas a las funciones; el namespace es para la procedencia, no para la posición.

Un caso intermedio aparece cuando el plugin ofrece varios modos que el usuario alterna. Si tienes anotaciones permanentes y otras que solo se muestran mientras el cursor está en la línea, esas dos familias tienen ciclos de vida radicalmente distintos: las primeras se rehacen al cambiar el texto, las segundas al mover el cursor, que es un evento muchísimo más frecuente. Meterlas en el mismo grupo obliga a recalcular lo caro cada vez que ocurre lo barato, y ese es exactamente el síntoma que percibe el usuario como el editor va lento desde que instalé esto.

local ns_fijo = vim.api.nvim_create_namespace("miplugin.persistente")
local ns_cursor = vim.api.nvim_create_namespace("miplugin.bajo_cursor")

vim.api.nvim_create_autocmd("CursorMoved", {
  callback = function()
    -- Solo se rehace lo barato: lo persistente ni se toca
    vim.api.nvim_buf_clear_namespace(0, ns_cursor, 0, -1)
    -- ... y se vuelve a pintar la anotacion de la linea actual
  end,
})

Hay un segundo uso que conviene tener en el radar. Un namespace también puede llevar asociada una tabla de resaltado propia mediante nvim_set_hl_ns y nvim_win_set_hl_ns, lo que permite que un mismo grupo de resaltado se vea distinto en una ventana concreta. Es la maquinaria que hay detrás de las ventanas flotantes con tema propio, y aunque no la necesitarás para anotar texto, explica por qué el namespace no es solo una etiqueta de borrado sino una unidad de contexto visual completa.

El namespace es propiedad, no organización

Es tentador leer el namespace como lo que su nombre sugiere en otros lenguajes: un mecanismo para evitar colisiones de nombres, una comodidad de higiene. No lo es. En Neovim el namespace responde a una pregunta de propiedad: cuando llegue el momento de retirar estas anotaciones, ¿quién tiene derecho a hacerlo y sobre qué exactamente? Fíjate en que el problema que resuelve es de gestión de recursos, no de nombres. Las extmarks son duraderas por diseño —ese es todo su valor, sobreviven a la edición—, y la durabilidad sin una operación de liberación es precisamente la definición de fuga. En un lenguaje con recolector de basura las liberarías dejando de referenciarlas; aquí no puedes, porque quien las referencia es el buffer, y el buffer va a vivir mucho más que tu cálculo. El namespace es entonces el equivalente exacto de una arena de memoria: reservas muchas cosas pequeñas sin llevar la cuenta de cada una, y las liberas todas juntas con una sola operación cuando el trabajo termina. Esa es la razón de que la API no ofrezca nada parecido a “bórrame las marcas cuyo texto ya no aplique”: no hace falta, porque el patrón correcto nunca es la retirada selectiva sino el ciclo de arrasar y repintar el rango afectado. Y de ahí sale una regla de diseño que vale para el nivel entero: la granularidad de tus namespaces debe seguir la granularidad de tus ciclos de recálculo, no la de tus funcionalidades ni la de tu estructura de archivos. Dos anotaciones que siempre se rehacen juntas pertenecen al mismo grupo aunque conceptualmente no tengan nada que ver; dos que se rehacen a ritmos distintos deben separarse aunque salgan del mismo módulo. Piensa en tiempos de vida y acertarás siempre.

📝
Lo esencial de los namespaces

nvim_create_namespace convierte una cadena en un entero de forma idempotente, así que puedes llamarla donde quieras. Toda extmark que crees con ese namespace se puede consultar con nvim_buf_get_extmarks y borrar en bloque con nvim_buf_clear_namespace, acotando por rango de líneas para repintar solo lo que cambió. Usa el prefijo del plugin en el nombre, evita el namespace cero en producción y separa grupos solo cuando sus ciclos de recálculo difieran.

⚔️ Ciclo de limpieza y repintado
  1. Crea dos namespaces, reto.a y reto.b, y comprueba con assert que volver a pedirlos devuelve el mismo entero.
  2. Coloca cinco marcas con resaltado en cada grupo, alternando líneas, y enumera solo las de reto.a con nvim_buf_get_extmarks pasando details.
  3. Limpia reto.a con nvim_buf_clear_namespace y verifica que las de reto.b siguen intactas.
  4. Escribe una función que, dado un rango de líneas, limpie y repinte solo ese tramo; llámala desde un autocomando TextChanged y comprueba que no se duplican anotaciones.
  5. Coloca a propósito una marca con namespace cero y razona por escrito por qué ya no puedes retirarla en grupo.