wandres.dev
LUA · el lenguaje del editor

Lua dentro de Neovim: ejecutar, inspeccionar y el puente con Vimscript

Las formas de ejecutar Lua en el editor, en un archivo y en modo intérprete; la inspección de valores con vim.print y vim.inspect; y el puente bidireccional con Vimscript mediante vim.cmd, vim.fn y el prefijo de llamada desde Vimscript, con las conversiones de tipos que se pierden al cruzar.

⏱ 18 min

Sabes Lua y sabes cómo se cargan los módulos. Falta la frontera: dónde termina el lenguaje y empieza el editor. Neovim ejecuta Lua en varios contextos distintos, imprime valores de tres maneras diferentes y mantiene un puente bidireccional con treinta años de Vimscript que no piensa desaparecer. Esta lección cierra el nivel enseñándote a moverte por esa frontera sin perder información por el camino.

🎯 Al terminar esta lección sabrás
  • Ejecutar Lua desde el editor, desde un archivo y desde la línea de comandos.
  • Inspeccionar valores con vim.print, vim.inspect y la lista de mensajes.
  • Llamar a Vimscript desde Lua y a Lua desde Vimscript.
  • Anticipar las conversiones de tipos que ocurren al cruzar el puente.

Cuatro maneras de ejecutar Lua

La primera es el comando :lua, que evalúa un fragmento en el contexto del editor vivo. Su variante más útil lleva un signo igual: :lua =expr imprime el valor de la expresión, y desde hace varias versiones existe la abreviatura :=expr, el equivalente más cercano a una consola interactiva.

:lua vim.opt.number = true
:lua =vim.bo.filetype
:=vim.fn.getcwd()
:lua for _, b in ipairs(vim.api.nvim_list_bufs()) do print(b) end

Para varias líneas dentro de un archivo de Vimscript existe el documento incrustado, que delimita un bloque completo:

lua << EOF
local mapa = { n = "normal", i = "insercion" }
vim.print(mapa)
EOF

La segunda forma es ejecutar un archivo entero. :luafile ruta.lua lo hace explícitamente, y :source ruta.lua también, porque desde Neovim 0.9 el comando distingue por extensión. Ninguna de las dos pasa por package.loaded, así que el archivo se reejecuta cada vez: son la herramienta natural para iterar sobre un script suelto, no para módulos.

:luafile %
:source %

La tercera es el modo intérprete: nvim -l script.lua arranca Neovim sin interfaz, ejecuta el script y sale. Tienes toda la API, el bucle de eventos y vim.fn disponibles, pero sin dibujar una sola celda de pantalla. Es la manera canónica de escribir herramientas y tests que necesiten el editor como biblioteca.

nvim -l ./scripts/generar.lua --salida build/
nvim --headless -c "lua require('mi.tarea').run()" -c "qa!"

La cuarta la conoces desde el nivel 1 y es la que de verdad importa: los archivos que Neovim carga solo. El init.lua, todo lo que él requiera, y los directorios plugin, ftplugin y after del runtimepath, que se ejecutan automáticamente en el orden que ya estudiaste.

Ver lo que está pasando

Depurar configuración es sobre todo mirar valores, y aquí hay una jerarquía clara. print convierte con tostring, así que una tabla se muestra como table: 0x55f..., un dato inútil. vim.inspect serializa cualquier valor a una cadena legible y recursiva. vim.print hace lo mismo pero además lo imprime, respetando las tablas y devolviendo sus argumentos intactos, lo que permite intercalarlo en medio de una expresión sin alterar el flujo.

print(vim.bo)                       --> table: 0x7f9c...   inservible
vim.print(vim.bo.filetype, vim.bo.shiftwidth)
vim.print(vim.api.nvim_get_mode())  --> { blocking = false, mode = "n" }

local texto = vim.inspect(config, { depth = 2, newline = " ", indent = "" })
vim.notify(texto, vim.log.levels.DEBUG)

Los mensajes que emiten estas funciones se acumulan y se consultan con :messages. Cuando algo falla durante el arranque, ese es el primer sitio donde mirar, junto con :checkhealth. Y para avisos dirigidos al usuario, vim.notify es la vía correcta, porque respeta el nivel y puede ser reemplazada por un plugin de notificaciones.

💡
El truco de intercalar `vim.print`

Como vim.print devuelve sus argumentos, puedes envolver cualquier subexpresión sin tocar la lógica: local x = vim.print(calcular(a)) sigue asignando lo mismo y, de paso, te enseña el valor. Es lo más parecido a un punto de traza sin depurador.

El puente hacia Vimscript

Neovim no reescribió treinta años de Vimscript: lo mantiene y expone tres caminos hacia él. vim.cmd ejecuta comandos Ex, en su forma de cadena o en la forma estructurada, más segura porque no hay que escapar nada. vim.fn da acceso a cualquier función de Vimscript, incluidas las de plugins escritos en ese lenguaje. Y nvim_exec2 evalúa bloques enteros capturando su salida.

vim.cmd("colorscheme catppuccin")               -- forma de cadena
vim.cmd.colorscheme("catppuccin")               -- forma estructurada
vim.cmd({ cmd = "write", bang = true })         -- control total

