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:
- Push the plugin to a public repository with
volt.toml,README.md,icon.pngand a way to build the.wasm(a plaincargo buildas above is enough). - Open an issue on
github.com/Empoorio/graphoostitledregistry: <name>with the repository URL and the version. The entry is added toWebsite/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