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}