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");
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(§ions);
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(§ions);
317
318 assert!(result.ends_with('\n'));
319 }
320}