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