Skip to main content

fallow_core/plugins/registry/
helpers.rs

1//! Helper functions for plugin registry orchestration.
2//!
3//! Contains pattern aggregation, external plugin processing, config file discovery,
4//! config result merging, and plugin detection logic.
5
6use 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/// True when a config pattern names a source-extension config file living
20/// directly in some directory (no path separator, no leading dot, all expanded
21/// extensions are in `SOURCE_EXTENSIONS`).
22///
23/// Such patterns describe files that are already in the discovered file set, so
24/// Phase 3a's in-memory matchers can find them after a `**/` prefix is added.
25/// Callers use this to skip the corresponding filesystem fallback walk in
26/// `discover_config_files`, which is the dominant cost on large monorepos.
27#[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/// Prepare a config pattern for `globset::Glob`.
50#[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
59/// Credit the `default` export of each config file that `patterns` match.
60///
61/// The tool loads its config file and reads the default export, so the export
62/// counts as used under `includeEntryExports`. The patterns get the same
63/// `**/` prefix that config discovery adds, so a config file in a nested
64/// directory gets the credit too.
65fn 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
78/// Collect static patterns from a single plugin into the aggregated result.
79pub 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
94/// Collect a plugin's entry/used-export/class-member/config/always-used rules
95/// into the aggregate, all scoped under `pname`.
96fn 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
151/// Collect a plugin's dependency/virtual-module/alias/auto-import metadata into
152/// the aggregate.
153fn 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
196/// Resolve package.json metadata hooks for active plugins.
197pub 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
229/// Determine whether an external plugin activates against the given project.
230///
231/// Shared between `process_external_plugins` and the collision-warning
232/// helper in `registry::mod` so both paths agree on activation semantics.
233pub 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
255/// Process external plugin definitions, checking activation and aggregating patterns.
256pub 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
313/// In-memory directory listing of config-candidate files (and discovered source
314/// files), keyed by absolute directory, used to resolve plugin config patterns
315/// without re-walking the filesystem.
316///
317/// Built once from the files the discovery walk already collected, so a
318/// `discover_config_files` lookup that previously cost one filesystem stat per
319/// `(plugin, ancestor-directory, pattern)` becomes an in-memory set lookup. The
320/// walk respects `.gitignore` / `ignorePatterns` / the hidden-directory
321/// allowlist, so config discovery via this index follows the same traversal
322/// rules as source discovery (the raw filesystem path used in production mode
323/// does not); see the crate docs / CHANGELOG for that deliberate refinement.
324pub struct ConfigCandidateIndex {
325    dirs: FxHashMap<PathBuf, FxHashSet<OsString>>,
326}
327
328impl ConfigCandidateIndex {
329    /// Build the index from absolute file paths (discovered source files unioned
330    /// with non-source config candidates). Files with no parent or no file name
331    /// are skipped.
332    #[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    /// Whether the discovery walk collected the file at `path`.
346    #[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    /// Whether the directory `dir` contains a file named `name`, per the files
355    /// the discovery walk collected. Used by file-based plugin activation to
356    /// avoid a per-directory filesystem `read` probe.
357    #[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    /// Whether any directory at or below `root` contains a file named `name`,
363    /// per the files the discovery walk collected. Lets a plugin activate from
364    /// a sentinel file nested anywhere under `root` (e.g. a `.env.schema` in a
365    /// workspace subdirectory) without a recursive filesystem walk.
366    #[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    /// Whether any directory at or below `root` contains a file whose name
374    /// matches `matcher`, per the files the discovery walk collected. The glob
375    /// analogue of [`Self::any_descendant_contains`], used to activate a plugin
376    /// from a wildcard config filename (e.g. `tsconfig.*.json`) nested anywhere
377    /// under `root` without a recursive filesystem walk.
378    #[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    /// The file names that the discovery walk collected in the directory `dir`.
390    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
409/// Discover config files for plugins that were not matched against the
410/// discovered source set.
411///
412/// This intentionally probes only known search roots instead of recursively
413/// globbing the whole repository tree. Large monorepos often contain enormous
414/// `node_modules` directories, and a full `**/project.json` walk becomes
415/// pathological there. Callers should therefore pass a focused root list such
416/// as the repo root, workspace roots, and ancestors of discovered source files.
417///
418/// When `candidate_index` is `Some` (the non-production fast path), patterns are
419/// resolved against the in-memory directory index the discovery walk already
420/// built, avoiding one filesystem stat per `(plugin, root, pattern)`. When it is
421/// `None` (production mode), the filesystem is probed directly.
422///
423/// When `production_mode` is `false`, source-extension root-anchored patterns
424/// (e.g., `webpack.config.{ts,js,mjs,cjs}`) are skipped because Phase 3a's
425/// `**/`-prefixed matcher already finds them in the discovered source file
426/// set. In production mode, the file walker excludes `*.config.*` and dotfile
427/// configs, so the FS walk is still required to keep the discovery correct.
428pub 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        // A resolved plugin still probes the patterns that source discovery
439        // cannot index, such as `build/webpack.prod.js`: Phase 3a never saw
440        // those files, so a root config found there does not cover them. The
441        // `seen` set below removes a duplicate hit.
442        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                    // A pattern under a non-allowlisted hidden directory
463                    // (e.g. `.config/prisma.ts`) is never descended by the
464                    // discovery walk, so it cannot be in the in-memory index;
465                    // probe the filesystem for those few patterns even on the
466                    // fast path so they stay discoverable.
467                    Some(index) if !pattern_needs_filesystem(&expanded) => {
468                        match_pattern_in_index(root, &expanded, index)
469                    }
470                    _ => discover_pattern_matches(root, &expanded),
471                })
472                // Phase 3a already read an indexed file of a resolved plugin,
473                // such as a config in a plugin's discovery hidden directory.
474                .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
496/// True when `pattern` has a directory component (any component before the
497/// basename) that the discovery walk does not index: a hidden directory NOT on
498/// the walk's traversal allowlist, or a directory that a built-in ignore
499/// pattern excludes, such as `build`. The in-memory candidate index cannot
500/// contain files under them, so the filesystem probe is required to keep those
501/// configs (e.g. `.config/prisma.ts`, `build/webpack.prod.js`) discoverable.
502pub(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; // the basename is not a directory component
508        }
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
525/// Whether a built-in ignore pattern of the form `**/<name>/**` excludes a
526/// directory of this name from source discovery.
527fn 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
538/// In-memory equivalent of [`discover_pattern_matches`], resolving `pattern`
539/// against `index` instead of the filesystem. Mirrors that function's structure
540/// arm-for-arm (plain path, `**/` strip, parent/glob split) so the two produce
541/// identical hits for any file the index contains.
542fn 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
638/// Validate the user-supplied exclude regexes attached to a `PathRule`.
639///
640/// The originating plugin and source config file are surfaced so users can
641/// locate every typo in one config-load error.
642fn 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
681/// Merge a `PluginResult` from config parsing into the aggregated result.
682///
683/// `config_path` is the source config file the plugin parsed (when known).
684/// It is only used to enrich config-load errors so users can find their typo.
685/// Tests and inline package.json fallbacks may pass `None`.
686pub 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
719/// Merge the entry patterns of one config result, with the replace and
720/// accumulate semantics of the plugin.
721fn 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            // An earlier config of the plugin that did not replace (a Vitest
732            // config below the root) keeps its patterns. Only the static
733            // defaults go.
734            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
755/// Merge a validated `PluginResult`'s payload fields into the aggregate, applying
756/// the per-plugin `replace_*` semantics for entry patterns, used-export rules,
757/// and path aliases.
758fn 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
849/// Check if a plugin already has a config file matched against discovered files.
850pub 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
864/// Check if a `PluginDetection` condition is satisfied.
865pub 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        // `.config` is a hidden directory not on the walk's allowlist, so a
903        // config under it is never in the in-memory index and must be probed.
904        assert!(pattern_needs_filesystem(".config/prisma.ts"));
905        // Plain, nested-non-hidden, dotfile-basename, `**/`, and allowlisted
906        // hidden-dir (`.storybook`) patterns all resolve from the index.
907        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        // A directory that a built-in ignore pattern excludes is not indexed.
914        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        // Plain basename resolved at the project root and at a nested root.
929        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        // Nested non-glob, `**/` strip, and a basename glob.
938        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        // A pattern present in no indexed directory yields nothing.
951        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        // `.*` names hidden directories. The `glob` crate also lets it match
962        // `..`, so this pattern found the marker in the parent directory.
963        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}