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