wandres.dev
PROCESOS EXTERNOS · jobs y terminal

vim.system: la forma moderna de lanzar un proceso

La API canónica para ejecutar programas externos desde Lua: una sola función con dos modos, síncrono con wait y asíncrono con callback, la captura de stdout y stderr, el código de retorno y las opciones que gobiernan el entorno del hijo.

⏱ 18 min

Un editor que no sabe hablar con el sistema operativo es un procesador de textos caro. Todo lo que hace útil a Neovim en un flujo real —compilar, formatear, consultar git, invocar un linter— ocurre fuera de su proceso. Durante años ese diálogo se hizo con funciones de Vimscript heredadas, cada una con su propia forma de mentir sobre los errores. vim.system las sustituye a todas: una única puerta, con semántica explícita y sin sorpresas.

🎯 Al terminar esta lección sabrás
  • Ejecutar un programa externo con vim.system en modo síncrono y asíncrono.
  • Interpretar el objeto de resultado: code, signal, stdout y stderr.
  • Gobernar el entorno del hijo con cwd, env, stdin, text y timeout.
  • Entender por qué el modo asíncrono es el correcto por defecto.

Una función, dos modos

vim.system recibe una lista de argumentos, no una cadena de shell. Esta decisión es la primera línea de defensa: no hay expansión de comodines, ni división por espacios, ni inyección posible a través de un nombre de archivo con caracteres raros. El primer elemento es el ejecutable; el resto son sus argumentos, uno por posición.

local obj = vim.system({ 'git', 'rev-parse', '--abbrev-ref', 'HEAD' }, { text = true }):wait()
print(obj.code, vim.trim(obj.stdout))

La llamada devuelve un objeto de proceso de inmediato. Encadenar :wait() lo convierte en síncrono: el bucle de eventos se detiene ahí hasta que el hijo termina. Es cómodo para un dato instantáneo, y es veneno para cualquier cosa que tarde: mientras esperas, Neovim no repinta, no atiende teclas y no responde al LSP.

El mismo trabajo en asíncrono se hace pasando una función como tercer argumento. vim.system retorna al instante y tu callback se invoca cuando el proceso muere:

vim.system({ 'cargo', 'build' }, { text = true }, function(obj)
  vim.schedule(function()
    if obj.code == 0 then
      vim.notify('build ok')
    else
      vim.notify(obj.stderr, vim.log.levels.ERROR)
    end
  end)
end)

El vim.schedule no es decorativo. El callback corre en el llamado fast event context, un momento del bucle de eventos donde la mayoría de la API de Neovim está prohibida. vim.schedule aplaza el cuerpo al siguiente instante seguro, y omitirlo produce el clásico error de API llamada desde un contexto rápido.

flowchart TB
cmd[vim.system con la lista de argumentos] --> s[Modo sincrono con wait]
cmd --> a[Modo asincrono con callback]
s --> f[El bucle de eventos se congela hasta el final]
a --> l[El bucle de eventos sigue libre]
l --> cb[El callback recibe code signal stdout stderr]
cb --> sch[vim.schedule para tocar la API del editor]

Anatomía del resultado

Tanto wait() como el callback entregan la misma tabla. Sus cuatro campos cuentan la historia completa de la vida del proceso.

🔢

code

El código de salida. Cero significa éxito por convención universal de POSIX. Cualquier otro valor es un fallo cuyo significado define el programa.

💀

signal

La señal que lo mató, si murió por señal. Un proceso terminado a la fuerza tiene signal distinto de cero, y su code no significa nada.

📤

stdout

La salida estándar completa, acumulada. Con text = true es una cadena; sin él, bytes crudos con los finales de línea intactos.

📛

stderr

El canal de diagnóstico. Muchas herramientas escriben aquí incluso cuando triunfan, así que no lo uses como sinónimo de error.

La regla operativa es simple: code decide si hubo éxito, stderr explica por qué no lo hubo. Confundir los dos es el error más frecuente al envolver herramientas. Un grep sin coincidencias devuelve 1 y no ha fallado; un compilador puede devolver 0 y haber escrito avisos en stderr.

El objeto que vim.system devuelve —antes de esperarlo— también tiene vida propia. Es una manija sobre el proceso vivo, y expone justo lo necesario para gobernarlo desde fuera:

local proc = vim.system({ 'sort' }, { stdin = true, text = true }, on_done)
proc:write('pera\nmanzana\n')   -- alimenta stdin
proc:write(nil)                  -- cierra stdin: sort ya puede ordenar
print(proc.pid)                  -- identificador real en el sistema
-- proc:kill(15)                 -- si te arrepientes

Guardar esa manija es lo que más adelante te permitirá cancelar, matar al cerrar un buffer y evitar que dos ejecuciones compitan. Tirarla y quedarte solo con el callback funciona hasta el día en que necesitas interrumpir algo, y ese día llega siempre.

El write(nil) merece un subrayado: cerrar la entrada estándar no es cortesía, es la señal que muchas herramientas esperan para empezar a trabajar. Un sort con stdin abierto no ordena nada porque, correctamente, sigue creyendo que puede llegar más entrada.

⚠️
text solo normaliza, no decodifica

La opción text = true convierte la salida en cadena y traduce los retornos de carro, pero no interpreta la codificación. Si la herramienta emite algo que no es UTF-8 válido, recibirás bytes que Neovim no podrá insertar limpiamente en un buffer. Para salida binaria —un archivo comprimido, una imagen— omite text y trata el resultado como lo que es.

El entorno del hijo

