rich_plugin_api/lib.rs
1//! The plugin contract for the `rich` Rust port.
2//!
3//! A plugin implements [`Plugin`]: it describes itself with [`PluginMetadata`]
4//! and adds capabilities through a [`PluginRegistrar`]. A host (`rs-rich-ext`'s
5//! `ExtensionRegistry`) calls [`Plugin::register`], checks the result, and then
6//! makes the capabilities available to consoles and commands.
7//!
8//! This crate depends only on core `rich`, so a plugin never needs `rs-rich-ext`.
9//! Everything first-party plugins do goes through this same contract.
10//!
11//! ```
12//! use rich_plugin_api::{Plugin, PluginError, PluginMetadata, PluginRegistrar};
13//! use rich::Theme;
14//!
15//! struct Solarized;
16//!
17//! impl Plugin for Solarized {
18//! fn metadata(&self) -> PluginMetadata {
19//! PluginMetadata::new("solarized", "Solarized themes", "1.0.0")
20//! }
21//! fn register(&self, registrar: &mut dyn PluginRegistrar) -> Result<(), PluginError> {
22//! let theme = Theme::from_styles([("repr.number", "#268bd2")], true)
23//! .map_err(|e| PluginError::Other(e.to_string()))?;
24//! registrar.theme("solarized", theme);
25//! Ok(())
26//! }
27//! }
28//! # assert_eq!(Solarized.metadata().api_version, rich_plugin_api::PLUGIN_API_VERSION);
29//! ```
30//!
31//! **Three ways to load a plugin.** A host adds a plugin value it was given
32//! (`add_plugin`). A plugin crate can also register itself for link-time
33//! collection with [`export_plugin!`], so that depending on it is enough
34//! ([`linked_plugins`]). And a plugin can be built as a native library or a
35//! WASM module and loaded at run time, through the text-only ABI in [`abi`].
36//! See docs/design/plugin-loading.md. Interactive [`component`]s are
37//! stateful Rust values, so only the first two ways carry them: the runtime
38//! ABI has no component kind.
39//!
40//! **Stability:** at 0.0.x this contract still changes. Every breaking change
41//! bumps [`PLUGIN_API_VERSION`], and hosts refuse a plugin built for another
42//! version rather than misbehaving.
43
44// A builder method that returns `Self` is `#[must_use]`: dropping its
45// result drops the value (CONTRIBUTING.md, "API conventions").
46#![warn(clippy::return_self_not_must_use)]
47
48use std::fmt;
49use std::sync::Arc;
50
51pub mod abi;
52pub mod component;
53mod linked;
54
55pub use component::{ComponentFactory, PluginComponent};
56#[doc(hidden)]
57pub use linked::__private;
58pub use linked::{LinkedPlugin, linked_plugins};
59
60use rich::r#box::Box as BoxStyle;
61use rich::{CodeHighlighter, FenceRenderer, Highlighter, Renderable, Text, Theme};
62
63/// The version of this contract (the Rust traits) that this host implements.
64/// A host accepts a plugin only if the plugin's
65/// [`PluginMetadata::api_version`] equals it ([`is_supported_api_version`]).
66///
67/// Every breaking change to the contract bumps it. It versions compile-time
68/// plugins (added with `add_plugin` or linked with [`export_plugin!`]); a
69/// runtime plugin is versioned by the ABI instead ([`abi::ABI_MAJOR`],
70/// [`abi::ABI_MINOR`]). A marketplace listing of a Cargo crate names it as
71/// `requires.plugin_api` (see docs/design/marketplace.md).
72///
73/// ```
74/// use rich_plugin_api::{is_supported_api_version, PluginMetadata, PLUGIN_API_VERSION};
75///
76/// assert_eq!(PLUGIN_API_VERSION, 1);
77/// let metadata = PluginMetadata::new("solarized", "Solarized themes", "1.0.0");
78/// assert_eq!(metadata.api_version, PLUGIN_API_VERSION);
79/// assert!(is_supported_api_version(metadata.api_version));
80/// ```
81pub const PLUGIN_API_VERSION: u32 = 1;
82
83/// Whether this host accepts a plugin built against plugin API
84/// `api_version`: only [`PLUGIN_API_VERSION`] itself.
85///
86/// This is the check `rs-rich-ext`'s `ExtensionRegistry::add_plugin` makes,
87/// so a tool that chooses a plugin before adding it decides as the host will.
88///
89/// ```
90/// use rich_plugin_api::{is_supported_api_version, PLUGIN_API_VERSION};
91///
92/// assert!(is_supported_api_version(PLUGIN_API_VERSION));
93/// assert!(!is_supported_api_version(PLUGIN_API_VERSION + 1));
94/// ```
95pub const fn is_supported_api_version(api_version: u32) -> bool {
96 api_version == PLUGIN_API_VERSION
97}
98
99/// Makes a fresh regex [`Highlighter`] each time a host installs it, so one
100/// registration can be installed onto many consoles.
101pub type HighlighterFactory = Box<dyn Fn() -> Box<dyn Highlighter + Send> + Send + Sync>;
102
103/// Who a plugin is. Build it with [`PluginMetadata::new`].
104#[derive(Clone, Debug, PartialEq, Eq)]
105#[non_exhaustive]
106pub struct PluginMetadata {
107 /// A stable, unique identifier: lowercase letters, digits, `-`, `_` and `.`.
108 pub id: String,
109 /// A human-readable name.
110 pub name: String,
111 /// The plugin's own version, usually `env!("CARGO_PKG_VERSION")`.
112 pub version: String,
113 /// The [`PLUGIN_API_VERSION`] the plugin was built against.
114 pub api_version: u32,
115 /// One line on what the plugin adds.
116 pub description: Option<String>,
117}
118
119impl PluginMetadata {
120 /// Metadata for a plugin built against this crate's [`PLUGIN_API_VERSION`].
121 pub fn new(id: impl Into<String>, name: impl Into<String>, version: impl Into<String>) -> Self {
122 PluginMetadata {
123 id: id.into(),
124 name: name.into(),
125 version: version.into(),
126 api_version: PLUGIN_API_VERSION,
127 description: None,
128 }
129 }
130
131 /// Add a one-line description.
132 #[must_use]
133 pub fn description(mut self, description: impl Into<String>) -> Self {
134 self.description = Some(description.into());
135 self
136 }
137}
138
139/// Turns source text into a renderable: a diagram, a data format, a report.
140/// Registered under a name with [`PluginRegistrar::renderer`].
141pub trait SourceRenderer: Send + Sync {
142 /// Render `source`. Errors should say what was wrong with the input.
143 fn render(&self, source: &str) -> Result<Box<dyn Renderable + Send + Sync>, PluginError>;
144}
145
146/// Rewrites a [`Text`]: keeps some lines, styles matches, masks secrets.
147/// Registered under a name with [`PluginRegistrar::transform`]; a host chains
148/// named transforms into a pipeline.
149///
150/// The text comes from the input being rendered, so treat it as untrusted.
151/// Change styles freely; text a transform adds must not carry terminal control
152/// sequences.
153pub trait TextTransform: Send + Sync {
154 /// Transform `text`. Errors should say what was wrong with the input.
155 fn transform(&self, text: Text) -> Result<Text, PluginError>;
156}
157
158impl<T: TextTransform + ?Sized> TextTransform for Arc<T> {
159 fn transform(&self, text: Text) -> Result<Text, PluginError> {
160 (**self).transform(text)
161 }
162}
163
164/// A custom action on interactive views (#491): list items, table rows, tree
165/// nodes and file entries. A host shows it in a view's action menu, and on
166/// its key if it has one; when the user picks it, the view reports the
167/// action's name and target, and the host may call [`run`](Self::run).
168pub trait CustomAction: Send + Sync {
169 /// The label a menu shows.
170 fn label(&self) -> String;
171
172 /// A key that picks it directly, as a key name (`ctrl+o`, `f5`), or
173 /// `None` (the default): from the menu only.
174 fn key(&self) -> Option<String> {
175 None
176 }
177
178 /// Whether it applies to a target: its kind (`item`, `row`, `node` or
179 /// `file`) and its value (an item's text, a row's cells joined by tabs,
180 /// a node's path of labels joined by `/`, a file's path). All, by
181 /// default.
182 fn applies(&self, kind: &str, value: &str) -> bool {
183 let _ = (kind, value);
184 true
185 }
186
187 /// Do it to a target. `Ok(Some(text))` is output for the host to show;
188 /// the default does nothing, leaving the host to act on the name. The
189 /// value comes from what the user browsed, so treat it as untrusted.
190 fn run(&self, kind: &str, value: &str) -> Result<Option<String>, PluginError> {
191 let _ = (kind, value);
192 Ok(None)
193 }
194}
195
196/// What a plugin can add. [`Plugin::register`] receives one of these.
197///
198/// Names are checked by the host when `register` returns: they must be
199/// non-empty and use lowercase letters, digits, `-`, `_` and `.`, and two
200/// plugins may not register the same capability under the same name.
201pub trait PluginRegistrar {
202 /// A regex highlighter applied to printed text.
203 fn highlighter(&mut self, factory: HighlighterFactory);
204
205 /// A syntax-highlighting engine, selectable by `name`.
206 fn code_highlighter(&mut self, name: &str, highlighter: Arc<dyn CodeHighlighter>);
207
208 /// A named theme.
209 fn theme(&mut self, name: &str, theme: Theme);
210
211 /// A named table/panel box style.
212 fn box_style(&mut self, name: &str, style: BoxStyle);
213
214 /// A named source renderer.
215 fn renderer(&mut self, name: &str, renderer: Arc<dyn SourceRenderer>);
216
217 /// A renderer for Markdown fences whose language is `language` (for
218 /// example `"mermaid"`), used in place of highlighting them as code.
219 fn fence_renderer(&mut self, language: &str, renderer: Arc<dyn FenceRenderer>);
220
221 /// A named text transform.
222 fn transform(&mut self, name: &str, transform: Arc<dyn TextTransform>);
223
224 /// A named custom action for interactive views. A host without them
225 /// ignores it, which is the default.
226 fn action(&mut self, name: &str, action: Arc<dyn CustomAction>) {
227 let _ = (name, action);
228 }
229
230 /// A named interactive component (see [`component`]): a view an app
231 /// or the CLI mounts by name, beside the built-in components. `factory`
232 /// makes a fresh one for every mount. A host without interactive views
233 /// ignores it, which is the default.
234 fn component(&mut self, name: &str, factory: ComponentFactory) {
235 let _ = (name, factory);
236 }
237}
238
239/// Something that extends `rich`.
240pub trait Plugin: Send + Sync {
241 /// Who the plugin is. Called before [`register`](Self::register).
242 fn metadata(&self) -> PluginMetadata;
243
244 /// Add capabilities. If this returns an error, the host keeps nothing the
245 /// plugin registered.
246 fn register(&self, registrar: &mut dyn PluginRegistrar) -> Result<(), PluginError>;
247}
248
249/// One thing a plugin registered, as a host reports it.
250#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
251#[non_exhaustive]
252pub enum Capability {
253 /// A regex highlighter (these are not named).
254 Highlighter,
255 CodeHighlighter(String),
256 Theme(String),
257 BoxStyle(String),
258 Renderer(String),
259 FenceRenderer(String),
260 Transform(String),
261 Action(String),
262 Component(String),
263}
264
265impl fmt::Display for Capability {
266 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267 match self {
268 Capability::Highlighter => write!(f, "highlighter"),
269 Capability::CodeHighlighter(name) => write!(f, "code highlighter {name:?}"),
270 Capability::Theme(name) => write!(f, "theme {name:?}"),
271 Capability::BoxStyle(name) => write!(f, "box style {name:?}"),
272 Capability::Renderer(name) => write!(f, "renderer {name:?}"),
273 Capability::FenceRenderer(language) => write!(f, "fence renderer {language:?}"),
274 Capability::Transform(name) => write!(f, "transform {name:?}"),
275 Capability::Action(name) => write!(f, "action {name:?}"),
276 Capability::Component(name) => write!(f, "component {name:?}"),
277 }
278 }
279}
280
281/// Why a plugin could not be added, or a renderer failed.
282#[derive(Clone, Debug, PartialEq, Eq)]
283#[non_exhaustive]
284pub enum PluginError {
285 /// The plugin was built for another [`PLUGIN_API_VERSION`].
286 IncompatibleApi {
287 plugin: String,
288 built_for: u32,
289 host: u32,
290 },
291 /// A plugin with this id is already registered.
292 DuplicatePlugin { id: String },
293 /// Two plugins registered the same capability under the same name.
294 Conflict {
295 capability: Capability,
296 existing: String,
297 plugin: String,
298 },
299 /// An id or capability name is empty or uses characters other than
300 /// lowercase letters, digits, `-`, `_` and `.`.
301 InvalidName { plugin: String, name: String },
302 /// The plugin's own `register` failed.
303 Failed { plugin: String, message: String },
304 /// A plugin-defined error, for example from a [`SourceRenderer`].
305 Other(String),
306}
307
308impl fmt::Display for PluginError {
309 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
310 match self {
311 PluginError::IncompatibleApi {
312 plugin,
313 built_for,
314 host,
315 } => write!(
316 f,
317 "plugin {plugin:?} was built for plugin API {built_for}, but this host supports \
318 plugin API {host}; use a version of the plugin built for API {host}"
319 ),
320 PluginError::DuplicatePlugin { id } => {
321 write!(f, "a plugin with id {id:?} is already registered")
322 }
323 PluginError::Conflict {
324 capability,
325 existing,
326 plugin,
327 } => write!(
328 f,
329 "plugin {plugin:?} registers {capability}, which plugin {existing:?} already provides"
330 ),
331 PluginError::InvalidName { plugin, name } => write!(
332 f,
333 "plugin {plugin:?} uses the invalid name {name:?}: use lowercase letters, digits, \
334 '-', '_' and '.', starting with a letter or digit"
335 ),
336 PluginError::Failed { plugin, message } => {
337 write!(f, "plugin {plugin:?} failed to register: {message}")
338 }
339 PluginError::Other(message) => f.write_str(message),
340 }
341 }
342}
343
344impl std::error::Error for PluginError {}
345
346/// Whether `name` is a valid plugin id or capability name: lowercase
347/// letters, digits, `-`, `_` and `.`, starting with a letter or digit so it
348/// is never read as a flag (`-x`) or a path component (`.`, `..`).
349pub fn is_valid_name(name: &str) -> bool {
350 name.len() <= 64
351 && name
352 .bytes()
353 .next()
354 .is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
355 && name.bytes().all(|b| {
356 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'-' | b'_' | b'.')
357 })
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363
364 #[test]
365 fn names_are_safe_cli_values() {
366 for good in ["syntect", "lumis", "ansi_dark", "a.b-c", "7z"] {
367 assert!(is_valid_name(good), "{good}");
368 }
369 for bad in [
370 "",
371 "-",
372 "-x",
373 "--help",
374 ".",
375 "..",
376 "_x",
377 "Upper",
378 "a b",
379 &"x".repeat(65),
380 ] {
381 assert!(!is_valid_name(bad), "{bad:?}");
382 }
383 }
384
385 #[test]
386 fn only_this_api_version_is_supported() {
387 assert!(is_supported_api_version(PLUGIN_API_VERSION));
388 assert!(is_supported_api_version(
389 PluginMetadata::new("a", "A", "1").api_version
390 ));
391 assert!(!is_supported_api_version(PLUGIN_API_VERSION + 1));
392 assert!(!is_supported_api_version(PLUGIN_API_VERSION - 1));
393 }
394
395 #[test]
396 fn metadata_targets_this_api_version() {
397 let meta = PluginMetadata::new("x", "X", "1.0.0").description("does x");
398 assert_eq!(meta.api_version, PLUGIN_API_VERSION);
399 assert_eq!(meta.description.as_deref(), Some("does x"));
400 }
401
402 #[test]
403 fn names_are_restricted() {
404 for good in ["syntect", "lumis", "my-plugin_2.x"] {
405 assert!(is_valid_name(good), "{good}");
406 }
407 for bad in [
408 "",
409 "Upper",
410 "has space",
411 "semi;colon",
412 "\u{1b}]0;x",
413 &"a".repeat(65),
414 ] {
415 assert!(!is_valid_name(bad), "{bad:?}");
416 }
417 }
418
419 #[test]
420 fn errors_name_both_sides() {
421 let conflict = PluginError::Conflict {
422 capability: Capability::Theme("dark".into()),
423 existing: "a".into(),
424 plugin: "b".into(),
425 };
426 let message = conflict.to_string();
427 assert!(message.contains("\"a\"") && message.contains("\"b\"") && message.contains("dark"));
428 let incompatible = PluginError::IncompatibleApi {
429 plugin: "old".into(),
430 built_for: 0,
431 host: PLUGIN_API_VERSION,
432 };
433 assert!(incompatible.to_string().contains("plugin API 0"));
434 }
435}