rs-rich-plugin-api 0.0.3

The plugin contract for the `rich` Rust port: register highlighters, themes, box styles and renderers
Documentation
//! The plugin contract for the `rich` Rust port.
//!
//! A plugin implements [`Plugin`]: it describes itself with [`PluginMetadata`]
//! and adds capabilities through a [`PluginRegistrar`]. A host (`rs-rich-ext`'s
//! `ExtensionRegistry`) calls [`Plugin::register`], checks the result, and then
//! makes the capabilities available to consoles and commands.
//!
//! This crate depends only on core `rich`, so a plugin never needs `rs-rich-ext`.
//! Everything first-party plugins do goes through this same contract.
//!
//! ```
//! use rich_plugin_api::{Plugin, PluginError, PluginMetadata, PluginRegistrar};
//! use rich::Theme;
//!
//! struct Solarized;
//!
//! impl Plugin for Solarized {
//!     fn metadata(&self) -> PluginMetadata {
//!         PluginMetadata::new("solarized", "Solarized themes", "1.0.0")
//!     }
//!     fn register(&self, registrar: &mut dyn PluginRegistrar) -> Result<(), PluginError> {
//!         let theme = Theme::from_styles([("repr.number", "#268bd2")], true)
//!             .map_err(|e| PluginError::Other(e.to_string()))?;
//!         registrar.theme("solarized", theme);
//!         Ok(())
//!     }
//! }
//! # assert_eq!(Solarized.metadata().api_version, rich_plugin_api::PLUGIN_API_VERSION);
//! ```
//!
//! **Three ways to load a plugin.** A host adds a plugin value it was given
//! (`add_plugin`). A plugin crate can also register itself for link-time
//! collection with [`export_plugin!`], so that depending on it is enough
//! ([`linked_plugins`]). And a plugin can be built as a native library or a
//! WASM module and loaded at run time, through the text-only ABI in [`abi`].
//! See docs/design/plugin-loading.md. Interactive [`component`]s are
//! stateful Rust values, so only the first two ways carry them: the runtime
//! ABI has no component kind.
//!
//! **Stability:** at 0.0.x this contract still changes. Every breaking change
//! bumps [`PLUGIN_API_VERSION`], and hosts refuse a plugin built for another
//! version rather than misbehaving.

use std::fmt;
use std::sync::Arc;

pub mod abi;
pub mod component;
mod linked;

pub use component::{ComponentFactory, PluginComponent};
#[doc(hidden)]
pub use linked::__private;
pub use linked::{linked_plugins, LinkedPlugin};

use rich::r#box::Box as BoxStyle;
use rich::{CodeHighlighter, FenceRenderer, Highlighter, Renderable, Text, Theme};

/// The version of this contract. A host accepts a plugin only if the plugin's
/// [`PluginMetadata::api_version`] equals the host's.
pub const PLUGIN_API_VERSION: u32 = 1;

/// Makes a fresh regex [`Highlighter`] each time a host installs it, so one
/// registration can be installed onto many consoles.
pub type HighlighterFactory = Box<dyn Fn() -> Box<dyn Highlighter + Send> + Send + Sync>;

/// Who a plugin is. Build it with [`PluginMetadata::new`].
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct PluginMetadata {
    /// A stable, unique identifier: lowercase letters, digits, `-`, `_` and `.`.
    pub id: String,
    /// A human-readable name.
    pub name: String,
    /// The plugin's own version, usually `env!("CARGO_PKG_VERSION")`.
    pub version: String,
    /// The [`PLUGIN_API_VERSION`] the plugin was built against.
    pub api_version: u32,
    /// One line on what the plugin adds.
    pub description: Option<String>,
}

impl PluginMetadata {
    /// Metadata for a plugin built against this crate's [`PLUGIN_API_VERSION`].
    pub fn new(id: impl Into<String>, name: impl Into<String>, version: impl Into<String>) -> Self {
        PluginMetadata {
            id: id.into(),
            name: name.into(),
            version: version.into(),
            api_version: PLUGIN_API_VERSION,
            description: None,
        }
    }

    /// Add a one-line description.
    pub fn description(mut self, description: impl Into<String>) -> Self {
        self.description = Some(description.into());
        self
    }
}

/// Turns source text into a renderable: a diagram, a data format, a report.
/// Registered under a name with [`PluginRegistrar::renderer`].
pub trait SourceRenderer: Send + Sync {
    /// Render `source`. Errors should say what was wrong with the input.
    fn render(&self, source: &str) -> Result<Box<dyn Renderable + Send + Sync>, PluginError>;
}

