razeri-plugin-api 0.18.2

Extension traits and registries for Razeri plugins
Documentation
// Copyright (C) 2026 Gabriel Shea
// Licensed under either of the Apache License, Version 2.0 or the MIT
// license, at your option.

//! One dialect registry per process.
//!
//! Razeri resolves platforms by name in hundreds of places — every renderer,
//! every command, every binding. A registry built afresh at each of those calls
//! would hold only the dialects linked into the binary, and a dialect loaded
//! from a shared library at run time would land in whichever builder loaded it
//! and be seen by nothing that resolves by name.
//!
//! This module holds the one registry those calls read. It is built once,
//! from the linked dialects plus whatever a plugin directory contributes, and
//! it never changes afterwards. Two consequences follow, and both are the
//! point:
//!
//! - **A plugin loaded here is visible everywhere.** The command line's
//!   `--target`, the materialization core three calls deeper, and a Python
//!   caller in another process entirely all resolve through the same registry.
//! - **The directory is chosen before the first resolution, or not at all.**
//!   A registry that could change under a running process would let two
//!   halves of one render disagree about what a name means. So the directory
//!   is installed first — explicitly by a host that has one, or from
//!   `RAZERI_PLUGIN_DIR` when the first resolution happens — and a later
//!   attempt to install a different one is refused rather than honoured.
//!
//! Dialects live here, and so do event sinks ([`process_event_sinks`]): a sink
//! a plugin directory contributes reaches a host that emits through this one
//! registry, as a sink linked into the binary does. A plugin may also register
//! storage providers, language renderers and model adapters, and those still
//! reach only the builder that loaded them; the process-wide registry grows to
//! hold them when something resolves them by name the way dialects are
//! resolved.

use crate::builder::PluginRegistryBuilder;
use crate::dialect::PlatformRegistry;
use crate::event::EventRegistry;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;

/// The environment variable that names a directory of plugin shared libraries.
///
/// Read when the process-wide registry is first built and no host has
/// installed a directory explicitly. Unset means no directory; a value that
/// names nothing readable contributes nothing and is noted in the log.
pub const PLUGIN_DIR_ENV: &str = "RAZERI_PLUGIN_DIR";

/// What loading a plugin directory produced, kept for the life of the process
/// so a face can report it — which library loaded, which was refused, and why.
#[derive(Debug)]
pub struct PluginDirReport {
    /// The directory that was scanned.
    pub dir: PathBuf,
    /// One entry per shared library found in it, in directory order.
    pub loaded: Vec<crate::dynamic::PluginLoadResult>,
}

/// Why an explicit installation was refused.
#[derive(Debug, thiserror::Error)]
pub enum InstallError {
    /// The registry was already built — from a different directory, or from
    /// none — before this directory was offered. The order is the rule: a
    /// host installs its plugin directory before anything resolves a name.
    #[error(
        "the plugin directory {dir} was offered after this process had already built its \
         dialect registry{earlier}; a plugin directory is installed before the first \
         platform name is resolved, never after"
    )]
    AlreadyBuilt {
        /// The directory that was refused.
        dir: PathBuf,
        /// `" from <dir>"` when a directory had been installed, else empty.
        earlier: String,
    },
    /// The directory cannot be read at all — it is missing, or not a
    /// directory, or not readable by this process.
    #[error("the plugin directory {dir} cannot be read: {source}")]
    Unreadable {
        /// The directory that was offered.
        dir: PathBuf,
        /// What the filesystem said.
        #[source]
        source: std::io::Error,
    },
}

struct Installed {
    dialects: PlatformRegistry,
    events: EventRegistry,
    report: Option<PluginDirReport>,
}

static INSTALLED: OnceLock<Installed> = OnceLock::new();

/// The registry every by-name resolution in this process reads.
///
/// Built on first use from the dialects linked into the binary, plus the
/// shared libraries in the directory named by [`PLUGIN_DIR_ENV`] if that is
/// set and no host installed a directory first. The result never changes for
/// the life of the process.
pub fn process_registry() -> &'static PlatformRegistry {
    &INSTALLED.get_or_init(|| build(plugin_dir_from_environment())).dialects
}

/// **The event sinks every host in this process emits through**: the ones
/// linked into the binary, and the ones the plugin directory contributed.
///
/// Built with [`process_registry`], from the same directory, once.
pub fn process_event_sinks() -> &'static EventRegistry {
    &INSTALLED.get_or_init(|| build(plugin_dir_from_environment())).events
}

