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