This repository has been archived on 2025-08-04. You can view files and clone it, but cannot push or open issues or pull requests.
rhaj/rhai_system/tera_integration.md
Timur Gordon 372b7a2772 refactor: Overhaul Rhai scripting with multi-file hot reloading
This commit represents a major refactoring of our Rhai scripting system,
transforming it from a factory-based approach to a more robust system-based
architecture with improved hot reloading capabilities.

Key Changes:
- Renamed package from rhai_factory to rhai_system to better reflect its purpose
- Renamed system_factory.rs to factory.rs for consistency and clarity
- Implemented support for multiple script files in hot reloading
- Added cross-script function calls, allowing functions in one script to call functions in another
- Improved file watching to monitor all script files for changes
- Enhanced error handling for script compilation failures
- Simplified the API with a cleaner create_hot_reloadable_system function
- Removed unused modules (error.rs, factory.rs, hot_reload_old.rs, module_cache.rs, relative_resolver.rs)
- Updated all tests to work with the new architecture

The new architecture:
- Uses a System struct that holds references to script paths and provides a clean API
- Compiles and merges multiple Rhai script files into a single AST
- Automatically detects changes to any script file and recompiles them
- Maintains thread safety with proper synchronization primitives
- Provides better error messages when scripts fail to compile

This refactoring aligns with our BasePathModuleResolver approach for module imports,
making the resolution process more predictable and consistent. The hot reload example
has been updated to demonstrate the new capabilities, showing how to:
1. Load and execute multiple script files
2. Watch for changes to these files
3. Automatically reload scripts when they change
4. Call functions across different script files

All tests are passing, and the example demonstrates the improved functionality.
2025-05-02 21:04:33 +02:00

17 KiB

Tera Engine Factory with Hot Reloadable Rhai Integration

Overview

We'll create a TeraFactory module that provides a factory for creating Tera template engines with integrated Rhai scripting support. The factory will:

  1. Create Tera engines with specified template directories
  2. Integrate with the hot reloadable Rhai AST from the RhaiFactory
  3. Allow Rhai functions to be called from Tera templates
  4. Automatically update available functions when Rhai scripts are hot reloaded

Architecture

graph TD
    A[TeraFactory] --> B[create_tera_engine]
    A --> C[create_tera_with_rhai]
    
    C --> D[Tera Engine with Rhai Functions]
    
    E[RhaiFactory] --> F[HotReloadableAST]
    F --> C
    
    G[RhaiFunctionAdapter] --> D
    H[Template Directories] --> B

Component Details

1. TeraFactory Module Structure

tera_factory/
├── Cargo.toml           # Dependencies including tera and rhai_factory
└── src/
    ├── lib.rs           # Main module exports and unit tests
    ├── factory.rs       # Factory implementation and unit tests
    ├── error.rs         # Custom error types and unit tests
    └── function_adapter.rs  # Rhai function adapter for Tera

2. TeraFactory Implementation

The core factory will provide these main functions:

  1. create_tera_engine(template_dirs) - Creates a basic Tera engine with the specified template directories
  2. create_tera_with_rhai(template_dirs, hot_ast) - Creates a Tera engine with Rhai function integration using a hot reloadable AST

3. RhaiFunctionAdapter

We'll enhance the existing RhaiFunctionAdapter to work with the hot reloadable AST:

/// Thread-safe adapter to use Rhai functions in Tera templates with hot reload support
pub struct RhaiFunctionAdapter {
    fn_name: String,
    hot_ast: Arc<RwLock<AST>>,
}

impl TeraFunction for RhaiFunctionAdapter {
    fn call(&self, args: &HashMap<String, Value>) -> TeraResult<Value> {
        // Convert args from Tera into Rhai's Dynamic
        let mut scope = Scope::new();
        for (key, value) in args {
            // Convert Tera value to Rhai Dynamic
            let dynamic = convert_tera_to_rhai(value);
            scope.push_dynamic(key.clone(), dynamic);
        }

        // Create a new engine for each call
        let engine = Engine::new();
        
        // Get a read lock on the AST
        let ast = self.hot_ast.read().unwrap();
        
        // Call the function using the latest AST
        let result = engine
            .call_fn::<Dynamic>(&mut scope, &ast, &self.fn_name, ())
            .map_err(|e| tera::Error::msg(format!("Rhai error: {}", e)))?;

        // Convert Rhai result to Tera value
        let tera_value = convert_rhai_to_tera(&result);
        Ok(tera_value)
    }
}

4. TeraFactory API

pub struct TeraFactory {
    // Configuration options
}

