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