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        // Try to evaluate (defined? 'name) or check if the symbol exists
108        let check_code = format!("(if (defined? '{}) #t #f)", name);
109        match self.engine.run(check_code) {
110            Ok(results) => {
111                // Check if we got true back
112                if let Some(result) = results.into_iter().next() {
113                    matches!(result, steel::SteelVal::BoolV(true))
114                } else {
115                    false
116                }
117            }
118            Err(_) => false,
119        }
120    }
121
122    /// Call a function with no arguments.
123    fn call_function(&mut self, name: &str) -> Result<()> {
124        let call_code = format!("({})", name);
125        self.engine
126            .run(call_code)
127            .map_err(|e| PluginError::runtime(name, e.to_string()))?;
128        Ok(())
129    }
130}
131
132impl PluginSystem for SteelEngine {
133    fn initialize(&mut self) -> Result<()> {
134        info!("Initializing Steel plugin engine");
135
136        // Register the hx API
137        self.register_api()?;
138
139        // Load prelude/standard library if needed
140        let prelude = include_str!("prelude.scm").to_string();
141        self.eval(prelude)?;
142
143        debug!("Steel engine initialized");
144        Ok(())
145    }
146
147    fn load_plugin(&mut self, path: &Path) -> Result<()> {
148        if !path.exists() {
149            return Err(PluginError::not_found(path.to_path_buf()));
150        }
151
152        if self.is_loaded(path) {
153            debug!("Plugin already loaded: {}", path.display());
154            return Ok(());
155        }
156
157        info!("Loading plugin: {}", path.display());
158        self.load_file(path)?;
159        self.loaded_plugins.push(path.to_path_buf());
160
161        Ok(())
162    }
163
164    fn run_hook(&mut self, event: HookEvent, ctx: &PluginContext) -> Result<HookResult> {
165        // Clone the scripts to avoid borrow checker issues
166        let scripts: Vec<String> = self.config.scripts_for_hook(event).to_vec();
167
168        if scripts.is_empty() {
169            return Ok(HookResult::skipped());
170        }
171
172        debug!("Running {} hook with {} scripts", event, scripts.len());
173
174        // Set up the context for this execution
175        let _guard = ContextGuard::new(ctx.clone());
176        let start = Instant::now();
177
178        let project_root = ctx.project_root.clone();
179
180        for script in &scripts {
181            // Find the script in plugin paths
182            let script_path = self.find_script(script, &project_root)?;
183
184            // Load the script if not already loaded
185            if !self.is_loaded(&script_path) {
186                self.load_plugin(&script_path)?;
187            }
188
189            // Check if the hook function is defined
190            let hook_fn = event.scheme_function();
191            if self.has_function(hook_fn) {
192                match self.call_function(hook_fn) {
193                    Ok(()) => {
194                        debug!("Hook {} completed successfully", hook_fn);
195                    }
196                    Err(e) => {
197                        let duration = start.elapsed();
198                        warn!("Hook {} failed: {}", hook_fn, e);
199
200                        if !self.config.continue_on_error {
201                            return Ok(HookResult::failure(duration, e.to_string()));
202                        }
203                    }
204                }
205            }
206        }
207
208        let duration = start.elapsed();
209        Ok(HookResult::success(duration))
210    }
211
212    fn run_command(&mut self, name: &str, args: &[String]) -> Result<i32> {
213        if !self.commands.contains_key(name) {
214            return Err(PluginError::unknown_command(name));
215        }
216
217        // Set up arguments in the engine
218        let args_list = args
219            .iter()
220            .map(|a| format!("\"{}\"", a.replace('\\', "\\\\").replace('"', "\\\"")))
221            .collect::<Vec<_>>()
222            .join(" ");
223
224        let call_code = format!("(hx/run-command \"{}\" (list {}))", name, args_list);
225
226        match self.engine.run(call_code) {
227            Ok(results) => {
228                // Try to get an exit code from the result
229                if let Some(steel::SteelVal::IntV(code)) = results.into_iter().next() {
230                    return Ok(code as i32);
231                }
232                Ok(0)
233            }
234            Err(e) => Err(PluginError::runtime(name, e.to_string())),
235        }
236    }
237
238    fn register_api(&mut self) -> Result<()> {
239        // Register the hx API functions
240        // These are registered using Steel's FFI mechanism
241        crate::api::register_all(&mut self.engine)?;
242        Ok(())
243    }
244
245    fn commands(&self) -> &HashMap<String, CustomCommand> {
246        &self.commands
247    }
248}
249
250impl SteelEngine {
251    /// Find a script file in the plugin paths.
252    fn find_script(&self, name: &str, project_root: &Path) -> Result<PathBuf> {
253        let paths = self.config.all_paths(project_root);
254
255        for base_path in &paths {
256            let script_path = base_path.join(name);
257            if script_path.exists() {
258                return Ok(script_path);
259            }
260        }
261
262        Err(PluginError::not_found(PathBuf::from(name)))
263    }
264
265    /// Register a custom command from Scheme.
266    pub fn register_command(&mut self, cmd: CustomCommand) {
267        info!("Registering custom command: {}", cmd.name);
268        self.commands.insert(cmd.name.clone(), cmd);
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn test_engine_creation() {
278        let engine = SteelEngine::with_defaults();
279        assert!(engine.loaded_plugins().is_empty());
280        assert!(engine.commands().is_empty());
281    }
282}