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
59fn push_config_default_exports<'a>(
66 pname: &str,
67 patterns: impl Iterator<Item = &'a str>,
68 result: &mut AggregatedPluginResult,
69) {
70 for pat in patterns {
71 result.config_export_credits.push(PluginUsedExportRule::new(
72 pname,
73 UsedExportRule::new(prepare_config_pattern(pat), ["default"]),
74 ));
75 }
76}
77
78pub fn process_static_patterns(
80 plugin: &dyn Plugin,
81 root: &Path,
82 result: &mut AggregatedPluginResult,
83) {
84 let pname = plugin.name().to_string();
85 result.active_plugins.push(pname.clone());
86 result
87 .entry_point_roles
88 .insert(pname.clone(), plugin.entry_point_role());
89
90 collect_static_plugin_rules(plugin, &pname, result);
91 collect_static_plugin_metadata(plugin, root, result);
92}
93
94fn collect_static_plugin_rules(
97 plugin: &dyn Plugin,
98 pname: &str,
99 result: &mut AggregatedPluginResult,
100) {
101 for rule in plugin.entry_pattern_rules() {
102 result.entry_patterns.push((rule, pname.to_string()));
103 }
104 for pat in plugin.config_patterns() {
105 result.config_patterns.push((*pat).to_string());
106 }
107 let opt_out = plugin.config_default_export_opt_out();
108 push_config_default_exports(
109 pname,
110 plugin
111 .config_patterns()
112 .iter()
113 .copied()
114 .filter(|pat| !opt_out.contains(pat)),
115 result,
116 );
117 for (pat, exports) in plugin.config_file_exports() {
118 result.config_export_credits.push(PluginUsedExportRule::new(
119 pname,
120 UsedExportRule::from_static(pat, exports),
121 ));
122 }
123 for pat in plugin.always_used() {
124 result
125 .always_used
126 .push(((*pat).to_string(), pname.to_string()));
127 }
128 for rule in plugin.used_export_rules() {
129 result
130 .used_exports
131 .push(PluginUsedExportRule::new(pname.to_string(), rule));
132 }
133 for member in plugin.used_class_members() {
134 result
135 .used_class_members
136 .push(UsedClassMemberRule::from(*member));
137 }
138 for rule in plugin.used_class_member_rules() {
139 result.used_class_members.push(rule);
140 }
141 result
142 .framework_class_member_contracts
143 .extend(plugin.framework_class_member_contracts());
144 for pat in plugin.fixture_glob_patterns() {
145 result
146 .fixture_patterns
147 .push(((*pat).to_string(), pname.to_string()));
148 }
149}
150
151fn collect_static_plugin_metadata(
154 plugin: &dyn Plugin,
155 root: &Path,
156 result: &mut AggregatedPluginResult,
157) {
158 for dep in plugin.tooling_dependencies() {
159 result.tooling_dependencies.push((*dep).to_string());
160 }
161 for prefix in plugin.virtual_module_prefixes() {
162 result.virtual_module_prefixes.push((*prefix).to_string());
163 }
164 for suffix in plugin.virtual_package_suffixes() {
165 result.virtual_package_suffixes.push((*suffix).to_string());
166 }
167 for pattern in plugin.generated_import_patterns() {
168 result
169 .generated_import_patterns
170 .push((*pattern).to_string());
171 }
172 for prefix in plugin.generated_type_import_prefixes() {
173 result
174 .generated_type_import_prefixes
175 .push((*prefix).to_string());
176 }
177 for (prefix, replacement) in plugin.path_aliases(root) {
178 result.path_aliases.push((prefix.to_string(), replacement));
179 }
180 result
181 .framework_static_dir_mappings
182 .extend(plugin.static_dir_mappings(root));
183 result
184 .auto_imports
185 .extend(plugin.auto_imports(root).into_iter().map(|mut rule| {
186 if rule.scope.is_empty() {
187 rule.scope.push(root.to_path_buf());
188 }
189 rule
190 }));
191 result
192 .provided_dependencies
193 .extend(plugin.provided_dependencies());
194}
195
196pub fn process_package_json_metadata(
198 active: &[&dyn Plugin],
199 pkg: &PackageJson,
200 root: &Path,
201 result: &mut AggregatedPluginResult,
202 regex_errors: &mut Vec<PluginRegexValidationError>,
203) {
204 for plugin in active {
205 let package_referenced = plugin.package_json_referenced_dependencies(pkg, root);
206 if !package_referenced.is_empty() {
207 let pkg_path = root.join("package.json");
208 result.package_referenced_dependencies.extend(
209 package_referenced
210 .into_iter()
211 .map(|dep| (pkg_path.clone(), dep)),
212 );
213 }
214 let plugin_result = plugin.resolve_package_json(pkg, root);
215 if plugin_result.is_empty() {
216 continue;
217 }
218 tracing::debug!(
219 plugin = plugin.name(),
220 deps = plugin_result.referenced_dependencies.len(),
221 "resolved package.json metadata"
222 );
223 if let Err(mut errors) = process_config_result(plugin.name(), plugin_result, result, None) {
224 regex_errors.append(&mut errors);
225 }
226 }
227}
228
229pub fn is_external_plugin_active(
234 ext: &ExternalPluginDef,
235 all_deps: &[String],
236 root: &Path,
237 discovered_files: &[PathBuf],
238) -> bool {
239 if let Some(detection) = &ext.detection {
240 let all_dep_refs: Vec<&str> = all_deps.iter().map(String::as_str).collect();
241 check_plugin_detection(detection, &all_dep_refs, root, discovered_files)
242 } else if !ext.enablers.is_empty() {
243 ext.enablers.iter().any(|enabler| {
244 if enabler.ends_with('/') {
245 all_deps.iter().any(|d| d.starts_with(enabler))
246 } else {
247 all_deps.iter().any(|d| d == enabler)
248 }
249 })
250 } else {
251 false
252 }
253}
254
255pub fn process_external_plugins(
257 external_plugins: &[ExternalPluginDef],
258 all_deps: &[String],
259 root: &Path,
260 discovered_files: &[PathBuf],
261 result: &mut AggregatedPluginResult,
262) {
263 for ext in external_plugins {
264 let is_active = is_external_plugin_active(ext, all_deps, root, discovered_files);
265 if is_active {
266 result.active_plugins.push(ext.name.clone());
267 result
268 .entry_point_roles
269 .insert(ext.name.clone(), ext.entry_point_role);
270 result.entry_patterns.extend(
271 ext.entry_points
272 .iter()
273 .map(|p| (PathRule::new(p.clone()), ext.name.clone())),
274 );
275 if !ext.manifest_entries.is_empty() {
276 result.entry_patterns.extend(
277 crate::plugins::manifest_entries::evaluate_manifest_entries(ext, root)
278 .into_iter()
279 .map(|rule| (rule, ext.name.clone())),
280 );
281 }
282 result.config_patterns.extend(ext.config_patterns.clone());
283 push_config_default_exports(
284 &ext.name,
285 ext.config_patterns
286 .iter()
287 .filter(|pat| !ext.config_default_export_opt_out.contains(pat))
288 .map(String::as_str),
289 result,
290 );
291 result.always_used.extend(
292 ext.config_patterns
293 .iter()
294 .chain(ext.always_used.iter())
295 .map(|p| (p.clone(), ext.name.clone())),
296 );
297 result
298 .tooling_dependencies
299 .extend(ext.tooling_dependencies.clone());
300 for ue in &ext.used_exports {
301 result.used_exports.push(PluginUsedExportRule::new(
302 ext.name.clone(),
303 UsedExportRule::new(ue.pattern.clone(), ue.exports.clone()),
304 ));
305 }
306 result
307 .used_class_members
308 .extend(ext.used_class_members.iter().cloned());
309 }
310 }
311}
312
313pub struct ConfigCandidateIndex {
325 dirs: FxHashMap<PathBuf, FxHashSet<OsString>>,
326}
327
328impl ConfigCandidateIndex {
329 #[must_use]
333 pub(crate) fn build<'a>(paths: impl IntoIterator<Item = &'a Path>) -> Self {
334 let mut dirs: FxHashMap<PathBuf, FxHashSet<OsString>> = FxHashMap::default();
335 for path in paths {
336 if let (Some(parent), Some(name)) = (path.parent(), path.file_name()) {
337 dirs.entry(parent.to_path_buf())
338 .or_default()
339 .insert(name.to_os_string());
340 }
341 }
342 Self { dirs }
343 }
344
345 #[must_use]
347 pub(crate) fn contains_file(&self, path: &Path) -> bool {
348 match (path.parent(), path.file_name()) {
349 (Some(dir), Some(name)) => self.dir_contains(dir, name),
350 _ => false,
351 }
352 }
353
354 #[must_use]
358 pub(crate) fn dir_contains(&self, dir: &Path, name: &OsStr) -> bool {
359 self.dirs.get(dir).is_some_and(|names| names.contains(name))
360 }
361
362 #[must_use]
367 pub(crate) fn any_descendant_contains(&self, root: &Path, name: &OsStr) -> bool {
368 self.dirs
369 .iter()
370 .any(|(dir, names)| dir.starts_with(root) && names.contains(name))
371 }
372
373 #[must_use]
379 pub(crate) fn any_descendant_matches(
380 &self,
381 root: &Path,
382 matcher: &globset::GlobMatcher,
383 ) -> bool {
384 self.dirs.iter().any(|(dir, names)| {
385 dir.starts_with(root) && names.iter().any(|name| matcher.is_match(Path::new(name)))
386 })
387 }
388
389 pub(super) fn names_in_dir(&self, dir: &Path) -> Option<&FxHashSet<OsString>> {
391 self.dirs.get(dir)
392 }
393
394 pub(super) fn glob_matches_in_dir(
395 &self,
396 dir: &Path,
397 matcher: &globset::GlobMatcher,
398 ) -> Vec<PathBuf> {
399 self.dirs.get(dir).map_or_else(Vec::new, |names| {
400 names
401 .iter()
402 .filter(|name| matcher.is_match(Path::new(name)))
403 .map(|name| dir.join(name))
404 .collect()
405 })
406 }
407}
408
409pub fn discover_config_files<'a>(
429 config_matchers: &[(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
430 resolved_plugins: &FxHashSet<&str>,
431 roots: &[&Path],
432 production_mode: bool,
433 candidate_index: Option<&ConfigCandidateIndex>,
434) -> Vec<(PathBuf, &'a dyn Plugin)> {
435 use rayon::prelude::*;
436 let mut pending: Vec<(&'a dyn Plugin, &Path, String, bool)> = Vec::new();
437 for (plugin, _) in config_matchers {
438 let resolved = resolved_plugins.contains(plugin.name());
443 for root in roots {
444 for pat in plugin.config_patterns() {
445 if !production_mode && is_source_ext_root_pattern(pat) {
446 continue;
447 }
448 if resolved && !pattern_needs_filesystem(pat) {
449 continue;
450 }
451 pending.push((*plugin, *root, pat.to_string(), resolved));
452 }
453 }
454 }
455
456 let hits: Vec<(PathBuf, &'a dyn Plugin)> = pending
457 .par_iter()
458 .flat_map_iter(|(plugin, root, pat, resolved)| {
459 expand_brace_pattern(pat)
460 .into_iter()
461 .flat_map(|expanded| match candidate_index {
462 Some(index) if !pattern_needs_filesystem(&expanded) => {
468 match_pattern_in_index(root, &expanded, index)
469 }
470 _ => discover_pattern_matches(root, &expanded),
471 })
472 .filter(|path| {
475 !*resolved || !candidate_index.is_some_and(|index| index.contains_file(path))
476 })
477 .map(move |path| (path, *plugin))
478 .collect::<Vec<_>>()
479 })
480 .collect();
481
482 let mut seen: FxHashSet<(PathBuf, &'a str)> = FxHashSet::default();
483 let mut config_files: Vec<(PathBuf, &'a dyn Plugin)> = Vec::with_capacity(hits.len());
484 for (path, plugin) in hits {
485 if seen.insert((path.clone(), plugin.name())) {
486 config_files.push((path, plugin));
487 }
488 }
489 config_files
490}
491
492fn pattern_has_glob(pattern: &str) -> bool {
493 pattern.contains('*') || pattern.contains('?') || pattern.contains('[')
494}
495
496pub(super) fn pattern_needs_filesystem(pattern: &str) -> bool {
503 let mut components = pattern.split('/').peekable();
504 let mut needs_fs = false;
505 while let Some(component) = components.next() {
506 if components.peek().is_none() {
507 break; }
509 if component.starts_with('.')
510 && component != "."
511 && component != ".."
512 && !crate::discover::is_allowed_hidden_dir(OsStr::new(component))
513 {
514 needs_fs = true;
515 break;
516 }
517 if is_default_ignored_directory(component) {
518 needs_fs = true;
519 break;
520 }
521 }
522 needs_fs
523}
524
525fn is_default_ignored_directory(name: &str) -> bool {
528 fallow_config::DEFAULT_IGNORE_PATTERNS
529 .iter()
530 .any(|pattern| {
531 pattern
532 .strip_prefix("**/")
533 .and_then(|rest| rest.strip_suffix("/**"))
534 .is_some_and(|directory| directory == name)
535 })
536}
537
538fn match_pattern_in_index(
543 root: &Path,
544 pattern: &str,
545 index: &ConfigCandidateIndex,
546) -> Vec<PathBuf> {
547 if !pattern_has_glob(pattern) {
548 let path = root.join(pattern);
549 return match (path.parent(), path.file_name()) {
550 (Some(dir), Some(name)) if index.dir_contains(dir, name) => vec![path],
551 _ => Vec::new(),
552 };
553 }
554
555 if let Some(stripped) = pattern.strip_prefix("**/") {
556 return match_pattern_in_index(root, stripped, index);
557 }
558
559 let (dir, file_pattern) = match pattern.rsplit_once('/') {
560 Some((parent, file_pattern)) if !pattern_has_glob(parent) => {
561 (root.join(parent), file_pattern)
562 }
563 Some(_) => return Vec::new(),
564 None => (root.to_path_buf(), pattern),
565 };
566
567 let Ok(matcher) = globset::Glob::new(file_pattern).map(|g| g.compile_matcher()) else {
568 return Vec::new();
569 };
570 index.glob_matches_in_dir(&dir, &matcher)
571}
572
573fn discover_pattern_matches(root: &Path, pattern: &str) -> Vec<PathBuf> {
574 if !pattern_has_glob(pattern) {
575 let path = root.join(pattern);
576 return if path.is_file() {
577 vec![path]
578 } else {
579 Vec::new()
580 };
581 }
582
583 if let Some(stripped) = pattern.strip_prefix("**/") {
584 return discover_pattern_matches(root, stripped);
585 }
586
587 let (dir, file_pattern) = match pattern.rsplit_once('/') {
588 Some((parent, file_pattern)) if !pattern_has_glob(parent) => {
589 (root.join(parent), file_pattern)
590 }
591 Some(_) => return Vec::new(),
592 None => (root.to_path_buf(), pattern),
593 };
594
595 scan_dir_for_pattern(&dir, file_pattern)
596}
597
598fn scan_dir_for_pattern(dir: &Path, file_pattern: &str) -> Vec<PathBuf> {
599 let Ok(matcher) = globset::Glob::new(file_pattern).map(|g| g.compile_matcher()) else {
600 return Vec::new();
601 };
602 let Ok(entries) = std::fs::read_dir(dir) else {
603 return Vec::new();
604 };
605
606 entries
607 .filter_map(Result::ok)
608 .map(|entry| entry.path())
609 .filter(|path| path.is_file())
610 .filter(|path| {
611 path.file_name()
612 .is_some_and(|name| matcher.is_match(std::path::Path::new(name)))
613 })
614 .collect()
615}
616
617pub(super) fn expand_brace_pattern(pattern: &str) -> Vec<String> {
618 let Some(open) = pattern.find('{') else {
619 return vec![pattern.to_string()];
620 };
621 let Some(close_rel) = pattern[open + 1..].find('}') else {
622 return vec![pattern.to_string()];
623 };
624 let close = open + 1 + close_rel;
625
626 let prefix = &pattern[..open];
627 let suffix = &pattern[close + 1..];
628 let inner = &pattern[open + 1..close];
629 let mut expanded = Vec::new();
630 for option in inner.split(',') {
631 for tail in expand_brace_pattern(suffix) {
632 expanded.push(format!("{prefix}{option}{tail}"));
633 }
634 }
635 expanded
636}
637
638fn collect_path_rule_regex_errors(
643 rule: &crate::plugins::PathRule,
644 plugin_name: &str,
645 config_path: Option<&Path>,
646 rule_kind: &'static str,
647 errors: &mut Vec<PluginRegexValidationError>,
648) {
649 for pattern in &rule.exclude_regexes {
650 if let Err(source) = regex::Regex::new(pattern) {
651 errors.push(PluginRegexValidationError::new(
652 PluginRegexValidationErrorInput {
653 plugin_name,
654 config_path,
655 rule_kind,
656 field: "exclude_regexes",
657 rule_pattern: &rule.pattern,
658 regex_pattern: pattern,
659 source: &source,
660 },
661 ));
662 }
663 }
664 for pattern in &rule.exclude_segment_regexes {
665 if let Err(source) = regex::Regex::new(pattern) {
666 errors.push(PluginRegexValidationError::new(
667 PluginRegexValidationErrorInput {
668 plugin_name,
669 config_path,
670 rule_kind,
671 field: "exclude_segment_regexes",
672 rule_pattern: &rule.pattern,
673 regex_pattern: pattern,
674 source: &source,
675 },
676 ));
677 }
678 }
679}
680
681pub fn process_config_result(
687 plugin_name: &str,
688 plugin_result: PluginResult,
689 result: &mut AggregatedPluginResult,
690 config_path: Option<&Path>,
691) -> Result<(), Vec<PluginRegexValidationError>> {
692 let mut regex_errors = Vec::new();
693
694 for rule in &plugin_result.entry_patterns {
695 collect_path_rule_regex_errors(
696 rule,
697 plugin_name,
698 config_path,
699 "entry_patterns[]",
700 &mut regex_errors,
701 );
702 }
703 for rule in &plugin_result.used_exports {
704 collect_path_rule_regex_errors(
705 &rule.path,
706 plugin_name,
707 config_path,
708 "used_exports[].path",
709 &mut regex_errors,
710 );
711 }
712 if !regex_errors.is_empty() {
713 return Err(regex_errors);
714 }
715 merge_plugin_result_fields(plugin_name, plugin_result, result);
716 Ok(())
717}
718
719fn merge_entry_patterns(
722 pname: &str,
723 plugin_result: &mut PluginResult,
724 result: &mut AggregatedPluginResult,
725) {
726 if plugin_result.replace_entry_patterns && !plugin_result.entry_patterns.is_empty() {
727 let first_replacement = result.replaced_entry_plugins.insert(pname.to_string());
728 if !plugin_result.accumulate_config_entry_patterns {
729 result.entry_patterns.retain(|(_, name)| name != pname);
730 } else if first_replacement {
731 let config_patterns = &result.config_entry_patterns;
735 result.entry_patterns.retain(|(rule, name)| {
736 name != pname || config_patterns.contains(&(name.clone(), rule.pattern.clone()))
737 });
738 }
739 }
740 if plugin_result.accumulate_config_entry_patterns {
741 result.config_entry_patterns.extend(
742 plugin_result
743 .entry_patterns
744 .iter()
745 .map(|rule| (pname.to_string(), rule.pattern.clone())),
746 );
747 }
748 result.entry_patterns.extend(
749 std::mem::take(&mut plugin_result.entry_patterns)
750 .into_iter()
751 .map(|rule| (rule, pname.to_string())),
752 );
753}
754
755fn merge_plugin_result_fields(
759 pname: &str,
760 mut plugin_result: PluginResult,
761 result: &mut AggregatedPluginResult,
762) {
763 merge_entry_patterns(pname, &mut plugin_result, result);
764 if plugin_result.replace_used_export_rules && !plugin_result.used_exports.is_empty() {
765 result.used_exports.retain(|rule| rule.plugin_name != pname);
766 }
767 result.used_exports.extend(
768 plugin_result
769 .used_exports
770 .into_iter()
771 .map(|rule| PluginUsedExportRule::new(pname.to_string(), rule)),
772 );
773 result.config_export_credits.extend(
774 plugin_result
775 .entry_export_credits
776 .into_iter()
777 .map(|rule| PluginUsedExportRule::new(pname.to_string(), rule)),
778 );
779 result
780 .used_class_members
781 .extend(plugin_result.used_class_members);
782 result
783 .referenced_dependencies
784 .extend(plugin_result.referenced_dependencies);
785 result
786 .package_referenced_dependencies
787 .extend(plugin_result.package_referenced_dependencies);
788 result.discovered_always_used.extend(
789 plugin_result
790 .always_used_files
791 .into_iter()
792 .map(|p| (p, pname.to_string())),
793 );
794 for (prefix, replacement) in plugin_result.path_aliases {
795 result
796 .path_aliases
797 .retain(|(existing_prefix, _)| existing_prefix != &prefix);
798 result.path_aliases.push((prefix, replacement));
799 }
800 result.setup_files.extend(
801 plugin_result
802 .setup_files
803 .into_iter()
804 .map(|p| (p, pname.to_string())),
805 );
806 result.fixture_patterns.extend(
807 plugin_result
808 .fixture_patterns
809 .into_iter()
810 .map(|p| (p, pname.to_string())),
811 );
812 result
813 .scss_include_paths
814 .extend(plugin_result.scss_include_paths);
815 result
816 .static_dir_mappings
817 .extend(plugin_result.static_dir_mappings);
818 result
819 .framework_static_dir_mappings
820 .extend(plugin_result.framework_static_dir_mappings);
821 result
822 .provided_dependencies
823 .extend(plugin_result.provided_dependencies);
824 result
825 .federation_sources
826 .extend(plugin_result.federation_sources);
827 for rule in plugin_result.jsx_import_sources {
828 if !result.jsx_import_sources.contains(&rule) {
829 result.jsx_import_sources.push(rule);
830 }
831 }
832 for rule in plugin_result.jsx_package_credits {
833 if !result.jsx_package_credits.contains(&rule) {
834 result.jsx_package_credits.push(rule);
835 }
836 }
837 for dir in plugin_result.tsconfig_jsx_dirs {
838 if !result.tsconfig_jsx_dirs.contains(&dir) {
839 result.tsconfig_jsx_dirs.push(dir);
840 }
841 }
842 for diagnostic in plugin_result.config_diagnostics {
843 if !result.config_diagnostics.contains(&diagnostic) {
844 result.config_diagnostics.push(diagnostic);
845 }
846 }
847}
848
849pub fn check_has_config_file(
851 plugin: &dyn Plugin,
852 config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
853 relative_files: &[(PathBuf, String)],
854) -> bool {
855 !plugin.config_patterns().is_empty()
856 && config_matchers.iter().any(|(p, matchers)| {
857 p.name() == plugin.name()
858 && relative_files
859 .iter()
860 .any(|(_, rel)| matchers.iter().any(|m| m.is_match(rel.as_str())))
861 })
862}
863
864pub fn check_plugin_detection(
866 detection: &PluginDetection,
867 all_deps: &[&str],
868 root: &Path,
869 discovered_files: &[PathBuf],
870) -> bool {
871 match detection {
872 PluginDetection::Dependency { package } => all_deps.iter().any(|d| *d == package),
873 PluginDetection::FileExists { pattern } => {
874 if let Ok(matcher) =
875 fallow_config::compile_path_glob(pattern).map(|g| g.compile_matcher())
876 {
877 for file in discovered_files {
878 let relative = file.strip_prefix(root).unwrap_or(file);
879 if matcher.is_match(relative) {
880 return true;
881 }
882 }
883 }
884 fallow_config::glob_validation::expand_disk_glob(root, pattern)
885 .is_some_and(|mut paths| paths.next().is_some())
886 }
887 PluginDetection::All { conditions } => conditions
888 .iter()
889 .all(|c| check_plugin_detection(c, all_deps, root, discovered_files)),
890 PluginDetection::Any { conditions } => conditions
891 .iter()
892 .any(|c| check_plugin_detection(c, all_deps, root, discovered_files)),
893 }
894}
895
896#[cfg(test)]
897mod tests {
898 use super::*;
899
900 #[test]
901 fn pattern_needs_filesystem_only_for_non_allowlisted_hidden_dirs() {
902 assert!(pattern_needs_filesystem(".config/prisma.ts"));
905 assert!(!pattern_needs_filesystem("tsconfig.json"));
908 assert!(!pattern_needs_filesystem("prisma/schema.prisma"));
909 assert!(!pattern_needs_filesystem(".eslintrc.json"));
910 assert!(!pattern_needs_filesystem("**/project.json"));
911 assert!(!pattern_needs_filesystem(".storybook/main.ts"));
912 assert!(!pattern_needs_filesystem("a/b/c.json"));
913 assert!(pattern_needs_filesystem("build/webpack.prod.js"));
915 assert!(!pattern_needs_filesystem("config/webpack.prod.js"));
916 }
917
918 #[test]
919 fn config_candidate_index_matches_plain_nested_and_glob_shapes() {
920 let root = Path::new("/project");
921 let index = ConfigCandidateIndex::build([
922 Path::new("/project/tsconfig.json"),
923 Path::new("/project/packages/a/tsconfig.json"),
924 Path::new("/project/prisma/schema.prisma"),
925 Path::new("/project/src/main.ts"),
926 ]);
927
928 assert_eq!(
930 match_pattern_in_index(root, "tsconfig.json", &index),
931 vec![PathBuf::from("/project/tsconfig.json")]
932 );
933 assert_eq!(
934 match_pattern_in_index(Path::new("/project/packages/a"), "tsconfig.json", &index),
935 vec![PathBuf::from("/project/packages/a/tsconfig.json")]
936 );
937 assert_eq!(
939 match_pattern_in_index(root, "prisma/schema.prisma", &index),
940 vec![PathBuf::from("/project/prisma/schema.prisma")]
941 );
942 assert_eq!(
943 match_pattern_in_index(root, "**/tsconfig.json", &index),
944 vec![PathBuf::from("/project/tsconfig.json")]
945 );
946 assert_eq!(
947 match_pattern_in_index(Path::new("/project/prisma"), "*.prisma", &index),
948 vec![PathBuf::from("/project/prisma/schema.prisma")]
949 );
950 assert!(match_pattern_in_index(root, "missing.json", &index).is_empty());
952 }
953
954 #[test]
955 fn file_exists_detection_never_matches_outside_root() {
956 let base = tempfile::tempdir().expect("create temp dir");
957 let root = base.path().join("project");
958 std::fs::create_dir_all(root.join(".config")).expect("create project dirs");
959 std::fs::write(base.path().join("outside-marker.json"), "{}").expect("write marker");
960
961 for pattern in [".*/outside-marker.json", "..*/outside-marker.json"] {
964 let detection = PluginDetection::FileExists {
965 pattern: pattern.to_string(),
966 };
967 assert!(
968 !check_plugin_detection(&detection, &[], &root, &[]),
969 "{pattern} matched a file outside the project root"
970 );
971 }
972
973 std::fs::write(root.join(".config").join("outside-marker.json"), "{}")
974 .expect("write marker inside root");
975 let detection = PluginDetection::FileExists {
976 pattern: ".*/outside-marker.json".to_string(),
977 };
978 assert!(check_plugin_detection(&detection, &[], &root, &[]));
979 }
980
981 #[test]
982 fn file_exists_detection_works_under_a_root_with_glob_characters() {
983 let base = tempfile::tempdir().expect("create temp dir");
984 let root = base.path().join("app [1]");
985 std::fs::create_dir_all(root.join(".config")).expect("create project dirs");
986 std::fs::write(root.join(".config").join("tool.json"), "{}").expect("write config");
987
988 let detection = PluginDetection::FileExists {
989 pattern: ".config/*.json".to_string(),
990 };
991 assert!(check_plugin_detection(&detection, &[], &root, &[]));
992 }
993}