Skip to main content

lumen_plugin/
wire.rs

1//! What the two sides say to each other, as serde shapes.
2//!
3//! Each of these is encoded once per crossing through [`crate::codec`]: the
4//! host sends an [`InitCx`] and takes back a [`Manifest`], sends a [`Call`]
5//! and takes back a [`CallOut`], and receives a [`PluginEvent`] whenever the
6//! plugin pushes one. The types a plugin describes its functions with are
7//! `lumen_script`'s own, so a manifest says exactly what the engine's script
8//! registry stores.
9
10use std::path::PathBuf;
11
12use lumen_script::{ScriptCommand, ScriptNs, ScriptPrelude, ScriptSig, ScriptValue};
13use serde::de::DeserializeOwned;
14use serde::{Deserialize, Serialize};
15
16use crate::Error;
17
18/// What a plugin knows about the app it was loaded into.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct InitCx {
21    /// The app directory, canonicalized. A plugin resolves its own files
22    /// against this rather than against the process's working directory.
23    pub app_dir: PathBuf,
24    /// The app's id from `lumen.toml`, or its directory name when it
25    /// declares none.
26    pub app_id: String,
27    /// True when the app runs without a window.
28    pub headless: bool,
29    /// True when the app was started with hot reload on, so a plugin that
30    /// caches derived state knows the sources under it can change.
31    pub hot_reload: bool,
32    /// The engine's version, as a semver string.
33    pub lumen_version: String,
34    /// This module's own `config` table, re-serialized. Read it through
35    /// [`InitCx::config`].
36    config_toml: String,
37}
38
39impl InitCx {
40    /// Build a context. Host-side; a plugin only ever reads one.
41    pub fn new(
42        app_dir: PathBuf,
43        app_id: String,
44        headless: bool,
45        hot_reload: bool,
46        lumen_version: String,
47        config_toml: String,
48    ) -> Self {
49        InitCx {
50            app_dir,
51            app_id,
52            headless,
53            hot_reload,
54            lumen_version,
55            config_toml,
56        }
57    }
58
59    /// Deserialize the module's `config` table into any serde type. An app
60    /// that declares no table yields the type's view of an empty table.
61    pub fn config<T: DeserializeOwned>(&self) -> Result<T, Error> {
62        toml::from_str(&self.config_toml).map_err(Error::from)
63    }
64}
65
66/// What a plugin registered, answered from its init.
67#[derive(Debug, Clone, Default, Serialize, Deserialize)]
68pub struct Manifest {
69    /// The functions the plugin offers, in registration order. A
70    /// [`Call`] names one by its index here, so the order is the calling
71    /// convention rather than a presentation detail.
72    pub fns: Vec<FnDecl>,
73    /// Language sources the matching host compiles ahead of the app's own
74    /// program.
75    pub preludes: Vec<ScriptPrelude>,
76    /// Reserved for the capability grants a sandboxed plugin will ask for.
77    /// Must be empty; a host refuses a manifest that declares any.
78    pub capabilities: Vec<String>,
79}
80
81/// One function a plugin offers, in the terms every script host understands.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct FnDecl {
84    /// The name the script calls it by.
85    pub name: String,
86    /// The namespace it lives in. [`ScriptNs::Builtin`] is the runtime's own
87    /// surface and a plugin may not claim it.
88    pub ns: ScriptNs,
89    /// Its declared signature.
90    pub sig: ScriptSig,
91}
92
93/// One call into a plugin.
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct Call {
96    /// Index into [`Manifest::fns`].
97    pub index: u32,
98    /// The arguments the script passed.
99    pub args: Vec<ScriptValue>,
100}
101
102/// What one call produced.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct CallOut {
105    /// The value the script receives, or the message it raises. A function
106    /// that fails is a completed call, so this travels under a success
107    /// status; the error statuses are for the boundary itself.
108    pub ret: Result<ScriptValue, String>,
109    /// What the call emitted before returning. Applied even when `ret` is an
110    /// error, matching what an in-process script function does.
111    pub commands: Vec<ScriptCommand>,
112}
113
114// The event a plugin pushes outside a call is a script-surface shape, so it
115// lives with the other script wire types and is re-exported here: the engine
116// side decodes it in `lumen-script`'s per-tick drain, which cannot depend on
117// this crate.
118pub use lumen_script::PluginEvent;
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::codec;
124
125    fn cx(config_toml: &str) -> InitCx {
126        InitCx::new(
127            PathBuf::from("/app"),
128            "demo".to_string(),
129            true,
130            false,
131            "0.1.0".to_string(),
132            config_toml.to_string(),
133        )
134    }
135
136    #[test]
137    fn init_cx_config_deserializes_the_table_and_reports_type_errors() {
138        let cx = cx("poll_ms = 250");
139        let table: toml::Table = cx.config().unwrap();
140        assert_eq!(table.get("poll_ms").and_then(|v| v.as_integer()), Some(250));
141
142        #[derive(Deserialize, Debug)]
143        struct Wrong {
144            #[allow(dead_code)]
145            poll_ms: String,
146        }
147        let err = cx.config::<Wrong>().unwrap_err();
148        assert!(err.message.contains("invalid type"), "{}", err.message);
149    }
150
151    #[test]
152    fn an_absent_config_table_is_the_types_empty_view() {
153        #[derive(Deserialize, Debug, Default)]
154        #[serde(default)]
155        struct Cfg {
156            poll_ms: u64,
157        }
158        assert_eq!(cx("").config::<Cfg>().unwrap().poll_ms, 0);
159    }
160
161    #[test]
162    fn the_context_survives_the_boundary() {
163        let bytes = codec::encode(&cx("poll_ms = 250")).unwrap();
164        let back: InitCx = codec::decode(&bytes).unwrap();
165        assert_eq!(back.app_id, "demo");
166        assert!(back.headless);
167        assert_eq!(
168            back.config::<toml::Table>()
169                .unwrap()
170                .get("poll_ms")
171                .and_then(|v| v.as_integer()),
172            Some(250)
173        );
174    }
175
176    #[test]
177    fn a_call_and_its_outcome_survive_the_boundary() {
178        let call = Call {
179            index: 3,
180            args: vec![ScriptValue::Str("x".into()), ScriptValue::I64(7)],
181        };
182        let back: Call = codec::decode(&codec::encode(&call).unwrap()).unwrap();
183        assert_eq!(back.index, 3);
184        assert_eq!(back.args[1], ScriptValue::I64(7));
185
186        let out = CallOut {
187            ret: Err("no such device".to_string()),
188            commands: vec![ScriptCommand::Print("tried".into())],
189        };
190        let back: CallOut = codec::decode(&codec::encode(&out).unwrap()).unwrap();
191        assert_eq!(back.ret, Err("no such device".to_string()));
192        assert_eq!(back.commands.len(), 1);
193    }
194
195    #[test]
196    fn an_event_survives_the_boundary() {
197        let event = PluginEvent::Call {
198            event: "on_file_changed".to_string(),
199            key: "watcher".to_string(),
200            fallback: String::new(),
201            args: vec![ScriptValue::Bool(true)],
202        };
203        let back: PluginEvent = codec::decode(&codec::encode(&event).unwrap()).unwrap();
204        assert!(matches!(back, PluginEvent::Call { event, .. } if event == "on_file_changed"));
205    }
206}