Skip to main content

fallow_core/plugins/registry/
mod.rs

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