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}
433
434fn extend_unique(target: &mut Vec<String>, incoming: Vec<String>) {
439 let mut seen: FxHashSet<String> = target.iter().cloned().collect();
440 for item in incoming {
441 if seen.insert(item.clone()) {
442 target.push(item);
443 }
444 }
445}
446
447fn prefix_if_needed(pat: &str, ws_prefix: &str) -> String {
451 if pat.starts_with(ws_prefix) || pat.starts_with('/') {
452 pat.to_string()
453 } else {
454 format!("{ws_prefix}/{pat}")
455 }
456}
457
458impl AggregatedPluginResult {
459 pub(crate) fn apply_workspace_prefix(&mut self, ws_prefix: &str) {
475 for (rule, _) in &mut self.entry_patterns {
476 *rule = rule.prefixed(ws_prefix);
477 }
478 for (pat, _) in &mut self.always_used {
479 *pat = prefix_if_needed(pat, ws_prefix);
480 }
481 for (pat, _) in &mut self.discovered_always_used {
482 *pat = prefix_if_needed(pat, ws_prefix);
483 }
484 for (pat, _) in &mut self.fixture_patterns {
485 *pat = prefix_if_needed(pat, ws_prefix);
486 }
487 for rule in &mut self.used_exports {
488 *rule = rule.prefixed(ws_prefix);
489 }
490 for rule in &mut self.provided_dependencies {
491 *rule = rule.prefixed(ws_prefix);
492 }
493 for source in &mut self.federation_sources {
494 *source = source.prefixed(ws_prefix);
495 }
496 for (_, replacement) in &mut self.path_aliases {
497 *replacement = format!("{ws_prefix}/{replacement}");
498 }
499 }
500
501 pub(crate) fn merge_into(&mut self, other: Self) {
515 let Self {
516 entry_patterns,
517 replaced_entry_plugins: _,
519 entry_point_roles,
520 config_patterns,
521 always_used,
522 used_exports,
523 used_class_members,
524 framework_class_member_contracts,
525 referenced_dependencies,
526 package_referenced_dependencies,
527 discovered_always_used,
528 setup_files,
529 tooling_dependencies,
530 plugin_tooling,
531 script_used_packages,
532 dependency_binaries: _,
533 dev_dependency_names,
534 virtual_module_prefixes,
535 virtual_package_suffixes,
536 generated_import_patterns,
537 generated_type_import_prefixes,
538 path_aliases,
539 auto_imports,
540 active_plugins,
541 fixture_patterns,
542 scss_include_paths,
543 static_dir_mappings,
544 framework_static_dir_mappings,
545 provided_dependencies,
546 config_diagnostics,
547 federation_sources,
548 jsx_import_sources,
549 } = other;
550
551 self.entry_patterns.extend(entry_patterns);
552 for (plugin_name, role) in entry_point_roles {
553 self.entry_point_roles.entry(plugin_name).or_insert(role);
554 }
555 self.config_patterns.extend(config_patterns);
556 self.always_used.extend(always_used);
557 self.used_exports.extend(used_exports);
558 self.used_class_members.extend(used_class_members);
559 for contract in framework_class_member_contracts {
560 if !self.framework_class_member_contracts.contains(&contract) {
561 self.framework_class_member_contracts.push(contract);
562 }
563 }
564 self.referenced_dependencies.extend(referenced_dependencies);
565 self.package_referenced_dependencies
566 .extend(package_referenced_dependencies);
567 self.discovered_always_used.extend(discovered_always_used);
568 self.setup_files.extend(setup_files);
569 self.tooling_dependencies.extend(tooling_dependencies);
570 self.plugin_tooling.extend(plugin_tooling);
571 self.dev_dependency_names.extend(dev_dependency_names);
572 self.script_used_packages.extend(script_used_packages);
573 extend_unique(&mut self.virtual_module_prefixes, virtual_module_prefixes);
574 extend_unique(&mut self.virtual_package_suffixes, virtual_package_suffixes);
575 extend_unique(
576 &mut self.generated_import_patterns,
577 generated_import_patterns,
578 );
579 extend_unique(
580 &mut self.generated_type_import_prefixes,
581 generated_type_import_prefixes,
582 );
583 self.path_aliases.extend(path_aliases);
584 self.auto_imports.extend(auto_imports);
585 extend_unique(&mut self.active_plugins, active_plugins);
586 self.fixture_patterns.extend(fixture_patterns);
587 self.scss_include_paths.extend(scss_include_paths);
588 self.static_dir_mappings.extend(static_dir_mappings);
589 self.framework_static_dir_mappings
590 .extend(framework_static_dir_mappings);
591 self.provided_dependencies.extend(provided_dependencies);
592 self.federation_sources.extend(federation_sources);
593 for rule in jsx_import_sources {
594 if !self.jsx_import_sources.contains(&rule) {
595 self.jsx_import_sources.push(rule);
596 }
597 }
598 for diagnostic in config_diagnostics {
599 if !self.config_diagnostics.contains(&diagnostic) {
600 self.config_diagnostics.push(diagnostic);
601 }
602 }
603 }
604}
605
606impl PluginRegistry {
607 #[must_use]
609 pub fn new(external: Vec<ExternalPluginDef>) -> Self {
610 Self {
611 plugins: builtin::create_builtin_plugins(),
612 external_plugins: external,
613 }
614 }
615
616 #[must_use]
621 pub fn discovery_hidden_dirs(&self, pkg: &PackageJson, root: &Path) -> Vec<String> {
622 let all_deps = pkg.all_dependency_names();
623 let mut seen = FxHashSet::default();
624 let mut dirs = Vec::new();
625
626 for plugin in &self.plugins {
627 if !plugin.is_enabled_with_deps(&all_deps, root) {
628 continue;
629 }
630 for dir in plugin.discovery_hidden_dirs() {
631 if seen.insert(*dir) {
632 dirs.push((*dir).to_string());
633 }
634 }
635 }
636
637 dirs
638 }
639
640 #[cfg(test)]
645 fn run(
646 &self,
647 pkg: &PackageJson,
648 root: &Path,
649 discovered_files: &[PathBuf],
650 ) -> AggregatedPluginResult {
651 self.try_run(pkg, root, discovered_files)
652 .unwrap_or_else(|errors| panic!("{}", format_plugin_regex_errors(&errors)))
653 }
654
655 pub fn try_run(
657 &self,
658 pkg: &PackageJson,
659 root: &Path,
660 discovered_files: &[PathBuf],
661 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
662 self.try_run_with_search_roots(pkg, root, discovered_files, &[root], false, None)
663 }
664
665 #[expect(
668 clippy::too_many_arguments,
669 reason = "public PluginRegistry API; signature is part of the crate surface for embedders"
670 )]
671 pub(crate) fn try_run_with_search_roots(
672 &self,
673 pkg: &PackageJson,
674 root: &Path,
675 discovered_files: &[PathBuf],
676 config_search_roots: &[&Path],
677 production_mode: bool,
678 candidate_index: Option<&ConfigCandidateIndex>,
679 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
680 let _span = tracing::info_span!("run_plugins").entered();
681 let mut result = AggregatedPluginResult::default();
682 let mut regex_errors = Vec::new();
683
684 let PluginRunContext { all_deps, active } = self.prepare_plugin_run_context(
685 pkg,
686 root,
687 discovered_files,
688 production_mode,
689 candidate_index,
690 );
691
692 self.run_plugin_preflight(&active, &all_deps, root, discovered_files);
693
694 for plugin in &active {
695 process_static_patterns(*plugin, root, &mut result);
696 }
697 process_package_json_metadata(&active, pkg, root, &mut result, &mut regex_errors);
698
699 process_external_plugins(
700 &self.external_plugins,
701 &all_deps,
702 root,
703 discovered_files,
704 &mut result,
705 );
706 tooling_evidence::record_plugin_tooling(
707 &tooling_evidence::ToolingEvidenceInput {
708 active: &active,
709 external_plugins: &self.external_plugins,
710 all_deps: &all_deps,
711 roots: &[root],
712 extra_roots: if production_mode {
716 &[]
717 } else {
718 config_search_roots
719 },
720 candidate_index,
721 discovered_files,
722 },
723 &mut result,
724 );
725
726 let config_matchers = compile_config_matchers(&active);
727 let relative_files =
728 compute_relative_files(&config_matchers, &active, discovered_files, root);
729
730 resolve_plugin_config_files(&mut PluginConfigResolutionInput {
731 config_matchers: &config_matchers,
732 relative_files: &relative_files,
733 config_search_roots,
734 production_mode,
735 candidate_index,
736 root,
737 result: &mut result,
738 regex_errors: &mut regex_errors,
739 });
740
741 process_package_json_inline_configs(
742 &active,
743 &config_matchers,
744 &relative_files,
745 root,
746 &mut result,
747 &mut regex_errors,
748 );
749
750 if regex_errors.is_empty() {
751 Ok(result)
752 } else {
753 Err(regex_errors)
754 }
755 }
756
757 #[cfg(test)]
763 fn run_workspace_fast(&self, input: &WorkspacePluginRunInput<'_>) -> AggregatedPluginResult {
764 self.try_run_workspace_fast(input)
765 .unwrap_or_else(|errors| panic!("{}", format_plugin_regex_errors(&errors)))
766 }
767
768 pub(crate) fn try_run_workspace_fast(
774 &self,
775 input: &WorkspacePluginRunInput<'_>,
776 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
777 let _span = tracing::info_span!("run_plugins").entered();
778 let mut result = AggregatedPluginResult::default();
779 let mut regex_errors = Vec::new();
780
781 let all_deps = input.pkg.all_dependency_names();
782 let script_packages =
783 script_activation_packages(input.pkg, input.root, &all_deps, input.production_mode);
784 let workspace_files: Vec<PathBuf> = input
785 .relative_files
786 .iter()
787 .map(|(abs_path, _)| abs_path.clone())
788 .collect();
789
790 let active = self.collect_active_plugins(&PluginActivationInput {
791 pkg: input.pkg,
792 root: input.root,
793 discovered_files: &workspace_files,
794 all_deps: &all_deps,
795 script_packages: &script_packages,
796 candidate_index: input.candidate_index,
797 });
798
799 log_active_plugins(&active);
800
801 self.emit_silent_fail_diagnostics(&active, &all_deps, input.root, &workspace_files);
802
803 process_external_plugins(
804 &self.external_plugins,
805 &all_deps,
806 input.root,
807 &workspace_files,
808 &mut result,
809 );
810
811 if active.is_empty() && result.active_plugins.is_empty() {
812 return Ok(result);
813 }
814
815 process_workspace_active_plugins(&active, input, &mut result, &mut regex_errors);
816 let evidence_roots: &[&Path] = if input.root == input.project_root {
818 &[input.root]
819 } else {
820 &[input.root, input.project_root]
821 };
822 tooling_evidence::record_plugin_tooling(
823 &tooling_evidence::ToolingEvidenceInput {
824 active: &active,
825 external_plugins: &self.external_plugins,
826 all_deps: &all_deps,
827 roots: evidence_roots,
828 extra_roots: &[],
829 candidate_index: input.candidate_index,
830 discovered_files: &workspace_files,
831 },
832 &mut result,
833 );
834 resolve_workspace_plugin_configs(&active, input, &mut result, &mut regex_errors);
835
836 if regex_errors.is_empty() {
837 Ok(result)
838 } else {
839 Err(regex_errors)
840 }
841 }
842
843 #[must_use]
846 pub(crate) fn precompile_config_matchers(
847 &self,
848 ) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
849 self.plugins
850 .iter()
851 .filter(|p| !p.config_patterns().is_empty())
852 .map(|p| (p.as_ref(), cached_plugin_config_matchers(p.as_ref())))
853 .collect()
854 }
855
856 pub(crate) fn resolve_script_config_files(
867 &self,
868 config_files: &[scripts::BinaryConfigFile],
869 package_root: &Path,
870 ws_prefix: &str,
871 ) -> AggregatedPluginResult {
872 let mut aggregated = AggregatedPluginResult::default();
873 for config_file in config_files {
874 let Some(rel_path) =
875 scripts::normalize_script_entry_pattern(ws_prefix, &config_file.path)
876 else {
877 continue;
878 };
879 for plugin in &self.plugins {
880 let plugin = plugin.as_ref();
881 if !plugin
882 .script_config_binaries()
883 .contains(&config_file.binary.as_str())
884 || cached_plugin_config_matchers(plugin)
885 .iter()
886 .any(|m| m.is_match(rel_path.as_str()))
887 {
888 continue;
889 }
890 let abs_path = package_root.join(config_file.path.trim_start_matches("./"));
891 let Ok(source) = std::fs::read_to_string(&abs_path) else {
892 continue;
893 };
894 let mut file_result = AggregatedPluginResult::default();
895 file_result
896 .entry_point_roles
897 .insert(plugin.name().to_string(), plugin.entry_point_role());
898 let plugin_result = plugin.resolve_config(&abs_path, &source, package_root);
899 if let Err(errors) = process_config_result(
900 plugin.name(),
901 plugin_result,
902 &mut file_result,
903 Some(&abs_path),
904 ) {
905 for error in errors {
906 tracing::warn!("{error}");
907 }
908 continue;
909 }
910 if !ws_prefix.is_empty() {
911 file_result.apply_workspace_prefix(ws_prefix);
912 }
913 file_result.used_exports.push(PluginUsedExportRule::new(
915 plugin.name().to_string(),
916 super::UsedExportRule::new(rel_path.clone(), ["default"]),
917 ));
918 aggregated.merge_into(file_result);
919 }
920 }
921 aggregated
922 }
923}
924
925fn process_workspace_active_plugins(
926 active: &[&dyn Plugin],
927 input: &WorkspacePluginRunInput<'_>,
928 result: &mut AggregatedPluginResult,
929 regex_errors: &mut Vec<PluginRegexValidationError>,
930) {
931 for plugin in active {
932 process_static_patterns(*plugin, input.root, result);
933 }
934 process_package_json_metadata(active, input.pkg, input.root, result, regex_errors);
935}
936
937fn resolve_workspace_plugin_configs(
938 active: &[&dyn Plugin],
939 input: &WorkspacePluginRunInput<'_>,
940 result: &mut AggregatedPluginResult,
941 regex_errors: &mut Vec<PluginRegexValidationError>,
942) {
943 let workspace_matchers = select_workspace_matchers(
944 input.precompiled_config_matchers,
945 active,
946 input.skip_config_plugins,
947 );
948
949 let mut resolved_ws_plugins: FxHashSet<&str> = FxHashSet::default();
950 for (plugin, matchers) in &workspace_matchers {
951 resolve_plugin_matching_files(&mut PluginMatchingFilesInput {
952 plugin: *plugin,
953 matchers,
954 relative_files: input.relative_files,
955 root: input.root,
956 result,
957 regex_errors,
958 resolved_plugins: &mut resolved_ws_plugins,
959 });
960 }
961
962 load_workspace_filesystem_configs(&mut WorkspaceFsConfigInput {
963 workspace_matchers: &workspace_matchers,
964 resolved_ws_plugins: &resolved_ws_plugins,
965 root: input.root,
966 project_root: input.project_root,
967 production_mode: input.production_mode,
968 candidate_index: input.candidate_index,
969 result,
970 regex_errors,
971 });
972}
973
974impl Default for PluginRegistry {
975 fn default() -> Self {
976 Self::new(vec![])
977 }
978}
979
980impl PluginRegistry {
981 fn prepare_plugin_run_context<'a>(
982 &'a self,
983 pkg: &PackageJson,
984 root: &Path,
985 discovered_files: &[PathBuf],
986 production_mode: bool,
987 candidate_index: Option<&ConfigCandidateIndex>,
988 ) -> PluginRunContext<'a> {
989 let all_deps = pkg.all_dependency_names();
990 let script_packages = script_activation_packages(pkg, root, &all_deps, production_mode);
991 let active = self.collect_active_plugins(&PluginActivationInput {
992 pkg,
993 root,
994 discovered_files,
995 all_deps: &all_deps,
996 script_packages: &script_packages,
997 candidate_index,
998 });
999
1000 PluginRunContext { all_deps, active }
1001 }
1002
1003 fn run_plugin_preflight(
1004 &self,
1005 active: &[&dyn Plugin],
1006 all_deps: &[String],
1007 root: &Path,
1008 discovered_files: &[PathBuf],
1009 ) {
1010 log_active_plugins(active);
1011 check_meta_framework_prerequisites(active, root);
1012 self.emit_silent_fail_diagnostics(active, all_deps, root, discovered_files);
1013 }
1014
1015 fn collect_active_plugins<'a>(
1018 &'a self,
1019 activation: &PluginActivationInput<'_>,
1020 ) -> Vec<&'a dyn Plugin> {
1021 self.plugins
1022 .iter()
1023 .filter(|p| {
1024 p.is_enabled_with_files(
1025 activation.all_deps,
1026 activation.root,
1027 activation.discovered_files,
1028 activation.candidate_index,
1029 ) || p.is_enabled_with_scripts(activation.script_packages, activation.root)
1030 || p.is_enabled_with_package_json(activation.pkg, activation.root)
1031 })
1032 .map(AsRef::as_ref)
1033 .collect()
1034 }
1035
1036 fn emit_silent_fail_diagnostics(
1045 &self,
1046 active: &[&dyn Plugin],
1047 all_deps: &[String],
1048 root: &Path,
1049 discovered_files: &[PathBuf],
1050 ) {
1051 let active_external: Vec<&ExternalPluginDef> = self
1052 .external_plugins
1053 .iter()
1054 .filter(|ext| is_external_plugin_active(ext, all_deps, root, discovered_files))
1055 .collect();
1056 let mut diagnostics = detect_pattern_collisions(active, &active_external);
1057 diagnostics.extend(detect_enabler_typos(&self.external_plugins, all_deps));
1058 emit_plugin_diagnostics(&diagnostics);
1059 }
1060}
1061
1062fn plugin_warn_dedupe() -> &'static std::sync::Mutex<FxHashSet<String>> {
1069 static WARNED: std::sync::OnceLock<std::sync::Mutex<FxHashSet<String>>> =
1070 std::sync::OnceLock::new();
1071 WARNED.get_or_init(|| std::sync::Mutex::new(FxHashSet::default()))
1072}
1073
1074struct PluginConfigResolutionInput<'a> {
1075 config_matchers: &'a [(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
1076 relative_files: &'a [(PathBuf, String)],
1077 config_search_roots: &'a [&'a Path],
1078 production_mode: bool,
1079 candidate_index: Option<&'a ConfigCandidateIndex>,
1080 root: &'a Path,
1081 result: &'a mut AggregatedPluginResult,
1082 regex_errors: &'a mut Vec<PluginRegexValidationError>,
1083}
1084
1085fn select_workspace_matchers<'a>(
1088 precompiled_config_matchers: &[(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
1089 active: &[&dyn Plugin],
1090 skip_config_plugins: &FxHashSet<&str>,
1091) -> Vec<(&'a dyn Plugin, Vec<globset::GlobMatcher>)> {
1092 let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
1093 precompiled_config_matchers
1094 .iter()
1095 .filter(|(p, _)| {
1096 active_names.contains(p.name())
1097 && (!skip_config_plugins.contains(p.name())
1098 || must_parse_workspace_config_when_root_active(p.name()))
1099 })
1100 .map(|(plugin, matchers)| (*plugin, matchers.clone()))
1101 .collect()
1102}
1103
1104struct WorkspaceFsConfigInput<'a> {
1105 workspace_matchers: &'a [(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
1106 resolved_ws_plugins: &'a FxHashSet<&'a str>,
1107 root: &'a Path,
1108 project_root: &'a Path,
1109 production_mode: bool,
1110 candidate_index: Option<&'a ConfigCandidateIndex>,
1111 result: &'a mut AggregatedPluginResult,
1112 regex_errors: &'a mut Vec<PluginRegexValidationError>,
1113}
1114
1115fn load_workspace_filesystem_configs(input: &mut WorkspaceFsConfigInput<'_>) {
1118 let search_roots: &[&Path] = if input.root == input.project_root {
1119 &[input.root]
1120 } else {
1121 &[input.root, input.project_root]
1122 };
1123 let ws_json_configs = discover_config_files(
1124 input.workspace_matchers,
1125 input.resolved_ws_plugins,
1126 search_roots,
1127 input.production_mode,
1128 input.candidate_index,
1129 );
1130 for (abs_path, plugin) in &ws_json_configs {
1131 let Ok(source) = std::fs::read_to_string(abs_path) else {
1132 continue;
1133 };
1134 let plugin_result = plugin.resolve_config(abs_path, &source, input.root);
1135 if plugin_result.is_empty() {
1136 continue;
1137 }
1138 let rel = abs_path
1139 .strip_prefix(input.project_root)
1140 .map(|p| p.to_string_lossy())
1141 .unwrap_or_default();
1142 tracing::debug!(
1143 plugin = plugin.name(),
1144 config = %rel,
1145 entries = plugin_result.entry_patterns.len(),
1146 deps = plugin_result.referenced_dependencies.len(),
1147 "resolved config (workspace filesystem fallback)"
1148 );
1149 if let Err(mut errors) =
1150 process_config_result(plugin.name(), plugin_result, input.result, Some(abs_path))
1151 {
1152 input.regex_errors.append(&mut errors);
1153 }
1154 }
1155}
1156
1157fn resolve_plugin_config_files(input: &mut PluginConfigResolutionInput<'_>) {
1158 if input.config_matchers.is_empty() {
1159 return;
1160 }
1161
1162 let mut resolved_plugins: FxHashSet<&str> = FxHashSet::default();
1163 for (plugin, matchers) in input.config_matchers {
1164 resolve_plugin_matching_files(&mut PluginMatchingFilesInput {
1165 plugin: *plugin,
1166 matchers,
1167 relative_files: input.relative_files,
1168 root: input.root,
1169 result: input.result,
1170 regex_errors: input.regex_errors,
1171 resolved_plugins: &mut resolved_plugins,
1172 });
1173 }
1174
1175 let json_configs = discover_config_files(
1176 input.config_matchers,
1177 &resolved_plugins,
1178 input.config_search_roots,
1179 input.production_mode,
1180 input.candidate_index,
1181 );
1182 for (abs_path, plugin) in &json_configs {
1183 resolve_plugin_filesystem_config(
1184 *plugin,
1185 abs_path,
1186 input.root,
1187 input.result,
1188 input.regex_errors,
1189 );
1190 }
1191}
1192
1193struct PluginMatchingFilesInput<'plugins, 'data, 'state> {
1194 plugin: &'plugins dyn Plugin,
1195 matchers: &'data [globset::GlobMatcher],
1196 relative_files: &'data [(PathBuf, String)],
1197 root: &'data Path,
1198 result: &'state mut AggregatedPluginResult,
1199 regex_errors: &'state mut Vec<PluginRegexValidationError>,
1200 resolved_plugins: &'state mut FxHashSet<&'plugins str>,
1201}
1202
1203fn resolve_plugin_matching_files(input: &mut PluginMatchingFilesInput<'_, '_, '_>) {
1204 use rayon::prelude::*;
1205
1206 let plugin_hits: Vec<&PathBuf> = input
1207 .relative_files
1208 .par_iter()
1209 .filter_map(|(abs_path, rel_path)| {
1210 input
1211 .matchers
1212 .iter()
1213 .any(|m| m.is_match(rel_path.as_str()))
1214 .then_some(abs_path)
1215 })
1216 .collect();
1217 for abs_path in plugin_hits {
1218 let Ok(source) = std::fs::read_to_string(abs_path) else {
1219 continue;
1220 };
1221 let plugin_result = input.plugin.resolve_config(abs_path, &source, input.root);
1222 if plugin_result.is_empty() {
1223 continue;
1224 }
1225 input.resolved_plugins.insert(input.plugin.name());
1226 process_resolved_plugin_config(ResolvedPluginConfigInput {
1227 plugin: input.plugin,
1228 abs_path,
1229 plugin_result,
1230 result: input.result,
1231 regex_errors: input.regex_errors,
1232 message: "resolved config",
1233 config_display: abs_path.display(),
1234 });
1235 }
1236}
1237
1238fn resolve_plugin_filesystem_config(
1239 plugin: &dyn Plugin,
1240 abs_path: &Path,
1241 root: &Path,
1242 result: &mut AggregatedPluginResult,
1243 regex_errors: &mut Vec<PluginRegexValidationError>,
1244) {
1245 let Ok(source) = std::fs::read_to_string(abs_path) else {
1246 return;
1247 };
1248 let plugin_result = plugin.resolve_config(abs_path, &source, root);
1249 if plugin_result.is_empty() {
1250 return;
1251 }
1252 let rel = abs_path
1253 .strip_prefix(root)
1254 .map(|p| p.to_string_lossy())
1255 .unwrap_or_default();
1256 process_resolved_plugin_config(ResolvedPluginConfigInput {
1257 plugin,
1258 abs_path,
1259 plugin_result,
1260 result,
1261 regex_errors,
1262 message: "resolved config (filesystem fallback)",
1263 config_display: rel,
1264 });
1265}
1266
1267struct ResolvedPluginConfigInput<'a, D> {
1268 plugin: &'a dyn Plugin,
1269 abs_path: &'a Path,
1270 plugin_result: PluginResult,
1271 result: &'a mut AggregatedPluginResult,
1272 regex_errors: &'a mut Vec<PluginRegexValidationError>,
1273 message: &'static str,
1274 config_display: D,
1275}
1276
1277fn process_resolved_plugin_config(input: ResolvedPluginConfigInput<'_, impl std::fmt::Display>) {
1278 tracing::debug!(
1279 plugin = input.plugin.name(),
1280 config = %input.config_display,
1281 entries = input.plugin_result.entry_patterns.len(),
1282 deps = input.plugin_result.referenced_dependencies.len(),
1283 input.message
1284 );
1285 if let Err(mut errors) = process_config_result(
1286 input.plugin.name(),
1287 input.plugin_result,
1288 input.result,
1289 Some(input.abs_path),
1290 ) {
1291 input.regex_errors.append(&mut errors);
1292 }
1293}
1294
1295fn should_warn(key: String) -> bool {
1299 plugin_warn_dedupe()
1300 .lock()
1301 .map_or(true, |mut set| set.insert(key))
1302}
1303
1304#[derive(Debug, Clone, PartialEq, Eq)]
1311pub(crate) enum PluginDiagnostic {
1312 PatternCollision {
1314 pattern: String,
1315 owners: Vec<String>,
1316 },
1317 EnablerTypo {
1320 plugin: String,
1321 enabler: String,
1322 suggestion: String,
1323 },
1324}
1325
1326fn detect_pattern_collisions(
1352 builtin_active: &[&dyn Plugin],
1353 external_active: &[&ExternalPluginDef],
1354) -> Vec<PluginDiagnostic> {
1355 use rustc_hash::FxHashMap;
1356
1357 let mut pattern_owners: FxHashMap<String, (Vec<String>, FxHashSet<String>)> =
1358 FxHashMap::default();
1359
1360 let record = |pattern_owners: &mut FxHashMap<_, (Vec<String>, FxHashSet<String>)>,
1361 pattern: String,
1362 name: String| {
1363 let (list, seen) = pattern_owners.entry(pattern).or_default();
1364 if seen.insert(name.clone()) {
1365 list.push(name);
1366 }
1367 };
1368
1369 for plugin in builtin_active {
1370 for pat in plugin.config_patterns() {
1371 record(
1372 &mut pattern_owners,
1373 (*pat).to_string(),
1374 plugin.name().to_string(),
1375 );
1376 }
1377 }
1378 for ext in external_active {
1379 for pat in &ext.config_patterns {
1380 record(&mut pattern_owners, pat.clone(), ext.name.clone());
1381 }
1382 }
1383
1384 let builtin_names: FxHashSet<&str> = builtin_active.iter().map(|p| p.name()).collect();
1391
1392 let mut findings: Vec<PluginDiagnostic> = pattern_owners
1393 .into_iter()
1394 .filter_map(|(pattern, (owners, _seen))| {
1395 if owners.len() < 2 || owners.iter().all(|o| builtin_names.contains(o.as_str())) {
1396 None
1397 } else {
1398 Some(PluginDiagnostic::PatternCollision { pattern, owners })
1399 }
1400 })
1401 .collect();
1402 findings.sort_unstable_by(|a, b| match (a, b) {
1403 (
1404 PluginDiagnostic::PatternCollision { pattern: ap, .. },
1405 PluginDiagnostic::PatternCollision { pattern: bp, .. },
1406 ) => ap.cmp(bp),
1407 _ => std::cmp::Ordering::Equal,
1408 });
1409 findings
1410}
1411
1412fn detect_enabler_typos(
1427 external_plugins: &[ExternalPluginDef],
1428 all_deps: &[String],
1429) -> Vec<PluginDiagnostic> {
1430 let mut findings = Vec::new();
1431
1432 for ext in external_plugins {
1433 if ext.detection.is_some() || ext.enablers.is_empty() {
1434 continue;
1435 }
1436
1437 let any_match = ext.enablers.iter().any(|enabler| {
1438 if enabler.ends_with('/') {
1439 all_deps.iter().any(|d| d.starts_with(enabler))
1440 } else {
1441 all_deps.iter().any(|d| d == enabler)
1442 }
1443 });
1444 if any_match {
1445 continue;
1446 }
1447
1448 for enabler in &ext.enablers {
1449 let candidates = all_deps.iter().map(String::as_str);
1450 let Some(suggestion) = fallow_config::levenshtein::closest_match(enabler, candidates)
1451 else {
1452 continue;
1453 };
1454
1455 findings.push(PluginDiagnostic::EnablerTypo {
1456 plugin: ext.name.clone(),
1457 enabler: enabler.clone(),
1458 suggestion: suggestion.to_string(),
1459 });
1460 }
1461 }
1462
1463 findings
1464}
1465
1466fn emit_plugin_diagnostics(findings: &[PluginDiagnostic]) {
1469 for finding in findings {
1470 match finding {
1471 PluginDiagnostic::PatternCollision { pattern, owners } => {
1472 let key = format!("collision::{pattern}::{owners:?}");
1473 if !should_warn(key) {
1474 continue;
1475 }
1476 let winner = &owners[0];
1477 let others = owners[1..].join(", ");
1478 tracing::warn!(
1479 "plugin config_patterns collision: identical pattern \
1480 '{pattern}' is claimed by plugins [{joined}]; '{winner}' \
1481 runs first (registration order), others ({others}) \
1482 follow. Rename one of the patterns or remove the \
1483 duplicate plugin to make resolution explicit. A future \
1484 release may reject identical-pattern collisions.",
1485 joined = owners.join(", "),
1486 );
1487 }
1488 PluginDiagnostic::EnablerTypo {
1489 plugin,
1490 enabler,
1491 suggestion,
1492 } => {
1493 let key = format!("enabler::{plugin}::{enabler}");
1494 if !should_warn(key) {
1495 continue;
1496 }
1497 tracing::warn!(
1498 "plugin '{plugin}' enabler '{enabler}' does not match any \
1499 dependency in package.json; did you mean '{suggestion}'? \
1500 The plugin will not activate. A future release may reject \
1501 unmatched enablers.",
1502 );
1503 }
1504 }
1505 }
1506}
1507
1508fn process_package_json_inline_configs(
1513 active: &[&dyn Plugin],
1514 config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
1515 relative_files: &[(PathBuf, String)],
1516 root: &Path,
1517 result: &mut AggregatedPluginResult,
1518 regex_errors: &mut Vec<PluginRegexValidationError>,
1519) {
1520 for plugin in active {
1521 let Some(key) = plugin.package_json_config_key() else {
1522 continue;
1523 };
1524 if check_has_config_file(*plugin, config_matchers, relative_files) {
1525 continue;
1526 }
1527 let pkg_path = root.join("package.json");
1528 let Ok(content) = std::fs::read_to_string(&pkg_path) else {
1529 continue;
1530 };
1531 let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
1532 continue;
1533 };
1534 let Some(config_value) = json.get(key) else {
1535 continue;
1536 };
1537 let config_json = serde_json::to_string(config_value).unwrap_or_default();
1538 let fake_path = root.join(format!("{key}.config.json"));
1539 let plugin_result = plugin.resolve_config(&fake_path, &config_json, root);
1540 if plugin_result.is_empty() {
1541 continue;
1542 }
1543 tracing::debug!(
1544 plugin = plugin.name(),
1545 key = key,
1546 "resolved inline package.json config"
1547 );
1548 if let Err(mut errors) =
1549 process_config_result(plugin.name(), plugin_result, result, Some(&pkg_path))
1550 {
1551 regex_errors.append(&mut errors);
1552 }
1553 }
1554}
1555
1556#[derive(Debug)]
1559struct MetaFrameworkWarning {
1560 dedupe_key: &'static str,
1561 message: &'static str,
1562}
1563
1564fn missing_meta_framework_prerequisites(
1574 active_plugins: &[&dyn Plugin],
1575 root: &Path,
1576) -> Vec<MetaFrameworkWarning> {
1577 active_plugins
1578 .iter()
1579 .filter_map(|plugin| match plugin.name() {
1580 "nuxt" if !root.join(".nuxt/tsconfig.json").exists() => Some(MetaFrameworkWarning {
1581 dedupe_key: "meta-prereq::nuxt",
1582 message: "Nuxt project missing .nuxt/tsconfig.json: run `nuxt prepare` \
1583 before fallow for accurate analysis",
1584 }),
1585 "astro" if !root.join(".astro").exists() => Some(MetaFrameworkWarning {
1586 dedupe_key: "meta-prereq::astro",
1587 message: "Astro project missing .astro/ types: run `astro sync` \
1588 before fallow for accurate analysis",
1589 }),
1590 _ => None,
1591 })
1592 .collect()
1593}
1594
1595fn check_meta_framework_prerequisites(active_plugins: &[&dyn Plugin], root: &Path) {
1605 for warning in missing_meta_framework_prerequisites(active_plugins, root) {
1606 if should_warn(warning.dedupe_key.to_owned()) {
1607 tracing::warn!("{}", warning.message);
1608 }
1609 }
1610}
1611
1612fn script_activation_packages(
1613 pkg: &PackageJson,
1614 root: &Path,
1615 all_deps: &[String],
1616 production_mode: bool,
1617) -> FxHashSet<String> {
1618 let Some(pkg_scripts) = pkg.scripts.as_ref() else {
1619 return FxHashSet::default();
1620 };
1621
1622 let scripts_to_analyze = if production_mode {
1623 scripts::filter_production_scripts(pkg_scripts)
1624 } else {
1625 pkg_scripts.clone()
1626 };
1627
1628 let mut nm_roots = Vec::new();
1629 if root.join("node_modules").is_dir() {
1630 nm_roots.push(root);
1631 }
1632 let bin_map = scripts::build_bin_to_package_map(&nm_roots, all_deps);
1633 let dep_set: FxHashSet<String> = all_deps.iter().cloned().collect();
1634 let catalog =
1635 scripts::ScriptCatalog::from_scripts_with_bodies(pkg_scripts, &scripts_to_analyze);
1636
1637 scripts::analyze_scripts_with_dependency_context(
1638 &scripts_to_analyze,
1639 root,
1640 &bin_map,
1641 &dep_set,
1642 &catalog,
1643 scripts::IgnoredCommandEntries::NONE,
1644 )
1645 .used_packages
1646}
1647
1648#[cfg(test)]
1649mod tests;