Streaming: leer la salida según llega
Cómo consumir stdout en tiempo real en lugar de esperar al final: el callback por trozos, el reensamblado de líneas parciales, el aplazamiento seguro a la API del editor y el patrón para mostrar el progreso de un comando largo sin bloquear.
Recoger la salida entera al final es cómodo y es mentira. Una compilación de tres minutos ya emitió su primer error en el segundo cuatro, y tenerlo retenido en un búfer invisible hasta que el proceso muera no te ayuda: te frustra. El streaming invierte la relación. En vez de preguntar al final, escuchas mientras ocurre, y cada línea que llega es información utilizable de inmediato.
- Registrar un callback de
stdoutque reciba la salida por trozos. - Reensamblar líneas completas a partir de fragmentos arbitrarios.
- Aplazar con
vim.scheduletodo contacto con buffers y ventanas. - Construir un indicador de progreso que no congele el editor.
Trozos, no líneas
La opción stdout de vim.system acepta una función. Cuando la pasas, Neovim deja de acumular la salida y te la entrega según sale de la tubería:
vim.system({ 'cargo', 'build' }, {
text = true,
stdout = function(err, chunk)
if err then return end
if chunk == nil then return end -- fin de la tuberia
-- chunk es un trozo cualquiera, no una linea
end,
}, on_exit)
El contrato tiene dos avisos que hay que leer despacio. El primero: chunk vale nil exactamente una vez, cuando la tubería se cierra. Ese es tu EOF, y es donde te toca vaciar lo que quedara pendiente. El segundo, más traicionero: un trozo no es una línea. El kernel entrega lo que tiene cuando lo tiene. Puedes recibir tres líneas juntas, o media línea, o un solo carácter, y nada garantiza que un trozo empiece o acabe en un salto de línea.
Esa es la diferencia esencial con la API antigua. jobstart con on_stdout sí te entrega una lista de líneas, pero con una trampa documentada y olvidada por todos: el último elemento de la lista es siempre un fragmento incompleto que debes concatenar con el primer elemento de la siguiente llamada. Ambas APIs te obligan al mismo trabajo; vim.system simplemente es honesta sobre ello.
Si no usas text = true, los trozos son bytes crudos y el corte puede caer dentro de un carácter multibyte. Un acento partido en dos entregas se convierte en dos bytes inválidos si los procesas por separado. El reensamblado por líneas resuelve esto de paso, porque no interpreta nada hasta tener el salto de línea completo.
El reensamblador
El patrón es siempre el mismo y merece la pena escribirlo una vez bien: guardas un resto pendiente, le pegas cada trozo nuevo y extraes del acumulado todas las líneas completas que haya.
local function lector_de_lineas(cuando_haya_linea)
local pendiente = ''
return function(err, chunk)
if err then return end
if chunk == nil then
if pendiente ~= '' then cuando_haya_linea(pendiente) end
return
end
pendiente = pendiente .. chunk
while true do
local corte = pendiente:find('\n', 1, true)
if not corte then break end
local linea = pendiente:sub(1, corte - 1):gsub('\r$', '')
pendiente = pendiente:sub(corte + 1)
cuando_haya_linea(linea)
end
end
end
Tres detalles justifican cada línea. find con el tercer argumento en true busca literal, sin coste de patrón. El gsub final absorbe el retorno de carro que dejan las herramientas de Windows y los emuladores de terminal. Y la rama de chunk == nil es imprescindible: muchos programas no terminan su última línea con salto, y sin ese vaciado perderías precisamente el mensaje final, que suele ser el resumen.
flowchart LR p[Proceso hijo] --> t[Tuberia del kernel] t --> c[Trozos de tamano arbitrario] c --> r[Reensamblador con resto pendiente] r --> l[Lineas completas] l --> s[vim.schedule] s --> ui[Buffer o notificacion o quickfix]
Cruzar al hilo del editor
El callback de stdout corre en contexto rápido. Ahí no puedes crear buffers, ni escribir líneas, ni mover el cursor: casi toda la API está vetada porque el editor podría estar a mitad de una operación. vim.schedule encola tu función para el siguiente momento seguro del bucle principal.
La tentación es envolver cada línea en su propio vim.schedule. Funciona, y es un error de rendimiento serio: un comando que emite cinco mil líneas encolaría cinco mil tareas y cada una repintaría la pantalla. La solución es agrupar: acumula en una tabla desde el contexto rápido y vuelca por lotes.
local cola, programado = {}, false
local function encolar(linea)
cola[#cola + 1] = linea
if programado then return end
programado = true
vim.schedule(function()
programado = false
local lote = cola
cola = {}
vim.api.nvim_buf_set_lines(bufnr, -1, -1, false, lote)
end)
end
Este es un debounce natural: mientras el volcado no se ejecute, las líneas nuevas se suman al mismo lote. Con salida lenta se comporta como línea a línea; con salida torrencial, colapsa miles de eventos en un puñado de escrituras. El editor sigue fluido en ambos extremos sin que tú ajustes ningún temporizador.
Crea el destino con vim.api.nvim_create_buf(false, true) y márcalo modifiable = false entre escrituras. Un buffer scratch no pide guardar, no dispara autocomandos de escritura y no contamina la lista de buffers. Si además fijas nvim_buf_set_lines con índices -1, -1 estás anexando, que es la única operación barata cuando el buffer crece.
Progreso que se siente
Con las tres piezas montadas —lector de líneas, agrupación y buffer scratch— un indicador de progreso es casi gratis. Lo importante no es la animación sino qué información destilas de la corriente. Un contador de líneas es ruido; un contador de errores encontrados es señal.
local errores = 0
local total = 0
local function on_linea(linea)
total = total + 1
if linea:match('^error') then errores = errores + 1 end
encolar(linea)
end
La misma corriente alimenta dos consumidores con vidas distintas: el buffer, que quiere el texto íntegro para consultarlo después, y el contador, que solo quiere un resumen vivo en la línea de estado. Separar transporte de interpretación es lo que permite que más tarde puedas añadir un tercer consumidor —la quickfix, en la lección cuatro— sin tocar nada de lo anterior.
Queda un detalle de presentación que casi nadie resuelve bien: el desplazamiento automático. Si el usuario está mirando el buffer, quieres que siga el final; si se ha ido a leer un error de más arriba, arrastrarle el cursor cada cien milisegundos es intolerable. La condición correcta no es un ajuste, es una pregunta sobre dónde está el cursor:
local function anexar(bufnr, lote)
local win = vim.fn.bufwinid(bufnr)
local pegado = win ~= -1
and vim.api.nvim_win_get_cursor(win)[1] == vim.api.nvim_buf_line_count(bufnr)
vim.api.nvim_buf_set_lines(bufnr, -1, -1, false, lote)
if pegado then
vim.api.nvim_win_set_cursor(win, { vim.api.nvim_buf_line_count(bufnr), 0 })
end
end
Si el cursor estaba en la última línea, el usuario estaba siguiendo la salida y quiere continuar siguiéndola. Si no lo estaba, estaba leyendo, y lo respetas. Es la misma heurística que usan los visores de registros del sistema, y se nota exactamente cuando falta.
Se suele vender el streaming como una optimización de memoria: no acumulas la salida entera, luego consumes menos. Es la razón menos interesante y casi nunca la decisiva, porque la salida de un compilador rara vez llega a un megabyte. Lo que el streaming compra de verdad es latencia de la primera información útil, y esa es una magnitud cualitativamente distinta del tiempo total. Un proceso que tarda ciento ochenta segundos y te da su primer error en el segundo cuatro tiene, desde el punto de vista de tu ciclo de trabajo, una latencia de cuatro segundos: puedes empezar a arreglarlo mientras el resto compila. El mismo proceso leído al final tiene una latencia de ciento ochenta. El tiempo de cómputo es idéntico; el tiempo de tu atención bloqueada difiere en dos órdenes de magnitud. De ahí se sigue una consecuencia de diseño que cambia cómo escribes plugins: la unidad correcta de procesamiento no es el proceso, es el evento. En cuanto aceptas eso, tu envoltorio deja de ser una función que devuelve un resultado y pasa a ser una tubería con etapas —trozos, líneas, entidades, presentación— donde cada etapa tiene su propia política de agrupación y su propio destino. Y es entonces cuando aparece la tensión real del diseño, que no es memoria contra velocidad sino granularidad contra coste de sincronización: cada cruce al hilo del editor cuesta un repintado, así que la calidad de tu implementación se mide por cómo agrupa, no por cómo lee. El reensamblador es trivial; el debounce que lo acompaña es donde vive la ingeniería.
Pasa una función en la opción stdout para recibir la salida por trozos; nil marca el fin y es donde vacías el resto pendiente. Un trozo nunca es una línea: acumula y corta por el salto de línea. Todo contacto con el editor va dentro de vim.schedule, y agrupado por lotes con un debounce para no repintar miles de veces.
- Implementa
lector_de_lineasy úsalo conping -c 10para escribir cada respuesta en un buffer scratch conforme llega. - Añade el agrupado con el patrón de
programado. Compara el comportamiento contra unvim.schedulepor línea usando una salida enorme comofind /usr. - Ejecuta un comando cuya última línea no termine en salto y comprueba que sin la rama de
chunk == nilesa línea se pierde. - Registra también una función en
stderry escribe sus líneas en el mismo buffer con un prefijo distinto. Observa que el orden relativo entre ambos canales no está garantizado. - Añade un contador de errores que se refleje en
vim.o.statusliney que se actualice sin repintar el buffer entero.