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