Skip to main content

razeri_plugin_api/
process.rs

1// Copyright (C) 2026 Gabriel Shea
2// Licensed under either of the Apache License, Version 2.0 or the MIT
3// license, at your option.
4
5//! One dialect registry per process.
6//!
7//! Razeri resolves platforms by name in hundreds of places — every renderer,
8//! every command, every binding. A registry built afresh at each of those calls
9//! would hold only the dialects linked into the binary, and a dialect loaded
10//! from a shared library at run time would land in whichever builder loaded it
11//! and be seen by nothing that resolves by name.
12//!
13//! This module holds the one registry those calls read. It is built once,
14//! from the linked dialects plus whatever a plugin directory contributes, and
15//! it never changes afterwards. Two consequences follow, and both are the
16//! point:
17//!
18//! - **A plugin loaded here is visible everywhere.** The command line's
19//!   `--target`, the materialization core three calls deeper, and a Python
20//!   caller in another process entirely all resolve through the same registry.
21//! - **The directory is chosen before the first resolution, or not at all.**
22//!   A registry that could change under a running process would let two
23//!   halves of one render disagree about what a name means. So the directory
24//!   is installed first — explicitly by a host that has one, or from
25//!   `RAZERI_PLUGIN_DIR` when the first resolution happens — and a later
26//!   attempt to install a different one is refused rather than honoured.
27//!
28//! Only dialects live here. A plugin may also register storage providers,
29//! language renderers and model adapters, and those still reach only the
30//! builder that loaded them; the process-wide registry grows to hold them when
31//! something resolves them by name the way dialects are resolved.
32
33use crate::builder::PluginRegistryBuilder;
34use crate::dialect::PlatformRegistry;
35use std::path::{Path, PathBuf};
36use std::sync::OnceLock;
37
38/// The environment variable that names a directory of plugin shared libraries.
39///
40/// Read when the process-wide registry is first built and no host has
41/// installed a directory explicitly. Unset means no directory; a value that
42/// names nothing readable contributes nothing and is noted in the log.
43pub const PLUGIN_DIR_ENV: &str = "RAZERI_PLUGIN_DIR";
44
45/// What loading a plugin directory produced, kept for the life of the process
46/// so a face can report it — which library loaded, which was refused, and why.
47#[derive(Debug)]
48pub struct PluginDirReport {
49    /// The directory that was scanned.
50    pub dir: PathBuf,
51    /// One entry per shared library found in it, in directory order.
52    pub loaded: Vec<crate::dynamic::PluginLoadResult>,
53}
54
55/// Why an explicit installation was refused.
56#[derive(Debug, thiserror::Error)]
57pub enum InstallError {
58    /// The registry was already built — from a different directory, or from
59    /// none — before this directory was offered. The order is the rule: a
60    /// host installs its plugin directory before anything resolves a name.
61    #[error(
62        "the plugin directory {dir} was offered after this process had already built its \
63         dialect registry{earlier}; a plugin directory is installed before the first \
64         platform name is resolved, never after"
65    )]
66    AlreadyBuilt {
67        /// The directory that was refused.
68        dir: PathBuf,
69        /// `" from <dir>"` when a directory had been installed, else empty.
70        earlier: String,
71    },
72    /// The directory cannot be read at all — it is missing, or not a
73    /// directory, or not readable by this process.
74    #[error("the plugin directory {dir} cannot be read: {source}")]
75    Unreadable {
76        /// The directory that was offered.
77        dir: PathBuf,
78        /// What the filesystem said.
79        #[source]
80        source: std::io::Error,
81    },
82}
83
84struct Installed {
85    dialects: PlatformRegistry,
86    report: Option<PluginDirReport>,
87}
88
89static INSTALLED: OnceLock<Installed> = OnceLock::new();
90
91/// The registry every by-name resolution in this process reads.
92///
93/// Built on first use from the dialects linked into the binary, plus the
94/// shared libraries in the directory named by [`PLUGIN_DIR_ENV`] if that is
95/// set and no host installed a directory first. The result never changes for
96/// the life of the process.
97pub fn process_registry() -> &'static PlatformRegistry {
98    &INSTALLED.get_or_init(|| build(plugin_dir_from_environment())).dialects
99}
100
101/// Install a plugin directory as the one this process loads from, and build
102/// the registry now.
103///
104/// This is the call a host makes when it has a directory from its own
105/// configuration — a command-line flag, say — and wants failures reported
106/// rather than logged. It must come before the first call to
107/// [`process_registry`] or to any by-name resolver, directly or indirectly.
108/// Offering the same directory twice is fine and returns the same report.
109///
110/// # Errors
111///
112/// [`InstallError::Unreadable`] when `dir` cannot be read;
113/// [`InstallError::AlreadyBuilt`] when the registry already exists and was not
114/// built from this same directory. Individual libraries that fail to load are
115/// not errors here — they are entries in the returned report, because a host
116/// decides whether a broken plugin is fatal.
117pub fn install_plugin_dir(dir: &Path) -> Result<&'static PluginDirReport, InstallError> {
118    std::fs::read_dir(dir)
119        .map_err(|source| InstallError::Unreadable { dir: dir.to_path_buf(), source })?;
120
121    let mut built_now = false;
122    let installed = INSTALLED.get_or_init(|| {
123        built_now = true;
124        build(Some(dir.to_path_buf()))
125    });
126
127    match &installed.report {
128        Some(report) if built_now || same_dir(&report.dir, dir) => Ok(report),
129        Some(report) => Err(InstallError::AlreadyBuilt {
130            dir: dir.to_path_buf(),
131            earlier: format!(" from {}", report.dir.display()),
132        }),
133        None => Err(InstallError::AlreadyBuilt { dir: dir.to_path_buf(), earlier: String::new() }),
134    }
135}
136
137/// The report from whichever directory this process loaded, if any.
138///
139/// `None` until the registry is built, and `None` afterwards if no directory
140/// was installed and [`PLUGIN_DIR_ENV`] was unset.
141pub fn plugin_dir_report() -> Option<&'static PluginDirReport> {
142    INSTALLED.get().and_then(|installed| installed.report.as_ref())
143}
144
145/// The directory [`PLUGIN_DIR_ENV`] names, if it is set to anything.
146#[must_use]
147pub fn plugin_dir_from_environment() -> Option<PathBuf> {
148    std::env::var_os(PLUGIN_DIR_ENV).filter(|value| !value.is_empty()).map(PathBuf::from)
149}
150
151fn build(dir: Option<PathBuf>) -> Installed {
152    let mut builder = PluginRegistryBuilder::new().with_auto_discovered();
153    let report = dir.map(|dir| {
154        let loaded = load_into(&dir, &mut builder);
155        PluginDirReport { dir, loaded }
156    });
157    Installed { dialects: builder.build().dialects, report }
158}
159
160#[cfg(all(feature = "dynamic", not(target_arch = "wasm32")))]
161fn load_into(
162    dir: &Path,
163    builder: &mut PluginRegistryBuilder,
164) -> Vec<crate::dynamic::PluginLoadResult> {
165    crate::dynamic::load_plugins_from_dir(dir, builder)
166}
167
168#[cfg(not(all(feature = "dynamic", not(target_arch = "wasm32"))))]
169fn load_into(
170    dir: &Path,
171    _builder: &mut PluginRegistryBuilder,
172) -> Vec<crate::dynamic::PluginLoadResult> {
173    tracing::warn!(
174        "a plugin directory was named ({}) but this build cannot load shared libraries",
175        dir.display()
176    );
177    Vec::new()
178}
179
180fn same_dir(a: &Path, b: &Path) -> bool {
181    match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
182        (Ok(a), Ok(b)) => a == b,
183        _ => a == b,
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    // The registry is process-wide, so these tests share it and are written to
192    // agree with each other rather than to reset it: nothing here depends on
193    // the registry being empty, only on it being the same one every time.
194
195    #[test]
196    fn the_registry_is_built_once_and_handed_back_every_time() {
197        let first: *const PlatformRegistry = process_registry();
198        let second: *const PlatformRegistry = process_registry();
199        assert!(std::ptr::eq(first, second), "two calls returned two registries");
200    }
201
202    #[test]
203    fn a_directory_offered_after_the_build_is_refused_unless_it_is_the_same_one() {
204        let _ = process_registry();
205        let elsewhere = tempfile::tempdir().unwrap();
206        let err = install_plugin_dir(elsewhere.path()).unwrap_err();
207        assert!(matches!(err, InstallError::AlreadyBuilt { .. }), "{err}");
208        assert!(err.to_string().contains("before the first platform name is resolved"), "{err}");
209    }
210
211    #[test]
212    fn an_unreadable_directory_is_named_in_the_error() {
213        let missing = std::env::temp_dir().join("razeri-no-such-plugin-dir-ever");
214        let err = install_plugin_dir(&missing).unwrap_err();
215        assert!(matches!(err, InstallError::Unreadable { .. }), "{err}");
216        assert!(err.to_string().contains("razeri-no-such-plugin-dir-ever"), "{err}");
217    }
218
219    #[test]
220    fn the_environment_variable_is_read_as_a_path_and_empty_means_unset() {
221        // Read-only: the variable's value in this process is whatever the
222        // harness set, and the function must agree with it.
223        let expected =
224            std::env::var_os(PLUGIN_DIR_ENV).filter(|v| !v.is_empty()).map(PathBuf::from);
225        assert_eq!(plugin_dir_from_environment(), expected);
226    }
227}