local ancho = vim.fn.strdisplaywidth("añadir")  -- funcion de Vimscript
local hay   = vim.fn.executable("rg") == 1      -- devuelve 0 o 1, no booleano
local salida = vim.api.nvim_exec2("verbose set shiftwidth?", { output = true })

En sentido contrario, el prefijo v:lua permite llamar funciones Lua desde cualquier contexto de Vimscript: expresiones, opciones que aceptan una función y mapeos de tipo expresión.

set foldtext=v:lua.require'mi.folds'.texto()
set statusline=%!v:lua.require'mi.linea'.render()
inoremap <expr> <Tab> v:lua.require'mi.tab'.siguiente()
flowchart LR
L[Codigo Lua] -->|vim.cmd| V[Comandos Ex]
L -->|vim.fn| F[Funciones de Vimscript]
L -->|vim.api| A[API de Neovim]
V2[Vimscript] -->|v dos puntos lua| L
V2 -->|luaeval| L
A --> N[Estado del editor]
F --> N
V --> N
style L fill:#89b4fa,color:#11111b
style N fill:#a6e3a1,color:#11111b

La pregunta natural es cuál usar. La regla práctica: prefiere vim.api cuando exista, porque es la superficie estable, tipada y pensada para ser llamada desde código; usa vim.fn para lo que solo existe en Vimscript, como las funciones de cadena o de rutas; y reserva vim.cmd para comandos de usuario y órdenes que no tienen equivalente en la API.

Lo que se pierde al cruzar

Cada llamada a vim.fn o a la API convierte valores entre el mundo de Lua y el de Vim, y esa conversión no es biyectiva. Conviene tenerla presente porque genera errores silenciosos.

🚫

El vacío ambiguo

Una tabla {} vacía cruza como lista vacía. Si necesitas un diccionario vacío, usa vim.empty_dict().

Tres formas de nada

El nil de Lua borra claves y no viaja; lo que llega de Vim como v:null se representa con la centinela vim.NIL, distinta de nil y de false.

🔢

Booleanos que no lo son

Muchas funciones de Vimscript devuelven 0 o 1, y en Lua ambos son verdaderos. Compara siempre de forma explícita contra 1.

Hay una asimetría más, que es la de listas y diccionarios: como Lua usa una sola estructura para los dos, la conversión decide por la forma de la tabla. Una tabla con claves consecutivas desde 1 se convierte en lista; una con claves de cadena, en diccionario; una tabla mixta no tiene traducción y provoca un error. Por último, las cadenas de Lua son secuencias de bytes sin codificación, igual que en Vim, así que para contar caracteres visibles necesitas vim.fn.strchars o las funciones vim.str_utfindex y vim.str_byteindex.

vim.fn.setqflist({ { filename = "a.lua", lnum = 1, text = "aviso" } })  -- lista
vim.fn.json_encode(vim.empty_dict())          --> {}   y no []
if vim.fn.filereadable(ruta) == 1 then end    -- comparacion explicita
print(#"añadir", vim.fn.strchars("añadir"))   --> 8  6   bytes frente a caracteres
Una frontera, no dos editores

Cierra el nivel con esta idea, porque ordena todo lo demás. Neovim no es un editor con Lua pegado al lado: es un núcleo en C con un estado —buffers, ventanas, opciones, registros— y varias fachadas que hablan con él. Vimscript es la fachada histórica; la API con sus funciones nvim_ es la fachada estable, la misma que usan los clientes remotos por RPC; y Lua es la fachada que ejecutas dentro del proceso, con acceso directo a las otras dos. Nada de lo que escribes en Lua es privilegiado por ser Lua: cuando llamas a vim.fn.expand estás cruzando al intérprete de Vimscript y volviendo, y cuando llamas a vim.api.nvim_buf_set_lines estás invocando exactamente la misma función que invocaría un plugin escrito en Python al otro lado de un socket. Por eso las conversiones de tipos existen y por eso duelen: hay una serialización real entre modelos de datos distintos, y la ambigüedad entre lista y diccionario o entre nil y v:null es el precio de que un lenguaje con una sola estructura compuesta hable con otro que tiene dos. Interiorizar esa arquitectura te da dos superpoderes. El primero, saber siempre a qué capa dirigirte y en qué orden preferirlas. El segundo, y más valioso, entender que el editor entero es programable desde fuera: cualquier cosa que hagas a mano tiene un equivalente en la API, y a partir del nivel siguiente vas a dejar de configurar opciones para empezar a escribir programas que manejan el editor. Lua era solo el idioma; lo que viene ahora es el vocabulario.

⚔️ Cruza el puente en las dos direcciones
  1. Imprime con := el valor de tres opciones de buffer y explica en qué se diferencia de usar print.
  2. Escribe un script que se ejecute con nvim -l, lea un archivo con la API y escriba un resumen por la salida estándar.
  3. Convierte tres llamadas de vim.cmd de tu configuración a su equivalente en vim.api o vim.fn y justifica cada elección.
  4. Demuestra con json_encode la diferencia entre una tabla vacía y vim.empty_dict(), y describe un caso real donde importe.
  5. Define una función Lua en un módulo y llámala desde una opción de Vimscript mediante v:lua, comprobando que recibe los argumentos esperados.