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(e) => {
235            eprintln!(
236                "mir: warning: failed to parse {}: {e} (vendor PSR-4 map will be empty)",
237                installed_path.display()
238            );
239            return;
240        }
241    };
242
243    let packages = if let Some(arr) = value.get("packages").and_then(|v| v.as_array()) {
244        arr.clone()
245    } else if let Some(arr) = value.as_array() {
246        arr.clone()
247    } else {
248        return;
249    };
250
251    let vendor_dir = root.join("vendor");
252
253    for pkg in &packages {
254        let pkg_name = pkg.get("name").and_then(|v| v.as_str()).unwrap_or("");
255        let pkg_dir = vendor_dir.join(pkg_name);
256        if let Some(autoload) = pkg.get("autoload") {
257            parse_autoload_section(autoload, &pkg_dir, entries, extras);
258        }
259    }
260}
261
262/// Read `vendor/composer/autoload_classmap.php`. Returns an empty map if the
263/// file is absent or unreadable — callers fall back to the project-defined
264/// PSR-4 entries which still cover the typical case.
265///
266/// Uses lossy UTF-8 decoding because some real-world classmap files contain
267/// stray Latin-1 bytes (e.g. Laravel's includes a `\xa9` key from a vendor
268/// package). Lossy decoding only affects the malformed FQCN itself — every
269/// other entry parses correctly.
270fn read_classmap(vendor_dir: &Path, base_dir: &Path) -> FxHashMap<String, PathBuf> {
271    let path = vendor_dir.join("composer/autoload_classmap.php");
272    let Ok(bytes) = std::fs::read(&path) else {
273        return FxHashMap::default();
274    };
275    let content = String::from_utf8_lossy(&bytes);
276    parse_composer_autoload_array(&content, vendor_dir, base_dir)
277        .into_iter()
278        .collect()
279}
280
281/// Read `vendor/composer/autoload_files.php`. Falls back to walking
282/// `installed.json` if the generated file is absent — covers projects that
283/// haven't run `composer dump-autoload --optimize`.
284fn read_files_autoload(vendor_dir: &Path, base_dir: &Path) -> Vec<PathBuf> {
285    let path = vendor_dir.join("composer/autoload_files.php");
286    if let Ok(bytes) = std::fs::read(&path) {
287        let content = String::from_utf8_lossy(&bytes);
288        return parse_composer_autoload_array(&content, vendor_dir, base_dir)
289            .into_iter()
290            .map(|(_, p)| p)
291            .filter(|p| p.is_file())
292            .collect();
293    }
294    // Fallback: walk installed.json packages for autoload.files entries only.
295    let installed_path = vendor_dir.join("composer/installed.json");
296    let content = match std::fs::read_to_string(&installed_path) {
297        Ok(c) => c,
298        Err(_) => return Vec::new(),
299    };
300    let value: serde_json::Value = match serde_json::from_str(&content) {
301        Ok(v) => v,
302        Err(e) => {
303            eprintln!(
304                "mir: warning: failed to parse {}: {e} (autoload.files from vendor will be empty)",
305                installed_path.display()
306            );
307            return Vec::new();
308        }
309    };
310    let packages = if let Some(arr) = value.get("packages").and_then(|v| v.as_array()) {
311        arr.clone()
312    } else if let Some(arr) = value.as_array() {
313        arr.clone()
314    } else {
315        return Vec::new();
316    };
317    let mut out = Vec::new();
318    for pkg in &packages {
319        let pkg_name = pkg.get("name").and_then(|v| v.as_str()).unwrap_or("");
320        let pkg_dir = vendor_dir.join(pkg_name);
321        if let Some(files) = pkg.get("autoload").and_then(|a| a.get("files")) {
322            collect_path_array(files, &pkg_dir, &mut out);
323        }
324    }
325    let _ = base_dir; // unused in fallback path (paths are package-relative)
326    out.into_iter().filter(|p| p.is_file()).collect()
327}
328
329impl Psr4Map {
330    pub fn from_composer(root: &Path) -> Result<Self, ComposerError> {
331        let composer_path = root.join("composer.json");
332        let content = std::fs::read_to_string(&composer_path)?;
333        let value: serde_json::Value = serde_json::from_str(&content)?;
334
335        let has_autoload = value.get("autoload").is_some() || value.get("autoload-dev").is_some();
336        if !has_autoload {
337            return Err(ComposerError::MissingAutoload);
338        }
339
340        let mut project_entries: Vec<(String, PathBuf)> = Vec::new();
341        let mut project_extra_paths: Vec<PathBuf> = Vec::new();
342
343        if let Some(autoload) = value.get("autoload") {
344            parse_autoload_section(
345                autoload,
346                root,
347                &mut project_entries,
348                &mut project_extra_paths,
349            );
350        }
351        if let Some(autoload) = value.get("autoload-dev") {
352            parse_autoload_section(
353                autoload,
354                root,
355                &mut project_entries,
356                &mut project_extra_paths,
357            );
358        }
359
360        project_entries.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
361
362        let mut vendor_entries: Vec<(String, PathBuf)> = Vec::new();
363        let mut vendor_extra_paths: Vec<PathBuf> = Vec::new();
364        parse_vendor(root, &mut vendor_entries, &mut vendor_extra_paths);
365        vendor_entries.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
366
367        // Read composer-generated FQCN → file map from autoload_classmap.php.
368        // When present this is the source of truth for non-PSR-4 vendor classes,
369        // letting lazy-mode resolve them without parsing whole classmap dirs.
370        let vendor_dir = root.join("vendor");
371        let classmap = read_classmap(&vendor_dir, root);
372
373        // Eager-load list = autoload.files entries (project + vendor). These
374        // hold unbound globals (functions, constants, polyfills) that the lazy
375        // FQCN-based resolver cannot reach.
376        let vendor_eager_files = read_files_autoload(&vendor_dir, root);
377
378        Ok(Psr4Map {
379            project_entries,
380            vendor_entries,
381            project_extra_paths,
382            vendor_extra_paths,
383            classmap,
384            vendor_eager_files,
385            root: root.to_path_buf(),
386        })
387    }
388
389    pub fn project_files(&self) -> Vec<PathBuf> {
390        let mut out = Vec::new();
391        for (_, dir) in &self.project_entries {
392            crate::batch::collect_php_files(dir, &mut out);
393        }
394        for path in &self.project_extra_paths {
395            collect_php_path(path, &mut out);
396        }
397        out
398    }
399
400    pub fn vendor_files(&self) -> Vec<PathBuf> {
401        let mut out = Vec::new();
402        for (_, dir) in &self.vendor_entries {
403            crate::batch::collect_php_files(dir, &mut out);
404        }
405        for path in &self.vendor_extra_paths {
406            collect_php_path(path, &mut out);
407        }
408        out
409    }
410
411    /// Resolve a fully-qualified class name to a file path using longest-prefix-first matching.
412    /// Returns `None` if no prefix matches or the mapped file does not exist on disk.
413    ///
414    /// Resolution order:
415    /// 1. PSR-4 project entries (longest-prefix-first).
416    /// 2. PSR-4 vendor entries (longest-prefix-first).
417    /// 3. Classmap from `vendor/composer/autoload_classmap.php` — exact FQCN match.
418    ///
419    /// PSR-4 wins over classmap because Composer's runtime resolver uses the
420    /// same order; this matches what the PHP code being analyzed actually sees.
421    pub fn resolve(&self, fqcn: &str) -> Option<PathBuf> {
422        let key = fqcn.trim_start_matches('\\');
423        for (prefix, dir) in self
424            .project_entries
425            .iter()
426            .chain(self.vendor_entries.iter())
427        {
428            if key.starts_with(prefix.as_str()) {
429                let relative = &key[prefix.len()..];
430                let file_path = dir.join(relative.replace('\\', "/")).with_extension("php");
431                if file_path.exists() {
432                    return Some(file_path);
433                }
434            }
435        }
436        if let Some(path) = self.classmap.get(key) {
437            if path.exists() {
438                return Some(path.clone());
439            }
440        }
441        None
442    }
443
444    /// Vendor files that must be eagerly parsed in lazy mode: `autoload.files`
445    /// entries from composer. These hold globals (functions, constants,
446    /// polyfills) that the FQCN-based lazy resolver cannot reach because they
447    /// have no namespace mapping.
448    ///
449    /// Returns just the existing `.php` files; missing entries (stale generated
450    /// file) are dropped.
451    pub fn vendor_eager_files(&self) -> Vec<PathBuf> {
452        self.vendor_eager_files.clone()
453    }
454
455    /// Every vendor file the analyzer should index eagerly, for the
456    /// rust-analyzer-style static-input model: the union of
457    ///
458    /// 1. [`Self::vendor_files`] — PSR-4 / PSR-0 walked directories + extra paths,
459    /// 2. classmap file targets — packages that use `classmap:` autoload (no
460    ///    PSR-4 prefix), which [`Self::vendor_files`] does NOT walk, and
461    /// 3. [`Self::vendor_eager_files`] — `autoload.files` globals.
462    ///
463    /// Deduplicated by path. Non-existent classmap targets (stale generated
464    /// file) are skipped. This is the work-list a consumer feeds to the chunked
465    /// background indexer ([`crate::AnalysisSession::index_batch`]).
466    pub fn all_vendor_files(&self) -> Vec<PathBuf> {
467        let mut seen: rustc_hash::FxHashSet<PathBuf> = rustc_hash::FxHashSet::default();
468        let mut out = Vec::new();
469        let mut push = |p: PathBuf, out: &mut Vec<PathBuf>| {
470            if seen.insert(p.clone()) {
471                out.push(p);
472            }
473        };
474        for p in self.vendor_files() {
475            push(p, &mut out);
476        }
477        // Classmap-only packages are not covered by vendor_files() (which walks
478        // only PSR-4/PSR-0 dirs + extra paths). Include their file targets.
479        for p in self.classmap.values() {
480            if p.is_file() {
481                push(p.clone(), &mut out);
482            }
483        }
484        for p in self.vendor_eager_files() {
485            push(p, &mut out);
486        }
487        out
488    }
489
490    /// Number of FQCN entries known to the classmap. Used by callers that want
491    /// to log/verify the classmap loaded successfully.
492    pub fn classmap_len(&self) -> usize {
493        self.classmap.len()
494    }
495}
496
497/// Collect `.php` files from `path`. If `path` is a file, push it directly
498/// (when it has a `.php` extension); if it is a directory, walk it.
499fn collect_php_path(path: &Path, out: &mut Vec<PathBuf>) {
500    let Ok(meta) = std::fs::metadata(path) else {
501        return;
502    };
503    if meta.is_file() {
504        if path.extension().and_then(|e| e.to_str()) == Some("php") {
505            out.push(path.to_path_buf());
506        }
507    } else if meta.is_dir() {
508        crate::batch::collect_php_files(path, out);
509    }
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515    use std::fs;
516
517    fn make_temp_project(name: &str) -> PathBuf {
518        let dir = std::env::temp_dir().join(format!("mir_psr4_{name}"));
519        let _ = fs::remove_dir_all(&dir);
520        fs::create_dir_all(&dir).unwrap();
521        dir
522    }
523
524    #[test]
525    fn parse_project_entries() {
526        let root = make_temp_project("parse_project_entries");
527        fs::write(
528            root.join("composer.json"),
529            r#"{
530                "autoload": {
531                    "psr-4": { "App\\": "src/", "App\\Models\\": "src/models/" }
532                },
533                "autoload-dev": {
534                    "psr-4": { "Tests\\": "tests/" }
535                }
536            }"#,
537        )
538        .unwrap();
539
540        let map = Psr4Map::from_composer(&root).unwrap();
541
542        let prefixes: Vec<&str> = map
543            .project_entries
544            .iter()
545            .map(|(p, _)| p.as_str())
546            .collect();
547        assert!(prefixes.contains(&"App\\Models\\"), "missing App\\Models\\");
548        assert!(prefixes.contains(&"App\\"), "missing App\\");
549        assert!(prefixes.contains(&"Tests\\"), "missing Tests\\");
550    }
551
552    #[test]
553    fn longest_prefix_first() {
554        let root = make_temp_project("longest_prefix_first");
555        fs::write(
556            root.join("composer.json"),
557            r#"{
558                "autoload": {
559                    "psr-4": { "App\\": "src/", "App\\Models\\": "src/models/" }
560                }
561            }"#,
562        )
563        .unwrap();
564
565        let map = Psr4Map::from_composer(&root).unwrap();
566
567        assert_eq!(map.project_entries[0].0, "App\\Models\\");
568    }
569
570    #[test]
571    fn missing_autoload_section_is_error() {
572        let root = make_temp_project("missing_autoload");
573        fs::write(root.join("composer.json"), r#"{ "name": "my/pkg" }"#).unwrap();
574
575        let result = Psr4Map::from_composer(&root);
576        assert!(
577            matches!(result, Err(ComposerError::MissingAutoload)),
578            "expected MissingAutoload error"
579        );
580    }
581
582    #[test]
583    fn composer_v2_installed() {
584        let root = make_temp_project("composer_v2");
585        fs::write(
586            root.join("composer.json"),
587            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
588        )
589        .unwrap();
590
591        let vendor_dir = root.join("vendor/composer");
592        fs::create_dir_all(&vendor_dir).unwrap();
593        fs::write(
594            vendor_dir.join("installed.json"),
595            r#"{
596                "packages": [
597                    {
598                        "name": "vendor/pkg",
599                        "autoload": { "psr-4": { "Vendor\\Pkg\\": "src/" } }
600                    }
601                ]
602            }"#,
603        )
604        .unwrap();
605        fs::create_dir_all(root.join("vendor/vendor/pkg/src")).unwrap();
606
607        let map = Psr4Map::from_composer(&root).unwrap();
608        let prefixes: Vec<&str> = map.vendor_entries.iter().map(|(p, _)| p.as_str()).collect();
609        assert!(prefixes.contains(&"Vendor\\Pkg\\"), "missing Vendor\\Pkg\\");
610    }
611
612    #[test]
613    fn composer_v1_installed() {
614        let root = make_temp_project("composer_v1");
615        fs::write(
616            root.join("composer.json"),
617            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
618        )
619        .unwrap();
620
621        let vendor_dir = root.join("vendor/composer");
622        fs::create_dir_all(&vendor_dir).unwrap();
623        fs::write(
624            vendor_dir.join("installed.json"),
625            r#"[
626                {
627                    "name": "vendor/pkg",
628                    "autoload": { "psr-4": { "Vendor\\Pkg\\": "src/" } }
629                }
630            ]"#,
631        )
632        .unwrap();
633        fs::create_dir_all(root.join("vendor/vendor/pkg/src")).unwrap();
634
635        let map = Psr4Map::from_composer(&root).unwrap();
636        let prefixes: Vec<&str> = map.vendor_entries.iter().map(|(p, _)| p.as_str()).collect();
637        assert!(prefixes.contains(&"Vendor\\Pkg\\"), "missing Vendor\\Pkg\\");
638    }
639
640    #[test]
641    fn missing_installed_json() {
642        let root = make_temp_project("missing_installed");
643        fs::write(
644            root.join("composer.json"),
645            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
646        )
647        .unwrap();
648        let map = Psr4Map::from_composer(&root).unwrap();
649        assert!(map.vendor_entries.is_empty());
650    }
651
652    #[test]
653    fn project_files_returns_php_files() {
654        let root = make_temp_project("project_files");
655        let src = root.join("src");
656        fs::create_dir_all(&src).unwrap();
657        fs::write(src.join("Foo.php"), "<?php class Foo {}").unwrap();
658        fs::write(src.join("README.md"), "not php").unwrap();
659        fs::write(
660            root.join("composer.json"),
661            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
662        )
663        .unwrap();
664
665        let map = Psr4Map::from_composer(&root).unwrap();
666        let files = map.project_files();
667        assert_eq!(files.len(), 1);
668        assert!(files[0].ends_with("Foo.php"));
669    }
670
671    #[test]
672    fn resolve_existing_file() {
673        let root = make_temp_project("resolve_existing");
674        let models = root.join("src/models");
675        fs::create_dir_all(&models).unwrap();
676        fs::write(models.join("User.php"), "<?php class User {}").unwrap();
677        fs::write(
678            root.join("composer.json"),
679            r#"{"autoload":{"psr-4":{"App\\Models\\":"src/models/","App\\":"src/"}}}"#,
680        )
681        .unwrap();
682
683        let map = Psr4Map::from_composer(&root).unwrap();
684        let result = map.resolve("App\\Models\\User");
685        assert!(result.is_some(), "expected a resolved path");
686        assert!(result.unwrap().ends_with("User.php"));
687    }
688
689    #[test]
690    fn resolve_missing_file() {
691        let root = make_temp_project("resolve_missing");
692        fs::create_dir_all(root.join("src")).unwrap();
693        fs::write(
694            root.join("composer.json"),
695            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
696        )
697        .unwrap();
698
699        let map = Psr4Map::from_composer(&root).unwrap();
700        let result = map.resolve("App\\Models\\User");
701        assert!(result.is_none());
702    }
703
704    #[test]
705    fn boundary_check() {
706        let root = make_temp_project("boundary_check");
707        fs::create_dir_all(root.join("src")).unwrap();
708        fs::write(
709            root.join("composer.json"),
710            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
711        )
712        .unwrap();
713
714        let map = Psr4Map::from_composer(&root).unwrap();
715        // "App\" must NOT match "Application\Foo"
716        let result = map.resolve("Application\\Foo");
717        assert!(
718            result.is_none(),
719            "App\\ prefix must not match Application\\Foo"
720        );
721    }
722
723    #[test]
724    fn array_valued_psr4_dirs() {
725        let root = make_temp_project("array_dirs");
726        let src = root.join("src");
727        let lib = root.join("lib");
728        fs::create_dir_all(&src).unwrap();
729        fs::create_dir_all(&lib).unwrap();
730        fs::write(src.join("Foo.php"), "<?php class Foo {}").unwrap();
731        fs::write(lib.join("Bar.php"), "<?php class Bar {}").unwrap();
732        fs::write(
733            root.join("composer.json"),
734            r#"{"autoload":{"psr-4":{"App\\":["src/","lib/"]}}}"#,
735        )
736        .unwrap();
737
738        let map = Psr4Map::from_composer(&root).unwrap();
739        // Both dirs should be in project_entries
740        assert_eq!(
741            map.project_entries.len(),
742            2,
743            "expected 2 entries for array-valued dir"
744        );
745        let files = map.project_files();
746        assert_eq!(files.len(), 2, "expected Foo.php and Bar.php");
747    }
748
749    // -----------------------------------------------------------------------
750    // classmap / files / psr-0 — vendor and project
751    // -----------------------------------------------------------------------
752
753    #[test]
754    fn project_classmap_dir_is_collected() {
755        let root = make_temp_project("project_classmap");
756        let lib = root.join("lib");
757        fs::create_dir_all(&lib).unwrap();
758        fs::write(lib.join("Legacy.php"), "<?php class Legacy {}").unwrap();
759        fs::write(
760            root.join("composer.json"),
761            r#"{"autoload":{"classmap":["lib/"]}}"#,
762        )
763        .unwrap();
764
765        let map = Psr4Map::from_composer(&root).unwrap();
766        let files = map.project_files();
767        assert_eq!(files.len(), 1);
768        assert!(files[0].ends_with("Legacy.php"));
769    }
770
771    #[test]
772    fn project_files_autoload_is_collected() {
773        let root = make_temp_project("project_files_autoload");
774        fs::write(root.join("helpers.php"), "<?php function my_helper() {}").unwrap();
775        fs::write(
776            root.join("composer.json"),
777            r#"{"autoload":{"files":["helpers.php"]}}"#,
778        )
779        .unwrap();
780
781        let map = Psr4Map::from_composer(&root).unwrap();
782        let files = map.project_files();
783        assert_eq!(files.len(), 1);
784        assert!(files[0].ends_with("helpers.php"));
785    }
786
787    #[test]
788    fn project_psr0_dir_is_collected() {
789        let root = make_temp_project("project_psr0");
790        let lib = root.join("legacy");
791        fs::create_dir_all(&lib).unwrap();
792        fs::write(lib.join("Old.php"), "<?php class Old {}").unwrap();
793        fs::write(
794            root.join("composer.json"),
795            r#"{"autoload":{"psr-0":{"":"legacy/"}}}"#,
796        )
797        .unwrap();
798
799        let map = Psr4Map::from_composer(&root).unwrap();
800        let files = map.project_files();
801        assert_eq!(files.len(), 1);
802        assert!(files[0].ends_with("Old.php"));
803    }
804
805    #[test]
806    fn vendor_classmap_is_collected() {
807        let root = make_temp_project("vendor_classmap");
808        fs::write(
809            root.join("composer.json"),
810            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
811        )
812        .unwrap();
813        let vendor_dir = root.join("vendor/composer");
814        fs::create_dir_all(&vendor_dir).unwrap();
815        fs::write(
816            vendor_dir.join("installed.json"),
817            r#"{
818                "packages": [{
819                    "name": "vendor/pkg",
820                    "autoload": { "classmap": ["src/"] }
821                }]
822            }"#,
823        )
824        .unwrap();
825        let pkg_src = root.join("vendor/vendor/pkg/src");
826        fs::create_dir_all(&pkg_src).unwrap();
827        fs::write(pkg_src.join("Legacy.php"), "<?php class Legacy {}").unwrap();
828
829        let map = Psr4Map::from_composer(&root).unwrap();
830        let files = map.vendor_files();
831        assert_eq!(files.len(), 1);
832        assert!(files[0].ends_with("Legacy.php"));
833    }
834
835    #[test]
836    fn vendor_files_autoload_is_collected() {
837        let root = make_temp_project("vendor_files_autoload");
838        fs::write(
839            root.join("composer.json"),
840            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
841        )
842        .unwrap();
843        let vendor_dir = root.join("vendor/composer");
844        fs::create_dir_all(&vendor_dir).unwrap();
845        fs::write(
846            vendor_dir.join("installed.json"),
847            r#"{
848                "packages": [{
849                    "name": "vendor/pkg",
850                    "autoload": { "files": ["bootstrap.php"] }
851                }]
852            }"#,
853        )
854        .unwrap();
855        let pkg_dir = root.join("vendor/vendor/pkg");
856        fs::create_dir_all(&pkg_dir).unwrap();
857        fs::write(
858            pkg_dir.join("bootstrap.php"),
859            "<?php function pkg_bootstrap() {}",
860        )
861        .unwrap();
862
863        let map = Psr4Map::from_composer(&root).unwrap();
864        let files = map.vendor_files();
865        assert_eq!(files.len(), 1);
866        assert!(files[0].ends_with("bootstrap.php"));
867    }
868
869    #[test]
870    fn vendor_psr0_is_collected() {
871        let root = make_temp_project("vendor_psr0");
872        fs::write(
873            root.join("composer.json"),
874            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
875        )
876        .unwrap();
877        let vendor_dir = root.join("vendor/composer");
878        fs::create_dir_all(&vendor_dir).unwrap();
879        fs::write(
880            vendor_dir.join("installed.json"),
881            r#"{
882                "packages": [{
883                    "name": "vendor/pkg",
884                    "autoload": { "psr-0": { "Old_": "src/" } }
885                }]
886            }"#,
887        )
888        .unwrap();
889        let pkg_src = root.join("vendor/vendor/pkg/src/Old");
890        fs::create_dir_all(&pkg_src).unwrap();
891        fs::write(pkg_src.join("Thing.php"), "<?php class Old_Thing {}").unwrap();
892
893        let map = Psr4Map::from_composer(&root).unwrap();
894        let files = map.vendor_files();
895        assert_eq!(files.len(), 1);
896        assert!(files[0].ends_with("Thing.php"));
897    }
898}