1use std::path::{Path, PathBuf};
13
14use fallow_config::{
15 AutoImportRule, EntryPointRole, JsxImportSourceRule, PackageJson, UsedClassMemberRule,
16};
17use fallow_types::semantic::SemanticFrameworkContract;
18use regex::Regex;
19
20const TEST_ENTRY_POINT_PLUGINS: &[&str] = &[
21 "ava",
22 "bun",
23 "deno",
24 "cucumber",
25 "cypress",
26 "jasmine",
27 "jest",
28 "k6",
29 "mocha",
30 "playwright",
31 "rstest",
32 "tap",
33 "tsd",
34 "vitest",
35 "webdriverio",
36];
37
38const RUNTIME_ENTRY_POINT_PLUGINS: &[&str] = &[
39 "adonis",
40 "angular",
41 "astro",
42 "browser-extension",
43 "convex",
44 "docusaurus",
45 "electron",
46 "ember",
47 "eve",
48 "expo",
49 "expo-router",
50 "flue",
51 "gatsby",
52 "hardhat",
53 "kibana",
54 "module-federation",
55 "nestjs",
56 "next-intl",
57 "nextjs",
58 "nitro",
59 "nuxt",
60 "obsidian",
61 "parcel",
62 "qwik",
63 "react-native",
64 "react-router",
65 "redwoodsdk",
66 "remix",
67 "rolldown",
68 "rollup",
69 "rsbuild",
70 "rspack",
71 "sanity",
72 "supabase",
73 "sveltekit",
74 "tanstack-router",
75 "tsdown",
76 "tsup",
77 "vercel",
78 "vite",
79 "vitepress",
80 "waku",
81 "webpack",
82 "wrangler",
83 "wxt",
84];
85
86#[cfg(test)]
89pub(crate) fn entry_rules_match(result: &PluginResult, path: &str) -> bool {
90 result
91 .entry_patterns
92 .iter()
93 .filter_map(|rule| CompiledPathRule::for_entry_rule(rule, "entry pattern"))
94 .any(|rule| rule.matches(path))
95}
96
97#[cfg(test)]
98const SUPPORT_ENTRY_POINT_PLUGINS: &[&str] = &[
99 "alchemy",
100 "content-collections",
101 "contentlayer",
102 "danger",
103 "drizzle",
104 "fumadocs",
105 "i18next",
106 "knex",
107 "kysely",
108 "mintlify",
109 "msw",
110 "opencode",
111 "prisma",
112 "storybook",
113 "stryker",
114 "typeorm",
115 "velite",
116];
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum PluginConfigEffect {
121 Unreadable,
124 NotModeled,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct PluginConfigDiagnostic {
141 pub config_path: PathBuf,
143 pub plugin: String,
147 pub key: String,
150 pub reason: String,
152 pub effect: PluginConfigEffect,
154}
155
156impl PluginConfigDiagnostic {
157 pub(super) fn unreadable(
159 config_path: &Path,
160 plugin: &str,
161 key: &str,
162 reason: &'static str,
163 ) -> Self {
164 Self {
165 config_path: config_path.to_path_buf(),
166 plugin: plugin.to_owned(),
167 key: key.to_owned(),
168 reason: reason.to_owned(),
169 effect: PluginConfigEffect::Unreadable,
170 }
171 }
172
173 pub(crate) fn not_modeled(
175 config_path: &Path,
176 plugin: &str,
177 key: &str,
178 reason: &'static str,
179 ) -> Self {
180 Self {
181 config_path: config_path.to_path_buf(),
182 plugin: plugin.to_owned(),
183 key: key.to_owned(),
184 reason: reason.to_owned(),
185 effect: PluginConfigEffect::NotModeled,
186 }
187 }
188
189 #[must_use]
192 pub fn into_workspace_diagnostic(self, root: &Path) -> fallow_config::WorkspaceDiagnostic {
193 let Self {
194 config_path,
195 plugin,
196 key,
197 reason,
198 effect,
199 } = self;
200 let kind = match effect {
201 PluginConfigEffect::Unreadable => {
202 fallow_config::WorkspaceDiagnosticKind::PluginConfigUnreadable {
203 plugin,
204 key,
205 reason,
206 }
207 }
208 PluginConfigEffect::NotModeled => {
209 fallow_config::WorkspaceDiagnosticKind::PluginEffectNotModeled {
210 plugin,
211 key,
212 reason,
213 }
214 }
215 };
216 fallow_config::WorkspaceDiagnostic::new(root, config_path, kind)
217 }
218}
219
220#[derive(Debug, Default)]
222pub struct PluginResult {
223 entry_patterns: Vec<PathRule>,
225 replace_entry_patterns: bool,
230 accumulate_config_entry_patterns: bool,
234 replace_used_export_rules: bool,
237 used_exports: Vec<UsedExportRule>,
239 used_class_members: Vec<UsedClassMemberRule>,
244 referenced_dependencies: Vec<String>,
246 package_referenced_dependencies: Vec<(PathBuf, String)>,
249 always_used_files: Vec<String>,
251 path_aliases: Vec<(String, String)>,
253 setup_files: Vec<PathBuf>,
255 fixture_patterns: Vec<String>,
257 scss_include_paths: Vec<PathBuf>,
265 static_dir_mappings: Vec<(PathBuf, String)>,
268 framework_static_dir_mappings: Vec<(PathBuf, String)>,
269 provided_dependencies: Vec<ProvidedDependencyRule>,
272 config_diagnostics: Vec<PluginConfigDiagnostic>,
276 federation_sources: Vec<FederationSource>,
279 jsx_import_sources: Vec<JsxImportSourceRule>,
283 jsx_package_credits: Vec<JsxImportSourceRule>,
288 tsconfig_jsx_dirs: Vec<PathBuf>,
293}
294
295#[derive(Debug, Clone, PartialEq, Eq)]
297pub struct FederationSource {
298 pub target: FederationSourceTarget,
300 pub config_path: PathBuf,
302 pub plugin: String,
304 pub key: &'static str,
306}
307
308#[derive(Debug, Clone, PartialEq, Eq)]
310pub enum FederationSourceTarget {
311 Exposed(PathRule),
313 Remote(String),
315}
316
317impl FederationSource {
318 #[must_use]
319 fn prefixed(&self, ws_prefix: &str) -> Self {
320 let target = match &self.target {
321 FederationSourceTarget::Exposed(rule) => {
322 FederationSourceTarget::Exposed(rule.prefixed(ws_prefix))
323 }
324 FederationSourceTarget::Remote(alias) => FederationSourceTarget::Remote(alias.clone()),
325 };
326 Self {
327 target,
328 config_path: self.config_path.clone(),
329 plugin: self.plugin.clone(),
330 key: self.key,
331 }
332 }
333}
334
335#[must_use]
343pub fn federation_trace_provenance(
344 root: &Path,
345 files: &[crate::discover::DiscoveredFile],
346 sources: &[FederationSource],
347 modules: &[crate::extract::ModuleInfo],
348) -> fallow_types::trace::TraceProvenance {
349 let mut provenance = fallow_types::trace::TraceProvenance::default();
350 push_runtime_remote_sources(&mut provenance, root, files, modules);
351 if sources.is_empty() {
352 return provenance;
353 }
354 let mut exposed = Vec::new();
355 for source in sources {
356 let config = source
357 .config_path
358 .strip_prefix(root)
359 .unwrap_or(&source.config_path)
360 .to_path_buf();
361 let trace_source = fallow_types::trace::TraceSource {
362 kind: "module-federation".to_owned(),
363 plugin: source.plugin.clone(),
364 config,
365 key: source.key.to_owned(),
366 };
367 match &source.target {
368 FederationSourceTarget::Exposed(rule) => {
369 if let Some(compiled) =
370 CompiledPathRule::for_entry_rule(rule, "Module Federation exposes target")
371 {
372 exposed.push((compiled, trace_source));
373 }
374 }
375 FederationSourceTarget::Remote(alias) => {
376 provenance.push_dependency(alias.clone(), trace_source);
377 }
378 }
379 }
380 if exposed.is_empty() {
381 return provenance;
382 }
383 for file in files {
384 let Ok(relative) = file.path.strip_prefix(root) else {
385 continue;
386 };
387 let relative_str = relative.to_string_lossy().replace('\\', "/");
388 for (rule, source) in &exposed {
389 if rule.matches(&relative_str) {
390 provenance.push_file(relative.to_path_buf(), source.clone());
391 }
392 }
393 }
394 provenance
395}
396
397pub const CONFIG_ONLY_EXPORT_NAMES: &[&str] = &[
406 "dynamic",
407 "dynamicParams",
408 "revalidate",
409 "fetchCache",
410 "runtime",
411 "preferredRegion",
412 "maxDuration",
413 "prerender",
414 "config",
415 "metadata",
416 "viewport",
417];
418
419pub fn push_config_export_provenance(
423 provenance: &mut fallow_types::trace::TraceProvenance,
424 root: &Path,
425 files: &[crate::discover::DiscoveredFile],
426 modules: &[crate::extract::ModuleInfo],
427 plugin_result: &AggregatedPluginResult,
428) {
429 let rules: Vec<(CompiledPathRule, Vec<&str>)> = plugin_result
430 .used_exports
431 .iter()
432 .filter_map(|rule| {
433 let names: Vec<&str> = rule
434 .rule
435 .exports
436 .iter()
437 .map(String::as_str)
438 .filter(|name| CONFIG_ONLY_EXPORT_NAMES.contains(name))
439 .collect();
440 if names.is_empty() {
441 return None;
442 }
443 let compiled =
444 CompiledPathRule::for_used_export_rule(&rule.rule.path, "used_exports pattern")?;
445 Some((compiled, names))
446 })
447 .collect();
448 if rules.is_empty() {
449 return;
450 }
451 for module in modules {
452 let config_names: Vec<String> = module
453 .exports
454 .iter()
455 .filter_map(|export| match &export.name {
456 fallow_types::extract::ExportName::Named(name)
457 if !export.is_type_only
458 && CONFIG_ONLY_EXPORT_NAMES.contains(&name.as_str()) =>
459 {
460 Some(name.clone())
461 }
462 _ => None,
463 })
464 .collect();
465 if config_names.is_empty() {
466 continue;
467 }
468 let Some(file) = files.get(module.file_id.0 as usize) else {
469 continue;
470 };
471 let relative = file.path.strip_prefix(root).unwrap_or(&file.path);
472 let relative_str = relative.to_string_lossy().replace('\\', "/");
473 for name in config_names {
474 if rules
475 .iter()
476 .any(|(rule, names)| names.contains(&name.as_str()) && rule.matches(&relative_str))
477 {
478 provenance.push_config_export(relative.to_path_buf(), name);
479 }
480 }
481 }
482}
483
484pub fn push_tooling_trace_credits(
488 provenance: &mut fallow_types::trace::TraceProvenance,
489 root: &Path,
490 workspaces: &[fallow_config::WorkspaceInfo],
491 plugin_result: &AggregatedPluginResult,
492) {
493 for entry in &plugin_result.plugin_tooling {
494 let Some(evidence) = entry.evidence(&plugin_result.script_used_packages) else {
495 continue;
496 };
497 let credit = match evidence {
498 PluginToolingEvidence::OwnConfig(config) => fallow_types::trace::ToolingCredit {
499 reason: "plugin-config".to_owned(),
500 plugin: Some(entry.plugin.clone()),
501 config: Some(config.strip_prefix(root).unwrap_or(config).to_path_buf()),
502 reference: None,
503 },
504 PluginToolingEvidence::Reference(name) => fallow_types::trace::ToolingCredit {
505 reason: "plugin-reference".to_owned(),
506 plugin: Some(entry.plugin.clone()),
507 config: None,
508 reference: Some(name.to_owned()),
509 },
510 };
511 for dependency in &entry.dependencies {
512 provenance.push_tooling_credit(dependency.clone(), credit.clone());
513 }
514 }
515 push_types_trace_credits(provenance, plugin_result);
516 push_catalogue_trace_credits(provenance, root, workspaces, plugin_result);
517}
518
519fn push_catalogue_trace_credits(
529 provenance: &mut fallow_types::trace::TraceProvenance,
530 root: &Path,
531 workspaces: &[fallow_config::WorkspaceInfo],
532 plugin_result: &AggregatedPluginResult,
533) {
534 let manifests = read_trace_manifests(root, workspaces);
535 let mut names: Vec<&String> = plugin_result.dev_dependency_names.iter().collect();
536 names.sort_unstable();
537 for name in names {
538 let declaring: Vec<&(PathBuf, serde_json::Value)> = manifests
539 .iter()
540 .filter(|(_, manifest)| manifest_lists(manifest, "devDependencies", name))
541 .collect();
542 if let Some(credit) = catalogue_credit(name, root, &declaring) {
543 provenance.push_tooling_credit(name.clone(), credit);
544 }
545 if let Some((path, _)) = declaring
546 .iter()
547 .find(|(_, manifest)| manifest_lists(manifest, "peerDependencies", name))
548 {
549 provenance.push_tooling_credit(
550 name.clone(),
551 fallow_types::trace::ToolingCredit {
552 reason: "own-peer".to_owned(),
553 plugin: None,
554 config: Some(path.strip_prefix(root).unwrap_or(path).to_path_buf()),
555 reference: None,
556 },
557 );
558 }
559 }
560}
561
562fn read_trace_manifests(
564 root: &Path,
565 workspaces: &[fallow_config::WorkspaceInfo],
566) -> Vec<(PathBuf, serde_json::Value)> {
567 std::iter::once(root)
568 .chain(
569 workspaces
570 .iter()
571 .map(|ws| ws.root.as_path())
572 .filter(|ws_root| *ws_root != root),
573 )
574 .filter_map(|package_root| {
575 let path = package_root.join("package.json");
576 let content = std::fs::read_to_string(&path).ok()?;
577 serde_json::from_str(&content)
578 .ok()
579 .map(|value| (path, value))
580 })
581 .collect()
582}
583
584fn manifest_lists(manifest: &serde_json::Value, section: &str, name: &str) -> bool {
585 manifest
586 .get(section)
587 .and_then(serde_json::Value::as_object)
588 .is_some_and(|deps| deps.contains_key(name))
589}
590
591fn catalogue_credit(
594 name: &str,
595 root: &Path,
596 declaring: &[&(PathBuf, serde_json::Value)],
597) -> Option<fallow_types::trace::ToolingCredit> {
598 if is_ambient_types_package(name) || types_package_target(name).is_some() {
599 return None;
600 }
601 let Some(patterns) = cli_tooling_config_patterns(name) else {
602 return is_known_tooling_dependency(name).then(|| fallow_types::trace::ToolingCredit {
603 reason: "known-tooling".to_owned(),
604 plugin: None,
605 config: None,
606 reference: None,
607 });
608 };
609 let config = declaring.iter().find_map(|(path, manifest)| {
610 crate::analyze::cli_tool_own_config(name, patterns, root, path, Some(manifest))
611 })?;
612 Some(fallow_types::trace::ToolingCredit {
613 reason: "known-tooling-config".to_owned(),
614 plugin: None,
615 config: Some(config.strip_prefix(root).unwrap_or(&config).to_path_buf()),
616 reference: None,
617 })
618}
619
620fn push_types_trace_credits(
625 provenance: &mut fallow_types::trace::TraceProvenance,
626 plugin_result: &AggregatedPluginResult,
627) {
628 let declared = plugin_result.dependency_binaries.declared_packages();
629 let mut names: Vec<&String> = plugin_result.dev_dependency_names.iter().collect();
630 names.sort_unstable();
631 for name in names {
632 let credit = if is_ambient_types_package(name) {
633 fallow_types::trace::ToolingCredit {
634 reason: "ambient-types".to_owned(),
635 plugin: None,
636 config: None,
637 reference: None,
638 }
639 } else if let Some(target) = types_package_target(name) {
640 if declared.contains(&target) {
641 fallow_types::trace::ToolingCredit {
642 reason: "types-target".to_owned(),
643 plugin: None,
644 config: None,
645 reference: Some(target),
646 }
647 } else if plugin_result.referenced_dependencies.contains(name) {
648 fallow_types::trace::ToolingCredit {
649 reason: "types-config".to_owned(),
650 plugin: None,
651 config: None,
652 reference: None,
653 }
654 } else {
655 continue;
656 }
657 } else {
658 continue;
659 };
660 provenance.push_tooling_credit(name.clone(), credit);
661 }
662}
663
664fn push_runtime_remote_sources(
666 provenance: &mut fallow_types::trace::TraceProvenance,
667 root: &Path,
668 files: &[crate::discover::DiscoveredFile],
669 modules: &[crate::extract::ModuleInfo],
670) {
671 for module in modules {
672 let mut file = None;
673 for fact in module.semantic_facts.iter() {
674 let fallow_types::extract::SemanticFact::FederationRuntimeRemote(fact) = fact else {
675 continue;
676 };
677 let Some(remote) = &fact.remote else {
678 continue;
679 };
680 let Some(path) = file.get_or_insert_with(|| {
681 files.get(module.file_id.0 as usize).map(|file| {
682 file.path
683 .strip_prefix(root)
684 .unwrap_or(&file.path)
685 .to_path_buf()
686 })
687 }) else {
688 break;
689 };
690 provenance.push_dependency(
691 remote.clone(),
692 fallow_types::trace::TraceSource {
693 kind: "module-federation".to_owned(),
694 plugin: "module-federation".to_owned(),
695 config: path.clone(),
696 key: fact.call.name().to_owned(),
697 },
698 );
699 }
700 }
701}
702
703impl PluginResult {
704 fn extend_config_dir_entry_patterns(
709 &mut self,
710 values: Vec<String>,
711 config_path: &Path,
712 root: &Path,
713 ) {
714 let (negated, positive): (Vec<String>, Vec<String>) =
715 values.into_iter().partition(|value| value.starts_with('!'));
716 let excluded: Vec<String> = negated
717 .iter()
718 .filter_map(|value| value.strip_prefix('!'))
719 .filter_map(|value| config_parser::normalize_config_path(value, config_path, root))
720 .collect();
721 self.entry_patterns.extend(
722 positive
723 .iter()
724 .filter_map(|value| config_parser::normalize_config_path(value, config_path, root))
725 .map(|pattern| {
726 PathRule::new(pattern).with_excluded_globs(excluded.iter().cloned())
727 }),
728 );
729 }
730
731 fn push_parent_relative_entry_pattern(&mut self, pattern: String) {
734 let mut rule = PathRule::new(pattern);
735 rule.parent_relative = true;
736 self.entry_patterns.push(rule);
737 }
738
739 fn push_entry_pattern(&mut self, pattern: impl Into<String>) {
740 self.entry_patterns
741 .push(PathRule::new(normalize_entry_pattern(pattern.into())));
742 }
743
744 fn extend_entry_patterns<I, S>(&mut self, patterns: I)
745 where
746 I: IntoIterator<Item = S>,
747 S: Into<String>,
748 {
749 self.entry_patterns.extend(
750 patterns
751 .into_iter()
752 .map(|pat| PathRule::new(normalize_entry_pattern(pat.into()))),
753 );
754 }
755
756 fn extend_entry_patterns_or_dependencies<I, S>(
771 &mut self,
772 values: I,
773 resolve_path: impl Fn(String) -> String,
774 ) where
775 I: IntoIterator<Item = S>,
776 S: Into<String>,
777 {
778 for value in values {
779 let value = value.into();
780 if let Some(request) = module_request(&value) {
781 self.referenced_dependencies
782 .push(crate::resolve::extract_package_name(request));
783 continue;
784 }
785 self.push_entry_path(resolve_path(value));
786 }
787 }
788
789 fn extend_entry_patterns_and_dependencies<I, S>(&mut self, values: I, root: &Path)
801 where
802 I: IntoIterator<Item = S>,
803 S: Into<String>,
804 {
805 for value in values {
806 let value = value.into();
807 if let Some(request) = module_request(&value)
808 && !names_project_file(root, request)
809 {
810 self.referenced_dependencies
811 .push(crate::resolve::extract_package_name(request));
812 }
813 self.push_entry_path(value);
814 }
815 }
816
817 fn extend_entry_paths<I, S>(&mut self, values: I)
819 where
820 I: IntoIterator<Item = S>,
821 S: Into<String>,
822 {
823 for value in values {
824 self.push_entry_path(value.into());
825 }
826 }
827
828 fn push_entry_path(&mut self, value: String) {
836 if has_glob_syntax(&value) || has_source_extension(&value) {
837 self.push_entry_pattern(value);
838 return;
839 }
840 let base = value.trim_end_matches('/').to_owned();
841 self.push_entry_pattern(value);
842 self.push_entry_pattern(format!("{base}.{REQUEST_EXTENSIONS}"));
843 self.push_entry_pattern(format!("{base}/index.{REQUEST_EXTENSIONS}"));
844 }
845
846 fn push_used_export_rule(
847 &mut self,
848 pattern: impl Into<String>,
849 exports: impl IntoIterator<Item = impl Into<String>>,
850 ) {
851 self.used_exports
852 .push(UsedExportRule::new(pattern, exports));
853 }
854
855 #[must_use]
862 const fn is_empty(&self) -> bool {
863 self.config_diagnostics.is_empty()
864 && self.entry_patterns.is_empty()
865 && self.used_exports.is_empty()
866 && self.used_class_members.is_empty()
867 && self.referenced_dependencies.is_empty()
868 && self.package_referenced_dependencies.is_empty()
869 && self.always_used_files.is_empty()
870 && self.path_aliases.is_empty()
871 && self.setup_files.is_empty()
872 && self.fixture_patterns.is_empty()
873 && self.scss_include_paths.is_empty()
874 && self.static_dir_mappings.is_empty()
875 && self.framework_static_dir_mappings.is_empty()
876 && self.provided_dependencies.is_empty()
877 && self.federation_sources.is_empty()
878 && self.jsx_import_sources.is_empty()
879 && self.jsx_package_credits.is_empty()
880 && self.tsconfig_jsx_dirs.is_empty()
881 }
882}
883
884fn names_project_file(root: &Path, value: &str) -> bool {
888 let base = root.join(value);
889 base.is_file()
890 || crate::discover::SOURCE_EXTENSIONS.iter().any(|extension| {
891 let mut candidate = base.clone().into_os_string();
892 candidate.push(".");
893 candidate.push(extension);
894 Path::new(&candidate).is_file() || base.join(format!("index.{extension}")).is_file()
895 })
896}
897
898const REQUEST_EXTENSIONS: &str = "{ts,tsx,mts,cts,gts,js,jsx,mjs,cjs,gjs,vue,svelte,astro,mdx}";
902
903fn normalize_entry_pattern(pattern: String) -> String {
904 pattern
905 .strip_prefix("./")
906 .map(str::to_owned)
907 .unwrap_or(pattern)
908}
909
910fn module_request(value: &str) -> Option<&str> {
921 let request = strip_resource_query(value);
922 (config_parser::is_package_specifier(request)
923 && !has_glob_syntax(request)
924 && !has_source_extension(request))
925 .then_some(request)
926}
927
928fn strip_resource_query(value: &str) -> &str {
936 match value.split_once('?') {
937 Some((request, query)) if is_resource_query(query) => request,
938 _ => value,
939 }
940}
941
942fn is_resource_query(query: &str) -> bool {
945 !query.is_empty()
946 && query.split('&').all(|pair| {
947 let key = pair.split_once('=').map_or(pair, |(key, _)| key);
948 key.starts_with(|first: char| first.is_ascii_alphanumeric() || first == '_')
949 && key
950 .chars()
951 .all(|char| char.is_ascii_alphanumeric() || matches!(char, '_' | '-' | '.'))
952 })
953}
954
955fn has_glob_syntax(value: &str) -> bool {
958 value.contains('*') || value.contains('?') || value.contains('[') || value.contains('{')
959}
960
961fn has_source_extension(value: &str) -> bool {
965 Path::new(value)
966 .extension()
967 .and_then(|ext| ext.to_str())
968 .is_some_and(|ext| {
969 crate::discover::SOURCE_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str())
970 })
971}
972
973#[derive(Debug, Clone, Default, PartialEq, Eq)]
979pub struct PathRule {
980 pub pattern: String,
981 pub exclude_globs: Vec<String>,
982 pub exclude_regexes: Vec<String>,
983 pub exclude_segment_regexes: Vec<String>,
987 pub parent_relative: bool,
994}
995
996impl PathRule {
997 #[must_use]
998 pub(crate) fn new(pattern: impl Into<String>) -> Self {
999 Self {
1000 pattern: pattern.into(),
1001 exclude_globs: Vec::new(),
1002 exclude_regexes: Vec::new(),
1003 exclude_segment_regexes: Vec::new(),
1004 parent_relative: false,
1005 }
1006 }
1007
1008 #[must_use]
1009 fn from_static(pattern: &'static str) -> Self {
1010 Self::new(pattern)
1011 }
1012
1013 #[must_use]
1017 pub(crate) fn from_extglob(pattern: &str) -> Self {
1018 let rewritten = fallow_config::extglob::rewrite_include(pattern);
1019 Self::new(rewritten.include).with_excluded_globs(rewritten.excludes)
1020 }
1021
1022 #[must_use]
1026 pub(crate) fn with_excluded_extglobs<I, S>(self, patterns: I) -> Self
1027 where
1028 I: IntoIterator<Item = S>,
1029 S: AsRef<str>,
1030 {
1031 let excluded: Vec<String> = patterns
1032 .into_iter()
1033 .filter_map(|pattern| fallow_config::extglob::rewrite_exclude(pattern.as_ref()))
1034 .collect();
1035 self.with_excluded_globs(excluded)
1036 }
1037
1038 #[must_use]
1039 pub(crate) fn with_excluded_globs<I, S>(mut self, patterns: I) -> Self
1040 where
1041 I: IntoIterator<Item = S>,
1042 S: Into<String>,
1043 {
1044 self.exclude_globs
1045 .extend(patterns.into_iter().map(Into::into));
1046 self
1047 }
1048
1049 #[must_use]
1050 fn with_excluded_regexes<I, S>(mut self, patterns: I) -> Self
1051 where
1052 I: IntoIterator<Item = S>,
1053 S: Into<String>,
1054 {
1055 self.exclude_regexes
1056 .extend(patterns.into_iter().map(Into::into));
1057 self
1058 }
1059
1060 #[must_use]
1061 fn with_excluded_segment_regexes<I, S>(mut self, patterns: I) -> Self
1062 where
1063 I: IntoIterator<Item = S>,
1064 S: Into<String>,
1065 {
1066 self.exclude_segment_regexes
1067 .extend(patterns.into_iter().map(Into::into));
1068 self
1069 }
1070
1071 #[must_use]
1072 fn prefixed(&self, ws_prefix: &str) -> Self {
1073 let prefix = |pattern: &str| {
1074 if self.parent_relative && pattern.starts_with("../") {
1075 resolve_parent_relative_pattern(pattern, ws_prefix)
1076 } else {
1077 prefix_workspace_pattern(pattern, ws_prefix)
1078 }
1079 };
1080 Self {
1081 pattern: prefix(&self.pattern),
1082 exclude_globs: self
1083 .exclude_globs
1084 .iter()
1085 .map(|pattern| prefix(pattern))
1086 .collect(),
1087 exclude_regexes: self
1088 .exclude_regexes
1089 .iter()
1090 .map(|pattern| prefix_workspace_regex(pattern, ws_prefix))
1091 .collect(),
1092 exclude_segment_regexes: self.exclude_segment_regexes.clone(),
1093 parent_relative: false,
1094 }
1095 }
1096}
1097
1098#[derive(Debug, Clone, Default, PartialEq, Eq)]
1100pub struct UsedExportRule {
1101 pub(crate) path: PathRule,
1102 pub(crate) exports: Vec<String>,
1103}
1104
1105impl UsedExportRule {
1106 #[must_use]
1107 pub(crate) fn new(
1108 pattern: impl Into<String>,
1109 exports: impl IntoIterator<Item = impl Into<String>>,
1110 ) -> Self {
1111 Self {
1112 path: PathRule::new(pattern),
1113 exports: exports.into_iter().map(Into::into).collect(),
1114 }
1115 }
1116
1117 #[must_use]
1118 fn from_static(pattern: &'static str, exports: &'static [&'static str]) -> Self {
1119 Self::new(pattern, exports.iter().copied())
1120 }
1121
1122 #[must_use]
1123 fn with_excluded_globs<I, S>(mut self, patterns: I) -> Self
1124 where
1125 I: IntoIterator<Item = S>,
1126 S: Into<String>,
1127 {
1128 self.path = self.path.with_excluded_globs(patterns);
1129 self
1130 }
1131
1132 #[must_use]
1133 fn with_excluded_regexes<I, S>(mut self, patterns: I) -> Self
1134 where
1135 I: IntoIterator<Item = S>,
1136 S: Into<String>,
1137 {
1138 self.path = self.path.with_excluded_regexes(patterns);
1139 self
1140 }
1141
1142 #[must_use]
1143 fn with_excluded_segment_regexes<I, S>(mut self, patterns: I) -> Self
1144 where
1145 I: IntoIterator<Item = S>,
1146 S: Into<String>,
1147 {
1148 self.path = self.path.with_excluded_segment_regexes(patterns);
1149 self
1150 }
1151
1152 #[must_use]
1153 fn prefixed(&self, ws_prefix: &str) -> Self {
1154 Self {
1155 path: self.path.prefixed(ws_prefix),
1156 exports: self.exports.clone(),
1157 }
1158 }
1159}
1160
1161#[derive(Debug, Clone, PartialEq, Eq)]
1163pub struct PluginUsedExportRule {
1164 pub(crate) plugin_name: String,
1165 pub(crate) rule: UsedExportRule,
1166}
1167
1168impl PluginUsedExportRule {
1169 #[must_use]
1170 pub(crate) fn new(plugin_name: impl Into<String>, rule: UsedExportRule) -> Self {
1171 Self {
1172 plugin_name: plugin_name.into(),
1173 rule,
1174 }
1175 }
1176
1177 #[must_use]
1178 fn prefixed(&self, ws_prefix: &str) -> Self {
1179 Self {
1180 plugin_name: self.plugin_name.clone(),
1181 rule: self.rule.prefixed(ws_prefix),
1182 }
1183 }
1184}
1185
1186#[derive(Debug, Clone, Default, PartialEq, Eq)]
1188pub struct ProvidedDependencyRule {
1189 pub(crate) path: PathRule,
1190 exact_specifiers: Vec<String>,
1191 specifier_prefixes: Vec<String>,
1192}
1193
1194impl ProvidedDependencyRule {
1195 #[must_use]
1196 fn new(
1197 pattern: impl Into<String>,
1198 exact_specifiers: impl IntoIterator<Item = impl Into<String>>,
1199 specifier_prefixes: impl IntoIterator<Item = impl Into<String>>,
1200 ) -> Self {
1201 Self {
1202 path: PathRule::new(pattern),
1203 exact_specifiers: exact_specifiers.into_iter().map(Into::into).collect(),
1204 specifier_prefixes: specifier_prefixes.into_iter().map(Into::into).collect(),
1205 }
1206 }
1207
1208 #[must_use]
1209 fn prefixed(&self, ws_prefix: &str) -> Self {
1210 Self {
1211 path: self.path.prefixed(ws_prefix),
1212 exact_specifiers: self.exact_specifiers.clone(),
1213 specifier_prefixes: self.specifier_prefixes.clone(),
1214 }
1215 }
1216
1217 #[must_use]
1218 pub(crate) fn may_cover_package(&self, package_name: &str) -> bool {
1219 self.exact_specifiers
1220 .iter()
1221 .chain(self.specifier_prefixes.iter())
1222 .any(|specifier| crate::resolve::extract_package_name(specifier) == package_name)
1223 }
1224
1225 #[must_use]
1226 pub(crate) fn covers_specifier(&self, specifier: &str) -> bool {
1227 self.exact_specifiers
1228 .iter()
1229 .any(|allowed| allowed == specifier)
1230 || self
1231 .specifier_prefixes
1232 .iter()
1233 .any(|prefix| specifier.starts_with(prefix))
1234 }
1235}
1236
1237pub(crate) fn entry_rule_glob(pattern: &str) -> Result<globset::Glob, globset::Error> {
1241 globset::GlobBuilder::new(pattern)
1242 .literal_separator(true)
1243 .empty_alternates(true)
1244 .build()
1245}
1246
1247#[derive(Debug, Clone)]
1252pub(crate) struct CompiledPathRule {
1253 include: globset::GlobMatcher,
1254 exclude_globs: Vec<globset::GlobMatcher>,
1255 exclude_regexes: Vec<Regex>,
1256 exclude_segment_regexes: Vec<Regex>,
1257}
1258
1259impl CompiledPathRule {
1260 pub(crate) fn for_entry_rule(rule: &PathRule, rule_kind: &str) -> Option<Self> {
1261 let include = match entry_rule_glob(&rule.pattern) {
1262 Ok(glob) => glob.compile_matcher(),
1263 Err(err) => {
1264 tracing::warn!("invalid {rule_kind} '{}': {err}", rule.pattern);
1265 return None;
1266 }
1267 };
1268 Some(Self {
1269 include,
1270 exclude_globs: compile_excluded_globs(&rule.exclude_globs, rule_kind, &rule.pattern),
1271 exclude_regexes: compile_excluded_regexes(
1272 &rule.exclude_regexes,
1273 rule_kind,
1274 &rule.pattern,
1275 ),
1276 exclude_segment_regexes: compile_excluded_segment_regexes(
1277 &rule.exclude_segment_regexes,
1278 rule_kind,
1279 &rule.pattern,
1280 ),
1281 })
1282 }
1283
1284 pub(crate) fn for_used_export_rule(rule: &PathRule, rule_kind: &str) -> Option<Self> {
1285 let include = match entry_rule_glob(&rule.pattern) {
1286 Ok(glob) => glob.compile_matcher(),
1287 Err(err) => {
1288 tracing::warn!("invalid {rule_kind} '{}': {err}", rule.pattern);
1289 return None;
1290 }
1291 };
1292 Some(Self {
1293 include,
1294 exclude_globs: compile_excluded_globs(&rule.exclude_globs, rule_kind, &rule.pattern),
1295 exclude_regexes: compile_excluded_regexes(
1296 &rule.exclude_regexes,
1297 rule_kind,
1298 &rule.pattern,
1299 ),
1300 exclude_segment_regexes: compile_excluded_segment_regexes(
1301 &rule.exclude_segment_regexes,
1302 rule_kind,
1303 &rule.pattern,
1304 ),
1305 })
1306 }
1307
1308 #[must_use]
1309 pub(crate) fn matches(&self, path: &str) -> bool {
1310 self.include.is_match(path)
1311 && !self.exclude_globs.iter().any(|glob| glob.is_match(path))
1312 && !self
1313 .exclude_regexes
1314 .iter()
1315 .any(|regex| regex.is_match(path))
1316 && !matches_segment_regex(path, &self.exclude_segment_regexes)
1317 }
1318}
1319
1320fn prefix_workspace_pattern(pattern: &str, ws_prefix: &str) -> String {
1321 if pattern.starts_with(ws_prefix) || pattern.starts_with('/') {
1322 pattern.to_string()
1323 } else {
1324 format!("{ws_prefix}/{pattern}")
1325 }
1326}
1327
1328fn resolve_parent_relative_pattern(pattern: &str, ws_prefix: &str) -> String {
1334 if ws_prefix.starts_with('/') || Path::new(ws_prefix).is_absolute() {
1335 return pattern.to_string();
1336 }
1337 let mut base: Vec<&str> = ws_prefix
1340 .split(['/', '\\'])
1341 .filter(|segment| !segment.is_empty())
1342 .collect();
1343 let mut rest = pattern;
1344 while let Some(stripped) = rest.strip_prefix("../") {
1345 if base.pop().is_none() {
1346 return pattern.to_string();
1347 }
1348 rest = stripped;
1349 }
1350 if base.is_empty() {
1351 rest.to_string()
1352 } else {
1353 format!("{}/{rest}", base.join("/"))
1354 }
1355}
1356
1357fn prefix_workspace_regex(pattern: &str, ws_prefix: &str) -> String {
1358 if let Some(pattern) = pattern.strip_prefix('^') {
1359 format!("^{}/{}", regex::escape(ws_prefix), pattern)
1360 } else {
1361 format!("^{}/(?:{})", regex::escape(ws_prefix), pattern)
1362 }
1363}
1364
1365fn compile_excluded_globs(
1366 patterns: &[String],
1367 rule_kind: &str,
1368 rule_pattern: &str,
1369) -> Vec<globset::GlobMatcher> {
1370 patterns
1371 .iter()
1372 .filter_map(|pattern| {
1373 match globset::GlobBuilder::new(pattern)
1374 .literal_separator(true)
1375 .empty_alternates(true)
1376 .build()
1377 {
1378 Ok(glob) => Some(glob.compile_matcher()),
1379 Err(err) => {
1380 tracing::warn!(
1381 "skipping invalid excluded glob '{}' for {} '{}': {err}",
1382 pattern,
1383 rule_kind,
1384 rule_pattern
1385 );
1386 None
1387 }
1388 }
1389 })
1390 .collect()
1391}
1392
1393fn compile_excluded_regexes(
1394 patterns: &[String],
1395 rule_kind: &str,
1396 rule_pattern: &str,
1397) -> Vec<Regex> {
1398 patterns
1399 .iter()
1400 .filter_map(|pattern| match Regex::new(pattern) {
1401 Ok(regex) => Some(regex),
1402 Err(err) => {
1403 tracing::warn!(
1404 "skipping invalid excluded regex '{}' for {} '{}': {err}",
1405 pattern,
1406 rule_kind,
1407 rule_pattern
1408 );
1409 None
1410 }
1411 })
1412 .collect()
1413}
1414
1415fn compile_excluded_segment_regexes(
1416 patterns: &[String],
1417 rule_kind: &str,
1418 rule_pattern: &str,
1419) -> Vec<Regex> {
1420 patterns
1421 .iter()
1422 .filter_map(|pattern| match Regex::new(pattern) {
1423 Ok(regex) => Some(regex),
1424 Err(err) => {
1425 tracing::warn!(
1426 "skipping invalid excluded segment regex '{}' for {} '{}': {err}",
1427 pattern,
1428 rule_kind,
1429 rule_pattern
1430 );
1431 None
1432 }
1433 })
1434 .collect()
1435}
1436
1437fn matches_segment_regex(path: &str, regexes: &[Regex]) -> bool {
1440 path.split(std::path::is_separator)
1441 .any(|segment| regexes.iter().any(|regex| regex.is_match(segment)))
1442}
1443
1444impl From<String> for PathRule {
1445 fn from(pattern: String) -> Self {
1446 Self::new(pattern)
1447 }
1448}
1449
1450impl From<&str> for PathRule {
1451 fn from(pattern: &str) -> Self {
1452 Self::new(pattern)
1453 }
1454}
1455
1456impl std::ops::Deref for PathRule {
1457 type Target = str;
1458
1459 fn deref(&self) -> &Self::Target {
1460 &self.pattern
1461 }
1462}
1463
1464impl PartialEq<&str> for PathRule {
1465 fn eq(&self, other: &&str) -> bool {
1466 self.pattern == *other
1467 }
1468}
1469
1470impl PartialEq<str> for PathRule {
1471 fn eq(&self, other: &str) -> bool {
1472 self.pattern == other
1473 }
1474}
1475
1476impl PartialEq<String> for PathRule {
1477 fn eq(&self, other: &String) -> bool {
1478 &self.pattern == other
1479 }
1480}
1481
1482pub trait Plugin: Send + Sync {
1484 fn name(&self) -> &'static str;
1486
1487 fn enablers(&self) -> &'static [&'static str] {
1490 &[]
1491 }
1492
1493 fn is_enabled(&self, pkg: &PackageJson, root: &Path) -> bool {
1496 let deps = pkg.all_dependency_names();
1497 self.is_enabled_with_deps(&deps, root)
1498 }
1499
1500 fn is_enabled_with_deps(&self, deps: &[String], _root: &Path) -> bool {
1503 let enablers = self.enablers();
1504 if enablers.is_empty() {
1505 return false;
1506 }
1507 enablers.iter().any(|enabler| {
1508 if enabler.ends_with('/') {
1509 deps.iter().any(|d| d.starts_with(enabler))
1511 } else {
1512 deps.iter().any(|d| d == enabler)
1513 }
1514 })
1515 }
1516
1517 fn is_enabled_with_files(
1530 &self,
1531 deps: &[String],
1532 root: &Path,
1533 _discovered_files: &[PathBuf],
1534 _candidate_index: Option<®istry::ConfigCandidateIndex>,
1535 ) -> bool {
1536 self.is_enabled_with_deps(deps, root)
1537 }
1538
1539 fn script_enablers(&self) -> &'static [&'static str] {
1541 &[]
1542 }
1543
1544 fn is_enabled_with_scripts(
1546 &self,
1547 script_packages: &rustc_hash::FxHashSet<String>,
1548 _root: &Path,
1549 ) -> bool {
1550 let enablers = self.script_enablers();
1551 if enablers.is_empty() {
1552 return false;
1553 }
1554 enablers.iter().any(|enabler| {
1555 if enabler.ends_with('/') {
1556 script_packages
1557 .iter()
1558 .any(|package| package.starts_with(enabler))
1559 } else {
1560 script_packages.contains(*enabler)
1561 }
1562 })
1563 }
1564
1565 fn entry_patterns(&self) -> &'static [&'static str] {
1567 &[]
1568 }
1569
1570 fn entry_pattern_rules(&self) -> Vec<PathRule> {
1572 self.entry_patterns()
1573 .iter()
1574 .map(|pattern| PathRule::from_static(pattern))
1575 .collect()
1576 }
1577
1578 fn entry_point_role(&self) -> EntryPointRole {
1583 builtin_entry_point_role(self.name())
1584 }
1585
1586 fn config_patterns(&self) -> &'static [&'static str] {
1588 &[]
1589 }
1590
1591 fn always_used(&self) -> &'static [&'static str] {
1593 &[]
1594 }
1595
1596 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
1598 vec![]
1599 }
1600
1601 fn used_export_rules(&self) -> Vec<UsedExportRule> {
1603 self.used_exports()
1604 .into_iter()
1605 .map(|(pattern, exports)| UsedExportRule::from_static(pattern, exports))
1606 .collect()
1607 }
1608
1609 fn used_class_members(&self) -> &'static [&'static str] {
1614 &[]
1615 }
1616
1617 fn used_class_member_rules(&self) -> Vec<UsedClassMemberRule> {
1625 Vec::new()
1626 }
1627
1628 fn framework_class_member_contracts(&self) -> Vec<SemanticFrameworkContract> {
1631 Vec::new()
1632 }
1633
1634 fn fixture_glob_patterns(&self) -> &'static [&'static str] {
1639 &[]
1640 }
1641
1642 fn discovery_hidden_dirs(&self) -> &'static [&'static str] {
1647 &[]
1648 }
1649
1650 fn tooling_dependencies(&self) -> &'static [&'static str] {
1653 &[]
1654 }
1655
1656 fn virtual_module_prefixes(&self) -> &'static [&'static str] {
1661 &[]
1662 }
1663
1664 fn virtual_package_suffixes(&self) -> &'static [&'static str] {
1670 &[]
1671 }
1672
1673 fn generated_import_patterns(&self) -> &'static [&'static str] {
1679 &[]
1680 }
1681
1682 fn generated_type_import_prefixes(&self) -> &'static [&'static str] {
1687 &[]
1688 }
1689
1690 fn path_aliases(&self, _root: &Path) -> Vec<(&'static str, String)> {
1700 vec![]
1701 }
1702
1703 fn static_dir_mappings(&self, _root: &Path) -> Vec<(std::path::PathBuf, String)> {
1715 vec![]
1716 }
1717
1718 fn auto_imports(&self, _root: &Path) -> Vec<AutoImportRule> {
1736 Vec::new()
1737 }
1738
1739 fn provided_dependencies(&self) -> Vec<ProvidedDependencyRule> {
1741 Vec::new()
1742 }
1743
1744 fn is_enabled_with_package_json(&self, _pkg: &PackageJson, _root: &Path) -> bool {
1746 false
1747 }
1748
1749 fn resolve_package_json(&self, _pkg: &PackageJson, _root: &Path) -> PluginResult {
1751 PluginResult::default()
1752 }
1753
1754 fn package_json_referenced_dependencies(
1759 &self,
1760 _pkg: &PackageJson,
1761 _root: &Path,
1762 ) -> Vec<String> {
1763 Vec::new()
1764 }
1765
1766 fn resolve_config(&self, _config_path: &Path, _source: &str, _root: &Path) -> PluginResult {
1771 PluginResult::default()
1772 }
1773
1774 fn resolve_config_in_project(
1783 &self,
1784 config_path: &Path,
1785 source: &str,
1786 root: &Path,
1787 _project_root: &Path,
1788 ) -> PluginResult {
1789 self.resolve_config(config_path, source, root)
1790 }
1791
1792 fn script_config_binaries(&self) -> &'static [&'static str] {
1800 &[]
1801 }
1802
1803 fn package_json_config_key(&self) -> Option<&'static str> {
1808 None
1809 }
1810}
1811
1812fn builtin_entry_point_role(name: &str) -> EntryPointRole {
1813 if TEST_ENTRY_POINT_PLUGINS.contains(&name) {
1814 EntryPointRole::Test
1815 } else if RUNTIME_ENTRY_POINT_PLUGINS.contains(&name) {
1816 EntryPointRole::Runtime
1817 } else {
1818 EntryPointRole::Support
1819 }
1820}
1821
1822macro_rules! define_plugin {
1883 (
1884 struct $name:ident => $display:expr,
1885 enablers: $enablers:expr
1886 $(, entry_patterns: $entry:expr)?
1887 $(, config_patterns: $config:expr)?
1888 $(, always_used: $always:expr)?
1889 $(, tooling_dependencies: $tooling:expr)?
1890 $(, fixture_glob_patterns: $fixtures:expr)?
1891 $(, discovery_hidden_dirs: $hidden_dirs:expr)?
1892 $(, virtual_module_prefixes: $virtual:expr)?
1893 $(, virtual_package_suffixes: $virtual_suffixes:expr)?
1894 $(, generated_type_import_prefixes: $generated_type_prefixes:expr)?
1895 $(, provided_dependencies: $provided_dependencies:expr)?
1896 $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
1897 , resolve_config: imports_only
1898 $(,)?
1899 ) => {
1900 pub struct $name;
1901
1902 impl Plugin for $name {
1903 fn name(&self) -> &'static str {
1904 $display
1905 }
1906
1907 fn enablers(&self) -> &'static [&'static str] {
1908 $enablers
1909 }
1910
1911 $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
1912 $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
1913 $( fn always_used(&self) -> &'static [&'static str] { $always } )?
1914 $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
1915 $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
1916 $( fn discovery_hidden_dirs(&self) -> &'static [&'static str] { $hidden_dirs } )?
1917 $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
1918 $( fn virtual_package_suffixes(&self) -> &'static [&'static str] { $virtual_suffixes } )?
1919 $( fn generated_type_import_prefixes(&self) -> &'static [&'static str] { $generated_type_prefixes } )?
1920 $( fn provided_dependencies(&self) -> Vec<ProvidedDependencyRule> { $provided_dependencies } )?
1921
1922 $(
1923 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
1924 vec![$( ($pat, $exports) ),*]
1925 }
1926 )?
1927
1928 fn resolve_config(
1929 &self,
1930 config_path: &std::path::Path,
1931 source: &str,
1932 _root: &std::path::Path,
1933 ) -> PluginResult {
1934 let mut result = PluginResult::default();
1935 crate::plugins::add_import_referenced_dependencies(
1936 &mut result,
1937 source,
1938 config_path,
1939 );
1940 result
1941 }
1942 }
1943 };
1944
1945 (
1946 struct $name:ident => $display:expr,
1947 enablers: $enablers:expr
1948 $(, entry_patterns: $entry:expr)?
1949 $(, config_patterns: $config:expr)?
1950 $(, always_used: $always:expr)?
1951 $(, tooling_dependencies: $tooling:expr)?
1952 $(, fixture_glob_patterns: $fixtures:expr)?
1953 $(, discovery_hidden_dirs: $hidden_dirs:expr)?
1954 $(, virtual_module_prefixes: $virtual:expr)?
1955 $(, virtual_package_suffixes: $virtual_suffixes:expr)?
1956 $(, generated_type_import_prefixes: $generated_type_prefixes:expr)?
1957 $(, provided_dependencies: $provided_dependencies:expr)?
1958 $(, package_json_config_key: $pkg_key:expr)?
1959 $(, script_config_binaries: $script_binaries:expr)?
1960 $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
1961 , resolve_config($cp:ident, $src:ident, $root:ident) $body:block
1962 $(, resolve_config_in_project($pcp:ident, $psrc:ident, $proot:ident, $pproj:ident) $pbody:block)?
1963 $(,)?
1964 ) => {
1965 pub struct $name;
1966
1967 impl Plugin for $name {
1968 fn name(&self) -> &'static str {
1969 $display
1970 }
1971
1972 fn enablers(&self) -> &'static [&'static str] {
1973 $enablers
1974 }
1975
1976 $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
1977 $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
1978 $( fn always_used(&self) -> &'static [&'static str] { $always } )?
1979 $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
1980 $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
1981 $( fn discovery_hidden_dirs(&self) -> &'static [&'static str] { $hidden_dirs } )?
1982 $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
1983 $( fn virtual_package_suffixes(&self) -> &'static [&'static str] { $virtual_suffixes } )?
1984 $( fn generated_type_import_prefixes(&self) -> &'static [&'static str] { $generated_type_prefixes } )?
1985 $( fn provided_dependencies(&self) -> Vec<ProvidedDependencyRule> { $provided_dependencies } )?
1986
1987 $(
1988 fn package_json_config_key(&self) -> Option<&'static str> {
1989 Some($pkg_key)
1990 }
1991 )?
1992
1993 $(
1994 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
1995 vec![$( ($pat, $exports) ),*]
1996 }
1997 )?
1998
1999 $( fn script_config_binaries(&self) -> &'static [&'static str] { $script_binaries } )?
2000
2001 fn resolve_config(
2002 &self,
2003 $cp: &std::path::Path,
2004 $src: &str,
2005 $root: &std::path::Path,
2006 ) -> PluginResult
2007 $body
2008
2009 $(
2010 fn resolve_config_in_project(
2011 &self,
2012 $pcp: &std::path::Path,
2013 $psrc: &str,
2014 $proot: &std::path::Path,
2015 $pproj: &std::path::Path,
2016 ) -> PluginResult
2017 $pbody
2018 )?
2019 }
2020 };
2021
2022 (
2023 struct $name:ident => $display:expr,
2024 enablers: $enablers:expr
2025 $(, entry_patterns: $entry:expr)?
2026 $(, config_patterns: $config:expr)?
2027 $(, always_used: $always:expr)?
2028 $(, tooling_dependencies: $tooling:expr)?
2029 $(, fixture_glob_patterns: $fixtures:expr)?
2030 $(, discovery_hidden_dirs: $hidden_dirs:expr)?
2031 $(, virtual_module_prefixes: $virtual:expr)?
2032 $(, virtual_package_suffixes: $virtual_suffixes:expr)?
2033 $(, generated_type_import_prefixes: $generated_type_prefixes:expr)?
2034 $(, provided_dependencies: $provided_dependencies:expr)?
2035 $(, package_json_config_key: $pkg_key:expr)?
2036 $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
2037 $(,)?
2038 ) => {
2039 pub struct $name;
2040
2041 impl Plugin for $name {
2042 fn name(&self) -> &'static str {
2043 $display
2044 }
2045
2046 fn enablers(&self) -> &'static [&'static str] {
2047 $enablers
2048 }
2049
2050 $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
2051 $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
2052 $( fn always_used(&self) -> &'static [&'static str] { $always } )?
2053 $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
2054 $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
2055 $( fn discovery_hidden_dirs(&self) -> &'static [&'static str] { $hidden_dirs } )?
2056 $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
2057 $( fn virtual_package_suffixes(&self) -> &'static [&'static str] { $virtual_suffixes } )?
2058 $( fn generated_type_import_prefixes(&self) -> &'static [&'static str] { $generated_type_prefixes } )?
2059 $( fn provided_dependencies(&self) -> Vec<ProvidedDependencyRule> { $provided_dependencies } )?
2060
2061 $(
2062 fn package_json_config_key(&self) -> Option<&'static str> {
2063 Some($pkg_key)
2064 }
2065 )?
2066
2067 $(
2068 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
2069 vec![$( ($pat, $exports) ),*]
2070 }
2071 )?
2072 }
2073 };
2074}
2075
2076pub mod config_parser;
2077mod config_value_credits;
2078mod flue;
2079mod jasmine;
2080mod manifest;
2081pub mod manifest_entries;
2082mod mise;
2083mod osls;
2084mod project_glob;
2085mod railway;
2086pub mod registry;
2087mod rstest;
2088mod serverless_framework;
2089mod textlint;
2090mod tooling;
2091mod used_export_names;
2092
2093pub(crate) use module_federation::runtime_remotes;
2094pub use registry::{
2095 AggregatedPluginResult, PluginRegistry, PluginToolingDependencies, PluginToolingEvidence,
2096};
2097pub use tooling::types_package_target;
2098pub(crate) use tooling::{
2099 cli_tooling_config_patterns, is_ambient_types_package, is_known_tooling_dependency,
2100};
2101pub use used_export_names::UsedExportNames;
2102
2103fn add_import_referenced_dependencies(result: &mut PluginResult, source: &str, config_path: &Path) {
2104 let imports = config_parser::extract_imports(source, config_path);
2105 for import in &imports {
2106 result
2107 .referenced_dependencies
2108 .push(crate::resolve::extract_package_name(import));
2109 }
2110}
2111
2112fn credit_environment_optional_peers(environment: &str, result: &mut PluginResult) {
2124 credit_config_value(
2125 config_value_credits::CreditSurface::TestEnvironmentOptionalPeer,
2126 canonical_test_environment(environment),
2127 result,
2128 );
2129}
2130
2131fn credit_config_value(
2136 surface: config_value_credits::CreditSurface,
2137 value: &str,
2138 result: &mut PluginResult,
2139) -> bool {
2140 let Some(packages) = config_value_credits::credited_packages(surface, value) else {
2141 return false;
2142 };
2143 result
2144 .referenced_dependencies
2145 .extend(packages.iter().cloned());
2146 true
2147}
2148
2149fn canonical_test_environment(environment: &str) -> &str {
2157 environment
2158 .strip_prefix("jest-environment-")
2159 .or_else(|| environment.strip_prefix("vitest-environment-"))
2160 .unwrap_or(environment)
2161}
2162
2163mod adonis;
2164mod ag_ui;
2165mod alchemy;
2166mod angular;
2167mod astro;
2168mod ava;
2169mod babel;
2170mod biome;
2171mod browser_extension;
2172mod bun;
2173mod c8;
2174mod capacitor;
2175mod changesets;
2176mod commit_and_tag_version;
2177mod commitizen;
2178mod commitlint;
2179mod content_collections;
2180mod contentlayer;
2181mod convex;
2182mod cspell;
2183mod cucumber;
2184mod cypress;
2185mod danger;
2186mod deno;
2187mod dependency_cruiser;
2188mod docusaurus;
2189mod drizzle;
2190mod electron;
2191mod ember;
2192mod eslint;
2193mod eve;
2194mod expo;
2195mod expo_router;
2196mod firebase;
2197mod fumadocs;
2198mod gatsby;
2199mod graphql_codegen;
2200mod hardhat;
2201mod husky;
2202mod i18next;
2203mod ionic;
2204mod jest;
2205mod k6;
2206mod karma;
2207mod kibana;
2208mod knex;
2209mod kysely;
2210mod lefthook;
2211mod lexical;
2212mod lint_staged;
2213mod lit;
2214mod markdownlint;
2215mod mintlify;
2216mod mocha;
2217mod module_federation;
2218mod msw;
2219mod napi_rs;
2220mod nest_commander;
2221mod nestjs;
2222mod nestjs_trpc;
2223mod next_intl;
2224mod nextjs;
2225mod nitro;
2226mod nodemon;
2227pub(crate) mod nuxt;
2228mod nx;
2229mod nyc;
2230mod obsidian;
2231mod openapi_ts;
2232mod opencode;
2233mod opennext_cloudflare;
2234mod opentelemetry;
2235mod oxfmt;
2236mod oxlint;
2237mod pandacss;
2238mod parcel;
2239mod pinia;
2240mod pkg_utils;
2241mod playwright;
2242mod plop;
2243mod pm2;
2244mod pnpm;
2245mod postcss;
2246mod prettier;
2247mod prisma;
2248mod qwik;
2249mod react_compiler;
2250mod react_native;
2251mod react_router;
2252mod redwoodsdk;
2253mod relay;
2254mod release_it;
2255mod remark;
2256mod remix;
2257mod rolldown;
2258mod rollup;
2259mod rsbuild;
2260mod rspack;
2261mod rspress;
2262mod sanity;
2263mod semantic_release;
2264mod sentry;
2265mod simple_git_hooks;
2266mod size_limit;
2267mod storybook;
2268mod stryker;
2269mod stylelint;
2270mod supabase;
2271mod sveltekit;
2272mod svgo;
2273mod svgr;
2274mod swc;
2275mod syncpack;
2276mod tailwind;
2277mod tanstack_router;
2278mod tap;
2279mod test_alias;
2280mod tsd;
2281mod tsdown;
2282mod tsup;
2283mod turborepo;
2284mod typedoc;
2285mod typeorm;
2286mod typescript;
2287mod typescript_content_mapper;
2288mod unocss;
2289mod varlock;
2290mod velite;
2291mod vercel;
2292mod vite;
2293mod vitepress;
2294mod vitest;
2295mod vscode;
2296mod waku;
2297mod webdriverio;
2298mod webpack;
2299mod wrangler;
2300mod wuchale;
2301mod wxt;
2302
2303#[cfg(test)]
2304mod tests {
2305 use super::*;
2306
2307 #[test]
2308 fn segment_regexes_split_on_the_native_separator() {
2309 let regexes = vec![Regex::new("^_(components|hooks)$").expect("valid regex")];
2310 assert!(matches_segment_regex(
2311 "src/pages/_components/a.tsx",
2312 ®exes
2313 ));
2314 assert!(!matches_segment_regex(
2315 "src/pages/components/a.tsx",
2316 ®exes
2317 ));
2318 assert_eq!(
2319 matches_segment_regex("src\\pages\\_components\\a.tsx", ®exes),
2320 cfg!(windows),
2321 "a backslash separates segments only where it is the native separator"
2322 );
2323 }
2324 use std::path::Path;
2325
2326 #[test]
2327 fn is_enabled_with_deps_exact_match() {
2328 let plugin = nextjs::NextJsPlugin;
2329 let deps = vec!["next".to_string()];
2330 assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
2331 }
2332
2333 #[test]
2334 fn is_enabled_with_deps_no_match() {
2335 let plugin = nextjs::NextJsPlugin;
2336 let deps = vec!["react".to_string()];
2337 assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
2338 }
2339
2340 #[test]
2341 fn is_enabled_with_deps_empty_deps() {
2342 let plugin = nextjs::NextJsPlugin;
2343 let deps: Vec<String> = vec![];
2344 assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
2345 }
2346
2347 #[test]
2348 fn environment_optional_peers_come_from_the_credit_catalogue() {
2349 for environment in [
2350 "jsdom",
2351 "jest-environment-jsdom",
2352 "vitest-environment-jsdom",
2353 ] {
2354 let mut result = PluginResult::default();
2355 credit_environment_optional_peers(environment, &mut result);
2356 assert_eq!(
2357 result.referenced_dependencies,
2358 vec!["canvas".to_string()],
2359 "expected the catalogue credit for {environment}"
2360 );
2361 }
2362 }
2363
2364 #[test]
2365 fn environment_without_a_catalogue_row_credits_nothing() {
2366 let mut result = PluginResult::default();
2367 credit_environment_optional_peers("happy-dom", &mut result);
2368 assert!(result.referenced_dependencies.is_empty());
2369 }
2370
2371 #[test]
2372 fn entry_point_role_defaults_are_centralized() {
2373 assert_eq!(vite::VitePlugin.entry_point_role(), EntryPointRole::Runtime);
2374 assert_eq!(
2375 vitest::VitestPlugin.entry_point_role(),
2376 EntryPointRole::Test
2377 );
2378 assert_eq!(
2379 storybook::StorybookPlugin.entry_point_role(),
2380 EntryPointRole::Support
2381 );
2382 assert_eq!(
2383 obsidian::ObsidianPlugin.entry_point_role(),
2384 EntryPointRole::Runtime
2385 );
2386 assert_eq!(knex::KnexPlugin.entry_point_role(), EntryPointRole::Support);
2387 }
2388
2389 #[test]
2390 fn plugins_with_entry_patterns_have_explicit_role_intent() {
2391 let runtime_or_test_or_support: rustc_hash::FxHashSet<&'static str> =
2392 TEST_ENTRY_POINT_PLUGINS
2393 .iter()
2394 .chain(RUNTIME_ENTRY_POINT_PLUGINS.iter())
2395 .chain(SUPPORT_ENTRY_POINT_PLUGINS.iter())
2396 .copied()
2397 .collect();
2398
2399 for plugin in crate::plugins::registry::builtin::create_builtin_plugins() {
2400 if plugin.entry_patterns().is_empty() {
2401 continue;
2402 }
2403 assert!(
2404 runtime_or_test_or_support.contains(plugin.name()),
2405 "plugin '{}' exposes entry patterns but is missing from the entry-point role map",
2406 plugin.name()
2407 );
2408 }
2409 }
2410
2411 #[test]
2414 fn plugin_result_is_empty_only_when_every_field_is_empty() {
2415 type Fill = fn(&mut PluginResult);
2416
2417 assert!(PluginResult::default().is_empty());
2418
2419 let rows: [(&str, Fill); 18] = [
2420 ("entry_patterns", |r| {
2421 r.entry_patterns.push(PathRule::new("src/*.ts"));
2422 }),
2423 ("used_exports", |r| {
2424 r.used_exports
2425 .push(UsedExportRule::new("src/*.ts", ["default"]));
2426 }),
2427 ("used_class_members", |r| {
2428 r.used_class_members
2429 .push(UsedClassMemberRule::from("render"));
2430 }),
2431 ("referenced_dependencies", |r| {
2432 r.referenced_dependencies.push("lodash".to_string());
2433 }),
2434 ("package_referenced_dependencies", |r| {
2435 r.package_referenced_dependencies
2436 .push((PathBuf::from("/project/pkg"), "lodash".to_string()));
2437 }),
2438 ("always_used_files", |r| {
2439 r.always_used_files.push("**/*.stories.tsx".to_string());
2440 }),
2441 ("path_aliases", |r| {
2442 r.path_aliases.push(("@".to_string(), "src".to_string()));
2443 }),
2444 ("setup_files", |r| {
2445 r.setup_files.push(PathBuf::from("/setup.ts"));
2446 }),
2447 ("fixture_patterns", |r| {
2448 r.fixture_patterns.push("**/__fixtures__/**/*".to_string());
2449 }),
2450 ("scss_include_paths", |r| {
2451 r.scss_include_paths.push(PathBuf::from("/project/styles"));
2452 }),
2453 ("static_dir_mappings", |r| {
2454 r.static_dir_mappings
2455 .push((PathBuf::from("/project/public"), "/".to_string()));
2456 }),
2457 ("framework_static_dir_mappings", |r| {
2458 r.framework_static_dir_mappings
2459 .push((PathBuf::from("/project/static"), "/".to_string()));
2460 }),
2461 ("provided_dependencies", |r| {
2462 r.provided_dependencies.push(ProvidedDependencyRule::new(
2463 "**/*.stories.tsx",
2464 ["react"],
2465 Vec::<String>::new(),
2466 ));
2467 }),
2468 ("config_diagnostics", |r| {
2469 r.config_diagnostics
2470 .push(PluginConfigDiagnostic::unreadable(
2471 Path::new("/project/webpack.config.js"),
2472 "webpack",
2473 "exposes",
2474 "dynamic-value",
2475 ));
2476 }),
2477 ("federation_sources", |r| {
2478 r.federation_sources.push(FederationSource {
2479 target: FederationSourceTarget::Remote("app".to_string()),
2480 config_path: PathBuf::from("/project/webpack.config.js"),
2481 plugin: "webpack".to_string(),
2482 key: "remotes",
2483 });
2484 }),
2485 ("jsx_import_sources", |r| {
2486 r.jsx_import_sources.push(JsxImportSourceRule {
2487 source: "./src/jsx".to_string(),
2488 config_dir: PathBuf::from("/project"),
2489 include: vec!["src/**/*.test.tsx".to_string()],
2490 exclude: vec![],
2491 });
2492 }),
2493 ("jsx_package_credits", |r| {
2494 r.jsx_package_credits.push(JsxImportSourceRule {
2495 source: "react".to_string(),
2496 config_dir: PathBuf::from("/project"),
2497 include: vec!["src/**/*.test.tsx".to_string()],
2498 exclude: vec![],
2499 });
2500 }),
2501 ("tsconfig_jsx_dirs", |r| {
2502 r.tsconfig_jsx_dirs.push(PathBuf::from("/project"));
2503 }),
2504 ];
2505
2506 for (field, fill) in rows {
2507 let mut result = PluginResult::default();
2508 fill(&mut result);
2509 assert!(
2510 !result.is_empty(),
2511 "a result with only {field} set must not be empty"
2512 );
2513 }
2514 }
2515
2516 #[test]
2517 fn is_enabled_with_deps_prefix_match() {
2518 let plugin = storybook::StorybookPlugin;
2519 let deps = vec!["@storybook/react".to_string()];
2520 assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
2521 }
2522
2523 #[test]
2524 fn is_enabled_with_deps_prefix_no_match_without_slash() {
2525 let plugin = storybook::StorybookPlugin;
2526 let deps = vec!["@storybookish".to_string()];
2527 assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
2528 }
2529
2530 #[test]
2531 fn is_enabled_with_deps_multiple_enablers() {
2532 let plugin = vitest::VitestPlugin;
2533 let deps_vitest = vec!["vitest".to_string()];
2534 let deps_none = vec!["mocha".to_string()];
2535 assert!(plugin.is_enabled_with_deps(&deps_vitest, Path::new("/project")));
2536 assert!(!plugin.is_enabled_with_deps(&deps_none, Path::new("/project")));
2537 }
2538
2539 #[test]
2540 fn plugin_resolve_config_default_returns_empty() {
2541 let plugin = commitizen::CommitizenPlugin;
2542 let result = plugin.resolve_config(
2543 Path::new("/project/config.js"),
2544 "const x = 1;",
2545 Path::new("/project"),
2546 );
2547 assert!(result.is_empty());
2548 }
2549
2550 #[test]
2551 fn is_enabled_with_deps_exact_and_prefix_both_work() {
2552 let plugin = storybook::StorybookPlugin;
2553 let deps_exact = vec!["storybook".to_string()];
2554 assert!(plugin.is_enabled_with_deps(&deps_exact, Path::new("/project")));
2555 let deps_prefix = vec!["@storybook/vue3".to_string()];
2556 assert!(plugin.is_enabled_with_deps(&deps_prefix, Path::new("/project")));
2557 }
2558
2559 #[test]
2560 fn is_enabled_with_deps_multiple_enablers_remix() {
2561 let plugin = remix::RemixPlugin;
2562 let deps_node = vec!["@remix-run/node".to_string()];
2563 assert!(plugin.is_enabled_with_deps(&deps_node, Path::new("/project")));
2564 let deps_react = vec!["@remix-run/react".to_string()];
2565 assert!(plugin.is_enabled_with_deps(&deps_react, Path::new("/project")));
2566 let deps_cf = vec!["@remix-run/cloudflare".to_string()];
2567 assert!(plugin.is_enabled_with_deps(&deps_cf, Path::new("/project")));
2568 }
2569
2570 struct MinimalPlugin;
2571 impl Plugin for MinimalPlugin {
2572 fn name(&self) -> &'static str {
2573 "minimal"
2574 }
2575 }
2576
2577 #[test]
2578 fn default_resolve_config_returns_empty() {
2579 let r = MinimalPlugin.resolve_config(
2580 Path::new("config.js"),
2581 "export default {}",
2582 Path::new("/"),
2583 );
2584 assert!(r.is_empty());
2585 }
2586
2587 #[test]
2588 fn default_package_json_metadata_hooks_are_empty() {
2589 let pkg = PackageJson::default();
2590 assert!(!MinimalPlugin.is_enabled_with_package_json(&pkg, Path::new("/")));
2591 assert!(
2592 MinimalPlugin
2593 .resolve_package_json(&pkg, Path::new("/"))
2594 .is_empty()
2595 );
2596 }
2597
2598 #[test]
2599 fn default_is_enabled_returns_false_when_no_enablers() {
2600 let deps = vec!["anything".to_string()];
2601 assert!(!MinimalPlugin.is_enabled_with_deps(&deps, Path::new("/")));
2602 }
2603
2604 #[test]
2605 fn all_builtin_plugin_names_are_non_empty_and_unique() {
2606 let plugins = registry::builtin::create_builtin_plugins();
2607 let mut seen = std::collections::BTreeSet::new();
2608 for p in &plugins {
2609 let name = p.name();
2610 assert!(
2611 !name.is_empty(),
2612 "builtin plugins must have a non-empty name"
2613 );
2614 assert!(seen.insert(name), "duplicate plugin name: {name}");
2615 }
2616 }
2617
2618 #[test]
2619 fn all_builtin_plugins_have_activation_signals() {
2620 const NON_DEPENDENCY_ACTIVATED_PLUGINS: &[&str] =
2624 &["napi-rs", "deno", "kibana", "typescript-content-mapper"];
2625 let plugins = registry::builtin::create_builtin_plugins();
2626 for p in &plugins {
2627 assert!(
2628 !p.enablers().is_empty()
2629 || !p.script_enablers().is_empty()
2630 || NON_DEPENDENCY_ACTIVATED_PLUGINS.contains(&p.name()),
2631 "plugin '{}' has no activation signal",
2632 p.name()
2633 );
2634 }
2635 }
2636
2637 #[test]
2638 fn plugins_with_config_patterns_have_always_used() {
2639 let plugins = registry::builtin::create_builtin_plugins();
2640 for p in &plugins {
2641 if !p.config_patterns().is_empty() {
2642 assert!(
2643 !p.always_used().is_empty(),
2644 "plugin '{}' has config_patterns but no always_used",
2645 p.name()
2646 );
2647 }
2648 }
2649 }
2650
2651 #[test]
2652 fn framework_plugins_enablers() {
2653 let cases: Vec<(&dyn Plugin, &[&str])> = vec![
2654 (&nextjs::NextJsPlugin, &["next"]),
2655 (&nuxt::NuxtPlugin, &["nuxt"]),
2656 (&angular::AngularPlugin, &["@angular/core"]),
2657 (&ionic::IonicPlugin, &["@ionic/angular"]),
2658 (&sveltekit::SvelteKitPlugin, &["@sveltejs/kit"]),
2659 (&gatsby::GatsbyPlugin, &["gatsby"]),
2660 ];
2661 for (plugin, expected_enablers) in cases {
2662 let enablers = plugin.enablers();
2663 for expected in expected_enablers {
2664 assert!(
2665 enablers.contains(expected),
2666 "plugin '{}' should have '{}'",
2667 plugin.name(),
2668 expected
2669 );
2670 }
2671 }
2672 }
2673
2674 #[test]
2675 fn testing_plugins_enablers() {
2676 let cases: Vec<(&dyn Plugin, &str)> = vec![
2677 (&jest::JestPlugin, "jest"),
2678 (&vitest::VitestPlugin, "vitest"),
2679 (&playwright::PlaywrightPlugin, "@playwright/test"),
2680 (&cypress::CypressPlugin, "cypress"),
2681 (&mocha::MochaPlugin, "mocha"),
2682 (&stryker::StrykerPlugin, "@stryker-mutator/core"),
2683 ];
2684 for (plugin, enabler) in cases {
2685 assert!(
2686 plugin.enablers().contains(&enabler),
2687 "plugin '{}' should have '{}'",
2688 plugin.name(),
2689 enabler
2690 );
2691 }
2692 }
2693
2694 #[test]
2695 fn bundler_plugins_enablers() {
2696 let cases: Vec<(&dyn Plugin, &str)> = vec![
2697 (&vite::VitePlugin, "vite"),
2698 (&webpack::WebpackPlugin, "webpack"),
2699 (&rollup::RollupPlugin, "rollup"),
2700 ];
2701 for (plugin, enabler) in cases {
2702 assert!(
2703 plugin.enablers().contains(&enabler),
2704 "plugin '{}' should have '{}'",
2705 plugin.name(),
2706 enabler
2707 );
2708 }
2709 }
2710
2711 #[test]
2712 fn test_plugins_have_test_entry_patterns() {
2713 let test_plugins: Vec<&dyn Plugin> = vec![
2714 &bun::BunPlugin,
2715 &deno::DenoPlugin,
2716 &jest::JestPlugin,
2717 &vitest::VitestPlugin,
2718 &mocha::MochaPlugin,
2719 &tap::TapPlugin,
2720 &tsd::TsdPlugin,
2721 ];
2722 for plugin in test_plugins {
2723 let patterns = plugin.entry_patterns();
2724 assert!(
2725 !patterns.is_empty(),
2726 "test plugin '{}' should have entry patterns",
2727 plugin.name()
2728 );
2729 assert!(
2730 patterns
2731 .iter()
2732 .any(|p| p.contains("test") || p.contains("spec") || p.contains("__tests__")),
2733 "test plugin '{}' should have test/spec patterns",
2734 plugin.name()
2735 );
2736 }
2737 }
2738
2739 #[test]
2740 fn framework_plugins_have_entry_patterns() {
2741 let plugins: Vec<&dyn Plugin> = vec![
2742 &nextjs::NextJsPlugin,
2743 &nuxt::NuxtPlugin,
2744 &angular::AngularPlugin,
2745 &sveltekit::SvelteKitPlugin,
2746 ];
2747 for plugin in plugins {
2748 assert!(
2749 !plugin.entry_patterns().is_empty(),
2750 "framework plugin '{}' should have entry patterns",
2751 plugin.name()
2752 );
2753 }
2754 }
2755
2756 #[test]
2757 fn plugins_with_resolve_config_have_config_patterns() {
2758 let plugins: Vec<&dyn Plugin> = vec![
2759 &jest::JestPlugin,
2760 &vitest::VitestPlugin,
2761 &babel::BabelPlugin,
2762 &eslint::EslintPlugin,
2763 &webpack::WebpackPlugin,
2764 &storybook::StorybookPlugin,
2765 &typescript::TypeScriptPlugin,
2766 &postcss::PostCssPlugin,
2767 &nextjs::NextJsPlugin,
2768 &nuxt::NuxtPlugin,
2769 &angular::AngularPlugin,
2770 &nx::NxPlugin,
2771 &stryker::StrykerPlugin,
2772 &wuchale::WuchalePlugin,
2773 &rollup::RollupPlugin,
2774 &sveltekit::SvelteKitPlugin,
2775 &prettier::PrettierPlugin,
2776 &contentlayer::ContentlayerPlugin,
2777 ];
2778 for plugin in plugins {
2779 assert!(
2780 !plugin.config_patterns().is_empty(),
2781 "plugin '{}' with resolve_config should have config_patterns",
2782 plugin.name()
2783 );
2784 }
2785 }
2786
2787 #[test]
2788 fn plugin_tooling_deps_include_enabler_package() {
2789 let plugins: Vec<&dyn Plugin> = vec![
2790 &jest::JestPlugin,
2791 &vitest::VitestPlugin,
2792 &webpack::WebpackPlugin,
2793 &typescript::TypeScriptPlugin,
2794 &eslint::EslintPlugin,
2795 &prettier::PrettierPlugin,
2796 &danger::DangerPlugin,
2797 &stryker::StrykerPlugin,
2798 &wuchale::WuchalePlugin,
2799 &contentlayer::ContentlayerPlugin,
2800 ];
2801 for plugin in plugins {
2802 let tooling = plugin.tooling_dependencies();
2803 let enablers = plugin.enablers();
2804 assert!(
2805 enablers
2806 .iter()
2807 .any(|e| !e.ends_with('/') && tooling.contains(e)),
2808 "plugin '{}': at least one non-prefix enabler should be in tooling_dependencies",
2809 plugin.name()
2810 );
2811 }
2812 }
2813
2814 #[test]
2815 fn nextjs_has_used_exports_for_pages() {
2816 let plugin = nextjs::NextJsPlugin;
2817 let exports = plugin.used_exports();
2818 assert!(!exports.is_empty());
2819 assert!(exports.iter().any(|(_, names)| names.contains(&"default")));
2820 }
2821
2822 #[test]
2823 fn remix_has_used_exports_for_routes() {
2824 let plugin = remix::RemixPlugin;
2825 let exports = plugin.used_exports();
2826 assert!(!exports.is_empty());
2827 let route_entry = exports.iter().find(|(pat, _)| pat.contains("routes"));
2828 assert!(route_entry.is_some());
2829 let (_, names) = route_entry.unwrap();
2830 assert!(names.contains(&"loader"));
2831 assert!(names.contains(&"action"));
2832 assert!(names.contains(&"default"));
2833 }
2834
2835 #[test]
2836 fn sveltekit_has_used_exports_for_routes() {
2837 let plugin = sveltekit::SvelteKitPlugin;
2838 let exports = plugin.used_exports();
2839 assert!(!exports.is_empty());
2840 assert!(exports.iter().any(|(_, names)| names.contains(&"GET")));
2841 }
2842
2843 #[test]
2844 fn nuxt_has_hash_virtual_prefix() {
2845 assert!(nuxt::NuxtPlugin.virtual_module_prefixes().contains(&"#"));
2846 }
2847
2848 #[test]
2849 fn sveltekit_has_dollar_virtual_prefixes() {
2850 let prefixes = sveltekit::SvelteKitPlugin.virtual_module_prefixes();
2851 assert!(prefixes.contains(&"$app/"));
2852 assert!(prefixes.contains(&"$env/"));
2853 assert!(prefixes.contains(&"$lib/"));
2854 }
2855
2856 #[test]
2857 fn sveltekit_has_lib_path_alias() {
2858 let aliases = sveltekit::SvelteKitPlugin.path_aliases(Path::new("/project"));
2859 assert!(aliases.iter().any(|(prefix, _)| *prefix == "$lib/"));
2860 }
2861
2862 #[test]
2863 fn nuxt_has_tilde_path_alias() {
2864 let aliases = nuxt::NuxtPlugin.path_aliases(Path::new("/nonexistent"));
2865 assert!(aliases.iter().any(|(prefix, _)| *prefix == "~/"));
2866 assert!(aliases.iter().any(|(prefix, _)| *prefix == "~~/"));
2867 }
2868
2869 #[test]
2870 fn jest_has_package_json_config_key() {
2871 assert_eq!(jest::JestPlugin.package_json_config_key(), Some("jest"));
2872 }
2873
2874 #[test]
2875 fn tsd_has_package_json_config_key() {
2876 assert_eq!(tsd::TsdPlugin.package_json_config_key(), Some("tsd"));
2877 }
2878
2879 #[test]
2880 fn babel_has_package_json_config_key() {
2881 assert_eq!(babel::BabelPlugin.package_json_config_key(), Some("babel"));
2882 }
2883
2884 #[test]
2885 fn eslint_has_package_json_config_key() {
2886 assert_eq!(
2887 eslint::EslintPlugin.package_json_config_key(),
2888 Some("eslintConfig")
2889 );
2890 }
2891
2892 #[test]
2893 fn prettier_has_package_json_config_key() {
2894 assert_eq!(
2895 prettier::PrettierPlugin.package_json_config_key(),
2896 Some("prettier")
2897 );
2898 }
2899
2900 #[test]
2901 fn macro_generated_plugin_basic_properties() {
2902 let plugin = msw::MswPlugin;
2903 assert_eq!(plugin.name(), "msw");
2904 assert!(plugin.enablers().contains(&"msw"));
2905 assert!(!plugin.entry_patterns().is_empty());
2906 assert!(plugin.config_patterns().is_empty());
2907 assert!(!plugin.always_used().is_empty());
2908 assert!(!plugin.tooling_dependencies().is_empty());
2909 }
2910
2911 #[test]
2912 fn macro_generated_plugin_with_used_exports() {
2913 let plugin = remix::RemixPlugin;
2914 assert_eq!(plugin.name(), "remix");
2915 assert!(!plugin.used_exports().is_empty());
2916 }
2917
2918 #[test]
2919 fn macro_passes_through_virtual_package_suffixes() {
2920 define_plugin! {
2921 struct MacroSuffixSmokePlugin => "macro-suffix-smoke",
2922 enablers: &["macro-suffix-smoke"],
2923 virtual_package_suffixes: &["/__macro_smoke__"],
2924 }
2925
2926 let plugin = MacroSuffixSmokePlugin;
2927 assert_eq!(
2928 plugin.virtual_package_suffixes(),
2929 &["/__macro_smoke__"],
2930 "macro-declared virtual_package_suffixes must propagate to the trait method"
2931 );
2932 }
2933
2934 #[test]
2935 fn macro_generated_plugin_imports_only_resolve_config() {
2936 let plugin = cypress::CypressPlugin;
2937 let source = r"
2938 import { defineConfig } from 'cypress';
2939 import coveragePlugin from '@cypress/code-coverage';
2940 export default defineConfig({});
2941 ";
2942 let result = plugin.resolve_config(
2943 Path::new("cypress.config.ts"),
2944 source,
2945 Path::new("/project"),
2946 );
2947 assert!(
2948 result
2949 .referenced_dependencies
2950 .contains(&"cypress".to_string())
2951 );
2952 assert!(
2953 result
2954 .referenced_dependencies
2955 .contains(&"@cypress/code-coverage".to_string())
2956 );
2957 }
2958
2959 #[test]
2960 fn builtin_plugin_count_is_expected() {
2961 let plugins = registry::builtin::create_builtin_plugins();
2962 assert!(
2963 plugins.len() >= 110,
2964 "expected at least 110 built-in plugins, got {}",
2965 plugins.len()
2966 );
2967 }
2968
2969 #[test]
2973 fn a_parent_relative_pattern_resolves_against_the_workspace_prefix() {
2974 let parent_relative = |pattern: &str| {
2975 let mut rule = PathRule::new(pattern);
2976 rule.parent_relative = true;
2977 rule
2978 };
2979 assert_eq!(
2980 parent_relative("../shared/src/Thing.tsx")
2981 .prefixed("packages/app")
2982 .pattern,
2983 "packages/shared/src/Thing.tsx"
2984 );
2985 assert_eq!(
2986 parent_relative("../../lib/index.{ts,js}")
2987 .prefixed("apps/web/client")
2988 .pattern,
2989 "apps/lib/index.{ts,js}"
2990 );
2991 assert!(
2992 parent_relative("../../../outside/Thing.tsx")
2993 .prefixed("packages/app")
2994 .pattern
2995 .starts_with("../"),
2996 "a climb past the project root matches no project file"
2997 );
2998 assert_eq!(
2999 parent_relative("src/index.ts")
3000 .prefixed("packages/app")
3001 .pattern,
3002 "packages/app/src/index.ts"
3003 );
3004 }
3005
3006 #[test]
3009 fn a_parent_relative_pattern_resolves_against_a_backslash_prefix() {
3010 let mut rule = PathRule::new("../../packages/ui/src/**/*.mdx");
3011 rule.parent_relative = true;
3012 assert_eq!(
3013 rule.prefixed("apps\\docs").pattern,
3014 "packages/ui/src/**/*.mdx"
3015 );
3016 }
3017
3018 #[test]
3021 fn a_plain_parent_pattern_is_not_resolved() {
3022 assert_eq!(
3023 PathRule::new("../src/**/*.stories.tsx")
3024 .prefixed("packages/ui")
3025 .pattern,
3026 "packages/ui/../src/**/*.stories.tsx"
3027 );
3028 }
3029}