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        // Add feature gate as inner attribute — entire crate is gated
429        let php_config = config.php.as_ref();
430        if let Some(feature_name) = php_config.and_then(|c| c.feature_gate.as_deref()) {
431            builder.add_inner_attribute(&format!("cfg(feature = \"{feature_name}\")"));
432            builder.add_inner_attribute(&format!(
433                "cfg_attr(all(windows, target_env = \"msvc\", feature = \"{feature_name}\"), feature(abi_vectorcall))"
434            ));
435        }
436
437        // PHP module entry point — explicit class registration required because
438        // `inventory` crate auto-registration doesn't work in cdylib on macOS.
439        let mut class_registrations = String::new();
440        for typ in api
441            .types
442            .iter()
443            .filter(|typ| !typ.is_trait && !exclude_types.contains(&typ.name))
444        {
445            class_registrations.push_str(&format!("\n    .class::<{}>()", typ.name));
446        }
447        // Register the facade class that wraps free functions as static methods.
448        if !api.functions.is_empty() {
449            let facade_class_name = extension_name.to_pascal_case();
450            class_registrations.push_str(&format!("\n    .class::<{facade_class_name}Api>()"));
451        }
452        // Tagged data enums are lowered to flat PHP classes — register them like other classes.
453        // Unit-variant enums remain as string constants and don't need .class::<T>() registration.
454        for enum_def in api.enums.iter().filter(|e| is_tagged_data_enum(e)) {
455            class_registrations.push_str(&format!("\n    .class::<{}>()", enum_def.name));
456        }
457        builder.add_item(&format!(
458            "#[php_module]\npub fn get_module(module: ModuleBuilder) -> ModuleBuilder {{\n    module{class_registrations}\n}}"
459        ));
460
461        let content = builder.build();
462
463        Ok(vec![GeneratedFile {
464            path: PathBuf::from(&output_dir).join("lib.rs"),
465            content,
466            generated_header: false,
467        }])
468    }
469
470    fn generate_public_api(&self, api: &ApiSurface, config: &AlefConfig) -> anyhow::Result<Vec<GeneratedFile>> {
471        let extension_name = config.php_extension_name();
472        let class_name = extension_name.to_pascal_case();
473
474        // Generate PHP wrapper class
475        let mut content = String::from("<?php\n");
476        content.push_str(&hash::header(CommentStyle::DoubleSlash));
477        content.push_str("declare(strict_types=1);\n\n");
478
479        // Determine namespace
480        let namespace = if extension_name.contains('_') {
481            let parts: Vec<&str> = extension_name.split('_').collect();
482            let ns_parts: Vec<String> = parts.iter().map(|p| p.to_pascal_case()).collect();
483            ns_parts.join("\\")
484        } else {
485            class_name.clone()
486        };
487
488        content.push_str(&format!("namespace {};\n\n", namespace));
489        content.push_str(&format!("final class {}\n", class_name));
490        content.push_str("{\n");
491
492        // Build the set of bridge param names so they are excluded from public PHP signatures.
493        let bridge_param_names_pub: ahash::AHashSet<&str> = config
494            .trait_bridges
495            .iter()
496            .filter_map(|b| b.param_name.as_deref())
497            .collect();
498
499        // Generate wrapper methods for functions
500        for func in &api.functions {
501            let method_name = func.name.to_lower_camel_case();
502            let return_php_type = php_type(&func.return_type);
503
504            // Visible params exclude bridge params (not surfaced to PHP callers).
505            let visible_params: Vec<_> = func
506                .params
507                .iter()
508                .filter(|p| !bridge_param_names_pub.contains(p.name.as_str()))
509                .collect();
510
511            // PHPDoc block
512            content.push_str("    /**\n");
513            for line in func.doc.lines() {
514                if line.is_empty() {
515                    content.push_str("     *\n");
516                } else {
517                    content.push_str(&format!("     * {}\n", line));
518                }
519            }
520            if func.doc.is_empty() {
521                content.push_str(&format!("     * {}.\n", method_name));
522            }
523            content.push_str("     *\n");
524            for p in &visible_params {
525                let ptype = php_phpdoc_type(&p.ty);
526                let nullable_prefix = if p.optional { "?" } else { "" };
527                content.push_str(&format!("     * @param {}{} ${}\n", nullable_prefix, ptype, p.name));
528            }
529            let return_phpdoc = php_phpdoc_type(&func.return_type);
530            content.push_str(&format!("     * @return {}\n", return_phpdoc));
531            if func.error_type.is_some() {
532                content.push_str(&format!("     * @throws \\{}\\{}Exception\n", namespace, class_name));
533            }
534            content.push_str("     */\n");
535
536            // Method signature with type hints.
537            // PHP requires required parameters before optional ones, so stable-sort
538            // visible_params: required first, then optional (preserving relative order).
539            let mut sorted_visible_params = visible_params.clone();
540            sorted_visible_params.sort_by_key(|p| p.optional);
541
542            content.push_str(&format!("    public static function {}(", method_name));
543
544            let params: Vec<String> = sorted_visible_params
545                .iter()
546                .map(|p| {
547                    let ptype = php_type(&p.ty);
548                    if p.optional {
549                        format!("?{} ${} = null", ptype, p.name)
550                    } else {
551                        format!("{} ${}", ptype, p.name)
552                    }
553                })
554                .collect();
555            content.push_str(&params.join(", "));
556            content.push_str(&format!("): {}\n", return_php_type));
557            content.push_str("    {\n");
558            // Async functions are registered in the extension with an `_async` suffix
559            // (see gen_async_function which generates `pub fn {name}_async`).
560            // Delegate to the native extension class (registered as `{namespace}\{class_name}Api`).
561            // ext-php-rs auto-converts Rust snake_case to PHP camelCase
562            let ext_method_name = if func.is_async {
563                format!("{}_async", func.name).to_lower_camel_case()
564            } else {
565                func.name.to_lower_camel_case()
566            };
567            let is_void = matches!(&func.return_type, TypeRef::Unit);
568            let call_expr = format!(
569                "\\{}\\{}Api::{}({})",
570                namespace,
571                class_name,
572                ext_method_name,
573                sorted_visible_params
574                    .iter()
575                    .map(|p| format!("${}", p.name))
576                    .collect::<Vec<_>>()
577                    .join(", ")
578            );
579            if is_void {
580                content.push_str(&format!(
581                    "        {}; // delegate to native extension class\n",
582                    call_expr
583                ));
584            } else {
585                content.push_str(&format!(
586                    "        return {}; // delegate to native extension class\n",
587                    call_expr
588                ));
589            }
590            content.push_str("    }\n\n");
591        }
592
593        content.push_str("}\n");
594
595        // Use PHP stubs output path if configured, otherwise fall back to packages/php/src/.
596        // This is intentionally separate from config.output.php, which controls the Rust binding
597        // crate output directory (e.g., crates/kreuzcrawl-php/src/).
598        let output_dir = config
599            .php
600            .as_ref()
601            .and_then(|p| p.stubs.as_ref())
602            .map(|s| s.output.to_string_lossy().to_string())
603            .unwrap_or_else(|| "packages/php/src/".to_string());
604
605        Ok(vec![GeneratedFile {
606            path: PathBuf::from(&output_dir).join(format!("{}.php", class_name)),
607            content,
608            generated_header: false,
609        }])
610    }
611
612    fn generate_type_stubs(&self, api: &ApiSurface, config: &AlefConfig) -> anyhow::Result<Vec<GeneratedFile>> {
613        let extension_name = config.php_extension_name();
614        let class_name = extension_name.to_pascal_case();
615
616        // Determine namespace (same logic as generate_public_api)
617        let namespace = if extension_name.contains('_') {
618            let parts: Vec<&str> = extension_name.split('_').collect();
619            let ns_parts: Vec<String> = parts.iter().map(|p| p.to_pascal_case()).collect();
620            ns_parts.join("\\")
621        } else {
622            class_name.clone()
623        };
624
625        // PSR-12 requires a blank line after the opening `<?php` tag.
626        // php-cs-fixer enforces this and would insert it post-write,
627        // making `alef verify` see content that differs from what was
628        // freshly generated. Emit it here so generated == on-disk.
629        let mut content = String::from("<?php\n\n");
630        content.push_str(&hash::header(CommentStyle::DoubleSlash));
631        content.push_str("// Type stubs for the native PHP extension — declares classes\n");
632        content.push_str("// provided at runtime by the compiled Rust extension (.so/.dll).\n");
633        content.push_str("// Include this in phpstan.neon scanFiles for static analysis.\n\n");
634        content.push_str("declare(strict_types=1);\n\n");
635        // Use bracketed namespace syntax so we can add global-namespace function stubs later.
636        content.push_str(&format!("namespace {} {{\n\n", namespace));
637
638        // Exception class
639        content.push_str(&format!(
640            "class {}Exception extends \\RuntimeException\n{{\n",
641            class_name
642        ));
643        content.push_str("    public function getErrorCode(): int { }\n");
644        content.push_str("}\n\n");
645
646        // Opaque handle classes
647        for typ in api.types.iter().filter(|typ| !typ.is_trait) {
648            if typ.is_opaque {
649                if !typ.doc.is_empty() {
650                    content.push_str("/**\n");
651                    for line in typ.doc.lines() {
652                        if line.is_empty() {
653                            content.push_str(" *\n");
654                        } else {
655                            content.push_str(&format!(" * {}\n", line));
656                        }
657                    }
658                    content.push_str(" */\n");
659                }
660                content.push_str(&format!("class {}\n{{\n", typ.name));
661                // Opaque handles have no public constructors in PHP
662                content.push_str("}\n\n");
663            }
664        }
665
666        // Record / struct types (non-opaque with fields)
667        for typ in api.types.iter().filter(|typ| !typ.is_trait) {
668            if typ.is_opaque || typ.fields.is_empty() {
669                continue;
670            }
671            if !typ.doc.is_empty() {
672                content.push_str("/**\n");
673                for line in typ.doc.lines() {
674                    if line.is_empty() {
675                        content.push_str(" *\n");
676                    } else {
677                        content.push_str(&format!(" * {}\n", line));
678                    }
679                }
680                content.push_str(" */\n");
681            }
682            content.push_str(&format!("class {}\n{{\n", typ.name));
683
684            // Public property declarations (ext-php-rs exposes struct fields as properties)
685            for field in &typ.fields {
686                let is_array = matches!(&field.ty, TypeRef::Vec(_) | TypeRef::Map(_, _));
687                let prop_type = if field.optional {
688                    let inner = php_type(&field.ty);
689                    if inner.starts_with('?') {
690                        inner
691                    } else {
692                        format!("?{inner}")
693                    }
694                } else {
695                    php_type(&field.ty)
696                };
697                if is_array {
698                    let phpdoc = php_phpdoc_type(&field.ty);
699                    let nullable_prefix = if field.optional { "?" } else { "" };
700                    content.push_str(&format!("    /** @var {}{} */\n", nullable_prefix, phpdoc));
701                }
702                content.push_str(&format!("    public {} ${};\n", prop_type, field.name));
703            }
704            content.push('\n');
705
706            // Constructor with typed parameters.
707            // PHP requires required parameters to come before optional ones, so sort
708            // the fields: required first, then optional (preserving relative order within each group).
709            let mut sorted_fields: Vec<&alef_core::ir::FieldDef> = typ.fields.iter().collect();
710            sorted_fields.sort_by_key(|f| f.optional);
711
712            // Emit PHPDoc before the constructor for any array-typed fields so PHPStan
713            // understands the generic element type (e.g. `@param array<string> $items`).
714            let array_fields: Vec<&alef_core::ir::FieldDef> = sorted_fields
715                .iter()
716                .copied()
717                .filter(|f| matches!(&f.ty, TypeRef::Vec(_) | TypeRef::Map(_, _)))
718                .collect();
719            if !array_fields.is_empty() {
720                content.push_str("    /**\n");
721                for f in &array_fields {
722                    let phpdoc = php_phpdoc_type(&f.ty);
723                    let nullable_prefix = if f.optional { "?" } else { "" };
724                    content.push_str(&format!("     * @param {}{} ${}\n", nullable_prefix, phpdoc, f.name));
725                }
726                content.push_str("     */\n");
727            }
728
729            let params: Vec<String> = sorted_fields
730                .iter()
731                .map(|f| {
732                    let ptype = php_type(&f.ty);
733                    let nullable = if f.optional && !ptype.starts_with('?') {
734                        format!("?{ptype}")
735                    } else {
736                        ptype
737                    };
738                    let default = if f.optional { " = null" } else { "" };
739                    format!("        {} ${}{}", nullable, f.name, default)
740                })
741                .collect();
742            content.push_str("    public function __construct(\n");
743            content.push_str(&params.join(",\n"));
744            content.push_str("\n    ) { }\n\n");
745
746            // Getter methods for each field
747            for field in &typ.fields {
748                let is_array = matches!(&field.ty, TypeRef::Vec(_) | TypeRef::Map(_, _));
749                let return_type = if field.optional {
750                    let inner = php_type(&field.ty);
751                    if inner.starts_with('?') {
752                        inner
753                    } else {
754                        format!("?{inner}")
755                    }
756                } else {
757                    php_type(&field.ty)
758                };
759                let getter_name = field.name.to_lower_camel_case();
760                // Emit PHPDoc for array return types so PHPStan knows the element type.
761                if is_array {
762                    let phpdoc = php_phpdoc_type(&field.ty);
763                    let nullable_prefix = if field.optional { "?" } else { "" };
764                    content.push_str(&format!("    /** @return {}{} */\n", nullable_prefix, phpdoc));
765                }
766                content.push_str(&format!(
767                    "    public function get{}(): {} {{ }}\n",
768                    getter_name.to_pascal_case(),
769                    return_type
770                ));
771            }
772
773            content.push_str("}\n\n");
774        }
775
776        // Enum constants (PHP 8.1+ enums)
777        for enum_def in &api.enums {
778            content.push_str(&format!("enum {}: string\n{{\n", enum_def.name));
779            for variant in &enum_def.variants {
780                content.push_str(&format!("    case {} = '{}';\n", variant.name, variant.name));
781            }
782            content.push_str("}\n\n");
783        }
784
785        // Extension function stubs — generated as a native `{ClassName}Api` class with static
786        // methods. The PHP facade (`{ClassName}`) delegates to `{ClassName}Api::method()`.
787        // Using a class instead of global functions avoids the `inventory` crate registration
788        // issue on macOS (cdylib builds do not collect `#[php_function]` entries there).
789        if !api.functions.is_empty() {
790            // Bridge params are hidden from the PHP-visible API in stubs too.
791            let bridge_param_names_stubs: ahash::AHashSet<&str> = config
792                .trait_bridges
793                .iter()
794                .filter_map(|b| b.param_name.as_deref())
795                .collect();
796
797            content.push_str(&format!("class {}Api\n{{\n", class_name));
798            for func in &api.functions {
799                let return_type = php_type_fq(&func.return_type, &namespace);
800                let return_phpdoc = php_phpdoc_type_fq(&func.return_type, &namespace);
801                // Visible params exclude bridge params.
802                let visible_params: Vec<_> = func
803                    .params
804                    .iter()
805                    .filter(|p| !bridge_param_names_stubs.contains(p.name.as_str()))
806                    .collect();
807                // PHP requires required parameters before optional ones — stable sort.
808                let mut sorted_visible_params = visible_params.clone();
809                sorted_visible_params.sort_by_key(|p| p.optional);
810                // Emit PHPDoc when any param or the return type is an array, so PHPStan
811                // understands generic element types (e.g. array<string> vs bare array).
812                let has_array_params = visible_params
813                    .iter()
814                    .any(|p| matches!(&p.ty, TypeRef::Vec(_) | TypeRef::Map(_, _)));
815                let has_array_return = matches!(&func.return_type, TypeRef::Vec(_) | TypeRef::Map(_, _))
816                    || matches!(&func.return_type, TypeRef::Optional(inner) if matches!(inner.as_ref(), TypeRef::Vec(_) | TypeRef::Map(_, _)));
817                if has_array_params || has_array_return {
818                    content.push_str("    /**\n");
819                    for p in &visible_params {
820                        let ptype = php_phpdoc_type_fq(&p.ty, &namespace);
821                        let nullable_prefix = if p.optional { "?" } else { "" };
822                        content.push_str(&format!("     * @param {}{} ${}\n", nullable_prefix, ptype, p.name));
823                    }
824                    content.push_str(&format!("     * @return {}\n", return_phpdoc));
825                    content.push_str("     */\n");
826                }
827                let params: Vec<String> = sorted_visible_params
828                    .iter()
829                    .map(|p| {
830                        let ptype = php_type_fq(&p.ty, &namespace);
831                        if p.optional {
832                            format!("?{} ${} = null", ptype, p.name)
833                        } else {
834                            format!("{} ${}", ptype, p.name)
835                        }
836                    })
837                    .collect();
838                // ext-php-rs auto-converts Rust snake_case to PHP camelCase.
839                let stub_method_name = if func.is_async {
840                    format!("{}_async", func.name).to_lower_camel_case()
841                } else {
842                    func.name.to_lower_camel_case()
843                };
844                content.push_str(&format!(
845                    "    public static function {}({}): {} {{ }}\n",
846                    stub_method_name,
847                    params.join(", "),
848                    return_type
849                ));
850            }
851            content.push_str("}\n\n");
852        }
853
854        // Close the namespaced block
855        content.push_str("} // end namespace\n");
856
857        // Use stubs output path if configured, otherwise packages/php/stubs/
858        let output_dir = config
859            .php
860            .as_ref()
861            .and_then(|p| p.stubs.as_ref())
862            .map(|s| s.output.to_string_lossy().to_string())
863            .unwrap_or_else(|| "packages/php/stubs/".to_string());
864
865        Ok(vec![GeneratedFile {
866            path: PathBuf::from(&output_dir).join(format!("{}_extension.php", extension_name)),
867            content,
868            generated_header: false,
869        }])
870    }
871
872    fn build_config(&self) -> Option<BuildConfig> {
873        Some(BuildConfig {
874            tool: "cargo",
875            crate_suffix: "-php",
876            build_dep: BuildDependency::None,
877            post_build: vec![],
878        })
879    }
880}
881
882/// Map an IR [`TypeRef`] to a PHPDoc type string with generic parameters (e.g., `array<string>`).
883/// PHPStan at level `max` requires iterable value types in PHPDoc annotations.
884fn php_phpdoc_type(ty: &TypeRef) -> String {
885    match ty {
886        TypeRef::Vec(inner) => format!("array<{}>", php_phpdoc_type(inner)),
887        TypeRef::Map(k, v) => format!("array<{}, {}>", php_phpdoc_type(k), php_phpdoc_type(v)),
888        TypeRef::Optional(inner) => format!("?{}", php_phpdoc_type(inner)),
889        _ => php_type(ty),
890    }
891}
892
893/// Map an IR [`TypeRef`] to a fully-qualified PHPDoc type string with generics (e.g., `array<\Ns\T>`).
894fn php_phpdoc_type_fq(ty: &TypeRef, namespace: &str) -> String {
895    match ty {
896        TypeRef::Vec(inner) => format!("array<{}>", php_phpdoc_type_fq(inner, namespace)),
897        TypeRef::Map(k, v) => format!(
898            "array<{}, {}>",
899            php_phpdoc_type_fq(k, namespace),
900            php_phpdoc_type_fq(v, namespace)
901        ),
902        TypeRef::Named(name) => format!("\\{}\\{}", namespace, name),
903        TypeRef::Optional(inner) => format!("?{}", php_phpdoc_type_fq(inner, namespace)),
904        _ => php_type(ty),
905    }
906}
907
908/// Map an IR [`TypeRef`] to a fully-qualified PHP type-hint string for use outside the namespace.
909fn php_type_fq(ty: &TypeRef, namespace: &str) -> String {
910    match ty {
911        TypeRef::Named(name) => format!("\\{}\\{}", namespace, name),
912        TypeRef::Optional(inner) => {
913            let inner_type = php_type_fq(inner, namespace);
914            if inner_type.starts_with('?') {
915                inner_type
916            } else {
917                format!("?{inner_type}")
918            }
919        }
920        _ => php_type(ty),
921    }
922}
923
924/// Map an IR [`TypeRef`] to a PHP type-hint string.
925fn php_type(ty: &TypeRef) -> String {
926    match ty {
927        TypeRef::String | TypeRef::Char | TypeRef::Json | TypeRef::Bytes | TypeRef::Path => "string".to_string(),
928        TypeRef::Primitive(p) => match p {
929            PrimitiveType::Bool => "bool".to_string(),
930            PrimitiveType::F32 | PrimitiveType::F64 => "float".to_string(),
931            PrimitiveType::U8
932            | PrimitiveType::U16
933            | PrimitiveType::U32
934            | PrimitiveType::U64
935            | PrimitiveType::I8
936            | PrimitiveType::I16
937            | PrimitiveType::I32
938            | PrimitiveType::I64
939            | PrimitiveType::Usize
940            | PrimitiveType::Isize => "int".to_string(),
941        },
942        TypeRef::Optional(inner) => {
943            // Flatten nested Option<Option<T>> to a single nullable type.
944            // PHP has no double-nullable concept; ?T already covers null.
945            let inner_type = php_type(inner);
946            if inner_type.starts_with('?') {
947                inner_type
948            } else {
949                format!("?{inner_type}")
950            }
951        }
952        TypeRef::Vec(_) | TypeRef::Map(_, _) => "array".to_string(),
953        TypeRef::Named(name) => name.clone(),
954        TypeRef::Unit => "void".to_string(),
955        TypeRef::Duration => "float".to_string(),
956    }
957}