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 "nestjs",
48 "next-intl",
49 "nextjs",
50 "nitro",
51 "nuxt",
52 "obsidian",
53 "parcel",
54 "qwik",
55 "react-native",
56 "react-router",
57 "redwoodsdk",
58 "remix",
59 "rolldown",
60 "rollup",
61 "rsbuild",
62 "rspack",
63 "sanity",
64 "supabase",
65 "sveltekit",
66 "tanstack-router",
67 "tsdown",
68 "tsup",
69 "vite",
70 "vitepress",
71 "webpack",
72 "wrangler",
73 "wxt",
74];
75
76#[cfg(test)]
77const SUPPORT_ENTRY_POINT_PLUGINS: &[&str] = &[
78 "content-collections",
79 "contentlayer",
80 "danger",
81 "drizzle",
82 "fumadocs",
83 "i18next",
84 "knex",
85 "kysely",
86 "mintlify",
87 "msw",
88 "opencode",
89 "prisma",
90 "storybook",
91 "stryker",
92 "typeorm",
93 "velite",
94];
95
96#[derive(Debug, Default)]
98pub struct PluginResult {
99 entry_patterns: Vec<PathRule>,
101 replace_entry_patterns: bool,
106 replace_used_export_rules: bool,
109 used_exports: Vec<UsedExportRule>,
111 used_class_members: Vec<UsedClassMemberRule>,
116 referenced_dependencies: Vec<String>,
118 always_used_files: Vec<String>,
120 path_aliases: Vec<(String, String)>,
122 setup_files: Vec<PathBuf>,
124 fixture_patterns: Vec<String>,
126 scss_include_paths: Vec<PathBuf>,
134 static_dir_mappings: Vec<(PathBuf, String)>,
137 provided_dependencies: Vec<ProvidedDependencyRule>,
140}
141
142impl PluginResult {
143 fn push_entry_pattern(&mut self, pattern: impl Into<String>) {
144 self.entry_patterns
145 .push(PathRule::new(normalize_entry_pattern(pattern.into())));
146 }
147
148 fn extend_entry_patterns<I, S>(&mut self, patterns: I)
149 where
150 I: IntoIterator<Item = S>,
151 S: Into<String>,
152 {
153 self.entry_patterns.extend(
154 patterns
155 .into_iter()
156 .map(|pat| PathRule::new(normalize_entry_pattern(pat.into()))),
157 );
158 }
159
160 fn push_used_export_rule(
161 &mut self,
162 pattern: impl Into<String>,
163 exports: impl IntoIterator<Item = impl Into<String>>,
164 ) {
165 self.used_exports
166 .push(UsedExportRule::new(pattern, exports));
167 }
168
169 #[must_use]
170 const fn is_empty(&self) -> bool {
171 self.entry_patterns.is_empty()
172 && self.used_exports.is_empty()
173 && self.used_class_members.is_empty()
174 && self.referenced_dependencies.is_empty()
175 && self.always_used_files.is_empty()
176 && self.path_aliases.is_empty()
177 && self.setup_files.is_empty()
178 && self.fixture_patterns.is_empty()
179 && self.scss_include_paths.is_empty()
180 && self.static_dir_mappings.is_empty()
181 && self.provided_dependencies.is_empty()
182 }
183}
184
185fn normalize_entry_pattern(pattern: String) -> String {
186 pattern
187 .strip_prefix("./")
188 .map(str::to_owned)
189 .unwrap_or(pattern)
190}
191
192#[derive(Debug, Clone, Default, PartialEq, Eq)]
198pub struct PathRule {
199 pub pattern: String,
200 pub exclude_globs: Vec<String>,
201 pub exclude_regexes: Vec<String>,
202 pub exclude_segment_regexes: Vec<String>,
206}
207
208impl PathRule {
209 #[must_use]
210 pub(crate) fn new(pattern: impl Into<String>) -> Self {
211 Self {
212 pattern: pattern.into(),
213 exclude_globs: Vec::new(),
214 exclude_regexes: Vec::new(),
215 exclude_segment_regexes: Vec::new(),
216 }
217 }
218
219 #[must_use]
220 fn from_static(pattern: &'static str) -> Self {
221 Self::new(pattern)
222 }
223
224 #[must_use]
225 pub(crate) fn with_excluded_globs<I, S>(mut self, patterns: I) -> Self
226 where
227 I: IntoIterator<Item = S>,
228 S: Into<String>,
229 {
230 self.exclude_globs
231 .extend(patterns.into_iter().map(Into::into));
232 self
233 }
234
235 #[must_use]
236 fn with_excluded_regexes<I, S>(mut self, patterns: I) -> Self
237 where
238 I: IntoIterator<Item = S>,
239 S: Into<String>,
240 {
241 self.exclude_regexes
242 .extend(patterns.into_iter().map(Into::into));
243 self
244 }
245
246 #[must_use]
247 fn with_excluded_segment_regexes<I, S>(mut self, patterns: I) -> Self
248 where
249 I: IntoIterator<Item = S>,
250 S: Into<String>,
251 {
252 self.exclude_segment_regexes
253 .extend(patterns.into_iter().map(Into::into));
254 self
255 }
256
257 #[must_use]
258 fn prefixed(&self, ws_prefix: &str) -> Self {
259 Self {
260 pattern: prefix_workspace_pattern(&self.pattern, ws_prefix),
261 exclude_globs: self
262 .exclude_globs
263 .iter()
264 .map(|pattern| prefix_workspace_pattern(pattern, ws_prefix))
265 .collect(),
266 exclude_regexes: self
267 .exclude_regexes
268 .iter()
269 .map(|pattern| prefix_workspace_regex(pattern, ws_prefix))
270 .collect(),
271 exclude_segment_regexes: self.exclude_segment_regexes.clone(),
272 }
273 }
274}
275
276#[derive(Debug, Clone, Default, PartialEq, Eq)]
278pub struct UsedExportRule {
279 pub(crate) path: PathRule,
280 pub(crate) exports: Vec<String>,
281}
282
283impl UsedExportRule {
284 #[must_use]
285 pub(crate) fn new(
286 pattern: impl Into<String>,
287 exports: impl IntoIterator<Item = impl Into<String>>,
288 ) -> Self {
289 Self {
290 path: PathRule::new(pattern),
291 exports: exports.into_iter().map(Into::into).collect(),
292 }
293 }
294
295 #[must_use]
296 fn from_static(pattern: &'static str, exports: &'static [&'static str]) -> Self {
297 Self::new(pattern, exports.iter().copied())
298 }
299
300 #[must_use]
301 fn with_excluded_globs<I, S>(mut self, patterns: I) -> Self
302 where
303 I: IntoIterator<Item = S>,
304 S: Into<String>,
305 {
306 self.path = self.path.with_excluded_globs(patterns);
307 self
308 }
309
310 #[must_use]
311 fn with_excluded_regexes<I, S>(mut self, patterns: I) -> Self
312 where
313 I: IntoIterator<Item = S>,
314 S: Into<String>,
315 {
316 self.path = self.path.with_excluded_regexes(patterns);
317 self
318 }
319
320 #[must_use]
321 fn with_excluded_segment_regexes<I, S>(mut self, patterns: I) -> Self
322 where
323 I: IntoIterator<Item = S>,
324 S: Into<String>,
325 {
326 self.path = self.path.with_excluded_segment_regexes(patterns);
327 self
328 }
329
330 #[must_use]
331 fn prefixed(&self, ws_prefix: &str) -> Self {
332 Self {
333 path: self.path.prefixed(ws_prefix),
334 exports: self.exports.clone(),
335 }
336 }
337}
338
339#[derive(Debug, Clone, PartialEq, Eq)]
341pub struct PluginUsedExportRule {
342 pub(crate) plugin_name: String,
343 pub(crate) rule: UsedExportRule,
344}
345
346impl PluginUsedExportRule {
347 #[must_use]
348 pub(crate) fn new(plugin_name: impl Into<String>, rule: UsedExportRule) -> Self {
349 Self {
350 plugin_name: plugin_name.into(),
351 rule,
352 }
353 }
354
355 #[must_use]
356 fn prefixed(&self, ws_prefix: &str) -> Self {
357 Self {
358 plugin_name: self.plugin_name.clone(),
359 rule: self.rule.prefixed(ws_prefix),
360 }
361 }
362}
363
364#[derive(Debug, Clone, Default, PartialEq, Eq)]
366pub struct ProvidedDependencyRule {
367 pub(crate) path: PathRule,
368 exact_specifiers: Vec<String>,
369 specifier_prefixes: Vec<String>,
370}
371
372impl ProvidedDependencyRule {
373 #[must_use]
374 fn new(
375 pattern: impl Into<String>,
376 exact_specifiers: impl IntoIterator<Item = impl Into<String>>,
377 specifier_prefixes: impl IntoIterator<Item = impl Into<String>>,
378 ) -> Self {
379 Self {
380 path: PathRule::new(pattern),
381 exact_specifiers: exact_specifiers.into_iter().map(Into::into).collect(),
382 specifier_prefixes: specifier_prefixes.into_iter().map(Into::into).collect(),
383 }
384 }
385
386 #[must_use]
387 fn prefixed(&self, ws_prefix: &str) -> Self {
388 Self {
389 path: self.path.prefixed(ws_prefix),
390 exact_specifiers: self.exact_specifiers.clone(),
391 specifier_prefixes: self.specifier_prefixes.clone(),
392 }
393 }
394
395 #[must_use]
396 pub(crate) fn may_cover_package(&self, package_name: &str) -> bool {
397 self.exact_specifiers
398 .iter()
399 .chain(self.specifier_prefixes.iter())
400 .any(|specifier| crate::resolve::extract_package_name(specifier) == package_name)
401 }
402
403 #[must_use]
404 pub(crate) fn covers_specifier(&self, specifier: &str) -> bool {
405 self.exact_specifiers
406 .iter()
407 .any(|allowed| allowed == specifier)
408 || self
409 .specifier_prefixes
410 .iter()
411 .any(|prefix| specifier.starts_with(prefix))
412 }
413}
414
415#[derive(Debug, Clone)]
417pub(crate) struct CompiledPathRule {
418 include: globset::GlobMatcher,
419 exclude_globs: Vec<globset::GlobMatcher>,
420 exclude_regexes: Vec<Regex>,
421 exclude_segment_regexes: Vec<Regex>,
422}
423
424impl CompiledPathRule {
425 pub(crate) fn for_entry_rule(rule: &PathRule, rule_kind: &str) -> Option<Self> {
426 let include = match globset::GlobBuilder::new(&rule.pattern)
427 .literal_separator(true)
428 .build()
429 {
430 Ok(glob) => glob.compile_matcher(),
431 Err(err) => {
432 tracing::warn!("invalid {rule_kind} '{}': {err}", rule.pattern);
433 return None;
434 }
435 };
436 Some(Self {
437 include,
438 exclude_globs: compile_excluded_globs(&rule.exclude_globs, rule_kind, &rule.pattern),
439 exclude_regexes: compile_excluded_regexes(
440 &rule.exclude_regexes,
441 rule_kind,
442 &rule.pattern,
443 ),
444 exclude_segment_regexes: compile_excluded_segment_regexes(
445 &rule.exclude_segment_regexes,
446 rule_kind,
447 &rule.pattern,
448 ),
449 })
450 }
451
452 pub(crate) fn for_used_export_rule(rule: &PathRule, rule_kind: &str) -> Option<Self> {
453 let include = match globset::Glob::new(&rule.pattern) {
454 Ok(glob) => glob.compile_matcher(),
455 Err(err) => {
456 tracing::warn!("invalid {rule_kind} '{}': {err}", rule.pattern);
457 return None;
458 }
459 };
460 Some(Self {
461 include,
462 exclude_globs: compile_excluded_globs(&rule.exclude_globs, rule_kind, &rule.pattern),
463 exclude_regexes: compile_excluded_regexes(
464 &rule.exclude_regexes,
465 rule_kind,
466 &rule.pattern,
467 ),
468 exclude_segment_regexes: compile_excluded_segment_regexes(
469 &rule.exclude_segment_regexes,
470 rule_kind,
471 &rule.pattern,
472 ),
473 })
474 }
475
476 #[must_use]
477 pub(crate) fn matches(&self, path: &str) -> bool {
478 self.include.is_match(path)
479 && !self.exclude_globs.iter().any(|glob| glob.is_match(path))
480 && !self
481 .exclude_regexes
482 .iter()
483 .any(|regex| regex.is_match(path))
484 && !matches_segment_regex(path, &self.exclude_segment_regexes)
485 }
486}
487
488fn prefix_workspace_pattern(pattern: &str, ws_prefix: &str) -> String {
489 if pattern.starts_with(ws_prefix) || pattern.starts_with('/') {
490 pattern.to_string()
491 } else {
492 format!("{ws_prefix}/{pattern}")
493 }
494}
495
496fn prefix_workspace_regex(pattern: &str, ws_prefix: &str) -> String {
497 if let Some(pattern) = pattern.strip_prefix('^') {
498 format!("^{}/{}", regex::escape(ws_prefix), pattern)
499 } else {
500 format!("^{}/(?:{})", regex::escape(ws_prefix), pattern)
501 }
502}
503
504fn compile_excluded_globs(
505 patterns: &[String],
506 rule_kind: &str,
507 rule_pattern: &str,
508) -> Vec<globset::GlobMatcher> {
509 patterns
510 .iter()
511 .filter_map(|pattern| {
512 match globset::GlobBuilder::new(pattern)
513 .literal_separator(true)
514 .build()
515 {
516 Ok(glob) => Some(glob.compile_matcher()),
517 Err(err) => {
518 tracing::warn!(
519 "skipping invalid excluded glob '{}' for {} '{}': {err}",
520 pattern,
521 rule_kind,
522 rule_pattern
523 );
524 None
525 }
526 }
527 })
528 .collect()
529}
530
531fn compile_excluded_regexes(
532 patterns: &[String],
533 rule_kind: &str,
534 rule_pattern: &str,
535) -> Vec<Regex> {
536 patterns
537 .iter()
538 .filter_map(|pattern| match Regex::new(pattern) {
539 Ok(regex) => Some(regex),
540 Err(err) => {
541 tracing::warn!(
542 "skipping invalid excluded regex '{}' for {} '{}': {err}",
543 pattern,
544 rule_kind,
545 rule_pattern
546 );
547 None
548 }
549 })
550 .collect()
551}
552
553fn compile_excluded_segment_regexes(
554 patterns: &[String],
555 rule_kind: &str,
556 rule_pattern: &str,
557) -> Vec<Regex> {
558 patterns
559 .iter()
560 .filter_map(|pattern| match Regex::new(pattern) {
561 Ok(regex) => Some(regex),
562 Err(err) => {
563 tracing::warn!(
564 "skipping invalid excluded segment regex '{}' for {} '{}': {err}",
565 pattern,
566 rule_kind,
567 rule_pattern
568 );
569 None
570 }
571 })
572 .collect()
573}
574
575fn matches_segment_regex(path: &str, regexes: &[Regex]) -> bool {
576 path.split('/')
577 .any(|segment| regexes.iter().any(|regex| regex.is_match(segment)))
578}
579
580impl From<String> for PathRule {
581 fn from(pattern: String) -> Self {
582 Self::new(pattern)
583 }
584}
585
586impl From<&str> for PathRule {
587 fn from(pattern: &str) -> Self {
588 Self::new(pattern)
589 }
590}
591
592impl std::ops::Deref for PathRule {
593 type Target = str;
594
595 fn deref(&self) -> &Self::Target {
596 &self.pattern
597 }
598}
599
600impl PartialEq<&str> for PathRule {
601 fn eq(&self, other: &&str) -> bool {
602 self.pattern == *other
603 }
604}
605
606impl PartialEq<str> for PathRule {
607 fn eq(&self, other: &str) -> bool {
608 self.pattern == other
609 }
610}
611
612impl PartialEq<String> for PathRule {
613 fn eq(&self, other: &String) -> bool {
614 &self.pattern == other
615 }
616}
617
618pub trait Plugin: Send + Sync {
620 fn name(&self) -> &'static str;
622
623 fn enablers(&self) -> &'static [&'static str] {
626 &[]
627 }
628
629 fn is_enabled(&self, pkg: &PackageJson, root: &Path) -> bool {
632 let deps = pkg.all_dependency_names();
633 self.is_enabled_with_deps(&deps, root)
634 }
635
636 fn is_enabled_with_deps(&self, deps: &[String], _root: &Path) -> bool {
639 let enablers = self.enablers();
640 if enablers.is_empty() {
641 return false;
642 }
643 enablers.iter().any(|enabler| {
644 if enabler.ends_with('/') {
645 deps.iter().any(|d| d.starts_with(enabler))
647 } else {
648 deps.iter().any(|d| d == enabler)
649 }
650 })
651 }
652
653 fn is_enabled_with_files(
666 &self,
667 deps: &[String],
668 root: &Path,
669 _discovered_files: &[PathBuf],
670 _candidate_index: Option<®istry::ConfigCandidateIndex>,
671 ) -> bool {
672 self.is_enabled_with_deps(deps, root)
673 }
674
675 fn script_enablers(&self) -> &'static [&'static str] {
677 &[]
678 }
679
680 fn is_enabled_with_scripts(
682 &self,
683 script_packages: &rustc_hash::FxHashSet<String>,
684 _root: &Path,
685 ) -> bool {
686 let enablers = self.script_enablers();
687 if enablers.is_empty() {
688 return false;
689 }
690 enablers.iter().any(|enabler| {
691 if enabler.ends_with('/') {
692 script_packages
693 .iter()
694 .any(|package| package.starts_with(enabler))
695 } else {
696 script_packages.contains(*enabler)
697 }
698 })
699 }
700
701 fn entry_patterns(&self) -> &'static [&'static str] {
703 &[]
704 }
705
706 fn entry_pattern_rules(&self) -> Vec<PathRule> {
708 self.entry_patterns()
709 .iter()
710 .map(|pattern| PathRule::from_static(pattern))
711 .collect()
712 }
713
714 fn entry_point_role(&self) -> EntryPointRole {
719 builtin_entry_point_role(self.name())
720 }
721
722 fn config_patterns(&self) -> &'static [&'static str] {
724 &[]
725 }
726
727 fn always_used(&self) -> &'static [&'static str] {
729 &[]
730 }
731
732 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
734 vec![]
735 }
736
737 fn used_export_rules(&self) -> Vec<UsedExportRule> {
739 self.used_exports()
740 .into_iter()
741 .map(|(pattern, exports)| UsedExportRule::from_static(pattern, exports))
742 .collect()
743 }
744
745 fn used_class_members(&self) -> &'static [&'static str] {
750 &[]
751 }
752
753 fn used_class_member_rules(&self) -> Vec<UsedClassMemberRule> {
761 Vec::new()
762 }
763
764 fn framework_class_member_contracts(&self) -> Vec<SemanticFrameworkContract> {
767 Vec::new()
768 }
769
770 fn fixture_glob_patterns(&self) -> &'static [&'static str] {
775 &[]
776 }
777
778 fn discovery_hidden_dirs(&self) -> &'static [&'static str] {
783 &[]
784 }
785
786 fn tooling_dependencies(&self) -> &'static [&'static str] {
789 &[]
790 }
791
792 fn virtual_module_prefixes(&self) -> &'static [&'static str] {
797 &[]
798 }
799
800 fn virtual_package_suffixes(&self) -> &'static [&'static str] {
806 &[]
807 }
808
809 fn generated_import_patterns(&self) -> &'static [&'static str] {
815 &[]
816 }
817
818 fn generated_type_import_prefixes(&self) -> &'static [&'static str] {
823 &[]
824 }
825
826 fn path_aliases(&self, _root: &Path) -> Vec<(&'static str, String)> {
836 vec![]
837 }
838
839 fn auto_imports(&self, _root: &Path) -> Vec<AutoImportRule> {
852 Vec::new()
853 }
854
855 fn provided_dependencies(&self) -> Vec<ProvidedDependencyRule> {
857 Vec::new()
858 }
859
860 fn is_enabled_with_package_json(&self, _pkg: &PackageJson, _root: &Path) -> bool {
862 false
863 }
864
865 fn resolve_package_json(&self, _pkg: &PackageJson, _root: &Path) -> PluginResult {
867 PluginResult::default()
868 }
869
870 fn package_json_referenced_dependencies(
875 &self,
876 _pkg: &PackageJson,
877 _root: &Path,
878 ) -> Vec<String> {
879 Vec::new()
880 }
881
882 fn resolve_config(&self, _config_path: &Path, _source: &str, _root: &Path) -> PluginResult {
887 PluginResult::default()
888 }
889
890 fn package_json_config_key(&self) -> Option<&'static str> {
895 None
896 }
897}
898
899fn builtin_entry_point_role(name: &str) -> EntryPointRole {
900 if TEST_ENTRY_POINT_PLUGINS.contains(&name) {
901 EntryPointRole::Test
902 } else if RUNTIME_ENTRY_POINT_PLUGINS.contains(&name) {
903 EntryPointRole::Runtime
904 } else {
905 EntryPointRole::Support
906 }
907}
908
909macro_rules! define_plugin {
970 (
971 struct $name:ident => $display:expr,
972 enablers: $enablers:expr
973 $(, entry_patterns: $entry:expr)?
974 $(, config_patterns: $config:expr)?
975 $(, always_used: $always:expr)?
976 $(, tooling_dependencies: $tooling:expr)?
977 $(, fixture_glob_patterns: $fixtures:expr)?
978 $(, discovery_hidden_dirs: $hidden_dirs:expr)?
979 $(, virtual_module_prefixes: $virtual:expr)?
980 $(, virtual_package_suffixes: $virtual_suffixes:expr)?
981 $(, generated_type_import_prefixes: $generated_type_prefixes:expr)?
982 $(, provided_dependencies: $provided_dependencies:expr)?
983 $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
984 , resolve_config: imports_only
985 $(,)?
986 ) => {
987 pub struct $name;
988
989 impl Plugin for $name {
990 fn name(&self) -> &'static str {
991 $display
992 }
993
994 fn enablers(&self) -> &'static [&'static str] {
995 $enablers
996 }
997
998 $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
999 $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
1000 $( fn always_used(&self) -> &'static [&'static str] { $always } )?
1001 $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
1002 $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
1003 $( fn discovery_hidden_dirs(&self) -> &'static [&'static str] { $hidden_dirs } )?
1004 $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
1005 $( fn virtual_package_suffixes(&self) -> &'static [&'static str] { $virtual_suffixes } )?
1006 $( fn generated_type_import_prefixes(&self) -> &'static [&'static str] { $generated_type_prefixes } )?
1007 $( fn provided_dependencies(&self) -> Vec<ProvidedDependencyRule> { $provided_dependencies } )?
1008
1009 $(
1010 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
1011 vec![$( ($pat, $exports) ),*]
1012 }
1013 )?
1014
1015 fn resolve_config(
1016 &self,
1017 config_path: &std::path::Path,
1018 source: &str,
1019 _root: &std::path::Path,
1020 ) -> PluginResult {
1021 let mut result = PluginResult::default();
1022 crate::plugins::add_import_referenced_dependencies(
1023 &mut result,
1024 source,
1025 config_path,
1026 );
1027 result
1028 }
1029 }
1030 };
1031
1032 (
1033 struct $name:ident => $display:expr,
1034 enablers: $enablers:expr
1035 $(, entry_patterns: $entry:expr)?
1036 $(, config_patterns: $config:expr)?
1037 $(, always_used: $always:expr)?
1038 $(, tooling_dependencies: $tooling:expr)?
1039 $(, fixture_glob_patterns: $fixtures:expr)?
1040 $(, discovery_hidden_dirs: $hidden_dirs:expr)?
1041 $(, virtual_module_prefixes: $virtual:expr)?
1042 $(, virtual_package_suffixes: $virtual_suffixes:expr)?
1043 $(, generated_type_import_prefixes: $generated_type_prefixes:expr)?
1044 $(, provided_dependencies: $provided_dependencies:expr)?
1045 $(, package_json_config_key: $pkg_key:expr)?
1046 $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
1047 , resolve_config($cp:ident, $src:ident, $root:ident) $body:block
1048 $(,)?
1049 ) => {
1050 pub struct $name;
1051
1052 impl Plugin for $name {
1053 fn name(&self) -> &'static str {
1054 $display
1055 }
1056
1057 fn enablers(&self) -> &'static [&'static str] {
1058 $enablers
1059 }
1060
1061 $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
1062 $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
1063 $( fn always_used(&self) -> &'static [&'static str] { $always } )?
1064 $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
1065 $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
1066 $( fn discovery_hidden_dirs(&self) -> &'static [&'static str] { $hidden_dirs } )?
1067 $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
1068 $( fn virtual_package_suffixes(&self) -> &'static [&'static str] { $virtual_suffixes } )?
1069 $( fn generated_type_import_prefixes(&self) -> &'static [&'static str] { $generated_type_prefixes } )?
1070 $( fn provided_dependencies(&self) -> Vec<ProvidedDependencyRule> { $provided_dependencies } )?
1071
1072 $(
1073 fn package_json_config_key(&self) -> Option<&'static str> {
1074 Some($pkg_key)
1075 }
1076 )?
1077
1078 $(
1079 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
1080 vec![$( ($pat, $exports) ),*]
1081 }
1082 )?
1083
1084 fn resolve_config(
1085 &self,
1086 $cp: &std::path::Path,
1087 $src: &str,
1088 $root: &std::path::Path,
1089 ) -> PluginResult
1090 $body
1091 }
1092 };
1093
1094 (
1095 struct $name:ident => $display:expr,
1096 enablers: $enablers:expr
1097 $(, entry_patterns: $entry:expr)?
1098 $(, config_patterns: $config:expr)?
1099 $(, always_used: $always:expr)?
1100 $(, tooling_dependencies: $tooling:expr)?
1101 $(, fixture_glob_patterns: $fixtures:expr)?
1102 $(, discovery_hidden_dirs: $hidden_dirs:expr)?
1103 $(, virtual_module_prefixes: $virtual:expr)?
1104 $(, virtual_package_suffixes: $virtual_suffixes:expr)?
1105 $(, generated_type_import_prefixes: $generated_type_prefixes:expr)?
1106 $(, provided_dependencies: $provided_dependencies:expr)?
1107 $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
1108 $(,)?
1109 ) => {
1110 pub struct $name;
1111
1112 impl Plugin for $name {
1113 fn name(&self) -> &'static str {
1114 $display
1115 }
1116
1117 fn enablers(&self) -> &'static [&'static str] {
1118 $enablers
1119 }
1120
1121 $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
1122 $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
1123 $( fn always_used(&self) -> &'static [&'static str] { $always } )?
1124 $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
1125 $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
1126 $( fn discovery_hidden_dirs(&self) -> &'static [&'static str] { $hidden_dirs } )?
1127 $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
1128 $( fn virtual_package_suffixes(&self) -> &'static [&'static str] { $virtual_suffixes } )?
1129 $( fn generated_type_import_prefixes(&self) -> &'static [&'static str] { $generated_type_prefixes } )?
1130 $( fn provided_dependencies(&self) -> Vec<ProvidedDependencyRule> { $provided_dependencies } )?
1131
1132 $(
1133 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
1134 vec![$( ($pat, $exports) ),*]
1135 }
1136 )?
1137 }
1138 };
1139}
1140
1141pub mod config_parser;
1142mod config_value_credits;
1143mod manifest;
1144pub mod manifest_entries;
1145pub mod registry;
1146mod tooling;
1147
1148pub use registry::{AggregatedPluginResult, PluginRegistry};
1149pub(crate) use tooling::is_known_tooling_dependency;
1150
1151fn add_import_referenced_dependencies(result: &mut PluginResult, source: &str, config_path: &Path) {
1152 let imports = config_parser::extract_imports(source, config_path);
1153 for import in &imports {
1154 result
1155 .referenced_dependencies
1156 .push(crate::resolve::extract_package_name(import));
1157 }
1158}
1159
1160fn credit_environment_optional_peers(environment: &str, result: &mut PluginResult) {
1172 credit_config_value(
1173 config_value_credits::CreditSurface::TestEnvironmentOptionalPeer,
1174 canonical_test_environment(environment),
1175 result,
1176 );
1177}
1178
1179fn credit_config_value(
1184 surface: config_value_credits::CreditSurface,
1185 value: &str,
1186 result: &mut PluginResult,
1187) -> bool {
1188 let Some(packages) = config_value_credits::credited_packages(surface, value) else {
1189 return false;
1190 };
1191 result
1192 .referenced_dependencies
1193 .extend(packages.iter().cloned());
1194 true
1195}
1196
1197fn canonical_test_environment(environment: &str) -> &str {
1205 environment
1206 .strip_prefix("jest-environment-")
1207 .or_else(|| environment.strip_prefix("vitest-environment-"))
1208 .unwrap_or(environment)
1209}
1210
1211mod adonis;
1212mod angular;
1213mod astro;
1214mod ava;
1215mod babel;
1216mod biome;
1217mod browser_extension;
1218mod bun;
1219mod c8;
1220mod capacitor;
1221mod changesets;
1222mod commit_and_tag_version;
1223mod commitizen;
1224mod commitlint;
1225mod content_collections;
1226mod contentlayer;
1227mod convex;
1228mod cspell;
1229mod cucumber;
1230mod cypress;
1231mod danger;
1232mod deno;
1233mod dependency_cruiser;
1234mod docusaurus;
1235mod drizzle;
1236mod electron;
1237mod ember;
1238mod eslint;
1239mod expo;
1240mod expo_router;
1241mod firebase;
1242mod fumadocs;
1243mod gatsby;
1244mod graphql_codegen;
1245mod hardhat;
1246mod husky;
1247mod i18next;
1248mod ionic;
1249mod jest;
1250mod k6;
1251mod karma;
1252mod knex;
1253mod kysely;
1254mod lefthook;
1255mod lexical;
1256mod lint_staged;
1257mod lit;
1258mod markdownlint;
1259mod mintlify;
1260mod mocha;
1261mod msw;
1262mod napi_rs;
1263mod nestjs;
1264mod next_intl;
1265mod nextjs;
1266mod nitro;
1267mod nodemon;
1268pub(crate) mod nuxt;
1269mod nx;
1270mod nyc;
1271mod obsidian;
1272mod openapi_ts;
1273mod opencode;
1274mod opennext_cloudflare;
1275mod oxlint;
1276mod pandacss;
1277mod parcel;
1278mod pinia;
1279mod pkg_utils;
1280mod playwright;
1281mod plop;
1282mod pm2;
1283mod pnpm;
1284mod postcss;
1285mod prettier;
1286mod prisma;
1287mod qwik;
1288mod react_compiler;
1289mod react_native;
1290mod react_router;
1291mod redwoodsdk;
1292mod relay;
1293mod remark;
1294mod remix;
1295mod rolldown;
1296mod rollup;
1297mod rsbuild;
1298mod rspack;
1299mod rspress;
1300mod sanity;
1301mod semantic_release;
1302mod sentry;
1303mod simple_git_hooks;
1304mod storybook;
1305mod stryker;
1306mod stylelint;
1307mod supabase;
1308mod sveltekit;
1309mod svgo;
1310mod svgr;
1311mod swc;
1312mod syncpack;
1313mod tailwind;
1314mod tanstack_router;
1315mod tap;
1316mod test_alias;
1317mod tsd;
1318mod tsdown;
1319mod tsup;
1320mod turborepo;
1321mod typedoc;
1322mod typeorm;
1323mod typescript;
1324mod unocss;
1325mod varlock;
1326mod velite;
1327mod vercel;
1328mod vite;
1329mod vitepress;
1330mod vitest;
1331mod vscode;
1332mod webdriverio;
1333mod webpack;
1334mod wrangler;
1335mod wuchale;
1336mod wxt;
1337
1338#[cfg(test)]
1339mod tests {
1340 use super::*;
1341 use std::path::Path;
1342
1343 #[test]
1344 fn is_enabled_with_deps_exact_match() {
1345 let plugin = nextjs::NextJsPlugin;
1346 let deps = vec!["next".to_string()];
1347 assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1348 }
1349
1350 #[test]
1351 fn is_enabled_with_deps_no_match() {
1352 let plugin = nextjs::NextJsPlugin;
1353 let deps = vec!["react".to_string()];
1354 assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1355 }
1356
1357 #[test]
1358 fn is_enabled_with_deps_empty_deps() {
1359 let plugin = nextjs::NextJsPlugin;
1360 let deps: Vec<String> = vec![];
1361 assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1362 }
1363
1364 #[test]
1365 fn environment_optional_peers_come_from_the_credit_catalogue() {
1366 for environment in [
1367 "jsdom",
1368 "jest-environment-jsdom",
1369 "vitest-environment-jsdom",
1370 ] {
1371 let mut result = PluginResult::default();
1372 credit_environment_optional_peers(environment, &mut result);
1373 assert_eq!(
1374 result.referenced_dependencies,
1375 vec!["canvas".to_string()],
1376 "expected the catalogue credit for {environment}"
1377 );
1378 }
1379 }
1380
1381 #[test]
1382 fn environment_without_a_catalogue_row_credits_nothing() {
1383 let mut result = PluginResult::default();
1384 credit_environment_optional_peers("happy-dom", &mut result);
1385 assert!(result.referenced_dependencies.is_empty());
1386 }
1387
1388 #[test]
1389 fn entry_point_role_defaults_are_centralized() {
1390 assert_eq!(vite::VitePlugin.entry_point_role(), EntryPointRole::Runtime);
1391 assert_eq!(
1392 vitest::VitestPlugin.entry_point_role(),
1393 EntryPointRole::Test
1394 );
1395 assert_eq!(
1396 storybook::StorybookPlugin.entry_point_role(),
1397 EntryPointRole::Support
1398 );
1399 assert_eq!(
1400 obsidian::ObsidianPlugin.entry_point_role(),
1401 EntryPointRole::Runtime
1402 );
1403 assert_eq!(knex::KnexPlugin.entry_point_role(), EntryPointRole::Support);
1404 }
1405
1406 #[test]
1407 fn plugins_with_entry_patterns_have_explicit_role_intent() {
1408 let runtime_or_test_or_support: rustc_hash::FxHashSet<&'static str> =
1409 TEST_ENTRY_POINT_PLUGINS
1410 .iter()
1411 .chain(RUNTIME_ENTRY_POINT_PLUGINS.iter())
1412 .chain(SUPPORT_ENTRY_POINT_PLUGINS.iter())
1413 .copied()
1414 .collect();
1415
1416 for plugin in crate::plugins::registry::builtin::create_builtin_plugins() {
1417 if plugin.entry_patterns().is_empty() {
1418 continue;
1419 }
1420 assert!(
1421 runtime_or_test_or_support.contains(plugin.name()),
1422 "plugin '{}' exposes entry patterns but is missing from the entry-point role map",
1423 plugin.name()
1424 );
1425 }
1426 }
1427
1428 #[test]
1429 fn plugin_result_is_empty_when_default() {
1430 let r = PluginResult::default();
1431 assert!(r.is_empty());
1432 }
1433
1434 #[test]
1435 fn plugin_result_not_empty_with_entry_patterns() {
1436 let r = PluginResult {
1437 entry_patterns: vec!["*.ts".into()],
1438 ..Default::default()
1439 };
1440 assert!(!r.is_empty());
1441 }
1442
1443 #[test]
1444 fn plugin_result_not_empty_with_referenced_deps() {
1445 let r = PluginResult {
1446 referenced_dependencies: vec!["lodash".to_string()],
1447 ..Default::default()
1448 };
1449 assert!(!r.is_empty());
1450 }
1451
1452 #[test]
1453 fn plugin_result_not_empty_with_setup_files() {
1454 let r = PluginResult {
1455 setup_files: vec![PathBuf::from("/setup.ts")],
1456 ..Default::default()
1457 };
1458 assert!(!r.is_empty());
1459 }
1460
1461 #[test]
1462 fn plugin_result_not_empty_with_always_used_files() {
1463 let r = PluginResult {
1464 always_used_files: vec!["**/*.stories.tsx".to_string()],
1465 ..Default::default()
1466 };
1467 assert!(!r.is_empty());
1468 }
1469
1470 #[test]
1471 fn plugin_result_not_empty_with_fixture_patterns() {
1472 let r = PluginResult {
1473 fixture_patterns: vec!["**/__fixtures__/**/*".to_string()],
1474 ..Default::default()
1475 };
1476 assert!(!r.is_empty());
1477 }
1478
1479 #[test]
1480 fn is_enabled_with_deps_prefix_match() {
1481 let plugin = storybook::StorybookPlugin;
1482 let deps = vec!["@storybook/react".to_string()];
1483 assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1484 }
1485
1486 #[test]
1487 fn is_enabled_with_deps_prefix_no_match_without_slash() {
1488 let plugin = storybook::StorybookPlugin;
1489 let deps = vec!["@storybookish".to_string()];
1490 assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1491 }
1492
1493 #[test]
1494 fn is_enabled_with_deps_multiple_enablers() {
1495 let plugin = vitest::VitestPlugin;
1496 let deps_vitest = vec!["vitest".to_string()];
1497 let deps_none = vec!["mocha".to_string()];
1498 assert!(plugin.is_enabled_with_deps(&deps_vitest, Path::new("/project")));
1499 assert!(!plugin.is_enabled_with_deps(&deps_none, Path::new("/project")));
1500 }
1501
1502 #[test]
1503 fn plugin_default_methods_return_empty() {
1504 let plugin = commitizen::CommitizenPlugin;
1505 assert!(
1506 plugin.tooling_dependencies().is_empty() || !plugin.tooling_dependencies().is_empty()
1507 );
1508 assert!(plugin.virtual_module_prefixes().is_empty());
1509 assert!(plugin.virtual_package_suffixes().is_empty());
1510 assert!(plugin.path_aliases(Path::new("/project")).is_empty());
1511 assert!(
1512 plugin.package_json_config_key().is_none()
1513 || plugin.package_json_config_key().is_some()
1514 );
1515 }
1516
1517 #[test]
1518 fn plugin_resolve_config_default_returns_empty() {
1519 let plugin = commitizen::CommitizenPlugin;
1520 let result = plugin.resolve_config(
1521 Path::new("/project/config.js"),
1522 "const x = 1;",
1523 Path::new("/project"),
1524 );
1525 assert!(result.is_empty());
1526 }
1527
1528 #[test]
1529 fn is_enabled_with_deps_exact_and_prefix_both_work() {
1530 let plugin = storybook::StorybookPlugin;
1531 let deps_exact = vec!["storybook".to_string()];
1532 assert!(plugin.is_enabled_with_deps(&deps_exact, Path::new("/project")));
1533 let deps_prefix = vec!["@storybook/vue3".to_string()];
1534 assert!(plugin.is_enabled_with_deps(&deps_prefix, Path::new("/project")));
1535 }
1536
1537 #[test]
1538 fn is_enabled_with_deps_multiple_enablers_remix() {
1539 let plugin = remix::RemixPlugin;
1540 let deps_node = vec!["@remix-run/node".to_string()];
1541 assert!(plugin.is_enabled_with_deps(&deps_node, Path::new("/project")));
1542 let deps_react = vec!["@remix-run/react".to_string()];
1543 assert!(plugin.is_enabled_with_deps(&deps_react, Path::new("/project")));
1544 let deps_cf = vec!["@remix-run/cloudflare".to_string()];
1545 assert!(plugin.is_enabled_with_deps(&deps_cf, Path::new("/project")));
1546 }
1547
1548 struct MinimalPlugin;
1549 impl Plugin for MinimalPlugin {
1550 fn name(&self) -> &'static str {
1551 "minimal"
1552 }
1553 }
1554
1555 #[test]
1556 fn default_enablers_is_empty() {
1557 assert!(MinimalPlugin.enablers().is_empty());
1558 }
1559
1560 #[test]
1561 fn default_entry_patterns_is_empty() {
1562 assert!(MinimalPlugin.entry_patterns().is_empty());
1563 }
1564
1565 #[test]
1566 fn default_config_patterns_is_empty() {
1567 assert!(MinimalPlugin.config_patterns().is_empty());
1568 }
1569
1570 #[test]
1571 fn default_always_used_is_empty() {
1572 assert!(MinimalPlugin.always_used().is_empty());
1573 }
1574
1575 #[test]
1576 fn default_used_exports_is_empty() {
1577 assert!(MinimalPlugin.used_exports().is_empty());
1578 }
1579
1580 #[test]
1581 fn default_tooling_dependencies_is_empty() {
1582 assert!(MinimalPlugin.tooling_dependencies().is_empty());
1583 }
1584
1585 #[test]
1586 fn default_fixture_glob_patterns_is_empty() {
1587 assert!(MinimalPlugin.fixture_glob_patterns().is_empty());
1588 }
1589
1590 #[test]
1591 fn default_virtual_module_prefixes_is_empty() {
1592 assert!(MinimalPlugin.virtual_module_prefixes().is_empty());
1593 }
1594
1595 #[test]
1596 fn default_virtual_package_suffixes_is_empty() {
1597 assert!(MinimalPlugin.virtual_package_suffixes().is_empty());
1598 }
1599
1600 #[test]
1601 fn default_path_aliases_is_empty() {
1602 assert!(MinimalPlugin.path_aliases(Path::new("/")).is_empty());
1603 }
1604
1605 #[test]
1606 fn default_resolve_config_returns_empty() {
1607 let r = MinimalPlugin.resolve_config(
1608 Path::new("config.js"),
1609 "export default {}",
1610 Path::new("/"),
1611 );
1612 assert!(r.is_empty());
1613 }
1614
1615 #[test]
1616 fn default_package_json_metadata_hooks_are_empty() {
1617 let pkg = PackageJson::default();
1618 assert!(!MinimalPlugin.is_enabled_with_package_json(&pkg, Path::new("/")));
1619 assert!(
1620 MinimalPlugin
1621 .resolve_package_json(&pkg, Path::new("/"))
1622 .is_empty()
1623 );
1624 }
1625
1626 #[test]
1627 fn default_package_json_config_key_is_none() {
1628 assert!(MinimalPlugin.package_json_config_key().is_none());
1629 }
1630
1631 #[test]
1632 fn default_is_enabled_returns_false_when_no_enablers() {
1633 let deps = vec!["anything".to_string()];
1634 assert!(!MinimalPlugin.is_enabled_with_deps(&deps, Path::new("/")));
1635 }
1636
1637 #[test]
1638 fn all_builtin_plugin_names_are_unique() {
1639 let plugins = registry::builtin::create_builtin_plugins();
1640 let mut seen = std::collections::BTreeSet::new();
1641 for p in &plugins {
1642 let name = p.name();
1643 assert!(seen.insert(name), "duplicate plugin name: {name}");
1644 }
1645 }
1646
1647 #[test]
1648 fn all_builtin_plugins_have_activation_signals() {
1649 const NON_DEPENDENCY_ACTIVATED_PLUGINS: &[&str] = &["napi-rs", "deno"];
1652 let plugins = registry::builtin::create_builtin_plugins();
1653 for p in &plugins {
1654 assert!(
1655 !p.enablers().is_empty()
1656 || !p.script_enablers().is_empty()
1657 || NON_DEPENDENCY_ACTIVATED_PLUGINS.contains(&p.name()),
1658 "plugin '{}' has no activation signal",
1659 p.name()
1660 );
1661 }
1662 }
1663
1664 #[test]
1665 fn plugins_with_config_patterns_have_always_used() {
1666 let plugins = registry::builtin::create_builtin_plugins();
1667 for p in &plugins {
1668 if !p.config_patterns().is_empty() {
1669 assert!(
1670 !p.always_used().is_empty(),
1671 "plugin '{}' has config_patterns but no always_used",
1672 p.name()
1673 );
1674 }
1675 }
1676 }
1677
1678 #[test]
1679 fn framework_plugins_enablers() {
1680 let cases: Vec<(&dyn Plugin, &[&str])> = vec![
1681 (&nextjs::NextJsPlugin, &["next"]),
1682 (&nuxt::NuxtPlugin, &["nuxt"]),
1683 (&angular::AngularPlugin, &["@angular/core"]),
1684 (&ionic::IonicPlugin, &["@ionic/angular"]),
1685 (&sveltekit::SvelteKitPlugin, &["@sveltejs/kit"]),
1686 (&gatsby::GatsbyPlugin, &["gatsby"]),
1687 ];
1688 for (plugin, expected_enablers) in cases {
1689 let enablers = plugin.enablers();
1690 for expected in expected_enablers {
1691 assert!(
1692 enablers.contains(expected),
1693 "plugin '{}' should have '{}'",
1694 plugin.name(),
1695 expected
1696 );
1697 }
1698 }
1699 }
1700
1701 #[test]
1702 fn testing_plugins_enablers() {
1703 let cases: Vec<(&dyn Plugin, &str)> = vec![
1704 (&jest::JestPlugin, "jest"),
1705 (&vitest::VitestPlugin, "vitest"),
1706 (&playwright::PlaywrightPlugin, "@playwright/test"),
1707 (&cypress::CypressPlugin, "cypress"),
1708 (&mocha::MochaPlugin, "mocha"),
1709 (&stryker::StrykerPlugin, "@stryker-mutator/core"),
1710 ];
1711 for (plugin, enabler) in cases {
1712 assert!(
1713 plugin.enablers().contains(&enabler),
1714 "plugin '{}' should have '{}'",
1715 plugin.name(),
1716 enabler
1717 );
1718 }
1719 }
1720
1721 #[test]
1722 fn bundler_plugins_enablers() {
1723 let cases: Vec<(&dyn Plugin, &str)> = vec![
1724 (&vite::VitePlugin, "vite"),
1725 (&webpack::WebpackPlugin, "webpack"),
1726 (&rollup::RollupPlugin, "rollup"),
1727 ];
1728 for (plugin, enabler) in cases {
1729 assert!(
1730 plugin.enablers().contains(&enabler),
1731 "plugin '{}' should have '{}'",
1732 plugin.name(),
1733 enabler
1734 );
1735 }
1736 }
1737
1738 #[test]
1739 fn test_plugins_have_test_entry_patterns() {
1740 let test_plugins: Vec<&dyn Plugin> = vec![
1741 &bun::BunPlugin,
1742 &deno::DenoPlugin,
1743 &jest::JestPlugin,
1744 &vitest::VitestPlugin,
1745 &mocha::MochaPlugin,
1746 &tap::TapPlugin,
1747 &tsd::TsdPlugin,
1748 ];
1749 for plugin in test_plugins {
1750 let patterns = plugin.entry_patterns();
1751 assert!(
1752 !patterns.is_empty(),
1753 "test plugin '{}' should have entry patterns",
1754 plugin.name()
1755 );
1756 assert!(
1757 patterns
1758 .iter()
1759 .any(|p| p.contains("test") || p.contains("spec") || p.contains("__tests__")),
1760 "test plugin '{}' should have test/spec patterns",
1761 plugin.name()
1762 );
1763 }
1764 }
1765
1766 #[test]
1767 fn framework_plugins_have_entry_patterns() {
1768 let plugins: Vec<&dyn Plugin> = vec![
1769 &nextjs::NextJsPlugin,
1770 &nuxt::NuxtPlugin,
1771 &angular::AngularPlugin,
1772 &sveltekit::SvelteKitPlugin,
1773 ];
1774 for plugin in plugins {
1775 assert!(
1776 !plugin.entry_patterns().is_empty(),
1777 "framework plugin '{}' should have entry patterns",
1778 plugin.name()
1779 );
1780 }
1781 }
1782
1783 #[test]
1784 fn plugins_with_resolve_config_have_config_patterns() {
1785 let plugins: Vec<&dyn Plugin> = vec![
1786 &jest::JestPlugin,
1787 &vitest::VitestPlugin,
1788 &babel::BabelPlugin,
1789 &eslint::EslintPlugin,
1790 &webpack::WebpackPlugin,
1791 &storybook::StorybookPlugin,
1792 &typescript::TypeScriptPlugin,
1793 &postcss::PostCssPlugin,
1794 &nextjs::NextJsPlugin,
1795 &nuxt::NuxtPlugin,
1796 &angular::AngularPlugin,
1797 &nx::NxPlugin,
1798 &stryker::StrykerPlugin,
1799 &wuchale::WuchalePlugin,
1800 &rollup::RollupPlugin,
1801 &sveltekit::SvelteKitPlugin,
1802 &prettier::PrettierPlugin,
1803 &contentlayer::ContentlayerPlugin,
1804 ];
1805 for plugin in plugins {
1806 assert!(
1807 !plugin.config_patterns().is_empty(),
1808 "plugin '{}' with resolve_config should have config_patterns",
1809 plugin.name()
1810 );
1811 }
1812 }
1813
1814 #[test]
1815 fn plugin_tooling_deps_include_enabler_package() {
1816 let plugins: Vec<&dyn Plugin> = vec![
1817 &jest::JestPlugin,
1818 &vitest::VitestPlugin,
1819 &webpack::WebpackPlugin,
1820 &typescript::TypeScriptPlugin,
1821 &eslint::EslintPlugin,
1822 &prettier::PrettierPlugin,
1823 &danger::DangerPlugin,
1824 &stryker::StrykerPlugin,
1825 &wuchale::WuchalePlugin,
1826 &contentlayer::ContentlayerPlugin,
1827 ];
1828 for plugin in plugins {
1829 let tooling = plugin.tooling_dependencies();
1830 let enablers = plugin.enablers();
1831 assert!(
1832 enablers
1833 .iter()
1834 .any(|e| !e.ends_with('/') && tooling.contains(e)),
1835 "plugin '{}': at least one non-prefix enabler should be in tooling_dependencies",
1836 plugin.name()
1837 );
1838 }
1839 }
1840
1841 #[test]
1842 fn nextjs_has_used_exports_for_pages() {
1843 let plugin = nextjs::NextJsPlugin;
1844 let exports = plugin.used_exports();
1845 assert!(!exports.is_empty());
1846 assert!(exports.iter().any(|(_, names)| names.contains(&"default")));
1847 }
1848
1849 #[test]
1850 fn remix_has_used_exports_for_routes() {
1851 let plugin = remix::RemixPlugin;
1852 let exports = plugin.used_exports();
1853 assert!(!exports.is_empty());
1854 let route_entry = exports.iter().find(|(pat, _)| pat.contains("routes"));
1855 assert!(route_entry.is_some());
1856 let (_, names) = route_entry.unwrap();
1857 assert!(names.contains(&"loader"));
1858 assert!(names.contains(&"action"));
1859 assert!(names.contains(&"default"));
1860 }
1861
1862 #[test]
1863 fn sveltekit_has_used_exports_for_routes() {
1864 let plugin = sveltekit::SvelteKitPlugin;
1865 let exports = plugin.used_exports();
1866 assert!(!exports.is_empty());
1867 assert!(exports.iter().any(|(_, names)| names.contains(&"GET")));
1868 }
1869
1870 #[test]
1871 fn nuxt_has_hash_virtual_prefix() {
1872 assert!(nuxt::NuxtPlugin.virtual_module_prefixes().contains(&"#"));
1873 }
1874
1875 #[test]
1876 fn sveltekit_has_dollar_virtual_prefixes() {
1877 let prefixes = sveltekit::SvelteKitPlugin.virtual_module_prefixes();
1878 assert!(prefixes.contains(&"$app/"));
1879 assert!(prefixes.contains(&"$env/"));
1880 assert!(prefixes.contains(&"$lib/"));
1881 }
1882
1883 #[test]
1884 fn sveltekit_has_lib_path_alias() {
1885 let aliases = sveltekit::SvelteKitPlugin.path_aliases(Path::new("/project"));
1886 assert!(aliases.iter().any(|(prefix, _)| *prefix == "$lib/"));
1887 }
1888
1889 #[test]
1890 fn nuxt_has_tilde_path_alias() {
1891 let aliases = nuxt::NuxtPlugin.path_aliases(Path::new("/nonexistent"));
1892 assert!(aliases.iter().any(|(prefix, _)| *prefix == "~/"));
1893 assert!(aliases.iter().any(|(prefix, _)| *prefix == "~~/"));
1894 }
1895
1896 #[test]
1897 fn jest_has_package_json_config_key() {
1898 assert_eq!(jest::JestPlugin.package_json_config_key(), Some("jest"));
1899 }
1900
1901 #[test]
1902 fn tsd_has_package_json_config_key() {
1903 assert_eq!(tsd::TsdPlugin.package_json_config_key(), Some("tsd"));
1904 }
1905
1906 #[test]
1907 fn babel_has_package_json_config_key() {
1908 assert_eq!(babel::BabelPlugin.package_json_config_key(), Some("babel"));
1909 }
1910
1911 #[test]
1912 fn eslint_has_package_json_config_key() {
1913 assert_eq!(
1914 eslint::EslintPlugin.package_json_config_key(),
1915 Some("eslintConfig")
1916 );
1917 }
1918
1919 #[test]
1920 fn prettier_has_package_json_config_key() {
1921 assert_eq!(
1922 prettier::PrettierPlugin.package_json_config_key(),
1923 Some("prettier")
1924 );
1925 }
1926
1927 #[test]
1928 fn macro_generated_plugin_basic_properties() {
1929 let plugin = msw::MswPlugin;
1930 assert_eq!(plugin.name(), "msw");
1931 assert!(plugin.enablers().contains(&"msw"));
1932 assert!(!plugin.entry_patterns().is_empty());
1933 assert!(plugin.config_patterns().is_empty());
1934 assert!(!plugin.always_used().is_empty());
1935 assert!(!plugin.tooling_dependencies().is_empty());
1936 }
1937
1938 #[test]
1939 fn macro_generated_plugin_with_used_exports() {
1940 let plugin = remix::RemixPlugin;
1941 assert_eq!(plugin.name(), "remix");
1942 assert!(!plugin.used_exports().is_empty());
1943 }
1944
1945 #[test]
1946 fn macro_passes_through_virtual_package_suffixes() {
1947 define_plugin! {
1948 struct MacroSuffixSmokePlugin => "macro-suffix-smoke",
1949 enablers: &["macro-suffix-smoke"],
1950 virtual_package_suffixes: &["/__macro_smoke__"],
1951 }
1952
1953 let plugin = MacroSuffixSmokePlugin;
1954 assert_eq!(
1955 plugin.virtual_package_suffixes(),
1956 &["/__macro_smoke__"],
1957 "macro-declared virtual_package_suffixes must propagate to the trait method"
1958 );
1959 }
1960
1961 #[test]
1962 fn macro_generated_plugin_imports_only_resolve_config() {
1963 let plugin = cypress::CypressPlugin;
1964 let source = r"
1965 import { defineConfig } from 'cypress';
1966 import coveragePlugin from '@cypress/code-coverage';
1967 export default defineConfig({});
1968 ";
1969 let result = plugin.resolve_config(
1970 Path::new("cypress.config.ts"),
1971 source,
1972 Path::new("/project"),
1973 );
1974 assert!(
1975 result
1976 .referenced_dependencies
1977 .contains(&"cypress".to_string())
1978 );
1979 assert!(
1980 result
1981 .referenced_dependencies
1982 .contains(&"@cypress/code-coverage".to_string())
1983 );
1984 }
1985
1986 #[test]
1987 fn builtin_plugin_count_is_expected() {
1988 let plugins = registry::builtin::create_builtin_plugins();
1989 assert!(
1990 plugins.len() >= 110,
1991 "expected at least 110 built-in plugins, got {}",
1992 plugins.len()
1993 );
1994 }
1995}