1use std::path::{Path, PathBuf};
13
14use fallow_config::PackageJson;
15
16#[derive(Debug, Default)]
18pub struct PluginResult {
19 pub entry_patterns: Vec<String>,
21 pub referenced_dependencies: Vec<String>,
23 pub always_used_files: Vec<String>,
25 pub setup_files: Vec<PathBuf>,
27}
28
29impl PluginResult {
30 pub const fn is_empty(&self) -> bool {
31 self.entry_patterns.is_empty()
32 && self.referenced_dependencies.is_empty()
33 && self.always_used_files.is_empty()
34 && self.setup_files.is_empty()
35 }
36}
37
38pub trait Plugin: Send + Sync {
40 fn name(&self) -> &'static str;
42
43 fn enablers(&self) -> &'static [&'static str] {
46 &[]
47 }
48
49 fn is_enabled(&self, pkg: &PackageJson, root: &Path) -> bool {
52 let deps = pkg.all_dependency_names();
53 self.is_enabled_with_deps(&deps, root)
54 }
55
56 fn is_enabled_with_deps(&self, deps: &[String], _root: &Path) -> bool {
59 let enablers = self.enablers();
60 if enablers.is_empty() {
61 return false;
62 }
63 enablers.iter().any(|enabler| {
64 if enabler.ends_with('/') {
65 deps.iter().any(|d| d.starts_with(enabler))
67 } else {
68 deps.iter().any(|d| d == enabler)
69 }
70 })
71 }
72
73 fn entry_patterns(&self) -> &'static [&'static str] {
75 &[]
76 }
77
78 fn config_patterns(&self) -> &'static [&'static str] {
80 &[]
81 }
82
83 fn always_used(&self) -> &'static [&'static str] {
85 &[]
86 }
87
88 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
90 vec![]
91 }
92
93 fn tooling_dependencies(&self) -> &'static [&'static str] {
96 &[]
97 }
98
99 fn virtual_module_prefixes(&self) -> &'static [&'static str] {
104 &[]
105 }
106
107 fn path_aliases(&self, _root: &Path) -> Vec<(&'static str, String)> {
117 vec![]
118 }
119
120 fn resolve_config(&self, _config_path: &Path, _source: &str, _root: &Path) -> PluginResult {
125 PluginResult::default()
126 }
127
128 fn package_json_config_key(&self) -> Option<&'static str> {
133 None
134 }
135}
136
137macro_rules! define_plugin {
183 (
186 struct $name:ident => $display:expr,
187 enablers: $enablers:expr
188 $(, entry_patterns: $entry:expr)?
189 $(, config_patterns: $config:expr)?
190 $(, always_used: $always:expr)?
191 $(, tooling_dependencies: $tooling:expr)?
192 $(, virtual_module_prefixes: $virtual:expr)?
193 $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
194 , resolve_config: imports_only
195 $(,)?
196 ) => {
197 pub struct $name;
198
199 impl Plugin for $name {
200 fn name(&self) -> &'static str {
201 $display
202 }
203
204 fn enablers(&self) -> &'static [&'static str] {
205 $enablers
206 }
207
208 $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
209 $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
210 $( fn always_used(&self) -> &'static [&'static str] { $always } )?
211 $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
212 $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
213
214 $(
215 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
216 vec![$( ($pat, $exports) ),*]
217 }
218 )?
219
220 fn resolve_config(
221 &self,
222 config_path: &std::path::Path,
223 source: &str,
224 _root: &std::path::Path,
225 ) -> PluginResult {
226 let mut result = PluginResult::default();
227 let imports = crate::plugins::config_parser::extract_imports(source, config_path);
228 for imp in &imports {
229 let dep = crate::resolve::extract_package_name(imp);
230 result.referenced_dependencies.push(dep);
231 }
232 result
233 }
234 }
235 };
236
237 (
239 struct $name:ident => $display:expr,
240 enablers: $enablers:expr
241 $(, entry_patterns: $entry:expr)?
242 $(, config_patterns: $config:expr)?
243 $(, always_used: $always:expr)?
244 $(, tooling_dependencies: $tooling:expr)?
245 $(, virtual_module_prefixes: $virtual:expr)?
246 $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
247 $(,)?
248 ) => {
249 pub struct $name;
250
251 impl Plugin for $name {
252 fn name(&self) -> &'static str {
253 $display
254 }
255
256 fn enablers(&self) -> &'static [&'static str] {
257 $enablers
258 }
259
260 $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
261 $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
262 $( fn always_used(&self) -> &'static [&'static str] { $always } )?
263 $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
264 $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
265
266 $(
267 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
268 vec![$( ($pat, $exports) ),*]
269 }
270 )?
271 }
272 };
273}
274
275pub mod config_parser;
276pub mod registry;
277mod tooling;
278
279pub use registry::{AggregatedPluginResult, PluginRegistry};
280pub use tooling::is_known_tooling_dependency;
281
282mod angular;
283mod astro;
284mod ava;
285mod babel;
286mod biome;
287mod bun;
288mod c8;
289mod capacitor;
290mod changesets;
291mod commitizen;
292mod commitlint;
293mod cspell;
294mod cucumber;
295mod cypress;
296mod dependency_cruiser;
297mod docusaurus;
298mod drizzle;
299mod electron;
300mod eslint;
301mod expo;
302mod gatsby;
303mod graphql_codegen;
304mod husky;
305mod i18next;
306mod jest;
307mod karma;
308mod knex;
309mod kysely;
310mod lefthook;
311mod lint_staged;
312mod markdownlint;
313mod mocha;
314mod msw;
315mod nestjs;
316mod next_intl;
317mod nextjs;
318mod nitro;
319mod nodemon;
320mod nuxt;
321mod nx;
322mod nyc;
323mod openapi_ts;
324mod oxlint;
325mod parcel;
326mod playwright;
327mod plop;
328mod pm2;
329mod postcss;
330mod prettier;
331mod prisma;
332mod react_native;
333mod react_router;
334mod relay;
335mod remark;
336mod remix;
337mod rolldown;
338mod rollup;
339mod rsbuild;
340mod rspack;
341mod sanity;
342mod semantic_release;
343mod sentry;
344mod simple_git_hooks;
345mod storybook;
346mod stylelint;
347mod sveltekit;
348mod svgo;
349mod svgr;
350mod swc;
351mod syncpack;
352mod tailwind;
353mod tanstack_router;
354mod tsdown;
355mod tsup;
356mod turborepo;
357mod typedoc;
358mod typeorm;
359mod typescript;
360mod vite;
361mod vitepress;
362mod vitest;
363mod webdriverio;
364mod webpack;
365mod wrangler;
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370 use std::path::Path;
371
372 #[test]
375 fn is_enabled_with_deps_exact_match() {
376 let plugin = nextjs::NextJsPlugin;
377 let deps = vec!["next".to_string()];
378 assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
379 }
380
381 #[test]
382 fn is_enabled_with_deps_no_match() {
383 let plugin = nextjs::NextJsPlugin;
384 let deps = vec!["react".to_string()];
385 assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
386 }
387
388 #[test]
389 fn is_enabled_with_deps_empty_deps() {
390 let plugin = nextjs::NextJsPlugin;
391 let deps: Vec<String> = vec![];
392 assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
393 }
394
395 #[test]
398 fn plugin_result_is_empty_when_default() {
399 let r = PluginResult::default();
400 assert!(r.is_empty());
401 }
402
403 #[test]
404 fn plugin_result_not_empty_with_entry_patterns() {
405 let r = PluginResult {
406 entry_patterns: vec!["*.ts".to_string()],
407 ..Default::default()
408 };
409 assert!(!r.is_empty());
410 }
411
412 #[test]
413 fn plugin_result_not_empty_with_referenced_deps() {
414 let r = PluginResult {
415 referenced_dependencies: vec!["lodash".to_string()],
416 ..Default::default()
417 };
418 assert!(!r.is_empty());
419 }
420
421 #[test]
422 fn plugin_result_not_empty_with_setup_files() {
423 let r = PluginResult {
424 setup_files: vec![PathBuf::from("/setup.ts")],
425 ..Default::default()
426 };
427 assert!(!r.is_empty());
428 }
429
430 #[test]
431 fn plugin_result_not_empty_with_always_used_files() {
432 let r = PluginResult {
433 always_used_files: vec!["**/*.stories.tsx".to_string()],
434 ..Default::default()
435 };
436 assert!(!r.is_empty());
437 }
438
439 #[test]
442 fn is_enabled_with_deps_prefix_match() {
443 let plugin = storybook::StorybookPlugin;
445 let deps = vec!["@storybook/react".to_string()];
446 assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
447 }
448
449 #[test]
450 fn is_enabled_with_deps_prefix_no_match_without_slash() {
451 let plugin = storybook::StorybookPlugin;
453 let deps = vec!["@storybookish".to_string()];
454 assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
455 }
456
457 #[test]
458 fn is_enabled_with_deps_multiple_enablers() {
459 let plugin = vitest::VitestPlugin;
461 let deps_vitest = vec!["vitest".to_string()];
462 let deps_none = vec!["mocha".to_string()];
463 assert!(plugin.is_enabled_with_deps(&deps_vitest, Path::new("/project")));
464 assert!(!plugin.is_enabled_with_deps(&deps_none, Path::new("/project")));
465 }
466
467 #[test]
470 fn plugin_default_methods_return_empty() {
471 let plugin = commitizen::CommitizenPlugin;
473 assert!(
474 plugin.tooling_dependencies().is_empty() || !plugin.tooling_dependencies().is_empty()
475 );
476 assert!(plugin.virtual_module_prefixes().is_empty());
477 assert!(plugin.path_aliases(Path::new("/project")).is_empty());
478 assert!(
479 plugin.package_json_config_key().is_none()
480 || plugin.package_json_config_key().is_some()
481 );
482 }
483
484 #[test]
485 fn plugin_resolve_config_default_returns_empty() {
486 let plugin = commitizen::CommitizenPlugin;
487 let result = plugin.resolve_config(
488 Path::new("/project/config.js"),
489 "const x = 1;",
490 Path::new("/project"),
491 );
492 assert!(result.is_empty());
493 }
494
495 #[test]
498 fn is_enabled_with_deps_exact_and_prefix_both_work() {
499 let plugin = storybook::StorybookPlugin;
500 let deps_exact = vec!["storybook".to_string()];
501 assert!(plugin.is_enabled_with_deps(&deps_exact, Path::new("/project")));
502 let deps_prefix = vec!["@storybook/vue3".to_string()];
503 assert!(plugin.is_enabled_with_deps(&deps_prefix, Path::new("/project")));
504 }
505
506 #[test]
507 fn is_enabled_with_deps_multiple_enablers_remix() {
508 let plugin = remix::RemixPlugin;
509 let deps_node = vec!["@remix-run/node".to_string()];
510 assert!(plugin.is_enabled_with_deps(&deps_node, Path::new("/project")));
511 let deps_react = vec!["@remix-run/react".to_string()];
512 assert!(plugin.is_enabled_with_deps(&deps_react, Path::new("/project")));
513 let deps_cf = vec!["@remix-run/cloudflare".to_string()];
514 assert!(plugin.is_enabled_with_deps(&deps_cf, Path::new("/project")));
515 }
516
517 struct MinimalPlugin;
520 impl Plugin for MinimalPlugin {
521 fn name(&self) -> &'static str {
522 "minimal"
523 }
524 }
525
526 #[test]
527 fn default_enablers_is_empty() {
528 assert!(MinimalPlugin.enablers().is_empty());
529 }
530
531 #[test]
532 fn default_entry_patterns_is_empty() {
533 assert!(MinimalPlugin.entry_patterns().is_empty());
534 }
535
536 #[test]
537 fn default_config_patterns_is_empty() {
538 assert!(MinimalPlugin.config_patterns().is_empty());
539 }
540
541 #[test]
542 fn default_always_used_is_empty() {
543 assert!(MinimalPlugin.always_used().is_empty());
544 }
545
546 #[test]
547 fn default_used_exports_is_empty() {
548 assert!(MinimalPlugin.used_exports().is_empty());
549 }
550
551 #[test]
552 fn default_tooling_dependencies_is_empty() {
553 assert!(MinimalPlugin.tooling_dependencies().is_empty());
554 }
555
556 #[test]
557 fn default_virtual_module_prefixes_is_empty() {
558 assert!(MinimalPlugin.virtual_module_prefixes().is_empty());
559 }
560
561 #[test]
562 fn default_path_aliases_is_empty() {
563 assert!(MinimalPlugin.path_aliases(Path::new("/")).is_empty());
564 }
565
566 #[test]
567 fn default_resolve_config_returns_empty() {
568 let r = MinimalPlugin.resolve_config(
569 Path::new("config.js"),
570 "export default {}",
571 Path::new("/"),
572 );
573 assert!(r.is_empty());
574 }
575
576 #[test]
577 fn default_package_json_config_key_is_none() {
578 assert!(MinimalPlugin.package_json_config_key().is_none());
579 }
580
581 #[test]
582 fn default_is_enabled_returns_false_when_no_enablers() {
583 let deps = vec!["anything".to_string()];
584 assert!(!MinimalPlugin.is_enabled_with_deps(&deps, Path::new("/")));
585 }
586
587 #[test]
590 fn all_builtin_plugin_names_are_unique() {
591 let plugins = registry::builtin::create_builtin_plugins();
592 let mut seen = std::collections::BTreeSet::new();
593 for p in &plugins {
594 let name = p.name();
595 assert!(seen.insert(name), "duplicate plugin name: {name}");
596 }
597 }
598
599 #[test]
600 fn all_builtin_plugins_have_enablers() {
601 let plugins = registry::builtin::create_builtin_plugins();
602 for p in &plugins {
603 assert!(
604 !p.enablers().is_empty(),
605 "plugin '{}' has no enablers",
606 p.name()
607 );
608 }
609 }
610
611 #[test]
612 fn plugins_with_config_patterns_have_always_used() {
613 let plugins = registry::builtin::create_builtin_plugins();
614 for p in &plugins {
615 if !p.config_patterns().is_empty() {
616 assert!(
617 !p.always_used().is_empty(),
618 "plugin '{}' has config_patterns but no always_used",
619 p.name()
620 );
621 }
622 }
623 }
624
625 #[test]
628 fn framework_plugins_enablers() {
629 let cases: Vec<(&dyn Plugin, &[&str])> = vec![
630 (&nextjs::NextJsPlugin, &["next"]),
631 (&nuxt::NuxtPlugin, &["nuxt"]),
632 (&angular::AngularPlugin, &["@angular/core"]),
633 (&sveltekit::SvelteKitPlugin, &["@sveltejs/kit"]),
634 (&gatsby::GatsbyPlugin, &["gatsby"]),
635 ];
636 for (plugin, expected_enablers) in cases {
637 let enablers = plugin.enablers();
638 for expected in expected_enablers {
639 assert!(
640 enablers.contains(expected),
641 "plugin '{}' should have '{}'",
642 plugin.name(),
643 expected
644 );
645 }
646 }
647 }
648
649 #[test]
650 fn testing_plugins_enablers() {
651 let cases: Vec<(&dyn Plugin, &str)> = vec![
652 (&jest::JestPlugin, "jest"),
653 (&vitest::VitestPlugin, "vitest"),
654 (&playwright::PlaywrightPlugin, "@playwright/test"),
655 (&cypress::CypressPlugin, "cypress"),
656 (&mocha::MochaPlugin, "mocha"),
657 ];
658 for (plugin, enabler) in cases {
659 assert!(
660 plugin.enablers().contains(&enabler),
661 "plugin '{}' should have '{}'",
662 plugin.name(),
663 enabler
664 );
665 }
666 }
667
668 #[test]
669 fn bundler_plugins_enablers() {
670 let cases: Vec<(&dyn Plugin, &str)> = vec![
671 (&vite::VitePlugin, "vite"),
672 (&webpack::WebpackPlugin, "webpack"),
673 (&rollup::RollupPlugin, "rollup"),
674 ];
675 for (plugin, enabler) in cases {
676 assert!(
677 plugin.enablers().contains(&enabler),
678 "plugin '{}' should have '{}'",
679 plugin.name(),
680 enabler
681 );
682 }
683 }
684
685 #[test]
686 fn test_plugins_have_test_entry_patterns() {
687 let test_plugins: Vec<&dyn Plugin> = vec![
688 &jest::JestPlugin,
689 &vitest::VitestPlugin,
690 &mocha::MochaPlugin,
691 ];
692 for plugin in test_plugins {
693 let patterns = plugin.entry_patterns();
694 assert!(
695 !patterns.is_empty(),
696 "test plugin '{}' should have entry patterns",
697 plugin.name()
698 );
699 assert!(
700 patterns
701 .iter()
702 .any(|p| p.contains("test") || p.contains("spec") || p.contains("__tests__")),
703 "test plugin '{}' should have test/spec patterns",
704 plugin.name()
705 );
706 }
707 }
708
709 #[test]
710 fn framework_plugins_have_entry_patterns() {
711 let plugins: Vec<&dyn Plugin> = vec![
712 &nextjs::NextJsPlugin,
713 &nuxt::NuxtPlugin,
714 &angular::AngularPlugin,
715 &sveltekit::SvelteKitPlugin,
716 ];
717 for plugin in plugins {
718 assert!(
719 !plugin.entry_patterns().is_empty(),
720 "framework plugin '{}' should have entry patterns",
721 plugin.name()
722 );
723 }
724 }
725
726 #[test]
727 fn plugins_with_resolve_config_have_config_patterns() {
728 let plugins: Vec<&dyn Plugin> = vec![
729 &jest::JestPlugin,
730 &vitest::VitestPlugin,
731 &babel::BabelPlugin,
732 &eslint::EslintPlugin,
733 &webpack::WebpackPlugin,
734 &storybook::StorybookPlugin,
735 &typescript::TypeScriptPlugin,
736 &postcss::PostCssPlugin,
737 &nextjs::NextJsPlugin,
738 &nuxt::NuxtPlugin,
739 &angular::AngularPlugin,
740 &nx::NxPlugin,
741 &rollup::RollupPlugin,
742 &sveltekit::SvelteKitPlugin,
743 ];
744 for plugin in plugins {
745 assert!(
746 !plugin.config_patterns().is_empty(),
747 "plugin '{}' with resolve_config should have config_patterns",
748 plugin.name()
749 );
750 }
751 }
752
753 #[test]
754 fn plugin_tooling_deps_include_enabler_package() {
755 let plugins: Vec<&dyn Plugin> = vec![
756 &jest::JestPlugin,
757 &vitest::VitestPlugin,
758 &webpack::WebpackPlugin,
759 &typescript::TypeScriptPlugin,
760 &eslint::EslintPlugin,
761 &prettier::PrettierPlugin,
762 ];
763 for plugin in plugins {
764 let tooling = plugin.tooling_dependencies();
765 let enablers = plugin.enablers();
766 assert!(
767 enablers
768 .iter()
769 .any(|e| !e.ends_with('/') && tooling.contains(e)),
770 "plugin '{}': at least one non-prefix enabler should be in tooling_dependencies",
771 plugin.name()
772 );
773 }
774 }
775
776 #[test]
777 fn nextjs_has_used_exports_for_pages() {
778 let plugin = nextjs::NextJsPlugin;
779 let exports = plugin.used_exports();
780 assert!(!exports.is_empty());
781 assert!(exports.iter().any(|(_, names)| names.contains(&"default")));
782 }
783
784 #[test]
785 fn remix_has_used_exports_for_routes() {
786 let plugin = remix::RemixPlugin;
787 let exports = plugin.used_exports();
788 assert!(!exports.is_empty());
789 let route_entry = exports.iter().find(|(pat, _)| pat.contains("routes"));
790 assert!(route_entry.is_some());
791 let (_, names) = route_entry.unwrap();
792 assert!(names.contains(&"loader"));
793 assert!(names.contains(&"action"));
794 assert!(names.contains(&"default"));
795 }
796
797 #[test]
798 fn sveltekit_has_used_exports_for_routes() {
799 let plugin = sveltekit::SvelteKitPlugin;
800 let exports = plugin.used_exports();
801 assert!(!exports.is_empty());
802 assert!(exports.iter().any(|(_, names)| names.contains(&"GET")));
803 }
804
805 #[test]
806 fn nuxt_has_hash_virtual_prefix() {
807 assert!(nuxt::NuxtPlugin.virtual_module_prefixes().contains(&"#"));
808 }
809
810 #[test]
811 fn sveltekit_has_dollar_virtual_prefixes() {
812 let prefixes = sveltekit::SvelteKitPlugin.virtual_module_prefixes();
813 assert!(prefixes.contains(&"$app/"));
814 assert!(prefixes.contains(&"$env/"));
815 assert!(prefixes.contains(&"$lib/"));
816 }
817
818 #[test]
819 fn sveltekit_has_lib_path_alias() {
820 let aliases = sveltekit::SvelteKitPlugin.path_aliases(Path::new("/project"));
821 assert!(aliases.iter().any(|(prefix, _)| *prefix == "$lib/"));
822 }
823
824 #[test]
825 fn nuxt_has_tilde_path_alias() {
826 let aliases = nuxt::NuxtPlugin.path_aliases(Path::new("/nonexistent"));
827 assert!(aliases.iter().any(|(prefix, _)| *prefix == "~/"));
828 assert!(aliases.iter().any(|(prefix, _)| *prefix == "~~/"));
829 }
830
831 #[test]
832 fn jest_has_package_json_config_key() {
833 assert_eq!(jest::JestPlugin.package_json_config_key(), Some("jest"));
834 }
835
836 #[test]
837 fn babel_has_package_json_config_key() {
838 assert_eq!(babel::BabelPlugin.package_json_config_key(), Some("babel"));
839 }
840
841 #[test]
842 fn macro_generated_plugin_basic_properties() {
843 let plugin = msw::MswPlugin;
844 assert_eq!(plugin.name(), "msw");
845 assert!(plugin.enablers().contains(&"msw"));
846 assert!(!plugin.entry_patterns().is_empty());
847 assert!(plugin.config_patterns().is_empty());
848 assert!(!plugin.always_used().is_empty());
849 assert!(!plugin.tooling_dependencies().is_empty());
850 }
851
852 #[test]
853 fn macro_generated_plugin_with_used_exports() {
854 let plugin = remix::RemixPlugin;
855 assert_eq!(plugin.name(), "remix");
856 assert!(!plugin.used_exports().is_empty());
857 }
858
859 #[test]
860 fn macro_generated_plugin_imports_only_resolve_config() {
861 let plugin = cypress::CypressPlugin;
862 let source = r"
863 import { defineConfig } from 'cypress';
864 import coveragePlugin from '@cypress/code-coverage';
865 export default defineConfig({});
866 ";
867 let result = plugin.resolve_config(
868 Path::new("cypress.config.ts"),
869 source,
870 Path::new("/project"),
871 );
872 assert!(
873 result
874 .referenced_dependencies
875 .contains(&"cypress".to_string())
876 );
877 assert!(
878 result
879 .referenced_dependencies
880 .contains(&"@cypress/code-coverage".to_string())
881 );
882 }
883
884 #[test]
885 fn builtin_plugin_count_is_expected() {
886 let plugins = registry::builtin::create_builtin_plugins();
887 assert!(
888 plugins.len() >= 80,
889 "expected at least 80 built-in plugins, got {}",
890 plugins.len()
891 );
892 }
893}