Skip to main content

alef_backend_php/gen_bindings/
mod.rs

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