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        // Opening PHP tag (CRITICAL: only one)
63        output.push_str("<?php\n");
64
65        // Declare strict types
66        output.push_str("declare(strict_types=1);\n");
67
68        // Auto-generation notice and metadata
69        output.push_str("\n/**\n");
70        output.push_str(" * DO NOT EDIT - Auto-generated by Spikard CLI\n");
71
72        if let Some(schema_file) = &metadata.schema_file {
73            output.push_str(&format!(" * Schema: {schema_file}\n"));
74        }
75
76        if let Some(version) = &metadata.generator_version {
77            output.push_str(&format!(" * Generator version: {version}\n"));
78        }
79
80        if metadata.auto_generated {
81            output.push_str(" *\n");
82            output.push_str(" * This file was automatically generated and should not be manually edited.\n");
83            output.push_str(" * Regenerate from the source schema to incorporate changes.\n");
84        }
85
86        output.push_str(" */\n");
87
88        output
89    }
90
91    fn format_imports(&self, imports: &[Import]) -> String {
92        if imports.is_empty() {
93            return String::new();
94        }
95
96        // Group imports: external packages first, then internal
97        let mut external = Vec::new();
98        let mut internal = Vec::new();
99
100        for import in imports {
101            if import.module.starts_with('\\') || import.module.contains('\\') {
102                // Namespaced imports (internal)
103                internal.push(import.clone());
104            } else {
105                // Package imports (external)
106                external.push(import.clone());
107            }
108        }
109
110        // Sort each group alphabetically
111        external.sort_by(|a, b| a.module.cmp(&b.module));
112        internal.sort_by(|a, b| a.module.cmp(&b.module));
113
114        let mut output = String::new();
115
116        // External packages
117        for import in &external {
118            if import.items.is_empty() {
119                output.push_str(&format!("use {};\n", import.module));
120            } else {
121                let items = import.items.join(", ");
122                output.push_str(&format!("use {}\\{{ {} }};\n", import.module, items));
123            }
124        }
125
126        // Add blank line between groups
127        if !external.is_empty() && !internal.is_empty() {
128            output.push('\n');
129        }
130
131        // Internal namespace imports
132        for import in &internal {
133            if import.items.is_empty() {
134                output.push_str(&format!("use {};\n", import.module));
135            } else {
136                let items = import.items.join(", ");
137                output.push_str(&format!("use {}\\{{ {} }};\n", import.module, items));
138            }
139        }
140
141        output.trim_end().to_string()
142    }
143
144    fn format_docstring(&self, content: &str) -> String {
145        let lines: Vec<&str> = content.lines().collect();
146
147        if lines.is_empty() {
148            return "/**\n */".to_string();
149        }
150
151        let mut output = String::new();
152        output.push_str("/**\n");
153
154        for line in lines {
155            let trimmed = line.trim();
156            if trimmed.is_empty() {
157                output.push_str(" *\n");
158            } else {
159                output.push_str(&format!(" * {trimmed}\n"));
160            }
161        }
162
163        output.push_str(" */");
164
165        output
166    }
167
168    fn merge_sections(&self, sections: &[Section]) -> String {
169        let mut header_content = String::new();
170        let mut imports_content = String::new();
171        let mut body_content = String::new();
172
173        // Separate sections
174        for section in sections {
175            match section {
176                Section::Header(content) => header_content.push_str(content),
177                Section::Imports(content) => imports_content.push_str(content),
178                Section::Body(content) => body_content.push_str(content),
179            }
180        }
181
182        let mut output = String::new();
183
184        // Add header
185        if !header_content.is_empty() {
186            output.push_str(&header_content);
187            if !output.ends_with('\n') {
188                output.push('\n');
189            }
190        }
191
192        // Ensure we have only ONE opening <?php tag
193        // Strip any duplicate opening tags from imports or body
194        let imports_cleaned = imports_content
195            .lines()
196            .filter(|line| !line.trim().starts_with("<?php"))
197            .collect::<Vec<_>>()
198            .join("\n");
199
200        let body_cleaned = body_content
201            .lines()
202            .filter(|line| !line.trim().starts_with("<?php"))
203            .collect::<Vec<_>>()
204            .join("\n");
205
206        // Add imports with spacing
207        if !imports_cleaned.is_empty() {
208            let imports_trimmed = imports_cleaned.trim();
209            if !imports_trimmed.is_empty() {
210                output.push('\n');
211                output.push_str(imports_trimmed);
212                output.push('\n');
213            }
214        }
215
216        // Add body with spacing
217        if !body_cleaned.is_empty() {
218            let body_trimmed = body_cleaned.trim();
219            if !body_trimmed.is_empty() {
220                output.push('\n');
221                output.push_str(body_trimmed);
222            }
223        }
224
225        // Ensure trailing newline
226        if !output.ends_with('\n') {
227            output.push('\n');
228        }
229
230        output
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn test_format_header_contains_php_tag() {
240        let formatter = PhpFormatter::new();
241        let metadata = HeaderMetadata {
242            auto_generated: true,
243            schema_file: None,
244            generator_version: None,
245        };
246        let header = formatter.format_header(&metadata);
247
248        assert!(header.contains("<?php"));
249        assert!(header.contains("declare(strict_types=1);"));
250        assert!(header.contains("DO NOT EDIT"));
251    }
252
253    #[test]
254    fn test_format_header_with_metadata() {
255        let formatter = PhpFormatter::new();
256        let metadata = HeaderMetadata {
257            auto_generated: true,
258            schema_file: Some("schema.graphql".to_string()),
259            generator_version: Some("0.6.2".to_string()),
260        };
261        let header = formatter.format_header(&metadata);
262
263        assert!(header.contains("schema.graphql"));
264        assert!(header.contains("0.6.2"));
265    }
266
267    #[test]
268    fn test_format_imports_empty() {
269        let formatter = PhpFormatter::new();
270        let imports = vec![];
271        let result = formatter.format_imports(&imports);
272
273        assert!(result.is_empty());
274    }
275
276    #[test]
277    fn test_format_imports_single() {
278        let formatter = PhpFormatter::new();
279        let imports = vec![Import::new("Symfony\\Component\\HttpFoundation\\Response")];
280        let result = formatter.format_imports(&imports);
281
282        assert!(result.contains("use Symfony"));
283    }
284
285    #[test]
286    fn test_format_imports_sorted() {
287        let formatter = PhpFormatter::new();
288        let imports = vec![
289            Import::new("Zend\\Framework"),
290            Import::new("Symfony\\Component"),
291            Import::new("Doctrine\\ORM"),
292        ];
293        let result = formatter.format_imports(&imports);
294
295        // Should be alphabetically sorted
296        let doctrine_pos = result.find("Doctrine").unwrap();
297        let symfony_pos = result.find("Symfony").unwrap();
298        let zend_pos = result.find("Zend").unwrap();
299
300        assert!(doctrine_pos < symfony_pos);
301        assert!(symfony_pos < zend_pos);
302    }
303
304    #[test]
305    fn test_format_docstring() {
306        let formatter = PhpFormatter::new();
307        let content = "This is a test\nWith multiple lines";
308        let result = formatter.format_docstring(content);
309
310        assert!(result.contains("/**"));
311        assert!(result.contains("*/"));
312        assert!(result.contains("This is a test"));
313        assert!(result.contains("With multiple lines"));
314    }
315
316    #[test]
317    fn test_merge_sections_removes_duplicate_php_tags() {
318        let formatter = PhpFormatter::new();
319        let sections = vec![
320            Section::Header("<?php\ndeclare(strict_types=1);\n".to_string()),
321            Section::Imports("<?php\nuse Symfony\\Component;\n".to_string()),
322            Section::Body("class MyClass {}".to_string()),
323        ];
324        let result = formatter.merge_sections(&sections);
325
326        // Count occurrences of opening PHP tag
327        let count = result.matches("<?php").count();
328        assert_eq!(count, 1, "Should have exactly one opening PHP tag");
329    }
330
331    #[test]
332    fn test_merge_sections_ends_with_newline() {
333        let formatter = PhpFormatter::new();
334        let sections = vec![Section::Body("class MyClass {}".to_string())];
335        let result = formatter.merge_sections(&sections);
336
337        assert!(result.ends_with('\n'));
338    }
339}