1use php_ast::ast::{BinaryOp, MagicConstKind};
2use php_ast::owned::visitor::{walk_owned_expr, OwnedVisitor};
3use php_ast::owned::{Expr, ExprKind};
4use rustc_hash::FxHashMap;
5use std::ops::ControlFlow;
6use std::path::{Component, Path, PathBuf};
7use thiserror::Error;
8
9#[derive(Debug, Error)]
14pub enum ComposerError {
15 #[error("composer I/O error: {0}")]
16 Io(#[from] std::io::Error),
17 #[error("composer JSON error: {0}")]
18 Json(#[from] serde_json::Error),
19 #[error("composer.json has no autoload section")]
20 MissingAutoload,
21}
22
23#[derive(Clone)]
42pub struct Psr4Map {
43 project_entries: Vec<(String, PathBuf)>,
44 vendor_entries: Vec<(String, PathBuf)>,
45 project_psr0_entries: Vec<(String, PathBuf)>,
46 vendor_psr0_entries: Vec<(String, PathBuf)>,
47 project_extra_paths: Vec<PathBuf>,
48 vendor_extra_paths: Vec<PathBuf>,
49 classmap: FxHashMap<String, PathBuf>,
56 vendor_eager_files: Vec<PathBuf>,
62 #[allow(dead_code)] root: PathBuf,
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 let composer_path = root.join("composer.json");
390 let content = std::fs::read_to_string(&composer_path)?;
391 let value: serde_json::Value = serde_json::from_str(&content)?;
392
393 let has_autoload = value.get("autoload").is_some() || value.get("autoload-dev").is_some();
394 if !has_autoload {
395 return Err(ComposerError::MissingAutoload);
396 }
397
398 let mut project_entries: Vec<(String, PathBuf)> = Vec::new();
399 let mut project_psr0_entries: Vec<(String, PathBuf)> = Vec::new();
400 let mut project_extra_paths: Vec<PathBuf> = Vec::new();
401
402 if let Some(autoload) = value.get("autoload") {
403 parse_autoload_section(
404 autoload,
405 root,
406 &mut project_entries,
407 &mut project_psr0_entries,
408 &mut project_extra_paths,
409 );
410 }
411 if let Some(autoload) = value.get("autoload-dev") {
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
421 project_entries.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
422 project_psr0_entries.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
423
424 let mut vendor_entries: Vec<(String, PathBuf)> = Vec::new();
425 let mut vendor_psr0_entries: Vec<(String, PathBuf)> = Vec::new();
426 let mut vendor_extra_paths: Vec<PathBuf> = Vec::new();
427 parse_vendor(
428 root,
429 &mut vendor_entries,
430 &mut vendor_psr0_entries,
431 &mut vendor_extra_paths,
432 );
433 vendor_entries.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
434 vendor_psr0_entries.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
435
436 let vendor_dir = root.join("vendor");
440 let classmap = read_classmap(&vendor_dir, root);
441
442 let mut vendor_eager_files = read_files_autoload(&vendor_dir, root);
446 expand_via_local_requires(&mut vendor_eager_files, false);
455
456 Ok(Psr4Map {
457 project_entries,
458 vendor_entries,
459 project_psr0_entries,
460 vendor_psr0_entries,
461 project_extra_paths,
462 vendor_extra_paths,
463 classmap,
464 vendor_eager_files,
465 root: root.to_path_buf(),
466 })
467 }
468
469 pub fn project_files(&self) -> Vec<PathBuf> {
470 let mut out = Vec::new();
471 for (_, dir) in &self.project_entries {
472 crate::batch::collect_php_files(dir, &mut out);
473 }
474 for path in &self.project_extra_paths {
475 collect_php_path(path, &mut out);
476 }
477 expand_via_local_requires(&mut out, true);
478 out
479 }
480
481 pub fn vendor_files(&self) -> Vec<PathBuf> {
482 let mut out = Vec::new();
483 for (_, dir) in &self.vendor_entries {
484 crate::batch::collect_php_files(dir, &mut out);
485 }
486 for path in &self.vendor_extra_paths {
487 collect_php_path(path, &mut out);
488 }
489 out
490 }
491
492 pub fn resolve(&self, fqcn: &str) -> Option<PathBuf> {
506 let key = fqcn.trim_start_matches('\\');
507 for (prefix, dir) in self
508 .project_entries
509 .iter()
510 .chain(self.vendor_entries.iter())
511 {
512 if key.starts_with(prefix.as_str()) {
513 let relative = &key[prefix.len()..];
514 let file_path = dir.join(relative.replace('\\', "/")).with_extension("php");
515 if file_path.exists() {
516 return Some(file_path);
517 }
518 }
519 }
520 for (prefix, dir) in self
521 .project_psr0_entries
522 .iter()
523 .chain(self.vendor_psr0_entries.iter())
524 {
525 if !prefix.is_empty() && !key.starts_with(prefix.as_str()) {
526 continue;
527 }
528 let file_path = dir.join(psr0_logical_path(key));
529 if file_path.exists() {
530 return Some(file_path);
531 }
532 }
533 if let Some(path) = self.classmap.get(key) {
534 if path.exists() {
535 return Some(path.clone());
536 }
537 }
538 None
539 }
540
541 pub fn vendor_eager_files(&self) -> Vec<PathBuf> {
549 self.vendor_eager_files.clone()
550 }
551
552 pub fn all_vendor_files(&self) -> Vec<PathBuf> {
564 let mut seen: rustc_hash::FxHashSet<PathBuf> = rustc_hash::FxHashSet::default();
565 let mut out = Vec::new();
566 let mut push = |p: PathBuf, out: &mut Vec<PathBuf>| {
567 if seen.insert(p.clone()) {
568 out.push(p);
569 }
570 };
571 for p in self.vendor_files() {
572 push(p, &mut out);
573 }
574 for p in self.classmap.values() {
577 if p.is_file() {
578 push(p.clone(), &mut out);
579 }
580 }
581 for p in self.vendor_eager_files() {
582 push(p, &mut out);
583 }
584 out
585 }
586
587 pub fn classmap_len(&self) -> usize {
590 self.classmap.len()
591 }
592}
593
594fn collect_php_path(path: &Path, out: &mut Vec<PathBuf>) {
597 let Ok(meta) = std::fs::metadata(path) else {
598 return;
599 };
600 if meta.is_file() {
601 if path.extension().and_then(|e| e.to_str()) == Some("php") {
602 out.push(path.to_path_buf());
603 }
604 } else if meta.is_dir() {
605 crate::batch::collect_php_files(path, out);
606 }
607}
608
609fn expand_via_local_requires(out: &mut Vec<PathBuf>, skip_vendor: bool) {
626 let mut seen: rustc_hash::FxHashSet<PathBuf> = out.iter().cloned().collect();
627 let mut queue: Vec<PathBuf> = out.clone();
628 while let Some(file) = queue.pop() {
629 let Some(dir) = file.parent() else {
630 continue;
631 };
632 let Ok(text) = std::fs::read_to_string(&file) else {
633 continue;
634 };
635 if !text.contains("require") && !text.contains("include") {
638 continue;
639 }
640 let parsed = php_rs_parser::parse(&text);
641 let mut scanner = IncludeTargetScanner {
642 dir,
643 targets: Vec::new(),
644 };
645 let _ = scanner.visit_program(&parsed.program);
646 for target in scanner.targets {
647 let resolved = if Path::new(&target).is_absolute() {
648 PathBuf::from(target)
649 } else {
650 dir.join(target)
651 };
652 let resolved = lexically_normalize(&resolved);
653 if resolved.extension().and_then(|e| e.to_str()) != Some("php") {
654 continue;
655 }
656 if skip_vendor && resolved.components().any(|c| c.as_os_str() == "vendor") {
657 continue;
658 }
659 if !resolved.is_file() {
660 continue;
661 }
662 if seen.insert(resolved.clone()) {
663 out.push(resolved.clone());
664 queue.push(resolved);
665 }
666 }
667 }
668}
669
670fn lexically_normalize(path: &Path) -> PathBuf {
676 let mut components = path.components().peekable();
677 let mut ret = if let Some(c @ Component::Prefix(_)) = components.peek().copied() {
678 components.next();
679 PathBuf::from(c.as_os_str())
680 } else {
681 PathBuf::new()
682 };
683
684 for component in components {
685 match component {
686 Component::Prefix(_) => unreachable!(),
687 Component::RootDir => ret.push(component.as_os_str()),
688 Component::CurDir => {}
689 Component::ParentDir => {
690 ret.pop();
691 }
692 Component::Normal(c) => ret.push(c),
693 }
694 }
695 ret
696}
697
698struct IncludeTargetScanner<'a> {
699 dir: &'a Path,
700 targets: Vec<String>,
701}
702
703impl OwnedVisitor for IncludeTargetScanner<'_> {
704 fn visit_expr(&mut self, expr: &Expr) -> ControlFlow<()> {
705 if let ExprKind::Include(_, inner) = &expr.kind {
706 if let Some(target) = resolve_static_include_target(inner, self.dir) {
707 self.targets.push(target);
708 }
709 }
710 walk_owned_expr(self, expr)
711 }
712}
713
714fn resolve_static_include_target(expr: &Expr, dir: &Path) -> Option<String> {
719 match &expr.kind {
720 ExprKind::String(s) => Some(s.to_string()),
721 ExprKind::MagicConst(MagicConstKind::Dir) => Some(dir.to_string_lossy().into_owned()),
722 ExprKind::Parenthesized(inner) => resolve_static_include_target(inner, dir),
723 ExprKind::Binary(b) if b.op == BinaryOp::Concat => {
724 let left = resolve_static_include_target(&b.left, dir)?;
725 let right = resolve_static_include_target(&b.right, dir)?;
726 Some(format!("{left}{right}"))
727 }
728 ExprKind::FunctionCall(call) => {
729 let ExprKind::Identifier(name) = &call.name.kind else {
730 return None;
731 };
732 if !name.eq_ignore_ascii_case("dirname") {
733 return None;
734 }
735 let arg = call.args.first()?;
736 arg.value
737 .as_ref()
738 .is_some_and(|v| matches!(v.kind, ExprKind::MagicConst(MagicConstKind::File)))
739 .then(|| dir.to_string_lossy().into_owned())
740 }
741 _ => None,
742 }
743}
744
745#[cfg(test)]
746mod tests {
747 use super::*;
748 use std::fs;
749
750 fn make_temp_project(name: &str) -> PathBuf {
751 let dir = std::env::temp_dir().join(format!("mir_psr4_{name}"));
752 let _ = fs::remove_dir_all(&dir);
753 fs::create_dir_all(&dir).unwrap();
754 dir
755 }
756
757 #[test]
758 fn parse_project_entries() {
759 let root = make_temp_project("parse_project_entries");
760 fs::write(
761 root.join("composer.json"),
762 r#"{
763 "autoload": {
764 "psr-4": { "App\\": "src/", "App\\Models\\": "src/models/" }
765 },
766 "autoload-dev": {
767 "psr-4": { "Tests\\": "tests/" }
768 }
769 }"#,
770 )
771 .unwrap();
772
773 let map = Psr4Map::from_composer(&root).unwrap();
774
775 let prefixes: Vec<&str> = map
776 .project_entries
777 .iter()
778 .map(|(p, _)| p.as_str())
779 .collect();
780 assert!(prefixes.contains(&"App\\Models\\"), "missing App\\Models\\");
781 assert!(prefixes.contains(&"App\\"), "missing App\\");
782 assert!(prefixes.contains(&"Tests\\"), "missing Tests\\");
783 }
784
785 #[test]
786 fn longest_prefix_first() {
787 let root = make_temp_project("longest_prefix_first");
788 fs::write(
789 root.join("composer.json"),
790 r#"{
791 "autoload": {
792 "psr-4": { "App\\": "src/", "App\\Models\\": "src/models/" }
793 }
794 }"#,
795 )
796 .unwrap();
797
798 let map = Psr4Map::from_composer(&root).unwrap();
799
800 assert_eq!(map.project_entries[0].0, "App\\Models\\");
801 }
802
803 #[test]
804 fn missing_autoload_section_is_error() {
805 let root = make_temp_project("missing_autoload");
806 fs::write(root.join("composer.json"), r#"{ "name": "my/pkg" }"#).unwrap();
807
808 let result = Psr4Map::from_composer(&root);
809 assert!(
810 matches!(result, Err(ComposerError::MissingAutoload)),
811 "expected MissingAutoload error"
812 );
813 }
814
815 #[test]
816 fn composer_v2_installed() {
817 let root = make_temp_project("composer_v2");
818 fs::write(
819 root.join("composer.json"),
820 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
821 )
822 .unwrap();
823
824 let vendor_dir = root.join("vendor/composer");
825 fs::create_dir_all(&vendor_dir).unwrap();
826 fs::write(
827 vendor_dir.join("installed.json"),
828 r#"{
829 "packages": [
830 {
831 "name": "vendor/pkg",
832 "autoload": { "psr-4": { "Vendor\\Pkg\\": "src/" } }
833 }
834 ]
835 }"#,
836 )
837 .unwrap();
838 fs::create_dir_all(root.join("vendor/vendor/pkg/src")).unwrap();
839
840 let map = Psr4Map::from_composer(&root).unwrap();
841 let prefixes: Vec<&str> = map.vendor_entries.iter().map(|(p, _)| p.as_str()).collect();
842 assert!(prefixes.contains(&"Vendor\\Pkg\\"), "missing Vendor\\Pkg\\");
843 }
844
845 #[test]
846 fn composer_v1_installed() {
847 let root = make_temp_project("composer_v1");
848 fs::write(
849 root.join("composer.json"),
850 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
851 )
852 .unwrap();
853
854 let vendor_dir = root.join("vendor/composer");
855 fs::create_dir_all(&vendor_dir).unwrap();
856 fs::write(
857 vendor_dir.join("installed.json"),
858 r#"[
859 {
860 "name": "vendor/pkg",
861 "autoload": { "psr-4": { "Vendor\\Pkg\\": "src/" } }
862 }
863 ]"#,
864 )
865 .unwrap();
866 fs::create_dir_all(root.join("vendor/vendor/pkg/src")).unwrap();
867
868 let map = Psr4Map::from_composer(&root).unwrap();
869 let prefixes: Vec<&str> = map.vendor_entries.iter().map(|(p, _)| p.as_str()).collect();
870 assert!(prefixes.contains(&"Vendor\\Pkg\\"), "missing Vendor\\Pkg\\");
871 }
872
873 #[test]
874 fn missing_installed_json() {
875 let root = make_temp_project("missing_installed");
876 fs::write(
877 root.join("composer.json"),
878 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
879 )
880 .unwrap();
881 let map = Psr4Map::from_composer(&root).unwrap();
882 assert!(map.vendor_entries.is_empty());
883 }
884
885 #[test]
886 fn project_files_returns_php_files() {
887 let root = make_temp_project("project_files");
888 let src = root.join("src");
889 fs::create_dir_all(&src).unwrap();
890 fs::write(src.join("Foo.php"), "<?php class Foo {}").unwrap();
891 fs::write(src.join("README.md"), "not php").unwrap();
892 fs::write(
893 root.join("composer.json"),
894 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
895 )
896 .unwrap();
897
898 let map = Psr4Map::from_composer(&root).unwrap();
899 let files = map.project_files();
900 assert_eq!(files.len(), 1);
901 assert!(files[0].ends_with("Foo.php"));
902 }
903
904 #[test]
910 fn project_files_follows_dir_relative_require() {
911 let root = make_temp_project("require_dir_relative");
912 let src = root.join("src");
913 let legacy = root.join("legacy");
914 fs::create_dir_all(&src).unwrap();
915 fs::create_dir_all(&legacy).unwrap();
916 fs::write(
917 src.join("Bootstrap.php"),
918 "<?php require_once __DIR__ . '/../legacy/bootstrap.php'; class Bootstrap {}",
919 )
920 .unwrap();
921 fs::write(
922 legacy.join("bootstrap.php"),
923 "<?php function legacy_init() {}",
924 )
925 .unwrap();
926 fs::write(
927 root.join("composer.json"),
928 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
929 )
930 .unwrap();
931
932 let map = Psr4Map::from_composer(&root).unwrap();
933 let files = map.project_files();
934 assert_eq!(files.len(), 2, "expected Bootstrap.php + bootstrap.php");
935 assert!(files.iter().any(|f| f.ends_with("bootstrap.php")));
936 }
937
938 #[test]
939 fn project_files_follows_dirname_file_require() {
940 let root = make_temp_project("require_dirname_file");
941 let src = root.join("src");
942 let legacy = root.join("legacy");
943 fs::create_dir_all(&src).unwrap();
944 fs::create_dir_all(&legacy).unwrap();
945 fs::write(
946 src.join("Bootstrap.php"),
947 "<?php require_once dirname(__FILE__) . '/../legacy/bootstrap.php'; class Bootstrap {}",
948 )
949 .unwrap();
950 fs::write(
951 legacy.join("bootstrap.php"),
952 "<?php function legacy_init() {}",
953 )
954 .unwrap();
955 fs::write(
956 root.join("composer.json"),
957 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
958 )
959 .unwrap();
960
961 let map = Psr4Map::from_composer(&root).unwrap();
962 let files = map.project_files();
963 assert!(files.iter().any(|f| f.ends_with("bootstrap.php")));
964 }
965
966 #[test]
967 fn project_files_follows_bare_literal_require_relative_to_including_file() {
968 let root = make_temp_project("require_bare_literal");
969 let src = root.join("src");
970 let legacy = root.join("legacy");
971 fs::create_dir_all(&src).unwrap();
972 fs::create_dir_all(&legacy).unwrap();
973 fs::write(
974 src.join("Bootstrap.php"),
975 "<?php require_once '../legacy/bootstrap.php'; class Bootstrap {}",
976 )
977 .unwrap();
978 fs::write(
979 legacy.join("bootstrap.php"),
980 "<?php function legacy_init() {}",
981 )
982 .unwrap();
983 fs::write(
984 root.join("composer.json"),
985 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
986 )
987 .unwrap();
988
989 let map = Psr4Map::from_composer(&root).unwrap();
990 let files = map.project_files();
991 assert!(files.iter().any(|f| f.ends_with("bootstrap.php")));
992 }
993
994 #[test]
995 fn project_files_follows_require_transitively() {
996 let root = make_temp_project("require_transitive");
997 let src = root.join("src");
998 let legacy = root.join("legacy");
999 fs::create_dir_all(&src).unwrap();
1000 fs::create_dir_all(&legacy).unwrap();
1001 fs::write(
1002 src.join("Bootstrap.php"),
1003 "<?php require_once __DIR__ . '/../legacy/a.php';",
1004 )
1005 .unwrap();
1006 fs::write(
1007 legacy.join("a.php"),
1008 "<?php require_once __DIR__ . '/b.php';",
1009 )
1010 .unwrap();
1011 fs::write(legacy.join("b.php"), "<?php function b() {}").unwrap();
1012 fs::write(
1013 root.join("composer.json"),
1014 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1015 )
1016 .unwrap();
1017
1018 let map = Psr4Map::from_composer(&root).unwrap();
1019 let files = map.project_files();
1020 assert!(files.iter().any(|f| f.ends_with("a.php")));
1021 assert!(files.iter().any(|f| f.ends_with("b.php")));
1022 }
1023
1024 #[test]
1025 fn project_files_require_cycle_does_not_hang() {
1026 let root = make_temp_project("require_cycle");
1027 let src = root.join("src");
1028 let legacy = root.join("legacy");
1029 fs::create_dir_all(&src).unwrap();
1030 fs::create_dir_all(&legacy).unwrap();
1031 fs::write(
1032 src.join("Bootstrap.php"),
1033 "<?php require_once __DIR__ . '/../legacy/a.php';",
1034 )
1035 .unwrap();
1036 fs::write(
1037 legacy.join("a.php"),
1038 "<?php require_once __DIR__ . '/b.php';",
1039 )
1040 .unwrap();
1041 fs::write(
1042 legacy.join("b.php"),
1043 "<?php require_once __DIR__ . '/a.php';",
1044 )
1045 .unwrap();
1046 fs::write(
1047 root.join("composer.json"),
1048 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1049 )
1050 .unwrap();
1051
1052 let map = Psr4Map::from_composer(&root).unwrap();
1053 let files = map.project_files();
1054 assert_eq!(
1055 files.len(),
1056 3,
1057 "each file discovered exactly once despite the cycle"
1058 );
1059 }
1060
1061 #[test]
1062 fn project_files_missing_require_target_is_skipped() {
1063 let root = make_temp_project("require_missing_target");
1064 let src = root.join("src");
1065 fs::create_dir_all(&src).unwrap();
1066 fs::write(
1067 src.join("Bootstrap.php"),
1068 "<?php require_once __DIR__ . '/../legacy/does_not_exist.php';",
1069 )
1070 .unwrap();
1071 fs::write(
1072 root.join("composer.json"),
1073 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1074 )
1075 .unwrap();
1076
1077 let map = Psr4Map::from_composer(&root).unwrap();
1078 let files = map.project_files();
1079 assert_eq!(files.len(), 1, "only Bootstrap.php itself");
1080 }
1081
1082 #[test]
1083 fn project_files_dynamic_require_target_not_followed() {
1084 let root = make_temp_project("require_dynamic_target");
1085 let src = root.join("src");
1086 let legacy = root.join("legacy");
1087 fs::create_dir_all(&src).unwrap();
1088 fs::create_dir_all(&legacy).unwrap();
1089 fs::write(
1090 src.join("Bootstrap.php"),
1091 "<?php $path = getPath(); require_once $path;",
1092 )
1093 .unwrap();
1094 fs::write(
1095 legacy.join("bootstrap.php"),
1096 "<?php function legacy_init() {}",
1097 )
1098 .unwrap();
1099 fs::write(
1100 root.join("composer.json"),
1101 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1102 )
1103 .unwrap();
1104
1105 let map = Psr4Map::from_composer(&root).unwrap();
1106 let files = map.project_files();
1107 assert_eq!(
1108 files.len(),
1109 1,
1110 "a non-statically-resolvable require target must not be followed"
1111 );
1112 }
1113
1114 #[test]
1115 fn project_files_require_into_vendor_is_skipped() {
1116 let root = make_temp_project("require_into_vendor");
1117 let src = root.join("src");
1118 let vendor_pkg = root.join("vendor/some/pkg");
1119 fs::create_dir_all(&src).unwrap();
1120 fs::create_dir_all(&vendor_pkg).unwrap();
1121 fs::write(
1122 src.join("Bootstrap.php"),
1123 "<?php require_once __DIR__ . '/../vendor/some/pkg/lib.php';",
1124 )
1125 .unwrap();
1126 fs::write(vendor_pkg.join("lib.php"), "<?php function lib() {}").unwrap();
1127 fs::write(
1128 root.join("composer.json"),
1129 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1130 )
1131 .unwrap();
1132
1133 let map = Psr4Map::from_composer(&root).unwrap();
1134 let files = map.project_files();
1135 assert_eq!(
1136 files.len(),
1137 1,
1138 "vendor-reaching require must not be indexed as a project file"
1139 );
1140 }
1141
1142 #[test]
1143 fn resolve_existing_file() {
1144 let root = make_temp_project("resolve_existing");
1145 let models = root.join("src/models");
1146 fs::create_dir_all(&models).unwrap();
1147 fs::write(models.join("User.php"), "<?php class User {}").unwrap();
1148 fs::write(
1149 root.join("composer.json"),
1150 r#"{"autoload":{"psr-4":{"App\\Models\\":"src/models/","App\\":"src/"}}}"#,
1151 )
1152 .unwrap();
1153
1154 let map = Psr4Map::from_composer(&root).unwrap();
1155 let result = map.resolve("App\\Models\\User");
1156 assert!(result.is_some(), "expected a resolved path");
1157 assert!(result.unwrap().ends_with("User.php"));
1158 }
1159
1160 #[test]
1161 fn resolve_missing_file() {
1162 let root = make_temp_project("resolve_missing");
1163 fs::create_dir_all(root.join("src")).unwrap();
1164 fs::write(
1165 root.join("composer.json"),
1166 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1167 )
1168 .unwrap();
1169
1170 let map = Psr4Map::from_composer(&root).unwrap();
1171 let result = map.resolve("App\\Models\\User");
1172 assert!(result.is_none());
1173 }
1174
1175 #[test]
1176 fn boundary_check() {
1177 let root = make_temp_project("boundary_check");
1178 fs::create_dir_all(root.join("src")).unwrap();
1179 fs::write(
1180 root.join("composer.json"),
1181 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1182 )
1183 .unwrap();
1184
1185 let map = Psr4Map::from_composer(&root).unwrap();
1186 let result = map.resolve("Application\\Foo");
1188 assert!(
1189 result.is_none(),
1190 "App\\ prefix must not match Application\\Foo"
1191 );
1192 }
1193
1194 #[test]
1195 fn array_valued_psr4_dirs() {
1196 let root = make_temp_project("array_dirs");
1197 let src = root.join("src");
1198 let lib = root.join("lib");
1199 fs::create_dir_all(&src).unwrap();
1200 fs::create_dir_all(&lib).unwrap();
1201 fs::write(src.join("Foo.php"), "<?php class Foo {}").unwrap();
1202 fs::write(lib.join("Bar.php"), "<?php class Bar {}").unwrap();
1203 fs::write(
1204 root.join("composer.json"),
1205 r#"{"autoload":{"psr-4":{"App\\":["src/","lib/"]}}}"#,
1206 )
1207 .unwrap();
1208
1209 let map = Psr4Map::from_composer(&root).unwrap();
1210 assert_eq!(
1212 map.project_entries.len(),
1213 2,
1214 "expected 2 entries for array-valued dir"
1215 );
1216 let files = map.project_files();
1217 assert_eq!(files.len(), 2, "expected Foo.php and Bar.php");
1218 }
1219
1220 #[test]
1225 fn project_classmap_dir_is_collected() {
1226 let root = make_temp_project("project_classmap");
1227 let lib = root.join("lib");
1228 fs::create_dir_all(&lib).unwrap();
1229 fs::write(lib.join("Legacy.php"), "<?php class Legacy {}").unwrap();
1230 fs::write(
1231 root.join("composer.json"),
1232 r#"{"autoload":{"classmap":["lib/"]}}"#,
1233 )
1234 .unwrap();
1235
1236 let map = Psr4Map::from_composer(&root).unwrap();
1237 let files = map.project_files();
1238 assert_eq!(files.len(), 1);
1239 assert!(files[0].ends_with("Legacy.php"));
1240 }
1241
1242 #[test]
1243 fn project_files_autoload_is_collected() {
1244 let root = make_temp_project("project_files_autoload");
1245 fs::write(root.join("helpers.php"), "<?php function my_helper() {}").unwrap();
1246 fs::write(
1247 root.join("composer.json"),
1248 r#"{"autoload":{"files":["helpers.php"]}}"#,
1249 )
1250 .unwrap();
1251
1252 let map = Psr4Map::from_composer(&root).unwrap();
1253 let files = map.project_files();
1254 assert_eq!(files.len(), 1);
1255 assert!(files[0].ends_with("helpers.php"));
1256 }
1257
1258 #[test]
1259 fn project_psr0_dir_is_collected() {
1260 let root = make_temp_project("project_psr0");
1261 let lib = root.join("legacy");
1262 fs::create_dir_all(&lib).unwrap();
1263 fs::write(lib.join("Old.php"), "<?php class Old {}").unwrap();
1264 fs::write(
1265 root.join("composer.json"),
1266 r#"{"autoload":{"psr-0":{"":"legacy/"}}}"#,
1267 )
1268 .unwrap();
1269
1270 let map = Psr4Map::from_composer(&root).unwrap();
1271 let files = map.project_files();
1272 assert_eq!(files.len(), 1);
1273 assert!(files[0].ends_with("Old.php"));
1274 }
1275
1276 #[test]
1277 fn vendor_classmap_is_collected() {
1278 let root = make_temp_project("vendor_classmap");
1279 fs::write(
1280 root.join("composer.json"),
1281 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1282 )
1283 .unwrap();
1284 let vendor_dir = root.join("vendor/composer");
1285 fs::create_dir_all(&vendor_dir).unwrap();
1286 fs::write(
1287 vendor_dir.join("installed.json"),
1288 r#"{
1289 "packages": [{
1290 "name": "vendor/pkg",
1291 "autoload": { "classmap": ["src/"] }
1292 }]
1293 }"#,
1294 )
1295 .unwrap();
1296 let pkg_src = root.join("vendor/vendor/pkg/src");
1297 fs::create_dir_all(&pkg_src).unwrap();
1298 fs::write(pkg_src.join("Legacy.php"), "<?php class Legacy {}").unwrap();
1299
1300 let map = Psr4Map::from_composer(&root).unwrap();
1301 let files = map.vendor_files();
1302 assert_eq!(files.len(), 1);
1303 assert!(files[0].ends_with("Legacy.php"));
1304 }
1305
1306 #[test]
1307 fn vendor_files_autoload_is_collected() {
1308 let root = make_temp_project("vendor_files_autoload");
1309 fs::write(
1310 root.join("composer.json"),
1311 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1312 )
1313 .unwrap();
1314 let vendor_dir = root.join("vendor/composer");
1315 fs::create_dir_all(&vendor_dir).unwrap();
1316 fs::write(
1317 vendor_dir.join("installed.json"),
1318 r#"{
1319 "packages": [{
1320 "name": "vendor/pkg",
1321 "autoload": { "files": ["bootstrap.php"] }
1322 }]
1323 }"#,
1324 )
1325 .unwrap();
1326 let pkg_dir = root.join("vendor/vendor/pkg");
1327 fs::create_dir_all(&pkg_dir).unwrap();
1328 fs::write(
1329 pkg_dir.join("bootstrap.php"),
1330 "<?php function pkg_bootstrap() {}",
1331 )
1332 .unwrap();
1333
1334 let map = Psr4Map::from_composer(&root).unwrap();
1335 let files = map.vendor_files();
1336 assert_eq!(files.len(), 1);
1337 assert!(files[0].ends_with("bootstrap.php"));
1338 }
1339
1340 #[test]
1346 fn vendor_eager_files_follows_polyfill_bootstrap_require() {
1347 let root = make_temp_project("vendor_eager_files_polyfill");
1348 fs::write(
1349 root.join("composer.json"),
1350 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1351 )
1352 .unwrap();
1353 let vendor_dir = root.join("vendor/composer");
1354 fs::create_dir_all(&vendor_dir).unwrap();
1355 fs::write(
1356 vendor_dir.join("installed.json"),
1357 r#"{
1358 "packages": [{
1359 "name": "vendor/polyfill",
1360 "autoload": { "files": ["bootstrap.php"] }
1361 }]
1362 }"#,
1363 )
1364 .unwrap();
1365 let pkg_dir = root.join("vendor/vendor/polyfill");
1366 fs::create_dir_all(&pkg_dir).unwrap();
1367 fs::write(
1368 pkg_dir.join("bootstrap.php"),
1369 "<?php if (PHP_VERSION_ID >= 80000) { require_once __DIR__ . '/bootstrap80.php'; } \
1370 else { require_once __DIR__ . '/bootstrap_php7.php'; }",
1371 )
1372 .unwrap();
1373 fs::write(
1374 pkg_dir.join("bootstrap80.php"),
1375 "<?php function polyfill_mb_str_split() {}",
1376 )
1377 .unwrap();
1378 fs::write(
1379 pkg_dir.join("bootstrap_php7.php"),
1380 "<?php function polyfill_mb_str_split_legacy() {}",
1381 )
1382 .unwrap();
1383
1384 let map = Psr4Map::from_composer(&root).unwrap();
1385 let files = map.vendor_eager_files();
1386 assert!(
1387 files.iter().any(|p| p.ends_with("bootstrap.php")),
1388 "expected bootstrap.php in {files:?}"
1389 );
1390 assert!(
1391 files.iter().any(|p| p.ends_with("bootstrap80.php")),
1392 "expected the require_once-d sibling bootstrap80.php in {files:?}"
1393 );
1394 assert!(
1395 files.iter().any(|p| p.ends_with("bootstrap_php7.php")),
1396 "expected the require_once-d sibling bootstrap_php7.php in {files:?}"
1397 );
1398 }
1399
1400 #[test]
1401 fn resolve_psr0_project_namespaced_class() {
1402 let root = make_temp_project("resolve_psr0_project_namespaced");
1403 let mailer_dir = root.join("src/Mailer");
1404 fs::create_dir_all(&mailer_dir).unwrap();
1405 fs::write(
1406 mailer_dir.join("Message.php"),
1407 "<?php namespace Mailer; class Message {}",
1408 )
1409 .unwrap();
1410 fs::write(
1411 root.join("composer.json"),
1412 r#"{"autoload":{"psr-0":{"Mailer\\":"src/"}}}"#,
1413 )
1414 .unwrap();
1415
1416 let map = Psr4Map::from_composer(&root).unwrap();
1417 let result = map.resolve("Mailer\\Message");
1418 assert!(result.is_some(), "expected a resolved PSR-0 path");
1419 assert!(result.unwrap().ends_with("Mailer/Message.php"));
1420 }
1421
1422 #[test]
1423 fn resolve_psr0_pear_style_class_with_underscores() {
1424 let root = make_temp_project("resolve_psr0_pear_style");
1425 let old_dir = root.join("src/Old");
1426 fs::create_dir_all(&old_dir).unwrap();
1427 fs::write(old_dir.join("Thing.php"), "<?php class Old_Thing {}").unwrap();
1428 fs::write(
1429 root.join("composer.json"),
1430 r#"{"autoload":{"psr-0":{"Old_":"src/"}}}"#,
1431 )
1432 .unwrap();
1433
1434 let map = Psr4Map::from_composer(&root).unwrap();
1435 let result = map.resolve("Old_Thing");
1436 assert!(result.is_some(), "expected a resolved PSR-0 path");
1437 assert!(result.unwrap().ends_with("Old/Thing.php"));
1438 }
1439
1440 #[test]
1441 fn resolve_psr0_vendor_class() {
1442 let root = make_temp_project("resolve_psr0_vendor");
1443 fs::write(
1444 root.join("composer.json"),
1445 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1446 )
1447 .unwrap();
1448 let vendor_dir = root.join("vendor/composer");
1449 fs::create_dir_all(&vendor_dir).unwrap();
1450 fs::write(
1451 vendor_dir.join("installed.json"),
1452 r#"{
1453 "packages": [{
1454 "name": "vendor/pkg",
1455 "autoload": { "psr-0": { "Old_": "src/" } }
1456 }]
1457 }"#,
1458 )
1459 .unwrap();
1460 let pkg_src = root.join("vendor/vendor/pkg/src/Old");
1461 fs::create_dir_all(&pkg_src).unwrap();
1462 fs::write(pkg_src.join("Thing.php"), "<?php class Old_Thing {}").unwrap();
1463
1464 let map = Psr4Map::from_composer(&root).unwrap();
1465 let result = map.resolve("Old_Thing");
1466 assert!(result.is_some(), "expected a resolved PSR-0 vendor path");
1467 assert!(result.unwrap().ends_with("Old/Thing.php"));
1468 }
1469
1470 #[test]
1471 fn resolve_psr0_prefix_must_still_match() {
1472 let root = make_temp_project("resolve_psr0_prefix_mismatch");
1473 fs::create_dir_all(root.join("src")).unwrap();
1474 fs::write(
1475 root.join("composer.json"),
1476 r#"{"autoload":{"psr-0":{"Mailer\\":"src/"}}}"#,
1477 )
1478 .unwrap();
1479
1480 let map = Psr4Map::from_composer(&root).unwrap();
1481 let result = map.resolve("Other\\Message");
1482 assert!(
1483 result.is_none(),
1484 "Mailer\\ psr-0 prefix must not match Other\\Message"
1485 );
1486 }
1487
1488 #[test]
1489 fn vendor_psr0_is_collected() {
1490 let root = make_temp_project("vendor_psr0");
1491 fs::write(
1492 root.join("composer.json"),
1493 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1494 )
1495 .unwrap();
1496 let vendor_dir = root.join("vendor/composer");
1497 fs::create_dir_all(&vendor_dir).unwrap();
1498 fs::write(
1499 vendor_dir.join("installed.json"),
1500 r#"{
1501 "packages": [{
1502 "name": "vendor/pkg",
1503 "autoload": { "psr-0": { "Old_": "src/" } }
1504 }]
1505 }"#,
1506 )
1507 .unwrap();
1508 let pkg_src = root.join("vendor/vendor/pkg/src/Old");
1509 fs::create_dir_all(&pkg_src).unwrap();
1510 fs::write(pkg_src.join("Thing.php"), "<?php class Old_Thing {}").unwrap();
1511
1512 let map = Psr4Map::from_composer(&root).unwrap();
1513 let files = map.vendor_files();
1514 assert_eq!(files.len(), 1);
1515 assert!(files[0].ends_with("Thing.php"));
1516 }
1517}