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