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