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, Capabilities, GeneratedFile};
12use alef_core::config::{AlefConfig, Language, detect_serde_available, resolve_output_dir};
13use alef_core::ir::ApiSurface;
14use alef_core::ir::{PrimitiveType, TypeRef};
15use heck::{ToLowerCamelCase, ToPascalCase};
16use std::path::PathBuf;
17
18use functions::{gen_async_function_as_static_method, gen_function_as_static_method};
19use helpers::{
20    gen_enum_tainted_from_binding_to_core, gen_serde_bridge_from, gen_tokio_runtime, has_enum_named_field,
21    references_named_type,
22};
23use types::{gen_enum_constants, gen_opaque_struct_methods, gen_php_struct, gen_struct_methods};
24
25pub struct PhpBackend;
26
27impl PhpBackend {
28    fn binding_config(core_import: &str, has_serde: bool) -> RustBindingConfig<'_> {
29        RustBindingConfig {
30            struct_attrs: &["php_class"],
31            field_attrs: &[],
32            struct_derives: &["Clone"],
33            method_block_attr: Some("php_impl"),
34            constructor_attr: "",
35            static_attr: None,
36            function_attr: "#[php_function]",
37            enum_attrs: &[],
38            enum_derives: &[],
39            needs_signature: false,
40            signature_prefix: "",
41            signature_suffix: "",
42            core_import,
43            async_pattern: AsyncPattern::TokioBlockOn,
44            has_serde,
45            type_name_prefix: "",
46            option_duration_on_defaults: true,
47        }
48    }
49}
50
51impl Backend for PhpBackend {
52    fn name(&self) -> &str {
53        "php"
54    }
55
56    fn language(&self) -> Language {
57        Language::Php
58    }
59
60    fn capabilities(&self) -> Capabilities {
61        Capabilities {
62            supports_async: true,
63            supports_classes: true,
64            supports_enums: true,
65            supports_option: true,
66            supports_result: true,
67            ..Capabilities::default()
68        }
69    }
70
71    fn generate_bindings(&self, api: &ApiSurface, config: &AlefConfig) -> anyhow::Result<Vec<GeneratedFile>> {
72        let enum_names = api.enums.iter().map(|e| e.name.clone()).collect();
73        let mapper = PhpMapper { enum_names };
74        let core_import = config.core_import();
75
76        let output_dir = resolve_output_dir(
77            config.output.php.as_ref(),
78            &config.crate_config.name,
79            "crates/{name}-php/src/",
80        );
81        let has_serde = detect_serde_available(&output_dir);
82        let cfg = Self::binding_config(&core_import, has_serde);
83
84        // Build the inner module content (types, methods, conversions)
85        let mut builder = RustFileBuilder::new();
86        builder.add_import("ext_php_rs::prelude::*");
87
88        // Import serde_json when available (needed for serde-based param conversion)
89        if has_serde {
90            builder.add_import("serde_json");
91        }
92
93        // Import traits needed for trait method dispatch
94        for trait_path in generators::collect_trait_imports(api) {
95            builder.add_import(&trait_path);
96        }
97
98        // Only import HashMap when Map-typed fields or returns are present
99        let has_maps = api.types.iter().any(|t| {
100            t.fields
101                .iter()
102                .any(|f| matches!(&f.ty, alef_core::ir::TypeRef::Map(_, _)))
103        }) || api
104            .functions
105            .iter()
106            .any(|f| matches!(&f.return_type, alef_core::ir::TypeRef::Map(_, _)));
107        if has_maps {
108            builder.add_import("std::collections::HashMap");
109        }
110
111        // Custom module declarations
112        let custom_mods = config.custom_modules.for_language(Language::Php);
113        for module in custom_mods {
114            builder.add_item(&format!("pub mod {module};"));
115        }
116
117        // Check if any function or method is async
118        let has_async =
119            api.functions.iter().any(|f| f.is_async) || api.types.iter().any(|t| t.methods.iter().any(|m| m.is_async));
120
121        if has_async {
122            builder.add_item(&gen_tokio_runtime());
123        }
124
125        // Check if we have opaque types and add Arc import if needed
126        let opaque_types: AHashSet<String> = api
127            .types
128            .iter()
129            .filter(|t| t.is_opaque)
130            .map(|t| t.name.clone())
131            .collect();
132        if !opaque_types.is_empty() {
133            builder.add_import("std::sync::Arc");
134        }
135
136        // Enum names for PHP property classification — enums are mapped as String,
137        // so Named(enum) fields should be treated as scalar props not getters.
138        let enum_names: AHashSet<String> = api.enums.iter().map(|e| e.name.clone()).collect();
139
140        // Compute the PHP namespace for namespaced class registration.
141        // Uses the same logic as `generate_public_api` / `generate_type_stubs`.
142        let extension_name = config.php_extension_name();
143        let php_namespace = if extension_name.contains('_') {
144            let parts: Vec<&str> = extension_name.split('_').collect();
145            let ns_parts: Vec<String> = parts.iter().map(|p| p.to_pascal_case()).collect();
146            ns_parts.join("\\")
147        } else {
148            extension_name.to_pascal_case()
149        };
150
151        for typ in &api.types {
152            if typ.is_opaque {
153                // Generate the opaque struct with separate #[php_class] and
154                // #[php(name = "Ns\\Type")] attributes (ext-php-rs 0.15+ syntax).
155                let php_name_attr = format!("php(name = \"{}\\\\{}\")", php_namespace, typ.name);
156                let opaque_attr_arr = ["php_class", php_name_attr.as_str()];
157                let opaque_cfg = RustBindingConfig {
158                    struct_attrs: &opaque_attr_arr,
159                    ..cfg
160                };
161                builder.add_item(&generators::gen_opaque_struct(typ, &opaque_cfg));
162                builder.add_item(&gen_opaque_struct_methods(typ, &mapper, &opaque_types, &core_import));
163            } else {
164                // gen_struct adds #[derive(Default)] when typ.has_default is true,
165                // so no separate Default impl is needed.
166                builder.add_item(&gen_php_struct(typ, &mapper, &cfg, Some(&php_namespace), &enum_names));
167                builder.add_item(&gen_struct_methods(
168                    typ,
169                    &mapper,
170                    has_serde,
171                    &core_import,
172                    &opaque_types,
173                    &enum_names,
174                ));
175            }
176        }
177
178        for enum_def in &api.enums {
179            builder.add_item(&gen_enum_constants(enum_def));
180        }
181
182        // Generate free functions as static methods on a facade class rather than standalone
183        // `#[php_function]` items. Standalone functions rely on the `inventory` crate for
184        // auto-registration, which does not work in cdylib builds on macOS. Classes registered
185        // via `.class::<T>()` in the module builder DO work on all platforms.
186        if !api.functions.is_empty() {
187            let facade_class_name = extension_name.to_pascal_case();
188            // Build each static method body (no #[php_function] attribute — they live inside
189            // a #[php_impl] block which handles registration via the class machinery).
190            let mut method_items: Vec<String> = Vec::new();
191            for func in &api.functions {
192                if func.is_async {
193                    method_items.push(gen_async_function_as_static_method(
194                        func,
195                        &mapper,
196                        &opaque_types,
197                        &core_import,
198                    ));
199                } else {
200                    method_items.push(gen_function_as_static_method(
201                        func,
202                        &mapper,
203                        &opaque_types,
204                        &core_import,
205                    ));
206                }
207            }
208            let methods_joined = method_items
209                .iter()
210                .map(|m| {
211                    // Indent each line of each method by 4 spaces
212                    m.lines()
213                        .map(|l| {
214                            if l.is_empty() {
215                                String::new()
216                            } else {
217                                format!("    {l}")
218                            }
219                        })
220                        .collect::<Vec<_>>()
221                        .join("\n")
222                })
223                .collect::<Vec<_>>()
224                .join("\n\n");
225            // The PHP-visible class name gets an "Api" suffix to avoid collision with the
226            // PHP facade class (e.g. `Kreuzcrawl\Kreuzcrawl`) that Composer autoloads.
227            let php_api_class_name = format!("{facade_class_name}Api");
228            let php_name_attr = format!("php(name = \"{}\\\\{}\")", php_namespace, php_api_class_name);
229            // Add a create_engine_from_json helper that takes a JSON string and
230            // deserializes via core serde (bypassing the binding type's serde which
231            // maps enums as String and loses nested object structure).
232            let has_config_param = api.functions.iter().any(|f| {
233                f.params
234                    .iter()
235                    .any(|p| matches!(&p.ty, TypeRef::Named(n) if !opaque_types.contains(n.as_str())))
236            });
237            let json_helper = if has_config_param {
238                format!(
239                    "\n\n    pub fn create_engine_from_json(json: Option<String>) -> PhpResult<CrawlEngineHandle> {{\n        \
240                     let config: Option<{core_import}::CrawlConfig> = json.map(|s| serde_json::from_str(&s).map_err(|e| PhpException::default(e.to_string()))).transpose()?;\n        \
241                     let result = {core_import}::create_engine(config).map_err(|e| PhpException::default(e.to_string()))?;\n        \
242                     Ok(CrawlEngineHandle {{ inner: Arc::new(result) }})\n    }}"
243                )
244            } else {
245                String::new()
246            };
247            let facade_struct = format!(
248                "#[php_class]\n#[{php_name_attr}]\npub struct {facade_class_name}Api;\n\n#[php_impl]\nimpl {facade_class_name}Api {{\n{methods_joined}{json_helper}\n}}"
249            );
250            builder.add_item(&facade_struct);
251        }
252
253        let convertible = alef_codegen::conversions::convertible_types(api);
254        let core_to_binding = alef_codegen::conversions::core_to_binding_convertible_types(api);
255        // From/Into conversions with PHP-specific i64 casts.
256        // Types with enum Named fields (or that reference such types transitively) can't
257        // have binding->core From impls because PHP maps enums to String and there's no
258        // From<String> for the core enum type. Core->binding is always safe.
259        let enum_names_ref = &mapper.enum_names;
260        let php_conv_config = ConversionConfig {
261            cast_large_ints_to_i64: true,
262            enum_string_names: Some(enum_names_ref),
263            json_to_string: true,
264            include_cfg_metadata: false,
265            option_duration_on_defaults: true,
266            ..Default::default()
267        };
268        // Build transitive set of types that can't have binding->core From
269        let mut enum_tainted: AHashSet<String> = AHashSet::new();
270        for typ in &api.types {
271            if has_enum_named_field(typ, enum_names_ref) {
272                enum_tainted.insert(typ.name.clone());
273            }
274        }
275        // Transitively mark types that reference enum-tainted types
276        let mut changed = true;
277        while changed {
278            changed = false;
279            for typ in &api.types {
280                if !enum_tainted.contains(&typ.name)
281                    && typ.fields.iter().any(|f| references_named_type(&f.ty, &enum_tainted))
282                {
283                    enum_tainted.insert(typ.name.clone());
284                    changed = true;
285                }
286            }
287        }
288        for typ in &api.types {
289            // binding->core: only when not enum-tainted
290            if !enum_tainted.contains(&typ.name)
291                && alef_codegen::conversions::can_generate_conversion(typ, &convertible)
292            {
293                builder.add_item(&alef_codegen::conversions::gen_from_binding_to_core_cfg(
294                    typ,
295                    &core_import,
296                    &php_conv_config,
297                ));
298            } else if enum_tainted.contains(&typ.name) && has_serde {
299                // Enum-tainted types can't use field-by-field From (no From<String> for core enum),
300                // but when serde is available we bridge via JSON serialization round-trip.
301                builder.add_item(&gen_serde_bridge_from(typ, &core_import));
302            } else if enum_tainted.contains(&typ.name) {
303                // Enum-tainted types: generate From with string->enum parsing for enum-Named
304                // fields, using first variant as fallback. Data-variant enum fields fill
305                // data fields with Default::default().
306                builder.add_item(&gen_enum_tainted_from_binding_to_core(
307                    typ,
308                    &core_import,
309                    enum_names_ref,
310                    &enum_tainted,
311                    &php_conv_config,
312                    &api.enums,
313                ));
314            }
315            // core->binding: always (enum->String via format, sanitized fields via format)
316            if alef_codegen::conversions::can_generate_conversion(typ, &core_to_binding) {
317                builder.add_item(&alef_codegen::conversions::gen_from_core_to_binding_cfg(
318                    typ,
319                    &core_import,
320                    &opaque_types,
321                    &php_conv_config,
322                ));
323            }
324        }
325
326        // Error converter functions
327        for error in &api.errors {
328            builder.add_item(&alef_codegen::error_gen::gen_php_error_converter(error, &core_import));
329        }
330
331        let _adapter_bodies = alef_adapters::build_adapter_bodies(config, Language::Php)?;
332
333        // Add feature gate as inner attribute — entire crate is gated
334        let php_config = config.php.as_ref();
335        if let Some(feature_name) = php_config.and_then(|c| c.feature_gate.as_deref()) {
336            builder.add_inner_attribute(&format!("cfg(feature = \"{feature_name}\")"));
337            builder.add_inner_attribute(&format!(
338                "cfg_attr(all(windows, target_env = \"msvc\", feature = \"{feature_name}\"), feature(abi_vectorcall))"
339            ));
340        }
341
342        // PHP module entry point — explicit class registration required because
343        // `inventory` crate auto-registration doesn't work in cdylib on macOS.
344        let mut class_registrations = String::new();
345        for typ in &api.types {
346            class_registrations.push_str(&format!("\n    .class::<{}>()", typ.name));
347        }
348        // Register the facade class that wraps free functions as static methods.
349        if !api.functions.is_empty() {
350            let facade_class_name = extension_name.to_pascal_case();
351            class_registrations.push_str(&format!("\n    .class::<{facade_class_name}Api>()"));
352        }
353        // Note: enums are represented as PHP string-backed enums, not Rust structs,
354        // so they don't need .class::<T>() registration.
355        builder.add_item(&format!(
356            "#[php_module]\npub fn get_module(module: ModuleBuilder) -> ModuleBuilder {{\n    module{class_registrations}\n}}"
357        ));
358
359        let content = builder.build();
360
361        Ok(vec![GeneratedFile {
362            path: PathBuf::from(&output_dir).join("lib.rs"),
363            content,
364            generated_header: false,
365        }])
366    }
367
368    fn generate_public_api(&self, api: &ApiSurface, config: &AlefConfig) -> anyhow::Result<Vec<GeneratedFile>> {
369        let extension_name = config.php_extension_name();
370        let class_name = extension_name.to_pascal_case();
371
372        // Generate PHP wrapper class
373        let mut content = String::from("<?php\n");
374        content.push_str("// This file is auto-generated by alef. DO NOT EDIT.\n");
375        content.push_str("declare(strict_types=1);\n\n");
376
377        // Determine namespace
378        let namespace = if extension_name.contains('_') {
379            let parts: Vec<&str> = extension_name.split('_').collect();
380            let ns_parts: Vec<String> = parts.iter().map(|p| p.to_pascal_case()).collect();
381            ns_parts.join("\\")
382        } else {
383            class_name.clone()
384        };
385
386        content.push_str(&format!("namespace {};\n\n", namespace));
387        content.push_str(&format!("final class {}\n", class_name));
388        content.push_str("{\n");
389
390        // Generate wrapper methods for functions
391        for func in &api.functions {
392            let method_name = func.name.to_lower_camel_case();
393            let return_php_type = php_type(&func.return_type);
394
395            // PHPDoc block
396            content.push_str("    /**\n");
397            for line in func.doc.lines() {
398                if line.is_empty() {
399                    content.push_str("     *\n");
400                } else {
401                    content.push_str(&format!("     * {}\n", line));
402                }
403            }
404            if func.doc.is_empty() {
405                content.push_str(&format!("     * {}.\n", method_name));
406            }
407            content.push_str("     *\n");
408            for p in &func.params {
409                let ptype = php_phpdoc_type(&p.ty);
410                let nullable_prefix = if p.optional { "?" } else { "" };
411                content.push_str(&format!("     * @param {}{} ${}\n", nullable_prefix, ptype, p.name));
412            }
413            let return_phpdoc = php_phpdoc_type(&func.return_type);
414            content.push_str(&format!("     * @return {}\n", return_phpdoc));
415            if func.error_type.is_some() {
416                content.push_str(&format!("     * @throws \\{}\\{}Exception\n", namespace, class_name));
417            }
418            content.push_str("     */\n");
419
420            // Method signature with type hints
421            content.push_str(&format!("    public static function {}(", method_name));
422
423            let params: Vec<String> = func
424                .params
425                .iter()
426                .map(|p| {
427                    let ptype = php_type(&p.ty);
428                    if p.optional {
429                        format!("?{} ${} = null", ptype, p.name)
430                    } else {
431                        format!("{} ${}", ptype, p.name)
432                    }
433                })
434                .collect();
435            content.push_str(&params.join(", "));
436            content.push_str(&format!("): {}\n", return_php_type));
437            content.push_str("    {\n");
438            // Async functions are registered in the extension with an `_async` suffix
439            // (see gen_async_function which generates `pub fn {name}_async`).
440            // Delegate to the native extension class (registered as `{namespace}\{class_name}Api`).
441            // ext-php-rs auto-converts Rust snake_case to PHP camelCase
442            let ext_method_name = if func.is_async {
443                format!("{}_async", func.name).to_lower_camel_case()
444            } else {
445                func.name.to_lower_camel_case()
446            };
447            content.push_str(&format!(
448                "        return \\{}\\{}Api::{}({}); // delegate to native extension class\n",
449                namespace,
450                class_name,
451                ext_method_name,
452                func.params
453                    .iter()
454                    .map(|p| format!("${}", p.name))
455                    .collect::<Vec<_>>()
456                    .join(", ")
457            ));
458            content.push_str("    }\n\n");
459        }
460
461        // Add createEngineFromJson helper for configs with complex fields
462        content.push_str(&format!(
463            "    /**\n     * Create engine from JSON config string (handles complex nested config).\n     *\n     * @param ?string $json\n     * @return CrawlEngineHandle\n     */\n    public static function createEngineFromJson(?string $json = null): CrawlEngineHandle\n    {{\n        return \\{namespace}\\{class_name}Api::createEngineFromJson($json);\n    }}\n\n",
464            namespace = namespace,
465            class_name = class_name,
466        ));
467
468        content.push_str("}\n");
469
470        // Use PHP stubs output path if configured, otherwise fall back to packages/php/src/.
471        // This is intentionally separate from config.output.php, which controls the Rust binding
472        // crate output directory (e.g., crates/kreuzcrawl-php/src/).
473        let output_dir = config
474            .php
475            .as_ref()
476            .and_then(|p| p.stubs.as_ref())
477            .map(|s| s.output.to_string_lossy().to_string())
478            .unwrap_or_else(|| "packages/php/src/".to_string());
479
480        Ok(vec![GeneratedFile {
481            path: PathBuf::from(&output_dir).join(format!("{}.php", class_name)),
482            content,
483            generated_header: false,
484        }])
485    }
486
487    fn generate_type_stubs(&self, api: &ApiSurface, config: &AlefConfig) -> anyhow::Result<Vec<GeneratedFile>> {
488        let extension_name = config.php_extension_name();
489        let class_name = extension_name.to_pascal_case();
490
491        // Determine namespace (same logic as generate_public_api)
492        let namespace = if extension_name.contains('_') {
493            let parts: Vec<&str> = extension_name.split('_').collect();
494            let ns_parts: Vec<String> = parts.iter().map(|p| p.to_pascal_case()).collect();
495            ns_parts.join("\\")
496        } else {
497            class_name.clone()
498        };
499
500        let mut content = String::from("<?php\n");
501        content.push_str("// This file is auto-generated by alef. DO NOT EDIT.\n");
502        content.push_str("// Type stubs for the native PHP extension — declares classes\n");
503        content.push_str("// provided at runtime by the compiled Rust extension (.so/.dll).\n");
504        content.push_str("// Include this in phpstan.neon scanFiles for static analysis.\n\n");
505        content.push_str("declare(strict_types=1);\n\n");
506        // Use bracketed namespace syntax so we can add global-namespace function stubs later.
507        content.push_str(&format!("namespace {} {{\n\n", namespace));
508
509        // Exception class
510        content.push_str(&format!(
511            "class {}Exception extends \\RuntimeException\n{{\n",
512            class_name
513        ));
514        content.push_str("    public function getErrorCode(): int { }\n");
515        content.push_str("}\n\n");
516
517        // Opaque handle classes
518        for typ in &api.types {
519            if typ.is_opaque {
520                if !typ.doc.is_empty() {
521                    content.push_str("/**\n");
522                    for line in typ.doc.lines() {
523                        if line.is_empty() {
524                            content.push_str(" *\n");
525                        } else {
526                            content.push_str(&format!(" * {}\n", line));
527                        }
528                    }
529                    content.push_str(" */\n");
530                }
531                content.push_str(&format!("class {}\n{{\n", typ.name));
532                // Opaque handles have no public constructors in PHP
533                content.push_str("}\n\n");
534            }
535        }
536
537        // Record / struct types (non-opaque with fields)
538        for typ in &api.types {
539            if typ.is_opaque || typ.fields.is_empty() {
540                continue;
541            }
542            if !typ.doc.is_empty() {
543                content.push_str("/**\n");
544                for line in typ.doc.lines() {
545                    if line.is_empty() {
546                        content.push_str(" *\n");
547                    } else {
548                        content.push_str(&format!(" * {}\n", line));
549                    }
550                }
551                content.push_str(" */\n");
552            }
553            content.push_str(&format!("class {}\n{{\n", typ.name));
554
555            // Constructor with typed parameters.
556            // PHP requires required parameters to come before optional ones, so sort
557            // the fields: required first, then optional (preserving relative order within each group).
558            let mut sorted_fields: Vec<&alef_core::ir::FieldDef> = typ.fields.iter().collect();
559            sorted_fields.sort_by_key(|f| f.optional);
560
561            // Emit PHPDoc before the constructor for any array-typed fields so PHPStan
562            // understands the generic element type (e.g. `@param array<string> $items`).
563            let array_fields: Vec<&alef_core::ir::FieldDef> = sorted_fields
564                .iter()
565                .copied()
566                .filter(|f| matches!(&f.ty, TypeRef::Vec(_) | TypeRef::Map(_, _)))
567                .collect();
568            if !array_fields.is_empty() {
569                content.push_str("    /**\n");
570                for f in &array_fields {
571                    let phpdoc = php_phpdoc_type(&f.ty);
572                    let nullable_prefix = if f.optional { "?" } else { "" };
573                    content.push_str(&format!("     * @param {}{} ${}\n", nullable_prefix, phpdoc, f.name));
574                }
575                content.push_str("     */\n");
576            }
577
578            let params: Vec<String> = sorted_fields
579                .iter()
580                .map(|f| {
581                    let ptype = php_type(&f.ty);
582                    let nullable = if f.optional { format!("?{}", ptype) } else { ptype };
583                    let default = if f.optional { " = null" } else { "" };
584                    format!("        {} ${}{}", nullable, f.name, default)
585                })
586                .collect();
587            content.push_str("    public function __construct(\n");
588            content.push_str(&params.join(",\n"));
589            content.push_str("\n    ) { }\n\n");
590
591            // Getter methods for each field
592            for field in &typ.fields {
593                let is_array = matches!(&field.ty, TypeRef::Vec(_) | TypeRef::Map(_, _));
594                let return_type = if field.optional {
595                    format!("?{}", php_type(&field.ty))
596                } else {
597                    php_type(&field.ty)
598                };
599                let getter_name = field.name.to_lower_camel_case();
600                // Emit PHPDoc for array return types so PHPStan knows the element type.
601                if is_array {
602                    let phpdoc = php_phpdoc_type(&field.ty);
603                    let nullable_prefix = if field.optional { "?" } else { "" };
604                    content.push_str(&format!("    /** @return {}{} */\n", nullable_prefix, phpdoc));
605                }
606                content.push_str(&format!(
607                    "    public function get{}(): {} {{ }}\n",
608                    getter_name.to_pascal_case(),
609                    return_type
610                ));
611            }
612
613            content.push_str("}\n\n");
614        }
615
616        // Enum constants (PHP 8.1+ enums)
617        for enum_def in &api.enums {
618            content.push_str(&format!("enum {}: string\n{{\n", enum_def.name));
619            for variant in &enum_def.variants {
620                content.push_str(&format!("    case {} = '{}';\n", variant.name, variant.name));
621            }
622            content.push_str("}\n\n");
623        }
624
625        // Extension function stubs — generated as a native `{ClassName}Api` class with static
626        // methods. The PHP facade (`{ClassName}`) delegates to `{ClassName}Api::method()`.
627        // Using a class instead of global functions avoids the `inventory` crate registration
628        // issue on macOS (cdylib builds do not collect `#[php_function]` entries there).
629        if !api.functions.is_empty() {
630            content.push_str(&format!("class {}Api\n{{\n", class_name));
631            for func in &api.functions {
632                let return_type = php_type_fq(&func.return_type, &namespace);
633                let return_phpdoc = php_phpdoc_type_fq(&func.return_type, &namespace);
634                // PHPDoc block
635                let has_array_params = func
636                    .params
637                    .iter()
638                    .any(|p| matches!(&p.ty, TypeRef::Vec(_) | TypeRef::Map(_, _)));
639                if has_array_params {
640                    content.push_str("    /**\n");
641                    for p in &func.params {
642                        let ptype = php_phpdoc_type_fq(&p.ty, &namespace);
643                        let nullable_prefix = if p.optional { "?" } else { "" };
644                        content.push_str(&format!("     * @param {}{} ${}\n", nullable_prefix, ptype, p.name));
645                    }
646                    content.push_str(&format!("     * @return {}\n", return_phpdoc));
647                    content.push_str("     */\n");
648                }
649                let params: Vec<String> = func
650                    .params
651                    .iter()
652                    .map(|p| {
653                        let ptype = php_type_fq(&p.ty, &namespace);
654                        if p.optional {
655                            format!("?{} ${} = null", ptype, p.name)
656                        } else {
657                            format!("{} ${}", ptype, p.name)
658                        }
659                    })
660                    .collect();
661                // ext-php-rs auto-converts Rust snake_case to PHP camelCase.
662                let stub_method_name = if func.is_async {
663                    format!("{}_async", func.name).to_lower_camel_case()
664                } else {
665                    func.name.to_lower_camel_case()
666                };
667                content.push_str(&format!(
668                    "    public static function {}({}): {} {{ }}\n",
669                    stub_method_name,
670                    params.join(", "),
671                    return_type
672                ));
673            }
674            // Add createEngineFromJson stub
675            content.push_str("    public static function createEngineFromJson(?string $json = null): \\Kreuzcrawl\\CrawlEngineHandle {}\n");
676            content.push_str("}\n\n");
677        }
678
679        // Close the namespaced block
680        content.push_str("} // end namespace\n");
681
682        // Use stubs output path if configured, otherwise packages/php/stubs/
683        let output_dir = config
684            .php
685            .as_ref()
686            .and_then(|p| p.stubs.as_ref())
687            .map(|s| s.output.to_string_lossy().to_string())
688            .unwrap_or_else(|| "packages/php/stubs/".to_string());
689
690        Ok(vec![GeneratedFile {
691            path: PathBuf::from(&output_dir).join(format!("{}_extension.php", extension_name)),
692            content,
693            generated_header: false,
694        }])
695    }
696
697    fn build_config(&self) -> Option<BuildConfig> {
698        Some(BuildConfig {
699            tool: "cargo",
700            crate_suffix: "-php",
701            depends_on_ffi: false,
702            post_build: vec![],
703        })
704    }
705}
706
707/// Map an IR [`TypeRef`] to a PHPDoc type string with generic parameters (e.g., `array<string>`).
708/// PHPStan at level `max` requires iterable value types in PHPDoc annotations.
709fn php_phpdoc_type(ty: &TypeRef) -> String {
710    match ty {
711        TypeRef::Vec(inner) => format!("array<{}>", php_phpdoc_type(inner)),
712        TypeRef::Map(k, v) => format!("array<{}, {}>", php_phpdoc_type(k), php_phpdoc_type(v)),
713        TypeRef::Optional(inner) => format!("?{}", php_phpdoc_type(inner)),
714        _ => php_type(ty),
715    }
716}
717
718/// Map an IR [`TypeRef`] to a fully-qualified PHPDoc type string with generics (e.g., `array<\Ns\T>`).
719fn php_phpdoc_type_fq(ty: &TypeRef, namespace: &str) -> String {
720    match ty {
721        TypeRef::Vec(inner) => format!("array<{}>", php_phpdoc_type_fq(inner, namespace)),
722        TypeRef::Map(k, v) => format!(
723            "array<{}, {}>",
724            php_phpdoc_type_fq(k, namespace),
725            php_phpdoc_type_fq(v, namespace)
726        ),
727        TypeRef::Named(name) => format!("\\{}\\{}", namespace, name),
728        TypeRef::Optional(inner) => format!("?{}", php_phpdoc_type_fq(inner, namespace)),
729        _ => php_type(ty),
730    }
731}
732
733/// Map an IR [`TypeRef`] to a fully-qualified PHP type-hint string for use outside the namespace.
734fn php_type_fq(ty: &TypeRef, namespace: &str) -> String {
735    match ty {
736        TypeRef::Named(name) => format!("\\{}\\{}", namespace, name),
737        TypeRef::Optional(inner) => {
738            let inner_type = php_type_fq(inner, namespace);
739            format!("?{}", inner_type)
740        }
741        _ => php_type(ty),
742    }
743}
744
745/// Map an IR [`TypeRef`] to a PHP type-hint string.
746fn php_type(ty: &TypeRef) -> String {
747    match ty {
748        TypeRef::String | TypeRef::Char | TypeRef::Json | TypeRef::Bytes | TypeRef::Path => "string".to_string(),
749        TypeRef::Primitive(p) => match p {
750            PrimitiveType::Bool => "bool".to_string(),
751            PrimitiveType::F32 | PrimitiveType::F64 => "float".to_string(),
752            PrimitiveType::U8
753            | PrimitiveType::U16
754            | PrimitiveType::U32
755            | PrimitiveType::U64
756            | PrimitiveType::I8
757            | PrimitiveType::I16
758            | PrimitiveType::I32
759            | PrimitiveType::I64
760            | PrimitiveType::Usize
761            | PrimitiveType::Isize => "int".to_string(),
762        },
763        TypeRef::Optional(inner) => {
764            let inner_type = php_type(inner);
765            format!("?{}", inner_type)
766        }
767        TypeRef::Vec(_) | TypeRef::Map(_, _) => "array".to_string(),
768        TypeRef::Named(name) => name.clone(),
769        TypeRef::Unit => "void".to_string(),
770        TypeRef::Duration => "float".to_string(),
771    }
772}