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::{Language, ResolvedCrateConfig, 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 minijinja::context;
18use std::path::PathBuf;
19
20use crate::naming::php_autoload_namespace;
21use functions::{gen_async_function_as_static_method, gen_function_as_static_method};
22
23/// PHP 8.1 enum cases cannot use case-insensitive `class` (reserved for
24/// `EnumName::class` syntax). Append a trailing underscore for those cases.
25fn sanitize_php_enum_case(name: &str) -> String {
26    if name.eq_ignore_ascii_case("class") {
27        format!("{name}_")
28    } else {
29        name.to_string()
30    }
31}
32use helpers::{gen_enum_tainted_from_binding_to_core, gen_tokio_runtime, has_enum_named_field, references_named_type};
33use types::{
34    gen_enum_constants, gen_flat_data_enum, gen_flat_data_enum_from_impls, gen_flat_data_enum_methods,
35    gen_opaque_struct_methods, gen_php_struct, is_tagged_data_enum, is_untagged_data_enum,
36};
37
38pub struct PhpBackend;
39
40impl PhpBackend {
41    fn binding_config(core_import: &str, has_serde: bool) -> RustBindingConfig<'_> {
42        RustBindingConfig {
43            struct_attrs: &["php_class"],
44            field_attrs: &[],
45            struct_derives: &["Clone"],
46            method_block_attr: Some("php_impl"),
47            constructor_attr: "",
48            static_attr: None,
49            function_attr: "#[php_function]",
50            enum_attrs: &[],
51            enum_derives: &[],
52            needs_signature: false,
53            signature_prefix: "",
54            signature_suffix: "",
55            core_import,
56            async_pattern: AsyncPattern::TokioBlockOn,
57            has_serde,
58            type_name_prefix: "",
59            option_duration_on_defaults: true,
60            opaque_type_names: &[],
61            skip_impl_constructor: false,
62            cast_uints_to_i32: false,
63            cast_large_ints_to_f64: false,
64            named_non_opaque_params_by_ref: false,
65            lossy_skip_types: &[],
66        }
67    }
68}
69
70impl Backend for PhpBackend {
71    fn name(&self) -> &str {
72        "php"
73    }
74
75    fn language(&self) -> Language {
76        Language::Php
77    }
78
79    fn capabilities(&self) -> Capabilities {
80        Capabilities {
81            supports_async: false,
82            supports_classes: true,
83            supports_enums: true,
84            supports_option: true,
85            supports_result: true,
86            ..Capabilities::default()
87        }
88    }
89
90    fn generate_bindings(&self, api: &ApiSurface, config: &ResolvedCrateConfig) -> anyhow::Result<Vec<GeneratedFile>> {
91        // Separate unit-variant enums (→ String), tagged data enums (→ flat PHP class),
92        // and untagged data enums (→ serde_json::Value, converted via from_value at binding↔core boundary).
93        let data_enum_names: AHashSet<String> = api
94            .enums
95            .iter()
96            .filter(|e| is_tagged_data_enum(e))
97            .map(|e| e.name.clone())
98            .collect();
99        let untagged_data_enum_names: AHashSet<String> = api
100            .enums
101            .iter()
102            .filter(|e| is_untagged_data_enum(e))
103            .map(|e| e.name.clone())
104            .collect();
105        // String-mapped enums: everything that is NOT a tagged-data enum AND NOT an untagged-data enum.
106        // Includes unit-variant enums (FilePurpose, ToolType, …) which are exposed as PHP string constants.
107        let enum_names: AHashSet<String> = api
108            .enums
109            .iter()
110            .filter(|e| !is_tagged_data_enum(e) && !is_untagged_data_enum(e))
111            .map(|e| e.name.clone())
112            .collect();
113        let mapper = PhpMapper {
114            enum_names: enum_names.clone(),
115            data_enum_names: data_enum_names.clone(),
116            untagged_data_enum_names: untagged_data_enum_names.clone(),
117        };
118        let core_import = config.core_import_name();
119
120        // Get exclusion lists from PHP config
121        let php_config = config.php.as_ref();
122        let exclude_functions = php_config.map(|c| c.exclude_functions.clone()).unwrap_or_default();
123        let exclude_types = php_config.map(|c| c.exclude_types.clone()).unwrap_or_default();
124
125        let output_dir = resolve_output_dir(config.output_paths.get("php"), &config.name, "crates/{name}-php/src/");
126        let has_serde = detect_serde_available(&output_dir);
127
128        // Build the opaque type names list: IR opaque types + bridge type aliases.
129        // Bridge type aliases (e.g. `VisitorHandle`) wrap Rc-based handles and cannot
130        // implement serde::Serialize/Deserialize.  Including them ensures gen_php_struct
131        // emits #[serde(skip)] for fields of those types so derives on the enclosing
132        // struct (e.g. ConversionOptions) still compile.
133        let bridge_type_aliases_php: Vec<String> = config
134            .trait_bridges
135            .iter()
136            .filter_map(|b| b.type_alias.clone())
137            .collect();
138        let bridge_type_aliases_set: AHashSet<String> = bridge_type_aliases_php.iter().cloned().collect();
139        let mut opaque_names_vec_php: Vec<String> = api
140            .types
141            .iter()
142            .filter(|t| t.is_opaque)
143            .map(|t| t.name.clone())
144            .collect();
145        opaque_names_vec_php.extend(bridge_type_aliases_php);
146
147        let mut cfg = Self::binding_config(&core_import, has_serde);
148        cfg.opaque_type_names = &opaque_names_vec_php;
149
150        // Build the inner module content (types, methods, conversions)
151        let mut builder = RustFileBuilder::new().with_generated_header();
152        builder.add_inner_attribute("allow(dead_code, unused_imports, unused_variables)");
153        builder.add_inner_attribute("allow(unsafe_code)");
154        // PHP parameter names are lowerCamelCase; Rust complains about non-snake_case variables.
155        builder.add_inner_attribute("allow(non_snake_case)");
156        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, clippy::arc_with_non_send_sync, clippy::collapsible_if, clippy::clone_on_copy)");
157        builder.add_import("ext_php_rs::prelude::*");
158
159        // Import serde_json when available (needed for serde-based param conversion)
160        if has_serde {
161            builder.add_import("serde_json");
162        }
163
164        // Import traits needed for trait method dispatch
165        for trait_path in generators::collect_trait_imports(api) {
166            builder.add_import(&trait_path);
167        }
168
169        // Only import HashMap when Map-typed fields or returns are present
170        let has_maps = api.types.iter().any(|t| {
171            t.fields
172                .iter()
173                .any(|f| matches!(&f.ty, alef_core::ir::TypeRef::Map(_, _)))
174        }) || api
175            .functions
176            .iter()
177            .any(|f| matches!(&f.return_type, alef_core::ir::TypeRef::Map(_, _)));
178        if has_maps {
179            builder.add_import("std::collections::HashMap");
180        }
181
182        // PhpBytes wrapper: accepts PHP binary strings without UTF-8 validation.
183        // ext-php-rs's String FromZval rejects non-UTF-8 strings, so binary content
184        // (PDFs, images, etc.) gets "Invalid value given for argument" errors. This
185        // wrapper reads the raw bytes via `zend_str()` and exposes them as Vec<u8>.
186        builder.add_item(
187            "#[derive(Debug, Clone, Default)]\n\
188             pub struct PhpBytes(pub Vec<u8>);\n\
189             \n\
190             impl<'a> ext_php_rs::convert::FromZval<'a> for PhpBytes {\n    \
191                 const TYPE: ext_php_rs::flags::DataType = ext_php_rs::flags::DataType::String;\n    \
192                 fn from_zval(zval: &'a ext_php_rs::types::Zval) -> Option<Self> {\n        \
193                     zval.zend_str().map(|zs| PhpBytes(zs.as_bytes().to_vec()))\n    \
194                 }\n\
195             }\n\
196             \n\
197             impl From<PhpBytes> for Vec<u8> {\n    \
198                 fn from(b: PhpBytes) -> Self { b.0 }\n\
199             }\n\
200             \n\
201             impl From<Vec<u8>> for PhpBytes {\n    \
202                 fn from(v: Vec<u8>) -> Self { PhpBytes(v) }\n\
203             }\n",
204        );
205
206        // Custom module declarations
207        let custom_mods = config.custom_modules.for_language(Language::Php);
208        for module in custom_mods {
209            builder.add_item(&format!("pub mod {module};"));
210        }
211
212        // Check if any function or method is async
213        let has_async =
214            api.functions.iter().any(|f| f.is_async) || api.types.iter().any(|t| t.methods.iter().any(|m| m.is_async));
215
216        if has_async {
217            builder.add_item(&gen_tokio_runtime());
218        }
219
220        // Check if we have opaque types and add Arc import if needed
221        let opaque_types: AHashSet<String> = api
222            .types
223            .iter()
224            .filter(|t| t.is_opaque)
225            .map(|t| t.name.clone())
226            .collect();
227        if !opaque_types.is_empty() {
228            builder.add_import("std::sync::Arc");
229        }
230
231        // Compute the PHP namespace for namespaced class registration.
232        // Delegates to config so [php].namespace overrides are respected.
233        let extension_name = config.php_extension_name();
234        let php_namespace = php_autoload_namespace(config);
235
236        // Build adapter body map before type iteration so bodies are available for method generation.
237        let adapter_bodies = alef_adapters::build_adapter_bodies(config, Language::Php)?;
238
239        // Emit adapter-generated standalone items (streaming iterators, callback bridges).
240        for adapter in &config.adapters {
241            match adapter.pattern {
242                alef_core::config::AdapterPattern::Streaming => {
243                    let key = format!("{}.__stream_struct__", adapter.item_type.as_deref().unwrap_or(""));
244                    if let Some(struct_code) = adapter_bodies.get(&key) {
245                        builder.add_item(struct_code);
246                    }
247                }
248                alef_core::config::AdapterPattern::CallbackBridge => {
249                    let struct_key = format!("{}.__bridge_struct__", adapter.name);
250                    let impl_key = format!("{}.__bridge_impl__", adapter.name);
251                    if let Some(struct_code) = adapter_bodies.get(&struct_key) {
252                        builder.add_item(struct_code);
253                    }
254                    if let Some(impl_code) = adapter_bodies.get(&impl_key) {
255                        builder.add_item(impl_code);
256                    }
257                }
258                _ => {}
259            }
260        }
261
262        for typ in api
263            .types
264            .iter()
265            .filter(|typ| !typ.is_trait && !exclude_types.contains(&typ.name))
266        {
267            if typ.is_opaque {
268                // Generate the opaque struct with separate #[php_class] and
269                // #[php(name = "Ns\\Type")] attributes (ext-php-rs 0.15+ syntax).
270                // Escape '\' in the namespace so the generated Rust string literal is valid.
271                let ns_escaped = php_namespace.replace('\\', "\\\\");
272                let php_name_attr = format!("php(name = \"{}\\\\{}\")", ns_escaped, typ.name);
273                let opaque_attr_arr = ["php_class", php_name_attr.as_str()];
274                let opaque_cfg = RustBindingConfig {
275                    struct_attrs: &opaque_attr_arr,
276                    ..cfg
277                };
278                builder.add_item(&generators::gen_opaque_struct(typ, &opaque_cfg));
279                builder.add_item(&gen_opaque_struct_methods(
280                    typ,
281                    &mapper,
282                    &opaque_types,
283                    &core_import,
284                    &adapter_bodies,
285                ));
286            } else {
287                // gen_struct adds #[derive(Default)] when typ.has_default is true,
288                // so no separate Default impl is needed.
289                builder.add_item(&gen_php_struct(typ, &mapper, &cfg, Some(&php_namespace), &enum_names));
290                builder.add_item(&types::gen_struct_methods_with_exclude(
291                    typ,
292                    &mapper,
293                    has_serde,
294                    &core_import,
295                    &opaque_types,
296                    &enum_names,
297                    &api.enums,
298                    &exclude_functions,
299                    &bridge_type_aliases_set,
300                ));
301            }
302        }
303
304        for enum_def in &api.enums {
305            if is_tagged_data_enum(enum_def) {
306                // Tagged data enums (struct variants) are lowered to a flat PHP class.
307                builder.add_item(&gen_flat_data_enum(enum_def, &mapper, Some(&php_namespace)));
308                builder.add_item(&gen_flat_data_enum_methods(enum_def, &mapper));
309            } else {
310                builder.add_item(&gen_enum_constants(enum_def));
311            }
312        }
313
314        // Generate free functions as static methods on a facade class rather than standalone
315        // `#[php_function]` items. Standalone functions rely on the `inventory` crate for
316        // auto-registration, which does not work in cdylib builds on macOS. Classes registered
317        // via `.class::<T>()` in the module builder DO work on all platforms.
318        let included_functions: Vec<_> = api
319            .functions
320            .iter()
321            .filter(|f| !exclude_functions.contains(&f.name))
322            .collect();
323        if !included_functions.is_empty() {
324            let facade_class_name = extension_name.to_pascal_case();
325            // Build each static method body (no #[php_function] attribute — they live inside
326            // a #[php_impl] block which handles registration via the class machinery).
327            let mut method_items: Vec<String> = Vec::new();
328            for func in included_functions {
329                let bridge_param = crate::trait_bridge::find_bridge_param(func, &config.trait_bridges);
330                if let Some((param_idx, bridge_cfg)) = bridge_param {
331                    method_items.push(crate::trait_bridge::gen_bridge_function(
332                        func,
333                        param_idx,
334                        bridge_cfg,
335                        &mapper,
336                        &opaque_types,
337                        &core_import,
338                    ));
339                } else if func.is_async {
340                    method_items.push(gen_async_function_as_static_method(
341                        func,
342                        &mapper,
343                        &opaque_types,
344                        &core_import,
345                        &config.trait_bridges,
346                    ));
347                } else {
348                    method_items.push(gen_function_as_static_method(
349                        func,
350                        &mapper,
351                        &opaque_types,
352                        &core_import,
353                        &config.trait_bridges,
354                        has_serde,
355                    ));
356                }
357            }
358
359            let methods_joined = method_items
360                .iter()
361                .map(|m| {
362                    // Indent each line of each method by 4 spaces
363                    m.lines()
364                        .map(|l| {
365                            if l.is_empty() {
366                                String::new()
367                            } else {
368                                format!("    {l}")
369                            }
370                        })
371                        .collect::<Vec<_>>()
372                        .join("\n")
373                })
374                .collect::<Vec<_>>()
375                .join("\n\n");
376            // The PHP-visible class name gets an "Api" suffix to avoid collision with the
377            // PHP facade class (e.g. `Kreuzcrawl\Kreuzcrawl`) that Composer autoloads.
378            let php_api_class_name = format!("{facade_class_name}Api");
379            // Escape '\' so the generated Rust string literal is valid (e.g. "Ns\\ClassName").
380            let ns_escaped_facade = php_namespace.replace('\\', "\\\\");
381            let php_name_attr = format!("php(name = \"{}\\\\{}\")", ns_escaped_facade, php_api_class_name);
382            let facade_struct = format!(
383                "#[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}}"
384            );
385            builder.add_item(&facade_struct);
386
387            // Trait bridge structs — top-level items (outside the facade class)
388            for bridge_cfg in &config.trait_bridges {
389                if let Some(trait_type) = api.types.iter().find(|t| t.is_trait && t.name == bridge_cfg.trait_name) {
390                    let bridge = crate::trait_bridge::gen_trait_bridge(
391                        trait_type,
392                        bridge_cfg,
393                        &core_import,
394                        &config.error_type_name(),
395                        &config.error_constructor_expr(),
396                        api,
397                    );
398                    for imp in &bridge.imports {
399                        builder.add_import(imp);
400                    }
401                    builder.add_item(&bridge.code);
402                }
403            }
404        }
405
406        let convertible = alef_codegen::conversions::convertible_types(api);
407        let core_to_binding = alef_codegen::conversions::core_to_binding_convertible_types(api);
408        let input_types = alef_codegen::conversions::input_type_names(api);
409        // From/Into conversions with PHP-specific i64 casts.
410        // Types with enum Named fields (or that reference such types transitively) can't
411        // have binding->core From impls because PHP maps enums to String and there's no
412        // From<String> for the core enum type. Core->binding is always safe.
413        let enum_names_ref = &mapper.enum_names;
414        let bridge_skip_types: Vec<String> = config
415            .trait_bridges
416            .iter()
417            .filter_map(|b| b.type_alias.clone())
418            .collect();
419        let php_conv_config = ConversionConfig {
420            cast_large_ints_to_i64: true,
421            enum_string_names: Some(enum_names_ref),
422            untagged_data_enum_names: Some(&mapper.untagged_data_enum_names),
423            // PHP keeps `serde_json::Value` as-is in the binding struct (matches PhpMapper::json).
424            // `json_to_string` was previously enabled but caused `from_json` to fail when a JSON
425            // object/array landed in a `String`-typed field (e.g. tool `parameters` schema).
426            json_as_value: true,
427            include_cfg_metadata: false,
428            option_duration_on_defaults: true,
429            from_binding_skip_types: &bridge_skip_types,
430            ..Default::default()
431        };
432        // Build transitive set of types that can't have binding->core From
433        let mut enum_tainted: AHashSet<String> = AHashSet::new();
434        for typ in api.types.iter().filter(|typ| !typ.is_trait) {
435            if has_enum_named_field(typ, enum_names_ref) {
436                enum_tainted.insert(typ.name.clone());
437            }
438        }
439        // Transitively mark types that reference enum-tainted types
440        let mut changed = true;
441        while changed {
442            changed = false;
443            for typ in api.types.iter().filter(|typ| !typ.is_trait) {
444                if !enum_tainted.contains(&typ.name)
445                    && typ.fields.iter().any(|f| references_named_type(&f.ty, &enum_tainted))
446                {
447                    enum_tainted.insert(typ.name.clone());
448                    changed = true;
449                }
450            }
451        }
452        for typ in api.types.iter().filter(|typ| !typ.is_trait) {
453            // binding->core: only when not enum-tainted and type is used as input
454            if input_types.contains(&typ.name)
455                && !enum_tainted.contains(&typ.name)
456                && alef_codegen::conversions::can_generate_conversion(typ, &convertible)
457            {
458                builder.add_item(&alef_codegen::conversions::gen_from_binding_to_core_cfg(
459                    typ,
460                    &core_import,
461                    &php_conv_config,
462                ));
463            } else if input_types.contains(&typ.name) && enum_tainted.contains(&typ.name) {
464                // Enum-tainted types: generate From with string->enum parsing for enum-Named
465                // fields, using first variant as fallback. Data-variant enum fields fill
466                // data fields with Default::default().
467                // Note: JSON roundtrip was previously used when has_serde=true, but that
468                // breaks on non-optional Duration fields (null != u64) and empty-string enum
469                // fields ("" is not a valid variant). Field-by-field conversion handles both.
470                builder.add_item(&gen_enum_tainted_from_binding_to_core(
471                    typ,
472                    &core_import,
473                    enum_names_ref,
474                    &enum_tainted,
475                    &php_conv_config,
476                    &api.enums,
477                    &bridge_type_aliases_set,
478                ));
479            }
480            // core->binding: always (enum->String via format, sanitized fields via format)
481            if alef_codegen::conversions::can_generate_conversion(typ, &core_to_binding) {
482                builder.add_item(&alef_codegen::conversions::gen_from_core_to_binding_cfg(
483                    typ,
484                    &core_import,
485                    &opaque_types,
486                    &php_conv_config,
487                ));
488            }
489        }
490
491        // From impls for tagged data enums lowered to flat PHP classes.
492        for enum_def in api.enums.iter().filter(|e| is_tagged_data_enum(e)) {
493            builder.add_item(&gen_flat_data_enum_from_impls(enum_def, &core_import));
494        }
495
496        // Error converter functions
497        for error in &api.errors {
498            builder.add_item(&alef_codegen::error_gen::gen_php_error_converter(error, &core_import));
499        }
500
501        // Serde default helpers for bool fields whose core default is `true`,
502        // and for SecurityLimits fields which use struct-level defaults.
503        // Referenced by #[serde(default = "crate::serde_defaults::...")] on struct fields.
504        if has_serde {
505            let serde_module = "mod serde_defaults {\n    pub fn bool_true() -> bool { true }\n\
506                   pub fn max_archive_size() -> i64 { 500 * 1024 * 1024 }\n\
507                   pub fn max_compression_ratio() -> i64 { 100 }\n\
508                   pub fn max_files_in_archive() -> i64 { 10_000 }\n\
509                   pub fn max_nesting_depth() -> i64 { 1024 }\n\
510                   pub fn max_entity_length() -> i64 { 1024 * 1024 }\n\
511                   pub fn max_content_size() -> i64 { 100 * 1024 * 1024 }\n\
512                   pub fn max_iterations() -> i64 { 10_000_000 }\n\
513                   pub fn max_xml_depth() -> i64 { 1024 }\n\
514                   pub fn max_table_cells() -> i64 { 100_000 }\n\
515                }";
516            builder.add_item(serde_module);
517        }
518
519        // Always enable abi_vectorcall on Windows — ext-php-rs requires the
520        // `vectorcall` calling convention for PHP entry points there. The feature
521        // is unstable on stable Rust; consumers either build with nightly or set
522        // RUSTC_BOOTSTRAP=1 (the upstream-recommended workaround). This cfg_attr
523        // is a no-op on non-windows so it costs nothing on Linux/macOS builds.
524        let php_config = config.php.as_ref();
525        builder.add_inner_attribute("cfg_attr(windows, feature(abi_vectorcall))");
526
527        // Optional feature gate — when [php].feature_gate is set, the entire crate
528        // is conditionally compiled. Use this for parity with PyO3's `extension-module`
529        // pattern; most PHP bindings don't need it.
530        if let Some(feature_name) = php_config.and_then(|c| c.feature_gate.as_deref()) {
531            builder.add_inner_attribute(&format!("cfg(feature = \"{feature_name}\")"));
532        }
533
534        // PHP module entry point — explicit class registration required because
535        // `inventory` crate auto-registration doesn't work in cdylib on macOS.
536        let mut class_registrations = String::new();
537        for typ in api
538            .types
539            .iter()
540            .filter(|typ| !typ.is_trait && !exclude_types.contains(&typ.name))
541        {
542            class_registrations.push_str(&crate::template_env::render(
543                "php_class_registration.jinja",
544                context! { class_name => &typ.name },
545            ));
546        }
547        // Register the facade class that wraps free functions as static methods.
548        if !api.functions.is_empty() {
549            let facade_class_name = extension_name.to_pascal_case();
550            class_registrations.push_str(&crate::template_env::render(
551                "php_class_registration.jinja",
552                context! { class_name => &format!("{facade_class_name}Api") },
553            ));
554        }
555        // Tagged data enums are lowered to flat PHP classes — register them like other classes.
556        // Unit-variant enums remain as string constants and don't need .class::<T>() registration.
557        for enum_def in api.enums.iter().filter(|e| is_tagged_data_enum(e)) {
558            class_registrations.push_str(&crate::template_env::render(
559                "php_class_registration.jinja",
560                context! { class_name => &enum_def.name },
561            ));
562        }
563        builder.add_item(&format!(
564            "#[php_module]\npub fn get_module(module: ModuleBuilder) -> ModuleBuilder {{\n    module{class_registrations}\n}}"
565        ));
566
567        let mut content = builder.build();
568
569        // Post-process generated code to replace the bridge builder method.
570        // The generated code produces `visitor(Option<&VisitorHandle>)` which is
571        // unreachable from PHP. Replace the entire method — signature and body —
572        // with one that accepts a ZendObject and builds the proper bridge handle.
573        for bridge in &config.trait_bridges {
574            if let Some(field_name) = bridge.resolved_options_field() {
575                let param_name = bridge.param_name.as_deref().unwrap_or(field_name);
576                let type_alias = bridge.type_alias.as_deref().unwrap_or("VisitorHandle");
577                let options_type = bridge.options_type.as_deref().unwrap_or("ConversionOptions");
578                let builder_type = format!("{}Builder", options_type);
579                let bridge_struct = format!("Php{}Bridge", bridge.trait_name);
580
581                // Match the verbatim pre-rustfmt output from codegen.
582                // gen_instance_method produces 4-space-indented lines (signature + body),
583                // then ImplBuilder.build() adds 4 more spaces to every line → 8/8/4 indent.
584                // The body is a single-line Self { inner: Arc::new(...) } expression.
585                // rustfmt later reformats this to the 4/8/8/4 multi-line style on disk.
586                let old_method = format!(
587                    "        pub fn {field_name}(&self, {param_name}: Option<&{type_alias}>) -> {builder_type} {{\n        Self {{ inner: Arc::new((*self.inner).clone().{field_name}({param_name}.as_ref().map(|v| &v.inner))) }}\n    }}"
588                );
589                let new_method = format!(
590                    "        pub fn {field_name}(&self, {param_name}: &mut ext_php_rs::types::ZendObject) -> {builder_type} {{\n        let bridge = {bridge_struct}::new({param_name});\n        let handle: html_to_markdown_rs::visitor::VisitorHandle = std::rc::Rc::new(std::cell::RefCell::new(bridge));\n        Self {{ inner: Arc::new((*self.inner).clone().{field_name}(Some(handle))) }}\n    }}"
591                );
592
593                content = content.replace(&old_method, &new_method);
594            }
595        }
596
597        Ok(vec![GeneratedFile {
598            path: PathBuf::from(&output_dir).join("lib.rs"),
599            content,
600            generated_header: false,
601        }])
602    }
603
604    fn generate_public_api(
605        &self,
606        api: &ApiSurface,
607        config: &ResolvedCrateConfig,
608    ) -> anyhow::Result<Vec<GeneratedFile>> {
609        let extension_name = config.php_extension_name();
610        let class_name = extension_name.to_pascal_case();
611
612        // Generate PHP wrapper class
613        let mut content = String::new();
614        content.push_str(&crate::template_env::render(
615            "php_file_header.jinja",
616            minijinja::Value::default(),
617        ));
618        content.push_str(&hash::header(CommentStyle::DoubleSlash));
619        content.push_str(&crate::template_env::render(
620            "php_declare_strict_types.jinja",
621            minijinja::Value::default(),
622        ));
623
624        // Determine namespace — delegates to config so [php].namespace overrides are respected.
625        let namespace = php_autoload_namespace(config);
626
627        content.push_str(&crate::template_env::render(
628            "php_namespace.jinja",
629            context! { namespace => &namespace },
630        ));
631        content.push_str(&crate::template_env::render(
632            "php_facade_class_declaration.jinja",
633            context! { class_name => &class_name },
634        ));
635
636        // Build the set of bridge param names so they are excluded from public PHP signatures.
637        let bridge_param_names_pub: ahash::AHashSet<&str> = config
638            .trait_bridges
639            .iter()
640            .filter_map(|b| b.param_name.as_deref())
641            .collect();
642
643        // Config types whose PHP constructors can be called with zero arguments.
644        // Only qualifies when ALL fields are optional (PHP constructor needs no required args).
645        // `has_default` (Rust Default impl) is NOT sufficient — the PHP constructor is
646        // generated from struct fields and still requires non-optional ones.
647        let no_arg_constructor_types: AHashSet<String> = api
648            .types
649            .iter()
650            .filter(|t| t.fields.iter().all(|f| f.optional))
651            .map(|t| t.name.clone())
652            .collect();
653
654        // Generate wrapper methods for functions
655        for func in &api.functions {
656            // PHP method names are based on the Rust source name (camelCased).
657            // Async functions get an "Async" suffix to disambiguate from sync variants.
658            // For example: `extract_bytes` (async) → `extractBytesAsync()`,
659            // `extract_bytes_sync` (sync) → `extractBytesSync()`.
660            let base_name = func.name.to_lower_camel_case();
661            let method_name = if func.is_async && !func.name.ends_with("_async") {
662                format!("{}Async", base_name)
663            } else {
664                base_name
665            };
666            let return_php_type = php_type(&func.return_type);
667
668            // Visible params exclude bridge params (not surfaced to PHP callers).
669            let visible_params: Vec<_> = func
670                .params
671                .iter()
672                .filter(|p| !bridge_param_names_pub.contains(p.name.as_str()))
673                .collect();
674
675            // PHPDoc block
676            content.push_str(&crate::template_env::render(
677                "php_phpdoc_block_start.jinja",
678                minijinja::Value::default(),
679            ));
680            for line in func.doc.lines() {
681                if line.is_empty() {
682                    content.push_str(&crate::template_env::render(
683                        "php_phpdoc_empty_line.jinja",
684                        minijinja::Value::default(),
685                    ));
686                } else {
687                    content.push_str(&crate::template_env::render(
688                        "php_phpdoc_text_line.jinja",
689                        context! { text => line },
690                    ));
691                }
692            }
693            if func.doc.is_empty() {
694                content.push_str(&crate::template_env::render(
695                    "php_phpdoc_text_line.jinja",
696                    context! { text => &format!("{}.", method_name) },
697                ));
698            }
699            content.push_str(&crate::template_env::render(
700                "php_phpdoc_empty_line.jinja",
701                minijinja::Value::default(),
702            ));
703            for p in &visible_params {
704                let ptype = php_phpdoc_type(&p.ty);
705                let nullable_prefix = if p.optional { "?" } else { "" };
706                content.push_str(&crate::template_env::render(
707                    "php_phpdoc_param_line.jinja",
708                    context! {
709                        nullable_prefix => nullable_prefix,
710                        param_type => &ptype,
711                        param_name => &p.name,
712                    },
713                ));
714            }
715            let return_phpdoc = php_phpdoc_type(&func.return_type);
716            content.push_str(&crate::template_env::render(
717                "php_phpdoc_return_line.jinja",
718                context! { return_type => &return_phpdoc },
719            ));
720            if func.error_type.is_some() {
721                content.push_str(&crate::template_env::render(
722                    "php_phpdoc_throws_line.jinja",
723                    context! {
724                        namespace => namespace.as_str(),
725                        class_name => &class_name,
726                    },
727                ));
728            }
729            content.push_str(&crate::template_env::render(
730                "php_phpdoc_block_end.jinja",
731                minijinja::Value::default(),
732            ));
733
734            // Method signature with type hints.
735            // Keep parameters in their original Rust order.
736            // Since PHP doesn't allow optional params before required ones, and some Rust
737            // functions have optional params in the middle, we must make all params after
738            // the first optional one also optional (nullable with null default).
739            // This ensures e2e generated test code (which uses Rust param order) will work.
740            // Additionally, config-like parameters (Named types ending in "Config") should
741            // be treated as optional for PHP even if not explicitly marked as such in the IR.
742            // Helper: a config param is only treated as optional when its type can be
743            // constructed with zero arguments (all fields are optional in the IR).
744            let is_optional_config_param = |p: &alef_core::ir::ParamDef| -> bool {
745                if let TypeRef::Named(name) = &p.ty {
746                    (name.ends_with("Config") || name.as_str() == "config")
747                        && no_arg_constructor_types.contains(name.as_str())
748                } else {
749                    false
750                }
751            };
752
753            let mut first_optional_idx = None;
754            for (idx, p) in visible_params.iter().enumerate() {
755                if p.optional || is_optional_config_param(p) {
756                    first_optional_idx = Some(idx);
757                    break;
758                }
759            }
760
761            content.push_str(&crate::template_env::render(
762                "php_method_signature_start.jinja",
763                context! { method_name => &method_name },
764            ));
765
766            let params: Vec<String> = visible_params
767                .iter()
768                .enumerate()
769                .map(|(idx, p)| {
770                    let ptype = php_type(&p.ty);
771                    // Make param optional if:
772                    // 1. It's explicitly optional OR
773                    // 2. It's a config parameter with a no-arg constructor OR
774                    // 3. It comes after the first optional/config param
775                    let should_be_optional = p.optional
776                        || is_optional_config_param(p)
777                        || first_optional_idx.is_some_and(|first| idx >= first);
778                    if should_be_optional {
779                        format!("?{} ${} = null", ptype, p.name)
780                    } else {
781                        format!("{} ${}", ptype, p.name)
782                    }
783                })
784                .collect();
785            content.push_str(&params.join(", "));
786            content.push_str(&crate::template_env::render(
787                "php_method_signature_end.jinja",
788                context! { return_type => &return_php_type },
789            ));
790            // Async functions are registered in the extension with an `_async` suffix
791            // (see gen_async_function which generates `pub fn {name}_async`).
792            // Delegate to the native extension class (registered as `{namespace}\{class_name}Api`).
793            // ext-php-rs auto-converts Rust snake_case to PHP camelCase
794            let ext_method_name = if func.is_async {
795                format!("{}_async", func.name).to_lower_camel_case()
796            } else {
797                func.name.to_lower_camel_case()
798            };
799            let is_void = matches!(&func.return_type, TypeRef::Unit);
800            // Pass parameters to the native function in their ORIGINAL order (not sorted).
801            // The native extension expects parameters in the order defined in the Rust function.
802            // The PHP facade reorders them only in its own signature for PHP syntax compliance,
803            // but must pass them in the original order when calling the native method.
804            // Config-type params that were made optional (nullable) in the facade must be
805            // coerced to their default constructor when null, since the native ext requires
806            // non-nullable objects.
807            let call_params = visible_params
808                .iter()
809                .enumerate()
810                .map(|(idx, p)| {
811                    let should_be_optional = p.optional
812                        || is_optional_config_param(p)
813                        || first_optional_idx.is_some_and(|first| idx >= first);
814                    if should_be_optional && is_optional_config_param(p) {
815                        if let TypeRef::Named(type_name) = &p.ty {
816                            return format!("${} ?? new {}()", p.name, type_name);
817                        }
818                    }
819                    format!("${}", p.name)
820                })
821                .collect::<Vec<_>>()
822                .join(", ");
823            let call_expr = format!("\\{namespace}\\{class_name}Api::{ext_method_name}({call_params})");
824            if is_void {
825                content.push_str(&crate::template_env::render(
826                    "php_method_call_statement.jinja",
827                    context! { call_expr => &call_expr },
828                ));
829            } else {
830                content.push_str(&crate::template_env::render(
831                    "php_method_call_return.jinja",
832                    context! { call_expr => &call_expr },
833                ));
834            }
835            content.push_str(&crate::template_env::render(
836                "php_method_end.jinja",
837                minijinja::Value::default(),
838            ));
839        }
840
841        content.push_str(&crate::template_env::render(
842            "php_class_end.jinja",
843            minijinja::Value::default(),
844        ));
845
846        // Use PHP stubs output path if configured, otherwise fall back to packages/php/src/.
847        // This is intentionally separate from config.output.php, which controls the Rust binding
848        // crate output directory (e.g., crates/kreuzcrawl-php/src/).
849        let output_dir = config
850            .php
851            .as_ref()
852            .and_then(|p| p.stubs.as_ref())
853            .map(|s| s.output.to_string_lossy().to_string())
854            .unwrap_or_else(|| "packages/php/src/".to_string());
855
856        Ok(vec![GeneratedFile {
857            path: PathBuf::from(&output_dir).join(format!("{}.php", class_name)),
858            content,
859            generated_header: false,
860        }])
861    }
862
863    fn generate_type_stubs(
864        &self,
865        api: &ApiSurface,
866        config: &ResolvedCrateConfig,
867    ) -> anyhow::Result<Vec<GeneratedFile>> {
868        let extension_name = config.php_extension_name();
869        let class_name = extension_name.to_pascal_case();
870
871        // Determine namespace — delegates to config so [php].namespace overrides are respected.
872        let namespace = php_autoload_namespace(config);
873
874        // PSR-12 requires a blank line after the opening `<?php` tag.
875        // php-cs-fixer enforces this and would insert it post-write,
876        // making `alef verify` see content that differs from what was
877        // freshly generated. Emit it here so generated == on-disk.
878        let mut content = String::new();
879        content.push_str(&crate::template_env::render(
880            "php_file_header.jinja",
881            minijinja::Value::default(),
882        ));
883        content.push_str(&hash::header(CommentStyle::DoubleSlash));
884        content.push_str("// Type stubs for the native PHP extension — declares classes\n");
885        content.push_str("// provided at runtime by the compiled Rust extension (.so/.dll).\n");
886        content.push_str("// Include this in phpstan.neon scanFiles for static analysis.\n\n");
887        content.push_str(&crate::template_env::render(
888            "php_declare_strict_types.jinja",
889            minijinja::Value::default(),
890        ));
891        // Use bracketed namespace syntax so we can add global-namespace function stubs later.
892        content.push_str(&crate::template_env::render(
893            "php_namespace_block_begin.jinja",
894            context! { namespace => &namespace },
895        ));
896
897        // Exception class
898        content.push_str(&crate::template_env::render(
899            "php_exception_class_declaration.jinja",
900            context! { class_name => &class_name },
901        ));
902        content.push_str(
903            "    public function getErrorCode(): int { throw new \\RuntimeException('Not implemented.'); }\n",
904        );
905        content.push_str("}\n\n");
906
907        // Opaque handle classes
908        for typ in api.types.iter().filter(|typ| !typ.is_trait) {
909            if typ.is_opaque {
910                if !typ.doc.is_empty() {
911                    content.push_str("/**\n");
912                    for line in typ.doc.lines() {
913                        if line.is_empty() {
914                            content.push_str(" *\n");
915                        } else {
916                            content.push_str(&crate::template_env::render(
917                                "php_phpdoc_doc_line.jinja",
918                                context! { line => line },
919                            ));
920                        }
921                    }
922                    content.push_str(" */\n");
923                }
924                content.push_str(&crate::template_env::render(
925                    "php_opaque_class_stub_declaration.jinja",
926                    context! { class_name => &typ.name },
927                ));
928                // Opaque handles have no public constructors in PHP
929                content.push_str("}\n\n");
930            }
931        }
932
933        // Record / struct types (non-opaque with fields)
934        for typ in api.types.iter().filter(|typ| !typ.is_trait) {
935            if typ.is_opaque || typ.fields.is_empty() {
936                continue;
937            }
938            if !typ.doc.is_empty() {
939                content.push_str("/**\n");
940                for line in typ.doc.lines() {
941                    if line.is_empty() {
942                        content.push_str(" *\n");
943                    } else {
944                        content.push_str(&crate::template_env::render(
945                            "php_phpdoc_doc_line.jinja",
946                            context! { line => line },
947                        ));
948                    }
949                }
950                content.push_str(" */\n");
951            }
952            content.push_str(&crate::template_env::render(
953                "php_record_class_stub_declaration.jinja",
954                context! { class_name => &typ.name },
955            ));
956
957            // Public property declarations (ext-php-rs exposes struct fields as properties)
958            for field in &typ.fields {
959                let is_array = matches!(&field.ty, TypeRef::Vec(_) | TypeRef::Map(_, _));
960                let prop_type = if field.optional {
961                    let inner = php_type(&field.ty);
962                    if inner.starts_with('?') {
963                        inner
964                    } else {
965                        format!("?{inner}")
966                    }
967                } else {
968                    php_type(&field.ty)
969                };
970                if is_array {
971                    let phpdoc = php_phpdoc_type(&field.ty);
972                    let nullable_prefix = if field.optional { "?" } else { "" };
973                    content.push_str(&crate::template_env::render(
974                        "php_property_type_annotation.jinja",
975                        context! {
976                            nullable_prefix => nullable_prefix,
977                            phpdoc => &phpdoc,
978                        },
979                    ));
980                }
981                content.push_str(&crate::template_env::render(
982                    "php_property_stub.jinja",
983                    context! {
984                        prop_type => &prop_type,
985                        field_name => &field.name,
986                    },
987                ));
988            }
989            content.push('\n');
990
991            // Constructor with typed parameters.
992            // PHP requires required parameters to come before optional ones, so sort
993            // the fields: required first, then optional (preserving relative order within each group).
994            let mut sorted_fields: Vec<&alef_core::ir::FieldDef> = typ.fields.iter().collect();
995            sorted_fields.sort_by_key(|f| f.optional);
996
997            // Emit PHPDoc before the constructor for any array-typed fields so PHPStan
998            // understands the generic element type (e.g. `@param array<string> $items`).
999            let array_fields: Vec<&alef_core::ir::FieldDef> = sorted_fields
1000                .iter()
1001                .copied()
1002                .filter(|f| matches!(&f.ty, TypeRef::Vec(_) | TypeRef::Map(_, _)))
1003                .collect();
1004            if !array_fields.is_empty() {
1005                content.push_str("    /**\n");
1006                for f in &array_fields {
1007                    let phpdoc = php_phpdoc_type(&f.ty);
1008                    let nullable_prefix = if f.optional { "?" } else { "" };
1009                    content.push_str(&crate::template_env::render(
1010                        "php_phpdoc_array_param.jinja",
1011                        context! {
1012                            nullable_prefix => nullable_prefix,
1013                            phpdoc => &phpdoc,
1014                            param_name => &f.name,
1015                        },
1016                    ));
1017                }
1018                content.push_str("     */\n");
1019            }
1020
1021            let params: Vec<String> = sorted_fields
1022                .iter()
1023                .map(|f| {
1024                    let ptype = php_type(&f.ty);
1025                    let nullable = if f.optional && !ptype.starts_with('?') {
1026                        format!("?{ptype}")
1027                    } else {
1028                        ptype
1029                    };
1030                    let default = if f.optional { " = null" } else { "" };
1031                    format!("        {} ${}{}", nullable, f.name, default)
1032                })
1033                .collect();
1034            content.push_str(&crate::template_env::render(
1035                "php_constructor_method.jinja",
1036                context! { params => &params.join(",\n") },
1037            ));
1038
1039            // Getter methods for each field
1040            for field in &typ.fields {
1041                let is_array = matches!(&field.ty, TypeRef::Vec(_) | TypeRef::Map(_, _));
1042                let return_type = if field.optional {
1043                    let inner = php_type(&field.ty);
1044                    if inner.starts_with('?') {
1045                        inner
1046                    } else {
1047                        format!("?{inner}")
1048                    }
1049                } else {
1050                    php_type(&field.ty)
1051                };
1052                let getter_name = field.name.to_lower_camel_case();
1053                // Emit PHPDoc for array return types so PHPStan knows the element type.
1054                if is_array {
1055                    let phpdoc = php_phpdoc_type(&field.ty);
1056                    let nullable_prefix = if field.optional { "?" } else { "" };
1057                    content.push_str(&crate::template_env::render(
1058                        "php_constructor_doc_return.jinja",
1059                        context! { return_type => &format!("{nullable_prefix}{phpdoc}") },
1060                    ));
1061                }
1062                let is_void_getter = return_type == "void";
1063                let getter_body = if is_void_getter {
1064                    "{ }".to_string()
1065                } else {
1066                    "{ throw new \\RuntimeException('Not implemented.'); }".to_string()
1067                };
1068                content.push_str(&crate::template_env::render(
1069                    "php_getter_stub.jinja",
1070                    context! {
1071                        getter_name => &format!("get{}", getter_name.to_pascal_case()),
1072                        return_type => &return_type,
1073                        getter_body => &getter_body,
1074                    },
1075                ));
1076            }
1077
1078            content.push_str("}\n\n");
1079        }
1080
1081        // Emit tagged data enums as classes (they're lowered to flat PHP classes in the binding).
1082        // Unit-variant enums → PHP 8.1+ enum constants.
1083        for enum_def in &api.enums {
1084            if is_tagged_data_enum(enum_def) {
1085                // Tagged data enums are lowered to flat classes; emit class stubs.
1086                if !enum_def.doc.is_empty() {
1087                    content.push_str("/**\n");
1088                    for line in enum_def.doc.lines() {
1089                        if line.is_empty() {
1090                            content.push_str(" *\n");
1091                        } else {
1092                            content.push_str(&crate::template_env::render(
1093                                "php_phpdoc_doc_line.jinja",
1094                                context! { line => line },
1095                            ));
1096                        }
1097                    }
1098                    content.push_str(" */\n");
1099                }
1100                content.push_str(&crate::template_env::render(
1101                    "php_record_class_stub_declaration.jinja",
1102                    context! { class_name => &enum_def.name },
1103                ));
1104                content.push_str("}\n\n");
1105            } else {
1106                // Unit-variant enums → PHP 8.1+ enum constants.
1107                content.push_str(&crate::template_env::render(
1108                    "php_tagged_enum_declaration.jinja",
1109                    context! { enum_name => &enum_def.name },
1110                ));
1111                for variant in &enum_def.variants {
1112                    let case_name = sanitize_php_enum_case(&variant.name);
1113                    content.push_str(&crate::template_env::render(
1114                        "php_enum_variant_stub.jinja",
1115                        context! {
1116                            variant_name => case_name,
1117                            value => &variant.name,
1118                        },
1119                    ));
1120                }
1121                content.push_str("}\n\n");
1122            }
1123        }
1124
1125        // Extension function stubs — generated as a native `{ClassName}Api` class with static
1126        // methods. The PHP facade (`{ClassName}`) delegates to `{ClassName}Api::method()`.
1127        // Using a class instead of global functions avoids the `inventory` crate registration
1128        // issue on macOS (cdylib builds do not collect `#[php_function]` entries there).
1129        if !api.functions.is_empty() {
1130            // Bridge params are hidden from the PHP-visible API in stubs too.
1131            let bridge_param_names_stubs: ahash::AHashSet<&str> = config
1132                .trait_bridges
1133                .iter()
1134                .filter_map(|b| b.param_name.as_deref())
1135                .collect();
1136
1137            content.push_str(&crate::template_env::render(
1138                "php_api_class_declaration.jinja",
1139                context! { class_name => &class_name },
1140            ));
1141            for func in &api.functions {
1142                let return_type = php_type_fq(&func.return_type, &namespace);
1143                let return_phpdoc = php_phpdoc_type_fq(&func.return_type, &namespace);
1144                // Visible params exclude bridge params.
1145                let visible_params: Vec<_> = func
1146                    .params
1147                    .iter()
1148                    .filter(|p| !bridge_param_names_stubs.contains(p.name.as_str()))
1149                    .collect();
1150                // Stubs declare the ACTUAL native interface, which has parameters in their original order
1151                // (ext-php-rs doesn't reorder them). DO NOT sort them here.
1152                // The PHP facade may reorder them for syntax compliance, but the stub must match
1153                // the actual native extension signature.
1154                // Emit PHPDoc when any param or the return type is an array, so PHPStan
1155                // understands generic element types (e.g. array<string> vs bare array).
1156                let has_array_params = visible_params
1157                    .iter()
1158                    .any(|p| matches!(&p.ty, TypeRef::Vec(_) | TypeRef::Map(_, _)));
1159                let has_array_return = matches!(&func.return_type, TypeRef::Vec(_) | TypeRef::Map(_, _))
1160                    || matches!(&func.return_type, TypeRef::Optional(inner) if matches!(inner.as_ref(), TypeRef::Vec(_) | TypeRef::Map(_, _)));
1161                if has_array_params || has_array_return {
1162                    content.push_str("    /**\n");
1163                    for p in &visible_params {
1164                        let ptype = php_phpdoc_type_fq(&p.ty, &namespace);
1165                        let nullable_prefix = if p.optional { "?" } else { "" };
1166                        content.push_str(&crate::template_env::render(
1167                            "php_phpdoc_static_param.jinja",
1168                            context! {
1169                                nullable_prefix => nullable_prefix,
1170                                ptype => &ptype,
1171                                param_name => &p.name,
1172                            },
1173                        ));
1174                    }
1175                    content.push_str(&crate::template_env::render(
1176                        "php_phpdoc_static_return.jinja",
1177                        context! { return_phpdoc => &return_phpdoc },
1178                    ));
1179                    content.push_str("     */\n");
1180                }
1181                let params: Vec<String> = visible_params
1182                    .iter()
1183                    .map(|p| {
1184                        let ptype = php_type_fq(&p.ty, &namespace);
1185                        if p.optional {
1186                            format!("?{} ${} = null", ptype, p.name)
1187                        } else {
1188                            format!("{} ${}", ptype, p.name)
1189                        }
1190                    })
1191                    .collect();
1192                // ext-php-rs auto-converts Rust snake_case to PHP camelCase.
1193                let stub_method_name = if func.is_async {
1194                    format!("{}_async", func.name).to_lower_camel_case()
1195                } else {
1196                    func.name.to_lower_camel_case()
1197                };
1198                let is_void_stub = return_type == "void";
1199                let stub_body = if is_void_stub {
1200                    "{ }".to_string()
1201                } else {
1202                    "{ throw new \\RuntimeException('Not implemented.'); }".to_string()
1203                };
1204                content.push_str(&crate::template_env::render(
1205                    "php_static_method_stub.jinja",
1206                    context! {
1207                        method_name => &stub_method_name,
1208                        params => &params.join(", "),
1209                        return_type => &return_type,
1210                        stub_body => &stub_body,
1211                    },
1212                ));
1213            }
1214            content.push_str("}\n\n");
1215        }
1216
1217        // Close the namespaced block
1218        content.push_str(&crate::template_env::render(
1219            "php_namespace_block_end.jinja",
1220            minijinja::Value::default(),
1221        ));
1222
1223        // Use stubs output path if configured, otherwise packages/php/stubs/
1224        let output_dir = config
1225            .php
1226            .as_ref()
1227            .and_then(|p| p.stubs.as_ref())
1228            .map(|s| s.output.to_string_lossy().to_string())
1229            .unwrap_or_else(|| "packages/php/stubs/".to_string());
1230
1231        Ok(vec![GeneratedFile {
1232            path: PathBuf::from(&output_dir).join(format!("{}_extension.php", extension_name)),
1233            content,
1234            generated_header: false,
1235        }])
1236    }
1237
1238    fn build_config(&self) -> Option<BuildConfig> {
1239        Some(BuildConfig {
1240            tool: "cargo",
1241            crate_suffix: "-php",
1242            build_dep: BuildDependency::None,
1243            post_build: vec![],
1244        })
1245    }
1246}
1247
1248/// Map an IR [`TypeRef`] to a PHPDoc type string with generic parameters (e.g., `array<string>`).
1249/// PHPStan at level `max` requires iterable value types in PHPDoc annotations.
1250fn php_phpdoc_type(ty: &TypeRef) -> String {
1251    match ty {
1252        TypeRef::Vec(inner) => format!("array<{}>", php_phpdoc_type(inner)),
1253        TypeRef::Map(k, v) => format!("array<{}, {}>", php_phpdoc_type(k), php_phpdoc_type(v)),
1254        TypeRef::Optional(inner) => format!("?{}", php_phpdoc_type(inner)),
1255        _ => php_type(ty),
1256    }
1257}
1258
1259/// Map an IR [`TypeRef`] to a fully-qualified PHPDoc type string with generics (e.g., `array<\Ns\T>`).
1260fn php_phpdoc_type_fq(ty: &TypeRef, namespace: &str) -> String {
1261    match ty {
1262        TypeRef::Vec(inner) => format!("array<{}>", php_phpdoc_type_fq(inner, namespace)),
1263        TypeRef::Map(k, v) => format!(
1264            "array<{}, {}>",
1265            php_phpdoc_type_fq(k, namespace),
1266            php_phpdoc_type_fq(v, namespace)
1267        ),
1268        TypeRef::Named(name) => format!("\\{}\\{}", namespace, name),
1269        TypeRef::Optional(inner) => format!("?{}", php_phpdoc_type_fq(inner, namespace)),
1270        _ => php_type(ty),
1271    }
1272}
1273
1274/// Map an IR [`TypeRef`] to a fully-qualified PHP type-hint string for use outside the namespace.
1275fn php_type_fq(ty: &TypeRef, namespace: &str) -> String {
1276    match ty {
1277        TypeRef::Named(name) => format!("\\{}\\{}", namespace, name),
1278        TypeRef::Optional(inner) => {
1279            let inner_type = php_type_fq(inner, namespace);
1280            if inner_type.starts_with('?') {
1281                inner_type
1282            } else {
1283                format!("?{inner_type}")
1284            }
1285        }
1286        _ => php_type(ty),
1287    }
1288}
1289
1290/// Map an IR [`TypeRef`] to a PHP type-hint string.
1291fn php_type(ty: &TypeRef) -> String {
1292    match ty {
1293        TypeRef::String | TypeRef::Char | TypeRef::Json | TypeRef::Bytes | TypeRef::Path => "string".to_string(),
1294        TypeRef::Primitive(p) => match p {
1295            PrimitiveType::Bool => "bool".to_string(),
1296            PrimitiveType::F32 | PrimitiveType::F64 => "float".to_string(),
1297            PrimitiveType::U8
1298            | PrimitiveType::U16
1299            | PrimitiveType::U32
1300            | PrimitiveType::U64
1301            | PrimitiveType::I8
1302            | PrimitiveType::I16
1303            | PrimitiveType::I32
1304            | PrimitiveType::I64
1305            | PrimitiveType::Usize
1306            | PrimitiveType::Isize => "int".to_string(),
1307        },
1308        TypeRef::Optional(inner) => {
1309            // Flatten nested Option<Option<T>> to a single nullable type.
1310            // PHP has no double-nullable concept; ?T already covers null.
1311            let inner_type = php_type(inner);
1312            if inner_type.starts_with('?') {
1313                inner_type
1314            } else {
1315                format!("?{inner_type}")
1316            }
1317        }
1318        TypeRef::Vec(_) | TypeRef::Map(_, _) => "array".to_string(),
1319        TypeRef::Named(name) => name.clone(),
1320        TypeRef::Unit => "void".to_string(),
1321        TypeRef::Duration => "float".to_string(),
1322    }
1323}