impl TeraFactory {
    /// Create a new TeraFactory with default settings
    pub fn new() -> Self {
        Self {}
    }
    
    /// Create a Tera engine with the specified template directories
    pub fn create_tera_engine<P: AsRef<Path>>(&self, template_dirs: &[P]) 
        -> Result<Tera, TeraFactoryError> {
        // Create a Tera engine with the specified template directories
    }
    
    /// Create a Tera engine with Rhai function integration
    pub fn create_tera_with_rhai<P: AsRef<Path>>(
        &self,
        template_dirs: &[P],
        hot_ast: Arc<RwLock<AST>>
    ) -> Result<Tera, TeraFactoryError> {
        // Create a Tera engine with Rhai function integration
    }
}

Implementation Details

1. Creating a Tera Engine

impl TeraFactory {
    pub fn create_tera_engine<P: AsRef<Path>>(&self, template_dirs: &[P]) 
        -> Result<Tera, TeraFactoryError> {
        let mut tera = Tera::default();
        
        // Add templates from each directory
        for template_dir in template_dirs {
            let pattern = format!("{}/**/*.html", template_dir.as_ref().display());
            match Tera::parse(&pattern) {
                Ok(parsed_tera) => {
                    tera.extend(&parsed_tera).map_err(|e| {
                        TeraFactoryError::new(format!("Failed to extend Tera with templates: {}", e))
                    })?;
                }
                Err(e) => {
                    // If glob pattern fails, try to find individual HTML files
                    let dir_path = template_dir.as_ref();
                    if let Ok(entries) = std::fs::read_dir(dir_path) {
                        for entry in entries.filter_map(Result::ok) {
                            let path = entry.path();
                            if path.extension().map_or(false, |ext| ext == "html") {
                                let name = path.file_name().unwrap().to_string_lossy().to_string();
                                if let Ok(content) = std::fs::read_to_string(&path) {
                                    tera.add_raw_template(&name, &content).map_err(|e| {
                                        TeraFactoryError::new(format!("Failed to add template {}: {}", name, e))
                                    })?;
                                }
                            }
                        }
                    } else {
                        return Err(TeraFactoryError::new(format!(
                            "Failed to parse templates from {} and could not read directory: {}", 
                            dir_path.display(), e
                        )));
                    }
                }
            }
        }
        
        Ok(tera)
    }
}

2. Integrating with Rhai

impl TeraFactory {
    pub fn create_tera_with_rhai<P: AsRef<Path>>(
        &self,
        template_dirs: &[P],
        hot_ast: Arc<RwLock<AST>>
    ) -> Result<Tera, TeraFactoryError> {
        // Create a basic Tera engine
        let mut tera = self.create_tera_engine(template_dirs)?;
        
        // Get a read lock on the AST to register functions
        let ast = hot_ast.read().unwrap();
        
        // Register all functions from the AST
        for fn_def in ast.iter_functions() {
            let fn_name = fn_def.name.to_string();
            
            // Create an adapter for this function
            let adapter = RhaiFunctionAdapter {
                fn_name: fn_name.clone(),
                hot_ast: Arc::clone(&hot_ast),
            };
            
            // Register the function with Tera
            tera.register_function(&fn_name, adapter);
        }
        
        Ok(tera)
    }
}

3. Error Handling

#[derive(Debug)]
pub struct TeraFactoryError {
    message: String,
    source: Option<Box<dyn Error + Send + Sync>>,
}

impl TeraFactoryError {
    pub fn new(message: impl Into<String>) -> Self {
        Self {
            message: message.into(),
            source: None,
        }
    }
    
    pub fn with_source(mut self, source: impl Error + Send + Sync + 'static) -> Self {
        self.source = Some(Box::new(source));
        self
    }
}

impl fmt::Display for TeraFactoryError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.message)
    }
}

impl Error for TeraFactoryError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        self.source.as_ref().map(|s| s.as_ref() as &(dyn Error + 'static))
    }
}

4. Value Conversion

/// Convert a Tera value to a Rhai Dynamic value
fn convert_tera_to_rhai(value: &Value) -> Dynamic {
    match value {
        Value::Null => Dynamic::UNIT,
        Value::Bool(b) => Dynamic::from(*b),
        Value::Number(n) => {
            if n.is_i64() {
                Dynamic::from(n.as_i64().unwrap())
            } else if n.is_u64() {
                Dynamic::from(n.as_u64().unwrap())
            } else {
                Dynamic::from(n.as_f64().unwrap())
            }
        },
        Value::String(s) => Dynamic::from(s.clone()),
        Value::Array(arr) => {
            let mut rhai_array = Vec::new();
            for item in arr {
                rhai_array.push(convert_tera_to_rhai(item));
            }
            Dynamic::from(rhai_array)
        },
        Value::Object(obj) => {
            let mut rhai_map = rhai::Map::new();
            for (key, value) in obj {
                rhai_map.insert(key.clone().into(), convert_tera_to_rhai(value));
            }
            Dynamic::from(rhai_map)
        },
    }
}

