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