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

La arquitectura del core: msgpack-rpc y el editor sin interfaz

El protocolo que sostiene la plataforma: los tipos de mensaje de msgpack-rpc, la API generada desde las firmas en C, la separación entre el editor y su interfaz mediante el protocolo de redibujado por rejillas, y qué hace posible que existan clientes remotos, interfaces gráficas y Neovim embebido en otro programa.

⏱ 23 min

Hay una frase que se repite sobre Neovim y que casi nadie toma literalmente: el editor no tiene interfaz de usuario. No significa que sea incómodo ni que haya que instalarle una; significa que el proceso que edita texto y el proceso que dibuja caracteres en tu terminal son entidades distintas que se comunican por un protocolo documentado, y que la ventana que tienes delante es un cliente cualquiera, sin más derechos que un programa escrito por ti esta tarde. Esa decisión, tomada en los primeros meses del proyecto, es la que hace posibles las interfaces gráficas, el control remoto, las suites de pruebas que arrancan editores de verdad y la posibilidad de incrustar Neovim dentro de otra aplicación. Este capítulo baja al protocolo, porque es la juntura exacta donde la plataforma se separa de su presentación.

🎯 Al terminar esta lección sabrás
  • Describir los tipos de mensaje de msgpack-rpc y cómo transportan peticiones, respuestas y notificaciones.
  • Explicar por qué la API se genera desde las firmas en C y qué garantiza esa generación.
  • Entender el protocolo de interfaz por rejillas y el papel de las extensiones ext_ en la delegación de widgets.
  • Conectar un cliente remoto a una instancia viva y operar sobre ella desde fuera del editor.

Un editor que habla

En el fondo de la arquitectura hay un formato de serialización binario, MessagePack, que codifica los mismos tipos que JSON —enteros, cadenas, listas, mapas, booleanos, nulo— en muchos menos bytes y sin necesidad de analizar texto. Sobre él, Neovim habla msgpack-rpc, una convención minúscula: cada mensaje es una lista cuyo primer elemento es un número que declara de qué clase es.

La elección de un formato binario en lugar de texto no es estética. Un editor emite eventos de redibujado a razón de decenas de miles por segundo mientras te desplazas por un fichero, y cada uno lleva coordenadas, índices de atributo y fragmentos de texto. Serializar eso como texto obliga a escapar cadenas, a formatear números en decimal y a analizarlos de vuelta en el otro extremo, y ese coste, multiplicado por el volumen, se convierte en latencia visible. MessagePack codifica un entero pequeño en un byte y una cadena corta en su longitud más sus bytes, sin escapado; la diferencia frente a JSON no es de porcentaje sino de orden de magnitud en el caso que importa.

Solo existen tres clases y se agotan en un párrafo. Una petición lleva un identificador, el nombre de un método y sus argumentos, y obliga al receptor a contestar. Una respuesta lleva el identificador de la petición que responde, un error y un resultado, y exactamente uno de esos dos huecos está vacío. Una notificación lleva un método y argumentos, y no espera nada: es fuego y olvido. Con esas tres formas se construye todo, incluida la parte más compleja de la interfaz gráfica.

-- Levantar un canal y hablar con otra instancia como si fueras un cliente externo
vim.fn.serverstart("/tmp/nvim.sock")     -- en la instancia servidora
-- ... y desde otra instancia cualquiera:
local ch = vim.fn.sockconnect("pipe", "/tmp/nvim.sock", { rpc = true })
vim.rpcrequest(ch, "nvim_buf_set_lines", 0, 0, -1, false, { "escrito desde fuera" })
vim.rpcnotify(ch, "nvim_command", "write")   -- sin esperar respuesta
print(vim.inspect(vim.rpcrequest(ch, "nvim_get_mode")))

