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