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 framework_class_member_contracts: Vec<fallow_types::semantic::SemanticFrameworkContract>,
246 pub referenced_dependencies: Vec<String>,
248 pub package_referenced_dependencies: Vec<(PathBuf, String)>,
250 pub discovered_always_used: Vec<(String, String)>,
252 pub setup_files: Vec<(PathBuf, String)>,
254 pub tooling_dependencies: Vec<String>,
256 pub script_used_packages: FxHashSet<String>,
258 pub virtual_module_prefixes: Vec<String>,
261 pub virtual_package_suffixes: Vec<String>,
264 pub generated_import_patterns: Vec<String>,
267 pub generated_type_import_prefixes: Vec<String>,
270 pub path_aliases: Vec<(String, String)>,
273 pub auto_imports: Vec<AutoImportRule>,
277 pub active_plugins: Vec<String>,
279 pub fixture_patterns: Vec<(String, String)>,
281 pub scss_include_paths: Vec<PathBuf>,
286 pub static_dir_mappings: Vec<(PathBuf, String)>,
288 pub provided_dependencies: Vec<ProvidedDependencyRule>,
290}
291
292fn extend_unique(target: &mut Vec<String>, incoming: Vec<String>) {
297 let mut seen: FxHashSet<String> = target.iter().cloned().collect();
298 for item in incoming {
299 if seen.insert(item.clone()) {
300 target.push(item);
301 }
302 }
303}
304
305fn prefix_if_needed(pat: &str, ws_prefix: &str) -> String {
309 if pat.starts_with(ws_prefix) || pat.starts_with('/') {
310 pat.to_string()
311 } else {
312 format!("{ws_prefix}/{pat}")
313 }
314}
315
316impl AggregatedPluginResult {
317 pub(crate) fn apply_workspace_prefix(&mut self, ws_prefix: &str) {
331 for (rule, _) in &mut self.entry_patterns {
332 *rule = rule.prefixed(ws_prefix);
333 }
334 for (pat, _) in &mut self.always_used {
335 *pat = prefix_if_needed(pat, ws_prefix);
336 }
337 for (pat, _) in &mut self.discovered_always_used {
338 *pat = prefix_if_needed(pat, ws_prefix);
339 }
340 for (pat, _) in &mut self.fixture_patterns {
341 *pat = prefix_if_needed(pat, ws_prefix);
342 }
343 for rule in &mut self.used_exports {
344 *rule = rule.prefixed(ws_prefix);
345 }
346 for rule in &mut self.provided_dependencies {
347 *rule = rule.prefixed(ws_prefix);
348 }
349 for (_, replacement) in &mut self.path_aliases {
350 *replacement = format!("{ws_prefix}/{replacement}");
351 }
352 }
353
354 pub(crate) fn merge_into(&mut self, other: Self) {
368 let Self {
369 entry_patterns,
370 entry_point_roles,
371 config_patterns,
372 always_used,
373 used_exports,
374 used_class_members,
375 framework_class_member_contracts,
376 referenced_dependencies,
377 package_referenced_dependencies,
378 discovered_always_used,
379 setup_files,
380 tooling_dependencies,
381 script_used_packages,
382 virtual_module_prefixes,
383 virtual_package_suffixes,
384 generated_import_patterns,
385 generated_type_import_prefixes,
386 path_aliases,
387 auto_imports,
388 active_plugins,
389 fixture_patterns,
390 scss_include_paths,
391 static_dir_mappings,
392 provided_dependencies,
393 } = other;
394
395 self.entry_patterns.extend(entry_patterns);
396 for (plugin_name, role) in entry_point_roles {
397 self.entry_point_roles.entry(plugin_name).or_insert(role);
398 }
399 self.config_patterns.extend(config_patterns);
400 self.always_used.extend(always_used);
401 self.used_exports.extend(used_exports);
402 self.used_class_members.extend(used_class_members);
403 for contract in framework_class_member_contracts {
404 if !self.framework_class_member_contracts.contains(&contract) {
405 self.framework_class_member_contracts.push(contract);
406 }
407 }
408 self.referenced_dependencies.extend(referenced_dependencies);
409 self.package_referenced_dependencies
410 .extend(package_referenced_dependencies);
411 self.discovered_always_used.extend(discovered_always_used);
412 self.setup_files.extend(setup_files);
413 self.tooling_dependencies.extend(tooling_dependencies);
414 self.script_used_packages.extend(script_used_packages);
415 extend_unique(&mut self.virtual_module_prefixes, virtual_module_prefixes);
416 extend_unique(&mut self.virtual_package_suffixes, virtual_package_suffixes);
417 extend_unique(
418 &mut self.generated_import_patterns,
419 generated_import_patterns,
420 );
421 extend_unique(
422 &mut self.generated_type_import_prefixes,
423 generated_type_import_prefixes,
424 );
425 self.path_aliases.extend(path_aliases);
426 self.auto_imports.extend(auto_imports);
427 extend_unique(&mut self.active_plugins, active_plugins);
428 self.fixture_patterns.extend(fixture_patterns);
429 self.scss_include_paths.extend(scss_include_paths);
430 self.static_dir_mappings.extend(static_dir_mappings);
431 self.provided_dependencies.extend(provided_dependencies);
432 }
433}
434
435impl PluginRegistry {
436 #[must_use]
438 pub fn new(external: Vec<ExternalPluginDef>) -> Self {
439 Self {
440 plugins: builtin::create_builtin_plugins(),
441 external_plugins: external,
442 }
443 }
444
445 #[must_use]
450 pub fn discovery_hidden_dirs(&self, pkg: &PackageJson, root: &Path) -> Vec<String> {
451 let all_deps = pkg.all_dependency_names();
452 let mut seen = FxHashSet::default();
453 let mut dirs = Vec::new();
454
455 for plugin in &self.plugins {
456 if !plugin.is_enabled_with_deps(&all_deps, root) {
457 continue;
458 }
459 for dir in plugin.discovery_hidden_dirs() {
460 if seen.insert(*dir) {
461 dirs.push((*dir).to_string());
462 }
463 }
464 }
465
466 dirs
467 }
468
469 #[cfg(test)]
474 fn run(
475 &self,
476 pkg: &PackageJson,
477 root: &Path,
478 discovered_files: &[PathBuf],
479 ) -> AggregatedPluginResult {
480 self.try_run(pkg, root, discovered_files)
481 .unwrap_or_else(|errors| panic!("{}", format_plugin_regex_errors(&errors)))
482 }
483
484 pub fn try_run(
486 &self,
487 pkg: &PackageJson,
488 root: &Path,
489 discovered_files: &[PathBuf],
490 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
491 self.try_run_with_search_roots(pkg, root, discovered_files, &[root], false, None)
492 }
493
494 #[expect(
497 clippy::too_many_arguments,
498 reason = "public PluginRegistry API; signature is part of the crate surface for embedders"
499 )]
500 pub(crate) fn try_run_with_search_roots(
501 &self,
502 pkg: &PackageJson,
503 root: &Path,
504 discovered_files: &[PathBuf],
505 config_search_roots: &[&Path],
506 production_mode: bool,
507 candidate_index: Option<&ConfigCandidateIndex>,
508 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
509 let _span = tracing::info_span!("run_plugins").entered();
510 let mut result = AggregatedPluginResult::default();
511 let mut regex_errors = Vec::new();
512
513 let PluginRunContext { all_deps, active } = self.prepare_plugin_run_context(
514 pkg,
515 root,
516 discovered_files,
517 production_mode,
518 candidate_index,
519 );
520
521 self.run_plugin_preflight(&active, &all_deps, root, discovered_files);
522
523 for plugin in &active {
524 process_static_patterns(*plugin, root, &mut result);
525 }
526 process_package_json_metadata(&active, pkg, root, &mut result, &mut regex_errors);
527
528 process_external_plugins(
529 &self.external_plugins,
530 &all_deps,
531 root,
532 discovered_files,
533 &mut result,
534 );
535
536 let config_matchers = compile_config_matchers(&active);
537 let relative_files =
538 compute_relative_files(&config_matchers, &active, discovered_files, root);
539
540 resolve_plugin_config_files(&mut PluginConfigResolutionInput {
541 config_matchers: &config_matchers,
542 relative_files: &relative_files,
543 config_search_roots,
544 production_mode,
545 candidate_index,
546 root,
547 result: &mut result,
548 regex_errors: &mut regex_errors,
549 });
550
551 process_package_json_inline_configs(
552 &active,
553 &config_matchers,
554 &relative_files,
555 root,
556 &mut result,
557 &mut regex_errors,
558 );
559
560 if regex_errors.is_empty() {
561 Ok(result)
562 } else {
563 Err(regex_errors)
564 }
565 }
566
567 #[cfg(test)]
573 fn run_workspace_fast(&self, input: &WorkspacePluginRunInput<'_>) -> AggregatedPluginResult {
574 self.try_run_workspace_fast(input)
575 .unwrap_or_else(|errors| panic!("{}", format_plugin_regex_errors(&errors)))
576 }
577
578 pub(crate) fn try_run_workspace_fast(
584 &self,
585 input: &WorkspacePluginRunInput<'_>,
586 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
587 let _span = tracing::info_span!("run_plugins").entered();
588 let mut result = AggregatedPluginResult::default();
589 let mut regex_errors = Vec::new();
590
591 let all_deps = input.pkg.all_dependency_names();
592 let script_packages =
593 script_activation_packages(input.pkg, input.root, &all_deps, input.production_mode);
594 let workspace_files: Vec<PathBuf> = input
595 .relative_files
596 .iter()
597 .map(|(abs_path, _)| abs_path.clone())
598 .collect();
599
600 let active = self.collect_active_plugins(&PluginActivationInput {
601 pkg: input.pkg,
602 root: input.root,
603 discovered_files: &workspace_files,
604 all_deps: &all_deps,
605 script_packages: &script_packages,
606 candidate_index: input.candidate_index,
607 });
608
609 log_active_plugins(&active);
610
611 self.emit_silent_fail_diagnostics(&active, &all_deps, input.root, &workspace_files);
612
613 process_external_plugins(
614 &self.external_plugins,
615 &all_deps,
616 input.root,
617 &workspace_files,
618 &mut result,
619 );
620
621 if active.is_empty() && result.active_plugins.is_empty() {
622 return Ok(result);
623 }
624
625 process_workspace_active_plugins(&active, input, &mut result, &mut regex_errors);
626 resolve_workspace_plugin_configs(&active, input, &mut result, &mut regex_errors);
627
628 if regex_errors.is_empty() {
629 Ok(result)
630 } else {
631 Err(regex_errors)
632 }
633 }
634
635 #[must_use]
638 pub(crate) fn precompile_config_matchers(
639 &self,
640 ) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
641 self.plugins
642 .iter()
643 .filter(|p| !p.config_patterns().is_empty())
644 .map(|p| {
645 let matchers: Vec<globset::GlobMatcher> = p
646 .config_patterns()
647 .iter()
648 .filter_map(|pat| {
649 let prepared = prepare_config_pattern(pat);
650 globset::Glob::new(&prepared)
651 .ok()
652 .map(|g| g.compile_matcher())
653 })
654 .collect();
655 (p.as_ref(), matchers)
656 })
657 .collect()
658 }
659}
660
661fn process_workspace_active_plugins(
662 active: &[&dyn Plugin],
663 input: &WorkspacePluginRunInput<'_>,
664 result: &mut AggregatedPluginResult,
665 regex_errors: &mut Vec<PluginRegexValidationError>,
666) {
667 for plugin in active {
668 process_static_patterns(*plugin, input.root, result);
669 }
670 process_package_json_metadata(active, input.pkg, input.root, result, regex_errors);
671}
672
673fn resolve_workspace_plugin_configs(
674 active: &[&dyn Plugin],
675 input: &WorkspacePluginRunInput<'_>,
676 result: &mut AggregatedPluginResult,
677 regex_errors: &mut Vec<PluginRegexValidationError>,
678) {
679 let workspace_matchers = select_workspace_matchers(
680 input.precompiled_config_matchers,
681 active,
682 input.skip_config_plugins,
683 );
684
685 let mut resolved_ws_plugins: FxHashSet<&str> = FxHashSet::default();
686 for (plugin, matchers) in &workspace_matchers {
687 resolve_plugin_matching_files(&mut PluginMatchingFilesInput {
688 plugin: *plugin,
689 matchers,
690 relative_files: input.relative_files,
691 root: input.root,
692 result,
693 regex_errors,
694 resolved_plugins: &mut resolved_ws_plugins,
695 });
696 }
697
698 load_workspace_filesystem_configs(&mut WorkspaceFsConfigInput {
699 workspace_matchers: &workspace_matchers,
700 resolved_ws_plugins: &resolved_ws_plugins,
701 root: input.root,
702 project_root: input.project_root,
703 production_mode: input.production_mode,
704 candidate_index: input.candidate_index,
705 result,
706 regex_errors,
707 });
708}
709
710impl Default for PluginRegistry {
711 fn default() -> Self {
712 Self::new(vec![])
713 }
714}
715
716impl PluginRegistry {
717 fn prepare_plugin_run_context<'a>(
718 &'a self,
719 pkg: &PackageJson,
720 root: &Path,
721 discovered_files: &[PathBuf],
722 production_mode: bool,
723 candidate_index: Option<&ConfigCandidateIndex>,
724 ) -> PluginRunContext<'a> {
725 let all_deps = pkg.all_dependency_names();
726 let script_packages = script_activation_packages(pkg, root, &all_deps, production_mode);
727 let active = self.collect_active_plugins(&PluginActivationInput {
728 pkg,
729 root,
730 discovered_files,
731 all_deps: &all_deps,
732 script_packages: &script_packages,
733 candidate_index,
734 });
735
736 PluginRunContext { all_deps, active }
737 }
738
739 fn run_plugin_preflight(
740 &self,
741 active: &[&dyn Plugin],
742 all_deps: &[String],
743 root: &Path,
744 discovered_files: &[PathBuf],
745 ) {
746 log_active_plugins(active);
747 check_meta_framework_prerequisites(active, root);
748 self.emit_silent_fail_diagnostics(active, all_deps, root, discovered_files);
749 }
750
751 fn collect_active_plugins<'a>(
754 &'a self,
755 activation: &PluginActivationInput<'_>,
756 ) -> Vec<&'a dyn Plugin> {
757 self.plugins
758 .iter()
759 .filter(|p| {
760 p.is_enabled_with_files(
761 activation.all_deps,
762 activation.root,
763 activation.discovered_files,
764 activation.candidate_index,
765 ) || p.is_enabled_with_scripts(activation.script_packages, activation.root)
766 || p.is_enabled_with_package_json(activation.pkg, activation.root)
767 })
768 .map(AsRef::as_ref)
769 .collect()
770 }
771
772 fn emit_silent_fail_diagnostics(
781 &self,
782 active: &[&dyn Plugin],
783 all_deps: &[String],
784 root: &Path,
785 discovered_files: &[PathBuf],
786 ) {
787 let active_external: Vec<&ExternalPluginDef> = self
788 .external_plugins
789 .iter()
790 .filter(|ext| is_external_plugin_active(ext, all_deps, root, discovered_files))
791 .collect();
792 let mut diagnostics = detect_pattern_collisions(active, &active_external);
793 diagnostics.extend(detect_enabler_typos(&self.external_plugins, all_deps));
794 emit_plugin_diagnostics(&diagnostics);
795 }
796}
797
798fn plugin_warn_dedupe() -> &'static std::sync::Mutex<FxHashSet<String>> {
805 static WARNED: std::sync::OnceLock<std::sync::Mutex<FxHashSet<String>>> =
806 std::sync::OnceLock::new();
807 WARNED.get_or_init(|| std::sync::Mutex::new(FxHashSet::default()))
808}
809
810struct PluginConfigResolutionInput<'a> {
811 config_matchers: &'a [(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
812 relative_files: &'a [(PathBuf, String)],
813 config_search_roots: &'a [&'a Path],
814 production_mode: bool,
815 candidate_index: Option<&'a ConfigCandidateIndex>,
816 root: &'a Path,
817 result: &'a mut AggregatedPluginResult,
818 regex_errors: &'a mut Vec<PluginRegexValidationError>,
819}
820
821fn select_workspace_matchers<'a>(
824 precompiled_config_matchers: &[(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
825 active: &[&dyn Plugin],
826 skip_config_plugins: &FxHashSet<&str>,
827) -> Vec<(&'a dyn Plugin, Vec<globset::GlobMatcher>)> {
828 let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
829 precompiled_config_matchers
830 .iter()
831 .filter(|(p, _)| {
832 active_names.contains(p.name())
833 && (!skip_config_plugins.contains(p.name())
834 || must_parse_workspace_config_when_root_active(p.name()))
835 })
836 .map(|(plugin, matchers)| (*plugin, matchers.clone()))
837 .collect()
838}
839
840struct WorkspaceFsConfigInput<'a> {
841 workspace_matchers: &'a [(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
842 resolved_ws_plugins: &'a FxHashSet<&'a str>,
843 root: &'a Path,
844 project_root: &'a Path,
845 production_mode: bool,
846 candidate_index: Option<&'a ConfigCandidateIndex>,
847 result: &'a mut AggregatedPluginResult,
848 regex_errors: &'a mut Vec<PluginRegexValidationError>,
849}
850
851fn load_workspace_filesystem_configs(input: &mut WorkspaceFsConfigInput<'_>) {
854 let search_roots: &[&Path] = if input.root == input.project_root {
855 &[input.root]
856 } else {
857 &[input.root, input.project_root]
858 };
859 let ws_json_configs = discover_config_files(
860 input.workspace_matchers,
861 input.resolved_ws_plugins,
862 search_roots,
863 input.production_mode,
864 input.candidate_index,
865 );
866 for (abs_path, plugin) in &ws_json_configs {
867 let Ok(source) = std::fs::read_to_string(abs_path) else {
868 continue;
869 };
870 let plugin_result = plugin.resolve_config(abs_path, &source, input.root);
871 if plugin_result.is_empty() {
872 continue;
873 }
874 let rel = abs_path
875 .strip_prefix(input.project_root)
876 .map(|p| p.to_string_lossy())
877 .unwrap_or_default();
878 tracing::debug!(
879 plugin = plugin.name(),
880 config = %rel,
881 entries = plugin_result.entry_patterns.len(),
882 deps = plugin_result.referenced_dependencies.len(),
883 "resolved config (workspace filesystem fallback)"
884 );
885 if let Err(mut errors) =
886 process_config_result(plugin.name(), plugin_result, input.result, Some(abs_path))
887 {
888 input.regex_errors.append(&mut errors);
889 }
890 }
891}
892
893fn resolve_plugin_config_files(input: &mut PluginConfigResolutionInput<'_>) {
894 if input.config_matchers.is_empty() {
895 return;
896 }
897
898 let mut resolved_plugins: FxHashSet<&str> = FxHashSet::default();
899 for (plugin, matchers) in input.config_matchers {
900 resolve_plugin_matching_files(&mut PluginMatchingFilesInput {
901 plugin: *plugin,
902 matchers,
903 relative_files: input.relative_files,
904 root: input.root,
905 result: input.result,
906 regex_errors: input.regex_errors,
907 resolved_plugins: &mut resolved_plugins,
908 });
909 }
910
911 let json_configs = discover_config_files(
912 input.config_matchers,
913 &resolved_plugins,
914 input.config_search_roots,
915 input.production_mode,
916 input.candidate_index,
917 );
918 for (abs_path, plugin) in &json_configs {
919 resolve_plugin_filesystem_config(
920 *plugin,
921 abs_path,
922 input.root,
923 input.result,
924 input.regex_errors,
925 );
926 }
927}
928
929struct PluginMatchingFilesInput<'plugins, 'data, 'state> {
930 plugin: &'plugins dyn Plugin,
931 matchers: &'data [globset::GlobMatcher],
932 relative_files: &'data [(PathBuf, String)],
933 root: &'data Path,
934 result: &'state mut AggregatedPluginResult,
935 regex_errors: &'state mut Vec<PluginRegexValidationError>,
936 resolved_plugins: &'state mut FxHashSet<&'plugins str>,
937}
938
939fn resolve_plugin_matching_files(input: &mut PluginMatchingFilesInput<'_, '_, '_>) {
940 use rayon::prelude::*;
941
942 let plugin_hits: Vec<&PathBuf> = input
943 .relative_files
944 .par_iter()
945 .filter_map(|(abs_path, rel_path)| {
946 input
947 .matchers
948 .iter()
949 .any(|m| m.is_match(rel_path.as_str()))
950 .then_some(abs_path)
951 })
952 .collect();
953 for abs_path in plugin_hits {
954 let Ok(source) = std::fs::read_to_string(abs_path) else {
955 continue;
956 };
957 let plugin_result = input.plugin.resolve_config(abs_path, &source, input.root);
958 if plugin_result.is_empty() {
959 continue;
960 }
961 input.resolved_plugins.insert(input.plugin.name());
962 process_resolved_plugin_config(ResolvedPluginConfigInput {
963 plugin: input.plugin,
964 abs_path,
965 plugin_result,
966 result: input.result,
967 regex_errors: input.regex_errors,
968 message: "resolved config",
969 config_display: abs_path.display(),
970 });
971 }
972}
973
974fn resolve_plugin_filesystem_config(
975 plugin: &dyn Plugin,
976 abs_path: &Path,
977 root: &Path,
978 result: &mut AggregatedPluginResult,
979 regex_errors: &mut Vec<PluginRegexValidationError>,
980) {
981 let Ok(source) = std::fs::read_to_string(abs_path) else {
982 return;
983 };
984 let plugin_result = plugin.resolve_config(abs_path, &source, root);
985 if plugin_result.is_empty() {
986 return;
987 }
988 let rel = abs_path
989 .strip_prefix(root)
990 .map(|p| p.to_string_lossy())
991 .unwrap_or_default();
992 process_resolved_plugin_config(ResolvedPluginConfigInput {
993 plugin,
994 abs_path,
995 plugin_result,
996 result,
997 regex_errors,
998 message: "resolved config (filesystem fallback)",
999 config_display: rel,
1000 });
1001}
1002
1003struct ResolvedPluginConfigInput<'a, D> {
1004 plugin: &'a dyn Plugin,
1005 abs_path: &'a Path,
1006 plugin_result: PluginResult,
1007 result: &'a mut AggregatedPluginResult,
1008 regex_errors: &'a mut Vec<PluginRegexValidationError>,
1009 message: &'static str,
1010 config_display: D,
1011}
1012
1013fn process_resolved_plugin_config(input: ResolvedPluginConfigInput<'_, impl std::fmt::Display>) {
1014 tracing::debug!(
1015 plugin = input.plugin.name(),
1016 config = %input.config_display,
1017 entries = input.plugin_result.entry_patterns.len(),
1018 deps = input.plugin_result.referenced_dependencies.len(),
1019 input.message
1020 );
1021 if let Err(mut errors) = process_config_result(
1022 input.plugin.name(),
1023 input.plugin_result,
1024 input.result,
1025 Some(input.abs_path),
1026 ) {
1027 input.regex_errors.append(&mut errors);
1028 }
1029}
1030
1031fn should_warn(key: String) -> bool {
1035 plugin_warn_dedupe()
1036 .lock()
1037 .map_or(true, |mut set| set.insert(key))
1038}
1039
1040#[derive(Debug, Clone, PartialEq, Eq)]
1047pub(crate) enum PluginDiagnostic {
1048 PatternCollision {
1050 pattern: String,
1051 owners: Vec<String>,
1052 },
1053 EnablerTypo {
1056 plugin: String,
1057 enabler: String,
1058 suggestion: String,
1059 },
1060}
1061
1062fn detect_pattern_collisions(
1088 builtin_active: &[&dyn Plugin],
1089 external_active: &[&ExternalPluginDef],
1090) -> Vec<PluginDiagnostic> {
1091 use rustc_hash::FxHashMap;
1092
1093 let mut pattern_owners: FxHashMap<String, (Vec<String>, FxHashSet<String>)> =
1094 FxHashMap::default();
1095
1096 let record = |pattern_owners: &mut FxHashMap<_, (Vec<String>, FxHashSet<String>)>,
1097 pattern: String,
1098 name: String| {
1099 let (list, seen) = pattern_owners.entry(pattern).or_default();
1100 if seen.insert(name.clone()) {
1101 list.push(name);
1102 }
1103 };
1104
1105 for plugin in builtin_active {
1106 for pat in plugin.config_patterns() {
1107 record(
1108 &mut pattern_owners,
1109 (*pat).to_string(),
1110 plugin.name().to_string(),
1111 );
1112 }
1113 }
1114 for ext in external_active {
1115 for pat in &ext.config_patterns {
1116 record(&mut pattern_owners, pat.clone(), ext.name.clone());
1117 }
1118 }
1119
1120 let builtin_names: FxHashSet<&str> = builtin_active.iter().map(|p| p.name()).collect();
1127
1128 let mut findings: Vec<PluginDiagnostic> = pattern_owners
1129 .into_iter()
1130 .filter_map(|(pattern, (owners, _seen))| {
1131 if owners.len() < 2 || owners.iter().all(|o| builtin_names.contains(o.as_str())) {
1132 None
1133 } else {
1134 Some(PluginDiagnostic::PatternCollision { pattern, owners })
1135 }
1136 })
1137 .collect();
1138 findings.sort_unstable_by(|a, b| match (a, b) {
1139 (
1140 PluginDiagnostic::PatternCollision { pattern: ap, .. },
1141 PluginDiagnostic::PatternCollision { pattern: bp, .. },
1142 ) => ap.cmp(bp),
1143 _ => std::cmp::Ordering::Equal,
1144 });
1145 findings
1146}
1147
1148fn detect_enabler_typos(
1163 external_plugins: &[ExternalPluginDef],
1164 all_deps: &[String],
1165) -> Vec<PluginDiagnostic> {
1166 let mut findings = Vec::new();
1167
1168 for ext in external_plugins {
1169 if ext.detection.is_some() || ext.enablers.is_empty() {
1170 continue;
1171 }
1172
1173 let any_match = ext.enablers.iter().any(|enabler| {
1174 if enabler.ends_with('/') {
1175 all_deps.iter().any(|d| d.starts_with(enabler))
1176 } else {
1177 all_deps.iter().any(|d| d == enabler)
1178 }
1179 });
1180 if any_match {
1181 continue;
1182 }
1183
1184 for enabler in &ext.enablers {
1185 let candidates = all_deps.iter().map(String::as_str);
1186 let Some(suggestion) = fallow_config::levenshtein::closest_match(enabler, candidates)
1187 else {
1188 continue;
1189 };
1190
1191 findings.push(PluginDiagnostic::EnablerTypo {
1192 plugin: ext.name.clone(),
1193 enabler: enabler.clone(),
1194 suggestion: suggestion.to_string(),
1195 });
1196 }
1197 }
1198
1199 findings
1200}
1201
1202fn emit_plugin_diagnostics(findings: &[PluginDiagnostic]) {
1205 for finding in findings {
1206 match finding {
1207 PluginDiagnostic::PatternCollision { pattern, owners } => {
1208 let key = format!("collision::{pattern}::{owners:?}");
1209 if !should_warn(key) {
1210 continue;
1211 }
1212 let winner = &owners[0];
1213 let others = owners[1..].join(", ");
1214 tracing::warn!(
1215 "plugin config_patterns collision: identical pattern \
1216 '{pattern}' is claimed by plugins [{joined}]; '{winner}' \
1217 runs first (registration order), others ({others}) \
1218 follow. Rename one of the patterns or remove the \
1219 duplicate plugin to make resolution explicit. A future \
1220 release may reject identical-pattern collisions.",
1221 joined = owners.join(", "),
1222 );
1223 }
1224 PluginDiagnostic::EnablerTypo {
1225 plugin,
1226 enabler,
1227 suggestion,
1228 } => {
1229 let key = format!("enabler::{plugin}::{enabler}");
1230 if !should_warn(key) {
1231 continue;
1232 }
1233 tracing::warn!(
1234 "plugin '{plugin}' enabler '{enabler}' does not match any \
1235 dependency in package.json; did you mean '{suggestion}'? \
1236 The plugin will not activate. A future release may reject \
1237 unmatched enablers.",
1238 );
1239 }
1240 }
1241 }
1242}
1243
1244fn process_package_json_inline_configs(
1249 active: &[&dyn Plugin],
1250 config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
1251 relative_files: &[(PathBuf, String)],
1252 root: &Path,
1253 result: &mut AggregatedPluginResult,
1254 regex_errors: &mut Vec<PluginRegexValidationError>,
1255) {
1256 for plugin in active {
1257 let Some(key) = plugin.package_json_config_key() else {
1258 continue;
1259 };
1260 if check_has_config_file(*plugin, config_matchers, relative_files) {
1261 continue;
1262 }
1263 let pkg_path = root.join("package.json");
1264 let Ok(content) = std::fs::read_to_string(&pkg_path) else {
1265 continue;
1266 };
1267 let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
1268 continue;
1269 };
1270 let Some(config_value) = json.get(key) else {
1271 continue;
1272 };
1273 let config_json = serde_json::to_string(config_value).unwrap_or_default();
1274 let fake_path = root.join(format!("{key}.config.json"));
1275 let plugin_result = plugin.resolve_config(&fake_path, &config_json, root);
1276 if plugin_result.is_empty() {
1277 continue;
1278 }
1279 tracing::debug!(
1280 plugin = plugin.name(),
1281 key = key,
1282 "resolved inline package.json config"
1283 );
1284 if let Err(mut errors) =
1285 process_config_result(plugin.name(), plugin_result, result, Some(&pkg_path))
1286 {
1287 regex_errors.append(&mut errors);
1288 }
1289 }
1290}
1291
1292#[derive(Debug)]
1295struct MetaFrameworkWarning {
1296 dedupe_key: &'static str,
1297 message: &'static str,
1298}
1299
1300fn missing_meta_framework_prerequisites(
1310 active_plugins: &[&dyn Plugin],
1311 root: &Path,
1312) -> Vec<MetaFrameworkWarning> {
1313 active_plugins
1314 .iter()
1315 .filter_map(|plugin| match plugin.name() {
1316 "nuxt" if !root.join(".nuxt/tsconfig.json").exists() => Some(MetaFrameworkWarning {
1317 dedupe_key: "meta-prereq::nuxt",
1318 message: "Nuxt project missing .nuxt/tsconfig.json: run `nuxt prepare` \
1319 before fallow for accurate analysis",
1320 }),
1321 "astro" if !root.join(".astro").exists() => Some(MetaFrameworkWarning {
1322 dedupe_key: "meta-prereq::astro",
1323 message: "Astro project missing .astro/ types: run `astro sync` \
1324 before fallow for accurate analysis",
1325 }),
1326 _ => None,
1327 })
1328 .collect()
1329}
1330
1331fn check_meta_framework_prerequisites(active_plugins: &[&dyn Plugin], root: &Path) {
1341 for warning in missing_meta_framework_prerequisites(active_plugins, root) {
1342 if should_warn(warning.dedupe_key.to_owned()) {
1343 tracing::warn!("{}", warning.message);
1344 }
1345 }
1346}
1347
1348fn script_activation_packages(
1349 pkg: &PackageJson,
1350 root: &Path,
1351 all_deps: &[String],
1352 production_mode: bool,
1353) -> FxHashSet<String> {
1354 let Some(pkg_scripts) = pkg.scripts.as_ref() else {
1355 return FxHashSet::default();
1356 };
1357
1358 let scripts_to_analyze = if production_mode {
1359 scripts::filter_production_scripts(pkg_scripts)
1360 } else {
1361 pkg_scripts.clone()
1362 };
1363
1364 let mut nm_roots = Vec::new();
1365 if root.join("node_modules").is_dir() {
1366 nm_roots.push(root);
1367 }
1368 let bin_map = scripts::build_bin_to_package_map(&nm_roots, all_deps);
1369 let dep_set: FxHashSet<String> = all_deps.iter().cloned().collect();
1370 let catalog =
1371 scripts::ScriptCatalog::from_scripts_with_bodies(pkg_scripts, &scripts_to_analyze);
1372
1373 scripts::analyze_scripts_with_dependency_context(
1374 &scripts_to_analyze,
1375 root,
1376 &bin_map,
1377 &dep_set,
1378 &catalog,
1379 )
1380 .used_packages
1381}
1382
1383#[cfg(test)]
1384mod tests;