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 vendor_eager_files = read_files_autoload(&vendor_dir, root);
446
447 Ok(Psr4Map {
448 project_entries,
449 vendor_entries,
450 project_psr0_entries,
451 vendor_psr0_entries,
452 project_extra_paths,
453 vendor_extra_paths,
454 classmap,
455 vendor_eager_files,
456 root: root.to_path_buf(),
457 })
458 }
459
460 pub fn project_files(&self) -> Vec<PathBuf> {
461 let mut out = Vec::new();
462 for (_, dir) in &self.project_entries {
463 crate::batch::collect_php_files(dir, &mut out);
464 }
465 for path in &self.project_extra_paths {
466 collect_php_path(path, &mut out);
467 }
468 expand_via_local_requires(&mut out);
469 out
470 }
471
472 pub fn vendor_files(&self) -> Vec<PathBuf> {
473 let mut out = Vec::new();
474 for (_, dir) in &self.vendor_entries {
475 crate::batch::collect_php_files(dir, &mut out);
476 }
477 for path in &self.vendor_extra_paths {
478 collect_php_path(path, &mut out);
479 }
480 out
481 }
482
483 pub fn resolve(&self, fqcn: &str) -> Option<PathBuf> {
497 let key = fqcn.trim_start_matches('\\');
498 for (prefix, dir) in self
499 .project_entries
500 .iter()
501 .chain(self.vendor_entries.iter())
502 {
503 if key.starts_with(prefix.as_str()) {
504 let relative = &key[prefix.len()..];
505 let file_path = dir.join(relative.replace('\\', "/")).with_extension("php");
506 if file_path.exists() {
507 return Some(file_path);
508 }
509 }
510 }
511 for (prefix, dir) in self
512 .project_psr0_entries
513 .iter()
514 .chain(self.vendor_psr0_entries.iter())
515 {
516 if !prefix.is_empty() && !key.starts_with(prefix.as_str()) {
517 continue;
518 }
519 let file_path = dir.join(psr0_logical_path(key));
520 if file_path.exists() {
521 return Some(file_path);
522 }
523 }
524 if let Some(path) = self.classmap.get(key) {
525 if path.exists() {
526 return Some(path.clone());
527 }
528 }
529 None
530 }
531
532 pub fn vendor_eager_files(&self) -> Vec<PathBuf> {
540 self.vendor_eager_files.clone()
541 }
542
543 pub fn all_vendor_files(&self) -> Vec<PathBuf> {
555 let mut seen: rustc_hash::FxHashSet<PathBuf> = rustc_hash::FxHashSet::default();
556 let mut out = Vec::new();
557 let mut push = |p: PathBuf, out: &mut Vec<PathBuf>| {
558 if seen.insert(p.clone()) {
559 out.push(p);
560 }
561 };
562 for p in self.vendor_files() {
563 push(p, &mut out);
564 }
565 for p in self.classmap.values() {
568 if p.is_file() {
569 push(p.clone(), &mut out);
570 }
571 }
572 for p in self.vendor_eager_files() {
573 push(p, &mut out);
574 }
575 out
576 }
577
578 pub fn classmap_len(&self) -> usize {
581 self.classmap.len()
582 }
583}
584
585fn collect_php_path(path: &Path, out: &mut Vec<PathBuf>) {
588 let Ok(meta) = std::fs::metadata(path) else {
589 return;
590 };
591 if meta.is_file() {
592 if path.extension().and_then(|e| e.to_str()) == Some("php") {
593 out.push(path.to_path_buf());
594 }
595 } else if meta.is_dir() {
596 crate::batch::collect_php_files(path, out);
597 }
598}
599
600fn expand_via_local_requires(out: &mut Vec<PathBuf>) {
614 let mut seen: rustc_hash::FxHashSet<PathBuf> = out.iter().cloned().collect();
615 let mut queue: Vec<PathBuf> = out.clone();
616 while let Some(file) = queue.pop() {
617 let Some(dir) = file.parent() else {
618 continue;
619 };
620 let Ok(text) = std::fs::read_to_string(&file) else {
621 continue;
622 };
623 if !text.contains("require") && !text.contains("include") {
626 continue;
627 }
628 let parsed = php_rs_parser::parse(&text);
629 let mut scanner = IncludeTargetScanner {
630 dir,
631 targets: Vec::new(),
632 };
633 let _ = scanner.visit_program(&parsed.program);
634 for target in scanner.targets {
635 let resolved = if Path::new(&target).is_absolute() {
636 PathBuf::from(target)
637 } else {
638 dir.join(target)
639 };
640 let resolved = lexically_normalize(&resolved);
641 if resolved.extension().and_then(|e| e.to_str()) != Some("php") {
642 continue;
643 }
644 if resolved.components().any(|c| c.as_os_str() == "vendor") {
645 continue;
646 }
647 if !resolved.is_file() {
648 continue;
649 }
650 if seen.insert(resolved.clone()) {
651 out.push(resolved.clone());
652 queue.push(resolved);
653 }
654 }
655 }
656}
657
658fn lexically_normalize(path: &Path) -> PathBuf {
664 let mut components = path.components().peekable();
665 let mut ret = if let Some(c @ Component::Prefix(_)) = components.peek().copied() {
666 components.next();
667 PathBuf::from(c.as_os_str())
668 } else {
669 PathBuf::new()
670 };
671
672 for component in components {
673 match component {
674 Component::Prefix(_) => unreachable!(),
675 Component::RootDir => ret.push(component.as_os_str()),
676 Component::CurDir => {}
677 Component::ParentDir => {
678 ret.pop();
679 }
680 Component::Normal(c) => ret.push(c),
681 }
682 }
683 ret
684}
685
686struct IncludeTargetScanner<'a> {
687 dir: &'a Path,
688 targets: Vec<String>,
689}
690
691impl OwnedVisitor for IncludeTargetScanner<'_> {
692 fn visit_expr(&mut self, expr: &Expr) -> ControlFlow<()> {
693 if let ExprKind::Include(_, inner) = &expr.kind {
694 if let Some(target) = resolve_static_include_target(inner, self.dir) {
695 self.targets.push(target);
696 }
697 }
698 walk_owned_expr(self, expr)
699 }
700}
701
702fn resolve_static_include_target(expr: &Expr, dir: &Path) -> Option<String> {
707 match &expr.kind {
708 ExprKind::String(s) => Some(s.to_string()),
709 ExprKind::MagicConst(MagicConstKind::Dir) => Some(dir.to_string_lossy().into_owned()),
710 ExprKind::Parenthesized(inner) => resolve_static_include_target(inner, dir),
711 ExprKind::Binary(b) if b.op == BinaryOp::Concat => {
712 let left = resolve_static_include_target(&b.left, dir)?;
713 let right = resolve_static_include_target(&b.right, dir)?;
714 Some(format!("{left}{right}"))
715 }
716 ExprKind::FunctionCall(call) => {
717 let ExprKind::Identifier(name) = &call.name.kind else {
718 return None;
719 };
720 if !name.eq_ignore_ascii_case("dirname") {
721 return None;
722 }
723 let arg = call.args.first()?;
724 matches!(arg.value.kind, ExprKind::MagicConst(MagicConstKind::File))
725 .then(|| dir.to_string_lossy().into_owned())
726 }
727 _ => None,
728 }
729}
730
731#[cfg(test)]
732mod tests {
733 use super::*;
734 use std::fs;
735
736 fn make_temp_project(name: &str) -> PathBuf {
737 let dir = std::env::temp_dir().join(format!("mir_psr4_{name}"));
738 let _ = fs::remove_dir_all(&dir);
739 fs::create_dir_all(&dir).unwrap();
740 dir
741 }
742
743 #[test]
744 fn parse_project_entries() {
745 let root = make_temp_project("parse_project_entries");
746 fs::write(
747 root.join("composer.json"),
748 r#"{
749 "autoload": {
750 "psr-4": { "App\\": "src/", "App\\Models\\": "src/models/" }
751 },
752 "autoload-dev": {
753 "psr-4": { "Tests\\": "tests/" }
754 }
755 }"#,
756 )
757 .unwrap();
758
759 let map = Psr4Map::from_composer(&root).unwrap();
760
761 let prefixes: Vec<&str> = map
762 .project_entries
763 .iter()
764 .map(|(p, _)| p.as_str())
765 .collect();
766 assert!(prefixes.contains(&"App\\Models\\"), "missing App\\Models\\");
767 assert!(prefixes.contains(&"App\\"), "missing App\\");
768 assert!(prefixes.contains(&"Tests\\"), "missing Tests\\");
769 }
770
771 #[test]
772 fn longest_prefix_first() {
773 let root = make_temp_project("longest_prefix_first");
774 fs::write(
775 root.join("composer.json"),
776 r#"{
777 "autoload": {
778 "psr-4": { "App\\": "src/", "App\\Models\\": "src/models/" }
779 }
780 }"#,
781 )
782 .unwrap();
783
784 let map = Psr4Map::from_composer(&root).unwrap();
785
786 assert_eq!(map.project_entries[0].0, "App\\Models\\");
787 }
788
789 #[test]
790 fn missing_autoload_section_is_error() {
791 let root = make_temp_project("missing_autoload");
792 fs::write(root.join("composer.json"), r#"{ "name": "my/pkg" }"#).unwrap();
793
794 let result = Psr4Map::from_composer(&root);
795 assert!(
796 matches!(result, Err(ComposerError::MissingAutoload)),
797 "expected MissingAutoload error"
798 );
799 }
800
801 #[test]
802 fn composer_v2_installed() {
803 let root = make_temp_project("composer_v2");
804 fs::write(
805 root.join("composer.json"),
806 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
807 )
808 .unwrap();
809
810 let vendor_dir = root.join("vendor/composer");
811 fs::create_dir_all(&vendor_dir).unwrap();
812 fs::write(
813 vendor_dir.join("installed.json"),
814 r#"{
815 "packages": [
816 {
817 "name": "vendor/pkg",
818 "autoload": { "psr-4": { "Vendor\\Pkg\\": "src/" } }
819 }
820 ]
821 }"#,
822 )
823 .unwrap();
824 fs::create_dir_all(root.join("vendor/vendor/pkg/src")).unwrap();
825
826 let map = Psr4Map::from_composer(&root).unwrap();
827 let prefixes: Vec<&str> = map.vendor_entries.iter().map(|(p, _)| p.as_str()).collect();
828 assert!(prefixes.contains(&"Vendor\\Pkg\\"), "missing Vendor\\Pkg\\");
829 }
830
831 #[test]
832 fn composer_v1_installed() {
833 let root = make_temp_project("composer_v1");
834 fs::write(
835 root.join("composer.json"),
836 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
837 )
838 .unwrap();
839
840 let vendor_dir = root.join("vendor/composer");
841 fs::create_dir_all(&vendor_dir).unwrap();
842 fs::write(
843 vendor_dir.join("installed.json"),
844 r#"[
845 {
846 "name": "vendor/pkg",
847 "autoload": { "psr-4": { "Vendor\\Pkg\\": "src/" } }
848 }
849 ]"#,
850 )
851 .unwrap();
852 fs::create_dir_all(root.join("vendor/vendor/pkg/src")).unwrap();
853
854 let map = Psr4Map::from_composer(&root).unwrap();
855 let prefixes: Vec<&str> = map.vendor_entries.iter().map(|(p, _)| p.as_str()).collect();
856 assert!(prefixes.contains(&"Vendor\\Pkg\\"), "missing Vendor\\Pkg\\");
857 }
858
859 #[test]
860 fn missing_installed_json() {
861 let root = make_temp_project("missing_installed");
862 fs::write(
863 root.join("composer.json"),
864 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
865 )
866 .unwrap();
867 let map = Psr4Map::from_composer(&root).unwrap();
868 assert!(map.vendor_entries.is_empty());
869 }
870
871 #[test]
872 fn project_files_returns_php_files() {
873 let root = make_temp_project("project_files");
874 let src = root.join("src");
875 fs::create_dir_all(&src).unwrap();
876 fs::write(src.join("Foo.php"), "<?php class Foo {}").unwrap();
877 fs::write(src.join("README.md"), "not php").unwrap();
878 fs::write(
879 root.join("composer.json"),
880 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
881 )
882 .unwrap();
883
884 let map = Psr4Map::from_composer(&root).unwrap();
885 let files = map.project_files();
886 assert_eq!(files.len(), 1);
887 assert!(files[0].ends_with("Foo.php"));
888 }
889
890 #[test]
896 fn project_files_follows_dir_relative_require() {
897 let root = make_temp_project("require_dir_relative");
898 let src = root.join("src");
899 let legacy = root.join("legacy");
900 fs::create_dir_all(&src).unwrap();
901 fs::create_dir_all(&legacy).unwrap();
902 fs::write(
903 src.join("Bootstrap.php"),
904 "<?php require_once __DIR__ . '/../legacy/bootstrap.php'; class Bootstrap {}",
905 )
906 .unwrap();
907 fs::write(
908 legacy.join("bootstrap.php"),
909 "<?php function legacy_init() {}",
910 )
911 .unwrap();
912 fs::write(
913 root.join("composer.json"),
914 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
915 )
916 .unwrap();
917
918 let map = Psr4Map::from_composer(&root).unwrap();
919 let files = map.project_files();
920 assert_eq!(files.len(), 2, "expected Bootstrap.php + bootstrap.php");
921 assert!(files.iter().any(|f| f.ends_with("bootstrap.php")));
922 }
923
924 #[test]
925 fn project_files_follows_dirname_file_require() {
926 let root = make_temp_project("require_dirname_file");
927 let src = root.join("src");
928 let legacy = root.join("legacy");
929 fs::create_dir_all(&src).unwrap();
930 fs::create_dir_all(&legacy).unwrap();
931 fs::write(
932 src.join("Bootstrap.php"),
933 "<?php require_once dirname(__FILE__) . '/../legacy/bootstrap.php'; class Bootstrap {}",
934 )
935 .unwrap();
936 fs::write(
937 legacy.join("bootstrap.php"),
938 "<?php function legacy_init() {}",
939 )
940 .unwrap();
941 fs::write(
942 root.join("composer.json"),
943 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
944 )
945 .unwrap();
946
947 let map = Psr4Map::from_composer(&root).unwrap();
948 let files = map.project_files();
949 assert!(files.iter().any(|f| f.ends_with("bootstrap.php")));
950 }
951
952 #[test]
953 fn project_files_follows_bare_literal_require_relative_to_including_file() {
954 let root = make_temp_project("require_bare_literal");
955 let src = root.join("src");
956 let legacy = root.join("legacy");
957 fs::create_dir_all(&src).unwrap();
958 fs::create_dir_all(&legacy).unwrap();
959 fs::write(
960 src.join("Bootstrap.php"),
961 "<?php require_once '../legacy/bootstrap.php'; class Bootstrap {}",
962 )
963 .unwrap();
964 fs::write(
965 legacy.join("bootstrap.php"),
966 "<?php function legacy_init() {}",
967 )
968 .unwrap();
969 fs::write(
970 root.join("composer.json"),
971 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
972 )
973 .unwrap();
974
975 let map = Psr4Map::from_composer(&root).unwrap();
976 let files = map.project_files();
977 assert!(files.iter().any(|f| f.ends_with("bootstrap.php")));
978 }
979
980 #[test]
981 fn project_files_follows_require_transitively() {
982 let root = make_temp_project("require_transitive");
983 let src = root.join("src");
984 let legacy = root.join("legacy");
985 fs::create_dir_all(&src).unwrap();
986 fs::create_dir_all(&legacy).unwrap();
987 fs::write(
988 src.join("Bootstrap.php"),
989 "<?php require_once __DIR__ . '/../legacy/a.php';",
990 )
991 .unwrap();
992 fs::write(
993 legacy.join("a.php"),
994 "<?php require_once __DIR__ . '/b.php';",
995 )
996 .unwrap();
997 fs::write(legacy.join("b.php"), "<?php function b() {}").unwrap();
998 fs::write(
999 root.join("composer.json"),
1000 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1001 )
1002 .unwrap();
1003
1004 let map = Psr4Map::from_composer(&root).unwrap();
1005 let files = map.project_files();
1006 assert!(files.iter().any(|f| f.ends_with("a.php")));
1007 assert!(files.iter().any(|f| f.ends_with("b.php")));
1008 }
1009
1010 #[test]
1011 fn project_files_require_cycle_does_not_hang() {
1012 let root = make_temp_project("require_cycle");
1013 let src = root.join("src");
1014 let legacy = root.join("legacy");
1015 fs::create_dir_all(&src).unwrap();
1016 fs::create_dir_all(&legacy).unwrap();
1017 fs::write(
1018 src.join("Bootstrap.php"),
1019 "<?php require_once __DIR__ . '/../legacy/a.php';",
1020 )
1021 .unwrap();
1022 fs::write(
1023 legacy.join("a.php"),
1024 "<?php require_once __DIR__ . '/b.php';",
1025 )
1026 .unwrap();
1027 fs::write(
1028 legacy.join("b.php"),
1029 "<?php require_once __DIR__ . '/a.php';",
1030 )
1031 .unwrap();
1032 fs::write(
1033 root.join("composer.json"),
1034 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1035 )
1036 .unwrap();
1037
1038 let map = Psr4Map::from_composer(&root).unwrap();
1039 let files = map.project_files();
1040 assert_eq!(
1041 files.len(),
1042 3,
1043 "each file discovered exactly once despite the cycle"
1044 );
1045 }
1046
1047 #[test]
1048 fn project_files_missing_require_target_is_skipped() {
1049 let root = make_temp_project("require_missing_target");
1050 let src = root.join("src");
1051 fs::create_dir_all(&src).unwrap();
1052 fs::write(
1053 src.join("Bootstrap.php"),
1054 "<?php require_once __DIR__ . '/../legacy/does_not_exist.php';",
1055 )
1056 .unwrap();
1057 fs::write(
1058 root.join("composer.json"),
1059 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1060 )
1061 .unwrap();
1062
1063 let map = Psr4Map::from_composer(&root).unwrap();
1064 let files = map.project_files();
1065 assert_eq!(files.len(), 1, "only Bootstrap.php itself");
1066 }
1067
1068 #[test]
1069 fn project_files_dynamic_require_target_not_followed() {
1070 let root = make_temp_project("require_dynamic_target");
1071 let src = root.join("src");
1072 let legacy = root.join("legacy");
1073 fs::create_dir_all(&src).unwrap();
1074 fs::create_dir_all(&legacy).unwrap();
1075 fs::write(
1076 src.join("Bootstrap.php"),
1077 "<?php $path = getPath(); require_once $path;",
1078 )
1079 .unwrap();
1080 fs::write(
1081 legacy.join("bootstrap.php"),
1082 "<?php function legacy_init() {}",
1083 )
1084 .unwrap();
1085 fs::write(
1086 root.join("composer.json"),
1087 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1088 )
1089 .unwrap();
1090
1091 let map = Psr4Map::from_composer(&root).unwrap();
1092 let files = map.project_files();
1093 assert_eq!(
1094 files.len(),
1095 1,
1096 "a non-statically-resolvable require target must not be followed"
1097 );
1098 }
1099
1100 #[test]
1101 fn project_files_require_into_vendor_is_skipped() {
1102 let root = make_temp_project("require_into_vendor");
1103 let src = root.join("src");
1104 let vendor_pkg = root.join("vendor/some/pkg");
1105 fs::create_dir_all(&src).unwrap();
1106 fs::create_dir_all(&vendor_pkg).unwrap();
1107 fs::write(
1108 src.join("Bootstrap.php"),
1109 "<?php require_once __DIR__ . '/../vendor/some/pkg/lib.php';",
1110 )
1111 .unwrap();
1112 fs::write(vendor_pkg.join("lib.php"), "<?php function lib() {}").unwrap();
1113 fs::write(
1114 root.join("composer.json"),
1115 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1116 )
1117 .unwrap();
1118
1119 let map = Psr4Map::from_composer(&root).unwrap();
1120 let files = map.project_files();
1121 assert_eq!(
1122 files.len(),
1123 1,
1124 "vendor-reaching require must not be indexed as a project file"
1125 );
1126 }
1127
1128 #[test]
1129 fn resolve_existing_file() {
1130 let root = make_temp_project("resolve_existing");
1131 let models = root.join("src/models");
1132 fs::create_dir_all(&models).unwrap();
1133 fs::write(models.join("User.php"), "<?php class User {}").unwrap();
1134 fs::write(
1135 root.join("composer.json"),
1136 r#"{"autoload":{"psr-4":{"App\\Models\\":"src/models/","App\\":"src/"}}}"#,
1137 )
1138 .unwrap();
1139
1140 let map = Psr4Map::from_composer(&root).unwrap();
1141 let result = map.resolve("App\\Models\\User");
1142 assert!(result.is_some(), "expected a resolved path");
1143 assert!(result.unwrap().ends_with("User.php"));
1144 }
1145
1146 #[test]
1147 fn resolve_missing_file() {
1148 let root = make_temp_project("resolve_missing");
1149 fs::create_dir_all(root.join("src")).unwrap();
1150 fs::write(
1151 root.join("composer.json"),
1152 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1153 )
1154 .unwrap();
1155
1156 let map = Psr4Map::from_composer(&root).unwrap();
1157 let result = map.resolve("App\\Models\\User");
1158 assert!(result.is_none());
1159 }
1160
1161 #[test]
1162 fn boundary_check() {
1163 let root = make_temp_project("boundary_check");
1164 fs::create_dir_all(root.join("src")).unwrap();
1165 fs::write(
1166 root.join("composer.json"),
1167 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1168 )
1169 .unwrap();
1170
1171 let map = Psr4Map::from_composer(&root).unwrap();
1172 let result = map.resolve("Application\\Foo");
1174 assert!(
1175 result.is_none(),
1176 "App\\ prefix must not match Application\\Foo"
1177 );
1178 }
1179
1180 #[test]
1181 fn array_valued_psr4_dirs() {
1182 let root = make_temp_project("array_dirs");
1183 let src = root.join("src");
1184 let lib = root.join("lib");
1185 fs::create_dir_all(&src).unwrap();
1186 fs::create_dir_all(&lib).unwrap();
1187 fs::write(src.join("Foo.php"), "<?php class Foo {}").unwrap();
1188 fs::write(lib.join("Bar.php"), "<?php class Bar {}").unwrap();
1189 fs::write(
1190 root.join("composer.json"),
1191 r#"{"autoload":{"psr-4":{"App\\":["src/","lib/"]}}}"#,
1192 )
1193 .unwrap();
1194
1195 let map = Psr4Map::from_composer(&root).unwrap();
1196 assert_eq!(
1198 map.project_entries.len(),
1199 2,
1200 "expected 2 entries for array-valued dir"
1201 );
1202 let files = map.project_files();
1203 assert_eq!(files.len(), 2, "expected Foo.php and Bar.php");
1204 }
1205
1206 #[test]
1211 fn project_classmap_dir_is_collected() {
1212 let root = make_temp_project("project_classmap");
1213 let lib = root.join("lib");
1214 fs::create_dir_all(&lib).unwrap();
1215 fs::write(lib.join("Legacy.php"), "<?php class Legacy {}").unwrap();
1216 fs::write(
1217 root.join("composer.json"),
1218 r#"{"autoload":{"classmap":["lib/"]}}"#,
1219 )
1220 .unwrap();
1221
1222 let map = Psr4Map::from_composer(&root).unwrap();
1223 let files = map.project_files();
1224 assert_eq!(files.len(), 1);
1225 assert!(files[0].ends_with("Legacy.php"));
1226 }
1227
1228 #[test]
1229 fn project_files_autoload_is_collected() {
1230 let root = make_temp_project("project_files_autoload");
1231 fs::write(root.join("helpers.php"), "<?php function my_helper() {}").unwrap();
1232 fs::write(
1233 root.join("composer.json"),
1234 r#"{"autoload":{"files":["helpers.php"]}}"#,
1235 )
1236 .unwrap();
1237
1238 let map = Psr4Map::from_composer(&root).unwrap();
1239 let files = map.project_files();
1240 assert_eq!(files.len(), 1);
1241 assert!(files[0].ends_with("helpers.php"));
1242 }
1243
1244 #[test]
1245 fn project_psr0_dir_is_collected() {
1246 let root = make_temp_project("project_psr0");
1247 let lib = root.join("legacy");
1248 fs::create_dir_all(&lib).unwrap();
1249 fs::write(lib.join("Old.php"), "<?php class Old {}").unwrap();
1250 fs::write(
1251 root.join("composer.json"),
1252 r#"{"autoload":{"psr-0":{"":"legacy/"}}}"#,
1253 )
1254 .unwrap();
1255
1256 let map = Psr4Map::from_composer(&root).unwrap();
1257 let files = map.project_files();
1258 assert_eq!(files.len(), 1);
1259 assert!(files[0].ends_with("Old.php"));
1260 }
1261
1262 #[test]
1263 fn vendor_classmap_is_collected() {
1264 let root = make_temp_project("vendor_classmap");
1265 fs::write(
1266 root.join("composer.json"),
1267 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1268 )
1269 .unwrap();
1270 let vendor_dir = root.join("vendor/composer");
1271 fs::create_dir_all(&vendor_dir).unwrap();
1272 fs::write(
1273 vendor_dir.join("installed.json"),
1274 r#"{
1275 "packages": [{
1276 "name": "vendor/pkg",
1277 "autoload": { "classmap": ["src/"] }
1278 }]
1279 }"#,
1280 )
1281 .unwrap();
1282 let pkg_src = root.join("vendor/vendor/pkg/src");
1283 fs::create_dir_all(&pkg_src).unwrap();
1284 fs::write(pkg_src.join("Legacy.php"), "<?php class Legacy {}").unwrap();
1285
1286 let map = Psr4Map::from_composer(&root).unwrap();
1287 let files = map.vendor_files();
1288 assert_eq!(files.len(), 1);
1289 assert!(files[0].ends_with("Legacy.php"));
1290 }
1291
1292 #[test]
1293 fn vendor_files_autoload_is_collected() {
1294 let root = make_temp_project("vendor_files_autoload");
1295 fs::write(
1296 root.join("composer.json"),
1297 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1298 )
1299 .unwrap();
1300 let vendor_dir = root.join("vendor/composer");
1301 fs::create_dir_all(&vendor_dir).unwrap();
1302 fs::write(
1303 vendor_dir.join("installed.json"),
1304 r#"{
1305 "packages": [{
1306 "name": "vendor/pkg",
1307 "autoload": { "files": ["bootstrap.php"] }
1308 }]
1309 }"#,
1310 )
1311 .unwrap();
1312 let pkg_dir = root.join("vendor/vendor/pkg");
1313 fs::create_dir_all(&pkg_dir).unwrap();
1314 fs::write(
1315 pkg_dir.join("bootstrap.php"),
1316 "<?php function pkg_bootstrap() {}",
1317 )
1318 .unwrap();
1319
1320 let map = Psr4Map::from_composer(&root).unwrap();
1321 let files = map.vendor_files();
1322 assert_eq!(files.len(), 1);
1323 assert!(files[0].ends_with("bootstrap.php"));
1324 }
1325
1326 #[test]
1327 fn resolve_psr0_project_namespaced_class() {
1328 let root = make_temp_project("resolve_psr0_project_namespaced");
1329 let mailer_dir = root.join("src/Mailer");
1330 fs::create_dir_all(&mailer_dir).unwrap();
1331 fs::write(
1332 mailer_dir.join("Message.php"),
1333 "<?php namespace Mailer; class Message {}",
1334 )
1335 .unwrap();
1336 fs::write(
1337 root.join("composer.json"),
1338 r#"{"autoload":{"psr-0":{"Mailer\\":"src/"}}}"#,
1339 )
1340 .unwrap();
1341
1342 let map = Psr4Map::from_composer(&root).unwrap();
1343 let result = map.resolve("Mailer\\Message");
1344 assert!(result.is_some(), "expected a resolved PSR-0 path");
1345 assert!(result.unwrap().ends_with("Mailer/Message.php"));
1346 }
1347
1348 #[test]
1349 fn resolve_psr0_pear_style_class_with_underscores() {
1350 let root = make_temp_project("resolve_psr0_pear_style");
1351 let old_dir = root.join("src/Old");
1352 fs::create_dir_all(&old_dir).unwrap();
1353 fs::write(old_dir.join("Thing.php"), "<?php class Old_Thing {}").unwrap();
1354 fs::write(
1355 root.join("composer.json"),
1356 r#"{"autoload":{"psr-0":{"Old_":"src/"}}}"#,
1357 )
1358 .unwrap();
1359
1360 let map = Psr4Map::from_composer(&root).unwrap();
1361 let result = map.resolve("Old_Thing");
1362 assert!(result.is_some(), "expected a resolved PSR-0 path");
1363 assert!(result.unwrap().ends_with("Old/Thing.php"));
1364 }
1365
1366 #[test]
1367 fn resolve_psr0_vendor_class() {
1368 let root = make_temp_project("resolve_psr0_vendor");
1369 fs::write(
1370 root.join("composer.json"),
1371 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1372 )
1373 .unwrap();
1374 let vendor_dir = root.join("vendor/composer");
1375 fs::create_dir_all(&vendor_dir).unwrap();
1376 fs::write(
1377 vendor_dir.join("installed.json"),
1378 r#"{
1379 "packages": [{
1380 "name": "vendor/pkg",
1381 "autoload": { "psr-0": { "Old_": "src/" } }
1382 }]
1383 }"#,
1384 )
1385 .unwrap();
1386 let pkg_src = root.join("vendor/vendor/pkg/src/Old");
1387 fs::create_dir_all(&pkg_src).unwrap();
1388 fs::write(pkg_src.join("Thing.php"), "<?php class Old_Thing {}").unwrap();
1389
1390 let map = Psr4Map::from_composer(&root).unwrap();
1391 let result = map.resolve("Old_Thing");
1392 assert!(result.is_some(), "expected a resolved PSR-0 vendor path");
1393 assert!(result.unwrap().ends_with("Old/Thing.php"));
1394 }
1395
1396 #[test]
1397 fn resolve_psr0_prefix_must_still_match() {
1398 let root = make_temp_project("resolve_psr0_prefix_mismatch");
1399 fs::create_dir_all(root.join("src")).unwrap();
1400 fs::write(
1401 root.join("composer.json"),
1402 r#"{"autoload":{"psr-0":{"Mailer\\":"src/"}}}"#,
1403 )
1404 .unwrap();
1405
1406 let map = Psr4Map::from_composer(&root).unwrap();
1407 let result = map.resolve("Other\\Message");
1408 assert!(
1409 result.is_none(),
1410 "Mailer\\ psr-0 prefix must not match Other\\Message"
1411 );
1412 }
1413
1414 #[test]
1415 fn vendor_psr0_is_collected() {
1416 let root = make_temp_project("vendor_psr0");
1417 fs::write(
1418 root.join("composer.json"),
1419 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1420 )
1421 .unwrap();
1422 let vendor_dir = root.join("vendor/composer");
1423 fs::create_dir_all(&vendor_dir).unwrap();
1424 fs::write(
1425 vendor_dir.join("installed.json"),
1426 r#"{
1427 "packages": [{
1428 "name": "vendor/pkg",
1429 "autoload": { "psr-0": { "Old_": "src/" } }
1430 }]
1431 }"#,
1432 )
1433 .unwrap();
1434 let pkg_src = root.join("vendor/vendor/pkg/src/Old");
1435 fs::create_dir_all(&pkg_src).unwrap();
1436 fs::write(pkg_src.join("Thing.php"), "<?php class Old_Thing {}").unwrap();
1437
1438 let map = Psr4Map::from_composer(&root).unwrap();
1439 let files = map.vendor_files();
1440 assert_eq!(files.len(), 1);
1441 assert!(files[0].ends_with("Thing.php"));
1442 }
1443}