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
30pub use helpers::ConfigCandidateIndex;
31use helpers::{
32 check_has_config_file, discover_config_files, is_external_plugin_active,
33 prepare_config_pattern, process_config_result, process_external_plugins,
34 process_package_json_metadata, process_static_patterns,
35};
36
37fn must_parse_workspace_config_when_root_active(plugin_name: &str) -> bool {
38 matches!(
39 plugin_name,
40 "eslint" | "docusaurus" | "jest" | "tanstack-router" | "vitest"
41 )
42}
43
44fn compile_config_matchers<'a>(
45 active: &[&'a dyn Plugin],
46) -> Vec<(&'a dyn Plugin, Vec<globset::GlobMatcher>)> {
47 active
48 .iter()
49 .filter(|plugin| !plugin.config_patterns().is_empty())
50 .map(|plugin| {
51 let matchers = plugin
52 .config_patterns()
53 .iter()
54 .filter_map(|pattern| {
55 let prepared = prepare_config_pattern(pattern);
56 globset::Glob::new(&prepared)
57 .ok()
58 .map(|glob| glob.compile_matcher())
59 })
60 .collect();
61 (*plugin, matchers)
62 })
63 .collect()
64}
65
66fn log_active_plugins(active: &[&dyn Plugin]) {
68 tracing::info!(
69 plugins = active
70 .iter()
71 .map(|p| p.name())
72 .collect::<Vec<_>>()
73 .join(", "),
74 "active plugins"
75 );
76}
77
78fn compute_relative_files(
82 config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
83 active: &[&dyn Plugin],
84 discovered_files: &[PathBuf],
85 root: &Path,
86) -> Vec<(PathBuf, String)> {
87 use rayon::prelude::*;
88 let needs_relative_files =
89 !config_matchers.is_empty() || active.iter().any(|p| p.package_json_config_key().is_some());
90 if !needs_relative_files {
91 return Vec::new();
92 }
93 discovered_files
94 .par_iter()
95 .map(|f| {
96 let rel = f
97 .strip_prefix(root)
98 .unwrap_or(f)
99 .to_string_lossy()
100 .into_owned();
101 (f.clone(), rel)
102 })
103 .collect()
104}
105
106pub struct PluginRegistry {
108 plugins: Vec<Box<dyn Plugin>>,
109 external_plugins: Vec<ExternalPluginDef>,
110}
111
112pub struct WorkspacePluginRunInput<'a> {
114 pub pkg: &'a PackageJson,
115 pub root: &'a Path,
116 pub project_root: &'a Path,
117 pub precompiled_config_matchers: &'a [(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
118 pub relative_files: &'a [(PathBuf, String)],
119 pub skip_config_plugins: &'a FxHashSet<&'a str>,
120 pub production_mode: bool,
121 pub candidate_index: Option<&'a ConfigCandidateIndex>,
122}
123
124struct PluginRunContext<'a> {
125 all_deps: Vec<String>,
126 active: Vec<&'a dyn Plugin>,
127}
128
129struct PluginActivationInput<'a> {
131 pkg: &'a PackageJson,
132 root: &'a Path,
133 discovered_files: &'a [PathBuf],
134 all_deps: &'a [String],
135 script_packages: &'a FxHashSet<String>,
136 candidate_index: Option<&'a ConfigCandidateIndex>,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct PluginRegexValidationError {
142 plugin_name: String,
143 config_path: Option<PathBuf>,
144 rule_kind: &'static str,
145 field: &'static str,
146 rule_pattern: String,
147 regex_pattern: String,
148 source: String,
149}
150
151impl PluginRegexValidationError {
152 pub(crate) fn new(input: PluginRegexValidationErrorInput<'_>) -> Self {
153 Self {
154 plugin_name: input.plugin_name.to_owned(),
155 config_path: input.config_path.map(Path::to_path_buf),
156 rule_kind: input.rule_kind,
157 field: input.field,
158 rule_pattern: input.rule_pattern.to_owned(),
159 regex_pattern: input.regex_pattern.to_owned(),
160 source: input.source.to_string(),
161 }
162 }
163}
164
165#[derive(Clone, Copy)]
166pub(crate) struct PluginRegexValidationErrorInput<'a> {
167 pub(crate) plugin_name: &'a str,
168 pub(crate) config_path: Option<&'a Path>,
169 pub(crate) rule_kind: &'static str,
170 pub(crate) field: &'static str,
171 pub(crate) rule_pattern: &'a str,
172 pub(crate) regex_pattern: &'a str,
173 pub(crate) source: &'a regex::Error,
174}
175
176impl fmt::Display for PluginRegexValidationError {
177 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178 let location = self
179 .config_path
180 .as_ref()
181 .map(|path| format!(" in {}", path.display()))
182 .unwrap_or_default();
183 write!(
184 f,
185 "plugin '{}'{}: invalid regex '{}' in {}.{} for path rule '{}': {}",
186 self.plugin_name,
187 location,
188 self.regex_pattern,
189 self.rule_kind,
190 self.field,
191 self.rule_pattern,
192 self.source
193 )
194 }
195}
196
197#[must_use]
198pub fn format_plugin_regex_errors(errors: &[PluginRegexValidationError]) -> String {
199 let joined = errors
200 .iter()
201 .map(ToString::to_string)
202 .collect::<Vec<_>>()
203 .join("\n - ");
204 format!(
205 "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."
206 )
207}
208
209#[derive(Debug, Clone, Default)]
211pub struct AggregatedPluginResult {
212 pub entry_patterns: Vec<(PathRule, String)>,
214 pub entry_point_roles: rustc_hash::FxHashMap<String, EntryPointRole>,
216 pub config_patterns: Vec<String>,
218 pub always_used: Vec<(String, String)>,
220 pub used_exports: Vec<PluginUsedExportRule>,
222 pub used_class_members: Vec<UsedClassMemberRule>,
226 pub referenced_dependencies: Vec<String>,
228 pub package_referenced_dependencies: Vec<(PathBuf, String)>,
230 pub discovered_always_used: Vec<(String, String)>,
232 pub setup_files: Vec<(PathBuf, String)>,
234 pub tooling_dependencies: Vec<String>,
236 pub script_used_packages: FxHashSet<String>,
238 pub virtual_module_prefixes: Vec<String>,
241 pub virtual_package_suffixes: Vec<String>,
244 pub generated_import_patterns: Vec<String>,
247 pub generated_type_import_prefixes: Vec<String>,
250 pub path_aliases: Vec<(String, String)>,
253 pub auto_imports: Vec<AutoImportRule>,
257 pub active_plugins: Vec<String>,
259 pub fixture_patterns: Vec<(String, String)>,
261 pub scss_include_paths: Vec<PathBuf>,
266 pub static_dir_mappings: Vec<(PathBuf, String)>,
268 pub provided_dependencies: Vec<ProvidedDependencyRule>,
270}
271
272fn extend_unique(target: &mut Vec<String>, incoming: Vec<String>) {
277 let mut seen: FxHashSet<String> = target.iter().cloned().collect();
278 for item in incoming {
279 if seen.insert(item.clone()) {
280 target.push(item);
281 }
282 }
283}
284
285fn prefix_if_needed(pat: &str, ws_prefix: &str) -> String {
289 if pat.starts_with(ws_prefix) || pat.starts_with('/') {
290 pat.to_string()
291 } else {
292 format!("{ws_prefix}/{pat}")
293 }
294}
295
296impl AggregatedPluginResult {
297 pub fn apply_workspace_prefix(&mut self, ws_prefix: &str) {
311 for (rule, _) in &mut self.entry_patterns {
312 *rule = rule.prefixed(ws_prefix);
313 }
314 for (pat, _) in &mut self.always_used {
315 *pat = prefix_if_needed(pat, ws_prefix);
316 }
317 for (pat, _) in &mut self.discovered_always_used {
318 *pat = prefix_if_needed(pat, ws_prefix);
319 }
320 for (pat, _) in &mut self.fixture_patterns {
321 *pat = prefix_if_needed(pat, ws_prefix);
322 }
323 for rule in &mut self.used_exports {
324 *rule = rule.prefixed(ws_prefix);
325 }
326 for rule in &mut self.provided_dependencies {
327 *rule = rule.prefixed(ws_prefix);
328 }
329 for (_, replacement) in &mut self.path_aliases {
330 *replacement = format!("{ws_prefix}/{replacement}");
331 }
332 }
333
334 pub fn merge_into(&mut self, other: Self) {
347 let Self {
348 entry_patterns,
349 entry_point_roles,
350 config_patterns,
351 always_used,
352 used_exports,
353 used_class_members,
354 referenced_dependencies,
355 package_referenced_dependencies,
356 discovered_always_used,
357 setup_files,
358 tooling_dependencies,
359 script_used_packages,
360 virtual_module_prefixes,
361 virtual_package_suffixes,
362 generated_import_patterns,
363 generated_type_import_prefixes,
364 path_aliases,
365 auto_imports,
366 active_plugins,
367 fixture_patterns,
368 scss_include_paths,
369 static_dir_mappings,
370 provided_dependencies,
371 } = other;
372
373 self.entry_patterns.extend(entry_patterns);
374 for (plugin_name, role) in entry_point_roles {
375 self.entry_point_roles.entry(plugin_name).or_insert(role);
376 }
377 self.config_patterns.extend(config_patterns);
378 self.always_used.extend(always_used);
379 self.used_exports.extend(used_exports);
380 self.used_class_members.extend(used_class_members);
381 self.referenced_dependencies.extend(referenced_dependencies);
382 self.package_referenced_dependencies
383 .extend(package_referenced_dependencies);
384 self.discovered_always_used.extend(discovered_always_used);
385 self.setup_files.extend(setup_files);
386 self.tooling_dependencies.extend(tooling_dependencies);
387 self.script_used_packages.extend(script_used_packages);
388 extend_unique(&mut self.virtual_module_prefixes, virtual_module_prefixes);
389 extend_unique(&mut self.virtual_package_suffixes, virtual_package_suffixes);
390 extend_unique(
391 &mut self.generated_import_patterns,
392 generated_import_patterns,
393 );
394 extend_unique(
395 &mut self.generated_type_import_prefixes,
396 generated_type_import_prefixes,
397 );
398 self.path_aliases.extend(path_aliases);
399 self.auto_imports.extend(auto_imports);
400 extend_unique(&mut self.active_plugins, active_plugins);
401 self.fixture_patterns.extend(fixture_patterns);
402 self.scss_include_paths.extend(scss_include_paths);
403 self.static_dir_mappings.extend(static_dir_mappings);
404 self.provided_dependencies.extend(provided_dependencies);
405 }
406}
407
408impl PluginRegistry {
409 #[must_use]
411 pub fn new(external: Vec<ExternalPluginDef>) -> Self {
412 Self {
413 plugins: builtin::create_builtin_plugins(),
414 external_plugins: external,
415 }
416 }
417
418 #[must_use]
423 pub fn discovery_hidden_dirs(&self, pkg: &PackageJson, root: &Path) -> Vec<String> {
424 let all_deps = pkg.all_dependency_names();
425 let mut seen = FxHashSet::default();
426 let mut dirs = Vec::new();
427
428 for plugin in &self.plugins {
429 if !plugin.is_enabled_with_deps(&all_deps, root) {
430 continue;
431 }
432 for dir in plugin.discovery_hidden_dirs() {
433 if seen.insert(*dir) {
434 dirs.push((*dir).to_string());
435 }
436 }
437 }
438
439 dirs
440 }
441
442 #[cfg(test)]
447 pub fn run(
448 &self,
449 pkg: &PackageJson,
450 root: &Path,
451 discovered_files: &[PathBuf],
452 ) -> AggregatedPluginResult {
453 self.try_run(pkg, root, discovered_files)
454 .unwrap_or_else(|errors| panic!("{}", format_plugin_regex_errors(&errors)))
455 }
456
457 pub fn try_run(
459 &self,
460 pkg: &PackageJson,
461 root: &Path,
462 discovered_files: &[PathBuf],
463 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
464 self.try_run_with_search_roots(pkg, root, discovered_files, &[root], false, None)
465 }
466
467 #[expect(
470 clippy::too_many_arguments,
471 reason = "public PluginRegistry API; signature is part of the crate surface for embedders"
472 )]
473 pub fn try_run_with_search_roots(
474 &self,
475 pkg: &PackageJson,
476 root: &Path,
477 discovered_files: &[PathBuf],
478 config_search_roots: &[&Path],
479 production_mode: bool,
480 candidate_index: Option<&ConfigCandidateIndex>,
481 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
482 let _span = tracing::info_span!("run_plugins").entered();
483 let mut result = AggregatedPluginResult::default();
484 let mut regex_errors = Vec::new();
485
486 let PluginRunContext { all_deps, active } = self.prepare_plugin_run_context(
487 pkg,
488 root,
489 discovered_files,
490 production_mode,
491 candidate_index,
492 );
493
494 self.run_plugin_preflight(&active, &all_deps, root, discovered_files);
495
496 for plugin in &active {
497 process_static_patterns(*plugin, root, &mut result);
498 }
499 process_package_json_metadata(&active, pkg, root, &mut result, &mut regex_errors);
500
501 process_external_plugins(
502 &self.external_plugins,
503 &all_deps,
504 root,
505 discovered_files,
506 &mut result,
507 );
508
509 let config_matchers = compile_config_matchers(&active);
510 let relative_files =
511 compute_relative_files(&config_matchers, &active, discovered_files, root);
512
513 resolve_plugin_config_files(&mut PluginConfigResolutionInput {
514 config_matchers: &config_matchers,
515 relative_files: &relative_files,
516 config_search_roots,
517 production_mode,
518 candidate_index,
519 root,
520 result: &mut result,
521 regex_errors: &mut regex_errors,
522 });
523
524 process_package_json_inline_configs(
525 &active,
526 &config_matchers,
527 &relative_files,
528 root,
529 &mut result,
530 &mut regex_errors,
531 );
532
533 if regex_errors.is_empty() {
534 Ok(result)
535 } else {
536 Err(regex_errors)
537 }
538 }
539
540 #[cfg(test)]
546 fn run_workspace_fast(&self, input: &WorkspacePluginRunInput<'_>) -> AggregatedPluginResult {
547 self.try_run_workspace_fast(input)
548 .unwrap_or_else(|errors| panic!("{}", format_plugin_regex_errors(&errors)))
549 }
550
551 pub fn try_run_workspace_fast(
557 &self,
558 input: &WorkspacePluginRunInput<'_>,
559 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
560 let _span = tracing::info_span!("run_plugins").entered();
561 let mut result = AggregatedPluginResult::default();
562 let mut regex_errors = Vec::new();
563
564 let all_deps = input.pkg.all_dependency_names();
565 let script_packages =
566 script_activation_packages(input.pkg, input.root, &all_deps, input.production_mode);
567 let workspace_files: Vec<PathBuf> = input
568 .relative_files
569 .iter()
570 .map(|(abs_path, _)| abs_path.clone())
571 .collect();
572
573 let active = self.collect_active_plugins(&PluginActivationInput {
574 pkg: input.pkg,
575 root: input.root,
576 discovered_files: &workspace_files,
577 all_deps: &all_deps,
578 script_packages: &script_packages,
579 candidate_index: input.candidate_index,
580 });
581
582 log_active_plugins(&active);
583
584 self.emit_silent_fail_diagnostics(&active, &all_deps, input.root, &workspace_files);
585
586 process_external_plugins(
587 &self.external_plugins,
588 &all_deps,
589 input.root,
590 &workspace_files,
591 &mut result,
592 );
593
594 if active.is_empty() && result.active_plugins.is_empty() {
595 return Ok(result);
596 }
597
598 process_workspace_active_plugins(&active, input, &mut result, &mut regex_errors);
599 resolve_workspace_plugin_configs(&active, input, &mut result, &mut regex_errors);
600
601 if regex_errors.is_empty() {
602 Ok(result)
603 } else {
604 Err(regex_errors)
605 }
606 }
607
608 #[must_use]
611 pub fn precompile_config_matchers(&self) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
612 self.plugins
613 .iter()
614 .filter(|p| !p.config_patterns().is_empty())
615 .map(|p| {
616 let matchers: Vec<globset::GlobMatcher> = p
617 .config_patterns()
618 .iter()
619 .filter_map(|pat| {
620 let prepared = prepare_config_pattern(pat);
621 globset::Glob::new(&prepared)
622 .ok()
623 .map(|g| g.compile_matcher())
624 })
625 .collect();
626 (p.as_ref(), matchers)
627 })
628 .collect()
629 }
630}
631
632fn process_workspace_active_plugins(
633 active: &[&dyn Plugin],
634 input: &WorkspacePluginRunInput<'_>,
635 result: &mut AggregatedPluginResult,
636 regex_errors: &mut Vec<PluginRegexValidationError>,
637) {
638 for plugin in active {
639 process_static_patterns(*plugin, input.root, result);
640 }
641 process_package_json_metadata(active, input.pkg, input.root, result, regex_errors);
642}
643
644fn resolve_workspace_plugin_configs(
645 active: &[&dyn Plugin],
646 input: &WorkspacePluginRunInput<'_>,
647 result: &mut AggregatedPluginResult,
648 regex_errors: &mut Vec<PluginRegexValidationError>,
649) {
650 let workspace_matchers = select_workspace_matchers(
651 input.precompiled_config_matchers,
652 active,
653 input.skip_config_plugins,
654 );
655
656 let mut resolved_ws_plugins: FxHashSet<&str> = FxHashSet::default();
657 for (plugin, matchers) in &workspace_matchers {
658 resolve_plugin_matching_files(&mut PluginMatchingFilesInput {
659 plugin: *plugin,
660 matchers,
661 relative_files: input.relative_files,
662 root: input.root,
663 result,
664 regex_errors,
665 resolved_plugins: &mut resolved_ws_plugins,
666 });
667 }
668
669 load_workspace_filesystem_configs(&mut WorkspaceFsConfigInput {
670 workspace_matchers: &workspace_matchers,
671 resolved_ws_plugins: &resolved_ws_plugins,
672 root: input.root,
673 project_root: input.project_root,
674 production_mode: input.production_mode,
675 candidate_index: input.candidate_index,
676 result,
677 regex_errors,
678 });
679}
680
681impl Default for PluginRegistry {
682 fn default() -> Self {
683 Self::new(vec![])
684 }
685}
686
687impl PluginRegistry {
688 fn prepare_plugin_run_context<'a>(
689 &'a self,
690 pkg: &PackageJson,
691 root: &Path,
692 discovered_files: &[PathBuf],
693 production_mode: bool,
694 candidate_index: Option<&ConfigCandidateIndex>,
695 ) -> PluginRunContext<'a> {
696 let all_deps = pkg.all_dependency_names();
697 let script_packages = script_activation_packages(pkg, root, &all_deps, production_mode);
698 let active = self.collect_active_plugins(&PluginActivationInput {
699 pkg,
700 root,
701 discovered_files,
702 all_deps: &all_deps,
703 script_packages: &script_packages,
704 candidate_index,
705 });
706
707 PluginRunContext { all_deps, active }
708 }
709
710 fn run_plugin_preflight(
711 &self,
712 active: &[&dyn Plugin],
713 all_deps: &[String],
714 root: &Path,
715 discovered_files: &[PathBuf],
716 ) {
717 log_active_plugins(active);
718 check_meta_framework_prerequisites(active, root);
719 self.emit_silent_fail_diagnostics(active, all_deps, root, discovered_files);
720 }
721
722 fn collect_active_plugins<'a>(
725 &'a self,
726 activation: &PluginActivationInput<'_>,
727 ) -> Vec<&'a dyn Plugin> {
728 self.plugins
729 .iter()
730 .filter(|p| {
731 p.is_enabled_with_files(
732 activation.all_deps,
733 activation.root,
734 activation.discovered_files,
735 activation.candidate_index,
736 ) || p.is_enabled_with_scripts(activation.script_packages, activation.root)
737 || p.is_enabled_with_package_json(activation.pkg, activation.root)
738 })
739 .map(AsRef::as_ref)
740 .collect()
741 }
742
743 fn emit_silent_fail_diagnostics(
752 &self,
753 active: &[&dyn Plugin],
754 all_deps: &[String],
755 root: &Path,
756 discovered_files: &[PathBuf],
757 ) {
758 let active_external: Vec<&ExternalPluginDef> = self
759 .external_plugins
760 .iter()
761 .filter(|ext| is_external_plugin_active(ext, all_deps, root, discovered_files))
762 .collect();
763 let mut diagnostics = detect_pattern_collisions(active, &active_external);
764 diagnostics.extend(detect_enabler_typos(&self.external_plugins, all_deps));
765 emit_plugin_diagnostics(&diagnostics);
766 }
767}
768
769fn plugin_warn_dedupe() -> &'static std::sync::Mutex<FxHashSet<String>> {
776 static WARNED: std::sync::OnceLock<std::sync::Mutex<FxHashSet<String>>> =
777 std::sync::OnceLock::new();
778 WARNED.get_or_init(|| std::sync::Mutex::new(FxHashSet::default()))
779}
780
781struct PluginConfigResolutionInput<'a> {
782 config_matchers: &'a [(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
783 relative_files: &'a [(PathBuf, String)],
784 config_search_roots: &'a [&'a Path],
785 production_mode: bool,
786 candidate_index: Option<&'a ConfigCandidateIndex>,
787 root: &'a Path,
788 result: &'a mut AggregatedPluginResult,
789 regex_errors: &'a mut Vec<PluginRegexValidationError>,
790}
791
792fn select_workspace_matchers<'a>(
795 precompiled_config_matchers: &[(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
796 active: &[&dyn Plugin],
797 skip_config_plugins: &FxHashSet<&str>,
798) -> Vec<(&'a dyn Plugin, Vec<globset::GlobMatcher>)> {
799 let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
800 precompiled_config_matchers
801 .iter()
802 .filter(|(p, _)| {
803 active_names.contains(p.name())
804 && (!skip_config_plugins.contains(p.name())
805 || must_parse_workspace_config_when_root_active(p.name()))
806 })
807 .map(|(plugin, matchers)| (*plugin, matchers.clone()))
808 .collect()
809}
810
811struct WorkspaceFsConfigInput<'a> {
812 workspace_matchers: &'a [(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
813 resolved_ws_plugins: &'a FxHashSet<&'a str>,
814 root: &'a Path,
815 project_root: &'a Path,
816 production_mode: bool,
817 candidate_index: Option<&'a ConfigCandidateIndex>,
818 result: &'a mut AggregatedPluginResult,
819 regex_errors: &'a mut Vec<PluginRegexValidationError>,
820}
821
822fn load_workspace_filesystem_configs(input: &mut WorkspaceFsConfigInput<'_>) {
825 let search_roots: &[&Path] = if input.root == input.project_root {
826 &[input.root]
827 } else {
828 &[input.root, input.project_root]
829 };
830 let ws_json_configs = discover_config_files(
831 input.workspace_matchers,
832 input.resolved_ws_plugins,
833 search_roots,
834 input.production_mode,
835 input.candidate_index,
836 );
837 for (abs_path, plugin) in &ws_json_configs {
838 let Ok(source) = std::fs::read_to_string(abs_path) else {
839 continue;
840 };
841 let plugin_result = plugin.resolve_config(abs_path, &source, input.root);
842 if plugin_result.is_empty() {
843 continue;
844 }
845 let rel = abs_path
846 .strip_prefix(input.project_root)
847 .map(|p| p.to_string_lossy())
848 .unwrap_or_default();
849 tracing::debug!(
850 plugin = plugin.name(),
851 config = %rel,
852 entries = plugin_result.entry_patterns.len(),
853 deps = plugin_result.referenced_dependencies.len(),
854 "resolved config (workspace filesystem fallback)"
855 );
856 if let Err(mut errors) =
857 process_config_result(plugin.name(), plugin_result, input.result, Some(abs_path))
858 {
859 input.regex_errors.append(&mut errors);
860 }
861 }
862}
863
864fn resolve_plugin_config_files(input: &mut PluginConfigResolutionInput<'_>) {
865 if input.config_matchers.is_empty() {
866 return;
867 }
868
869 let mut resolved_plugins: FxHashSet<&str> = FxHashSet::default();
870 for (plugin, matchers) in input.config_matchers {
871 resolve_plugin_matching_files(&mut PluginMatchingFilesInput {
872 plugin: *plugin,
873 matchers,
874 relative_files: input.relative_files,
875 root: input.root,
876 result: input.result,
877 regex_errors: input.regex_errors,
878 resolved_plugins: &mut resolved_plugins,
879 });
880 }
881
882 let json_configs = discover_config_files(
883 input.config_matchers,
884 &resolved_plugins,
885 input.config_search_roots,
886 input.production_mode,
887 input.candidate_index,
888 );
889 for (abs_path, plugin) in &json_configs {
890 resolve_plugin_filesystem_config(
891 *plugin,
892 abs_path,
893 input.root,
894 input.result,
895 input.regex_errors,
896 );
897 }
898}
899
900struct PluginMatchingFilesInput<'plugins, 'data, 'state> {
901 plugin: &'plugins dyn Plugin,
902 matchers: &'data [globset::GlobMatcher],
903 relative_files: &'data [(PathBuf, String)],
904 root: &'data Path,
905 result: &'state mut AggregatedPluginResult,
906 regex_errors: &'state mut Vec<PluginRegexValidationError>,
907 resolved_plugins: &'state mut FxHashSet<&'plugins str>,
908}
909
910fn resolve_plugin_matching_files(input: &mut PluginMatchingFilesInput<'_, '_, '_>) {
911 use rayon::prelude::*;
912
913 let plugin_hits: Vec<&PathBuf> = input
914 .relative_files
915 .par_iter()
916 .filter_map(|(abs_path, rel_path)| {
917 input
918 .matchers
919 .iter()
920 .any(|m| m.is_match(rel_path.as_str()))
921 .then_some(abs_path)
922 })
923 .collect();
924 for abs_path in plugin_hits {
925 let Ok(source) = std::fs::read_to_string(abs_path) else {
926 continue;
927 };
928 let plugin_result = input.plugin.resolve_config(abs_path, &source, input.root);
929 if plugin_result.is_empty() {
930 continue;
931 }
932 input.resolved_plugins.insert(input.plugin.name());
933 process_resolved_plugin_config(ResolvedPluginConfigInput {
934 plugin: input.plugin,
935 abs_path,
936 plugin_result,
937 result: input.result,
938 regex_errors: input.regex_errors,
939 message: "resolved config",
940 config_display: abs_path.display(),
941 });
942 }
943}
944
945fn resolve_plugin_filesystem_config(
946 plugin: &dyn Plugin,
947 abs_path: &Path,
948 root: &Path,
949 result: &mut AggregatedPluginResult,
950 regex_errors: &mut Vec<PluginRegexValidationError>,
951) {
952 let Ok(source) = std::fs::read_to_string(abs_path) else {
953 return;
954 };
955 let plugin_result = plugin.resolve_config(abs_path, &source, root);
956 if plugin_result.is_empty() {
957 return;
958 }
959 let rel = abs_path
960 .strip_prefix(root)
961 .map(|p| p.to_string_lossy())
962 .unwrap_or_default();
963 process_resolved_plugin_config(ResolvedPluginConfigInput {
964 plugin,
965 abs_path,
966 plugin_result,
967 result,
968 regex_errors,
969 message: "resolved config (filesystem fallback)",
970 config_display: rel,
971 });
972}
973
974struct ResolvedPluginConfigInput<'a, D> {
975 plugin: &'a dyn Plugin,
976 abs_path: &'a Path,
977 plugin_result: PluginResult,
978 result: &'a mut AggregatedPluginResult,
979 regex_errors: &'a mut Vec<PluginRegexValidationError>,
980 message: &'static str,
981 config_display: D,
982}
983
984fn process_resolved_plugin_config(input: ResolvedPluginConfigInput<'_, impl std::fmt::Display>) {
985 tracing::debug!(
986 plugin = input.plugin.name(),
987 config = %input.config_display,
988 entries = input.plugin_result.entry_patterns.len(),
989 deps = input.plugin_result.referenced_dependencies.len(),
990 input.message
991 );
992 if let Err(mut errors) = process_config_result(
993 input.plugin.name(),
994 input.plugin_result,
995 input.result,
996 Some(input.abs_path),
997 ) {
998 input.regex_errors.append(&mut errors);
999 }
1000}
1001
1002fn should_warn(key: String) -> bool {
1006 plugin_warn_dedupe()
1007 .lock()
1008 .map_or(true, |mut set| set.insert(key))
1009}
1010
1011#[derive(Debug, Clone, PartialEq, Eq)]
1018pub(crate) enum PluginDiagnostic {
1019 PatternCollision {
1021 pattern: String,
1022 owners: Vec<String>,
1023 },
1024 EnablerTypo {
1027 plugin: String,
1028 enabler: String,
1029 suggestion: String,
1030 },
1031}
1032
1033pub(crate) fn detect_pattern_collisions(
1059 builtin_active: &[&dyn Plugin],
1060 external_active: &[&ExternalPluginDef],
1061) -> Vec<PluginDiagnostic> {
1062 use rustc_hash::FxHashMap;
1063
1064 let mut pattern_owners: FxHashMap<String, (Vec<String>, FxHashSet<String>)> =
1065 FxHashMap::default();
1066
1067 let record = |pattern_owners: &mut FxHashMap<_, (Vec<String>, FxHashSet<String>)>,
1068 pattern: String,
1069 name: String| {
1070 let (list, seen) = pattern_owners.entry(pattern).or_default();
1071 if seen.insert(name.clone()) {
1072 list.push(name);
1073 }
1074 };
1075
1076 for plugin in builtin_active {
1077 for pat in plugin.config_patterns() {
1078 record(
1079 &mut pattern_owners,
1080 (*pat).to_string(),
1081 plugin.name().to_string(),
1082 );
1083 }
1084 }
1085 for ext in external_active {
1086 for pat in &ext.config_patterns {
1087 record(&mut pattern_owners, pat.clone(), ext.name.clone());
1088 }
1089 }
1090
1091 let builtin_names: FxHashSet<&str> = builtin_active.iter().map(|p| p.name()).collect();
1098
1099 let mut findings: Vec<PluginDiagnostic> = pattern_owners
1100 .into_iter()
1101 .filter_map(|(pattern, (owners, _seen))| {
1102 if owners.len() < 2 || owners.iter().all(|o| builtin_names.contains(o.as_str())) {
1103 None
1104 } else {
1105 Some(PluginDiagnostic::PatternCollision { pattern, owners })
1106 }
1107 })
1108 .collect();
1109 findings.sort_unstable_by(|a, b| match (a, b) {
1110 (
1111 PluginDiagnostic::PatternCollision { pattern: ap, .. },
1112 PluginDiagnostic::PatternCollision { pattern: bp, .. },
1113 ) => ap.cmp(bp),
1114 _ => std::cmp::Ordering::Equal,
1115 });
1116 findings
1117}
1118
1119pub(crate) fn detect_enabler_typos(
1134 external_plugins: &[ExternalPluginDef],
1135 all_deps: &[String],
1136) -> Vec<PluginDiagnostic> {
1137 let mut findings = Vec::new();
1138
1139 for ext in external_plugins {
1140 if ext.detection.is_some() || ext.enablers.is_empty() {
1141 continue;
1142 }
1143
1144 let any_match = ext.enablers.iter().any(|enabler| {
1145 if enabler.ends_with('/') {
1146 all_deps.iter().any(|d| d.starts_with(enabler))
1147 } else {
1148 all_deps.iter().any(|d| d == enabler)
1149 }
1150 });
1151 if any_match {
1152 continue;
1153 }
1154
1155 for enabler in &ext.enablers {
1156 let candidates = all_deps.iter().map(String::as_str);
1157 let Some(suggestion) = fallow_config::levenshtein::closest_match(enabler, candidates)
1158 else {
1159 continue;
1160 };
1161
1162 findings.push(PluginDiagnostic::EnablerTypo {
1163 plugin: ext.name.clone(),
1164 enabler: enabler.clone(),
1165 suggestion: suggestion.to_string(),
1166 });
1167 }
1168 }
1169
1170 findings
1171}
1172
1173fn emit_plugin_diagnostics(findings: &[PluginDiagnostic]) {
1176 for finding in findings {
1177 match finding {
1178 PluginDiagnostic::PatternCollision { pattern, owners } => {
1179 let key = format!("collision::{pattern}::{owners:?}");
1180 if !should_warn(key) {
1181 continue;
1182 }
1183 let winner = &owners[0];
1184 let others = owners[1..].join(", ");
1185 tracing::warn!(
1186 "plugin config_patterns collision: identical pattern \
1187 '{pattern}' is claimed by plugins [{joined}]; '{winner}' \
1188 runs first (registration order), others ({others}) \
1189 follow. Rename one of the patterns or remove the \
1190 duplicate plugin to make resolution explicit. A future \
1191 release may reject identical-pattern collisions.",
1192 joined = owners.join(", "),
1193 );
1194 }
1195 PluginDiagnostic::EnablerTypo {
1196 plugin,
1197 enabler,
1198 suggestion,
1199 } => {
1200 let key = format!("enabler::{plugin}::{enabler}");
1201 if !should_warn(key) {
1202 continue;
1203 }
1204 tracing::warn!(
1205 "plugin '{plugin}' enabler '{enabler}' does not match any \
1206 dependency in package.json; did you mean '{suggestion}'? \
1207 The plugin will not activate. A future release may reject \
1208 unmatched enablers.",
1209 );
1210 }
1211 }
1212 }
1213}
1214
1215fn process_package_json_inline_configs(
1220 active: &[&dyn Plugin],
1221 config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
1222 relative_files: &[(PathBuf, String)],
1223 root: &Path,
1224 result: &mut AggregatedPluginResult,
1225 regex_errors: &mut Vec<PluginRegexValidationError>,
1226) {
1227 for plugin in active {
1228 let Some(key) = plugin.package_json_config_key() else {
1229 continue;
1230 };
1231 if check_has_config_file(*plugin, config_matchers, relative_files) {
1232 continue;
1233 }
1234 let pkg_path = root.join("package.json");
1235 let Ok(content) = std::fs::read_to_string(&pkg_path) else {
1236 continue;
1237 };
1238 let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
1239 continue;
1240 };
1241 let Some(config_value) = json.get(key) else {
1242 continue;
1243 };
1244 let config_json = serde_json::to_string(config_value).unwrap_or_default();
1245 let fake_path = root.join(format!("{key}.config.json"));
1246 let plugin_result = plugin.resolve_config(&fake_path, &config_json, root);
1247 if plugin_result.is_empty() {
1248 continue;
1249 }
1250 tracing::debug!(
1251 plugin = plugin.name(),
1252 key = key,
1253 "resolved inline package.json config"
1254 );
1255 if let Err(mut errors) =
1256 process_config_result(plugin.name(), plugin_result, result, Some(&pkg_path))
1257 {
1258 regex_errors.append(&mut errors);
1259 }
1260 }
1261}
1262
1263#[derive(Debug)]
1266struct MetaFrameworkWarning {
1267 dedupe_key: &'static str,
1268 message: &'static str,
1269}
1270
1271fn missing_meta_framework_prerequisites(
1281 active_plugins: &[&dyn Plugin],
1282 root: &Path,
1283) -> Vec<MetaFrameworkWarning> {
1284 active_plugins
1285 .iter()
1286 .filter_map(|plugin| match plugin.name() {
1287 "nuxt" if !root.join(".nuxt/tsconfig.json").exists() => Some(MetaFrameworkWarning {
1288 dedupe_key: "meta-prereq::nuxt",
1289 message: "Nuxt project missing .nuxt/tsconfig.json: run `nuxt prepare` \
1290 before fallow for accurate analysis",
1291 }),
1292 "astro" if !root.join(".astro").exists() => Some(MetaFrameworkWarning {
1293 dedupe_key: "meta-prereq::astro",
1294 message: "Astro project missing .astro/ types: run `astro sync` \
1295 before fallow for accurate analysis",
1296 }),
1297 _ => None,
1298 })
1299 .collect()
1300}
1301
1302fn check_meta_framework_prerequisites(active_plugins: &[&dyn Plugin], root: &Path) {
1312 for warning in missing_meta_framework_prerequisites(active_plugins, root) {
1313 if should_warn(warning.dedupe_key.to_owned()) {
1314 tracing::warn!("{}", warning.message);
1315 }
1316 }
1317}
1318
1319fn script_activation_packages(
1320 pkg: &PackageJson,
1321 root: &Path,
1322 all_deps: &[String],
1323 production_mode: bool,
1324) -> FxHashSet<String> {
1325 let Some(pkg_scripts) = pkg.scripts.as_ref() else {
1326 return FxHashSet::default();
1327 };
1328
1329 let scripts_to_analyze = if production_mode {
1330 scripts::filter_production_scripts(pkg_scripts)
1331 } else {
1332 pkg_scripts.clone()
1333 };
1334
1335 let mut nm_roots = Vec::new();
1336 if root.join("node_modules").is_dir() {
1337 nm_roots.push(root);
1338 }
1339 let bin_map = scripts::build_bin_to_package_map(&nm_roots, all_deps);
1340 let dep_set: FxHashSet<String> = all_deps.iter().cloned().collect();
1341 let script_names: FxHashSet<String> = pkg_scripts.keys().cloned().collect();
1342
1343 scripts::analyze_scripts_with_dependency_context(
1344 &scripts_to_analyze,
1345 root,
1346 &bin_map,
1347 &dep_set,
1348 &script_names,
1349 )
1350 .used_packages
1351}
1352
1353#[cfg(test)]
1354mod tests;