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:

extern "C" fn(ctx: mut ElleCtx, args: const ElleValue, nargs: usize) -> ElleResult

allocating call so the result lands in the calling form's region on the calling fiber's heap. A plugin never dereferences it.

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


See also