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