1use rustc_hash::FxHashMap;
2use std::path::{Path, PathBuf};
3use thiserror::Error;
4
5use crate::file_extensions::PhpFileExtensions;
6use crate::include_graph::{include_closure, VendorTargets};
7
8#[derive(Debug, Error)]
13pub enum ComposerError {
14 #[error("composer I/O error: {0}")]
15 Io(#[from] std::io::Error),
16 #[error("composer JSON error: {0}")]
17 Json(#[from] serde_json::Error),
18 #[error("composer.json has no autoload section")]
19 MissingAutoload,
20}
21
22#[derive(Clone)]
41pub struct Psr4Map {
42 project_entries: Vec<(String, PathBuf)>,
43 vendor_entries: Vec<(String, PathBuf)>,
44 project_psr0_entries: Vec<(String, PathBuf)>,
45 vendor_psr0_entries: Vec<(String, PathBuf)>,
46 project_extra_paths: Vec<PathBuf>,
47 vendor_extra_paths: Vec<PathBuf>,
48 classmap: FxHashMap<String, PathBuf>,
55 vendor_eager_files: Vec<PathBuf>,
61 #[allow(dead_code)] root: PathBuf,
63 extensions: PhpFileExtensions,
64}
65
66fn ensure_trailing_backslash(prefix: &str) -> String {
67 if prefix.ends_with('\\') {
68 prefix.to_string()
69 } else {
70 format!("{prefix}\\")
71 }
72}
73
74fn collect_prefix_dirs(
77 value: &serde_json::Value,
78 prefix: &str,
79 base: &Path,
80 entries: &mut Vec<(String, PathBuf)>,
81) {
82 let pfx = ensure_trailing_backslash(prefix);
83 if let Some(d) = value.as_str() {
84 entries.push((pfx, base.join(d)));
85 } else if let Some(arr) = value.as_array() {
86 for item in arr {
87 if let Some(d) = item.as_str() {
88 entries.push((pfx.clone(), base.join(d)));
89 }
90 }
91 }
92}
93
94fn collect_prefix_dirs_raw(
99 value: &serde_json::Value,
100 prefix: &str,
101 base: &Path,
102 entries: &mut Vec<(String, PathBuf)>,
103) {
104 if let Some(d) = value.as_str() {
105 entries.push((prefix.to_string(), base.join(d)));
106 } else if let Some(arr) = value.as_array() {
107 for item in arr {
108 if let Some(d) = item.as_str() {
109 entries.push((prefix.to_string(), base.join(d)));
110 }
111 }
112 }
113}
114
115fn collect_path_array(value: &serde_json::Value, base: &Path, out: &mut Vec<PathBuf>) {
117 if let Some(arr) = value.as_array() {
118 for item in arr {
119 if let Some(s) = item.as_str() {
120 out.push(base.join(s));
121 }
122 }
123 }
124}
125
126fn parse_autoload_section(
127 autoload: &serde_json::Value,
128 base: &Path,
129 entries: &mut Vec<(String, PathBuf)>,
130 psr0_entries: &mut Vec<(String, PathBuf)>,
131 extras: &mut Vec<PathBuf>,
132) {
133 if let Some(map) = autoload.get("psr-4").and_then(|v| v.as_object()) {
134 for (prefix, dir) in map {
135 collect_prefix_dirs(dir, prefix, base, entries);
136 }
137 }
138 if let Some(map) = autoload.get("psr-0").and_then(|v| v.as_object()) {
145 for (prefix, dir) in map {
146 collect_prefix_dirs_raw(dir, prefix, base, psr0_entries);
147 if let Some(d) = dir.as_str() {
148 extras.push(base.join(d));
149 } else if let Some(arr) = dir.as_array() {
150 for item in arr {
151 if let Some(d) = item.as_str() {
152 extras.push(base.join(d));
153 }
154 }
155 }
156 }
157 }
158 if let Some(cm) = autoload.get("classmap") {
159 collect_path_array(cm, base, extras);
160 }
161 if let Some(files) = autoload.get("files") {
162 collect_path_array(files, base, extras);
163 }
164}
165
166fn parse_composer_autoload_array(
185 content: &str,
186 vendor_dir: &Path,
187 base_dir: &Path,
188) -> Vec<(String, PathBuf)> {
189 let mut out = Vec::new();
190 for line in content.lines() {
191 let line = line.trim();
192 let (key, rest) = match extract_quoted(line) {
194 Some(p) => p,
195 None => continue,
196 };
197 let rest = rest.trim_start();
198 let rest = match rest.strip_prefix("=>") {
199 Some(r) => r.trim_start(),
200 None => continue,
201 };
202 let (var, rest) = match rest.strip_prefix('$') {
203 Some(r) => {
204 let end = r
205 .find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
206 .unwrap_or(r.len());
207 (&r[..end], &r[end..])
208 }
209 None => continue,
210 };
211 let rest = rest.trim_start();
212 let rest = match rest.strip_prefix('.') {
213 Some(r) => r.trim_start(),
214 None => continue,
215 };
216 let (path_frag, _) = match extract_quoted(rest) {
217 Some(p) => p,
218 None => continue,
219 };
220 let base = match var {
221 "vendorDir" => vendor_dir,
222 "baseDir" => base_dir,
223 _ => continue,
224 };
225 let path_rel = path_frag.trim_start_matches('/');
227 out.push((key, base.join(path_rel)));
228 }
229 out
230}
231
232fn extract_quoted(s: &str) -> Option<(String, &str)> {
236 let mut it = s.char_indices();
237 let (_, quote) = it.next()?;
238 if quote != '\'' && quote != '"' {
239 return None;
240 }
241 let mut out = String::new();
242 let mut escape = false;
243 for (i, ch) in it {
244 if escape {
245 out.push(ch);
246 escape = false;
247 } else if ch == '\\' {
248 escape = true;
249 } else if ch == quote {
250 return Some((out, &s[i + ch.len_utf8()..]));
251 } else {
252 out.push(ch);
253 }
254 }
255 None
256}
257
258fn parse_vendor(
259 root: &Path,
260 entries: &mut Vec<(String, PathBuf)>,
261 psr0_entries: &mut Vec<(String, PathBuf)>,
262 extras: &mut Vec<PathBuf>,
263) {
264 let installed_path = root.join("vendor/composer/installed.json");
265 let content = match std::fs::read_to_string(&installed_path) {
266 Ok(c) => c,
267 Err(_) => return,
268 };
269 let value: serde_json::Value = match serde_json::from_str(&content) {
270 Ok(v) => v,
271 Err(e) => {
272 eprintln!(
273 "mir: warning: failed to parse {}: {e} (vendor PSR-4 map will be empty)",
274 installed_path.display()
275 );
276 return;
277 }
278 };
279
280 let packages = if let Some(arr) = value.get("packages").and_then(|v| v.as_array()) {
281 arr.clone()
282 } else if let Some(arr) = value.as_array() {
283 arr.clone()
284 } else {
285 return;
286 };
287
288 let vendor_dir = root.join("vendor");
289
290 for pkg in &packages {
291 let pkg_name = pkg.get("name").and_then(|v| v.as_str()).unwrap_or("");
292 let pkg_dir = vendor_dir.join(pkg_name);
293 if let Some(autoload) = pkg.get("autoload") {
294 parse_autoload_section(autoload, &pkg_dir, entries, psr0_entries, extras);
295 }
296 }
297}
298
299fn read_classmap(vendor_dir: &Path, base_dir: &Path) -> FxHashMap<String, PathBuf> {
308 let path = vendor_dir.join("composer/autoload_classmap.php");
309 let Ok(bytes) = std::fs::read(&path) else {
310 return FxHashMap::default();
311 };
312 let content = String::from_utf8_lossy(&bytes);
313 parse_composer_autoload_array(&content, vendor_dir, base_dir)
314 .into_iter()
315 .collect()
316}
317
318fn read_files_autoload(vendor_dir: &Path, base_dir: &Path) -> Vec<PathBuf> {
322 let path = vendor_dir.join("composer/autoload_files.php");
323 if let Ok(bytes) = std::fs::read(&path) {
324 let content = String::from_utf8_lossy(&bytes);
325 return parse_composer_autoload_array(&content, vendor_dir, base_dir)
326 .into_iter()
327 .map(|(_, p)| p)
328 .filter(|p| p.is_file())
329 .collect();
330 }
331 let installed_path = vendor_dir.join("composer/installed.json");
333 let content = match std::fs::read_to_string(&installed_path) {
334 Ok(c) => c,
335 Err(_) => return Vec::new(),
336 };
337 let value: serde_json::Value = match serde_json::from_str(&content) {
338 Ok(v) => v,
339 Err(e) => {
340 eprintln!(
341 "mir: warning: failed to parse {}: {e} (autoload.files from vendor will be empty)",
342 installed_path.display()
343 );
344 return Vec::new();
345 }
346 };
347 let packages = if let Some(arr) = value.get("packages").and_then(|v| v.as_array()) {
348 arr.clone()
349 } else if let Some(arr) = value.as_array() {
350 arr.clone()
351 } else {
352 return Vec::new();
353 };
354 let mut out = Vec::new();
355 for pkg in &packages {
356 let pkg_name = pkg.get("name").and_then(|v| v.as_str()).unwrap_or("");
357 let pkg_dir = vendor_dir.join(pkg_name);
358 if let Some(files) = pkg.get("autoload").and_then(|a| a.get("files")) {
359 collect_path_array(files, &pkg_dir, &mut out);
360 }
361 }
362 let _ = base_dir; out.into_iter().filter(|p| p.is_file()).collect()
364}
365
366fn psr0_logical_path(key: &str) -> PathBuf {
373 let logical = match key.rfind('\\') {
374 Some(pos) => {
375 let (namespace, class_name) = key.split_at(pos + 1);
376 format!(
377 "{}{}",
378 namespace.replace('\\', "/"),
379 class_name.replace('_', "/")
380 )
381 }
382 None => key.replace('_', "/"),
383 };
384 PathBuf::from(logical).with_extension("php")
385}
386
387impl Psr4Map {
388 pub fn from_composer(root: &Path) -> Result<Self, ComposerError> {
389 Self::from_composer_with_extensions(root, PhpFileExtensions::default())
390 }
391
392 pub fn from_composer_with_extensions(
395 root: &Path,
396 extensions: PhpFileExtensions,
397 ) -> Result<Self, ComposerError> {
398 let composer_path = root.join("composer.json");
399 let content = std::fs::read_to_string(&composer_path)?;
400 let value: serde_json::Value = serde_json::from_str(&content)?;
401
402 let has_autoload = value.get("autoload").is_some() || value.get("autoload-dev").is_some();
403 if !has_autoload {
404 return Err(ComposerError::MissingAutoload);
405 }
406
407 let mut project_entries: Vec<(String, PathBuf)> = Vec::new();
408 let mut project_psr0_entries: Vec<(String, PathBuf)> = Vec::new();
409 let mut project_extra_paths: Vec<PathBuf> = Vec::new();
410
411 if let Some(autoload) = value.get("autoload") {
412 parse_autoload_section(
413 autoload,
414 root,
415 &mut project_entries,
416 &mut project_psr0_entries,
417 &mut project_extra_paths,
418 );
419 }
420 if let Some(autoload) = value.get("autoload-dev") {
421 parse_autoload_section(
422 autoload,
423 root,
424 &mut project_entries,
425 &mut project_psr0_entries,
426 &mut project_extra_paths,
427 );
428 }
429
430 project_entries.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
431 project_psr0_entries.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
432
433 let mut vendor_entries: Vec<(String, PathBuf)> = Vec::new();
434 let mut vendor_psr0_entries: Vec<(String, PathBuf)> = Vec::new();
435 let mut vendor_extra_paths: Vec<PathBuf> = Vec::new();
436 parse_vendor(
437 root,
438 &mut vendor_entries,
439 &mut vendor_psr0_entries,
440 &mut vendor_extra_paths,
441 );
442 vendor_entries.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
443 vendor_psr0_entries.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
444
445 let vendor_dir = root.join("vendor");
449 let classmap = read_classmap(&vendor_dir, root);
450
451 let mut vendor_eager_files = read_files_autoload(&vendor_dir, root);
455 vendor_eager_files =
464 include_closure(vendor_eager_files, VendorTargets::Follow, &extensions);
465
466 Ok(Psr4Map {
467 project_entries,
468 vendor_entries,
469 project_psr0_entries,
470 vendor_psr0_entries,
471 project_extra_paths,
472 vendor_extra_paths,
473 classmap,
474 vendor_eager_files,
475 root: root.to_path_buf(),
476 extensions,
477 })
478 }
479
480 pub fn project_files(&self) -> Vec<PathBuf> {
481 let mut out = Vec::new();
482 for (_, dir) in &self.project_entries {
483 crate::batch::collect_php_files(dir, &mut out, &self.extensions);
484 }
485 for path in &self.project_extra_paths {
486 collect_php_path(path, &mut out, &self.extensions);
487 }
488 include_closure(out, VendorTargets::Skip, &self.extensions)
489 }
490
491 pub fn vendor_files(&self) -> Vec<PathBuf> {
492 let mut out = Vec::new();
493 for (_, dir) in &self.vendor_entries {
494 crate::batch::collect_php_files(dir, &mut out, &self.extensions);
495 }
496 for path in &self.vendor_extra_paths {
497 collect_php_path(path, &mut out, &self.extensions);
498 }
499 out
500 }
501
502 pub fn resolve(&self, fqcn: &str) -> Option<PathBuf> {
516 let key = fqcn.trim_start_matches('\\');
517 for (prefix, dir) in self
518 .project_entries
519 .iter()
520 .chain(self.vendor_entries.iter())
521 {
522 if key.starts_with(prefix.as_str()) {
523 let relative = &key[prefix.len()..];
524 let file_path = dir.join(relative.replace('\\', "/")).with_extension("php");
525 if file_path.exists() {
526 return Some(file_path);
527 }
528 }
529 }
530 for (prefix, dir) in self
531 .project_psr0_entries
532 .iter()
533 .chain(self.vendor_psr0_entries.iter())
534 {
535 if !prefix.is_empty() && !key.starts_with(prefix.as_str()) {
536 continue;
537 }
538 let file_path = dir.join(psr0_logical_path(key));
539 if file_path.exists() {
540 return Some(file_path);
541 }
542 }
543 if let Some(path) = self.classmap.get(key) {
544 if path.exists() {
545 return Some(path.clone());
546 }
547 }
548 None
549 }
550
551 pub fn vendor_eager_files(&self) -> Vec<PathBuf> {
559 self.vendor_eager_files.clone()
560 }
561
562 pub fn all_vendor_files(&self) -> Vec<PathBuf> {
573 let mut seen: rustc_hash::FxHashSet<PathBuf> = rustc_hash::FxHashSet::default();
574 let mut out = Vec::new();
575 let mut push = |p: PathBuf, out: &mut Vec<PathBuf>| {
576 if seen.insert(p.clone()) {
577 out.push(p);
578 }
579 };
580 for p in self.vendor_files() {
581 push(p, &mut out);
582 }
583 for p in self.classmap.values() {
586 if p.is_file() {
587 push(p.clone(), &mut out);
588 }
589 }
590 for p in self.vendor_eager_files() {
591 push(p, &mut out);
592 }
593 out
594 }
595
596 pub fn classmap_len(&self) -> usize {
599 self.classmap.len()
600 }
601}
602
603fn collect_php_path(path: &Path, out: &mut Vec<PathBuf>, extensions: &PhpFileExtensions) {
606 let Ok(meta) = std::fs::metadata(path) else {
607 return;
608 };
609 if meta.is_file() {
610 if extensions.is_php_source(path) {
611 out.push(path.to_path_buf());
612 }
613 } else if meta.is_dir() {
614 crate::batch::collect_php_files(path, out, extensions);
615 }
616}
617
618#[cfg(test)]
619mod tests {
620 use super::*;
621 use std::fs;
622
623 fn make_temp_project(name: &str) -> PathBuf {
624 let dir = std::env::temp_dir().join(format!("mir_psr4_{name}"));
625 let _ = fs::remove_dir_all(&dir);
626 fs::create_dir_all(&dir).unwrap();
627 dir
628 }
629
630 #[test]
631 fn parse_project_entries() {
632 let root = make_temp_project("parse_project_entries");
633 fs::write(
634 root.join("composer.json"),
635 r#"{
636 "autoload": {
637 "psr-4": { "App\\": "src/", "App\\Models\\": "src/models/" }
638 },
639 "autoload-dev": {
640 "psr-4": { "Tests\\": "tests/" }
641 }
642 }"#,
643 )
644 .unwrap();
645
646 let map = Psr4Map::from_composer(&root).unwrap();
647
648 let prefixes: Vec<&str> = map
649 .project_entries
650 .iter()
651 .map(|(p, _)| p.as_str())
652 .collect();
653 assert!(prefixes.contains(&"App\\Models\\"), "missing App\\Models\\");
654 assert!(prefixes.contains(&"App\\"), "missing App\\");
655 assert!(prefixes.contains(&"Tests\\"), "missing Tests\\");
656 }
657
658 #[test]
659 fn longest_prefix_first() {
660 let root = make_temp_project("longest_prefix_first");
661 fs::write(
662 root.join("composer.json"),
663 r#"{
664 "autoload": {
665 "psr-4": { "App\\": "src/", "App\\Models\\": "src/models/" }
666 }
667 }"#,
668 )
669 .unwrap();
670
671 let map = Psr4Map::from_composer(&root).unwrap();
672
673 assert_eq!(map.project_entries[0].0, "App\\Models\\");
674 }
675
676 #[test]
677 fn missing_autoload_section_is_error() {
678 let root = make_temp_project("missing_autoload");
679 fs::write(root.join("composer.json"), r#"{ "name": "my/pkg" }"#).unwrap();
680
681 let result = Psr4Map::from_composer(&root);
682 assert!(
683 matches!(result, Err(ComposerError::MissingAutoload)),
684 "expected MissingAutoload error"
685 );
686 }
687
688 #[test]
689 fn composer_v2_installed() {
690 let root = make_temp_project("composer_v2");
691 fs::write(
692 root.join("composer.json"),
693 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
694 )
695 .unwrap();
696
697 let vendor_dir = root.join("vendor/composer");
698 fs::create_dir_all(&vendor_dir).unwrap();
699 fs::write(
700 vendor_dir.join("installed.json"),
701 r#"{
702 "packages": [
703 {
704 "name": "vendor/pkg",
705 "autoload": { "psr-4": { "Vendor\\Pkg\\": "src/" } }
706 }
707 ]
708 }"#,
709 )
710 .unwrap();
711 fs::create_dir_all(root.join("vendor/vendor/pkg/src")).unwrap();
712
713 let map = Psr4Map::from_composer(&root).unwrap();
714 let prefixes: Vec<&str> = map.vendor_entries.iter().map(|(p, _)| p.as_str()).collect();
715 assert!(prefixes.contains(&"Vendor\\Pkg\\"), "missing Vendor\\Pkg\\");
716 }
717
718 #[test]
719 fn composer_v1_installed() {
720 let root = make_temp_project("composer_v1");
721 fs::write(
722 root.join("composer.json"),
723 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
724 )
725 .unwrap();
726
727 let vendor_dir = root.join("vendor/composer");
728 fs::create_dir_all(&vendor_dir).unwrap();
729 fs::write(
730 vendor_dir.join("installed.json"),
731 r#"[
732 {
733 "name": "vendor/pkg",
734 "autoload": { "psr-4": { "Vendor\\Pkg\\": "src/" } }
735 }
736 ]"#,
737 )
738 .unwrap();
739 fs::create_dir_all(root.join("vendor/vendor/pkg/src")).unwrap();
740
741 let map = Psr4Map::from_composer(&root).unwrap();
742 let prefixes: Vec<&str> = map.vendor_entries.iter().map(|(p, _)| p.as_str()).collect();
743 assert!(prefixes.contains(&"Vendor\\Pkg\\"), "missing Vendor\\Pkg\\");
744 }
745
746 #[test]
747 fn missing_installed_json() {
748 let root = make_temp_project("missing_installed");
749 fs::write(
750 root.join("composer.json"),
751 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
752 )
753 .unwrap();
754 let map = Psr4Map::from_composer(&root).unwrap();
755 assert!(map.vendor_entries.is_empty());
756 }
757
758 #[test]
759 fn project_files_returns_php_files() {
760 let root = make_temp_project("project_files");
761 let src = root.join("src");
762 fs::create_dir_all(&src).unwrap();
763 fs::write(src.join("Foo.php"), "<?php class Foo {}").unwrap();
764 fs::write(src.join("README.md"), "not php").unwrap();
765 fs::write(
766 root.join("composer.json"),
767 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
768 )
769 .unwrap();
770
771 let map = Psr4Map::from_composer(&root).unwrap();
772 let files = map.project_files();
773 assert_eq!(files.len(), 1);
774 assert!(files[0].ends_with("Foo.php"));
775 }
776
777 #[test]
783 fn project_files_follows_dir_relative_require() {
784 let root = make_temp_project("require_dir_relative");
785 let src = root.join("src");
786 let legacy = root.join("legacy");
787 fs::create_dir_all(&src).unwrap();
788 fs::create_dir_all(&legacy).unwrap();
789 fs::write(
790 src.join("Bootstrap.php"),
791 "<?php require_once __DIR__ . '/../legacy/bootstrap.php'; class Bootstrap {}",
792 )
793 .unwrap();
794 fs::write(
795 legacy.join("bootstrap.php"),
796 "<?php function legacy_init() {}",
797 )
798 .unwrap();
799 fs::write(
800 root.join("composer.json"),
801 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
802 )
803 .unwrap();
804
805 let map = Psr4Map::from_composer(&root).unwrap();
806 let files = map.project_files();
807 assert_eq!(files.len(), 2, "expected Bootstrap.php + bootstrap.php");
808 assert!(files.iter().any(|f| f.ends_with("bootstrap.php")));
809 }
810
811 #[test]
812 fn project_files_follows_dirname_file_require() {
813 let root = make_temp_project("require_dirname_file");
814 let src = root.join("src");
815 let legacy = root.join("legacy");
816 fs::create_dir_all(&src).unwrap();
817 fs::create_dir_all(&legacy).unwrap();
818 fs::write(
819 src.join("Bootstrap.php"),
820 "<?php require_once dirname(__FILE__) . '/../legacy/bootstrap.php'; class Bootstrap {}",
821 )
822 .unwrap();
823 fs::write(
824 legacy.join("bootstrap.php"),
825 "<?php function legacy_init() {}",
826 )
827 .unwrap();
828 fs::write(
829 root.join("composer.json"),
830 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
831 )
832 .unwrap();
833
834 let map = Psr4Map::from_composer(&root).unwrap();
835 let files = map.project_files();
836 assert!(files.iter().any(|f| f.ends_with("bootstrap.php")));
837 }
838
839 #[test]
840 fn project_files_follows_bare_literal_require_relative_to_including_file() {
841 let root = make_temp_project("require_bare_literal");
842 let src = root.join("src");
843 let legacy = root.join("legacy");
844 fs::create_dir_all(&src).unwrap();
845 fs::create_dir_all(&legacy).unwrap();
846 fs::write(
847 src.join("Bootstrap.php"),
848 "<?php require_once '../legacy/bootstrap.php'; class Bootstrap {}",
849 )
850 .unwrap();
851 fs::write(
852 legacy.join("bootstrap.php"),
853 "<?php function legacy_init() {}",
854 )
855 .unwrap();
856 fs::write(
857 root.join("composer.json"),
858 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
859 )
860 .unwrap();
861
862 let map = Psr4Map::from_composer(&root).unwrap();
863 let files = map.project_files();
864 assert!(files.iter().any(|f| f.ends_with("bootstrap.php")));
865 }
866
867 #[test]
868 fn project_files_follows_require_transitively() {
869 let root = make_temp_project("require_transitive");
870 let src = root.join("src");
871 let legacy = root.join("legacy");
872 fs::create_dir_all(&src).unwrap();
873 fs::create_dir_all(&legacy).unwrap();
874 fs::write(
875 src.join("Bootstrap.php"),
876 "<?php require_once __DIR__ . '/../legacy/a.php';",
877 )
878 .unwrap();
879 fs::write(
880 legacy.join("a.php"),
881 "<?php require_once __DIR__ . '/b.php';",
882 )
883 .unwrap();
884 fs::write(legacy.join("b.php"), "<?php function b() {}").unwrap();
885 fs::write(
886 root.join("composer.json"),
887 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
888 )
889 .unwrap();
890
891 let map = Psr4Map::from_composer(&root).unwrap();
892 let files = map.project_files();
893 assert!(files.iter().any(|f| f.ends_with("a.php")));
894 assert!(files.iter().any(|f| f.ends_with("b.php")));
895 }
896
897 #[test]
898 fn project_files_require_cycle_does_not_hang() {
899 let root = make_temp_project("require_cycle");
900 let src = root.join("src");
901 let legacy = root.join("legacy");
902 fs::create_dir_all(&src).unwrap();
903 fs::create_dir_all(&legacy).unwrap();
904 fs::write(
905 src.join("Bootstrap.php"),
906 "<?php require_once __DIR__ . '/../legacy/a.php';",
907 )
908 .unwrap();
909 fs::write(
910 legacy.join("a.php"),
911 "<?php require_once __DIR__ . '/b.php';",
912 )
913 .unwrap();
914 fs::write(
915 legacy.join("b.php"),
916 "<?php require_once __DIR__ . '/a.php';",
917 )
918 .unwrap();
919 fs::write(
920 root.join("composer.json"),
921 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
922 )
923 .unwrap();
924
925 let map = Psr4Map::from_composer(&root).unwrap();
926 let files = map.project_files();
927 assert_eq!(
928 files.len(),
929 3,
930 "each file discovered exactly once despite the cycle"
931 );
932 }
933
934 #[test]
935 fn project_files_missing_require_target_is_skipped() {
936 let root = make_temp_project("require_missing_target");
937 let src = root.join("src");
938 fs::create_dir_all(&src).unwrap();
939 fs::write(
940 src.join("Bootstrap.php"),
941 "<?php require_once __DIR__ . '/../legacy/does_not_exist.php';",
942 )
943 .unwrap();
944 fs::write(
945 root.join("composer.json"),
946 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
947 )
948 .unwrap();
949
950 let map = Psr4Map::from_composer(&root).unwrap();
951 let files = map.project_files();
952 assert_eq!(files.len(), 1, "only Bootstrap.php itself");
953 }
954
955 #[test]
956 fn project_files_dynamic_require_target_not_followed() {
957 let root = make_temp_project("require_dynamic_target");
958 let src = root.join("src");
959 let legacy = root.join("legacy");
960 fs::create_dir_all(&src).unwrap();
961 fs::create_dir_all(&legacy).unwrap();
962 fs::write(
963 src.join("Bootstrap.php"),
964 "<?php $path = getPath(); require_once $path;",
965 )
966 .unwrap();
967 fs::write(
968 legacy.join("bootstrap.php"),
969 "<?php function legacy_init() {}",
970 )
971 .unwrap();
972 fs::write(
973 root.join("composer.json"),
974 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
975 )
976 .unwrap();
977
978 let map = Psr4Map::from_composer(&root).unwrap();
979 let files = map.project_files();
980 assert_eq!(
981 files.len(),
982 1,
983 "a non-statically-resolvable require target must not be followed"
984 );
985 }
986
987 #[test]
988 fn project_files_require_into_vendor_is_skipped() {
989 let root = make_temp_project("require_into_vendor");
990 let src = root.join("src");
991 let vendor_pkg = root.join("vendor/some/pkg");
992 fs::create_dir_all(&src).unwrap();
993 fs::create_dir_all(&vendor_pkg).unwrap();
994 fs::write(
995 src.join("Bootstrap.php"),
996 "<?php require_once __DIR__ . '/../vendor/some/pkg/lib.php';",
997 )
998 .unwrap();
999 fs::write(vendor_pkg.join("lib.php"), "<?php function lib() {}").unwrap();
1000 fs::write(
1001 root.join("composer.json"),
1002 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1003 )
1004 .unwrap();
1005
1006 let map = Psr4Map::from_composer(&root).unwrap();
1007 let files = map.project_files();
1008 assert_eq!(
1009 files.len(),
1010 1,
1011 "vendor-reaching require must not be indexed as a project file"
1012 );
1013 }
1014
1015 #[test]
1016 fn resolve_existing_file() {
1017 let root = make_temp_project("resolve_existing");
1018 let models = root.join("src/models");
1019 fs::create_dir_all(&models).unwrap();
1020 fs::write(models.join("User.php"), "<?php class User {}").unwrap();
1021 fs::write(
1022 root.join("composer.json"),
1023 r#"{"autoload":{"psr-4":{"App\\Models\\":"src/models/","App\\":"src/"}}}"#,
1024 )
1025 .unwrap();
1026
1027 let map = Psr4Map::from_composer(&root).unwrap();
1028 let result = map.resolve("App\\Models\\User");
1029 assert!(result.is_some(), "expected a resolved path");
1030 assert!(result.unwrap().ends_with("User.php"));
1031 }
1032
1033 #[test]
1034 fn resolves_same_short_name_from_its_exact_namespace() {
1035 let root = make_temp_project("same_short_name_exact_namespace");
1041 let target = root.join("vendor/pkg/target/src");
1042 let decoy = root.join("vendor/pkg/decoy/src");
1043 fs::create_dir_all(&target).unwrap();
1044 fs::create_dir_all(&decoy).unwrap();
1045 fs::write(
1046 target.join("Widget.php"),
1047 "<?php namespace Zeta; class Widget {}",
1048 )
1049 .unwrap();
1050 fs::write(
1051 decoy.join("Widget.php"),
1052 "<?php namespace Alpha; class Widget {}",
1053 )
1054 .unwrap();
1055 fs::write(
1056 root.join("composer.json"),
1057 r#"{"autoload":{"psr-4":{"Alpha\\":"vendor/pkg/decoy/src/","Zeta\\":"vendor/pkg/target/src/"}}}"#,
1058 )
1059 .unwrap();
1060
1061 let map = Psr4Map::from_composer(&root).unwrap();
1062 assert_eq!(
1063 map.resolve("\\Zeta\\Widget"),
1064 Some(target.join("Widget.php"))
1065 );
1066 assert_eq!(map.resolve("Alpha\\Widget"), Some(decoy.join("Widget.php")));
1067 }
1068
1069 #[test]
1070 fn resolve_missing_file() {
1071 let root = make_temp_project("resolve_missing");
1072 fs::create_dir_all(root.join("src")).unwrap();
1073 fs::write(
1074 root.join("composer.json"),
1075 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1076 )
1077 .unwrap();
1078
1079 let map = Psr4Map::from_composer(&root).unwrap();
1080 let result = map.resolve("App\\Models\\User");
1081 assert!(result.is_none());
1082 }
1083
1084 #[test]
1085 fn boundary_check() {
1086 let root = make_temp_project("boundary_check");
1087 fs::create_dir_all(root.join("src")).unwrap();
1088 fs::write(
1089 root.join("composer.json"),
1090 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1091 )
1092 .unwrap();
1093
1094 let map = Psr4Map::from_composer(&root).unwrap();
1095 let result = map.resolve("Application\\Foo");
1097 assert!(
1098 result.is_none(),
1099 "App\\ prefix must not match Application\\Foo"
1100 );
1101 }
1102
1103 #[test]
1104 fn array_valued_psr4_dirs() {
1105 let root = make_temp_project("array_dirs");
1106 let src = root.join("src");
1107 let lib = root.join("lib");
1108 fs::create_dir_all(&src).unwrap();
1109 fs::create_dir_all(&lib).unwrap();
1110 fs::write(src.join("Foo.php"), "<?php class Foo {}").unwrap();
1111 fs::write(lib.join("Bar.php"), "<?php class Bar {}").unwrap();
1112 fs::write(
1113 root.join("composer.json"),
1114 r#"{"autoload":{"psr-4":{"App\\":["src/","lib/"]}}}"#,
1115 )
1116 .unwrap();
1117
1118 let map = Psr4Map::from_composer(&root).unwrap();
1119 assert_eq!(
1121 map.project_entries.len(),
1122 2,
1123 "expected 2 entries for array-valued dir"
1124 );
1125 let files = map.project_files();
1126 assert_eq!(files.len(), 2, "expected Foo.php and Bar.php");
1127 }
1128
1129 #[test]
1134 fn project_classmap_dir_is_collected() {
1135 let root = make_temp_project("project_classmap");
1136 let lib = root.join("lib");
1137 fs::create_dir_all(&lib).unwrap();
1138 fs::write(lib.join("Legacy.php"), "<?php class Legacy {}").unwrap();
1139 fs::write(
1140 root.join("composer.json"),
1141 r#"{"autoload":{"classmap":["lib/"]}}"#,
1142 )
1143 .unwrap();
1144
1145 let map = Psr4Map::from_composer(&root).unwrap();
1146 let files = map.project_files();
1147 assert_eq!(files.len(), 1);
1148 assert!(files[0].ends_with("Legacy.php"));
1149 }
1150
1151 #[test]
1152 fn project_files_autoload_is_collected() {
1153 let root = make_temp_project("project_files_autoload");
1154 fs::write(root.join("helpers.php"), "<?php function my_helper() {}").unwrap();
1155 fs::write(
1156 root.join("composer.json"),
1157 r#"{"autoload":{"files":["helpers.php"]}}"#,
1158 )
1159 .unwrap();
1160
1161 let map = Psr4Map::from_composer(&root).unwrap();
1162 let files = map.project_files();
1163 assert_eq!(files.len(), 1);
1164 assert!(files[0].ends_with("helpers.php"));
1165 }
1166
1167 #[test]
1168 fn project_psr0_dir_is_collected() {
1169 let root = make_temp_project("project_psr0");
1170 let lib = root.join("legacy");
1171 fs::create_dir_all(&lib).unwrap();
1172 fs::write(lib.join("Old.php"), "<?php class Old {}").unwrap();
1173 fs::write(
1174 root.join("composer.json"),
1175 r#"{"autoload":{"psr-0":{"":"legacy/"}}}"#,
1176 )
1177 .unwrap();
1178
1179 let map = Psr4Map::from_composer(&root).unwrap();
1180 let files = map.project_files();
1181 assert_eq!(files.len(), 1);
1182 assert!(files[0].ends_with("Old.php"));
1183 }
1184
1185 #[test]
1186 fn vendor_classmap_is_collected() {
1187 let root = make_temp_project("vendor_classmap");
1188 fs::write(
1189 root.join("composer.json"),
1190 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1191 )
1192 .unwrap();
1193 let vendor_dir = root.join("vendor/composer");
1194 fs::create_dir_all(&vendor_dir).unwrap();
1195 fs::write(
1196 vendor_dir.join("installed.json"),
1197 r#"{
1198 "packages": [{
1199 "name": "vendor/pkg",
1200 "autoload": { "classmap": ["src/"] }
1201 }]
1202 }"#,
1203 )
1204 .unwrap();
1205 let pkg_src = root.join("vendor/vendor/pkg/src");
1206 fs::create_dir_all(&pkg_src).unwrap();
1207 fs::write(pkg_src.join("Legacy.php"), "<?php class Legacy {}").unwrap();
1208
1209 let map = Psr4Map::from_composer(&root).unwrap();
1210 let files = map.vendor_files();
1211 assert_eq!(files.len(), 1);
1212 assert!(files[0].ends_with("Legacy.php"));
1213 }
1214
1215 #[test]
1216 fn vendor_files_autoload_is_collected() {
1217 let root = make_temp_project("vendor_files_autoload");
1218 fs::write(
1219 root.join("composer.json"),
1220 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1221 )
1222 .unwrap();
1223 let vendor_dir = root.join("vendor/composer");
1224 fs::create_dir_all(&vendor_dir).unwrap();
1225 fs::write(
1226 vendor_dir.join("installed.json"),
1227 r#"{
1228 "packages": [{
1229 "name": "vendor/pkg",
1230 "autoload": { "files": ["bootstrap.php"] }
1231 }]
1232 }"#,
1233 )
1234 .unwrap();
1235 let pkg_dir = root.join("vendor/vendor/pkg");
1236 fs::create_dir_all(&pkg_dir).unwrap();
1237 fs::write(
1238 pkg_dir.join("bootstrap.php"),
1239 "<?php function pkg_bootstrap() {}",
1240 )
1241 .unwrap();
1242
1243 let map = Psr4Map::from_composer(&root).unwrap();
1244 let files = map.vendor_files();
1245 assert_eq!(files.len(), 1);
1246 assert!(files[0].ends_with("bootstrap.php"));
1247 }
1248
1249 #[test]
1254 fn vendor_eager_files_follows_polyfill_bootstrap_require() {
1255 let root = make_temp_project("vendor_eager_files_polyfill");
1256 fs::write(
1257 root.join("composer.json"),
1258 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1259 )
1260 .unwrap();
1261 let vendor_dir = root.join("vendor/composer");
1262 fs::create_dir_all(&vendor_dir).unwrap();
1263 fs::write(
1264 vendor_dir.join("installed.json"),
1265 r#"{
1266 "packages": [{
1267 "name": "vendor/polyfill",
1268 "autoload": { "files": ["bootstrap.php"] }
1269 }]
1270 }"#,
1271 )
1272 .unwrap();
1273 let pkg_dir = root.join("vendor/vendor/polyfill");
1274 fs::create_dir_all(&pkg_dir).unwrap();
1275 fs::write(
1276 pkg_dir.join("bootstrap.php"),
1277 "<?php if (PHP_VERSION_ID >= 80000) { require_once __DIR__ . '/bootstrap80.php'; } \
1278 else { require_once __DIR__ . '/bootstrap_php7.php'; }",
1279 )
1280 .unwrap();
1281 fs::write(
1282 pkg_dir.join("bootstrap80.php"),
1283 "<?php function polyfill_mb_str_split() {}",
1284 )
1285 .unwrap();
1286 fs::write(
1287 pkg_dir.join("bootstrap_php7.php"),
1288 "<?php function polyfill_mb_str_split_legacy() {}",
1289 )
1290 .unwrap();
1291
1292 let map = Psr4Map::from_composer(&root).unwrap();
1293 let files = map.vendor_eager_files();
1294 assert!(
1295 files.iter().any(|p| p.ends_with("bootstrap.php")),
1296 "expected bootstrap.php in {files:?}"
1297 );
1298 assert!(
1299 files.iter().any(|p| p.ends_with("bootstrap80.php")),
1300 "expected the require_once-d sibling bootstrap80.php in {files:?}"
1301 );
1302 assert!(
1303 files.iter().any(|p| p.ends_with("bootstrap_php7.php")),
1304 "expected the require_once-d sibling bootstrap_php7.php in {files:?}"
1305 );
1306 }
1307
1308 #[test]
1309 fn resolve_psr0_project_namespaced_class() {
1310 let root = make_temp_project("resolve_psr0_project_namespaced");
1311 let mailer_dir = root.join("src/Mailer");
1312 fs::create_dir_all(&mailer_dir).unwrap();
1313 fs::write(
1314 mailer_dir.join("Message.php"),
1315 "<?php namespace Mailer; class Message {}",
1316 )
1317 .unwrap();
1318 fs::write(
1319 root.join("composer.json"),
1320 r#"{"autoload":{"psr-0":{"Mailer\\":"src/"}}}"#,
1321 )
1322 .unwrap();
1323
1324 let map = Psr4Map::from_composer(&root).unwrap();
1325 let result = map.resolve("Mailer\\Message");
1326 assert!(result.is_some(), "expected a resolved PSR-0 path");
1327 assert!(result.unwrap().ends_with("Mailer/Message.php"));
1328 }
1329
1330 #[test]
1331 fn resolve_psr0_pear_style_class_with_underscores() {
1332 let root = make_temp_project("resolve_psr0_pear_style");
1333 let old_dir = root.join("src/Old");
1334 fs::create_dir_all(&old_dir).unwrap();
1335 fs::write(old_dir.join("Thing.php"), "<?php class Old_Thing {}").unwrap();
1336 fs::write(
1337 root.join("composer.json"),
1338 r#"{"autoload":{"psr-0":{"Old_":"src/"}}}"#,
1339 )
1340 .unwrap();
1341
1342 let map = Psr4Map::from_composer(&root).unwrap();
1343 let result = map.resolve("Old_Thing");
1344 assert!(result.is_some(), "expected a resolved PSR-0 path");
1345 assert!(result.unwrap().ends_with("Old/Thing.php"));
1346 }
1347
1348 #[test]
1349 fn resolve_psr0_vendor_class() {
1350 let root = make_temp_project("resolve_psr0_vendor");
1351 fs::write(
1352 root.join("composer.json"),
1353 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1354 )
1355 .unwrap();
1356 let vendor_dir = root.join("vendor/composer");
1357 fs::create_dir_all(&vendor_dir).unwrap();
1358 fs::write(
1359 vendor_dir.join("installed.json"),
1360 r#"{
1361 "packages": [{
1362 "name": "vendor/pkg",
1363 "autoload": { "psr-0": { "Old_": "src/" } }
1364 }]
1365 }"#,
1366 )
1367 .unwrap();
1368 let pkg_src = root.join("vendor/vendor/pkg/src/Old");
1369 fs::create_dir_all(&pkg_src).unwrap();
1370 fs::write(pkg_src.join("Thing.php"), "<?php class Old_Thing {}").unwrap();
1371
1372 let map = Psr4Map::from_composer(&root).unwrap();
1373 let result = map.resolve("Old_Thing");
1374 assert!(result.is_some(), "expected a resolved PSR-0 vendor path");
1375 assert!(result.unwrap().ends_with("Old/Thing.php"));
1376 }
1377
1378 #[test]
1379 fn resolve_psr0_prefix_must_still_match() {
1380 let root = make_temp_project("resolve_psr0_prefix_mismatch");
1381 fs::create_dir_all(root.join("src")).unwrap();
1382 fs::write(
1383 root.join("composer.json"),
1384 r#"{"autoload":{"psr-0":{"Mailer\\":"src/"}}}"#,
1385 )
1386 .unwrap();
1387
1388 let map = Psr4Map::from_composer(&root).unwrap();
1389 let result = map.resolve("Other\\Message");
1390 assert!(
1391 result.is_none(),
1392 "Mailer\\ psr-0 prefix must not match Other\\Message"
1393 );
1394 }
1395
1396 #[test]
1397 fn vendor_psr0_is_collected() {
1398 let root = make_temp_project("vendor_psr0");
1399 fs::write(
1400 root.join("composer.json"),
1401 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1402 )
1403 .unwrap();
1404 let vendor_dir = root.join("vendor/composer");
1405 fs::create_dir_all(&vendor_dir).unwrap();
1406 fs::write(
1407 vendor_dir.join("installed.json"),
1408 r#"{
1409 "packages": [{
1410 "name": "vendor/pkg",
1411 "autoload": { "psr-0": { "Old_": "src/" } }
1412 }]
1413 }"#,
1414 )
1415 .unwrap();
1416 let pkg_src = root.join("vendor/vendor/pkg/src/Old");
1417 fs::create_dir_all(&pkg_src).unwrap();
1418 fs::write(pkg_src.join("Thing.php"), "<?php class Old_Thing {}").unwrap();
1419
1420 let map = Psr4Map::from_composer(&root).unwrap();
1421 let files = map.vendor_files();
1422 assert_eq!(files.len(), 1);
1423 assert!(files[0].ends_with("Thing.php"));
1424 }
1425}