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