Las capas de la API: nvim_, Vimscript y comandos ex
Neovim no ofrece una API sino tres superficies sobre el mismo núcleo —vim.api, vim.fn y vim.cmd—, y elegir mal entre ellas es la causa silenciosa de la mitad de las configuraciones frágiles.
Casi todo el mundo aprende a configurar Neovim por acumulación: copia una línea con vim.cmd, otra con vim.fn, otra con vim.api, y la config funciona sin que nadie llegue a preguntarse por qué hay tres formas de decir lo mismo. Funciona hasta que deja de hacerlo, y entonces el fallo es incomprensible porque no se entendió nunca la topología. La verdad es sencilla y vale la pena instalarla antes que cualquier otra cosa de este nivel: no hay tres APIs, hay un núcleo en C con tres puertas de entrada, y cada puerta cobra un peaje distinto en conversión de tipos, en efectos colaterales y en garantías de estabilidad. Elegir puerta no es cuestión de gusto: es una decisión de ingeniería con consecuencias medibles.
- Distinguir las tres superficies de la API y el camino que recorre cada llamada hasta el núcleo.
- Explicar por qué
vim.apies un contrato versionado y qué implica eso para tu configuración. - Anticipar las conversiones de tipo y las ambigüedades que introduce
vim.fn. - Elegir entre las tres capas con un criterio basado en efectos colaterales, no en velocidad.
Un solo núcleo, tres superficies
El núcleo de Neovim está escrito en C y expone su funcionalidad a través de una interfaz descrita en metadatos: cada función nvim_ declara sus parámetros, su tipo de retorno y la versión en la que apareció. Esa descripción no es documentación decorativa, es la fuente desde la que se genera tanto el enlace de Lua como el protocolo msgpack-RPC que hablan los clientes externos. Cuando escribes vim.api.nvim_buf_set_lines(...) estás usando exactamente la misma superficie que usa un plugin escrito en Python o el editor embebido en otro programa.
Las otras dos capas no son alternativas paralelas: son caminos más largos hacia el mismo sitio. vim.fn entrega la llamada al evaluador de Vimscript, que resuelve la función incorporada y devuelve un valor que hay que traducir de vuelta al mundo de Lua. vim.cmd entrega una cadena al analizador de la línea de comandos ex, que la descompone en comando, rango, modificadores y argumentos, y después ejecuta la acción como si la hubieras tecleado.
flowchart TB L[Tu codigo Lua] --> A[vim.api] L --> F[vim.fn] L --> C[vim.cmd] F --> V[Evaluador de Vimscript] C --> P[Parser de linea ex] A --> N[Nucleo en C] V --> N P --> N R[Cliente remoto msgpack RPC] --> A style A fill:#89b4fa,color:#11111b style N fill:#a6e3a1,color:#11111b
Por encima de las tres hay una cuarta cosa que conviene no confundir con ellas: la biblioteca estándar de Lua de Neovim, es decir vim.keymap, vim.diagnostic, vim.lsp, vim.treesitter, vim.fs. No es una capa de acceso al núcleo sino un conjunto de módulos escritos en Lua encima de vim.api, con ergonomía pensada para humanos. Cuando existe una función de esa biblioteca para lo que quieres hacer, casi siempre es la respuesta correcta.
vim.api: el contrato que también hablan los clientes remotos
Tres propiedades hacen de vim.api la capa de referencia para manipular estado.
La primera es que acepta manejadores explícitos. Una llamada como vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lineas) opera sobre el búfer indicado sin exigir que sea el búfer actual, sin cambiar de ventana y sin que el usuario perciba parpadeo alguno. El equivalente en comandos ex requeriría saltar al búfer, actuar y volver, con toda la contaminación que eso arrastra.
La segunda es que no imita a un usuario. No toca registros, no mueve la lista de saltos, no altera la marca de última posición. Modifica estado y punto. Esa asepsia es exactamente lo que quieres cuando escribes código que se ejecuta dentro de un autocomando y que no debe destruir el contexto de la persona que está editando.
La tercera es que está versionada. Los metadatos de cada función incluyen desde qué versión existe y, si procede, desde cuál está obsoleta. Eso convierte a la API en un contrato con el que puedes razonar sobre compatibilidad en lugar de descubrirla a golpes.
-- Reemplazar el contenido de un buffer que ni siquiera esta visible
local buf = vim.api.nvim_create_buf(false, true) -- no listado, scratch
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { "primera", "segunda" })
-- Indices basados en cero y final exclusivo: es la convencion de toda la API
local primeras = vim.api.nvim_buf_get_lines(buf, 0, 2, false)
-- Los errores de la API son errores de Lua, capturables
local ok, err = pcall(vim.api.nvim_buf_set_lines, 9999, 0, -1, false, {})
if not ok then
vim.notify("buffer invalido: " .. tostring(err), vim.log.levels.WARN)
end
vim.api numera líneas desde cero y trata el final del rango como exclusivo. vim.fn.line y los comandos ex numeran desde uno con final inclusivo. Mezclar ambas convenciones en la misma función es el error de desplazamiento por uno más frecuente en configuraciones de Neovim, y se manifiesta como una línea que sobra o falta justo en el borde.
vim.fn y vim.cmd: el peaje de la herencia
vim.fn existe porque Vimscript acumuló décadas de funciones útiles que nunca se reimplementaron en la API: expand, glob, fnamemodify, getcompletion, systemlist, strwidth. Usarlas es legítimo y a menudo inevitable. Lo que hay que tener presente es que cruzar la frontera cuesta una traducción de tipos, y esa traducción no es biyectiva.
local ruta = vim.fn.expand("%:p:h") -- string, sin sorpresas
local archivos = vim.fn.glob("*.lua", false, true) -- lista -> tabla Lua
-- Vimscript distingue lista vacia de diccionario vacio; Lua no
local d = vim.empty_dict() -- se convierte en dict, no en list
-- v:null no es nil: llega como vim.NIL y sobrevive dentro de tablas
La asimetría importante es esta: una tabla vacía de Lua se convierte por defecto en una lista vacía de Vimscript, y si la función esperaba un diccionario, el error aparecerá lejos del lugar donde lo causaste. Del mismo modo, vim.NIL no desaparece al recorrer una tabla como haría nil, así que un bucle ingenuo sobre un resultado con nulos se comporta distinto de lo previsto.
vim.cmd, por su parte, es la puerta de los efectos de usuario. Ejecutar vim.cmd("normal! dd") borra la línea igual que lo haría una persona: llena el registro sin nombre, desplaza los registros numerados, dispara los autocomandos correspondientes y actualiza la posición del cursor. A veces eso es precisamente lo que quieres, y sustituirlo por llamadas a la API sería reimplementar mal lo que el editor ya hace bien. Cuando no lo quieres, es un desastre silencioso.
-- Forma de cadena: comoda, pero exige escapar los argumentos a mano
vim.cmd("edit " .. vim.fn.fnameescape(ruta))
-- Forma estructurada: sin escapado, porque no hay linea de comandos que parsear
vim.cmd.edit(ruta)
vim.api.nvim_cmd({ cmd = "substitute", args = { "/foo/bar/g" }, range = { 1, 10 } }, {})
La forma estructurada merece un lugar de honor. vim.cmd.edit(ruta) no construye una cadena: arma una tabla de comando y la pasa por nvim_cmd, de modo que un nombre de archivo con espacios, corchetes o el carácter de porcentaje llega intacto. Toda la familia de fallos que se resuelven con capas de escapado desaparece por construcción.
Criterio de elección
Ordenar las capas por velocidad es una distracción. La diferencia de nanosegundos entre vim.api y vim.fn solo importa dentro de un bucle que se ejecuta miles de veces, y en ese caso el verdadero coste está en cruzar la frontera muchas veces, no en cuál cruzas. El criterio útil es otro y tiene tres preguntas.
Primera: ¿describo un cambio de estado o imito a un usuario? Si el resultado deseado es “el búfer siete debe contener estas líneas”, eso es vim.api. Si es “haz lo que haría un usuario al pulsar esta secuencia”, eso es vim.cmd con normal, precisamente porque quieres los efectos colaterales.
Segunda: ¿existe la función en la biblioteca estándar de Lua? Si la hay, gana siempre: vim.keymap.set sobre nvim_set_keymap, vim.fs.find sobre inventar rutas con glob, vim.system sobre vim.fn.system.
Tercera: ¿tengo API para esto? Si no la hay, vim.fn sin remordimientos. Nadie premia por reimplementar fnamemodify.
El ejercicio que fija el criterio es escribir la misma tarea por las tres puertas y mirar qué queda distinto. Añadir una línea al final del buffer actual admite estas tres formas.
-- Puerta ex: efectos de usuario completos, exige que el buffer sea el actual
vim.cmd("normal! Gotexto nuevo")
-- Puerta Vimscript: indices desde uno, sin efectos sobre registros
vim.fn.append(vim.fn.line("$"), "texto nuevo")
-- Puerta API: indices desde cero, manejador explicito, sin efectos colaterales
vim.api.nvim_buf_set_lines(0, -1, -1, false, { "texto nuevo" })
Las tres producen el mismo texto. La primera modifica el registro sin nombre y deja el cursor en la línea nueva en modo Inserción hasta que se sale de él; la segunda respeta registros y cursor pero solo opera sobre el buffer activo; la tercera acepta cualquier manejador y no altera nada más. Si tu función se ejecuta dentro de un autocomando de guardado, solo la tercera es defendible.
Una última observación sobre errores, que a menudo decide la elección en código de producción. Los fallos de vim.api se propagan como errores de Lua y se capturan con pcall, lo que permite degradar con elegancia. Los comandos ex pueden imprimir un mensaje sin lanzar nada, sobre todo bajo el prefijo de silencio, de modo que un fallo pasa inadvertido y el flujo continúa como si todo hubiera ido bien. Cuando necesitas saber con certeza si una operación tuvo éxito, la API es la única que te lo dice sin ambigüedad.
Estado contra actuacion
vim.api describe estado y no ensucia el contexto. vim.cmd actúa como un usuario y ensucia el contexto a propósito. Elegir es decidir cuál de las dos cosas necesitas.
La forma estructurada primero
vim.cmd.edit(ruta) y nvim_cmd eliminan el escapado de argumentos. Construir comandos por concatenación de cadenas es la última opción, no la primera.
Lo que separa a quien configura Neovim de quien lo domina es entender que vim.api no fue diseñada para tu init.lua. Fue diseñada para que un proceso externo, escrito en cualquier lenguaje, pudiera conducir el editor por un socket sin compartir memoria con él. Todas sus decisiones se deducen de ahí. Los manejadores numéricos existen porque un puntero no viaja por un socket. Los índices basados en cero existen porque el protocolo se pensó para programas, no para la comodidad de quien cuenta líneas a ojo. La ausencia de efectos colaterales existe porque un cliente remoto no tiene un cursor que proteger ni un registro que respetar. Los metadatos de versión existen porque un cliente compilado hace dos años debe seguir funcionando. Y la consecuencia práctica es enorme: cuando escribes tu configuración en vim.api, no estás usando una capa de conveniencia sino el único contrato que Neovim se ha comprometido a mantener estable, el mismo que sostiene a todo el ecosistema de plugins y clientes. Vimscript y los comandos ex no tienen ese compromiso; tienen algo distinto y también valioso, que es la compatibilidad histórica y la semántica de usuario. Por eso el criterio final no es purista sino topológico: usa la frontera estable cuando programes contra el editor, y usa la superficie de usuario cuando quieras que el editor se comporte como si hubiera un usuario. Confundir las dos es escribir código que funciona en tu máquina y falla en la de otro.
- Toma cinco líneas de tu configuración actual que usen
vim.cmdy clasifica cada una: ¿buscabas el efecto de usuario o solo el cambio de estado? Reescribe las que caigan en el segundo grupo. - Escribe una función que inserte texto en un búfer que no esté visible. Hazla primero con comandos ex y después con
nvim_buf_set_lines. Compara qué le pasa al cursor y a los registros en cada caso. - Llama a una función de
vim.fnque devuelva una lista vacía y otra que devuelva un diccionario vacío. Imprime ambas convim.printy explica por qué se ven iguales desde Lua. - Sustituye una concatenación de cadenas en un
vim.cmdpor su forma estructurada equivalente. Prueba después con un nombre de archivo que contenga espacios y corchetes. - Busca en tu configuración una llamada que mezcle índices de
vim.apicon índices devim.fn. Si no encuentras ninguna, escríbela a propósito y observa el desplazamiento por uno.