Neovim headless como intérprete de tests
Qué desaparece y qué sobrevive cuando arrancas el editor sin interfaz, cómo aislar la configuración con variables XDG y un runtimepath explícito, la diferencia entre el modo headless y el intérprete de Lua, y cómo construir fixtures de buffers reproducibles con limpieza garantizada.
La idea que hace posible todo lo anterior es engañosamente simple: Neovim es un programa que sabe funcionar sin nadie mirando. Con la bandera headless no arranca terminal, no dibuja ventanas y no espera pulsaciones, pero conserva intacto todo lo demás —buffers, opciones, autocomandos, LSP, Treesitter, el bucle de eventos— y ejecuta lo que le indiques hasta terminar con un código de salida. Eso lo convierte en algo que casi nadie usa a fondo: un intérprete de Lua con un editor de texto completo incorporado como biblioteca estándar. Un plugin es, en el fondo, un programa que se prueba invocando ese intérprete con una configuración que tú controlas por entero.
- Distinguir qué capacidades desaparecen sin interfaz y cuáles siguen plenamente disponibles.
- Aislar la ejecución de tu configuración personal con variables
XDGy unruntimepathexplícito. - Elegir entre el modo
headlesscon comandos y el intérprete de Lua directo según el caso. - Construir fixtures de buffers reproducibles y garantizar su destrucción aunque el caso falle.
Qué desaparece y qué sobrevive
Sin interfaz no hay pantalla, y por tanto no hay dimensiones reales de ventana, ni redibujado, ni resaltado visible, ni mensajes por la línea de comandos. Lo que sí sobrevive es prácticamente todo lo demás: el modelo de buffers y ventanas sigue existiendo aunque nadie lo vea, los autocomandos se disparan, los temporizadores corren, los procesos externos se lanzan, los servidores de lenguaje se conectan y los parsers de Treesitter analizan igual que siempre.
Esa asimetría dicta qué puedes afirmar. Puedes comprobar que existe una extmark con su grupo de resaltado y su posición; no puedes comprobar de qué color se ve. Puedes comprobar que una ventana flotante se creó con cierta configuración; no puedes comprobar si tapa a otra en pantalla. Puedes comprobar que un mapeo está registrado y que ejecutarlo cambia el buffer; no puedes comprobar cómo se ve el cursor al hacerlo.
# Ejecutar Lua y salir, sin interfaz y sin configuracion del usuario
nvim --headless -u NONE -c "lua print(vim.version().major)" -c "qa!"
# Arranque limpio pero con los directorios estandar en su sitio
nvim --headless --clean -c "lua print(#vim.api.nvim_list_bufs())" -c "qa!"
Hay un detalle operativo que se paga caro cuando se ignora: sin interfaz nadie cierra el editor por ti. Si tu invocación no termina en un comando de salida, el proceso se queda vivo indefinidamente, y en un servidor de integración continua eso se traduce en un trabajo que agota su tiempo máximo sin decir por qué. Toda invocación de prueba debe terminar sí o sí, y hacerlo con el código correcto.
Un autocmd que abre una ventana, un input esperando confirmación o una petición al servidor de lenguaje que nunca responde bloquean un editor sin interfaz exactamente igual que uno con ella, solo que sin nadie que vea el aviso. Fija un tiempo máximo en el propio comando de la suite y no confíes en el del sistema de integración continua.
Aislar la configuración
Una suite que carga tu configuración personal no prueba tu plugin: prueba tu plugin más treinta plugins ajenos, y sus fallos son irreproducibles para cualquier otra persona. El aislamiento tiene dos niveles y conviene usar ambos.
El primero es no leer la configuración: la bandera -u con un archivo propio sustituye el arranque estándar. El segundo, más profundo, es redirigir los directorios: Neovim resuelve dónde viven configuración, datos, estado y caché a partir de variables de entorno, así que exportarlas hacia un directorio temporal garantiza que nada de la máquina real se cuele y que nada de la prueba quede después.
# Aislamiento total: ningun archivo del usuario entra ni sale
export XDG_CONFIG_HOME="$PWD/.tests/config"
export XDG_DATA_HOME="$PWD/.tests/data"
export XDG_STATE_HOME="$PWD/.tests/state"
export XDG_CACHE_HOME="$PWD/.tests/cache"
nvim --headless -u tests/minimal_init.lua \
-c "PlenaryBustedDirectory tests/ { minimal_init = 'tests/minimal_init.lua' }"
-- tests/minimal_init.lua: construir el entorno desde cero, sin suponer nada
local raiz = vim.fn.fnamemodify(vim.fn.getcwd(), ":p")
local deps = raiz .. ".tests/site"
vim.opt.runtimepath:remove(vim.fn.stdpath("config")) -- fuera la config del usuario
vim.opt.runtimepath:append(raiz) -- el plugin bajo prueba
vim.opt.runtimepath:append(deps .. "/pack/deps/start/plenary.nvim")
vim.opt.packpath = deps
vim.opt.swapfile = false -- nada de archivos de intercambio en una carpeta temporal
vim.opt.shada = "" -- ni historial persistente entre ejecuciones
vim.opt.undofile = false
vim.g.mapleader = " " -- fijar lo que el codigo pueda leer
vim.cmd("runtime plugin/plenary.vim")
Fijar de forma explícita cosas aparentemente irrelevantes evita fallos desconcertantes. El archivo de intercambio provoca preguntas interactivas que cuelgan el proceso; el historial persistente hace que una ejecución influya en la siguiente; una opción de líder no definida rompe el registro de mapeos. La regla es que si tu código lee una opción o una variable global, la suite debe fijarla, porque su valor por defecto en un arranque limpio rara vez coincide con el que tú das por hecho.
Dos intérpretes, dos usos
Neovim ofrece además un modo distinto del headless clásico: ejecutar un archivo de Lua directamente, recibiendo los argumentos de la línea de comandos y saliendo al terminar. Arranca notablemente más rápido porque omite parte de la inicialización y no necesita comandos de cierre.
# Modo comandos: util cuando necesitas el arranque completo y comandos de usuario
nvim --headless -u tests/minimal_init.lua -c "PlenaryBustedDirectory tests/" -c "qa!"
# Interprete de Lua: script directo con argumentos, salida por stdout, sin comandos
nvim -l tests/ejecutar.lua --patron nucleo
-- tests/ejecutar.lua: recibe los argumentos en una variable global y sale con codigo propio
local patron, fallos = (_G.arg or {})[2] or "", 0
for _, archivo in ipairs(vim.fn.glob("tests/**/*_spec.lua", true, true)) do
if archivo:find(patron, 1, true) then
local ok, err = pcall(dofile, archivo)
if not ok then
fallos = fallos + 1
io.stderr:write(("fallo en %s: %s\n"):format(archivo, err))
end
end
end
os.exit(fallos == 0 and 0 or 1)
La elección entre ambos no es de gusto. El intérprete de Lua es ideal para herramientas auxiliares —generar documentación, validar esquemas de configuración, comprobar que todos los módulos cargan— porque su latencia de arranque es mínima. El modo headless con comandos es el correcto para la suite propiamente dicha, porque los ejecutores de pruebas se exponen como comandos de usuario y porque muchas rutas de código solo se inicializan en un arranque completo.
flowchart TD A[Proceso de tests] --> B[Variables XDG hacia un directorio temporal] B --> C[Arranque con init minimo y runtimepath explicito] C --> D[Eleccion de modo de ejecucion] D --> E[Modo headless con comandos para la suite] D --> F[Interprete de Lua para utilidades rapidas] E --> G[Codigo de salida distinto de cero si algo falla] F --> G style B fill:#89b4fa,color:#11111b style G fill:#a6e3a1,color:#11111b
Fixtures de buffers
Casi toda prueba de integración necesita lo mismo: un buffer con contenido conocido, un tipo de archivo asignado y la certeza de que desaparecerá al terminar. Merece la pena escribir esa utilidad una vez y usarla en todas partes, porque hacerlo a mano en cada caso es la fuente número uno de contaminación entre pruebas.
-- tests/helpers.lua
local M = {}
function M.buffer(lineas, ft)
local buf = vim.api.nvim_create_buf(false, true) -- no listado, de usar y tirar
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lineas)
if ft then vim.bo[buf].filetype = ft end
return buf
end
function M.con_buffer(lineas, ft, fn)
local buf = M.buffer(lineas, ft)
local previo = vim.api.nvim_get_current_buf()
vim.api.nvim_set_current_buf(buf)
local ok, err = pcall(fn, buf) -- el fallo no impide limpiar
if vim.api.nvim_buf_is_valid(previo) then vim.api.nvim_set_current_buf(previo) end
vim.api.nvim_buf_delete(buf, { force = true })
if not ok then error(err, 0) end
end
function M.lineas(buf) return vim.api.nvim_buf_get_lines(buf, 0, -1, false) end
return M
La forma de esa utilidad encierra dos decisiones que valen más que su código. La primera es que el buffer sea temporal y no listado: así no entra en la lista visible, no intenta escribirse en disco y no arrastra estado de archivo. La segunda es que la limpieza ocurra aunque el caso falle, envolviendo la ejecución en una llamada protegida y relanzando el error después de destruir el buffer. Sin eso, el primer fallo deja residuos y los siguientes casos empiezan a fallar por razones que no tienen nada que ver, produciendo el fenómeno más desmoralizante de una suite: un error real seguido de diez falsos que lo esconden.
Aislar por entorno
Redirigir las variables XDG a un directorio temporal es la única forma de garantizar que la máquina no influye.
Salir siempre
Sin interfaz nadie cierra el proceso. Todo camino, incluido el de error, debe terminar con un código explícito.
Limpiar en el fallo
La destrucción del fixture va fuera del camino feliz, o el primer error contaminará a todos los siguientes.
Vale la pena detenerse en por qué esta bandera existe y funciona tan bien, porque la explicación cómoda —que sirve para automatizar cosas— oculta algo mucho más interesante. Que Neovim pueda arrancar sin interfaz sin perder ninguna capacidad no es una funcionalidad añadida ni un modo degradado: es la manifestación externa de una decisión arquitectónica tomada en la raíz del proyecto, la de separar por completo el núcleo del editor de cualquier forma de presentación y comunicar ambos por un protocolo. El resultado es que la terminal que ves normalmente no es el editor, es simplemente el cliente por defecto de un servidor que existe con independencia de ella, y por eso el mismo núcleo puede atender a una interfaz gráfica, a un componente empotrado en otro programa, a un proceso de integración continua o a nadie en absoluto. Ahí está la implicación que cambia cómo piensas tu plugin, y no es la de poder ejecutar pruebas. Es que tu plugin nunca tuvo derecho a suponer que hay una pantalla. Cada vez que tu código lee el ancho de una ventana para decidir un formato, mide el número de columnas para truncar un texto o asume que existe un cursor con posición válida, está introduciendo un acoplamiento con un cliente concreto que puede no estar presente; y la suite sin interfaz no es que descubra ese acoplamiento por casualidad, es que lo provoca deliberadamente al ejecutar el núcleo en su forma desnuda. De ahí que las pruebas en modo headless encuentren una clase de defectos que ninguna revisión de código detecta: dimensiones supuestas, mensajes escritos donde nadie los lee, ventanas creadas sin comprobar el espacio disponible. Cuando entiendes que estás probando el servidor y no la aplicación que usas a diario, la disciplina deja de parecer un rodeo incómodo y se convierte en lo que realmente es: la única forma de saber que tu plugin depende de lo que dice depender.
- Escribe un
minimal_init.luaque retire la configuración del usuario delruntimepathy añada solo tu plugin y sus dependencias. - Exporta las cuatro variables
XDGhacia un directorio temporal y comprueba, tras ejecutar la suite, que no ha aparecido ningún archivo fuera de él. - Provoca a propósito un proceso que no termina y observa el comportamiento. Después añade un tiempo máximo a la invocación.
- Implementa la utilidad de fixture con limpieza garantizada y verifica que un caso que falla no deja buffers vivos.
- Compara el tiempo de arranque del modo
headlesscon comandos y el del intérprete de Lua sobre la misma tarea trivial, y decide cuál usarás para cada cosa.