Skip to main content

recast_core/
script.rs

1//! Rhai-backed scripted replacement (feature `script`).
2//!
3//! Compiles a single Rhai script and evaluates it once per regex match.
4//! The script sees `captures` (array of strings; index 0 is the full
5//! match) and `whole` (the full match as a convenience — `match` is a
6//! Rhai reserved keyword). Its return value, coerced to a string,
7//! becomes the replacement.
8
9use std::fs;
10use std::path::Path;
11
12use rhai::{AST, Array, Dynamic, Engine, Scope};
13
14use crate::error::{Error, IoCtx, Result};
15
16/// Pre-compiled Rhai script used as a per-match replacement callback.
17pub struct ScriptRewriter {
18    engine: Engine,
19    ast: AST,
20}
21
22impl std::fmt::Debug for ScriptRewriter {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        f.debug_struct("ScriptRewriter").finish_non_exhaustive()
25    }
26}
27
28impl ScriptRewriter {
29    /// Compile `source` directly. Returns [`Error::ScriptParse`] on
30    /// syntax errors.
31    pub fn from_source(source: &str) -> Result<Self> {
32        let engine = sandboxed_engine();
33        let ast = engine.compile(source).map_err(|e| Error::ScriptParse(e.to_string()))?;
34        Ok(Self { engine, ast })
35    }
36
37    /// Read the script from `path` and compile it.
38    pub fn from_file(path: &Path) -> Result<Self> {
39        let source = fs::read_to_string(path).io_ctx(path)?;
40        Self::from_source(&source)
41    }
42
43    /// Build a sibling rewriter that shares the compiled AST with `self`
44    /// but owns a fresh sandboxed engine. Rhai `Engine` is `!Sync`, so
45    /// parallel pipelines that want to evaluate the same script on
46    /// multiple worker threads call `fresh()` per worker (e.g. via
47    /// `rayon::par_iter().map_init(|| script.fresh(), ...)`).
48    pub fn fresh(&self) -> Self {
49        Self { engine: sandboxed_engine(), ast: self.ast.clone() }
50    }
51
52    /// Evaluate the script with `captures` (index 0 = full match) and
53    /// return the resulting replacement string.
54    pub fn replace(&self, captures: &[&str]) -> Result<String> {
55        let mut scope = Scope::new();
56        let arr: Array = captures.iter().map(|s| Dynamic::from((*s).to_string())).collect();
57        scope.push("captures", arr);
58        let full = captures.first().copied().unwrap_or("").to_string();
59        scope.push("whole", full);
60        let out: Dynamic = self
61            .engine
62            .eval_ast_with_scope(&mut scope, &self.ast)
63            .map_err(|e| Error::ScriptRuntime(e.to_string()))?;
64        Ok(out.to_string())
65    }
66}
67
68fn sandboxed_engine() -> Engine {
69    let mut engine = Engine::new();
70    // CPU sandbox: rough cap so a runaway loop in a user script doesn't
71    // wedge the planner.
72    engine.set_max_operations(1_000_000);
73    engine.set_max_string_size(1024 * 1024);
74    engine.set_max_array_size(1024);
75    engine.set_max_expr_depths(64, 64);
76    engine
77}
78
79#[cfg(test)]
80#[path = "script_tests.rs"]
81mod tests;