1use crate::config::E2eConfig;
8use crate::escape::{escape_php, sanitize_filename};
9use crate::field_access::FieldResolver;
10use crate::fixture::{
11 Assertion, CallbackAction, Fixture, FixtureGroup, HttpExpectedResponse, HttpFixture, HttpRequest,
12};
13use alef_core::backend::GeneratedFile;
14use alef_core::config::AlefConfig;
15use anyhow::Result;
16use heck::{ToSnakeCase, ToUpperCamelCase};
17use std::collections::HashMap;
18use std::fmt::Write as FmtWrite;
19use std::path::PathBuf;
20
21use super::E2eCodegen;
22
23pub struct PhpCodegen;
25
26impl E2eCodegen for PhpCodegen {
27 fn generate(
28 &self,
29 groups: &[FixtureGroup],
30 e2e_config: &E2eConfig,
31 alef_config: &AlefConfig,
32 ) -> Result<Vec<GeneratedFile>> {
33 let lang = self.language_name();
34 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
35
36 let mut files = Vec::new();
37
38 let call = &e2e_config.call;
42 let overrides = call.overrides.get(lang);
43 let extension_name = alef_config.php_extension_name();
44 let class_name = overrides
45 .and_then(|o| o.class.as_ref())
46 .cloned()
47 .unwrap_or_else(|| extension_name.to_upper_camel_case());
48 let namespace = overrides.and_then(|o| o.module.as_ref()).cloned().unwrap_or_else(|| {
49 if extension_name.contains('_') {
50 extension_name
51 .split('_')
52 .map(|p| p.to_upper_camel_case())
53 .collect::<Vec<_>>()
54 .join("\\")
55 } else {
56 extension_name.to_upper_camel_case()
57 }
58 });
59 let empty_enum_fields = HashMap::new();
60 let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields);
61 let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
62 let php_client_factory = overrides.and_then(|o| o.php_client_factory.as_deref());
63 let options_via = overrides.and_then(|o| o.options_via.as_deref()).unwrap_or("array");
64
65 let php_pkg = e2e_config.resolve_package("php");
67 let pkg_name = php_pkg
68 .as_ref()
69 .and_then(|p| p.name.as_ref())
70 .cloned()
71 .unwrap_or_else(|| format!("kreuzberg/{}", call.module.replace('_', "-")));
72 let pkg_path = php_pkg
73 .as_ref()
74 .and_then(|p| p.path.as_ref())
75 .cloned()
76 .unwrap_or_else(|| "../../packages/php".to_string());
77 let pkg_version = php_pkg
78 .as_ref()
79 .and_then(|p| p.version.as_ref())
80 .cloned()
81 .unwrap_or_else(|| "0.1.0".to_string());
82
83 files.push(GeneratedFile {
85 path: output_base.join("composer.json"),
86 content: render_composer_json(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
87 generated_header: false,
88 });
89
90 files.push(GeneratedFile {
92 path: output_base.join("phpunit.xml"),
93 content: render_phpunit_xml(),
94 generated_header: false,
95 });
96
97 files.push(GeneratedFile {
99 path: output_base.join("bootstrap.php"),
100 content: render_bootstrap(&pkg_path),
101 generated_header: true,
102 });
103
104 let tests_base = output_base.join("tests");
106 let field_resolver = FieldResolver::new(
107 &e2e_config.fields,
108 &e2e_config.fields_optional,
109 &e2e_config.result_fields,
110 &e2e_config.fields_array,
111 );
112
113 for group in groups {
114 let active: Vec<&Fixture> = group
115 .fixtures
116 .iter()
117 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
118 .collect();
119
120 if active.is_empty() {
121 continue;
122 }
123
124 let test_class = format!("{}Test", sanitize_filename(&group.category).to_upper_camel_case());
125 let filename = format!("{test_class}.php");
126 let content = render_test_file(
127 &group.category,
128 &active,
129 e2e_config,
130 lang,
131 &namespace,
132 &class_name,
133 &test_class,
134 &field_resolver,
135 enum_fields,
136 result_is_simple,
137 php_client_factory,
138 options_via,
139 );
140 files.push(GeneratedFile {
141 path: tests_base.join(filename),
142 content,
143 generated_header: true,
144 });
145 }
146
147 Ok(files)
148 }
149
150 fn language_name(&self) -> &'static str {
151 "php"
152 }
153}
154
155fn render_composer_json(
160 pkg_name: &str,
161 _pkg_path: &str,
162 pkg_version: &str,
163 dep_mode: crate::config::DependencyMode,
164) -> String {
165 let require_section = match dep_mode {
166 crate::config::DependencyMode::Registry => {
167 format!(
168 r#" "require": {{
169 "{pkg_name}": "{pkg_version}"
170 }},
171 "require-dev": {{
172 "phpunit/phpunit": "^13.1",
173 "guzzlehttp/guzzle": "^7.0"
174 }},"#
175 )
176 }
177 crate::config::DependencyMode::Local => r#" "require-dev": {
178 "phpunit/phpunit": "^13.1",
179 "guzzlehttp/guzzle": "^7.0"
180 },"#
181 .to_string(),
182 };
183
184 format!(
185 r#"{{
186 "name": "kreuzberg/e2e-php",
187 "description": "E2e tests for PHP bindings",
188 "type": "project",
189{require_section}
190 "autoload-dev": {{
191 "psr-4": {{
192 "Kreuzberg\\E2e\\": "tests/"
193 }}
194 }}
195}}
196"#
197 )
198}
199
200fn render_phpunit_xml() -> String {
201 r#"<?xml version="1.0" encoding="UTF-8"?>
202<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
203 xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/13.1/phpunit.xsd"
204 bootstrap="bootstrap.php"
205 colors="true"
206 failOnRisky="true"
207 failOnWarning="true">
208 <testsuites>
209 <testsuite name="e2e">
210 <directory>tests</directory>
211 </testsuite>
212 </testsuites>
213</phpunit>
214"#
215 .to_string()
216}
217
218fn render_bootstrap(pkg_path: &str) -> String {
219 format!(
220 r#"<?php
221// This file is auto-generated by alef. DO NOT EDIT.
222
223declare(strict_types=1);
224
225// Load the e2e project autoloader (PHPUnit, test helpers).
226require_once __DIR__ . '/vendor/autoload.php';
227
228// Load the PHP binding package classes via its Composer autoloader.
229// The package's autoloader is separate from the e2e project's autoloader
230// since the php-ext type prevents direct composer path dependency.
231$pkgAutoloader = __DIR__ . '/{pkg_path}/vendor/autoload.php';
232if (file_exists($pkgAutoloader)) {{
233 require_once $pkgAutoloader;
234}}
235"#
236 )
237}
238
239#[allow(clippy::too_many_arguments)]
240fn render_test_file(
241 category: &str,
242 fixtures: &[&Fixture],
243 e2e_config: &E2eConfig,
244 lang: &str,
245 namespace: &str,
246 class_name: &str,
247 test_class: &str,
248 field_resolver: &FieldResolver,
249 enum_fields: &HashMap<String, String>,
250 result_is_simple: bool,
251 php_client_factory: Option<&str>,
252 options_via: &str,
253) -> String {
254 let mut out = String::new();
255 let _ = writeln!(out, "<?php");
256 let _ = writeln!(out, "// This file is auto-generated by alef. DO NOT EDIT.");
257 let _ = writeln!(out);
258 let _ = writeln!(out, "declare(strict_types=1);");
259 let _ = writeln!(out);
260 let _ = writeln!(out, "namespace Kreuzberg\\E2e;");
261 let _ = writeln!(out);
262
263 let needs_crawl_config_import = fixtures.iter().any(|f| {
265 let call = e2e_config.resolve_call(f.call.as_deref());
266 call.args.iter().filter(|a| a.arg_type == "handle").any(|a| {
267 let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
268 !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
269 })
270 });
271
272 let has_http_tests = fixtures.iter().any(|f| f.is_http_test());
274
275 let _ = writeln!(out, "use PHPUnit\\Framework\\TestCase;");
276 let _ = writeln!(out, "use {namespace}\\{class_name};");
277 if needs_crawl_config_import {
278 let _ = writeln!(out, "use {namespace}\\CrawlConfig;");
279 }
280 if has_http_tests {
281 let _ = writeln!(out, "use GuzzleHttp\\Client;");
282 }
283 let _ = writeln!(out);
284 let _ = writeln!(out, "/** E2e tests for category: {category}. */");
285 let _ = writeln!(out, "final class {test_class} extends TestCase");
286 let _ = writeln!(out, "{{");
287
288 if has_http_tests {
290 let _ = writeln!(out, " private Client $httpClient;");
291 let _ = writeln!(out);
292 let _ = writeln!(out, " protected function setUp(): void");
293 let _ = writeln!(out, " {{");
294 let _ = writeln!(out, " parent::setUp();");
295 let _ = writeln!(
296 out,
297 " $baseUrl = getenv('TEST_SERVER_URL') ?: 'http://localhost:8080';"
298 );
299 let _ = writeln!(
300 out,
301 " $this->httpClient = new Client(['base_uri' => $baseUrl, 'http_errors' => false]);"
302 );
303 let _ = writeln!(out, " }}");
304 let _ = writeln!(out);
305 }
306
307 for (i, fixture) in fixtures.iter().enumerate() {
308 if fixture.is_http_test() {
309 render_http_test_method(&mut out, fixture, fixture.http.as_ref().unwrap());
310 } else {
311 render_test_method(
312 &mut out,
313 fixture,
314 e2e_config,
315 lang,
316 namespace,
317 class_name,
318 field_resolver,
319 enum_fields,
320 result_is_simple,
321 php_client_factory,
322 options_via,
323 );
324 }
325 if i + 1 < fixtures.len() {
326 let _ = writeln!(out);
327 }
328 }
329
330 let _ = writeln!(out, "}}");
331 out
332}
333
334fn render_http_test_method(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
340 let method_name = sanitize_filename(&fixture.id);
341 let description = &fixture.description;
342
343 let _ = writeln!(out, " /** {description} */");
344 let _ = writeln!(out, " public function test_{method_name}(): void");
345 let _ = writeln!(out, " {{");
346
347 render_php_http_request(out, &http.request);
349
350 let status = http.expected_response.status_code;
352 let _ = writeln!(
353 out,
354 " $this->assertEquals({status}, $response->getStatusCode());"
355 );
356
357 render_php_body_assertions(out, &http.expected_response);
359
360 render_php_header_assertions(out, &http.expected_response);
362
363 let _ = writeln!(out, " }}");
364}
365
366fn render_php_http_request(out: &mut String, req: &HttpRequest) {
368 let method = req.method.to_uppercase();
369
370 let mut opts: Vec<String> = Vec::new();
372
373 if let Some(body) = &req.body {
374 let php_body = json_to_php(body);
375 opts.push(format!("'json' => {php_body}"));
376 }
377
378 if !req.headers.is_empty() {
379 let header_pairs: Vec<String> = req
380 .headers
381 .iter()
382 .map(|(k, v)| format!("\"{}\" => \"{}\"", escape_php(k), escape_php(v)))
383 .collect();
384 opts.push(format!("'headers' => [{}]", header_pairs.join(", ")));
385 }
386
387 if !req.cookies.is_empty() {
388 let cookie_str = req
389 .cookies
390 .iter()
391 .map(|(k, v)| format!("{}={}", k, v))
392 .collect::<Vec<_>>()
393 .join("; ");
394 opts.push(format!("'headers' => ['Cookie' => \"{}\"]", escape_php(&cookie_str)));
395 }
396
397 if !req.query_params.is_empty() {
398 let pairs: Vec<String> = req
399 .query_params
400 .iter()
401 .map(|(k, v)| {
402 let val_str = match v {
403 serde_json::Value::String(s) => s.clone(),
404 other => other.to_string(),
405 };
406 format!("\"{}\" => \"{}\"", escape_php(k), escape_php(&val_str))
407 })
408 .collect();
409 opts.push(format!("'query' => [{}]", pairs.join(", ")));
410 }
411
412 let path_lit = format!("\"{}\"", escape_php(&req.path));
413 if opts.is_empty() {
414 let _ = writeln!(
415 out,
416 " $response = $this->httpClient->request('{method}', {path_lit});"
417 );
418 } else {
419 let _ = writeln!(
420 out,
421 " $response = $this->httpClient->request('{method}', {path_lit}, ["
422 );
423 for opt in &opts {
424 let _ = writeln!(out, " {opt},");
425 }
426 let _ = writeln!(out, " ]);");
427 }
428
429 let _ = writeln!(
431 out,
432 " $body = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);"
433 );
434}
435
436fn render_php_body_assertions(out: &mut String, expected: &HttpExpectedResponse) {
438 if let Some(body) = &expected.body {
439 let php_val = json_to_php(body);
440 let _ = writeln!(out, " $this->assertEquals({php_val}, $body);");
441 }
442 if let Some(partial) = &expected.body_partial {
443 if let Some(obj) = partial.as_object() {
444 for (key, val) in obj {
445 let php_key = format!("\"{}\"", escape_php(key));
446 let php_val = json_to_php(val);
447 let _ = writeln!(out, " $this->assertEquals({php_val}, $body[{php_key}]);");
448 }
449 }
450 }
451 if let Some(errors) = &expected.validation_errors {
452 for err in errors {
453 let msg_lit = format!("\"{}\"", escape_php(&err.msg));
454 let _ = writeln!(
455 out,
456 " $this->assertStringContainsString({msg_lit}, json_encode($body));"
457 );
458 }
459 }
460}
461
462fn render_php_header_assertions(out: &mut String, expected: &HttpExpectedResponse) {
469 for (name, value) in &expected.headers {
470 let header_key = name.to_lowercase();
471 let header_key_lit = format!("\"{}\"", escape_php(&header_key));
472 match value.as_str() {
473 "<<present>>" => {
474 let _ = writeln!(
475 out,
476 " $this->assertTrue($response->hasHeader({header_key_lit}));"
477 );
478 }
479 "<<absent>>" => {
480 let _ = writeln!(
481 out,
482 " $this->assertFalse($response->hasHeader({header_key_lit}));"
483 );
484 }
485 "<<uuid>>" => {
486 let _ = writeln!(
487 out,
488 " $this->assertMatchesRegularExpression('/^[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}$/i', $response->getHeaderLine({header_key_lit}));"
489 );
490 }
491 literal => {
492 let val_lit = format!("\"{}\"", escape_php(literal));
493 let _ = writeln!(
494 out,
495 " $this->assertEquals({val_lit}, $response->getHeaderLine({header_key_lit}));"
496 );
497 }
498 }
499 }
500}
501
502#[allow(clippy::too_many_arguments)]
507fn render_test_method(
508 out: &mut String,
509 fixture: &Fixture,
510 e2e_config: &E2eConfig,
511 lang: &str,
512 namespace: &str,
513 class_name: &str,
514 field_resolver: &FieldResolver,
515 enum_fields: &HashMap<String, String>,
516 result_is_simple: bool,
517 php_client_factory: Option<&str>,
518 options_via: &str,
519) {
520 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
522 let call_overrides = call_config.overrides.get(lang);
523 let mut function_name = call_overrides
524 .and_then(|o| o.function.as_ref())
525 .cloned()
526 .unwrap_or_else(|| call_config.function.clone());
527 if call_config.r#async {
529 function_name = format!("{function_name}_async");
530 }
531 let result_var = &call_config.result_var;
532 let args = &call_config.args;
533
534 let method_name = sanitize_filename(&fixture.id);
535 let description = &fixture.description;
536 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
537
538 let (mut setup_lines, args_str) =
539 build_args_and_setup(&fixture.input, args, class_name, enum_fields, &fixture.id, options_via);
540
541 let mut visitor_arg = String::new();
543 if let Some(visitor_spec) = &fixture.visitor {
544 visitor_arg = build_php_visitor(&mut setup_lines, visitor_spec);
545 }
546
547 let final_args = if visitor_arg.is_empty() {
548 args_str
549 } else if args_str.is_empty() {
550 visitor_arg
551 } else {
552 format!("{args_str}, {visitor_arg}")
553 };
554
555 let call_expr = if php_client_factory.is_some() {
556 format!("$client->{function_name}({final_args})")
557 } else {
558 format!("{class_name}::{function_name}({final_args})")
559 };
560
561 let _ = writeln!(out, " /** {description} */");
562 let _ = writeln!(out, " public function test_{method_name}(): void");
563 let _ = writeln!(out, " {{");
564
565 if let Some(factory) = php_client_factory {
566 let _ = writeln!(
567 out,
568 " $client = \\{namespace}\\{class_name}::{factory}('test-key');"
569 );
570 }
571
572 for line in &setup_lines {
573 let _ = writeln!(out, " {line}");
574 }
575
576 if expects_error {
577 let _ = writeln!(out, " $this->expectException(\\Exception::class);");
578 let _ = writeln!(out, " {call_expr};");
579 let _ = writeln!(out, " }}");
580 return;
581 }
582
583 let has_usable = fixture.assertions.iter().any(|a| {
586 if a.assertion_type == "error" || a.assertion_type == "not_error" {
587 return false;
588 }
589 match &a.field {
590 Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
591 _ => true,
592 }
593 });
594 if !has_usable {
595 let _ = writeln!(out, " $this->expectNotToPerformAssertions();");
596 }
597
598 let _ = writeln!(out, " ${result_var} = {call_expr};");
599
600 for assertion in &fixture.assertions {
601 render_assertion(out, assertion, result_var, field_resolver, result_is_simple);
602 }
603
604 let _ = writeln!(out, " }}");
605}
606
607fn build_args_and_setup(
615 input: &serde_json::Value,
616 args: &[crate::config::ArgMapping],
617 class_name: &str,
618 enum_fields: &HashMap<String, String>,
619 fixture_id: &str,
620 options_via: &str,
621) -> (Vec<String>, String) {
622 if args.is_empty() {
623 let is_empty_input = match input {
626 serde_json::Value::Null => true,
627 serde_json::Value::Object(m) => m.is_empty(),
628 _ => false,
629 };
630 if is_empty_input {
631 return (Vec::new(), String::new());
632 }
633 return (Vec::new(), json_to_php(input));
634 }
635
636 let mut setup_lines: Vec<String> = Vec::new();
637 let mut parts: Vec<String> = Vec::new();
638
639 for arg in args {
640 if arg.arg_type == "mock_url" {
641 setup_lines.push(format!(
642 "${} = getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}';",
643 arg.name,
644 ));
645 parts.push(format!("${}", arg.name));
646 continue;
647 }
648
649 if arg.arg_type == "handle" {
650 let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
652 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
653 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
654 if config_value.is_null()
655 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
656 {
657 setup_lines.push(format!("${} = {class_name}::{constructor_name}(null);", arg.name,));
658 } else {
659 let name = &arg.name;
660 setup_lines.push(format!("${name}_config = CrawlConfig::default();"));
664 if let Some(obj) = config_value.as_object() {
665 for (key, val) in obj {
666 let php_val = json_to_php(val);
667 setup_lines.push(format!("${name}_config->{key} = {php_val};"));
668 }
669 }
670 setup_lines.push(format!(
671 "${} = {class_name}::{constructor_name}(${name}_config);",
672 arg.name,
673 ));
674 }
675 parts.push(format!("${}", arg.name));
676 continue;
677 }
678
679 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
680 let val = input.get(field);
681 match val {
682 None | Some(serde_json::Value::Null) if arg.optional => {
683 continue;
685 }
686 None | Some(serde_json::Value::Null) => {
687 let default_val = match arg.arg_type.as_str() {
689 "string" => "\"\"".to_string(),
690 "int" | "integer" => "0".to_string(),
691 "float" | "number" => "0.0".to_string(),
692 "bool" | "boolean" => "false".to_string(),
693 "json_object" if options_via == "json" => "null".to_string(),
694 _ => "null".to_string(),
695 };
696 parts.push(default_val);
697 }
698 Some(v) => {
699 if arg.arg_type == "json_object" && !v.is_null() {
700 match options_via {
701 "json" => {
702 parts.push(format!("json_encode({})", json_to_php(v)));
704 continue;
705 }
706 _ => {
707 if let Some(obj) = v.as_object() {
709 let items: Vec<String> = obj
710 .iter()
711 .map(|(k, vv)| {
712 let snake_key = k.to_snake_case();
713 let php_val = if enum_fields.contains_key(k) {
714 if let Some(s) = vv.as_str() {
715 let snake_val = s.to_snake_case();
716 format!("\"{}\"", escape_php(&snake_val))
717 } else {
718 json_to_php(vv)
719 }
720 } else {
721 json_to_php(vv)
722 };
723 format!("\"{}\" => {}", escape_php(&snake_key), php_val)
724 })
725 .collect();
726 parts.push(format!("[{}]", items.join(", ")));
727 continue;
728 }
729 }
730 }
731 }
732 parts.push(json_to_php(v));
733 }
734 }
735 }
736
737 (setup_lines, parts.join(", "))
738}
739
740fn render_assertion(
741 out: &mut String,
742 assertion: &Assertion,
743 result_var: &str,
744 field_resolver: &FieldResolver,
745 result_is_simple: bool,
746) {
747 if let Some(f) = &assertion.field {
749 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
750 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
751 return;
752 }
753 }
754
755 if result_is_simple {
758 if let Some(f) = &assertion.field {
759 let f_lower = f.to_lowercase();
760 if !f.is_empty()
761 && f_lower != "content"
762 && (f_lower.starts_with("metadata")
763 || f_lower.starts_with("document")
764 || f_lower.starts_with("structure"))
765 {
766 let _ = writeln!(out, " // TODO: skipped (result_is_simple, field: {f})");
767 return;
768 }
769 }
770 }
771
772 let field_expr = if result_is_simple {
773 format!("${result_var}")
774 } else {
775 match &assertion.field {
776 Some(f) if !f.is_empty() => field_resolver.accessor(f, "php", &format!("${result_var}")),
777 _ => format!("${result_var}"),
778 }
779 };
780
781 let trimmed_field_expr = if result_is_simple {
783 format!("trim(${result_var})")
784 } else {
785 field_expr.clone()
786 };
787
788 match assertion.assertion_type.as_str() {
789 "equals" => {
790 if let Some(expected) = &assertion.value {
791 let php_val = json_to_php(expected);
792 let _ = writeln!(out, " $this->assertEquals({php_val}, {trimmed_field_expr});");
793 }
794 }
795 "contains" => {
796 if let Some(expected) = &assertion.value {
797 let php_val = json_to_php(expected);
798 let _ = writeln!(
799 out,
800 " $this->assertStringContainsString({php_val}, {field_expr});"
801 );
802 }
803 }
804 "contains_all" => {
805 if let Some(values) = &assertion.values {
806 for val in values {
807 let php_val = json_to_php(val);
808 let _ = writeln!(
809 out,
810 " $this->assertStringContainsString({php_val}, {field_expr});"
811 );
812 }
813 }
814 }
815 "not_contains" => {
816 if let Some(expected) = &assertion.value {
817 let php_val = json_to_php(expected);
818 let _ = writeln!(
819 out,
820 " $this->assertStringNotContainsString({php_val}, {field_expr});"
821 );
822 }
823 }
824 "not_empty" => {
825 let _ = writeln!(out, " $this->assertNotEmpty({field_expr});");
826 }
827 "is_empty" => {
828 let _ = writeln!(out, " $this->assertEmpty({trimmed_field_expr});");
829 }
830 "contains_any" => {
831 if let Some(values) = &assertion.values {
832 let _ = writeln!(out, " $found = false;");
833 for val in values {
834 let php_val = json_to_php(val);
835 let _ = writeln!(
836 out,
837 " if (str_contains({field_expr}, {php_val})) {{ $found = true; }}"
838 );
839 }
840 let _ = writeln!(
841 out,
842 " $this->assertTrue($found, 'expected to contain at least one of the specified values');"
843 );
844 }
845 }
846 "greater_than" => {
847 if let Some(val) = &assertion.value {
848 let php_val = json_to_php(val);
849 let _ = writeln!(out, " $this->assertGreaterThan({php_val}, {field_expr});");
850 }
851 }
852 "less_than" => {
853 if let Some(val) = &assertion.value {
854 let php_val = json_to_php(val);
855 let _ = writeln!(out, " $this->assertLessThan({php_val}, {field_expr});");
856 }
857 }
858 "greater_than_or_equal" => {
859 if let Some(val) = &assertion.value {
860 let php_val = json_to_php(val);
861 let _ = writeln!(out, " $this->assertGreaterThanOrEqual({php_val}, {field_expr});");
862 }
863 }
864 "less_than_or_equal" => {
865 if let Some(val) = &assertion.value {
866 let php_val = json_to_php(val);
867 let _ = writeln!(out, " $this->assertLessThanOrEqual({php_val}, {field_expr});");
868 }
869 }
870 "starts_with" => {
871 if let Some(expected) = &assertion.value {
872 let php_val = json_to_php(expected);
873 let _ = writeln!(out, " $this->assertStringStartsWith({php_val}, {field_expr});");
874 }
875 }
876 "ends_with" => {
877 if let Some(expected) = &assertion.value {
878 let php_val = json_to_php(expected);
879 let _ = writeln!(out, " $this->assertStringEndsWith({php_val}, {field_expr});");
880 }
881 }
882 "min_length" => {
883 if let Some(val) = &assertion.value {
884 if let Some(n) = val.as_u64() {
885 let _ = writeln!(
886 out,
887 " $this->assertGreaterThanOrEqual({n}, strlen({field_expr}));"
888 );
889 }
890 }
891 }
892 "max_length" => {
893 if let Some(val) = &assertion.value {
894 if let Some(n) = val.as_u64() {
895 let _ = writeln!(out, " $this->assertLessThanOrEqual({n}, strlen({field_expr}));");
896 }
897 }
898 }
899 "count_min" => {
900 if let Some(val) = &assertion.value {
901 if let Some(n) = val.as_u64() {
902 let _ = writeln!(
903 out,
904 " $this->assertGreaterThanOrEqual({n}, count({field_expr}));"
905 );
906 }
907 }
908 }
909 "count_equals" => {
910 if let Some(val) = &assertion.value {
911 if let Some(n) = val.as_u64() {
912 let _ = writeln!(out, " $this->assertCount({n}, {field_expr});");
913 }
914 }
915 }
916 "is_true" => {
917 let _ = writeln!(out, " $this->assertTrue({field_expr});");
918 }
919 "not_error" => {
920 }
922 "error" => {
923 }
925 other => {
926 let _ = writeln!(out, " // TODO: unsupported assertion type: {other}");
927 }
928 }
929}
930
931fn json_to_php(value: &serde_json::Value) -> String {
933 match value {
934 serde_json::Value::String(s) => format!("\"{}\"", escape_php(s)),
935 serde_json::Value::Bool(true) => "true".to_string(),
936 serde_json::Value::Bool(false) => "false".to_string(),
937 serde_json::Value::Number(n) => n.to_string(),
938 serde_json::Value::Null => "null".to_string(),
939 serde_json::Value::Array(arr) => {
940 let items: Vec<String> = arr.iter().map(json_to_php).collect();
941 format!("[{}]", items.join(", "))
942 }
943 serde_json::Value::Object(map) => {
944 let items: Vec<String> = map
945 .iter()
946 .map(|(k, v)| format!("\"{}\" => {}", escape_php(k), json_to_php(v)))
947 .collect();
948 format!("[{}]", items.join(", "))
949 }
950 }
951}
952
953fn build_php_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
959 setup_lines.push("$visitor = new class {".to_string());
960 for (method_name, action) in &visitor_spec.callbacks {
961 emit_php_visitor_method(setup_lines, method_name, action);
962 }
963 setup_lines.push("};".to_string());
964 "$visitor".to_string()
965}
966
967fn emit_php_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
969 let snake_method = method_name;
970 let params = match method_name {
971 "visit_link" => "$ctx, $href, $text, $title",
972 "visit_image" => "$ctx, $src, $alt, $title",
973 "visit_heading" => "$ctx, $level, $text, $id",
974 "visit_code_block" => "$ctx, $lang, $code",
975 "visit_code_inline"
976 | "visit_strong"
977 | "visit_emphasis"
978 | "visit_strikethrough"
979 | "visit_underline"
980 | "visit_subscript"
981 | "visit_superscript"
982 | "visit_mark"
983 | "visit_button"
984 | "visit_summary"
985 | "visit_figcaption"
986 | "visit_definition_term"
987 | "visit_definition_description" => "$ctx, $text",
988 "visit_text" => "$ctx, $text",
989 "visit_list_item" => "$ctx, $ordered, $marker, $text",
990 "visit_blockquote" => "$ctx, $content, $depth",
991 "visit_table_row" => "$ctx, $cells, $isHeader",
992 "visit_custom_element" => "$ctx, $tagName, $html",
993 "visit_form" => "$ctx, $actionUrl, $method",
994 "visit_input" => "$ctx, $inputType, $name, $value",
995 "visit_audio" | "visit_video" | "visit_iframe" => "$ctx, $src",
996 "visit_details" => "$ctx, $isOpen",
997 _ => "$ctx",
998 };
999
1000 setup_lines.push(format!(" public function {snake_method}({params}) {{"));
1001 match action {
1002 CallbackAction::Skip => {
1003 setup_lines.push(" return 'skip';".to_string());
1004 }
1005 CallbackAction::Continue => {
1006 setup_lines.push(" return 'continue';".to_string());
1007 }
1008 CallbackAction::PreserveHtml => {
1009 setup_lines.push(" return 'preserve_html';".to_string());
1010 }
1011 CallbackAction::Custom { output } => {
1012 let escaped = escape_php(output);
1013 setup_lines.push(format!(" return ['custom' => {escaped}];"));
1014 }
1015 CallbackAction::CustomTemplate { template } => {
1016 setup_lines.push(format!(" return ['custom' => \"{template}\"];"));
1017 }
1018 }
1019 setup_lines.push(" }".to_string());
1020}