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