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, PackageJson, UsedClassMemberRule,
10};
11
12use crate::scripts;
13
14use super::{PathRule, Plugin, PluginResult, PluginUsedExportRule, ProvidedDependencyRule};
15
16pub(crate) mod builtin;
17mod helpers;
18mod tooling_evidence;
19
20pub(crate) use tooling_evidence::find_config_file;
21
22#[must_use]
27pub fn builtin_plugin_names() -> Vec<&'static str> {
28 builtin::create_builtin_plugins()
29 .iter()
30 .map(|plugin| plugin.name())
31 .collect()
32}
33
34#[must_use]
39pub fn builtin_config_patterns() -> Vec<&'static str> {
40 let mut patterns: Vec<&'static str> = builtin::create_builtin_plugins()
41 .iter()
42 .flat_map(|plugin| plugin.config_patterns().iter().copied())
43 .collect();
44 patterns.sort_unstable();
45 patterns.dedup();
46 patterns
47}
48
49pub use helpers::ConfigCandidateIndex;
50pub use helpers::is_external_plugin_active;
51use helpers::{
52 check_has_config_file, discover_config_files, prepare_config_pattern, process_config_result,
53 process_external_plugins, process_package_json_metadata, process_static_patterns,
54};
55
56fn must_parse_workspace_config_when_root_active(plugin_name: &str) -> bool {
57 matches!(
58 plugin_name,
59 "eslint" | "docusaurus" | "jest" | "storybook" | "tanstack-router" | "vitest"
60 )
61}
62
63fn compile_config_matchers<'a>(
64 active: &[&'a dyn Plugin],
65) -> Vec<(&'a dyn Plugin, Vec<globset::GlobMatcher>)> {
66 active
67 .iter()
68 .filter(|plugin| !plugin.config_patterns().is_empty())
69 .map(|plugin| (*plugin, cached_plugin_config_matchers(*plugin)))
70 .collect()
71}
72
73fn compile_plugin_config_matchers(plugin: &dyn Plugin) -> Vec<globset::GlobMatcher> {
74 plugin
75 .config_patterns()
76 .iter()
77 .filter_map(|pattern| {
78 let prepared = prepare_config_pattern(pattern);
79 globset::Glob::new(&prepared)
80 .ok()
81 .map(|glob| glob.compile_matcher())
82 })
83 .collect()
84}
85
86struct CachedPluginConfigMatchers {
87 patterns: &'static [&'static str],
88 matchers: Vec<globset::GlobMatcher>,
89}
90
91#[derive(Default)]
92struct PluginConfigMatcherCache {
93 by_name: RwLock<FxHashMap<&'static str, Vec<CachedPluginConfigMatchers>>>,
94}
95
96impl PluginConfigMatcherCache {
97 fn get_or_compile(&self, plugin: &dyn Plugin) -> Vec<globset::GlobMatcher> {
98 let patterns = plugin.config_patterns();
99 let cached = self
100 .by_name
101 .read()
102 .unwrap_or_else(std::sync::PoisonError::into_inner)
103 .get(plugin.name())
104 .and_then(|variants| {
105 variants
106 .iter()
107 .find(|entry| entry.patterns == patterns)
108 .map(|entry| entry.matchers.clone())
109 });
110 if let Some(matchers) = cached {
111 return matchers;
112 }
113
114 let matchers = compile_plugin_config_matchers(plugin);
115 {
116 let mut by_name = self
117 .by_name
118 .write()
119 .unwrap_or_else(std::sync::PoisonError::into_inner);
120 let variants = by_name.entry(plugin.name()).or_default();
121 if let Some(entry) = variants.iter().find(|entry| entry.patterns == patterns) {
122 return entry.matchers.clone();
123 }
124 variants.push(CachedPluginConfigMatchers {
125 patterns,
126 matchers: matchers.clone(),
127 });
128 drop(by_name);
129 }
130 matchers
131 }
132}
133
134fn cached_plugin_config_matchers(plugin: &dyn Plugin) -> Vec<globset::GlobMatcher> {
135 static MATCHERS: OnceLock<PluginConfigMatcherCache> = OnceLock::new();
136 MATCHERS
137 .get_or_init(PluginConfigMatcherCache::default)
138 .get_or_compile(plugin)
139}
140
141fn log_active_plugins(active: &[&dyn Plugin]) {
143 tracing::info!(
144 plugins = active
145 .iter()
146 .map(|p| p.name())
147 .collect::<Vec<_>>()
148 .join(", "),
149 "active plugins"
150 );
151}
152
153fn compute_relative_files(
157 config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
158 active: &[&dyn Plugin],
159 discovered_files: &[PathBuf],
160 root: &Path,
161) -> Vec<(PathBuf, String)> {
162 use rayon::prelude::*;
163 let needs_relative_files =
164 !config_matchers.is_empty() || active.iter().any(|p| p.package_json_config_key().is_some());
165 if !needs_relative_files {
166 return Vec::new();
167 }
168 discovered_files
169 .par_iter()
170 .map(|f| {
171 let rel = f
172 .strip_prefix(root)
173 .unwrap_or(f)
174 .to_string_lossy()
175 .into_owned();
176 (f.clone(), rel)
177 })
178 .collect()
179}
180
181pub struct PluginRegistry {
183 plugins: Vec<Box<dyn Plugin>>,
184 external_plugins: Vec<ExternalPluginDef>,
185}
186
187pub(crate) struct WorkspacePluginRunInput<'a> {
189 pub(crate) pkg: &'a PackageJson,
190 pub(crate) root: &'a Path,
191 pub(crate) project_root: &'a Path,
192 pub(crate) precompiled_config_matchers: &'a [(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
193 pub(crate) relative_files: &'a [(PathBuf, String)],
194 pub(crate) skip_config_plugins: &'a FxHashSet<&'a str>,
195 pub(crate) production_mode: bool,
196 pub(crate) candidate_index: Option<&'a ConfigCandidateIndex>,
197}
198
199struct PluginRunContext<'a> {
200 all_deps: Vec<String>,
201 active: Vec<&'a dyn Plugin>,
202}
203
204struct PluginActivationInput<'a> {
206 pkg: &'a PackageJson,
207 root: &'a Path,
208 discovered_files: &'a [PathBuf],
209 all_deps: &'a [String],
210 script_packages: &'a FxHashSet<String>,
211 candidate_index: Option<&'a ConfigCandidateIndex>,
212}
213
214#[derive(Debug, Clone, PartialEq, Eq)]
216pub struct PluginRegexValidationError {
217 plugin_name: String,
218 config_path: Option<PathBuf>,
219 rule_kind: &'static str,
220 field: &'static str,
221 rule_pattern: String,
222 regex_pattern: String,
223 source: String,
224}
225
226impl PluginRegexValidationError {
227 fn new(input: PluginRegexValidationErrorInput<'_>) -> Self {
228 Self {
229 plugin_name: input.plugin_name.to_owned(),
230 config_path: input.config_path.map(Path::to_path_buf),
231 rule_kind: input.rule_kind,
232 field: input.field,
233 rule_pattern: input.rule_pattern.to_owned(),
234 regex_pattern: input.regex_pattern.to_owned(),
235 source: input.source.to_string(),
236 }
237 }
238}
239
240#[derive(Clone, Copy)]
241pub(crate) struct PluginRegexValidationErrorInput<'a> {
242 plugin_name: &'a str,
243 config_path: Option<&'a Path>,
244 rule_kind: &'static str,
245 field: &'static str,
246 rule_pattern: &'a str,
247 regex_pattern: &'a str,
248 source: &'a regex::Error,
249}
250
251impl fmt::Display for PluginRegexValidationError {
252 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
253 let location = self
254 .config_path
255 .as_ref()
256 .map(|path| format!(" in {}", path.display()))
257 .unwrap_or_default();
258 write!(
259 f,
260 "plugin '{}'{}: invalid regex '{}' in {}.{} for path rule '{}': {}",
261 self.plugin_name,
262 location,
263 self.regex_pattern,
264 self.rule_kind,
265 self.field,
266 self.rule_pattern,
267 self.source
268 )
269 }
270}
271
272#[must_use]
273pub(crate) fn format_plugin_regex_errors(errors: &[PluginRegexValidationError]) -> String {
274 let joined = errors
275 .iter()
276 .map(ToString::to_string)
277 .collect::<Vec<_>>()
278 .join("\n - ");
279 format!(
280 "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."
281 )
282}
283
284#[derive(Debug, Clone, Default, PartialEq, Eq)]
292pub struct PluginToolingDependencies {
293 pub plugin: String,
295 pub dependencies: Vec<String>,
297 pub references: Vec<String>,
301 pub own_config: Option<PathBuf>,
304}
305
306#[derive(Debug, Clone, Copy, PartialEq, Eq)]
308pub enum PluginToolingEvidence<'a> {
309 OwnConfig(&'a Path),
311 Reference(&'a str),
313}
314
315impl PluginToolingDependencies {
316 #[must_use]
320 pub fn evidence<'a>(
321 &'a self,
322 script_used_packages: &FxHashSet<String>,
323 ) -> Option<PluginToolingEvidence<'a>> {
324 if let Some(config) = &self.own_config {
325 return Some(PluginToolingEvidence::OwnConfig(config));
326 }
327 self.references
328 .iter()
329 .find(|name| script_used_packages.contains(name.as_str()))
330 .map(|name| PluginToolingEvidence::Reference(name))
331 }
332}
333
334#[derive(Debug, Clone, Default)]
336pub struct AggregatedPluginResult {
337 pub entry_patterns: Vec<(PathRule, String)>,
339 pub replaced_entry_plugins: FxHashSet<String>,
344 pub entry_point_roles: rustc_hash::FxHashMap<String, EntryPointRole>,
346 pub config_patterns: Vec<String>,
348 pub always_used: Vec<(String, String)>,
350 pub used_exports: Vec<PluginUsedExportRule>,
352 pub used_class_members: Vec<UsedClassMemberRule>,
356 pub framework_class_member_contracts: Vec<fallow_types::semantic::SemanticFrameworkContract>,
358 pub referenced_dependencies: Vec<String>,
360 pub package_referenced_dependencies: Vec<(PathBuf, String)>,
362 pub discovered_always_used: Vec<(String, String)>,
364 pub setup_files: Vec<(PathBuf, String)>,
366 pub tooling_dependencies: Vec<String>,
370 pub plugin_tooling: Vec<PluginToolingDependencies>,
374 pub script_used_packages: FxHashSet<String>,
376 pub dependency_binaries: crate::scripts::DependencyBinaries,
380 pub dev_dependency_names: FxHashSet<String>,
385 pub virtual_module_prefixes: Vec<String>,
388 pub virtual_package_suffixes: Vec<String>,
391 pub generated_import_patterns: Vec<String>,
394 pub generated_type_import_prefixes: Vec<String>,
397 pub path_aliases: Vec<(String, String)>,
400 pub auto_imports: Vec<AutoImportRule>,
404 pub active_plugins: Vec<String>,
406 pub fixture_patterns: Vec<(String, String)>,
408 pub scss_include_paths: Vec<PathBuf>,
413 pub static_dir_mappings: Vec<(PathBuf, String)>,
415 pub framework_static_dir_mappings: Vec<(PathBuf, String)>,
418 pub provided_dependencies: Vec<ProvidedDependencyRule>,
420 pub config_diagnostics: Vec<super::PluginConfigDiagnostic>,
424 pub federation_sources: Vec<super::FederationSource>,
427}
428
429fn extend_unique(target: &mut Vec<String>, incoming: Vec<String>) {
434 let mut seen: FxHashSet<String> = target.iter().cloned().collect();
435 for item in incoming {
436 if seen.insert(item.clone()) {
437 target.push(item);
438 }
439 }
440}
441
442fn prefix_if_needed(pat: &str, ws_prefix: &str) -> String {
446 if pat.starts_with(ws_prefix) || pat.starts_with('/') {
447 pat.to_string()
448 } else {
449 format!("{ws_prefix}/{pat}")
450 }
451}
452
453impl AggregatedPluginResult {
454 pub(crate) fn apply_workspace_prefix(&mut self, ws_prefix: &str) {
470 for (rule, _) in &mut self.entry_patterns {
471 *rule = rule.prefixed(ws_prefix);
472 }
473 for (pat, _) in &mut self.always_used {
474 *pat = prefix_if_needed(pat, ws_prefix);
475 }
476 for (pat, _) in &mut self.discovered_always_used {
477 *pat = prefix_if_needed(pat, ws_prefix);
478 }
479 for (pat, _) in &mut self.fixture_patterns {
480 *pat = prefix_if_needed(pat, ws_prefix);
481 }
482 for rule in &mut self.used_exports {
483 *rule = rule.prefixed(ws_prefix);
484 }
485 for rule in &mut self.provided_dependencies {
486 *rule = rule.prefixed(ws_prefix);
487 }
488 for source in &mut self.federation_sources {
489 *source = source.prefixed(ws_prefix);
490 }
491 for (_, replacement) in &mut self.path_aliases {
492 *replacement = format!("{ws_prefix}/{replacement}");
493 }
494 }
495
496 pub(crate) fn merge_into(&mut self, other: Self) {
510 let Self {
511 entry_patterns,
512 replaced_entry_plugins: _,
514 entry_point_roles,
515 config_patterns,
516 always_used,
517 used_exports,
518 used_class_members,
519 framework_class_member_contracts,
520 referenced_dependencies,
521 package_referenced_dependencies,
522 discovered_always_used,
523 setup_files,
524 tooling_dependencies,
525 plugin_tooling,
526 script_used_packages,
527 dependency_binaries: _,
528 dev_dependency_names,
529 virtual_module_prefixes,
530 virtual_package_suffixes,
531 generated_import_patterns,
532 generated_type_import_prefixes,
533 path_aliases,
534 auto_imports,
535 active_plugins,
536 fixture_patterns,
537 scss_include_paths,
538 static_dir_mappings,
539 framework_static_dir_mappings,
540 provided_dependencies,
541 config_diagnostics,
542 federation_sources,
543 } = other;
544
545 self.entry_patterns.extend(entry_patterns);
546 for (plugin_name, role) in entry_point_roles {
547 self.entry_point_roles.entry(plugin_name).or_insert(role);
548 }
549 self.config_patterns.extend(config_patterns);
550 self.always_used.extend(always_used);
551 self.used_exports.extend(used_exports);
552 self.used_class_members.extend(used_class_members);
553 for contract in framework_class_member_contracts {
554 if !self.framework_class_member_contracts.contains(&contract) {
555 self.framework_class_member_contracts.push(contract);
556 }
557 }
558 self.referenced_dependencies.extend(referenced_dependencies);
559 self.package_referenced_dependencies
560 .extend(package_referenced_dependencies);
561 self.discovered_always_used.extend(discovered_always_used);
562 self.setup_files.extend(setup_files);
563 self.tooling_dependencies.extend(tooling_dependencies);
564 self.plugin_tooling.extend(plugin_tooling);
565 self.dev_dependency_names.extend(dev_dependency_names);
566 self.script_used_packages.extend(script_used_packages);
567 extend_unique(&mut self.virtual_module_prefixes, virtual_module_prefixes);
568 extend_unique(&mut self.virtual_package_suffixes, virtual_package_suffixes);
569 extend_unique(
570 &mut self.generated_import_patterns,
571 generated_import_patterns,
572 );
573 extend_unique(
574 &mut self.generated_type_import_prefixes,
575 generated_type_import_prefixes,
576 );
577 self.path_aliases.extend(path_aliases);
578 self.auto_imports.extend(auto_imports);
579 extend_unique(&mut self.active_plugins, active_plugins);
580 self.fixture_patterns.extend(fixture_patterns);
581 self.scss_include_paths.extend(scss_include_paths);
582 self.static_dir_mappings.extend(static_dir_mappings);
583 self.framework_static_dir_mappings
584 .extend(framework_static_dir_mappings);
585 self.provided_dependencies.extend(provided_dependencies);
586 self.federation_sources.extend(federation_sources);
587 for diagnostic in config_diagnostics {
588 if !self.config_diagnostics.contains(&diagnostic) {
589 self.config_diagnostics.push(diagnostic);
590 }
591 }
592 }
593}
594
595impl PluginRegistry {
596 #[must_use]
598 pub fn new(external: Vec<ExternalPluginDef>) -> Self {
599 Self {
600 plugins: builtin::create_builtin_plugins(),
601 external_plugins: external,
602 }
603 }
604
605 #[must_use]
610 pub fn discovery_hidden_dirs(&self, pkg: &PackageJson, root: &Path) -> Vec<String> {
611 let all_deps = pkg.all_dependency_names();
612 let mut seen = FxHashSet::default();
613 let mut dirs = Vec::new();
614
615 for plugin in &self.plugins {
616 if !plugin.is_enabled_with_deps(&all_deps, root) {
617 continue;
618 }
619 for dir in plugin.discovery_hidden_dirs() {
620 if seen.insert(*dir) {
621 dirs.push((*dir).to_string());
622 }
623 }
624 }
625
626 dirs
627 }
628
629 #[cfg(test)]
634 fn run(
635 &self,
636 pkg: &PackageJson,
637 root: &Path,
638 discovered_files: &[PathBuf],
639 ) -> AggregatedPluginResult {
640 self.try_run(pkg, root, discovered_files)
641 .unwrap_or_else(|errors| panic!("{}", format_plugin_regex_errors(&errors)))
642 }
643
644 pub fn try_run(
646 &self,
647 pkg: &PackageJson,
648 root: &Path,
649 discovered_files: &[PathBuf],
650 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
651 self.try_run_with_search_roots(pkg, root, discovered_files, &[root], false, None)
652 }
653
654 #[expect(
657 clippy::too_many_arguments,
658 reason = "public PluginRegistry API; signature is part of the crate surface for embedders"
659 )]
660 pub(crate) fn try_run_with_search_roots(
661 &self,
662 pkg: &PackageJson,
663 root: &Path,
664 discovered_files: &[PathBuf],
665 config_search_roots: &[&Path],
666 production_mode: bool,
667 candidate_index: Option<&ConfigCandidateIndex>,
668 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
669 let _span = tracing::info_span!("run_plugins").entered();
670 let mut result = AggregatedPluginResult::default();
671 let mut regex_errors = Vec::new();
672
673 let PluginRunContext { all_deps, active } = self.prepare_plugin_run_context(
674 pkg,
675 root,
676 discovered_files,
677 production_mode,
678 candidate_index,
679 );
680
681 self.run_plugin_preflight(&active, &all_deps, root, discovered_files);
682
683 for plugin in &active {
684 process_static_patterns(*plugin, root, &mut result);
685 }
686 process_package_json_metadata(&active, pkg, root, &mut result, &mut regex_errors);
687
688 process_external_plugins(
689 &self.external_plugins,
690 &all_deps,
691 root,
692 discovered_files,
693 &mut result,
694 );
695 tooling_evidence::record_plugin_tooling(
696 &tooling_evidence::ToolingEvidenceInput {
697 active: &active,
698 external_plugins: &self.external_plugins,
699 all_deps: &all_deps,
700 roots: &[root],
701 extra_roots: if production_mode {
705 &[]
706 } else {
707 config_search_roots
708 },
709 candidate_index,
710 discovered_files,
711 },
712 &mut result,
713 );
714
715 let config_matchers = compile_config_matchers(&active);
716 let relative_files =
717 compute_relative_files(&config_matchers, &active, discovered_files, root);
718
719 resolve_plugin_config_files(&mut PluginConfigResolutionInput {
720 config_matchers: &config_matchers,
721 relative_files: &relative_files,
722 config_search_roots,
723 production_mode,
724 candidate_index,
725 root,
726 result: &mut result,
727 regex_errors: &mut regex_errors,
728 });
729
730 process_package_json_inline_configs(
731 &active,
732 &config_matchers,
733 &relative_files,
734 root,
735 &mut result,
736 &mut regex_errors,
737 );
738
739 if regex_errors.is_empty() {
740 Ok(result)
741 } else {
742 Err(regex_errors)
743 }
744 }
745
746 #[cfg(test)]
752 fn run_workspace_fast(&self, input: &WorkspacePluginRunInput<'_>) -> AggregatedPluginResult {
753 self.try_run_workspace_fast(input)
754 .unwrap_or_else(|errors| panic!("{}", format_plugin_regex_errors(&errors)))
755 }
756
757 pub(crate) fn try_run_workspace_fast(
763 &self,
764 input: &WorkspacePluginRunInput<'_>,
765 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
766 let _span = tracing::info_span!("run_plugins").entered();
767 let mut result = AggregatedPluginResult::default();
768 let mut regex_errors = Vec::new();
769
770 let all_deps = input.pkg.all_dependency_names();
771 let script_packages =
772 script_activation_packages(input.pkg, input.root, &all_deps, input.production_mode);
773 let workspace_files: Vec<PathBuf> = input
774 .relative_files
775 .iter()
776 .map(|(abs_path, _)| abs_path.clone())
777 .collect();
778
779 let active = self.collect_active_plugins(&PluginActivationInput {
780 pkg: input.pkg,
781 root: input.root,
782 discovered_files: &workspace_files,
783 all_deps: &all_deps,
784 script_packages: &script_packages,
785 candidate_index: input.candidate_index,
786 });
787
788 log_active_plugins(&active);
789
790 self.emit_silent_fail_diagnostics(&active, &all_deps, input.root, &workspace_files);
791
792 process_external_plugins(
793 &self.external_plugins,
794 &all_deps,
795 input.root,
796 &workspace_files,
797 &mut result,
798 );
799
800 if active.is_empty() && result.active_plugins.is_empty() {
801 return Ok(result);
802 }
803
804 process_workspace_active_plugins(&active, input, &mut result, &mut regex_errors);
805 let evidence_roots: &[&Path] = if input.root == input.project_root {
807 &[input.root]
808 } else {
809 &[input.root, input.project_root]
810 };
811 tooling_evidence::record_plugin_tooling(
812 &tooling_evidence::ToolingEvidenceInput {
813 active: &active,
814 external_plugins: &self.external_plugins,
815 all_deps: &all_deps,
816 roots: evidence_roots,
817 extra_roots: &[],
818 candidate_index: input.candidate_index,
819 discovered_files: &workspace_files,
820 },
821 &mut result,
822 );
823 resolve_workspace_plugin_configs(&active, input, &mut result, &mut regex_errors);
824
825 if regex_errors.is_empty() {
826 Ok(result)
827 } else {
828 Err(regex_errors)
829 }
830 }
831
832 #[must_use]
835 pub(crate) fn precompile_config_matchers(
836 &self,
837 ) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
838 self.plugins
839 .iter()
840 .filter(|p| !p.config_patterns().is_empty())
841 .map(|p| (p.as_ref(), cached_plugin_config_matchers(p.as_ref())))
842 .collect()
843 }
844
845 pub(crate) fn resolve_script_config_files(
856 &self,
857 config_files: &[scripts::BinaryConfigFile],
858 package_root: &Path,
859 ws_prefix: &str,
860 ) -> AggregatedPluginResult {
861 let mut aggregated = AggregatedPluginResult::default();
862 for config_file in config_files {
863 let Some(rel_path) =
864 scripts::normalize_script_entry_pattern(ws_prefix, &config_file.path)
865 else {
866 continue;
867 };
868 for plugin in &self.plugins {
869 let plugin = plugin.as_ref();
870 if !plugin
871 .script_config_binaries()
872 .contains(&config_file.binary.as_str())
873 || cached_plugin_config_matchers(plugin)
874 .iter()
875 .any(|m| m.is_match(rel_path.as_str()))
876 {
877 continue;
878 }
879 let abs_path = package_root.join(config_file.path.trim_start_matches("./"));
880 let Ok(source) = std::fs::read_to_string(&abs_path) else {
881 continue;
882 };
883 let mut file_result = AggregatedPluginResult::default();
884 file_result
885 .entry_point_roles
886 .insert(plugin.name().to_string(), plugin.entry_point_role());
887 let plugin_result = plugin.resolve_config(&abs_path, &source, package_root);
888 if let Err(errors) = process_config_result(
889 plugin.name(),
890 plugin_result,
891 &mut file_result,
892 Some(&abs_path),
893 ) {
894 for error in errors {
895 tracing::warn!("{error}");
896 }
897 continue;
898 }
899 if !ws_prefix.is_empty() {
900 file_result.apply_workspace_prefix(ws_prefix);
901 }
902 file_result.used_exports.push(PluginUsedExportRule::new(
904 plugin.name().to_string(),
905 super::UsedExportRule::new(rel_path.clone(), ["default"]),
906 ));
907 aggregated.merge_into(file_result);
908 }
909 }
910 aggregated
911 }
912}
913
914fn process_workspace_active_plugins(
915 active: &[&dyn Plugin],
916 input: &WorkspacePluginRunInput<'_>,
917 result: &mut AggregatedPluginResult,
918 regex_errors: &mut Vec<PluginRegexValidationError>,
919) {
920 for plugin in active {
921 process_static_patterns(*plugin, input.root, result);
922 }
923 process_package_json_metadata(active, input.pkg, input.root, result, regex_errors);
924}
925
926fn resolve_workspace_plugin_configs(
927 active: &[&dyn Plugin],
928 input: &WorkspacePluginRunInput<'_>,
929 result: &mut AggregatedPluginResult,
930 regex_errors: &mut Vec<PluginRegexValidationError>,
931) {
932 let workspace_matchers = select_workspace_matchers(
933 input.precompiled_config_matchers,
934 active,
935 input.skip_config_plugins,
936 );
937
938 let mut resolved_ws_plugins: FxHashSet<&str> = FxHashSet::default();
939 for (plugin, matchers) in &workspace_matchers {
940 resolve_plugin_matching_files(&mut PluginMatchingFilesInput {
941 plugin: *plugin,
942 matchers,
943 relative_files: input.relative_files,
944 root: input.root,
945 result,
946 regex_errors,
947 resolved_plugins: &mut resolved_ws_plugins,
948 });
949 }
950
951 load_workspace_filesystem_configs(&mut WorkspaceFsConfigInput {
952 workspace_matchers: &workspace_matchers,
953 resolved_ws_plugins: &resolved_ws_plugins,
954 root: input.root,
955 project_root: input.project_root,
956 production_mode: input.production_mode,
957 candidate_index: input.candidate_index,
958 result,
959 regex_errors,
960 });
961}
962
963impl Default for PluginRegistry {
964 fn default() -> Self {
965 Self::new(vec![])
966 }
967}
968
969impl PluginRegistry {
970 fn prepare_plugin_run_context<'a>(
971 &'a self,
972 pkg: &PackageJson,
973 root: &Path,
974 discovered_files: &[PathBuf],
975 production_mode: bool,
976 candidate_index: Option<&ConfigCandidateIndex>,
977 ) -> PluginRunContext<'a> {
978 let all_deps = pkg.all_dependency_names();
979 let script_packages = script_activation_packages(pkg, root, &all_deps, production_mode);
980 let active = self.collect_active_plugins(&PluginActivationInput {
981 pkg,
982 root,
983 discovered_files,
984 all_deps: &all_deps,
985 script_packages: &script_packages,
986 candidate_index,
987 });
988
989 PluginRunContext { all_deps, active }
990 }
991
992 fn run_plugin_preflight(
993 &self,
994 active: &[&dyn Plugin],
995 all_deps: &[String],
996 root: &Path,
997 discovered_files: &[PathBuf],
998 ) {
999 log_active_plugins(active);
1000 check_meta_framework_prerequisites(active, root);
1001 self.emit_silent_fail_diagnostics(active, all_deps, root, discovered_files);
1002 }
1003
1004 fn collect_active_plugins<'a>(
1007 &'a self,
1008 activation: &PluginActivationInput<'_>,
1009 ) -> Vec<&'a dyn Plugin> {
1010 self.plugins
1011 .iter()
1012 .filter(|p| {
1013 p.is_enabled_with_files(
1014 activation.all_deps,
1015 activation.root,
1016 activation.discovered_files,
1017 activation.candidate_index,
1018 ) || p.is_enabled_with_scripts(activation.script_packages, activation.root)
1019 || p.is_enabled_with_package_json(activation.pkg, activation.root)
1020 })
1021 .map(AsRef::as_ref)
1022 .collect()
1023 }
1024
1025 fn emit_silent_fail_diagnostics(
1034 &self,
1035 active: &[&dyn Plugin],
1036 all_deps: &[String],
1037 root: &Path,
1038 discovered_files: &[PathBuf],
1039 ) {
1040 let active_external: Vec<&ExternalPluginDef> = self
1041 .external_plugins
1042 .iter()
1043 .filter(|ext| is_external_plugin_active(ext, all_deps, root, discovered_files))
1044 .collect();
1045 let mut diagnostics = detect_pattern_collisions(active, &active_external);
1046 diagnostics.extend(detect_enabler_typos(&self.external_plugins, all_deps));
1047 emit_plugin_diagnostics(&diagnostics);
1048 }
1049}
1050
1051fn plugin_warn_dedupe() -> &'static std::sync::Mutex<FxHashSet<String>> {
1058 static WARNED: std::sync::OnceLock<std::sync::Mutex<FxHashSet<String>>> =
1059 std::sync::OnceLock::new();
1060 WARNED.get_or_init(|| std::sync::Mutex::new(FxHashSet::default()))
1061}
1062
1063struct PluginConfigResolutionInput<'a> {
1064 config_matchers: &'a [(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
1065 relative_files: &'a [(PathBuf, String)],
1066 config_search_roots: &'a [&'a Path],
1067 production_mode: bool,
1068 candidate_index: Option<&'a ConfigCandidateIndex>,
1069 root: &'a Path,
1070 result: &'a mut AggregatedPluginResult,
1071 regex_errors: &'a mut Vec<PluginRegexValidationError>,
1072}
1073
1074fn select_workspace_matchers<'a>(
1077 precompiled_config_matchers: &[(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
1078 active: &[&dyn Plugin],
1079 skip_config_plugins: &FxHashSet<&str>,
1080) -> Vec<(&'a dyn Plugin, Vec<globset::GlobMatcher>)> {
1081 let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
1082 precompiled_config_matchers
1083 .iter()
1084 .filter(|(p, _)| {
1085 active_names.contains(p.name())
1086 && (!skip_config_plugins.contains(p.name())
1087 || must_parse_workspace_config_when_root_active(p.name()))
1088 })
1089 .map(|(plugin, matchers)| (*plugin, matchers.clone()))
1090 .collect()
1091}
1092
1093struct WorkspaceFsConfigInput<'a> {
1094 workspace_matchers: &'a [(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
1095 resolved_ws_plugins: &'a FxHashSet<&'a str>,
1096 root: &'a Path,
1097 project_root: &'a Path,
1098 production_mode: bool,
1099 candidate_index: Option<&'a ConfigCandidateIndex>,
1100 result: &'a mut AggregatedPluginResult,
1101 regex_errors: &'a mut Vec<PluginRegexValidationError>,
1102}
1103
1104fn load_workspace_filesystem_configs(input: &mut WorkspaceFsConfigInput<'_>) {
1107 let search_roots: &[&Path] = if input.root == input.project_root {
1108 &[input.root]
1109 } else {
1110 &[input.root, input.project_root]
1111 };
1112 let ws_json_configs = discover_config_files(
1113 input.workspace_matchers,
1114 input.resolved_ws_plugins,
1115 search_roots,
1116 input.production_mode,
1117 input.candidate_index,
1118 );
1119 for (abs_path, plugin) in &ws_json_configs {
1120 let Ok(source) = std::fs::read_to_string(abs_path) else {
1121 continue;
1122 };
1123 let plugin_result = plugin.resolve_config(abs_path, &source, input.root);
1124 if plugin_result.is_empty() {
1125 continue;
1126 }
1127 let rel = abs_path
1128 .strip_prefix(input.project_root)
1129 .map(|p| p.to_string_lossy())
1130 .unwrap_or_default();
1131 tracing::debug!(
1132 plugin = plugin.name(),
1133 config = %rel,
1134 entries = plugin_result.entry_patterns.len(),
1135 deps = plugin_result.referenced_dependencies.len(),
1136 "resolved config (workspace filesystem fallback)"
1137 );
1138 if let Err(mut errors) =
1139 process_config_result(plugin.name(), plugin_result, input.result, Some(abs_path))
1140 {
1141 input.regex_errors.append(&mut errors);
1142 }
1143 }
1144}
1145
1146fn resolve_plugin_config_files(input: &mut PluginConfigResolutionInput<'_>) {
1147 if input.config_matchers.is_empty() {
1148 return;
1149 }
1150
1151 let mut resolved_plugins: FxHashSet<&str> = FxHashSet::default();
1152 for (plugin, matchers) in input.config_matchers {
1153 resolve_plugin_matching_files(&mut PluginMatchingFilesInput {
1154 plugin: *plugin,
1155 matchers,
1156 relative_files: input.relative_files,
1157 root: input.root,
1158 result: input.result,
1159 regex_errors: input.regex_errors,
1160 resolved_plugins: &mut resolved_plugins,
1161 });
1162 }
1163
1164 let json_configs = discover_config_files(
1165 input.config_matchers,
1166 &resolved_plugins,
1167 input.config_search_roots,
1168 input.production_mode,
1169 input.candidate_index,
1170 );
1171 for (abs_path, plugin) in &json_configs {
1172 resolve_plugin_filesystem_config(
1173 *plugin,
1174 abs_path,
1175 input.root,
1176 input.result,
1177 input.regex_errors,
1178 );
1179 }
1180}
1181
1182struct PluginMatchingFilesInput<'plugins, 'data, 'state> {
1183 plugin: &'plugins dyn Plugin,
1184 matchers: &'data [globset::GlobMatcher],
1185 relative_files: &'data [(PathBuf, String)],
1186 root: &'data Path,
1187 result: &'state mut AggregatedPluginResult,
1188 regex_errors: &'state mut Vec<PluginRegexValidationError>,
1189 resolved_plugins: &'state mut FxHashSet<&'plugins str>,
1190}
1191
1192fn resolve_plugin_matching_files(input: &mut PluginMatchingFilesInput<'_, '_, '_>) {
1193 use rayon::prelude::*;
1194
1195 let plugin_hits: Vec<&PathBuf> = input
1196 .relative_files
1197 .par_iter()
1198 .filter_map(|(abs_path, rel_path)| {
1199 input
1200 .matchers
1201 .iter()
1202 .any(|m| m.is_match(rel_path.as_str()))
1203 .then_some(abs_path)
1204 })
1205 .collect();
1206 for abs_path in plugin_hits {
1207 let Ok(source) = std::fs::read_to_string(abs_path) else {
1208 continue;
1209 };
1210 let plugin_result = input.plugin.resolve_config(abs_path, &source, input.root);
1211 if plugin_result.is_empty() {
1212 continue;
1213 }
1214 input.resolved_plugins.insert(input.plugin.name());
1215 process_resolved_plugin_config(ResolvedPluginConfigInput {
1216 plugin: input.plugin,
1217 abs_path,
1218 plugin_result,
1219 result: input.result,
1220 regex_errors: input.regex_errors,
1221 message: "resolved config",
1222 config_display: abs_path.display(),
1223 });
1224 }
1225}
1226
1227fn resolve_plugin_filesystem_config(
1228 plugin: &dyn Plugin,
1229 abs_path: &Path,
1230 root: &Path,
1231 result: &mut AggregatedPluginResult,
1232 regex_errors: &mut Vec<PluginRegexValidationError>,
1233) {
1234 let Ok(source) = std::fs::read_to_string(abs_path) else {
1235 return;
1236 };
1237 let plugin_result = plugin.resolve_config(abs_path, &source, root);
1238 if plugin_result.is_empty() {
1239 return;
1240 }
1241 let rel = abs_path
1242 .strip_prefix(root)
1243 .map(|p| p.to_string_lossy())
1244 .unwrap_or_default();
1245 process_resolved_plugin_config(ResolvedPluginConfigInput {
1246 plugin,
1247 abs_path,
1248 plugin_result,
1249 result,
1250 regex_errors,
1251 message: "resolved config (filesystem fallback)",
1252 config_display: rel,
1253 });
1254}
1255
1256struct ResolvedPluginConfigInput<'a, D> {
1257 plugin: &'a dyn Plugin,
1258 abs_path: &'a Path,
1259 plugin_result: PluginResult,
1260 result: &'a mut AggregatedPluginResult,
1261 regex_errors: &'a mut Vec<PluginRegexValidationError>,
1262 message: &'static str,
1263 config_display: D,
1264}
1265
1266fn process_resolved_plugin_config(input: ResolvedPluginConfigInput<'_, impl std::fmt::Display>) {
1267 tracing::debug!(
1268 plugin = input.plugin.name(),
1269 config = %input.config_display,
1270 entries = input.plugin_result.entry_patterns.len(),
1271 deps = input.plugin_result.referenced_dependencies.len(),
1272 input.message
1273 );
1274 if let Err(mut errors) = process_config_result(
1275 input.plugin.name(),
1276 input.plugin_result,
1277 input.result,
1278 Some(input.abs_path),
1279 ) {
1280 input.regex_errors.append(&mut errors);
1281 }
1282}
1283
1284fn should_warn(key: String) -> bool {
1288 plugin_warn_dedupe()
1289 .lock()
1290 .map_or(true, |mut set| set.insert(key))
1291}
1292
1293#[derive(Debug, Clone, PartialEq, Eq)]
1300pub(crate) enum PluginDiagnostic {
1301 PatternCollision {
1303 pattern: String,
1304 owners: Vec<String>,
1305 },
1306 EnablerTypo {
1309 plugin: String,
1310 enabler: String,
1311 suggestion: String,
1312 },
1313}
1314
1315fn detect_pattern_collisions(
1341 builtin_active: &[&dyn Plugin],
1342 external_active: &[&ExternalPluginDef],
1343) -> Vec<PluginDiagnostic> {
1344 use rustc_hash::FxHashMap;
1345
1346 let mut pattern_owners: FxHashMap<String, (Vec<String>, FxHashSet<String>)> =
1347 FxHashMap::default();
1348
1349 let record = |pattern_owners: &mut FxHashMap<_, (Vec<String>, FxHashSet<String>)>,
1350 pattern: String,
1351 name: String| {
1352 let (list, seen) = pattern_owners.entry(pattern).or_default();
1353 if seen.insert(name.clone()) {
1354 list.push(name);
1355 }
1356 };
1357
1358 for plugin in builtin_active {
1359 for pat in plugin.config_patterns() {
1360 record(
1361 &mut pattern_owners,
1362 (*pat).to_string(),
1363 plugin.name().to_string(),
1364 );
1365 }
1366 }
1367 for ext in external_active {
1368 for pat in &ext.config_patterns {
1369 record(&mut pattern_owners, pat.clone(), ext.name.clone());
1370 }
1371 }
1372
1373 let builtin_names: FxHashSet<&str> = builtin_active.iter().map(|p| p.name()).collect();
1380
1381 let mut findings: Vec<PluginDiagnostic> = pattern_owners
1382 .into_iter()
1383 .filter_map(|(pattern, (owners, _seen))| {
1384 if owners.len() < 2 || owners.iter().all(|o| builtin_names.contains(o.as_str())) {
1385 None
1386 } else {
1387 Some(PluginDiagnostic::PatternCollision { pattern, owners })
1388 }
1389 })
1390 .collect();
1391 findings.sort_unstable_by(|a, b| match (a, b) {
1392 (
1393 PluginDiagnostic::PatternCollision { pattern: ap, .. },
1394 PluginDiagnostic::PatternCollision { pattern: bp, .. },
1395 ) => ap.cmp(bp),
1396 _ => std::cmp::Ordering::Equal,
1397 });
1398 findings
1399}
1400
1401fn detect_enabler_typos(
1416 external_plugins: &[ExternalPluginDef],
1417 all_deps: &[String],
1418) -> Vec<PluginDiagnostic> {
1419 let mut findings = Vec::new();
1420
1421 for ext in external_plugins {
1422 if ext.detection.is_some() || ext.enablers.is_empty() {
1423 continue;
1424 }
1425
1426 let any_match = ext.enablers.iter().any(|enabler| {
1427 if enabler.ends_with('/') {
1428 all_deps.iter().any(|d| d.starts_with(enabler))
1429 } else {
1430 all_deps.iter().any(|d| d == enabler)
1431 }
1432 });
1433 if any_match {
1434 continue;
1435 }
1436
1437 for enabler in &ext.enablers {
1438 let candidates = all_deps.iter().map(String::as_str);
1439 let Some(suggestion) = fallow_config::levenshtein::closest_match(enabler, candidates)
1440 else {
1441 continue;
1442 };
1443
1444 findings.push(PluginDiagnostic::EnablerTypo {
1445 plugin: ext.name.clone(),
1446 enabler: enabler.clone(),
1447 suggestion: suggestion.to_string(),
1448 });
1449 }
1450 }
1451
1452 findings
1453}
1454
1455fn emit_plugin_diagnostics(findings: &[PluginDiagnostic]) {
1458 for finding in findings {
1459 match finding {
1460 PluginDiagnostic::PatternCollision { pattern, owners } => {
1461 let key = format!("collision::{pattern}::{owners:?}");
1462 if !should_warn(key) {
1463 continue;
1464 }
1465 let winner = &owners[0];
1466 let others = owners[1..].join(", ");
1467 tracing::warn!(
1468 "plugin config_patterns collision: identical pattern \
1469 '{pattern}' is claimed by plugins [{joined}]; '{winner}' \
1470 runs first (registration order), others ({others}) \
1471 follow. Rename one of the patterns or remove the \
1472 duplicate plugin to make resolution explicit. A future \
1473 release may reject identical-pattern collisions.",
1474 joined = owners.join(", "),
1475 );
1476 }
1477 PluginDiagnostic::EnablerTypo {
1478 plugin,
1479 enabler,
1480 suggestion,
1481 } => {
1482 let key = format!("enabler::{plugin}::{enabler}");
1483 if !should_warn(key) {
1484 continue;
1485 }
1486 tracing::warn!(
1487 "plugin '{plugin}' enabler '{enabler}' does not match any \
1488 dependency in package.json; did you mean '{suggestion}'? \
1489 The plugin will not activate. A future release may reject \
1490 unmatched enablers.",
1491 );
1492 }
1493 }
1494 }
1495}
1496
1497fn process_package_json_inline_configs(
1502 active: &[&dyn Plugin],
1503 config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
1504 relative_files: &[(PathBuf, String)],
1505 root: &Path,
1506 result: &mut AggregatedPluginResult,
1507 regex_errors: &mut Vec<PluginRegexValidationError>,
1508) {
1509 for plugin in active {
1510 let Some(key) = plugin.package_json_config_key() else {
1511 continue;
1512 };
1513 if check_has_config_file(*plugin, config_matchers, relative_files) {
1514 continue;
1515 }
1516 let pkg_path = root.join("package.json");
1517 let Ok(content) = std::fs::read_to_string(&pkg_path) else {
1518 continue;
1519 };
1520 let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
1521 continue;
1522 };
1523 let Some(config_value) = json.get(key) else {
1524 continue;
1525 };
1526 let config_json = serde_json::to_string(config_value).unwrap_or_default();
1527 let fake_path = root.join(format!("{key}.config.json"));
1528 let plugin_result = plugin.resolve_config(&fake_path, &config_json, root);
1529 if plugin_result.is_empty() {
1530 continue;
1531 }
1532 tracing::debug!(
1533 plugin = plugin.name(),
1534 key = key,
1535 "resolved inline package.json config"
1536 );
1537 if let Err(mut errors) =
1538 process_config_result(plugin.name(), plugin_result, result, Some(&pkg_path))
1539 {
1540 regex_errors.append(&mut errors);
1541 }
1542 }
1543}
1544
1545#[derive(Debug)]
1548struct MetaFrameworkWarning {
1549 dedupe_key: &'static str,
1550 message: &'static str,
1551}
1552
1553fn missing_meta_framework_prerequisites(
1563 active_plugins: &[&dyn Plugin],
1564 root: &Path,
1565) -> Vec<MetaFrameworkWarning> {
1566 active_plugins
1567 .iter()
1568 .filter_map(|plugin| match plugin.name() {
1569 "nuxt" if !root.join(".nuxt/tsconfig.json").exists() => Some(MetaFrameworkWarning {
1570 dedupe_key: "meta-prereq::nuxt",
1571 message: "Nuxt project missing .nuxt/tsconfig.json: run `nuxt prepare` \
1572 before fallow for accurate analysis",
1573 }),
1574 "astro" if !root.join(".astro").exists() => Some(MetaFrameworkWarning {
1575 dedupe_key: "meta-prereq::astro",
1576 message: "Astro project missing .astro/ types: run `astro sync` \
1577 before fallow for accurate analysis",
1578 }),
1579 _ => None,
1580 })
1581 .collect()
1582}
1583
1584fn check_meta_framework_prerequisites(active_plugins: &[&dyn Plugin], root: &Path) {
1594 for warning in missing_meta_framework_prerequisites(active_plugins, root) {
1595 if should_warn(warning.dedupe_key.to_owned()) {
1596 tracing::warn!("{}", warning.message);
1597 }
1598 }
1599}
1600
1601fn script_activation_packages(
1602 pkg: &PackageJson,
1603 root: &Path,
1604 all_deps: &[String],
1605 production_mode: bool,
1606) -> FxHashSet<String> {
1607 let Some(pkg_scripts) = pkg.scripts.as_ref() else {
1608 return FxHashSet::default();
1609 };
1610
1611 let scripts_to_analyze = if production_mode {
1612 scripts::filter_production_scripts(pkg_scripts)
1613 } else {
1614 pkg_scripts.clone()
1615 };
1616
1617 let mut nm_roots = Vec::new();
1618 if root.join("node_modules").is_dir() {
1619 nm_roots.push(root);
1620 }
1621 let bin_map = scripts::build_bin_to_package_map(&nm_roots, all_deps);
1622 let dep_set: FxHashSet<String> = all_deps.iter().cloned().collect();
1623 let catalog =
1624 scripts::ScriptCatalog::from_scripts_with_bodies(pkg_scripts, &scripts_to_analyze);
1625
1626 scripts::analyze_scripts_with_dependency_context(
1627 &scripts_to_analyze,
1628 root,
1629 &bin_map,
1630 &dep_set,
1631 &catalog,
1632 scripts::IgnoredCommandEntries::NONE,
1633 )
1634 .used_packages
1635}
1636
1637#[cfg(test)]
1638mod tests;