Skip to main content

mir_analyzer/
composer.rs

1use rustc_hash::FxHashMap;
2use std::path::{Path, PathBuf};
3use thiserror::Error;
4
5// ---------------------------------------------------------------------------
6// Error
7// ---------------------------------------------------------------------------
8
9#[derive(Debug, Error)]
10pub enum ComposerError {
11    #[error("composer I/O error: {0}")]
12    Io(#[from] std::io::Error),
13    #[error("composer JSON error: {0}")]
14    Json(#[from] serde_json::Error),
15    #[error("composer.json has no autoload section")]
16    MissingAutoload,
17}
18
19// ---------------------------------------------------------------------------
20// Psr4Map
21// ---------------------------------------------------------------------------
22
23/// PSR-4 / PSR-0 / classmap / files autoload mapping, built from `composer.json`
24/// and `vendor/composer/installed.json`.
25///
26/// `project_entries` covers `autoload.psr-4` / `autoload-dev.psr-4` /
27/// `autoload.psr-0` / `autoload-dev.psr-0` for the project itself.
28/// `vendor_entries` covers the same keys from each installed package.
29/// `project_extra_paths` and `vendor_extra_paths` collect the (prefix-less)
30/// `classmap` and `files` entries as raw paths — files are kept as-is, dirs
31/// are walked when assembling the file list.
32///
33/// Both prefix lists are sorted longest-prefix-first for correct prefix matching.
34#[derive(Clone)]
35pub struct Psr4Map {
36    project_entries: Vec<(String, PathBuf)>,
37    vendor_entries: Vec<(String, PathBuf)>,
38    project_extra_paths: Vec<PathBuf>,
39    vendor_extra_paths: Vec<PathBuf>,
40    /// Pre-resolved FQCN → file map from `vendor/composer/autoload_classmap.php`.
41    /// Covers packages using `classmap:` autoload (non-PSR-4) so they can be
42    /// lazy-loaded by FQCN without parsing every classmap directory eagerly.
43    /// Key is the FQCN with single backslashes (e.g. `AWS\\CRT\\Auth\\Signing`
44    /// in source becomes `AWS\CRT\Auth\Signing` here, matching what PHP code
45    /// uses at call sites).
46    classmap: FxHashMap<String, PathBuf>,
47    /// Files registered via `autoload.files` (project + vendor). These contain
48    /// unbound global functions/constants that are NOT FQCN-resolvable, so they
49    /// must be eagerly parsed even in lazy mode. Read from
50    /// `vendor/composer/autoload_files.php` if present, falling back to the
51    /// per-package `installed.json` walk.
52    vendor_eager_files: Vec<PathBuf>,
53    #[allow(dead_code)] // used by issue #50 (lazy FQCN resolution)
54    root: PathBuf,
55}
56
57fn ensure_trailing_backslash(prefix: &str) -> String {
58    if prefix.ends_with('\\') {
59        prefix.to_string()
60    } else {
61        format!("{prefix}\\")
62    }
63}
64
65/// Append `(prefix, base.join(dir))` to `entries` for every dir-string in `value`
66/// (which may be a JSON string or an array of strings).
67fn collect_prefix_dirs(
68    value: &serde_json::Value,
69    prefix: &str,
70    base: &Path,
71    entries: &mut Vec<(String, PathBuf)>,
72) {
73    let pfx = ensure_trailing_backslash(prefix);
74    if let Some(d) = value.as_str() {
75        entries.push((pfx, base.join(d)));
76    } else if let Some(arr) = value.as_array() {
77        for item in arr {
78            if let Some(d) = item.as_str() {
79                entries.push((pfx.clone(), base.join(d)));
80            }
81        }
82    }
83}
84
85/// Append every string in `value` (a JSON array) to `out` as `base.join(s)`.
86fn collect_path_array(value: &serde_json::Value, base: &Path, out: &mut Vec<PathBuf>) {
87    if let Some(arr) = value.as_array() {
88        for item in arr {
89            if let Some(s) = item.as_str() {
90                out.push(base.join(s));
91            }
92        }
93    }
94}
95
96fn parse_autoload_section(
97    autoload: &serde_json::Value,
98    base: &Path,
99    entries: &mut Vec<(String, PathBuf)>,
100    extras: &mut Vec<PathBuf>,
101) {
102    if let Some(map) = autoload.get("psr-4").and_then(|v| v.as_object()) {
103        for (prefix, dir) in map {
104            collect_prefix_dirs(dir, prefix, base, entries);
105        }
106    }
107    // PSR-0 maps prefix → dir similarly to PSR-4. The class-name-to-file
108    // resolution differs (underscores in class basename become dirs), but for
109    // discovering all .php files in the mapped directories, walking the dir
110    // is sufficient. We do NOT add these to `entries` for FQCN resolution
111    // because `Psr4Map::resolve` uses PSR-4 semantics — instead we treat the
112    // dirs as bulk-scan paths.
113    if let Some(map) = autoload.get("psr-0").and_then(|v| v.as_object()) {
114        for (_, dir) in map {
115            if let Some(d) = dir.as_str() {
116                extras.push(base.join(d));
117            } else if let Some(arr) = dir.as_array() {
118                for item in arr {
119                    if let Some(d) = item.as_str() {
120                        extras.push(base.join(d));
121                    }
122                }
123            }
124        }
125    }
126    if let Some(cm) = autoload.get("classmap") {
127        collect_path_array(cm, base, extras);
128    }
129    if let Some(files) = autoload.get("files") {
130        collect_path_array(files, base, extras);
131    }
132}
133
134/// Parse a Composer-generated `autoload_classmap.php` or `autoload_files.php`.
135///
136/// The format is mechanically generated and stable:
137///
138/// ```text
139/// <?php
140/// $vendorDir = dirname(__DIR__);
141/// $baseDir = dirname($vendorDir);
142/// return array(
143///     'KEY' => $vendorDir . '/relative/path.php',
144///     'OTHER' => $baseDir . '/other/path.php',
145/// );
146/// ```
147///
148/// Returns `(key, absolute_path)` pairs. Unparseable lines are silently
149/// skipped — a stale or hand-edited file degrades gracefully.
150///
151/// `key` is returned with PHP-escape-sequence handling for backslashes (`\\` → `\`).
152fn parse_composer_autoload_array(
153    content: &str,
154    vendor_dir: &Path,
155    base_dir: &Path,
156) -> Vec<(String, PathBuf)> {
157    let mut out = Vec::new();
158    for line in content.lines() {
159        let line = line.trim();
160        // Find `'KEY' => $VAR . 'PATH'` or `"KEY" => $VAR . "PATH"`.
161        let (key, rest) = match extract_quoted(line) {
162            Some(p) => p,
163            None => continue,
164        };
165        let rest = rest.trim_start();
166        let rest = match rest.strip_prefix("=>") {
167            Some(r) => r.trim_start(),
168            None => continue,
169        };
170        let (var, rest) = match rest.strip_prefix('$') {
171            Some(r) => {
172                let end = r
173                    .find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
174                    .unwrap_or(r.len());
175                (&r[..end], &r[end..])
176            }
177            None => continue,
178        };
179        let rest = rest.trim_start();
180        let rest = match rest.strip_prefix('.') {
181            Some(r) => r.trim_start(),
182            None => continue,
183        };
184        let (path_frag, _) = match extract_quoted(rest) {
185            Some(p) => p,
186            None => continue,
187        };
188        let base = match var {
189            "vendorDir" => vendor_dir,
190            "baseDir" => base_dir,
191            _ => continue,
192        };
193        // path_frag begins with '/' relative to the chosen base.
194        let path_rel = path_frag.trim_start_matches('/');
195        out.push((key, base.join(path_rel)));
196    }
197    out
198}
199
200/// Pull a PHP single- or double-quoted string from the start of `s`, decoding
201/// `\\` → `\` and `\'` / `\"` → the corresponding quote. Returns `(decoded, tail)`
202/// where `tail` is the slice after the closing quote.
203fn extract_quoted(s: &str) -> Option<(String, &str)> {
204    let mut it = s.char_indices();
205    let (_, quote) = it.next()?;
206    if quote != '\'' && quote != '"' {
207        return None;
208    }
209    let mut out = String::new();
210    let mut escape = false;
211    for (i, ch) in it {
212        if escape {
213            out.push(ch);
214            escape = false;
215        } else if ch == '\\' {
216            escape = true;
217        } else if ch == quote {
218            return Some((out, &s[i + ch.len_utf8()..]));
219        } else {
220            out.push(ch);
221        }
222    }
223    None
224}
225
226fn parse_vendor(root: &Path, entries: &mut Vec<(String, PathBuf)>, extras: &mut Vec<PathBuf>) {
227    let installed_path = root.join("vendor/composer/installed.json");
228    let content = match std::fs::read_to_string(&installed_path) {
229        Ok(c) => c,
230        Err(_) => return,
231    };
232    let value: serde_json::Value = match serde_json::from_str(&content) {
233        Ok(v) => v,
234        Err(_) => return,
235    };
236
237    let packages = if let Some(arr) = value.get("packages").and_then(|v| v.as_array()) {
238        arr.clone()
239    } else if let Some(arr) = value.as_array() {
240        arr.clone()
241    } else {
242        return;
243    };
244
245    let vendor_dir = root.join("vendor");
246
247    for pkg in &packages {
248        let pkg_name = pkg.get("name").and_then(|v| v.as_str()).unwrap_or("");
249        let pkg_dir = vendor_dir.join(pkg_name);
250        if let Some(autoload) = pkg.get("autoload") {
251            parse_autoload_section(autoload, &pkg_dir, entries, extras);
252        }
253    }
254}
255
256/// Read `vendor/composer/autoload_classmap.php`. Returns an empty map if the
257/// file is absent or unreadable — callers fall back to the project-defined
258/// PSR-4 entries which still cover the typical case.
259///
260/// Uses lossy UTF-8 decoding because some real-world classmap files contain
261/// stray Latin-1 bytes (e.g. Laravel's includes a `\xa9` key from a vendor
262/// package). Lossy decoding only affects the malformed FQCN itself — every
263/// other entry parses correctly.
264fn read_classmap(vendor_dir: &Path, base_dir: &Path) -> FxHashMap<String, PathBuf> {
265    let path = vendor_dir.join("composer/autoload_classmap.php");
266    let Ok(bytes) = std::fs::read(&path) else {
267        return FxHashMap::default();
268    };
269    let content = String::from_utf8_lossy(&bytes);
270    parse_composer_autoload_array(&content, vendor_dir, base_dir)
271        .into_iter()
272        .collect()
273}
274
275/// Read `vendor/composer/autoload_files.php`. Falls back to walking
276/// `installed.json` if the generated file is absent — covers projects that
277/// haven't run `composer dump-autoload --optimize`.
278fn read_files_autoload(vendor_dir: &Path, base_dir: &Path) -> Vec<PathBuf> {
279    let path = vendor_dir.join("composer/autoload_files.php");
280    if let Ok(bytes) = std::fs::read(&path) {
281        let content = String::from_utf8_lossy(&bytes);
282        return parse_composer_autoload_array(&content, vendor_dir, base_dir)
283            .into_iter()
284            .map(|(_, p)| p)
285            .filter(|p| p.is_file())
286            .collect();
287    }
288    // Fallback: walk installed.json packages for autoload.files entries only.
289    let installed_path = vendor_dir.join("composer/installed.json");
290    let content = match std::fs::read_to_string(&installed_path) {
291        Ok(c) => c,
292        Err(_) => return Vec::new(),
293    };
294    let value: serde_json::Value = match serde_json::from_str(&content) {
295        Ok(v) => v,
296        Err(_) => return Vec::new(),
297    };
298    let packages = if let Some(arr) = value.get("packages").and_then(|v| v.as_array()) {
299        arr.clone()
300    } else if let Some(arr) = value.as_array() {
301        arr.clone()
302    } else {
303        return Vec::new();
304    };
305    let mut out = Vec::new();
306    for pkg in &packages {
307        let pkg_name = pkg.get("name").and_then(|v| v.as_str()).unwrap_or("");
308        let pkg_dir = vendor_dir.join(pkg_name);
309        if let Some(files) = pkg.get("autoload").and_then(|a| a.get("files")) {
310            collect_path_array(files, &pkg_dir, &mut out);
311        }
312    }
313    let _ = base_dir; // unused in fallback path (paths are package-relative)
314    out.into_iter().filter(|p| p.is_file()).collect()
315}
316
317impl Psr4Map {
318    pub fn from_composer(root: &Path) -> Result<Self, ComposerError> {
319        let composer_path = root.join("composer.json");
320        let content = std::fs::read_to_string(&composer_path)?;
321        let value: serde_json::Value = serde_json::from_str(&content)?;
322
323        let has_autoload = value.get("autoload").is_some() || value.get("autoload-dev").is_some();
324        if !has_autoload {
325            return Err(ComposerError::MissingAutoload);
326        }
327
328        let mut project_entries: Vec<(String, PathBuf)> = Vec::new();
329        let mut project_extra_paths: Vec<PathBuf> = Vec::new();
330
331        if let Some(autoload) = value.get("autoload") {
332            parse_autoload_section(
333                autoload,
334                root,
335                &mut project_entries,
336                &mut project_extra_paths,
337            );
338        }
339        if let Some(autoload) = value.get("autoload-dev") {
340            parse_autoload_section(
341                autoload,
342                root,
343                &mut project_entries,
344                &mut project_extra_paths,
345            );
346        }
347
348        project_entries.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
349
350        let mut vendor_entries: Vec<(String, PathBuf)> = Vec::new();
351        let mut vendor_extra_paths: Vec<PathBuf> = Vec::new();
352        parse_vendor(root, &mut vendor_entries, &mut vendor_extra_paths);
353        vendor_entries.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
354
355        // Read composer-generated FQCN → file map from autoload_classmap.php.
356        // When present this is the source of truth for non-PSR-4 vendor classes,
357        // letting lazy-mode resolve them without parsing whole classmap dirs.
358        let vendor_dir = root.join("vendor");
359        let classmap = read_classmap(&vendor_dir, root);
360
361        // Eager-load list = autoload.files entries (project + vendor). These
362        // hold unbound globals (functions, constants, polyfills) that the lazy
363        // FQCN-based resolver cannot reach.
364        let vendor_eager_files = read_files_autoload(&vendor_dir, root);
365
366        Ok(Psr4Map {
367            project_entries,
368            vendor_entries,
369            project_extra_paths,
370            vendor_extra_paths,
371            classmap,
372            vendor_eager_files,
373            root: root.to_path_buf(),
374        })
375    }
376
377    pub fn project_files(&self) -> Vec<PathBuf> {
378        let mut out = Vec::new();
379        for (_, dir) in &self.project_entries {
380            crate::batch::collect_php_files(dir, &mut out);
381        }
382        for path in &self.project_extra_paths {
383            collect_php_path(path, &mut out);
384        }
385        out
386    }
387
388    pub fn vendor_files(&self) -> Vec<PathBuf> {
389        let mut out = Vec::new();
390        for (_, dir) in &self.vendor_entries {
391            crate::batch::collect_php_files(dir, &mut out);
392        }
393        for path in &self.vendor_extra_paths {
394            collect_php_path(path, &mut out);
395        }
396        out
397    }
398
399    /// Resolve a fully-qualified class name to a file path using longest-prefix-first matching.
400    /// Returns `None` if no prefix matches or the mapped file does not exist on disk.
401    ///
402    /// Resolution order:
403    /// 1. PSR-4 project entries (longest-prefix-first).
404    /// 2. PSR-4 vendor entries (longest-prefix-first).
405    /// 3. Classmap from `vendor/composer/autoload_classmap.php` — exact FQCN match.
406    ///
407    /// PSR-4 wins over classmap because Composer's runtime resolver uses the
408    /// same order; this matches what the PHP code being analyzed actually sees.
409    pub fn resolve(&self, fqcn: &str) -> Option<PathBuf> {
410        let key = fqcn.trim_start_matches('\\');
411        for (prefix, dir) in self
412            .project_entries
413            .iter()
414            .chain(self.vendor_entries.iter())
415        {
416            if key.starts_with(prefix.as_str()) {
417                let relative = &key[prefix.len()..];
418                let file_path = dir.join(relative.replace('\\', "/")).with_extension("php");
419                if file_path.exists() {
420                    return Some(file_path);
421                }
422            }
423        }
424        if let Some(path) = self.classmap.get(key) {
425            if path.exists() {
426                return Some(path.clone());
427            }
428        }
429        None
430    }
431
432    /// Vendor files that must be eagerly parsed in lazy mode: `autoload.files`
433    /// entries from composer. These hold globals (functions, constants,
434    /// polyfills) that the FQCN-based lazy resolver cannot reach because they
435    /// have no namespace mapping.
436    ///
437    /// Returns just the existing `.php` files; missing entries (stale generated
438    /// file) are dropped.
439    pub fn vendor_eager_files(&self) -> Vec<PathBuf> {
440        self.vendor_eager_files.clone()
441    }
442
443    /// Every vendor file the analyzer should index eagerly, for the
444    /// rust-analyzer-style static-input model: the union of
445    ///
446    /// 1. [`Self::vendor_files`] — PSR-4 / PSR-0 walked directories + extra paths,
447    /// 2. classmap file targets — packages that use `classmap:` autoload (no
448    ///    PSR-4 prefix), which [`Self::vendor_files`] does NOT walk, and
449    /// 3. [`Self::vendor_eager_files`] — `autoload.files` globals.
450    ///
451    /// Deduplicated by path. Non-existent classmap targets (stale generated
452    /// file) are skipped. This is the work-list a consumer feeds to the chunked
453    /// background indexer ([`crate::AnalysisSession::index_batch`]).
454    pub fn all_vendor_files(&self) -> Vec<PathBuf> {
455        let mut seen: rustc_hash::FxHashSet<PathBuf> = rustc_hash::FxHashSet::default();
456        let mut out = Vec::new();
457        let mut push = |p: PathBuf, out: &mut Vec<PathBuf>| {
458            if seen.insert(p.clone()) {
459                out.push(p);
460            }
461        };
462        for p in self.vendor_files() {
463            push(p, &mut out);
464        }
465        // Classmap-only packages are not covered by vendor_files() (which walks
466        // only PSR-4/PSR-0 dirs + extra paths). Include their file targets.
467        for p in self.classmap.values() {
468            if p.is_file() {
469                push(p.clone(), &mut out);
470            }
471        }
472        for p in self.vendor_eager_files() {
473            push(p, &mut out);
474        }
475        out
476    }
477
478    /// Number of FQCN entries known to the classmap. Used by callers that want
479    /// to log/verify the classmap loaded successfully.
480    pub fn classmap_len(&self) -> usize {
481        self.classmap.len()
482    }
483}
484
485/// Collect `.php` files from `path`. If `path` is a file, push it directly
486/// (when it has a `.php` extension); if it is a directory, walk it.
487fn collect_php_path(path: &Path, out: &mut Vec<PathBuf>) {
488    let Ok(meta) = std::fs::metadata(path) else {
489        return;
490    };
491    if meta.is_file() {
492        if path.extension().and_then(|e| e.to_str()) == Some("php") {
493            out.push(path.to_path_buf());
494        }
495    } else if meta.is_dir() {
496        crate::batch::collect_php_files(path, out);
497    }
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503    use std::fs;
504
505    fn make_temp_project(name: &str) -> PathBuf {
506        let dir = std::env::temp_dir().join(format!("mir_psr4_{name}"));
507        let _ = fs::remove_dir_all(&dir);
508        fs::create_dir_all(&dir).unwrap();
509        dir
510    }
511
512    #[test]
513    fn parse_project_entries() {
514        let root = make_temp_project("parse_project_entries");
515        fs::write(
516            root.join("composer.json"),
517            r#"{
518                "autoload": {
519                    "psr-4": { "App\\": "src/", "App\\Models\\": "src/models/" }
520                },
521                "autoload-dev": {
522                    "psr-4": { "Tests\\": "tests/" }
523                }
524            }"#,
525        )
526        .unwrap();
527
528        let map = Psr4Map::from_composer(&root).unwrap();
529
530        let prefixes: Vec<&str> = map
531            .project_entries
532            .iter()
533            .map(|(p, _)| p.as_str())
534            .collect();
535        assert!(prefixes.contains(&"App\\Models\\"), "missing App\\Models\\");
536        assert!(prefixes.contains(&"App\\"), "missing App\\");
537        assert!(prefixes.contains(&"Tests\\"), "missing Tests\\");
538    }
539
540    #[test]
541    fn longest_prefix_first() {
542        let root = make_temp_project("longest_prefix_first");
543        fs::write(
544            root.join("composer.json"),
545            r#"{
546                "autoload": {
547                    "psr-4": { "App\\": "src/", "App\\Models\\": "src/models/" }
548                }
549            }"#,
550        )
551        .unwrap();
552
553        let map = Psr4Map::from_composer(&root).unwrap();
554
555        assert_eq!(map.project_entries[0].0, "App\\Models\\");
556    }
557
558    #[test]
559    fn missing_autoload_section_is_error() {
560        let root = make_temp_project("missing_autoload");
561        fs::write(root.join("composer.json"), r#"{ "name": "my/pkg" }"#).unwrap();
562
563        let result = Psr4Map::from_composer(&root);
564        assert!(
565            matches!(result, Err(ComposerError::MissingAutoload)),
566            "expected MissingAutoload error"
567        );
568    }
569
570    #[test]
571    fn composer_v2_installed() {
572        let root = make_temp_project("composer_v2");
573        fs::write(
574            root.join("composer.json"),
575            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
576        )
577        .unwrap();
578
579        let vendor_dir = root.join("vendor/composer");
580        fs::create_dir_all(&vendor_dir).unwrap();
581        fs::write(
582            vendor_dir.join("installed.json"),
583            r#"{
584                "packages": [
585                    {
586                        "name": "vendor/pkg",
587                        "autoload": { "psr-4": { "Vendor\\Pkg\\": "src/" } }
588                    }
589                ]
590            }"#,
591        )
592        .unwrap();
593        fs::create_dir_all(root.join("vendor/vendor/pkg/src")).unwrap();
594
595        let map = Psr4Map::from_composer(&root).unwrap();
596        let prefixes: Vec<&str> = map.vendor_entries.iter().map(|(p, _)| p.as_str()).collect();
597        assert!(prefixes.contains(&"Vendor\\Pkg\\"), "missing Vendor\\Pkg\\");
598    }
599
600    #[test]
601    fn composer_v1_installed() {
602        let root = make_temp_project("composer_v1");
603        fs::write(
604            root.join("composer.json"),
605            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
606        )
607        .unwrap();
608
609        let vendor_dir = root.join("vendor/composer");
610        fs::create_dir_all(&vendor_dir).unwrap();
611        fs::write(
612            vendor_dir.join("installed.json"),
613            r#"[
614                {
615                    "name": "vendor/pkg",
616                    "autoload": { "psr-4": { "Vendor\\Pkg\\": "src/" } }
617                }
618            ]"#,
619        )
620        .unwrap();
621        fs::create_dir_all(root.join("vendor/vendor/pkg/src")).unwrap();
622
623        let map = Psr4Map::from_composer(&root).unwrap();
624        let prefixes: Vec<&str> = map.vendor_entries.iter().map(|(p, _)| p.as_str()).collect();
625        assert!(prefixes.contains(&"Vendor\\Pkg\\"), "missing Vendor\\Pkg\\");
626    }
627
628    #[test]
629    fn missing_installed_json() {
630        let root = make_temp_project("missing_installed");
631        fs::write(
632            root.join("composer.json"),
633            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
634        )
635        .unwrap();
636        let map = Psr4Map::from_composer(&root).unwrap();
637        assert!(map.vendor_entries.is_empty());
638    }
639
640    #[test]
641    fn project_files_returns_php_files() {
642        let root = make_temp_project("project_files");
643        let src = root.join("src");
644        fs::create_dir_all(&src).unwrap();
645        fs::write(src.join("Foo.php"), "<?php class Foo {}").unwrap();
646        fs::write(src.join("README.md"), "not php").unwrap();
647        fs::write(
648            root.join("composer.json"),
649            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
650        )
651        .unwrap();
652
653        let map = Psr4Map::from_composer(&root).unwrap();
654        let files = map.project_files();
655        assert_eq!(files.len(), 1);
656        assert!(files[0].ends_with("Foo.php"));
657    }
658
659    #[test]
660    fn resolve_existing_file() {
661        let root = make_temp_project("resolve_existing");
662        let models = root.join("src/models");
663        fs::create_dir_all(&models).unwrap();
664        fs::write(models.join("User.php"), "<?php class User {}").unwrap();
665        fs::write(
666            root.join("composer.json"),
667            r#"{"autoload":{"psr-4":{"App\\Models\\":"src/models/","App\\":"src/"}}}"#,
668        )
669        .unwrap();
670
671        let map = Psr4Map::from_composer(&root).unwrap();
672        let result = map.resolve("App\\Models\\User");
673        assert!(result.is_some(), "expected a resolved path");
674        assert!(result.unwrap().ends_with("User.php"));
675    }
676
677    #[test]
678    fn resolve_missing_file() {
679        let root = make_temp_project("resolve_missing");
680        fs::create_dir_all(root.join("src")).unwrap();
681        fs::write(
682            root.join("composer.json"),
683            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
684        )
685        .unwrap();
686
687        let map = Psr4Map::from_composer(&root).unwrap();
688        let result = map.resolve("App\\Models\\User");
689        assert!(result.is_none());
690    }
691
692    #[test]
693    fn boundary_check() {
694        let root = make_temp_project("boundary_check");
695        fs::create_dir_all(root.join("src")).unwrap();
696        fs::write(
697            root.join("composer.json"),
698            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
699        )
700        .unwrap();
701
702        let map = Psr4Map::from_composer(&root).unwrap();
703        // "App\" must NOT match "Application\Foo"
704        let result = map.resolve("Application\\Foo");
705        assert!(
706            result.is_none(),
707            "App\\ prefix must not match Application\\Foo"
708        );
709    }
710
711    #[test]
712    fn array_valued_psr4_dirs() {
713        let root = make_temp_project("array_dirs");
714        let src = root.join("src");
715        let lib = root.join("lib");
716        fs::create_dir_all(&src).unwrap();
717        fs::create_dir_all(&lib).unwrap();
718        fs::write(src.join("Foo.php"), "<?php class Foo {}").unwrap();
719        fs::write(lib.join("Bar.php"), "<?php class Bar {}").unwrap();
720        fs::write(
721            root.join("composer.json"),
722            r#"{"autoload":{"psr-4":{"App\\":["src/","lib/"]}}}"#,
723        )
724        .unwrap();
725
726        let map = Psr4Map::from_composer(&root).unwrap();
727        // Both dirs should be in project_entries
728        assert_eq!(
729            map.project_entries.len(),
730            2,
731            "expected 2 entries for array-valued dir"
732        );
733        let files = map.project_files();
734        assert_eq!(files.len(), 2, "expected Foo.php and Bar.php");
735    }
736
737    // -----------------------------------------------------------------------
738    // classmap / files / psr-0 — vendor and project
739    // -----------------------------------------------------------------------
740
741    #[test]
742    fn project_classmap_dir_is_collected() {
743        let root = make_temp_project("project_classmap");
744        let lib = root.join("lib");
745        fs::create_dir_all(&lib).unwrap();
746        fs::write(lib.join("Legacy.php"), "<?php class Legacy {}").unwrap();
747        fs::write(
748            root.join("composer.json"),
749            r#"{"autoload":{"classmap":["lib/"]}}"#,
750        )
751        .unwrap();
752
753        let map = Psr4Map::from_composer(&root).unwrap();
754        let files = map.project_files();
755        assert_eq!(files.len(), 1);
756        assert!(files[0].ends_with("Legacy.php"));
757    }
758
759    #[test]
760    fn project_files_autoload_is_collected() {
761        let root = make_temp_project("project_files_autoload");
762        fs::write(root.join("helpers.php"), "<?php function my_helper() {}").unwrap();
763        fs::write(
764            root.join("composer.json"),
765            r#"{"autoload":{"files":["helpers.php"]}}"#,
766        )
767        .unwrap();
768
769        let map = Psr4Map::from_composer(&root).unwrap();
770        let files = map.project_files();
771        assert_eq!(files.len(), 1);
772        assert!(files[0].ends_with("helpers.php"));
773    }
774
775    #[test]
776    fn project_psr0_dir_is_collected() {
777        let root = make_temp_project("project_psr0");
778        let lib = root.join("legacy");
779        fs::create_dir_all(&lib).unwrap();
780        fs::write(lib.join("Old.php"), "<?php class Old {}").unwrap();
781        fs::write(
782            root.join("composer.json"),
783            r#"{"autoload":{"psr-0":{"":"legacy/"}}}"#,
784        )
785        .unwrap();
786
787        let map = Psr4Map::from_composer(&root).unwrap();
788        let files = map.project_files();
789        assert_eq!(files.len(), 1);
790        assert!(files[0].ends_with("Old.php"));
791    }
792
793    #[test]
794    fn vendor_classmap_is_collected() {
795        let root = make_temp_project("vendor_classmap");
796        fs::write(
797            root.join("composer.json"),
798            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
799        )
800        .unwrap();
801        let vendor_dir = root.join("vendor/composer");
802        fs::create_dir_all(&vendor_dir).unwrap();
803        fs::write(
804            vendor_dir.join("installed.json"),
805            r#"{
806                "packages": [{
807                    "name": "vendor/pkg",
808                    "autoload": { "classmap": ["src/"] }
809                }]
810            }"#,
811        )
812        .unwrap();
813        let pkg_src = root.join("vendor/vendor/pkg/src");
814        fs::create_dir_all(&pkg_src).unwrap();
815        fs::write(pkg_src.join("Legacy.php"), "<?php class Legacy {}").unwrap();
816
817        let map = Psr4Map::from_composer(&root).unwrap();
818        let files = map.vendor_files();
819        assert_eq!(files.len(), 1);
820        assert!(files[0].ends_with("Legacy.php"));
821    }
822
823    #[test]
824    fn vendor_files_autoload_is_collected() {
825        let root = make_temp_project("vendor_files_autoload");
826        fs::write(
827            root.join("composer.json"),
828            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
829        )
830        .unwrap();
831        let vendor_dir = root.join("vendor/composer");
832        fs::create_dir_all(&vendor_dir).unwrap();
833        fs::write(
834            vendor_dir.join("installed.json"),
835            r#"{
836                "packages": [{
837                    "name": "vendor/pkg",
838                    "autoload": { "files": ["bootstrap.php"] }
839                }]
840            }"#,
841        )
842        .unwrap();
843        let pkg_dir = root.join("vendor/vendor/pkg");
844        fs::create_dir_all(&pkg_dir).unwrap();
845        fs::write(
846            pkg_dir.join("bootstrap.php"),
847            "<?php function pkg_bootstrap() {}",
848        )
849        .unwrap();
850
851        let map = Psr4Map::from_composer(&root).unwrap();
852        let files = map.vendor_files();
853        assert_eq!(files.len(), 1);
854        assert!(files[0].ends_with("bootstrap.php"));
855    }
856
857    #[test]
858    fn vendor_psr0_is_collected() {
859        let root = make_temp_project("vendor_psr0");
860        fs::write(
861            root.join("composer.json"),
862            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
863        )
864        .unwrap();
865        let vendor_dir = root.join("vendor/composer");
866        fs::create_dir_all(&vendor_dir).unwrap();
867        fs::write(
868            vendor_dir.join("installed.json"),
869            r#"{
870                "packages": [{
871                    "name": "vendor/pkg",
872                    "autoload": { "psr-0": { "Old_": "src/" } }
873                }]
874            }"#,
875        )
876        .unwrap();
877        let pkg_src = root.join("vendor/vendor/pkg/src/Old");
878        fs::create_dir_all(&pkg_src).unwrap();
879        fs::write(pkg_src.join("Thing.php"), "<?php class Old_Thing {}").unwrap();
880
881        let map = Psr4Map::from_composer(&root).unwrap();
882        let files = map.vendor_files();
883        assert_eq!(files.len(), 1);
884        assert!(files[0].ends_with("Thing.php"));
885    }
886}