diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-21 16:37:03 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-21 16:37:03 +0900 |
| commit | 3535037592f149477c915a8b66da974eb59586db (patch) | |
| tree | f476925e9d136d2ddcf206d80355a9a0ca4afda5 /crates | |
| parent | c04744719bd16d9414a9f9a358691d03a993670c (diff) | |
| download | php-mozart-3535037592f149477c915a8b66da974eb59586db.tar.gz php-mozart-3535037592f149477c915a8b66da974eb59586db.tar.zst php-mozart-3535037592f149477c915a8b66da974eb59586db.zip | |
feat(autoload): add classmap scanning, optimize, APCu, platform checks, and strict-psr
Add PHP file scanner (php_scanner.rs) with class/interface/trait/enum
detection, comment/string/heredoc stripping, and PSR-4/PSR-0 validation.
Extend autoload generation with: classmap directory scanning, --optimize
mode (PSR-4/PSR-0 to classmap), --classmap-authoritative, --apcu caching
with optional prefix, platform_check.php generation, and --strict-psr
violation reporting. Wire new options through dump-autoload, install,
require, update, and remove commands.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/mozart/src/autoload.rs | 583 | ||||
| -rw-r--r-- | crates/mozart/src/commands/dump_autoload.rs | 6 | ||||
| -rw-r--r-- | crates/mozart/src/commands/install.rs | 14 | ||||
| -rw-r--r-- | crates/mozart/src/commands/remove.rs | 2 | ||||
| -rw-r--r-- | crates/mozart/src/commands/require.rs | 2 | ||||
| -rw-r--r-- | crates/mozart/src/commands/update.rs | 2 | ||||
| -rw-r--r-- | crates/mozart/src/lib.rs | 1 | ||||
| -rw-r--r-- | crates/mozart/src/php_scanner.rs | 629 |
8 files changed, 1233 insertions, 6 deletions
diff --git a/crates/mozart/src/autoload.rs b/crates/mozart/src/autoload.rs index d4421a6..5d5d13c 100644 --- a/crates/mozart/src/autoload.rs +++ b/crates/mozart/src/autoload.rs @@ -1,5 +1,6 @@ use crate::installed::InstalledPackages; -use std::collections::BTreeMap; +use crate::lockfile::LockedPackage; +use std::collections::{BTreeMap, HashSet}; use std::path::{Path, PathBuf}; // Embed Composer PHP files from the submodule at compile time. @@ -9,6 +10,18 @@ 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). @@ -22,6 +35,18 @@ pub struct AutoloadConfig { 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. @@ -428,8 +453,339 @@ fn generate_autoload_static(static_data: &AutoloadData, suffix: &str) -> String 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) -> String { +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"); @@ -459,6 +815,9 @@ fn generate_autoload_real(suffix: &str, has_files: bool, classmap_authoritative: 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" @@ -469,6 +828,12 @@ fn generate_autoload_real(suffix: &str, has_files: bool, 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!( @@ -654,7 +1019,7 @@ pub fn generate(config: &AutoloadConfig) -> anyhow::Result<()> { }; // 3. Collect autoload data - let (data, static_data) = collect_autoloads( + let (mut data, mut static_data) = collect_autoloads( &installed, root_autoload.as_ref(), root_autoload_dev.as_ref(), @@ -662,6 +1027,96 @@ pub fn generate(config: &AutoloadConfig) -> anyhow::Result<()> { 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)?; @@ -689,14 +1144,94 @@ pub fn generate(config: &AutoloadConfig) -> anyhow::Result<()> { } } + // 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| crate::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), + 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"), @@ -1028,7 +1563,7 @@ mod tests { #[test] fn test_generate_autoload_real_with_files() { - let output = generate_autoload_real("abc123", true, false); + 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")); @@ -1037,12 +1572,36 @@ mod tests { #[test] fn test_generate_autoload_real_without_files() { - let output = generate_autoload_real("abc123", false, false); + 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 // ------------------------------------------------------------------------- @@ -1111,6 +1670,12 @@ mod tests { 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(); @@ -1207,6 +1772,12 @@ mod tests { 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(); diff --git a/crates/mozart/src/commands/dump_autoload.rs b/crates/mozart/src/commands/dump_autoload.rs index 2c17c55..7d5b748 100644 --- a/crates/mozart/src/commands/dump_autoload.rs +++ b/crates/mozart/src/commands/dump_autoload.rs @@ -71,6 +71,12 @@ pub fn execute(args: &DumpAutoloadArgs, cli: &super::Cli) -> anyhow::Result<()> dev_mode, suffix, classmap_authoritative: args.classmap_authoritative, + optimize: args.optimize, + apcu: args.apcu, + apcu_prefix: args.apcu_prefix.clone(), + strict_psr: args.strict_psr, + platform_check: crate::autoload::PlatformCheckMode::Full, + ignore_platform_reqs: args.ignore_platform_reqs, })?; eprintln!("Generated autoload files"); diff --git a/crates/mozart/src/commands/install.rs b/crates/mozart/src/commands/install.rs index 6c023a1..53b827a 100644 --- a/crates/mozart/src/commands/install.rs +++ b/crates/mozart/src/commands/install.rs @@ -110,6 +110,10 @@ pub struct InstallConfig { pub optimize_autoloader: bool, /// Use classmap-only autoloading (implies optimize_autoloader). pub classmap_authoritative: bool, + /// Use APCu to cache found/not-found classes. + pub apcu_autoloader: bool, + /// Custom prefix for APCu autoloader cache. + pub apcu_autoloader_prefix: Option<String>, } impl Default for InstallConfig { @@ -123,6 +127,8 @@ impl Default for InstallConfig { ignore_platform_req: vec![], optimize_autoloader: false, classmap_authoritative: false, + apcu_autoloader: false, + apcu_autoloader_prefix: None, } } } @@ -474,6 +480,12 @@ pub fn install_from_lock( dev_mode, suffix, classmap_authoritative: config.classmap_authoritative, + optimize: config.optimize_autoloader, + apcu: config.apcu_autoloader, + apcu_prefix: config.apcu_autoloader_prefix.clone(), + strict_psr: false, + platform_check: crate::autoload::PlatformCheckMode::Full, + ignore_platform_reqs: config.ignore_platform_reqs, })?; eprintln!("Generated autoload files"); @@ -586,6 +598,8 @@ pub fn execute(args: &InstallArgs, cli: &super::Cli) -> anyhow::Result<()> { ignore_platform_req: args.ignore_platform_req.clone(), optimize_autoloader: args.optimize_autoloader, classmap_authoritative: args.classmap_authoritative, + apcu_autoloader: args.apcu_autoloader, + apcu_autoloader_prefix: args.apcu_autoloader_prefix.clone(), }, ) } diff --git a/crates/mozart/src/commands/remove.rs b/crates/mozart/src/commands/remove.rs index 1c9b619..3010547 100644 --- a/crates/mozart/src/commands/remove.rs +++ b/crates/mozart/src/commands/remove.rs @@ -454,6 +454,8 @@ pub fn execute(args: &RemoveArgs, cli: &super::Cli) -> anyhow::Result<()> { ignore_platform_req: args.ignore_platform_req.clone(), optimize_autoloader: args.optimize_autoloader, classmap_authoritative: args.classmap_authoritative, + apcu_autoloader: false, + apcu_autoloader_prefix: None, }, )?; } diff --git a/crates/mozart/src/commands/require.rs b/crates/mozart/src/commands/require.rs index b1062f0..128e4a9 100644 --- a/crates/mozart/src/commands/require.rs +++ b/crates/mozart/src/commands/require.rs @@ -778,6 +778,8 @@ pub fn execute(args: &RequireArgs, cli: &super::Cli) -> anyhow::Result<()> { ignore_platform_req: args.ignore_platform_req.clone(), optimize_autoloader: args.optimize_autoloader, classmap_authoritative: args.classmap_authoritative, + apcu_autoloader: false, + apcu_autoloader_prefix: None, }, )?; } diff --git a/crates/mozart/src/commands/update.rs b/crates/mozart/src/commands/update.rs index 06dfdeb..fc9400a 100644 --- a/crates/mozart/src/commands/update.rs +++ b/crates/mozart/src/commands/update.rs @@ -978,6 +978,8 @@ pub fn execute(args: &UpdateArgs, cli: &super::Cli) -> anyhow::Result<()> { ignore_platform_req: args.ignore_platform_req.clone(), optimize_autoloader: args.optimize_autoloader, classmap_authoritative: args.classmap_authoritative, + apcu_autoloader: false, + apcu_autoloader_prefix: None, }, )?; } diff --git a/crates/mozart/src/lib.rs b/crates/mozart/src/lib.rs index 392e2a3..f9220e9 100644 --- a/crates/mozart/src/lib.rs +++ b/crates/mozart/src/lib.rs @@ -7,6 +7,7 @@ pub mod installed; pub mod lockfile; pub mod package; pub mod packagist; +pub mod php_scanner; pub mod resolver; pub mod validation; pub mod version; diff --git a/crates/mozart/src/php_scanner.rs b/crates/mozart/src/php_scanner.rs new file mode 100644 index 0000000..3d0d51d --- /dev/null +++ b/crates/mozart/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" + )); + } +} |
