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, 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
43pub struct PluginRegistry {
45 plugins: Vec<Box<dyn Plugin>>,
46 external_plugins: Vec<ExternalPluginDef>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct PluginRegexValidationError {
52 plugin_name: String,
53 config_path: Option<PathBuf>,
54 rule_kind: &'static str,
55 field: &'static str,
56 rule_pattern: String,
57 regex_pattern: String,
58 source: String,
59}
60
61impl PluginRegexValidationError {
62 pub(crate) fn new(
63 plugin_name: &str,
64 config_path: Option<&Path>,
65 rule_kind: &'static str,
66 field: &'static str,
67 rule_pattern: &str,
68 regex_pattern: &str,
69 source: ®ex::Error,
70 ) -> Self {
71 Self {
72 plugin_name: plugin_name.to_owned(),
73 config_path: config_path.map(Path::to_path_buf),
74 rule_kind,
75 field,
76 rule_pattern: rule_pattern.to_owned(),
77 regex_pattern: regex_pattern.to_owned(),
78 source: source.to_string(),
79 }
80 }
81}
82
83impl fmt::Display for PluginRegexValidationError {
84 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85 let location = self
86 .config_path
87 .as_ref()
88 .map(|path| format!(" in {}", path.display()))
89 .unwrap_or_default();
90 write!(
91 f,
92 "plugin '{}'{}: invalid regex '{}' in {}.{} for path rule '{}': {}",
93 self.plugin_name,
94 location,
95 self.regex_pattern,
96 self.rule_kind,
97 self.field,
98 self.rule_pattern,
99 self.source
100 )
101 }
102}
103
104#[must_use]
105pub fn format_plugin_regex_errors(errors: &[PluginRegexValidationError]) -> String {
106 let joined = errors
107 .iter()
108 .map(ToString::to_string)
109 .collect::<Vec<_>>()
110 .join("\n - ");
111 format!(
112 "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."
113 )
114}
115
116#[derive(Debug, Clone, Default)]
118pub struct AggregatedPluginResult {
119 pub entry_patterns: Vec<(PathRule, String)>,
121 pub entry_point_roles: rustc_hash::FxHashMap<String, EntryPointRole>,
123 pub config_patterns: Vec<String>,
125 pub always_used: Vec<(String, String)>,
127 pub used_exports: Vec<PluginUsedExportRule>,
129 pub used_class_members: Vec<UsedClassMemberRule>,
133 pub referenced_dependencies: Vec<String>,
135 pub package_referenced_dependencies: Vec<(PathBuf, String)>,
137 pub discovered_always_used: Vec<(String, String)>,
139 pub setup_files: Vec<(PathBuf, String)>,
141 pub tooling_dependencies: Vec<String>,
143 pub script_used_packages: FxHashSet<String>,
145 pub virtual_module_prefixes: Vec<String>,
148 pub virtual_package_suffixes: Vec<String>,
151 pub generated_import_patterns: Vec<String>,
154 pub generated_type_import_prefixes: Vec<String>,
157 pub path_aliases: Vec<(String, String)>,
160 pub auto_imports: Vec<AutoImportRule>,
164 pub active_plugins: Vec<String>,
166 pub fixture_patterns: Vec<(String, String)>,
168 pub scss_include_paths: Vec<PathBuf>,
173 pub static_dir_mappings: Vec<(PathBuf, String)>,
175 pub provided_dependencies: Vec<ProvidedDependencyRule>,
177}
178
179fn extend_unique(target: &mut Vec<String>, incoming: Vec<String>) {
184 let mut seen: FxHashSet<String> = target.iter().cloned().collect();
185 for item in incoming {
186 if seen.insert(item.clone()) {
187 target.push(item);
188 }
189 }
190}
191
192fn prefix_if_needed(pat: &str, ws_prefix: &str) -> String {
196 if pat.starts_with(ws_prefix) || pat.starts_with('/') {
197 pat.to_string()
198 } else {
199 format!("{ws_prefix}/{pat}")
200 }
201}
202
203impl AggregatedPluginResult {
204 pub fn apply_workspace_prefix(&mut self, ws_prefix: &str) {
218 for (rule, _) in &mut self.entry_patterns {
219 *rule = rule.prefixed(ws_prefix);
220 }
221 for (pat, _) in &mut self.always_used {
222 *pat = prefix_if_needed(pat, ws_prefix);
223 }
224 for (pat, _) in &mut self.discovered_always_used {
225 *pat = prefix_if_needed(pat, ws_prefix);
226 }
227 for (pat, _) in &mut self.fixture_patterns {
228 *pat = prefix_if_needed(pat, ws_prefix);
229 }
230 for rule in &mut self.used_exports {
231 *rule = rule.prefixed(ws_prefix);
232 }
233 for rule in &mut self.provided_dependencies {
234 *rule = rule.prefixed(ws_prefix);
235 }
236 for (_, replacement) in &mut self.path_aliases {
237 *replacement = format!("{ws_prefix}/{replacement}");
238 }
239 }
240
241 pub fn merge_into(&mut self, other: Self) {
254 let Self {
255 entry_patterns,
256 entry_point_roles,
257 config_patterns,
258 always_used,
259 used_exports,
260 used_class_members,
261 referenced_dependencies,
262 package_referenced_dependencies,
263 discovered_always_used,
264 setup_files,
265 tooling_dependencies,
266 script_used_packages,
267 virtual_module_prefixes,
268 virtual_package_suffixes,
269 generated_import_patterns,
270 generated_type_import_prefixes,
271 path_aliases,
272 auto_imports,
273 active_plugins,
274 fixture_patterns,
275 scss_include_paths,
276 static_dir_mappings,
277 provided_dependencies,
278 } = other;
279
280 self.entry_patterns.extend(entry_patterns);
281 for (plugin_name, role) in entry_point_roles {
282 self.entry_point_roles.entry(plugin_name).or_insert(role);
283 }
284 self.config_patterns.extend(config_patterns);
285 self.always_used.extend(always_used);
286 self.used_exports.extend(used_exports);
287 self.used_class_members.extend(used_class_members);
288 self.referenced_dependencies.extend(referenced_dependencies);
289 self.package_referenced_dependencies
290 .extend(package_referenced_dependencies);
291 self.discovered_always_used.extend(discovered_always_used);
292 self.setup_files.extend(setup_files);
293 self.tooling_dependencies.extend(tooling_dependencies);
294 self.script_used_packages.extend(script_used_packages);
295 extend_unique(&mut self.virtual_module_prefixes, virtual_module_prefixes);
296 extend_unique(&mut self.virtual_package_suffixes, virtual_package_suffixes);
297 extend_unique(
298 &mut self.generated_import_patterns,
299 generated_import_patterns,
300 );
301 extend_unique(
302 &mut self.generated_type_import_prefixes,
303 generated_type_import_prefixes,
304 );
305 self.path_aliases.extend(path_aliases);
306 self.auto_imports.extend(auto_imports);
307 extend_unique(&mut self.active_plugins, active_plugins);
308 self.fixture_patterns.extend(fixture_patterns);
309 self.scss_include_paths.extend(scss_include_paths);
310 self.static_dir_mappings.extend(static_dir_mappings);
311 self.provided_dependencies.extend(provided_dependencies);
312 }
313}
314
315impl PluginRegistry {
316 #[must_use]
318 pub fn new(external: Vec<ExternalPluginDef>) -> Self {
319 Self {
320 plugins: builtin::create_builtin_plugins(),
321 external_plugins: external,
322 }
323 }
324
325 #[must_use]
330 pub fn discovery_hidden_dirs(&self, pkg: &PackageJson, root: &Path) -> Vec<String> {
331 let all_deps = pkg.all_dependency_names();
332 let mut seen = FxHashSet::default();
333 let mut dirs = Vec::new();
334
335 for plugin in &self.plugins {
336 if !plugin.is_enabled_with_deps(&all_deps, root) {
337 continue;
338 }
339 for dir in plugin.discovery_hidden_dirs() {
340 if seen.insert(*dir) {
341 dirs.push((*dir).to_string());
342 }
343 }
344 }
345
346 dirs
347 }
348
349 #[cfg(test)]
354 pub fn run(
355 &self,
356 pkg: &PackageJson,
357 root: &Path,
358 discovered_files: &[PathBuf],
359 ) -> AggregatedPluginResult {
360 self.try_run(pkg, root, discovered_files)
361 .unwrap_or_else(|errors| panic!("{}", format_plugin_regex_errors(&errors)))
362 }
363
364 pub fn try_run(
366 &self,
367 pkg: &PackageJson,
368 root: &Path,
369 discovered_files: &[PathBuf],
370 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
371 self.try_run_with_search_roots(pkg, root, discovered_files, &[root], false)
372 }
373
374 #[expect(
377 clippy::too_many_lines,
378 reason = "Plugin discovery phases stay together to preserve the existing registry flow."
379 )]
380 pub fn try_run_with_search_roots(
381 &self,
382 pkg: &PackageJson,
383 root: &Path,
384 discovered_files: &[PathBuf],
385 config_search_roots: &[&Path],
386 production_mode: bool,
387 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
388 let _span = tracing::info_span!("run_plugins").entered();
389 let mut result = AggregatedPluginResult::default();
390 let mut regex_errors = Vec::new();
391
392 let all_deps = pkg.all_dependency_names();
393 let script_packages = script_activation_packages(pkg, root, &all_deps, production_mode);
394 let active: Vec<&dyn Plugin> = self
395 .plugins
396 .iter()
397 .filter(|p| {
398 p.is_enabled_with_files(&all_deps, root, discovered_files)
399 || p.is_enabled_with_scripts(&script_packages, root)
400 || p.is_enabled_with_package_json(pkg, root)
401 })
402 .map(AsRef::as_ref)
403 .collect();
404
405 tracing::info!(
406 plugins = active
407 .iter()
408 .map(|p| p.name())
409 .collect::<Vec<_>>()
410 .join(", "),
411 "active plugins"
412 );
413
414 check_meta_framework_prerequisites(&active, root);
415
416 self.emit_silent_fail_diagnostics(&active, &all_deps, root, discovered_files);
417
418 for plugin in &active {
419 process_static_patterns(*plugin, root, &mut result);
420 }
421 process_package_json_metadata(&active, pkg, root, &mut result, &mut regex_errors);
422
423 process_external_plugins(
424 &self.external_plugins,
425 &all_deps,
426 root,
427 discovered_files,
428 &mut result,
429 );
430
431 let config_matchers: Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> = active
432 .iter()
433 .filter(|p| !p.config_patterns().is_empty())
434 .map(|p| {
435 let matchers: Vec<globset::GlobMatcher> = p
436 .config_patterns()
437 .iter()
438 .filter_map(|pat| {
439 let prepared = prepare_config_pattern(pat);
440 globset::Glob::new(&prepared)
441 .ok()
442 .map(|g| g.compile_matcher())
443 })
444 .collect();
445 (*p, matchers)
446 })
447 .collect();
448
449 use rayon::prelude::*;
450 let needs_relative_files = !config_matchers.is_empty()
451 || active.iter().any(|p| p.package_json_config_key().is_some());
452 let relative_files: Vec<(PathBuf, String)> = if needs_relative_files {
453 discovered_files
454 .par_iter()
455 .map(|f| {
456 let rel = f
457 .strip_prefix(root)
458 .unwrap_or(f)
459 .to_string_lossy()
460 .into_owned();
461 (f.clone(), rel)
462 })
463 .collect()
464 } else {
465 Vec::new()
466 };
467
468 if !config_matchers.is_empty() {
469 let mut resolved_plugins: FxHashSet<&str> = FxHashSet::default();
470
471 for (plugin, matchers) in &config_matchers {
472 let plugin_hits: Vec<&PathBuf> = relative_files
473 .par_iter()
474 .filter_map(|(abs_path, rel_path)| {
475 matchers
476 .iter()
477 .any(|m| m.is_match(rel_path.as_str()))
478 .then_some(abs_path)
479 })
480 .collect();
481 for abs_path in plugin_hits {
482 let Ok(source) = std::fs::read_to_string(abs_path) else {
483 continue;
484 };
485 let plugin_result = plugin.resolve_config(abs_path, &source, root);
486 if plugin_result.is_empty() {
487 continue;
488 }
489 resolved_plugins.insert(plugin.name());
490 tracing::debug!(
491 plugin = plugin.name(),
492 config = %abs_path.display(),
493 entries = plugin_result.entry_patterns.len(),
494 deps = plugin_result.referenced_dependencies.len(),
495 "resolved config"
496 );
497 if let Err(mut errors) = process_config_result(
498 plugin.name(),
499 plugin_result,
500 &mut result,
501 Some(abs_path),
502 ) {
503 regex_errors.append(&mut errors);
504 }
505 }
506 }
507
508 let json_configs = discover_config_files(
509 &config_matchers,
510 &resolved_plugins,
511 config_search_roots,
512 production_mode,
513 );
514 for (abs_path, plugin) in &json_configs {
515 if let Ok(source) = std::fs::read_to_string(abs_path) {
516 let plugin_result = plugin.resolve_config(abs_path, &source, root);
517 if !plugin_result.is_empty() {
518 let rel = abs_path
519 .strip_prefix(root)
520 .map(|p| p.to_string_lossy())
521 .unwrap_or_default();
522 tracing::debug!(
523 plugin = plugin.name(),
524 config = %rel,
525 entries = plugin_result.entry_patterns.len(),
526 deps = plugin_result.referenced_dependencies.len(),
527 "resolved config (filesystem fallback)"
528 );
529 if let Err(mut errors) = process_config_result(
530 plugin.name(),
531 plugin_result,
532 &mut result,
533 Some(abs_path),
534 ) {
535 regex_errors.append(&mut errors);
536 }
537 }
538 }
539 }
540 }
541
542 process_package_json_inline_configs(
543 &active,
544 &config_matchers,
545 &relative_files,
546 root,
547 &mut result,
548 &mut regex_errors,
549 );
550
551 if regex_errors.is_empty() {
552 Ok(result)
553 } else {
554 Err(regex_errors)
555 }
556 }
557
558 #[expect(
564 clippy::too_many_arguments,
565 reason = "Each parameter is a distinct, small value with no natural grouping; \
566 bundling them into a struct hurts call-site readability."
567 )]
568 #[cfg(test)]
569 fn run_workspace_fast(
570 &self,
571 pkg: &PackageJson,
572 root: &Path,
573 project_root: &Path,
574 precompiled_config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
575 relative_files: &[(PathBuf, String)],
576 skip_config_plugins: &FxHashSet<&str>,
577 production_mode: bool,
578 ) -> AggregatedPluginResult {
579 self.try_run_workspace_fast(
580 pkg,
581 root,
582 project_root,
583 precompiled_config_matchers,
584 relative_files,
585 skip_config_plugins,
586 production_mode,
587 )
588 .unwrap_or_else(|errors| panic!("{}", format_plugin_regex_errors(&errors)))
589 }
590
591 #[expect(
597 clippy::too_many_arguments,
598 reason = "Each parameter is a distinct, small value with no natural grouping; \
599 bundling them into a struct hurts call-site readability."
600 )]
601 pub fn try_run_workspace_fast(
602 &self,
603 pkg: &PackageJson,
604 root: &Path,
605 project_root: &Path,
606 precompiled_config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
607 relative_files: &[(PathBuf, String)],
608 skip_config_plugins: &FxHashSet<&str>,
609 production_mode: bool,
610 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
611 let _span = tracing::info_span!("run_plugins").entered();
612 let mut result = AggregatedPluginResult::default();
613 let mut regex_errors = Vec::new();
614
615 let all_deps = pkg.all_dependency_names();
616 let script_packages = script_activation_packages(pkg, root, &all_deps, production_mode);
617 let workspace_files: Vec<PathBuf> = relative_files
618 .iter()
619 .map(|(abs_path, _)| abs_path.clone())
620 .collect();
621
622 let active: Vec<&dyn Plugin> = self
623 .plugins
624 .iter()
625 .filter(|p| {
626 p.is_enabled_with_files(&all_deps, root, &workspace_files)
627 || p.is_enabled_with_scripts(&script_packages, root)
628 || p.is_enabled_with_package_json(pkg, root)
629 })
630 .map(AsRef::as_ref)
631 .collect();
632
633 tracing::info!(
634 plugins = active
635 .iter()
636 .map(|p| p.name())
637 .collect::<Vec<_>>()
638 .join(", "),
639 "active plugins"
640 );
641
642 self.emit_silent_fail_diagnostics(&active, &all_deps, root, &workspace_files);
643
644 process_external_plugins(
645 &self.external_plugins,
646 &all_deps,
647 root,
648 &workspace_files,
649 &mut result,
650 );
651
652 if active.is_empty() && result.active_plugins.is_empty() {
653 return Ok(result);
654 }
655
656 for plugin in &active {
657 process_static_patterns(*plugin, root, &mut result);
658 }
659 process_package_json_metadata(&active, pkg, root, &mut result, &mut regex_errors);
660
661 let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
662 let workspace_matchers: Vec<_> = precompiled_config_matchers
663 .iter()
664 .filter(|(p, _)| {
665 active_names.contains(p.name())
666 && (!skip_config_plugins.contains(p.name())
667 || must_parse_workspace_config_when_root_active(p.name()))
668 })
669 .map(|(plugin, matchers)| (*plugin, matchers.clone()))
670 .collect();
671
672 let mut resolved_ws_plugins: FxHashSet<&str> = FxHashSet::default();
673 if !workspace_matchers.is_empty() {
674 use rayon::prelude::*;
675 for (plugin, matchers) in &workspace_matchers {
676 let plugin_hits: Vec<&PathBuf> = relative_files
677 .par_iter()
678 .filter_map(|(abs_path, rel_path)| {
679 matchers
680 .iter()
681 .any(|m| m.is_match(rel_path.as_str()))
682 .then_some(abs_path)
683 })
684 .collect();
685 for abs_path in plugin_hits {
686 let Ok(source) = std::fs::read_to_string(abs_path) else {
687 continue;
688 };
689 let plugin_result = plugin.resolve_config(abs_path, &source, root);
690 if plugin_result.is_empty() {
691 continue;
692 }
693 resolved_ws_plugins.insert(plugin.name());
694 tracing::debug!(
695 plugin = plugin.name(),
696 config = %abs_path.display(),
697 entries = plugin_result.entry_patterns.len(),
698 deps = plugin_result.referenced_dependencies.len(),
699 "resolved config"
700 );
701 if let Err(mut errors) = process_config_result(
702 plugin.name(),
703 plugin_result,
704 &mut result,
705 Some(abs_path),
706 ) {
707 regex_errors.append(&mut errors);
708 }
709 }
710 }
711 }
712
713 let ws_json_configs = if root == project_root {
714 discover_config_files(
715 &workspace_matchers,
716 &resolved_ws_plugins,
717 &[root],
718 production_mode,
719 )
720 } else {
721 discover_config_files(
722 &workspace_matchers,
723 &resolved_ws_plugins,
724 &[root, project_root],
725 production_mode,
726 )
727 };
728 for (abs_path, plugin) in &ws_json_configs {
729 if let Ok(source) = std::fs::read_to_string(abs_path) {
730 let plugin_result = plugin.resolve_config(abs_path, &source, root);
731 if !plugin_result.is_empty() {
732 let rel = abs_path
733 .strip_prefix(project_root)
734 .map(|p| p.to_string_lossy())
735 .unwrap_or_default();
736 tracing::debug!(
737 plugin = plugin.name(),
738 config = %rel,
739 entries = plugin_result.entry_patterns.len(),
740 deps = plugin_result.referenced_dependencies.len(),
741 "resolved config (workspace filesystem fallback)"
742 );
743 if let Err(mut errors) = process_config_result(
744 plugin.name(),
745 plugin_result,
746 &mut result,
747 Some(abs_path),
748 ) {
749 regex_errors.append(&mut errors);
750 }
751 }
752 }
753 }
754
755 if regex_errors.is_empty() {
756 Ok(result)
757 } else {
758 Err(regex_errors)
759 }
760 }
761
762 #[must_use]
765 pub fn precompile_config_matchers(&self) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
766 self.plugins
767 .iter()
768 .filter(|p| !p.config_patterns().is_empty())
769 .map(|p| {
770 let matchers: Vec<globset::GlobMatcher> = p
771 .config_patterns()
772 .iter()
773 .filter_map(|pat| {
774 let prepared = prepare_config_pattern(pat);
775 globset::Glob::new(&prepared)
776 .ok()
777 .map(|g| g.compile_matcher())
778 })
779 .collect();
780 (p.as_ref(), matchers)
781 })
782 .collect()
783 }
784}
785
786impl Default for PluginRegistry {
787 fn default() -> Self {
788 Self::new(vec![])
789 }
790}
791
792impl PluginRegistry {
793 fn emit_silent_fail_diagnostics(
802 &self,
803 active: &[&dyn Plugin],
804 all_deps: &[String],
805 root: &Path,
806 discovered_files: &[PathBuf],
807 ) {
808 let active_external: Vec<&ExternalPluginDef> = self
809 .external_plugins
810 .iter()
811 .filter(|ext| is_external_plugin_active(ext, all_deps, root, discovered_files))
812 .collect();
813 let mut diagnostics = detect_pattern_collisions(active, &active_external);
814 diagnostics.extend(detect_enabler_typos(&self.external_plugins, all_deps));
815 emit_plugin_diagnostics(&diagnostics);
816 }
817}
818
819fn plugin_warn_dedupe() -> &'static std::sync::Mutex<FxHashSet<String>> {
826 static WARNED: std::sync::OnceLock<std::sync::Mutex<FxHashSet<String>>> =
827 std::sync::OnceLock::new();
828 WARNED.get_or_init(|| std::sync::Mutex::new(FxHashSet::default()))
829}
830
831fn should_warn(key: String) -> bool {
835 plugin_warn_dedupe()
836 .lock()
837 .map_or(true, |mut set| set.insert(key))
838}
839
840#[derive(Debug, Clone, PartialEq, Eq)]
847pub(crate) enum PluginDiagnostic {
848 PatternCollision {
850 pattern: String,
851 owners: Vec<String>,
852 },
853 EnablerTypo {
856 plugin: String,
857 enabler: String,
858 suggestion: String,
859 },
860}
861
862pub(crate) fn detect_pattern_collisions(
888 builtin_active: &[&dyn Plugin],
889 external_active: &[&ExternalPluginDef],
890) -> Vec<PluginDiagnostic> {
891 use rustc_hash::FxHashMap;
892
893 let mut pattern_owners: FxHashMap<String, (Vec<String>, FxHashSet<String>)> =
894 FxHashMap::default();
895
896 let record = |pattern_owners: &mut FxHashMap<_, (Vec<String>, FxHashSet<String>)>,
897 pattern: String,
898 name: String| {
899 let (list, seen) = pattern_owners.entry(pattern).or_default();
900 if seen.insert(name.clone()) {
901 list.push(name);
902 }
903 };
904
905 for plugin in builtin_active {
906 for pat in plugin.config_patterns() {
907 record(
908 &mut pattern_owners,
909 (*pat).to_string(),
910 plugin.name().to_string(),
911 );
912 }
913 }
914 for ext in external_active {
915 for pat in &ext.config_patterns {
916 record(&mut pattern_owners, pat.clone(), ext.name.clone());
917 }
918 }
919
920 let builtin_names: FxHashSet<&str> = builtin_active.iter().map(|p| p.name()).collect();
927
928 let mut findings: Vec<PluginDiagnostic> = pattern_owners
929 .into_iter()
930 .filter_map(|(pattern, (owners, _seen))| {
931 if owners.len() < 2 || owners.iter().all(|o| builtin_names.contains(o.as_str())) {
932 None
933 } else {
934 Some(PluginDiagnostic::PatternCollision { pattern, owners })
935 }
936 })
937 .collect();
938 findings.sort_unstable_by(|a, b| match (a, b) {
939 (
940 PluginDiagnostic::PatternCollision { pattern: ap, .. },
941 PluginDiagnostic::PatternCollision { pattern: bp, .. },
942 ) => ap.cmp(bp),
943 _ => std::cmp::Ordering::Equal,
944 });
945 findings
946}
947
948pub(crate) fn detect_enabler_typos(
963 external_plugins: &[ExternalPluginDef],
964 all_deps: &[String],
965) -> Vec<PluginDiagnostic> {
966 let mut findings = Vec::new();
967
968 for ext in external_plugins {
969 if ext.detection.is_some() || ext.enablers.is_empty() {
970 continue;
971 }
972
973 let any_match = ext.enablers.iter().any(|enabler| {
974 if enabler.ends_with('/') {
975 all_deps.iter().any(|d| d.starts_with(enabler))
976 } else {
977 all_deps.iter().any(|d| d == enabler)
978 }
979 });
980 if any_match {
981 continue;
982 }
983
984 for enabler in &ext.enablers {
985 let candidates = all_deps.iter().map(String::as_str);
986 let Some(suggestion) = fallow_config::levenshtein::closest_match(enabler, candidates)
987 else {
988 continue;
989 };
990
991 findings.push(PluginDiagnostic::EnablerTypo {
992 plugin: ext.name.clone(),
993 enabler: enabler.clone(),
994 suggestion: suggestion.to_string(),
995 });
996 }
997 }
998
999 findings
1000}
1001
1002fn emit_plugin_diagnostics(findings: &[PluginDiagnostic]) {
1005 for finding in findings {
1006 match finding {
1007 PluginDiagnostic::PatternCollision { pattern, owners } => {
1008 let key = format!("collision::{pattern}::{owners:?}");
1009 if !should_warn(key) {
1010 continue;
1011 }
1012 let winner = &owners[0];
1013 let others = owners[1..].join(", ");
1014 tracing::warn!(
1015 "plugin config_patterns collision: identical pattern \
1016 '{pattern}' is claimed by plugins [{joined}]; '{winner}' \
1017 runs first (registration order), others ({others}) \
1018 follow. Rename one of the patterns or remove the \
1019 duplicate plugin to make resolution explicit. A future \
1020 release may reject identical-pattern collisions.",
1021 joined = owners.join(", "),
1022 );
1023 }
1024 PluginDiagnostic::EnablerTypo {
1025 plugin,
1026 enabler,
1027 suggestion,
1028 } => {
1029 let key = format!("enabler::{plugin}::{enabler}");
1030 if !should_warn(key) {
1031 continue;
1032 }
1033 tracing::warn!(
1034 "plugin '{plugin}' enabler '{enabler}' does not match any \
1035 dependency in package.json; did you mean '{suggestion}'? \
1036 The plugin will not activate. A future release may reject \
1037 unmatched enablers.",
1038 );
1039 }
1040 }
1041 }
1042}
1043
1044fn process_package_json_inline_configs(
1049 active: &[&dyn Plugin],
1050 config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
1051 relative_files: &[(PathBuf, String)],
1052 root: &Path,
1053 result: &mut AggregatedPluginResult,
1054 regex_errors: &mut Vec<PluginRegexValidationError>,
1055) {
1056 for plugin in active {
1057 let Some(key) = plugin.package_json_config_key() else {
1058 continue;
1059 };
1060 if check_has_config_file(*plugin, config_matchers, relative_files) {
1061 continue;
1062 }
1063 let pkg_path = root.join("package.json");
1064 let Ok(content) = std::fs::read_to_string(&pkg_path) else {
1065 continue;
1066 };
1067 let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
1068 continue;
1069 };
1070 let Some(config_value) = json.get(key) else {
1071 continue;
1072 };
1073 let config_json = serde_json::to_string(config_value).unwrap_or_default();
1074 let fake_path = root.join(format!("{key}.config.json"));
1075 let plugin_result = plugin.resolve_config(&fake_path, &config_json, root);
1076 if plugin_result.is_empty() {
1077 continue;
1078 }
1079 tracing::debug!(
1080 plugin = plugin.name(),
1081 key = key,
1082 "resolved inline package.json config"
1083 );
1084 if let Err(mut errors) =
1085 process_config_result(plugin.name(), plugin_result, result, Some(&pkg_path))
1086 {
1087 regex_errors.append(&mut errors);
1088 }
1089 }
1090}
1091
1092#[derive(Debug)]
1095struct MetaFrameworkWarning {
1096 dedupe_key: &'static str,
1097 message: &'static str,
1098}
1099
1100fn missing_meta_framework_prerequisites(
1110 active_plugins: &[&dyn Plugin],
1111 root: &Path,
1112) -> Vec<MetaFrameworkWarning> {
1113 active_plugins
1114 .iter()
1115 .filter_map(|plugin| match plugin.name() {
1116 "nuxt" if !root.join(".nuxt/tsconfig.json").exists() => Some(MetaFrameworkWarning {
1117 dedupe_key: "meta-prereq::nuxt",
1118 message: "Nuxt project missing .nuxt/tsconfig.json: run `nuxt prepare` \
1119 before fallow for accurate analysis",
1120 }),
1121 "astro" if !root.join(".astro").exists() => Some(MetaFrameworkWarning {
1122 dedupe_key: "meta-prereq::astro",
1123 message: "Astro project missing .astro/ types: run `astro sync` \
1124 before fallow for accurate analysis",
1125 }),
1126 _ => None,
1127 })
1128 .collect()
1129}
1130
1131fn check_meta_framework_prerequisites(active_plugins: &[&dyn Plugin], root: &Path) {
1141 for warning in missing_meta_framework_prerequisites(active_plugins, root) {
1142 if should_warn(warning.dedupe_key.to_owned()) {
1143 tracing::warn!("{}", warning.message);
1144 }
1145 }
1146}
1147
1148fn script_activation_packages(
1149 pkg: &PackageJson,
1150 root: &Path,
1151 all_deps: &[String],
1152 production_mode: bool,
1153) -> FxHashSet<String> {
1154 let Some(pkg_scripts) = pkg.scripts.as_ref() else {
1155 return FxHashSet::default();
1156 };
1157
1158 let scripts_to_analyze = if production_mode {
1159 scripts::filter_production_scripts(pkg_scripts)
1160 } else {
1161 pkg_scripts.clone()
1162 };
1163
1164 let mut nm_roots = Vec::new();
1165 if root.join("node_modules").is_dir() {
1166 nm_roots.push(root);
1167 }
1168 let bin_map = scripts::build_bin_to_package_map(&nm_roots, all_deps);
1169 let dep_set: FxHashSet<String> = all_deps.iter().cloned().collect();
1170 let script_names: FxHashSet<String> = pkg_scripts.keys().cloned().collect();
1171
1172 scripts::analyze_scripts_with_dependency_context(
1173 &scripts_to_analyze,
1174 root,
1175 &bin_map,
1176 &dep_set,
1177 &script_names,
1178 )
1179 .used_packages
1180}
1181
1182#[cfg(test)]
1183mod tests;