Something went wrong. Try again.
Homebrew 4D Game Engine In Rust, inspired by Engine4D
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383//! Hot-reload support for Lua scripts//!//! Watches the scripts directory for changes and reloads modified modules.//! This module is only fully functional when the `hot-reload` feature is enabled.//!//! # Usage//!//! ```no_run//! use rust4d_scripting::{ScriptEngine, ScriptConfig};//!//! let config = ScriptConfig {//! scripts_dir: "my_game/scripts".to_string(),//! hot_reload: true,//! ..Default::default()//! };//! let mut engine = ScriptEngine::new(config).unwrap();//!//! // In the game loop://! if engine.check_hot_reload() {//! println!("Scripts were reloaded!");//! }//! ```
#[cfg(feature = "hot-reload")]use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};#[cfg(feature = "hot-reload")]use std::collections::HashSet;use std::path::{Path, PathBuf};#[cfg(feature = "hot-reload")]use std::sync::mpsc::{self, Receiver};
use crate::error::ScriptError;use mlua::Lua;
/// Watches a directory for Lua file changes.////// When the `hot-reload` feature is enabled, this uses the `notify` crate/// to watch for file system events. When disabled, all operations are no-ops.pub struct ScriptWatcher { #[cfg(feature = "hot-reload")] _watcher: RecommendedWatcher, #[cfg(feature = "hot-reload")] rx: Receiver<notify::Result<Event>>, scripts_dir: PathBuf,}
impl ScriptWatcher { /// Create a new watcher for the scripts directory. /// /// # Errors /// /// Returns `ScriptError::WatcherError` if the file watcher cannot be created /// or if it fails to watch the scripts directory. #[cfg(feature = "hot-reload")] pub fn new(scripts_dir: impl AsRef<Path>) -> Result<Self, ScriptError> { let (tx, rx) = mpsc::channel(); let scripts_path = scripts_dir.as_ref().to_path_buf();
let mut watcher = RecommendedWatcher::new( move |res| { let _ = tx.send(res); }, notify::Config::default(), ) .map_err(|e| ScriptError::WatcherError(e.to_string()))?;
watcher .watch(&scripts_path, RecursiveMode::Recursive) .map_err(|e| ScriptError::WatcherError(e.to_string()))?;
log::info!( "[scripting] Hot-reload enabled, watching: {}", scripts_path.display() );
Ok(Self { _watcher: watcher, rx, scripts_dir: scripts_path, }) }
/// Stub implementation when hot-reload feature is disabled. #[cfg(not(feature = "hot-reload"))] pub fn new(scripts_dir: impl AsRef<Path>) -> Result<Self, ScriptError> { Ok(Self { scripts_dir: scripts_dir.as_ref().to_path_buf(), }) }
/// Poll for changed .lua files. /// /// Returns a list of paths to Lua files that have been modified or created /// since the last poll. Duplicate events are deduplicated. #[cfg(feature = "hot-reload")] pub fn poll_changes(&self) -> Vec<PathBuf> { let mut changed = HashSet::new();
while let Ok(Ok(event)) = self.rx.try_recv() { match event.kind { EventKind::Modify(_) | EventKind::Create(_) => { for path in event.paths { if path.extension().is_some_and(|ext| ext == "lua") { changed.insert(path); } } } _ => {} } }
changed.into_iter().collect() }
/// Stub implementation when hot-reload feature is disabled. #[cfg(not(feature = "hot-reload"))] pub fn poll_changes(&self) -> Vec<PathBuf> { Vec::new() }
/// Get the scripts directory being watched. pub fn scripts_dir(&self) -> &Path { &self.scripts_dir }}
/// Convert a file path to a Lua module name.////// Given a file path and the scripts directory, returns the module name/// that would be used with `require()` in Lua.////// # Examples////// ```text/// "/path/to/scripts/enemies/rusher.lua" with scripts_dir="/path/to/scripts"/// returns Some("enemies.rusher")/// ```pub fn path_to_module_name(path: &Path, scripts_dir: &Path) -> Option<String> { let relative = path.strip_prefix(scripts_dir).ok()?; let stem = relative.with_extension(""); let parts: Vec<&str> = stem .components() .filter_map(|c| c.as_os_str().to_str()) .collect(); if parts.is_empty() { None } else { Some(parts.join(".")) }}
/// Reload a changed module in the Lua VM.////// This performs the following steps:/// 1. Reads the new source from disk/// 2. Clears the module from `package.loaded`/// 3. Re-executes the module using `eval()` to capture its return value/// 4. Stores the return value in `package.loaded` (so `require()` returns the new version)/// 5. Calls `on_reload()` if it exists (for scripts to handle state migration)////// # Module Return Values////// Lua modules typically return a table of exports. When hot-reloading, we must/// preserve this pattern: the module is evaluated, and its return value is stored/// in `package.loaded[module_name]`. This ensures that existing code holding/// references to the old module table will still work (though they'll see old values),/// while new `require()` calls will get the fresh version.////// # Error Recovery////// If the reload fails (e.g., syntax error), the error is returned but the/// old version of the module continues to run. This allows developers to/// fix errors without crashing the game.////// # Errors////// Returns `ScriptError::IoError` if the file cannot be read, or/// `ScriptError::ModuleReloadError` if the Lua execution fails.pub fn reload_module(lua: &Lua, module_name: &str, file_path: &Path) -> Result<(), ScriptError> { // Read new source let source = std::fs::read_to_string(file_path) .map_err(|e| ScriptError::IoError(file_path.display().to_string(), e))?;
// Clear from package.loaded first (so require() won't return cached version) let clear_code = format!(r#"package.loaded["{}"] = nil"#, module_name); lua.load(&clear_code) .exec() .map_err(ScriptError::LuaError)?;
// Re-execute the module using eval() to capture its return value. // This is important: Lua modules typically return a table of exports, // and we need to store that return value in package.loaded so that // subsequent require() calls get the updated module. let result: mlua::Value = lua .load(&source) .set_name(file_path.to_string_lossy()) .eval() .map_err(|e| ScriptError::ModuleReloadError { path: file_path.display().to_string(), error: e, })?;
// Store the result in package.loaded so require() returns the new version. // If the module returned nil/nothing, we store true (Lua convention for "loaded"). let package: mlua::Table = lua .globals() .get("package") .map_err(ScriptError::LuaError)?; let loaded: mlua::Table = package.get("loaded").map_err(ScriptError::LuaError)?;
let value_to_store = if result.is_nil() { mlua::Value::Boolean(true) } else { result }; loaded .set(module_name, value_to_store) .map_err(ScriptError::LuaError)?;
// Call on_reload() if it exists (silently ignore if not) let _ = lua.load("if on_reload then on_reload() end").exec();
log::info!("[scripting] Reloaded: {}", file_path.display()); Ok(())}
#[cfg(test)]mod tests { use super::*;
#[test] fn test_path_to_module_name_simple() { let scripts_dir = Path::new("/game/scripts");
assert_eq!( path_to_module_name(Path::new("/game/scripts/main.lua"), scripts_dir), Some("main".to_string()) ); }
#[test] fn test_path_to_module_name_nested() { let scripts_dir = Path::new("/game/scripts");
assert_eq!( path_to_module_name(Path::new("/game/scripts/enemies/rusher.lua"), scripts_dir), Some("enemies.rusher".to_string()) ); }
#[test] fn test_path_to_module_name_deeply_nested() { let scripts_dir = Path::new("/game/scripts");
assert_eq!( path_to_module_name( Path::new("/game/scripts/systems/ai/behaviors/patrol.lua"), scripts_dir ), Some("systems.ai.behaviors.patrol".to_string()) ); }
#[test] fn test_path_to_module_name_outside_scripts_dir() { let scripts_dir = Path::new("/game/scripts");
// Path not under scripts_dir assert_eq!( path_to_module_name(Path::new("/other/path/module.lua"), scripts_dir), None ); }
#[test] fn test_reload_module_clears_package_loaded() { let lua = mlua::Lua::new();
// Create a temp file let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("test.lua"); std::fs::write(&path, "test_value = 1").unwrap();
// Load it initially lua.load(std::fs::read_to_string(&path).unwrap()) .exec() .unwrap(); let val: i64 = lua.load("return test_value").eval().unwrap(); assert_eq!(val, 1);
// Change it std::fs::write(&path, "test_value = 2").unwrap();
// Reload reload_module(&lua, "test", &path).unwrap(); let val: i64 = lua.load("return test_value").eval().unwrap(); assert_eq!(val, 2); }
#[test] fn test_reload_module_calls_on_reload() { let lua = mlua::Lua::new();
let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("test.lua"); std::fs::write( &path, r#" reload_called = false function on_reload() reload_called = true end "#, ) .unwrap();
reload_module(&lua, "test", &path).unwrap();
let called: bool = lua.load("return reload_called").eval().unwrap(); assert!(called); }
#[test] fn test_reload_module_handles_missing_on_reload() { let lua = mlua::Lua::new();
let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("test.lua"); std::fs::write(&path, "-- no on_reload function").unwrap();
// Should not error even without on_reload let result = reload_module(&lua, "test", &path); assert!(result.is_ok()); }
#[test] fn test_reload_module_returns_error_on_syntax_error() { let lua = mlua::Lua::new();
let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("bad.lua"); std::fs::write(&path, "this is not valid lua syntax!!!").unwrap();
let result = reload_module(&lua, "bad", &path); assert!(result.is_err()); assert!(matches!(result, Err(ScriptError::ModuleReloadError { .. }))); }
#[test] fn test_reload_module_returns_error_on_missing_file() { let lua = mlua::Lua::new();
let path = Path::new("/nonexistent/path/module.lua"); let result = reload_module(&lua, "module", path); assert!(result.is_err()); assert!(matches!(result, Err(ScriptError::IoError(_, _)))); }
#[cfg(feature = "hot-reload")] #[test] fn test_watcher_creation() { let dir = tempfile::tempdir().unwrap(); let watcher = ScriptWatcher::new(dir.path()); assert!(watcher.is_ok()); }
#[cfg(feature = "hot-reload")] #[test] fn test_watcher_scripts_dir() { let dir = tempfile::tempdir().unwrap(); let watcher = ScriptWatcher::new(dir.path()).unwrap(); assert_eq!(watcher.scripts_dir(), dir.path()); }
#[cfg(not(feature = "hot-reload"))] #[test] fn test_watcher_stub_poll_returns_empty() { let dir = tempfile::tempdir().unwrap(); let watcher = ScriptWatcher::new(dir.path()).unwrap(); assert!(watcher.poll_changes().is_empty()); }}