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