use std::collections::HashMap;
use std::path::{Path, PathBuf};
pub struct Psr4Map {
entries: Vec<(String, Vec<PathBuf>)>,
}
impl Psr4Map {
pub fn empty() -> Self {
Psr4Map { entries: vec![] }
}
pub fn load(root: &Path) -> Self {
let mut map: HashMap<String, Vec<PathBuf>> = HashMap::new();
add_from_composer_json(&root.join("composer.json"), root, &mut map);
let installed_json = root.join("vendor").join("composer").join("installed.json");
if let Ok(text) = std::fs::read_to_string(&installed_json)
&& let Ok(json) = serde_json::from_str::<serde_json::Value>(&text)
{
let packages = json
.get("packages")
.and_then(|v| v.as_array())
.or_else(|| json.as_array());
if let Some(pkgs) = packages {
let vendor_composer = root.join("vendor").join("composer");
for pkg in pkgs {
let install_path = pkg
.get("install-path")
.and_then(|v| v.as_str())
.map(|p| vendor_composer.join(p));
if let Some(pkg_root) = install_path {
let pkg_root = std::fs::canonicalize(&pkg_root).unwrap_or(pkg_root);
if let Some(autoload) = pkg.get("autoload") {
load_psr4_section(autoload, &pkg_root, &mut map);
}
}
}
}
}
let mut entries: Vec<_> = map.into_iter().collect();
entries.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
Psr4Map { entries }
}
pub fn extend(&mut self, other: Psr4Map) {
self.entries.extend(other.entries);
self.entries.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
}
pub fn file_to_fqn(&self, path: &Path) -> Option<String> {
for (prefix, base_dirs) in &self.entries {
for base in base_dirs {
if let Ok(rel) = path.strip_prefix(base) {
let rel_str = rel.to_string_lossy();
let without_ext = rel_str.strip_suffix(".php")?;
let class_path = without_ext.replace([std::path::MAIN_SEPARATOR, '/'], "\\");
return Some(format!("{}{}", prefix, class_path));
}
}
}
None
}
pub fn resolve(&self, class_name: &str) -> Option<PathBuf> {
let class_name = class_name.trim_start_matches('\\');
for (prefix, base_dirs) in &self.entries {
let ns = prefix.trim_end_matches('\\');
if !class_name.starts_with(ns) {
continue;
}
let after = &class_name[ns.len()..];
if !after.is_empty() && !after.starts_with('\\') {
continue;
}
let remainder = after.trim_start_matches('\\');
let relative = remainder.replace('\\', "/") + ".php";
for base in base_dirs {
let candidate = base.join(&relative);
if candidate.exists() {
return Some(candidate);
}
}
}
None
}
}
fn add_from_composer_json(
composer_json: &Path,
root: &Path,
map: &mut HashMap<String, Vec<PathBuf>>,
) {
let Ok(text) = std::fs::read_to_string(composer_json) else {
return;
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) else {
return;
};
for section in ["autoload", "autoload-dev"] {
if let Some(autoload) = json.get(section) {
load_psr4_section(autoload, root, map);
}
}
}
fn load_psr4_section(
autoload: &serde_json::Value,
root: &Path,
map: &mut HashMap<String, Vec<PathBuf>>,
) {
let Some(psr4) = autoload.get("psr-4").and_then(|v| v.as_object()) else {
return;
};
for (ns, paths) in psr4 {
let dirs: Vec<PathBuf> = match paths {
serde_json::Value::String(s) => vec![root.join(s)],
serde_json::Value::Array(arr) => arr
.iter()
.filter_map(|v| v.as_str())
.map(|s| root.join(s))
.collect(),
_ => continue,
};
map.entry(ns.clone()).or_default().extend(dirs);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write as _;
fn write(path: &Path, content: &str) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
let mut f = std::fs::File::create(path).unwrap();
f.write_all(content.as_bytes()).unwrap();
}
#[test]
fn empty_map_resolves_nothing() {
let m = Psr4Map::empty();
assert!(m.entries.is_empty());
assert!(m.resolve("App\\Foo").is_none());
}
#[test]
fn resolves_class_from_composer_json() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write(&root.join("src/Services/Foo.php"), "<?php class Foo {}");
write(
&root.join("composer.json"),
r#"{"autoload": {"psr-4": {"App\\": "src/"}}}"#,
);
let m = Psr4Map::load(root);
assert!(!m.entries.is_empty());
let resolved = m.resolve("App\\Services\\Foo");
assert!(resolved.is_some(), "should resolve App\\Services\\Foo");
assert!(resolved.unwrap().ends_with("src/Services/Foo.php"));
}
#[test]
fn returns_none_when_file_does_not_exist() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write(
&root.join("composer.json"),
r#"{"autoload": {"psr-4": {"App\\": "src/"}}}"#,
);
let m = Psr4Map::load(root);
assert!(m.resolve("App\\Missing\\Class").is_none());
}
#[test]
fn leading_backslash_is_stripped() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write(&root.join("src/Foo.php"), "<?php class Foo {}");
write(
&root.join("composer.json"),
r#"{"autoload": {"psr-4": {"App\\": "src/"}}}"#,
);
let m = Psr4Map::load(root);
assert!(m.resolve("\\App\\Foo").is_some());
assert!(m.resolve("App\\Foo").is_some());
}
#[test]
fn longer_prefix_wins_over_shorter() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write(&root.join("src/Foo.php"), "<?php");
write(&root.join("core/Foo.php"), "<?php");
write(
&root.join("composer.json"),
r#"{"autoload": {"psr-4": {"App\\": "src/", "App\\Core\\": "core/"}}}"#,
);
let m = Psr4Map::load(root);
let resolved = m.resolve("App\\Core\\Foo").unwrap();
assert!(resolved.ends_with("core/Foo.php"), "got {:?}", resolved);
}
#[test]
fn loads_empty_when_composer_json_absent() {
let dir = tempfile::tempdir().unwrap();
let m = Psr4Map::load(dir.path());
assert!(m.entries.is_empty());
}
#[test]
fn psr4_prefix_does_not_match_longer_namespace() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write(&root.join("src/lication/Foo.php"), "<?php");
write(
&root.join("composer.json"),
r#"{"autoload": {"psr-4": {"App\\": "src/"}}}"#,
);
let m = Psr4Map::load(root);
assert!(
m.resolve("Application\\Foo").is_none(),
"App\\ prefix must not match Application\\Foo"
);
}
#[test]
fn psr4_exact_prefix_still_resolves() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write(&root.join("src/Foo.php"), "<?php");
write(
&root.join("composer.json"),
r#"{"autoload": {"psr-4": {"App\\": "src/"}}}"#,
);
let m = Psr4Map::load(root);
assert!(m.resolve("App\\Foo").is_some(), "App\\Foo must resolve");
}
#[test]
fn autoload_dev_entries_are_included() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write(&root.join("tests/FooTest.php"), "<?php");
write(
&root.join("composer.json"),
r#"{"autoload-dev": {"psr-4": {"Tests\\": "tests/"}}}"#,
);
let m = Psr4Map::load(root);
assert!(m.resolve("Tests\\FooTest").is_some());
}
}