1use rustc_hash::FxHashSet;
13use std::path::{Path, PathBuf};
14
15use fallow_config::{ExternalPluginDef, PackageJson, PluginDetection};
16
17#[derive(Debug, Default)]
19pub struct PluginResult {
20 pub entry_patterns: Vec<String>,
22 pub referenced_dependencies: Vec<String>,
24 pub always_used_files: Vec<String>,
26 pub setup_files: Vec<PathBuf>,
28}
29
30impl PluginResult {
31 pub const fn is_empty(&self) -> bool {
32 self.entry_patterns.is_empty()
33 && self.referenced_dependencies.is_empty()
34 && self.always_used_files.is_empty()
35 && self.setup_files.is_empty()
36 }
37}
38
39pub trait Plugin: Send + Sync {
41 fn name(&self) -> &'static str;
43
44 fn enablers(&self) -> &'static [&'static str] {
47 &[]
48 }
49
50 fn is_enabled(&self, pkg: &PackageJson, root: &Path) -> bool {
53 let deps = pkg.all_dependency_names();
54 self.is_enabled_with_deps(&deps, root)
55 }
56
57 fn is_enabled_with_deps(&self, deps: &[String], _root: &Path) -> bool {
60 let enablers = self.enablers();
61 if enablers.is_empty() {
62 return false;
63 }
64 enablers.iter().any(|enabler| {
65 if enabler.ends_with('/') {
66 deps.iter().any(|d| d.starts_with(enabler))
68 } else {
69 deps.iter().any(|d| d == enabler)
70 }
71 })
72 }
73
74 fn entry_patterns(&self) -> &'static [&'static str] {
76 &[]
77 }
78
79 fn config_patterns(&self) -> &'static [&'static str] {
81 &[]
82 }
83
84 fn always_used(&self) -> &'static [&'static str] {
86 &[]
87 }
88
89 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
91 vec![]
92 }
93
94 fn tooling_dependencies(&self) -> &'static [&'static str] {
97 &[]
98 }
99
100 fn virtual_module_prefixes(&self) -> &'static [&'static str] {
105 &[]
106 }
107
108 fn path_aliases(&self, _root: &Path) -> Vec<(&'static str, String)> {
118 vec![]
119 }
120
121 fn resolve_config(&self, _config_path: &Path, _source: &str, _root: &Path) -> PluginResult {
126 PluginResult::default()
127 }
128
129 fn package_json_config_key(&self) -> Option<&'static str> {
134 None
135 }
136}
137
138macro_rules! define_plugin {
173 (
174 struct $name:ident => $display:expr,
175 enablers: $enablers:expr
176 $(, entry_patterns: $entry:expr)?
177 $(, config_patterns: $config:expr)?
178 $(, always_used: $always:expr)?
179 $(, tooling_dependencies: $tooling:expr)?
180 $(, virtual_module_prefixes: $virtual:expr)?
181 $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
182 $(,)?
183 ) => {
184 pub struct $name;
185
186 impl Plugin for $name {
187 fn name(&self) -> &'static str {
188 $display
189 }
190
191 fn enablers(&self) -> &'static [&'static str] {
192 $enablers
193 }
194
195 $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
196 $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
197 $( fn always_used(&self) -> &'static [&'static str] { $always } )?
198 $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
199 $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
200
201 $(
202 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
203 vec![$( ($pat, $exports) ),*]
204 }
205 )?
206 }
207 };
208}
209
210pub mod config_parser;
211mod tooling;
212
213pub use tooling::is_known_tooling_dependency;
214
215mod angular;
216mod astro;
217mod ava;
218mod babel;
219mod biome;
220mod bun;
221mod c8;
222mod capacitor;
223mod changesets;
224mod commitizen;
225mod commitlint;
226mod cspell;
227mod cucumber;
228mod cypress;
229mod dependency_cruiser;
230mod docusaurus;
231mod drizzle;
232mod electron;
233mod eslint;
234mod expo;
235mod gatsby;
236mod graphql_codegen;
237mod husky;
238mod i18next;
239mod jest;
240mod karma;
241mod knex;
242mod kysely;
243mod lefthook;
244mod lint_staged;
245mod markdownlint;
246mod mocha;
247mod msw;
248mod nestjs;
249mod next_intl;
250mod nextjs;
251mod nitro;
252mod nodemon;
253mod nuxt;
254mod nx;
255mod nyc;
256mod openapi_ts;
257mod oxlint;
258mod parcel;
259mod playwright;
260mod plop;
261mod pm2;
262mod postcss;
263mod prettier;
264mod prisma;
265mod react_native;
266mod react_router;
267mod relay;
268mod remark;
269mod remix;
270mod rolldown;
271mod rollup;
272mod rsbuild;
273mod rspack;
274mod sanity;
275mod semantic_release;
276mod sentry;
277mod simple_git_hooks;
278mod storybook;
279mod stylelint;
280mod sveltekit;
281mod svgo;
282mod svgr;
283mod swc;
284mod syncpack;
285mod tailwind;
286mod tanstack_router;
287mod tsdown;
288mod tsup;
289mod turborepo;
290mod typedoc;
291mod typeorm;
292mod typescript;
293mod vite;
294mod vitepress;
295mod vitest;
296mod webdriverio;
297mod webpack;
298mod wrangler;
299
300pub struct PluginRegistry {
302 plugins: Vec<Box<dyn Plugin>>,
303 external_plugins: Vec<ExternalPluginDef>,
304}
305
306#[derive(Debug, Default)]
308pub struct AggregatedPluginResult {
309 pub entry_patterns: Vec<(String, String)>,
311 pub config_patterns: Vec<String>,
313 pub always_used: Vec<(String, String)>,
315 pub used_exports: Vec<(String, Vec<String>)>,
317 pub referenced_dependencies: Vec<String>,
319 pub discovered_always_used: Vec<(String, String)>,
321 pub setup_files: Vec<(PathBuf, String)>,
323 pub tooling_dependencies: Vec<String>,
325 pub script_used_packages: FxHashSet<String>,
327 pub virtual_module_prefixes: Vec<String>,
330 pub path_aliases: Vec<(String, String)>,
333 pub active_plugins: Vec<String>,
335}
336
337impl PluginRegistry {
338 pub fn new(external: Vec<ExternalPluginDef>) -> Self {
340 let plugins: Vec<Box<dyn Plugin>> = vec![
341 Box::new(nextjs::NextJsPlugin),
343 Box::new(nuxt::NuxtPlugin),
344 Box::new(remix::RemixPlugin),
345 Box::new(astro::AstroPlugin),
346 Box::new(angular::AngularPlugin),
347 Box::new(react_router::ReactRouterPlugin),
348 Box::new(tanstack_router::TanstackRouterPlugin),
349 Box::new(react_native::ReactNativePlugin),
350 Box::new(expo::ExpoPlugin),
351 Box::new(nestjs::NestJsPlugin),
352 Box::new(docusaurus::DocusaurusPlugin),
353 Box::new(gatsby::GatsbyPlugin),
354 Box::new(sveltekit::SvelteKitPlugin),
355 Box::new(nitro::NitroPlugin),
356 Box::new(capacitor::CapacitorPlugin),
357 Box::new(sanity::SanityPlugin),
358 Box::new(vitepress::VitePressPlugin),
359 Box::new(next_intl::NextIntlPlugin),
360 Box::new(relay::RelayPlugin),
361 Box::new(electron::ElectronPlugin),
362 Box::new(i18next::I18nextPlugin),
363 Box::new(vite::VitePlugin),
365 Box::new(webpack::WebpackPlugin),
366 Box::new(rollup::RollupPlugin),
367 Box::new(rolldown::RolldownPlugin),
368 Box::new(rspack::RspackPlugin),
369 Box::new(rsbuild::RsbuildPlugin),
370 Box::new(tsup::TsupPlugin),
371 Box::new(tsdown::TsdownPlugin),
372 Box::new(parcel::ParcelPlugin),
373 Box::new(vitest::VitestPlugin),
375 Box::new(jest::JestPlugin),
376 Box::new(playwright::PlaywrightPlugin),
377 Box::new(cypress::CypressPlugin),
378 Box::new(mocha::MochaPlugin),
379 Box::new(ava::AvaPlugin),
380 Box::new(storybook::StorybookPlugin),
381 Box::new(karma::KarmaPlugin),
382 Box::new(cucumber::CucumberPlugin),
383 Box::new(webdriverio::WebdriverioPlugin),
384 Box::new(eslint::EslintPlugin),
386 Box::new(biome::BiomePlugin),
387 Box::new(stylelint::StylelintPlugin),
388 Box::new(prettier::PrettierPlugin),
389 Box::new(oxlint::OxlintPlugin),
390 Box::new(markdownlint::MarkdownlintPlugin),
391 Box::new(cspell::CspellPlugin),
392 Box::new(remark::RemarkPlugin),
393 Box::new(typescript::TypeScriptPlugin),
395 Box::new(babel::BabelPlugin),
396 Box::new(swc::SwcPlugin),
397 Box::new(tailwind::TailwindPlugin),
399 Box::new(postcss::PostCssPlugin),
400 Box::new(prisma::PrismaPlugin),
402 Box::new(drizzle::DrizzlePlugin),
403 Box::new(knex::KnexPlugin),
404 Box::new(typeorm::TypeormPlugin),
405 Box::new(kysely::KyselyPlugin),
406 Box::new(turborepo::TurborepoPlugin),
408 Box::new(nx::NxPlugin),
409 Box::new(changesets::ChangesetsPlugin),
410 Box::new(syncpack::SyncpackPlugin),
411 Box::new(commitlint::CommitlintPlugin),
413 Box::new(commitizen::CommitizenPlugin),
414 Box::new(semantic_release::SemanticReleasePlugin),
415 Box::new(wrangler::WranglerPlugin),
417 Box::new(sentry::SentryPlugin),
418 Box::new(husky::HuskyPlugin),
420 Box::new(lint_staged::LintStagedPlugin),
421 Box::new(lefthook::LefthookPlugin),
422 Box::new(simple_git_hooks::SimpleGitHooksPlugin),
423 Box::new(svgo::SvgoPlugin),
425 Box::new(svgr::SvgrPlugin),
426 Box::new(graphql_codegen::GraphqlCodegenPlugin),
428 Box::new(typedoc::TypedocPlugin),
429 Box::new(openapi_ts::OpenapiTsPlugin),
430 Box::new(plop::PlopPlugin),
431 Box::new(c8::C8Plugin),
433 Box::new(nyc::NycPlugin),
434 Box::new(msw::MswPlugin),
436 Box::new(nodemon::NodemonPlugin),
437 Box::new(pm2::Pm2Plugin),
438 Box::new(dependency_cruiser::DependencyCruiserPlugin),
439 Box::new(bun::BunPlugin),
441 ];
442 Self {
443 plugins,
444 external_plugins: external,
445 }
446 }
447
448 #[expect(clippy::cognitive_complexity)] pub fn run(
454 &self,
455 pkg: &PackageJson,
456 root: &Path,
457 discovered_files: &[PathBuf],
458 ) -> AggregatedPluginResult {
459 let _span = tracing::info_span!("run_plugins").entered();
460 let mut result = AggregatedPluginResult::default();
461
462 let all_deps = pkg.all_dependency_names();
465 let active: Vec<&dyn Plugin> = self
466 .plugins
467 .iter()
468 .filter(|p| p.is_enabled_with_deps(&all_deps, root))
469 .map(|p| p.as_ref())
470 .collect();
471
472 tracing::info!(
473 plugins = active
474 .iter()
475 .map(|p| p.name())
476 .collect::<Vec<_>>()
477 .join(", "),
478 "active plugins"
479 );
480
481 for plugin in &active {
483 result.active_plugins.push(plugin.name().to_string());
484
485 let pname = plugin.name().to_string();
486 for pat in plugin.entry_patterns() {
487 result
488 .entry_patterns
489 .push(((*pat).to_string(), pname.clone()));
490 }
491 for pat in plugin.config_patterns() {
492 result.config_patterns.push((*pat).to_string());
493 }
494 for pat in plugin.always_used() {
495 result.always_used.push(((*pat).to_string(), pname.clone()));
496 }
497 for (file_pat, exports) in plugin.used_exports() {
498 result.used_exports.push((
499 file_pat.to_string(),
500 exports.iter().map(|s| s.to_string()).collect(),
501 ));
502 }
503 for dep in plugin.tooling_dependencies() {
504 result.tooling_dependencies.push((*dep).to_string());
505 }
506 for prefix in plugin.virtual_module_prefixes() {
507 result.virtual_module_prefixes.push((*prefix).to_string());
508 }
509 for (prefix, replacement) in plugin.path_aliases(root) {
510 result.path_aliases.push((prefix.to_string(), replacement));
511 }
512 }
513
514 let all_dep_refs: Vec<&str> = all_deps.iter().map(|s| s.as_str()).collect();
517 for ext in &self.external_plugins {
518 let is_active = if let Some(detection) = &ext.detection {
519 check_plugin_detection(detection, &all_dep_refs, root, discovered_files)
520 } else if !ext.enablers.is_empty() {
521 ext.enablers.iter().any(|enabler| {
522 if enabler.ends_with('/') {
523 all_deps.iter().any(|d| d.starts_with(enabler))
524 } else {
525 all_deps.iter().any(|d| d == enabler)
526 }
527 })
528 } else {
529 false
530 };
531 if is_active {
532 result.active_plugins.push(ext.name.clone());
533 result.entry_patterns.extend(
534 ext.entry_points
535 .iter()
536 .map(|p| (p.clone(), ext.name.clone())),
537 );
538 result.config_patterns.extend(ext.config_patterns.clone());
541 result.always_used.extend(
542 ext.config_patterns
543 .iter()
544 .chain(ext.always_used.iter())
545 .map(|p| (p.clone(), ext.name.clone())),
546 );
547 result
548 .tooling_dependencies
549 .extend(ext.tooling_dependencies.clone());
550 for ue in &ext.used_exports {
551 result
552 .used_exports
553 .push((ue.pattern.clone(), ue.exports.clone()));
554 }
555 }
556 }
557
558 let config_matchers: Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> = active
561 .iter()
562 .filter(|p| !p.config_patterns().is_empty())
563 .map(|p| {
564 let matchers: Vec<globset::GlobMatcher> = p
565 .config_patterns()
566 .iter()
567 .filter_map(|pat| globset::Glob::new(pat).ok().map(|g| g.compile_matcher()))
568 .collect();
569 (*p, matchers)
570 })
571 .collect();
572
573 let relative_files: Vec<(&PathBuf, String)> = discovered_files
575 .iter()
576 .map(|f| {
577 let rel = f
578 .strip_prefix(root)
579 .unwrap_or(f)
580 .to_string_lossy()
581 .into_owned();
582 (f, rel)
583 })
584 .collect();
585
586 if !config_matchers.is_empty() {
587 let mut resolved_plugins: FxHashSet<&str> = FxHashSet::default();
589
590 for (plugin, matchers) in &config_matchers {
591 for (abs_path, rel_path) in &relative_files {
592 if matchers.iter().any(|m| m.is_match(rel_path.as_str())) {
593 resolved_plugins.insert(plugin.name());
597 if let Ok(source) = std::fs::read_to_string(abs_path) {
598 let plugin_result = plugin.resolve_config(abs_path, &source, root);
599 if !plugin_result.is_empty() {
600 tracing::debug!(
601 plugin = plugin.name(),
602 config = rel_path.as_str(),
603 entries = plugin_result.entry_patterns.len(),
604 deps = plugin_result.referenced_dependencies.len(),
605 "resolved config"
606 );
607 let pname = plugin.name().to_string();
608 result.entry_patterns.extend(
609 plugin_result
610 .entry_patterns
611 .into_iter()
612 .map(|p| (p, pname.clone())),
613 );
614 result
615 .referenced_dependencies
616 .extend(plugin_result.referenced_dependencies);
617 result.discovered_always_used.extend(
618 plugin_result
619 .always_used_files
620 .into_iter()
621 .map(|p| (p, pname.clone())),
622 );
623 result.setup_files.extend(
624 plugin_result
625 .setup_files
626 .into_iter()
627 .map(|p| (p, pname.clone())),
628 );
629 }
630 }
631 }
632 }
633 }
634
635 let mut json_configs: Vec<(PathBuf, &dyn Plugin)> = Vec::new();
643 for (plugin, _) in &config_matchers {
644 if resolved_plugins.contains(plugin.name()) {
645 continue;
646 }
647 for pat in plugin.config_patterns() {
648 let has_glob = pat.contains("**") || pat.contains('*') || pat.contains('?');
649 if !has_glob {
650 let abs_path = root.join(pat);
652 if abs_path.is_file() {
653 json_configs.push((abs_path, *plugin));
654 }
655 } else {
656 let filename = std::path::Path::new(pat)
659 .file_name()
660 .and_then(|n| n.to_str())
661 .unwrap_or(pat);
662 let matcher = globset::Glob::new(pat).ok().map(|g| g.compile_matcher());
663 if let Some(matcher) = matcher {
664 let mut checked_dirs: FxHashSet<&Path> = FxHashSet::default();
665 checked_dirs.insert(root);
666 for (abs_path, _) in &relative_files {
667 if let Some(parent) = abs_path.parent() {
668 checked_dirs.insert(parent);
669 }
670 }
671 for dir in checked_dirs {
672 let candidate = dir.join(filename);
673 if candidate.is_file() {
674 let rel = candidate
675 .strip_prefix(root)
676 .map(|p| p.to_string_lossy())
677 .unwrap_or_default();
678 if matcher.is_match(rel.as_ref()) {
679 json_configs.push((candidate, *plugin));
680 }
681 }
682 }
683 }
684 }
685 }
686 }
687 for (abs_path, plugin) in &json_configs {
689 if let Ok(source) = std::fs::read_to_string(abs_path) {
690 let plugin_result = plugin.resolve_config(abs_path, &source, root);
691 if !plugin_result.is_empty() {
692 let rel = abs_path
693 .strip_prefix(root)
694 .map(|p| p.to_string_lossy())
695 .unwrap_or_default();
696 tracing::debug!(
697 plugin = plugin.name(),
698 config = %rel,
699 entries = plugin_result.entry_patterns.len(),
700 deps = plugin_result.referenced_dependencies.len(),
701 "resolved config (filesystem fallback)"
702 );
703 let pname = plugin.name().to_string();
704 result.entry_patterns.extend(
705 plugin_result
706 .entry_patterns
707 .into_iter()
708 .map(|p| (p, pname.clone())),
709 );
710 result
711 .referenced_dependencies
712 .extend(plugin_result.referenced_dependencies);
713 result.discovered_always_used.extend(
714 plugin_result
715 .always_used_files
716 .into_iter()
717 .map(|p| (p, pname.clone())),
718 );
719 result.setup_files.extend(
720 plugin_result
721 .setup_files
722 .into_iter()
723 .map(|p| (p, pname.clone())),
724 );
725 }
726 }
727 }
728 }
729
730 for plugin in &active {
734 if let Some(key) = plugin.package_json_config_key() {
735 let has_config_file = !plugin.config_patterns().is_empty()
737 && config_matchers.iter().any(|(p, matchers)| {
738 p.name() == plugin.name()
739 && relative_files
740 .iter()
741 .any(|(_, rel)| matchers.iter().any(|m| m.is_match(rel.as_str())))
742 });
743 if !has_config_file {
744 let pkg_path = root.join("package.json");
746 if let Ok(content) = std::fs::read_to_string(&pkg_path)
747 && let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
748 && let Some(config_value) = json.get(key)
749 {
750 let config_json = serde_json::to_string(config_value).unwrap_or_default();
751 let fake_path = root.join(format!("{key}.config.json"));
752 let plugin_result = plugin.resolve_config(&fake_path, &config_json, root);
753 if !plugin_result.is_empty() {
754 let pname = plugin.name().to_string();
755 tracing::debug!(
756 plugin = pname.as_str(),
757 key = key,
758 "resolved inline package.json config"
759 );
760 result.entry_patterns.extend(
761 plugin_result
762 .entry_patterns
763 .into_iter()
764 .map(|p| (p, pname.clone())),
765 );
766 result
767 .referenced_dependencies
768 .extend(plugin_result.referenced_dependencies);
769 result.discovered_always_used.extend(
770 plugin_result
771 .always_used_files
772 .into_iter()
773 .map(|p| (p, pname.clone())),
774 );
775 result.setup_files.extend(
776 plugin_result
777 .setup_files
778 .into_iter()
779 .map(|p| (p, pname.clone())),
780 );
781 }
782 }
783 }
784 }
785 }
786
787 result
788 }
789
790 pub fn run_workspace_fast(
797 &self,
798 pkg: &PackageJson,
799 root: &Path,
800 project_root: &Path,
801 precompiled_config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
802 relative_files: &[(&PathBuf, String)],
803 ) -> AggregatedPluginResult {
804 let _span = tracing::info_span!("run_plugins").entered();
805 let mut result = AggregatedPluginResult::default();
806
807 let all_deps = pkg.all_dependency_names();
809 let active: Vec<&dyn Plugin> = self
810 .plugins
811 .iter()
812 .filter(|p| p.is_enabled_with_deps(&all_deps, root))
813 .map(|p| p.as_ref())
814 .collect();
815
816 tracing::info!(
817 plugins = active
818 .iter()
819 .map(|p| p.name())
820 .collect::<Vec<_>>()
821 .join(", "),
822 "active plugins"
823 );
824
825 if active.is_empty() {
827 return result;
828 }
829
830 for plugin in &active {
832 result.active_plugins.push(plugin.name().to_string());
833
834 let pname = plugin.name().to_string();
835 for pat in plugin.entry_patterns() {
836 result
837 .entry_patterns
838 .push(((*pat).to_string(), pname.clone()));
839 }
840 for pat in plugin.config_patterns() {
841 result.config_patterns.push((*pat).to_string());
842 }
843 for pat in plugin.always_used() {
844 result.always_used.push(((*pat).to_string(), pname.clone()));
845 }
846 for (file_pat, exports) in plugin.used_exports() {
847 result.used_exports.push((
848 file_pat.to_string(),
849 exports.iter().map(|s| s.to_string()).collect(),
850 ));
851 }
852 for dep in plugin.tooling_dependencies() {
853 result.tooling_dependencies.push((*dep).to_string());
854 }
855 for prefix in plugin.virtual_module_prefixes() {
856 result.virtual_module_prefixes.push((*prefix).to_string());
857 }
858 for (prefix, replacement) in plugin.path_aliases(root) {
859 result.path_aliases.push((prefix.to_string(), replacement));
860 }
861 }
862
863 let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
866 let workspace_matchers: Vec<_> = precompiled_config_matchers
867 .iter()
868 .filter(|(p, _)| active_names.contains(p.name()))
869 .collect();
870
871 let mut resolved_ws_plugins: FxHashSet<&str> = FxHashSet::default();
872 if !workspace_matchers.is_empty() {
873 for (plugin, matchers) in &workspace_matchers {
874 for (abs_path, rel_path) in relative_files {
875 if matchers.iter().any(|m| m.is_match(rel_path.as_str()))
876 && let Ok(source) = std::fs::read_to_string(abs_path)
877 {
878 resolved_ws_plugins.insert(plugin.name());
881 let plugin_result = plugin.resolve_config(abs_path, &source, root);
882 if !plugin_result.is_empty() {
883 let pname = plugin.name().to_string();
884 tracing::debug!(
885 plugin = pname.as_str(),
886 config = rel_path.as_str(),
887 entries = plugin_result.entry_patterns.len(),
888 deps = plugin_result.referenced_dependencies.len(),
889 "resolved config"
890 );
891 result.entry_patterns.extend(
892 plugin_result
893 .entry_patterns
894 .into_iter()
895 .map(|p| (p, pname.clone())),
896 );
897 result
898 .referenced_dependencies
899 .extend(plugin_result.referenced_dependencies);
900 result.discovered_always_used.extend(
901 plugin_result
902 .always_used_files
903 .into_iter()
904 .map(|p| (p, pname.clone())),
905 );
906 result.setup_files.extend(
907 plugin_result
908 .setup_files
909 .into_iter()
910 .map(|p| (p, pname.clone())),
911 );
912 }
913 }
914 }
915 }
916 }
917
918 let mut ws_json_configs: Vec<(PathBuf, &dyn Plugin)> = Vec::new();
923 let mut ws_seen_paths: FxHashSet<PathBuf> = FxHashSet::default();
924 for plugin in &active {
925 if resolved_ws_plugins.contains(plugin.name()) || plugin.config_patterns().is_empty() {
926 continue;
927 }
928 for pat in plugin.config_patterns() {
929 let has_glob = pat.contains("**") || pat.contains('*') || pat.contains('?');
930 if !has_glob {
931 let check_roots: Vec<&Path> = if root == project_root {
933 vec![root]
934 } else {
935 vec![root, project_root]
936 };
937 for check_root in check_roots {
938 let abs_path = check_root.join(pat);
939 if abs_path.is_file() && ws_seen_paths.insert(abs_path.clone()) {
940 ws_json_configs.push((abs_path, *plugin));
941 break; }
943 }
944 } else {
945 let filename = std::path::Path::new(pat)
948 .file_name()
949 .and_then(|n| n.to_str())
950 .unwrap_or(pat);
951 let matcher = globset::Glob::new(pat).ok().map(|g| g.compile_matcher());
952 if let Some(matcher) = matcher {
953 let mut checked_dirs: FxHashSet<&Path> = FxHashSet::default();
954 checked_dirs.insert(root);
955 if root != project_root {
956 checked_dirs.insert(project_root);
957 }
958 for (abs_path, _) in relative_files {
959 if let Some(parent) = abs_path.parent() {
960 checked_dirs.insert(parent);
961 }
962 }
963 for dir in checked_dirs {
964 let candidate = dir.join(filename);
965 if candidate.is_file() && ws_seen_paths.insert(candidate.clone()) {
966 let rel = candidate
967 .strip_prefix(project_root)
968 .map(|p| p.to_string_lossy())
969 .unwrap_or_default();
970 if matcher.is_match(rel.as_ref()) {
971 ws_json_configs.push((candidate, *plugin));
972 }
973 }
974 }
975 }
976 }
977 }
978 }
979 for (abs_path, plugin) in &ws_json_configs {
981 if let Ok(source) = std::fs::read_to_string(abs_path) {
982 let plugin_result = plugin.resolve_config(abs_path, &source, root);
983 if !plugin_result.is_empty() {
984 let rel = abs_path
985 .strip_prefix(project_root)
986 .map(|p| p.to_string_lossy())
987 .unwrap_or_default();
988 tracing::debug!(
989 plugin = plugin.name(),
990 config = %rel,
991 entries = plugin_result.entry_patterns.len(),
992 deps = plugin_result.referenced_dependencies.len(),
993 "resolved config (workspace filesystem fallback)"
994 );
995 let pname = plugin.name().to_string();
996 result.entry_patterns.extend(
997 plugin_result
998 .entry_patterns
999 .into_iter()
1000 .map(|p| (p, pname.clone())),
1001 );
1002 result
1003 .referenced_dependencies
1004 .extend(plugin_result.referenced_dependencies);
1005 result.discovered_always_used.extend(
1006 plugin_result
1007 .always_used_files
1008 .into_iter()
1009 .map(|p| (p, pname.clone())),
1010 );
1011 result.setup_files.extend(
1012 plugin_result
1013 .setup_files
1014 .into_iter()
1015 .map(|p| (p, pname.clone())),
1016 );
1017 }
1018 }
1019 }
1020
1021 result
1022 }
1023
1024 pub fn precompile_config_matchers(&self) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
1027 self.plugins
1028 .iter()
1029 .filter(|p| !p.config_patterns().is_empty())
1030 .map(|p| {
1031 let matchers: Vec<globset::GlobMatcher> = p
1032 .config_patterns()
1033 .iter()
1034 .filter_map(|pat| globset::Glob::new(pat).ok().map(|g| g.compile_matcher()))
1035 .collect();
1036 (p.as_ref(), matchers)
1037 })
1038 .collect()
1039 }
1040}
1041
1042fn check_plugin_detection(
1044 detection: &PluginDetection,
1045 all_deps: &[&str],
1046 root: &Path,
1047 discovered_files: &[PathBuf],
1048) -> bool {
1049 match detection {
1050 PluginDetection::Dependency { package } => all_deps.iter().any(|d| *d == package),
1051 PluginDetection::FileExists { pattern } => {
1052 if let Ok(matcher) = globset::Glob::new(pattern).map(|g| g.compile_matcher()) {
1054 for file in discovered_files {
1055 let relative = file.strip_prefix(root).unwrap_or(file);
1056 if matcher.is_match(relative) {
1057 return true;
1058 }
1059 }
1060 }
1061 let full_pattern = root.join(pattern).to_string_lossy().to_string();
1063 glob::glob(&full_pattern)
1064 .ok()
1065 .is_some_and(|mut g| g.next().is_some())
1066 }
1067 PluginDetection::All { conditions } => conditions
1068 .iter()
1069 .all(|c| check_plugin_detection(c, all_deps, root, discovered_files)),
1070 PluginDetection::Any { conditions } => conditions
1071 .iter()
1072 .any(|c| check_plugin_detection(c, all_deps, root, discovered_files)),
1073 }
1074}
1075
1076impl Default for PluginRegistry {
1077 fn default() -> Self {
1078 Self::new(vec![])
1079 }
1080}
1081
1082#[cfg(test)]
1083#[expect(clippy::disallowed_types)]
1084mod tests {
1085 use super::*;
1086 use fallow_config::{ExternalPluginDef, ExternalUsedExport, PluginDetection};
1087 use std::collections::HashMap;
1088
1089 fn make_pkg(deps: &[&str]) -> PackageJson {
1091 let map: HashMap<String, String> =
1092 deps.iter().map(|d| (d.to_string(), "*".into())).collect();
1093 PackageJson {
1094 dependencies: Some(map),
1095 ..Default::default()
1096 }
1097 }
1098
1099 fn make_pkg_dev(deps: &[&str]) -> PackageJson {
1101 let map: HashMap<String, String> =
1102 deps.iter().map(|d| (d.to_string(), "*".into())).collect();
1103 PackageJson {
1104 dev_dependencies: Some(map),
1105 ..Default::default()
1106 }
1107 }
1108
1109 #[test]
1112 fn nextjs_detected_when_next_in_deps() {
1113 let registry = PluginRegistry::default();
1114 let pkg = make_pkg(&["next", "react"]);
1115 let result = registry.run(&pkg, Path::new("/project"), &[]);
1116 assert!(
1117 result.active_plugins.contains(&"nextjs".to_string()),
1118 "nextjs plugin should be active when 'next' is in deps"
1119 );
1120 }
1121
1122 #[test]
1123 fn nextjs_not_detected_without_next() {
1124 let registry = PluginRegistry::default();
1125 let pkg = make_pkg(&["react", "react-dom"]);
1126 let result = registry.run(&pkg, Path::new("/project"), &[]);
1127 assert!(
1128 !result.active_plugins.contains(&"nextjs".to_string()),
1129 "nextjs plugin should not be active without 'next' in deps"
1130 );
1131 }
1132
1133 #[test]
1134 fn prefix_enabler_matches_scoped_packages() {
1135 let registry = PluginRegistry::default();
1137 let pkg = make_pkg(&["@storybook/react"]);
1138 let result = registry.run(&pkg, Path::new("/project"), &[]);
1139 assert!(
1140 result.active_plugins.contains(&"storybook".to_string()),
1141 "storybook should activate via prefix match on @storybook/react"
1142 );
1143 }
1144
1145 #[test]
1146 fn prefix_enabler_does_not_match_without_slash() {
1147 let registry = PluginRegistry::default();
1149 let mut map = HashMap::new();
1151 map.insert("@storybookish".to_string(), "*".to_string());
1152 let pkg = PackageJson {
1153 dependencies: Some(map),
1154 ..Default::default()
1155 };
1156 let result = registry.run(&pkg, Path::new("/project"), &[]);
1157 assert!(
1158 !result.active_plugins.contains(&"storybook".to_string()),
1159 "storybook should not activate for '@storybookish' (no slash prefix match)"
1160 );
1161 }
1162
1163 #[test]
1164 fn multiple_plugins_detected_simultaneously() {
1165 let registry = PluginRegistry::default();
1166 let pkg = make_pkg(&["next", "vitest", "typescript"]);
1167 let result = registry.run(&pkg, Path::new("/project"), &[]);
1168 assert!(result.active_plugins.contains(&"nextjs".to_string()));
1169 assert!(result.active_plugins.contains(&"vitest".to_string()));
1170 assert!(result.active_plugins.contains(&"typescript".to_string()));
1171 }
1172
1173 #[test]
1174 fn no_plugins_for_empty_deps() {
1175 let registry = PluginRegistry::default();
1176 let pkg = PackageJson::default();
1177 let result = registry.run(&pkg, Path::new("/project"), &[]);
1178 assert!(
1179 result.active_plugins.is_empty(),
1180 "no plugins should activate with empty package.json"
1181 );
1182 }
1183
1184 #[test]
1187 fn active_plugin_contributes_entry_patterns() {
1188 let registry = PluginRegistry::default();
1189 let pkg = make_pkg(&["next"]);
1190 let result = registry.run(&pkg, Path::new("/project"), &[]);
1191 assert!(
1193 result
1194 .entry_patterns
1195 .iter()
1196 .any(|(p, _)| p.contains("app/**/page")),
1197 "nextjs plugin should add app/**/page entry pattern"
1198 );
1199 }
1200
1201 #[test]
1202 fn inactive_plugin_does_not_contribute_entry_patterns() {
1203 let registry = PluginRegistry::default();
1204 let pkg = make_pkg(&["react"]);
1205 let result = registry.run(&pkg, Path::new("/project"), &[]);
1206 assert!(
1208 !result
1209 .entry_patterns
1210 .iter()
1211 .any(|(p, _)| p.contains("app/**/page")),
1212 "nextjs patterns should not appear when plugin is inactive"
1213 );
1214 }
1215
1216 #[test]
1217 fn active_plugin_contributes_tooling_deps() {
1218 let registry = PluginRegistry::default();
1219 let pkg = make_pkg(&["next"]);
1220 let result = registry.run(&pkg, Path::new("/project"), &[]);
1221 assert!(
1222 result.tooling_dependencies.contains(&"next".to_string()),
1223 "nextjs plugin should list 'next' as a tooling dependency"
1224 );
1225 }
1226
1227 #[test]
1228 fn dev_deps_also_trigger_plugins() {
1229 let registry = PluginRegistry::default();
1230 let pkg = make_pkg_dev(&["vitest"]);
1231 let result = registry.run(&pkg, Path::new("/project"), &[]);
1232 assert!(
1233 result.active_plugins.contains(&"vitest".to_string()),
1234 "vitest should activate from devDependencies"
1235 );
1236 }
1237
1238 #[test]
1241 fn external_plugin_detected_by_enablers() {
1242 let ext = ExternalPluginDef {
1243 schema: None,
1244 name: "my-framework".to_string(),
1245 detection: None,
1246 enablers: vec!["my-framework".to_string()],
1247 entry_points: vec!["src/routes/**/*.ts".to_string()],
1248 config_patterns: vec![],
1249 always_used: vec!["my.config.ts".to_string()],
1250 tooling_dependencies: vec!["my-framework-cli".to_string()],
1251 used_exports: vec![],
1252 };
1253 let registry = PluginRegistry::new(vec![ext]);
1254 let pkg = make_pkg(&["my-framework"]);
1255 let result = registry.run(&pkg, Path::new("/project"), &[]);
1256 assert!(result.active_plugins.contains(&"my-framework".to_string()));
1257 assert!(
1258 result
1259 .entry_patterns
1260 .iter()
1261 .any(|(p, _)| p == "src/routes/**/*.ts")
1262 );
1263 assert!(
1264 result
1265 .tooling_dependencies
1266 .contains(&"my-framework-cli".to_string())
1267 );
1268 }
1269
1270 #[test]
1271 fn external_plugin_not_detected_when_dep_missing() {
1272 let ext = ExternalPluginDef {
1273 schema: None,
1274 name: "my-framework".to_string(),
1275 detection: None,
1276 enablers: vec!["my-framework".to_string()],
1277 entry_points: vec!["src/routes/**/*.ts".to_string()],
1278 config_patterns: vec![],
1279 always_used: vec![],
1280 tooling_dependencies: vec![],
1281 used_exports: vec![],
1282 };
1283 let registry = PluginRegistry::new(vec![ext]);
1284 let pkg = make_pkg(&["react"]);
1285 let result = registry.run(&pkg, Path::new("/project"), &[]);
1286 assert!(!result.active_plugins.contains(&"my-framework".to_string()));
1287 assert!(
1288 !result
1289 .entry_patterns
1290 .iter()
1291 .any(|(p, _)| p == "src/routes/**/*.ts")
1292 );
1293 }
1294
1295 #[test]
1296 fn external_plugin_prefix_enabler() {
1297 let ext = ExternalPluginDef {
1298 schema: None,
1299 name: "custom-plugin".to_string(),
1300 detection: None,
1301 enablers: vec!["@custom/".to_string()],
1302 entry_points: vec!["custom/**/*.ts".to_string()],
1303 config_patterns: vec![],
1304 always_used: vec![],
1305 tooling_dependencies: vec![],
1306 used_exports: vec![],
1307 };
1308 let registry = PluginRegistry::new(vec![ext]);
1309 let pkg = make_pkg(&["@custom/core"]);
1310 let result = registry.run(&pkg, Path::new("/project"), &[]);
1311 assert!(result.active_plugins.contains(&"custom-plugin".to_string()));
1312 }
1313
1314 #[test]
1315 fn external_plugin_detection_dependency() {
1316 let ext = ExternalPluginDef {
1317 schema: None,
1318 name: "detected-plugin".to_string(),
1319 detection: Some(PluginDetection::Dependency {
1320 package: "special-dep".to_string(),
1321 }),
1322 enablers: vec![],
1323 entry_points: vec!["special/**/*.ts".to_string()],
1324 config_patterns: vec![],
1325 always_used: vec![],
1326 tooling_dependencies: vec![],
1327 used_exports: vec![],
1328 };
1329 let registry = PluginRegistry::new(vec![ext]);
1330 let pkg = make_pkg(&["special-dep"]);
1331 let result = registry.run(&pkg, Path::new("/project"), &[]);
1332 assert!(
1333 result
1334 .active_plugins
1335 .contains(&"detected-plugin".to_string())
1336 );
1337 }
1338
1339 #[test]
1340 fn external_plugin_detection_any_combinator() {
1341 let ext = ExternalPluginDef {
1342 schema: None,
1343 name: "any-plugin".to_string(),
1344 detection: Some(PluginDetection::Any {
1345 conditions: vec![
1346 PluginDetection::Dependency {
1347 package: "pkg-a".to_string(),
1348 },
1349 PluginDetection::Dependency {
1350 package: "pkg-b".to_string(),
1351 },
1352 ],
1353 }),
1354 enablers: vec![],
1355 entry_points: vec!["any/**/*.ts".to_string()],
1356 config_patterns: vec![],
1357 always_used: vec![],
1358 tooling_dependencies: vec![],
1359 used_exports: vec![],
1360 };
1361 let registry = PluginRegistry::new(vec![ext]);
1362 let pkg = make_pkg(&["pkg-b"]);
1364 let result = registry.run(&pkg, Path::new("/project"), &[]);
1365 assert!(result.active_plugins.contains(&"any-plugin".to_string()));
1366 }
1367
1368 #[test]
1369 fn external_plugin_detection_all_combinator_fails_partial() {
1370 let ext = ExternalPluginDef {
1371 schema: None,
1372 name: "all-plugin".to_string(),
1373 detection: Some(PluginDetection::All {
1374 conditions: vec![
1375 PluginDetection::Dependency {
1376 package: "pkg-a".to_string(),
1377 },
1378 PluginDetection::Dependency {
1379 package: "pkg-b".to_string(),
1380 },
1381 ],
1382 }),
1383 enablers: vec![],
1384 entry_points: vec![],
1385 config_patterns: vec![],
1386 always_used: vec![],
1387 tooling_dependencies: vec![],
1388 used_exports: vec![],
1389 };
1390 let registry = PluginRegistry::new(vec![ext]);
1391 let pkg = make_pkg(&["pkg-a"]);
1393 let result = registry.run(&pkg, Path::new("/project"), &[]);
1394 assert!(!result.active_plugins.contains(&"all-plugin".to_string()));
1395 }
1396
1397 #[test]
1398 fn external_plugin_used_exports_aggregated() {
1399 let ext = ExternalPluginDef {
1400 schema: None,
1401 name: "ue-plugin".to_string(),
1402 detection: None,
1403 enablers: vec!["ue-dep".to_string()],
1404 entry_points: vec![],
1405 config_patterns: vec![],
1406 always_used: vec![],
1407 tooling_dependencies: vec![],
1408 used_exports: vec![ExternalUsedExport {
1409 pattern: "pages/**/*.tsx".to_string(),
1410 exports: vec!["default".to_string(), "getServerSideProps".to_string()],
1411 }],
1412 };
1413 let registry = PluginRegistry::new(vec![ext]);
1414 let pkg = make_pkg(&["ue-dep"]);
1415 let result = registry.run(&pkg, Path::new("/project"), &[]);
1416 assert!(result.used_exports.iter().any(|(pat, exports)| {
1417 pat == "pages/**/*.tsx" && exports.contains(&"default".to_string())
1418 }));
1419 }
1420
1421 #[test]
1422 fn external_plugin_without_enablers_or_detection_stays_inactive() {
1423 let ext = ExternalPluginDef {
1424 schema: None,
1425 name: "orphan-plugin".to_string(),
1426 detection: None,
1427 enablers: vec![],
1428 entry_points: vec!["orphan/**/*.ts".to_string()],
1429 config_patterns: vec![],
1430 always_used: vec![],
1431 tooling_dependencies: vec![],
1432 used_exports: vec![],
1433 };
1434 let registry = PluginRegistry::new(vec![ext]);
1435 let pkg = make_pkg(&["anything"]);
1436 let result = registry.run(&pkg, Path::new("/project"), &[]);
1437 assert!(!result.active_plugins.contains(&"orphan-plugin".to_string()));
1438 }
1439
1440 #[test]
1443 fn is_enabled_with_deps_exact_match() {
1444 let plugin = nextjs::NextJsPlugin;
1445 let deps = vec!["next".to_string()];
1446 assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1447 }
1448
1449 #[test]
1450 fn is_enabled_with_deps_no_match() {
1451 let plugin = nextjs::NextJsPlugin;
1452 let deps = vec!["react".to_string()];
1453 assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1454 }
1455
1456 #[test]
1457 fn is_enabled_with_empty_deps() {
1458 let plugin = nextjs::NextJsPlugin;
1459 let deps: Vec<String> = vec![];
1460 assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1461 }
1462
1463 #[test]
1466 fn nuxt_contributes_virtual_module_prefixes() {
1467 let registry = PluginRegistry::default();
1468 let pkg = make_pkg(&["nuxt"]);
1469 let result = registry.run(&pkg, Path::new("/project"), &[]);
1470 assert!(
1471 result.virtual_module_prefixes.contains(&"#".to_string()),
1472 "nuxt should contribute '#' virtual module prefix"
1473 );
1474 }
1475
1476 #[test]
1479 fn plugin_result_is_empty_when_default() {
1480 let r = PluginResult::default();
1481 assert!(r.is_empty());
1482 }
1483
1484 #[test]
1485 fn plugin_result_not_empty_with_entry_patterns() {
1486 let r = PluginResult {
1487 entry_patterns: vec!["*.ts".to_string()],
1488 ..Default::default()
1489 };
1490 assert!(!r.is_empty());
1491 }
1492
1493 #[test]
1494 fn plugin_result_not_empty_with_referenced_deps() {
1495 let r = PluginResult {
1496 referenced_dependencies: vec!["lodash".to_string()],
1497 ..Default::default()
1498 };
1499 assert!(!r.is_empty());
1500 }
1501
1502 #[test]
1503 fn plugin_result_not_empty_with_setup_files() {
1504 let r = PluginResult {
1505 setup_files: vec![PathBuf::from("/setup.ts")],
1506 ..Default::default()
1507 };
1508 assert!(!r.is_empty());
1509 }
1510
1511 #[test]
1514 fn precompile_config_matchers_returns_entries() {
1515 let registry = PluginRegistry::default();
1516 let matchers = registry.precompile_config_matchers();
1517 assert!(
1519 !matchers.is_empty(),
1520 "precompile_config_matchers should return entries for plugins with config patterns"
1521 );
1522 }
1523
1524 #[test]
1525 fn precompile_config_matchers_only_for_plugins_with_patterns() {
1526 let registry = PluginRegistry::default();
1527 let matchers = registry.precompile_config_matchers();
1528 for (plugin, _) in &matchers {
1529 assert!(
1530 !plugin.config_patterns().is_empty(),
1531 "plugin '{}' in matchers should have config patterns",
1532 plugin.name()
1533 );
1534 }
1535 }
1536}