Skip to main content

fallow_core/plugins/registry/
mod.rs

1//! Plugin registry: discovers active plugins, collects patterns, parses configs.
2#![expect(
3    clippy::excessive_nesting,
4    reason = "plugin config parsing requires deep AST matching"
5)]
6
7use rustc_hash::FxHashSet;
8use std::path::{Path, PathBuf};
9
10use fallow_config::{ExternalPluginDef, PackageJson};
11
12use super::Plugin;
13
14pub(crate) mod builtin;
15mod helpers;
16
17use helpers::{
18    check_has_config_file, discover_json_config_files, process_config_result,
19    process_external_plugins, process_static_patterns,
20};
21
22/// Registry of all available plugins (built-in + external).
23pub struct PluginRegistry {
24    plugins: Vec<Box<dyn Plugin>>,
25    external_plugins: Vec<ExternalPluginDef>,
26}
27
28/// Aggregated results from all active plugins for a project.
29#[derive(Debug, Default)]
30pub struct AggregatedPluginResult {
31    /// All entry point patterns from active plugins: (pattern, plugin_name).
32    pub entry_patterns: Vec<(String, String)>,
33    /// All config file patterns from active plugins.
34    pub config_patterns: Vec<String>,
35    /// All always-used file patterns from active plugins: (pattern, plugin_name).
36    pub always_used: Vec<(String, String)>,
37    /// All used export rules from active plugins.
38    pub used_exports: Vec<(String, Vec<String>)>,
39    /// Dependencies referenced in config files (should not be flagged unused).
40    pub referenced_dependencies: Vec<String>,
41    /// Additional always-used files discovered from config parsing: (pattern, plugin_name).
42    pub discovered_always_used: Vec<(String, String)>,
43    /// Setup files discovered from config parsing: (path, plugin_name).
44    pub setup_files: Vec<(PathBuf, String)>,
45    /// Tooling dependencies (should not be flagged as unused devDeps).
46    pub tooling_dependencies: Vec<String>,
47    /// Package names discovered as used in package.json scripts (binary invocations).
48    pub script_used_packages: FxHashSet<String>,
49    /// Import prefixes for virtual modules provided by active frameworks.
50    /// Imports matching these prefixes should not be flagged as unlisted dependencies.
51    pub virtual_module_prefixes: Vec<String>,
52    /// Import suffixes for build-time generated relative imports.
53    /// Unresolved imports ending with these suffixes are suppressed.
54    pub generated_import_patterns: Vec<String>,
55    /// Path alias mappings from active plugins (prefix → replacement directory).
56    /// Used by the resolver to substitute import prefixes before re-resolving.
57    pub path_aliases: Vec<(String, String)>,
58    /// Names of active plugins.
59    pub active_plugins: Vec<String>,
60    /// Test fixture glob patterns from active plugins: (pattern, plugin_name).
61    pub fixture_patterns: Vec<(String, String)>,
62}
63
64impl PluginRegistry {
65    /// Create a registry with all built-in plugins and optional external plugins.
66    #[must_use]
67    pub fn new(external: Vec<ExternalPluginDef>) -> Self {
68        Self {
69            plugins: builtin::create_builtin_plugins(),
70            external_plugins: external,
71        }
72    }
73
74    /// Run all plugins against a project, returning aggregated results.
75    ///
76    /// This discovers which plugins are active, collects their static patterns,
77    /// then parses any config files to extract dynamic information.
78    pub fn run(
79        &self,
80        pkg: &PackageJson,
81        root: &Path,
82        discovered_files: &[PathBuf],
83    ) -> AggregatedPluginResult {
84        let _span = tracing::info_span!("run_plugins").entered();
85        let mut result = AggregatedPluginResult::default();
86
87        // Phase 1: Determine which plugins are active
88        // Compute deps once to avoid repeated Vec<String> allocation per plugin
89        let all_deps = pkg.all_dependency_names();
90        let active: Vec<&dyn Plugin> = self
91            .plugins
92            .iter()
93            .filter(|p| p.is_enabled_with_deps(&all_deps, root))
94            .map(AsRef::as_ref)
95            .collect();
96
97        tracing::info!(
98            plugins = active
99                .iter()
100                .map(|p| p.name())
101                .collect::<Vec<_>>()
102                .join(", "),
103            "active plugins"
104        );
105
106        // Phase 2: Collect static patterns from active plugins
107        for plugin in &active {
108            process_static_patterns(*plugin, root, &mut result);
109        }
110
111        // Phase 2b: Process external plugins (includes inline framework definitions)
112        process_external_plugins(
113            &self.external_plugins,
114            &all_deps,
115            root,
116            discovered_files,
117            &mut result,
118        );
119
120        // Phase 3: Find and parse config files for dynamic resolution
121        // Pre-compile all config patterns
122        let config_matchers: Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> = active
123            .iter()
124            .filter(|p| !p.config_patterns().is_empty())
125            .map(|p| {
126                let matchers: Vec<globset::GlobMatcher> = p
127                    .config_patterns()
128                    .iter()
129                    .filter_map(|pat| globset::Glob::new(pat).ok().map(|g| g.compile_matcher()))
130                    .collect();
131                (*p, matchers)
132            })
133            .collect();
134
135        // Build relative paths for matching (used by Phase 3 and 4)
136        let relative_files: Vec<(&PathBuf, String)> = discovered_files
137            .iter()
138            .map(|f| {
139                let rel = f
140                    .strip_prefix(root)
141                    .unwrap_or(f)
142                    .to_string_lossy()
143                    .into_owned();
144                (f, rel)
145            })
146            .collect();
147
148        if !config_matchers.is_empty() {
149            // Phase 3a: Match config files from discovered source files
150            let mut resolved_plugins: FxHashSet<&str> = FxHashSet::default();
151
152            for (plugin, matchers) in &config_matchers {
153                for (abs_path, rel_path) in &relative_files {
154                    if matchers.iter().any(|m| m.is_match(rel_path.as_str())) {
155                        // Mark as resolved regardless of result to prevent Phase 3b
156                        // from re-parsing a JSON config for the same plugin.
157                        resolved_plugins.insert(plugin.name());
158                        if let Ok(source) = std::fs::read_to_string(abs_path) {
159                            let plugin_result = plugin.resolve_config(abs_path, &source, root);
160                            if !plugin_result.is_empty() {
161                                tracing::debug!(
162                                    plugin = plugin.name(),
163                                    config = rel_path.as_str(),
164                                    entries = plugin_result.entry_patterns.len(),
165                                    deps = plugin_result.referenced_dependencies.len(),
166                                    "resolved config"
167                                );
168                                process_config_result(plugin.name(), plugin_result, &mut result);
169                            }
170                        }
171                    }
172                }
173            }
174
175            // Phase 3b: Filesystem fallback for JSON config files.
176            // JSON files (angular.json, project.json) are not in the discovered file set
177            // because fallow only discovers JS/TS/CSS/Vue/etc. files.
178            let json_configs = discover_json_config_files(
179                &config_matchers,
180                &resolved_plugins,
181                &relative_files,
182                root,
183            );
184            for (abs_path, plugin) in &json_configs {
185                if let Ok(source) = std::fs::read_to_string(abs_path) {
186                    let plugin_result = plugin.resolve_config(abs_path, &source, root);
187                    if !plugin_result.is_empty() {
188                        let rel = abs_path
189                            .strip_prefix(root)
190                            .map(|p| p.to_string_lossy())
191                            .unwrap_or_default();
192                        tracing::debug!(
193                            plugin = plugin.name(),
194                            config = %rel,
195                            entries = plugin_result.entry_patterns.len(),
196                            deps = plugin_result.referenced_dependencies.len(),
197                            "resolved config (filesystem fallback)"
198                        );
199                        process_config_result(plugin.name(), plugin_result, &mut result);
200                    }
201                }
202            }
203        }
204
205        // Phase 4: Package.json inline config fallback
206        // For plugins that define `package_json_config_key()`, check if the root
207        // package.json contains that key and no standalone config file was found.
208        for plugin in &active {
209            if let Some(key) = plugin.package_json_config_key()
210                && !check_has_config_file(*plugin, &config_matchers, &relative_files)
211            {
212                // Try to extract the key from package.json
213                let pkg_path = root.join("package.json");
214                if let Ok(content) = std::fs::read_to_string(&pkg_path)
215                    && let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
216                    && let Some(config_value) = json.get(key)
217                {
218                    let config_json = serde_json::to_string(config_value).unwrap_or_default();
219                    let fake_path = root.join(format!("{key}.config.json"));
220                    let plugin_result = plugin.resolve_config(&fake_path, &config_json, root);
221                    if !plugin_result.is_empty() {
222                        tracing::debug!(
223                            plugin = plugin.name(),
224                            key = key,
225                            "resolved inline package.json config"
226                        );
227                        process_config_result(plugin.name(), plugin_result, &mut result);
228                    }
229                }
230            }
231        }
232
233        result
234    }
235
236    /// Fast variant of `run()` for workspace packages.
237    ///
238    /// Reuses pre-compiled config matchers and pre-computed relative files from the root
239    /// project run, avoiding repeated glob compilation and path computation per workspace.
240    /// Skips external plugins (they only activate at root level) and package.json inline
241    /// config (workspace packages rarely have inline configs).
242    pub fn run_workspace_fast(
243        &self,
244        pkg: &PackageJson,
245        root: &Path,
246        project_root: &Path,
247        precompiled_config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
248        relative_files: &[(&PathBuf, String)],
249    ) -> AggregatedPluginResult {
250        let _span = tracing::info_span!("run_plugins").entered();
251        let mut result = AggregatedPluginResult::default();
252
253        // Phase 1: Determine which plugins are active (with pre-computed deps)
254        let all_deps = pkg.all_dependency_names();
255        let active: Vec<&dyn Plugin> = self
256            .plugins
257            .iter()
258            .filter(|p| p.is_enabled_with_deps(&all_deps, root))
259            .map(AsRef::as_ref)
260            .collect();
261
262        tracing::info!(
263            plugins = active
264                .iter()
265                .map(|p| p.name())
266                .collect::<Vec<_>>()
267                .join(", "),
268            "active plugins"
269        );
270
271        // Early exit if no plugins are active (common for leaf workspace packages)
272        if active.is_empty() {
273            return result;
274        }
275
276        // Phase 2: Collect static patterns from active plugins
277        for plugin in &active {
278            process_static_patterns(*plugin, root, &mut result);
279        }
280
281        // Phase 3: Find and parse config files using pre-compiled matchers
282        // Only check matchers for plugins that are active in this workspace
283        let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
284        let workspace_matchers: Vec<_> = precompiled_config_matchers
285            .iter()
286            .filter(|(p, _)| active_names.contains(p.name()))
287            .collect();
288
289        let mut resolved_ws_plugins: FxHashSet<&str> = FxHashSet::default();
290        if !workspace_matchers.is_empty() {
291            for (plugin, matchers) in &workspace_matchers {
292                for (abs_path, rel_path) in relative_files {
293                    if matchers.iter().any(|m| m.is_match(rel_path.as_str()))
294                        && let Ok(source) = std::fs::read_to_string(abs_path)
295                    {
296                        // Mark resolved regardless of result to prevent Phase 3b
297                        // from re-parsing a JSON config for the same plugin.
298                        resolved_ws_plugins.insert(plugin.name());
299                        let plugin_result = plugin.resolve_config(abs_path, &source, root);
300                        if !plugin_result.is_empty() {
301                            tracing::debug!(
302                                plugin = plugin.name(),
303                                config = rel_path.as_str(),
304                                entries = plugin_result.entry_patterns.len(),
305                                deps = plugin_result.referenced_dependencies.len(),
306                                "resolved config"
307                            );
308                            process_config_result(plugin.name(), plugin_result, &mut result);
309                        }
310                    }
311                }
312            }
313        }
314
315        // Phase 3b: Filesystem fallback for JSON config files at the project root.
316        // Config files like angular.json live at the monorepo root, but Angular is
317        // only active in workspace packages. Check the project root for unresolved
318        // config patterns.
319        let mut ws_json_configs: Vec<(PathBuf, &dyn Plugin)> = Vec::new();
320        let mut ws_seen_paths: FxHashSet<PathBuf> = FxHashSet::default();
321        for plugin in &active {
322            if resolved_ws_plugins.contains(plugin.name()) || plugin.config_patterns().is_empty() {
323                continue;
324            }
325            for pat in plugin.config_patterns() {
326                let has_glob = pat.contains("**") || pat.contains('*') || pat.contains('?');
327                if has_glob {
328                    // Glob pattern (e.g., "**/project.json") — check directories
329                    // that contain discovered source files
330                    let filename = std::path::Path::new(pat)
331                        .file_name()
332                        .and_then(|n| n.to_str())
333                        .unwrap_or(pat);
334                    let matcher = globset::Glob::new(pat).ok().map(|g| g.compile_matcher());
335                    if let Some(matcher) = matcher {
336                        let mut checked_dirs: FxHashSet<&Path> = FxHashSet::default();
337                        checked_dirs.insert(root);
338                        if root != project_root {
339                            checked_dirs.insert(project_root);
340                        }
341                        for (abs_path, _) in relative_files {
342                            if let Some(parent) = abs_path.parent() {
343                                checked_dirs.insert(parent);
344                            }
345                        }
346                        for dir in checked_dirs {
347                            let candidate = dir.join(filename);
348                            if candidate.is_file() && ws_seen_paths.insert(candidate.clone()) {
349                                let rel = candidate
350                                    .strip_prefix(project_root)
351                                    .map(|p| p.to_string_lossy())
352                                    .unwrap_or_default();
353                                if matcher.is_match(rel.as_ref()) {
354                                    ws_json_configs.push((candidate, *plugin));
355                                }
356                            }
357                        }
358                    }
359                } else {
360                    // Check both workspace root and project root (deduplicate when equal)
361                    let check_roots: Vec<&Path> = if root == project_root {
362                        vec![root]
363                    } else {
364                        vec![root, project_root]
365                    };
366                    for check_root in check_roots {
367                        let abs_path = check_root.join(pat);
368                        if abs_path.is_file() && ws_seen_paths.insert(abs_path.clone()) {
369                            ws_json_configs.push((abs_path, *plugin));
370                            break; // Found it — don't check other roots for this pattern
371                        }
372                    }
373                }
374            }
375        }
376        // Parse discovered JSON config files
377        for (abs_path, plugin) in &ws_json_configs {
378            if let Ok(source) = std::fs::read_to_string(abs_path) {
379                let plugin_result = plugin.resolve_config(abs_path, &source, root);
380                if !plugin_result.is_empty() {
381                    let rel = abs_path
382                        .strip_prefix(project_root)
383                        .map(|p| p.to_string_lossy())
384                        .unwrap_or_default();
385                    tracing::debug!(
386                        plugin = plugin.name(),
387                        config = %rel,
388                        entries = plugin_result.entry_patterns.len(),
389                        deps = plugin_result.referenced_dependencies.len(),
390                        "resolved config (workspace filesystem fallback)"
391                    );
392                    process_config_result(plugin.name(), plugin_result, &mut result);
393                }
394            }
395        }
396
397        result
398    }
399
400    /// Pre-compile config pattern glob matchers for all plugins that have config patterns.
401    /// Returns a vec of (plugin, matchers) pairs that can be reused across multiple `run_workspace_fast` calls.
402    #[must_use]
403    pub fn precompile_config_matchers(&self) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
404        self.plugins
405            .iter()
406            .filter(|p| !p.config_patterns().is_empty())
407            .map(|p| {
408                let matchers: Vec<globset::GlobMatcher> = p
409                    .config_patterns()
410                    .iter()
411                    .filter_map(|pat| globset::Glob::new(pat).ok().map(|g| g.compile_matcher()))
412                    .collect();
413                (p.as_ref(), matchers)
414            })
415            .collect()
416    }
417}
418
419impl Default for PluginRegistry {
420    fn default() -> Self {
421        Self::new(vec![])
422    }
423}
424
425#[cfg(test)]
426#[expect(
427    clippy::disallowed_types,
428    reason = "test assertions use std HashMap for readability"
429)]
430mod tests;