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