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.
19 KiB
19 KiB
Rhai Factory Project Structure
This document outlines the structure and content of the implementation files for the Rhai Factory project.
Directory Structure
rhai_factory/
├── Cargo.toml
├── src/
│ ├── lib.rs
│ ├── factory.rs
│ ├── error.rs
│ └── module_cache.rs
└── tests/
├── common/
│ └── mod.rs
├── integration_tests.rs
└── rhai_scripts/
├── main.rhai
├── module1.rhai
└── module2.rhai
File Contents
Cargo.toml
[package]
name = "rhai_factory"
version = "0.1.0"
edition = "2021"
authors = ["Your Name <your.email@example.com>"]
description = "A thread-safe factory for creating and managing Rhai script engines"
repository = "https://github.com/yourusername/rhai_factory"
license = "MIT"
readme = "README.md"
[dependencies]
rhai = { version = "1.15.0", features = ["sync"] }
thiserror = "1.0"
log = "0.4"
[dev-dependencies]
tokio = { version = "1.28", features = ["full"] }
tempfile = "3.5"
src/lib.rs
//! A thread-safe factory for creating and managing Rhai script engines.
//!
//! This crate provides a factory for creating thread-safe Rhai engines with
//! pre-compiled scripts. It handles module imports, provides detailed error
//! information, and ensures thread safety.
mod factory;
mod error;
mod module_cache;
pub use factory::RhaiFactory;
pub use error::RhaiFactoryError;
/// Re-export commonly used Rhai types for convenience
pub use rhai::{Engine, AST, Scope, Module};
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
// Test fixture for common setup
struct TestFixture {
factory: RhaiFactory,
}
impl TestFixture {
fn new() -> Self {
Self {
factory: RhaiFactory::new(),
}
}
fn with_caching() -> Self {
Self {
factory: RhaiFactory::with_caching(),
}
}
}
#[test]
fn engine_can_evaluate_simple_expressions() {
let fixture = TestFixture::new();
let engine = fixture.factory.create_engine();
let result: i64 = engine.eval("40 + 2").unwrap();
assert_eq!(result, 42);
}
#[test]
fn factory_creates_thread_safe_engine() {
let fixture = TestFixture::new();
let engine = fixture.factory.create_engine();
// This test verifies that the engine can be sent between threads
std::thread::spawn(move || {
let result: i64 = engine.eval("40 + 2").unwrap();
assert_eq!(result, 42);
}).join().unwrap();
}
#[test]
fn module_cache_improves_performance() {
let fixture_no_cache = TestFixture::new();
let fixture_with_cache = TestFixture::with_caching();
// First compilation without cache
let start = std::time::Instant::now();
let _ = fixture_no_cache.factory.compile_modules(
&[Path::new("tests/rhai_scripts/main.rhai")],
Some(Path::new(".")),
).unwrap();
let no_cache_time = start.elapsed();
// First compilation with cache
let start = std::time::Instant::now();
let _ = fixture_with_cache.factory.compile_modules(
&[Path::new("tests/rhai_scripts/main.rhai")],
Some(Path::new(".")),
).unwrap();
let first_cache_time = start.elapsed();
// Second compilation with cache should be faster
let start = std::time::Instant::now();
let _ = fixture_with_cache.factory.compile_modules(
&[Path::new("tests/rhai_scripts/main.rhai")],
Some(Path::new(".")),
).unwrap();
let second_cache_time = start.elapsed();
// The second compilation with cache should be faster than the first
assert!(second_cache_time < first_cache_time);
}
}
src/factory.rs
//! Implementation of the RhaiFactory.
use rhai::{Engine, AST, Scope, Module, EvalAltResult};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::error::RhaiFactoryError;
use crate::module_cache::ModuleCache;
/// A factory for creating thread-safe Rhai engines with pre-compiled scripts.
pub struct RhaiFactory {
/// Optional module cache for improved performance
module_cache: Option<ModuleCache>,
}
impl RhaiFactory {
/// Create a new RhaiFactory with default settings.
pub fn new() -> Self {
Self {
module_cache: None,
}
}
/// Create a new RhaiFactory with module caching enabled.
pub fn with_caching() -> Self {
Self {
module_cache: Some(ModuleCache::new()),
}
}
/// Create a thread-safe Rhai engine.
pub fn create_engine(&self) -> Engine {
let mut engine = Engine::new();
// Configure the engine for thread safety
// The sync feature ensures the engine is Send + Sync
engine
}
/// Compile a list of Rhai modules into a self-contained AST.
///
/// # Arguments
///
/// * `module_paths` - A list of paths to Rhai script modules
/// * `base_path` - An optional base path for resolving relative module paths
///
/// # Returns
///
/// A Result containing either the compiled AST or a RhaiFactoryError
pub fn compile_modules<P: AsRef<Path>>(&self, module_paths: &[P], base_path: Option<P>)
-> Result<AST, RhaiFactoryError> {
// Implementation details...
// 1. Create a new engine
// 2. Set up a file module resolver with the base path
// 3. Compile the main module
// 4. Compile into a self-contained AST to handle imports
// 5. Return the compiled AST
}
/// Create an engine with pre-compiled modules.
///
/// # Arguments
///
/// * `module_paths` - A list of paths to Rhai script modules
/// * `base_path` - An optional base path for resolving relative module paths
///
/// # Returns
///
/// A Result containing either a tuple of (Engine, AST) or a RhaiFactoryError
pub fn create_engine_with_modules<P: AsRef<Path>>(&self, module_paths: &[P], base_path: Option<P>)
-> Result<(Engine, AST), RhaiFactoryError> {
// Implementation details...
// 1. Create a new engine
// 2. Compile the modules
// 3. Return the engine and compiled AST
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn compile_modules_handles_single_module() {
let factory = RhaiFactory::new();
let result = factory.compile_modules(
&[Path::new("tests/rhai_scripts/module2.rhai")],
Some(Path::new(".")),
);
assert!(result.is_ok());
let ast = result.unwrap();
let engine = factory.create_engine();
// Verify the module was compiled correctly
let scope = Scope::new();
let result = engine.eval_ast_with_scope::<()>(&mut scope.clone(), &ast);
assert!(result.is_ok());
// Verify the function was defined
let result = engine.call_fn::<i64>(&mut scope, &ast, "multiply", (6, 7));
assert!(result.is_ok());
assert_eq!(result.unwrap(), 42);
}
#[test]
fn compile_modules_handles_module_imports() {
let factory = RhaiFactory::new();
let result = factory.compile_modules(
&[Path::new("tests/rhai_scripts/main.rhai")],
Some(Path::new(".")),
);
assert!(result.is_ok());
let ast = result.unwrap();
let engine = factory.create_engine();
let result: i64 = engine.eval_ast(&ast).unwrap();
assert_eq!(result, 42);
}
#[test]
fn create_engine_with_modules_returns_usable_engine_and_ast() {
let factory = RhaiFactory::new();
let result = factory.create_engine_with_modules(
&[Path::new("tests/rhai_scripts/main.rhai")],
Some(Path::new(".")),
);
assert!(result.is_ok());
let (engine, ast) = result.unwrap();
let result: i64 = engine.eval_ast(&ast).unwrap();
assert_eq!(result, 42);
}
}
src/error.rs
//! Error types for the RhaiFactory.
use std::error::Error;
use std::fmt;
use std::path::PathBuf;
/// Error type for RhaiFactory operations.
#[derive(Debug)]
pub struct RhaiFactoryError {
/// Path to the module that caused the error, if any
module_path: Option<PathBuf>,
/// Error message
message: String,
/// Source error, if any
source: Option<Box<dyn Error + Send + Sync>>,
}
impl RhaiFactoryError {
/// Create a new RhaiFactoryError with the given message.
pub fn new(message: impl Into<String>) -> Self {
Self {
module_path: None,
message: message.into(),
source: None,
}
}
/// Add a module path to the error.
pub fn with_module(mut self, module_path: impl Into<PathBuf>) -> Self {
self.module_path = Some(module_path.into());
self
}
/// Add a source error to the error.
pub fn with_source(mut self, source: impl Error + Send + Sync + 'static) -> Self {
self.source = Some(Box::new(source));
self
}
}
impl fmt::Display for RhaiFactoryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(ref path) = self.module_path {
write!(f, "Error in module '{}': {}", path.display(), self.message)
} else {
write!(f, "{}", self.message)
}
}
}
impl Error for RhaiFactoryError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
self.source.as_ref().map(|s| s.as_ref() as &(dyn Error + 'static))
}
}
/// Convert Rhai's EvalAltResult to RhaiFactoryError.
impl From<Box<rhai::EvalAltResult>> for RhaiFactoryError {
fn from(err: Box<rhai::EvalAltResult>) -> Self {
RhaiFactoryError::new(format!("Rhai evaluation error: {}", err))
.with_source(RhaiEvalError(err))
}
}
/// Wrapper for Rhai's EvalAltResult to implement Error.
#[derive(Debug)]
struct RhaiEvalError(Box<rhai::EvalAltResult>);
impl fmt::Display for RhaiEvalError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl Error for RhaiEvalError {}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Error as IoError, ErrorKind};
#[test]
fn error_displays_module_path_when_available() {
let error = RhaiFactoryError::new("test error")
.with_module("test/path.rhai");
assert_eq!(
format!("{}", error),
"Error in module 'test/path.rhai': test error"
);
}
#[test]
fn error_displays_message_without_module_path() {
let error = RhaiFactoryError::new("test error");
assert_eq!(format!("{}", error), "test error");
}
#[test]
fn error_preserves_source_error() {
let io_error = IoError::new(ErrorKind::NotFound, "file not found");
let error = RhaiFactoryError::new("test error")
.with_source(io_error);
assert!(error.source().is_some());
assert_eq!(
error.source().unwrap().to_string(),
"file not found"
);
}
}
src/module_cache.rs
//! Module caching for improved performance.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use rhai::AST;
/// A cache for compiled Rhai modules.
pub struct ModuleCache {
/// Map of module paths to compiled ASTs
cache: Arc<Mutex<HashMap<PathBuf, Arc<AST>>>>,
}
impl ModuleCache {
/// Create a new empty module cache.
pub fn new() -> Self {
Self {
cache: Arc::new(Mutex::new(HashMap::new())),
}
}
/// Get a cached AST for the given module path, if available.
pub fn get<P: AsRef<Path>>(&self, path: P) -> Option<Arc<AST>> {
let path = path.as_ref().to_path_buf();
let cache = self.cache.lock().unwrap();
cache.get(&path).cloned()
}
/// Store an AST in the cache for the given module path.
pub fn put<P: AsRef<Path>>(&self, path: P, ast: AST) -> Arc<AST> {
let path = path.as_ref().to_path_buf();
let ast = Arc::new(ast);
let mut cache = self.cache.lock().unwrap();
cache.insert(path, ast.clone());
ast
}
/// Clear the cache.
pub fn clear(&self) {
let mut cache = self.cache.lock().unwrap();
cache.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
use rhai::{Engine, Scope};
#[test]
fn cache_stores_and_retrieves_ast() {
let cache = ModuleCache::new();
let engine = Engine::new();
let ast = engine.compile("40 + 2").unwrap();
let path = PathBuf::from("test.rhai");
// Store the AST in the cache
let cached_ast = cache.put(&path, ast);
// Retrieve the AST from the cache
let retrieved_ast = cache.get(&path);
assert!(retrieved_ast.is_some());
// Verify the retrieved AST works correctly
let retrieved_ast = retrieved_ast.unwrap();
let result: i64 = engine.eval_ast(&retrieved_ast).unwrap();
assert_eq!(result, 42);
}
#[test]
fn cache_returns_none_for_missing_ast() {
let cache = ModuleCache::new();
let path = PathBuf::from("nonexistent.rhai");
let retrieved_ast = cache.get(&path);
assert!(retrieved_ast.is_none());
}
#[test]
fn cache_clear_removes_all_entries() {
let cache = ModuleCache::new();
let engine = Engine::new();
// Add multiple ASTs to the cache
let ast1 = engine.compile("40 + 2").unwrap();
let ast2 = engine.compile("50 + 3").unwrap();
cache.put("test1.rhai", ast1);
cache.put("test2.rhai", ast2);
// Verify the ASTs are in the cache
assert!(cache.get("test1.rhai").is_some());
assert!(cache.get("test2.rhai").is_some());
// Clear the cache
cache.clear();
// Verify the ASTs are no longer in the cache
assert!(cache.get("test1.rhai").is_none());
assert!(cache.get("test2.rhai").is_none());
}
}
tests/common/mod.rs
//! Common utilities for integration tests.
use rhai_factory::RhaiFactory;
use std::path::{Path, PathBuf};
/// Test fixture for integration tests.
pub struct TestFixture {
pub factory: RhaiFactory,
pub scripts_dir: PathBuf,
}
impl TestFixture {
/// Create a new test fixture.
pub fn new() -> Self {
let factory = RhaiFactory::new();
let scripts_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("rhai_scripts");
Self {
factory,
scripts_dir,
}
}
/// Get the path to a test script.
pub fn script_path(&self, name: &str) -> PathBuf {
self.scripts_dir.join(name)
}
}
tests/integration_tests.rs
//! Integration tests for the RhaiFactory.
mod common;
use common::TestFixture;
use rhai_factory::{RhaiFactory, RhaiFactoryError};
use std::path::Path;
#[test]
fn factory_compiles_and_runs_scripts() {
let fixture = TestFixture::new();
// Compile the main script
let result = fixture.factory.compile_modules(
&[fixture.script_path("main.rhai")],
Some(&fixture.scripts_dir),
);
assert!(result.is_ok());
// Run the compiled script
let ast = result.unwrap();
let engine = fixture.factory.create_engine();
let result: i64 = engine.eval_ast(&ast).unwrap();
assert_eq!(result, 42);
}
#[test]
fn factory_handles_recursive_imports() {
let fixture = TestFixture::new();
// Compile the main script which imports module1, which imports module2
let result = fixture.factory.compile_modules(
&[fixture.script_path("main.rhai")],
Some(&fixture.scripts_dir),
);
assert!(result.is_ok());
// Run the compiled script
let ast = result.unwrap();
let engine = fixture.factory.create_engine();
let result: i64 = engine.eval_ast(&ast).unwrap();
assert_eq!(result, 42);
}
#[test]
fn factory_provides_detailed_error_for_missing_module() {
let fixture = TestFixture::new();
// Try to compile a non-existent script
let result = fixture.factory.compile_modules(
&[fixture.script_path("non_existent.rhai")],
Some(&fixture.scripts_dir),
);
assert!(result.is_err());
// Verify the error contains the module path
let err = result.unwrap_err();
assert!(format!("{}", err).contains("non_existent.rhai"));
}
#[test]
fn factory_creates_thread_safe_engine_and_ast() {
let fixture = TestFixture::new();
// Compile the main script
let result = fixture.factory.compile_modules(
&[fixture.script_path("main.rhai")],
Some(&fixture.scripts_dir),
);
assert!(result.is_ok());
let ast = result.unwrap();
let engine = fixture.factory.create_engine();
// Verify the engine and AST can be sent to another thread
let handle = std::thread::spawn(move || {
let result: i64 = engine.eval_ast(&ast).unwrap();
result
});
let result = handle.join().unwrap();
assert_eq!(result, 42);
}
tests/rhai_scripts/main.rhai
// Import the module1 module
import "module1" as m1;
// Call a function from the imported module
let result = m1::add(40, 2);
// Return the result
result
tests/rhai_scripts/module1.rhai
// Import the module2 module
import "module2" as m2;
// Define a function that uses a function from module2
fn add(a, b) {
// Call the multiply function from module2
let product = m2::multiply(a, 1);
// Add b to the product
product + b
}
tests/rhai_scripts/module2.rhai
// Define a function that multiplies two numbers
fn multiply(a, b) {
a * b
}
Implementation Notes
- The
sync
feature of Rhai is used to ensure thread safety - Module compilation uses the
compile_into_self_contained
method to handle imports - Error handling provides detailed information about which module failed to import and why
- Module caching is optional but can improve performance when repeatedly using the same modules
- Tests follow Rust's standard approach with unit tests in each module and integration tests in the tests directory
Next Steps
To implement this project:
- Create the directory structure as outlined above
- Create the implementation files with the provided content
- Run the tests to verify that everything works as expected
- Add additional features or optimizations as needed