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