Writing a GraphoOS plugin

A GraphoOS plugin is a WebAssembly module (WASI, wasm32-wasip1) that the editor's proxy runs in a sandbox. Plugins usually connect a language server: they tell the editor how to start it and the editor does the rest (LSP traffic, diagnostics, completion). They can also run processes, download files over HTTPS and talk to the user.

This guide takes you from nothing to a published plugin in about thirty minutes, in Rust. The nine plugins Empoorio maintains (github.com/Empoorio/graphoos-<language>) are written exactly this way; copy the closest one.

Before it goes anywhere

graphoos plugin check .       # what is wrong with this plugin
graphoos plugin package .     # the package the registry serves

check reads the volt.toml and says what would break: a field that is missing, a .wasm that is not there or is not a WebAssembly module, an API version this editor does not speak (all of those stop it), and what is only worth saying — no description, no [permissions], or permissions that ask for everything with *. package writes the author.name-version.tar.gz the registry serves: the manifest, the wasm under the name the manifest gives it, and the icon.

Which API it was written for

api = "1.2"

The first number changes when something plugins used stops working the way it did, the second when something is added. A plugin that needs a newer API than the editor speaks is refused by name before anything of it runs («… needs plugin API 2.0, and this GraphoOS speaks 1.2: update the editor»), one written for a newer minor is loaded with a warning, and one that says nothing is taken to be 1.0 — which is what the plugins written before this was asked for were built against.

What that number covers is written down, in defaults/plugin-api.toml: the methods a plugin may call on the editor, the functions a wasm plugin is given, and the keys its volt.toml may hold. Five conformance tests hold the editor to that file — they read the editor's own code, not a copy of it — so taking something away, or renaming it, fails the build with the reason: that is a new version of the plugin API. Adding is a minor version; taking away is a major one, and a major version refuses every plugin written for the one before it.

What a plugin may do

A plugin is a program someone else wrote, running on your machine with your files. It says in its volt.toml what it needs, and the editor holds it to that:

[permissions]
# the programs it may run, by name
processes = ["rust-analyzer", "codelldb", "which"]
# the hosts it may reach; *.example.com covers the subdomains
network = ["github.com", "*.githubusercontent.com"]

Running anything else comes back refused, with the reason in the log and in the editor's messages; reaching anywhere else is refused by the HTTP layer. ["*"] is everything, and a plugin that declares nothing keeps what plugins have always had — breaking every plugin that exists would help nobody — but the log says so. The plugins of this repository all declare what they need.

What a plugin may not do

One call into a plugin has ten seconds. A plugin that loops for ever, or waits on something that never comes, is cut short and the reason is logged; the editor, the other plugins and the plugin's own next call are not affected. (Wasmtime counts epochs, not seconds: the editor ticks them and the deadline is a number of ticks.)

What a plugin is made of

File Role
volt.toml Manifest: name, version, when to activate, settings the user can edit
<name>.wasm The compiled plugin
icon.png Shown in the plugins panel
README.md Shown in the plugins panel

The editor loads the module, then sends it LSP-style JSON-RPC messages on stdin and reads answers on stdout. The first message is always initialize, with the plugin's settings in initialization_options. The module imports one host module, "graphoos": only modules built with the graphoos-plugin crate below can load.

Prerequisites

rustup target add wasm32-wasip1

1. Scaffold (5 minutes)

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 (the manifest; author must be the publisher's name, and the registry checks it):

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]
# The plugin starts when a file of these languages is opened…
language = ["mylang"]
# …or when the workspace contains such a file (glob).
workspace-contains = ["*/mylang.toml"]

# Settings the user edits in the plugin's page. They reach the plugin as
# `initialization_options` of the `initialize` request.
[config."serverPath"]
default = ""
description = "Path to mylang-ls. When empty, `mylang-ls` from PATH is used."

Language ids are the ones the editor uses for syntax highlighting (rust, python, go, c, cpp, typescript, …).

2. The plugin (10 minutes)

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 {}

// Generates `main` and the `handle_rpc` entry point the editor calls.
register_plugin!(State);

