1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
#![warn(missing_docs)]

//! Crate to easily script any rust project
//! # Rscript
//! The main idea is:
//! - Create a new crate (my-project-api for example)
//! - Add hooks to this api-crate
//! - This api-crate should be used by the main-crate and by the scripts
//! - Trigger Hooks in the main crate
//! - Receive the hooks on the script side, and react to them with any output
//!
//!
//! Goals:
//! - Be as easy as possible to include on already established projects
//! - Strive for maximum compile time guarantees
//!
//! This crate was extracted from [IRust](https://github.com/sigmaSd/IRust)
//!
//! Taking *IRust* as an example:
//! - It has an API crate where hooks are defined [irust_api](https://github.com/sigmaSd/IRust/blob/master/crates/irust_api/src/lib.rs#L22)
//! - It trigger hooks on the main crate [irust](https://github.com/sigmaSd/IRust/blob/master/crates/irust/src/irust.rs#L136)
//! - And a script example [vim_mode](https://github.com/sigmaSd/IRust/tree/master/scripts_examples/script4/irust_vim)
//!
//! Check out the [examples](https://github.com/sigmaSd/Rscript/tree/master/examples) for more info.

use scripting::{FFiData, FFiStr};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use std::{
    env,
    path::Path,
    process::{Child, Stdio},
};

// Rexport Version, VersionReq
/// *SemVer version* as defined by <https://semver.org.>\
/// The main crate must specify its version when adding scripts to [ScriptManager]
pub use semver::Version;
/// *SemVer version requirement* describing the intersection of some version comparators, such as >=1.2.3, <1.8.\
/// Each script must specify the required version of the main crate when responding to [Message::Greeting]
pub use semver::VersionReq;

pub mod scripting;

mod error;
pub use error::Error;

use crate::scripting::DynamicScript;

/// Script metadata that every script should send to the main_crate  when starting up after receiving the greeting message [Message::Greeting]
#[derive(Serialize, Deserialize, Debug)]
pub struct ScriptInfo {
    /// Script name
    pub name: String,
    /// Script type: Daemon/OneShot
    pub script_type: ScriptType,
    /// The hooks that the script wants to listen to
    pub hooks: Box<[String]>,
    /// The version requirement of the program that the script will run against
    pub version_requirement: VersionReq,
}

impl ScriptInfo {
    /// Create a new script metadata, the new constructor tries to add more ergonomics
    pub fn new(
        name: &'static str,
        script_type: ScriptType,
        hooks: &'static [&'static str],
        version_requirement: VersionReq,
    ) -> Self {
        Self {
            name: name.into(),
            script_type,
            hooks: hooks.iter().map(|hook| String::from(*hook)).collect(),
            version_requirement,
        }
    }
    /// Serialize `ScriptInfo` into `FFiData`
    /// This is needed for writing [ScriptType::DynamicLib] scripts
    pub fn into_ffi_data(self) -> FFiData {
        FFiData::serialize_from(&self).expect("ScriptInfo is always serialize-able")
    }
}

/// ScriptType: Daemon/OneShot/DynamicLib
/// - *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)
///
/// - *Daemon* scripts are expected to run indefinitely, the main advantage is better performance and keeping the state
///
/// - *DynamicLib* scripts compiled as dynamic libraries, the main advantage is even better performance, but this is the least safe option
#[derive(Serialize, Deserialize, Debug, Clone, Copy)]
pub enum ScriptType {
    /// Scripts that is executed each time
    OneShot,
    /// Scripts that runs indefinitely, it will continue to receive and send hooks while its
    /// running
    Daemon,
    /// Script compiled as a dynamic library\
    /// It needs to export a static [DynamicScript] instance with [DynamicScript::NAME] as name (with `#[no_mangle]` attribute)
    DynamicLib,
}

/// ScriptManager holds all the scripts found, it can be constructed with [ScriptManager::default]\
/// 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
#[derive(Default)]
pub struct ScriptManager {
    scripts: Vec<Script>,
}

/// Message that is sent from the main crate to the script each time it wants to interact with it\
/// Greeting message must be sent when looking for scripts\
/// Execute message must be sent each time a hook is triggered
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub enum Message {
    /// Greet a script, the script must respond with [ScriptInfo]
    Greeting,
    /// Must be sent each time a hook is triggered
    Execute,
}

