Skip to main content

fallow_core/plugins/registry/
mod.rs

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