Plugin loading (#14, #232)¶
Status: implemented in 0.0.13 (plugin API 0.0.2, ext 0.0.11, CLI 0.0.13). This note records why third-party plugins load the way they do. How to write one is in the plugin guide.
Summary¶
A plugin reaches a registry in one of four ways. Each is more opt-in than the one before it.
| Way | How | Code runs | Off by default? |
|---|---|---|---|
| Explicit | registry.add_plugin(&MyPlugin) |
in process, compiled in | no: the caller names the plugin |
| Linked | export_plugin!(MyPlugin) in the plugin crate, ExtensionRegistry::with_linked_plugins() in the host |
in process, compiled in | yes: the host must ask for linked plugins |
| Native | a cdylib loaded from a path through a C ABI |
in process, arbitrary native code | yes: the dylib-plugins feature, then a path |
| WASM | a .wasm module loaded from a path, run by wasmi |
in a sandbox | yes: the wasm-plugins feature, then a path |
All four produce an ordinary rich_plugin_api::Plugin, so
ExtensionRegistry::add_plugin checks every one the same way: the name rules,
the API version, no two providers for one capability, all or nothing. Core
rich is unchanged and knows about none of this.
Compile-time plugins: inventory¶
rich_plugin_api::export_plugin!(expr) submits a LinkedPlugin (a function
that makes the plugin, and the module that exported it) to a link-time
collection. rich_plugin_api::linked_plugins() lists them, and
ExtensionRegistry::add_linked_plugins() adds them.
We use inventory rather than
linkme:
- No proc macro.
inventory'ssubmit!is a declarative macro.linkme's#[distributed_slice]is an attribute macro, which pullssyn,quoteandproc-macro2into every plugin's build. The plugin API crate is meant to stay small;inventoryadds one crate (andrustversion), with no build script. - Platform coverage.
inventoryregisters through each platform's constructor section (.init_array,__mod_init_func,.CRT$XCU) and supports the platformsrichdoes.linkmerelies on linker section start/stop symbols, which some linkers do not provide. - The cost is a constructor per plugin that runs before
main. It only links a node into a list; nothing of the plugin runs until a host asks.
Order is not the linker's. add_linked_plugins collects every plugin's id,
sorts them, and refuses the whole set when two share an id
(PluginError::DuplicatePlugin), before adding any. So the same binary always
gets the same registry, and a duplicate cannot depend on link order.
Linked plugins are not added by ExtensionRegistry::with_defaults():
registration stays explicit, and a host opts in with with_linked_plugins().
The rich binary does opt in, so a custom build that adds a plugin crate as a
dependency gets it without code changes.
One caveat applies to every link-time scheme: the linker may drop a crate the
binary depends on but never names. use my_plugin as _; keeps it.
One ABI for native and WASM plugins¶
Rust's ABI is unstable, and a trait object cannot cross a library boundary
built by another compiler. So runtime plugins get a deliberately small
contract that is text in, text out (rich_plugin_api::abi):
| Kind | Input | Output | Becomes |
|---|---|---|---|
transform |
plain text | plain text | a named TextTransform |
highlighter |
plain text | START END STYLE lines (byte offsets) |
a Highlighter |
fence-markup |
a fence's code, and the width | rich markup |
a FenceRenderer |
fence-ansi |
a fence's code, and the width | ANSI SGR text | a FenceRenderer |
Every call also receives the width available in cells (0 when unknown). Renderables, themes, box styles and code highlighters stay compile-time only: their Rust types cannot cross the boundary, and a serialized form would be a second rendering API to keep stable.
Both loaders decode a plugin's self-description into one type,
rich_plugin_api::abi::PluginAbi (ABI version, name, version, description,
and the capabilities in order), and wrap it with a backend in
rich_ext::plugin_loading::RuntimePlugin. One set of adapters in rich-ext
turns the capabilities into registry types and sanitizes every output. The
loaders differ only in how they call the plugin.
Versioning¶
ABI_MAJOR.ABI_MINOR is 1.0. The ABI version is separate from
PLUGIN_API_VERSION (which versions the Rust traits) because the two change
for different reasons. A host refuses another major. A newer minor loads if it
uses nothing the host lacks; an unknown capability kind is refused, never
skipped, so a plugin never silently loses half of what it does.
Native plugins (dylib-plugins)¶
The library exports rich_plugin_entry, an extern "C" fn() -> *const
PluginDescriptor. The descriptor is repr(C): the ABI version (two u32s
that stay first in every version, so a host reads them before trusting the
rest), the name, version and description as pointer and length pairs, the
capability array, and a vtable of two functions, call and free, neither
of which may be null. Output is allocated by the plugin and given back to it
through free, so the two sides never share an allocator.
A plugin author writes no unsafe code: export_dylib_plugin!(|| Exports::new(…)
.transform("reverse", reverse)) generates the entry point and the call
function, which catches panics so none unwinds across the boundary.
The host (libloading) canonicalizes the path, so a bare name is never looked
up in the system library path. The Library lives inside the backend, and
every registered capability holds the backend through an Arc, so the
library stays loaded as long as anything registered from it exists; it is
never unloaded while a function pointer into it can be called.
WASM plugins (wasm-plugins)¶
A module exports memory, rich_plugin_alloc, rich_plugin_manifest and
rich_plugin_call. The manifest is PluginAbi in a line-based text form
(PluginAbi::to_manifest), because a WASM module has no C struct layout to
share. Results are packed ptr << 32 | len; bit 63 marks an error message.
We use wasmi, a pure-Rust interpreter,
rather than wasmtime: no JIT, no C or assembly, far fewer dependencies, and
it builds on the workspace's MSRV. Plugins handle short texts, so an
interpreter's speed is enough. The sandbox:
- No imports. A module that imports anything is refused at load time, so it has no WASI, file system, network, clock or randomness. It sees only the text it is given.
- Fuel. Every call (instantiation included) gets a fuel budget, 50 million units by default; a module that runs out is stopped with an error.
- Memory. An instance may use 64 MiB by default. A module whose memory
starts above the cap is refused at load time; one that grows past it traps.
Its table is capped at 10,000 elements the same way, because each element
takes host memory outside the linear memory.
wasmi's strict limits bound the module's own size and structure, and the file may be 16 MiB. - A fresh instance per call, so no state or leaked memory carries over, and calls cannot interfere with each other.
Output hygiene¶
Runtime plugin output reaches a terminal, so the host never trusts it:
- Output over 4 MiB, or not UTF-8, is an error.
transform,highlighterandfence-markupoutput goes throughsanitize_terminal_controls: every control character, ESC included, is made visible.fence-ansioutput goes throughsanitize_ansi_for_decoder, the sanitizerrich viewuses: CSI sequences reach the ANSI decoder, which keeps only SGR styling, OSC strings are removed, and other controls are made visible.- A highlighter span that is out of range, not on a character boundary, or has a style that does not parse is skipped.
- Links and click meta are removed from every style in plugin output, from
[link=…]markup and from highlighter spans alike: a plugin cannot show one URL and link to another. - A failed fence call leaves the fence to render as code, as upstream does.
The CLI¶
rich --plugin PATH loads a plugin for one run. A config's plugins = [...]
does the same, only from a trusted config: the user's own
~/.config/rich/config.toml or a file given with --config. A project's
./rich.toml is ignored with a warning, as it is for
mermaid_backend = "mmdc", since a checked-out repository could otherwise
make every rich command in it run a native library. Relative paths are
relative to the config file.
Loading fails closed: a missing file (exit 3), a file of an unknown kind, a build without the loader, a refused ABI or a name another plugin provides (exit 2) all stop the command with a message naming the file. Nothing panics.
A loaded plugin draws its Markdown fences, adds its highlighters, and its
transforms run with --transform NAME: repeatable, in the order given, on
text, --print and --syntax, after --filter and before --highlight. The
names are checked once plugins are loaded, so an unknown one is a usage error
listing the names there are.
rich plugins list and rich plugins info NAME show every plugin with its
source (built-in, linked, native, wasm), version, ABI and
capabilities; --report json writes the same to stdout.
Deferred¶
- A Rust helper for WASM plugins. Native plugins get
export_dylib_plugin!; a WASM plugin is written against the documented exports (the example is hand-written WAT). A matching macro needs awasm32build in CI to test. - Manifests without running code, the registry and
rich plugin install(#232 phases 5 and 6). - Declared permissions. WASM plugins get no host access at all, so there is nothing yet to permit.