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