spikard-cli 0.15.6-rc.12

Command-line interface for building and validating Spikard applications
Documentation
//! PHP code formatter for generated code output
//!
//! Formats PHP code according to PSR-4, PSR-12, and PSR-7 standards with support for:
//! - Strict types enforcement via `declare(strict_types=1);`
//! - `PHPDoc` comment formatting with Psalm/PHPStan type annotations
//! - Alphabetically sorted and grouped imports (use statements)
//! - Proper namespace declaration and file structure
//!
//! # Design
//!
//! This formatter ensures generated PHP code maintains consistency with:
//! - Single opening `<?php` tag (CRITICAL: duplicates are stripped)
//! - Proper import grouping: external packages before internal
//! - `PHPDoc` with `@param`, `@return`, `@var` tags
//! - PSR-12 formatting conventions
//!
//! # Example
//!
//! ```no_run
//! use spikard_cli::codegen::formatters::{Formatter, Import, HeaderMetadata, PhpFormatter};
//!
//! let formatter = PhpFormatter::new();
//! let metadata = HeaderMetadata {
//!     auto_generated: true,
//!     schema_file: Some("schema.graphql".to_string()),
//!     generator_version: Some("0.6.2".to_string()),
//! };
//!
//! let header = formatter.format_header(&metadata);
//! assert!(header.contains("<?php"));
//! assert!(header.contains("declare(strict_types=1);"));
//! ```

use super::{Formatter, HeaderMetadata, Import, Section};

/// PHP code formatter implementing PSR-4, PSR-12, and PSR-7 standards
///
/// This formatter generates PHP code that adheres to PHP Standards Recommendations,
/// ensuring consistency across the spikard toolkit. It handles proper namespace
/// declarations, type safety via declare statements, and organized imports.
#[derive(Debug, Clone)]
pub struct PhpFormatter;

impl PhpFormatter {
    /// Create a new PHP formatter instance
    #[must_use]
    pub const fn new() -> Self {
        Self
    }
}

impl Default for PhpFormatter {
    fn default() -> Self {
        Self::new()
    }
}

impl Formatter for PhpFormatter {
    fn format_header(&self, metadata: &HeaderMetadata) -> String {
        let mut output = String::new();

        // Opening PHP tag (CRITICAL: only one)
        output.push_str("<?php\n");

        // Declare strict types
        output.push_str("declare(strict_types=1);\n");

        // Auto-generation notice and metadata
        output.push_str("\n/**\n");
        output.push_str(" * DO NOT EDIT - Auto-generated by Spikard CLI\n");

        if let Some(schema_file) = &metadata.schema_file {
            output.push_str(&format!(" * Schema: {schema_file}\n"));
        }

        if let Some(version) = &metadata.generator_version {
            output.push_str(&format!(" * Generator version: {version}\n"));
        }

        if metadata.auto_generated {
            output.push_str(" *\n");
            output.push_str(" * This file was automatically generated and should not be manually edited.\n");
            output.push_str(" * Regenerate from the source schema to incorporate changes.\n");
        }

        output.push_str(" */\n");

        output
    }

    fn format_imports(&self, imports: &[Import]) -> String {
        if imports.is_empty() {
            return String::new();
        }

        // Group imports: external packages first, then internal
        let mut external = Vec::new();
        let mut internal = Vec::new();

        for import in imports {
            if import.module.starts_with('\\') || import.module.contains('\\') {
                // Namespaced imports (internal)
                internal.push(import.clone());
            } else {
                // Package imports (external)
                external.push(import.clone());
            }
        }

        // Sort each group alphabetically
        external.sort_by(|a, b| a.module.cmp(&b.module));
        internal.sort_by(|a, b| a.module.cmp(&b.module));

        let mut output = String::new();

        // External packages
        for import in &external {
            if import.items.is_empty() {
                output.push_str(&format!("use {};\n", import.module));
            } else {
                let items = import.items.join(", ");
                output.push_str(&format!("use {}\\{{ {} }};\n", import.module, items));
            }
        }

        // Add blank line between groups
        if !external.is_empty() && !internal.is_empty() {
            output.push('\n');
        }

        // Internal namespace imports
        for import in &internal {
            if import.items.is_empty() {
                output.push_str(&format!("use {};\n", import.module));
            } else {
                let items = import.items.join(", ");
                output.push_str(&format!("use {}\\{{ {} }};\n", import.module, items));
            }
        }

        output.trim_end().to_string()
    }

    fn format_docstring(&self, content: &str) -> String {
        let lines: Vec<&str> = content.lines().collect();

        if lines.is_empty() {
            return "/**\n */".to_string();
        }

        let mut output = String::new();
        output.push_str("/**\n");

        for line in lines {
            let trimmed = line.trim();
            if trimmed.is_empty() {
                output.push_str(" *\n");
            } else {
                output.push_str(&format!(" * {trimmed}\n"));
            }
        }

        output.push_str(" */");

        output
    }

