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 let mut builder = RustFileBuilder::new();
86 builder.add_import("ext_php_rs::prelude::*");
87
88 if has_serde {
90 builder.add_import("serde_json");
91 }
92
93 for trait_path in generators::collect_trait_imports(api) {
95 builder.add_import(&trait_path);
96 }
97
98 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 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 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 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 let enum_names: AHashSet<String> = api.enums.iter().map(|e| e.name.clone()).collect();
139
140 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 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 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 if !api.functions.is_empty() {
187 let facade_class_name = extension_name.to_pascal_case();
188 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 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 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 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 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 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 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 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 builder.add_item(&gen_serde_bridge_from(typ, &core_import));
302 } else if enum_tainted.contains(&typ.name) {
303 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 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 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 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 let mut class_registrations = String::new();
345 for typ in &api.types {
346 class_registrations.push_str(&format!("\n .class::<{}>()", typ.name));
347 }
348 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 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 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 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 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 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 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(¶ms.join(", "));
436 content.push_str(&format!("): {}\n", return_php_type));
437 content.push_str(" {\n");
438 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 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 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 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 content.push_str(&format!("namespace {} {{\n\n", namespace));
508
509 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 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 content.push_str("}\n\n");
534 }
535 }
536
537 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 let mut sorted_fields: Vec<&alef_core::ir::FieldDef> = typ.fields.iter().collect();
559 sorted_fields.sort_by_key(|f| f.optional);
560
561 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(¶ms.join(",\n"));
589 content.push_str("\n ) { }\n\n");
590
591 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 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 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 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 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 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 content.push_str(" public static function createEngineFromJson(?string $json = null): \\Kreuzcrawl\\CrawlEngineHandle {}\n");
676 content.push_str("}\n\n");
677 }
678
679 content.push_str("} // end namespace\n");
681
682 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
707fn 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
718fn 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
733fn 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
745fn 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}