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 "jest",
27 "k6",
28 "mocha",
29 "playwright",
30 "tap",
31 "tsd",
32 "vitest",
33 "webdriverio",
34];
35
36const RUNTIME_ENTRY_POINT_PLUGINS: &[&str] = &[
37 "adonis",
38 "angular",
39 "astro",
40 "browser-extension",
41 "convex",
42 "docusaurus",
43 "electron",
44 "ember",
45 "eve",
46 "expo",
47 "expo-router",
48 "gatsby",
49 "hardhat",
50 "kibana",
51 "module-federation",
52 "nestjs",
53 "next-intl",
54 "nextjs",
55 "nitro",
56 "nuxt",
57 "obsidian",
58 "parcel",
59 "qwik",
60 "react-native",
61 "react-router",
62 "redwoodsdk",
63 "remix",
64 "rolldown",
65 "rollup",
66 "rsbuild",
67 "rspack",
68 "sanity",
69 "supabase",
70 "sveltekit",
71 "tanstack-router",
72 "tsdown",
73 "tsup",
74 "vercel",
75 "vite",
76 "vitepress",
77 "waku",
78 "webpack",
79 "wrangler",
80 "wxt",
81];
82
83#[cfg(test)]
84const SUPPORT_ENTRY_POINT_PLUGINS: &[&str] = &[
85 "content-collections",
86 "contentlayer",
87 "danger",
88 "drizzle",
89 "fumadocs",
90 "i18next",
91 "knex",
92 "kysely",
93 "mintlify",
94 "msw",
95 "opencode",
96 "prisma",
97 "storybook",
98 "stryker",
99 "typeorm",
100 "velite",
101];
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum PluginConfigEffect {
106 Unreadable,
109 NotModeled,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct PluginConfigDiagnostic {
126 pub config_path: PathBuf,
128 pub plugin: String,
132 pub key: String,
135 pub reason: String,
137 pub effect: PluginConfigEffect,
139}
140
141impl PluginConfigDiagnostic {
142 pub(super) fn unreadable(
144 config_path: &Path,
145 plugin: &str,
146 key: &str,
147 reason: &'static str,
148 ) -> Self {
149 Self {
150 config_path: config_path.to_path_buf(),
151 plugin: plugin.to_owned(),
152 key: key.to_owned(),
153 reason: reason.to_owned(),
154 effect: PluginConfigEffect::Unreadable,
155 }
156 }
157
158 pub(crate) fn not_modeled(
160 config_path: &Path,
161 plugin: &str,
162 key: &str,
163 reason: &'static str,
164 ) -> Self {
165 Self {
166 config_path: config_path.to_path_buf(),
167 plugin: plugin.to_owned(),
168 key: key.to_owned(),
169 reason: reason.to_owned(),
170 effect: PluginConfigEffect::NotModeled,
171 }
172 }
173
174 #[must_use]
177 pub fn into_workspace_diagnostic(self, root: &Path) -> fallow_config::WorkspaceDiagnostic {
178 let Self {
179 config_path,
180 plugin,
181 key,
182 reason,
183 effect,
184 } = self;
185 let kind = match effect {
186 PluginConfigEffect::Unreadable => {
187 fallow_config::WorkspaceDiagnosticKind::PluginConfigUnreadable {
188 plugin,
189 key,
190 reason,
191 }
192 }
193 PluginConfigEffect::NotModeled => {
194 fallow_config::WorkspaceDiagnosticKind::PluginEffectNotModeled {
195 plugin,
196 key,
197 reason,
198 }
199 }
200 };
201 fallow_config::WorkspaceDiagnostic::new(root, config_path, kind)
202 }
203}
204
205#[derive(Debug, Default)]
207pub struct PluginResult {
208 entry_patterns: Vec<PathRule>,
210 replace_entry_patterns: bool,
215 accumulate_config_entry_patterns: bool,
219 replace_used_export_rules: bool,
222 used_exports: Vec<UsedExportRule>,
224 used_class_members: Vec<UsedClassMemberRule>,
229 referenced_dependencies: Vec<String>,
231 package_referenced_dependencies: Vec<(PathBuf, String)>,
234 always_used_files: Vec<String>,
236 path_aliases: Vec<(String, String)>,
238 setup_files: Vec<PathBuf>,
240 fixture_patterns: Vec<String>,
242 scss_include_paths: Vec<PathBuf>,
250 static_dir_mappings: Vec<(PathBuf, String)>,
253 framework_static_dir_mappings: Vec<(PathBuf, String)>,
254 provided_dependencies: Vec<ProvidedDependencyRule>,
257 config_diagnostics: Vec<PluginConfigDiagnostic>,
261 federation_sources: Vec<FederationSource>,
264 jsx_import_sources: Vec<JsxImportSourceRule>,
268}
269
270#[derive(Debug, Clone, PartialEq, Eq)]
272pub struct FederationSource {
273 pub target: FederationSourceTarget,
275 pub config_path: PathBuf,
277 pub plugin: String,
279 pub key: &'static str,
281}
282
283#[derive(Debug, Clone, PartialEq, Eq)]
285pub enum FederationSourceTarget {
286 Exposed(PathRule),
288 Remote(String),
290}
291
292impl FederationSource {
293 #[must_use]
294 fn prefixed(&self, ws_prefix: &str) -> Self {
295 let target = match &self.target {
296 FederationSourceTarget::Exposed(rule) => {
297 FederationSourceTarget::Exposed(rule.prefixed(ws_prefix))
298 }
299 FederationSourceTarget::Remote(alias) => FederationSourceTarget::Remote(alias.clone()),
300 };
301 Self {
302 target,
303 config_path: self.config_path.clone(),
304 plugin: self.plugin.clone(),
305 key: self.key,
306 }
307 }
308}
309
310#[must_use]
318pub fn federation_trace_provenance(
319 root: &Path,
320 files: &[crate::discover::DiscoveredFile],
321 sources: &[FederationSource],
322 modules: &[crate::extract::ModuleInfo],
323) -> fallow_types::trace::TraceProvenance {
324 let mut provenance = fallow_types::trace::TraceProvenance::default();
325 push_runtime_remote_sources(&mut provenance, root, files, modules);
326 if sources.is_empty() {
327 return provenance;
328 }
329 let mut exposed = Vec::new();
330 for source in sources {
331 let config = source
332 .config_path
333 .strip_prefix(root)
334 .unwrap_or(&source.config_path)
335 .to_path_buf();
336 let trace_source = fallow_types::trace::TraceSource {
337 kind: "module-federation".to_owned(),
338 plugin: source.plugin.clone(),
339 config,
340 key: source.key.to_owned(),
341 };
342 match &source.target {
343 FederationSourceTarget::Exposed(rule) => {
344 if let Some(compiled) =
345 CompiledPathRule::for_entry_rule(rule, "Module Federation exposes target")
346 {
347 exposed.push((compiled, trace_source));
348 }
349 }
350 FederationSourceTarget::Remote(alias) => {
351 provenance.push_dependency(alias.clone(), trace_source);
352 }
353 }
354 }
355 if exposed.is_empty() {
356 return provenance;
357 }
358 for file in files {
359 let Ok(relative) = file.path.strip_prefix(root) else {
360 continue;
361 };
362 let relative_str = relative.to_string_lossy().replace('\\', "/");
363 for (rule, source) in &exposed {
364 if rule.matches(&relative_str) {
365 provenance.push_file(relative.to_path_buf(), source.clone());
366 }
367 }
368 }
369 provenance
370}
371
372pub fn push_tooling_trace_credits(
376 provenance: &mut fallow_types::trace::TraceProvenance,
377 root: &Path,
378 workspaces: &[fallow_config::WorkspaceInfo],
379 plugin_result: &AggregatedPluginResult,
380) {
381 for entry in &plugin_result.plugin_tooling {
382 let Some(evidence) = entry.evidence(&plugin_result.script_used_packages) else {
383 continue;
384 };
385 let credit = match evidence {
386 PluginToolingEvidence::OwnConfig(config) => fallow_types::trace::ToolingCredit {
387 reason: "plugin-config".to_owned(),
388 plugin: Some(entry.plugin.clone()),
389 config: Some(config.strip_prefix(root).unwrap_or(config).to_path_buf()),
390 reference: None,
391 },
392 PluginToolingEvidence::Reference(name) => fallow_types::trace::ToolingCredit {
393 reason: "plugin-reference".to_owned(),
394 plugin: Some(entry.plugin.clone()),
395 config: None,
396 reference: Some(name.to_owned()),
397 },
398 };
399 for dependency in &entry.dependencies {
400 provenance.push_tooling_credit(dependency.clone(), credit.clone());
401 }
402 }
403 push_types_trace_credits(provenance, plugin_result);
404 push_catalogue_trace_credits(provenance, root, workspaces, plugin_result);
405}
406
407fn push_catalogue_trace_credits(
417 provenance: &mut fallow_types::trace::TraceProvenance,
418 root: &Path,
419 workspaces: &[fallow_config::WorkspaceInfo],
420 plugin_result: &AggregatedPluginResult,
421) {
422 let manifests = read_trace_manifests(root, workspaces);
423 let mut names: Vec<&String> = plugin_result.dev_dependency_names.iter().collect();
424 names.sort_unstable();
425 for name in names {
426 let declaring: Vec<&(PathBuf, serde_json::Value)> = manifests
427 .iter()
428 .filter(|(_, manifest)| manifest_lists(manifest, "devDependencies", name))
429 .collect();
430 if let Some(credit) = catalogue_credit(name, root, &declaring) {
431 provenance.push_tooling_credit(name.clone(), credit);
432 }
433 if let Some((path, _)) = declaring
434 .iter()
435 .find(|(_, manifest)| manifest_lists(manifest, "peerDependencies", name))
436 {
437 provenance.push_tooling_credit(
438 name.clone(),
439 fallow_types::trace::ToolingCredit {
440 reason: "own-peer".to_owned(),
441 plugin: None,
442 config: Some(path.strip_prefix(root).unwrap_or(path).to_path_buf()),
443 reference: None,
444 },
445 );
446 }
447 }
448}
449
450fn read_trace_manifests(
452 root: &Path,
453 workspaces: &[fallow_config::WorkspaceInfo],
454) -> Vec<(PathBuf, serde_json::Value)> {
455 std::iter::once(root)
456 .chain(
457 workspaces
458 .iter()
459 .map(|ws| ws.root.as_path())
460 .filter(|ws_root| *ws_root != root),
461 )
462 .filter_map(|package_root| {
463 let path = package_root.join("package.json");
464 let content = std::fs::read_to_string(&path).ok()?;
465 serde_json::from_str(&content)
466 .ok()
467 .map(|value| (path, value))
468 })
469 .collect()
470}
471
472fn manifest_lists(manifest: &serde_json::Value, section: &str, name: &str) -> bool {
473 manifest
474 .get(section)
475 .and_then(serde_json::Value::as_object)
476 .is_some_and(|deps| deps.contains_key(name))
477}
478
479fn catalogue_credit(
482 name: &str,
483 root: &Path,
484 declaring: &[&(PathBuf, serde_json::Value)],
485) -> Option<fallow_types::trace::ToolingCredit> {
486 if is_ambient_types_package(name) || types_package_target(name).is_some() {
487 return None;
488 }
489 let Some(patterns) = cli_tooling_config_patterns(name) else {
490 return is_known_tooling_dependency(name).then(|| fallow_types::trace::ToolingCredit {
491 reason: "known-tooling".to_owned(),
492 plugin: None,
493 config: None,
494 reference: None,
495 });
496 };
497 let config = declaring.iter().find_map(|(path, manifest)| {
498 crate::analyze::cli_tool_own_config(name, patterns, root, path, Some(manifest))
499 })?;
500 Some(fallow_types::trace::ToolingCredit {
501 reason: "known-tooling-config".to_owned(),
502 plugin: None,
503 config: Some(config.strip_prefix(root).unwrap_or(&config).to_path_buf()),
504 reference: None,
505 })
506}
507
508fn push_types_trace_credits(
513 provenance: &mut fallow_types::trace::TraceProvenance,
514 plugin_result: &AggregatedPluginResult,
515) {
516 let declared = plugin_result.dependency_binaries.declared_packages();
517 let mut names: Vec<&String> = plugin_result.dev_dependency_names.iter().collect();
518 names.sort_unstable();
519 for name in names {
520 let credit = if is_ambient_types_package(name) {
521 fallow_types::trace::ToolingCredit {
522 reason: "ambient-types".to_owned(),
523 plugin: None,
524 config: None,
525 reference: None,
526 }
527 } else if let Some(target) = types_package_target(name) {
528 if declared.contains(&target) {
529 fallow_types::trace::ToolingCredit {
530 reason: "types-target".to_owned(),
531 plugin: None,
532 config: None,
533 reference: Some(target),
534 }
535 } else if plugin_result.referenced_dependencies.contains(name) {
536 fallow_types::trace::ToolingCredit {
537 reason: "types-config".to_owned(),
538 plugin: None,
539 config: None,
540 reference: None,
541 }
542 } else {
543 continue;
544 }
545 } else {
546 continue;
547 };
548 provenance.push_tooling_credit(name.clone(), credit);
549 }
550}
551
552fn push_runtime_remote_sources(
554 provenance: &mut fallow_types::trace::TraceProvenance,
555 root: &Path,
556 files: &[crate::discover::DiscoveredFile],
557 modules: &[crate::extract::ModuleInfo],
558) {
559 for module in modules {
560 let mut file = None;
561 for fact in module.semantic_facts.iter() {
562 let fallow_types::extract::SemanticFact::FederationRuntimeRemote(fact) = fact else {
563 continue;
564 };
565 let Some(remote) = &fact.remote else {
566 continue;
567 };
568 let Some(path) = file.get_or_insert_with(|| {
569 files.get(module.file_id.0 as usize).map(|file| {
570 file.path
571 .strip_prefix(root)
572 .unwrap_or(&file.path)
573 .to_path_buf()
574 })
575 }) else {
576 break;
577 };
578 provenance.push_dependency(
579 remote.clone(),
580 fallow_types::trace::TraceSource {
581 kind: "module-federation".to_owned(),
582 plugin: "module-federation".to_owned(),
583 config: path.clone(),
584 key: fact.call.name().to_owned(),
585 },
586 );
587 }
588 }
589}
590
591impl PluginResult {
592 fn extend_config_dir_entry_patterns(
597 &mut self,
598 values: Vec<String>,
599 config_path: &Path,
600 root: &Path,
601 ) {
602 let (negated, positive): (Vec<String>, Vec<String>) =
603 values.into_iter().partition(|value| value.starts_with('!'));
604 let excluded: Vec<String> = negated
605 .iter()
606 .filter_map(|value| value.strip_prefix('!'))
607 .filter_map(|value| config_parser::normalize_config_path(value, config_path, root))
608 .collect();
609 self.entry_patterns.extend(
610 positive
611 .iter()
612 .filter_map(|value| config_parser::normalize_config_path(value, config_path, root))
613 .map(|pattern| {
614 PathRule::new(pattern).with_excluded_globs(excluded.iter().cloned())
615 }),
616 );
617 }
618
619 fn push_parent_relative_entry_pattern(&mut self, pattern: String) {
622 let mut rule = PathRule::new(pattern);
623 rule.parent_relative = true;
624 self.entry_patterns.push(rule);
625 }
626
627 fn push_entry_pattern(&mut self, pattern: impl Into<String>) {
628 self.entry_patterns
629 .push(PathRule::new(normalize_entry_pattern(pattern.into())));
630 }
631
632 fn extend_entry_patterns<I, S>(&mut self, patterns: I)
633 where
634 I: IntoIterator<Item = S>,
635 S: Into<String>,
636 {
637 self.entry_patterns.extend(
638 patterns
639 .into_iter()
640 .map(|pat| PathRule::new(normalize_entry_pattern(pat.into()))),
641 );
642 }
643
644 fn extend_entry_patterns_or_dependencies<I, S>(
659 &mut self,
660 values: I,
661 resolve_path: impl Fn(String) -> String,
662 ) where
663 I: IntoIterator<Item = S>,
664 S: Into<String>,
665 {
666 for value in values {
667 let value = value.into();
668 if let Some(request) = module_request(&value) {
669 self.referenced_dependencies
670 .push(crate::resolve::extract_package_name(request));
671 continue;
672 }
673 self.push_entry_path(resolve_path(value));
674 }
675 }
676
677 fn extend_entry_patterns_and_dependencies<I, S>(&mut self, values: I, root: &Path)
689 where
690 I: IntoIterator<Item = S>,
691 S: Into<String>,
692 {
693 for value in values {
694 let value = value.into();
695 if let Some(request) = module_request(&value)
696 && !names_project_file(root, request)
697 {
698 self.referenced_dependencies
699 .push(crate::resolve::extract_package_name(request));
700 }
701 self.push_entry_path(value);
702 }
703 }
704
705 fn extend_entry_paths<I, S>(&mut self, values: I)
707 where
708 I: IntoIterator<Item = S>,
709 S: Into<String>,
710 {
711 for value in values {
712 self.push_entry_path(value.into());
713 }
714 }
715
716 fn push_entry_path(&mut self, value: String) {
724 if has_glob_syntax(&value) || has_source_extension(&value) {
725 self.push_entry_pattern(value);
726 return;
727 }
728 let base = value.trim_end_matches('/').to_owned();
729 self.push_entry_pattern(value);
730 self.push_entry_pattern(format!("{base}.{REQUEST_EXTENSIONS}"));
731 self.push_entry_pattern(format!("{base}/index.{REQUEST_EXTENSIONS}"));
732 }
733
734 fn push_used_export_rule(
735 &mut self,
736 pattern: impl Into<String>,
737 exports: impl IntoIterator<Item = impl Into<String>>,
738 ) {
739 self.used_exports
740 .push(UsedExportRule::new(pattern, exports));
741 }
742
743 #[must_use]
750 const fn is_empty(&self) -> bool {
751 self.config_diagnostics.is_empty()
752 && self.entry_patterns.is_empty()
753 && self.used_exports.is_empty()
754 && self.used_class_members.is_empty()
755 && self.referenced_dependencies.is_empty()
756 && self.package_referenced_dependencies.is_empty()
757 && self.always_used_files.is_empty()
758 && self.path_aliases.is_empty()
759 && self.setup_files.is_empty()
760 && self.fixture_patterns.is_empty()
761 && self.scss_include_paths.is_empty()
762 && self.static_dir_mappings.is_empty()
763 && self.framework_static_dir_mappings.is_empty()
764 && self.provided_dependencies.is_empty()
765 && self.federation_sources.is_empty()
766 && self.jsx_import_sources.is_empty()
767 }
768}
769
770fn names_project_file(root: &Path, value: &str) -> bool {
774 let base = root.join(value);
775 base.is_file()
776 || crate::discover::SOURCE_EXTENSIONS.iter().any(|extension| {
777 let mut candidate = base.clone().into_os_string();
778 candidate.push(".");
779 candidate.push(extension);
780 Path::new(&candidate).is_file() || base.join(format!("index.{extension}")).is_file()
781 })
782}
783
784const REQUEST_EXTENSIONS: &str = "{ts,tsx,mts,cts,gts,js,jsx,mjs,cjs,gjs,vue,svelte,astro,mdx}";
788
789fn normalize_entry_pattern(pattern: String) -> String {
790 pattern
791 .strip_prefix("./")
792 .map(str::to_owned)
793 .unwrap_or(pattern)
794}
795
796fn module_request(value: &str) -> Option<&str> {
807 let request = strip_resource_query(value);
808 (config_parser::is_package_specifier(request)
809 && !has_glob_syntax(request)
810 && !has_source_extension(request))
811 .then_some(request)
812}
813
814fn strip_resource_query(value: &str) -> &str {
822 match value.split_once('?') {
823 Some((request, query)) if is_resource_query(query) => request,
824 _ => value,
825 }
826}
827
828fn is_resource_query(query: &str) -> bool {
831 !query.is_empty()
832 && query.split('&').all(|pair| {
833 let key = pair.split_once('=').map_or(pair, |(key, _)| key);
834 key.starts_with(|first: char| first.is_ascii_alphanumeric() || first == '_')
835 && key
836 .chars()
837 .all(|char| char.is_ascii_alphanumeric() || matches!(char, '_' | '-' | '.'))
838 })
839}
840
841fn has_glob_syntax(value: &str) -> bool {
844 value.contains('*') || value.contains('?') || value.contains('[') || value.contains('{')
845}
846
847fn has_source_extension(value: &str) -> bool {
851 Path::new(value)
852 .extension()
853 .and_then(|ext| ext.to_str())
854 .is_some_and(|ext| {
855 crate::discover::SOURCE_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str())
856 })
857}
858
859#[derive(Debug, Clone, Default, PartialEq, Eq)]
865pub struct PathRule {
866 pub pattern: String,
867 pub exclude_globs: Vec<String>,
868 pub exclude_regexes: Vec<String>,
869 pub exclude_segment_regexes: Vec<String>,
873 pub parent_relative: bool,
880}
881
882impl PathRule {
883 #[must_use]
884 pub(crate) fn new(pattern: impl Into<String>) -> Self {
885 Self {
886 pattern: pattern.into(),
887 exclude_globs: Vec::new(),
888 exclude_regexes: Vec::new(),
889 exclude_segment_regexes: Vec::new(),
890 parent_relative: false,
891 }
892 }
893
894 #[must_use]
895 fn from_static(pattern: &'static str) -> Self {
896 Self::new(pattern)
897 }
898
899 #[must_use]
900 pub(crate) fn with_excluded_globs<I, S>(mut self, patterns: I) -> Self
901 where
902 I: IntoIterator<Item = S>,
903 S: Into<String>,
904 {
905 self.exclude_globs
906 .extend(patterns.into_iter().map(Into::into));
907 self
908 }
909
910 #[must_use]
911 fn with_excluded_regexes<I, S>(mut self, patterns: I) -> Self
912 where
913 I: IntoIterator<Item = S>,
914 S: Into<String>,
915 {
916 self.exclude_regexes
917 .extend(patterns.into_iter().map(Into::into));
918 self
919 }
920
921 #[must_use]
922 fn with_excluded_segment_regexes<I, S>(mut self, patterns: I) -> Self
923 where
924 I: IntoIterator<Item = S>,
925 S: Into<String>,
926 {
927 self.exclude_segment_regexes
928 .extend(patterns.into_iter().map(Into::into));
929 self
930 }
931
932 #[must_use]
933 fn prefixed(&self, ws_prefix: &str) -> Self {
934 let pattern = if self.parent_relative && self.pattern.starts_with("../") {
935 resolve_parent_relative_pattern(&self.pattern, ws_prefix)
936 } else {
937 prefix_workspace_pattern(&self.pattern, ws_prefix)
938 };
939 Self {
940 pattern,
941 exclude_globs: self
942 .exclude_globs
943 .iter()
944 .map(|pattern| prefix_workspace_pattern(pattern, ws_prefix))
945 .collect(),
946 exclude_regexes: self
947 .exclude_regexes
948 .iter()
949 .map(|pattern| prefix_workspace_regex(pattern, ws_prefix))
950 .collect(),
951 exclude_segment_regexes: self.exclude_segment_regexes.clone(),
952 parent_relative: false,
953 }
954 }
955}
956
957#[derive(Debug, Clone, Default, PartialEq, Eq)]
959pub struct UsedExportRule {
960 pub(crate) path: PathRule,
961 pub(crate) exports: Vec<String>,
962}
963
964impl UsedExportRule {
965 #[must_use]
966 pub(crate) fn new(
967 pattern: impl Into<String>,
968 exports: impl IntoIterator<Item = impl Into<String>>,
969 ) -> Self {
970 Self {
971 path: PathRule::new(pattern),
972 exports: exports.into_iter().map(Into::into).collect(),
973 }
974 }
975
976 #[must_use]
977 fn from_static(pattern: &'static str, exports: &'static [&'static str]) -> Self {
978 Self::new(pattern, exports.iter().copied())
979 }
980
981 #[must_use]
982 fn with_excluded_globs<I, S>(mut self, patterns: I) -> Self
983 where
984 I: IntoIterator<Item = S>,
985 S: Into<String>,
986 {
987 self.path = self.path.with_excluded_globs(patterns);
988 self
989 }
990
991 #[must_use]
992 fn with_excluded_regexes<I, S>(mut self, patterns: I) -> Self
993 where
994 I: IntoIterator<Item = S>,
995 S: Into<String>,
996 {
997 self.path = self.path.with_excluded_regexes(patterns);
998 self
999 }
1000
1001 #[must_use]
1002 fn with_excluded_segment_regexes<I, S>(mut self, patterns: I) -> Self
1003 where
1004 I: IntoIterator<Item = S>,
1005 S: Into<String>,
1006 {
1007 self.path = self.path.with_excluded_segment_regexes(patterns);
1008 self
1009 }
1010
1011 #[must_use]
1012 fn prefixed(&self, ws_prefix: &str) -> Self {
1013 Self {
1014 path: self.path.prefixed(ws_prefix),
1015 exports: self.exports.clone(),
1016 }
1017 }
1018}
1019
1020#[derive(Debug, Clone, PartialEq, Eq)]
1022pub struct PluginUsedExportRule {
1023 pub(crate) plugin_name: String,
1024 pub(crate) rule: UsedExportRule,
1025}
1026
1027impl PluginUsedExportRule {
1028 #[must_use]
1029 pub(crate) fn new(plugin_name: impl Into<String>, rule: UsedExportRule) -> Self {
1030 Self {
1031 plugin_name: plugin_name.into(),
1032 rule,
1033 }
1034 }
1035
1036 #[must_use]
1037 fn prefixed(&self, ws_prefix: &str) -> Self {
1038 Self {
1039 plugin_name: self.plugin_name.clone(),
1040 rule: self.rule.prefixed(ws_prefix),
1041 }
1042 }
1043}
1044
1045#[derive(Debug, Clone, Default, PartialEq, Eq)]
1047pub struct ProvidedDependencyRule {
1048 pub(crate) path: PathRule,
1049 exact_specifiers: Vec<String>,
1050 specifier_prefixes: Vec<String>,
1051}
1052
1053impl ProvidedDependencyRule {
1054 #[must_use]
1055 fn new(
1056 pattern: impl Into<String>,
1057 exact_specifiers: impl IntoIterator<Item = impl Into<String>>,
1058 specifier_prefixes: impl IntoIterator<Item = impl Into<String>>,
1059 ) -> Self {
1060 Self {
1061 path: PathRule::new(pattern),
1062 exact_specifiers: exact_specifiers.into_iter().map(Into::into).collect(),
1063 specifier_prefixes: specifier_prefixes.into_iter().map(Into::into).collect(),
1064 }
1065 }
1066
1067 #[must_use]
1068 fn prefixed(&self, ws_prefix: &str) -> Self {
1069 Self {
1070 path: self.path.prefixed(ws_prefix),
1071 exact_specifiers: self.exact_specifiers.clone(),
1072 specifier_prefixes: self.specifier_prefixes.clone(),
1073 }
1074 }
1075
1076 #[must_use]
1077 pub(crate) fn may_cover_package(&self, package_name: &str) -> bool {
1078 self.exact_specifiers
1079 .iter()
1080 .chain(self.specifier_prefixes.iter())
1081 .any(|specifier| crate::resolve::extract_package_name(specifier) == package_name)
1082 }
1083
1084 #[must_use]
1085 pub(crate) fn covers_specifier(&self, specifier: &str) -> bool {
1086 self.exact_specifiers
1087 .iter()
1088 .any(|allowed| allowed == specifier)
1089 || self
1090 .specifier_prefixes
1091 .iter()
1092 .any(|prefix| specifier.starts_with(prefix))
1093 }
1094}
1095
1096#[derive(Debug, Clone)]
1098pub(crate) struct CompiledPathRule {
1099 include: globset::GlobMatcher,
1100 exclude_globs: Vec<globset::GlobMatcher>,
1101 exclude_regexes: Vec<Regex>,
1102 exclude_segment_regexes: Vec<Regex>,
1103}
1104
1105impl CompiledPathRule {
1106 pub(crate) fn for_entry_rule(rule: &PathRule, rule_kind: &str) -> Option<Self> {
1107 let include = match globset::GlobBuilder::new(&rule.pattern)
1108 .literal_separator(true)
1109 .build()
1110 {
1111 Ok(glob) => glob.compile_matcher(),
1112 Err(err) => {
1113 tracing::warn!("invalid {rule_kind} '{}': {err}", rule.pattern);
1114 return None;
1115 }
1116 };
1117 Some(Self {
1118 include,
1119 exclude_globs: compile_excluded_globs(&rule.exclude_globs, rule_kind, &rule.pattern),
1120 exclude_regexes: compile_excluded_regexes(
1121 &rule.exclude_regexes,
1122 rule_kind,
1123 &rule.pattern,
1124 ),
1125 exclude_segment_regexes: compile_excluded_segment_regexes(
1126 &rule.exclude_segment_regexes,
1127 rule_kind,
1128 &rule.pattern,
1129 ),
1130 })
1131 }
1132
1133 pub(crate) fn for_used_export_rule(rule: &PathRule, rule_kind: &str) -> Option<Self> {
1134 let include = match globset::Glob::new(&rule.pattern) {
1135 Ok(glob) => glob.compile_matcher(),
1136 Err(err) => {
1137 tracing::warn!("invalid {rule_kind} '{}': {err}", rule.pattern);
1138 return None;
1139 }
1140 };
1141 Some(Self {
1142 include,
1143 exclude_globs: compile_excluded_globs(&rule.exclude_globs, rule_kind, &rule.pattern),
1144 exclude_regexes: compile_excluded_regexes(
1145 &rule.exclude_regexes,
1146 rule_kind,
1147 &rule.pattern,
1148 ),
1149 exclude_segment_regexes: compile_excluded_segment_regexes(
1150 &rule.exclude_segment_regexes,
1151 rule_kind,
1152 &rule.pattern,
1153 ),
1154 })
1155 }
1156
1157 #[must_use]
1158 pub(crate) fn matches(&self, path: &str) -> bool {
1159 self.include.is_match(path)
1160 && !self.exclude_globs.iter().any(|glob| glob.is_match(path))
1161 && !self
1162 .exclude_regexes
1163 .iter()
1164 .any(|regex| regex.is_match(path))
1165 && !matches_segment_regex(path, &self.exclude_segment_regexes)
1166 }
1167}
1168
1169fn prefix_workspace_pattern(pattern: &str, ws_prefix: &str) -> String {
1170 if pattern.starts_with(ws_prefix) || pattern.starts_with('/') {
1171 pattern.to_string()
1172 } else {
1173 format!("{ws_prefix}/{pattern}")
1174 }
1175}
1176
1177fn resolve_parent_relative_pattern(pattern: &str, ws_prefix: &str) -> String {
1183 if ws_prefix.starts_with('/') || Path::new(ws_prefix).is_absolute() {
1184 return pattern.to_string();
1185 }
1186 let mut base: Vec<&str> = ws_prefix
1189 .split(['/', '\\'])
1190 .filter(|segment| !segment.is_empty())
1191 .collect();
1192 let mut rest = pattern;
1193 while let Some(stripped) = rest.strip_prefix("../") {
1194 if base.pop().is_none() {
1195 return pattern.to_string();
1196 }
1197 rest = stripped;
1198 }
1199 if base.is_empty() {
1200 rest.to_string()
1201 } else {
1202 format!("{}/{rest}", base.join("/"))
1203 }
1204}
1205
1206fn prefix_workspace_regex(pattern: &str, ws_prefix: &str) -> String {
1207 if let Some(pattern) = pattern.strip_prefix('^') {
1208 format!("^{}/{}", regex::escape(ws_prefix), pattern)
1209 } else {
1210 format!("^{}/(?:{})", regex::escape(ws_prefix), pattern)
1211 }
1212}
1213
1214fn compile_excluded_globs(
1215 patterns: &[String],
1216 rule_kind: &str,
1217 rule_pattern: &str,
1218) -> Vec<globset::GlobMatcher> {
1219 patterns
1220 .iter()
1221 .filter_map(|pattern| {
1222 match globset::GlobBuilder::new(pattern)
1223 .literal_separator(true)
1224 .build()
1225 {
1226 Ok(glob) => Some(glob.compile_matcher()),
1227 Err(err) => {
1228 tracing::warn!(
1229 "skipping invalid excluded glob '{}' for {} '{}': {err}",
1230 pattern,
1231 rule_kind,
1232 rule_pattern
1233 );
1234 None
1235 }
1236 }
1237 })
1238 .collect()
1239}
1240
1241fn compile_excluded_regexes(
1242 patterns: &[String],
1243 rule_kind: &str,
1244 rule_pattern: &str,
1245) -> Vec<Regex> {
1246 patterns
1247 .iter()
1248 .filter_map(|pattern| match Regex::new(pattern) {
1249 Ok(regex) => Some(regex),
1250 Err(err) => {
1251 tracing::warn!(
1252 "skipping invalid excluded regex '{}' for {} '{}': {err}",
1253 pattern,
1254 rule_kind,
1255 rule_pattern
1256 );
1257 None
1258 }
1259 })
1260 .collect()
1261}
1262
1263fn compile_excluded_segment_regexes(
1264 patterns: &[String],
1265 rule_kind: &str,
1266 rule_pattern: &str,
1267) -> Vec<Regex> {
1268 patterns
1269 .iter()
1270 .filter_map(|pattern| match Regex::new(pattern) {
1271 Ok(regex) => Some(regex),
1272 Err(err) => {
1273 tracing::warn!(
1274 "skipping invalid excluded segment regex '{}' for {} '{}': {err}",
1275 pattern,
1276 rule_kind,
1277 rule_pattern
1278 );
1279 None
1280 }
1281 })
1282 .collect()
1283}
1284
1285fn matches_segment_regex(path: &str, regexes: &[Regex]) -> bool {
1288 path.split(std::path::is_separator)
1289 .any(|segment| regexes.iter().any(|regex| regex.is_match(segment)))
1290}
1291
1292impl From<String> for PathRule {
1293 fn from(pattern: String) -> Self {
1294 Self::new(pattern)
1295 }
1296}
1297
1298impl From<&str> for PathRule {
1299 fn from(pattern: &str) -> Self {
1300 Self::new(pattern)
1301 }
1302}
1303
1304impl std::ops::Deref for PathRule {
1305 type Target = str;
1306
1307 fn deref(&self) -> &Self::Target {
1308 &self.pattern
1309 }
1310}
1311
1312impl PartialEq<&str> for PathRule {
1313 fn eq(&self, other: &&str) -> bool {
1314 self.pattern == *other
1315 }
1316}
1317
1318impl PartialEq<str> for PathRule {
1319 fn eq(&self, other: &str) -> bool {
1320 self.pattern == other
1321 }
1322}
1323
1324impl PartialEq<String> for PathRule {
1325 fn eq(&self, other: &String) -> bool {
1326 &self.pattern == other
1327 }
1328}
1329
1330pub trait Plugin: Send + Sync {
1332 fn name(&self) -> &'static str;
1334
1335 fn enablers(&self) -> &'static [&'static str] {
1338 &[]
1339 }
1340
1341 fn is_enabled(&self, pkg: &PackageJson, root: &Path) -> bool {
1344 let deps = pkg.all_dependency_names();
1345 self.is_enabled_with_deps(&deps, root)
1346 }
1347
1348 fn is_enabled_with_deps(&self, deps: &[String], _root: &Path) -> bool {
1351 let enablers = self.enablers();
1352 if enablers.is_empty() {
1353 return false;
1354 }
1355 enablers.iter().any(|enabler| {
1356 if enabler.ends_with('/') {
1357 deps.iter().any(|d| d.starts_with(enabler))
1359 } else {
1360 deps.iter().any(|d| d == enabler)
1361 }
1362 })
1363 }
1364
1365 fn is_enabled_with_files(
1378 &self,
1379 deps: &[String],
1380 root: &Path,
1381 _discovered_files: &[PathBuf],
1382 _candidate_index: Option<®istry::ConfigCandidateIndex>,
1383 ) -> bool {
1384 self.is_enabled_with_deps(deps, root)
1385 }
1386
1387 fn script_enablers(&self) -> &'static [&'static str] {
1389 &[]
1390 }
1391
1392 fn is_enabled_with_scripts(
1394 &self,
1395 script_packages: &rustc_hash::FxHashSet<String>,
1396 _root: &Path,
1397 ) -> bool {
1398 let enablers = self.script_enablers();
1399 if enablers.is_empty() {
1400 return false;
1401 }
1402 enablers.iter().any(|enabler| {
1403 if enabler.ends_with('/') {
1404 script_packages
1405 .iter()
1406 .any(|package| package.starts_with(enabler))
1407 } else {
1408 script_packages.contains(*enabler)
1409 }
1410 })
1411 }
1412
1413 fn entry_patterns(&self) -> &'static [&'static str] {
1415 &[]
1416 }
1417
1418 fn entry_pattern_rules(&self) -> Vec<PathRule> {
1420 self.entry_patterns()
1421 .iter()
1422 .map(|pattern| PathRule::from_static(pattern))
1423 .collect()
1424 }
1425
1426 fn entry_point_role(&self) -> EntryPointRole {
1431 builtin_entry_point_role(self.name())
1432 }
1433
1434 fn config_patterns(&self) -> &'static [&'static str] {
1436 &[]
1437 }
1438
1439 fn always_used(&self) -> &'static [&'static str] {
1441 &[]
1442 }
1443
1444 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
1446 vec![]
1447 }
1448
1449 fn used_export_rules(&self) -> Vec<UsedExportRule> {
1451 self.used_exports()
1452 .into_iter()
1453 .map(|(pattern, exports)| UsedExportRule::from_static(pattern, exports))
1454 .collect()
1455 }
1456
1457 fn used_class_members(&self) -> &'static [&'static str] {
1462 &[]
1463 }
1464
1465 fn used_class_member_rules(&self) -> Vec<UsedClassMemberRule> {
1473 Vec::new()
1474 }
1475
1476 fn framework_class_member_contracts(&self) -> Vec<SemanticFrameworkContract> {
1479 Vec::new()
1480 }
1481
1482 fn fixture_glob_patterns(&self) -> &'static [&'static str] {
1487 &[]
1488 }
1489
1490 fn discovery_hidden_dirs(&self) -> &'static [&'static str] {
1495 &[]
1496 }
1497
1498 fn tooling_dependencies(&self) -> &'static [&'static str] {
1501 &[]
1502 }
1503
1504 fn virtual_module_prefixes(&self) -> &'static [&'static str] {
1509 &[]
1510 }
1511
1512 fn virtual_package_suffixes(&self) -> &'static [&'static str] {
1518 &[]
1519 }
1520
1521 fn generated_import_patterns(&self) -> &'static [&'static str] {
1527 &[]
1528 }
1529
1530 fn generated_type_import_prefixes(&self) -> &'static [&'static str] {
1535 &[]
1536 }
1537
1538 fn path_aliases(&self, _root: &Path) -> Vec<(&'static str, String)> {
1548 vec![]
1549 }
1550
1551 fn static_dir_mappings(&self, _root: &Path) -> Vec<(std::path::PathBuf, String)> {
1563 vec![]
1564 }
1565
1566 fn auto_imports(&self, _root: &Path) -> Vec<AutoImportRule> {
1584 Vec::new()
1585 }
1586
1587 fn provided_dependencies(&self) -> Vec<ProvidedDependencyRule> {
1589 Vec::new()
1590 }
1591
1592 fn is_enabled_with_package_json(&self, _pkg: &PackageJson, _root: &Path) -> bool {
1594 false
1595 }
1596
1597 fn resolve_package_json(&self, _pkg: &PackageJson, _root: &Path) -> PluginResult {
1599 PluginResult::default()
1600 }
1601
1602 fn package_json_referenced_dependencies(
1607 &self,
1608 _pkg: &PackageJson,
1609 _root: &Path,
1610 ) -> Vec<String> {
1611 Vec::new()
1612 }
1613
1614 fn resolve_config(&self, _config_path: &Path, _source: &str, _root: &Path) -> PluginResult {
1619 PluginResult::default()
1620 }
1621
1622 fn script_config_binaries(&self) -> &'static [&'static str] {
1630 &[]
1631 }
1632
1633 fn package_json_config_key(&self) -> Option<&'static str> {
1638 None
1639 }
1640}
1641
1642fn builtin_entry_point_role(name: &str) -> EntryPointRole {
1643 if TEST_ENTRY_POINT_PLUGINS.contains(&name) {
1644 EntryPointRole::Test
1645 } else if RUNTIME_ENTRY_POINT_PLUGINS.contains(&name) {
1646 EntryPointRole::Runtime
1647 } else {
1648 EntryPointRole::Support
1649 }
1650}
1651
1652macro_rules! define_plugin {
1713 (
1714 struct $name:ident => $display:expr,
1715 enablers: $enablers:expr
1716 $(, entry_patterns: $entry:expr)?
1717 $(, config_patterns: $config:expr)?
1718 $(, always_used: $always:expr)?
1719 $(, tooling_dependencies: $tooling:expr)?
1720 $(, fixture_glob_patterns: $fixtures:expr)?
1721 $(, discovery_hidden_dirs: $hidden_dirs:expr)?
1722 $(, virtual_module_prefixes: $virtual:expr)?
1723 $(, virtual_package_suffixes: $virtual_suffixes:expr)?
1724 $(, generated_type_import_prefixes: $generated_type_prefixes:expr)?
1725 $(, provided_dependencies: $provided_dependencies:expr)?
1726 $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
1727 , resolve_config: imports_only
1728 $(,)?
1729 ) => {
1730 pub struct $name;
1731
1732 impl Plugin for $name {
1733 fn name(&self) -> &'static str {
1734 $display
1735 }
1736
1737 fn enablers(&self) -> &'static [&'static str] {
1738 $enablers
1739 }
1740
1741 $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
1742 $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
1743 $( fn always_used(&self) -> &'static [&'static str] { $always } )?
1744 $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
1745 $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
1746 $( fn discovery_hidden_dirs(&self) -> &'static [&'static str] { $hidden_dirs } )?
1747 $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
1748 $( fn virtual_package_suffixes(&self) -> &'static [&'static str] { $virtual_suffixes } )?
1749 $( fn generated_type_import_prefixes(&self) -> &'static [&'static str] { $generated_type_prefixes } )?
1750 $( fn provided_dependencies(&self) -> Vec<ProvidedDependencyRule> { $provided_dependencies } )?
1751
1752 $(
1753 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
1754 vec![$( ($pat, $exports) ),*]
1755 }
1756 )?
1757
1758 fn resolve_config(
1759 &self,
1760 config_path: &std::path::Path,
1761 source: &str,
1762 _root: &std::path::Path,
1763 ) -> PluginResult {
1764 let mut result = PluginResult::default();
1765 crate::plugins::add_import_referenced_dependencies(
1766 &mut result,
1767 source,
1768 config_path,
1769 );
1770 result
1771 }
1772 }
1773 };
1774
1775 (
1776 struct $name:ident => $display:expr,
1777 enablers: $enablers:expr
1778 $(, entry_patterns: $entry:expr)?
1779 $(, config_patterns: $config:expr)?
1780 $(, always_used: $always:expr)?
1781 $(, tooling_dependencies: $tooling:expr)?
1782 $(, fixture_glob_patterns: $fixtures:expr)?
1783 $(, discovery_hidden_dirs: $hidden_dirs:expr)?
1784 $(, virtual_module_prefixes: $virtual:expr)?
1785 $(, virtual_package_suffixes: $virtual_suffixes:expr)?
1786 $(, generated_type_import_prefixes: $generated_type_prefixes:expr)?
1787 $(, provided_dependencies: $provided_dependencies:expr)?
1788 $(, package_json_config_key: $pkg_key:expr)?
1789 $(, script_config_binaries: $script_binaries:expr)?
1790 $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
1791 , resolve_config($cp:ident, $src:ident, $root:ident) $body:block
1792 $(,)?
1793 ) => {
1794 pub struct $name;
1795
1796 impl Plugin for $name {
1797 fn name(&self) -> &'static str {
1798 $display
1799 }
1800
1801 fn enablers(&self) -> &'static [&'static str] {
1802 $enablers
1803 }
1804
1805 $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
1806 $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
1807 $( fn always_used(&self) -> &'static [&'static str] { $always } )?
1808 $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
1809 $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
1810 $( fn discovery_hidden_dirs(&self) -> &'static [&'static str] { $hidden_dirs } )?
1811 $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
1812 $( fn virtual_package_suffixes(&self) -> &'static [&'static str] { $virtual_suffixes } )?
1813 $( fn generated_type_import_prefixes(&self) -> &'static [&'static str] { $generated_type_prefixes } )?
1814 $( fn provided_dependencies(&self) -> Vec<ProvidedDependencyRule> { $provided_dependencies } )?
1815
1816 $(
1817 fn package_json_config_key(&self) -> Option<&'static str> {
1818 Some($pkg_key)
1819 }
1820 )?
1821
1822 $(
1823 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
1824 vec![$( ($pat, $exports) ),*]
1825 }
1826 )?
1827
1828 $( fn script_config_binaries(&self) -> &'static [&'static str] { $script_binaries } )?
1829
1830 fn resolve_config(
1831 &self,
1832 $cp: &std::path::Path,
1833 $src: &str,
1834 $root: &std::path::Path,
1835 ) -> PluginResult
1836 $body
1837 }
1838 };
1839
1840 (
1841 struct $name:ident => $display:expr,
1842 enablers: $enablers:expr
1843 $(, entry_patterns: $entry:expr)?
1844 $(, config_patterns: $config:expr)?
1845 $(, always_used: $always:expr)?
1846 $(, tooling_dependencies: $tooling:expr)?
1847 $(, fixture_glob_patterns: $fixtures:expr)?
1848 $(, discovery_hidden_dirs: $hidden_dirs:expr)?
1849 $(, virtual_module_prefixes: $virtual:expr)?
1850 $(, virtual_package_suffixes: $virtual_suffixes:expr)?
1851 $(, generated_type_import_prefixes: $generated_type_prefixes:expr)?
1852 $(, provided_dependencies: $provided_dependencies:expr)?
1853 $(, package_json_config_key: $pkg_key:expr)?
1854 $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
1855 $(,)?
1856 ) => {
1857 pub struct $name;
1858
1859 impl Plugin for $name {
1860 fn name(&self) -> &'static str {
1861 $display
1862 }
1863
1864 fn enablers(&self) -> &'static [&'static str] {
1865 $enablers
1866 }
1867
1868 $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
1869 $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
1870 $( fn always_used(&self) -> &'static [&'static str] { $always } )?
1871 $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
1872 $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
1873 $( fn discovery_hidden_dirs(&self) -> &'static [&'static str] { $hidden_dirs } )?
1874 $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
1875 $( fn virtual_package_suffixes(&self) -> &'static [&'static str] { $virtual_suffixes } )?
1876 $( fn generated_type_import_prefixes(&self) -> &'static [&'static str] { $generated_type_prefixes } )?
1877 $( fn provided_dependencies(&self) -> Vec<ProvidedDependencyRule> { $provided_dependencies } )?
1878
1879 $(
1880 fn package_json_config_key(&self) -> Option<&'static str> {
1881 Some($pkg_key)
1882 }
1883 )?
1884
1885 $(
1886 fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
1887 vec![$( ($pat, $exports) ),*]
1888 }
1889 )?
1890 }
1891 };
1892}
1893
1894pub mod config_parser;
1895mod config_value_credits;
1896mod manifest;
1897pub mod manifest_entries;
1898pub mod registry;
1899mod tooling;
1900
1901pub(crate) use module_federation::runtime_remotes;
1902pub use registry::{
1903 AggregatedPluginResult, PluginRegistry, PluginToolingDependencies, PluginToolingEvidence,
1904};
1905pub use tooling::types_package_target;
1906pub(crate) use tooling::{
1907 cli_tooling_config_patterns, is_ambient_types_package, is_known_tooling_dependency,
1908};
1909
1910fn add_import_referenced_dependencies(result: &mut PluginResult, source: &str, config_path: &Path) {
1911 let imports = config_parser::extract_imports(source, config_path);
1912 for import in &imports {
1913 result
1914 .referenced_dependencies
1915 .push(crate::resolve::extract_package_name(import));
1916 }
1917}
1918
1919fn credit_environment_optional_peers(environment: &str, result: &mut PluginResult) {
1931 credit_config_value(
1932 config_value_credits::CreditSurface::TestEnvironmentOptionalPeer,
1933 canonical_test_environment(environment),
1934 result,
1935 );
1936}
1937
1938fn credit_config_value(
1943 surface: config_value_credits::CreditSurface,
1944 value: &str,
1945 result: &mut PluginResult,
1946) -> bool {
1947 let Some(packages) = config_value_credits::credited_packages(surface, value) else {
1948 return false;
1949 };
1950 result
1951 .referenced_dependencies
1952 .extend(packages.iter().cloned());
1953 true
1954}
1955
1956fn canonical_test_environment(environment: &str) -> &str {
1964 environment
1965 .strip_prefix("jest-environment-")
1966 .or_else(|| environment.strip_prefix("vitest-environment-"))
1967 .unwrap_or(environment)
1968}
1969
1970mod adonis;
1971mod ag_ui;
1972mod angular;
1973mod astro;
1974mod ava;
1975mod babel;
1976mod biome;
1977mod browser_extension;
1978mod bun;
1979mod c8;
1980mod capacitor;
1981mod changesets;
1982mod commit_and_tag_version;
1983mod commitizen;
1984mod commitlint;
1985mod content_collections;
1986mod contentlayer;
1987mod convex;
1988mod cspell;
1989mod cucumber;
1990mod cypress;
1991mod danger;
1992mod deno;
1993mod dependency_cruiser;
1994mod docusaurus;
1995mod drizzle;
1996mod electron;
1997mod ember;
1998mod eslint;
1999mod eve;
2000mod expo;
2001mod expo_router;
2002mod firebase;
2003mod fumadocs;
2004mod gatsby;
2005mod graphql_codegen;
2006mod hardhat;
2007mod husky;
2008mod i18next;
2009mod ionic;
2010mod jest;
2011mod k6;
2012mod karma;
2013mod kibana;
2014mod knex;
2015mod kysely;
2016mod lefthook;
2017mod lexical;
2018mod lint_staged;
2019mod lit;
2020mod markdownlint;
2021mod mintlify;
2022mod mocha;
2023mod module_federation;
2024mod msw;
2025mod napi_rs;
2026mod nest_commander;
2027mod nestjs;
2028mod nestjs_trpc;
2029mod next_intl;
2030mod nextjs;
2031mod nitro;
2032mod nodemon;
2033pub(crate) mod nuxt;
2034mod nx;
2035mod nyc;
2036mod obsidian;
2037mod openapi_ts;
2038mod opencode;
2039mod opennext_cloudflare;
2040mod oxfmt;
2041mod oxlint;
2042mod pandacss;
2043mod parcel;
2044mod pinia;
2045mod pkg_utils;
2046mod playwright;
2047mod plop;
2048mod pm2;
2049mod pnpm;
2050mod postcss;
2051mod prettier;
2052mod prisma;
2053mod qwik;
2054mod react_compiler;
2055mod react_native;
2056mod react_router;
2057mod redwoodsdk;
2058mod relay;
2059mod release_it;
2060mod remark;
2061mod remix;
2062mod rolldown;
2063mod rollup;
2064mod rsbuild;
2065mod rspack;
2066mod rspress;
2067mod sanity;
2068mod semantic_release;
2069mod sentry;
2070mod simple_git_hooks;
2071mod size_limit;
2072mod storybook;
2073mod stryker;
2074mod stylelint;
2075mod supabase;
2076mod sveltekit;
2077mod svgo;
2078mod svgr;
2079mod swc;
2080mod syncpack;
2081mod tailwind;
2082mod tanstack_router;
2083mod tap;
2084mod test_alias;
2085mod tsd;
2086mod tsdown;
2087mod tsup;
2088mod turborepo;
2089mod typedoc;
2090mod typeorm;
2091mod typescript;
2092mod unocss;
2093mod varlock;
2094mod velite;
2095mod vercel;
2096mod vite;
2097mod vitepress;
2098mod vitest;
2099mod vscode;
2100mod waku;
2101mod webdriverio;
2102mod webpack;
2103mod wrangler;
2104mod wuchale;
2105mod wxt;
2106
2107#[cfg(test)]
2108mod tests {
2109 use super::*;
2110
2111 #[test]
2112 fn segment_regexes_split_on_the_native_separator() {
2113 let regexes = vec![Regex::new("^_(components|hooks)$").expect("valid regex")];
2114 assert!(matches_segment_regex(
2115 "src/pages/_components/a.tsx",
2116 ®exes
2117 ));
2118 assert!(!matches_segment_regex(
2119 "src/pages/components/a.tsx",
2120 ®exes
2121 ));
2122 assert_eq!(
2123 matches_segment_regex("src\\pages\\_components\\a.tsx", ®exes),
2124 cfg!(windows),
2125 "a backslash separates segments only where it is the native separator"
2126 );
2127 }
2128 use std::path::Path;
2129
2130 #[test]
2131 fn is_enabled_with_deps_exact_match() {
2132 let plugin = nextjs::NextJsPlugin;
2133 let deps = vec!["next".to_string()];
2134 assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
2135 }
2136
2137 #[test]
2138 fn is_enabled_with_deps_no_match() {
2139 let plugin = nextjs::NextJsPlugin;
2140 let deps = vec!["react".to_string()];
2141 assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
2142 }
2143
2144 #[test]
2145 fn is_enabled_with_deps_empty_deps() {
2146 let plugin = nextjs::NextJsPlugin;
2147 let deps: Vec<String> = vec![];
2148 assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
2149 }
2150
2151 #[test]
2152 fn environment_optional_peers_come_from_the_credit_catalogue() {
2153 for environment in [
2154 "jsdom",
2155 "jest-environment-jsdom",
2156 "vitest-environment-jsdom",
2157 ] {
2158 let mut result = PluginResult::default();
2159 credit_environment_optional_peers(environment, &mut result);
2160 assert_eq!(
2161 result.referenced_dependencies,
2162 vec!["canvas".to_string()],
2163 "expected the catalogue credit for {environment}"
2164 );
2165 }
2166 }
2167
2168 #[test]
2169 fn environment_without_a_catalogue_row_credits_nothing() {
2170 let mut result = PluginResult::default();
2171 credit_environment_optional_peers("happy-dom", &mut result);
2172 assert!(result.referenced_dependencies.is_empty());
2173 }
2174
2175 #[test]
2176 fn entry_point_role_defaults_are_centralized() {
2177 assert_eq!(vite::VitePlugin.entry_point_role(), EntryPointRole::Runtime);
2178 assert_eq!(
2179 vitest::VitestPlugin.entry_point_role(),
2180 EntryPointRole::Test
2181 );
2182 assert_eq!(
2183 storybook::StorybookPlugin.entry_point_role(),
2184 EntryPointRole::Support
2185 );
2186 assert_eq!(
2187 obsidian::ObsidianPlugin.entry_point_role(),
2188 EntryPointRole::Runtime
2189 );
2190 assert_eq!(knex::KnexPlugin.entry_point_role(), EntryPointRole::Support);
2191 }
2192
2193 #[test]
2194 fn plugins_with_entry_patterns_have_explicit_role_intent() {
2195 let runtime_or_test_or_support: rustc_hash::FxHashSet<&'static str> =
2196 TEST_ENTRY_POINT_PLUGINS
2197 .iter()
2198 .chain(RUNTIME_ENTRY_POINT_PLUGINS.iter())
2199 .chain(SUPPORT_ENTRY_POINT_PLUGINS.iter())
2200 .copied()
2201 .collect();
2202
2203 for plugin in crate::plugins::registry::builtin::create_builtin_plugins() {
2204 if plugin.entry_patterns().is_empty() {
2205 continue;
2206 }
2207 assert!(
2208 runtime_or_test_or_support.contains(plugin.name()),
2209 "plugin '{}' exposes entry patterns but is missing from the entry-point role map",
2210 plugin.name()
2211 );
2212 }
2213 }
2214
2215 #[test]
2218 fn plugin_result_is_empty_only_when_every_field_is_empty() {
2219 type Fill = fn(&mut PluginResult);
2220
2221 assert!(PluginResult::default().is_empty());
2222
2223 let rows: [(&str, Fill); 16] = [
2224 ("entry_patterns", |r| {
2225 r.entry_patterns.push(PathRule::new("src/*.ts"));
2226 }),
2227 ("used_exports", |r| {
2228 r.used_exports
2229 .push(UsedExportRule::new("src/*.ts", ["default"]));
2230 }),
2231 ("used_class_members", |r| {
2232 r.used_class_members
2233 .push(UsedClassMemberRule::from("render"));
2234 }),
2235 ("referenced_dependencies", |r| {
2236 r.referenced_dependencies.push("lodash".to_string());
2237 }),
2238 ("package_referenced_dependencies", |r| {
2239 r.package_referenced_dependencies
2240 .push((PathBuf::from("/project/pkg"), "lodash".to_string()));
2241 }),
2242 ("always_used_files", |r| {
2243 r.always_used_files.push("**/*.stories.tsx".to_string());
2244 }),
2245 ("path_aliases", |r| {
2246 r.path_aliases.push(("@".to_string(), "src".to_string()));
2247 }),
2248 ("setup_files", |r| {
2249 r.setup_files.push(PathBuf::from("/setup.ts"));
2250 }),
2251 ("fixture_patterns", |r| {
2252 r.fixture_patterns.push("**/__fixtures__/**/*".to_string());
2253 }),
2254 ("scss_include_paths", |r| {
2255 r.scss_include_paths.push(PathBuf::from("/project/styles"));
2256 }),
2257 ("static_dir_mappings", |r| {
2258 r.static_dir_mappings
2259 .push((PathBuf::from("/project/public"), "/".to_string()));
2260 }),
2261 ("framework_static_dir_mappings", |r| {
2262 r.framework_static_dir_mappings
2263 .push((PathBuf::from("/project/static"), "/".to_string()));
2264 }),
2265 ("provided_dependencies", |r| {
2266 r.provided_dependencies.push(ProvidedDependencyRule::new(
2267 "**/*.stories.tsx",
2268 ["react"],
2269 Vec::<String>::new(),
2270 ));
2271 }),
2272 ("config_diagnostics", |r| {
2273 r.config_diagnostics
2274 .push(PluginConfigDiagnostic::unreadable(
2275 Path::new("/project/webpack.config.js"),
2276 "webpack",
2277 "exposes",
2278 "dynamic-value",
2279 ));
2280 }),
2281 ("federation_sources", |r| {
2282 r.federation_sources.push(FederationSource {
2283 target: FederationSourceTarget::Remote("app".to_string()),
2284 config_path: PathBuf::from("/project/webpack.config.js"),
2285 plugin: "webpack".to_string(),
2286 key: "remotes",
2287 });
2288 }),
2289 ("jsx_import_sources", |r| {
2290 r.jsx_import_sources.push(JsxImportSourceRule {
2291 source: "./src/jsx".to_string(),
2292 config_dir: PathBuf::from("/project"),
2293 include: vec!["src/**/*.test.tsx".to_string()],
2294 });
2295 }),
2296 ];
2297
2298 for (field, fill) in rows {
2299 let mut result = PluginResult::default();
2300 fill(&mut result);
2301 assert!(
2302 !result.is_empty(),
2303 "a result with only {field} set must not be empty"
2304 );
2305 }
2306 }
2307
2308 #[test]
2309 fn is_enabled_with_deps_prefix_match() {
2310 let plugin = storybook::StorybookPlugin;
2311 let deps = vec!["@storybook/react".to_string()];
2312 assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
2313 }
2314
2315 #[test]
2316 fn is_enabled_with_deps_prefix_no_match_without_slash() {
2317 let plugin = storybook::StorybookPlugin;
2318 let deps = vec!["@storybookish".to_string()];
2319 assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
2320 }
2321
2322 #[test]
2323 fn is_enabled_with_deps_multiple_enablers() {
2324 let plugin = vitest::VitestPlugin;
2325 let deps_vitest = vec!["vitest".to_string()];
2326 let deps_none = vec!["mocha".to_string()];
2327 assert!(plugin.is_enabled_with_deps(&deps_vitest, Path::new("/project")));
2328 assert!(!plugin.is_enabled_with_deps(&deps_none, Path::new("/project")));
2329 }
2330
2331 #[test]
2332 fn plugin_resolve_config_default_returns_empty() {
2333 let plugin = commitizen::CommitizenPlugin;
2334 let result = plugin.resolve_config(
2335 Path::new("/project/config.js"),
2336 "const x = 1;",
2337 Path::new("/project"),
2338 );
2339 assert!(result.is_empty());
2340 }
2341
2342 #[test]
2343 fn is_enabled_with_deps_exact_and_prefix_both_work() {
2344 let plugin = storybook::StorybookPlugin;
2345 let deps_exact = vec!["storybook".to_string()];
2346 assert!(plugin.is_enabled_with_deps(&deps_exact, Path::new("/project")));
2347 let deps_prefix = vec!["@storybook/vue3".to_string()];
2348 assert!(plugin.is_enabled_with_deps(&deps_prefix, Path::new("/project")));
2349 }
2350
2351 #[test]
2352 fn is_enabled_with_deps_multiple_enablers_remix() {
2353 let plugin = remix::RemixPlugin;
2354 let deps_node = vec!["@remix-run/node".to_string()];
2355 assert!(plugin.is_enabled_with_deps(&deps_node, Path::new("/project")));
2356 let deps_react = vec!["@remix-run/react".to_string()];
2357 assert!(plugin.is_enabled_with_deps(&deps_react, Path::new("/project")));
2358 let deps_cf = vec!["@remix-run/cloudflare".to_string()];
2359 assert!(plugin.is_enabled_with_deps(&deps_cf, Path::new("/project")));
2360 }
2361
2362 struct MinimalPlugin;
2363 impl Plugin for MinimalPlugin {
2364 fn name(&self) -> &'static str {
2365 "minimal"
2366 }
2367 }
2368
2369 #[test]
2370 fn default_resolve_config_returns_empty() {
2371 let r = MinimalPlugin.resolve_config(
2372 Path::new("config.js"),
2373 "export default {}",
2374 Path::new("/"),
2375 );
2376 assert!(r.is_empty());
2377 }
2378
2379 #[test]
2380 fn default_package_json_metadata_hooks_are_empty() {
2381 let pkg = PackageJson::default();
2382 assert!(!MinimalPlugin.is_enabled_with_package_json(&pkg, Path::new("/")));
2383 assert!(
2384 MinimalPlugin
2385 .resolve_package_json(&pkg, Path::new("/"))
2386 .is_empty()
2387 );
2388 }
2389
2390 #[test]
2391 fn default_is_enabled_returns_false_when_no_enablers() {
2392 let deps = vec!["anything".to_string()];
2393 assert!(!MinimalPlugin.is_enabled_with_deps(&deps, Path::new("/")));
2394 }
2395
2396 #[test]
2397 fn all_builtin_plugin_names_are_non_empty_and_unique() {
2398 let plugins = registry::builtin::create_builtin_plugins();
2399 let mut seen = std::collections::BTreeSet::new();
2400 for p in &plugins {
2401 let name = p.name();
2402 assert!(
2403 !name.is_empty(),
2404 "builtin plugins must have a non-empty name"
2405 );
2406 assert!(seen.insert(name), "duplicate plugin name: {name}");
2407 }
2408 }
2409
2410 #[test]
2411 fn all_builtin_plugins_have_activation_signals() {
2412 const NON_DEPENDENCY_ACTIVATED_PLUGINS: &[&str] = &["napi-rs", "deno", "kibana"];
2416 let plugins = registry::builtin::create_builtin_plugins();
2417 for p in &plugins {
2418 assert!(
2419 !p.enablers().is_empty()
2420 || !p.script_enablers().is_empty()
2421 || NON_DEPENDENCY_ACTIVATED_PLUGINS.contains(&p.name()),
2422 "plugin '{}' has no activation signal",
2423 p.name()
2424 );
2425 }
2426 }
2427
2428 #[test]
2429 fn plugins_with_config_patterns_have_always_used() {
2430 let plugins = registry::builtin::create_builtin_plugins();
2431 for p in &plugins {
2432 if !p.config_patterns().is_empty() {
2433 assert!(
2434 !p.always_used().is_empty(),
2435 "plugin '{}' has config_patterns but no always_used",
2436 p.name()
2437 );
2438 }
2439 }
2440 }
2441
2442 #[test]
2443 fn framework_plugins_enablers() {
2444 let cases: Vec<(&dyn Plugin, &[&str])> = vec![
2445 (&nextjs::NextJsPlugin, &["next"]),
2446 (&nuxt::NuxtPlugin, &["nuxt"]),
2447 (&angular::AngularPlugin, &["@angular/core"]),
2448 (&ionic::IonicPlugin, &["@ionic/angular"]),
2449 (&sveltekit::SvelteKitPlugin, &["@sveltejs/kit"]),
2450 (&gatsby::GatsbyPlugin, &["gatsby"]),
2451 ];
2452 for (plugin, expected_enablers) in cases {
2453 let enablers = plugin.enablers();
2454 for expected in expected_enablers {
2455 assert!(
2456 enablers.contains(expected),
2457 "plugin '{}' should have '{}'",
2458 plugin.name(),
2459 expected
2460 );
2461 }
2462 }
2463 }
2464
2465 #[test]
2466 fn testing_plugins_enablers() {
2467 let cases: Vec<(&dyn Plugin, &str)> = vec![
2468 (&jest::JestPlugin, "jest"),
2469 (&vitest::VitestPlugin, "vitest"),
2470 (&playwright::PlaywrightPlugin, "@playwright/test"),
2471 (&cypress::CypressPlugin, "cypress"),
2472 (&mocha::MochaPlugin, "mocha"),
2473 (&stryker::StrykerPlugin, "@stryker-mutator/core"),
2474 ];
2475 for (plugin, enabler) in cases {
2476 assert!(
2477 plugin.enablers().contains(&enabler),
2478 "plugin '{}' should have '{}'",
2479 plugin.name(),
2480 enabler
2481 );
2482 }
2483 }
2484
2485 #[test]
2486 fn bundler_plugins_enablers() {
2487 let cases: Vec<(&dyn Plugin, &str)> = vec![
2488 (&vite::VitePlugin, "vite"),
2489 (&webpack::WebpackPlugin, "webpack"),
2490 (&rollup::RollupPlugin, "rollup"),
2491 ];
2492 for (plugin, enabler) in cases {
2493 assert!(
2494 plugin.enablers().contains(&enabler),
2495 "plugin '{}' should have '{}'",
2496 plugin.name(),
2497 enabler
2498 );
2499 }
2500 }
2501
2502 #[test]
2503 fn test_plugins_have_test_entry_patterns() {
2504 let test_plugins: Vec<&dyn Plugin> = vec![
2505 &bun::BunPlugin,
2506 &deno::DenoPlugin,
2507 &jest::JestPlugin,
2508 &vitest::VitestPlugin,
2509 &mocha::MochaPlugin,
2510 &tap::TapPlugin,
2511 &tsd::TsdPlugin,
2512 ];
2513 for plugin in test_plugins {
2514 let patterns = plugin.entry_patterns();
2515 assert!(
2516 !patterns.is_empty(),
2517 "test plugin '{}' should have entry patterns",
2518 plugin.name()
2519 );
2520 assert!(
2521 patterns
2522 .iter()
2523 .any(|p| p.contains("test") || p.contains("spec") || p.contains("__tests__")),
2524 "test plugin '{}' should have test/spec patterns",
2525 plugin.name()
2526 );
2527 }
2528 }
2529
2530 #[test]
2531 fn framework_plugins_have_entry_patterns() {
2532 let plugins: Vec<&dyn Plugin> = vec![
2533 &nextjs::NextJsPlugin,
2534 &nuxt::NuxtPlugin,
2535 &angular::AngularPlugin,
2536 &sveltekit::SvelteKitPlugin,
2537 ];
2538 for plugin in plugins {
2539 assert!(
2540 !plugin.entry_patterns().is_empty(),
2541 "framework plugin '{}' should have entry patterns",
2542 plugin.name()
2543 );
2544 }
2545 }
2546
2547 #[test]
2548 fn plugins_with_resolve_config_have_config_patterns() {
2549 let plugins: Vec<&dyn Plugin> = vec![
2550 &jest::JestPlugin,
2551 &vitest::VitestPlugin,
2552 &babel::BabelPlugin,
2553 &eslint::EslintPlugin,
2554 &webpack::WebpackPlugin,
2555 &storybook::StorybookPlugin,
2556 &typescript::TypeScriptPlugin,
2557 &postcss::PostCssPlugin,
2558 &nextjs::NextJsPlugin,
2559 &nuxt::NuxtPlugin,
2560 &angular::AngularPlugin,
2561 &nx::NxPlugin,
2562 &stryker::StrykerPlugin,
2563 &wuchale::WuchalePlugin,
2564 &rollup::RollupPlugin,
2565 &sveltekit::SvelteKitPlugin,
2566 &prettier::PrettierPlugin,
2567 &contentlayer::ContentlayerPlugin,
2568 ];
2569 for plugin in plugins {
2570 assert!(
2571 !plugin.config_patterns().is_empty(),
2572 "plugin '{}' with resolve_config should have config_patterns",
2573 plugin.name()
2574 );
2575 }
2576 }
2577
2578 #[test]
2579 fn plugin_tooling_deps_include_enabler_package() {
2580 let plugins: Vec<&dyn Plugin> = vec![
2581 &jest::JestPlugin,
2582 &vitest::VitestPlugin,
2583 &webpack::WebpackPlugin,
2584 &typescript::TypeScriptPlugin,
2585 &eslint::EslintPlugin,
2586 &prettier::PrettierPlugin,
2587 &danger::DangerPlugin,
2588 &stryker::StrykerPlugin,
2589 &wuchale::WuchalePlugin,
2590 &contentlayer::ContentlayerPlugin,
2591 ];
2592 for plugin in plugins {
2593 let tooling = plugin.tooling_dependencies();
2594 let enablers = plugin.enablers();
2595 assert!(
2596 enablers
2597 .iter()
2598 .any(|e| !e.ends_with('/') && tooling.contains(e)),
2599 "plugin '{}': at least one non-prefix enabler should be in tooling_dependencies",
2600 plugin.name()
2601 );
2602 }
2603 }
2604
2605 #[test]
2606 fn nextjs_has_used_exports_for_pages() {
2607 let plugin = nextjs::NextJsPlugin;
2608 let exports = plugin.used_exports();
2609 assert!(!exports.is_empty());
2610 assert!(exports.iter().any(|(_, names)| names.contains(&"default")));
2611 }
2612
2613 #[test]
2614 fn remix_has_used_exports_for_routes() {
2615 let plugin = remix::RemixPlugin;
2616 let exports = plugin.used_exports();
2617 assert!(!exports.is_empty());
2618 let route_entry = exports.iter().find(|(pat, _)| pat.contains("routes"));
2619 assert!(route_entry.is_some());
2620 let (_, names) = route_entry.unwrap();
2621 assert!(names.contains(&"loader"));
2622 assert!(names.contains(&"action"));
2623 assert!(names.contains(&"default"));
2624 }
2625
2626 #[test]
2627 fn sveltekit_has_used_exports_for_routes() {
2628 let plugin = sveltekit::SvelteKitPlugin;
2629 let exports = plugin.used_exports();
2630 assert!(!exports.is_empty());
2631 assert!(exports.iter().any(|(_, names)| names.contains(&"GET")));
2632 }
2633
2634 #[test]
2635 fn nuxt_has_hash_virtual_prefix() {
2636 assert!(nuxt::NuxtPlugin.virtual_module_prefixes().contains(&"#"));
2637 }
2638
2639 #[test]
2640 fn sveltekit_has_dollar_virtual_prefixes() {
2641 let prefixes = sveltekit::SvelteKitPlugin.virtual_module_prefixes();
2642 assert!(prefixes.contains(&"$app/"));
2643 assert!(prefixes.contains(&"$env/"));
2644 assert!(prefixes.contains(&"$lib/"));
2645 }
2646
2647 #[test]
2648 fn sveltekit_has_lib_path_alias() {
2649 let aliases = sveltekit::SvelteKitPlugin.path_aliases(Path::new("/project"));
2650 assert!(aliases.iter().any(|(prefix, _)| *prefix == "$lib/"));
2651 }
2652
2653 #[test]
2654 fn nuxt_has_tilde_path_alias() {
2655 let aliases = nuxt::NuxtPlugin.path_aliases(Path::new("/nonexistent"));
2656 assert!(aliases.iter().any(|(prefix, _)| *prefix == "~/"));
2657 assert!(aliases.iter().any(|(prefix, _)| *prefix == "~~/"));
2658 }
2659
2660 #[test]
2661 fn jest_has_package_json_config_key() {
2662 assert_eq!(jest::JestPlugin.package_json_config_key(), Some("jest"));
2663 }
2664
2665 #[test]
2666 fn tsd_has_package_json_config_key() {
2667 assert_eq!(tsd::TsdPlugin.package_json_config_key(), Some("tsd"));
2668 }
2669
2670 #[test]
2671 fn babel_has_package_json_config_key() {
2672 assert_eq!(babel::BabelPlugin.package_json_config_key(), Some("babel"));
2673 }
2674
2675 #[test]
2676 fn eslint_has_package_json_config_key() {
2677 assert_eq!(
2678 eslint::EslintPlugin.package_json_config_key(),
2679 Some("eslintConfig")
2680 );
2681 }
2682
2683 #[test]
2684 fn prettier_has_package_json_config_key() {
2685 assert_eq!(
2686 prettier::PrettierPlugin.package_json_config_key(),
2687 Some("prettier")
2688 );
2689 }
2690
2691 #[test]
2692 fn macro_generated_plugin_basic_properties() {
2693 let plugin = msw::MswPlugin;
2694 assert_eq!(plugin.name(), "msw");
2695 assert!(plugin.enablers().contains(&"msw"));
2696 assert!(!plugin.entry_patterns().is_empty());
2697 assert!(plugin.config_patterns().is_empty());
2698 assert!(!plugin.always_used().is_empty());
2699 assert!(!plugin.tooling_dependencies().is_empty());
2700 }
2701
2702 #[test]
2703 fn macro_generated_plugin_with_used_exports() {
2704 let plugin = remix::RemixPlugin;
2705 assert_eq!(plugin.name(), "remix");
2706 assert!(!plugin.used_exports().is_empty());
2707 }
2708
2709 #[test]
2710 fn macro_passes_through_virtual_package_suffixes() {
2711 define_plugin! {
2712 struct MacroSuffixSmokePlugin => "macro-suffix-smoke",
2713 enablers: &["macro-suffix-smoke"],
2714 virtual_package_suffixes: &["/__macro_smoke__"],
2715 }
2716
2717 let plugin = MacroSuffixSmokePlugin;
2718 assert_eq!(
2719 plugin.virtual_package_suffixes(),
2720 &["/__macro_smoke__"],
2721 "macro-declared virtual_package_suffixes must propagate to the trait method"
2722 );
2723 }
2724
2725 #[test]
2726 fn macro_generated_plugin_imports_only_resolve_config() {
2727 let plugin = cypress::CypressPlugin;
2728 let source = r"
2729 import { defineConfig } from 'cypress';
2730 import coveragePlugin from '@cypress/code-coverage';
2731 export default defineConfig({});
2732 ";
2733 let result = plugin.resolve_config(
2734 Path::new("cypress.config.ts"),
2735 source,
2736 Path::new("/project"),
2737 );
2738 assert!(
2739 result
2740 .referenced_dependencies
2741 .contains(&"cypress".to_string())
2742 );
2743 assert!(
2744 result
2745 .referenced_dependencies
2746 .contains(&"@cypress/code-coverage".to_string())
2747 );
2748 }
2749
2750 #[test]
2751 fn builtin_plugin_count_is_expected() {
2752 let plugins = registry::builtin::create_builtin_plugins();
2753 assert!(
2754 plugins.len() >= 110,
2755 "expected at least 110 built-in plugins, got {}",
2756 plugins.len()
2757 );
2758 }
2759
2760 #[test]
2764 fn a_parent_relative_pattern_resolves_against_the_workspace_prefix() {
2765 let parent_relative = |pattern: &str| {
2766 let mut rule = PathRule::new(pattern);
2767 rule.parent_relative = true;
2768 rule
2769 };
2770 assert_eq!(
2771 parent_relative("../shared/src/Thing.tsx")
2772 .prefixed("packages/app")
2773 .pattern,
2774 "packages/shared/src/Thing.tsx"
2775 );
2776 assert_eq!(
2777 parent_relative("../../lib/index.{ts,js}")
2778 .prefixed("apps/web/client")
2779 .pattern,
2780 "apps/lib/index.{ts,js}"
2781 );
2782 assert!(
2783 parent_relative("../../../outside/Thing.tsx")
2784 .prefixed("packages/app")
2785 .pattern
2786 .starts_with("../"),
2787 "a climb past the project root matches no project file"
2788 );
2789 assert_eq!(
2790 parent_relative("src/index.ts")
2791 .prefixed("packages/app")
2792 .pattern,
2793 "packages/app/src/index.ts"
2794 );
2795 }
2796
2797 #[test]
2800 fn a_parent_relative_pattern_resolves_against_a_backslash_prefix() {
2801 let mut rule = PathRule::new("../../packages/ui/src/**/*.mdx");
2802 rule.parent_relative = true;
2803 assert_eq!(
2804 rule.prefixed("apps\\docs").pattern,
2805 "packages/ui/src/**/*.mdx"
2806 );
2807 }
2808
2809 #[test]
2812 fn a_plain_parent_pattern_is_not_resolved() {
2813 assert_eq!(
2814 PathRule::new("../src/**/*.stories.tsx")
2815 .prefixed("packages/ui")
2816 .pattern,
2817 "packages/ui/../src/**/*.stories.tsx"
2818 );
2819 }
2820}