1use std::path::{Path, PathBuf};
13
14use fallow_config::{AutoImportRule, EntryPointRole, PackageJson, UsedClassMemberRule};
15use fallow_types::semantic::SemanticFrameworkContract;
16use regex::Regex;
17
18const TEST_ENTRY_POINT_PLUGINS: &[&str] = &[
19 "ava",
20 "bun",
21 "deno",
22 "cucumber",
23 "cypress",
24 "jest",
25 "k6",
26 "mocha",
27 "playwright",
28 "tap",
29 "tsd",
30 "vitest",
31 "webdriverio",
32];
33
34const RUNTIME_ENTRY_POINT_PLUGINS: &[&str] = &[
35 "adonis",
36 "angular",
37 "astro",
38 "browser-extension",
39 "convex",
40 "docusaurus",
41 "electron",
42 "ember",
43 "expo",
44 "expo-router",
45 "gatsby",
46 "hardhat",
47 "module-federation",
48 "nestjs",
49 "next-intl",
50 "nextjs",
51 "nitro",
52 "nuxt",
53 "obsidian",
54 "parcel",
55 "qwik",
56 "react-native",
57 "react-router",
58 "redwoodsdk",
59 "remix",
60 "rolldown",
61 "rollup",
62 "rsbuild",
63 "rspack",
64 "sanity",
65 "supabase",
66 "sveltekit",
67 "tanstack-router",
68 "tsdown",
69 "tsup",
70 "vite",
71 "vitepress",
72 "webpack",
73 "wrangler",
74 "wxt",
75];
76
77#[cfg(test)]
78const SUPPORT_ENTRY_POINT_PLUGINS: &[&str] = &[
79 "content-collections",
80 "contentlayer",
81 "danger",
82 "drizzle",
83 "fumadocs",
84 "i18next",
85 "knex",
86 "kysely",
87 "mintlify",
88 "msw",
89 "opencode",
90 "prisma",
91 "storybook",
92 "stryker",
93 "typeorm",
94 "velite",
95];
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum PluginConfigEffect {
100 Unreadable,
103 NotModeled,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct PluginConfigDiagnostic {
120 pub config_path: PathBuf,
122 pub plugin: String,
126 pub key: String,
129 pub reason: String,
131 pub effect: PluginConfigEffect,
133}
134
135impl PluginConfigDiagnostic {
136 pub(super) fn unreadable(
138 config_path: &Path,
139 plugin: &str,
140 key: &str,
141 reason: &'static str,
142 ) -> Self {
143 Self {
144 config_path: config_path.to_path_buf(),
145 plugin: plugin.to_owned(),
146 key: key.to_owned(),
147 reason: reason.to_owned(),
148 effect: PluginConfigEffect::Unreadable,
149 }
150 }
151
152 pub(super) fn not_modeled(
154 config_path: &Path,
155 plugin: &str,
156 key: &str,
157 reason: &'static str,
158 ) -> Self {
159 Self {
160 config_path: config_path.to_path_buf(),
161 plugin: plugin.to_owned(),
162 key: key.to_owned(),
163 reason: reason.to_owned(),
164 effect: PluginConfigEffect::NotModeled,
165 }
166 }
167
168 #[must_use]
171 pub fn into_workspace_diagnostic(self, root: &Path) -> fallow_config::WorkspaceDiagnostic {
172 let Self {
173 config_path,
174 plugin,
175 key,
176 reason,
177 effect,
178 } = self;
179 let kind = match effect {
180 PluginConfigEffect::Unreadable => {
181 fallow_config::WorkspaceDiagnosticKind::PluginConfigUnreadable {
182 plugin,
183 key,
184 reason,
185 }
186 }
187 PluginConfigEffect::NotModeled => {
188 fallow_config::WorkspaceDiagnosticKind::PluginEffectNotModeled {
189 plugin,
190 key,
191 reason,
192 }
193 }
194 };
195 fallow_config::WorkspaceDiagnostic::new(root, config_path, kind)
196 }
197}
198
199#[derive(Debug, Default)]
201pub struct PluginResult {
202 entry_patterns: Vec<PathRule>,
204 replace_entry_patterns: bool,
209 replace_used_export_rules: bool,
212 used_exports: Vec<UsedExportRule>,
214 used_class_members: Vec<UsedClassMemberRule>,
219 referenced_dependencies: Vec<String>,
221 always_used_files: Vec<String>,
223 path_aliases: Vec<(String, String)>,
225 setup_files: Vec<PathBuf>,
227 fixture_patterns: Vec<String>,
229 scss_include_paths: Vec<PathBuf>,
237 static_dir_mappings: Vec<(PathBuf, String)>,
240 framework_static_dir_mappings: Vec<(PathBuf, String)>,
241 provided_dependencies: Vec<ProvidedDependencyRule>,
244 config_diagnostics: Vec<PluginConfigDiagnostic>,
248}
249
250impl PluginResult {
251 fn push_entry_pattern(&mut self, pattern: impl Into<String>) {
252 self.entry_patterns
253 .push(PathRule::new(normalize_entry_pattern(pattern.into())));
254 }
255
256 fn extend_entry_patterns<I, S>(&mut self, patterns: I)
257 where
258 I: IntoIterator<Item = S>,
259 S: Into<String>,
260 {
261 self.entry_patterns.extend(
262 patterns
263 .into_iter()
264 .map(|pat| PathRule::new(normalize_entry_pattern(pat.into()))),
265 );
266 }
267
268 fn extend_entry_patterns_or_dependencies<I, S>(&mut self, values: I)
278 where
279 I: IntoIterator<Item = S>,
280 S: Into<String>,
281 {
282 for value in values {
283 let value = value.into();
284 if let Some(request) = module_request(&value) {
285 self.referenced_dependencies
286 .push(crate::resolve::extract_package_name(request));
287 continue;
288 }
289 self.push_entry_pattern(value);
290 }
291 }
292
293 fn push_used_export_rule(
294 &mut self,
295 pattern: impl Into<String>,
296 exports: impl IntoIterator<Item = impl Into<String>>,
297 ) {
298 self.used_exports
299 .push(UsedExportRule::new(pattern, exports));
300 }
301
302 #[must_use]
309 const fn is_empty(&self) -> bool {
310 self.config_diagnostics.is_empty()
311 && self.entry_patterns.is_empty()
312 && self.used_exports.is_empty()
313 && self.used_class_members.is_empty()
314 && self.referenced_dependencies.is_empty()
315 && self.always_used_files.is_empty()
316 && self.path_aliases.is_empty()
317 && self.setup_files.is_empty()
318 && self.fixture_patterns.is_empty()
319 && self.scss_include_paths.is_empty()
320 && self.static_dir_mappings.is_empty()
321 && self.framework_static_dir_mappings.is_empty()
322 && self.provided_dependencies.is_empty()
323 }
324}
325
326fn normalize_entry_pattern(pattern: String) -> String {
327 pattern
328 .strip_prefix("./")
329 .map(str::to_owned)
330 .unwrap_or(pattern)
331}
332
333fn module_request(value: &str) -> Option<&str> {
344 let request = strip_resource_query(value);
345 (config_parser::is_package_specifier(request)
346 && !has_glob_syntax(request)
347 && !has_source_extension(request))
348 .then_some(request)
349}
350
351fn strip_resource_query(value: &str) -> &str {
359 match value.split_once('?') {
360 Some((request, query)) if is_resource_query(query) => request,
361 _ => value,
362 }
363}
364
365fn is_resource_query(query: &str) -> bool {
368 !query.is_empty()
369 && query.split('&').all(|pair| {
370 let key = pair.split_once('=').map_or(pair, |(key, _)| key);
371 key.starts_with(|first: char| first.is_ascii_alphanumeric() || first == '_')
372 && key
373 .chars()
374 .all(|char| char.is_ascii_alphanumeric() || matches!(char, '_' | '-' | '.'))
375 })
376}
377
378fn has_glob_syntax(value: &str) -> bool {
381 value.contains('*') || value.contains('?') || value.contains('[') || value.contains('{')
382}
383
384fn has_source_extension(value: &str) -> bool {
388 Path::new(value)
389 .extension()
390 .and_then(|ext| ext.to_str())
391 .is_some_and(|ext| {
392 crate::discover::SOURCE_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str())
393 })
394}
395
396#[derive(Debug, Clone, Default, PartialEq, Eq)]
402pub struct PathRule {
403 pub pattern: String,
404 pub exclude_globs: Vec<String>,
405 pub exclude_regexes: Vec<String>,
406 pub exclude_segment_regexes: Vec<String>,
410}
411
412impl PathRule {
413 #[must_use]
414 pub(crate) fn new(pattern: impl Into<String>) -> Self {
415 Self {
416 pattern: pattern.into(),
417 exclude_globs: Vec::new(),
418 exclude_regexes: Vec::new(),
419 exclude_segment_regexes: Vec::new(),
420 }
421 }
422
423 #[must_use]
424 fn from_static(pattern: &'static str) -> Self {
425 Self::new(pattern)
426 }
427
428 #[must_use]
429 pub(crate) fn with_excluded_globs<I, S>(mut self, patterns: I) -> Self
430 where
431 I: IntoIterator<Item = S>,
432 S: Into<String>,
433 {
434 self.exclude_globs
435 .extend(patterns.into_iter().map(Into::into));
436 self
437 }
438
439 #[must_use]
440 fn with_excluded_regexes<I, S>(mut self, patterns: I) -> Self
441 where
442 I: IntoIterator<Item = S>,
443 S: Into<String>,
444 {
445 self.exclude_regexes
446 .extend(patterns.into_iter().map(Into::into));
447 self
448 }
449
450 #[must_use]
451 fn with_excluded_segment_regexes<I, S>(mut self, patterns: I) -> Self
452 where
453 I: IntoIterator<Item = S>,
454 S: Into<String>,
455 {
456 self.exclude_segment_regexes
457 .extend(patterns.into_iter().map(Into::into));
458 self
459 }
460
461 #[must_use]
462 fn prefixed(&self, ws_prefix: &str) -> Self {
463 Self {
464 pattern: prefix_workspace_pattern(&self.pattern, ws_prefix),
465 exclude_globs: self
466 .exclude_globs
467 .iter()
468 .map(|pattern| prefix_workspace_pattern(pattern, ws_prefix))
469 .collect(),
470 exclude_regexes: self
471 .exclude_regexes
472 .iter()
473 .map(|pattern| prefix_workspace_regex(pattern, ws_prefix))
474 .collect(),
475 exclude_segment_regexes: self.exclude_segment_regexes.clone(),
476 }
477 }
478}
479
480#[derive(Debug, Clone, Default, PartialEq, Eq)]
482pub struct UsedExportRule {
483 pub(crate) path: PathRule,
484 pub(crate) exports: Vec<String>,
485}
486
487impl UsedExportRule {
488 #[must_use]
489 pub(crate) fn new(
490 pattern: impl Into<String>,
491 exports: impl IntoIterator<Item = impl Into<String>>,
492 ) -> Self {
493 Self {
494 path: PathRule::new(pattern),
495 exports: exports.into_iter().map(Into::into).collect(),
496 }
497 }
498
499 #[must_use]
500 fn from_static(pattern: &'static str, exports: &'static [&'static str]) -> Self {
501 Self::new(pattern, exports.iter().copied())
502 }
503
504 #[must_use]
505 fn with_excluded_globs<I, S>(mut self, patterns: I) -> Self
506 where
507 I: IntoIterator<Item = S>,
508 S: Into<String>,
509 {
510 self.path = self.path.with_excluded_globs(patterns);
511 self
512 }
513
514 #[must_use]
515 fn with_excluded_regexes<I, S>(mut self, patterns: I) -> Self
516 where
517 I: IntoIterator<Item = S>,
518 S: Into<String>,
519 {
520 self.path = self.path.with_excluded_regexes(patterns);
521 self
522 }
523
524 #[must_use]
525 fn with_excluded_segment_regexes<I, S>(mut self, patterns: I) -> Self
526 where
527 I: IntoIterator<Item = S>,
528 S: Into<String>,
529 {
530 self.path = self.path.with_excluded_segment_regexes(patterns);
531 self
532 }
533
534 #[must_use]
535 fn prefixed(&self, ws_prefix: &str) -> Self {
536 Self {
537 path: self.path.prefixed(ws_prefix),
538 exports: self.exports.clone(),
539 }
540 }
541}
542
543#[derive(Debug, Clone, PartialEq, Eq)]
545pub struct PluginUsedExportRule {
546 pub(crate) plugin_name: String,
547 pub(crate) rule: UsedExportRule,
548}
549
550impl PluginUsedExportRule {
551 #[must_use]
552 pub(crate) fn new(plugin_name: impl Into<String>, rule: UsedExportRule) -> Self {
553 Self {
554 plugin_name: plugin_name.into(),
555 rule,
556 }
557 }
558
559 #[must_use]
560 fn prefixed(&self, ws_prefix: &str) -> Self {
561 Self {
562 plugin_name: self.plugin_name.clone(),
563 rule: self.rule.prefixed(ws_prefix),
564 }
565 }
566}
567
568#[derive(Debug, Clone, Default, PartialEq, Eq)]
570pub struct ProvidedDependencyRule {
571 pub(crate) path: PathRule,
572 exact_specifiers: Vec<String>,
573 specifier_prefixes: Vec<String>,
574}
575
576impl ProvidedDependencyRule {
577 #[must_use]
578 fn new(
579 pattern: impl Into<String>,
580 exact_specifiers: impl IntoIterator<Item = impl Into<String>>,
581 specifier_prefixes: impl IntoIterator<Item = impl Into<String>>,
582 ) -> Self {
583 Self {
584 path: PathRule::new(pattern),
585 exact_specifiers: exact_specifiers.into_iter().map(Into::into).collect(),
586 specifier_prefixes: specifier_prefixes.into_iter().map(Into::into).collect(),
587 }
588 }
589
590 #[must_use]
591 fn prefixed(&self, ws_prefix: &str) -> Self {
592 Self {
593 path: self.path.prefixed(ws_prefix),
594 exact_specifiers: self.exact_specifiers.clone(),
595 specifier_prefixes: self.specifier_prefixes.clone(),
596 }
597 }
598
599 #[must_use]
600 pub(crate) fn may_cover_package(&self, package_name: &str) -> bool {
601 self.exact_specifiers
602 .iter()
603 .chain(self.specifier_prefixes.iter())
604 .any(|specifier| crate::resolve::extract_package_name(specifier) == package_name)
605 }
606
607 #[must_use]
608 pub(crate) fn covers_specifier(&self, specifier: &str) -> bool {
609 self.exact_specifiers
610 .iter()
611 .any(|allowed| allowed == specifier)
612 || self
613 .specifier_prefixes
614 .iter()
615 .any(|prefix| specifier.starts_with(prefix))
616 }
617}
618
619#[derive(Debug, Clone)]
621pub(crate) struct CompiledPathRule {
622 include: globset::GlobMatcher,
623 exclude_globs: Vec<globset::GlobMatcher>,
624 exclude_regexes: Vec<Regex>,
625 exclude_segment_regexes: Vec<Regex>,
626}
627
628impl CompiledPathRule {
629 pub(crate) fn for_entry_rule(rule: &PathRule, rule_kind: &str) -> Option<Self> {
630 let include = match globset::GlobBuilder::new(&rule.pattern)
631 .literal_separator(true)
632 .build()
633 {
634 Ok(glob) => glob.compile_matcher(),
635 Err(err) => {
636 tracing::warn!("invalid {rule_kind} '{}': {err}", rule.pattern);
637 return None;
638 }
639 };
640 Some(Self {
641 include,
642 exclude_globs: compile_excluded_globs(&rule.exclude_globs, rule_kind, &rule.pattern),
643 exclude_regexes: compile_excluded_regexes(
644 &rule.exclude_regexes,
645 rule_kind,
646 &rule.pattern,
647 ),
648 exclude_segment_regexes: compile_excluded_segment_regexes(
649 &rule.exclude_segment_regexes,
650 rule_kind,
651 &rule.pattern,
652 ),
653 })
654 }
655
656 pub(crate) fn for_used_export_rule(rule: &PathRule, rule_kind: &str) -> Option<Self> {
657 let include = match globset::Glob::new(&rule.pattern) {
658 Ok(glob) => glob.compile_matcher(),
659 Err(err) => {
660 tracing::warn!("invalid {rule_kind} '{}': {err}", rule.pattern);
661 return None;
662 }
663 };
664 Some(Self {
665 include,
666 exclude_globs: compile_excluded_globs(&rule.exclude_globs, rule_kind, &rule.pattern),
667 exclude_regexes: compile_excluded_regexes(
668 &rule.exclude_regexes,
669 rule_kind,
670 &rule.pattern,
671 ),
672 exclude_segment_regexes: compile_excluded_segment_regexes(
673 &rule.exclude_segment_regexes,
674 rule_kind,
675 &rule.pattern,
676 ),
677 })
678 }
679
680 #[must_use]
681 pub(crate) fn matches(&self, path: &str) -> bool {
682 self.include.is_match(path)
683 && !self.exclude_globs.iter().any(|glob| glob.is_match(path))
684 && !self
685 .exclude_regexes
686 .iter()
687 .any(|regex| regex.is_match(path))
688 && !matches_segment_regex(path, &self.exclude_segment_regexes)
689 }
690}
691
692fn prefix_workspace_pattern(pattern: &str, ws_prefix: &str) -> String {
693 if pattern.starts_with(ws_prefix) || pattern.starts_with('/') {
694 pattern.to_string()
695 } else {
696 format!("{ws_prefix}/{pattern}")
697 }
698}
699
700fn prefix_workspace_regex(pattern: &str, ws_prefix: &str) -> String {
701 if let Some(pattern) = pattern.strip_prefix('^') {
702 format!("^{}/{}", regex::escape(ws_prefix), pattern)
703 } else {
704 format!("^{}/(?:{})", regex::escape(ws_prefix), pattern)
705 }
706}
707
708fn compile_excluded_globs(
709 patterns: &[String],
710 rule_kind: &str,
711 rule_pattern: &str,
712) -> Vec<globset::GlobMatcher> {
713 patterns
714 .iter()
715 .filter_map(|pattern| {
716 match globset::GlobBuilder::new(pattern)
717 .literal_separator(true)
718 .build()
719 {
720 Ok(glob) => Some(glob.compile_matcher()),
721 Err(err) => {
722 tracing::warn!(
723 "skipping invalid excluded glob '{}' for {} '{}': {err}",
724 pattern,
725 rule_kind,
726 rule_pattern
727 );
728 None
729 }
730 }
731 })
732 .collect()
733}
734
735fn compile_excluded_regexes(
736 patterns: &[String],
737 rule_kind: &str,
738 rule_pattern: &str,
739) -> Vec<Regex> {
740 patterns
741 .iter()
742 .filter_map(|pattern| match Regex::new(pattern) {
743 Ok(regex) => Some(regex),
744 Err(err) => {
745 tracing::warn!(
746 "skipping invalid excluded regex '{}' for {} '{}': {err}",
747 pattern,
748 rule_kind,
749 rule_pattern
750 );
751 None
752 }
753 })
754 .collect()
755}
756
757fn compile_excluded_segment_regexes(
758 patterns: &[String],
759 rule_kind: &str,
760 rule_pattern: &str,
761) -> Vec<Regex> {
762 patterns
763 .iter()
764 .filter_map(|pattern| match Regex::new(pattern) {
765 Ok(regex) => Some(regex),
766 Err(err) => {
767 tracing::warn!(
768 "skipping invalid excluded segment regex '{}' for {} '{}': {err}",
769 pattern,
770 rule_kind,
771 rule_pattern
772 );
773 None
774 }
775 })
776 .collect()
777}
778
779fn matches_segment_regex(path: &str, regexes: &[Regex]) -> bool {
780 path.split('/')
781 .any(|segment| regexes.iter().any(|regex| regex.is_match(segment)))
782}
783
784impl From<String> for PathRule {
785 fn from(pattern: String) -> Self {
786 Self::new(pattern)
787 }
788}
789
790impl From<&str> for PathRule {
791 fn from(pattern: &str) -> Self {
792 Self::new(pattern)
793 }
794}
795
796impl std::ops::Deref for PathRule {
797 type Target = str;
798
799 fn deref(&self) -> &Self::Target {
800 &self.pattern
801 }
802}
803
804impl PartialEq<&str> for PathRule {
805 fn eq(&self, other: &&str) -> bool {
806 self.pattern == *other
807 }
808}
809
810impl PartialEq<str> for PathRule {
811 fn eq(&self, other: &str) -> bool {
812 self.pattern == other
813 }
814}
815
816impl PartialEq<String> for PathRule {
817 fn eq(&self, other: &String) -> bool {
818 &self.pattern == other
819 }
820}
821
822pub trait Plugin: Send + Sync {
824 fn name(&self) -> &'static str;
826
827 fn enablers(&self) -> &'static [&'static str] {
830 &[]
831 }
832
833 fn is_enabled(&self, pkg: &PackageJson, root: &Path) -> bool {
836 let deps = pkg.all_dependency_names();
837 self.is_enabled_with_deps(&deps, root)
838 }
839
840 fn is_enabled_with_deps(&self, deps: &[String], _root: &Path) -> bool {
843 let enablers = self.enablers();
844 if enablers.is_empty() {
845 return false;
846 }
847 enablers.iter().any(|enabler| {
848 if enabler.ends_with('/') {
849 deps.iter().any(|d| d.starts_with(enabler))
851 } else {
852 deps.iter().any(|d| d == enabler)
853 }
854 })
855 }
856
857 fn is_enabled_with_files(
870 &self,
871 deps: &[String],
872 root: &Path,
873 _discovered_files: &[PathBuf],
874 _candidate_index: Option<®istry::ConfigCandidateIndex>,
875 ) -> bool {
876 self.is_enabled_with_deps(deps, root)
877 }
878
879 fn script_enablers(&self) -> &'static [&'static str] {
881 &[]
882 }
883
884 fn is_enabled_with_scripts(
886 &self,
887 script_packages: &rustc_hash::FxHashSet<String>,
888 _root: &Path,
889 ) -> bool {
890 let enablers = self.script_enablers();
891 if enablers.is_empty() {
892 return false;
893 }
894 enablers.iter().any(|enabler| {
895 if enabler.ends_with('/') {
896 script_packages
897 .iter()
898 .any(|package| package.starts_with(enabler))
899 } else {
900 script_packages.contains(*enabler)
901 }
902 })
903 }
904
905 fn entry_patterns(&self) -> &'static [&'static str] {
907 &[]
908 }
909
910 fn entry_pattern_rules(&self) -> Vec<PathRule> {
912 self.entry_patterns()
913 .iter()
914 .map(|pattern| PathRule::from_static(pattern))
915 .collect()
916 }
917
918 fn entry_point_role(&self) -> EntryPointRole {
923 builtin_entry_point_role(self.name())
924 }
925
926 fn config_patterns(&self) -> &'static [&'static str] {
928 &[]
929 }
930
931 fn always_used(&self) -> &'static [&'static str] {
933 &[]
934 }
935
936 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
938 vec![]
939 }
940
941 fn used_export_rules(&self) -> Vec<UsedExportRule> {
943 self.used_exports()
944 .into_iter()
945 .map(|(pattern, exports)| UsedExportRule::from_static(pattern, exports))
946 .collect()
947 }
948
949 fn used_class_members(&self) -> &'static [&'static str] {
954 &[]
955 }
956
957 fn used_class_member_rules(&self) -> Vec<UsedClassMemberRule> {
965 Vec::new()
966 }
967
968 fn framework_class_member_contracts(&self) -> Vec<SemanticFrameworkContract> {
971 Vec::new()
972 }
973
974 fn fixture_glob_patterns(&self) -> &'static [&'static str] {
979 &[]
980 }
981
982 fn discovery_hidden_dirs(&self) -> &'static [&'static str] {
987 &[]
988 }
989
990 fn tooling_dependencies(&self) -> &'static [&'static str] {
993 &[]
994 }
995
996 fn virtual_module_prefixes(&self) -> &'static [&'static str] {
1001 &[]
1002 }
1003
1004 fn virtual_package_suffixes(&self) -> &'static [&'static str] {
1010 &[]
1011 }
1012
1013 fn generated_import_patterns(&self) -> &'static [&'static str] {
1019 &[]
1020 }
1021
1022 fn generated_type_import_prefixes(&self) -> &'static [&'static str] {
1027 &[]
1028 }
1029
1030 fn path_aliases(&self, _root: &Path) -> Vec<(&'static str, String)> {
1040 vec![]
1041 }
1042
1043 fn static_dir_mappings(&self, _root: &Path) -> Vec<(std::path::PathBuf, String)> {
1055 vec![]
1056 }
1057
1058 fn auto_imports(&self, _root: &Path) -> Vec<AutoImportRule> {
1071 Vec::new()
1072 }
1073
1074 fn provided_dependencies(&self) -> Vec<ProvidedDependencyRule> {
1076 Vec::new()
1077 }
1078
1079 fn is_enabled_with_package_json(&self, _pkg: &PackageJson, _root: &Path) -> bool {
1081 false
1082 }
1083
1084 fn resolve_package_json(&self, _pkg: &PackageJson, _root: &Path) -> PluginResult {
1086 PluginResult::default()
1087 }
1088
1089 fn package_json_referenced_dependencies(
1094 &self,
1095 _pkg: &PackageJson,
1096 _root: &Path,
1097 ) -> Vec<String> {
1098 Vec::new()
1099 }
1100
1101 fn resolve_config(&self, _config_path: &Path, _source: &str, _root: &Path) -> PluginResult {
1106 PluginResult::default()
1107 }
1108
1109 fn package_json_config_key(&self) -> Option<&'static str> {
1114 None
1115 }
1116}
1117
1118fn builtin_entry_point_role(name: &str) -> EntryPointRole {
1119 if TEST_ENTRY_POINT_PLUGINS.contains(&name) {
1120 EntryPointRole::Test
1121 } else if RUNTIME_ENTRY_POINT_PLUGINS.contains(&name) {
1122 EntryPointRole::Runtime
1123 } else {
1124 EntryPointRole::Support
1125 }
1126}
1127
1128macro_rules! define_plugin {
1189 (
1190 struct $name:ident => $display:expr,
1191 enablers: $enablers:expr
1192 $(, entry_patterns: $entry:expr)?
1193 $(, config_patterns: $config:expr)?
1194 $(, always_used: $always:expr)?
1195 $(, tooling_dependencies: $tooling:expr)?
1196 $(, fixture_glob_patterns: $fixtures:expr)?
1197 $(, discovery_hidden_dirs: $hidden_dirs:expr)?
1198 $(, virtual_module_prefixes: $virtual:expr)?
1199 $(, virtual_package_suffixes: $virtual_suffixes:expr)?
1200 $(, generated_type_import_prefixes: $generated_type_prefixes:expr)?
1201 $(, provided_dependencies: $provided_dependencies:expr)?
1202 $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
1203 , resolve_config: imports_only
1204 $(,)?
1205 ) => {
1206 pub struct $name;
1207
1208 impl Plugin for $name {
1209 fn name(&self) -> &'static str {
1210 $display
1211 }
1212
1213 fn enablers(&self) -> &'static [&'static str] {
1214 $enablers
1215 }
1216
1217 $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
1218 $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
1219 $( fn always_used(&self) -> &'static [&'static str] { $always } )?
1220 $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
1221 $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
1222 $( fn discovery_hidden_dirs(&self) -> &'static [&'static str] { $hidden_dirs } )?
1223 $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
1224 $( fn virtual_package_suffixes(&self) -> &'static [&'static str] { $virtual_suffixes } )?
1225 $( fn generated_type_import_prefixes(&self) -> &'static [&'static str] { $generated_type_prefixes } )?
1226 $( fn provided_dependencies(&self) -> Vec<ProvidedDependencyRule> { $provided_dependencies } )?
1227
1228 $(
1229 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
1230 vec![$( ($pat, $exports) ),*]
1231 }
1232 )?
1233
1234 fn resolve_config(
1235 &self,
1236 config_path: &std::path::Path,
1237 source: &str,
1238 _root: &std::path::Path,
1239 ) -> PluginResult {
1240 let mut result = PluginResult::default();
1241 crate::plugins::add_import_referenced_dependencies(
1242 &mut result,
1243 source,
1244 config_path,
1245 );
1246 result
1247 }
1248 }
1249 };
1250
1251 (
1252 struct $name:ident => $display:expr,
1253 enablers: $enablers:expr
1254 $(, entry_patterns: $entry:expr)?
1255 $(, config_patterns: $config:expr)?
1256 $(, always_used: $always:expr)?
1257 $(, tooling_dependencies: $tooling:expr)?
1258 $(, fixture_glob_patterns: $fixtures:expr)?
1259 $(, discovery_hidden_dirs: $hidden_dirs:expr)?
1260 $(, virtual_module_prefixes: $virtual:expr)?
1261 $(, virtual_package_suffixes: $virtual_suffixes:expr)?
1262 $(, generated_type_import_prefixes: $generated_type_prefixes:expr)?
1263 $(, provided_dependencies: $provided_dependencies:expr)?
1264 $(, package_json_config_key: $pkg_key:expr)?
1265 $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
1266 , resolve_config($cp:ident, $src:ident, $root:ident) $body:block
1267 $(,)?
1268 ) => {
1269 pub struct $name;
1270
1271 impl Plugin for $name {
1272 fn name(&self) -> &'static str {
1273 $display
1274 }
1275
1276 fn enablers(&self) -> &'static [&'static str] {
1277 $enablers
1278 }
1279
1280 $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
1281 $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
1282 $( fn always_used(&self) -> &'static [&'static str] { $always } )?
1283 $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
1284 $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
1285 $( fn discovery_hidden_dirs(&self) -> &'static [&'static str] { $hidden_dirs } )?
1286 $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
1287 $( fn virtual_package_suffixes(&self) -> &'static [&'static str] { $virtual_suffixes } )?
1288 $( fn generated_type_import_prefixes(&self) -> &'static [&'static str] { $generated_type_prefixes } )?
1289 $( fn provided_dependencies(&self) -> Vec<ProvidedDependencyRule> { $provided_dependencies } )?
1290
1291 $(
1292 fn package_json_config_key(&self) -> Option<&'static str> {
1293 Some($pkg_key)
1294 }
1295 )?
1296
1297 $(
1298 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
1299 vec![$( ($pat, $exports) ),*]
1300 }
1301 )?
1302
1303 fn resolve_config(
1304 &self,
1305 $cp: &std::path::Path,
1306 $src: &str,
1307 $root: &std::path::Path,
1308 ) -> PluginResult
1309 $body
1310 }
1311 };
1312
1313 (
1314 struct $name:ident => $display:expr,
1315 enablers: $enablers:expr
1316 $(, entry_patterns: $entry:expr)?
1317 $(, config_patterns: $config:expr)?
1318 $(, always_used: $always:expr)?
1319 $(, tooling_dependencies: $tooling:expr)?
1320 $(, fixture_glob_patterns: $fixtures:expr)?
1321 $(, discovery_hidden_dirs: $hidden_dirs:expr)?
1322 $(, virtual_module_prefixes: $virtual:expr)?
1323 $(, virtual_package_suffixes: $virtual_suffixes:expr)?
1324 $(, generated_type_import_prefixes: $generated_type_prefixes:expr)?
1325 $(, provided_dependencies: $provided_dependencies:expr)?
1326 $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
1327 $(,)?
1328 ) => {
1329 pub struct $name;
1330
1331 impl Plugin for $name {
1332 fn name(&self) -> &'static str {
1333 $display
1334 }
1335
1336 fn enablers(&self) -> &'static [&'static str] {
1337 $enablers
1338 }
1339
1340 $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
1341 $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
1342 $( fn always_used(&self) -> &'static [&'static str] { $always } )?
1343 $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
1344 $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
1345 $( fn discovery_hidden_dirs(&self) -> &'static [&'static str] { $hidden_dirs } )?
1346 $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
1347 $( fn virtual_package_suffixes(&self) -> &'static [&'static str] { $virtual_suffixes } )?
1348 $( fn generated_type_import_prefixes(&self) -> &'static [&'static str] { $generated_type_prefixes } )?
1349 $( fn provided_dependencies(&self) -> Vec<ProvidedDependencyRule> { $provided_dependencies } )?
1350
1351 $(
1352 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
1353 vec![$( ($pat, $exports) ),*]
1354 }
1355 )?
1356 }
1357 };
1358}
1359
1360pub mod config_parser;
1361mod config_value_credits;
1362mod manifest;
1363pub mod manifest_entries;
1364pub mod registry;
1365mod tooling;
1366
1367pub use registry::{AggregatedPluginResult, PluginRegistry};
1368pub(crate) use tooling::is_known_tooling_dependency;
1369
1370fn add_import_referenced_dependencies(result: &mut PluginResult, source: &str, config_path: &Path) {
1371 let imports = config_parser::extract_imports(source, config_path);
1372 for import in &imports {
1373 result
1374 .referenced_dependencies
1375 .push(crate::resolve::extract_package_name(import));
1376 }
1377}
1378
1379fn credit_environment_optional_peers(environment: &str, result: &mut PluginResult) {
1391 credit_config_value(
1392 config_value_credits::CreditSurface::TestEnvironmentOptionalPeer,
1393 canonical_test_environment(environment),
1394 result,
1395 );
1396}
1397
1398fn credit_config_value(
1403 surface: config_value_credits::CreditSurface,
1404 value: &str,
1405 result: &mut PluginResult,
1406) -> bool {
1407 let Some(packages) = config_value_credits::credited_packages(surface, value) else {
1408 return false;
1409 };
1410 result
1411 .referenced_dependencies
1412 .extend(packages.iter().cloned());
1413 true
1414}
1415
1416fn canonical_test_environment(environment: &str) -> &str {
1424 environment
1425 .strip_prefix("jest-environment-")
1426 .or_else(|| environment.strip_prefix("vitest-environment-"))
1427 .unwrap_or(environment)
1428}
1429
1430mod adonis;
1431mod angular;
1432mod astro;
1433mod ava;
1434mod babel;
1435mod biome;
1436mod browser_extension;
1437mod bun;
1438mod c8;
1439mod capacitor;
1440mod changesets;
1441mod commit_and_tag_version;
1442mod commitizen;
1443mod commitlint;
1444mod content_collections;
1445mod contentlayer;
1446mod convex;
1447mod cspell;
1448mod cucumber;
1449mod cypress;
1450mod danger;
1451mod deno;
1452mod dependency_cruiser;
1453mod docusaurus;
1454mod drizzle;
1455mod electron;
1456mod ember;
1457mod eslint;
1458mod expo;
1459mod expo_router;
1460mod firebase;
1461mod fumadocs;
1462mod gatsby;
1463mod graphql_codegen;
1464mod hardhat;
1465mod husky;
1466mod i18next;
1467mod ionic;
1468mod jest;
1469mod k6;
1470mod karma;
1471mod knex;
1472mod kysely;
1473mod lefthook;
1474mod lexical;
1475mod lint_staged;
1476mod lit;
1477mod markdownlint;
1478mod mintlify;
1479mod mocha;
1480mod module_federation;
1481mod msw;
1482mod napi_rs;
1483mod nestjs;
1484mod next_intl;
1485mod nextjs;
1486mod nitro;
1487mod nodemon;
1488pub(crate) mod nuxt;
1489mod nx;
1490mod nyc;
1491mod obsidian;
1492mod openapi_ts;
1493mod opencode;
1494mod opennext_cloudflare;
1495mod oxfmt;
1496mod oxlint;
1497mod pandacss;
1498mod parcel;
1499mod pinia;
1500mod pkg_utils;
1501mod playwright;
1502mod plop;
1503mod pm2;
1504mod pnpm;
1505mod postcss;
1506mod prettier;
1507mod prisma;
1508mod qwik;
1509mod react_compiler;
1510mod react_native;
1511mod react_router;
1512mod redwoodsdk;
1513mod relay;
1514mod remark;
1515mod remix;
1516mod rolldown;
1517mod rollup;
1518mod rsbuild;
1519mod rspack;
1520mod rspress;
1521mod sanity;
1522mod semantic_release;
1523mod sentry;
1524mod simple_git_hooks;
1525mod size_limit;
1526mod storybook;
1527mod stryker;
1528mod stylelint;
1529mod supabase;
1530mod sveltekit;
1531mod svgo;
1532mod svgr;
1533mod swc;
1534mod syncpack;
1535mod tailwind;
1536mod tanstack_router;
1537mod tap;
1538mod test_alias;
1539mod tsd;
1540mod tsdown;
1541mod tsup;
1542mod turborepo;
1543mod typedoc;
1544mod typeorm;
1545mod typescript;
1546mod unocss;
1547mod varlock;
1548mod velite;
1549mod vercel;
1550mod vite;
1551mod vitepress;
1552mod vitest;
1553mod vscode;
1554mod webdriverio;
1555mod webpack;
1556mod wrangler;
1557mod wuchale;
1558mod wxt;
1559
1560#[cfg(test)]
1561mod tests {
1562 use super::*;
1563 use std::path::Path;
1564
1565 #[test]
1566 fn is_enabled_with_deps_exact_match() {
1567 let plugin = nextjs::NextJsPlugin;
1568 let deps = vec!["next".to_string()];
1569 assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1570 }
1571
1572 #[test]
1573 fn is_enabled_with_deps_no_match() {
1574 let plugin = nextjs::NextJsPlugin;
1575 let deps = vec!["react".to_string()];
1576 assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1577 }
1578
1579 #[test]
1580 fn is_enabled_with_deps_empty_deps() {
1581 let plugin = nextjs::NextJsPlugin;
1582 let deps: Vec<String> = vec![];
1583 assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1584 }
1585
1586 #[test]
1587 fn environment_optional_peers_come_from_the_credit_catalogue() {
1588 for environment in [
1589 "jsdom",
1590 "jest-environment-jsdom",
1591 "vitest-environment-jsdom",
1592 ] {
1593 let mut result = PluginResult::default();
1594 credit_environment_optional_peers(environment, &mut result);
1595 assert_eq!(
1596 result.referenced_dependencies,
1597 vec!["canvas".to_string()],
1598 "expected the catalogue credit for {environment}"
1599 );
1600 }
1601 }
1602
1603 #[test]
1604 fn environment_without_a_catalogue_row_credits_nothing() {
1605 let mut result = PluginResult::default();
1606 credit_environment_optional_peers("happy-dom", &mut result);
1607 assert!(result.referenced_dependencies.is_empty());
1608 }
1609
1610 #[test]
1611 fn entry_point_role_defaults_are_centralized() {
1612 assert_eq!(vite::VitePlugin.entry_point_role(), EntryPointRole::Runtime);
1613 assert_eq!(
1614 vitest::VitestPlugin.entry_point_role(),
1615 EntryPointRole::Test
1616 );
1617 assert_eq!(
1618 storybook::StorybookPlugin.entry_point_role(),
1619 EntryPointRole::Support
1620 );
1621 assert_eq!(
1622 obsidian::ObsidianPlugin.entry_point_role(),
1623 EntryPointRole::Runtime
1624 );
1625 assert_eq!(knex::KnexPlugin.entry_point_role(), EntryPointRole::Support);
1626 }
1627
1628 #[test]
1629 fn plugins_with_entry_patterns_have_explicit_role_intent() {
1630 let runtime_or_test_or_support: rustc_hash::FxHashSet<&'static str> =
1631 TEST_ENTRY_POINT_PLUGINS
1632 .iter()
1633 .chain(RUNTIME_ENTRY_POINT_PLUGINS.iter())
1634 .chain(SUPPORT_ENTRY_POINT_PLUGINS.iter())
1635 .copied()
1636 .collect();
1637
1638 for plugin in crate::plugins::registry::builtin::create_builtin_plugins() {
1639 if plugin.entry_patterns().is_empty() {
1640 continue;
1641 }
1642 assert!(
1643 runtime_or_test_or_support.contains(plugin.name()),
1644 "plugin '{}' exposes entry patterns but is missing from the entry-point role map",
1645 plugin.name()
1646 );
1647 }
1648 }
1649
1650 #[test]
1651 fn plugin_result_is_empty_when_default() {
1652 let r = PluginResult::default();
1653 assert!(r.is_empty());
1654 }
1655
1656 #[test]
1657 fn plugin_result_not_empty_with_entry_patterns() {
1658 let r = PluginResult {
1659 entry_patterns: vec!["*.ts".into()],
1660 ..Default::default()
1661 };
1662 assert!(!r.is_empty());
1663 }
1664
1665 #[test]
1666 fn plugin_result_not_empty_with_referenced_deps() {
1667 let r = PluginResult {
1668 referenced_dependencies: vec!["lodash".to_string()],
1669 ..Default::default()
1670 };
1671 assert!(!r.is_empty());
1672 }
1673
1674 #[test]
1675 fn plugin_result_not_empty_with_setup_files() {
1676 let r = PluginResult {
1677 setup_files: vec![PathBuf::from("/setup.ts")],
1678 ..Default::default()
1679 };
1680 assert!(!r.is_empty());
1681 }
1682
1683 #[test]
1684 fn plugin_result_not_empty_with_always_used_files() {
1685 let r = PluginResult {
1686 always_used_files: vec!["**/*.stories.tsx".to_string()],
1687 ..Default::default()
1688 };
1689 assert!(!r.is_empty());
1690 }
1691
1692 #[test]
1693 fn plugin_result_not_empty_with_fixture_patterns() {
1694 let r = PluginResult {
1695 fixture_patterns: vec!["**/__fixtures__/**/*".to_string()],
1696 ..Default::default()
1697 };
1698 assert!(!r.is_empty());
1699 }
1700
1701 #[test]
1702 fn is_enabled_with_deps_prefix_match() {
1703 let plugin = storybook::StorybookPlugin;
1704 let deps = vec!["@storybook/react".to_string()];
1705 assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1706 }
1707
1708 #[test]
1709 fn is_enabled_with_deps_prefix_no_match_without_slash() {
1710 let plugin = storybook::StorybookPlugin;
1711 let deps = vec!["@storybookish".to_string()];
1712 assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1713 }
1714
1715 #[test]
1716 fn is_enabled_with_deps_multiple_enablers() {
1717 let plugin = vitest::VitestPlugin;
1718 let deps_vitest = vec!["vitest".to_string()];
1719 let deps_none = vec!["mocha".to_string()];
1720 assert!(plugin.is_enabled_with_deps(&deps_vitest, Path::new("/project")));
1721 assert!(!plugin.is_enabled_with_deps(&deps_none, Path::new("/project")));
1722 }
1723
1724 #[test]
1725 fn plugin_default_methods_return_empty() {
1726 let plugin = commitizen::CommitizenPlugin;
1727 assert!(
1728 plugin.tooling_dependencies().is_empty() || !plugin.tooling_dependencies().is_empty()
1729 );
1730 assert!(plugin.virtual_module_prefixes().is_empty());
1731 assert!(plugin.virtual_package_suffixes().is_empty());
1732 assert!(plugin.path_aliases(Path::new("/project")).is_empty());
1733 assert!(
1734 plugin.package_json_config_key().is_none()
1735 || plugin.package_json_config_key().is_some()
1736 );
1737 }
1738
1739 #[test]
1740 fn plugin_resolve_config_default_returns_empty() {
1741 let plugin = commitizen::CommitizenPlugin;
1742 let result = plugin.resolve_config(
1743 Path::new("/project/config.js"),
1744 "const x = 1;",
1745 Path::new("/project"),
1746 );
1747 assert!(result.is_empty());
1748 }
1749
1750 #[test]
1751 fn is_enabled_with_deps_exact_and_prefix_both_work() {
1752 let plugin = storybook::StorybookPlugin;
1753 let deps_exact = vec!["storybook".to_string()];
1754 assert!(plugin.is_enabled_with_deps(&deps_exact, Path::new("/project")));
1755 let deps_prefix = vec!["@storybook/vue3".to_string()];
1756 assert!(plugin.is_enabled_with_deps(&deps_prefix, Path::new("/project")));
1757 }
1758
1759 #[test]
1760 fn is_enabled_with_deps_multiple_enablers_remix() {
1761 let plugin = remix::RemixPlugin;
1762 let deps_node = vec!["@remix-run/node".to_string()];
1763 assert!(plugin.is_enabled_with_deps(&deps_node, Path::new("/project")));
1764 let deps_react = vec!["@remix-run/react".to_string()];
1765 assert!(plugin.is_enabled_with_deps(&deps_react, Path::new("/project")));
1766 let deps_cf = vec!["@remix-run/cloudflare".to_string()];
1767 assert!(plugin.is_enabled_with_deps(&deps_cf, Path::new("/project")));
1768 }
1769
1770 struct MinimalPlugin;
1771 impl Plugin for MinimalPlugin {
1772 fn name(&self) -> &'static str {
1773 "minimal"
1774 }
1775 }
1776
1777 #[test]
1778 fn default_enablers_is_empty() {
1779 assert!(MinimalPlugin.enablers().is_empty());
1780 }
1781
1782 #[test]
1783 fn default_entry_patterns_is_empty() {
1784 assert!(MinimalPlugin.entry_patterns().is_empty());
1785 }
1786
1787 #[test]
1788 fn default_config_patterns_is_empty() {
1789 assert!(MinimalPlugin.config_patterns().is_empty());
1790 }
1791
1792 #[test]
1793 fn default_always_used_is_empty() {
1794 assert!(MinimalPlugin.always_used().is_empty());
1795 }
1796
1797 #[test]
1798 fn default_used_exports_is_empty() {
1799 assert!(MinimalPlugin.used_exports().is_empty());
1800 }
1801
1802 #[test]
1803 fn default_tooling_dependencies_is_empty() {
1804 assert!(MinimalPlugin.tooling_dependencies().is_empty());
1805 }
1806
1807 #[test]
1808 fn default_fixture_glob_patterns_is_empty() {
1809 assert!(MinimalPlugin.fixture_glob_patterns().is_empty());
1810 }
1811
1812 #[test]
1813 fn default_virtual_module_prefixes_is_empty() {
1814 assert!(MinimalPlugin.virtual_module_prefixes().is_empty());
1815 }
1816
1817 #[test]
1818 fn default_virtual_package_suffixes_is_empty() {
1819 assert!(MinimalPlugin.virtual_package_suffixes().is_empty());
1820 }
1821
1822 #[test]
1823 fn default_path_aliases_is_empty() {
1824 assert!(MinimalPlugin.path_aliases(Path::new("/")).is_empty());
1825 }
1826
1827 #[test]
1828 fn default_resolve_config_returns_empty() {
1829 let r = MinimalPlugin.resolve_config(
1830 Path::new("config.js"),
1831 "export default {}",
1832 Path::new("/"),
1833 );
1834 assert!(r.is_empty());
1835 }
1836
1837 #[test]
1838 fn default_package_json_metadata_hooks_are_empty() {
1839 let pkg = PackageJson::default();
1840 assert!(!MinimalPlugin.is_enabled_with_package_json(&pkg, Path::new("/")));
1841 assert!(
1842 MinimalPlugin
1843 .resolve_package_json(&pkg, Path::new("/"))
1844 .is_empty()
1845 );
1846 }
1847
1848 #[test]
1849 fn default_package_json_config_key_is_none() {
1850 assert!(MinimalPlugin.package_json_config_key().is_none());
1851 }
1852
1853 #[test]
1854 fn default_is_enabled_returns_false_when_no_enablers() {
1855 let deps = vec!["anything".to_string()];
1856 assert!(!MinimalPlugin.is_enabled_with_deps(&deps, Path::new("/")));
1857 }
1858
1859 #[test]
1860 fn all_builtin_plugin_names_are_unique() {
1861 let plugins = registry::builtin::create_builtin_plugins();
1862 let mut seen = std::collections::BTreeSet::new();
1863 for p in &plugins {
1864 let name = p.name();
1865 assert!(seen.insert(name), "duplicate plugin name: {name}");
1866 }
1867 }
1868
1869 #[test]
1870 fn all_builtin_plugins_have_activation_signals() {
1871 const NON_DEPENDENCY_ACTIVATED_PLUGINS: &[&str] = &["napi-rs", "deno"];
1874 let plugins = registry::builtin::create_builtin_plugins();
1875 for p in &plugins {
1876 assert!(
1877 !p.enablers().is_empty()
1878 || !p.script_enablers().is_empty()
1879 || NON_DEPENDENCY_ACTIVATED_PLUGINS.contains(&p.name()),
1880 "plugin '{}' has no activation signal",
1881 p.name()
1882 );
1883 }
1884 }
1885
1886 #[test]
1887 fn plugins_with_config_patterns_have_always_used() {
1888 let plugins = registry::builtin::create_builtin_plugins();
1889 for p in &plugins {
1890 if !p.config_patterns().is_empty() {
1891 assert!(
1892 !p.always_used().is_empty(),
1893 "plugin '{}' has config_patterns but no always_used",
1894 p.name()
1895 );
1896 }
1897 }
1898 }
1899
1900 #[test]
1901 fn framework_plugins_enablers() {
1902 let cases: Vec<(&dyn Plugin, &[&str])> = vec![
1903 (&nextjs::NextJsPlugin, &["next"]),
1904 (&nuxt::NuxtPlugin, &["nuxt"]),
1905 (&angular::AngularPlugin, &["@angular/core"]),
1906 (&ionic::IonicPlugin, &["@ionic/angular"]),
1907 (&sveltekit::SvelteKitPlugin, &["@sveltejs/kit"]),
1908 (&gatsby::GatsbyPlugin, &["gatsby"]),
1909 ];
1910 for (plugin, expected_enablers) in cases {
1911 let enablers = plugin.enablers();
1912 for expected in expected_enablers {
1913 assert!(
1914 enablers.contains(expected),
1915 "plugin '{}' should have '{}'",
1916 plugin.name(),
1917 expected
1918 );
1919 }
1920 }
1921 }
1922
1923 #[test]
1924 fn testing_plugins_enablers() {
1925 let cases: Vec<(&dyn Plugin, &str)> = vec![
1926 (&jest::JestPlugin, "jest"),
1927 (&vitest::VitestPlugin, "vitest"),
1928 (&playwright::PlaywrightPlugin, "@playwright/test"),
1929 (&cypress::CypressPlugin, "cypress"),
1930 (&mocha::MochaPlugin, "mocha"),
1931 (&stryker::StrykerPlugin, "@stryker-mutator/core"),
1932 ];
1933 for (plugin, enabler) in cases {
1934 assert!(
1935 plugin.enablers().contains(&enabler),
1936 "plugin '{}' should have '{}'",
1937 plugin.name(),
1938 enabler
1939 );
1940 }
1941 }
1942
1943 #[test]
1944 fn bundler_plugins_enablers() {
1945 let cases: Vec<(&dyn Plugin, &str)> = vec![
1946 (&vite::VitePlugin, "vite"),
1947 (&webpack::WebpackPlugin, "webpack"),
1948 (&rollup::RollupPlugin, "rollup"),
1949 ];
1950 for (plugin, enabler) in cases {
1951 assert!(
1952 plugin.enablers().contains(&enabler),
1953 "plugin '{}' should have '{}'",
1954 plugin.name(),
1955 enabler
1956 );
1957 }
1958 }
1959
1960 #[test]
1961 fn test_plugins_have_test_entry_patterns() {
1962 let test_plugins: Vec<&dyn Plugin> = vec![
1963 &bun::BunPlugin,
1964 &deno::DenoPlugin,
1965 &jest::JestPlugin,
1966 &vitest::VitestPlugin,
1967 &mocha::MochaPlugin,
1968 &tap::TapPlugin,
1969 &tsd::TsdPlugin,
1970 ];
1971 for plugin in test_plugins {
1972 let patterns = plugin.entry_patterns();
1973 assert!(
1974 !patterns.is_empty(),
1975 "test plugin '{}' should have entry patterns",
1976 plugin.name()
1977 );
1978 assert!(
1979 patterns
1980 .iter()
1981 .any(|p| p.contains("test") || p.contains("spec") || p.contains("__tests__")),
1982 "test plugin '{}' should have test/spec patterns",
1983 plugin.name()
1984 );
1985 }
1986 }
1987
1988 #[test]
1989 fn framework_plugins_have_entry_patterns() {
1990 let plugins: Vec<&dyn Plugin> = vec![
1991 &nextjs::NextJsPlugin,
1992 &nuxt::NuxtPlugin,
1993 &angular::AngularPlugin,
1994 &sveltekit::SvelteKitPlugin,
1995 ];
1996 for plugin in plugins {
1997 assert!(
1998 !plugin.entry_patterns().is_empty(),
1999 "framework plugin '{}' should have entry patterns",
2000 plugin.name()
2001 );
2002 }
2003 }
2004
2005 #[test]
2006 fn plugins_with_resolve_config_have_config_patterns() {
2007 let plugins: Vec<&dyn Plugin> = vec![
2008 &jest::JestPlugin,
2009 &vitest::VitestPlugin,
2010 &babel::BabelPlugin,
2011 &eslint::EslintPlugin,
2012 &webpack::WebpackPlugin,
2013 &storybook::StorybookPlugin,
2014 &typescript::TypeScriptPlugin,
2015 &postcss::PostCssPlugin,
2016 &nextjs::NextJsPlugin,
2017 &nuxt::NuxtPlugin,
2018 &angular::AngularPlugin,
2019 &nx::NxPlugin,
2020 &stryker::StrykerPlugin,
2021 &wuchale::WuchalePlugin,
2022 &rollup::RollupPlugin,
2023 &sveltekit::SvelteKitPlugin,
2024 &prettier::PrettierPlugin,
2025 &contentlayer::ContentlayerPlugin,
2026 ];
2027 for plugin in plugins {
2028 assert!(
2029 !plugin.config_patterns().is_empty(),
2030 "plugin '{}' with resolve_config should have config_patterns",
2031 plugin.name()
2032 );
2033 }
2034 }
2035
2036 #[test]
2037 fn plugin_tooling_deps_include_enabler_package() {
2038 let plugins: Vec<&dyn Plugin> = vec![
2039 &jest::JestPlugin,
2040 &vitest::VitestPlugin,
2041 &webpack::WebpackPlugin,
2042 &typescript::TypeScriptPlugin,
2043 &eslint::EslintPlugin,
2044 &prettier::PrettierPlugin,
2045 &danger::DangerPlugin,
2046 &stryker::StrykerPlugin,
2047 &wuchale::WuchalePlugin,
2048 &contentlayer::ContentlayerPlugin,
2049 ];
2050 for plugin in plugins {
2051 let tooling = plugin.tooling_dependencies();
2052 let enablers = plugin.enablers();
2053 assert!(
2054 enablers
2055 .iter()
2056 .any(|e| !e.ends_with('/') && tooling.contains(e)),
2057 "plugin '{}': at least one non-prefix enabler should be in tooling_dependencies",
2058 plugin.name()
2059 );
2060 }
2061 }
2062
2063 #[test]
2064 fn nextjs_has_used_exports_for_pages() {
2065 let plugin = nextjs::NextJsPlugin;
2066 let exports = plugin.used_exports();
2067 assert!(!exports.is_empty());
2068 assert!(exports.iter().any(|(_, names)| names.contains(&"default")));
2069 }
2070
2071 #[test]
2072 fn remix_has_used_exports_for_routes() {
2073 let plugin = remix::RemixPlugin;
2074 let exports = plugin.used_exports();
2075 assert!(!exports.is_empty());
2076 let route_entry = exports.iter().find(|(pat, _)| pat.contains("routes"));
2077 assert!(route_entry.is_some());
2078 let (_, names) = route_entry.unwrap();
2079 assert!(names.contains(&"loader"));
2080 assert!(names.contains(&"action"));
2081 assert!(names.contains(&"default"));
2082 }
2083
2084 #[test]
2085 fn sveltekit_has_used_exports_for_routes() {
2086 let plugin = sveltekit::SvelteKitPlugin;
2087 let exports = plugin.used_exports();
2088 assert!(!exports.is_empty());
2089 assert!(exports.iter().any(|(_, names)| names.contains(&"GET")));
2090 }
2091
2092 #[test]
2093 fn nuxt_has_hash_virtual_prefix() {
2094 assert!(nuxt::NuxtPlugin.virtual_module_prefixes().contains(&"#"));
2095 }
2096
2097 #[test]
2098 fn sveltekit_has_dollar_virtual_prefixes() {
2099 let prefixes = sveltekit::SvelteKitPlugin.virtual_module_prefixes();
2100 assert!(prefixes.contains(&"$app/"));
2101 assert!(prefixes.contains(&"$env/"));
2102 assert!(prefixes.contains(&"$lib/"));
2103 }
2104
2105 #[test]
2106 fn sveltekit_has_lib_path_alias() {
2107 let aliases = sveltekit::SvelteKitPlugin.path_aliases(Path::new("/project"));
2108 assert!(aliases.iter().any(|(prefix, _)| *prefix == "$lib/"));
2109 }
2110
2111 #[test]
2112 fn nuxt_has_tilde_path_alias() {
2113 let aliases = nuxt::NuxtPlugin.path_aliases(Path::new("/nonexistent"));
2114 assert!(aliases.iter().any(|(prefix, _)| *prefix == "~/"));
2115 assert!(aliases.iter().any(|(prefix, _)| *prefix == "~~/"));
2116 }
2117
2118 #[test]
2119 fn jest_has_package_json_config_key() {
2120 assert_eq!(jest::JestPlugin.package_json_config_key(), Some("jest"));
2121 }
2122
2123 #[test]
2124 fn tsd_has_package_json_config_key() {
2125 assert_eq!(tsd::TsdPlugin.package_json_config_key(), Some("tsd"));
2126 }
2127
2128 #[test]
2129 fn babel_has_package_json_config_key() {
2130 assert_eq!(babel::BabelPlugin.package_json_config_key(), Some("babel"));
2131 }
2132
2133 #[test]
2134 fn eslint_has_package_json_config_key() {
2135 assert_eq!(
2136 eslint::EslintPlugin.package_json_config_key(),
2137 Some("eslintConfig")
2138 );
2139 }
2140
2141 #[test]
2142 fn prettier_has_package_json_config_key() {
2143 assert_eq!(
2144 prettier::PrettierPlugin.package_json_config_key(),
2145 Some("prettier")
2146 );
2147 }
2148
2149 #[test]
2150 fn macro_generated_plugin_basic_properties() {
2151 let plugin = msw::MswPlugin;
2152 assert_eq!(plugin.name(), "msw");
2153 assert!(plugin.enablers().contains(&"msw"));
2154 assert!(!plugin.entry_patterns().is_empty());
2155 assert!(plugin.config_patterns().is_empty());
2156 assert!(!plugin.always_used().is_empty());
2157 assert!(!plugin.tooling_dependencies().is_empty());
2158 }
2159
2160 #[test]
2161 fn macro_generated_plugin_with_used_exports() {
2162 let plugin = remix::RemixPlugin;
2163 assert_eq!(plugin.name(), "remix");
2164 assert!(!plugin.used_exports().is_empty());
2165 }
2166
2167 #[test]
2168 fn macro_passes_through_virtual_package_suffixes() {
2169 define_plugin! {
2170 struct MacroSuffixSmokePlugin => "macro-suffix-smoke",
2171 enablers: &["macro-suffix-smoke"],
2172 virtual_package_suffixes: &["/__macro_smoke__"],
2173 }
2174
2175 let plugin = MacroSuffixSmokePlugin;
2176 assert_eq!(
2177 plugin.virtual_package_suffixes(),
2178 &["/__macro_smoke__"],
2179 "macro-declared virtual_package_suffixes must propagate to the trait method"
2180 );
2181 }
2182
2183 #[test]
2184 fn macro_generated_plugin_imports_only_resolve_config() {
2185 let plugin = cypress::CypressPlugin;
2186 let source = r"
2187 import { defineConfig } from 'cypress';
2188 import coveragePlugin from '@cypress/code-coverage';
2189 export default defineConfig({});
2190 ";
2191 let result = plugin.resolve_config(
2192 Path::new("cypress.config.ts"),
2193 source,
2194 Path::new("/project"),
2195 );
2196 assert!(
2197 result
2198 .referenced_dependencies
2199 .contains(&"cypress".to_string())
2200 );
2201 assert!(
2202 result
2203 .referenced_dependencies
2204 .contains(&"@cypress/code-coverage".to_string())
2205 );
2206 }
2207
2208 #[test]
2209 fn builtin_plugin_count_is_expected() {
2210 let plugins = registry::builtin::create_builtin_plugins();
2211 assert!(
2212 plugins.len() >= 110,
2213 "expected at least 110 built-in plugins, got {}",
2214 plugins.len()
2215 );
2216 }
2217}