diff options
Diffstat (limited to 'crates/mozart-autoload/src')
| -rw-r--r-- | crates/mozart-autoload/src/autoload.rs | 1597 | ||||
| -rw-r--r-- | crates/mozart-autoload/src/dump.rs | 340 | ||||
| -rw-r--r-- | crates/mozart-autoload/src/lib.rs | 4 |
3 files changed, 0 insertions, 1941 deletions
diff --git a/crates/mozart-autoload/src/autoload.rs b/crates/mozart-autoload/src/autoload.rs deleted file mode 100644 index 21a1de2..0000000 --- a/crates/mozart-autoload/src/autoload.rs +++ /dev/null @@ -1,1597 +0,0 @@ -use indexmap::IndexSet; -use mozart_class_map_generator::{scan_classmap_dirs, scan_psr_for_classmap}; -use mozart_registry::installed::InstalledPackages; -use mozart_registry::lockfile::LockedPackage; -use std::collections::BTreeMap; -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, -} - -/// Result of autoload generation, reporting statistics and warnings. -pub struct GenerateResult { - pub class_count: usize, - pub has_psr_violations: bool, - pub has_ambiguous_classes: bool, -} - -/// 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, - /// When true, return exit code 2 if ambiguous class mappings are detected. - pub strict_ambiguous: 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 -} - -/// 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: &IndexSet<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<GenerateResult> { - // 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 - let mut ambiguous_found = false; - 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 { - if let Some(existing) = data.classmap.get(&class) - && existing != &path_expr - { - ambiguous_found = true; - } - // 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 { - if let Some(existing) = data.classmap.get(&class) - && existing != &path_expr - { - ambiguous_found = true; - } - 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: IndexSet<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(), - provide: std::collections::BTreeMap::new(), - replace: 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(GenerateResult { - class_count: data.classmap.len(), - has_psr_violations: !psr_violations.is_empty(), - has_ambiguous_classes: ambiguous_found, - }) -} - -#[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![], - homepage: None, - support: None, - 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, - strict_ambiguous: 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, - strict_ambiguous: 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/dump.rs b/crates/mozart-autoload/src/dump.rs deleted file mode 100644 index 103c683..0000000 --- a/crates/mozart-autoload/src/dump.rs +++ /dev/null @@ -1,340 +0,0 @@ -//! `Composer\Autoload\AutoloadGenerator::dump` extension. -//! -//! [`mozart_core::composer::AutoloadGenerator`] is a state container in -//! `mozart-core`; the dumping algorithm itself sits here in -//! `mozart-autoload` because it pulls in the classmap scanner, -//! installed.json reader, and PHP-emission helpers. This module hangs -//! `dump()` off the generator via [`AutoloadGeneratorExt`] so callers -//! can still write `composer.autoload_generator().dump(...)`, matching -//! `$composer->getAutoloadGenerator()->dump(...)` in PHP. -//! -//! Bring [`AutoloadGeneratorExt`] into scope at the call site: -//! -//! ```ignore -//! use mozart_autoload::AutoloadGeneratorExt; -//! ``` -//! -//! See `Composer\Autoload\AutoloadGenerator::dump()` (the ~500-line -//! implementation in `composer/src/Composer/Autoload/AutoloadGenerator.php`) -//! for the upstream semantics. - -use std::collections::BTreeMap; -use std::path::PathBuf; - -use mozart_core::composer::{ - AutoloadDumpOptions, AutoloadGenerator, InstallationManager, LocalRepository, Locker, - PlatformRequirementFilter, -}; -use mozart_core::config::Config; -use mozart_core::package::RawPackageData; - -use crate::autoload::{AutoloadConfig, PlatformCheckMode, generate}; - -/// Mirror of `Composer\ClassMapGenerator\ClassMap` — the return value -/// of `AutoloadGenerator::dump`. PHP's class is a `Countable` carrying -/// the discovered class map plus PSR-violation and ambiguous-class -/// records; Mozart only models the slice that command handlers need to -/// branch on today (`count`, `has_psr_violations`, `has_ambiguous_classes`). -/// -/// The `map` / `psr_violations` / `ambiguous_classes` fields are -/// currently populated from the existing [`generate`]'s coarse -/// summary — once `generate` is refactored to expose the full classmap -/// these fields will hold the real entries. -pub struct ClassMap { - map: BTreeMap<String, String>, - psr_violations: Vec<String>, - ambiguous_classes: BTreeMap<String, Vec<String>>, -} - -impl ClassMap { - /// Mirror of `ClassMap::count`. - pub fn count(&self) -> usize { - self.map.len() - } - - /// Mirror of `count($classMap->getPsrViolations()) > 0`. PHP returns - /// the violation strings; commands typically only need the boolean. - pub fn has_psr_violations(&self) -> bool { - !self.psr_violations.is_empty() - } - - /// Mirror of `count($classMap->getAmbiguousClasses($filter)) > 0`. - /// `with_filter = true` applies PHP's default test/fixture/example - /// path filter; `false` skips it (the `$duplicatesFilter = false` - /// branch upstream). - pub fn has_ambiguous_classes(&self, with_filter: bool) -> bool { - if !with_filter { - return !self.ambiguous_classes.is_empty(); - } - let pattern = regex_filter_default(); - self.ambiguous_classes.values().any(|paths| { - paths - .iter() - .any(|p| !pattern.is_match(&p.replace('\\', "/"))) - }) - } - - /// Read access to the underlying map (`getMap()` upstream). - pub fn map(&self) -> &BTreeMap<String, String> { - &self.map - } - - /// Read access to the PSR-violation warnings. - pub fn psr_violations(&self) -> &[String] { - &self.psr_violations - } - - /// Read access to the ambiguous-class records. - pub fn ambiguous_classes(&self) -> &BTreeMap<String, Vec<String>> { - &self.ambiguous_classes - } -} - -fn regex_filter_default() -> regex::Regex { - use std::sync::OnceLock; - static RE: OnceLock<regex::Regex> = OnceLock::new(); - RE.get_or_init(|| { - // `{/(test|fixture|example|stub)s?/}i` from PHP's - // ClassMap::getAmbiguousClasses default. - regex::Regex::new(r"(?i)/(test|fixture|example|stub)s?/") - .expect("default ambiguous filter compiles") - }) - .clone() -} - -/// Extension trait hanging `dump()` off -/// [`mozart_core::composer::AutoloadGenerator`]. Mirrors -/// `Composer\Autoload\AutoloadGenerator::dump()`. -/// -/// Bring this trait into scope (`use mozart_autoload::AutoloadGeneratorExt;`) -/// to make the method visible. -/// -/// Diverges from PHP in one place: the per-call toggles PHP fixes via -/// `setDryRun` / `setDevMode` / … on the generator are passed in here -/// as an [`AutoloadDumpOptions`] argument, because Mozart's -/// [`AutoloadGenerator`] is stateless. -pub trait AutoloadGeneratorExt { - /// Mirror of `AutoloadGenerator::dump(Config $config, - /// InstalledRepositoryInterface $localRepo, RootPackageInterface - /// $rootPackage, InstallationManager $installationManager, string - /// $targetDir, bool $scanPsrPackages = false, ?string $suffix = null, - /// ?Locker $locker = null, bool $strictAmbiguous = false)`. - /// - /// Mozart-specific notes: - /// - `options` carries the toggles PHP fixes via setters on the - /// generator (`setDryRun`, `setDevMode`, `setApcu`, …). - /// - `target_dir` is currently unused (the underlying [`generate`] - /// always writes into `vendor_dir/composer`); the parameter is - /// kept on the signature so the call site mirrors PHP and we can - /// honour it once the writer is parameterised. - /// - `local_repo` and `root_package` are accepted to mirror the - /// PHP signature, but [`generate`] currently re-reads them from - /// `installed.json` / `composer.json`. Refactoring to consume the - /// passed-in values lives in a follow-up. - #[allow(clippy::too_many_arguments)] - fn dump( - &self, - options: &AutoloadDumpOptions, - config: &Config, - local_repo: &LocalRepository, - root_package: &RawPackageData, - installation_manager: &InstallationManager, - target_dir: &str, - scan_psr_packages: bool, - suffix: Option<&str>, - locker: &Locker, - strict_ambiguous: bool, - ) -> anyhow::Result<ClassMap>; -} - -impl AutoloadGeneratorExt for AutoloadGenerator { - fn dump( - &self, - options: &AutoloadDumpOptions, - config: &Config, - _local_repo: &LocalRepository, - _root_package: &RawPackageData, - installation_manager: &InstallationManager, - _target_dir: &str, - scan_psr_packages: bool, - suffix: Option<&str>, - locker: &Locker, - strict_ambiguous: bool, - ) -> anyhow::Result<ClassMap> { - // Mirrors PHP: classmap-authoritative implies PSR scanning so - // every class gets a fixed map entry. - let scan = scan_psr_packages || options.class_map_authoritative; - - // Mirrors PHP's `if (null === $this->devMode)` branch: read the - // `dev` flag from `vendor/composer/installed.json` when no - // explicit dev-mode has been set on the options. - let dev_mode = match options.dev_mode { - Some(m) => m, - None => read_installed_dev_flag(installation_manager.vendor_dir()), - }; - - // Mirrors PHP's suffix resolution chain in `dump()`: - // 1. explicit argument - // 2. `Config::get('autoloader-suffix')` - // 3. existing `vendor/autoload.php`'s `ComposerAutoloaderInit{X}` - // 4. `composer.lock`'s `content-hash` (when locked) - // 5. random hex - let resolved_suffix = resolve_suffix(suffix, config, installation_manager, locker)?; - - // Mirrors PHP: `$basePath = realpath(getcwd())`. We don't have - // an explicit project_dir on the generator, but `vendor_dir`'s - // parent matches the project root for the common - // `vendor-dir = "vendor"` layout. When the user points - // `vendor-dir` outside the project we fall back to `.`. - let project_dir = installation_manager - .vendor_dir() - .parent() - .map(|p| p.to_path_buf()) - .unwrap_or_else(|| PathBuf::from(".")); - - // Mirrors PHP's `$checkPlatform = $config->get('platform-check') !== - // false && !($filter instanceof IgnoreAllPlatformRequirementFilter)`. - let platform_check = if matches!( - options.platform_requirement_filter, - PlatformRequirementFilter::IgnoreAll - ) { - PlatformCheckMode::Disabled - } else { - platform_check_mode_from_config(&config.platform_check) - }; - - let cfg = AutoloadConfig { - project_dir, - vendor_dir: installation_manager.vendor_dir().to_path_buf(), - dev_mode, - suffix: resolved_suffix, - classmap_authoritative: options.class_map_authoritative, - optimize: scan, - apcu: options.apcu, - apcu_prefix: options.apcu_prefix.clone(), - // `dump()` does not surface a `--strict-psr` option (that's - // a separate command-line flag on `dump-autoload`); the - // generator only reports violations via `ClassMap`. - strict_psr: false, - strict_ambiguous, - platform_check, - ignore_platform_reqs: matches!( - options.platform_requirement_filter, - PlatformRequirementFilter::IgnoreAll - ), - }; - - if options.dry_run { - // PHP's dry-run still scans and returns the classmap but - // skips file writes. The current [`generate`] does not - // expose a dry-run hook, so we return an empty ClassMap - // for now and surface the limitation here rather than - // silently writing files. - return Ok(ClassMap { - map: BTreeMap::new(), - psr_violations: Vec::new(), - ambiguous_classes: BTreeMap::new(), - }); - } - - let result = generate(&cfg)?; - - // Mozart's `GenerateResult` only carries summary flags - // (`class_count`, `has_psr_violations`, `has_ambiguous_classes`), - // not the actual class-name / path entries that PHP's `ClassMap` - // exposes. We project the summary onto a `ClassMap` shape so - // command code that only branches on `count()` / `has_*()` works - // today; refactoring `generate` to surface the full map is - // tracked as follow-up work. - let mut map = BTreeMap::new(); - for i in 0..result.class_count { - map.insert(format!("__mozart_placeholder_{i}"), String::new()); - } - let psr_violations = if result.has_psr_violations { - vec![String::from( - "PSR-0/4 violation detected (details not yet surfaced)", - )] - } else { - Vec::new() - }; - let mut ambiguous_classes = BTreeMap::new(); - if result.has_ambiguous_classes { - ambiguous_classes.insert("__mozart_placeholder".to_string(), Vec::new()); - } - - Ok(ClassMap { - map, - psr_violations, - ambiguous_classes, - }) - } -} - -fn read_installed_dev_flag(vendor_dir: &std::path::Path) -> bool { - let path = vendor_dir.join("composer/installed.json"); - if !path.exists() { - return false; - } - let Ok(content) = std::fs::read_to_string(&path) else { - return false; - }; - let Ok(value) = serde_json::from_str::<serde_json::Value>(&content) else { - return false; - }; - value.get("dev").and_then(|v| v.as_bool()).unwrap_or(false) -} - -fn resolve_suffix( - explicit: Option<&str>, - config: &Config, - installation_manager: &InstallationManager, - locker: &Locker, -) -> anyhow::Result<String> { - if let Some(s) = explicit - && !s.is_empty() - { - return Ok(s.to_string()); - } - if let Some(s) = config.autoloader_suffix.as_ref() - && !s.is_empty() - { - return Ok(s.clone()); - } - let vendor_path = installation_manager.vendor_dir(); - let autoload_path = vendor_path.join("autoload.php"); - if autoload_path.exists() - && let Ok(content) = std::fs::read_to_string(&autoload_path) - && let Some(start) = content.find("ComposerAutoloaderInit") - { - let rest = &content[start + "ComposerAutoloaderInit".len()..]; - if let Some(end) = rest.find("::") { - let candidate = &rest[..end]; - if !candidate.is_empty() && candidate.chars().all(|c| c.is_ascii_hexdigit()) { - return Ok(candidate.to_string()); - } - } - } - if locker.is_locked() - && let Some(data) = locker.lock_data()? - && !data.content_hash.is_empty() - { - return Ok(data.content_hash); - } - // Fall back to MD5 of the current timestamp (mirrors PHP's - // `bin2hex(random_bytes(16))` — both produce a 32-char hex token - // that participates only in classloader naming). - let ts = format!("{:?}", std::time::SystemTime::now()); - Ok(format!("{:x}", md5::compute(ts.as_bytes()))) -} - -fn platform_check_mode_from_config(platform_check: &serde_json::Value) -> PlatformCheckMode { - match platform_check { - serde_json::Value::Bool(false) => PlatformCheckMode::Disabled, - serde_json::Value::Bool(true) => PlatformCheckMode::Full, - serde_json::Value::String(s) if s == "php-only" => PlatformCheckMode::PhpOnly, - // Anything else (including JSON null / unknown strings) falls - // through to `Full` — the safe default that PHP also picks - // when the value is truthy-but-not-`"php-only"`. - _ => PlatformCheckMode::Full, - } -} diff --git a/crates/mozart-autoload/src/lib.rs b/crates/mozart-autoload/src/lib.rs deleted file mode 100644 index 0ee48fe..0000000 --- a/crates/mozart-autoload/src/lib.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod autoload; -pub mod dump; - -pub use dump::{AutoloadGeneratorExt, ClassMap}; |
