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 size_limit;
1305mod storybook;
1306mod stryker;
1307mod stylelint;
1308mod supabase;
1309mod sveltekit;
1310mod svgo;
1311mod svgr;
1312mod swc;
1313mod syncpack;
1314mod tailwind;
1315mod tanstack_router;
1316mod tap;
1317mod test_alias;
1318mod tsd;
1319mod tsdown;
1320mod tsup;
1321mod turborepo;
1322mod typedoc;
1323mod typeorm;
1324mod typescript;
1325mod unocss;
1326mod varlock;
1327mod velite;
1328mod vercel;
1329mod vite;
1330mod vitepress;
1331mod vitest;
1332mod vscode;
1333mod webdriverio;
1334mod webpack;
1335mod wrangler;
1336mod wuchale;
1337mod wxt;
1338
1339#[cfg(test)]
1340mod tests {
1341 use super::*;
1342 use std::path::Path;
1343
1344 #[test]
1345 fn is_enabled_with_deps_exact_match() {
1346 let plugin = nextjs::NextJsPlugin;
1347 let deps = vec!["next".to_string()];
1348 assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1349 }
1350
1351 #[test]
1352 fn is_enabled_with_deps_no_match() {
1353 let plugin = nextjs::NextJsPlugin;
1354 let deps = vec!["react".to_string()];
1355 assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1356 }
1357
1358 #[test]
1359 fn is_enabled_with_deps_empty_deps() {
1360 let plugin = nextjs::NextJsPlugin;
1361 let deps: Vec<String> = vec![];
1362 assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1363 }
1364
1365 #[test]
1366 fn environment_optional_peers_come_from_the_credit_catalogue() {
1367 for environment in [
1368 "jsdom",
1369 "jest-environment-jsdom",
1370 "vitest-environment-jsdom",
1371 ] {
1372 let mut result = PluginResult::default();
1373 credit_environment_optional_peers(environment, &mut result);
1374 assert_eq!(
1375 result.referenced_dependencies,
1376 vec!["canvas".to_string()],
1377 "expected the catalogue credit for {environment}"
1378 );
1379 }
1380 }
1381
1382 #[test]
1383 fn environment_without_a_catalogue_row_credits_nothing() {
1384 let mut result = PluginResult::default();
1385 credit_environment_optional_peers("happy-dom", &mut result);
1386 assert!(result.referenced_dependencies.is_empty());
1387 }
1388
1389 #[test]
1390 fn entry_point_role_defaults_are_centralized() {
1391 assert_eq!(vite::VitePlugin.entry_point_role(), EntryPointRole::Runtime);
1392 assert_eq!(
1393 vitest::VitestPlugin.entry_point_role(),
1394 EntryPointRole::Test
1395 );
1396 assert_eq!(
1397 storybook::StorybookPlugin.entry_point_role(),
1398 EntryPointRole::Support
1399 );
1400 assert_eq!(
1401 obsidian::ObsidianPlugin.entry_point_role(),
1402 EntryPointRole::Runtime
1403 );
1404 assert_eq!(knex::KnexPlugin.entry_point_role(), EntryPointRole::Support);
1405 }
1406
1407 #[test]
1408 fn plugins_with_entry_patterns_have_explicit_role_intent() {
1409 let runtime_or_test_or_support: rustc_hash::FxHashSet<&'static str> =
1410 TEST_ENTRY_POINT_PLUGINS
1411 .iter()
1412 .chain(RUNTIME_ENTRY_POINT_PLUGINS.iter())
1413 .chain(SUPPORT_ENTRY_POINT_PLUGINS.iter())
1414 .copied()
1415 .collect();
1416
1417 for plugin in crate::plugins::registry::builtin::create_builtin_plugins() {
1418 if plugin.entry_patterns().is_empty() {
1419 continue;
1420 }
1421 assert!(
1422 runtime_or_test_or_support.contains(plugin.name()),
1423 "plugin '{}' exposes entry patterns but is missing from the entry-point role map",
1424 plugin.name()
1425 );
1426 }
1427 }
1428
1429 #[test]
1430 fn plugin_result_is_empty_when_default() {
1431 let r = PluginResult::default();
1432 assert!(r.is_empty());
1433 }
1434
1435 #[test]
1436 fn plugin_result_not_empty_with_entry_patterns() {
1437 let r = PluginResult {
1438 entry_patterns: vec!["*.ts".into()],
1439 ..Default::default()
1440 };
1441 assert!(!r.is_empty());
1442 }
1443
1444 #[test]
1445 fn plugin_result_not_empty_with_referenced_deps() {
1446 let r = PluginResult {
1447 referenced_dependencies: vec!["lodash".to_string()],
1448 ..Default::default()
1449 };
1450 assert!(!r.is_empty());
1451 }
1452
1453 #[test]
1454 fn plugin_result_not_empty_with_setup_files() {
1455 let r = PluginResult {
1456 setup_files: vec![PathBuf::from("/setup.ts")],
1457 ..Default::default()
1458 };
1459 assert!(!r.is_empty());
1460 }
1461
1462 #[test]
1463 fn plugin_result_not_empty_with_always_used_files() {
1464 let r = PluginResult {
1465 always_used_files: vec!["**/*.stories.tsx".to_string()],
1466 ..Default::default()
1467 };
1468 assert!(!r.is_empty());
1469 }
1470
1471 #[test]
1472 fn plugin_result_not_empty_with_fixture_patterns() {
1473 let r = PluginResult {
1474 fixture_patterns: vec!["**/__fixtures__/**/*".to_string()],
1475 ..Default::default()
1476 };
1477 assert!(!r.is_empty());
1478 }
1479
1480 #[test]
1481 fn is_enabled_with_deps_prefix_match() {
1482 let plugin = storybook::StorybookPlugin;
1483 let deps = vec!["@storybook/react".to_string()];
1484 assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1485 }
1486
1487 #[test]
1488 fn is_enabled_with_deps_prefix_no_match_without_slash() {
1489 let plugin = storybook::StorybookPlugin;
1490 let deps = vec!["@storybookish".to_string()];
1491 assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1492 }
1493
1494 #[test]
1495 fn is_enabled_with_deps_multiple_enablers() {
1496 let plugin = vitest::VitestPlugin;
1497 let deps_vitest = vec!["vitest".to_string()];
1498 let deps_none = vec!["mocha".to_string()];
1499 assert!(plugin.is_enabled_with_deps(&deps_vitest, Path::new("/project")));
1500 assert!(!plugin.is_enabled_with_deps(&deps_none, Path::new("/project")));
1501 }
1502
1503 #[test]
1504 fn plugin_default_methods_return_empty() {
1505 let plugin = commitizen::CommitizenPlugin;
1506 assert!(
1507 plugin.tooling_dependencies().is_empty() || !plugin.tooling_dependencies().is_empty()
1508 );
1509 assert!(plugin.virtual_module_prefixes().is_empty());
1510 assert!(plugin.virtual_package_suffixes().is_empty());
1511 assert!(plugin.path_aliases(Path::new("/project")).is_empty());
1512 assert!(
1513 plugin.package_json_config_key().is_none()
1514 || plugin.package_json_config_key().is_some()
1515 );
1516 }
1517
1518 #[test]
1519 fn plugin_resolve_config_default_returns_empty() {
1520 let plugin = commitizen::CommitizenPlugin;
1521 let result = plugin.resolve_config(
1522 Path::new("/project/config.js"),
1523 "const x = 1;",
1524 Path::new("/project"),
1525 );
1526 assert!(result.is_empty());
1527 }
1528
1529 #[test]
1530 fn is_enabled_with_deps_exact_and_prefix_both_work() {
1531 let plugin = storybook::StorybookPlugin;
1532 let deps_exact = vec!["storybook".to_string()];
1533 assert!(plugin.is_enabled_with_deps(&deps_exact, Path::new("/project")));
1534 let deps_prefix = vec!["@storybook/vue3".to_string()];
1535 assert!(plugin.is_enabled_with_deps(&deps_prefix, Path::new("/project")));
1536 }
1537
1538 #[test]
1539 fn is_enabled_with_deps_multiple_enablers_remix() {
1540 let plugin = remix::RemixPlugin;
1541 let deps_node = vec!["@remix-run/node".to_string()];
1542 assert!(plugin.is_enabled_with_deps(&deps_node, Path::new("/project")));
1543 let deps_react = vec!["@remix-run/react".to_string()];
1544 assert!(plugin.is_enabled_with_deps(&deps_react, Path::new("/project")));
1545 let deps_cf = vec!["@remix-run/cloudflare".to_string()];
1546 assert!(plugin.is_enabled_with_deps(&deps_cf, Path::new("/project")));
1547 }
1548
1549 struct MinimalPlugin;
1550 impl Plugin for MinimalPlugin {
1551 fn name(&self) -> &'static str {
1552 "minimal"
1553 }
1554 }
1555
1556 #[test]
1557 fn default_enablers_is_empty() {
1558 assert!(MinimalPlugin.enablers().is_empty());
1559 }
1560
1561 #[test]
1562 fn default_entry_patterns_is_empty() {
1563 assert!(MinimalPlugin.entry_patterns().is_empty());
1564 }
1565
1566 #[test]
1567 fn default_config_patterns_is_empty() {
1568 assert!(MinimalPlugin.config_patterns().is_empty());
1569 }
1570
1571 #[test]
1572 fn default_always_used_is_empty() {
1573 assert!(MinimalPlugin.always_used().is_empty());
1574 }
1575
1576 #[test]
1577 fn default_used_exports_is_empty() {
1578 assert!(MinimalPlugin.used_exports().is_empty());
1579 }
1580
1581 #[test]
1582 fn default_tooling_dependencies_is_empty() {
1583 assert!(MinimalPlugin.tooling_dependencies().is_empty());
1584 }
1585
1586 #[test]
1587 fn default_fixture_glob_patterns_is_empty() {
1588 assert!(MinimalPlugin.fixture_glob_patterns().is_empty());
1589 }
1590
1591 #[test]
1592 fn default_virtual_module_prefixes_is_empty() {
1593 assert!(MinimalPlugin.virtual_module_prefixes().is_empty());
1594 }
1595
1596 #[test]
1597 fn default_virtual_package_suffixes_is_empty() {
1598 assert!(MinimalPlugin.virtual_package_suffixes().is_empty());
1599 }
1600
1601 #[test]
1602 fn default_path_aliases_is_empty() {
1603 assert!(MinimalPlugin.path_aliases(Path::new("/")).is_empty());
1604 }
1605
1606 #[test]
1607 fn default_resolve_config_returns_empty() {
1608 let r = MinimalPlugin.resolve_config(
1609 Path::new("config.js"),
1610 "export default {}",
1611 Path::new("/"),
1612 );
1613 assert!(r.is_empty());
1614 }
1615
1616 #[test]
1617 fn default_package_json_metadata_hooks_are_empty() {
1618 let pkg = PackageJson::default();
1619 assert!(!MinimalPlugin.is_enabled_with_package_json(&pkg, Path::new("/")));
1620 assert!(
1621 MinimalPlugin
1622 .resolve_package_json(&pkg, Path::new("/"))
1623 .is_empty()
1624 );
1625 }
1626
1627 #[test]
1628 fn default_package_json_config_key_is_none() {
1629 assert!(MinimalPlugin.package_json_config_key().is_none());
1630 }
1631
1632 #[test]
1633 fn default_is_enabled_returns_false_when_no_enablers() {
1634 let deps = vec!["anything".to_string()];
1635 assert!(!MinimalPlugin.is_enabled_with_deps(&deps, Path::new("/")));
1636 }
1637
1638 #[test]
1639 fn all_builtin_plugin_names_are_unique() {
1640 let plugins = registry::builtin::create_builtin_plugins();
1641 let mut seen = std::collections::BTreeSet::new();
1642 for p in &plugins {
1643 let name = p.name();
1644 assert!(seen.insert(name), "duplicate plugin name: {name}");
1645 }
1646 }
1647
1648 #[test]
1649 fn all_builtin_plugins_have_activation_signals() {
1650 const NON_DEPENDENCY_ACTIVATED_PLUGINS: &[&str] = &["napi-rs", "deno"];
1653 let plugins = registry::builtin::create_builtin_plugins();
1654 for p in &plugins {
1655 assert!(
1656 !p.enablers().is_empty()
1657 || !p.script_enablers().is_empty()
1658 || NON_DEPENDENCY_ACTIVATED_PLUGINS.contains(&p.name()),
1659 "plugin '{}' has no activation signal",
1660 p.name()
1661 );
1662 }
1663 }
1664
1665 #[test]
1666 fn plugins_with_config_patterns_have_always_used() {
1667 let plugins = registry::builtin::create_builtin_plugins();
1668 for p in &plugins {
1669 if !p.config_patterns().is_empty() {
1670 assert!(
1671 !p.always_used().is_empty(),
1672 "plugin '{}' has config_patterns but no always_used",
1673 p.name()
1674 );
1675 }
1676 }
1677 }
1678
1679 #[test]
1680 fn framework_plugins_enablers() {
1681 let cases: Vec<(&dyn Plugin, &[&str])> = vec![
1682 (&nextjs::NextJsPlugin, &["next"]),
1683 (&nuxt::NuxtPlugin, &["nuxt"]),
1684 (&angular::AngularPlugin, &["@angular/core"]),
1685 (&ionic::IonicPlugin, &["@ionic/angular"]),
1686 (&sveltekit::SvelteKitPlugin, &["@sveltejs/kit"]),
1687 (&gatsby::GatsbyPlugin, &["gatsby"]),
1688 ];
1689 for (plugin, expected_enablers) in cases {
1690 let enablers = plugin.enablers();
1691 for expected in expected_enablers {
1692 assert!(
1693 enablers.contains(expected),
1694 "plugin '{}' should have '{}'",
1695 plugin.name(),
1696 expected
1697 );
1698 }
1699 }
1700 }
1701
1702 #[test]
1703 fn testing_plugins_enablers() {
1704 let cases: Vec<(&dyn Plugin, &str)> = vec![
1705 (&jest::JestPlugin, "jest"),
1706 (&vitest::VitestPlugin, "vitest"),
1707 (&playwright::PlaywrightPlugin, "@playwright/test"),
1708 (&cypress::CypressPlugin, "cypress"),
1709 (&mocha::MochaPlugin, "mocha"),
1710 (&stryker::StrykerPlugin, "@stryker-mutator/core"),
1711 ];
1712 for (plugin, enabler) in cases {
1713 assert!(
1714 plugin.enablers().contains(&enabler),
1715 "plugin '{}' should have '{}'",
1716 plugin.name(),
1717 enabler
1718 );
1719 }
1720 }
1721
1722 #[test]
1723 fn bundler_plugins_enablers() {
1724 let cases: Vec<(&dyn Plugin, &str)> = vec![
1725 (&vite::VitePlugin, "vite"),
1726 (&webpack::WebpackPlugin, "webpack"),
1727 (&rollup::RollupPlugin, "rollup"),
1728 ];
1729 for (plugin, enabler) in cases {
1730 assert!(
1731 plugin.enablers().contains(&enabler),
1732 "plugin '{}' should have '{}'",
1733 plugin.name(),
1734 enabler
1735 );
1736 }
1737 }
1738
1739 #[test]
1740 fn test_plugins_have_test_entry_patterns() {
1741 let test_plugins: Vec<&dyn Plugin> = vec![
1742 &bun::BunPlugin,
1743 &deno::DenoPlugin,
1744 &jest::JestPlugin,
1745 &vitest::VitestPlugin,
1746 &mocha::MochaPlugin,
1747 &tap::TapPlugin,
1748 &tsd::TsdPlugin,
1749 ];
1750 for plugin in test_plugins {
1751 let patterns = plugin.entry_patterns();
1752 assert!(
1753 !patterns.is_empty(),
1754 "test plugin '{}' should have entry patterns",
1755 plugin.name()
1756 );
1757 assert!(
1758 patterns
1759 .iter()
1760 .any(|p| p.contains("test") || p.contains("spec") || p.contains("__tests__")),
1761 "test plugin '{}' should have test/spec patterns",
1762 plugin.name()
1763 );
1764 }
1765 }
1766
1767 #[test]
1768 fn framework_plugins_have_entry_patterns() {
1769 let plugins: Vec<&dyn Plugin> = vec![
1770 &nextjs::NextJsPlugin,
1771 &nuxt::NuxtPlugin,
1772 &angular::AngularPlugin,
1773 &sveltekit::SvelteKitPlugin,
1774 ];
1775 for plugin in plugins {
1776 assert!(
1777 !plugin.entry_patterns().is_empty(),
1778 "framework plugin '{}' should have entry patterns",
1779 plugin.name()
1780 );
1781 }
1782 }
1783
1784 #[test]
1785 fn plugins_with_resolve_config_have_config_patterns() {
1786 let plugins: Vec<&dyn Plugin> = vec![
1787 &jest::JestPlugin,
1788 &vitest::VitestPlugin,
1789 &babel::BabelPlugin,
1790 &eslint::EslintPlugin,
1791 &webpack::WebpackPlugin,
1792 &storybook::StorybookPlugin,
1793 &typescript::TypeScriptPlugin,
1794 &postcss::PostCssPlugin,
1795 &nextjs::NextJsPlugin,
1796 &nuxt::NuxtPlugin,
1797 &angular::AngularPlugin,
1798 &nx::NxPlugin,
1799 &stryker::StrykerPlugin,
1800 &wuchale::WuchalePlugin,
1801 &rollup::RollupPlugin,
1802 &sveltekit::SvelteKitPlugin,
1803 &prettier::PrettierPlugin,
1804 &contentlayer::ContentlayerPlugin,
1805 ];
1806 for plugin in plugins {
1807 assert!(
1808 !plugin.config_patterns().is_empty(),
1809 "plugin '{}' with resolve_config should have config_patterns",
1810 plugin.name()
1811 );
1812 }
1813 }
1814
1815 #[test]
1816 fn plugin_tooling_deps_include_enabler_package() {
1817 let plugins: Vec<&dyn Plugin> = vec![
1818 &jest::JestPlugin,
1819 &vitest::VitestPlugin,
1820 &webpack::WebpackPlugin,
1821 &typescript::TypeScriptPlugin,
1822 &eslint::EslintPlugin,
1823 &prettier::PrettierPlugin,
1824 &danger::DangerPlugin,
1825 &stryker::StrykerPlugin,
1826 &wuchale::WuchalePlugin,
1827 &contentlayer::ContentlayerPlugin,
1828 ];
1829 for plugin in plugins {
1830 let tooling = plugin.tooling_dependencies();
1831 let enablers = plugin.enablers();
1832 assert!(
1833 enablers
1834 .iter()
1835 .any(|e| !e.ends_with('/') && tooling.contains(e)),
1836 "plugin '{}': at least one non-prefix enabler should be in tooling_dependencies",
1837 plugin.name()
1838 );
1839 }
1840 }
1841
1842 #[test]
1843 fn nextjs_has_used_exports_for_pages() {
1844 let plugin = nextjs::NextJsPlugin;
1845 let exports = plugin.used_exports();
1846 assert!(!exports.is_empty());
1847 assert!(exports.iter().any(|(_, names)| names.contains(&"default")));
1848 }
1849
1850 #[test]
1851 fn remix_has_used_exports_for_routes() {
1852 let plugin = remix::RemixPlugin;
1853 let exports = plugin.used_exports();
1854 assert!(!exports.is_empty());
1855 let route_entry = exports.iter().find(|(pat, _)| pat.contains("routes"));
1856 assert!(route_entry.is_some());
1857 let (_, names) = route_entry.unwrap();
1858 assert!(names.contains(&"loader"));
1859 assert!(names.contains(&"action"));
1860 assert!(names.contains(&"default"));
1861 }
1862
1863 #[test]
1864 fn sveltekit_has_used_exports_for_routes() {
1865 let plugin = sveltekit::SvelteKitPlugin;
1866 let exports = plugin.used_exports();
1867 assert!(!exports.is_empty());
1868 assert!(exports.iter().any(|(_, names)| names.contains(&"GET")));
1869 }
1870
1871 #[test]
1872 fn nuxt_has_hash_virtual_prefix() {
1873 assert!(nuxt::NuxtPlugin.virtual_module_prefixes().contains(&"#"));
1874 }
1875
1876 #[test]
1877 fn sveltekit_has_dollar_virtual_prefixes() {
1878 let prefixes = sveltekit::SvelteKitPlugin.virtual_module_prefixes();
1879 assert!(prefixes.contains(&"$app/"));
1880 assert!(prefixes.contains(&"$env/"));
1881 assert!(prefixes.contains(&"$lib/"));
1882 }
1883
1884 #[test]
1885 fn sveltekit_has_lib_path_alias() {
1886 let aliases = sveltekit::SvelteKitPlugin.path_aliases(Path::new("/project"));
1887 assert!(aliases.iter().any(|(prefix, _)| *prefix == "$lib/"));
1888 }
1889
1890 #[test]
1891 fn nuxt_has_tilde_path_alias() {
1892 let aliases = nuxt::NuxtPlugin.path_aliases(Path::new("/nonexistent"));
1893 assert!(aliases.iter().any(|(prefix, _)| *prefix == "~/"));
1894 assert!(aliases.iter().any(|(prefix, _)| *prefix == "~~/"));
1895 }
1896
1897 #[test]
1898 fn jest_has_package_json_config_key() {
1899 assert_eq!(jest::JestPlugin.package_json_config_key(), Some("jest"));
1900 }
1901
1902 #[test]
1903 fn tsd_has_package_json_config_key() {
1904 assert_eq!(tsd::TsdPlugin.package_json_config_key(), Some("tsd"));
1905 }
1906
1907 #[test]
1908 fn babel_has_package_json_config_key() {
1909 assert_eq!(babel::BabelPlugin.package_json_config_key(), Some("babel"));
1910 }
1911
1912 #[test]
1913 fn eslint_has_package_json_config_key() {
1914 assert_eq!(
1915 eslint::EslintPlugin.package_json_config_key(),
1916 Some("eslintConfig")
1917 );
1918 }
1919
1920 #[test]
1921 fn prettier_has_package_json_config_key() {
1922 assert_eq!(
1923 prettier::PrettierPlugin.package_json_config_key(),
1924 Some("prettier")
1925 );
1926 }
1927
1928 #[test]
1929 fn macro_generated_plugin_basic_properties() {
1930 let plugin = msw::MswPlugin;
1931 assert_eq!(plugin.name(), "msw");
1932 assert!(plugin.enablers().contains(&"msw"));
1933 assert!(!plugin.entry_patterns().is_empty());
1934 assert!(plugin.config_patterns().is_empty());
1935 assert!(!plugin.always_used().is_empty());
1936 assert!(!plugin.tooling_dependencies().is_empty());
1937 }
1938
1939 #[test]
1940 fn macro_generated_plugin_with_used_exports() {
1941 let plugin = remix::RemixPlugin;
1942 assert_eq!(plugin.name(), "remix");
1943 assert!(!plugin.used_exports().is_empty());
1944 }
1945
1946 #[test]
1947 fn macro_passes_through_virtual_package_suffixes() {
1948 define_plugin! {
1949 struct MacroSuffixSmokePlugin => "macro-suffix-smoke",
1950 enablers: &["macro-suffix-smoke"],
1951 virtual_package_suffixes: &["/__macro_smoke__"],
1952 }
1953
1954 let plugin = MacroSuffixSmokePlugin;
1955 assert_eq!(
1956 plugin.virtual_package_suffixes(),
1957 &["/__macro_smoke__"],
1958 "macro-declared virtual_package_suffixes must propagate to the trait method"
1959 );
1960 }
1961
1962 #[test]
1963 fn macro_generated_plugin_imports_only_resolve_config() {
1964 let plugin = cypress::CypressPlugin;
1965 let source = r"
1966 import { defineConfig } from 'cypress';
1967 import coveragePlugin from '@cypress/code-coverage';
1968 export default defineConfig({});
1969 ";
1970 let result = plugin.resolve_config(
1971 Path::new("cypress.config.ts"),
1972 source,
1973 Path::new("/project"),
1974 );
1975 assert!(
1976 result
1977 .referenced_dependencies
1978 .contains(&"cypress".to_string())
1979 );
1980 assert!(
1981 result
1982 .referenced_dependencies
1983 .contains(&"@cypress/code-coverage".to_string())
1984 );
1985 }
1986
1987 #[test]
1988 fn builtin_plugin_count_is_expected() {
1989 let plugins = registry::builtin::create_builtin_plugins();
1990 assert!(
1991 plugins.len() >= 110,
1992 "expected at least 110 built-in plugins, got {}",
1993 plugins.len()
1994 );
1995 }
1996}