/// Rewrites a [`Text`]: keeps some lines, styles matches, masks secrets.
/// Registered under a name with [`PluginRegistrar::transform`]; a host chains
/// named transforms into a pipeline.
///
/// The text comes from the input being rendered, so treat it as untrusted.
/// Change styles freely; text a transform adds must not carry terminal control
/// sequences.
pub trait TextTransform: Send + Sync {
    /// Transform `text`. Errors should say what was wrong with the input.
    fn transform(&self, text: Text) -> Result<Text, PluginError>;
}

impl<T: TextTransform + ?Sized> TextTransform for Arc<T> {
    fn transform(&self, text: Text) -> Result<Text, PluginError> {
        (**self).transform(text)
    }
}

/// A custom action on interactive views (#491): list items, table rows, tree
/// nodes and file entries. A host shows it in a view's action menu, and on
/// its key if it has one; when the user picks it, the view reports the
/// action's name and target, and the host may call [`run`](Self::run).
pub trait CustomAction: Send + Sync {
    /// The label a menu shows.
    fn label(&self) -> String;

    /// A key that picks it directly, as a key name (`ctrl+o`, `f5`), or
    /// `None` (the default): from the menu only.
    fn key(&self) -> Option<String> {
        None
    }

    /// Whether it applies to a target: its kind (`item`, `row`, `node` or
    /// `file`) and its value (an item's text, a row's cells joined by tabs,
    /// a node's path of labels joined by `/`, a file's path). All, by
    /// default.
    fn applies(&self, kind: &str, value: &str) -> bool {
        let _ = (kind, value);
        true
    }

    /// Do it to a target. `Ok(Some(text))` is output for the host to show;
    /// the default does nothing, leaving the host to act on the name. The
    /// value comes from what the user browsed, so treat it as untrusted.
    fn run(&self, kind: &str, value: &str) -> Result<Option<String>, PluginError> {
        let _ = (kind, value);
        Ok(None)
    }
}

/// What a plugin can add. [`Plugin::register`] receives one of these.
///
/// Names are checked by the host when `register` returns: they must be
/// non-empty and use lowercase letters, digits, `-`, `_` and `.`, and two
/// plugins may not register the same capability under the same name.
pub trait PluginRegistrar {
    /// A regex highlighter applied to printed text.
    fn highlighter(&mut self, factory: HighlighterFactory);

    /// A syntax-highlighting engine, selectable by `name`.
    fn code_highlighter(&mut self, name: &str, highlighter: Arc<dyn CodeHighlighter>);

    /// A named theme.
    fn theme(&mut self, name: &str, theme: Theme);

    /// A named table/panel box style.
    fn box_style(&mut self, name: &str, style: BoxStyle);

    /// A named source renderer.
    fn renderer(&mut self, name: &str, renderer: Arc<dyn SourceRenderer>);

    /// A renderer for Markdown fences whose language is `language` (for
    /// example `"mermaid"`), used in place of highlighting them as code.
    fn fence_renderer(&mut self, language: &str, renderer: Arc<dyn FenceRenderer>);

    /// A named text transform.
    fn transform(&mut self, name: &str, transform: Arc<dyn TextTransform>);

    /// A named custom action for interactive views. A host without them
    /// ignores it, which is the default.
    fn action(&mut self, name: &str, action: Arc<dyn CustomAction>) {
        let _ = (name, action);
    }

    /// A named interactive component (see [`component`]): a view an app
    /// or the CLI mounts by name, beside the built-in components. `factory`
    /// makes a fresh one for every mount. A host without interactive views
    /// ignores it, which is the default.
    fn component(&mut self, name: &str, factory: ComponentFactory) {
        let _ = (name, factory);
    }
}

/// Something that extends `rich`.
pub trait Plugin: Send + Sync {
    /// Who the plugin is. Called before [`register`](Self::register).
    fn metadata(&self) -> PluginMetadata;

    /// Add capabilities. If this returns an error, the host keeps nothing the
    /// plugin registered.
    fn register(&self, registrar: &mut dyn PluginRegistrar) -> Result<(), PluginError>;
}

/// One thing a plugin registered, as a host reports it.
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[non_exhaustive]
pub enum Capability {
    /// A regex highlighter (these are not named).
    Highlighter,
    CodeHighlighter(String),
    Theme(String),
    BoxStyle(String),
    Renderer(String),
    FenceRenderer(String),
    Transform(String),
    Action(String),
    Component(String),
}

