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