Saltar al contenido

vim.system: la forma moderna de lanzar un proceso

La puerta única para ejecutar programas externos desde Lua: modo síncrono con :wait() y asíncrono con on_exit, el resultado con code, signal, stdout y stderr, la manija del proceso vivo con write y kill, y las opciones que gobiernan el entorno del hijo: cwd, env, stdin, text, timeout y detach.

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.

  • Ejecutar un programa externo con vim.system en modo síncrono y asíncrono.
  • Interpretar el resultado: code, signal, stdout y stderr, y no confundir los dos primeros.
  • Manejar el proceso vivo con la manija: write, kill, wait e is_closing.
  • Gobernar el entorno del hijo con cwd, env, stdin, text, timeout y detach.
  • Saber por qué el modo asíncrono es el correcto por defecto, y qué pinta vim.schedule aquí.

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.

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

La contrapartida de saltarse el shell es que los builtins del shell no existen: cd, export o el echo interno de tu intérprete no son ejecutables. Si de verdad necesitas uno, invoca el shell explícitamente —vim.system({ 'bash', '-c', 'help' })—, sabiendo que reintroduces todo lo que la lista te ahorraba.

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:

LUA
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 fast event context que conociste en la lección 12.3: un momento del bucle 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 E5560: nvim_… must not be called in a fast event context.

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 deja de contar la historia real.

stdout

La salida estándar completa, acumulada. Es nil si la desactivaste o si registraste tu propio handler, que es el modo streaming de la lección siguiente.

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.

La manija del proceso vivo

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

LUA
local proc = vim.system({ 'sort' }, { stdin = true, text = true }, on_done)proc:write({ 'pera', 'manzana' })  -- una lista escribe cada elemento con su \nproc:write(nil)                    -- cierra stdin: sort ya puede ordenarprint(proc.pid)                    -- identificador real en el sistema-- proc:kill('sigterm')            -- si te arrepientes; también acepta el número 15

write distingue entre formas: con una lista, escribe cada elemento seguido de un salto de línea; con una cadena, la escribe tal cual, sin añadir nada; con nil, cierra el canal. Ese 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.

La manija completa: pid, write, kill —acepta la señal como número o como nombre—, is_closing, y un wait(timeout) con su propio matiz: si le pasas un límite y vence, el proceso recibe SIGKILL, no el SIGTERM educado de la opción timeout. Guardar la manija es lo que en la lección 13.5 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.

text solo normaliza, no decodifica

La opción text = true hace exactamente una cosa: reemplaza \r\n por \n en stdout y stderr. No interpreta la codificación. Si la herramienta emite algo que no es UTF-8 válido, recibirás esos bytes tal cual, y Neovim no podrá insertarlos 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.

LUA
vim.system({ 'rg', '--json', 'TODO' }, {  cwd = vim.fs.root(0, '.git'),          -- raíz del repositorio, no el cwd de la ventana  env = { RIPGREP_CONFIG_PATH = '' },    -- neutraliza la config personal del usuario  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 —que además llega con NVIM apuntando al servidor del editor padre, detalle que los procesos hijos pueden usar para hablarle—; si quieres partir de cero, acompáñalo de clear_env = true.

stdin acepta tres formas, y conviene decir primero cuál es el defecto: si no lo pides, el hijo no recibe tubería de entrada, que es lo correcto para casi todo. Con una cadena o una lista de líneas, Neovim escribe ese contenido y cierra el canal él solo: es la forma cómoda de pasarle un buffer a un formateador sin tocar el disco. Con true, el canal queda abierto para que escribas tú con write, y cerrarlo con write(nil) pasa a ser responsabilidad tuya.

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.

Queda detach, la opción que casi nunca quieres: arranca el hijo como líder de su propio grupo de procesos, con la consecuencia de que sobrevivirá al cierre de Neovim. Es lo correcto para lanzar un demonio de verdad, y es una fábrica de huérfanos para todo lo demás; la lección 13.5 le pasa factura.

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

La propia ayuda de jobstart lo dice hoy en su primera línea: desde Lua, prefiere vim.system, salvo que necesites rpc, pty o term. Esas excepciones existen —un buffer de terminal necesita la opción term, y la verás en la lección 13.3—, pero para el noventa por ciento de los casos, lanzar algo, esperar su veredicto y reaccionar, vim.system 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:

LUA
-- anteslocal salida = vim.fn.systemlist('git status --porcelain')if vim.v.shell_error ~= 0 then return end-- despuéslocal obj = vim.system({ 'git', 'status', '--porcelain' }, { text = true }):wait()if obj.code ~= 0 then return endlocal 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 una manija 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. La manija sabe write —nil cierra stdin—, kill e is_closing. Fija siempre cwd y timeout.

Síncrono y legítimo: un rev-parse tarda milisegundos. La comprobación de code evita imprimir el stderr de Git como si fuera una rama.

Imprime 124 15: el code de la convención de coreutils y el SIGTERM con que Neovim lo terminó. Fíjate en que el editor no se congeló en ningún momento: el callback llegó solo.

La lista en stdin se escribe con un salto de línea por elemento y el canal se cierra solo. El vim.schedule es obligatorio: vim.notify toca la API y el callback corre en fast context (12.3).

Tres segundos moviendo el cursor con normalidad, y la notificación llega al final. Ese contraste con el primer drill es la lección entera en dos comandos.

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 —nvim_buf_get_lines te las da como lista— y muestra el resultado con vim.notify, sin escribir nada en disco.
  6. Guarda la manija de un sleep 30 en una variable global, comprueba is_closing(), mátalo con kill('sigterm') y vuelve a comprobarla.