1use rustc_hash::FxHashSet;
4use std::fmt;
5use std::path::{Path, PathBuf};
6
7use fallow_config::{
8 AutoImportRule, EntryPointRole, ExternalPluginDef, PackageJson, UsedClassMemberRule,
9};
10
11use crate::scripts;
12
13use super::{PathRule, Plugin, PluginResult, PluginUsedExportRule, ProvidedDependencyRule};
14
15pub(crate) mod builtin;
16mod helpers;
17
18#[must_use]
23pub fn builtin_plugin_names() -> Vec<&'static str> {
24 builtin::create_builtin_plugins()
25 .iter()
26 .map(|plugin| plugin.name())
27 .collect()
28}
29
30#[must_use]
35pub fn builtin_plugin_config_candidate_basenames() -> Vec<String> {
36 let mut set: FxHashSet<String> = FxHashSet::default();
37 for plugin in builtin::create_builtin_plugins() {
38 for pattern in plugin.config_patterns() {
39 let basename = pattern.rsplit('/').next().unwrap_or(pattern);
40 set.insert(basename.to_string());
41 }
42 }
43 let mut basenames = set.into_iter().collect::<Vec<_>>();
44 basenames.sort_unstable();
45 basenames
46}
47
48pub use helpers::ConfigCandidateIndex;
49pub use helpers::is_external_plugin_active;
50use helpers::{
51 check_has_config_file, discover_config_files, prepare_config_pattern, process_config_result,
52 process_external_plugins, process_package_json_metadata, process_static_patterns,
53};
54
55fn must_parse_workspace_config_when_root_active(plugin_name: &str) -> bool {
56 matches!(
57 plugin_name,
58 "eslint" | "docusaurus" | "jest" | "tanstack-router" | "vitest"
59 )
60}
61
62fn compile_config_matchers<'a>(
63 active: &[&'a dyn Plugin],
64) -> Vec<(&'a dyn Plugin, Vec<globset::GlobMatcher>)> {
65 active
66 .iter()
67 .filter(|plugin| !plugin.config_patterns().is_empty())
68 .map(|plugin| {
69 let matchers = plugin
70 .config_patterns()
71 .iter()
72 .filter_map(|pattern| {
73 let prepared = prepare_config_pattern(pattern);
74 globset::Glob::new(&prepared)
75 .ok()
76 .map(|glob| glob.compile_matcher())
77 })
78 .collect();
79 (*plugin, matchers)
80 })
81 .collect()
82}
83
84fn log_active_plugins(active: &[&dyn Plugin]) {
86 tracing::info!(
87 plugins = active
88 .iter()
89 .map(|p| p.name())
90 .collect::<Vec<_>>()
91 .join(", "),
92 "active plugins"
93 );
94}
95
96fn compute_relative_files(
100 config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
101 active: &[&dyn Plugin],
102 discovered_files: &[PathBuf],
103 root: &Path,
104) -> Vec<(PathBuf, String)> {
105 use rayon::prelude::*;
106 let needs_relative_files =
107 !config_matchers.is_empty() || active.iter().any(|p| p.package_json_config_key().is_some());
108 if !needs_relative_files {
109 return Vec::new();
110 }
111 discovered_files
112 .par_iter()
113 .map(|f| {
114 let rel = f
115 .strip_prefix(root)
116 .unwrap_or(f)
117 .to_string_lossy()
118 .into_owned();
119 (f.clone(), rel)
120 })
121 .collect()
122}
123
124pub struct PluginRegistry {
126 plugins: Vec<Box<dyn Plugin>>,
127 external_plugins: Vec<ExternalPluginDef>,
128}
129
130pub(crate) struct WorkspacePluginRunInput<'a> {
132 pub(crate) pkg: &'a PackageJson,
133 pub(crate) root: &'a Path,
134 pub(crate) project_root: &'a Path,
135 pub(crate) precompiled_config_matchers: &'a [(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
136 pub(crate) relative_files: &'a [(PathBuf, String)],
137 pub(crate) skip_config_plugins: &'a FxHashSet<&'a str>,
138 pub(crate) production_mode: bool,
139 pub(crate) candidate_index: Option<&'a ConfigCandidateIndex>,
140}
141
142struct PluginRunContext<'a> {
143 all_deps: Vec<String>,
144 active: Vec<&'a dyn Plugin>,
145}
146
147struct PluginActivationInput<'a> {
149 pkg: &'a PackageJson,
150 root: &'a Path,
151 discovered_files: &'a [PathBuf],
152 all_deps: &'a [String],
153 script_packages: &'a FxHashSet<String>,
154 candidate_index: Option<&'a ConfigCandidateIndex>,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct PluginRegexValidationError {
160 plugin_name: String,
161 config_path: Option<PathBuf>,
162 rule_kind: &'static str,
163 field: &'static str,
164 rule_pattern: String,
165 regex_pattern: String,
166 source: String,
167}
168
169impl PluginRegexValidationError {
170 fn new(input: PluginRegexValidationErrorInput<'_>) -> Self {
171 Self {
172 plugin_name: input.plugin_name.to_owned(),
173 config_path: input.config_path.map(Path::to_path_buf),
174 rule_kind: input.rule_kind,
175 field: input.field,
176 rule_pattern: input.rule_pattern.to_owned(),
177 regex_pattern: input.regex_pattern.to_owned(),
178 source: input.source.to_string(),
179 }
180 }
181}
182
183#[derive(Clone, Copy)]
184pub(crate) struct PluginRegexValidationErrorInput<'a> {
185 plugin_name: &'a str,
186 config_path: Option<&'a Path>,
187 rule_kind: &'static str,
188 field: &'static str,
189 rule_pattern: &'a str,
190 regex_pattern: &'a str,
191 source: &'a regex::Error,
192}
193
194impl fmt::Display for PluginRegexValidationError {
195 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
196 let location = self
197 .config_path
198 .as_ref()
199 .map(|path| format!(" in {}", path.display()))
200 .unwrap_or_default();
201 write!(
202 f,
203 "plugin '{}'{}: invalid regex '{}' in {}.{} for path rule '{}': {}",
204 self.plugin_name,
205 location,
206 self.regex_pattern,
207 self.rule_kind,
208 self.field,
209 self.rule_pattern,
210 self.source
211 )
212 }
213}
214
215#[must_use]
216pub(crate) fn format_plugin_regex_errors(errors: &[PluginRegexValidationError]) -> String {
217 let joined = errors
218 .iter()
219 .map(ToString::to_string)
220 .collect::<Vec<_>>()
221 .join("\n - ");
222 format!(
223 "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."
224 )
225}
226
227#[derive(Debug, Clone, Default)]
229pub struct AggregatedPluginResult {
230 pub entry_patterns: Vec<(PathRule, String)>,
232 pub entry_point_roles: rustc_hash::FxHashMap<String, EntryPointRole>,
234 pub config_patterns: Vec<String>,
236 pub always_used: Vec<(String, String)>,
238 pub used_exports: Vec<PluginUsedExportRule>,
240 pub used_class_members: Vec<UsedClassMemberRule>,
244 pub referenced_dependencies: Vec<String>,
246 pub package_referenced_dependencies: Vec<(PathBuf, String)>,
248 pub discovered_always_used: Vec<(String, String)>,
250 pub setup_files: Vec<(PathBuf, String)>,
252 pub tooling_dependencies: Vec<String>,
254 pub script_used_packages: FxHashSet<String>,
256 pub virtual_module_prefixes: Vec<String>,
259 pub virtual_package_suffixes: Vec<String>,
262 pub generated_import_patterns: Vec<String>,
265 pub generated_type_import_prefixes: Vec<String>,
268 pub path_aliases: Vec<(String, String)>,
271 pub auto_imports: Vec<AutoImportRule>,
275 pub active_plugins: Vec<String>,
277 pub fixture_patterns: Vec<(String, String)>,
279 pub scss_include_paths: Vec<PathBuf>,
284 pub static_dir_mappings: Vec<(PathBuf, String)>,
286 pub provided_dependencies: Vec<ProvidedDependencyRule>,
288}
289
290fn extend_unique(target: &mut Vec<String>, incoming: Vec<String>) {
295 let mut seen: FxHashSet<String> = target.iter().cloned().collect();
296 for item in incoming {
297 if seen.insert(item.clone()) {
298 target.push(item);
299 }
300 }
301}
302
303fn prefix_if_needed(pat: &str, ws_prefix: &str) -> String {
307 if pat.starts_with(ws_prefix) || pat.starts_with('/') {
308 pat.to_string()
309 } else {
310 format!("{ws_prefix}/{pat}")
311 }
312}
313
314impl AggregatedPluginResult {
315 pub(crate) fn apply_workspace_prefix(&mut self, ws_prefix: &str) {
329 for (rule, _) in &mut self.entry_patterns {
330 *rule = rule.prefixed(ws_prefix);
331 }
332 for (pat, _) in &mut self.always_used {
333 *pat = prefix_if_needed(pat, ws_prefix);
334 }
335 for (pat, _) in &mut self.discovered_always_used {
336 *pat = prefix_if_needed(pat, ws_prefix);
337 }
338 for (pat, _) in &mut self.fixture_patterns {
339 *pat = prefix_if_needed(pat, ws_prefix);
340 }
341 for rule in &mut self.used_exports {
342 *rule = rule.prefixed(ws_prefix);
343 }
344 for rule in &mut self.provided_dependencies {
345 *rule = rule.prefixed(ws_prefix);
346 }
347 for (_, replacement) in &mut self.path_aliases {
348 *replacement = format!("{ws_prefix}/{replacement}");
349 }
350 }
351
352 pub(crate) fn merge_into(&mut self, other: Self) {
365 let Self {
366 entry_patterns,
367 entry_point_roles,
368 config_patterns,
369 always_used,
370 used_exports,
371 used_class_members,
372 referenced_dependencies,
373 package_referenced_dependencies,
374 discovered_always_used,
375 setup_files,
376 tooling_dependencies,
377 script_used_packages,
378 virtual_module_prefixes,
379 virtual_package_suffixes,
380 generated_import_patterns,
381 generated_type_import_prefixes,
382 path_aliases,
383 auto_imports,
384 active_plugins,
385 fixture_patterns,
386 scss_include_paths,
387 static_dir_mappings,
388 provided_dependencies,
389 } = other;
390
391 self.entry_patterns.extend(entry_patterns);
392 for (plugin_name, role) in entry_point_roles {
393 self.entry_point_roles.entry(plugin_name).or_insert(role);
394 }
395 self.config_patterns.extend(config_patterns);
396 self.always_used.extend(always_used);
397 self.used_exports.extend(used_exports);
398 self.used_class_members.extend(used_class_members);
399 self.referenced_dependencies.extend(referenced_dependencies);
400 self.package_referenced_dependencies
401 .extend(package_referenced_dependencies);
402 self.discovered_always_used.extend(discovered_always_used);
403 self.setup_files.extend(setup_files);
404 self.tooling_dependencies.extend(tooling_dependencies);
405 self.script_used_packages.extend(script_used_packages);
406 extend_unique(&mut self.virtual_module_prefixes, virtual_module_prefixes);
407 extend_unique(&mut self.virtual_package_suffixes, virtual_package_suffixes);
408 extend_unique(
409 &mut self.generated_import_patterns,
410 generated_import_patterns,
411 );
412 extend_unique(
413 &mut self.generated_type_import_prefixes,
414 generated_type_import_prefixes,
415 );
416 self.path_aliases.extend(path_aliases);
417 self.auto_imports.extend(auto_imports);
418 extend_unique(&mut self.active_plugins, active_plugins);
419 self.fixture_patterns.extend(fixture_patterns);
420 self.scss_include_paths.extend(scss_include_paths);
421 self.static_dir_mappings.extend(static_dir_mappings);
422 self.provided_dependencies.extend(provided_dependencies);
423 }
424}
425
426impl PluginRegistry {
427 #[must_use]
429 pub fn new(external: Vec<ExternalPluginDef>) -> Self {
430 Self {
431 plugins: builtin::create_builtin_plugins(),
432 external_plugins: external,
433 }
434 }
435
436 #[must_use]
441 pub fn discovery_hidden_dirs(&self, pkg: &PackageJson, root: &Path) -> Vec<String> {
442 let all_deps = pkg.all_dependency_names();
443 let mut seen = FxHashSet::default();
444 let mut dirs = Vec::new();
445
446 for plugin in &self.plugins {
447 if !plugin.is_enabled_with_deps(&all_deps, root) {
448 continue;
449 }
450 for dir in plugin.discovery_hidden_dirs() {
451 if seen.insert(*dir) {
452 dirs.push((*dir).to_string());
453 }
454 }
455 }
456
457 dirs
458 }
459
460 #[cfg(test)]
465 fn run(
466 &self,
467 pkg: &PackageJson,
468 root: &Path,
469 discovered_files: &[PathBuf],
470 ) -> AggregatedPluginResult {
471 self.try_run(pkg, root, discovered_files)
472 .unwrap_or_else(|errors| panic!("{}", format_plugin_regex_errors(&errors)))
473 }
474
475 pub fn try_run(
477 &self,
478 pkg: &PackageJson,
479 root: &Path,
480 discovered_files: &[PathBuf],
481 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
482 self.try_run_with_search_roots(pkg, root, discovered_files, &[root], false, None)
483 }
484
485 #[expect(
488 clippy::too_many_arguments,
489 reason = "public PluginRegistry API; signature is part of the crate surface for embedders"
490 )]
491 pub(crate) fn try_run_with_search_roots(
492 &self,
493 pkg: &PackageJson,
494 root: &Path,
495 discovered_files: &[PathBuf],
496 config_search_roots: &[&Path],
497 production_mode: bool,
498 candidate_index: Option<&ConfigCandidateIndex>,
499 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
500 let _span = tracing::info_span!("run_plugins").entered();
501 let mut result = AggregatedPluginResult::default();
502 let mut regex_errors = Vec::new();
503
504 let PluginRunContext { all_deps, active } = self.prepare_plugin_run_context(
505 pkg,
506 root,
507 discovered_files,
508 production_mode,
509 candidate_index,
510 );
511
512 self.run_plugin_preflight(&active, &all_deps, root, discovered_files);
513
514 for plugin in &active {
515 process_static_patterns(*plugin, root, &mut result);
516 }
517 process_package_json_metadata(&active, pkg, root, &mut result, &mut regex_errors);
518
519 process_external_plugins(
520 &self.external_plugins,
521 &all_deps,
522 root,
523 discovered_files,
524 &mut result,
525 );
526
527 let config_matchers = compile_config_matchers(&active);
528 let relative_files =
529 compute_relative_files(&config_matchers, &active, discovered_files, root);
530
531 resolve_plugin_config_files(&mut PluginConfigResolutionInput {
532 config_matchers: &config_matchers,
533 relative_files: &relative_files,
534 config_search_roots,
535 production_mode,
536 candidate_index,
537 root,
538 result: &mut result,
539 regex_errors: &mut regex_errors,
540 });
541
542 process_package_json_inline_configs(
543 &active,
544 &config_matchers,
545 &relative_files,
546 root,
547 &mut result,
548 &mut regex_errors,
549 );
550
551 if regex_errors.is_empty() {
552 Ok(result)
553 } else {
554 Err(regex_errors)
555 }
556 }
557
558 #[cfg(test)]
564 fn run_workspace_fast(&self, input: &WorkspacePluginRunInput<'_>) -> AggregatedPluginResult {
565 self.try_run_workspace_fast(input)
566 .unwrap_or_else(|errors| panic!("{}", format_plugin_regex_errors(&errors)))
567 }
568
569 pub(crate) fn try_run_workspace_fast(
575 &self,
576 input: &WorkspacePluginRunInput<'_>,
577 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
578 let _span = tracing::info_span!("run_plugins").entered();
579 let mut result = AggregatedPluginResult::default();
580 let mut regex_errors = Vec::new();
581
582 let all_deps = input.pkg.all_dependency_names();
583 let script_packages =
584 script_activation_packages(input.pkg, input.root, &all_deps, input.production_mode);
585 let workspace_files: Vec<PathBuf> = input
586 .relative_files
587 .iter()
588 .map(|(abs_path, _)| abs_path.clone())
589 .collect();
590
591 let active = self.collect_active_plugins(&PluginActivationInput {
592 pkg: input.pkg,
593 root: input.root,
594 discovered_files: &workspace_files,
595 all_deps: &all_deps,
596 script_packages: &script_packages,
597 candidate_index: input.candidate_index,
598 });
599
600 log_active_plugins(&active);
601
602 self.emit_silent_fail_diagnostics(&active, &all_deps, input.root, &workspace_files);
603
604 process_external_plugins(
605 &self.external_plugins,
606 &all_deps,
607 input.root,
608 &workspace_files,
609 &mut result,
610 );
611
612 if active.is_empty() && result.active_plugins.is_empty() {
613 return Ok(result);
614 }
615
616 process_workspace_active_plugins(&active, input, &mut result, &mut regex_errors);
617 resolve_workspace_plugin_configs(&active, input, &mut result, &mut regex_errors);
618
619 if regex_errors.is_empty() {
620 Ok(result)
621 } else {
622 Err(regex_errors)
623 }
624 }
625
626 #[must_use]
629 pub(crate) fn precompile_config_matchers(
630 &self,
631 ) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
632 self.plugins
633 .iter()
634 .filter(|p| !p.config_patterns().is_empty())
635 .map(|p| {
636 let matchers: Vec<globset::GlobMatcher> = p
637 .config_patterns()
638 .iter()
639 .filter_map(|pat| {
640 let prepared = prepare_config_pattern(pat);
641 globset::Glob::new(&prepared)
642 .ok()
643 .map(|g| g.compile_matcher())
644 })
645 .collect();
646 (p.as_ref(), matchers)
647 })
648 .collect()
649 }
650}
651
652fn process_workspace_active_plugins(
653 active: &[&dyn Plugin],
654 input: &WorkspacePluginRunInput<'_>,
655 result: &mut AggregatedPluginResult,
656 regex_errors: &mut Vec<PluginRegexValidationError>,
657) {
658 for plugin in active {
659 process_static_patterns(*plugin, input.root, result);
660 }
661 process_package_json_metadata(active, input.pkg, input.root, result, regex_errors);
662}
663
664fn resolve_workspace_plugin_configs(
665 active: &[&dyn Plugin],
666 input: &WorkspacePluginRunInput<'_>,
667 result: &mut AggregatedPluginResult,
668 regex_errors: &mut Vec<PluginRegexValidationError>,
669) {
670 let workspace_matchers = select_workspace_matchers(
671 input.precompiled_config_matchers,
672 active,
673 input.skip_config_plugins,
674 );
675
676 let mut resolved_ws_plugins: FxHashSet<&str> = FxHashSet::default();
677 for (plugin, matchers) in &workspace_matchers {
678 resolve_plugin_matching_files(&mut PluginMatchingFilesInput {
679 plugin: *plugin,
680 matchers,
681 relative_files: input.relative_files,
682 root: input.root,
683 result,
684 regex_errors,
685 resolved_plugins: &mut resolved_ws_plugins,
686 });
687 }
688
689 load_workspace_filesystem_configs(&mut WorkspaceFsConfigInput {
690 workspace_matchers: &workspace_matchers,
691 resolved_ws_plugins: &resolved_ws_plugins,
692 root: input.root,
693 project_root: input.project_root,
694 production_mode: input.production_mode,
695 candidate_index: input.candidate_index,
696 result,
697 regex_errors,
698 });
699}
700
701impl Default for PluginRegistry {
702 fn default() -> Self {
703 Self::new(vec![])
704 }
705}
706
707impl PluginRegistry {
708 fn prepare_plugin_run_context<'a>(
709 &'a self,
710 pkg: &PackageJson,
711 root: &Path,
712 discovered_files: &[PathBuf],
713 production_mode: bool,
714 candidate_index: Option<&ConfigCandidateIndex>,
715 ) -> PluginRunContext<'a> {
716 let all_deps = pkg.all_dependency_names();
717 let script_packages = script_activation_packages(pkg, root, &all_deps, production_mode);
718 let active = self.collect_active_plugins(&PluginActivationInput {
719 pkg,
720 root,
721 discovered_files,
722 all_deps: &all_deps,
723 script_packages: &script_packages,
724 candidate_index,
725 });
726
727 PluginRunContext { all_deps, active }
728 }
729
730 fn run_plugin_preflight(
731 &self,
732 active: &[&dyn Plugin],
733 all_deps: &[String],
734 root: &Path,
735 discovered_files: &[PathBuf],
736 ) {
737 log_active_plugins(active);
738 check_meta_framework_prerequisites(active, root);
739 self.emit_silent_fail_diagnostics(active, all_deps, root, discovered_files);
740 }
741
742 fn collect_active_plugins<'a>(
745 &'a self,
746 activation: &PluginActivationInput<'_>,
747 ) -> Vec<&'a dyn Plugin> {
748 self.plugins
749 .iter()
750 .filter(|p| {
751 p.is_enabled_with_files(
752 activation.all_deps,
753 activation.root,
754 activation.discovered_files,
755 activation.candidate_index,
756 ) || p.is_enabled_with_scripts(activation.script_packages, activation.root)
757 || p.is_enabled_with_package_json(activation.pkg, activation.root)
758 })
759 .map(AsRef::as_ref)
760 .collect()
761 }
762
763 fn emit_silent_fail_diagnostics(
772 &self,
773 active: &[&dyn Plugin],
774 all_deps: &[String],
775 root: &Path,
776 discovered_files: &[PathBuf],
777 ) {
778 let active_external: Vec<&ExternalPluginDef> = self
779 .external_plugins
780 .iter()
781 .filter(|ext| is_external_plugin_active(ext, all_deps, root, discovered_files))
782 .collect();
783 let mut diagnostics = detect_pattern_collisions(active, &active_external);
784 diagnostics.extend(detect_enabler_typos(&self.external_plugins, all_deps));
785 emit_plugin_diagnostics(&diagnostics);
786 }
787}
788
789fn plugin_warn_dedupe() -> &'static std::sync::Mutex<FxHashSet<String>> {
796 static WARNED: std::sync::OnceLock<std::sync::Mutex<FxHashSet<String>>> =
797 std::sync::OnceLock::new();
798 WARNED.get_or_init(|| std::sync::Mutex::new(FxHashSet::default()))
799}
800
801struct PluginConfigResolutionInput<'a> {
802 config_matchers: &'a [(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
803 relative_files: &'a [(PathBuf, String)],
804 config_search_roots: &'a [&'a Path],
805 production_mode: bool,
806 candidate_index: Option<&'a ConfigCandidateIndex>,
807 root: &'a Path,
808 result: &'a mut AggregatedPluginResult,
809 regex_errors: &'a mut Vec<PluginRegexValidationError>,
810}
811
812fn select_workspace_matchers<'a>(
815 precompiled_config_matchers: &[(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
816 active: &[&dyn Plugin],
817 skip_config_plugins: &FxHashSet<&str>,
818) -> Vec<(&'a dyn Plugin, Vec<globset::GlobMatcher>)> {
819 let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
820 precompiled_config_matchers
821 .iter()
822 .filter(|(p, _)| {
823 active_names.contains(p.name())
824 && (!skip_config_plugins.contains(p.name())
825 || must_parse_workspace_config_when_root_active(p.name()))
826 })
827 .map(|(plugin, matchers)| (*plugin, matchers.clone()))
828 .collect()
829}
830
831struct WorkspaceFsConfigInput<'a> {
832 workspace_matchers: &'a [(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
833 resolved_ws_plugins: &'a FxHashSet<&'a str>,
834 root: &'a Path,
835 project_root: &'a Path,
836 production_mode: bool,
837 candidate_index: Option<&'a ConfigCandidateIndex>,
838 result: &'a mut AggregatedPluginResult,
839 regex_errors: &'a mut Vec<PluginRegexValidationError>,
840}
841
842fn load_workspace_filesystem_configs(input: &mut WorkspaceFsConfigInput<'_>) {
845 let search_roots: &[&Path] = if input.root == input.project_root {
846 &[input.root]
847 } else {
848 &[input.root, input.project_root]
849 };
850 let ws_json_configs = discover_config_files(
851 input.workspace_matchers,
852 input.resolved_ws_plugins,
853 search_roots,
854 input.production_mode,
855 input.candidate_index,
856 );
857 for (abs_path, plugin) in &ws_json_configs {
858 let Ok(source) = std::fs::read_to_string(abs_path) else {
859 continue;
860 };
861 let plugin_result = plugin.resolve_config(abs_path, &source, input.root);
862 if plugin_result.is_empty() {
863 continue;
864 }
865 let rel = abs_path
866 .strip_prefix(input.project_root)
867 .map(|p| p.to_string_lossy())
868 .unwrap_or_default();
869 tracing::debug!(
870 plugin = plugin.name(),
871 config = %rel,
872 entries = plugin_result.entry_patterns.len(),
873 deps = plugin_result.referenced_dependencies.len(),
874 "resolved config (workspace filesystem fallback)"
875 );
876 if let Err(mut errors) =
877 process_config_result(plugin.name(), plugin_result, input.result, Some(abs_path))
878 {
879 input.regex_errors.append(&mut errors);
880 }
881 }
882}
883
884fn resolve_plugin_config_files(input: &mut PluginConfigResolutionInput<'_>) {
885 if input.config_matchers.is_empty() {
886 return;
887 }
888
889 let mut resolved_plugins: FxHashSet<&str> = FxHashSet::default();
890 for (plugin, matchers) in input.config_matchers {
891 resolve_plugin_matching_files(&mut PluginMatchingFilesInput {
892 plugin: *plugin,
893 matchers,
894 relative_files: input.relative_files,
895 root: input.root,
896 result: input.result,
897 regex_errors: input.regex_errors,
898 resolved_plugins: &mut resolved_plugins,
899 });
900 }
901
902 let json_configs = discover_config_files(
903 input.config_matchers,
904 &resolved_plugins,
905 input.config_search_roots,
906 input.production_mode,
907 input.candidate_index,
908 );
909 for (abs_path, plugin) in &json_configs {
910 resolve_plugin_filesystem_config(
911 *plugin,
912 abs_path,
913 input.root,
914 input.result,
915 input.regex_errors,
916 );
917 }
918}
919
920struct PluginMatchingFilesInput<'plugins, 'data, 'state> {
921 plugin: &'plugins dyn Plugin,
922 matchers: &'data [globset::GlobMatcher],
923 relative_files: &'data [(PathBuf, String)],
924 root: &'data Path,
925 result: &'state mut AggregatedPluginResult,
926 regex_errors: &'state mut Vec<PluginRegexValidationError>,
927 resolved_plugins: &'state mut FxHashSet<&'plugins str>,
928}
929
930fn resolve_plugin_matching_files(input: &mut PluginMatchingFilesInput<'_, '_, '_>) {
931 use rayon::prelude::*;
932
933 let plugin_hits: Vec<&PathBuf> = input
934 .relative_files
935 .par_iter()
936 .filter_map(|(abs_path, rel_path)| {
937 input
938 .matchers
939 .iter()
940 .any(|m| m.is_match(rel_path.as_str()))
941 .then_some(abs_path)
942 })
943 .collect();
944 for abs_path in plugin_hits {
945 let Ok(source) = std::fs::read_to_string(abs_path) else {
946 continue;
947 };
948 let plugin_result = input.plugin.resolve_config(abs_path, &source, input.root);
949 if plugin_result.is_empty() {
950 continue;
951 }
952 input.resolved_plugins.insert(input.plugin.name());
953 process_resolved_plugin_config(ResolvedPluginConfigInput {
954 plugin: input.plugin,
955 abs_path,
956 plugin_result,
957 result: input.result,
958 regex_errors: input.regex_errors,
959 message: "resolved config",
960 config_display: abs_path.display(),
961 });
962 }
963}
964
965fn resolve_plugin_filesystem_config(
966 plugin: &dyn Plugin,
967 abs_path: &Path,
968 root: &Path,
969 result: &mut AggregatedPluginResult,
970 regex_errors: &mut Vec<PluginRegexValidationError>,
971) {
972 let Ok(source) = std::fs::read_to_string(abs_path) else {
973 return;
974 };
975 let plugin_result = plugin.resolve_config(abs_path, &source, root);
976 if plugin_result.is_empty() {
977 return;
978 }
979 let rel = abs_path
980 .strip_prefix(root)
981 .map(|p| p.to_string_lossy())
982 .unwrap_or_default();
983 process_resolved_plugin_config(ResolvedPluginConfigInput {
984 plugin,
985 abs_path,
986 plugin_result,
987 result,
988 regex_errors,
989 message: "resolved config (filesystem fallback)",
990 config_display: rel,
991 });
992}
993
994struct ResolvedPluginConfigInput<'a, D> {
995 plugin: &'a dyn Plugin,
996 abs_path: &'a Path,
997 plugin_result: PluginResult,
998 result: &'a mut AggregatedPluginResult,
999 regex_errors: &'a mut Vec<PluginRegexValidationError>,
1000 message: &'static str,
1001 config_display: D,
1002}
1003
1004fn process_resolved_plugin_config(input: ResolvedPluginConfigInput<'_, impl std::fmt::Display>) {
1005 tracing::debug!(
1006 plugin = input.plugin.name(),
1007 config = %input.config_display,
1008 entries = input.plugin_result.entry_patterns.len(),
1009 deps = input.plugin_result.referenced_dependencies.len(),
1010 input.message
1011 );
1012 if let Err(mut errors) = process_config_result(
1013 input.plugin.name(),
1014 input.plugin_result,
1015 input.result,
1016 Some(input.abs_path),
1017 ) {
1018 input.regex_errors.append(&mut errors);
1019 }
1020}
1021
1022fn should_warn(key: String) -> bool {
1026 plugin_warn_dedupe()
1027 .lock()
1028 .map_or(true, |mut set| set.insert(key))
1029}
1030
1031#[derive(Debug, Clone, PartialEq, Eq)]
1038pub(crate) enum PluginDiagnostic {
1039 PatternCollision {
1041 pattern: String,
1042 owners: Vec<String>,
1043 },
1044 EnablerTypo {
1047 plugin: String,
1048 enabler: String,
1049 suggestion: String,
1050 },
1051}
1052
1053fn detect_pattern_collisions(
1079 builtin_active: &[&dyn Plugin],
1080 external_active: &[&ExternalPluginDef],
1081) -> Vec<PluginDiagnostic> {
1082 use rustc_hash::FxHashMap;
1083
1084 let mut pattern_owners: FxHashMap<String, (Vec<String>, FxHashSet<String>)> =
1085 FxHashMap::default();
1086
1087 let record = |pattern_owners: &mut FxHashMap<_, (Vec<String>, FxHashSet<String>)>,
1088 pattern: String,
1089 name: String| {
1090 let (list, seen) = pattern_owners.entry(pattern).or_default();
1091 if seen.insert(name.clone()) {
1092 list.push(name);
1093 }
1094 };
1095
1096 for plugin in builtin_active {
1097 for pat in plugin.config_patterns() {
1098 record(
1099 &mut pattern_owners,
1100 (*pat).to_string(),
1101 plugin.name().to_string(),
1102 );
1103 }
1104 }
1105 for ext in external_active {
1106 for pat in &ext.config_patterns {
1107 record(&mut pattern_owners, pat.clone(), ext.name.clone());
1108 }
1109 }
1110
1111 let builtin_names: FxHashSet<&str> = builtin_active.iter().map(|p| p.name()).collect();
1118
1119 let mut findings: Vec<PluginDiagnostic> = pattern_owners
1120 .into_iter()
1121 .filter_map(|(pattern, (owners, _seen))| {
1122 if owners.len() < 2 || owners.iter().all(|o| builtin_names.contains(o.as_str())) {
1123 None
1124 } else {
1125 Some(PluginDiagnostic::PatternCollision { pattern, owners })
1126 }
1127 })
1128 .collect();
1129 findings.sort_unstable_by(|a, b| match (a, b) {
1130 (
1131 PluginDiagnostic::PatternCollision { pattern: ap, .. },
1132 PluginDiagnostic::PatternCollision { pattern: bp, .. },
1133 ) => ap.cmp(bp),
1134 _ => std::cmp::Ordering::Equal,
1135 });
1136 findings
1137}
1138
1139fn detect_enabler_typos(
1154 external_plugins: &[ExternalPluginDef],
1155 all_deps: &[String],
1156) -> Vec<PluginDiagnostic> {
1157 let mut findings = Vec::new();
1158
1159 for ext in external_plugins {
1160 if ext.detection.is_some() || ext.enablers.is_empty() {
1161 continue;
1162 }
1163
1164 let any_match = ext.enablers.iter().any(|enabler| {
1165 if enabler.ends_with('/') {
1166 all_deps.iter().any(|d| d.starts_with(enabler))
1167 } else {
1168 all_deps.iter().any(|d| d == enabler)
1169 }
1170 });
1171 if any_match {
1172 continue;
1173 }
1174
1175 for enabler in &ext.enablers {
1176 let candidates = all_deps.iter().map(String::as_str);
1177 let Some(suggestion) = fallow_config::levenshtein::closest_match(enabler, candidates)
1178 else {
1179 continue;
1180 };
1181
1182 findings.push(PluginDiagnostic::EnablerTypo {
1183 plugin: ext.name.clone(),
1184 enabler: enabler.clone(),
1185 suggestion: suggestion.to_string(),
1186 });
1187 }
1188 }
1189
1190 findings
1191}
1192
1193fn emit_plugin_diagnostics(findings: &[PluginDiagnostic]) {
1196 for finding in findings {
1197 match finding {
1198 PluginDiagnostic::PatternCollision { pattern, owners } => {
1199 let key = format!("collision::{pattern}::{owners:?}");
1200 if !should_warn(key) {
1201 continue;
1202 }
1203 let winner = &owners[0];
1204 let others = owners[1..].join(", ");
1205 tracing::warn!(
1206 "plugin config_patterns collision: identical pattern \
1207 '{pattern}' is claimed by plugins [{joined}]; '{winner}' \
1208 runs first (registration order), others ({others}) \
1209 follow. Rename one of the patterns or remove the \
1210 duplicate plugin to make resolution explicit. A future \
1211 release may reject identical-pattern collisions.",
1212 joined = owners.join(", "),
1213 );
1214 }
1215 PluginDiagnostic::EnablerTypo {
1216 plugin,
1217 enabler,
1218 suggestion,
1219 } => {
1220 let key = format!("enabler::{plugin}::{enabler}");
1221 if !should_warn(key) {
1222 continue;
1223 }
1224 tracing::warn!(
1225 "plugin '{plugin}' enabler '{enabler}' does not match any \
1226 dependency in package.json; did you mean '{suggestion}'? \
1227 The plugin will not activate. A future release may reject \
1228 unmatched enablers.",
1229 );
1230 }
1231 }
1232 }
1233}
1234
1235fn process_package_json_inline_configs(
1240 active: &[&dyn Plugin],
1241 config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
1242 relative_files: &[(PathBuf, String)],
1243 root: &Path,
1244 result: &mut AggregatedPluginResult,
1245 regex_errors: &mut Vec<PluginRegexValidationError>,
1246) {
1247 for plugin in active {
1248 let Some(key) = plugin.package_json_config_key() else {
1249 continue;
1250 };
1251 if check_has_config_file(*plugin, config_matchers, relative_files) {
1252 continue;
1253 }
1254 let pkg_path = root.join("package.json");
1255 let Ok(content) = std::fs::read_to_string(&pkg_path) else {
1256 continue;
1257 };
1258 let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
1259 continue;
1260 };
1261 let Some(config_value) = json.get(key) else {
1262 continue;
1263 };
1264 let config_json = serde_json::to_string(config_value).unwrap_or_default();
1265 let fake_path = root.join(format!("{key}.config.json"));
1266 let plugin_result = plugin.resolve_config(&fake_path, &config_json, root);
1267 if plugin_result.is_empty() {
1268 continue;
1269 }
1270 tracing::debug!(
1271 plugin = plugin.name(),
1272 key = key,
1273 "resolved inline package.json config"
1274 );
1275 if let Err(mut errors) =
1276 process_config_result(plugin.name(), plugin_result, result, Some(&pkg_path))
1277 {
1278 regex_errors.append(&mut errors);
1279 }
1280 }
1281}
1282
1283#[derive(Debug)]
1286struct MetaFrameworkWarning {
1287 dedupe_key: &'static str,
1288 message: &'static str,
1289}
1290
1291fn missing_meta_framework_prerequisites(
1301 active_plugins: &[&dyn Plugin],
1302 root: &Path,
1303) -> Vec<MetaFrameworkWarning> {
1304 active_plugins
1305 .iter()
1306 .filter_map(|plugin| match plugin.name() {
1307 "nuxt" if !root.join(".nuxt/tsconfig.json").exists() => Some(MetaFrameworkWarning {
1308 dedupe_key: "meta-prereq::nuxt",
1309 message: "Nuxt project missing .nuxt/tsconfig.json: run `nuxt prepare` \
1310 before fallow for accurate analysis",
1311 }),
1312 "astro" if !root.join(".astro").exists() => Some(MetaFrameworkWarning {
1313 dedupe_key: "meta-prereq::astro",
1314 message: "Astro project missing .astro/ types: run `astro sync` \
1315 before fallow for accurate analysis",
1316 }),
1317 _ => None,
1318 })
1319 .collect()
1320}
1321
1322fn check_meta_framework_prerequisites(active_plugins: &[&dyn Plugin], root: &Path) {
1332 for warning in missing_meta_framework_prerequisites(active_plugins, root) {
1333 if should_warn(warning.dedupe_key.to_owned()) {
1334 tracing::warn!("{}", warning.message);
1335 }
1336 }
1337}
1338
1339fn script_activation_packages(
1340 pkg: &PackageJson,
1341 root: &Path,
1342 all_deps: &[String],
1343 production_mode: bool,
1344) -> FxHashSet<String> {
1345 let Some(pkg_scripts) = pkg.scripts.as_ref() else {
1346 return FxHashSet::default();
1347 };
1348
1349 let scripts_to_analyze = if production_mode {
1350 scripts::filter_production_scripts(pkg_scripts)
1351 } else {
1352 pkg_scripts.clone()
1353 };
1354
1355 let mut nm_roots = Vec::new();
1356 if root.join("node_modules").is_dir() {
1357 nm_roots.push(root);
1358 }
1359 let bin_map = scripts::build_bin_to_package_map(&nm_roots, all_deps);
1360 let dep_set: FxHashSet<String> = all_deps.iter().cloned().collect();
1361 let script_names: FxHashSet<String> = pkg_scripts.keys().cloned().collect();
1362
1363 scripts::analyze_scripts_with_dependency_context(
1364 &scripts_to_analyze,
1365 root,
1366 &bin_map,
1367 &dep_set,
1368 &script_names,
1369 )
1370 .used_packages
1371}
1372
1373#[cfg(test)]
1374mod tests;