1mod functions;
2mod helpers;
3mod types;
4
5use crate::type_map::PhpMapper;
6use ahash::AHashSet;
7use alef_codegen::builder::RustFileBuilder;
8use alef_codegen::conversions::ConversionConfig;
9use alef_codegen::generators::RustBindingConfig;
10use alef_codegen::generators::{self, AsyncPattern};
11use alef_core::backend::{Backend, BuildConfig, BuildDependency, Capabilities, GeneratedFile};
12use alef_core::config::{Language, ResolvedCrateConfig, detect_serde_available, resolve_output_dir};
13use alef_core::hash::{self, CommentStyle};
14use alef_core::ir::ApiSurface;
15use alef_core::ir::{PrimitiveType, TypeRef};
16use heck::{ToLowerCamelCase, ToPascalCase};
17use minijinja::context;
18use std::path::PathBuf;
19
20use crate::naming::php_autoload_namespace;
21use functions::{gen_async_function_as_static_method, gen_function_as_static_method};
22
23fn sanitize_php_enum_case(name: &str) -> String {
26 if name.eq_ignore_ascii_case("class") {
27 format!("{name}_")
28 } else {
29 name.to_string()
30 }
31}
32use helpers::{gen_enum_tainted_from_binding_to_core, gen_tokio_runtime, has_enum_named_field, references_named_type};
33use types::{
34 gen_enum_constants, gen_flat_data_enum, gen_flat_data_enum_from_impls, gen_flat_data_enum_methods,
35 gen_opaque_struct_methods, gen_php_struct, is_tagged_data_enum,
36};
37
38pub struct PhpBackend;
39
40impl PhpBackend {
41 fn binding_config(core_import: &str, has_serde: bool) -> RustBindingConfig<'_> {
42 RustBindingConfig {
43 struct_attrs: &["php_class"],
44 field_attrs: &[],
45 struct_derives: &["Clone"],
46 method_block_attr: Some("php_impl"),
47 constructor_attr: "",
48 static_attr: None,
49 function_attr: "#[php_function]",
50 enum_attrs: &[],
51 enum_derives: &[],
52 needs_signature: false,
53 signature_prefix: "",
54 signature_suffix: "",
55 core_import,
56 async_pattern: AsyncPattern::TokioBlockOn,
57 has_serde,
58 type_name_prefix: "",
59 option_duration_on_defaults: true,
60 opaque_type_names: &[],
61 skip_impl_constructor: false,
62 cast_uints_to_i32: false,
63 cast_large_ints_to_f64: false,
64 named_non_opaque_params_by_ref: false,
65 lossy_skip_types: &[],
66 }
67 }
68}
69
70impl Backend for PhpBackend {
71 fn name(&self) -> &str {
72 "php"
73 }
74
75 fn language(&self) -> Language {
76 Language::Php
77 }
78
79 fn capabilities(&self) -> Capabilities {
80 Capabilities {
81 supports_async: false,
82 supports_classes: true,
83 supports_enums: true,
84 supports_option: true,
85 supports_result: true,
86 ..Capabilities::default()
87 }
88 }
89
90 fn generate_bindings(&self, api: &ApiSurface, config: &ResolvedCrateConfig) -> anyhow::Result<Vec<GeneratedFile>> {
91 let data_enum_names: AHashSet<String> = api
93 .enums
94 .iter()
95 .filter(|e| is_tagged_data_enum(e))
96 .map(|e| e.name.clone())
97 .collect();
98 let enum_names: AHashSet<String> = api
99 .enums
100 .iter()
101 .filter(|e| !is_tagged_data_enum(e))
102 .map(|e| e.name.clone())
103 .collect();
104 let mapper = PhpMapper {
105 enum_names: enum_names.clone(),
106 data_enum_names: data_enum_names.clone(),
107 };
108 let core_import = config.core_import_name();
109
110 let php_config = config.php.as_ref();
112 let exclude_functions = php_config.map(|c| c.exclude_functions.clone()).unwrap_or_default();
113 let exclude_types = php_config.map(|c| c.exclude_types.clone()).unwrap_or_default();
114
115 let output_dir = resolve_output_dir(config.output_paths.get("php"), &config.name, "crates/{name}-php/src/");
116 let has_serde = detect_serde_available(&output_dir);
117
118 let bridge_type_aliases_php: Vec<String> = config
124 .trait_bridges
125 .iter()
126 .filter_map(|b| b.type_alias.clone())
127 .collect();
128 let bridge_type_aliases_set: AHashSet<String> = bridge_type_aliases_php.iter().cloned().collect();
129 let mut opaque_names_vec_php: Vec<String> = api
130 .types
131 .iter()
132 .filter(|t| t.is_opaque)
133 .map(|t| t.name.clone())
134 .collect();
135 opaque_names_vec_php.extend(bridge_type_aliases_php);
136
137 let mut cfg = Self::binding_config(&core_import, has_serde);
138 cfg.opaque_type_names = &opaque_names_vec_php;
139
140 let mut builder = RustFileBuilder::new().with_generated_header();
142 builder.add_inner_attribute("allow(dead_code, unused_imports, unused_variables)");
143 builder.add_inner_attribute("allow(unsafe_code)");
144 builder.add_inner_attribute("allow(non_snake_case)");
146 builder.add_inner_attribute("allow(clippy::too_many_arguments, clippy::let_unit_value, clippy::needless_borrow, clippy::map_identity, clippy::just_underscores_and_digits, clippy::unnecessary_cast, clippy::unused_unit, clippy::unwrap_or_default, clippy::derivable_impls, clippy::needless_borrows_for_generic_args, clippy::unnecessary_fallible_conversions, clippy::arc_with_non_send_sync, clippy::collapsible_if, clippy::clone_on_copy)");
147 builder.add_import("ext_php_rs::prelude::*");
148
149 if has_serde {
151 builder.add_import("serde_json");
152 }
153
154 for trait_path in generators::collect_trait_imports(api) {
156 builder.add_import(&trait_path);
157 }
158
159 let has_maps = api.types.iter().any(|t| {
161 t.fields
162 .iter()
163 .any(|f| matches!(&f.ty, alef_core::ir::TypeRef::Map(_, _)))
164 }) || api
165 .functions
166 .iter()
167 .any(|f| matches!(&f.return_type, alef_core::ir::TypeRef::Map(_, _)));
168 if has_maps {
169 builder.add_import("std::collections::HashMap");
170 }
171
172 let custom_mods = config.custom_modules.for_language(Language::Php);
174 for module in custom_mods {
175 builder.add_item(&format!("pub mod {module};"));
176 }
177
178 let has_async =
180 api.functions.iter().any(|f| f.is_async) || api.types.iter().any(|t| t.methods.iter().any(|m| m.is_async));
181
182 if has_async {
183 builder.add_item(&gen_tokio_runtime());
184 }
185
186 let opaque_types: AHashSet<String> = api
188 .types
189 .iter()
190 .filter(|t| t.is_opaque)
191 .map(|t| t.name.clone())
192 .collect();
193 if !opaque_types.is_empty() {
194 builder.add_import("std::sync::Arc");
195 }
196
197 let extension_name = config.php_extension_name();
200 let php_namespace = php_autoload_namespace(config);
201
202 let adapter_bodies = alef_adapters::build_adapter_bodies(config, Language::Php)?;
204
205 for adapter in &config.adapters {
207 match adapter.pattern {
208 alef_core::config::AdapterPattern::Streaming => {
209 let key = format!("{}.__stream_struct__", adapter.item_type.as_deref().unwrap_or(""));
210 if let Some(struct_code) = adapter_bodies.get(&key) {
211 builder.add_item(struct_code);
212 }
213 }
214 alef_core::config::AdapterPattern::CallbackBridge => {
215 let struct_key = format!("{}.__bridge_struct__", adapter.name);
216 let impl_key = format!("{}.__bridge_impl__", adapter.name);
217 if let Some(struct_code) = adapter_bodies.get(&struct_key) {
218 builder.add_item(struct_code);
219 }
220 if let Some(impl_code) = adapter_bodies.get(&impl_key) {
221 builder.add_item(impl_code);
222 }
223 }
224 _ => {}
225 }
226 }
227
228 for typ in api
229 .types
230 .iter()
231 .filter(|typ| !typ.is_trait && !exclude_types.contains(&typ.name))
232 {
233 if typ.is_opaque {
234 let ns_escaped = php_namespace.replace('\\', "\\\\");
238 let php_name_attr = format!("php(name = \"{}\\\\{}\")", ns_escaped, typ.name);
239 let opaque_attr_arr = ["php_class", php_name_attr.as_str()];
240 let opaque_cfg = RustBindingConfig {
241 struct_attrs: &opaque_attr_arr,
242 ..cfg
243 };
244 builder.add_item(&generators::gen_opaque_struct(typ, &opaque_cfg));
245 builder.add_item(&gen_opaque_struct_methods(
246 typ,
247 &mapper,
248 &opaque_types,
249 &core_import,
250 &adapter_bodies,
251 ));
252 } else {
253 builder.add_item(&gen_php_struct(typ, &mapper, &cfg, Some(&php_namespace), &enum_names));
256 builder.add_item(&types::gen_struct_methods_with_exclude(
257 typ,
258 &mapper,
259 has_serde,
260 &core_import,
261 &opaque_types,
262 &enum_names,
263 &api.enums,
264 &exclude_functions,
265 &bridge_type_aliases_set,
266 ));
267 }
268 }
269
270 for enum_def in &api.enums {
271 if is_tagged_data_enum(enum_def) {
272 builder.add_item(&gen_flat_data_enum(enum_def, &mapper, Some(&php_namespace)));
274 builder.add_item(&gen_flat_data_enum_methods(enum_def, &mapper));
275 } else {
276 builder.add_item(&gen_enum_constants(enum_def));
277 }
278 }
279
280 let included_functions: Vec<_> = api
285 .functions
286 .iter()
287 .filter(|f| !exclude_functions.contains(&f.name))
288 .collect();
289 if !included_functions.is_empty() {
290 let facade_class_name = extension_name.to_pascal_case();
291 let mut method_items: Vec<String> = Vec::new();
294 for func in included_functions {
295 let bridge_param = crate::trait_bridge::find_bridge_param(func, &config.trait_bridges);
296 if let Some((param_idx, bridge_cfg)) = bridge_param {
297 method_items.push(crate::trait_bridge::gen_bridge_function(
298 func,
299 param_idx,
300 bridge_cfg,
301 &mapper,
302 &opaque_types,
303 &core_import,
304 ));
305 } else if func.is_async {
306 method_items.push(gen_async_function_as_static_method(
307 func,
308 &mapper,
309 &opaque_types,
310 &core_import,
311 &config.trait_bridges,
312 ));
313 } else {
314 method_items.push(gen_function_as_static_method(
315 func,
316 &mapper,
317 &opaque_types,
318 &core_import,
319 &config.trait_bridges,
320 has_serde,
321 ));
322 }
323 }
324
325 let methods_joined = method_items
326 .iter()
327 .map(|m| {
328 m.lines()
330 .map(|l| {
331 if l.is_empty() {
332 String::new()
333 } else {
334 format!(" {l}")
335 }
336 })
337 .collect::<Vec<_>>()
338 .join("\n")
339 })
340 .collect::<Vec<_>>()
341 .join("\n\n");
342 let php_api_class_name = format!("{facade_class_name}Api");
345 let ns_escaped_facade = php_namespace.replace('\\', "\\\\");
347 let php_name_attr = format!("php(name = \"{}\\\\{}\")", ns_escaped_facade, php_api_class_name);
348 let facade_struct = format!(
349 "#[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}}"
350 );
351 builder.add_item(&facade_struct);
352
353 for bridge_cfg in &config.trait_bridges {
355 if let Some(trait_type) = api.types.iter().find(|t| t.is_trait && t.name == bridge_cfg.trait_name) {
356 let bridge = crate::trait_bridge::gen_trait_bridge(
357 trait_type,
358 bridge_cfg,
359 &core_import,
360 &config.error_type_name(),
361 &config.error_constructor_expr(),
362 api,
363 );
364 for imp in &bridge.imports {
365 builder.add_import(imp);
366 }
367 builder.add_item(&bridge.code);
368 }
369 }
370 }
371
372 let convertible = alef_codegen::conversions::convertible_types(api);
373 let core_to_binding = alef_codegen::conversions::core_to_binding_convertible_types(api);
374 let input_types = alef_codegen::conversions::input_type_names(api);
375 let enum_names_ref = &mapper.enum_names;
380 let bridge_skip_types: Vec<String> = config
381 .trait_bridges
382 .iter()
383 .filter_map(|b| b.type_alias.clone())
384 .collect();
385 let php_conv_config = ConversionConfig {
386 cast_large_ints_to_i64: true,
387 enum_string_names: Some(enum_names_ref),
388 json_to_string: true,
389 include_cfg_metadata: false,
390 option_duration_on_defaults: true,
391 from_binding_skip_types: &bridge_skip_types,
392 ..Default::default()
393 };
394 let mut enum_tainted: AHashSet<String> = AHashSet::new();
396 for typ in api.types.iter().filter(|typ| !typ.is_trait) {
397 if has_enum_named_field(typ, enum_names_ref) {
398 enum_tainted.insert(typ.name.clone());
399 }
400 }
401 let mut changed = true;
403 while changed {
404 changed = false;
405 for typ in api.types.iter().filter(|typ| !typ.is_trait) {
406 if !enum_tainted.contains(&typ.name)
407 && typ.fields.iter().any(|f| references_named_type(&f.ty, &enum_tainted))
408 {
409 enum_tainted.insert(typ.name.clone());
410 changed = true;
411 }
412 }
413 }
414 for typ in api.types.iter().filter(|typ| !typ.is_trait) {
415 if input_types.contains(&typ.name)
417 && !enum_tainted.contains(&typ.name)
418 && alef_codegen::conversions::can_generate_conversion(typ, &convertible)
419 {
420 builder.add_item(&alef_codegen::conversions::gen_from_binding_to_core_cfg(
421 typ,
422 &core_import,
423 &php_conv_config,
424 ));
425 } else if input_types.contains(&typ.name) && enum_tainted.contains(&typ.name) {
426 builder.add_item(&gen_enum_tainted_from_binding_to_core(
433 typ,
434 &core_import,
435 enum_names_ref,
436 &enum_tainted,
437 &php_conv_config,
438 &api.enums,
439 &bridge_type_aliases_set,
440 ));
441 }
442 if alef_codegen::conversions::can_generate_conversion(typ, &core_to_binding) {
444 builder.add_item(&alef_codegen::conversions::gen_from_core_to_binding_cfg(
445 typ,
446 &core_import,
447 &opaque_types,
448 &php_conv_config,
449 ));
450 }
451 }
452
453 for enum_def in api.enums.iter().filter(|e| is_tagged_data_enum(e)) {
455 builder.add_item(&gen_flat_data_enum_from_impls(enum_def, &core_import));
456 }
457
458 for error in &api.errors {
460 builder.add_item(&alef_codegen::error_gen::gen_php_error_converter(error, &core_import));
461 }
462
463 if has_serde {
466 builder.add_item("mod serde_defaults {\n pub fn bool_true() -> bool { true }\n}");
467 }
468
469 let php_config = config.php.as_ref();
475 builder.add_inner_attribute("cfg_attr(windows, feature(abi_vectorcall))");
476
477 if let Some(feature_name) = php_config.and_then(|c| c.feature_gate.as_deref()) {
481 builder.add_inner_attribute(&format!("cfg(feature = \"{feature_name}\")"));
482 }
483
484 let mut class_registrations = String::new();
487 for typ in api
488 .types
489 .iter()
490 .filter(|typ| !typ.is_trait && !exclude_types.contains(&typ.name))
491 {
492 class_registrations.push_str(&crate::template_env::render(
493 "php_class_registration.jinja",
494 context! { class_name => &typ.name },
495 ));
496 }
497 if !api.functions.is_empty() {
499 let facade_class_name = extension_name.to_pascal_case();
500 class_registrations.push_str(&crate::template_env::render(
501 "php_class_registration.jinja",
502 context! { class_name => &format!("{facade_class_name}Api") },
503 ));
504 }
505 for enum_def in api.enums.iter().filter(|e| is_tagged_data_enum(e)) {
508 class_registrations.push_str(&crate::template_env::render(
509 "php_class_registration.jinja",
510 context! { class_name => &enum_def.name },
511 ));
512 }
513 builder.add_item(&format!(
514 "#[php_module]\npub fn get_module(module: ModuleBuilder) -> ModuleBuilder {{\n module{class_registrations}\n}}"
515 ));
516
517 let mut content = builder.build();
518
519 for bridge in &config.trait_bridges {
524 if let Some(field_name) = bridge.resolved_options_field() {
525 let param_name = bridge.param_name.as_deref().unwrap_or(field_name);
526 let type_alias = bridge.type_alias.as_deref().unwrap_or("VisitorHandle");
527 let options_type = bridge.options_type.as_deref().unwrap_or("ConversionOptions");
528 let builder_type = format!("{}Builder", options_type);
529 let bridge_struct = format!("Php{}Bridge", bridge.trait_name);
530
531 let old_method = format!(
537 " pub fn {field_name}(&self, {param_name}: Option<&{type_alias}>) -> {builder_type} {{\n Self {{ inner: Arc::new((*self.inner).clone().{field_name}({param_name}.as_ref().map(|v| &v.inner))) }}\n }}"
538 );
539 let new_method = format!(
540 " pub fn {field_name}(&self, {param_name}: &mut ext_php_rs::types::ZendObject) -> {builder_type} {{\n let bridge = {bridge_struct}::new({param_name});\n let handle: html_to_markdown_rs::visitor::VisitorHandle = std::rc::Rc::new(std::cell::RefCell::new(bridge));\n Self {{ inner: Arc::new((*self.inner).clone().{field_name}(Some(handle))) }}\n }}"
541 );
542
543 content = content.replace(&old_method, &new_method);
544 }
545 }
546
547 Ok(vec![GeneratedFile {
548 path: PathBuf::from(&output_dir).join("lib.rs"),
549 content,
550 generated_header: false,
551 }])
552 }
553
554 fn generate_public_api(
555 &self,
556 api: &ApiSurface,
557 config: &ResolvedCrateConfig,
558 ) -> anyhow::Result<Vec<GeneratedFile>> {
559 let extension_name = config.php_extension_name();
560 let class_name = extension_name.to_pascal_case();
561
562 let mut content = String::new();
564 content.push_str(&crate::template_env::render(
565 "php_file_header.jinja",
566 minijinja::Value::default(),
567 ));
568 content.push_str(&hash::header(CommentStyle::DoubleSlash));
569 content.push_str(&crate::template_env::render(
570 "php_declare_strict_types.jinja",
571 minijinja::Value::default(),
572 ));
573
574 let namespace = php_autoload_namespace(config);
576
577 content.push_str(&crate::template_env::render(
578 "php_namespace.jinja",
579 context! { namespace => &namespace },
580 ));
581 content.push_str(&crate::template_env::render(
582 "php_facade_class_declaration.jinja",
583 context! { class_name => &class_name },
584 ));
585
586 let bridge_param_names_pub: ahash::AHashSet<&str> = config
588 .trait_bridges
589 .iter()
590 .filter_map(|b| b.param_name.as_deref())
591 .collect();
592
593 let no_arg_constructor_types: AHashSet<String> = api
598 .types
599 .iter()
600 .filter(|t| t.fields.iter().all(|f| f.optional))
601 .map(|t| t.name.clone())
602 .collect();
603
604 for func in &api.functions {
606 let method_name = func.name.to_lower_camel_case();
612 let return_php_type = php_type(&func.return_type);
613
614 let visible_params: Vec<_> = func
616 .params
617 .iter()
618 .filter(|p| !bridge_param_names_pub.contains(p.name.as_str()))
619 .collect();
620
621 content.push_str(&crate::template_env::render(
623 "php_phpdoc_block_start.jinja",
624 minijinja::Value::default(),
625 ));
626 for line in func.doc.lines() {
627 if line.is_empty() {
628 content.push_str(&crate::template_env::render(
629 "php_phpdoc_empty_line.jinja",
630 minijinja::Value::default(),
631 ));
632 } else {
633 content.push_str(&crate::template_env::render(
634 "php_phpdoc_text_line.jinja",
635 context! { text => line },
636 ));
637 }
638 }
639 if func.doc.is_empty() {
640 content.push_str(&crate::template_env::render(
641 "php_phpdoc_text_line.jinja",
642 context! { text => &format!("{}.", method_name) },
643 ));
644 }
645 content.push_str(&crate::template_env::render(
646 "php_phpdoc_empty_line.jinja",
647 minijinja::Value::default(),
648 ));
649 for p in &visible_params {
650 let ptype = php_phpdoc_type(&p.ty);
651 let nullable_prefix = if p.optional { "?" } else { "" };
652 content.push_str(&crate::template_env::render(
653 "php_phpdoc_param_line.jinja",
654 context! {
655 nullable_prefix => nullable_prefix,
656 param_type => &ptype,
657 param_name => &p.name,
658 },
659 ));
660 }
661 let return_phpdoc = php_phpdoc_type(&func.return_type);
662 content.push_str(&crate::template_env::render(
663 "php_phpdoc_return_line.jinja",
664 context! { return_type => &return_phpdoc },
665 ));
666 if func.error_type.is_some() {
667 content.push_str(&crate::template_env::render(
668 "php_phpdoc_throws_line.jinja",
669 context! {
670 namespace => namespace.as_str(),
671 class_name => &class_name,
672 },
673 ));
674 }
675 content.push_str(&crate::template_env::render(
676 "php_phpdoc_block_end.jinja",
677 minijinja::Value::default(),
678 ));
679
680 let is_optional_config_param = |p: &alef_core::ir::ParamDef| -> bool {
691 if let TypeRef::Named(name) = &p.ty {
692 (name.ends_with("Config") || name.as_str() == "config")
693 && no_arg_constructor_types.contains(name.as_str())
694 } else {
695 false
696 }
697 };
698
699 let mut first_optional_idx = None;
700 for (idx, p) in visible_params.iter().enumerate() {
701 if p.optional || is_optional_config_param(p) {
702 first_optional_idx = Some(idx);
703 break;
704 }
705 }
706
707 content.push_str(&crate::template_env::render(
708 "php_method_signature_start.jinja",
709 context! { method_name => &method_name },
710 ));
711
712 let params: Vec<String> = visible_params
713 .iter()
714 .enumerate()
715 .map(|(idx, p)| {
716 let ptype = php_type(&p.ty);
717 let should_be_optional = p.optional
722 || is_optional_config_param(p)
723 || first_optional_idx.is_some_and(|first| idx >= first);
724 if should_be_optional {
725 format!("?{} ${} = null", ptype, p.name)
726 } else {
727 format!("{} ${}", ptype, p.name)
728 }
729 })
730 .collect();
731 content.push_str(¶ms.join(", "));
732 content.push_str(&crate::template_env::render(
733 "php_method_signature_end.jinja",
734 context! { return_type => &return_php_type },
735 ));
736 let ext_method_name = if func.is_async {
741 format!("{}_async", func.name).to_lower_camel_case()
742 } else {
743 func.name.to_lower_camel_case()
744 };
745 let is_void = matches!(&func.return_type, TypeRef::Unit);
746 let call_params = visible_params
754 .iter()
755 .enumerate()
756 .map(|(idx, p)| {
757 let should_be_optional = p.optional
758 || is_optional_config_param(p)
759 || first_optional_idx.is_some_and(|first| idx >= first);
760 if should_be_optional && is_optional_config_param(p) {
761 if let TypeRef::Named(type_name) = &p.ty {
762 return format!("${} ?? new {}()", p.name, type_name);
763 }
764 }
765 format!("${}", p.name)
766 })
767 .collect::<Vec<_>>()
768 .join(", ");
769 let call_expr = format!("\\{namespace}\\{class_name}Api::{ext_method_name}({call_params})");
770 if is_void {
771 content.push_str(&crate::template_env::render(
772 "php_method_call_statement.jinja",
773 context! { call_expr => &call_expr },
774 ));
775 } else {
776 content.push_str(&crate::template_env::render(
777 "php_method_call_return.jinja",
778 context! { call_expr => &call_expr },
779 ));
780 }
781 content.push_str(&crate::template_env::render(
782 "php_method_end.jinja",
783 minijinja::Value::default(),
784 ));
785 }
786
787 content.push_str(&crate::template_env::render(
788 "php_class_end.jinja",
789 minijinja::Value::default(),
790 ));
791
792 let output_dir = config
796 .php
797 .as_ref()
798 .and_then(|p| p.stubs.as_ref())
799 .map(|s| s.output.to_string_lossy().to_string())
800 .unwrap_or_else(|| "packages/php/src/".to_string());
801
802 Ok(vec![GeneratedFile {
803 path: PathBuf::from(&output_dir).join(format!("{}.php", class_name)),
804 content,
805 generated_header: false,
806 }])
807 }
808
809 fn generate_type_stubs(
810 &self,
811 api: &ApiSurface,
812 config: &ResolvedCrateConfig,
813 ) -> anyhow::Result<Vec<GeneratedFile>> {
814 let extension_name = config.php_extension_name();
815 let class_name = extension_name.to_pascal_case();
816
817 let namespace = php_autoload_namespace(config);
819
820 let mut content = String::new();
825 content.push_str(&crate::template_env::render(
826 "php_file_header.jinja",
827 minijinja::Value::default(),
828 ));
829 content.push_str(&hash::header(CommentStyle::DoubleSlash));
830 content.push_str("// Type stubs for the native PHP extension — declares classes\n");
831 content.push_str("// provided at runtime by the compiled Rust extension (.so/.dll).\n");
832 content.push_str("// Include this in phpstan.neon scanFiles for static analysis.\n\n");
833 content.push_str(&crate::template_env::render(
834 "php_declare_strict_types.jinja",
835 minijinja::Value::default(),
836 ));
837 content.push_str(&crate::template_env::render(
839 "php_namespace_block_begin.jinja",
840 context! { namespace => &namespace },
841 ));
842
843 content.push_str(&crate::template_env::render(
845 "php_exception_class_declaration.jinja",
846 context! { class_name => &class_name },
847 ));
848 content.push_str(
849 " public function getErrorCode(): int { throw new \\RuntimeException('Not implemented.'); }\n",
850 );
851 content.push_str("}\n\n");
852
853 for typ in api.types.iter().filter(|typ| !typ.is_trait) {
855 if typ.is_opaque {
856 if !typ.doc.is_empty() {
857 content.push_str("/**\n");
858 for line in typ.doc.lines() {
859 if line.is_empty() {
860 content.push_str(" *\n");
861 } else {
862 content.push_str(&crate::template_env::render(
863 "php_phpdoc_doc_line.jinja",
864 context! { line => line },
865 ));
866 }
867 }
868 content.push_str(" */\n");
869 }
870 content.push_str(&crate::template_env::render(
871 "php_opaque_class_stub_declaration.jinja",
872 context! { class_name => &typ.name },
873 ));
874 content.push_str("}\n\n");
876 }
877 }
878
879 for typ in api.types.iter().filter(|typ| !typ.is_trait) {
881 if typ.is_opaque || typ.fields.is_empty() {
882 continue;
883 }
884 if !typ.doc.is_empty() {
885 content.push_str("/**\n");
886 for line in typ.doc.lines() {
887 if line.is_empty() {
888 content.push_str(" *\n");
889 } else {
890 content.push_str(&crate::template_env::render(
891 "php_phpdoc_doc_line.jinja",
892 context! { line => line },
893 ));
894 }
895 }
896 content.push_str(" */\n");
897 }
898 content.push_str(&crate::template_env::render(
899 "php_record_class_stub_declaration.jinja",
900 context! { class_name => &typ.name },
901 ));
902
903 for field in &typ.fields {
905 let is_array = matches!(&field.ty, TypeRef::Vec(_) | TypeRef::Map(_, _));
906 let prop_type = if field.optional {
907 let inner = php_type(&field.ty);
908 if inner.starts_with('?') {
909 inner
910 } else {
911 format!("?{inner}")
912 }
913 } else {
914 php_type(&field.ty)
915 };
916 if is_array {
917 let phpdoc = php_phpdoc_type(&field.ty);
918 let nullable_prefix = if field.optional { "?" } else { "" };
919 content.push_str(&crate::template_env::render(
920 "php_property_type_annotation.jinja",
921 context! {
922 nullable_prefix => nullable_prefix,
923 phpdoc => &phpdoc,
924 },
925 ));
926 }
927 content.push_str(&crate::template_env::render(
928 "php_property_stub.jinja",
929 context! {
930 prop_type => &prop_type,
931 field_name => &field.name,
932 },
933 ));
934 }
935 content.push('\n');
936
937 let mut sorted_fields: Vec<&alef_core::ir::FieldDef> = typ.fields.iter().collect();
941 sorted_fields.sort_by_key(|f| f.optional);
942
943 let array_fields: Vec<&alef_core::ir::FieldDef> = sorted_fields
946 .iter()
947 .copied()
948 .filter(|f| matches!(&f.ty, TypeRef::Vec(_) | TypeRef::Map(_, _)))
949 .collect();
950 if !array_fields.is_empty() {
951 content.push_str(" /**\n");
952 for f in &array_fields {
953 let phpdoc = php_phpdoc_type(&f.ty);
954 let nullable_prefix = if f.optional { "?" } else { "" };
955 content.push_str(&crate::template_env::render(
956 "php_phpdoc_array_param.jinja",
957 context! {
958 nullable_prefix => nullable_prefix,
959 phpdoc => &phpdoc,
960 param_name => &f.name,
961 },
962 ));
963 }
964 content.push_str(" */\n");
965 }
966
967 let params: Vec<String> = sorted_fields
968 .iter()
969 .map(|f| {
970 let ptype = php_type(&f.ty);
971 let nullable = if f.optional && !ptype.starts_with('?') {
972 format!("?{ptype}")
973 } else {
974 ptype
975 };
976 let default = if f.optional { " = null" } else { "" };
977 format!(" {} ${}{}", nullable, f.name, default)
978 })
979 .collect();
980 content.push_str(&crate::template_env::render(
981 "php_constructor_method.jinja",
982 context! { params => ¶ms.join(",\n") },
983 ));
984
985 for field in &typ.fields {
987 let is_array = matches!(&field.ty, TypeRef::Vec(_) | TypeRef::Map(_, _));
988 let return_type = if field.optional {
989 let inner = php_type(&field.ty);
990 if inner.starts_with('?') {
991 inner
992 } else {
993 format!("?{inner}")
994 }
995 } else {
996 php_type(&field.ty)
997 };
998 let getter_name = field.name.to_lower_camel_case();
999 if is_array {
1001 let phpdoc = php_phpdoc_type(&field.ty);
1002 let nullable_prefix = if field.optional { "?" } else { "" };
1003 content.push_str(&crate::template_env::render(
1004 "php_constructor_doc_return.jinja",
1005 context! { return_type => &format!("{nullable_prefix}{phpdoc}") },
1006 ));
1007 }
1008 let is_void_getter = return_type == "void";
1009 let getter_body = if is_void_getter {
1010 "{ }".to_string()
1011 } else {
1012 "{ throw new \\RuntimeException('Not implemented.'); }".to_string()
1013 };
1014 content.push_str(&crate::template_env::render(
1015 "php_getter_stub.jinja",
1016 context! {
1017 getter_name => &format!("get{}", getter_name.to_pascal_case()),
1018 return_type => &return_type,
1019 getter_body => &getter_body,
1020 },
1021 ));
1022 }
1023
1024 content.push_str("}\n\n");
1025 }
1026
1027 for enum_def in &api.enums {
1030 if is_tagged_data_enum(enum_def) {
1031 if !enum_def.doc.is_empty() {
1033 content.push_str("/**\n");
1034 for line in enum_def.doc.lines() {
1035 if line.is_empty() {
1036 content.push_str(" *\n");
1037 } else {
1038 content.push_str(&crate::template_env::render(
1039 "php_phpdoc_doc_line.jinja",
1040 context! { line => line },
1041 ));
1042 }
1043 }
1044 content.push_str(" */\n");
1045 }
1046 content.push_str(&crate::template_env::render(
1047 "php_record_class_stub_declaration.jinja",
1048 context! { class_name => &enum_def.name },
1049 ));
1050 content.push_str("}\n\n");
1051 } else {
1052 content.push_str(&crate::template_env::render(
1054 "php_tagged_enum_declaration.jinja",
1055 context! { enum_name => &enum_def.name },
1056 ));
1057 for variant in &enum_def.variants {
1058 let case_name = sanitize_php_enum_case(&variant.name);
1059 content.push_str(&crate::template_env::render(
1060 "php_enum_variant_stub.jinja",
1061 context! {
1062 variant_name => case_name,
1063 value => &variant.name,
1064 },
1065 ));
1066 }
1067 content.push_str("}\n\n");
1068 }
1069 }
1070
1071 if !api.functions.is_empty() {
1076 let bridge_param_names_stubs: ahash::AHashSet<&str> = config
1078 .trait_bridges
1079 .iter()
1080 .filter_map(|b| b.param_name.as_deref())
1081 .collect();
1082
1083 content.push_str(&crate::template_env::render(
1084 "php_api_class_declaration.jinja",
1085 context! { class_name => &class_name },
1086 ));
1087 for func in &api.functions {
1088 let return_type = php_type_fq(&func.return_type, &namespace);
1089 let return_phpdoc = php_phpdoc_type_fq(&func.return_type, &namespace);
1090 let visible_params: Vec<_> = func
1092 .params
1093 .iter()
1094 .filter(|p| !bridge_param_names_stubs.contains(p.name.as_str()))
1095 .collect();
1096 let has_array_params = visible_params
1103 .iter()
1104 .any(|p| matches!(&p.ty, TypeRef::Vec(_) | TypeRef::Map(_, _)));
1105 let has_array_return = matches!(&func.return_type, TypeRef::Vec(_) | TypeRef::Map(_, _))
1106 || matches!(&func.return_type, TypeRef::Optional(inner) if matches!(inner.as_ref(), TypeRef::Vec(_) | TypeRef::Map(_, _)));
1107 if has_array_params || has_array_return {
1108 content.push_str(" /**\n");
1109 for p in &visible_params {
1110 let ptype = php_phpdoc_type_fq(&p.ty, &namespace);
1111 let nullable_prefix = if p.optional { "?" } else { "" };
1112 content.push_str(&crate::template_env::render(
1113 "php_phpdoc_static_param.jinja",
1114 context! {
1115 nullable_prefix => nullable_prefix,
1116 ptype => &ptype,
1117 param_name => &p.name,
1118 },
1119 ));
1120 }
1121 content.push_str(&crate::template_env::render(
1122 "php_phpdoc_static_return.jinja",
1123 context! { return_phpdoc => &return_phpdoc },
1124 ));
1125 content.push_str(" */\n");
1126 }
1127 let params: Vec<String> = visible_params
1128 .iter()
1129 .map(|p| {
1130 let ptype = php_type_fq(&p.ty, &namespace);
1131 if p.optional {
1132 format!("?{} ${} = null", ptype, p.name)
1133 } else {
1134 format!("{} ${}", ptype, p.name)
1135 }
1136 })
1137 .collect();
1138 let stub_method_name = if func.is_async {
1140 format!("{}_async", func.name).to_lower_camel_case()
1141 } else {
1142 func.name.to_lower_camel_case()
1143 };
1144 let is_void_stub = return_type == "void";
1145 let stub_body = if is_void_stub {
1146 "{ }".to_string()
1147 } else {
1148 "{ throw new \\RuntimeException('Not implemented.'); }".to_string()
1149 };
1150 content.push_str(&crate::template_env::render(
1151 "php_static_method_stub.jinja",
1152 context! {
1153 method_name => &stub_method_name,
1154 params => ¶ms.join(", "),
1155 return_type => &return_type,
1156 stub_body => &stub_body,
1157 },
1158 ));
1159 }
1160 content.push_str("}\n\n");
1161 }
1162
1163 content.push_str(&crate::template_env::render(
1165 "php_namespace_block_end.jinja",
1166 minijinja::Value::default(),
1167 ));
1168
1169 let output_dir = config
1171 .php
1172 .as_ref()
1173 .and_then(|p| p.stubs.as_ref())
1174 .map(|s| s.output.to_string_lossy().to_string())
1175 .unwrap_or_else(|| "packages/php/stubs/".to_string());
1176
1177 Ok(vec![GeneratedFile {
1178 path: PathBuf::from(&output_dir).join(format!("{}_extension.php", extension_name)),
1179 content,
1180 generated_header: false,
1181 }])
1182 }
1183
1184 fn build_config(&self) -> Option<BuildConfig> {
1185 Some(BuildConfig {
1186 tool: "cargo",
1187 crate_suffix: "-php",
1188 build_dep: BuildDependency::None,
1189 post_build: vec![],
1190 })
1191 }
1192}
1193
1194fn php_phpdoc_type(ty: &TypeRef) -> String {
1197 match ty {
1198 TypeRef::Vec(inner) => format!("array<{}>", php_phpdoc_type(inner)),
1199 TypeRef::Map(k, v) => format!("array<{}, {}>", php_phpdoc_type(k), php_phpdoc_type(v)),
1200 TypeRef::Optional(inner) => format!("?{}", php_phpdoc_type(inner)),
1201 _ => php_type(ty),
1202 }
1203}
1204
1205fn php_phpdoc_type_fq(ty: &TypeRef, namespace: &str) -> String {
1207 match ty {
1208 TypeRef::Vec(inner) => format!("array<{}>", php_phpdoc_type_fq(inner, namespace)),
1209 TypeRef::Map(k, v) => format!(
1210 "array<{}, {}>",
1211 php_phpdoc_type_fq(k, namespace),
1212 php_phpdoc_type_fq(v, namespace)
1213 ),
1214 TypeRef::Named(name) => format!("\\{}\\{}", namespace, name),
1215 TypeRef::Optional(inner) => format!("?{}", php_phpdoc_type_fq(inner, namespace)),
1216 _ => php_type(ty),
1217 }
1218}
1219
1220fn php_type_fq(ty: &TypeRef, namespace: &str) -> String {
1222 match ty {
1223 TypeRef::Named(name) => format!("\\{}\\{}", namespace, name),
1224 TypeRef::Optional(inner) => {
1225 let inner_type = php_type_fq(inner, namespace);
1226 if inner_type.starts_with('?') {
1227 inner_type
1228 } else {
1229 format!("?{inner_type}")
1230 }
1231 }
1232 _ => php_type(ty),
1233 }
1234}
1235
1236fn php_type(ty: &TypeRef) -> String {
1238 match ty {
1239 TypeRef::String | TypeRef::Char | TypeRef::Json | TypeRef::Bytes | TypeRef::Path => "string".to_string(),
1240 TypeRef::Primitive(p) => match p {
1241 PrimitiveType::Bool => "bool".to_string(),
1242 PrimitiveType::F32 | PrimitiveType::F64 => "float".to_string(),
1243 PrimitiveType::U8
1244 | PrimitiveType::U16
1245 | PrimitiveType::U32
1246 | PrimitiveType::U64
1247 | PrimitiveType::I8
1248 | PrimitiveType::I16
1249 | PrimitiveType::I32
1250 | PrimitiveType::I64
1251 | PrimitiveType::Usize
1252 | PrimitiveType::Isize => "int".to_string(),
1253 },
1254 TypeRef::Optional(inner) => {
1255 let inner_type = php_type(inner);
1258 if inner_type.starts_with('?') {
1259 inner_type
1260 } else {
1261 format!("?{inner_type}")
1262 }
1263 }
1264 TypeRef::Vec(_) | TypeRef::Map(_, _) => "array".to_string(),
1265 TypeRef::Named(name) => name.clone(),
1266 TypeRef::Unit => "void".to_string(),
1267 TypeRef::Duration => "float".to_string(),
1268 }
1269}