1use crate::config::E2eConfig;
8use crate::escape::{escape_php, sanitize_filename};
9use crate::field_access::FieldResolver;
10use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup, HttpFixture, ValidationErrorExpectation};
11use alef_core::backend::GeneratedFile;
12use alef_core::config::AlefConfig;
13use alef_core::hash::{self, CommentStyle};
14use alef_core::template_versions as tv;
15use anyhow::Result;
16use heck::{ToSnakeCase, ToUpperCamelCase};
17use std::collections::HashMap;
18use std::fmt::Write as FmtWrite;
19use std::path::PathBuf;
20
21use super::E2eCodegen;
22use super::client;
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(|| {
73 let org = alef_config
76 .try_github_repo()
77 .ok()
78 .as_deref()
79 .and_then(alef_core::config::derive_repo_org)
80 .unwrap_or_else(|| alef_config.crate_config.name.clone());
81 format!("{org}/{}", call.module.replace('_', "-"))
82 });
83 let pkg_path = php_pkg
84 .as_ref()
85 .and_then(|p| p.path.as_ref())
86 .cloned()
87 .unwrap_or_else(|| "../../packages/php".to_string());
88 let pkg_version = php_pkg
89 .as_ref()
90 .and_then(|p| p.version.as_ref())
91 .cloned()
92 .unwrap_or_else(|| "0.1.0".to_string());
93
94 let e2e_vendor = pkg_name.split('/').next().unwrap_or(&pkg_name).to_string();
99 let e2e_pkg_name = format!("{e2e_vendor}/e2e-php");
100 let php_namespace_escaped = alef_config.php_autoload_namespace().replace('\\', "\\\\");
105 let e2e_autoload_ns = format!("{php_namespace_escaped}\\\\E2e\\\\");
106
107 files.push(GeneratedFile {
109 path: output_base.join("composer.json"),
110 content: render_composer_json(
111 &e2e_pkg_name,
112 &e2e_autoload_ns,
113 &pkg_name,
114 &pkg_path,
115 &pkg_version,
116 e2e_config.dep_mode,
117 ),
118 generated_header: false,
119 });
120
121 files.push(GeneratedFile {
123 path: output_base.join("phpunit.xml"),
124 content: render_phpunit_xml(),
125 generated_header: false,
126 });
127
128 let has_http_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| f.is_http_test());
130
131 files.push(GeneratedFile {
133 path: output_base.join("bootstrap.php"),
134 content: render_bootstrap(&pkg_path, has_http_fixtures),
135 generated_header: true,
136 });
137
138 let tests_base = output_base.join("tests");
140 let field_resolver = FieldResolver::new(
141 &e2e_config.fields,
142 &e2e_config.fields_optional,
143 &e2e_config.result_fields,
144 &e2e_config.fields_array,
145 );
146
147 for group in groups {
148 let active: Vec<&Fixture> = group
149 .fixtures
150 .iter()
151 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
152 .collect();
153
154 if active.is_empty() {
155 continue;
156 }
157
158 let test_class = format!("{}Test", sanitize_filename(&group.category).to_upper_camel_case());
159 let filename = format!("{test_class}.php");
160 let content = render_test_file(
161 &group.category,
162 &active,
163 e2e_config,
164 lang,
165 &namespace,
166 &class_name,
167 &test_class,
168 &field_resolver,
169 enum_fields,
170 result_is_simple,
171 php_client_factory,
172 options_via,
173 );
174 files.push(GeneratedFile {
175 path: tests_base.join(filename),
176 content,
177 generated_header: true,
178 });
179 }
180
181 Ok(files)
182 }
183
184 fn language_name(&self) -> &'static str {
185 "php"
186 }
187}
188
189fn render_composer_json(
194 e2e_pkg_name: &str,
195 e2e_autoload_ns: &str,
196 pkg_name: &str,
197 _pkg_path: &str,
198 pkg_version: &str,
199 dep_mode: crate::config::DependencyMode,
200) -> String {
201 let require_section = match dep_mode {
202 crate::config::DependencyMode::Registry => {
203 format!(
204 r#" "require": {{
205 "{pkg_name}": "{pkg_version}"
206 }},
207 "require-dev": {{
208 "phpunit/phpunit": "{phpunit}",
209 "guzzlehttp/guzzle": "{guzzle}"
210 }},"#,
211 phpunit = tv::packagist::PHPUNIT,
212 guzzle = tv::packagist::GUZZLE,
213 )
214 }
215 crate::config::DependencyMode::Local => format!(
216 r#" "require-dev": {{
217 "phpunit/phpunit": "{phpunit}",
218 "guzzlehttp/guzzle": "{guzzle}"
219 }},"#,
220 phpunit = tv::packagist::PHPUNIT,
221 guzzle = tv::packagist::GUZZLE,
222 ),
223 };
224
225 format!(
226 r#"{{
227 "name": "{e2e_pkg_name}",
228 "description": "E2e tests for PHP bindings",
229 "type": "project",
230{require_section}
231 "autoload-dev": {{
232 "psr-4": {{
233 "{e2e_autoload_ns}": "tests/"
234 }}
235 }}
236}}
237"#
238 )
239}
240
241fn render_phpunit_xml() -> String {
242 r#"<?xml version="1.0" encoding="UTF-8"?>
243<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
244 xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/13.1/phpunit.xsd"
245 bootstrap="bootstrap.php"
246 colors="true"
247 failOnRisky="true"
248 failOnWarning="true">
249 <testsuites>
250 <testsuite name="e2e">
251 <directory>tests</directory>
252 </testsuite>
253 </testsuites>
254</phpunit>
255"#
256 .to_string()
257}
258
259fn render_bootstrap(pkg_path: &str, has_http_fixtures: bool) -> String {
260 let header = hash::header(CommentStyle::DoubleSlash);
261 let mock_server_block = if has_http_fixtures {
262 r#"
263// Spawn the mock HTTP server binary for HTTP fixture tests.
264$mockServerBin = __DIR__ . '/../rust/target/release/mock-server';
265$fixturesDir = __DIR__ . '/../../fixtures';
266if (file_exists($mockServerBin)) {
267 $descriptors = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => STDERR];
268 $proc = proc_open([$mockServerBin, $fixturesDir], $descriptors, $pipes);
269 if (is_resource($proc)) {
270 $line = fgets($pipes[1]);
271 if ($line !== false && str_starts_with($line, 'MOCK_SERVER_URL=')) {
272 putenv(trim($line));
273 $_ENV['MOCK_SERVER_URL'] = trim(substr(trim($line), strlen('MOCK_SERVER_URL=')));
274 }
275 // Drain stdout in background thread is not possible in PHP; keep pipe open.
276 register_shutdown_function(static function () use ($proc, $pipes): void {
277 fclose($pipes[0]);
278 proc_close($proc);
279 });
280 }
281}
282"#
283 } else {
284 ""
285 };
286 format!(
287 r#"<?php
288{header}
289declare(strict_types=1);
290
291// Load the e2e project autoloader (PHPUnit, test helpers).
292require_once __DIR__ . '/vendor/autoload.php';
293
294// Load the PHP binding package classes via its Composer autoloader.
295// The package's autoloader is separate from the e2e project's autoloader
296// since the php-ext type prevents direct composer path dependency.
297$pkgAutoloader = __DIR__ . '/{pkg_path}/vendor/autoload.php';
298if (file_exists($pkgAutoloader)) {{
299 require_once $pkgAutoloader;
300}}{mock_server_block}
301"#
302 )
303}
304
305#[allow(clippy::too_many_arguments)]
306fn render_test_file(
307 category: &str,
308 fixtures: &[&Fixture],
309 e2e_config: &E2eConfig,
310 lang: &str,
311 namespace: &str,
312 class_name: &str,
313 test_class: &str,
314 field_resolver: &FieldResolver,
315 enum_fields: &HashMap<String, String>,
316 result_is_simple: bool,
317 php_client_factory: Option<&str>,
318 options_via: &str,
319) -> String {
320 let mut out = String::new();
321 let _ = writeln!(out, "<?php");
322 out.push_str(&hash::header(CommentStyle::DoubleSlash));
323 let _ = writeln!(out);
324 let _ = writeln!(out, "declare(strict_types=1);");
325 let _ = writeln!(out);
326 let _ = writeln!(out, "namespace Kreuzberg\\E2e;");
327 let _ = writeln!(out);
328
329 let needs_crawl_config_import = fixtures.iter().any(|f| {
331 let call = e2e_config.resolve_call(f.call.as_deref());
332 call.args.iter().filter(|a| a.arg_type == "handle").any(|a| {
333 let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
334 !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
335 })
336 });
337
338 let has_http_tests = fixtures.iter().any(|f| f.is_http_test());
340
341 let _ = writeln!(out, "use PHPUnit\\Framework\\TestCase;");
342 let _ = writeln!(out, "use {namespace}\\{class_name};");
343 if needs_crawl_config_import {
344 let _ = writeln!(out, "use {namespace}\\CrawlConfig;");
345 }
346 if has_http_tests {
347 let _ = writeln!(out, "use GuzzleHttp\\Client;");
348 }
349 let _ = writeln!(out);
350 let _ = writeln!(out, "/** E2e tests for category: {category}. */");
351 let _ = writeln!(out, "final class {test_class} extends TestCase");
352 let _ = writeln!(out, "{{");
353
354 if has_http_tests {
356 let _ = writeln!(out, " private Client $httpClient;");
357 let _ = writeln!(out);
358 let _ = writeln!(out, " protected function setUp(): void");
359 let _ = writeln!(out, " {{");
360 let _ = writeln!(out, " parent::setUp();");
361 let _ = writeln!(
362 out,
363 " $baseUrl = (string)(getenv('MOCK_SERVER_URL') ?: 'http://localhost:8080');"
364 );
365 let _ = writeln!(
366 out,
367 " $this->httpClient = new Client(['base_uri' => $baseUrl, 'http_errors' => false, 'decode_content' => false, 'allow_redirects' => false]);"
368 );
369 let _ = writeln!(out, " }}");
370 let _ = writeln!(out);
371 }
372
373 for (i, fixture) in fixtures.iter().enumerate() {
374 if fixture.is_http_test() {
375 render_http_test_method(&mut out, fixture, fixture.http.as_ref().unwrap());
376 } else {
377 render_test_method(
378 &mut out,
379 fixture,
380 e2e_config,
381 lang,
382 namespace,
383 class_name,
384 field_resolver,
385 enum_fields,
386 result_is_simple,
387 php_client_factory,
388 options_via,
389 );
390 }
391 if i + 1 < fixtures.len() {
392 let _ = writeln!(out);
393 }
394 }
395
396 let _ = writeln!(out, "}}");
397 out
398}
399
400struct PhpTestClientRenderer;
408
409impl client::TestClientRenderer for PhpTestClientRenderer {
410 fn language_name(&self) -> &'static str {
411 "php"
412 }
413
414 fn sanitize_test_name(&self, id: &str) -> String {
416 sanitize_filename(id)
417 }
418
419 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
425 let _ = writeln!(out, " /** {description} */");
426 let _ = writeln!(out, " public function test_{fn_name}(): void");
427 let _ = writeln!(out, " {{");
428 if let Some(reason) = skip_reason {
429 let reason_lit = format!("\"{}\"", escape_php(reason));
430 let _ = writeln!(out, " $this->markTestSkipped({reason_lit});");
431 }
432 }
433
434 fn render_test_close(&self, out: &mut String) {
436 let _ = writeln!(out, " }}");
437 }
438
439 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
444 let method = ctx.method.to_uppercase();
445
446 let mut opts: Vec<String> = Vec::new();
448
449 if let Some(body) = ctx.body {
450 let php_body = json_to_php(body);
451 opts.push(format!("'json' => {php_body}"));
452 }
453
454 let mut header_pairs: Vec<String> = Vec::new();
456 if let Some(ct) = ctx.content_type {
457 if !ctx.headers.keys().any(|k| k.to_lowercase() == "content-type") {
459 header_pairs.push(format!("\"Content-Type\" => \"{}\"", escape_php(ct)));
460 }
461 }
462 for (k, v) in ctx.headers {
463 header_pairs.push(format!("\"{}\" => \"{}\"", escape_php(k), escape_php(v)));
464 }
465 if !header_pairs.is_empty() {
466 opts.push(format!("'headers' => [{}]", header_pairs.join(", ")));
467 }
468
469 if !ctx.cookies.is_empty() {
470 let cookie_str = ctx
471 .cookies
472 .iter()
473 .map(|(k, v)| format!("{}={}", k, v))
474 .collect::<Vec<_>>()
475 .join("; ");
476 opts.push(format!("'headers' => ['Cookie' => \"{}\"]", escape_php(&cookie_str)));
477 }
478
479 if !ctx.query_params.is_empty() {
480 let pairs: Vec<String> = ctx
481 .query_params
482 .iter()
483 .map(|(k, v)| {
484 let val_str = match v {
485 serde_json::Value::String(s) => s.clone(),
486 other => other.to_string(),
487 };
488 format!("\"{}\" => \"{}\"", escape_php(k), escape_php(&val_str))
489 })
490 .collect();
491 opts.push(format!("'query' => [{}]", pairs.join(", ")));
492 }
493
494 let path_lit = format!("\"{}\"", escape_php(ctx.path));
495 if opts.is_empty() {
496 let _ = writeln!(
497 out,
498 " ${} = $this->httpClient->request('{method}', {path_lit});",
499 ctx.response_var,
500 );
501 } else {
502 let _ = writeln!(
503 out,
504 " ${} = $this->httpClient->request('{method}', {path_lit}, [",
505 ctx.response_var,
506 );
507 for opt in &opts {
508 let _ = writeln!(out, " {opt},");
509 }
510 let _ = writeln!(out, " ]);");
511 }
512 }
513
514 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
516 let _ = writeln!(
517 out,
518 " $this->assertEquals({status}, $response->getStatusCode());"
519 );
520 }
521
522 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
527 let header_key = name.to_lowercase();
528 let header_key_lit = format!("\"{}\"", escape_php(&header_key));
529 match expected {
530 "<<present>>" => {
531 let _ = writeln!(
532 out,
533 " $this->assertTrue($response->hasHeader({header_key_lit}));"
534 );
535 }
536 "<<absent>>" => {
537 let _ = writeln!(
538 out,
539 " $this->assertFalse($response->hasHeader({header_key_lit}));"
540 );
541 }
542 "<<uuid>>" => {
543 let _ = writeln!(
544 out,
545 " $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}));"
546 );
547 }
548 literal => {
549 let val_lit = format!("\"{}\"", escape_php(literal));
550 let _ = writeln!(
551 out,
552 " $this->assertEquals({val_lit}, $response->getHeaderLine({header_key_lit}));"
553 );
554 }
555 }
556 }
557
558 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
564 match expected {
565 serde_json::Value::String(s) if !s.is_empty() => {
566 let php_val = format!("\"{}\"", escape_php(s));
567 let _ = writeln!(
568 out,
569 " $this->assertEquals({php_val}, (string) $response->getBody());"
570 );
571 }
572 _ => {
573 let php_val = json_to_php(expected);
574 let _ = writeln!(
575 out,
576 " $body = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);"
577 );
578 let _ = writeln!(out, " $this->assertEquals({php_val}, $body);");
579 }
580 }
581 }
582
583 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
585 if let Some(obj) = expected.as_object() {
586 let _ = writeln!(
587 out,
588 " $body = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);"
589 );
590 for (key, val) in obj {
591 let php_key = format!("\"{}\"", escape_php(key));
592 let php_val = json_to_php(val);
593 let _ = writeln!(out, " $this->assertEquals({php_val}, $body[{php_key}]);");
594 }
595 }
596 }
597
598 fn render_assert_validation_errors(
601 &self,
602 out: &mut String,
603 _response_var: &str,
604 errors: &[ValidationErrorExpectation],
605 ) {
606 let _ = writeln!(out, " $body = json_decode((string) $response->getBody(), true);");
607 for err in errors {
608 let msg_lit = format!("\"{}\"", escape_php(&err.msg));
609 let _ = writeln!(
610 out,
611 " $this->assertStringContainsString({msg_lit}, json_encode($body, JSON_UNESCAPED_SLASHES));"
612 );
613 }
614 }
615}
616
617fn render_http_test_method(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
622 if http.expected_response.status_code == 101 {
626 let method_name = sanitize_filename(&fixture.id);
627 let description = &fixture.description;
628 let _ = writeln!(out, " /** {description} */");
629 let _ = writeln!(out, " public function test_{method_name}(): void");
630 let _ = writeln!(out, " {{");
631 let _ = writeln!(
632 out,
633 " $this->markTestSkipped('HTTP 101 WebSocket upgrade cannot be tested via Guzzle HTTP client');"
634 );
635 let _ = writeln!(out, " }}");
636 return;
637 }
638
639 client::http_call::render_http_test(out, &PhpTestClientRenderer, fixture);
640}
641
642#[allow(clippy::too_many_arguments)]
647fn render_test_method(
648 out: &mut String,
649 fixture: &Fixture,
650 e2e_config: &E2eConfig,
651 lang: &str,
652 namespace: &str,
653 class_name: &str,
654 field_resolver: &FieldResolver,
655 enum_fields: &HashMap<String, String>,
656 result_is_simple: bool,
657 php_client_factory: Option<&str>,
658 options_via: &str,
659) {
660 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
662 let call_overrides = call_config.overrides.get(lang);
663 let mut function_name = call_overrides
664 .and_then(|o| o.function.as_ref())
665 .cloned()
666 .unwrap_or_else(|| call_config.function.clone());
667 if call_config.r#async {
669 function_name = format!("{function_name}_async");
670 }
671 let result_var = &call_config.result_var;
672 let args = &call_config.args;
673
674 let method_name = sanitize_filename(&fixture.id);
675 let description = &fixture.description;
676 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
677
678 let (mut setup_lines, args_str) =
679 build_args_and_setup(&fixture.input, args, class_name, enum_fields, &fixture.id, options_via);
680
681 let mut visitor_arg = String::new();
683 if let Some(visitor_spec) = &fixture.visitor {
684 visitor_arg = build_php_visitor(&mut setup_lines, visitor_spec);
685 }
686
687 let final_args = if visitor_arg.is_empty() {
688 args_str
689 } else if args_str.is_empty() {
690 format!("(object)['visitor' => {visitor_arg}]")
691 } else {
692 format!("{args_str}, (object)['visitor' => {visitor_arg}]")
693 };
694
695 let call_expr = if php_client_factory.is_some() {
696 format!("$client->{function_name}({final_args})")
697 } else {
698 format!("{class_name}::{function_name}({final_args})")
699 };
700
701 let _ = writeln!(out, " /** {description} */");
702 let _ = writeln!(out, " public function test_{method_name}(): void");
703 let _ = writeln!(out, " {{");
704
705 if let Some(factory) = php_client_factory {
706 let _ = writeln!(
707 out,
708 " $client = \\{namespace}\\{class_name}::{factory}('test-key');"
709 );
710 }
711
712 for line in &setup_lines {
713 let _ = writeln!(out, " {line}");
714 }
715
716 if expects_error {
717 let _ = writeln!(out, " $this->expectException(\\Exception::class);");
718 let _ = writeln!(out, " {call_expr};");
719 let _ = writeln!(out, " }}");
720 return;
721 }
722
723 if fixture.assertions.is_empty() {
726 let _ = writeln!(
727 out,
728 " $this->markTestSkipped('no assertions configured for this fixture in php e2e');"
729 );
730 let _ = writeln!(out, " }}");
731 return;
732 }
733
734 let has_usable = fixture.assertions.iter().any(|a| {
737 if a.assertion_type == "error" || a.assertion_type == "not_error" {
738 return false;
739 }
740 match &a.field {
741 Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
742 _ => true,
743 }
744 });
745 if !has_usable {
746 let _ = writeln!(out, " $this->expectNotToPerformAssertions();");
747 }
748
749 let _ = writeln!(out, " ${result_var} = {call_expr};");
750
751 for assertion in &fixture.assertions {
752 render_assertion(out, assertion, result_var, field_resolver, result_is_simple);
753 }
754
755 let _ = writeln!(out, " }}");
756}
757
758fn build_args_and_setup(
766 input: &serde_json::Value,
767 args: &[crate::config::ArgMapping],
768 class_name: &str,
769 enum_fields: &HashMap<String, String>,
770 fixture_id: &str,
771 options_via: &str,
772) -> (Vec<String>, String) {
773 if args.is_empty() {
774 let is_empty_input = match input {
777 serde_json::Value::Null => true,
778 serde_json::Value::Object(m) => m.is_empty(),
779 _ => false,
780 };
781 if is_empty_input {
782 return (Vec::new(), String::new());
783 }
784 return (Vec::new(), json_to_php(input));
785 }
786
787 let mut setup_lines: Vec<String> = Vec::new();
788 let mut parts: Vec<String> = Vec::new();
789
790 for arg in args {
791 if arg.arg_type == "mock_url" {
792 setup_lines.push(format!(
793 "${} = getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}';",
794 arg.name,
795 ));
796 parts.push(format!("${}", arg.name));
797 continue;
798 }
799
800 if arg.arg_type == "handle" {
801 let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
803 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
804 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
805 if config_value.is_null()
806 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
807 {
808 setup_lines.push(format!("${} = {class_name}::{constructor_name}(null);", arg.name,));
809 } else {
810 let name = &arg.name;
811 setup_lines.push(format!("${name}_config = CrawlConfig::default();"));
815 if let Some(obj) = config_value.as_object() {
816 for (key, val) in obj {
817 let php_val = json_to_php(val);
818 setup_lines.push(format!("${name}_config->{key} = {php_val};"));
819 }
820 }
821 setup_lines.push(format!(
822 "${} = {class_name}::{constructor_name}(${name}_config);",
823 arg.name,
824 ));
825 }
826 parts.push(format!("${}", arg.name));
827 continue;
828 }
829
830 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
831 let val = input.get(field);
832 match val {
833 None | Some(serde_json::Value::Null) if arg.optional => {
834 continue;
836 }
837 None | Some(serde_json::Value::Null) => {
838 let default_val = match arg.arg_type.as_str() {
840 "string" => "\"\"".to_string(),
841 "int" | "integer" => "0".to_string(),
842 "float" | "number" => "0.0".to_string(),
843 "bool" | "boolean" => "false".to_string(),
844 "json_object" if options_via == "json" => "null".to_string(),
845 _ => "null".to_string(),
846 };
847 parts.push(default_val);
848 }
849 Some(v) => {
850 if arg.arg_type == "json_object" && !v.is_null() {
851 match options_via {
852 "json" => {
853 parts.push(format!("json_encode({})", json_to_php(v)));
855 continue;
856 }
857 _ => {
858 if let Some(obj) = v.as_object() {
860 let items: Vec<String> = obj
861 .iter()
862 .map(|(k, vv)| {
863 let snake_key = k.to_snake_case();
864 let php_val = if enum_fields.contains_key(k) {
865 if let Some(s) = vv.as_str() {
866 let snake_val = s.to_snake_case();
867 format!("\"{}\"", escape_php(&snake_val))
868 } else {
869 json_to_php(vv)
870 }
871 } else {
872 json_to_php(vv)
873 };
874 format!("\"{}\" => {}", escape_php(&snake_key), php_val)
875 })
876 .collect();
877 parts.push(format!("[{}]", items.join(", ")));
878 continue;
879 }
880 }
881 }
882 }
883 parts.push(json_to_php(v));
884 }
885 }
886 }
887
888 (setup_lines, parts.join(", "))
889}
890
891fn render_assertion(
892 out: &mut String,
893 assertion: &Assertion,
894 result_var: &str,
895 field_resolver: &FieldResolver,
896 result_is_simple: bool,
897) {
898 if let Some(f) = &assertion.field {
901 match f.as_str() {
902 "chunks_have_content" => {
903 let pred = format!(
904 "array_reduce(${result_var}->chunks ?? [], fn($carry, $c) => $carry && !empty($c->content), true)"
905 );
906 match assertion.assertion_type.as_str() {
907 "is_true" => {
908 let _ = writeln!(out, " $this->assertTrue({pred});");
909 }
910 "is_false" => {
911 let _ = writeln!(out, " $this->assertFalse({pred});");
912 }
913 _ => {
914 let _ = writeln!(
915 out,
916 " // skipped: unsupported assertion type on synthetic field '{f}'"
917 );
918 }
919 }
920 return;
921 }
922 "chunks_have_embeddings" => {
923 let pred = format!(
924 "array_reduce(${result_var}->chunks ?? [], fn($carry, $c) => $carry && !empty($c->embedding), true)"
925 );
926 match assertion.assertion_type.as_str() {
927 "is_true" => {
928 let _ = writeln!(out, " $this->assertTrue({pred});");
929 }
930 "is_false" => {
931 let _ = writeln!(out, " $this->assertFalse({pred});");
932 }
933 _ => {
934 let _ = writeln!(
935 out,
936 " // skipped: unsupported assertion type on synthetic field '{f}'"
937 );
938 }
939 }
940 return;
941 }
942 "embeddings" => {
946 match assertion.assertion_type.as_str() {
947 "count_equals" => {
948 if let Some(val) = &assertion.value {
949 let php_val = json_to_php(val);
950 let _ = writeln!(out, " $this->assertCount({php_val}, ${result_var});");
951 }
952 }
953 "count_min" => {
954 if let Some(val) = &assertion.value {
955 let php_val = json_to_php(val);
956 let _ = writeln!(
957 out,
958 " $this->assertGreaterThanOrEqual({php_val}, count(${result_var}));"
959 );
960 }
961 }
962 "not_empty" => {
963 let _ = writeln!(out, " $this->assertNotEmpty(${result_var});");
964 }
965 "is_empty" => {
966 let _ = writeln!(out, " $this->assertEmpty(${result_var});");
967 }
968 _ => {
969 let _ = writeln!(
970 out,
971 " // skipped: unsupported assertion type on synthetic field 'embeddings'"
972 );
973 }
974 }
975 return;
976 }
977 "embedding_dimensions" => {
978 let expr = format!("(empty(${result_var}) ? 0 : count(${result_var}[0]))");
979 match assertion.assertion_type.as_str() {
980 "equals" => {
981 if let Some(val) = &assertion.value {
982 let php_val = json_to_php(val);
983 let _ = writeln!(out, " $this->assertEquals({php_val}, {expr});");
984 }
985 }
986 "greater_than" => {
987 if let Some(val) = &assertion.value {
988 let php_val = json_to_php(val);
989 let _ = writeln!(out, " $this->assertGreaterThan({php_val}, {expr});");
990 }
991 }
992 _ => {
993 let _ = writeln!(
994 out,
995 " // skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
996 );
997 }
998 }
999 return;
1000 }
1001 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1002 let pred = match f.as_str() {
1003 "embeddings_valid" => {
1004 format!("array_reduce(${result_var}, fn($carry, $e) => $carry && count($e) > 0, true)")
1005 }
1006 "embeddings_finite" => {
1007 format!(
1008 "array_reduce(${result_var}, fn($carry, $e) => $carry && array_reduce($e, fn($c, $v) => $c && is_finite($v), true), true)"
1009 )
1010 }
1011 "embeddings_non_zero" => {
1012 format!(
1013 "array_reduce(${result_var}, fn($carry, $e) => $carry && count(array_filter($e, fn($v) => $v !== 0.0)) > 0, true)"
1014 )
1015 }
1016 "embeddings_normalized" => {
1017 format!(
1018 "array_reduce(${result_var}, fn($carry, $e) => $carry && abs(array_sum(array_map(fn($v) => $v * $v, $e)) - 1.0) < 1e-3, true)"
1019 )
1020 }
1021 _ => unreachable!(),
1022 };
1023 match assertion.assertion_type.as_str() {
1024 "is_true" => {
1025 let _ = writeln!(out, " $this->assertTrue({pred});");
1026 }
1027 "is_false" => {
1028 let _ = writeln!(out, " $this->assertFalse({pred});");
1029 }
1030 _ => {
1031 let _ = writeln!(
1032 out,
1033 " // skipped: unsupported assertion type on synthetic field '{f}'"
1034 );
1035 }
1036 }
1037 return;
1038 }
1039 "keywords" | "keywords_count" => {
1042 let _ = writeln!(
1043 out,
1044 " // skipped: field '{f}' not available on PHP ExtractionResult"
1045 );
1046 return;
1047 }
1048 _ => {}
1049 }
1050 }
1051
1052 if let Some(f) = &assertion.field {
1054 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1055 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
1056 return;
1057 }
1058 }
1059
1060 if result_is_simple {
1063 if let Some(f) = &assertion.field {
1064 let f_lower = f.to_lowercase();
1065 if !f.is_empty()
1066 && f_lower != "content"
1067 && (f_lower.starts_with("metadata")
1068 || f_lower.starts_with("document")
1069 || f_lower.starts_with("structure"))
1070 {
1071 let _ = writeln!(
1072 out,
1073 " // skipped: result_is_simple, field '{f}' not on simple result type"
1074 );
1075 return;
1076 }
1077 }
1078 }
1079
1080 let field_expr = if result_is_simple {
1081 format!("${result_var}")
1082 } else {
1083 match &assertion.field {
1084 Some(f) if !f.is_empty() => field_resolver.accessor(f, "php", &format!("${result_var}")),
1085 _ => format!("${result_var}"),
1086 }
1087 };
1088
1089 let trimmed_field_expr = if result_is_simple {
1091 format!("trim(${result_var})")
1092 } else {
1093 field_expr.clone()
1094 };
1095
1096 match assertion.assertion_type.as_str() {
1097 "equals" => {
1098 if let Some(expected) = &assertion.value {
1099 let php_val = json_to_php(expected);
1100 let _ = writeln!(out, " $this->assertEquals({php_val}, {trimmed_field_expr});");
1101 }
1102 }
1103 "contains" => {
1104 if let Some(expected) = &assertion.value {
1105 let php_val = json_to_php(expected);
1106 let _ = writeln!(
1107 out,
1108 " $this->assertStringContainsString({php_val}, {field_expr});"
1109 );
1110 }
1111 }
1112 "contains_all" => {
1113 if let Some(values) = &assertion.values {
1114 for val in values {
1115 let php_val = json_to_php(val);
1116 let _ = writeln!(
1117 out,
1118 " $this->assertStringContainsString({php_val}, {field_expr});"
1119 );
1120 }
1121 }
1122 }
1123 "not_contains" => {
1124 if let Some(expected) = &assertion.value {
1125 let php_val = json_to_php(expected);
1126 let _ = writeln!(
1127 out,
1128 " $this->assertStringNotContainsString({php_val}, {field_expr});"
1129 );
1130 }
1131 }
1132 "not_empty" => {
1133 let _ = writeln!(out, " $this->assertNotEmpty({field_expr});");
1134 }
1135 "is_empty" => {
1136 let _ = writeln!(out, " $this->assertEmpty({trimmed_field_expr});");
1137 }
1138 "contains_any" => {
1139 if let Some(values) = &assertion.values {
1140 let _ = writeln!(out, " $found = false;");
1141 for val in values {
1142 let php_val = json_to_php(val);
1143 let _ = writeln!(
1144 out,
1145 " if (str_contains({field_expr}, {php_val})) {{ $found = true; }}"
1146 );
1147 }
1148 let _ = writeln!(
1149 out,
1150 " $this->assertTrue($found, 'expected to contain at least one of the specified values');"
1151 );
1152 }
1153 }
1154 "greater_than" => {
1155 if let Some(val) = &assertion.value {
1156 let php_val = json_to_php(val);
1157 let _ = writeln!(out, " $this->assertGreaterThan({php_val}, {field_expr});");
1158 }
1159 }
1160 "less_than" => {
1161 if let Some(val) = &assertion.value {
1162 let php_val = json_to_php(val);
1163 let _ = writeln!(out, " $this->assertLessThan({php_val}, {field_expr});");
1164 }
1165 }
1166 "greater_than_or_equal" => {
1167 if let Some(val) = &assertion.value {
1168 let php_val = json_to_php(val);
1169 let _ = writeln!(out, " $this->assertGreaterThanOrEqual({php_val}, {field_expr});");
1170 }
1171 }
1172 "less_than_or_equal" => {
1173 if let Some(val) = &assertion.value {
1174 let php_val = json_to_php(val);
1175 let _ = writeln!(out, " $this->assertLessThanOrEqual({php_val}, {field_expr});");
1176 }
1177 }
1178 "starts_with" => {
1179 if let Some(expected) = &assertion.value {
1180 let php_val = json_to_php(expected);
1181 let _ = writeln!(out, " $this->assertStringStartsWith({php_val}, {field_expr});");
1182 }
1183 }
1184 "ends_with" => {
1185 if let Some(expected) = &assertion.value {
1186 let php_val = json_to_php(expected);
1187 let _ = writeln!(out, " $this->assertStringEndsWith({php_val}, {field_expr});");
1188 }
1189 }
1190 "min_length" => {
1191 if let Some(val) = &assertion.value {
1192 if let Some(n) = val.as_u64() {
1193 let _ = writeln!(
1194 out,
1195 " $this->assertGreaterThanOrEqual({n}, strlen({field_expr}));"
1196 );
1197 }
1198 }
1199 }
1200 "max_length" => {
1201 if let Some(val) = &assertion.value {
1202 if let Some(n) = val.as_u64() {
1203 let _ = writeln!(out, " $this->assertLessThanOrEqual({n}, strlen({field_expr}));");
1204 }
1205 }
1206 }
1207 "count_min" => {
1208 if let Some(val) = &assertion.value {
1209 if let Some(n) = val.as_u64() {
1210 let _ = writeln!(
1211 out,
1212 " $this->assertGreaterThanOrEqual({n}, count({field_expr}));"
1213 );
1214 }
1215 }
1216 }
1217 "count_equals" => {
1218 if let Some(val) = &assertion.value {
1219 if let Some(n) = val.as_u64() {
1220 let _ = writeln!(out, " $this->assertCount({n}, {field_expr});");
1221 }
1222 }
1223 }
1224 "is_true" => {
1225 let _ = writeln!(out, " $this->assertTrue({field_expr});");
1226 }
1227 "is_false" => {
1228 let _ = writeln!(out, " $this->assertFalse({field_expr});");
1229 }
1230 "method_result" => {
1231 if let Some(method_name) = &assertion.method {
1232 let call_expr = build_php_method_call(result_var, method_name, assertion.args.as_ref());
1233 let check = assertion.check.as_deref().unwrap_or("is_true");
1234 match check {
1235 "equals" => {
1236 if let Some(val) = &assertion.value {
1237 if val.is_boolean() {
1238 if val.as_bool() == Some(true) {
1239 let _ = writeln!(out, " $this->assertTrue({call_expr});");
1240 } else {
1241 let _ = writeln!(out, " $this->assertFalse({call_expr});");
1242 }
1243 } else {
1244 let expected = json_to_php(val);
1245 let _ = writeln!(out, " $this->assertEquals({expected}, {call_expr});");
1246 }
1247 }
1248 }
1249 "is_true" => {
1250 let _ = writeln!(out, " $this->assertTrue({call_expr});");
1251 }
1252 "is_false" => {
1253 let _ = writeln!(out, " $this->assertFalse({call_expr});");
1254 }
1255 "greater_than_or_equal" => {
1256 if let Some(val) = &assertion.value {
1257 let n = val.as_u64().unwrap_or(0);
1258 let _ = writeln!(out, " $this->assertGreaterThanOrEqual({n}, {call_expr});");
1259 }
1260 }
1261 "count_min" => {
1262 if let Some(val) = &assertion.value {
1263 let n = val.as_u64().unwrap_or(0);
1264 let _ = writeln!(out, " $this->assertGreaterThanOrEqual({n}, count({call_expr}));");
1265 }
1266 }
1267 "is_error" => {
1268 let _ = writeln!(out, " $this->expectException(\\Exception::class);");
1269 let _ = writeln!(out, " {call_expr};");
1270 }
1271 "contains" => {
1272 if let Some(val) = &assertion.value {
1273 let expected = json_to_php(val);
1274 let _ = writeln!(
1275 out,
1276 " $this->assertStringContainsString({expected}, {call_expr});"
1277 );
1278 }
1279 }
1280 other_check => {
1281 panic!("PHP e2e generator: unsupported method_result check type: {other_check}");
1282 }
1283 }
1284 } else {
1285 panic!("PHP e2e generator: method_result assertion missing 'method' field");
1286 }
1287 }
1288 "matches_regex" => {
1289 if let Some(expected) = &assertion.value {
1290 let php_val = json_to_php(expected);
1291 let _ = writeln!(
1292 out,
1293 " $this->assertMatchesRegularExpression({php_val}, {field_expr});"
1294 );
1295 }
1296 }
1297 "not_error" => {
1298 }
1300 "error" => {
1301 }
1303 other => {
1304 panic!("PHP e2e generator: unsupported assertion type: {other}");
1305 }
1306 }
1307}
1308
1309fn build_php_method_call(result_var: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
1314 match method_name {
1315 "root_child_count" => {
1316 format!("count(TreeSitterLanguagePack::named_children_info(${result_var}))")
1317 }
1318 "root_node_type" => {
1319 format!("TreeSitterLanguagePack::root_node_info(${result_var})->kind")
1320 }
1321 "named_children_count" => {
1322 format!("count(TreeSitterLanguagePack::named_children_info(${result_var}))")
1323 }
1324 "has_error_nodes" => {
1325 format!("TreeSitterLanguagePack::tree_has_error_nodes(${result_var})")
1326 }
1327 "error_count" | "tree_error_count" => {
1328 format!("TreeSitterLanguagePack::tree_error_count(${result_var})")
1329 }
1330 "tree_to_sexp" => {
1331 format!("TreeSitterLanguagePack::tree_to_sexp(${result_var})")
1332 }
1333 "contains_node_type" => {
1334 let node_type = args
1335 .and_then(|a| a.get("node_type"))
1336 .and_then(|v| v.as_str())
1337 .unwrap_or("");
1338 format!("TreeSitterLanguagePack::tree_contains_node_type(${result_var}, \"{node_type}\")")
1339 }
1340 "find_nodes_by_type" => {
1341 let node_type = args
1342 .and_then(|a| a.get("node_type"))
1343 .and_then(|v| v.as_str())
1344 .unwrap_or("");
1345 format!("TreeSitterLanguagePack::find_nodes_by_type(${result_var}, \"{node_type}\")")
1346 }
1347 "run_query" => {
1348 let query_source = args
1349 .and_then(|a| a.get("query_source"))
1350 .and_then(|v| v.as_str())
1351 .unwrap_or("");
1352 let language = args
1353 .and_then(|a| a.get("language"))
1354 .and_then(|v| v.as_str())
1355 .unwrap_or("");
1356 format!("TreeSitterLanguagePack::run_query(${result_var}, \"{language}\", \"{query_source}\", $source)")
1357 }
1358 _ => {
1359 format!("${result_var}->{method_name}()")
1360 }
1361 }
1362}
1363
1364fn json_to_php(value: &serde_json::Value) -> String {
1366 match value {
1367 serde_json::Value::String(s) => format!("\"{}\"", escape_php(s)),
1368 serde_json::Value::Bool(true) => "true".to_string(),
1369 serde_json::Value::Bool(false) => "false".to_string(),
1370 serde_json::Value::Number(n) => n.to_string(),
1371 serde_json::Value::Null => "null".to_string(),
1372 serde_json::Value::Array(arr) => {
1373 let items: Vec<String> = arr.iter().map(json_to_php).collect();
1374 format!("[{}]", items.join(", "))
1375 }
1376 serde_json::Value::Object(map) => {
1377 let items: Vec<String> = map
1378 .iter()
1379 .map(|(k, v)| format!("\"{}\" => {}", escape_php(k), json_to_php(v)))
1380 .collect();
1381 format!("[{}]", items.join(", "))
1382 }
1383 }
1384}
1385
1386fn build_php_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1392 setup_lines.push("$visitor = new class {".to_string());
1393 for (method_name, action) in &visitor_spec.callbacks {
1394 emit_php_visitor_method(setup_lines, method_name, action);
1395 }
1396 setup_lines.push("};".to_string());
1397 "$visitor".to_string()
1398}
1399
1400fn emit_php_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
1402 let snake_method = method_name;
1403 let params = match method_name {
1404 "visit_link" => "$ctx, $href, $text, $title",
1405 "visit_image" => "$ctx, $src, $alt, $title",
1406 "visit_heading" => "$ctx, $level, $text, $id",
1407 "visit_code_block" => "$ctx, $lang, $code",
1408 "visit_code_inline"
1409 | "visit_strong"
1410 | "visit_emphasis"
1411 | "visit_strikethrough"
1412 | "visit_underline"
1413 | "visit_subscript"
1414 | "visit_superscript"
1415 | "visit_mark"
1416 | "visit_button"
1417 | "visit_summary"
1418 | "visit_figcaption"
1419 | "visit_definition_term"
1420 | "visit_definition_description" => "$ctx, $text",
1421 "visit_text" => "$ctx, $text",
1422 "visit_list_item" => "$ctx, $ordered, $marker, $text",
1423 "visit_blockquote" => "$ctx, $content, $depth",
1424 "visit_table_row" => "$ctx, $cells, $isHeader",
1425 "visit_custom_element" => "$ctx, $tagName, $html",
1426 "visit_form" => "$ctx, $actionUrl, $method",
1427 "visit_input" => "$ctx, $inputType, $name, $value",
1428 "visit_audio" | "visit_video" | "visit_iframe" => "$ctx, $src",
1429 "visit_details" => "$ctx, $isOpen",
1430 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "$ctx, $output",
1431 "visit_list_start" => "$ctx, $ordered",
1432 "visit_list_end" => "$ctx, $ordered, $output",
1433 _ => "$ctx",
1434 };
1435
1436 setup_lines.push(format!(" public function {snake_method}({params}) {{"));
1437 match action {
1438 CallbackAction::Skip => {
1439 setup_lines.push(" return 'skip';".to_string());
1440 }
1441 CallbackAction::Continue => {
1442 setup_lines.push(" return 'continue';".to_string());
1443 }
1444 CallbackAction::PreserveHtml => {
1445 setup_lines.push(" return 'preserve_html';".to_string());
1446 }
1447 CallbackAction::Custom { output } => {
1448 let escaped = escape_php(output);
1449 setup_lines.push(format!(" return ['custom' => \"{escaped}\"];"));
1450 }
1451 CallbackAction::CustomTemplate { template } => {
1452 let escaped = escape_php(template);
1453 setup_lines.push(format!(" return ['custom' => \"{escaped}\"];"));
1454 }
1455 }
1456 setup_lines.push(" }".to_string());
1457}