Skip to main content

fallow_engine/
plugins.rs

1//! Plugin registry helpers and types exposed through the engine boundary.
2
3use std::path::{Path, PathBuf};
4
5use fallow_config::{ExternalPluginDef, PackageJson};
6
7use crate::core_backend;
8
9/// External-plugin dry-run primitives for the CLI's `plugin-check` command.
10pub use crate::core_backend::{
11    CheckWarning, ManifestResult, RuleReport, WarningKind, check_manifest_entries,
12    is_external_plugin_active,
13};
14
15/// Built-in plugin name roster and plugin-regex validation diagnostics.
16pub mod registry {
17    use crate::core_backend;
18
19    /// Invalid user-authored regex extracted from a plugin config file.
20    #[derive(Debug, Clone, PartialEq, Eq)]
21    pub struct PluginRegexValidationError {
22        message: String,
23    }
24
25    impl From<core_backend::BackendPluginRegexValidationError> for PluginRegexValidationError {
26        fn from(inner: core_backend::BackendPluginRegexValidationError) -> Self {
27            Self {
28                message: inner.message(),
29            }
30        }
31    }
32
33    /// Names of every built-in framework plugin in registry order.
34    ///
35    /// Delegates to the core registry rather than mirroring it. A hand-kept
36    /// copy drifted here once already, silently omitting `deno`, and nothing
37    /// pinned the two together.
38    #[must_use]
39    pub fn builtin_plugin_names() -> Vec<&'static str> {
40        core_backend::builtin_plugin_names()
41    }
42
43    /// Format plugin regex validation errors for user-facing diagnostics.
44    #[must_use]
45    pub fn format_plugin_regex_errors(errors: &[PluginRegexValidationError]) -> String {
46        let joined = errors
47            .iter()
48            .map(|error| error.message.as_str())
49            .collect::<Vec<_>>();
50        format!(
51            "invalid plugin regex configuration:\n  - {}\n\nRewrite the plugin config with Rust-compatible regex syntax, or remove unsupported constructs such as JavaScript lookahead and lookbehind.",
52            joined.join("\n  - ")
53        )
54    }
55}
56
57/// Aggregated results from all active plugins for a project.
58#[derive(Debug, Clone, Default)]
59pub struct AggregatedPluginResult {
60    inner: core_backend::BackendAggregatedPluginResult,
61}
62
63impl AggregatedPluginResult {
64    /// Names of active plugins.
65    #[must_use]
66    pub fn active_plugins(&self) -> &[String] {
67        self.inner.active_plugins()
68    }
69
70    /// Merge active plugin names from another result, preserving insertion order.
71    pub(crate) fn merge_active_plugins_from(&mut self, other: &Self) {
72        self.inner.merge_active_plugins_from(&other.inner);
73    }
74
75    pub(crate) fn backend(&self) -> &core_backend::BackendAggregatedPluginResult {
76        &self.inner
77    }
78}
79
80impl From<core_backend::BackendAggregatedPluginResult> for AggregatedPluginResult {
81    fn from(inner: core_backend::BackendAggregatedPluginResult) -> Self {
82        Self { inner }
83    }
84}
85
86/// Registry of all available plugins.
87pub struct PluginRegistry {
88    inner: core_backend::BackendPluginRegistry,
89}
90
91impl PluginRegistry {
92    /// Create a registry with all built-in plugins and optional external plugins.
93    #[must_use]
94    pub(crate) fn new(external: Vec<ExternalPluginDef>) -> Self {
95        Self {
96            inner: core_backend::BackendPluginRegistry::new(external),
97        }
98    }
99
100    /// Hidden directory names that should be traversed before full plugin execution.
101    #[must_use]
102    pub(crate) fn discovery_hidden_dirs(&self, pkg: &PackageJson, root: &Path) -> Vec<String> {
103        self.inner.discovery_hidden_dirs(pkg, root)
104    }
105
106    /// Run all plugins against a project.
107    pub(crate) fn try_run(
108        &self,
109        pkg: &PackageJson,
110        root: &Path,
111        discovered_files: &[PathBuf],
112    ) -> Result<AggregatedPluginResult, Vec<registry::PluginRegexValidationError>> {
113        self.inner
114            .try_run(pkg, root, discovered_files)
115            .map(Into::into)
116            .map_err(|errors| errors.into_iter().map(Into::into).collect())
117    }
118}
119
120impl Default for PluginRegistry {
121    fn default() -> Self {
122        Self::new(vec![])
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use std::path::PathBuf;
129
130    use super::{AggregatedPluginResult, PluginRegistry};
131
132    #[test]
133    fn plugin_registry_try_run_returns_engine_result() {
134        let registry = PluginRegistry::default();
135        let result = registry
136            .try_run(
137                &fallow_config::PackageJson::default(),
138                &PathBuf::from("/repo"),
139                &[],
140            )
141            .expect("empty package should not produce regex errors");
142
143        assert!(result.active_plugins().is_empty());
144    }
145
146    #[test]
147    fn aggregated_plugin_result_merges_active_plugins() {
148        let mut base = AggregatedPluginResult::default();
149        base.inner.push_active_plugin_for_test("nextjs");
150        let mut incoming = AggregatedPluginResult::default();
151        incoming.inner.push_active_plugin_for_test("nextjs");
152        incoming.inner.push_active_plugin_for_test("vitest");
153
154        base.merge_active_plugins_from(&incoming);
155
156        assert_eq!(base.active_plugins(), ["nextjs", "vitest"]);
157    }
158}
159
160#[cfg(test)]
161mod roster_tests {
162    /// The engine mirrored core's roster by hand and the copy drifted, omitting
163    /// `deno`. Delegation removes the copy, so there is nothing left to diverge;
164    /// this pins the name the drift lost and that the roster is really populated.
165    ///
166    /// The roster is read through `core_backend`, not from `fallow_core`
167    /// directly, because the boundary guard requires every crossing to go
168    /// through that adapter.
169    #[test]
170    fn roster_carries_every_registered_plugin() {
171        let names = super::registry::builtin_plugin_names();
172        assert!(
173            names.contains(&"deno"),
174            "deno is registered in core and must reach the roster"
175        );
176        assert!(
177            names.len() > 100,
178            "roster looks truncated: {} names",
179            names.len()
180        );
181    }
182}