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