1use 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
14pub trait PluginSystem {
19 fn initialize(&mut self) -> Result<()>;
21
22 fn load_plugin(&mut self, path: &Path) -> Result<()>;
24
25 fn run_hook(&mut self, event: HookEvent, ctx: &PluginContext) -> Result<HookResult>;
27
28 fn run_command(&mut self, name: &str, args: &[String]) -> Result<i32>;
30
31 fn register_api(&mut self) -> Result<()>;
33
34 fn commands(&self) -> &HashMap<String, CustomCommand>;
36}
37
38pub struct SteelEngine {
40 engine: Engine,
42
43 loaded_plugins: Vec<PathBuf>,
45
46 commands: HashMap<String, CustomCommand>,
48
49 config: PluginConfig,
51}
52
53impl SteelEngine {
54 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 pub fn with_defaults() -> Self {
66 Self::new(PluginConfig::new())
67 }
68
69 pub fn config(&self) -> &PluginConfig {
71 &self.config
72 }
73
74 pub fn is_loaded(&self, path: &Path) -> bool {
76 self.loaded_plugins.iter().any(|p| p == path)
77 }
78
79 pub fn loaded_plugins(&self) -> &[PathBuf] {
81 &self.loaded_plugins
82 }
83
84 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 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 fn has_function(&mut self, name: &str) -> bool {
107 engine_has_function(&mut self.engine, name)
108 }
109
110 fn call_function(&mut self, name: &str) -> Result<()> {
112 engine_call_function(&mut self.engine, name)
113 }
114}
115
116fn 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
131fn 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
140fn 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
150fn 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 self.register_api()?;
195
196 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 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 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 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 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 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 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 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 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 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}