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