1use crate::config::E2eConfig;
8use crate::escape::{escape_php, sanitize_filename};
9use crate::field_access::FieldResolver;
10use crate::fixture::{Assertion, Fixture, FixtureGroup};
11use alef_core::backend::GeneratedFile;
12use alef_core::config::AlefConfig;
13use anyhow::Result;
14use heck::{ToSnakeCase, ToUpperCamelCase};
15use std::collections::HashMap;
16use std::fmt::Write as FmtWrite;
17use std::path::PathBuf;
18
19use super::E2eCodegen;
20
21pub struct PhpCodegen;
23
24impl E2eCodegen for PhpCodegen {
25 fn generate(
26 &self,
27 groups: &[FixtureGroup],
28 e2e_config: &E2eConfig,
29 alef_config: &AlefConfig,
30 ) -> Result<Vec<GeneratedFile>> {
31 let lang = self.language_name();
32 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
33
34 let mut files = Vec::new();
35
36 let call = &e2e_config.call;
38 let overrides = call.overrides.get(lang);
39 let function_name = overrides
40 .and_then(|o| o.function.as_ref())
41 .cloned()
42 .unwrap_or_else(|| call.function.clone());
43 let class_name = overrides
44 .and_then(|o| o.class.as_ref())
45 .cloned()
46 .unwrap_or_else(|| alef_config.crate_config.name.to_upper_camel_case());
47 let namespace = overrides.and_then(|o| o.module.as_ref()).cloned().unwrap_or_else(|| {
48 if call.module.is_empty() {
49 "Kreuzberg".to_string()
50 } else {
51 call.module.to_upper_camel_case()
52 }
53 });
54 let empty_enum_fields = HashMap::new();
55 let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields);
56 let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
57 let result_var = &call.result_var;
58
59 let php_pkg = e2e_config.resolve_package("php");
61 let pkg_name = php_pkg
62 .as_ref()
63 .and_then(|p| p.name.as_ref())
64 .cloned()
65 .unwrap_or_else(|| format!("kreuzberg/{}", call.module.replace('_', "-")));
66 let pkg_path = php_pkg
67 .as_ref()
68 .and_then(|p| p.path.as_ref())
69 .cloned()
70 .unwrap_or_else(|| "../../packages/php".to_string());
71 let pkg_version = php_pkg
72 .as_ref()
73 .and_then(|p| p.version.as_ref())
74 .cloned()
75 .unwrap_or_else(|| "0.1.0".to_string());
76
77 files.push(GeneratedFile {
79 path: output_base.join("composer.json"),
80 content: render_composer_json(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
81 generated_header: false,
82 });
83
84 files.push(GeneratedFile {
86 path: output_base.join("phpunit.xml"),
87 content: render_phpunit_xml(),
88 generated_header: false,
89 });
90
91 files.push(GeneratedFile {
93 path: output_base.join("bootstrap.php"),
94 content: render_bootstrap(&pkg_path),
95 generated_header: true,
96 });
97
98 let tests_base = output_base.join("tests");
100 let field_resolver = FieldResolver::new(
101 &e2e_config.fields,
102 &e2e_config.fields_optional,
103 &e2e_config.result_fields,
104 &e2e_config.fields_array,
105 );
106
107 for group in groups {
108 let active: Vec<&Fixture> = group
109 .fixtures
110 .iter()
111 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
112 .collect();
113
114 if active.is_empty() {
115 continue;
116 }
117
118 let test_class = format!("{}Test", sanitize_filename(&group.category).to_upper_camel_case());
119 let filename = format!("{test_class}.php");
120 let content = render_test_file(
121 &group.category,
122 &active,
123 &namespace,
124 &class_name,
125 &function_name,
126 result_var,
127 &test_class,
128 &e2e_config.call.args,
129 &field_resolver,
130 enum_fields,
131 result_is_simple,
132 );
133 files.push(GeneratedFile {
134 path: tests_base.join(filename),
135 content,
136 generated_header: true,
137 });
138 }
139
140 Ok(files)
141 }
142
143 fn language_name(&self) -> &'static str {
144 "php"
145 }
146}
147
148fn render_composer_json(
153 pkg_name: &str,
154 _pkg_path: &str,
155 pkg_version: &str,
156 dep_mode: crate::config::DependencyMode,
157) -> String {
158 let require_section = match dep_mode {
159 crate::config::DependencyMode::Registry => {
160 format!(
161 r#" "require": {{
162 "{pkg_name}": "{pkg_version}"
163 }},
164 "require-dev": {{
165 "phpunit/phpunit": "^11.0"
166 }},"#
167 )
168 }
169 crate::config::DependencyMode::Local => r#" "require-dev": {
170 "phpunit/phpunit": "^11.0"
171 },"#
172 .to_string(),
173 };
174
175 format!(
176 r#"{{
177 "name": "kreuzberg/e2e-php",
178 "description": "E2e tests for PHP bindings",
179 "type": "project",
180{require_section}
181 "autoload-dev": {{
182 "psr-4": {{
183 "Kreuzberg\\E2e\\": "tests/"
184 }}
185 }}
186}}
187"#
188 )
189}
190
191fn render_phpunit_xml() -> String {
192 r#"<?xml version="1.0" encoding="UTF-8"?>
193<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
194 xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.0/phpunit.xsd"
195 bootstrap="bootstrap.php"
196 colors="true"
197 failOnRisky="true"
198 failOnWarning="true">
199 <testsuites>
200 <testsuite name="e2e">
201 <directory>tests</directory>
202 </testsuite>
203 </testsuites>
204</phpunit>
205"#
206 .to_string()
207}
208
209fn render_bootstrap(pkg_path: &str) -> String {
210 format!(
211 r#"<?php
212// This file is auto-generated by alef. DO NOT EDIT.
213
214declare(strict_types=1);
215
216// Load the e2e project autoloader (PHPUnit, test helpers).
217require_once __DIR__ . '/vendor/autoload.php';
218
219// Load the PHP binding package classes via its Composer autoloader.
220// The package's autoloader is separate from the e2e project's autoloader
221// since the php-ext type prevents direct composer path dependency.
222$pkgAutoloader = __DIR__ . '/{pkg_path}/vendor/autoload.php';
223if (file_exists($pkgAutoloader)) {{
224 require_once $pkgAutoloader;
225}}
226"#
227 )
228}
229
230#[allow(clippy::too_many_arguments)]
231fn render_test_file(
232 category: &str,
233 fixtures: &[&Fixture],
234 namespace: &str,
235 class_name: &str,
236 function_name: &str,
237 result_var: &str,
238 test_class: &str,
239 args: &[crate::config::ArgMapping],
240 field_resolver: &FieldResolver,
241 enum_fields: &HashMap<String, String>,
242 result_is_simple: bool,
243) -> String {
244 let mut out = String::new();
245 let _ = writeln!(out, "<?php");
246 let _ = writeln!(out, "// This file is auto-generated by alef. DO NOT EDIT.");
247 let _ = writeln!(out);
248 let _ = writeln!(out, "declare(strict_types=1);");
249 let _ = writeln!(out);
250 let _ = writeln!(out, "namespace Kreuzberg\\E2e;");
251 let _ = writeln!(out);
252 let needs_crawl_config_import = fixtures.iter().any(|f| {
254 args.iter().filter(|a| a.arg_type == "handle").any(|a| {
255 let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
256 !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
257 })
258 });
259
260 let _ = writeln!(out, "use PHPUnit\\Framework\\TestCase;");
261 if !result_is_simple {
262 let _ = writeln!(out, "use {namespace}\\{class_name};");
263 }
264 if needs_crawl_config_import {
265 let _ = writeln!(out, "use {namespace}\\CrawlConfig;");
266 }
267 let _ = writeln!(out);
268 let _ = writeln!(out, "/** E2e tests for category: {category}. */");
269 let _ = writeln!(out, "final class {test_class} extends TestCase");
270 let _ = writeln!(out, "{{");
271
272 for (i, fixture) in fixtures.iter().enumerate() {
273 render_test_method(
274 &mut out,
275 fixture,
276 class_name,
277 function_name,
278 result_var,
279 args,
280 field_resolver,
281 enum_fields,
282 result_is_simple,
283 );
284 if i + 1 < fixtures.len() {
285 let _ = writeln!(out);
286 }
287 }
288
289 let _ = writeln!(out, "}}");
290 out
291}
292
293#[allow(clippy::too_many_arguments)]
294fn render_test_method(
295 out: &mut String,
296 fixture: &Fixture,
297 class_name: &str,
298 function_name: &str,
299 result_var: &str,
300 args: &[crate::config::ArgMapping],
301 field_resolver: &FieldResolver,
302 enum_fields: &HashMap<String, String>,
303 result_is_simple: bool,
304) {
305 let method_name = sanitize_filename(&fixture.id);
306 let description = &fixture.description;
307 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
308
309 let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, class_name, enum_fields, &fixture.id);
310
311 let call_expr = format!("{class_name}::{function_name}({args_str})");
312
313 let _ = writeln!(out, " /** {description} */");
314 let _ = writeln!(out, " public function test_{method_name}(): void");
315 let _ = writeln!(out, " {{");
316
317 for line in &setup_lines {
318 let _ = writeln!(out, " {line}");
319 }
320
321 if expects_error {
322 let _ = writeln!(out, " $this->expectException(\\Exception::class);");
323 let _ = writeln!(out, " {call_expr};");
324 let _ = writeln!(out, " }}");
325 return;
326 }
327
328 let _ = writeln!(out, " ${result_var} = {call_expr};");
329
330 for assertion in &fixture.assertions {
331 render_assertion(out, assertion, result_var, field_resolver, result_is_simple);
332 }
333
334 let _ = writeln!(out, " }}");
335}
336
337fn build_args_and_setup(
341 input: &serde_json::Value,
342 args: &[crate::config::ArgMapping],
343 class_name: &str,
344 enum_fields: &HashMap<String, String>,
345 fixture_id: &str,
346) -> (Vec<String>, String) {
347 if args.is_empty() {
348 return (Vec::new(), json_to_php(input));
349 }
350
351 let mut setup_lines: Vec<String> = Vec::new();
352 let mut parts: Vec<String> = Vec::new();
353
354 for arg in args {
355 if arg.arg_type == "mock_url" {
356 setup_lines.push(format!(
357 "${} = getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}';",
358 arg.name,
359 ));
360 parts.push(format!("${}", arg.name));
361 continue;
362 }
363
364 if arg.arg_type == "handle" {
365 let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
367 let config_value = input.get(&arg.field).unwrap_or(&serde_json::Value::Null);
368 if config_value.is_null()
369 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
370 {
371 setup_lines.push(format!("${} = {class_name}::{constructor_name}(null);", arg.name,));
372 } else {
373 let name = &arg.name;
374 let has_complex = config_value
378 .as_object()
379 .is_some_and(|obj| obj.values().any(|v| v.is_object() || v.is_array()));
380 if has_complex {
381 let json_str = serde_json::to_string(config_value).unwrap_or_default();
382 let escaped = json_str.replace('\'', "\\'");
383 setup_lines.push(format!(
384 "${} = {class_name}::createEngineFromJson('{escaped}');",
385 arg.name,
386 ));
387 } else {
388 setup_lines.push(format!("${name}_config = CrawlConfig::default();"));
389 if let Some(obj) = config_value.as_object() {
390 for (key, val) in obj {
391 let php_val = json_to_php(val);
392 setup_lines.push(format!("${name}_config->{key} = {php_val};"));
393 }
394 }
395 setup_lines.push(format!(
396 "${} = {class_name}::{constructor_name}(${name}_config);",
397 arg.name,
398 name = name,
399 ));
400 }
401 }
402 parts.push(format!("${}", arg.name));
403 continue;
404 }
405
406 let val = input.get(&arg.field);
407 match val {
408 None | Some(serde_json::Value::Null) if arg.optional => {
409 continue;
411 }
412 None | Some(serde_json::Value::Null) => {
413 let default_val = match arg.arg_type.as_str() {
415 "string" => "\"\"".to_string(),
416 "int" | "integer" => "0".to_string(),
417 "float" | "number" => "0.0".to_string(),
418 "bool" | "boolean" => "false".to_string(),
419 _ => "null".to_string(),
420 };
421 parts.push(default_val);
422 }
423 Some(v) => {
424 if arg.arg_type == "json_object" && !v.is_null() {
426 if let Some(obj) = v.as_object() {
427 let items: Vec<String> = obj
428 .iter()
429 .map(|(k, vv)| {
430 let snake_key = k.to_snake_case();
431 let php_val = if enum_fields.contains_key(k) {
432 if let Some(s) = vv.as_str() {
433 let snake_val = s.to_snake_case();
434 format!("\"{}\"", escape_php(&snake_val))
435 } else {
436 json_to_php(vv)
437 }
438 } else {
439 json_to_php(vv)
440 };
441 format!("\"{}\" => {}", escape_php(&snake_key), php_val)
442 })
443 .collect();
444 parts.push(format!("[{}]", items.join(", ")));
445 continue;
446 }
447 }
448 parts.push(json_to_php(v));
449 }
450 }
451 }
452
453 (setup_lines, parts.join(", "))
454}
455
456fn render_assertion(
457 out: &mut String,
458 assertion: &Assertion,
459 result_var: &str,
460 field_resolver: &FieldResolver,
461 result_is_simple: bool,
462) {
463 if let Some(f) = &assertion.field {
465 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
466 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
467 return;
468 }
469 }
470
471 if result_is_simple {
474 if let Some(f) = &assertion.field {
475 let f_lower = f.to_lowercase();
476 if !f.is_empty()
477 && f_lower != "content"
478 && (f_lower.starts_with("metadata")
479 || f_lower.starts_with("document")
480 || f_lower.starts_with("structure"))
481 {
482 let _ = writeln!(out, " // TODO: skipped (result_is_simple, field: {f})");
483 return;
484 }
485 }
486 }
487
488 let field_expr = if result_is_simple {
489 format!("${result_var}")
490 } else {
491 match &assertion.field {
492 Some(f) if !f.is_empty() => field_resolver.accessor(f, "php", &format!("${result_var}")),
493 _ => format!("${result_var}"),
494 }
495 };
496
497 let trimmed_field_expr = if result_is_simple {
499 format!("trim(${result_var})")
500 } else {
501 field_expr.clone()
502 };
503
504 match assertion.assertion_type.as_str() {
505 "equals" => {
506 if let Some(expected) = &assertion.value {
507 let php_val = json_to_php(expected);
508 let _ = writeln!(out, " $this->assertEquals({php_val}, {trimmed_field_expr});");
509 }
510 }
511 "contains" => {
512 if let Some(expected) = &assertion.value {
513 let php_val = json_to_php(expected);
514 let _ = writeln!(
515 out,
516 " $this->assertStringContainsString({php_val}, {field_expr});"
517 );
518 }
519 }
520 "contains_all" => {
521 if let Some(values) = &assertion.values {
522 for val in values {
523 let php_val = json_to_php(val);
524 let _ = writeln!(
525 out,
526 " $this->assertStringContainsString({php_val}, {field_expr});"
527 );
528 }
529 }
530 }
531 "not_contains" => {
532 if let Some(expected) = &assertion.value {
533 let php_val = json_to_php(expected);
534 let _ = writeln!(
535 out,
536 " $this->assertStringNotContainsString({php_val}, {field_expr});"
537 );
538 }
539 }
540 "not_empty" => {
541 let _ = writeln!(out, " $this->assertNotEmpty({field_expr});");
542 }
543 "is_empty" => {
544 let _ = writeln!(out, " $this->assertEmpty({trimmed_field_expr});");
545 }
546 "contains_any" => {
547 if let Some(values) = &assertion.values {
548 let _ = writeln!(out, " $found = false;");
549 for val in values {
550 let php_val = json_to_php(val);
551 let _ = writeln!(
552 out,
553 " if (str_contains({field_expr}, {php_val})) {{ $found = true; }}"
554 );
555 }
556 let _ = writeln!(
557 out,
558 " $this->assertTrue($found, 'expected to contain at least one of the specified values');"
559 );
560 }
561 }
562 "greater_than" => {
563 if let Some(val) = &assertion.value {
564 let php_val = json_to_php(val);
565 let _ = writeln!(out, " $this->assertGreaterThan({php_val}, {field_expr});");
566 }
567 }
568 "less_than" => {
569 if let Some(val) = &assertion.value {
570 let php_val = json_to_php(val);
571 let _ = writeln!(out, " $this->assertLessThan({php_val}, {field_expr});");
572 }
573 }
574 "greater_than_or_equal" => {
575 if let Some(val) = &assertion.value {
576 let php_val = json_to_php(val);
577 let _ = writeln!(out, " $this->assertGreaterThanOrEqual({php_val}, {field_expr});");
578 }
579 }
580 "less_than_or_equal" => {
581 if let Some(val) = &assertion.value {
582 let php_val = json_to_php(val);
583 let _ = writeln!(out, " $this->assertLessThanOrEqual({php_val}, {field_expr});");
584 }
585 }
586 "starts_with" => {
587 if let Some(expected) = &assertion.value {
588 let php_val = json_to_php(expected);
589 let _ = writeln!(out, " $this->assertStringStartsWith({php_val}, {field_expr});");
590 }
591 }
592 "ends_with" => {
593 if let Some(expected) = &assertion.value {
594 let php_val = json_to_php(expected);
595 let _ = writeln!(out, " $this->assertStringEndsWith({php_val}, {field_expr});");
596 }
597 }
598 "min_length" => {
599 if let Some(val) = &assertion.value {
600 if let Some(n) = val.as_u64() {
601 let _ = writeln!(
602 out,
603 " $this->assertGreaterThanOrEqual({n}, strlen({field_expr}));"
604 );
605 }
606 }
607 }
608 "max_length" => {
609 if let Some(val) = &assertion.value {
610 if let Some(n) = val.as_u64() {
611 let _ = writeln!(out, " $this->assertLessThanOrEqual({n}, strlen({field_expr}));");
612 }
613 }
614 }
615 "count_min" => {
616 if let Some(val) = &assertion.value {
617 if let Some(n) = val.as_u64() {
618 let _ = writeln!(
619 out,
620 " $this->assertGreaterThanOrEqual({n}, count({field_expr}));"
621 );
622 }
623 }
624 }
625 "not_error" => {
626 }
628 "error" => {
629 }
631 other => {
632 let _ = writeln!(out, " // TODO: unsupported assertion type: {other}");
633 }
634 }
635}
636
637fn json_to_php(value: &serde_json::Value) -> String {
639 match value {
640 serde_json::Value::String(s) => format!("\"{}\"", escape_php(s)),
641 serde_json::Value::Bool(true) => "true".to_string(),
642 serde_json::Value::Bool(false) => "false".to_string(),
643 serde_json::Value::Number(n) => n.to_string(),
644 serde_json::Value::Null => "null".to_string(),
645 serde_json::Value::Array(arr) => {
646 let items: Vec<String> = arr.iter().map(json_to_php).collect();
647 format!("[{}]", items.join(", "))
648 }
649 serde_json::Value::Object(map) => {
650 let items: Vec<String> = map
651 .iter()
652 .map(|(k, v)| format!("\"{}\" => {}", escape_php(k), json_to_php(v)))
653 .collect();
654 format!("[{}]", items.join(", "))
655 }
656 }
657}