aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-autoload/src
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-22 00:37:54 +0900
committernsfisis <nsfisis@gmail.com>2026-02-22 00:37:54 +0900
commit0a8e5935e6305819bb02d8c69e2f046ff397913a (patch)
treee5a288e679477b1603d7989e986ca22bbe590aa4 /crates/mozart-autoload/src
parentb5af594fec7da72b15c9a202c641af0494db6355 (diff)
downloadphp-mozart-0a8e5935e6305819bb02d8c69e2f046ff397913a.tar.gz
php-mozart-0a8e5935e6305819bb02d8c69e2f046ff397913a.tar.zst
php-mozart-0a8e5935e6305819bb02d8c69e2f046ff397913a.zip
refactor(workspace): split monolithic crate into 6 workspace crates
Extract modules from the single `mozart` crate into 5 focused library crates to improve compilation parallelism and architectural clarity: - mozart-constraint: version constraint parser (independent) - mozart-core: base types, console, validation, platform utilities - mozart-archiver: archive creation (tar, zip, bzip2) - mozart-registry: Packagist API, cache, resolver, downloader, lockfile - mozart-autoload: autoloader generation and PHP scanner Refactor Console::from_cli and build_cache_config to accept primitive args instead of &Cli to break circular dependencies. Introduce [workspace.dependencies] for centralized version management. Remove 9 unused direct dependencies from the CLI crate. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart-autoload/src')
-rw-r--r--crates/mozart-autoload/src/autoload.rs1801
-rw-r--r--crates/mozart-autoload/src/lib.rs2
-rw-r--r--crates/mozart-autoload/src/php_scanner.rs629
3 files changed, 2432 insertions, 0 deletions
diff --git a/crates/mozart-autoload/src/autoload.rs b/crates/mozart-autoload/src/autoload.rs
new file mode 100644
index 0000000..2e4158c
--- /dev/null
+++ b/crates/mozart-autoload/src/autoload.rs
@@ -0,0 +1,1801 @@
+use mozart_registry::installed::InstalledPackages;
+use mozart_registry::lockfile::LockedPackage;
+use std::collections::{BTreeMap, HashSet};
+use std::path::{Path, PathBuf};
+
+// Embed Composer PHP files from the submodule at compile time.
+const CLASSLOADER_PHP: &str =
+ include_str!("../../../composer/src/Composer/Autoload/ClassLoader.php");
+const INSTALLED_VERSIONS_PHP: &str =
+ include_str!("../../../composer/src/Composer/InstalledVersions.php");
+const COMPOSER_LICENSE: &str = include_str!("../../../composer/LICENSE");
+
+/// How platform requirements are checked during autoloader generation.
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+pub enum PlatformCheckMode {
+ /// Check all platform requirements (php, ext-*, lib-*).
+ #[default]
+ Full,
+ /// Only check the PHP version requirement.
+ PhpOnly,
+ /// Disable platform requirement checks entirely.
+ Disabled,
+}
+
+/// Configuration for autoload generation.
+pub struct AutoloadConfig {
+ /// Absolute path to the project root (where composer.json lives).
+ pub project_dir: PathBuf,
+ /// Absolute path to the vendor directory.
+ pub vendor_dir: PathBuf,
+ /// Whether dev-mode autoloading is active (include autoload-dev rules).
+ pub dev_mode: bool,
+ /// Unique suffix for the autoloader class names (typically the lock file content-hash).
+ /// Used to generate `ComposerAutoloaderInit{suffix}` and `ComposerStaticInit{suffix}`.
+ pub suffix: String,
+ /// When true, emit `$loader->setClassMapAuthoritative(true)` in the generated autoloader.
+ pub classmap_authoritative: bool,
+ /// When true, scan PSR-4/PSR-0 directories and generate a full classmap (optimize mode).
+ pub optimize: bool,
+ /// When true, generate APCu-based class caching in the autoloader.
+ pub apcu: bool,
+ /// Optional prefix for APCu cache keys (implies `apcu`).
+ pub apcu_prefix: Option<String>,
+ /// When true, return an error on PSR mapping violations detected during classmap scan.
+ pub strict_psr: bool,
+ /// How to handle platform requirement checks.
+ pub platform_check: PlatformCheckMode,
+ /// When true, skip all platform requirement checks.
+ pub ignore_platform_reqs: bool,
+}
+
+/// Collected autoload mappings from all packages.
+pub struct AutoloadData {
+ /// PSR-4: namespace prefix -> list of directory path expressions.
+ /// Each path is a PHP expression string like `$vendorDir . '/psr/log/src'`.
+ pub psr4: BTreeMap<String, Vec<String>>,
+ /// PSR-0: namespace prefix -> list of directory path expressions.
+ /// (Empty in Phase 2.2, populated in 5.6.)
+ pub psr0: BTreeMap<String, Vec<String>>,
+ /// Classmap entries: class name -> file path expression.
+ /// (Empty in Phase 2.2, populated in 5.6.)
+ pub classmap: BTreeMap<String, String>,
+ /// Files to include on every request: file_identifier -> path expression.
+ pub files: BTreeMap<String, String>,
+}
+
+/// Escape a string for use in a PHP single-quoted string literal.
+pub fn php_escape(s: &str) -> String {
+ s.replace('\\', "\\\\").replace('\'', "\\'")
+}
+
+/// Compute the file identifier matching Composer's `getFileIdentifier()`.
+/// This is the MD5 hex digest of `"package_name:path"`.
+pub fn file_identifier(package_name: &str, path: &str) -> String {
+ let input = format!("{package_name}:{path}");
+ format!("{:x}", md5::compute(input.as_bytes()))
+}
+
+/// Extract a path or array of paths from a JSON value.
+/// Handles both string and array-of-strings (Composer allows both).
+fn json_to_paths(value: &serde_json::Value) -> Vec<String> {
+ match value {
+ serde_json::Value::String(s) => vec![s.clone()],
+ serde_json::Value::Array(arr) => arr
+ .iter()
+ .filter_map(|v| v.as_str().map(|s| s.to_string()))
+ .collect(),
+ _ => vec![],
+ }
+}
+
+/// Strip trailing slash from a path component.
+fn strip_trailing_slash(s: &str) -> &str {
+ s.trim_end_matches('/')
+}
+
+/// Normalize a PSR-4 namespace: ensure it ends with `\`.
+/// (The empty string "" is valid and is left as-is.)
+fn normalize_namespace(ns: &str) -> String {
+ if ns.is_empty() || ns.ends_with('\\') {
+ ns.to_string()
+ } else {
+ format!("{ns}\\")
+ }
+}
+
+/// Build a PHP path expression from a base expression and a relative path component.
+///
+/// For vendor packages: `base_expr` = `"$vendorDir"`, `pkg_path` = `"psr/log"`,
+/// `sub_path` = `"src/"` → result: `"$vendorDir . '/psr/log/src'"`.
+///
+/// For root packages: `base_expr` = `"$baseDir"`, `pkg_path` = `""`,
+/// `sub_path` = `"src/"` → result: `"$baseDir . '/src'"`.
+fn build_path_expr(base_expr: &str, pkg_path: &str, sub_path: &str) -> String {
+ let sub = strip_trailing_slash(sub_path);
+ let combined = if pkg_path.is_empty() {
+ sub.to_string()
+ } else if sub.is_empty() {
+ pkg_path.to_string()
+ } else {
+ format!("{pkg_path}/{sub}")
+ };
+
+ if combined.is_empty() {
+ base_expr.to_string()
+ } else {
+ format!("{base_expr} . '/{combined}'")
+ }
+}
+
+/// Process an autoload JSON value and merge its rules into `data`.
+///
+/// `pkg_path` is the package-relative path segment within vendor.
+/// For vendor packages it is `"vendor/name"` (e.g. `"psr/log"`).
+/// For the root package it is `""`.
+///
+/// `dyn_base` is the dynamic PHP variable: `"$vendorDir"` or `"$baseDir"`.
+/// `static_base` is the static PHP expression: `"__DIR__ . '/..'"` or `"__DIR__ . '/../.'"`.
+fn process_autoload_value(
+ autoload_val: &serde_json::Value,
+ package_name: &str,
+ pkg_path: &str,
+ dyn_base: &str,
+ static_base: &str,
+ data: &mut AutoloadData,
+ static_data: &mut AutoloadData,
+) {
+ // PSR-4
+ if let Some(psr4_obj) = autoload_val.get("psr-4").and_then(|v| v.as_object()) {
+ for (ns_raw, paths_val) in psr4_obj {
+ let ns = normalize_namespace(ns_raw);
+ let paths = json_to_paths(paths_val);
+ let entry = data.psr4.entry(ns.clone()).or_default();
+ let static_entry = static_data.psr4.entry(ns).or_default();
+ for path in paths {
+ entry.push(build_path_expr(dyn_base, pkg_path, &path));
+ static_entry.push(build_path_expr(static_base, pkg_path, &path));
+ }
+ }
+ }
+
+ // PSR-0
+ if let Some(psr0_obj) = autoload_val.get("psr-0").and_then(|v| v.as_object()) {
+ for (ns_raw, paths_val) in psr0_obj {
+ let ns = ns_raw.clone();
+ let paths = json_to_paths(paths_val);
+ let entry = data.psr0.entry(ns.clone()).or_default();
+ let static_entry = static_data.psr0.entry(ns).or_default();
+ for path in paths {
+ entry.push(build_path_expr(dyn_base, pkg_path, &path));
+ static_entry.push(build_path_expr(static_base, pkg_path, &path));
+ }
+ }
+ }
+
+ // Files
+ if let Some(files_arr) = autoload_val.get("files").and_then(|v| v.as_array()) {
+ for file_val in files_arr {
+ if let Some(file_path) = file_val.as_str() {
+ let id = file_identifier(package_name, file_path);
+ let expr = build_path_expr(dyn_base, pkg_path, file_path);
+ let static_expr = build_path_expr(static_base, pkg_path, file_path);
+ data.files.insert(id.clone(), expr);
+ static_data.files.insert(id, static_expr);
+ }
+ }
+ }
+}
+
+/// Collect autoload rules from all installed packages and the root package.
+///
+/// Returns a tuple of `(dynamic_data, static_data)` where:
+/// - `dynamic_data` uses `$vendorDir` / `$baseDir` path expressions (for autoload_psr4.php, etc.)
+/// - `static_data` uses `__DIR__ . '/..'` path expressions (for autoload_static.php)
+fn collect_autoloads(
+ installed: &InstalledPackages,
+ root_autoload: Option<&serde_json::Value>,
+ root_autoload_dev: Option<&serde_json::Value>,
+ root_package_name: &str,
+ dev_mode: bool,
+) -> (AutoloadData, AutoloadData) {
+ let mut data = AutoloadData {
+ psr4: BTreeMap::new(),
+ psr0: BTreeMap::new(),
+ classmap: BTreeMap::new(),
+ files: BTreeMap::new(),
+ };
+ let mut static_data = AutoloadData {
+ psr4: BTreeMap::new(),
+ psr0: BTreeMap::new(),
+ classmap: BTreeMap::new(),
+ files: BTreeMap::new(),
+ };
+
+ // Process each installed package
+ for pkg in &installed.packages {
+ if let Some(autoload_val) = &pkg.autoload {
+ process_autoload_value(
+ autoload_val,
+ &pkg.name,
+ &pkg.name, // pkg_path within vendor
+ "$vendorDir",
+ "__DIR__ . '/..'",
+ &mut data,
+ &mut static_data,
+ );
+ }
+ }
+
+ // Process root package autoload
+ if let Some(autoload_val) = root_autoload {
+ process_autoload_value(
+ autoload_val,
+ root_package_name,
+ "", // no pkg_path for root
+ "$baseDir",
+ "__DIR__ . '/../..'",
+ &mut data,
+ &mut static_data,
+ );
+ }
+
+ // Process root package autoload-dev (only in dev mode)
+ if dev_mode && let Some(autoload_dev_val) = root_autoload_dev {
+ process_autoload_value(
+ autoload_dev_val,
+ root_package_name,
+ "",
+ "$baseDir",
+ "__DIR__ . '/../..'",
+ &mut data,
+ &mut static_data,
+ );
+ }
+
+ (data, static_data)
+}
+
+/// Generate `vendor/composer/autoload_psr4.php`.
+fn generate_autoload_psr4(data: &AutoloadData) -> String {
+ let mut out = String::new();
+ out.push_str("<?php\n\n// autoload_psr4.php @generated by Composer\n\n");
+ out.push_str("$vendorDir = dirname(__DIR__);\n");
+ out.push_str("$baseDir = dirname($vendorDir);\n\n");
+ out.push_str("return array(\n");
+
+ // krsort: reverse alphabetical (longer/more specific namespaces first)
+ let mut sorted: Vec<(&String, &Vec<String>)> = data.psr4.iter().collect();
+ sorted.sort_by(|(a, _), (b, _)| b.cmp(a));
+
+ for (ns, paths) in &sorted {
+ let escaped_ns = php_escape(ns);
+ if paths.len() == 1 {
+ out.push_str(&format!(" '{}' => array({}),\n", escaped_ns, paths[0]));
+ } else {
+ out.push_str(&format!(" '{}' => array(\n", escaped_ns));
+ for path in paths.iter() {
+ out.push_str(&format!(" {},\n", path));
+ }
+ out.push_str(" ),\n");
+ }
+ }
+
+ out.push_str(");\n");
+ out
+}
+
+/// Generate `vendor/composer/autoload_namespaces.php` (PSR-0, empty for Phase 2.2).
+fn generate_autoload_namespaces(data: &AutoloadData) -> String {
+ let mut out = String::new();
+ out.push_str("<?php\n\n// autoload_namespaces.php @generated by Composer\n\n");
+ out.push_str("$vendorDir = dirname(__DIR__);\n");
+ out.push_str("$baseDir = dirname($vendorDir);\n\n");
+ out.push_str("return array(\n");
+
+ let mut sorted: Vec<(&String, &Vec<String>)> = data.psr0.iter().collect();
+ sorted.sort_by(|(a, _), (b, _)| b.cmp(a));
+
+ for (ns, paths) in &sorted {
+ let escaped_ns = php_escape(ns);
+ if paths.len() == 1 {
+ out.push_str(&format!(" '{}' => array({}),\n", escaped_ns, paths[0]));
+ } else {
+ out.push_str(&format!(" '{}' => array(\n", escaped_ns));
+ for path in paths.iter() {
+ out.push_str(&format!(" {},\n", path));
+ }
+ out.push_str(" ),\n");
+ }
+ }
+
+ out.push_str(");\n");
+ out
+}
+
+/// Generate `vendor/composer/autoload_classmap.php`.
+/// Always contains `Composer\InstalledVersions`; classmap scanning deferred to Phase 5.6.
+fn generate_autoload_classmap(data: &AutoloadData) -> String {
+ let mut out = String::new();
+ out.push_str("<?php\n\n// autoload_classmap.php @generated by Composer\n\n");
+ out.push_str("$vendorDir = dirname(__DIR__);\n");
+ out.push_str("$baseDir = dirname($vendorDir);\n\n");
+ out.push_str("return array(\n");
+ out.push_str(
+ " 'Composer\\\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',\n",
+ );
+
+ // Include any additional classmap entries from data
+ for (class, path) in &data.classmap {
+ let escaped_class = php_escape(class);
+ out.push_str(&format!(" '{}' => {},\n", escaped_class, path));
+ }
+
+ out.push_str(");\n");
+ out
+}
+
+/// Generate `vendor/composer/autoload_files.php`.
+/// Returns `None` if there are no files to autoload.
+fn generate_autoload_files(data: &AutoloadData) -> Option<String> {
+ if data.files.is_empty() {
+ return None;
+ }
+
+ let mut out = String::new();
+ out.push_str("<?php\n\n// autoload_files.php @generated by Composer\n\n");
+ out.push_str("$vendorDir = dirname(__DIR__);\n");
+ out.push_str("$baseDir = dirname($vendorDir);\n\n");
+ out.push_str("return array(\n");
+
+ for (id, path) in &data.files {
+ out.push_str(&format!(" '{}' => {},\n", id, path));
+ }
+
+ out.push_str(");\n");
+ Some(out)
+}
+
+/// Generate `vendor/composer/autoload_static.php`.
+///
+/// `static_data` must have been collected with `__DIR__ . '/..'` path prefixes.
+fn generate_autoload_static(static_data: &AutoloadData, suffix: &str) -> String {
+ let mut out = String::new();
+ out.push_str("<?php\n\n// autoload_static.php @generated by Composer\n\n");
+ out.push_str("namespace Composer\\Autoload;\n\n");
+ out.push_str(&format!("class ComposerStaticInit{suffix}\n{{\n"));
+
+ // $files
+ if !static_data.files.is_empty() {
+ out.push_str(" public static $files = array (\n");
+ for (id, path) in &static_data.files {
+ out.push_str(&format!(" '{id}' => {path},\n"));
+ }
+ out.push_str(" );\n\n");
+ }
+
+ // $prefixLengthsPsr4 — group by first character of namespace
+ if !static_data.psr4.is_empty() {
+ // Group namespaces by first character, sorted reverse
+ let mut by_char: BTreeMap<char, Vec<(&String, usize)>> = BTreeMap::new();
+
+ let mut sorted_ns: Vec<&String> = static_data.psr4.keys().collect();
+ sorted_ns.sort_by(|a, b| b.cmp(a));
+
+ for ns in sorted_ns {
+ if let Some(first_char) = ns.chars().next() {
+ // The byte length in PHP (single-quoted string with single backslashes)
+ // ns in our data uses single backslash (stored as-is from JSON).
+ let byte_len = ns.len();
+ by_char.entry(first_char).or_default().push((ns, byte_len));
+ }
+ }
+
+ out.push_str(" public static $prefixLengthsPsr4 = array (\n");
+ // Sort characters in reverse order too
+ let mut chars: Vec<char> = by_char.keys().copied().collect();
+ chars.sort_by(|a, b| b.cmp(a));
+ for ch in &chars {
+ out.push_str(&format!(" '{ch}' =>\n array (\n"));
+ if let Some(entries) = by_char.get(ch) {
+ for (ns, len) in entries {
+ let escaped_ns = php_escape(ns);
+ out.push_str(&format!(" '{escaped_ns}' => {len},\n"));
+ }
+ }
+ out.push_str(" ),\n");
+ }
+ out.push_str(" );\n\n");
+
+ // $prefixDirsPsr4
+ out.push_str(" public static $prefixDirsPsr4 = array (\n");
+ let mut sorted_ns2: Vec<(&String, &Vec<String>)> = static_data.psr4.iter().collect();
+ sorted_ns2.sort_by(|(a, _), (b, _)| b.cmp(a));
+ for (ns, paths) in sorted_ns2 {
+ let escaped_ns = php_escape(ns);
+ out.push_str(&format!(" '{escaped_ns}' =>\n array (\n"));
+ for (i, path) in paths.iter().enumerate() {
+ out.push_str(&format!(" {i} => {path},\n"));
+ }
+ out.push_str(" ),\n");
+ }
+ out.push_str(" );\n\n");
+ }
+
+ // $classMap — always contains Composer\InstalledVersions
+ out.push_str(" public static $classMap = array (\n");
+ out.push_str(
+ " 'Composer\\\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',\n",
+ );
+ for (class, path) in &static_data.classmap {
+ let escaped_class = php_escape(class);
+ out.push_str(&format!(" '{}' => {},\n", escaped_class, path));
+ }
+ out.push_str(" );\n\n");
+
+ // getInitializer
+ out.push_str(" public static function getInitializer(ClassLoader $loader)\n {\n");
+ out.push_str(" return \\Closure::bind(function () use ($loader) {\n");
+
+ if !static_data.psr4.is_empty() {
+ out.push_str(&format!(
+ " $loader->prefixLengthsPsr4 = ComposerStaticInit{suffix}::$prefixLengthsPsr4;\n"
+ ));
+ out.push_str(&format!(
+ " $loader->prefixDirsPsr4 = ComposerStaticInit{suffix}::$prefixDirsPsr4;\n"
+ ));
+ }
+ out.push_str(&format!(
+ " $loader->classMap = ComposerStaticInit{suffix}::$classMap;\n"
+ ));
+ out.push_str("\n }, null, ClassLoader::class);\n }\n}\n");
+
+ out
+}
+
+/// Recursively collect PHP files from a directory, skipping excluded paths.
+fn collect_php_files(
+ dir: &Path,
+ excluded: &[String],
+ vendor_dir: &Path,
+ project_dir: &Path,
+) -> Vec<PathBuf> {
+ let mut result = Vec::new();
+ if !dir.is_dir() {
+ return result;
+ }
+ collect_php_files_inner(dir, excluded, vendor_dir, project_dir, &mut result);
+ result
+}
+
+fn collect_php_files_inner(
+ dir: &Path,
+ excluded: &[String],
+ vendor_dir: &Path,
+ project_dir: &Path,
+ result: &mut Vec<PathBuf>,
+) {
+ let entries = match std::fs::read_dir(dir) {
+ Ok(e) => e,
+ Err(_) => return,
+ };
+ for entry in entries.flatten() {
+ let path = entry.path();
+
+ // Check if path matches any excluded pattern
+ if is_excluded(&path, excluded, vendor_dir, project_dir) {
+ continue;
+ }
+
+ if path.is_dir() {
+ collect_php_files_inner(&path, excluded, vendor_dir, project_dir, result);
+ } else if crate::php_scanner::is_php_ext(&path) {
+ result.push(path);
+ }
+ }
+}
+
+/// Check whether a path matches any of the excluded patterns.
+fn is_excluded(path: &Path, excluded: &[String], vendor_dir: &Path, project_dir: &Path) -> bool {
+ for exc in excluded {
+ // Excluded patterns can be relative to project_dir or absolute
+ let exc_path = if Path::new(exc).is_absolute() {
+ PathBuf::from(exc)
+ } else {
+ project_dir.join(exc)
+ };
+ if path.starts_with(&exc_path) || path == exc_path {
+ return true;
+ }
+ // Also check relative to vendor_dir
+ let exc_vendor = vendor_dir.join(exc);
+ if path.starts_with(&exc_vendor) || path == exc_vendor {
+ return true;
+ }
+ }
+ false
+}
+
+/// Scan directories for PHP class declarations and return a classmap.
+///
+/// `dirs` is a list of absolute directory paths to scan.
+/// Returns a `BTreeMap<class_name, file_path_expression>` where the path expression
+/// uses `$vendorDir` or `$baseDir` as appropriate.
+fn scan_classmap_dirs(
+ dirs: &[PathBuf],
+ vendor_dir: &Path,
+ project_dir: &Path,
+ excluded: &[String],
+) -> BTreeMap<String, String> {
+ let mut classmap = BTreeMap::new();
+
+ for dir in dirs {
+ let files = collect_php_files(dir, excluded, vendor_dir, project_dir);
+ for file in files {
+ match crate::php_scanner::find_classes(&file) {
+ Ok(classes) => {
+ for class in classes {
+ let path_expr = path_to_php_expr(&file, vendor_dir, project_dir);
+ classmap.entry(class).or_insert(path_expr);
+ }
+ }
+ Err(_) => continue,
+ }
+ }
+ }
+
+ classmap
+}
+
+/// Convert an absolute file path to a PHP path expression using `$vendorDir` or `$baseDir`.
+fn path_to_php_expr(file: &Path, vendor_dir: &Path, project_dir: &Path) -> String {
+ if let Ok(rel) = file.strip_prefix(vendor_dir) {
+ let rel_str = rel.to_string_lossy().replace('\\', "/");
+ format!("$vendorDir . '/{rel_str}'")
+ } else if let Ok(rel) = file.strip_prefix(project_dir) {
+ let rel_str = rel.to_string_lossy().replace('\\', "/");
+ format!("$baseDir . '/{rel_str}'")
+ } else {
+ // Fall back to absolute path
+ let abs = file.to_string_lossy().replace('\\', "/");
+ format!("'{abs}'")
+ }
+}
+
+/// Convert an absolute file path to a static PHP path expression using `__DIR__ . '/..` form.
+fn path_to_static_expr(file: &Path, vendor_dir: &Path, project_dir: &Path) -> String {
+ if let Ok(rel) = file.strip_prefix(vendor_dir) {
+ let rel_str = rel.to_string_lossy().replace('\\', "/");
+ format!("__DIR__ . '/..' . '/{rel_str}'")
+ } else if let Ok(rel) = file.strip_prefix(project_dir) {
+ let rel_str = rel.to_string_lossy().replace('\\', "/");
+ format!("__DIR__ . '/../..' . '/{rel_str}'")
+ } else {
+ let abs = file.to_string_lossy().replace('\\', "/");
+ format!("'{abs}'")
+ }
+}
+
+/// Scan PSR-4 and PSR-0 directories for class declarations (used in optimize mode).
+///
+/// Returns `(dynamic_classmap, static_classmap, psr_violations)`.
+fn scan_psr_for_classmap(
+ psr4: &BTreeMap<String, Vec<String>>,
+ psr0: &BTreeMap<String, Vec<String>>,
+ vendor_dir: &Path,
+ project_dir: &Path,
+ excluded: &[String],
+) -> (
+ BTreeMap<String, String>,
+ BTreeMap<String, String>,
+ Vec<String>,
+) {
+ let mut dyn_map: BTreeMap<String, String> = BTreeMap::new();
+ let mut static_map: BTreeMap<String, String> = BTreeMap::new();
+ let mut violations: Vec<String> = Vec::new();
+
+ // Helper: resolve a PHP path expression to an absolute path.
+ let resolve = |expr: &str| -> Option<PathBuf> {
+ // Expressions look like:
+ // $vendorDir . '/psr/log/src'
+ // $baseDir . '/src'
+ // __DIR__ . '/..' . '/psr/log/src'
+ // __DIR__ . '/../..' . '/src'
+ if let Some(rest) = expr.strip_prefix("$vendorDir . '") {
+ let rel = rest.trim_end_matches('\'');
+ Some(vendor_dir.join(rel.trim_start_matches('/')))
+ } else if let Some(rest) = expr.strip_prefix("$baseDir . '") {
+ let rel = rest.trim_end_matches('\'');
+ Some(project_dir.join(rel.trim_start_matches('/')))
+ } else if expr == "$vendorDir" {
+ Some(vendor_dir.to_path_buf())
+ } else if expr == "$baseDir" {
+ Some(project_dir.to_path_buf())
+ } else {
+ None
+ }
+ };
+
+ // Scan PSR-4 dirs
+ for (ns, paths) in psr4 {
+ for path_expr in paths {
+ if let Some(abs_dir) = resolve(path_expr) {
+ let files = collect_php_files(&abs_dir, excluded, vendor_dir, project_dir);
+ for file in files {
+ match crate::php_scanner::find_classes(&file) {
+ Ok(classes) => {
+ for class in classes {
+ // PSR-4 validation
+ let file_str = file.to_string_lossy();
+ let dir_str = abs_dir.to_string_lossy();
+ let base_ns = ns.as_str();
+ if !crate::php_scanner::validate_psr4_class(
+ &class, base_ns, &file_str, &dir_str,
+ ) {
+ violations.push(format!(
+ "Class {class} in {file_str} does not comply with PSR-4 (namespace prefix: {ns})"
+ ));
+ }
+ let dyn_expr = path_to_php_expr(&file, vendor_dir, project_dir);
+ let static_expr =
+ path_to_static_expr(&file, vendor_dir, project_dir);
+ dyn_map.entry(class.clone()).or_insert(dyn_expr);
+ static_map.entry(class).or_insert(static_expr);
+ }
+ }
+ Err(_) => continue,
+ }
+ }
+ }
+ }
+ }
+
+ // Scan PSR-0 dirs
+ for (ns, paths) in psr0 {
+ for path_expr in paths {
+ if let Some(abs_dir) = resolve(path_expr) {
+ let files = collect_php_files(&abs_dir, excluded, vendor_dir, project_dir);
+ for file in files {
+ match crate::php_scanner::find_classes(&file) {
+ Ok(classes) => {
+ for class in classes {
+ let file_str = file.to_string_lossy();
+ let dir_str = abs_dir.to_string_lossy();
+ if !crate::php_scanner::validate_psr0_class(
+ &class, &file_str, &dir_str,
+ ) {
+ violations.push(format!(
+ "Class {class} in {file_str} does not comply with PSR-0 (namespace prefix: {ns})"
+ ));
+ }
+ let dyn_expr = path_to_php_expr(&file, vendor_dir, project_dir);
+ let static_expr =
+ path_to_static_expr(&file, vendor_dir, project_dir);
+ dyn_map.entry(class.clone()).or_insert(dyn_expr);
+ static_map.entry(class).or_insert(static_expr);
+ }
+ }
+ Err(_) => continue,
+ }
+ }
+ }
+ }
+ }
+
+ (dyn_map, static_map, violations)
+}
+
+/// Generate `vendor/composer/platform_check.php`.
+///
+/// Returns `None` if mode is `Disabled` or there are no relevant requirements.
+fn generate_platform_check(
+ packages: &[LockedPackage],
+ root_require: Option<&serde_json::Value>,
+ mode: &PlatformCheckMode,
+ dev_package_names: &HashSet<String>,
+) -> Option<String> {
+ if matches!(mode, PlatformCheckMode::Disabled) {
+ return None;
+ }
+
+ // Collect PHP version constraint from root require
+ let mut php_constraint: Option<String> = None;
+ if let Some(req_obj) = root_require.and_then(|v| v.as_object())
+ && let Some(v) = req_obj.get("php").and_then(|v| v.as_str())
+ {
+ php_constraint = Some(v.to_string());
+ }
+
+ // Collect extension requirements from packages (prod only)
+ let mut ext_reqs: Vec<(String, String)> = Vec::new();
+ if matches!(mode, PlatformCheckMode::Full) {
+ for pkg in packages {
+ let is_dev = dev_package_names.contains(&pkg.name.to_lowercase());
+ if is_dev {
+ continue;
+ }
+ for (req_name, req_constraint) in &pkg.require {
+ let lower = req_name.to_lowercase();
+ if lower.starts_with("ext-") {
+ ext_reqs.push((req_name.clone(), req_constraint.clone()));
+ }
+ }
+ }
+ ext_reqs.sort();
+ ext_reqs.dedup();
+ }
+
+ if php_constraint.is_none() && ext_reqs.is_empty() {
+ return None;
+ }
+
+ let mut out = String::new();
+ out.push_str("<?php\n\n");
+ out.push_str("// platform_check.php @generated by Composer\n\n");
+ out.push_str("$issues = array();\n\n");
+
+ if let Some(ref constraint) = php_constraint {
+ // Emit a simple PHP version check
+ let escaped = php_escape(constraint);
+ out.push_str(&format!("// PHP version check: {constraint}\n"));
+ out.push_str("if (!(PHP_VERSION_ID >= 50600)) {\n");
+ out.push_str(&format!(
+ " $issues[] = 'Your Composer dependencies require a PHP version \"{escaped}\". You are running ' . PHP_VERSION . '.';\n"
+ ));
+ out.push_str("}\n\n");
+ }
+
+ for (ext_name, _constraint) in &ext_reqs {
+ let ext_short = ext_name.trim_start_matches("ext-");
+ let escaped_ext = php_escape(ext_short);
+ out.push_str(&format!("if (!extension_loaded('{escaped_ext}')) {{\n"));
+ out.push_str(&format!(
+ " $issues[] = 'Your Composer dependencies require the \"{escaped_ext}\" PHP extension to be installed.';\n"
+ ));
+ out.push_str("}\n\n");
+ }
+
+ out.push_str("if ($issues) {\n");
+ out.push_str(" if (!headers_sent()) {\n");
+ out.push_str(" header('HTTP/1.1 500 Internal Server Error');\n");
+ out.push_str(" }\n");
+ out.push_str(" if (!ini_get('display_errors')) {\n");
+ out.push_str(" if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {\n");
+ out.push_str(" fwrite(STDERR, 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL);\n");
+ out.push_str(" } elseif (!headers_sent()) {\n");
+ out.push_str(" echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL;\n");
+ out.push_str(" }\n");
+ out.push_str(" }\n");
+ out.push_str(" trigger_error(\n");
+ out.push_str(
+ " 'Composer detected issues in your platform: ' . implode(' ', $issues),\n",
+ );
+ out.push_str(" E_USER_ERROR\n");
+ out.push_str(" );\n");
+ out.push_str("}\n");
+
+ Some(out)
+}
+
+/// Generate `vendor/composer/autoload_real.php`.
+fn generate_autoload_real(
+ suffix: &str,
+ has_files: bool,
+ classmap_authoritative: bool,
+ apcu: bool,
+ apcu_prefix: Option<&str>,
+ has_platform_check: bool,
+) -> String {
+ let mut out = String::new();
+ out.push_str("<?php\n\n");
+ out.push_str("// autoload_real.php @generated by Composer\n\n");
+ out.push_str(&format!("class ComposerAutoloaderInit{suffix}\n"));
+ out.push_str("{\n");
+ out.push_str(" private static $loader;\n\n");
+ out.push_str(" public static function loadClassLoader($class)\n");
+ out.push_str(" {\n");
+ out.push_str(" if ('Composer\\Autoload\\ClassLoader' === $class) {\n");
+ out.push_str(" require __DIR__ . '/ClassLoader.php';\n");
+ out.push_str(" }\n");
+ out.push_str(" }\n\n");
+ out.push_str(" /**\n");
+ out.push_str(" * @return \\Composer\\Autoload\\ClassLoader\n");
+ out.push_str(" */\n");
+ out.push_str(" public static function getLoader()\n");
+ out.push_str(" {\n");
+ out.push_str(" if (null !== self::$loader) {\n");
+ out.push_str(" return self::$loader;\n");
+ out.push_str(" }\n\n");
+ out.push_str(&format!(
+ " spl_autoload_register(array('ComposerAutoloaderInit{suffix}', 'loadClassLoader'), true, true);\n"
+ ));
+ out.push_str(
+ " self::$loader = $loader = new \\Composer\\Autoload\\ClassLoader(\\dirname(__DIR__));\n",
+ );
+ out.push_str(&format!(
+ " spl_autoload_unregister(array('ComposerAutoloaderInit{suffix}', 'loadClassLoader'));\n\n"
+ ));
+ if has_platform_check {
+ out.push_str(" require __DIR__ . '/platform_check.php';\n");
+ }
+ out.push_str(" require __DIR__ . '/autoload_static.php';\n");
+ out.push_str(&format!(
+ " call_user_func(\\Composer\\Autoload\\ComposerStaticInit{suffix}::getInitializer($loader));\n\n"
+ ));
+ out.push_str(" $loader->register(true);\n");
+
+ if classmap_authoritative {
+ out.push_str(" $loader->setClassMapAuthoritative(true);\n");
+ }
+
+ if apcu {
+ let prefix = apcu_prefix.unwrap_or(suffix);
+ let escaped = php_escape(prefix);
+ out.push_str(&format!(" $loader->setApcuPrefix('{escaped}');\n"));
+ }
+
+ if has_files {
+ out.push('\n');
+ out.push_str(&format!(
+ " $filesToLoad = \\Composer\\Autoload\\ComposerStaticInit{suffix}::$files;\n"
+ ));
+ out.push_str(
+ " $requireFile = \\Closure::bind(static function ($fileIdentifier, $file) {\n",
+ );
+ out.push_str(
+ " if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) {\n",
+ );
+ out.push_str(
+ " $GLOBALS['__composer_autoload_files'][$fileIdentifier] = true;\n",
+ );
+ out.push('\n');
+ out.push_str(" require $file;\n");
+ out.push_str(" }\n");
+ out.push_str(" }, null, null);\n");
+ out.push_str(" foreach ($filesToLoad as $fileIdentifier => $file) {\n");
+ out.push_str(" $requireFile($fileIdentifier, $file);\n");
+ out.push_str(" }\n");
+ }
+
+ out.push('\n');
+ out.push_str(" return $loader;\n");
+ out.push_str(" }\n");
+ out.push_str("}\n");
+ out
+}
+
+/// Generate `vendor/autoload.php` (the entry point).
+fn generate_autoload_php(suffix: &str) -> String {
+ let mut out = String::new();
+ out.push_str("<?php\n\n");
+ out.push_str("// autoload.php @generated by Composer\n\n");
+ out.push_str("if (PHP_VERSION_ID < 50600) {\n");
+ out.push_str(" if (!headers_sent()) {\n");
+ out.push_str(" header('HTTP/1.1 500 Internal Server Error');\n");
+ out.push_str(" }\n");
+ out.push_str(" $err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via \"composer self-update --2.2\". Aborting.'.PHP_EOL;\n");
+ out.push_str(" if (!ini_get('display_errors')) {\n");
+ out.push_str(" if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {\n");
+ out.push_str(" fwrite(STDERR, $err);\n");
+ out.push_str(" } elseif (!headers_sent()) {\n");
+ out.push_str(" echo $err;\n");
+ out.push_str(" }\n");
+ out.push_str(" }\n");
+ out.push_str(" throw new RuntimeException($err);\n");
+ out.push_str("}\n\n");
+ out.push_str("require_once __DIR__ . '/composer/autoload_real.php';\n\n");
+ out.push_str(&format!(
+ "return ComposerAutoloaderInit{suffix}::getLoader();\n"
+ ));
+ out
+}
+
+/// Generate `vendor/composer/installed.php`.
+fn generate_installed_php(
+ root_name: &str,
+ root_type: &str,
+ installed: &InstalledPackages,
+ dev_mode: bool,
+) -> String {
+ let dev_str = if dev_mode { "true" } else { "false" };
+
+ let mut out = String::new();
+ out.push_str("<?php return array(\n");
+ out.push_str(" 'root' => array(\n");
+ out.push_str(&format!(" 'name' => '{}',\n", php_escape(root_name)));
+ out.push_str(" 'pretty_version' => 'dev-main',\n");
+ out.push_str(" 'version' => 'dev-main',\n");
+ out.push_str(" 'reference' => null,\n");
+ out.push_str(&format!(" 'type' => '{}',\n", php_escape(root_type)));
+ out.push_str(" 'install_path' => __DIR__ . '/../../',\n");
+ out.push_str(" 'aliases' => array(),\n");
+ out.push_str(&format!(" 'dev' => {dev_str},\n"));
+ out.push_str(" ),\n");
+ out.push_str(" 'versions' => array(\n");
+
+ for pkg in &installed.packages {
+ let version = &pkg.version;
+ let version_normalized = pkg.version_normalized.as_deref().unwrap_or(version);
+ let pkg_type = pkg.package_type.as_deref().unwrap_or("library");
+ let is_dev = installed
+ .dev_package_names
+ .iter()
+ .any(|n| n.eq_ignore_ascii_case(&pkg.name));
+ let is_dev_str = if is_dev { "true" } else { "false" };
+
+ out.push_str(&format!(" '{}' => array(\n", php_escape(&pkg.name)));
+ out.push_str(&format!(
+ " 'pretty_version' => '{}',\n",
+ php_escape(version)
+ ));
+ out.push_str(&format!(
+ " 'version' => '{}',\n",
+ php_escape(version_normalized)
+ ));
+ out.push_str(" 'reference' => null,\n");
+ out.push_str(&format!(
+ " 'type' => '{}',\n",
+ php_escape(pkg_type)
+ ));
+ // Install path relative to vendor/composer/installed.php: __DIR__ . '/./' . relative_name
+ // The install_path stored is like '../psr/log', relative to vendor/composer/
+ // So from vendor/composer/, the package is at __DIR__ . '/../psr/log/'
+ out.push_str(&format!(
+ " 'install_path' => __DIR__ . '/../{}/',\n",
+ pkg.name
+ ));
+ out.push_str(" 'aliases' => array(),\n");
+ out.push_str(&format!(" 'dev_requirement' => {is_dev_str},\n"));
+ out.push_str(" ),\n");
+ }
+
+ out.push_str(" ),\n");
+ out.push_str(");\n");
+ out
+}
+
+/// Determine the autoloader suffix.
+///
+/// Priority:
+/// 1. Existing `vendor/autoload.php` suffix (carry over to avoid breaking existing references).
+/// 2. Lock file `content-hash` (if locked).
+/// 3. Fall back to a timestamp-based hex string.
+pub fn determine_suffix(working_dir: &Path, vendor_dir: &Path) -> anyhow::Result<String> {
+ // Try existing autoload.php
+ let autoload_path = vendor_dir.join("autoload.php");
+ if autoload_path.exists() {
+ let content = std::fs::read_to_string(&autoload_path)?;
+ if let Some(start) = content.find("ComposerAutoloaderInit") {
+ let rest = &content[start + "ComposerAutoloaderInit".len()..];
+ if let Some(end) = rest.find("::") {
+ let suffix = &rest[..end];
+ if !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_hexdigit()) {
+ return Ok(suffix.to_string());
+ }
+ }
+ }
+ }
+
+ // Try composer.lock content-hash
+ let lock_path = working_dir.join("composer.lock");
+ if lock_path.exists() {
+ let lock = mozart_registry::lockfile::LockFile::read_from_file(&lock_path)?;
+ return Ok(lock.content_hash);
+ }
+
+ // Fall back to MD5 of current timestamp
+ let ts = format!("{:?}", std::time::SystemTime::now());
+ Ok(format!("{:x}", md5::compute(ts.as_bytes())))
+}
+
+/// Generate all autoloader files for the given project.
+///
+/// This is the main entry point called by `install` and `dump-autoload`.
+pub fn generate(config: &AutoloadConfig) -> anyhow::Result<()> {
+ // 1. Read installed.json
+ let installed = InstalledPackages::read(&config.vendor_dir)?;
+
+ // 2. Read root package autoload from composer.json
+ let composer_json_path = config.project_dir.join("composer.json");
+ let (root_autoload, root_autoload_dev, root_name, root_type) = if composer_json_path.exists() {
+ let content = std::fs::read_to_string(&composer_json_path)?;
+ let value: serde_json::Value = serde_json::from_str(&content)?;
+ (
+ value.get("autoload").cloned(),
+ value.get("autoload-dev").cloned(),
+ value
+ .get("name")
+ .and_then(|n| n.as_str())
+ .unwrap_or("__root__")
+ .to_string(),
+ value
+ .get("type")
+ .and_then(|t| t.as_str())
+ .unwrap_or("project")
+ .to_string(),
+ )
+ } else {
+ (None, None, "__root__".to_string(), "project".to_string())
+ };
+
+ // 3. Collect autoload data
+ let (mut data, mut static_data) = collect_autoloads(
+ &installed,
+ root_autoload.as_ref(),
+ root_autoload_dev.as_ref(),
+ &root_name,
+ config.dev_mode,
+ );
+
+ // 3a. Read classmap dirs declared in composer.json
+ let excluded: Vec<String> = root_autoload
+ .as_ref()
+ .and_then(|v| v.get("exclude-from-classmap"))
+ .and_then(|v| v.as_array())
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|v| v.as_str().map(|s| s.to_string()))
+ .collect()
+ })
+ .unwrap_or_default();
+
+ // Scan explicit classmap dirs from all packages
+ let mut classmap_dirs: Vec<PathBuf> = Vec::new();
+
+ // Collect classmap dirs from installed packages
+ for pkg in &installed.packages {
+ if let Some(autoload_val) = &pkg.autoload
+ && let Some(cm_arr) = autoload_val.get("classmap").and_then(|v| v.as_array())
+ {
+ for cm_val in cm_arr {
+ if let Some(cm_path) = cm_val.as_str() {
+ let abs = config.vendor_dir.join(&pkg.name).join(cm_path);
+ classmap_dirs.push(abs);
+ }
+ }
+ }
+ }
+
+ // Collect classmap dirs from root autoload
+ if let Some(autoload_val) = root_autoload.as_ref()
+ && let Some(cm_arr) = autoload_val.get("classmap").and_then(|v| v.as_array())
+ {
+ for cm_val in cm_arr {
+ if let Some(cm_path) = cm_val.as_str() {
+ let abs = config.project_dir.join(cm_path);
+ classmap_dirs.push(abs);
+ }
+ }
+ }
+
+ // Scan classmap dirs
+ if !classmap_dirs.is_empty() {
+ let scanned = scan_classmap_dirs(
+ &classmap_dirs,
+ &config.vendor_dir,
+ &config.project_dir,
+ &excluded,
+ );
+ for (class, path_expr) in scanned {
+ // Also generate the static expression
+ // We store the dynamic expression in data.classmap; static_data.classmap
+ // will be populated similarly. For now we insert into both.
+ data.classmap.entry(class.clone()).or_insert(path_expr);
+ // Generate corresponding static expr by replacing dynamic prefixes
+ // (static_data classmap is populated in the static pass below)
+ }
+ }
+
+ // 3b. Optimize mode: scan PSR-4/PSR-0 dirs for classmap
+ let do_optimize = config.optimize || config.classmap_authoritative;
+ let mut psr_violations: Vec<String> = Vec::new();
+
+ if do_optimize {
+ let (opt_dyn, opt_static, violations) = scan_psr_for_classmap(
+ &data.psr4,
+ &data.psr0,
+ &config.vendor_dir,
+ &config.project_dir,
+ &excluded,
+ );
+ psr_violations = violations;
+ for (class, path_expr) in opt_dyn {
+ data.classmap.entry(class).or_insert(path_expr);
+ }
+ for (class, path_expr) in opt_static {
+ static_data.classmap.entry(class).or_insert(path_expr);
+ }
+ }
+
+ // 3c. Handle strict-psr violations
+ if config.strict_psr && !psr_violations.is_empty() {
+ for violation in &psr_violations {
+ eprintln!("PSR violation: {violation}");
+ }
+ return Err(anyhow::anyhow!(
+ "PSR mapping violations detected (--strict-psr). Run without --strict-psr to ignore."
+ ));
+ }
+
+ // 4. Generate and write files
+ let composer_dir = config.vendor_dir.join("composer");
+ std::fs::create_dir_all(&composer_dir)?;
+
+ std::fs::write(
+ composer_dir.join("autoload_psr4.php"),
+ generate_autoload_psr4(&data),
+ )?;
+ std::fs::write(
+ composer_dir.join("autoload_namespaces.php"),
+ generate_autoload_namespaces(&data),
+ )?;
+ std::fs::write(
+ composer_dir.join("autoload_classmap.php"),
+ generate_autoload_classmap(&data),
+ )?;
+
+ if let Some(files_content) = generate_autoload_files(&data) {
+ std::fs::write(composer_dir.join("autoload_files.php"), files_content)?;
+ } else {
+ // Remove stale file if it exists
+ let files_path = composer_dir.join("autoload_files.php");
+ if files_path.exists() {
+ std::fs::remove_file(files_path)?;
+ }
+ }
+
+ // 4a. Generate platform_check.php if needed
+ let dev_package_names_set: HashSet<String> = installed
+ .dev_package_names
+ .iter()
+ .map(|n| n.to_lowercase())
+ .collect();
+
+ // Re-read composer.json for root require (not from autoload, but from root "require" key)
+ let root_require_val: Option<serde_json::Value> = if composer_json_path.exists() {
+ let content = std::fs::read_to_string(&composer_json_path)?;
+ let value: serde_json::Value = serde_json::from_str(&content)?;
+ value.get("require").cloned()
+ } else {
+ None
+ };
+
+ let all_locked: Vec<LockedPackage> = {
+ // Collect locked packages from installed for platform check
+ // (installed.packages are LockedPackage-compatible via InstalledPackageEntry)
+ // We'll build minimal LockedPackage-like data from installed entries
+ installed
+ .packages
+ .iter()
+ .map(|p| mozart_registry::lockfile::LockedPackage {
+ name: p.name.clone(),
+ version: p.version.clone(),
+ version_normalized: p.version_normalized.clone(),
+ source: None,
+ dist: None,
+ require: std::collections::BTreeMap::new(),
+ require_dev: std::collections::BTreeMap::new(),
+ conflict: std::collections::BTreeMap::new(),
+ suggest: None,
+ package_type: p.package_type.clone(),
+ autoload: p.autoload.clone(),
+ autoload_dev: None,
+ license: None,
+ description: None,
+ homepage: None,
+ keywords: None,
+ authors: None,
+ support: None,
+ funding: None,
+ time: None,
+ extra_fields: std::collections::BTreeMap::new(),
+ })
+ .collect()
+ };
+
+ let effective_mode = if config.ignore_platform_reqs {
+ PlatformCheckMode::Disabled
+ } else {
+ config.platform_check.clone()
+ };
+
+ let platform_check_content = generate_platform_check(
+ &all_locked,
+ root_require_val.as_ref(),
+ &effective_mode,
+ &dev_package_names_set,
+ );
+ let has_platform_check = platform_check_content.is_some();
+
+ if let Some(content) = platform_check_content {
+ std::fs::write(composer_dir.join("platform_check.php"), content)?;
+ } else {
+ let pc_path = composer_dir.join("platform_check.php");
+ if pc_path.exists() {
+ std::fs::remove_file(pc_path)?;
+ }
+ }
+
+ let has_files = !data.files.is_empty();
+ let use_apcu = config.apcu || config.apcu_prefix.is_some();
+ std::fs::write(
+ composer_dir.join("autoload_static.php"),
+ generate_autoload_static(&static_data, &config.suffix),
+ )?;
+ std::fs::write(
+ composer_dir.join("autoload_real.php"),
+ generate_autoload_real(
+ &config.suffix,
+ has_files,
+ config.classmap_authoritative,
+ use_apcu,
+ config.apcu_prefix.as_deref(),
+ has_platform_check,
+ ),
+ )?;
+ std::fs::write(
+ config.vendor_dir.join("autoload.php"),
+ generate_autoload_php(&config.suffix),
+ )?;
+
+ // 5. Copy ClassLoader.php, InstalledVersions.php, LICENSE
+ std::fs::write(composer_dir.join("ClassLoader.php"), CLASSLOADER_PHP)?;
+ std::fs::write(
+ composer_dir.join("InstalledVersions.php"),
+ INSTALLED_VERSIONS_PHP,
+ )?;
+ std::fs::write(composer_dir.join("LICENSE"), COMPOSER_LICENSE)?;
+
+ // 6. Generate installed.php
+ std::fs::write(
+ composer_dir.join("installed.php"),
+ generate_installed_php(&root_name, &root_type, &installed, config.dev_mode),
+ )?;
+
+ Ok(())
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Tests
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use mozart_registry::installed::{InstalledPackageEntry, InstalledPackages};
+ use std::collections::BTreeMap;
+ use tempfile::tempdir;
+
+ fn make_installed_pkg(name: &str, version: &str) -> InstalledPackageEntry {
+ InstalledPackageEntry {
+ name: name.to_string(),
+ version: version.to_string(),
+ version_normalized: None,
+ source: None,
+ dist: None,
+ package_type: Some("library".to_string()),
+ install_path: Some(format!("../{name}")),
+ autoload: None,
+ aliases: vec![],
+ extra_fields: BTreeMap::new(),
+ }
+ }
+
+ fn make_installed_pkg_with_autoload(
+ name: &str,
+ version: &str,
+ autoload: serde_json::Value,
+ ) -> InstalledPackageEntry {
+ let mut entry = make_installed_pkg(name, version);
+ entry.autoload = Some(autoload);
+ entry
+ }
+
+ // -------------------------------------------------------------------------
+ // Helper function tests
+ // -------------------------------------------------------------------------
+
+ #[test]
+ fn test_php_escape_backslash() {
+ assert_eq!(php_escape("Psr\\Log\\"), "Psr\\\\Log\\\\");
+ }
+
+ #[test]
+ fn test_php_escape_quote() {
+ assert_eq!(php_escape("don't"), "don\\'t");
+ }
+
+ #[test]
+ fn test_php_escape_mixed() {
+ assert_eq!(php_escape("A\\B'C"), "A\\\\B\\'C");
+ }
+
+ #[test]
+ fn test_file_identifier_known_vector() {
+ // Known test vector from Composer docs:
+ // md5("symfony/polyfill-php80:bootstrap.php") = "a4a119a56e50fbb293281d9a48007e0e"
+ let id = file_identifier("symfony/polyfill-php80", "bootstrap.php");
+ assert_eq!(id, "a4a119a56e50fbb293281d9a48007e0e");
+ }
+
+ #[test]
+ fn test_file_identifier_format() {
+ let id = file_identifier("psr/log", "src/functions.php");
+ // Should be 32 hex chars (MD5)
+ assert_eq!(id.len(), 32);
+ assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
+ }
+
+ #[test]
+ fn test_json_to_paths_string() {
+ let v = serde_json::json!("src/");
+ assert_eq!(json_to_paths(&v), vec!["src/"]);
+ }
+
+ #[test]
+ fn test_json_to_paths_array() {
+ let v = serde_json::json!(["src/", "lib/"]);
+ assert_eq!(json_to_paths(&v), vec!["src/", "lib/"]);
+ }
+
+ #[test]
+ fn test_json_to_paths_invalid() {
+ let v = serde_json::json!(42);
+ assert!(json_to_paths(&v).is_empty());
+ }
+
+ // -------------------------------------------------------------------------
+ // collect_autoloads tests
+ // -------------------------------------------------------------------------
+
+ #[test]
+ fn test_collect_autoloads_psr4_basic() {
+ let mut installed = InstalledPackages::new();
+ installed.upsert(make_installed_pkg_with_autoload(
+ "psr/log",
+ "3.0.2",
+ serde_json::json!({"psr-4": {"Psr\\Log\\": "src/"}}),
+ ));
+
+ let (data, _static_data) = collect_autoloads(&installed, None, None, "__root__", false);
+
+ assert!(data.psr4.contains_key("Psr\\Log\\"));
+ let paths = &data.psr4["Psr\\Log\\"];
+ assert_eq!(paths.len(), 1);
+ assert_eq!(paths[0], "$vendorDir . '/psr/log/src'");
+ }
+
+ #[test]
+ fn test_collect_autoloads_psr4_multiple_dirs() {
+ let mut installed = InstalledPackages::new();
+ installed.upsert(make_installed_pkg_with_autoload(
+ "monolog/monolog",
+ "3.8.0",
+ serde_json::json!({"psr-4": {"Monolog\\": ["src/Monolog", "lib/"]}}),
+ ));
+
+ let (data, _static_data) = collect_autoloads(&installed, None, None, "__root__", false);
+
+ let paths = &data.psr4["Monolog\\"];
+ assert_eq!(paths.len(), 2);
+ assert_eq!(paths[0], "$vendorDir . '/monolog/monolog/src/Monolog'");
+ assert_eq!(paths[1], "$vendorDir . '/monolog/monolog/lib'");
+ }
+
+ #[test]
+ fn test_collect_autoloads_files() {
+ let mut installed = InstalledPackages::new();
+ installed.upsert(make_installed_pkg_with_autoload(
+ "symfony/polyfill-php80",
+ "1.32.0",
+ serde_json::json!({"files": ["bootstrap.php"]}),
+ ));
+
+ let (data, _static_data) = collect_autoloads(&installed, None, None, "__root__", false);
+
+ // The identifier should match Composer's MD5 computation
+ let expected_id = "a4a119a56e50fbb293281d9a48007e0e";
+ assert!(data.files.contains_key(expected_id));
+ assert_eq!(
+ data.files[expected_id],
+ "$vendorDir . '/symfony/polyfill-php80/bootstrap.php'"
+ );
+ }
+
+ #[test]
+ fn test_collect_autoloads_root_package() {
+ let installed = InstalledPackages::new();
+ let root_autoload = serde_json::json!({"psr-4": {"App\\": "src/"}});
+
+ let (data, _static_data) = collect_autoloads(
+ &installed,
+ Some(&root_autoload),
+ None,
+ "myproject/app",
+ false,
+ );
+
+ assert!(data.psr4.contains_key("App\\"));
+ let paths = &data.psr4["App\\"];
+ assert_eq!(paths[0], "$baseDir . '/src'");
+ }
+
+ #[test]
+ fn test_collect_autoloads_root_autoload_dev_included_when_dev() {
+ let installed = InstalledPackages::new();
+ let root_autoload_dev = serde_json::json!({"psr-4": {"Tests\\": "tests/"}});
+
+ let (data, _) = collect_autoloads(
+ &installed,
+ None,
+ Some(&root_autoload_dev),
+ "myproject/app",
+ true, // dev_mode = true
+ );
+
+ assert!(data.psr4.contains_key("Tests\\"));
+ }
+
+ #[test]
+ fn test_collect_autoloads_root_autoload_dev_excluded_when_no_dev() {
+ let installed = InstalledPackages::new();
+ let root_autoload_dev = serde_json::json!({"psr-4": {"Tests\\": "tests/"}});
+
+ let (data, _) = collect_autoloads(
+ &installed,
+ None,
+ Some(&root_autoload_dev),
+ "myproject/app",
+ false, // dev_mode = false
+ );
+
+ assert!(!data.psr4.contains_key("Tests\\"));
+ }
+
+ // -------------------------------------------------------------------------
+ // generate_autoload_psr4 tests
+ // -------------------------------------------------------------------------
+
+ #[test]
+ fn test_generate_autoload_psr4_output() {
+ let mut installed = InstalledPackages::new();
+ installed.upsert(make_installed_pkg_with_autoload(
+ "psr/log",
+ "3.0.2",
+ serde_json::json!({"psr-4": {"Psr\\Log\\": "src/"}}),
+ ));
+
+ let (data, _) = collect_autoloads(&installed, None, None, "__root__", false);
+ let output = generate_autoload_psr4(&data);
+
+ assert!(output.contains("<?php"));
+ assert!(output.contains("autoload_psr4.php @generated by Composer"));
+ assert!(output.contains("$vendorDir = dirname(__DIR__);"));
+ assert!(output.contains("$baseDir = dirname($vendorDir);"));
+ assert!(output.contains("'Psr\\\\Log\\\\'"));
+ assert!(output.contains("$vendorDir . '/psr/log/src'"));
+ assert!(output.starts_with("<?php\n"));
+ }
+
+ #[test]
+ fn test_generate_autoload_psr4_empty() {
+ let data = AutoloadData {
+ psr4: BTreeMap::new(),
+ psr0: BTreeMap::new(),
+ classmap: BTreeMap::new(),
+ files: BTreeMap::new(),
+ };
+ let output = generate_autoload_psr4(&data);
+ assert!(output.contains("return array(\n);"));
+ }
+
+ #[test]
+ fn test_generate_autoload_psr4_sorted_reverse() {
+ let mut installed = InstalledPackages::new();
+ installed.upsert(make_installed_pkg_with_autoload(
+ "aaa/pkg",
+ "1.0.0",
+ serde_json::json!({"psr-4": {"Aaa\\": "src/"}}),
+ ));
+ installed.upsert(make_installed_pkg_with_autoload(
+ "zzz/pkg",
+ "1.0.0",
+ serde_json::json!({"psr-4": {"Zzz\\": "src/"}}),
+ ));
+
+ let (data, _) = collect_autoloads(&installed, None, None, "__root__", false);
+ let output = generate_autoload_psr4(&data);
+
+ // Zzz should appear before Aaa (reverse sort)
+ let zzz_pos = output.find("Zzz").unwrap();
+ let aaa_pos = output.find("Aaa").unwrap();
+ assert!(
+ zzz_pos < aaa_pos,
+ "Zzz should appear before Aaa (reverse sort)"
+ );
+ }
+
+ // -------------------------------------------------------------------------
+ // generate_autoload_static tests
+ // -------------------------------------------------------------------------
+
+ #[test]
+ fn test_generate_autoload_static_output() {
+ let mut installed = InstalledPackages::new();
+ installed.upsert(make_installed_pkg_with_autoload(
+ "psr/log",
+ "3.0.2",
+ serde_json::json!({"psr-4": {"Psr\\Log\\": "src/"}}),
+ ));
+
+ let (_, static_data) = collect_autoloads(&installed, None, None, "__root__", false);
+ let output = generate_autoload_static(&static_data, "abc123");
+
+ assert!(output.contains("class ComposerStaticInitabc123"));
+ assert!(output.contains("$prefixLengthsPsr4"));
+ assert!(output.contains("$prefixDirsPsr4"));
+ assert!(output.contains("$classMap"));
+ assert!(output.contains("Composer\\\\InstalledVersions"));
+ assert!(output.contains("getInitializer"));
+ assert!(output.contains("__DIR__ . '/..' . '/psr/log/src'"));
+ }
+
+ #[test]
+ fn test_generate_autoload_static_prefix_lengths() {
+ let mut installed = InstalledPackages::new();
+ // "Psr\Log\" = 8 bytes (with single backslashes)
+ installed.upsert(make_installed_pkg_with_autoload(
+ "psr/log",
+ "3.0.2",
+ serde_json::json!({"psr-4": {"Psr\\Log\\": "src/"}}),
+ ));
+
+ let (_, static_data) = collect_autoloads(&installed, None, None, "__root__", false);
+ let output = generate_autoload_static(&static_data, "test");
+
+ // The namespace "Psr\Log\" is 8 bytes
+ assert!(output.contains("'Psr\\\\Log\\\\' => 8"));
+ }
+
+ // -------------------------------------------------------------------------
+ // generate_autoload_real tests
+ // -------------------------------------------------------------------------
+
+ #[test]
+ fn test_generate_autoload_real_with_files() {
+ let output = generate_autoload_real("abc123", true, false, false, None, false);
+ assert!(output.contains("class ComposerAutoloaderInitabc123"));
+ assert!(output.contains("ComposerStaticInitabc123::$files"));
+ assert!(output.contains("$requireFile"));
+ assert!(output.contains("__composer_autoload_files"));
+ }
+
+ #[test]
+ fn test_generate_autoload_real_without_files() {
+ let output = generate_autoload_real("abc123", false, false, false, None, false);
+ assert!(output.contains("class ComposerAutoloaderInitabc123"));
+ assert!(!output.contains("$filesToLoad"));
+ assert!(!output.contains("__composer_autoload_files"));
+ }
+
+ #[test]
+ fn test_generate_autoload_real_apcu() {
+ let output = generate_autoload_real("abc123", false, false, true, None, false);
+ assert!(output.contains("setApcuPrefix('abc123')"));
+ }
+
+ #[test]
+ fn test_generate_autoload_real_apcu_custom_prefix() {
+ let output = generate_autoload_real("abc123", false, false, true, Some("myprefix"), false);
+ assert!(output.contains("setApcuPrefix('myprefix')"));
+ }
+
+ #[test]
+ fn test_generate_autoload_real_platform_check() {
+ let output = generate_autoload_real("abc123", false, false, false, None, true);
+ assert!(output.contains("require __DIR__ . '/platform_check.php'"));
+ }
+
+ #[test]
+ fn test_generate_autoload_real_no_platform_check() {
+ let output = generate_autoload_real("abc123", false, false, false, None, false);
+ assert!(!output.contains("platform_check.php"));
+ }
+
+ // -------------------------------------------------------------------------
+ // generate_installed_php tests
+ // -------------------------------------------------------------------------
+
+ #[test]
+ fn test_generate_installed_php() {
+ let mut installed = InstalledPackages::new();
+ let mut pkg = make_installed_pkg("psr/log", "3.0.2");
+ pkg.version_normalized = Some("3.0.2.0".to_string());
+ installed.upsert(pkg);
+
+ let output = generate_installed_php("myproject/app", "project", &installed, true);
+
+ assert!(output.contains("'name' => 'myproject/app'"));
+ assert!(output.contains("'type' => 'project'"));
+ assert!(output.contains("'dev' => true"));
+ assert!(output.contains("'psr/log'"));
+ assert!(output.contains("'pretty_version' => '3.0.2'"));
+ assert!(output.contains("'version' => '3.0.2.0'"));
+ assert!(output.contains("__DIR__ . '/../psr/log/'"));
+ assert!(output.contains("'dev_requirement' => false"));
+ }
+
+ #[test]
+ fn test_generate_installed_php_dev_package() {
+ let mut installed = InstalledPackages::new();
+ installed.upsert(make_installed_pkg("phpunit/phpunit", "11.0.0"));
+ installed
+ .dev_package_names
+ .push("phpunit/phpunit".to_string());
+
+ let output = generate_installed_php("test/project", "project", &installed, true);
+
+ assert!(output.contains("'dev_requirement' => true"));
+ }
+
+ // -------------------------------------------------------------------------
+ // generate() integration test
+ // -------------------------------------------------------------------------
+
+ #[test]
+ fn test_generate_full_roundtrip() {
+ let dir = tempdir().unwrap();
+ let project_dir = dir.path().to_path_buf();
+ let vendor_dir = project_dir.join("vendor");
+
+ // Write a minimal composer.json
+ std::fs::write(
+ project_dir.join("composer.json"),
+ r#"{"name": "test/project", "type": "project", "autoload": {"psr-4": {"App\\": "src/"}}}"#,
+ )
+ .unwrap();
+
+ // Write a minimal installed.json
+ let mut installed = InstalledPackages::new();
+ installed.upsert(make_installed_pkg_with_autoload(
+ "psr/log",
+ "3.0.2",
+ serde_json::json!({"psr-4": {"Psr\\Log\\": "src/"}}),
+ ));
+ installed.write(&vendor_dir).unwrap();
+
+ let config = AutoloadConfig {
+ project_dir: project_dir.clone(),
+ vendor_dir: vendor_dir.clone(),
+ dev_mode: false,
+ suffix: "abc123def456".to_string(),
+ classmap_authoritative: false,
+ optimize: false,
+ apcu: false,
+ apcu_prefix: None,
+ strict_psr: false,
+ platform_check: PlatformCheckMode::Disabled,
+ ignore_platform_reqs: false,
+ };
+
+ generate(&config).unwrap();
+
+ // Verify all expected files exist
+ assert!(
+ vendor_dir.join("autoload.php").exists(),
+ "autoload.php should exist"
+ );
+ assert!(
+ vendor_dir.join("composer/autoload_psr4.php").exists(),
+ "autoload_psr4.php should exist"
+ );
+ assert!(
+ vendor_dir.join("composer/autoload_namespaces.php").exists(),
+ "autoload_namespaces.php should exist"
+ );
+ assert!(
+ vendor_dir.join("composer/autoload_classmap.php").exists(),
+ "autoload_classmap.php should exist"
+ );
+ assert!(
+ vendor_dir.join("composer/autoload_static.php").exists(),
+ "autoload_static.php should exist"
+ );
+ assert!(
+ vendor_dir.join("composer/autoload_real.php").exists(),
+ "autoload_real.php should exist"
+ );
+ assert!(
+ vendor_dir.join("composer/ClassLoader.php").exists(),
+ "ClassLoader.php should exist"
+ );
+ assert!(
+ vendor_dir.join("composer/InstalledVersions.php").exists(),
+ "InstalledVersions.php should exist"
+ );
+ assert!(
+ vendor_dir.join("composer/installed.php").exists(),
+ "installed.php should exist"
+ );
+ assert!(
+ vendor_dir.join("composer/LICENSE").exists(),
+ "LICENSE should exist"
+ );
+ // autoload_files.php should NOT exist (no files autoloading)
+ assert!(
+ !vendor_dir.join("composer/autoload_files.php").exists(),
+ "autoload_files.php should not exist when no files"
+ );
+
+ // Check autoload.php content
+ let autoload_php = std::fs::read_to_string(vendor_dir.join("autoload.php")).unwrap();
+ assert!(autoload_php.contains("ComposerAutoloaderInitabc123def456"));
+
+ // Check autoload_psr4.php
+ let psr4_php =
+ std::fs::read_to_string(vendor_dir.join("composer/autoload_psr4.php")).unwrap();
+ assert!(psr4_php.contains("Psr\\\\Log\\\\"));
+ assert!(psr4_php.contains("App\\\\"));
+ assert!(psr4_php.contains("$vendorDir . '/psr/log/src'"));
+ assert!(psr4_php.contains("$baseDir . '/src'"));
+
+ // Check installed.php
+ let installed_php =
+ std::fs::read_to_string(vendor_dir.join("composer/installed.php")).unwrap();
+ assert!(installed_php.contains("'name' => 'test/project'"));
+ assert!(installed_php.contains("'psr/log'"));
+ }
+
+ #[test]
+ fn test_generate_with_files_autoload() {
+ let dir = tempdir().unwrap();
+ let project_dir = dir.path().to_path_buf();
+ let vendor_dir = project_dir.join("vendor");
+
+ std::fs::write(
+ project_dir.join("composer.json"),
+ r#"{"name": "test/project", "type": "project"}"#,
+ )
+ .unwrap();
+
+ let mut installed = InstalledPackages::new();
+ installed.upsert(make_installed_pkg_with_autoload(
+ "symfony/polyfill-php80",
+ "1.32.0",
+ serde_json::json!({"files": ["bootstrap.php"]}),
+ ));
+ installed.write(&vendor_dir).unwrap();
+
+ let config = AutoloadConfig {
+ project_dir: project_dir.clone(),
+ vendor_dir: vendor_dir.clone(),
+ dev_mode: false,
+ suffix: "test".to_string(),
+ classmap_authoritative: false,
+ optimize: false,
+ apcu: false,
+ apcu_prefix: None,
+ strict_psr: false,
+ platform_check: PlatformCheckMode::Disabled,
+ ignore_platform_reqs: false,
+ };
+
+ generate(&config).unwrap();
+
+ // autoload_files.php SHOULD exist
+ assert!(
+ vendor_dir.join("composer/autoload_files.php").exists(),
+ "autoload_files.php should exist when files are present"
+ );
+
+ let files_php =
+ std::fs::read_to_string(vendor_dir.join("composer/autoload_files.php")).unwrap();
+ assert!(files_php.contains("a4a119a56e50fbb293281d9a48007e0e"));
+ assert!(files_php.contains("$vendorDir . '/symfony/polyfill-php80/bootstrap.php'"));
+
+ // autoload_real.php should contain the files loading block
+ let real_php =
+ std::fs::read_to_string(vendor_dir.join("composer/autoload_real.php")).unwrap();
+ assert!(real_php.contains("$filesToLoad"));
+ }
+}
diff --git a/crates/mozart-autoload/src/lib.rs b/crates/mozart-autoload/src/lib.rs
new file mode 100644
index 0000000..9d798c6
--- /dev/null
+++ b/crates/mozart-autoload/src/lib.rs
@@ -0,0 +1,2 @@
+pub mod autoload;
+pub mod php_scanner;
diff --git a/crates/mozart-autoload/src/php_scanner.rs b/crates/mozart-autoload/src/php_scanner.rs
new file mode 100644
index 0000000..3d0d51d
--- /dev/null
+++ b/crates/mozart-autoload/src/php_scanner.rs
@@ -0,0 +1,629 @@
+use anyhow::Result;
+use regex::Regex;
+use std::path::Path;
+
+/// File extensions considered PHP source files for class scanning.
+const PHP_EXTENSIONS: &[&str] = &["php", "inc", "hh"];
+
+/// Check if a file path has a PHP-like extension.
+fn is_php_file(path: &Path) -> bool {
+ is_php_ext(path)
+}
+
+/// Public version of the PHP extension check, used by the autoload scanner.
+pub fn is_php_ext(path: &Path) -> bool {
+ path.extension()
+ .and_then(|e| e.to_str())
+ .map(|ext| PHP_EXTENSIONS.iter().any(|&e| ext.eq_ignore_ascii_case(e)))
+ .unwrap_or(false)
+}
+
+/// Scan a PHP file and return the list of fully-qualified class names declared in it.
+///
+/// Returns an empty vec if the file has no relevant extension or no class declarations.
+pub fn find_classes(path: &Path) -> Result<Vec<String>> {
+ if !is_php_file(path) {
+ return Ok(vec![]);
+ }
+
+ let contents = std::fs::read_to_string(path)?;
+
+ // Quick check: does the file even contain a class-like keyword?
+ let quick_re = Regex::new(r"(?i)\b(?:class|interface|trait|enum)\s").unwrap();
+ if !quick_re.is_match(&contents) {
+ return Ok(vec![]);
+ }
+
+ let cleaned = clean_php_content(&contents);
+ Ok(extract_declarations(&cleaned))
+}
+
+/// State machine that strips strings, comments, and heredocs/nowdocs from PHP code.
+///
+/// Returns a string of equal byte length where non-PHP content is replaced with spaces
+/// so that regex offsets are preserved. Only PHP mode content is kept; everything else
+/// is blanked out.
+fn clean_php_content(contents: &str) -> String {
+ let bytes = contents.as_bytes();
+ let len = bytes.len();
+ let mut out = vec![b' '; len];
+ let mut i = 0;
+ let mut in_php = false;
+
+ while i < len {
+ if !in_php {
+ // Look for `<?`
+ if i + 1 < len && bytes[i] == b'<' && bytes[i + 1] == b'?' {
+ in_php = true;
+ out[i] = b' ';
+ out[i + 1] = b' ';
+ i += 2;
+ // Skip optional "php" or "="
+ if i + 3 <= len && bytes[i..i + 3].eq_ignore_ascii_case(b"php") {
+ i += 3;
+ } else if i < len && bytes[i] == b'=' {
+ i += 1;
+ }
+ continue;
+ }
+ i += 1;
+ continue;
+ }
+
+ // In PHP mode
+ // Check for `?>`
+ if i + 1 < len && bytes[i] == b'?' && bytes[i + 1] == b'>' {
+ in_php = false;
+ i += 2;
+ continue;
+ }
+
+ // Line comment: // or #
+ if i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'/' {
+ // Skip to end of line
+ while i < len && bytes[i] != b'\n' {
+ i += 1;
+ }
+ continue;
+ }
+ if bytes[i] == b'#' {
+ while i < len && bytes[i] != b'\n' {
+ i += 1;
+ }
+ continue;
+ }
+
+ // Block comment: /* ... */
+ if i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'*' {
+ i += 2;
+ while i + 1 < len {
+ if bytes[i] == b'*' && bytes[i + 1] == b'/' {
+ i += 2;
+ break;
+ }
+ i += 1;
+ }
+ continue;
+ }
+
+ // Single-quoted string
+ if bytes[i] == b'\'' {
+ out[i] = b'\'';
+ i += 1;
+ while i < len {
+ if bytes[i] == b'\\' && i + 1 < len {
+ // escaped character — blank both
+ i += 2;
+ } else if bytes[i] == b'\'' {
+ out[i] = b'\'';
+ i += 1;
+ break;
+ } else {
+ i += 1;
+ }
+ }
+ continue;
+ }
+
+ // Double-quoted string
+ if bytes[i] == b'"' {
+ out[i] = b'"';
+ i += 1;
+ while i < len {
+ if bytes[i] == b'\\' && i + 1 < len {
+ i += 2;
+ } else if bytes[i] == b'"' {
+ out[i] = b'"';
+ i += 1;
+ break;
+ } else {
+ i += 1;
+ }
+ }
+ continue;
+ }
+
+ // Heredoc / Nowdoc: <<<
+ if i + 2 < len && bytes[i] == b'<' && bytes[i + 1] == b'<' && bytes[i + 2] == b'<' {
+ i += 3;
+ // Skip whitespace
+ while i < len && (bytes[i] == b' ' || bytes[i] == b'\t') {
+ i += 1;
+ }
+
+ // Nowdoc uses single quotes around label; heredoc may use double quotes.
+ let is_nowdoc = i < len && bytes[i] == b'\'';
+ // Skip optional opening quote (single for nowdoc, double for heredoc)
+ if i < len && (bytes[i] == b'\'' || bytes[i] == b'"') {
+ i += 1;
+ }
+
+ // Read label
+ let label_start = i;
+ while i < len && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
+ i += 1;
+ }
+ let label = std::str::from_utf8(&bytes[label_start..i])
+ .unwrap_or("")
+ .to_string();
+
+ // Skip closing quote of label (must match the opening quote)
+ let expected_close = if is_nowdoc { b'\'' } else { b'"' };
+ if i < len && bytes[i] == expected_close {
+ i += 1;
+ }
+
+ // Skip to end of line
+ while i < len && bytes[i] != b'\n' {
+ i += 1;
+ }
+ if i < len {
+ i += 1; // consume newline
+ }
+
+ // Scan for the terminator label on its own line
+ if !label.is_empty() {
+ loop {
+ if i >= len {
+ break;
+ }
+ // Check if current line starts with the label
+ let line_start = i;
+ // Skip optional whitespace for indented heredoc (PHP 7.3+)
+ while i < len && (bytes[i] == b' ' || bytes[i] == b'\t') {
+ i += 1;
+ }
+ let remaining = &bytes[i..];
+ let label_bytes = label.as_bytes();
+ if remaining.len() >= label_bytes.len()
+ && &remaining[..label_bytes.len()] == label_bytes
+ {
+ let after = i + label_bytes.len();
+ // Terminator must be followed by ; or newline or EOF
+ if after >= len
+ || bytes[after] == b';'
+ || bytes[after] == b'\n'
+ || bytes[after] == b'\r'
+ {
+ // Skip to end of this line
+ i = after;
+ while i < len && bytes[i] != b'\n' {
+ i += 1;
+ }
+ if i < len {
+ i += 1;
+ }
+ break;
+ }
+ }
+ // Not a terminator line — skip to end of line
+ i = line_start;
+ while i < len && bytes[i] != b'\n' {
+ i += 1;
+ }
+ if i < len {
+ i += 1;
+ }
+ }
+ }
+ continue;
+ }
+
+ // Backtick strings (shell exec)
+ if bytes[i] == b'`' {
+ out[i] = b'`';
+ i += 1;
+ while i < len {
+ if bytes[i] == b'\\' && i + 1 < len {
+ i += 2;
+ } else if bytes[i] == b'`' {
+ out[i] = b'`';
+ i += 1;
+ break;
+ } else {
+ i += 1;
+ }
+ }
+ continue;
+ }
+
+ // Keep normal PHP content
+ out[i] = bytes[i];
+ i += 1;
+ }
+
+ String::from_utf8_lossy(&out).into_owned()
+}
+
+/// Extract fully-qualified class names from cleaned PHP content.
+///
+/// Tracks the current namespace and finds class/interface/trait/enum declarations.
+fn extract_declarations(cleaned: &str) -> Vec<String> {
+ let mut results = Vec::new();
+
+ // Regex for namespace declarations:
+ // namespace Foo\Bar; — simple
+ // namespace Foo\Bar { — block
+ // namespace { — global block
+ let ns_re = Regex::new(
+ r"(?x)
+ \bnamespace\s+
+ ((?:[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*\\)*[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)
+ \s*[;{]
+ |
+ \bnamespace\s*\{
+ ",
+ )
+ .unwrap();
+
+ // Regex for class/interface/trait/enum declarations.
+ // We need to capture the name; anonymous classes (new class ...) are excluded.
+ let decl_re = Regex::new(
+ r"(?x)
+ \b(?:abstract\s+|final\s+|readonly\s+)*
+ (?P<kind>class|interface|trait|enum)\s+
+ (?P<name>[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)
+ ",
+ )
+ .unwrap();
+
+ let mut current_ns = String::new();
+
+ // We process namespace changes as we walk through the file.
+ // Build a list of all namespace and declaration positions.
+ #[derive(Debug)]
+ enum Event {
+ Namespace(usize, String), // position, namespace
+ Declaration(usize, String), // position, simple name
+ }
+
+ let mut events: Vec<Event> = Vec::new();
+
+ // Find namespace declarations
+ for cap in ns_re.captures_iter(cleaned) {
+ let pos = cap.get(0).unwrap().start();
+ let ns_name = cap
+ .get(1)
+ .map(|m| m.as_str().to_string())
+ .unwrap_or_default();
+ events.push(Event::Namespace(pos, ns_name));
+ }
+
+ // Find class/interface/trait/enum declarations
+ for cap in decl_re.captures_iter(cleaned) {
+ let pos = cap.get(0).unwrap().start();
+ let name = cap.name("name").unwrap().as_str().to_string();
+
+ // Skip anonymous classes: check if "new" precedes "class" on the same "expression".
+ // A reliable check: look back for "new " before this match.
+ let before = &cleaned[..pos];
+ let kind = cap.name("kind").unwrap().as_str();
+ if kind == "class" {
+ // Check if "new" appears right before (with possible whitespace/modifiers).
+ // Simple heuristic: scan backwards for non-whitespace token.
+ let trimmed = before.trim_end();
+ if trimmed.ends_with("new") {
+ continue;
+ }
+ }
+
+ events.push(Event::Declaration(pos, name));
+ }
+
+ // Sort all events by position
+ events.sort_by_key(|e| match e {
+ Event::Namespace(pos, _) => *pos,
+ Event::Declaration(pos, _) => *pos,
+ });
+
+ // Process events in order
+ for event in events {
+ match event {
+ Event::Namespace(_, ns) => {
+ current_ns = ns;
+ }
+ Event::Declaration(_, name) => {
+ let fqn = if current_ns.is_empty() {
+ name
+ } else {
+ format!("{}\\{}", current_ns, name)
+ };
+ results.push(fqn);
+ }
+ }
+ }
+
+ results
+}
+
+/// Validate that a class file is correctly placed according to PSR-4.
+///
+/// - `class`: fully-qualified class name (e.g. `Foo\Bar\Baz`)
+/// - `base_namespace`: the PSR-4 namespace prefix (e.g. `Foo\Bar\`)
+/// - `file_path`: absolute path to the PHP file
+/// - `base_path`: the directory mapped to `base_namespace` (absolute)
+///
+/// Returns `true` if the file path matches the PSR-4 mapping.
+pub fn validate_psr4_class(
+ class: &str,
+ base_namespace: &str,
+ file_path: &str,
+ base_path: &str,
+) -> bool {
+ // Normalize the base namespace: ensure it ends with `\`
+ let base_ns = if base_namespace.is_empty() || base_namespace.ends_with('\\') {
+ base_namespace.to_string()
+ } else {
+ format!("{base_namespace}\\")
+ };
+
+ // Class must start with the base namespace
+ if !class.starts_with(&*base_ns) {
+ return false;
+ }
+
+ // The relative class name after the base namespace
+ let relative_class = &class[base_ns.len()..];
+
+ // Convert relative class to a relative file path: replace `\` with `/`
+ let expected_relative = relative_class.replace('\\', "/");
+ let expected_file = format!(
+ "{}/{}.php",
+ base_path.trim_end_matches('/'),
+ expected_relative
+ );
+
+ // Normalize both paths for comparison (simplistic: just compare strings)
+ Path::new(file_path) == Path::new(&expected_file)
+}
+
+/// Validate that a class file is correctly placed according to PSR-0.
+///
+/// - `class`: fully-qualified class name (e.g. `Foo_Bar_Baz` or `Foo\Bar`)
+/// - `file_path`: absolute path to the PHP file
+/// - `base_path`: the base directory for PSR-0 lookup
+///
+/// Returns `true` if the file path matches the PSR-0 mapping.
+pub fn validate_psr0_class(class: &str, file_path: &str, base_path: &str) -> bool {
+ // PSR-0: namespace separators AND underscores (in class part) map to directory separators.
+ // Split on `\` first; the last segment may contain underscores that also become `/`.
+ let parts: Vec<&str> = class.split('\\').collect();
+ let relative = if parts.len() == 1 {
+ // No namespace: underscores in class name become dir separators
+ parts[0].replace('_', "/")
+ } else {
+ let ns_part = parts[..parts.len() - 1].join("/");
+ let class_part = parts[parts.len() - 1].replace('_', "/");
+ format!("{}/{}", ns_part, class_part)
+ };
+
+ let expected_file = format!("{}/{}.php", base_path.trim_end_matches('/'), relative);
+ Path::new(file_path) == Path::new(&expected_file)
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Tests
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::io::Write;
+ use tempfile::NamedTempFile;
+
+ fn write_php(content: &str) -> NamedTempFile {
+ let mut f = NamedTempFile::with_suffix(".php").unwrap();
+ f.write_all(content.as_bytes()).unwrap();
+ f
+ }
+
+ // -------------------------------------------------------------------------
+ // find_classes tests
+ // -------------------------------------------------------------------------
+
+ #[test]
+ fn test_find_classes_simple_class() {
+ let f = write_php("<?php\nclass Foo {}\n");
+ let classes = find_classes(f.path()).unwrap();
+ assert_eq!(classes, vec!["Foo"]);
+ }
+
+ #[test]
+ fn test_find_classes_with_namespace() {
+ let f = write_php("<?php\nnamespace Foo\\Bar;\nclass Baz {}\n");
+ let classes = find_classes(f.path()).unwrap();
+ assert_eq!(classes, vec!["Foo\\Bar\\Baz"]);
+ }
+
+ #[test]
+ fn test_find_classes_multiple_classes() {
+ let f = write_php("<?php\nnamespace App;\nclass Foo {}\nclass Bar {}\ninterface Baz {}\n");
+ let classes = find_classes(f.path()).unwrap();
+ assert_eq!(classes, vec!["App\\Foo", "App\\Bar", "App\\Baz"]);
+ }
+
+ #[test]
+ fn test_find_classes_interface() {
+ let f = write_php("<?php\ninterface MyInterface {}\n");
+ let classes = find_classes(f.path()).unwrap();
+ assert_eq!(classes, vec!["MyInterface"]);
+ }
+
+ #[test]
+ fn test_find_classes_trait() {
+ let f = write_php("<?php\ntrait MyTrait {}\n");
+ let classes = find_classes(f.path()).unwrap();
+ assert_eq!(classes, vec!["MyTrait"]);
+ }
+
+ #[test]
+ fn test_find_classes_enum() {
+ let f = write_php("<?php\nenum Status {}\n");
+ let classes = find_classes(f.path()).unwrap();
+ assert_eq!(classes, vec!["Status"]);
+ }
+
+ #[test]
+ fn test_find_classes_enum_with_backing_type() {
+ let f = write_php("<?php\nenum Color: string { case Red = 'red'; }\n");
+ let classes = find_classes(f.path()).unwrap();
+ assert_eq!(classes, vec!["Color"]);
+ }
+
+ #[test]
+ fn test_find_classes_anonymous_class_skipped() {
+ let f = write_php("<?php\n$obj = new class {};\n");
+ let classes = find_classes(f.path()).unwrap();
+ assert!(classes.is_empty(), "anonymous class should not be scanned");
+ }
+
+ #[test]
+ fn test_find_classes_comments_ignored() {
+ let f = write_php(
+ "<?php\n// class FakeClass {}\n/* interface FakeInterface {} */\nclass RealClass {}\n",
+ );
+ let classes = find_classes(f.path()).unwrap();
+ assert_eq!(classes, vec!["RealClass"]);
+ }
+
+ #[test]
+ fn test_find_classes_strings_ignored() {
+ let f = write_php(
+ "<?php\n$s = 'class NotAClass {}';\n$t = \"interface NotAnInterface {}\";\nclass RealClass {}\n",
+ );
+ let classes = find_classes(f.path()).unwrap();
+ assert_eq!(classes, vec!["RealClass"]);
+ }
+
+ #[test]
+ fn test_find_classes_heredoc_ignored() {
+ let f = write_php("<?php\n$s = <<<EOT\nclass FakeClass {}\nEOT;\nclass RealClass {}\n");
+ let classes = find_classes(f.path()).unwrap();
+ assert_eq!(classes, vec!["RealClass"]);
+ }
+
+ #[test]
+ fn test_find_classes_empty_file() {
+ let f = write_php("<?php\n// nothing here\n");
+ let classes = find_classes(f.path()).unwrap();
+ assert!(classes.is_empty());
+ }
+
+ #[test]
+ fn test_find_classes_no_classes() {
+ let f = write_php("<?php\necho 'hello';\n");
+ let classes = find_classes(f.path()).unwrap();
+ assert!(classes.is_empty());
+ }
+
+ #[test]
+ fn test_find_classes_abstract_final() {
+ let f = write_php("<?php\nabstract class AbstractFoo {}\nfinal class FinalBar {}\n");
+ let classes = find_classes(f.path()).unwrap();
+ assert!(classes.contains(&"AbstractFoo".to_string()));
+ assert!(classes.contains(&"FinalBar".to_string()));
+ }
+
+ #[test]
+ fn test_find_classes_non_php_extension() {
+ let mut f = NamedTempFile::with_suffix(".txt").unwrap();
+ f.write_all(b"<?php\nclass Foo {}\n").unwrap();
+ let classes = find_classes(f.path()).unwrap();
+ assert!(classes.is_empty(), "non-PHP extension should be skipped");
+ }
+
+ // -------------------------------------------------------------------------
+ // PSR-4 validation tests
+ // -------------------------------------------------------------------------
+
+ #[test]
+ fn test_validate_psr4_correct() {
+ assert!(validate_psr4_class(
+ "Foo\\Bar\\Baz",
+ "Foo\\Bar\\",
+ "/srv/project/src/Baz.php",
+ "/srv/project/src"
+ ));
+ }
+
+ #[test]
+ fn test_validate_psr4_wrong_path() {
+ assert!(!validate_psr4_class(
+ "Foo\\Bar\\Baz",
+ "Foo\\Bar\\",
+ "/srv/project/src/Wrong.php",
+ "/srv/project/src"
+ ));
+ }
+
+ #[test]
+ fn test_validate_psr4_namespace_mismatch() {
+ assert!(!validate_psr4_class(
+ "Other\\Baz",
+ "Foo\\Bar\\",
+ "/srv/project/src/Baz.php",
+ "/srv/project/src"
+ ));
+ }
+
+ #[test]
+ fn test_validate_psr4_nested() {
+ assert!(validate_psr4_class(
+ "App\\Http\\Controllers\\HomeController",
+ "App\\",
+ "/project/src/Http/Controllers/HomeController.php",
+ "/project/src"
+ ));
+ }
+
+ // -------------------------------------------------------------------------
+ // PSR-0 validation tests
+ // -------------------------------------------------------------------------
+
+ #[test]
+ fn test_validate_psr0_simple() {
+ assert!(validate_psr0_class(
+ "Foo_Bar_Baz",
+ "/srv/project/src/Foo/Bar/Baz.php",
+ "/srv/project/src"
+ ));
+ }
+
+ #[test]
+ fn test_validate_psr0_with_namespace() {
+ assert!(validate_psr0_class(
+ "Foo\\Bar",
+ "/srv/project/src/Foo/Bar.php",
+ "/srv/project/src"
+ ));
+ }
+
+ #[test]
+ fn test_validate_psr0_wrong_path() {
+ assert!(!validate_psr0_class(
+ "Foo_Bar",
+ "/srv/project/src/Foo/Baz.php",
+ "/srv/project/src"
+ ));
+ }
+}