use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
pub use mago_composer::ComposerPackage;
use mago_composer::ComposerPackageExtra;
use crate::types::PhpVersion;
#[derive(Debug, Clone)]
pub struct Psr4Mapping {
pub prefix: String,
pub base_path: String,
}
pub fn parse_composer_json(workspace_root: &Path) -> (Vec<Psr4Mapping>, String) {
let package = match read_composer_package(workspace_root) {
Some(p) => p,
None => return (Vec::new(), "vendor".to_string()),
};
let vendor_dir = get_vendor_dir(&package);
let mappings = extract_psr4_mappings_from_package(&package);
(mappings, vendor_dir)
}
pub fn read_composer_package(workspace_root: &Path) -> Option<ComposerPackage> {
let composer_path = workspace_root.join("composer.json");
let content = fs::read_to_string(&composer_path).ok()?;
content.parse::<ComposerPackage>().ok()
}
pub fn extract_psr4_mappings_from_package(package: &ComposerPackage) -> Vec<Psr4Mapping> {
let mut mappings = Vec::new();
if let Some(autoload) = &package.autoload {
for (prefix, paths) in &autoload.psr_4 {
collect_psr4_entries(prefix, paths, &mut mappings);
}
}
if let Some(autoload_dev) = &package.autoload_dev {
for (prefix, paths) in &autoload_dev.psr_4 {
collect_psr4_entries(prefix, paths, &mut mappings);
}
}
mappings.sort_by(|a, b| b.prefix.len().cmp(&a.prefix.len()));
mappings
}
pub struct ScanDirs {
pub psr4: Vec<(String, String)>,
pub classmap: Vec<String>,
}
pub fn extract_scan_dirs(package: &ComposerPackage) -> ScanDirs {
let mut psr4 = Vec::new();
let mut classmap = Vec::new();
if let Some(autoload) = &package.autoload {
for (prefix, paths) in &autoload.psr_4 {
let normalised = normalise_prefix(prefix);
paths.for_each_path(|dir| {
psr4.push((normalised.clone(), dir.to_owned()));
});
}
classmap.extend(autoload.classmap.iter().cloned());
}
if let Some(autoload_dev) = &package.autoload_dev {
for (prefix, paths) in &autoload_dev.psr_4 {
let normalised = normalise_prefix(prefix);
paths.for_each_path(|dir| {
psr4.push((normalised.clone(), dir.to_owned()));
});
}
classmap.extend(autoload_dev.classmap.iter().cloned());
}
ScanDirs { psr4, classmap }
}
pub(crate) fn get_vendor_dir(package: &ComposerPackage) -> String {
package
.config
.as_ref()
.and_then(|c| c.vendor_dir.as_deref())
.map(|s| s.trim_end_matches('/').to_string())
.unwrap_or_else(|| "vendor".to_string())
}
pub(crate) fn get_bin_dir(package: &ComposerPackage) -> String {
if let Some(explicit) = package.config.as_ref().and_then(|c| c.bin_dir.as_deref()) {
explicit.trim_end_matches('/').to_string()
} else {
format!("{}/bin", get_vendor_dir(package))
}
}
pub fn parse_autoload_classmap(
workspace_root: &Path,
vendor_dir: &str,
) -> HashMap<String, PathBuf> {
let autoload_path = workspace_root
.join(vendor_dir)
.join("composer")
.join("autoload_classmap.php");
let content = match fs::read_to_string(&autoload_path) {
Ok(c) => c,
Err(_) => return HashMap::new(),
};
let mut classmap = HashMap::new();
for line in content.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix('\'')
&& let Some(arrow_pos) = rest.find("' => ")
{
let class_name = rest[..arrow_pos]
.replace("\\\\'", "'")
.replace("\\\\", "\\");
let rhs = rest[arrow_pos + "' => ".len()..]
.trim()
.trim_end_matches(',');
if let Some(relative_path) = resolve_autoload_path_entry(rhs, vendor_dir) {
classmap.insert(class_name, workspace_root.join(&relative_path));
}
}
}
classmap
}
fn resolve_autoload_path_entry(entry: &str, vendor_dir: &str) -> Option<String> {
let entry = entry.trim();
if let Some(rest) = entry.strip_prefix("$vendorDir . '") {
let path = rest.strip_suffix('\'')?;
let path = path.strip_prefix('/').unwrap_or(path);
Some(format!("{}/{}", vendor_dir, path))
} else if let Some(rest) = entry.strip_prefix("$baseDir . '") {
let path = rest.strip_suffix('\'')?;
let path = path.strip_prefix('/').unwrap_or(path);
Some(path.to_string())
} else {
None
}
}
pub fn parse_autoload_files(workspace_root: &Path, vendor_dir: &str) -> Vec<PathBuf> {
let autoload_path = workspace_root
.join(vendor_dir)
.join("composer")
.join("autoload_files.php");
let content = match fs::read_to_string(&autoload_path) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let mut files = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if let Some(arrow_pos) = trimmed.find("=> ") {
let rhs = trimmed[arrow_pos + 3..].trim().trim_end_matches(',');
if let Some(base_path) = resolve_autoload_path_entry(rhs, vendor_dir) {
let full_path = workspace_root.join(&base_path);
if full_path.is_file() {
files.push(full_path);
}
}
}
}
files
}
trait Psr4Paths {
fn for_each_path(&self, f: impl FnMut(&str));
}
macro_rules! impl_psr4_paths {
($ty:ty) => {
impl Psr4Paths for $ty {
fn for_each_path(&self, mut f: impl FnMut(&str)) {
match self {
Self::String(s) => f(s),
Self::Array(arr) => arr.iter().for_each(|s| f(s)),
}
}
}
};
}
impl_psr4_paths!(mago_composer::AutoloadPsr4value);
impl_psr4_paths!(mago_composer::ComposerPackageAutoloadDevPsr4value);
fn normalise_prefix(prefix: &str) -> String {
if prefix.ends_with('\\') {
prefix.to_string()
} else if prefix.is_empty() {
String::new()
} else {
format!("{}\\", prefix)
}
}
fn collect_psr4_entries<P: Psr4Paths>(prefix: &str, paths: &P, mappings: &mut Vec<Psr4Mapping>) {
let normalised_prefix = normalise_prefix(prefix);
paths.for_each_path(|path| {
mappings.push(Psr4Mapping {
prefix: normalised_prefix.clone(),
base_path: normalise_path(path),
});
});
}
pub fn normalise_path(path: &str) -> String {
let p = path.replace('\\', "/");
if p.ends_with('/') || p.is_empty() {
p
} else {
format!("{}/", p)
}
}
pub fn resolve_class_path(
mappings: &[Psr4Mapping],
workspace_root: &Path,
class_name: &str,
) -> Option<PathBuf> {
let name = class_name;
if crate::php_type::is_keyword_type(name) {
return None;
}
for mapping in mappings {
let relative = if mapping.prefix.is_empty() {
Some(name)
} else {
name.strip_prefix(&mapping.prefix)
};
if let Some(relative_class) = relative {
let relative_path = relative_class.replace('\\', "/");
let file_path = workspace_root
.join(&mapping.base_path)
.join(format!("{}.php", relative_path));
if file_path.is_file() {
return Some(file_path);
}
}
}
None
}
pub fn extract_require_once_paths(content: &str) -> Vec<String> {
let mut paths = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if !trimmed.starts_with("require_once") {
continue;
}
let rest = trimmed["require_once".len()..].trim_start();
let rest = if let Some(inner) = rest.strip_prefix('(') {
if let Some(end) = inner.rfind(')') {
inner[..end].trim()
} else {
continue;
}
} else {
rest
};
let rest = rest.trim_end_matches(';').trim();
let path = if (rest.starts_with('\'') && rest.ends_with('\''))
|| (rest.starts_with('"') && rest.ends_with('"'))
{
&rest[1..rest.len() - 1]
} else {
continue;
};
if !path.is_empty() {
paths.push(path.to_string());
}
}
paths
}
pub fn parse_autoload_namespaces(
workspace_root: &Path,
vendor_dir: &str,
) -> HashMap<String, PathBuf> {
let autoload_path = workspace_root
.join(vendor_dir)
.join("composer")
.join("autoload_namespaces.php");
let content = match fs::read_to_string(&autoload_path) {
Ok(c) => c,
Err(_) => return HashMap::new(),
};
let mut base_dirs: Vec<PathBuf> = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if !trimmed.contains("=>") {
continue;
}
let rhs = if let Some(pos) = trimmed.find("=> array(") {
&trimmed[pos + "=> array(".len()..]
} else if let Some(pos) = trimmed.find("=> [") {
&trimmed[pos + "=> [".len()..]
} else {
continue;
};
let rhs =
rhs.trim_end_matches(|c: char| c == ')' || c == ']' || c == ',' || c.is_whitespace());
for segment in rhs.split(',') {
let segment = segment.trim();
if let Some(relative_path) = resolve_autoload_path_entry(segment, vendor_dir) {
let full_path = workspace_root.join(&relative_path);
if full_path.is_dir() {
base_dirs.push(full_path);
}
}
}
}
let mut classmap = HashMap::new();
for base_dir in &base_dirs {
scan_directory_for_classes(base_dir, &mut classmap);
}
classmap
}
fn scan_directory_for_classes(dir: &Path, classmap: &mut HashMap<String, PathBuf>) {
let entries = match fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
scan_directory_for_classes(&path, classmap);
} else if path.extension().is_some_and(|ext| ext == "php")
&& let Ok(content) = fs::read(&path)
{
let classes = crate::classmap_scanner::find_classes(&content);
for fqn in classes {
classmap.entry(fqn).or_insert_with(|| path.clone());
}
}
}
}
pub fn detect_phar_references(content: &str, file_dir: &Path) -> Vec<PathBuf> {
let mut phars = Vec::new();
let mut seen = std::collections::HashSet::new();
for line in content.lines() {
if !line.contains("__DIR__") || !line.contains(".phar") {
continue;
}
for quote in [b'\'', b'"'] {
let bytes = line.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == quote {
if let Some(end) = bytes[i + 1..].iter().position(|&b| b == quote) {
let fragment = &line[i + 1..i + 1 + end];
if let Some(phar_end) = fragment.find(".phar") {
let rel = &fragment[..phar_end + 5]; let rel = rel.trim_start_matches('/');
if !rel.is_empty() {
let phar_path = file_dir.join(rel);
if phar_path.is_file() && seen.insert(phar_path.clone()) {
phars.push(phar_path);
}
}
}
i += 1 + end + 1;
} else {
i += 1;
}
} else {
i += 1;
}
}
}
}
phars
}
pub fn discover_subproject_roots(workspace_root: &Path) -> Vec<(PathBuf, String)> {
use ignore::WalkBuilder;
use std::collections::HashSet;
let mut candidates: Vec<PathBuf> = Vec::new();
let walker = WalkBuilder::new(workspace_root)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.hidden(true)
.parents(true)
.ignore(true)
.sort_by_file_name(|a, b| a.cmp(b))
.build();
for entry in walker.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
if path.file_name().and_then(|n| n.to_str()) != Some("composer.json") {
continue;
}
if let Some(parent) = path.parent() {
if parent == workspace_root {
continue;
}
candidates.push(parent.to_path_buf());
}
}
candidates.sort_by_key(|p| p.as_os_str().len());
let mut roots: Vec<(PathBuf, String)> = Vec::new();
let mut root_set: HashSet<PathBuf> = HashSet::new();
for candidate in &candidates {
let is_nested = root_set.iter().any(|root| candidate.starts_with(root));
if is_nested {
continue;
}
let vendor_dir = read_composer_package(candidate)
.map(|pkg| get_vendor_dir(&pkg))
.unwrap_or_else(|| "vendor".to_string());
root_set.insert(candidate.clone());
roots.push((candidate.clone(), vendor_dir));
}
roots
}
pub fn detect_php_version(workspace_root: &Path) -> Option<PhpVersion> {
let package = read_composer_package(workspace_root)?;
detect_php_version_from_package(&package)
}
pub fn detect_php_version_from_package(package: &ComposerPackage) -> Option<PhpVersion> {
if let Some(platform_val) = package.config.as_ref().and_then(|c| c.platform.get("php"))
&& let mago_composer::ComposerPackageConfigPlatformValue::String(s) = platform_val
&& let Some(ver) = PhpVersion::from_composer_constraint(s)
{
return Some(ver);
}
if let Some(require_php) = package.require.get("php")
&& let Some(ver) = PhpVersion::from_composer_constraint(require_php)
{
return Some(ver);
}
None
}
pub(crate) fn has_require_dev(package: &ComposerPackage, dep: &str) -> bool {
package.require_dev.contains_key(dep)
}
pub(crate) fn detect_drupal_web_root(
workspace_root: &Path,
package: &ComposerPackage,
) -> Option<PathBuf> {
const DRUPAL_PACKAGES: &[&str] = &["drupal/core", "drupal/core-recommended", "drupal/core-dev"];
let is_drupal = DRUPAL_PACKAGES
.iter()
.any(|pkg| package.require.contains_key(*pkg) || package.require_dev.contains_key(*pkg));
if !is_drupal {
return None;
}
if let Some(ComposerPackageExtra::Object(extra_map)) = &package.extra
&& let Some(scaffold) = extra_map.get("drupal-scaffold")
&& let Some(web_root_str) = scaffold
.get("locations")
.and_then(|l| l.get("web-root"))
.and_then(|v| v.as_str())
{
let path = workspace_root.join(web_root_str.trim_end_matches('/'));
return Some(path);
}
for candidate in &["web", "docroot", "public", "html"] {
let path = workspace_root.join(candidate);
if path.join("core/lib/Drupal.php").exists() {
return Some(path);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn pkg(json: &str) -> ComposerPackage {
json.parse::<ComposerPackage>().unwrap()
}
#[test]
fn vendor_dir_default() {
assert_eq!(get_vendor_dir(&pkg("{}")), "vendor");
}
#[test]
fn vendor_dir_explicit() {
let p = pkg(r#"{"config": {"vendor-dir": "lib"}}"#);
assert_eq!(get_vendor_dir(&p), "lib");
}
#[test]
fn vendor_dir_strips_trailing_slash() {
let p = pkg(r#"{"config": {"vendor-dir": "lib/"}}"#);
assert_eq!(get_vendor_dir(&p), "lib");
}
#[test]
fn bin_dir_default_follows_vendor_dir() {
assert_eq!(get_bin_dir(&pkg("{}")), "vendor/bin");
}
#[test]
fn bin_dir_default_with_custom_vendor_dir() {
let p = pkg(r#"{"config": {"vendor-dir": "lib"}}"#);
assert_eq!(get_bin_dir(&p), "lib/bin");
}
#[test]
fn bin_dir_explicit() {
let p = pkg(r#"{"config": {"bin-dir": "bin"}}"#);
assert_eq!(get_bin_dir(&p), "bin");
}
#[test]
fn bin_dir_explicit_overrides_vendor_dir() {
let p = pkg(r#"{"config": {"vendor-dir": "lib", "bin-dir": "tools/bin"}}"#);
assert_eq!(get_bin_dir(&p), "tools/bin");
}
#[test]
fn bin_dir_strips_trailing_slash() {
let p = pkg(r#"{"config": {"bin-dir": "bin/"}}"#);
assert_eq!(get_bin_dir(&p), "bin");
}
#[test]
fn drupal_not_detected_without_drupal_packages() {
let dir = tempfile::tempdir().unwrap();
let p = pkg(r#"{"require": {"php": "^8.1", "some/package": "^1.0"}}"#);
assert!(detect_drupal_web_root(dir.path(), &p).is_none());
}
#[test]
fn drupal_detected_via_drupal_core() {
let dir = tempfile::tempdir().unwrap();
let web = dir.path().join("web").join("core").join("lib");
std::fs::create_dir_all(&web).unwrap();
std::fs::write(web.join("Drupal.php"), "<?php\nclass Drupal {}").unwrap();
let p = pkg(r#"{"require": {"drupal/core": "^10.0"}}"#);
let result = detect_drupal_web_root(dir.path(), &p);
assert_eq!(result, Some(dir.path().join("web")));
}
#[test]
fn drupal_detected_via_core_recommended_in_require_dev() {
let dir = tempfile::tempdir().unwrap();
let docroot = dir.path().join("docroot").join("core").join("lib");
std::fs::create_dir_all(&docroot).unwrap();
std::fs::write(docroot.join("Drupal.php"), "<?php").unwrap();
let p = pkg(r#"{"require-dev": {"drupal/core-recommended": "^10.0"}}"#);
let result = detect_drupal_web_root(dir.path(), &p);
assert_eq!(result, Some(dir.path().join("docroot")));
}
#[test]
fn drupal_detected_via_core_dev() {
let dir = tempfile::tempdir().unwrap();
let web = dir.path().join("web").join("core").join("lib");
std::fs::create_dir_all(&web).unwrap();
std::fs::write(web.join("Drupal.php"), "<?php").unwrap();
let p = pkg(r#"{"require-dev": {"drupal/core-dev": "^10.0"}}"#);
let result = detect_drupal_web_root(dir.path(), &p);
assert_eq!(result, Some(dir.path().join("web")));
}
#[test]
fn drupal_web_root_from_scaffold_config() {
let dir = tempfile::tempdir().unwrap();
let p = pkg(r#"{
"require": {"drupal/core": "^10.0"},
"extra": {
"drupal-scaffold": {
"locations": {
"web-root": "docroot/"
}
}
}
}"#);
let result = detect_drupal_web_root(dir.path(), &p);
assert_eq!(result, Some(dir.path().join("docroot")));
}
#[test]
fn drupal_scaffold_config_takes_priority_over_filesystem() {
let dir = tempfile::tempdir().unwrap();
let web = dir.path().join("web").join("core").join("lib");
std::fs::create_dir_all(&web).unwrap();
std::fs::write(web.join("Drupal.php"), "<?php").unwrap();
let p = pkg(r#"{
"require": {"drupal/core": "^10.0"},
"extra": {
"drupal-scaffold": {
"locations": {
"web-root": "custom_root"
}
}
}
}"#);
let result = detect_drupal_web_root(dir.path(), &p);
assert_eq!(result, Some(dir.path().join("custom_root")));
}
#[test]
fn drupal_filesystem_fallback_tries_candidates_in_order() {
let dir = tempfile::tempdir().unwrap();
let public = dir.path().join("public").join("core").join("lib");
std::fs::create_dir_all(&public).unwrap();
std::fs::write(public.join("Drupal.php"), "<?php").unwrap();
let p = pkg(r#"{"require": {"drupal/core": "^10.0"}}"#);
let result = detect_drupal_web_root(dir.path(), &p);
assert_eq!(result, Some(dir.path().join("public")));
}
#[test]
fn drupal_returns_none_when_no_web_root_found() {
let dir = tempfile::tempdir().unwrap();
let p = pkg(r#"{"require": {"drupal/core": "^10.0"}}"#);
assert!(detect_drupal_web_root(dir.path(), &p).is_none());
}
}