1use std::borrow::Cow;
7use std::ffi::{OsStr, OsString};
8use std::path::{Path, PathBuf};
9
10use rustc_hash::{FxHashMap, FxHashSet};
11
12use fallow_config::{ExternalPluginDef, PackageJson, PluginDetection, UsedClassMemberRule};
13
14use crate::discover::SOURCE_EXTENSIONS;
15
16use super::super::{PathRule, Plugin, PluginResult, PluginUsedExportRule, UsedExportRule};
17use super::{AggregatedPluginResult, PluginRegexValidationError, PluginRegexValidationErrorInput};
18
19#[must_use]
28pub fn is_source_ext_root_pattern(pat: &str) -> bool {
29 if pat.is_empty() || pat.contains('/') {
30 return false;
31 }
32 for expanded in expand_brace_pattern(pat) {
33 if expanded.starts_with('.') {
34 return false;
35 }
36 let Some(ext) = std::path::Path::new(&expanded).extension() else {
37 return false;
38 };
39 let Some(ext_str) = ext.to_str() else {
40 return false;
41 };
42 if !SOURCE_EXTENSIONS.contains(&ext_str) {
43 return false;
44 }
45 }
46 true
47}
48
49#[must_use]
51pub fn prepare_config_pattern(pat: &str) -> Cow<'_, str> {
52 if is_source_ext_root_pattern(pat) {
53 Cow::Owned(format!("**/{pat}"))
54 } else {
55 Cow::Borrowed(pat)
56 }
57}
58
59pub fn process_static_patterns(
61 plugin: &dyn Plugin,
62 root: &Path,
63 result: &mut AggregatedPluginResult,
64) {
65 let pname = plugin.name().to_string();
66 result.active_plugins.push(pname.clone());
67 result
68 .entry_point_roles
69 .insert(pname.clone(), plugin.entry_point_role());
70
71 collect_static_plugin_rules(plugin, &pname, result);
72 collect_static_plugin_metadata(plugin, root, result);
73}
74
75fn collect_static_plugin_rules(
78 plugin: &dyn Plugin,
79 pname: &str,
80 result: &mut AggregatedPluginResult,
81) {
82 for rule in plugin.entry_pattern_rules() {
83 result.entry_patterns.push((rule, pname.to_string()));
84 }
85 for pat in plugin.config_patterns() {
86 result.config_patterns.push((*pat).to_string());
87 }
88 for pat in plugin.always_used() {
89 result
90 .always_used
91 .push(((*pat).to_string(), pname.to_string()));
92 }
93 for rule in plugin.used_export_rules() {
94 result
95 .used_exports
96 .push(PluginUsedExportRule::new(pname.to_string(), rule));
97 }
98 for member in plugin.used_class_members() {
99 result
100 .used_class_members
101 .push(UsedClassMemberRule::from(*member));
102 }
103 for rule in plugin.used_class_member_rules() {
104 result.used_class_members.push(rule);
105 }
106 result
107 .framework_class_member_contracts
108 .extend(plugin.framework_class_member_contracts());
109 for pat in plugin.fixture_glob_patterns() {
110 result
111 .fixture_patterns
112 .push(((*pat).to_string(), pname.to_string()));
113 }
114}
115
116fn collect_static_plugin_metadata(
119 plugin: &dyn Plugin,
120 root: &Path,
121 result: &mut AggregatedPluginResult,
122) {
123 for dep in plugin.tooling_dependencies() {
124 result.tooling_dependencies.push((*dep).to_string());
125 }
126 for prefix in plugin.virtual_module_prefixes() {
127 result.virtual_module_prefixes.push((*prefix).to_string());
128 }
129 for suffix in plugin.virtual_package_suffixes() {
130 result.virtual_package_suffixes.push((*suffix).to_string());
131 }
132 for pattern in plugin.generated_import_patterns() {
133 result
134 .generated_import_patterns
135 .push((*pattern).to_string());
136 }
137 for prefix in plugin.generated_type_import_prefixes() {
138 result
139 .generated_type_import_prefixes
140 .push((*prefix).to_string());
141 }
142 for (prefix, replacement) in plugin.path_aliases(root) {
143 result.path_aliases.push((prefix.to_string(), replacement));
144 }
145 result.auto_imports.extend(plugin.auto_imports(root));
146 result
147 .provided_dependencies
148 .extend(plugin.provided_dependencies());
149}
150
151pub fn process_package_json_metadata(
153 active: &[&dyn Plugin],
154 pkg: &PackageJson,
155 root: &Path,
156 result: &mut AggregatedPluginResult,
157 regex_errors: &mut Vec<PluginRegexValidationError>,
158) {
159 for plugin in active {
160 let package_referenced = plugin.package_json_referenced_dependencies(pkg, root);
161 if !package_referenced.is_empty() {
162 let pkg_path = root.join("package.json");
163 result.package_referenced_dependencies.extend(
164 package_referenced
165 .into_iter()
166 .map(|dep| (pkg_path.clone(), dep)),
167 );
168 }
169 let plugin_result = plugin.resolve_package_json(pkg, root);
170 if plugin_result.is_empty() {
171 continue;
172 }
173 tracing::debug!(
174 plugin = plugin.name(),
175 deps = plugin_result.referenced_dependencies.len(),
176 "resolved package.json metadata"
177 );
178 if let Err(mut errors) = process_config_result(plugin.name(), plugin_result, result, None) {
179 regex_errors.append(&mut errors);
180 }
181 }
182}
183
184pub fn is_external_plugin_active(
189 ext: &ExternalPluginDef,
190 all_deps: &[String],
191 root: &Path,
192 discovered_files: &[PathBuf],
193) -> bool {
194 if let Some(detection) = &ext.detection {
195 let all_dep_refs: Vec<&str> = all_deps.iter().map(String::as_str).collect();
196 check_plugin_detection(detection, &all_dep_refs, root, discovered_files)
197 } else if !ext.enablers.is_empty() {
198 ext.enablers.iter().any(|enabler| {
199 if enabler.ends_with('/') {
200 all_deps.iter().any(|d| d.starts_with(enabler))
201 } else {
202 all_deps.iter().any(|d| d == enabler)
203 }
204 })
205 } else {
206 false
207 }
208}
209
210pub fn process_external_plugins(
212 external_plugins: &[ExternalPluginDef],
213 all_deps: &[String],
214 root: &Path,
215 discovered_files: &[PathBuf],
216 result: &mut AggregatedPluginResult,
217) {
218 for ext in external_plugins {
219 let is_active = is_external_plugin_active(ext, all_deps, root, discovered_files);
220 if is_active {
221 result.active_plugins.push(ext.name.clone());
222 result
223 .entry_point_roles
224 .insert(ext.name.clone(), ext.entry_point_role);
225 result.entry_patterns.extend(
226 ext.entry_points
227 .iter()
228 .map(|p| (PathRule::new(p.clone()), ext.name.clone())),
229 );
230 if !ext.manifest_entries.is_empty() {
231 result.entry_patterns.extend(
232 crate::plugins::manifest_entries::evaluate_manifest_entries(ext, root)
233 .into_iter()
234 .map(|rule| (rule, ext.name.clone())),
235 );
236 }
237 result.config_patterns.extend(ext.config_patterns.clone());
238 result.always_used.extend(
239 ext.config_patterns
240 .iter()
241 .chain(ext.always_used.iter())
242 .map(|p| (p.clone(), ext.name.clone())),
243 );
244 result
245 .tooling_dependencies
246 .extend(ext.tooling_dependencies.clone());
247 for ue in &ext.used_exports {
248 result.used_exports.push(PluginUsedExportRule::new(
249 ext.name.clone(),
250 UsedExportRule::new(ue.pattern.clone(), ue.exports.clone()),
251 ));
252 }
253 result
254 .used_class_members
255 .extend(ext.used_class_members.iter().cloned());
256 }
257 }
258}
259
260pub struct ConfigCandidateIndex {
272 dirs: FxHashMap<PathBuf, FxHashSet<OsString>>,
273}
274
275impl ConfigCandidateIndex {
276 #[must_use]
280 pub(crate) fn build<'a>(paths: impl IntoIterator<Item = &'a Path>) -> Self {
281 let mut dirs: FxHashMap<PathBuf, FxHashSet<OsString>> = FxHashMap::default();
282 for path in paths {
283 if let (Some(parent), Some(name)) = (path.parent(), path.file_name()) {
284 dirs.entry(parent.to_path_buf())
285 .or_default()
286 .insert(name.to_os_string());
287 }
288 }
289 Self { dirs }
290 }
291
292 #[must_use]
296 pub(crate) fn dir_contains(&self, dir: &Path, name: &OsStr) -> bool {
297 self.dirs.get(dir).is_some_and(|names| names.contains(name))
298 }
299
300 #[must_use]
305 pub(crate) fn any_descendant_contains(&self, root: &Path, name: &OsStr) -> bool {
306 self.dirs
307 .iter()
308 .any(|(dir, names)| dir.starts_with(root) && names.contains(name))
309 }
310
311 #[must_use]
317 pub(crate) fn any_descendant_matches(
318 &self,
319 root: &Path,
320 matcher: &globset::GlobMatcher,
321 ) -> bool {
322 self.dirs.iter().any(|(dir, names)| {
323 dir.starts_with(root) && names.iter().any(|name| matcher.is_match(Path::new(name)))
324 })
325 }
326
327 fn glob_matches_in_dir(&self, dir: &Path, matcher: &globset::GlobMatcher) -> Vec<PathBuf> {
328 self.dirs.get(dir).map_or_else(Vec::new, |names| {
329 names
330 .iter()
331 .filter(|name| matcher.is_match(Path::new(name)))
332 .map(|name| dir.join(name))
333 .collect()
334 })
335 }
336}
337
338pub fn discover_config_files<'a>(
358 config_matchers: &[(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
359 resolved_plugins: &FxHashSet<&str>,
360 roots: &[&Path],
361 production_mode: bool,
362 candidate_index: Option<&ConfigCandidateIndex>,
363) -> Vec<(PathBuf, &'a dyn Plugin)> {
364 use rayon::prelude::*;
365 let mut pending: Vec<(&'a dyn Plugin, &Path, String)> = Vec::new();
366 for (plugin, _) in config_matchers {
367 if resolved_plugins.contains(plugin.name()) {
368 continue;
369 }
370 for root in roots {
371 for pat in plugin.config_patterns() {
372 if !production_mode && is_source_ext_root_pattern(pat) {
373 continue;
374 }
375 pending.push((*plugin, *root, pat.to_string()));
376 }
377 }
378 }
379
380 let hits: Vec<(PathBuf, &'a dyn Plugin)> = pending
381 .par_iter()
382 .flat_map_iter(|(plugin, root, pat)| {
383 expand_brace_pattern(pat)
384 .into_iter()
385 .flat_map(|expanded| match candidate_index {
386 Some(index) if !pattern_needs_filesystem(&expanded) => {
392 match_pattern_in_index(root, &expanded, index)
393 }
394 _ => discover_pattern_matches(root, &expanded),
395 })
396 .map(move |path| (path, *plugin))
397 .collect::<Vec<_>>()
398 })
399 .collect();
400
401 let mut seen: FxHashSet<(PathBuf, &'a str)> = FxHashSet::default();
402 let mut config_files: Vec<(PathBuf, &'a dyn Plugin)> = Vec::with_capacity(hits.len());
403 for (path, plugin) in hits {
404 if seen.insert((path.clone(), plugin.name())) {
405 config_files.push((path, plugin));
406 }
407 }
408 config_files
409}
410
411fn pattern_has_glob(pattern: &str) -> bool {
412 pattern.contains('*') || pattern.contains('?') || pattern.contains('[')
413}
414
415fn pattern_needs_filesystem(pattern: &str) -> bool {
421 let mut components = pattern.split('/').peekable();
422 let mut needs_fs = false;
423 while let Some(component) = components.next() {
424 if components.peek().is_none() {
425 break; }
427 if component.starts_with('.')
428 && component != "."
429 && component != ".."
430 && !crate::discover::is_allowed_hidden_dir(OsStr::new(component))
431 {
432 needs_fs = true;
433 break;
434 }
435 }
436 needs_fs
437}
438
439fn match_pattern_in_index(
444 root: &Path,
445 pattern: &str,
446 index: &ConfigCandidateIndex,
447) -> Vec<PathBuf> {
448 if !pattern_has_glob(pattern) {
449 let path = root.join(pattern);
450 return match (path.parent(), path.file_name()) {
451 (Some(dir), Some(name)) if index.dir_contains(dir, name) => vec![path],
452 _ => Vec::new(),
453 };
454 }
455
456 if let Some(stripped) = pattern.strip_prefix("**/") {
457 return match_pattern_in_index(root, stripped, index);
458 }
459
460 let (dir, file_pattern) = match pattern.rsplit_once('/') {
461 Some((parent, file_pattern)) if !pattern_has_glob(parent) => {
462 (root.join(parent), file_pattern)
463 }
464 Some(_) => return Vec::new(),
465 None => (root.to_path_buf(), pattern),
466 };
467
468 let Ok(matcher) = globset::Glob::new(file_pattern).map(|g| g.compile_matcher()) else {
469 return Vec::new();
470 };
471 index.glob_matches_in_dir(&dir, &matcher)
472}
473
474fn discover_pattern_matches(root: &Path, pattern: &str) -> Vec<PathBuf> {
475 if !pattern_has_glob(pattern) {
476 let path = root.join(pattern);
477 return if path.is_file() {
478 vec![path]
479 } else {
480 Vec::new()
481 };
482 }
483
484 if let Some(stripped) = pattern.strip_prefix("**/") {
485 return discover_pattern_matches(root, stripped);
486 }
487
488 let (dir, file_pattern) = match pattern.rsplit_once('/') {
489 Some((parent, file_pattern)) if !pattern_has_glob(parent) => {
490 (root.join(parent), file_pattern)
491 }
492 Some(_) => return Vec::new(),
493 None => (root.to_path_buf(), pattern),
494 };
495
496 scan_dir_for_pattern(&dir, file_pattern)
497}
498
499fn scan_dir_for_pattern(dir: &Path, file_pattern: &str) -> Vec<PathBuf> {
500 let Ok(matcher) = globset::Glob::new(file_pattern).map(|g| g.compile_matcher()) else {
501 return Vec::new();
502 };
503 let Ok(entries) = std::fs::read_dir(dir) else {
504 return Vec::new();
505 };
506
507 entries
508 .filter_map(Result::ok)
509 .map(|entry| entry.path())
510 .filter(|path| path.is_file())
511 .filter(|path| {
512 path.file_name()
513 .is_some_and(|name| matcher.is_match(std::path::Path::new(name)))
514 })
515 .collect()
516}
517
518fn expand_brace_pattern(pattern: &str) -> Vec<String> {
519 let Some(open) = pattern.find('{') else {
520 return vec![pattern.to_string()];
521 };
522 let Some(close_rel) = pattern[open + 1..].find('}') else {
523 return vec![pattern.to_string()];
524 };
525 let close = open + 1 + close_rel;
526
527 let prefix = &pattern[..open];
528 let suffix = &pattern[close + 1..];
529 let inner = &pattern[open + 1..close];
530 let mut expanded = Vec::new();
531 for option in inner.split(',') {
532 for tail in expand_brace_pattern(suffix) {
533 expanded.push(format!("{prefix}{option}{tail}"));
534 }
535 }
536 expanded
537}
538
539fn collect_path_rule_regex_errors(
544 rule: &crate::plugins::PathRule,
545 plugin_name: &str,
546 config_path: Option<&Path>,
547 rule_kind: &'static str,
548 errors: &mut Vec<PluginRegexValidationError>,
549) {
550 for pattern in &rule.exclude_regexes {
551 if let Err(source) = regex::Regex::new(pattern) {
552 errors.push(PluginRegexValidationError::new(
553 PluginRegexValidationErrorInput {
554 plugin_name,
555 config_path,
556 rule_kind,
557 field: "exclude_regexes",
558 rule_pattern: &rule.pattern,
559 regex_pattern: pattern,
560 source: &source,
561 },
562 ));
563 }
564 }
565 for pattern in &rule.exclude_segment_regexes {
566 if let Err(source) = regex::Regex::new(pattern) {
567 errors.push(PluginRegexValidationError::new(
568 PluginRegexValidationErrorInput {
569 plugin_name,
570 config_path,
571 rule_kind,
572 field: "exclude_segment_regexes",
573 rule_pattern: &rule.pattern,
574 regex_pattern: pattern,
575 source: &source,
576 },
577 ));
578 }
579 }
580}
581
582pub fn process_config_result(
588 plugin_name: &str,
589 plugin_result: PluginResult,
590 result: &mut AggregatedPluginResult,
591 config_path: Option<&Path>,
592) -> Result<(), Vec<PluginRegexValidationError>> {
593 let mut regex_errors = Vec::new();
594
595 for rule in &plugin_result.entry_patterns {
596 collect_path_rule_regex_errors(
597 rule,
598 plugin_name,
599 config_path,
600 "entry_patterns[]",
601 &mut regex_errors,
602 );
603 }
604 for rule in &plugin_result.used_exports {
605 collect_path_rule_regex_errors(
606 &rule.path,
607 plugin_name,
608 config_path,
609 "used_exports[].path",
610 &mut regex_errors,
611 );
612 }
613 if !regex_errors.is_empty() {
614 return Err(regex_errors);
615 }
616 merge_plugin_result_fields(plugin_name, plugin_result, result);
617 Ok(())
618}
619
620fn merge_plugin_result_fields(
624 pname: &str,
625 plugin_result: PluginResult,
626 result: &mut AggregatedPluginResult,
627) {
628 if plugin_result.replace_entry_patterns && !plugin_result.entry_patterns.is_empty() {
629 result.entry_patterns.retain(|(_, name)| name != pname);
630 }
631 if plugin_result.replace_used_export_rules && !plugin_result.used_exports.is_empty() {
632 result.used_exports.retain(|rule| rule.plugin_name != pname);
633 }
634 result.entry_patterns.extend(
635 plugin_result
636 .entry_patterns
637 .into_iter()
638 .map(|rule| (rule, pname.to_string())),
639 );
640 result.used_exports.extend(
641 plugin_result
642 .used_exports
643 .into_iter()
644 .map(|rule| PluginUsedExportRule::new(pname.to_string(), rule)),
645 );
646 result
647 .used_class_members
648 .extend(plugin_result.used_class_members);
649 result
650 .referenced_dependencies
651 .extend(plugin_result.referenced_dependencies);
652 result.discovered_always_used.extend(
653 plugin_result
654 .always_used_files
655 .into_iter()
656 .map(|p| (p, pname.to_string())),
657 );
658 for (prefix, replacement) in plugin_result.path_aliases {
659 result
660 .path_aliases
661 .retain(|(existing_prefix, _)| existing_prefix != &prefix);
662 result.path_aliases.push((prefix, replacement));
663 }
664 result.setup_files.extend(
665 plugin_result
666 .setup_files
667 .into_iter()
668 .map(|p| (p, pname.to_string())),
669 );
670 result.fixture_patterns.extend(
671 plugin_result
672 .fixture_patterns
673 .into_iter()
674 .map(|p| (p, pname.to_string())),
675 );
676 result
677 .scss_include_paths
678 .extend(plugin_result.scss_include_paths);
679 result
680 .static_dir_mappings
681 .extend(plugin_result.static_dir_mappings);
682 result
683 .provided_dependencies
684 .extend(plugin_result.provided_dependencies);
685}
686
687pub fn check_has_config_file(
689 plugin: &dyn Plugin,
690 config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
691 relative_files: &[(PathBuf, String)],
692) -> bool {
693 !plugin.config_patterns().is_empty()
694 && config_matchers.iter().any(|(p, matchers)| {
695 p.name() == plugin.name()
696 && relative_files
697 .iter()
698 .any(|(_, rel)| matchers.iter().any(|m| m.is_match(rel.as_str())))
699 })
700}
701
702pub fn check_plugin_detection(
704 detection: &PluginDetection,
705 all_deps: &[&str],
706 root: &Path,
707 discovered_files: &[PathBuf],
708) -> bool {
709 match detection {
710 PluginDetection::Dependency { package } => all_deps.iter().any(|d| *d == package),
711 PluginDetection::FileExists { pattern } => {
712 if let Ok(matcher) = globset::Glob::new(pattern).map(|g| g.compile_matcher()) {
713 for file in discovered_files {
714 let relative = file.strip_prefix(root).unwrap_or(file);
715 if matcher.is_match(relative) {
716 return true;
717 }
718 }
719 }
720 let full_pattern = root.join(pattern).to_string_lossy().to_string();
721 glob::glob(&full_pattern)
722 .ok()
723 .is_some_and(|mut g| g.next().is_some())
724 }
725 PluginDetection::All { conditions } => conditions
726 .iter()
727 .all(|c| check_plugin_detection(c, all_deps, root, discovered_files)),
728 PluginDetection::Any { conditions } => conditions
729 .iter()
730 .any(|c| check_plugin_detection(c, all_deps, root, discovered_files)),
731 }
732}
733
734#[cfg(test)]
735mod tests {
736 use super::*;
737
738 #[test]
739 fn pattern_needs_filesystem_only_for_non_allowlisted_hidden_dirs() {
740 assert!(pattern_needs_filesystem(".config/prisma.ts"));
743 assert!(!pattern_needs_filesystem("tsconfig.json"));
746 assert!(!pattern_needs_filesystem("prisma/schema.prisma"));
747 assert!(!pattern_needs_filesystem(".eslintrc.json"));
748 assert!(!pattern_needs_filesystem("**/project.json"));
749 assert!(!pattern_needs_filesystem(".storybook/main.ts"));
750 assert!(!pattern_needs_filesystem("a/b/c.json"));
751 }
752
753 #[test]
754 fn config_candidate_index_matches_plain_nested_and_glob_shapes() {
755 let root = Path::new("/project");
756 let index = ConfigCandidateIndex::build([
757 Path::new("/project/tsconfig.json"),
758 Path::new("/project/packages/a/tsconfig.json"),
759 Path::new("/project/prisma/schema.prisma"),
760 Path::new("/project/src/main.ts"),
761 ]);
762
763 assert_eq!(
765 match_pattern_in_index(root, "tsconfig.json", &index),
766 vec![PathBuf::from("/project/tsconfig.json")]
767 );
768 assert_eq!(
769 match_pattern_in_index(Path::new("/project/packages/a"), "tsconfig.json", &index),
770 vec![PathBuf::from("/project/packages/a/tsconfig.json")]
771 );
772 assert_eq!(
774 match_pattern_in_index(root, "prisma/schema.prisma", &index),
775 vec![PathBuf::from("/project/prisma/schema.prisma")]
776 );
777 assert_eq!(
778 match_pattern_in_index(root, "**/tsconfig.json", &index),
779 vec![PathBuf::from("/project/tsconfig.json")]
780 );
781 assert_eq!(
782 match_pattern_in_index(Path::new("/project/prisma"), "*.prisma", &index),
783 vec![PathBuf::from("/project/prisma/schema.prisma")]
784 );
785 assert!(match_pattern_in_index(root, "missing.json", &index).is_empty());
787 }
788}