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;
18
19#[must_use]
24pub fn builtin_plugin_names() -> Vec<&'static str> {
25 builtin::create_builtin_plugins()
26 .iter()
27 .map(|plugin| plugin.name())
28 .collect()
29}
30
31#[must_use]
36pub fn builtin_config_patterns() -> Vec<&'static str> {
37 let mut patterns: Vec<&'static str> = builtin::create_builtin_plugins()
38 .iter()
39 .flat_map(|plugin| plugin.config_patterns().iter().copied())
40 .collect();
41 patterns.sort_unstable();
42 patterns.dedup();
43 patterns
44}
45
46pub use helpers::ConfigCandidateIndex;
47pub use helpers::is_external_plugin_active;
48use helpers::{
49 check_has_config_file, discover_config_files, prepare_config_pattern, process_config_result,
50 process_external_plugins, process_package_json_metadata, process_static_patterns,
51};
52
53fn must_parse_workspace_config_when_root_active(plugin_name: &str) -> bool {
54 matches!(
55 plugin_name,
56 "eslint" | "docusaurus" | "jest" | "storybook" | "tanstack-router" | "vitest"
57 )
58}
59
60fn compile_config_matchers<'a>(
61 active: &[&'a dyn Plugin],
62) -> Vec<(&'a dyn Plugin, Vec<globset::GlobMatcher>)> {
63 active
64 .iter()
65 .filter(|plugin| !plugin.config_patterns().is_empty())
66 .map(|plugin| (*plugin, cached_plugin_config_matchers(*plugin)))
67 .collect()
68}
69
70fn compile_plugin_config_matchers(plugin: &dyn Plugin) -> Vec<globset::GlobMatcher> {
71 plugin
72 .config_patterns()
73 .iter()
74 .filter_map(|pattern| {
75 let prepared = prepare_config_pattern(pattern);
76 globset::Glob::new(&prepared)
77 .ok()
78 .map(|glob| glob.compile_matcher())
79 })
80 .collect()
81}
82
83struct CachedPluginConfigMatchers {
84 patterns: &'static [&'static str],
85 matchers: Vec<globset::GlobMatcher>,
86}
87
88#[derive(Default)]
89struct PluginConfigMatcherCache {
90 by_name: RwLock<FxHashMap<&'static str, Vec<CachedPluginConfigMatchers>>>,
91}
92
93impl PluginConfigMatcherCache {
94 fn get_or_compile(&self, plugin: &dyn Plugin) -> Vec<globset::GlobMatcher> {
95 let patterns = plugin.config_patterns();
96 let cached = self
97 .by_name
98 .read()
99 .unwrap_or_else(std::sync::PoisonError::into_inner)
100 .get(plugin.name())
101 .and_then(|variants| {
102 variants
103 .iter()
104 .find(|entry| entry.patterns == patterns)
105 .map(|entry| entry.matchers.clone())
106 });
107 if let Some(matchers) = cached {
108 return matchers;
109 }
110
111 let matchers = compile_plugin_config_matchers(plugin);
112 {
113 let mut by_name = self
114 .by_name
115 .write()
116 .unwrap_or_else(std::sync::PoisonError::into_inner);
117 let variants = by_name.entry(plugin.name()).or_default();
118 if let Some(entry) = variants.iter().find(|entry| entry.patterns == patterns) {
119 return entry.matchers.clone();
120 }
121 variants.push(CachedPluginConfigMatchers {
122 patterns,
123 matchers: matchers.clone(),
124 });
125 drop(by_name);
126 }
127 matchers
128 }
129}
130
131fn cached_plugin_config_matchers(plugin: &dyn Plugin) -> Vec<globset::GlobMatcher> {
132 static MATCHERS: OnceLock<PluginConfigMatcherCache> = OnceLock::new();
133 MATCHERS
134 .get_or_init(PluginConfigMatcherCache::default)
135 .get_or_compile(plugin)
136}
137
138fn log_active_plugins(active: &[&dyn Plugin]) {
140 tracing::info!(
141 plugins = active
142 .iter()
143 .map(|p| p.name())
144 .collect::<Vec<_>>()
145 .join(", "),
146 "active plugins"
147 );
148}
149
150fn compute_relative_files(
154 config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
155 active: &[&dyn Plugin],
156 discovered_files: &[PathBuf],
157 root: &Path,
158) -> Vec<(PathBuf, String)> {
159 use rayon::prelude::*;
160 let needs_relative_files =
161 !config_matchers.is_empty() || active.iter().any(|p| p.package_json_config_key().is_some());
162 if !needs_relative_files {
163 return Vec::new();
164 }
165 discovered_files
166 .par_iter()
167 .map(|f| {
168 let rel = f
169 .strip_prefix(root)
170 .unwrap_or(f)
171 .to_string_lossy()
172 .into_owned();
173 (f.clone(), rel)
174 })
175 .collect()
176}
177
178pub struct PluginRegistry {
180 plugins: Vec<Box<dyn Plugin>>,
181 external_plugins: Vec<ExternalPluginDef>,
182}
183
184pub(crate) struct WorkspacePluginRunInput<'a> {
186 pub(crate) pkg: &'a PackageJson,
187 pub(crate) root: &'a Path,
188 pub(crate) project_root: &'a Path,
189 pub(crate) precompiled_config_matchers: &'a [(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
190 pub(crate) relative_files: &'a [(PathBuf, String)],
191 pub(crate) skip_config_plugins: &'a FxHashSet<&'a str>,
192 pub(crate) production_mode: bool,
193 pub(crate) candidate_index: Option<&'a ConfigCandidateIndex>,
194}
195
196struct PluginRunContext<'a> {
197 all_deps: Vec<String>,
198 active: Vec<&'a dyn Plugin>,
199}
200
201struct PluginActivationInput<'a> {
203 pkg: &'a PackageJson,
204 root: &'a Path,
205 discovered_files: &'a [PathBuf],
206 all_deps: &'a [String],
207 script_packages: &'a FxHashSet<String>,
208 candidate_index: Option<&'a ConfigCandidateIndex>,
209}
210
211#[derive(Debug, Clone, PartialEq, Eq)]
213pub struct PluginRegexValidationError {
214 plugin_name: String,
215 config_path: Option<PathBuf>,
216 rule_kind: &'static str,
217 field: &'static str,
218 rule_pattern: String,
219 regex_pattern: String,
220 source: String,
221}
222
223impl PluginRegexValidationError {
224 fn new(input: PluginRegexValidationErrorInput<'_>) -> Self {
225 Self {
226 plugin_name: input.plugin_name.to_owned(),
227 config_path: input.config_path.map(Path::to_path_buf),
228 rule_kind: input.rule_kind,
229 field: input.field,
230 rule_pattern: input.rule_pattern.to_owned(),
231 regex_pattern: input.regex_pattern.to_owned(),
232 source: input.source.to_string(),
233 }
234 }
235}
236
237#[derive(Clone, Copy)]
238pub(crate) struct PluginRegexValidationErrorInput<'a> {
239 plugin_name: &'a str,
240 config_path: Option<&'a Path>,
241 rule_kind: &'static str,
242 field: &'static str,
243 rule_pattern: &'a str,
244 regex_pattern: &'a str,
245 source: &'a regex::Error,
246}
247
248impl fmt::Display for PluginRegexValidationError {
249 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
250 let location = self
251 .config_path
252 .as_ref()
253 .map(|path| format!(" in {}", path.display()))
254 .unwrap_or_default();
255 write!(
256 f,
257 "plugin '{}'{}: invalid regex '{}' in {}.{} for path rule '{}': {}",
258 self.plugin_name,
259 location,
260 self.regex_pattern,
261 self.rule_kind,
262 self.field,
263 self.rule_pattern,
264 self.source
265 )
266 }
267}
268
269#[must_use]
270pub(crate) fn format_plugin_regex_errors(errors: &[PluginRegexValidationError]) -> String {
271 let joined = errors
272 .iter()
273 .map(ToString::to_string)
274 .collect::<Vec<_>>()
275 .join("\n - ");
276 format!(
277 "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."
278 )
279}
280
281#[derive(Debug, Clone, Default)]
283pub struct AggregatedPluginResult {
284 pub entry_patterns: Vec<(PathRule, String)>,
286 pub entry_point_roles: rustc_hash::FxHashMap<String, EntryPointRole>,
288 pub config_patterns: Vec<String>,
290 pub always_used: Vec<(String, String)>,
292 pub used_exports: Vec<PluginUsedExportRule>,
294 pub used_class_members: Vec<UsedClassMemberRule>,
298 pub framework_class_member_contracts: Vec<fallow_types::semantic::SemanticFrameworkContract>,
300 pub referenced_dependencies: Vec<String>,
302 pub package_referenced_dependencies: Vec<(PathBuf, String)>,
304 pub discovered_always_used: Vec<(String, String)>,
306 pub setup_files: Vec<(PathBuf, String)>,
308 pub tooling_dependencies: Vec<String>,
310 pub script_used_packages: FxHashSet<String>,
312 pub virtual_module_prefixes: Vec<String>,
315 pub virtual_package_suffixes: Vec<String>,
318 pub generated_import_patterns: Vec<String>,
321 pub generated_type_import_prefixes: Vec<String>,
324 pub path_aliases: Vec<(String, String)>,
327 pub auto_imports: Vec<AutoImportRule>,
331 pub active_plugins: Vec<String>,
333 pub fixture_patterns: Vec<(String, String)>,
335 pub scss_include_paths: Vec<PathBuf>,
340 pub static_dir_mappings: Vec<(PathBuf, String)>,
342 pub framework_static_dir_mappings: Vec<(PathBuf, String)>,
345 pub provided_dependencies: Vec<ProvidedDependencyRule>,
347 pub config_diagnostics: Vec<super::PluginConfigDiagnostic>,
351 pub federation_sources: Vec<super::FederationSource>,
354}
355
356fn extend_unique(target: &mut Vec<String>, incoming: Vec<String>) {
361 let mut seen: FxHashSet<String> = target.iter().cloned().collect();
362 for item in incoming {
363 if seen.insert(item.clone()) {
364 target.push(item);
365 }
366 }
367}
368
369fn prefix_if_needed(pat: &str, ws_prefix: &str) -> String {
373 if pat.starts_with(ws_prefix) || pat.starts_with('/') {
374 pat.to_string()
375 } else {
376 format!("{ws_prefix}/{pat}")
377 }
378}
379
380impl AggregatedPluginResult {
381 pub(crate) fn apply_workspace_prefix(&mut self, ws_prefix: &str) {
397 for (rule, _) in &mut self.entry_patterns {
398 *rule = rule.prefixed(ws_prefix);
399 }
400 for (pat, _) in &mut self.always_used {
401 *pat = prefix_if_needed(pat, ws_prefix);
402 }
403 for (pat, _) in &mut self.discovered_always_used {
404 *pat = prefix_if_needed(pat, ws_prefix);
405 }
406 for (pat, _) in &mut self.fixture_patterns {
407 *pat = prefix_if_needed(pat, ws_prefix);
408 }
409 for rule in &mut self.used_exports {
410 *rule = rule.prefixed(ws_prefix);
411 }
412 for rule in &mut self.provided_dependencies {
413 *rule = rule.prefixed(ws_prefix);
414 }
415 for source in &mut self.federation_sources {
416 *source = source.prefixed(ws_prefix);
417 }
418 for (_, replacement) in &mut self.path_aliases {
419 *replacement = format!("{ws_prefix}/{replacement}");
420 }
421 }
422
423 pub(crate) fn merge_into(&mut self, other: Self) {
437 let Self {
438 entry_patterns,
439 entry_point_roles,
440 config_patterns,
441 always_used,
442 used_exports,
443 used_class_members,
444 framework_class_member_contracts,
445 referenced_dependencies,
446 package_referenced_dependencies,
447 discovered_always_used,
448 setup_files,
449 tooling_dependencies,
450 script_used_packages,
451 virtual_module_prefixes,
452 virtual_package_suffixes,
453 generated_import_patterns,
454 generated_type_import_prefixes,
455 path_aliases,
456 auto_imports,
457 active_plugins,
458 fixture_patterns,
459 scss_include_paths,
460 static_dir_mappings,
461 framework_static_dir_mappings,
462 provided_dependencies,
463 config_diagnostics,
464 federation_sources,
465 } = other;
466
467 self.entry_patterns.extend(entry_patterns);
468 for (plugin_name, role) in entry_point_roles {
469 self.entry_point_roles.entry(plugin_name).or_insert(role);
470 }
471 self.config_patterns.extend(config_patterns);
472 self.always_used.extend(always_used);
473 self.used_exports.extend(used_exports);
474 self.used_class_members.extend(used_class_members);
475 for contract in framework_class_member_contracts {
476 if !self.framework_class_member_contracts.contains(&contract) {
477 self.framework_class_member_contracts.push(contract);
478 }
479 }
480 self.referenced_dependencies.extend(referenced_dependencies);
481 self.package_referenced_dependencies
482 .extend(package_referenced_dependencies);
483 self.discovered_always_used.extend(discovered_always_used);
484 self.setup_files.extend(setup_files);
485 self.tooling_dependencies.extend(tooling_dependencies);
486 self.script_used_packages.extend(script_used_packages);
487 extend_unique(&mut self.virtual_module_prefixes, virtual_module_prefixes);
488 extend_unique(&mut self.virtual_package_suffixes, virtual_package_suffixes);
489 extend_unique(
490 &mut self.generated_import_patterns,
491 generated_import_patterns,
492 );
493 extend_unique(
494 &mut self.generated_type_import_prefixes,
495 generated_type_import_prefixes,
496 );
497 self.path_aliases.extend(path_aliases);
498 self.auto_imports.extend(auto_imports);
499 extend_unique(&mut self.active_plugins, active_plugins);
500 self.fixture_patterns.extend(fixture_patterns);
501 self.scss_include_paths.extend(scss_include_paths);
502 self.static_dir_mappings.extend(static_dir_mappings);
503 self.framework_static_dir_mappings
504 .extend(framework_static_dir_mappings);
505 self.provided_dependencies.extend(provided_dependencies);
506 self.federation_sources.extend(federation_sources);
507 for diagnostic in config_diagnostics {
508 if !self.config_diagnostics.contains(&diagnostic) {
509 self.config_diagnostics.push(diagnostic);
510 }
511 }
512 }
513}
514
515impl PluginRegistry {
516 #[must_use]
518 pub fn new(external: Vec<ExternalPluginDef>) -> Self {
519 Self {
520 plugins: builtin::create_builtin_plugins(),
521 external_plugins: external,
522 }
523 }
524
525 #[must_use]
530 pub fn discovery_hidden_dirs(&self, pkg: &PackageJson, root: &Path) -> Vec<String> {
531 let all_deps = pkg.all_dependency_names();
532 let mut seen = FxHashSet::default();
533 let mut dirs = Vec::new();
534
535 for plugin in &self.plugins {
536 if !plugin.is_enabled_with_deps(&all_deps, root) {
537 continue;
538 }
539 for dir in plugin.discovery_hidden_dirs() {
540 if seen.insert(*dir) {
541 dirs.push((*dir).to_string());
542 }
543 }
544 }
545
546 dirs
547 }
548
549 #[cfg(test)]
554 fn run(
555 &self,
556 pkg: &PackageJson,
557 root: &Path,
558 discovered_files: &[PathBuf],
559 ) -> AggregatedPluginResult {
560 self.try_run(pkg, root, discovered_files)
561 .unwrap_or_else(|errors| panic!("{}", format_plugin_regex_errors(&errors)))
562 }
563
564 pub fn try_run(
566 &self,
567 pkg: &PackageJson,
568 root: &Path,
569 discovered_files: &[PathBuf],
570 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
571 self.try_run_with_search_roots(pkg, root, discovered_files, &[root], false, None)
572 }
573
574 #[expect(
577 clippy::too_many_arguments,
578 reason = "public PluginRegistry API; signature is part of the crate surface for embedders"
579 )]
580 pub(crate) fn try_run_with_search_roots(
581 &self,
582 pkg: &PackageJson,
583 root: &Path,
584 discovered_files: &[PathBuf],
585 config_search_roots: &[&Path],
586 production_mode: bool,
587 candidate_index: Option<&ConfigCandidateIndex>,
588 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
589 let _span = tracing::info_span!("run_plugins").entered();
590 let mut result = AggregatedPluginResult::default();
591 let mut regex_errors = Vec::new();
592
593 let PluginRunContext { all_deps, active } = self.prepare_plugin_run_context(
594 pkg,
595 root,
596 discovered_files,
597 production_mode,
598 candidate_index,
599 );
600
601 self.run_plugin_preflight(&active, &all_deps, root, discovered_files);
602
603 for plugin in &active {
604 process_static_patterns(*plugin, root, &mut result);
605 }
606 process_package_json_metadata(&active, pkg, root, &mut result, &mut regex_errors);
607
608 process_external_plugins(
609 &self.external_plugins,
610 &all_deps,
611 root,
612 discovered_files,
613 &mut result,
614 );
615
616 let config_matchers = compile_config_matchers(&active);
617 let relative_files =
618 compute_relative_files(&config_matchers, &active, discovered_files, root);
619
620 resolve_plugin_config_files(&mut PluginConfigResolutionInput {
621 config_matchers: &config_matchers,
622 relative_files: &relative_files,
623 config_search_roots,
624 production_mode,
625 candidate_index,
626 root,
627 result: &mut result,
628 regex_errors: &mut regex_errors,
629 });
630
631 process_package_json_inline_configs(
632 &active,
633 &config_matchers,
634 &relative_files,
635 root,
636 &mut result,
637 &mut regex_errors,
638 );
639
640 if regex_errors.is_empty() {
641 Ok(result)
642 } else {
643 Err(regex_errors)
644 }
645 }
646
647 #[cfg(test)]
653 fn run_workspace_fast(&self, input: &WorkspacePluginRunInput<'_>) -> AggregatedPluginResult {
654 self.try_run_workspace_fast(input)
655 .unwrap_or_else(|errors| panic!("{}", format_plugin_regex_errors(&errors)))
656 }
657
658 pub(crate) fn try_run_workspace_fast(
664 &self,
665 input: &WorkspacePluginRunInput<'_>,
666 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
667 let _span = tracing::info_span!("run_plugins").entered();
668 let mut result = AggregatedPluginResult::default();
669 let mut regex_errors = Vec::new();
670
671 let all_deps = input.pkg.all_dependency_names();
672 let script_packages =
673 script_activation_packages(input.pkg, input.root, &all_deps, input.production_mode);
674 let workspace_files: Vec<PathBuf> = input
675 .relative_files
676 .iter()
677 .map(|(abs_path, _)| abs_path.clone())
678 .collect();
679
680 let active = self.collect_active_plugins(&PluginActivationInput {
681 pkg: input.pkg,
682 root: input.root,
683 discovered_files: &workspace_files,
684 all_deps: &all_deps,
685 script_packages: &script_packages,
686 candidate_index: input.candidate_index,
687 });
688
689 log_active_plugins(&active);
690
691 self.emit_silent_fail_diagnostics(&active, &all_deps, input.root, &workspace_files);
692
693 process_external_plugins(
694 &self.external_plugins,
695 &all_deps,
696 input.root,
697 &workspace_files,
698 &mut result,
699 );
700
701 if active.is_empty() && result.active_plugins.is_empty() {
702 return Ok(result);
703 }
704
705 process_workspace_active_plugins(&active, input, &mut result, &mut regex_errors);
706 resolve_workspace_plugin_configs(&active, input, &mut result, &mut regex_errors);
707
708 if regex_errors.is_empty() {
709 Ok(result)
710 } else {
711 Err(regex_errors)
712 }
713 }
714
715 #[must_use]
718 pub(crate) fn precompile_config_matchers(
719 &self,
720 ) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
721 self.plugins
722 .iter()
723 .filter(|p| !p.config_patterns().is_empty())
724 .map(|p| (p.as_ref(), cached_plugin_config_matchers(p.as_ref())))
725 .collect()
726 }
727}
728
729fn process_workspace_active_plugins(
730 active: &[&dyn Plugin],
731 input: &WorkspacePluginRunInput<'_>,
732 result: &mut AggregatedPluginResult,
733 regex_errors: &mut Vec<PluginRegexValidationError>,
734) {
735 for plugin in active {
736 process_static_patterns(*plugin, input.root, result);
737 }
738 process_package_json_metadata(active, input.pkg, input.root, result, regex_errors);
739}
740
741fn resolve_workspace_plugin_configs(
742 active: &[&dyn Plugin],
743 input: &WorkspacePluginRunInput<'_>,
744 result: &mut AggregatedPluginResult,
745 regex_errors: &mut Vec<PluginRegexValidationError>,
746) {
747 let workspace_matchers = select_workspace_matchers(
748 input.precompiled_config_matchers,
749 active,
750 input.skip_config_plugins,
751 );
752
753 let mut resolved_ws_plugins: FxHashSet<&str> = FxHashSet::default();
754 for (plugin, matchers) in &workspace_matchers {
755 resolve_plugin_matching_files(&mut PluginMatchingFilesInput {
756 plugin: *plugin,
757 matchers,
758 relative_files: input.relative_files,
759 root: input.root,
760 result,
761 regex_errors,
762 resolved_plugins: &mut resolved_ws_plugins,
763 });
764 }
765
766 load_workspace_filesystem_configs(&mut WorkspaceFsConfigInput {
767 workspace_matchers: &workspace_matchers,
768 resolved_ws_plugins: &resolved_ws_plugins,
769 root: input.root,
770 project_root: input.project_root,
771 production_mode: input.production_mode,
772 candidate_index: input.candidate_index,
773 result,
774 regex_errors,
775 });
776}
777
778impl Default for PluginRegistry {
779 fn default() -> Self {
780 Self::new(vec![])
781 }
782}
783
784impl PluginRegistry {
785 fn prepare_plugin_run_context<'a>(
786 &'a self,
787 pkg: &PackageJson,
788 root: &Path,
789 discovered_files: &[PathBuf],
790 production_mode: bool,
791 candidate_index: Option<&ConfigCandidateIndex>,
792 ) -> PluginRunContext<'a> {
793 let all_deps = pkg.all_dependency_names();
794 let script_packages = script_activation_packages(pkg, root, &all_deps, production_mode);
795 let active = self.collect_active_plugins(&PluginActivationInput {
796 pkg,
797 root,
798 discovered_files,
799 all_deps: &all_deps,
800 script_packages: &script_packages,
801 candidate_index,
802 });
803
804 PluginRunContext { all_deps, active }
805 }
806
807 fn run_plugin_preflight(
808 &self,
809 active: &[&dyn Plugin],
810 all_deps: &[String],
811 root: &Path,
812 discovered_files: &[PathBuf],
813 ) {
814 log_active_plugins(active);
815 check_meta_framework_prerequisites(active, root);
816 self.emit_silent_fail_diagnostics(active, all_deps, root, discovered_files);
817 }
818
819 fn collect_active_plugins<'a>(
822 &'a self,
823 activation: &PluginActivationInput<'_>,
824 ) -> Vec<&'a dyn Plugin> {
825 self.plugins
826 .iter()
827 .filter(|p| {
828 p.is_enabled_with_files(
829 activation.all_deps,
830 activation.root,
831 activation.discovered_files,
832 activation.candidate_index,
833 ) || p.is_enabled_with_scripts(activation.script_packages, activation.root)
834 || p.is_enabled_with_package_json(activation.pkg, activation.root)
835 })
836 .map(AsRef::as_ref)
837 .collect()
838 }
839
840 fn emit_silent_fail_diagnostics(
849 &self,
850 active: &[&dyn Plugin],
851 all_deps: &[String],
852 root: &Path,
853 discovered_files: &[PathBuf],
854 ) {
855 let active_external: Vec<&ExternalPluginDef> = self
856 .external_plugins
857 .iter()
858 .filter(|ext| is_external_plugin_active(ext, all_deps, root, discovered_files))
859 .collect();
860 let mut diagnostics = detect_pattern_collisions(active, &active_external);
861 diagnostics.extend(detect_enabler_typos(&self.external_plugins, all_deps));
862 emit_plugin_diagnostics(&diagnostics);
863 }
864}
865
866fn plugin_warn_dedupe() -> &'static std::sync::Mutex<FxHashSet<String>> {
873 static WARNED: std::sync::OnceLock<std::sync::Mutex<FxHashSet<String>>> =
874 std::sync::OnceLock::new();
875 WARNED.get_or_init(|| std::sync::Mutex::new(FxHashSet::default()))
876}
877
878struct PluginConfigResolutionInput<'a> {
879 config_matchers: &'a [(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
880 relative_files: &'a [(PathBuf, String)],
881 config_search_roots: &'a [&'a Path],
882 production_mode: bool,
883 candidate_index: Option<&'a ConfigCandidateIndex>,
884 root: &'a Path,
885 result: &'a mut AggregatedPluginResult,
886 regex_errors: &'a mut Vec<PluginRegexValidationError>,
887}
888
889fn select_workspace_matchers<'a>(
892 precompiled_config_matchers: &[(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
893 active: &[&dyn Plugin],
894 skip_config_plugins: &FxHashSet<&str>,
895) -> Vec<(&'a dyn Plugin, Vec<globset::GlobMatcher>)> {
896 let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
897 precompiled_config_matchers
898 .iter()
899 .filter(|(p, _)| {
900 active_names.contains(p.name())
901 && (!skip_config_plugins.contains(p.name())
902 || must_parse_workspace_config_when_root_active(p.name()))
903 })
904 .map(|(plugin, matchers)| (*plugin, matchers.clone()))
905 .collect()
906}
907
908struct WorkspaceFsConfigInput<'a> {
909 workspace_matchers: &'a [(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
910 resolved_ws_plugins: &'a FxHashSet<&'a str>,
911 root: &'a Path,
912 project_root: &'a Path,
913 production_mode: bool,
914 candidate_index: Option<&'a ConfigCandidateIndex>,
915 result: &'a mut AggregatedPluginResult,
916 regex_errors: &'a mut Vec<PluginRegexValidationError>,
917}
918
919fn load_workspace_filesystem_configs(input: &mut WorkspaceFsConfigInput<'_>) {
922 let search_roots: &[&Path] = if input.root == input.project_root {
923 &[input.root]
924 } else {
925 &[input.root, input.project_root]
926 };
927 let ws_json_configs = discover_config_files(
928 input.workspace_matchers,
929 input.resolved_ws_plugins,
930 search_roots,
931 input.production_mode,
932 input.candidate_index,
933 );
934 for (abs_path, plugin) in &ws_json_configs {
935 let Ok(source) = std::fs::read_to_string(abs_path) else {
936 continue;
937 };
938 let plugin_result = plugin.resolve_config(abs_path, &source, input.root);
939 if plugin_result.is_empty() {
940 continue;
941 }
942 let rel = abs_path
943 .strip_prefix(input.project_root)
944 .map(|p| p.to_string_lossy())
945 .unwrap_or_default();
946 tracing::debug!(
947 plugin = plugin.name(),
948 config = %rel,
949 entries = plugin_result.entry_patterns.len(),
950 deps = plugin_result.referenced_dependencies.len(),
951 "resolved config (workspace filesystem fallback)"
952 );
953 if let Err(mut errors) =
954 process_config_result(plugin.name(), plugin_result, input.result, Some(abs_path))
955 {
956 input.regex_errors.append(&mut errors);
957 }
958 }
959}
960
961fn resolve_plugin_config_files(input: &mut PluginConfigResolutionInput<'_>) {
962 if input.config_matchers.is_empty() {
963 return;
964 }
965
966 let mut resolved_plugins: FxHashSet<&str> = FxHashSet::default();
967 for (plugin, matchers) in input.config_matchers {
968 resolve_plugin_matching_files(&mut PluginMatchingFilesInput {
969 plugin: *plugin,
970 matchers,
971 relative_files: input.relative_files,
972 root: input.root,
973 result: input.result,
974 regex_errors: input.regex_errors,
975 resolved_plugins: &mut resolved_plugins,
976 });
977 }
978
979 let json_configs = discover_config_files(
980 input.config_matchers,
981 &resolved_plugins,
982 input.config_search_roots,
983 input.production_mode,
984 input.candidate_index,
985 );
986 for (abs_path, plugin) in &json_configs {
987 resolve_plugin_filesystem_config(
988 *plugin,
989 abs_path,
990 input.root,
991 input.result,
992 input.regex_errors,
993 );
994 }
995}
996
997struct PluginMatchingFilesInput<'plugins, 'data, 'state> {
998 plugin: &'plugins dyn Plugin,
999 matchers: &'data [globset::GlobMatcher],
1000 relative_files: &'data [(PathBuf, String)],
1001 root: &'data Path,
1002 result: &'state mut AggregatedPluginResult,
1003 regex_errors: &'state mut Vec<PluginRegexValidationError>,
1004 resolved_plugins: &'state mut FxHashSet<&'plugins str>,
1005}
1006
1007fn resolve_plugin_matching_files(input: &mut PluginMatchingFilesInput<'_, '_, '_>) {
1008 use rayon::prelude::*;
1009
1010 let plugin_hits: Vec<&PathBuf> = input
1011 .relative_files
1012 .par_iter()
1013 .filter_map(|(abs_path, rel_path)| {
1014 input
1015 .matchers
1016 .iter()
1017 .any(|m| m.is_match(rel_path.as_str()))
1018 .then_some(abs_path)
1019 })
1020 .collect();
1021 for abs_path in plugin_hits {
1022 let Ok(source) = std::fs::read_to_string(abs_path) else {
1023 continue;
1024 };
1025 let plugin_result = input.plugin.resolve_config(abs_path, &source, input.root);
1026 if plugin_result.is_empty() {
1027 continue;
1028 }
1029 input.resolved_plugins.insert(input.plugin.name());
1030 process_resolved_plugin_config(ResolvedPluginConfigInput {
1031 plugin: input.plugin,
1032 abs_path,
1033 plugin_result,
1034 result: input.result,
1035 regex_errors: input.regex_errors,
1036 message: "resolved config",
1037 config_display: abs_path.display(),
1038 });
1039 }
1040}
1041
1042fn resolve_plugin_filesystem_config(
1043 plugin: &dyn Plugin,
1044 abs_path: &Path,
1045 root: &Path,
1046 result: &mut AggregatedPluginResult,
1047 regex_errors: &mut Vec<PluginRegexValidationError>,
1048) {
1049 let Ok(source) = std::fs::read_to_string(abs_path) else {
1050 return;
1051 };
1052 let plugin_result = plugin.resolve_config(abs_path, &source, root);
1053 if plugin_result.is_empty() {
1054 return;
1055 }
1056 let rel = abs_path
1057 .strip_prefix(root)
1058 .map(|p| p.to_string_lossy())
1059 .unwrap_or_default();
1060 process_resolved_plugin_config(ResolvedPluginConfigInput {
1061 plugin,
1062 abs_path,
1063 plugin_result,
1064 result,
1065 regex_errors,
1066 message: "resolved config (filesystem fallback)",
1067 config_display: rel,
1068 });
1069}
1070
1071struct ResolvedPluginConfigInput<'a, D> {
1072 plugin: &'a dyn Plugin,
1073 abs_path: &'a Path,
1074 plugin_result: PluginResult,
1075 result: &'a mut AggregatedPluginResult,
1076 regex_errors: &'a mut Vec<PluginRegexValidationError>,
1077 message: &'static str,
1078 config_display: D,
1079}
1080
1081fn process_resolved_plugin_config(input: ResolvedPluginConfigInput<'_, impl std::fmt::Display>) {
1082 tracing::debug!(
1083 plugin = input.plugin.name(),
1084 config = %input.config_display,
1085 entries = input.plugin_result.entry_patterns.len(),
1086 deps = input.plugin_result.referenced_dependencies.len(),
1087 input.message
1088 );
1089 if let Err(mut errors) = process_config_result(
1090 input.plugin.name(),
1091 input.plugin_result,
1092 input.result,
1093 Some(input.abs_path),
1094 ) {
1095 input.regex_errors.append(&mut errors);
1096 }
1097}
1098
1099fn should_warn(key: String) -> bool {
1103 plugin_warn_dedupe()
1104 .lock()
1105 .map_or(true, |mut set| set.insert(key))
1106}
1107
1108#[derive(Debug, Clone, PartialEq, Eq)]
1115pub(crate) enum PluginDiagnostic {
1116 PatternCollision {
1118 pattern: String,
1119 owners: Vec<String>,
1120 },
1121 EnablerTypo {
1124 plugin: String,
1125 enabler: String,
1126 suggestion: String,
1127 },
1128}
1129
1130fn detect_pattern_collisions(
1156 builtin_active: &[&dyn Plugin],
1157 external_active: &[&ExternalPluginDef],
1158) -> Vec<PluginDiagnostic> {
1159 use rustc_hash::FxHashMap;
1160
1161 let mut pattern_owners: FxHashMap<String, (Vec<String>, FxHashSet<String>)> =
1162 FxHashMap::default();
1163
1164 let record = |pattern_owners: &mut FxHashMap<_, (Vec<String>, FxHashSet<String>)>,
1165 pattern: String,
1166 name: String| {
1167 let (list, seen) = pattern_owners.entry(pattern).or_default();
1168 if seen.insert(name.clone()) {
1169 list.push(name);
1170 }
1171 };
1172
1173 for plugin in builtin_active {
1174 for pat in plugin.config_patterns() {
1175 record(
1176 &mut pattern_owners,
1177 (*pat).to_string(),
1178 plugin.name().to_string(),
1179 );
1180 }
1181 }
1182 for ext in external_active {
1183 for pat in &ext.config_patterns {
1184 record(&mut pattern_owners, pat.clone(), ext.name.clone());
1185 }
1186 }
1187
1188 let builtin_names: FxHashSet<&str> = builtin_active.iter().map(|p| p.name()).collect();
1195
1196 let mut findings: Vec<PluginDiagnostic> = pattern_owners
1197 .into_iter()
1198 .filter_map(|(pattern, (owners, _seen))| {
1199 if owners.len() < 2 || owners.iter().all(|o| builtin_names.contains(o.as_str())) {
1200 None
1201 } else {
1202 Some(PluginDiagnostic::PatternCollision { pattern, owners })
1203 }
1204 })
1205 .collect();
1206 findings.sort_unstable_by(|a, b| match (a, b) {
1207 (
1208 PluginDiagnostic::PatternCollision { pattern: ap, .. },
1209 PluginDiagnostic::PatternCollision { pattern: bp, .. },
1210 ) => ap.cmp(bp),
1211 _ => std::cmp::Ordering::Equal,
1212 });
1213 findings
1214}
1215
1216fn detect_enabler_typos(
1231 external_plugins: &[ExternalPluginDef],
1232 all_deps: &[String],
1233) -> Vec<PluginDiagnostic> {
1234 let mut findings = Vec::new();
1235
1236 for ext in external_plugins {
1237 if ext.detection.is_some() || ext.enablers.is_empty() {
1238 continue;
1239 }
1240
1241 let any_match = ext.enablers.iter().any(|enabler| {
1242 if enabler.ends_with('/') {
1243 all_deps.iter().any(|d| d.starts_with(enabler))
1244 } else {
1245 all_deps.iter().any(|d| d == enabler)
1246 }
1247 });
1248 if any_match {
1249 continue;
1250 }
1251
1252 for enabler in &ext.enablers {
1253 let candidates = all_deps.iter().map(String::as_str);
1254 let Some(suggestion) = fallow_config::levenshtein::closest_match(enabler, candidates)
1255 else {
1256 continue;
1257 };
1258
1259 findings.push(PluginDiagnostic::EnablerTypo {
1260 plugin: ext.name.clone(),
1261 enabler: enabler.clone(),
1262 suggestion: suggestion.to_string(),
1263 });
1264 }
1265 }
1266
1267 findings
1268}
1269
1270fn emit_plugin_diagnostics(findings: &[PluginDiagnostic]) {
1273 for finding in findings {
1274 match finding {
1275 PluginDiagnostic::PatternCollision { pattern, owners } => {
1276 let key = format!("collision::{pattern}::{owners:?}");
1277 if !should_warn(key) {
1278 continue;
1279 }
1280 let winner = &owners[0];
1281 let others = owners[1..].join(", ");
1282 tracing::warn!(
1283 "plugin config_patterns collision: identical pattern \
1284 '{pattern}' is claimed by plugins [{joined}]; '{winner}' \
1285 runs first (registration order), others ({others}) \
1286 follow. Rename one of the patterns or remove the \
1287 duplicate plugin to make resolution explicit. A future \
1288 release may reject identical-pattern collisions.",
1289 joined = owners.join(", "),
1290 );
1291 }
1292 PluginDiagnostic::EnablerTypo {
1293 plugin,
1294 enabler,
1295 suggestion,
1296 } => {
1297 let key = format!("enabler::{plugin}::{enabler}");
1298 if !should_warn(key) {
1299 continue;
1300 }
1301 tracing::warn!(
1302 "plugin '{plugin}' enabler '{enabler}' does not match any \
1303 dependency in package.json; did you mean '{suggestion}'? \
1304 The plugin will not activate. A future release may reject \
1305 unmatched enablers.",
1306 );
1307 }
1308 }
1309 }
1310}
1311
1312fn process_package_json_inline_configs(
1317 active: &[&dyn Plugin],
1318 config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
1319 relative_files: &[(PathBuf, String)],
1320 root: &Path,
1321 result: &mut AggregatedPluginResult,
1322 regex_errors: &mut Vec<PluginRegexValidationError>,
1323) {
1324 for plugin in active {
1325 let Some(key) = plugin.package_json_config_key() else {
1326 continue;
1327 };
1328 if check_has_config_file(*plugin, config_matchers, relative_files) {
1329 continue;
1330 }
1331 let pkg_path = root.join("package.json");
1332 let Ok(content) = std::fs::read_to_string(&pkg_path) else {
1333 continue;
1334 };
1335 let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
1336 continue;
1337 };
1338 let Some(config_value) = json.get(key) else {
1339 continue;
1340 };
1341 let config_json = serde_json::to_string(config_value).unwrap_or_default();
1342 let fake_path = root.join(format!("{key}.config.json"));
1343 let plugin_result = plugin.resolve_config(&fake_path, &config_json, root);
1344 if plugin_result.is_empty() {
1345 continue;
1346 }
1347 tracing::debug!(
1348 plugin = plugin.name(),
1349 key = key,
1350 "resolved inline package.json config"
1351 );
1352 if let Err(mut errors) =
1353 process_config_result(plugin.name(), plugin_result, result, Some(&pkg_path))
1354 {
1355 regex_errors.append(&mut errors);
1356 }
1357 }
1358}
1359
1360#[derive(Debug)]
1363struct MetaFrameworkWarning {
1364 dedupe_key: &'static str,
1365 message: &'static str,
1366}
1367
1368fn missing_meta_framework_prerequisites(
1378 active_plugins: &[&dyn Plugin],
1379 root: &Path,
1380) -> Vec<MetaFrameworkWarning> {
1381 active_plugins
1382 .iter()
1383 .filter_map(|plugin| match plugin.name() {
1384 "nuxt" if !root.join(".nuxt/tsconfig.json").exists() => Some(MetaFrameworkWarning {
1385 dedupe_key: "meta-prereq::nuxt",
1386 message: "Nuxt project missing .nuxt/tsconfig.json: run `nuxt prepare` \
1387 before fallow for accurate analysis",
1388 }),
1389 "astro" if !root.join(".astro").exists() => Some(MetaFrameworkWarning {
1390 dedupe_key: "meta-prereq::astro",
1391 message: "Astro project missing .astro/ types: run `astro sync` \
1392 before fallow for accurate analysis",
1393 }),
1394 _ => None,
1395 })
1396 .collect()
1397}
1398
1399fn check_meta_framework_prerequisites(active_plugins: &[&dyn Plugin], root: &Path) {
1409 for warning in missing_meta_framework_prerequisites(active_plugins, root) {
1410 if should_warn(warning.dedupe_key.to_owned()) {
1411 tracing::warn!("{}", warning.message);
1412 }
1413 }
1414}
1415
1416fn script_activation_packages(
1417 pkg: &PackageJson,
1418 root: &Path,
1419 all_deps: &[String],
1420 production_mode: bool,
1421) -> FxHashSet<String> {
1422 let Some(pkg_scripts) = pkg.scripts.as_ref() else {
1423 return FxHashSet::default();
1424 };
1425
1426 let scripts_to_analyze = if production_mode {
1427 scripts::filter_production_scripts(pkg_scripts)
1428 } else {
1429 pkg_scripts.clone()
1430 };
1431
1432 let mut nm_roots = Vec::new();
1433 if root.join("node_modules").is_dir() {
1434 nm_roots.push(root);
1435 }
1436 let bin_map = scripts::build_bin_to_package_map(&nm_roots, all_deps);
1437 let dep_set: FxHashSet<String> = all_deps.iter().cloned().collect();
1438 let catalog =
1439 scripts::ScriptCatalog::from_scripts_with_bodies(pkg_scripts, &scripts_to_analyze);
1440
1441 scripts::analyze_scripts_with_dependency_context(
1442 &scripts_to_analyze,
1443 root,
1444 &bin_map,
1445 &dep_set,
1446 &catalog,
1447 scripts::IgnoredCommandEntries::NONE,
1448 )
1449 .used_packages
1450}
1451
1452#[cfg(test)]
1453mod tests;