wandres.dev
EXTMARKS · anotar el texto

Resaltado y signos: pintar rangos y ocupar el margen

La misma extmark que marca una posicion puede colorear un rango con hl_group, pintar la linea entera, teñir el numero y colocar un simbolo en la columna de signos. Prioridades y la tabla vim.hl.priorities, la anchura de dos celdas del margen, y como se apoyan en todo esto los indicadores de git y los diagnosticos.

⏱ 20 min

Una vez que sabes colocar una marca que sigue al texto, colorear es casi gratis: basta con añadirle campos. La misma llamada que crea una extmark puede pintar un rango, teñir la línea entera, cambiar el color del número lateral y estampar un símbolo en el margen izquierdo. Eso significa que el resaltado deja de ser un subsistema aparte —con sus patrones, su orden de aplicación y sus reglas propias— para convertirse en metadatos de una posición estable. Esta lección cierra el círculo: cómo se pinta con extmarks, quién gana cuando dos anotaciones se solapan, y por qué los indicadores de git y los diagnósticos son, por debajo, exactamente el mismo mecanismo.

🎯 Al terminar esta lección sabrás
  • Resaltar un rango con hl_group, end_row y end_col, y extenderlo con hl_eol.
  • Resolver solapamientos con priority y situarte en la escala de vim.hl.priorities.
  • Colocar símbolos en el margen con sign_text y teñir línea y número.
  • Reconocer el mismo patrón funcionando bajo los indicadores de git y los diagnósticos.

Colorear un rango

Añadir hl_group a una extmark con extremo final convierte el rango en una región resaltada que se estira y se encoge con la edición. No hay patrón, no hay expresión regular y no hay recálculo por ventana: hay un rango con identidad, y el color va pegado a él.

local ns = vim.api.nvim_create_namespace("demo.hl")

vim.api.nvim_buf_set_extmark(0, ns, 8, 4, {
  end_row = 8,
  end_col = 17,
  hl_group = "IncSearch",
  priority = 200,
})

Fíjate en que aquí no hay un paso de aplicar ni de refrescar: la marca existe y el color existe con ella. Si el usuario borra media palabra, el rango se encoge y el color con él; si la duplica con p, la copia sale sin resaltar, porque el color nunca estuvo en el texto. Esa correspondencia estricta entre rango y pintura es justo lo que matchadd no podía dar, porque su unidad no era una región concreta sino un patrón que podía coincidir en sitios nuevos sin que nadie lo pidiera.

Cuando el rango abarca varias líneas, hl_eol decide si el color llega hasta el borde de la ventana en las líneas intermedias o se detiene en el último carácter real de cada una; con bloques enteros, dejarlo activo produce el efecto de banda continua al que estamos acostumbrados en un diff. Para resaltados efímeros —el destello tras copiar un texto, por ejemplo— existe el atajo vim.hl.range, que crea la marca por ti y admite un temporizador para retirarla sola.

⚠️
El resaltado se apaga si el buffer no lo tiene activo

Un hl_group inexistente no da error: simplemente no pinta nada. Antes de dudar de tus coordenadas, comprueba el nombre del grupo con :highlight y recuerda que las columnas van en bytes: un acento antes de tu columna final desplaza el borde del color a mitad de carácter.

Quién gana cuando se solapan

Varias marcas pueden cubrir el mismo carácter, y entonces manda priority: el valor más alto se dibuja encima. El valor por defecto de una extmark es 4096, deliberadamente enorme, y eso tiene una consecuencia que sorprende a todo el mundo: una extmark creada sin pensar en prioridades tapa el resaltado de Treesitter, que usa números mucho más bajos.

La escala convencional del ecosistema está publicada en la tabla vim.hl.priorities, y conviene tenerla memorizada porque es el mapa de quién pinta sobre quién.

Capa Prioridad
sintaxis clásica 50
Treesitter 100
tokens semánticos del servidor de lenguaje 125
diagnósticos 150
resaltados del usuario 200

Los números no están grabados en el núcleo: son una convención que el ecosistema respeta porque respetarla es lo único que hace predecible el resultado. Neovim se limita a ordenar por el entero que le des, y el hueco de cincuenta unidades entre capa y capa existe para que puedas colarte entre dos sin desplazar a nadie: una anotación que deba verse por encima de Treesitter pero por debajo de los diagnósticos vive cómodamente en el 110.

