1use std::fmt;
36use std::sync::Arc;
37
38use rich::r#box::Box as BoxStyle;
39use rich::{CodeHighlighter, FenceRenderer, Highlighter, Renderable, Text, Theme};
40
41pub const PLUGIN_API_VERSION: u32 = 1;
44
45pub type HighlighterFactory = Box<dyn Fn() -> Box<dyn Highlighter + Send> + Send + Sync>;
48
49#[derive(Clone, Debug, PartialEq, Eq)]
51#[non_exhaustive]
52pub struct PluginMetadata {
53 pub id: String,
55 pub name: String,
57 pub version: String,
59 pub api_version: u32,
61 pub description: Option<String>,
63}
64
65impl PluginMetadata {
66 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 pub fn description(mut self, description: impl Into<String>) -> Self {
79 self.description = Some(description.into());
80 self
81 }
82}
83
84pub trait SourceRenderer: Send + Sync {
87 fn render(&self, source: &str) -> Result<Box<dyn Renderable + Send + Sync>, PluginError>;
89}
90
91pub trait TextTransform: Send + Sync {
99 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
109pub trait PluginRegistrar {
115 fn highlighter(&mut self, factory: HighlighterFactory);
117
118 fn code_highlighter(&mut self, name: &str, highlighter: Arc<dyn CodeHighlighter>);
120
121 fn theme(&mut self, name: &str, theme: Theme);
123
124 fn box_style(&mut self, name: &str, style: BoxStyle);
126
127 fn renderer(&mut self, name: &str, renderer: Arc<dyn SourceRenderer>);
129
130 fn fence_renderer(&mut self, language: &str, renderer: Arc<dyn FenceRenderer>);
133
134 fn transform(&mut self, name: &str, transform: Arc<dyn TextTransform>);
136}
137
138pub trait Plugin: Send + Sync {
140 fn metadata(&self) -> PluginMetadata;
142
143 fn register(&self, registrar: &mut dyn PluginRegistrar) -> Result<(), PluginError>;
146}
147
148#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
150#[non_exhaustive]
151pub enum Capability {
152 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#[derive(Clone, Debug, PartialEq, Eq)]
178#[non_exhaustive]
179pub enum PluginError {
180 IncompatibleApi {
182 plugin: String,
183 built_for: u32,
184 host: u32,
185 },
186 DuplicatePlugin { id: String },
188 Conflict {
190 capability: Capability,
191 existing: String,
192 plugin: String,
193 },
194 InvalidName { plugin: String, name: String },
197 Failed { plugin: String, message: String },
199 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
241pub 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}