/// Install a plugin directory as the one this process loads from, and build
/// the registry now.
///
/// This is the call a host makes when it has a directory from its own
/// configuration — a command-line flag, say — and wants failures reported
/// rather than logged. It must come before the first call to
/// [`process_registry`] or to any by-name resolver, directly or indirectly.
/// Offering the same directory twice is fine and returns the same report.
///
/// # Errors
///
/// [`InstallError::Unreadable`] when `dir` cannot be read;
/// [`InstallError::AlreadyBuilt`] when the registry already exists and was not
/// built from this same directory. Individual libraries that fail to load are
/// not errors here — they are entries in the returned report, because a host
/// decides whether a broken plugin is fatal.
pub fn install_plugin_dir(dir: &Path) -> Result<&'static PluginDirReport, InstallError> {
    std::fs::read_dir(dir)
        .map_err(|source| InstallError::Unreadable { dir: dir.to_path_buf(), source })?;

    let mut built_now = false;
    let installed = INSTALLED.get_or_init(|| {
        built_now = true;
        build(Some(dir.to_path_buf()))
    });

    match &installed.report {
        Some(report) if built_now || same_dir(&report.dir, dir) => Ok(report),
        Some(report) => Err(InstallError::AlreadyBuilt {
            dir: dir.to_path_buf(),
            earlier: format!(" from {}", report.dir.display()),
        }),
        None => Err(InstallError::AlreadyBuilt { dir: dir.to_path_buf(), earlier: String::new() }),
    }
}

/// The report from whichever directory this process loaded, if any.
///
/// `None` until the registry is built, and `None` afterwards if no directory
/// was installed and [`PLUGIN_DIR_ENV`] was unset.
pub fn plugin_dir_report() -> Option<&'static PluginDirReport> {
    INSTALLED.get().and_then(|installed| installed.report.as_ref())
}

/// The directory [`PLUGIN_DIR_ENV`] names, if it is set to anything.
#[must_use]
pub fn plugin_dir_from_environment() -> Option<PathBuf> {
    std::env::var_os(PLUGIN_DIR_ENV).filter(|value| !value.is_empty()).map(PathBuf::from)
}

fn build(dir: Option<PathBuf>) -> Installed {
    let mut builder = PluginRegistryBuilder::new().with_auto_discovered();
    let report = dir.map(|dir| {
        let loaded = load_into(&dir, &mut builder);
        PluginDirReport { dir, loaded }
    });
    let built = builder.build();
    Installed { dialects: built.dialects, events: built.events, report }
}

#[cfg(all(feature = "dynamic", not(target_arch = "wasm32")))]
fn load_into(
    dir: &Path,
    builder: &mut PluginRegistryBuilder,
) -> Vec<crate::dynamic::PluginLoadResult> {
    crate::dynamic::load_plugins_from_dir(dir, builder)
}

#[cfg(not(all(feature = "dynamic", not(target_arch = "wasm32"))))]
fn load_into(
    dir: &Path,
    _builder: &mut PluginRegistryBuilder,
) -> Vec<crate::dynamic::PluginLoadResult> {
    tracing::warn!(
        "a plugin directory was named ({}) but this build cannot load shared libraries",
        dir.display()
    );
    Vec::new()
}

fn same_dir(a: &Path, b: &Path) -> bool {
    match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
        (Ok(a), Ok(b)) => a == b,
        _ => a == b,
    }
}

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

    // The registry is process-wide, so these tests share it and are written to
    // agree with each other rather than to reset it: nothing here depends on
    // the registry being empty, only on it being the same one every time.

    #[test]
    fn the_registry_is_built_once_and_handed_back_every_time() {
        let first: *const PlatformRegistry = process_registry();
        let second: *const PlatformRegistry = process_registry();
        assert!(std::ptr::eq(first, second), "two calls returned two registries");
    }

    /// **The process's sinks are the ones the builder registered** — those
    /// linked in, and a plugin directory's — not an empty set (`fr-1019-91`).
    #[test]
    fn the_process_keeps_the_event_sinks_it_was_built_with() {
        let linked = PluginRegistryBuilder::new().with_auto_discovered().build().events.len();
        assert_eq!(process_event_sinks().len(), linked);
        let first: *const EventRegistry = process_event_sinks();
        assert!(std::ptr::eq(first, process_event_sinks()), "two calls, two registries");
    }

    #[test]
    fn a_directory_offered_after_the_build_is_refused_unless_it_is_the_same_one() {
        let _ = process_registry();
        let elsewhere = tempfile::tempdir().unwrap();
        let err = install_plugin_dir(elsewhere.path()).unwrap_err();
        assert!(matches!(err, InstallError::AlreadyBuilt { .. }), "{err}");
        assert!(err.to_string().contains("before the first platform name is resolved"), "{err}");
    }

    #[test]
    fn an_unreadable_directory_is_named_in_the_error() {
        let missing = std::env::temp_dir().join("razeri-no-such-plugin-dir-ever");
        let err = install_plugin_dir(&missing).unwrap_err();
        assert!(matches!(err, InstallError::Unreadable { .. }), "{err}");
        assert!(err.to_string().contains("razeri-no-such-plugin-dir-ever"), "{err}");
    }

    #[test]
    fn the_environment_variable_is_read_as_a_path_and_empty_means_unset() {
        // Read-only: the variable's value in this process is whatever the
        // harness set, and the function must agree with it.
        let expected =
            std::env::var_os(PLUGIN_DIR_ENV).filter(|v| !v.is_empty()).map(PathBuf::from);
        assert_eq!(plugin_dir_from_environment(), expected);
    }
}