rhai_loco/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use ::serde::{de::DeserializeOwned, Deserialize, Serialize};
4use axum::{extract::FromRequestParts, http::request::Parts, Extension, Router as AxumRouter};
5use loco_rs::app::{AppContext, Initializer};
6use loco_rs::prelude::*;
7use serde_json::Value;
8use std::{
9    collections::HashMap,
10    fmt::Debug,
11    fs::read_dir,
12    path::{Path, PathBuf},
13    sync::{Arc, OnceLock, RwLock},
14};
15use tracing::{debug, info, trace, trace_span};
16
17// Re-export useful Rhai types and functions.
18use rhai::module_resolvers::FileModuleResolver;
19pub use rhai::serde::{from_dynamic, to_dynamic};
20pub use rhai::*;
21pub use tera;
22
23/// Type alias for `Result<T, Box<EvalAltResult>>`.
24pub type RhaiResult<T> = std::result::Result<T, Box<EvalAltResult>>;
25
26/// Target namespace path for logging.
27///
28/// Notice that this is changed to start with the `loco_rs` crate in order for script logs to be
29/// visible under Loco.
30pub const ROOT: &str = "loco_rs::scripting::rhai_script";
31
32/// Directory containing Rhai scripts.
33pub const SCRIPTS_DIR: &'static str = "assets/scripts";
34
35/// Directory containing Rhai scripts for Tera filters.
36pub const FILTER_SCRIPTS_DIR: &'static str = "assets/scripts/tera/filters";
37
38/// Global Rhai [`Engine`] instance for scripts evaluation.
39pub static ENGINE: OnceLock<Engine> = OnceLock::new();
40
41/// Global Rhai [`Engine`] instance for filter scripts evaluation.
42pub static FILTERS_ENGINE: OnceLock<Engine> = OnceLock::new();
43
44/// Global `RhaiScript` instance for scripts evaluation.
45pub static RHAI_SCRIPT: OnceLock<RhaiScript> = OnceLock::new();
46
47/// Error message for script file not found.
48const SCRIPT_FILE_NOT_FOUND: &str = "script file not found";
49
50/// Type that wraps a scripting engine for use in [`Axum`][axum] handlers.
51#[derive(Debug, PartialEq, Eq, Clone)]
52pub struct ScriptingEngine<E>(pub E);
53
54impl<E> ScriptingEngine<E> {
55    /// Creates a new [`ScriptingEngine`] that wraps the given scripting engine
56    #[inline(always)]
57    #[must_use]
58    pub fn new(engine: E) -> Self {
59        Self(engine)
60    }
61}
62
63impl<E> From<E> for ScriptingEngine<E> {
64    fn from(inner: E) -> Self {
65        Self::new(inner)
66    }
67}
68
69impl<S, E> FromRequestParts<S> for ScriptingEngine<E>
70where
71    S: Send + Sync,
72    E: Clone + Send + Sync + 'static,
73{
74    type Rejection = std::convert::Infallible;
75
76    async fn from_request_parts(
77        parts: &mut Parts,
78        state: &S,
79    ) -> std::result::Result<Self, Self::Rejection> {
80        let Extension(tl): Extension<Self> = Extension::from_request_parts(parts, state)
81            .await
82            .expect("Scripting layer missing. Is it installed?");
83
84        Ok(tl)
85    }
86}
87
88/// A scripting engine based on [`Rhai`](https://rhai.rs).
89#[derive(Debug, Clone)]
90pub struct RhaiScript {
91    /// Path to the directory containing Rhai scripts.
92    scripts_path: Arc<PathBuf>,
93    /// Cache of compiled Rhai scripts in [`AST`] form.
94    cache: Arc<RwLock<HashMap<PathBuf, Arc<AST>>>>,
95}
96
97impl RhaiScript {
98    /// File extension for Rhai scripts.
99    pub const SCRIPTS_EXT: &'static str = "rhai";
100
101    /// Get a new [`RhaiScript`] instance.
102    ///
103    /// The methods [`new`][`RhaiScript::new`] or [`new_with_setup`][`RhaiScript::new_with_setup`] must be called first.
104    ///
105    /// # Panics
106    ///
107    /// Panics if called before [`new`][`RhaiScript::new`] or [`new_with_setup`][`RhaiScript::new_with_setup`].
108    #[inline(always)]
109    pub fn get_instance() -> Self {
110        RHAI_SCRIPT.get().unwrap().clone()
111    }
112
113    /// Create a new [`RhaiScript`] instance.
114    ///
115    /// This method can only be called once. A Rhai [`Engine`] instance is created and shared globally.
116    ///
117    /// # Panics
118    ///
119    /// Panics if called more than once.
120    ///
121    /// # Errors
122    ///
123    /// Error if the scripts directory does not exist.
124    #[inline(always)]
125    pub fn new(scripts_path: impl Into<PathBuf>) -> Result<Self> {
126        Self::new_with_setup(scripts_path, |_| {})
127    }
128
129    /// Create a new [`RhaiScript`] instance with custom setup.
130    ///
131    /// This method can only be called once. A Rhai [`Engine`] instance is created and shared globally.
132    ///
133    /// # Panics
134    ///
135    /// Panics if called more than once.
136    ///
137    /// # Errors
138    ///
139    /// Error if the scripts directory does not exist.
140    pub fn new_with_setup(
141        scripts_path: impl Into<PathBuf>,
142        setup: impl FnOnce(&mut Engine),
143    ) -> Result<Self> {
144        let scripts_path = scripts_path.into();
145
146        if !scripts_path.exists() {
147            return Err(Error::string(&format!(
148                "missing scripts directory: `{}`",
149                scripts_path.to_string_lossy()
150            )));
151        }
152
153        let mut engine = Engine::new();
154
155        let mut resolver = FileModuleResolver::new_with_path(SCRIPTS_DIR);
156        resolver.enable_cache(false);
157
158        engine
159            .set_module_resolver(resolver)
160            .on_print(|message| info!(target: ROOT, message))
161            .on_debug(
162                |message, source, pos| debug!(target: ROOT, ?message, source, position = ?pos),
163            );
164
165        setup(&mut engine);
166
167        ENGINE
168            .set(engine)
169            .expect("`RhaiScript::new` or `RhaiScript::new_with_setup` can be called only once.");
170
171        RHAI_SCRIPT
172            .set(Self {
173                scripts_path: Arc::new(scripts_path),
174                cache: Arc::new(RwLock::new(HashMap::new())),
175            })
176            .unwrap();
177
178        Ok(Self::get_instance())
179    }
180
181    /// Get a reference to the Rhai [`Engine`].
182    #[inline(always)]
183    #[must_use]
184    pub fn engine(&self) -> &Engine {
185        ENGINE.get().unwrap()
186    }
187
188    /// Convert a [Rhai error][EvalAltResult] to a [Loco error][Result].
189    ///
190    /// If the error is a [runtime error][EvalAltResult::ErrorRuntime],
191    /// it is converted using the provided closure.
192    ///
193    /// Otherwise, the error is converted via [`Error::msg`].
194    pub fn convert_runtime_error<T>(
195        &self,
196        err: Box<EvalAltResult>,
197        converter: impl FnOnce(String) -> Result<T>,
198    ) -> Result<T> {
199        match *err {
200            EvalAltResult::ErrorRuntime(r, _) => converter(r.to_string()),
201            e => Err(Error::msg(e)),
202        }
203    }
204
205    /// Run a script if it exists.
206    ///
207    /// Return `Value::Null` if the script does not exist.
208    ///
209    /// # Errors
210    ///
211    /// * Error if there is a syntax error during compilation.
212    /// * Error if there is an error during script evaluation.
213    #[inline(always)]
214    pub fn run_script_if_exists(
215        &self,
216        script_file: &str,
217        data: &mut (impl Serialize + DeserializeOwned + Debug),
218        fn_name: &str,
219        args: impl FuncArgs,
220    ) -> RhaiResult<Value> {
221        self.run_script(script_file, data, fn_name, args)
222            .or_else(|err| match *err {
223                EvalAltResult::ErrorSystem(s, e)
224                    if s == SCRIPT_FILE_NOT_FOUND && e.to_string() == script_file =>
225                {
226                    Ok(Value::Null)
227                }
228                _ => Err(err),
229            })
230    }
231
232    /// Run a script.
233    ///
234    /// # Errors
235    ///
236    /// * Error if the script file does not exist.
237    /// * Error if there is a syntax error during compilation.
238    /// * Error if there is an error during script evaluation.
239    pub fn run_script(
240        &self,
241        script_file: &str,
242        data: &mut (impl Serialize + DeserializeOwned + Debug),
243        fn_name: &str,
244        args: impl FuncArgs,
245    ) -> RhaiResult<Value> {
246        let mut script_path = self.scripts_path.join(script_file);
247
248        if script_path.extension().is_none() {
249            script_path.set_extension(Self::SCRIPTS_EXT);
250        }
251
252        let _ = trace_span!("run_script").enter();
253
254        if !script_path.exists() {
255            debug!(target: ROOT, script = script_path.to_string_lossy().as_ref(), message = SCRIPT_FILE_NOT_FOUND);
256            return Err(EvalAltResult::ErrorSystem(
257                SCRIPT_FILE_NOT_FOUND.to_string(),
258                script_file.into(),
259            )
260            .into());
261        }
262
263        let mut cache = self.cache.write().unwrap();
264
265        let ast = if let Some(ast) = cache.get(&script_path) {
266            ast
267        } else {
268            let mut ast = self.engine().compile_file(script_path.clone())?;
269            ast.set_source(script_path.to_string_lossy().as_ref());
270            cache
271                .entry(script_path)
272                .or_insert_with(|| Arc::new(ast.clone()))
273        };
274
275        let source = ast.source();
276        debug!(fn_name, ?data, source, "Rhai: call function");
277
278        let mut obj = to_dynamic(&*data).unwrap();
279        let options = CallFnOptions::new().bind_this_ptr(&mut obj);
280
281        let result = self
282            .engine()
283            .call_fn_with_options(options, &mut Scope::new(), ast, fn_name, args)
284            .map(|v| from_dynamic(&v).unwrap())
285            .map_err(|err| match *err {
286                EvalAltResult::ErrorInFunctionCall(f, _, e, Position::NONE) if f == fn_name => e,
287                _ => err,
288            });
289
290        *data = from_dynamic(&obj).unwrap();
291
292        debug!(?result, ?data, fn_name, source, "Rhai: function returns");
293
294        result
295    }
296
297    /// Register Tera filters from Rhai scripts on a raw [`Tera`](tera::Tera) instance.
298    ///
299    /// If the Tera i18n function `t` is provided, it is also registered into the Rhai [`Engine`]
300    /// for use in filter scripts.
301    ///
302    /// # Errors
303    ///
304    /// * Error if the filter scripts directory does not exist.
305    /// * Error if there is a syntax error in any script during compilation.
306    pub fn register_tera_filters(
307        tera: &mut tera::Tera,
308        scripts_path: impl AsRef<Path>,
309        engine_setup: impl FnOnce(&mut Engine),
310        i18n: Option<impl tera::Function + 'static>,
311    ) -> Result<()> {
312        let path = scripts_path.as_ref();
313
314        if !path.exists() {
315            return Err(Error::string(&format!(
316                "missing scripts directory: `{}`",
317                path.to_string_lossy()
318            )));
319        }
320
321        let span = trace_span!("register_filters", dir = ?path);
322        let _ = span.enter();
323
324        let engine = FILTERS_ENGINE.get_or_init(|| {
325            let mut engine = Engine::new();
326
327            engine_setup(&mut engine);
328
329            engine
330                .on_print(|message| info!(target: ROOT, message))
331                .on_debug(
332                    |message, source, pos| debug!(target: ROOT, ?message, source, position = ?pos),
333                );
334
335            if let Some(i18n) = i18n {
336                let i18n = Arc::new(i18n);
337
338                let t = i18n.clone();
339                engine.register_fn("t", move |args: Map| -> RhaiResult<Dynamic> {
340                    let map: HashMap<String, Value> = args
341                        .into_iter()
342                        .map(|(k, v)| -> RhaiResult<(String, Value)> {
343                            Ok((k.to_string(), from_dynamic(&v)?))
344                        })
345                        .collect::<RhaiResult<_>>()?;
346                    match t.call(&map) {
347                        Ok(v) => Ok(to_dynamic(v)?),
348                        Err(e) => Err(e.to_string().into()),
349                    }
350                });
351
352                let t = i18n.clone();
353                engine.register_fn("t", move |key: &str, lang: &str| -> RhaiResult<Dynamic> {
354                    let mut map = HashMap::new();
355                    let _ = map.insert("key".to_string(), key.into());
356                    let _ = map.insert("lang".to_string(), lang.into());
357                    match t.call(&map) {
358                        Ok(v) => Ok(to_dynamic(v)?),
359                        Err(e) => Err(e.to_string().into()),
360                    }
361                });
362
363                info!(target: ROOT, "i18n function loaded into Rhai engine");
364            }
365
366            engine
367        });
368
369        for entry in read_dir(path)? {
370            let entry = entry?;
371            let script = entry.path();
372
373            if script.is_dir() {
374                debug!(target: ROOT, dir = ?entry.file_name().to_string_lossy(), "skip dir");
375                continue;
376            } else if script
377                .extension()
378                .map_or(true, |ext| ext.to_string_lossy() != Self::SCRIPTS_EXT)
379            {
380                debug!(target: ROOT, file = ?entry.file_name().to_string_lossy(), "skip non-script file");
381                continue;
382            }
383
384            let mut ast = engine.compile_file(script.clone()).map_err(|err| {
385                Error::string(&(format!("`{}`: {err}", entry.file_name().to_string_lossy())))
386            })?;
387            ast.set_source(script.to_string_lossy().as_ref());
388            let shared_ast = Arc::new(ast);
389            debug!(target: ROOT, file = ?entry.file_name().to_string_lossy(), "compile script");
390
391            shared_ast.iter_functions()
392                .filter(|fn_def| fn_def.access != FnAccess::Private && fn_def.params.len() == 1)
393                .for_each(|fn_def| {
394                    let fn_name = fn_def.name.to_string();
395                    let ast = shared_ast.clone();
396
397                    let f = move |value: &Value,
398                                  variables: &HashMap<String, Value>|
399                          -> tera::Result<Value> {
400                        trace!(target: ROOT, fn_name, ?value, ?variables, "Rhai: call Tera filter");
401
402                        let mut obj = to_dynamic(value).unwrap();
403                        let dict = to_dynamic(variables).unwrap().cast::<Map>();
404
405                        let scope = &mut Scope::new();
406                        dict.iter().for_each(|(k, v)| {
407                            scope.push_dynamic(k.clone(), v.clone());
408                        });
409
410                        let options = CallFnOptions::new().bind_this_ptr(&mut obj);
411                        let value = engine
412                            .call_fn_with_options::<Dynamic>(options, scope, &ast, &fn_name, (dict,))
413                            .map_err(tera::Error::msg)?;
414
415                        let value = from_dynamic(&value).unwrap();
416                        trace!(target: ROOT, ?value, fn_name, ?variables, "Rhai: return value from Tera filter");
417
418                        Ok(value)
419                    };
420
421                    tera.register_filter(fn_def.name, f);
422
423                    info!(target: ROOT, fn_name = fn_def.name, file = ?entry.file_name().to_string_lossy(), "register Tera filter");
424                });
425        }
426
427        Ok(())
428    }
429}
430
431/// Loco initializer for the Rhai scripting engine with custom setup.
432#[derive(Default)]
433pub struct ScriptingEngineInitializerWithSetup<F: Fn(&mut Engine) + Send + Sync + 'static> {
434    /// Custom setup for the Rhai [`Engine`], if any.
435    setup: Option<F>,
436}
437
438/// Loco initializer for the Rhai scripting engine.
439pub type ScriptingEngineInitializer = ScriptingEngineInitializerWithSetup<fn(&mut Engine)>;
440
441#[derive(Debug, Clone, Serialize, Deserialize)]
442pub struct ScriptingEngineInitializerConfig {
443    /// Directory containing scripts.
444    #[serde(default = "ScriptingEngineInitializerConfig::default_scripts_path")]
445    pub scripts_path: PathBuf,
446    /// Directory containing Tera filters.
447    #[serde(default = "ScriptingEngineInitializerConfig::default_filters_path")]
448    pub filters_path: PathBuf,
449}
450
451impl Default for ScriptingEngineInitializerConfig {
452    #[inline(always)]
453    fn default() -> Self {
454        Self {
455            scripts_path: Self::default_scripts_path(),
456            filters_path: Self::default_filters_path(),
457        }
458    }
459}
460
461impl ScriptingEngineInitializerConfig {
462    /// Default directory containing scripts.
463    pub fn default_scripts_path() -> PathBuf {
464        SCRIPTS_DIR.into()
465    }
466    /// Default directory containing Tera filters.
467    pub fn default_filters_path() -> PathBuf {
468        FILTER_SCRIPTS_DIR.into()
469    }
470    /// Create a new [`ScriptingEngineInitializerConfig`] instance from the Loco [`AppContext`].
471    pub fn from_app_context(ctx: &AppContext) -> Result<Self> {
472        let config = ctx
473            .config
474            .initializers
475            .as_ref()
476            .and_then(|m| m.get(ScriptingEngineInitializer::NAME))
477            .cloned()
478            .unwrap_or_default();
479
480        Ok(serde_json::from_value(config)?)
481    }
482}
483
484impl<F: Fn(&mut Engine) + Send + Sync + 'static> ScriptingEngineInitializerWithSetup<F> {
485    /// Initializer name.
486    pub const NAME: &'static str = "scripting";
487
488    /// Create a new [`ScriptingEngineInitializerWithSetup`] instance with custom setup for the Rhai [`Engine`].
489    #[inline(always)]
490    #[must_use]
491    pub fn new_with_setup(setup: F) -> Self {
492        Self { setup: Some(setup) }
493    }
494}
495
496#[async_trait]
497impl<F: Fn(&mut Engine) + Send + Sync + 'static> Initializer
498    for ScriptingEngineInitializerWithSetup<F>
499{
500    #[inline(always)]
501    fn name(&self) -> String {
502        Self::NAME.to_string()
503    }
504
505    async fn after_routes(&self, router: AxumRouter, ctx: &AppContext) -> Result<AxumRouter> {
506        let config = ScriptingEngineInitializerConfig::from_app_context(ctx)?;
507        let engine = if let Some(ref setup) = self.setup {
508            RhaiScript::new_with_setup(config.scripts_path.clone(), setup)?
509        } else {
510            RhaiScript::new(config.scripts_path.clone())?
511        };
512
513        Ok(router.layer(Extension(ScriptingEngine::from(engine))))
514    }
515}