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
146 .framework_static_dir_mappings
147 .extend(plugin.static_dir_mappings(root));
148 result.auto_imports.extend(plugin.auto_imports(root));
149 result
150 .provided_dependencies
151 .extend(plugin.provided_dependencies());
152}
153
154pub fn process_package_json_metadata(
156 active: &[&dyn Plugin],
157 pkg: &PackageJson,
158 root: &Path,
159 result: &mut AggregatedPluginResult,
160 regex_errors: &mut Vec<PluginRegexValidationError>,
161) {
162 for plugin in active {
163 let package_referenced = plugin.package_json_referenced_dependencies(pkg, root);
164 if !package_referenced.is_empty() {
165 let pkg_path = root.join("package.json");
166 result.package_referenced_dependencies.extend(
167 package_referenced
168 .into_iter()
169 .map(|dep| (pkg_path.clone(), dep)),
170 );
171 }
172 let plugin_result = plugin.resolve_package_json(pkg, root);
173 if plugin_result.is_empty() {
174 continue;
175 }
176 tracing::debug!(
177 plugin = plugin.name(),
178 deps = plugin_result.referenced_dependencies.len(),
179 "resolved package.json metadata"
180 );
181 if let Err(mut errors) = process_config_result(plugin.name(), plugin_result, result, None) {
182 regex_errors.append(&mut errors);
183 }
184 }
185}
186
187pub fn is_external_plugin_active(
192 ext: &ExternalPluginDef,
193 all_deps: &[String],
194 root: &Path,
195 discovered_files: &[PathBuf],
196) -> bool {
197 if let Some(detection) = &ext.detection {
198 let all_dep_refs: Vec<&str> = all_deps.iter().map(String::as_str).collect();
199 check_plugin_detection(detection, &all_dep_refs, root, discovered_files)
200 } else if !ext.enablers.is_empty() {
201 ext.enablers.iter().any(|enabler| {
202 if enabler.ends_with('/') {
203 all_deps.iter().any(|d| d.starts_with(enabler))
204 } else {
205 all_deps.iter().any(|d| d == enabler)
206 }
207 })
208 } else {
209 false
210 }
211}
212
213pub fn process_external_plugins(
215 external_plugins: &[ExternalPluginDef],
216 all_deps: &[String],
217 root: &Path,
218 discovered_files: &[PathBuf],
219 result: &mut AggregatedPluginResult,
220) {
221 for ext in external_plugins {
222 let is_active = is_external_plugin_active(ext, all_deps, root, discovered_files);
223 if is_active {
224 result.active_plugins.push(ext.name.clone());
225 result
226 .entry_point_roles
227 .insert(ext.name.clone(), ext.entry_point_role);
228 result.entry_patterns.extend(
229 ext.entry_points
230 .iter()
231 .map(|p| (PathRule::new(p.clone()), ext.name.clone())),
232 );
233 if !ext.manifest_entries.is_empty() {
234 result.entry_patterns.extend(
235 crate::plugins::manifest_entries::evaluate_manifest_entries(ext, root)
236 .into_iter()
237 .map(|rule| (rule, ext.name.clone())),
238 );
239 }
240 result.config_patterns.extend(ext.config_patterns.clone());
241 result.always_used.extend(
242 ext.config_patterns
243 .iter()
244 .chain(ext.always_used.iter())
245 .map(|p| (p.clone(), ext.name.clone())),
246 );
247 result
248 .tooling_dependencies
249 .extend(ext.tooling_dependencies.clone());
250 for ue in &ext.used_exports {
251 result.used_exports.push(PluginUsedExportRule::new(
252 ext.name.clone(),
253 UsedExportRule::new(ue.pattern.clone(), ue.exports.clone()),
254 ));
255 }
256 result
257 .used_class_members
258 .extend(ext.used_class_members.iter().cloned());
259 }
260 }
261}
262
263pub struct ConfigCandidateIndex {
275 dirs: FxHashMap<PathBuf, FxHashSet<OsString>>,
276}
277
278impl ConfigCandidateIndex {
279 #[must_use]
283 pub(crate) fn build<'a>(paths: impl IntoIterator<Item = &'a Path>) -> Self {
284 let mut dirs: FxHashMap<PathBuf, FxHashSet<OsString>> = FxHashMap::default();
285 for path in paths {
286 if let (Some(parent), Some(name)) = (path.parent(), path.file_name()) {
287 dirs.entry(parent.to_path_buf())
288 .or_default()
289 .insert(name.to_os_string());
290 }
291 }
292 Self { dirs }
293 }
294
295 #[must_use]
299 pub(crate) fn dir_contains(&self, dir: &Path, name: &OsStr) -> bool {
300 self.dirs.get(dir).is_some_and(|names| names.contains(name))
301 }
302
303 #[must_use]
308 pub(crate) fn any_descendant_contains(&self, root: &Path, name: &OsStr) -> bool {
309 self.dirs
310 .iter()
311 .any(|(dir, names)| dir.starts_with(root) && names.contains(name))
312 }
313
314 #[must_use]
320 pub(crate) fn any_descendant_matches(
321 &self,
322 root: &Path,
323 matcher: &globset::GlobMatcher,
324 ) -> bool {
325 self.dirs.iter().any(|(dir, names)| {
326 dir.starts_with(root) && names.iter().any(|name| matcher.is_match(Path::new(name)))
327 })
328 }
329
330 fn glob_matches_in_dir(&self, dir: &Path, matcher: &globset::GlobMatcher) -> Vec<PathBuf> {
331 self.dirs.get(dir).map_or_else(Vec::new, |names| {
332 names
333 .iter()
334 .filter(|name| matcher.is_match(Path::new(name)))
335 .map(|name| dir.join(name))
336 .collect()
337 })
338 }
339}
340
341pub fn discover_config_files<'a>(
361 config_matchers: &[(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
362 resolved_plugins: &FxHashSet<&str>,
363 roots: &[&Path],
364 production_mode: bool,
365 candidate_index: Option<&ConfigCandidateIndex>,
366) -> Vec<(PathBuf, &'a dyn Plugin)> {
367 use rayon::prelude::*;
368 let mut pending: Vec<(&'a dyn Plugin, &Path, String)> = Vec::new();
369 for (plugin, _) in config_matchers {
370 if resolved_plugins.contains(plugin.name()) {
371 continue;
372 }
373 for root in roots {
374 for pat in plugin.config_patterns() {
375 if !production_mode && is_source_ext_root_pattern(pat) {
376 continue;
377 }
378 pending.push((*plugin, *root, pat.to_string()));
379 }
380 }
381 }
382
383 let hits: Vec<(PathBuf, &'a dyn Plugin)> = pending
384 .par_iter()
385 .flat_map_iter(|(plugin, root, pat)| {
386 expand_brace_pattern(pat)
387 .into_iter()
388 .flat_map(|expanded| match candidate_index {
389 Some(index) if !pattern_needs_filesystem(&expanded) => {
395 match_pattern_in_index(root, &expanded, index)
396 }
397 _ => discover_pattern_matches(root, &expanded),
398 })
399 .map(move |path| (path, *plugin))
400 .collect::<Vec<_>>()
401 })
402 .collect();
403
404 let mut seen: FxHashSet<(PathBuf, &'a str)> = FxHashSet::default();
405 let mut config_files: Vec<(PathBuf, &'a dyn Plugin)> = Vec::with_capacity(hits.len());
406 for (path, plugin) in hits {
407 if seen.insert((path.clone(), plugin.name())) {
408 config_files.push((path, plugin));
409 }
410 }
411 config_files
412}
413
414fn pattern_has_glob(pattern: &str) -> bool {
415 pattern.contains('*') || pattern.contains('?') || pattern.contains('[')
416}
417
418fn pattern_needs_filesystem(pattern: &str) -> bool {
424 let mut components = pattern.split('/').peekable();
425 let mut needs_fs = false;
426 while let Some(component) = components.next() {
427 if components.peek().is_none() {
428 break; }
430 if component.starts_with('.')
431 && component != "."
432 && component != ".."
433 && !crate::discover::is_allowed_hidden_dir(OsStr::new(component))
434 {
435 needs_fs = true;
436 break;
437 }
438 }
439 needs_fs
440}
441
442fn match_pattern_in_index(
447 root: &Path,
448 pattern: &str,
449 index: &ConfigCandidateIndex,
450) -> Vec<PathBuf> {
451 if !pattern_has_glob(pattern) {
452 let path = root.join(pattern);
453 return match (path.parent(), path.file_name()) {
454 (Some(dir), Some(name)) if index.dir_contains(dir, name) => vec![path],
455 _ => Vec::new(),
456 };
457 }
458
459 if let Some(stripped) = pattern.strip_prefix("**/") {
460 return match_pattern_in_index(root, stripped, index);
461 }
462
463 let (dir, file_pattern) = match pattern.rsplit_once('/') {
464 Some((parent, file_pattern)) if !pattern_has_glob(parent) => {
465 (root.join(parent), file_pattern)
466 }
467 Some(_) => return Vec::new(),
468 None => (root.to_path_buf(), pattern),
469 };
470
471 let Ok(matcher) = globset::Glob::new(file_pattern).map(|g| g.compile_matcher()) else {
472 return Vec::new();
473 };
474 index.glob_matches_in_dir(&dir, &matcher)
475}
476
477fn discover_pattern_matches(root: &Path, pattern: &str) -> Vec<PathBuf> {
478 if !pattern_has_glob(pattern) {
479 let path = root.join(pattern);
480 return if path.is_file() {
481 vec![path]
482 } else {
483 Vec::new()
484 };
485 }
486
487 if let Some(stripped) = pattern.strip_prefix("**/") {
488 return discover_pattern_matches(root, stripped);
489 }
490
491 let (dir, file_pattern) = match pattern.rsplit_once('/') {
492 Some((parent, file_pattern)) if !pattern_has_glob(parent) => {
493 (root.join(parent), file_pattern)
494 }
495 Some(_) => return Vec::new(),
496 None => (root.to_path_buf(), pattern),
497 };
498
499 scan_dir_for_pattern(&dir, file_pattern)
500}
501
502fn scan_dir_for_pattern(dir: &Path, file_pattern: &str) -> Vec<PathBuf> {
503 let Ok(matcher) = globset::Glob::new(file_pattern).map(|g| g.compile_matcher()) else {
504 return Vec::new();
505 };
506 let Ok(entries) = std::fs::read_dir(dir) else {
507 return Vec::new();
508 };
509
510 entries
511 .filter_map(Result::ok)
512 .map(|entry| entry.path())
513 .filter(|path| path.is_file())
514 .filter(|path| {
515 path.file_name()
516 .is_some_and(|name| matcher.is_match(std::path::Path::new(name)))
517 })
518 .collect()
519}
520
521fn expand_brace_pattern(pattern: &str) -> Vec<String> {
522 let Some(open) = pattern.find('{') else {
523 return vec![pattern.to_string()];
524 };
525 let Some(close_rel) = pattern[open + 1..].find('}') else {
526 return vec![pattern.to_string()];
527 };
528 let close = open + 1 + close_rel;
529
530 let prefix = &pattern[..open];
531 let suffix = &pattern[close + 1..];
532 let inner = &pattern[open + 1..close];
533 let mut expanded = Vec::new();
534 for option in inner.split(',') {
535 for tail in expand_brace_pattern(suffix) {
536 expanded.push(format!("{prefix}{option}{tail}"));
537 }
538 }
539 expanded
540}
541
542fn collect_path_rule_regex_errors(
547 rule: &crate::plugins::PathRule,
548 plugin_name: &str,
549 config_path: Option<&Path>,
550 rule_kind: &'static str,
551 errors: &mut Vec<PluginRegexValidationError>,
552) {
553 for pattern in &rule.exclude_regexes {
554 if let Err(source) = regex::Regex::new(pattern) {
555 errors.push(PluginRegexValidationError::new(
556 PluginRegexValidationErrorInput {
557 plugin_name,
558 config_path,
559 rule_kind,
560 field: "exclude_regexes",
561 rule_pattern: &rule.pattern,
562 regex_pattern: pattern,
563 source: &source,
564 },
565 ));
566 }
567 }
568 for pattern in &rule.exclude_segment_regexes {
569 if let Err(source) = regex::Regex::new(pattern) {
570 errors.push(PluginRegexValidationError::new(
571 PluginRegexValidationErrorInput {
572 plugin_name,
573 config_path,
574 rule_kind,
575 field: "exclude_segment_regexes",
576 rule_pattern: &rule.pattern,
577 regex_pattern: pattern,
578 source: &source,
579 },
580 ));
581 }
582 }
583}
584
585pub fn process_config_result(
591 plugin_name: &str,
592 plugin_result: PluginResult,
593 result: &mut AggregatedPluginResult,
594 config_path: Option<&Path>,
595) -> Result<(), Vec<PluginRegexValidationError>> {
596 let mut regex_errors = Vec::new();
597
598 for rule in &plugin_result.entry_patterns {
599 collect_path_rule_regex_errors(
600 rule,
601 plugin_name,
602 config_path,
603 "entry_patterns[]",
604 &mut regex_errors,
605 );
606 }
607 for rule in &plugin_result.used_exports {
608 collect_path_rule_regex_errors(
609 &rule.path,
610 plugin_name,
611 config_path,
612 "used_exports[].path",
613 &mut regex_errors,
614 );
615 }
616 if !regex_errors.is_empty() {
617 return Err(regex_errors);
618 }
619 merge_plugin_result_fields(plugin_name, plugin_result, result);
620 Ok(())
621}
622
623fn merge_plugin_result_fields(
627 pname: &str,
628 plugin_result: PluginResult,
629 result: &mut AggregatedPluginResult,
630) {
631 if plugin_result.replace_entry_patterns && !plugin_result.entry_patterns.is_empty() {
632 result.entry_patterns.retain(|(_, name)| name != pname);
633 }
634 if plugin_result.replace_used_export_rules && !plugin_result.used_exports.is_empty() {
635 result.used_exports.retain(|rule| rule.plugin_name != pname);
636 }
637 result.entry_patterns.extend(
638 plugin_result
639 .entry_patterns
640 .into_iter()
641 .map(|rule| (rule, pname.to_string())),
642 );
643 result.used_exports.extend(
644 plugin_result
645 .used_exports
646 .into_iter()
647 .map(|rule| PluginUsedExportRule::new(pname.to_string(), rule)),
648 );
649 result
650 .used_class_members
651 .extend(plugin_result.used_class_members);
652 result
653 .referenced_dependencies
654 .extend(plugin_result.referenced_dependencies);
655 result.discovered_always_used.extend(
656 plugin_result
657 .always_used_files
658 .into_iter()
659 .map(|p| (p, pname.to_string())),
660 );
661 for (prefix, replacement) in plugin_result.path_aliases {
662 result
663 .path_aliases
664 .retain(|(existing_prefix, _)| existing_prefix != &prefix);
665 result.path_aliases.push((prefix, replacement));
666 }
667 result.setup_files.extend(
668 plugin_result
669 .setup_files
670 .into_iter()
671 .map(|p| (p, pname.to_string())),
672 );
673 result.fixture_patterns.extend(
674 plugin_result
675 .fixture_patterns
676 .into_iter()
677 .map(|p| (p, pname.to_string())),
678 );
679 result
680 .scss_include_paths
681 .extend(plugin_result.scss_include_paths);
682 result
683 .static_dir_mappings
684 .extend(plugin_result.static_dir_mappings);
685 result
686 .framework_static_dir_mappings
687 .extend(plugin_result.framework_static_dir_mappings);
688 result
689 .provided_dependencies
690 .extend(plugin_result.provided_dependencies);
691}
692
693pub fn check_has_config_file(
695 plugin: &dyn Plugin,
696 config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
697 relative_files: &[(PathBuf, String)],
698) -> bool {
699 !plugin.config_patterns().is_empty()
700 && config_matchers.iter().any(|(p, matchers)| {
701 p.name() == plugin.name()
702 && relative_files
703 .iter()
704 .any(|(_, rel)| matchers.iter().any(|m| m.is_match(rel.as_str())))
705 })
706}
707
708pub fn check_plugin_detection(
710 detection: &PluginDetection,
711 all_deps: &[&str],
712 root: &Path,
713 discovered_files: &[PathBuf],
714) -> bool {
715 match detection {
716 PluginDetection::Dependency { package } => all_deps.iter().any(|d| *d == package),
717 PluginDetection::FileExists { pattern } => {
718 if let Ok(matcher) = globset::Glob::new(pattern).map(|g| g.compile_matcher()) {
719 for file in discovered_files {
720 let relative = file.strip_prefix(root).unwrap_or(file);
721 if matcher.is_match(relative) {
722 return true;
723 }
724 }
725 }
726 let full_pattern = root.join(pattern).to_string_lossy().to_string();
727 glob::glob(&full_pattern)
728 .ok()
729 .is_some_and(|mut g| g.next().is_some())
730 }
731 PluginDetection::All { conditions } => conditions
732 .iter()
733 .all(|c| check_plugin_detection(c, all_deps, root, discovered_files)),
734 PluginDetection::Any { conditions } => conditions
735 .iter()
736 .any(|c| check_plugin_detection(c, all_deps, root, discovered_files)),
737 }
738}
739
740#[cfg(test)]
741mod tests {
742 use super::*;
743
744 #[test]
745 fn pattern_needs_filesystem_only_for_non_allowlisted_hidden_dirs() {
746 assert!(pattern_needs_filesystem(".config/prisma.ts"));
749 assert!(!pattern_needs_filesystem("tsconfig.json"));
752 assert!(!pattern_needs_filesystem("prisma/schema.prisma"));
753 assert!(!pattern_needs_filesystem(".eslintrc.json"));
754 assert!(!pattern_needs_filesystem("**/project.json"));
755 assert!(!pattern_needs_filesystem(".storybook/main.ts"));
756 assert!(!pattern_needs_filesystem("a/b/c.json"));
757 }
758
759 #[test]
760 fn config_candidate_index_matches_plain_nested_and_glob_shapes() {
761 let root = Path::new("/project");
762 let index = ConfigCandidateIndex::build([
763 Path::new("/project/tsconfig.json"),
764 Path::new("/project/packages/a/tsconfig.json"),
765 Path::new("/project/prisma/schema.prisma"),
766 Path::new("/project/src/main.ts"),
767 ]);
768
769 assert_eq!(
771 match_pattern_in_index(root, "tsconfig.json", &index),
772 vec![PathBuf::from("/project/tsconfig.json")]
773 );
774 assert_eq!(
775 match_pattern_in_index(Path::new("/project/packages/a"), "tsconfig.json", &index),
776 vec![PathBuf::from("/project/packages/a/tsconfig.json")]
777 );
778 assert_eq!(
780 match_pattern_in_index(root, "prisma/schema.prisma", &index),
781 vec![PathBuf::from("/project/prisma/schema.prisma")]
782 );
783 assert_eq!(
784 match_pattern_in_index(root, "**/tsconfig.json", &index),
785 vec![PathBuf::from("/project/tsconfig.json")]
786 );
787 assert_eq!(
788 match_pattern_in_index(Path::new("/project/prisma"), "*.prisma", &index),
789 vec![PathBuf::from("/project/prisma/schema.prisma")]
790 );
791 assert!(match_pattern_in_index(root, "missing.json", &index).is_empty());
793 }
794}