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