rhai_eval.rs wraps the embedded Rhai scripting engine. monty_eval.rs wraps the monty crate, which compiles a Python function body to bytecode and runs it in a pure-Rust interpreter. Both are called from core.rs after the macro body has been expanded to a string.

Design rationale

Rhai: embedded, zero-process, type-preserving store

Rhai is embedded at compile time — no external process, no FFI. The engine is allocated once in RhaiEvaluator::new() and reused across all %rhaidef calls.

Variable injection order: weaveback string variables are added to the Rhai Scope first (lower priority); persistent store entries are added on top and override any same-named weaveback variable. This ensures scripts always see the typed, persistent value in the store rather than the string snapshot from weaveback’s scope.

Store write-back: after the script runs, any store key whose scope value was touched by the script is written back into the HashMap<String, Dynamic>. Only keys that were already present in the store participate — new variables created inside the script are not auto-persisted. Use %rhaiset / %rhaiexpr to pre-initialise a key before first use.

Numeric auto-promotion: rhaistore_set_str parses numeric strings to i64 or f64 before storing them. This means arithmetic operators work directly on store values inside Rhai scripts without parse_int(x).

Operations limit: set_max_operations(100_000) prevents infinite loops from hanging the process.

Registered helpers available in every %rhaidef body:

  • parse_int(s)i64

  • parse_float(s)f64

  • to_hex(n)"0xXX"

Python (monty): compile-once, run-no-limits

monty compiles the function body to bytecode once per call and runs it in a pure-Rust interpreter. This avoids PyO3 and CPython entirely — no dynamic linking, no Python installation required at runtime.

Parameter injection: declared parameters are passed as positional arguments. Store entries are prepended as additional parameters (store keys not in the declared parameter set), so they are visible inside the script as plain variables. Declared params shadow any store key with the same name.

No automatic store write-back: unlike Rhai, the Python store is not written back automatically after the script runs. Use %pyset to persist values explicitly.

Rhai evaluator

PlantUML diagram

File structure

// <<@file weaveback-macro/src/evaluator/rhai_eval.rs>>=
// <<rhai eval preamble>>
// <<rhai evaluator struct>>
// <<rhai evaluator impl>>
// <<dynamic to string>>
// @

// <<@file weaveback-macro/src/evaluator/monty_eval.rs>>=
// <<monty eval preamble>>
// <<monty evaluator struct>>
// <<monty evaluator impl>>
// <<monty object to string>>
// @

Preamble

// <<rhai eval preamble>>=
// crates/weaveback-macro/src/evaluator/rhai_eval.rs

use rhai::{Dynamic, Engine, Scope};
use std::collections::HashMap;
// @

RhaiEvaluator struct

// <<rhai evaluator struct>>=
pub struct RhaiEvaluator {
    engine: Engine,
}

impl Default for RhaiEvaluator {
    fn default() -> Self {
        Self::new()
    }
}
// @

RhaiEvaluator implementation

// <<rhai evaluator impl>>=
impl RhaiEvaluator {
    pub fn new() -> Self {
        let mut engine = Engine::new();
        engine.set_max_operations(100_000);

        engine.register_fn("parse_int", |s: &str| -> i64 {
            s.trim().parse::<i64>().unwrap_or(0)
        });
        engine.register_fn("parse_float", |s: &str| -> f64 {
            s.trim().parse::<f64>().unwrap_or(0.0)
        });
        engine.register_fn("to_hex", |n: i64| -> String { format!("0x{:X}", n) });

        Self { engine }
    }

    /// Evaluate a standalone Rhai expression and return the Dynamic result.
    /// Used by `%rhaiexpr` to initialise store entries with typed literals.
    pub fn eval_expr(&self, expr: &str) -> Result<Dynamic, String> {
        let mut scope = Scope::new();
        self.engine
            .eval_with_scope::<Dynamic>(&mut scope, expr)
            .map_err(|e| e.to_string())
    }

