Skip to main content

spikard_cli/codegen/formatters/
php.rs

1//! PHP code formatter for generated code output
2//!
3//! Formats PHP code according to PSR-4, PSR-12, and PSR-7 standards with support for:
4//! - Strict types enforcement via `declare(strict_types=1);`
5//! - `PHPDoc` comment formatting with Psalm/PHPStan type annotations
6//! - Alphabetically sorted and grouped imports (use statements)
7//! - Proper namespace declaration and file structure
8//!
9//! # Design
10//!
11//! This formatter ensures generated PHP code maintains consistency with:
12//! - Single opening `<?php` tag (CRITICAL: duplicates are stripped)
13//! - Proper import grouping: external packages before internal
14//! - `PHPDoc` with `@param`, `@return`, `@var` tags
15//! - PSR-12 formatting conventions
16//!
17//! # Example
18//!
19//! ```no_run
20//! use spikard_cli::codegen::formatters::{Formatter, Import, HeaderMetadata, PhpFormatter};
21//!
22//! let formatter = PhpFormatter::new();
23//! let metadata = HeaderMetadata {
24//!     auto_generated: true,
25//!     schema_file: Some("schema.graphql".to_string()),
26//!     generator_version: Some("0.6.2".to_string()),
27//! };
28//!
29//! let header = formatter.format_header(&metadata);
30//! assert!(header.contains("<?php"));
31//! assert!(header.contains("declare(strict_types=1);"));
32//! ```
33
34use super::{Formatter, HeaderMetadata, Import, Section};
35
36/// PHP code formatter implementing PSR-4, PSR-12, and PSR-7 standards
37///
38/// This formatter generates PHP code that adheres to PHP Standards Recommendations,
39/// ensuring consistency across the spikard toolkit. It handles proper namespace
40/// declarations, type safety via declare statements, and organized imports.
41#[derive(Debug, Clone)]
42pub struct PhpFormatter;
43
44impl PhpFormatter {
45    /// Create a new PHP formatter instance
46    #[must_use]
47    pub const fn new() -> Self {
48        Self
49    }
50}
51
52impl Default for PhpFormatter {
53    fn default() -> Self {
54        Self::new()
55    }
56}
57
58impl Formatter for PhpFormatter {
59    fn format_header(&self, metadata: &HeaderMetadata) -> String {
60        let mut output = String::new();
61
62        output.push_str("<?php\n");
63
64        output.push_str("declare(strict_types=1);\n");
65
66        output.push_str("\n/**\n");
67        output.push_str(" * DO NOT EDIT - Auto-generated by Spikard CLI\n");
68
69        if let Some(schema_file) = &metadata.schema_file {
70            output.push_str(&format!(" * Schema: {schema_file}\n"));
71        }
72
73        if let Some(version) = &metadata.generator_version {
74            output.push_str(&format!(" * Generator version: {version}\n"));
75        }
76
77        if metadata.auto_generated {
78            output.push_str(" *\n");
79            output.push_str(" * This file was automatically generated and should not be manually edited.\n");
80            output.push_str(" * Regenerate from the source schema to incorporate changes.\n");
81        }
82
83        output.push_str(" */\n");
84
85        output
86    }
87
88    fn format_imports(&self, imports: &[Import]) -> String {
89        if imports.is_empty() {
90            return String::new();
91        }
92
93        let mut external = Vec::new();
94        let mut internal = Vec::new();
95
96        for import in imports {
97            if import.module.starts_with('\\') || import.module.contains('\\') {
98                internal.push(import.clone());
99            } else {
100                external.push(import.clone());
101            }
102        }
103
104        external.sort_by(|a, b| a.module.cmp(&b.module));
105        internal.sort_by(|a, b| a.module.cmp(&b.module));
106
107        let mut output = String::new();
108
109        for import in &external {
110            if import.items.is_empty() {
111                output.push_str(&format!("use {};\n", import.module));
112            } else {
113                let items = import.items.join(", ");
114                output.push_str(&format!("use {}\\{{ {} }};\n", import.module, items));
115            }
116        }
117
118        if !external.is_empty() && !internal.is_empty() {
119            output.push('\n');
120        }
121
122        for import in &internal {
123            if import.items.is_empty() {
124                output.push_str(&format!("use {};\n", import.module));
125            } else {
126                let items = import.items.join(", ");
127                output.push_str(&format!("use {}\\{{ {} }};\n", import.module, items));
128            }
129        }
130
131        output.trim_end().to_string()
132    }
133
134    fn format_docstring(&self, content: &str) -> String {
135        let lines: Vec<&str> = content.lines().collect();
136
137        if lines.is_empty() {
138            return "/**\n */".to_string();
139        }
140
141        let mut output = String::new();
142        output.push_str("/**\n");
143
144        for line in lines {
145            let trimmed = line.trim();
146            if trimmed.is_empty() {
147                output.push_str(" *\n");
148            } else {
149                output.push_str(&format!(" * {trimmed}\n"));
150            }
151        }
152
153        output.push_str(" */");
154
155        output
156    }
157
158    fn merge_sections(&self, sections: &[Section]) -> String {
159        let mut header_content = String::new();
160        let mut imports_content = String::new();
161        let mut body_content = String::new();
162
163        for section in sections {
164            match section {
165                Section::Header(content) => header_content.push_str(content),
166                Section::Imports(content) => imports_content.push_str(content),
167                Section::Body(content) => body_content.push_str(content),
168            }
169        }
170
171        let mut output = String::new();
172
173        if !header_content.is_empty() {
174            output.push_str(&header_content);
175            if !output.ends_with('\n') {
176                output.push('\n');
177            }
178        }
179
180        let imports_cleaned = imports_content
181            .lines()
182            .filter(|line| !line.trim().starts_with("<?php"))
183            .collect::<Vec<_>>()
184            .join("\n");
185
186        let body_cleaned = body_content
187            .lines()
188            .filter(|line| !line.trim().starts_with("<?php"))
189            .collect::<Vec<_>>()
190            .join("\n");
191
192        if !imports_cleaned.is_empty() {
193            let imports_trimmed = imports_cleaned.trim();
194            if !imports_trimmed.is_empty() {
195                output.push('\n');
196                output.push_str(imports_trimmed);
197                output.push('\n');
198            }
199        }
200
201        if !body_cleaned.is_empty() {
202            let body_trimmed = body_cleaned.trim();
203            if !body_trimmed.is_empty() {
204                output.push('\n');
205                output.push_str(body_trimmed);
206            }
207        }
208
209        if !output.ends_with('\n') {
210            output.push('\n');
211        }
212
213        output
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn test_format_header_contains_php_tag() {
223        let formatter = PhpFormatter::new();
224        let metadata = HeaderMetadata {
225            auto_generated: true,
226            schema_file: None,
227            generator_version: None,
228        };
229        let header = formatter.format_header(&metadata);
230
231        assert!(header.contains("<?php"));
232        assert!(header.contains("declare(strict_types=1);"));
233        assert!(header.contains("DO NOT EDIT"));
234    }
235
236    #[test]
237    fn test_format_header_with_metadata() {
238        let formatter = PhpFormatter::new();
239        let metadata = HeaderMetadata {
240            auto_generated: true,
241            schema_file: Some("schema.graphql".to_string()),
242            generator_version: Some("0.6.2".to_string()),
243        };
244        let header = formatter.format_header(&metadata);
245
246        assert!(header.contains("schema.graphql"));
247        assert!(header.contains("0.6.2"));
248    }
249
250    #[test]
251    fn test_format_imports_empty() {
252        let formatter = PhpFormatter::new();
253        let imports = vec![];
254        let result = formatter.format_imports(&imports);
255
256        assert!(result.is_empty());
257    }
258
259    #[test]
260    fn test_format_imports_single() {
261        let formatter = PhpFormatter::new();
262        let imports = vec![Import::new("Symfony\\Component\\HttpFoundation\\Response")];
263        let result = formatter.format_imports(&imports);
264
265        assert!(result.contains("use Symfony"));
266    }
267
268    #[test]
269    fn test_format_imports_sorted() {
270        let formatter = PhpFormatter::new();
271        let imports = vec![
272            Import::new("Zend\\Framework"),
273            Import::new("Symfony\\Component"),
274            Import::new("Doctrine\\ORM"),
275        ];
276        let result = formatter.format_imports(&imports);
277
278        let doctrine_pos = result.find("Doctrine").unwrap();
279        let symfony_pos = result.find("Symfony").unwrap();
280        let zend_pos = result.find("Zend").unwrap();
281
282        assert!(doctrine_pos < symfony_pos);
283        assert!(symfony_pos < zend_pos);
284    }
285
286    #[test]
287    fn test_format_docstring() {
288        let formatter = PhpFormatter::new();
289        let content = "This is a test\nWith multiple lines";
290        let result = formatter.format_docstring(content);
291
292        assert!(result.contains("/**"));
293        assert!(result.contains("*/"));
294        assert!(result.contains("This is a test"));
295        assert!(result.contains("With multiple lines"));
296    }
297
298    #[test]
299    fn test_merge_sections_removes_duplicate_php_tags() {
300        let formatter = PhpFormatter::new();
301        let sections = vec![
302            Section::Header("<?php\ndeclare(strict_types=1);\n".to_string()),
303            Section::Imports("<?php\nuse Symfony\\Component;\n".to_string()),
304            Section::Body("class MyClass {}".to_string()),
305        ];
306        let result = formatter.merge_sections(&sections);
307
308        let count = result.matches("<?php").count();
309        assert_eq!(count, 1, "Should have exactly one opening PHP tag");
310    }
311
312    #[test]
313    fn test_merge_sections_ends_with_newline() {
314        let formatter = PhpFormatter::new();
315        let sections = vec![Section::Body("class MyClass {}".to_string())];
316        let result = formatter.merge_sections(&sections);
317
318        assert!(result.ends_with('\n'));
319    }
320}