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>,
311 pub config_patterns: Vec<String>,
313 pub always_used: Vec<String>,
315 pub used_exports: Vec<(String, Vec<String>)>,
317 pub referenced_dependencies: Vec<String>,
319 pub discovered_always_used: Vec<String>,
321 pub setup_files: Vec<PathBuf>,
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 #[allow(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 for pat in plugin.entry_patterns() {
486 result.entry_patterns.push((*pat).to_string());
487 }
488 for pat in plugin.config_patterns() {
489 result.config_patterns.push((*pat).to_string());
490 }
491 for pat in plugin.always_used() {
492 result.always_used.push((*pat).to_string());
493 }
494 for (file_pat, exports) in plugin.used_exports() {
495 result.used_exports.push((
496 file_pat.to_string(),
497 exports.iter().map(|s| s.to_string()).collect(),
498 ));
499 }
500 for dep in plugin.tooling_dependencies() {
501 result.tooling_dependencies.push((*dep).to_string());
502 }
503 for prefix in plugin.virtual_module_prefixes() {
504 result.virtual_module_prefixes.push((*prefix).to_string());
505 }
506 for (prefix, replacement) in plugin.path_aliases(root) {
507 result.path_aliases.push((prefix.to_string(), replacement));
508 }
509 }
510
511 let all_dep_refs: Vec<&str> = all_deps.iter().map(|s| s.as_str()).collect();
514 for ext in &self.external_plugins {
515 #[allow(clippy::option_if_let_else)] let is_active = if let Some(detection) = &ext.detection {
517 check_plugin_detection(detection, &all_dep_refs, root, discovered_files)
518 } else if !ext.enablers.is_empty() {
519 ext.enablers.iter().any(|enabler| {
520 if enabler.ends_with('/') {
521 all_deps.iter().any(|d| d.starts_with(enabler))
522 } else {
523 all_deps.iter().any(|d| d == enabler)
524 }
525 })
526 } else {
527 false
528 };
529 if is_active {
530 result.active_plugins.push(ext.name.clone());
531 result.entry_patterns.extend(ext.entry_points.clone());
532 result.config_patterns.extend(ext.config_patterns.clone());
535 result.always_used.extend(ext.config_patterns.clone());
536 result.always_used.extend(ext.always_used.clone());
537 result
538 .tooling_dependencies
539 .extend(ext.tooling_dependencies.clone());
540 for ue in &ext.used_exports {
541 result
542 .used_exports
543 .push((ue.pattern.clone(), ue.exports.clone()));
544 }
545 }
546 }
547
548 let config_matchers: Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> = active
551 .iter()
552 .filter(|p| !p.config_patterns().is_empty())
553 .map(|p| {
554 let matchers: Vec<globset::GlobMatcher> = p
555 .config_patterns()
556 .iter()
557 .filter_map(|pat| globset::Glob::new(pat).ok().map(|g| g.compile_matcher()))
558 .collect();
559 (*p, matchers)
560 })
561 .collect();
562
563 let relative_files: Vec<(&PathBuf, String)> = discovered_files
565 .iter()
566 .map(|f| {
567 let rel = f
568 .strip_prefix(root)
569 .unwrap_or(f)
570 .to_string_lossy()
571 .into_owned();
572 (f, rel)
573 })
574 .collect();
575
576 if !config_matchers.is_empty() {
577 for (plugin, matchers) in &config_matchers {
578 for (abs_path, rel_path) in &relative_files {
579 if matchers.iter().any(|m| m.is_match(rel_path.as_str())) {
580 if let Ok(source) = std::fs::read_to_string(abs_path) {
582 let plugin_result = plugin.resolve_config(abs_path, &source, root);
583 if !plugin_result.is_empty() {
584 tracing::debug!(
585 plugin = plugin.name(),
586 config = rel_path.as_str(),
587 entries = plugin_result.entry_patterns.len(),
588 deps = plugin_result.referenced_dependencies.len(),
589 "resolved config"
590 );
591 result.entry_patterns.extend(plugin_result.entry_patterns);
592 result
593 .referenced_dependencies
594 .extend(plugin_result.referenced_dependencies);
595 result
596 .discovered_always_used
597 .extend(plugin_result.always_used_files);
598 result.setup_files.extend(plugin_result.setup_files);
599 }
600 }
601 }
602 }
603 }
604 }
605
606 for plugin in &active {
610 if let Some(key) = plugin.package_json_config_key() {
611 let has_config_file = !plugin.config_patterns().is_empty()
613 && config_matchers.iter().any(|(p, matchers)| {
614 p.name() == plugin.name()
615 && relative_files
616 .iter()
617 .any(|(_, rel)| matchers.iter().any(|m| m.is_match(rel.as_str())))
618 });
619 if !has_config_file {
620 let pkg_path = root.join("package.json");
622 if let Ok(content) = std::fs::read_to_string(&pkg_path)
623 && let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
624 && let Some(config_value) = json.get(key)
625 {
626 let config_json = serde_json::to_string(config_value).unwrap_or_default();
627 let fake_path = root.join(format!("{key}.config.json"));
628 let plugin_result = plugin.resolve_config(&fake_path, &config_json, root);
629 if !plugin_result.is_empty() {
630 tracing::debug!(
631 plugin = plugin.name(),
632 key = key,
633 "resolved inline package.json config"
634 );
635 result.entry_patterns.extend(plugin_result.entry_patterns);
636 result
637 .referenced_dependencies
638 .extend(plugin_result.referenced_dependencies);
639 result
640 .discovered_always_used
641 .extend(plugin_result.always_used_files);
642 result.setup_files.extend(plugin_result.setup_files);
643 }
644 }
645 }
646 }
647 }
648
649 result
650 }
651
652 pub fn run_workspace_fast(
659 &self,
660 pkg: &PackageJson,
661 root: &Path,
662 precompiled_config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
663 relative_files: &[(&PathBuf, String)],
664 ) -> AggregatedPluginResult {
665 let _span = tracing::info_span!("run_plugins").entered();
666 let mut result = AggregatedPluginResult::default();
667
668 let all_deps = pkg.all_dependency_names();
670 let active: Vec<&dyn Plugin> = self
671 .plugins
672 .iter()
673 .filter(|p| p.is_enabled_with_deps(&all_deps, root))
674 .map(|p| p.as_ref())
675 .collect();
676
677 tracing::info!(
678 plugins = active
679 .iter()
680 .map(|p| p.name())
681 .collect::<Vec<_>>()
682 .join(", "),
683 "active plugins"
684 );
685
686 if active.is_empty() {
688 return result;
689 }
690
691 for plugin in &active {
693 result.active_plugins.push(plugin.name().to_string());
694
695 for pat in plugin.entry_patterns() {
696 result.entry_patterns.push((*pat).to_string());
697 }
698 for pat in plugin.config_patterns() {
699 result.config_patterns.push((*pat).to_string());
700 }
701 for pat in plugin.always_used() {
702 result.always_used.push((*pat).to_string());
703 }
704 for (file_pat, exports) in plugin.used_exports() {
705 result.used_exports.push((
706 file_pat.to_string(),
707 exports.iter().map(|s| s.to_string()).collect(),
708 ));
709 }
710 for dep in plugin.tooling_dependencies() {
711 result.tooling_dependencies.push((*dep).to_string());
712 }
713 for prefix in plugin.virtual_module_prefixes() {
714 result.virtual_module_prefixes.push((*prefix).to_string());
715 }
716 for (prefix, replacement) in plugin.path_aliases(root) {
717 result.path_aliases.push((prefix.to_string(), replacement));
718 }
719 }
720
721 let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
724 let workspace_matchers: Vec<_> = precompiled_config_matchers
725 .iter()
726 .filter(|(p, _)| active_names.contains(p.name()))
727 .collect();
728
729 if !workspace_matchers.is_empty() {
730 for (plugin, matchers) in workspace_matchers {
731 for (abs_path, rel_path) in relative_files {
732 if matchers.iter().any(|m| m.is_match(rel_path.as_str()))
733 && let Ok(source) = std::fs::read_to_string(abs_path)
734 {
735 let plugin_result = plugin.resolve_config(abs_path, &source, root);
736 if !plugin_result.is_empty() {
737 tracing::debug!(
738 plugin = plugin.name(),
739 config = rel_path.as_str(),
740 entries = plugin_result.entry_patterns.len(),
741 deps = plugin_result.referenced_dependencies.len(),
742 "resolved config"
743 );
744 result.entry_patterns.extend(plugin_result.entry_patterns);
745 result
746 .referenced_dependencies
747 .extend(plugin_result.referenced_dependencies);
748 result
749 .discovered_always_used
750 .extend(plugin_result.always_used_files);
751 result.setup_files.extend(plugin_result.setup_files);
752 }
753 }
754 }
755 }
756 }
757
758 result
759 }
760
761 pub fn precompile_config_matchers(&self) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
764 self.plugins
765 .iter()
766 .filter(|p| !p.config_patterns().is_empty())
767 .map(|p| {
768 let matchers: Vec<globset::GlobMatcher> = p
769 .config_patterns()
770 .iter()
771 .filter_map(|pat| globset::Glob::new(pat).ok().map(|g| g.compile_matcher()))
772 .collect();
773 (p.as_ref(), matchers)
774 })
775 .collect()
776 }
777}
778
779fn check_plugin_detection(
781 detection: &PluginDetection,
782 all_deps: &[&str],
783 root: &Path,
784 discovered_files: &[PathBuf],
785) -> bool {
786 match detection {
787 PluginDetection::Dependency { package } => all_deps.iter().any(|d| *d == package),
788 PluginDetection::FileExists { pattern } => {
789 if let Ok(matcher) = globset::Glob::new(pattern).map(|g| g.compile_matcher()) {
791 for file in discovered_files {
792 let relative = file.strip_prefix(root).unwrap_or(file);
793 if matcher.is_match(relative) {
794 return true;
795 }
796 }
797 }
798 let full_pattern = root.join(pattern).to_string_lossy().to_string();
800 glob::glob(&full_pattern)
801 .ok()
802 .is_some_and(|mut g| g.next().is_some())
803 }
804 PluginDetection::All { conditions } => conditions
805 .iter()
806 .all(|c| check_plugin_detection(c, all_deps, root, discovered_files)),
807 PluginDetection::Any { conditions } => conditions
808 .iter()
809 .any(|c| check_plugin_detection(c, all_deps, root, discovered_files)),
810 }
811}
812
813impl Default for PluginRegistry {
814 fn default() -> Self {
815 Self::new(vec![])
816 }
817}
818
819#[cfg(test)]
820#[allow(clippy::disallowed_types)]
821mod tests {
822 use super::*;
823 use fallow_config::{ExternalPluginDef, ExternalUsedExport, PluginDetection};
824 use std::collections::HashMap;
825
826 fn make_pkg(deps: &[&str]) -> PackageJson {
828 let map: HashMap<String, String> =
829 deps.iter().map(|d| (d.to_string(), "*".into())).collect();
830 PackageJson {
831 dependencies: Some(map),
832 ..Default::default()
833 }
834 }
835
836 fn make_pkg_dev(deps: &[&str]) -> PackageJson {
838 let map: HashMap<String, String> =
839 deps.iter().map(|d| (d.to_string(), "*".into())).collect();
840 PackageJson {
841 dev_dependencies: Some(map),
842 ..Default::default()
843 }
844 }
845
846 #[test]
849 fn nextjs_detected_when_next_in_deps() {
850 let registry = PluginRegistry::default();
851 let pkg = make_pkg(&["next", "react"]);
852 let result = registry.run(&pkg, Path::new("/project"), &[]);
853 assert!(
854 result.active_plugins.contains(&"nextjs".to_string()),
855 "nextjs plugin should be active when 'next' is in deps"
856 );
857 }
858
859 #[test]
860 fn nextjs_not_detected_without_next() {
861 let registry = PluginRegistry::default();
862 let pkg = make_pkg(&["react", "react-dom"]);
863 let result = registry.run(&pkg, Path::new("/project"), &[]);
864 assert!(
865 !result.active_plugins.contains(&"nextjs".to_string()),
866 "nextjs plugin should not be active without 'next' in deps"
867 );
868 }
869
870 #[test]
871 fn prefix_enabler_matches_scoped_packages() {
872 let registry = PluginRegistry::default();
874 let pkg = make_pkg(&["@storybook/react"]);
875 let result = registry.run(&pkg, Path::new("/project"), &[]);
876 assert!(
877 result.active_plugins.contains(&"storybook".to_string()),
878 "storybook should activate via prefix match on @storybook/react"
879 );
880 }
881
882 #[test]
883 fn prefix_enabler_does_not_match_without_slash() {
884 let registry = PluginRegistry::default();
886 let mut map = HashMap::new();
888 map.insert("@storybookish".to_string(), "*".to_string());
889 let pkg = PackageJson {
890 dependencies: Some(map),
891 ..Default::default()
892 };
893 let result = registry.run(&pkg, Path::new("/project"), &[]);
894 assert!(
895 !result.active_plugins.contains(&"storybook".to_string()),
896 "storybook should not activate for '@storybookish' (no slash prefix match)"
897 );
898 }
899
900 #[test]
901 fn multiple_plugins_detected_simultaneously() {
902 let registry = PluginRegistry::default();
903 let pkg = make_pkg(&["next", "vitest", "typescript"]);
904 let result = registry.run(&pkg, Path::new("/project"), &[]);
905 assert!(result.active_plugins.contains(&"nextjs".to_string()));
906 assert!(result.active_plugins.contains(&"vitest".to_string()));
907 assert!(result.active_plugins.contains(&"typescript".to_string()));
908 }
909
910 #[test]
911 fn no_plugins_for_empty_deps() {
912 let registry = PluginRegistry::default();
913 let pkg = PackageJson::default();
914 let result = registry.run(&pkg, Path::new("/project"), &[]);
915 assert!(
916 result.active_plugins.is_empty(),
917 "no plugins should activate with empty package.json"
918 );
919 }
920
921 #[test]
924 fn active_plugin_contributes_entry_patterns() {
925 let registry = PluginRegistry::default();
926 let pkg = make_pkg(&["next"]);
927 let result = registry.run(&pkg, Path::new("/project"), &[]);
928 assert!(
930 result
931 .entry_patterns
932 .iter()
933 .any(|p| p.contains("app/**/page")),
934 "nextjs plugin should add app/**/page entry pattern"
935 );
936 }
937
938 #[test]
939 fn inactive_plugin_does_not_contribute_entry_patterns() {
940 let registry = PluginRegistry::default();
941 let pkg = make_pkg(&["react"]);
942 let result = registry.run(&pkg, Path::new("/project"), &[]);
943 assert!(
945 !result
946 .entry_patterns
947 .iter()
948 .any(|p| p.contains("app/**/page")),
949 "nextjs patterns should not appear when plugin is inactive"
950 );
951 }
952
953 #[test]
954 fn active_plugin_contributes_tooling_deps() {
955 let registry = PluginRegistry::default();
956 let pkg = make_pkg(&["next"]);
957 let result = registry.run(&pkg, Path::new("/project"), &[]);
958 assert!(
959 result.tooling_dependencies.contains(&"next".to_string()),
960 "nextjs plugin should list 'next' as a tooling dependency"
961 );
962 }
963
964 #[test]
965 fn dev_deps_also_trigger_plugins() {
966 let registry = PluginRegistry::default();
967 let pkg = make_pkg_dev(&["vitest"]);
968 let result = registry.run(&pkg, Path::new("/project"), &[]);
969 assert!(
970 result.active_plugins.contains(&"vitest".to_string()),
971 "vitest should activate from devDependencies"
972 );
973 }
974
975 #[test]
978 fn external_plugin_detected_by_enablers() {
979 let ext = ExternalPluginDef {
980 schema: None,
981 name: "my-framework".to_string(),
982 detection: None,
983 enablers: vec!["my-framework".to_string()],
984 entry_points: vec!["src/routes/**/*.ts".to_string()],
985 config_patterns: vec![],
986 always_used: vec!["my.config.ts".to_string()],
987 tooling_dependencies: vec!["my-framework-cli".to_string()],
988 used_exports: vec![],
989 };
990 let registry = PluginRegistry::new(vec![ext]);
991 let pkg = make_pkg(&["my-framework"]);
992 let result = registry.run(&pkg, Path::new("/project"), &[]);
993 assert!(result.active_plugins.contains(&"my-framework".to_string()));
994 assert!(
995 result
996 .entry_patterns
997 .contains(&"src/routes/**/*.ts".to_string())
998 );
999 assert!(
1000 result
1001 .tooling_dependencies
1002 .contains(&"my-framework-cli".to_string())
1003 );
1004 }
1005
1006 #[test]
1007 fn external_plugin_not_detected_when_dep_missing() {
1008 let ext = ExternalPluginDef {
1009 schema: None,
1010 name: "my-framework".to_string(),
1011 detection: None,
1012 enablers: vec!["my-framework".to_string()],
1013 entry_points: vec!["src/routes/**/*.ts".to_string()],
1014 config_patterns: vec![],
1015 always_used: vec![],
1016 tooling_dependencies: vec![],
1017 used_exports: vec![],
1018 };
1019 let registry = PluginRegistry::new(vec![ext]);
1020 let pkg = make_pkg(&["react"]);
1021 let result = registry.run(&pkg, Path::new("/project"), &[]);
1022 assert!(!result.active_plugins.contains(&"my-framework".to_string()));
1023 assert!(
1024 !result
1025 .entry_patterns
1026 .contains(&"src/routes/**/*.ts".to_string())
1027 );
1028 }
1029
1030 #[test]
1031 fn external_plugin_prefix_enabler() {
1032 let ext = ExternalPluginDef {
1033 schema: None,
1034 name: "custom-plugin".to_string(),
1035 detection: None,
1036 enablers: vec!["@custom/".to_string()],
1037 entry_points: vec!["custom/**/*.ts".to_string()],
1038 config_patterns: vec![],
1039 always_used: vec![],
1040 tooling_dependencies: vec![],
1041 used_exports: vec![],
1042 };
1043 let registry = PluginRegistry::new(vec![ext]);
1044 let pkg = make_pkg(&["@custom/core"]);
1045 let result = registry.run(&pkg, Path::new("/project"), &[]);
1046 assert!(result.active_plugins.contains(&"custom-plugin".to_string()));
1047 }
1048
1049 #[test]
1050 fn external_plugin_detection_dependency() {
1051 let ext = ExternalPluginDef {
1052 schema: None,
1053 name: "detected-plugin".to_string(),
1054 detection: Some(PluginDetection::Dependency {
1055 package: "special-dep".to_string(),
1056 }),
1057 enablers: vec![],
1058 entry_points: vec!["special/**/*.ts".to_string()],
1059 config_patterns: vec![],
1060 always_used: vec![],
1061 tooling_dependencies: vec![],
1062 used_exports: vec![],
1063 };
1064 let registry = PluginRegistry::new(vec![ext]);
1065 let pkg = make_pkg(&["special-dep"]);
1066 let result = registry.run(&pkg, Path::new("/project"), &[]);
1067 assert!(
1068 result
1069 .active_plugins
1070 .contains(&"detected-plugin".to_string())
1071 );
1072 }
1073
1074 #[test]
1075 fn external_plugin_detection_any_combinator() {
1076 let ext = ExternalPluginDef {
1077 schema: None,
1078 name: "any-plugin".to_string(),
1079 detection: Some(PluginDetection::Any {
1080 conditions: vec![
1081 PluginDetection::Dependency {
1082 package: "pkg-a".to_string(),
1083 },
1084 PluginDetection::Dependency {
1085 package: "pkg-b".to_string(),
1086 },
1087 ],
1088 }),
1089 enablers: vec![],
1090 entry_points: vec!["any/**/*.ts".to_string()],
1091 config_patterns: vec![],
1092 always_used: vec![],
1093 tooling_dependencies: vec![],
1094 used_exports: vec![],
1095 };
1096 let registry = PluginRegistry::new(vec![ext]);
1097 let pkg = make_pkg(&["pkg-b"]);
1099 let result = registry.run(&pkg, Path::new("/project"), &[]);
1100 assert!(result.active_plugins.contains(&"any-plugin".to_string()));
1101 }
1102
1103 #[test]
1104 fn external_plugin_detection_all_combinator_fails_partial() {
1105 let ext = ExternalPluginDef {
1106 schema: None,
1107 name: "all-plugin".to_string(),
1108 detection: Some(PluginDetection::All {
1109 conditions: vec![
1110 PluginDetection::Dependency {
1111 package: "pkg-a".to_string(),
1112 },
1113 PluginDetection::Dependency {
1114 package: "pkg-b".to_string(),
1115 },
1116 ],
1117 }),
1118 enablers: vec![],
1119 entry_points: vec![],
1120 config_patterns: vec![],
1121 always_used: vec![],
1122 tooling_dependencies: vec![],
1123 used_exports: vec![],
1124 };
1125 let registry = PluginRegistry::new(vec![ext]);
1126 let pkg = make_pkg(&["pkg-a"]);
1128 let result = registry.run(&pkg, Path::new("/project"), &[]);
1129 assert!(!result.active_plugins.contains(&"all-plugin".to_string()));
1130 }
1131
1132 #[test]
1133 fn external_plugin_used_exports_aggregated() {
1134 let ext = ExternalPluginDef {
1135 schema: None,
1136 name: "ue-plugin".to_string(),
1137 detection: None,
1138 enablers: vec!["ue-dep".to_string()],
1139 entry_points: vec![],
1140 config_patterns: vec![],
1141 always_used: vec![],
1142 tooling_dependencies: vec![],
1143 used_exports: vec![ExternalUsedExport {
1144 pattern: "pages/**/*.tsx".to_string(),
1145 exports: vec!["default".to_string(), "getServerSideProps".to_string()],
1146 }],
1147 };
1148 let registry = PluginRegistry::new(vec![ext]);
1149 let pkg = make_pkg(&["ue-dep"]);
1150 let result = registry.run(&pkg, Path::new("/project"), &[]);
1151 assert!(result.used_exports.iter().any(|(pat, exports)| {
1152 pat == "pages/**/*.tsx" && exports.contains(&"default".to_string())
1153 }));
1154 }
1155
1156 #[test]
1157 fn external_plugin_without_enablers_or_detection_stays_inactive() {
1158 let ext = ExternalPluginDef {
1159 schema: None,
1160 name: "orphan-plugin".to_string(),
1161 detection: None,
1162 enablers: vec![],
1163 entry_points: vec!["orphan/**/*.ts".to_string()],
1164 config_patterns: vec![],
1165 always_used: vec![],
1166 tooling_dependencies: vec![],
1167 used_exports: vec![],
1168 };
1169 let registry = PluginRegistry::new(vec![ext]);
1170 let pkg = make_pkg(&["anything"]);
1171 let result = registry.run(&pkg, Path::new("/project"), &[]);
1172 assert!(!result.active_plugins.contains(&"orphan-plugin".to_string()));
1173 }
1174
1175 #[test]
1178 fn is_enabled_with_deps_exact_match() {
1179 let plugin = nextjs::NextJsPlugin;
1180 let deps = vec!["next".to_string()];
1181 assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1182 }
1183
1184 #[test]
1185 fn is_enabled_with_deps_no_match() {
1186 let plugin = nextjs::NextJsPlugin;
1187 let deps = vec!["react".to_string()];
1188 assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1189 }
1190
1191 #[test]
1192 fn is_enabled_with_empty_deps() {
1193 let plugin = nextjs::NextJsPlugin;
1194 let deps: Vec<String> = vec![];
1195 assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1196 }
1197
1198 #[test]
1201 fn nuxt_contributes_virtual_module_prefixes() {
1202 let registry = PluginRegistry::default();
1203 let pkg = make_pkg(&["nuxt"]);
1204 let result = registry.run(&pkg, Path::new("/project"), &[]);
1205 assert!(
1206 result.virtual_module_prefixes.contains(&"#".to_string()),
1207 "nuxt should contribute '#' virtual module prefix"
1208 );
1209 }
1210
1211 #[test]
1214 fn plugin_result_is_empty_when_default() {
1215 let r = PluginResult::default();
1216 assert!(r.is_empty());
1217 }
1218
1219 #[test]
1220 fn plugin_result_not_empty_with_entry_patterns() {
1221 let r = PluginResult {
1222 entry_patterns: vec!["*.ts".to_string()],
1223 ..Default::default()
1224 };
1225 assert!(!r.is_empty());
1226 }
1227
1228 #[test]
1229 fn plugin_result_not_empty_with_referenced_deps() {
1230 let r = PluginResult {
1231 referenced_dependencies: vec!["lodash".to_string()],
1232 ..Default::default()
1233 };
1234 assert!(!r.is_empty());
1235 }
1236
1237 #[test]
1238 fn plugin_result_not_empty_with_setup_files() {
1239 let r = PluginResult {
1240 setup_files: vec![PathBuf::from("/setup.ts")],
1241 ..Default::default()
1242 };
1243 assert!(!r.is_empty());
1244 }
1245
1246 #[test]
1249 fn precompile_config_matchers_returns_entries() {
1250 let registry = PluginRegistry::default();
1251 let matchers = registry.precompile_config_matchers();
1252 assert!(
1254 !matchers.is_empty(),
1255 "precompile_config_matchers should return entries for plugins with config patterns"
1256 );
1257 }
1258
1259 #[test]
1260 fn precompile_config_matchers_only_for_plugins_with_patterns() {
1261 let registry = PluginRegistry::default();
1262 let matchers = registry.precompile_config_matchers();
1263 for (plugin, _) in &matchers {
1264 assert!(
1265 !plugin.config_patterns().is_empty(),
1266 "plugin '{}' in matchers should have config patterns",
1267 plugin.name()
1268 );
1269 }
1270 }
1271}