    /// Evaluate a rhaidef script.
    ///
    /// `variables` — weaveback string scope (injected first, lower priority)
    /// `store`     — persistent Rhai store (injected on top of variables)
    ///
    /// After the script runs, every store key whose value changed in the scope
    /// is written back into `store`, preserving full `Dynamic` types (maps,
    /// arrays, integers, …).  New variables set by the script that are not
    /// already in the store are NOT auto-persisted; use `%rhaiset` to
    /// initialise a key before its first use if you want it persisted.
    pub fn evaluate(
        &self,
        code: &str,
        variables: &HashMap<String, String>,
        store: &mut HashMap<String, Dynamic>,
        name: Option<&str>,
    ) -> Result<String, String> {
        let mut scope = Scope::new();

        // Weaveback string variables (lower priority — store values override them)
        for (k, v) in variables {
            if !store.contains_key(k) {
                scope.push_dynamic(k, Dynamic::from(v.clone()));
            }
        }

        // Store values (higher priority, full Dynamic types)
        for (k, v) in store.iter() {
            scope.push_dynamic(k, v.clone());
        }

        let result: Dynamic = self
            .engine
            .eval_with_scope(&mut scope, code)
            .map_err(|e| format!("rhaidef '{}': {}", name.unwrap_or("?"), e))?;

        // Write back any store key whose value was touched by the script
        for key in store.keys().cloned().collect::<Vec<_>>() {
            if let Some(val) = scope.get_value::<Dynamic>(&key) {
                store.insert(key, val);
            }
        }

        Ok(dynamic_to_string(result))
    }
}
// @

dynamic_to_string

String Dynamic values are returned as-is. Unit (Rhai’s ()) maps to an empty string. All other types use their to_string() representation.

// <<dynamic to string>>=
pub fn dynamic_to_string(d: Dynamic) -> String {
    if d.is::<String>() {
        d.cast::<String>()
    } else if d.is_unit() {
        String::new()
    } else {
        d.to_string()
    }
}
// @

Monty evaluator (Python)

PlantUML diagram

Preamble

// <<monty eval preamble>>=
// crates/weaveback-macro/src/evaluator/monty_eval.rs

use monty::{MontyObject, MontyRun};
use std::collections::{HashMap, HashSet};
// @

MontyEvaluator struct

// <<monty evaluator struct>>=
pub struct MontyEvaluator;

impl Default for MontyEvaluator {
    fn default() -> Self {
        Self::new()
    }
}
// @

MontyEvaluator implementation

Store keys are prepended before declared params so that prefix + name works inside Python without explicit parameter declaration. Declared params always shadow same-named store keys because params are appended last and positional binding is left-to-right.

// <<monty evaluator impl>>=
impl MontyEvaluator {
    pub fn new() -> Self {
        Self
    }

    pub fn evaluate(
        &self,
        code: &str,
        params: &[String],
        args: &[String],
        store: &HashMap<String, String>,
        name: Option<&str>,
    ) -> Result<String, String> {
        let macro_name = name.unwrap_or("pydef");

        // Inject store entries as additional parameters that come before the
        // declared params. Declared params shadow any store key with the same name.
        let param_set: HashSet<&str> = params.iter().map(String::as_str).collect();
        let mut all_params: Vec<String> = store
            .keys()
            .filter(|k| !param_set.contains(k.as_str()))
            .cloned()
            .collect();
        all_params.extend_from_slice(params);

        let mut all_args: Vec<MontyObject> = store
            .iter()
            .filter(|(k, _)| !param_set.contains(k.as_str()))
            .map(|(_, v)| MontyObject::String(v.clone()))
            .collect();
        all_args.extend(args.iter().map(|s| MontyObject::String(s.clone())));

        let runner = MontyRun::new(code.to_owned(), &format!("{macro_name}.py"), all_params)
            .map_err(|e| format!("pydef '{macro_name}': compile error: {e:?}"))?;

        let result = runner
            .run_no_limits(all_args)
            .map_err(|e| format!("pydef '{macro_name}': runtime error: {e:?}"))?;

        Ok(monty_object_to_string(result))
    }
}
// @

monty_object_to_string

MontyObject::List items are concatenated without a separator — this matches the behaviour of returning a list of strings from a Python function as if they were a single concatenated string.

// <<monty object to string>>=
fn monty_object_to_string(obj: MontyObject) -> String {
    match obj {
        MontyObject::String(s) => s,
        MontyObject::Int(n) => n.to_string(),
        MontyObject::Float(f) => f.to_string(),
        MontyObject::Bool(b) => {
            if b {
                "true".into()
            } else {
                "false".into()
            }
        }
        MontyObject::None => String::new(),
        MontyObject::List(items) => items
            .into_iter()
            .map(monty_object_to_string)
            .collect::<Vec<_>>()
            .join(""),
        other => format!("{other:?}"),
    }
}
// @