use super::{Formatter, HeaderMetadata, Import, Section};
#[derive(Debug, Clone)]
pub struct PhpFormatter;
impl PhpFormatter {
#[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();
output.push_str("<?php\n");
output.push_str("declare(strict_types=1);\n");
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();
}
let mut external = Vec::new();
let mut internal = Vec::new();
for import in imports {
if import.module.starts_with('\\') || import.module.contains('\\') {
internal.push(import.clone());
} else {
external.push(import.clone());
}
}
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();
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));
}
}
if !external.is_empty() && !internal.is_empty() {
output.push('\n');
}
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();
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();
if !header_content.is_empty() {
output.push_str(&header_content);
if !output.ends_with('\n') {
output.push('\n');
}
}
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");
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');
}
}
if !body_cleaned.is_empty() {
let body_trimmed = body_cleaned.trim();
if !body_trimmed.is_empty() {
output.push('\n');
output.push_str(body_trimmed);
}
}
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);
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(§ions);
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(§ions);
assert!(result.ends_with('\n'));
}
}