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