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
30pub use helpers::ConfigCandidateIndex;
31use helpers::{
32 check_has_config_file, discover_config_files, is_external_plugin_active,
33 prepare_config_pattern, process_config_result, process_external_plugins,
34 process_package_json_metadata, process_static_patterns,
35};
36
37fn must_parse_workspace_config_when_root_active(plugin_name: &str) -> bool {
38 matches!(
39 plugin_name,
40 "eslint" | "docusaurus" | "jest" | "tanstack-router" | "vitest"
41 )
42}
43
44fn compile_config_matchers<'a>(
45 active: &[&'a dyn Plugin],
46) -> Vec<(&'a dyn Plugin, Vec<globset::GlobMatcher>)> {
47 active
48 .iter()
49 .filter(|plugin| !plugin.config_patterns().is_empty())
50 .map(|plugin| {
51 let matchers = plugin
52 .config_patterns()
53 .iter()
54 .filter_map(|pattern| {
55 let prepared = prepare_config_pattern(pattern);
56 globset::Glob::new(&prepared)
57 .ok()
58 .map(|glob| glob.compile_matcher())
59 })
60 .collect();
61 (*plugin, matchers)
62 })
63 .collect()
64}
65
66fn log_active_plugins(active: &[&dyn Plugin]) {
68 tracing::info!(
69 plugins = active
70 .iter()
71 .map(|p| p.name())
72 .collect::<Vec<_>>()
73 .join(", "),
74 "active plugins"
75 );
76}
77
78fn compute_relative_files(
82 config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
83 active: &[&dyn Plugin],
84 discovered_files: &[PathBuf],
85 root: &Path,
86) -> Vec<(PathBuf, String)> {
87 use rayon::prelude::*;
88 let needs_relative_files =
89 !config_matchers.is_empty() || active.iter().any(|p| p.package_json_config_key().is_some());
90 if !needs_relative_files {
91 return Vec::new();
92 }
93 discovered_files
94 .par_iter()
95 .map(|f| {
96 let rel = f
97 .strip_prefix(root)
98 .unwrap_or(f)
99 .to_string_lossy()
100 .into_owned();
101 (f.clone(), rel)
102 })
103 .collect()
104}
105
106pub struct PluginRegistry {
108 plugins: Vec<Box<dyn Plugin>>,
109 external_plugins: Vec<ExternalPluginDef>,
110}
111
112pub struct WorkspacePluginRunInput<'a> {
114 pub pkg: &'a PackageJson,
115 pub root: &'a Path,
116 pub project_root: &'a Path,
117 pub precompiled_config_matchers: &'a [(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
118 pub relative_files: &'a [(PathBuf, String)],
119 pub skip_config_plugins: &'a FxHashSet<&'a str>,
120 pub production_mode: bool,
121 pub candidate_index: Option<&'a ConfigCandidateIndex>,
122}
123
124struct PluginRunContext<'a> {
125 all_deps: Vec<String>,
126 active: Vec<&'a dyn Plugin>,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct PluginRegexValidationError {
132 plugin_name: String,
133 config_path: Option<PathBuf>,
134 rule_kind: &'static str,
135 field: &'static str,
136 rule_pattern: String,
137 regex_pattern: String,
138 source: String,
139}
140
141impl PluginRegexValidationError {
142 pub(crate) fn new(input: PluginRegexValidationErrorInput<'_>) -> Self {
143 Self {
144 plugin_name: input.plugin_name.to_owned(),
145 config_path: input.config_path.map(Path::to_path_buf),
146 rule_kind: input.rule_kind,
147 field: input.field,
148 rule_pattern: input.rule_pattern.to_owned(),
149 regex_pattern: input.regex_pattern.to_owned(),
150 source: input.source.to_string(),
151 }
152 }
153}
154
155#[derive(Clone, Copy)]
156pub(crate) struct PluginRegexValidationErrorInput<'a> {
157 pub(crate) plugin_name: &'a str,
158 pub(crate) config_path: Option<&'a Path>,
159 pub(crate) rule_kind: &'static str,
160 pub(crate) field: &'static str,
161 pub(crate) rule_pattern: &'a str,
162 pub(crate) regex_pattern: &'a str,
163 pub(crate) source: &'a regex::Error,
164}
165
166impl fmt::Display for PluginRegexValidationError {
167 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
168 let location = self
169 .config_path
170 .as_ref()
171 .map(|path| format!(" in {}", path.display()))
172 .unwrap_or_default();
173 write!(
174 f,
175 "plugin '{}'{}: invalid regex '{}' in {}.{} for path rule '{}': {}",
176 self.plugin_name,
177 location,
178 self.regex_pattern,
179 self.rule_kind,
180 self.field,
181 self.rule_pattern,
182 self.source
183 )
184 }
185}
186
187#[must_use]
188pub fn format_plugin_regex_errors(errors: &[PluginRegexValidationError]) -> String {
189 let joined = errors
190 .iter()
191 .map(ToString::to_string)
192 .collect::<Vec<_>>()
193 .join("\n - ");
194 format!(
195 "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."
196 )
197}
198
199#[derive(Debug, Clone, Default)]
201pub struct AggregatedPluginResult {
202 pub entry_patterns: Vec<(PathRule, String)>,
204 pub entry_point_roles: rustc_hash::FxHashMap<String, EntryPointRole>,
206 pub config_patterns: Vec<String>,
208 pub always_used: Vec<(String, String)>,
210 pub used_exports: Vec<PluginUsedExportRule>,
212 pub used_class_members: Vec<UsedClassMemberRule>,
216 pub referenced_dependencies: Vec<String>,
218 pub package_referenced_dependencies: Vec<(PathBuf, String)>,
220 pub discovered_always_used: Vec<(String, String)>,
222 pub setup_files: Vec<(PathBuf, String)>,
224 pub tooling_dependencies: Vec<String>,
226 pub script_used_packages: FxHashSet<String>,
228 pub virtual_module_prefixes: Vec<String>,
231 pub virtual_package_suffixes: Vec<String>,
234 pub generated_import_patterns: Vec<String>,
237 pub generated_type_import_prefixes: Vec<String>,
240 pub path_aliases: Vec<(String, String)>,
243 pub auto_imports: Vec<AutoImportRule>,
247 pub active_plugins: Vec<String>,
249 pub fixture_patterns: Vec<(String, String)>,
251 pub scss_include_paths: Vec<PathBuf>,
256 pub static_dir_mappings: Vec<(PathBuf, String)>,
258 pub provided_dependencies: Vec<ProvidedDependencyRule>,
260}
261
262fn extend_unique(target: &mut Vec<String>, incoming: Vec<String>) {
267 let mut seen: FxHashSet<String> = target.iter().cloned().collect();
268 for item in incoming {
269 if seen.insert(item.clone()) {
270 target.push(item);
271 }
272 }
273}
274
275fn prefix_if_needed(pat: &str, ws_prefix: &str) -> String {
279 if pat.starts_with(ws_prefix) || pat.starts_with('/') {
280 pat.to_string()
281 } else {
282 format!("{ws_prefix}/{pat}")
283 }
284}
285
286impl AggregatedPluginResult {
287 pub fn apply_workspace_prefix(&mut self, ws_prefix: &str) {
301 for (rule, _) in &mut self.entry_patterns {
302 *rule = rule.prefixed(ws_prefix);
303 }
304 for (pat, _) in &mut self.always_used {
305 *pat = prefix_if_needed(pat, ws_prefix);
306 }
307 for (pat, _) in &mut self.discovered_always_used {
308 *pat = prefix_if_needed(pat, ws_prefix);
309 }
310 for (pat, _) in &mut self.fixture_patterns {
311 *pat = prefix_if_needed(pat, ws_prefix);
312 }
313 for rule in &mut self.used_exports {
314 *rule = rule.prefixed(ws_prefix);
315 }
316 for rule in &mut self.provided_dependencies {
317 *rule = rule.prefixed(ws_prefix);
318 }
319 for (_, replacement) in &mut self.path_aliases {
320 *replacement = format!("{ws_prefix}/{replacement}");
321 }
322 }
323
324 pub fn merge_into(&mut self, other: Self) {
337 let Self {
338 entry_patterns,
339 entry_point_roles,
340 config_patterns,
341 always_used,
342 used_exports,
343 used_class_members,
344 referenced_dependencies,
345 package_referenced_dependencies,
346 discovered_always_used,
347 setup_files,
348 tooling_dependencies,
349 script_used_packages,
350 virtual_module_prefixes,
351 virtual_package_suffixes,
352 generated_import_patterns,
353 generated_type_import_prefixes,
354 path_aliases,
355 auto_imports,
356 active_plugins,
357 fixture_patterns,
358 scss_include_paths,
359 static_dir_mappings,
360 provided_dependencies,
361 } = other;
362
363 self.entry_patterns.extend(entry_patterns);
364 for (plugin_name, role) in entry_point_roles {
365 self.entry_point_roles.entry(plugin_name).or_insert(role);
366 }
367 self.config_patterns.extend(config_patterns);
368 self.always_used.extend(always_used);
369 self.used_exports.extend(used_exports);
370 self.used_class_members.extend(used_class_members);
371 self.referenced_dependencies.extend(referenced_dependencies);
372 self.package_referenced_dependencies
373 .extend(package_referenced_dependencies);
374 self.discovered_always_used.extend(discovered_always_used);
375 self.setup_files.extend(setup_files);
376 self.tooling_dependencies.extend(tooling_dependencies);
377 self.script_used_packages.extend(script_used_packages);
378 extend_unique(&mut self.virtual_module_prefixes, virtual_module_prefixes);
379 extend_unique(&mut self.virtual_package_suffixes, virtual_package_suffixes);
380 extend_unique(
381 &mut self.generated_import_patterns,
382 generated_import_patterns,
383 );
384 extend_unique(
385 &mut self.generated_type_import_prefixes,
386 generated_type_import_prefixes,
387 );
388 self.path_aliases.extend(path_aliases);
389 self.auto_imports.extend(auto_imports);
390 extend_unique(&mut self.active_plugins, active_plugins);
391 self.fixture_patterns.extend(fixture_patterns);
392 self.scss_include_paths.extend(scss_include_paths);
393 self.static_dir_mappings.extend(static_dir_mappings);
394 self.provided_dependencies.extend(provided_dependencies);
395 }
396}
397
398impl PluginRegistry {
399 #[must_use]
401 pub fn new(external: Vec<ExternalPluginDef>) -> Self {
402 Self {
403 plugins: builtin::create_builtin_plugins(),
404 external_plugins: external,
405 }
406 }
407
408 #[must_use]
413 pub fn discovery_hidden_dirs(&self, pkg: &PackageJson, root: &Path) -> Vec<String> {
414 let all_deps = pkg.all_dependency_names();
415 let mut seen = FxHashSet::default();
416 let mut dirs = Vec::new();
417
418 for plugin in &self.plugins {
419 if !plugin.is_enabled_with_deps(&all_deps, root) {
420 continue;
421 }
422 for dir in plugin.discovery_hidden_dirs() {
423 if seen.insert(*dir) {
424 dirs.push((*dir).to_string());
425 }
426 }
427 }
428
429 dirs
430 }
431
432 #[cfg(test)]
437 pub fn run(
438 &self,
439 pkg: &PackageJson,
440 root: &Path,
441 discovered_files: &[PathBuf],
442 ) -> AggregatedPluginResult {
443 self.try_run(pkg, root, discovered_files)
444 .unwrap_or_else(|errors| panic!("{}", format_plugin_regex_errors(&errors)))
445 }
446
447 pub fn try_run(
449 &self,
450 pkg: &PackageJson,
451 root: &Path,
452 discovered_files: &[PathBuf],
453 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
454 self.try_run_with_search_roots(pkg, root, discovered_files, &[root], false, None)
455 }
456
457 pub fn try_run_with_search_roots(
460 &self,
461 pkg: &PackageJson,
462 root: &Path,
463 discovered_files: &[PathBuf],
464 config_search_roots: &[&Path],
465 production_mode: bool,
466 candidate_index: Option<&ConfigCandidateIndex>,
467 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
468 let _span = tracing::info_span!("run_plugins").entered();
469 let mut result = AggregatedPluginResult::default();
470 let mut regex_errors = Vec::new();
471
472 let PluginRunContext { all_deps, active } = self.prepare_plugin_run_context(
473 pkg,
474 root,
475 discovered_files,
476 production_mode,
477 candidate_index,
478 );
479
480 self.run_plugin_preflight(&active, &all_deps, root, discovered_files);
481
482 for plugin in &active {
483 process_static_patterns(*plugin, root, &mut result);
484 }
485 process_package_json_metadata(&active, pkg, root, &mut result, &mut regex_errors);
486
487 process_external_plugins(
488 &self.external_plugins,
489 &all_deps,
490 root,
491 discovered_files,
492 &mut result,
493 );
494
495 let config_matchers = compile_config_matchers(&active);
496 let relative_files =
497 compute_relative_files(&config_matchers, &active, discovered_files, root);
498
499 resolve_plugin_config_files(&mut PluginConfigResolutionInput {
500 config_matchers: &config_matchers,
501 relative_files: &relative_files,
502 config_search_roots,
503 production_mode,
504 candidate_index,
505 root,
506 result: &mut result,
507 regex_errors: &mut regex_errors,
508 });
509
510 process_package_json_inline_configs(
511 &active,
512 &config_matchers,
513 &relative_files,
514 root,
515 &mut result,
516 &mut regex_errors,
517 );
518
519 if regex_errors.is_empty() {
520 Ok(result)
521 } else {
522 Err(regex_errors)
523 }
524 }
525
526 #[cfg(test)]
532 fn run_workspace_fast(&self, input: &WorkspacePluginRunInput<'_>) -> AggregatedPluginResult {
533 self.try_run_workspace_fast(input)
534 .unwrap_or_else(|errors| panic!("{}", format_plugin_regex_errors(&errors)))
535 }
536
537 pub fn try_run_workspace_fast(
543 &self,
544 input: &WorkspacePluginRunInput<'_>,
545 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
546 let _span = tracing::info_span!("run_plugins").entered();
547 let mut result = AggregatedPluginResult::default();
548 let mut regex_errors = Vec::new();
549
550 let all_deps = input.pkg.all_dependency_names();
551 let script_packages =
552 script_activation_packages(input.pkg, input.root, &all_deps, input.production_mode);
553 let workspace_files: Vec<PathBuf> = input
554 .relative_files
555 .iter()
556 .map(|(abs_path, _)| abs_path.clone())
557 .collect();
558
559 let active = self.collect_active_plugins(
560 input.pkg,
561 input.root,
562 &workspace_files,
563 &all_deps,
564 &script_packages,
565 input.candidate_index,
566 );
567
568 log_active_plugins(&active);
569
570 self.emit_silent_fail_diagnostics(&active, &all_deps, input.root, &workspace_files);
571
572 process_external_plugins(
573 &self.external_plugins,
574 &all_deps,
575 input.root,
576 &workspace_files,
577 &mut result,
578 );
579
580 if active.is_empty() && result.active_plugins.is_empty() {
581 return Ok(result);
582 }
583
584 process_workspace_active_plugins(&active, input, &mut result, &mut regex_errors);
585 resolve_workspace_plugin_configs(&active, input, &mut result, &mut regex_errors);
586
587 if regex_errors.is_empty() {
588 Ok(result)
589 } else {
590 Err(regex_errors)
591 }
592 }
593
594 #[must_use]
597 pub fn precompile_config_matchers(&self) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
598 self.plugins
599 .iter()
600 .filter(|p| !p.config_patterns().is_empty())
601 .map(|p| {
602 let matchers: Vec<globset::GlobMatcher> = p
603 .config_patterns()
604 .iter()
605 .filter_map(|pat| {
606 let prepared = prepare_config_pattern(pat);
607 globset::Glob::new(&prepared)
608 .ok()
609 .map(|g| g.compile_matcher())
610 })
611 .collect();
612 (p.as_ref(), matchers)
613 })
614 .collect()
615 }
616}
617
618fn process_workspace_active_plugins(
619 active: &[&dyn Plugin],
620 input: &WorkspacePluginRunInput<'_>,
621 result: &mut AggregatedPluginResult,
622 regex_errors: &mut Vec<PluginRegexValidationError>,
623) {
624 for plugin in active {
625 process_static_patterns(*plugin, input.root, result);
626 }
627 process_package_json_metadata(active, input.pkg, input.root, result, regex_errors);
628}
629
630fn resolve_workspace_plugin_configs(
631 active: &[&dyn Plugin],
632 input: &WorkspacePluginRunInput<'_>,
633 result: &mut AggregatedPluginResult,
634 regex_errors: &mut Vec<PluginRegexValidationError>,
635) {
636 let workspace_matchers = select_workspace_matchers(
637 input.precompiled_config_matchers,
638 active,
639 input.skip_config_plugins,
640 );
641
642 let mut resolved_ws_plugins: FxHashSet<&str> = FxHashSet::default();
643 for (plugin, matchers) in &workspace_matchers {
644 resolve_plugin_matching_files(&mut PluginMatchingFilesInput {
645 plugin: *plugin,
646 matchers,
647 relative_files: input.relative_files,
648 root: input.root,
649 result,
650 regex_errors,
651 resolved_plugins: &mut resolved_ws_plugins,
652 });
653 }
654
655 load_workspace_filesystem_configs(&mut WorkspaceFsConfigInput {
656 workspace_matchers: &workspace_matchers,
657 resolved_ws_plugins: &resolved_ws_plugins,
658 root: input.root,
659 project_root: input.project_root,
660 production_mode: input.production_mode,
661 candidate_index: input.candidate_index,
662 result,
663 regex_errors,
664 });
665}
666
667impl Default for PluginRegistry {
668 fn default() -> Self {
669 Self::new(vec![])
670 }
671}
672
673impl PluginRegistry {
674 fn prepare_plugin_run_context<'a>(
675 &'a self,
676 pkg: &PackageJson,
677 root: &Path,
678 discovered_files: &[PathBuf],
679 production_mode: bool,
680 candidate_index: Option<&ConfigCandidateIndex>,
681 ) -> PluginRunContext<'a> {
682 let all_deps = pkg.all_dependency_names();
683 let script_packages = script_activation_packages(pkg, root, &all_deps, production_mode);
684 let active = self.collect_active_plugins(
685 pkg,
686 root,
687 discovered_files,
688 &all_deps,
689 &script_packages,
690 candidate_index,
691 );
692
693 PluginRunContext { all_deps, active }
694 }
695
696 fn run_plugin_preflight(
697 &self,
698 active: &[&dyn Plugin],
699 all_deps: &[String],
700 root: &Path,
701 discovered_files: &[PathBuf],
702 ) {
703 log_active_plugins(active);
704 check_meta_framework_prerequisites(active, root);
705 self.emit_silent_fail_diagnostics(active, all_deps, root, discovered_files);
706 }
707
708 fn collect_active_plugins<'a>(
711 &'a self,
712 pkg: &PackageJson,
713 root: &Path,
714 discovered_files: &[PathBuf],
715 all_deps: &[String],
716 script_packages: &FxHashSet<String>,
717 candidate_index: Option<&ConfigCandidateIndex>,
718 ) -> Vec<&'a dyn Plugin> {
719 self.plugins
720 .iter()
721 .filter(|p| {
722 p.is_enabled_with_files(all_deps, root, discovered_files, candidate_index)
723 || p.is_enabled_with_scripts(script_packages, root)
724 || p.is_enabled_with_package_json(pkg, root)
725 })
726 .map(AsRef::as_ref)
727 .collect()
728 }
729
730 fn emit_silent_fail_diagnostics(
739 &self,
740 active: &[&dyn Plugin],
741 all_deps: &[String],
742 root: &Path,
743 discovered_files: &[PathBuf],
744 ) {
745 let active_external: Vec<&ExternalPluginDef> = self
746 .external_plugins
747 .iter()
748 .filter(|ext| is_external_plugin_active(ext, all_deps, root, discovered_files))
749 .collect();
750 let mut diagnostics = detect_pattern_collisions(active, &active_external);
751 diagnostics.extend(detect_enabler_typos(&self.external_plugins, all_deps));
752 emit_plugin_diagnostics(&diagnostics);
753 }
754}
755
756fn plugin_warn_dedupe() -> &'static std::sync::Mutex<FxHashSet<String>> {
763 static WARNED: std::sync::OnceLock<std::sync::Mutex<FxHashSet<String>>> =
764 std::sync::OnceLock::new();
765 WARNED.get_or_init(|| std::sync::Mutex::new(FxHashSet::default()))
766}
767
768struct PluginConfigResolutionInput<'a> {
769 config_matchers: &'a [(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
770 relative_files: &'a [(PathBuf, String)],
771 config_search_roots: &'a [&'a Path],
772 production_mode: bool,
773 candidate_index: Option<&'a ConfigCandidateIndex>,
774 root: &'a Path,
775 result: &'a mut AggregatedPluginResult,
776 regex_errors: &'a mut Vec<PluginRegexValidationError>,
777}
778
779fn select_workspace_matchers<'a>(
782 precompiled_config_matchers: &[(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
783 active: &[&dyn Plugin],
784 skip_config_plugins: &FxHashSet<&str>,
785) -> Vec<(&'a dyn Plugin, Vec<globset::GlobMatcher>)> {
786 let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
787 precompiled_config_matchers
788 .iter()
789 .filter(|(p, _)| {
790 active_names.contains(p.name())
791 && (!skip_config_plugins.contains(p.name())
792 || must_parse_workspace_config_when_root_active(p.name()))
793 })
794 .map(|(plugin, matchers)| (*plugin, matchers.clone()))
795 .collect()
796}
797
798struct WorkspaceFsConfigInput<'a> {
799 workspace_matchers: &'a [(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
800 resolved_ws_plugins: &'a FxHashSet<&'a str>,
801 root: &'a Path,
802 project_root: &'a Path,
803 production_mode: bool,
804 candidate_index: Option<&'a ConfigCandidateIndex>,
805 result: &'a mut AggregatedPluginResult,
806 regex_errors: &'a mut Vec<PluginRegexValidationError>,
807}
808
809fn load_workspace_filesystem_configs(input: &mut WorkspaceFsConfigInput<'_>) {
812 let search_roots: &[&Path] = if input.root == input.project_root {
813 &[input.root]
814 } else {
815 &[input.root, input.project_root]
816 };
817 let ws_json_configs = discover_config_files(
818 input.workspace_matchers,
819 input.resolved_ws_plugins,
820 search_roots,
821 input.production_mode,
822 input.candidate_index,
823 );
824 for (abs_path, plugin) in &ws_json_configs {
825 let Ok(source) = std::fs::read_to_string(abs_path) else {
826 continue;
827 };
828 let plugin_result = plugin.resolve_config(abs_path, &source, input.root);
829 if plugin_result.is_empty() {
830 continue;
831 }
832 let rel = abs_path
833 .strip_prefix(input.project_root)
834 .map(|p| p.to_string_lossy())
835 .unwrap_or_default();
836 tracing::debug!(
837 plugin = plugin.name(),
838 config = %rel,
839 entries = plugin_result.entry_patterns.len(),
840 deps = plugin_result.referenced_dependencies.len(),
841 "resolved config (workspace filesystem fallback)"
842 );
843 if let Err(mut errors) =
844 process_config_result(plugin.name(), plugin_result, input.result, Some(abs_path))
845 {
846 input.regex_errors.append(&mut errors);
847 }
848 }
849}
850
851fn resolve_plugin_config_files(input: &mut PluginConfigResolutionInput<'_>) {
852 if input.config_matchers.is_empty() {
853 return;
854 }
855
856 let mut resolved_plugins: FxHashSet<&str> = FxHashSet::default();
857 for (plugin, matchers) in input.config_matchers {
858 resolve_plugin_matching_files(&mut PluginMatchingFilesInput {
859 plugin: *plugin,
860 matchers,
861 relative_files: input.relative_files,
862 root: input.root,
863 result: input.result,
864 regex_errors: input.regex_errors,
865 resolved_plugins: &mut resolved_plugins,
866 });
867 }
868
869 let json_configs = discover_config_files(
870 input.config_matchers,
871 &resolved_plugins,
872 input.config_search_roots,
873 input.production_mode,
874 input.candidate_index,
875 );
876 for (abs_path, plugin) in &json_configs {
877 resolve_plugin_filesystem_config(
878 *plugin,
879 abs_path,
880 input.root,
881 input.result,
882 input.regex_errors,
883 );
884 }
885}
886
887struct PluginMatchingFilesInput<'plugins, 'data, 'state> {
888 plugin: &'plugins dyn Plugin,
889 matchers: &'data [globset::GlobMatcher],
890 relative_files: &'data [(PathBuf, String)],
891 root: &'data Path,
892 result: &'state mut AggregatedPluginResult,
893 regex_errors: &'state mut Vec<PluginRegexValidationError>,
894 resolved_plugins: &'state mut FxHashSet<&'plugins str>,
895}
896
897fn resolve_plugin_matching_files(input: &mut PluginMatchingFilesInput<'_, '_, '_>) {
898 use rayon::prelude::*;
899
900 let plugin_hits: Vec<&PathBuf> = input
901 .relative_files
902 .par_iter()
903 .filter_map(|(abs_path, rel_path)| {
904 input
905 .matchers
906 .iter()
907 .any(|m| m.is_match(rel_path.as_str()))
908 .then_some(abs_path)
909 })
910 .collect();
911 for abs_path in plugin_hits {
912 let Ok(source) = std::fs::read_to_string(abs_path) else {
913 continue;
914 };
915 let plugin_result = input.plugin.resolve_config(abs_path, &source, input.root);
916 if plugin_result.is_empty() {
917 continue;
918 }
919 input.resolved_plugins.insert(input.plugin.name());
920 process_resolved_plugin_config(ResolvedPluginConfigInput {
921 plugin: input.plugin,
922 abs_path,
923 plugin_result,
924 result: input.result,
925 regex_errors: input.regex_errors,
926 message: "resolved config",
927 config_display: abs_path.display(),
928 });
929 }
930}
931
932fn resolve_plugin_filesystem_config(
933 plugin: &dyn Plugin,
934 abs_path: &Path,
935 root: &Path,
936 result: &mut AggregatedPluginResult,
937 regex_errors: &mut Vec<PluginRegexValidationError>,
938) {
939 let Ok(source) = std::fs::read_to_string(abs_path) else {
940 return;
941 };
942 let plugin_result = plugin.resolve_config(abs_path, &source, root);
943 if plugin_result.is_empty() {
944 return;
945 }
946 let rel = abs_path
947 .strip_prefix(root)
948 .map(|p| p.to_string_lossy())
949 .unwrap_or_default();
950 process_resolved_plugin_config(ResolvedPluginConfigInput {
951 plugin,
952 abs_path,
953 plugin_result,
954 result,
955 regex_errors,
956 message: "resolved config (filesystem fallback)",
957 config_display: rel,
958 });
959}
960
961struct ResolvedPluginConfigInput<'a, D> {
962 plugin: &'a dyn Plugin,
963 abs_path: &'a Path,
964 plugin_result: PluginResult,
965 result: &'a mut AggregatedPluginResult,
966 regex_errors: &'a mut Vec<PluginRegexValidationError>,
967 message: &'static str,
968 config_display: D,
969}
970
971fn process_resolved_plugin_config(input: ResolvedPluginConfigInput<'_, impl std::fmt::Display>) {
972 tracing::debug!(
973 plugin = input.plugin.name(),
974 config = %input.config_display,
975 entries = input.plugin_result.entry_patterns.len(),
976 deps = input.plugin_result.referenced_dependencies.len(),
977 input.message
978 );
979 if let Err(mut errors) = process_config_result(
980 input.plugin.name(),
981 input.plugin_result,
982 input.result,
983 Some(input.abs_path),
984 ) {
985 input.regex_errors.append(&mut errors);
986 }
987}
988
989fn should_warn(key: String) -> bool {
993 plugin_warn_dedupe()
994 .lock()
995 .map_or(true, |mut set| set.insert(key))
996}
997
998#[derive(Debug, Clone, PartialEq, Eq)]
1005pub(crate) enum PluginDiagnostic {
1006 PatternCollision {
1008 pattern: String,
1009 owners: Vec<String>,
1010 },
1011 EnablerTypo {
1014 plugin: String,
1015 enabler: String,
1016 suggestion: String,
1017 },
1018}
1019
1020pub(crate) fn detect_pattern_collisions(
1046 builtin_active: &[&dyn Plugin],
1047 external_active: &[&ExternalPluginDef],
1048) -> Vec<PluginDiagnostic> {
1049 use rustc_hash::FxHashMap;
1050
1051 let mut pattern_owners: FxHashMap<String, (Vec<String>, FxHashSet<String>)> =
1052 FxHashMap::default();
1053
1054 let record = |pattern_owners: &mut FxHashMap<_, (Vec<String>, FxHashSet<String>)>,
1055 pattern: String,
1056 name: String| {
1057 let (list, seen) = pattern_owners.entry(pattern).or_default();
1058 if seen.insert(name.clone()) {
1059 list.push(name);
1060 }
1061 };
1062
1063 for plugin in builtin_active {
1064 for pat in plugin.config_patterns() {
1065 record(
1066 &mut pattern_owners,
1067 (*pat).to_string(),
1068 plugin.name().to_string(),
1069 );
1070 }
1071 }
1072 for ext in external_active {
1073 for pat in &ext.config_patterns {
1074 record(&mut pattern_owners, pat.clone(), ext.name.clone());
1075 }
1076 }
1077
1078 let builtin_names: FxHashSet<&str> = builtin_active.iter().map(|p| p.name()).collect();
1085
1086 let mut findings: Vec<PluginDiagnostic> = pattern_owners
1087 .into_iter()
1088 .filter_map(|(pattern, (owners, _seen))| {
1089 if owners.len() < 2 || owners.iter().all(|o| builtin_names.contains(o.as_str())) {
1090 None
1091 } else {
1092 Some(PluginDiagnostic::PatternCollision { pattern, owners })
1093 }
1094 })
1095 .collect();
1096 findings.sort_unstable_by(|a, b| match (a, b) {
1097 (
1098 PluginDiagnostic::PatternCollision { pattern: ap, .. },
1099 PluginDiagnostic::PatternCollision { pattern: bp, .. },
1100 ) => ap.cmp(bp),
1101 _ => std::cmp::Ordering::Equal,
1102 });
1103 findings
1104}
1105
1106pub(crate) fn detect_enabler_typos(
1121 external_plugins: &[ExternalPluginDef],
1122 all_deps: &[String],
1123) -> Vec<PluginDiagnostic> {
1124 let mut findings = Vec::new();
1125
1126 for ext in external_plugins {
1127 if ext.detection.is_some() || ext.enablers.is_empty() {
1128 continue;
1129 }
1130
1131 let any_match = ext.enablers.iter().any(|enabler| {
1132 if enabler.ends_with('/') {
1133 all_deps.iter().any(|d| d.starts_with(enabler))
1134 } else {
1135 all_deps.iter().any(|d| d == enabler)
1136 }
1137 });
1138 if any_match {
1139 continue;
1140 }
1141
1142 for enabler in &ext.enablers {
1143 let candidates = all_deps.iter().map(String::as_str);
1144 let Some(suggestion) = fallow_config::levenshtein::closest_match(enabler, candidates)
1145 else {
1146 continue;
1147 };
1148
1149 findings.push(PluginDiagnostic::EnablerTypo {
1150 plugin: ext.name.clone(),
1151 enabler: enabler.clone(),
1152 suggestion: suggestion.to_string(),
1153 });
1154 }
1155 }
1156
1157 findings
1158}
1159
1160fn emit_plugin_diagnostics(findings: &[PluginDiagnostic]) {
1163 for finding in findings {
1164 match finding {
1165 PluginDiagnostic::PatternCollision { pattern, owners } => {
1166 let key = format!("collision::{pattern}::{owners:?}");
1167 if !should_warn(key) {
1168 continue;
1169 }
1170 let winner = &owners[0];
1171 let others = owners[1..].join(", ");
1172 tracing::warn!(
1173 "plugin config_patterns collision: identical pattern \
1174 '{pattern}' is claimed by plugins [{joined}]; '{winner}' \
1175 runs first (registration order), others ({others}) \
1176 follow. Rename one of the patterns or remove the \
1177 duplicate plugin to make resolution explicit. A future \
1178 release may reject identical-pattern collisions.",
1179 joined = owners.join(", "),
1180 );
1181 }
1182 PluginDiagnostic::EnablerTypo {
1183 plugin,
1184 enabler,
1185 suggestion,
1186 } => {
1187 let key = format!("enabler::{plugin}::{enabler}");
1188 if !should_warn(key) {
1189 continue;
1190 }
1191 tracing::warn!(
1192 "plugin '{plugin}' enabler '{enabler}' does not match any \
1193 dependency in package.json; did you mean '{suggestion}'? \
1194 The plugin will not activate. A future release may reject \
1195 unmatched enablers.",
1196 );
1197 }
1198 }
1199 }
1200}
1201
1202fn process_package_json_inline_configs(
1207 active: &[&dyn Plugin],
1208 config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
1209 relative_files: &[(PathBuf, String)],
1210 root: &Path,
1211 result: &mut AggregatedPluginResult,
1212 regex_errors: &mut Vec<PluginRegexValidationError>,
1213) {
1214 for plugin in active {
1215 let Some(key) = plugin.package_json_config_key() else {
1216 continue;
1217 };
1218 if check_has_config_file(*plugin, config_matchers, relative_files) {
1219 continue;
1220 }
1221 let pkg_path = root.join("package.json");
1222 let Ok(content) = std::fs::read_to_string(&pkg_path) else {
1223 continue;
1224 };
1225 let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
1226 continue;
1227 };
1228 let Some(config_value) = json.get(key) else {
1229 continue;
1230 };
1231 let config_json = serde_json::to_string(config_value).unwrap_or_default();
1232 let fake_path = root.join(format!("{key}.config.json"));
1233 let plugin_result = plugin.resolve_config(&fake_path, &config_json, root);
1234 if plugin_result.is_empty() {
1235 continue;
1236 }
1237 tracing::debug!(
1238 plugin = plugin.name(),
1239 key = key,
1240 "resolved inline package.json config"
1241 );
1242 if let Err(mut errors) =
1243 process_config_result(plugin.name(), plugin_result, result, Some(&pkg_path))
1244 {
1245 regex_errors.append(&mut errors);
1246 }
1247 }
1248}
1249
1250#[derive(Debug)]
1253struct MetaFrameworkWarning {
1254 dedupe_key: &'static str,
1255 message: &'static str,
1256}
1257
1258fn missing_meta_framework_prerequisites(
1268 active_plugins: &[&dyn Plugin],
1269 root: &Path,
1270) -> Vec<MetaFrameworkWarning> {
1271 active_plugins
1272 .iter()
1273 .filter_map(|plugin| match plugin.name() {
1274 "nuxt" if !root.join(".nuxt/tsconfig.json").exists() => Some(MetaFrameworkWarning {
1275 dedupe_key: "meta-prereq::nuxt",
1276 message: "Nuxt project missing .nuxt/tsconfig.json: run `nuxt prepare` \
1277 before fallow for accurate analysis",
1278 }),
1279 "astro" if !root.join(".astro").exists() => Some(MetaFrameworkWarning {
1280 dedupe_key: "meta-prereq::astro",
1281 message: "Astro project missing .astro/ types: run `astro sync` \
1282 before fallow for accurate analysis",
1283 }),
1284 _ => None,
1285 })
1286 .collect()
1287}
1288
1289fn check_meta_framework_prerequisites(active_plugins: &[&dyn Plugin], root: &Path) {
1299 for warning in missing_meta_framework_prerequisites(active_plugins, root) {
1300 if should_warn(warning.dedupe_key.to_owned()) {
1301 tracing::warn!("{}", warning.message);
1302 }
1303 }
1304}
1305
1306fn script_activation_packages(
1307 pkg: &PackageJson,
1308 root: &Path,
1309 all_deps: &[String],
1310 production_mode: bool,
1311) -> FxHashSet<String> {
1312 let Some(pkg_scripts) = pkg.scripts.as_ref() else {
1313 return FxHashSet::default();
1314 };
1315
1316 let scripts_to_analyze = if production_mode {
1317 scripts::filter_production_scripts(pkg_scripts)
1318 } else {
1319 pkg_scripts.clone()
1320 };
1321
1322 let mut nm_roots = Vec::new();
1323 if root.join("node_modules").is_dir() {
1324 nm_roots.push(root);
1325 }
1326 let bin_map = scripts::build_bin_to_package_map(&nm_roots, all_deps);
1327 let dep_set: FxHashSet<String> = all_deps.iter().cloned().collect();
1328 let script_names: FxHashSet<String> = pkg_scripts.keys().cloned().collect();
1329
1330 scripts::analyze_scripts_with_dependency_context(
1331 &scripts_to_analyze,
1332 root,
1333 &bin_map,
1334 &dep_set,
1335 &script_names,
1336 )
1337 .used_packages
1338}
1339
1340#[cfg(test)]
1341mod tests;