Un proceso hereda de su padre más de lo que parece: el directorio actual, las variables de entorno, la entrada estándar. Dejar esa herencia al azar es la causa número uno de plugins que funcionan en la máquina de su autor y en ninguna otra.

vim.system({ 'rg', '--json', 'TODO' }, {
  cwd = vim.fs.root(0, '.git'),          -- raiz del repositorio, no el cwd de la ventana
  env = { RIPGREP_CONFIG_PATH = '' },    -- neutraliza la config personal del usuario
  stdin = false,                          -- el hijo no espera entrada
  text = true,
  timeout = 5000,                         -- milisegundos
}, on_done)

cwd merece atención especial. El directorio de trabajo de Neovim puede cambiar por ventana o por pestaña, así que anclarlo explícitamente a la raíz del proyecto hace el comportamiento reproducible. env añade al entorno heredado; si quieres partir de cero, acompáñalo de clear_env = true.

stdin acepta tres formas: false para cerrar la entrada de inmediato, una cadena o lista de líneas para alimentar al hijo de una vez, o true para dejar el canal abierto y escribir después con el método write del objeto. Esa tercera forma es la que permite pasarle un buffer a un formateador sin tocar el disco.

timeout es la única red de seguridad barata contra un proceso que no termina. Al vencer, Neovim envía SIGTERM y fija code en 124, la misma convención que la utilidad timeout de coreutils. Sin él, un comando colgado se queda colgado para siempre.

Lo que sustituye

Neovim arrastra tres generaciones de API para lo mismo, y saber cuál estás leyendo es parte de saber leer configuraciones ajenas.

Generación Función Problema
Vimscript system y systemlist Síncrona, pasa por el shell, mezcla stderr y no distingue señales
Job control jobstart y jobwait Asíncrona y correcta, pero de bajo nivel y con callbacks por trozos
Moderna vim.system Los dos modos, resultado estructurado, cierre y limpieza automáticos

vim.system no sustituye a jobstart en todo: para un buffer de terminal o para streaming fino sigue habiendo razones para bajar un nivel, y las verás en las dos lecciones siguientes. Pero para el noventa por ciento de los casos —lanzar algo, esperar su veredicto, reaccionar— es la respuesta correcta y la única que deberías escribir en código nuevo.

Si heredas una configuración antigua, la traducción es casi mecánica:

-- antes
local salida = vim.fn.systemlist('git status --porcelain')
if vim.v.shell_error ~= 0 then return end

-- despues
local obj = vim.system({ 'git', 'status', '--porcelain' }, { text = true }):wait()
if obj.code ~= 0 then return end
local salida = vim.split(obj.stdout, '\n', { trimempty = true })

Fíjate en lo que desaparece con el cambio: la variable global vim.v.shell_error, que era estado compartido y por tanto imposible de usar en código asíncrono; el paso por el shell, con su citación y sus comodines; y la ambigüedad sobre dónde acabó stderr. Lo que ganas no es sintaxis más limpia, es un contrato que se puede razonar en concurrencia.

El proceso como frontera de confianza

Cuando llamas a vim.system no estás llamando a una función: estás cruzando la frontera más dura que existe en tu programa. Al otro lado hay un espacio de direcciones distinto, con su propio planificador, su propia tabla de descriptores y su propia idea de qué significa fallar. Esa frontera impone tres asimetrías que ninguna abstracción puede borrar. La primera es temporal: el hijo no tiene ninguna obligación de terminar, y todo el diseño de la API —el timeout, el kill, el modo asíncrono— existe para que esa libertad no se convierta en tu bloqueo. La segunda es de canal: stdout y stderr no son campos de un objeto sino tuberías con búfer del kernel, y el objeto plano que recibes es una ficción cómoda construida acumulando trozos que llegaron en un orden que tú no controlas. La tercera, y la más subestimada, es de contrato: el único vocabulario compartido entre padre e hijo es un entero de ocho bits y dos flujos de bytes sin estructura. Todo lo demás —que 1 significa no encontrado, que el formato de salida es estable, que stderr solo lleva errores— es convención local de cada herramienta, no garantía del sistema. Por eso la lista de argumentos importa más que la comodidad de una cadena de shell: al pasar una lista eliminas un intérprete entero de la cadena de confianza, y con él la clase completa de fallos que nacen de que alguien llame a un archivo con un espacio o un punto y coma en el nombre. Un plugin robusto no es el que llama bien al proceso; es el que trata cada uno de esos tres huecos como un caso a manejar, no como un detalle a ignorar.

📝
Lo esencial

vim.system recibe una lista de argumentos y devuelve un objeto de proceso. Con :wait() bloquea; con un callback como tercer argumento no bloquea, y ese callback debe envolver en vim.schedule cualquier acceso a la API. El resultado trae code, signal, stdout y stderr: code decide el éxito, stderr lo explica. Fija siempre cwd y timeout.

⚔️ Tu primer envoltorio honesto
  1. Escribe una función rama_actual que use vim.system con :wait() y devuelva el nombre de la rama de Git, o nil si code no es cero.
  2. Reescríbela en modo asíncrono, recibiendo un callback, y comprueba con vim.notify que el editor sigue respondiendo mientras corre.
  3. Lanza sleep 10 con timeout = 1000 e imprime code y signal. Explica qué valor toma cada uno y por qué.
  4. Ejecuta grep sobre un patrón inexistente y comprueba que code vale 1 sin que haya ocurrido ningún error real.
  5. Alimenta a sort por stdin con las líneas del buffer actual y muestra el resultado con vim.notify, sin escribir nada en disco.