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