La lectura de esa tabla es una jerarquía de certeza creciente. La sintaxis por expresiones regulares adivina; Treesitter conoce la gramática; el servidor de lenguaje conoce además los tipos y las resoluciones; un diagnóstico es un hecho comprobado sobre ese código concreto; y lo que el usuario pide explícitamente gana siempre. Si escribes un plugin, sitúate en esa escala en vez de dejar el 4096 por defecto: es la diferencia entre integrarte y atropellar.

flowchart TD
s[Sintaxis 50] --> t[Treesitter 100]
t --> l[Tokens semanticos 125]
l --> d[Diagnosticos 150]
d --> u[Usuario 200]
u --> v[Se dibuja encima]
style d fill:#f38ba8,color:#11111b
style u fill:#a6e3a1,color:#11111b

El margen izquierdo

La columna de signos es la franja a la izquierda del texto, y en Neovim moderno se llena poniendo sign_text en una extmark. La cadena debe ocupar una o dos celdas de pantalla: ese es todo el presupuesto, y por eso el ecosistema entero se expresa ahí con un carácter de bloque, una flecha o un icono de fuente parcheada. Junto a ella viajan varios campos hermanos que pintan zonas contiguas.

vim.api.nvim_buf_set_extmark(0, ns, 20, 0, {
  sign_text = "▎",
  sign_hl_group = "GitSignsAdd",
  number_hl_group = "GitSignsAddNr",
  line_hl_group = "DiffAdd",
  priority = 10,
})

El límite de dos celdas no es negociable y conviene entenderlo en términos de ancho de pantalla, no de caracteres: un icono de fuente parcheada ocupa una celda, un emoji ocupa dos, y una cadena de tres letras es rechazada por la API. Esa restricción tan dura tiene una razón sensata detrás, y es que el margen debe tener un ancho constante para que el texto no baile horizontalmente cada vez que cambia una anotación.

Cuando dos marcas quieren poner símbolo en la misma línea y solo cabe uno, vuelve a decidir priority: el mayor ocupa el hueco y el otro no se dibuja. Si prefieres que convivan, la opción signcolumn acepta un ancho explícito con la forma auto:2 o yes:3, reservando varias columnas a costa de estrechar el texto. Es un ajuste del usuario, no del plugin, y por eso ningún plugin educado debería forzarlo desde su código.

sign_hl_group colorea el símbolo, number_hl_group tiñe el número de línea, line_hl_group pinta el fondo de la línea entera y cursorline_hl_group se aplica solo cuando el cursor está encima. Merece la pena detenerse en line_hl_group y cursorline_hl_group, porque son los dos únicos campos que pintan superficie que no pediste explícitamente. El primero tiñe la línea completa hasta el borde de la ventana, incluido el espacio vacío tras el último carácter; el segundo hace lo mismo pero solo mientras el cursor esté ahí, lo que da un efecto de foco muy limpio para anotaciones que solo importan cuando las miras. Combinados con number_hl_group permiten construir una jerarquía de intensidad sin escribir un solo carácter en pantalla: número teñido para lo leve, signo en el margen para lo relevante, línea completa para lo que no debe pasar desapercibido.

La opción signcolumn decide si el margen aparece siempre con yes, solo cuando hay algo que mostrar con auto, o si se funde con el número con number. Con auto, el texto se desplaza un par de celdas cada vez que aparece o desaparece un signo, un salto visual que muchos evitan fijándola en yes.

Los dos ejemplos canónicos

🌿

Indicadores de git

Comparan el buffer con el índice, traducen el diff a rangos y colocan una extmark por tramo cambiado, con sign_text en el margen y prioridad baja para no competir con los diagnósticos. Al editar, las marcas se desplazan solas y solo se recalcula el fragmento tocado.

⚠️

Diagnósticos

Reciben del servidor de lenguaje rangos con severidad, y crean por cada uno una extmark que combina tres capas: hl_group subrayado sobre el rango exacto, sign_text en el margen y virt_text con el mensaje al final de la línea.

Que ambos casos —tan distintos en origen, uno viene del control de versiones y el otro del análisis estático— se implementen con la misma llamada es la mejor prueba de que la abstracción está bien elegida. Lo que comparten no es el dominio, sino la forma: información externa que se refiere a rangos concretos del texto y que debe seguir refiriéndose a ellos aunque el usuario siga escribiendo.

