use crate::commands::CustomCommand;
use crate::config::PluginConfig;
use crate::context::{ContextGuard, PluginContext};
use crate::error::{PluginError, Result};
use crate::hooks::{HookEvent, HookResult};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::Instant;
use steel::steel_vm::engine::Engine;
use tracing::{debug, info, warn};
pub trait PluginSystem {
fn initialize(&mut self) -> Result<()>;
fn load_plugin(&mut self, path: &Path) -> Result<()>;
fn run_hook(&mut self, event: HookEvent, ctx: &PluginContext) -> Result<HookResult>;
fn run_command(&mut self, name: &str, args: &[String]) -> Result<i32>;
fn register_api(&mut self) -> Result<()>;
fn commands(&self) -> &HashMap<String, CustomCommand>;
}
pub struct SteelEngine {
engine: Engine,
loaded_plugins: Vec<PathBuf>,
commands: HashMap<String, CustomCommand>,
config: PluginConfig,
}
fn ensure_steel_home() {
if let Some(dirs) = directories::BaseDirs::new() {
let _ = std::fs::create_dir_all(dirs.data_local_dir());
}
}
impl SteelEngine {
pub fn new(config: PluginConfig) -> Self {
ensure_steel_home();
SteelEngine {
engine: Engine::new(),
loaded_plugins: Vec::new(),
commands: HashMap::new(),
config,
}
}
pub fn with_defaults() -> Self {
Self::new(PluginConfig::new())
}
pub fn config(&self) -> &PluginConfig {
&self.config
}
pub fn is_loaded(&self, path: &Path) -> bool {
self.loaded_plugins.iter().any(|p| p == path)
}
pub fn loaded_plugins(&self) -> &[PathBuf] {
&self.loaded_plugins
}
pub fn eval(&mut self, code: String) -> Result<()> {
self.engine
.run(code)
.map_err(|e| PluginError::runtime("eval", e.to_string()))?;
Ok(())
}
fn load_file(&mut self, path: &Path) -> Result<()> {
let content = std::fs::read_to_string(path).map_err(|e| {
PluginError::io(format!("failed to read plugin file: {}", path.display()), e)
})?;
self.engine.run(content).map_err(|e| {
PluginError::load(path.to_path_buf(), format!("Steel evaluation error: {}", e))
})?;
Ok(())
}
fn has_function(&mut self, name: &str) -> bool {
engine_has_function(&mut self.engine, name)
}
fn call_function(&mut self, name: &str) -> Result<()> {
engine_call_function(&mut self.engine, name)
}
}
fn engine_has_function(engine: &mut Engine, name: &str) -> bool {
let check_code = format!("(if (defined? '{}) #t #f)", name);
match engine.run(check_code) {
Ok(results) => {
if let Some(result) = results.into_iter().next() {
matches!(result, steel::SteelVal::BoolV(true))
} else {
false
}
}
Err(_) => false,
}
}
fn engine_call_function(engine: &mut Engine, name: &str) -> Result<()> {
let call_code = format!("({})", name);
engine
.run(call_code)
.map_err(|e| PluginError::runtime(name, e.to_string()))?;
Ok(())
}
fn new_initialized_engine() -> Result<Engine> {
ensure_steel_home();
let mut engine = Engine::new();
crate::api::register_all(&mut engine)?;
engine
.run(include_str!("prelude.scm").to_string())
.map_err(|e| PluginError::runtime("prelude", e.to_string()))?;
Ok(engine)
}
fn run_hook_isolated(
scripts: &[PathBuf],
hook_fn: &str,
ctx: &PluginContext,
continue_on_error: bool,
) -> Result<HookResult> {
let _guard = ContextGuard::new(ctx.clone());
let start = Instant::now();
let mut engine = new_initialized_engine()?;
for path in scripts {
let content = std::fs::read_to_string(path).map_err(|e| {
PluginError::io(format!("failed to read plugin file: {}", path.display()), e)
})?;
engine.run(content).map_err(|e| {
PluginError::load(path.clone(), format!("Steel evaluation error: {}", e))
})?;
if engine_has_function(&mut engine, hook_fn) {
match engine_call_function(&mut engine, hook_fn) {
Ok(()) => debug!("Hook {} completed successfully", hook_fn),
Err(e) => {
warn!("Hook {} failed: {}", hook_fn, e);
if !continue_on_error {
return Ok(HookResult::failure(start.elapsed(), e.to_string()));
}
}
}
}
}
Ok(HookResult::success(start.elapsed()))
}
impl PluginSystem for SteelEngine {
fn initialize(&mut self) -> Result<()> {
info!("Initializing Steel plugin engine");
self.register_api()?;
let prelude = include_str!("prelude.scm").to_string();
self.eval(prelude)?;
debug!("Steel engine initialized");
Ok(())
}
fn load_plugin(&mut self, path: &Path) -> Result<()> {
if !path.exists() {
return Err(PluginError::not_found(path.to_path_buf()));
}
if self.is_loaded(path) {
debug!("Plugin already loaded: {}", path.display());
return Ok(());
}
info!("Loading plugin: {}", path.display());
self.load_file(path)?;
self.loaded_plugins.push(path.to_path_buf());
Ok(())
}
fn run_hook(&mut self, event: HookEvent, ctx: &PluginContext) -> Result<HookResult> {
let scripts: Vec<String> = self.config.scripts_for_hook(event).to_vec();
if scripts.is_empty() {
return Ok(HookResult::skipped());
}
debug!("Running {} hook with {} scripts", event, scripts.len());
let project_root = ctx.project_root.clone();
let mut resolved = Vec::with_capacity(scripts.len());
for script in &scripts {
resolved.push(self.find_script(script, &project_root)?);
}
let timeout = self.config.hook_timeout();
let hook_fn = event.scheme_function();
if timeout.is_zero() {
let _guard = ContextGuard::new(ctx.clone());
let start = Instant::now();
for script_path in &resolved {
if !self.is_loaded(script_path) {
self.load_plugin(script_path)?;
}
if self.has_function(hook_fn) {
match self.call_function(hook_fn) {
Ok(()) => {
debug!("Hook {} completed successfully", hook_fn);
}
Err(e) => {
let duration = start.elapsed();
warn!("Hook {} failed: {}", hook_fn, e);
if !self.config.continue_on_error {
return Ok(HookResult::failure(duration, e.to_string()));
}
}
}
}
}
return Ok(HookResult::success(start.elapsed()));
}
let (tx, rx) = std::sync::mpsc::channel();
let ctx_clone = ctx.clone();
let hook_fn = hook_fn.to_string();
let continue_on_error = self.config.continue_on_error;
let start = Instant::now();
std::thread::Builder::new()
.name("hx-plugin-hook".into())
.spawn(move || {
let result = run_hook_isolated(&resolved, &hook_fn, &ctx_clone, continue_on_error);
let _ = tx.send(result);
})
.map_err(|e| PluginError::io("failed to spawn hook thread".to_string(), e))?;
match rx.recv_timeout(timeout) {
Ok(result) => result,
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
warn!(
"{} hook timed out after {}ms; abandoning hook thread",
event,
timeout.as_millis()
);
Ok(HookResult::failure(
start.elapsed(),
format!(
"hook timed out after {}ms (configure [plugins].hook_timeout_ms to adjust)",
timeout.as_millis()
),
))
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(PluginError::runtime(
event.scheme_function(),
"hook thread terminated unexpectedly".to_string(),
)),
}
}
fn run_command(&mut self, name: &str, args: &[String]) -> Result<i32> {
if !self.commands.contains_key(name) {
return Err(PluginError::unknown_command(name));
}
let args_list = args
.iter()
.map(|a| format!("\"{}\"", a.replace('\\', "\\\\").replace('"', "\\\"")))
.collect::<Vec<_>>()
.join(" ");
let call_code = format!("(hx/run-command \"{}\" (list {}))", name, args_list);
match self.engine.run(call_code) {
Ok(results) => {
if let Some(steel::SteelVal::IntV(code)) = results.into_iter().next() {
return Ok(code as i32);
}
Ok(0)
}
Err(e) => Err(PluginError::runtime(name, e.to_string())),
}
}
fn register_api(&mut self) -> Result<()> {
crate::api::register_all(&mut self.engine)?;
Ok(())
}
fn commands(&self) -> &HashMap<String, CustomCommand> {
&self.commands
}
}
impl SteelEngine {
fn find_script(&self, name: &str, project_root: &Path) -> Result<PathBuf> {
let paths = self.config.all_paths(project_root);
for base_path in &paths {
let script_path = base_path.join(name);
if script_path.exists() {
return Ok(script_path);
}
}
Err(PluginError::not_found(PathBuf::from(name)))
}
pub fn register_command(&mut self, cmd: CustomCommand) {
info!("Registering custom command: {}", cmd.name);
self.commands.insert(cmd.name.clone(), cmd);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_engine_creation() {
let engine = SteelEngine::with_defaults();
assert!(engine.loaded_plugins().is_empty());
assert!(engine.commands().is_empty());
}
}