fn initialize(params: InitializeParams) -> Result<()> {
    // The user's settings, from volt.toml's [config] section.
    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());

    // Is the server there? `execute_process` runs a program on the host and
    // fails when it can't be found.
    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(());
    }

    // Hand the server to the editor. `urn:<program>` means "a program on
    // PATH"; a `file://` URL points at a binary (see downloads below).
    PLUGIN_RPC.start_lsp(
        Url::parse(&format!("urn:{server_path}"))?,
        Vec::new(), // arguments
        vec![DocumentFilter {
            language: Some("mylang".to_string()),
            scheme: Some("file".to_string()),
            pattern: Some("**/*.ml".to_string()),
        }],
        params.initialization_options, // forwarded to the server
    );
    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}"));
            }
        }
    }
}

Build:

cargo build --release --target wasm32-wasip1
cp target/wasm32-wasip1/release/graphoos-mylang.wasm .

The API

Everything goes through PLUGIN_RPC:

Call What it does
start_lsp(server_uri, args, document_selector, options) Start a language server; the editor speaks LSP to it from then on
execute_process(program, args) -> Result<ExecuteProcessResult> Run a program on the host and get its exit status and output
window_show_message(kind, text) A notification for the user
window_log_message(kind, text) A line in the editor's log
stderr(text) Plugin's own stderr, shown in the log too

VoltEnvironment tells the plugin where it lives and what the host is:

Function Value
VoltEnvironment::uri() file:// URL of the plugin's directory (write downloads there)
VoltEnvironment::operating_system() macos, linux, windows
VoltEnvironment::architecture() x86_64, aarch64
VoltEnvironment::libc() glibc or musl on Linux, empty elsewhere

Http::get(url) downloads over HTTPS (the host adds a User-Agent, which GitHub's API needs). graphoos-toml, graphoos-markdown and graphoos-c download their servers on first use: fetch the release, unpack it next to the plugin, remember the path in a file, and start it with a file:// URL:

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()?;
// …parse the tag from the page, download the asset for the platform, unpack…
PLUGIN_RPC.start_lsp(Url::from_file_path(dir.join("taplo")).unwrap(), vec!["lsp".into(), "stdio".into()], filters, options);

Prefer a serverPath setting so users can point at their own binary.

3. Try it in the editor (5 minutes)

Plugins live in the editor's data directory:

OS Directory
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\

(-Nightly and -Debug builds have their own directories.) Copy the plugin as plugins/<author>.<name>/ holding volt.toml, the .wasm, the icon and the README, restart the editor and open a file of the language. logs/graphoos.<date>.log in the same data directory shows the plugin's stderr, the initialize traffic and the language server's answers; a plugin that fails to load says why there.

4. Publish (10 minutes)

graphoos plugin check .
graphoos plugin publish . --registry ../Website/registry --dry-run
graphoos plugin publish . --registry ../Website/registry

publish puts the plugin in the registry's catalogue — or moves the version of one that is already there, where it is, so the order the registry lists them in does not change under anybody — and says what is left to do: build the registry and publish the site. --dry-run says what would change and writes nothing.

The wasm is looked for beside volt.toml, which is how a plugin is laid out once it is installed, and under target/wasm32-wasip1/release, which is where it is while it is being written.

The registry at graphoos.com/api/v1 is a static catalogue generated from a list of plugins; the editor's plugins panel installs from it. To be listed:

  1. Push the plugin to a public repository with volt.toml, README.md, icon.png and a way to build the .wasm (a plain cargo build as above is enough).
  2. Open an issue on github.com/Empoorio/graphoos titled registry: <name> with the repository URL and the version. The entry is added to Website/registry/plugins.toml, the package <author>.<name>-<version>.tar.gz (the four files) is built and the catalogue is redeployed.

Versions follow semver; bump version in both Cargo.toml and volt.toml for every release. The editor checks the catalogue for newer versions of installed plugins.

Beyond language servers

The same module can handle any request the editor sends (handle_request / handle_notification receive every method), start a debugger with RegisterDebuggerType, or send its own LSP requests and notifications with SendLspRequest / SendLspNotification (see psp_types). The nine maintained plugins cover the common shapes: graphoos-rust (server bundled by the toolchain), graphoos-go (server on PATH, with fallbacks), graphoos-c and graphoos-toml (server downloaded per platform), graphoos-python and graphoos-typescript (server the user installs with npm, with a clear message when missing).

Other languages than Rust: any toolchain that produces a wasm32-wasi module importing the graphoos host functions works in principle, but only the Rust crate is maintained today. An AssemblyScript SDK is on the roadmap.

Source: docs/plugins.md