aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-class-map-generator
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-22 16:37:49 +0900
committernsfisis <nsfisis@gmail.com>2026-02-22 16:37:49 +0900
commitb696eb7608d921ae0e14a4296e412c33340ceee8 (patch)
tree9a6937bed42ee550553fdb118b9281d00cdce4d9 /crates/mozart-class-map-generator
parent6490fe43676919bc1dcc8659ec4e52da225f92e6 (diff)
downloadphp-mozart-b696eb7608d921ae0e14a4296e412c33340ceee8.tar.gz
php-mozart-b696eb7608d921ae0e14a4296e412c33340ceee8.tar.zst
php-mozart-b696eb7608d921ae0e14a4296e412c33340ceee8.zip
refactor: reorganize crates to match Composer subpackage structure
Rename mozart-constraint to mozart-semver (mirrors composer/semver) and extract mozart-class-map-generator from mozart-autoload (mirrors composer/class-map-generator). No logic changes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart-class-map-generator')
-rw-r--r--crates/mozart-class-map-generator/Cargo.toml11
-rw-r--r--crates/mozart-class-map-generator/src/classmap.rs239
-rw-r--r--crates/mozart-class-map-generator/src/lib.rs8
-rw-r--r--crates/mozart-class-map-generator/src/php_scanner.rs629
4 files changed, 887 insertions, 0 deletions
diff --git a/crates/mozart-class-map-generator/Cargo.toml b/crates/mozart-class-map-generator/Cargo.toml
new file mode 100644
index 0000000..15ae0cb
--- /dev/null
+++ b/crates/mozart-class-map-generator/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "mozart-class-map-generator"
+version.workspace = true
+edition.workspace = true
+
+[dependencies]
+anyhow.workspace = true
+regex.workspace = true
+
+[dev-dependencies]
+tempfile.workspace = true
diff --git a/crates/mozart-class-map-generator/src/classmap.rs b/crates/mozart-class-map-generator/src/classmap.rs
new file mode 100644
index 0000000..e1631f4
--- /dev/null
+++ b/crates/mozart-class-map-generator/src/classmap.rs
@@ -0,0 +1,239 @@
+use std::collections::BTreeMap;
+use std::path::{Path, PathBuf};
+
+/// Recursively collect PHP files from a directory, skipping excluded paths.
+pub 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.
+pub 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.
+pub 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`.
+pub 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.
+pub 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)`.
+pub 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)
+}
diff --git a/crates/mozart-class-map-generator/src/lib.rs b/crates/mozart-class-map-generator/src/lib.rs
new file mode 100644
index 0000000..a17405c
--- /dev/null
+++ b/crates/mozart-class-map-generator/src/lib.rs
@@ -0,0 +1,8 @@
+pub mod classmap;
+pub mod php_scanner;
+
+pub use classmap::{
+ collect_php_files, is_excluded, path_to_php_expr, path_to_static_expr, scan_classmap_dirs,
+ scan_psr_for_classmap,
+};
+pub use php_scanner::{find_classes, is_php_ext, validate_psr0_class, validate_psr4_class};
diff --git a/crates/mozart-class-map-generator/src/php_scanner.rs b/crates/mozart-class-map-generator/src/php_scanner.rs
new file mode 100644
index 0000000..3d0d51d
--- /dev/null
+++ b/crates/mozart-class-map-generator/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"
+ ));
+ }
+}