1use std::path::{Path, PathBuf};
13
14use fallow_config::{EntryPointRole, PackageJson};
15use regex::Regex;
16
17const TEST_ENTRY_POINT_PLUGINS: &[&str] = &[
18 "ava",
19 "cucumber",
20 "cypress",
21 "jest",
22 "mocha",
23 "playwright",
24 "vitest",
25 "webdriverio",
26];
27
28const RUNTIME_ENTRY_POINT_PLUGINS: &[&str] = &[
29 "angular",
30 "astro",
31 "docusaurus",
32 "electron",
33 "expo",
34 "expo-router",
35 "gatsby",
36 "nestjs",
37 "next-intl",
38 "nextjs",
39 "nitro",
40 "nuxt",
41 "parcel",
42 "react-native",
43 "react-router",
44 "remix",
45 "rolldown",
46 "rollup",
47 "rsbuild",
48 "rspack",
49 "sanity",
50 "sveltekit",
51 "tanstack-router",
52 "tsdown",
53 "tsup",
54 "vite",
55 "vitepress",
56 "webpack",
57 "wrangler",
58];
59
60#[cfg(test)]
61const SUPPORT_ENTRY_POINT_PLUGINS: &[&str] = &[
62 "drizzle",
63 "i18next",
64 "knex",
65 "kysely",
66 "msw",
67 "prisma",
68 "storybook",
69 "typeorm",
70];
71
72#[derive(Debug, Default)]
74pub struct PluginResult {
75 pub entry_patterns: Vec<PathRule>,
77 pub replace_entry_patterns: bool,
82 pub replace_used_export_rules: bool,
85 pub used_exports: Vec<UsedExportRule>,
87 pub referenced_dependencies: Vec<String>,
89 pub always_used_files: Vec<String>,
91 pub path_aliases: Vec<(String, String)>,
93 pub setup_files: Vec<PathBuf>,
95 pub fixture_patterns: Vec<String>,
97}
98
99impl PluginResult {
100 pub fn push_entry_pattern(&mut self, pattern: impl Into<String>) {
101 self.entry_patterns.push(PathRule::new(pattern));
102 }
103
104 pub fn extend_entry_patterns<I, S>(&mut self, patterns: I)
105 where
106 I: IntoIterator<Item = S>,
107 S: Into<String>,
108 {
109 self.entry_patterns
110 .extend(patterns.into_iter().map(PathRule::new));
111 }
112
113 pub fn push_used_export_rule(
114 &mut self,
115 pattern: impl Into<String>,
116 exports: impl IntoIterator<Item = impl Into<String>>,
117 ) {
118 self.used_exports
119 .push(UsedExportRule::new(pattern, exports));
120 }
121
122 #[must_use]
123 pub const fn is_empty(&self) -> bool {
124 self.entry_patterns.is_empty()
125 && self.used_exports.is_empty()
126 && self.referenced_dependencies.is_empty()
127 && self.always_used_files.is_empty()
128 && self.path_aliases.is_empty()
129 && self.setup_files.is_empty()
130 && self.fixture_patterns.is_empty()
131 }
132}
133
134#[derive(Debug, Clone, Default, PartialEq, Eq)]
140pub struct PathRule {
141 pub pattern: String,
142 pub exclude_globs: Vec<String>,
143 pub exclude_regexes: Vec<String>,
144 pub exclude_segment_regexes: Vec<String>,
148}
149
150impl PathRule {
151 #[must_use]
152 pub fn new(pattern: impl Into<String>) -> Self {
153 Self {
154 pattern: pattern.into(),
155 exclude_globs: Vec::new(),
156 exclude_regexes: Vec::new(),
157 exclude_segment_regexes: Vec::new(),
158 }
159 }
160
161 #[must_use]
162 pub fn from_static(pattern: &'static str) -> Self {
163 Self::new(pattern)
164 }
165
166 #[must_use]
167 pub fn with_excluded_globs<I, S>(mut self, patterns: I) -> Self
168 where
169 I: IntoIterator<Item = S>,
170 S: Into<String>,
171 {
172 self.exclude_globs
173 .extend(patterns.into_iter().map(Into::into));
174 self
175 }
176
177 #[must_use]
178 pub fn with_excluded_regexes<I, S>(mut self, patterns: I) -> Self
179 where
180 I: IntoIterator<Item = S>,
181 S: Into<String>,
182 {
183 self.exclude_regexes
184 .extend(patterns.into_iter().map(Into::into));
185 self
186 }
187
188 #[must_use]
189 pub fn with_excluded_segment_regexes<I, S>(mut self, patterns: I) -> Self
190 where
191 I: IntoIterator<Item = S>,
192 S: Into<String>,
193 {
194 self.exclude_segment_regexes
195 .extend(patterns.into_iter().map(Into::into));
196 self
197 }
198
199 #[must_use]
200 pub fn prefixed(&self, ws_prefix: &str) -> Self {
201 Self {
202 pattern: prefix_workspace_pattern(&self.pattern, ws_prefix),
203 exclude_globs: self
204 .exclude_globs
205 .iter()
206 .map(|pattern| prefix_workspace_pattern(pattern, ws_prefix))
207 .collect(),
208 exclude_regexes: self
209 .exclude_regexes
210 .iter()
211 .map(|pattern| prefix_workspace_regex(pattern, ws_prefix))
212 .collect(),
213 exclude_segment_regexes: self.exclude_segment_regexes.clone(),
214 }
215 }
216}
217
218#[derive(Debug, Clone, Default, PartialEq, Eq)]
220pub struct UsedExportRule {
221 pub path: PathRule,
222 pub exports: Vec<String>,
223}
224
225impl UsedExportRule {
226 #[must_use]
227 pub fn new(
228 pattern: impl Into<String>,
229 exports: impl IntoIterator<Item = impl Into<String>>,
230 ) -> Self {
231 Self {
232 path: PathRule::new(pattern),
233 exports: exports.into_iter().map(Into::into).collect(),
234 }
235 }
236
237 #[must_use]
238 pub fn from_static(pattern: &'static str, exports: &'static [&'static str]) -> Self {
239 Self::new(pattern, exports.iter().copied())
240 }
241
242 #[must_use]
243 pub fn with_excluded_globs<I, S>(mut self, patterns: I) -> Self
244 where
245 I: IntoIterator<Item = S>,
246 S: Into<String>,
247 {
248 self.path = self.path.with_excluded_globs(patterns);
249 self
250 }
251
252 #[must_use]
253 pub fn with_excluded_regexes<I, S>(mut self, patterns: I) -> Self
254 where
255 I: IntoIterator<Item = S>,
256 S: Into<String>,
257 {
258 self.path = self.path.with_excluded_regexes(patterns);
259 self
260 }
261
262 #[must_use]
263 pub fn with_excluded_segment_regexes<I, S>(mut self, patterns: I) -> Self
264 where
265 I: IntoIterator<Item = S>,
266 S: Into<String>,
267 {
268 self.path = self.path.with_excluded_segment_regexes(patterns);
269 self
270 }
271
272 #[must_use]
273 pub fn prefixed(&self, ws_prefix: &str) -> Self {
274 Self {
275 path: self.path.prefixed(ws_prefix),
276 exports: self.exports.clone(),
277 }
278 }
279}
280
281#[derive(Debug, Clone, PartialEq, Eq)]
283pub struct PluginUsedExportRule {
284 pub plugin_name: String,
285 pub rule: UsedExportRule,
286}
287
288impl PluginUsedExportRule {
289 #[must_use]
290 pub fn new(plugin_name: impl Into<String>, rule: UsedExportRule) -> Self {
291 Self {
292 plugin_name: plugin_name.into(),
293 rule,
294 }
295 }
296
297 #[must_use]
298 pub fn prefixed(&self, ws_prefix: &str) -> Self {
299 Self {
300 plugin_name: self.plugin_name.clone(),
301 rule: self.rule.prefixed(ws_prefix),
302 }
303 }
304}
305
306#[derive(Debug, Clone)]
308pub(crate) struct CompiledPathRule {
309 include: globset::GlobMatcher,
310 exclude_globs: Vec<globset::GlobMatcher>,
311 exclude_regexes: Vec<Regex>,
312 exclude_segment_regexes: Vec<Regex>,
313}
314
315impl CompiledPathRule {
316 pub(crate) fn for_entry_rule(rule: &PathRule, rule_kind: &str) -> Option<Self> {
317 let include = match globset::GlobBuilder::new(&rule.pattern)
318 .literal_separator(true)
319 .build()
320 {
321 Ok(glob) => glob.compile_matcher(),
322 Err(err) => {
323 tracing::warn!("invalid {rule_kind} '{}': {err}", rule.pattern);
324 return None;
325 }
326 };
327 Some(Self {
328 include,
329 exclude_globs: compile_excluded_globs(&rule.exclude_globs, rule_kind, &rule.pattern),
330 exclude_regexes: compile_excluded_regexes(
331 &rule.exclude_regexes,
332 rule_kind,
333 &rule.pattern,
334 ),
335 exclude_segment_regexes: compile_excluded_segment_regexes(
336 &rule.exclude_segment_regexes,
337 rule_kind,
338 &rule.pattern,
339 ),
340 })
341 }
342
343 pub(crate) fn for_used_export_rule(rule: &PathRule, rule_kind: &str) -> Option<Self> {
344 let include = match globset::Glob::new(&rule.pattern) {
345 Ok(glob) => glob.compile_matcher(),
346 Err(err) => {
347 tracing::warn!("invalid {rule_kind} '{}': {err}", rule.pattern);
348 return None;
349 }
350 };
351 Some(Self {
352 include,
353 exclude_globs: compile_excluded_globs(&rule.exclude_globs, rule_kind, &rule.pattern),
354 exclude_regexes: compile_excluded_regexes(
355 &rule.exclude_regexes,
356 rule_kind,
357 &rule.pattern,
358 ),
359 exclude_segment_regexes: compile_excluded_segment_regexes(
360 &rule.exclude_segment_regexes,
361 rule_kind,
362 &rule.pattern,
363 ),
364 })
365 }
366
367 #[must_use]
368 pub(crate) fn matches(&self, path: &str) -> bool {
369 self.include.is_match(path)
370 && !self.exclude_globs.iter().any(|glob| glob.is_match(path))
371 && !self
372 .exclude_regexes
373 .iter()
374 .any(|regex| regex.is_match(path))
375 && !matches_segment_regex(path, &self.exclude_segment_regexes)
376 }
377}
378
379fn prefix_workspace_pattern(pattern: &str, ws_prefix: &str) -> String {
380 if pattern.starts_with(ws_prefix) || pattern.starts_with('/') {
381 pattern.to_string()
382 } else {
383 format!("{ws_prefix}/{pattern}")
384 }
385}
386
387fn prefix_workspace_regex(pattern: &str, ws_prefix: &str) -> String {
388 if let Some(pattern) = pattern.strip_prefix('^') {
389 format!("^{}/{}", regex::escape(ws_prefix), pattern)
390 } else {
391 format!("^{}/(?:{})", regex::escape(ws_prefix), pattern)
392 }
393}
394
395fn compile_excluded_globs(
396 patterns: &[String],
397 rule_kind: &str,
398 rule_pattern: &str,
399) -> Vec<globset::GlobMatcher> {
400 patterns
401 .iter()
402 .filter_map(|pattern| {
403 match globset::GlobBuilder::new(pattern)
404 .literal_separator(true)
405 .build()
406 {
407 Ok(glob) => Some(glob.compile_matcher()),
408 Err(err) => {
409 tracing::warn!(
410 "skipping invalid excluded glob '{}' for {} '{}': {err}",
411 pattern,
412 rule_kind,
413 rule_pattern
414 );
415 None
416 }
417 }
418 })
419 .collect()
420}
421
422fn compile_excluded_regexes(
423 patterns: &[String],
424 rule_kind: &str,
425 rule_pattern: &str,
426) -> Vec<Regex> {
427 patterns
428 .iter()
429 .filter_map(|pattern| match Regex::new(pattern) {
430 Ok(regex) => Some(regex),
431 Err(err) => {
432 tracing::warn!(
433 "skipping invalid excluded regex '{}' for {} '{}': {err}",
434 pattern,
435 rule_kind,
436 rule_pattern
437 );
438 None
439 }
440 })
441 .collect()
442}
443
444fn compile_excluded_segment_regexes(
445 patterns: &[String],
446 rule_kind: &str,
447 rule_pattern: &str,
448) -> Vec<Regex> {
449 patterns
450 .iter()
451 .filter_map(|pattern| match Regex::new(pattern) {
452 Ok(regex) => Some(regex),
453 Err(err) => {
454 tracing::warn!(
455 "skipping invalid excluded segment regex '{}' for {} '{}': {err}",
456 pattern,
457 rule_kind,
458 rule_pattern
459 );
460 None
461 }
462 })
463 .collect()
464}
465
466fn matches_segment_regex(path: &str, regexes: &[Regex]) -> bool {
467 path.split('/')
468 .any(|segment| regexes.iter().any(|regex| regex.is_match(segment)))
469}
470
471impl From<String> for PathRule {
472 fn from(pattern: String) -> Self {
473 Self::new(pattern)
474 }
475}
476
477impl From<&str> for PathRule {
478 fn from(pattern: &str) -> Self {
479 Self::new(pattern)
480 }
481}
482
483impl std::ops::Deref for PathRule {
484 type Target = str;
485
486 fn deref(&self) -> &Self::Target {
487 &self.pattern
488 }
489}
490
491impl PartialEq<&str> for PathRule {
492 fn eq(&self, other: &&str) -> bool {
493 self.pattern == *other
494 }
495}
496
497impl PartialEq<str> for PathRule {
498 fn eq(&self, other: &str) -> bool {
499 self.pattern == other
500 }
501}
502
503impl PartialEq<String> for PathRule {
504 fn eq(&self, other: &String) -> bool {
505 &self.pattern == other
506 }
507}
508
509pub trait Plugin: Send + Sync {
511 fn name(&self) -> &'static str;
513
514 fn enablers(&self) -> &'static [&'static str] {
517 &[]
518 }
519
520 fn is_enabled(&self, pkg: &PackageJson, root: &Path) -> bool {
523 let deps = pkg.all_dependency_names();
524 self.is_enabled_with_deps(&deps, root)
525 }
526
527 fn is_enabled_with_deps(&self, deps: &[String], _root: &Path) -> bool {
530 let enablers = self.enablers();
531 if enablers.is_empty() {
532 return false;
533 }
534 enablers.iter().any(|enabler| {
535 if enabler.ends_with('/') {
536 deps.iter().any(|d| d.starts_with(enabler))
538 } else {
539 deps.iter().any(|d| d == enabler)
540 }
541 })
542 }
543
544 fn entry_patterns(&self) -> &'static [&'static str] {
546 &[]
547 }
548
549 fn entry_pattern_rules(&self) -> Vec<PathRule> {
551 self.entry_patterns()
552 .iter()
553 .map(|pattern| PathRule::from_static(pattern))
554 .collect()
555 }
556
557 fn entry_point_role(&self) -> EntryPointRole {
562 builtin_entry_point_role(self.name())
563 }
564
565 fn config_patterns(&self) -> &'static [&'static str] {
567 &[]
568 }
569
570 fn always_used(&self) -> &'static [&'static str] {
572 &[]
573 }
574
575 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
577 vec![]
578 }
579
580 fn used_export_rules(&self) -> Vec<UsedExportRule> {
582 self.used_exports()
583 .into_iter()
584 .map(|(pattern, exports)| UsedExportRule::from_static(pattern, exports))
585 .collect()
586 }
587
588 fn fixture_glob_patterns(&self) -> &'static [&'static str] {
593 &[]
594 }
595
596 fn tooling_dependencies(&self) -> &'static [&'static str] {
599 &[]
600 }
601
602 fn virtual_module_prefixes(&self) -> &'static [&'static str] {
607 &[]
608 }
609
610 fn generated_import_patterns(&self) -> &'static [&'static str] {
616 &[]
617 }
618
619 fn path_aliases(&self, _root: &Path) -> Vec<(&'static str, String)> {
629 vec![]
630 }
631
632 fn resolve_config(&self, _config_path: &Path, _source: &str, _root: &Path) -> PluginResult {
637 PluginResult::default()
638 }
639
640 fn package_json_config_key(&self) -> Option<&'static str> {
645 None
646 }
647}
648
649fn builtin_entry_point_role(name: &str) -> EntryPointRole {
650 if TEST_ENTRY_POINT_PLUGINS.contains(&name) {
651 EntryPointRole::Test
652 } else if RUNTIME_ENTRY_POINT_PLUGINS.contains(&name) {
653 EntryPointRole::Runtime
654 } else {
655 EntryPointRole::Support
656 }
657}
658
659macro_rules! define_plugin {
719 (
722 struct $name:ident => $display:expr,
723 enablers: $enablers:expr
724 $(, entry_patterns: $entry:expr)?
725 $(, config_patterns: $config:expr)?
726 $(, always_used: $always:expr)?
727 $(, tooling_dependencies: $tooling:expr)?
728 $(, fixture_glob_patterns: $fixtures:expr)?
729 $(, virtual_module_prefixes: $virtual:expr)?
730 $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
731 , resolve_config: imports_only
732 $(,)?
733 ) => {
734 pub struct $name;
735
736 impl Plugin for $name {
737 fn name(&self) -> &'static str {
738 $display
739 }
740
741 fn enablers(&self) -> &'static [&'static str] {
742 $enablers
743 }
744
745 $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
746 $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
747 $( fn always_used(&self) -> &'static [&'static str] { $always } )?
748 $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
749 $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
750 $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
751
752 $(
753 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
754 vec![$( ($pat, $exports) ),*]
755 }
756 )?
757
758 fn resolve_config(
759 &self,
760 config_path: &std::path::Path,
761 source: &str,
762 _root: &std::path::Path,
763 ) -> PluginResult {
764 let mut result = PluginResult::default();
765 let imports = crate::plugins::config_parser::extract_imports(source, config_path);
766 for imp in &imports {
767 let dep = crate::resolve::extract_package_name(imp);
768 result.referenced_dependencies.push(dep);
769 }
770 result
771 }
772 }
773 };
774
775 (
779 struct $name:ident => $display:expr,
780 enablers: $enablers:expr
781 $(, entry_patterns: $entry:expr)?
782 $(, config_patterns: $config:expr)?
783 $(, always_used: $always:expr)?
784 $(, tooling_dependencies: $tooling:expr)?
785 $(, fixture_glob_patterns: $fixtures:expr)?
786 $(, virtual_module_prefixes: $virtual:expr)?
787 $(, package_json_config_key: $pkg_key:expr)?
788 $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
789 , resolve_config($cp:ident, $src:ident, $root:ident) $body:block
790 $(,)?
791 ) => {
792 pub struct $name;
793
794 impl Plugin for $name {
795 fn name(&self) -> &'static str {
796 $display
797 }
798
799 fn enablers(&self) -> &'static [&'static str] {
800 $enablers
801 }
802
803 $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
804 $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
805 $( fn always_used(&self) -> &'static [&'static str] { $always } )?
806 $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
807 $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
808 $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
809
810 $(
811 fn package_json_config_key(&self) -> Option<&'static str> {
812 Some($pkg_key)
813 }
814 )?
815
816 $(
817 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
818 vec![$( ($pat, $exports) ),*]
819 }
820 )?
821
822 fn resolve_config(
823 &self,
824 $cp: &std::path::Path,
825 $src: &str,
826 $root: &std::path::Path,
827 ) -> PluginResult
828 $body
829 }
830 };
831
832 (
834 struct $name:ident => $display:expr,
835 enablers: $enablers:expr
836 $(, entry_patterns: $entry:expr)?
837 $(, config_patterns: $config:expr)?
838 $(, always_used: $always:expr)?
839 $(, tooling_dependencies: $tooling:expr)?
840 $(, fixture_glob_patterns: $fixtures:expr)?
841 $(, virtual_module_prefixes: $virtual:expr)?
842 $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
843 $(,)?
844 ) => {
845 pub struct $name;
846
847 impl Plugin for $name {
848 fn name(&self) -> &'static str {
849 $display
850 }
851
852 fn enablers(&self) -> &'static [&'static str] {
853 $enablers
854 }
855
856 $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
857 $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
858 $( fn always_used(&self) -> &'static [&'static str] { $always } )?
859 $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
860 $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
861 $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
862
863 $(
864 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
865 vec![$( ($pat, $exports) ),*]
866 }
867 )?
868 }
869 };
870}
871
872pub mod config_parser;
873pub mod registry;
874mod tooling;
875
876pub use registry::{AggregatedPluginResult, PluginRegistry};
877pub use tooling::is_known_tooling_dependency;
878
879mod angular;
880mod astro;
881mod ava;
882mod babel;
883mod biome;
884mod bun;
885mod c8;
886mod capacitor;
887mod changesets;
888mod commitizen;
889mod commitlint;
890mod cspell;
891mod cucumber;
892mod cypress;
893mod dependency_cruiser;
894mod docusaurus;
895mod drizzle;
896mod electron;
897mod eslint;
898mod expo;
899mod expo_router;
900mod gatsby;
901mod graphql_codegen;
902mod husky;
903mod i18next;
904mod jest;
905mod karma;
906mod knex;
907mod kysely;
908mod lefthook;
909mod lint_staged;
910mod markdownlint;
911mod mocha;
912mod msw;
913mod nestjs;
914mod next_intl;
915mod nextjs;
916mod nitro;
917mod nodemon;
918mod nuxt;
919mod nx;
920mod nyc;
921mod openapi_ts;
922mod oxlint;
923mod parcel;
924mod playwright;
925mod plop;
926mod pm2;
927mod postcss;
928mod prettier;
929mod prisma;
930mod react_native;
931mod react_router;
932mod relay;
933mod remark;
934mod remix;
935mod rolldown;
936mod rollup;
937mod rsbuild;
938mod rspack;
939mod sanity;
940mod semantic_release;
941mod sentry;
942mod simple_git_hooks;
943mod storybook;
944mod stylelint;
945mod sveltekit;
946mod svgo;
947mod svgr;
948mod swc;
949mod syncpack;
950mod tailwind;
951mod tanstack_router;
952mod tsdown;
953mod tsup;
954mod turborepo;
955mod typedoc;
956mod typeorm;
957mod typescript;
958mod vite;
959mod vitepress;
960mod vitest;
961mod webdriverio;
962mod webpack;
963mod wrangler;
964
965#[cfg(test)]
966mod tests {
967 use super::*;
968 use std::path::Path;
969
970 #[test]
973 fn is_enabled_with_deps_exact_match() {
974 let plugin = nextjs::NextJsPlugin;
975 let deps = vec!["next".to_string()];
976 assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
977 }
978
979 #[test]
980 fn is_enabled_with_deps_no_match() {
981 let plugin = nextjs::NextJsPlugin;
982 let deps = vec!["react".to_string()];
983 assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
984 }
985
986 #[test]
987 fn is_enabled_with_deps_empty_deps() {
988 let plugin = nextjs::NextJsPlugin;
989 let deps: Vec<String> = vec![];
990 assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
991 }
992
993 #[test]
994 fn entry_point_role_defaults_are_centralized() {
995 assert_eq!(vite::VitePlugin.entry_point_role(), EntryPointRole::Runtime);
996 assert_eq!(
997 vitest::VitestPlugin.entry_point_role(),
998 EntryPointRole::Test
999 );
1000 assert_eq!(
1001 storybook::StorybookPlugin.entry_point_role(),
1002 EntryPointRole::Support
1003 );
1004 assert_eq!(knex::KnexPlugin.entry_point_role(), EntryPointRole::Support);
1005 }
1006
1007 #[test]
1008 fn plugins_with_entry_patterns_have_explicit_role_intent() {
1009 let runtime_or_test_or_support: rustc_hash::FxHashSet<&'static str> =
1010 TEST_ENTRY_POINT_PLUGINS
1011 .iter()
1012 .chain(RUNTIME_ENTRY_POINT_PLUGINS.iter())
1013 .chain(SUPPORT_ENTRY_POINT_PLUGINS.iter())
1014 .copied()
1015 .collect();
1016
1017 for plugin in crate::plugins::registry::builtin::create_builtin_plugins() {
1018 if plugin.entry_patterns().is_empty() {
1019 continue;
1020 }
1021 assert!(
1022 runtime_or_test_or_support.contains(plugin.name()),
1023 "plugin '{}' exposes entry patterns but is missing from the entry-point role map",
1024 plugin.name()
1025 );
1026 }
1027 }
1028
1029 #[test]
1032 fn plugin_result_is_empty_when_default() {
1033 let r = PluginResult::default();
1034 assert!(r.is_empty());
1035 }
1036
1037 #[test]
1038 fn plugin_result_not_empty_with_entry_patterns() {
1039 let r = PluginResult {
1040 entry_patterns: vec!["*.ts".into()],
1041 ..Default::default()
1042 };
1043 assert!(!r.is_empty());
1044 }
1045
1046 #[test]
1047 fn plugin_result_not_empty_with_referenced_deps() {
1048 let r = PluginResult {
1049 referenced_dependencies: vec!["lodash".to_string()],
1050 ..Default::default()
1051 };
1052 assert!(!r.is_empty());
1053 }
1054
1055 #[test]
1056 fn plugin_result_not_empty_with_setup_files() {
1057 let r = PluginResult {
1058 setup_files: vec![PathBuf::from("/setup.ts")],
1059 ..Default::default()
1060 };
1061 assert!(!r.is_empty());
1062 }
1063
1064 #[test]
1065 fn plugin_result_not_empty_with_always_used_files() {
1066 let r = PluginResult {
1067 always_used_files: vec!["**/*.stories.tsx".to_string()],
1068 ..Default::default()
1069 };
1070 assert!(!r.is_empty());
1071 }
1072
1073 #[test]
1074 fn plugin_result_not_empty_with_fixture_patterns() {
1075 let r = PluginResult {
1076 fixture_patterns: vec!["**/__fixtures__/**/*".to_string()],
1077 ..Default::default()
1078 };
1079 assert!(!r.is_empty());
1080 }
1081
1082 #[test]
1085 fn is_enabled_with_deps_prefix_match() {
1086 let plugin = storybook::StorybookPlugin;
1088 let deps = vec!["@storybook/react".to_string()];
1089 assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1090 }
1091
1092 #[test]
1093 fn is_enabled_with_deps_prefix_no_match_without_slash() {
1094 let plugin = storybook::StorybookPlugin;
1096 let deps = vec!["@storybookish".to_string()];
1097 assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1098 }
1099
1100 #[test]
1101 fn is_enabled_with_deps_multiple_enablers() {
1102 let plugin = vitest::VitestPlugin;
1104 let deps_vitest = vec!["vitest".to_string()];
1105 let deps_none = vec!["mocha".to_string()];
1106 assert!(plugin.is_enabled_with_deps(&deps_vitest, Path::new("/project")));
1107 assert!(!plugin.is_enabled_with_deps(&deps_none, Path::new("/project")));
1108 }
1109
1110 #[test]
1113 fn plugin_default_methods_return_empty() {
1114 let plugin = commitizen::CommitizenPlugin;
1116 assert!(
1117 plugin.tooling_dependencies().is_empty() || !plugin.tooling_dependencies().is_empty()
1118 );
1119 assert!(plugin.virtual_module_prefixes().is_empty());
1120 assert!(plugin.path_aliases(Path::new("/project")).is_empty());
1121 assert!(
1122 plugin.package_json_config_key().is_none()
1123 || plugin.package_json_config_key().is_some()
1124 );
1125 }
1126
1127 #[test]
1128 fn plugin_resolve_config_default_returns_empty() {
1129 let plugin = commitizen::CommitizenPlugin;
1130 let result = plugin.resolve_config(
1131 Path::new("/project/config.js"),
1132 "const x = 1;",
1133 Path::new("/project"),
1134 );
1135 assert!(result.is_empty());
1136 }
1137
1138 #[test]
1141 fn is_enabled_with_deps_exact_and_prefix_both_work() {
1142 let plugin = storybook::StorybookPlugin;
1143 let deps_exact = vec!["storybook".to_string()];
1144 assert!(plugin.is_enabled_with_deps(&deps_exact, Path::new("/project")));
1145 let deps_prefix = vec!["@storybook/vue3".to_string()];
1146 assert!(plugin.is_enabled_with_deps(&deps_prefix, Path::new("/project")));
1147 }
1148
1149 #[test]
1150 fn is_enabled_with_deps_multiple_enablers_remix() {
1151 let plugin = remix::RemixPlugin;
1152 let deps_node = vec!["@remix-run/node".to_string()];
1153 assert!(plugin.is_enabled_with_deps(&deps_node, Path::new("/project")));
1154 let deps_react = vec!["@remix-run/react".to_string()];
1155 assert!(plugin.is_enabled_with_deps(&deps_react, Path::new("/project")));
1156 let deps_cf = vec!["@remix-run/cloudflare".to_string()];
1157 assert!(plugin.is_enabled_with_deps(&deps_cf, Path::new("/project")));
1158 }
1159
1160 struct MinimalPlugin;
1163 impl Plugin for MinimalPlugin {
1164 fn name(&self) -> &'static str {
1165 "minimal"
1166 }
1167 }
1168
1169 #[test]
1170 fn default_enablers_is_empty() {
1171 assert!(MinimalPlugin.enablers().is_empty());
1172 }
1173
1174 #[test]
1175 fn default_entry_patterns_is_empty() {
1176 assert!(MinimalPlugin.entry_patterns().is_empty());
1177 }
1178
1179 #[test]
1180 fn default_config_patterns_is_empty() {
1181 assert!(MinimalPlugin.config_patterns().is_empty());
1182 }
1183
1184 #[test]
1185 fn default_always_used_is_empty() {
1186 assert!(MinimalPlugin.always_used().is_empty());
1187 }
1188
1189 #[test]
1190 fn default_used_exports_is_empty() {
1191 assert!(MinimalPlugin.used_exports().is_empty());
1192 }
1193
1194 #[test]
1195 fn default_tooling_dependencies_is_empty() {
1196 assert!(MinimalPlugin.tooling_dependencies().is_empty());
1197 }
1198
1199 #[test]
1200 fn default_fixture_glob_patterns_is_empty() {
1201 assert!(MinimalPlugin.fixture_glob_patterns().is_empty());
1202 }
1203
1204 #[test]
1205 fn default_virtual_module_prefixes_is_empty() {
1206 assert!(MinimalPlugin.virtual_module_prefixes().is_empty());
1207 }
1208
1209 #[test]
1210 fn default_path_aliases_is_empty() {
1211 assert!(MinimalPlugin.path_aliases(Path::new("/")).is_empty());
1212 }
1213
1214 #[test]
1215 fn default_resolve_config_returns_empty() {
1216 let r = MinimalPlugin.resolve_config(
1217 Path::new("config.js"),
1218 "export default {}",
1219 Path::new("/"),
1220 );
1221 assert!(r.is_empty());
1222 }
1223
1224 #[test]
1225 fn default_package_json_config_key_is_none() {
1226 assert!(MinimalPlugin.package_json_config_key().is_none());
1227 }
1228
1229 #[test]
1230 fn default_is_enabled_returns_false_when_no_enablers() {
1231 let deps = vec!["anything".to_string()];
1232 assert!(!MinimalPlugin.is_enabled_with_deps(&deps, Path::new("/")));
1233 }
1234
1235 #[test]
1238 fn all_builtin_plugin_names_are_unique() {
1239 let plugins = registry::builtin::create_builtin_plugins();
1240 let mut seen = std::collections::BTreeSet::new();
1241 for p in &plugins {
1242 let name = p.name();
1243 assert!(seen.insert(name), "duplicate plugin name: {name}");
1244 }
1245 }
1246
1247 #[test]
1248 fn all_builtin_plugins_have_enablers() {
1249 let plugins = registry::builtin::create_builtin_plugins();
1250 for p in &plugins {
1251 assert!(
1252 !p.enablers().is_empty(),
1253 "plugin '{}' has no enablers",
1254 p.name()
1255 );
1256 }
1257 }
1258
1259 #[test]
1260 fn plugins_with_config_patterns_have_always_used() {
1261 let plugins = registry::builtin::create_builtin_plugins();
1262 for p in &plugins {
1263 if !p.config_patterns().is_empty() {
1264 assert!(
1265 !p.always_used().is_empty(),
1266 "plugin '{}' has config_patterns but no always_used",
1267 p.name()
1268 );
1269 }
1270 }
1271 }
1272
1273 #[test]
1276 fn framework_plugins_enablers() {
1277 let cases: Vec<(&dyn Plugin, &[&str])> = vec![
1278 (&nextjs::NextJsPlugin, &["next"]),
1279 (&nuxt::NuxtPlugin, &["nuxt"]),
1280 (&angular::AngularPlugin, &["@angular/core"]),
1281 (&sveltekit::SvelteKitPlugin, &["@sveltejs/kit"]),
1282 (&gatsby::GatsbyPlugin, &["gatsby"]),
1283 ];
1284 for (plugin, expected_enablers) in cases {
1285 let enablers = plugin.enablers();
1286 for expected in expected_enablers {
1287 assert!(
1288 enablers.contains(expected),
1289 "plugin '{}' should have '{}'",
1290 plugin.name(),
1291 expected
1292 );
1293 }
1294 }
1295 }
1296
1297 #[test]
1298 fn testing_plugins_enablers() {
1299 let cases: Vec<(&dyn Plugin, &str)> = vec![
1300 (&jest::JestPlugin, "jest"),
1301 (&vitest::VitestPlugin, "vitest"),
1302 (&playwright::PlaywrightPlugin, "@playwright/test"),
1303 (&cypress::CypressPlugin, "cypress"),
1304 (&mocha::MochaPlugin, "mocha"),
1305 ];
1306 for (plugin, enabler) in cases {
1307 assert!(
1308 plugin.enablers().contains(&enabler),
1309 "plugin '{}' should have '{}'",
1310 plugin.name(),
1311 enabler
1312 );
1313 }
1314 }
1315
1316 #[test]
1317 fn bundler_plugins_enablers() {
1318 let cases: Vec<(&dyn Plugin, &str)> = vec![
1319 (&vite::VitePlugin, "vite"),
1320 (&webpack::WebpackPlugin, "webpack"),
1321 (&rollup::RollupPlugin, "rollup"),
1322 ];
1323 for (plugin, enabler) in cases {
1324 assert!(
1325 plugin.enablers().contains(&enabler),
1326 "plugin '{}' should have '{}'",
1327 plugin.name(),
1328 enabler
1329 );
1330 }
1331 }
1332
1333 #[test]
1334 fn test_plugins_have_test_entry_patterns() {
1335 let test_plugins: Vec<&dyn Plugin> = vec![
1336 &jest::JestPlugin,
1337 &vitest::VitestPlugin,
1338 &mocha::MochaPlugin,
1339 ];
1340 for plugin in test_plugins {
1341 let patterns = plugin.entry_patterns();
1342 assert!(
1343 !patterns.is_empty(),
1344 "test plugin '{}' should have entry patterns",
1345 plugin.name()
1346 );
1347 assert!(
1348 patterns
1349 .iter()
1350 .any(|p| p.contains("test") || p.contains("spec") || p.contains("__tests__")),
1351 "test plugin '{}' should have test/spec patterns",
1352 plugin.name()
1353 );
1354 }
1355 }
1356
1357 #[test]
1358 fn framework_plugins_have_entry_patterns() {
1359 let plugins: Vec<&dyn Plugin> = vec![
1360 &nextjs::NextJsPlugin,
1361 &nuxt::NuxtPlugin,
1362 &angular::AngularPlugin,
1363 &sveltekit::SvelteKitPlugin,
1364 ];
1365 for plugin in plugins {
1366 assert!(
1367 !plugin.entry_patterns().is_empty(),
1368 "framework plugin '{}' should have entry patterns",
1369 plugin.name()
1370 );
1371 }
1372 }
1373
1374 #[test]
1375 fn plugins_with_resolve_config_have_config_patterns() {
1376 let plugins: Vec<&dyn Plugin> = vec![
1377 &jest::JestPlugin,
1378 &vitest::VitestPlugin,
1379 &babel::BabelPlugin,
1380 &eslint::EslintPlugin,
1381 &webpack::WebpackPlugin,
1382 &storybook::StorybookPlugin,
1383 &typescript::TypeScriptPlugin,
1384 &postcss::PostCssPlugin,
1385 &nextjs::NextJsPlugin,
1386 &nuxt::NuxtPlugin,
1387 &angular::AngularPlugin,
1388 &nx::NxPlugin,
1389 &rollup::RollupPlugin,
1390 &sveltekit::SvelteKitPlugin,
1391 &prettier::PrettierPlugin,
1392 ];
1393 for plugin in plugins {
1394 assert!(
1395 !plugin.config_patterns().is_empty(),
1396 "plugin '{}' with resolve_config should have config_patterns",
1397 plugin.name()
1398 );
1399 }
1400 }
1401
1402 #[test]
1403 fn plugin_tooling_deps_include_enabler_package() {
1404 let plugins: Vec<&dyn Plugin> = vec![
1405 &jest::JestPlugin,
1406 &vitest::VitestPlugin,
1407 &webpack::WebpackPlugin,
1408 &typescript::TypeScriptPlugin,
1409 &eslint::EslintPlugin,
1410 &prettier::PrettierPlugin,
1411 ];
1412 for plugin in plugins {
1413 let tooling = plugin.tooling_dependencies();
1414 let enablers = plugin.enablers();
1415 assert!(
1416 enablers
1417 .iter()
1418 .any(|e| !e.ends_with('/') && tooling.contains(e)),
1419 "plugin '{}': at least one non-prefix enabler should be in tooling_dependencies",
1420 plugin.name()
1421 );
1422 }
1423 }
1424
1425 #[test]
1426 fn nextjs_has_used_exports_for_pages() {
1427 let plugin = nextjs::NextJsPlugin;
1428 let exports = plugin.used_exports();
1429 assert!(!exports.is_empty());
1430 assert!(exports.iter().any(|(_, names)| names.contains(&"default")));
1431 }
1432
1433 #[test]
1434 fn remix_has_used_exports_for_routes() {
1435 let plugin = remix::RemixPlugin;
1436 let exports = plugin.used_exports();
1437 assert!(!exports.is_empty());
1438 let route_entry = exports.iter().find(|(pat, _)| pat.contains("routes"));
1439 assert!(route_entry.is_some());
1440 let (_, names) = route_entry.unwrap();
1441 assert!(names.contains(&"loader"));
1442 assert!(names.contains(&"action"));
1443 assert!(names.contains(&"default"));
1444 }
1445
1446 #[test]
1447 fn sveltekit_has_used_exports_for_routes() {
1448 let plugin = sveltekit::SvelteKitPlugin;
1449 let exports = plugin.used_exports();
1450 assert!(!exports.is_empty());
1451 assert!(exports.iter().any(|(_, names)| names.contains(&"GET")));
1452 }
1453
1454 #[test]
1455 fn nuxt_has_hash_virtual_prefix() {
1456 assert!(nuxt::NuxtPlugin.virtual_module_prefixes().contains(&"#"));
1457 }
1458
1459 #[test]
1460 fn sveltekit_has_dollar_virtual_prefixes() {
1461 let prefixes = sveltekit::SvelteKitPlugin.virtual_module_prefixes();
1462 assert!(prefixes.contains(&"$app/"));
1463 assert!(prefixes.contains(&"$env/"));
1464 assert!(prefixes.contains(&"$lib/"));
1465 }
1466
1467 #[test]
1468 fn sveltekit_has_lib_path_alias() {
1469 let aliases = sveltekit::SvelteKitPlugin.path_aliases(Path::new("/project"));
1470 assert!(aliases.iter().any(|(prefix, _)| *prefix == "$lib/"));
1471 }
1472
1473 #[test]
1474 fn nuxt_has_tilde_path_alias() {
1475 let aliases = nuxt::NuxtPlugin.path_aliases(Path::new("/nonexistent"));
1476 assert!(aliases.iter().any(|(prefix, _)| *prefix == "~/"));
1477 assert!(aliases.iter().any(|(prefix, _)| *prefix == "~~/"));
1478 }
1479
1480 #[test]
1481 fn jest_has_package_json_config_key() {
1482 assert_eq!(jest::JestPlugin.package_json_config_key(), Some("jest"));
1483 }
1484
1485 #[test]
1486 fn babel_has_package_json_config_key() {
1487 assert_eq!(babel::BabelPlugin.package_json_config_key(), Some("babel"));
1488 }
1489
1490 #[test]
1491 fn eslint_has_package_json_config_key() {
1492 assert_eq!(
1493 eslint::EslintPlugin.package_json_config_key(),
1494 Some("eslintConfig")
1495 );
1496 }
1497
1498 #[test]
1499 fn prettier_has_package_json_config_key() {
1500 assert_eq!(
1501 prettier::PrettierPlugin.package_json_config_key(),
1502 Some("prettier")
1503 );
1504 }
1505
1506 #[test]
1507 fn macro_generated_plugin_basic_properties() {
1508 let plugin = msw::MswPlugin;
1509 assert_eq!(plugin.name(), "msw");
1510 assert!(plugin.enablers().contains(&"msw"));
1511 assert!(!plugin.entry_patterns().is_empty());
1512 assert!(plugin.config_patterns().is_empty());
1513 assert!(!plugin.always_used().is_empty());
1514 assert!(!plugin.tooling_dependencies().is_empty());
1515 }
1516
1517 #[test]
1518 fn macro_generated_plugin_with_used_exports() {
1519 let plugin = remix::RemixPlugin;
1520 assert_eq!(plugin.name(), "remix");
1521 assert!(!plugin.used_exports().is_empty());
1522 }
1523
1524 #[test]
1525 fn macro_generated_plugin_imports_only_resolve_config() {
1526 let plugin = cypress::CypressPlugin;
1527 let source = r"
1528 import { defineConfig } from 'cypress';
1529 import coveragePlugin from '@cypress/code-coverage';
1530 export default defineConfig({});
1531 ";
1532 let result = plugin.resolve_config(
1533 Path::new("cypress.config.ts"),
1534 source,
1535 Path::new("/project"),
1536 );
1537 assert!(
1538 result
1539 .referenced_dependencies
1540 .contains(&"cypress".to_string())
1541 );
1542 assert!(
1543 result
1544 .referenced_dependencies
1545 .contains(&"@cypress/code-coverage".to_string())
1546 );
1547 }
1548
1549 #[test]
1550 fn builtin_plugin_count_is_expected() {
1551 let plugins = registry::builtin::create_builtin_plugins();
1552 assert!(
1553 plugins.len() >= 80,
1554 "expected at least 80 built-in plugins, got {}",
1555 plugins.len()
1556 );
1557 }
1558}