use php_ast::ast::{BinaryOp, MagicConstKind};
use php_ast::owned::visitor::{walk_owned_expr, OwnedVisitor};
use php_ast::owned::{Expr, ExprKind};
use rustc_hash::FxHashMap;
use std::ops::ControlFlow;
use std::path::{Component, 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)>,
project_psr0_entries: Vec<(String, PathBuf)>,
vendor_psr0_entries: Vec<(String, PathBuf)>,
project_extra_paths: Vec<PathBuf>,
vendor_extra_paths: Vec<PathBuf>,
classmap: FxHashMap<String, PathBuf>,
vendor_eager_files: Vec<PathBuf>,
#[allow(dead_code)] root: PathBuf,
}
fn ensure_trailing_backslash(prefix: &str) -> String {
if prefix.ends_with('\\') {
prefix.to_string()
} else {
format!("{prefix}\\")
}
}
fn collect_prefix_dirs(
value: &serde_json::Value,
prefix: &str,
base: &Path,
entries: &mut Vec<(String, PathBuf)>,
) {
let pfx = ensure_trailing_backslash(prefix);
if let Some(d) = value.as_str() {
entries.push((pfx, base.join(d)));
} else if let Some(arr) = value.as_array() {
for item in arr {
if let Some(d) = item.as_str() {
entries.push((pfx.clone(), base.join(d)));
}
}
}
}
fn collect_prefix_dirs_raw(
value: &serde_json::Value,
prefix: &str,
base: &Path,
entries: &mut Vec<(String, PathBuf)>,
) {
if let Some(d) = value.as_str() {
entries.push((prefix.to_string(), base.join(d)));
} else if let Some(arr) = value.as_array() {
for item in arr {
if let Some(d) = item.as_str() {
entries.push((prefix.to_string(), base.join(d)));
}
}
}
}
fn collect_path_array(value: &serde_json::Value, base: &Path, out: &mut Vec<PathBuf>) {
if let Some(arr) = value.as_array() {
for item in arr {
if let Some(s) = item.as_str() {
out.push(base.join(s));
}
}
}
}
fn parse_autoload_section(
autoload: &serde_json::Value,
base: &Path,
entries: &mut Vec<(String, PathBuf)>,
psr0_entries: &mut Vec<(String, PathBuf)>,
extras: &mut Vec<PathBuf>,
) {
if let Some(map) = autoload.get("psr-4").and_then(|v| v.as_object()) {
for (prefix, dir) in map {
collect_prefix_dirs(dir, prefix, base, entries);
}
}
if let Some(map) = autoload.get("psr-0").and_then(|v| v.as_object()) {
for (prefix, dir) in map {
collect_prefix_dirs_raw(dir, prefix, base, psr0_entries);
if let Some(d) = dir.as_str() {
extras.push(base.join(d));
} else if let Some(arr) = dir.as_array() {
for item in arr {
if let Some(d) = item.as_str() {
extras.push(base.join(d));
}
}
}
}
}
if let Some(cm) = autoload.get("classmap") {
collect_path_array(cm, base, extras);
}
if let Some(files) = autoload.get("files") {
collect_path_array(files, base, extras);
}
}
fn parse_composer_autoload_array(
content: &str,
vendor_dir: &Path,
base_dir: &Path,
) -> Vec<(String, PathBuf)> {
let mut out = Vec::new();
for line in content.lines() {
let line = line.trim();
let (key, rest) = match extract_quoted(line) {
Some(p) => p,
None => continue,
};
let rest = rest.trim_start();
let rest = match rest.strip_prefix("=>") {
Some(r) => r.trim_start(),
None => continue,
};
let (var, rest) = match rest.strip_prefix('$') {
Some(r) => {
let end = r
.find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
.unwrap_or(r.len());
(&r[..end], &r[end..])
}
None => continue,
};
let rest = rest.trim_start();
let rest = match rest.strip_prefix('.') {
Some(r) => r.trim_start(),
None => continue,
};
let (path_frag, _) = match extract_quoted(rest) {
Some(p) => p,
None => continue,
};
let base = match var {
"vendorDir" => vendor_dir,
"baseDir" => base_dir,
_ => continue,
};
let path_rel = path_frag.trim_start_matches('/');
out.push((key, base.join(path_rel)));
}
out
}
fn extract_quoted(s: &str) -> Option<(String, &str)> {
let mut it = s.char_indices();
let (_, quote) = it.next()?;
if quote != '\'' && quote != '"' {
return None;
}
let mut out = String::new();
let mut escape = false;
for (i, ch) in it {
if escape {
out.push(ch);
escape = false;
} else if ch == '\\' {
escape = true;
} else if ch == quote {
return Some((out, &s[i + ch.len_utf8()..]));
} else {
out.push(ch);
}
}
None
}
fn parse_vendor(
root: &Path,
entries: &mut Vec<(String, PathBuf)>,
psr0_entries: &mut Vec<(String, PathBuf)>,
extras: &mut Vec<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,
};
let value: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => {
eprintln!(
"mir: warning: failed to parse {}: {e} (vendor PSR-4 map will be empty)",
installed_path.display()
);
return;
}
};
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;
};
let vendor_dir = root.join("vendor");
for pkg in &packages {
let pkg_name = pkg.get("name").and_then(|v| v.as_str()).unwrap_or("");
let pkg_dir = vendor_dir.join(pkg_name);
if let Some(autoload) = pkg.get("autoload") {
parse_autoload_section(autoload, &pkg_dir, entries, psr0_entries, extras);
}
}
}
fn read_classmap(vendor_dir: &Path, base_dir: &Path) -> FxHashMap<String, PathBuf> {
let path = vendor_dir.join("composer/autoload_classmap.php");
let Ok(bytes) = std::fs::read(&path) else {
return FxHashMap::default();
};
let content = String::from_utf8_lossy(&bytes);
parse_composer_autoload_array(&content, vendor_dir, base_dir)
.into_iter()
.collect()
}
fn read_files_autoload(vendor_dir: &Path, base_dir: &Path) -> Vec<PathBuf> {
let path = vendor_dir.join("composer/autoload_files.php");
if let Ok(bytes) = std::fs::read(&path) {
let content = String::from_utf8_lossy(&bytes);
return parse_composer_autoload_array(&content, vendor_dir, base_dir)
.into_iter()
.map(|(_, p)| p)
.filter(|p| p.is_file())
.collect();
}
let installed_path = vendor_dir.join("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(e) => {
eprintln!(
"mir: warning: failed to parse {}: {e} (autoload.files from vendor will be empty)",
installed_path.display()
);
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 mut out = Vec::new();
for pkg in &packages {
let pkg_name = pkg.get("name").and_then(|v| v.as_str()).unwrap_or("");
let pkg_dir = vendor_dir.join(pkg_name);
if let Some(files) = pkg.get("autoload").and_then(|a| a.get("files")) {
collect_path_array(files, &pkg_dir, &mut out);
}
}
let _ = base_dir; out.into_iter().filter(|p| p.is_file()).collect()
}
fn psr0_logical_path(key: &str) -> PathBuf {
let logical = match key.rfind('\\') {
Some(pos) => {
let (namespace, class_name) = key.split_at(pos + 1);
format!(
"{}{}",
namespace.replace('\\', "/"),
class_name.replace('_', "/")
)
}
None => key.replace('_', "/"),
};
PathBuf::from(logical).with_extension("php")
}
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();
let mut project_psr0_entries: Vec<(String, PathBuf)> = Vec::new();
let mut project_extra_paths: Vec<PathBuf> = Vec::new();
if let Some(autoload) = value.get("autoload") {
parse_autoload_section(
autoload,
root,
&mut project_entries,
&mut project_psr0_entries,
&mut project_extra_paths,
);
}
if let Some(autoload) = value.get("autoload-dev") {
parse_autoload_section(
autoload,
root,
&mut project_entries,
&mut project_psr0_entries,
&mut project_extra_paths,
);
}
project_entries.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
project_psr0_entries.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
let mut vendor_entries: Vec<(String, PathBuf)> = Vec::new();
let mut vendor_psr0_entries: Vec<(String, PathBuf)> = Vec::new();
let mut vendor_extra_paths: Vec<PathBuf> = Vec::new();
parse_vendor(
root,
&mut vendor_entries,
&mut vendor_psr0_entries,
&mut vendor_extra_paths,
);
vendor_entries.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
vendor_psr0_entries.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
let vendor_dir = root.join("vendor");
let classmap = read_classmap(&vendor_dir, root);
let mut vendor_eager_files = read_files_autoload(&vendor_dir, root);
expand_via_local_requires(&mut vendor_eager_files, false);
Ok(Psr4Map {
project_entries,
vendor_entries,
project_psr0_entries,
vendor_psr0_entries,
project_extra_paths,
vendor_extra_paths,
classmap,
vendor_eager_files,
root: root.to_path_buf(),
})
}
pub fn project_files(&self) -> Vec<PathBuf> {
let mut out = Vec::new();
for (_, dir) in &self.project_entries {
crate::batch::collect_php_files(dir, &mut out);
}
for path in &self.project_extra_paths {
collect_php_path(path, &mut out);
}
expand_via_local_requires(&mut out, true);
out
}
pub fn vendor_files(&self) -> Vec<PathBuf> {
let mut out = Vec::new();
for (_, dir) in &self.vendor_entries {
crate::batch::collect_php_files(dir, &mut out);
}
for path in &self.vendor_extra_paths {
collect_php_path(path, &mut out);
}
out
}
pub fn resolve(&self, fqcn: &str) -> Option<PathBuf> {
let key = fqcn.trim_start_matches('\\');
for (prefix, dir) in self
.project_entries
.iter()
.chain(self.vendor_entries.iter())
{
if key.starts_with(prefix.as_str()) {
let relative = &key[prefix.len()..];
let file_path = dir.join(relative.replace('\\', "/")).with_extension("php");
if file_path.exists() {
return Some(file_path);
}
}
}
for (prefix, dir) in self
.project_psr0_entries
.iter()
.chain(self.vendor_psr0_entries.iter())
{
if !prefix.is_empty() && !key.starts_with(prefix.as_str()) {
continue;
}
let file_path = dir.join(psr0_logical_path(key));
if file_path.exists() {
return Some(file_path);
}
}
if let Some(path) = self.classmap.get(key) {
if path.exists() {
return Some(path.clone());
}
}
None
}
pub fn vendor_eager_files(&self) -> Vec<PathBuf> {
self.vendor_eager_files.clone()
}
pub fn all_vendor_files(&self) -> Vec<PathBuf> {
let mut seen: rustc_hash::FxHashSet<PathBuf> = rustc_hash::FxHashSet::default();
let mut out = Vec::new();
let mut push = |p: PathBuf, out: &mut Vec<PathBuf>| {
if seen.insert(p.clone()) {
out.push(p);
}
};
for p in self.vendor_files() {
push(p, &mut out);
}
for p in self.classmap.values() {
if p.is_file() {
push(p.clone(), &mut out);
}
}
for p in self.vendor_eager_files() {
push(p, &mut out);
}
out
}
pub fn classmap_len(&self) -> usize {
self.classmap.len()
}
}
fn collect_php_path(path: &Path, out: &mut Vec<PathBuf>) {
let Ok(meta) = std::fs::metadata(path) else {
return;
};
if meta.is_file() {
if path.extension().and_then(|e| e.to_str()) == Some("php") {
out.push(path.to_path_buf());
}
} else if meta.is_dir() {
crate::batch::collect_php_files(path, out);
}
}
fn expand_via_local_requires(out: &mut Vec<PathBuf>, skip_vendor: bool) {
let mut seen: rustc_hash::FxHashSet<PathBuf> = out.iter().cloned().collect();
let mut queue: Vec<PathBuf> = out.clone();
while let Some(file) = queue.pop() {
let Some(dir) = file.parent() else {
continue;
};
let Ok(text) = std::fs::read_to_string(&file) else {
continue;
};
if !text.contains("require") && !text.contains("include") {
continue;
}
let parsed = php_rs_parser::parse(&text);
let mut scanner = IncludeTargetScanner {
dir,
targets: Vec::new(),
};
let _ = scanner.visit_program(&parsed.program);
for target in scanner.targets {
let resolved = if Path::new(&target).is_absolute() {
PathBuf::from(target)
} else {
dir.join(target)
};
let resolved = lexically_normalize(&resolved);
if resolved.extension().and_then(|e| e.to_str()) != Some("php") {
continue;
}
if skip_vendor && resolved.components().any(|c| c.as_os_str() == "vendor") {
continue;
}
if !resolved.is_file() {
continue;
}
if seen.insert(resolved.clone()) {
out.push(resolved.clone());
queue.push(resolved);
}
}
}
}
fn lexically_normalize(path: &Path) -> PathBuf {
let mut components = path.components().peekable();
let mut ret = if let Some(c @ Component::Prefix(_)) = components.peek().copied() {
components.next();
PathBuf::from(c.as_os_str())
} else {
PathBuf::new()
};
for component in components {
match component {
Component::Prefix(_) => unreachable!(),
Component::RootDir => ret.push(component.as_os_str()),
Component::CurDir => {}
Component::ParentDir => {
ret.pop();
}
Component::Normal(c) => ret.push(c),
}
}
ret
}
struct IncludeTargetScanner<'a> {
dir: &'a Path,
targets: Vec<String>,
}
impl OwnedVisitor for IncludeTargetScanner<'_> {
fn visit_expr(&mut self, expr: &Expr) -> ControlFlow<()> {
if let ExprKind::Include(_, inner) = &expr.kind {
if let Some(target) = resolve_static_include_target(inner, self.dir) {
self.targets.push(target);
}
}
walk_owned_expr(self, expr)
}
}
fn resolve_static_include_target(expr: &Expr, dir: &Path) -> Option<String> {
match &expr.kind {
ExprKind::String(s) => Some(s.to_string()),
ExprKind::MagicConst(MagicConstKind::Dir) => Some(dir.to_string_lossy().into_owned()),
ExprKind::Parenthesized(inner) => resolve_static_include_target(inner, dir),
ExprKind::Binary(b) if b.op == BinaryOp::Concat => {
let left = resolve_static_include_target(&b.left, dir)?;
let right = resolve_static_include_target(&b.right, dir)?;
Some(format!("{left}{right}"))
}
ExprKind::FunctionCall(call) => {
let ExprKind::Identifier(name) = &call.name.kind else {
return None;
};
if !name.eq_ignore_ascii_case("dirname") {
return None;
}
let arg = call.args.first()?;
arg.value
.as_ref()
.is_some_and(|v| matches!(v.kind, ExprKind::MagicConst(MagicConstKind::File)))
.then(|| dir.to_string_lossy().into_owned())
}
_ => 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}"));
let _ = fs::remove_dir_all(&dir);
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 project_files_follows_dir_relative_require() {
let root = make_temp_project("require_dir_relative");
let src = root.join("src");
let legacy = root.join("legacy");
fs::create_dir_all(&src).unwrap();
fs::create_dir_all(&legacy).unwrap();
fs::write(
src.join("Bootstrap.php"),
"<?php require_once __DIR__ . '/../legacy/bootstrap.php'; class Bootstrap {}",
)
.unwrap();
fs::write(
legacy.join("bootstrap.php"),
"<?php function legacy_init() {}",
)
.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(), 2, "expected Bootstrap.php + bootstrap.php");
assert!(files.iter().any(|f| f.ends_with("bootstrap.php")));
}
#[test]
fn project_files_follows_dirname_file_require() {
let root = make_temp_project("require_dirname_file");
let src = root.join("src");
let legacy = root.join("legacy");
fs::create_dir_all(&src).unwrap();
fs::create_dir_all(&legacy).unwrap();
fs::write(
src.join("Bootstrap.php"),
"<?php require_once dirname(__FILE__) . '/../legacy/bootstrap.php'; class Bootstrap {}",
)
.unwrap();
fs::write(
legacy.join("bootstrap.php"),
"<?php function legacy_init() {}",
)
.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!(files.iter().any(|f| f.ends_with("bootstrap.php")));
}
#[test]
fn project_files_follows_bare_literal_require_relative_to_including_file() {
let root = make_temp_project("require_bare_literal");
let src = root.join("src");
let legacy = root.join("legacy");
fs::create_dir_all(&src).unwrap();
fs::create_dir_all(&legacy).unwrap();
fs::write(
src.join("Bootstrap.php"),
"<?php require_once '../legacy/bootstrap.php'; class Bootstrap {}",
)
.unwrap();
fs::write(
legacy.join("bootstrap.php"),
"<?php function legacy_init() {}",
)
.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!(files.iter().any(|f| f.ends_with("bootstrap.php")));
}
#[test]
fn project_files_follows_require_transitively() {
let root = make_temp_project("require_transitive");
let src = root.join("src");
let legacy = root.join("legacy");
fs::create_dir_all(&src).unwrap();
fs::create_dir_all(&legacy).unwrap();
fs::write(
src.join("Bootstrap.php"),
"<?php require_once __DIR__ . '/../legacy/a.php';",
)
.unwrap();
fs::write(
legacy.join("a.php"),
"<?php require_once __DIR__ . '/b.php';",
)
.unwrap();
fs::write(legacy.join("b.php"), "<?php function b() {}").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!(files.iter().any(|f| f.ends_with("a.php")));
assert!(files.iter().any(|f| f.ends_with("b.php")));
}
#[test]
fn project_files_require_cycle_does_not_hang() {
let root = make_temp_project("require_cycle");
let src = root.join("src");
let legacy = root.join("legacy");
fs::create_dir_all(&src).unwrap();
fs::create_dir_all(&legacy).unwrap();
fs::write(
src.join("Bootstrap.php"),
"<?php require_once __DIR__ . '/../legacy/a.php';",
)
.unwrap();
fs::write(
legacy.join("a.php"),
"<?php require_once __DIR__ . '/b.php';",
)
.unwrap();
fs::write(
legacy.join("b.php"),
"<?php require_once __DIR__ . '/a.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(),
3,
"each file discovered exactly once despite the cycle"
);
}
#[test]
fn project_files_missing_require_target_is_skipped() {
let root = make_temp_project("require_missing_target");
let src = root.join("src");
fs::create_dir_all(&src).unwrap();
fs::write(
src.join("Bootstrap.php"),
"<?php require_once __DIR__ . '/../legacy/does_not_exist.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, "only Bootstrap.php itself");
}
#[test]
fn project_files_dynamic_require_target_not_followed() {
let root = make_temp_project("require_dynamic_target");
let src = root.join("src");
let legacy = root.join("legacy");
fs::create_dir_all(&src).unwrap();
fs::create_dir_all(&legacy).unwrap();
fs::write(
src.join("Bootstrap.php"),
"<?php $path = getPath(); require_once $path;",
)
.unwrap();
fs::write(
legacy.join("bootstrap.php"),
"<?php function legacy_init() {}",
)
.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,
"a non-statically-resolvable require target must not be followed"
);
}
#[test]
fn project_files_require_into_vendor_is_skipped() {
let root = make_temp_project("require_into_vendor");
let src = root.join("src");
let vendor_pkg = root.join("vendor/some/pkg");
fs::create_dir_all(&src).unwrap();
fs::create_dir_all(&vendor_pkg).unwrap();
fs::write(
src.join("Bootstrap.php"),
"<?php require_once __DIR__ . '/../vendor/some/pkg/lib.php';",
)
.unwrap();
fs::write(vendor_pkg.join("lib.php"), "<?php function lib() {}").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,
"vendor-reaching require must not be indexed as a project file"
);
}
#[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");
}
#[test]
fn project_classmap_dir_is_collected() {
let root = make_temp_project("project_classmap");
let lib = root.join("lib");
fs::create_dir_all(&lib).unwrap();
fs::write(lib.join("Legacy.php"), "<?php class Legacy {}").unwrap();
fs::write(
root.join("composer.json"),
r#"{"autoload":{"classmap":["lib/"]}}"#,
)
.unwrap();
let map = Psr4Map::from_composer(&root).unwrap();
let files = map.project_files();
assert_eq!(files.len(), 1);
assert!(files[0].ends_with("Legacy.php"));
}
#[test]
fn project_files_autoload_is_collected() {
let root = make_temp_project("project_files_autoload");
fs::write(root.join("helpers.php"), "<?php function my_helper() {}").unwrap();
fs::write(
root.join("composer.json"),
r#"{"autoload":{"files":["helpers.php"]}}"#,
)
.unwrap();
let map = Psr4Map::from_composer(&root).unwrap();
let files = map.project_files();
assert_eq!(files.len(), 1);
assert!(files[0].ends_with("helpers.php"));
}
#[test]
fn project_psr0_dir_is_collected() {
let root = make_temp_project("project_psr0");
let lib = root.join("legacy");
fs::create_dir_all(&lib).unwrap();
fs::write(lib.join("Old.php"), "<?php class Old {}").unwrap();
fs::write(
root.join("composer.json"),
r#"{"autoload":{"psr-0":{"":"legacy/"}}}"#,
)
.unwrap();
let map = Psr4Map::from_composer(&root).unwrap();
let files = map.project_files();
assert_eq!(files.len(), 1);
assert!(files[0].ends_with("Old.php"));
}
#[test]
fn vendor_classmap_is_collected() {
let root = make_temp_project("vendor_classmap");
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": { "classmap": ["src/"] }
}]
}"#,
)
.unwrap();
let pkg_src = root.join("vendor/vendor/pkg/src");
fs::create_dir_all(&pkg_src).unwrap();
fs::write(pkg_src.join("Legacy.php"), "<?php class Legacy {}").unwrap();
let map = Psr4Map::from_composer(&root).unwrap();
let files = map.vendor_files();
assert_eq!(files.len(), 1);
assert!(files[0].ends_with("Legacy.php"));
}
#[test]
fn vendor_files_autoload_is_collected() {
let root = make_temp_project("vendor_files_autoload");
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": { "files": ["bootstrap.php"] }
}]
}"#,
)
.unwrap();
let pkg_dir = root.join("vendor/vendor/pkg");
fs::create_dir_all(&pkg_dir).unwrap();
fs::write(
pkg_dir.join("bootstrap.php"),
"<?php function pkg_bootstrap() {}",
)
.unwrap();
let map = Psr4Map::from_composer(&root).unwrap();
let files = map.vendor_files();
assert_eq!(files.len(), 1);
assert!(files[0].ends_with("bootstrap.php"));
}
#[test]
fn vendor_eager_files_follows_polyfill_bootstrap_require() {
let root = make_temp_project("vendor_eager_files_polyfill");
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/polyfill",
"autoload": { "files": ["bootstrap.php"] }
}]
}"#,
)
.unwrap();
let pkg_dir = root.join("vendor/vendor/polyfill");
fs::create_dir_all(&pkg_dir).unwrap();
fs::write(
pkg_dir.join("bootstrap.php"),
"<?php if (PHP_VERSION_ID >= 80000) { require_once __DIR__ . '/bootstrap80.php'; } \
else { require_once __DIR__ . '/bootstrap_php7.php'; }",
)
.unwrap();
fs::write(
pkg_dir.join("bootstrap80.php"),
"<?php function polyfill_mb_str_split() {}",
)
.unwrap();
fs::write(
pkg_dir.join("bootstrap_php7.php"),
"<?php function polyfill_mb_str_split_legacy() {}",
)
.unwrap();
let map = Psr4Map::from_composer(&root).unwrap();
let files = map.vendor_eager_files();
assert!(
files.iter().any(|p| p.ends_with("bootstrap.php")),
"expected bootstrap.php in {files:?}"
);
assert!(
files.iter().any(|p| p.ends_with("bootstrap80.php")),
"expected the require_once-d sibling bootstrap80.php in {files:?}"
);
assert!(
files.iter().any(|p| p.ends_with("bootstrap_php7.php")),
"expected the require_once-d sibling bootstrap_php7.php in {files:?}"
);
}
#[test]
fn resolve_psr0_project_namespaced_class() {
let root = make_temp_project("resolve_psr0_project_namespaced");
let mailer_dir = root.join("src/Mailer");
fs::create_dir_all(&mailer_dir).unwrap();
fs::write(
mailer_dir.join("Message.php"),
"<?php namespace Mailer; class Message {}",
)
.unwrap();
fs::write(
root.join("composer.json"),
r#"{"autoload":{"psr-0":{"Mailer\\":"src/"}}}"#,
)
.unwrap();
let map = Psr4Map::from_composer(&root).unwrap();
let result = map.resolve("Mailer\\Message");
assert!(result.is_some(), "expected a resolved PSR-0 path");
assert!(result.unwrap().ends_with("Mailer/Message.php"));
}
#[test]
fn resolve_psr0_pear_style_class_with_underscores() {
let root = make_temp_project("resolve_psr0_pear_style");
let old_dir = root.join("src/Old");
fs::create_dir_all(&old_dir).unwrap();
fs::write(old_dir.join("Thing.php"), "<?php class Old_Thing {}").unwrap();
fs::write(
root.join("composer.json"),
r#"{"autoload":{"psr-0":{"Old_":"src/"}}}"#,
)
.unwrap();
let map = Psr4Map::from_composer(&root).unwrap();
let result = map.resolve("Old_Thing");
assert!(result.is_some(), "expected a resolved PSR-0 path");
assert!(result.unwrap().ends_with("Old/Thing.php"));
}
#[test]
fn resolve_psr0_vendor_class() {
let root = make_temp_project("resolve_psr0_vendor");
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-0": { "Old_": "src/" } }
}]
}"#,
)
.unwrap();
let pkg_src = root.join("vendor/vendor/pkg/src/Old");
fs::create_dir_all(&pkg_src).unwrap();
fs::write(pkg_src.join("Thing.php"), "<?php class Old_Thing {}").unwrap();
let map = Psr4Map::from_composer(&root).unwrap();
let result = map.resolve("Old_Thing");
assert!(result.is_some(), "expected a resolved PSR-0 vendor path");
assert!(result.unwrap().ends_with("Old/Thing.php"));
}
#[test]
fn resolve_psr0_prefix_must_still_match() {
let root = make_temp_project("resolve_psr0_prefix_mismatch");
fs::create_dir_all(root.join("src")).unwrap();
fs::write(
root.join("composer.json"),
r#"{"autoload":{"psr-0":{"Mailer\\":"src/"}}}"#,
)
.unwrap();
let map = Psr4Map::from_composer(&root).unwrap();
let result = map.resolve("Other\\Message");
assert!(
result.is_none(),
"Mailer\\ psr-0 prefix must not match Other\\Message"
);
}
#[test]
fn vendor_psr0_is_collected() {
let root = make_temp_project("vendor_psr0");
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-0": { "Old_": "src/" } }
}]
}"#,
)
.unwrap();
let pkg_src = root.join("vendor/vendor/pkg/src/Old");
fs::create_dir_all(&pkg_src).unwrap();
fs::write(pkg_src.join("Thing.php"), "<?php class Old_Thing {}").unwrap();
let map = Psr4Map::from_composer(&root).unwrap();
let files = map.vendor_files();
assert_eq!(files.len(), 1);
assert!(files[0].ends_with("Thing.php"));
}
}