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(
85 plugin_name: &str,
86 config_path: Option<&Path>,
87 rule_kind: &'static str,
88 field: &'static str,
89 rule_pattern: &str,
90 regex_pattern: &str,
91 source: ®ex::Error,
92 ) -> Self {
93 Self {
94 plugin_name: plugin_name.to_owned(),
95 config_path: config_path.map(Path::to_path_buf),
96 rule_kind,
97 field,
98 rule_pattern: rule_pattern.to_owned(),
99 regex_pattern: regex_pattern.to_owned(),
100 source: source.to_string(),
101 }
102 }
103}
104
105impl fmt::Display for PluginRegexValidationError {
106 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107 let location = self
108 .config_path
109 .as_ref()
110 .map(|path| format!(" in {}", path.display()))
111 .unwrap_or_default();
112 write!(
113 f,
114 "plugin '{}'{}: invalid regex '{}' in {}.{} for path rule '{}': {}",
115 self.plugin_name,
116 location,
117 self.regex_pattern,
118 self.rule_kind,
119 self.field,
120 self.rule_pattern,
121 self.source
122 )
123 }
124}
125
126#[must_use]
127pub fn format_plugin_regex_errors(errors: &[PluginRegexValidationError]) -> String {
128 let joined = errors
129 .iter()
130 .map(ToString::to_string)
131 .collect::<Vec<_>>()
132 .join("\n - ");
133 format!(
134 "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."
135 )
136}
137
138#[derive(Debug, Clone, Default)]
140pub struct AggregatedPluginResult {
141 pub entry_patterns: Vec<(PathRule, String)>,
143 pub entry_point_roles: rustc_hash::FxHashMap<String, EntryPointRole>,
145 pub config_patterns: Vec<String>,
147 pub always_used: Vec<(String, String)>,
149 pub used_exports: Vec<PluginUsedExportRule>,
151 pub used_class_members: Vec<UsedClassMemberRule>,
155 pub referenced_dependencies: Vec<String>,
157 pub package_referenced_dependencies: Vec<(PathBuf, String)>,
159 pub discovered_always_used: Vec<(String, String)>,
161 pub setup_files: Vec<(PathBuf, String)>,
163 pub tooling_dependencies: Vec<String>,
165 pub script_used_packages: FxHashSet<String>,
167 pub virtual_module_prefixes: Vec<String>,
170 pub virtual_package_suffixes: Vec<String>,
173 pub generated_import_patterns: Vec<String>,
176 pub generated_type_import_prefixes: Vec<String>,
179 pub path_aliases: Vec<(String, String)>,
182 pub auto_imports: Vec<AutoImportRule>,
186 pub active_plugins: Vec<String>,
188 pub fixture_patterns: Vec<(String, String)>,
190 pub scss_include_paths: Vec<PathBuf>,
195 pub static_dir_mappings: Vec<(PathBuf, String)>,
197 pub provided_dependencies: Vec<ProvidedDependencyRule>,
199}
200
201fn extend_unique(target: &mut Vec<String>, incoming: Vec<String>) {
206 let mut seen: FxHashSet<String> = target.iter().cloned().collect();
207 for item in incoming {
208 if seen.insert(item.clone()) {
209 target.push(item);
210 }
211 }
212}
213
214fn prefix_if_needed(pat: &str, ws_prefix: &str) -> String {
218 if pat.starts_with(ws_prefix) || pat.starts_with('/') {
219 pat.to_string()
220 } else {
221 format!("{ws_prefix}/{pat}")
222 }
223}
224
225impl AggregatedPluginResult {
226 pub fn apply_workspace_prefix(&mut self, ws_prefix: &str) {
240 for (rule, _) in &mut self.entry_patterns {
241 *rule = rule.prefixed(ws_prefix);
242 }
243 for (pat, _) in &mut self.always_used {
244 *pat = prefix_if_needed(pat, ws_prefix);
245 }
246 for (pat, _) in &mut self.discovered_always_used {
247 *pat = prefix_if_needed(pat, ws_prefix);
248 }
249 for (pat, _) in &mut self.fixture_patterns {
250 *pat = prefix_if_needed(pat, ws_prefix);
251 }
252 for rule in &mut self.used_exports {
253 *rule = rule.prefixed(ws_prefix);
254 }
255 for rule in &mut self.provided_dependencies {
256 *rule = rule.prefixed(ws_prefix);
257 }
258 for (_, replacement) in &mut self.path_aliases {
259 *replacement = format!("{ws_prefix}/{replacement}");
260 }
261 }
262
263 pub fn merge_into(&mut self, other: Self) {
276 let Self {
277 entry_patterns,
278 entry_point_roles,
279 config_patterns,
280 always_used,
281 used_exports,
282 used_class_members,
283 referenced_dependencies,
284 package_referenced_dependencies,
285 discovered_always_used,
286 setup_files,
287 tooling_dependencies,
288 script_used_packages,
289 virtual_module_prefixes,
290 virtual_package_suffixes,
291 generated_import_patterns,
292 generated_type_import_prefixes,
293 path_aliases,
294 auto_imports,
295 active_plugins,
296 fixture_patterns,
297 scss_include_paths,
298 static_dir_mappings,
299 provided_dependencies,
300 } = other;
301
302 self.entry_patterns.extend(entry_patterns);
303 for (plugin_name, role) in entry_point_roles {
304 self.entry_point_roles.entry(plugin_name).or_insert(role);
305 }
306 self.config_patterns.extend(config_patterns);
307 self.always_used.extend(always_used);
308 self.used_exports.extend(used_exports);
309 self.used_class_members.extend(used_class_members);
310 self.referenced_dependencies.extend(referenced_dependencies);
311 self.package_referenced_dependencies
312 .extend(package_referenced_dependencies);
313 self.discovered_always_used.extend(discovered_always_used);
314 self.setup_files.extend(setup_files);
315 self.tooling_dependencies.extend(tooling_dependencies);
316 self.script_used_packages.extend(script_used_packages);
317 extend_unique(&mut self.virtual_module_prefixes, virtual_module_prefixes);
318 extend_unique(&mut self.virtual_package_suffixes, virtual_package_suffixes);
319 extend_unique(
320 &mut self.generated_import_patterns,
321 generated_import_patterns,
322 );
323 extend_unique(
324 &mut self.generated_type_import_prefixes,
325 generated_type_import_prefixes,
326 );
327 self.path_aliases.extend(path_aliases);
328 self.auto_imports.extend(auto_imports);
329 extend_unique(&mut self.active_plugins, active_plugins);
330 self.fixture_patterns.extend(fixture_patterns);
331 self.scss_include_paths.extend(scss_include_paths);
332 self.static_dir_mappings.extend(static_dir_mappings);
333 self.provided_dependencies.extend(provided_dependencies);
334 }
335}
336
337impl PluginRegistry {
338 #[must_use]
340 pub fn new(external: Vec<ExternalPluginDef>) -> Self {
341 Self {
342 plugins: builtin::create_builtin_plugins(),
343 external_plugins: external,
344 }
345 }
346
347 #[must_use]
352 pub fn discovery_hidden_dirs(&self, pkg: &PackageJson, root: &Path) -> Vec<String> {
353 let all_deps = pkg.all_dependency_names();
354 let mut seen = FxHashSet::default();
355 let mut dirs = Vec::new();
356
357 for plugin in &self.plugins {
358 if !plugin.is_enabled_with_deps(&all_deps, root) {
359 continue;
360 }
361 for dir in plugin.discovery_hidden_dirs() {
362 if seen.insert(*dir) {
363 dirs.push((*dir).to_string());
364 }
365 }
366 }
367
368 dirs
369 }
370
371 #[cfg(test)]
376 pub fn run(
377 &self,
378 pkg: &PackageJson,
379 root: &Path,
380 discovered_files: &[PathBuf],
381 ) -> AggregatedPluginResult {
382 self.try_run(pkg, root, discovered_files)
383 .unwrap_or_else(|errors| panic!("{}", format_plugin_regex_errors(&errors)))
384 }
385
386 pub fn try_run(
388 &self,
389 pkg: &PackageJson,
390 root: &Path,
391 discovered_files: &[PathBuf],
392 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
393 self.try_run_with_search_roots(pkg, root, discovered_files, &[root], false)
394 }
395
396 pub fn try_run_with_search_roots(
399 &self,
400 pkg: &PackageJson,
401 root: &Path,
402 discovered_files: &[PathBuf],
403 config_search_roots: &[&Path],
404 production_mode: bool,
405 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
406 let _span = tracing::info_span!("run_plugins").entered();
407 let mut result = AggregatedPluginResult::default();
408 let mut regex_errors = Vec::new();
409
410 let all_deps = pkg.all_dependency_names();
411 let script_packages = script_activation_packages(pkg, root, &all_deps, production_mode);
412 let active: Vec<&dyn Plugin> = self
413 .plugins
414 .iter()
415 .filter(|p| {
416 p.is_enabled_with_files(&all_deps, root, discovered_files)
417 || p.is_enabled_with_scripts(&script_packages, root)
418 || p.is_enabled_with_package_json(pkg, root)
419 })
420 .map(AsRef::as_ref)
421 .collect();
422
423 tracing::info!(
424 plugins = active
425 .iter()
426 .map(|p| p.name())
427 .collect::<Vec<_>>()
428 .join(", "),
429 "active plugins"
430 );
431
432 check_meta_framework_prerequisites(&active, root);
433
434 self.emit_silent_fail_diagnostics(&active, &all_deps, root, discovered_files);
435
436 for plugin in &active {
437 process_static_patterns(*plugin, root, &mut result);
438 }
439 process_package_json_metadata(&active, pkg, root, &mut result, &mut regex_errors);
440
441 process_external_plugins(
442 &self.external_plugins,
443 &all_deps,
444 root,
445 discovered_files,
446 &mut result,
447 );
448
449 let config_matchers = compile_config_matchers(&active);
450
451 use rayon::prelude::*;
452 let needs_relative_files = !config_matchers.is_empty()
453 || active.iter().any(|p| p.package_json_config_key().is_some());
454 let relative_files: Vec<(PathBuf, String)> = if needs_relative_files {
455 discovered_files
456 .par_iter()
457 .map(|f| {
458 let rel = f
459 .strip_prefix(root)
460 .unwrap_or(f)
461 .to_string_lossy()
462 .into_owned();
463 (f.clone(), rel)
464 })
465 .collect()
466 } else {
467 Vec::new()
468 };
469
470 resolve_plugin_config_files(
471 &config_matchers,
472 &relative_files,
473 config_search_roots,
474 production_mode,
475 root,
476 &mut result,
477 &mut regex_errors,
478 );
479
480 process_package_json_inline_configs(
481 &active,
482 &config_matchers,
483 &relative_files,
484 root,
485 &mut result,
486 &mut regex_errors,
487 );
488
489 if regex_errors.is_empty() {
490 Ok(result)
491 } else {
492 Err(regex_errors)
493 }
494 }
495
496 #[expect(
502 clippy::too_many_arguments,
503 reason = "Each parameter is a distinct, small value with no natural grouping; \
504 bundling them into a struct hurts call-site readability."
505 )]
506 #[cfg(test)]
507 fn run_workspace_fast(
508 &self,
509 pkg: &PackageJson,
510 root: &Path,
511 project_root: &Path,
512 precompiled_config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
513 relative_files: &[(PathBuf, String)],
514 skip_config_plugins: &FxHashSet<&str>,
515 production_mode: bool,
516 ) -> AggregatedPluginResult {
517 self.try_run_workspace_fast(
518 pkg,
519 root,
520 project_root,
521 precompiled_config_matchers,
522 relative_files,
523 skip_config_plugins,
524 production_mode,
525 )
526 .unwrap_or_else(|errors| panic!("{}", format_plugin_regex_errors(&errors)))
527 }
528
529 #[expect(
535 clippy::too_many_arguments,
536 reason = "Each parameter is a distinct, small value with no natural grouping; \
537 bundling them into a struct hurts call-site readability."
538 )]
539 pub fn try_run_workspace_fast(
540 &self,
541 pkg: &PackageJson,
542 root: &Path,
543 project_root: &Path,
544 precompiled_config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
545 relative_files: &[(PathBuf, String)],
546 skip_config_plugins: &FxHashSet<&str>,
547 production_mode: bool,
548 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
549 let _span = tracing::info_span!("run_plugins").entered();
550 let mut result = AggregatedPluginResult::default();
551 let mut regex_errors = Vec::new();
552
553 let all_deps = pkg.all_dependency_names();
554 let script_packages = script_activation_packages(pkg, root, &all_deps, production_mode);
555 let workspace_files: Vec<PathBuf> = relative_files
556 .iter()
557 .map(|(abs_path, _)| abs_path.clone())
558 .collect();
559
560 let active: Vec<&dyn Plugin> = self
561 .plugins
562 .iter()
563 .filter(|p| {
564 p.is_enabled_with_files(&all_deps, root, &workspace_files)
565 || p.is_enabled_with_scripts(&script_packages, root)
566 || p.is_enabled_with_package_json(pkg, root)
567 })
568 .map(AsRef::as_ref)
569 .collect();
570
571 tracing::info!(
572 plugins = active
573 .iter()
574 .map(|p| p.name())
575 .collect::<Vec<_>>()
576 .join(", "),
577 "active plugins"
578 );
579
580 self.emit_silent_fail_diagnostics(&active, &all_deps, root, &workspace_files);
581
582 process_external_plugins(
583 &self.external_plugins,
584 &all_deps,
585 root,
586 &workspace_files,
587 &mut result,
588 );
589
590 if active.is_empty() && result.active_plugins.is_empty() {
591 return Ok(result);
592 }
593
594 for plugin in &active {
595 process_static_patterns(*plugin, root, &mut result);
596 }
597 process_package_json_metadata(&active, pkg, root, &mut result, &mut regex_errors);
598
599 let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
600 let workspace_matchers: Vec<_> = precompiled_config_matchers
601 .iter()
602 .filter(|(p, _)| {
603 active_names.contains(p.name())
604 && (!skip_config_plugins.contains(p.name())
605 || must_parse_workspace_config_when_root_active(p.name()))
606 })
607 .map(|(plugin, matchers)| (*plugin, matchers.clone()))
608 .collect();
609
610 let mut resolved_ws_plugins: FxHashSet<&str> = FxHashSet::default();
611 for (plugin, matchers) in &workspace_matchers {
612 resolve_plugin_matching_files(
613 *plugin,
614 matchers,
615 relative_files,
616 root,
617 &mut result,
618 &mut regex_errors,
619 &mut resolved_ws_plugins,
620 );
621 }
622
623 let ws_json_configs = if root == project_root {
624 discover_config_files(
625 &workspace_matchers,
626 &resolved_ws_plugins,
627 &[root],
628 production_mode,
629 )
630 } else {
631 discover_config_files(
632 &workspace_matchers,
633 &resolved_ws_plugins,
634 &[root, project_root],
635 production_mode,
636 )
637 };
638 for (abs_path, plugin) in &ws_json_configs {
639 if let Ok(source) = std::fs::read_to_string(abs_path) {
640 let plugin_result = plugin.resolve_config(abs_path, &source, root);
641 if !plugin_result.is_empty() {
642 let rel = abs_path
643 .strip_prefix(project_root)
644 .map(|p| p.to_string_lossy())
645 .unwrap_or_default();
646 tracing::debug!(
647 plugin = plugin.name(),
648 config = %rel,
649 entries = plugin_result.entry_patterns.len(),
650 deps = plugin_result.referenced_dependencies.len(),
651 "resolved config (workspace filesystem fallback)"
652 );
653 if let Err(mut errors) = process_config_result(
654 plugin.name(),
655 plugin_result,
656 &mut result,
657 Some(abs_path),
658 ) {
659 regex_errors.append(&mut errors);
660 }
661 }
662 }
663 }
664
665 if regex_errors.is_empty() {
666 Ok(result)
667 } else {
668 Err(regex_errors)
669 }
670 }
671
672 #[must_use]
675 pub fn precompile_config_matchers(&self) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
676 self.plugins
677 .iter()
678 .filter(|p| !p.config_patterns().is_empty())
679 .map(|p| {
680 let matchers: Vec<globset::GlobMatcher> = p
681 .config_patterns()
682 .iter()
683 .filter_map(|pat| {
684 let prepared = prepare_config_pattern(pat);
685 globset::Glob::new(&prepared)
686 .ok()
687 .map(|g| g.compile_matcher())
688 })
689 .collect();
690 (p.as_ref(), matchers)
691 })
692 .collect()
693 }
694}
695
696impl Default for PluginRegistry {
697 fn default() -> Self {
698 Self::new(vec![])
699 }
700}
701
702impl PluginRegistry {
703 fn emit_silent_fail_diagnostics(
712 &self,
713 active: &[&dyn Plugin],
714 all_deps: &[String],
715 root: &Path,
716 discovered_files: &[PathBuf],
717 ) {
718 let active_external: Vec<&ExternalPluginDef> = self
719 .external_plugins
720 .iter()
721 .filter(|ext| is_external_plugin_active(ext, all_deps, root, discovered_files))
722 .collect();
723 let mut diagnostics = detect_pattern_collisions(active, &active_external);
724 diagnostics.extend(detect_enabler_typos(&self.external_plugins, all_deps));
725 emit_plugin_diagnostics(&diagnostics);
726 }
727}
728
729fn plugin_warn_dedupe() -> &'static std::sync::Mutex<FxHashSet<String>> {
736 static WARNED: std::sync::OnceLock<std::sync::Mutex<FxHashSet<String>>> =
737 std::sync::OnceLock::new();
738 WARNED.get_or_init(|| std::sync::Mutex::new(FxHashSet::default()))
739}
740
741fn resolve_plugin_config_files(
742 config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
743 relative_files: &[(PathBuf, String)],
744 config_search_roots: &[&Path],
745 production_mode: bool,
746 root: &Path,
747 result: &mut AggregatedPluginResult,
748 regex_errors: &mut Vec<PluginRegexValidationError>,
749) {
750 if config_matchers.is_empty() {
751 return;
752 }
753
754 let mut resolved_plugins: FxHashSet<&str> = FxHashSet::default();
755 for (plugin, matchers) in config_matchers {
756 resolve_plugin_matching_files(
757 *plugin,
758 matchers,
759 relative_files,
760 root,
761 result,
762 regex_errors,
763 &mut resolved_plugins,
764 );
765 }
766
767 let json_configs = discover_config_files(
768 config_matchers,
769 &resolved_plugins,
770 config_search_roots,
771 production_mode,
772 );
773 for (abs_path, plugin) in &json_configs {
774 resolve_plugin_filesystem_config(*plugin, abs_path, root, result, regex_errors);
775 }
776}
777
778fn resolve_plugin_matching_files<'a>(
779 plugin: &'a dyn Plugin,
780 matchers: &[globset::GlobMatcher],
781 relative_files: &'a [(PathBuf, String)],
782 root: &Path,
783 result: &mut AggregatedPluginResult,
784 regex_errors: &mut Vec<PluginRegexValidationError>,
785 resolved_plugins: &mut FxHashSet<&'a str>,
786) {
787 use rayon::prelude::*;
788
789 let plugin_hits: Vec<&PathBuf> = relative_files
790 .par_iter()
791 .filter_map(|(abs_path, rel_path)| {
792 matchers
793 .iter()
794 .any(|m| m.is_match(rel_path.as_str()))
795 .then_some(abs_path)
796 })
797 .collect();
798 for abs_path in plugin_hits {
799 let Ok(source) = std::fs::read_to_string(abs_path) else {
800 continue;
801 };
802 let plugin_result = plugin.resolve_config(abs_path, &source, root);
803 if plugin_result.is_empty() {
804 continue;
805 }
806 resolved_plugins.insert(plugin.name());
807 process_resolved_plugin_config(
808 plugin,
809 abs_path,
810 plugin_result,
811 result,
812 regex_errors,
813 "resolved config",
814 abs_path.display(),
815 );
816 }
817}
818
819fn resolve_plugin_filesystem_config(
820 plugin: &dyn Plugin,
821 abs_path: &Path,
822 root: &Path,
823 result: &mut AggregatedPluginResult,
824 regex_errors: &mut Vec<PluginRegexValidationError>,
825) {
826 let Ok(source) = std::fs::read_to_string(abs_path) else {
827 return;
828 };
829 let plugin_result = plugin.resolve_config(abs_path, &source, root);
830 if plugin_result.is_empty() {
831 return;
832 }
833 let rel = abs_path
834 .strip_prefix(root)
835 .map(|p| p.to_string_lossy())
836 .unwrap_or_default();
837 process_resolved_plugin_config(
838 plugin,
839 abs_path,
840 plugin_result,
841 result,
842 regex_errors,
843 "resolved config (filesystem fallback)",
844 rel,
845 );
846}
847
848fn process_resolved_plugin_config(
849 plugin: &dyn Plugin,
850 abs_path: &Path,
851 plugin_result: PluginResult,
852 result: &mut AggregatedPluginResult,
853 regex_errors: &mut Vec<PluginRegexValidationError>,
854 message: &'static str,
855 config_display: impl std::fmt::Display,
856) {
857 tracing::debug!(
858 plugin = plugin.name(),
859 config = %config_display,
860 entries = plugin_result.entry_patterns.len(),
861 deps = plugin_result.referenced_dependencies.len(),
862 message
863 );
864 if let Err(mut errors) =
865 process_config_result(plugin.name(), plugin_result, result, Some(abs_path))
866 {
867 regex_errors.append(&mut errors);
868 }
869}
870
871fn should_warn(key: String) -> bool {
875 plugin_warn_dedupe()
876 .lock()
877 .map_or(true, |mut set| set.insert(key))
878}
879
880#[derive(Debug, Clone, PartialEq, Eq)]
887pub(crate) enum PluginDiagnostic {
888 PatternCollision {
890 pattern: String,
891 owners: Vec<String>,
892 },
893 EnablerTypo {
896 plugin: String,
897 enabler: String,
898 suggestion: String,
899 },
900}
901
902pub(crate) fn detect_pattern_collisions(
928 builtin_active: &[&dyn Plugin],
929 external_active: &[&ExternalPluginDef],
930) -> Vec<PluginDiagnostic> {
931 use rustc_hash::FxHashMap;
932
933 let mut pattern_owners: FxHashMap<String, (Vec<String>, FxHashSet<String>)> =
934 FxHashMap::default();
935
936 let record = |pattern_owners: &mut FxHashMap<_, (Vec<String>, FxHashSet<String>)>,
937 pattern: String,
938 name: String| {
939 let (list, seen) = pattern_owners.entry(pattern).or_default();
940 if seen.insert(name.clone()) {
941 list.push(name);
942 }
943 };
944
945 for plugin in builtin_active {
946 for pat in plugin.config_patterns() {
947 record(
948 &mut pattern_owners,
949 (*pat).to_string(),
950 plugin.name().to_string(),
951 );
952 }
953 }
954 for ext in external_active {
955 for pat in &ext.config_patterns {
956 record(&mut pattern_owners, pat.clone(), ext.name.clone());
957 }
958 }
959
960 let builtin_names: FxHashSet<&str> = builtin_active.iter().map(|p| p.name()).collect();
967
968 let mut findings: Vec<PluginDiagnostic> = pattern_owners
969 .into_iter()
970 .filter_map(|(pattern, (owners, _seen))| {
971 if owners.len() < 2 || owners.iter().all(|o| builtin_names.contains(o.as_str())) {
972 None
973 } else {
974 Some(PluginDiagnostic::PatternCollision { pattern, owners })
975 }
976 })
977 .collect();
978 findings.sort_unstable_by(|a, b| match (a, b) {
979 (
980 PluginDiagnostic::PatternCollision { pattern: ap, .. },
981 PluginDiagnostic::PatternCollision { pattern: bp, .. },
982 ) => ap.cmp(bp),
983 _ => std::cmp::Ordering::Equal,
984 });
985 findings
986}
987
988pub(crate) fn detect_enabler_typos(
1003 external_plugins: &[ExternalPluginDef],
1004 all_deps: &[String],
1005) -> Vec<PluginDiagnostic> {
1006 let mut findings = Vec::new();
1007
1008 for ext in external_plugins {
1009 if ext.detection.is_some() || ext.enablers.is_empty() {
1010 continue;
1011 }
1012
1013 let any_match = ext.enablers.iter().any(|enabler| {
1014 if enabler.ends_with('/') {
1015 all_deps.iter().any(|d| d.starts_with(enabler))
1016 } else {
1017 all_deps.iter().any(|d| d == enabler)
1018 }
1019 });
1020 if any_match {
1021 continue;
1022 }
1023
1024 for enabler in &ext.enablers {
1025 let candidates = all_deps.iter().map(String::as_str);
1026 let Some(suggestion) = fallow_config::levenshtein::closest_match(enabler, candidates)
1027 else {
1028 continue;
1029 };
1030
1031 findings.push(PluginDiagnostic::EnablerTypo {
1032 plugin: ext.name.clone(),
1033 enabler: enabler.clone(),
1034 suggestion: suggestion.to_string(),
1035 });
1036 }
1037 }
1038
1039 findings
1040}
1041
1042fn emit_plugin_diagnostics(findings: &[PluginDiagnostic]) {
1045 for finding in findings {
1046 match finding {
1047 PluginDiagnostic::PatternCollision { pattern, owners } => {
1048 let key = format!("collision::{pattern}::{owners:?}");
1049 if !should_warn(key) {
1050 continue;
1051 }
1052 let winner = &owners[0];
1053 let others = owners[1..].join(", ");
1054 tracing::warn!(
1055 "plugin config_patterns collision: identical pattern \
1056 '{pattern}' is claimed by plugins [{joined}]; '{winner}' \
1057 runs first (registration order), others ({others}) \
1058 follow. Rename one of the patterns or remove the \
1059 duplicate plugin to make resolution explicit. A future \
1060 release may reject identical-pattern collisions.",
1061 joined = owners.join(", "),
1062 );
1063 }
1064 PluginDiagnostic::EnablerTypo {
1065 plugin,
1066 enabler,
1067 suggestion,
1068 } => {
1069 let key = format!("enabler::{plugin}::{enabler}");
1070 if !should_warn(key) {
1071 continue;
1072 }
1073 tracing::warn!(
1074 "plugin '{plugin}' enabler '{enabler}' does not match any \
1075 dependency in package.json; did you mean '{suggestion}'? \
1076 The plugin will not activate. A future release may reject \
1077 unmatched enablers.",
1078 );
1079 }
1080 }
1081 }
1082}
1083
1084fn process_package_json_inline_configs(
1089 active: &[&dyn Plugin],
1090 config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
1091 relative_files: &[(PathBuf, String)],
1092 root: &Path,
1093 result: &mut AggregatedPluginResult,
1094 regex_errors: &mut Vec<PluginRegexValidationError>,
1095) {
1096 for plugin in active {
1097 let Some(key) = plugin.package_json_config_key() else {
1098 continue;
1099 };
1100 if check_has_config_file(*plugin, config_matchers, relative_files) {
1101 continue;
1102 }
1103 let pkg_path = root.join("package.json");
1104 let Ok(content) = std::fs::read_to_string(&pkg_path) else {
1105 continue;
1106 };
1107 let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
1108 continue;
1109 };
1110 let Some(config_value) = json.get(key) else {
1111 continue;
1112 };
1113 let config_json = serde_json::to_string(config_value).unwrap_or_default();
1114 let fake_path = root.join(format!("{key}.config.json"));
1115 let plugin_result = plugin.resolve_config(&fake_path, &config_json, root);
1116 if plugin_result.is_empty() {
1117 continue;
1118 }
1119 tracing::debug!(
1120 plugin = plugin.name(),
1121 key = key,
1122 "resolved inline package.json config"
1123 );
1124 if let Err(mut errors) =
1125 process_config_result(plugin.name(), plugin_result, result, Some(&pkg_path))
1126 {
1127 regex_errors.append(&mut errors);
1128 }
1129 }
1130}
1131
1132#[derive(Debug)]
1135struct MetaFrameworkWarning {
1136 dedupe_key: &'static str,
1137 message: &'static str,
1138}
1139
1140fn missing_meta_framework_prerequisites(
1150 active_plugins: &[&dyn Plugin],
1151 root: &Path,
1152) -> Vec<MetaFrameworkWarning> {
1153 active_plugins
1154 .iter()
1155 .filter_map(|plugin| match plugin.name() {
1156 "nuxt" if !root.join(".nuxt/tsconfig.json").exists() => Some(MetaFrameworkWarning {
1157 dedupe_key: "meta-prereq::nuxt",
1158 message: "Nuxt project missing .nuxt/tsconfig.json: run `nuxt prepare` \
1159 before fallow for accurate analysis",
1160 }),
1161 "astro" if !root.join(".astro").exists() => Some(MetaFrameworkWarning {
1162 dedupe_key: "meta-prereq::astro",
1163 message: "Astro project missing .astro/ types: run `astro sync` \
1164 before fallow for accurate analysis",
1165 }),
1166 _ => None,
1167 })
1168 .collect()
1169}
1170
1171fn check_meta_framework_prerequisites(active_plugins: &[&dyn Plugin], root: &Path) {
1181 for warning in missing_meta_framework_prerequisites(active_plugins, root) {
1182 if should_warn(warning.dedupe_key.to_owned()) {
1183 tracing::warn!("{}", warning.message);
1184 }
1185 }
1186}
1187
1188fn script_activation_packages(
1189 pkg: &PackageJson,
1190 root: &Path,
1191 all_deps: &[String],
1192 production_mode: bool,
1193) -> FxHashSet<String> {
1194 let Some(pkg_scripts) = pkg.scripts.as_ref() else {
1195 return FxHashSet::default();
1196 };
1197
1198 let scripts_to_analyze = if production_mode {
1199 scripts::filter_production_scripts(pkg_scripts)
1200 } else {
1201 pkg_scripts.clone()
1202 };
1203
1204 let mut nm_roots = Vec::new();
1205 if root.join("node_modules").is_dir() {
1206 nm_roots.push(root);
1207 }
1208 let bin_map = scripts::build_bin_to_package_map(&nm_roots, all_deps);
1209 let dep_set: FxHashSet<String> = all_deps.iter().cloned().collect();
1210 let script_names: FxHashSet<String> = pkg_scripts.keys().cloned().collect();
1211
1212 scripts::analyze_scripts_with_dependency_context(
1213 &scripts_to_analyze,
1214 root,
1215 &bin_map,
1216 &dep_set,
1217 &script_names,
1218 )
1219 .used_packages
1220}
1221
1222#[cfg(test)]
1223mod tests;