Skip to main content

spikard_cli/codegen/
php.rs

1//! PHP code generation from `OpenAPI` schemas
2
3use super::PhpDtoStyle;
4use anyhow::Result;
5use heck::{ToPascalCase, ToSnakeCase};
6use openapiv3::{
7    OpenAPI, Operation, Parameter, ParameterData, ParameterSchemaOrContent, ReferenceOr, Schema, SchemaKind,
8    StringFormat, Type, VariantOrUnknownOrEmpty,
9};
10use std::collections::BTreeSet;
11
12pub struct PhpGenerator {
13    spec: OpenAPI,
14    style: PhpDtoStyle,
15}
16
17impl PhpGenerator {
18    #[must_use]
19    pub const fn new(spec: OpenAPI, style: PhpDtoStyle) -> Self {
20        Self { spec, style }
21    }
22
23    /// Generate complete PHP code from `OpenAPI` spec
24    /// Returns the complete PHP file as a string
25    pub fn generate(&self) -> Result<String> {
26        let mut output = String::new();
27        match self.style {
28            PhpDtoStyle::ReadonlyClass => {}
29        }
30
31        output.push_str(&self.generate_header());
32        output.push_str(&self.generate_models()?);
33        output.push_str(&self.generate_controllers()?);
34        output.push_str(&self.generate_main());
35
36        Ok(output)
37    }
38
39    /// Generate PHP file header with strict types and namespace declaration
40    fn generate_header(&self) -> String {
41        let title = Self::escape_php_string(&self.spec.info.title);
42        let openapi = Self::escape_php_string(&self.spec.openapi);
43
44        format!(
45            "<?php\n/**\n * Generated by Spikard OpenAPI code generator\n * OpenAPI Version: {openapi}\n * Title: {title}\n * DO NOT EDIT - regenerate from OpenAPI schema\n */\n\ndeclare(strict_types=1);\n\nnamespace SpikardGenerated;\n\n"
46        )
47    }
48
49    fn generate_models(&self) -> Result<String> {
50        let mut output = String::new();
51        output.push_str("// Schema Models\n\n");
52        let mut emitted_models = BTreeSet::new();
53        let mut emitted_enums = BTreeSet::new();
54
55        if self.uses_uuid_types() {
56            output.push_str(&self.generate_uuid_value_class());
57            output.push('\n');
58        }
59
60        if let Some(components) = &self.spec.components {
61            for (name, schema_ref) in &components.schemas {
62                match schema_ref {
63                    ReferenceOr::Item(schema) => {
64                        self.generate_schema_family(
65                            &name.to_pascal_case(),
66                            schema,
67                            &mut emitted_models,
68                            &mut emitted_enums,
69                            &mut output,
70                        )?;
71                    }
72                    ReferenceOr::Reference { .. } => {
73                        continue;
74                    }
75                }
76            }
77        }
78
79        self.generate_inline_route_models(&mut emitted_models, &mut emitted_enums, &mut output)?;
80        self.generate_inline_parameter_enums(&mut emitted_enums, &mut output)?;
81
82        Ok(output)
83    }
84
85    /// Generate a PHP model class for an `OpenAPI` schema
86    fn generate_model_class(&self, class_name: &str, schema: &Schema) -> Result<String> {
87        let mut output = String::new();
88
89        self.append_php_doc(&mut output, &schema.schema_data.description, &class_name);
90
91        output.push_str(&format!("readonly class {class_name}\n{{\n"));
92
93        match &schema.schema_kind {
94            SchemaKind::Type(Type::Object(obj)) => {
95                if obj.properties.is_empty() {
96                    output.push_str("    // Empty schema\n");
97                } else {
98                    self.append_constructor(&mut output, class_name, obj)?;
99                }
100            }
101            _ => {
102                output.push_str("    // Unsupported schema type\n");
103            }
104        }
105
106        output.push_str("}\n");
107
108        Ok(output)
109    }
110
111    fn generate_enum_class(&self, enum_name: &str, schema: &Schema) -> Result<String> {
112        let mut output = String::new();
113
114        self.append_php_doc(&mut output, &schema.schema_data.description, enum_name);
115        output.push_str(&format!("enum {enum_name}: string\n{{\n"));
116
117        let SchemaKind::Type(Type::String(string_type)) = &schema.schema_kind else {
118            output.push_str("}\n");
119            return Ok(output);
120        };
121
122        for value in string_type.enumeration.iter().flatten() {
123            output.push_str(&format!(
124                "    case {} = '{}';\n",
125                Self::enum_case_name(value),
126                Self::escape_php_string(value)
127            ));
128        }
129
130        output.push_str("}\n");
131        Ok(output)
132    }
133
134    fn generate_uuid_value_class(&self) -> String {
135        String::from(
136            "/**\n * UUID value object\n */\nreadonly class UuidValue\n{\n    public function __construct(public string $value)\n    {\n        if (!preg_match('/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/', $value)) {\n            throw new \\InvalidArgumentException('Invalid UUID value');\n        }\n    }\n\n    public function __toString(): string\n    {\n        return $this->value;\n    }\n}\n",
137        )
138    }
139
140    fn generate_schema_family(
141        &self,
142        class_name: &str,
143        schema: &Schema,
144        emitted_models: &mut BTreeSet<String>,
145        emitted_enums: &mut BTreeSet<String>,
146        output: &mut String,
147    ) -> Result<()> {
148        if Self::is_enum_schema(schema) {
149            return self.generate_enum_family(class_name, schema, emitted_enums, output);
150        }
151
152        if !emitted_models.insert(class_name.to_string()) {
153            return Ok(());
154        }
155
156        self.generate_nested_model_families(class_name, schema, emitted_models, emitted_enums, output)?;
157        output.push_str(&self.generate_model_class(class_name, schema)?);
158        output.push('\n');
159        Ok(())
160    }
161
162    fn generate_enum_family(
163        &self,
164        enum_name: &str,
165        schema: &Schema,
166        emitted_enums: &mut BTreeSet<String>,
167        output: &mut String,
168    ) -> Result<()> {
169        if !emitted_enums.insert(enum_name.to_string()) {
170            return Ok(());
171        }
172
173        output.push_str(&self.generate_enum_class(enum_name, schema)?);
174        output.push('\n');
175        Ok(())
176    }
177
178    fn generate_nested_model_families(
179        &self,
180        parent_class_name: &str,
181        schema: &Schema,
182        emitted_models: &mut BTreeSet<String>,
183        emitted_enums: &mut BTreeSet<String>,
184        output: &mut String,
185    ) -> Result<()> {
186        match &schema.schema_kind {
187            SchemaKind::Type(Type::Object(obj)) => {
188                for (prop_name, prop_schema_ref) in &obj.properties {
189                    match prop_schema_ref {
190                        ReferenceOr::Item(prop_schema) => {
191                            if let Some(enum_name) = self.inline_enum_name(parent_class_name, prop_name, prop_schema) {
192                                self.generate_enum_family(&enum_name, prop_schema, emitted_enums, output)?;
193                            }
194                            if let Some(class_name) = self.inline_model_name(parent_class_name, prop_name, prop_schema)
195                            {
196                                self.generate_schema_family(
197                                    &class_name,
198                                    prop_schema,
199                                    emitted_models,
200                                    emitted_enums,
201                                    output,
202                                )?;
203                            }
204                            if let Some(array_item_name) =
205                                self.inline_array_item_model_name(parent_class_name, prop_name, prop_schema)
206                                && let Some(item_schema) = Self::inline_array_item_schema(prop_schema)
207                            {
208                                self.generate_schema_family(
209                                    &array_item_name,
210                                    item_schema,
211                                    emitted_models,
212                                    emitted_enums,
213                                    output,
214                                )?;
215                            }
216                            if let Some(array_item_enum_name) =
217                                self.inline_array_item_enum_name(parent_class_name, prop_name, prop_schema)
218                                && let Some(item_schema) = Self::inline_array_item_schema(prop_schema)
219                            {
220                                self.generate_enum_family(&array_item_enum_name, item_schema, emitted_enums, output)?;
221                            }
222                        }
223                        ReferenceOr::Reference { .. } => {}
224                    }
225                }
226            }
227            SchemaKind::AllOf { all_of } => {
228                for schema_ref in all_of {
229                    match schema_ref {
230                        ReferenceOr::Item(item_schema) => self.generate_nested_model_families(
231                            parent_class_name,
232                            item_schema,
233                            emitted_models,
234                            emitted_enums,
235                            output,
236                        )?,
237                        ReferenceOr::Reference { reference } => {
238                            if let Some(item_schema) = self.resolve_schema_reference(reference) {
239                                self.generate_nested_model_families(
240                                    parent_class_name,
241                                    item_schema,
242                                    emitted_models,
243                                    emitted_enums,
244                                    output,
245                                )?;
246                            }
247                        }
248                    }
249                }
250            }
251            _ => {}
252        }
253
254        Ok(())
255    }
256
257    fn generate_inline_route_models(
258        &self,
259        emitted_models: &mut BTreeSet<String>,
260        emitted_enums: &mut BTreeSet<String>,
261        output: &mut String,
262    ) -> Result<()> {
263        for path_item_ref in self.spec.paths.paths.values() {
264            let ReferenceOr::Item(path_item) = path_item_ref else {
265                continue;
266            };
267
268            for operation in [
269                path_item.get.as_ref(),
270                path_item.post.as_ref(),
271                path_item.put.as_ref(),
272                path_item.delete.as_ref(),
273                path_item.patch.as_ref(),
274            ]
275            .into_iter()
276            .flatten()
277            {
278                if let Some((class_name, schema)) = self.inline_request_body_model(operation) {
279                    self.generate_schema_family(&class_name, schema, emitted_models, emitted_enums, output)?;
280                }
281                if let Some((class_name, schema)) = self.inline_response_model(operation) {
282                    self.generate_schema_family(&class_name, schema, emitted_models, emitted_enums, output)?;
283                }
284            }
285        }
286
287        Ok(())
288    }
289
290    fn generate_inline_parameter_enums(&self, emitted_enums: &mut BTreeSet<String>, output: &mut String) -> Result<()> {
291        for path_item_ref in self.spec.paths.paths.values() {
292            let ReferenceOr::Item(path_item) = path_item_ref else {
293                continue;
294            };
295
296            for operation in [
297                path_item.get.as_ref(),
298                path_item.post.as_ref(),
299                path_item.put.as_ref(),
300                path_item.delete.as_ref(),
301                path_item.patch.as_ref(),
302            ]
303            .into_iter()
304            .flatten()
305            {
306                let operation_id = operation.operation_id.as_deref();
307
308                for parameter_ref in &operation.parameters {
309                    let ReferenceOr::Item(parameter) = parameter_ref else {
310                        continue;
311                    };
312
313                    let parameter_data = match parameter {
314                        Parameter::Path { parameter_data, .. }
315                        | Parameter::Query { parameter_data, .. }
316                        | Parameter::Header { parameter_data, .. }
317                        | Parameter::Cookie { parameter_data, .. } => parameter_data,
318                    };
319
320                    let ParameterSchemaOrContent::Schema(ReferenceOr::Item(schema)) = &parameter_data.format else {
321                        continue;
322                    };
323
324                    let Some(enum_name) = self.parameter_enum_name(operation_id, &parameter_data.name, schema) else {
325                        continue;
326                    };
327
328                    self.generate_enum_family(&enum_name, schema, emitted_enums, output)?;
329                }
330            }
331        }
332
333        Ok(())
334    }
335
336    /// Append `PHPDoc` comment block to output
337    fn append_php_doc(&self, output: &mut String, description: &Option<String>, class_name: &str) {
338        output.push_str("/**\n");
339        if let Some(desc) = description {
340            let escaped = Self::escape_php_string(desc);
341            output.push_str(&format!(" * {escaped}\n"));
342        } else {
343            output.push_str(&format!(" * {class_name} model\n"));
344        }
345        output.push_str(" */\n");
346    }
347
348    /// Append constructor method with typed properties
349    fn append_constructor(&self, output: &mut String, class_name: &str, obj: &openapiv3::ObjectType) -> Result<()> {
350        let (property_lines, property_docs) = self.build_constructor_params(class_name, obj)?;
351
352        if !property_docs.is_empty() {
353            output.push_str("    /**\n");
354            for doc_line in &property_docs {
355                output.push_str(doc_line);
356            }
357            output.push_str("     */\n");
358        }
359
360        output.push_str("    public function __construct(\n");
361
362        let props_str = property_lines.join("");
363        let props_str = props_str.trim_end_matches(",\n").to_string() + "\n";
364        output.push_str(&props_str);
365
366        output.push_str("    ) {}\n");
367        Ok(())
368    }
369
370    /// Build constructor parameter declarations for object properties
371    /// Partitions properties so required parameters come first (PHP 8.1+ requirement)
372    fn build_constructor_params(
373        &self,
374        class_name: &str,
375        obj: &openapiv3::ObjectType,
376    ) -> Result<(Vec<String>, Vec<String>)> {
377        let mut required_props = Vec::new();
378        let mut optional_props = Vec::new();
379        let mut required_docs = Vec::new();
380        let mut optional_docs = Vec::new();
381
382        for (prop_name, prop_schema_ref) in &obj.properties {
383            let is_required = obj.required.contains(prop_name);
384            let field_name = Self::to_camel_case(prop_name);
385
386            let (type_hint, nullable, phpdoc_type) = match prop_schema_ref {
387                ReferenceOr::Item(prop_schema) => {
388                    let (type_hint, nullable) =
389                        self.schema_to_php_type(Some(class_name), Some(prop_name), prop_schema, !is_required, None);
390                    let phpdoc_type =
391                        self.schema_to_phpdoc_type(Some(class_name), Some(prop_name), prop_schema, !is_required, None);
392                    (type_hint, nullable, phpdoc_type)
393                }
394                ReferenceOr::Reference { reference } => {
395                    let ref_name = self.extract_ref_name(reference);
396                    let ref_type = ref_name.to_pascal_case();
397                    if is_required {
398                        (ref_type.clone(), false, ref_type)
399                    } else {
400                        (format!("?{ref_type}"), true, format!("{ref_type}|null"))
401                    }
402                }
403            };
404
405            let prop_line = self.build_property_line(&type_hint, &field_name, is_required, nullable);
406            let doc_line = format!("     * @param {phpdoc_type} ${field_name}\n");
407
408            if is_required {
409                required_props.push(prop_line);
410                required_docs.push(doc_line);
411            } else {
412                optional_props.push(prop_line);
413                optional_docs.push(doc_line);
414            }
415        }
416
417        let mut property_lines = required_props;
418        property_lines.extend(optional_props);
419        let mut property_docs = required_docs;
420        property_docs.extend(optional_docs);
421
422        Ok((property_lines, property_docs))
423    }
424
425    /// Build a single property parameter line for constructor
426    fn build_property_line(&self, type_hint: &str, field_name: &str, is_required: bool, nullable: bool) -> String {
427        if is_required {
428            format!("        public {type_hint} ${field_name},\n")
429        } else if nullable {
430            format!("        public {type_hint} ${field_name} = null,\n")
431        } else {
432            format!("        public ?{type_hint} ${field_name} = null,\n")
433        }
434    }
435
436    /// Escape PHP string content for safe output
437    /// Handles special characters in PHP string context
438    fn escape_php_string(s: &str) -> String {
439        s.chars()
440            .flat_map(|c| match c {
441                '\\' => vec!['\\', '\\'],
442                '\'' => vec!['\\', '\''],
443                '\n' => vec!['\\', 'n'],
444                '\r' => vec!['\\', 'r'],
445                '\t' => vec!['\\', 't'],
446                _ => vec![c],
447            })
448            .collect()
449    }
450
451    /// Extract the last component of a JSON reference path
452    fn extract_ref_name(&self, reference: &str) -> String {
453        reference.split('/').next_back().unwrap_or("UnknownType").to_string()
454    }
455
456    fn resolve_schema_reference<'a>(&'a self, reference: &str) -> Option<&'a Schema> {
457        let name = reference.split('/').next_back()?;
458        self.spec
459            .components
460            .as_ref()?
461            .schemas
462            .get(name)
463            .and_then(|schema_ref| match schema_ref {
464                ReferenceOr::Item(schema) => Some(schema),
465                ReferenceOr::Reference { .. } => None,
466            })
467    }
468
469    fn uses_uuid_types(&self) -> bool {
470        self.spec.components.as_ref().is_some_and(|components| {
471            components
472                .schemas
473                .values()
474                .filter_map(|schema_ref| match schema_ref {
475                    ReferenceOr::Item(schema) => Some(schema),
476                    ReferenceOr::Reference { .. } => None,
477                })
478                .any(|schema| self.schema_uses_uuid_type(schema))
479        }) || self.spec.paths.paths.values().any(|path_item_ref| {
480            let ReferenceOr::Item(path_item) = path_item_ref else {
481                return false;
482            };
483
484            [
485                path_item.get.as_ref(),
486                path_item.post.as_ref(),
487                path_item.put.as_ref(),
488                path_item.delete.as_ref(),
489                path_item.patch.as_ref(),
490            ]
491            .into_iter()
492            .flatten()
493            .any(|operation| self.operation_uses_uuid_type(operation))
494        })
495    }
496
497    fn operation_uses_uuid_type(&self, operation: &Operation) -> bool {
498        operation.parameters.iter().any(|parameter_ref| {
499            let ReferenceOr::Item(parameter) = parameter_ref else {
500                return false;
501            };
502            match parameter {
503                Parameter::Path { parameter_data, .. }
504                | Parameter::Query { parameter_data, .. }
505                | Parameter::Header { parameter_data, .. }
506                | Parameter::Cookie { parameter_data, .. } => {
507                    let ParameterSchemaOrContent::Schema(schema_ref) = &parameter_data.format else {
508                        return false;
509                    };
510                    match schema_ref {
511                        ReferenceOr::Item(schema) => self.schema_uses_uuid_type(schema),
512                        ReferenceOr::Reference { reference } => self
513                            .resolve_schema_reference(reference)
514                            .is_some_and(|schema| self.schema_uses_uuid_type(schema)),
515                    }
516                }
517            }
518        }) || operation
519            .request_body
520            .as_ref()
521            .and_then(|body_ref| match body_ref {
522                ReferenceOr::Item(request_body) => request_body
523                    .content
524                    .get("application/json")
525                    .and_then(|media_type| media_type.schema.as_ref())
526                    .and_then(|schema_ref| match schema_ref {
527                        ReferenceOr::Item(schema) => Some(schema),
528                        ReferenceOr::Reference { reference } => self.resolve_schema_reference(reference),
529                    }),
530                ReferenceOr::Reference { .. } => None,
531            })
532            .is_some_and(|schema| self.schema_uses_uuid_type(schema))
533    }
534
535    fn schema_uses_uuid_type(&self, schema: &Schema) -> bool {
536        let SchemaKind::Type(ty) = &schema.schema_kind else {
537            if let SchemaKind::AllOf { all_of } = &schema.schema_kind {
538                return all_of.iter().any(|schema_ref| match schema_ref {
539                    ReferenceOr::Item(schema) => self.schema_uses_uuid_type(schema),
540                    ReferenceOr::Reference { reference } => self
541                        .resolve_schema_reference(reference)
542                        .is_some_and(|schema| self.schema_uses_uuid_type(schema)),
543                });
544            }
545            return false;
546        };
547
548        match ty {
549            Type::String(string_type) => {
550                matches!(&string_type.format, VariantOrUnknownOrEmpty::Unknown(format) if format == "uuid")
551            }
552            Type::Array(array_type) => array_type
553                .items
554                .as_ref()
555                .and_then(|item_schema| match item_schema {
556                    ReferenceOr::Item(schema) => Some(schema.as_ref()),
557                    ReferenceOr::Reference { reference } => self.resolve_schema_reference(reference),
558                })
559                .is_some_and(|schema| self.schema_uses_uuid_type(schema)),
560            Type::Object(object_type) => object_type.properties.values().any(|schema_ref| match schema_ref {
561                ReferenceOr::Item(schema) => self.schema_uses_uuid_type(schema),
562                ReferenceOr::Reference { reference } => self
563                    .resolve_schema_reference(reference)
564                    .is_some_and(|schema| self.schema_uses_uuid_type(schema)),
565            }),
566            _ => false,
567        }
568    }
569
570    /// Convert `snake_case` or kebab-case to camelCase
571    fn to_camel_case(s: &str) -> String {
572        let snake = s.to_snake_case();
573        let mut chars = snake.chars();
574        match chars.next() {
575            None => String::new(),
576            Some(first) => {
577                let mut result = first.to_lowercase().collect::<String>();
578                let mut capitalize_next = false;
579                for c in chars {
580                    if c == '_' {
581                        capitalize_next = true;
582                    } else if capitalize_next {
583                        result.push_str(&c.to_uppercase().to_string());
584                        capitalize_next = false;
585                    } else {
586                        result.push(c);
587                    }
588                }
589                result
590            }
591        }
592    }
593
594    fn inline_model_name(&self, parent_class_name: &str, field_name: &str, schema: &Schema) -> Option<String> {
595        match &schema.schema_kind {
596            SchemaKind::Type(Type::Object(obj)) if !obj.properties.is_empty() => {
597                Some(format!("{parent_class_name}{}", field_name.to_pascal_case()))
598            }
599            _ => None,
600        }
601    }
602
603    fn inline_enum_name(&self, parent_class_name: &str, field_name: &str, schema: &Schema) -> Option<String> {
604        Self::is_enum_schema(schema).then(|| format!("{parent_class_name}{}", field_name.to_pascal_case()))
605    }
606
607    fn inline_array_item_model_name(
608        &self,
609        parent_class_name: &str,
610        field_name: &str,
611        schema: &Schema,
612    ) -> Option<String> {
613        let item_schema = Self::inline_array_item_schema(schema)?;
614        match &item_schema.schema_kind {
615            SchemaKind::Type(Type::Object(obj)) if !obj.properties.is_empty() => {
616                Some(format!("{parent_class_name}{}Item", field_name.to_pascal_case()))
617            }
618            _ => None,
619        }
620    }
621
622    fn inline_array_item_enum_name(
623        &self,
624        parent_class_name: &str,
625        field_name: &str,
626        schema: &Schema,
627    ) -> Option<String> {
628        let item_schema = Self::inline_array_item_schema(schema)?;
629        Self::is_enum_schema(item_schema).then(|| format!("{parent_class_name}{}Item", field_name.to_pascal_case()))
630    }
631
632    fn inline_array_item_schema(schema: &Schema) -> Option<&Schema> {
633        match &schema.schema_kind {
634            SchemaKind::Type(Type::Array(array_type)) => match &array_type.items {
635                Some(ReferenceOr::Item(item_schema)) => Some(item_schema),
636                _ => None,
637            },
638            _ => None,
639        }
640    }
641
642    fn parameter_enum_name(&self, operation_id: Option<&str>, parameter_name: &str, schema: &Schema) -> Option<String> {
643        Self::is_enum_schema(schema).then(|| {
644            let operation_prefix = operation_id
645                .map(str::to_pascal_case)
646                .unwrap_or_else(|| "Operation".to_string());
647            format!("{operation_prefix}{}", parameter_name.to_pascal_case())
648        })
649    }
650
651    fn inline_request_body_model<'a>(&self, operation: &'a Operation) -> Option<(String, &'a Schema)> {
652        let operation_id = operation.operation_id.as_deref()?;
653        let request_body = operation.request_body.as_ref()?;
654
655        match request_body {
656            ReferenceOr::Item(body) => {
657                let schema_ref = body.content.get("application/json")?.schema.as_ref()?;
658                match schema_ref {
659                    ReferenceOr::Item(schema) if Self::is_named_inline_object_schema(schema) => {
660                        Some((format!("{}RequestBody", operation_id.to_pascal_case()), schema))
661                    }
662                    _ => None,
663                }
664            }
665            ReferenceOr::Reference { .. } => None,
666        }
667    }
668
669    fn inline_response_model<'a>(&self, operation: &'a Operation) -> Option<(String, &'a Schema)> {
670        use openapiv3::StatusCode;
671
672        let operation_id = operation.operation_id.as_deref()?;
673        let response = operation
674            .responses
675            .responses
676            .get(&StatusCode::Code(200))
677            .or_else(|| operation.responses.responses.get(&StatusCode::Code(201)))
678            .or_else(|| operation.responses.responses.get(&StatusCode::Range(2)))?;
679
680        match response {
681            ReferenceOr::Item(response) => {
682                let schema_ref = response.content.get("application/json")?.schema.as_ref()?;
683                match schema_ref {
684                    ReferenceOr::Item(schema) if Self::is_named_inline_object_schema(schema) => {
685                        Some((format!("{}ResponseBody", operation_id.to_pascal_case()), schema))
686                    }
687                    _ => None,
688                }
689            }
690            ReferenceOr::Reference { .. } => None,
691        }
692    }
693
694    fn is_named_inline_object_schema(schema: &Schema) -> bool {
695        matches!(&schema.schema_kind, SchemaKind::Type(Type::Object(obj)) if !obj.properties.is_empty())
696    }
697
698    fn is_enum_schema(schema: &Schema) -> bool {
699        matches!(&schema.schema_kind, SchemaKind::Type(Type::String(string_type)) if !string_type.enumeration.is_empty())
700    }
701
702    fn enum_case_name(value: &str) -> String {
703        let mut name = value
704            .chars()
705            .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { ' ' })
706            .collect::<String>()
707            .to_pascal_case();
708        if name.is_empty() {
709            name = "Value".to_string();
710        }
711        if name.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
712            name.insert_str(0, "Value");
713        }
714        name
715    }
716
717    fn enum_type_name(
718        &self,
719        parent_class_name: Option<&str>,
720        field_name: Option<&str>,
721        schema: &Schema,
722        inline_name: Option<&str>,
723    ) -> Option<String> {
724        Self::is_enum_schema(schema).then(|| {
725            inline_name
726                .map(ToOwned::to_owned)
727                .or_else(|| {
728                    parent_class_name
729                        .zip(field_name)
730                        .map(|(parent, field)| format!("{parent}{}", field.to_pascal_case()))
731                })
732                .unwrap_or_else(|| "StringBackedEnum".to_string())
733        })
734    }
735
736    fn string_format_php_type(&self, string_type: &openapiv3::StringType) -> String {
737        match &string_type.format {
738            VariantOrUnknownOrEmpty::Item(StringFormat::Date | StringFormat::DateTime) => {
739                "\\DateTimeImmutable".to_string()
740            }
741            VariantOrUnknownOrEmpty::Unknown(format) if format == "uuid" => "UuidValue".to_string(),
742            _ => "string".to_string(),
743        }
744    }
745
746    /// Extract type name from a schema reference or inline schema
747    fn extract_type_from_schema_ref(&self, schema_ref: &ReferenceOr<Schema>, inline_name: Option<&str>) -> String {
748        match schema_ref {
749            ReferenceOr::Reference { reference } => self.extract_ref_name(reference).to_pascal_case(),
750            ReferenceOr::Item(schema) => self.schema_to_php_type(None, None, schema, false, inline_name).0,
751        }
752    }
753
754    /// Extract request body type from operation if present
755    fn extract_request_body_type(&self, operation: &Operation) -> Option<String> {
756        operation.request_body.as_ref().and_then(|body_ref| match body_ref {
757            ReferenceOr::Item(request_body) => self.extract_json_schema_type(
758                request_body.content.get("application/json"),
759                operation
760                    .operation_id
761                    .as_deref()
762                    .map(|id| format!("{}RequestBody", id.to_pascal_case()))
763                    .as_deref(),
764            ),
765            ReferenceOr::Reference { reference } => {
766                let ref_name = self.extract_ref_name(reference);
767                Some(ref_name.to_pascal_case())
768            }
769        })
770    }
771
772    /// Extract JSON schema type from media type content
773    fn extract_json_schema_type(
774        &self,
775        media_type: Option<&openapiv3::MediaType>,
776        inline_name: Option<&str>,
777    ) -> Option<String> {
778        media_type.and_then(|mt| {
779            mt.schema
780                .as_ref()
781                .map(|schema_ref| self.extract_type_from_schema_ref(schema_ref, inline_name))
782        })
783    }
784
785    fn extract_json_schema_doc_type(
786        &self,
787        media_type: Option<&openapiv3::MediaType>,
788        inline_name: Option<&str>,
789    ) -> Option<String> {
790        media_type.and_then(|mt| {
791            mt.schema
792                .as_ref()
793                .map(|schema_ref| self.phpdoc_type_from_schema_ref(schema_ref, false, inline_name))
794        })
795    }
796
797    /// Extract response type from operation (looks for 200/201 responses)
798    fn extract_response_type(&self, operation: &Operation) -> String {
799        use openapiv3::StatusCode;
800
801        let response = operation
802            .responses
803            .responses
804            .get(&StatusCode::Code(200))
805            .or_else(|| operation.responses.responses.get(&StatusCode::Code(201)))
806            .or_else(|| operation.responses.responses.get(&StatusCode::Range(2)));
807
808        response
809            .and_then(|response_ref| {
810                self.extract_response_type_from_ref(
811                    response_ref,
812                    operation
813                        .operation_id
814                        .as_deref()
815                        .map(|id| format!("{}ResponseBody", id.to_pascal_case()))
816                        .as_deref(),
817                )
818            })
819            .unwrap_or_else(|| "array".to_string())
820    }
821
822    fn extract_request_body_doc_type(&self, operation: &Operation) -> Option<String> {
823        operation.request_body.as_ref().and_then(|body_ref| match body_ref {
824            ReferenceOr::Item(request_body) => self.extract_json_schema_doc_type(
825                request_body.content.get("application/json"),
826                operation
827                    .operation_id
828                    .as_deref()
829                    .map(|id| format!("{}RequestBody", id.to_pascal_case()))
830                    .as_deref(),
831            ),
832            ReferenceOr::Reference { reference } => {
833                let ref_name = self.extract_ref_name(reference);
834                Some(ref_name.to_pascal_case())
835            }
836        })
837    }
838
839    fn extract_response_doc_type(&self, operation: &Operation) -> String {
840        use openapiv3::StatusCode;
841
842        let response = operation
843            .responses
844            .responses
845            .get(&StatusCode::Code(200))
846            .or_else(|| operation.responses.responses.get(&StatusCode::Code(201)))
847            .or_else(|| operation.responses.responses.get(&StatusCode::Range(2)));
848
849        response
850            .and_then(|response_ref| {
851                self.extract_response_doc_type_from_ref(
852                    response_ref,
853                    operation
854                        .operation_id
855                        .as_deref()
856                        .map(|id| format!("{}ResponseBody", id.to_pascal_case()))
857                        .as_deref(),
858                )
859            })
860            .unwrap_or_else(|| "array<string, mixed>".to_string())
861    }
862
863    /// Extract response type from a single response reference
864    fn extract_response_type_from_ref(
865        &self,
866        response_ref: &ReferenceOr<openapiv3::Response>,
867        inline_name: Option<&str>,
868    ) -> Option<String> {
869        match response_ref {
870            ReferenceOr::Item(response) => {
871                let media_type = response.content.get("application/json")?;
872                let schema_ref = media_type.schema.as_ref()?;
873                Some(self.extract_type_from_schema_ref(schema_ref, inline_name))
874            }
875            ReferenceOr::Reference { reference } => {
876                let ref_name = self.extract_ref_name(reference);
877                Some(ref_name.to_pascal_case())
878            }
879        }
880    }
881
882    fn extract_response_doc_type_from_ref(
883        &self,
884        response_ref: &ReferenceOr<openapiv3::Response>,
885        inline_name: Option<&str>,
886    ) -> Option<String> {
887        match response_ref {
888            ReferenceOr::Item(response) => {
889                let media_type = response.content.get("application/json")?;
890                let schema_ref = media_type.schema.as_ref()?;
891                Some(self.phpdoc_type_from_schema_ref(schema_ref, false, inline_name))
892            }
893            ReferenceOr::Reference { reference } => {
894                let ref_name = self.extract_ref_name(reference);
895                Some(ref_name.to_pascal_case())
896            }
897        }
898    }
899
900    /// Convert `OpenAPI` schema to PHP type hint
901    /// Returns (`type_hint`, `is_already_nullable`)
902    /// Uses native PHP type syntax for type hints (valid in PHP 7.4+)
903    fn schema_to_php_type(
904        &self,
905        parent_class_name: Option<&str>,
906        field_name: Option<&str>,
907        schema: &Schema,
908        optional: bool,
909        inline_name: Option<&str>,
910    ) -> (String, bool) {
911        let base_type = if let Some(enum_type) = self.enum_type_name(parent_class_name, field_name, schema, inline_name)
912        {
913            enum_type
914        } else {
915            match &schema.schema_kind {
916                SchemaKind::Type(Type::String(string_type)) => self.string_format_php_type(string_type),
917                SchemaKind::Type(Type::Number(_)) => "float".to_string(),
918                SchemaKind::Type(Type::Integer(_)) => "int".to_string(),
919                SchemaKind::Type(Type::Boolean(_)) => "bool".to_string(),
920                SchemaKind::Type(Type::Array(_)) => "array".to_string(),
921                SchemaKind::Type(Type::Object(obj)) if !obj.properties.is_empty() => inline_name
922                    .map(ToOwned::to_owned)
923                    .or_else(|| {
924                        parent_class_name
925                            .zip(field_name)
926                            .map(|(parent, field)| format!("{parent}{}", field.to_pascal_case()))
927                    })
928                    .unwrap_or_else(|| "array".to_string()),
929                SchemaKind::Type(Type::Object(_)) => "array".to_string(),
930                _ => "mixed".to_string(),
931            }
932        };
933
934        if optional {
935            (format!("?{base_type}"), true)
936        } else {
937            (base_type, false)
938        }
939    }
940
941    /// Generate `PHPDoc` type annotation for arrays
942    #[allow(dead_code)]
943    fn schema_to_phpdoc_type(
944        &self,
945        parent_class_name: Option<&str>,
946        field_name: Option<&str>,
947        schema: &Schema,
948        optional: bool,
949        inline_name: Option<&str>,
950    ) -> String {
951        let base_type = if let Some(enum_type) = self.enum_type_name(parent_class_name, field_name, schema, inline_name)
952        {
953            enum_type
954        } else {
955            match &schema.schema_kind {
956                SchemaKind::Type(Type::String(string_type)) => {
957                    if string_type.enumeration.is_empty() {
958                        self.string_format_php_type(string_type)
959                    } else {
960                        string_type
961                            .enumeration
962                            .iter()
963                            .flatten()
964                            .map(|value| format!("'{}'", Self::escape_php_string(value)))
965                            .collect::<Vec<_>>()
966                            .join("|")
967                    }
968                }
969                SchemaKind::Type(Type::Number(number_type)) => {
970                    if number_type.enumeration.is_empty() {
971                        "float".to_string()
972                    } else {
973                        number_type
974                            .enumeration
975                            .iter()
976                            .flatten()
977                            .map(ToString::to_string)
978                            .collect::<Vec<_>>()
979                            .join("|")
980                    }
981                }
982                SchemaKind::Type(Type::Integer(integer_type)) => {
983                    if integer_type.enumeration.is_empty() {
984                        "int".to_string()
985                    } else {
986                        integer_type
987                            .enumeration
988                            .iter()
989                            .flatten()
990                            .map(ToString::to_string)
991                            .collect::<Vec<_>>()
992                            .join("|")
993                    }
994                }
995                SchemaKind::Type(Type::Boolean(_)) => "bool".to_string(),
996                SchemaKind::Type(Type::Array(arr)) => {
997                    let item_type = match &arr.items {
998                        Some(ReferenceOr::Item(item_schema)) => self.schema_to_phpdoc_type(
999                            None,
1000                            None,
1001                            item_schema,
1002                            false,
1003                            parent_class_name
1004                                .zip(field_name)
1005                                .and_then(|(parent, field)| {
1006                                    self.inline_array_item_model_name(parent, field, schema)
1007                                        .or_else(|| self.inline_array_item_enum_name(parent, field, schema))
1008                                })
1009                                .as_deref(),
1010                        ),
1011                        Some(ReferenceOr::Reference { reference }) => {
1012                            let ref_name = reference.split('/').next_back().unwrap();
1013                            ref_name.to_pascal_case()
1014                        }
1015                        None => "mixed".to_string(),
1016                    };
1017                    format!("list<{item_type}>")
1018                }
1019                SchemaKind::Type(Type::Object(obj)) => {
1020                    if obj.properties.is_empty() {
1021                        "array<string, mixed>".to_string()
1022                    } else {
1023                        inline_name
1024                            .map(ToOwned::to_owned)
1025                            .or_else(|| {
1026                                parent_class_name
1027                                    .zip(field_name)
1028                                    .map(|(parent, field)| format!("{parent}{}", field.to_pascal_case()))
1029                            })
1030                            .unwrap_or_else(|| {
1031                                let mut entries = Vec::new();
1032                                for (prop_name, prop_schema_ref) in &obj.properties {
1033                                    let is_required = obj.required.contains(prop_name);
1034                                    let prop_type = match prop_schema_ref {
1035                                        ReferenceOr::Item(prop_schema) => self.schema_to_phpdoc_type(
1036                                            parent_class_name,
1037                                            Some(prop_name),
1038                                            prop_schema,
1039                                            !is_required,
1040                                            None,
1041                                        ),
1042                                        ReferenceOr::Reference { reference } => {
1043                                            let ref_name = reference.split('/').next_back().unwrap();
1044                                            let mut base = ref_name.to_pascal_case();
1045                                            if !is_required {
1046                                                base.push_str("|null");
1047                                            }
1048                                            base
1049                                        }
1050                                    };
1051                                    if is_required {
1052                                        entries.push(format!("{prop_name}: {prop_type}"));
1053                                    } else {
1054                                        entries.push(format!("{prop_name}?: {prop_type}"));
1055                                    }
1056                                }
1057                                format!("array{{{}}}", entries.join(", "))
1058                            })
1059                    }
1060                }
1061                _ => "mixed".to_string(),
1062            }
1063        };
1064
1065        if optional {
1066            format!("{base_type}|null")
1067        } else {
1068            base_type
1069        }
1070    }
1071
1072    /// Generate all controller classes from API paths
1073    fn generate_controllers(&self) -> Result<String> {
1074        let mut output = String::new();
1075        output.push_str("\n// Controller Classes\n\n");
1076
1077        let controllers = self.group_operations_by_controller();
1078
1079        for (controller_name, routes) in controllers {
1080            output.push_str(&self.generate_controller_class(&controller_name, &routes)?);
1081            output.push('\n');
1082        }
1083
1084        Ok(output)
1085    }
1086
1087    /// Group operations by controller name based on API path
1088    fn group_operations_by_controller(&self) -> std::collections::HashMap<String, Vec<(String, String, Operation)>> {
1089        let mut controllers: std::collections::HashMap<String, Vec<(String, String, Operation)>> =
1090            std::collections::HashMap::new();
1091
1092        for (path, path_item_ref) in &self.spec.paths.paths {
1093            let path_item = match path_item_ref {
1094                ReferenceOr::Item(item) => item,
1095                ReferenceOr::Reference { .. } => continue,
1096            };
1097
1098            let controller_name = self.extract_controller_name(path);
1099            self.add_path_operations(&mut controllers, path, &controller_name, path_item);
1100        }
1101
1102        controllers
1103    }
1104
1105    /// Add all HTTP method operations from a path item to controllers map
1106    fn add_path_operations(
1107        &self,
1108        controllers: &mut std::collections::HashMap<String, Vec<(String, String, Operation)>>,
1109        path: &str,
1110        controller_name: &str,
1111        path_item: &openapiv3::PathItem,
1112    ) {
1113        let methods = [
1114            ("GET", &path_item.get),
1115            ("POST", &path_item.post),
1116            ("PUT", &path_item.put),
1117            ("DELETE", &path_item.delete),
1118            ("PATCH", &path_item.patch),
1119        ];
1120
1121        for (method, op_opt) in methods {
1122            if let Some(op) = op_opt {
1123                controllers.entry(controller_name.to_string()).or_default().push((
1124                    path.to_string(),
1125                    method.to_string(),
1126                    op.clone(),
1127                ));
1128            }
1129        }
1130    }
1131
1132    fn extract_controller_name(&self, path: &str) -> String {
1133        let segments: Vec<&str> = path
1134            .split('/')
1135            .filter(|s| !s.is_empty() && !s.starts_with('{'))
1136            .collect();
1137
1138        if let Some(first_segment) = segments.first() {
1139            format!("{}Controller", first_segment.to_pascal_case())
1140        } else {
1141            "DefaultController".to_string()
1142        }
1143    }
1144
1145    fn generate_controller_class(
1146        &self,
1147        controller_name: &str,
1148        routes: &[(String, String, Operation)],
1149    ) -> Result<String> {
1150        let mut output = String::new();
1151
1152        output.push_str("/**\n");
1153        output.push_str(&format!(" * {controller_name} - Generated controller for API routes\n"));
1154        output.push_str(" */\n");
1155
1156        output.push_str(&format!("class {controller_name}\n{{\n"));
1157
1158        for (path, method, operation) in routes {
1159            output.push_str(&self.generate_route_handler(path, method, operation)?);
1160        }
1161
1162        output.push_str("}\n");
1163
1164        Ok(output)
1165    }
1166
1167    /// Generate a single route handler method
1168    fn generate_route_handler(&self, path: &str, method: &str, operation: &Operation) -> Result<String> {
1169        let mut output = String::new();
1170        let method_name = self.extract_handler_method_name(operation, method, path);
1171        let (path_params, query_params, body_type, return_type) =
1172            self.extract_handler_parameters(operation, &mut output)?;
1173
1174        output.push_str(&format!(
1175            "    #[Route('{}', methods: ['{}'])]\n",
1176            path,
1177            method.to_uppercase()
1178        ));
1179
1180        self.append_function_signature(
1181            &mut output,
1182            &method_name,
1183            &path_params,
1184            &query_params,
1185            &body_type,
1186            &return_type,
1187        );
1188        self.append_function_body(&mut output);
1189
1190        Ok(output)
1191    }
1192
1193    /// Extract handler method name from operation ID or generate from path/method
1194    fn extract_handler_method_name(&self, operation: &Operation, method: &str, path: &str) -> String {
1195        operation
1196            .operation_id
1197            .as_ref()
1198            .map(|id| Self::to_camel_case(id))
1199            .unwrap_or_else(|| {
1200                Self::to_camel_case(&format!(
1201                    "{}_{}",
1202                    method.to_lowercase(),
1203                    path.replace('/', "_").replace(['{', '}'], "").trim_matches('_')
1204                ))
1205            })
1206    }
1207
1208    /// Extract and document handler parameters from operation
1209    #[allow(clippy::type_complexity)]
1210    fn extract_handler_parameters(
1211        &self,
1212        operation: &Operation,
1213        output: &mut String,
1214    ) -> Result<(
1215        Vec<(String, String)>,
1216        Vec<(String, String, bool)>,
1217        Option<String>,
1218        String,
1219    )> {
1220        output.push_str("    /**\n");
1221        self.append_operation_description(output, operation);
1222
1223        let (path_params, query_params) = self.extract_path_and_query_params(operation, output);
1224        let body_type = self.extract_request_body_type(operation);
1225        let return_type = self.extract_response_type(operation);
1226        let body_doc_type = self.extract_request_body_doc_type(operation);
1227        let return_doc_type = self.extract_response_doc_type(operation);
1228
1229        self.append_parameter_docs(output, &body_doc_type, &return_doc_type);
1230
1231        Ok((path_params, query_params, body_type, return_type))
1232    }
1233
1234    /// Append operation summary and description to `PHPDoc`
1235    fn append_operation_description(&self, output: &mut String, operation: &Operation) {
1236        if let Some(summary) = &operation.summary {
1237            output.push_str(&format!("     * {summary}\n"));
1238        }
1239        if let Some(description) = &operation.description {
1240            output.push_str(&format!("     * \n     * {description}\n"));
1241        }
1242        output.push_str("     * \n");
1243    }
1244
1245    /// Extract path and query parameters from operation
1246    #[allow(clippy::type_complexity)]
1247    fn extract_path_and_query_params(
1248        &self,
1249        operation: &Operation,
1250        output: &mut String,
1251    ) -> (Vec<(String, String)>, Vec<(String, String, bool)>) {
1252        let mut path_params = Vec::new();
1253        let mut query_params = Vec::new();
1254
1255        for param_ref in &operation.parameters {
1256            if let ReferenceOr::Item(param) = param_ref {
1257                self.process_parameter(
1258                    operation.operation_id.as_deref(),
1259                    param,
1260                    &mut path_params,
1261                    &mut query_params,
1262                    output,
1263                );
1264            }
1265        }
1266
1267        (path_params, query_params)
1268    }
1269
1270    /// Process a single parameter and add to path or query params
1271    fn process_parameter(
1272        &self,
1273        operation_id: Option<&str>,
1274        param: &Parameter,
1275        path_params: &mut Vec<(String, String)>,
1276        query_params: &mut Vec<(String, String, bool)>,
1277        output: &mut String,
1278    ) {
1279        match param {
1280            Parameter::Path { parameter_data, .. } => {
1281                let param_name = Self::to_camel_case(&parameter_data.name);
1282                let (native_type, phpdoc_type) = self.parameter_types(operation_id, parameter_data, false);
1283                path_params.push((param_name.clone(), native_type));
1284                output.push_str(&format!("     * @param {phpdoc_type} ${param_name}\n"));
1285            }
1286            Parameter::Query { parameter_data, .. } => {
1287                let param_name = Self::to_camel_case(&parameter_data.name);
1288                let required = parameter_data.required;
1289                let (native_type, phpdoc_type) = self.parameter_types(operation_id, parameter_data, !required);
1290                query_params.push((param_name.clone(), native_type, required));
1291                output.push_str(&format!("     * @param {phpdoc_type} ${param_name}\n"));
1292            }
1293            _ => {}
1294        }
1295    }
1296
1297    fn parameter_types(
1298        &self,
1299        operation_id: Option<&str>,
1300        parameter_data: &ParameterData,
1301        optional: bool,
1302    ) -> (String, String) {
1303        match &parameter_data.format {
1304            ParameterSchemaOrContent::Schema(schema_ref) => match schema_ref {
1305                ReferenceOr::Item(schema) => {
1306                    let inline_name = self.parameter_enum_name(operation_id, &parameter_data.name, schema);
1307                    let (native_type, _) =
1308                        self.schema_to_php_type(None, None, schema, optional, inline_name.as_deref());
1309                    let phpdoc_type = self.schema_to_phpdoc_type(None, None, schema, optional, inline_name.as_deref());
1310                    (native_type, phpdoc_type)
1311                }
1312                ReferenceOr::Reference { reference } => {
1313                    let base = self.extract_ref_name(reference).to_pascal_case();
1314                    if optional {
1315                        (format!("?{base}"), format!("{base}|null"))
1316                    } else {
1317                        (base.clone(), base)
1318                    }
1319                }
1320            },
1321            ParameterSchemaOrContent::Content(_) => {
1322                if optional {
1323                    ("?array".to_string(), "array<string, mixed>|null".to_string())
1324                } else {
1325                    ("array".to_string(), "array<string, mixed>".to_string())
1326                }
1327            }
1328        }
1329    }
1330
1331    /// Append parameter documentation to `PHPDoc`
1332    fn append_parameter_docs(&self, output: &mut String, body_doc_type: &Option<String>, return_doc_type: &str) {
1333        if let Some(body_type_name) = body_doc_type {
1334            output.push_str(&format!("     * @param {body_type_name} $body\n"));
1335        }
1336        output.push_str(&format!("     * @return {return_doc_type}\n"));
1337        output.push_str("     */\n");
1338    }
1339
1340    fn phpdoc_type_from_schema_ref(
1341        &self,
1342        schema_ref: &ReferenceOr<Schema>,
1343        optional: bool,
1344        inline_name: Option<&str>,
1345    ) -> String {
1346        match schema_ref {
1347            ReferenceOr::Item(schema) => self.schema_to_phpdoc_type(None, None, schema, optional, inline_name),
1348            ReferenceOr::Reference { reference } => {
1349                let mut base = self.extract_ref_name(reference).to_pascal_case();
1350                if optional {
1351                    base.push_str("|null");
1352                }
1353                base
1354            }
1355        }
1356    }
1357
1358    /// Append function signature to output
1359    fn append_function_signature(
1360        &self,
1361        output: &mut String,
1362        method_name: &str,
1363        path_params: &[(String, String)],
1364        query_params: &[(String, String, bool)],
1365        body_type: &Option<String>,
1366        return_type: &str,
1367    ) {
1368        output.push_str(&format!("    public function {method_name}("));
1369
1370        let mut params = Vec::new();
1371
1372        for (param_name, param_type) in path_params {
1373            params.push(format!("{param_type} ${param_name}"));
1374        }
1375
1376        for (param_name, param_type, required) in query_params.iter().filter(|(_, _, required)| *required) {
1377            let _ = required;
1378            params.push(format!("{param_type} ${param_name}"));
1379        }
1380
1381        if let Some(body_type_name) = body_type {
1382            params.push(format!("{body_type_name} $body"));
1383        }
1384
1385        for (param_name, param_type, required) in query_params.iter().filter(|(_, _, required)| !*required) {
1386            let _ = required;
1387            params.push(format!("{param_type} ${param_name} = null"));
1388        }
1389
1390        output.push_str(&params.join(", "));
1391        output.push_str(&format!("): {return_type}\n    {{\n"));
1392    }
1393
1394    /// Append placeholder function body.
1395    fn append_function_body(&self, output: &mut String) {
1396        output.push_str("        // Implement this endpoint\n");
1397        output.push_str("        throw new \\RuntimeException('Not implemented');\n");
1398        output.push_str("    }\n\n");
1399    }
1400
1401    fn generate_main(&self) -> String {
1402        format!(
1403            r"
1404// Bootstrap Application
1405// This section shows how to initialize and run the application
1406
1407/**
1408 * Example using Slim Framework 4:
1409 *
1410 * require __DIR__ . '/vendor/autoload.php';
1411 *
1412 * use Slim\Factory\AppFactory;
1413 *
1414 * $app = AppFactory::create();
1415 *
1416 * // Register routes
1417 * // Note: You'll need to manually extract route attributes and register them
1418 * // or use a library that supports PHP 8 attributes
1419 *
1420 * $app->run();
1421 */
1422
1423/**
1424 * Example using Symfony:
1425 *
1426 * The #[Route] attributes are compatible with Symfony's routing.
1427 * Simply ensure the controllers are registered as services and
1428 * Symfony will automatically discover the routes.
1429 */
1430
1431/**
1432 * Application Information:
1433 * Title: {}
1434 * Version: {}
1435 * OpenAPI: {}
1436 */
1437",
1438            self.spec.info.title, self.spec.info.version, self.spec.openapi
1439        )
1440    }
1441}