    fn merge_sections(&self, sections: &[Section]) -> String {
        let mut header_content = String::new();
        let mut imports_content = String::new();
        let mut body_content = String::new();

        // Separate sections
        for section in sections {
            match section {
                Section::Header(content) => header_content.push_str(content),
                Section::Imports(content) => imports_content.push_str(content),
                Section::Body(content) => body_content.push_str(content),
            }
        }

        let mut output = String::new();

        // Add header
        if !header_content.is_empty() {
            output.push_str(&header_content);
            if !output.ends_with('\n') {
                output.push('\n');
            }
        }

        // Ensure we have only ONE opening <?php tag
        // Strip any duplicate opening tags from imports or body
        let imports_cleaned = imports_content
            .lines()
            .filter(|line| !line.trim().starts_with("<?php"))
            .collect::<Vec<_>>()
            .join("\n");

        let body_cleaned = body_content
            .lines()
            .filter(|line| !line.trim().starts_with("<?php"))
            .collect::<Vec<_>>()
            .join("\n");

        // Add imports with spacing
        if !imports_cleaned.is_empty() {
            let imports_trimmed = imports_cleaned.trim();
            if !imports_trimmed.is_empty() {
                output.push('\n');
                output.push_str(imports_trimmed);
                output.push('\n');
            }
        }

        // Add body with spacing
        if !body_cleaned.is_empty() {
            let body_trimmed = body_cleaned.trim();
            if !body_trimmed.is_empty() {
                output.push('\n');
                output.push_str(body_trimmed);
            }
        }

        // Ensure trailing newline
        if !output.ends_with('\n') {
            output.push('\n');
        }

        output
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_format_header_contains_php_tag() {
        let formatter = PhpFormatter::new();
        let metadata = HeaderMetadata {
            auto_generated: true,
            schema_file: None,
            generator_version: None,
        };
        let header = formatter.format_header(&metadata);

        assert!(header.contains("<?php"));
        assert!(header.contains("declare(strict_types=1);"));
        assert!(header.contains("DO NOT EDIT"));
    }

    #[test]
    fn test_format_header_with_metadata() {
        let formatter = PhpFormatter::new();
        let metadata = HeaderMetadata {
            auto_generated: true,
            schema_file: Some("schema.graphql".to_string()),
            generator_version: Some("0.6.2".to_string()),
        };
        let header = formatter.format_header(&metadata);

        assert!(header.contains("schema.graphql"));
        assert!(header.contains("0.6.2"));
    }

    #[test]
    fn test_format_imports_empty() {
        let formatter = PhpFormatter::new();
        let imports = vec![];
        let result = formatter.format_imports(&imports);

        assert!(result.is_empty());
    }

    #[test]
    fn test_format_imports_single() {
        let formatter = PhpFormatter::new();
        let imports = vec![Import::new("Symfony\\Component\\HttpFoundation\\Response")];
        let result = formatter.format_imports(&imports);

        assert!(result.contains("use Symfony"));
    }

    #[test]
    fn test_format_imports_sorted() {
        let formatter = PhpFormatter::new();
        let imports = vec![
            Import::new("Zend\\Framework"),
            Import::new("Symfony\\Component"),
            Import::new("Doctrine\\ORM"),
        ];
        let result = formatter.format_imports(&imports);

        // Should be alphabetically sorted
        let doctrine_pos = result.find("Doctrine").unwrap();
        let symfony_pos = result.find("Symfony").unwrap();
        let zend_pos = result.find("Zend").unwrap();

        assert!(doctrine_pos < symfony_pos);
        assert!(symfony_pos < zend_pos);
    }

    #[test]
    fn test_format_docstring() {
        let formatter = PhpFormatter::new();
        let content = "This is a test\nWith multiple lines";
        let result = formatter.format_docstring(content);

        assert!(result.contains("/**"));
        assert!(result.contains("*/"));
        assert!(result.contains("This is a test"));
        assert!(result.contains("With multiple lines"));
    }

    #[test]
    fn test_merge_sections_removes_duplicate_php_tags() {
        let formatter = PhpFormatter::new();
        let sections = vec![
            Section::Header("<?php\ndeclare(strict_types=1);\n".to_string()),
            Section::Imports("<?php\nuse Symfony\\Component;\n".to_string()),
            Section::Body("class MyClass {}".to_string()),
        ];
        let result = formatter.merge_sections(&sections);

        // Count occurrences of opening PHP tag
        let count = result.matches("<?php").count();
        assert_eq!(count, 1, "Should have exactly one opening PHP tag");
    }

    #[test]
    fn test_merge_sections_ends_with_newline() {
        let formatter = PhpFormatter::new();
        let sections = vec![Section::Body("class MyClass {}".to_string())];
        let result = formatter.merge_sections(&sections);

        assert!(result.ends_with('\n'));
    }
}