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:

  1. Sube el plugin a un repositorio público con volt.toml, README.md, icon.png y una forma de compilar el .wasm (basta con un cargo build normal como el de arriba).
  2. Abre una issue en github.com/Empoorio/graphoos titulada registry: <name> con la URL del repositorio y la versión. La entrada se añade a Website/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