1use rustc_hash::{FxHashMap, FxHashSet};
4use std::fmt;
5use std::path::{Path, PathBuf};
6use std::sync::{OnceLock, RwLock};
7
8use fallow_config::{
9 AutoImportRule, EntryPointRole, ExternalPluginDef, JsxImportSourceRule, PackageJson,
10 UsedClassMemberRule,
11};
12
13use crate::scripts;
14
15use super::{PathRule, Plugin, PluginResult, PluginUsedExportRule, ProvidedDependencyRule};
16
17pub(crate) mod builtin;
18mod helpers;
19mod tooling_evidence;
20
21pub(crate) use tooling_evidence::find_config_file;
22
23#[must_use]
28pub fn builtin_plugin_names() -> Vec<&'static str> {
29 builtin::create_builtin_plugins()
30 .iter()
31 .map(|plugin| plugin.name())
32 .collect()
33}
34
35#[must_use]
40pub fn builtin_config_patterns() -> Vec<&'static str> {
41 let mut patterns: Vec<&'static str> = builtin::create_builtin_plugins()
42 .iter()
43 .flat_map(|plugin| plugin.config_patterns().iter().copied())
44 .collect();
45 patterns.sort_unstable();
46 patterns.dedup();
47 patterns
48}
49
50pub use helpers::ConfigCandidateIndex;
51pub use helpers::is_external_plugin_active;
52use helpers::{
53 check_has_config_file, discover_config_files, prepare_config_pattern, process_config_result,
54 process_external_plugins, process_package_json_metadata, process_static_patterns,
55};
56
57fn must_parse_workspace_config_when_root_active(plugin_name: &str) -> bool {
58 matches!(
59 plugin_name,
60 "eslint" | "docusaurus" | "jest" | "storybook" | "tanstack-router" | "vitest"
61 )
62}
63
64fn compile_config_matchers<'a>(
65 active: &[&'a dyn Plugin],
66) -> Vec<(&'a dyn Plugin, Vec<globset::GlobMatcher>)> {
67 active
68 .iter()
69 .filter(|plugin| !plugin.config_patterns().is_empty())
70 .map(|plugin| (*plugin, cached_plugin_config_matchers(*plugin)))
71 .collect()
72}
73
74fn compile_plugin_config_matchers(plugin: &dyn Plugin) -> Vec<globset::GlobMatcher> {
75 plugin
76 .config_patterns()
77 .iter()
78 .filter_map(|pattern| {
79 let prepared = prepare_config_pattern(pattern);
80 globset::Glob::new(&prepared)
81 .ok()
82 .map(|glob| glob.compile_matcher())
83 })
84 .collect()
85}
86
87struct CachedPluginConfigMatchers {
88 patterns: &'static [&'static str],
89 matchers: Vec<globset::GlobMatcher>,
90}
91
92#[derive(Default)]
93struct PluginConfigMatcherCache {
94 by_name: RwLock<FxHashMap<&'static str, Vec<CachedPluginConfigMatchers>>>,
95}
96
97impl PluginConfigMatcherCache {
98 fn get_or_compile(&self, plugin: &dyn Plugin) -> Vec<globset::GlobMatcher> {
99 let patterns = plugin.config_patterns();
100 let cached = self
101 .by_name
102 .read()
103 .unwrap_or_else(std::sync::PoisonError::into_inner)
104 .get(plugin.name())
105 .and_then(|variants| {
106 variants
107 .iter()
108 .find(|entry| entry.patterns == patterns)
109 .map(|entry| entry.matchers.clone())
110 });
111 if let Some(matchers) = cached {
112 return matchers;
113 }
114
115 let matchers = compile_plugin_config_matchers(plugin);
116 {
117 let mut by_name = self
118 .by_name
119 .write()
120 .unwrap_or_else(std::sync::PoisonError::into_inner);
121 let variants = by_name.entry(plugin.name()).or_default();
122 if let Some(entry) = variants.iter().find(|entry| entry.patterns == patterns) {
123 return entry.matchers.clone();
124 }
125 variants.push(CachedPluginConfigMatchers {
126 patterns,
127 matchers: matchers.clone(),
128 });
129 drop(by_name);
130 }
131 matchers
132 }
133}
134
135fn cached_plugin_config_matchers(plugin: &dyn Plugin) -> Vec<globset::GlobMatcher> {
136 static MATCHERS: OnceLock<PluginConfigMatcherCache> = OnceLock::new();
137 MATCHERS
138 .get_or_init(PluginConfigMatcherCache::default)
139 .get_or_compile(plugin)
140}
141
142fn log_active_plugins(active: &[&dyn Plugin]) {
144 tracing::info!(
145 plugins = active
146 .iter()
147 .map(|p| p.name())
148 .collect::<Vec<_>>()
149 .join(", "),
150 "active plugins"
151 );
152}
153
154fn compute_relative_files(
158 config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
159 active: &[&dyn Plugin],
160 discovered_files: &[PathBuf],
161 root: &Path,
162) -> Vec<(PathBuf, String)> {
163 use rayon::prelude::*;
164 let needs_relative_files =
165 !config_matchers.is_empty() || active.iter().any(|p| p.package_json_config_key().is_some());
166 if !needs_relative_files {
167 return Vec::new();
168 }
169 discovered_files
170 .par_iter()
171 .map(|f| {
172 let rel = f
173 .strip_prefix(root)
174 .unwrap_or(f)
175 .to_string_lossy()
176 .into_owned();
177 (f.clone(), rel)
178 })
179 .collect()
180}
181
182pub struct PluginRegistry {
184 plugins: Vec<Box<dyn Plugin>>,
185 external_plugins: Vec<ExternalPluginDef>,
186}
187
188pub(crate) struct WorkspacePluginRunInput<'a> {
190 pub(crate) pkg: &'a PackageJson,
191 pub(crate) root: &'a Path,
192 pub(crate) project_root: &'a Path,
193 pub(crate) precompiled_config_matchers: &'a [(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
194 pub(crate) relative_files: &'a [(PathBuf, String)],
195 pub(crate) skip_config_plugins: &'a FxHashSet<&'a str>,
196 pub(crate) production_mode: bool,
197 pub(crate) candidate_index: Option<&'a ConfigCandidateIndex>,
198}
199
200struct PluginRunContext<'a> {
201 all_deps: Vec<String>,
202 active: Vec<&'a dyn Plugin>,
203}
204
205struct PluginActivationInput<'a> {
207 pkg: &'a PackageJson,
208 root: &'a Path,
209 discovered_files: &'a [PathBuf],
210 all_deps: &'a [String],
211 script_packages: &'a FxHashSet<String>,
212 candidate_index: Option<&'a ConfigCandidateIndex>,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct PluginRegexValidationError {
218 plugin_name: String,
219 config_path: Option<PathBuf>,
220 rule_kind: &'static str,
221 field: &'static str,
222 rule_pattern: String,
223 regex_pattern: String,
224 source: String,
225}
226
227impl PluginRegexValidationError {
228 fn new(input: PluginRegexValidationErrorInput<'_>) -> Self {
229 Self {
230 plugin_name: input.plugin_name.to_owned(),
231 config_path: input.config_path.map(Path::to_path_buf),
232 rule_kind: input.rule_kind,
233 field: input.field,
234 rule_pattern: input.rule_pattern.to_owned(),
235 regex_pattern: input.regex_pattern.to_owned(),
236 source: input.source.to_string(),
237 }
238 }
239}
240
241#[derive(Clone, Copy)]
242pub(crate) struct PluginRegexValidationErrorInput<'a> {
243 plugin_name: &'a str,
244 config_path: Option<&'a Path>,
245 rule_kind: &'static str,
246 field: &'static str,
247 rule_pattern: &'a str,
248 regex_pattern: &'a str,
249 source: &'a regex::Error,
250}
251
252impl fmt::Display for PluginRegexValidationError {
253 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
254 let location = self
255 .config_path
256 .as_ref()
257 .map(|path| format!(" in {}", path.display()))
258 .unwrap_or_default();
259 write!(
260 f,
261 "plugin '{}'{}: invalid regex '{}' in {}.{} for path rule '{}': {}",
262 self.plugin_name,
263 location,
264 self.regex_pattern,
265 self.rule_kind,
266 self.field,
267 self.rule_pattern,
268 self.source
269 )
270 }
271}
272
273#[must_use]
274pub(crate) fn format_plugin_regex_errors(errors: &[PluginRegexValidationError]) -> String {
275 let joined = errors
276 .iter()
277 .map(ToString::to_string)
278 .collect::<Vec<_>>()
279 .join("\n - ");
280 format!(
281 "invalid plugin regex configuration:\n - {joined}\n\nRewrite the plugin config with Rust-compatible regex syntax, or remove unsupported constructs such as JavaScript lookahead and lookbehind."
282 )
283}
284
285#[derive(Debug, Clone, Default, PartialEq, Eq)]
293pub struct PluginToolingDependencies {
294 pub plugin: String,
296 pub dependencies: Vec<String>,
298 pub references: Vec<String>,
302 pub own_config: Option<PathBuf>,
305}
306
307#[derive(Debug, Clone, Copy, PartialEq, Eq)]
309pub enum PluginToolingEvidence<'a> {
310 OwnConfig(&'a Path),
312 Reference(&'a str),
314}
315
316impl PluginToolingDependencies {
317 #[must_use]
321 pub fn evidence<'a>(
322 &'a self,
323 script_used_packages: &FxHashSet<String>,
324 ) -> Option<PluginToolingEvidence<'a>> {
325 if let Some(config) = &self.own_config {
326 return Some(PluginToolingEvidence::OwnConfig(config));
327 }
328 self.references
329 .iter()
330 .find(|name| script_used_packages.contains(name.as_str()))
331 .map(|name| PluginToolingEvidence::Reference(name))
332 }
333}
334
335#[derive(Debug, Clone, Default)]
337pub struct AggregatedPluginResult {
338 pub entry_patterns: Vec<(PathRule, String)>,
340 pub replaced_entry_plugins: FxHashSet<String>,
345 pub entry_point_roles: rustc_hash::FxHashMap<String, EntryPointRole>,
347 pub config_patterns: Vec<String>,
349 pub always_used: Vec<(String, String)>,
351 pub used_exports: Vec<PluginUsedExportRule>,
353 pub used_class_members: Vec<UsedClassMemberRule>,
357 pub framework_class_member_contracts: Vec<fallow_types::semantic::SemanticFrameworkContract>,
359 pub referenced_dependencies: Vec<String>,
361 pub package_referenced_dependencies: Vec<(PathBuf, String)>,
363 pub discovered_always_used: Vec<(String, String)>,
365 pub setup_files: Vec<(PathBuf, String)>,
367 pub tooling_dependencies: Vec<String>,
371 pub plugin_tooling: Vec<PluginToolingDependencies>,
375 pub script_used_packages: FxHashSet<String>,
377 pub dependency_binaries: crate::scripts::DependencyBinaries,
381 pub dev_dependency_names: FxHashSet<String>,
386 pub virtual_module_prefixes: Vec<String>,
389 pub virtual_package_suffixes: Vec<String>,
392 pub generated_import_patterns: Vec<String>,
395 pub generated_type_import_prefixes: Vec<String>,
398 pub path_aliases: Vec<(String, String)>,
401 pub auto_imports: Vec<AutoImportRule>,
405 pub active_plugins: Vec<String>,
407 pub fixture_patterns: Vec<(String, String)>,
409 pub scss_include_paths: Vec<PathBuf>,
414 pub static_dir_mappings: Vec<(PathBuf, String)>,
416 pub framework_static_dir_mappings: Vec<(PathBuf, String)>,
419 pub provided_dependencies: Vec<ProvidedDependencyRule>,
421 pub config_diagnostics: Vec<super::PluginConfigDiagnostic>,
425 pub federation_sources: Vec<super::FederationSource>,
428 pub jsx_import_sources: Vec<JsxImportSourceRule>,
432 pub jsx_package_credits: Vec<JsxImportSourceRule>,
437 pub tsconfig_jsx_dirs: Vec<PathBuf>,
441}
442
443fn extend_unique(target: &mut Vec<String>, incoming: Vec<String>) {
448 let mut seen: FxHashSet<String> = target.iter().cloned().collect();
449 for item in incoming {
450 if seen.insert(item.clone()) {
451 target.push(item);
452 }
453 }
454}
455
456fn prefix_if_needed(pat: &str, ws_prefix: &str) -> String {
460 if pat.starts_with(ws_prefix) || pat.starts_with('/') {
461 pat.to_string()
462 } else {
463 format!("{ws_prefix}/{pat}")
464 }
465}
466
467impl AggregatedPluginResult {
468 pub(crate) fn apply_workspace_prefix(&mut self, ws_prefix: &str) {
484 for (rule, _) in &mut self.entry_patterns {
485 *rule = rule.prefixed(ws_prefix);
486 }
487 for (pat, _) in &mut self.always_used {
488 *pat = prefix_if_needed(pat, ws_prefix);
489 }
490 for (pat, _) in &mut self.discovered_always_used {
491 *pat = prefix_if_needed(pat, ws_prefix);
492 }
493 for (pat, _) in &mut self.fixture_patterns {
494 *pat = prefix_if_needed(pat, ws_prefix);
495 }
496 for rule in &mut self.used_exports {
497 *rule = rule.prefixed(ws_prefix);
498 }
499 for rule in &mut self.provided_dependencies {
500 *rule = rule.prefixed(ws_prefix);
501 }
502 for source in &mut self.federation_sources {
503 *source = source.prefixed(ws_prefix);
504 }
505 for (_, replacement) in &mut self.path_aliases {
506 *replacement = format!("{ws_prefix}/{replacement}");
507 }
508 }
509
510 pub(crate) fn merge_into(&mut self, other: Self) {
524 let Self {
525 entry_patterns,
526 replaced_entry_plugins: _,
528 entry_point_roles,
529 config_patterns,
530 always_used,
531 used_exports,
532 used_class_members,
533 framework_class_member_contracts,
534 referenced_dependencies,
535 package_referenced_dependencies,
536 discovered_always_used,
537 setup_files,
538 tooling_dependencies,
539 plugin_tooling,
540 script_used_packages,
541 dependency_binaries: _,
542 dev_dependency_names,
543 virtual_module_prefixes,
544 virtual_package_suffixes,
545 generated_import_patterns,
546 generated_type_import_prefixes,
547 path_aliases,
548 auto_imports,
549 active_plugins,
550 fixture_patterns,
551 scss_include_paths,
552 static_dir_mappings,
553 framework_static_dir_mappings,
554 provided_dependencies,
555 config_diagnostics,
556 federation_sources,
557 jsx_import_sources,
558 jsx_package_credits,
559 tsconfig_jsx_dirs,
560 } = other;
561
562 self.entry_patterns.extend(entry_patterns);
563 for (plugin_name, role) in entry_point_roles {
564 self.entry_point_roles.entry(plugin_name).or_insert(role);
565 }
566 self.config_patterns.extend(config_patterns);
567 self.always_used.extend(always_used);
568 self.used_exports.extend(used_exports);
569 self.used_class_members.extend(used_class_members);
570 for contract in framework_class_member_contracts {
571 if !self.framework_class_member_contracts.contains(&contract) {
572 self.framework_class_member_contracts.push(contract);
573 }
574 }
575 self.referenced_dependencies.extend(referenced_dependencies);
576 self.package_referenced_dependencies
577 .extend(package_referenced_dependencies);
578 self.discovered_always_used.extend(discovered_always_used);
579 self.setup_files.extend(setup_files);
580 self.tooling_dependencies.extend(tooling_dependencies);
581 self.plugin_tooling.extend(plugin_tooling);
582 self.dev_dependency_names.extend(dev_dependency_names);
583 self.script_used_packages.extend(script_used_packages);
584 extend_unique(&mut self.virtual_module_prefixes, virtual_module_prefixes);
585 extend_unique(&mut self.virtual_package_suffixes, virtual_package_suffixes);
586 extend_unique(
587 &mut self.generated_import_patterns,
588 generated_import_patterns,
589 );
590 extend_unique(
591 &mut self.generated_type_import_prefixes,
592 generated_type_import_prefixes,
593 );
594 self.path_aliases.extend(path_aliases);
595 self.auto_imports.extend(auto_imports);
596 extend_unique(&mut self.active_plugins, active_plugins);
597 self.fixture_patterns.extend(fixture_patterns);
598 self.scss_include_paths.extend(scss_include_paths);
599 self.static_dir_mappings.extend(static_dir_mappings);
600 self.framework_static_dir_mappings
601 .extend(framework_static_dir_mappings);
602 self.provided_dependencies.extend(provided_dependencies);
603 self.federation_sources.extend(federation_sources);
604 for rule in jsx_import_sources {
605 if !self.jsx_import_sources.contains(&rule) {
606 self.jsx_import_sources.push(rule);
607 }
608 }
609 for rule in jsx_package_credits {
610 if !self.jsx_package_credits.contains(&rule) {
611 self.jsx_package_credits.push(rule);
612 }
613 }
614 for dir in tsconfig_jsx_dirs {
615 if !self.tsconfig_jsx_dirs.contains(&dir) {
616 self.tsconfig_jsx_dirs.push(dir);
617 }
618 }
619 for diagnostic in config_diagnostics {
620 if !self.config_diagnostics.contains(&diagnostic) {
621 self.config_diagnostics.push(diagnostic);
622 }
623 }
624 }
625}
626
627impl PluginRegistry {
628 #[must_use]
630 pub fn new(external: Vec<ExternalPluginDef>) -> Self {
631 Self {
632 plugins: builtin::create_builtin_plugins(),
633 external_plugins: external,
634 }
635 }
636
637 #[must_use]
642 pub fn discovery_hidden_dirs(&self, pkg: &PackageJson, root: &Path) -> Vec<String> {
643 let all_deps = pkg.all_dependency_names();
644 let mut seen = FxHashSet::default();
645 let mut dirs = Vec::new();
646
647 for plugin in &self.plugins {
648 if !plugin.is_enabled_with_deps(&all_deps, root) {
649 continue;
650 }
651 for dir in plugin.discovery_hidden_dirs() {
652 if seen.insert(*dir) {
653 dirs.push((*dir).to_string());
654 }
655 }
656 }
657
658 dirs
659 }
660
661 #[cfg(test)]
666 fn run(
667 &self,
668 pkg: &PackageJson,
669 root: &Path,
670 discovered_files: &[PathBuf],
671 ) -> AggregatedPluginResult {
672 self.try_run(pkg, root, discovered_files)
673 .unwrap_or_else(|errors| panic!("{}", format_plugin_regex_errors(&errors)))
674 }
675
676 pub fn try_run(
678 &self,
679 pkg: &PackageJson,
680 root: &Path,
681 discovered_files: &[PathBuf],
682 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
683 self.try_run_with_search_roots(pkg, root, discovered_files, &[root], false, None)
684 }
685
686 #[expect(
689 clippy::too_many_arguments,
690 reason = "public PluginRegistry API; signature is part of the crate surface for embedders"
691 )]
692 pub(crate) fn try_run_with_search_roots(
693 &self,
694 pkg: &PackageJson,
695 root: &Path,
696 discovered_files: &[PathBuf],
697 config_search_roots: &[&Path],
698 production_mode: bool,
699 candidate_index: Option<&ConfigCandidateIndex>,
700 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
701 let _span = tracing::info_span!("run_plugins").entered();
702 let mut result = AggregatedPluginResult::default();
703 let mut regex_errors = Vec::new();
704
705 let PluginRunContext { all_deps, active } = self.prepare_plugin_run_context(
706 pkg,
707 root,
708 discovered_files,
709 production_mode,
710 candidate_index,
711 );
712
713 self.run_plugin_preflight(&active, &all_deps, root, discovered_files);
714
715 for plugin in &active {
716 process_static_patterns(*plugin, root, &mut result);
717 }
718 process_package_json_metadata(&active, pkg, root, &mut result, &mut regex_errors);
719
720 process_external_plugins(
721 &self.external_plugins,
722 &all_deps,
723 root,
724 discovered_files,
725 &mut result,
726 );
727 tooling_evidence::record_plugin_tooling(
728 &tooling_evidence::ToolingEvidenceInput {
729 active: &active,
730 external_plugins: &self.external_plugins,
731 all_deps: &all_deps,
732 roots: &[root],
733 extra_roots: if production_mode {
737 &[]
738 } else {
739 config_search_roots
740 },
741 candidate_index,
742 discovered_files,
743 },
744 &mut result,
745 );
746
747 let config_matchers = compile_config_matchers(&active);
748 let relative_files =
749 compute_relative_files(&config_matchers, &active, discovered_files, root);
750
751 resolve_plugin_config_files(&mut PluginConfigResolutionInput {
752 config_matchers: &config_matchers,
753 relative_files: &relative_files,
754 config_search_roots,
755 production_mode,
756 candidate_index,
757 root,
758 result: &mut result,
759 regex_errors: &mut regex_errors,
760 });
761
762 process_package_json_inline_configs(
763 &active,
764 &config_matchers,
765 &relative_files,
766 root,
767 &mut result,
768 &mut regex_errors,
769 );
770
771 if regex_errors.is_empty() {
772 Ok(result)
773 } else {
774 Err(regex_errors)
775 }
776 }
777
778 #[cfg(test)]
784 fn run_workspace_fast(&self, input: &WorkspacePluginRunInput<'_>) -> AggregatedPluginResult {
785 self.try_run_workspace_fast(input)
786 .unwrap_or_else(|errors| panic!("{}", format_plugin_regex_errors(&errors)))
787 }
788
789 pub(crate) fn try_run_workspace_fast(
795 &self,
796 input: &WorkspacePluginRunInput<'_>,
797 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
798 let _span = tracing::info_span!("run_plugins").entered();
799 let mut result = AggregatedPluginResult::default();
800 let mut regex_errors = Vec::new();
801
802 let all_deps = input.pkg.all_dependency_names();
803 let script_packages =
804 script_activation_packages(input.pkg, input.root, &all_deps, input.production_mode);
805 let workspace_files: Vec<PathBuf> = input
806 .relative_files
807 .iter()
808 .map(|(abs_path, _)| abs_path.clone())
809 .collect();
810
811 let active = self.collect_active_plugins(&PluginActivationInput {
812 pkg: input.pkg,
813 root: input.root,
814 discovered_files: &workspace_files,
815 all_deps: &all_deps,
816 script_packages: &script_packages,
817 candidate_index: input.candidate_index,
818 });
819
820 log_active_plugins(&active);
821
822 self.emit_silent_fail_diagnostics(&active, &all_deps, input.root, &workspace_files);
823
824 process_external_plugins(
825 &self.external_plugins,
826 &all_deps,
827 input.root,
828 &workspace_files,
829 &mut result,
830 );
831
832 if active.is_empty() && result.active_plugins.is_empty() {
833 return Ok(result);
834 }
835
836 process_workspace_active_plugins(&active, input, &mut result, &mut regex_errors);
837 let evidence_roots: &[&Path] = if input.root == input.project_root {
839 &[input.root]
840 } else {
841 &[input.root, input.project_root]
842 };
843 tooling_evidence::record_plugin_tooling(
844 &tooling_evidence::ToolingEvidenceInput {
845 active: &active,
846 external_plugins: &self.external_plugins,
847 all_deps: &all_deps,
848 roots: evidence_roots,
849 extra_roots: &[],
850 candidate_index: input.candidate_index,
851 discovered_files: &workspace_files,
852 },
853 &mut result,
854 );
855 resolve_workspace_plugin_configs(&active, input, &mut result, &mut regex_errors);
856
857 if regex_errors.is_empty() {
858 Ok(result)
859 } else {
860 Err(regex_errors)
861 }
862 }
863
864 #[must_use]
867 pub(crate) fn precompile_config_matchers(
868 &self,
869 ) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
870 self.plugins
871 .iter()
872 .filter(|p| !p.config_patterns().is_empty())
873 .map(|p| (p.as_ref(), cached_plugin_config_matchers(p.as_ref())))
874 .collect()
875 }
876
877 pub(crate) fn resolve_script_config_files(
888 &self,
889 config_files: &[scripts::BinaryConfigFile],
890 package_root: &Path,
891 ws_prefix: &str,
892 ) -> AggregatedPluginResult {
893 let mut aggregated = AggregatedPluginResult::default();
894 for config_file in config_files {
895 let Some(rel_path) =
896 scripts::normalize_script_entry_pattern(ws_prefix, &config_file.path)
897 else {
898 continue;
899 };
900 for plugin in &self.plugins {
901 let plugin = plugin.as_ref();
902 if !plugin
903 .script_config_binaries()
904 .contains(&config_file.binary.as_str())
905 || cached_plugin_config_matchers(plugin)
906 .iter()
907 .any(|m| m.is_match(rel_path.as_str()))
908 {
909 continue;
910 }
911 let abs_path = package_root.join(config_file.path.trim_start_matches("./"));
912 let Ok(source) = std::fs::read_to_string(&abs_path) else {
913 continue;
914 };
915 let mut file_result = AggregatedPluginResult::default();
916 file_result
917 .entry_point_roles
918 .insert(plugin.name().to_string(), plugin.entry_point_role());
919 let plugin_result = plugin.resolve_config(&abs_path, &source, package_root);
920 if let Err(errors) = process_config_result(
921 plugin.name(),
922 plugin_result,
923 &mut file_result,
924 Some(&abs_path),
925 ) {
926 for error in errors {
927 tracing::warn!("{error}");
928 }
929 continue;
930 }
931 if !ws_prefix.is_empty() {
932 file_result.apply_workspace_prefix(ws_prefix);
933 }
934 file_result.used_exports.push(PluginUsedExportRule::new(
936 plugin.name().to_string(),
937 super::UsedExportRule::new(rel_path.clone(), ["default"]),
938 ));
939 aggregated.merge_into(file_result);
940 }
941 }
942 aggregated
943 }
944}
945
946fn process_workspace_active_plugins(
947 active: &[&dyn Plugin],
948 input: &WorkspacePluginRunInput<'_>,
949 result: &mut AggregatedPluginResult,
950 regex_errors: &mut Vec<PluginRegexValidationError>,
951) {
952 for plugin in active {
953 process_static_patterns(*plugin, input.root, result);
954 }
955 process_package_json_metadata(active, input.pkg, input.root, result, regex_errors);
956}
957
958fn resolve_workspace_plugin_configs(
959 active: &[&dyn Plugin],
960 input: &WorkspacePluginRunInput<'_>,
961 result: &mut AggregatedPluginResult,
962 regex_errors: &mut Vec<PluginRegexValidationError>,
963) {
964 let workspace_matchers = select_workspace_matchers(
965 input.precompiled_config_matchers,
966 active,
967 input.skip_config_plugins,
968 );
969
970 let mut resolved_ws_plugins: FxHashSet<&str> = FxHashSet::default();
971 for (plugin, matchers) in &workspace_matchers {
972 resolve_plugin_matching_files(&mut PluginMatchingFilesInput {
973 plugin: *plugin,
974 matchers,
975 relative_files: input.relative_files,
976 root: input.root,
977 result,
978 regex_errors,
979 resolved_plugins: &mut resolved_ws_plugins,
980 });
981 }
982
983 load_workspace_filesystem_configs(&mut WorkspaceFsConfigInput {
984 workspace_matchers: &workspace_matchers,
985 resolved_ws_plugins: &resolved_ws_plugins,
986 root: input.root,
987 project_root: input.project_root,
988 production_mode: input.production_mode,
989 candidate_index: input.candidate_index,
990 result,
991 regex_errors,
992 });
993}
994
995impl Default for PluginRegistry {
996 fn default() -> Self {
997 Self::new(vec![])
998 }
999}
1000
1001impl PluginRegistry {
1002 fn prepare_plugin_run_context<'a>(
1003 &'a self,
1004 pkg: &PackageJson,
1005 root: &Path,
1006 discovered_files: &[PathBuf],
1007 production_mode: bool,
1008 candidate_index: Option<&ConfigCandidateIndex>,
1009 ) -> PluginRunContext<'a> {
1010 let all_deps = pkg.all_dependency_names();
1011 let script_packages = script_activation_packages(pkg, root, &all_deps, production_mode);
1012 let active = self.collect_active_plugins(&PluginActivationInput {
1013 pkg,
1014 root,
1015 discovered_files,
1016 all_deps: &all_deps,
1017 script_packages: &script_packages,
1018 candidate_index,
1019 });
1020
1021 PluginRunContext { all_deps, active }
1022 }
1023
1024 fn run_plugin_preflight(
1025 &self,
1026 active: &[&dyn Plugin],
1027 all_deps: &[String],
1028 root: &Path,
1029 discovered_files: &[PathBuf],
1030 ) {
1031 log_active_plugins(active);
1032 check_meta_framework_prerequisites(active, root);
1033 self.emit_silent_fail_diagnostics(active, all_deps, root, discovered_files);
1034 }
1035
1036 fn collect_active_plugins<'a>(
1039 &'a self,
1040 activation: &PluginActivationInput<'_>,
1041 ) -> Vec<&'a dyn Plugin> {
1042 self.plugins
1043 .iter()
1044 .filter(|p| {
1045 p.is_enabled_with_files(
1046 activation.all_deps,
1047 activation.root,
1048 activation.discovered_files,
1049 activation.candidate_index,
1050 ) || p.is_enabled_with_scripts(activation.script_packages, activation.root)
1051 || p.is_enabled_with_package_json(activation.pkg, activation.root)
1052 })
1053 .map(AsRef::as_ref)
1054 .collect()
1055 }
1056
1057 fn emit_silent_fail_diagnostics(
1066 &self,
1067 active: &[&dyn Plugin],
1068 all_deps: &[String],
1069 root: &Path,
1070 discovered_files: &[PathBuf],
1071 ) {
1072 let active_external: Vec<&ExternalPluginDef> = self
1073 .external_plugins
1074 .iter()
1075 .filter(|ext| is_external_plugin_active(ext, all_deps, root, discovered_files))
1076 .collect();
1077 let mut diagnostics = detect_pattern_collisions(active, &active_external);
1078 diagnostics.extend(detect_enabler_typos(&self.external_plugins, all_deps));
1079 emit_plugin_diagnostics(&diagnostics);
1080 }
1081}
1082
1083fn plugin_warn_dedupe() -> &'static std::sync::Mutex<FxHashSet<String>> {
1090 static WARNED: std::sync::OnceLock<std::sync::Mutex<FxHashSet<String>>> =
1091 std::sync::OnceLock::new();
1092 WARNED.get_or_init(|| std::sync::Mutex::new(FxHashSet::default()))
1093}
1094
1095struct PluginConfigResolutionInput<'a> {
1096 config_matchers: &'a [(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
1097 relative_files: &'a [(PathBuf, String)],
1098 config_search_roots: &'a [&'a Path],
1099 production_mode: bool,
1100 candidate_index: Option<&'a ConfigCandidateIndex>,
1101 root: &'a Path,
1102 result: &'a mut AggregatedPluginResult,
1103 regex_errors: &'a mut Vec<PluginRegexValidationError>,
1104}
1105
1106fn select_workspace_matchers<'a>(
1109 precompiled_config_matchers: &[(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
1110 active: &[&dyn Plugin],
1111 skip_config_plugins: &FxHashSet<&str>,
1112) -> Vec<(&'a dyn Plugin, Vec<globset::GlobMatcher>)> {
1113 let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
1114 precompiled_config_matchers
1115 .iter()
1116 .filter(|(p, _)| {
1117 active_names.contains(p.name())
1118 && (!skip_config_plugins.contains(p.name())
1119 || must_parse_workspace_config_when_root_active(p.name()))
1120 })
1121 .map(|(plugin, matchers)| (*plugin, matchers.clone()))
1122 .collect()
1123}
1124
1125struct WorkspaceFsConfigInput<'a> {
1126 workspace_matchers: &'a [(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
1127 resolved_ws_plugins: &'a FxHashSet<&'a str>,
1128 root: &'a Path,
1129 project_root: &'a Path,
1130 production_mode: bool,
1131 candidate_index: Option<&'a ConfigCandidateIndex>,
1132 result: &'a mut AggregatedPluginResult,
1133 regex_errors: &'a mut Vec<PluginRegexValidationError>,
1134}
1135
1136fn load_workspace_filesystem_configs(input: &mut WorkspaceFsConfigInput<'_>) {
1139 let search_roots: &[&Path] = if input.root == input.project_root {
1140 &[input.root]
1141 } else {
1142 &[input.root, input.project_root]
1143 };
1144 let ws_json_configs = discover_config_files(
1145 input.workspace_matchers,
1146 input.resolved_ws_plugins,
1147 search_roots,
1148 input.production_mode,
1149 input.candidate_index,
1150 );
1151 for (abs_path, plugin) in &ws_json_configs {
1152 let Ok(source) = std::fs::read_to_string(abs_path) else {
1153 continue;
1154 };
1155 let plugin_result = plugin.resolve_config(abs_path, &source, input.root);
1156 if plugin_result.is_empty() {
1157 continue;
1158 }
1159 let rel = abs_path
1160 .strip_prefix(input.project_root)
1161 .map(|p| p.to_string_lossy())
1162 .unwrap_or_default();
1163 tracing::debug!(
1164 plugin = plugin.name(),
1165 config = %rel,
1166 entries = plugin_result.entry_patterns.len(),
1167 deps = plugin_result.referenced_dependencies.len(),
1168 "resolved config (workspace filesystem fallback)"
1169 );
1170 if let Err(mut errors) =
1171 process_config_result(plugin.name(), plugin_result, input.result, Some(abs_path))
1172 {
1173 input.regex_errors.append(&mut errors);
1174 }
1175 }
1176}
1177
1178fn resolve_plugin_config_files(input: &mut PluginConfigResolutionInput<'_>) {
1179 if input.config_matchers.is_empty() {
1180 return;
1181 }
1182
1183 let mut resolved_plugins: FxHashSet<&str> = FxHashSet::default();
1184 for (plugin, matchers) in input.config_matchers {
1185 resolve_plugin_matching_files(&mut PluginMatchingFilesInput {
1186 plugin: *plugin,
1187 matchers,
1188 relative_files: input.relative_files,
1189 root: input.root,
1190 result: input.result,
1191 regex_errors: input.regex_errors,
1192 resolved_plugins: &mut resolved_plugins,
1193 });
1194 }
1195
1196 let json_configs = discover_config_files(
1197 input.config_matchers,
1198 &resolved_plugins,
1199 input.config_search_roots,
1200 input.production_mode,
1201 input.candidate_index,
1202 );
1203 for (abs_path, plugin) in &json_configs {
1204 resolve_plugin_filesystem_config(
1205 *plugin,
1206 abs_path,
1207 input.root,
1208 input.result,
1209 input.regex_errors,
1210 );
1211 }
1212}
1213
1214struct PluginMatchingFilesInput<'plugins, 'data, 'state> {
1215 plugin: &'plugins dyn Plugin,
1216 matchers: &'data [globset::GlobMatcher],
1217 relative_files: &'data [(PathBuf, String)],
1218 root: &'data Path,
1219 result: &'state mut AggregatedPluginResult,
1220 regex_errors: &'state mut Vec<PluginRegexValidationError>,
1221 resolved_plugins: &'state mut FxHashSet<&'plugins str>,
1222}
1223
1224fn resolve_plugin_matching_files(input: &mut PluginMatchingFilesInput<'_, '_, '_>) {
1225 use rayon::prelude::*;
1226
1227 let plugin_hits: Vec<&PathBuf> = input
1228 .relative_files
1229 .par_iter()
1230 .filter_map(|(abs_path, rel_path)| {
1231 input
1232 .matchers
1233 .iter()
1234 .any(|m| m.is_match(rel_path.as_str()))
1235 .then_some(abs_path)
1236 })
1237 .collect();
1238 for abs_path in plugin_hits {
1239 let Ok(source) = std::fs::read_to_string(abs_path) else {
1240 continue;
1241 };
1242 let plugin_result = input.plugin.resolve_config(abs_path, &source, input.root);
1243 if plugin_result.is_empty() {
1244 continue;
1245 }
1246 input.resolved_plugins.insert(input.plugin.name());
1247 process_resolved_plugin_config(ResolvedPluginConfigInput {
1248 plugin: input.plugin,
1249 abs_path,
1250 plugin_result,
1251 result: input.result,
1252 regex_errors: input.regex_errors,
1253 message: "resolved config",
1254 config_display: abs_path.display(),
1255 });
1256 }
1257}
1258
1259fn resolve_plugin_filesystem_config(
1260 plugin: &dyn Plugin,
1261 abs_path: &Path,
1262 root: &Path,
1263 result: &mut AggregatedPluginResult,
1264 regex_errors: &mut Vec<PluginRegexValidationError>,
1265) {
1266 let Ok(source) = std::fs::read_to_string(abs_path) else {
1267 return;
1268 };
1269 let plugin_result = plugin.resolve_config(abs_path, &source, root);
1270 if plugin_result.is_empty() {
1271 return;
1272 }
1273 let rel = abs_path
1274 .strip_prefix(root)
1275 .map(|p| p.to_string_lossy())
1276 .unwrap_or_default();
1277 process_resolved_plugin_config(ResolvedPluginConfigInput {
1278 plugin,
1279 abs_path,
1280 plugin_result,
1281 result,
1282 regex_errors,
1283 message: "resolved config (filesystem fallback)",
1284 config_display: rel,
1285 });
1286}
1287
1288struct ResolvedPluginConfigInput<'a, D> {
1289 plugin: &'a dyn Plugin,
1290 abs_path: &'a Path,
1291 plugin_result: PluginResult,
1292 result: &'a mut AggregatedPluginResult,
1293 regex_errors: &'a mut Vec<PluginRegexValidationError>,
1294 message: &'static str,
1295 config_display: D,
1296}
1297
1298fn process_resolved_plugin_config(input: ResolvedPluginConfigInput<'_, impl std::fmt::Display>) {
1299 tracing::debug!(
1300 plugin = input.plugin.name(),
1301 config = %input.config_display,
1302 entries = input.plugin_result.entry_patterns.len(),
1303 deps = input.plugin_result.referenced_dependencies.len(),
1304 input.message
1305 );
1306 if let Err(mut errors) = process_config_result(
1307 input.plugin.name(),
1308 input.plugin_result,
1309 input.result,
1310 Some(input.abs_path),
1311 ) {
1312 input.regex_errors.append(&mut errors);
1313 }
1314}
1315
1316fn should_warn(key: String) -> bool {
1320 plugin_warn_dedupe()
1321 .lock()
1322 .map_or(true, |mut set| set.insert(key))
1323}
1324
1325#[derive(Debug, Clone, PartialEq, Eq)]
1332pub(crate) enum PluginDiagnostic {
1333 PatternCollision {
1335 pattern: String,
1336 owners: Vec<String>,
1337 },
1338 EnablerTypo {
1341 plugin: String,
1342 enabler: String,
1343 suggestion: String,
1344 },
1345}
1346
1347fn detect_pattern_collisions(
1373 builtin_active: &[&dyn Plugin],
1374 external_active: &[&ExternalPluginDef],
1375) -> Vec<PluginDiagnostic> {
1376 use rustc_hash::FxHashMap;
1377
1378 let mut pattern_owners: FxHashMap<String, (Vec<String>, FxHashSet<String>)> =
1379 FxHashMap::default();
1380
1381 let record = |pattern_owners: &mut FxHashMap<_, (Vec<String>, FxHashSet<String>)>,
1382 pattern: String,
1383 name: String| {
1384 let (list, seen) = pattern_owners.entry(pattern).or_default();
1385 if seen.insert(name.clone()) {
1386 list.push(name);
1387 }
1388 };
1389
1390 for plugin in builtin_active {
1391 for pat in plugin.config_patterns() {
1392 record(
1393 &mut pattern_owners,
1394 (*pat).to_string(),
1395 plugin.name().to_string(),
1396 );
1397 }
1398 }
1399 for ext in external_active {
1400 for pat in &ext.config_patterns {
1401 record(&mut pattern_owners, pat.clone(), ext.name.clone());
1402 }
1403 }
1404
1405 let builtin_names: FxHashSet<&str> = builtin_active.iter().map(|p| p.name()).collect();
1412
1413 let mut findings: Vec<PluginDiagnostic> = pattern_owners
1414 .into_iter()
1415 .filter_map(|(pattern, (owners, _seen))| {
1416 if owners.len() < 2 || owners.iter().all(|o| builtin_names.contains(o.as_str())) {
1417 None
1418 } else {
1419 Some(PluginDiagnostic::PatternCollision { pattern, owners })
1420 }
1421 })
1422 .collect();
1423 findings.sort_unstable_by(|a, b| match (a, b) {
1424 (
1425 PluginDiagnostic::PatternCollision { pattern: ap, .. },
1426 PluginDiagnostic::PatternCollision { pattern: bp, .. },
1427 ) => ap.cmp(bp),
1428 _ => std::cmp::Ordering::Equal,
1429 });
1430 findings
1431}
1432
1433fn detect_enabler_typos(
1448 external_plugins: &[ExternalPluginDef],
1449 all_deps: &[String],
1450) -> Vec<PluginDiagnostic> {
1451 let mut findings = Vec::new();
1452
1453 for ext in external_plugins {
1454 if ext.detection.is_some() || ext.enablers.is_empty() {
1455 continue;
1456 }
1457
1458 let any_match = ext.enablers.iter().any(|enabler| {
1459 if enabler.ends_with('/') {
1460 all_deps.iter().any(|d| d.starts_with(enabler))
1461 } else {
1462 all_deps.iter().any(|d| d == enabler)
1463 }
1464 });
1465 if any_match {
1466 continue;
1467 }
1468
1469 for enabler in &ext.enablers {
1470 let candidates = all_deps.iter().map(String::as_str);
1471 let Some(suggestion) = fallow_config::levenshtein::closest_match(enabler, candidates)
1472 else {
1473 continue;
1474 };
1475
1476 findings.push(PluginDiagnostic::EnablerTypo {
1477 plugin: ext.name.clone(),
1478 enabler: enabler.clone(),
1479 suggestion: suggestion.to_string(),
1480 });
1481 }
1482 }
1483
1484 findings
1485}
1486
1487fn emit_plugin_diagnostics(findings: &[PluginDiagnostic]) {
1490 for finding in findings {
1491 match finding {
1492 PluginDiagnostic::PatternCollision { pattern, owners } => {
1493 let key = format!("collision::{pattern}::{owners:?}");
1494 if !should_warn(key) {
1495 continue;
1496 }
1497 let winner = &owners[0];
1498 let others = owners[1..].join(", ");
1499 tracing::warn!(
1500 "plugin config_patterns collision: identical pattern \
1501 '{pattern}' is claimed by plugins [{joined}]; '{winner}' \
1502 runs first (registration order), others ({others}) \
1503 follow. Rename one of the patterns or remove the \
1504 duplicate plugin to make resolution explicit. A future \
1505 release may reject identical-pattern collisions.",
1506 joined = owners.join(", "),
1507 );
1508 }
1509 PluginDiagnostic::EnablerTypo {
1510 plugin,
1511 enabler,
1512 suggestion,
1513 } => {
1514 let key = format!("enabler::{plugin}::{enabler}");
1515 if !should_warn(key) {
1516 continue;
1517 }
1518 tracing::warn!(
1519 "plugin '{plugin}' enabler '{enabler}' does not match any \
1520 dependency in package.json; did you mean '{suggestion}'? \
1521 The plugin will not activate. A future release may reject \
1522 unmatched enablers.",
1523 );
1524 }
1525 }
1526 }
1527}
1528
1529fn process_package_json_inline_configs(
1534 active: &[&dyn Plugin],
1535 config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
1536 relative_files: &[(PathBuf, String)],
1537 root: &Path,
1538 result: &mut AggregatedPluginResult,
1539 regex_errors: &mut Vec<PluginRegexValidationError>,
1540) {
1541 for plugin in active {
1542 let Some(key) = plugin.package_json_config_key() else {
1543 continue;
1544 };
1545 if check_has_config_file(*plugin, config_matchers, relative_files) {
1546 continue;
1547 }
1548 let pkg_path = root.join("package.json");
1549 let Ok(content) = std::fs::read_to_string(&pkg_path) else {
1550 continue;
1551 };
1552 let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
1553 continue;
1554 };
1555 let Some(config_value) = json.get(key) else {
1556 continue;
1557 };
1558 let config_json = serde_json::to_string(config_value).unwrap_or_default();
1559 let fake_path = root.join(format!("{key}.config.json"));
1560 let plugin_result = plugin.resolve_config(&fake_path, &config_json, root);
1561 if plugin_result.is_empty() {
1562 continue;
1563 }
1564 tracing::debug!(
1565 plugin = plugin.name(),
1566 key = key,
1567 "resolved inline package.json config"
1568 );
1569 if let Err(mut errors) =
1570 process_config_result(plugin.name(), plugin_result, result, Some(&pkg_path))
1571 {
1572 regex_errors.append(&mut errors);
1573 }
1574 }
1575}
1576
1577#[derive(Debug)]
1580struct MetaFrameworkWarning {
1581 dedupe_key: &'static str,
1582 message: &'static str,
1583}
1584
1585fn missing_meta_framework_prerequisites(
1595 active_plugins: &[&dyn Plugin],
1596 root: &Path,
1597) -> Vec<MetaFrameworkWarning> {
1598 active_plugins
1599 .iter()
1600 .filter_map(|plugin| match plugin.name() {
1601 "nuxt" if !root.join(".nuxt/tsconfig.json").exists() => Some(MetaFrameworkWarning {
1602 dedupe_key: "meta-prereq::nuxt",
1603 message: "Nuxt project missing .nuxt/tsconfig.json: run `nuxt prepare` \
1604 before fallow for accurate analysis",
1605 }),
1606 "astro" if !root.join(".astro").exists() => Some(MetaFrameworkWarning {
1607 dedupe_key: "meta-prereq::astro",
1608 message: "Astro project missing .astro/ types: run `astro sync` \
1609 before fallow for accurate analysis",
1610 }),
1611 _ => None,
1612 })
1613 .collect()
1614}
1615
1616fn check_meta_framework_prerequisites(active_plugins: &[&dyn Plugin], root: &Path) {
1626 for warning in missing_meta_framework_prerequisites(active_plugins, root) {
1627 if should_warn(warning.dedupe_key.to_owned()) {
1628 tracing::warn!("{}", warning.message);
1629 }
1630 }
1631}
1632
1633fn script_activation_packages(
1634 pkg: &PackageJson,
1635 root: &Path,
1636 all_deps: &[String],
1637 production_mode: bool,
1638) -> FxHashSet<String> {
1639 let Some(pkg_scripts) = pkg.scripts.as_ref() else {
1640 return FxHashSet::default();
1641 };
1642
1643 let scripts_to_analyze = if production_mode {
1644 scripts::filter_production_scripts(pkg_scripts)
1645 } else {
1646 pkg_scripts.clone()
1647 };
1648
1649 let mut nm_roots = Vec::new();
1650 if root.join("node_modules").is_dir() {
1651 nm_roots.push(root);
1652 }
1653 let bin_map = scripts::build_bin_to_package_map(&nm_roots, all_deps);
1654 let dep_set: FxHashSet<String> = all_deps.iter().cloned().collect();
1655 let catalog =
1656 scripts::ScriptCatalog::from_scripts_with_bodies(pkg_scripts, &scripts_to_analyze);
1657
1658 scripts::analyze_scripts_with_dependency_context(
1659 &scripts_to_analyze,
1660 root,
1661 &bin_map,
1662 &dep_set,
1663 &catalog,
1664 scripts::IgnoredCommandEntries::NONE,
1665 )
1666 .used_packages
1667}
1668
1669#[cfg(test)]
1670mod tests;