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 std::path::PathBuf;
18
19use crate::naming::php_autoload_namespace;
20use functions::{gen_async_function_as_static_method, gen_function_as_static_method};
21use helpers::{gen_enum_tainted_from_binding_to_core, gen_tokio_runtime, has_enum_named_field, references_named_type};
22use types::{
23    gen_enum_constants, gen_flat_data_enum, gen_flat_data_enum_from_impls, gen_flat_data_enum_methods,
24    gen_opaque_struct_methods, gen_php_struct, is_tagged_data_enum,
25};
26
27pub struct PhpBackend;
28
29impl PhpBackend {
30    fn binding_config(core_import: &str, has_serde: bool) -> RustBindingConfig<'_> {
31        RustBindingConfig {
32            struct_attrs: &["php_class"],
33            field_attrs: &[],
34            struct_derives: &["Clone"],
35            method_block_attr: Some("php_impl"),
36            constructor_attr: "",
37            static_attr: None,
38            function_attr: "#[php_function]",
39            enum_attrs: &[],
40            enum_derives: &[],
41            needs_signature: false,
42            signature_prefix: "",
43            signature_suffix: "",
44            core_import,
45            async_pattern: AsyncPattern::TokioBlockOn,
46            has_serde,
47            type_name_prefix: "",
48            option_duration_on_defaults: true,
49            opaque_type_names: &[],
50            skip_impl_constructor: false,
51            cast_uints_to_i32: false,
52            cast_large_ints_to_f64: false,
53            named_non_opaque_params_by_ref: false,
54            lossy_skip_types: &[],
55        }
56    }
57}
58
59impl Backend for PhpBackend {
60    fn name(&self) -> &str {
61        "php"
62    }
63
64    fn language(&self) -> Language {
65        Language::Php
66    }
67
68    fn capabilities(&self) -> Capabilities {
69        Capabilities {
70            supports_async: false,
71            supports_classes: true,
72            supports_enums: true,
73            supports_option: true,
74            supports_result: true,
75            ..Capabilities::default()
76        }
77    }
78
79    fn generate_bindings(&self, api: &ApiSurface, config: &ResolvedCrateConfig) -> anyhow::Result<Vec<GeneratedFile>> {
80        // Separate unit-variant enums (→ String) from tagged data enums (→ flat PHP class).
81        let data_enum_names: AHashSet<String> = api
82            .enums
83            .iter()
84            .filter(|e| is_tagged_data_enum(e))
85            .map(|e| e.name.clone())
86            .collect();
87        let enum_names: AHashSet<String> = api
88            .enums
89            .iter()
90            .filter(|e| !is_tagged_data_enum(e))
91            .map(|e| e.name.clone())
92            .collect();
93        let mapper = PhpMapper {
94            enum_names: enum_names.clone(),
95            data_enum_names: data_enum_names.clone(),
96        };
97        let core_import = config.core_import_name();
98
99        // Get exclusion lists from PHP config
100        let php_config = config.php.as_ref();
101        let exclude_functions = php_config.map(|c| c.exclude_functions.clone()).unwrap_or_default();
102        let exclude_types = php_config.map(|c| c.exclude_types.clone()).unwrap_or_default();
103
104        let output_dir = resolve_output_dir(config.output_paths.get("php"), &config.name, "crates/{name}-php/src/");
105        let has_serde = detect_serde_available(&output_dir);
106
107        // Build the opaque type names list: IR opaque types + bridge type aliases.
108        // Bridge type aliases (e.g. `VisitorHandle`) wrap Rc-based handles and cannot
109        // implement serde::Serialize/Deserialize.  Including them ensures gen_php_struct
110        // emits #[serde(skip)] for fields of those types so derives on the enclosing
111        // struct (e.g. ConversionOptions) still compile.
112        let bridge_type_aliases_php: Vec<String> = config
113            .trait_bridges
114            .iter()
115            .filter_map(|b| b.type_alias.clone())
116            .collect();
117        let bridge_type_aliases_set: AHashSet<String> = bridge_type_aliases_php.iter().cloned().collect();
118        let mut opaque_names_vec_php: Vec<String> = api
119            .types
120            .iter()
121            .filter(|t| t.is_opaque)
122            .map(|t| t.name.clone())
123            .collect();
124        opaque_names_vec_php.extend(bridge_type_aliases_php);
125
126        let mut cfg = Self::binding_config(&core_import, has_serde);
127        cfg.opaque_type_names = &opaque_names_vec_php;
128
129        // Build the inner module content (types, methods, conversions)
130        let mut builder = RustFileBuilder::new().with_generated_header();
131        builder.add_inner_attribute("allow(dead_code, unused_imports, unused_variables)");
132        builder.add_inner_attribute("allow(unsafe_code)");
133        // PHP parameter names are lowerCamelCase; Rust complains about non-snake_case variables.
134        builder.add_inner_attribute("allow(non_snake_case)");
135        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)");
136        builder.add_import("ext_php_rs::prelude::*");
137
138        // Import serde_json when available (needed for serde-based param conversion)
139        if has_serde {
140            builder.add_import("serde_json");
141        }
142
143        // Import traits needed for trait method dispatch
144        for trait_path in generators::collect_trait_imports(api) {
145            builder.add_import(&trait_path);
146        }
147
148        // Only import HashMap when Map-typed fields or returns are present
149        let has_maps = api.types.iter().any(|t| {
150            t.fields
151                .iter()
152                .any(|f| matches!(&f.ty, alef_core::ir::TypeRef::Map(_, _)))
153        }) || api
154            .functions
155            .iter()
156            .any(|f| matches!(&f.return_type, alef_core::ir::TypeRef::Map(_, _)));
157        if has_maps {
158            builder.add_import("std::collections::HashMap");
159        }
160
161        // Custom module declarations
162        let custom_mods = config.custom_modules.for_language(Language::Php);
163        for module in custom_mods {
164            builder.add_item(&format!("pub mod {module};"));
165        }
166
167        // Check if any function or method is async
168        let has_async =
169            api.functions.iter().any(|f| f.is_async) || api.types.iter().any(|t| t.methods.iter().any(|m| m.is_async));
170
171        if has_async {
172            builder.add_item(&gen_tokio_runtime());
173        }
174
175        // Check if we have opaque types and add Arc import if needed
176        let opaque_types: AHashSet<String> = api
177            .types
178            .iter()
179            .filter(|t| t.is_opaque)
180            .map(|t| t.name.clone())
181            .collect();
182        if !opaque_types.is_empty() {
183            builder.add_import("std::sync::Arc");
184        }
185
186        // Compute the PHP namespace for namespaced class registration.
187        // Delegates to config so [php].namespace overrides are respected.
188        let extension_name = config.php_extension_name();
189        let php_namespace = php_autoload_namespace(config);
190
191        // Build adapter body map before type iteration so bodies are available for method generation.
192        let adapter_bodies = alef_adapters::build_adapter_bodies(config, Language::Php)?;
193
194        // Emit adapter-generated standalone items (streaming iterators, callback bridges).
195        for adapter in &config.adapters {
196            match adapter.pattern {
197                alef_core::config::AdapterPattern::Streaming => {
198                    let key = format!("{}.__stream_struct__", adapter.item_type.as_deref().unwrap_or(""));
199                    if let Some(struct_code) = adapter_bodies.get(&key) {
200                        builder.add_item(struct_code);
201                    }
202                }
203                alef_core::config::AdapterPattern::CallbackBridge => {
204                    let struct_key = format!("{}.__bridge_struct__", adapter.name);
205                    let impl_key = format!("{}.__bridge_impl__", adapter.name);
206                    if let Some(struct_code) = adapter_bodies.get(&struct_key) {
207                        builder.add_item(struct_code);
208                    }
209                    if let Some(impl_code) = adapter_bodies.get(&impl_key) {
210                        builder.add_item(impl_code);
211                    }
212                }
213                _ => {}
214            }
215        }
216
217        for typ in api
218            .types
219            .iter()
220            .filter(|typ| !typ.is_trait && !exclude_types.contains(&typ.name))
221        {
222            if typ.is_opaque {
223                // Generate the opaque struct with separate #[php_class] and
224                // #[php(name = "Ns\\Type")] attributes (ext-php-rs 0.15+ syntax).
225                // Escape '\' in the namespace so the generated Rust string literal is valid.
226                let ns_escaped = php_namespace.replace('\\', "\\\\");
227                let php_name_attr = format!("php(name = \"{}\\\\{}\")", ns_escaped, typ.name);
228                let opaque_attr_arr = ["php_class", php_name_attr.as_str()];
229                let opaque_cfg = RustBindingConfig {
230                    struct_attrs: &opaque_attr_arr,
231                    ..cfg
232                };
233                builder.add_item(&generators::gen_opaque_struct(typ, &opaque_cfg));
234                builder.add_item(&gen_opaque_struct_methods(
235                    typ,
236                    &mapper,
237                    &opaque_types,
238                    &core_import,
239                    &adapter_bodies,
240                ));
241            } else {
242                // gen_struct adds #[derive(Default)] when typ.has_default is true,
243                // so no separate Default impl is needed.
244                builder.add_item(&gen_php_struct(typ, &mapper, &cfg, Some(&php_namespace), &enum_names));
245                builder.add_item(&types::gen_struct_methods_with_exclude(
246                    typ,
247                    &mapper,
248                    has_serde,
249                    &core_import,
250                    &opaque_types,
251                    &enum_names,
252                    &api.enums,
253                    &exclude_functions,
254                    &bridge_type_aliases_set,
255                ));
256            }
257        }
258
259        for enum_def in &api.enums {
260            if is_tagged_data_enum(enum_def) {
261                // Tagged data enums (struct variants) are lowered to a flat PHP class.
262                builder.add_item(&gen_flat_data_enum(enum_def, &mapper, Some(&php_namespace)));
263                builder.add_item(&gen_flat_data_enum_methods(enum_def, &mapper));
264            } else {
265                builder.add_item(&gen_enum_constants(enum_def));
266            }
267        }
268
269        // Generate free functions as static methods on a facade class rather than standalone
270        // `#[php_function]` items. Standalone functions rely on the `inventory` crate for
271        // auto-registration, which does not work in cdylib builds on macOS. Classes registered
272        // via `.class::<T>()` in the module builder DO work on all platforms.
273        let included_functions: Vec<_> = api
274            .functions
275            .iter()
276            .filter(|f| !exclude_functions.contains(&f.name))
277            .collect();
278        if !included_functions.is_empty() {
279            let facade_class_name = extension_name.to_pascal_case();
280            // Build each static method body (no #[php_function] attribute — they live inside
281            // a #[php_impl] block which handles registration via the class machinery).
282            let mut method_items: Vec<String> = Vec::new();
283            for func in included_functions {
284                let bridge_param = crate::trait_bridge::find_bridge_param(func, &config.trait_bridges);
285                if let Some((param_idx, bridge_cfg)) = bridge_param {
286                    method_items.push(crate::trait_bridge::gen_bridge_function(
287                        func,
288                        param_idx,
289                        bridge_cfg,
290                        &mapper,
291                        &opaque_types,
292                        &core_import,
293                    ));
294                } else if func.is_async {
295                    method_items.push(gen_async_function_as_static_method(
296                        func,
297                        &mapper,
298                        &opaque_types,
299                        &core_import,
300                        &config.trait_bridges,
301                    ));
302                } else {
303                    method_items.push(gen_function_as_static_method(
304                        func,
305                        &mapper,
306                        &opaque_types,
307                        &core_import,
308                        &config.trait_bridges,
309                        has_serde,
310                    ));
311                }
312            }
313
314            let methods_joined = method_items
315                .iter()
316                .map(|m| {
317                    // Indent each line of each method by 4 spaces
318                    m.lines()
319                        .map(|l| {
320                            if l.is_empty() {
321                                String::new()
322                            } else {
323                                format!("    {l}")
324                            }
325                        })
326                        .collect::<Vec<_>>()
327                        .join("\n")
328                })
329                .collect::<Vec<_>>()
330                .join("\n\n");
331            // The PHP-visible class name gets an "Api" suffix to avoid collision with the
332            // PHP facade class (e.g. `Kreuzcrawl\Kreuzcrawl`) that Composer autoloads.
333            let php_api_class_name = format!("{facade_class_name}Api");
334            // Escape '\' so the generated Rust string literal is valid (e.g. "Ns\\ClassName").
335            let ns_escaped_facade = php_namespace.replace('\\', "\\\\");
336            let php_name_attr = format!("php(name = \"{}\\\\{}\")", ns_escaped_facade, php_api_class_name);
337            let facade_struct = format!(
338                "#[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}}"
339            );
340            builder.add_item(&facade_struct);
341
342            // Trait bridge structs — top-level items (outside the facade class)
343            for bridge_cfg in &config.trait_bridges {
344                if let Some(trait_type) = api.types.iter().find(|t| t.is_trait && t.name == bridge_cfg.trait_name) {
345                    let bridge = crate::trait_bridge::gen_trait_bridge(
346                        trait_type,
347                        bridge_cfg,
348                        &core_import,
349                        &config.error_type_name(),
350                        &config.error_constructor_expr(),
351                        api,
352                    );
353                    for imp in &bridge.imports {
354                        builder.add_import(imp);
355                    }
356                    builder.add_item(&bridge.code);
357                }
358            }
359        }
360
361        let convertible = alef_codegen::conversions::convertible_types(api);
362        let core_to_binding = alef_codegen::conversions::core_to_binding_convertible_types(api);
363        let input_types = alef_codegen::conversions::input_type_names(api);
364        // From/Into conversions with PHP-specific i64 casts.
365        // Types with enum Named fields (or that reference such types transitively) can't
366        // have binding->core From impls because PHP maps enums to String and there's no
367        // From<String> for the core enum type. Core->binding is always safe.
368        let enum_names_ref = &mapper.enum_names;
369        let bridge_skip_types: Vec<String> = config
370            .trait_bridges
371            .iter()
372            .filter_map(|b| b.type_alias.clone())
373            .collect();
374        let php_conv_config = ConversionConfig {
375            cast_large_ints_to_i64: true,
376            enum_string_names: Some(enum_names_ref),
377            json_to_string: true,
378            include_cfg_metadata: false,
379            option_duration_on_defaults: true,
380            from_binding_skip_types: &bridge_skip_types,
381            ..Default::default()
382        };
383        // Build transitive set of types that can't have binding->core From
384        let mut enum_tainted: AHashSet<String> = AHashSet::new();
385        for typ in api.types.iter().filter(|typ| !typ.is_trait) {
386            if has_enum_named_field(typ, enum_names_ref) {
387                enum_tainted.insert(typ.name.clone());
388            }
389        }
390        // Transitively mark types that reference enum-tainted types
391        let mut changed = true;
392        while changed {
393            changed = false;
394            for typ in api.types.iter().filter(|typ| !typ.is_trait) {
395                if !enum_tainted.contains(&typ.name)
396                    && typ.fields.iter().any(|f| references_named_type(&f.ty, &enum_tainted))
397                {
398                    enum_tainted.insert(typ.name.clone());
399                    changed = true;
400                }
401            }
402        }
403        for typ in api.types.iter().filter(|typ| !typ.is_trait) {
404            // binding->core: only when not enum-tainted and type is used as input
405            if input_types.contains(&typ.name)
406                && !enum_tainted.contains(&typ.name)
407                && alef_codegen::conversions::can_generate_conversion(typ, &convertible)
408            {
409                builder.add_item(&alef_codegen::conversions::gen_from_binding_to_core_cfg(
410                    typ,
411                    &core_import,
412                    &php_conv_config,
413                ));
414            } else if input_types.contains(&typ.name) && enum_tainted.contains(&typ.name) {
415                // Enum-tainted types: generate From with string->enum parsing for enum-Named
416                // fields, using first variant as fallback. Data-variant enum fields fill
417                // data fields with Default::default().
418                // Note: JSON roundtrip was previously used when has_serde=true, but that
419                // breaks on non-optional Duration fields (null != u64) and empty-string enum
420                // fields ("" is not a valid variant). Field-by-field conversion handles both.
421                builder.add_item(&gen_enum_tainted_from_binding_to_core(
422                    typ,
423                    &core_import,
424                    enum_names_ref,
425                    &enum_tainted,
426                    &php_conv_config,
427                    &api.enums,
428                    &bridge_type_aliases_set,
429                ));
430            }
431            // core->binding: always (enum->String via format, sanitized fields via format)
432            if alef_codegen::conversions::can_generate_conversion(typ, &core_to_binding) {
433                builder.add_item(&alef_codegen::conversions::gen_from_core_to_binding_cfg(
434                    typ,
435                    &core_import,
436                    &opaque_types,
437                    &php_conv_config,
438                ));
439            }
440        }
441
442        // From impls for tagged data enums lowered to flat PHP classes.
443        for enum_def in api.enums.iter().filter(|e| is_tagged_data_enum(e)) {
444            builder.add_item(&gen_flat_data_enum_from_impls(enum_def, &core_import));
445        }
446
447        // Error converter functions
448        for error in &api.errors {
449            builder.add_item(&alef_codegen::error_gen::gen_php_error_converter(error, &core_import));
450        }
451
452        // Serde default helpers for bool fields whose core default is `true`.
453        // Referenced by #[serde(default = "crate::serde_defaults::bool_true")] on struct fields.
454        if has_serde {
455            builder.add_item("mod serde_defaults {\n    pub fn bool_true() -> bool { true }\n}");
456        }
457
458        // Always enable abi_vectorcall on Windows — ext-php-rs requires the
459        // `vectorcall` calling convention for PHP entry points there. The feature
460        // is unstable on stable Rust; consumers either build with nightly or set
461        // RUSTC_BOOTSTRAP=1 (the upstream-recommended workaround). This cfg_attr
462        // is a no-op on non-windows so it costs nothing on Linux/macOS builds.
463        let php_config = config.php.as_ref();
464        builder.add_inner_attribute("cfg_attr(windows, feature(abi_vectorcall))");
465
466        // Optional feature gate — when [php].feature_gate is set, the entire crate
467        // is conditionally compiled. Use this for parity with PyO3's `extension-module`
468        // pattern; most PHP bindings don't need it.
469        if let Some(feature_name) = php_config.and_then(|c| c.feature_gate.as_deref()) {
470            builder.add_inner_attribute(&format!("cfg(feature = \"{feature_name}\")"));
471        }
472
473        // PHP module entry point — explicit class registration required because
474        // `inventory` crate auto-registration doesn't work in cdylib on macOS.
475        let mut class_registrations = String::new();
476        for typ in api
477            .types
478            .iter()
479            .filter(|typ| !typ.is_trait && !exclude_types.contains(&typ.name))
480        {
481            class_registrations.push_str(&format!("\n    .class::<{}>()", typ.name));
482        }
483        // Register the facade class that wraps free functions as static methods.
484        if !api.functions.is_empty() {
485            let facade_class_name = extension_name.to_pascal_case();
486            class_registrations.push_str(&format!("\n    .class::<{facade_class_name}Api>()"));
487        }
488        // Tagged data enums are lowered to flat PHP classes — register them like other classes.
489        // Unit-variant enums remain as string constants and don't need .class::<T>() registration.
490        for enum_def in api.enums.iter().filter(|e| is_tagged_data_enum(e)) {
491            class_registrations.push_str(&format!("\n    .class::<{}>()", enum_def.name));
492        }
493        builder.add_item(&format!(
494            "#[php_module]\npub fn get_module(module: ModuleBuilder) -> ModuleBuilder {{\n    module{class_registrations}\n}}"
495        ));
496
497        let mut content = builder.build();
498
499        // Post-process generated code to replace the bridge builder method.
500        // The generated code produces `visitor(Option<&VisitorHandle>)` which is
501        // unreachable from PHP. Replace the entire method — signature and body —
502        // with one that accepts a ZendObject and builds the proper bridge handle.
503        for bridge in &config.trait_bridges {
504            if let Some(field_name) = bridge.resolved_options_field() {
505                let param_name = bridge.param_name.as_deref().unwrap_or(field_name);
506                let type_alias = bridge.type_alias.as_deref().unwrap_or("VisitorHandle");
507                let options_type = bridge.options_type.as_deref().unwrap_or("ConversionOptions");
508                let builder_type = format!("{}Builder", options_type);
509                let bridge_struct = format!("Php{}Bridge", bridge.trait_name);
510
511                // Match the verbatim pre-rustfmt output from codegen.
512                // gen_instance_method produces 4-space-indented lines (signature + body),
513                // then ImplBuilder.build() adds 4 more spaces to every line → 8/8/4 indent.
514                // The body is a single-line Self { inner: Arc::new(...) } expression.
515                // rustfmt later reformats this to the 4/8/8/4 multi-line style on disk.
516                let old_method = format!(
517                    "        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    }}"
518                );
519                let new_method = format!(
520                    "        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: html_to_markdown_rs::visitor::VisitorHandle = std::rc::Rc::new(std::cell::RefCell::new(bridge));\n        Self {{ inner: Arc::new((*self.inner).clone().{field_name}(Some(handle))) }}\n    }}"
521                );
522
523                content = content.replace(&old_method, &new_method);
524            }
525        }
526
527        Ok(vec![GeneratedFile {
528            path: PathBuf::from(&output_dir).join("lib.rs"),
529            content,
530            generated_header: false,
531        }])
532    }
533
534    fn generate_public_api(
535        &self,
536        api: &ApiSurface,
537        config: &ResolvedCrateConfig,
538    ) -> anyhow::Result<Vec<GeneratedFile>> {
539        let extension_name = config.php_extension_name();
540        let class_name = extension_name.to_pascal_case();
541
542        // Generate PHP wrapper class
543        let mut content = String::from("<?php\n\n");
544        content.push_str(&hash::header(CommentStyle::DoubleSlash));
545        content.push_str("declare(strict_types=1);\n\n");
546
547        // Determine namespace — delegates to config so [php].namespace overrides are respected.
548        let namespace = php_autoload_namespace(config);
549
550        content.push_str(&format!("namespace {};\n\n", namespace));
551        content.push_str(&format!("final class {}\n", class_name));
552        content.push_str("{\n");
553
554        // Build the set of bridge param names so they are excluded from public PHP signatures.
555        let bridge_param_names_pub: ahash::AHashSet<&str> = config
556            .trait_bridges
557            .iter()
558            .filter_map(|b| b.param_name.as_deref())
559            .collect();
560
561        // Config types whose PHP constructors can be called with zero arguments.
562        // Only qualifies when ALL fields are optional (PHP constructor needs no required args).
563        // `has_default` (Rust Default impl) is NOT sufficient — the PHP constructor is
564        // generated from struct fields and still requires non-optional ones.
565        let no_arg_constructor_types: AHashSet<String> = api
566            .types
567            .iter()
568            .filter(|t| t.fields.iter().all(|f| f.optional))
569            .map(|t| t.name.clone())
570            .collect();
571
572        // Generate wrapper methods for functions
573        for func in &api.functions {
574            // PHP method names mirror the Rust source name verbatim (camelCased).
575            // Async-vs-sync disambiguation comes from the Rust source itself
576            // (e.g. `extract_file` vs `extract_file_sync`), so no extra suffix
577            // is needed — and adding one would break parity with the alef.toml
578            // call overrides.
579            let method_name = func.name.to_lower_camel_case();
580            let return_php_type = php_type(&func.return_type);
581
582            // Visible params exclude bridge params (not surfaced to PHP callers).
583            let visible_params: Vec<_> = func
584                .params
585                .iter()
586                .filter(|p| !bridge_param_names_pub.contains(p.name.as_str()))
587                .collect();
588
589            // PHPDoc block
590            content.push_str("    /**\n");
591            for line in func.doc.lines() {
592                if line.is_empty() {
593                    content.push_str("     *\n");
594                } else {
595                    content.push_str(&format!("     * {}\n", line));
596                }
597            }
598            if func.doc.is_empty() {
599                content.push_str(&format!("     * {}.\n", method_name));
600            }
601            content.push_str("     *\n");
602            for p in &visible_params {
603                let ptype = php_phpdoc_type(&p.ty);
604                let nullable_prefix = if p.optional { "?" } else { "" };
605                content.push_str(&format!("     * @param {}{} ${}\n", nullable_prefix, ptype, p.name));
606            }
607            let return_phpdoc = php_phpdoc_type(&func.return_type);
608            content.push_str(&format!("     * @return {}\n", return_phpdoc));
609            if func.error_type.is_some() {
610                content.push_str(&format!("     * @throws \\{}\\{}Exception\n", namespace, class_name));
611            }
612            content.push_str("     */\n");
613
614            // Method signature with type hints.
615            // Keep parameters in their original Rust order.
616            // Since PHP doesn't allow optional params before required ones, and some Rust
617            // functions have optional params in the middle, we must make all params after
618            // the first optional one also optional (nullable with null default).
619            // This ensures e2e generated test code (which uses Rust param order) will work.
620            // Additionally, config-like parameters (Named types ending in "Config") should
621            // be treated as optional for PHP even if not explicitly marked as such in the IR.
622            // Helper: a config param is only treated as optional when its type can be
623            // constructed with zero arguments (all fields are optional in the IR).
624            let is_optional_config_param = |p: &alef_core::ir::ParamDef| -> bool {
625                if let TypeRef::Named(name) = &p.ty {
626                    (name.ends_with("Config") || name.as_str() == "config")
627                        && no_arg_constructor_types.contains(name.as_str())
628                } else {
629                    false
630                }
631            };
632
633            let mut first_optional_idx = None;
634            for (idx, p) in visible_params.iter().enumerate() {
635                if p.optional || is_optional_config_param(p) {
636                    first_optional_idx = Some(idx);
637                    break;
638                }
639            }
640
641            content.push_str(&format!("    public static function {}(", method_name));
642
643            let params: Vec<String> = visible_params
644                .iter()
645                .enumerate()
646                .map(|(idx, p)| {
647                    let ptype = php_type(&p.ty);
648                    // Make param optional if:
649                    // 1. It's explicitly optional OR
650                    // 2. It's a config parameter with a no-arg constructor OR
651                    // 3. It comes after the first optional/config param
652                    let should_be_optional = p.optional
653                        || is_optional_config_param(p)
654                        || first_optional_idx.is_some_and(|first| idx >= first);
655                    if should_be_optional {
656                        format!("?{} ${} = null", ptype, p.name)
657                    } else {
658                        format!("{} ${}", ptype, p.name)
659                    }
660                })
661                .collect();
662            content.push_str(&params.join(", "));
663            content.push_str(&format!("): {}\n", return_php_type));
664            content.push_str("    {\n");
665            // Async functions are registered in the extension with an `_async` suffix
666            // (see gen_async_function which generates `pub fn {name}_async`).
667            // Delegate to the native extension class (registered as `{namespace}\{class_name}Api`).
668            // ext-php-rs auto-converts Rust snake_case to PHP camelCase
669            let ext_method_name = if func.is_async {
670                format!("{}_async", func.name).to_lower_camel_case()
671            } else {
672                func.name.to_lower_camel_case()
673            };
674            let is_void = matches!(&func.return_type, TypeRef::Unit);
675            // Pass parameters to the native function in their ORIGINAL order (not sorted).
676            // The native extension expects parameters in the order defined in the Rust function.
677            // The PHP facade reorders them only in its own signature for PHP syntax compliance,
678            // but must pass them in the original order when calling the native method.
679            // Config-type params that were made optional (nullable) in the facade must be
680            // coerced to their default constructor when null, since the native ext requires
681            // non-nullable objects.
682            let call_params = visible_params
683                .iter()
684                .enumerate()
685                .map(|(idx, p)| {
686                    let should_be_optional = p.optional
687                        || is_optional_config_param(p)
688                        || first_optional_idx.is_some_and(|first| idx >= first);
689                    if should_be_optional && is_optional_config_param(p) {
690                        if let TypeRef::Named(type_name) = &p.ty {
691                            return format!("${} ?? new {}()", p.name, type_name);
692                        }
693                    }
694                    format!("${}", p.name)
695                })
696                .collect::<Vec<_>>()
697                .join(", ");
698            let call_expr = format!(
699                "\\{}\\{}Api::{}({})",
700                namespace, class_name, ext_method_name, call_params
701            );
702            if is_void {
703                content.push_str(&format!(
704                    "        {}; // delegate to native extension class\n",
705                    call_expr
706                ));
707            } else {
708                content.push_str(&format!(
709                    "        return {}; // delegate to native extension class\n",
710                    call_expr
711                ));
712            }
713            content.push_str("    }\n\n");
714        }
715
716        content.push_str("}\n");
717
718        // Use PHP stubs output path if configured, otherwise fall back to packages/php/src/.
719        // This is intentionally separate from config.output.php, which controls the Rust binding
720        // crate output directory (e.g., crates/kreuzcrawl-php/src/).
721        let output_dir = config
722            .php
723            .as_ref()
724            .and_then(|p| p.stubs.as_ref())
725            .map(|s| s.output.to_string_lossy().to_string())
726            .unwrap_or_else(|| "packages/php/src/".to_string());
727
728        Ok(vec![GeneratedFile {
729            path: PathBuf::from(&output_dir).join(format!("{}.php", class_name)),
730            content,
731            generated_header: false,
732        }])
733    }
734
735    fn generate_type_stubs(
736        &self,
737        api: &ApiSurface,
738        config: &ResolvedCrateConfig,
739    ) -> anyhow::Result<Vec<GeneratedFile>> {
740        let extension_name = config.php_extension_name();
741        let class_name = extension_name.to_pascal_case();
742
743        // Determine namespace — delegates to config so [php].namespace overrides are respected.
744        let namespace = php_autoload_namespace(config);
745
746        // PSR-12 requires a blank line after the opening `<?php` tag.
747        // php-cs-fixer enforces this and would insert it post-write,
748        // making `alef verify` see content that differs from what was
749        // freshly generated. Emit it here so generated == on-disk.
750        let mut content = String::from("<?php\n\n");
751        content.push_str(&hash::header(CommentStyle::DoubleSlash));
752        content.push_str("// Type stubs for the native PHP extension — declares classes\n");
753        content.push_str("// provided at runtime by the compiled Rust extension (.so/.dll).\n");
754        content.push_str("// Include this in phpstan.neon scanFiles for static analysis.\n\n");
755        content.push_str("declare(strict_types=1);\n\n");
756        // Use bracketed namespace syntax so we can add global-namespace function stubs later.
757        content.push_str(&format!("namespace {} {{\n\n", namespace));
758
759        // Exception class
760        content.push_str(&format!(
761            "class {}Exception extends \\RuntimeException\n{{\n",
762            class_name
763        ));
764        content.push_str(
765            "    public function getErrorCode(): int { throw new \\RuntimeException('Not implemented.'); }\n",
766        );
767        content.push_str("}\n\n");
768
769        // Opaque handle classes
770        for typ in api.types.iter().filter(|typ| !typ.is_trait) {
771            if typ.is_opaque {
772                if !typ.doc.is_empty() {
773                    content.push_str("/**\n");
774                    for line in typ.doc.lines() {
775                        if line.is_empty() {
776                            content.push_str(" *\n");
777                        } else {
778                            content.push_str(&format!(" * {}\n", line));
779                        }
780                    }
781                    content.push_str(" */\n");
782                }
783                content.push_str(&format!("class {}\n{{\n", typ.name));
784                // Opaque handles have no public constructors in PHP
785                content.push_str("}\n\n");
786            }
787        }
788
789        // Record / struct types (non-opaque with fields)
790        for typ in api.types.iter().filter(|typ| !typ.is_trait) {
791            if typ.is_opaque || typ.fields.is_empty() {
792                continue;
793            }
794            if !typ.doc.is_empty() {
795                content.push_str("/**\n");
796                for line in typ.doc.lines() {
797                    if line.is_empty() {
798                        content.push_str(" *\n");
799                    } else {
800                        content.push_str(&format!(" * {}\n", line));
801                    }
802                }
803                content.push_str(" */\n");
804            }
805            content.push_str(&format!("class {}\n{{\n", typ.name));
806
807            // Public property declarations (ext-php-rs exposes struct fields as properties)
808            for field in &typ.fields {
809                let is_array = matches!(&field.ty, TypeRef::Vec(_) | TypeRef::Map(_, _));
810                let prop_type = if field.optional {
811                    let inner = php_type(&field.ty);
812                    if inner.starts_with('?') {
813                        inner
814                    } else {
815                        format!("?{inner}")
816                    }
817                } else {
818                    php_type(&field.ty)
819                };
820                if is_array {
821                    let phpdoc = php_phpdoc_type(&field.ty);
822                    let nullable_prefix = if field.optional { "?" } else { "" };
823                    content.push_str(&format!("    /** @var {}{} */\n", nullable_prefix, phpdoc));
824                }
825                content.push_str(&format!("    public {} ${};\n", prop_type, field.name));
826            }
827            content.push('\n');
828
829            // Constructor with typed parameters.
830            // PHP requires required parameters to come before optional ones, so sort
831            // the fields: required first, then optional (preserving relative order within each group).
832            let mut sorted_fields: Vec<&alef_core::ir::FieldDef> = typ.fields.iter().collect();
833            sorted_fields.sort_by_key(|f| f.optional);
834
835            // Emit PHPDoc before the constructor for any array-typed fields so PHPStan
836            // understands the generic element type (e.g. `@param array<string> $items`).
837            let array_fields: Vec<&alef_core::ir::FieldDef> = sorted_fields
838                .iter()
839                .copied()
840                .filter(|f| matches!(&f.ty, TypeRef::Vec(_) | TypeRef::Map(_, _)))
841                .collect();
842            if !array_fields.is_empty() {
843                content.push_str("    /**\n");
844                for f in &array_fields {
845                    let phpdoc = php_phpdoc_type(&f.ty);
846                    let nullable_prefix = if f.optional { "?" } else { "" };
847                    content.push_str(&format!("     * @param {}{} ${}\n", nullable_prefix, phpdoc, f.name));
848                }
849                content.push_str("     */\n");
850            }
851
852            let params: Vec<String> = sorted_fields
853                .iter()
854                .map(|f| {
855                    let ptype = php_type(&f.ty);
856                    let nullable = if f.optional && !ptype.starts_with('?') {
857                        format!("?{ptype}")
858                    } else {
859                        ptype
860                    };
861                    let default = if f.optional { " = null" } else { "" };
862                    format!("        {} ${}{}", nullable, f.name, default)
863                })
864                .collect();
865            content.push_str("    public function __construct(\n");
866            content.push_str(&params.join(",\n"));
867            content.push_str("\n    ) { }\n\n");
868
869            // Getter methods for each field
870            for field in &typ.fields {
871                let is_array = matches!(&field.ty, TypeRef::Vec(_) | TypeRef::Map(_, _));
872                let return_type = if field.optional {
873                    let inner = php_type(&field.ty);
874                    if inner.starts_with('?') {
875                        inner
876                    } else {
877                        format!("?{inner}")
878                    }
879                } else {
880                    php_type(&field.ty)
881                };
882                let getter_name = field.name.to_lower_camel_case();
883                // Emit PHPDoc for array return types so PHPStan knows the element type.
884                if is_array {
885                    let phpdoc = php_phpdoc_type(&field.ty);
886                    let nullable_prefix = if field.optional { "?" } else { "" };
887                    content.push_str(&format!("    /** @return {}{} */\n", nullable_prefix, phpdoc));
888                }
889                let is_void_getter = return_type == "void";
890                let getter_body = if is_void_getter {
891                    "{ }".to_string()
892                } else {
893                    "{ throw new \\RuntimeException('Not implemented.'); }".to_string()
894                };
895                content.push_str(&format!(
896                    "    public function get{}(): {} {getter_body}\n",
897                    getter_name.to_pascal_case(),
898                    return_type
899                ));
900            }
901
902            content.push_str("}\n\n");
903        }
904
905        // Emit tagged data enums as classes (they're lowered to flat PHP classes in the binding).
906        // Unit-variant enums → PHP 8.1+ enum constants.
907        for enum_def in &api.enums {
908            if is_tagged_data_enum(enum_def) {
909                // Tagged data enums are lowered to flat classes; emit class stubs.
910                if !enum_def.doc.is_empty() {
911                    content.push_str("/**\n");
912                    for line in enum_def.doc.lines() {
913                        if line.is_empty() {
914                            content.push_str(" *\n");
915                        } else {
916                            content.push_str(&format!(" * {}\n", line));
917                        }
918                    }
919                    content.push_str(" */\n");
920                }
921                content.push_str(&format!("class {}\n{{\n", enum_def.name));
922                content.push_str("}\n\n");
923            } else {
924                // Unit-variant enums → PHP 8.1+ enum constants.
925                content.push_str(&format!("enum {}: string\n{{\n", enum_def.name));
926                for variant in &enum_def.variants {
927                    content.push_str(&format!("    case {} = '{}';\n", variant.name, variant.name));
928                }
929                content.push_str("}\n\n");
930            }
931        }
932
933        // Extension function stubs — generated as a native `{ClassName}Api` class with static
934        // methods. The PHP facade (`{ClassName}`) delegates to `{ClassName}Api::method()`.
935        // Using a class instead of global functions avoids the `inventory` crate registration
936        // issue on macOS (cdylib builds do not collect `#[php_function]` entries there).
937        if !api.functions.is_empty() {
938            // Bridge params are hidden from the PHP-visible API in stubs too.
939            let bridge_param_names_stubs: ahash::AHashSet<&str> = config
940                .trait_bridges
941                .iter()
942                .filter_map(|b| b.param_name.as_deref())
943                .collect();
944
945            content.push_str(&format!("class {}Api\n{{\n", class_name));
946            for func in &api.functions {
947                let return_type = php_type_fq(&func.return_type, &namespace);
948                let return_phpdoc = php_phpdoc_type_fq(&func.return_type, &namespace);
949                // Visible params exclude bridge params.
950                let visible_params: Vec<_> = func
951                    .params
952                    .iter()
953                    .filter(|p| !bridge_param_names_stubs.contains(p.name.as_str()))
954                    .collect();
955                // Stubs declare the ACTUAL native interface, which has parameters in their original order
956                // (ext-php-rs doesn't reorder them). DO NOT sort them here.
957                // The PHP facade may reorder them for syntax compliance, but the stub must match
958                // the actual native extension signature.
959                // Emit PHPDoc when any param or the return type is an array, so PHPStan
960                // understands generic element types (e.g. array<string> vs bare array).
961                let has_array_params = visible_params
962                    .iter()
963                    .any(|p| matches!(&p.ty, TypeRef::Vec(_) | TypeRef::Map(_, _)));
964                let has_array_return = matches!(&func.return_type, TypeRef::Vec(_) | TypeRef::Map(_, _))
965                    || matches!(&func.return_type, TypeRef::Optional(inner) if matches!(inner.as_ref(), TypeRef::Vec(_) | TypeRef::Map(_, _)));
966                if has_array_params || has_array_return {
967                    content.push_str("    /**\n");
968                    for p in &visible_params {
969                        let ptype = php_phpdoc_type_fq(&p.ty, &namespace);
970                        let nullable_prefix = if p.optional { "?" } else { "" };
971                        content.push_str(&format!("     * @param {}{} ${}\n", nullable_prefix, ptype, p.name));
972                    }
973                    content.push_str(&format!("     * @return {}\n", return_phpdoc));
974                    content.push_str("     */\n");
975                }
976                let params: Vec<String> = visible_params
977                    .iter()
978                    .map(|p| {
979                        let ptype = php_type_fq(&p.ty, &namespace);
980                        if p.optional {
981                            format!("?{} ${} = null", ptype, p.name)
982                        } else {
983                            format!("{} ${}", ptype, p.name)
984                        }
985                    })
986                    .collect();
987                // ext-php-rs auto-converts Rust snake_case to PHP camelCase.
988                let stub_method_name = if func.is_async {
989                    format!("{}_async", func.name).to_lower_camel_case()
990                } else {
991                    func.name.to_lower_camel_case()
992                };
993                let is_void_stub = return_type == "void";
994                let stub_body = if is_void_stub {
995                    "{ }".to_string()
996                } else {
997                    "{ throw new \\RuntimeException('Not implemented.'); }".to_string()
998                };
999                content.push_str(&format!(
1000                    "    public static function {}({}): {} {stub_body}\n",
1001                    stub_method_name,
1002                    params.join(", "),
1003                    return_type
1004                ));
1005            }
1006            content.push_str("}\n\n");
1007        }
1008
1009        // Close the namespaced block
1010        content.push_str("} // end namespace\n");
1011
1012        // Use stubs output path if configured, otherwise packages/php/stubs/
1013        let output_dir = config
1014            .php
1015            .as_ref()
1016            .and_then(|p| p.stubs.as_ref())
1017            .map(|s| s.output.to_string_lossy().to_string())
1018            .unwrap_or_else(|| "packages/php/stubs/".to_string());
1019
1020        Ok(vec![GeneratedFile {
1021            path: PathBuf::from(&output_dir).join(format!("{}_extension.php", extension_name)),
1022            content,
1023            generated_header: false,
1024        }])
1025    }
1026
1027    fn build_config(&self) -> Option<BuildConfig> {
1028        Some(BuildConfig {
1029            tool: "cargo",
1030            crate_suffix: "-php",
1031            build_dep: BuildDependency::None,
1032            post_build: vec![],
1033        })
1034    }
1035}
1036
1037/// Map an IR [`TypeRef`] to a PHPDoc type string with generic parameters (e.g., `array<string>`).
1038/// PHPStan at level `max` requires iterable value types in PHPDoc annotations.
1039fn php_phpdoc_type(ty: &TypeRef) -> String {
1040    match ty {
1041        TypeRef::Vec(inner) => format!("array<{}>", php_phpdoc_type(inner)),
1042        TypeRef::Map(k, v) => format!("array<{}, {}>", php_phpdoc_type(k), php_phpdoc_type(v)),
1043        TypeRef::Optional(inner) => format!("?{}", php_phpdoc_type(inner)),
1044        _ => php_type(ty),
1045    }
1046}
1047
1048/// Map an IR [`TypeRef`] to a fully-qualified PHPDoc type string with generics (e.g., `array<\Ns\T>`).
1049fn php_phpdoc_type_fq(ty: &TypeRef, namespace: &str) -> String {
1050    match ty {
1051        TypeRef::Vec(inner) => format!("array<{}>", php_phpdoc_type_fq(inner, namespace)),
1052        TypeRef::Map(k, v) => format!(
1053            "array<{}, {}>",
1054            php_phpdoc_type_fq(k, namespace),
1055            php_phpdoc_type_fq(v, namespace)
1056        ),
1057        TypeRef::Named(name) => format!("\\{}\\{}", namespace, name),
1058        TypeRef::Optional(inner) => format!("?{}", php_phpdoc_type_fq(inner, namespace)),
1059        _ => php_type(ty),
1060    }
1061}
1062
1063/// Map an IR [`TypeRef`] to a fully-qualified PHP type-hint string for use outside the namespace.
1064fn php_type_fq(ty: &TypeRef, namespace: &str) -> String {
1065    match ty {
1066        TypeRef::Named(name) => format!("\\{}\\{}", namespace, name),
1067        TypeRef::Optional(inner) => {
1068            let inner_type = php_type_fq(inner, namespace);
1069            if inner_type.starts_with('?') {
1070                inner_type
1071            } else {
1072                format!("?{inner_type}")
1073            }
1074        }
1075        _ => php_type(ty),
1076    }
1077}
1078
1079/// Map an IR [`TypeRef`] to a PHP type-hint string.
1080fn php_type(ty: &TypeRef) -> String {
1081    match ty {
1082        TypeRef::String | TypeRef::Char | TypeRef::Json | TypeRef::Bytes | TypeRef::Path => "string".to_string(),
1083        TypeRef::Primitive(p) => match p {
1084            PrimitiveType::Bool => "bool".to_string(),
1085            PrimitiveType::F32 | PrimitiveType::F64 => "float".to_string(),
1086            PrimitiveType::U8
1087            | PrimitiveType::U16
1088            | PrimitiveType::U32
1089            | PrimitiveType::U64
1090            | PrimitiveType::I8
1091            | PrimitiveType::I16
1092            | PrimitiveType::I32
1093            | PrimitiveType::I64
1094            | PrimitiveType::Usize
1095            | PrimitiveType::Isize => "int".to_string(),
1096        },
1097        TypeRef::Optional(inner) => {
1098            // Flatten nested Option<Option<T>> to a single nullable type.
1099            // PHP has no double-nullable concept; ?T already covers null.
1100            let inner_type = php_type(inner);
1101            if inner_type.starts_with('?') {
1102                inner_type
1103            } else {
1104                format!("?{inner_type}")
1105            }
1106        }
1107        TypeRef::Vec(_) | TypeRef::Map(_, _) => "array".to_string(),
1108        TypeRef::Named(name) => name.clone(),
1109        TypeRef::Unit => "void".to_string(),
1110        TypeRef::Duration => "float".to_string(),
1111    }
1112}