use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use rich::console::ConsoleOptions;
use rich::segment::Segment;
use rich::{Console, FenceRenderer, Highlighter, Renderable, Style, StyleType, Text};
use rich_plugin_api::abi::{AbiError, CapabilityKind, PluginAbi};
use rich_plugin_api::{Plugin, PluginError, PluginMetadata, PluginRegistrar, TextTransform};
use crate::sanitize::{sanitize_ansi_for_decoder, sanitize_terminal_controls};
#[cfg(feature = "dylib-plugins")]
mod native;
#[cfg(feature = "wasm-plugins")]
mod wasm;
#[cfg(feature = "dylib-plugins")]
pub use native::load_native;
#[cfg(feature = "wasm-plugins")]
pub use wasm::load_wasm;
pub const MAX_OUTPUT_BYTES: usize = 4 * 1024 * 1024;
pub const MAX_SPANS: usize = 10_000;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum RuntimeKind {
Native,
Wasm,
}
impl RuntimeKind {
pub fn as_str(self) -> &'static str {
match self {
RuntimeKind::Native => "native",
RuntimeKind::Wasm => "wasm",
}
}
pub fn for_path(path: &Path) -> Option<RuntimeKind> {
match path.extension()?.to_str()?.to_ascii_lowercase().as_str() {
"wasm" => Some(RuntimeKind::Wasm),
"so" | "dylib" | "dll" => Some(RuntimeKind::Native),
_ => None,
}
}
pub fn feature(self) -> &'static str {
match self {
RuntimeKind::Native => "dylib-plugins",
RuntimeKind::Wasm => "wasm-plugins",
}
}
pub fn supported(self) -> bool {
match self {
RuntimeKind::Native => cfg!(feature = "dylib-plugins"),
RuntimeKind::Wasm => cfg!(feature = "wasm-plugins"),
}
}
}
impl fmt::Display for RuntimeKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct WasmLimits {
pub fuel: u64,
pub memory_bytes: usize,
pub table_elements: usize,
pub module_bytes: u64,
}
impl Default for WasmLimits {
fn default() -> Self {
WasmLimits {
fuel: 50_000_000,
memory_bytes: 64 * 1024 * 1024,
table_elements: 10_000,
module_bytes: 16 * 1024 * 1024,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum LoadError {
Io { path: PathBuf, message: String },
UnknownKind { path: PathBuf },
Unsupported {
path: PathBuf,
feature: &'static str,
},
NotAPlugin { path: PathBuf, message: String },
Abi { path: PathBuf, error: AbiError },
Limit { path: PathBuf, message: String },
}
impl LoadError {
pub fn path(&self) -> &Path {
match self {
LoadError::Io { path, .. }
| LoadError::UnknownKind { path }
| LoadError::Unsupported { path, .. }
| LoadError::NotAPlugin { path, .. }
| LoadError::Abi { path, .. }
| LoadError::Limit { path, .. } => path,
}
}
}
impl fmt::Display for LoadError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let path = self.path().display();
match self {
LoadError::Io { message, .. } => write!(f, "plugin {path}: {message}"),
LoadError::UnknownKind { .. } => write!(
f,
"plugin {path}: not a plugin file; expected .wasm, or a native library \
(.so, .dylib, .dll)"
),
LoadError::Unsupported { feature, .. } => write!(
f,
"plugin {path}: this build cannot load it; it needs the {feature} feature"
),
LoadError::NotAPlugin { message, .. } => {
write!(f, "plugin {path}: not a rich plugin: {message}")
}
LoadError::Abi { error, .. } => write!(f, "plugin {path}: {error}"),
LoadError::Limit { message, .. } => write!(f, "plugin {path}: {message}"),
}
}
}
impl std::error::Error for LoadError {}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct LoadOptions {
pub wasm: WasmLimits,
}
pub fn load(path: &Path, options: &LoadOptions) -> Result<RuntimePlugin, LoadError> {
let kind = RuntimeKind::for_path(path).ok_or_else(|| LoadError::UnknownKind {
path: path.to_path_buf(),
})?;
if !kind.supported() {
return Err(LoadError::Unsupported {
path: path.to_path_buf(),
feature: kind.feature(),
});
}
let _ = options;
match kind {
#[cfg(feature = "dylib-plugins")]
RuntimeKind::Native => load_native(path),
#[cfg(feature = "wasm-plugins")]
RuntimeKind::Wasm => load_wasm(path, &options.wasm),
#[allow(unreachable_patterns)]
_ => unreachable!("checked by RuntimeKind::supported"),
}
}
pub trait AbiBackend: Send + Sync {
fn call(&self, capability: usize, input: &str, width: u32) -> Result<String, String>;
}
#[derive(Clone)]
pub struct RuntimePlugin {
abi: PluginAbi,
kind: RuntimeKind,
path: PathBuf,
backend: Arc<dyn AbiBackend>,
}
impl fmt::Debug for RuntimePlugin {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RuntimePlugin")
.field("abi", &self.abi)
.field("kind", &self.kind)
.field("path", &self.path)
.finish_non_exhaustive()
}
}
impl RuntimePlugin {
pub fn new(
abi: PluginAbi,
kind: RuntimeKind,
path: impl Into<PathBuf>,
backend: Arc<dyn AbiBackend>,
) -> Result<Self, LoadError> {
let path = path.into();
abi.validate().map_err(|error| LoadError::Abi {
path: path.clone(),
error,
})?;
Ok(RuntimePlugin {
abi,
kind,
path,
backend,
})
}
pub fn abi(&self) -> &PluginAbi {
&self.abi
}
pub fn kind(&self) -> RuntimeKind {
self.kind
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn call(&self, capability: usize, input: &str, width: u32) -> Result<String, String> {
let output = self.backend.call(capability, input, width)?;
if output.len() > MAX_OUTPUT_BYTES {
return Err(format!(
"the plugin returned {} bytes; at most {MAX_OUTPUT_BYTES} are accepted",
output.len()
));
}
Ok(output)
}
fn capability(&self, index: usize) -> Capability {
Capability {
plugin: Arc::new(self.clone()),
index,
}
}
}
impl Plugin for RuntimePlugin {
fn metadata(&self) -> PluginMetadata {
let metadata = PluginMetadata::new(&*self.abi.name, &*self.abi.name, &*self.abi.version);
if self.abi.description.is_empty() {
metadata
} else {
metadata.description(&*self.abi.description)
}
}
fn register(&self, registrar: &mut dyn PluginRegistrar) -> Result<(), PluginError> {
for (index, capability) in self.abi.capabilities.iter().enumerate() {
let adapter = self.capability(index);
match capability.kind {
CapabilityKind::Transform => {
registrar.transform(&capability.name, Arc::new(adapter));
}
CapabilityKind::Highlighter => {
registrar.highlighter(Box::new(move || Box::new(adapter.clone())));
}
CapabilityKind::FenceMarkup | CapabilityKind::FenceAnsi => {
registrar.fence_renderer(&capability.name, Arc::new(adapter));
}
other => {
return Err(PluginError::Other(format!(
"unsupported capability kind {other}"
)))
}
}
}
Ok(())
}
}
#[derive(Clone)]
struct Capability {
plugin: Arc<RuntimePlugin>,
index: usize,
}
impl Capability {
fn kind(&self) -> CapabilityKind {
self.plugin.abi.capabilities[self.index].kind
}
fn call(&self, input: &str, width: u32) -> Result<String, String> {
self.plugin.call(self.index, input, width)
}
fn text(&self, output: &str) -> Option<Text> {
let mut text = if self.kind().produces_ansi() {
Text::from_ansi(&sanitize_ansi_for_decoder(output), "")
} else if self.kind() == CapabilityKind::FenceMarkup {
Text::from_markup(&sanitize_terminal_controls(output)).ok()?
} else {
Text::new(sanitize_terminal_controls(output))
};
for span in text.spans_mut() {
without_links(&mut span.style);
}
Some(text)
}
}
fn without_links(style: &mut StyleType) {
let resolved = match style {
StyleType::Style(style) => Some(style.clear_meta_and_links()),
StyleType::Name(name) => Style::parse(name).ok().map(|s| s.clear_meta_and_links()),
};
if let Some(resolved) = resolved {
*style = StyleType::Style(resolved);
}
}
impl TextTransform for Capability {
fn transform(&self, text: Text) -> Result<Text, PluginError> {
let output = self.call(text.plain(), 0).map_err(|message| {
PluginError::Other(format!("plugin {:?}: {message}", self.plugin.abi.name))
})?;
Ok(Text::new(sanitize_terminal_controls(&output)))
}
}
impl Highlighter for Capability {
fn highlight(&self, text: &mut Text) {
let Ok(output) = self.call(text.plain(), 0) else {
return;
};
let plain = text.plain().to_string();
for span in parse_spans(&output, &plain).take(MAX_SPANS) {
text.stylize(span.2, span.0, span.1);
}
}
}
fn parse_spans<'a>(
output: &'a str,
plain: &'a str,
) -> impl Iterator<Item = (usize, usize, Style)> + 'a {
output.lines().filter_map(move |line| {
let mut parts = line.trim().splitn(3, ' ');
let start: usize = parts.next()?.parse().ok()?;
let end: usize = parts.next()?.parse().ok()?;
let style = Style::parse(parts.next()?.trim())
.ok()?
.clear_meta_and_links();
(start < end
&& end <= plain.len()
&& plain.is_char_boundary(start)
&& plain.is_char_boundary(end))
.then_some((start, end, style))
})
}
impl FenceRenderer for Capability {
fn render_fence(
&self,
_language: &str,
code: &str,
console: &Console,
options: &ConsoleOptions,
) -> Option<Vec<Segment>> {
let width = u32::try_from(options.max_width).unwrap_or(u32::MAX);
let output = self.call(code, width).ok()?;
let text = self.text(&output)?;
Some(text.rich_render(console, options))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ExtensionRegistry;
use rich_plugin_api::abi::AbiCapability;
struct Fake;
impl AbiBackend for Fake {
fn call(&self, capability: usize, input: &str, _width: u32) -> Result<String, String> {
match capability {
0 => Ok(format!("{}\u{1b}]0;pwned\u{7}", input.to_uppercase())),
1 => Ok(input
.char_indices()
.filter(|(_, c)| c.is_ascii_digit())
.map(|(i, _)| format!("{i} {} bold\n", i + 1))
.chain(["0 999 bold\n".into(), "x y z\n".into()])
.collect()),
2 => Ok(format!("[bold]{input}[/]\u{1b}[2J")),
3 => Ok(format!("\u{1b}[31m{input}\u{1b}[0m\u{1b}]0;t\u{7}\u{9b}2J")),
_ => Err("no such capability".into()),
}
}
}
fn plugin() -> RuntimePlugin {
let mut abi = PluginAbi::new("fake", "1.0.0");
for (kind, name) in [
(CapabilityKind::Transform, "upper"),
(CapabilityKind::Highlighter, "digits"),
(CapabilityKind::FenceMarkup, "box"),
(CapabilityKind::FenceAnsi, "paint"),
] {
abi.capabilities.push(AbiCapability {
kind,
name: name.into(),
});
}
RuntimePlugin::new(abi, RuntimeKind::Wasm, "fake.wasm", Arc::new(Fake)).unwrap()
}
#[test]
fn capabilities_reach_the_registry_and_output_is_sanitized() {
let mut registry = ExtensionRegistry::new();
registry.add_plugin(&plugin()).unwrap();
let caps = ®istry.plugins()[0].capabilities;
assert_eq!(caps.len(), 4);
let upper = registry.transform("upper").unwrap();
let out = upper.transform(Text::new("a1")).unwrap();
assert_eq!(out.plain(), "A1␛]0;pwned␇");
let mut console = Console::builder()
.width(20)
.color_system(Some(rich::ColorSystem::Standard))
.force_terminal(true)
.build();
registry.install(&mut console);
let mut text = Text::new("a1b22");
Capability {
plugin: Arc::new(plugin()),
index: 1,
}
.highlight(&mut text);
assert_eq!(text.spans().len(), 3);
let fences = registry.fences().unwrap();
let options = console.options();
let render = |language: &str| {
let segments = fences
.render_fence(language, "hi", &console, &options)
.unwrap();
segments
.iter()
.map(|s| s.text.to_string())
.collect::<String>()
};
let boxed = render("box");
assert!(boxed.starts_with("hi␛[2J"), "{boxed:?}");
let painted = render("paint");
assert!(painted.starts_with("hi\\u{009B}2J"), "{painted:?}");
let segments = fences
.render_fence("paint", "hi", &console, &options)
.unwrap();
assert!(segments.iter().any(|s| s.text == "hi" && s.style.is_some()));
}
#[test]
fn a_failing_fence_falls_back_and_output_is_capped() {
struct Big;
impl AbiBackend for Big {
fn call(&self, _: usize, _: &str, _: u32) -> Result<String, String> {
Ok("x".repeat(MAX_OUTPUT_BYTES + 1))
}
}
let mut abi = PluginAbi::new("big", "1");
abi.capabilities.push(AbiCapability {
kind: CapabilityKind::FenceMarkup,
name: "big".into(),
});
let plugin = RuntimePlugin::new(abi, RuntimeKind::Native, "big.so", Arc::new(Big)).unwrap();
assert!(plugin.call(0, "", 0).unwrap_err().contains("at most"));
let console = Console::builder().width(20).build();
let fence = plugin.capability(0);
assert!(fence
.render_fence("big", "x", &console, &console.options())
.is_none());
}
#[test]
fn a_backend_cannot_bypass_the_abi_checks() {
let abi = PluginAbi::new("Bad Name", "1");
let error =
RuntimePlugin::new(abi, RuntimeKind::Wasm, "x.wasm", Arc::new(Fake)).unwrap_err();
assert!(error.to_string().contains("x.wasm"));
let mut abi = PluginAbi::new("ok", "1");
abi.abi_major += 1;
assert!(matches!(
RuntimePlugin::new(abi, RuntimeKind::Wasm, "x.wasm", Arc::new(Fake)),
Err(LoadError::Abi {
error: AbiError::Incompatible { .. },
..
})
));
}
#[test]
fn unknown_and_disabled_kinds_are_refused() {
let options = LoadOptions::default();
assert!(matches!(
load(Path::new("plugin.txt"), &options),
Err(LoadError::UnknownKind { .. })
));
if !cfg!(feature = "wasm-plugins") {
let error = load(Path::new("p.wasm"), &options).unwrap_err();
assert!(error.to_string().contains("wasm-plugins"), "{error}");
}
if !cfg!(feature = "dylib-plugins") {
let error = load(Path::new("p.so"), &options).unwrap_err();
assert!(error.to_string().contains("dylib-plugins"), "{error}");
}
}
}