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