Skip to main content

alef_backend_php/gen_bindings/
mod.rs

1mod functions;
2mod helpers;
3mod types;
4
5use crate::type_map::PhpMapper;
6use ahash::AHashSet;
7use alef_codegen::builder::RustFileBuilder;
8use alef_codegen::conversions::ConversionConfig;
9use alef_codegen::generators::RustBindingConfig;
10use alef_codegen::generators::{self, AsyncPattern};
11use alef_core::backend::{Backend, BuildConfig, Capabilities, GeneratedFile};
12use alef_core::config::{AlefConfig, Language, detect_serde_available, resolve_output_dir};
13use alef_core::ir::ApiSurface;
14use alef_core::ir::{PrimitiveType, TypeRef};
15use heck::{ToLowerCamelCase, ToPascalCase};
16use std::path::PathBuf;
17
18use functions::{gen_async_function_as_static_method, gen_function_as_static_method};
19use helpers::{
20    gen_enum_tainted_from_binding_to_core, gen_serde_bridge_from, gen_tokio_runtime, has_enum_named_field,
21    references_named_type,
22};
23use types::{gen_enum_constants, gen_opaque_struct_methods, gen_php_struct};
24
25pub struct PhpBackend;
26
27impl PhpBackend {
28    fn binding_config(core_import: &str, has_serde: bool) -> RustBindingConfig<'_> {
29        RustBindingConfig {
30            struct_attrs: &["php_class"],
31            field_attrs: &[],
32            struct_derives: &["Clone"],
33            method_block_attr: Some("php_impl"),
34            constructor_attr: "",
35            static_attr: None,
36            function_attr: "#[php_function]",
37            enum_attrs: &[],
38            enum_derives: &[],
39            needs_signature: false,
40            signature_prefix: "",
41            signature_suffix: "",
42            core_import,
43            async_pattern: AsyncPattern::TokioBlockOn,
44            has_serde,
45            type_name_prefix: "",
46            option_duration_on_defaults: true,
47        }
48    }
49}
50
51impl Backend for PhpBackend {
52    fn name(&self) -> &str {
53        "php"
54    }
55
56    fn language(&self) -> Language {
57        Language::Php
58    }
59
60    fn capabilities(&self) -> Capabilities {
61        Capabilities {
62            supports_async: true,
63            supports_classes: true,
64            supports_enums: true,
65            supports_option: true,
66            supports_result: true,
67            ..Capabilities::default()
68        }
69    }
70
71    fn generate_bindings(&self, api: &ApiSurface, config: &AlefConfig) -> anyhow::Result<Vec<GeneratedFile>> {
72        let enum_names = api.enums.iter().map(|e| e.name.clone()).collect();
73        let mapper = PhpMapper { enum_names };
74        let core_import = config.core_import();
75
76        // Get exclusion lists from PHP config
77        let php_config = config.php.as_ref();
78        let exclude_functions = php_config.map(|c| c.exclude_functions.clone()).unwrap_or_default();
79        let exclude_types = php_config.map(|c| c.exclude_types.clone()).unwrap_or_default();
80
81        let output_dir = resolve_output_dir(
82            config.output.php.as_ref(),
83            &config.crate_config.name,
84            "crates/{name}-php/src/",
85        );
86        let has_serde = detect_serde_available(&output_dir);
87        let cfg = Self::binding_config(&core_import, has_serde);
88
89        // Build the inner module content (types, methods, conversions)
90        let mut builder = RustFileBuilder::new();
91        builder.add_inner_attribute("allow(dead_code)");
92        builder.add_import("ext_php_rs::prelude::*");
93
94        // Import serde_json when available (needed for serde-based param conversion)
95        if has_serde {
96            builder.add_import("serde_json");
97        }
98
99        // Import traits needed for trait method dispatch
100        for trait_path in generators::collect_trait_imports(api) {
101            builder.add_import(&trait_path);
102        }
103
104        // Only import HashMap when Map-typed fields or returns are present
105        let has_maps = api.types.iter().any(|t| {
106            t.fields
107                .iter()
108                .any(|f| matches!(&f.ty, alef_core::ir::TypeRef::Map(_, _)))
109        }) || api
110            .functions
111            .iter()
112            .any(|f| matches!(&f.return_type, alef_core::ir::TypeRef::Map(_, _)));
113        if has_maps {
114            builder.add_import("std::collections::HashMap");
115        }
116
117        // Custom module declarations
118        let custom_mods = config.custom_modules.for_language(Language::Php);
119        for module in custom_mods {
120            builder.add_item(&format!("pub mod {module};"));
121        }
122
123        // Check if any function or method is async
124        let has_async =
125            api.functions.iter().any(|f| f.is_async) || api.types.iter().any(|t| t.methods.iter().any(|m| m.is_async));
126
127        if has_async {
128            builder.add_item(&gen_tokio_runtime());
129        }
130
131        // Check if we have opaque types and add Arc import if needed
132        let opaque_types: AHashSet<String> = api
133            .types
134            .iter()
135            .filter(|t| t.is_opaque)
136            .map(|t| t.name.clone())
137            .collect();
138        if !opaque_types.is_empty() {
139            builder.add_import("std::sync::Arc");
140        }
141
142        // Enum names for PHP property classification — enums are mapped as String,
143        // so Named(enum) fields should be treated as scalar props not getters.
144        let enum_names: AHashSet<String> = api.enums.iter().map(|e| e.name.clone()).collect();
145
146        // Compute the PHP namespace for namespaced class registration.
147        // Uses the same logic as `generate_public_api` / `generate_type_stubs`.
148        let extension_name = config.php_extension_name();
149        let php_namespace = if extension_name.contains('_') {
150            let parts: Vec<&str> = extension_name.split('_').collect();
151            let ns_parts: Vec<String> = parts.iter().map(|p| p.to_pascal_case()).collect();
152            ns_parts.join("\\")
153        } else {
154            extension_name.to_pascal_case()
155        };
156
157        for typ in api
158            .types
159            .iter()
160            .filter(|typ| !typ.is_trait && !exclude_types.contains(&typ.name))
161        {
162            if typ.is_opaque {
163                // Generate the opaque struct with separate #[php_class] and
164                // #[php(name = "Ns\\Type")] attributes (ext-php-rs 0.15+ syntax).
165                // Escape '\' in the namespace so the generated Rust string literal is valid.
166                let ns_escaped = php_namespace.replace('\\', "\\\\");
167                let php_name_attr = format!("php(name = \"{}\\\\{}\")", ns_escaped, typ.name);
168                let opaque_attr_arr = ["php_class", php_name_attr.as_str()];
169                let opaque_cfg = RustBindingConfig {
170                    struct_attrs: &opaque_attr_arr,
171                    ..cfg
172                };
173                builder.add_item(&generators::gen_opaque_struct(typ, &opaque_cfg));
174                builder.add_item(&gen_opaque_struct_methods(typ, &mapper, &opaque_types, &core_import));
175            } else {
176                // gen_struct adds #[derive(Default)] when typ.has_default is true,
177                // so no separate Default impl is needed.
178                builder.add_item(&gen_php_struct(typ, &mapper, &cfg, Some(&php_namespace), &enum_names));
179                builder.add_item(&types::gen_struct_methods_with_exclude(
180                    typ,
181                    &mapper,
182                    has_serde,
183                    &core_import,
184                    &opaque_types,
185                    &enum_names,
186                    &api.enums,
187                    &exclude_functions,
188                ));
189            }
190        }
191
192        for enum_def in &api.enums {
193            builder.add_item(&gen_enum_constants(enum_def));
194        }
195
196        // Generate free functions as static methods on a facade class rather than standalone
197        // `#[php_function]` items. Standalone functions rely on the `inventory` crate for
198        // auto-registration, which does not work in cdylib builds on macOS. Classes registered
199        // via `.class::<T>()` in the module builder DO work on all platforms.
200        let included_functions: Vec<_> = api
201            .functions
202            .iter()
203            .filter(|f| !exclude_functions.contains(&f.name))
204            .collect();
205        if !included_functions.is_empty() {
206            let facade_class_name = extension_name.to_pascal_case();
207            // Build each static method body (no #[php_function] attribute — they live inside
208            // a #[php_impl] block which handles registration via the class machinery).
209            let mut method_items: Vec<String> = Vec::new();
210            for func in included_functions {
211                if func.is_async {
212                    method_items.push(gen_async_function_as_static_method(
213                        func,
214                        &mapper,
215                        &opaque_types,
216                        &core_import,
217                    ));
218                } else {
219                    method_items.push(gen_function_as_static_method(
220                        func,
221                        &mapper,
222                        &opaque_types,
223                        &core_import,
224                    ));
225                }
226            }
227            let methods_joined = method_items
228                .iter()
229                .map(|m| {
230                    // Indent each line of each method by 4 spaces
231                    m.lines()
232                        .map(|l| {
233                            if l.is_empty() {
234                                String::new()
235                            } else {
236                                format!("    {l}")
237                            }
238                        })
239                        .collect::<Vec<_>>()
240                        .join("\n")
241                })
242                .collect::<Vec<_>>()
243                .join("\n\n");
244            // The PHP-visible class name gets an "Api" suffix to avoid collision with the
245            // PHP facade class (e.g. `Kreuzcrawl\Kreuzcrawl`) that Composer autoloads.
246            let php_api_class_name = format!("{facade_class_name}Api");
247            // Escape '\' so the generated Rust string literal is valid (e.g. "Ns\\ClassName").
248            let ns_escaped_facade = php_namespace.replace('\\', "\\\\");
249            let php_name_attr = format!("php(name = \"{}\\\\{}\")", ns_escaped_facade, php_api_class_name);
250            let facade_struct = format!(
251                "#[php_class]\n#[{php_name_attr}]\npub struct {facade_class_name}Api;\n\n#[php_impl]\nimpl {facade_class_name}Api {{\n{methods_joined}\n}}"
252            );
253            builder.add_item(&facade_struct);
254        }
255
256        let convertible = alef_codegen::conversions::convertible_types(api);
257        let core_to_binding = alef_codegen::conversions::core_to_binding_convertible_types(api);
258        let input_types = alef_codegen::conversions::input_type_names(api);
259        // From/Into conversions with PHP-specific i64 casts.
260        // Types with enum Named fields (or that reference such types transitively) can't
261        // have binding->core From impls because PHP maps enums to String and there's no
262        // From<String> for the core enum type. Core->binding is always safe.
263        let enum_names_ref = &mapper.enum_names;
264        let php_conv_config = ConversionConfig {
265            cast_large_ints_to_i64: true,
266            enum_string_names: Some(enum_names_ref),
267            json_to_string: true,
268            include_cfg_metadata: false,
269            option_duration_on_defaults: true,
270            ..Default::default()
271        };
272        // Build transitive set of types that can't have binding->core From
273        let mut enum_tainted: AHashSet<String> = AHashSet::new();
274        for typ in api.types.iter().filter(|typ| !typ.is_trait) {
275            if has_enum_named_field(typ, enum_names_ref) {
276                enum_tainted.insert(typ.name.clone());
277            }
278        }
279        // Transitively mark types that reference enum-tainted types
280        let mut changed = true;
281        while changed {
282            changed = false;
283            for typ in api.types.iter().filter(|typ| !typ.is_trait) {
284                if !enum_tainted.contains(&typ.name)
285                    && typ.fields.iter().any(|f| references_named_type(&f.ty, &enum_tainted))
286                {
287                    enum_tainted.insert(typ.name.clone());
288                    changed = true;
289                }
290            }
291        }
292        for typ in api.types.iter().filter(|typ| !typ.is_trait) {
293            // binding->core: only when not enum-tainted and type is used as input
294            if input_types.contains(&typ.name)
295                && !enum_tainted.contains(&typ.name)
296                && alef_codegen::conversions::can_generate_conversion(typ, &convertible)
297            {
298                builder.add_item(&alef_codegen::conversions::gen_from_binding_to_core_cfg(
299                    typ,
300                    &core_import,
301                    &php_conv_config,
302                ));
303            } else if input_types.contains(&typ.name) && enum_tainted.contains(&typ.name) && has_serde {
304                // Enum-tainted types can't use field-by-field From (no From<String> for core enum),
305                // but when serde is available we bridge via JSON serialization round-trip.
306                builder.add_item(&gen_serde_bridge_from(typ, &core_import));
307            } else if input_types.contains(&typ.name) && enum_tainted.contains(&typ.name) {
308                // Enum-tainted types: generate From with string->enum parsing for enum-Named
309                // fields, using first variant as fallback. Data-variant enum fields fill
310                // data fields with Default::default().
311                builder.add_item(&gen_enum_tainted_from_binding_to_core(
312                    typ,
313                    &core_import,
314                    enum_names_ref,
315                    &enum_tainted,
316                    &php_conv_config,
317                    &api.enums,
318                ));
319            }
320            // core->binding: always (enum->String via format, sanitized fields via format)
321            if alef_codegen::conversions::can_generate_conversion(typ, &core_to_binding) {
322                builder.add_item(&alef_codegen::conversions::gen_from_core_to_binding_cfg(
323                    typ,
324                    &core_import,
325                    &opaque_types,
326                    &php_conv_config,
327                ));
328            }
329        }
330
331        // Error converter functions
332        for error in &api.errors {
333            builder.add_item(&alef_codegen::error_gen::gen_php_error_converter(error, &core_import));
334        }
335
336        let _adapter_bodies = alef_adapters::build_adapter_bodies(config, Language::Php)?;
337
338        // Add feature gate as inner attribute — entire crate is gated
339        let php_config = config.php.as_ref();
340        if let Some(feature_name) = php_config.and_then(|c| c.feature_gate.as_deref()) {
341            builder.add_inner_attribute(&format!("cfg(feature = \"{feature_name}\")"));
342            builder.add_inner_attribute(&format!(
343                "cfg_attr(all(windows, target_env = \"msvc\", feature = \"{feature_name}\"), feature(abi_vectorcall))"
344            ));
345        }
346
347        // PHP module entry point — explicit class registration required because
348        // `inventory` crate auto-registration doesn't work in cdylib on macOS.
349        let mut class_registrations = String::new();
350        for typ in api
351            .types
352            .iter()
353            .filter(|typ| !typ.is_trait && !exclude_types.contains(&typ.name))
354        {
355            class_registrations.push_str(&format!("\n    .class::<{}>()", typ.name));
356        }
357        // Register the facade class that wraps free functions as static methods.
358        if !api.functions.is_empty() {
359            let facade_class_name = extension_name.to_pascal_case();
360            class_registrations.push_str(&format!("\n    .class::<{facade_class_name}Api>()"));
361        }
362        // Note: enums are represented as PHP string-backed enums, not Rust structs,
363        // so they don't need .class::<T>() registration.
364        builder.add_item(&format!(
365            "#[php_module]\npub fn get_module(module: ModuleBuilder) -> ModuleBuilder {{\n    module{class_registrations}\n}}"
366        ));
367
368        let content = builder.build();
369
370        Ok(vec![GeneratedFile {
371            path: PathBuf::from(&output_dir).join("lib.rs"),
372            content,
373            generated_header: false,
374        }])
375    }
376
377    fn generate_public_api(&self, api: &ApiSurface, config: &AlefConfig) -> anyhow::Result<Vec<GeneratedFile>> {
378        let extension_name = config.php_extension_name();
379        let class_name = extension_name.to_pascal_case();
380
381        // Generate PHP wrapper class
382        let mut content = String::from("<?php\n");
383        content.push_str("// This file is auto-generated by alef. DO NOT EDIT.\n");
384        content.push_str("declare(strict_types=1);\n\n");
385
386        // Determine namespace
387        let namespace = if extension_name.contains('_') {
388            let parts: Vec<&str> = extension_name.split('_').collect();
389            let ns_parts: Vec<String> = parts.iter().map(|p| p.to_pascal_case()).collect();
390            ns_parts.join("\\")
391        } else {
392            class_name.clone()
393        };
394
395        content.push_str(&format!("namespace {};\n\n", namespace));
396        content.push_str(&format!("final class {}\n", class_name));
397        content.push_str("{\n");
398
399        // Generate wrapper methods for functions
400        for func in &api.functions {
401            let method_name = func.name.to_lower_camel_case();
402            let return_php_type = php_type(&func.return_type);
403
404            // PHPDoc block
405            content.push_str("    /**\n");
406            for line in func.doc.lines() {
407                if line.is_empty() {
408                    content.push_str("     *\n");
409                } else {
410                    content.push_str(&format!("     * {}\n", line));
411                }
412            }
413            if func.doc.is_empty() {
414                content.push_str(&format!("     * {}.\n", method_name));
415            }
416            content.push_str("     *\n");
417            for p in &func.params {
418                let ptype = php_phpdoc_type(&p.ty);
419                let nullable_prefix = if p.optional { "?" } else { "" };
420                content.push_str(&format!("     * @param {}{} ${}\n", nullable_prefix, ptype, p.name));
421            }
422            let return_phpdoc = php_phpdoc_type(&func.return_type);
423            content.push_str(&format!("     * @return {}\n", return_phpdoc));
424            if func.error_type.is_some() {
425                content.push_str(&format!("     * @throws \\{}\\{}Exception\n", namespace, class_name));
426            }
427            content.push_str("     */\n");
428
429            // Method signature with type hints
430            content.push_str(&format!("    public static function {}(", method_name));
431
432            let params: Vec<String> = func
433                .params
434                .iter()
435                .map(|p| {
436                    let ptype = php_type(&p.ty);
437                    if p.optional {
438                        format!("?{} ${} = null", ptype, p.name)
439                    } else {
440                        format!("{} ${}", ptype, p.name)
441                    }
442                })
443                .collect();
444            content.push_str(&params.join(", "));
445            content.push_str(&format!("): {}\n", return_php_type));
446            content.push_str("    {\n");
447            // Async functions are registered in the extension with an `_async` suffix
448            // (see gen_async_function which generates `pub fn {name}_async`).
449            // Delegate to the native extension class (registered as `{namespace}\{class_name}Api`).
450            // ext-php-rs auto-converts Rust snake_case to PHP camelCase
451            let ext_method_name = if func.is_async {
452                format!("{}_async", func.name).to_lower_camel_case()
453            } else {
454                func.name.to_lower_camel_case()
455            };
456            content.push_str(&format!(
457                "        return \\{}\\{}Api::{}({}); // delegate to native extension class\n",
458                namespace,
459                class_name,
460                ext_method_name,
461                func.params
462                    .iter()
463                    .map(|p| format!("${}", p.name))
464                    .collect::<Vec<_>>()
465                    .join(", ")
466            ));
467            content.push_str("    }\n\n");
468        }
469
470        content.push_str("}\n");
471
472        // Use PHP stubs output path if configured, otherwise fall back to packages/php/src/.
473        // This is intentionally separate from config.output.php, which controls the Rust binding
474        // crate output directory (e.g., crates/kreuzcrawl-php/src/).
475        let output_dir = config
476            .php
477            .as_ref()
478            .and_then(|p| p.stubs.as_ref())
479            .map(|s| s.output.to_string_lossy().to_string())
480            .unwrap_or_else(|| "packages/php/src/".to_string());
481
482        Ok(vec![GeneratedFile {
483            path: PathBuf::from(&output_dir).join(format!("{}.php", class_name)),
484            content,
485            generated_header: false,
486        }])
487    }
488
489    fn generate_type_stubs(&self, api: &ApiSurface, config: &AlefConfig) -> anyhow::Result<Vec<GeneratedFile>> {
490        let extension_name = config.php_extension_name();
491        let class_name = extension_name.to_pascal_case();
492
493        // Determine namespace (same logic as generate_public_api)
494        let namespace = if extension_name.contains('_') {
495            let parts: Vec<&str> = extension_name.split('_').collect();
496            let ns_parts: Vec<String> = parts.iter().map(|p| p.to_pascal_case()).collect();
497            ns_parts.join("\\")
498        } else {
499            class_name.clone()
500        };
501
502        let mut content = String::from("<?php\n");
503        content.push_str("// This file is auto-generated by alef. DO NOT EDIT.\n");
504        content.push_str("// Type stubs for the native PHP extension — declares classes\n");
505        content.push_str("// provided at runtime by the compiled Rust extension (.so/.dll).\n");
506        content.push_str("// Include this in phpstan.neon scanFiles for static analysis.\n\n");
507        content.push_str("declare(strict_types=1);\n\n");
508        // Use bracketed namespace syntax so we can add global-namespace function stubs later.
509        content.push_str(&format!("namespace {} {{\n\n", namespace));
510
511        // Exception class
512        content.push_str(&format!(
513            "class {}Exception extends \\RuntimeException\n{{\n",
514            class_name
515        ));
516        content.push_str("    public function getErrorCode(): int { }\n");
517        content.push_str("}\n\n");
518
519        // Opaque handle classes
520        for typ in api.types.iter().filter(|typ| !typ.is_trait) {
521            if typ.is_opaque {
522                if !typ.doc.is_empty() {
523                    content.push_str("/**\n");
524                    for line in typ.doc.lines() {
525                        if line.is_empty() {
526                            content.push_str(" *\n");
527                        } else {
528                            content.push_str(&format!(" * {}\n", line));
529                        }
530                    }
531                    content.push_str(" */\n");
532                }
533                content.push_str(&format!("class {}\n{{\n", typ.name));
534                // Opaque handles have no public constructors in PHP
535                content.push_str("}\n\n");
536            }
537        }
538
539        // Record / struct types (non-opaque with fields)
540        for typ in api.types.iter().filter(|typ| !typ.is_trait) {
541            if typ.is_opaque || typ.fields.is_empty() {
542                continue;
543            }
544            if !typ.doc.is_empty() {
545                content.push_str("/**\n");
546                for line in typ.doc.lines() {
547                    if line.is_empty() {
548                        content.push_str(" *\n");
549                    } else {
550                        content.push_str(&format!(" * {}\n", line));
551                    }
552                }
553                content.push_str(" */\n");
554            }
555            content.push_str(&format!("class {}\n{{\n", typ.name));
556
557            // Public property declarations (ext-php-rs exposes struct fields as properties)
558            for field in &typ.fields {
559                let is_array = matches!(&field.ty, TypeRef::Vec(_) | TypeRef::Map(_, _));
560                let prop_type = if field.optional {
561                    let inner = php_type(&field.ty);
562                    if inner.starts_with('?') {
563                        inner
564                    } else {
565                        format!("?{inner}")
566                    }
567                } else {
568                    php_type(&field.ty)
569                };
570                if is_array {
571                    let phpdoc = php_phpdoc_type(&field.ty);
572                    let nullable_prefix = if field.optional { "?" } else { "" };
573                    content.push_str(&format!("    /** @var {}{} */\n", nullable_prefix, phpdoc));
574                }
575                content.push_str(&format!("    public {} ${};\n", prop_type, field.name));
576            }
577            content.push('\n');
578
579            // Constructor with typed parameters.
580            // PHP requires required parameters to come before optional ones, so sort
581            // the fields: required first, then optional (preserving relative order within each group).
582            let mut sorted_fields: Vec<&alef_core::ir::FieldDef> = typ.fields.iter().collect();
583            sorted_fields.sort_by_key(|f| f.optional);
584
585            // Emit PHPDoc before the constructor for any array-typed fields so PHPStan
586            // understands the generic element type (e.g. `@param array<string> $items`).
587            let array_fields: Vec<&alef_core::ir::FieldDef> = sorted_fields
588                .iter()
589                .copied()
590                .filter(|f| matches!(&f.ty, TypeRef::Vec(_) | TypeRef::Map(_, _)))
591                .collect();
592            if !array_fields.is_empty() {
593                content.push_str("    /**\n");
594                for f in &array_fields {
595                    let phpdoc = php_phpdoc_type(&f.ty);
596                    let nullable_prefix = if f.optional { "?" } else { "" };
597                    content.push_str(&format!("     * @param {}{} ${}\n", nullable_prefix, phpdoc, f.name));
598                }
599                content.push_str("     */\n");
600            }
601
602            let params: Vec<String> = sorted_fields
603                .iter()
604                .map(|f| {
605                    let ptype = php_type(&f.ty);
606                    let nullable = if f.optional && !ptype.starts_with('?') {
607                        format!("?{ptype}")
608                    } else {
609                        ptype
610                    };
611                    let default = if f.optional { " = null" } else { "" };
612                    format!("        {} ${}{}", nullable, f.name, default)
613                })
614                .collect();
615            content.push_str("    public function __construct(\n");
616            content.push_str(&params.join(",\n"));
617            content.push_str("\n    ) { }\n\n");
618
619            // Getter methods for each field
620            for field in &typ.fields {
621                let is_array = matches!(&field.ty, TypeRef::Vec(_) | TypeRef::Map(_, _));
622                let return_type = if field.optional {
623                    let inner = php_type(&field.ty);
624                    if inner.starts_with('?') {
625                        inner
626                    } else {
627                        format!("?{inner}")
628                    }
629                } else {
630                    php_type(&field.ty)
631                };
632                let getter_name = field.name.to_lower_camel_case();
633                // Emit PHPDoc for array return types so PHPStan knows the element type.
634                if is_array {
635                    let phpdoc = php_phpdoc_type(&field.ty);
636                    let nullable_prefix = if field.optional { "?" } else { "" };
637                    content.push_str(&format!("    /** @return {}{} */\n", nullable_prefix, phpdoc));
638                }
639                content.push_str(&format!(
640                    "    public function get{}(): {} {{ }}\n",
641                    getter_name.to_pascal_case(),
642                    return_type
643                ));
644            }
645
646            content.push_str("}\n\n");
647        }
648
649        // Enum constants (PHP 8.1+ enums)
650        for enum_def in &api.enums {
651            content.push_str(&format!("enum {}: string\n{{\n", enum_def.name));
652            for variant in &enum_def.variants {
653                content.push_str(&format!("    case {} = '{}';\n", variant.name, variant.name));
654            }
655            content.push_str("}\n\n");
656        }
657
658        // Extension function stubs — generated as a native `{ClassName}Api` class with static
659        // methods. The PHP facade (`{ClassName}`) delegates to `{ClassName}Api::method()`.
660        // Using a class instead of global functions avoids the `inventory` crate registration
661        // issue on macOS (cdylib builds do not collect `#[php_function]` entries there).
662        if !api.functions.is_empty() {
663            content.push_str(&format!("class {}Api\n{{\n", class_name));
664            for func in &api.functions {
665                let return_type = php_type_fq(&func.return_type, &namespace);
666                let return_phpdoc = php_phpdoc_type_fq(&func.return_type, &namespace);
667                // PHPDoc block
668                let has_array_params = func
669                    .params
670                    .iter()
671                    .any(|p| matches!(&p.ty, TypeRef::Vec(_) | TypeRef::Map(_, _)));
672                if has_array_params {
673                    content.push_str("    /**\n");
674                    for p in &func.params {
675                        let ptype = php_phpdoc_type_fq(&p.ty, &namespace);
676                        let nullable_prefix = if p.optional { "?" } else { "" };
677                        content.push_str(&format!("     * @param {}{} ${}\n", nullable_prefix, ptype, p.name));
678                    }
679                    content.push_str(&format!("     * @return {}\n", return_phpdoc));
680                    content.push_str("     */\n");
681                }
682                let params: Vec<String> = func
683                    .params
684                    .iter()
685                    .map(|p| {
686                        let ptype = php_type_fq(&p.ty, &namespace);
687                        if p.optional {
688                            format!("?{} ${} = null", ptype, p.name)
689                        } else {
690                            format!("{} ${}", ptype, p.name)
691                        }
692                    })
693                    .collect();
694                // ext-php-rs auto-converts Rust snake_case to PHP camelCase.
695                let stub_method_name = if func.is_async {
696                    format!("{}_async", func.name).to_lower_camel_case()
697                } else {
698                    func.name.to_lower_camel_case()
699                };
700                content.push_str(&format!(
701                    "    public static function {}({}): {} {{ }}\n",
702                    stub_method_name,
703                    params.join(", "),
704                    return_type
705                ));
706            }
707            content.push_str("}\n\n");
708        }
709
710        // Close the namespaced block
711        content.push_str("} // end namespace\n");
712
713        // Use stubs output path if configured, otherwise packages/php/stubs/
714        let output_dir = config
715            .php
716            .as_ref()
717            .and_then(|p| p.stubs.as_ref())
718            .map(|s| s.output.to_string_lossy().to_string())
719            .unwrap_or_else(|| "packages/php/stubs/".to_string());
720
721        Ok(vec![GeneratedFile {
722            path: PathBuf::from(&output_dir).join(format!("{}_extension.php", extension_name)),
723            content,
724            generated_header: false,
725        }])
726    }
727
728    fn build_config(&self) -> Option<BuildConfig> {
729        Some(BuildConfig {
730            tool: "cargo",
731            crate_suffix: "-php",
732            depends_on_ffi: false,
733            post_build: vec![],
734        })
735    }
736}
737
738/// Map an IR [`TypeRef`] to a PHPDoc type string with generic parameters (e.g., `array<string>`).
739/// PHPStan at level `max` requires iterable value types in PHPDoc annotations.
740fn php_phpdoc_type(ty: &TypeRef) -> String {
741    match ty {
742        TypeRef::Vec(inner) => format!("array<{}>", php_phpdoc_type(inner)),
743        TypeRef::Map(k, v) => format!("array<{}, {}>", php_phpdoc_type(k), php_phpdoc_type(v)),
744        TypeRef::Optional(inner) => format!("?{}", php_phpdoc_type(inner)),
745        _ => php_type(ty),
746    }
747}
748
749/// Map an IR [`TypeRef`] to a fully-qualified PHPDoc type string with generics (e.g., `array<\Ns\T>`).
750fn php_phpdoc_type_fq(ty: &TypeRef, namespace: &str) -> String {
751    match ty {
752        TypeRef::Vec(inner) => format!("array<{}>", php_phpdoc_type_fq(inner, namespace)),
753        TypeRef::Map(k, v) => format!(
754            "array<{}, {}>",
755            php_phpdoc_type_fq(k, namespace),
756            php_phpdoc_type_fq(v, namespace)
757        ),
758        TypeRef::Named(name) => format!("\\{}\\{}", namespace, name),
759        TypeRef::Optional(inner) => format!("?{}", php_phpdoc_type_fq(inner, namespace)),
760        _ => php_type(ty),
761    }
762}
763
764/// Map an IR [`TypeRef`] to a fully-qualified PHP type-hint string for use outside the namespace.
765fn php_type_fq(ty: &TypeRef, namespace: &str) -> String {
766    match ty {
767        TypeRef::Named(name) => format!("\\{}\\{}", namespace, name),
768        TypeRef::Optional(inner) => {
769            let inner_type = php_type_fq(inner, namespace);
770            if inner_type.starts_with('?') {
771                inner_type
772            } else {
773                format!("?{inner_type}")
774            }
775        }
776        _ => php_type(ty),
777    }
778}
779
780/// Map an IR [`TypeRef`] to a PHP type-hint string.
781fn php_type(ty: &TypeRef) -> String {
782    match ty {
783        TypeRef::String | TypeRef::Char | TypeRef::Json | TypeRef::Bytes | TypeRef::Path => "string".to_string(),
784        TypeRef::Primitive(p) => match p {
785            PrimitiveType::Bool => "bool".to_string(),
786            PrimitiveType::F32 | PrimitiveType::F64 => "float".to_string(),
787            PrimitiveType::U8
788            | PrimitiveType::U16
789            | PrimitiveType::U32
790            | PrimitiveType::U64
791            | PrimitiveType::I8
792            | PrimitiveType::I16
793            | PrimitiveType::I32
794            | PrimitiveType::I64
795            | PrimitiveType::Usize
796            | PrimitiveType::Isize => "int".to_string(),
797        },
798        TypeRef::Optional(inner) => {
799            // Flatten nested Option<Option<T>> to a single nullable type.
800            // PHP has no double-nullable concept; ?T already covers null.
801            let inner_type = php_type(inner);
802            if inner_type.starts_with('?') {
803                inner_type
804            } else {
805                format!("?{inner_type}")
806            }
807        }
808        TypeRef::Vec(_) | TypeRef::Map(_, _) => "array".to_string(),
809        TypeRef::Named(name) => name.clone(),
810        TypeRef::Unit => "void".to_string(),
811        TypeRef::Duration => "float".to_string(),
812    }
813}