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
30use helpers::{
31 check_has_config_file, discover_config_files, is_external_plugin_active,
32 prepare_config_pattern, process_config_result, process_external_plugins,
33 process_package_json_metadata, process_static_patterns,
34};
35
36fn must_parse_workspace_config_when_root_active(plugin_name: &str) -> bool {
37 matches!(
38 plugin_name,
39 "eslint" | "docusaurus" | "jest" | "tanstack-router" | "vitest"
40 )
41}
42
43fn compile_config_matchers<'a>(
44 active: &[&'a dyn Plugin],
45) -> Vec<(&'a dyn Plugin, Vec<globset::GlobMatcher>)> {
46 active
47 .iter()
48 .filter(|plugin| !plugin.config_patterns().is_empty())
49 .map(|plugin| {
50 let matchers = plugin
51 .config_patterns()
52 .iter()
53 .filter_map(|pattern| {
54 let prepared = prepare_config_pattern(pattern);
55 globset::Glob::new(&prepared)
56 .ok()
57 .map(|glob| glob.compile_matcher())
58 })
59 .collect();
60 (*plugin, matchers)
61 })
62 .collect()
63}
64
65pub struct PluginRegistry {
67 plugins: Vec<Box<dyn Plugin>>,
68 external_plugins: Vec<ExternalPluginDef>,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct PluginRegexValidationError {
74 plugin_name: String,
75 config_path: Option<PathBuf>,
76 rule_kind: &'static str,
77 field: &'static str,
78 rule_pattern: String,
79 regex_pattern: String,
80 source: String,
81}
82
83impl PluginRegexValidationError {
84 pub(crate) fn new(input: PluginRegexValidationErrorInput<'_>) -> Self {
85 Self {
86 plugin_name: input.plugin_name.to_owned(),
87 config_path: input.config_path.map(Path::to_path_buf),
88 rule_kind: input.rule_kind,
89 field: input.field,
90 rule_pattern: input.rule_pattern.to_owned(),
91 regex_pattern: input.regex_pattern.to_owned(),
92 source: input.source.to_string(),
93 }
94 }
95}
96
97#[derive(Clone, Copy)]
98pub(crate) struct PluginRegexValidationErrorInput<'a> {
99 pub(crate) plugin_name: &'a str,
100 pub(crate) config_path: Option<&'a Path>,
101 pub(crate) rule_kind: &'static str,
102 pub(crate) field: &'static str,
103 pub(crate) rule_pattern: &'a str,
104 pub(crate) regex_pattern: &'a str,
105 pub(crate) source: &'a regex::Error,
106}
107
108impl fmt::Display for PluginRegexValidationError {
109 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110 let location = self
111 .config_path
112 .as_ref()
113 .map(|path| format!(" in {}", path.display()))
114 .unwrap_or_default();
115 write!(
116 f,
117 "plugin '{}'{}: invalid regex '{}' in {}.{} for path rule '{}': {}",
118 self.plugin_name,
119 location,
120 self.regex_pattern,
121 self.rule_kind,
122 self.field,
123 self.rule_pattern,
124 self.source
125 )
126 }
127}
128
129#[must_use]
130pub fn format_plugin_regex_errors(errors: &[PluginRegexValidationError]) -> String {
131 let joined = errors
132 .iter()
133 .map(ToString::to_string)
134 .collect::<Vec<_>>()
135 .join("\n - ");
136 format!(
137 "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."
138 )
139}
140
141#[derive(Debug, Clone, Default)]
143pub struct AggregatedPluginResult {
144 pub entry_patterns: Vec<(PathRule, String)>,
146 pub entry_point_roles: rustc_hash::FxHashMap<String, EntryPointRole>,
148 pub config_patterns: Vec<String>,
150 pub always_used: Vec<(String, String)>,
152 pub used_exports: Vec<PluginUsedExportRule>,
154 pub used_class_members: Vec<UsedClassMemberRule>,
158 pub referenced_dependencies: Vec<String>,
160 pub package_referenced_dependencies: Vec<(PathBuf, String)>,
162 pub discovered_always_used: Vec<(String, String)>,
164 pub setup_files: Vec<(PathBuf, String)>,
166 pub tooling_dependencies: Vec<String>,
168 pub script_used_packages: FxHashSet<String>,
170 pub virtual_module_prefixes: Vec<String>,
173 pub virtual_package_suffixes: Vec<String>,
176 pub generated_import_patterns: Vec<String>,
179 pub generated_type_import_prefixes: Vec<String>,
182 pub path_aliases: Vec<(String, String)>,
185 pub auto_imports: Vec<AutoImportRule>,
189 pub active_plugins: Vec<String>,
191 pub fixture_patterns: Vec<(String, String)>,
193 pub scss_include_paths: Vec<PathBuf>,
198 pub static_dir_mappings: Vec<(PathBuf, String)>,
200 pub provided_dependencies: Vec<ProvidedDependencyRule>,
202}
203
204fn extend_unique(target: &mut Vec<String>, incoming: Vec<String>) {
209 let mut seen: FxHashSet<String> = target.iter().cloned().collect();
210 for item in incoming {
211 if seen.insert(item.clone()) {
212 target.push(item);
213 }
214 }
215}
216
217fn prefix_if_needed(pat: &str, ws_prefix: &str) -> String {
221 if pat.starts_with(ws_prefix) || pat.starts_with('/') {
222 pat.to_string()
223 } else {
224 format!("{ws_prefix}/{pat}")
225 }
226}
227
228impl AggregatedPluginResult {
229 pub fn apply_workspace_prefix(&mut self, ws_prefix: &str) {
243 for (rule, _) in &mut self.entry_patterns {
244 *rule = rule.prefixed(ws_prefix);
245 }
246 for (pat, _) in &mut self.always_used {
247 *pat = prefix_if_needed(pat, ws_prefix);
248 }
249 for (pat, _) in &mut self.discovered_always_used {
250 *pat = prefix_if_needed(pat, ws_prefix);
251 }
252 for (pat, _) in &mut self.fixture_patterns {
253 *pat = prefix_if_needed(pat, ws_prefix);
254 }
255 for rule in &mut self.used_exports {
256 *rule = rule.prefixed(ws_prefix);
257 }
258 for rule in &mut self.provided_dependencies {
259 *rule = rule.prefixed(ws_prefix);
260 }
261 for (_, replacement) in &mut self.path_aliases {
262 *replacement = format!("{ws_prefix}/{replacement}");
263 }
264 }
265
266 pub fn merge_into(&mut self, other: Self) {
279 let Self {
280 entry_patterns,
281 entry_point_roles,
282 config_patterns,
283 always_used,
284 used_exports,
285 used_class_members,
286 referenced_dependencies,
287 package_referenced_dependencies,
288 discovered_always_used,
289 setup_files,
290 tooling_dependencies,
291 script_used_packages,
292 virtual_module_prefixes,
293 virtual_package_suffixes,
294 generated_import_patterns,
295 generated_type_import_prefixes,
296 path_aliases,
297 auto_imports,
298 active_plugins,
299 fixture_patterns,
300 scss_include_paths,
301 static_dir_mappings,
302 provided_dependencies,
303 } = other;
304
305 self.entry_patterns.extend(entry_patterns);
306 for (plugin_name, role) in entry_point_roles {
307 self.entry_point_roles.entry(plugin_name).or_insert(role);
308 }
309 self.config_patterns.extend(config_patterns);
310 self.always_used.extend(always_used);
311 self.used_exports.extend(used_exports);
312 self.used_class_members.extend(used_class_members);
313 self.referenced_dependencies.extend(referenced_dependencies);
314 self.package_referenced_dependencies
315 .extend(package_referenced_dependencies);
316 self.discovered_always_used.extend(discovered_always_used);
317 self.setup_files.extend(setup_files);
318 self.tooling_dependencies.extend(tooling_dependencies);
319 self.script_used_packages.extend(script_used_packages);
320 extend_unique(&mut self.virtual_module_prefixes, virtual_module_prefixes);
321 extend_unique(&mut self.virtual_package_suffixes, virtual_package_suffixes);
322 extend_unique(
323 &mut self.generated_import_patterns,
324 generated_import_patterns,
325 );
326 extend_unique(
327 &mut self.generated_type_import_prefixes,
328 generated_type_import_prefixes,
329 );
330 self.path_aliases.extend(path_aliases);
331 self.auto_imports.extend(auto_imports);
332 extend_unique(&mut self.active_plugins, active_plugins);
333 self.fixture_patterns.extend(fixture_patterns);
334 self.scss_include_paths.extend(scss_include_paths);
335 self.static_dir_mappings.extend(static_dir_mappings);
336 self.provided_dependencies.extend(provided_dependencies);
337 }
338}
339
340impl PluginRegistry {
341 #[must_use]
343 pub fn new(external: Vec<ExternalPluginDef>) -> Self {
344 Self {
345 plugins: builtin::create_builtin_plugins(),
346 external_plugins: external,
347 }
348 }
349
350 #[must_use]
355 pub fn discovery_hidden_dirs(&self, pkg: &PackageJson, root: &Path) -> Vec<String> {
356 let all_deps = pkg.all_dependency_names();
357 let mut seen = FxHashSet::default();
358 let mut dirs = Vec::new();
359
360 for plugin in &self.plugins {
361 if !plugin.is_enabled_with_deps(&all_deps, root) {
362 continue;
363 }
364 for dir in plugin.discovery_hidden_dirs() {
365 if seen.insert(*dir) {
366 dirs.push((*dir).to_string());
367 }
368 }
369 }
370
371 dirs
372 }
373
374 #[cfg(test)]
379 pub fn run(
380 &self,
381 pkg: &PackageJson,
382 root: &Path,
383 discovered_files: &[PathBuf],
384 ) -> AggregatedPluginResult {
385 self.try_run(pkg, root, discovered_files)
386 .unwrap_or_else(|errors| panic!("{}", format_plugin_regex_errors(&errors)))
387 }
388
389 pub fn try_run(
391 &self,
392 pkg: &PackageJson,
393 root: &Path,
394 discovered_files: &[PathBuf],
395 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
396 self.try_run_with_search_roots(pkg, root, discovered_files, &[root], false)
397 }
398
399 pub fn try_run_with_search_roots(
402 &self,
403 pkg: &PackageJson,
404 root: &Path,
405 discovered_files: &[PathBuf],
406 config_search_roots: &[&Path],
407 production_mode: bool,
408 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
409 let _span = tracing::info_span!("run_plugins").entered();
410 let mut result = AggregatedPluginResult::default();
411 let mut regex_errors = Vec::new();
412
413 let all_deps = pkg.all_dependency_names();
414 let script_packages = script_activation_packages(pkg, root, &all_deps, production_mode);
415 let active: Vec<&dyn Plugin> = self
416 .plugins
417 .iter()
418 .filter(|p| {
419 p.is_enabled_with_files(&all_deps, root, discovered_files)
420 || p.is_enabled_with_scripts(&script_packages, root)
421 || p.is_enabled_with_package_json(pkg, root)
422 })
423 .map(AsRef::as_ref)
424 .collect();
425
426 tracing::info!(
427 plugins = active
428 .iter()
429 .map(|p| p.name())
430 .collect::<Vec<_>>()
431 .join(", "),
432 "active plugins"
433 );
434
435 check_meta_framework_prerequisites(&active, root);
436
437 self.emit_silent_fail_diagnostics(&active, &all_deps, root, discovered_files);
438
439 for plugin in &active {
440 process_static_patterns(*plugin, root, &mut result);
441 }
442 process_package_json_metadata(&active, pkg, root, &mut result, &mut regex_errors);
443
444 process_external_plugins(
445 &self.external_plugins,
446 &all_deps,
447 root,
448 discovered_files,
449 &mut result,
450 );
451
452 let config_matchers = compile_config_matchers(&active);
453
454 use rayon::prelude::*;
455 let needs_relative_files = !config_matchers.is_empty()
456 || active.iter().any(|p| p.package_json_config_key().is_some());
457 let relative_files: Vec<(PathBuf, String)> = if needs_relative_files {
458 discovered_files
459 .par_iter()
460 .map(|f| {
461 let rel = f
462 .strip_prefix(root)
463 .unwrap_or(f)
464 .to_string_lossy()
465 .into_owned();
466 (f.clone(), rel)
467 })
468 .collect()
469 } else {
470 Vec::new()
471 };
472
473 resolve_plugin_config_files(&mut PluginConfigResolutionInput {
474 config_matchers: &config_matchers,
475 relative_files: &relative_files,
476 config_search_roots,
477 production_mode,
478 root,
479 result: &mut result,
480 regex_errors: &mut regex_errors,
481 });
482
483 process_package_json_inline_configs(
484 &active,
485 &config_matchers,
486 &relative_files,
487 root,
488 &mut result,
489 &mut regex_errors,
490 );
491
492 if regex_errors.is_empty() {
493 Ok(result)
494 } else {
495 Err(regex_errors)
496 }
497 }
498
499 #[expect(
505 clippy::too_many_arguments,
506 reason = "Each parameter is a distinct, small value with no natural grouping; \
507 bundling them into a struct hurts call-site readability."
508 )]
509 #[cfg(test)]
510 fn run_workspace_fast(
511 &self,
512 pkg: &PackageJson,
513 root: &Path,
514 project_root: &Path,
515 precompiled_config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
516 relative_files: &[(PathBuf, String)],
517 skip_config_plugins: &FxHashSet<&str>,
518 production_mode: bool,
519 ) -> AggregatedPluginResult {
520 self.try_run_workspace_fast(
521 pkg,
522 root,
523 project_root,
524 precompiled_config_matchers,
525 relative_files,
526 skip_config_plugins,
527 production_mode,
528 )
529 .unwrap_or_else(|errors| panic!("{}", format_plugin_regex_errors(&errors)))
530 }
531
532 #[expect(
538 clippy::too_many_arguments,
539 reason = "Each parameter is a distinct, small value with no natural grouping; \
540 bundling them into a struct hurts call-site readability."
541 )]
542 pub fn try_run_workspace_fast(
543 &self,
544 pkg: &PackageJson,
545 root: &Path,
546 project_root: &Path,
547 precompiled_config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
548 relative_files: &[(PathBuf, String)],
549 skip_config_plugins: &FxHashSet<&str>,
550 production_mode: bool,
551 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
552 let _span = tracing::info_span!("run_plugins").entered();
553 let mut result = AggregatedPluginResult::default();
554 let mut regex_errors = Vec::new();
555
556 let all_deps = pkg.all_dependency_names();
557 let script_packages = script_activation_packages(pkg, root, &all_deps, production_mode);
558 let workspace_files: Vec<PathBuf> = relative_files
559 .iter()
560 .map(|(abs_path, _)| abs_path.clone())
561 .collect();
562
563 let active: Vec<&dyn Plugin> = self
564 .plugins
565 .iter()
566 .filter(|p| {
567 p.is_enabled_with_files(&all_deps, root, &workspace_files)
568 || p.is_enabled_with_scripts(&script_packages, root)
569 || p.is_enabled_with_package_json(pkg, root)
570 })
571 .map(AsRef::as_ref)
572 .collect();
573
574 tracing::info!(
575 plugins = active
576 .iter()
577 .map(|p| p.name())
578 .collect::<Vec<_>>()
579 .join(", "),
580 "active plugins"
581 );
582
583 self.emit_silent_fail_diagnostics(&active, &all_deps, root, &workspace_files);
584
585 process_external_plugins(
586 &self.external_plugins,
587 &all_deps,
588 root,
589 &workspace_files,
590 &mut result,
591 );
592
593 if active.is_empty() && result.active_plugins.is_empty() {
594 return Ok(result);
595 }
596
597 for plugin in &active {
598 process_static_patterns(*plugin, root, &mut result);
599 }
600 process_package_json_metadata(&active, pkg, root, &mut result, &mut regex_errors);
601
602 let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
603 let workspace_matchers: Vec<_> = precompiled_config_matchers
604 .iter()
605 .filter(|(p, _)| {
606 active_names.contains(p.name())
607 && (!skip_config_plugins.contains(p.name())
608 || must_parse_workspace_config_when_root_active(p.name()))
609 })
610 .map(|(plugin, matchers)| (*plugin, matchers.clone()))
611 .collect();
612
613 let mut resolved_ws_plugins: FxHashSet<&str> = FxHashSet::default();
614 for (plugin, matchers) in &workspace_matchers {
615 resolve_plugin_matching_files(&mut PluginMatchingFilesInput {
616 plugin: *plugin,
617 matchers,
618 relative_files,
619 root,
620 result: &mut result,
621 regex_errors: &mut regex_errors,
622 resolved_plugins: &mut resolved_ws_plugins,
623 });
624 }
625
626 let ws_json_configs = if root == project_root {
627 discover_config_files(
628 &workspace_matchers,
629 &resolved_ws_plugins,
630 &[root],
631 production_mode,
632 )
633 } else {
634 discover_config_files(
635 &workspace_matchers,
636 &resolved_ws_plugins,
637 &[root, project_root],
638 production_mode,
639 )
640 };
641 for (abs_path, plugin) in &ws_json_configs {
642 if let Ok(source) = std::fs::read_to_string(abs_path) {
643 let plugin_result = plugin.resolve_config(abs_path, &source, root);
644 if !plugin_result.is_empty() {
645 let rel = abs_path
646 .strip_prefix(project_root)
647 .map(|p| p.to_string_lossy())
648 .unwrap_or_default();
649 tracing::debug!(
650 plugin = plugin.name(),
651 config = %rel,
652 entries = plugin_result.entry_patterns.len(),
653 deps = plugin_result.referenced_dependencies.len(),
654 "resolved config (workspace filesystem fallback)"
655 );
656 if let Err(mut errors) = process_config_result(
657 plugin.name(),
658 plugin_result,
659 &mut result,
660 Some(abs_path),
661 ) {
662 regex_errors.append(&mut errors);
663 }
664 }
665 }
666 }
667
668 if regex_errors.is_empty() {
669 Ok(result)
670 } else {
671 Err(regex_errors)
672 }
673 }
674
675 #[must_use]
678 pub fn precompile_config_matchers(&self) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
679 self.plugins
680 .iter()
681 .filter(|p| !p.config_patterns().is_empty())
682 .map(|p| {
683 let matchers: Vec<globset::GlobMatcher> = p
684 .config_patterns()
685 .iter()
686 .filter_map(|pat| {
687 let prepared = prepare_config_pattern(pat);
688 globset::Glob::new(&prepared)
689 .ok()
690 .map(|g| g.compile_matcher())
691 })
692 .collect();
693 (p.as_ref(), matchers)
694 })
695 .collect()
696 }
697}
698
699impl Default for PluginRegistry {
700 fn default() -> Self {
701 Self::new(vec![])
702 }
703}
704
705impl PluginRegistry {
706 fn emit_silent_fail_diagnostics(
715 &self,
716 active: &[&dyn Plugin],
717 all_deps: &[String],
718 root: &Path,
719 discovered_files: &[PathBuf],
720 ) {
721 let active_external: Vec<&ExternalPluginDef> = self
722 .external_plugins
723 .iter()
724 .filter(|ext| is_external_plugin_active(ext, all_deps, root, discovered_files))
725 .collect();
726 let mut diagnostics = detect_pattern_collisions(active, &active_external);
727 diagnostics.extend(detect_enabler_typos(&self.external_plugins, all_deps));
728 emit_plugin_diagnostics(&diagnostics);
729 }
730}
731
732fn plugin_warn_dedupe() -> &'static std::sync::Mutex<FxHashSet<String>> {
739 static WARNED: std::sync::OnceLock<std::sync::Mutex<FxHashSet<String>>> =
740 std::sync::OnceLock::new();
741 WARNED.get_or_init(|| std::sync::Mutex::new(FxHashSet::default()))
742}
743
744struct PluginConfigResolutionInput<'a> {
745 config_matchers: &'a [(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
746 relative_files: &'a [(PathBuf, String)],
747 config_search_roots: &'a [&'a Path],
748 production_mode: bool,
749 root: &'a Path,
750 result: &'a mut AggregatedPluginResult,
751 regex_errors: &'a mut Vec<PluginRegexValidationError>,
752}
753
754fn resolve_plugin_config_files(input: &mut PluginConfigResolutionInput<'_>) {
755 if input.config_matchers.is_empty() {
756 return;
757 }
758
759 let mut resolved_plugins: FxHashSet<&str> = FxHashSet::default();
760 for (plugin, matchers) in input.config_matchers {
761 resolve_plugin_matching_files(&mut PluginMatchingFilesInput {
762 plugin: *plugin,
763 matchers,
764 relative_files: input.relative_files,
765 root: input.root,
766 result: input.result,
767 regex_errors: input.regex_errors,
768 resolved_plugins: &mut resolved_plugins,
769 });
770 }
771
772 let json_configs = discover_config_files(
773 input.config_matchers,
774 &resolved_plugins,
775 input.config_search_roots,
776 input.production_mode,
777 );
778 for (abs_path, plugin) in &json_configs {
779 resolve_plugin_filesystem_config(
780 *plugin,
781 abs_path,
782 input.root,
783 input.result,
784 input.regex_errors,
785 );
786 }
787}
788
789struct PluginMatchingFilesInput<'plugins, 'data, 'state> {
790 plugin: &'plugins dyn Plugin,
791 matchers: &'data [globset::GlobMatcher],
792 relative_files: &'data [(PathBuf, String)],
793 root: &'data Path,
794 result: &'state mut AggregatedPluginResult,
795 regex_errors: &'state mut Vec<PluginRegexValidationError>,
796 resolved_plugins: &'state mut FxHashSet<&'plugins str>,
797}
798
799fn resolve_plugin_matching_files(input: &mut PluginMatchingFilesInput<'_, '_, '_>) {
800 use rayon::prelude::*;
801
802 let plugin_hits: Vec<&PathBuf> = input
803 .relative_files
804 .par_iter()
805 .filter_map(|(abs_path, rel_path)| {
806 input
807 .matchers
808 .iter()
809 .any(|m| m.is_match(rel_path.as_str()))
810 .then_some(abs_path)
811 })
812 .collect();
813 for abs_path in plugin_hits {
814 let Ok(source) = std::fs::read_to_string(abs_path) else {
815 continue;
816 };
817 let plugin_result = input.plugin.resolve_config(abs_path, &source, input.root);
818 if plugin_result.is_empty() {
819 continue;
820 }
821 input.resolved_plugins.insert(input.plugin.name());
822 process_resolved_plugin_config(ResolvedPluginConfigInput {
823 plugin: input.plugin,
824 abs_path,
825 plugin_result,
826 result: input.result,
827 regex_errors: input.regex_errors,
828 message: "resolved config",
829 config_display: abs_path.display(),
830 });
831 }
832}
833
834fn resolve_plugin_filesystem_config(
835 plugin: &dyn Plugin,
836 abs_path: &Path,
837 root: &Path,
838 result: &mut AggregatedPluginResult,
839 regex_errors: &mut Vec<PluginRegexValidationError>,
840) {
841 let Ok(source) = std::fs::read_to_string(abs_path) else {
842 return;
843 };
844 let plugin_result = plugin.resolve_config(abs_path, &source, root);
845 if plugin_result.is_empty() {
846 return;
847 }
848 let rel = abs_path
849 .strip_prefix(root)
850 .map(|p| p.to_string_lossy())
851 .unwrap_or_default();
852 process_resolved_plugin_config(ResolvedPluginConfigInput {
853 plugin,
854 abs_path,
855 plugin_result,
856 result,
857 regex_errors,
858 message: "resolved config (filesystem fallback)",
859 config_display: rel,
860 });
861}
862
863struct ResolvedPluginConfigInput<'a, D> {
864 plugin: &'a dyn Plugin,
865 abs_path: &'a Path,
866 plugin_result: PluginResult,
867 result: &'a mut AggregatedPluginResult,
868 regex_errors: &'a mut Vec<PluginRegexValidationError>,
869 message: &'static str,
870 config_display: D,
871}
872
873fn process_resolved_plugin_config(input: ResolvedPluginConfigInput<'_, impl std::fmt::Display>) {
874 tracing::debug!(
875 plugin = input.plugin.name(),
876 config = %input.config_display,
877 entries = input.plugin_result.entry_patterns.len(),
878 deps = input.plugin_result.referenced_dependencies.len(),
879 input.message
880 );
881 if let Err(mut errors) = process_config_result(
882 input.plugin.name(),
883 input.plugin_result,
884 input.result,
885 Some(input.abs_path),
886 ) {
887 input.regex_errors.append(&mut errors);
888 }
889}
890
891fn should_warn(key: String) -> bool {
895 plugin_warn_dedupe()
896 .lock()
897 .map_or(true, |mut set| set.insert(key))
898}
899
900#[derive(Debug, Clone, PartialEq, Eq)]
907pub(crate) enum PluginDiagnostic {
908 PatternCollision {
910 pattern: String,
911 owners: Vec<String>,
912 },
913 EnablerTypo {
916 plugin: String,
917 enabler: String,
918 suggestion: String,
919 },
920}
921
922pub(crate) fn detect_pattern_collisions(
948 builtin_active: &[&dyn Plugin],
949 external_active: &[&ExternalPluginDef],
950) -> Vec<PluginDiagnostic> {
951 use rustc_hash::FxHashMap;
952
953 let mut pattern_owners: FxHashMap<String, (Vec<String>, FxHashSet<String>)> =
954 FxHashMap::default();
955
956 let record = |pattern_owners: &mut FxHashMap<_, (Vec<String>, FxHashSet<String>)>,
957 pattern: String,
958 name: String| {
959 let (list, seen) = pattern_owners.entry(pattern).or_default();
960 if seen.insert(name.clone()) {
961 list.push(name);
962 }
963 };
964
965 for plugin in builtin_active {
966 for pat in plugin.config_patterns() {
967 record(
968 &mut pattern_owners,
969 (*pat).to_string(),
970 plugin.name().to_string(),
971 );
972 }
973 }
974 for ext in external_active {
975 for pat in &ext.config_patterns {
976 record(&mut pattern_owners, pat.clone(), ext.name.clone());
977 }
978 }
979
980 let builtin_names: FxHashSet<&str> = builtin_active.iter().map(|p| p.name()).collect();
987
988 let mut findings: Vec<PluginDiagnostic> = pattern_owners
989 .into_iter()
990 .filter_map(|(pattern, (owners, _seen))| {
991 if owners.len() < 2 || owners.iter().all(|o| builtin_names.contains(o.as_str())) {
992 None
993 } else {
994 Some(PluginDiagnostic::PatternCollision { pattern, owners })
995 }
996 })
997 .collect();
998 findings.sort_unstable_by(|a, b| match (a, b) {
999 (
1000 PluginDiagnostic::PatternCollision { pattern: ap, .. },
1001 PluginDiagnostic::PatternCollision { pattern: bp, .. },
1002 ) => ap.cmp(bp),
1003 _ => std::cmp::Ordering::Equal,
1004 });
1005 findings
1006}
1007
1008pub(crate) fn detect_enabler_typos(
1023 external_plugins: &[ExternalPluginDef],
1024 all_deps: &[String],
1025) -> Vec<PluginDiagnostic> {
1026 let mut findings = Vec::new();
1027
1028 for ext in external_plugins {
1029 if ext.detection.is_some() || ext.enablers.is_empty() {
1030 continue;
1031 }
1032
1033 let any_match = ext.enablers.iter().any(|enabler| {
1034 if enabler.ends_with('/') {
1035 all_deps.iter().any(|d| d.starts_with(enabler))
1036 } else {
1037 all_deps.iter().any(|d| d == enabler)
1038 }
1039 });
1040 if any_match {
1041 continue;
1042 }
1043
1044 for enabler in &ext.enablers {
1045 let candidates = all_deps.iter().map(String::as_str);
1046 let Some(suggestion) = fallow_config::levenshtein::closest_match(enabler, candidates)
1047 else {
1048 continue;
1049 };
1050
1051 findings.push(PluginDiagnostic::EnablerTypo {
1052 plugin: ext.name.clone(),
1053 enabler: enabler.clone(),
1054 suggestion: suggestion.to_string(),
1055 });
1056 }
1057 }
1058
1059 findings
1060}
1061
1062fn emit_plugin_diagnostics(findings: &[PluginDiagnostic]) {
1065 for finding in findings {
1066 match finding {
1067 PluginDiagnostic::PatternCollision { pattern, owners } => {
1068 let key = format!("collision::{pattern}::{owners:?}");
1069 if !should_warn(key) {
1070 continue;
1071 }
1072 let winner = &owners[0];
1073 let others = owners[1..].join(", ");
1074 tracing::warn!(
1075 "plugin config_patterns collision: identical pattern \
1076 '{pattern}' is claimed by plugins [{joined}]; '{winner}' \
1077 runs first (registration order), others ({others}) \
1078 follow. Rename one of the patterns or remove the \
1079 duplicate plugin to make resolution explicit. A future \
1080 release may reject identical-pattern collisions.",
1081 joined = owners.join(", "),
1082 );
1083 }
1084 PluginDiagnostic::EnablerTypo {
1085 plugin,
1086 enabler,
1087 suggestion,
1088 } => {
1089 let key = format!("enabler::{plugin}::{enabler}");
1090 if !should_warn(key) {
1091 continue;
1092 }
1093 tracing::warn!(
1094 "plugin '{plugin}' enabler '{enabler}' does not match any \
1095 dependency in package.json; did you mean '{suggestion}'? \
1096 The plugin will not activate. A future release may reject \
1097 unmatched enablers.",
1098 );
1099 }
1100 }
1101 }
1102}
1103
1104fn process_package_json_inline_configs(
1109 active: &[&dyn Plugin],
1110 config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
1111 relative_files: &[(PathBuf, String)],
1112 root: &Path,
1113 result: &mut AggregatedPluginResult,
1114 regex_errors: &mut Vec<PluginRegexValidationError>,
1115) {
1116 for plugin in active {
1117 let Some(key) = plugin.package_json_config_key() else {
1118 continue;
1119 };
1120 if check_has_config_file(*plugin, config_matchers, relative_files) {
1121 continue;
1122 }
1123 let pkg_path = root.join("package.json");
1124 let Ok(content) = std::fs::read_to_string(&pkg_path) else {
1125 continue;
1126 };
1127 let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
1128 continue;
1129 };
1130 let Some(config_value) = json.get(key) else {
1131 continue;
1132 };
1133 let config_json = serde_json::to_string(config_value).unwrap_or_default();
1134 let fake_path = root.join(format!("{key}.config.json"));
1135 let plugin_result = plugin.resolve_config(&fake_path, &config_json, root);
1136 if plugin_result.is_empty() {
1137 continue;
1138 }
1139 tracing::debug!(
1140 plugin = plugin.name(),
1141 key = key,
1142 "resolved inline package.json config"
1143 );
1144 if let Err(mut errors) =
1145 process_config_result(plugin.name(), plugin_result, result, Some(&pkg_path))
1146 {
1147 regex_errors.append(&mut errors);
1148 }
1149 }
1150}
1151
1152#[derive(Debug)]
1155struct MetaFrameworkWarning {
1156 dedupe_key: &'static str,
1157 message: &'static str,
1158}
1159
1160fn missing_meta_framework_prerequisites(
1170 active_plugins: &[&dyn Plugin],
1171 root: &Path,
1172) -> Vec<MetaFrameworkWarning> {
1173 active_plugins
1174 .iter()
1175 .filter_map(|plugin| match plugin.name() {
1176 "nuxt" if !root.join(".nuxt/tsconfig.json").exists() => Some(MetaFrameworkWarning {
1177 dedupe_key: "meta-prereq::nuxt",
1178 message: "Nuxt project missing .nuxt/tsconfig.json: run `nuxt prepare` \
1179 before fallow for accurate analysis",
1180 }),
1181 "astro" if !root.join(".astro").exists() => Some(MetaFrameworkWarning {
1182 dedupe_key: "meta-prereq::astro",
1183 message: "Astro project missing .astro/ types: run `astro sync` \
1184 before fallow for accurate analysis",
1185 }),
1186 _ => None,
1187 })
1188 .collect()
1189}
1190
1191fn check_meta_framework_prerequisites(active_plugins: &[&dyn Plugin], root: &Path) {
1201 for warning in missing_meta_framework_prerequisites(active_plugins, root) {
1202 if should_warn(warning.dedupe_key.to_owned()) {
1203 tracing::warn!("{}", warning.message);
1204 }
1205 }
1206}
1207
1208fn script_activation_packages(
1209 pkg: &PackageJson,
1210 root: &Path,
1211 all_deps: &[String],
1212 production_mode: bool,
1213) -> FxHashSet<String> {
1214 let Some(pkg_scripts) = pkg.scripts.as_ref() else {
1215 return FxHashSet::default();
1216 };
1217
1218 let scripts_to_analyze = if production_mode {
1219 scripts::filter_production_scripts(pkg_scripts)
1220 } else {
1221 pkg_scripts.clone()
1222 };
1223
1224 let mut nm_roots = Vec::new();
1225 if root.join("node_modules").is_dir() {
1226 nm_roots.push(root);
1227 }
1228 let bin_map = scripts::build_bin_to_package_map(&nm_roots, all_deps);
1229 let dep_set: FxHashSet<String> = all_deps.iter().cloned().collect();
1230 let script_names: FxHashSet<String> = pkg_scripts.keys().cloned().collect();
1231
1232 scripts::analyze_scripts_with_dependency_context(
1233 &scripts_to_analyze,
1234 root,
1235 &bin_map,
1236 &dep_set,
1237 &script_names,
1238 )
1239 .used_packages
1240}
1241
1242#[cfg(test)]
1243mod tests;