Skip to main content

lumen_plugin_abi/
config.rs

1//! The `[[plugins]]` schema in `lumen.toml`.
2//!
3//! An array of tables, so declaration order is the run order:
4//!
5//! ```toml
6//! [[plugins]]
7//! name = "markdown"                 # must match what the cdylib reports
8//! version = "1.2"                   # registry source, fetched by lpm
9//! config = { flavor = "gfm" }       # optional, handed to the plugin verbatim
10//!
11//! [[plugins]]
12//! name = "local-dev"
13//! path = "plugins/local-dev"        # local cdylib, relative to the app dir
14//! ```
15//!
16//! Exactly one source per entry: `version` (a package in the registry, which
17//! `lpm` resolves and downloads for `lumenc`) or `path` (a built cdylib for
18//! local development, never locked). Nothing here resolves a version; the
19//! schema says what was declared and the caller is handed the file. `git`,
20//! `rev`, and `registry` are rejected with an error naming the reason, so the
21//! future shape is not precluded and a present-day typo does not read as an
22//! unknown field.
23
24use std::path::{Path, PathBuf};
25
26use serde::Deserialize;
27
28/// Where one plugin's cdylib comes from.
29#[derive(Debug, Clone, PartialEq)]
30pub enum PluginSource {
31    /// A built cdylib on disk, relative to the app directory unless
32    /// absolute. Without an extension, the platform spellings are probed
33    /// (`lib<p>.so`, `lib<p>.dylib`, `<p>.dll`).
34    Path(String),
35    /// A version requirement (cargo semantics: `"1.2"` means `^1.2`) on a
36    /// registry package. `lpm` resolves it, downloads what it resolves to,
37    /// and pins it in `lumen.lock`.
38    Version(String),
39}
40
41/// One `[[plugins]]` entry.
42#[derive(Debug, Clone, PartialEq)]
43pub struct PluginCfg {
44    /// Plugin name; the load handshake requires the cdylib to report the
45    /// same one.
46    pub name: String,
47    /// Where the cdylib comes from.
48    pub source: PluginSource,
49    /// The plugin's own configuration, passed through untouched.
50    pub config: toml::Table,
51}
52
53#[derive(Deserialize)]
54#[serde(deny_unknown_fields)]
55struct RawPluginCfg {
56    name: String,
57    path: Option<String>,
58    version: Option<String>,
59    git: Option<String>,
60    rev: Option<String>,
61    registry: Option<String>,
62    permissions: Option<toml::Value>,
63    #[serde(default)]
64    config: toml::Table,
65}
66
67impl<'de> Deserialize<'de> for PluginCfg {
68    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
69    where
70        D: serde::Deserializer<'de>,
71    {
72        let raw = RawPluginCfg::deserialize(deserializer)?;
73        if raw.name.trim().is_empty() {
74            return Err(serde::de::Error::custom(
75                "plugins: `name` must not be empty",
76            ));
77        }
78        let reserved = [
79            ("git", &raw.git),
80            ("rev", &raw.rev),
81            ("registry", &raw.registry),
82        ];
83        if let Some((key, _)) = reserved.iter().find(|(_, v)| v.is_some()) {
84            return Err(serde::de::Error::custom(format!(
85                "plugin '{}': `{key}` sources are not supported yet; use `version` (a registry package) or `path` (a built cdylib)",
86                raw.name
87            )));
88        }
89        if raw.permissions.is_some() {
90            return Err(serde::de::Error::custom(format!(
91                "plugin '{}': capability declarations are not supported yet; a plugin is native \
92                 code running in the compiler's process, the same trust model as a build script",
93                raw.name
94            )));
95        }
96        let source = match (raw.path, raw.version) {
97            (Some(path), None) => PluginSource::Path(path),
98            (None, Some(version)) => PluginSource::Version(version),
99            (Some(_), Some(_)) => {
100                return Err(serde::de::Error::custom(format!(
101                    "plugin '{}': `path` and `version` are two sources; declare exactly one",
102                    raw.name
103                )));
104            }
105            (None, None) => {
106                return Err(serde::de::Error::custom(format!(
107                    "plugin '{}': a source is required - `version` (a registry package) or `path` (a built cdylib)",
108                    raw.name
109                )));
110            }
111        };
112        Ok(PluginCfg {
113            name: raw.name,
114            source,
115            config: raw.config,
116        })
117    }
118}
119
120impl PluginCfg {
121    /// Read the `[[plugins]]` array out of an already-parsed `lumen.toml`
122    /// document. A missing array is an empty set; a malformed entry is an
123    /// error naming it.
124    pub fn from_document(doc: &toml::Table) -> Result<Vec<PluginCfg>, String> {
125        let Some(value) = doc.get("plugins") else {
126            return Ok(Vec::new());
127        };
128        value
129            .clone()
130            .try_into::<Vec<PluginCfg>>()
131            .map_err(|e| format!("lumen.toml [[plugins]]: {e}"))
132    }
133}
134
135/// The candidate file names of a library called `name`, in probe order: the
136/// running platform's spelling first, the other platforms' after (so one
137/// lumen.toml reads the same everywhere), and for a hyphenated name the
138/// underscored variant of each (cargo writes `libfoo_bar.so` for a package
139/// named `foo-bar`). Every loader that probes for a library by name uses
140/// this list: the `[[plugins]]` path resolution here, the runtime-module
141/// probe in `lumen-modules`, and `lumenc package`, which stages module
142/// libraries into the same names the probes look for.
143pub fn library_spellings(name: &str) -> Vec<String> {
144    let mut spellings = Vec::new();
145    let mut push = |name: &str| {
146        let host = format!(
147            "{}{name}{}",
148            std::env::consts::DLL_PREFIX,
149            std::env::consts::DLL_SUFFIX
150        );
151        for cand in [
152            host,
153            format!("lib{name}.so"),
154            format!("lib{name}.dylib"),
155            format!("{name}.dll"),
156        ] {
157            if !spellings.contains(&cand) {
158                spellings.push(cand);
159            }
160        }
161    };
162    push(name);
163    if name.contains('-') {
164        push(&name.replace('-', "_"));
165    }
166    spellings
167}
168
169/// Resolve a `[[plugins]] path` to the file to load. Relative paths anchor at
170/// the app directory. A path with an extension is used verbatim; without one,
171/// the platform library spellings are probed in order. Returns the first
172/// existing candidate, or `Err` listing every probed path.
173pub fn resolve_plugin_path(app_dir: &Path, path: &str) -> Result<PathBuf, Vec<PathBuf>> {
174    let base = {
175        let p = Path::new(path);
176        if p.is_absolute() {
177            p.to_path_buf()
178        } else {
179            app_dir.join(p)
180        }
181    };
182    if base.extension().is_some() {
183        return if base.is_file() {
184            Ok(base)
185        } else {
186            Err(vec![base])
187        };
188    }
189    let stem = base
190        .file_name()
191        .map(|n| n.to_string_lossy().into_owned())
192        .unwrap_or_default();
193    let dir = base.parent().unwrap_or(Path::new("."));
194    let candidates: Vec<PathBuf> = library_spellings(&stem)
195        .iter()
196        .map(|f| dir.join(f))
197        .collect();
198    for c in &candidates {
199        if c.is_file() {
200            return Ok(c.clone());
201        }
202    }
203    Err(candidates)
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    fn parse(doc: &str) -> Result<Vec<PluginCfg>, String> {
211        let table: toml::Table = toml::from_str(doc).unwrap();
212        PluginCfg::from_document(&table)
213    }
214
215    #[test]
216    fn declaration_order_is_preserved() {
217        let cfgs = parse(
218            "[[plugins]]\nname = \"a\"\npath = \"a\"\n\
219             [[plugins]]\nname = \"b\"\npath = \"b\"\n\
220             [[plugins]]\nname = \"c\"\npath = \"c\"\n",
221        )
222        .unwrap();
223        let names: Vec<&str> = cfgs.iter().map(|c| c.name.as_str()).collect();
224        assert_eq!(names, ["a", "b", "c"]);
225    }
226
227    #[test]
228    fn missing_source_is_an_error() {
229        let err = parse("[[plugins]]\nname = \"x\"\n").unwrap_err();
230        assert!(err.contains("plugin 'x'"), "{err}");
231        assert!(err.contains("a source is required"), "{err}");
232    }
233
234    #[test]
235    fn two_sources_are_an_error() {
236        let err = parse("[[plugins]]\nname = \"x\"\npath = \"x\"\nversion = \"1\"\n").unwrap_err();
237        assert!(err.contains("exactly one"), "{err}");
238    }
239
240    #[test]
241    fn version_source_parses() {
242        let cfgs = parse("[[plugins]]\nname = \"x\"\nversion = \"1.2\"\n").unwrap();
243        assert_eq!(cfgs[0].source, PluginSource::Version("1.2".to_string()));
244    }
245
246    #[test]
247    fn reserved_sources_are_rejected_with_the_reason() {
248        for key in ["git", "rev", "registry"] {
249            let err = parse(&format!("[[plugins]]\nname = \"x\"\n{key} = \"1\"\n")).unwrap_err();
250            assert!(err.contains("not supported yet"), "{key}: {err}");
251        }
252    }
253
254    #[test]
255    fn permissions_are_rejected_with_the_trust_model() {
256        let err =
257            parse("[[plugins]]\nname = \"x\"\npath = \"x\"\npermissions = [\"fs\"]\n").unwrap_err();
258        assert!(err.contains("trust model"), "{err}");
259    }
260
261    #[test]
262    fn config_table_passes_through() {
263        let cfgs =
264            parse("[[plugins]]\nname = \"x\"\npath = \"x\"\nconfig = { flavor = \"gfm\" }\n")
265                .unwrap();
266        assert_eq!(
267            cfgs[0].config.get("flavor").and_then(|v| v.as_str()),
268            Some("gfm")
269        );
270    }
271
272    #[test]
273    fn an_empty_name_is_refused() {
274        let err = parse("[[plugins]]\nname = \"  \"\npath = \"x\"\n").unwrap_err();
275        assert!(err.contains("must not be empty"), "{err}");
276    }
277
278    #[test]
279    fn an_extensioned_path_that_does_not_exist_reports_itself() {
280        let dir = std::env::temp_dir();
281        let err = resolve_plugin_path(&dir, "nope/libmissing.so").unwrap_err();
282        assert_eq!(err.len(), 1);
283        assert!(err[0].ends_with("nope/libmissing.so"), "{err:?}");
284    }
285
286    #[test]
287    fn no_plugins_array_is_empty() {
288        assert!(parse("[app]\nname = \"x\"\n").unwrap().is_empty());
289    }
290
291    #[test]
292    fn extensionless_path_probes_platform_spellings() {
293        let dir = std::env::temp_dir().join("lumenc-plugin-probe-test");
294        let _ = std::fs::remove_dir_all(&dir);
295        std::fs::create_dir_all(&dir).unwrap();
296        let err = resolve_plugin_path(&dir, "plugins/demo").unwrap_err();
297        let names: Vec<String> = err
298            .iter()
299            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
300            .collect();
301        // The host platform's spelling leads; every platform's is probed.
302        assert!(
303            names[0].starts_with(std::env::consts::DLL_PREFIX),
304            "{names:?}"
305        );
306        for want in ["libdemo.so", "libdemo.dylib", "demo.dll"] {
307            assert!(names.iter().any(|n| n == want), "{names:?}");
308        }
309
310        std::fs::create_dir_all(dir.join("plugins")).unwrap();
311        let hit = dir.join("plugins").join("libdemo.so");
312        std::fs::write(&hit, b"").unwrap();
313        assert_eq!(resolve_plugin_path(&dir, "plugins/demo").unwrap(), hit);
314        let _ = std::fs::remove_dir_all(&dir);
315    }
316}
317
318#[cfg(test)]
319mod spelling_tests {
320    use super::*;
321
322    #[test]
323    fn hyphenated_names_probe_the_underscore_variant() {
324        let names = library_spellings("foo-bar");
325        assert!(names.iter().any(|n| n == "libfoo-bar.so"), "{names:?}");
326        assert!(names.iter().any(|n| n == "libfoo_bar.so"), "{names:?}");
327        assert!(names.iter().any(|n| n == "foo_bar.dll"), "{names:?}");
328    }
329}