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