wandres.dev
NIVEL DIOS: EL EDITOR · síntesis y contribuir

El modelo mental completo: Neovim como plataforma

La síntesis de todo el recorrido: Neovim no es un programa con extensiones sino una plataforma con un núcleo pequeño y una API uniforme. Las capas desde el bucle de eventos de libuv hasta tu configuración, por qué el runtime no tiene privilegios sobre ti, y qué significa el contexto rápido.

⏱ 22 min

Durante veinticinco niveles has ido cambiando de sitio sin que nadie te avisara. Empezaste enfrente del editor, pulsando teclas que producían efectos; después empezaste a describir esos efectos en un lenguaje, y el editor pasó a obedecer descripciones; más tarde escribiste plugins, y el editor pasó a ejecutar programas tuyos con la misma dignidad con la que ejecuta los suyos. Ese desplazamiento tiene un nombre técnico y conviene decirlo con precisión: dejaste de ser usuario de un programa para ser programador de una plataforma. Este capítulo no añade una técnica más. Ordena en un solo esquema todo lo que ya sabes hacer, y coloca cada pieza en la capa exacta donde vive, porque el modelo mental correcto es lo que convierte un repertorio de trucos en la capacidad de resolver problemas que nadie te ha enseñado todavía.

🎯 Al terminar esta lección sabrás
  • Distinguir la arquitectura de un programa extensible de la de una plataforma con núcleo mínimo.
  • Nombrar las capas de Neovim desde libuv hasta tu configuración y saber qué responsabilidad tiene cada una.
  • Explicar el ciclo completo que va de una pulsación a un cambio en el buffer y a un redibujado.
  • Justificar la existencia del contexto rápido y de vim.schedule a partir del diseño del bucle de eventos.

Un programa que resultó ser una plataforma

La diferencia entre un editor extensible y una plataforma de edición no es cuántos ganchos ofrece, sino quién tiene privilegios. En la arquitectura clásica —la de Vim, y la de casi todos los editores de los años noventa— existe un programa que hace cosas y, a un lado, un mecanismo de extensión que puede pedirle algunas de ellas. El mecanismo es siempre más pobre que el programa: hay operaciones que el núcleo puede realizar y el lenguaje de extensión no; hay estado que el núcleo consulta y el lenguaje no ve. El resultado es una jerarquía en la que el autor de un plugin trabaja siempre con una versión mutilada del editor.

Neovim invirtió esa relación con una decisión de ingeniería que parece administrativa y no lo es: exponer toda la funcionalidad del editor como una API con una interfaz uniforme y obligar al propio editor a usarla. Cuando vim.lsp coloca un diagnóstico, no llama a una función interna privilegiada de C: llama a nvim_buf_set_extmark, exactamente la misma que llamarías tú. Cuando la interfaz de terminal dibuja una línea, no accede a la memoria del buffer: recibe eventos de redibujado por un canal, como cualquier cliente externo. El editor se convirtió en el primer consumidor de su propia API, y esa es la razón —no una promesa de marketing— por la que lo que tú escribes puede alcanzar la misma potencia que lo que viene de fábrica.

Merece la pena registrar que esa inversión no fue gratuita ni instantánea. El código heredado tenía la entrada y salida entrelazada con la lógica de edición, la interfaz de terminal escribía directamente sobre estructuras internas y no existía nada parecido a una frontera. Los primeros años del proyecto se dedicaron, en buena medida, a un trabajo que desde fuera no producía ninguna funcionalidad visible: extraer el bucle de eventos, aislar las dependencias del sistema operativo y definir una superficie pública. Es el ejemplo de manual de una refactorización cuyo rendimiento no aparece en la versión en que se hace, sino en todas las siguientes.

La consecuencia práctica es la que importa para tu trabajo diario: en Neovim no existe la categoría de lo que solo el núcleo puede hacer. Si algo ocurre en el editor, hay una llamada que lo provoca, y esa llamada está a tu alcance. Cuando te encuentres pensando que cierto comportamiento es intocable, casi siempre estarás describiendo un límite de tu conocimiento del catálogo, no un límite de la arquitectura.

ℹ️
La prueba empírica de que no hay privilegios

El runtime en Lua que Neovim distribuye —vim.lsp, vim.treesitter, vim.diagnostic, vim.pack— vive en runtime/lua/vim/ y está escrito contra la misma API pública que tú usas. Puedes leerlo, copiarlo, sustituirlo por el tuyo con un package.loaded reasignado o parchearlo en caliente. No hay compilación intermedia ni contrato oculto: es Lua que se carga como cualquier otro módulo.

Las capas, del descriptor de fichero a tu configuración

