Skip to main content

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
44use std::fmt;
45use std::sync::Arc;
46
47pub mod abi;
48pub mod component;
49mod linked;
50
51pub use component::{ComponentFactory, PluginComponent};
52#[doc(hidden)]
53pub use linked::__private;
54pub use linked::{linked_plugins, LinkedPlugin};
55
56use rich::r#box::Box as BoxStyle;
57use rich::{CodeHighlighter, FenceRenderer, Highlighter, Renderable, Text, Theme};
58
59/// The version of this contract. A host accepts a plugin only if the plugin's
60/// [`PluginMetadata::api_version`] equals the host's.
61pub const PLUGIN_API_VERSION: u32 = 1;
62
63/// Makes a fresh regex [`Highlighter`] each time a host installs it, so one
64/// registration can be installed onto many consoles.
65pub type HighlighterFactory = Box<dyn Fn() -> Box<dyn Highlighter + Send> + Send + Sync>;
66
67/// Who a plugin is. Build it with [`PluginMetadata::new`].
68#[derive(Clone, Debug, PartialEq, Eq)]
69#[non_exhaustive]
70pub struct PluginMetadata {
71    /// A stable, unique identifier: lowercase letters, digits, `-`, `_` and `.`.
72    pub id: String,
73    /// A human-readable name.
74    pub name: String,
75    /// The plugin's own version, usually `env!("CARGO_PKG_VERSION")`.
76    pub version: String,
77    /// The [`PLUGIN_API_VERSION`] the plugin was built against.
78    pub api_version: u32,
79    /// One line on what the plugin adds.
80    pub description: Option<String>,
81}
82
83impl PluginMetadata {
84    /// Metadata for a plugin built against this crate's [`PLUGIN_API_VERSION`].
85    pub fn new(id: impl Into<String>, name: impl Into<String>, version: impl Into<String>) -> Self {
86        PluginMetadata {
87            id: id.into(),
88            name: name.into(),
89            version: version.into(),
90            api_version: PLUGIN_API_VERSION,
91            description: None,
92        }
93    }
94
95    /// Add a one-line description.
96    pub fn description(mut self, description: impl Into<String>) -> Self {
97        self.description = Some(description.into());
98        self
99    }
100}
101
102/// Turns source text into a renderable: a diagram, a data format, a report.
103/// Registered under a name with [`PluginRegistrar::renderer`].
104pub trait SourceRenderer: Send + Sync {
105    /// Render `source`. Errors should say what was wrong with the input.
106    fn render(&self, source: &str) -> Result<Box<dyn Renderable + Send + Sync>, PluginError>;
107}
108
109/// Rewrites a [`Text`]: keeps some lines, styles matches, masks secrets.
110/// Registered under a name with [`PluginRegistrar::transform`]; a host chains
111/// named transforms into a pipeline.
112///
113/// The text comes from the input being rendered, so treat it as untrusted.
114/// Change styles freely; text a transform adds must not carry terminal control
115/// sequences.
116pub trait TextTransform: Send + Sync {
117    /// Transform `text`. Errors should say what was wrong with the input.
118    fn transform(&self, text: Text) -> Result<Text, PluginError>;
119}
120
121impl<T: TextTransform + ?Sized> TextTransform for Arc<T> {
122    fn transform(&self, text: Text) -> Result<Text, PluginError> {
123        (**self).transform(text)
124    }
125}
126
127/// A custom action on interactive views (#491): list items, table rows, tree
128/// nodes and file entries. A host shows it in a view's action menu, and on
129/// its key if it has one; when the user picks it, the view reports the
130/// action's name and target, and the host may call [`run`](Self::run).
131pub trait CustomAction: Send + Sync {
132    /// The label a menu shows.
133    fn label(&self) -> String;
134
135    /// A key that picks it directly, as a key name (`ctrl+o`, `f5`), or
136    /// `None` (the default): from the menu only.
137    fn key(&self) -> Option<String> {
138        None
139    }
140
141    /// Whether it applies to a target: its kind (`item`, `row`, `node` or
142    /// `file`) and its value (an item's text, a row's cells joined by tabs,
143    /// a node's path of labels joined by `/`, a file's path). All, by
144    /// default.
145    fn applies(&self, kind: &str, value: &str) -> bool {
146        let _ = (kind, value);
147        true
148    }
149
150    /// Do it to a target. `Ok(Some(text))` is output for the host to show;
151    /// the default does nothing, leaving the host to act on the name. The
152    /// value comes from what the user browsed, so treat it as untrusted.
153    fn run(&self, kind: &str, value: &str) -> Result<Option<String>, PluginError> {
154        let _ = (kind, value);
155        Ok(None)
156    }
157}
158
159/// What a plugin can add. [`Plugin::register`] receives one of these.
160///
161/// Names are checked by the host when `register` returns: they must be
162/// non-empty and use lowercase letters, digits, `-`, `_` and `.`, and two
163/// plugins may not register the same capability under the same name.
164pub trait PluginRegistrar {
165    /// A regex highlighter applied to printed text.
166    fn highlighter(&mut self, factory: HighlighterFactory);
167
168    /// A syntax-highlighting engine, selectable by `name`.
169    fn code_highlighter(&mut self, name: &str, highlighter: Arc<dyn CodeHighlighter>);
170
171    /// A named theme.
172    fn theme(&mut self, name: &str, theme: Theme);
173
174    /// A named table/panel box style.
175    fn box_style(&mut self, name: &str, style: BoxStyle);
176
177    /// A named source renderer.
178    fn renderer(&mut self, name: &str, renderer: Arc<dyn SourceRenderer>);
179
180    /// A renderer for Markdown fences whose language is `language` (for
181    /// example `"mermaid"`), used in place of highlighting them as code.
182    fn fence_renderer(&mut self, language: &str, renderer: Arc<dyn FenceRenderer>);
183
184    /// A named text transform.
185    fn transform(&mut self, name: &str, transform: Arc<dyn TextTransform>);
186
187    /// A named custom action for interactive views. A host without them
188    /// ignores it, which is the default.
189    fn action(&mut self, name: &str, action: Arc<dyn CustomAction>) {
190        let _ = (name, action);
191    }
192
193    /// A named interactive component (see [`component`]): a view an app
194    /// or the CLI mounts by name, beside the built-in components. `factory`
195    /// makes a fresh one for every mount. A host without interactive views
196    /// ignores it, which is the default.
197    fn component(&mut self, name: &str, factory: ComponentFactory) {
198        let _ = (name, factory);
199    }
200}
201
202/// Something that extends `rich`.
203pub trait Plugin: Send + Sync {
204    /// Who the plugin is. Called before [`register`](Self::register).
205    fn metadata(&self) -> PluginMetadata;
206
207    /// Add capabilities. If this returns an error, the host keeps nothing the
208    /// plugin registered.
209    fn register(&self, registrar: &mut dyn PluginRegistrar) -> Result<(), PluginError>;
210}
211
212/// One thing a plugin registered, as a host reports it.
213#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
214#[non_exhaustive]
215pub enum Capability {
216    /// A regex highlighter (these are not named).
217    Highlighter,
218    CodeHighlighter(String),
219    Theme(String),
220    BoxStyle(String),
221    Renderer(String),
222    FenceRenderer(String),
223    Transform(String),
224    Action(String),
225    Component(String),
226}
227
228impl fmt::Display for Capability {
229    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230        match self {
231            Capability::Highlighter => write!(f, "highlighter"),
232            Capability::CodeHighlighter(name) => write!(f, "code highlighter {name:?}"),
233            Capability::Theme(name) => write!(f, "theme {name:?}"),
234            Capability::BoxStyle(name) => write!(f, "box style {name:?}"),
235            Capability::Renderer(name) => write!(f, "renderer {name:?}"),
236            Capability::FenceRenderer(language) => write!(f, "fence renderer {language:?}"),
237            Capability::Transform(name) => write!(f, "transform {name:?}"),
238            Capability::Action(name) => write!(f, "action {name:?}"),
239            Capability::Component(name) => write!(f, "component {name:?}"),
240        }
241    }
242}
243
244/// Why a plugin could not be added, or a renderer failed.
245#[derive(Clone, Debug, PartialEq, Eq)]
246#[non_exhaustive]
247pub enum PluginError {
248    /// The plugin was built for another [`PLUGIN_API_VERSION`].
249    IncompatibleApi {
250        plugin: String,
251        built_for: u32,
252        host: u32,
253    },
254    /// A plugin with this id is already registered.
255    DuplicatePlugin { id: String },
256    /// Two plugins registered the same capability under the same name.
257    Conflict {
258        capability: Capability,
259        existing: String,
260        plugin: String,
261    },
262    /// An id or capability name is empty or uses characters other than
263    /// lowercase letters, digits, `-`, `_` and `.`.
264    InvalidName { plugin: String, name: String },
265    /// The plugin's own `register` failed.
266    Failed { plugin: String, message: String },
267    /// A plugin-defined error, for example from a [`SourceRenderer`].
268    Other(String),
269}
270
271impl fmt::Display for PluginError {
272    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
273        match self {
274            PluginError::IncompatibleApi {
275                plugin,
276                built_for,
277                host,
278            } => write!(
279                f,
280                "plugin {plugin:?} was built for plugin API {built_for}, but this host supports \
281                 plugin API {host}; use a version of the plugin built for API {host}"
282            ),
283            PluginError::DuplicatePlugin { id } => {
284                write!(f, "a plugin with id {id:?} is already registered")
285            }
286            PluginError::Conflict {
287                capability,
288                existing,
289                plugin,
290            } => write!(
291                f,
292                "plugin {plugin:?} registers {capability}, which plugin {existing:?} already provides"
293            ),
294            PluginError::InvalidName { plugin, name } => write!(
295                f,
296                "plugin {plugin:?} uses the invalid name {name:?}: use lowercase letters, digits, \
297                 '-', '_' and '.', starting with a letter or digit"
298            ),
299            PluginError::Failed { plugin, message } => {
300                write!(f, "plugin {plugin:?} failed to register: {message}")
301            }
302            PluginError::Other(message) => f.write_str(message),
303        }
304    }
305}
306
307impl std::error::Error for PluginError {}
308
309/// Whether `name` is a valid plugin id or capability name: lowercase
310/// letters, digits, `-`, `_` and `.`, starting with a letter or digit so it
311/// is never read as a flag (`-x`) or a path component (`.`, `..`).
312pub fn is_valid_name(name: &str) -> bool {
313    name.len() <= 64
314        && name
315            .bytes()
316            .next()
317            .is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
318        && name.bytes().all(|b| {
319            b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'-' | b'_' | b'.')
320        })
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn names_are_safe_cli_values() {
329        for good in ["syntect", "lumis", "ansi_dark", "a.b-c", "7z"] {
330            assert!(is_valid_name(good), "{good}");
331        }
332        for bad in [
333            "",
334            "-",
335            "-x",
336            "--help",
337            ".",
338            "..",
339            "_x",
340            "Upper",
341            "a b",
342            &"x".repeat(65),
343        ] {
344            assert!(!is_valid_name(bad), "{bad:?}");
345        }
346    }
347
348    #[test]
349    fn metadata_targets_this_api_version() {
350        let meta = PluginMetadata::new("x", "X", "1.0.0").description("does x");
351        assert_eq!(meta.api_version, PLUGIN_API_VERSION);
352        assert_eq!(meta.description.as_deref(), Some("does x"));
353    }
354
355    #[test]
356    fn names_are_restricted() {
357        for good in ["syntect", "lumis", "my-plugin_2.x"] {
358            assert!(is_valid_name(good), "{good}");
359        }
360        for bad in [
361            "",
362            "Upper",
363            "has space",
364            "semi;colon",
365            "\u{1b}]0;x",
366            &"a".repeat(65),
367        ] {
368            assert!(!is_valid_name(bad), "{bad:?}");
369        }
370    }
371
372    #[test]
373    fn errors_name_both_sides() {
374        let conflict = PluginError::Conflict {
375            capability: Capability::Theme("dark".into()),
376            existing: "a".into(),
377            plugin: "b".into(),
378        };
379        let message = conflict.to_string();
380        assert!(message.contains("\"a\"") && message.contains("\"b\"") && message.contains("dark"));
381        let incompatible = PluginError::IncompatibleApi {
382            plugin: "old".into(),
383            built_for: 0,
384            host: PLUGIN_API_VERSION,
385        };
386        assert!(incompatible.to_string().contains("plugin API 0"));
387    }
388}