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_plugin_config_candidate_basenames() -> Vec<String> {
37 let mut set: FxHashSet<String> = FxHashSet::default();
38 for plugin in builtin::create_builtin_plugins() {
39 for pattern in plugin.config_patterns() {
40 let basename = pattern.rsplit('/').next().unwrap_or(pattern);
41 set.insert(basename.to_string());
42 }
43 }
44 let mut basenames = set.into_iter().collect::<Vec<_>>();
45 basenames.sort_unstable();
46 basenames
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" | "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)]
286pub struct AggregatedPluginResult {
287 pub entry_patterns: Vec<(PathRule, String)>,
289 pub entry_point_roles: rustc_hash::FxHashMap<String, EntryPointRole>,
291 pub config_patterns: Vec<String>,
293 pub always_used: Vec<(String, String)>,
295 pub used_exports: Vec<PluginUsedExportRule>,
297 pub used_class_members: Vec<UsedClassMemberRule>,
301 pub framework_class_member_contracts: Vec<fallow_types::semantic::SemanticFrameworkContract>,
303 pub referenced_dependencies: Vec<String>,
305 pub package_referenced_dependencies: Vec<(PathBuf, String)>,
307 pub discovered_always_used: Vec<(String, String)>,
309 pub setup_files: Vec<(PathBuf, String)>,
311 pub tooling_dependencies: Vec<String>,
313 pub script_used_packages: FxHashSet<String>,
315 pub virtual_module_prefixes: Vec<String>,
318 pub virtual_package_suffixes: Vec<String>,
321 pub generated_import_patterns: Vec<String>,
324 pub generated_type_import_prefixes: Vec<String>,
327 pub path_aliases: Vec<(String, String)>,
330 pub auto_imports: Vec<AutoImportRule>,
334 pub active_plugins: Vec<String>,
336 pub fixture_patterns: Vec<(String, String)>,
338 pub scss_include_paths: Vec<PathBuf>,
343 pub static_dir_mappings: Vec<(PathBuf, String)>,
345 pub framework_static_dir_mappings: Vec<(PathBuf, String)>,
348 pub provided_dependencies: Vec<ProvidedDependencyRule>,
350 pub config_diagnostics: Vec<super::PluginConfigDiagnostic>,
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 (_, replacement) in &mut self.path_aliases {
416 *replacement = format!("{ws_prefix}/{replacement}");
417 }
418 }
419
420 pub(crate) fn merge_into(&mut self, other: Self) {
434 let Self {
435 entry_patterns,
436 entry_point_roles,
437 config_patterns,
438 always_used,
439 used_exports,
440 used_class_members,
441 framework_class_member_contracts,
442 referenced_dependencies,
443 package_referenced_dependencies,
444 discovered_always_used,
445 setup_files,
446 tooling_dependencies,
447 script_used_packages,
448 virtual_module_prefixes,
449 virtual_package_suffixes,
450 generated_import_patterns,
451 generated_type_import_prefixes,
452 path_aliases,
453 auto_imports,
454 active_plugins,
455 fixture_patterns,
456 scss_include_paths,
457 static_dir_mappings,
458 framework_static_dir_mappings,
459 provided_dependencies,
460 config_diagnostics,
461 } = other;
462
463 self.entry_patterns.extend(entry_patterns);
464 for (plugin_name, role) in entry_point_roles {
465 self.entry_point_roles.entry(plugin_name).or_insert(role);
466 }
467 self.config_patterns.extend(config_patterns);
468 self.always_used.extend(always_used);
469 self.used_exports.extend(used_exports);
470 self.used_class_members.extend(used_class_members);
471 for contract in framework_class_member_contracts {
472 if !self.framework_class_member_contracts.contains(&contract) {
473 self.framework_class_member_contracts.push(contract);
474 }
475 }
476 self.referenced_dependencies.extend(referenced_dependencies);
477 self.package_referenced_dependencies
478 .extend(package_referenced_dependencies);
479 self.discovered_always_used.extend(discovered_always_used);
480 self.setup_files.extend(setup_files);
481 self.tooling_dependencies.extend(tooling_dependencies);
482 self.script_used_packages.extend(script_used_packages);
483 extend_unique(&mut self.virtual_module_prefixes, virtual_module_prefixes);
484 extend_unique(&mut self.virtual_package_suffixes, virtual_package_suffixes);
485 extend_unique(
486 &mut self.generated_import_patterns,
487 generated_import_patterns,
488 );
489 extend_unique(
490 &mut self.generated_type_import_prefixes,
491 generated_type_import_prefixes,
492 );
493 self.path_aliases.extend(path_aliases);
494 self.auto_imports.extend(auto_imports);
495 extend_unique(&mut self.active_plugins, active_plugins);
496 self.fixture_patterns.extend(fixture_patterns);
497 self.scss_include_paths.extend(scss_include_paths);
498 self.static_dir_mappings.extend(static_dir_mappings);
499 self.framework_static_dir_mappings
500 .extend(framework_static_dir_mappings);
501 self.provided_dependencies.extend(provided_dependencies);
502 for diagnostic in config_diagnostics {
503 if !self.config_diagnostics.contains(&diagnostic) {
504 self.config_diagnostics.push(diagnostic);
505 }
506 }
507 }
508}
509
510impl PluginRegistry {
511 #[must_use]
513 pub fn new(external: Vec<ExternalPluginDef>) -> Self {
514 Self {
515 plugins: builtin::create_builtin_plugins(),
516 external_plugins: external,
517 }
518 }
519
520 #[must_use]
525 pub fn discovery_hidden_dirs(&self, pkg: &PackageJson, root: &Path) -> Vec<String> {
526 let all_deps = pkg.all_dependency_names();
527 let mut seen = FxHashSet::default();
528 let mut dirs = Vec::new();
529
530 for plugin in &self.plugins {
531 if !plugin.is_enabled_with_deps(&all_deps, root) {
532 continue;
533 }
534 for dir in plugin.discovery_hidden_dirs() {
535 if seen.insert(*dir) {
536 dirs.push((*dir).to_string());
537 }
538 }
539 }
540
541 dirs
542 }
543
544 #[cfg(test)]
549 fn run(
550 &self,
551 pkg: &PackageJson,
552 root: &Path,
553 discovered_files: &[PathBuf],
554 ) -> AggregatedPluginResult {
555 self.try_run(pkg, root, discovered_files)
556 .unwrap_or_else(|errors| panic!("{}", format_plugin_regex_errors(&errors)))
557 }
558
559 pub fn try_run(
561 &self,
562 pkg: &PackageJson,
563 root: &Path,
564 discovered_files: &[PathBuf],
565 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
566 self.try_run_with_search_roots(pkg, root, discovered_files, &[root], false, None)
567 }
568
569 #[expect(
572 clippy::too_many_arguments,
573 reason = "public PluginRegistry API; signature is part of the crate surface for embedders"
574 )]
575 pub(crate) fn try_run_with_search_roots(
576 &self,
577 pkg: &PackageJson,
578 root: &Path,
579 discovered_files: &[PathBuf],
580 config_search_roots: &[&Path],
581 production_mode: bool,
582 candidate_index: Option<&ConfigCandidateIndex>,
583 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
584 let _span = tracing::info_span!("run_plugins").entered();
585 let mut result = AggregatedPluginResult::default();
586 let mut regex_errors = Vec::new();
587
588 let PluginRunContext { all_deps, active } = self.prepare_plugin_run_context(
589 pkg,
590 root,
591 discovered_files,
592 production_mode,
593 candidate_index,
594 );
595
596 self.run_plugin_preflight(&active, &all_deps, root, discovered_files);
597
598 for plugin in &active {
599 process_static_patterns(*plugin, root, &mut result);
600 }
601 process_package_json_metadata(&active, pkg, root, &mut result, &mut regex_errors);
602
603 process_external_plugins(
604 &self.external_plugins,
605 &all_deps,
606 root,
607 discovered_files,
608 &mut result,
609 );
610
611 let config_matchers = compile_config_matchers(&active);
612 let relative_files =
613 compute_relative_files(&config_matchers, &active, discovered_files, root);
614
615 resolve_plugin_config_files(&mut PluginConfigResolutionInput {
616 config_matchers: &config_matchers,
617 relative_files: &relative_files,
618 config_search_roots,
619 production_mode,
620 candidate_index,
621 root,
622 result: &mut result,
623 regex_errors: &mut regex_errors,
624 });
625
626 process_package_json_inline_configs(
627 &active,
628 &config_matchers,
629 &relative_files,
630 root,
631 &mut result,
632 &mut regex_errors,
633 );
634
635 if regex_errors.is_empty() {
636 Ok(result)
637 } else {
638 Err(regex_errors)
639 }
640 }
641
642 #[cfg(test)]
648 fn run_workspace_fast(&self, input: &WorkspacePluginRunInput<'_>) -> AggregatedPluginResult {
649 self.try_run_workspace_fast(input)
650 .unwrap_or_else(|errors| panic!("{}", format_plugin_regex_errors(&errors)))
651 }
652
653 pub(crate) fn try_run_workspace_fast(
659 &self,
660 input: &WorkspacePluginRunInput<'_>,
661 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
662 let _span = tracing::info_span!("run_plugins").entered();
663 let mut result = AggregatedPluginResult::default();
664 let mut regex_errors = Vec::new();
665
666 let all_deps = input.pkg.all_dependency_names();
667 let script_packages =
668 script_activation_packages(input.pkg, input.root, &all_deps, input.production_mode);
669 let workspace_files: Vec<PathBuf> = input
670 .relative_files
671 .iter()
672 .map(|(abs_path, _)| abs_path.clone())
673 .collect();
674
675 let active = self.collect_active_plugins(&PluginActivationInput {
676 pkg: input.pkg,
677 root: input.root,
678 discovered_files: &workspace_files,
679 all_deps: &all_deps,
680 script_packages: &script_packages,
681 candidate_index: input.candidate_index,
682 });
683
684 log_active_plugins(&active);
685
686 self.emit_silent_fail_diagnostics(&active, &all_deps, input.root, &workspace_files);
687
688 process_external_plugins(
689 &self.external_plugins,
690 &all_deps,
691 input.root,
692 &workspace_files,
693 &mut result,
694 );
695
696 if active.is_empty() && result.active_plugins.is_empty() {
697 return Ok(result);
698 }
699
700 process_workspace_active_plugins(&active, input, &mut result, &mut regex_errors);
701 resolve_workspace_plugin_configs(&active, input, &mut result, &mut regex_errors);
702
703 if regex_errors.is_empty() {
704 Ok(result)
705 } else {
706 Err(regex_errors)
707 }
708 }
709
710 #[must_use]
713 pub(crate) fn precompile_config_matchers(
714 &self,
715 ) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
716 self.plugins
717 .iter()
718 .filter(|p| !p.config_patterns().is_empty())
719 .map(|p| (p.as_ref(), cached_plugin_config_matchers(p.as_ref())))
720 .collect()
721 }
722}
723
724fn process_workspace_active_plugins(
725 active: &[&dyn Plugin],
726 input: &WorkspacePluginRunInput<'_>,
727 result: &mut AggregatedPluginResult,
728 regex_errors: &mut Vec<PluginRegexValidationError>,
729) {
730 for plugin in active {
731 process_static_patterns(*plugin, input.root, result);
732 }
733 process_package_json_metadata(active, input.pkg, input.root, result, regex_errors);
734}
735
736fn resolve_workspace_plugin_configs(
737 active: &[&dyn Plugin],
738 input: &WorkspacePluginRunInput<'_>,
739 result: &mut AggregatedPluginResult,
740 regex_errors: &mut Vec<PluginRegexValidationError>,
741) {
742 let workspace_matchers = select_workspace_matchers(
743 input.precompiled_config_matchers,
744 active,
745 input.skip_config_plugins,
746 );
747
748 let mut resolved_ws_plugins: FxHashSet<&str> = FxHashSet::default();
749 for (plugin, matchers) in &workspace_matchers {
750 resolve_plugin_matching_files(&mut PluginMatchingFilesInput {
751 plugin: *plugin,
752 matchers,
753 relative_files: input.relative_files,
754 root: input.root,
755 result,
756 regex_errors,
757 resolved_plugins: &mut resolved_ws_plugins,
758 });
759 }
760
761 load_workspace_filesystem_configs(&mut WorkspaceFsConfigInput {
762 workspace_matchers: &workspace_matchers,
763 resolved_ws_plugins: &resolved_ws_plugins,
764 root: input.root,
765 project_root: input.project_root,
766 production_mode: input.production_mode,
767 candidate_index: input.candidate_index,
768 result,
769 regex_errors,
770 });
771}
772
773impl Default for PluginRegistry {
774 fn default() -> Self {
775 Self::new(vec![])
776 }
777}
778
779impl PluginRegistry {
780 fn prepare_plugin_run_context<'a>(
781 &'a self,
782 pkg: &PackageJson,
783 root: &Path,
784 discovered_files: &[PathBuf],
785 production_mode: bool,
786 candidate_index: Option<&ConfigCandidateIndex>,
787 ) -> PluginRunContext<'a> {
788 let all_deps = pkg.all_dependency_names();
789 let script_packages = script_activation_packages(pkg, root, &all_deps, production_mode);
790 let active = self.collect_active_plugins(&PluginActivationInput {
791 pkg,
792 root,
793 discovered_files,
794 all_deps: &all_deps,
795 script_packages: &script_packages,
796 candidate_index,
797 });
798
799 PluginRunContext { all_deps, active }
800 }
801
802 fn run_plugin_preflight(
803 &self,
804 active: &[&dyn Plugin],
805 all_deps: &[String],
806 root: &Path,
807 discovered_files: &[PathBuf],
808 ) {
809 log_active_plugins(active);
810 check_meta_framework_prerequisites(active, root);
811 self.emit_silent_fail_diagnostics(active, all_deps, root, discovered_files);
812 }
813
814 fn collect_active_plugins<'a>(
817 &'a self,
818 activation: &PluginActivationInput<'_>,
819 ) -> Vec<&'a dyn Plugin> {
820 self.plugins
821 .iter()
822 .filter(|p| {
823 p.is_enabled_with_files(
824 activation.all_deps,
825 activation.root,
826 activation.discovered_files,
827 activation.candidate_index,
828 ) || p.is_enabled_with_scripts(activation.script_packages, activation.root)
829 || p.is_enabled_with_package_json(activation.pkg, activation.root)
830 })
831 .map(AsRef::as_ref)
832 .collect()
833 }
834
835 fn emit_silent_fail_diagnostics(
844 &self,
845 active: &[&dyn Plugin],
846 all_deps: &[String],
847 root: &Path,
848 discovered_files: &[PathBuf],
849 ) {
850 let active_external: Vec<&ExternalPluginDef> = self
851 .external_plugins
852 .iter()
853 .filter(|ext| is_external_plugin_active(ext, all_deps, root, discovered_files))
854 .collect();
855 let mut diagnostics = detect_pattern_collisions(active, &active_external);
856 diagnostics.extend(detect_enabler_typos(&self.external_plugins, all_deps));
857 emit_plugin_diagnostics(&diagnostics);
858 }
859}
860
861fn plugin_warn_dedupe() -> &'static std::sync::Mutex<FxHashSet<String>> {
868 static WARNED: std::sync::OnceLock<std::sync::Mutex<FxHashSet<String>>> =
869 std::sync::OnceLock::new();
870 WARNED.get_or_init(|| std::sync::Mutex::new(FxHashSet::default()))
871}
872
873struct PluginConfigResolutionInput<'a> {
874 config_matchers: &'a [(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
875 relative_files: &'a [(PathBuf, String)],
876 config_search_roots: &'a [&'a Path],
877 production_mode: bool,
878 candidate_index: Option<&'a ConfigCandidateIndex>,
879 root: &'a Path,
880 result: &'a mut AggregatedPluginResult,
881 regex_errors: &'a mut Vec<PluginRegexValidationError>,
882}
883
884fn select_workspace_matchers<'a>(
887 precompiled_config_matchers: &[(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
888 active: &[&dyn Plugin],
889 skip_config_plugins: &FxHashSet<&str>,
890) -> Vec<(&'a dyn Plugin, Vec<globset::GlobMatcher>)> {
891 let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
892 precompiled_config_matchers
893 .iter()
894 .filter(|(p, _)| {
895 active_names.contains(p.name())
896 && (!skip_config_plugins.contains(p.name())
897 || must_parse_workspace_config_when_root_active(p.name()))
898 })
899 .map(|(plugin, matchers)| (*plugin, matchers.clone()))
900 .collect()
901}
902
903struct WorkspaceFsConfigInput<'a> {
904 workspace_matchers: &'a [(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
905 resolved_ws_plugins: &'a FxHashSet<&'a str>,
906 root: &'a Path,
907 project_root: &'a Path,
908 production_mode: bool,
909 candidate_index: Option<&'a ConfigCandidateIndex>,
910 result: &'a mut AggregatedPluginResult,
911 regex_errors: &'a mut Vec<PluginRegexValidationError>,
912}
913
914fn load_workspace_filesystem_configs(input: &mut WorkspaceFsConfigInput<'_>) {
917 let search_roots: &[&Path] = if input.root == input.project_root {
918 &[input.root]
919 } else {
920 &[input.root, input.project_root]
921 };
922 let ws_json_configs = discover_config_files(
923 input.workspace_matchers,
924 input.resolved_ws_plugins,
925 search_roots,
926 input.production_mode,
927 input.candidate_index,
928 );
929 for (abs_path, plugin) in &ws_json_configs {
930 let Ok(source) = std::fs::read_to_string(abs_path) else {
931 continue;
932 };
933 let plugin_result = plugin.resolve_config(abs_path, &source, input.root);
934 if plugin_result.is_empty() {
935 continue;
936 }
937 let rel = abs_path
938 .strip_prefix(input.project_root)
939 .map(|p| p.to_string_lossy())
940 .unwrap_or_default();
941 tracing::debug!(
942 plugin = plugin.name(),
943 config = %rel,
944 entries = plugin_result.entry_patterns.len(),
945 deps = plugin_result.referenced_dependencies.len(),
946 "resolved config (workspace filesystem fallback)"
947 );
948 if let Err(mut errors) =
949 process_config_result(plugin.name(), plugin_result, input.result, Some(abs_path))
950 {
951 input.regex_errors.append(&mut errors);
952 }
953 }
954}
955
956fn resolve_plugin_config_files(input: &mut PluginConfigResolutionInput<'_>) {
957 if input.config_matchers.is_empty() {
958 return;
959 }
960
961 let mut resolved_plugins: FxHashSet<&str> = FxHashSet::default();
962 for (plugin, matchers) in input.config_matchers {
963 resolve_plugin_matching_files(&mut PluginMatchingFilesInput {
964 plugin: *plugin,
965 matchers,
966 relative_files: input.relative_files,
967 root: input.root,
968 result: input.result,
969 regex_errors: input.regex_errors,
970 resolved_plugins: &mut resolved_plugins,
971 });
972 }
973
974 let json_configs = discover_config_files(
975 input.config_matchers,
976 &resolved_plugins,
977 input.config_search_roots,
978 input.production_mode,
979 input.candidate_index,
980 );
981 for (abs_path, plugin) in &json_configs {
982 resolve_plugin_filesystem_config(
983 *plugin,
984 abs_path,
985 input.root,
986 input.result,
987 input.regex_errors,
988 );
989 }
990}
991
992struct PluginMatchingFilesInput<'plugins, 'data, 'state> {
993 plugin: &'plugins dyn Plugin,
994 matchers: &'data [globset::GlobMatcher],
995 relative_files: &'data [(PathBuf, String)],
996 root: &'data Path,
997 result: &'state mut AggregatedPluginResult,
998 regex_errors: &'state mut Vec<PluginRegexValidationError>,
999 resolved_plugins: &'state mut FxHashSet<&'plugins str>,
1000}
1001
1002fn resolve_plugin_matching_files(input: &mut PluginMatchingFilesInput<'_, '_, '_>) {
1003 use rayon::prelude::*;
1004
1005 let plugin_hits: Vec<&PathBuf> = input
1006 .relative_files
1007 .par_iter()
1008 .filter_map(|(abs_path, rel_path)| {
1009 input
1010 .matchers
1011 .iter()
1012 .any(|m| m.is_match(rel_path.as_str()))
1013 .then_some(abs_path)
1014 })
1015 .collect();
1016 for abs_path in plugin_hits {
1017 let Ok(source) = std::fs::read_to_string(abs_path) else {
1018 continue;
1019 };
1020 let plugin_result = input.plugin.resolve_config(abs_path, &source, input.root);
1021 if plugin_result.is_empty() {
1022 continue;
1023 }
1024 input.resolved_plugins.insert(input.plugin.name());
1025 process_resolved_plugin_config(ResolvedPluginConfigInput {
1026 plugin: input.plugin,
1027 abs_path,
1028 plugin_result,
1029 result: input.result,
1030 regex_errors: input.regex_errors,
1031 message: "resolved config",
1032 config_display: abs_path.display(),
1033 });
1034 }
1035}
1036
1037fn resolve_plugin_filesystem_config(
1038 plugin: &dyn Plugin,
1039 abs_path: &Path,
1040 root: &Path,
1041 result: &mut AggregatedPluginResult,
1042 regex_errors: &mut Vec<PluginRegexValidationError>,
1043) {
1044 let Ok(source) = std::fs::read_to_string(abs_path) else {
1045 return;
1046 };
1047 let plugin_result = plugin.resolve_config(abs_path, &source, root);
1048 if plugin_result.is_empty() {
1049 return;
1050 }
1051 let rel = abs_path
1052 .strip_prefix(root)
1053 .map(|p| p.to_string_lossy())
1054 .unwrap_or_default();
1055 process_resolved_plugin_config(ResolvedPluginConfigInput {
1056 plugin,
1057 abs_path,
1058 plugin_result,
1059 result,
1060 regex_errors,
1061 message: "resolved config (filesystem fallback)",
1062 config_display: rel,
1063 });
1064}
1065
1066struct ResolvedPluginConfigInput<'a, D> {
1067 plugin: &'a dyn Plugin,
1068 abs_path: &'a Path,
1069 plugin_result: PluginResult,
1070 result: &'a mut AggregatedPluginResult,
1071 regex_errors: &'a mut Vec<PluginRegexValidationError>,
1072 message: &'static str,
1073 config_display: D,
1074}
1075
1076fn process_resolved_plugin_config(input: ResolvedPluginConfigInput<'_, impl std::fmt::Display>) {
1077 tracing::debug!(
1078 plugin = input.plugin.name(),
1079 config = %input.config_display,
1080 entries = input.plugin_result.entry_patterns.len(),
1081 deps = input.plugin_result.referenced_dependencies.len(),
1082 input.message
1083 );
1084 if let Err(mut errors) = process_config_result(
1085 input.plugin.name(),
1086 input.plugin_result,
1087 input.result,
1088 Some(input.abs_path),
1089 ) {
1090 input.regex_errors.append(&mut errors);
1091 }
1092}
1093
1094fn should_warn(key: String) -> bool {
1098 plugin_warn_dedupe()
1099 .lock()
1100 .map_or(true, |mut set| set.insert(key))
1101}
1102
1103#[derive(Debug, Clone, PartialEq, Eq)]
1110pub(crate) enum PluginDiagnostic {
1111 PatternCollision {
1113 pattern: String,
1114 owners: Vec<String>,
1115 },
1116 EnablerTypo {
1119 plugin: String,
1120 enabler: String,
1121 suggestion: String,
1122 },
1123}
1124
1125fn detect_pattern_collisions(
1151 builtin_active: &[&dyn Plugin],
1152 external_active: &[&ExternalPluginDef],
1153) -> Vec<PluginDiagnostic> {
1154 use rustc_hash::FxHashMap;
1155
1156 let mut pattern_owners: FxHashMap<String, (Vec<String>, FxHashSet<String>)> =
1157 FxHashMap::default();
1158
1159 let record = |pattern_owners: &mut FxHashMap<_, (Vec<String>, FxHashSet<String>)>,
1160 pattern: String,
1161 name: String| {
1162 let (list, seen) = pattern_owners.entry(pattern).or_default();
1163 if seen.insert(name.clone()) {
1164 list.push(name);
1165 }
1166 };
1167
1168 for plugin in builtin_active {
1169 for pat in plugin.config_patterns() {
1170 record(
1171 &mut pattern_owners,
1172 (*pat).to_string(),
1173 plugin.name().to_string(),
1174 );
1175 }
1176 }
1177 for ext in external_active {
1178 for pat in &ext.config_patterns {
1179 record(&mut pattern_owners, pat.clone(), ext.name.clone());
1180 }
1181 }
1182
1183 let builtin_names: FxHashSet<&str> = builtin_active.iter().map(|p| p.name()).collect();
1190
1191 let mut findings: Vec<PluginDiagnostic> = pattern_owners
1192 .into_iter()
1193 .filter_map(|(pattern, (owners, _seen))| {
1194 if owners.len() < 2 || owners.iter().all(|o| builtin_names.contains(o.as_str())) {
1195 None
1196 } else {
1197 Some(PluginDiagnostic::PatternCollision { pattern, owners })
1198 }
1199 })
1200 .collect();
1201 findings.sort_unstable_by(|a, b| match (a, b) {
1202 (
1203 PluginDiagnostic::PatternCollision { pattern: ap, .. },
1204 PluginDiagnostic::PatternCollision { pattern: bp, .. },
1205 ) => ap.cmp(bp),
1206 _ => std::cmp::Ordering::Equal,
1207 });
1208 findings
1209}
1210
1211fn detect_enabler_typos(
1226 external_plugins: &[ExternalPluginDef],
1227 all_deps: &[String],
1228) -> Vec<PluginDiagnostic> {
1229 let mut findings = Vec::new();
1230
1231 for ext in external_plugins {
1232 if ext.detection.is_some() || ext.enablers.is_empty() {
1233 continue;
1234 }
1235
1236 let any_match = ext.enablers.iter().any(|enabler| {
1237 if enabler.ends_with('/') {
1238 all_deps.iter().any(|d| d.starts_with(enabler))
1239 } else {
1240 all_deps.iter().any(|d| d == enabler)
1241 }
1242 });
1243 if any_match {
1244 continue;
1245 }
1246
1247 for enabler in &ext.enablers {
1248 let candidates = all_deps.iter().map(String::as_str);
1249 let Some(suggestion) = fallow_config::levenshtein::closest_match(enabler, candidates)
1250 else {
1251 continue;
1252 };
1253
1254 findings.push(PluginDiagnostic::EnablerTypo {
1255 plugin: ext.name.clone(),
1256 enabler: enabler.clone(),
1257 suggestion: suggestion.to_string(),
1258 });
1259 }
1260 }
1261
1262 findings
1263}
1264
1265fn emit_plugin_diagnostics(findings: &[PluginDiagnostic]) {
1268 for finding in findings {
1269 match finding {
1270 PluginDiagnostic::PatternCollision { pattern, owners } => {
1271 let key = format!("collision::{pattern}::{owners:?}");
1272 if !should_warn(key) {
1273 continue;
1274 }
1275 let winner = &owners[0];
1276 let others = owners[1..].join(", ");
1277 tracing::warn!(
1278 "plugin config_patterns collision: identical pattern \
1279 '{pattern}' is claimed by plugins [{joined}]; '{winner}' \
1280 runs first (registration order), others ({others}) \
1281 follow. Rename one of the patterns or remove the \
1282 duplicate plugin to make resolution explicit. A future \
1283 release may reject identical-pattern collisions.",
1284 joined = owners.join(", "),
1285 );
1286 }
1287 PluginDiagnostic::EnablerTypo {
1288 plugin,
1289 enabler,
1290 suggestion,
1291 } => {
1292 let key = format!("enabler::{plugin}::{enabler}");
1293 if !should_warn(key) {
1294 continue;
1295 }
1296 tracing::warn!(
1297 "plugin '{plugin}' enabler '{enabler}' does not match any \
1298 dependency in package.json; did you mean '{suggestion}'? \
1299 The plugin will not activate. A future release may reject \
1300 unmatched enablers.",
1301 );
1302 }
1303 }
1304 }
1305}
1306
1307fn process_package_json_inline_configs(
1312 active: &[&dyn Plugin],
1313 config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
1314 relative_files: &[(PathBuf, String)],
1315 root: &Path,
1316 result: &mut AggregatedPluginResult,
1317 regex_errors: &mut Vec<PluginRegexValidationError>,
1318) {
1319 for plugin in active {
1320 let Some(key) = plugin.package_json_config_key() else {
1321 continue;
1322 };
1323 if check_has_config_file(*plugin, config_matchers, relative_files) {
1324 continue;
1325 }
1326 let pkg_path = root.join("package.json");
1327 let Ok(content) = std::fs::read_to_string(&pkg_path) else {
1328 continue;
1329 };
1330 let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
1331 continue;
1332 };
1333 let Some(config_value) = json.get(key) else {
1334 continue;
1335 };
1336 let config_json = serde_json::to_string(config_value).unwrap_or_default();
1337 let fake_path = root.join(format!("{key}.config.json"));
1338 let plugin_result = plugin.resolve_config(&fake_path, &config_json, root);
1339 if plugin_result.is_empty() {
1340 continue;
1341 }
1342 tracing::debug!(
1343 plugin = plugin.name(),
1344 key = key,
1345 "resolved inline package.json config"
1346 );
1347 if let Err(mut errors) =
1348 process_config_result(plugin.name(), plugin_result, result, Some(&pkg_path))
1349 {
1350 regex_errors.append(&mut errors);
1351 }
1352 }
1353}
1354
1355#[derive(Debug)]
1358struct MetaFrameworkWarning {
1359 dedupe_key: &'static str,
1360 message: &'static str,
1361}
1362
1363fn missing_meta_framework_prerequisites(
1373 active_plugins: &[&dyn Plugin],
1374 root: &Path,
1375) -> Vec<MetaFrameworkWarning> {
1376 active_plugins
1377 .iter()
1378 .filter_map(|plugin| match plugin.name() {
1379 "nuxt" if !root.join(".nuxt/tsconfig.json").exists() => Some(MetaFrameworkWarning {
1380 dedupe_key: "meta-prereq::nuxt",
1381 message: "Nuxt project missing .nuxt/tsconfig.json: run `nuxt prepare` \
1382 before fallow for accurate analysis",
1383 }),
1384 "astro" if !root.join(".astro").exists() => Some(MetaFrameworkWarning {
1385 dedupe_key: "meta-prereq::astro",
1386 message: "Astro project missing .astro/ types: run `astro sync` \
1387 before fallow for accurate analysis",
1388 }),
1389 _ => None,
1390 })
1391 .collect()
1392}
1393
1394fn check_meta_framework_prerequisites(active_plugins: &[&dyn Plugin], root: &Path) {
1404 for warning in missing_meta_framework_prerequisites(active_plugins, root) {
1405 if should_warn(warning.dedupe_key.to_owned()) {
1406 tracing::warn!("{}", warning.message);
1407 }
1408 }
1409}
1410
1411fn script_activation_packages(
1412 pkg: &PackageJson,
1413 root: &Path,
1414 all_deps: &[String],
1415 production_mode: bool,
1416) -> FxHashSet<String> {
1417 let Some(pkg_scripts) = pkg.scripts.as_ref() else {
1418 return FxHashSet::default();
1419 };
1420
1421 let scripts_to_analyze = if production_mode {
1422 scripts::filter_production_scripts(pkg_scripts)
1423 } else {
1424 pkg_scripts.clone()
1425 };
1426
1427 let mut nm_roots = Vec::new();
1428 if root.join("node_modules").is_dir() {
1429 nm_roots.push(root);
1430 }
1431 let bin_map = scripts::build_bin_to_package_map(&nm_roots, all_deps);
1432 let dep_set: FxHashSet<String> = all_deps.iter().cloned().collect();
1433 let catalog =
1434 scripts::ScriptCatalog::from_scripts_with_bodies(pkg_scripts, &scripts_to_analyze);
1435
1436 scripts::analyze_scripts_with_dependency_context(
1437 &scripts_to_analyze,
1438 root,
1439 &bin_map,
1440 &dep_set,
1441 &catalog,
1442 )
1443 .used_packages
1444}
1445
1446#[cfg(test)]
1447mod tests;