1use std::path::{Path, PathBuf};
2use thiserror::Error;
3
4#[derive(Debug, Error)]
9pub enum ComposerError {
10 #[error("composer I/O error: {0}")]
11 Io(#[from] std::io::Error),
12 #[error("composer JSON error: {0}")]
13 Json(#[from] serde_json::Error),
14 #[error("composer.json has no autoload section")]
15 MissingAutoload,
16}
17
18#[derive(Clone)]
34pub struct Psr4Map {
35 project_entries: Vec<(String, PathBuf)>,
36 vendor_entries: Vec<(String, PathBuf)>,
37 project_extra_paths: Vec<PathBuf>,
38 vendor_extra_paths: Vec<PathBuf>,
39 #[allow(dead_code)] root: PathBuf,
41}
42
43fn ensure_trailing_backslash(prefix: &str) -> String {
44 if prefix.ends_with('\\') {
45 prefix.to_string()
46 } else {
47 format!("{prefix}\\")
48 }
49}
50
51fn collect_prefix_dirs(
54 value: &serde_json::Value,
55 prefix: &str,
56 base: &Path,
57 entries: &mut Vec<(String, PathBuf)>,
58) {
59 let pfx = ensure_trailing_backslash(prefix);
60 if let Some(d) = value.as_str() {
61 entries.push((pfx, base.join(d)));
62 } else if let Some(arr) = value.as_array() {
63 for item in arr {
64 if let Some(d) = item.as_str() {
65 entries.push((pfx.clone(), base.join(d)));
66 }
67 }
68 }
69}
70
71fn collect_path_array(value: &serde_json::Value, base: &Path, out: &mut Vec<PathBuf>) {
73 if let Some(arr) = value.as_array() {
74 for item in arr {
75 if let Some(s) = item.as_str() {
76 out.push(base.join(s));
77 }
78 }
79 }
80}
81
82fn parse_autoload_section(
83 autoload: &serde_json::Value,
84 base: &Path,
85 entries: &mut Vec<(String, PathBuf)>,
86 extras: &mut Vec<PathBuf>,
87) {
88 if let Some(map) = autoload.get("psr-4").and_then(|v| v.as_object()) {
89 for (prefix, dir) in map {
90 collect_prefix_dirs(dir, prefix, base, entries);
91 }
92 }
93 if let Some(map) = autoload.get("psr-0").and_then(|v| v.as_object()) {
100 for (_, dir) in map {
101 if let Some(d) = dir.as_str() {
102 extras.push(base.join(d));
103 } else if let Some(arr) = dir.as_array() {
104 for item in arr {
105 if let Some(d) = item.as_str() {
106 extras.push(base.join(d));
107 }
108 }
109 }
110 }
111 }
112 if let Some(cm) = autoload.get("classmap") {
113 collect_path_array(cm, base, extras);
114 }
115 if let Some(files) = autoload.get("files") {
116 collect_path_array(files, base, extras);
117 }
118}
119
120fn parse_vendor(root: &Path, entries: &mut Vec<(String, PathBuf)>, extras: &mut Vec<PathBuf>) {
121 let installed_path = root.join("vendor/composer/installed.json");
122 let content = match std::fs::read_to_string(&installed_path) {
123 Ok(c) => c,
124 Err(_) => return,
125 };
126 let value: serde_json::Value = match serde_json::from_str(&content) {
127 Ok(v) => v,
128 Err(_) => return,
129 };
130
131 let packages = if let Some(arr) = value.get("packages").and_then(|v| v.as_array()) {
132 arr.clone()
133 } else if let Some(arr) = value.as_array() {
134 arr.clone()
135 } else {
136 return;
137 };
138
139 let vendor_dir = root.join("vendor");
140
141 for pkg in &packages {
142 let pkg_name = pkg.get("name").and_then(|v| v.as_str()).unwrap_or("");
143 let pkg_dir = vendor_dir.join(pkg_name);
144 if let Some(autoload) = pkg.get("autoload") {
145 parse_autoload_section(autoload, &pkg_dir, entries, extras);
146 }
147 }
148}
149
150impl Psr4Map {
151 pub fn from_composer(root: &Path) -> Result<Self, ComposerError> {
152 let composer_path = root.join("composer.json");
153 let content = std::fs::read_to_string(&composer_path)?;
154 let value: serde_json::Value = serde_json::from_str(&content)?;
155
156 let has_autoload = value.get("autoload").is_some() || value.get("autoload-dev").is_some();
157 if !has_autoload {
158 return Err(ComposerError::MissingAutoload);
159 }
160
161 let mut project_entries: Vec<(String, PathBuf)> = Vec::new();
162 let mut project_extra_paths: Vec<PathBuf> = Vec::new();
163
164 if let Some(autoload) = value.get("autoload") {
165 parse_autoload_section(
166 autoload,
167 root,
168 &mut project_entries,
169 &mut project_extra_paths,
170 );
171 }
172 if let Some(autoload) = value.get("autoload-dev") {
173 parse_autoload_section(
174 autoload,
175 root,
176 &mut project_entries,
177 &mut project_extra_paths,
178 );
179 }
180
181 project_entries.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
182
183 let mut vendor_entries: Vec<(String, PathBuf)> = Vec::new();
184 let mut vendor_extra_paths: Vec<PathBuf> = Vec::new();
185 parse_vendor(root, &mut vendor_entries, &mut vendor_extra_paths);
186 vendor_entries.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
187
188 Ok(Psr4Map {
189 project_entries,
190 vendor_entries,
191 project_extra_paths,
192 vendor_extra_paths,
193 root: root.to_path_buf(),
194 })
195 }
196
197 pub fn project_files(&self) -> Vec<PathBuf> {
198 let mut out = Vec::new();
199 for (_, dir) in &self.project_entries {
200 crate::project::collect_php_files(dir, &mut out);
201 }
202 for path in &self.project_extra_paths {
203 collect_php_path(path, &mut out);
204 }
205 out
206 }
207
208 pub fn vendor_files(&self) -> Vec<PathBuf> {
209 let mut out = Vec::new();
210 for (_, dir) in &self.vendor_entries {
211 crate::project::collect_php_files(dir, &mut out);
212 }
213 for path in &self.vendor_extra_paths {
214 collect_php_path(path, &mut out);
215 }
216 out
217 }
218
219 pub fn resolve(&self, fqcn: &str) -> Option<PathBuf> {
222 for (prefix, dir) in self
223 .project_entries
224 .iter()
225 .chain(self.vendor_entries.iter())
226 {
227 if fqcn.starts_with(prefix.as_str()) {
228 let relative = &fqcn[prefix.len()..];
229 let file_path = dir.join(relative.replace('\\', "/")).with_extension("php");
230 if file_path.exists() {
231 return Some(file_path);
232 }
233 }
234 }
235 None
236 }
237}
238
239fn collect_php_path(path: &Path, out: &mut Vec<PathBuf>) {
242 let Ok(meta) = std::fs::metadata(path) else {
243 return;
244 };
245 if meta.is_file() {
246 if path.extension().and_then(|e| e.to_str()) == Some("php") {
247 out.push(path.to_path_buf());
248 }
249 } else if meta.is_dir() {
250 crate::project::collect_php_files(path, out);
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257 use std::fs;
258
259 fn make_temp_project(name: &str) -> PathBuf {
260 let dir = std::env::temp_dir().join(format!("mir_psr4_{name}"));
261 let _ = fs::remove_dir_all(&dir);
262 fs::create_dir_all(&dir).unwrap();
263 dir
264 }
265
266 #[test]
267 fn parse_project_entries() {
268 let root = make_temp_project("parse_project_entries");
269 fs::write(
270 root.join("composer.json"),
271 r#"{
272 "autoload": {
273 "psr-4": { "App\\": "src/", "App\\Models\\": "src/models/" }
274 },
275 "autoload-dev": {
276 "psr-4": { "Tests\\": "tests/" }
277 }
278 }"#,
279 )
280 .unwrap();
281
282 let map = Psr4Map::from_composer(&root).unwrap();
283
284 let prefixes: Vec<&str> = map
285 .project_entries
286 .iter()
287 .map(|(p, _)| p.as_str())
288 .collect();
289 assert!(prefixes.contains(&"App\\Models\\"), "missing App\\Models\\");
290 assert!(prefixes.contains(&"App\\"), "missing App\\");
291 assert!(prefixes.contains(&"Tests\\"), "missing Tests\\");
292 }
293
294 #[test]
295 fn longest_prefix_first() {
296 let root = make_temp_project("longest_prefix_first");
297 fs::write(
298 root.join("composer.json"),
299 r#"{
300 "autoload": {
301 "psr-4": { "App\\": "src/", "App\\Models\\": "src/models/" }
302 }
303 }"#,
304 )
305 .unwrap();
306
307 let map = Psr4Map::from_composer(&root).unwrap();
308
309 assert_eq!(map.project_entries[0].0, "App\\Models\\");
310 }
311
312 #[test]
313 fn missing_autoload_section_is_error() {
314 let root = make_temp_project("missing_autoload");
315 fs::write(root.join("composer.json"), r#"{ "name": "my/pkg" }"#).unwrap();
316
317 let result = Psr4Map::from_composer(&root);
318 assert!(
319 matches!(result, Err(ComposerError::MissingAutoload)),
320 "expected MissingAutoload error"
321 );
322 }
323
324 #[test]
325 fn composer_v2_installed() {
326 let root = make_temp_project("composer_v2");
327 fs::write(
328 root.join("composer.json"),
329 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
330 )
331 .unwrap();
332
333 let vendor_dir = root.join("vendor/composer");
334 fs::create_dir_all(&vendor_dir).unwrap();
335 fs::write(
336 vendor_dir.join("installed.json"),
337 r#"{
338 "packages": [
339 {
340 "name": "vendor/pkg",
341 "autoload": { "psr-4": { "Vendor\\Pkg\\": "src/" } }
342 }
343 ]
344 }"#,
345 )
346 .unwrap();
347 fs::create_dir_all(root.join("vendor/vendor/pkg/src")).unwrap();
348
349 let map = Psr4Map::from_composer(&root).unwrap();
350 let prefixes: Vec<&str> = map.vendor_entries.iter().map(|(p, _)| p.as_str()).collect();
351 assert!(prefixes.contains(&"Vendor\\Pkg\\"), "missing Vendor\\Pkg\\");
352 }
353
354 #[test]
355 fn composer_v1_installed() {
356 let root = make_temp_project("composer_v1");
357 fs::write(
358 root.join("composer.json"),
359 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
360 )
361 .unwrap();
362
363 let vendor_dir = root.join("vendor/composer");
364 fs::create_dir_all(&vendor_dir).unwrap();
365 fs::write(
366 vendor_dir.join("installed.json"),
367 r#"[
368 {
369 "name": "vendor/pkg",
370 "autoload": { "psr-4": { "Vendor\\Pkg\\": "src/" } }
371 }
372 ]"#,
373 )
374 .unwrap();
375 fs::create_dir_all(root.join("vendor/vendor/pkg/src")).unwrap();
376
377 let map = Psr4Map::from_composer(&root).unwrap();
378 let prefixes: Vec<&str> = map.vendor_entries.iter().map(|(p, _)| p.as_str()).collect();
379 assert!(prefixes.contains(&"Vendor\\Pkg\\"), "missing Vendor\\Pkg\\");
380 }
381
382 #[test]
383 fn missing_installed_json() {
384 let root = make_temp_project("missing_installed");
385 fs::write(
386 root.join("composer.json"),
387 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
388 )
389 .unwrap();
390 let map = Psr4Map::from_composer(&root).unwrap();
391 assert!(map.vendor_entries.is_empty());
392 }
393
394 #[test]
395 fn project_files_returns_php_files() {
396 let root = make_temp_project("project_files");
397 let src = root.join("src");
398 fs::create_dir_all(&src).unwrap();
399 fs::write(src.join("Foo.php"), "<?php class Foo {}").unwrap();
400 fs::write(src.join("README.md"), "not php").unwrap();
401 fs::write(
402 root.join("composer.json"),
403 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
404 )
405 .unwrap();
406
407 let map = Psr4Map::from_composer(&root).unwrap();
408 let files = map.project_files();
409 assert_eq!(files.len(), 1);
410 assert!(files[0].ends_with("Foo.php"));
411 }
412
413 #[test]
414 fn resolve_existing_file() {
415 let root = make_temp_project("resolve_existing");
416 let models = root.join("src/models");
417 fs::create_dir_all(&models).unwrap();
418 fs::write(models.join("User.php"), "<?php class User {}").unwrap();
419 fs::write(
420 root.join("composer.json"),
421 r#"{"autoload":{"psr-4":{"App\\Models\\":"src/models/","App\\":"src/"}}}"#,
422 )
423 .unwrap();
424
425 let map = Psr4Map::from_composer(&root).unwrap();
426 let result = map.resolve("App\\Models\\User");
427 assert!(result.is_some(), "expected a resolved path");
428 assert!(result.unwrap().ends_with("User.php"));
429 }
430
431 #[test]
432 fn resolve_missing_file() {
433 let root = make_temp_project("resolve_missing");
434 fs::create_dir_all(root.join("src")).unwrap();
435 fs::write(
436 root.join("composer.json"),
437 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
438 )
439 .unwrap();
440
441 let map = Psr4Map::from_composer(&root).unwrap();
442 let result = map.resolve("App\\Models\\User");
443 assert!(result.is_none());
444 }
445
446 #[test]
447 fn boundary_check() {
448 let root = make_temp_project("boundary_check");
449 fs::create_dir_all(root.join("src")).unwrap();
450 fs::write(
451 root.join("composer.json"),
452 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
453 )
454 .unwrap();
455
456 let map = Psr4Map::from_composer(&root).unwrap();
457 let result = map.resolve("Application\\Foo");
459 assert!(
460 result.is_none(),
461 "App\\ prefix must not match Application\\Foo"
462 );
463 }
464
465 #[test]
466 fn array_valued_psr4_dirs() {
467 let root = make_temp_project("array_dirs");
468 let src = root.join("src");
469 let lib = root.join("lib");
470 fs::create_dir_all(&src).unwrap();
471 fs::create_dir_all(&lib).unwrap();
472 fs::write(src.join("Foo.php"), "<?php class Foo {}").unwrap();
473 fs::write(lib.join("Bar.php"), "<?php class Bar {}").unwrap();
474 fs::write(
475 root.join("composer.json"),
476 r#"{"autoload":{"psr-4":{"App\\":["src/","lib/"]}}}"#,
477 )
478 .unwrap();
479
480 let map = Psr4Map::from_composer(&root).unwrap();
481 assert_eq!(
483 map.project_entries.len(),
484 2,
485 "expected 2 entries for array-valued dir"
486 );
487 let files = map.project_files();
488 assert_eq!(files.len(), 2, "expected Foo.php and Bar.php");
489 }
490
491 #[test]
496 fn project_classmap_dir_is_collected() {
497 let root = make_temp_project("project_classmap");
498 let lib = root.join("lib");
499 fs::create_dir_all(&lib).unwrap();
500 fs::write(lib.join("Legacy.php"), "<?php class Legacy {}").unwrap();
501 fs::write(
502 root.join("composer.json"),
503 r#"{"autoload":{"classmap":["lib/"]}}"#,
504 )
505 .unwrap();
506
507 let map = Psr4Map::from_composer(&root).unwrap();
508 let files = map.project_files();
509 assert_eq!(files.len(), 1);
510 assert!(files[0].ends_with("Legacy.php"));
511 }
512
513 #[test]
514 fn project_files_autoload_is_collected() {
515 let root = make_temp_project("project_files_autoload");
516 fs::write(root.join("helpers.php"), "<?php function my_helper() {}").unwrap();
517 fs::write(
518 root.join("composer.json"),
519 r#"{"autoload":{"files":["helpers.php"]}}"#,
520 )
521 .unwrap();
522
523 let map = Psr4Map::from_composer(&root).unwrap();
524 let files = map.project_files();
525 assert_eq!(files.len(), 1);
526 assert!(files[0].ends_with("helpers.php"));
527 }
528
529 #[test]
530 fn project_psr0_dir_is_collected() {
531 let root = make_temp_project("project_psr0");
532 let lib = root.join("legacy");
533 fs::create_dir_all(&lib).unwrap();
534 fs::write(lib.join("Old.php"), "<?php class Old {}").unwrap();
535 fs::write(
536 root.join("composer.json"),
537 r#"{"autoload":{"psr-0":{"":"legacy/"}}}"#,
538 )
539 .unwrap();
540
541 let map = Psr4Map::from_composer(&root).unwrap();
542 let files = map.project_files();
543 assert_eq!(files.len(), 1);
544 assert!(files[0].ends_with("Old.php"));
545 }
546
547 #[test]
548 fn vendor_classmap_is_collected() {
549 let root = make_temp_project("vendor_classmap");
550 fs::write(
551 root.join("composer.json"),
552 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
553 )
554 .unwrap();
555 let vendor_dir = root.join("vendor/composer");
556 fs::create_dir_all(&vendor_dir).unwrap();
557 fs::write(
558 vendor_dir.join("installed.json"),
559 r#"{
560 "packages": [{
561 "name": "vendor/pkg",
562 "autoload": { "classmap": ["src/"] }
563 }]
564 }"#,
565 )
566 .unwrap();
567 let pkg_src = root.join("vendor/vendor/pkg/src");
568 fs::create_dir_all(&pkg_src).unwrap();
569 fs::write(pkg_src.join("Legacy.php"), "<?php class Legacy {}").unwrap();
570
571 let map = Psr4Map::from_composer(&root).unwrap();
572 let files = map.vendor_files();
573 assert_eq!(files.len(), 1);
574 assert!(files[0].ends_with("Legacy.php"));
575 }
576
577 #[test]
578 fn vendor_files_autoload_is_collected() {
579 let root = make_temp_project("vendor_files_autoload");
580 fs::write(
581 root.join("composer.json"),
582 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
583 )
584 .unwrap();
585 let vendor_dir = root.join("vendor/composer");
586 fs::create_dir_all(&vendor_dir).unwrap();
587 fs::write(
588 vendor_dir.join("installed.json"),
589 r#"{
590 "packages": [{
591 "name": "vendor/pkg",
592 "autoload": { "files": ["bootstrap.php"] }
593 }]
594 }"#,
595 )
596 .unwrap();
597 let pkg_dir = root.join("vendor/vendor/pkg");
598 fs::create_dir_all(&pkg_dir).unwrap();
599 fs::write(
600 pkg_dir.join("bootstrap.php"),
601 "<?php function pkg_bootstrap() {}",
602 )
603 .unwrap();
604
605 let map = Psr4Map::from_composer(&root).unwrap();
606 let files = map.vendor_files();
607 assert_eq!(files.len(), 1);
608 assert!(files[0].ends_with("bootstrap.php"));
609 }
610
611 #[test]
612 fn vendor_psr0_is_collected() {
613 let root = make_temp_project("vendor_psr0");
614 fs::write(
615 root.join("composer.json"),
616 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
617 )
618 .unwrap();
619 let vendor_dir = root.join("vendor/composer");
620 fs::create_dir_all(&vendor_dir).unwrap();
621 fs::write(
622 vendor_dir.join("installed.json"),
623 r#"{
624 "packages": [{
625 "name": "vendor/pkg",
626 "autoload": { "psr-0": { "Old_": "src/" } }
627 }]
628 }"#,
629 )
630 .unwrap();
631 let pkg_src = root.join("vendor/vendor/pkg/src/Old");
632 fs::create_dir_all(&pkg_src).unwrap();
633 fs::write(pkg_src.join("Thing.php"), "<?php class Old_Thing {}").unwrap();
634
635 let map = Psr4Map::from_composer(&root).unwrap();
636 let files = map.vendor_files();
637 assert_eq!(files.len(), 1);
638 assert!(files[0].ends_with("Thing.php"));
639 }
640}