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