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!(
767 out,
768 " // skipped: result_is_simple, field '{f}' not on simple result type"
769 );
770 return;
771 }
772 }
773 }
774
775 let field_expr = if result_is_simple {
776 format!("${result_var}")
777 } else {
778 match &assertion.field {
779 Some(f) if !f.is_empty() => field_resolver.accessor(f, "php", &format!("${result_var}")),
780 _ => format!("${result_var}"),
781 }
782 };
783
784 let trimmed_field_expr = if result_is_simple {
786 format!("trim(${result_var})")
787 } else {
788 field_expr.clone()
789 };
790
791 match assertion.assertion_type.as_str() {
792 "equals" => {
793 if let Some(expected) = &assertion.value {
794 let php_val = json_to_php(expected);
795 let _ = writeln!(out, " $this->assertEquals({php_val}, {trimmed_field_expr});");
796 }
797 }
798 "contains" => {
799 if let Some(expected) = &assertion.value {
800 let php_val = json_to_php(expected);
801 let _ = writeln!(
802 out,
803 " $this->assertStringContainsString({php_val}, {field_expr});"
804 );
805 }
806 }
807 "contains_all" => {
808 if let Some(values) = &assertion.values {
809 for val in values {
810 let php_val = json_to_php(val);
811 let _ = writeln!(
812 out,
813 " $this->assertStringContainsString({php_val}, {field_expr});"
814 );
815 }
816 }
817 }
818 "not_contains" => {
819 if let Some(expected) = &assertion.value {
820 let php_val = json_to_php(expected);
821 let _ = writeln!(
822 out,
823 " $this->assertStringNotContainsString({php_val}, {field_expr});"
824 );
825 }
826 }
827 "not_empty" => {
828 let _ = writeln!(out, " $this->assertNotEmpty({field_expr});");
829 }
830 "is_empty" => {
831 let _ = writeln!(out, " $this->assertEmpty({trimmed_field_expr});");
832 }
833 "contains_any" => {
834 if let Some(values) = &assertion.values {
835 let _ = writeln!(out, " $found = false;");
836 for val in values {
837 let php_val = json_to_php(val);
838 let _ = writeln!(
839 out,
840 " if (str_contains({field_expr}, {php_val})) {{ $found = true; }}"
841 );
842 }
843 let _ = writeln!(
844 out,
845 " $this->assertTrue($found, 'expected to contain at least one of the specified values');"
846 );
847 }
848 }
849 "greater_than" => {
850 if let Some(val) = &assertion.value {
851 let php_val = json_to_php(val);
852 let _ = writeln!(out, " $this->assertGreaterThan({php_val}, {field_expr});");
853 }
854 }
855 "less_than" => {
856 if let Some(val) = &assertion.value {
857 let php_val = json_to_php(val);
858 let _ = writeln!(out, " $this->assertLessThan({php_val}, {field_expr});");
859 }
860 }
861 "greater_than_or_equal" => {
862 if let Some(val) = &assertion.value {
863 let php_val = json_to_php(val);
864 let _ = writeln!(out, " $this->assertGreaterThanOrEqual({php_val}, {field_expr});");
865 }
866 }
867 "less_than_or_equal" => {
868 if let Some(val) = &assertion.value {
869 let php_val = json_to_php(val);
870 let _ = writeln!(out, " $this->assertLessThanOrEqual({php_val}, {field_expr});");
871 }
872 }
873 "starts_with" => {
874 if let Some(expected) = &assertion.value {
875 let php_val = json_to_php(expected);
876 let _ = writeln!(out, " $this->assertStringStartsWith({php_val}, {field_expr});");
877 }
878 }
879 "ends_with" => {
880 if let Some(expected) = &assertion.value {
881 let php_val = json_to_php(expected);
882 let _ = writeln!(out, " $this->assertStringEndsWith({php_val}, {field_expr});");
883 }
884 }
885 "min_length" => {
886 if let Some(val) = &assertion.value {
887 if let Some(n) = val.as_u64() {
888 let _ = writeln!(
889 out,
890 " $this->assertGreaterThanOrEqual({n}, strlen({field_expr}));"
891 );
892 }
893 }
894 }
895 "max_length" => {
896 if let Some(val) = &assertion.value {
897 if let Some(n) = val.as_u64() {
898 let _ = writeln!(out, " $this->assertLessThanOrEqual({n}, strlen({field_expr}));");
899 }
900 }
901 }
902 "count_min" => {
903 if let Some(val) = &assertion.value {
904 if let Some(n) = val.as_u64() {
905 let _ = writeln!(
906 out,
907 " $this->assertGreaterThanOrEqual({n}, count({field_expr}));"
908 );
909 }
910 }
911 }
912 "count_equals" => {
913 if let Some(val) = &assertion.value {
914 if let Some(n) = val.as_u64() {
915 let _ = writeln!(out, " $this->assertCount({n}, {field_expr});");
916 }
917 }
918 }
919 "is_true" => {
920 let _ = writeln!(out, " $this->assertTrue({field_expr});");
921 }
922 "is_false" => {
923 let _ = writeln!(out, " $this->assertFalse({field_expr});");
924 }
925 "method_result" => {
926 if let Some(method_name) = &assertion.method {
927 let call_expr = build_php_method_call(result_var, method_name, assertion.args.as_ref());
928 let check = assertion.check.as_deref().unwrap_or("is_true");
929 match check {
930 "equals" => {
931 if let Some(val) = &assertion.value {
932 if val.is_boolean() {
933 if val.as_bool() == Some(true) {
934 let _ = writeln!(out, " $this->assertTrue({call_expr});");
935 } else {
936 let _ = writeln!(out, " $this->assertFalse({call_expr});");
937 }
938 } else {
939 let expected = json_to_php(val);
940 let _ = writeln!(out, " $this->assertEquals({expected}, {call_expr});");
941 }
942 }
943 }
944 "is_true" => {
945 let _ = writeln!(out, " $this->assertTrue({call_expr});");
946 }
947 "is_false" => {
948 let _ = writeln!(out, " $this->assertFalse({call_expr});");
949 }
950 "greater_than_or_equal" => {
951 if let Some(val) = &assertion.value {
952 let n = val.as_u64().unwrap_or(0);
953 let _ = writeln!(out, " $this->assertGreaterThanOrEqual({n}, {call_expr});");
954 }
955 }
956 "count_min" => {
957 if let Some(val) = &assertion.value {
958 let n = val.as_u64().unwrap_or(0);
959 let _ = writeln!(out, " $this->assertGreaterThanOrEqual({n}, count({call_expr}));");
960 }
961 }
962 "is_error" => {
963 let _ = writeln!(out, " $this->expectException(\\Exception::class);");
964 let _ = writeln!(out, " {call_expr};");
965 }
966 "contains" => {
967 if let Some(val) = &assertion.value {
968 let expected = json_to_php(val);
969 let _ = writeln!(
970 out,
971 " $this->assertStringContainsString({expected}, {call_expr});"
972 );
973 }
974 }
975 other_check => {
976 panic!("PHP e2e generator: unsupported method_result check type: {other_check}");
977 }
978 }
979 } else {
980 panic!("PHP e2e generator: method_result assertion missing 'method' field");
981 }
982 }
983 "not_error" => {
984 }
986 "error" => {
987 }
989 other => {
990 panic!("PHP e2e generator: unsupported assertion type: {other}");
991 }
992 }
993}
994
995fn build_php_method_call(result_var: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
1000 match method_name {
1001 "root_child_count" => {
1002 format!("count(TreeSitterLanguagePack::named_children_info(${result_var}))")
1003 }
1004 "root_node_type" => {
1005 format!("TreeSitterLanguagePack::root_node_info(${result_var})->kind")
1006 }
1007 "named_children_count" => {
1008 format!("count(TreeSitterLanguagePack::named_children_info(${result_var}))")
1009 }
1010 "has_error_nodes" => {
1011 format!("TreeSitterLanguagePack::tree_has_error_nodes(${result_var})")
1012 }
1013 "error_count" | "tree_error_count" => {
1014 format!("TreeSitterLanguagePack::tree_error_count(${result_var})")
1015 }
1016 "tree_to_sexp" => {
1017 format!("TreeSitterLanguagePack::tree_to_sexp(${result_var})")
1018 }
1019 "contains_node_type" => {
1020 let node_type = args
1021 .and_then(|a| a.get("node_type"))
1022 .and_then(|v| v.as_str())
1023 .unwrap_or("");
1024 format!("TreeSitterLanguagePack::tree_contains_node_type(${result_var}, \"{node_type}\")")
1025 }
1026 "find_nodes_by_type" => {
1027 let node_type = args
1028 .and_then(|a| a.get("node_type"))
1029 .and_then(|v| v.as_str())
1030 .unwrap_or("");
1031 format!("TreeSitterLanguagePack::find_nodes_by_type(${result_var}, \"{node_type}\")")
1032 }
1033 "run_query" => {
1034 let query_source = args
1035 .and_then(|a| a.get("query_source"))
1036 .and_then(|v| v.as_str())
1037 .unwrap_or("");
1038 let language = args
1039 .and_then(|a| a.get("language"))
1040 .and_then(|v| v.as_str())
1041 .unwrap_or("");
1042 format!("TreeSitterLanguagePack::run_query(${result_var}, \"{language}\", \"{query_source}\", $source)")
1043 }
1044 _ => {
1045 format!("${result_var}->{method_name}()")
1046 }
1047 }
1048}
1049
1050fn json_to_php(value: &serde_json::Value) -> String {
1052 match value {
1053 serde_json::Value::String(s) => format!("\"{}\"", escape_php(s)),
1054 serde_json::Value::Bool(true) => "true".to_string(),
1055 serde_json::Value::Bool(false) => "false".to_string(),
1056 serde_json::Value::Number(n) => n.to_string(),
1057 serde_json::Value::Null => "null".to_string(),
1058 serde_json::Value::Array(arr) => {
1059 let items: Vec<String> = arr.iter().map(json_to_php).collect();
1060 format!("[{}]", items.join(", "))
1061 }
1062 serde_json::Value::Object(map) => {
1063 let items: Vec<String> = map
1064 .iter()
1065 .map(|(k, v)| format!("\"{}\" => {}", escape_php(k), json_to_php(v)))
1066 .collect();
1067 format!("[{}]", items.join(", "))
1068 }
1069 }
1070}
1071
1072fn build_php_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1078 setup_lines.push("$visitor = new class {".to_string());
1079 for (method_name, action) in &visitor_spec.callbacks {
1080 emit_php_visitor_method(setup_lines, method_name, action);
1081 }
1082 setup_lines.push("};".to_string());
1083 "$visitor".to_string()
1084}
1085
1086fn emit_php_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
1088 let snake_method = method_name;
1089 let params = match method_name {
1090 "visit_link" => "$ctx, $href, $text, $title",
1091 "visit_image" => "$ctx, $src, $alt, $title",
1092 "visit_heading" => "$ctx, $level, $text, $id",
1093 "visit_code_block" => "$ctx, $lang, $code",
1094 "visit_code_inline"
1095 | "visit_strong"
1096 | "visit_emphasis"
1097 | "visit_strikethrough"
1098 | "visit_underline"
1099 | "visit_subscript"
1100 | "visit_superscript"
1101 | "visit_mark"
1102 | "visit_button"
1103 | "visit_summary"
1104 | "visit_figcaption"
1105 | "visit_definition_term"
1106 | "visit_definition_description" => "$ctx, $text",
1107 "visit_text" => "$ctx, $text",
1108 "visit_list_item" => "$ctx, $ordered, $marker, $text",
1109 "visit_blockquote" => "$ctx, $content, $depth",
1110 "visit_table_row" => "$ctx, $cells, $isHeader",
1111 "visit_custom_element" => "$ctx, $tagName, $html",
1112 "visit_form" => "$ctx, $actionUrl, $method",
1113 "visit_input" => "$ctx, $inputType, $name, $value",
1114 "visit_audio" | "visit_video" | "visit_iframe" => "$ctx, $src",
1115 "visit_details" => "$ctx, $isOpen",
1116 _ => "$ctx",
1117 };
1118
1119 setup_lines.push(format!(" public function {snake_method}({params}) {{"));
1120 match action {
1121 CallbackAction::Skip => {
1122 setup_lines.push(" return 'skip';".to_string());
1123 }
1124 CallbackAction::Continue => {
1125 setup_lines.push(" return 'continue';".to_string());
1126 }
1127 CallbackAction::PreserveHtml => {
1128 setup_lines.push(" return 'preserve_html';".to_string());
1129 }
1130 CallbackAction::Custom { output } => {
1131 let escaped = escape_php(output);
1132 setup_lines.push(format!(" return ['custom' => {escaped}];"));
1133 }
1134 CallbackAction::CustomTemplate { template } => {
1135 setup_lines.push(format!(" return ['custom' => \"{template}\"];"));
1136 }
1137 }
1138 setup_lines.push(" }".to_string());
1139}