Skip to main content

mir_analyzer/
composer.rs

1use php_ast::ast::{BinaryOp, MagicConstKind};
2use php_ast::owned::visitor::{walk_owned_expr, OwnedVisitor};
3use php_ast::owned::{Expr, ExprKind};
4use rustc_hash::FxHashMap;
5use std::ops::ControlFlow;
6use std::path::{Component, Path, PathBuf};
7use thiserror::Error;
8
9// ---------------------------------------------------------------------------
10// Error
11// ---------------------------------------------------------------------------
12
13#[derive(Debug, Error)]
14pub enum ComposerError {
15    #[error("composer I/O error: {0}")]
16    Io(#[from] std::io::Error),
17    #[error("composer JSON error: {0}")]
18    Json(#[from] serde_json::Error),
19    #[error("composer.json has no autoload section")]
20    MissingAutoload,
21}
22
23// ---------------------------------------------------------------------------
24// Psr4Map
25// ---------------------------------------------------------------------------
26
27/// PSR-4 / PSR-0 / classmap / files autoload mapping, built from `composer.json`
28/// and `vendor/composer/installed.json`.
29///
30/// `project_entries` covers `autoload.psr-4` / `autoload-dev.psr-4` for the
31/// project itself; `project_psr0_entries` covers `autoload.psr-0` /
32/// `autoload-dev.psr-0` (kept separate because PSR-0 file-path construction
33/// differs from PSR-4 — see [`Self::resolve`]). `vendor_entries` /
34/// `vendor_psr0_entries` cover the same keys from each installed package.
35/// `project_extra_paths` and `vendor_extra_paths` collect the (prefix-less)
36/// `classmap` and `files` entries as raw paths, plus the PSR-0 dirs themselves
37/// (for bulk file-list walking) — files are kept as-is, dirs are walked when
38/// assembling the file list.
39///
40/// All prefix lists are sorted longest-prefix-first for correct prefix matching.
41#[derive(Clone)]
42pub struct Psr4Map {
43    project_entries: Vec<(String, PathBuf)>,
44    vendor_entries: Vec<(String, PathBuf)>,
45    project_psr0_entries: Vec<(String, PathBuf)>,
46    vendor_psr0_entries: Vec<(String, PathBuf)>,
47    project_extra_paths: Vec<PathBuf>,
48    vendor_extra_paths: Vec<PathBuf>,
49    /// Pre-resolved FQCN → file map from `vendor/composer/autoload_classmap.php`.
50    /// Covers packages using `classmap:` autoload (non-PSR-4) so they can be
51    /// lazy-loaded by FQCN without parsing every classmap directory eagerly.
52    /// Key is the FQCN with single backslashes (e.g. `AWS\\CRT\\Auth\\Signing`
53    /// in source becomes `AWS\CRT\Auth\Signing` here, matching what PHP code
54    /// uses at call sites).
55    classmap: FxHashMap<String, PathBuf>,
56    /// Files registered via `autoload.files` (project + vendor). These contain
57    /// unbound global functions/constants that are NOT FQCN-resolvable, so they
58    /// must be eagerly parsed even in lazy mode. Read from
59    /// `vendor/composer/autoload_files.php` if present, falling back to the
60    /// per-package `installed.json` walk.
61    vendor_eager_files: Vec<PathBuf>,
62    #[allow(dead_code)] // used by issue #50 (lazy FQCN resolution)
63    root: PathBuf,
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, same as before.
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        let composer_path = root.join("composer.json");
390        let content = std::fs::read_to_string(&composer_path)?;
391        let value: serde_json::Value = serde_json::from_str(&content)?;
392
393        let has_autoload = value.get("autoload").is_some() || value.get("autoload-dev").is_some();
394        if !has_autoload {
395            return Err(ComposerError::MissingAutoload);
396        }
397
398        let mut project_entries: Vec<(String, PathBuf)> = Vec::new();
399        let mut project_psr0_entries: Vec<(String, PathBuf)> = Vec::new();
400        let mut project_extra_paths: Vec<PathBuf> = Vec::new();
401
402        if let Some(autoload) = value.get("autoload") {
403            parse_autoload_section(
404                autoload,
405                root,
406                &mut project_entries,
407                &mut project_psr0_entries,
408                &mut project_extra_paths,
409            );
410        }
411        if let Some(autoload) = value.get("autoload-dev") {
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
421        project_entries.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
422        project_psr0_entries.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
423
424        let mut vendor_entries: Vec<(String, PathBuf)> = Vec::new();
425        let mut vendor_psr0_entries: Vec<(String, PathBuf)> = Vec::new();
426        let mut vendor_extra_paths: Vec<PathBuf> = Vec::new();
427        parse_vendor(
428            root,
429            &mut vendor_entries,
430            &mut vendor_psr0_entries,
431            &mut vendor_extra_paths,
432        );
433        vendor_entries.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
434        vendor_psr0_entries.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
435
436        // Read composer-generated FQCN → file map from autoload_classmap.php.
437        // When present this is the source of truth for non-PSR-4 vendor classes,
438        // letting lazy-mode resolve them without parsing whole classmap dirs.
439        let vendor_dir = root.join("vendor");
440        let classmap = read_classmap(&vendor_dir, root);
441
442        // Eager-load list = autoload.files entries (project + vendor). These
443        // hold unbound globals (functions, constants, polyfills) that the lazy
444        // FQCN-based resolver cannot reach.
445        let vendor_eager_files = read_files_autoload(&vendor_dir, root);
446
447        Ok(Psr4Map {
448            project_entries,
449            vendor_entries,
450            project_psr0_entries,
451            vendor_psr0_entries,
452            project_extra_paths,
453            vendor_extra_paths,
454            classmap,
455            vendor_eager_files,
456            root: root.to_path_buf(),
457        })
458    }
459
460    pub fn project_files(&self) -> Vec<PathBuf> {
461        let mut out = Vec::new();
462        for (_, dir) in &self.project_entries {
463            crate::batch::collect_php_files(dir, &mut out);
464        }
465        for path in &self.project_extra_paths {
466            collect_php_path(path, &mut out);
467        }
468        expand_via_local_requires(&mut out);
469        out
470    }
471
472    pub fn vendor_files(&self) -> Vec<PathBuf> {
473        let mut out = Vec::new();
474        for (_, dir) in &self.vendor_entries {
475            crate::batch::collect_php_files(dir, &mut out);
476        }
477        for path in &self.vendor_extra_paths {
478            collect_php_path(path, &mut out);
479        }
480        out
481    }
482
483    /// Resolve a fully-qualified class name to a file path using longest-prefix-first matching.
484    /// Returns `None` if no prefix matches or the mapped file does not exist on disk.
485    ///
486    /// Resolution order:
487    /// 1. PSR-4 project entries (longest-prefix-first).
488    /// 2. PSR-4 vendor entries (longest-prefix-first).
489    /// 3. PSR-0 project entries (longest-prefix-first).
490    /// 4. PSR-0 vendor entries (longest-prefix-first).
491    /// 5. Classmap from `vendor/composer/autoload_classmap.php` — exact FQCN match.
492    ///
493    /// PSR-4 wins over PSR-0 wins over classmap because Composer's runtime
494    /// resolver uses the same order; this matches what the PHP code being
495    /// analyzed actually sees.
496    pub fn resolve(&self, fqcn: &str) -> Option<PathBuf> {
497        let key = fqcn.trim_start_matches('\\');
498        for (prefix, dir) in self
499            .project_entries
500            .iter()
501            .chain(self.vendor_entries.iter())
502        {
503            if key.starts_with(prefix.as_str()) {
504                let relative = &key[prefix.len()..];
505                let file_path = dir.join(relative.replace('\\', "/")).with_extension("php");
506                if file_path.exists() {
507                    return Some(file_path);
508                }
509            }
510        }
511        for (prefix, dir) in self
512            .project_psr0_entries
513            .iter()
514            .chain(self.vendor_psr0_entries.iter())
515        {
516            if !prefix.is_empty() && !key.starts_with(prefix.as_str()) {
517                continue;
518            }
519            let file_path = dir.join(psr0_logical_path(key));
520            if file_path.exists() {
521                return Some(file_path);
522            }
523        }
524        if let Some(path) = self.classmap.get(key) {
525            if path.exists() {
526                return Some(path.clone());
527            }
528        }
529        None
530    }
531
532    /// Vendor files that must be eagerly parsed in lazy mode: `autoload.files`
533    /// entries from composer. These hold globals (functions, constants,
534    /// polyfills) that the FQCN-based lazy resolver cannot reach because they
535    /// have no namespace mapping.
536    ///
537    /// Returns just the existing `.php` files; missing entries (stale generated
538    /// file) are dropped.
539    pub fn vendor_eager_files(&self) -> Vec<PathBuf> {
540        self.vendor_eager_files.clone()
541    }
542
543    /// Every vendor file the analyzer should index eagerly, for the
544    /// rust-analyzer-style static-input model: the union of
545    ///
546    /// 1. [`Self::vendor_files`] — PSR-4 / PSR-0 walked directories + extra paths,
547    /// 2. classmap file targets — packages that use `classmap:` autoload (no
548    ///    PSR-4 prefix), which [`Self::vendor_files`] does NOT walk, and
549    /// 3. [`Self::vendor_eager_files`] — `autoload.files` globals.
550    ///
551    /// Deduplicated by path. Non-existent classmap targets (stale generated
552    /// file) are skipped. This is the work-list a consumer feeds to the chunked
553    /// background indexer ([`crate::AnalysisSession::index_batch`]).
554    pub fn all_vendor_files(&self) -> Vec<PathBuf> {
555        let mut seen: rustc_hash::FxHashSet<PathBuf> = rustc_hash::FxHashSet::default();
556        let mut out = Vec::new();
557        let mut push = |p: PathBuf, out: &mut Vec<PathBuf>| {
558            if seen.insert(p.clone()) {
559                out.push(p);
560            }
561        };
562        for p in self.vendor_files() {
563            push(p, &mut out);
564        }
565        // Classmap-only packages are not covered by vendor_files() (which walks
566        // only PSR-4/PSR-0 dirs + extra paths). Include their file targets.
567        for p in self.classmap.values() {
568            if p.is_file() {
569                push(p.clone(), &mut out);
570            }
571        }
572        for p in self.vendor_eager_files() {
573            push(p, &mut out);
574        }
575        out
576    }
577
578    /// Number of FQCN entries known to the classmap. Used by callers that want
579    /// to log/verify the classmap loaded successfully.
580    pub fn classmap_len(&self) -> usize {
581        self.classmap.len()
582    }
583}
584
585/// Collect `.php` files from `path`. If `path` is a file, push it directly
586/// (when it has a `.php` extension); if it is a directory, walk it.
587fn collect_php_path(path: &Path, out: &mut Vec<PathBuf>) {
588    let Ok(meta) = std::fs::metadata(path) else {
589        return;
590    };
591    if meta.is_file() {
592        if path.extension().and_then(|e| e.to_str()) == Some("php") {
593            out.push(path.to_path_buf());
594        }
595    } else if meta.is_dir() {
596        crate::batch::collect_php_files(path, out);
597    }
598}
599
600/// Follows `require`/`include` targets reaching outside every autoload root
601/// (composer.json's autoload sections are otherwise the sole "this file is
602/// part of the project" signal) and adds any that resolve to a real `.php`
603/// file, recursing since a newly-added file may itself reach further
604/// out-of-root files. Only statically-resolvable target shapes are
605/// followed: a literal string, and `__DIR__` / `dirname(__FILE__)`
606/// concatenated with a literal string — the common manual-bootstrap idiom
607/// (e.g. `require_once __DIR__ . '/../legacy/bootstrap.php'`). A bare
608/// literal with no `__DIR__` is resolved relative to the including file's
609/// own directory, the conventional meaning for this idiom in practice.
610/// Files under a `vendor` directory are skipped — those are already reached
611/// through the composer autoload machinery, and re-adding them here would
612/// double-index vendor code as project code.
613fn expand_via_local_requires(out: &mut Vec<PathBuf>) {
614    let mut seen: rustc_hash::FxHashSet<PathBuf> = out.iter().cloned().collect();
615    let mut queue: Vec<PathBuf> = out.clone();
616    while let Some(file) = queue.pop() {
617        let Some(dir) = file.parent() else {
618            continue;
619        };
620        let Ok(text) = std::fs::read_to_string(&file) else {
621            continue;
622        };
623        // Cheap bailout: skip the full parse for the (common) majority of
624        // files that don't even mention require/include.
625        if !text.contains("require") && !text.contains("include") {
626            continue;
627        }
628        let parsed = php_rs_parser::parse(&text);
629        let mut scanner = IncludeTargetScanner {
630            dir,
631            targets: Vec::new(),
632        };
633        let _ = scanner.visit_program(&parsed.program);
634        for target in scanner.targets {
635            let resolved = if Path::new(&target).is_absolute() {
636                PathBuf::from(target)
637            } else {
638                dir.join(target)
639            };
640            let resolved = lexically_normalize(&resolved);
641            if resolved.extension().and_then(|e| e.to_str()) != Some("php") {
642                continue;
643            }
644            if resolved.components().any(|c| c.as_os_str() == "vendor") {
645                continue;
646            }
647            if !resolved.is_file() {
648                continue;
649            }
650            if seen.insert(resolved.clone()) {
651                out.push(resolved.clone());
652                queue.push(resolved);
653            }
654        }
655    }
656}
657
658/// Collapses `.`/`..` components lexically instead of relying on the OS to
659/// resolve them, since a `__DIR__`-derived base can be a Windows verbatim
660/// (`\\?\`-prefixed) path — those disable `..` resolution by the OS, so a
661/// naively-concatenated `\\?\C:\...\src/../legacy/helpers.php` would never
662/// resolve to a real file even though the target genuinely exists.
663fn lexically_normalize(path: &Path) -> PathBuf {
664    let mut components = path.components().peekable();
665    let mut ret = if let Some(c @ Component::Prefix(_)) = components.peek().copied() {
666        components.next();
667        PathBuf::from(c.as_os_str())
668    } else {
669        PathBuf::new()
670    };
671
672    for component in components {
673        match component {
674            Component::Prefix(_) => unreachable!(),
675            Component::RootDir => ret.push(component.as_os_str()),
676            Component::CurDir => {}
677            Component::ParentDir => {
678                ret.pop();
679            }
680            Component::Normal(c) => ret.push(c),
681        }
682    }
683    ret
684}
685
686struct IncludeTargetScanner<'a> {
687    dir: &'a Path,
688    targets: Vec<String>,
689}
690
691impl OwnedVisitor for IncludeTargetScanner<'_> {
692    fn visit_expr(&mut self, expr: &Expr) -> ControlFlow<()> {
693        if let ExprKind::Include(_, inner) = &expr.kind {
694            if let Some(target) = resolve_static_include_target(inner, self.dir) {
695                self.targets.push(target);
696            }
697        }
698        walk_owned_expr(self, expr)
699    }
700}
701
702/// Statically evaluates the include-target expression shapes real code
703/// actually uses: a literal string, `__DIR__` / `dirname(__FILE__)`, and `.`
704/// concatenations of those. Anything else (a variable, a non-`dirname` call)
705/// can't be resolved without running the program, so is left alone.
706fn resolve_static_include_target(expr: &Expr, dir: &Path) -> Option<String> {
707    match &expr.kind {
708        ExprKind::String(s) => Some(s.to_string()),
709        ExprKind::MagicConst(MagicConstKind::Dir) => Some(dir.to_string_lossy().into_owned()),
710        ExprKind::Parenthesized(inner) => resolve_static_include_target(inner, dir),
711        ExprKind::Binary(b) if b.op == BinaryOp::Concat => {
712            let left = resolve_static_include_target(&b.left, dir)?;
713            let right = resolve_static_include_target(&b.right, dir)?;
714            Some(format!("{left}{right}"))
715        }
716        ExprKind::FunctionCall(call) => {
717            let ExprKind::Identifier(name) = &call.name.kind else {
718                return None;
719            };
720            if !name.eq_ignore_ascii_case("dirname") {
721                return None;
722            }
723            let arg = call.args.first()?;
724            matches!(arg.value.kind, ExprKind::MagicConst(MagicConstKind::File))
725                .then(|| dir.to_string_lossy().into_owned())
726        }
727        _ => None,
728    }
729}
730
731#[cfg(test)]
732mod tests {
733    use super::*;
734    use std::fs;
735
736    fn make_temp_project(name: &str) -> PathBuf {
737        let dir = std::env::temp_dir().join(format!("mir_psr4_{name}"));
738        let _ = fs::remove_dir_all(&dir);
739        fs::create_dir_all(&dir).unwrap();
740        dir
741    }
742
743    #[test]
744    fn parse_project_entries() {
745        let root = make_temp_project("parse_project_entries");
746        fs::write(
747            root.join("composer.json"),
748            r#"{
749                "autoload": {
750                    "psr-4": { "App\\": "src/", "App\\Models\\": "src/models/" }
751                },
752                "autoload-dev": {
753                    "psr-4": { "Tests\\": "tests/" }
754                }
755            }"#,
756        )
757        .unwrap();
758
759        let map = Psr4Map::from_composer(&root).unwrap();
760
761        let prefixes: Vec<&str> = map
762            .project_entries
763            .iter()
764            .map(|(p, _)| p.as_str())
765            .collect();
766        assert!(prefixes.contains(&"App\\Models\\"), "missing App\\Models\\");
767        assert!(prefixes.contains(&"App\\"), "missing App\\");
768        assert!(prefixes.contains(&"Tests\\"), "missing Tests\\");
769    }
770
771    #[test]
772    fn longest_prefix_first() {
773        let root = make_temp_project("longest_prefix_first");
774        fs::write(
775            root.join("composer.json"),
776            r#"{
777                "autoload": {
778                    "psr-4": { "App\\": "src/", "App\\Models\\": "src/models/" }
779                }
780            }"#,
781        )
782        .unwrap();
783
784        let map = Psr4Map::from_composer(&root).unwrap();
785
786        assert_eq!(map.project_entries[0].0, "App\\Models\\");
787    }
788
789    #[test]
790    fn missing_autoload_section_is_error() {
791        let root = make_temp_project("missing_autoload");
792        fs::write(root.join("composer.json"), r#"{ "name": "my/pkg" }"#).unwrap();
793
794        let result = Psr4Map::from_composer(&root);
795        assert!(
796            matches!(result, Err(ComposerError::MissingAutoload)),
797            "expected MissingAutoload error"
798        );
799    }
800
801    #[test]
802    fn composer_v2_installed() {
803        let root = make_temp_project("composer_v2");
804        fs::write(
805            root.join("composer.json"),
806            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
807        )
808        .unwrap();
809
810        let vendor_dir = root.join("vendor/composer");
811        fs::create_dir_all(&vendor_dir).unwrap();
812        fs::write(
813            vendor_dir.join("installed.json"),
814            r#"{
815                "packages": [
816                    {
817                        "name": "vendor/pkg",
818                        "autoload": { "psr-4": { "Vendor\\Pkg\\": "src/" } }
819                    }
820                ]
821            }"#,
822        )
823        .unwrap();
824        fs::create_dir_all(root.join("vendor/vendor/pkg/src")).unwrap();
825
826        let map = Psr4Map::from_composer(&root).unwrap();
827        let prefixes: Vec<&str> = map.vendor_entries.iter().map(|(p, _)| p.as_str()).collect();
828        assert!(prefixes.contains(&"Vendor\\Pkg\\"), "missing Vendor\\Pkg\\");
829    }
830
831    #[test]
832    fn composer_v1_installed() {
833        let root = make_temp_project("composer_v1");
834        fs::write(
835            root.join("composer.json"),
836            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
837        )
838        .unwrap();
839
840        let vendor_dir = root.join("vendor/composer");
841        fs::create_dir_all(&vendor_dir).unwrap();
842        fs::write(
843            vendor_dir.join("installed.json"),
844            r#"[
845                {
846                    "name": "vendor/pkg",
847                    "autoload": { "psr-4": { "Vendor\\Pkg\\": "src/" } }
848                }
849            ]"#,
850        )
851        .unwrap();
852        fs::create_dir_all(root.join("vendor/vendor/pkg/src")).unwrap();
853
854        let map = Psr4Map::from_composer(&root).unwrap();
855        let prefixes: Vec<&str> = map.vendor_entries.iter().map(|(p, _)| p.as_str()).collect();
856        assert!(prefixes.contains(&"Vendor\\Pkg\\"), "missing Vendor\\Pkg\\");
857    }
858
859    #[test]
860    fn missing_installed_json() {
861        let root = make_temp_project("missing_installed");
862        fs::write(
863            root.join("composer.json"),
864            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
865        )
866        .unwrap();
867        let map = Psr4Map::from_composer(&root).unwrap();
868        assert!(map.vendor_entries.is_empty());
869    }
870
871    #[test]
872    fn project_files_returns_php_files() {
873        let root = make_temp_project("project_files");
874        let src = root.join("src");
875        fs::create_dir_all(&src).unwrap();
876        fs::write(src.join("Foo.php"), "<?php class Foo {}").unwrap();
877        fs::write(src.join("README.md"), "not php").unwrap();
878        fs::write(
879            root.join("composer.json"),
880            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
881        )
882        .unwrap();
883
884        let map = Psr4Map::from_composer(&root).unwrap();
885        let files = map.project_files();
886        assert_eq!(files.len(), 1);
887        assert!(files[0].ends_with("Foo.php"));
888    }
889
890    // -----------------------------------------------------------------------
891    // project_files() — require/include reaching outside every autoload root
892    // (Sector C7)
893    // -----------------------------------------------------------------------
894
895    #[test]
896    fn project_files_follows_dir_relative_require() {
897        let root = make_temp_project("require_dir_relative");
898        let src = root.join("src");
899        let legacy = root.join("legacy");
900        fs::create_dir_all(&src).unwrap();
901        fs::create_dir_all(&legacy).unwrap();
902        fs::write(
903            src.join("Bootstrap.php"),
904            "<?php require_once __DIR__ . '/../legacy/bootstrap.php'; class Bootstrap {}",
905        )
906        .unwrap();
907        fs::write(
908            legacy.join("bootstrap.php"),
909            "<?php function legacy_init() {}",
910        )
911        .unwrap();
912        fs::write(
913            root.join("composer.json"),
914            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
915        )
916        .unwrap();
917
918        let map = Psr4Map::from_composer(&root).unwrap();
919        let files = map.project_files();
920        assert_eq!(files.len(), 2, "expected Bootstrap.php + bootstrap.php");
921        assert!(files.iter().any(|f| f.ends_with("bootstrap.php")));
922    }
923
924    #[test]
925    fn project_files_follows_dirname_file_require() {
926        let root = make_temp_project("require_dirname_file");
927        let src = root.join("src");
928        let legacy = root.join("legacy");
929        fs::create_dir_all(&src).unwrap();
930        fs::create_dir_all(&legacy).unwrap();
931        fs::write(
932            src.join("Bootstrap.php"),
933            "<?php require_once dirname(__FILE__) . '/../legacy/bootstrap.php'; class Bootstrap {}",
934        )
935        .unwrap();
936        fs::write(
937            legacy.join("bootstrap.php"),
938            "<?php function legacy_init() {}",
939        )
940        .unwrap();
941        fs::write(
942            root.join("composer.json"),
943            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
944        )
945        .unwrap();
946
947        let map = Psr4Map::from_composer(&root).unwrap();
948        let files = map.project_files();
949        assert!(files.iter().any(|f| f.ends_with("bootstrap.php")));
950    }
951
952    #[test]
953    fn project_files_follows_bare_literal_require_relative_to_including_file() {
954        let root = make_temp_project("require_bare_literal");
955        let src = root.join("src");
956        let legacy = root.join("legacy");
957        fs::create_dir_all(&src).unwrap();
958        fs::create_dir_all(&legacy).unwrap();
959        fs::write(
960            src.join("Bootstrap.php"),
961            "<?php require_once '../legacy/bootstrap.php'; class Bootstrap {}",
962        )
963        .unwrap();
964        fs::write(
965            legacy.join("bootstrap.php"),
966            "<?php function legacy_init() {}",
967        )
968        .unwrap();
969        fs::write(
970            root.join("composer.json"),
971            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
972        )
973        .unwrap();
974
975        let map = Psr4Map::from_composer(&root).unwrap();
976        let files = map.project_files();
977        assert!(files.iter().any(|f| f.ends_with("bootstrap.php")));
978    }
979
980    #[test]
981    fn project_files_follows_require_transitively() {
982        let root = make_temp_project("require_transitive");
983        let src = root.join("src");
984        let legacy = root.join("legacy");
985        fs::create_dir_all(&src).unwrap();
986        fs::create_dir_all(&legacy).unwrap();
987        fs::write(
988            src.join("Bootstrap.php"),
989            "<?php require_once __DIR__ . '/../legacy/a.php';",
990        )
991        .unwrap();
992        fs::write(
993            legacy.join("a.php"),
994            "<?php require_once __DIR__ . '/b.php';",
995        )
996        .unwrap();
997        fs::write(legacy.join("b.php"), "<?php function b() {}").unwrap();
998        fs::write(
999            root.join("composer.json"),
1000            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1001        )
1002        .unwrap();
1003
1004        let map = Psr4Map::from_composer(&root).unwrap();
1005        let files = map.project_files();
1006        assert!(files.iter().any(|f| f.ends_with("a.php")));
1007        assert!(files.iter().any(|f| f.ends_with("b.php")));
1008    }
1009
1010    #[test]
1011    fn project_files_require_cycle_does_not_hang() {
1012        let root = make_temp_project("require_cycle");
1013        let src = root.join("src");
1014        let legacy = root.join("legacy");
1015        fs::create_dir_all(&src).unwrap();
1016        fs::create_dir_all(&legacy).unwrap();
1017        fs::write(
1018            src.join("Bootstrap.php"),
1019            "<?php require_once __DIR__ . '/../legacy/a.php';",
1020        )
1021        .unwrap();
1022        fs::write(
1023            legacy.join("a.php"),
1024            "<?php require_once __DIR__ . '/b.php';",
1025        )
1026        .unwrap();
1027        fs::write(
1028            legacy.join("b.php"),
1029            "<?php require_once __DIR__ . '/a.php';",
1030        )
1031        .unwrap();
1032        fs::write(
1033            root.join("composer.json"),
1034            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1035        )
1036        .unwrap();
1037
1038        let map = Psr4Map::from_composer(&root).unwrap();
1039        let files = map.project_files();
1040        assert_eq!(
1041            files.len(),
1042            3,
1043            "each file discovered exactly once despite the cycle"
1044        );
1045    }
1046
1047    #[test]
1048    fn project_files_missing_require_target_is_skipped() {
1049        let root = make_temp_project("require_missing_target");
1050        let src = root.join("src");
1051        fs::create_dir_all(&src).unwrap();
1052        fs::write(
1053            src.join("Bootstrap.php"),
1054            "<?php require_once __DIR__ . '/../legacy/does_not_exist.php';",
1055        )
1056        .unwrap();
1057        fs::write(
1058            root.join("composer.json"),
1059            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1060        )
1061        .unwrap();
1062
1063        let map = Psr4Map::from_composer(&root).unwrap();
1064        let files = map.project_files();
1065        assert_eq!(files.len(), 1, "only Bootstrap.php itself");
1066    }
1067
1068    #[test]
1069    fn project_files_dynamic_require_target_not_followed() {
1070        let root = make_temp_project("require_dynamic_target");
1071        let src = root.join("src");
1072        let legacy = root.join("legacy");
1073        fs::create_dir_all(&src).unwrap();
1074        fs::create_dir_all(&legacy).unwrap();
1075        fs::write(
1076            src.join("Bootstrap.php"),
1077            "<?php $path = getPath(); require_once $path;",
1078        )
1079        .unwrap();
1080        fs::write(
1081            legacy.join("bootstrap.php"),
1082            "<?php function legacy_init() {}",
1083        )
1084        .unwrap();
1085        fs::write(
1086            root.join("composer.json"),
1087            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1088        )
1089        .unwrap();
1090
1091        let map = Psr4Map::from_composer(&root).unwrap();
1092        let files = map.project_files();
1093        assert_eq!(
1094            files.len(),
1095            1,
1096            "a non-statically-resolvable require target must not be followed"
1097        );
1098    }
1099
1100    #[test]
1101    fn project_files_require_into_vendor_is_skipped() {
1102        let root = make_temp_project("require_into_vendor");
1103        let src = root.join("src");
1104        let vendor_pkg = root.join("vendor/some/pkg");
1105        fs::create_dir_all(&src).unwrap();
1106        fs::create_dir_all(&vendor_pkg).unwrap();
1107        fs::write(
1108            src.join("Bootstrap.php"),
1109            "<?php require_once __DIR__ . '/../vendor/some/pkg/lib.php';",
1110        )
1111        .unwrap();
1112        fs::write(vendor_pkg.join("lib.php"), "<?php function lib() {}").unwrap();
1113        fs::write(
1114            root.join("composer.json"),
1115            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1116        )
1117        .unwrap();
1118
1119        let map = Psr4Map::from_composer(&root).unwrap();
1120        let files = map.project_files();
1121        assert_eq!(
1122            files.len(),
1123            1,
1124            "vendor-reaching require must not be indexed as a project file"
1125        );
1126    }
1127
1128    #[test]
1129    fn resolve_existing_file() {
1130        let root = make_temp_project("resolve_existing");
1131        let models = root.join("src/models");
1132        fs::create_dir_all(&models).unwrap();
1133        fs::write(models.join("User.php"), "<?php class User {}").unwrap();
1134        fs::write(
1135            root.join("composer.json"),
1136            r#"{"autoload":{"psr-4":{"App\\Models\\":"src/models/","App\\":"src/"}}}"#,
1137        )
1138        .unwrap();
1139
1140        let map = Psr4Map::from_composer(&root).unwrap();
1141        let result = map.resolve("App\\Models\\User");
1142        assert!(result.is_some(), "expected a resolved path");
1143        assert!(result.unwrap().ends_with("User.php"));
1144    }
1145
1146    #[test]
1147    fn resolve_missing_file() {
1148        let root = make_temp_project("resolve_missing");
1149        fs::create_dir_all(root.join("src")).unwrap();
1150        fs::write(
1151            root.join("composer.json"),
1152            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1153        )
1154        .unwrap();
1155
1156        let map = Psr4Map::from_composer(&root).unwrap();
1157        let result = map.resolve("App\\Models\\User");
1158        assert!(result.is_none());
1159    }
1160
1161    #[test]
1162    fn boundary_check() {
1163        let root = make_temp_project("boundary_check");
1164        fs::create_dir_all(root.join("src")).unwrap();
1165        fs::write(
1166            root.join("composer.json"),
1167            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1168        )
1169        .unwrap();
1170
1171        let map = Psr4Map::from_composer(&root).unwrap();
1172        // "App\" must NOT match "Application\Foo"
1173        let result = map.resolve("Application\\Foo");
1174        assert!(
1175            result.is_none(),
1176            "App\\ prefix must not match Application\\Foo"
1177        );
1178    }
1179
1180    #[test]
1181    fn array_valued_psr4_dirs() {
1182        let root = make_temp_project("array_dirs");
1183        let src = root.join("src");
1184        let lib = root.join("lib");
1185        fs::create_dir_all(&src).unwrap();
1186        fs::create_dir_all(&lib).unwrap();
1187        fs::write(src.join("Foo.php"), "<?php class Foo {}").unwrap();
1188        fs::write(lib.join("Bar.php"), "<?php class Bar {}").unwrap();
1189        fs::write(
1190            root.join("composer.json"),
1191            r#"{"autoload":{"psr-4":{"App\\":["src/","lib/"]}}}"#,
1192        )
1193        .unwrap();
1194
1195        let map = Psr4Map::from_composer(&root).unwrap();
1196        // Both dirs should be in project_entries
1197        assert_eq!(
1198            map.project_entries.len(),
1199            2,
1200            "expected 2 entries for array-valued dir"
1201        );
1202        let files = map.project_files();
1203        assert_eq!(files.len(), 2, "expected Foo.php and Bar.php");
1204    }
1205
1206    // -----------------------------------------------------------------------
1207    // classmap / files / psr-0 — vendor and project
1208    // -----------------------------------------------------------------------
1209
1210    #[test]
1211    fn project_classmap_dir_is_collected() {
1212        let root = make_temp_project("project_classmap");
1213        let lib = root.join("lib");
1214        fs::create_dir_all(&lib).unwrap();
1215        fs::write(lib.join("Legacy.php"), "<?php class Legacy {}").unwrap();
1216        fs::write(
1217            root.join("composer.json"),
1218            r#"{"autoload":{"classmap":["lib/"]}}"#,
1219        )
1220        .unwrap();
1221
1222        let map = Psr4Map::from_composer(&root).unwrap();
1223        let files = map.project_files();
1224        assert_eq!(files.len(), 1);
1225        assert!(files[0].ends_with("Legacy.php"));
1226    }
1227
1228    #[test]
1229    fn project_files_autoload_is_collected() {
1230        let root = make_temp_project("project_files_autoload");
1231        fs::write(root.join("helpers.php"), "<?php function my_helper() {}").unwrap();
1232        fs::write(
1233            root.join("composer.json"),
1234            r#"{"autoload":{"files":["helpers.php"]}}"#,
1235        )
1236        .unwrap();
1237
1238        let map = Psr4Map::from_composer(&root).unwrap();
1239        let files = map.project_files();
1240        assert_eq!(files.len(), 1);
1241        assert!(files[0].ends_with("helpers.php"));
1242    }
1243
1244    #[test]
1245    fn project_psr0_dir_is_collected() {
1246        let root = make_temp_project("project_psr0");
1247        let lib = root.join("legacy");
1248        fs::create_dir_all(&lib).unwrap();
1249        fs::write(lib.join("Old.php"), "<?php class Old {}").unwrap();
1250        fs::write(
1251            root.join("composer.json"),
1252            r#"{"autoload":{"psr-0":{"":"legacy/"}}}"#,
1253        )
1254        .unwrap();
1255
1256        let map = Psr4Map::from_composer(&root).unwrap();
1257        let files = map.project_files();
1258        assert_eq!(files.len(), 1);
1259        assert!(files[0].ends_with("Old.php"));
1260    }
1261
1262    #[test]
1263    fn vendor_classmap_is_collected() {
1264        let root = make_temp_project("vendor_classmap");
1265        fs::write(
1266            root.join("composer.json"),
1267            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1268        )
1269        .unwrap();
1270        let vendor_dir = root.join("vendor/composer");
1271        fs::create_dir_all(&vendor_dir).unwrap();
1272        fs::write(
1273            vendor_dir.join("installed.json"),
1274            r#"{
1275                "packages": [{
1276                    "name": "vendor/pkg",
1277                    "autoload": { "classmap": ["src/"] }
1278                }]
1279            }"#,
1280        )
1281        .unwrap();
1282        let pkg_src = root.join("vendor/vendor/pkg/src");
1283        fs::create_dir_all(&pkg_src).unwrap();
1284        fs::write(pkg_src.join("Legacy.php"), "<?php class Legacy {}").unwrap();
1285
1286        let map = Psr4Map::from_composer(&root).unwrap();
1287        let files = map.vendor_files();
1288        assert_eq!(files.len(), 1);
1289        assert!(files[0].ends_with("Legacy.php"));
1290    }
1291
1292    #[test]
1293    fn vendor_files_autoload_is_collected() {
1294        let root = make_temp_project("vendor_files_autoload");
1295        fs::write(
1296            root.join("composer.json"),
1297            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1298        )
1299        .unwrap();
1300        let vendor_dir = root.join("vendor/composer");
1301        fs::create_dir_all(&vendor_dir).unwrap();
1302        fs::write(
1303            vendor_dir.join("installed.json"),
1304            r#"{
1305                "packages": [{
1306                    "name": "vendor/pkg",
1307                    "autoload": { "files": ["bootstrap.php"] }
1308                }]
1309            }"#,
1310        )
1311        .unwrap();
1312        let pkg_dir = root.join("vendor/vendor/pkg");
1313        fs::create_dir_all(&pkg_dir).unwrap();
1314        fs::write(
1315            pkg_dir.join("bootstrap.php"),
1316            "<?php function pkg_bootstrap() {}",
1317        )
1318        .unwrap();
1319
1320        let map = Psr4Map::from_composer(&root).unwrap();
1321        let files = map.vendor_files();
1322        assert_eq!(files.len(), 1);
1323        assert!(files[0].ends_with("bootstrap.php"));
1324    }
1325
1326    #[test]
1327    fn resolve_psr0_project_namespaced_class() {
1328        let root = make_temp_project("resolve_psr0_project_namespaced");
1329        let mailer_dir = root.join("src/Mailer");
1330        fs::create_dir_all(&mailer_dir).unwrap();
1331        fs::write(
1332            mailer_dir.join("Message.php"),
1333            "<?php namespace Mailer; class Message {}",
1334        )
1335        .unwrap();
1336        fs::write(
1337            root.join("composer.json"),
1338            r#"{"autoload":{"psr-0":{"Mailer\\":"src/"}}}"#,
1339        )
1340        .unwrap();
1341
1342        let map = Psr4Map::from_composer(&root).unwrap();
1343        let result = map.resolve("Mailer\\Message");
1344        assert!(result.is_some(), "expected a resolved PSR-0 path");
1345        assert!(result.unwrap().ends_with("Mailer/Message.php"));
1346    }
1347
1348    #[test]
1349    fn resolve_psr0_pear_style_class_with_underscores() {
1350        let root = make_temp_project("resolve_psr0_pear_style");
1351        let old_dir = root.join("src/Old");
1352        fs::create_dir_all(&old_dir).unwrap();
1353        fs::write(old_dir.join("Thing.php"), "<?php class Old_Thing {}").unwrap();
1354        fs::write(
1355            root.join("composer.json"),
1356            r#"{"autoload":{"psr-0":{"Old_":"src/"}}}"#,
1357        )
1358        .unwrap();
1359
1360        let map = Psr4Map::from_composer(&root).unwrap();
1361        let result = map.resolve("Old_Thing");
1362        assert!(result.is_some(), "expected a resolved PSR-0 path");
1363        assert!(result.unwrap().ends_with("Old/Thing.php"));
1364    }
1365
1366    #[test]
1367    fn resolve_psr0_vendor_class() {
1368        let root = make_temp_project("resolve_psr0_vendor");
1369        fs::write(
1370            root.join("composer.json"),
1371            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1372        )
1373        .unwrap();
1374        let vendor_dir = root.join("vendor/composer");
1375        fs::create_dir_all(&vendor_dir).unwrap();
1376        fs::write(
1377            vendor_dir.join("installed.json"),
1378            r#"{
1379                "packages": [{
1380                    "name": "vendor/pkg",
1381                    "autoload": { "psr-0": { "Old_": "src/" } }
1382                }]
1383            }"#,
1384        )
1385        .unwrap();
1386        let pkg_src = root.join("vendor/vendor/pkg/src/Old");
1387        fs::create_dir_all(&pkg_src).unwrap();
1388        fs::write(pkg_src.join("Thing.php"), "<?php class Old_Thing {}").unwrap();
1389
1390        let map = Psr4Map::from_composer(&root).unwrap();
1391        let result = map.resolve("Old_Thing");
1392        assert!(result.is_some(), "expected a resolved PSR-0 vendor path");
1393        assert!(result.unwrap().ends_with("Old/Thing.php"));
1394    }
1395
1396    #[test]
1397    fn resolve_psr0_prefix_must_still_match() {
1398        let root = make_temp_project("resolve_psr0_prefix_mismatch");
1399        fs::create_dir_all(root.join("src")).unwrap();
1400        fs::write(
1401            root.join("composer.json"),
1402            r#"{"autoload":{"psr-0":{"Mailer\\":"src/"}}}"#,
1403        )
1404        .unwrap();
1405
1406        let map = Psr4Map::from_composer(&root).unwrap();
1407        let result = map.resolve("Other\\Message");
1408        assert!(
1409            result.is_none(),
1410            "Mailer\\ psr-0 prefix must not match Other\\Message"
1411        );
1412    }
1413
1414    #[test]
1415    fn vendor_psr0_is_collected() {
1416        let root = make_temp_project("vendor_psr0");
1417        fs::write(
1418            root.join("composer.json"),
1419            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1420        )
1421        .unwrap();
1422        let vendor_dir = root.join("vendor/composer");
1423        fs::create_dir_all(&vendor_dir).unwrap();
1424        fs::write(
1425            vendor_dir.join("installed.json"),
1426            r#"{
1427                "packages": [{
1428                    "name": "vendor/pkg",
1429                    "autoload": { "psr-0": { "Old_": "src/" } }
1430                }]
1431            }"#,
1432        )
1433        .unwrap();
1434        let pkg_src = root.join("vendor/vendor/pkg/src/Old");
1435        fs::create_dir_all(&pkg_src).unwrap();
1436        fs::write(pkg_src.join("Thing.php"), "<?php class Old_Thing {}").unwrap();
1437
1438        let map = Psr4Map::from_composer(&root).unwrap();
1439        let files = map.vendor_files();
1440        assert_eq!(files.len(), 1);
1441        assert!(files[0].ends_with("Thing.php"));
1442    }
1443}