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