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