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