Skip to main content

alef_backend_php/gen_bindings/
mod.rs

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