1use rustc_hash::FxHashSet;
4use std::path::{Path, PathBuf};
5
6use fallow_config::{ExternalPluginDef, PackageJson, PluginDetection};
7
8use super::{Plugin, PluginResult};
9
10use 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
96pub struct PluginRegistry {
98 plugins: Vec<Box<dyn Plugin>>,
99 external_plugins: Vec<ExternalPluginDef>,
100}
101
102#[derive(Debug, Default)]
104pub struct AggregatedPluginResult {
105 pub entry_patterns: Vec<(String, String)>,
107 pub config_patterns: Vec<String>,
109 pub always_used: Vec<(String, String)>,
111 pub used_exports: Vec<(String, Vec<String>)>,
113 pub referenced_dependencies: Vec<String>,
115 pub discovered_always_used: Vec<(String, String)>,
117 pub setup_files: Vec<(PathBuf, String)>,
119 pub tooling_dependencies: Vec<String>,
121 pub script_used_packages: FxHashSet<String>,
123 pub virtual_module_prefixes: Vec<String>,
126 pub path_aliases: Vec<(String, String)>,
129 pub active_plugins: Vec<String>,
131}
132
133impl PluginRegistry {
134 pub fn new(external: Vec<ExternalPluginDef>) -> Self {
136 let plugins: Vec<Box<dyn Plugin>> = vec![
137 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 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 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 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 Box::new(TypeScriptPlugin),
191 Box::new(BabelPlugin),
192 Box::new(SwcPlugin),
193 Box::new(TailwindPlugin),
195 Box::new(PostCssPlugin),
196 Box::new(PrismaPlugin),
198 Box::new(DrizzlePlugin),
199 Box::new(KnexPlugin),
200 Box::new(TypeormPlugin),
201 Box::new(KyselyPlugin),
202 Box::new(TurborepoPlugin),
204 Box::new(NxPlugin),
205 Box::new(ChangesetsPlugin),
206 Box::new(SyncpackPlugin),
207 Box::new(CommitlintPlugin),
209 Box::new(CommitizenPlugin),
210 Box::new(SemanticReleasePlugin),
211 Box::new(WranglerPlugin),
213 Box::new(SentryPlugin),
214 Box::new(HuskyPlugin),
216 Box::new(LintStagedPlugin),
217 Box::new(LefthookPlugin),
218 Box::new(SimpleGitHooksPlugin),
219 Box::new(SvgoPlugin),
221 Box::new(SvgrPlugin),
222 Box::new(GraphqlCodegenPlugin),
224 Box::new(TypedocPlugin),
225 Box::new(OpenapiTsPlugin),
226 Box::new(PlopPlugin),
227 Box::new(C8Plugin),
229 Box::new(NycPlugin),
230 Box::new(MswPlugin),
232 Box::new(NodemonPlugin),
233 Box::new(Pm2Plugin),
234 Box::new(DependencyCruiserPlugin),
235 Box::new(BunPlugin),
237 ];
238 Self {
239 plugins,
240 external_plugins: external,
241 }
242 }
243
244 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 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 for plugin in &active {
278 process_static_patterns(*plugin, root, &mut result);
279 }
280
281 process_external_plugins(
283 &self.external_plugins,
284 &all_deps,
285 root,
286 discovered_files,
287 &mut result,
288 );
289
290 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 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 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 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 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 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 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 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 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 if active.is_empty() {
443 return result;
444 }
445
446 for plugin in &active {
448 process_static_patterns(*plugin, root, &mut result);
449 }
450
451 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 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 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 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; }
510 }
511 } else {
512 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 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 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
588fn 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
621fn 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 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
672fn 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 let abs_path = root.join(pat);
690 if abs_path.is_file() {
691 json_configs.push((abs_path, *plugin));
692 }
693 } else {
694 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
728fn 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
758fn 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
773fn 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 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 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 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 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 #[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 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 let registry = PluginRegistry::default();
880 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 #[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 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 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 #[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 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 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 #[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 #[test]
1187 fn precompile_config_matchers_returns_entries() {
1188 let registry = PluginRegistry::default();
1189 let matchers = registry.precompile_config_matchers();
1190 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}