rscript/lib.rs
1#![warn(missing_docs)]
2
3//! Crate to easily script any rust project
4//! # Rscript
5//! The main idea is:
6//! - Create a new crate (my-project-api for example)
7//! - Add hooks to this api-crate
8//! - This api-crate should be used by the main-crate and by the scripts
9//! - Trigger Hooks in the main crate
10//! - Receive the hooks on the script side, and react to them with any output
11//!
12//!
13//! Goals:
14//! - Be as easy as possible to include on already established projects
15//! - Strive for maximum compile time guarantees
16//!
17//! This crate was extracted from [IRust](https://github.com/sigmaSd/IRust)
18//!
19//! Taking *IRust* as an example:
20//! - It has an API crate where hooks are defined [irust_api](https://github.com/sigmaSd/IRust/blob/master/crates/irust_api/src/lib.rs)
21//! - It trigger hooks on the main crate [irust](https://github.com/sigmaSd/IRust/blob/master/crates/irust/src/irust.rs#L136)
22//! - And script examples:
23//! - OneShot: [irust_prompt](https://github.com/sigmaSd/IRust/tree/master/script_examples/irust_prompt)
24//! - Daemon: [ipython_mode](https://github.com/sigmaSd/IRust/tree/master/script_examples/ipython)
25//! - DynamicLibary: [vim_mode](https://github.com/sigmaSd/IRust/tree/master/script_examples/irust_vim_dylib)
26//!
27//! Check out the [examples](https://github.com/sigmaSd/Rscript/tree/master/examples) for more info.
28
29use scripting::{FFiData, FFiStr};
30use serde::{de::DeserializeOwned, Deserialize, Serialize};
31use std::{
32 env,
33 path::Path,
34 process::{Child, Stdio},
35};
36
37// Rexport Version, VersionReq
38/// *SemVer version* as defined by <https://semver.org.>\
39/// The main crate must specify its version when adding scripts to [ScriptManager]
40pub use semver::Version;
41/// *SemVer version requirement* describing the intersection of some version comparators, such as >=1.2.3, <1.8.\
42/// Each script must specify the required version of the main crate
43pub use semver::VersionReq;
44
45pub mod scripting;
46
47mod error;
48pub use error::Error;
49
50use crate::scripting::DynamicScript;
51
52/// Script metadata that every script should send to the main_crate when starting up
53#[derive(Serialize, Deserialize, Debug)]
54pub struct ScriptInfo {
55 /// Script name
56 pub name: String,
57 /// Script type: Daemon/OneShot
58 pub script_type: ScriptType,
59 /// The hooks that the script wants to listen to
60 pub hooks: Box<[String]>,
61 /// The version requirement of the program that the script will run against
62 pub version_requirement: VersionReq,
63}
64
65impl ScriptInfo {
66 /// Create a new script metadata, the new constructor tries to add more ergonomics
67 pub fn new(
68 name: &'static str,
69 script_type: ScriptType,
70 hooks: &'static [&'static str],
71 version_requirement: VersionReq,
72 ) -> Self {
73 Self {
74 name: name.into(),
75 script_type,
76 hooks: hooks.iter().map(|hook| String::from(*hook)).collect(),
77 version_requirement,
78 }
79 }
80 /// Serialize `ScriptInfo` into `FFiData`
81 /// This is needed for writing [ScriptType::DynamicLib] scripts
82 pub fn into_ffi_data(self) -> FFiData {
83 FFiData::serialize_from(&self).expect("ScriptInfo is always serialize-able")
84 }
85}
86
87/// ScriptType: Daemon/OneShot/DynamicLib
88/// - *OneShot* scripts are expected to be spawned(process::Command::new) by the main crate ach time they are used, this should be preferred if performance and keeping state are not a concern since it has some nice advantage which is the allure of hot reloading (recompiling the script will affect the main crate while its running)
89///
90/// - *Daemon* scripts are expected to run indefinitely, the main advantage is better performance and keeping the state
91///
92/// - *DynamicLib* scripts compiled as dynamic libraries, the main advantage is even better performance, but this is the least safe option
93#[derive(Serialize, Deserialize, Debug, Clone, Copy)]
94pub enum ScriptType {
95 /// Scripts that is executed each time
96 OneShot,
97 /// Scripts that runs indefinitely, it will continue to receive and send hooks while its
98 /// running
99 Daemon,
100 /// Script compiled as a dynamic library\
101 /// It needs to export a static [DynamicScript] instance with [DynamicScript::NAME] as name (with `#[no_mangle]` attribute)
102 DynamicLib,
103}
104
105/// ScriptManager holds all the scripts found, it can be constructed with [ScriptManager::default]\
106/// Initially its empty, to populate it, we can use one of the methods to add scripts, currently only [ScriptManager::add_scripts_by_path] is provided
107#[derive(Default)]
108pub struct ScriptManager {
109 scripts: Vec<Script>,
110}
111
112/// Message that is sent from the main crate to the script each time it wants to interact with it\
113/// Greeting message must be sent when looking for scripts\
114/// Execute message must be sent each time a hook is triggered
115#[derive(Serialize, Deserialize, Debug, PartialEq)]
116pub(crate) enum Message {
117 /// Greet a script, the script must respond with [ScriptInfo]
118 Greeting,
119 /// Must be sent each time a hook is triggered
120 Execute,
121}
122
123impl ScriptManager {
124 /// Look for scripts in the specified folder\
125 /// It requires specifying a [VersionReq] so the script manager can check for incompatibility and if that's the case it will return an error: [Error::ScriptVersionMismatch]\
126 ///
127 /// ```rust, no_run
128 /// # use rscript::*;
129 /// let mut sm = ScriptManager::default();
130 /// let scripts_path: std::path::PathBuf = todo!(); // Defined by the user
131 /// const VERSION: &'static str = concat!("main_crate-", env!("CARGO_PKG_VERSION"));
132 /// sm.add_scripts_by_path(scripts_path, Version::parse(VERSION).expect("version is correct"));
133 /// ```
134 pub fn add_scripts_by_path<P: AsRef<Path>>(
135 &mut self,
136 path: P,
137 version: Version,
138 ) -> Result<(), Error> {
139 fn start_script(path: &Path, version: &Version) -> Result<Script, Error> {
140 let mut script = std::process::Command::new(path)
141 .stdin(Stdio::piped())
142 .stdout(Stdio::piped())
143 .spawn()?;
144
145 // Send Greeting Message
146 let stdin = script.stdin.as_mut().expect("stdin is piped");
147 bincode::serialize_into(stdin, &Message::Greeting)?;
148
149 // Receive ScriptInfo
150 let stdout = script.stdout.as_mut().expect("stdout is piped");
151 let metadata: ScriptInfo = bincode::deserialize_from(stdout)?;
152
153 // Check if the provided version matches the script version
154 if !metadata.version_requirement.matches(version) {
155 return Err(Error::ScriptVersionMismatch {
156 program_actual_version: version.clone(),
157 program_required_version: metadata.version_requirement,
158 });
159 }
160
161 // Save script depending on its type
162 let script = if matches!(metadata.script_type, ScriptType::Daemon) {
163 ScriptTypeInternal::Daemon(script)
164 } else {
165 ScriptTypeInternal::OneShot(path.to_path_buf())
166 };
167 Ok(Script {
168 script,
169 metadata,
170 state: State::Active,
171 })
172 }
173 let path = path.as_ref();
174 for entry in std::fs::read_dir(path)? {
175 let entry = entry?;
176 let path = entry.path();
177 if path.is_file() {
178 if let Some(ext) = path.extension() {
179 if ext == env::consts::DLL_EXTENSION {
180 continue;
181 }
182 }
183 self.scripts.push(start_script(&path, &version)?);
184 }
185 }
186 Ok(())
187 }
188 /// Same as [ScriptManager::add_scripts_by_path] but looks for dynamic libraries instead
189 ///
190 /// # Safety
191 /// See <https://docs.rs/libloading/0.7.1/libloading/struct.Library.html#safety>
192 pub unsafe fn add_dynamic_scripts_by_path<P: AsRef<Path>>(
193 &mut self,
194 path: P,
195 version: Version,
196 ) -> Result<(), Error> {
197 fn load_dynamic_library(path: &Path, version: &Version) -> Result<Script, Error> {
198 let lib = unsafe { libloading::Library::new(path)? };
199 let script: libloading::Symbol<&DynamicScript> =
200 unsafe { lib.get(DynamicScript::NAME)? };
201
202 let metadata: ScriptInfo = (script.script_info)().deserialize()?;
203 if !metadata.version_requirement.matches(version) {
204 return Err(Error::ScriptVersionMismatch {
205 program_actual_version: version.clone(),
206 program_required_version: metadata.version_requirement,
207 });
208 }
209 Ok(Script {
210 script: ScriptTypeInternal::DynamicLib(lib),
211 metadata,
212 state: State::Active,
213 })
214 }
215 let path = path.as_ref();
216 for entry in std::fs::read_dir(path)? {
217 let entry = entry?;
218 let path = entry.path();
219 if path.is_file() {
220 if let Some(ext) = path.extension() {
221 if ext == env::consts::DLL_EXTENSION {
222 self.scripts.push(load_dynamic_library(&path, &version)?);
223 }
224 }
225 }
226 }
227 Ok(())
228 }
229 /// Trigger a hook
230 /// All scripts that are *active* and that are listening for this particular hook will receive it
231 pub fn trigger<'a, H: 'static + Hook>(
232 &'a mut self,
233 hook: H,
234 ) -> impl Iterator<Item = Result<<H as Hook>::Output, Error>> + 'a {
235 self.scripts.iter_mut().filter_map(move |script| {
236 if script.is_active() && script.is_listening_for::<H>() {
237 Some(script.trigger_internal(&hook))
238 } else {
239 None
240 }
241 })
242 }
243 /// List of current scripts
244 pub fn scripts(&self) -> &[Script] {
245 &self.scripts
246 }
247 /// Mutable list of current scripts, useful for activating/deactivating a script
248 pub fn scripts_mut(&mut self) -> &mut [Script] {
249 &mut self.scripts
250 }
251}
252
253impl Drop for ScriptManager {
254 fn drop(&mut self) {
255 self.scripts.iter_mut().for_each(|script| script.end());
256 }
257}
258
259/// A script abstraction
260// The user should not be able to construct a Script manually
261#[derive(Debug)]
262pub struct Script {
263 metadata: ScriptInfo,
264 script: ScriptTypeInternal,
265 state: State,
266}
267
268#[derive(Debug)]
269enum State {
270 Active,
271 Inactive,
272}
273
274#[derive(Debug)]
275enum ScriptTypeInternal {
276 Daemon(Child),
277 OneShot(std::path::PathBuf),
278 DynamicLib(libloading::Library),
279}
280
281impl Script {
282 //public
283 /// Returns the script metadata
284 pub fn metadata(&self) -> &ScriptInfo {
285 &self.metadata
286 }
287 /// Activate a script, inactive scripts will not react to hooks
288 pub fn activate(&mut self) {
289 self.state = State::Active;
290 }
291 /// Deactivate a script, inactive scripts will not react to hooks
292 pub fn deactivate(&mut self) {
293 self.state = State::Inactive;
294 }
295 /// Query the script state
296 pub fn is_active(&self) -> bool {
297 matches!(self.state, State::Active)
298 }
299 /// Check if a script is listening for a hook
300 pub fn is_listening_for<H: Hook>(&self) -> bool {
301 self.metadata
302 .hooks
303 .iter()
304 .any(|hook| hook.as_str() == H::NAME)
305 }
306 /// Trigger a hook on the script, this disregards the script state as in the hook will be triggered even if the script is inactive\
307 /// If the script is not listening for the specified hook, an error will be returned
308 pub fn trigger<H: Hook>(&mut self, hook: &H) -> Result<<H as Hook>::Output, Error> {
309 if self.is_listening_for::<H>() {
310 self.trigger_internal(hook)
311 } else {
312 Err(Error::ScriptIsNotListeningForHook)
313 }
314 }
315}
316
317impl Script {
318 // private
319 fn trigger_internal<H: Hook>(&mut self, hook: &H) -> Result<<H as Hook>::Output, Error> {
320 let trigger_hook_common =
321 |script: &mut Child| -> Result<<H as Hook>::Output, bincode::Error> {
322 let mut stdin = script.stdin.as_mut().expect("stdin is piped");
323 let stdout = script.stdout.as_mut().expect("stdout is piped");
324
325 // Send Execute message
326 bincode::serialize_into(&mut stdin, &Message::Execute)?;
327 // bincode write hook type
328 bincode::serialize_into(&mut stdin, H::NAME)?;
329 // bincode write hook
330 bincode::serialize_into(stdin, hook)?;
331 // bincode read result -> O
332 bincode::deserialize_from(stdout)
333 };
334
335 Ok(match &mut self.script {
336 ScriptTypeInternal::Daemon(ref mut script) => trigger_hook_common(script)?,
337 ScriptTypeInternal::OneShot(script_path) => trigger_hook_common(
338 &mut std::process::Command::new(script_path)
339 .stdin(Stdio::piped())
340 .stdout(Stdio::piped())
341 .spawn()?,
342 )?,
343 ScriptTypeInternal::DynamicLib(lib) => unsafe {
344 let script: libloading::Symbol<&DynamicScript> = lib.get(DynamicScript::NAME)?;
345
346 let output = (script.script)(FFiStr::new(H::NAME), FFiData::serialize_from(hook)?);
347 output.deserialize()?
348 },
349 })
350 }
351 fn end(&mut self) {
352 // This errors if the script has already exited
353 // We don't care about this error
354 if let ScriptTypeInternal::Daemon(ref mut script) = self.script {
355 let _ = script.kill();
356 }
357 }
358}
359
360/// Trait to mark the hooks that will be triggered in the main crate\
361/// Triggering the hook sends input to the script, and receive the output from it\
362/// The output type is declared on the hook associated type\
363/// The associated NAME is needed in order to differentiate the hooks received in the script\
364/// The hook struct is required to implement serde::Serialize+Deserialize, so it can be used by bincode\
365/// The hooks should be declared on an external crate (my-project-api for example) so they can be used both by the main crate and the script\
366/// ```rust
367/// #[derive(serde::Serialize, serde::Deserialize)]
368/// struct Eval(String);
369/// impl rscript::Hook for Eval {
370/// const NAME: &'static str = "Eval";
371/// type Output = Option<String>;
372/// }
373pub trait Hook: Serialize + DeserializeOwned {
374 /// The name of the hook, required to distinguish the received hook on the script side
375 const NAME: &'static str;
376 /// The output type of the script
377 type Output: Serialize + DeserializeOwned;
378}