1use crate::config::E2eConfig;
8use crate::escape::{escape_php, sanitize_filename};
9use crate::field_access::FieldResolver;
10use crate::fixture::{
11 Assertion, CallbackAction, Fixture, FixtureGroup, HttpFixture, TemplateReturnForm, ValidationErrorExpectation,
12};
13use alef_backend_php::naming::php_autoload_namespace;
14use alef_core::backend::GeneratedFile;
15use alef_core::config::ResolvedCrateConfig;
16use alef_core::hash::{self, CommentStyle};
17use alef_core::ir::TypeRef;
18use alef_core::template_versions as tv;
19use anyhow::Result;
20use heck::{ToLowerCamelCase, ToSnakeCase, ToUpperCamelCase};
21use std::collections::{HashMap, HashSet};
22use std::fmt::Write as FmtWrite;
23use std::path::PathBuf;
24
25use super::E2eCodegen;
26use super::client;
27
28pub struct PhpCodegen;
30
31impl E2eCodegen for PhpCodegen {
32 fn generate(
33 &self,
34 groups: &[FixtureGroup],
35 e2e_config: &E2eConfig,
36 config: &ResolvedCrateConfig,
37 type_defs: &[alef_core::ir::TypeDef],
38 enums: &[alef_core::ir::EnumDef],
39 ) -> Result<Vec<GeneratedFile>> {
40 let lang = self.language_name();
41 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
42
43 let mut files = Vec::new();
44
45 let call = &e2e_config.call;
49 let overrides = call.overrides.get(lang);
50 let extension_name = config.php_extension_name();
51 let class_name = overrides
52 .and_then(|o| o.class.as_ref())
53 .cloned()
54 .map(|cn| cn.split('\\').next_back().unwrap_or(&cn).to_string())
55 .unwrap_or_else(|| extension_name.to_upper_camel_case());
56 let namespace = overrides.and_then(|o| o.module.as_ref()).cloned().unwrap_or_else(|| {
57 if extension_name.contains('_') {
58 extension_name
59 .split('_')
60 .map(|p| p.to_upper_camel_case())
61 .collect::<Vec<_>>()
62 .join("\\")
63 } else {
64 extension_name.to_upper_camel_case()
65 }
66 });
67 let empty_enum_fields = HashMap::new();
68 let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields);
69 let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
70 let php_client_factory = overrides.and_then(|o| o.php_client_factory.as_deref());
71 let options_via = overrides.and_then(|o| o.options_via.as_deref()).unwrap_or("array");
72
73 let php_pkg = e2e_config.resolve_package("php");
75 let pkg_name = php_pkg
76 .as_ref()
77 .and_then(|p| p.name.as_ref())
78 .cloned()
79 .unwrap_or_else(|| {
80 let org = config
83 .try_github_repo()
84 .ok()
85 .as_deref()
86 .and_then(alef_core::config::derive_repo_org)
87 .unwrap_or_else(|| config.name.clone());
88 format!("{org}/{}", call.module.replace('_', "-"))
89 });
90 let pkg_path = php_pkg
91 .as_ref()
92 .and_then(|p| p.path.as_ref())
93 .cloned()
94 .unwrap_or_else(|| "../../packages/php".to_string());
95 let pkg_version = php_pkg
96 .as_ref()
97 .and_then(|p| p.version.as_ref())
98 .cloned()
99 .or_else(|| config.resolved_version())
100 .unwrap_or_else(|| "0.1.0".to_string());
101
102 let e2e_vendor = pkg_name.split('/').next().unwrap_or(&pkg_name).to_string();
107 let e2e_pkg_name = format!("{e2e_vendor}/e2e-php");
108 let php_namespace_escaped = php_autoload_namespace(config).replace('\\', "\\\\");
113 let e2e_autoload_ns = format!("{php_namespace_escaped}\\\\E2e\\\\");
114
115 files.push(GeneratedFile {
117 path: output_base.join("composer.json"),
118 content: render_composer_json(
119 &e2e_pkg_name,
120 &e2e_autoload_ns,
121 &pkg_name,
122 &pkg_path,
123 &pkg_version,
124 e2e_config.dep_mode,
125 ),
126 generated_header: false,
127 });
128
129 files.push(GeneratedFile {
131 path: output_base.join("phpunit.xml"),
132 content: render_phpunit_xml(),
133 generated_header: false,
134 });
135
136 let has_http_fixtures = groups
139 .iter()
140 .flat_map(|g| g.fixtures.iter())
141 .any(|f| f.needs_mock_server());
142
143 let has_file_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| {
145 let cc = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
146 cc.args
147 .iter()
148 .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
149 });
150
151 files.push(GeneratedFile {
153 path: output_base.join("bootstrap.php"),
154 content: render_bootstrap(
155 &pkg_path,
156 has_http_fixtures,
157 has_file_fixtures,
158 &e2e_config.test_documents_relative_from(0),
159 ),
160 generated_header: true,
161 });
162
163 files.push(GeneratedFile {
165 path: output_base.join("run_tests.php"),
166 content: render_run_tests_php(&extension_name, config.php_cargo_crate_name()),
167 generated_header: true,
168 });
169
170 let tests_base = output_base.join("tests");
172
173 let php_enum_names: HashSet<String> = enums.iter().map(|e| e.name.clone()).collect();
179 let php_getter_fields: HashSet<String> = type_defs
180 .iter()
181 .flat_map(|td| td.fields.iter())
182 .filter(|f| !is_php_scalar(&f.ty, &php_enum_names))
183 .map(|f| f.name.clone())
184 .collect();
185
186 let field_resolver = FieldResolver::new_with_php_getters(
187 &e2e_config.fields,
188 &e2e_config.fields_optional,
189 &e2e_config.result_fields,
190 &e2e_config.fields_array,
191 &HashSet::new(),
192 &HashMap::new(),
193 &php_getter_fields,
194 );
195
196 for group in groups {
197 let active: Vec<&Fixture> = group
198 .fixtures
199 .iter()
200 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
201 .collect();
202
203 if active.is_empty() {
204 continue;
205 }
206
207 let test_class = format!("{}Test", sanitize_filename(&group.category).to_upper_camel_case());
208 let filename = format!("{test_class}.php");
209 let content = render_test_file(
210 &group.category,
211 &active,
212 e2e_config,
213 lang,
214 &namespace,
215 &class_name,
216 &test_class,
217 &field_resolver,
218 enum_fields,
219 result_is_simple,
220 php_client_factory,
221 options_via,
222 );
223 files.push(GeneratedFile {
224 path: tests_base.join(filename),
225 content,
226 generated_header: true,
227 });
228 }
229
230 Ok(files)
231 }
232
233 fn language_name(&self) -> &'static str {
234 "php"
235 }
236}
237
238fn is_php_scalar(ty: &TypeRef, enum_names: &HashSet<String>) -> bool {
251 match ty {
252 TypeRef::Primitive(_) | TypeRef::String | TypeRef::Char | TypeRef::Duration | TypeRef::Path => true,
253 TypeRef::Optional(inner) => is_php_scalar(inner, enum_names),
254 TypeRef::Vec(inner) => {
255 matches!(inner.as_ref(), TypeRef::Primitive(_) | TypeRef::String | TypeRef::Char)
256 || matches!(inner.as_ref(), TypeRef::Named(n) if enum_names.contains(n))
257 }
258 TypeRef::Named(n) if enum_names.contains(n) => true,
259 TypeRef::Named(_) | TypeRef::Map(_, _) | TypeRef::Json | TypeRef::Bytes | TypeRef::Unit => false,
260 }
261}
262
263fn render_composer_json(
268 e2e_pkg_name: &str,
269 e2e_autoload_ns: &str,
270 pkg_name: &str,
271 pkg_path: &str,
272 pkg_version: &str,
273 dep_mode: crate::config::DependencyMode,
274) -> String {
275 let (require_section, autoload_section) = match dep_mode {
276 crate::config::DependencyMode::Registry => {
277 let require = format!(
278 r#" "require": {{
279 "{pkg_name}": "{pkg_version}"
280 }},
281 "require-dev": {{
282 "phpunit/phpunit": "{phpunit}",
283 "guzzlehttp/guzzle": "{guzzle}"
284 }},"#,
285 phpunit = tv::packagist::PHPUNIT,
286 guzzle = tv::packagist::GUZZLE,
287 );
288 (require, String::new())
289 }
290 crate::config::DependencyMode::Local => {
291 let require = format!(
292 r#" "require-dev": {{
293 "phpunit/phpunit": "{phpunit}",
294 "guzzlehttp/guzzle": "{guzzle}"
295 }},"#,
296 phpunit = tv::packagist::PHPUNIT,
297 guzzle = tv::packagist::GUZZLE,
298 );
299 let pkg_namespace = pkg_name
302 .split('/')
303 .nth(1)
304 .unwrap_or(pkg_name)
305 .split('-')
306 .map(heck::ToUpperCamelCase::to_upper_camel_case)
307 .collect::<Vec<_>>()
308 .join("\\");
309 let autoload = format!(
310 r#"
311 "autoload": {{
312 "psr-4": {{
313 "{}\\": "{}/src/"
314 }}
315 }},"#,
316 pkg_namespace.replace('\\', "\\\\"),
317 pkg_path
318 );
319 (require, autoload)
320 }
321 };
322
323 crate::template_env::render(
324 "php/composer.json.jinja",
325 minijinja::context! {
326 e2e_pkg_name => e2e_pkg_name,
327 e2e_autoload_ns => e2e_autoload_ns,
328 require_section => require_section,
329 autoload_section => autoload_section,
330 },
331 )
332}
333
334fn render_phpunit_xml() -> String {
335 crate::template_env::render("php/phpunit.xml.jinja", minijinja::context! {})
336}
337
338fn render_bootstrap(
339 pkg_path: &str,
340 has_http_fixtures: bool,
341 has_file_fixtures: bool,
342 test_documents_path: &str,
343) -> String {
344 let header = hash::header(CommentStyle::DoubleSlash);
345 crate::template_env::render(
346 "php/bootstrap.php.jinja",
347 minijinja::context! {
348 header => header,
349 pkg_path => pkg_path,
350 has_http_fixtures => has_http_fixtures,
351 has_file_fixtures => has_file_fixtures,
352 test_documents_path => test_documents_path,
353 },
354 )
355}
356
357fn render_run_tests_php(extension_name: &str, cargo_crate_name: Option<&str>) -> String {
358 let header = hash::header(CommentStyle::DoubleSlash);
359 let ext_lib_name = if let Some(crate_name) = cargo_crate_name {
360 format!("lib{}", crate_name.replace('-', "_"))
363 } else {
364 format!("lib{extension_name}_php")
365 };
366 format!(
367 r#"#!/usr/bin/env php
368<?php
369{header}
370declare(strict_types=1);
371
372// Determine platform-specific extension suffix.
373$extSuffix = match (PHP_OS_FAMILY) {{
374 'Darwin' => '.dylib',
375 default => '.so',
376}};
377$extPath = __DIR__ . '/../../target/release/{ext_lib_name}' . $extSuffix;
378
379// If the locally-built extension exists and we have not already restarted with it,
380// re-exec PHP with no system ini (-n) to avoid conflicts with any system-installed
381// version of the extension, then load the local build explicitly.
382if (file_exists($extPath) && !getenv('ALEF_PHP_LOCAL_EXT_LOADED')) {{
383 putenv('ALEF_PHP_LOCAL_EXT_LOADED=1');
384 $php = PHP_BINARY;
385 $phpunitPath = __DIR__ . '/vendor/bin/phpunit';
386
387 $cmd = array_merge(
388 [$php, '-n', '-d', 'extension=' . $extPath],
389 [$phpunitPath],
390 array_slice($GLOBALS['argv'], 1)
391 );
392
393 passthru(implode(' ', array_map('escapeshellarg', $cmd)), $exitCode);
394 exit($exitCode);
395}}
396
397// Extension is now loaded (via the restart above with -n flag).
398// Invoke PHPUnit normally.
399$phpunitPath = __DIR__ . '/vendor/bin/phpunit';
400if (!file_exists($phpunitPath)) {{
401 echo "PHPUnit not found at $phpunitPath. Run 'composer install' first.\n";
402 exit(1);
403}}
404
405require $phpunitPath;
406"#
407 )
408}
409
410#[allow(clippy::too_many_arguments)]
411fn render_test_file(
412 category: &str,
413 fixtures: &[&Fixture],
414 e2e_config: &E2eConfig,
415 lang: &str,
416 namespace: &str,
417 class_name: &str,
418 test_class: &str,
419 field_resolver: &FieldResolver,
420 enum_fields: &HashMap<String, String>,
421 result_is_simple: bool,
422 php_client_factory: Option<&str>,
423 options_via: &str,
424) -> String {
425 let header = hash::header(CommentStyle::DoubleSlash);
426
427 let needs_crawl_config_import = fixtures.iter().any(|f| {
429 let call = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
430 call.args.iter().filter(|a| a.arg_type == "handle").any(|a| {
431 let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
432 !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
433 })
434 });
435
436 let has_http_tests = fixtures.iter().any(|f| f.is_http_test());
438
439 let mut options_type_imports: Vec<String> = fixtures
441 .iter()
442 .flat_map(|f| {
443 let call = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
444 let php_override = call.overrides.get(lang);
445 let opt_type = php_override.and_then(|o| o.options_type.as_deref()).or_else(|| {
446 e2e_config
447 .call
448 .overrides
449 .get(lang)
450 .and_then(|o| o.options_type.as_deref())
451 });
452 let element_types: Vec<String> = call
453 .args
454 .iter()
455 .filter_map(|a| a.element_type.as_ref().map(|t| t.to_string()))
456 .filter(|t| !is_php_reserved_type(t))
457 .collect();
458 opt_type.map(|t| t.to_string()).into_iter().chain(element_types)
459 })
460 .collect::<std::collections::HashSet<_>>()
461 .into_iter()
462 .collect();
463 options_type_imports.sort();
464
465 let mut imports_use: Vec<String> = Vec::new();
467 if needs_crawl_config_import {
468 imports_use.push(format!("use {namespace}\\CrawlConfig;"));
469 }
470 for type_name in &options_type_imports {
471 if type_name != class_name {
472 imports_use.push(format!("use {namespace}\\{type_name};"));
473 }
474 }
475
476 let mut fixtures_body = String::new();
478 for (i, fixture) in fixtures.iter().enumerate() {
479 if fixture.is_http_test() {
480 render_http_test_method(&mut fixtures_body, fixture, fixture.http.as_ref().unwrap());
481 } else {
482 render_test_method(
483 &mut fixtures_body,
484 fixture,
485 e2e_config,
486 lang,
487 namespace,
488 class_name,
489 field_resolver,
490 enum_fields,
491 result_is_simple,
492 php_client_factory,
493 options_via,
494 );
495 }
496 if i + 1 < fixtures.len() {
497 fixtures_body.push('\n');
498 }
499 }
500
501 crate::template_env::render(
502 "php/test_file.jinja",
503 minijinja::context! {
504 header => header,
505 namespace => namespace,
506 class_name => class_name,
507 test_class => test_class,
508 category => category,
509 imports_use => imports_use,
510 has_http_tests => has_http_tests,
511 fixtures_body => fixtures_body,
512 },
513 )
514}
515
516struct PhpTestClientRenderer;
524
525impl client::TestClientRenderer for PhpTestClientRenderer {
526 fn language_name(&self) -> &'static str {
527 "php"
528 }
529
530 fn sanitize_test_name(&self, id: &str) -> String {
532 sanitize_filename(id)
533 }
534
535 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
541 let escaped_reason = skip_reason.map(escape_php);
542 let rendered = crate::template_env::render(
543 "php/http_test_open.jinja",
544 minijinja::context! {
545 fn_name => fn_name,
546 description => description,
547 skip_reason => escaped_reason,
548 },
549 );
550 out.push_str(&rendered);
551 }
552
553 fn render_test_close(&self, out: &mut String) {
555 let rendered = crate::template_env::render("php/http_test_close.jinja", minijinja::context! {});
556 out.push_str(&rendered);
557 }
558
559 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
564 let method = ctx.method.to_uppercase();
565
566 let mut opts: Vec<String> = Vec::new();
568
569 if let Some(body) = ctx.body {
570 let php_body = json_to_php(body);
571 opts.push(format!("'json' => {php_body}"));
572 }
573
574 let mut header_pairs: Vec<String> = Vec::new();
576 if let Some(ct) = ctx.content_type {
577 if !ctx.headers.keys().any(|k| k.to_lowercase() == "content-type") {
579 header_pairs.push(format!("\"Content-Type\" => \"{}\"", escape_php(ct)));
580 }
581 }
582 for (k, v) in ctx.headers {
583 header_pairs.push(format!("\"{}\" => \"{}\"", escape_php(k), escape_php(v)));
584 }
585 if !header_pairs.is_empty() {
586 opts.push(format!("'headers' => [{}]", header_pairs.join(", ")));
587 }
588
589 if !ctx.cookies.is_empty() {
590 let cookie_str = ctx
591 .cookies
592 .iter()
593 .map(|(k, v)| format!("{}={}", k, v))
594 .collect::<Vec<_>>()
595 .join("; ");
596 opts.push(format!("'headers' => ['Cookie' => \"{}\"]", escape_php(&cookie_str)));
597 }
598
599 if !ctx.query_params.is_empty() {
600 let pairs: Vec<String> = ctx
601 .query_params
602 .iter()
603 .map(|(k, v)| {
604 let val_str = match v {
605 serde_json::Value::String(s) => s.clone(),
606 other => other.to_string(),
607 };
608 format!("\"{}\" => \"{}\"", escape_php(k), escape_php(&val_str))
609 })
610 .collect();
611 opts.push(format!("'query' => [{}]", pairs.join(", ")));
612 }
613
614 let path_lit = format!("\"{}\"", escape_php(ctx.path));
615
616 let rendered = crate::template_env::render(
617 "php/http_request.jinja",
618 minijinja::context! {
619 method => method,
620 path => path_lit,
621 opts => opts,
622 response_var => ctx.response_var,
623 },
624 );
625 out.push_str(&rendered);
626 }
627
628 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
630 let rendered = crate::template_env::render(
631 "php/http_assertions.jinja",
632 minijinja::context! {
633 response_var => "",
634 status_code => status,
635 headers => Vec::<std::collections::HashMap<&str, String>>::new(),
636 body_assertion => String::new(),
637 partial_body => Vec::<std::collections::HashMap<&str, String>>::new(),
638 validation_errors => Vec::<std::collections::HashMap<&str, String>>::new(),
639 },
640 );
641 out.push_str(&rendered);
642 }
643
644 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
649 let header_key = name.to_lowercase();
650 let header_key_lit = format!("\"{}\"", escape_php(&header_key));
651 let assertion_code = match expected {
652 "<<present>>" => {
653 format!("$this->assertTrue($response->hasHeader({header_key_lit}));")
654 }
655 "<<absent>>" => {
656 format!("$this->assertFalse($response->hasHeader({header_key_lit}));")
657 }
658 "<<uuid>>" => {
659 format!(
660 "$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}));"
661 )
662 }
663 literal => {
664 let val_lit = format!("\"{}\"", escape_php(literal));
665 format!("$this->assertEquals({val_lit}, $response->getHeaderLine({header_key_lit}));")
666 }
667 };
668
669 let mut headers = vec![std::collections::HashMap::new()];
670 headers[0].insert("assertion_code", assertion_code);
671
672 let rendered = crate::template_env::render(
673 "php/http_assertions.jinja",
674 minijinja::context! {
675 response_var => "",
676 status_code => 0u16,
677 headers => headers,
678 body_assertion => String::new(),
679 partial_body => Vec::<std::collections::HashMap<&str, String>>::new(),
680 validation_errors => Vec::<std::collections::HashMap<&str, String>>::new(),
681 },
682 );
683 out.push_str(&rendered);
684 }
685
686 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
692 let body_assertion = match expected {
693 serde_json::Value::String(s) if !s.is_empty() => {
694 let php_val = format!("\"{}\"", escape_php(s));
695 format!("$this->assertEquals({php_val}, (string) $response->getBody());")
696 }
697 _ => {
698 let php_val = json_to_php(expected);
699 format!(
700 "$body = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);\n $this->assertEquals({php_val}, $body);"
701 )
702 }
703 };
704
705 let rendered = crate::template_env::render(
706 "php/http_assertions.jinja",
707 minijinja::context! {
708 response_var => "",
709 status_code => 0u16,
710 headers => Vec::<std::collections::HashMap<&str, String>>::new(),
711 body_assertion => body_assertion,
712 partial_body => Vec::<std::collections::HashMap<&str, String>>::new(),
713 validation_errors => Vec::<std::collections::HashMap<&str, String>>::new(),
714 },
715 );
716 out.push_str(&rendered);
717 }
718
719 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
721 if let Some(obj) = expected.as_object() {
722 let mut partial_body: Vec<std::collections::HashMap<&str, String>> = Vec::new();
723 for (key, val) in obj {
724 let php_key = format!("\"{}\"", escape_php(key));
725 let php_val = json_to_php(val);
726 let assertion_code = format!("$this->assertEquals({php_val}, $body[{php_key}]);");
727 let mut entry = std::collections::HashMap::new();
728 entry.insert("assertion_code", assertion_code);
729 partial_body.push(entry);
730 }
731
732 let rendered = crate::template_env::render(
733 "php/http_assertions.jinja",
734 minijinja::context! {
735 response_var => "",
736 status_code => 0u16,
737 headers => Vec::<std::collections::HashMap<&str, String>>::new(),
738 body_assertion => String::new(),
739 partial_body => partial_body,
740 validation_errors => Vec::<std::collections::HashMap<&str, String>>::new(),
741 },
742 );
743 out.push_str(&rendered);
744 }
745 }
746
747 fn render_assert_validation_errors(
750 &self,
751 out: &mut String,
752 _response_var: &str,
753 errors: &[ValidationErrorExpectation],
754 ) {
755 let mut validation_errors: Vec<std::collections::HashMap<&str, String>> = Vec::new();
756 for err in errors {
757 let msg_lit = format!("\"{}\"", escape_php(&err.msg));
758 let assertion_code =
759 format!("$this->assertStringContainsString({msg_lit}, json_encode($body, JSON_UNESCAPED_SLASHES));");
760 let mut entry = std::collections::HashMap::new();
761 entry.insert("assertion_code", assertion_code);
762 validation_errors.push(entry);
763 }
764
765 let rendered = crate::template_env::render(
766 "php/http_assertions.jinja",
767 minijinja::context! {
768 response_var => "",
769 status_code => 0u16,
770 headers => Vec::<std::collections::HashMap<&str, String>>::new(),
771 body_assertion => String::new(),
772 partial_body => Vec::<std::collections::HashMap<&str, String>>::new(),
773 validation_errors => validation_errors,
774 },
775 );
776 out.push_str(&rendered);
777 }
778}
779
780fn render_http_test_method(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
785 if http.expected_response.status_code == 101 {
789 let method_name = sanitize_filename(&fixture.id);
790 let description = &fixture.description;
791 out.push_str(&crate::template_env::render(
792 "php/http_test_skip_101.jinja",
793 minijinja::context! {
794 method_name => method_name,
795 description => description,
796 },
797 ));
798 return;
799 }
800
801 client::http_call::render_http_test(out, &PhpTestClientRenderer, fixture);
802}
803
804#[allow(clippy::too_many_arguments)]
809fn render_test_method(
810 out: &mut String,
811 fixture: &Fixture,
812 e2e_config: &E2eConfig,
813 lang: &str,
814 namespace: &str,
815 class_name: &str,
816 field_resolver: &FieldResolver,
817 enum_fields: &HashMap<String, String>,
818 result_is_simple: bool,
819 php_client_factory: Option<&str>,
820 options_via: &str,
821) {
822 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
824 let call_overrides = call_config.overrides.get(lang);
825 let has_override = call_overrides.is_some_and(|o| o.function.is_some());
826 let result_is_simple = call_overrides.is_some_and(|o| o.result_is_simple) || result_is_simple;
830 let mut function_name = call_overrides
831 .and_then(|o| o.function.as_ref())
832 .cloned()
833 .unwrap_or_else(|| call_config.function.clone());
834 if !has_override {
839 function_name = function_name.to_lower_camel_case();
840 }
841 let result_var = &call_config.result_var;
842 let args = &call_config.args;
843
844 let method_name = sanitize_filename(&fixture.id);
845 let description = &fixture.description;
846 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
847
848 let call_options_type = call_overrides.and_then(|o| o.options_type.as_deref()).or_else(|| {
850 e2e_config
851 .call
852 .overrides
853 .get(lang)
854 .and_then(|o| o.options_type.as_deref())
855 });
856
857 let (mut setup_lines, args_str) = build_args_and_setup(
858 &fixture.input,
859 args,
860 class_name,
861 enum_fields,
862 fixture,
863 options_via,
864 call_options_type,
865 );
866
867 let skip_test = call_config.skip_languages.iter().any(|l| l == "php");
869 if skip_test {
870 let rendered = crate::template_env::render(
871 "php/test_method.jinja",
872 minijinja::context! {
873 method_name => method_name,
874 description => description,
875 client_factory => String::new(),
876 setup_lines => Vec::<String>::new(),
877 expects_error => false,
878 skip_test => true,
879 has_usable_assertions => false,
880 call_expr => String::new(),
881 result_var => result_var,
882 assertions_body => String::new(),
883 },
884 );
885 out.push_str(&rendered);
886 return;
887 }
888
889 let mut options_already_created = !args_str.is_empty() && args_str == "$options";
891 if let Some(visitor_spec) = &fixture.visitor {
892 build_php_visitor(&mut setup_lines, visitor_spec);
893 if !options_already_created {
894 let options_type = call_options_type.unwrap_or("ConversionOptions");
895 setup_lines.push(format!("$builder = \\{namespace}\\{options_type}::builder();"));
896 setup_lines.push("$options = $builder->visitor($visitor)->build();".to_string());
897 options_already_created = true;
898 }
899 }
900
901 let final_args = if options_already_created {
902 if args_str.is_empty() || args_str == "$options" {
903 "$options".to_string()
904 } else {
905 format!("{args_str}, $options")
906 }
907 } else {
908 args_str
909 };
910
911 let call_expr = if php_client_factory.is_some() {
912 format!("$client->{function_name}({final_args})")
913 } else {
914 format!("{class_name}::{function_name}({final_args})")
915 };
916
917 let has_mock = fixture.mock_response.is_some() || fixture.http.is_some();
918 let api_key_var = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref());
919 let client_factory = if let Some(factory) = php_client_factory {
920 let fixture_id = &fixture.id;
921 if let Some(var) = api_key_var.filter(|_| has_mock) {
922 format!(
923 "$apiKey = getenv('{var}');\n $baseUrl = ($apiKey !== false && $apiKey !== '') ? null : getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}';\n fwrite(STDERR, \"{fixture_id}: \" . ($baseUrl === null ? 'using real API ({var} is set)' : 'using mock server ({var} not set)') . \"\\n\");\n $client = \\{namespace}\\{class_name}::{factory}($baseUrl === null ? $apiKey : 'test-key', $baseUrl);"
924 )
925 } else if has_mock {
926 let base_url_expr = if fixture.has_host_root_route() {
927 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
928 format!("(getenv('{env_key}') ?: getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}')")
929 } else {
930 format!("getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}'")
931 };
932 format!("$client = \\{namespace}\\{class_name}::{factory}('test-key', {base_url_expr});")
933 } else if let Some(var) = api_key_var {
934 format!(
935 "$apiKey = getenv('{var}');\n if (!$apiKey) {{ $this->markTestSkipped('{var} not set'); return; }}\n $client = \\{namespace}\\{class_name}::{factory}($apiKey);"
936 )
937 } else {
938 format!("$client = \\{namespace}\\{class_name}::{factory}('test-key');")
939 }
940 } else {
941 String::new()
942 };
943
944 let is_streaming = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
946
947 let has_usable_assertions = fixture.assertions.iter().any(|a| {
950 if a.assertion_type == "error" || a.assertion_type == "not_error" {
951 return false;
952 }
953 match &a.field {
954 Some(f) if !f.is_empty() => {
955 if is_streaming && crate::codegen::streaming_assertions::is_streaming_virtual_field(f) {
956 return true;
957 }
958 field_resolver.is_valid_for_result(f)
959 }
960 _ => true,
961 }
962 });
963
964 let collect_snippet = if is_streaming {
966 crate::codegen::streaming_assertions::StreamingFieldResolver::collect_snippet("php", result_var, "chunks")
967 .unwrap_or_default()
968 } else {
969 String::new()
970 };
971
972 let mut assertions_body = String::new();
974 for assertion in &fixture.assertions {
975 render_assertion(
976 &mut assertions_body,
977 assertion,
978 result_var,
979 field_resolver,
980 result_is_simple,
981 call_config.result_is_array,
982 );
983 }
984
985 if is_streaming && !expects_error && assertions_body.trim().is_empty() {
991 assertions_body.push_str(" $this->assertTrue(is_array($chunks), 'expected drained chunks list');\n");
992 }
993
994 let rendered = crate::template_env::render(
995 "php/test_method.jinja",
996 minijinja::context! {
997 method_name => method_name,
998 description => description,
999 client_factory => client_factory,
1000 setup_lines => setup_lines,
1001 expects_error => expects_error,
1002 skip_test => fixture.assertions.is_empty(),
1003 has_usable_assertions => has_usable_assertions || is_streaming,
1004 call_expr => call_expr,
1005 result_var => result_var,
1006 collect_snippet => collect_snippet,
1007 assertions_body => assertions_body,
1008 },
1009 );
1010 out.push_str(&rendered);
1011}
1012
1013fn emit_php_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
1026 if let Some(items) = arr.as_array() {
1027 let item_strs: Vec<String> = items
1028 .iter()
1029 .filter_map(|item| {
1030 if let Some(obj) = item.as_object() {
1031 match elem_type {
1032 "BatchBytesItem" => {
1033 let content = obj.get("content").and_then(|v| v.as_array());
1034 let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1035 let content_code = if let Some(arr) = content {
1036 let bytes: Vec<String> = arr
1037 .iter()
1038 .filter_map(|v| v.as_u64())
1039 .map(|n| format!("\\x{:02x}", n))
1040 .collect();
1041 format!("\"{}\"", bytes.join(""))
1042 } else {
1043 "\"\"".to_string()
1044 };
1045 Some(format!(
1046 "new {}(content: {}, mimeType: \"{}\")",
1047 elem_type, content_code, mime_type
1048 ))
1049 }
1050 "BatchFileItem" => {
1051 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1052 Some(format!("new {}(path: \"{}\")", elem_type, path))
1053 }
1054 _ => None,
1055 }
1056 } else {
1057 None
1058 }
1059 })
1060 .collect();
1061 format!("[{}]", item_strs.join(", "))
1062 } else {
1063 "[]".to_string()
1064 }
1065}
1066
1067fn build_args_and_setup(
1068 input: &serde_json::Value,
1069 args: &[crate::config::ArgMapping],
1070 class_name: &str,
1071 _enum_fields: &HashMap<String, String>,
1072 fixture: &crate::fixture::Fixture,
1073 options_via: &str,
1074 options_type: Option<&str>,
1075) -> (Vec<String>, String) {
1076 let fixture_id = &fixture.id;
1077 if args.is_empty() {
1078 let is_empty_input = match input {
1081 serde_json::Value::Null => true,
1082 serde_json::Value::Object(m) => m.is_empty(),
1083 _ => false,
1084 };
1085 if is_empty_input {
1086 return (Vec::new(), String::new());
1087 }
1088 return (Vec::new(), json_to_php(input));
1089 }
1090
1091 let mut setup_lines: Vec<String> = Vec::new();
1092 let mut parts: Vec<String> = Vec::new();
1093
1094 let arg_has_emission = |arg: &crate::config::ArgMapping| -> bool {
1099 let val = if arg.field == "input" {
1100 Some(input)
1101 } else {
1102 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1103 input.get(field)
1104 };
1105 match val {
1106 None | Some(serde_json::Value::Null) => !arg.optional,
1107 Some(_) => true,
1108 }
1109 };
1110 let any_later_has_emission = |from_idx: usize| -> bool { args[from_idx..].iter().any(arg_has_emission) };
1111
1112 for (idx, arg) in args.iter().enumerate() {
1113 if arg.arg_type == "mock_url" {
1114 if fixture.has_host_root_route() {
1115 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1116 setup_lines.push(format!(
1117 "${} = getenv('{env_key}') ?: getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}';",
1118 arg.name,
1119 ));
1120 } else {
1121 setup_lines.push(format!(
1122 "${} = getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}';",
1123 arg.name,
1124 ));
1125 }
1126 parts.push(format!("${}", arg.name));
1127 continue;
1128 }
1129
1130 if arg.arg_type == "handle" {
1131 let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
1133 let config_value = if arg.field == "input" {
1134 input
1135 } else {
1136 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1137 input.get(field).unwrap_or(&serde_json::Value::Null)
1138 };
1139 if config_value.is_null()
1140 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1141 {
1142 setup_lines.push(format!("${} = {class_name}::{constructor_name}(null);", arg.name,));
1143 } else {
1144 let name = &arg.name;
1145 let filtered_config = filter_empty_enum_strings(config_value);
1150 setup_lines.push(format!(
1151 "${name}_config = CrawlConfig::from_json(json_encode({}));",
1152 json_to_php_camel_keys(&filtered_config)
1153 ));
1154 setup_lines.push(format!(
1155 "${} = {class_name}::{constructor_name}(${name}_config);",
1156 arg.name,
1157 ));
1158 }
1159 parts.push(format!("${}", arg.name));
1160 continue;
1161 }
1162
1163 let val = if arg.field == "input" {
1164 Some(input)
1165 } else {
1166 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1167 input.get(field)
1168 };
1169
1170 if arg.arg_type == "bytes" {
1174 match val {
1175 None | Some(serde_json::Value::Null) => {
1176 if arg.optional {
1177 parts.push("null".to_string());
1178 } else {
1179 parts.push("\"\"".to_string());
1180 }
1181 }
1182 Some(serde_json::Value::String(s)) => {
1183 let var_name = format!("{}Bytes", arg.name);
1184 setup_lines.push(format!(
1185 "${var_name} = file_get_contents(\"{path}\");\n if (${var_name} === false) {{ $this->fail(\"failed to read fixture: {path}\"); }}",
1186 path = s.replace('"', "\\\"")
1187 ));
1188 parts.push(format!("${var_name}"));
1189 }
1190 Some(serde_json::Value::Array(arr)) => {
1191 let bytes: String = arr
1192 .iter()
1193 .filter_map(|v| v.as_u64())
1194 .map(|n| format!("\\x{:02x}", n))
1195 .collect();
1196 parts.push(format!("\"{bytes}\""));
1197 }
1198 Some(other) => {
1199 parts.push(json_to_php(other));
1200 }
1201 }
1202 continue;
1203 }
1204
1205 match val {
1206 None | Some(serde_json::Value::Null) if arg.arg_type == "json_object" && arg.name == "config" => {
1207 let type_name = if arg.name == "config" {
1213 "ExtractionConfig".to_string()
1214 } else {
1215 format!("{}Config", arg.name.to_upper_camel_case())
1216 };
1217 parts.push(format!("{type_name}::from_json('{{}}')"));
1218 continue;
1219 }
1220 None | Some(serde_json::Value::Null) if arg.optional => {
1221 if any_later_has_emission(idx + 1) {
1226 parts.push("null".to_string());
1227 }
1228 continue;
1229 }
1230 None | Some(serde_json::Value::Null) => {
1231 let default_val = match arg.arg_type.as_str() {
1233 "string" => "\"\"".to_string(),
1234 "int" | "integer" => "0".to_string(),
1235 "float" | "number" => "0.0".to_string(),
1236 "bool" | "boolean" => "false".to_string(),
1237 "json_object" if options_via == "json" => "null".to_string(),
1238 _ => "null".to_string(),
1239 };
1240 parts.push(default_val);
1241 }
1242 Some(v) => {
1243 if arg.arg_type == "json_object" && !v.is_null() {
1244 if let Some(elem_type) = &arg.element_type {
1246 if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && v.is_array() {
1247 parts.push(emit_php_batch_item_array(v, elem_type));
1248 continue;
1249 }
1250 if v.is_array() && is_php_reserved_type(elem_type) {
1254 parts.push(json_to_php(v));
1255 continue;
1256 }
1257 }
1258 match options_via {
1259 "json" => {
1260 let filtered_v = filter_empty_enum_strings(v);
1263
1264 if let serde_json::Value::Object(obj) = &filtered_v {
1266 if obj.is_empty() {
1267 parts.push("null".to_string());
1268 continue;
1269 }
1270 }
1271
1272 parts.push(format!("json_encode({})", json_to_php_camel_keys(&filtered_v)));
1273 continue;
1274 }
1275 _ => {
1276 if let Some(type_name) = options_type {
1277 let filtered_v = filter_empty_enum_strings(v);
1282
1283 if let serde_json::Value::Object(obj) = &filtered_v {
1286 if obj.is_empty() {
1287 let arg_var = format!("${}", arg.name);
1288 setup_lines.push(format!("{arg_var} = {type_name}::from_json('{{}}');"));
1289 parts.push(arg_var);
1290 continue;
1291 }
1292 }
1293
1294 let arg_var = format!("${}", arg.name);
1295 setup_lines.push(format!(
1299 "{arg_var} = {type_name}::from_json(json_encode({}));",
1300 json_to_php_camel_keys(&filtered_v)
1301 ));
1302 parts.push(arg_var);
1303 continue;
1304 }
1305 if let Some(obj) = v.as_object() {
1309 setup_lines.push("$builder = $this->createDefaultOptionsBuilder();".to_string());
1310 for (k, vv) in obj {
1311 let snake_key = k.to_snake_case();
1312 if snake_key == "preprocessing" {
1313 if let Some(prep_obj) = vv.as_object() {
1314 let enabled =
1315 prep_obj.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
1316 let preset =
1317 prep_obj.get("preset").and_then(|v| v.as_str()).unwrap_or("Minimal");
1318 let remove_navigation = prep_obj
1319 .get("remove_navigation")
1320 .and_then(|v| v.as_bool())
1321 .unwrap_or(true);
1322 let remove_forms =
1323 prep_obj.get("remove_forms").and_then(|v| v.as_bool()).unwrap_or(true);
1324 setup_lines.push(format!(
1325 "$preprocessing = $this->createPreprocessingOptions({}, {}, {}, {});",
1326 if enabled { "true" } else { "false" },
1327 json_to_php(&serde_json::Value::String(preset.to_string())),
1328 if remove_navigation { "true" } else { "false" },
1329 if remove_forms { "true" } else { "false" }
1330 ));
1331 setup_lines.push(
1332 "$builder = $builder->preprocessing($preprocessing);".to_string(),
1333 );
1334 }
1335 }
1336 }
1337 setup_lines.push("$options = $builder->build();".to_string());
1338 parts.push("$options".to_string());
1339 continue;
1340 }
1341 }
1342 }
1343 }
1344 parts.push(json_to_php(v));
1345 }
1346 }
1347 }
1348
1349 (setup_lines, parts.join(", "))
1350}
1351
1352fn render_assertion(
1353 out: &mut String,
1354 assertion: &Assertion,
1355 result_var: &str,
1356 field_resolver: &FieldResolver,
1357 result_is_simple: bool,
1358 result_is_array: bool,
1359) {
1360 if let Some(f) = &assertion.field {
1363 match f.as_str() {
1364 "chunks_have_content" => {
1365 let pred = format!(
1366 "array_reduce(${result_var}->chunks ?? [], fn($carry, $c) => $carry && !empty($c->content), true)"
1367 );
1368 out.push_str(&crate::template_env::render(
1369 "php/synthetic_assertion.jinja",
1370 minijinja::context! {
1371 assertion_kind => "chunks_content",
1372 assertion_type => assertion.assertion_type.as_str(),
1373 pred => pred,
1374 field_name => f,
1375 },
1376 ));
1377 return;
1378 }
1379 "chunks_have_embeddings" => {
1380 let pred = format!(
1381 "array_reduce(${result_var}->chunks ?? [], fn($carry, $c) => $carry && !empty($c->embedding), true)"
1382 );
1383 out.push_str(&crate::template_env::render(
1384 "php/synthetic_assertion.jinja",
1385 minijinja::context! {
1386 assertion_kind => "chunks_embeddings",
1387 assertion_type => assertion.assertion_type.as_str(),
1388 pred => pred,
1389 field_name => f,
1390 },
1391 ));
1392 return;
1393 }
1394 "embeddings" => {
1398 let php_val = assertion.value.as_ref().map(json_to_php).unwrap_or_default();
1399 out.push_str(&crate::template_env::render(
1400 "php/synthetic_assertion.jinja",
1401 minijinja::context! {
1402 assertion_kind => "embeddings",
1403 assertion_type => assertion.assertion_type.as_str(),
1404 php_val => php_val,
1405 result_var => result_var,
1406 },
1407 ));
1408 return;
1409 }
1410 "embedding_dimensions" => {
1411 let expr = format!("(empty(${result_var}) ? 0 : count(${result_var}[0]))");
1412 let php_val = assertion.value.as_ref().map(json_to_php).unwrap_or_default();
1413 out.push_str(&crate::template_env::render(
1414 "php/synthetic_assertion.jinja",
1415 minijinja::context! {
1416 assertion_kind => "embedding_dimensions",
1417 assertion_type => assertion.assertion_type.as_str(),
1418 expr => expr,
1419 php_val => php_val,
1420 },
1421 ));
1422 return;
1423 }
1424 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1425 let pred = match f.as_str() {
1426 "embeddings_valid" => {
1427 format!("array_reduce(${result_var}, fn($carry, $e) => $carry && count($e) > 0, true)")
1428 }
1429 "embeddings_finite" => {
1430 format!(
1431 "array_reduce(${result_var}, fn($carry, $e) => $carry && array_reduce($e, fn($c, $v) => $c && is_finite($v), true), true)"
1432 )
1433 }
1434 "embeddings_non_zero" => {
1435 format!(
1436 "array_reduce(${result_var}, fn($carry, $e) => $carry && count(array_filter($e, fn($v) => $v !== 0.0)) > 0, true)"
1437 )
1438 }
1439 "embeddings_normalized" => {
1440 format!(
1441 "array_reduce(${result_var}, fn($carry, $e) => $carry && abs(array_sum(array_map(fn($v) => $v * $v, $e)) - 1.0) < 1e-3, true)"
1442 )
1443 }
1444 _ => unreachable!(),
1445 };
1446 let assertion_kind = format!("embeddings_{}", f.strip_prefix("embeddings_").unwrap_or(f));
1447 out.push_str(&crate::template_env::render(
1448 "php/synthetic_assertion.jinja",
1449 minijinja::context! {
1450 assertion_kind => assertion_kind,
1451 assertion_type => assertion.assertion_type.as_str(),
1452 pred => pred,
1453 field_name => f,
1454 },
1455 ));
1456 return;
1457 }
1458 "keywords" | "keywords_count" => {
1461 out.push_str(&crate::template_env::render(
1462 "php/synthetic_assertion.jinja",
1463 minijinja::context! {
1464 assertion_kind => "keywords",
1465 field_name => f,
1466 },
1467 ));
1468 return;
1469 }
1470 _ => {}
1471 }
1472 }
1473
1474 if let Some(f) = &assertion.field {
1477 if !f.is_empty() && crate::codegen::streaming_assertions::is_streaming_virtual_field(f) {
1478 if let Some(expr) =
1479 crate::codegen::streaming_assertions::StreamingFieldResolver::accessor(f, "php", "chunks")
1480 {
1481 let line = match assertion.assertion_type.as_str() {
1482 "count_min" => {
1483 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1484 format!(
1485 " $this->assertGreaterThanOrEqual({n}, count({expr}), 'expected >= {n} chunks');\n"
1486 )
1487 } else {
1488 String::new()
1489 }
1490 }
1491 "count_equals" => {
1492 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1493 format!(" $this->assertCount({n}, {expr});\n")
1494 } else {
1495 String::new()
1496 }
1497 }
1498 "equals" => {
1499 if let Some(serde_json::Value::String(s)) = &assertion.value {
1500 let escaped = s.replace('\\', "\\\\").replace('\'', "\\'");
1501 format!(" $this->assertEquals('{escaped}', {expr});\n")
1502 } else if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1503 format!(" $this->assertEquals({n}, {expr});\n")
1504 } else {
1505 String::new()
1506 }
1507 }
1508 "not_empty" => format!(" $this->assertNotEmpty({expr});\n"),
1509 "is_empty" => format!(" $this->assertEmpty({expr});\n"),
1510 "is_true" => format!(" $this->assertTrue({expr});\n"),
1511 "is_false" => format!(" $this->assertFalse({expr});\n"),
1512 "greater_than" => {
1513 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1514 format!(" $this->assertGreaterThan({n}, {expr});\n")
1515 } else {
1516 String::new()
1517 }
1518 }
1519 "greater_than_or_equal" => {
1520 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1521 format!(" $this->assertGreaterThanOrEqual({n}, {expr});\n")
1522 } else {
1523 String::new()
1524 }
1525 }
1526 "contains" => {
1527 if let Some(serde_json::Value::String(s)) = &assertion.value {
1528 let escaped = s.replace('\\', "\\\\").replace('\'', "\\'");
1529 format!(" $this->assertStringContainsString('{escaped}', {expr});\n")
1530 } else {
1531 String::new()
1532 }
1533 }
1534 _ => format!(
1535 " // streaming field '{f}': assertion type '{}' not rendered\n",
1536 assertion.assertion_type
1537 ),
1538 };
1539 if !line.is_empty() {
1540 out.push_str(&line);
1541 }
1542 }
1543 return;
1544 }
1545 }
1546
1547 if let Some(f) = &assertion.field {
1549 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1550 out.push_str(&crate::template_env::render(
1551 "php/synthetic_assertion.jinja",
1552 minijinja::context! {
1553 assertion_kind => "skipped",
1554 field_name => f,
1555 },
1556 ));
1557 return;
1558 }
1559 }
1560
1561 if result_is_simple {
1564 if let Some(f) = &assertion.field {
1565 let f_lower = f.to_lowercase();
1566 if !f.is_empty()
1567 && f_lower != "content"
1568 && (f_lower.starts_with("metadata")
1569 || f_lower.starts_with("document")
1570 || f_lower.starts_with("structure"))
1571 {
1572 out.push_str(&crate::template_env::render(
1573 "php/synthetic_assertion.jinja",
1574 minijinja::context! {
1575 assertion_kind => "result_is_simple",
1576 field_name => f,
1577 },
1578 ));
1579 return;
1580 }
1581 }
1582 }
1583
1584 let field_expr = match &assertion.field {
1585 _ if result_is_simple => format!("${result_var}"),
1589 Some(f) if !f.is_empty() => field_resolver.accessor(f, "php", &format!("${result_var}")),
1590 _ => format!("${result_var}"),
1591 };
1592
1593 let field_is_array = assertion.field.as_ref().map_or(result_is_array, |f| {
1596 if f.is_empty() {
1597 result_is_array
1598 } else {
1599 field_resolver.is_array(f)
1600 }
1601 });
1602
1603 let trimmed_field_expr_for = |expected: &serde_json::Value| -> String {
1607 if expected.is_string() {
1608 format!("trim({})", field_expr)
1609 } else {
1610 field_expr.clone()
1611 }
1612 };
1613
1614 let assertion_type = assertion.assertion_type.as_str();
1616 let has_php_val = assertion.value.is_some();
1617 let php_val = match assertion.value.as_ref() {
1621 Some(v) => json_to_php(v),
1622 None if assertion_type == "equals" => "null".to_string(),
1623 None => String::new(),
1624 };
1625 let trimmed_field_expr = trimmed_field_expr_for(assertion.value.as_ref().unwrap_or(&serde_json::Value::Null));
1626 let is_string_val = assertion.value.as_ref().is_some_and(|v| v.is_string());
1627 let values_php: Vec<String> = assertion
1631 .values
1632 .as_ref()
1633 .map(|vals| vals.iter().map(json_to_php).collect::<Vec<_>>())
1634 .or_else(|| assertion.value.as_ref().map(|v| vec![json_to_php(v)]))
1635 .unwrap_or_default();
1636 let contains_any_checks: Vec<String> = assertion
1637 .values
1638 .as_ref()
1639 .map_or(Vec::new(), |vals| vals.iter().map(json_to_php).collect());
1640 let n = assertion.value.as_ref().and_then(|v| v.as_u64()).unwrap_or(0);
1641
1642 let call_expr = if let Some(method_name) = &assertion.method {
1644 build_php_method_call(result_var, method_name, assertion.args.as_ref())
1645 } else {
1646 String::new()
1647 };
1648 let check = assertion.check.as_deref().unwrap_or("is_true");
1649 let has_php_check_val = matches!(assertion.assertion_type.as_str(), "method_result") && assertion.value.is_some();
1650 let php_check_val = if matches!(assertion.assertion_type.as_str(), "method_result") {
1651 assertion.value.as_ref().map(json_to_php).unwrap_or_default()
1652 } else {
1653 String::new()
1654 };
1655 let check_n = assertion.value.as_ref().and_then(|v| v.as_u64()).unwrap_or(0);
1656 let is_bool_val = assertion.value.as_ref().is_some_and(|v| v.is_boolean());
1657 let bool_is_true = assertion.value.as_ref().and_then(|v| v.as_bool()).unwrap_or(false);
1658
1659 if matches!(assertion_type, "not_error" | "error") {
1661 if assertion_type == "not_error" {
1662 }
1664 return;
1666 }
1667
1668 let rendered = crate::template_env::render(
1669 "php/assertion.jinja",
1670 minijinja::context! {
1671 assertion_type => assertion_type,
1672 field_expr => field_expr,
1673 php_val => php_val,
1674 has_php_val => has_php_val,
1675 trimmed_field_expr => trimmed_field_expr,
1676 is_string_val => is_string_val,
1677 field_is_array => field_is_array,
1678 values_php => values_php,
1679 contains_any_checks => contains_any_checks,
1680 n => n,
1681 call_expr => call_expr,
1682 check => check,
1683 php_check_val => php_check_val,
1684 has_php_check_val => has_php_check_val,
1685 check_n => check_n,
1686 is_bool_val => is_bool_val,
1687 bool_is_true => bool_is_true,
1688 },
1689 );
1690 let _ = write!(out, " {}", rendered);
1691}
1692
1693fn build_php_method_call(result_var: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
1700 let extra_args = if let Some(args_val) = args {
1701 args_val
1702 .as_object()
1703 .map(|obj| {
1704 obj.values()
1705 .map(|v| match v {
1706 serde_json::Value::String(s) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")),
1707 serde_json::Value::Bool(true) => "true".to_string(),
1708 serde_json::Value::Bool(false) => "false".to_string(),
1709 serde_json::Value::Number(n) => n.to_string(),
1710 serde_json::Value::Null => "null".to_string(),
1711 other => format!("\"{}\"", other.to_string().replace('\\', "\\\\").replace('"', "\\\"")),
1712 })
1713 .collect::<Vec<_>>()
1714 .join(", ")
1715 })
1716 .unwrap_or_default()
1717 } else {
1718 String::new()
1719 };
1720
1721 if extra_args.is_empty() {
1722 format!("${result_var}->{method_name}()")
1723 } else {
1724 format!("${result_var}->{method_name}({extra_args})")
1725 }
1726}
1727
1728fn filter_empty_enum_strings(value: &serde_json::Value) -> serde_json::Value {
1732 match value {
1733 serde_json::Value::Object(map) => {
1734 let filtered: serde_json::Map<String, serde_json::Value> = map
1735 .iter()
1736 .filter_map(|(k, v)| {
1737 if let serde_json::Value::String(s) = v {
1739 if s.is_empty() {
1740 return None;
1741 }
1742 }
1743 Some((k.clone(), filter_empty_enum_strings(v)))
1745 })
1746 .collect();
1747 serde_json::Value::Object(filtered)
1748 }
1749 serde_json::Value::Array(arr) => {
1750 let filtered: Vec<serde_json::Value> = arr.iter().map(filter_empty_enum_strings).collect();
1751 serde_json::Value::Array(filtered)
1752 }
1753 other => other.clone(),
1754 }
1755}
1756
1757fn json_to_php(value: &serde_json::Value) -> String {
1759 match value {
1760 serde_json::Value::String(s) => format!("\"{}\"", escape_php(s)),
1761 serde_json::Value::Bool(true) => "true".to_string(),
1762 serde_json::Value::Bool(false) => "false".to_string(),
1763 serde_json::Value::Number(n) => n.to_string(),
1764 serde_json::Value::Null => "null".to_string(),
1765 serde_json::Value::Array(arr) => {
1766 let items: Vec<String> = arr.iter().map(json_to_php).collect();
1767 format!("[{}]", items.join(", "))
1768 }
1769 serde_json::Value::Object(map) => {
1770 let items: Vec<String> = map
1771 .iter()
1772 .map(|(k, v)| format!("\"{}\" => {}", escape_php(k), json_to_php(v)))
1773 .collect();
1774 format!("[{}]", items.join(", "))
1775 }
1776 }
1777}
1778
1779fn json_to_php_camel_keys(value: &serde_json::Value) -> String {
1784 match value {
1785 serde_json::Value::Object(map) => {
1786 let items: Vec<String> = map
1787 .iter()
1788 .map(|(k, v)| {
1789 let camel_key = k.to_lower_camel_case();
1790 format!("\"{}\" => {}", escape_php(&camel_key), json_to_php_camel_keys(v))
1791 })
1792 .collect();
1793 format!("[{}]", items.join(", "))
1794 }
1795 serde_json::Value::Array(arr) => {
1796 let items: Vec<String> = arr.iter().map(json_to_php_camel_keys).collect();
1797 format!("[{}]", items.join(", "))
1798 }
1799 _ => json_to_php(value),
1800 }
1801}
1802
1803fn build_php_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) {
1809 setup_lines.push("$visitor = new class {".to_string());
1810 for (method_name, action) in &visitor_spec.callbacks {
1811 emit_php_visitor_method(setup_lines, method_name, action);
1812 }
1813 setup_lines.push("};".to_string());
1814}
1815
1816fn emit_php_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
1818 let params = match method_name {
1819 "visit_link" => "$ctx, $href, $text, $title",
1820 "visit_image" => "$ctx, $src, $alt, $title",
1821 "visit_heading" => "$ctx, $level, $text, $id",
1822 "visit_code_block" => "$ctx, $lang, $code",
1823 "visit_code_inline"
1824 | "visit_strong"
1825 | "visit_emphasis"
1826 | "visit_strikethrough"
1827 | "visit_underline"
1828 | "visit_subscript"
1829 | "visit_superscript"
1830 | "visit_mark"
1831 | "visit_button"
1832 | "visit_summary"
1833 | "visit_figcaption"
1834 | "visit_definition_term"
1835 | "visit_definition_description" => "$ctx, $text",
1836 "visit_text" => "$ctx, $text",
1837 "visit_list_item" => "$ctx, $ordered, $marker, $text",
1838 "visit_blockquote" => "$ctx, $content, $depth",
1839 "visit_table_row" => "$ctx, $cells, $isHeader",
1840 "visit_custom_element" => "$ctx, $tagName, $html",
1841 "visit_form" => "$ctx, $actionUrl, $method",
1842 "visit_input" => "$ctx, $input_type, $name, $value",
1843 "visit_audio" | "visit_video" | "visit_iframe" => "$ctx, $src",
1844 "visit_details" => "$ctx, $isOpen",
1845 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "$ctx, $output",
1846 "visit_list_start" => "$ctx, $ordered",
1847 "visit_list_end" => "$ctx, $ordered, $output",
1848 _ => "$ctx",
1849 };
1850
1851 let (action_type, action_value, return_form) = match action {
1852 CallbackAction::Skip => ("skip", String::new(), "dict"),
1853 CallbackAction::Continue => ("continue", String::new(), "dict"),
1854 CallbackAction::PreserveHtml => ("preserve_html", String::new(), "dict"),
1855 CallbackAction::Custom { output } => ("custom", escape_php(output), "dict"),
1856 CallbackAction::CustomTemplate { template, return_form } => {
1857 let form = match return_form {
1858 TemplateReturnForm::Dict => "dict",
1859 TemplateReturnForm::BareString => "bare_string",
1860 };
1861 ("custom_template", escape_php(template), form)
1862 }
1863 };
1864
1865 let rendered = crate::template_env::render(
1866 "php/visitor_method.jinja",
1867 minijinja::context! {
1868 method_name => method_name,
1869 params => params,
1870 action_type => action_type,
1871 action_value => action_value,
1872 return_form => return_form,
1873 },
1874 );
1875 for line in rendered.lines() {
1876 setup_lines.push(line.to_string());
1877 }
1878}
1879
1880fn is_php_reserved_type(name: &str) -> bool {
1882 matches!(
1883 name.to_ascii_lowercase().as_str(),
1884 "string"
1885 | "int"
1886 | "integer"
1887 | "float"
1888 | "double"
1889 | "bool"
1890 | "boolean"
1891 | "array"
1892 | "object"
1893 | "null"
1894 | "void"
1895 | "callable"
1896 | "iterable"
1897 | "never"
1898 | "self"
1899 | "parent"
1900 | "static"
1901 | "true"
1902 | "false"
1903 | "mixed"
1904 )
1905}