diff --git a/Cargo.lock b/Cargo.lock index d7a90210c..53ff1948d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1395,6 +1395,10 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "helix" +version = "0.1.0" + [[package]] name = "helix-core" version = "25.7.1" @@ -1565,6 +1569,7 @@ dependencies = [ "indexmap", "indoc", "libc", + "libloading", "log", "nucleo", "once_cell", @@ -1983,9 +1988,9 @@ checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" [[package]] name = "libloading" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a793df0d7afeac54f95b471d3af7f0d4fb975699f972341a4b76988d49cdf0c" +checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" dependencies = [ "cfg-if", "windows-targets 0.53.2", diff --git a/Cargo.toml b/Cargo.toml index d860c2e59..ac1ebf349 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,9 @@ members = [ "helix-stdx", "xtask", ] +exclude = [ + "helix-plugins" +] default-members = [ "helix-term" @@ -61,3 +64,11 @@ repository = "https://github.com/helix-editor/helix" homepage = "https://helix-editor.com" license = "MPL-2.0" rust-version = "1.82" + +[package] +name = "helix" +version = "0.1.0" + +[lib] +name = "helix" +path = "helix-core/src/lib.rs" diff --git a/helix-term/Cargo.toml b/helix-term/Cargo.toml index 84c0ae5f3..3da290692 100644 --- a/helix-term/Cargo.toml +++ b/helix-term/Cargo.toml @@ -92,6 +92,7 @@ grep-regex = "0.1.13" grep-searcher = "0.1.14" dashmap = "6.0" +libloading = "0.8.8" [target.'cfg(not(windows))'.dependencies] # https://github.com/vorner/signal-hook/issues/100 signal-hook-tokio = { version = "0.3", features = ["futures-v0_3"] } diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index cf09aac0d..4bbad8603 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -26,6 +26,7 @@ use crate::{ handlers, job::Jobs, keymap::Keymaps, + plugins::Plugins, ui::{self, overlay::overlaid}, }; @@ -234,6 +235,9 @@ impl Application { ]) .context("build signal handler")?; + // after everything has been set up, load all plugins + Plugins::load_all(&config.load()); + let app = Self { compositor, terminal, @@ -414,6 +418,9 @@ impl Application { self.terminal .reconfigure(default_config.editor.clone().into())?; + + Plugins::config_updated(&default_config); + // Store new config self.config.store(Arc::new(default_config)); Ok(()) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index a3417ea1b..2a85041b3 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -63,6 +63,7 @@ use crate::{ compositor::{self, Component, Compositor}, filter_picker_entry, job::Callback, + plugins::Plugins, ui::{self, overlay::overlaid, Picker, PickerColumn, Popup, Prompt, PromptEvent}, }; @@ -246,7 +247,9 @@ impl MappableCommand { pub fn execute(&self, cx: &mut Context) { match &self { Self::Typable { name, args, doc: _ } => { - if let Some(command) = typed::TYPABLE_COMMAND_MAP.get(name.as_str()) { + //if Plugins::call_typed_command(name, cx, args) { + if false { + } else if let Some(command) = typed::TYPABLE_COMMAND_MAP.get(name.as_str()) { let mut cx = compositor::Context { editor: cx.editor, jobs: cx.jobs, diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 82cad8386..34774c7b9 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -3692,7 +3692,13 @@ fn execute_command_line( match typed::TYPABLE_COMMAND_MAP.get(command) { Some(cmd) => execute_command(cx, cmd, rest, event), - None if event == PromptEvent::Validate => Err(anyhow!("no such command: '{command}'")), + None if event == PromptEvent::Validate => { + if Plugins::call_typed_command(cx, command, rest) { + Ok(()) + } else { + Err(anyhow!("no such command: '{command}'")) + } + } None => Ok(()), } } @@ -3813,7 +3819,10 @@ fn complete_command_line(editor: &Editor, input: &str) -> Vec, pub keys: HashMap, pub editor: helix_view::editor::Config, + pub plugins: Option>, + pub plugin: Option, } #[derive(Debug, Clone, PartialEq, Deserialize)] @@ -22,6 +24,8 @@ pub struct ConfigRaw { pub theme: Option, pub keys: Option>, pub editor: Option, + pub plugins: Option>, + pub plugin: Option, } impl Default for Config { @@ -30,6 +34,8 @@ impl Default for Config { theme: None, keys: keymap::default(), editor: helix_view::editor::Config::default(), + plugins: None, + plugin: None, } } } @@ -84,10 +90,22 @@ impl Config { .map_err(ConfigLoadError::BadConfig)?, }; + let plugins = match (global.plugins, local.plugins) { + (None, None) => None, + (None, Some(val)) | (Some(val), None) => Some(val), + (Some(global), Some(local)) => { + let mut plugins = global.clone(); + plugins.extend(local); + Some(plugins) + } + }; + Config { theme: local.theme.or(global.theme), keys, editor, + plugins, + plugin: local.plugin.or(global.plugin), // TODO: merge } } // if any configs are invalid return that first @@ -107,6 +125,8 @@ impl Config { || Ok(helix_view::editor::Config::default()), |val| val.try_into().map_err(ConfigLoadError::BadConfig), )?, + plugins: config.plugins, + plugin: config.plugin, } } diff --git a/helix-term/src/lib.rs b/helix-term/src/lib.rs index 75b67479b..a4b05f7c0 100644 --- a/helix-term/src/lib.rs +++ b/helix-term/src/lib.rs @@ -10,6 +10,7 @@ pub mod events; pub mod health; pub mod job; pub mod keymap; +pub mod plugins; pub mod ui; use std::path::Path; diff --git a/helix-term/src/plugins.rs b/helix-term/src/plugins.rs new file mode 100644 index 000000000..edfe4d191 --- /dev/null +++ b/helix-term/src/plugins.rs @@ -0,0 +1,126 @@ +use crate::{compositor::Context, config::Config}; + +use libloading::Library; +use serde::Deserialize; +use std::{ + ffi::{c_char, CString}, + sync::Mutex, +}; + +enum PluginInstance {} +type InitFn = fn(&Config) -> &'static mut PluginInstance; +type ConfigUpdatedFn = fn(&mut PluginInstance, &Config); +type AvailableCmdsFn = fn(&mut PluginInstance) -> Vec; +type CmdFn = fn(&mut PluginInstance, &mut Context, *const c_char); + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct PluginInfo { + pub name: String, + pub version: String, + pub lib_path: String, + pub min_helix_version: Option, + pub dependencies: Option>, +} + +struct Plugin { + info: PluginInfo, + lib: Library, + instance: &'static mut PluginInstance, +} + +static PLUGINS: Mutex> = Mutex::>::new(vec![]); + +pub struct Plugins {} + +impl Plugins { + // Utils for plugin developers + + pub fn alloc() -> &'static mut T { + let layout = std::alloc::Layout::new::(); + unsafe { + let t = std::alloc::alloc(layout) as *mut T; + t.as_mut().unwrap() + } + } + + pub fn get_config_for<'a>(config: &'a Config, name: &str) -> Option<&'a toml::Table> { + if let Some(config) = &config.plugin { + if let Some(config) = config.get(name) { + return config.as_table(); + } + } + None + } + + // Internal plugin API + + pub fn load_all(config: &Config) { + if let Some(plugins) = &config.plugins { + for plugin in plugins { + Self::load(&plugin, config); + } + } + } + + pub fn load(info_path: &str, config: &Config) { + let info_str = std::fs::read_to_string(info_path).unwrap(); + let info = toml::from_str::(&info_str).unwrap(); + + // TODO: do nothing if this version is too low + // if (VERSION_AND_GIT_HASH < info.min_helix_version) { + // return; + // } + + // load the dynamic lib + let lib_path = std::path::Path::new(info_path) + .parent() + .unwrap() + .join(&info.lib_path); + let lib = unsafe { Library::new(lib_path).unwrap() }; + + // call the plugin's init method (if it has one) + let func_opt = unsafe { lib.get::(b"init") }; + let instance = func_opt.unwrap()(config); + + PLUGINS.lock().unwrap().push(Plugin { + info, + lib, + instance, + }); + } + + pub fn config_updated(config: &Config) { + for plugin in PLUGINS.lock().unwrap().iter_mut() { + let func_opt = unsafe { plugin.lib.get::(b"config_updated") }; + if let Ok(func) = func_opt { + func(plugin.instance, config); + } + } + } + + pub fn available_commands() -> Vec { + let mut commands = Vec::::new(); + for plugin in PLUGINS.lock().unwrap().iter_mut() { + let func_opt = unsafe { plugin.lib.get::(b"available_commands") }; + if let Ok(func) = func_opt { + let plugin_commands = func(plugin.instance); + for command in plugin_commands { + commands.push(command.to_string()); + } + } + } + commands + } + + pub fn call_typed_command(cx: &mut Context, name: &str, args: &str) -> bool { + for plugin in PLUGINS.lock().unwrap().iter_mut() { + let func_opt = unsafe { plugin.lib.get::(name.as_bytes()) }; + if let Ok(func) = func_opt { + func(plugin.instance, cx, CString::new(args).unwrap().as_ptr()); + return true; + } + } + false + } +}