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//! **Stability:** at 0.0.x this contract still changes. Every breaking change
32//! bumps [`PLUGIN_API_VERSION`], and hosts refuse a plugin built for another
33//! version rather than misbehaving.
34
35use std::fmt;
36use std::sync::Arc;
37
38use rich::r#box::Box as BoxStyle;
39use rich::{CodeHighlighter, FenceRenderer, Highlighter, Renderable, Text, Theme};
40
41/// The version of this contract. A host accepts a plugin only if the plugin's
42/// [`PluginMetadata::api_version`] equals the host's.
43pub const PLUGIN_API_VERSION: u32 = 1;
44
45/// Makes a fresh regex [`Highlighter`] each time a host installs it, so one
46/// registration can be installed onto many consoles.
47pub type HighlighterFactory = Box<dyn Fn() -> Box<dyn Highlighter + Send> + Send + Sync>;
48
49/// Who a plugin is. Build it with [`PluginMetadata::new`].
50#[derive(Clone, Debug, PartialEq, Eq)]
51#[non_exhaustive]
52pub struct PluginMetadata {
53    /// A stable, unique identifier: lowercase letters, digits, `-`, `_` and `.`.
54    pub id: String,
55    /// A human-readable name.
56    pub name: String,
57    /// The plugin's own version, usually `env!("CARGO_PKG_VERSION")`.
58    pub version: String,
59    /// The [`PLUGIN_API_VERSION`] the plugin was built against.
60    pub api_version: u32,
61    /// One line on what the plugin adds.
62    pub description: Option<String>,
63}
64
65impl PluginMetadata {
66    /// Metadata for a plugin built against this crate's [`PLUGIN_API_VERSION`].
67    pub fn new(id: impl Into<String>, name: impl Into<String>, version: impl Into<String>) -> Self {
68        PluginMetadata {
69            id: id.into(),
70            name: name.into(),
71            version: version.into(),
72            api_version: PLUGIN_API_VERSION,
73            description: None,
74        }
75    }
76
77    /// Add a one-line description.
78    pub fn description(mut self, description: impl Into<String>) -> Self {
79        self.description = Some(description.into());
80        self
81    }
82}
83
84/// Turns source text into a renderable: a diagram, a data format, a report.
85/// Registered under a name with [`PluginRegistrar::renderer`].
86pub trait SourceRenderer: Send + Sync {
87    /// Render `source`. Errors should say what was wrong with the input.
88    fn render(&self, source: &str) -> Result<Box<dyn Renderable + Send + Sync>, PluginError>;
89}
90
91/// Rewrites a [`Text`]: keeps some lines, styles matches, masks secrets.
92/// Registered under a name with [`PluginRegistrar::transform`]; a host chains
93/// named transforms into a pipeline.
94///
95/// The text comes from the input being rendered, so treat it as untrusted.
96/// Change styles freely; text a transform adds must not carry terminal control
97/// sequences.
98pub trait TextTransform: Send + Sync {
99    /// Transform `text`. Errors should say what was wrong with the input.
100    fn transform(&self, text: Text) -> Result<Text, PluginError>;
101}
102
103impl<T: TextTransform + ?Sized> TextTransform for Arc<T> {
104    fn transform(&self, text: Text) -> Result<Text, PluginError> {
105        (**self).transform(text)
106    }
107}
108
109/// What a plugin can add. [`Plugin::register`] receives one of these.
110///
111/// Names are checked by the host when `register` returns: they must be
112/// non-empty and use lowercase letters, digits, `-`, `_` and `.`, and two
113/// plugins may not register the same capability under the same name.
114pub trait PluginRegistrar {
115    /// A regex highlighter applied to printed text.
116    fn highlighter(&mut self, factory: HighlighterFactory);
117
118    /// A syntax-highlighting engine, selectable by `name`.
119    fn code_highlighter(&mut self, name: &str, highlighter: Arc<dyn CodeHighlighter>);
120
121    /// A named theme.
122    fn theme(&mut self, name: &str, theme: Theme);
123
124    /// A named table/panel box style.
125    fn box_style(&mut self, name: &str, style: BoxStyle);
126
127    /// A named source renderer.
128    fn renderer(&mut self, name: &str, renderer: Arc<dyn SourceRenderer>);
129
130    /// A renderer for Markdown fences whose language is `language` (for
131    /// example `"mermaid"`), used in place of highlighting them as code.
132    fn fence_renderer(&mut self, language: &str, renderer: Arc<dyn FenceRenderer>);
133
134    /// A named text transform.
135    fn transform(&mut self, name: &str, transform: Arc<dyn TextTransform>);
136}
137
138/// Something that extends `rich`.
139pub trait Plugin: Send + Sync {
140    /// Who the plugin is. Called before [`register`](Self::register).
141    fn metadata(&self) -> PluginMetadata;
142
143    /// Add capabilities. If this returns an error, the host keeps nothing the
144    /// plugin registered.
145    fn register(&self, registrar: &mut dyn PluginRegistrar) -> Result<(), PluginError>;
146}
147
148/// One thing a plugin registered, as a host reports it.
149#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
150#[non_exhaustive]
151pub enum Capability {
152    /// A regex highlighter (these are not named).
153    Highlighter,
154    CodeHighlighter(String),
155    Theme(String),
156    BoxStyle(String),
157    Renderer(String),
158    FenceRenderer(String),
159    Transform(String),
160}
161
162impl fmt::Display for Capability {
163    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164        match self {
165            Capability::Highlighter => write!(f, "highlighter"),
166            Capability::CodeHighlighter(name) => write!(f, "code highlighter {name:?}"),
167            Capability::Theme(name) => write!(f, "theme {name:?}"),
168            Capability::BoxStyle(name) => write!(f, "box style {name:?}"),
169            Capability::Renderer(name) => write!(f, "renderer {name:?}"),
170            Capability::FenceRenderer(language) => write!(f, "fence renderer {language:?}"),
171            Capability::Transform(name) => write!(f, "transform {name:?}"),
172        }
173    }
174}
175
176/// Why a plugin could not be added, or a renderer failed.
177#[derive(Clone, Debug, PartialEq, Eq)]
178#[non_exhaustive]
179pub enum PluginError {
180    /// The plugin was built for another [`PLUGIN_API_VERSION`].
181    IncompatibleApi {
182        plugin: String,
183        built_for: u32,
184        host: u32,
185    },
186    /// A plugin with this id is already registered.
187    DuplicatePlugin { id: String },
188    /// Two plugins registered the same capability under the same name.
189    Conflict {
190        capability: Capability,
191        existing: String,
192        plugin: String,
193    },
194    /// An id or capability name is empty or uses characters other than
195    /// lowercase letters, digits, `-`, `_` and `.`.
196    InvalidName { plugin: String, name: String },
197    /// The plugin's own `register` failed.
198    Failed { plugin: String, message: String },
199    /// A plugin-defined error, for example from a [`SourceRenderer`].
200    Other(String),
201}
202
203impl fmt::Display for PluginError {
204    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205        match self {
206            PluginError::IncompatibleApi {
207                plugin,
208                built_for,
209                host,
210            } => write!(
211                f,
212                "plugin {plugin:?} was built for plugin API {built_for}, but this host supports \
213                 plugin API {host}; use a version of the plugin built for API {host}"
214            ),
215            PluginError::DuplicatePlugin { id } => {
216                write!(f, "a plugin with id {id:?} is already registered")
217            }
218            PluginError::Conflict {
219                capability,
220                existing,
221                plugin,
222            } => write!(
223                f,
224                "plugin {plugin:?} registers {capability}, which plugin {existing:?} already provides"
225            ),
226            PluginError::InvalidName { plugin, name } => write!(
227                f,
228                "plugin {plugin:?} uses the invalid name {name:?}: use lowercase letters, digits, \
229                 '-', '_' and '.', starting with a letter or digit"
230            ),
231            PluginError::Failed { plugin, message } => {
232                write!(f, "plugin {plugin:?} failed to register: {message}")
233            }
234            PluginError::Other(message) => f.write_str(message),
235        }
236    }
237}
238
239impl std::error::Error for PluginError {}
240
241/// Whether `name` is a valid plugin id or capability name: lowercase
242/// letters, digits, `-`, `_` and `.`, starting with a letter or digit so it
243/// is never read as a flag (`-x`) or a path component (`.`, `..`).
244pub fn is_valid_name(name: &str) -> bool {
245    name.len() <= 64
246        && name
247            .bytes()
248            .next()
249            .is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
250        && name.bytes().all(|b| {
251            b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'-' | b'_' | b'.')
252        })
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn names_are_safe_cli_values() {
261        for good in ["syntect", "lumis", "ansi_dark", "a.b-c", "7z"] {
262            assert!(is_valid_name(good), "{good}");
263        }
264        for bad in [
265            "",
266            "-",
267            "-x",
268            "--help",
269            ".",
270            "..",
271            "_x",
272            "Upper",
273            "a b",
274            &"x".repeat(65),
275        ] {
276            assert!(!is_valid_name(bad), "{bad:?}");
277        }
278    }
279
280    #[test]
281    fn metadata_targets_this_api_version() {
282        let meta = PluginMetadata::new("x", "X", "1.0.0").description("does x");
283        assert_eq!(meta.api_version, PLUGIN_API_VERSION);
284        assert_eq!(meta.description.as_deref(), Some("does x"));
285    }
286
287    #[test]
288    fn names_are_restricted() {
289        for good in ["syntect", "lumis", "my-plugin_2.x"] {
290            assert!(is_valid_name(good), "{good}");
291        }
292        for bad in [
293            "",
294            "Upper",
295            "has space",
296            "semi;colon",
297            "\u{1b}]0;x",
298            &"a".repeat(65),
299        ] {
300            assert!(!is_valid_name(bad), "{bad:?}");
301        }
302    }
303
304    #[test]
305    fn errors_name_both_sides() {
306        let conflict = PluginError::Conflict {
307            capability: Capability::Theme("dark".into()),
308            existing: "a".into(),
309            plugin: "b".into(),
310        };
311        let message = conflict.to_string();
312        assert!(message.contains("\"a\"") && message.contains("\"b\"") && message.contains("dark"));
313        let incompatible = PluginError::IncompatibleApi {
314            plugin: "old".into(),
315            built_for: 0,
316            host: PLUGIN_API_VERSION,
317        };
318        assert!(incompatible.to_string().contains("plugin API 0"));
319    }
320}