1use 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#[derive(Debug)]
34struct FileWithSpecificity {
35 file: File,
36 specificity: usize,
37}
38
39pub 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 #[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 #[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 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 type TierSpecs = (Option<usize>, Option<usize>, Option<usize>);
177 let mut tier_specs: HashMap<FileId, TierSpecs> = HashMap::default();
178
179 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 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 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 let canonical_workspace =
278 self.configuration.workspace.canonicalize().unwrap_or_else(|_| self.configuration.workspace.to_path_buf());
279
280 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 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 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 let pattern = if root_path.is_absolute() {
319 bytes_to_string_lossy(root.as_ref()).into_owned()
320 } else {
321 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 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 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
529pub(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
561pub(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 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 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 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 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 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 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 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 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 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}