Escribir un plugin de GraphoOS
Un plugin de GraphoOS es un módulo WebAssembly (WASI, wasm32-wasip1) que el
proxy del editor ejecuta en un entorno aislado. Los plugins suelen conectar un servidor de lenguaje:
le dicen al editor cómo arrancarlo y el editor hace el resto (el tráfico LSP,
los diagnósticos, el completado). También pueden ejecutar procesos, descargar
ficheros por HTTPS y hablar con el usuario.
Esta guía te lleva de cero a un plugin publicado en unos treinta
minutos, en Rust. Los nueve plugins que mantiene Empoorio
(github.com/Empoorio/graphoos-<language>) están escritos exactamente así;
copia el que más se parezca.
Antes de que vaya a ningún sitio
graphoos plugin check . # qué está mal en este plugin
graphoos plugin package . # el paquete que sirve el registro
check lee el volt.toml y dice qué fallaría: un campo que
falta, un .wasm que no está o que no es un módulo WebAssembly, una
versión de la API que este editor no habla (todo eso lo detiene), y lo que
solo merece la pena mencionar: sin descripción, sin [permissions], o
permisos que lo piden todo con *. package escribe el
author.name-version.tar.gz que sirve el registro: el manifiesto, el wasm
con el nombre que le da el manifiesto, y el icono.
Para qué API se escribió
api = "1.2"
El primer número cambia cuando algo que usaban los plugins deja de funcionar como funcionaba, el segundo cuando se añade algo. Un plugin que necesita una API más nueva que la que habla el editor se rechaza por su nombre antes de que se ejecute nada de él («… needs plugin API 2.0, and this GraphoOS speaks 1.2: update the editor»), uno escrito para una versión menor más nueva se carga con un aviso, y uno que no dice nada se toma como 1.0, que es contra lo que se compilaron los plugins escritos antes de que se pidiera esto.
Lo que cubre ese número está escrito en defaults/plugin-api.toml:
los métodos que un plugin puede llamar en el editor, las funciones que se le dan
a un plugin wasm y las claves que puede tener su volt.toml. Cinco pruebas de conformidad
obligan al editor a cumplir ese fichero —leen el propio código del editor, no una copia
de él—, así que quitar algo, o renombrarlo, hace fallar la compilación con el
motivo: eso es una versión nueva de la API de plugins. Añadir es una versión
menor; quitar es una mayor, y una versión mayor rechaza todos los
plugins escritos para la anterior.
Qué puede hacer un plugin
Un plugin es un programa que ha escrito otra persona, ejecutándose en tu máquina con
tus ficheros. Dice en su volt.toml lo que necesita, y el editor
le obliga a cumplirlo:
[permissions]
# los programas que puede ejecutar, por nombre
processes = ["rust-analyzer", "codelldb", "which"]
# los equipos a los que puede conectarse; *.example.com cubre los subdominios
network = ["github.com", "*.githubusercontent.com"]
Ejecutar cualquier otra cosa se rechaza, con el motivo en el registro y
en los mensajes del editor; conectarse a cualquier otro sitio lo rechaza la
capa HTTP. ["*"] es todo, y un plugin que no declara nada conserva
lo que los plugins han tenido siempre —romper todos los plugins que existen no
ayudaría a nadie—, pero el registro lo dice. Todos los plugins de este repositorio
declaran lo que necesitan.
Qué no puede hacer un plugin
Cada llamada a un plugin tiene diez segundos. Un plugin que entra en un bucle infinito, o espera algo que nunca llega, se corta y el motivo se apunta en el registro; el editor, los demás plugins y la siguiente llamada del propio plugin no se ven afectados. (Wasmtime cuenta épocas, no segundos: el editor las hace avanzar y el plazo es un número de épocas.)
De qué está hecho un plugin
| Fichero | Papel |
|---|---|
volt.toml |
Manifiesto: nombre, versión, cuándo activarse, ajustes que el usuario puede editar |
<name>.wasm |
El plugin compilado |
icon.png |
Se muestra en el panel de extensiones |
README.md |
Se muestra en el panel de extensiones |
El editor carga el módulo, luego le envía mensajes JSON-RPC al estilo de LSP por
stdin y lee las respuestas por stdout. El primer mensaje es siempre
initialize, con los ajustes del plugin en initialization_options. El
módulo importa un módulo del anfitrión, "graphoos": solo pueden cargarse los módulos compilados con el
crate graphoos-plugin de más abajo.
Requisitos previos
rustup target add wasm32-wasip1
1. Esqueleto (5 minutos)
cargo new --bin graphoos-mylang && cd graphoos-mylang
Cargo.toml:
[package]
name = "graphoos-mylang"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0"
serde_json = "1.0"
graphoos-plugin = { git = "https://github.com/Empoorio/graphoos-plugin-rust", branch = "main" }
volt.toml (el manifiesto; author tiene que ser el nombre de quien lo publica, y el
registro lo comprueba):
name = "graphoos-mylang"
version = "0.1.0"
author = "Your Name"
display-name = "MyLang"
description = "MyLang for GraphoOS: powered by mylang-ls"
wasm = "graphoos-mylang.wasm"
icon = "icon.png"
repository = "https://github.com/you/graphoos-mylang"
[activation]
# El plugin arranca cuando se abre un fichero de estos lenguajes…
language = ["mylang"]
# …o cuando el proyecto contiene un fichero así (glob).
workspace-contains = ["*/mylang.toml"]
# Ajustes que el usuario edita en la página del plugin. Llegan al plugin como
# `initialization_options` de la petición `initialize`.
[config."serverPath"]
default = ""
description = "Path to mylang-ls. When empty, `mylang-ls` from PATH is used."
Los ids de lenguaje son los que usa el editor para el resaltado de sintaxis (rust,
python, go, c, cpp, typescript, …).
2. El plugin (10 minutos)
src/main.rs:
use anyhow::Result;
use graphoos_plugin::{
psp_types::{
lsp_types::{request::Initialize, DocumentFilter, InitializeParams, MessageType, Url},
Request,
},
register_plugin, GraphoOSPlugin, PLUGIN_RPC,
};
use serde_json::Value;
#[derive(Default)]
struct State {}
// Genera `main` y el punto de entrada `handle_rpc` al que llama el editor.
register_plugin!(State);
fn initialize(params: InitializeParams) -> Result<()> {
// Los ajustes del usuario, de la sección [config] de volt.toml.
let server_path = params
.initialization_options
.as_ref()
.and_then(|o| o.get("serverPath"))
.and_then(|p| p.as_str())
.filter(|p| !p.is_empty())
.map(|p| p.to_string())
.unwrap_or_else(|| "mylang-ls".to_string());
// ¿Está el servidor? `execute_process` ejecuta un programa en el anfitrión y
// falla cuando no lo encuentra.
if PLUGIN_RPC
.execute_process(server_path.clone(), vec!["--version".to_string()])
.is_err()
{
PLUGIN_RPC.window_show_message(
MessageType::WARNING,
"MyLang: `mylang-ls` was not found. Install it or set serverPath in the plugin settings.".to_string(),
);
return Ok(());
}
// Entrega el servidor al editor. `urn:<program>` significa "un programa del
// PATH"; una URL `file://` apunta a un binario (consulta las descargas más abajo).
PLUGIN_RPC.start_lsp(
Url::parse(&format!("urn:{server_path}"))?,
Vec::new(), // argumentos
vec![DocumentFilter {
language: Some("mylang".to_string()),
scheme: Some("file".to_string()),
pattern: Some("**/*.ml".to_string()),
}],
params.initialization_options, // se reenvían al servidor
);
Ok(())
}
impl GraphoOSPlugin for State {
fn handle_request(&mut self, _id: u64, method: String, params: Value) {
if method == Initialize::METHOD {
let params: InitializeParams = serde_json::from_value(params).unwrap();
if let Err(e) = initialize(params) {
PLUGIN_RPC.stderr(&format!("plugin returned with error: {e}"));
}
}
}
}
Compilar:
cargo build --release --target wasm32-wasip1
cp target/wasm32-wasip1/release/graphoos-mylang.wasm .
La API
Todo pasa por PLUGIN_RPC:
| Llamada | Qué hace |
|---|---|
start_lsp(server_uri, args, document_selector, options) |
Arranca un servidor de lenguaje; a partir de ahí el editor le habla en LSP |
execute_process(program, args) -> Result<ExecuteProcessResult> |
Ejecuta un programa en el anfitrión y obtiene su estado de salida y su salida |
window_show_message(kind, text) |
Una notificación para el usuario |
window_log_message(kind, text) |
Una línea en el registro del editor |
stderr(text) |
El stderr del propio plugin, que también se muestra en el registro |
VoltEnvironment le dice al plugin dónde vive y cómo es el anfitrión:
| Función | Valor |
|---|---|
VoltEnvironment::uri() |
URL file:// de la carpeta del plugin (escribe ahí las descargas) |
VoltEnvironment::operating_system() |
macos, linux, windows |
VoltEnvironment::architecture() |
x86_64, aarch64 |
VoltEnvironment::libc() |
glibc o musl en Linux, vacío en los demás |
Http::get(url) descarga por HTTPS (el anfitrión añade un User-Agent, que
necesita la API de GitHub). graphoos-toml, graphoos-markdown y graphoos-c
descargan sus servidores la primera vez que se usan: traen la release, la desempaquetan junto al
plugin, recuerdan la ruta en un fichero y lo arrancan con una URL file://:
let dir = Url::parse(&VoltEnvironment::uri()?)?.to_file_path().unwrap();
let mut resp = Http::get("https://github.com/tamasfe/taplo/releases/latest")?;
let body = resp.body_read_all()?;
// …sacar la etiqueta de la página, descargar el adjunto de la plataforma, desempaquetar…
PLUGIN_RPC.start_lsp(Url::from_file_path(dir.join("taplo")).unwrap(), vec!["lsp".into(), "stdio".into()], filters, options);
Mejor ofrece un ajuste serverPath para que los usuarios puedan apuntar a su propio binario.
3. Probarlo en el editor (5 minutos)
Los plugins viven en la carpeta de datos del editor:
| SO | Carpeta |
|---|---|
| macOS | ~/Library/Application Support/dev.graphoos.GraphoOS-Stable/plugins/ |
| Linux | ~/.local/share/graphoos-stable/plugins/ ($XDG_DATA_HOME) |
| Windows | %LOCALAPPDATA%\graphoos\GraphoOS-Stable\data\plugins\ |
(Las compilaciones -Nightly y -Debug tienen sus propias carpetas.) Copia el
plugin como plugins/<author>.<name>/ con el volt.toml, el .wasm, el
icono y el README, reinicia el editor y abre un fichero del lenguaje.
logs/graphoos.<date>.log, en la misma carpeta de datos, muestra el stderr del
plugin, el tráfico de initialize y las respuestas del servidor de lenguaje; un
plugin que no consigue cargarse dice ahí por qué.
4. Publicar (10 minutos)
graphoos plugin check .
graphoos plugin publish . --registry ../Website/registry --dry-run
graphoos plugin publish . --registry ../Website/registry
publish pone el plugin en el catálogo del registro —o cambia la
versión de uno que ya esté, en el sitio donde está, para que el orden en que el
registro los lista no le cambie a nadie— y dice lo que queda
por hacer: compilar el registro y publicar el sitio. --dry-run dice qué
cambiaría y no escribe nada.
El wasm se busca junto a volt.toml, que es como queda un plugin
una vez instalado, y en target/wasm32-wasip1/release, que es
donde está mientras se escribe.
El registro de graphoos.com/api/v1 es un catálogo estático generado
a partir de una lista de plugins; el panel de extensiones del editor instala desde él.
Para aparecer en él:
- Sube el plugin a un repositorio público con
volt.toml,README.md,icon.pngy una forma de compilar el.wasm(basta con uncargo buildnormal como el de arriba). - Abre una issue en
github.com/Empoorio/graphoostituladaregistry: <name>con la URL del repositorio y la versión. La entrada se añade aWebsite/registry/plugins.toml, se compila el paquete<author>.<name>-<version>.tar.gz(los cuatro ficheros) y el catálogo se vuelve a desplegar.
Las versiones siguen semver; sube version tanto en Cargo.toml como en
volt.toml en cada release. El editor busca en el catálogo versiones más nuevas
de los plugins instalados.
Más allá de los servidores de lenguaje
El mismo módulo puede atender cualquier petición que envíe el editor
(handle_request / handle_notification reciben todos los métodos), arrancar un
depurador con RegisterDebuggerType, o enviar sus propias peticiones y
notificaciones LSP con SendLspRequest / SendLspNotification (consulta
psp_types). Los nueve plugins mantenidos cubren las formas habituales:
graphoos-rust (servidor incluido en la toolchain), graphoos-go (servidor en el
PATH, con alternativas), graphoos-c y graphoos-toml (servidor descargado
por plataforma), graphoos-python y graphoos-typescript (servidor que el usuario
instala con npm, con un mensaje claro si falta).
Otros lenguajes aparte de Rust: cualquier toolchain que produzca un módulo wasm32-wasi
que importe las funciones del anfitrión graphoos funciona en principio, pero hoy solo
se mantiene el crate de Rust. Un SDK de AssemblyScript está en la hoja de ruta.
Fuente: docs/es/plugins.md