El modelo completo se ordena en siete estratos. Cada uno consume estrictamente al de abajo y desconoce a los de arriba, y esa disciplina es lo que permite que la interfaz gráfica de un cliente escrito en Rust y tu autocomando de tres líneas convivan sin saber el uno del otro.

En el fondo está libuv, la biblioteca de entrada y salida asíncrona que también sostiene a Node. Aporta el bucle de eventos, los temporizadores, los procesos hijo, las señales y los sockets, todo sin bloquear. Neovim la envuelve en src/nvim/event/ y la expone en Lua como vim.uv, que es literalmente la misma biblioteca: cuando programas un temporizador desde tu configuración, estás tocando el mismo mecanismo que espera la entrada del teclado.

Sobre él vive el bucle principal con su estructura característica, una jerarquía de colas —el multiqueue— que permite dar prioridad a unos eventos sobre otros. La entrada del usuario, las respuestas de un servidor de lenguaje, el vencimiento de un temporizador y la salida de un proceso no compiten en una lista plana: se drenan con un orden que garantiza que el editor responde a las teclas incluso mientras atiende trabajo de fondo.

Encima está la máquina de estados que reconoce lo que escribes. Los modos no son adornos: son estados de un autómata que interpreta pulsaciones acumulando un contador, un operador y un movimiento hasta formar un comando completo. Toda la gramática de edición que dominas desde los primeros niveles es la especificación de ese autómata, y detalles que parecían arbitrarios —que un mapeo ambiguo espere, que un operador quede pendiente hasta recibir su complemento, que el modo de operador pendiente exista como modo propio— son consecuencias directas de que sea un reconocedor incremental de una gramática y no una tabla de atajos.

Después viene el núcleo del editor propiamente dicho: los buffers con su almacenamiento paginado, las ventanas, las pestañas, las opciones con sus tres ámbitos, el árbol de deshacer, las marcas, los registros y las marcas extendidas. Es la capa que Vim ya tenía, reescrita y saneada, y sigue siendo la que más código en C ocupa.

Conviene detenerse un segundo en esa cuarta capa porque contiene la única estructura que la mayoría de la gente nunca examina y que explica bastantes comportamientos: el buffer no es una cadena de texto en memoria sino un almacenamiento paginado en bloques, diseñado en su día para editar ficheros mayores que la memoria disponible de la máquina. De ahí vienen los ficheros de intercambio, la recuperación tras un cierre abrupto y el hecho de que insertar una línea en medio de un fichero enorme no cueste lo que costaría desplazar megabytes.

La quinta capa es la API: un conjunto de funciones en C con convención uniforme, que reciben tipos serializables, devuelven tipos serializables y comunican fallos por un objeto de error en lugar de por excepciones. Su rasgo decisivo es que no está escrita a mano tres veces, sino generada desde las firmas en C, y de ahí salen a la vez el despachador binario, los metadatos que puedes consultar y las funciones que ves en Lua.

Un rasgo suyo pasa desapercibido y sin embargo condiciona todo lo que escribes encima: la API está diseñada para ser atravesada por desconocidos. Sus argumentos son tipos serializables porque tienen que viajar por un socket; sus errores son valores porque no puede haber excepciones cruzando una frontera de proceso; sus funciones validan con desconfianza porque quien llama puede ser un programa mal escrito en un lenguaje que sus autores no conocen. Esa paranoia deliberada es exactamente lo que la hace segura de usar desde Lua.

La sexta son las tres puertas que dan a esa misma API: el protocolo binario para procesos externos, la tabla vim.api para el intérprete de Lua incrustado y las funciones api# para Vimscript. Tres sintaxis, un único conjunto de operaciones.

Y la séptima es todo lo demás: el runtime en Lua, los plugins que instalas y tu configuración, indistinguibles entre sí en cuanto al mecanismo. Que uno venga en el tarball y otro lo hayas escrito esta mañana no cambia nada de cómo se cargan ni de qué pueden hacer.

Cada capa es observable desde arriba, y comprobarlo con las manos convierte el esquema en conocimiento operativo en lugar de en un diagrama que se olvida:

