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 provided_dependencies: Vec<ProvidedDependencyRule>,
347}
348
349fn extend_unique(target: &mut Vec<String>, incoming: Vec<String>) {
354 let mut seen: FxHashSet<String> = target.iter().cloned().collect();
355 for item in incoming {
356 if seen.insert(item.clone()) {
357 target.push(item);
358 }
359 }
360}
361
362fn prefix_if_needed(pat: &str, ws_prefix: &str) -> String {
366 if pat.starts_with(ws_prefix) || pat.starts_with('/') {
367 pat.to_string()
368 } else {
369 format!("{ws_prefix}/{pat}")
370 }
371}
372
373impl AggregatedPluginResult {
374 pub(crate) fn apply_workspace_prefix(&mut self, ws_prefix: &str) {
388 for (rule, _) in &mut self.entry_patterns {
389 *rule = rule.prefixed(ws_prefix);
390 }
391 for (pat, _) in &mut self.always_used {
392 *pat = prefix_if_needed(pat, ws_prefix);
393 }
394 for (pat, _) in &mut self.discovered_always_used {
395 *pat = prefix_if_needed(pat, ws_prefix);
396 }
397 for (pat, _) in &mut self.fixture_patterns {
398 *pat = prefix_if_needed(pat, ws_prefix);
399 }
400 for rule in &mut self.used_exports {
401 *rule = rule.prefixed(ws_prefix);
402 }
403 for rule in &mut self.provided_dependencies {
404 *rule = rule.prefixed(ws_prefix);
405 }
406 for (_, replacement) in &mut self.path_aliases {
407 *replacement = format!("{ws_prefix}/{replacement}");
408 }
409 }
410
411 pub(crate) fn merge_into(&mut self, other: Self) {
425 let Self {
426 entry_patterns,
427 entry_point_roles,
428 config_patterns,
429 always_used,
430 used_exports,
431 used_class_members,
432 framework_class_member_contracts,
433 referenced_dependencies,
434 package_referenced_dependencies,
435 discovered_always_used,
436 setup_files,
437 tooling_dependencies,
438 script_used_packages,
439 virtual_module_prefixes,
440 virtual_package_suffixes,
441 generated_import_patterns,
442 generated_type_import_prefixes,
443 path_aliases,
444 auto_imports,
445 active_plugins,
446 fixture_patterns,
447 scss_include_paths,
448 static_dir_mappings,
449 provided_dependencies,
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.provided_dependencies.extend(provided_dependencies);
489 }
490}
491
492impl PluginRegistry {
493 #[must_use]
495 pub fn new(external: Vec<ExternalPluginDef>) -> Self {
496 Self {
497 plugins: builtin::create_builtin_plugins(),
498 external_plugins: external,
499 }
500 }
501
502 #[must_use]
507 pub fn discovery_hidden_dirs(&self, pkg: &PackageJson, root: &Path) -> Vec<String> {
508 let all_deps = pkg.all_dependency_names();
509 let mut seen = FxHashSet::default();
510 let mut dirs = Vec::new();
511
512 for plugin in &self.plugins {
513 if !plugin.is_enabled_with_deps(&all_deps, root) {
514 continue;
515 }
516 for dir in plugin.discovery_hidden_dirs() {
517 if seen.insert(*dir) {
518 dirs.push((*dir).to_string());
519 }
520 }
521 }
522
523 dirs
524 }
525
526 #[cfg(test)]
531 fn run(
532 &self,
533 pkg: &PackageJson,
534 root: &Path,
535 discovered_files: &[PathBuf],
536 ) -> AggregatedPluginResult {
537 self.try_run(pkg, root, discovered_files)
538 .unwrap_or_else(|errors| panic!("{}", format_plugin_regex_errors(&errors)))
539 }
540
541 pub fn try_run(
543 &self,
544 pkg: &PackageJson,
545 root: &Path,
546 discovered_files: &[PathBuf],
547 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
548 self.try_run_with_search_roots(pkg, root, discovered_files, &[root], false, None)
549 }
550
551 #[expect(
554 clippy::too_many_arguments,
555 reason = "public PluginRegistry API; signature is part of the crate surface for embedders"
556 )]
557 pub(crate) fn try_run_with_search_roots(
558 &self,
559 pkg: &PackageJson,
560 root: &Path,
561 discovered_files: &[PathBuf],
562 config_search_roots: &[&Path],
563 production_mode: bool,
564 candidate_index: Option<&ConfigCandidateIndex>,
565 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
566 let _span = tracing::info_span!("run_plugins").entered();
567 let mut result = AggregatedPluginResult::default();
568 let mut regex_errors = Vec::new();
569
570 let PluginRunContext { all_deps, active } = self.prepare_plugin_run_context(
571 pkg,
572 root,
573 discovered_files,
574 production_mode,
575 candidate_index,
576 );
577
578 self.run_plugin_preflight(&active, &all_deps, root, discovered_files);
579
580 for plugin in &active {
581 process_static_patterns(*plugin, root, &mut result);
582 }
583 process_package_json_metadata(&active, pkg, root, &mut result, &mut regex_errors);
584
585 process_external_plugins(
586 &self.external_plugins,
587 &all_deps,
588 root,
589 discovered_files,
590 &mut result,
591 );
592
593 let config_matchers = compile_config_matchers(&active);
594 let relative_files =
595 compute_relative_files(&config_matchers, &active, discovered_files, root);
596
597 resolve_plugin_config_files(&mut PluginConfigResolutionInput {
598 config_matchers: &config_matchers,
599 relative_files: &relative_files,
600 config_search_roots,
601 production_mode,
602 candidate_index,
603 root,
604 result: &mut result,
605 regex_errors: &mut regex_errors,
606 });
607
608 process_package_json_inline_configs(
609 &active,
610 &config_matchers,
611 &relative_files,
612 root,
613 &mut result,
614 &mut regex_errors,
615 );
616
617 if regex_errors.is_empty() {
618 Ok(result)
619 } else {
620 Err(regex_errors)
621 }
622 }
623
624 #[cfg(test)]
630 fn run_workspace_fast(&self, input: &WorkspacePluginRunInput<'_>) -> AggregatedPluginResult {
631 self.try_run_workspace_fast(input)
632 .unwrap_or_else(|errors| panic!("{}", format_plugin_regex_errors(&errors)))
633 }
634
635 pub(crate) fn try_run_workspace_fast(
641 &self,
642 input: &WorkspacePluginRunInput<'_>,
643 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
644 let _span = tracing::info_span!("run_plugins").entered();
645 let mut result = AggregatedPluginResult::default();
646 let mut regex_errors = Vec::new();
647
648 let all_deps = input.pkg.all_dependency_names();
649 let script_packages =
650 script_activation_packages(input.pkg, input.root, &all_deps, input.production_mode);
651 let workspace_files: Vec<PathBuf> = input
652 .relative_files
653 .iter()
654 .map(|(abs_path, _)| abs_path.clone())
655 .collect();
656
657 let active = self.collect_active_plugins(&PluginActivationInput {
658 pkg: input.pkg,
659 root: input.root,
660 discovered_files: &workspace_files,
661 all_deps: &all_deps,
662 script_packages: &script_packages,
663 candidate_index: input.candidate_index,
664 });
665
666 log_active_plugins(&active);
667
668 self.emit_silent_fail_diagnostics(&active, &all_deps, input.root, &workspace_files);
669
670 process_external_plugins(
671 &self.external_plugins,
672 &all_deps,
673 input.root,
674 &workspace_files,
675 &mut result,
676 );
677
678 if active.is_empty() && result.active_plugins.is_empty() {
679 return Ok(result);
680 }
681
682 process_workspace_active_plugins(&active, input, &mut result, &mut regex_errors);
683 resolve_workspace_plugin_configs(&active, input, &mut result, &mut regex_errors);
684
685 if regex_errors.is_empty() {
686 Ok(result)
687 } else {
688 Err(regex_errors)
689 }
690 }
691
692 #[must_use]
695 pub(crate) fn precompile_config_matchers(
696 &self,
697 ) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
698 self.plugins
699 .iter()
700 .filter(|p| !p.config_patterns().is_empty())
701 .map(|p| (p.as_ref(), cached_plugin_config_matchers(p.as_ref())))
702 .collect()
703 }
704}
705
706fn process_workspace_active_plugins(
707 active: &[&dyn Plugin],
708 input: &WorkspacePluginRunInput<'_>,
709 result: &mut AggregatedPluginResult,
710 regex_errors: &mut Vec<PluginRegexValidationError>,
711) {
712 for plugin in active {
713 process_static_patterns(*plugin, input.root, result);
714 }
715 process_package_json_metadata(active, input.pkg, input.root, result, regex_errors);
716}
717
718fn resolve_workspace_plugin_configs(
719 active: &[&dyn Plugin],
720 input: &WorkspacePluginRunInput<'_>,
721 result: &mut AggregatedPluginResult,
722 regex_errors: &mut Vec<PluginRegexValidationError>,
723) {
724 let workspace_matchers = select_workspace_matchers(
725 input.precompiled_config_matchers,
726 active,
727 input.skip_config_plugins,
728 );
729
730 let mut resolved_ws_plugins: FxHashSet<&str> = FxHashSet::default();
731 for (plugin, matchers) in &workspace_matchers {
732 resolve_plugin_matching_files(&mut PluginMatchingFilesInput {
733 plugin: *plugin,
734 matchers,
735 relative_files: input.relative_files,
736 root: input.root,
737 result,
738 regex_errors,
739 resolved_plugins: &mut resolved_ws_plugins,
740 });
741 }
742
743 load_workspace_filesystem_configs(&mut WorkspaceFsConfigInput {
744 workspace_matchers: &workspace_matchers,
745 resolved_ws_plugins: &resolved_ws_plugins,
746 root: input.root,
747 project_root: input.project_root,
748 production_mode: input.production_mode,
749 candidate_index: input.candidate_index,
750 result,
751 regex_errors,
752 });
753}
754
755impl Default for PluginRegistry {
756 fn default() -> Self {
757 Self::new(vec![])
758 }
759}
760
761impl PluginRegistry {
762 fn prepare_plugin_run_context<'a>(
763 &'a self,
764 pkg: &PackageJson,
765 root: &Path,
766 discovered_files: &[PathBuf],
767 production_mode: bool,
768 candidate_index: Option<&ConfigCandidateIndex>,
769 ) -> PluginRunContext<'a> {
770 let all_deps = pkg.all_dependency_names();
771 let script_packages = script_activation_packages(pkg, root, &all_deps, production_mode);
772 let active = self.collect_active_plugins(&PluginActivationInput {
773 pkg,
774 root,
775 discovered_files,
776 all_deps: &all_deps,
777 script_packages: &script_packages,
778 candidate_index,
779 });
780
781 PluginRunContext { all_deps, active }
782 }
783
784 fn run_plugin_preflight(
785 &self,
786 active: &[&dyn Plugin],
787 all_deps: &[String],
788 root: &Path,
789 discovered_files: &[PathBuf],
790 ) {
791 log_active_plugins(active);
792 check_meta_framework_prerequisites(active, root);
793 self.emit_silent_fail_diagnostics(active, all_deps, root, discovered_files);
794 }
795
796 fn collect_active_plugins<'a>(
799 &'a self,
800 activation: &PluginActivationInput<'_>,
801 ) -> Vec<&'a dyn Plugin> {
802 self.plugins
803 .iter()
804 .filter(|p| {
805 p.is_enabled_with_files(
806 activation.all_deps,
807 activation.root,
808 activation.discovered_files,
809 activation.candidate_index,
810 ) || p.is_enabled_with_scripts(activation.script_packages, activation.root)
811 || p.is_enabled_with_package_json(activation.pkg, activation.root)
812 })
813 .map(AsRef::as_ref)
814 .collect()
815 }
816
817 fn emit_silent_fail_diagnostics(
826 &self,
827 active: &[&dyn Plugin],
828 all_deps: &[String],
829 root: &Path,
830 discovered_files: &[PathBuf],
831 ) {
832 let active_external: Vec<&ExternalPluginDef> = self
833 .external_plugins
834 .iter()
835 .filter(|ext| is_external_plugin_active(ext, all_deps, root, discovered_files))
836 .collect();
837 let mut diagnostics = detect_pattern_collisions(active, &active_external);
838 diagnostics.extend(detect_enabler_typos(&self.external_plugins, all_deps));
839 emit_plugin_diagnostics(&diagnostics);
840 }
841}
842
843fn plugin_warn_dedupe() -> &'static std::sync::Mutex<FxHashSet<String>> {
850 static WARNED: std::sync::OnceLock<std::sync::Mutex<FxHashSet<String>>> =
851 std::sync::OnceLock::new();
852 WARNED.get_or_init(|| std::sync::Mutex::new(FxHashSet::default()))
853}
854
855struct PluginConfigResolutionInput<'a> {
856 config_matchers: &'a [(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
857 relative_files: &'a [(PathBuf, String)],
858 config_search_roots: &'a [&'a Path],
859 production_mode: bool,
860 candidate_index: Option<&'a ConfigCandidateIndex>,
861 root: &'a Path,
862 result: &'a mut AggregatedPluginResult,
863 regex_errors: &'a mut Vec<PluginRegexValidationError>,
864}
865
866fn select_workspace_matchers<'a>(
869 precompiled_config_matchers: &[(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
870 active: &[&dyn Plugin],
871 skip_config_plugins: &FxHashSet<&str>,
872) -> Vec<(&'a dyn Plugin, Vec<globset::GlobMatcher>)> {
873 let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
874 precompiled_config_matchers
875 .iter()
876 .filter(|(p, _)| {
877 active_names.contains(p.name())
878 && (!skip_config_plugins.contains(p.name())
879 || must_parse_workspace_config_when_root_active(p.name()))
880 })
881 .map(|(plugin, matchers)| (*plugin, matchers.clone()))
882 .collect()
883}
884
885struct WorkspaceFsConfigInput<'a> {
886 workspace_matchers: &'a [(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
887 resolved_ws_plugins: &'a FxHashSet<&'a str>,
888 root: &'a Path,
889 project_root: &'a Path,
890 production_mode: bool,
891 candidate_index: Option<&'a ConfigCandidateIndex>,
892 result: &'a mut AggregatedPluginResult,
893 regex_errors: &'a mut Vec<PluginRegexValidationError>,
894}
895
896fn load_workspace_filesystem_configs(input: &mut WorkspaceFsConfigInput<'_>) {
899 let search_roots: &[&Path] = if input.root == input.project_root {
900 &[input.root]
901 } else {
902 &[input.root, input.project_root]
903 };
904 let ws_json_configs = discover_config_files(
905 input.workspace_matchers,
906 input.resolved_ws_plugins,
907 search_roots,
908 input.production_mode,
909 input.candidate_index,
910 );
911 for (abs_path, plugin) in &ws_json_configs {
912 let Ok(source) = std::fs::read_to_string(abs_path) else {
913 continue;
914 };
915 let plugin_result = plugin.resolve_config(abs_path, &source, input.root);
916 if plugin_result.is_empty() {
917 continue;
918 }
919 let rel = abs_path
920 .strip_prefix(input.project_root)
921 .map(|p| p.to_string_lossy())
922 .unwrap_or_default();
923 tracing::debug!(
924 plugin = plugin.name(),
925 config = %rel,
926 entries = plugin_result.entry_patterns.len(),
927 deps = plugin_result.referenced_dependencies.len(),
928 "resolved config (workspace filesystem fallback)"
929 );
930 if let Err(mut errors) =
931 process_config_result(plugin.name(), plugin_result, input.result, Some(abs_path))
932 {
933 input.regex_errors.append(&mut errors);
934 }
935 }
936}
937
938fn resolve_plugin_config_files(input: &mut PluginConfigResolutionInput<'_>) {
939 if input.config_matchers.is_empty() {
940 return;
941 }
942
943 let mut resolved_plugins: FxHashSet<&str> = FxHashSet::default();
944 for (plugin, matchers) in input.config_matchers {
945 resolve_plugin_matching_files(&mut PluginMatchingFilesInput {
946 plugin: *plugin,
947 matchers,
948 relative_files: input.relative_files,
949 root: input.root,
950 result: input.result,
951 regex_errors: input.regex_errors,
952 resolved_plugins: &mut resolved_plugins,
953 });
954 }
955
956 let json_configs = discover_config_files(
957 input.config_matchers,
958 &resolved_plugins,
959 input.config_search_roots,
960 input.production_mode,
961 input.candidate_index,
962 );
963 for (abs_path, plugin) in &json_configs {
964 resolve_plugin_filesystem_config(
965 *plugin,
966 abs_path,
967 input.root,
968 input.result,
969 input.regex_errors,
970 );
971 }
972}
973
974struct PluginMatchingFilesInput<'plugins, 'data, 'state> {
975 plugin: &'plugins dyn Plugin,
976 matchers: &'data [globset::GlobMatcher],
977 relative_files: &'data [(PathBuf, String)],
978 root: &'data Path,
979 result: &'state mut AggregatedPluginResult,
980 regex_errors: &'state mut Vec<PluginRegexValidationError>,
981 resolved_plugins: &'state mut FxHashSet<&'plugins str>,
982}
983
984fn resolve_plugin_matching_files(input: &mut PluginMatchingFilesInput<'_, '_, '_>) {
985 use rayon::prelude::*;
986
987 let plugin_hits: Vec<&PathBuf> = input
988 .relative_files
989 .par_iter()
990 .filter_map(|(abs_path, rel_path)| {
991 input
992 .matchers
993 .iter()
994 .any(|m| m.is_match(rel_path.as_str()))
995 .then_some(abs_path)
996 })
997 .collect();
998 for abs_path in plugin_hits {
999 let Ok(source) = std::fs::read_to_string(abs_path) else {
1000 continue;
1001 };
1002 let plugin_result = input.plugin.resolve_config(abs_path, &source, input.root);
1003 if plugin_result.is_empty() {
1004 continue;
1005 }
1006 input.resolved_plugins.insert(input.plugin.name());
1007 process_resolved_plugin_config(ResolvedPluginConfigInput {
1008 plugin: input.plugin,
1009 abs_path,
1010 plugin_result,
1011 result: input.result,
1012 regex_errors: input.regex_errors,
1013 message: "resolved config",
1014 config_display: abs_path.display(),
1015 });
1016 }
1017}
1018
1019fn resolve_plugin_filesystem_config(
1020 plugin: &dyn Plugin,
1021 abs_path: &Path,
1022 root: &Path,
1023 result: &mut AggregatedPluginResult,
1024 regex_errors: &mut Vec<PluginRegexValidationError>,
1025) {
1026 let Ok(source) = std::fs::read_to_string(abs_path) else {
1027 return;
1028 };
1029 let plugin_result = plugin.resolve_config(abs_path, &source, root);
1030 if plugin_result.is_empty() {
1031 return;
1032 }
1033 let rel = abs_path
1034 .strip_prefix(root)
1035 .map(|p| p.to_string_lossy())
1036 .unwrap_or_default();
1037 process_resolved_plugin_config(ResolvedPluginConfigInput {
1038 plugin,
1039 abs_path,
1040 plugin_result,
1041 result,
1042 regex_errors,
1043 message: "resolved config (filesystem fallback)",
1044 config_display: rel,
1045 });
1046}
1047
1048struct ResolvedPluginConfigInput<'a, D> {
1049 plugin: &'a dyn Plugin,
1050 abs_path: &'a Path,
1051 plugin_result: PluginResult,
1052 result: &'a mut AggregatedPluginResult,
1053 regex_errors: &'a mut Vec<PluginRegexValidationError>,
1054 message: &'static str,
1055 config_display: D,
1056}
1057
1058fn process_resolved_plugin_config(input: ResolvedPluginConfigInput<'_, impl std::fmt::Display>) {
1059 tracing::debug!(
1060 plugin = input.plugin.name(),
1061 config = %input.config_display,
1062 entries = input.plugin_result.entry_patterns.len(),
1063 deps = input.plugin_result.referenced_dependencies.len(),
1064 input.message
1065 );
1066 if let Err(mut errors) = process_config_result(
1067 input.plugin.name(),
1068 input.plugin_result,
1069 input.result,
1070 Some(input.abs_path),
1071 ) {
1072 input.regex_errors.append(&mut errors);
1073 }
1074}
1075
1076fn should_warn(key: String) -> bool {
1080 plugin_warn_dedupe()
1081 .lock()
1082 .map_or(true, |mut set| set.insert(key))
1083}
1084
1085#[derive(Debug, Clone, PartialEq, Eq)]
1092pub(crate) enum PluginDiagnostic {
1093 PatternCollision {
1095 pattern: String,
1096 owners: Vec<String>,
1097 },
1098 EnablerTypo {
1101 plugin: String,
1102 enabler: String,
1103 suggestion: String,
1104 },
1105}
1106
1107fn detect_pattern_collisions(
1133 builtin_active: &[&dyn Plugin],
1134 external_active: &[&ExternalPluginDef],
1135) -> Vec<PluginDiagnostic> {
1136 use rustc_hash::FxHashMap;
1137
1138 let mut pattern_owners: FxHashMap<String, (Vec<String>, FxHashSet<String>)> =
1139 FxHashMap::default();
1140
1141 let record = |pattern_owners: &mut FxHashMap<_, (Vec<String>, FxHashSet<String>)>,
1142 pattern: String,
1143 name: String| {
1144 let (list, seen) = pattern_owners.entry(pattern).or_default();
1145 if seen.insert(name.clone()) {
1146 list.push(name);
1147 }
1148 };
1149
1150 for plugin in builtin_active {
1151 for pat in plugin.config_patterns() {
1152 record(
1153 &mut pattern_owners,
1154 (*pat).to_string(),
1155 plugin.name().to_string(),
1156 );
1157 }
1158 }
1159 for ext in external_active {
1160 for pat in &ext.config_patterns {
1161 record(&mut pattern_owners, pat.clone(), ext.name.clone());
1162 }
1163 }
1164
1165 let builtin_names: FxHashSet<&str> = builtin_active.iter().map(|p| p.name()).collect();
1172
1173 let mut findings: Vec<PluginDiagnostic> = pattern_owners
1174 .into_iter()
1175 .filter_map(|(pattern, (owners, _seen))| {
1176 if owners.len() < 2 || owners.iter().all(|o| builtin_names.contains(o.as_str())) {
1177 None
1178 } else {
1179 Some(PluginDiagnostic::PatternCollision { pattern, owners })
1180 }
1181 })
1182 .collect();
1183 findings.sort_unstable_by(|a, b| match (a, b) {
1184 (
1185 PluginDiagnostic::PatternCollision { pattern: ap, .. },
1186 PluginDiagnostic::PatternCollision { pattern: bp, .. },
1187 ) => ap.cmp(bp),
1188 _ => std::cmp::Ordering::Equal,
1189 });
1190 findings
1191}
1192
1193fn detect_enabler_typos(
1208 external_plugins: &[ExternalPluginDef],
1209 all_deps: &[String],
1210) -> Vec<PluginDiagnostic> {
1211 let mut findings = Vec::new();
1212
1213 for ext in external_plugins {
1214 if ext.detection.is_some() || ext.enablers.is_empty() {
1215 continue;
1216 }
1217
1218 let any_match = ext.enablers.iter().any(|enabler| {
1219 if enabler.ends_with('/') {
1220 all_deps.iter().any(|d| d.starts_with(enabler))
1221 } else {
1222 all_deps.iter().any(|d| d == enabler)
1223 }
1224 });
1225 if any_match {
1226 continue;
1227 }
1228
1229 for enabler in &ext.enablers {
1230 let candidates = all_deps.iter().map(String::as_str);
1231 let Some(suggestion) = fallow_config::levenshtein::closest_match(enabler, candidates)
1232 else {
1233 continue;
1234 };
1235
1236 findings.push(PluginDiagnostic::EnablerTypo {
1237 plugin: ext.name.clone(),
1238 enabler: enabler.clone(),
1239 suggestion: suggestion.to_string(),
1240 });
1241 }
1242 }
1243
1244 findings
1245}
1246
1247fn emit_plugin_diagnostics(findings: &[PluginDiagnostic]) {
1250 for finding in findings {
1251 match finding {
1252 PluginDiagnostic::PatternCollision { pattern, owners } => {
1253 let key = format!("collision::{pattern}::{owners:?}");
1254 if !should_warn(key) {
1255 continue;
1256 }
1257 let winner = &owners[0];
1258 let others = owners[1..].join(", ");
1259 tracing::warn!(
1260 "plugin config_patterns collision: identical pattern \
1261 '{pattern}' is claimed by plugins [{joined}]; '{winner}' \
1262 runs first (registration order), others ({others}) \
1263 follow. Rename one of the patterns or remove the \
1264 duplicate plugin to make resolution explicit. A future \
1265 release may reject identical-pattern collisions.",
1266 joined = owners.join(", "),
1267 );
1268 }
1269 PluginDiagnostic::EnablerTypo {
1270 plugin,
1271 enabler,
1272 suggestion,
1273 } => {
1274 let key = format!("enabler::{plugin}::{enabler}");
1275 if !should_warn(key) {
1276 continue;
1277 }
1278 tracing::warn!(
1279 "plugin '{plugin}' enabler '{enabler}' does not match any \
1280 dependency in package.json; did you mean '{suggestion}'? \
1281 The plugin will not activate. A future release may reject \
1282 unmatched enablers.",
1283 );
1284 }
1285 }
1286 }
1287}
1288
1289fn process_package_json_inline_configs(
1294 active: &[&dyn Plugin],
1295 config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
1296 relative_files: &[(PathBuf, String)],
1297 root: &Path,
1298 result: &mut AggregatedPluginResult,
1299 regex_errors: &mut Vec<PluginRegexValidationError>,
1300) {
1301 for plugin in active {
1302 let Some(key) = plugin.package_json_config_key() else {
1303 continue;
1304 };
1305 if check_has_config_file(*plugin, config_matchers, relative_files) {
1306 continue;
1307 }
1308 let pkg_path = root.join("package.json");
1309 let Ok(content) = std::fs::read_to_string(&pkg_path) else {
1310 continue;
1311 };
1312 let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
1313 continue;
1314 };
1315 let Some(config_value) = json.get(key) else {
1316 continue;
1317 };
1318 let config_json = serde_json::to_string(config_value).unwrap_or_default();
1319 let fake_path = root.join(format!("{key}.config.json"));
1320 let plugin_result = plugin.resolve_config(&fake_path, &config_json, root);
1321 if plugin_result.is_empty() {
1322 continue;
1323 }
1324 tracing::debug!(
1325 plugin = plugin.name(),
1326 key = key,
1327 "resolved inline package.json config"
1328 );
1329 if let Err(mut errors) =
1330 process_config_result(plugin.name(), plugin_result, result, Some(&pkg_path))
1331 {
1332 regex_errors.append(&mut errors);
1333 }
1334 }
1335}
1336
1337#[derive(Debug)]
1340struct MetaFrameworkWarning {
1341 dedupe_key: &'static str,
1342 message: &'static str,
1343}
1344
1345fn missing_meta_framework_prerequisites(
1355 active_plugins: &[&dyn Plugin],
1356 root: &Path,
1357) -> Vec<MetaFrameworkWarning> {
1358 active_plugins
1359 .iter()
1360 .filter_map(|plugin| match plugin.name() {
1361 "nuxt" if !root.join(".nuxt/tsconfig.json").exists() => Some(MetaFrameworkWarning {
1362 dedupe_key: "meta-prereq::nuxt",
1363 message: "Nuxt project missing .nuxt/tsconfig.json: run `nuxt prepare` \
1364 before fallow for accurate analysis",
1365 }),
1366 "astro" if !root.join(".astro").exists() => Some(MetaFrameworkWarning {
1367 dedupe_key: "meta-prereq::astro",
1368 message: "Astro project missing .astro/ types: run `astro sync` \
1369 before fallow for accurate analysis",
1370 }),
1371 _ => None,
1372 })
1373 .collect()
1374}
1375
1376fn check_meta_framework_prerequisites(active_plugins: &[&dyn Plugin], root: &Path) {
1386 for warning in missing_meta_framework_prerequisites(active_plugins, root) {
1387 if should_warn(warning.dedupe_key.to_owned()) {
1388 tracing::warn!("{}", warning.message);
1389 }
1390 }
1391}
1392
1393fn script_activation_packages(
1394 pkg: &PackageJson,
1395 root: &Path,
1396 all_deps: &[String],
1397 production_mode: bool,
1398) -> FxHashSet<String> {
1399 let Some(pkg_scripts) = pkg.scripts.as_ref() else {
1400 return FxHashSet::default();
1401 };
1402
1403 let scripts_to_analyze = if production_mode {
1404 scripts::filter_production_scripts(pkg_scripts)
1405 } else {
1406 pkg_scripts.clone()
1407 };
1408
1409 let mut nm_roots = Vec::new();
1410 if root.join("node_modules").is_dir() {
1411 nm_roots.push(root);
1412 }
1413 let bin_map = scripts::build_bin_to_package_map(&nm_roots, all_deps);
1414 let dep_set: FxHashSet<String> = all_deps.iter().cloned().collect();
1415 let catalog =
1416 scripts::ScriptCatalog::from_scripts_with_bodies(pkg_scripts, &scripts_to_analyze);
1417
1418 scripts::analyze_scripts_with_dependency_context(
1419 &scripts_to_analyze,
1420 root,
1421 &bin_map,
1422 &dep_set,
1423 &catalog,
1424 )
1425 .used_packages
1426}
1427
1428#[cfg(test)]
1429mod tests;