/// Convert a Rhai Dynamic value to a Tera value
fn convert_rhai_to_tera(value: &Dynamic) -> Value {
    if value.is_unit() {
        Value::Null
    } else if value.is_bool() {
        Value::Bool(value.as_bool().unwrap())
    } else if value.is_i64() {
        Value::Number(serde_json::Number::from(value.as_i64().unwrap()))
    } else if value.is_f64() {
        // This is a bit tricky as serde_json::Number doesn't have a direct from_f64
        let f = value.as_f64().unwrap();
        serde_json::to_value(f).unwrap()
    } else if value.is_string() {
        Value::String(value.to_string())
    } else if value.is_array() {
        let arr = value.clone().into_array().unwrap();
        let mut tera_array = Vec::new();
        for item in arr {
            tera_array.push(convert_rhai_to_tera(&item));
        }
        Value::Array(tera_array)
    } else if value.is_map() {
        let map = value.clone().into_map().unwrap();
        let mut tera_object = serde_json::Map::new();
        for (key, value) in map {
            tera_object.insert(key.to_string(), convert_rhai_to_tera(&value));
        }
        Value::Object(tera_object)
    } else {
        // For any other type, convert to string
        Value::String(value.to_string())
    }
}

Testing Strategy

1. Unit Tests

#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn create_tera_engine_with_valid_directories() {
        let factory = TeraFactory::new();
        let template_dirs = vec!["tests/templates"];
        
        let result = factory.create_tera_engine(&template_dirs);
        assert!(result.is_ok());
        
        let tera = result.unwrap();
        assert!(tera.get_template_names().count() > 0);
    }
    
    #[test]
    fn create_tera_with_rhai_registers_functions() {
        let rhai_factory = Arc::new(RhaiFactory::with_caching());
        let tera_factory = TeraFactory::new();
        
        // Compile a script with a simple function
        let script = "fn sum(a, b) { a + b }";
        let engine = rhai_factory.create_engine();
        let ast = engine.compile(script).unwrap();
        let hot_ast = Arc::new(RwLock::new(ast));
        
        // Create a Tera engine with Rhai integration
        let template_dirs = vec!["tests/templates"];
        let result = tera_factory.create_tera_with_rhai(&template_dirs, hot_ast);
        assert!(result.is_ok());
        
        // Verify the function is registered
        let tera = result.unwrap();
        let mut context = tera::Context::new();
        context.insert("a", &10);
        context.insert("b", &32);
        
        let rendered = tera.render("function_test.html", &context).unwrap();
        assert_eq!(rendered.trim(), "42");
    }
    
    #[test]
    fn hot_reload_updates_functions() {
        let rhai_factory = Arc::new(RhaiFactory::with_caching());
        let tera_factory = TeraFactory::new();
        
        // Create a temporary script file
        let temp_dir = tempfile::tempdir().unwrap();
        let script_path = temp_dir.path().join("test.rhai");
        std::fs::write(&script_path, "fn sum(a, b) { a + b }").unwrap();
        
        // Compile the script
        let ast = rhai_factory.compile_modules(&[&script_path], None).unwrap();
        let hot_ast = Arc::new(RwLock::new(ast));
        
        // Enable hot reloading
        let handle = rhai_factory.enable_hot_reload(
            hot_ast.clone(),
            &[&script_path],
            None,
            None
        ).unwrap();
        
        // Create a Tera engine with Rhai integration
        let template_dirs = vec!["tests/templates"];
        let tera = tera_factory.create_tera_with_rhai(&template_dirs, hot_ast.clone()).unwrap();
        
        // Render the template with the initial function
        let mut context = tera::Context::new();
        context.insert("a", &10);
        context.insert("b", &32);
        let rendered = tera.render("function_test.html", &context).unwrap();
        assert_eq!(rendered.trim(), "42");
        
        // Modify the script to change the function
        std::fs::write(&script_path, "fn sum(a, b) { (a + b) * 2 }").unwrap();
        
        // Wait for the file system to register the change
        std::thread::sleep(std::time::Duration::from_millis(100));
        
        // Check for changes
        rhai_factory.check_for_changes().unwrap();
        
        // Render the template again with the updated function
        let rendered = tera.render("function_test.html", &context).unwrap();
        assert_eq!(rendered.trim(), "84");
        
        // Disable hot reloading
        rhai_factory.disable_hot_reload(handle);
    }
}

