1use 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#[derive(Debug, Clone, serde::Deserialize)]
56pub struct PluginManifest {
57 pub plugin: PluginMeta,
59 #[serde(default)]
61 pub commands: Vec<ConfigCommand>,
62 #[serde(default)]
64 pub keybindings: Vec<ConfigKeybinding>,
65}
66
67#[derive(Debug, Clone, serde::Deserialize)]
69pub struct PluginMeta {
70 pub name: String,
72 pub version: String,
74 #[serde(default)]
76 pub description: String,
77 #[serde(default)]
79 pub author: String,
80 #[serde(default)]
82 pub homepage: String,
83 #[serde(default)]
85 pub min_enya_version: Option<String>,
86 #[serde(default = "default_true")]
88 pub enabled: bool,
89}
90
91fn default_true() -> bool {
92 true
93}
94
95#[derive(Debug, Clone, serde::Deserialize)]
97pub struct ConfigCommand {
98 pub name: String,
100 #[serde(default)]
102 pub description: String,
103 #[serde(default)]
105 pub aliases: Vec<String>,
106 #[serde(default)]
108 pub shell: Option<String>,
109 #[serde(default)]
111 pub url: Option<String>,
112 #[serde(default)]
114 pub notify: Option<String>,
115 #[serde(default)]
117 pub accepts_args: bool,
118}
119
120#[derive(Debug, Clone, serde::Deserialize)]
122pub struct ConfigKeybinding {
123 pub keys: String,
125 pub command: String,
127 #[serde(default)]
129 pub description: String,
130 #[serde(default)]
132 pub modes: Vec<String>,
133}
134
135pub struct ConfigPlugin {
137 manifest: PluginManifest,
139 path: PathBuf,
141 active: bool,
143 name: &'static str,
145 version: &'static str,
147 description: &'static str,
149 min_editor_version: Option<&'static str>,
151}
152
153impl ConfigPlugin {
154 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 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 pub fn manifest(&self) -> &PluginManifest {
187 &self.manifest
188 }
189
190 #[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 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 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 if let Some(shell) = &cmd.shell {
349 return self.execute_shell(shell, args);
350 }
351
352 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 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
388fn 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
415pub struct PluginLoader {
417 user_dir: Option<PathBuf>,
419 workspace_dir: Option<PathBuf>,
421}
422
423impl PluginLoader {
424 pub fn new() -> Self {
426 Self {
427 user_dir: Self::default_user_plugin_dir(),
428 workspace_dir: None,
429 }
430 }
431
432 pub fn with_workspace_dir(mut self, dir: impl Into<PathBuf>) -> Self {
434 self.workspace_dir = Some(dir.into());
435 self
436 }
437
438 fn default_user_plugin_dir() -> Option<PathBuf> {
440 Some(enya_config::plugins_dir())
441 }
442
443 pub fn discover(&self) -> Vec<PathBuf> {
445 let mut plugins = Vec::new();
446
447 if let Some(ref user_dir) = self.user_dir {
449 plugins.extend(Self::scan_directory(user_dir));
450 }
451
452 if let Some(ref workspace_dir) = self.workspace_dir {
454 plugins.extend(Self::scan_directory(workspace_dir));
455 }
456
457 plugins
458 }
459
460 fn scan_directory(dir: &Path) -> Vec<PathBuf> {
462 scan_directory_with_ext(dir, "toml")
463 }
464
465 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 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 pub fn user_plugin_dir(&self) -> Option<&Path> {
483 self.user_dir.as_deref()
484 }
485
486 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 pub fn discover_lua(&self) -> Vec<PathBuf> {
503 let mut plugins = Vec::new();
504
505 if let Some(ref user_dir) = self.user_dir {
507 plugins.extend(Self::scan_directory_lua(user_dir));
508 }
509
510 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 fn scan_directory_lua(dir: &Path) -> Vec<PathBuf> {
520 scan_directory_with_ext(dir, "lua")
521 }
522
523 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 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
553pub 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); 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 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 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 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 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 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}