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