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