Skip to main content

enya_plugin/
loader.rs

1//! Plugin loader for user-defined plugins.
2//!
3//! This module handles loading plugins from the filesystem, allowing users
4//! to extend the host application without modifying the source code.
5//!
6//! # Plugin Locations
7//!
8//! Plugins are loaded from these directories (in order of priority):
9//! 1. `~/.enya/plugins/` - User plugins (highest priority)
10//! 2. `<workspace>/.enya/plugins/` - Workspace-local plugins
11//! 3. Built-in plugins (lowest priority)
12//!
13//! # Plugin Formats
14//!
15//! ## Config Plugins (`.toml`)
16//!
17//! Simple plugins defined in TOML that can add commands and keybindings:
18//!
19//! ```toml
20//! [plugin]
21//! name = "my-plugin"
22//! version = "0.1.0"
23//! description = "My custom plugin"
24//!
25//! [[commands]]
26//! name = "hello"
27//! description = "Say hello"
28//! # Run a shell command
29//! shell = "echo 'Hello, World!'"
30//!
31//! [[commands]]
32//! name = "open-grafana"
33//! description = "Open Grafana in browser"
34//! # Open a URL
35//! url = "http://localhost:3000"
36//!
37//! [[keybindings]]
38//! keys = "Space+x+h"
39//! command = "hello"
40//! description = "Say hello"
41//! ```
42//!
43//! ## Script Plugins (`.lua`) - See lua.rs
44//!
45//! Lua scripts for more complex plugins.
46
47use std::any::Any;
48use std::path::{Path, PathBuf};
49
50use crate::traits::{CommandConfig, KeybindingConfig, Plugin, PluginCapabilities};
51use crate::types::PluginContext;
52use crate::{PluginError, PluginResult};
53
54/// Metadata for a loadable plugin.
55#[derive(Debug, Clone, serde::Deserialize)]
56pub struct PluginManifest {
57    /// Plugin metadata
58    pub plugin: PluginMeta,
59    /// Commands provided by the plugin
60    #[serde(default)]
61    pub commands: Vec<ConfigCommand>,
62    /// Keybindings provided by the plugin
63    #[serde(default)]
64    pub keybindings: Vec<ConfigKeybinding>,
65}
66
67/// Plugin metadata from manifest.
68#[derive(Debug, Clone, serde::Deserialize)]
69pub struct PluginMeta {
70    /// Plugin name (unique identifier)
71    pub name: String,
72    /// Plugin version (semver)
73    pub version: String,
74    /// Human-readable description
75    #[serde(default)]
76    pub description: String,
77    /// Author name
78    #[serde(default)]
79    pub author: String,
80    /// Plugin homepage URL
81    #[serde(default)]
82    pub homepage: String,
83    /// Minimum host version required
84    #[serde(default)]
85    pub min_enya_version: Option<String>,
86    /// Whether the plugin is enabled by default
87    #[serde(default = "default_true")]
88    pub enabled: bool,
89}
90
91fn default_true() -> bool {
92    true
93}
94
95/// A command defined in a config plugin.
96#[derive(Debug, Clone, serde::Deserialize)]
97pub struct ConfigCommand {
98    /// Command name
99    pub name: String,
100    /// Command description
101    #[serde(default)]
102    pub description: String,
103    /// Aliases for the command
104    #[serde(default)]
105    pub aliases: Vec<String>,
106    /// Shell command to execute (mutually exclusive with `url`)
107    #[serde(default)]
108    pub shell: Option<String>,
109    /// URL to open (mutually exclusive with `shell`)
110    #[serde(default)]
111    pub url: Option<String>,
112    /// Notify message to show
113    #[serde(default)]
114    pub notify: Option<String>,
115    /// Whether this command accepts arguments
116    #[serde(default)]
117    pub accepts_args: bool,
118}
119
120/// A keybinding defined in a config plugin.
121#[derive(Debug, Clone, serde::Deserialize)]
122pub struct ConfigKeybinding {
123    /// Key sequence (e.g., "Space+x+h")
124    pub keys: String,
125    /// Command to execute
126    pub command: String,
127    /// Description for which-key overlay
128    #[serde(default)]
129    pub description: String,
130    /// Modes where binding is active (empty = all)
131    #[serde(default)]
132    pub modes: Vec<String>,
133}
134
135/// A plugin loaded from a config file.
136pub struct ConfigPlugin {
137    /// Plugin manifest
138    manifest: PluginManifest,
139    /// Path to the plugin file
140    path: PathBuf,
141    /// Whether the plugin is active
142    active: bool,
143    /// Cached static name (leaked once at load)
144    name: &'static str,
145    /// Cached static version (leaked once at load)
146    version: &'static str,
147    /// Cached static description (leaked once at load)
148    description: &'static str,
149    /// Cached static min_editor_version (leaked once at load)
150    min_editor_version: Option<&'static str>,
151}
152
153impl ConfigPlugin {
154    /// Load a plugin from a TOML file.
155    pub fn load(path: &Path) -> PluginResult<Self> {
156        let content = std::fs::read_to_string(path).map_err(|e| {
157            PluginError::InitializationFailed(format!("Failed to read {}: {e}", path.display()))
158        })?;
159
160        let manifest: PluginManifest = toml::from_str(&content).map_err(|e| {
161            PluginError::InvalidConfiguration(format!("Failed to parse {}: {e}", path.display()))
162        })?;
163
164        // Leak strings once at load time (plugins live for the duration of the program)
165        let name = Box::leak(manifest.plugin.name.clone().into_boxed_str());
166        let version = Box::leak(manifest.plugin.version.clone().into_boxed_str());
167        let description = Box::leak(manifest.plugin.description.clone().into_boxed_str());
168        let min_editor_version = manifest
169            .plugin
170            .min_enya_version
171            .as_ref()
172            .map(|v| Box::leak(v.clone().into_boxed_str()) as &'static str);
173
174        Ok(Self {
175            manifest,
176            path: path.to_path_buf(),
177            active: false,
178            name,
179            version,
180            description,
181            min_editor_version,
182        })
183    }
184
185    /// Get the plugin manifest.
186    pub fn manifest(&self) -> &PluginManifest {
187        &self.manifest
188    }
189
190    /// Create a plugin from a manifest (for testing).
191    #[cfg(test)]
192    pub fn from_manifest(manifest: PluginManifest, path: PathBuf) -> Self {
193        let name = Box::leak(manifest.plugin.name.clone().into_boxed_str());
194        let version = Box::leak(manifest.plugin.version.clone().into_boxed_str());
195        let description = Box::leak(manifest.plugin.description.clone().into_boxed_str());
196        let min_editor_version = manifest
197            .plugin
198            .min_enya_version
199            .as_ref()
200            .map(|v| Box::leak(v.clone().into_boxed_str()) as &'static str);
201
202        Self {
203            manifest,
204            path,
205            active: false,
206            name,
207            version,
208            description,
209            min_editor_version,
210        }
211    }
212
213    /// Execute a shell command.
214    fn execute_shell(&self, cmd: &str, args: &str) -> bool {
215        let full_cmd = if args.is_empty() {
216            cmd.to_string()
217        } else {
218            format!("{cmd} {args}")
219        };
220
221        log::info!(
222            "[plugin:{}] Executing: {full_cmd}",
223            self.manifest.plugin.name
224        );
225
226        let plugin_name = self.manifest.plugin.name.clone();
227        let cmd_for_log = full_cmd.clone();
228
229        match std::process::Command::new("sh")
230            .arg("-c")
231            .arg(&full_cmd)
232            .spawn()
233        {
234            Ok(mut child) => {
235                // Don't wait for the process to complete, but log exit status
236                std::thread::spawn(move || match child.wait() {
237                    Ok(status) => {
238                        if !status.success() {
239                            log::warn!(
240                                "[plugin:{plugin_name}] Command exited with {status}: {cmd_for_log}"
241                            );
242                        }
243                    }
244                    Err(e) => {
245                        log::error!("[plugin:{plugin_name}] Failed to wait for command: {e}");
246                    }
247                });
248                true
249            }
250            Err(e) => {
251                log::error!(
252                    "[plugin:{}] Failed to execute command: {e}",
253                    self.manifest.plugin.name
254                );
255                false
256            }
257        }
258    }
259}
260
261impl Plugin for ConfigPlugin {
262    fn name(&self) -> &'static str {
263        self.name
264    }
265
266    fn version(&self) -> &'static str {
267        self.version
268    }
269
270    fn description(&self) -> &'static str {
271        self.description
272    }
273
274    fn capabilities(&self) -> PluginCapabilities {
275        let mut caps = PluginCapabilities::empty();
276        if !self.manifest.commands.is_empty() {
277            caps |= PluginCapabilities::COMMANDS;
278        }
279        if !self.manifest.keybindings.is_empty() {
280            caps |= PluginCapabilities::KEYBOARD;
281        }
282        caps
283    }
284
285    fn min_editor_version(&self) -> Option<&'static str> {
286        self.min_editor_version
287    }
288
289    fn init(&mut self, _ctx: &PluginContext) -> PluginResult<()> {
290        log::info!(
291            "[plugin:{}] Loaded from {}",
292            self.manifest.plugin.name,
293            self.path.display()
294        );
295        Ok(())
296    }
297
298    fn activate(&mut self, _ctx: &PluginContext) -> PluginResult<()> {
299        self.active = true;
300        log::info!("[plugin:{}] Activated", self.manifest.plugin.name);
301        Ok(())
302    }
303
304    fn deactivate(&mut self, _ctx: &PluginContext) -> PluginResult<()> {
305        self.active = false;
306        log::info!("[plugin:{}] Deactivated", self.manifest.plugin.name);
307        Ok(())
308    }
309
310    fn commands(&self) -> Vec<CommandConfig> {
311        self.manifest
312            .commands
313            .iter()
314            .map(|c| CommandConfig {
315                name: c.name.clone(),
316                aliases: c.aliases.clone(),
317                description: c.description.clone(),
318                accepts_args: c.accepts_args,
319            })
320            .collect()
321    }
322
323    fn keybindings(&self) -> Vec<KeybindingConfig> {
324        self.manifest
325            .keybindings
326            .iter()
327            .map(|k| KeybindingConfig {
328                keys: k.keys.clone(),
329                command: k.command.clone(),
330                description: k.description.clone(),
331                modes: k.modes.clone(),
332            })
333            .collect()
334    }
335
336    fn execute_command(&mut self, command: &str, args: &str, ctx: &PluginContext) -> bool {
337        let cmd_config = self
338            .manifest
339            .commands
340            .iter()
341            .find(|c| c.name == command || c.aliases.contains(&command.to_string()));
342
343        let Some(cmd) = cmd_config else {
344            return false;
345        };
346
347        // Handle shell command
348        if let Some(shell) = &cmd.shell {
349            return self.execute_shell(shell, args);
350        }
351
352        // Handle URL opening
353        if let Some(url) = &cmd.url {
354            let url = if args.is_empty() {
355                url.clone()
356            } else {
357                format!("{url}{args}")
358            };
359            log::info!("[plugin:{}] Opening URL: {url}", self.manifest.plugin.name);
360            if let Err(e) = open::that(&url) {
361                log::error!(
362                    "[plugin:{}] Failed to open URL: {e}",
363                    self.manifest.plugin.name
364                );
365                ctx.notify("error", &format!("Failed to open URL: {e}"));
366            }
367            return true;
368        }
369
370        // Handle notify message
371        if let Some(msg) = &cmd.notify {
372            ctx.notify("info", msg);
373            return true;
374        }
375
376        false
377    }
378
379    fn as_any(&self) -> &dyn Any {
380        self
381    }
382
383    fn as_any_mut(&mut self) -> &mut dyn Any {
384        self
385    }
386}
387
388/// Scan a directory for plugin files with a specific extension.
389fn scan_directory_with_ext(dir: &Path, ext: &str) -> Vec<PathBuf> {
390    let mut plugins = Vec::new();
391
392    if !dir.exists() {
393        return plugins;
394    }
395
396    let Ok(entries) = std::fs::read_dir(dir) else {
397        log::warn!("Failed to read plugin directory: {}", dir.display());
398        return plugins;
399    };
400
401    for entry in entries.flatten() {
402        let path = entry.path();
403        if path.is_file() {
404            if let Some(file_ext) = path.extension() {
405                if file_ext == ext {
406                    plugins.push(path);
407                }
408            }
409        }
410    }
411
412    plugins
413}
414
415/// Plugin loader that discovers and loads plugins from the filesystem.
416pub struct PluginLoader {
417    /// User plugin directory (~/.enya/plugins/)
418    user_dir: Option<PathBuf>,
419    /// Workspace plugin directory (.enya/plugins/)
420    workspace_dir: Option<PathBuf>,
421}
422
423impl PluginLoader {
424    /// Create a new plugin loader.
425    pub fn new() -> Self {
426        Self {
427            user_dir: Self::default_user_plugin_dir(),
428            workspace_dir: None,
429        }
430    }
431
432    /// Set the workspace plugin directory.
433    pub fn with_workspace_dir(mut self, dir: impl Into<PathBuf>) -> Self {
434        self.workspace_dir = Some(dir.into());
435        self
436    }
437
438    /// Get the default user plugin directory (`~/.enya/plugins/`).
439    fn default_user_plugin_dir() -> Option<PathBuf> {
440        Some(enya_config::plugins_dir())
441    }
442
443    /// Discover all plugin files in the configured directories.
444    pub fn discover(&self) -> Vec<PathBuf> {
445        let mut plugins = Vec::new();
446
447        // Load from user directory
448        if let Some(ref user_dir) = self.user_dir {
449            plugins.extend(Self::scan_directory(user_dir));
450        }
451
452        // Load from workspace directory
453        if let Some(ref workspace_dir) = self.workspace_dir {
454            plugins.extend(Self::scan_directory(workspace_dir));
455        }
456
457        plugins
458    }
459
460    /// Scan a directory for plugin files with .toml extension.
461    fn scan_directory(dir: &Path) -> Vec<PathBuf> {
462        scan_directory_with_ext(dir, "toml")
463    }
464
465    /// Load all discovered plugins.
466    pub fn load_all(&self) -> Vec<PluginResult<ConfigPlugin>> {
467        self.discover()
468            .into_iter()
469            .map(|path| ConfigPlugin::load(&path))
470            .collect()
471    }
472
473    /// Ensure the user plugin directory exists.
474    pub fn ensure_user_dir(&self) -> std::io::Result<()> {
475        if let Some(ref dir) = self.user_dir {
476            std::fs::create_dir_all(dir)?;
477        }
478        Ok(())
479    }
480
481    /// Get the user plugin directory path.
482    pub fn user_plugin_dir(&self) -> Option<&Path> {
483        self.user_dir.as_deref()
484    }
485
486    /// Create an example plugin file.
487    pub fn create_example_plugin(&self) -> std::io::Result<PathBuf> {
488        let dir = self
489            .user_dir
490            .as_ref()
491            .ok_or_else(|| std::io::Error::other("No user plugin directory"))?;
492
493        std::fs::create_dir_all(dir)?;
494
495        let example_path = dir.join("example.toml");
496        std::fs::write(&example_path, EXAMPLE_PLUGIN)?;
497
498        Ok(example_path)
499    }
500
501    /// Discover all Lua plugin files in the configured directories.
502    pub fn discover_lua(&self) -> Vec<PathBuf> {
503        let mut plugins = Vec::new();
504
505        // Load from user directory
506        if let Some(ref user_dir) = self.user_dir {
507            plugins.extend(Self::scan_directory_lua(user_dir));
508        }
509
510        // Load from workspace directory
511        if let Some(ref workspace_dir) = self.workspace_dir {
512            plugins.extend(Self::scan_directory_lua(workspace_dir));
513        }
514
515        plugins
516    }
517
518    /// Scan a directory for Lua plugin files.
519    fn scan_directory_lua(dir: &Path) -> Vec<PathBuf> {
520        scan_directory_with_ext(dir, "lua")
521    }
522
523    /// Load all discovered Lua plugins.
524    pub fn load_all_lua(&self) -> Vec<PluginResult<super::lua::LuaPlugin>> {
525        self.discover_lua()
526            .into_iter()
527            .map(|path| super::lua::LuaPlugin::load(&path))
528            .collect()
529    }
530
531    /// Create an example Lua plugin file.
532    pub fn create_example_lua_plugin(&self) -> std::io::Result<PathBuf> {
533        let dir = self
534            .user_dir
535            .as_ref()
536            .ok_or_else(|| std::io::Error::other("No user plugin directory"))?;
537
538        std::fs::create_dir_all(dir)?;
539
540        let example_path = dir.join("example.lua");
541        std::fs::write(&example_path, super::lua::EXAMPLE_LUA_PLUGIN)?;
542
543        Ok(example_path)
544    }
545}
546
547impl Default for PluginLoader {
548    fn default() -> Self {
549        Self::new()
550    }
551}
552
553/// Example plugin template.
554pub const EXAMPLE_PLUGIN: &str = r#"# Example Enya Plugin
555# Place this file in ~/.enya/plugins/
556
557[plugin]
558name = "example"
559version = "0.1.0"
560description = "An example plugin showing available features"
561author = "Your Name"
562enabled = true
563
564# Commands are accessible via the command palette (:command-name)
565[[commands]]
566name = "hello"
567description = "Display a greeting message"
568notify = "Hello from the example plugin!"
569
570[[commands]]
571name = "open-docs"
572aliases = ["docs"]
573description = "Open Enya documentation"
574url = "https://enya.build/docs"
575
576[[commands]]
577name = "run-tests"
578description = "Run tests in current directory"
579# Shell commands run asynchronously
580shell = "cargo test"
581accepts_args = true
582
583# Keybindings for quick access
584# Format: "Modifier+Key" (Space for leader key)
585[[keybindings]]
586keys = "Space+x+h"
587command = "hello"
588description = "Say hello"
589
590[[keybindings]]
591keys = "Space+x+d"
592command = "open-docs"
593description = "Open docs"
594"#;
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599
600    #[test]
601    fn test_parse_minimal_manifest() {
602        let toml = r#"
603            [plugin]
604            name = "minimal"
605            version = "1.0.0"
606        "#;
607
608        let manifest: PluginManifest = toml::from_str(toml).unwrap();
609        assert_eq!(manifest.plugin.name, "minimal");
610        assert_eq!(manifest.plugin.version, "1.0.0");
611        assert!(manifest.plugin.description.is_empty());
612        assert!(manifest.plugin.enabled); // default true
613        assert!(manifest.commands.is_empty());
614        assert!(manifest.keybindings.is_empty());
615    }
616
617    #[test]
618    fn test_parse_full_manifest() {
619        let toml = r#"
620            [plugin]
621            name = "full-plugin"
622            version = "2.0.0"
623            description = "A fully configured plugin"
624            author = "Test Author"
625            homepage = "https://example.com"
626            min_enya_version = "1.0.0"
627            enabled = false
628
629            [[commands]]
630            name = "test-cmd"
631            description = "A test command"
632            aliases = ["tc", "test"]
633            shell = "echo hello"
634            accepts_args = true
635
636            [[commands]]
637            name = "open-url"
638            url = "https://example.com"
639
640            [[commands]]
641            name = "notify-cmd"
642            notify = "Hello!"
643
644            [[keybindings]]
645            keys = "Space+t+t"
646            command = "test-cmd"
647            description = "Run test"
648            modes = ["normal", "visual"]
649        "#;
650
651        let manifest: PluginManifest = toml::from_str(toml).unwrap();
652        assert_eq!(manifest.plugin.name, "full-plugin");
653        assert_eq!(manifest.plugin.version, "2.0.0");
654        assert_eq!(manifest.plugin.description, "A fully configured plugin");
655        assert_eq!(manifest.plugin.author, "Test Author");
656        assert!(!manifest.plugin.enabled);
657        assert_eq!(manifest.plugin.min_enya_version, Some("1.0.0".to_string()));
658
659        assert_eq!(manifest.commands.len(), 3);
660        let cmd = &manifest.commands[0];
661        assert_eq!(cmd.name, "test-cmd");
662        assert_eq!(cmd.aliases, vec!["tc", "test"]);
663        assert_eq!(cmd.shell, Some("echo hello".to_string()));
664        assert!(cmd.accepts_args);
665
666        let url_cmd = &manifest.commands[1];
667        assert_eq!(url_cmd.url, Some("https://example.com".to_string()));
668
669        let notify_cmd = &manifest.commands[2];
670        assert_eq!(notify_cmd.notify, Some("Hello!".to_string()));
671
672        assert_eq!(manifest.keybindings.len(), 1);
673        let kb = &manifest.keybindings[0];
674        assert_eq!(kb.keys, "Space+t+t");
675        assert_eq!(kb.command, "test-cmd");
676        assert_eq!(kb.modes, vec!["normal", "visual"]);
677    }
678
679    #[test]
680    fn test_parse_invalid_toml() {
681        let toml = r#"
682            [plugin
683            name = "broken"
684        "#;
685
686        let result: Result<PluginManifest, _> = toml::from_str(toml);
687        assert!(result.is_err());
688    }
689
690    #[test]
691    fn test_parse_missing_required_fields() {
692        // Missing version
693        let toml = r#"
694            [plugin]
695            name = "no-version"
696        "#;
697
698        let result: Result<PluginManifest, _> = toml::from_str(toml);
699        assert!(result.is_err());
700    }
701
702    #[test]
703    fn test_config_plugin_capabilities() {
704        // Empty plugin - no capabilities
705        let empty_toml = r#"
706            [plugin]
707            name = "empty"
708            version = "1.0.0"
709        "#;
710        let manifest: PluginManifest = toml::from_str(empty_toml).unwrap();
711        let plugin = ConfigPlugin::from_manifest(manifest, PathBuf::from("test.toml"));
712        assert!(plugin.capabilities().is_empty());
713
714        // With commands
715        let cmd_toml = r#"
716            [plugin]
717            name = "with-cmd"
718            version = "1.0.0"
719
720            [[commands]]
721            name = "test"
722        "#;
723        let manifest: PluginManifest = toml::from_str(cmd_toml).unwrap();
724        let plugin = ConfigPlugin::from_manifest(manifest, PathBuf::from("test.toml"));
725        assert!(plugin.capabilities().contains(PluginCapabilities::COMMANDS));
726
727        // With keybindings
728        let kb_toml = r#"
729            [plugin]
730            name = "with-kb"
731            version = "1.0.0"
732
733            [[keybindings]]
734            keys = "Space+t"
735            command = "test"
736        "#;
737        let manifest: PluginManifest = toml::from_str(kb_toml).unwrap();
738        let plugin = ConfigPlugin::from_manifest(manifest, PathBuf::from("test.toml"));
739        assert!(plugin.capabilities().contains(PluginCapabilities::KEYBOARD));
740    }
741
742    #[test]
743    fn test_plugin_loader_with_workspace() {
744        let loader = PluginLoader::new().with_workspace_dir("/tmp/test-workspace");
745        assert!(loader.workspace_dir.is_some());
746        assert_eq!(
747            loader.workspace_dir.unwrap(),
748            PathBuf::from("/tmp/test-workspace")
749        );
750    }
751
752    #[test]
753    fn test_discover_nonexistent_directory() {
754        let loader = PluginLoader {
755            user_dir: Some(PathBuf::from("/nonexistent/path/12345")),
756            workspace_dir: None,
757        };
758        let discovered = loader.discover();
759        assert!(discovered.is_empty());
760    }
761
762    #[test]
763    fn test_example_plugin_parses() {
764        // Ensure the example plugin constant is valid TOML
765        let manifest: PluginManifest = toml::from_str(EXAMPLE_PLUGIN).unwrap();
766        assert_eq!(manifest.plugin.name, "example");
767        assert!(!manifest.commands.is_empty());
768        assert!(!manifest.keybindings.is_empty());
769    }
770}