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