1use std::fmt;
23use std::path::{Path, PathBuf};
24use std::sync::Arc;
25
26use rich::console::ConsoleOptions;
27use rich::segment::Segment;
28use rich::{Console, FenceRenderer, Highlighter, Renderable, Style, StyleType, Text};
29use rich_plugin_api::abi::{AbiError, CapabilityKind, PluginAbi};
30use rich_plugin_api::{Plugin, PluginError, PluginMetadata, PluginRegistrar, TextTransform};
31
32use crate::sanitize::{sanitize_ansi_for_decoder, sanitize_terminal_controls};
33
34#[cfg(feature = "dylib-plugins")]
35mod native;
36#[cfg(feature = "wasm-plugins")]
37mod wasm;
38
39#[cfg(feature = "dylib-plugins")]
40pub use native::load_native;
41#[cfg(feature = "wasm-plugins")]
42pub use wasm::{load_wasm, load_wasm_bytes};
43
44pub const MAX_OUTPUT_BYTES: usize = 4 * 1024 * 1024;
46pub const MAX_SPANS: usize = 10_000;
48
49#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
51pub enum RuntimeKind {
52 Native,
54 Wasm,
56}
57
58impl RuntimeKind {
59 pub fn as_str(self) -> &'static str {
61 match self {
62 RuntimeKind::Native => "native",
63 RuntimeKind::Wasm => "wasm",
64 }
65 }
66
67 pub fn for_path(path: &Path) -> Option<RuntimeKind> {
70 match path.extension()?.to_str()?.to_ascii_lowercase().as_str() {
71 "wasm" => Some(RuntimeKind::Wasm),
72 "so" | "dylib" | "dll" => Some(RuntimeKind::Native),
73 _ => None,
74 }
75 }
76
77 pub fn feature(self) -> &'static str {
79 match self {
80 RuntimeKind::Native => "dylib-plugins",
81 RuntimeKind::Wasm => "wasm-plugins",
82 }
83 }
84
85 pub fn supported(self) -> bool {
87 match self {
88 RuntimeKind::Native => cfg!(feature = "dylib-plugins"),
89 RuntimeKind::Wasm => cfg!(feature = "wasm-plugins"),
90 }
91 }
92}
93
94impl fmt::Display for RuntimeKind {
95 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96 f.write_str(self.as_str())
97 }
98}
99
100#[derive(Clone, Copy, Debug, PartialEq, Eq)]
102pub struct WasmLimits {
103 pub fuel: u64,
105 pub memory_bytes: usize,
107 pub table_elements: usize,
110 pub module_bytes: u64,
112}
113
114impl Default for WasmLimits {
115 fn default() -> Self {
116 WasmLimits {
117 fuel: 50_000_000,
118 memory_bytes: 64 * 1024 * 1024,
119 table_elements: 10_000,
120 module_bytes: 16 * 1024 * 1024,
121 }
122 }
123}
124
125#[derive(Clone, Debug, PartialEq, Eq)]
127#[non_exhaustive]
128pub enum LoadError {
129 Io { path: PathBuf, message: String },
131 UnknownKind { path: PathBuf },
133 Unsupported {
135 path: PathBuf,
136 feature: &'static str,
137 },
138 NotAPlugin { path: PathBuf, message: String },
140 Abi { path: PathBuf, error: AbiError },
142 Limit { path: PathBuf, message: String },
144}
145
146impl LoadError {
147 pub fn path(&self) -> &Path {
149 match self {
150 LoadError::Io { path, .. }
151 | LoadError::UnknownKind { path }
152 | LoadError::Unsupported { path, .. }
153 | LoadError::NotAPlugin { path, .. }
154 | LoadError::Abi { path, .. }
155 | LoadError::Limit { path, .. } => path,
156 }
157 }
158}
159
160impl fmt::Display for LoadError {
161 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162 let path = self.path().display();
163 match self {
164 LoadError::Io { message, .. } => write!(f, "plugin {path}: {message}"),
165 LoadError::UnknownKind { .. } => write!(
166 f,
167 "plugin {path}: not a plugin file; expected .wasm, or a native library \
168 (.so, .dylib, .dll)"
169 ),
170 LoadError::Unsupported { feature, .. } => write!(
171 f,
172 "plugin {path}: this build cannot load it; it needs the {feature} feature"
173 ),
174 LoadError::NotAPlugin { message, .. } => {
175 write!(f, "plugin {path}: not a rich plugin: {message}")
176 }
177 LoadError::Abi { error, .. } => write!(f, "plugin {path}: {error}"),
178 LoadError::Limit { message, .. } => write!(f, "plugin {path}: {message}"),
179 }
180 }
181}
182
183impl std::error::Error for LoadError {}
184
185#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
187pub struct LoadOptions {
188 pub wasm: WasmLimits,
189}
190
191pub fn load(path: &Path, options: &LoadOptions) -> Result<RuntimePlugin, LoadError> {
196 let kind = RuntimeKind::for_path(path).ok_or_else(|| LoadError::UnknownKind {
197 path: path.to_path_buf(),
198 })?;
199 if !kind.supported() {
200 return Err(LoadError::Unsupported {
201 path: path.to_path_buf(),
202 feature: kind.feature(),
203 });
204 }
205 let _ = options;
206 match kind {
207 #[cfg(feature = "dylib-plugins")]
208 RuntimeKind::Native => load_native(path),
209 #[cfg(feature = "wasm-plugins")]
210 RuntimeKind::Wasm => load_wasm(path, &options.wasm),
211 #[allow(unreachable_patterns)]
212 _ => unreachable!("checked by RuntimeKind::supported"),
213 }
214}
215
216pub trait AbiBackend: Send + Sync {
219 fn call(&self, capability: usize, input: &str, width: u32) -> Result<String, String>;
223}
224
225#[derive(Clone)]
229pub struct RuntimePlugin {
230 abi: PluginAbi,
231 kind: RuntimeKind,
232 path: PathBuf,
233 backend: Arc<dyn AbiBackend>,
234}
235
236impl fmt::Debug for RuntimePlugin {
237 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238 f.debug_struct("RuntimePlugin")
239 .field("abi", &self.abi)
240 .field("kind", &self.kind)
241 .field("path", &self.path)
242 .finish_non_exhaustive()
243 }
244}
245
246impl RuntimePlugin {
247 pub fn new(
250 abi: PluginAbi,
251 kind: RuntimeKind,
252 path: impl Into<PathBuf>,
253 backend: Arc<dyn AbiBackend>,
254 ) -> Result<Self, LoadError> {
255 let path = path.into();
256 abi.validate().map_err(|error| LoadError::Abi {
257 path: path.clone(),
258 error,
259 })?;
260 Ok(RuntimePlugin {
261 abi,
262 kind,
263 path,
264 backend,
265 })
266 }
267
268 pub fn abi(&self) -> &PluginAbi {
270 &self.abi
271 }
272
273 pub fn kind(&self) -> RuntimeKind {
275 self.kind
276 }
277
278 pub fn path(&self) -> &Path {
280 &self.path
281 }
282
283 pub fn call(&self, capability: usize, input: &str, width: u32) -> Result<String, String> {
286 let output = self.backend.call(capability, input, width)?;
287 if output.len() > MAX_OUTPUT_BYTES {
288 return Err(format!(
289 "the plugin returned {} bytes; at most {MAX_OUTPUT_BYTES} are accepted",
290 output.len()
291 ));
292 }
293 Ok(output)
294 }
295
296 fn capability(&self, index: usize) -> Capability {
297 Capability {
298 plugin: Arc::new(self.clone()),
299 index,
300 }
301 }
302}
303
304impl Plugin for RuntimePlugin {
305 fn metadata(&self) -> PluginMetadata {
306 let metadata = PluginMetadata::new(&*self.abi.name, &*self.abi.name, &*self.abi.version);
307 if self.abi.description.is_empty() {
308 metadata
309 } else {
310 metadata.description(&*self.abi.description)
311 }
312 }
313
314 fn register(&self, registrar: &mut dyn PluginRegistrar) -> Result<(), PluginError> {
315 for (index, capability) in self.abi.capabilities.iter().enumerate() {
316 let adapter = self.capability(index);
317 match capability.kind {
318 CapabilityKind::Transform => {
319 registrar.transform(&capability.name, Arc::new(adapter));
320 }
321 CapabilityKind::Highlighter => {
322 registrar.highlighter(Box::new(move || Box::new(adapter.clone())));
323 }
324 CapabilityKind::FenceMarkup | CapabilityKind::FenceAnsi => {
325 registrar.fence_renderer(&capability.name, Arc::new(adapter));
326 }
327 other => {
328 return Err(PluginError::Other(format!(
329 "unsupported capability kind {other}"
330 )));
331 }
332 }
333 }
334 Ok(())
335 }
336}
337
338#[derive(Clone)]
340struct Capability {
341 plugin: Arc<RuntimePlugin>,
342 index: usize,
343}
344
345impl Capability {
346 fn kind(&self) -> CapabilityKind {
347 self.plugin.abi.capabilities[self.index].kind
348 }
349
350 fn call(&self, input: &str, width: u32) -> Result<String, String> {
351 self.plugin.call(self.index, input, width)
352 }
353
354 fn text(&self, output: &str) -> Option<Text> {
357 let mut text = if self.kind().produces_ansi() {
358 Text::from_ansi(&sanitize_ansi_for_decoder(output), "")
359 } else if self.kind() == CapabilityKind::FenceMarkup {
360 Text::from_markup(&sanitize_terminal_controls(output)).ok()?
361 } else {
362 Text::new(sanitize_terminal_controls(output))
363 };
364 for span in text.spans_mut() {
367 without_links(&mut span.style);
368 }
369 Some(text)
370 }
371}
372
373fn without_links(style: &mut StyleType) {
376 let resolved = match style {
377 StyleType::Style(style) => Some(style.clear_meta_and_links()),
378 StyleType::Name(name) => Style::parse(name).ok().map(|s| s.clear_meta_and_links()),
379 };
380 if let Some(resolved) = resolved {
381 *style = StyleType::Style(resolved);
382 }
383}
384
385impl TextTransform for Capability {
386 fn transform(&self, text: Text) -> Result<Text, PluginError> {
387 let output = self.call(text.plain(), 0).map_err(|message| {
388 PluginError::Other(format!("plugin {:?}: {message}", self.plugin.abi.name))
389 })?;
390 Ok(Text::new(sanitize_terminal_controls(&output)))
391 }
392}
393
394impl Highlighter for Capability {
395 fn highlight(&self, text: &mut Text) {
396 let Ok(output) = self.call(text.plain(), 0) else {
398 return;
399 };
400 let plain = text.plain().to_string();
401 for span in parse_spans(&output, &plain).take(MAX_SPANS) {
402 text.stylize(span.2, span.0, span.1);
403 }
404 }
405}
406
407fn parse_spans<'a>(
410 output: &'a str,
411 plain: &'a str,
412) -> impl Iterator<Item = (usize, usize, Style)> + 'a {
413 output.lines().filter_map(move |line| {
414 let mut parts = line.trim().splitn(3, ' ');
415 let start: usize = parts.next()?.parse().ok()?;
416 let end: usize = parts.next()?.parse().ok()?;
417 let style = Style::parse(parts.next()?.trim())
420 .ok()?
421 .clear_meta_and_links();
422 (start < end
423 && end <= plain.len()
424 && plain.is_char_boundary(start)
425 && plain.is_char_boundary(end))
426 .then_some((start, end, style))
427 })
428}
429
430impl FenceRenderer for Capability {
431 fn render_fence(
432 &self,
433 _language: &str,
434 code: &str,
435 console: &Console,
436 options: &ConsoleOptions,
437 ) -> Option<Vec<Segment>> {
438 let width = u32::try_from(options.max_width).unwrap_or(u32::MAX);
440 let output = self.call(code, width).ok()?;
441 let text = self.text(&output)?;
442 Some(text.rich_render(console, options))
443 }
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449 use crate::ExtensionRegistry;
450 use rich_plugin_api::abi::AbiCapability;
451
452 struct Fake;
455
456 impl AbiBackend for Fake {
457 fn call(&self, capability: usize, input: &str, _width: u32) -> Result<String, String> {
458 match capability {
459 0 => Ok(format!("{}\u{1b}]0;pwned\u{7}", input.to_uppercase())),
460 1 => Ok(input
461 .char_indices()
462 .filter(|(_, c)| c.is_ascii_digit())
463 .map(|(i, _)| format!("{i} {} bold\n", i + 1))
464 .chain(["0 999 bold\n".into(), "x y z\n".into()])
465 .collect()),
466 2 => Ok(format!("[bold]{input}[/]\u{1b}[2J")),
467 3 => Ok(format!("\u{1b}[31m{input}\u{1b}[0m\u{1b}]0;t\u{7}\u{9b}2J")),
468 _ => Err("no such capability".into()),
469 }
470 }
471 }
472
473 fn plugin() -> RuntimePlugin {
474 let mut abi = PluginAbi::new("fake", "1.0.0");
475 for (kind, name) in [
476 (CapabilityKind::Transform, "upper"),
477 (CapabilityKind::Highlighter, "digits"),
478 (CapabilityKind::FenceMarkup, "box"),
479 (CapabilityKind::FenceAnsi, "paint"),
480 ] {
481 abi.capabilities.push(AbiCapability {
482 kind,
483 name: name.into(),
484 });
485 }
486 RuntimePlugin::new(abi, RuntimeKind::Wasm, "fake.wasm", Arc::new(Fake)).unwrap()
487 }
488
489 #[test]
490 fn capabilities_reach_the_registry_and_output_is_sanitized() {
491 let mut registry = ExtensionRegistry::new();
492 registry.add_plugin(&plugin()).unwrap();
493 let caps = ®istry.plugins()[0].capabilities;
494 assert_eq!(caps.len(), 4);
495
496 let upper = registry.transform("upper").unwrap();
498 let out = upper.transform(Text::new("a1")).unwrap();
499 assert_eq!(out.plain(), "A1␛]0;pwned␇");
500
501 let mut console = Console::builder()
503 .width(20)
504 .color_system(Some(rich::ColorSystem::Standard))
505 .force_terminal(true)
506 .build();
507 registry.install(&mut console);
508 let mut text = Text::new("a1b22");
509 Capability {
510 plugin: Arc::new(plugin()),
511 index: 1,
512 }
513 .highlight(&mut text);
514 assert_eq!(text.spans().len(), 3);
515
516 let fences = registry.fences().unwrap();
518 let options = console.options();
519 let render = |language: &str| {
520 let segments = fences
521 .render_fence(language, "hi", &console, &options)
522 .unwrap();
523 segments
524 .iter()
525 .map(|s| s.text.to_string())
526 .collect::<String>()
527 };
528 let boxed = render("box");
529 assert!(boxed.starts_with("hi␛[2J"), "{boxed:?}");
530 let painted = render("paint");
531 assert!(painted.starts_with("hi\\u{009B}2J"), "{painted:?}");
532 let segments = fences
533 .render_fence("paint", "hi", &console, &options)
534 .unwrap();
535 assert!(segments.iter().any(|s| s.text == "hi" && s.style.is_some()));
536 }
537
538 #[test]
539 fn a_failing_fence_falls_back_and_output_is_capped() {
540 struct Big;
541 impl AbiBackend for Big {
542 fn call(&self, _: usize, _: &str, _: u32) -> Result<String, String> {
543 Ok("x".repeat(MAX_OUTPUT_BYTES + 1))
544 }
545 }
546 let mut abi = PluginAbi::new("big", "1");
547 abi.capabilities.push(AbiCapability {
548 kind: CapabilityKind::FenceMarkup,
549 name: "big".into(),
550 });
551 let plugin = RuntimePlugin::new(abi, RuntimeKind::Native, "big.so", Arc::new(Big)).unwrap();
552 assert!(plugin.call(0, "", 0).unwrap_err().contains("at most"));
553 let console = Console::builder().width(20).build();
554 let fence = plugin.capability(0);
555 assert!(
556 fence
557 .render_fence("big", "x", &console, &console.options())
558 .is_none()
559 );
560 }
561
562 #[test]
563 fn a_backend_cannot_bypass_the_abi_checks() {
564 let abi = PluginAbi::new("Bad Name", "1");
565 let error =
566 RuntimePlugin::new(abi, RuntimeKind::Wasm, "x.wasm", Arc::new(Fake)).unwrap_err();
567 assert!(error.to_string().contains("x.wasm"));
568 let mut abi = PluginAbi::new("ok", "1");
569 abi.abi_major += 1;
570 assert!(matches!(
571 RuntimePlugin::new(abi, RuntimeKind::Wasm, "x.wasm", Arc::new(Fake)),
572 Err(LoadError::Abi {
573 error: AbiError::Incompatible { .. },
574 ..
575 })
576 ));
577 }
578
579 #[test]
580 fn unknown_and_disabled_kinds_are_refused() {
581 let options = LoadOptions::default();
582 assert!(matches!(
583 load(Path::new("plugin.txt"), &options),
584 Err(LoadError::UnknownKind { .. })
585 ));
586 if !cfg!(feature = "wasm-plugins") {
587 let error = load(Path::new("p.wasm"), &options).unwrap_err();
588 assert!(error.to_string().contains("wasm-plugins"), "{error}");
589 }
590 if !cfg!(feature = "dylib-plugins") {
591 let error = load(Path::new("p.so"), &options).unwrap_err();
592 assert!(error.to_string().contains("dylib-plugins"), "{error}");
593 }
594 }
595}