Lo decisivo del diseño es que el catálogo de métodos no se escribe a mano. Las funciones de la API viven en src/nvim/api/, anotadas con comentarios de documentación y con una marca de la versión en que aparecieron; durante la compilación, un generador las recorre y produce, del mismo origen, el despachador que traduce mensajes binarios en llamadas, la tabla de metadatos que puedes consultar en tiempo de ejecución y los enlaces que ves como vim.api. De ahí se siguen tres propiedades que uno tiende a dar por supuestas y que en realidad son consecuencias: que las tres puertas de acceso a la API nunca divergen, que ningún cliente necesita conocer de antemano la versión del editor porque puede preguntársela, y que añadir una función a la API es un solo cambio en un solo sitio.

Esa última propiedad merece pensarse despacio, porque es una respuesta concreta a un problema que aparece en casi todos los sistemas que crecen: mantener sincronizadas varias representaciones de la misma cosa. La alternativa habitual es la disciplina —recordar actualizar los tres sitios— y la disciplina falla siempre, no por descuido sino por escala. Sustituirla por generación desde una fuente única convierte un problema social recurrente en un problema técnico resuelto una vez, y es el mismo patrón que reconocerás en cualquier sitio donde alguien haya decidido que el código es el esquema.

# El editor se describe a si mismo: nombres, tipos de argumento, version de la API
nvim --api-info | nvim -R -c 'set ft=msgpack' -
nvim --headless -c 'lua print(vim.inspect(vim.api.nvim_get_api_info()[2].version))' -c q
ℹ️
Por qué el nivel de API importa más que el número de versión

Los metadatos incluyen un api_level y un api_compatible. Un cliente serio no comprueba si habla con Neovim 0.11 o 0.12: comprueba el nivel y decide qué funciones puede usar. Esa indirección es la que permite que una interfaz gráfica publicada hace tres años siga funcionando hoy, y la que obliga a los mantenedores a tratar cada función de la API como un contrato permanente.

La interfaz es un cliente

La segunda mitad del protocolo es la que separa al editor de su pantalla. Un cliente que quiera dibujar no consulta el estado del buffer ni recalcula el resaltado: se adjunta declarando el tamaño de la rejilla que puede pintar y las extensiones que sabe manejar, y a partir de ahí recibe notificaciones de redibujado.

-- Arrancar un Neovim embebido y actuar como su interfaz
local ch = vim.fn.jobstart({ "nvim", "--embed", "--headless" }, { rpc = true })
vim.rpcrequest(ch, "nvim_ui_attach", 80, 24, {
  ext_linegrid = true,      -- rejilla de celdas con atributos por indice
  ext_messages = true,      -- los mensajes te llegan como eventos, no como texto pintado
  ext_popupmenu = true,     -- el menu de autocompletado lo dibujas tu
})
vim.rpcnotify(ch, "nvim_input", "ihola desde fuera")

Ese fragmento merece una lectura atenta, porque en cinco líneas hace algo que suena imposible: un editor arrancando otro editor y haciéndole de pantalla. No hay ninguna magia ni ninguna API especial para ello. Es la consecuencia de que adjuntarse como interfaz sea una llamada más del mismo catálogo, disponible para cualquiera que abra un canal.

Las notificaciones que llegan describen operaciones de pintura sobre una rejilla de celdas: definir tabla de atributos, escribir un tramo de línea, desplazar una región, mover el cursor, cambiar de modo, y una marca de fin de lote que indica cuándo la pantalla es coherente y puede presentarse. Es un protocolo deliberadamente tonto y por eso rapidísimo: el editor decide qué hay en cada celda y el cliente solo obedece.

Encima de ese suelo común viven las extensiones. Cuando un cliente declara que sabe dibujar la línea de comandos, el menú de autocompletado, los mensajes, la línea de pestañas o los ventanales flotantes, el editor deja de pintarlos como caracteres en la rejilla y empieza a enviarlos como datos estructurados. Ahí está el mecanismo por el que una interfaz gráfica puede mostrar el autocompletado con iconos, tipografía proporcional y animación sin que el núcleo del editor sepa una palabra de gráficos: negocia una extensión y asume la responsabilidad de ese widget. La lista de extensiones es, en la práctica, la lista de decisiones de presentación que el editor está dispuesto a delegar.

