Skip to main content

sqry_plugin_registry/
lib.rs

1//! Shared plugin registry for SQRY.
2//!
3//! This crate is the single source of truth for built-in plugin registration,
4//! plugin cost-tier metadata, and deterministic plugin-selection resolution.
5
6use std::collections::BTreeSet;
7use std::path::PathBuf;
8
9use sqry_core::plugin::PluginManager;
10
11pub mod feature_table;
12pub use feature_table::{
13    PLUGIN_FEATURE_TABLE, PluginFeatureSpec, all_unknown_ids_have_features, missing_features_for,
14};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17#[non_exhaustive]
18pub enum PluginCostTier {
19    Fast,
20    HighWallClock,
21    Optional,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25#[non_exhaustive]
26pub enum HighCostMode {
27    #[default]
28    FastPathDefault,
29    IncludeAll,
30    ExcludeAll,
31}
32
33impl HighCostMode {
34    #[must_use]
35    pub const fn as_str(self) -> &'static str {
36        match self {
37            Self::FastPathDefault => "fast_path_default",
38            Self::IncludeAll => "include_all",
39            Self::ExcludeAll => "exclude_all",
40        }
41    }
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Default)]
45pub struct PluginSelectionConfig {
46    pub high_cost_mode: HighCostMode,
47    pub enable_plugins: BTreeSet<String>,
48    pub disable_plugins: BTreeSet<String>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct PluginSelectionResolution {
53    pub high_cost_mode: HighCostMode,
54    pub active_plugin_ids: Vec<String>,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
58#[non_exhaustive]
59pub enum PluginSelectionError {
60    /// Legacy variant — retained for downstream consumers (LSP, MCP)
61    /// during the migration window. New call sites should use
62    /// [`Self::UnknownPluginIdsCtx`] for richer diagnostics.
63    #[deprecated(note = "use UnknownPluginIdsCtx for richer diagnostics (cluster-E §E.2)")]
64    UnknownPluginIds {
65        ids: Vec<String>,
66        supported_ids: Vec<String>,
67    },
68    /// Manifest references plugin ids the runtime does not know,
69    /// enriched with the manifest path and the Cargo feature flags
70    /// that, if enabled, would register them (cluster-E §E.2).
71    UnknownPluginIdsCtx {
72        /// Unknown plugin ids in the order they appeared in the manifest.
73        ids: Vec<String>,
74        /// Plugin ids this binary recognises.
75        supported_ids: Vec<String>,
76        /// Absolute path to the manifest that produced the unknown
77        /// ids. `None` only on the synthetic in-memory manifest path
78        /// used by unit tests.
79        manifest_path: Option<PathBuf>,
80        /// Cargo feature flags that, if enabled at build time, would
81        /// register the unknown plugin ids. May be empty if the
82        /// unknown ids are not gated by any feature flag (i.e.
83        /// genuinely unknown / typo).
84        suggested_features: Vec<&'static str>,
85        /// `true` iff every unknown id has a corresponding feature
86        /// flag. Drives the "rebuild with --features" suggestion vs
87        /// the "rebuild the index" suggestion.
88        all_unknown_ids_have_features: bool,
89    },
90}
91
92impl std::fmt::Display for PluginSelectionError {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        match self {
95            #[allow(deprecated)]
96            Self::UnknownPluginIds { ids, supported_ids } => write!(
97                f,
98                "unknown plugin ids: {} (supported ids: {})",
99                ids.join(", "),
100                supported_ids.join(", ")
101            ),
102            Self::UnknownPluginIdsCtx {
103                ids,
104                supported_ids,
105                manifest_path,
106                suggested_features,
107                all_unknown_ids_have_features,
108            } => {
109                writeln!(
110                    f,
111                    "unknown plugin ids: {} (this binary supports: {})",
112                    ids.join(", "),
113                    supported_ids.join(", ")
114                )?;
115                if let Some(p) = manifest_path {
116                    writeln!(f, "  manifest: {}", p.display())?;
117                }
118                if !suggested_features.is_empty() {
119                    writeln!(
120                        f,
121                        "  rebuild this binary with: cargo install --path sqry-cli --features {}",
122                        suggested_features.join(",")
123                    )?;
124                }
125                if *all_unknown_ids_have_features {
126                    write!(
127                        f,
128                        "  …or rebuild the index with the binary that produced it: \
129                         sqry index --force <workspace-root>"
130                    )
131                } else {
132                    write!(
133                        f,
134                        "  the unknown ids do not match any known feature flag — \
135                         the manifest may be from a newer sqry version. Rebuild \
136                         the index: sqry index --force <workspace-root>"
137                    )
138                }
139            }
140        }
141    }
142}
143
144impl std::error::Error for PluginSelectionError {}
145
146#[derive(Debug, Clone, Copy)]
147pub struct BuiltinPluginSpec {
148    pub id: &'static str,
149    pub cost_tier: PluginCostTier,
150    pub register: fn(&mut PluginManager),
151}
152
153macro_rules! builtin_plugin_specs {
154    ($($(#[$meta:meta])* [$id:literal, $tier:expr, $register:expr]),+ $(,)?) => {
155        const BUILTIN_PLUGIN_SPECS: &[BuiltinPluginSpec] = &[
156            $(
157                $(#[$meta])*
158                BuiltinPluginSpec {
159                    id: $id,
160                    cost_tier: $tier,
161                    register: $register,
162                },
163            )+
164        ];
165    };
166}
167
168builtin_plugin_specs!(
169    ["c", PluginCostTier::Fast, |pm| pm.register_builtin(
170        Box::new(sqry_lang_c::CPlugin::default())
171    )],
172    ["cpp", PluginCostTier::Fast, |pm| pm.register_builtin(
173        Box::new(sqry_lang_cpp::CppPlugin::default())
174    )],
175    ["csharp", PluginCostTier::Fast, |pm| pm.register_builtin(
176        Box::new(sqry_lang_csharp::CSharpPlugin::default())
177    )],
178    ["css", PluginCostTier::Fast, |pm| pm.register_builtin(
179        Box::new(sqry_lang_css::CssPlugin::default())
180    )],
181    ["dart", PluginCostTier::Fast, |pm| pm.register_builtin(
182        Box::new(sqry_lang_dart::DartPlugin::default())
183    )],
184    ["elixir", PluginCostTier::Fast, |pm| pm.register_builtin(
185        Box::new(sqry_lang_elixir::ElixirPlugin::default())
186    )],
187    ["go", PluginCostTier::Fast, |pm| pm.register_builtin(
188        Box::new(sqry_lang_go::GoPlugin::default())
189    )],
190    ["groovy", PluginCostTier::Fast, |pm| pm.register_builtin(
191        Box::new(sqry_lang_groovy::GroovyPlugin::default())
192    )],
193    ["haskell", PluginCostTier::Fast, |pm| pm.register_builtin(
194        Box::new(sqry_lang_haskell::HaskellPlugin::default())
195    )],
196    ["html", PluginCostTier::Fast, |pm| pm.register_builtin(
197        Box::new(sqry_lang_html::HtmlPlugin::default())
198    )],
199    ["java", PluginCostTier::Fast, |pm| pm.register_builtin(
200        Box::new(sqry_lang_java::JavaPlugin::default())
201    )],
202    ["javascript", PluginCostTier::Fast, |pm| pm
203        .register_builtin(Box::new(
204            sqry_lang_javascript::JavaScriptPlugin::default()
205        ))],
206    ["kotlin", PluginCostTier::Fast, |pm| pm.register_builtin(
207        Box::new(sqry_lang_kotlin::KotlinPlugin::default())
208    )],
209    ["lua", PluginCostTier::Fast, |pm| pm.register_builtin(
210        Box::new(sqry_lang_lua::LuaPlugin::default())
211    )],
212    ["perl", PluginCostTier::Fast, |pm| pm.register_builtin(
213        Box::new(sqry_lang_perl::PerlPlugin::default())
214    )],
215    ["php", PluginCostTier::Fast, |pm| pm.register_builtin(
216        Box::new(sqry_lang_php::PhpPlugin::default())
217    )],
218    ["python", PluginCostTier::Fast, |pm| pm.register_builtin(
219        Box::new(sqry_lang_python::PythonPlugin::default())
220    )],
221    ["r", PluginCostTier::Fast, |pm| pm.register_builtin(
222        Box::new(sqry_lang_r::RPlugin::default())
223    )],
224    ["ruby", PluginCostTier::Fast, |pm| pm.register_builtin(
225        Box::new(sqry_lang_ruby::RubyPlugin::default())
226    )],
227    ["rust", PluginCostTier::Fast, |pm| pm.register_builtin(
228        Box::new(sqry_lang_rust::RustPlugin::default())
229    )],
230    ["scala", PluginCostTier::Fast, |pm| pm.register_builtin(
231        Box::new(sqry_lang_scala::ScalaPlugin::default())
232    )],
233    ["shell", PluginCostTier::Fast, |pm| pm.register_builtin(
234        Box::new(sqry_lang_shell::ShellPlugin::default())
235    )],
236    ["sql", PluginCostTier::Fast, |pm| pm.register_builtin(
237        Box::new(sqry_lang_sql::SqlPlugin::default())
238    )],
239    ["svelte", PluginCostTier::Fast, |pm| pm.register_builtin(
240        Box::new(sqry_lang_svelte::SveltePlugin::default())
241    )],
242    ["swift", PluginCostTier::Fast, |pm| pm.register_builtin(
243        Box::new(sqry_lang_swift::SwiftPlugin::default())
244    )],
245    ["typescript", PluginCostTier::Fast, |pm| pm
246        .register_builtin(Box::new(
247            sqry_lang_typescript::TypeScriptPlugin::default()
248        ))],
249    ["vue", PluginCostTier::Fast, |pm| pm.register_builtin(
250        Box::new(sqry_lang_vue::VuePlugin::default())
251    )],
252    ["zig", PluginCostTier::Fast, |pm| pm.register_builtin(
253        Box::new(sqry_lang_zig::ZigPlugin::default())
254    )],
255    ["plsql", PluginCostTier::Fast, |pm| pm.register_builtin(
256        Box::new(sqry_lang_oracle_plsql::OraclePlsqlPlugin::default())
257    )],
258    #[cfg(feature = "plugin-apex")]
259    ["apex", PluginCostTier::Optional, |pm| pm.register_builtin(
260        Box::new(sqry_lang_salesforce_apex::SalesforceApexPlugin::default())
261    )],
262    #[cfg(feature = "plugin-abap")]
263    ["abap", PluginCostTier::Optional, |pm| pm.register_builtin(
264        Box::new(sqry_lang_sap_abap::SapAbapPlugin::default())
265    )],
266    #[cfg(feature = "plugin-servicenow-xanadu")]
267    ["servicenow-xanadu-js", PluginCostTier::Optional, |pm| pm
268        .register_builtin(Box::new(
269            sqry_lang_servicenow_xanadu::ServiceNowXanaduPlugin::default()
270        ))],
271    #[cfg(feature = "plugin-servicenow-xml")]
272    ["servicenow-xml", PluginCostTier::Optional, |pm| pm
273        .register_builtin(Box::new(
274            sqry_lang_servicenow_xml::ServiceNowXmlPlugin::default()
275        ))],
276    #[cfg(feature = "plugin-terraform")]
277    ["terraform", PluginCostTier::Optional, |pm| pm
278        .register_builtin(Box::new(
279            sqry_lang_terraform::TerraformPlugin::default()
280        ))],
281    #[cfg(feature = "plugin-puppet")]
282    ["puppet", PluginCostTier::Optional, |pm| pm
283        .register_builtin(Box::new(
284            sqry_lang_puppet::PuppetPlugin::default()
285        ))],
286    #[cfg(feature = "plugin-pulumi")]
287    ["pulumi", PluginCostTier::Optional, |pm| pm
288        .register_builtin(Box::new(
289            sqry_lang_pulumi::PulumiPlugin::default()
290        ))],
291    ["json", PluginCostTier::HighWallClock, |pm| pm
292        .register_builtin(Box::new(
293            sqry_lang_json::JsonPlugin::new()
294        ))]
295);
296
297#[must_use]
298pub fn builtin_plugin_ids() -> Vec<String> {
299    BUILTIN_PLUGIN_SPECS
300        .iter()
301        .map(|spec| spec.id.to_string())
302        .collect()
303}
304
305/// Create a `PluginManager` using the default fast-path plugin selection.
306#[must_use]
307pub fn create_plugin_manager() -> PluginManager {
308    let resolution = resolve_plugin_selection(&PluginSelectionConfig::default())
309        .unwrap_or_else(|_| unreachable!("default plugin selection must resolve"));
310    create_plugin_manager_for_plugin_ids(&resolution.active_plugin_ids)
311        .unwrap_or_else(|_| unreachable!("default plugin ids must be valid"))
312}
313
314/// Create a `PluginManager` containing the full built-in plugin roster.
315#[must_use]
316pub fn create_plugin_manager_all() -> PluginManager {
317    create_plugin_manager_with_config(&PluginSelectionConfig {
318        high_cost_mode: HighCostMode::IncludeAll,
319        ..PluginSelectionConfig::default()
320    })
321    .unwrap_or_else(|_| unreachable!("full built-in plugin roster must resolve"))
322}
323
324/// Create a `PluginManager` from an explicit selection config.
325///
326/// # Errors
327///
328/// Returns an error if the config references unknown built-in plugin ids.
329pub fn create_plugin_manager_with_config(
330    config: &PluginSelectionConfig,
331) -> Result<PluginManager, PluginSelectionError> {
332    let resolution = resolve_plugin_selection(config)?;
333    create_plugin_manager_for_plugin_ids(&resolution.active_plugin_ids)
334}
335
336/// Create a `PluginManager` from an explicit ordered built-in plugin subset.
337///
338/// # Errors
339///
340/// Returns an error if any requested built-in plugin id is unknown.
341pub fn create_plugin_manager_for_plugin_ids(
342    plugin_ids: &[String],
343) -> Result<PluginManager, PluginSelectionError> {
344    validate_plugin_ids(plugin_ids.iter().map(String::as_str))?;
345
346    let selected_ids: BTreeSet<&str> = plugin_ids.iter().map(String::as_str).collect();
347    let mut pm = PluginManager::new();
348
349    for spec in BUILTIN_PLUGIN_SPECS {
350        if selected_ids.contains(spec.id) {
351            (spec.register)(&mut pm);
352        }
353    }
354
355    Ok(pm)
356}
357
358/// Resolve the effective active plugin ids for a selection config.
359///
360/// # Errors
361///
362/// Returns an error if the config references unknown built-in plugin ids.
363pub fn resolve_plugin_selection(
364    config: &PluginSelectionConfig,
365) -> Result<PluginSelectionResolution, PluginSelectionError> {
366    validate_plugin_ids(
367        config
368            .enable_plugins
369            .iter()
370            .map(String::as_str)
371            .chain(config.disable_plugins.iter().map(String::as_str)),
372    )?;
373
374    let active_plugin_ids = BUILTIN_PLUGIN_SPECS
375        .iter()
376        .filter(|spec| is_plugin_enabled(spec, config))
377        .map(|spec| spec.id.to_string())
378        .collect();
379
380    Ok(PluginSelectionResolution {
381        high_cost_mode: config.high_cost_mode,
382        active_plugin_ids,
383    })
384}
385
386fn is_plugin_enabled(spec: &BuiltinPluginSpec, config: &PluginSelectionConfig) -> bool {
387    if config.disable_plugins.contains(spec.id) {
388        return false;
389    }
390    if config.enable_plugins.contains(spec.id) {
391        return true;
392    }
393
394    match config.high_cost_mode {
395        HighCostMode::IncludeAll => true,
396        HighCostMode::FastPathDefault | HighCostMode::ExcludeAll => {
397            matches!(spec.cost_tier, PluginCostTier::Fast)
398        }
399    }
400}
401
402fn validate_plugin_ids<'a>(
403    plugin_ids: impl IntoIterator<Item = &'a str>,
404) -> Result<(), PluginSelectionError> {
405    let supported_ids: BTreeSet<&str> = BUILTIN_PLUGIN_SPECS.iter().map(|spec| spec.id).collect();
406    let mut unknown_ids = plugin_ids
407        .into_iter()
408        .filter(|id| !supported_ids.contains(id))
409        .map(ToString::to_string)
410        .collect::<Vec<_>>();
411    unknown_ids.sort();
412    unknown_ids.dedup();
413
414    if unknown_ids.is_empty() {
415        return Ok(());
416    }
417
418    let suggested_features = missing_features_for(&unknown_ids);
419    let all_have_features = all_unknown_ids_have_features(&unknown_ids);
420    Err(PluginSelectionError::UnknownPluginIdsCtx {
421        ids: unknown_ids,
422        supported_ids: supported_ids.into_iter().map(ToString::to_string).collect(),
423        manifest_path: None,
424        suggested_features,
425        all_unknown_ids_have_features: all_have_features,
426    })
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    fn config_with_mode(high_cost_mode: HighCostMode) -> PluginSelectionConfig {
434        PluginSelectionConfig {
435            high_cost_mode,
436            ..PluginSelectionConfig::default()
437        }
438    }
439
440    #[test]
441    fn test_default_fast_path_excludes_high_cost_plugins() {
442        let pm = create_plugin_manager();
443        let plugins = pm.plugins();
444        let expected_len = BUILTIN_PLUGIN_SPECS
445            .iter()
446            .filter(|spec| matches!(spec.cost_tier, PluginCostTier::Fast))
447            .count();
448
449        assert_eq!(plugins.len(), expected_len);
450        assert!(pm.plugin_by_id("json").is_none());
451        #[cfg(feature = "plugin-servicenow-xml")]
452        assert!(pm.plugin_by_id("servicenow-xml").is_none());
453    }
454
455    #[test]
456    fn test_create_plugin_manager_has_rust() {
457        let pm = create_plugin_manager();
458        assert!(pm.plugin_for_extension("rs").is_some());
459        assert!(pm.plugin_by_id("rust").is_some());
460    }
461
462    #[test]
463    fn test_include_all_restores_high_cost_plugins() {
464        let pm = create_plugin_manager_with_config(&config_with_mode(HighCostMode::IncludeAll))
465            .expect("include-all config should be valid");
466
467        assert!(pm.plugin_by_id("json").is_some());
468        #[cfg(feature = "plugin-servicenow-xml")]
469        assert!(pm.plugin_by_id("servicenow-xml").is_some());
470    }
471
472    #[test]
473    fn test_explicit_enable_beats_fast_path_default() {
474        let mut config = PluginSelectionConfig::default();
475        config.enable_plugins.insert("json".to_string());
476
477        let resolution = resolve_plugin_selection(&config).expect("selection should resolve");
478        assert!(resolution.active_plugin_ids.iter().any(|id| id == "json"));
479    }
480
481    #[test]
482    fn test_explicit_disable_beats_explicit_enable() {
483        let mut config = config_with_mode(HighCostMode::IncludeAll);
484        config.enable_plugins.insert("json".to_string());
485        config.disable_plugins.insert("json".to_string());
486
487        let resolution = resolve_plugin_selection(&config).expect("selection should resolve");
488        assert!(!resolution.active_plugin_ids.iter().any(|id| id == "json"));
489    }
490
491    #[test]
492    fn test_unknown_plugin_ids_fail_validation() {
493        let mut config = PluginSelectionConfig::default();
494        config.enable_plugins.insert("missing-plugin".to_string());
495
496        let err = resolve_plugin_selection(&config).expect_err("selection should fail");
497        assert!(
498            matches!(err, PluginSelectionError::UnknownPluginIdsCtx { .. }),
499            "unexpected error: {err}"
500        );
501    }
502
503    /// `Display` for `UnknownPluginIdsCtx` includes the manifest path and
504    /// the `--features` suggestion when the unknown ids correspond to
505    /// known feature gates (cluster-E §E.2). Pinning this output is the
506    /// load-bearing assertion for the user-facing diagnostic.
507    #[test]
508    fn display_includes_manifest_and_feature_hint() {
509        // Build the variant directly so the test is independent of the
510        // current binary's compile-time feature set.
511        let err = PluginSelectionError::UnknownPluginIdsCtx {
512            ids: vec!["terraform".to_string(), "made-up".to_string()],
513            supported_ids: vec!["rust".to_string(), "go".to_string()],
514            manifest_path: Some(PathBuf::from("/repo/proj/.sqry/graph/manifest.json")),
515            suggested_features: vec!["plugin-terraform"],
516            all_unknown_ids_have_features: false,
517        };
518        let rendered = err.to_string();
519        assert!(
520            rendered.contains("terraform"),
521            "must list every unknown id, got: {rendered}"
522        );
523        assert!(
524            rendered.contains("/repo/proj/.sqry/graph/manifest.json"),
525            "must include the manifest path, got: {rendered}"
526        );
527        assert!(
528            rendered.contains("--features plugin-terraform"),
529            "must include the rebuild-with-features suggestion, got: {rendered}"
530        );
531        assert!(
532            rendered.contains("Rebuild the index"),
533            "must include the rebuild-the-index suggestion when not all ids have features, \
534             got: {rendered}"
535        );
536    }
537
538    #[test]
539    fn test_spec_id_matches_registered_plugin_id() {
540        for spec in BUILTIN_PLUGIN_SPECS {
541            let mut pm = PluginManager::new();
542            (spec.register)(&mut pm);
543
544            let plugin = pm
545                .plugin_by_id(spec.id)
546                .unwrap_or_else(|| panic!("registry spec {} did not register by id", spec.id));
547            assert_eq!(plugin.metadata().id, spec.id);
548        }
549    }
550}