1use std::path::{Path, PathBuf};
13
14use fallow_config::{EntryPointRole, PackageJson};
15
16const TEST_ENTRY_POINT_PLUGINS: &[&str] = &[
17 "ava",
18 "cucumber",
19 "cypress",
20 "jest",
21 "mocha",
22 "playwright",
23 "vitest",
24 "webdriverio",
25];
26
27const RUNTIME_ENTRY_POINT_PLUGINS: &[&str] = &[
28 "angular",
29 "astro",
30 "docusaurus",
31 "electron",
32 "expo",
33 "gatsby",
34 "nestjs",
35 "next-intl",
36 "nextjs",
37 "nitro",
38 "nuxt",
39 "parcel",
40 "react-native",
41 "react-router",
42 "remix",
43 "rolldown",
44 "rollup",
45 "rsbuild",
46 "rspack",
47 "sanity",
48 "sveltekit",
49 "tanstack-router",
50 "tsdown",
51 "tsup",
52 "vite",
53 "vitepress",
54 "webpack",
55 "wrangler",
56];
57
58#[cfg(test)]
59const SUPPORT_ENTRY_POINT_PLUGINS: &[&str] = &[
60 "drizzle",
61 "i18next",
62 "knex",
63 "kysely",
64 "msw",
65 "prisma",
66 "storybook",
67 "typeorm",
68];
69
70#[derive(Debug, Default)]
72pub struct PluginResult {
73 pub entry_patterns: Vec<String>,
75 pub replace_entry_patterns: bool,
80 pub used_exports: Vec<(String, Vec<String>)>,
82 pub referenced_dependencies: Vec<String>,
84 pub always_used_files: Vec<String>,
86 pub path_aliases: Vec<(String, String)>,
88 pub setup_files: Vec<PathBuf>,
90 pub fixture_patterns: Vec<String>,
92}
93
94impl PluginResult {
95 #[must_use]
96 pub const fn is_empty(&self) -> bool {
97 self.entry_patterns.is_empty()
98 && self.used_exports.is_empty()
99 && self.referenced_dependencies.is_empty()
100 && self.always_used_files.is_empty()
101 && self.path_aliases.is_empty()
102 && self.setup_files.is_empty()
103 && self.fixture_patterns.is_empty()
104 }
105}
106
107pub trait Plugin: Send + Sync {
109 fn name(&self) -> &'static str;
111
112 fn enablers(&self) -> &'static [&'static str] {
115 &[]
116 }
117
118 fn is_enabled(&self, pkg: &PackageJson, root: &Path) -> bool {
121 let deps = pkg.all_dependency_names();
122 self.is_enabled_with_deps(&deps, root)
123 }
124
125 fn is_enabled_with_deps(&self, deps: &[String], _root: &Path) -> bool {
128 let enablers = self.enablers();
129 if enablers.is_empty() {
130 return false;
131 }
132 enablers.iter().any(|enabler| {
133 if enabler.ends_with('/') {
134 deps.iter().any(|d| d.starts_with(enabler))
136 } else {
137 deps.iter().any(|d| d == enabler)
138 }
139 })
140 }
141
142 fn entry_patterns(&self) -> &'static [&'static str] {
144 &[]
145 }
146
147 fn entry_point_role(&self) -> EntryPointRole {
152 builtin_entry_point_role(self.name())
153 }
154
155 fn config_patterns(&self) -> &'static [&'static str] {
157 &[]
158 }
159
160 fn always_used(&self) -> &'static [&'static str] {
162 &[]
163 }
164
165 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
167 vec![]
168 }
169
170 fn fixture_glob_patterns(&self) -> &'static [&'static str] {
175 &[]
176 }
177
178 fn tooling_dependencies(&self) -> &'static [&'static str] {
181 &[]
182 }
183
184 fn virtual_module_prefixes(&self) -> &'static [&'static str] {
189 &[]
190 }
191
192 fn generated_import_patterns(&self) -> &'static [&'static str] {
198 &[]
199 }
200
201 fn path_aliases(&self, _root: &Path) -> Vec<(&'static str, String)> {
211 vec![]
212 }
213
214 fn resolve_config(&self, _config_path: &Path, _source: &str, _root: &Path) -> PluginResult {
219 PluginResult::default()
220 }
221
222 fn package_json_config_key(&self) -> Option<&'static str> {
227 None
228 }
229}
230
231fn builtin_entry_point_role(name: &str) -> EntryPointRole {
232 if TEST_ENTRY_POINT_PLUGINS.contains(&name) {
233 EntryPointRole::Test
234 } else if RUNTIME_ENTRY_POINT_PLUGINS.contains(&name) {
235 EntryPointRole::Runtime
236 } else {
237 EntryPointRole::Support
238 }
239}
240
241macro_rules! define_plugin {
301 (
304 struct $name:ident => $display:expr,
305 enablers: $enablers:expr
306 $(, entry_patterns: $entry:expr)?
307 $(, config_patterns: $config:expr)?
308 $(, always_used: $always:expr)?
309 $(, tooling_dependencies: $tooling:expr)?
310 $(, fixture_glob_patterns: $fixtures:expr)?
311 $(, virtual_module_prefixes: $virtual:expr)?
312 $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
313 , resolve_config: imports_only
314 $(,)?
315 ) => {
316 pub struct $name;
317
318 impl Plugin for $name {
319 fn name(&self) -> &'static str {
320 $display
321 }
322
323 fn enablers(&self) -> &'static [&'static str] {
324 $enablers
325 }
326
327 $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
328 $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
329 $( fn always_used(&self) -> &'static [&'static str] { $always } )?
330 $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
331 $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
332 $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
333
334 $(
335 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
336 vec![$( ($pat, $exports) ),*]
337 }
338 )?
339
340 fn resolve_config(
341 &self,
342 config_path: &std::path::Path,
343 source: &str,
344 _root: &std::path::Path,
345 ) -> PluginResult {
346 let mut result = PluginResult::default();
347 let imports = crate::plugins::config_parser::extract_imports(source, config_path);
348 for imp in &imports {
349 let dep = crate::resolve::extract_package_name(imp);
350 result.referenced_dependencies.push(dep);
351 }
352 result
353 }
354 }
355 };
356
357 (
361 struct $name:ident => $display:expr,
362 enablers: $enablers:expr
363 $(, entry_patterns: $entry:expr)?
364 $(, config_patterns: $config:expr)?
365 $(, always_used: $always:expr)?
366 $(, tooling_dependencies: $tooling:expr)?
367 $(, fixture_glob_patterns: $fixtures:expr)?
368 $(, virtual_module_prefixes: $virtual:expr)?
369 $(, package_json_config_key: $pkg_key:expr)?
370 $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
371 , resolve_config($cp:ident, $src:ident, $root:ident) $body:block
372 $(,)?
373 ) => {
374 pub struct $name;
375
376 impl Plugin for $name {
377 fn name(&self) -> &'static str {
378 $display
379 }
380
381 fn enablers(&self) -> &'static [&'static str] {
382 $enablers
383 }
384
385 $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
386 $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
387 $( fn always_used(&self) -> &'static [&'static str] { $always } )?
388 $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
389 $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
390 $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
391
392 $(
393 fn package_json_config_key(&self) -> Option<&'static str> {
394 Some($pkg_key)
395 }
396 )?
397
398 $(
399 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
400 vec![$( ($pat, $exports) ),*]
401 }
402 )?
403
404 fn resolve_config(
405 &self,
406 $cp: &std::path::Path,
407 $src: &str,
408 $root: &std::path::Path,
409 ) -> PluginResult
410 $body
411 }
412 };
413
414 (
416 struct $name:ident => $display:expr,
417 enablers: $enablers:expr
418 $(, entry_patterns: $entry:expr)?
419 $(, config_patterns: $config:expr)?
420 $(, always_used: $always:expr)?
421 $(, tooling_dependencies: $tooling:expr)?
422 $(, fixture_glob_patterns: $fixtures:expr)?
423 $(, virtual_module_prefixes: $virtual:expr)?
424 $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
425 $(,)?
426 ) => {
427 pub struct $name;
428
429 impl Plugin for $name {
430 fn name(&self) -> &'static str {
431 $display
432 }
433
434 fn enablers(&self) -> &'static [&'static str] {
435 $enablers
436 }
437
438 $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
439 $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
440 $( fn always_used(&self) -> &'static [&'static str] { $always } )?
441 $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
442 $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
443 $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
444
445 $(
446 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
447 vec![$( ($pat, $exports) ),*]
448 }
449 )?
450 }
451 };
452}
453
454pub mod config_parser;
455pub mod registry;
456mod tooling;
457
458pub use registry::{AggregatedPluginResult, PluginRegistry};
459pub use tooling::is_known_tooling_dependency;
460
461mod angular;
462mod astro;
463mod ava;
464mod babel;
465mod biome;
466mod bun;
467mod c8;
468mod capacitor;
469mod changesets;
470mod commitizen;
471mod commitlint;
472mod cspell;
473mod cucumber;
474mod cypress;
475mod dependency_cruiser;
476mod docusaurus;
477mod drizzle;
478mod electron;
479mod eslint;
480mod expo;
481mod gatsby;
482mod graphql_codegen;
483mod husky;
484mod i18next;
485mod jest;
486mod karma;
487mod knex;
488mod kysely;
489mod lefthook;
490mod lint_staged;
491mod markdownlint;
492mod mocha;
493mod msw;
494mod nestjs;
495mod next_intl;
496mod nextjs;
497mod nitro;
498mod nodemon;
499mod nuxt;
500mod nx;
501mod nyc;
502mod openapi_ts;
503mod oxlint;
504mod parcel;
505mod playwright;
506mod plop;
507mod pm2;
508mod postcss;
509mod prettier;
510mod prisma;
511mod react_native;
512mod react_router;
513mod relay;
514mod remark;
515mod remix;
516mod rolldown;
517mod rollup;
518mod rsbuild;
519mod rspack;
520mod sanity;
521mod semantic_release;
522mod sentry;
523mod simple_git_hooks;
524mod storybook;
525mod stylelint;
526mod sveltekit;
527mod svgo;
528mod svgr;
529mod swc;
530mod syncpack;
531mod tailwind;
532mod tanstack_router;
533mod tsdown;
534mod tsup;
535mod turborepo;
536mod typedoc;
537mod typeorm;
538mod typescript;
539mod vite;
540mod vitepress;
541mod vitest;
542mod webdriverio;
543mod webpack;
544mod wrangler;
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549 use std::path::Path;
550
551 #[test]
554 fn is_enabled_with_deps_exact_match() {
555 let plugin = nextjs::NextJsPlugin;
556 let deps = vec!["next".to_string()];
557 assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
558 }
559
560 #[test]
561 fn is_enabled_with_deps_no_match() {
562 let plugin = nextjs::NextJsPlugin;
563 let deps = vec!["react".to_string()];
564 assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
565 }
566
567 #[test]
568 fn is_enabled_with_deps_empty_deps() {
569 let plugin = nextjs::NextJsPlugin;
570 let deps: Vec<String> = vec![];
571 assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
572 }
573
574 #[test]
575 fn entry_point_role_defaults_are_centralized() {
576 assert_eq!(vite::VitePlugin.entry_point_role(), EntryPointRole::Runtime);
577 assert_eq!(
578 vitest::VitestPlugin.entry_point_role(),
579 EntryPointRole::Test
580 );
581 assert_eq!(
582 storybook::StorybookPlugin.entry_point_role(),
583 EntryPointRole::Support
584 );
585 assert_eq!(knex::KnexPlugin.entry_point_role(), EntryPointRole::Support);
586 }
587
588 #[test]
589 fn plugins_with_entry_patterns_have_explicit_role_intent() {
590 let runtime_or_test_or_support: rustc_hash::FxHashSet<&'static str> =
591 TEST_ENTRY_POINT_PLUGINS
592 .iter()
593 .chain(RUNTIME_ENTRY_POINT_PLUGINS.iter())
594 .chain(SUPPORT_ENTRY_POINT_PLUGINS.iter())
595 .copied()
596 .collect();
597
598 for plugin in crate::plugins::registry::builtin::create_builtin_plugins() {
599 if plugin.entry_patterns().is_empty() {
600 continue;
601 }
602 assert!(
603 runtime_or_test_or_support.contains(plugin.name()),
604 "plugin '{}' exposes entry patterns but is missing from the entry-point role map",
605 plugin.name()
606 );
607 }
608 }
609
610 #[test]
613 fn plugin_result_is_empty_when_default() {
614 let r = PluginResult::default();
615 assert!(r.is_empty());
616 }
617
618 #[test]
619 fn plugin_result_not_empty_with_entry_patterns() {
620 let r = PluginResult {
621 entry_patterns: vec!["*.ts".to_string()],
622 ..Default::default()
623 };
624 assert!(!r.is_empty());
625 }
626
627 #[test]
628 fn plugin_result_not_empty_with_referenced_deps() {
629 let r = PluginResult {
630 referenced_dependencies: vec!["lodash".to_string()],
631 ..Default::default()
632 };
633 assert!(!r.is_empty());
634 }
635
636 #[test]
637 fn plugin_result_not_empty_with_setup_files() {
638 let r = PluginResult {
639 setup_files: vec![PathBuf::from("/setup.ts")],
640 ..Default::default()
641 };
642 assert!(!r.is_empty());
643 }
644
645 #[test]
646 fn plugin_result_not_empty_with_always_used_files() {
647 let r = PluginResult {
648 always_used_files: vec!["**/*.stories.tsx".to_string()],
649 ..Default::default()
650 };
651 assert!(!r.is_empty());
652 }
653
654 #[test]
655 fn plugin_result_not_empty_with_fixture_patterns() {
656 let r = PluginResult {
657 fixture_patterns: vec!["**/__fixtures__/**/*".to_string()],
658 ..Default::default()
659 };
660 assert!(!r.is_empty());
661 }
662
663 #[test]
666 fn is_enabled_with_deps_prefix_match() {
667 let plugin = storybook::StorybookPlugin;
669 let deps = vec!["@storybook/react".to_string()];
670 assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
671 }
672
673 #[test]
674 fn is_enabled_with_deps_prefix_no_match_without_slash() {
675 let plugin = storybook::StorybookPlugin;
677 let deps = vec!["@storybookish".to_string()];
678 assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
679 }
680
681 #[test]
682 fn is_enabled_with_deps_multiple_enablers() {
683 let plugin = vitest::VitestPlugin;
685 let deps_vitest = vec!["vitest".to_string()];
686 let deps_none = vec!["mocha".to_string()];
687 assert!(plugin.is_enabled_with_deps(&deps_vitest, Path::new("/project")));
688 assert!(!plugin.is_enabled_with_deps(&deps_none, Path::new("/project")));
689 }
690
691 #[test]
694 fn plugin_default_methods_return_empty() {
695 let plugin = commitizen::CommitizenPlugin;
697 assert!(
698 plugin.tooling_dependencies().is_empty() || !plugin.tooling_dependencies().is_empty()
699 );
700 assert!(plugin.virtual_module_prefixes().is_empty());
701 assert!(plugin.path_aliases(Path::new("/project")).is_empty());
702 assert!(
703 plugin.package_json_config_key().is_none()
704 || plugin.package_json_config_key().is_some()
705 );
706 }
707
708 #[test]
709 fn plugin_resolve_config_default_returns_empty() {
710 let plugin = commitizen::CommitizenPlugin;
711 let result = plugin.resolve_config(
712 Path::new("/project/config.js"),
713 "const x = 1;",
714 Path::new("/project"),
715 );
716 assert!(result.is_empty());
717 }
718
719 #[test]
722 fn is_enabled_with_deps_exact_and_prefix_both_work() {
723 let plugin = storybook::StorybookPlugin;
724 let deps_exact = vec!["storybook".to_string()];
725 assert!(plugin.is_enabled_with_deps(&deps_exact, Path::new("/project")));
726 let deps_prefix = vec!["@storybook/vue3".to_string()];
727 assert!(plugin.is_enabled_with_deps(&deps_prefix, Path::new("/project")));
728 }
729
730 #[test]
731 fn is_enabled_with_deps_multiple_enablers_remix() {
732 let plugin = remix::RemixPlugin;
733 let deps_node = vec!["@remix-run/node".to_string()];
734 assert!(plugin.is_enabled_with_deps(&deps_node, Path::new("/project")));
735 let deps_react = vec!["@remix-run/react".to_string()];
736 assert!(plugin.is_enabled_with_deps(&deps_react, Path::new("/project")));
737 let deps_cf = vec!["@remix-run/cloudflare".to_string()];
738 assert!(plugin.is_enabled_with_deps(&deps_cf, Path::new("/project")));
739 }
740
741 struct MinimalPlugin;
744 impl Plugin for MinimalPlugin {
745 fn name(&self) -> &'static str {
746 "minimal"
747 }
748 }
749
750 #[test]
751 fn default_enablers_is_empty() {
752 assert!(MinimalPlugin.enablers().is_empty());
753 }
754
755 #[test]
756 fn default_entry_patterns_is_empty() {
757 assert!(MinimalPlugin.entry_patterns().is_empty());
758 }
759
760 #[test]
761 fn default_config_patterns_is_empty() {
762 assert!(MinimalPlugin.config_patterns().is_empty());
763 }
764
765 #[test]
766 fn default_always_used_is_empty() {
767 assert!(MinimalPlugin.always_used().is_empty());
768 }
769
770 #[test]
771 fn default_used_exports_is_empty() {
772 assert!(MinimalPlugin.used_exports().is_empty());
773 }
774
775 #[test]
776 fn default_tooling_dependencies_is_empty() {
777 assert!(MinimalPlugin.tooling_dependencies().is_empty());
778 }
779
780 #[test]
781 fn default_fixture_glob_patterns_is_empty() {
782 assert!(MinimalPlugin.fixture_glob_patterns().is_empty());
783 }
784
785 #[test]
786 fn default_virtual_module_prefixes_is_empty() {
787 assert!(MinimalPlugin.virtual_module_prefixes().is_empty());
788 }
789
790 #[test]
791 fn default_path_aliases_is_empty() {
792 assert!(MinimalPlugin.path_aliases(Path::new("/")).is_empty());
793 }
794
795 #[test]
796 fn default_resolve_config_returns_empty() {
797 let r = MinimalPlugin.resolve_config(
798 Path::new("config.js"),
799 "export default {}",
800 Path::new("/"),
801 );
802 assert!(r.is_empty());
803 }
804
805 #[test]
806 fn default_package_json_config_key_is_none() {
807 assert!(MinimalPlugin.package_json_config_key().is_none());
808 }
809
810 #[test]
811 fn default_is_enabled_returns_false_when_no_enablers() {
812 let deps = vec!["anything".to_string()];
813 assert!(!MinimalPlugin.is_enabled_with_deps(&deps, Path::new("/")));
814 }
815
816 #[test]
819 fn all_builtin_plugin_names_are_unique() {
820 let plugins = registry::builtin::create_builtin_plugins();
821 let mut seen = std::collections::BTreeSet::new();
822 for p in &plugins {
823 let name = p.name();
824 assert!(seen.insert(name), "duplicate plugin name: {name}");
825 }
826 }
827
828 #[test]
829 fn all_builtin_plugins_have_enablers() {
830 let plugins = registry::builtin::create_builtin_plugins();
831 for p in &plugins {
832 assert!(
833 !p.enablers().is_empty(),
834 "plugin '{}' has no enablers",
835 p.name()
836 );
837 }
838 }
839
840 #[test]
841 fn plugins_with_config_patterns_have_always_used() {
842 let plugins = registry::builtin::create_builtin_plugins();
843 for p in &plugins {
844 if !p.config_patterns().is_empty() {
845 assert!(
846 !p.always_used().is_empty(),
847 "plugin '{}' has config_patterns but no always_used",
848 p.name()
849 );
850 }
851 }
852 }
853
854 #[test]
857 fn framework_plugins_enablers() {
858 let cases: Vec<(&dyn Plugin, &[&str])> = vec![
859 (&nextjs::NextJsPlugin, &["next"]),
860 (&nuxt::NuxtPlugin, &["nuxt"]),
861 (&angular::AngularPlugin, &["@angular/core"]),
862 (&sveltekit::SvelteKitPlugin, &["@sveltejs/kit"]),
863 (&gatsby::GatsbyPlugin, &["gatsby"]),
864 ];
865 for (plugin, expected_enablers) in cases {
866 let enablers = plugin.enablers();
867 for expected in expected_enablers {
868 assert!(
869 enablers.contains(expected),
870 "plugin '{}' should have '{}'",
871 plugin.name(),
872 expected
873 );
874 }
875 }
876 }
877
878 #[test]
879 fn testing_plugins_enablers() {
880 let cases: Vec<(&dyn Plugin, &str)> = vec![
881 (&jest::JestPlugin, "jest"),
882 (&vitest::VitestPlugin, "vitest"),
883 (&playwright::PlaywrightPlugin, "@playwright/test"),
884 (&cypress::CypressPlugin, "cypress"),
885 (&mocha::MochaPlugin, "mocha"),
886 ];
887 for (plugin, enabler) in cases {
888 assert!(
889 plugin.enablers().contains(&enabler),
890 "plugin '{}' should have '{}'",
891 plugin.name(),
892 enabler
893 );
894 }
895 }
896
897 #[test]
898 fn bundler_plugins_enablers() {
899 let cases: Vec<(&dyn Plugin, &str)> = vec![
900 (&vite::VitePlugin, "vite"),
901 (&webpack::WebpackPlugin, "webpack"),
902 (&rollup::RollupPlugin, "rollup"),
903 ];
904 for (plugin, enabler) in cases {
905 assert!(
906 plugin.enablers().contains(&enabler),
907 "plugin '{}' should have '{}'",
908 plugin.name(),
909 enabler
910 );
911 }
912 }
913
914 #[test]
915 fn test_plugins_have_test_entry_patterns() {
916 let test_plugins: Vec<&dyn Plugin> = vec![
917 &jest::JestPlugin,
918 &vitest::VitestPlugin,
919 &mocha::MochaPlugin,
920 ];
921 for plugin in test_plugins {
922 let patterns = plugin.entry_patterns();
923 assert!(
924 !patterns.is_empty(),
925 "test plugin '{}' should have entry patterns",
926 plugin.name()
927 );
928 assert!(
929 patterns
930 .iter()
931 .any(|p| p.contains("test") || p.contains("spec") || p.contains("__tests__")),
932 "test plugin '{}' should have test/spec patterns",
933 plugin.name()
934 );
935 }
936 }
937
938 #[test]
939 fn framework_plugins_have_entry_patterns() {
940 let plugins: Vec<&dyn Plugin> = vec![
941 &nextjs::NextJsPlugin,
942 &nuxt::NuxtPlugin,
943 &angular::AngularPlugin,
944 &sveltekit::SvelteKitPlugin,
945 ];
946 for plugin in plugins {
947 assert!(
948 !plugin.entry_patterns().is_empty(),
949 "framework plugin '{}' should have entry patterns",
950 plugin.name()
951 );
952 }
953 }
954
955 #[test]
956 fn plugins_with_resolve_config_have_config_patterns() {
957 let plugins: Vec<&dyn Plugin> = vec![
958 &jest::JestPlugin,
959 &vitest::VitestPlugin,
960 &babel::BabelPlugin,
961 &eslint::EslintPlugin,
962 &webpack::WebpackPlugin,
963 &storybook::StorybookPlugin,
964 &typescript::TypeScriptPlugin,
965 &postcss::PostCssPlugin,
966 &nextjs::NextJsPlugin,
967 &nuxt::NuxtPlugin,
968 &angular::AngularPlugin,
969 &nx::NxPlugin,
970 &rollup::RollupPlugin,
971 &sveltekit::SvelteKitPlugin,
972 &prettier::PrettierPlugin,
973 ];
974 for plugin in plugins {
975 assert!(
976 !plugin.config_patterns().is_empty(),
977 "plugin '{}' with resolve_config should have config_patterns",
978 plugin.name()
979 );
980 }
981 }
982
983 #[test]
984 fn plugin_tooling_deps_include_enabler_package() {
985 let plugins: Vec<&dyn Plugin> = vec![
986 &jest::JestPlugin,
987 &vitest::VitestPlugin,
988 &webpack::WebpackPlugin,
989 &typescript::TypeScriptPlugin,
990 &eslint::EslintPlugin,
991 &prettier::PrettierPlugin,
992 ];
993 for plugin in plugins {
994 let tooling = plugin.tooling_dependencies();
995 let enablers = plugin.enablers();
996 assert!(
997 enablers
998 .iter()
999 .any(|e| !e.ends_with('/') && tooling.contains(e)),
1000 "plugin '{}': at least one non-prefix enabler should be in tooling_dependencies",
1001 plugin.name()
1002 );
1003 }
1004 }
1005
1006 #[test]
1007 fn nextjs_has_used_exports_for_pages() {
1008 let plugin = nextjs::NextJsPlugin;
1009 let exports = plugin.used_exports();
1010 assert!(!exports.is_empty());
1011 assert!(exports.iter().any(|(_, names)| names.contains(&"default")));
1012 }
1013
1014 #[test]
1015 fn remix_has_used_exports_for_routes() {
1016 let plugin = remix::RemixPlugin;
1017 let exports = plugin.used_exports();
1018 assert!(!exports.is_empty());
1019 let route_entry = exports.iter().find(|(pat, _)| pat.contains("routes"));
1020 assert!(route_entry.is_some());
1021 let (_, names) = route_entry.unwrap();
1022 assert!(names.contains(&"loader"));
1023 assert!(names.contains(&"action"));
1024 assert!(names.contains(&"default"));
1025 }
1026
1027 #[test]
1028 fn sveltekit_has_used_exports_for_routes() {
1029 let plugin = sveltekit::SvelteKitPlugin;
1030 let exports = plugin.used_exports();
1031 assert!(!exports.is_empty());
1032 assert!(exports.iter().any(|(_, names)| names.contains(&"GET")));
1033 }
1034
1035 #[test]
1036 fn nuxt_has_hash_virtual_prefix() {
1037 assert!(nuxt::NuxtPlugin.virtual_module_prefixes().contains(&"#"));
1038 }
1039
1040 #[test]
1041 fn sveltekit_has_dollar_virtual_prefixes() {
1042 let prefixes = sveltekit::SvelteKitPlugin.virtual_module_prefixes();
1043 assert!(prefixes.contains(&"$app/"));
1044 assert!(prefixes.contains(&"$env/"));
1045 assert!(prefixes.contains(&"$lib/"));
1046 }
1047
1048 #[test]
1049 fn sveltekit_has_lib_path_alias() {
1050 let aliases = sveltekit::SvelteKitPlugin.path_aliases(Path::new("/project"));
1051 assert!(aliases.iter().any(|(prefix, _)| *prefix == "$lib/"));
1052 }
1053
1054 #[test]
1055 fn nuxt_has_tilde_path_alias() {
1056 let aliases = nuxt::NuxtPlugin.path_aliases(Path::new("/nonexistent"));
1057 assert!(aliases.iter().any(|(prefix, _)| *prefix == "~/"));
1058 assert!(aliases.iter().any(|(prefix, _)| *prefix == "~~/"));
1059 }
1060
1061 #[test]
1062 fn jest_has_package_json_config_key() {
1063 assert_eq!(jest::JestPlugin.package_json_config_key(), Some("jest"));
1064 }
1065
1066 #[test]
1067 fn babel_has_package_json_config_key() {
1068 assert_eq!(babel::BabelPlugin.package_json_config_key(), Some("babel"));
1069 }
1070
1071 #[test]
1072 fn eslint_has_package_json_config_key() {
1073 assert_eq!(
1074 eslint::EslintPlugin.package_json_config_key(),
1075 Some("eslintConfig")
1076 );
1077 }
1078
1079 #[test]
1080 fn prettier_has_package_json_config_key() {
1081 assert_eq!(
1082 prettier::PrettierPlugin.package_json_config_key(),
1083 Some("prettier")
1084 );
1085 }
1086
1087 #[test]
1088 fn macro_generated_plugin_basic_properties() {
1089 let plugin = msw::MswPlugin;
1090 assert_eq!(plugin.name(), "msw");
1091 assert!(plugin.enablers().contains(&"msw"));
1092 assert!(!plugin.entry_patterns().is_empty());
1093 assert!(plugin.config_patterns().is_empty());
1094 assert!(!plugin.always_used().is_empty());
1095 assert!(!plugin.tooling_dependencies().is_empty());
1096 }
1097
1098 #[test]
1099 fn macro_generated_plugin_with_used_exports() {
1100 let plugin = remix::RemixPlugin;
1101 assert_eq!(plugin.name(), "remix");
1102 assert!(!plugin.used_exports().is_empty());
1103 }
1104
1105 #[test]
1106 fn macro_generated_plugin_imports_only_resolve_config() {
1107 let plugin = cypress::CypressPlugin;
1108 let source = r"
1109 import { defineConfig } from 'cypress';
1110 import coveragePlugin from '@cypress/code-coverage';
1111 export default defineConfig({});
1112 ";
1113 let result = plugin.resolve_config(
1114 Path::new("cypress.config.ts"),
1115 source,
1116 Path::new("/project"),
1117 );
1118 assert!(
1119 result
1120 .referenced_dependencies
1121 .contains(&"cypress".to_string())
1122 );
1123 assert!(
1124 result
1125 .referenced_dependencies
1126 .contains(&"@cypress/code-coverage".to_string())
1127 );
1128 }
1129
1130 #[test]
1131 fn builtin_plugin_count_is_expected() {
1132 let plugins = registry::builtin::create_builtin_plugins();
1133 assert!(
1134 plugins.len() >= 80,
1135 "expected at least 80 built-in plugins, got {}",
1136 plugins.len()
1137 );
1138 }
1139}