impl ScriptManager {
    /// Look for scripts in the specified folder\
    /// 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]\
    /// The script manager will send a [Message::Greeting] for every script found and the scripts must respond with [ScriptInfo]
    ///
    /// ```rust, no_run
    /// # use rscript::*;
    /// let mut sm = ScriptManager::default();
    /// let scripts_path: std::path::PathBuf = todo!(); // Defined by the user
    /// const VERSION: &'static str = concat!("main_crate-", env!("CARGO_PKG_VERSION"));
    /// sm.add_scripts_by_path(scripts_path, Version::parse(VERSION).expect("version is correct"));
    /// ```
    pub fn add_scripts_by_path<P: AsRef<Path>>(
        &mut self,
        path: P,
        version: Version,
    ) -> Result<(), Error> {
        fn start_script(path: &Path, version: &Version) -> Result<Script, Error> {
            let mut script = std::process::Command::new(path)
                .stdin(Stdio::piped())
                .stdout(Stdio::piped())
                .spawn()?;

            // Send Greeting Message
            let stdin = script.stdin.as_mut().expect("stdin is piped");
            bincode::serialize_into(stdin, &Message::Greeting)?;

            // Receive ScriptInfo
            let stdout = script.stdout.as_mut().expect("stdout is piped");
            let metadata: ScriptInfo = bincode::deserialize_from(stdout)?;

            // Check if the provided version matches the script version
            if !metadata.version_requirement.matches(version) {
                return Err(Error::ScriptVersionMismatch {
                    program_actual_version: version.clone(),
                    program_required_version: metadata.version_requirement,
                });
            }

            // Save script depending on its type
            let script = if matches!(metadata.script_type, ScriptType::Daemon) {
                ScriptTypeInternal::Daemon(script)
            } else {
                ScriptTypeInternal::OneShot(path.to_path_buf())
            };
            Ok(Script {
                script,
                metadata,
                state: State::Active,
            })
        }
        let path = path.as_ref();
        for entry in std::fs::read_dir(path)? {
            let entry = entry?;
            let path = entry.path();
            if path.is_file() {
                if let Some(ext) = path.extension() {
                    if ext == env::consts::DLL_EXTENSION {
                        continue;
                    }
                }
                self.scripts.push(start_script(&path, &version)?);
            }
        }
        Ok(())
    }
    /// Same as [ScriptManager::add_scripts_by_path] but looks for dynamic libraries instead
    ///
    /// # Safety
    /// See <https://docs.rs/libloading/0.7.1/libloading/struct.Library.html#safety>
    pub unsafe fn add_dynamic_scripts_by_path<P: AsRef<Path>>(
        &mut self,
        path: P,
        version: Version,
    ) -> Result<(), Error> {
        fn load_dynamic_library(path: &Path, version: &Version) -> Result<Script, Error> {
            let lib = unsafe { libloading::Library::new(path)? };
            let script: libloading::Symbol<&DynamicScript> =
                unsafe { lib.get(DynamicScript::NAME)? };

            let metadata: ScriptInfo = (script.script_info)().deserialize()?;
            if !metadata.version_requirement.matches(version) {
                return Err(Error::ScriptVersionMismatch {
                    program_actual_version: version.clone(),
                    program_required_version: metadata.version_requirement,
                });
            }
            Ok(Script {
                script: ScriptTypeInternal::DynamicLib(lib),
                metadata,
                state: State::Active,
            })
        }
        let path = path.as_ref();
        for entry in std::fs::read_dir(path)? {
            let entry = entry?;
            let path = entry.path();
            if path.is_file() {
                if let Some(ext) = path.extension() {
                    if ext == env::consts::DLL_EXTENSION {
                        self.scripts.push(load_dynamic_library(&path, &version)?);
                    }
                }
            }
        }
        Ok(())
    }
    /// Trigger a hook
    /// All scripts that are *active* and that are listening for this particular hook will receive it
    pub fn trigger<'a, H: 'static + Hook>(
        &'a mut self,
        hook: H,
    ) -> impl Iterator<Item = Result<<H as Hook>::Output, Error>> + 'a {
        self.scripts.iter_mut().filter_map(move |script| {
            if script.is_active() && script.is_listening_for::<H>() {
                Some(script.trigger_internal(&hook))
            } else {
                None
            }
        })
    }
    /// List of current scripts
    pub fn scripts(&self) -> &[Script] {
        &self.scripts
    }
    /// Mutable list of current scripts, useful for activating/deactivating a script
    pub fn scripts_mut(&mut self) -> &mut [Script] {
        &mut self.scripts
    }
}

