1use std::path::{Path, PathBuf};
13
14use fallow_config::{AutoImportRule, EntryPointRole, PackageJson, UsedClassMemberRule};
15use fallow_types::semantic::SemanticFrameworkContract;
16use regex::Regex;
17
18const TEST_ENTRY_POINT_PLUGINS: &[&str] = &[
19 "ava",
20 "bun",
21 "deno",
22 "cucumber",
23 "cypress",
24 "jest",
25 "k6",
26 "mocha",
27 "playwright",
28 "tap",
29 "tsd",
30 "vitest",
31 "webdriverio",
32];
33
34const RUNTIME_ENTRY_POINT_PLUGINS: &[&str] = &[
35 "adonis",
36 "angular",
37 "astro",
38 "browser-extension",
39 "convex",
40 "docusaurus",
41 "electron",
42 "ember",
43 "expo",
44 "expo-router",
45 "gatsby",
46 "hardhat",
47 "module-federation",
48 "nestjs",
49 "next-intl",
50 "nextjs",
51 "nitro",
52 "nuxt",
53 "obsidian",
54 "parcel",
55 "qwik",
56 "react-native",
57 "react-router",
58 "redwoodsdk",
59 "remix",
60 "rolldown",
61 "rollup",
62 "rsbuild",
63 "rspack",
64 "sanity",
65 "supabase",
66 "sveltekit",
67 "tanstack-router",
68 "tsdown",
69 "tsup",
70 "vite",
71 "vitepress",
72 "webpack",
73 "wrangler",
74 "wxt",
75];
76
77#[cfg(test)]
78const SUPPORT_ENTRY_POINT_PLUGINS: &[&str] = &[
79 "content-collections",
80 "contentlayer",
81 "danger",
82 "drizzle",
83 "fumadocs",
84 "i18next",
85 "knex",
86 "kysely",
87 "mintlify",
88 "msw",
89 "opencode",
90 "prisma",
91 "storybook",
92 "stryker",
93 "typeorm",
94 "velite",
95];
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum PluginConfigEffect {
100 Unreadable,
103 NotModeled,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct PluginConfigDiagnostic {
120 pub config_path: PathBuf,
122 pub plugin: String,
126 pub key: String,
129 pub reason: String,
131 pub effect: PluginConfigEffect,
133}
134
135impl PluginConfigDiagnostic {
136 pub(super) fn unreadable(
138 config_path: &Path,
139 plugin: &str,
140 key: &str,
141 reason: &'static str,
142 ) -> Self {
143 Self {
144 config_path: config_path.to_path_buf(),
145 plugin: plugin.to_owned(),
146 key: key.to_owned(),
147 reason: reason.to_owned(),
148 effect: PluginConfigEffect::Unreadable,
149 }
150 }
151
152 pub(crate) fn not_modeled(
154 config_path: &Path,
155 plugin: &str,
156 key: &str,
157 reason: &'static str,
158 ) -> Self {
159 Self {
160 config_path: config_path.to_path_buf(),
161 plugin: plugin.to_owned(),
162 key: key.to_owned(),
163 reason: reason.to_owned(),
164 effect: PluginConfigEffect::NotModeled,
165 }
166 }
167
168 #[must_use]
171 pub fn into_workspace_diagnostic(self, root: &Path) -> fallow_config::WorkspaceDiagnostic {
172 let Self {
173 config_path,
174 plugin,
175 key,
176 reason,
177 effect,
178 } = self;
179 let kind = match effect {
180 PluginConfigEffect::Unreadable => {
181 fallow_config::WorkspaceDiagnosticKind::PluginConfigUnreadable {
182 plugin,
183 key,
184 reason,
185 }
186 }
187 PluginConfigEffect::NotModeled => {
188 fallow_config::WorkspaceDiagnosticKind::PluginEffectNotModeled {
189 plugin,
190 key,
191 reason,
192 }
193 }
194 };
195 fallow_config::WorkspaceDiagnostic::new(root, config_path, kind)
196 }
197}
198
199#[derive(Debug, Default)]
201pub struct PluginResult {
202 entry_patterns: Vec<PathRule>,
204 replace_entry_patterns: bool,
209 replace_used_export_rules: bool,
212 used_exports: Vec<UsedExportRule>,
214 used_class_members: Vec<UsedClassMemberRule>,
219 referenced_dependencies: Vec<String>,
221 package_referenced_dependencies: Vec<(PathBuf, String)>,
224 always_used_files: Vec<String>,
226 path_aliases: Vec<(String, String)>,
228 setup_files: Vec<PathBuf>,
230 fixture_patterns: Vec<String>,
232 scss_include_paths: Vec<PathBuf>,
240 static_dir_mappings: Vec<(PathBuf, String)>,
243 framework_static_dir_mappings: Vec<(PathBuf, String)>,
244 provided_dependencies: Vec<ProvidedDependencyRule>,
247 config_diagnostics: Vec<PluginConfigDiagnostic>,
251 federation_sources: Vec<FederationSource>,
254}
255
256#[derive(Debug, Clone, PartialEq, Eq)]
258pub struct FederationSource {
259 pub target: FederationSourceTarget,
261 pub config_path: PathBuf,
263 pub plugin: String,
265 pub key: &'static str,
267}
268
269#[derive(Debug, Clone, PartialEq, Eq)]
271pub enum FederationSourceTarget {
272 Exposed(PathRule),
274 Remote(String),
276}
277
278impl FederationSource {
279 #[must_use]
280 fn prefixed(&self, ws_prefix: &str) -> Self {
281 let target = match &self.target {
282 FederationSourceTarget::Exposed(rule) => {
283 FederationSourceTarget::Exposed(rule.prefixed(ws_prefix))
284 }
285 FederationSourceTarget::Remote(alias) => FederationSourceTarget::Remote(alias.clone()),
286 };
287 Self {
288 target,
289 config_path: self.config_path.clone(),
290 plugin: self.plugin.clone(),
291 key: self.key,
292 }
293 }
294}
295
296#[must_use]
304pub fn federation_trace_provenance(
305 root: &Path,
306 files: &[crate::discover::DiscoveredFile],
307 sources: &[FederationSource],
308 modules: &[crate::extract::ModuleInfo],
309) -> fallow_types::trace::TraceProvenance {
310 let mut provenance = fallow_types::trace::TraceProvenance::default();
311 push_runtime_remote_sources(&mut provenance, root, files, modules);
312 if sources.is_empty() {
313 return provenance;
314 }
315 let mut exposed = Vec::new();
316 for source in sources {
317 let config = source
318 .config_path
319 .strip_prefix(root)
320 .unwrap_or(&source.config_path)
321 .to_path_buf();
322 let trace_source = fallow_types::trace::TraceSource {
323 kind: "module-federation".to_owned(),
324 plugin: source.plugin.clone(),
325 config,
326 key: source.key.to_owned(),
327 };
328 match &source.target {
329 FederationSourceTarget::Exposed(rule) => {
330 if let Some(compiled) =
331 CompiledPathRule::for_entry_rule(rule, "Module Federation exposes target")
332 {
333 exposed.push((compiled, trace_source));
334 }
335 }
336 FederationSourceTarget::Remote(alias) => {
337 provenance.push_dependency(alias.clone(), trace_source);
338 }
339 }
340 }
341 if exposed.is_empty() {
342 return provenance;
343 }
344 for file in files {
345 let Ok(relative) = file.path.strip_prefix(root) else {
346 continue;
347 };
348 let relative_str = relative.to_string_lossy().replace('\\', "/");
349 for (rule, source) in &exposed {
350 if rule.matches(&relative_str) {
351 provenance.push_file(relative.to_path_buf(), source.clone());
352 }
353 }
354 }
355 provenance
356}
357
358fn push_runtime_remote_sources(
360 provenance: &mut fallow_types::trace::TraceProvenance,
361 root: &Path,
362 files: &[crate::discover::DiscoveredFile],
363 modules: &[crate::extract::ModuleInfo],
364) {
365 for module in modules {
366 let mut file = None;
367 for fact in module.semantic_facts.iter() {
368 let fallow_types::extract::SemanticFact::FederationRuntimeRemote(fact) = fact else {
369 continue;
370 };
371 let Some(remote) = &fact.remote else {
372 continue;
373 };
374 let Some(path) = file.get_or_insert_with(|| {
375 files.get(module.file_id.0 as usize).map(|file| {
376 file.path
377 .strip_prefix(root)
378 .unwrap_or(&file.path)
379 .to_path_buf()
380 })
381 }) else {
382 break;
383 };
384 provenance.push_dependency(
385 remote.clone(),
386 fallow_types::trace::TraceSource {
387 kind: "module-federation".to_owned(),
388 plugin: "module-federation".to_owned(),
389 config: path.clone(),
390 key: fact.call.name().to_owned(),
391 },
392 );
393 }
394 }
395}
396
397impl PluginResult {
398 fn push_parent_relative_entry_pattern(&mut self, pattern: String) {
401 let mut rule = PathRule::new(pattern);
402 rule.parent_relative = true;
403 self.entry_patterns.push(rule);
404 }
405
406 fn push_entry_pattern(&mut self, pattern: impl Into<String>) {
407 self.entry_patterns
408 .push(PathRule::new(normalize_entry_pattern(pattern.into())));
409 }
410
411 fn extend_entry_patterns<I, S>(&mut self, patterns: I)
412 where
413 I: IntoIterator<Item = S>,
414 S: Into<String>,
415 {
416 self.entry_patterns.extend(
417 patterns
418 .into_iter()
419 .map(|pat| PathRule::new(normalize_entry_pattern(pat.into()))),
420 );
421 }
422
423 fn extend_entry_patterns_or_dependencies<I, S>(
438 &mut self,
439 values: I,
440 resolve_path: impl Fn(String) -> String,
441 ) where
442 I: IntoIterator<Item = S>,
443 S: Into<String>,
444 {
445 for value in values {
446 let value = value.into();
447 if let Some(request) = module_request(&value) {
448 self.referenced_dependencies
449 .push(crate::resolve::extract_package_name(request));
450 continue;
451 }
452 self.push_entry_path(resolve_path(value));
453 }
454 }
455
456 fn extend_entry_patterns_and_dependencies<I, S>(&mut self, values: I, root: &Path)
468 where
469 I: IntoIterator<Item = S>,
470 S: Into<String>,
471 {
472 for value in values {
473 let value = value.into();
474 if let Some(request) = module_request(&value)
475 && !names_project_file(root, request)
476 {
477 self.referenced_dependencies
478 .push(crate::resolve::extract_package_name(request));
479 }
480 self.push_entry_path(value);
481 }
482 }
483
484 fn extend_entry_paths<I, S>(&mut self, values: I)
486 where
487 I: IntoIterator<Item = S>,
488 S: Into<String>,
489 {
490 for value in values {
491 self.push_entry_path(value.into());
492 }
493 }
494
495 fn push_entry_path(&mut self, value: String) {
503 if has_glob_syntax(&value) || has_source_extension(&value) {
504 self.push_entry_pattern(value);
505 return;
506 }
507 let base = value.trim_end_matches('/').to_owned();
508 self.push_entry_pattern(value);
509 self.push_entry_pattern(format!("{base}.{REQUEST_EXTENSIONS}"));
510 self.push_entry_pattern(format!("{base}/index.{REQUEST_EXTENSIONS}"));
511 }
512
513 fn push_used_export_rule(
514 &mut self,
515 pattern: impl Into<String>,
516 exports: impl IntoIterator<Item = impl Into<String>>,
517 ) {
518 self.used_exports
519 .push(UsedExportRule::new(pattern, exports));
520 }
521
522 #[must_use]
529 const fn is_empty(&self) -> bool {
530 self.config_diagnostics.is_empty()
531 && self.entry_patterns.is_empty()
532 && self.used_exports.is_empty()
533 && self.used_class_members.is_empty()
534 && self.referenced_dependencies.is_empty()
535 && self.package_referenced_dependencies.is_empty()
536 && self.always_used_files.is_empty()
537 && self.path_aliases.is_empty()
538 && self.setup_files.is_empty()
539 && self.fixture_patterns.is_empty()
540 && self.scss_include_paths.is_empty()
541 && self.static_dir_mappings.is_empty()
542 && self.framework_static_dir_mappings.is_empty()
543 && self.provided_dependencies.is_empty()
544 && self.federation_sources.is_empty()
545 }
546}
547
548fn names_project_file(root: &Path, value: &str) -> bool {
552 let base = root.join(value);
553 base.is_file()
554 || crate::discover::SOURCE_EXTENSIONS.iter().any(|extension| {
555 let mut candidate = base.clone().into_os_string();
556 candidate.push(".");
557 candidate.push(extension);
558 Path::new(&candidate).is_file() || base.join(format!("index.{extension}")).is_file()
559 })
560}
561
562const REQUEST_EXTENSIONS: &str = "{ts,tsx,mts,cts,gts,js,jsx,mjs,cjs,gjs,vue,svelte,astro,mdx}";
566
567fn normalize_entry_pattern(pattern: String) -> String {
568 pattern
569 .strip_prefix("./")
570 .map(str::to_owned)
571 .unwrap_or(pattern)
572}
573
574fn module_request(value: &str) -> Option<&str> {
585 let request = strip_resource_query(value);
586 (config_parser::is_package_specifier(request)
587 && !has_glob_syntax(request)
588 && !has_source_extension(request))
589 .then_some(request)
590}
591
592fn strip_resource_query(value: &str) -> &str {
600 match value.split_once('?') {
601 Some((request, query)) if is_resource_query(query) => request,
602 _ => value,
603 }
604}
605
606fn is_resource_query(query: &str) -> bool {
609 !query.is_empty()
610 && query.split('&').all(|pair| {
611 let key = pair.split_once('=').map_or(pair, |(key, _)| key);
612 key.starts_with(|first: char| first.is_ascii_alphanumeric() || first == '_')
613 && key
614 .chars()
615 .all(|char| char.is_ascii_alphanumeric() || matches!(char, '_' | '-' | '.'))
616 })
617}
618
619fn has_glob_syntax(value: &str) -> bool {
622 value.contains('*') || value.contains('?') || value.contains('[') || value.contains('{')
623}
624
625fn has_source_extension(value: &str) -> bool {
629 Path::new(value)
630 .extension()
631 .and_then(|ext| ext.to_str())
632 .is_some_and(|ext| {
633 crate::discover::SOURCE_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str())
634 })
635}
636
637#[derive(Debug, Clone, Default, PartialEq, Eq)]
643pub struct PathRule {
644 pub pattern: String,
645 pub exclude_globs: Vec<String>,
646 pub exclude_regexes: Vec<String>,
647 pub exclude_segment_regexes: Vec<String>,
651 pub parent_relative: bool,
658}
659
660impl PathRule {
661 #[must_use]
662 pub(crate) fn new(pattern: impl Into<String>) -> Self {
663 Self {
664 pattern: pattern.into(),
665 exclude_globs: Vec::new(),
666 exclude_regexes: Vec::new(),
667 exclude_segment_regexes: Vec::new(),
668 parent_relative: false,
669 }
670 }
671
672 #[must_use]
673 fn from_static(pattern: &'static str) -> Self {
674 Self::new(pattern)
675 }
676
677 #[must_use]
678 pub(crate) fn with_excluded_globs<I, S>(mut self, patterns: I) -> Self
679 where
680 I: IntoIterator<Item = S>,
681 S: Into<String>,
682 {
683 self.exclude_globs
684 .extend(patterns.into_iter().map(Into::into));
685 self
686 }
687
688 #[must_use]
689 fn with_excluded_regexes<I, S>(mut self, patterns: I) -> Self
690 where
691 I: IntoIterator<Item = S>,
692 S: Into<String>,
693 {
694 self.exclude_regexes
695 .extend(patterns.into_iter().map(Into::into));
696 self
697 }
698
699 #[must_use]
700 fn with_excluded_segment_regexes<I, S>(mut self, patterns: I) -> Self
701 where
702 I: IntoIterator<Item = S>,
703 S: Into<String>,
704 {
705 self.exclude_segment_regexes
706 .extend(patterns.into_iter().map(Into::into));
707 self
708 }
709
710 #[must_use]
711 fn prefixed(&self, ws_prefix: &str) -> Self {
712 let pattern = if self.parent_relative && self.pattern.starts_with("../") {
713 resolve_parent_relative_pattern(&self.pattern, ws_prefix)
714 } else {
715 prefix_workspace_pattern(&self.pattern, ws_prefix)
716 };
717 Self {
718 pattern,
719 exclude_globs: self
720 .exclude_globs
721 .iter()
722 .map(|pattern| prefix_workspace_pattern(pattern, ws_prefix))
723 .collect(),
724 exclude_regexes: self
725 .exclude_regexes
726 .iter()
727 .map(|pattern| prefix_workspace_regex(pattern, ws_prefix))
728 .collect(),
729 exclude_segment_regexes: self.exclude_segment_regexes.clone(),
730 parent_relative: false,
731 }
732 }
733}
734
735#[derive(Debug, Clone, Default, PartialEq, Eq)]
737pub struct UsedExportRule {
738 pub(crate) path: PathRule,
739 pub(crate) exports: Vec<String>,
740}
741
742impl UsedExportRule {
743 #[must_use]
744 pub(crate) fn new(
745 pattern: impl Into<String>,
746 exports: impl IntoIterator<Item = impl Into<String>>,
747 ) -> Self {
748 Self {
749 path: PathRule::new(pattern),
750 exports: exports.into_iter().map(Into::into).collect(),
751 }
752 }
753
754 #[must_use]
755 fn from_static(pattern: &'static str, exports: &'static [&'static str]) -> Self {
756 Self::new(pattern, exports.iter().copied())
757 }
758
759 #[must_use]
760 fn with_excluded_globs<I, S>(mut self, patterns: I) -> Self
761 where
762 I: IntoIterator<Item = S>,
763 S: Into<String>,
764 {
765 self.path = self.path.with_excluded_globs(patterns);
766 self
767 }
768
769 #[must_use]
770 fn with_excluded_regexes<I, S>(mut self, patterns: I) -> Self
771 where
772 I: IntoIterator<Item = S>,
773 S: Into<String>,
774 {
775 self.path = self.path.with_excluded_regexes(patterns);
776 self
777 }
778
779 #[must_use]
780 fn with_excluded_segment_regexes<I, S>(mut self, patterns: I) -> Self
781 where
782 I: IntoIterator<Item = S>,
783 S: Into<String>,
784 {
785 self.path = self.path.with_excluded_segment_regexes(patterns);
786 self
787 }
788
789 #[must_use]
790 fn prefixed(&self, ws_prefix: &str) -> Self {
791 Self {
792 path: self.path.prefixed(ws_prefix),
793 exports: self.exports.clone(),
794 }
795 }
796}
797
798#[derive(Debug, Clone, PartialEq, Eq)]
800pub struct PluginUsedExportRule {
801 pub(crate) plugin_name: String,
802 pub(crate) rule: UsedExportRule,
803}
804
805impl PluginUsedExportRule {
806 #[must_use]
807 pub(crate) fn new(plugin_name: impl Into<String>, rule: UsedExportRule) -> Self {
808 Self {
809 plugin_name: plugin_name.into(),
810 rule,
811 }
812 }
813
814 #[must_use]
815 fn prefixed(&self, ws_prefix: &str) -> Self {
816 Self {
817 plugin_name: self.plugin_name.clone(),
818 rule: self.rule.prefixed(ws_prefix),
819 }
820 }
821}
822
823#[derive(Debug, Clone, Default, PartialEq, Eq)]
825pub struct ProvidedDependencyRule {
826 pub(crate) path: PathRule,
827 exact_specifiers: Vec<String>,
828 specifier_prefixes: Vec<String>,
829}
830
831impl ProvidedDependencyRule {
832 #[must_use]
833 fn new(
834 pattern: impl Into<String>,
835 exact_specifiers: impl IntoIterator<Item = impl Into<String>>,
836 specifier_prefixes: impl IntoIterator<Item = impl Into<String>>,
837 ) -> Self {
838 Self {
839 path: PathRule::new(pattern),
840 exact_specifiers: exact_specifiers.into_iter().map(Into::into).collect(),
841 specifier_prefixes: specifier_prefixes.into_iter().map(Into::into).collect(),
842 }
843 }
844
845 #[must_use]
846 fn prefixed(&self, ws_prefix: &str) -> Self {
847 Self {
848 path: self.path.prefixed(ws_prefix),
849 exact_specifiers: self.exact_specifiers.clone(),
850 specifier_prefixes: self.specifier_prefixes.clone(),
851 }
852 }
853
854 #[must_use]
855 pub(crate) fn may_cover_package(&self, package_name: &str) -> bool {
856 self.exact_specifiers
857 .iter()
858 .chain(self.specifier_prefixes.iter())
859 .any(|specifier| crate::resolve::extract_package_name(specifier) == package_name)
860 }
861
862 #[must_use]
863 pub(crate) fn covers_specifier(&self, specifier: &str) -> bool {
864 self.exact_specifiers
865 .iter()
866 .any(|allowed| allowed == specifier)
867 || self
868 .specifier_prefixes
869 .iter()
870 .any(|prefix| specifier.starts_with(prefix))
871 }
872}
873
874#[derive(Debug, Clone)]
876pub(crate) struct CompiledPathRule {
877 include: globset::GlobMatcher,
878 exclude_globs: Vec<globset::GlobMatcher>,
879 exclude_regexes: Vec<Regex>,
880 exclude_segment_regexes: Vec<Regex>,
881}
882
883impl CompiledPathRule {
884 pub(crate) fn for_entry_rule(rule: &PathRule, rule_kind: &str) -> Option<Self> {
885 let include = match globset::GlobBuilder::new(&rule.pattern)
886 .literal_separator(true)
887 .build()
888 {
889 Ok(glob) => glob.compile_matcher(),
890 Err(err) => {
891 tracing::warn!("invalid {rule_kind} '{}': {err}", rule.pattern);
892 return None;
893 }
894 };
895 Some(Self {
896 include,
897 exclude_globs: compile_excluded_globs(&rule.exclude_globs, rule_kind, &rule.pattern),
898 exclude_regexes: compile_excluded_regexes(
899 &rule.exclude_regexes,
900 rule_kind,
901 &rule.pattern,
902 ),
903 exclude_segment_regexes: compile_excluded_segment_regexes(
904 &rule.exclude_segment_regexes,
905 rule_kind,
906 &rule.pattern,
907 ),
908 })
909 }
910
911 pub(crate) fn for_used_export_rule(rule: &PathRule, rule_kind: &str) -> Option<Self> {
912 let include = match globset::Glob::new(&rule.pattern) {
913 Ok(glob) => glob.compile_matcher(),
914 Err(err) => {
915 tracing::warn!("invalid {rule_kind} '{}': {err}", rule.pattern);
916 return None;
917 }
918 };
919 Some(Self {
920 include,
921 exclude_globs: compile_excluded_globs(&rule.exclude_globs, rule_kind, &rule.pattern),
922 exclude_regexes: compile_excluded_regexes(
923 &rule.exclude_regexes,
924 rule_kind,
925 &rule.pattern,
926 ),
927 exclude_segment_regexes: compile_excluded_segment_regexes(
928 &rule.exclude_segment_regexes,
929 rule_kind,
930 &rule.pattern,
931 ),
932 })
933 }
934
935 #[must_use]
936 pub(crate) fn matches(&self, path: &str) -> bool {
937 self.include.is_match(path)
938 && !self.exclude_globs.iter().any(|glob| glob.is_match(path))
939 && !self
940 .exclude_regexes
941 .iter()
942 .any(|regex| regex.is_match(path))
943 && !matches_segment_regex(path, &self.exclude_segment_regexes)
944 }
945}
946
947fn prefix_workspace_pattern(pattern: &str, ws_prefix: &str) -> String {
948 if pattern.starts_with(ws_prefix) || pattern.starts_with('/') {
949 pattern.to_string()
950 } else {
951 format!("{ws_prefix}/{pattern}")
952 }
953}
954
955fn resolve_parent_relative_pattern(pattern: &str, ws_prefix: &str) -> String {
961 if ws_prefix.starts_with('/') || Path::new(ws_prefix).is_absolute() {
962 return pattern.to_string();
963 }
964 let mut base: Vec<&str> = ws_prefix
967 .split(['/', '\\'])
968 .filter(|segment| !segment.is_empty())
969 .collect();
970 let mut rest = pattern;
971 while let Some(stripped) = rest.strip_prefix("../") {
972 if base.pop().is_none() {
973 return pattern.to_string();
974 }
975 rest = stripped;
976 }
977 if base.is_empty() {
978 rest.to_string()
979 } else {
980 format!("{}/{rest}", base.join("/"))
981 }
982}
983
984fn prefix_workspace_regex(pattern: &str, ws_prefix: &str) -> String {
985 if let Some(pattern) = pattern.strip_prefix('^') {
986 format!("^{}/{}", regex::escape(ws_prefix), pattern)
987 } else {
988 format!("^{}/(?:{})", regex::escape(ws_prefix), pattern)
989 }
990}
991
992fn compile_excluded_globs(
993 patterns: &[String],
994 rule_kind: &str,
995 rule_pattern: &str,
996) -> Vec<globset::GlobMatcher> {
997 patterns
998 .iter()
999 .filter_map(|pattern| {
1000 match globset::GlobBuilder::new(pattern)
1001 .literal_separator(true)
1002 .build()
1003 {
1004 Ok(glob) => Some(glob.compile_matcher()),
1005 Err(err) => {
1006 tracing::warn!(
1007 "skipping invalid excluded glob '{}' for {} '{}': {err}",
1008 pattern,
1009 rule_kind,
1010 rule_pattern
1011 );
1012 None
1013 }
1014 }
1015 })
1016 .collect()
1017}
1018
1019fn compile_excluded_regexes(
1020 patterns: &[String],
1021 rule_kind: &str,
1022 rule_pattern: &str,
1023) -> Vec<Regex> {
1024 patterns
1025 .iter()
1026 .filter_map(|pattern| match Regex::new(pattern) {
1027 Ok(regex) => Some(regex),
1028 Err(err) => {
1029 tracing::warn!(
1030 "skipping invalid excluded regex '{}' for {} '{}': {err}",
1031 pattern,
1032 rule_kind,
1033 rule_pattern
1034 );
1035 None
1036 }
1037 })
1038 .collect()
1039}
1040
1041fn compile_excluded_segment_regexes(
1042 patterns: &[String],
1043 rule_kind: &str,
1044 rule_pattern: &str,
1045) -> Vec<Regex> {
1046 patterns
1047 .iter()
1048 .filter_map(|pattern| match Regex::new(pattern) {
1049 Ok(regex) => Some(regex),
1050 Err(err) => {
1051 tracing::warn!(
1052 "skipping invalid excluded segment regex '{}' for {} '{}': {err}",
1053 pattern,
1054 rule_kind,
1055 rule_pattern
1056 );
1057 None
1058 }
1059 })
1060 .collect()
1061}
1062
1063fn matches_segment_regex(path: &str, regexes: &[Regex]) -> bool {
1064 path.split('/')
1065 .any(|segment| regexes.iter().any(|regex| regex.is_match(segment)))
1066}
1067
1068impl From<String> for PathRule {
1069 fn from(pattern: String) -> Self {
1070 Self::new(pattern)
1071 }
1072}
1073
1074impl From<&str> for PathRule {
1075 fn from(pattern: &str) -> Self {
1076 Self::new(pattern)
1077 }
1078}
1079
1080impl std::ops::Deref for PathRule {
1081 type Target = str;
1082
1083 fn deref(&self) -> &Self::Target {
1084 &self.pattern
1085 }
1086}
1087
1088impl PartialEq<&str> for PathRule {
1089 fn eq(&self, other: &&str) -> bool {
1090 self.pattern == *other
1091 }
1092}
1093
1094impl PartialEq<str> for PathRule {
1095 fn eq(&self, other: &str) -> bool {
1096 self.pattern == other
1097 }
1098}
1099
1100impl PartialEq<String> for PathRule {
1101 fn eq(&self, other: &String) -> bool {
1102 &self.pattern == other
1103 }
1104}
1105
1106pub trait Plugin: Send + Sync {
1108 fn name(&self) -> &'static str;
1110
1111 fn enablers(&self) -> &'static [&'static str] {
1114 &[]
1115 }
1116
1117 fn is_enabled(&self, pkg: &PackageJson, root: &Path) -> bool {
1120 let deps = pkg.all_dependency_names();
1121 self.is_enabled_with_deps(&deps, root)
1122 }
1123
1124 fn is_enabled_with_deps(&self, deps: &[String], _root: &Path) -> bool {
1127 let enablers = self.enablers();
1128 if enablers.is_empty() {
1129 return false;
1130 }
1131 enablers.iter().any(|enabler| {
1132 if enabler.ends_with('/') {
1133 deps.iter().any(|d| d.starts_with(enabler))
1135 } else {
1136 deps.iter().any(|d| d == enabler)
1137 }
1138 })
1139 }
1140
1141 fn is_enabled_with_files(
1154 &self,
1155 deps: &[String],
1156 root: &Path,
1157 _discovered_files: &[PathBuf],
1158 _candidate_index: Option<®istry::ConfigCandidateIndex>,
1159 ) -> bool {
1160 self.is_enabled_with_deps(deps, root)
1161 }
1162
1163 fn script_enablers(&self) -> &'static [&'static str] {
1165 &[]
1166 }
1167
1168 fn is_enabled_with_scripts(
1170 &self,
1171 script_packages: &rustc_hash::FxHashSet<String>,
1172 _root: &Path,
1173 ) -> bool {
1174 let enablers = self.script_enablers();
1175 if enablers.is_empty() {
1176 return false;
1177 }
1178 enablers.iter().any(|enabler| {
1179 if enabler.ends_with('/') {
1180 script_packages
1181 .iter()
1182 .any(|package| package.starts_with(enabler))
1183 } else {
1184 script_packages.contains(*enabler)
1185 }
1186 })
1187 }
1188
1189 fn entry_patterns(&self) -> &'static [&'static str] {
1191 &[]
1192 }
1193
1194 fn entry_pattern_rules(&self) -> Vec<PathRule> {
1196 self.entry_patterns()
1197 .iter()
1198 .map(|pattern| PathRule::from_static(pattern))
1199 .collect()
1200 }
1201
1202 fn entry_point_role(&self) -> EntryPointRole {
1207 builtin_entry_point_role(self.name())
1208 }
1209
1210 fn config_patterns(&self) -> &'static [&'static str] {
1212 &[]
1213 }
1214
1215 fn always_used(&self) -> &'static [&'static str] {
1217 &[]
1218 }
1219
1220 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
1222 vec![]
1223 }
1224
1225 fn used_export_rules(&self) -> Vec<UsedExportRule> {
1227 self.used_exports()
1228 .into_iter()
1229 .map(|(pattern, exports)| UsedExportRule::from_static(pattern, exports))
1230 .collect()
1231 }
1232
1233 fn used_class_members(&self) -> &'static [&'static str] {
1238 &[]
1239 }
1240
1241 fn used_class_member_rules(&self) -> Vec<UsedClassMemberRule> {
1249 Vec::new()
1250 }
1251
1252 fn framework_class_member_contracts(&self) -> Vec<SemanticFrameworkContract> {
1255 Vec::new()
1256 }
1257
1258 fn fixture_glob_patterns(&self) -> &'static [&'static str] {
1263 &[]
1264 }
1265
1266 fn discovery_hidden_dirs(&self) -> &'static [&'static str] {
1271 &[]
1272 }
1273
1274 fn tooling_dependencies(&self) -> &'static [&'static str] {
1277 &[]
1278 }
1279
1280 fn virtual_module_prefixes(&self) -> &'static [&'static str] {
1285 &[]
1286 }
1287
1288 fn virtual_package_suffixes(&self) -> &'static [&'static str] {
1294 &[]
1295 }
1296
1297 fn generated_import_patterns(&self) -> &'static [&'static str] {
1303 &[]
1304 }
1305
1306 fn generated_type_import_prefixes(&self) -> &'static [&'static str] {
1311 &[]
1312 }
1313
1314 fn path_aliases(&self, _root: &Path) -> Vec<(&'static str, String)> {
1324 vec![]
1325 }
1326
1327 fn static_dir_mappings(&self, _root: &Path) -> Vec<(std::path::PathBuf, String)> {
1339 vec![]
1340 }
1341
1342 fn auto_imports(&self, _root: &Path) -> Vec<AutoImportRule> {
1360 Vec::new()
1361 }
1362
1363 fn provided_dependencies(&self) -> Vec<ProvidedDependencyRule> {
1365 Vec::new()
1366 }
1367
1368 fn is_enabled_with_package_json(&self, _pkg: &PackageJson, _root: &Path) -> bool {
1370 false
1371 }
1372
1373 fn resolve_package_json(&self, _pkg: &PackageJson, _root: &Path) -> PluginResult {
1375 PluginResult::default()
1376 }
1377
1378 fn package_json_referenced_dependencies(
1383 &self,
1384 _pkg: &PackageJson,
1385 _root: &Path,
1386 ) -> Vec<String> {
1387 Vec::new()
1388 }
1389
1390 fn resolve_config(&self, _config_path: &Path, _source: &str, _root: &Path) -> PluginResult {
1395 PluginResult::default()
1396 }
1397
1398 fn package_json_config_key(&self) -> Option<&'static str> {
1403 None
1404 }
1405}
1406
1407fn builtin_entry_point_role(name: &str) -> EntryPointRole {
1408 if TEST_ENTRY_POINT_PLUGINS.contains(&name) {
1409 EntryPointRole::Test
1410 } else if RUNTIME_ENTRY_POINT_PLUGINS.contains(&name) {
1411 EntryPointRole::Runtime
1412 } else {
1413 EntryPointRole::Support
1414 }
1415}
1416
1417macro_rules! define_plugin {
1478 (
1479 struct $name:ident => $display:expr,
1480 enablers: $enablers:expr
1481 $(, entry_patterns: $entry:expr)?
1482 $(, config_patterns: $config:expr)?
1483 $(, always_used: $always:expr)?
1484 $(, tooling_dependencies: $tooling:expr)?
1485 $(, fixture_glob_patterns: $fixtures:expr)?
1486 $(, discovery_hidden_dirs: $hidden_dirs:expr)?
1487 $(, virtual_module_prefixes: $virtual:expr)?
1488 $(, virtual_package_suffixes: $virtual_suffixes:expr)?
1489 $(, generated_type_import_prefixes: $generated_type_prefixes:expr)?
1490 $(, provided_dependencies: $provided_dependencies:expr)?
1491 $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
1492 , resolve_config: imports_only
1493 $(,)?
1494 ) => {
1495 pub struct $name;
1496
1497 impl Plugin for $name {
1498 fn name(&self) -> &'static str {
1499 $display
1500 }
1501
1502 fn enablers(&self) -> &'static [&'static str] {
1503 $enablers
1504 }
1505
1506 $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
1507 $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
1508 $( fn always_used(&self) -> &'static [&'static str] { $always } )?
1509 $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
1510 $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
1511 $( fn discovery_hidden_dirs(&self) -> &'static [&'static str] { $hidden_dirs } )?
1512 $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
1513 $( fn virtual_package_suffixes(&self) -> &'static [&'static str] { $virtual_suffixes } )?
1514 $( fn generated_type_import_prefixes(&self) -> &'static [&'static str] { $generated_type_prefixes } )?
1515 $( fn provided_dependencies(&self) -> Vec<ProvidedDependencyRule> { $provided_dependencies } )?
1516
1517 $(
1518 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
1519 vec![$( ($pat, $exports) ),*]
1520 }
1521 )?
1522
1523 fn resolve_config(
1524 &self,
1525 config_path: &std::path::Path,
1526 source: &str,
1527 _root: &std::path::Path,
1528 ) -> PluginResult {
1529 let mut result = PluginResult::default();
1530 crate::plugins::add_import_referenced_dependencies(
1531 &mut result,
1532 source,
1533 config_path,
1534 );
1535 result
1536 }
1537 }
1538 };
1539
1540 (
1541 struct $name:ident => $display:expr,
1542 enablers: $enablers:expr
1543 $(, entry_patterns: $entry:expr)?
1544 $(, config_patterns: $config:expr)?
1545 $(, always_used: $always:expr)?
1546 $(, tooling_dependencies: $tooling:expr)?
1547 $(, fixture_glob_patterns: $fixtures:expr)?
1548 $(, discovery_hidden_dirs: $hidden_dirs:expr)?
1549 $(, virtual_module_prefixes: $virtual:expr)?
1550 $(, virtual_package_suffixes: $virtual_suffixes:expr)?
1551 $(, generated_type_import_prefixes: $generated_type_prefixes:expr)?
1552 $(, provided_dependencies: $provided_dependencies:expr)?
1553 $(, package_json_config_key: $pkg_key:expr)?
1554 $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
1555 , resolve_config($cp:ident, $src:ident, $root:ident) $body:block
1556 $(,)?
1557 ) => {
1558 pub struct $name;
1559
1560 impl Plugin for $name {
1561 fn name(&self) -> &'static str {
1562 $display
1563 }
1564
1565 fn enablers(&self) -> &'static [&'static str] {
1566 $enablers
1567 }
1568
1569 $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
1570 $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
1571 $( fn always_used(&self) -> &'static [&'static str] { $always } )?
1572 $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
1573 $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
1574 $( fn discovery_hidden_dirs(&self) -> &'static [&'static str] { $hidden_dirs } )?
1575 $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
1576 $( fn virtual_package_suffixes(&self) -> &'static [&'static str] { $virtual_suffixes } )?
1577 $( fn generated_type_import_prefixes(&self) -> &'static [&'static str] { $generated_type_prefixes } )?
1578 $( fn provided_dependencies(&self) -> Vec<ProvidedDependencyRule> { $provided_dependencies } )?
1579
1580 $(
1581 fn package_json_config_key(&self) -> Option<&'static str> {
1582 Some($pkg_key)
1583 }
1584 )?
1585
1586 $(
1587 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
1588 vec![$( ($pat, $exports) ),*]
1589 }
1590 )?
1591
1592 fn resolve_config(
1593 &self,
1594 $cp: &std::path::Path,
1595 $src: &str,
1596 $root: &std::path::Path,
1597 ) -> PluginResult
1598 $body
1599 }
1600 };
1601
1602 (
1603 struct $name:ident => $display:expr,
1604 enablers: $enablers:expr
1605 $(, entry_patterns: $entry:expr)?
1606 $(, config_patterns: $config:expr)?
1607 $(, always_used: $always:expr)?
1608 $(, tooling_dependencies: $tooling:expr)?
1609 $(, fixture_glob_patterns: $fixtures:expr)?
1610 $(, discovery_hidden_dirs: $hidden_dirs:expr)?
1611 $(, virtual_module_prefixes: $virtual:expr)?
1612 $(, virtual_package_suffixes: $virtual_suffixes:expr)?
1613 $(, generated_type_import_prefixes: $generated_type_prefixes:expr)?
1614 $(, provided_dependencies: $provided_dependencies:expr)?
1615 $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
1616 $(,)?
1617 ) => {
1618 pub struct $name;
1619
1620 impl Plugin for $name {
1621 fn name(&self) -> &'static str {
1622 $display
1623 }
1624
1625 fn enablers(&self) -> &'static [&'static str] {
1626 $enablers
1627 }
1628
1629 $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
1630 $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
1631 $( fn always_used(&self) -> &'static [&'static str] { $always } )?
1632 $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
1633 $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
1634 $( fn discovery_hidden_dirs(&self) -> &'static [&'static str] { $hidden_dirs } )?
1635 $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
1636 $( fn virtual_package_suffixes(&self) -> &'static [&'static str] { $virtual_suffixes } )?
1637 $( fn generated_type_import_prefixes(&self) -> &'static [&'static str] { $generated_type_prefixes } )?
1638 $( fn provided_dependencies(&self) -> Vec<ProvidedDependencyRule> { $provided_dependencies } )?
1639
1640 $(
1641 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
1642 vec![$( ($pat, $exports) ),*]
1643 }
1644 )?
1645 }
1646 };
1647}
1648
1649pub mod config_parser;
1650mod config_value_credits;
1651mod manifest;
1652pub mod manifest_entries;
1653pub mod registry;
1654mod tooling;
1655
1656pub(crate) use module_federation::runtime_remotes;
1657pub use registry::{AggregatedPluginResult, PluginRegistry};
1658pub(crate) use tooling::is_known_tooling_dependency;
1659
1660fn add_import_referenced_dependencies(result: &mut PluginResult, source: &str, config_path: &Path) {
1661 let imports = config_parser::extract_imports(source, config_path);
1662 for import in &imports {
1663 result
1664 .referenced_dependencies
1665 .push(crate::resolve::extract_package_name(import));
1666 }
1667}
1668
1669fn credit_environment_optional_peers(environment: &str, result: &mut PluginResult) {
1681 credit_config_value(
1682 config_value_credits::CreditSurface::TestEnvironmentOptionalPeer,
1683 canonical_test_environment(environment),
1684 result,
1685 );
1686}
1687
1688fn credit_config_value(
1693 surface: config_value_credits::CreditSurface,
1694 value: &str,
1695 result: &mut PluginResult,
1696) -> bool {
1697 let Some(packages) = config_value_credits::credited_packages(surface, value) else {
1698 return false;
1699 };
1700 result
1701 .referenced_dependencies
1702 .extend(packages.iter().cloned());
1703 true
1704}
1705
1706fn canonical_test_environment(environment: &str) -> &str {
1714 environment
1715 .strip_prefix("jest-environment-")
1716 .or_else(|| environment.strip_prefix("vitest-environment-"))
1717 .unwrap_or(environment)
1718}
1719
1720mod adonis;
1721mod angular;
1722mod astro;
1723mod ava;
1724mod babel;
1725mod biome;
1726mod browser_extension;
1727mod bun;
1728mod c8;
1729mod capacitor;
1730mod changesets;
1731mod commit_and_tag_version;
1732mod commitizen;
1733mod commitlint;
1734mod content_collections;
1735mod contentlayer;
1736mod convex;
1737mod cspell;
1738mod cucumber;
1739mod cypress;
1740mod danger;
1741mod deno;
1742mod dependency_cruiser;
1743mod docusaurus;
1744mod drizzle;
1745mod electron;
1746mod ember;
1747mod eslint;
1748mod expo;
1749mod expo_router;
1750mod firebase;
1751mod fumadocs;
1752mod gatsby;
1753mod graphql_codegen;
1754mod hardhat;
1755mod husky;
1756mod i18next;
1757mod ionic;
1758mod jest;
1759mod k6;
1760mod karma;
1761mod knex;
1762mod kysely;
1763mod lefthook;
1764mod lexical;
1765mod lint_staged;
1766mod lit;
1767mod markdownlint;
1768mod mintlify;
1769mod mocha;
1770mod module_federation;
1771mod msw;
1772mod napi_rs;
1773mod nestjs;
1774mod next_intl;
1775mod nextjs;
1776mod nitro;
1777mod nodemon;
1778pub(crate) mod nuxt;
1779mod nx;
1780mod nyc;
1781mod obsidian;
1782mod openapi_ts;
1783mod opencode;
1784mod opennext_cloudflare;
1785mod oxfmt;
1786mod oxlint;
1787mod pandacss;
1788mod parcel;
1789mod pinia;
1790mod pkg_utils;
1791mod playwright;
1792mod plop;
1793mod pm2;
1794mod pnpm;
1795mod postcss;
1796mod prettier;
1797mod prisma;
1798mod qwik;
1799mod react_compiler;
1800mod react_native;
1801mod react_router;
1802mod redwoodsdk;
1803mod relay;
1804mod remark;
1805mod remix;
1806mod rolldown;
1807mod rollup;
1808mod rsbuild;
1809mod rspack;
1810mod rspress;
1811mod sanity;
1812mod semantic_release;
1813mod sentry;
1814mod simple_git_hooks;
1815mod size_limit;
1816mod storybook;
1817mod stryker;
1818mod stylelint;
1819mod supabase;
1820mod sveltekit;
1821mod svgo;
1822mod svgr;
1823mod swc;
1824mod syncpack;
1825mod tailwind;
1826mod tanstack_router;
1827mod tap;
1828mod test_alias;
1829mod tsd;
1830mod tsdown;
1831mod tsup;
1832mod turborepo;
1833mod typedoc;
1834mod typeorm;
1835mod typescript;
1836mod unocss;
1837mod varlock;
1838mod velite;
1839mod vercel;
1840mod vite;
1841mod vitepress;
1842mod vitest;
1843mod vscode;
1844mod webdriverio;
1845mod webpack;
1846mod wrangler;
1847mod wuchale;
1848mod wxt;
1849
1850#[cfg(test)]
1851mod tests {
1852 use super::*;
1853 use std::path::Path;
1854
1855 #[test]
1856 fn is_enabled_with_deps_exact_match() {
1857 let plugin = nextjs::NextJsPlugin;
1858 let deps = vec!["next".to_string()];
1859 assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1860 }
1861
1862 #[test]
1863 fn is_enabled_with_deps_no_match() {
1864 let plugin = nextjs::NextJsPlugin;
1865 let deps = vec!["react".to_string()];
1866 assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1867 }
1868
1869 #[test]
1870 fn is_enabled_with_deps_empty_deps() {
1871 let plugin = nextjs::NextJsPlugin;
1872 let deps: Vec<String> = vec![];
1873 assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1874 }
1875
1876 #[test]
1877 fn environment_optional_peers_come_from_the_credit_catalogue() {
1878 for environment in [
1879 "jsdom",
1880 "jest-environment-jsdom",
1881 "vitest-environment-jsdom",
1882 ] {
1883 let mut result = PluginResult::default();
1884 credit_environment_optional_peers(environment, &mut result);
1885 assert_eq!(
1886 result.referenced_dependencies,
1887 vec!["canvas".to_string()],
1888 "expected the catalogue credit for {environment}"
1889 );
1890 }
1891 }
1892
1893 #[test]
1894 fn environment_without_a_catalogue_row_credits_nothing() {
1895 let mut result = PluginResult::default();
1896 credit_environment_optional_peers("happy-dom", &mut result);
1897 assert!(result.referenced_dependencies.is_empty());
1898 }
1899
1900 #[test]
1901 fn entry_point_role_defaults_are_centralized() {
1902 assert_eq!(vite::VitePlugin.entry_point_role(), EntryPointRole::Runtime);
1903 assert_eq!(
1904 vitest::VitestPlugin.entry_point_role(),
1905 EntryPointRole::Test
1906 );
1907 assert_eq!(
1908 storybook::StorybookPlugin.entry_point_role(),
1909 EntryPointRole::Support
1910 );
1911 assert_eq!(
1912 obsidian::ObsidianPlugin.entry_point_role(),
1913 EntryPointRole::Runtime
1914 );
1915 assert_eq!(knex::KnexPlugin.entry_point_role(), EntryPointRole::Support);
1916 }
1917
1918 #[test]
1919 fn plugins_with_entry_patterns_have_explicit_role_intent() {
1920 let runtime_or_test_or_support: rustc_hash::FxHashSet<&'static str> =
1921 TEST_ENTRY_POINT_PLUGINS
1922 .iter()
1923 .chain(RUNTIME_ENTRY_POINT_PLUGINS.iter())
1924 .chain(SUPPORT_ENTRY_POINT_PLUGINS.iter())
1925 .copied()
1926 .collect();
1927
1928 for plugin in crate::plugins::registry::builtin::create_builtin_plugins() {
1929 if plugin.entry_patterns().is_empty() {
1930 continue;
1931 }
1932 assert!(
1933 runtime_or_test_or_support.contains(plugin.name()),
1934 "plugin '{}' exposes entry patterns but is missing from the entry-point role map",
1935 plugin.name()
1936 );
1937 }
1938 }
1939
1940 #[test]
1943 fn plugin_result_is_empty_only_when_every_field_is_empty() {
1944 type Fill = fn(&mut PluginResult);
1945
1946 assert!(PluginResult::default().is_empty());
1947
1948 let rows: [(&str, Fill); 15] = [
1949 ("entry_patterns", |r| {
1950 r.entry_patterns.push(PathRule::new("src/*.ts"));
1951 }),
1952 ("used_exports", |r| {
1953 r.used_exports
1954 .push(UsedExportRule::new("src/*.ts", ["default"]));
1955 }),
1956 ("used_class_members", |r| {
1957 r.used_class_members
1958 .push(UsedClassMemberRule::from("render"));
1959 }),
1960 ("referenced_dependencies", |r| {
1961 r.referenced_dependencies.push("lodash".to_string());
1962 }),
1963 ("package_referenced_dependencies", |r| {
1964 r.package_referenced_dependencies
1965 .push((PathBuf::from("/project/pkg"), "lodash".to_string()));
1966 }),
1967 ("always_used_files", |r| {
1968 r.always_used_files.push("**/*.stories.tsx".to_string());
1969 }),
1970 ("path_aliases", |r| {
1971 r.path_aliases.push(("@".to_string(), "src".to_string()));
1972 }),
1973 ("setup_files", |r| {
1974 r.setup_files.push(PathBuf::from("/setup.ts"));
1975 }),
1976 ("fixture_patterns", |r| {
1977 r.fixture_patterns.push("**/__fixtures__/**/*".to_string());
1978 }),
1979 ("scss_include_paths", |r| {
1980 r.scss_include_paths.push(PathBuf::from("/project/styles"));
1981 }),
1982 ("static_dir_mappings", |r| {
1983 r.static_dir_mappings
1984 .push((PathBuf::from("/project/public"), "/".to_string()));
1985 }),
1986 ("framework_static_dir_mappings", |r| {
1987 r.framework_static_dir_mappings
1988 .push((PathBuf::from("/project/static"), "/".to_string()));
1989 }),
1990 ("provided_dependencies", |r| {
1991 r.provided_dependencies.push(ProvidedDependencyRule::new(
1992 "**/*.stories.tsx",
1993 ["react"],
1994 Vec::<String>::new(),
1995 ));
1996 }),
1997 ("config_diagnostics", |r| {
1998 r.config_diagnostics
1999 .push(PluginConfigDiagnostic::unreadable(
2000 Path::new("/project/webpack.config.js"),
2001 "webpack",
2002 "exposes",
2003 "dynamic-value",
2004 ));
2005 }),
2006 ("federation_sources", |r| {
2007 r.federation_sources.push(FederationSource {
2008 target: FederationSourceTarget::Remote("app".to_string()),
2009 config_path: PathBuf::from("/project/webpack.config.js"),
2010 plugin: "webpack".to_string(),
2011 key: "remotes",
2012 });
2013 }),
2014 ];
2015
2016 for (field, fill) in rows {
2017 let mut result = PluginResult::default();
2018 fill(&mut result);
2019 assert!(
2020 !result.is_empty(),
2021 "a result with only {field} set must not be empty"
2022 );
2023 }
2024 }
2025
2026 #[test]
2027 fn is_enabled_with_deps_prefix_match() {
2028 let plugin = storybook::StorybookPlugin;
2029 let deps = vec!["@storybook/react".to_string()];
2030 assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
2031 }
2032
2033 #[test]
2034 fn is_enabled_with_deps_prefix_no_match_without_slash() {
2035 let plugin = storybook::StorybookPlugin;
2036 let deps = vec!["@storybookish".to_string()];
2037 assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
2038 }
2039
2040 #[test]
2041 fn is_enabled_with_deps_multiple_enablers() {
2042 let plugin = vitest::VitestPlugin;
2043 let deps_vitest = vec!["vitest".to_string()];
2044 let deps_none = vec!["mocha".to_string()];
2045 assert!(plugin.is_enabled_with_deps(&deps_vitest, Path::new("/project")));
2046 assert!(!plugin.is_enabled_with_deps(&deps_none, Path::new("/project")));
2047 }
2048
2049 #[test]
2050 fn plugin_resolve_config_default_returns_empty() {
2051 let plugin = commitizen::CommitizenPlugin;
2052 let result = plugin.resolve_config(
2053 Path::new("/project/config.js"),
2054 "const x = 1;",
2055 Path::new("/project"),
2056 );
2057 assert!(result.is_empty());
2058 }
2059
2060 #[test]
2061 fn is_enabled_with_deps_exact_and_prefix_both_work() {
2062 let plugin = storybook::StorybookPlugin;
2063 let deps_exact = vec!["storybook".to_string()];
2064 assert!(plugin.is_enabled_with_deps(&deps_exact, Path::new("/project")));
2065 let deps_prefix = vec!["@storybook/vue3".to_string()];
2066 assert!(plugin.is_enabled_with_deps(&deps_prefix, Path::new("/project")));
2067 }
2068
2069 #[test]
2070 fn is_enabled_with_deps_multiple_enablers_remix() {
2071 let plugin = remix::RemixPlugin;
2072 let deps_node = vec!["@remix-run/node".to_string()];
2073 assert!(plugin.is_enabled_with_deps(&deps_node, Path::new("/project")));
2074 let deps_react = vec!["@remix-run/react".to_string()];
2075 assert!(plugin.is_enabled_with_deps(&deps_react, Path::new("/project")));
2076 let deps_cf = vec!["@remix-run/cloudflare".to_string()];
2077 assert!(plugin.is_enabled_with_deps(&deps_cf, Path::new("/project")));
2078 }
2079
2080 struct MinimalPlugin;
2081 impl Plugin for MinimalPlugin {
2082 fn name(&self) -> &'static str {
2083 "minimal"
2084 }
2085 }
2086
2087 #[test]
2088 fn default_resolve_config_returns_empty() {
2089 let r = MinimalPlugin.resolve_config(
2090 Path::new("config.js"),
2091 "export default {}",
2092 Path::new("/"),
2093 );
2094 assert!(r.is_empty());
2095 }
2096
2097 #[test]
2098 fn default_package_json_metadata_hooks_are_empty() {
2099 let pkg = PackageJson::default();
2100 assert!(!MinimalPlugin.is_enabled_with_package_json(&pkg, Path::new("/")));
2101 assert!(
2102 MinimalPlugin
2103 .resolve_package_json(&pkg, Path::new("/"))
2104 .is_empty()
2105 );
2106 }
2107
2108 #[test]
2109 fn default_is_enabled_returns_false_when_no_enablers() {
2110 let deps = vec!["anything".to_string()];
2111 assert!(!MinimalPlugin.is_enabled_with_deps(&deps, Path::new("/")));
2112 }
2113
2114 #[test]
2115 fn all_builtin_plugin_names_are_non_empty_and_unique() {
2116 let plugins = registry::builtin::create_builtin_plugins();
2117 let mut seen = std::collections::BTreeSet::new();
2118 for p in &plugins {
2119 let name = p.name();
2120 assert!(
2121 !name.is_empty(),
2122 "builtin plugins must have a non-empty name"
2123 );
2124 assert!(seen.insert(name), "duplicate plugin name: {name}");
2125 }
2126 }
2127
2128 #[test]
2129 fn all_builtin_plugins_have_activation_signals() {
2130 const NON_DEPENDENCY_ACTIVATED_PLUGINS: &[&str] = &["napi-rs", "deno"];
2133 let plugins = registry::builtin::create_builtin_plugins();
2134 for p in &plugins {
2135 assert!(
2136 !p.enablers().is_empty()
2137 || !p.script_enablers().is_empty()
2138 || NON_DEPENDENCY_ACTIVATED_PLUGINS.contains(&p.name()),
2139 "plugin '{}' has no activation signal",
2140 p.name()
2141 );
2142 }
2143 }
2144
2145 #[test]
2146 fn plugins_with_config_patterns_have_always_used() {
2147 let plugins = registry::builtin::create_builtin_plugins();
2148 for p in &plugins {
2149 if !p.config_patterns().is_empty() {
2150 assert!(
2151 !p.always_used().is_empty(),
2152 "plugin '{}' has config_patterns but no always_used",
2153 p.name()
2154 );
2155 }
2156 }
2157 }
2158
2159 #[test]
2160 fn framework_plugins_enablers() {
2161 let cases: Vec<(&dyn Plugin, &[&str])> = vec![
2162 (&nextjs::NextJsPlugin, &["next"]),
2163 (&nuxt::NuxtPlugin, &["nuxt"]),
2164 (&angular::AngularPlugin, &["@angular/core"]),
2165 (&ionic::IonicPlugin, &["@ionic/angular"]),
2166 (&sveltekit::SvelteKitPlugin, &["@sveltejs/kit"]),
2167 (&gatsby::GatsbyPlugin, &["gatsby"]),
2168 ];
2169 for (plugin, expected_enablers) in cases {
2170 let enablers = plugin.enablers();
2171 for expected in expected_enablers {
2172 assert!(
2173 enablers.contains(expected),
2174 "plugin '{}' should have '{}'",
2175 plugin.name(),
2176 expected
2177 );
2178 }
2179 }
2180 }
2181
2182 #[test]
2183 fn testing_plugins_enablers() {
2184 let cases: Vec<(&dyn Plugin, &str)> = vec![
2185 (&jest::JestPlugin, "jest"),
2186 (&vitest::VitestPlugin, "vitest"),
2187 (&playwright::PlaywrightPlugin, "@playwright/test"),
2188 (&cypress::CypressPlugin, "cypress"),
2189 (&mocha::MochaPlugin, "mocha"),
2190 (&stryker::StrykerPlugin, "@stryker-mutator/core"),
2191 ];
2192 for (plugin, enabler) in cases {
2193 assert!(
2194 plugin.enablers().contains(&enabler),
2195 "plugin '{}' should have '{}'",
2196 plugin.name(),
2197 enabler
2198 );
2199 }
2200 }
2201
2202 #[test]
2203 fn bundler_plugins_enablers() {
2204 let cases: Vec<(&dyn Plugin, &str)> = vec![
2205 (&vite::VitePlugin, "vite"),
2206 (&webpack::WebpackPlugin, "webpack"),
2207 (&rollup::RollupPlugin, "rollup"),
2208 ];
2209 for (plugin, enabler) in cases {
2210 assert!(
2211 plugin.enablers().contains(&enabler),
2212 "plugin '{}' should have '{}'",
2213 plugin.name(),
2214 enabler
2215 );
2216 }
2217 }
2218
2219 #[test]
2220 fn test_plugins_have_test_entry_patterns() {
2221 let test_plugins: Vec<&dyn Plugin> = vec![
2222 &bun::BunPlugin,
2223 &deno::DenoPlugin,
2224 &jest::JestPlugin,
2225 &vitest::VitestPlugin,
2226 &mocha::MochaPlugin,
2227 &tap::TapPlugin,
2228 &tsd::TsdPlugin,
2229 ];
2230 for plugin in test_plugins {
2231 let patterns = plugin.entry_patterns();
2232 assert!(
2233 !patterns.is_empty(),
2234 "test plugin '{}' should have entry patterns",
2235 plugin.name()
2236 );
2237 assert!(
2238 patterns
2239 .iter()
2240 .any(|p| p.contains("test") || p.contains("spec") || p.contains("__tests__")),
2241 "test plugin '{}' should have test/spec patterns",
2242 plugin.name()
2243 );
2244 }
2245 }
2246
2247 #[test]
2248 fn framework_plugins_have_entry_patterns() {
2249 let plugins: Vec<&dyn Plugin> = vec![
2250 &nextjs::NextJsPlugin,
2251 &nuxt::NuxtPlugin,
2252 &angular::AngularPlugin,
2253 &sveltekit::SvelteKitPlugin,
2254 ];
2255 for plugin in plugins {
2256 assert!(
2257 !plugin.entry_patterns().is_empty(),
2258 "framework plugin '{}' should have entry patterns",
2259 plugin.name()
2260 );
2261 }
2262 }
2263
2264 #[test]
2265 fn plugins_with_resolve_config_have_config_patterns() {
2266 let plugins: Vec<&dyn Plugin> = vec![
2267 &jest::JestPlugin,
2268 &vitest::VitestPlugin,
2269 &babel::BabelPlugin,
2270 &eslint::EslintPlugin,
2271 &webpack::WebpackPlugin,
2272 &storybook::StorybookPlugin,
2273 &typescript::TypeScriptPlugin,
2274 &postcss::PostCssPlugin,
2275 &nextjs::NextJsPlugin,
2276 &nuxt::NuxtPlugin,
2277 &angular::AngularPlugin,
2278 &nx::NxPlugin,
2279 &stryker::StrykerPlugin,
2280 &wuchale::WuchalePlugin,
2281 &rollup::RollupPlugin,
2282 &sveltekit::SvelteKitPlugin,
2283 &prettier::PrettierPlugin,
2284 &contentlayer::ContentlayerPlugin,
2285 ];
2286 for plugin in plugins {
2287 assert!(
2288 !plugin.config_patterns().is_empty(),
2289 "plugin '{}' with resolve_config should have config_patterns",
2290 plugin.name()
2291 );
2292 }
2293 }
2294
2295 #[test]
2296 fn plugin_tooling_deps_include_enabler_package() {
2297 let plugins: Vec<&dyn Plugin> = vec![
2298 &jest::JestPlugin,
2299 &vitest::VitestPlugin,
2300 &webpack::WebpackPlugin,
2301 &typescript::TypeScriptPlugin,
2302 &eslint::EslintPlugin,
2303 &prettier::PrettierPlugin,
2304 &danger::DangerPlugin,
2305 &stryker::StrykerPlugin,
2306 &wuchale::WuchalePlugin,
2307 &contentlayer::ContentlayerPlugin,
2308 ];
2309 for plugin in plugins {
2310 let tooling = plugin.tooling_dependencies();
2311 let enablers = plugin.enablers();
2312 assert!(
2313 enablers
2314 .iter()
2315 .any(|e| !e.ends_with('/') && tooling.contains(e)),
2316 "plugin '{}': at least one non-prefix enabler should be in tooling_dependencies",
2317 plugin.name()
2318 );
2319 }
2320 }
2321
2322 #[test]
2323 fn nextjs_has_used_exports_for_pages() {
2324 let plugin = nextjs::NextJsPlugin;
2325 let exports = plugin.used_exports();
2326 assert!(!exports.is_empty());
2327 assert!(exports.iter().any(|(_, names)| names.contains(&"default")));
2328 }
2329
2330 #[test]
2331 fn remix_has_used_exports_for_routes() {
2332 let plugin = remix::RemixPlugin;
2333 let exports = plugin.used_exports();
2334 assert!(!exports.is_empty());
2335 let route_entry = exports.iter().find(|(pat, _)| pat.contains("routes"));
2336 assert!(route_entry.is_some());
2337 let (_, names) = route_entry.unwrap();
2338 assert!(names.contains(&"loader"));
2339 assert!(names.contains(&"action"));
2340 assert!(names.contains(&"default"));
2341 }
2342
2343 #[test]
2344 fn sveltekit_has_used_exports_for_routes() {
2345 let plugin = sveltekit::SvelteKitPlugin;
2346 let exports = plugin.used_exports();
2347 assert!(!exports.is_empty());
2348 assert!(exports.iter().any(|(_, names)| names.contains(&"GET")));
2349 }
2350
2351 #[test]
2352 fn nuxt_has_hash_virtual_prefix() {
2353 assert!(nuxt::NuxtPlugin.virtual_module_prefixes().contains(&"#"));
2354 }
2355
2356 #[test]
2357 fn sveltekit_has_dollar_virtual_prefixes() {
2358 let prefixes = sveltekit::SvelteKitPlugin.virtual_module_prefixes();
2359 assert!(prefixes.contains(&"$app/"));
2360 assert!(prefixes.contains(&"$env/"));
2361 assert!(prefixes.contains(&"$lib/"));
2362 }
2363
2364 #[test]
2365 fn sveltekit_has_lib_path_alias() {
2366 let aliases = sveltekit::SvelteKitPlugin.path_aliases(Path::new("/project"));
2367 assert!(aliases.iter().any(|(prefix, _)| *prefix == "$lib/"));
2368 }
2369
2370 #[test]
2371 fn nuxt_has_tilde_path_alias() {
2372 let aliases = nuxt::NuxtPlugin.path_aliases(Path::new("/nonexistent"));
2373 assert!(aliases.iter().any(|(prefix, _)| *prefix == "~/"));
2374 assert!(aliases.iter().any(|(prefix, _)| *prefix == "~~/"));
2375 }
2376
2377 #[test]
2378 fn jest_has_package_json_config_key() {
2379 assert_eq!(jest::JestPlugin.package_json_config_key(), Some("jest"));
2380 }
2381
2382 #[test]
2383 fn tsd_has_package_json_config_key() {
2384 assert_eq!(tsd::TsdPlugin.package_json_config_key(), Some("tsd"));
2385 }
2386
2387 #[test]
2388 fn babel_has_package_json_config_key() {
2389 assert_eq!(babel::BabelPlugin.package_json_config_key(), Some("babel"));
2390 }
2391
2392 #[test]
2393 fn eslint_has_package_json_config_key() {
2394 assert_eq!(
2395 eslint::EslintPlugin.package_json_config_key(),
2396 Some("eslintConfig")
2397 );
2398 }
2399
2400 #[test]
2401 fn prettier_has_package_json_config_key() {
2402 assert_eq!(
2403 prettier::PrettierPlugin.package_json_config_key(),
2404 Some("prettier")
2405 );
2406 }
2407
2408 #[test]
2409 fn macro_generated_plugin_basic_properties() {
2410 let plugin = msw::MswPlugin;
2411 assert_eq!(plugin.name(), "msw");
2412 assert!(plugin.enablers().contains(&"msw"));
2413 assert!(!plugin.entry_patterns().is_empty());
2414 assert!(plugin.config_patterns().is_empty());
2415 assert!(!plugin.always_used().is_empty());
2416 assert!(!plugin.tooling_dependencies().is_empty());
2417 }
2418
2419 #[test]
2420 fn macro_generated_plugin_with_used_exports() {
2421 let plugin = remix::RemixPlugin;
2422 assert_eq!(plugin.name(), "remix");
2423 assert!(!plugin.used_exports().is_empty());
2424 }
2425
2426 #[test]
2427 fn macro_passes_through_virtual_package_suffixes() {
2428 define_plugin! {
2429 struct MacroSuffixSmokePlugin => "macro-suffix-smoke",
2430 enablers: &["macro-suffix-smoke"],
2431 virtual_package_suffixes: &["/__macro_smoke__"],
2432 }
2433
2434 let plugin = MacroSuffixSmokePlugin;
2435 assert_eq!(
2436 plugin.virtual_package_suffixes(),
2437 &["/__macro_smoke__"],
2438 "macro-declared virtual_package_suffixes must propagate to the trait method"
2439 );
2440 }
2441
2442 #[test]
2443 fn macro_generated_plugin_imports_only_resolve_config() {
2444 let plugin = cypress::CypressPlugin;
2445 let source = r"
2446 import { defineConfig } from 'cypress';
2447 import coveragePlugin from '@cypress/code-coverage';
2448 export default defineConfig({});
2449 ";
2450 let result = plugin.resolve_config(
2451 Path::new("cypress.config.ts"),
2452 source,
2453 Path::new("/project"),
2454 );
2455 assert!(
2456 result
2457 .referenced_dependencies
2458 .contains(&"cypress".to_string())
2459 );
2460 assert!(
2461 result
2462 .referenced_dependencies
2463 .contains(&"@cypress/code-coverage".to_string())
2464 );
2465 }
2466
2467 #[test]
2468 fn builtin_plugin_count_is_expected() {
2469 let plugins = registry::builtin::create_builtin_plugins();
2470 assert!(
2471 plugins.len() >= 110,
2472 "expected at least 110 built-in plugins, got {}",
2473 plugins.len()
2474 );
2475 }
2476
2477 #[test]
2481 fn a_parent_relative_pattern_resolves_against_the_workspace_prefix() {
2482 let parent_relative = |pattern: &str| {
2483 let mut rule = PathRule::new(pattern);
2484 rule.parent_relative = true;
2485 rule
2486 };
2487 assert_eq!(
2488 parent_relative("../shared/src/Thing.tsx")
2489 .prefixed("packages/app")
2490 .pattern,
2491 "packages/shared/src/Thing.tsx"
2492 );
2493 assert_eq!(
2494 parent_relative("../../lib/index.{ts,js}")
2495 .prefixed("apps/web/client")
2496 .pattern,
2497 "apps/lib/index.{ts,js}"
2498 );
2499 assert!(
2500 parent_relative("../../../outside/Thing.tsx")
2501 .prefixed("packages/app")
2502 .pattern
2503 .starts_with("../"),
2504 "a climb past the project root matches no project file"
2505 );
2506 assert_eq!(
2507 parent_relative("src/index.ts")
2508 .prefixed("packages/app")
2509 .pattern,
2510 "packages/app/src/index.ts"
2511 );
2512 }
2513
2514 #[test]
2517 fn a_parent_relative_pattern_resolves_against_a_backslash_prefix() {
2518 let mut rule = PathRule::new("../../packages/ui/src/**/*.mdx");
2519 rule.parent_relative = true;
2520 assert_eq!(
2521 rule.prefixed("apps\\docs").pattern,
2522 "packages/ui/src/**/*.mdx"
2523 );
2524 }
2525
2526 #[test]
2529 fn a_plain_parent_pattern_is_not_resolved() {
2530 assert_eq!(
2531 PathRule::new("../src/**/*.stories.tsx")
2532 .prefixed("packages/ui")
2533 .pattern,
2534 "packages/ui/../src/**/*.stories.tsx"
2535 );
2536 }
2537}