El detalle que hace viable ese reparto es que la delegación sea negociada y opcional. Un cliente declara lo que sabe dibujar y el editor se adapta: si no pides la extensión de mensajes, los mensajes se siguen pintando en la rejilla como siempre; si la pides, dejan de aparecer ahí y empiezan a llegarte como datos que debes mostrar tú. Esa simetría es lo que permite que el mismo editor sirva a una terminal de ochenta columnas y a una aplicación nativa con animaciones, sin ramas condicionales en el núcleo ni versiones distintas del binario.

📝
El lote de redibujado es la unidad de coherencia

Las órdenes de pintura no llegan sueltas sino agrupadas, y el editor marca explícitamente el final de cada grupo. Un cliente que presente la pantalla en mitad de un lote mostrará estados intermedios inconsistentes: media línea del texto viejo junto a media del nuevo. Esa marca es lo que permite que una interfaz gráfica sincronice su repintado con el refresco del monitor sin inventarse heurísticas.

flowchart LR
subgraph S[Proceso servidor: el editor]
  N[Nucleo y API generada]
end
subgraph C[Clientes]
  T[Interfaz de terminal]
  G[Interfaz grafica]
  P[Script en Python o Rust]
  E[Suite de pruebas]
end
T -->|nvim input y llamadas de API| N
N -->|eventos de redibujado por rejilla| T
G -->|ui attach con extensiones| N
N -->|widgets como datos estructurados| G
P -->|peticiones sobre socket| N
E -->|instancia embebida| N
style N fill:#cba6f7,color:#11111b
style G fill:#a6e3a1,color:#11111b

Clientes remotos y editores embebidos

Con las dos mitades del protocolo en la mano, el catálogo de cosas construibles se ordena solo. Conviene distinguir cuatro figuras, porque se confunden con frecuencia y tienen requisitos muy distintos.

Un cliente de control se conecta a una instancia existente por su socket y opera sobre ella sin dibujar nada: es lo que hace nvim --remote para abrir un fichero en la ventana que ya tienes abierta, y lo que hace cualquier script que quiera preguntarle algo a tu sesión viva. Una interfaz alternativa se adjunta como cliente de dibujo, y la variedad de ellas —terminales, aplicaciones nativas, extensiones dentro de otros editores, interfaces en el navegador— existe precisamente porque ninguna necesita permiso ni parches en el núcleo. Un plugin en otro lenguaje arranca como proceso hijo con un canal, y desde ahí registra métodos que el editor puede invocar en él: el protocolo es simétrico, así que el editor también es cliente de sus plugins remotos. Y un editor incrustado es la variante más ambiciosa: otra aplicación arranca Neovim en modo embebido, le adjunta su propia interfaz y obtiene, con unas pocas decenas de líneas, un motor de edición modal completo con toda su historia de comandos, su modelo de deshacer y su ecosistema.

-- El editor como cliente de su plugin remoto: la simetria del protocolo
local ch = vim.fn.jobstart({ "python3", "servidor.py" }, { rpc = true })
local resultado = vim.rpcrequest(ch, "analizar", vim.api.nvim_buf_get_name(0))

-- Y al reves: el proceso hijo puede llamarnos en cualquier momento,
-- porque cada extremo del canal puede emitir peticiones y notificaciones.
vim.api.nvim_create_user_command("Chans", function()
  for _, c in ipairs(vim.api.nvim_list_chans()) do
    print(c.id, c.mode, vim.inspect(c.client and c.client.name or "?"))
  end
end, {})

Hay una quinta figura que suele pasar desapercibida y que usas cada vez que ejecutas la suite de pruebas de un plugin: el cliente de verificación. Arrancar un editor de verdad, conducirlo por el protocolo, inspeccionar su estado y hasta comparar la rejilla resultante con la esperada es posible porque el editor no distingue entre una persona escribiendo y un programa enviando nvim_input. Esa indistinción es lo que hace que las pruebas de Neovim y las de tus plugins verifiquen comportamiento observable en lugar de detalles internos, y es una de las razones por las que la refactorización del núcleo pudo hacerse sin destruir la compatibilidad.

