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