La diferencia interesante entre los dos está en el momento de la verdad. Los indicadores de git comparan el buffer con una referencia que casi nunca cambia, así que pueden recalcular con calma y de forma incremental: solo el tramo que el usuario tocó. Los diagnósticos, en cambio, llegan de fuera y llegan tarde: el servidor de lenguaje responde cientos de milisegundos después de que el texto cambió, para un estado del buffer que quizá ya no existe. Ahí es donde las extmarks salvan la situación, porque el rango que el servidor describió se colocó como marca sobre el texto de entonces y ha ido moviéndose con las ediciones posteriores; el subrayado sigue debajo de la expresión correcta aunque el usuario haya insertado diez líneas mientras esperaba.

-- Un diagnostico completo son tres capas sobre la misma extmark
vim.api.nvim_buf_set_extmark(0, ns, fila, col_ini, {
  end_row = fila,
  end_col = col_fin,
  hl_group = "DiagnosticUnderlineError",
  sign_text = "E",
  sign_hl_group = "DiagnosticSignError",
  virt_text = { { " " .. mensaje, "DiagnosticVirtualTextError" } },
  virt_text_pos = "eol",
  priority = vim.hl.priorities.diagnostics,
})

Ese bloque no es pseudocódigo ilustrativo: es, en esencia, lo que hace el motor de diagnósticos de Neovim por cada aviso que recibe. Y explica por qué configurar diagnósticos con vim.diagnostic.config se siente tan granular —puedes apagar el subrayado, dejar solo el signo, cambiar el prefijo del texto virtual—: cada opción de configuración enciende o apaga uno de los campos de esta llamada.

El margen es un recurso escaso y compartido

Hay una lección de diseño escondida en esas dos celdas de anchura, y merece pensarse despacio porque reaparece en cualquier sistema donde varios productores independientes compiten por la misma superficie. La columna de signos es un espacio fijo, público y sin coordinación: git quiere poner una barra, el diagnóstico quiere poner una cruz, el gestor de puntos de interrupción quiere poner un círculo, y los tres se refieren a la misma línea sin conocerse entre sí. No hay negociación posible en tiempo de ejecución, así que el sistema necesita una regla de arbitraje que funcione sin acuerdo previo, y esa regla es la prioridad numérica. Fíjate en lo que eso implica: la prioridad no es un ajuste estético, es un protocolo social codificado en un entero. Cuando eliges 10 para tus indicadores de git y 150 para tus errores, no estás decidiendo qué se ve más bonito, estás declarando qué información merece interrumpir a qué otra información cuando ambas compiten por la atención del lector en el mismo instante. Y ahí está el criterio que hace que un entorno de trabajo se sienta coherente en vez de ruidoso: la jerarquía debe ordenarse por coste de ignorar el aviso, no por lo reciente que sea ni por lo mucho que le importe a su autor. Un error de compilación bloquea el trabajo, así que gana; el estado de git es contexto permanente y de fondo, así que cede. La misma disciplina explica por qué line_hl_group se usa con tantísima cautela: teñir una línea entera es el reclamo visual más agresivo del repertorio, consume una superficie que nadie más puede compartir y, aplicado a algo rutinario, entrena al lector a ignorar precisamente el canal que necesitarás cuando algo grave ocurra. Cada capa de color que añades gasta un presupuesto de atención que no se repone.

📝
Lo esencial del resaltado y los signos

hl_group sobre una extmark con extremo final colorea un rango que se mueve con el texto, y hl_eol decide si el color llega al borde. Los solapamientos los resuelve priority, cuyo valor por defecto de 4096 tapa a Treesitter salvo que lo bajes a la escala de vim.hl.priorities. En el margen, sign_text admite una o dos celdas, y sign_hl_group, number_hl_group, line_hl_group y cursorline_hl_group colorean el símbolo, el número, la línea y la línea bajo el cursor. Los indicadores de git y los diagnósticos son la misma llamada con distintos datos de origen.

⚔️ Compón tus propias capas
  1. Resalta con hl_group el rango exacto de la palabra bajo el cursor y comprueba que el color acompaña a la palabra cuando insertas texto delante.
  2. Crea dos marcas solapadas con prioridades 100 y 300 y verifica cuál se dibuja encima; luego invierte los números.
  3. Coloca una marca sin especificar priority sobre código resaltado por Treesitter y explica por qué lo tapa.
  4. Añade un sign_text de dos celdas junto a number_hl_group y observa el efecto con signcolumn en auto y en yes; describe el salto visual.
  5. Usa line_hl_group sobre cinco líneas seguidas, míralo un minuto y decide, argumentando, si esa información merecía ese coste de atención.