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}