Skip to main content

mago_database/
loader.rs

1//! Database loader for scanning and loading project files.
2
3use std::borrow::Cow;
4use std::collections::hash_map::Entry;
5use std::ffi::OsString;
6use std::path::Path;
7use std::path::PathBuf;
8
9use foldhash::HashMap;
10use foldhash::HashSet;
11use globset::GlobSet;
12use rayon::prelude::*;
13use walkdir::WalkDir;
14
15use crate::Database;
16use crate::DatabaseConfiguration;
17use crate::error::DatabaseError;
18use crate::exclusion::Exclusion;
19use crate::file::File;
20use crate::file::FileId;
21use crate::file::FileType;
22use crate::matcher::build_glob_set;
23use crate::utils::bytes_to_os_str;
24use crate::utils::bytes_to_path;
25use crate::utils::bytes_to_string_lossy;
26use crate::utils::read_file;
27
28/// Holds a file along with the specificity of the pattern that matched it.
29///
30/// Specificity is used to resolve conflicts when a file matches both `paths` and `includes`.
31/// Higher specificity values indicate more specific matches (e.g., exact file paths have higher
32/// specificity than directory patterns).
33#[derive(Debug)]
34struct FileWithSpecificity {
35    file: File,
36    specificity: usize,
37}
38
39/// Builder for loading files into a Database from the filesystem and memory.
40pub struct DatabaseLoader<'config> {
41    database: Option<Database<'config>>,
42    configuration: DatabaseConfiguration<'config>,
43    memory_sources: Vec<(&'static [u8], &'static [u8], FileType)>,
44    stdin_override: Option<(Cow<'config, [u8]>, Vec<u8>)>,
45}
46
47impl<'config> DatabaseLoader<'config> {
48    #[inline]
49    #[must_use]
50    pub fn new(configuration: DatabaseConfiguration<'config>) -> Self {
51        Self { configuration, memory_sources: vec![], database: None, stdin_override: None }
52    }
53
54    #[inline]
55    #[must_use]
56    pub fn with_database(mut self, database: Database<'config>) -> Self {
57        self.database = Some(database);
58        self
59    }
60
61    /// When set, the file with this logical name (workspace-relative path) will use the given
62    /// content instead of being read from disk. The logical name is used for baseline and reporting.
63    ///
64    /// `content` is raw bytes: PHP source is binary-safe, so a buffer piped in via `--stdin-input`
65    /// may not be valid UTF-8.
66    #[inline]
67    #[must_use]
68    pub fn with_stdin_override(mut self, logical_name: impl AsRef<[u8]>, content: Vec<u8>) -> Self {
69        self.stdin_override = Some((Cow::Owned(logical_name.as_ref().to_vec()), content));
70        self
71    }
72
73    #[inline]
74    pub fn add_memory_source(&mut self, name: &'static str, contents: &'static str, file_type: FileType) {
75        self.memory_sources.push((name.as_bytes(), contents.as_bytes(), file_type));
76    }
77
78    /// Loads files from disk into the database.
79    ///
80    /// # Errors
81    ///
82    /// Returns a [`DatabaseError`] if:
83    /// - A glob pattern is invalid
84    /// - File system operations fail (reading directories, files)
85    /// - A file exceeds the maximum supported size
86    #[inline]
87    pub fn load(mut self) -> Result<Database<'config>, DatabaseError> {
88        let mut db = self.database.take().unwrap_or_else(|| Database::new(self.configuration.clone()));
89
90        // Update database configuration to use the loader's configuration
91        // (fixes workspace path when merging with prelude database)
92        db.configuration = self.configuration.clone();
93
94        let extensions_set: HashSet<OsString> =
95            self.configuration.extensions.iter().map(|s| bytes_to_os_str(s.as_ref()).into_owned()).collect();
96
97        let glob_exclude_patterns: Vec<&str> = self
98            .configuration
99            .excludes
100            .iter()
101            .filter_map(|ex| match ex {
102                Exclusion::Pattern(pat) => Some(pat.as_ref()),
103                Exclusion::Path(_) => None,
104            })
105            .collect();
106
107        let glob_excludes = build_glob_set(glob_exclude_patterns.iter().copied(), self.configuration.glob)?;
108        let dir_prune_patterns: Vec<&str> = glob_exclude_patterns
109            .iter()
110            .filter_map(|pat| {
111                let stripped =
112                    pat.strip_suffix("/**/*").or_else(|| pat.strip_suffix("/**")).or_else(|| pat.strip_suffix("/*"))?;
113                if stripped.is_empty() || stripped == "*" || stripped == "**" {
114                    return None;
115                }
116                Some(stripped)
117            })
118            .collect();
119
120        let dir_prune_globs = build_glob_set(dir_prune_patterns.iter().copied(), self.configuration.glob)?;
121
122        let path_excludes: HashSet<_> = self
123            .configuration
124            .excludes
125            .iter()
126            .filter_map(|ex| match ex {
127                Exclusion::Path(p) => Some(p),
128                Exclusion::Pattern(_) => None,
129            })
130            .collect();
131
132        let (host_files_with_spec, (vendored_files_with_spec, patch_files_with_spec)) = rayon::join(
133            || {
134                self.load_paths(
135                    &self.configuration.paths,
136                    FileType::Host,
137                    &extensions_set,
138                    &glob_excludes,
139                    &dir_prune_globs,
140                    &path_excludes,
141                )
142            },
143            || {
144                rayon::join(
145                    || {
146                        self.load_paths(
147                            &self.configuration.includes,
148                            FileType::Vendored,
149                            &extensions_set,
150                            &glob_excludes,
151                            &dir_prune_globs,
152                            &path_excludes,
153                        )
154                    },
155                    || {
156                        self.load_paths(
157                            &self.configuration.patches,
158                            FileType::Patch,
159                            &extensions_set,
160                            &glob_excludes,
161                            &dir_prune_globs,
162                            &path_excludes,
163                        )
164                    },
165                )
166            },
167        );
168        let host_files_with_spec = host_files_with_spec?;
169        let vendored_files_with_spec = vendored_files_with_spec?;
170        let patch_files_with_spec = patch_files_with_spec?;
171
172        let mut all_files: HashMap<FileId, File> = HashMap::default();
173        // Per-file maximum specificity for each tier the file matched. `None` in a slot means
174        // the file did not match any configured pattern in that tier; otherwise it carries the
175        // best specificity score that tier could offer, scored by `calculate_pattern_specificity`.
176        type TierSpecs = (Option<usize>, Option<usize>, Option<usize>);
177        let mut tier_specs: HashMap<FileId, TierSpecs> = HashMap::default();
178
179        // Process host files (from paths)
180        for file_with_spec in host_files_with_spec {
181            let file_id = file_with_spec.file.id;
182            let specificity = file_with_spec.specificity;
183
184            all_files.insert(file_id, file_with_spec.file);
185            bump_spec(&mut tier_specs.entry(file_id).or_insert((None, None, None)).0, specificity);
186        }
187
188        // When stdin override is set, ensure that the file is in the database
189        // (covers new/unsaved files, not on disk). Excluded paths are skipped
190        // so that editor integrations using `--stdin-input` honor the same
191        // exclude rules as a regular filesystem scan.
192        if let Some((name, content)) = &self.stdin_override {
193            let virtual_path = self.configuration.workspace.join(bytes_to_path(name.as_ref()).as_ref());
194            let virtual_path_canonical = virtual_path.canonicalize().unwrap_or_else(|_| virtual_path.clone());
195            let virtual_path_str = virtual_path_canonical.to_string_lossy();
196
197            let matched_glob = !glob_excludes.is_empty()
198                && (glob_excludes.is_match(virtual_path_canonical.as_path())
199                    || glob_excludes.is_match(bytes_to_path(name.as_ref()).as_ref()));
200
201            let matched_path = path_excludes.iter().any(|excl| {
202                let canonical = if Path::new(excl.as_ref()).is_absolute() {
203                    excl.as_ref().to_path_buf()
204                } else {
205                    self.configuration.workspace.join(excl.as_ref())
206                };
207                let canonical = canonical.canonicalize().unwrap_or(canonical);
208                let canonical_str = canonical.to_string_lossy();
209
210                virtual_path_str.starts_with(canonical_str.as_ref())
211                    && matches!(virtual_path_str.as_bytes().get(canonical_str.len()), None | Some(&b'/' | &b'\\'))
212            });
213
214            if !matched_glob && !matched_path {
215                let file = File::ephemeral(Cow::Owned(name.as_ref().to_vec()), Cow::Owned(content.clone()));
216                let file_id = file.id;
217                if let Entry::Vacant(e) = all_files.entry(file_id) {
218                    e.insert(file);
219
220                    bump_spec(&mut tier_specs.entry(file_id).or_insert((None, None, None)).0, usize::MAX);
221                }
222            }
223        }
224
225        for file_with_spec in vendored_files_with_spec {
226            let file_id = file_with_spec.file.id;
227            let vendored_specificity = file_with_spec.specificity;
228
229            all_files.entry(file_id).or_insert(file_with_spec.file);
230            bump_spec(&mut tier_specs.entry(file_id).or_insert((None, None, None)).1, vendored_specificity);
231        }
232
233        for file_with_spec in patch_files_with_spec {
234            let file_id = file_with_spec.file.id;
235            let specificity = file_with_spec.specificity;
236            all_files.entry(file_id).or_insert(file_with_spec.file);
237            bump_spec(&mut tier_specs.entry(file_id).or_insert((None, None, None)).2, specificity);
238        }
239
240        db.reserve(tier_specs.len() + self.memory_sources.len());
241
242        for (file_id, (host_spec, vendored_spec, patch_spec)) in tier_specs {
243            if let Some(mut file) = all_files.remove(&file_id) {
244                file.file_type = resolve_file_type(host_spec, vendored_spec, patch_spec);
245                db.add(file);
246            }
247        }
248
249        for (name, contents, file_type) in self.memory_sources {
250            let file = File::new(Cow::Borrowed(name), file_type, None, Cow::Borrowed(contents));
251
252            db.add(file);
253        }
254
255        Ok(db)
256    }
257
258    /// Discovers and reads all files from a set of root paths or glob patterns in parallel.
259    ///
260    /// Supports both:
261    /// - Directory paths (e.g., "src", "tests") - recursively walks all files
262    /// - Glob patterns (e.g., "src/**/*.php", "tests/Unit/*Test.php") - matches files using glob syntax
263    ///
264    /// Returns files along with their pattern specificity for conflict resolution.
265    fn load_paths(
266        &self,
267        roots: &[Cow<'config, [u8]>],
268        file_type: FileType,
269        extensions: &HashSet<OsString>,
270        glob_excludes: &GlobSet,
271        dir_prune_globs: &GlobSet,
272        path_excludes: &HashSet<&Cow<'config, Path>>,
273    ) -> Result<Vec<FileWithSpecificity>, DatabaseError> {
274        // Canonicalize the workspace once.  All WalkDir roots are canonicalized
275        // before traversal so their paths inherit the canonical prefix without
276        // any per-file syscalls.
277        let canonical_workspace =
278            self.configuration.workspace.canonicalize().unwrap_or_else(|_| self.configuration.workspace.to_path_buf());
279
280        // Pre-canonicalize path excludes once as strings.  A plain byte-string
281        // prefix check is then sufficient in the parallel section, replacing the
282        // per-file canonicalize() + Path::starts_with (Components iteration).
283        let canonical_excludes: Vec<String> = path_excludes
284            .iter()
285            .filter_map(|ex| {
286                let p = if Path::new(ex.as_ref()).is_absolute() {
287                    ex.as_ref().to_path_buf()
288                } else {
289                    self.configuration.workspace.join(ex.as_ref())
290                };
291
292                p.canonicalize().ok()?.into_os_string().into_string().ok()
293            })
294            .collect();
295
296        // The bool flags a path that was named exactly (a literal file on disk) rather than
297        // discovered by walking a configured directory. Such paths bypass the extension filter.
298        let mut paths_to_process: Vec<(PathBuf, usize, bool)> = Vec::new();
299        let mut directory_roots: Vec<(PathBuf, usize)> = Vec::new();
300
301        for root in roots {
302            // Check if this is a glob pattern (contains glob metacharacters).
303            // First check if it's an actual file/directory on disk. if so, treat it
304            // as a literal path even if the name contains glob metacharacters like `[]`.
305            let root_path = bytes_to_path(root.as_ref());
306            let resolved_path = if root_path.is_absolute() {
307                root_path.as_ref().to_path_buf()
308            } else {
309                self.configuration.workspace.join(root_path.as_ref())
310            };
311
312            let is_glob_pattern = !resolved_path.exists()
313                && (root.contains(&b'*') || root.contains(&b'?') || root.contains(&b'[') || root.contains(&b'{'));
314
315            let specificity = calculate_pattern_specificity(root.as_ref());
316            if is_glob_pattern {
317                // Handle as glob pattern
318                let pattern = if root_path.is_absolute() {
319                    bytes_to_string_lossy(root.as_ref()).into_owned()
320                } else {
321                    // Make relative patterns absolute by prepending workspace
322                    self.configuration.workspace.join(root_path.as_ref()).to_string_lossy().to_string()
323                };
324
325                match glob::glob(&pattern) {
326                    Ok(entries) => {
327                        for entry in entries {
328                            match entry {
329                                Ok(path) => {
330                                    if path.is_file() {
331                                        // Canonicalize so the path shares the same prefix as
332                                        // `canonical_workspace` (important on macOS where
333                                        // TempDir / glob return /var/… but canonicalize gives
334                                        // /private/var/…).  Fall back to the original on error.
335                                        let canonical = path.canonicalize().unwrap_or(path);
336                                        paths_to_process.push((canonical, specificity, false));
337                                    }
338                                }
339                                Err(e) => {
340                                    tracing::warn!("Failed to read glob entry: {}", e);
341                                }
342                            }
343                        }
344                    }
345                    Err(e) => {
346                        return Err(DatabaseError::Glob(e.to_string()));
347                    }
348                }
349            } else {
350                let canonical_root = resolved_path.canonicalize().unwrap_or(resolved_path);
351
352                // A path that resolves to a regular file was named explicitly rather than
353                // discovered by walking a directory. Honor it verbatim, bypassing the extension
354                // filter so extensionless PHP files (e.g. `bin/console`) can be loaded.
355                if canonical_root.is_file() {
356                    paths_to_process.push((canonical_root, specificity, true));
357                    continue;
358                }
359
360                for entry in WalkDir::new(&canonical_root).follow_links(true).max_depth(1) {
361                    match entry {
362                        Ok(entry) => {
363                            if entry.depth() == 0 {
364                                continue;
365                            }
366
367                            if entry.file_type().is_dir() {
368                                if !is_pruned_directory(
369                                    entry.path(),
370                                    canonical_workspace.as_path(),
371                                    &canonical_excludes,
372                                    dir_prune_globs,
373                                ) {
374                                    directory_roots.push((entry.into_path(), specificity));
375                                }
376                            } else {
377                                paths_to_process.push((entry.into_path(), specificity, false));
378                            }
379                        }
380                        Err(err) => warn_walk_error(&err, canonical_root.as_path()),
381                    }
382                }
383            }
384        }
385
386        let has_path_excludes = !canonical_excludes.is_empty();
387        let has_glob_excludes = !glob_excludes.is_empty();
388        let load_path = |(path, specificity, skip_ext_check): (PathBuf, usize, bool)| {
389            if has_glob_excludes
390                && (glob_excludes.is_match(&path)
391                    || glob_excludes.is_match(workspace_relative_string(&path, canonical_workspace.as_path())))
392            {
393                return None;
394            }
395
396            if !skip_ext_check {
397                let ext = path.extension()?;
398                if !extensions.contains(ext) {
399                    return None;
400                }
401            }
402
403            if has_path_excludes {
404                let excluded = path.to_str().is_some_and(|s| {
405                    canonical_excludes.iter().any(|excl| {
406                        s.starts_with(excl.as_str())
407                            && matches!(s.as_bytes().get(excl.len()), None | Some(&b'/' | &b'\\'))
408                    })
409                });
410
411                if excluded {
412                    return None;
413                }
414            }
415
416            let workspace = canonical_workspace.as_path();
417            #[cfg(windows)]
418            let logical_name =
419                path.strip_prefix(workspace).unwrap_or(path.as_path()).to_string_lossy().replace('\\', "/");
420            #[cfg(not(windows))]
421            let logical_name = path.strip_prefix(workspace).unwrap_or(path.as_path()).to_string_lossy().into_owned();
422
423            if let Some((override_name, override_content)) = &self.stdin_override
424                && override_name.as_ref() == logical_name.as_bytes()
425            {
426                let file = File::new(
427                    Cow::Owned(logical_name.into_bytes()),
428                    file_type,
429                    Some(path),
430                    Cow::Owned(override_content.clone()),
431                );
432
433                return Some(Ok(FileWithSpecificity { file, specificity }));
434            }
435
436            match read_file(workspace, &path, file_type) {
437                Ok(file) => Some(Ok(FileWithSpecificity { file, specificity })),
438                Err(e) => Some(Err(e)),
439            }
440        };
441
442        let (direct_files, directory_files) = rayon::join(
443            || paths_to_process.into_par_iter().filter_map(&load_path).collect::<Result<Vec<FileWithSpecificity>, _>>(),
444            || {
445                directory_roots
446                    .into_par_iter()
447                    .map(|(root, specificity)| {
448                        let walker = WalkDir::new(&root).follow_links(true).into_iter().filter_entry(|entry| {
449                            entry.depth() == 0
450                                || !entry.file_type().is_dir()
451                                || !is_pruned_directory(
452                                    entry.path(),
453                                    canonical_workspace.as_path(),
454                                    &canonical_excludes,
455                                    dir_prune_globs,
456                                )
457                        });
458
459                        let mut files = Vec::new();
460                        for entry in walker {
461                            match entry {
462                                Ok(entry) if !entry.file_type().is_dir() => {
463                                    if let Some(file) = load_path((entry.into_path(), specificity, false)) {
464                                        files.push(file?);
465                                    }
466                                }
467                                Ok(_) => {}
468                                Err(err) => warn_walk_error(&err, root.as_path()),
469                            }
470                        }
471
472                        Ok(files)
473                    })
474                    .collect::<Result<Vec<Vec<FileWithSpecificity>>, DatabaseError>>()
475            },
476        );
477
478        let mut files = direct_files?;
479        files.extend(directory_files?.into_iter().flatten());
480
481        Ok(files)
482    }
483}
484
485fn workspace_relative_string(path: &Path, workspace: &Path) -> String {
486    let relative = path.strip_prefix(workspace).unwrap_or(path).to_string_lossy();
487    #[cfg(windows)]
488    {
489        relative.replace('\\', "/")
490    }
491    #[cfg(not(windows))]
492    {
493        relative.into_owned()
494    }
495}
496
497fn is_pruned_directory(
498    path: &Path,
499    workspace: &Path,
500    canonical_excludes: &[String],
501    dir_prune_globs: &GlobSet,
502) -> bool {
503    if let Some(path) = path.to_str()
504        && canonical_excludes.iter().any(|excluded| {
505            path.starts_with(excluded.as_str())
506                && matches!(path.as_bytes().get(excluded.len()), None | Some(&b'/' | &b'\\'))
507        })
508    {
509        return true;
510    }
511
512    !dir_prune_globs.is_empty()
513        && (dir_prune_globs.is_match(path) || dir_prune_globs.is_match(workspace_relative_string(path, workspace)))
514}
515
516fn warn_walk_error(error: &walkdir::Error, fallback: &Path) {
517    let path = error.path().unwrap_or(fallback).display();
518    if let Some(ancestor) = error.loop_ancestor() {
519        tracing::warn!("Skipping symlink loop at `{path}`: link cycles back to `{}`.", ancestor.display());
520    } else {
521        tracing::warn!("Failed to walk `{path}`: {error}. Entry will be skipped.");
522    }
523}
524
525fn bump_spec(slot: &mut Option<usize>, s: usize) {
526    *slot = Some(slot.map_or(s, |e| e.max(s)));
527}
528
529/// Picks the final [`FileType`] for a file that matched configured base paths in one or
530/// more tiers.
531///
532/// Each argument carries the maximum specificity of any matching base path in that tier, or
533/// `None` if no base path in that tier matched. Vendored wins over Host at equal-or-more
534/// specificity; Host wins only when strictly more specific. Patch beats Vendored
535/// unconditionally; Patch beats Host only when strictly more specific. When nothing matches
536/// the result is `Host`.
537pub(crate) fn resolve_file_type(
538    host_spec: Option<usize>,
539    vendored_spec: Option<usize>,
540    patch_spec: Option<usize>,
541) -> FileType {
542    let mut decision: Option<(FileType, usize)> = host_spec.map(|s| (FileType::Host, s));
543
544    if let Some(v) = vendored_spec {
545        decision = match decision {
546            Some((FileType::Host, h)) if v < h => decision,
547            _ => Some((FileType::Vendored, v)),
548        };
549    }
550
551    if let Some(p) = patch_spec {
552        decision = match decision {
553            Some((FileType::Host | FileType::Patch, e)) if p <= e => decision,
554            _ => Some((FileType::Patch, p)),
555        };
556    }
557
558    decision.map(|(ft, _)| ft).unwrap_or(FileType::Host)
559}
560
561/// Calculates how specific a configured base path or glob pattern is for conflict resolution.
562///
563/// Examples:
564///
565/// - "src/b.php" matching src/b.php: ~2000 (exact file, 2 components)
566/// - "src/" matching src/b.php: ~100 (directory, 1 component)
567/// - "src" matching src/b.php: ~100 (directory, 1 component)
568pub(crate) fn calculate_pattern_specificity(pattern: &[u8]) -> usize {
569    let pattern_path = bytes_to_path(pattern);
570
571    let component_count = pattern_path.components().count();
572    let is_glob =
573        pattern.contains(&b'*') || pattern.contains(&b'?') || pattern.contains(&b'[') || pattern.contains(&b'{');
574
575    if is_glob {
576        let non_wildcard_components = pattern_path
577            .components()
578            .filter(|c| {
579                let s = c.as_os_str().to_string_lossy();
580                !s.contains('*') && !s.contains('?') && !s.contains('[') && !s.contains('{')
581            })
582            .count();
583        non_wildcard_components * 10
584    } else if pattern_path.is_file()
585        || pattern_path.extension().is_some()
586        || pattern.rsplit(|&b| b == b'.').next().is_some_and(|ext| ext.eq_ignore_ascii_case(b"php"))
587    {
588        component_count * 1000
589    } else {
590        component_count * 100
591    }
592}
593
594#[cfg(test)]
595mod resolution_tests {
596    use super::*;
597
598    #[test]
599    fn defaults_to_host_when_nothing_matches() {
600        assert_eq!(resolve_file_type(None, None, None), FileType::Host);
601    }
602
603    #[test]
604    fn host_only_match_yields_host() {
605        assert_eq!(resolve_file_type(Some(100), None, None), FileType::Host);
606    }
607
608    #[test]
609    fn vendored_only_match_yields_vendored() {
610        assert_eq!(resolve_file_type(None, Some(100), None), FileType::Vendored);
611    }
612
613    #[test]
614    fn patch_only_match_yields_patch() {
615        assert_eq!(resolve_file_type(None, None, Some(100)), FileType::Patch);
616    }
617
618    #[test]
619    fn vendored_beats_host_at_equal_specificity() {
620        assert_eq!(resolve_file_type(Some(100), Some(100), None), FileType::Vendored);
621    }
622
623    #[test]
624    fn vendored_beats_host_when_more_specific() {
625        assert_eq!(resolve_file_type(Some(100), Some(2000), None), FileType::Vendored);
626    }
627
628    #[test]
629    fn host_beats_vendored_only_when_strictly_more_specific() {
630        assert_eq!(resolve_file_type(Some(2000), Some(100), None), FileType::Host);
631    }
632
633    #[test]
634    fn patch_beats_vendored_unconditionally() {
635        assert_eq!(resolve_file_type(None, Some(2000), Some(100)), FileType::Patch);
636    }
637
638    #[test]
639    fn host_beats_patch_at_equal_specificity() {
640        assert_eq!(resolve_file_type(Some(100), None, Some(100)), FileType::Host);
641    }
642
643    #[test]
644    fn patch_beats_host_when_strictly_more_specific() {
645        assert_eq!(resolve_file_type(Some(100), None, Some(2000)), FileType::Patch);
646    }
647
648    #[test]
649    fn patch_beats_host_that_won_over_vendored() {
650        assert_eq!(resolve_file_type(Some(100), Some(2000), Some(50)), FileType::Patch);
651    }
652
653    #[test]
654    fn exact_file_path_beats_directory_at_same_component_count() {
655        assert!(calculate_pattern_specificity(b"src/foo.php") > calculate_pattern_specificity(b"src/foo"));
656    }
657
658    #[test]
659    fn directory_beats_glob_at_same_non_wildcard_count() {
660        assert!(calculate_pattern_specificity(b"src/") > calculate_pattern_specificity(b"src/**/*.php"));
661    }
662
663    #[test]
664    fn deeper_path_beats_shallower_at_same_kind() {
665        assert!(calculate_pattern_specificity(b"src/inner/") > calculate_pattern_specificity(b"src/"));
666    }
667
668    #[test]
669    fn extensionless_phpish_pattern_treated_as_file() {
670        assert_eq!(calculate_pattern_specificity(b"src/foo.PHP"), calculate_pattern_specificity(b"src/foo.php"),);
671    }
672}
673
674#[cfg(test)]
675#[allow(clippy::unwrap_used, clippy::expect_used)]
676mod tests {
677    use super::*;
678    use crate::DatabaseReader;
679    use crate::GlobSettings;
680    use std::borrow::Cow;
681    use tempfile::TempDir;
682
683    fn create_test_config(temp_dir: &TempDir, paths: Vec<&str>, includes: Vec<&str>) -> DatabaseConfiguration<'static> {
684        create_test_config_with_patches(temp_dir, paths, includes, vec![])
685    }
686
687    fn create_test_config_with_patches(
688        temp_dir: &TempDir,
689        paths: Vec<&str>,
690        includes: Vec<&str>,
691        patches: Vec<&str>,
692    ) -> DatabaseConfiguration<'static> {
693        // Normalize path separators to platform-specific separators
694        let normalize = |s: &str| s.replace('/', std::path::MAIN_SEPARATOR_STR);
695
696        DatabaseConfiguration {
697            workspace: Cow::Owned(temp_dir.path().to_path_buf()),
698            paths: paths.into_iter().map(|s| Cow::Owned(normalize(s).into_bytes())).collect(),
699            includes: includes.into_iter().map(|s| Cow::Owned(normalize(s).into_bytes())).collect(),
700            patches: patches.into_iter().map(|s| Cow::Owned(normalize(s).into_bytes())).collect(),
701            excludes: vec![],
702            extensions: vec![Cow::Borrowed(b"php")],
703            glob: GlobSettings::default(),
704        }
705    }
706
707    /// Returns the file's logical name as a lossy UTF-8 string for assertion matching.
708    fn name_str(name: &[u8]) -> std::borrow::Cow<'_, str> {
709        String::from_utf8_lossy(name)
710    }
711
712    fn create_test_file(temp_dir: &TempDir, relative_path: &str, content: &str) {
713        let file_path = temp_dir.path().join(relative_path);
714        if let Some(parent) = file_path.parent() {
715            std::fs::create_dir_all(parent).unwrap();
716        }
717        std::fs::write(file_path, content).unwrap();
718    }
719
720    #[test]
721    fn test_exact_file_vs_directory() {
722        let temp_dir = TempDir::new().unwrap();
723
724        create_test_file(&temp_dir, "src/b.php", "<?php");
725        create_test_file(&temp_dir, "src/a.php", "<?php");
726
727        let config = create_test_config(&temp_dir, vec!["src/b.php"], vec!["src/"]);
728        let loader = DatabaseLoader::new(config);
729        let db = loader.load().unwrap();
730
731        let b_file = db.files().find(|f| name_str(&f.name).contains("b.php")).unwrap();
732        assert_eq!(b_file.file_type, FileType::Host, "src/b.php should be Host (exact file beats directory)");
733
734        let a_file = db.files().find(|f| name_str(&f.name).contains("a.php")).unwrap();
735        assert_eq!(a_file.file_type, FileType::Vendored, "src/a.php should be Vendored");
736    }
737
738    #[test]
739    fn test_deeper_vs_shallower_directory() {
740        let temp_dir = TempDir::new().unwrap();
741
742        create_test_file(&temp_dir, "src/foo/bar.php", "<?php");
743
744        let config = create_test_config(&temp_dir, vec!["src/foo/"], vec!["src/"]);
745        let loader = DatabaseLoader::new(config);
746        let db = loader.load().unwrap();
747
748        let file = db.files().find(|f| name_str(&f.name).contains("bar.php")).unwrap();
749        assert_eq!(file.file_type, FileType::Host, "Deeper directory pattern should win");
750    }
751
752    #[test]
753    fn test_exact_file_vs_glob() {
754        let temp_dir = TempDir::new().unwrap();
755
756        create_test_file(&temp_dir, "src/b.php", "<?php");
757
758        let config = create_test_config(&temp_dir, vec!["src/b.php"], vec!["src/*.php"]);
759        let loader = DatabaseLoader::new(config);
760        let db = loader.load().unwrap();
761
762        let file = db.files().find(|f| name_str(&f.name).contains("b.php")).unwrap();
763        assert_eq!(file.file_type, FileType::Host, "Exact file should beat glob pattern");
764    }
765
766    #[test]
767    fn test_equal_specificity_includes_wins() {
768        let temp_dir = TempDir::new().unwrap();
769
770        create_test_file(&temp_dir, "src/a.php", "<?php");
771
772        let config = create_test_config(&temp_dir, vec!["src/"], vec!["src/"]);
773        let loader = DatabaseLoader::new(config);
774        let db = loader.load().unwrap();
775
776        let file = db.files().find(|f| name_str(&f.name).contains("a.php")).unwrap();
777        assert_eq!(file.file_type, FileType::Vendored, "Equal specificity: includes should win");
778    }
779
780    #[test]
781    fn test_complex_scenario_from_bug_report() {
782        let temp_dir = TempDir::new().unwrap();
783
784        create_test_file(&temp_dir, "src/a.php", "<?php");
785        create_test_file(&temp_dir, "src/b.php", "<?php");
786        create_test_file(&temp_dir, "src/c/d.php", "<?php");
787        create_test_file(&temp_dir, "src/c/e.php", "<?php");
788        create_test_file(&temp_dir, "vendor/lib1.php", "<?php");
789        create_test_file(&temp_dir, "vendor/lib2.php", "<?php");
790
791        let config = create_test_config(&temp_dir, vec!["src/b.php"], vec!["vendor", "src/c", "src/"]);
792        let loader = DatabaseLoader::new(config);
793        let db = loader.load().unwrap();
794
795        let b_file = db
796            .files()
797            .find(|f| name_str(&f.name).contains("src/b.php") || name_str(&f.name).ends_with("b.php"))
798            .unwrap();
799        assert_eq!(b_file.file_type, FileType::Host, "src/b.php should be Host in bug scenario");
800
801        let d_file = db.files().find(|f| name_str(&f.name).contains("d.php")).unwrap();
802        assert_eq!(d_file.file_type, FileType::Vendored, "src/c/d.php should be Vendored");
803
804        let lib_file = db.files().find(|f| name_str(&f.name).contains("lib1.php")).unwrap();
805        assert_eq!(lib_file.file_type, FileType::Vendored, "vendor/lib1.php should be Vendored");
806    }
807
808    #[test]
809    fn test_files_only_in_paths() {
810        let temp_dir = TempDir::new().unwrap();
811
812        create_test_file(&temp_dir, "src/a.php", "<?php");
813
814        let config = create_test_config(&temp_dir, vec!["src/"], vec![]);
815        let loader = DatabaseLoader::new(config);
816        let db = loader.load().unwrap();
817
818        let file = db.files().find(|f| name_str(&f.name).contains("a.php")).unwrap();
819        assert_eq!(file.file_type, FileType::Host, "File only in paths should be Host");
820    }
821
822    #[test]
823    fn test_files_only_in_includes() {
824        let temp_dir = TempDir::new().unwrap();
825
826        create_test_file(&temp_dir, "vendor/lib.php", "<?php");
827
828        let config = create_test_config(&temp_dir, vec![], vec!["vendor/"]);
829        let loader = DatabaseLoader::new(config);
830        let db = loader.load().unwrap();
831
832        let file = db.files().find(|f| name_str(&f.name).contains("lib.php")).unwrap();
833        assert_eq!(file.file_type, FileType::Vendored, "File only in includes should be Vendored");
834    }
835
836    #[test]
837    fn test_stdin_override_replaces_file_content() {
838        let temp_dir = TempDir::new().unwrap();
839        create_test_file(&temp_dir, "src/foo.php", "<?php\n// on disk");
840
841        let config = create_test_config(&temp_dir, vec!["src/"], vec![]);
842        let loader = DatabaseLoader::new(config).with_stdin_override("src/foo.php", b"<?php\n// from stdin".to_vec());
843        let db = loader.load().unwrap();
844
845        let file = db.files().find(|f| name_str(&f.name).contains("foo.php")).unwrap();
846        assert_eq!(
847            file.contents.as_ref(),
848            b"<?php\n// from stdin",
849            "stdin override content should be used instead of disk"
850        );
851    }
852
853    #[test]
854    fn test_glob_excludes_match_workspace_relative_paths() {
855        let temp_dir = TempDir::new().unwrap();
856
857        create_test_file(&temp_dir, "src/Absences/Foo/Foo.php", "<?php");
858        create_test_file(&temp_dir, "src/Absences/Test/Faker/Provider/AbsencesProvider.php", "<?php");
859        create_test_file(&temp_dir, "src/Calendar/Test/Helper.php", "<?php");
860
861        let mut config = create_test_config(&temp_dir, vec!["src"], vec![]);
862        config.excludes = vec![Exclusion::Pattern(Cow::Borrowed("src/*/Test/**"))];
863
864        let loader = DatabaseLoader::new(config);
865        let db = loader.load().unwrap();
866
867        let names: Vec<String> = db.files().map(|f| name_str(&f.name).into_owned()).collect();
868        assert!(names.iter().any(|n| n.ends_with("src/Absences/Foo/Foo.php")), "non-Test file should be loaded");
869        assert!(
870            !names.iter().any(|n| n.contains("src/Absences/Test/")),
871            "files under src/*/Test/** should be excluded, got {names:?}"
872        );
873        assert!(
874            !names.iter().any(|n| n.contains("src/Calendar/Test/")),
875            "files under src/*/Test/** should be excluded, got {names:?}"
876        );
877    }
878
879    #[test]
880    fn test_glob_excludes_match_legacy_absolute_prefix_patterns() {
881        let temp_dir = TempDir::new().unwrap();
882
883        create_test_file(&temp_dir, "packages/foo/src/main.php", "<?php");
884        create_test_file(&temp_dir, "packages/foo/vendor/lib.php", "<?php");
885
886        let mut config = create_test_config(&temp_dir, vec!["packages"], vec![]);
887        config.excludes = vec![Exclusion::Pattern(Cow::Borrowed("*/packages/**/vendor/*"))];
888
889        let loader = DatabaseLoader::new(config);
890        let db = loader.load().unwrap();
891
892        let names: Vec<String> = db.files().map(|f| name_str(&f.name).into_owned()).collect();
893        assert!(names.iter().any(|n| n.ends_with("packages/foo/src/main.php")));
894        assert!(
895            !names.iter().any(|n| n.contains("/vendor/")),
896            "legacy `*/packages/**/vendor/*` style should still exclude vendor files, got {names:?}"
897        );
898    }
899
900    #[test]
901    fn test_glob_dir_prune_skips_relative_directories() {
902        let temp_dir = TempDir::new().unwrap();
903
904        create_test_file(&temp_dir, "vendor/slevomat/coding-standard/main.php", "<?php");
905        create_test_file(&temp_dir, "vendor/slevomat/coding-standard/tests/Sniffs/Foo.php", "<?php");
906        create_test_file(&temp_dir, "vendor/another/lib.php", "<?php");
907
908        let mut config = create_test_config(&temp_dir, vec![], vec!["vendor"]);
909        config.excludes = vec![Exclusion::Pattern(Cow::Borrowed("vendor/**/tests/**"))];
910
911        let loader = DatabaseLoader::new(config);
912        let db = loader.load().unwrap();
913
914        let names: Vec<String> = db.files().map(|f| name_str(&f.name).into_owned()).collect();
915        assert!(names.iter().any(|n| n.ends_with("vendor/slevomat/coding-standard/main.php")));
916        assert!(names.iter().any(|n| n.ends_with("vendor/another/lib.php")));
917        assert!(
918            !names.iter().any(|n| n.contains("/tests/")),
919            "files under vendor/**/tests/** should be pruned, got {names:?}"
920        );
921    }
922
923    #[test]
924    fn test_stdin_override_adds_file_when_not_on_disk() {
925        let temp_dir = TempDir::new().unwrap();
926        create_test_file(&temp_dir, "src/.gitkeep", "");
927
928        let config = create_test_config(&temp_dir, vec!["src/"], vec![]);
929        let loader =
930            DatabaseLoader::new(config).with_stdin_override("src/unsaved.php", b"<?php\n// unsaved buffer".to_vec());
931        let db = loader.load().unwrap();
932
933        let file = db.files().find(|f| name_str(&f.name).contains("unsaved.php")).unwrap();
934        assert_eq!(file.file_type, FileType::Host);
935        assert_eq!(file.contents.as_ref(), b"<?php\n// unsaved buffer");
936    }
937
938    #[test]
939    fn test_stdin_override_accepts_non_utf8_content() {
940        let temp_dir = TempDir::new().unwrap();
941        create_test_file(&temp_dir, "src/.gitkeep", "");
942
943        let config = create_test_config(&temp_dir, vec!["src/"], vec![]);
944        // PHP identifiers are binary-safe, so a buffer piped in via `--stdin-input` may not
945        // be valid UTF-8. The loaded file must carry those bytes through verbatim.
946        let content = b"<?php\n\nfunction f\xC9\xFF(): void {}\n".to_vec();
947        assert!(std::str::from_utf8(&content).is_err(), "test buffer must contain non-UTF-8 bytes");
948
949        let loader = DatabaseLoader::new(config).with_stdin_override("src/buffer.php", content.clone());
950        let db = loader.load().unwrap();
951
952        let file = db.files().find(|f| name_str(&f.name).contains("buffer.php")).unwrap();
953        assert_eq!(file.contents.as_ref(), content.as_slice());
954    }
955
956    #[cfg(unix)]
957    #[test]
958    fn test_symlinked_file_under_include_is_loaded() {
959        let temp_dir = TempDir::new().unwrap();
960        let external = TempDir::new().unwrap();
961
962        create_test_file(&external, "Bar.php", "<?php class Bar {}\n");
963        std::fs::create_dir_all(temp_dir.path().join("vendor")).unwrap();
964        std::os::unix::fs::symlink(external.path().join("Bar.php"), temp_dir.path().join("vendor/Bar.php")).unwrap();
965
966        let config = create_test_config(&temp_dir, vec![], vec!["vendor/"]);
967        let db = DatabaseLoader::new(config).load().unwrap();
968
969        let bar = db.files().find(|f| name_str(&f.name).contains("Bar.php"));
970        assert!(bar.is_some(), "symlinked Bar.php should be loaded via include = ['vendor/']");
971    }
972
973    #[cfg(unix)]
974    #[test]
975    fn test_symlinked_directory_under_include_is_descended() {
976        let temp_dir = TempDir::new().unwrap();
977        let external = TempDir::new().unwrap();
978
979        create_test_file(&external, "src/Foo.php", "<?php class Foo {}\n");
980        create_test_file(&external, "src/Bar.php", "<?php class Bar {}\n");
981
982        std::fs::create_dir_all(temp_dir.path().join("vendor")).unwrap();
983        std::os::unix::fs::symlink(external.path(), temp_dir.path().join("vendor/example-package")).unwrap();
984
985        let config = create_test_config(&temp_dir, vec![], vec!["vendor/"]);
986        let db = DatabaseLoader::new(config).load().unwrap();
987
988        assert!(db.files().any(|f| name_str(&f.name).contains("Foo.php")), "Foo.php inside symlinked dir not found");
989        assert!(db.files().any(|f| name_str(&f.name).contains("Bar.php")), "Bar.php inside symlinked dir not found");
990    }
991
992    #[cfg(unix)]
993    #[test]
994    fn test_symlink_cycle_is_warned_and_skipped() {
995        let temp_dir = TempDir::new().unwrap();
996        create_test_file(&temp_dir, "src/Real.php", "<?php class Real {}\n");
997        std::os::unix::fs::symlink(temp_dir.path().join("src"), temp_dir.path().join("src/loop")).unwrap();
998
999        let config = create_test_config(&temp_dir, vec![], vec!["src/"]);
1000        let db = DatabaseLoader::new(config).load().expect("symlink cycle should not abort the load");
1001
1002        assert!(
1003            db.files().any(|f| name_str(&f.name).contains("Real.php")),
1004            "Real.php still reachable despite the loop"
1005        );
1006    }
1007
1008    #[test]
1009    fn test_exact_extensionless_file_is_loaded() {
1010        let temp_dir = TempDir::new().unwrap();
1011        create_test_file(&temp_dir, "bin/console", "<?php\n// entrypoint");
1012
1013        // `bin/console` has no extension, so it would be filtered out when discovered by
1014        // walking a directory. Naming it exactly must bypass the extension requirement.
1015        let config = create_test_config(&temp_dir, vec!["bin/console"], vec![]);
1016        let db = DatabaseLoader::new(config).load().unwrap();
1017
1018        let file = db.files().find(|f| name_str(&f.name).ends_with("bin/console")).unwrap();
1019        assert_eq!(file.file_type, FileType::Host);
1020        assert_eq!(file.contents.as_ref(), b"<?php\n// entrypoint");
1021    }
1022
1023    #[test]
1024    fn test_extensionless_file_in_directory_is_skipped() {
1025        let temp_dir = TempDir::new().unwrap();
1026        create_test_file(&temp_dir, "bin/console", "<?php");
1027        create_test_file(&temp_dir, "bin/run.php", "<?php");
1028
1029        // Walking the directory must still honor the extension filter: only `run.php` loads.
1030        let config = create_test_config(&temp_dir, vec!["bin"], vec![]);
1031        let db = DatabaseLoader::new(config).load().unwrap();
1032
1033        let names: Vec<String> = db.files().map(|f| name_str(&f.name).into_owned()).collect();
1034        assert!(names.iter().any(|n| n.ends_with("bin/run.php")), "run.php should be loaded, got {names:?}");
1035        assert!(!names.iter().any(|n| n.ends_with("bin/console")), "extensionless console should be skipped");
1036    }
1037
1038    #[test]
1039    fn test_patch_beats_vendored_at_equal_specificity() {
1040        // A file covered by both patches and includes at the same directory-level specificity
1041        // should be classified as Patch, not Vendored.
1042        let temp_dir = TempDir::new().unwrap();
1043        create_test_file(&temp_dir, "lib/Foo.php", "<?php");
1044
1045        let config = create_test_config_with_patches(&temp_dir, vec![], vec!["lib/"], vec!["lib/"]);
1046        let db = DatabaseLoader::new(config).load().unwrap();
1047
1048        let file = db.files().find(|f| String::from_utf8_lossy(&f.name).contains("Foo.php")).unwrap();
1049        assert_eq!(file.file_type, FileType::Patch, "patch should beat vendored at equal specificity");
1050    }
1051
1052    #[test]
1053    fn test_host_beats_patch_at_equal_specificity() {
1054        // When a file is covered by both paths and patches at the same directory-level specificity,
1055        // the host (paths) classification wins.  Patches only override host when strictly more specific.
1056        let temp_dir = TempDir::new().unwrap();
1057        create_test_file(&temp_dir, "src/Foo.php", "<?php");
1058
1059        let config = create_test_config_with_patches(&temp_dir, vec!["src/"], vec![], vec!["src/"]);
1060        let db = DatabaseLoader::new(config).load().unwrap();
1061
1062        let file = db.files().find(|f| String::from_utf8_lossy(&f.name).contains("Foo.php")).unwrap();
1063        assert_eq!(file.file_type, FileType::Host, "host should beat patch at equal specificity");
1064    }
1065
1066    #[test]
1067    fn test_patch_beats_host_when_strictly_more_specific() {
1068        // An exact-file patch pattern has higher specificity than a directory paths pattern,
1069        // so the patch wins and the file is treated as Patch rather than Host.
1070        let temp_dir = TempDir::new().unwrap();
1071        create_test_file(&temp_dir, "src/Foo.php", "<?php");
1072        create_test_file(&temp_dir, "src/Bar.php", "<?php");
1073
1074        // Patch covers only Foo.php exactly; paths covers the whole directory.
1075        let config = create_test_config_with_patches(&temp_dir, vec!["src/"], vec![], vec!["src/Foo.php"]);
1076        let db = DatabaseLoader::new(config).load().unwrap();
1077
1078        let foo = db.files().find(|f| String::from_utf8_lossy(&f.name).contains("Foo.php")).unwrap();
1079        assert_eq!(foo.file_type, FileType::Patch, "exact-file patch should beat directory-level host pattern");
1080
1081        let bar = db.files().find(|f| String::from_utf8_lossy(&f.name).contains("Bar.php")).unwrap();
1082        assert_eq!(bar.file_type, FileType::Host, "file not covered by patch should remain Host");
1083    }
1084}