impl fmt::Display for Capability {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Capability::Highlighter => write!(f, "highlighter"),
            Capability::CodeHighlighter(name) => write!(f, "code highlighter {name:?}"),
            Capability::Theme(name) => write!(f, "theme {name:?}"),
            Capability::BoxStyle(name) => write!(f, "box style {name:?}"),
            Capability::Renderer(name) => write!(f, "renderer {name:?}"),
            Capability::FenceRenderer(language) => write!(f, "fence renderer {language:?}"),
            Capability::Transform(name) => write!(f, "transform {name:?}"),
            Capability::Action(name) => write!(f, "action {name:?}"),
            Capability::Component(name) => write!(f, "component {name:?}"),
        }
    }
}

/// Why a plugin could not be added, or a renderer failed.
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum PluginError {
    /// The plugin was built for another [`PLUGIN_API_VERSION`].
    IncompatibleApi {
        plugin: String,
        built_for: u32,
        host: u32,
    },
    /// A plugin with this id is already registered.
    DuplicatePlugin { id: String },
    /// Two plugins registered the same capability under the same name.
    Conflict {
        capability: Capability,
        existing: String,
        plugin: String,
    },
    /// An id or capability name is empty or uses characters other than
    /// lowercase letters, digits, `-`, `_` and `.`.
    InvalidName { plugin: String, name: String },
    /// The plugin's own `register` failed.
    Failed { plugin: String, message: String },
    /// A plugin-defined error, for example from a [`SourceRenderer`].
    Other(String),
}

impl fmt::Display for PluginError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            PluginError::IncompatibleApi {
                plugin,
                built_for,
                host,
            } => write!(
                f,
                "plugin {plugin:?} was built for plugin API {built_for}, but this host supports \
                 plugin API {host}; use a version of the plugin built for API {host}"
            ),
            PluginError::DuplicatePlugin { id } => {
                write!(f, "a plugin with id {id:?} is already registered")
            }
            PluginError::Conflict {
                capability,
                existing,
                plugin,
            } => write!(
                f,
                "plugin {plugin:?} registers {capability}, which plugin {existing:?} already provides"
            ),
            PluginError::InvalidName { plugin, name } => write!(
                f,
                "plugin {plugin:?} uses the invalid name {name:?}: use lowercase letters, digits, \
                 '-', '_' and '.', starting with a letter or digit"
            ),
            PluginError::Failed { plugin, message } => {
                write!(f, "plugin {plugin:?} failed to register: {message}")
            }
            PluginError::Other(message) => f.write_str(message),
        }
    }
}

impl std::error::Error for PluginError {}

/// Whether `name` is a valid plugin id or capability name: lowercase
/// letters, digits, `-`, `_` and `.`, starting with a letter or digit so it
/// is never read as a flag (`-x`) or a path component (`.`, `..`).
pub fn is_valid_name(name: &str) -> bool {
    name.len() <= 64
        && name
            .bytes()
            .next()
            .is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
        && name.bytes().all(|b| {
            b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'-' | b'_' | b'.')
        })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn names_are_safe_cli_values() {
        for good in ["syntect", "lumis", "ansi_dark", "a.b-c", "7z"] {
            assert!(is_valid_name(good), "{good}");
        }
        for bad in [
            "",
            "-",
            "-x",
            "--help",
            ".",
            "..",
            "_x",
            "Upper",
            "a b",
            &"x".repeat(65),
        ] {
            assert!(!is_valid_name(bad), "{bad:?}");
        }
    }

    #[test]
    fn metadata_targets_this_api_version() {
        let meta = PluginMetadata::new("x", "X", "1.0.0").description("does x");
        assert_eq!(meta.api_version, PLUGIN_API_VERSION);
        assert_eq!(meta.description.as_deref(), Some("does x"));
    }

    #[test]
    fn names_are_restricted() {
        for good in ["syntect", "lumis", "my-plugin_2.x"] {
            assert!(is_valid_name(good), "{good}");
        }
        for bad in [
            "",
            "Upper",
            "has space",
            "semi;colon",
            "\u{1b}]0;x",
            &"a".repeat(65),
        ] {
            assert!(!is_valid_name(bad), "{bad:?}");
        }
    }

    #[test]
    fn errors_name_both_sides() {
        let conflict = PluginError::Conflict {
            capability: Capability::Theme("dark".into()),
            existing: "a".into(),
            plugin: "b".into(),
        };
        let message = conflict.to_string();
        assert!(message.contains("\"a\"") && message.contains("\"b\"") && message.contains("dark"));
        let incompatible = PluginError::IncompatibleApi {
            plugin: "old".into(),
            built_for: 0,
            host: PLUGIN_API_VERSION,
        };
        assert!(incompatible.to_string().contains("plugin API 0"));
    }
}