-- Capa 1: libuv, el mismo bucle que espera tus teclas
print(vim.uv.uptime(), vim.uv.os_uname().sysname)
-- Capa 3: el estado del automata en este instante
print(vim.inspect(vim.api.nvim_get_mode()))
-- Capa 4: las estructuras del nucleo
print(#vim.api.nvim_list_bufs(), #vim.api.nvim_list_wins())
-- Capa 5: la superficie generada
print(vim.api.nvim_get_api_info()[2].version.api_level)
-- Capa 6: los canales abiertos, incluida tu propia interfaz
print(vim.inspect(vim.api.nvim_list_chans()))
-- Capa 7: lo cargado, sin distinguir runtime de plugin ni de config
print(vim.inspect(vim.tbl_keys(package.loaded)))
flowchart TB
A[libuv: bucle de eventos, temporizadores, procesos] --> B[Bucle principal con colas por prioridad]
B --> C[Maquina de estados: modos, operadores, movimientos]
C --> D[Nucleo: buffers, ventanas, opciones, deshacer, extmarks]
D --> E[API generada desde las firmas en C]
E --> F1[Canal binario para procesos externos]
E --> F2[vim.api para Lua incrustado]
E --> F3[Funciones api para Vimscript]
F2 --> G[Runtime en Lua: lsp, treesitter, diagnostic]
G --> H[Plugins y tu configuracion]
style E fill:#cba6f7,color:#11111b
style H fill:#a6e3a1,color:#11111b

De la pulsación al píxel

Sigamos una tecla por el edificio entero, porque el recorrido explica varias cosas que hasta ahora parecían caprichos y porque es la comprobación de que el esquema de capas describe un sistema real y no una taxonomía cómoda.

Pulsas una tecla. libuv la entrega como bytes legibles en un descriptor. El bucle principal la encola y la máquina de estados la consume, resolviendo primero si forma parte de un mapeo pendiente —de ahí el retardo configurable de timeoutlen— y luego si completa un comando. Si el comando modifica texto, el núcleo aplica el cambio al almacenamiento del buffer, registra la operación en el árbol de deshacer, desplaza las marcas extendidas afectadas y emite eventos: los autocomandos que hayas colgado se ejecutan aquí, en medio del cambio. Después el editor marca las regiones sucias y, en el siguiente giro del bucle, envía a los clientes conectados las órdenes de redibujado. La interfaz que tienes delante recibe esas órdenes y pinta.

-- Los tres relojes del ciclo, observables desde Lua
vim.api.nvim_create_autocmd("TextChanged", {          -- 1. el editor notifica
  callback = function(a) vim.g.ultimo_cambio = a.buf end,
})

local t = vim.uv.new_timer()                          -- 2. libuv, contexto rapido
t:start(1000, 0, function()
  -- Aqui NO puedes tocar el editor: estas fuera del punto seguro del bucle
  vim.schedule(function()                             -- 3. vuelta al hilo del editor
    vim.api.nvim_echo({ { "un segundo despues" } }, false, {})
  end)
end)

Hay un detalle de ese recorrido que conviene subrayar porque explica una clase entera de errores sutiles: los autocomandos se ejecutan dentro del cambio, no después de él. Cuando tu devolución de llamada de TextChanged corre, el editor está en medio de una operación, y si desde ahí modificas el mismo buffer estás alterando el suelo que la operación pisa. De ahí las precauciones que la documentación repite —no mover el cursor, no cambiar de ventana, no editar el buffer que provocó el evento— y de ahí que el patrón robusto para reaccionar a un cambio sea casi siempre acumular el trabajo y ejecutarlo en un instante posterior del bucle.

Ese fragmento contiene la explicación de una de las asperezas que más desconcierta al principio. Algunas devoluciones de llamada se ejecutan en lo que la documentación llama contexto rápido: puntos del bucle en los que el editor está a medio hacer algo y su estado interno no es coherente para una llamada arbitraria de la API. Ahí solo son legales las operaciones marcadas como seguras. vim.schedule no es un truco para evitar un error: es la forma explícita de decir ejecuta esto en el próximo instante en que el editor esté en reposo, y entender por qué existe equivale a entender que el editor es un programa dirigido por eventos y no una secuencia de instrucciones.

🔱

Un solo hilo lógico

Todo lo que toca el estado del editor ocurre en un único hilo; la concurrencia vive en libuv, no en el buffer.

🧩

Sin capa privilegiada

El runtime distribuido usa exactamente la API pública. Lo que él puede hacer, tú también.

📡

Estado observable

Cada capa expone su estado: vim.uv, nvim_get_mode, nvim_list_bufs, nvim_get_api_info.

Dónde encaja lo que ya sabes

Ahora vuelve la vista atrás y coloca cada nivel en su estrato. Los movimientos, operadores y registros de la primera mitad describen la máquina de estados. Los buffers, ventanas y opciones son el núcleo. Lua, vim.api y la introspección son la puerta y el catálogo. Los autocomandos son el sistema de eventos del núcleo asomado a la capa alta. Las marcas extendidas y los grupos de resaltado son estructuras del núcleo que solo existen porque alguien decidió exponerlas. Treesitter es una biblioteca externa incrustada y publicada como API. El cliente de LSP es runtime en Lua sobre procesos de libuv y canales de mensajes. Los plugins, las pruebas, la interfaz flotante y el rendimiento son ya oficio de plataforma: producir software que otros ejecutan dentro del editor.

Ninguna de esas piezas es un tema suelto. Cada una era una vista parcial de este mismo edificio, y el edificio es lo que había que aprender.

El modelo tiene además un uso diagnóstico inmediato, y es probablemente su rendimiento más alto en el trabajo diario. Cuando algo va mal, la primera pregunta deja de ser qué plugin tiene la culpa y pasa a ser en qué capa está el síntoma. Si el editor se atasca al escribir, mira la capa de eventos: un autocomando caro colgado de un evento frecuente. Si un plugin no reacciona, mira la capa de eventos también, pero del otro lado: el evento que esperas quizá no se emite en el caso que te importa. Si la pantalla muestra algo incoherente con el estado, mira la frontera con el cliente. Si una llamada falla solo dentro de un temporizador, es contexto rápido. Si dos plugins se pisan, es la capa siete y no hay ningún misterio: ambos escriben en la misma estructura compartida sin coordinarse. Esa clasificación en cinco preguntas resuelve la mayoría de los problemas antes de abrir ningún código, y es exactamente lo que distingue a quien depura de quien prueba cosas.

⚠️
El error de atribución más caro

Cuando una configuración se vuelve lenta, la reacción habitual es desactivar plugins uno a uno. Es un método correcto pero costosísimo, y suele confirmar lo que el modelo ya predecía: el problema está casi siempre en la capa de eventos, no en el plugin. Un mismo plugin puede ser instantáneo o insufrible según el evento del que cuelgue su trabajo, y esa decisión la tomaste tú al configurarlo.

El núcleo pequeño no es una virtud estética: es la condición para que el editor te sobreviva

Detente en la implicación más profunda de esta arquitectura, porque no es técnica sino económica. Un editor con un núcleo grande concentra todo su valor en las decisiones de sus autores: cada capacidad que quieras tiene que estar en la lista de prioridades de otra persona, y tu única herramienta de negociación es esperar. Un editor con un núcleo pequeño y una API total invierte el reparto: los autores del núcleo mantienen los mecanismos —el bucle, el almacenamiento del buffer, el autómata de edición, la generación de la API— y las políticas quedan del lado de fuera, donde hay miles de personas y ninguna necesidad de coordinarse. Esa separación entre mecanismo y política, formulada hace medio siglo para los sistemas operativos, es exactamente la razón por la que Neovim pudo absorber en pocos años el analizador incremental, el protocolo de servidores de lenguaje, las marcas extendidas, las interfaces gráficas y la gestión de plugins sin reescribirse: ninguna de esas cosas está en el núcleo, todas están construidas sobre él. Y por eso lo que acabas de aprender no caduca. Las modas de plugins cambiarán, los nombres de los módulos cambiarán, el protocolo de moda del año que viene aún no tiene nombre; pero un editor cuyo estado completo es consultable y cuyas operaciones son todas invocables admite ser adaptado a cualquiera de esas cosas por quien entienda el modelo. Ahí está la diferencia entre haber aprendido un editor y haber aprendido a extender editores: lo primero es un activo que se deprecia con cada versión, lo segundo es capital que se acumula. El resto de este nivel consiste en bajar del todo la escalera —hasta el protocolo, hasta el código en C, hasta el proceso por el que ese código cambia— para que la última pieza del modelo, la que dice cómo se transforma el propio editor, deje también de ser una caja negra.

⚔️ Recorrer las capas con las manos
  1. Escribe en tu configuración un temporizador con vim.uv.new_timer que intente llamar a nvim_buf_set_lines directamente; observa el error, envuélvelo en vim.schedule y explica con el modelo qué ha cambiado.
  2. Ejecuta :lua =vim.api.nvim_get_api_info()[2].version y :lua =#vim.tbl_keys(vim.api) para medir la superficie real de la capa cinco.
  3. Localiza en tu instalación el directorio runtime/lua/vim/ y abre diagnostic.lua: identifica tres llamadas a la API pública que tú también podrías hacer.
  4. Cuelga un autocomando de TextChanged que imprima el reloj de vim.uv.hrtime y comprueba en qué momento del ciclo se ejecuta respecto al redibujado.
  5. Dibuja de memoria las siete capas y sitúa en ellas cinco cosas que hayas configurado este mes; si alguna no encaja, es que te falta entender qué hace de verdad.