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
17use rhai::module_resolvers::FileModuleResolver;
19pub use rhai::serde::{from_dynamic, to_dynamic};
20pub use rhai::*;
21pub use tera;
22
23pub type RhaiResult<T> = std::result::Result<T, Box<EvalAltResult>>;
25
26pub const ROOT: &str = "loco_rs::scripting::rhai_script";
31
32pub const SCRIPTS_DIR: &'static str = "assets/scripts";
34
35pub const FILTER_SCRIPTS_DIR: &'static str = "assets/scripts/tera/filters";
37
38pub static ENGINE: OnceLock<Engine> = OnceLock::new();
40
41pub static FILTERS_ENGINE: OnceLock<Engine> = OnceLock::new();
43
44pub static RHAI_SCRIPT: OnceLock<RhaiScript> = OnceLock::new();
46
47const SCRIPT_FILE_NOT_FOUND: &str = "script file not found";
49
50#[derive(Debug, PartialEq, Eq, Clone)]
52pub struct ScriptingEngine<E>(pub E);
53
54impl<E> ScriptingEngine<E> {
55 #[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#[derive(Debug, Clone)]
90pub struct RhaiScript {
91 scripts_path: Arc<PathBuf>,
93 cache: Arc<RwLock<HashMap<PathBuf, Arc<AST>>>>,
95}
96
97impl RhaiScript {
98 pub const SCRIPTS_EXT: &'static str = "rhai";
100
101 #[inline(always)]
109 pub fn get_instance() -> Self {
110 RHAI_SCRIPT.get().unwrap().clone()
111 }
112
113 #[inline(always)]
125 pub fn new(scripts_path: impl Into<PathBuf>) -> Result<Self> {
126 Self::new_with_setup(scripts_path, |_| {})
127 }
128
129 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 #[inline(always)]
183 #[must_use]
184 pub fn engine(&self) -> &Engine {
185 ENGINE.get().unwrap()
186 }
187
188 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 #[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 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 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#[derive(Default)]
433pub struct ScriptingEngineInitializerWithSetup<F: Fn(&mut Engine) + Send + Sync + 'static> {
434 setup: Option<F>,
436}
437
438pub type ScriptingEngineInitializer = ScriptingEngineInitializerWithSetup<fn(&mut Engine)>;
440
441#[derive(Debug, Clone, Serialize, Deserialize)]
442pub struct ScriptingEngineInitializerConfig {
443 #[serde(default = "ScriptingEngineInitializerConfig::default_scripts_path")]
445 pub scripts_path: PathBuf,
446 #[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 pub fn default_scripts_path() -> PathBuf {
464 SCRIPTS_DIR.into()
465 }
466 pub fn default_filters_path() -> PathBuf {
468 FILTER_SCRIPTS_DIR.into()
469 }
470 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 pub const NAME: &'static str = "scripting";
487
488 #[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}