Skip to main content

sqry_core/plugin/
manager.rs

1//! Plugin manager for language plugin registration and lookup.
2
3use super::types::LanguagePlugin;
4use std::collections::HashMap;
5use std::path::Path;
6
7/// Manages language plugins for sqry.
8///
9/// The `PluginManager` is responsible for:
10/// - Registering built-in plugins (statically linked)
11/// - Looking up plugins by file extension or language name
12/// - Enumerating available plugins
13///
14/// # Phase 2 Scope
15///
16/// Phase 2 supports built-in plugins only (statically linked). External plugin loading
17/// (.so/.dylib) will be added in Phase 3.
18///
19/// # Example
20///
21/// ```ignore
22/// use sqry_core::plugin::PluginManager;
23///
24/// // Create manager with default built-in plugins
25/// let manager = PluginManager::new();
26///
27/// // Lookup plugin by extension
28/// if let Some(plugin) = manager.plugin_for_extension("rs") {
29///     println!("Found Rust plugin");
30/// }
31///
32/// // Lookup plugin by language ID
33/// if let Some(plugin) = manager.plugin_by_id("rust") {
34///     println!("Found Rust plugin by ID");
35/// }
36/// ```
37pub struct PluginManager {
38    /// Built-in plugins (statically linked)
39    builtin_plugins: Vec<Box<dyn LanguagePlugin>>,
40
41    /// Fast lookup cache: extension -> index in `builtin_plugins`
42    extension_cache: HashMap<String, usize>,
43
44    /// Fast lookup cache: language ID -> index in `builtin_plugins`
45    id_cache: HashMap<String, usize>,
46
47    /// Cached hash of all plugin versions (for cache invalidation)
48    plugin_state_hash: u64,
49}
50
51impl PluginManager {
52    /// Create a new `PluginManager` with default built-in plugins.
53    ///
54    /// Default built-ins will be registered in Phase 2 after the first plugin is implemented.
55    /// For now, creates an empty manager.
56    ///
57    /// # Note
58    ///
59    /// For tests requiring built-in plugins, use the `plugin_factory_helpers` test module:
60    /// ```ignore
61    /// use plugin_factory_helpers::with_builtin_plugins;
62    /// let manager = with_builtin_plugins();
63    /// ```
64    #[must_use]
65    pub fn new() -> Self {
66        Self::with_plugins(Vec::new())
67    }
68
69    /// Create an empty `PluginManager` for testing (test-only).
70    ///
71    /// Creates a manager with no plugins registered. Useful for tests that need
72    /// isolation or want to manually register specific plugins.
73    ///
74    /// # Example
75    ///
76    /// ```ignore
77    /// use sqry_core::plugin::PluginManager;
78    ///
79    /// // Create empty manager for isolated testing
80    /// let manager = PluginManager::empty();
81    /// assert_eq!(manager.plugins().len(), 0);
82    ///
83    /// // Manually register specific plugin
84    /// let mut manager = PluginManager::empty();
85    /// manager.register_builtin(Box::new(RustPlugin::new()));
86    /// assert_eq!(manager.plugins().len(), 1);
87    /// ```
88    #[cfg(test)]
89    #[must_use]
90    pub fn empty() -> Self {
91        Self::with_plugins(Vec::new())
92    }
93
94    /// Create a `PluginManager` with custom plugins.
95    ///
96    /// This is primarily for testing, but can also be used to create a manager with
97    /// a specific set of plugins.
98    ///
99    /// # Arguments
100    ///
101    /// * `plugins` - Vector of boxed plugins to register
102    ///
103    /// # Example
104    ///
105    /// ```ignore
106    /// use sqry_core::plugin::PluginManager;
107    ///
108    /// let plugins = vec![
109    ///     Box::new(RustPlugin::new()) as Box<dyn LanguagePlugin>,
110    /// ];
111    ///
112    /// let manager = PluginManager::with_plugins(plugins);
113    /// ```
114    #[must_use]
115    pub fn with_plugins(plugins: Vec<Box<dyn LanguagePlugin>>) -> Self {
116        let plugin_state_hash = Self::compute_plugin_hash_static(&plugins);
117
118        let mut manager = Self {
119            builtin_plugins: plugins,
120            extension_cache: HashMap::new(),
121            id_cache: HashMap::new(),
122            plugin_state_hash,
123        };
124
125        // Build caches
126        manager.rebuild_caches();
127
128        manager
129    }
130
131    /// Register a built-in plugin.
132    ///
133    /// Adds a plugin to the manager and updates lookup caches.
134    ///
135    /// # Arguments
136    ///
137    /// * `plugin` - Boxed plugin to register
138    ///
139    /// # Example
140    ///
141    /// ```ignore
142    /// let mut manager = PluginManager::new();
143    /// manager.register_builtin(Box::new(RustPlugin::new()));
144    /// ```
145    pub fn register_builtin(&mut self, plugin: Box<dyn LanguagePlugin>) {
146        let index = self.builtin_plugins.len();
147        self.builtin_plugins.push(plugin);
148
149        // Update caches for the new plugin
150        let plugin_ref = &self.builtin_plugins[index];
151        let metadata = plugin_ref.metadata();
152
153        // Cache language ID
154        self.id_cache.insert(metadata.id.to_string(), index);
155
156        // Cache extensions
157        for ext in plugin_ref.extensions() {
158            self.extension_cache.insert((*ext).to_string(), index);
159        }
160
161        // Update plugin state hash
162        self.update_plugin_hash();
163    }
164
165    /// Get plugin for file extension.
166    ///
167    /// Returns a reference to the plugin that handles the given file extension.
168    ///
169    /// # Arguments
170    ///
171    /// * `ext` - File extension without leading dot (e.g., "rs" not ".rs")
172    ///
173    /// # Returns
174    ///
175    /// Optional reference to the language plugin, or None if no plugin handles this extension.
176    ///
177    /// # Example
178    ///
179    /// ```ignore
180    /// if let Some(plugin) = manager.plugin_for_extension("rs") {
181    ///     println!("Found {} plugin", plugin.metadata().name);
182    /// }
183    /// ```
184    #[must_use]
185    pub fn plugin_for_extension(&self, ext: &str) -> Option<&dyn LanguagePlugin> {
186        let ext = ext.to_ascii_lowercase();
187        self.extension_cache
188            .get(ext.as_str())
189            .and_then(|&index| self.builtin_plugins.get(index).map(|p| &**p))
190    }
191
192    /// Get plugin for a file path, including special filename routing.
193    ///
194    /// This helper supports standard extension matching and special-case routing
195    /// for filenames like `Pulumi.<stack>.yaml` that cannot be resolved by extension alone.
196    #[must_use]
197    pub fn plugin_for_path(&self, path: &Path) -> Option<&dyn LanguagePlugin> {
198        let filename = path
199            .file_name()
200            .and_then(|name| name.to_str())
201            .map(|name| name.trim_start_matches('.').to_ascii_lowercase());
202
203        if let Some(name) = filename.as_deref()
204            && name.starts_with("pulumi.")
205        {
206            let ext = Path::new(name).extension().and_then(|e| e.to_str());
207            if matches!(ext, Some("yaml" | "yml" | "json"))
208                && let Some(plugin) = self.plugin_by_id("pulumi")
209            {
210                return Some(plugin);
211            }
212        }
213
214        if let Some(ext) = path.extension().and_then(|e| e.to_str())
215            && let Some(plugin) = self.plugin_for_extension(ext)
216        {
217            return Some(plugin);
218        }
219
220        filename
221            .as_deref()
222            .and_then(|name| self.plugin_for_extension(name))
223    }
224
225    /// Get plugin by language ID.
226    ///
227    /// Returns a reference to the plugin with the given stable language ID.
228    ///
229    /// # Arguments
230    ///
231    /// * `id` - Stable language ID (e.g., "rust", "javascript")
232    ///
233    /// # Returns
234    ///
235    /// Optional reference to the language plugin, or None if no plugin has this ID.
236    ///
237    /// # Example
238    ///
239    /// ```ignore
240    /// if let Some(plugin) = manager.plugin_by_id("rust") {
241    ///     println!("Found Rust plugin");
242    /// }
243    /// ```
244    #[must_use]
245    pub fn plugin_by_id(&self, id: &str) -> Option<&dyn LanguagePlugin> {
246        self.id_cache
247            .get(id)
248            .and_then(|&index| self.builtin_plugins.get(index).map(|p| &**p))
249    }
250
251    /// List all registered plugins.
252    ///
253    /// Returns a vector of references to all plugins in the manager.
254    ///
255    /// # Returns
256    ///
257    /// Vector of plugin references
258    ///
259    /// # Example
260    ///
261    /// ```ignore
262    /// for plugin in manager.plugins() {
263    ///     let metadata = plugin.metadata();
264    ///     println!("Plugin: {} v{}", metadata.name, metadata.version);
265    /// }
266    /// ```
267    #[must_use]
268    pub fn plugins(&self) -> Vec<&dyn LanguagePlugin> {
269        self.builtin_plugins.iter().map(|p| &**p).collect()
270    }
271
272    /// Compute deterministic hash of all plugin versions
273    fn compute_plugin_hash_static(plugins: &[Box<dyn LanguagePlugin>]) -> u64 {
274        use std::collections::hash_map::DefaultHasher;
275        use std::hash::Hasher;
276
277        let mut hasher = DefaultHasher::new();
278
279        // Sort plugin IDs for deterministic ordering
280        let mut plugin_data: Vec<_> = plugins
281            .iter()
282            .map(|p| {
283                let meta = p.metadata();
284                (meta.id.to_string(), meta.version.to_string())
285            })
286            .collect();
287        plugin_data.sort_by(|a, b| a.0.cmp(&b.0));
288
289        for (id, version) in plugin_data {
290            hasher.write(id.as_bytes());
291            hasher.write(version.as_bytes());
292        }
293
294        hasher.finish()
295    }
296
297    /// Get cached plugin state hash (for cache key)
298    #[must_use]
299    pub fn plugin_state_hash(&self) -> u64 {
300        self.plugin_state_hash
301    }
302
303    /// Update plugin state hash (call after plugin changes)
304    fn update_plugin_hash(&mut self) {
305        self.plugin_state_hash = Self::compute_plugin_hash_static(&self.builtin_plugins);
306    }
307
308    /// Rebuild lookup caches.
309    ///
310    /// Called internally after plugins are modified. Rebuilds the `extension_cache`
311    /// and `id_cache` from the current plugin list.
312    fn rebuild_caches(&mut self) {
313        self.extension_cache.clear();
314        self.id_cache.clear();
315
316        for (index, plugin) in self.builtin_plugins.iter().enumerate() {
317            let metadata = plugin.metadata();
318
319            // Cache language ID
320            self.id_cache.insert(metadata.id.to_string(), index);
321
322            // Cache extensions
323            for ext in plugin.extensions() {
324                self.extension_cache.insert((*ext).to_string(), index);
325            }
326        }
327    }
328}
329
330impl Default for PluginManager {
331    fn default() -> Self {
332        Self::new()
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use crate::ast::Scope;
340    use crate::plugin::LanguageMetadata;
341    use std::path::Path;
342
343    // Mock plugin for testing
344    struct MockPlugin {
345        id: &'static str,
346        name: &'static str,
347        extensions: &'static [&'static str],
348    }
349
350    impl MockPlugin {
351        fn new(id: &'static str, name: &'static str, extensions: &'static [&'static str]) -> Self {
352            Self {
353                id,
354                name,
355                extensions,
356            }
357        }
358    }
359
360    impl LanguagePlugin for MockPlugin {
361        fn metadata(&self) -> LanguageMetadata {
362            LanguageMetadata {
363                id: self.id,
364                name: self.name,
365                version: "1.0.0",
366                author: "Test",
367                description: "Mock plugin for testing",
368                tree_sitter_version: "0.24",
369            }
370        }
371
372        fn extensions(&self) -> &'static [&'static str] {
373            self.extensions
374        }
375
376        fn language(&self) -> tree_sitter::Language {
377            // Return a test language (this is just for testing)
378            // In real plugins, this would return tree_sitter_rust::LANGUAGE, etc.
379            sqry_test_support::test_language()
380        }
381
382        fn parse_ast(
383            &self,
384            _content: &[u8],
385        ) -> Result<tree_sitter::Tree, super::super::error::ParseError> {
386            Err(super::super::error::ParseError::TreeSitterFailed)
387        }
388
389        fn extract_scopes(
390            &self,
391            _tree: &tree_sitter::Tree,
392            _content: &[u8],
393            _file_path: &Path,
394        ) -> Result<Vec<Scope>, super::super::error::ScopeError> {
395            Ok(Vec::new())
396        }
397    }
398
399    #[test]
400    fn test_empty_manager() {
401        let manager = PluginManager::new();
402        assert!(manager.plugin_for_extension("rs").is_none());
403        assert!(manager.plugin_by_id("rust").is_none());
404        assert_eq!(manager.plugins().len(), 0);
405    }
406
407    #[test]
408    fn test_register_plugin() {
409        let mut manager = PluginManager::new();
410        manager.register_builtin(Box::new(MockPlugin::new("rust", "Rust", &["rs"])));
411
412        assert!(manager.plugin_for_extension("rs").is_some());
413        assert!(manager.plugin_by_id("rust").is_some());
414        assert_eq!(manager.plugins().len(), 1);
415    }
416
417    #[test]
418    fn test_plugin_lookup_by_extension() {
419        let mut manager = PluginManager::new();
420        manager.register_builtin(Box::new(MockPlugin::new("rust", "Rust", &["rs"])));
421
422        let plugin = manager.plugin_for_extension("rs").unwrap();
423        assert_eq!(plugin.metadata().id, "rust");
424        assert_eq!(plugin.metadata().name, "Rust");
425    }
426
427    #[test]
428    fn test_plugin_lookup_by_extension_case_insensitive() {
429        let mut manager = PluginManager::new();
430        manager.register_builtin(Box::new(MockPlugin::new("rust", "Rust", &["rs"])));
431
432        assert!(manager.plugin_for_extension("RS").is_some());
433    }
434
435    #[test]
436    fn test_plugin_lookup_by_path_pulumi_stack() {
437        let mut manager = PluginManager::new();
438        manager.register_builtin(Box::new(MockPlugin::new(
439            "pulumi",
440            "Pulumi",
441            &["pulumi.yaml"],
442        )));
443
444        let plugin = manager
445            .plugin_for_path(Path::new("Pulumi.dev.yaml"))
446            .expect("pulumi plugin should match");
447        assert_eq!(plugin.metadata().id, "pulumi");
448    }
449
450    #[test]
451    fn test_plugin_lookup_by_id() {
452        let mut manager = PluginManager::new();
453        manager.register_builtin(Box::new(MockPlugin::new("rust", "Rust", &["rs"])));
454
455        let plugin = manager.plugin_by_id("rust").unwrap();
456        assert_eq!(plugin.metadata().id, "rust");
457        assert_eq!(plugin.metadata().name, "Rust");
458    }
459
460    #[test]
461    fn test_plugin_lookup_miss() {
462        let mut manager = PluginManager::new();
463        manager.register_builtin(Box::new(MockPlugin::new("rust", "Rust", &["rs"])));
464
465        assert!(manager.plugin_for_extension("js").is_none());
466        assert!(manager.plugin_by_id("javascript").is_none());
467    }
468
469    #[test]
470    fn test_multiple_extensions() {
471        let mut manager = PluginManager::new();
472        manager.register_builtin(Box::new(MockPlugin::new(
473            "typescript",
474            "TypeScript",
475            &["ts", "tsx"],
476        )));
477
478        assert!(manager.plugin_for_extension("ts").is_some());
479        assert!(manager.plugin_for_extension("tsx").is_some());
480
481        let plugin_ts = manager.plugin_for_extension("ts").unwrap();
482        let plugin_tsx = manager.plugin_for_extension("tsx").unwrap();
483
484        // Both extensions should return the same plugin
485        assert_eq!(plugin_ts.metadata().id, "typescript");
486        assert_eq!(plugin_tsx.metadata().id, "typescript");
487    }
488
489    #[test]
490    fn test_multiple_plugins() {
491        let mut manager = PluginManager::new();
492        manager.register_builtin(Box::new(MockPlugin::new("rust", "Rust", &["rs"])));
493        manager.register_builtin(Box::new(MockPlugin::new(
494            "javascript",
495            "JavaScript",
496            &["js"],
497        )));
498
499        assert_eq!(manager.plugins().len(), 2);
500        assert!(manager.plugin_for_extension("rs").is_some());
501        assert!(manager.plugin_for_extension("js").is_some());
502    }
503
504    #[test]
505    fn test_with_plugins() {
506        let plugins: Vec<Box<dyn LanguagePlugin>> = vec![
507            Box::new(MockPlugin::new("rust", "Rust", &["rs"])),
508            Box::new(MockPlugin::new("javascript", "JavaScript", &["js"])),
509        ];
510
511        let manager = PluginManager::with_plugins(plugins);
512
513        assert_eq!(manager.plugins().len(), 2);
514        assert!(manager.plugin_for_extension("rs").is_some());
515        assert!(manager.plugin_for_extension("js").is_some());
516    }
517
518    #[test]
519    fn test_plugin_state_hash_consistent() {
520        let manager1 = PluginManager::new();
521        let manager2 = PluginManager::new();
522        assert_eq!(manager1.plugin_state_hash(), manager2.plugin_state_hash());
523    }
524
525    #[test]
526    fn test_plugin_state_hash_changes() {
527        let mut manager = PluginManager::new();
528        let hash_before = manager.plugin_state_hash();
529
530        manager.register_builtin(Box::new(MockPlugin::new("rust", "Rust", &["rs"])));
531        let hash_after = manager.plugin_state_hash();
532
533        assert_ne!(hash_before, hash_after);
534    }
535
536    #[test]
537    fn test_plugin_manager_is_send_sync() {
538        // Compile-time verification that PluginManager is thread-safe
539        // This test will fail to compile if PluginManager doesn't implement Send + Sync
540        fn assert_send_sync<T: Send + Sync>() {}
541        assert_send_sync::<PluginManager>();
542    }
543
544    // Task 1.2: Enhanced factory method tests
545
546    #[test]
547    fn test_selective_loading() {
548        // Tests with_plugins() with a subset of plugins
549        let selective_plugins: Vec<Box<dyn LanguagePlugin>> = vec![Box::new(MockPlugin::new(
550            "test-lang",
551            "Test Language",
552            &["test"],
553        ))];
554
555        let manager = PluginManager::with_plugins(selective_plugins);
556
557        // Should have exactly 1 plugin
558        assert_eq!(manager.plugins().len(), 1, "Expected exactly 1 plugin");
559
560        // Should be able to find the test plugin
561        assert!(
562            manager.plugin_for_extension("test").is_some(),
563            "Test plugin should be available"
564        );
565
566        // Should NOT have other plugins
567        assert!(
568            manager.plugin_for_extension("rs").is_none(),
569            "Rust plugin should not be loaded (selective loading)"
570        );
571        assert!(
572            manager.plugin_for_extension("js").is_none(),
573            "JavaScript plugin should not be loaded (selective loading)"
574        );
575    }
576
577    #[test]
578    fn test_empty_has_no_plugins() {
579        // Validates empty() creates isolated manager with no plugins
580        let manager = PluginManager::empty();
581
582        assert_eq!(
583            manager.plugins().len(),
584            0,
585            "Empty manager should have 0 plugins"
586        );
587
588        // Should not find any plugins
589        assert!(
590            manager.plugin_for_extension("rs").is_none(),
591            "Empty manager should not have Rust plugin"
592        );
593        assert!(
594            manager.plugin_by_id("rust").is_none(),
595            "Empty manager should not have Rust plugin by ID"
596        );
597
598        // Verify can manually register after creation
599        let mut mutable_manager = PluginManager::empty();
600        mutable_manager.register_builtin(Box::new(MockPlugin::new("test", "Test", &["test"])));
601
602        assert_eq!(
603            mutable_manager.plugins().len(),
604            1,
605            "After manual registration, should have 1 plugin"
606        );
607    }
608}