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