Skip to main content

hx_plugins/
engine.rs

1//! Steel Scheme engine wrapper and PluginSystem trait.
2
3use crate::commands::CustomCommand;
4use crate::config::PluginConfig;
5use crate::context::{ContextGuard, PluginContext};
6use crate::error::{PluginError, Result};
7use crate::hooks::{HookEvent, HookResult};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use std::time::Instant;
11use steel::steel_vm::engine::Engine;
12use tracing::{debug, info, warn};
13
14/// Trait for plugin system implementations.
15///
16/// This abstraction allows for potential future alternative runtimes
17/// while providing a consistent interface.
18pub trait PluginSystem {
19    /// Initialize the plugin system.
20    fn initialize(&mut self) -> Result<()>;
21
22    /// Load a plugin from a file path.
23    fn load_plugin(&mut self, path: &Path) -> Result<()>;
24
25    /// Run hooks for an event.
26    fn run_hook(&mut self, event: HookEvent, ctx: &PluginContext) -> Result<HookResult>;
27
28    /// Run a custom command.
29    fn run_command(&mut self, name: &str, args: &[String]) -> Result<i32>;
30
31    /// Register the hx API functions.
32    fn register_api(&mut self) -> Result<()>;
33
34    /// Get registered custom commands.
35    fn commands(&self) -> &HashMap<String, CustomCommand>;
36}
37
38/// Steel Scheme-based plugin engine.
39pub struct SteelEngine {
40    /// The Steel VM engine.
41    engine: Engine,
42
43    /// Loaded plugin scripts.
44    loaded_plugins: Vec<PathBuf>,
45
46    /// Registered custom commands.
47    commands: HashMap<String, CustomCommand>,
48
49    /// Plugin configuration.
50    config: PluginConfig,
51}
52
53impl SteelEngine {
54    /// Create a new Steel engine with the given configuration.
55    pub fn new(config: PluginConfig) -> Self {
56        SteelEngine {
57            engine: Engine::new(),
58            loaded_plugins: Vec::new(),
59            commands: HashMap::new(),
60            config,
61        }
62    }
63
64    /// Create a new Steel engine with default configuration.
65    pub fn with_defaults() -> Self {
66        Self::new(PluginConfig::new())
67    }
68
69    /// Get the plugin configuration.
70    pub fn config(&self) -> &PluginConfig {
71        &self.config
72    }
73
74    /// Check if a plugin is already loaded.
75    pub fn is_loaded(&self, path: &Path) -> bool {
76        self.loaded_plugins.iter().any(|p| p == path)
77    }
78
79    /// Get the list of loaded plugins.
80    pub fn loaded_plugins(&self) -> &[PathBuf] {
81        &self.loaded_plugins
82    }
83
84    /// Evaluate Scheme code (takes ownership of the string).
85    pub fn eval(&mut self, code: String) -> Result<()> {
86        self.engine
87            .run(code)
88            .map_err(|e| PluginError::runtime("eval", e.to_string()))?;
89        Ok(())
90    }
91
92    /// Load and evaluate a file.
93    fn load_file(&mut self, path: &Path) -> Result<()> {
94        let content = std::fs::read_to_string(path).map_err(|e| {
95            PluginError::io(format!("failed to read plugin file: {}", path.display()), e)
96        })?;
97
98        self.engine.run(content).map_err(|e| {
99            PluginError::load(path.to_path_buf(), format!("Steel evaluation error: {}", e))
100        })?;
101
102        Ok(())
103    }
104
105    /// Check if a function is defined in the engine.
106    fn has_function(&mut self, name: &str) -> bool {
107        engine_has_function(&mut self.engine, name)
108    }
109
110    /// Call a function with no arguments.
111    fn call_function(&mut self, name: &str) -> Result<()> {
112        engine_call_function(&mut self.engine, name)
113    }
114}
115
116/// Check if a function is defined in an engine.
117fn engine_has_function(engine: &mut Engine, name: &str) -> bool {
118    let check_code = format!("(if (defined? '{}) #t #f)", name);
119    match engine.run(check_code) {
120        Ok(results) => {
121            if let Some(result) = results.into_iter().next() {
122                matches!(result, steel::SteelVal::BoolV(true))
123            } else {
124                false
125            }
126        }
127        Err(_) => false,
128    }
129}
130
131/// Call a function with no arguments in an engine.
132fn engine_call_function(engine: &mut Engine, name: &str) -> Result<()> {
133    let call_code = format!("({})", name);
134    engine
135        .run(call_code)
136        .map_err(|e| PluginError::runtime(name, e.to_string()))?;
137    Ok(())
138}
139
140/// Create a fresh engine with the hx API and prelude loaded.
141fn new_initialized_engine() -> Result<Engine> {
142    let mut engine = Engine::new();
143    crate::api::register_all(&mut engine)?;
144    engine
145        .run(include_str!("prelude.scm").to_string())
146        .map_err(|e| PluginError::runtime("prelude", e.to_string()))?;
147    Ok(engine)
148}
149
150/// Run hook scripts in a fresh engine on the current thread.
151///
152/// Used by the timeout-enforcing path: the worker thread cannot share the
153/// main engine (it is not thread-safe), so the scripts are loaded into an
154/// isolated engine with the same API and prelude.
155fn run_hook_isolated(
156    scripts: &[PathBuf],
157    hook_fn: &str,
158    ctx: &PluginContext,
159    continue_on_error: bool,
160) -> Result<HookResult> {
161    let _guard = ContextGuard::new(ctx.clone());
162    let start = Instant::now();
163    let mut engine = new_initialized_engine()?;
164
165    for path in scripts {
166        let content = std::fs::read_to_string(path).map_err(|e| {
167            PluginError::io(format!("failed to read plugin file: {}", path.display()), e)
168        })?;
169        engine.run(content).map_err(|e| {
170            PluginError::load(path.clone(), format!("Steel evaluation error: {}", e))
171        })?;
172
173        if engine_has_function(&mut engine, hook_fn) {
174            match engine_call_function(&mut engine, hook_fn) {
175                Ok(()) => debug!("Hook {} completed successfully", hook_fn),
176                Err(e) => {
177                    warn!("Hook {} failed: {}", hook_fn, e);
178                    if !continue_on_error {
179                        return Ok(HookResult::failure(start.elapsed(), e.to_string()));
180                    }
181                }
182            }
183        }
184    }
185
186    Ok(HookResult::success(start.elapsed()))
187}
188
189impl PluginSystem for SteelEngine {
190    fn initialize(&mut self) -> Result<()> {
191        info!("Initializing Steel plugin engine");
192
193        // Register the hx API
194        self.register_api()?;
195
196        // Load prelude/standard library if needed
197        let prelude = include_str!("prelude.scm").to_string();
198        self.eval(prelude)?;
199
200        debug!("Steel engine initialized");
201        Ok(())
202    }
203
204    fn load_plugin(&mut self, path: &Path) -> Result<()> {
205        if !path.exists() {
206            return Err(PluginError::not_found(path.to_path_buf()));
207        }
208
209        if self.is_loaded(path) {
210            debug!("Plugin already loaded: {}", path.display());
211            return Ok(());
212        }
213
214        info!("Loading plugin: {}", path.display());
215        self.load_file(path)?;
216        self.loaded_plugins.push(path.to_path_buf());
217
218        Ok(())
219    }
220
221    fn run_hook(&mut self, event: HookEvent, ctx: &PluginContext) -> Result<HookResult> {
222        // Clone the scripts to avoid borrow checker issues
223        let scripts: Vec<String> = self.config.scripts_for_hook(event).to_vec();
224
225        if scripts.is_empty() {
226            return Ok(HookResult::skipped());
227        }
228
229        debug!("Running {} hook with {} scripts", event, scripts.len());
230
231        let project_root = ctx.project_root.clone();
232
233        // Resolve all script paths up front
234        let mut resolved = Vec::with_capacity(scripts.len());
235        for script in &scripts {
236            resolved.push(self.find_script(script, &project_root)?);
237        }
238
239        let timeout = self.config.hook_timeout();
240        let hook_fn = event.scheme_function();
241
242        // hook_timeout_ms = 0 disables the timeout: run in the shared engine
243        if timeout.is_zero() {
244            let _guard = ContextGuard::new(ctx.clone());
245            let start = Instant::now();
246
247            for script_path in &resolved {
248                if !self.is_loaded(script_path) {
249                    self.load_plugin(script_path)?;
250                }
251
252                if self.has_function(hook_fn) {
253                    match self.call_function(hook_fn) {
254                        Ok(()) => {
255                            debug!("Hook {} completed successfully", hook_fn);
256                        }
257                        Err(e) => {
258                            let duration = start.elapsed();
259                            warn!("Hook {} failed: {}", hook_fn, e);
260
261                            if !self.config.continue_on_error {
262                                return Ok(HookResult::failure(duration, e.to_string()));
263                            }
264                        }
265                    }
266                }
267            }
268
269            return Ok(HookResult::success(start.elapsed()));
270        }
271
272        // Enforce the timeout by running the hook on a worker thread with its
273        // own engine; a hung script cannot stall the command indefinitely.
274        let (tx, rx) = std::sync::mpsc::channel();
275        let ctx_clone = ctx.clone();
276        let hook_fn = hook_fn.to_string();
277        let continue_on_error = self.config.continue_on_error;
278        let start = Instant::now();
279
280        std::thread::Builder::new()
281            .name("hx-plugin-hook".into())
282            .spawn(move || {
283                let result = run_hook_isolated(&resolved, &hook_fn, &ctx_clone, continue_on_error);
284                let _ = tx.send(result);
285            })
286            .map_err(|e| PluginError::io("failed to spawn hook thread".to_string(), e))?;
287
288        match rx.recv_timeout(timeout) {
289            Ok(result) => result,
290            Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
291                warn!(
292                    "{} hook timed out after {}ms; abandoning hook thread",
293                    event,
294                    timeout.as_millis()
295                );
296                Ok(HookResult::failure(
297                    start.elapsed(),
298                    format!(
299                        "hook timed out after {}ms (configure [plugins].hook_timeout_ms to adjust)",
300                        timeout.as_millis()
301                    ),
302                ))
303            }
304            Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(PluginError::runtime(
305                event.scheme_function(),
306                "hook thread terminated unexpectedly".to_string(),
307            )),
308        }
309    }
310
311    fn run_command(&mut self, name: &str, args: &[String]) -> Result<i32> {
312        if !self.commands.contains_key(name) {
313            return Err(PluginError::unknown_command(name));
314        }
315
316        // Set up arguments in the engine
317        let args_list = args
318            .iter()
319            .map(|a| format!("\"{}\"", a.replace('\\', "\\\\").replace('"', "\\\"")))
320            .collect::<Vec<_>>()
321            .join(" ");
322
323        let call_code = format!("(hx/run-command \"{}\" (list {}))", name, args_list);
324
325        match self.engine.run(call_code) {
326            Ok(results) => {
327                // Try to get an exit code from the result
328                if let Some(steel::SteelVal::IntV(code)) = results.into_iter().next() {
329                    return Ok(code as i32);
330                }
331                Ok(0)
332            }
333            Err(e) => Err(PluginError::runtime(name, e.to_string())),
334        }
335    }
336
337    fn register_api(&mut self) -> Result<()> {
338        // Register the hx API functions
339        // These are registered using Steel's FFI mechanism
340        crate::api::register_all(&mut self.engine)?;
341        Ok(())
342    }
343
344    fn commands(&self) -> &HashMap<String, CustomCommand> {
345        &self.commands
346    }
347}
348
349impl SteelEngine {
350    /// Find a script file in the plugin paths.
351    fn find_script(&self, name: &str, project_root: &Path) -> Result<PathBuf> {
352        let paths = self.config.all_paths(project_root);
353
354        for base_path in &paths {
355            let script_path = base_path.join(name);
356            if script_path.exists() {
357                return Ok(script_path);
358            }
359        }
360
361        Err(PluginError::not_found(PathBuf::from(name)))
362    }
363
364    /// Register a custom command from Scheme.
365    pub fn register_command(&mut self, cmd: CustomCommand) {
366        info!("Registering custom command: {}", cmd.name);
367        self.commands.insert(cmd.name.clone(), cmd);
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    #[test]
376    fn test_engine_creation() {
377        let engine = SteelEngine::with_defaults();
378        assert!(engine.loaded_plugins().is_empty());
379        assert!(engine.commands().is_empty());
380    }
381}