use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ComposerError {
#[error("composer I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("composer JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("composer.json has no autoload section")]
MissingAutoload,
}
#[derive(Clone)]
pub struct Psr4Map {
project_entries: Vec<(String, PathBuf)>,
vendor_entries: Vec<(String, PathBuf)>,
#[allow(dead_code)] root: PathBuf,
}
fn ensure_trailing_backslash(prefix: &str) -> String {
if prefix.ends_with('\\') {
prefix.to_string()
} else {
format!("{}\\", prefix)
}
}
fn parse_vendor_entries(root: &Path) -> Vec<(String, PathBuf)> {
let installed_path = root.join("vendor/composer/installed.json");
let content = match std::fs::read_to_string(&installed_path) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let value: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(_) => return Vec::new(),
};
let packages = if let Some(arr) = value.get("packages").and_then(|v| v.as_array()) {
arr.clone()
} else if let Some(arr) = value.as_array() {
arr.clone()
} else {
return Vec::new();
};
let vendor_dir = root.join("vendor");
let mut entries: Vec<(String, PathBuf)> = Vec::new();
for pkg in &packages {
if let Some(map) = pkg.pointer("/autoload/psr-4").and_then(|v| v.as_object()) {
let pkg_name = pkg.get("name").and_then(|v| v.as_str()).unwrap_or("");
let pkg_dir = vendor_dir.join(pkg_name);
for (prefix, dir) in map {
if let Some(d) = dir.as_str() {
entries.push((ensure_trailing_backslash(prefix), pkg_dir.join(d)));
} else if let Some(arr) = dir.as_array() {
for item in arr {
if let Some(d) = item.as_str() {
entries.push((ensure_trailing_backslash(prefix), pkg_dir.join(d)));
}
}
}
}
}
}
entries.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
entries
}
impl Psr4Map {
pub fn from_composer(root: &Path) -> Result<Self, ComposerError> {
let composer_path = root.join("composer.json");
let content = std::fs::read_to_string(&composer_path)?;
let value: serde_json::Value = serde_json::from_str(&content)?;
let has_autoload = value.get("autoload").is_some() || value.get("autoload-dev").is_some();
if !has_autoload {
return Err(ComposerError::MissingAutoload);
}
let mut project_entries: Vec<(String, PathBuf)> = Vec::new();
if let Some(map) = value.pointer("/autoload/psr-4").and_then(|v| v.as_object()) {
for (prefix, dir) in map {
if let Some(d) = dir.as_str() {
project_entries.push((ensure_trailing_backslash(prefix), root.join(d)));
} else if let Some(arr) = dir.as_array() {
for item in arr {
if let Some(d) = item.as_str() {
project_entries.push((ensure_trailing_backslash(prefix), root.join(d)));
}
}
}
}
}
if let Some(map) = value
.pointer("/autoload-dev/psr-4")
.and_then(|v| v.as_object())
{
for (prefix, dir) in map {
if let Some(d) = dir.as_str() {
project_entries.push((ensure_trailing_backslash(prefix), root.join(d)));
} else if let Some(arr) = dir.as_array() {
for item in arr {
if let Some(d) = item.as_str() {
project_entries.push((ensure_trailing_backslash(prefix), root.join(d)));
}
}
}
}
}
project_entries.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
let vendor_entries = parse_vendor_entries(root);
Ok(Psr4Map {
project_entries,
vendor_entries,
root: root.to_path_buf(),
})
}
pub fn project_files(&self) -> Vec<PathBuf> {
let mut out = Vec::new();
for (_, dir) in &self.project_entries {
crate::project::collect_php_files(dir, &mut out);
}
out
}
pub fn vendor_files(&self) -> Vec<PathBuf> {
let mut out = Vec::new();
for (_, dir) in &self.vendor_entries {
crate::project::collect_php_files(dir, &mut out);
}
out
}
pub fn resolve(&self, fqcn: &str) -> Option<PathBuf> {
for (prefix, dir) in self
.project_entries
.iter()
.chain(self.vendor_entries.iter())
{
if fqcn.starts_with(prefix.as_str()) {
let relative = &fqcn[prefix.len()..];
let file_path = dir.join(relative.replace('\\', "/")).with_extension("php");
if file_path.exists() {
return Some(file_path);
}
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn make_temp_project(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("mir_psr4_{}", name));
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn parse_project_entries() {
let root = make_temp_project("parse_project_entries");
fs::write(
root.join("composer.json"),
r#"{
"autoload": {
"psr-4": { "App\\": "src/", "App\\Models\\": "src/models/" }
},
"autoload-dev": {
"psr-4": { "Tests\\": "tests/" }
}
}"#,
)
.unwrap();
let map = Psr4Map::from_composer(&root).unwrap();
let prefixes: Vec<&str> = map
.project_entries
.iter()
.map(|(p, _)| p.as_str())
.collect();
assert!(prefixes.contains(&"App\\Models\\"), "missing App\\Models\\");
assert!(prefixes.contains(&"App\\"), "missing App\\");
assert!(prefixes.contains(&"Tests\\"), "missing Tests\\");
}
#[test]
fn longest_prefix_first() {
let root = make_temp_project("longest_prefix_first");
fs::write(
root.join("composer.json"),
r#"{
"autoload": {
"psr-4": { "App\\": "src/", "App\\Models\\": "src/models/" }
}
}"#,
)
.unwrap();
let map = Psr4Map::from_composer(&root).unwrap();
assert_eq!(map.project_entries[0].0, "App\\Models\\");
}
#[test]
fn missing_autoload_section_is_error() {
let root = make_temp_project("missing_autoload");
fs::write(root.join("composer.json"), r#"{ "name": "my/pkg" }"#).unwrap();
let result = Psr4Map::from_composer(&root);
assert!(
matches!(result, Err(ComposerError::MissingAutoload)),
"expected MissingAutoload error"
);
}
#[test]
fn composer_v2_installed() {
let root = make_temp_project("composer_v2");
fs::write(
root.join("composer.json"),
r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
)
.unwrap();
let vendor_dir = root.join("vendor/composer");
fs::create_dir_all(&vendor_dir).unwrap();
fs::write(
vendor_dir.join("installed.json"),
r#"{
"packages": [
{
"name": "vendor/pkg",
"autoload": { "psr-4": { "Vendor\\Pkg\\": "src/" } }
}
]
}"#,
)
.unwrap();
fs::create_dir_all(root.join("vendor/vendor/pkg/src")).unwrap();
let map = Psr4Map::from_composer(&root).unwrap();
let prefixes: Vec<&str> = map.vendor_entries.iter().map(|(p, _)| p.as_str()).collect();
assert!(prefixes.contains(&"Vendor\\Pkg\\"), "missing Vendor\\Pkg\\");
}
#[test]
fn composer_v1_installed() {
let root = make_temp_project("composer_v1");
fs::write(
root.join("composer.json"),
r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
)
.unwrap();
let vendor_dir = root.join("vendor/composer");
fs::create_dir_all(&vendor_dir).unwrap();
fs::write(
vendor_dir.join("installed.json"),
r#"[
{
"name": "vendor/pkg",
"autoload": { "psr-4": { "Vendor\\Pkg\\": "src/" } }
}
]"#,
)
.unwrap();
fs::create_dir_all(root.join("vendor/vendor/pkg/src")).unwrap();
let map = Psr4Map::from_composer(&root).unwrap();
let prefixes: Vec<&str> = map.vendor_entries.iter().map(|(p, _)| p.as_str()).collect();
assert!(prefixes.contains(&"Vendor\\Pkg\\"), "missing Vendor\\Pkg\\");
}
#[test]
fn missing_installed_json() {
let root = make_temp_project("missing_installed");
fs::write(
root.join("composer.json"),
r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
)
.unwrap();
let map = Psr4Map::from_composer(&root).unwrap();
assert!(map.vendor_entries.is_empty());
}
#[test]
fn project_files_returns_php_files() {
let root = make_temp_project("project_files");
let src = root.join("src");
fs::create_dir_all(&src).unwrap();
fs::write(src.join("Foo.php"), "<?php class Foo {}").unwrap();
fs::write(src.join("README.md"), "not php").unwrap();
fs::write(
root.join("composer.json"),
r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
)
.unwrap();
let map = Psr4Map::from_composer(&root).unwrap();
let files = map.project_files();
assert_eq!(files.len(), 1);
assert!(files[0].ends_with("Foo.php"));
}
#[test]
fn resolve_existing_file() {
let root = make_temp_project("resolve_existing");
let models = root.join("src/models");
fs::create_dir_all(&models).unwrap();
fs::write(models.join("User.php"), "<?php class User {}").unwrap();
fs::write(
root.join("composer.json"),
r#"{"autoload":{"psr-4":{"App\\Models\\":"src/models/","App\\":"src/"}}}"#,
)
.unwrap();
let map = Psr4Map::from_composer(&root).unwrap();
let result = map.resolve("App\\Models\\User");
assert!(result.is_some(), "expected a resolved path");
assert!(result.unwrap().ends_with("User.php"));
}
#[test]
fn resolve_missing_file() {
let root = make_temp_project("resolve_missing");
fs::create_dir_all(root.join("src")).unwrap();
fs::write(
root.join("composer.json"),
r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
)
.unwrap();
let map = Psr4Map::from_composer(&root).unwrap();
let result = map.resolve("App\\Models\\User");
assert!(result.is_none());
}
#[test]
fn boundary_check() {
let root = make_temp_project("boundary_check");
fs::create_dir_all(root.join("src")).unwrap();
fs::write(
root.join("composer.json"),
r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
)
.unwrap();
let map = Psr4Map::from_composer(&root).unwrap();
let result = map.resolve("Application\\Foo");
assert!(
result.is_none(),
"App\\ prefix must not match Application\\Foo"
);
}
#[test]
fn array_valued_psr4_dirs() {
let root = make_temp_project("array_dirs");
let src = root.join("src");
let lib = root.join("lib");
fs::create_dir_all(&src).unwrap();
fs::create_dir_all(&lib).unwrap();
fs::write(src.join("Foo.php"), "<?php class Foo {}").unwrap();
fs::write(lib.join("Bar.php"), "<?php class Bar {}").unwrap();
fs::write(
root.join("composer.json"),
r#"{"autoload":{"psr-4":{"App\\":["src/","lib/"]}}}"#,
)
.unwrap();
let map = Psr4Map::from_composer(&root).unwrap();
assert_eq!(
map.project_entries.len(),
2,
"expected 2 entries for array-valued dir"
);
let files = map.project_files();
assert_eq!(files.len(), 2, "expected Foo.php and Bar.php");
}
}