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