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