spikard_cli/codegen/formatters/
php.rs1use super::{Formatter, HeaderMetadata, Import, Section};
35
36#[derive(Debug, Clone)]
42pub struct PhpFormatter;
43
44impl PhpFormatter {
45 #[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");
64
65 output.push_str("declare(strict_types=1);\n");
67
68 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 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 internal.push(import.clone());
104 } else {
105 external.push(import.clone());
107 }
108 }
109
110 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 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 if !external.is_empty() && !internal.is_empty() {
128 output.push('\n');
129 }
130
131 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 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 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 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 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 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 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 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(§ions);
325
326 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(§ions);
336
337 assert!(result.ends_with('\n'));
338 }
339}