fresh/input/
command_registry.rs

1//! Dynamic command registry for plugins and extensions
2//!
3//! This module allows plugins to register custom commands dynamically
4//! while maintaining the built-in command set.
5
6use crate::input::commands::{get_all_commands, Command, Suggestion};
7use crate::input::fuzzy::fuzzy_match;
8use crate::input::keybindings::Action;
9use crate::input::keybindings::KeyContext;
10use std::sync::{Arc, RwLock};
11
12/// Registry for managing editor commands
13///
14/// Supports both built-in commands and dynamically registered plugin commands.
15/// Thread-safe for use across multiple threads (e.g., from async tasks).
16pub struct CommandRegistry {
17    /// Built-in commands (loaded once at startup)
18    builtin_commands: Vec<Command>,
19
20    /// Plugin-registered commands (dynamically added/removed)
21    plugin_commands: Arc<RwLock<Vec<Command>>>,
22
23    /// Command usage history (most recent first)
24    /// Used to sort command palette suggestions by recency
25    command_history: Vec<String>,
26}
27
28impl CommandRegistry {
29    /// Maximum number of commands to keep in history
30    const MAX_HISTORY_SIZE: usize = 50;
31
32    /// Create a new command registry with built-in commands
33    pub fn new() -> Self {
34        Self {
35            builtin_commands: get_all_commands(),
36            plugin_commands: Arc::new(RwLock::new(Vec::new())),
37            command_history: Vec::new(),
38        }
39    }
40
41    /// Refresh built-in commands (e.g. after locale change)
42    pub fn refresh_builtin_commands(&mut self) {
43        self.builtin_commands = get_all_commands();
44    }
45
46    /// Record that a command was used (for history/sorting)
47    ///
48    /// This moves the command to the front of the history list.
49    /// Recently used commands appear first in suggestions.
50    pub fn record_usage(&mut self, command_name: &str) {
51        // Remove existing entry if present
52        self.command_history.retain(|name| name != command_name);
53
54        // Add to front (most recent)
55        self.command_history.insert(0, command_name.to_string());
56
57        // Trim to max size
58        if self.command_history.len() > Self::MAX_HISTORY_SIZE {
59            self.command_history.truncate(Self::MAX_HISTORY_SIZE);
60        }
61    }
62
63    /// Get the position of a command in history (0 = most recent)
64    /// Returns None if command is not in history
65    fn history_position(&self, command_name: &str) -> Option<usize> {
66        self.command_history
67            .iter()
68            .position(|name| name == command_name)
69    }
70
71    /// Register a new command (typically from a plugin)
72    ///
73    /// If a command with the same name already exists, it will be replaced.
74    /// This allows plugins to override built-in commands.
75    pub fn register(&self, command: Command) {
76        tracing::debug!(
77            "CommandRegistry::register: name='{}', action={:?}",
78            command.name,
79            command.action
80        );
81        let mut commands = self.plugin_commands.write().unwrap();
82
83        // Remove existing command with same name
84        commands.retain(|c| c.name != command.name);
85
86        // Add new command
87        commands.push(command);
88        tracing::debug!(
89            "CommandRegistry::register: plugin_commands now has {} items",
90            commands.len()
91        );
92    }
93
94    /// Unregister a command by name
95    pub fn unregister(&self, name: &str) {
96        let mut commands = self.plugin_commands.write().unwrap();
97        commands.retain(|c| c.name != name);
98    }
99
100    /// Unregister all commands registered by a specific plugin
101    pub fn unregister_by_prefix(&self, prefix: &str) {
102        let mut commands = self.plugin_commands.write().unwrap();
103        commands.retain(|c| !c.name.starts_with(prefix));
104    }
105
106    /// Get all commands (built-in + plugin)
107    pub fn get_all(&self) -> Vec<Command> {
108        let mut all_commands = self.builtin_commands.clone();
109
110        let plugin_commands = self.plugin_commands.read().unwrap();
111        let plugin_count = plugin_commands.len();
112
113        // Debug: check if vi_mode_toggle is in plugin commands
114        let target_action =
115            crate::input::keybindings::Action::PluginAction("vi_mode_toggle".to_string());
116        let has_target = plugin_commands.iter().any(|c| c.action == target_action);
117        if has_target {
118            tracing::debug!("get_all: vi_mode_toggle found via comparison!");
119        } else if plugin_count > 0 {
120            tracing::debug!(
121                "get_all: {} plugin commands but vi_mode_toggle NOT found",
122                plugin_count
123            );
124        }
125
126        all_commands.extend(plugin_commands.iter().cloned());
127
128        tracing::trace!(
129            "CommandRegistry::get_all: {} builtin + {} plugin = {} total",
130            self.builtin_commands.len(),
131            plugin_count,
132            all_commands.len()
133        );
134        all_commands
135    }
136
137    /// Filter commands by fuzzy matching query with context awareness
138    ///
139    /// When query is empty, commands are sorted by recency (most recently used first).
140    /// When query is not empty, commands are sorted by match quality (fzf-style scoring)
141    /// with recency as tiebreaker for equal scores.
142    /// Disabled commands always appear after enabled ones.
143    pub fn filter(
144        &self,
145        query: &str,
146        current_context: KeyContext,
147        keybinding_resolver: &crate::input::keybindings::KeybindingResolver,
148        selection_active: bool,
149        active_custom_contexts: &std::collections::HashSet<String>,
150        active_buffer_mode: Option<&str>,
151    ) -> Vec<Suggestion> {
152        let commands = self.get_all();
153
154        // Helper function to check if command should be visible (custom context check)
155        // Commands with unmet custom contexts are completely hidden, not just disabled
156        // A custom context is satisfied if:
157        // 1. It's in the global active_custom_contexts set, OR
158        // 2. It matches the focused buffer's mode (for buffer-scoped commands)
159        let is_visible = |cmd: &Command| -> bool {
160            cmd.custom_contexts.is_empty()
161                || cmd.custom_contexts.iter().all(|ctx| {
162                    active_custom_contexts.contains(ctx)
163                        || active_buffer_mode.is_some_and(|mode| mode == ctx)
164                })
165        };
166
167        // Helper function to check if command is available in current context
168        let is_available = |cmd: &Command| -> bool {
169            // Global commands are always available
170            if cmd.contexts.contains(&KeyContext::Global) {
171                return true;
172            }
173
174            // Check built-in contexts
175            cmd.contexts.is_empty() || cmd.contexts.contains(&current_context)
176        };
177
178        // Helper to create a suggestion from a command
179        let make_suggestion =
180            |cmd: &Command, score: i32, localized_name: String, localized_desc: String| {
181                let mut available = is_available(cmd);
182                if cmd.action == Action::FindInSelection && !selection_active {
183                    available = false;
184                }
185                let keybinding =
186                    keybinding_resolver.get_keybinding_for_action(&cmd.action, current_context);
187                let history_pos = self.history_position(&cmd.name);
188
189                let suggestion = Suggestion::with_source(
190                    localized_name,
191                    Some(localized_desc),
192                    !available,
193                    keybinding,
194                    Some(cmd.source.clone()),
195                );
196                (suggestion, history_pos, score)
197            };
198
199        // First, try to match by name only
200        // Commands with unmet custom contexts are completely hidden
201        let mut suggestions: Vec<(Suggestion, Option<usize>, i32)> = commands
202            .iter()
203            .filter(|cmd| is_visible(cmd))
204            .filter_map(|cmd| {
205                let localized_name = cmd.get_localized_name();
206                let name_result = fuzzy_match(query, &localized_name);
207                if name_result.matched {
208                    let localized_desc = cmd.get_localized_description();
209                    Some(make_suggestion(
210                        cmd,
211                        name_result.score,
212                        localized_name,
213                        localized_desc,
214                    ))
215                } else {
216                    None
217                }
218            })
219            .collect();
220
221        // If no name matches found, try description matching as a fallback
222        if suggestions.is_empty() && !query.is_empty() {
223            suggestions = commands
224                .iter()
225                .filter(|cmd| is_visible(cmd))
226                .filter_map(|cmd| {
227                    let localized_desc = cmd.get_localized_description();
228                    let desc_result = fuzzy_match(query, &localized_desc);
229                    if desc_result.matched {
230                        let localized_name = cmd.get_localized_name();
231                        // Description matches get reduced score
232                        Some(make_suggestion(
233                            cmd,
234                            desc_result.score.saturating_sub(50),
235                            localized_name,
236                            localized_desc,
237                        ))
238                    } else {
239                        None
240                    }
241                })
242                .collect();
243        }
244
245        // Sort by:
246        // 1. Disabled status (enabled first)
247        // 2. Fuzzy match score (higher is better) - only when query is not empty
248        // 3. History position (recent first, then never-used alphabetically)
249        let has_query = !query.is_empty();
250        suggestions.sort_by(|(a, a_hist, a_score), (b, b_hist, b_score)| {
251            // First sort by disabled status
252            match a.disabled.cmp(&b.disabled) {
253                std::cmp::Ordering::Equal => {}
254                other => return other,
255            }
256
257            // When there's a query, sort by fuzzy score (higher is better)
258            if has_query {
259                match b_score.cmp(a_score) {
260                    std::cmp::Ordering::Equal => {}
261                    other => return other,
262                }
263            }
264
265            // Then sort by history position (lower = more recent = better)
266            match (a_hist, b_hist) {
267                (Some(a_pos), Some(b_pos)) => a_pos.cmp(b_pos),
268                (Some(_), None) => std::cmp::Ordering::Less, // In history beats not in history
269                (None, Some(_)) => std::cmp::Ordering::Greater,
270                (None, None) => a.text.cmp(&b.text), // Alphabetical for never-used commands
271            }
272        });
273
274        // Extract just the suggestions
275        suggestions.into_iter().map(|(s, _, _)| s).collect()
276    }
277
278    /// Get count of registered plugin commands
279    pub fn plugin_command_count(&self) -> usize {
280        self.plugin_commands.read().unwrap().len()
281    }
282
283    /// Get count of total commands (built-in + plugin)
284    pub fn total_command_count(&self) -> usize {
285        self.builtin_commands.len() + self.plugin_command_count()
286    }
287
288    /// Find a command by exact name match
289    pub fn find_by_name(&self, name: &str) -> Option<Command> {
290        // Check plugin commands first (they can override built-in)
291        {
292            let plugin_commands = self.plugin_commands.read().unwrap();
293            if let Some(cmd) = plugin_commands.iter().find(|c| c.name == name) {
294                return Some(cmd.clone());
295            }
296        }
297
298        // Then check built-in commands
299        self.builtin_commands
300            .iter()
301            .find(|c| c.name == name)
302            .cloned()
303    }
304}
305
306impl Default for CommandRegistry {
307    fn default() -> Self {
308        Self::new()
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use crate::input::commands::CommandSource;
316    use crate::input::keybindings::Action;
317
318    #[test]
319    fn test_command_registry_creation() {
320        let registry = CommandRegistry::new();
321        assert!(registry.total_command_count() > 0); // Has built-in commands
322        assert_eq!(registry.plugin_command_count(), 0); // No plugin commands yet
323    }
324
325    #[test]
326    fn test_register_command() {
327        let registry = CommandRegistry::new();
328
329        let custom_command = Command {
330            name: "Test Command".to_string(),
331            description: "A test command".to_string(),
332            action: Action::None,
333            contexts: vec![],
334            custom_contexts: vec![],
335            source: CommandSource::Builtin,
336        };
337
338        registry.register(custom_command.clone());
339        assert_eq!(registry.plugin_command_count(), 1);
340
341        let found = registry.find_by_name("Test Command");
342        assert!(found.is_some());
343        assert_eq!(found.unwrap().description, "A test command");
344    }
345
346    #[test]
347    fn test_unregister_command() {
348        let registry = CommandRegistry::new();
349
350        let custom_command = Command {
351            name: "Test Command".to_string(),
352            description: "A test command".to_string(),
353            action: Action::None,
354            contexts: vec![],
355            custom_contexts: vec![],
356            source: CommandSource::Builtin,
357        };
358
359        registry.register(custom_command);
360        assert_eq!(registry.plugin_command_count(), 1);
361
362        registry.unregister("Test Command");
363        assert_eq!(registry.plugin_command_count(), 0);
364    }
365
366    #[test]
367    fn test_register_replaces_existing() {
368        let registry = CommandRegistry::new();
369
370        let command1 = Command {
371            name: "Test Command".to_string(),
372            description: "First version".to_string(),
373            action: Action::None,
374            contexts: vec![],
375            custom_contexts: vec![],
376            source: CommandSource::Builtin,
377        };
378
379        let command2 = Command {
380            name: "Test Command".to_string(),
381            description: "Second version".to_string(),
382            action: Action::None,
383            contexts: vec![],
384            custom_contexts: vec![],
385            source: CommandSource::Builtin,
386        };
387
388        registry.register(command1);
389        assert_eq!(registry.plugin_command_count(), 1);
390
391        registry.register(command2);
392        assert_eq!(registry.plugin_command_count(), 1); // Still just one
393
394        let found = registry.find_by_name("Test Command").unwrap();
395        assert_eq!(found.description, "Second version");
396    }
397
398    #[test]
399    fn test_unregister_by_prefix() {
400        let registry = CommandRegistry::new();
401
402        registry.register(Command {
403            name: "Plugin A: Command 1".to_string(),
404            description: "".to_string(),
405            action: Action::None,
406            contexts: vec![],
407            custom_contexts: vec![],
408            source: CommandSource::Builtin,
409        });
410
411        registry.register(Command {
412            name: "Plugin A: Command 2".to_string(),
413            description: "".to_string(),
414            action: Action::None,
415            contexts: vec![],
416            custom_contexts: vec![],
417            source: CommandSource::Builtin,
418        });
419
420        registry.register(Command {
421            name: "Plugin B: Command".to_string(),
422            description: "".to_string(),
423            action: Action::None,
424            contexts: vec![],
425            custom_contexts: vec![],
426            source: CommandSource::Builtin,
427        });
428
429        assert_eq!(registry.plugin_command_count(), 3);
430
431        registry.unregister_by_prefix("Plugin A:");
432        assert_eq!(registry.plugin_command_count(), 1);
433
434        let remaining = registry.find_by_name("Plugin B: Command");
435        assert!(remaining.is_some());
436    }
437
438    #[test]
439    fn test_filter_commands() {
440        use crate::config::Config;
441        use crate::input::keybindings::KeybindingResolver;
442
443        let registry = CommandRegistry::new();
444        let config = Config::default();
445        let keybindings = KeybindingResolver::new(&config);
446
447        registry.register(Command {
448            name: "Test Save".to_string(),
449            description: "Test save command".to_string(),
450            action: Action::None,
451            contexts: vec![KeyContext::Normal],
452            custom_contexts: vec![],
453            source: CommandSource::Builtin,
454        });
455
456        let empty_contexts = std::collections::HashSet::new();
457        let results = registry.filter(
458            "save",
459            KeyContext::Normal,
460            &keybindings,
461            false,
462            &empty_contexts,
463            None,
464        );
465        assert!(results.len() >= 2); // At least "Save File" + "Test Save"
466
467        // Check that both built-in and custom commands appear
468        let names: Vec<String> = results.iter().map(|s| s.text.clone()).collect();
469        assert!(names.iter().any(|n| n.contains("Save")));
470    }
471
472    #[test]
473    fn test_context_filtering() {
474        use crate::config::Config;
475        use crate::input::keybindings::KeybindingResolver;
476
477        let registry = CommandRegistry::new();
478        let config = Config::default();
479        let keybindings = KeybindingResolver::new(&config);
480
481        registry.register(Command {
482            name: "Normal Only".to_string(),
483            description: "Available only in normal context".to_string(),
484            action: Action::None,
485            contexts: vec![KeyContext::Normal],
486            custom_contexts: vec![],
487            source: CommandSource::Builtin,
488        });
489
490        registry.register(Command {
491            name: "Popup Only".to_string(),
492            description: "Available only in popup context".to_string(),
493            action: Action::None,
494            contexts: vec![KeyContext::Popup],
495            custom_contexts: vec![],
496            source: CommandSource::Builtin,
497        });
498
499        // In normal context, "Popup Only" should be disabled
500        let empty_contexts = std::collections::HashSet::new();
501        let results = registry.filter(
502            "",
503            KeyContext::Normal,
504            &keybindings,
505            false,
506            &empty_contexts,
507            None,
508        );
509        let popup_only = results.iter().find(|s| s.text == "Popup Only");
510        assert!(popup_only.is_some());
511        assert!(popup_only.unwrap().disabled);
512
513        // In popup context, "Normal Only" should be disabled
514        let results = registry.filter(
515            "",
516            KeyContext::Popup,
517            &keybindings,
518            false,
519            &empty_contexts,
520            None,
521        );
522        let normal_only = results.iter().find(|s| s.text == "Normal Only");
523        assert!(normal_only.is_some());
524        assert!(normal_only.unwrap().disabled);
525    }
526
527    #[test]
528    fn test_get_all_merges_commands() {
529        let registry = CommandRegistry::new();
530        let initial_count = registry.total_command_count();
531
532        registry.register(Command {
533            name: "Custom 1".to_string(),
534            description: "".to_string(),
535            action: Action::None,
536            contexts: vec![],
537            custom_contexts: vec![],
538            source: CommandSource::Builtin,
539        });
540
541        registry.register(Command {
542            name: "Custom 2".to_string(),
543            description: "".to_string(),
544            action: Action::None,
545            contexts: vec![],
546            custom_contexts: vec![],
547            source: CommandSource::Builtin,
548        });
549
550        let all = registry.get_all();
551        assert_eq!(all.len(), initial_count + 2);
552    }
553
554    #[test]
555    fn test_plugin_command_overrides_builtin() {
556        let registry = CommandRegistry::new();
557
558        // Check a built-in command exists
559        let builtin = registry.find_by_name("Save File");
560        assert!(builtin.is_some());
561        let original_desc = builtin.unwrap().description;
562
563        // Override it with a plugin command
564        registry.register(Command {
565            name: "Save File".to_string(),
566            description: "Custom save implementation".to_string(),
567            action: Action::None,
568            contexts: vec![],
569            custom_contexts: vec![],
570            source: CommandSource::Builtin,
571        });
572
573        // Should now find the custom version
574        let custom = registry.find_by_name("Save File").unwrap();
575        assert_eq!(custom.description, "Custom save implementation");
576        assert_ne!(custom.description, original_desc);
577    }
578
579    #[test]
580    fn test_record_usage() {
581        let mut registry = CommandRegistry::new();
582
583        registry.record_usage("Save File");
584        assert_eq!(registry.history_position("Save File"), Some(0));
585
586        registry.record_usage("Open File");
587        assert_eq!(registry.history_position("Open File"), Some(0));
588        assert_eq!(registry.history_position("Save File"), Some(1));
589
590        // Using Save File again should move it to front
591        registry.record_usage("Save File");
592        assert_eq!(registry.history_position("Save File"), Some(0));
593        assert_eq!(registry.history_position("Open File"), Some(1));
594    }
595
596    #[test]
597    fn test_history_sorting() {
598        use crate::config::Config;
599        use crate::input::keybindings::KeybindingResolver;
600
601        let mut registry = CommandRegistry::new();
602        let config = Config::default();
603        let keybindings = KeybindingResolver::new(&config);
604
605        // Record some commands
606        registry.record_usage("Quit");
607        registry.record_usage("Save File");
608        registry.record_usage("Open File");
609
610        // Filter with empty query should return history-sorted results
611        let empty_contexts = std::collections::HashSet::new();
612        let results = registry.filter(
613            "",
614            KeyContext::Normal,
615            &keybindings,
616            false,
617            &empty_contexts,
618            None,
619        );
620
621        // Find positions of our test commands in results
622        let open_pos = results.iter().position(|s| s.text == "Open File").unwrap();
623        let save_pos = results.iter().position(|s| s.text == "Save File").unwrap();
624        let quit_pos = results.iter().position(|s| s.text == "Quit").unwrap();
625
626        // Most recently used should be first
627        assert!(
628            open_pos < save_pos,
629            "Open File should come before Save File"
630        );
631        assert!(save_pos < quit_pos, "Save File should come before Quit");
632    }
633
634    #[test]
635    fn test_history_max_size() {
636        let mut registry = CommandRegistry::new();
637
638        // Add more than MAX_HISTORY_SIZE commands
639        for i in 0..60 {
640            registry.record_usage(&format!("Command {}", i));
641        }
642
643        // Should be trimmed to MAX_HISTORY_SIZE
644        assert_eq!(
645            registry.command_history.len(),
646            CommandRegistry::MAX_HISTORY_SIZE
647        );
648
649        // Most recent should still be at front
650        assert_eq!(registry.history_position("Command 59"), Some(0));
651
652        // Oldest should be trimmed
653        assert_eq!(registry.history_position("Command 0"), None);
654    }
655
656    #[test]
657    fn test_unused_commands_alphabetical() {
658        use crate::config::Config;
659        use crate::input::keybindings::KeybindingResolver;
660
661        let mut registry = CommandRegistry::new();
662        let config = Config::default();
663        let keybindings = KeybindingResolver::new(&config);
664
665        // Register some custom commands (never used)
666        registry.register(Command {
667            name: "Zebra Command".to_string(),
668            description: "".to_string(),
669            action: Action::None,
670            contexts: vec![],
671            custom_contexts: vec![],
672            source: CommandSource::Builtin,
673        });
674
675        registry.register(Command {
676            name: "Alpha Command".to_string(),
677            description: "".to_string(),
678            action: Action::None,
679            contexts: vec![],
680            custom_contexts: vec![],
681            source: CommandSource::Builtin,
682        });
683
684        // Use one built-in command
685        registry.record_usage("Save File");
686
687        let empty_contexts = std::collections::HashSet::new();
688        let results = registry.filter(
689            "",
690            KeyContext::Normal,
691            &keybindings,
692            false,
693            &empty_contexts,
694            None,
695        );
696
697        let save_pos = results.iter().position(|s| s.text == "Save File").unwrap();
698        let alpha_pos = results
699            .iter()
700            .position(|s| s.text == "Alpha Command")
701            .unwrap();
702        let zebra_pos = results
703            .iter()
704            .position(|s| s.text == "Zebra Command")
705            .unwrap();
706
707        // Used command should be first
708        assert!(
709            save_pos < alpha_pos,
710            "Save File should come before Alpha Command"
711        );
712        // Unused commands should be alphabetical
713        assert!(
714            alpha_pos < zebra_pos,
715            "Alpha Command should come before Zebra Command"
716        );
717    }
718
719    #[test]
720    fn test_required_commands_exist() {
721        // This test ensures that all required command palette entries exist.
722        // If this test fails, it means a command was removed or renamed.
723        crate::i18n::set_locale("en");
724        let registry = CommandRegistry::new();
725
726        let required_commands = [
727            // LSP commands
728            ("Show Completions", Action::LspCompletion),
729            ("Go to Definition", Action::LspGotoDefinition),
730            ("Show Hover Info", Action::LspHover),
731            ("Find References", Action::LspReferences),
732            // Help commands
733            ("Show Manual", Action::ShowHelp),
734            ("Show Keyboard Shortcuts", Action::ShowKeyboardShortcuts),
735            // Scroll commands
736            ("Scroll Up", Action::ScrollUp),
737            ("Scroll Down", Action::ScrollDown),
738            ("Scroll Tabs Left", Action::ScrollTabsLeft),
739            ("Scroll Tabs Right", Action::ScrollTabsRight),
740            // Navigation commands
741            ("Smart Home", Action::SmartHome),
742            // Delete commands
743            ("Delete Word Backward", Action::DeleteWordBackward),
744            ("Delete Word Forward", Action::DeleteWordForward),
745            ("Delete to End of Line", Action::DeleteToLineEnd),
746        ];
747
748        for (name, expected_action) in required_commands {
749            let cmd = registry.find_by_name(name);
750            assert!(
751                cmd.is_some(),
752                "Command '{}' should exist in command palette",
753                name
754            );
755            assert_eq!(
756                cmd.unwrap().action,
757                expected_action,
758                "Command '{}' should have action {:?}",
759                name,
760                expected_action
761            );
762        }
763    }
764}