Adding a New Plugin
A plugin is a Rust cdylib crate that depends on elle-plugin (not elle) and exports elle_plugin_init. Plugins use a stable ABI and can be compiled independently from elle.
The worked example below is real code. It ships at demos/myplugin, make doctest builds it, and this document loads it — so every Rust line quoted here compiles and the Elle test at the end runs for real.
Files to create / modify (in order)
1. plugins/myplugin/Cargo.toml — New crate with crate-type = ["cdylib"].
2. plugins/myplugin/src/lib.rs — Plugin implementation.
3. Cargo.toml (root) — Add "plugins/myplugin" to [workspace] members.
4. Makefile — Add myplugin to the PLUGINS variable (one name per line, alphabetical).
5. tests/elle/plugins/myplugin.lisp — Integration tests.
6. plugins/myplugin/AGENTS.md — Documentation.
Step by step
Step 1: Create the crate.
# plugins/myplugin/Cargo.toml
[package]
name = "elle-myplugin"
version = "1.0.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
elle-plugin = { path = "../../elle-plugin" }
Step 2: Implement the plugin. Every plugin follows the same structure — an elle_plugin_init entry point generated by the define_plugin! macro:
use elle_plugin::{ElleCtx, EllePrimDef, ElleResult, ElleValue, SIG_OK};
elle_plugin::define_plugin!("myplugin/", &PRIMITIVES);
extern "C" fn prim_hello(ctx: *mut ElleCtx, _args: *const ElleValue, nargs: usize) -> ElleResult {
let a = api();
if nargs != 0 {
return a.err(ctx, "arity-error", "myplugin/hello: expected 0 arguments");
}
a.ok(a.string(ctx, "hello"))
}
static PRIMITIVES: &[EllePrimDef] = &[EllePrimDef::exact(
"myplugin/hello",
prim_hello,
SIG_OK,
0,
"Say hello.",
"myplugin",
"(myplugin/hello)",
)];
Key points:
- Primitives are
extern "C" fn(ctx: mut ElleCtx, args: const ElleValue, nargs: usize) -> ElleResult
ctxis an opaque per-call capability: pass it, unchanged, into every
allocating call so the result lands in the calling form's region on the calling fiber's heap. A plugin never dereferences it.
- Use
api()to access the resolved API (constructors, accessors, etc.) - Use
a.ok(value)for success,a.err(ctx, kind, msg)for errors EllePrimDef::exact(name, func, signal, arity, doc, category, example)
Step 3: Register in the workspace. Add to the root Cargo.toml:
[workspace]
members = [
# ...
"plugins/myplugin",
]
Step 4: Add to CI. Add myplugin to the PLUGINS variable in the Makefile, keeping alphabetical order. Run make check-plugin-list to verify the Makefile and Cargo.toml stay in sync.
Step 5: Write tests in tests/elle/plugins/myplugin.lisp:
(elle/epoch 12)
(def plugin (import "plugin/myplugin"))
(def hello-fn (get plugin :hello))
(assert (= (hello-fn) "hello") "myplugin/hello works")
Add a gate around that import in the file you write: a checkout that never built the plugins/ submodule has no library to load, and the test has to skip with a reason rather than fail. See testing.md § "Gating, not skip-lists" for the idiom. The version above is ungated because it is this document's own test, and make doctest builds the crate this document walks through — a failed import here is a broken build, not a missing optional dependency, so the document has to go red for it.
API reference
The api() function returns a reference to the resolved Api struct. A method takes the call's ctx first when it allocates, and also when it reads or writes a name: a symbol or keyword is a bare hash, and the spelling behind that hash lives in the calling instance's memo, which ctx is the way to reach (see docs/impl/symbol.md). Common methods:
let a = api();
// Constructors that do not allocate
a.int(42) // i64 → ElleValue
a.float(3.14) // f64 → ElleValue
a.boolean(true) // bool → ElleValue
a.nil() // nil
// Constructors that allocate or name, and so take ctx
a.string(ctx, "hello") // &str → ElleValue
a.bytes(ctx, &[1, 2, 3]) // &[u8] → ElleValue
a.keyword(ctx, "error") // &str → ElleValue (keyword)
a.array(ctx, &[v1, v2]) // &[ElleValue] → ElleValue
a.set(ctx, &[v1, v2]) // &[ElleValue] → ElleValue
a.build_struct(ctx, &[("key", val)]) // &[(&str, ElleValue)] → ElleValue
a.external(ctx, "name", data) // wrap Rust value
// Accessors — reads, so no ctx
a.get_int(v) // Option<i64>
a.get_float(v) // Option<f64>
a.get_string(v) // Option<&str>
a.get_bytes(v) // Option<&[u8]>
a.get_bool(v) // Option<bool>
a.get_external::<T>(v, "name") // Option<&T>
// Accessors that read a spelling back, and so take ctx
a.get_keyword_name(ctx, v) // Option<&str>
a.get_struct_key(ctx, v, i) // Option<&str>
a.struct_entries(ctx, v) // Vec<(&str, ElleValue)>
a.kw_name(ctx, hash) // Option<&str>
// Results
a.ok(value) // ElleResult with SIG_OK
a.err(ctx, "kind", "msg") // ElleResult with SIG_ERROR
a.yield_io(request) // ElleResult with SIG_IO
// Async
a.poll_fd(ctx, fd, events) // Create poll-fd I/O request
The full list is the elle_api! block and the impl Api wrappers in elle-plugin/src/lib.rs.
Conventions
- Plugin functions should validate arity and types.
- Return
a.ok(value)for success,a.err(ctx, kind, msg)for errors. - Wrap external Rust types via
a.external(ctx, "typename", value). - The entry point is generated by
define_plugin!— don't write it manually.
See also
- Cookbook index
- Plugin list
elle-plugin/AGENTS.md— ABI types referenceplugins/AGENTS.md— plugin system reference