Skip to main content

mir_analyzer/
composer.rs

1use std::path::{Path, PathBuf};
2use thiserror::Error;
3
4// ---------------------------------------------------------------------------
5// Error
6// ---------------------------------------------------------------------------
7
8#[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// ---------------------------------------------------------------------------
19// Psr4Map
20// ---------------------------------------------------------------------------
21
22/// PSR-4 / PSR-0 / classmap / files autoload mapping, built from `composer.json`
23/// and `vendor/composer/installed.json`.
24///
25/// `project_entries` covers `autoload.psr-4` / `autoload-dev.psr-4` /
26/// `autoload.psr-0` / `autoload-dev.psr-0` for the project itself.
27/// `vendor_entries` covers the same keys from each installed package.
28/// `project_extra_paths` and `vendor_extra_paths` collect the (prefix-less)
29/// `classmap` and `files` entries as raw paths — files are kept as-is, dirs
30/// are walked when assembling the file list.
31///
32/// Both prefix lists are sorted longest-prefix-first for correct prefix matching.
33#[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)] // used by issue #50 (lazy FQCN resolution)
40    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
51/// Append `(prefix, base.join(dir))` to `entries` for every dir-string in `value`
52/// (which may be a JSON string or an array of strings).
53fn 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
71/// Append every string in `value` (a JSON array) to `out` as `base.join(s)`.
72fn 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    // PSR-0 maps prefix → dir similarly to PSR-4. The class-name-to-file
94    // resolution differs (underscores in class basename become dirs), but for
95    // discovering all .php files in the mapped directories, walking the dir
96    // is sufficient. We do NOT add these to `entries` for FQCN resolution
97    // because `Psr4Map::resolve` uses PSR-4 semantics — instead we treat the
98    // dirs as bulk-scan paths.
99    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    /// Resolve a fully-qualified class name to a file path using longest-prefix-first matching.
220    /// Returns `None` if no prefix matches or the mapped file does not exist on disk.
221    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
239/// Collect `.php` files from `path`. If `path` is a file, push it directly
240/// (when it has a `.php` extension); if it is a directory, walk it.
241fn 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        // "App\" must NOT match "Application\Foo"
458        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        // Both dirs should be in project_entries
482        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    // -----------------------------------------------------------------------
492    // classmap / files / psr-0 — vendor and project
493    // -----------------------------------------------------------------------
494
495    #[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}