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/// Collect static patterns from a single plugin into the aggregated result.
60pub 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
75/// Collect a plugin's entry/used-export/class-member/config/always-used rules
76/// into the aggregate, all scoped under `pname`.
77fn 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
116/// Collect a plugin's dependency/virtual-module/alias/auto-import metadata into
117/// the aggregate.
118fn 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
149        .auto_imports
150        .extend(plugin.auto_imports(root).into_iter().map(|mut rule| {
151            if rule.scope.is_empty() {
152                rule.scope.push(root.to_path_buf());
153            }
154            rule
155        }));
156    result
157        .provided_dependencies
158        .extend(plugin.provided_dependencies());
159}
160
161/// Resolve package.json metadata hooks for active plugins.
162pub fn process_package_json_metadata(
163    active: &[&dyn Plugin],
164    pkg: &PackageJson,
165    root: &Path,
166    result: &mut AggregatedPluginResult,
167    regex_errors: &mut Vec<PluginRegexValidationError>,
168) {
169    for plugin in active {
170        let package_referenced = plugin.package_json_referenced_dependencies(pkg, root);
171        if !package_referenced.is_empty() {
172            let pkg_path = root.join("package.json");
173            result.package_referenced_dependencies.extend(
174                package_referenced
175                    .into_iter()
176                    .map(|dep| (pkg_path.clone(), dep)),
177            );
178        }
179        let plugin_result = plugin.resolve_package_json(pkg, root);
180        if plugin_result.is_empty() {
181            continue;
182        }
183        tracing::debug!(
184            plugin = plugin.name(),
185            deps = plugin_result.referenced_dependencies.len(),
186            "resolved package.json metadata"
187        );
188        if let Err(mut errors) = process_config_result(plugin.name(), plugin_result, result, None) {
189            regex_errors.append(&mut errors);
190        }
191    }
192}
193
194/// Determine whether an external plugin activates against the given project.
195///
196/// Shared between `process_external_plugins` and the collision-warning
197/// helper in `registry::mod` so both paths agree on activation semantics.
198pub fn is_external_plugin_active(
199    ext: &ExternalPluginDef,
200    all_deps: &[String],
201    root: &Path,
202    discovered_files: &[PathBuf],
203) -> bool {
204    if let Some(detection) = &ext.detection {
205        let all_dep_refs: Vec<&str> = all_deps.iter().map(String::as_str).collect();
206        check_plugin_detection(detection, &all_dep_refs, root, discovered_files)
207    } else if !ext.enablers.is_empty() {
208        ext.enablers.iter().any(|enabler| {
209            if enabler.ends_with('/') {
210                all_deps.iter().any(|d| d.starts_with(enabler))
211            } else {
212                all_deps.iter().any(|d| d == enabler)
213            }
214        })
215    } else {
216        false
217    }
218}
219
220/// Process external plugin definitions, checking activation and aggregating patterns.
221pub fn process_external_plugins(
222    external_plugins: &[ExternalPluginDef],
223    all_deps: &[String],
224    root: &Path,
225    discovered_files: &[PathBuf],
226    result: &mut AggregatedPluginResult,
227) {
228    for ext in external_plugins {
229        let is_active = is_external_plugin_active(ext, all_deps, root, discovered_files);
230        if is_active {
231            result.active_plugins.push(ext.name.clone());
232            result
233                .entry_point_roles
234                .insert(ext.name.clone(), ext.entry_point_role);
235            result.entry_patterns.extend(
236                ext.entry_points
237                    .iter()
238                    .map(|p| (PathRule::new(p.clone()), ext.name.clone())),
239            );
240            if !ext.manifest_entries.is_empty() {
241                result.entry_patterns.extend(
242                    crate::plugins::manifest_entries::evaluate_manifest_entries(ext, root)
243                        .into_iter()
244                        .map(|rule| (rule, ext.name.clone())),
245                );
246            }
247            result.config_patterns.extend(ext.config_patterns.clone());
248            result.always_used.extend(
249                ext.config_patterns
250                    .iter()
251                    .chain(ext.always_used.iter())
252                    .map(|p| (p.clone(), ext.name.clone())),
253            );
254            result
255                .tooling_dependencies
256                .extend(ext.tooling_dependencies.clone());
257            for ue in &ext.used_exports {
258                result.used_exports.push(PluginUsedExportRule::new(
259                    ext.name.clone(),
260                    UsedExportRule::new(ue.pattern.clone(), ue.exports.clone()),
261                ));
262            }
263            result
264                .used_class_members
265                .extend(ext.used_class_members.iter().cloned());
266        }
267    }
268}
269
270/// In-memory directory listing of config-candidate files (and discovered source
271/// files), keyed by absolute directory, used to resolve plugin config patterns
272/// without re-walking the filesystem.
273///
274/// Built once from the files the discovery walk already collected, so a
275/// `discover_config_files` lookup that previously cost one filesystem stat per
276/// `(plugin, ancestor-directory, pattern)` becomes an in-memory set lookup. The
277/// walk respects `.gitignore` / `ignorePatterns` / the hidden-directory
278/// allowlist, so config discovery via this index follows the same traversal
279/// rules as source discovery (the raw filesystem path used in production mode
280/// does not); see the crate docs / CHANGELOG for that deliberate refinement.
281pub struct ConfigCandidateIndex {
282    dirs: FxHashMap<PathBuf, FxHashSet<OsString>>,
283}
284
285impl ConfigCandidateIndex {
286    /// Build the index from absolute file paths (discovered source files unioned
287    /// with non-source config candidates). Files with no parent or no file name
288    /// are skipped.
289    #[must_use]
290    pub(crate) fn build<'a>(paths: impl IntoIterator<Item = &'a Path>) -> Self {
291        let mut dirs: FxHashMap<PathBuf, FxHashSet<OsString>> = FxHashMap::default();
292        for path in paths {
293            if let (Some(parent), Some(name)) = (path.parent(), path.file_name()) {
294                dirs.entry(parent.to_path_buf())
295                    .or_default()
296                    .insert(name.to_os_string());
297            }
298        }
299        Self { dirs }
300    }
301
302    /// Whether the discovery walk collected the file at `path`.
303    #[must_use]
304    pub(crate) fn contains_file(&self, path: &Path) -> bool {
305        match (path.parent(), path.file_name()) {
306            (Some(dir), Some(name)) => self.dir_contains(dir, name),
307            _ => false,
308        }
309    }
310
311    /// Whether the directory `dir` contains a file named `name`, per the files
312    /// the discovery walk collected. Used by file-based plugin activation to
313    /// avoid a per-directory filesystem `read` probe.
314    #[must_use]
315    pub(crate) fn dir_contains(&self, dir: &Path, name: &OsStr) -> bool {
316        self.dirs.get(dir).is_some_and(|names| names.contains(name))
317    }
318
319    /// Whether any directory at or below `root` contains a file named `name`,
320    /// per the files the discovery walk collected. Lets a plugin activate from
321    /// a sentinel file nested anywhere under `root` (e.g. a `.env.schema` in a
322    /// workspace subdirectory) without a recursive filesystem walk.
323    #[must_use]
324    pub(crate) fn any_descendant_contains(&self, root: &Path, name: &OsStr) -> bool {
325        self.dirs
326            .iter()
327            .any(|(dir, names)| dir.starts_with(root) && names.contains(name))
328    }
329
330    /// Whether any directory at or below `root` contains a file whose name
331    /// matches `matcher`, per the files the discovery walk collected. The glob
332    /// analogue of [`Self::any_descendant_contains`], used to activate a plugin
333    /// from a wildcard config filename (e.g. `tsconfig.*.json`) nested anywhere
334    /// under `root` without a recursive filesystem walk.
335    #[must_use]
336    pub(crate) fn any_descendant_matches(
337        &self,
338        root: &Path,
339        matcher: &globset::GlobMatcher,
340    ) -> bool {
341        self.dirs.iter().any(|(dir, names)| {
342            dir.starts_with(root) && names.iter().any(|name| matcher.is_match(Path::new(name)))
343        })
344    }
345
346    /// The file names that the discovery walk collected in the directory `dir`.
347    pub(super) fn names_in_dir(&self, dir: &Path) -> Option<&FxHashSet<OsString>> {
348        self.dirs.get(dir)
349    }
350
351    pub(super) fn glob_matches_in_dir(
352        &self,
353        dir: &Path,
354        matcher: &globset::GlobMatcher,
355    ) -> Vec<PathBuf> {
356        self.dirs.get(dir).map_or_else(Vec::new, |names| {
357            names
358                .iter()
359                .filter(|name| matcher.is_match(Path::new(name)))
360                .map(|name| dir.join(name))
361                .collect()
362        })
363    }
364}
365
366/// Discover config files for plugins that were not matched against the
367/// discovered source set.
368///
369/// This intentionally probes only known search roots instead of recursively
370/// globbing the whole repository tree. Large monorepos often contain enormous
371/// `node_modules` directories, and a full `**/project.json` walk becomes
372/// pathological there. Callers should therefore pass a focused root list such
373/// as the repo root, workspace roots, and ancestors of discovered source files.
374///
375/// When `candidate_index` is `Some` (the non-production fast path), patterns are
376/// resolved against the in-memory directory index the discovery walk already
377/// built, avoiding one filesystem stat per `(plugin, root, pattern)`. When it is
378/// `None` (production mode), the filesystem is probed directly.
379///
380/// When `production_mode` is `false`, source-extension root-anchored patterns
381/// (e.g., `webpack.config.{ts,js,mjs,cjs}`) are skipped because Phase 3a's
382/// `**/`-prefixed matcher already finds them in the discovered source file
383/// set. In production mode, the file walker excludes `*.config.*` and dotfile
384/// configs, so the FS walk is still required to keep the discovery correct.
385pub fn discover_config_files<'a>(
386    config_matchers: &[(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
387    resolved_plugins: &FxHashSet<&str>,
388    roots: &[&Path],
389    production_mode: bool,
390    candidate_index: Option<&ConfigCandidateIndex>,
391) -> Vec<(PathBuf, &'a dyn Plugin)> {
392    use rayon::prelude::*;
393    let mut pending: Vec<(&'a dyn Plugin, &Path, String, bool)> = Vec::new();
394    for (plugin, _) in config_matchers {
395        // A resolved plugin still probes the patterns that source discovery
396        // cannot index, such as `build/webpack.prod.js`: Phase 3a never saw
397        // those files, so a root config found there does not cover them. The
398        // `seen` set below removes a duplicate hit.
399        let resolved = resolved_plugins.contains(plugin.name());
400        for root in roots {
401            for pat in plugin.config_patterns() {
402                if !production_mode && is_source_ext_root_pattern(pat) {
403                    continue;
404                }
405                if resolved && !pattern_needs_filesystem(pat) {
406                    continue;
407                }
408                pending.push((*plugin, *root, pat.to_string(), resolved));
409            }
410        }
411    }
412
413    let hits: Vec<(PathBuf, &'a dyn Plugin)> = pending
414        .par_iter()
415        .flat_map_iter(|(plugin, root, pat, resolved)| {
416            expand_brace_pattern(pat)
417                .into_iter()
418                .flat_map(|expanded| match candidate_index {
419                    // A pattern under a non-allowlisted hidden directory
420                    // (e.g. `.config/prisma.ts`) is never descended by the
421                    // discovery walk, so it cannot be in the in-memory index;
422                    // probe the filesystem for those few patterns even on the
423                    // fast path so they stay discoverable.
424                    Some(index) if !pattern_needs_filesystem(&expanded) => {
425                        match_pattern_in_index(root, &expanded, index)
426                    }
427                    _ => discover_pattern_matches(root, &expanded),
428                })
429                // Phase 3a already read an indexed file of a resolved plugin,
430                // such as a config in a plugin's discovery hidden directory.
431                .filter(|path| {
432                    !*resolved || !candidate_index.is_some_and(|index| index.contains_file(path))
433                })
434                .map(move |path| (path, *plugin))
435                .collect::<Vec<_>>()
436        })
437        .collect();
438
439    let mut seen: FxHashSet<(PathBuf, &'a str)> = FxHashSet::default();
440    let mut config_files: Vec<(PathBuf, &'a dyn Plugin)> = Vec::with_capacity(hits.len());
441    for (path, plugin) in hits {
442        if seen.insert((path.clone(), plugin.name())) {
443            config_files.push((path, plugin));
444        }
445    }
446    config_files
447}
448
449fn pattern_has_glob(pattern: &str) -> bool {
450    pattern.contains('*') || pattern.contains('?') || pattern.contains('[')
451}
452
453/// True when `pattern` has a directory component (any component before the
454/// basename) that the discovery walk does not index: a hidden directory NOT on
455/// the walk's traversal allowlist, or a directory that a built-in ignore
456/// pattern excludes, such as `build`. The in-memory candidate index cannot
457/// contain files under them, so the filesystem probe is required to keep those
458/// configs (e.g. `.config/prisma.ts`, `build/webpack.prod.js`) discoverable.
459pub(super) fn pattern_needs_filesystem(pattern: &str) -> bool {
460    let mut components = pattern.split('/').peekable();
461    let mut needs_fs = false;
462    while let Some(component) = components.next() {
463        if components.peek().is_none() {
464            break; // the basename is not a directory component
465        }
466        if component.starts_with('.')
467            && component != "."
468            && component != ".."
469            && !crate::discover::is_allowed_hidden_dir(OsStr::new(component))
470        {
471            needs_fs = true;
472            break;
473        }
474        if is_default_ignored_directory(component) {
475            needs_fs = true;
476            break;
477        }
478    }
479    needs_fs
480}
481
482/// Whether a built-in ignore pattern of the form `**/<name>/**` excludes a
483/// directory of this name from source discovery.
484fn is_default_ignored_directory(name: &str) -> bool {
485    fallow_config::DEFAULT_IGNORE_PATTERNS
486        .iter()
487        .any(|pattern| {
488            pattern
489                .strip_prefix("**/")
490                .and_then(|rest| rest.strip_suffix("/**"))
491                .is_some_and(|directory| directory == name)
492        })
493}
494
495/// In-memory equivalent of [`discover_pattern_matches`], resolving `pattern`
496/// against `index` instead of the filesystem. Mirrors that function's structure
497/// arm-for-arm (plain path, `**/` strip, parent/glob split) so the two produce
498/// identical hits for any file the index contains.
499fn match_pattern_in_index(
500    root: &Path,
501    pattern: &str,
502    index: &ConfigCandidateIndex,
503) -> Vec<PathBuf> {
504    if !pattern_has_glob(pattern) {
505        let path = root.join(pattern);
506        return match (path.parent(), path.file_name()) {
507            (Some(dir), Some(name)) if index.dir_contains(dir, name) => vec![path],
508            _ => Vec::new(),
509        };
510    }
511
512    if let Some(stripped) = pattern.strip_prefix("**/") {
513        return match_pattern_in_index(root, stripped, index);
514    }
515
516    let (dir, file_pattern) = match pattern.rsplit_once('/') {
517        Some((parent, file_pattern)) if !pattern_has_glob(parent) => {
518            (root.join(parent), file_pattern)
519        }
520        Some(_) => return Vec::new(),
521        None => (root.to_path_buf(), pattern),
522    };
523
524    let Ok(matcher) = globset::Glob::new(file_pattern).map(|g| g.compile_matcher()) else {
525        return Vec::new();
526    };
527    index.glob_matches_in_dir(&dir, &matcher)
528}
529
530fn discover_pattern_matches(root: &Path, pattern: &str) -> Vec<PathBuf> {
531    if !pattern_has_glob(pattern) {
532        let path = root.join(pattern);
533        return if path.is_file() {
534            vec![path]
535        } else {
536            Vec::new()
537        };
538    }
539
540    if let Some(stripped) = pattern.strip_prefix("**/") {
541        return discover_pattern_matches(root, stripped);
542    }
543
544    let (dir, file_pattern) = match pattern.rsplit_once('/') {
545        Some((parent, file_pattern)) if !pattern_has_glob(parent) => {
546            (root.join(parent), file_pattern)
547        }
548        Some(_) => return Vec::new(),
549        None => (root.to_path_buf(), pattern),
550    };
551
552    scan_dir_for_pattern(&dir, file_pattern)
553}
554
555fn scan_dir_for_pattern(dir: &Path, file_pattern: &str) -> Vec<PathBuf> {
556    let Ok(matcher) = globset::Glob::new(file_pattern).map(|g| g.compile_matcher()) else {
557        return Vec::new();
558    };
559    let Ok(entries) = std::fs::read_dir(dir) else {
560        return Vec::new();
561    };
562
563    entries
564        .filter_map(Result::ok)
565        .map(|entry| entry.path())
566        .filter(|path| path.is_file())
567        .filter(|path| {
568            path.file_name()
569                .is_some_and(|name| matcher.is_match(std::path::Path::new(name)))
570        })
571        .collect()
572}
573
574pub(super) fn expand_brace_pattern(pattern: &str) -> Vec<String> {
575    let Some(open) = pattern.find('{') else {
576        return vec![pattern.to_string()];
577    };
578    let Some(close_rel) = pattern[open + 1..].find('}') else {
579        return vec![pattern.to_string()];
580    };
581    let close = open + 1 + close_rel;
582
583    let prefix = &pattern[..open];
584    let suffix = &pattern[close + 1..];
585    let inner = &pattern[open + 1..close];
586    let mut expanded = Vec::new();
587    for option in inner.split(',') {
588        for tail in expand_brace_pattern(suffix) {
589            expanded.push(format!("{prefix}{option}{tail}"));
590        }
591    }
592    expanded
593}
594
595/// Validate the user-supplied exclude regexes attached to a `PathRule`.
596///
597/// The originating plugin and source config file are surfaced so users can
598/// locate every typo in one config-load error.
599fn collect_path_rule_regex_errors(
600    rule: &crate::plugins::PathRule,
601    plugin_name: &str,
602    config_path: Option<&Path>,
603    rule_kind: &'static str,
604    errors: &mut Vec<PluginRegexValidationError>,
605) {
606    for pattern in &rule.exclude_regexes {
607        if let Err(source) = regex::Regex::new(pattern) {
608            errors.push(PluginRegexValidationError::new(
609                PluginRegexValidationErrorInput {
610                    plugin_name,
611                    config_path,
612                    rule_kind,
613                    field: "exclude_regexes",
614                    rule_pattern: &rule.pattern,
615                    regex_pattern: pattern,
616                    source: &source,
617                },
618            ));
619        }
620    }
621    for pattern in &rule.exclude_segment_regexes {
622        if let Err(source) = regex::Regex::new(pattern) {
623            errors.push(PluginRegexValidationError::new(
624                PluginRegexValidationErrorInput {
625                    plugin_name,
626                    config_path,
627                    rule_kind,
628                    field: "exclude_segment_regexes",
629                    rule_pattern: &rule.pattern,
630                    regex_pattern: pattern,
631                    source: &source,
632                },
633            ));
634        }
635    }
636}
637
638/// Merge a `PluginResult` from config parsing into the aggregated result.
639///
640/// `config_path` is the source config file the plugin parsed (when known).
641/// It is only used to enrich config-load errors so users can find their typo.
642/// Tests and inline package.json fallbacks may pass `None`.
643pub fn process_config_result(
644    plugin_name: &str,
645    plugin_result: PluginResult,
646    result: &mut AggregatedPluginResult,
647    config_path: Option<&Path>,
648) -> Result<(), Vec<PluginRegexValidationError>> {
649    let mut regex_errors = Vec::new();
650
651    for rule in &plugin_result.entry_patterns {
652        collect_path_rule_regex_errors(
653            rule,
654            plugin_name,
655            config_path,
656            "entry_patterns[]",
657            &mut regex_errors,
658        );
659    }
660    for rule in &plugin_result.used_exports {
661        collect_path_rule_regex_errors(
662            &rule.path,
663            plugin_name,
664            config_path,
665            "used_exports[].path",
666            &mut regex_errors,
667        );
668    }
669    if !regex_errors.is_empty() {
670        return Err(regex_errors);
671    }
672    merge_plugin_result_fields(plugin_name, plugin_result, result);
673    Ok(())
674}
675
676/// Merge a validated `PluginResult`'s payload fields into the aggregate, applying
677/// the per-plugin `replace_*` semantics for entry patterns, used-export rules,
678/// and path aliases.
679fn merge_plugin_result_fields(
680    pname: &str,
681    plugin_result: PluginResult,
682    result: &mut AggregatedPluginResult,
683) {
684    if plugin_result.replace_entry_patterns && !plugin_result.entry_patterns.is_empty() {
685        let first_replacement = result.replaced_entry_plugins.insert(pname.to_string());
686        if first_replacement || !plugin_result.accumulate_config_entry_patterns {
687            result.entry_patterns.retain(|(_, name)| name != pname);
688        }
689    }
690    if plugin_result.replace_used_export_rules && !plugin_result.used_exports.is_empty() {
691        result.used_exports.retain(|rule| rule.plugin_name != pname);
692    }
693    result.entry_patterns.extend(
694        plugin_result
695            .entry_patterns
696            .into_iter()
697            .map(|rule| (rule, pname.to_string())),
698    );
699    result.used_exports.extend(
700        plugin_result
701            .used_exports
702            .into_iter()
703            .map(|rule| PluginUsedExportRule::new(pname.to_string(), rule)),
704    );
705    result
706        .used_class_members
707        .extend(plugin_result.used_class_members);
708    result
709        .referenced_dependencies
710        .extend(plugin_result.referenced_dependencies);
711    result
712        .package_referenced_dependencies
713        .extend(plugin_result.package_referenced_dependencies);
714    result.discovered_always_used.extend(
715        plugin_result
716            .always_used_files
717            .into_iter()
718            .map(|p| (p, pname.to_string())),
719    );
720    for (prefix, replacement) in plugin_result.path_aliases {
721        result
722            .path_aliases
723            .retain(|(existing_prefix, _)| existing_prefix != &prefix);
724        result.path_aliases.push((prefix, replacement));
725    }
726    result.setup_files.extend(
727        plugin_result
728            .setup_files
729            .into_iter()
730            .map(|p| (p, pname.to_string())),
731    );
732    result.fixture_patterns.extend(
733        plugin_result
734            .fixture_patterns
735            .into_iter()
736            .map(|p| (p, pname.to_string())),
737    );
738    result
739        .scss_include_paths
740        .extend(plugin_result.scss_include_paths);
741    result
742        .static_dir_mappings
743        .extend(plugin_result.static_dir_mappings);
744    result
745        .framework_static_dir_mappings
746        .extend(plugin_result.framework_static_dir_mappings);
747    result
748        .provided_dependencies
749        .extend(plugin_result.provided_dependencies);
750    result
751        .federation_sources
752        .extend(plugin_result.federation_sources);
753    for diagnostic in plugin_result.config_diagnostics {
754        if !result.config_diagnostics.contains(&diagnostic) {
755            result.config_diagnostics.push(diagnostic);
756        }
757    }
758}
759
760/// Check if a plugin already has a config file matched against discovered files.
761pub fn check_has_config_file(
762    plugin: &dyn Plugin,
763    config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
764    relative_files: &[(PathBuf, String)],
765) -> bool {
766    !plugin.config_patterns().is_empty()
767        && config_matchers.iter().any(|(p, matchers)| {
768            p.name() == plugin.name()
769                && relative_files
770                    .iter()
771                    .any(|(_, rel)| matchers.iter().any(|m| m.is_match(rel.as_str())))
772        })
773}
774
775/// Check if a `PluginDetection` condition is satisfied.
776pub fn check_plugin_detection(
777    detection: &PluginDetection,
778    all_deps: &[&str],
779    root: &Path,
780    discovered_files: &[PathBuf],
781) -> bool {
782    match detection {
783        PluginDetection::Dependency { package } => all_deps.iter().any(|d| *d == package),
784        PluginDetection::FileExists { pattern } => {
785            if let Ok(matcher) = globset::Glob::new(pattern).map(|g| g.compile_matcher()) {
786                for file in discovered_files {
787                    let relative = file.strip_prefix(root).unwrap_or(file);
788                    if matcher.is_match(relative) {
789                        return true;
790                    }
791                }
792            }
793            let full_pattern = root.join(pattern).to_string_lossy().to_string();
794            glob::glob(&full_pattern).is_ok_and(|mut g| g.next().is_some())
795        }
796        PluginDetection::All { conditions } => conditions
797            .iter()
798            .all(|c| check_plugin_detection(c, all_deps, root, discovered_files)),
799        PluginDetection::Any { conditions } => conditions
800            .iter()
801            .any(|c| check_plugin_detection(c, all_deps, root, discovered_files)),
802    }
803}
804
805#[cfg(test)]
806mod tests {
807    use super::*;
808
809    #[test]
810    fn pattern_needs_filesystem_only_for_non_allowlisted_hidden_dirs() {
811        // `.config` is a hidden directory not on the walk's allowlist, so a
812        // config under it is never in the in-memory index and must be probed.
813        assert!(pattern_needs_filesystem(".config/prisma.ts"));
814        // Plain, nested-non-hidden, dotfile-basename, `**/`, and allowlisted
815        // hidden-dir (`.storybook`) patterns all resolve from the index.
816        assert!(!pattern_needs_filesystem("tsconfig.json"));
817        assert!(!pattern_needs_filesystem("prisma/schema.prisma"));
818        assert!(!pattern_needs_filesystem(".eslintrc.json"));
819        assert!(!pattern_needs_filesystem("**/project.json"));
820        assert!(!pattern_needs_filesystem(".storybook/main.ts"));
821        assert!(!pattern_needs_filesystem("a/b/c.json"));
822        // A directory that a built-in ignore pattern excludes is not indexed.
823        assert!(pattern_needs_filesystem("build/webpack.prod.js"));
824        assert!(!pattern_needs_filesystem("config/webpack.prod.js"));
825    }
826
827    #[test]
828    fn config_candidate_index_matches_plain_nested_and_glob_shapes() {
829        let root = Path::new("/project");
830        let index = ConfigCandidateIndex::build([
831            Path::new("/project/tsconfig.json"),
832            Path::new("/project/packages/a/tsconfig.json"),
833            Path::new("/project/prisma/schema.prisma"),
834            Path::new("/project/src/main.ts"),
835        ]);
836
837        // Plain basename resolved at the project root and at a nested root.
838        assert_eq!(
839            match_pattern_in_index(root, "tsconfig.json", &index),
840            vec![PathBuf::from("/project/tsconfig.json")]
841        );
842        assert_eq!(
843            match_pattern_in_index(Path::new("/project/packages/a"), "tsconfig.json", &index),
844            vec![PathBuf::from("/project/packages/a/tsconfig.json")]
845        );
846        // Nested non-glob, `**/` strip, and a basename glob.
847        assert_eq!(
848            match_pattern_in_index(root, "prisma/schema.prisma", &index),
849            vec![PathBuf::from("/project/prisma/schema.prisma")]
850        );
851        assert_eq!(
852            match_pattern_in_index(root, "**/tsconfig.json", &index),
853            vec![PathBuf::from("/project/tsconfig.json")]
854        );
855        assert_eq!(
856            match_pattern_in_index(Path::new("/project/prisma"), "*.prisma", &index),
857            vec![PathBuf::from("/project/prisma/schema.prisma")]
858        );
859        // A pattern present in no indexed directory yields nothing.
860        assert!(match_pattern_in_index(root, "missing.json", &index).is_empty());
861    }
862}