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;