wandres.dev
HIGHLIGHTS · el sistema de color

Grupos de resaltado: el átomo del color

Neovim no colorea caracteres, colorea nombres. Qué es un grupo de resaltado, qué atributos admite, por qué termguicolors lo cambia todo y cómo interrogar al editor con Inspect para saber quién pinta cada cosa.

⏱ 14 min

Entre el texto de tu buffer y el color que enciende la terminal hay una indirección deliberada: el grupo de resaltado. Nadie declara que una palabra concreta sea morada; se declara que esa palabra es un Keyword y, por separado y en otro archivo, que los Keyword son morados. Esa separación entre clasificar y pintar es la arquitectura completa del color en Neovim, y entenderla convierte el tema de un accidente estético en un sistema que puedes razonar, depurar y reescribir.

🎯 Al terminar esta lección sabrás
  • Definir qué es un grupo de resaltado y qué atributos admite.
  • Entender termguicolors y la frontera entre los 16 colores y los 24 bits.
  • Usar nvim_set_hl como API canónica y leer con nvim_get_hl.
  • Descubrir con Inspect qué grupo colorea exactamente cada carácter.

La indirección que lo explica todo

Un grupo de resaltado es un nombre con atributos visuales asociados: color de frente, color de fondo, negrita, cursiva, subrayado. Nada más. No sabe qué lenguaje muestras ni en qué línea estás; es una entrada en una tabla global que va del nombre a la apariencia.

El color ocurre entonces en dos fases estrictamente desacopladas:

  1. Clasificar. Alguien decide que cierto rango de bytes de la línea 8 pertenece al grupo Keyword. Ese alguien puede ser el motor syntax clásico, Treesitter, el servidor de lenguaje mediante semantic tokens, un diagnóstico o cualquier plugin que coloque una extmark.
  2. Pintar. Otro alguien —el esquema de color— decide qué aspecto tiene Keyword, sin saber ni preguntar qué lenguaje se está mostrando.

La consecuencia es profunda y casi nunca se enuncia: un tema escrito hace siete años puede colorear correctamente un lenguaje publicado ayer, siempre que ese lenguaje clasifique sus tokens con nombres que el tema ya conoce. El vocabulario compartido de nombres es el contrato, y todo el ecosistema de temas descansa sobre él.

flowchart LR
A[Bytes del buffer] --> B[Fase de clasificacion]
B --> B1[Motor syntax clasico]
B --> B2[Treesitter]
B --> B3[Semantic tokens del LSP]
B1 --> C[Nombre de grupo]
B2 --> C
B3 --> C
C --> D[Fase de pintado: el tema]
D --> E[Atributos fg bg negrita cursiva]
E --> F[Secuencias de escape a la terminal]
style C fill:#a6e3a1,color:#11111b
style D fill:#89b4fa,color:#11111b

termguicolors: la frontera de los 24 bits

Cada grupo guarda en realidad dos definiciones paralelas. La familia cterm (ctermfg, ctermbg, cterm) usa índices en la paleta de la terminal, del 0 al 255: no eliges un color, eliges una casilla cuyo valor real lo decide el perfil de tu emulador. La familia gui (fg, bg, sp y los atributos) usa RGB de 24 bits: eliges el color exacto.

La opción termguicolors es el interruptor que decide cuál de las dos familias se emite:

vim.o.termguicolors = true
  • Apagado: solo cuentan los atributos cterm. Un fg = "#89b4fa" se ignora por completo y ves los dieciséis colores de tu terminal.
  • Encendido: Neovim emite secuencias SGR con el triplete RGB literal y el tema se ve tal como su autor lo diseñó.

Desde Neovim 0.10 el editor intenta detectar el soporte de la terminal y activarlo solo. Aun así conviene saber diagnosticarlo: si tu tema de mil colores se ve como ocho colores feos, el culpable casi nunca es el tema. Comprueba que la variable de entorno COLORTERM valga truecolor y, si vives dentro de un multiplexor, que este anuncie la capacidad de color verdadero en su configuración de terminfo.

Hay una asimetría que conviene interiorizar. Con la familia cterm no controlas el color: el índice 4 es “el cuarto color de la paleta del usuario”, y qué azul sea eso lo decide el perfil de la terminal. Eso tiene una virtud —el editor se integra con el resto de la consola— y un defecto fatal para un tema, que es la imposibilidad de garantizar contraste. Con la familia gui ocurre lo contrario: controlas el color exacto y, por tanto, eres el único responsable de que se lea.

Un tema serio define ambas familias. La segunda para verse como su autor quiso, y la primera para degradar con dignidad en la consola de un servidor remoto donde nadie ha configurado nada.

nvim_set_hl: la API canónica

La forma canónica de definir un grupo en Neovim moderno es una función Lua que recibe una tabla de atributos:

vim.api.nvim_set_hl(0, "Keyword", {
  fg = "#cba6f7",
  bold = true,
  italic = true,
})

El primer argumento es el namespace. El 0 significa el espacio global, el que afecta a todo el editor; los namespaces creados con nvim_create_namespace permiten definiciones con alcance local a una ventana, y los verás en la lección 15.5.