2. Integration Tests

#[test]
fn integration_test_tera_with_hot_reloadable_rhai() {
    // Create the factories
    let rhai_factory = Arc::new(RhaiFactory::with_caching());
    let tera_factory = TeraFactory::new();
    
    // Set up test directories
    let scripts_dir = PathBuf::from("tests/rhai_scripts");
    let templates_dir = PathBuf::from("tests/templates");
    
    // Compile the initial script
    let script_path = scripts_dir.join("math.rhai");
    let ast = rhai_factory.compile_modules(&[&script_path], Some(&scripts_dir)).unwrap();
    let hot_ast = Arc::new(RwLock::new(ast));
    
    // Enable hot reloading
    let handle = rhai_factory.enable_hot_reload(
        hot_ast.clone(),
        &[&script_path],
        Some(&scripts_dir),
        None
    ).unwrap();
    
    // Create a Tera engine with Rhai integration
    let tera = tera_factory.create_tera_with_rhai(&[&templates_dir], hot_ast.clone()).unwrap();
    
    // Render the template with the initial function
    let mut context = tera::Context::new();
    context.insert("a", &20);
    context.insert("b", &22);
    let rendered = tera.render("math.html", &context).unwrap();
    assert_eq!(rendered.trim(), "42");
    
    // Modify the script to change the function
    let modified_script = r#"
    fn sum(a, b) {
        // Return twice the sum
        (a + b) * 2
    }
    "#;
    std::fs::write(&script_path, modified_script).unwrap();
    
    // Wait for the file system to register the change
    std::thread::sleep(std::time::Duration::from_millis(100));
    
    // Check for changes
    rhai_factory.check_for_changes().unwrap();
    
    // Render the template again with the updated function
    let rendered = tera.render("math.html", &context).unwrap();
    assert_eq!(rendered.trim(), "84");
    
    // Disable hot reloading
    rhai_factory.disable_hot_reload(handle);
}

Example Usage

Here's a complete example of how to use the TeraFactory with hot reloadable Rhai integration:

use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use rhai_factory::{RhaiFactory, HotReloadableAST};
use tera_factory::TeraFactory;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Create the factories
    let rhai_factory = Arc::new(RhaiFactory::with_caching());
    let tera_factory = TeraFactory::new();
    
    // Set up directories
    let scripts_dir = PathBuf::from("scripts");
    let templates_dir = PathBuf::from("templates");
    
    // Compile the initial script
    let script_path = scripts_dir.join("math.rhai");
    let ast = rhai_factory.compile_modules(&[&script_path], Some(&scripts_dir))?;
    let hot_ast = Arc::new(RwLock::new(ast));
    
    // Enable hot reloading
    let handle = rhai_factory.enable_hot_reload(
        hot_ast.clone(),
        &[&script_path],
        Some(&scripts_dir),
        Some(Box::new(|| println!("Script reloaded!")))
    )?;
    
    // Create a Tera engine with Rhai integration
    let tera = tera_factory.create_tera_with_rhai(&[&templates_dir], hot_ast.clone())?;
    
    // Application loop
    loop {
        // Check for script changes
        rhai_factory.check_for_changes()?;
        
        // Render a template
        let mut context = tera::Context::new();
        context.insert("a", &20);
        context.insert("b", &22);
        let rendered = tera.render("math.html", &context)?;
        println!("Rendered template: {}", rendered);
        
        // Wait a bit before checking again
        std::thread::sleep(std::time::Duration::from_secs(1));
        
        // In a real application, you would break out of this loop when done
    }
    
    // Disable hot reloading when done
    rhai_factory.disable_hot_reload(handle);
    
    Ok(())
}

Test Files

1. Rhai Script (tests/rhai_scripts/math.rhai)

// Initial version of the sum function
fn sum(a, b) {
    a + b
}

2. Tera Template (tests/templates/math.html)

{{ sum(a, b) }}

Conclusion

The TeraFactory with hot reloadable Rhai integration provides a powerful way to create dynamic templates that can call Rhai functions. The hot reload feature allows these functions to be updated without restarting the application, making it ideal for development environments and systems where behavior needs to be modified dynamically.

By leveraging the hot reload feature of the RhaiFactory, the TeraFactory can automatically update the available functions when Rhai scripts change, ensuring that templates always use the latest version of the functions.