diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-22 00:37:54 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-22 00:37:54 +0900 |
| commit | 0a8e5935e6305819bb02d8c69e2f046ff397913a (patch) | |
| tree | e5a288e679477b1603d7989e986ca22bbe590aa4 /crates/mozart-autoload | |
| parent | b5af594fec7da72b15c9a202c641af0494db6355 (diff) | |
| download | php-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')
| -rw-r--r-- | crates/mozart-autoload/Cargo.toml | 16 | ||||
| -rw-r--r-- | crates/mozart-autoload/src/autoload.rs | 1801 | ||||
| -rw-r--r-- | crates/mozart-autoload/src/lib.rs | 2 | ||||
| -rw-r--r-- | crates/mozart-autoload/src/php_scanner.rs | 629 |
4 files changed, 2448 insertions, 0 deletions
diff --git a/crates/mozart-autoload/Cargo.toml b/crates/mozart-autoload/Cargo.toml new file mode 100644 index 0000000..6aba8ab --- /dev/null +++ b/crates/mozart-autoload/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "mozart-autoload" +version.workspace = true +edition.workspace = true + +[dependencies] +mozart-core.workspace = true +mozart-registry.workspace = true +anyhow.workspace = true +md5.workspace = true +regex.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +tempfile.workspace = true 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" + )); + } +} |
