Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Plugins system MVP #455

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,292 changes: 1,284 additions & 8 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ members = [
"helix-tui",
"helix-syntax",
"helix-lsp",
"helix-plugin",
]

# Build helix-syntax in release mode to make the code path faster in development.
Expand Down
14 changes: 14 additions & 0 deletions helix-plugin/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[package]
name = "helix-plugin"
version = "0.1.0"
edition = "2018"

[dependencies]
wasmtime = "0.28"
wasmtime-wasi = { version = "0.28", features = ["tokio"] }
anyhow = "1"
futures-util = { version = "0.3", features = ["std", "async-await"], default-features = false }

[dev-dependencies]
tokio-macros = "1.3"
tokio = "1.8"
16 changes: 16 additions & 0 deletions helix-plugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Helix plugins system MVP

This is still an early alpha, you should expect frequent breaking changes.
The intend is to take inspiration from [CodeMirror 6](https://codemirror.net/6/docs/ref/).

Plugins for Helix are written in any language that can be compiled to WASM (Web Assembly).
This implies:
- No language language lock-in, user can use what they're most comfortable with to extend and customize their experience.
- Sandboxing, a plugin can do only what it has been allowed to.
- Portability, .wasm and .wat files are platform agnostic and can be packaged as is for any platform.

API is described by .witx files and bindings for some languages can be generated by [`witx-bindgen`](https://github.com/bytecodealliance/witx-bindgen).
As of now, `witx-bindgen` is still in prototype stage.

Host side code is generated with [BLABLA TODO]

253 changes: 253 additions & 0 deletions helix-plugin/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
use anyhow::{Context, Result};
use std::collections::HashMap;
use std::fmt;
use std::path::PathBuf;
use wasmtime_wasi::tokio::WasiCtxBuilder;

#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct PluginName(String);

impl From<&str> for PluginName {
fn from(name: &str) -> Self {
Self(name.to_owned())
}
}

impl From<String> for PluginName {
fn from(name: String) -> Self {
Self(name)
}
}

impl fmt::Display for PluginName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}

/// Describes a path accessible from sandbox
#[derive(Debug)]
pub enum DirDef {
Mirrored {
path: PathBuf,
},
Mapped {
host_path: PathBuf,
guest_path: PathBuf,
},
}

impl DirDef {
/// Preopens the directory definition for the given WASI context
pub fn preopen(&self, wasi_builder: WasiCtxBuilder) -> Result<WasiCtxBuilder> {
use std::fs::File;
use wasmtime_wasi::Dir;

let (host_path, guest_path) = match self {
DirDef::Mirrored { path } => (path.as_path(), path.as_path()),
DirDef::Mapped {
host_path,
guest_path,
} => (host_path.as_path(), guest_path.as_path()),
};

let host_dir = unsafe {
// SAFETY: user is deciding for himself folders that should be accessibles
Dir::from_std_file(File::open(host_path)?)
};

wasi_builder.preopened_dir(host_dir, guest_path)
}
}

pub struct HelixCtx {
wasi: wasmtime_wasi::WasiCtx,
}

pub type HelixStore = wasmtime::Store<HelixCtx>;

pub struct PluginDef {
pub name: PluginName,
pub path: PathBuf,
pub dependencies: Vec<PluginName>,
}

pub struct Plugin {
instance: wasmtime::Instance,
}

impl Plugin {
pub fn get_typed_func<Params, Results>(
&self,
store: &mut HelixStore,
name: &str,
) -> Result<wasmtime::TypedFunc<Params, Results>>
where
Params: wasmtime::WasmParams,
Results: wasmtime::WasmResults,
{
let func = self
.instance
.get_typed_func::<Params, Results, _>(store, name)?;
Ok(func)
}

pub fn get_func(&self, store: &mut HelixStore, name: &str) -> Option<wasmtime::Func> {
self.instance.get_func(store, name)
}
}

pub struct PluginsSystem {
pub store: HelixStore,
pub plugins: HashMap<PluginName, Plugin>,
}

impl PluginsSystem {
pub fn builder() -> PluginsSystemBuilder {
PluginsSystemBuilder::default()
}
}

pub struct PluginsSystemBuilder {
debug_info: bool,
definitions: Vec<PluginDef>,
preopened_dirs: Vec<DirDef>,
linker_fn: Box<dyn Fn(&mut wasmtime::Linker<HelixCtx>) -> Result<()>>,
}

impl Default for PluginsSystemBuilder {
fn default() -> Self {
Self {
debug_info: false,
definitions: vec![],
preopened_dirs: vec![],
linker_fn: Box::new(|_| Ok(())),
}
}
}

impl PluginsSystemBuilder {
pub fn plugin(&mut self, def: PluginDef) -> &mut Self {
self.definitions.push(def);
self
}

pub fn plugins(&mut self, mut defs: Vec<PluginDef>) -> &mut Self {
self.definitions.append(&mut defs);
self
}

pub fn dir(&mut self, dir: DirDef) -> &mut Self {
self.preopened_dirs.push(dir);
self
}

pub fn dirs(&mut self, mut dirs: Vec<DirDef>) -> &mut Self {
self.preopened_dirs.append(&mut dirs);
self
}

pub fn debug_info(&mut self, debug: bool) -> &mut Self {
self.debug_info = debug;
self
}

pub fn linker<F>(&mut self, linker_fn: F) -> &mut Self
where
F: Fn(&mut wasmtime::Linker<HelixCtx>) -> Result<()> + 'static,
{
self.linker_fn = Box::new(linker_fn);
self
}

/// Instanciate the plugins system, compiling and linking WASM modules as appropriate.
pub async fn build(&self) -> Result<PluginsSystem> {
use wasmtime::{Config, Engine, Linker, Module, Store};

let mut config = Config::new();
config.debug_info(self.debug_info);
config.async_support(true);

let engine = Engine::new(&config)?;

// Compile plugins
let modules: HashMap<PluginName, Module> = self
.definitions
.iter()
.map(|def| {
println!("Compile {}", def.name);
Module::from_file(&engine, &def.path)
.with_context(|| format!("module creation failed for `{}`", def.name))
.map(|module| (def.name.clone(), module))
})
.collect::<Result<HashMap<PluginName, Module>>>()?;

// Dumb link order resolution: a good one would detect cycles to give a better error, in
// our case a link error will arise at link time
let mut link_order = Vec::new();
for def in &self.definitions {
let insert_pos = if let Some(pos) = link_order.iter().position(|name| name == &def.name)
{
pos
} else {
link_order.push(def.name.clone());
link_order.len() - 1
};

for dep in &def.dependencies {
if let Some(pos) = link_order.iter().position(|name| name == dep) {
if pos > insert_pos {
link_order.remove(pos);
link_order.insert(insert_pos, dep.clone());
}
} else {
link_order.insert(insert_pos, dep.clone());
};
}
}

// Link and create instances
let mut wasi_builder = WasiCtxBuilder::new();
for dir in &self.preopened_dirs {
wasi_builder = dir
.preopen(wasi_builder)
.with_context(|| format!("couldn't preopen directory `{:?}`", dir))?;
}
let wasi = wasi_builder.build();

let ctx = HelixCtx { wasi };

let mut store = Store::new(&engine, ctx);

let mut linker = Linker::new(&engine);
wasmtime_wasi::add_to_linker(&mut linker, |s: &mut HelixCtx| &mut s.wasi)?;

(self.linker_fn)(&mut linker).context("couldn't add host-provided modules to linker")?;

let mut plugins: HashMap<PluginName, Plugin> = HashMap::new();

for name in link_order {
let module = modules.get(&name).expect("this module was compiled above");

let instance = linker
.instantiate_async(&mut store, module)
.await
.with_context(|| format!("couldn't instanciate `{}`", name))?;

// Register the instance with the linker for the next linking
linker.instance(&mut store, &name.0, instance)?;

plugins.insert(name, Plugin { instance });
}

// Call `init` function on all loaded plugins
for plugin in plugins.values_mut() {
// Call this plugin's `init` function if one is defined
if let Ok(func) = plugin.get_typed_func::<(), ()>(&mut store, "init") {
func.call_async(&mut store, ()).await?;
}
}

Ok(PluginsSystem { store, plugins })
}
}
7 changes: 7 additions & 0 deletions helix-plugin/tests/call_host.wat
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
(module
(import "host" "callback" (func $callback))

(func (export "init")
call $callback
)
)
88 changes: 88 additions & 0 deletions helix-plugin/tests/linking.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
use helix_plugin::{DirDef, PluginDef, PluginName, PluginsSystem};
use std::path::PathBuf;

/// Based on https://docs.wasmtime.dev/examples-rust-linking.html
#[tokio_macros::test]
async fn two_plugins() {
let linking1 = PluginName::from("linking1");
let linking2 = PluginName::from("linking2");

let mut system = PluginsSystem::builder()
.plugin(PluginDef {
name: linking1.clone(),
path: PathBuf::from("tests/linking1.wat"),
dependencies: vec![linking2.clone()],
})
.plugin(PluginDef {
name: linking2.clone(),
path: PathBuf::from("tests/linking2.wat"),
dependencies: vec![],
})
.build()
.await
.unwrap();

let run = system
.plugins
.get(&linking1)
.unwrap()
.get_typed_func::<(), ()>(&mut system.store, "run")
.unwrap();
run.call_async(&mut system.store, ()).await.unwrap();

let double = system
.plugins
.get(&linking2)
.unwrap()
.get_typed_func::<i32, i32>(&mut system.store, "double")
.unwrap();
assert_eq!(double.call_async(&mut system.store, 5).await.unwrap(), 10);
}

#[tokio_macros::test(flavor = "multi_thread")]
async fn wasi_preopen_dir() {
let name = PluginName::from("read_file");

// read_file.wasm is a program reading `./Cargo.toml` file and does nothing with it

PluginsSystem::builder()
.dir(DirDef::Mirrored {
path: PathBuf::from("./"),
})
.plugin(PluginDef {
name: name.clone(),
path: PathBuf::from("tests/read_file.wasm"),
dependencies: vec![],
})
.build()
.await
.unwrap();
}

#[tokio_macros::test]
async fn callback_to_host() {
use std::sync::atomic::{AtomicBool, Ordering};

static CANARY: AtomicBool = AtomicBool::new(false);

PluginsSystem::builder()
.plugin(PluginDef {
name: PluginName::from("call_host"),
path: PathBuf::from("tests/call_host.wat"),
dependencies: vec![],
})
.linker(|l| {
l.func_wrap("host", "callback", || CANARY.store(true, Ordering::Relaxed))?;
Ok(())
})
.build()
.await
.unwrap();

assert_eq!(CANARY.load(Ordering::Relaxed), true);
}

#[tokio_macros::test]
async fn callback_to_content() {
todo!()
}
Loading