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