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, prepare_config_pattern, process_config_result,
15    process_external_plugins, 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    /// Hidden directory names that should be traversed before full plugin execution.
96    ///
97    /// Source discovery runs before plugin config parsing, so this helper only uses
98    /// package-activation checks and static plugin metadata.
99    #[must_use]
100    pub fn discovery_hidden_dirs(&self, pkg: &PackageJson, root: &Path) -> Vec<String> {
101        let all_deps = pkg.all_dependency_names();
102        let mut seen = FxHashSet::default();
103        let mut dirs = Vec::new();
104
105        for plugin in &self.plugins {
106            if !plugin.is_enabled_with_deps(&all_deps, root) {
107                continue;
108            }
109            for dir in plugin.discovery_hidden_dirs() {
110                if seen.insert(*dir) {
111                    dirs.push((*dir).to_string());
112                }
113            }
114        }
115
116        dirs
117    }
118
119    /// Run all plugins against a project, returning aggregated results.
120    ///
121    /// This discovers which plugins are active, collects their static patterns,
122    /// then parses any config files to extract dynamic information.
123    pub fn run(
124        &self,
125        pkg: &PackageJson,
126        root: &Path,
127        discovered_files: &[PathBuf],
128    ) -> AggregatedPluginResult {
129        self.run_with_search_roots(pkg, root, discovered_files, &[root], false)
130    }
131
132    /// Run all plugins against a project with explicit config-file search roots.
133    ///
134    /// `config_search_roots` should stay narrowly focused to directories that are
135    /// already known to matter for this project. Broad recursive scans are
136    /// intentionally avoided because they become prohibitively expensive on
137    /// large monorepos with populated `node_modules` trees.
138    ///
139    /// `production_mode` controls the FS fallback for source-extension config
140    /// patterns. In production mode the source walker excludes `*.config.*` so
141    /// the FS walk is required; otherwise Phase 3a's in-memory matcher covers
142    /// them and the walk is skipped.
143    pub fn run_with_search_roots(
144        &self,
145        pkg: &PackageJson,
146        root: &Path,
147        discovered_files: &[PathBuf],
148        config_search_roots: &[&Path],
149        production_mode: bool,
150    ) -> AggregatedPluginResult {
151        let _span = tracing::info_span!("run_plugins").entered();
152        let mut result = AggregatedPluginResult::default();
153
154        // Phase 1: Determine which plugins are active
155        // Compute deps once to avoid repeated Vec<String> allocation per plugin
156        let all_deps = pkg.all_dependency_names();
157        let active: Vec<&dyn Plugin> = self
158            .plugins
159            .iter()
160            .filter(|p| p.is_enabled_with_deps(&all_deps, root))
161            .map(AsRef::as_ref)
162            .collect();
163
164        tracing::info!(
165            plugins = active
166                .iter()
167                .map(|p| p.name())
168                .collect::<Vec<_>>()
169                .join(", "),
170            "active plugins"
171        );
172
173        // Warn when meta-frameworks are active but their generated configs are missing.
174        // Without these, tsconfig extends chains break and import resolution fails.
175        check_meta_framework_prerequisites(&active, root);
176
177        // Phase 2: Collect static patterns from active plugins
178        for plugin in &active {
179            process_static_patterns(*plugin, root, &mut result);
180        }
181
182        // Phase 2b: Process external plugins (includes inline framework definitions)
183        process_external_plugins(
184            &self.external_plugins,
185            &all_deps,
186            root,
187            discovered_files,
188            &mut result,
189        );
190
191        // Phase 3: Find and parse config files for dynamic resolution
192        // Pre-compile all config patterns. Source-extension root-anchored
193        // patterns are wrapped with `**/` so they match nested files via the
194        // discovered file set (Phase 3a), letting Phase 3b skip those plugins
195        // and avoid a per-directory stat storm on large monorepos.
196        let config_matchers: Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> = active
197            .iter()
198            .filter(|p| !p.config_patterns().is_empty())
199            .map(|p| {
200                let matchers: Vec<globset::GlobMatcher> = p
201                    .config_patterns()
202                    .iter()
203                    .filter_map(|pat| {
204                        let prepared = prepare_config_pattern(pat);
205                        globset::Glob::new(&prepared)
206                            .ok()
207                            .map(|g| g.compile_matcher())
208                    })
209                    .collect();
210                (*p, matchers)
211            })
212            .collect();
213
214        use rayon::prelude::*;
215        // Build relative paths lazily: only needed when config matchers exist
216        // or plugins have package_json_config_key. Skip entirely for projects
217        // with no config-parsing plugins (e.g., only React), avoiding O(files)
218        // String allocations.
219        let needs_relative_files = !config_matchers.is_empty()
220            || active.iter().any(|p| p.package_json_config_key().is_some());
221        let relative_files: Vec<(PathBuf, String)> = if needs_relative_files {
222            discovered_files
223                .par_iter()
224                .map(|f| {
225                    let rel = f
226                        .strip_prefix(root)
227                        .unwrap_or(f)
228                        .to_string_lossy()
229                        .into_owned();
230                    (f.clone(), rel)
231                })
232                .collect()
233        } else {
234            Vec::new()
235        };
236
237        if !config_matchers.is_empty() {
238            // Phase 3a: Match config files from discovered source files. Per-file
239            // glob matching is parallelized: on monorepos with tens of thousands
240            // of source files, the file-scan cost dominates the plugins phase.
241            let mut resolved_plugins: FxHashSet<&str> = FxHashSet::default();
242
243            for (plugin, matchers) in &config_matchers {
244                let plugin_hits: Vec<&PathBuf> = relative_files
245                    .par_iter()
246                    .filter_map(|(abs_path, rel_path)| {
247                        matchers
248                            .iter()
249                            .any(|m| m.is_match(rel_path.as_str()))
250                            .then_some(abs_path)
251                    })
252                    .collect();
253                for abs_path in plugin_hits {
254                    if let Ok(source) = std::fs::read_to_string(abs_path) {
255                        let plugin_result = plugin.resolve_config(abs_path, &source, root);
256                        if !plugin_result.is_empty() {
257                            resolved_plugins.insert(plugin.name());
258                            tracing::debug!(
259                                plugin = plugin.name(),
260                                config = %abs_path.display(),
261                                entries = plugin_result.entry_patterns.len(),
262                                deps = plugin_result.referenced_dependencies.len(),
263                                "resolved config"
264                            );
265                            process_config_result(plugin.name(), plugin_result, &mut result);
266                        }
267                    }
268                }
269            }
270
271            // Phase 3b: Filesystem fallback for JSON config files.
272            // JSON files (angular.json, project.json) are not in the discovered file set
273            // because fallow only discovers JS/TS/CSS/Vue/etc. files. In production
274            // mode, source-extension configs (`*.config.*`, dotfiles) are also
275            // excluded from the walker, so the FS walk runs for those patterns too.
276            let json_configs = discover_config_files(
277                &config_matchers,
278                &resolved_plugins,
279                config_search_roots,
280                production_mode,
281            );
282            for (abs_path, plugin) in &json_configs {
283                if let Ok(source) = std::fs::read_to_string(abs_path) {
284                    let plugin_result = plugin.resolve_config(abs_path, &source, root);
285                    if !plugin_result.is_empty() {
286                        let rel = abs_path
287                            .strip_prefix(root)
288                            .map(|p| p.to_string_lossy())
289                            .unwrap_or_default();
290                        tracing::debug!(
291                            plugin = plugin.name(),
292                            config = %rel,
293                            entries = plugin_result.entry_patterns.len(),
294                            deps = plugin_result.referenced_dependencies.len(),
295                            "resolved config (filesystem fallback)"
296                        );
297                        process_config_result(plugin.name(), plugin_result, &mut result);
298                    }
299                }
300            }
301        }
302
303        // Phase 4: Package.json inline config fallback
304        // For plugins that define `package_json_config_key()`, check if the root
305        // package.json contains that key and no standalone config file was found.
306        for plugin in &active {
307            if let Some(key) = plugin.package_json_config_key()
308                && !check_has_config_file(*plugin, &config_matchers, &relative_files)
309            {
310                // Try to extract the key from package.json
311                let pkg_path = root.join("package.json");
312                if let Ok(content) = std::fs::read_to_string(&pkg_path)
313                    && let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
314                    && let Some(config_value) = json.get(key)
315                {
316                    let config_json = serde_json::to_string(config_value).unwrap_or_default();
317                    let fake_path = root.join(format!("{key}.config.json"));
318                    let plugin_result = plugin.resolve_config(&fake_path, &config_json, root);
319                    if !plugin_result.is_empty() {
320                        tracing::debug!(
321                            plugin = plugin.name(),
322                            key = key,
323                            "resolved inline package.json config"
324                        );
325                        process_config_result(plugin.name(), plugin_result, &mut result);
326                    }
327                }
328            }
329        }
330
331        result
332    }
333
334    /// Fast variant of `run()` for workspace packages.
335    ///
336    /// Reuses pre-compiled config matchers and pre-computed relative files from the root
337    /// project run, avoiding repeated glob compilation and path computation per workspace.
338    /// Skips package.json inline config (workspace packages rarely have inline configs).
339    #[expect(
340        clippy::too_many_arguments,
341        reason = "Each parameter is a distinct, small value with no natural grouping; \
342                  bundling them into a struct hurts call-site readability."
343    )]
344    pub fn run_workspace_fast(
345        &self,
346        pkg: &PackageJson,
347        root: &Path,
348        project_root: &Path,
349        precompiled_config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
350        relative_files: &[(PathBuf, String)],
351        skip_config_plugins: &FxHashSet<&str>,
352        production_mode: bool,
353    ) -> AggregatedPluginResult {
354        let _span = tracing::info_span!("run_plugins").entered();
355        let mut result = AggregatedPluginResult::default();
356
357        // Phase 1: Determine which plugins are active (with pre-computed deps)
358        let all_deps = pkg.all_dependency_names();
359        let active: Vec<&dyn Plugin> = self
360            .plugins
361            .iter()
362            .filter(|p| p.is_enabled_with_deps(&all_deps, root))
363            .map(AsRef::as_ref)
364            .collect();
365
366        let workspace_files: Vec<PathBuf> = relative_files
367            .iter()
368            .map(|(abs_path, _)| abs_path.clone())
369            .collect();
370
371        tracing::info!(
372            plugins = active
373                .iter()
374                .map(|p| p.name())
375                .collect::<Vec<_>>()
376                .join(", "),
377            "active plugins"
378        );
379
380        process_external_plugins(
381            &self.external_plugins,
382            &all_deps,
383            root,
384            &workspace_files,
385            &mut result,
386        );
387
388        // Early exit if no plugins are active (common for leaf workspace packages)
389        if active.is_empty() && result.active_plugins.is_empty() {
390            return result;
391        }
392
393        // Phase 2: Collect static patterns from active plugins
394        for plugin in &active {
395            process_static_patterns(*plugin, root, &mut result);
396        }
397
398        // Phase 3: Find and parse config files using pre-compiled matchers
399        // Only check matchers for plugins that are active in this workspace
400        let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
401        let workspace_matchers: Vec<_> = precompiled_config_matchers
402            .iter()
403            .filter(|(p, _)| {
404                active_names.contains(p.name())
405                    && (!skip_config_plugins.contains(p.name())
406                        || must_parse_workspace_config_when_root_active(p.name()))
407            })
408            .map(|(plugin, matchers)| (*plugin, matchers.clone()))
409            .collect();
410
411        let mut resolved_ws_plugins: FxHashSet<&str> = FxHashSet::default();
412        if !workspace_matchers.is_empty() {
413            use rayon::prelude::*;
414            for (plugin, matchers) in &workspace_matchers {
415                let plugin_hits: Vec<&PathBuf> = relative_files
416                    .par_iter()
417                    .filter_map(|(abs_path, rel_path)| {
418                        matchers
419                            .iter()
420                            .any(|m| m.is_match(rel_path.as_str()))
421                            .then_some(abs_path)
422                    })
423                    .collect();
424                for abs_path in plugin_hits {
425                    if let Ok(source) = std::fs::read_to_string(abs_path) {
426                        let plugin_result = plugin.resolve_config(abs_path, &source, root);
427                        if !plugin_result.is_empty() {
428                            resolved_ws_plugins.insert(plugin.name());
429                            tracing::debug!(
430                                plugin = plugin.name(),
431                                config = %abs_path.display(),
432                                entries = plugin_result.entry_patterns.len(),
433                                deps = plugin_result.referenced_dependencies.len(),
434                                "resolved config"
435                            );
436                            process_config_result(plugin.name(), plugin_result, &mut result);
437                        }
438                    }
439                }
440            }
441        }
442
443        // Phase 3b: Filesystem fallback for JSON config files at the project root.
444        // Config files like angular.json live at the monorepo root, but Angular is
445        // only active in workspace packages. Check the project root for unresolved
446        // config patterns.
447        let ws_json_configs = if root == project_root {
448            discover_config_files(
449                &workspace_matchers,
450                &resolved_ws_plugins,
451                &[root],
452                production_mode,
453            )
454        } else {
455            discover_config_files(
456                &workspace_matchers,
457                &resolved_ws_plugins,
458                &[root, project_root],
459                production_mode,
460            )
461        };
462        // Parse discovered JSON config files
463        for (abs_path, plugin) in &ws_json_configs {
464            if let Ok(source) = std::fs::read_to_string(abs_path) {
465                let plugin_result = plugin.resolve_config(abs_path, &source, root);
466                if !plugin_result.is_empty() {
467                    let rel = abs_path
468                        .strip_prefix(project_root)
469                        .map(|p| p.to_string_lossy())
470                        .unwrap_or_default();
471                    tracing::debug!(
472                        plugin = plugin.name(),
473                        config = %rel,
474                        entries = plugin_result.entry_patterns.len(),
475                        deps = plugin_result.referenced_dependencies.len(),
476                        "resolved config (workspace filesystem fallback)"
477                    );
478                    process_config_result(plugin.name(), plugin_result, &mut result);
479                }
480            }
481        }
482
483        result
484    }
485
486    /// Pre-compile config pattern glob matchers for all plugins that have config patterns.
487    /// Returns a vec of (plugin, matchers) pairs that can be reused across multiple `run_workspace_fast` calls.
488    #[must_use]
489    pub fn precompile_config_matchers(&self) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
490        self.plugins
491            .iter()
492            .filter(|p| !p.config_patterns().is_empty())
493            .map(|p| {
494                let matchers: Vec<globset::GlobMatcher> = p
495                    .config_patterns()
496                    .iter()
497                    .filter_map(|pat| {
498                        let prepared = prepare_config_pattern(pat);
499                        globset::Glob::new(&prepared)
500                            .ok()
501                            .map(|g| g.compile_matcher())
502                    })
503                    .collect();
504                (p.as_ref(), matchers)
505            })
506            .collect()
507    }
508}
509
510impl Default for PluginRegistry {
511    fn default() -> Self {
512        Self::new(vec![])
513    }
514}
515
516/// Warn when meta-frameworks are active but their generated configs are missing.
517///
518/// Meta-frameworks like Nuxt and Astro generate tsconfig/types files during a
519/// "prepare" step. Without these, the tsconfig extends chain breaks and
520/// extensionless imports fail wholesale (e.g. 2000+ unresolved imports).
521fn check_meta_framework_prerequisites(active_plugins: &[&dyn Plugin], root: &Path) {
522    for plugin in active_plugins {
523        match plugin.name() {
524            "nuxt" if !root.join(".nuxt/tsconfig.json").exists() => {
525                tracing::warn!(
526                    "Nuxt project missing .nuxt/tsconfig.json: run `nuxt prepare` \
527                     before fallow for accurate analysis"
528                );
529            }
530            "astro" if !root.join(".astro").exists() => {
531                tracing::warn!(
532                    "Astro project missing .astro/ types: run `astro sync` \
533                     before fallow for accurate analysis"
534                );
535            }
536            _ => {}
537        }
538    }
539}
540
541#[cfg(test)]
542mod tests;