impl Drop for ScriptManager {
    fn drop(&mut self) {
        self.scripts.iter_mut().for_each(|script| script.end());
    }
}

/// A script abstraction
// The user should not be able to construct a Script manually
#[derive(Debug)]
pub struct Script {
    metadata: ScriptInfo,
    script: ScriptTypeInternal,
    state: State,
}

#[derive(Debug)]
enum State {
    Active,
    Inactive,
}

#[derive(Debug)]
enum ScriptTypeInternal {
    Daemon(Child),
    OneShot(std::path::PathBuf),
    DynamicLib(libloading::Library),
}

impl Script {
    //public
    /// Returns the script metadata
    pub fn metadata(&self) -> &ScriptInfo {
        &self.metadata
    }
    /// Activate a script, inactive scripts will not react to hooks
    pub fn activate(&mut self) {
        self.state = State::Active;
    }
    /// Deactivate a script, inactive scripts will not react to hooks
    pub fn deactivate(&mut self) {
        self.state = State::Inactive;
    }
    /// Query the script state
    pub fn is_active(&self) -> bool {
        matches!(self.state, State::Active)
    }
    /// Check if a script is listening for a hook
    pub fn is_listening_for<H: Hook>(&self) -> bool {
        self.metadata
            .hooks
            .iter()
            .any(|hook| hook.as_str() == H::NAME)
    }
    /// Trigger a hook on the script, this disregards the script state as in the hook will be triggered even if the script is inactive\
    /// If the script is not listening for the specified hook, an error will be returned
    pub fn trigger<H: Hook>(&mut self, hook: &H) -> Result<<H as Hook>::Output, Error> {
        if self.is_listening_for::<H>() {
            self.trigger_internal(hook)
        } else {
            Err(Error::ScriptIsNotListeningForHook)
        }
    }
}

impl Script {
    // private
    fn trigger_internal<H: Hook>(&mut self, hook: &H) -> Result<<H as Hook>::Output, Error> {
        let trigger_hook_common =
            |script: &mut Child| -> Result<<H as Hook>::Output, bincode::Error> {
                let mut stdin = script.stdin.as_mut().expect("stdin is piped");
                let stdout = script.stdout.as_mut().expect("stdout is piped");

                // Send Execute message
                bincode::serialize_into(&mut stdin, &Message::Execute)?;
                // bincode write hook type
                bincode::serialize_into(&mut stdin, H::NAME)?;
                // bincode write hook
                bincode::serialize_into(stdin, hook)?;
                // bincode read result -> O
                bincode::deserialize_from(stdout)
            };

        Ok(match &mut self.script {
            ScriptTypeInternal::Daemon(ref mut script) => trigger_hook_common(script)?,
            ScriptTypeInternal::OneShot(script_path) => trigger_hook_common(
                &mut std::process::Command::new(script_path)
                    .stdin(Stdio::piped())
                    .stdout(Stdio::piped())
                    .spawn()?,
            )?,
            ScriptTypeInternal::DynamicLib(lib) => unsafe {
                let script: libloading::Symbol<&DynamicScript> = lib.get(DynamicScript::NAME)?;

                let output = (script.script)(FFiStr::new(H::NAME), FFiData::serialize_from(hook)?);
                output.deserialize()?
            },
        })
    }
    fn end(&mut self) {
        // This errors if the script has already exited
        // We don't care about this error
        if let ScriptTypeInternal::Daemon(ref mut script) = self.script {
            let _ = script.kill();
        }
    }
}

/// Trait to mark the hooks that will be triggered in the main crate\
/// Triggering the hook sends input to the script, and receive the output from it\
/// The output type is declared on the hook associated type\
/// The associated NAME is needed in order to differentiate the hooks received in the script\
/// The hook struct is required to implement serde::Serialize+Deserialize, so it can be used by bincode\
/// 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\
/// ```rust
/// #[derive(serde::Serialize, serde::Deserialize)]
/// struct Eval(String);
/// impl rscript::Hook for Eval {
///     const NAME: &'static str = "Eval";
///     type Output = Option<String>;
/// }
pub trait Hook: Serialize + DeserializeOwned {
    /// The name of the hook, required to distinguish the received hook on the script side
    const NAME: &'static str;
    /// The output type of the script
    type Output: Serialize + DeserializeOwned;
}