Las capas de la API: vim.api, vim.fn y vim.cmd
Neovim no tiene tres APIs sino un núcleo en C con tres puertas de entrada, y cada una cobra un peaje distinto: vim.api es el contrato versionado que hablan también los clientes remotos, vim.fn cruza al evaluador de Vimscript traduciendo tipos, y vim.cmd imita a un usuario con todos sus efectos. Elegir puerta con criterio es la diferencia entre una config robusta y una frágil.
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 de
vim.fn:vim.NIL,vim.empty_dict()y la tabla vacía ambigua. - Usar
vim.cmden sus dos formas —cadena y estructurada— sabiendo qué resuelve cada una. - Elegir entre las tres capas con un criterio basado en efectos colaterales, no en velocidad.
Un solo núcleo, tres puertas
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 el nivel de API en el que apareció. Esa descripción no es documentación decorativa: es la fuente desde la que se generan 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, una GUI, o el editor embebido en otro programa —de esa arquitectura hablaremos a fondo en el Nivel 26.
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.
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, vim.system. 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.keymap.set la explotarás en el Nivel 11, vim.system en el Nivel 13. El puente Lua–Vimscript ya lo cruzaste por primera vez en la lección 7.5; aquí le ponemos el mapa completo.
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 buffer indicado sin exigir que sea el buffer actual, sin cambiar de ventana y sin que el usuario perciba parpadeo alguno. El equivalente en comandos ex requeriría saltar al buffer, actuar y volver, con toda la contaminación que eso arrastra. En el Nivel 10 construirás herramientas enteras sobre esta propiedad.
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 y bajo contrato. Los metadatos de cada función incluyen desde qué nivel de API existe y, si procede, desde cuál está obsoleta, y :help api-contract enumera lo que el proyecto se compromete a no romper: las firmas publicadas no cambian, los diccionarios de opciones solo se extienden, y las funciones deprecadas no se eliminarán hasta Neovim 2.0. Eso convierte a la API en algo con lo que puedes razonar sobre compatibilidad en lugar de descubrirla a golpes.
-- Reemplazar el contenido de un buffer que ni siquiera esta visiblelocal buf = vim.api.nvim_create_buf(false, true) -- no listado, scratchvim.api.nvim_buf_set_lines(buf, 0, -1, false, { "primera", "segunda" })-- Indices basados en cero y final exclusivo: la convencion de toda la APIlocal primeras = vim.api.nvim_buf_get_lines(buf, 0, 2, false)-- Los errores de la API son errores de Lua, capturableslocal 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)endLos índices de la API no son los de Vimscript
vim.api numera líneas desde cero y trata el final del rango como exclusivo; -1 denota la última línea o columna. 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.
Y hay excepciones documentadas dentro de la propia API (:help api-indexing): las funciones de marcas y cursor —nvim_win_get_cursor, nvim_win_set_cursor, nvim_buf_get_mark— usan indexación «de marca»: fila desde uno, columna desde cero. Y las cuatro funciones de extmarks usan base cero con final inclusivo. No es capricho: es compatibilidad con lo que esas estructuras siempre devolvieron. Cuando una coordenada te baile, la primera sospecha es esta.
vim.fn: el peaje de la traducción
vim.fn existe porque Vimscript acumuló décadas de funciones útiles que nunca se reimplementaron en la API: expand, glob, fnamemodify, getcompletion, strwidth, mode. 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.
Números, cadenas, booleanos, listas y diccionarios cruzan sin sorpresas; los flotantes de Vimscript llegan como números de Lua. Los problemas están en los bordes:
local ruta = vim.fn.expand("%:p:h") -- cadena, sin sorpresaslocal archivos = vim.fn.glob("*.lua", false, true) -- lista -> tabla de Lua-- Lua no distingue lista vacia de diccionario vacio; Vimscript sivim.fn.type({}) -- 3: la tabla vacia cruza como LISTAvim.fn.type(vim.empty_dict()) -- 4: marcada como diccionario-- v:null no es nil: llega como vim.NIL y sobrevive dentro de tablasLa 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. vim.empty_dict() marca la tabla con una metatabla que fuerza la conversión a diccionario. En sentido contrario, v:null no puede convertirse en nil —en una tabla de Lua, nil significa «clave ausente», y {"foo", nil} es indistinguible de {"foo"}—, así que llega como el valor especial vim.NIL, que es verdadero en una condición y no desaparece al recorrer la tabla. Un bucle ingenuo sobre un resultado con nulos se comporta distinto de lo previsto.
vim.fn no funciona en cualquier contexto
La mayoría de las funciones de vim.fn no pueden ejecutarse dentro de un fast event —los callbacks de bajo nivel del bucle de eventos, como los de vim.uv—. Si has llegado aquí desde código asíncrono y una llamada explota con un error sobre contexto, la respuesta es vim.schedule(). La frontera completa entre lo rápido y lo diferido es el tema central de la lección 12.3.
vim.cmd: la puerta de los efectos de usuario
vim.cmd es la puerta que imita a una persona. Ejecutar vim.cmd("normal! dd") borra la línea igual que lo haría un usuario: llena el registro sin nombre, desplaza los registros numerados, dispara los autocomandos correspondientes y mueve el 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.
vim.cmd tiene dos formas, y no son equivalentes. La forma de cadena admite Vimscript multilínea —por debajo es un alias de nvim_exec2, que se comporta como un :source anónimo— pero te obliga a escapar los argumentos a mano. La forma estructurada arma una tabla de comando y la pasa por nvim_cmd, sin línea de comandos que parsear:
-- Forma de cadena: comoda, pero exige escapar a manovim.cmd("edit " .. vim.fn.fnameescape(ruta))-- Forma estructurada: cada argumento es un elemento, no un trozo de cadenavim.cmd.edit(ruta)vim.cmd.resize({ "+2", mods = { vertical = true } })-- Y si el nombre contiene % o #, apaga su expansion:vim.cmd.edit({ ruta, magic = { file = false } })La forma estructurada elimina por construcción los fallos de espacios y comillas: un argumento es un elemento de la tabla, no un trozo de cadena que el parser pueda partir. Pero ojo con un matiz que casi todas las guías pasan por alto: los caracteres especiales de nombre de fichero, % y #, se expanden igualmente salvo que lo desactives con magic = { file = false }. Escapar deja de ser un problema de comillas y se convierte en una opción declarada, que es como debe ser.
nvim_exec2, y qué fue de nvim_exec
Cuando necesites la salida de un comando como valor, nvim_exec2 la captura: vim.api.nvim_exec2("highlight Comment", { output = true }).output te da el texto que habrías visto en pantalla. Esta función sustituye desde la 0.9 a nvim_exec, que está deprecada; si un tutorial viejo te la ofrece, el reemplazo es mecánico: el segundo argumento pasa a ser la tabla { output = ... } y el resultado llega en un diccionario. Es un buen primer ejemplo del ciclo de obsolescencia que aprenderás a auditar en la lección 8.5.
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 buffer 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 casi 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: semantica de usuario completa, solo sobre el buffer actualvim.cmd("normal! Gonueva linea")-- Puerta Vimscript: indices desde uno, sin tocar el cursorvim.fn.append(vim.fn.line("$"), "nueva linea")-- Puerta API: indices desde cero, cualquier buffer, sin efectos colateralesvim.api.nvim_buf_set_lines(0, -1, -1, false, { "nueva linea" })Las tres producen el mismo texto, y ahí acaba el parecido. La primera mueve el cursor hasta el final del fichero —y G es un salto, así que además apunta la posición anterior en la jumplist— y dispara los autocomandos de entrada y salida de Inserción, porque o pasa por el modo Insertar de verdad. La segunda respeta cursor, saltos y registros, pero cuenta desde uno y 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 :silent, 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 actuación
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 espacios y comillas, y magic = { file = false } desactiva la expansión de % y #. Construir comandos por concatenación de cadenas es la última opción, no la primera.
La API no es una biblioteca, es una frontera
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.
type() de Vimscript devuelve 3 para lista y 4 para diccionario. La tabla vacía cruza como lista salvo que la marques con vim.empty_dict(). Si una función de vim.fn esperaba un diccionario y le llegó una lista, este es el motivo.
Devuelve false y el mensaje de error como valor, sin romper nada. Es la propiedad que hace a la API apta para código que debe degradar con elegancia: compara con un comando ex bajo :silent, que falla sin decírtelo.
nvim_exec2 con output = true captura lo que el comando habría impreso. Es el sustituto moderno de nvim_exec y del viejo :redir de Vimscript.
Fila en base uno, columna en base cero: indexación «de marca», documentada en :help api-indexing. Cuando conviertas a coordenadas de nvim_buf_set_lines, resta 1 a la fila y deja la columna.
Traza el camino de cada llamada
- 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 convim.api. - Escribe una función que inserte texto en un buffer que no esté visible. Hazla primero con comandos ex y después con
nvim_buf_set_lines. Comprueba con:registersy:jumpsqué contaminó cada versión. - Ejecuta
:lua= vim.fn.type({})y:lua= vim.fn.type(vim.empty_dict())y explica la diferencia. Después imprime una tabla que contengavim.NILy observa que no desaparece. - Sustituye una concatenación de cadenas en un
vim.cmdpor su forma estructurada. Prueba con un nombre de archivo que contenga espacios, y luego con uno que contenga%: verifica que el segundo necesitamagic = { file = false }. - Abre
:help api-contracty localiza dos garantías concretas que expliquen por qué una config escrita contravim.apienvejece mejor que una escrita a base de comandos.