Skip to main content

alef_backend_php/gen_bindings/
mod.rs

1mod functions;
2mod helpers;
3pub mod types;
4
5use crate::type_map::PhpMapper;
6use ahash::AHashSet;
7use alef_codegen::builder::RustFileBuilder;
8use alef_codegen::conversions::ConversionConfig;
9use alef_codegen::doc_emission::{self, DocTarget, sanitize_rust_idioms};
10use alef_codegen::generators::RustBindingConfig;
11use alef_codegen::generators::{self, AsyncPattern};
12use alef_codegen::naming::to_php_name;
13use alef_codegen::shared::binding_fields;
14use alef_core::backend::{Backend, BuildConfig, BuildDependency, Capabilities, GeneratedFile};
15use alef_core::config::{Language, ResolvedCrateConfig, detect_serde_available, resolve_output_dir};
16use alef_core::hash::{self, CommentStyle};
17use alef_core::ir::ApiSurface;
18use alef_core::ir::{PrimitiveType, TypeRef};
19use heck::{ToLowerCamelCase, ToPascalCase};
20use minijinja::context;
21use std::collections::HashMap;
22use std::path::PathBuf;
23
24use crate::naming::php_autoload_namespace;
25use functions::{gen_async_function_as_static_method, gen_function_as_static_method};
26
27/// PHP 8.1 enum cases cannot use case-insensitive `class` (reserved for
28/// `EnumName::class` syntax). Append a trailing underscore for those cases.
29fn sanitize_php_enum_case(name: &str) -> String {
30    if name.eq_ignore_ascii_case("class") {
31        format!("{name}_")
32    } else {
33        name.to_string()
34    }
35}
36use helpers::{gen_enum_tainted_from_binding_to_core, gen_tokio_runtime, has_enum_named_field, references_named_type};
37use types::{
38    gen_enum_constants, gen_flat_data_enum, gen_flat_data_enum_from_impls, gen_flat_data_enum_methods, gen_php_struct,
39    is_tagged_data_enum, is_untagged_data_enum,
40};
41
42pub struct PhpBackend;
43
44impl PhpBackend {
45    fn binding_config(core_import: &str, has_serde: bool) -> RustBindingConfig<'_> {
46        RustBindingConfig {
47            struct_attrs: &["php_class"],
48            field_attrs: &[],
49            struct_derives: &["Clone"],
50            method_block_attr: Some("php_impl"),
51            constructor_attr: "",
52            static_attr: None,
53            function_attr: "#[php_function]",
54            enum_attrs: &[],
55            enum_derives: &[],
56            needs_signature: false,
57            signature_prefix: "",
58            signature_suffix: "",
59            core_import,
60            async_pattern: AsyncPattern::TokioBlockOn,
61            has_serde,
62            type_name_prefix: "",
63            option_duration_on_defaults: true,
64            opaque_type_names: &[],
65            skip_impl_constructor: false,
66            cast_uints_to_i32: false,
67            cast_large_ints_to_f64: false,
68            named_non_opaque_params_by_ref: false,
69            lossy_skip_types: &[],
70            serializable_opaque_type_names: &[],
71            never_skip_cfg_field_names: &[],
72        }
73    }
74}
75
76impl Backend for PhpBackend {
77    fn name(&self) -> &str {
78        "php"
79    }
80
81    fn language(&self) -> Language {
82        Language::Php
83    }
84
85    fn capabilities(&self) -> Capabilities {
86        Capabilities {
87            supports_async: false,
88            supports_classes: true,
89            supports_enums: true,
90            supports_option: true,
91            supports_result: true,
92            ..Capabilities::default()
93        }
94    }
95
96    fn generate_bindings(&self, api: &ApiSurface, config: &ResolvedCrateConfig) -> anyhow::Result<Vec<GeneratedFile>> {
97        // Separate unit-variant enums (→ String), tagged data enums (→ flat PHP class),
98        // and untagged data enums (→ serde_json::Value, converted via from_value at binding↔core boundary).
99        let data_enum_names: AHashSet<String> = api
100            .enums
101            .iter()
102            .filter(|e| is_tagged_data_enum(e))
103            .map(|e| e.name.clone())
104            .collect();
105        let untagged_data_enum_names: AHashSet<String> = api
106            .enums
107            .iter()
108            .filter(|e| is_untagged_data_enum(e))
109            .map(|e| e.name.clone())
110            .collect();
111        // String-mapped enums: everything that is NOT a tagged-data enum AND NOT an untagged-data enum.
112        // Includes unit-variant enums (FilePurpose, ToolType, …) which are exposed as PHP string constants.
113        let enum_names: AHashSet<String> = api
114            .enums
115            .iter()
116            .filter(|e| !is_tagged_data_enum(e) && !is_untagged_data_enum(e))
117            .map(|e| e.name.clone())
118            .collect();
119        let mapper = PhpMapper {
120            enum_names: enum_names.clone(),
121            data_enum_names: data_enum_names.clone(),
122            untagged_data_enum_names: untagged_data_enum_names.clone(),
123        };
124        let core_import = config.core_import_name();
125        let lang_rename_all = config.serde_rename_all_for_language(Language::Php);
126
127        // Get exclusion lists from PHP config
128        let php_config = config.php.as_ref();
129        let exclude_functions = php_config.map(|c| c.exclude_functions.clone()).unwrap_or_default();
130        let exclude_types = php_config.map(|c| c.exclude_types.clone()).unwrap_or_default();
131
132        let output_dir = resolve_output_dir(config.output_paths.get("php"), &config.name, "crates/{name}-php/src/");
133        let has_serde = detect_serde_available(&output_dir);
134
135        // Build the opaque type names list: IR opaque types + bridge type aliases.
136        // Bridge type aliases (e.g. `VisitorHandle`) wrap Rc-based handles and cannot
137        // implement serde::Serialize/Deserialize.  Including them ensures gen_php_struct
138        // emits #[serde(skip)] for fields of those types so derives on the enclosing
139        // struct (e.g. ConversionOptions) still compile.
140        let bridge_type_aliases_php: Vec<String> = config
141            .trait_bridges
142            .iter()
143            .filter_map(|b| b.type_alias.clone())
144            .collect();
145        let bridge_type_aliases_set: AHashSet<String> = bridge_type_aliases_php.iter().cloned().collect();
146        let mut opaque_names_vec_php: Vec<String> = api
147            .types
148            .iter()
149            .filter(|t| t.is_opaque)
150            .map(|t| t.name.clone())
151            .collect();
152        opaque_names_vec_php.extend(bridge_type_aliases_php);
153
154        let mut cfg = Self::binding_config(&core_import, has_serde);
155        cfg.opaque_type_names = &opaque_names_vec_php;
156        let never_skip_cfg_field_names: Vec<String> = config
157            .trait_bridges
158            .iter()
159            .filter_map(|b| {
160                if b.bind_via == alef_core::config::BridgeBinding::OptionsField {
161                    b.resolved_options_field().map(|s| s.to_string())
162                } else {
163                    None
164                }
165            })
166            .collect();
167        cfg.never_skip_cfg_field_names = &never_skip_cfg_field_names;
168
169        // Build the inner module content (types, methods, conversions)
170        let mut builder = RustFileBuilder::new().with_generated_header();
171        builder.add_inner_attribute("allow(dead_code, unused_imports, unused_variables)");
172        builder.add_inner_attribute("allow(unsafe_code)");
173        // PHP parameter names are lowerCamelCase; Rust complains about non-snake_case variables.
174        builder.add_inner_attribute("allow(non_snake_case)");
175        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, clippy::should_implement_trait, clippy::useless_conversion)");
176        builder.add_import("ext_php_rs::prelude::*");
177
178        // Import serde_json when available (needed for serde-based param conversion)
179        if has_serde {
180            builder.add_import("serde_json");
181        }
182
183        // Import traits needed for trait method dispatch
184        for trait_path in generators::collect_trait_imports(api) {
185            builder.add_import(&trait_path);
186        }
187
188        // Only import HashMap when Map-typed fields or returns are present
189        let has_maps = api.types.iter().any(|t| {
190            t.fields
191                .iter()
192                .any(|f| matches!(&f.ty, alef_core::ir::TypeRef::Map(_, _)))
193        }) || api
194            .functions
195            .iter()
196            .any(|f| matches!(&f.return_type, alef_core::ir::TypeRef::Map(_, _)));
197        if has_maps {
198            builder.add_import("std::collections::HashMap");
199        }
200
201        // PhpBytes wrapper: accepts PHP binary strings without UTF-8 validation.
202        // ext-php-rs's String FromZval rejects non-UTF-8 strings, so binary content
203        // (PDFs, images, etc.) gets "Invalid value given for argument" errors. This
204        // wrapper reads the raw bytes via `zend_str()` and exposes them as Vec<u8>.
205        builder.add_item(
206            "#[derive(Debug, Clone, Default)]\n\
207             pub struct PhpBytes(pub Vec<u8>);\n\
208             \n\
209             impl<'a> ext_php_rs::convert::FromZval<'a> for PhpBytes {\n    \
210                 const TYPE: ext_php_rs::flags::DataType = ext_php_rs::flags::DataType::String;\n    \
211                 fn from_zval(zval: &'a ext_php_rs::types::Zval) -> Option<Self> {\n        \
212                     zval.zend_str().map(|zs| PhpBytes(zs.as_bytes().to_vec()))\n    \
213                 }\n\
214             }\n\
215             \n\
216             impl From<PhpBytes> for Vec<u8> {\n    \
217                 fn from(b: PhpBytes) -> Self { b.0 }\n\
218             }\n\
219             \n\
220             impl From<Vec<u8>> for PhpBytes {\n    \
221                 fn from(v: Vec<u8>) -> Self { PhpBytes(v) }\n\
222             }\n",
223        );
224
225        // Custom module declarations
226        let custom_mods = config.custom_modules.for_language(Language::Php);
227        for module in custom_mods {
228            builder.add_item(&format!("pub mod {module};"));
229        }
230
231        // Check if any function or method is async
232        let has_async =
233            api.functions.iter().any(|f| f.is_async) || api.types.iter().any(|t| t.methods.iter().any(|m| m.is_async));
234
235        if has_async {
236            builder.add_item(&gen_tokio_runtime());
237        }
238
239        // Check if we have opaque types and add Arc import if needed
240        let opaque_types: AHashSet<String> = api
241            .types
242            .iter()
243            .filter(|t| t.is_opaque)
244            .map(|t| t.name.clone())
245            .collect();
246        if !opaque_types.is_empty() {
247            builder.add_import("std::sync::Arc");
248        }
249
250        // Compute mutex types: opaque types with &mut self methods
251        let mutex_types: AHashSet<String> = api
252            .types
253            .iter()
254            .filter(|t| t.is_opaque && alef_codegen::generators::type_needs_mutex(t))
255            .map(|t| t.name.clone())
256            .collect();
257        if !mutex_types.is_empty() {
258            builder.add_import("std::sync::Mutex");
259        }
260
261        // Compute the PHP namespace for namespaced class registration.
262        // Delegates to config so [php].namespace overrides are respected.
263        let extension_name = config.php_extension_name();
264        let php_namespace = php_autoload_namespace(config);
265
266        // Build adapter body map before type iteration so bodies are available for method generation.
267        let adapter_bodies = alef_adapters::build_adapter_bodies(config, Language::Php)?;
268
269        // Streaming-adapter method keys ("Owner.method_name") — these methods are emitted
270        // as a triple of standalone functions (start/next/free) from the adapter struct hook, so the
271        // regular method-iteration loop must skip them to avoid double-emitting a function
272        // with the same name.
273        let streaming_method_keys: AHashSet<String> = config
274            .adapters
275            .iter()
276            .filter(|a| matches!(a.pattern, alef_core::config::AdapterPattern::Streaming))
277            .filter_map(|a| a.owner_type.as_deref().map(|owner| format!("{owner}.{}", a.name)))
278            .collect();
279
280        // Emit adapter-generated standalone items (streaming iterators, callback bridges).
281        for adapter in &config.adapters {
282            match adapter.pattern {
283                alef_core::config::AdapterPattern::Streaming => {
284                    let key = alef_adapters::stream_struct_key(adapter);
285                    if let Some(struct_code) = adapter_bodies.get(&key) {
286                        builder.add_item(struct_code);
287                    }
288                }
289                alef_core::config::AdapterPattern::CallbackBridge => {
290                    let struct_key = format!("{}.__bridge_struct__", adapter.name);
291                    let impl_key = format!("{}.__bridge_impl__", adapter.name);
292                    if let Some(struct_code) = adapter_bodies.get(&struct_key) {
293                        builder.add_item(struct_code);
294                    }
295                    if let Some(impl_code) = adapter_bodies.get(&impl_key) {
296                        builder.add_item(impl_code);
297                    }
298                }
299                _ => {}
300            }
301        }
302
303        for typ in api
304            .types
305            .iter()
306            .filter(|typ| !typ.is_trait && !exclude_types.contains(&typ.name))
307        {
308            if typ.is_opaque {
309                // Generate the opaque struct with separate #[php_class] and
310                // #[php(name = "Ns\\Type")] attributes (ext-php-rs 0.15+ syntax).
311                // Escape '\' in the namespace so the generated Rust string literal is valid.
312                let ns_escaped = php_namespace.replace('\\', "\\\\");
313                let php_name_attr = format!("php(name = \"{}\\\\{}\")", ns_escaped, typ.name);
314                let opaque_attr_arr = ["php_class", php_name_attr.as_str()];
315                let opaque_cfg = RustBindingConfig {
316                    struct_attrs: &opaque_attr_arr,
317                    ..cfg
318                };
319                builder.add_item(&generators::gen_opaque_struct(typ, &opaque_cfg));
320                builder.add_item(&types::gen_opaque_struct_methods_with_exclude(
321                    typ,
322                    &mapper,
323                    &opaque_types,
324                    &core_import,
325                    &adapter_bodies,
326                    &mutex_types,
327                    &streaming_method_keys,
328                    &config.trait_bridges,
329                ));
330                // Client constructor — emit a #[php_method] impl
331                if let Some(ctor) = config.client_constructors.get(&typ.name) {
332                    let ctor_body = generators::gen_opaque_constructor(ctor, &typ.name, &core_import, "#[php_method]");
333                    let ctor_impl = format!("#[php_impl]\nimpl {} {{\n{}}}", typ.name, ctor_body);
334                    builder.add_item(&ctor_impl);
335                }
336            } else {
337                // gen_struct adds #[derive(Default)] when typ.has_default is true,
338                // so no separate Default impl is needed.
339                builder.add_item(&gen_php_struct(
340                    typ,
341                    &mapper,
342                    &cfg,
343                    Some(&php_namespace),
344                    &enum_names,
345                    &lang_rename_all,
346                ));
347                builder.add_item(&types::gen_struct_methods_with_exclude(
348                    typ,
349                    &mapper,
350                    has_serde,
351                    &core_import,
352                    &opaque_types,
353                    &enum_names,
354                    &api.enums,
355                    &exclude_functions,
356                    &bridge_type_aliases_set,
357                    &never_skip_cfg_field_names,
358                    &mutex_types,
359                ));
360            }
361        }
362
363        for enum_def in &api.enums {
364            if is_tagged_data_enum(enum_def) {
365                // Tagged data enums (struct variants) are lowered to a flat PHP class.
366                builder.add_item(&gen_flat_data_enum(enum_def, &mapper, Some(&php_namespace)));
367                builder.add_item(&gen_flat_data_enum_methods(enum_def, &mapper));
368            } else {
369                builder.add_item(&gen_enum_constants(enum_def));
370            }
371        }
372
373        // Generate free functions as static methods on a facade class rather than standalone
374        // `#[php_function]` items. Standalone functions rely on the `inventory` crate for
375        // auto-registration, which does not work in cdylib builds on macOS. Classes registered
376        // via `.class::<T>()` in the module builder DO work on all platforms.
377        let included_functions: Vec<_> = api
378            .functions
379            .iter()
380            .filter(|f| !exclude_functions.contains(&f.name))
381            .collect();
382        if !included_functions.is_empty() {
383            let facade_class_name = extension_name.to_pascal_case();
384            // Build each static method body (no #[php_function] attribute — they live inside
385            // a #[php_impl] block which handles registration via the class machinery).
386            let mut method_items: Vec<String> = Vec::new();
387            for func in included_functions {
388                if alef_codegen::generators::trait_bridge::is_trait_bridge_managed_fn(&func.name, &config.trait_bridges)
389                {
390                    continue;
391                }
392                let bridge_param = crate::trait_bridge::find_bridge_param(func, &config.trait_bridges);
393                if let Some((param_idx, bridge_cfg)) = bridge_param {
394                    let bridge_handle_path = bridge_handle_path(api, bridge_cfg, &core_import);
395                    method_items.push(crate::trait_bridge::gen_bridge_function(
396                        func,
397                        param_idx,
398                        bridge_cfg,
399                        &mapper,
400                        &opaque_types,
401                        &core_import,
402                        &bridge_handle_path,
403                    ));
404                } else if func.is_async {
405                    method_items.push(gen_async_function_as_static_method(
406                        func,
407                        &mapper,
408                        &opaque_types,
409                        &core_import,
410                        &config.trait_bridges,
411                        &mutex_types,
412                    ));
413                } else {
414                    method_items.push(gen_function_as_static_method(
415                        func,
416                        &mapper,
417                        &opaque_types,
418                        &core_import,
419                        &config.trait_bridges,
420                        has_serde,
421                        &mutex_types,
422                    ));
423                }
424            }
425
426            // Emit trait-bridge registration functions as static methods
427            for bridge_cfg in &config.trait_bridges {
428                if let Some(register_fn) = bridge_cfg.register_fn.as_deref() {
429                    method_items.push(format!(
430                        "pub fn {}(backend: &mut ext_php_rs::types::ZendObject) -> ext_php_rs::prelude::PhpResult<()> {{\n    \
431                        crate::{}(backend)\n}}",
432                        register_fn,
433                        register_fn
434                    ));
435                }
436                if let Some(unregister_fn) = bridge_cfg.unregister_fn.as_deref() {
437                    method_items.push(format!(
438                        "pub fn {}(name: String) -> ext_php_rs::prelude::PhpResult<()> {{\n    \
439                        crate::{}(name)\n}}",
440                        unregister_fn, unregister_fn
441                    ));
442                }
443                if let Some(clear_fn) = bridge_cfg.clear_fn.as_deref() {
444                    method_items.push(format!(
445                        "pub fn {}() -> ext_php_rs::prelude::PhpResult<()> {{\n    \
446                        crate::{}()\n}}",
447                        clear_fn, clear_fn
448                    ));
449                }
450            }
451
452            let methods_joined = method_items
453                .iter()
454                .map(|m| {
455                    // Indent each line of each method by 4 spaces
456                    m.lines()
457                        .map(|l| {
458                            if l.is_empty() {
459                                String::new()
460                            } else {
461                                format!("    {l}")
462                            }
463                        })
464                        .collect::<Vec<_>>()
465                        .join("\n")
466                })
467                .collect::<Vec<_>>()
468                .join("\n\n");
469            // The PHP-visible class name gets an "Api" suffix to avoid collision with the
470            // PHP facade class (e.g. `Kreuzcrawl\Kreuzcrawl`) that Composer autoloads.
471            let php_api_class_name = format!("{facade_class_name}Api");
472            // Escape '\' so the generated Rust string literal is valid (e.g. "Ns\\ClassName").
473            let ns_escaped_facade = php_namespace.replace('\\', "\\\\");
474            let php_name_attr = format!("php(name = \"{}\\\\{}\")", ns_escaped_facade, php_api_class_name);
475            let facade_struct = format!(
476                "#[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}}"
477            );
478            builder.add_item(&facade_struct);
479
480            // Trait bridge structs — top-level items (outside the facade class)
481            for bridge_cfg in &config.trait_bridges {
482                if let Some(trait_type) = api.types.iter().find(|t| t.is_trait && t.name == bridge_cfg.trait_name) {
483                    let bridge = crate::trait_bridge::gen_trait_bridge(
484                        trait_type,
485                        bridge_cfg,
486                        &core_import,
487                        &config.error_type_name(),
488                        &config.error_constructor_expr(),
489                        api,
490                    );
491                    for imp in &bridge.imports {
492                        builder.add_import(imp);
493                    }
494                    builder.add_item(&bridge.code);
495                }
496            }
497        }
498
499        let convertible = alef_codegen::conversions::convertible_types(api);
500        let core_to_binding = alef_codegen::conversions::core_to_binding_convertible_types(api);
501        let input_types = alef_codegen::conversions::input_type_names(api);
502        // From/Into conversions with PHP-specific i64 casts.
503        // Types with enum Named fields (or that reference such types transitively) can't
504        // have binding->core From impls because PHP maps enums to String and there's no
505        // From<String> for the core enum type. Core->binding is always safe.
506        let enum_names_ref = &mapper.enum_names;
507        let bridge_skip_types: Vec<String> = config
508            .trait_bridges
509            .iter()
510            .filter(|b| !matches!(b.bind_via, alef_core::config::BridgeBinding::OptionsField))
511            .filter_map(|b| b.type_alias.clone())
512            .collect();
513        // Trait-bridge fields whose binding-side wrapper holds `inner: Arc<core::T>`
514        // (every OptionsField-style bridge in alef follows this convention). Used by
515        // `binding_to_core` to emit `val.{f}.map(|v| (*v.inner).clone())` instead of
516        // `Default::default()` so the visitor handle survives the `.into()` call.
517        let trait_bridge_arc_wrapper_field_names: Vec<String> = config
518            .trait_bridges
519            .iter()
520            .filter(|b| b.bind_via == alef_core::config::BridgeBinding::OptionsField)
521            .filter_map(|b| b.resolved_options_field().map(String::from))
522            .collect();
523        // Set of opaque type names for ConversionConfig. Combines Rust `#[opaque]`
524        // types in the API with trait-bridge type aliases (e.g. VisitorHandle) so the
525        // `is_opaque_no_wrapper_field` branch in binding_to_core fires for those
526        // fields and emits the Arc-wrapper forwarding pattern.
527        let mut conv_opaque_types: AHashSet<String> = opaque_types.clone();
528        for bridge in &config.trait_bridges {
529            if let Some(alias) = &bridge.type_alias {
530                conv_opaque_types.insert(alias.clone());
531            }
532        }
533        let php_conv_config = ConversionConfig {
534            cast_large_ints_to_i64: true,
535            enum_string_names: Some(enum_names_ref),
536            untagged_data_enum_names: Some(&mapper.untagged_data_enum_names),
537            // PHP keeps `serde_json::Value` as-is in the binding struct (matches PhpMapper::json).
538            // `json_to_string` was previously enabled but caused `from_json` to fail when a JSON
539            // object/array landed in a `String`-typed field (e.g. tool `parameters` schema).
540            json_as_value: true,
541            include_cfg_metadata: false,
542            option_duration_on_defaults: true,
543            from_binding_skip_types: &bridge_skip_types,
544            never_skip_cfg_field_names: &never_skip_cfg_field_names,
545            opaque_types: Some(&conv_opaque_types),
546            trait_bridge_arc_wrapper_field_names: &trait_bridge_arc_wrapper_field_names,
547            ..Default::default()
548        };
549        // Build transitive set of types that can't have binding->core From
550        let mut enum_tainted: AHashSet<String> = AHashSet::new();
551        for typ in api.types.iter().filter(|typ| !typ.is_trait) {
552            if has_enum_named_field(typ, enum_names_ref) {
553                enum_tainted.insert(typ.name.clone());
554            }
555        }
556        // Transitively mark types that reference enum-tainted types
557        let mut changed = true;
558        while changed {
559            changed = false;
560            for typ in api.types.iter().filter(|typ| !typ.is_trait) {
561                if !enum_tainted.contains(&typ.name)
562                    && binding_fields(&typ.fields).any(|f| references_named_type(&f.ty, &enum_tainted))
563                {
564                    enum_tainted.insert(typ.name.clone());
565                    changed = true;
566                }
567            }
568        }
569        for typ in api.types.iter().filter(|typ| !typ.is_trait) {
570            // binding->core: only when not enum-tainted and type is used as input
571            if input_types.contains(&typ.name)
572                && !enum_tainted.contains(&typ.name)
573                && alef_codegen::conversions::can_generate_conversion(typ, &convertible)
574            {
575                builder.add_item(&alef_codegen::conversions::gen_from_binding_to_core_cfg(
576                    typ,
577                    &core_import,
578                    &php_conv_config,
579                ));
580            } else if input_types.contains(&typ.name) && enum_tainted.contains(&typ.name) {
581                // Enum-tainted types: generate From with string->enum parsing for enum-Named
582                // fields, using first variant as fallback. Data-variant enum fields fill
583                // data fields with Default::default().
584                // Note: JSON roundtrip was previously used when has_serde=true, but that
585                // breaks on non-optional Duration fields (null != u64) and empty-string enum
586                // fields ("" is not a valid variant). Field-by-field conversion handles both.
587                builder.add_item(&gen_enum_tainted_from_binding_to_core(
588                    typ,
589                    &core_import,
590                    enum_names_ref,
591                    &enum_tainted,
592                    &php_conv_config,
593                    &api.enums,
594                    &bridge_type_aliases_set,
595                ));
596            }
597            // core->binding: always (enum->String via format, sanitized fields via format)
598            if alef_codegen::conversions::can_generate_conversion(typ, &core_to_binding) {
599                builder.add_item(&alef_codegen::conversions::gen_from_core_to_binding_cfg(
600                    typ,
601                    &core_import,
602                    &opaque_types,
603                    &php_conv_config,
604                ));
605            }
606        }
607
608        // From impls for tagged data enums lowered to flat PHP classes.
609        // Track types whose `From<binding> for core` impl has already been emitted by
610        // the main loop above (or by a prior variant in this loop) to avoid duplicate
611        // impls when the same DTO appears both as a top-level input type and as a
612        // variant payload of a tagged enum (e.g. `CrawlPageResult` used directly and
613        // inside `CrawlEvent::Page { result: Box<CrawlPageResult> }`).
614        // The main loop above emits a `From<binding> for core` impl for any type
615        // that is `input_types.contains(&typ.name)` (either via the plain branch
616        // or the enum-tainted branch). Pre-seed the dedup set with those.
617        let mut emitted_binding_to_core: AHashSet<String> = api
618            .types
619            .iter()
620            .filter(|typ| !typ.is_trait && input_types.contains(&typ.name))
621            .filter(|typ| {
622                (enum_tainted.contains(&typ.name))
623                    || alef_codegen::conversions::can_generate_conversion(typ, &convertible)
624            })
625            .map(|typ| typ.name.clone())
626            .collect();
627        for enum_def in api.enums.iter().filter(|e| is_tagged_data_enum(e)) {
628            builder.add_item(&gen_flat_data_enum_from_impls(enum_def, &core_import));
629            // Also generate From impls for variant data types (e.g., ArchiveMetadata from FormatMetadata::Archive).
630            // These are needed when flat enum binding→core conversion calls `.into()` on variant fields.
631            for variant in &enum_def.variants {
632                for field in &variant.fields {
633                    if let TypeRef::Named(type_name) = &field.ty {
634                        if let Some(typ) = api.types.iter().find(|t| &t.name == type_name) {
635                            if emitted_binding_to_core.contains(&typ.name) {
636                                continue;
637                            }
638                            if enum_tainted.contains(&typ.name) {
639                                builder.add_item(&gen_enum_tainted_from_binding_to_core(
640                                    typ,
641                                    &core_import,
642                                    enum_names_ref,
643                                    &enum_tainted,
644                                    &php_conv_config,
645                                    &api.enums,
646                                    &bridge_type_aliases_set,
647                                ));
648                                emitted_binding_to_core.insert(typ.name.clone());
649                            } else if alef_codegen::conversions::can_generate_conversion(typ, &convertible) {
650                                builder.add_item(&alef_codegen::conversions::gen_from_binding_to_core_cfg(
651                                    typ,
652                                    &core_import,
653                                    &php_conv_config,
654                                ));
655                                emitted_binding_to_core.insert(typ.name.clone());
656                            }
657                        }
658                    }
659                }
660            }
661        }
662
663        // Emit From impls for all remaining DTO types that are convertible but haven't been
664        // emitted yet. This handles nested types that appear as fields in output structures
665        // but are not direct input types or enum variant payloads.
666        for typ in api.types.iter().filter(|t| !t.is_trait) {
667            if !emitted_binding_to_core.contains(&typ.name) {
668                if enum_tainted.contains(&typ.name) {
669                    builder.add_item(&gen_enum_tainted_from_binding_to_core(
670                        typ,
671                        &core_import,
672                        enum_names_ref,
673                        &enum_tainted,
674                        &php_conv_config,
675                        &api.enums,
676                        &bridge_type_aliases_set,
677                    ));
678                    emitted_binding_to_core.insert(typ.name.clone());
679                } else if alef_codegen::conversions::can_generate_conversion(typ, &convertible) {
680                    builder.add_item(&alef_codegen::conversions::gen_from_binding_to_core_cfg(
681                        typ,
682                        &core_import,
683                        &php_conv_config,
684                    ));
685                    emitted_binding_to_core.insert(typ.name.clone());
686                }
687            }
688        }
689
690        // Error converter functions + optional introspection method impl structs
691        for error in &api.errors {
692            builder.add_item(&alef_codegen::error_gen::gen_php_error_converter(error, &core_import));
693            // Emit #[php_class] + #[php_impl] block for errors with introspection methods.
694            let methods_impl = alef_codegen::error_gen::gen_php_error_methods_impl(error, &core_import);
695            if !methods_impl.is_empty() {
696                builder.add_item(&methods_impl);
697            }
698        }
699
700        // Serde default helpers for bool fields whose core default is `true`,
701        // and for SecurityLimits fields which use struct-level defaults.
702        // Referenced by #[serde(default = "crate::serde_defaults::...")] on struct fields.
703        if has_serde {
704            let serde_module = "mod serde_defaults {\n    pub fn bool_true() -> bool { true }\n\
705                   pub fn max_archive_size() -> i64 { 500 * 1024 * 1024 }\n\
706                   pub fn max_compression_ratio() -> i64 { 100 }\n\
707                   pub fn max_files_in_archive() -> i64 { 10_000 }\n\
708                   pub fn max_nesting_depth() -> i64 { 1024 }\n\
709                   pub fn max_entity_length() -> i64 { 1024 * 1024 }\n\
710                   pub fn max_content_size() -> i64 { 100 * 1024 * 1024 }\n\
711                   pub fn max_iterations() -> i64 { 10_000_000 }\n\
712                   pub fn max_xml_depth() -> i64 { 1024 }\n\
713                   pub fn max_table_cells() -> i64 { 100_000 }\n\
714                }";
715            builder.add_item(serde_module);
716        }
717
718        // Always enable abi_vectorcall on Windows — ext-php-rs requires the
719        // `vectorcall` calling convention for PHP entry points there. The feature
720        // is unstable on stable Rust; consumers either build with nightly or set
721        // RUSTC_BOOTSTRAP=1 (the upstream-recommended workaround). This cfg_attr
722        // is a no-op on non-windows so it costs nothing on Linux/macOS builds.
723        let php_config = config.php.as_ref();
724        builder.add_inner_attribute("cfg_attr(windows, feature(abi_vectorcall))");
725
726        // Optional feature gate — when [php].feature_gate is set, the entire crate
727        // is conditionally compiled. Use this for parity with PyO3's `extension-module`
728        // pattern; most PHP bindings don't need it.
729        if let Some(feature_name) = php_config.and_then(|c| c.feature_gate.as_deref()) {
730            builder.add_inner_attribute(&format!("cfg(feature = \"{feature_name}\")"));
731        }
732
733        // PHP module entry point — explicit class registration required because
734        // `inventory` crate auto-registration doesn't work in cdylib on macOS.
735        let mut class_registrations = String::new();
736        for typ in api
737            .types
738            .iter()
739            .filter(|typ| !typ.is_trait && !exclude_types.contains(&typ.name))
740        {
741            class_registrations.push_str(&crate::template_env::render(
742                "php_class_registration.jinja",
743                context! { class_name => &typ.name },
744            ));
745        }
746        // Register the facade class that wraps free functions as static methods.
747        if !api.functions.is_empty() {
748            let facade_class_name = extension_name.to_pascal_case();
749            class_registrations.push_str(&crate::template_env::render(
750                "php_class_registration.jinja",
751                context! { class_name => &format!("{facade_class_name}Api") },
752            ));
753        }
754        // Tagged data enums are lowered to flat PHP classes — register them like other classes.
755        // Unit-variant enums remain as string constants and don't need .class::<T>() registration.
756        for enum_def in api.enums.iter().filter(|e| is_tagged_data_enum(e)) {
757            class_registrations.push_str(&crate::template_env::render(
758                "php_class_registration.jinja",
759                context! { class_name => &enum_def.name },
760            ));
761        }
762        // Register error info classes for errors that expose introspection methods.
763        for error in api.errors.iter().filter(|e| !e.methods.is_empty()) {
764            let info_class = format!("{}Info", error.name);
765            class_registrations.push_str(&crate::template_env::render(
766                "php_class_registration.jinja",
767                context! { class_name => &info_class },
768            ));
769        }
770        builder.add_item(&format!(
771            "#[php_module]\npub fn get_module(module: ModuleBuilder) -> ModuleBuilder {{\n    module{class_registrations}\n}}"
772        ));
773
774        let mut content = builder.build();
775
776        // Post-process generated code to replace the bridge builder method.
777        // The generated code produces `visitor(Option<&VisitorHandle>)` which is
778        // unreachable from PHP. Replace the entire method — signature and body —
779        // with one that accepts a ZendObject and builds the proper bridge handle.
780        for bridge in &config.trait_bridges {
781            if let Some(field_name) = bridge.resolved_options_field() {
782                let param_name = bridge.param_name.as_deref().unwrap_or(field_name);
783                let type_alias = bridge.type_alias.as_deref().unwrap_or("VisitorHandle");
784                let options_type = bridge.options_type.as_deref().unwrap_or("ConversionOptions");
785                let builder_type = format!("{}Builder", options_type);
786                let bridge_struct = format!("Php{}Bridge", bridge.trait_name);
787                let bridge_handle_path = bridge_handle_path(api, bridge, &core_import);
788
789                // Match the verbatim pre-rustfmt output from codegen.
790                // gen_instance_method produces 4-space-indented lines (signature + body),
791                // then ImplBuilder.build() adds 4 more spaces to every line → 8/8/4 indent.
792                // The body is a single-line Self { inner: Arc::new(...) } expression.
793                // rustfmt later reformats this to the 4/8/8/4 multi-line style on disk.
794                let old_method = format!(
795                    "        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    }}"
796                );
797                let new_method = format!(
798                    "        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: {bridge_handle_path} = std::sync::Arc::new(std::sync::Mutex::new(bridge));\n        Self {{ inner: Arc::new((*self.inner).clone().{field_name}(Some(handle))) }}\n    }}"
799                );
800
801                content = content.replace(&old_method, &new_method);
802            }
803        }
804
805        // Generate PHP interface files for visitor-style bridges.
806        // Use PHP stubs output path if configured, otherwise fall back to packages/php/src/.
807        let php_stubs_dir = config
808            .php
809            .as_ref()
810            .and_then(|p| p.stubs.as_ref())
811            .map(|s| s.output.to_string_lossy().to_string())
812            .unwrap_or_else(|| "packages/php/src/".to_string());
813
814        let php_namespace = php_autoload_namespace(config);
815
816        let mut generated_files = vec![GeneratedFile {
817            path: PathBuf::from(&output_dir).join("lib.rs"),
818            content,
819            generated_header: false,
820        }];
821
822        // Emit PHP interface files for visitor bridges
823        for bridge_cfg in &config.trait_bridges {
824            if let Some(trait_type) = api.types.iter().find(|t| t.is_trait && t.name == bridge_cfg.trait_name) {
825                // Check if this is a visitor-style bridge (has type_alias, no register_fn, all methods have defaults)
826                let is_visitor_bridge = bridge_cfg.type_alias.is_some()
827                    && bridge_cfg.register_fn.is_none()
828                    && bridge_cfg.super_trait.is_none()
829                    && trait_type.methods.iter().all(|m| m.has_default_impl);
830
831                if is_visitor_bridge {
832                    let interface_content = crate::trait_bridge::gen_visitor_interface(
833                        trait_type,
834                        bridge_cfg,
835                        &php_namespace,
836                        &HashMap::new(), // type_paths not needed for the interface file itself
837                    );
838                    let interface_filename = format!("{}Interface.php", bridge_cfg.trait_name);
839                    generated_files.push(GeneratedFile {
840                        path: PathBuf::from(&php_stubs_dir).join(&interface_filename),
841                        content: interface_content,
842                        generated_header: false,
843                    });
844                }
845            }
846        }
847
848        Ok(generated_files)
849    }
850
851    fn generate_public_api(
852        &self,
853        api: &ApiSurface,
854        config: &ResolvedCrateConfig,
855    ) -> anyhow::Result<Vec<GeneratedFile>> {
856        // Helper: escape `*/` sequences that could close PHPDoc early
857        let escape_phpdoc_line = |s: &str| s.replace("*/", "* /");
858
859        let extension_name = config.php_extension_name();
860        let class_name = extension_name.to_pascal_case();
861
862        // Generate PHP wrapper class
863        let mut content = String::new();
864        content.push_str(&crate::template_env::render(
865            "php_file_header.jinja",
866            minijinja::Value::default(),
867        ));
868        content.push_str(&hash::header(CommentStyle::DoubleSlash));
869        content.push_str(&crate::template_env::render(
870            "php_declare_strict_types.jinja",
871            minijinja::Value::default(),
872        ));
873        // PSR-12: blank line between `declare(strict_types=1);` and `namespace`.
874        content.push('\n');
875
876        // Determine namespace — delegates to config so [php].namespace overrides are respected.
877        let namespace = php_autoload_namespace(config);
878
879        content.push_str(&crate::template_env::render(
880            "php_namespace.jinja",
881            context! { namespace => &namespace },
882        ));
883        // PSR-12: blank line between `namespace` and class declaration.
884        content.push('\n');
885        content.push_str(&crate::template_env::render(
886            "php_facade_class_declaration.jinja",
887            context! { class_name => &class_name },
888        ));
889
890        // Build the set of bridge param names so they are excluded from public PHP signatures.
891        let bridge_param_names_pub: ahash::AHashSet<&str> = config
892            .trait_bridges
893            .iter()
894            .filter_map(|b| b.param_name.as_deref())
895            .collect();
896
897        // Config types whose PHP constructors can be called with zero arguments.
898        // Only qualifies when ALL fields are optional (PHP constructor needs no required args).
899        // `has_default` (Rust Default impl) is NOT sufficient — the PHP constructor is
900        // generated from struct fields and still requires non-optional ones.
901        let no_arg_constructor_types: AHashSet<String> = api
902            .types
903            .iter()
904            .filter(|t| t.fields.iter().all(|f| f.optional))
905            .map(|t| t.name.clone())
906            .collect();
907
908        // Generate wrapper methods for functions
909        for func in &api.functions {
910            // Skip trait-bridge-managed names (clear_fn) — the trait-bridge loop below
911            // emits its own static method, and duplicating it here would cause a
912            // PHP fatal "Cannot redeclare" at load time.
913            if alef_codegen::generators::trait_bridge::is_trait_bridge_managed_fn(&func.name, &config.trait_bridges) {
914                continue;
915            }
916            // PHP method names are based on the Rust source name (camelCased).
917            // Async functions do not get a suffix because PHP blocks on async internally
918            // via `block_on`, presenting a synchronous API to callers.
919            // For example: `scrape` (async in Rust) → `scrape()` (sync from PHP perspective).
920            let method_name = func.name.to_lower_camel_case();
921            let return_php_type = php_type(&func.return_type);
922
923            // Visible params exclude bridge params (not surfaced to PHP callers).
924            let visible_params: Vec<_> = func
925                .params
926                .iter()
927                .filter(|p| !bridge_param_names_pub.contains(p.name.as_str()))
928                .collect();
929
930            // PHPDoc block: translate rustdoc sections to PHPDoc format, stripping Rust-specific syntax.
931            content.push_str(&crate::template_env::render(
932                "php_phpdoc_block_start.jinja",
933                minijinja::Value::default(),
934            ));
935            if func.doc.is_empty() {
936                content.push_str(&crate::template_env::render(
937                    "php_phpdoc_text_line.jinja",
938                    context! { text => &format!("{}.", method_name) },
939                ));
940            } else {
941                // Extract and render summary + major rustdoc sections, stripping Rust-specific syntax.
942                let sections = doc_emission::parse_rustdoc_sections(&func.doc);
943                // Emit summary
944                for line in sections.summary.lines() {
945                    content.push_str("     * ");
946                    content.push_str(&escape_phpdoc_line(line));
947                    content.push('\n');
948                }
949                // Skip Arguments, Returns, Errors, Example — they're emitted as @param/@return/@throws below.
950                // This prevents raw Rust syntax from leaking into the docstring.
951            }
952            content.push_str(&crate::template_env::render(
953                "php_phpdoc_empty_line.jinja",
954                minijinja::Value::default(),
955            ));
956            for p in &visible_params {
957                let ptype = php_phpdoc_type(&p.ty);
958                let nullable_prefix = if p.optional { "?" } else { "" };
959                content.push_str(&crate::template_env::render(
960                    "php_phpdoc_param_line.jinja",
961                    context! {
962                        nullable_prefix => nullable_prefix,
963                        param_type => &ptype,
964                        param_name => &p.name,
965                    },
966                ));
967            }
968            let return_phpdoc = php_phpdoc_type(&func.return_type);
969            content.push_str(&crate::template_env::render(
970                "php_phpdoc_return_line.jinja",
971                context! { return_type => &return_phpdoc },
972            ));
973            if func.error_type.is_some() {
974                content.push_str(&crate::template_env::render(
975                    "php_phpdoc_throws_line.jinja",
976                    context! {
977                        namespace => namespace.as_str(),
978                        class_name => &class_name,
979                    },
980                ));
981            }
982            content.push_str(&crate::template_env::render(
983                "php_phpdoc_block_end.jinja",
984                minijinja::Value::default(),
985            ));
986
987            // Method signature with type hints.
988            // Keep parameters in their original Rust order.
989            // Since PHP doesn't allow optional params before required ones, and some Rust
990            // functions have optional params in the middle, we must make all params after
991            // the first optional one also optional (nullable with null default).
992            // This ensures e2e generated test code (which uses Rust param order) will work.
993            // Additionally, config-like parameters (Named types ending in "Config") should
994            // be treated as optional for PHP even if not explicitly marked as such in the IR.
995            // Helper: a config param is only treated as optional when its type can be
996            // constructed with zero arguments (all fields are optional in the IR).
997            let is_optional_config_param = |p: &alef_core::ir::ParamDef| -> bool {
998                if let TypeRef::Named(name) = &p.ty {
999                    (name.ends_with("Config") || name.as_str() == "config")
1000                        && no_arg_constructor_types.contains(name.as_str())
1001                } else {
1002                    false
1003                }
1004            };
1005
1006            let mut first_optional_idx = None;
1007            for (idx, p) in visible_params.iter().enumerate() {
1008                if p.optional || is_optional_config_param(p) {
1009                    first_optional_idx = Some(idx);
1010                    break;
1011                }
1012            }
1013
1014            content.push_str(&crate::template_env::render(
1015                "php_method_signature_start.jinja",
1016                context! { method_name => &method_name },
1017            ));
1018
1019            let params: Vec<String> = visible_params
1020                .iter()
1021                .enumerate()
1022                .map(|(idx, p)| {
1023                    let ptype = php_type(&p.ty);
1024                    // Make param optional if:
1025                    // 1. It's explicitly optional OR
1026                    // 2. It's a config parameter with a no-arg constructor OR
1027                    // 3. It comes after the first optional/config param
1028                    let should_be_optional = p.optional
1029                        || is_optional_config_param(p)
1030                        || first_optional_idx.is_some_and(|first| idx >= first);
1031                    if should_be_optional {
1032                        format!("?{} ${} = null", ptype, p.name)
1033                    } else {
1034                        format!("{} ${}", ptype, p.name)
1035                    }
1036                })
1037                .collect();
1038            content.push_str(&params.join(", "));
1039            content.push_str(&crate::template_env::render(
1040                "php_method_signature_end.jinja",
1041                context! { return_type => &return_php_type },
1042            ));
1043            // Delegate to the native extension class (registered as `{namespace}\{class_name}Api`).
1044            // ext-php-rs auto-converts Rust snake_case to PHP camelCase.
1045            // PHP does not expose async — async behaviour is handled internally via Tokio
1046            // block_on, so the Rust function name matches the PHP method name exactly.
1047            let ext_method_name = func.name.to_lower_camel_case();
1048            let is_void = matches!(&func.return_type, TypeRef::Unit);
1049            // Pass parameters to the native function in their ORIGINAL order (not sorted).
1050            // The native extension expects parameters in the order defined in the Rust function.
1051            // The PHP facade reorders them only in its own signature for PHP syntax compliance,
1052            // but must pass them in the original order when calling the native method.
1053            // Config-type params that were made optional (nullable) in the facade must be
1054            // coerced to their default constructor when null, since the native ext requires
1055            // non-nullable objects.
1056            let call_params = visible_params
1057                .iter()
1058                .enumerate()
1059                .map(|(idx, p)| {
1060                    let should_be_optional = p.optional
1061                        || is_optional_config_param(p)
1062                        || first_optional_idx.is_some_and(|first| idx >= first);
1063                    if should_be_optional && is_optional_config_param(p) {
1064                        if let TypeRef::Named(type_name) = &p.ty {
1065                            return format!("${} ?? new {}()", p.name, type_name);
1066                        }
1067                    }
1068                    format!("${}", p.name)
1069                })
1070                .collect::<Vec<_>>()
1071                .join(", ");
1072            let call_expr = format!("\\{namespace}\\{class_name}Api::{ext_method_name}({call_params})");
1073            if is_void {
1074                content.push_str(&crate::template_env::render(
1075                    "php_method_call_statement.jinja",
1076                    context! { call_expr => &call_expr },
1077                ));
1078            } else {
1079                content.push_str(&crate::template_env::render(
1080                    "php_method_call_return.jinja",
1081                    context! { call_expr => &call_expr },
1082                ));
1083            }
1084            content.push_str(&crate::template_env::render(
1085                "php_method_end.jinja",
1086                minijinja::Value::default(),
1087            ));
1088        }
1089
1090        // Emit trait-bridge registration methods in the PHP facade
1091        for bridge_cfg in &config.trait_bridges {
1092            if let Some(register_fn) = bridge_cfg.register_fn.as_deref() {
1093                let method_name = register_fn.to_lower_camel_case();
1094                content.push_str(&crate::template_env::render(
1095                    "php_phpdoc_block_start.jinja",
1096                    minijinja::Value::default(),
1097                ));
1098                content.push_str(&crate::template_env::render(
1099                    "php_phpdoc_text_line.jinja",
1100                    context! { text => &format!("{}.", method_name) },
1101                ));
1102                content.push_str(&crate::template_env::render(
1103                    "php_phpdoc_empty_line.jinja",
1104                    minijinja::Value::default(),
1105                ));
1106                let interface_name = &bridge_cfg.trait_name;
1107                content.push_str(&crate::template_env::render(
1108                    "php_phpdoc_param_line.jinja",
1109                    context! {
1110                        nullable_prefix => "",
1111                        param_type => interface_name,
1112                        param_name => "backend",
1113                    },
1114                ));
1115                content.push_str(&crate::template_env::render(
1116                    "php_phpdoc_return_line.jinja",
1117                    context! { return_type => "void" },
1118                ));
1119                content.push_str(&crate::template_env::render(
1120                    "php_phpdoc_block_end.jinja",
1121                    minijinja::Value::default(),
1122                ));
1123                content.push_str(&crate::template_env::render(
1124                    "php_method_signature_start.jinja",
1125                    context! { method_name => &method_name },
1126                ));
1127                content.push_str(&format!("{} $backend = null) : void\n    {{\n", interface_name));
1128                let call_expr = format!("\\{namespace}\\{class_name}Api::{method_name}($backend)");
1129                content.push_str(&crate::template_env::render(
1130                    "php_method_call_statement.jinja",
1131                    context! { call_expr => &call_expr },
1132                ));
1133                content.push_str(&crate::template_env::render(
1134                    "php_method_end.jinja",
1135                    minijinja::Value::default(),
1136                ));
1137            }
1138            if let Some(unregister_fn) = bridge_cfg.unregister_fn.as_deref() {
1139                let method_name = unregister_fn.to_lower_camel_case();
1140                content.push_str(&crate::template_env::render(
1141                    "php_phpdoc_block_start.jinja",
1142                    minijinja::Value::default(),
1143                ));
1144                content.push_str(&crate::template_env::render(
1145                    "php_phpdoc_text_line.jinja",
1146                    context! { text => &format!("{}.", method_name) },
1147                ));
1148                content.push_str(&crate::template_env::render(
1149                    "php_phpdoc_empty_line.jinja",
1150                    minijinja::Value::default(),
1151                ));
1152                content.push_str(&crate::template_env::render(
1153                    "php_phpdoc_param_line.jinja",
1154                    context! {
1155                        nullable_prefix => "",
1156                        param_type => "string",
1157                        param_name => "name",
1158                    },
1159                ));
1160                content.push_str(&crate::template_env::render(
1161                    "php_phpdoc_return_line.jinja",
1162                    context! { return_type => "void" },
1163                ));
1164                content.push_str(&crate::template_env::render(
1165                    "php_phpdoc_block_end.jinja",
1166                    minijinja::Value::default(),
1167                ));
1168                content.push_str(&crate::template_env::render(
1169                    "php_method_signature_start.jinja",
1170                    context! { method_name => &method_name },
1171                ));
1172                content.push_str("string $name) : void\n    {\n");
1173                let call_expr = format!("\\{namespace}\\{class_name}Api::{method_name}($name)");
1174                content.push_str(&crate::template_env::render(
1175                    "php_method_call_statement.jinja",
1176                    context! { call_expr => &call_expr },
1177                ));
1178                content.push_str(&crate::template_env::render(
1179                    "php_method_end.jinja",
1180                    minijinja::Value::default(),
1181                ));
1182            }
1183            if let Some(clear_fn) = bridge_cfg.clear_fn.as_deref() {
1184                let method_name = clear_fn.to_lower_camel_case();
1185                content.push_str(&crate::template_env::render(
1186                    "php_phpdoc_block_start.jinja",
1187                    minijinja::Value::default(),
1188                ));
1189                content.push_str(&crate::template_env::render(
1190                    "php_phpdoc_text_line.jinja",
1191                    context! { text => &format!("{}.", method_name) },
1192                ));
1193                content.push_str(&crate::template_env::render(
1194                    "php_phpdoc_empty_line.jinja",
1195                    minijinja::Value::default(),
1196                ));
1197                content.push_str(&crate::template_env::render(
1198                    "php_phpdoc_return_line.jinja",
1199                    context! { return_type => "void" },
1200                ));
1201                content.push_str(&crate::template_env::render(
1202                    "php_phpdoc_block_end.jinja",
1203                    minijinja::Value::default(),
1204                ));
1205                content.push_str(&crate::template_env::render(
1206                    "php_method_signature_start.jinja",
1207                    context! { method_name => &method_name },
1208                ));
1209                content.push_str(") : void\n    {\n");
1210                let call_expr = format!("\\{namespace}\\{class_name}Api::{method_name}()");
1211                content.push_str(&crate::template_env::render(
1212                    "php_method_call_statement.jinja",
1213                    context! { call_expr => &call_expr },
1214                ));
1215                content.push_str(&crate::template_env::render(
1216                    "php_method_end.jinja",
1217                    minijinja::Value::default(),
1218                ));
1219            }
1220        }
1221
1222        content.push_str(&crate::template_env::render(
1223            "php_class_end.jinja",
1224            minijinja::Value::default(),
1225        ));
1226
1227        // Use PHP stubs output path if configured, otherwise fall back to packages/php/src/.
1228        // This is intentionally separate from config.output.php, which controls the Rust binding
1229        // crate output directory (e.g., crates/kreuzcrawl-php/src/).
1230        let output_dir = config
1231            .php
1232            .as_ref()
1233            .and_then(|p| p.stubs.as_ref())
1234            .map(|s| s.output.to_string_lossy().to_string())
1235            .unwrap_or_else(|| "packages/php/src/".to_string());
1236
1237        let mut files: Vec<GeneratedFile> = Vec::new();
1238        files.push(GeneratedFile {
1239            path: PathBuf::from(&output_dir).join(format!("{}.php", class_name)),
1240            content,
1241            generated_header: false,
1242        });
1243
1244        // Emit a per-opaque-type PHP class file alongside the facade. These provide
1245        // method declarations for static analysis (PHPStan) and IDE autocomplete.
1246        // The native PHP extension registers the same class names at module load
1247        // (before Composer autoload runs), so these userland files are never
1248        // included at runtime — the native class always wins.
1249        for typ in api.types.iter().filter(|t| t.is_opaque && !t.is_trait) {
1250            let streaming_adapters: Vec<&alef_core::config::AdapterConfig> = config
1251                .adapters
1252                .iter()
1253                .filter(|a| {
1254                    matches!(a.pattern, alef_core::config::AdapterPattern::Streaming)
1255                        && a.owner_type.as_deref() == Some(&typ.name)
1256                        && !a.skip_languages.iter().any(|l| l == "php")
1257                })
1258                .collect();
1259            let streaming_method_names: AHashSet<String> = streaming_adapters.iter().map(|a| a.name.clone()).collect();
1260            let opaque_file = gen_php_opaque_class_file(
1261                typ,
1262                &namespace,
1263                &streaming_adapters,
1264                &streaming_method_names,
1265                &config.trait_bridges,
1266            );
1267            files.push(GeneratedFile {
1268                path: PathBuf::from(&output_dir).join(format!("{}.php", typ.name)),
1269                content: opaque_file,
1270                generated_header: false,
1271            });
1272        }
1273
1274        Ok(files)
1275    }
1276
1277    fn generate_type_stubs(
1278        &self,
1279        api: &ApiSurface,
1280        config: &ResolvedCrateConfig,
1281    ) -> anyhow::Result<Vec<GeneratedFile>> {
1282        let extension_name = config.php_extension_name();
1283        let class_name = extension_name.to_pascal_case();
1284
1285        // Determine namespace — delegates to config so [php].namespace overrides are respected.
1286        let namespace = php_autoload_namespace(config);
1287
1288        // PSR-12 requires a blank line after the opening `<?php` tag.
1289        // php-cs-fixer enforces this and would insert it post-write,
1290        // making `alef verify` see content that differs from what was
1291        // freshly generated. Emit it here so generated == on-disk.
1292        let mut content = String::new();
1293        content.push_str(&crate::template_env::render(
1294            "php_file_header.jinja",
1295            minijinja::Value::default(),
1296        ));
1297        content.push_str(&hash::header(CommentStyle::DoubleSlash));
1298        content.push_str("// Type stubs for the native PHP extension — declares classes\n");
1299        content.push_str("// provided at runtime by the compiled Rust extension (.so/.dll).\n");
1300        content.push_str("// Include this in phpstan.neon scanFiles for static analysis.\n\n");
1301        content.push_str(&crate::template_env::render(
1302            "php_declare_strict_types.jinja",
1303            minijinja::Value::default(),
1304        ));
1305        // PSR-12: blank line between `declare(strict_types=1);` and `namespace`.
1306        content.push('\n');
1307        // Use bracketed namespace syntax so we can add global-namespace function stubs later.
1308        content.push_str(&crate::template_env::render(
1309            "php_namespace_block_begin.jinja",
1310            context! { namespace => &namespace },
1311        ));
1312
1313        // Exception class
1314        content.push_str(&crate::template_env::render(
1315            "php_exception_class_declaration.jinja",
1316            context! { class_name => &class_name },
1317        ));
1318        content.push_str(
1319            "    public function getErrorCode(): int { throw new \\RuntimeException('Not implemented.'); }\n",
1320        );
1321        // Emit introspection method stubs for errors that expose them.
1322        // These are backed by #[php_method] impls in the generated native extension.
1323        let has_status_code = api
1324            .errors
1325            .iter()
1326            .any(|e| e.methods.iter().any(|m| m.name == "status_code"));
1327        let has_is_transient = api
1328            .errors
1329            .iter()
1330            .any(|e| e.methods.iter().any(|m| m.name == "is_transient"));
1331        let has_error_type = api
1332            .errors
1333            .iter()
1334            .any(|e| e.methods.iter().any(|m| m.name == "error_type"));
1335        if has_status_code {
1336            content.push_str(
1337                "    /** HTTP status code for this error (0 means no associated status). */\n    \
1338                 public function statusCode(): int { throw new \\RuntimeException('Not implemented.'); }\n",
1339            );
1340        }
1341        if has_is_transient {
1342            content.push_str(
1343                "    /** Returns true if the error is transient and a retry may succeed. */\n    \
1344                 public function isTransient(): bool { throw new \\RuntimeException('Not implemented.'); }\n",
1345            );
1346        }
1347        if has_error_type {
1348            content.push_str(
1349                "    /** Machine-readable error category string for matching and logging. */\n    \
1350                 public function errorType(): string { throw new \\RuntimeException('Not implemented.'); }\n",
1351            );
1352        }
1353        content.push_str("}\n\n");
1354
1355        // Opaque handle classes are declared as per-type PHP files in
1356        // `packages/php/src/{TypeName}.php` (see `generate_public_api`). They
1357        // are intentionally omitted from this aggregate extension stub so PHPStan
1358        // does not see two class declarations for the same fully-qualified name.
1359
1360        // Record / struct types (non-opaque with fields)
1361        for typ in api.types.iter().filter(|typ| !typ.is_trait) {
1362            if typ.is_opaque || typ.fields.is_empty() {
1363                continue;
1364            }
1365            if !typ.doc.is_empty() {
1366                content.push_str("/**\n");
1367                let sanitized = sanitize_rust_idioms(&typ.doc, DocTarget::PhpDoc);
1368                content.push_str(&crate::template_env::render(
1369                    "php_phpdoc_lines.jinja",
1370                    context! {
1371                        doc_lines => sanitized.lines().collect::<Vec<_>>(),
1372                        indent => "",
1373                    },
1374                ));
1375                content.push_str(" */\n");
1376            }
1377            content.push_str(&crate::template_env::render(
1378                "php_record_class_stub_declaration.jinja",
1379                context! { class_name => &typ.name },
1380            ));
1381
1382            // PHP 8.3+ constructor property promotion with `public readonly`.
1383            // Required parameters come before optional ones (PHP syntax requirement).
1384            let mut sorted_fields: Vec<&alef_core::ir::FieldDef> = binding_fields(&typ.fields).collect();
1385            sorted_fields.sort_by_key(|f| f.optional);
1386
1387            // Promoted readonly parameters replace both separate property declarations
1388            // and redundant getter methods — direct property access is the PHP 8.3+ idiom.
1389            // Each promoted parameter gets an inline /** @var T [description] */ block so that
1390            // phpdoc-lint (phpstan level max) and IDEs see the precise generic type and field docs.
1391            let params: Vec<String> = sorted_fields
1392                .iter()
1393                .map(|f| {
1394                    let ptype = php_type(&f.ty);
1395                    let nullable = if f.optional && !ptype.starts_with('?') {
1396                        format!("?{ptype}")
1397                    } else {
1398                        ptype
1399                    };
1400                    let default = if f.optional { " = null" } else { "" };
1401                    let php_name = to_php_name(&f.name);
1402                    let phpdoc_type = php_phpdoc_type(&f.ty);
1403                    let var_type = if f.optional && !phpdoc_type.starts_with('?') {
1404                        format!("?{phpdoc_type}")
1405                    } else {
1406                        phpdoc_type
1407                    };
1408                    let phpdoc = php_property_phpdoc(&var_type, &f.doc, "        ");
1409                    format!("{phpdoc}        public readonly {nullable} ${php_name}{default}",)
1410                })
1411                .collect();
1412            content.push_str(&crate::template_env::render(
1413                "php_constructor_method.jinja",
1414                context! { params => &params.join(",\n") },
1415            ));
1416
1417            // Emit method stubs for impl methods declared on this DTO type.
1418            // PHPStan can only see methods that appear in the stub; without these,
1419            // static preset factories (e.g. `all()`, `minimal()`) and withers
1420            // (e.g. `withChunking()`) are flagged as "Call to undefined method".
1421            let non_excluded_methods: Vec<&alef_core::ir::MethodDef> = typ
1422                .methods
1423                .iter()
1424                .filter(|m| !m.binding_excluded && !m.sanitized)
1425                .collect();
1426            for method in non_excluded_methods {
1427                let method_name = method.name.to_lower_camel_case();
1428                let is_static = method.receiver.is_none();
1429                let return_type = php_type(&method.return_type);
1430                let first_optional_idx = method.params.iter().position(|p| p.optional);
1431                let params: Vec<String> = method
1432                    .params
1433                    .iter()
1434                    .enumerate()
1435                    .map(|(idx, p)| {
1436                        let ptype = php_type(&p.ty);
1437                        if p.optional || first_optional_idx.is_some_and(|first| idx >= first) {
1438                            let nullable = if ptype.starts_with('?') { "" } else { "?" };
1439                            format!("{nullable}{ptype} ${} = null", p.name)
1440                        } else {
1441                            format!("{} ${}", ptype, p.name)
1442                        }
1443                    })
1444                    .collect();
1445                let static_kw = if is_static { "static " } else { "" };
1446                let is_void = matches!(&method.return_type, TypeRef::Unit);
1447                let stub_body = if is_void {
1448                    "{ }".to_string()
1449                } else {
1450                    "{ throw new \\RuntimeException('Not implemented — provided by the native extension.'); }"
1451                        .to_string()
1452                };
1453                content.push_str(&format!(
1454                    "    public {static_kw}function {method_name}({}): {return_type}\n    {stub_body}\n",
1455                    params.join(", ")
1456                ));
1457            }
1458
1459            content.push_str("}\n\n");
1460        }
1461
1462        // Emit tagged data enums as classes (they're lowered to flat PHP classes in the binding).
1463        // Unit-variant enums → PHP 8.1+ enum constants.
1464        for enum_def in &api.enums {
1465            if is_tagged_data_enum(enum_def) {
1466                // Tagged data enums are lowered to flat classes; emit class stubs.
1467                if !enum_def.doc.is_empty() {
1468                    content.push_str("/**\n");
1469                    let sanitized = sanitize_rust_idioms(&enum_def.doc, DocTarget::PhpDoc);
1470                    content.push_str(&crate::template_env::render(
1471                        "php_phpdoc_lines.jinja",
1472                        context! {
1473                            doc_lines => sanitized.lines().collect::<Vec<_>>(),
1474                            indent => "",
1475                        },
1476                    ));
1477                    content.push_str(" */\n");
1478                }
1479                content.push_str(&crate::template_env::render(
1480                    "php_record_class_stub_declaration.jinja",
1481                    context! { class_name => &enum_def.name },
1482                ));
1483                content.push_str("}\n\n");
1484            } else {
1485                // Unit-variant enums → PHP 8.1+ enum constants.
1486                content.push_str(&crate::template_env::render(
1487                    "php_tagged_enum_declaration.jinja",
1488                    context! { enum_name => &enum_def.name },
1489                ));
1490                for variant in &enum_def.variants {
1491                    let case_name = sanitize_php_enum_case(&variant.name);
1492                    content.push_str(&crate::template_env::render(
1493                        "php_enum_variant_stub.jinja",
1494                        context! {
1495                            variant_name => case_name,
1496                            value => &variant.name,
1497                        },
1498                    ));
1499                }
1500                content.push_str("}\n\n");
1501            }
1502        }
1503
1504        // Extension function stubs — generated as a native `{ClassName}Api` class with static
1505        // methods. The PHP facade (`{ClassName}`) delegates to `{ClassName}Api::method()`.
1506        // Using a class instead of global functions avoids the `inventory` crate registration
1507        // issue on macOS (cdylib builds do not collect `#[php_function]` entries there).
1508        if !api.functions.is_empty() {
1509            // Bridge params are hidden from the PHP-visible API in stubs too.
1510            let bridge_param_names_stubs: ahash::AHashSet<&str> = config
1511                .trait_bridges
1512                .iter()
1513                .filter_map(|b| b.param_name.as_deref())
1514                .collect();
1515
1516            content.push_str(&crate::template_env::render(
1517                "php_api_class_declaration.jinja",
1518                context! { class_name => &class_name },
1519            ));
1520            for func in &api.functions {
1521                let return_type = php_type_fq(&func.return_type, &namespace);
1522                let return_phpdoc = php_phpdoc_type_fq(&func.return_type, &namespace);
1523                // Visible params exclude bridge params.
1524                let visible_params: Vec<_> = func
1525                    .params
1526                    .iter()
1527                    .filter(|p| !bridge_param_names_stubs.contains(p.name.as_str()))
1528                    .collect();
1529                // Stubs declare the ACTUAL native interface, which has parameters in their original order
1530                // (ext-php-rs doesn't reorder them). DO NOT sort them here.
1531                // The PHP facade may reorder them for syntax compliance, but the stub must match
1532                // the actual native extension signature.
1533                // Emit PHPDoc when any param or the return type is an array, so PHPStan
1534                // understands generic element types (e.g. array<string> vs bare array).
1535                let has_array_params = visible_params
1536                    .iter()
1537                    .any(|p| matches!(&p.ty, TypeRef::Vec(_) | TypeRef::Map(_, _)));
1538                let has_array_return = matches!(&func.return_type, TypeRef::Vec(_) | TypeRef::Map(_, _))
1539                    || matches!(&func.return_type, TypeRef::Optional(inner) if matches!(inner.as_ref(), TypeRef::Vec(_) | TypeRef::Map(_, _)));
1540                let first_optional_idx = visible_params.iter().position(|p| p.optional);
1541                if has_array_params || has_array_return {
1542                    content.push_str("    /**\n");
1543                    for (idx, p) in visible_params.iter().enumerate() {
1544                        let ptype = php_phpdoc_type_fq(&p.ty, &namespace);
1545                        let nullable_prefix = if p.optional || first_optional_idx.is_some_and(|first| idx >= first) {
1546                            "?"
1547                        } else {
1548                            ""
1549                        };
1550                        content.push_str(&crate::template_env::render(
1551                            "php_phpdoc_static_param.jinja",
1552                            context! {
1553                                nullable_prefix => nullable_prefix,
1554                                ptype => &ptype,
1555                                param_name => &p.name,
1556                            },
1557                        ));
1558                    }
1559                    content.push_str(&crate::template_env::render(
1560                        "php_phpdoc_static_return.jinja",
1561                        context! { return_phpdoc => &return_phpdoc },
1562                    ));
1563                    content.push_str("     */\n");
1564                }
1565                let params: Vec<String> = visible_params
1566                    .iter()
1567                    .enumerate()
1568                    .map(|(idx, p)| {
1569                        let ptype = php_type_fq(&p.ty, &namespace);
1570                        if p.optional || first_optional_idx.is_some_and(|first| idx >= first) {
1571                            let nullable_ptype = if ptype.starts_with('?') {
1572                                ptype
1573                            } else {
1574                                format!("?{ptype}")
1575                            };
1576                            format!("{} ${} = null", nullable_ptype, p.name)
1577                        } else {
1578                            format!("{} ${}", ptype, p.name)
1579                        }
1580                    })
1581                    .collect();
1582                // ext-php-rs auto-converts Rust snake_case to PHP camelCase.
1583                // PHP does not expose async — async behaviour is handled internally via
1584                // Tokio block_on, so the stub method name matches the Rust function name.
1585                let stub_method_name = func.name.to_lower_camel_case();
1586                let is_void_stub = return_type == "void";
1587                let stub_body = if is_void_stub {
1588                    "{ }".to_string()
1589                } else {
1590                    "{ throw new \\RuntimeException('Not implemented.'); }".to_string()
1591                };
1592                content.push_str(&crate::template_env::render(
1593                    "php_static_method_stub.jinja",
1594                    context! {
1595                        method_name => &stub_method_name,
1596                        params => &params.join(", "),
1597                        return_type => &return_type,
1598                        stub_body => &stub_body,
1599                    },
1600                ));
1601            }
1602            content.push_str("}\n\n");
1603        }
1604
1605        // Close the namespaced block
1606        content.push_str(&crate::template_env::render(
1607            "php_namespace_block_end.jinja",
1608            minijinja::Value::default(),
1609        ));
1610
1611        // Use stubs output path if configured, otherwise packages/php/stubs/
1612        let output_dir = config
1613            .php
1614            .as_ref()
1615            .and_then(|p| p.stubs.as_ref())
1616            .map(|s| s.output.to_string_lossy().to_string())
1617            .unwrap_or_else(|| "packages/php/stubs/".to_string());
1618
1619        Ok(vec![GeneratedFile {
1620            path: PathBuf::from(&output_dir).join(format!("{}_extension.php", extension_name)),
1621            content,
1622            generated_header: false,
1623        }])
1624    }
1625
1626    fn build_config(&self) -> Option<BuildConfig> {
1627        Some(BuildConfig {
1628            tool: "cargo",
1629            crate_suffix: "-php",
1630            build_dep: BuildDependency::None,
1631            post_build: vec![],
1632        })
1633    }
1634}
1635
1636fn bridge_handle_path(api: &ApiSurface, bridge: &alef_core::config::TraitBridgeConfig, core_import: &str) -> String {
1637    let alias = bridge.type_alias.as_deref().unwrap_or("VisitorHandle");
1638    api.types
1639        .iter()
1640        .find(|t| t.name == alias && !t.rust_path.is_empty())
1641        .map(|t| t.rust_path.replace('-', "_"))
1642        .or_else(|| api.excluded_type_paths.get(alias).map(|path| path.replace('-', "_")))
1643        .unwrap_or_else(|| format!("{core_import}::visitor::{alias}"))
1644}
1645
1646/// Map an IR [`TypeRef`] to a PHPDoc type string with generic parameters (e.g., `array<string>`).
1647/// PHPStan at level `max` requires iterable value types in PHPDoc annotations.
1648fn php_phpdoc_type(ty: &TypeRef) -> String {
1649    match ty {
1650        TypeRef::Vec(inner) => format!("array<{}>", php_phpdoc_type(inner)),
1651        TypeRef::Map(k, v) => format!("array<{}, {}>", php_phpdoc_type(k), php_phpdoc_type(v)),
1652        TypeRef::Optional(inner) => format!("?{}", php_phpdoc_type(inner)),
1653        _ => php_type(ty),
1654    }
1655}
1656
1657/// Map an IR [`TypeRef`] to a fully-qualified PHPDoc type string with generics (e.g., `array<\Ns\T>`).
1658fn php_phpdoc_type_fq(ty: &TypeRef, namespace: &str) -> String {
1659    match ty {
1660        TypeRef::Vec(inner) => format!("array<{}>", php_phpdoc_type_fq(inner, namespace)),
1661        TypeRef::Map(k, v) => format!(
1662            "array<{}, {}>",
1663            php_phpdoc_type_fq(k, namespace),
1664            php_phpdoc_type_fq(v, namespace)
1665        ),
1666        TypeRef::Named(name) => format!("\\{}\\{}", namespace, name),
1667        TypeRef::Optional(inner) => format!("?{}", php_phpdoc_type_fq(inner, namespace)),
1668        _ => php_type(ty),
1669    }
1670}
1671
1672/// Map an IR [`TypeRef`] to a fully-qualified PHP type-hint string for use outside the namespace.
1673fn php_type_fq(ty: &TypeRef, namespace: &str) -> String {
1674    match ty {
1675        TypeRef::Named(name) => format!("\\{}\\{}", namespace, name),
1676        TypeRef::Optional(inner) => {
1677            let inner_type = php_type_fq(inner, namespace);
1678            if inner_type.starts_with('?') {
1679                inner_type
1680            } else {
1681                format!("?{inner_type}")
1682            }
1683        }
1684        _ => php_type(ty),
1685    }
1686}
1687
1688/// Generate a per-opaque-type PHP class file for `packages/php/src/{TypeName}.php`.
1689///
1690/// The native ext-php-rs extension registers the same class at module load time
1691/// (before Composer autoload runs), so this userland file is never included at
1692/// runtime — the native class always wins. The file is consumed by PHPStan and
1693/// IDEs as the authoritative declaration of the type's public API surface.
1694fn gen_php_opaque_class_file(
1695    typ: &alef_core::ir::TypeDef,
1696    namespace: &str,
1697    streaming_adapters: &[&alef_core::config::AdapterConfig],
1698    streaming_method_names: &AHashSet<String>,
1699    trait_bridges: &[alef_core::config::TraitBridgeConfig],
1700) -> String {
1701    let mut content = String::new();
1702    content.push_str(&crate::template_env::render(
1703        "php_file_header.jinja",
1704        minijinja::Value::default(),
1705    ));
1706    content.push_str(&hash::header(CommentStyle::DoubleSlash));
1707    content.push_str(&crate::template_env::render(
1708        "php_declare_strict_types.jinja",
1709        minijinja::Value::default(),
1710    ));
1711    // PSR-12: blank line between `declare(strict_types=1);` and `namespace`.
1712    content.push('\n');
1713    content.push_str(&crate::template_env::render(
1714        "php_namespace.jinja",
1715        context! { namespace => namespace },
1716    ));
1717    // PSR-12: blank line between `namespace` and class declaration.
1718    content.push('\n');
1719
1720    // Type-level docblock.
1721    if !typ.doc.is_empty() {
1722        content.push_str("/**\n");
1723        let sanitized = sanitize_rust_idioms(&typ.doc, DocTarget::PhpDoc);
1724        content.push_str(&crate::template_env::render(
1725            "php_phpdoc_lines.jinja",
1726            context! {
1727                doc_lines => sanitized.lines().collect::<Vec<_>>(),
1728                indent => "",
1729            },
1730        ));
1731        content.push_str(" */\n");
1732    }
1733
1734    content.push_str(&format!("final class {}\n{{\n", typ.name));
1735
1736    // Instance methods first, static methods second — skip streaming methods
1737    // (they'll be emitted as Generator wrappers after regular methods).
1738    let mut method_order: Vec<&alef_core::ir::MethodDef> = Vec::new();
1739    method_order.extend(
1740        typ.methods
1741            .iter()
1742            .filter(|m| m.receiver.is_some() && !streaming_method_names.contains(&m.name)),
1743    );
1744    method_order.extend(
1745        typ.methods
1746            .iter()
1747            .filter(|m| m.receiver.is_none() && !streaming_method_names.contains(&m.name)),
1748    );
1749
1750    for method in method_order {
1751        let method_name = method.name.to_lower_camel_case();
1752        let return_type = php_type(&method.return_type);
1753        let is_void = matches!(&method.return_type, TypeRef::Unit);
1754        let is_static = method.receiver.is_none();
1755
1756        // PHPDoc block — keep it short to avoid line-width issues.
1757        let mut doc_lines: Vec<String> = vec![];
1758        let sanitized = sanitize_rust_idioms(&method.doc, DocTarget::PhpDoc);
1759        let doc_line = sanitized.lines().next().unwrap_or("").trim();
1760        if !doc_line.is_empty() {
1761            doc_lines.push(doc_line.to_string());
1762        }
1763
1764        // Add @param PHPDoc for array parameters so PHPStan knows the element type
1765        let mut phpdoc_params: Vec<String> = vec![];
1766        for param in &method.params {
1767            if matches!(&param.ty, TypeRef::Vec(_) | TypeRef::Map(_, _)) {
1768                let phpdoc_type = php_phpdoc_type(&param.ty);
1769                phpdoc_params.push(format!("@param {} ${}", phpdoc_type, param.name));
1770            }
1771        }
1772        doc_lines.extend(phpdoc_params);
1773
1774        // Add @return PHPDoc for array types so PHPStan knows the element type
1775        let needs_return_phpdoc = matches!(&method.return_type, TypeRef::Vec(_) | TypeRef::Map(_, _));
1776        if needs_return_phpdoc {
1777            let phpdoc_type = php_phpdoc_type(&method.return_type);
1778            doc_lines.push(format!("@return {phpdoc_type}"));
1779        }
1780
1781        // Emit PHPDoc if needed
1782        if !doc_lines.is_empty() {
1783            content.push_str("    /**\n");
1784            for line in doc_lines {
1785                content.push_str(&format!("     * {}\n", line));
1786            }
1787            content.push_str("     */\n");
1788        }
1789
1790        // Method signature.
1791        let static_kw = if is_static { "static " } else { "" };
1792        let first_optional_idx = method.params.iter().position(|p| p.optional);
1793        let params: Vec<String> = method
1794            .params
1795            .iter()
1796            .enumerate()
1797            .map(|(idx, p)| {
1798                let ptype = php_type(&p.ty);
1799                if p.optional || first_optional_idx.is_some_and(|first| idx >= first) {
1800                    let nullable = if ptype.starts_with('?') { "" } else { "?" };
1801                    format!("{nullable}{ptype} ${} = null", p.name)
1802                } else {
1803                    format!("{} ${}", ptype, p.name)
1804                }
1805            })
1806            .collect();
1807        content.push_str(&format!(
1808            "    public {static_kw}function {method_name}({}): {return_type}\n",
1809            params.join(", ")
1810        ));
1811        let body = if is_void {
1812            "    {\n    }\n"
1813        } else {
1814            "    {\n        throw new \\RuntimeException('Not implemented — provided by the native extension.');\n    }\n"
1815        };
1816        content.push_str(body);
1817    }
1818
1819    // Streaming wrapper methods: convert _start/_next/_free Rust functions to PHP Generators.
1820    for adapter in streaming_adapters {
1821        let item_type = adapter.item_type.as_deref().unwrap_or("array");
1822        content.push_str(&gen_php_streaming_method_wrapper(adapter, item_type));
1823        content.push('\n');
1824    }
1825
1826    // Check if this type is a trait bridge type alias (e.g., VisitorHandle)
1827    for bridge in trait_bridges {
1828        if let Some(ref type_alias) = bridge.type_alias {
1829            if type_alias == &typ.name {
1830                // Emit the from_php_object static method for trait bridge handles
1831                content.push_str("    /**\n");
1832                content
1833                    .push_str("     * Wrap a PHP object implementing the visitor interface as a shareable handle.\n");
1834                content.push_str("     */\n");
1835                content.push_str("    public static function from_php_object(object $visitor): self\n");
1836                content.push_str("    {\n");
1837                content.push_str(
1838                    "        throw new \\RuntimeException('Not implemented — provided by the native extension.');\n",
1839                );
1840                content.push_str("    }\n");
1841            }
1842        }
1843    }
1844
1845    content.push_str("}\n");
1846    content
1847}
1848
1849/// Generate a PHP streaming method wrapper for an adapter.
1850///
1851/// For PHP, we generate a Generator method that calls the Rust streaming methods directly.
1852/// Since PHP can't easily pass opaque types as function parameters, we skip the _start/_next/_free
1853/// pattern and instead keep the streaming logic on the class.
1854fn gen_php_streaming_method_wrapper(adapter: &alef_core::config::AdapterConfig, _item_type: &str) -> String {
1855    let method_name = adapter.name.to_lower_camel_case();
1856
1857    // Build parameter list.
1858    let mut params_vec: Vec<String> = Vec::new();
1859
1860    for p in &adapter.params {
1861        let ptype = php_type(&alef_core::ir::TypeRef::Named(p.ty.clone()));
1862        let nullable = if p.optional { "?" } else { "" };
1863        let default = if p.optional { " = null" } else { "" };
1864        params_vec.push(format!("{nullable}{ptype} ${}{default}", p.name));
1865    }
1866
1867    let params_sig = params_vec.join(", ");
1868
1869    // Generate a stub method that indicates it's provided by the native extension.
1870    // The actual streaming implementation is on the Rust side; this PHP method
1871    // is a placeholder for IDE/PHPStan. At runtime, the native extension
1872    // provides the actual Generator-yielding implementation.
1873    format!(
1874        "    public function {method_name}({params_sig}): \\Generator\n    {{\n        \
1875         throw new \\RuntimeException('Not implemented — provided by the native extension.');\n    \
1876         }}\n",
1877        method_name = method_name,
1878    )
1879}
1880
1881/// Map an IR [`TypeRef`] to a PHP type-hint string.
1882fn php_type(ty: &TypeRef) -> String {
1883    match ty {
1884        TypeRef::String | TypeRef::Char | TypeRef::Json | TypeRef::Bytes | TypeRef::Path => "string".to_string(),
1885        TypeRef::Primitive(p) => match p {
1886            PrimitiveType::Bool => "bool".to_string(),
1887            PrimitiveType::F32 | PrimitiveType::F64 => "float".to_string(),
1888            PrimitiveType::U8
1889            | PrimitiveType::U16
1890            | PrimitiveType::U32
1891            | PrimitiveType::U64
1892            | PrimitiveType::I8
1893            | PrimitiveType::I16
1894            | PrimitiveType::I32
1895            | PrimitiveType::I64
1896            | PrimitiveType::Usize
1897            | PrimitiveType::Isize => "int".to_string(),
1898        },
1899        TypeRef::Optional(inner) => {
1900            // Flatten nested Option<Option<T>> to a single nullable type.
1901            // PHP has no double-nullable concept; ?T already covers null.
1902            let inner_type = php_type(inner);
1903            if inner_type.starts_with('?') {
1904                inner_type
1905            } else {
1906                format!("?{inner_type}")
1907            }
1908        }
1909        TypeRef::Vec(_) | TypeRef::Map(_, _) => "array".to_string(),
1910        TypeRef::Named(name) => name.clone(),
1911        TypeRef::Unit => "void".to_string(),
1912        TypeRef::Duration => "float".to_string(),
1913    }
1914}
1915
1916/// Build an inline PHPDoc block for a class property or constructor-promoted parameter.
1917///
1918/// - When `doc` is non-empty and multi-line, emits a multi-line block with description lines
1919///   followed by an `@var` tag.
1920/// - When `doc` is non-empty and single-line, emits a compact `/** @var T Description. */` form.
1921/// - When `doc` is empty, emits the type-only compact form `/** @var T */`.
1922///
1923/// `indent` is prepended to every line of the output (typically 4 or 8 spaces).
1924fn php_property_phpdoc(var_type: &str, doc: &str, indent: &str) -> String {
1925    let doc = doc.trim();
1926    if doc.is_empty() {
1927        return format!("{indent}/** @var {var_type} */\n");
1928    }
1929    let lines: Vec<&str> = doc.lines().collect();
1930    if lines.len() == 1 {
1931        let line = lines[0].trim();
1932        return format!("{indent}/** @var {var_type} {line} */\n");
1933    }
1934    // Multi-line: description block + @var tag.
1935    let mut out = format!("{indent}/**\n");
1936    for line in &lines {
1937        let trimmed = line.trim();
1938        if trimmed.is_empty() {
1939            out.push_str(&format!("{indent} *\n"));
1940        } else {
1941            out.push_str(&format!("{indent} * {trimmed}\n"));
1942        }
1943    }
1944    out.push_str(&format!("{indent} *\n"));
1945    out.push_str(&format!("{indent} * @var {var_type}\n"));
1946    out.push_str(&format!("{indent} */\n"));
1947    out
1948}