Skip to main content

alef_backend_php/gen_bindings/
mod.rs

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