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