Skip to main content

mir_analyzer/
composer.rs

1use rustc_hash::FxHashMap;
2use std::path::{Path, PathBuf};
3use thiserror::Error;
4
5use crate::file_extensions::PhpFileExtensions;
6use crate::include_graph::{include_closure, VendorTargets};
7
8// ---------------------------------------------------------------------------
9// Error
10// ---------------------------------------------------------------------------
11
12#[derive(Debug, Error)]
13pub enum ComposerError {
14    #[error("composer I/O error: {0}")]
15    Io(#[from] std::io::Error),
16    #[error("composer JSON error: {0}")]
17    Json(#[from] serde_json::Error),
18    #[error("composer.json has no autoload section")]
19    MissingAutoload,
20}
21
22// ---------------------------------------------------------------------------
23// Psr4Map
24// ---------------------------------------------------------------------------
25
26/// PSR-4 / PSR-0 / classmap / files autoload mapping, built from `composer.json`
27/// and `vendor/composer/installed.json`.
28///
29/// `project_entries` covers `autoload.psr-4` / `autoload-dev.psr-4` for the
30/// project itself; `project_psr0_entries` covers `autoload.psr-0` /
31/// `autoload-dev.psr-0` (kept separate because PSR-0 file-path construction
32/// differs from PSR-4 — see [`Self::resolve`]). `vendor_entries` /
33/// `vendor_psr0_entries` cover the same keys from each installed package.
34/// `project_extra_paths` and `vendor_extra_paths` collect the (prefix-less)
35/// `classmap` and `files` entries as raw paths, plus the PSR-0 dirs themselves
36/// (for bulk file-list walking) — files are kept as-is, dirs are walked when
37/// assembling the file list.
38///
39/// All prefix lists are sorted longest-prefix-first for correct prefix matching.
40#[derive(Clone)]
41pub struct Psr4Map {
42    project_entries: Vec<(String, PathBuf)>,
43    vendor_entries: Vec<(String, PathBuf)>,
44    project_psr0_entries: Vec<(String, PathBuf)>,
45    vendor_psr0_entries: Vec<(String, PathBuf)>,
46    project_extra_paths: Vec<PathBuf>,
47    vendor_extra_paths: Vec<PathBuf>,
48    /// Pre-resolved FQCN → file map from `vendor/composer/autoload_classmap.php`.
49    /// Covers packages using `classmap:` autoload (non-PSR-4) so they can be
50    /// lazy-loaded by FQCN without parsing every classmap directory eagerly.
51    /// Key is the FQCN with single backslashes (e.g. `AWS\\CRT\\Auth\\Signing`
52    /// in source becomes `AWS\CRT\Auth\Signing` here, matching what PHP code
53    /// uses at call sites).
54    classmap: FxHashMap<String, PathBuf>,
55    /// Files registered via `autoload.files` (project + vendor). These contain
56    /// unbound global functions/constants that are NOT FQCN-resolvable, so they
57    /// must be eagerly parsed even in lazy mode. Read from
58    /// `vendor/composer/autoload_files.php` if present, falling back to the
59    /// per-package `installed.json` walk.
60    vendor_eager_files: Vec<PathBuf>,
61    #[allow(dead_code)] // used by issue #50 (lazy FQCN resolution)
62    root: PathBuf,
63    extensions: PhpFileExtensions,
64}
65
66fn ensure_trailing_backslash(prefix: &str) -> String {
67    if prefix.ends_with('\\') {
68        prefix.to_string()
69    } else {
70        format!("{prefix}\\")
71    }
72}
73
74/// Append `(prefix, base.join(dir))` to `entries` for every dir-string in `value`
75/// (which may be a JSON string or an array of strings).
76fn collect_prefix_dirs(
77    value: &serde_json::Value,
78    prefix: &str,
79    base: &Path,
80    entries: &mut Vec<(String, PathBuf)>,
81) {
82    let pfx = ensure_trailing_backslash(prefix);
83    if let Some(d) = value.as_str() {
84        entries.push((pfx, base.join(d)));
85    } else if let Some(arr) = value.as_array() {
86        for item in arr {
87            if let Some(d) = item.as_str() {
88                entries.push((pfx.clone(), base.join(d)));
89            }
90        }
91    }
92}
93
94/// Same as [`collect_prefix_dirs`] but keeps the prefix exactly as written in
95/// `composer.json` (no forced trailing backslash). PSR-0 prefixes are matched
96/// as literal string prefixes by Composer — e.g. a PEAR-style `"Old_"` key has
97/// no namespace separator at all, so appending one would break prefix matching.
98fn collect_prefix_dirs_raw(
99    value: &serde_json::Value,
100    prefix: &str,
101    base: &Path,
102    entries: &mut Vec<(String, PathBuf)>,
103) {
104    if let Some(d) = value.as_str() {
105        entries.push((prefix.to_string(), base.join(d)));
106    } else if let Some(arr) = value.as_array() {
107        for item in arr {
108            if let Some(d) = item.as_str() {
109                entries.push((prefix.to_string(), base.join(d)));
110            }
111        }
112    }
113}
114
115/// Append every string in `value` (a JSON array) to `out` as `base.join(s)`.
116fn collect_path_array(value: &serde_json::Value, base: &Path, out: &mut Vec<PathBuf>) {
117    if let Some(arr) = value.as_array() {
118        for item in arr {
119            if let Some(s) = item.as_str() {
120                out.push(base.join(s));
121            }
122        }
123    }
124}
125
126fn parse_autoload_section(
127    autoload: &serde_json::Value,
128    base: &Path,
129    entries: &mut Vec<(String, PathBuf)>,
130    psr0_entries: &mut Vec<(String, PathBuf)>,
131    extras: &mut Vec<PathBuf>,
132) {
133    if let Some(map) = autoload.get("psr-4").and_then(|v| v.as_object()) {
134        for (prefix, dir) in map {
135            collect_prefix_dirs(dir, prefix, base, entries);
136        }
137    }
138    // PSR-0 maps prefix → dir similarly to PSR-4, but the class-name-to-file
139    // resolution differs (namespace separators AND trailing underscores in the
140    // class basename become directories — see `psr0_logical_path`), so these
141    // go into their own prefix list rather than `entries`. We ALSO keep
142    // pushing the raw dirs into `extras` so bulk file discovery (project/vendor
143    // file listing) still walks them.
144    if let Some(map) = autoload.get("psr-0").and_then(|v| v.as_object()) {
145        for (prefix, dir) in map {
146            collect_prefix_dirs_raw(dir, prefix, base, psr0_entries);
147            if let Some(d) = dir.as_str() {
148                extras.push(base.join(d));
149            } else if let Some(arr) = dir.as_array() {
150                for item in arr {
151                    if let Some(d) = item.as_str() {
152                        extras.push(base.join(d));
153                    }
154                }
155            }
156        }
157    }
158    if let Some(cm) = autoload.get("classmap") {
159        collect_path_array(cm, base, extras);
160    }
161    if let Some(files) = autoload.get("files") {
162        collect_path_array(files, base, extras);
163    }
164}
165
166/// Parse a Composer-generated `autoload_classmap.php` or `autoload_files.php`.
167///
168/// The format is mechanically generated and stable:
169///
170/// ```text
171/// <?php
172/// $vendorDir = dirname(__DIR__);
173/// $baseDir = dirname($vendorDir);
174/// return array(
175///     'KEY' => $vendorDir . '/relative/path.php',
176///     'OTHER' => $baseDir . '/other/path.php',
177/// );
178/// ```
179///
180/// Returns `(key, absolute_path)` pairs. Unparseable lines are silently
181/// skipped — a stale or hand-edited file degrades gracefully.
182///
183/// `key` is returned with PHP-escape-sequence handling for backslashes (`\\` → `\`).
184fn parse_composer_autoload_array(
185    content: &str,
186    vendor_dir: &Path,
187    base_dir: &Path,
188) -> Vec<(String, PathBuf)> {
189    let mut out = Vec::new();
190    for line in content.lines() {
191        let line = line.trim();
192        // Find `'KEY' => $VAR . 'PATH'` or `"KEY" => $VAR . "PATH"`.
193        let (key, rest) = match extract_quoted(line) {
194            Some(p) => p,
195            None => continue,
196        };
197        let rest = rest.trim_start();
198        let rest = match rest.strip_prefix("=>") {
199            Some(r) => r.trim_start(),
200            None => continue,
201        };
202        let (var, rest) = match rest.strip_prefix('$') {
203            Some(r) => {
204                let end = r
205                    .find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
206                    .unwrap_or(r.len());
207                (&r[..end], &r[end..])
208            }
209            None => continue,
210        };
211        let rest = rest.trim_start();
212        let rest = match rest.strip_prefix('.') {
213            Some(r) => r.trim_start(),
214            None => continue,
215        };
216        let (path_frag, _) = match extract_quoted(rest) {
217            Some(p) => p,
218            None => continue,
219        };
220        let base = match var {
221            "vendorDir" => vendor_dir,
222            "baseDir" => base_dir,
223            _ => continue,
224        };
225        // path_frag begins with '/' relative to the chosen base.
226        let path_rel = path_frag.trim_start_matches('/');
227        out.push((key, base.join(path_rel)));
228    }
229    out
230}
231
232/// Pull a PHP single- or double-quoted string from the start of `s`, decoding
233/// `\\` → `\` and `\'` / `\"` → the corresponding quote. Returns `(decoded, tail)`
234/// where `tail` is the slice after the closing quote.
235fn extract_quoted(s: &str) -> Option<(String, &str)> {
236    let mut it = s.char_indices();
237    let (_, quote) = it.next()?;
238    if quote != '\'' && quote != '"' {
239        return None;
240    }
241    let mut out = String::new();
242    let mut escape = false;
243    for (i, ch) in it {
244        if escape {
245            out.push(ch);
246            escape = false;
247        } else if ch == '\\' {
248            escape = true;
249        } else if ch == quote {
250            return Some((out, &s[i + ch.len_utf8()..]));
251        } else {
252            out.push(ch);
253        }
254    }
255    None
256}
257
258fn parse_vendor(
259    root: &Path,
260    entries: &mut Vec<(String, PathBuf)>,
261    psr0_entries: &mut Vec<(String, PathBuf)>,
262    extras: &mut Vec<PathBuf>,
263) {
264    let installed_path = root.join("vendor/composer/installed.json");
265    let content = match std::fs::read_to_string(&installed_path) {
266        Ok(c) => c,
267        Err(_) => return,
268    };
269    let value: serde_json::Value = match serde_json::from_str(&content) {
270        Ok(v) => v,
271        Err(e) => {
272            eprintln!(
273                "mir: warning: failed to parse {}: {e} (vendor PSR-4 map will be empty)",
274                installed_path.display()
275            );
276            return;
277        }
278    };
279
280    let packages = if let Some(arr) = value.get("packages").and_then(|v| v.as_array()) {
281        arr.clone()
282    } else if let Some(arr) = value.as_array() {
283        arr.clone()
284    } else {
285        return;
286    };
287
288    let vendor_dir = root.join("vendor");
289
290    for pkg in &packages {
291        let pkg_name = pkg.get("name").and_then(|v| v.as_str()).unwrap_or("");
292        let pkg_dir = vendor_dir.join(pkg_name);
293        if let Some(autoload) = pkg.get("autoload") {
294            parse_autoload_section(autoload, &pkg_dir, entries, psr0_entries, extras);
295        }
296    }
297}
298
299/// Read `vendor/composer/autoload_classmap.php`. Returns an empty map if the
300/// file is absent or unreadable — callers fall back to the project-defined
301/// PSR-4 entries which still cover the typical case.
302///
303/// Uses lossy UTF-8 decoding because some real-world classmap files contain
304/// stray Latin-1 bytes (e.g. Laravel's includes a `\xa9` key from a vendor
305/// package). Lossy decoding only affects the malformed FQCN itself — every
306/// other entry parses correctly.
307fn read_classmap(vendor_dir: &Path, base_dir: &Path) -> FxHashMap<String, PathBuf> {
308    let path = vendor_dir.join("composer/autoload_classmap.php");
309    let Ok(bytes) = std::fs::read(&path) else {
310        return FxHashMap::default();
311    };
312    let content = String::from_utf8_lossy(&bytes);
313    parse_composer_autoload_array(&content, vendor_dir, base_dir)
314        .into_iter()
315        .collect()
316}
317
318/// Read `vendor/composer/autoload_files.php`. Falls back to walking
319/// `installed.json` if the generated file is absent — covers projects that
320/// haven't run `composer dump-autoload --optimize`.
321fn read_files_autoload(vendor_dir: &Path, base_dir: &Path) -> Vec<PathBuf> {
322    let path = vendor_dir.join("composer/autoload_files.php");
323    if let Ok(bytes) = std::fs::read(&path) {
324        let content = String::from_utf8_lossy(&bytes);
325        return parse_composer_autoload_array(&content, vendor_dir, base_dir)
326            .into_iter()
327            .map(|(_, p)| p)
328            .filter(|p| p.is_file())
329            .collect();
330    }
331    // Fallback: walk installed.json packages for autoload.files entries only.
332    let installed_path = vendor_dir.join("composer/installed.json");
333    let content = match std::fs::read_to_string(&installed_path) {
334        Ok(c) => c,
335        Err(_) => return Vec::new(),
336    };
337    let value: serde_json::Value = match serde_json::from_str(&content) {
338        Ok(v) => v,
339        Err(e) => {
340            eprintln!(
341                "mir: warning: failed to parse {}: {e} (autoload.files from vendor will be empty)",
342                installed_path.display()
343            );
344            return Vec::new();
345        }
346    };
347    let packages = if let Some(arr) = value.get("packages").and_then(|v| v.as_array()) {
348        arr.clone()
349    } else if let Some(arr) = value.as_array() {
350        arr.clone()
351    } else {
352        return Vec::new();
353    };
354    let mut out = Vec::new();
355    for pkg in &packages {
356        let pkg_name = pkg.get("name").and_then(|v| v.as_str()).unwrap_or("");
357        let pkg_dir = vendor_dir.join(pkg_name);
358        if let Some(files) = pkg.get("autoload").and_then(|a| a.get("files")) {
359            collect_path_array(files, &pkg_dir, &mut out);
360        }
361    }
362    let _ = base_dir; // unused in fallback path (paths are package-relative)
363    out.into_iter().filter(|p| p.is_file()).collect()
364}
365
366/// Build a PSR-0 relative file path from a class name, per Composer's own
367/// `ClassLoader::findFileWithExtension`: namespace separators always become
368/// directory separators, but `_` only becomes a directory separator within
369/// the last (class-name) segment — a namespaced `App\Foo_Bar` stays
370/// `App/Foo_Bar.php`, while a PEAR-style `Foo_Bar` (no namespace) becomes
371/// `Foo/Bar.php`.
372fn psr0_logical_path(key: &str) -> PathBuf {
373    let logical = match key.rfind('\\') {
374        Some(pos) => {
375            let (namespace, class_name) = key.split_at(pos + 1);
376            format!(
377                "{}{}",
378                namespace.replace('\\', "/"),
379                class_name.replace('_', "/")
380            )
381        }
382        None => key.replace('_', "/"),
383    };
384    PathBuf::from(logical).with_extension("php")
385}
386
387impl Psr4Map {
388    pub fn from_composer(root: &Path) -> Result<Self, ComposerError> {
389        Self::from_composer_with_extensions(root, PhpFileExtensions::default())
390    }
391
392    /// Like [`Self::from_composer`], but file walks and include-following accept
393    /// every extension in `extensions` instead of only `.php`.
394    pub fn from_composer_with_extensions(
395        root: &Path,
396        extensions: PhpFileExtensions,
397    ) -> Result<Self, ComposerError> {
398        let composer_path = root.join("composer.json");
399        let content = std::fs::read_to_string(&composer_path)?;
400        let value: serde_json::Value = serde_json::from_str(&content)?;
401
402        let has_autoload = value.get("autoload").is_some() || value.get("autoload-dev").is_some();
403        if !has_autoload {
404            return Err(ComposerError::MissingAutoload);
405        }
406
407        let mut project_entries: Vec<(String, PathBuf)> = Vec::new();
408        let mut project_psr0_entries: Vec<(String, PathBuf)> = Vec::new();
409        let mut project_extra_paths: Vec<PathBuf> = Vec::new();
410
411        if let Some(autoload) = value.get("autoload") {
412            parse_autoload_section(
413                autoload,
414                root,
415                &mut project_entries,
416                &mut project_psr0_entries,
417                &mut project_extra_paths,
418            );
419        }
420        if let Some(autoload) = value.get("autoload-dev") {
421            parse_autoload_section(
422                autoload,
423                root,
424                &mut project_entries,
425                &mut project_psr0_entries,
426                &mut project_extra_paths,
427            );
428        }
429
430        project_entries.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
431        project_psr0_entries.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
432
433        let mut vendor_entries: Vec<(String, PathBuf)> = Vec::new();
434        let mut vendor_psr0_entries: Vec<(String, PathBuf)> = Vec::new();
435        let mut vendor_extra_paths: Vec<PathBuf> = Vec::new();
436        parse_vendor(
437            root,
438            &mut vendor_entries,
439            &mut vendor_psr0_entries,
440            &mut vendor_extra_paths,
441        );
442        vendor_entries.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
443        vendor_psr0_entries.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
444
445        // Read composer-generated FQCN → file map from autoload_classmap.php.
446        // When present this is the source of truth for non-PSR-4 vendor classes,
447        // letting lazy-mode resolve them without parsing whole classmap dirs.
448        let vendor_dir = root.join("vendor");
449        let classmap = read_classmap(&vendor_dir, root);
450
451        // Eager-load list = autoload.files entries (project + vendor). These
452        // hold unbound globals (functions, constants, polyfills) that the lazy
453        // FQCN-based resolver cannot reach.
454        let mut vendor_eager_files = read_files_autoload(&vendor_dir, root);
455        // A files-autoload bootstrap commonly just dispatches to a sibling
456        // implementation file (the version-gated polyfill idiom: `if
457        // (PHP_VERSION_ID >= 80000) { require __DIR__.'/bootstrap80.php'; }
458        // else { require __DIR__.'/bootstrap.php'; }`) — follow those the same
459        // way `project_files()` follows a manual require reaching outside every
460        // autoload root. Unlike that call site, don't skip `vendor`-rooted
461        // targets: the starting files are themselves inside `vendor`, so their
462        // sibling requires are too.
463        vendor_eager_files =
464            include_closure(vendor_eager_files, VendorTargets::Follow, &extensions);
465
466        Ok(Psr4Map {
467            project_entries,
468            vendor_entries,
469            project_psr0_entries,
470            vendor_psr0_entries,
471            project_extra_paths,
472            vendor_extra_paths,
473            classmap,
474            vendor_eager_files,
475            root: root.to_path_buf(),
476            extensions,
477        })
478    }
479
480    pub fn project_files(&self) -> Vec<PathBuf> {
481        let mut out = Vec::new();
482        for (_, dir) in &self.project_entries {
483            crate::batch::collect_php_files(dir, &mut out, &self.extensions);
484        }
485        for path in &self.project_extra_paths {
486            collect_php_path(path, &mut out, &self.extensions);
487        }
488        include_closure(out, VendorTargets::Skip, &self.extensions)
489    }
490
491    pub fn vendor_files(&self) -> Vec<PathBuf> {
492        let mut out = Vec::new();
493        for (_, dir) in &self.vendor_entries {
494            crate::batch::collect_php_files(dir, &mut out, &self.extensions);
495        }
496        for path in &self.vendor_extra_paths {
497            collect_php_path(path, &mut out, &self.extensions);
498        }
499        out
500    }
501
502    /// Resolve a fully-qualified class name to a file path using longest-prefix-first matching.
503    /// Returns `None` if no prefix matches or the mapped file does not exist on disk.
504    ///
505    /// Resolution order:
506    /// 1. PSR-4 project entries (longest-prefix-first).
507    /// 2. PSR-4 vendor entries (longest-prefix-first).
508    /// 3. PSR-0 project entries (longest-prefix-first).
509    /// 4. PSR-0 vendor entries (longest-prefix-first).
510    /// 5. Classmap from `vendor/composer/autoload_classmap.php` — exact FQCN match.
511    ///
512    /// PSR-4 wins over PSR-0 wins over classmap because Composer's runtime
513    /// resolver uses the same order; this matches what the PHP code being
514    /// analyzed actually sees.
515    pub fn resolve(&self, fqcn: &str) -> Option<PathBuf> {
516        let key = fqcn.trim_start_matches('\\');
517        for (prefix, dir) in self
518            .project_entries
519            .iter()
520            .chain(self.vendor_entries.iter())
521        {
522            if key.starts_with(prefix.as_str()) {
523                let relative = &key[prefix.len()..];
524                let file_path = dir.join(relative.replace('\\', "/")).with_extension("php");
525                if file_path.exists() {
526                    return Some(file_path);
527                }
528            }
529        }
530        for (prefix, dir) in self
531            .project_psr0_entries
532            .iter()
533            .chain(self.vendor_psr0_entries.iter())
534        {
535            if !prefix.is_empty() && !key.starts_with(prefix.as_str()) {
536                continue;
537            }
538            let file_path = dir.join(psr0_logical_path(key));
539            if file_path.exists() {
540                return Some(file_path);
541            }
542        }
543        if let Some(path) = self.classmap.get(key) {
544            if path.exists() {
545                return Some(path.clone());
546            }
547        }
548        None
549    }
550
551    /// Vendor files that must be eagerly parsed in lazy mode: `autoload.files`
552    /// entries from composer. These hold globals (functions, constants,
553    /// polyfills) that the FQCN-based lazy resolver cannot reach because they
554    /// have no namespace mapping.
555    ///
556    /// Returns just the existing `.php` files; missing entries (stale generated
557    /// file) are dropped.
558    pub fn vendor_eager_files(&self) -> Vec<PathBuf> {
559        self.vendor_eager_files.clone()
560    }
561
562    /// Every vendor file the analyzer should index eagerly: the union of
563    ///
564    /// 1. [`Self::vendor_files`] — PSR-4 / PSR-0 walked directories + extra paths,
565    /// 2. classmap file targets — packages that use `classmap:` autoload (no
566    ///    PSR-4 prefix), which [`Self::vendor_files`] does NOT walk, and
567    /// 3. [`Self::vendor_eager_files`] — `autoload.files` globals.
568    ///
569    /// Deduplicated by path. Non-existent classmap targets (stale generated
570    /// file) are skipped. This is the work-list a consumer feeds to the chunked
571    /// background indexer ([`crate::AnalysisSession::index_batch`]).
572    pub fn all_vendor_files(&self) -> Vec<PathBuf> {
573        let mut seen: rustc_hash::FxHashSet<PathBuf> = rustc_hash::FxHashSet::default();
574        let mut out = Vec::new();
575        let mut push = |p: PathBuf, out: &mut Vec<PathBuf>| {
576            if seen.insert(p.clone()) {
577                out.push(p);
578            }
579        };
580        for p in self.vendor_files() {
581            push(p, &mut out);
582        }
583        // Classmap-only packages are not covered by vendor_files() (which walks
584        // only PSR-4/PSR-0 dirs + extra paths). Include their file targets.
585        for p in self.classmap.values() {
586            if p.is_file() {
587                push(p.clone(), &mut out);
588            }
589        }
590        for p in self.vendor_eager_files() {
591            push(p, &mut out);
592        }
593        out
594    }
595
596    /// Number of FQCN entries known to the classmap. Used by callers that want
597    /// to log/verify the classmap loaded successfully.
598    pub fn classmap_len(&self) -> usize {
599        self.classmap.len()
600    }
601}
602
603/// Collect PHP files from `path`. If `path` is a file, push it directly
604/// (when its extension is in `extensions`); if it is a directory, walk it.
605fn collect_php_path(path: &Path, out: &mut Vec<PathBuf>, extensions: &PhpFileExtensions) {
606    let Ok(meta) = std::fs::metadata(path) else {
607        return;
608    };
609    if meta.is_file() {
610        if extensions.is_php_source(path) {
611            out.push(path.to_path_buf());
612        }
613    } else if meta.is_dir() {
614        crate::batch::collect_php_files(path, out, extensions);
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621    use std::fs;
622
623    fn make_temp_project(name: &str) -> PathBuf {
624        let dir = std::env::temp_dir().join(format!("mir_psr4_{name}"));
625        let _ = fs::remove_dir_all(&dir);
626        fs::create_dir_all(&dir).unwrap();
627        dir
628    }
629
630    #[test]
631    fn parse_project_entries() {
632        let root = make_temp_project("parse_project_entries");
633        fs::write(
634            root.join("composer.json"),
635            r#"{
636                "autoload": {
637                    "psr-4": { "App\\": "src/", "App\\Models\\": "src/models/" }
638                },
639                "autoload-dev": {
640                    "psr-4": { "Tests\\": "tests/" }
641                }
642            }"#,
643        )
644        .unwrap();
645
646        let map = Psr4Map::from_composer(&root).unwrap();
647
648        let prefixes: Vec<&str> = map
649            .project_entries
650            .iter()
651            .map(|(p, _)| p.as_str())
652            .collect();
653        assert!(prefixes.contains(&"App\\Models\\"), "missing App\\Models\\");
654        assert!(prefixes.contains(&"App\\"), "missing App\\");
655        assert!(prefixes.contains(&"Tests\\"), "missing Tests\\");
656    }
657
658    #[test]
659    fn longest_prefix_first() {
660        let root = make_temp_project("longest_prefix_first");
661        fs::write(
662            root.join("composer.json"),
663            r#"{
664                "autoload": {
665                    "psr-4": { "App\\": "src/", "App\\Models\\": "src/models/" }
666                }
667            }"#,
668        )
669        .unwrap();
670
671        let map = Psr4Map::from_composer(&root).unwrap();
672
673        assert_eq!(map.project_entries[0].0, "App\\Models\\");
674    }
675
676    #[test]
677    fn missing_autoload_section_is_error() {
678        let root = make_temp_project("missing_autoload");
679        fs::write(root.join("composer.json"), r#"{ "name": "my/pkg" }"#).unwrap();
680
681        let result = Psr4Map::from_composer(&root);
682        assert!(
683            matches!(result, Err(ComposerError::MissingAutoload)),
684            "expected MissingAutoload error"
685        );
686    }
687
688    #[test]
689    fn composer_v2_installed() {
690        let root = make_temp_project("composer_v2");
691        fs::write(
692            root.join("composer.json"),
693            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
694        )
695        .unwrap();
696
697        let vendor_dir = root.join("vendor/composer");
698        fs::create_dir_all(&vendor_dir).unwrap();
699        fs::write(
700            vendor_dir.join("installed.json"),
701            r#"{
702                "packages": [
703                    {
704                        "name": "vendor/pkg",
705                        "autoload": { "psr-4": { "Vendor\\Pkg\\": "src/" } }
706                    }
707                ]
708            }"#,
709        )
710        .unwrap();
711        fs::create_dir_all(root.join("vendor/vendor/pkg/src")).unwrap();
712
713        let map = Psr4Map::from_composer(&root).unwrap();
714        let prefixes: Vec<&str> = map.vendor_entries.iter().map(|(p, _)| p.as_str()).collect();
715        assert!(prefixes.contains(&"Vendor\\Pkg\\"), "missing Vendor\\Pkg\\");
716    }
717
718    #[test]
719    fn composer_v1_installed() {
720        let root = make_temp_project("composer_v1");
721        fs::write(
722            root.join("composer.json"),
723            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
724        )
725        .unwrap();
726
727        let vendor_dir = root.join("vendor/composer");
728        fs::create_dir_all(&vendor_dir).unwrap();
729        fs::write(
730            vendor_dir.join("installed.json"),
731            r#"[
732                {
733                    "name": "vendor/pkg",
734                    "autoload": { "psr-4": { "Vendor\\Pkg\\": "src/" } }
735                }
736            ]"#,
737        )
738        .unwrap();
739        fs::create_dir_all(root.join("vendor/vendor/pkg/src")).unwrap();
740
741        let map = Psr4Map::from_composer(&root).unwrap();
742        let prefixes: Vec<&str> = map.vendor_entries.iter().map(|(p, _)| p.as_str()).collect();
743        assert!(prefixes.contains(&"Vendor\\Pkg\\"), "missing Vendor\\Pkg\\");
744    }
745
746    #[test]
747    fn missing_installed_json() {
748        let root = make_temp_project("missing_installed");
749        fs::write(
750            root.join("composer.json"),
751            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
752        )
753        .unwrap();
754        let map = Psr4Map::from_composer(&root).unwrap();
755        assert!(map.vendor_entries.is_empty());
756    }
757
758    #[test]
759    fn project_files_returns_php_files() {
760        let root = make_temp_project("project_files");
761        let src = root.join("src");
762        fs::create_dir_all(&src).unwrap();
763        fs::write(src.join("Foo.php"), "<?php class Foo {}").unwrap();
764        fs::write(src.join("README.md"), "not php").unwrap();
765        fs::write(
766            root.join("composer.json"),
767            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
768        )
769        .unwrap();
770
771        let map = Psr4Map::from_composer(&root).unwrap();
772        let files = map.project_files();
773        assert_eq!(files.len(), 1);
774        assert!(files[0].ends_with("Foo.php"));
775    }
776
777    // -----------------------------------------------------------------------
778    // project_files() — require/include reaching outside every autoload root
779    // (Sector C7)
780    // -----------------------------------------------------------------------
781
782    #[test]
783    fn project_files_follows_dir_relative_require() {
784        let root = make_temp_project("require_dir_relative");
785        let src = root.join("src");
786        let legacy = root.join("legacy");
787        fs::create_dir_all(&src).unwrap();
788        fs::create_dir_all(&legacy).unwrap();
789        fs::write(
790            src.join("Bootstrap.php"),
791            "<?php require_once __DIR__ . '/../legacy/bootstrap.php'; class Bootstrap {}",
792        )
793        .unwrap();
794        fs::write(
795            legacy.join("bootstrap.php"),
796            "<?php function legacy_init() {}",
797        )
798        .unwrap();
799        fs::write(
800            root.join("composer.json"),
801            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
802        )
803        .unwrap();
804
805        let map = Psr4Map::from_composer(&root).unwrap();
806        let files = map.project_files();
807        assert_eq!(files.len(), 2, "expected Bootstrap.php + bootstrap.php");
808        assert!(files.iter().any(|f| f.ends_with("bootstrap.php")));
809    }
810
811    #[test]
812    fn project_files_follows_dirname_file_require() {
813        let root = make_temp_project("require_dirname_file");
814        let src = root.join("src");
815        let legacy = root.join("legacy");
816        fs::create_dir_all(&src).unwrap();
817        fs::create_dir_all(&legacy).unwrap();
818        fs::write(
819            src.join("Bootstrap.php"),
820            "<?php require_once dirname(__FILE__) . '/../legacy/bootstrap.php'; class Bootstrap {}",
821        )
822        .unwrap();
823        fs::write(
824            legacy.join("bootstrap.php"),
825            "<?php function legacy_init() {}",
826        )
827        .unwrap();
828        fs::write(
829            root.join("composer.json"),
830            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
831        )
832        .unwrap();
833
834        let map = Psr4Map::from_composer(&root).unwrap();
835        let files = map.project_files();
836        assert!(files.iter().any(|f| f.ends_with("bootstrap.php")));
837    }
838
839    #[test]
840    fn project_files_follows_bare_literal_require_relative_to_including_file() {
841        let root = make_temp_project("require_bare_literal");
842        let src = root.join("src");
843        let legacy = root.join("legacy");
844        fs::create_dir_all(&src).unwrap();
845        fs::create_dir_all(&legacy).unwrap();
846        fs::write(
847            src.join("Bootstrap.php"),
848            "<?php require_once '../legacy/bootstrap.php'; class Bootstrap {}",
849        )
850        .unwrap();
851        fs::write(
852            legacy.join("bootstrap.php"),
853            "<?php function legacy_init() {}",
854        )
855        .unwrap();
856        fs::write(
857            root.join("composer.json"),
858            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
859        )
860        .unwrap();
861
862        let map = Psr4Map::from_composer(&root).unwrap();
863        let files = map.project_files();
864        assert!(files.iter().any(|f| f.ends_with("bootstrap.php")));
865    }
866
867    #[test]
868    fn project_files_follows_require_transitively() {
869        let root = make_temp_project("require_transitive");
870        let src = root.join("src");
871        let legacy = root.join("legacy");
872        fs::create_dir_all(&src).unwrap();
873        fs::create_dir_all(&legacy).unwrap();
874        fs::write(
875            src.join("Bootstrap.php"),
876            "<?php require_once __DIR__ . '/../legacy/a.php';",
877        )
878        .unwrap();
879        fs::write(
880            legacy.join("a.php"),
881            "<?php require_once __DIR__ . '/b.php';",
882        )
883        .unwrap();
884        fs::write(legacy.join("b.php"), "<?php function b() {}").unwrap();
885        fs::write(
886            root.join("composer.json"),
887            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
888        )
889        .unwrap();
890
891        let map = Psr4Map::from_composer(&root).unwrap();
892        let files = map.project_files();
893        assert!(files.iter().any(|f| f.ends_with("a.php")));
894        assert!(files.iter().any(|f| f.ends_with("b.php")));
895    }
896
897    #[test]
898    fn project_files_require_cycle_does_not_hang() {
899        let root = make_temp_project("require_cycle");
900        let src = root.join("src");
901        let legacy = root.join("legacy");
902        fs::create_dir_all(&src).unwrap();
903        fs::create_dir_all(&legacy).unwrap();
904        fs::write(
905            src.join("Bootstrap.php"),
906            "<?php require_once __DIR__ . '/../legacy/a.php';",
907        )
908        .unwrap();
909        fs::write(
910            legacy.join("a.php"),
911            "<?php require_once __DIR__ . '/b.php';",
912        )
913        .unwrap();
914        fs::write(
915            legacy.join("b.php"),
916            "<?php require_once __DIR__ . '/a.php';",
917        )
918        .unwrap();
919        fs::write(
920            root.join("composer.json"),
921            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
922        )
923        .unwrap();
924
925        let map = Psr4Map::from_composer(&root).unwrap();
926        let files = map.project_files();
927        assert_eq!(
928            files.len(),
929            3,
930            "each file discovered exactly once despite the cycle"
931        );
932    }
933
934    #[test]
935    fn project_files_missing_require_target_is_skipped() {
936        let root = make_temp_project("require_missing_target");
937        let src = root.join("src");
938        fs::create_dir_all(&src).unwrap();
939        fs::write(
940            src.join("Bootstrap.php"),
941            "<?php require_once __DIR__ . '/../legacy/does_not_exist.php';",
942        )
943        .unwrap();
944        fs::write(
945            root.join("composer.json"),
946            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
947        )
948        .unwrap();
949
950        let map = Psr4Map::from_composer(&root).unwrap();
951        let files = map.project_files();
952        assert_eq!(files.len(), 1, "only Bootstrap.php itself");
953    }
954
955    #[test]
956    fn project_files_dynamic_require_target_not_followed() {
957        let root = make_temp_project("require_dynamic_target");
958        let src = root.join("src");
959        let legacy = root.join("legacy");
960        fs::create_dir_all(&src).unwrap();
961        fs::create_dir_all(&legacy).unwrap();
962        fs::write(
963            src.join("Bootstrap.php"),
964            "<?php $path = getPath(); require_once $path;",
965        )
966        .unwrap();
967        fs::write(
968            legacy.join("bootstrap.php"),
969            "<?php function legacy_init() {}",
970        )
971        .unwrap();
972        fs::write(
973            root.join("composer.json"),
974            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
975        )
976        .unwrap();
977
978        let map = Psr4Map::from_composer(&root).unwrap();
979        let files = map.project_files();
980        assert_eq!(
981            files.len(),
982            1,
983            "a non-statically-resolvable require target must not be followed"
984        );
985    }
986
987    #[test]
988    fn project_files_require_into_vendor_is_skipped() {
989        let root = make_temp_project("require_into_vendor");
990        let src = root.join("src");
991        let vendor_pkg = root.join("vendor/some/pkg");
992        fs::create_dir_all(&src).unwrap();
993        fs::create_dir_all(&vendor_pkg).unwrap();
994        fs::write(
995            src.join("Bootstrap.php"),
996            "<?php require_once __DIR__ . '/../vendor/some/pkg/lib.php';",
997        )
998        .unwrap();
999        fs::write(vendor_pkg.join("lib.php"), "<?php function lib() {}").unwrap();
1000        fs::write(
1001            root.join("composer.json"),
1002            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1003        )
1004        .unwrap();
1005
1006        let map = Psr4Map::from_composer(&root).unwrap();
1007        let files = map.project_files();
1008        assert_eq!(
1009            files.len(),
1010            1,
1011            "vendor-reaching require must not be indexed as a project file"
1012        );
1013    }
1014
1015    #[test]
1016    fn resolve_existing_file() {
1017        let root = make_temp_project("resolve_existing");
1018        let models = root.join("src/models");
1019        fs::create_dir_all(&models).unwrap();
1020        fs::write(models.join("User.php"), "<?php class User {}").unwrap();
1021        fs::write(
1022            root.join("composer.json"),
1023            r#"{"autoload":{"psr-4":{"App\\Models\\":"src/models/","App\\":"src/"}}}"#,
1024        )
1025        .unwrap();
1026
1027        let map = Psr4Map::from_composer(&root).unwrap();
1028        let result = map.resolve("App\\Models\\User");
1029        assert!(result.is_some(), "expected a resolved path");
1030        assert!(result.unwrap().ends_with("User.php"));
1031    }
1032
1033    #[test]
1034    fn resolves_same_short_name_from_its_exact_namespace() {
1035        // Keep this at the resolver boundary as well as the analyzer fixture:
1036        // php-lsp#253 was only observable when a completion receiver's
1037        // fully-qualified MIR type (`Zeta\\Widget`) shared `Widget` with a
1038        // differently-mapped namespace (`Alpha\\Widget`). A resolver must never
1039        // turn that exact FQCN into the other namespace's file.
1040        let root = make_temp_project("same_short_name_exact_namespace");
1041        let target = root.join("vendor/pkg/target/src");
1042        let decoy = root.join("vendor/pkg/decoy/src");
1043        fs::create_dir_all(&target).unwrap();
1044        fs::create_dir_all(&decoy).unwrap();
1045        fs::write(
1046            target.join("Widget.php"),
1047            "<?php namespace Zeta; class Widget {}",
1048        )
1049        .unwrap();
1050        fs::write(
1051            decoy.join("Widget.php"),
1052            "<?php namespace Alpha; class Widget {}",
1053        )
1054        .unwrap();
1055        fs::write(
1056            root.join("composer.json"),
1057            r#"{"autoload":{"psr-4":{"Alpha\\":"vendor/pkg/decoy/src/","Zeta\\":"vendor/pkg/target/src/"}}}"#,
1058        )
1059        .unwrap();
1060
1061        let map = Psr4Map::from_composer(&root).unwrap();
1062        assert_eq!(
1063            map.resolve("\\Zeta\\Widget"),
1064            Some(target.join("Widget.php"))
1065        );
1066        assert_eq!(map.resolve("Alpha\\Widget"), Some(decoy.join("Widget.php")));
1067    }
1068
1069    #[test]
1070    fn resolve_missing_file() {
1071        let root = make_temp_project("resolve_missing");
1072        fs::create_dir_all(root.join("src")).unwrap();
1073        fs::write(
1074            root.join("composer.json"),
1075            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1076        )
1077        .unwrap();
1078
1079        let map = Psr4Map::from_composer(&root).unwrap();
1080        let result = map.resolve("App\\Models\\User");
1081        assert!(result.is_none());
1082    }
1083
1084    #[test]
1085    fn boundary_check() {
1086        let root = make_temp_project("boundary_check");
1087        fs::create_dir_all(root.join("src")).unwrap();
1088        fs::write(
1089            root.join("composer.json"),
1090            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1091        )
1092        .unwrap();
1093
1094        let map = Psr4Map::from_composer(&root).unwrap();
1095        // "App\" must NOT match "Application\Foo"
1096        let result = map.resolve("Application\\Foo");
1097        assert!(
1098            result.is_none(),
1099            "App\\ prefix must not match Application\\Foo"
1100        );
1101    }
1102
1103    #[test]
1104    fn array_valued_psr4_dirs() {
1105        let root = make_temp_project("array_dirs");
1106        let src = root.join("src");
1107        let lib = root.join("lib");
1108        fs::create_dir_all(&src).unwrap();
1109        fs::create_dir_all(&lib).unwrap();
1110        fs::write(src.join("Foo.php"), "<?php class Foo {}").unwrap();
1111        fs::write(lib.join("Bar.php"), "<?php class Bar {}").unwrap();
1112        fs::write(
1113            root.join("composer.json"),
1114            r#"{"autoload":{"psr-4":{"App\\":["src/","lib/"]}}}"#,
1115        )
1116        .unwrap();
1117
1118        let map = Psr4Map::from_composer(&root).unwrap();
1119        // Both dirs should be in project_entries
1120        assert_eq!(
1121            map.project_entries.len(),
1122            2,
1123            "expected 2 entries for array-valued dir"
1124        );
1125        let files = map.project_files();
1126        assert_eq!(files.len(), 2, "expected Foo.php and Bar.php");
1127    }
1128
1129    // -----------------------------------------------------------------------
1130    // classmap / files / psr-0 — vendor and project
1131    // -----------------------------------------------------------------------
1132
1133    #[test]
1134    fn project_classmap_dir_is_collected() {
1135        let root = make_temp_project("project_classmap");
1136        let lib = root.join("lib");
1137        fs::create_dir_all(&lib).unwrap();
1138        fs::write(lib.join("Legacy.php"), "<?php class Legacy {}").unwrap();
1139        fs::write(
1140            root.join("composer.json"),
1141            r#"{"autoload":{"classmap":["lib/"]}}"#,
1142        )
1143        .unwrap();
1144
1145        let map = Psr4Map::from_composer(&root).unwrap();
1146        let files = map.project_files();
1147        assert_eq!(files.len(), 1);
1148        assert!(files[0].ends_with("Legacy.php"));
1149    }
1150
1151    #[test]
1152    fn project_files_autoload_is_collected() {
1153        let root = make_temp_project("project_files_autoload");
1154        fs::write(root.join("helpers.php"), "<?php function my_helper() {}").unwrap();
1155        fs::write(
1156            root.join("composer.json"),
1157            r#"{"autoload":{"files":["helpers.php"]}}"#,
1158        )
1159        .unwrap();
1160
1161        let map = Psr4Map::from_composer(&root).unwrap();
1162        let files = map.project_files();
1163        assert_eq!(files.len(), 1);
1164        assert!(files[0].ends_with("helpers.php"));
1165    }
1166
1167    #[test]
1168    fn project_psr0_dir_is_collected() {
1169        let root = make_temp_project("project_psr0");
1170        let lib = root.join("legacy");
1171        fs::create_dir_all(&lib).unwrap();
1172        fs::write(lib.join("Old.php"), "<?php class Old {}").unwrap();
1173        fs::write(
1174            root.join("composer.json"),
1175            r#"{"autoload":{"psr-0":{"":"legacy/"}}}"#,
1176        )
1177        .unwrap();
1178
1179        let map = Psr4Map::from_composer(&root).unwrap();
1180        let files = map.project_files();
1181        assert_eq!(files.len(), 1);
1182        assert!(files[0].ends_with("Old.php"));
1183    }
1184
1185    #[test]
1186    fn vendor_classmap_is_collected() {
1187        let root = make_temp_project("vendor_classmap");
1188        fs::write(
1189            root.join("composer.json"),
1190            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1191        )
1192        .unwrap();
1193        let vendor_dir = root.join("vendor/composer");
1194        fs::create_dir_all(&vendor_dir).unwrap();
1195        fs::write(
1196            vendor_dir.join("installed.json"),
1197            r#"{
1198                "packages": [{
1199                    "name": "vendor/pkg",
1200                    "autoload": { "classmap": ["src/"] }
1201                }]
1202            }"#,
1203        )
1204        .unwrap();
1205        let pkg_src = root.join("vendor/vendor/pkg/src");
1206        fs::create_dir_all(&pkg_src).unwrap();
1207        fs::write(pkg_src.join("Legacy.php"), "<?php class Legacy {}").unwrap();
1208
1209        let map = Psr4Map::from_composer(&root).unwrap();
1210        let files = map.vendor_files();
1211        assert_eq!(files.len(), 1);
1212        assert!(files[0].ends_with("Legacy.php"));
1213    }
1214
1215    #[test]
1216    fn vendor_files_autoload_is_collected() {
1217        let root = make_temp_project("vendor_files_autoload");
1218        fs::write(
1219            root.join("composer.json"),
1220            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1221        )
1222        .unwrap();
1223        let vendor_dir = root.join("vendor/composer");
1224        fs::create_dir_all(&vendor_dir).unwrap();
1225        fs::write(
1226            vendor_dir.join("installed.json"),
1227            r#"{
1228                "packages": [{
1229                    "name": "vendor/pkg",
1230                    "autoload": { "files": ["bootstrap.php"] }
1231                }]
1232            }"#,
1233        )
1234        .unwrap();
1235        let pkg_dir = root.join("vendor/vendor/pkg");
1236        fs::create_dir_all(&pkg_dir).unwrap();
1237        fs::write(
1238            pkg_dir.join("bootstrap.php"),
1239            "<?php function pkg_bootstrap() {}",
1240        )
1241        .unwrap();
1242
1243        let map = Psr4Map::from_composer(&root).unwrap();
1244        let files = map.vendor_files();
1245        assert_eq!(files.len(), 1);
1246        assert!(files[0].ends_with("bootstrap.php"));
1247    }
1248
1249    /// A files-autoload bootstrap that only dispatches to a version-gated
1250    /// sibling implementation (the common polyfill idiom) must have that
1251    /// sibling followed into `vendor_eager_files()` too, or the functions it
1252    /// declares are invisible to the lazy-mode analyzer.
1253    #[test]
1254    fn vendor_eager_files_follows_polyfill_bootstrap_require() {
1255        let root = make_temp_project("vendor_eager_files_polyfill");
1256        fs::write(
1257            root.join("composer.json"),
1258            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1259        )
1260        .unwrap();
1261        let vendor_dir = root.join("vendor/composer");
1262        fs::create_dir_all(&vendor_dir).unwrap();
1263        fs::write(
1264            vendor_dir.join("installed.json"),
1265            r#"{
1266                "packages": [{
1267                    "name": "vendor/polyfill",
1268                    "autoload": { "files": ["bootstrap.php"] }
1269                }]
1270            }"#,
1271        )
1272        .unwrap();
1273        let pkg_dir = root.join("vendor/vendor/polyfill");
1274        fs::create_dir_all(&pkg_dir).unwrap();
1275        fs::write(
1276            pkg_dir.join("bootstrap.php"),
1277            "<?php if (PHP_VERSION_ID >= 80000) { require_once __DIR__ . '/bootstrap80.php'; } \
1278             else { require_once __DIR__ . '/bootstrap_php7.php'; }",
1279        )
1280        .unwrap();
1281        fs::write(
1282            pkg_dir.join("bootstrap80.php"),
1283            "<?php function polyfill_mb_str_split() {}",
1284        )
1285        .unwrap();
1286        fs::write(
1287            pkg_dir.join("bootstrap_php7.php"),
1288            "<?php function polyfill_mb_str_split_legacy() {}",
1289        )
1290        .unwrap();
1291
1292        let map = Psr4Map::from_composer(&root).unwrap();
1293        let files = map.vendor_eager_files();
1294        assert!(
1295            files.iter().any(|p| p.ends_with("bootstrap.php")),
1296            "expected bootstrap.php in {files:?}"
1297        );
1298        assert!(
1299            files.iter().any(|p| p.ends_with("bootstrap80.php")),
1300            "expected the require_once-d sibling bootstrap80.php in {files:?}"
1301        );
1302        assert!(
1303            files.iter().any(|p| p.ends_with("bootstrap_php7.php")),
1304            "expected the require_once-d sibling bootstrap_php7.php in {files:?}"
1305        );
1306    }
1307
1308    #[test]
1309    fn resolve_psr0_project_namespaced_class() {
1310        let root = make_temp_project("resolve_psr0_project_namespaced");
1311        let mailer_dir = root.join("src/Mailer");
1312        fs::create_dir_all(&mailer_dir).unwrap();
1313        fs::write(
1314            mailer_dir.join("Message.php"),
1315            "<?php namespace Mailer; class Message {}",
1316        )
1317        .unwrap();
1318        fs::write(
1319            root.join("composer.json"),
1320            r#"{"autoload":{"psr-0":{"Mailer\\":"src/"}}}"#,
1321        )
1322        .unwrap();
1323
1324        let map = Psr4Map::from_composer(&root).unwrap();
1325        let result = map.resolve("Mailer\\Message");
1326        assert!(result.is_some(), "expected a resolved PSR-0 path");
1327        assert!(result.unwrap().ends_with("Mailer/Message.php"));
1328    }
1329
1330    #[test]
1331    fn resolve_psr0_pear_style_class_with_underscores() {
1332        let root = make_temp_project("resolve_psr0_pear_style");
1333        let old_dir = root.join("src/Old");
1334        fs::create_dir_all(&old_dir).unwrap();
1335        fs::write(old_dir.join("Thing.php"), "<?php class Old_Thing {}").unwrap();
1336        fs::write(
1337            root.join("composer.json"),
1338            r#"{"autoload":{"psr-0":{"Old_":"src/"}}}"#,
1339        )
1340        .unwrap();
1341
1342        let map = Psr4Map::from_composer(&root).unwrap();
1343        let result = map.resolve("Old_Thing");
1344        assert!(result.is_some(), "expected a resolved PSR-0 path");
1345        assert!(result.unwrap().ends_with("Old/Thing.php"));
1346    }
1347
1348    #[test]
1349    fn resolve_psr0_vendor_class() {
1350        let root = make_temp_project("resolve_psr0_vendor");
1351        fs::write(
1352            root.join("composer.json"),
1353            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1354        )
1355        .unwrap();
1356        let vendor_dir = root.join("vendor/composer");
1357        fs::create_dir_all(&vendor_dir).unwrap();
1358        fs::write(
1359            vendor_dir.join("installed.json"),
1360            r#"{
1361                "packages": [{
1362                    "name": "vendor/pkg",
1363                    "autoload": { "psr-0": { "Old_": "src/" } }
1364                }]
1365            }"#,
1366        )
1367        .unwrap();
1368        let pkg_src = root.join("vendor/vendor/pkg/src/Old");
1369        fs::create_dir_all(&pkg_src).unwrap();
1370        fs::write(pkg_src.join("Thing.php"), "<?php class Old_Thing {}").unwrap();
1371
1372        let map = Psr4Map::from_composer(&root).unwrap();
1373        let result = map.resolve("Old_Thing");
1374        assert!(result.is_some(), "expected a resolved PSR-0 vendor path");
1375        assert!(result.unwrap().ends_with("Old/Thing.php"));
1376    }
1377
1378    #[test]
1379    fn resolve_psr0_prefix_must_still_match() {
1380        let root = make_temp_project("resolve_psr0_prefix_mismatch");
1381        fs::create_dir_all(root.join("src")).unwrap();
1382        fs::write(
1383            root.join("composer.json"),
1384            r#"{"autoload":{"psr-0":{"Mailer\\":"src/"}}}"#,
1385        )
1386        .unwrap();
1387
1388        let map = Psr4Map::from_composer(&root).unwrap();
1389        let result = map.resolve("Other\\Message");
1390        assert!(
1391            result.is_none(),
1392            "Mailer\\ psr-0 prefix must not match Other\\Message"
1393        );
1394    }
1395
1396    #[test]
1397    fn vendor_psr0_is_collected() {
1398        let root = make_temp_project("vendor_psr0");
1399        fs::write(
1400            root.join("composer.json"),
1401            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1402        )
1403        .unwrap();
1404        let vendor_dir = root.join("vendor/composer");
1405        fs::create_dir_all(&vendor_dir).unwrap();
1406        fs::write(
1407            vendor_dir.join("installed.json"),
1408            r#"{
1409                "packages": [{
1410                    "name": "vendor/pkg",
1411                    "autoload": { "psr-0": { "Old_": "src/" } }
1412                }]
1413            }"#,
1414        )
1415        .unwrap();
1416        let pkg_src = root.join("vendor/vendor/pkg/src/Old");
1417        fs::create_dir_all(&pkg_src).unwrap();
1418        fs::write(pkg_src.join("Thing.php"), "<?php class Old_Thing {}").unwrap();
1419
1420        let map = Psr4Map::from_composer(&root).unwrap();
1421        let files = map.vendor_files();
1422        assert_eq!(files.len(), 1);
1423        assert!(files[0].ends_with("Thing.php"));
1424    }
1425}