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