Skip to main content

fallow_core/plugins/
registry.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::{ExternalPluginDef, PackageJson, PluginDetection};
7
8use super::{Plugin, PluginResult};
9
10// Import all plugin structs
11use super::angular::AngularPlugin;
12use super::astro::AstroPlugin;
13use super::ava::AvaPlugin;
14use super::babel::BabelPlugin;
15use super::biome::BiomePlugin;
16use super::bun::BunPlugin;
17use super::c8::C8Plugin;
18use super::capacitor::CapacitorPlugin;
19use super::changesets::ChangesetsPlugin;
20use super::commitizen::CommitizenPlugin;
21use super::commitlint::CommitlintPlugin;
22use super::cspell::CspellPlugin;
23use super::cucumber::CucumberPlugin;
24use super::cypress::CypressPlugin;
25use super::dependency_cruiser::DependencyCruiserPlugin;
26use super::docusaurus::DocusaurusPlugin;
27use super::drizzle::DrizzlePlugin;
28use super::electron::ElectronPlugin;
29use super::eslint::EslintPlugin;
30use super::expo::ExpoPlugin;
31use super::gatsby::GatsbyPlugin;
32use super::graphql_codegen::GraphqlCodegenPlugin;
33use super::husky::HuskyPlugin;
34use super::i18next::I18nextPlugin;
35use super::jest::JestPlugin;
36use super::karma::KarmaPlugin;
37use super::knex::KnexPlugin;
38use super::kysely::KyselyPlugin;
39use super::lefthook::LefthookPlugin;
40use super::lint_staged::LintStagedPlugin;
41use super::markdownlint::MarkdownlintPlugin;
42use super::mocha::MochaPlugin;
43use super::msw::MswPlugin;
44use super::nestjs::NestJsPlugin;
45use super::next_intl::NextIntlPlugin;
46use super::nextjs::NextJsPlugin;
47use super::nitro::NitroPlugin;
48use super::nodemon::NodemonPlugin;
49use super::nuxt::NuxtPlugin;
50use super::nx::NxPlugin;
51use super::nyc::NycPlugin;
52use super::openapi_ts::OpenapiTsPlugin;
53use super::oxlint::OxlintPlugin;
54use super::parcel::ParcelPlugin;
55use super::playwright::PlaywrightPlugin;
56use super::plop::PlopPlugin;
57use super::pm2::Pm2Plugin;
58use super::postcss::PostCssPlugin;
59use super::prettier::PrettierPlugin;
60use super::prisma::PrismaPlugin;
61use super::react_native::ReactNativePlugin;
62use super::react_router::ReactRouterPlugin;
63use super::relay::RelayPlugin;
64use super::remark::RemarkPlugin;
65use super::remix::RemixPlugin;
66use super::rolldown::RolldownPlugin;
67use super::rollup::RollupPlugin;
68use super::rsbuild::RsbuildPlugin;
69use super::rspack::RspackPlugin;
70use super::sanity::SanityPlugin;
71use super::semantic_release::SemanticReleasePlugin;
72use super::sentry::SentryPlugin;
73use super::simple_git_hooks::SimpleGitHooksPlugin;
74use super::storybook::StorybookPlugin;
75use super::stylelint::StylelintPlugin;
76use super::sveltekit::SvelteKitPlugin;
77use super::svgo::SvgoPlugin;
78use super::svgr::SvgrPlugin;
79use super::swc::SwcPlugin;
80use super::syncpack::SyncpackPlugin;
81use super::tailwind::TailwindPlugin;
82use super::tanstack_router::TanstackRouterPlugin;
83use super::tsdown::TsdownPlugin;
84use super::tsup::TsupPlugin;
85use super::turborepo::TurborepoPlugin;
86use super::typedoc::TypedocPlugin;
87use super::typeorm::TypeormPlugin;
88use super::typescript::TypeScriptPlugin;
89use super::vite::VitePlugin;
90use super::vitepress::VitePressPlugin;
91use super::vitest::VitestPlugin;
92use super::webdriverio::WebdriverioPlugin;
93use super::webpack::WebpackPlugin;
94use super::wrangler::WranglerPlugin;
95
96/// Registry of all available plugins (built-in + external).
97pub struct PluginRegistry {
98    plugins: Vec<Box<dyn Plugin>>,
99    external_plugins: Vec<ExternalPluginDef>,
100}
101
102/// Aggregated results from all active plugins for a project.
103#[derive(Debug, Default)]
104pub struct AggregatedPluginResult {
105    /// All entry point patterns from active plugins: (pattern, plugin_name).
106    pub entry_patterns: Vec<(String, String)>,
107    /// All config file patterns from active plugins.
108    pub config_patterns: Vec<String>,
109    /// All always-used file patterns from active plugins: (pattern, plugin_name).
110    pub always_used: Vec<(String, String)>,
111    /// All used export rules from active plugins.
112    pub used_exports: Vec<(String, Vec<String>)>,
113    /// Dependencies referenced in config files (should not be flagged unused).
114    pub referenced_dependencies: Vec<String>,
115    /// Additional always-used files discovered from config parsing: (pattern, plugin_name).
116    pub discovered_always_used: Vec<(String, String)>,
117    /// Setup files discovered from config parsing: (path, plugin_name).
118    pub setup_files: Vec<(PathBuf, String)>,
119    /// Tooling dependencies (should not be flagged as unused devDeps).
120    pub tooling_dependencies: Vec<String>,
121    /// Package names discovered as used in package.json scripts (binary invocations).
122    pub script_used_packages: FxHashSet<String>,
123    /// Import prefixes for virtual modules provided by active frameworks.
124    /// Imports matching these prefixes should not be flagged as unlisted dependencies.
125    pub virtual_module_prefixes: Vec<String>,
126    /// Path alias mappings from active plugins (prefix → replacement directory).
127    /// Used by the resolver to substitute import prefixes before re-resolving.
128    pub path_aliases: Vec<(String, String)>,
129    /// Names of active plugins.
130    pub active_plugins: Vec<String>,
131}
132
133impl PluginRegistry {
134    /// Create a registry with all built-in plugins and optional external plugins.
135    pub fn new(external: Vec<ExternalPluginDef>) -> Self {
136        let plugins: Vec<Box<dyn Plugin>> = vec![
137            // Frameworks
138            Box::new(NextJsPlugin),
139            Box::new(NuxtPlugin),
140            Box::new(RemixPlugin),
141            Box::new(AstroPlugin),
142            Box::new(AngularPlugin),
143            Box::new(ReactRouterPlugin),
144            Box::new(TanstackRouterPlugin),
145            Box::new(ReactNativePlugin),
146            Box::new(ExpoPlugin),
147            Box::new(NestJsPlugin),
148            Box::new(DocusaurusPlugin),
149            Box::new(GatsbyPlugin),
150            Box::new(SvelteKitPlugin),
151            Box::new(NitroPlugin),
152            Box::new(CapacitorPlugin),
153            Box::new(SanityPlugin),
154            Box::new(VitePressPlugin),
155            Box::new(NextIntlPlugin),
156            Box::new(RelayPlugin),
157            Box::new(ElectronPlugin),
158            Box::new(I18nextPlugin),
159            // Bundlers
160            Box::new(VitePlugin),
161            Box::new(WebpackPlugin),
162            Box::new(RollupPlugin),
163            Box::new(RolldownPlugin),
164            Box::new(RspackPlugin),
165            Box::new(RsbuildPlugin),
166            Box::new(TsupPlugin),
167            Box::new(TsdownPlugin),
168            Box::new(ParcelPlugin),
169            // Testing
170            Box::new(VitestPlugin),
171            Box::new(JestPlugin),
172            Box::new(PlaywrightPlugin),
173            Box::new(CypressPlugin),
174            Box::new(MochaPlugin),
175            Box::new(AvaPlugin),
176            Box::new(StorybookPlugin),
177            Box::new(KarmaPlugin),
178            Box::new(CucumberPlugin),
179            Box::new(WebdriverioPlugin),
180            // Linting & formatting
181            Box::new(EslintPlugin),
182            Box::new(BiomePlugin),
183            Box::new(StylelintPlugin),
184            Box::new(PrettierPlugin),
185            Box::new(OxlintPlugin),
186            Box::new(MarkdownlintPlugin),
187            Box::new(CspellPlugin),
188            Box::new(RemarkPlugin),
189            // Transpilation & language
190            Box::new(TypeScriptPlugin),
191            Box::new(BabelPlugin),
192            Box::new(SwcPlugin),
193            // CSS
194            Box::new(TailwindPlugin),
195            Box::new(PostCssPlugin),
196            // Database & ORM
197            Box::new(PrismaPlugin),
198            Box::new(DrizzlePlugin),
199            Box::new(KnexPlugin),
200            Box::new(TypeormPlugin),
201            Box::new(KyselyPlugin),
202            // Monorepo
203            Box::new(TurborepoPlugin),
204            Box::new(NxPlugin),
205            Box::new(ChangesetsPlugin),
206            Box::new(SyncpackPlugin),
207            // CI/CD & release
208            Box::new(CommitlintPlugin),
209            Box::new(CommitizenPlugin),
210            Box::new(SemanticReleasePlugin),
211            // Deployment
212            Box::new(WranglerPlugin),
213            Box::new(SentryPlugin),
214            // Git hooks
215            Box::new(HuskyPlugin),
216            Box::new(LintStagedPlugin),
217            Box::new(LefthookPlugin),
218            Box::new(SimpleGitHooksPlugin),
219            // Media & assets
220            Box::new(SvgoPlugin),
221            Box::new(SvgrPlugin),
222            // Code generation & docs
223            Box::new(GraphqlCodegenPlugin),
224            Box::new(TypedocPlugin),
225            Box::new(OpenapiTsPlugin),
226            Box::new(PlopPlugin),
227            // Coverage
228            Box::new(C8Plugin),
229            Box::new(NycPlugin),
230            // Other tools
231            Box::new(MswPlugin),
232            Box::new(NodemonPlugin),
233            Box::new(Pm2Plugin),
234            Box::new(DependencyCruiserPlugin),
235            // Runtime
236            Box::new(BunPlugin),
237        ];
238        Self {
239            plugins,
240            external_plugins: external,
241        }
242    }
243
244    /// Run all plugins against a project, returning aggregated results.
245    ///
246    /// This discovers which plugins are active, collects their static patterns,
247    /// then parses any config files to extract dynamic information.
248    pub fn run(
249        &self,
250        pkg: &PackageJson,
251        root: &Path,
252        discovered_files: &[PathBuf],
253    ) -> AggregatedPluginResult {
254        let _span = tracing::info_span!("run_plugins").entered();
255        let mut result = AggregatedPluginResult::default();
256
257        // Phase 1: Determine which plugins are active
258        // Compute deps once to avoid repeated Vec<String> allocation per plugin
259        let all_deps = pkg.all_dependency_names();
260        let active: Vec<&dyn Plugin> = self
261            .plugins
262            .iter()
263            .filter(|p| p.is_enabled_with_deps(&all_deps, root))
264            .map(|p| p.as_ref())
265            .collect();
266
267        tracing::info!(
268            plugins = active
269                .iter()
270                .map(|p| p.name())
271                .collect::<Vec<_>>()
272                .join(", "),
273            "active plugins"
274        );
275
276        // Phase 2: Collect static patterns from active plugins
277        for plugin in &active {
278            process_static_patterns(*plugin, root, &mut result);
279        }
280
281        // Phase 2b: Process external plugins (includes inline framework definitions)
282        process_external_plugins(
283            &self.external_plugins,
284            &all_deps,
285            root,
286            discovered_files,
287            &mut result,
288        );
289
290        // Phase 3: Find and parse config files for dynamic resolution
291        // Pre-compile all config patterns
292        let config_matchers: Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> = active
293            .iter()
294            .filter(|p| !p.config_patterns().is_empty())
295            .map(|p| {
296                let matchers: Vec<globset::GlobMatcher> = p
297                    .config_patterns()
298                    .iter()
299                    .filter_map(|pat| globset::Glob::new(pat).ok().map(|g| g.compile_matcher()))
300                    .collect();
301                (*p, matchers)
302            })
303            .collect();
304
305        // Build relative paths for matching (used by Phase 3 and 4)
306        let relative_files: Vec<(&PathBuf, String)> = discovered_files
307            .iter()
308            .map(|f| {
309                let rel = f
310                    .strip_prefix(root)
311                    .unwrap_or(f)
312                    .to_string_lossy()
313                    .into_owned();
314                (f, rel)
315            })
316            .collect();
317
318        if !config_matchers.is_empty() {
319            // Phase 3a: Match config files from discovered source files
320            let mut resolved_plugins: FxHashSet<&str> = FxHashSet::default();
321
322            for (plugin, matchers) in &config_matchers {
323                for (abs_path, rel_path) in &relative_files {
324                    if matchers.iter().any(|m| m.is_match(rel_path.as_str())) {
325                        // Mark as resolved regardless of result to prevent Phase 3b
326                        // from re-parsing a JSON config for the same plugin.
327                        resolved_plugins.insert(plugin.name());
328                        if let Ok(source) = std::fs::read_to_string(abs_path) {
329                            let plugin_result = plugin.resolve_config(abs_path, &source, root);
330                            if !plugin_result.is_empty() {
331                                tracing::debug!(
332                                    plugin = plugin.name(),
333                                    config = rel_path.as_str(),
334                                    entries = plugin_result.entry_patterns.len(),
335                                    deps = plugin_result.referenced_dependencies.len(),
336                                    "resolved config"
337                                );
338                                process_config_result(plugin.name(), plugin_result, &mut result);
339                            }
340                        }
341                    }
342                }
343            }
344
345            // Phase 3b: Filesystem fallback for JSON config files.
346            // JSON files (angular.json, project.json) are not in the discovered file set
347            // because fallow only discovers JS/TS/CSS/Vue/etc. files.
348            let json_configs = discover_json_config_files(
349                &config_matchers,
350                &resolved_plugins,
351                &relative_files,
352                root,
353            );
354            for (abs_path, plugin) in &json_configs {
355                if let Ok(source) = std::fs::read_to_string(abs_path) {
356                    let plugin_result = plugin.resolve_config(abs_path, &source, root);
357                    if !plugin_result.is_empty() {
358                        let rel = abs_path
359                            .strip_prefix(root)
360                            .map(|p| p.to_string_lossy())
361                            .unwrap_or_default();
362                        tracing::debug!(
363                            plugin = plugin.name(),
364                            config = %rel,
365                            entries = plugin_result.entry_patterns.len(),
366                            deps = plugin_result.referenced_dependencies.len(),
367                            "resolved config (filesystem fallback)"
368                        );
369                        process_config_result(plugin.name(), plugin_result, &mut result);
370                    }
371                }
372            }
373        }
374
375        // Phase 4: Package.json inline config fallback
376        // For plugins that define `package_json_config_key()`, check if the root
377        // package.json contains that key and no standalone config file was found.
378        for plugin in &active {
379            if let Some(key) = plugin.package_json_config_key()
380                && !check_has_config_file(*plugin, &config_matchers, &relative_files)
381            {
382                // Try to extract the key from package.json
383                let pkg_path = root.join("package.json");
384                if let Ok(content) = std::fs::read_to_string(&pkg_path)
385                    && let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
386                    && let Some(config_value) = json.get(key)
387                {
388                    let config_json = serde_json::to_string(config_value).unwrap_or_default();
389                    let fake_path = root.join(format!("{key}.config.json"));
390                    let plugin_result = plugin.resolve_config(&fake_path, &config_json, root);
391                    if !plugin_result.is_empty() {
392                        tracing::debug!(
393                            plugin = plugin.name(),
394                            key = key,
395                            "resolved inline package.json config"
396                        );
397                        process_config_result(plugin.name(), plugin_result, &mut result);
398                    }
399                }
400            }
401        }
402
403        result
404    }
405
406    /// Fast variant of `run()` for workspace packages.
407    ///
408    /// Reuses pre-compiled config matchers and pre-computed relative files from the root
409    /// project run, avoiding repeated glob compilation and path computation per workspace.
410    /// Skips external plugins (they only activate at root level) and package.json inline
411    /// config (workspace packages rarely have inline configs).
412    pub fn run_workspace_fast(
413        &self,
414        pkg: &PackageJson,
415        root: &Path,
416        project_root: &Path,
417        precompiled_config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
418        relative_files: &[(&PathBuf, String)],
419    ) -> AggregatedPluginResult {
420        let _span = tracing::info_span!("run_plugins").entered();
421        let mut result = AggregatedPluginResult::default();
422
423        // Phase 1: Determine which plugins are active (with pre-computed deps)
424        let all_deps = pkg.all_dependency_names();
425        let active: Vec<&dyn Plugin> = self
426            .plugins
427            .iter()
428            .filter(|p| p.is_enabled_with_deps(&all_deps, root))
429            .map(|p| p.as_ref())
430            .collect();
431
432        tracing::info!(
433            plugins = active
434                .iter()
435                .map(|p| p.name())
436                .collect::<Vec<_>>()
437                .join(", "),
438            "active plugins"
439        );
440
441        // Early exit if no plugins are active (common for leaf workspace packages)
442        if active.is_empty() {
443            return result;
444        }
445
446        // Phase 2: Collect static patterns from active plugins
447        for plugin in &active {
448            process_static_patterns(*plugin, root, &mut result);
449        }
450
451        // Phase 3: Find and parse config files using pre-compiled matchers
452        // Only check matchers for plugins that are active in this workspace
453        let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
454        let workspace_matchers: Vec<_> = precompiled_config_matchers
455            .iter()
456            .filter(|(p, _)| active_names.contains(p.name()))
457            .collect();
458
459        let mut resolved_ws_plugins: FxHashSet<&str> = FxHashSet::default();
460        if !workspace_matchers.is_empty() {
461            for (plugin, matchers) in &workspace_matchers {
462                for (abs_path, rel_path) in relative_files {
463                    if matchers.iter().any(|m| m.is_match(rel_path.as_str()))
464                        && let Ok(source) = std::fs::read_to_string(abs_path)
465                    {
466                        // Mark resolved regardless of result to prevent Phase 3b
467                        // from re-parsing a JSON config for the same plugin.
468                        resolved_ws_plugins.insert(plugin.name());
469                        let plugin_result = plugin.resolve_config(abs_path, &source, root);
470                        if !plugin_result.is_empty() {
471                            tracing::debug!(
472                                plugin = plugin.name(),
473                                config = rel_path.as_str(),
474                                entries = plugin_result.entry_patterns.len(),
475                                deps = plugin_result.referenced_dependencies.len(),
476                                "resolved config"
477                            );
478                            process_config_result(plugin.name(), plugin_result, &mut result);
479                        }
480                    }
481                }
482            }
483        }
484
485        // Phase 3b: Filesystem fallback for JSON config files at the project root.
486        // Config files like angular.json live at the monorepo root, but Angular is
487        // only active in workspace packages. Check the project root for unresolved
488        // config patterns.
489        let mut ws_json_configs: Vec<(PathBuf, &dyn Plugin)> = Vec::new();
490        let mut ws_seen_paths: FxHashSet<PathBuf> = FxHashSet::default();
491        for plugin in &active {
492            if resolved_ws_plugins.contains(plugin.name()) || plugin.config_patterns().is_empty() {
493                continue;
494            }
495            for pat in plugin.config_patterns() {
496                let has_glob = pat.contains("**") || pat.contains('*') || pat.contains('?');
497                if !has_glob {
498                    // Check both workspace root and project root (deduplicate when equal)
499                    let check_roots: Vec<&Path> = if root == project_root {
500                        vec![root]
501                    } else {
502                        vec![root, project_root]
503                    };
504                    for check_root in check_roots {
505                        let abs_path = check_root.join(pat);
506                        if abs_path.is_file() && ws_seen_paths.insert(abs_path.clone()) {
507                            ws_json_configs.push((abs_path, *plugin));
508                            break; // Found it — don't check other roots for this pattern
509                        }
510                    }
511                } else {
512                    // Glob pattern (e.g., "**/project.json") — check directories
513                    // that contain discovered source files
514                    let filename = std::path::Path::new(pat)
515                        .file_name()
516                        .and_then(|n| n.to_str())
517                        .unwrap_or(pat);
518                    let matcher = globset::Glob::new(pat).ok().map(|g| g.compile_matcher());
519                    if let Some(matcher) = matcher {
520                        let mut checked_dirs: FxHashSet<&Path> = FxHashSet::default();
521                        checked_dirs.insert(root);
522                        if root != project_root {
523                            checked_dirs.insert(project_root);
524                        }
525                        for (abs_path, _) in relative_files {
526                            if let Some(parent) = abs_path.parent() {
527                                checked_dirs.insert(parent);
528                            }
529                        }
530                        for dir in checked_dirs {
531                            let candidate = dir.join(filename);
532                            if candidate.is_file() && ws_seen_paths.insert(candidate.clone()) {
533                                let rel = candidate
534                                    .strip_prefix(project_root)
535                                    .map(|p| p.to_string_lossy())
536                                    .unwrap_or_default();
537                                if matcher.is_match(rel.as_ref()) {
538                                    ws_json_configs.push((candidate, *plugin));
539                                }
540                            }
541                        }
542                    }
543                }
544            }
545        }
546        // Parse discovered JSON config files
547        for (abs_path, plugin) in &ws_json_configs {
548            if let Ok(source) = std::fs::read_to_string(abs_path) {
549                let plugin_result = plugin.resolve_config(abs_path, &source, root);
550                if !plugin_result.is_empty() {
551                    let rel = abs_path
552                        .strip_prefix(project_root)
553                        .map(|p| p.to_string_lossy())
554                        .unwrap_or_default();
555                    tracing::debug!(
556                        plugin = plugin.name(),
557                        config = %rel,
558                        entries = plugin_result.entry_patterns.len(),
559                        deps = plugin_result.referenced_dependencies.len(),
560                        "resolved config (workspace filesystem fallback)"
561                    );
562                    process_config_result(plugin.name(), plugin_result, &mut result);
563                }
564            }
565        }
566
567        result
568    }
569
570    /// Pre-compile config pattern glob matchers for all plugins that have config patterns.
571    /// Returns a vec of (plugin, matchers) pairs that can be reused across multiple `run_workspace_fast` calls.
572    pub fn precompile_config_matchers(&self) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
573        self.plugins
574            .iter()
575            .filter(|p| !p.config_patterns().is_empty())
576            .map(|p| {
577                let matchers: Vec<globset::GlobMatcher> = p
578                    .config_patterns()
579                    .iter()
580                    .filter_map(|pat| globset::Glob::new(pat).ok().map(|g| g.compile_matcher()))
581                    .collect();
582                (p.as_ref(), matchers)
583            })
584            .collect()
585    }
586}
587
588/// Collect static patterns from a single plugin into the aggregated result.
589fn process_static_patterns(plugin: &dyn Plugin, root: &Path, result: &mut AggregatedPluginResult) {
590    result.active_plugins.push(plugin.name().to_string());
591
592    let pname = plugin.name().to_string();
593    for pat in plugin.entry_patterns() {
594        result
595            .entry_patterns
596            .push(((*pat).to_string(), pname.clone()));
597    }
598    for pat in plugin.config_patterns() {
599        result.config_patterns.push((*pat).to_string());
600    }
601    for pat in plugin.always_used() {
602        result.always_used.push(((*pat).to_string(), pname.clone()));
603    }
604    for (file_pat, exports) in plugin.used_exports() {
605        result.used_exports.push((
606            file_pat.to_string(),
607            exports.iter().map(|s| s.to_string()).collect(),
608        ));
609    }
610    for dep in plugin.tooling_dependencies() {
611        result.tooling_dependencies.push((*dep).to_string());
612    }
613    for prefix in plugin.virtual_module_prefixes() {
614        result.virtual_module_prefixes.push((*prefix).to_string());
615    }
616    for (prefix, replacement) in plugin.path_aliases(root) {
617        result.path_aliases.push((prefix.to_string(), replacement));
618    }
619}
620
621/// Process external plugin definitions, checking activation and aggregating patterns.
622fn process_external_plugins(
623    external_plugins: &[ExternalPluginDef],
624    all_deps: &[String],
625    root: &Path,
626    discovered_files: &[PathBuf],
627    result: &mut AggregatedPluginResult,
628) {
629    let all_dep_refs: Vec<&str> = all_deps.iter().map(|s| s.as_str()).collect();
630    for ext in external_plugins {
631        let is_active = if let Some(detection) = &ext.detection {
632            check_plugin_detection(detection, &all_dep_refs, root, discovered_files)
633        } else if !ext.enablers.is_empty() {
634            ext.enablers.iter().any(|enabler| {
635                if enabler.ends_with('/') {
636                    all_deps.iter().any(|d| d.starts_with(enabler))
637                } else {
638                    all_deps.iter().any(|d| d == enabler)
639                }
640            })
641        } else {
642            false
643        };
644        if is_active {
645            result.active_plugins.push(ext.name.clone());
646            result.entry_patterns.extend(
647                ext.entry_points
648                    .iter()
649                    .map(|p| (p.clone(), ext.name.clone())),
650            );
651            // Track config patterns for introspection (not used for AST parsing —
652            // external plugins cannot do resolve_config())
653            result.config_patterns.extend(ext.config_patterns.clone());
654            result.always_used.extend(
655                ext.config_patterns
656                    .iter()
657                    .chain(ext.always_used.iter())
658                    .map(|p| (p.clone(), ext.name.clone())),
659            );
660            result
661                .tooling_dependencies
662                .extend(ext.tooling_dependencies.clone());
663            for ue in &ext.used_exports {
664                result
665                    .used_exports
666                    .push((ue.pattern.clone(), ue.exports.clone()));
667            }
668        }
669    }
670}
671
672/// Discover JSON config files on the filesystem for plugins that weren't matched against
673/// discovered source files. Returns `(path, plugin)` pairs.
674fn discover_json_config_files<'a>(
675    config_matchers: &[(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
676    resolved_plugins: &FxHashSet<&str>,
677    relative_files: &[(&PathBuf, String)],
678    root: &Path,
679) -> Vec<(PathBuf, &'a dyn Plugin)> {
680    let mut json_configs: Vec<(PathBuf, &'a dyn Plugin)> = Vec::new();
681    for (plugin, _) in config_matchers {
682        if resolved_plugins.contains(plugin.name()) {
683            continue;
684        }
685        for pat in plugin.config_patterns() {
686            let has_glob = pat.contains("**") || pat.contains('*') || pat.contains('?');
687            if !has_glob {
688                // Simple pattern (e.g., "angular.json") — check at root
689                let abs_path = root.join(pat);
690                if abs_path.is_file() {
691                    json_configs.push((abs_path, *plugin));
692                }
693            } else {
694                // Glob pattern (e.g., "**/project.json") — check directories
695                // that contain discovered source files
696                let filename = std::path::Path::new(pat)
697                    .file_name()
698                    .and_then(|n| n.to_str())
699                    .unwrap_or(pat);
700                let matcher = globset::Glob::new(pat).ok().map(|g| g.compile_matcher());
701                if let Some(matcher) = matcher {
702                    let mut checked_dirs: FxHashSet<&Path> = FxHashSet::default();
703                    checked_dirs.insert(root);
704                    for (abs_path, _) in relative_files {
705                        if let Some(parent) = abs_path.parent() {
706                            checked_dirs.insert(parent);
707                        }
708                    }
709                    for dir in checked_dirs {
710                        let candidate = dir.join(filename);
711                        if candidate.is_file() {
712                            let rel = candidate
713                                .strip_prefix(root)
714                                .map(|p| p.to_string_lossy())
715                                .unwrap_or_default();
716                            if matcher.is_match(rel.as_ref()) {
717                                json_configs.push((candidate, *plugin));
718                            }
719                        }
720                    }
721                }
722            }
723        }
724    }
725    json_configs
726}
727
728/// Merge a `PluginResult` from config parsing into the aggregated result.
729fn process_config_result(
730    plugin_name: &str,
731    plugin_result: PluginResult,
732    result: &mut AggregatedPluginResult,
733) {
734    let pname = plugin_name.to_string();
735    result.entry_patterns.extend(
736        plugin_result
737            .entry_patterns
738            .into_iter()
739            .map(|p| (p, pname.clone())),
740    );
741    result
742        .referenced_dependencies
743        .extend(plugin_result.referenced_dependencies);
744    result.discovered_always_used.extend(
745        plugin_result
746            .always_used_files
747            .into_iter()
748            .map(|p| (p, pname.clone())),
749    );
750    result.setup_files.extend(
751        plugin_result
752            .setup_files
753            .into_iter()
754            .map(|p| (p, pname.clone())),
755    );
756}
757
758/// Check if a plugin already has a config file matched against discovered files.
759fn check_has_config_file(
760    plugin: &dyn Plugin,
761    config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
762    relative_files: &[(&PathBuf, String)],
763) -> bool {
764    !plugin.config_patterns().is_empty()
765        && config_matchers.iter().any(|(p, matchers)| {
766            p.name() == plugin.name()
767                && relative_files
768                    .iter()
769                    .any(|(_, rel)| matchers.iter().any(|m| m.is_match(rel.as_str())))
770        })
771}
772
773/// Check if a `PluginDetection` condition is satisfied.
774fn check_plugin_detection(
775    detection: &PluginDetection,
776    all_deps: &[&str],
777    root: &Path,
778    discovered_files: &[PathBuf],
779) -> bool {
780    match detection {
781        PluginDetection::Dependency { package } => all_deps.iter().any(|d| *d == package),
782        PluginDetection::FileExists { pattern } => {
783            // Check against discovered files first (fast path)
784            if let Ok(matcher) = globset::Glob::new(pattern).map(|g| g.compile_matcher()) {
785                for file in discovered_files {
786                    let relative = file.strip_prefix(root).unwrap_or(file);
787                    if matcher.is_match(relative) {
788                        return true;
789                    }
790                }
791            }
792            // Fall back to glob on disk for non-source files (e.g., config files)
793            let full_pattern = root.join(pattern).to_string_lossy().to_string();
794            glob::glob(&full_pattern)
795                .ok()
796                .is_some_and(|mut g| g.next().is_some())
797        }
798        PluginDetection::All { conditions } => conditions
799            .iter()
800            .all(|c| check_plugin_detection(c, all_deps, root, discovered_files)),
801        PluginDetection::Any { conditions } => conditions
802            .iter()
803            .any(|c| check_plugin_detection(c, all_deps, root, discovered_files)),
804    }
805}
806
807impl Default for PluginRegistry {
808    fn default() -> Self {
809        Self::new(vec![])
810    }
811}
812
813#[cfg(test)]
814#[expect(clippy::disallowed_types)]
815mod tests {
816    use super::*;
817    use fallow_config::{ExternalPluginDef, ExternalUsedExport, PluginDetection};
818    use std::collections::HashMap;
819
820    /// Helper: build a PackageJson with given dependency names.
821    fn make_pkg(deps: &[&str]) -> PackageJson {
822        let map: HashMap<String, String> =
823            deps.iter().map(|d| (d.to_string(), "*".into())).collect();
824        PackageJson {
825            dependencies: Some(map),
826            ..Default::default()
827        }
828    }
829
830    /// Helper: build a PackageJson with dev dependencies.
831    fn make_pkg_dev(deps: &[&str]) -> PackageJson {
832        let map: HashMap<String, String> =
833            deps.iter().map(|d| (d.to_string(), "*".into())).collect();
834        PackageJson {
835            dev_dependencies: Some(map),
836            ..Default::default()
837        }
838    }
839
840    // ── Plugin detection via enablers ────────────────────────────
841
842    #[test]
843    fn nextjs_detected_when_next_in_deps() {
844        let registry = PluginRegistry::default();
845        let pkg = make_pkg(&["next", "react"]);
846        let result = registry.run(&pkg, Path::new("/project"), &[]);
847        assert!(
848            result.active_plugins.contains(&"nextjs".to_string()),
849            "nextjs plugin should be active when 'next' is in deps"
850        );
851    }
852
853    #[test]
854    fn nextjs_not_detected_without_next() {
855        let registry = PluginRegistry::default();
856        let pkg = make_pkg(&["react", "react-dom"]);
857        let result = registry.run(&pkg, Path::new("/project"), &[]);
858        assert!(
859            !result.active_plugins.contains(&"nextjs".to_string()),
860            "nextjs plugin should not be active without 'next' in deps"
861        );
862    }
863
864    #[test]
865    fn prefix_enabler_matches_scoped_packages() {
866        // Storybook uses "@storybook/" prefix matcher
867        let registry = PluginRegistry::default();
868        let pkg = make_pkg(&["@storybook/react"]);
869        let result = registry.run(&pkg, Path::new("/project"), &[]);
870        assert!(
871            result.active_plugins.contains(&"storybook".to_string()),
872            "storybook should activate via prefix match on @storybook/react"
873        );
874    }
875
876    #[test]
877    fn prefix_enabler_does_not_match_without_slash() {
878        // "storybook" (exact) should match, but "@storybook" (without /) should not match via prefix
879        let registry = PluginRegistry::default();
880        // This only has a package called "@storybookish" — it should NOT match
881        let mut map = HashMap::new();
882        map.insert("@storybookish".to_string(), "*".to_string());
883        let pkg = PackageJson {
884            dependencies: Some(map),
885            ..Default::default()
886        };
887        let result = registry.run(&pkg, Path::new("/project"), &[]);
888        assert!(
889            !result.active_plugins.contains(&"storybook".to_string()),
890            "storybook should not activate for '@storybookish' (no slash prefix match)"
891        );
892    }
893
894    #[test]
895    fn multiple_plugins_detected_simultaneously() {
896        let registry = PluginRegistry::default();
897        let pkg = make_pkg(&["next", "vitest", "typescript"]);
898        let result = registry.run(&pkg, Path::new("/project"), &[]);
899        assert!(result.active_plugins.contains(&"nextjs".to_string()));
900        assert!(result.active_plugins.contains(&"vitest".to_string()));
901        assert!(result.active_plugins.contains(&"typescript".to_string()));
902    }
903
904    #[test]
905    fn no_plugins_for_empty_deps() {
906        let registry = PluginRegistry::default();
907        let pkg = PackageJson::default();
908        let result = registry.run(&pkg, Path::new("/project"), &[]);
909        assert!(
910            result.active_plugins.is_empty(),
911            "no plugins should activate with empty package.json"
912        );
913    }
914
915    // ── Aggregation: entry patterns, tooling deps ────────────────
916
917    #[test]
918    fn active_plugin_contributes_entry_patterns() {
919        let registry = PluginRegistry::default();
920        let pkg = make_pkg(&["next"]);
921        let result = registry.run(&pkg, Path::new("/project"), &[]);
922        // Next.js should contribute App Router entry patterns
923        assert!(
924            result
925                .entry_patterns
926                .iter()
927                .any(|(p, _)| p.contains("app/**/page")),
928            "nextjs plugin should add app/**/page entry pattern"
929        );
930    }
931
932    #[test]
933    fn inactive_plugin_does_not_contribute_entry_patterns() {
934        let registry = PluginRegistry::default();
935        let pkg = make_pkg(&["react"]);
936        let result = registry.run(&pkg, Path::new("/project"), &[]);
937        // Next.js patterns should not be present
938        assert!(
939            !result
940                .entry_patterns
941                .iter()
942                .any(|(p, _)| p.contains("app/**/page")),
943            "nextjs patterns should not appear when plugin is inactive"
944        );
945    }
946
947    #[test]
948    fn active_plugin_contributes_tooling_deps() {
949        let registry = PluginRegistry::default();
950        let pkg = make_pkg(&["next"]);
951        let result = registry.run(&pkg, Path::new("/project"), &[]);
952        assert!(
953            result.tooling_dependencies.contains(&"next".to_string()),
954            "nextjs plugin should list 'next' as a tooling dependency"
955        );
956    }
957
958    #[test]
959    fn dev_deps_also_trigger_plugins() {
960        let registry = PluginRegistry::default();
961        let pkg = make_pkg_dev(&["vitest"]);
962        let result = registry.run(&pkg, Path::new("/project"), &[]);
963        assert!(
964            result.active_plugins.contains(&"vitest".to_string()),
965            "vitest should activate from devDependencies"
966        );
967    }
968
969    // ── External plugins ─────────────────────────────────────────
970
971    #[test]
972    fn external_plugin_detected_by_enablers() {
973        let ext = ExternalPluginDef {
974            schema: None,
975            name: "my-framework".to_string(),
976            detection: None,
977            enablers: vec!["my-framework".to_string()],
978            entry_points: vec!["src/routes/**/*.ts".to_string()],
979            config_patterns: vec![],
980            always_used: vec!["my.config.ts".to_string()],
981            tooling_dependencies: vec!["my-framework-cli".to_string()],
982            used_exports: vec![],
983        };
984        let registry = PluginRegistry::new(vec![ext]);
985        let pkg = make_pkg(&["my-framework"]);
986        let result = registry.run(&pkg, Path::new("/project"), &[]);
987        assert!(result.active_plugins.contains(&"my-framework".to_string()));
988        assert!(
989            result
990                .entry_patterns
991                .iter()
992                .any(|(p, _)| p == "src/routes/**/*.ts")
993        );
994        assert!(
995            result
996                .tooling_dependencies
997                .contains(&"my-framework-cli".to_string())
998        );
999    }
1000
1001    #[test]
1002    fn external_plugin_not_detected_when_dep_missing() {
1003        let ext = ExternalPluginDef {
1004            schema: None,
1005            name: "my-framework".to_string(),
1006            detection: None,
1007            enablers: vec!["my-framework".to_string()],
1008            entry_points: vec!["src/routes/**/*.ts".to_string()],
1009            config_patterns: vec![],
1010            always_used: vec![],
1011            tooling_dependencies: vec![],
1012            used_exports: vec![],
1013        };
1014        let registry = PluginRegistry::new(vec![ext]);
1015        let pkg = make_pkg(&["react"]);
1016        let result = registry.run(&pkg, Path::new("/project"), &[]);
1017        assert!(!result.active_plugins.contains(&"my-framework".to_string()));
1018        assert!(
1019            !result
1020                .entry_patterns
1021                .iter()
1022                .any(|(p, _)| p == "src/routes/**/*.ts")
1023        );
1024    }
1025
1026    #[test]
1027    fn external_plugin_prefix_enabler() {
1028        let ext = ExternalPluginDef {
1029            schema: None,
1030            name: "custom-plugin".to_string(),
1031            detection: None,
1032            enablers: vec!["@custom/".to_string()],
1033            entry_points: vec!["custom/**/*.ts".to_string()],
1034            config_patterns: vec![],
1035            always_used: vec![],
1036            tooling_dependencies: vec![],
1037            used_exports: vec![],
1038        };
1039        let registry = PluginRegistry::new(vec![ext]);
1040        let pkg = make_pkg(&["@custom/core"]);
1041        let result = registry.run(&pkg, Path::new("/project"), &[]);
1042        assert!(result.active_plugins.contains(&"custom-plugin".to_string()));
1043    }
1044
1045    #[test]
1046    fn external_plugin_detection_dependency() {
1047        let ext = ExternalPluginDef {
1048            schema: None,
1049            name: "detected-plugin".to_string(),
1050            detection: Some(PluginDetection::Dependency {
1051                package: "special-dep".to_string(),
1052            }),
1053            enablers: vec![],
1054            entry_points: vec!["special/**/*.ts".to_string()],
1055            config_patterns: vec![],
1056            always_used: vec![],
1057            tooling_dependencies: vec![],
1058            used_exports: vec![],
1059        };
1060        let registry = PluginRegistry::new(vec![ext]);
1061        let pkg = make_pkg(&["special-dep"]);
1062        let result = registry.run(&pkg, Path::new("/project"), &[]);
1063        assert!(
1064            result
1065                .active_plugins
1066                .contains(&"detected-plugin".to_string())
1067        );
1068    }
1069
1070    #[test]
1071    fn external_plugin_detection_any_combinator() {
1072        let ext = ExternalPluginDef {
1073            schema: None,
1074            name: "any-plugin".to_string(),
1075            detection: Some(PluginDetection::Any {
1076                conditions: vec![
1077                    PluginDetection::Dependency {
1078                        package: "pkg-a".to_string(),
1079                    },
1080                    PluginDetection::Dependency {
1081                        package: "pkg-b".to_string(),
1082                    },
1083                ],
1084            }),
1085            enablers: vec![],
1086            entry_points: vec!["any/**/*.ts".to_string()],
1087            config_patterns: vec![],
1088            always_used: vec![],
1089            tooling_dependencies: vec![],
1090            used_exports: vec![],
1091        };
1092        let registry = PluginRegistry::new(vec![ext]);
1093        // Only pkg-b present — should still match via Any
1094        let pkg = make_pkg(&["pkg-b"]);
1095        let result = registry.run(&pkg, Path::new("/project"), &[]);
1096        assert!(result.active_plugins.contains(&"any-plugin".to_string()));
1097    }
1098
1099    #[test]
1100    fn external_plugin_detection_all_combinator_fails_partial() {
1101        let ext = ExternalPluginDef {
1102            schema: None,
1103            name: "all-plugin".to_string(),
1104            detection: Some(PluginDetection::All {
1105                conditions: vec![
1106                    PluginDetection::Dependency {
1107                        package: "pkg-a".to_string(),
1108                    },
1109                    PluginDetection::Dependency {
1110                        package: "pkg-b".to_string(),
1111                    },
1112                ],
1113            }),
1114            enablers: vec![],
1115            entry_points: vec![],
1116            config_patterns: vec![],
1117            always_used: vec![],
1118            tooling_dependencies: vec![],
1119            used_exports: vec![],
1120        };
1121        let registry = PluginRegistry::new(vec![ext]);
1122        // Only pkg-a present — All requires both
1123        let pkg = make_pkg(&["pkg-a"]);
1124        let result = registry.run(&pkg, Path::new("/project"), &[]);
1125        assert!(!result.active_plugins.contains(&"all-plugin".to_string()));
1126    }
1127
1128    #[test]
1129    fn external_plugin_used_exports_aggregated() {
1130        let ext = ExternalPluginDef {
1131            schema: None,
1132            name: "ue-plugin".to_string(),
1133            detection: None,
1134            enablers: vec!["ue-dep".to_string()],
1135            entry_points: vec![],
1136            config_patterns: vec![],
1137            always_used: vec![],
1138            tooling_dependencies: vec![],
1139            used_exports: vec![ExternalUsedExport {
1140                pattern: "pages/**/*.tsx".to_string(),
1141                exports: vec!["default".to_string(), "getServerSideProps".to_string()],
1142            }],
1143        };
1144        let registry = PluginRegistry::new(vec![ext]);
1145        let pkg = make_pkg(&["ue-dep"]);
1146        let result = registry.run(&pkg, Path::new("/project"), &[]);
1147        assert!(result.used_exports.iter().any(|(pat, exports)| {
1148            pat == "pages/**/*.tsx" && exports.contains(&"default".to_string())
1149        }));
1150    }
1151
1152    #[test]
1153    fn external_plugin_without_enablers_or_detection_stays_inactive() {
1154        let ext = ExternalPluginDef {
1155            schema: None,
1156            name: "orphan-plugin".to_string(),
1157            detection: None,
1158            enablers: vec![],
1159            entry_points: vec!["orphan/**/*.ts".to_string()],
1160            config_patterns: vec![],
1161            always_used: vec![],
1162            tooling_dependencies: vec![],
1163            used_exports: vec![],
1164        };
1165        let registry = PluginRegistry::new(vec![ext]);
1166        let pkg = make_pkg(&["anything"]);
1167        let result = registry.run(&pkg, Path::new("/project"), &[]);
1168        assert!(!result.active_plugins.contains(&"orphan-plugin".to_string()));
1169    }
1170
1171    // ── Virtual module prefixes ──────────────────────────────────
1172
1173    #[test]
1174    fn nuxt_contributes_virtual_module_prefixes() {
1175        let registry = PluginRegistry::default();
1176        let pkg = make_pkg(&["nuxt"]);
1177        let result = registry.run(&pkg, Path::new("/project"), &[]);
1178        assert!(
1179            result.virtual_module_prefixes.contains(&"#".to_string()),
1180            "nuxt should contribute '#' virtual module prefix"
1181        );
1182    }
1183
1184    // ── Precompile config matchers ───────────────────────────────
1185
1186    #[test]
1187    fn precompile_config_matchers_returns_entries() {
1188        let registry = PluginRegistry::default();
1189        let matchers = registry.precompile_config_matchers();
1190        // At minimum, nextjs, vite, jest, typescript, etc. all have config patterns
1191        assert!(
1192            !matchers.is_empty(),
1193            "precompile_config_matchers should return entries for plugins with config patterns"
1194        );
1195    }
1196
1197    #[test]
1198    fn precompile_config_matchers_only_for_plugins_with_patterns() {
1199        let registry = PluginRegistry::default();
1200        let matchers = registry.precompile_config_matchers();
1201        for (plugin, _) in &matchers {
1202            assert!(
1203                !plugin.config_patterns().is_empty(),
1204                "plugin '{}' in matchers should have config patterns",
1205                plugin.name()
1206            );
1207        }
1208    }
1209}