La forma clásica, el comando :highlight, sigue existiendo y la verás en configuraciones heredadas, con sus pares de clave y valor separados por espacios dentro de una sola cadena:

highlight Keyword guifg=#cba6f7 gui=bold,italic ctermfg=141

Ambas hacen lo mismo, pero la versión Lua es preferible por razones que no son de estilo: recibe datos estructurados en lugar de una cadena que hay que construir concatenando, permite calcular atributos con lógica normal y falla de forma explícita cuando le pasas una clave inexistente.

Hay una semántica que rompe a mucha gente: nvim_set_hl reemplaza, no fusiona. Si un grupo tenía fondo y tú llamas a la función pasando solo fg, el fondo desaparece. Para retocar un único atributo hay que leer, modificar y volver a escribir:

local hl = vim.api.nvim_get_hl(0, { name = "Comment" })
hl.italic = true
vim.api.nvim_set_hl(0, "Comment", hl)
🎨

Color

Los campos fg, bg y sp aceptan una cadena hexadecimal o un entero. El tercero, sp, es el color del subrayado y solo lo pintan las terminales que soportan subrayados con color propio.

🅱️

Énfasis

Booleanos bold, italic, reverse, standout, strikethrough y nocombine. El reverse intercambia frente y fondo y es la forma más portable de destacar algo sin elegir color.

〰️

Subrayados

Además de underline existen undercurl, underdouble, underdotted y underdashed. Son la señal preferida para diagnósticos porque no compiten con el color semántico del token.

🧮

Compatibilidad

Los campos ctermfg, ctermbg y cterm viven en la misma tabla y solo se usan con termguicolors apagado. Definirlos es lo que hace que tu tema degrade con dignidad en una consola pobre.

Inspect: interrogar al pixel

La pregunta operativa nunca es “qué grupos existen”, sino “qué grupo está coloreando este carácter”. Neovim responde con un comando:

:Inspect

Bajo el cursor te devuelve la pila completa: las capturas de Treesitter que cubren esa posición, los semantic tokens del servidor de lenguaje, el grupo de syntax clásico si lo hubiera y las extmarks activas. Su equivalente programático es vim.inspect_pos, útil cuando quieres el dato dentro de un script en vez de en una ventana flotante.

Cuando ya tienes el nombre, el siguiente paso es ver su definición efectiva. El comando :hi Keyword la imprime en formato clásico, y nvim_get_hl la devuelve como tabla:

vim.print(vim.api.nvim_get_hl(0, { name = "Keyword" }))

Este bucle de tres pasos —posición, nombre, definición— es el método universal para depurar color. No adivines qué grupo debería colorear algo: pregúntaselo al editor.

Dos comandos completan el arsenal. El primero, :hi sin argumentos, vuelca la tabla completa de grupos definidos; es abrumador, pero resulta insustituible cuando buscas el nombre de algo cuya posición no puedes situar bajo el cursor, como un elemento del menú de completado. El segundo, :InspectTree, abre el árbol sintáctico en una ventana lateral y sincroniza el cursor con él, lo que convierte la pregunta “por qué esto se clasifica así” en algo observable en vez de deducible.

Un último detalle que ahorra confusión: un mismo carácter puede estar cubierto por varios grupos a la vez, y Inspect te los muestra todos ordenados. Lo que ves en pantalla es el resultado de una resolución de prioridades entre capas, no la opinión de un único responsable. Esa pila es el asunto de la lección 15.3, pero conviene salir de aquí sabiendo que existe: buena parte de los “el tema no me aplica este color” son en realidad “otra capa lo está tapando”.

El nombre es la interfaz, el color es la implementación

Lo que de verdad está ocurriendo aquí es una inversión de dependencias clásica, resuelta con un vocabulario compartido. El resaltador de un lenguaje no depende de tu tema y tu tema no depende de ningún lenguaje: ambos dependen de un conjunto de nombres abstractos. Ese es el motivo por el que puedes instalar un parser nuevo y verlo coloreado al instante, y también el motivo por el que la única decisión verdaderamente irreversible al escribir un tema no es la paleta —los hexadecimales se cambian en un minuto— sino qué distinciones semánticas decides hacer visibles. Un tema que pinta igual una constante y una variable ha tomado una decisión epistemológica sobre tu código, no estética. Cuando entiendes que el color es una proyección de una taxonomía sobre un espacio perceptual, dejas de coleccionar temas y empiezas a diseñar el tuyo: primero decides qué quieres poder distinguir de un vistazo, y solo después buscas colores que lo hagan posible.

⚔️ Auditar el color de tu propio buffer
  1. Comprueba el estado de termguicolors y, si estaba apagado, enciéndelo y observa el cambio.
  2. Abre un archivo de tu lenguaje habitual, sitúa el cursor sobre una palabra clave y ejecuta Inspect. Anota la pila completa que devuelve.
  3. Imprime la definición efectiva de ese grupo con nvim_get_hl y localiza sus atributos.
  4. Redefine el grupo con nvim_set_hl pasando solo fg. Observa qué otros atributos has destruido sin querer y explica por qué.
  5. Repara el destrozo con el patrón leer, modificar y volver a escribir, y deja el resultado en tu configuración.