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