Conviene registrar también el precio, porque un modelo mental sin costes es una ilusión. Cada llamada por el canal cuesta una serialización, un cambio de contexto y una vuelta al bucle de eventos; un plugin remoto que haga miles de peticiones pequeñas será perceptiblemente más lento que el mismo código en Lua incrustado, que no cruza ningún proceso. La regla que se ha ido decantando en el ecosistema es sencilla: Lua para lo que ocurre durante la escritura, canal para lo que necesita otro lenguaje, otra biblioteca o aislamiento de fallos.

Y conviene añadir una advertencia que el protocolo no impone y que es responsabilidad tuya: un canal abierto es control total sobre el editor. Quien pueda conectarse a ese socket puede leer tus buffers, escribir ficheros y ejecutar comandos con tus permisos. En una máquina de un solo usuario eso es irrelevante; en un servidor compartido, en un contenedor con volúmenes montados o en un socket expuesto por conveniencia, no lo es. La misma uniformidad que hace tan potente a la API la convierte en una superficie de ataque completa, y el hecho de que no exista un mecanismo de permisos por función es una decisión de diseño coherente con el resto: el editor confía en quien puede hablarle, y decidir quién puede hablarle es tu problema.

🔱

Un único vocabulario

La interfaz de terminal, tu configuración y una aplicación remota invocan el mismo conjunto de funciones.

🧪

Pruebas de verdad

La suite funcional arranca instancias embebidas y las conduce por el protocolo, sin simulacros.

🪟

Presentación delegada

Las extensiones ext_ mueven widgets enteros del núcleo al cliente sin tocar el núcleo.

El protocolo es la frontera que convierte un editor en una pieza de infraestructura

La lección honda de esta arquitectura no es que Neovim tenga una API remota; la tienen muchos programas. Es que la frontera está en el sitio correcto. Al partir el sistema exactamente entre el estado editable y su representación visual, y al obligar a que esa juntura se cruce con un vocabulario finito, serializable y versionado, el proyecto convirtió una decisión de implementación en una superficie de composición. Todo lo que ha ocurrido después es consecuencia mecánica de esa frontera bien puesta: las interfaces gráficas no son adaptaciones toleradas sino ciudadanos de primera; la suite de pruebas puede conducir editores reales en lugar de fingirlos, y por eso las regresiones se detectan al nivel del comportamiento observable y no de las funciones internas; los plugins pueden escribirse en cualquier lenguaje capaz de abrir un socket; y una aplicación completamente ajena puede adoptar un motor de edición modal sin heredar una sola línea de la interfaz. Fíjate en que ninguna de esas capacidades fue una funcionalidad planificada: todas son emergentes, y la única condición para que emergieran fue que el contrato fuese explícito y estable. Ahí está el principio general que vale mucho más allá de este editor y que merece salir contigo del nivel: cuando defines una frontera con un vocabulario cerrado y la respetas sin excepciones, los usos que no imaginaste dejan de necesitar tu permiso. Un sistema con la frontera en el sitio equivocado exige negociación para cada caso nuevo y termina siendo una lista de integraciones especiales; un sistema con la frontera bien puesta se vuelve infraestructura, y la infraestructura la extiende gente cuyos nombres sus autores nunca conocerán.

⚔️ Ser cliente del editor
  1. Arranca una instancia con nvim --listen /tmp/nvim.sock, conéctate desde otra con sockconnect y modifica su buffer con vim.rpcrequest.
  2. Vuelca nvim --api-info y localiza el api_level, después cuenta cuántas funciones declaran ser marcadas como seguras en contexto rápido.
  3. Lanza un nvim --embed --headless desde Lua, adjúntale una interfaz con nvim_ui_attach e imprime los primeros lotes de eventos de redibujado que recibas.
  4. Repite la prueba activando ext_messages y compara qué deja de aparecer en la rejilla y qué empieza a llegarte como datos.
  5. Mide con vim.uv.hrtime el coste de diez mil llamadas triviales por canal frente a las mismas diez mil en Lua incrustado, y escribe la conclusión en una frase que puedas aplicar al diseñar tu próximo plugin.