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