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            // Keep this inline test self-contained so sanitized OSS release
378            // validation can still compile lib tests after internal-only
379            // support crates are removed from the workspace.
380            tree_sitter_rust::LANGUAGE.into()
381        }
382
383        fn parse_ast(
384            &self,
385            _content: &[u8],
386        ) -> Result<tree_sitter::Tree, super::super::error::ParseError> {
387            Err(super::super::error::ParseError::TreeSitterFailed)
388        }
389
390        fn extract_scopes(
391            &self,
392            _tree: &tree_sitter::Tree,
393            _content: &[u8],
394            _file_path: &Path,
395        ) -> Result<Vec<Scope>, super::super::error::ScopeError> {
396            Ok(Vec::new())
397        }
398    }
399
400    #[test]
401    fn test_empty_manager() {
402        let manager = PluginManager::new();
403        assert!(manager.plugin_for_extension("rs").is_none());
404        assert!(manager.plugin_by_id("rust").is_none());
405        assert_eq!(manager.plugins().len(), 0);
406    }
407
408    #[test]
409    fn test_register_plugin() {
410        let mut manager = PluginManager::new();
411        manager.register_builtin(Box::new(MockPlugin::new("rust", "Rust", &["rs"])));
412
413        assert!(manager.plugin_for_extension("rs").is_some());
414        assert!(manager.plugin_by_id("rust").is_some());
415        assert_eq!(manager.plugins().len(), 1);
416    }
417
418    #[test]
419    fn test_plugin_lookup_by_extension() {
420        let mut manager = PluginManager::new();
421        manager.register_builtin(Box::new(MockPlugin::new("rust", "Rust", &["rs"])));
422
423        let plugin = manager.plugin_for_extension("rs").unwrap();
424        assert_eq!(plugin.metadata().id, "rust");
425        assert_eq!(plugin.metadata().name, "Rust");
426    }
427
428    #[test]
429    fn test_plugin_lookup_by_extension_case_insensitive() {
430        let mut manager = PluginManager::new();
431        manager.register_builtin(Box::new(MockPlugin::new("rust", "Rust", &["rs"])));
432
433        assert!(manager.plugin_for_extension("RS").is_some());
434    }
435
436    #[test]
437    fn test_plugin_lookup_by_path_pulumi_stack() {
438        let mut manager = PluginManager::new();
439        manager.register_builtin(Box::new(MockPlugin::new(
440            "pulumi",
441            "Pulumi",
442            &["pulumi.yaml"],
443        )));
444
445        let plugin = manager
446            .plugin_for_path(Path::new("Pulumi.dev.yaml"))
447            .expect("pulumi plugin should match");
448        assert_eq!(plugin.metadata().id, "pulumi");
449    }
450
451    #[test]
452    fn test_plugin_lookup_by_id() {
453        let mut manager = PluginManager::new();
454        manager.register_builtin(Box::new(MockPlugin::new("rust", "Rust", &["rs"])));
455
456        let plugin = manager.plugin_by_id("rust").unwrap();
457        assert_eq!(plugin.metadata().id, "rust");
458        assert_eq!(plugin.metadata().name, "Rust");
459    }
460
461    #[test]
462    fn test_plugin_lookup_miss() {
463        let mut manager = PluginManager::new();
464        manager.register_builtin(Box::new(MockPlugin::new("rust", "Rust", &["rs"])));
465
466        assert!(manager.plugin_for_extension("js").is_none());
467        assert!(manager.plugin_by_id("javascript").is_none());
468    }
469
470    #[test]
471    fn test_multiple_extensions() {
472        let mut manager = PluginManager::new();
473        manager.register_builtin(Box::new(MockPlugin::new(
474            "typescript",
475            "TypeScript",
476            &["ts", "tsx"],
477        )));
478
479        assert!(manager.plugin_for_extension("ts").is_some());
480        assert!(manager.plugin_for_extension("tsx").is_some());
481
482        let plugin_ts = manager.plugin_for_extension("ts").unwrap();
483        let plugin_tsx = manager.plugin_for_extension("tsx").unwrap();
484
485        // Both extensions should return the same plugin
486        assert_eq!(plugin_ts.metadata().id, "typescript");
487        assert_eq!(plugin_tsx.metadata().id, "typescript");
488    }
489
490    #[test]
491    fn test_multiple_plugins() {
492        let mut manager = PluginManager::new();
493        manager.register_builtin(Box::new(MockPlugin::new("rust", "Rust", &["rs"])));
494        manager.register_builtin(Box::new(MockPlugin::new(
495            "javascript",
496            "JavaScript",
497            &["js"],
498        )));
499
500        assert_eq!(manager.plugins().len(), 2);
501        assert!(manager.plugin_for_extension("rs").is_some());
502        assert!(manager.plugin_for_extension("js").is_some());
503    }
504
505    #[test]
506    fn test_with_plugins() {
507        let plugins: Vec<Box<dyn LanguagePlugin>> = vec![
508            Box::new(MockPlugin::new("rust", "Rust", &["rs"])),
509            Box::new(MockPlugin::new("javascript", "JavaScript", &["js"])),
510        ];
511
512        let manager = PluginManager::with_plugins(plugins);
513
514        assert_eq!(manager.plugins().len(), 2);
515        assert!(manager.plugin_for_extension("rs").is_some());
516        assert!(manager.plugin_for_extension("js").is_some());
517    }
518
519    #[test]
520    fn test_plugin_state_hash_consistent() {
521        let manager1 = PluginManager::new();
522        let manager2 = PluginManager::new();
523        assert_eq!(manager1.plugin_state_hash(), manager2.plugin_state_hash());
524    }
525
526    #[test]
527    fn test_plugin_state_hash_changes() {
528        let mut manager = PluginManager::new();
529        let hash_before = manager.plugin_state_hash();
530
531        manager.register_builtin(Box::new(MockPlugin::new("rust", "Rust", &["rs"])));
532        let hash_after = manager.plugin_state_hash();
533
534        assert_ne!(hash_before, hash_after);
535    }
536
537    #[test]
538    fn test_plugin_manager_is_send_sync() {
539        // Compile-time verification that PluginManager is thread-safe
540        // This test will fail to compile if PluginManager doesn't implement Send + Sync
541        fn assert_send_sync<T: Send + Sync>() {}
542        assert_send_sync::<PluginManager>();
543    }
544
545    // Task 1.2: Enhanced factory method tests
546
547    #[test]
548    fn test_selective_loading() {
549        // Tests with_plugins() with a subset of plugins
550        let selective_plugins: Vec<Box<dyn LanguagePlugin>> = vec![Box::new(MockPlugin::new(
551            "test-lang",
552            "Test Language",
553            &["test"],
554        ))];
555
556        let manager = PluginManager::with_plugins(selective_plugins);
557
558        // Should have exactly 1 plugin
559        assert_eq!(manager.plugins().len(), 1, "Expected exactly 1 plugin");
560
561        // Should be able to find the test plugin
562        assert!(
563            manager.plugin_for_extension("test").is_some(),
564            "Test plugin should be available"
565        );
566
567        // Should NOT have other plugins
568        assert!(
569            manager.plugin_for_extension("rs").is_none(),
570            "Rust plugin should not be loaded (selective loading)"
571        );
572        assert!(
573            manager.plugin_for_extension("js").is_none(),
574            "JavaScript plugin should not be loaded (selective loading)"
575        );
576    }
577
578    #[test]
579    fn test_empty_has_no_plugins() {
580        // Validates empty() creates isolated manager with no plugins
581        let manager = PluginManager::empty();
582
583        assert_eq!(
584            manager.plugins().len(),
585            0,
586            "Empty manager should have 0 plugins"
587        );
588
589        // Should not find any plugins
590        assert!(
591            manager.plugin_for_extension("rs").is_none(),
592            "Empty manager should not have Rust plugin"
593        );
594        assert!(
595            manager.plugin_by_id("rust").is_none(),
596            "Empty manager should not have Rust plugin by ID"
597        );
598
599        // Verify can manually register after creation
600        let mut mutable_manager = PluginManager::empty();
601        mutable_manager.register_builtin(Box::new(MockPlugin::new("test", "Test", &["test"])));
602
603        assert_eq!(
604            mutable_manager.plugins().len(),
605            1,
606            "After manual registration, should have 1 plugin"
607        );
608    }
609}