1use crate::config::E2eConfig;
8use crate::escape::{escape_php, sanitize_filename};
9use crate::field_access::{FieldResolver, PhpGetterMap};
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();
187 let php_getter_map = build_php_getter_map(type_defs, &php_enum_names, call, &e2e_config.result_fields);
188
189 let field_resolver = FieldResolver::new_with_php_getters(
190 &e2e_config.fields,
191 &e2e_config.fields_optional,
192 &e2e_config.result_fields,
193 &e2e_config.fields_array,
194 &HashSet::new(),
195 &HashMap::new(),
196 php_getter_map,
197 );
198
199 for group in groups {
200 let active: Vec<&Fixture> = group
201 .fixtures
202 .iter()
203 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
204 .collect();
205
206 if active.is_empty() {
207 continue;
208 }
209
210 let test_class = format!("{}Test", sanitize_filename(&group.category).to_upper_camel_case());
211 let filename = format!("{test_class}.php");
212 let content = render_test_file(
213 &group.category,
214 &active,
215 e2e_config,
216 lang,
217 &namespace,
218 &class_name,
219 &test_class,
220 &field_resolver,
221 enum_fields,
222 result_is_simple,
223 php_client_factory,
224 options_via,
225 );
226 files.push(GeneratedFile {
227 path: tests_base.join(filename),
228 content,
229 generated_header: true,
230 });
231 }
232
233 Ok(files)
234 }
235
236 fn language_name(&self) -> &'static str {
237 "php"
238 }
239}
240
241fn build_php_getter_map(
267 type_defs: &[alef_core::ir::TypeDef],
268 enum_names: &HashSet<String>,
269 call: &alef_core::config::e2e::CallConfig,
270 result_fields: &HashSet<String>,
271) -> PhpGetterMap {
272 let mut getters: HashMap<String, HashSet<String>> = HashMap::new();
273 let mut field_types: HashMap<String, HashMap<String, String>> = HashMap::new();
274 for td in type_defs {
275 let mut getter_fields: HashSet<String> = HashSet::new();
276 let mut field_type_map: HashMap<String, String> = HashMap::new();
277 for f in &td.fields {
278 if !is_php_scalar(&f.ty, enum_names) {
279 getter_fields.insert(f.name.clone());
280 }
281 if let Some(named) = inner_named(&f.ty) {
282 field_type_map.insert(f.name.clone(), named);
283 }
284 }
285 getters.insert(td.name.clone(), getter_fields);
286 if !field_type_map.is_empty() {
287 field_types.insert(td.name.clone(), field_type_map);
288 }
289 }
290 let root_type = derive_root_type(call, type_defs, result_fields);
291 PhpGetterMap {
292 getters,
293 field_types,
294 root_type,
295 }
296}
297
298fn inner_named(ty: &TypeRef) -> Option<String> {
301 match ty {
302 TypeRef::Named(n) => Some(n.clone()),
303 TypeRef::Optional(inner) | TypeRef::Vec(inner) => inner_named(inner),
304 _ => None,
305 }
306}
307
308fn derive_root_type(
319 call: &alef_core::config::e2e::CallConfig,
320 type_defs: &[alef_core::ir::TypeDef],
321 result_fields: &HashSet<String>,
322) -> Option<String> {
323 const LOOKUP_LANGS: &[&str] = &["php", "c", "csharp", "java", "kotlin", "go"];
324 for lang in LOOKUP_LANGS {
325 if let Some(o) = call.overrides.get(*lang)
326 && let Some(rt) = o.result_type.as_deref()
327 && !rt.is_empty()
328 && type_defs.iter().any(|td| td.name == rt)
329 {
330 return Some(rt.to_string());
331 }
332 }
333 if result_fields.is_empty() {
334 return None;
335 }
336 let matches: Vec<&alef_core::ir::TypeDef> = type_defs
337 .iter()
338 .filter(|td| {
339 let names: HashSet<&str> = td.fields.iter().map(|f| f.name.as_str()).collect();
340 result_fields.iter().all(|rf| names.contains(rf.as_str()))
341 })
342 .collect();
343 if matches.len() == 1 {
344 return Some(matches[0].name.clone());
345 }
346 None
347}
348
349fn is_php_scalar(ty: &TypeRef, enum_names: &HashSet<String>) -> bool {
350 match ty {
351 TypeRef::Primitive(_) | TypeRef::String | TypeRef::Char | TypeRef::Duration | TypeRef::Path => true,
352 TypeRef::Optional(inner) => is_php_scalar(inner, enum_names),
353 TypeRef::Vec(inner) => {
354 matches!(inner.as_ref(), TypeRef::Primitive(_) | TypeRef::String | TypeRef::Char)
355 || matches!(inner.as_ref(), TypeRef::Named(n) if enum_names.contains(n))
356 }
357 TypeRef::Named(n) if enum_names.contains(n) => true,
358 TypeRef::Named(_) | TypeRef::Map(_, _) | TypeRef::Json | TypeRef::Bytes | TypeRef::Unit => false,
359 }
360}
361
362fn render_composer_json(
367 e2e_pkg_name: &str,
368 e2e_autoload_ns: &str,
369 pkg_name: &str,
370 pkg_path: &str,
371 pkg_version: &str,
372 dep_mode: crate::config::DependencyMode,
373) -> String {
374 let (require_section, autoload_section) = match dep_mode {
375 crate::config::DependencyMode::Registry => {
376 let require = format!(
377 r#" "require": {{
378 "{pkg_name}": "{pkg_version}"
379 }},
380 "require-dev": {{
381 "phpunit/phpunit": "{phpunit}",
382 "guzzlehttp/guzzle": "{guzzle}"
383 }},"#,
384 phpunit = tv::packagist::PHPUNIT,
385 guzzle = tv::packagist::GUZZLE,
386 );
387 (require, String::new())
388 }
389 crate::config::DependencyMode::Local => {
390 let require = format!(
391 r#" "require-dev": {{
392 "phpunit/phpunit": "{phpunit}",
393 "guzzlehttp/guzzle": "{guzzle}"
394 }},"#,
395 phpunit = tv::packagist::PHPUNIT,
396 guzzle = tv::packagist::GUZZLE,
397 );
398 let pkg_namespace = pkg_name
401 .split('/')
402 .nth(1)
403 .unwrap_or(pkg_name)
404 .split('-')
405 .map(heck::ToUpperCamelCase::to_upper_camel_case)
406 .collect::<Vec<_>>()
407 .join("\\");
408 let autoload = format!(
409 r#"
410 "autoload": {{
411 "psr-4": {{
412 "{}\\": "{}/src/"
413 }}
414 }},"#,
415 pkg_namespace.replace('\\', "\\\\"),
416 pkg_path
417 );
418 (require, autoload)
419 }
420 };
421
422 crate::template_env::render(
423 "php/composer.json.jinja",
424 minijinja::context! {
425 e2e_pkg_name => e2e_pkg_name,
426 e2e_autoload_ns => e2e_autoload_ns,
427 require_section => require_section,
428 autoload_section => autoload_section,
429 },
430 )
431}
432
433fn render_phpunit_xml() -> String {
434 crate::template_env::render("php/phpunit.xml.jinja", minijinja::context! {})
435}
436
437fn render_bootstrap(
438 pkg_path: &str,
439 has_http_fixtures: bool,
440 has_file_fixtures: bool,
441 test_documents_path: &str,
442) -> String {
443 let header = hash::header(CommentStyle::DoubleSlash);
444 crate::template_env::render(
445 "php/bootstrap.php.jinja",
446 minijinja::context! {
447 header => header,
448 pkg_path => pkg_path,
449 has_http_fixtures => has_http_fixtures,
450 has_file_fixtures => has_file_fixtures,
451 test_documents_path => test_documents_path,
452 },
453 )
454}
455
456fn render_run_tests_php(extension_name: &str, cargo_crate_name: Option<&str>) -> String {
457 let header = hash::header(CommentStyle::DoubleSlash);
458 let ext_lib_name = if let Some(crate_name) = cargo_crate_name {
459 format!("lib{}", crate_name.replace('-', "_"))
462 } else {
463 format!("lib{extension_name}_php")
464 };
465 format!(
466 r#"#!/usr/bin/env php
467<?php
468{header}
469declare(strict_types=1);
470
471// Determine platform-specific extension suffix.
472$extSuffix = match (PHP_OS_FAMILY) {{
473 'Darwin' => '.dylib',
474 default => '.so',
475}};
476$extPath = __DIR__ . '/../../target/release/{ext_lib_name}' . $extSuffix;
477
478// If the locally-built extension exists and we have not already restarted with it,
479// re-exec PHP with no system ini (-n) to avoid conflicts with any system-installed
480// version of the extension, then load the local build explicitly.
481if (file_exists($extPath) && !getenv('ALEF_PHP_LOCAL_EXT_LOADED')) {{
482 putenv('ALEF_PHP_LOCAL_EXT_LOADED=1');
483 $php = PHP_BINARY;
484 $phpunitPath = __DIR__ . '/vendor/bin/phpunit';
485
486 $cmd = array_merge(
487 [$php, '-n', '-d', 'extension=' . $extPath],
488 [$phpunitPath],
489 array_slice($GLOBALS['argv'], 1)
490 );
491
492 passthru(implode(' ', array_map('escapeshellarg', $cmd)), $exitCode);
493 exit($exitCode);
494}}
495
496// Extension is now loaded (via the restart above with -n flag).
497// Invoke PHPUnit normally.
498$phpunitPath = __DIR__ . '/vendor/bin/phpunit';
499if (!file_exists($phpunitPath)) {{
500 echo "PHPUnit not found at $phpunitPath. Run 'composer install' first.\n";
501 exit(1);
502}}
503
504require $phpunitPath;
505"#
506 )
507}
508
509#[allow(clippy::too_many_arguments)]
510fn render_test_file(
511 category: &str,
512 fixtures: &[&Fixture],
513 e2e_config: &E2eConfig,
514 lang: &str,
515 namespace: &str,
516 class_name: &str,
517 test_class: &str,
518 field_resolver: &FieldResolver,
519 enum_fields: &HashMap<String, String>,
520 result_is_simple: bool,
521 php_client_factory: Option<&str>,
522 options_via: &str,
523) -> String {
524 let header = hash::header(CommentStyle::DoubleSlash);
525
526 let needs_crawl_config_import = fixtures.iter().any(|f| {
528 let call = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
529 call.args.iter().filter(|a| a.arg_type == "handle").any(|a| {
530 let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
531 !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
532 })
533 });
534
535 let has_http_tests = fixtures.iter().any(|f| f.is_http_test());
537
538 let mut options_type_imports: Vec<String> = fixtures
540 .iter()
541 .flat_map(|f| {
542 let call = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
543 let php_override = call.overrides.get(lang);
544 let opt_type = php_override.and_then(|o| o.options_type.as_deref()).or_else(|| {
545 e2e_config
546 .call
547 .overrides
548 .get(lang)
549 .and_then(|o| o.options_type.as_deref())
550 });
551 let element_types: Vec<String> = call
552 .args
553 .iter()
554 .filter_map(|a| a.element_type.as_ref().map(|t| t.to_string()))
555 .filter(|t| !is_php_reserved_type(t))
556 .collect();
557 opt_type.map(|t| t.to_string()).into_iter().chain(element_types)
558 })
559 .collect::<std::collections::HashSet<_>>()
560 .into_iter()
561 .collect();
562 options_type_imports.sort();
563
564 let mut imports_use: Vec<String> = Vec::new();
566 if needs_crawl_config_import {
567 imports_use.push(format!("use {namespace}\\CrawlConfig;"));
568 }
569 for type_name in &options_type_imports {
570 if type_name != class_name {
571 imports_use.push(format!("use {namespace}\\{type_name};"));
572 }
573 }
574
575 let mut fixtures_body = String::new();
577 for (i, fixture) in fixtures.iter().enumerate() {
578 if fixture.is_http_test() {
579 render_http_test_method(&mut fixtures_body, fixture, fixture.http.as_ref().unwrap());
580 } else {
581 render_test_method(
582 &mut fixtures_body,
583 fixture,
584 e2e_config,
585 lang,
586 namespace,
587 class_name,
588 field_resolver,
589 enum_fields,
590 result_is_simple,
591 php_client_factory,
592 options_via,
593 );
594 }
595 if i + 1 < fixtures.len() {
596 fixtures_body.push('\n');
597 }
598 }
599
600 crate::template_env::render(
601 "php/test_file.jinja",
602 minijinja::context! {
603 header => header,
604 namespace => namespace,
605 class_name => class_name,
606 test_class => test_class,
607 category => category,
608 imports_use => imports_use,
609 has_http_tests => has_http_tests,
610 fixtures_body => fixtures_body,
611 },
612 )
613}
614
615struct PhpTestClientRenderer;
623
624impl client::TestClientRenderer for PhpTestClientRenderer {
625 fn language_name(&self) -> &'static str {
626 "php"
627 }
628
629 fn sanitize_test_name(&self, id: &str) -> String {
631 sanitize_filename(id)
632 }
633
634 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
640 let escaped_reason = skip_reason.map(escape_php);
641 let rendered = crate::template_env::render(
642 "php/http_test_open.jinja",
643 minijinja::context! {
644 fn_name => fn_name,
645 description => description,
646 skip_reason => escaped_reason,
647 },
648 );
649 out.push_str(&rendered);
650 }
651
652 fn render_test_close(&self, out: &mut String) {
654 let rendered = crate::template_env::render("php/http_test_close.jinja", minijinja::context! {});
655 out.push_str(&rendered);
656 }
657
658 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
663 let method = ctx.method.to_uppercase();
664
665 let mut opts: Vec<String> = Vec::new();
667
668 if let Some(body) = ctx.body {
669 let php_body = json_to_php(body);
670 opts.push(format!("'json' => {php_body}"));
671 }
672
673 let mut header_pairs: Vec<String> = Vec::new();
675 if let Some(ct) = ctx.content_type {
676 if !ctx.headers.keys().any(|k| k.to_lowercase() == "content-type") {
678 header_pairs.push(format!("\"Content-Type\" => \"{}\"", escape_php(ct)));
679 }
680 }
681 for (k, v) in ctx.headers {
682 header_pairs.push(format!("\"{}\" => \"{}\"", escape_php(k), escape_php(v)));
683 }
684 if !header_pairs.is_empty() {
685 opts.push(format!("'headers' => [{}]", header_pairs.join(", ")));
686 }
687
688 if !ctx.cookies.is_empty() {
689 let cookie_str = ctx
690 .cookies
691 .iter()
692 .map(|(k, v)| format!("{}={}", k, v))
693 .collect::<Vec<_>>()
694 .join("; ");
695 opts.push(format!("'headers' => ['Cookie' => \"{}\"]", escape_php(&cookie_str)));
696 }
697
698 if !ctx.query_params.is_empty() {
699 let pairs: Vec<String> = ctx
700 .query_params
701 .iter()
702 .map(|(k, v)| {
703 let val_str = match v {
704 serde_json::Value::String(s) => s.clone(),
705 other => other.to_string(),
706 };
707 format!("\"{}\" => \"{}\"", escape_php(k), escape_php(&val_str))
708 })
709 .collect();
710 opts.push(format!("'query' => [{}]", pairs.join(", ")));
711 }
712
713 let path_lit = format!("\"{}\"", escape_php(ctx.path));
714
715 let rendered = crate::template_env::render(
716 "php/http_request.jinja",
717 minijinja::context! {
718 method => method,
719 path => path_lit,
720 opts => opts,
721 response_var => ctx.response_var,
722 },
723 );
724 out.push_str(&rendered);
725 }
726
727 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
729 let rendered = crate::template_env::render(
730 "php/http_assertions.jinja",
731 minijinja::context! {
732 response_var => "",
733 status_code => status,
734 headers => Vec::<std::collections::HashMap<&str, String>>::new(),
735 body_assertion => String::new(),
736 partial_body => Vec::<std::collections::HashMap<&str, String>>::new(),
737 validation_errors => Vec::<std::collections::HashMap<&str, String>>::new(),
738 },
739 );
740 out.push_str(&rendered);
741 }
742
743 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
748 let header_key = name.to_lowercase();
749 let header_key_lit = format!("\"{}\"", escape_php(&header_key));
750 let assertion_code = match expected {
751 "<<present>>" => {
752 format!("$this->assertTrue($response->hasHeader({header_key_lit}));")
753 }
754 "<<absent>>" => {
755 format!("$this->assertFalse($response->hasHeader({header_key_lit}));")
756 }
757 "<<uuid>>" => {
758 format!(
759 "$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}));"
760 )
761 }
762 literal => {
763 let val_lit = format!("\"{}\"", escape_php(literal));
764 format!("$this->assertEquals({val_lit}, $response->getHeaderLine({header_key_lit}));")
765 }
766 };
767
768 let mut headers = vec![std::collections::HashMap::new()];
769 headers[0].insert("assertion_code", assertion_code);
770
771 let rendered = crate::template_env::render(
772 "php/http_assertions.jinja",
773 minijinja::context! {
774 response_var => "",
775 status_code => 0u16,
776 headers => headers,
777 body_assertion => String::new(),
778 partial_body => Vec::<std::collections::HashMap<&str, String>>::new(),
779 validation_errors => Vec::<std::collections::HashMap<&str, String>>::new(),
780 },
781 );
782 out.push_str(&rendered);
783 }
784
785 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
791 let body_assertion = match expected {
792 serde_json::Value::String(s) if !s.is_empty() => {
793 let php_val = format!("\"{}\"", escape_php(s));
794 format!("$this->assertEquals({php_val}, (string) $response->getBody());")
795 }
796 _ => {
797 let php_val = json_to_php(expected);
798 format!(
799 "$body = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);\n $this->assertEquals({php_val}, $body);"
800 )
801 }
802 };
803
804 let rendered = crate::template_env::render(
805 "php/http_assertions.jinja",
806 minijinja::context! {
807 response_var => "",
808 status_code => 0u16,
809 headers => Vec::<std::collections::HashMap<&str, String>>::new(),
810 body_assertion => body_assertion,
811 partial_body => Vec::<std::collections::HashMap<&str, String>>::new(),
812 validation_errors => Vec::<std::collections::HashMap<&str, String>>::new(),
813 },
814 );
815 out.push_str(&rendered);
816 }
817
818 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
820 if let Some(obj) = expected.as_object() {
821 let mut partial_body: Vec<std::collections::HashMap<&str, String>> = Vec::new();
822 for (key, val) in obj {
823 let php_key = format!("\"{}\"", escape_php(key));
824 let php_val = json_to_php(val);
825 let assertion_code = format!("$this->assertEquals({php_val}, $body[{php_key}]);");
826 let mut entry = std::collections::HashMap::new();
827 entry.insert("assertion_code", assertion_code);
828 partial_body.push(entry);
829 }
830
831 let rendered = crate::template_env::render(
832 "php/http_assertions.jinja",
833 minijinja::context! {
834 response_var => "",
835 status_code => 0u16,
836 headers => Vec::<std::collections::HashMap<&str, String>>::new(),
837 body_assertion => String::new(),
838 partial_body => partial_body,
839 validation_errors => Vec::<std::collections::HashMap<&str, String>>::new(),
840 },
841 );
842 out.push_str(&rendered);
843 }
844 }
845
846 fn render_assert_validation_errors(
849 &self,
850 out: &mut String,
851 _response_var: &str,
852 errors: &[ValidationErrorExpectation],
853 ) {
854 let mut validation_errors: Vec<std::collections::HashMap<&str, String>> = Vec::new();
855 for err in errors {
856 let msg_lit = format!("\"{}\"", escape_php(&err.msg));
857 let assertion_code =
858 format!("$this->assertStringContainsString({msg_lit}, json_encode($body, JSON_UNESCAPED_SLASHES));");
859 let mut entry = std::collections::HashMap::new();
860 entry.insert("assertion_code", assertion_code);
861 validation_errors.push(entry);
862 }
863
864 let rendered = crate::template_env::render(
865 "php/http_assertions.jinja",
866 minijinja::context! {
867 response_var => "",
868 status_code => 0u16,
869 headers => Vec::<std::collections::HashMap<&str, String>>::new(),
870 body_assertion => String::new(),
871 partial_body => Vec::<std::collections::HashMap<&str, String>>::new(),
872 validation_errors => validation_errors,
873 },
874 );
875 out.push_str(&rendered);
876 }
877}
878
879fn render_http_test_method(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
884 if http.expected_response.status_code == 101 {
888 let method_name = sanitize_filename(&fixture.id);
889 let description = &fixture.description;
890 out.push_str(&crate::template_env::render(
891 "php/http_test_skip_101.jinja",
892 minijinja::context! {
893 method_name => method_name,
894 description => description,
895 },
896 ));
897 return;
898 }
899
900 client::http_call::render_http_test(out, &PhpTestClientRenderer, fixture);
901}
902
903#[allow(clippy::too_many_arguments)]
908fn render_test_method(
909 out: &mut String,
910 fixture: &Fixture,
911 e2e_config: &E2eConfig,
912 lang: &str,
913 namespace: &str,
914 class_name: &str,
915 field_resolver: &FieldResolver,
916 enum_fields: &HashMap<String, String>,
917 result_is_simple: bool,
918 php_client_factory: Option<&str>,
919 options_via: &str,
920) {
921 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
923 let call_overrides = call_config.overrides.get(lang);
924 let has_override = call_overrides.is_some_and(|o| o.function.is_some());
925 let result_is_simple = call_overrides.is_some_and(|o| o.result_is_simple) || result_is_simple;
929 let mut function_name = call_overrides
930 .and_then(|o| o.function.as_ref())
931 .cloned()
932 .unwrap_or_else(|| call_config.function.clone());
933 if !has_override {
938 function_name = function_name.to_lower_camel_case();
939 }
940 let result_var = &call_config.result_var;
941 let args = &call_config.args;
942
943 let method_name = sanitize_filename(&fixture.id);
944 let description = &fixture.description;
945 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
946
947 let call_options_type = call_overrides.and_then(|o| o.options_type.as_deref()).or_else(|| {
949 e2e_config
950 .call
951 .overrides
952 .get(lang)
953 .and_then(|o| o.options_type.as_deref())
954 });
955
956 let (mut setup_lines, args_str) = build_args_and_setup(
957 &fixture.input,
958 args,
959 class_name,
960 enum_fields,
961 fixture,
962 options_via,
963 call_options_type,
964 );
965
966 let skip_test = call_config.skip_languages.iter().any(|l| l == "php");
968 if skip_test {
969 let rendered = crate::template_env::render(
970 "php/test_method.jinja",
971 minijinja::context! {
972 method_name => method_name,
973 description => description,
974 client_factory => String::new(),
975 setup_lines => Vec::<String>::new(),
976 expects_error => false,
977 skip_test => true,
978 has_usable_assertions => false,
979 call_expr => String::new(),
980 result_var => result_var,
981 assertions_body => String::new(),
982 },
983 );
984 out.push_str(&rendered);
985 return;
986 }
987
988 let mut options_already_created = !args_str.is_empty() && args_str == "$options";
990 if let Some(visitor_spec) = &fixture.visitor {
991 build_php_visitor(&mut setup_lines, visitor_spec);
992 if !options_already_created {
993 let options_type = call_options_type.unwrap_or("ConversionOptions");
994 setup_lines.push(format!("$builder = \\{namespace}\\{options_type}::builder();"));
995 setup_lines.push("$options = $builder->visitor($visitor)->build();".to_string());
996 options_already_created = true;
997 }
998 }
999
1000 let final_args = if options_already_created {
1001 if args_str.is_empty() || args_str == "$options" {
1002 "$options".to_string()
1003 } else {
1004 format!("{args_str}, $options")
1005 }
1006 } else {
1007 args_str
1008 };
1009
1010 let call_expr = if php_client_factory.is_some() {
1011 format!("$client->{function_name}({final_args})")
1012 } else {
1013 format!("{class_name}::{function_name}({final_args})")
1014 };
1015
1016 let has_mock = fixture.mock_response.is_some() || fixture.http.is_some();
1017 let api_key_var = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref());
1018 let client_factory = if let Some(factory) = php_client_factory {
1019 let fixture_id = &fixture.id;
1020 if let Some(var) = api_key_var.filter(|_| has_mock) {
1021 format!(
1022 "$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);"
1023 )
1024 } else if has_mock {
1025 let base_url_expr = if fixture.has_host_root_route() {
1026 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1027 format!("(getenv('{env_key}') ?: getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}')")
1028 } else {
1029 format!("getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}'")
1030 };
1031 format!("$client = \\{namespace}\\{class_name}::{factory}('test-key', {base_url_expr});")
1032 } else if let Some(var) = api_key_var {
1033 format!(
1034 "$apiKey = getenv('{var}');\n if (!$apiKey) {{ $this->markTestSkipped('{var} not set'); return; }}\n $client = \\{namespace}\\{class_name}::{factory}($apiKey);"
1035 )
1036 } else {
1037 format!("$client = \\{namespace}\\{class_name}::{factory}('test-key');")
1038 }
1039 } else {
1040 String::new()
1041 };
1042
1043 let is_streaming = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
1045
1046 let has_usable_assertions = fixture.assertions.iter().any(|a| {
1049 if a.assertion_type == "error" || a.assertion_type == "not_error" {
1050 return false;
1051 }
1052 match &a.field {
1053 Some(f) if !f.is_empty() => {
1054 if is_streaming && crate::codegen::streaming_assertions::is_streaming_virtual_field(f) {
1055 return true;
1056 }
1057 field_resolver.is_valid_for_result(f)
1058 }
1059 _ => true,
1060 }
1061 });
1062
1063 let collect_snippet = if is_streaming {
1065 crate::codegen::streaming_assertions::StreamingFieldResolver::collect_snippet("php", result_var, "chunks")
1066 .unwrap_or_default()
1067 } else {
1068 String::new()
1069 };
1070
1071 let mut assertions_body = String::new();
1073 for assertion in &fixture.assertions {
1074 render_assertion(
1075 &mut assertions_body,
1076 assertion,
1077 result_var,
1078 field_resolver,
1079 result_is_simple,
1080 call_config.result_is_array,
1081 );
1082 }
1083
1084 if is_streaming && !expects_error && assertions_body.trim().is_empty() {
1090 assertions_body.push_str(" $this->assertTrue(is_array($chunks), 'expected drained chunks list');\n");
1091 }
1092
1093 let rendered = crate::template_env::render(
1094 "php/test_method.jinja",
1095 minijinja::context! {
1096 method_name => method_name,
1097 description => description,
1098 client_factory => client_factory,
1099 setup_lines => setup_lines,
1100 expects_error => expects_error,
1101 skip_test => fixture.assertions.is_empty(),
1102 has_usable_assertions => has_usable_assertions || is_streaming,
1103 call_expr => call_expr,
1104 result_var => result_var,
1105 collect_snippet => collect_snippet,
1106 assertions_body => assertions_body,
1107 },
1108 );
1109 out.push_str(&rendered);
1110}
1111
1112fn emit_php_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
1125 if let Some(items) = arr.as_array() {
1126 let item_strs: Vec<String> = items
1127 .iter()
1128 .filter_map(|item| {
1129 if let Some(obj) = item.as_object() {
1130 match elem_type {
1131 "BatchBytesItem" => {
1132 let content = obj.get("content").and_then(|v| v.as_array());
1133 let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1134 let content_code = if let Some(arr) = content {
1135 let bytes: Vec<String> = arr
1136 .iter()
1137 .filter_map(|v| v.as_u64())
1138 .map(|n| format!("\\x{:02x}", n))
1139 .collect();
1140 format!("\"{}\"", bytes.join(""))
1141 } else {
1142 "\"\"".to_string()
1143 };
1144 Some(format!(
1145 "new {}(content: {}, mimeType: \"{}\")",
1146 elem_type, content_code, mime_type
1147 ))
1148 }
1149 "BatchFileItem" => {
1150 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1151 Some(format!("new {}(path: \"{}\")", elem_type, path))
1152 }
1153 _ => None,
1154 }
1155 } else {
1156 None
1157 }
1158 })
1159 .collect();
1160 format!("[{}]", item_strs.join(", "))
1161 } else {
1162 "[]".to_string()
1163 }
1164}
1165
1166fn build_args_and_setup(
1167 input: &serde_json::Value,
1168 args: &[crate::config::ArgMapping],
1169 class_name: &str,
1170 _enum_fields: &HashMap<String, String>,
1171 fixture: &crate::fixture::Fixture,
1172 options_via: &str,
1173 options_type: Option<&str>,
1174) -> (Vec<String>, String) {
1175 let fixture_id = &fixture.id;
1176 if args.is_empty() {
1177 let is_empty_input = match input {
1180 serde_json::Value::Null => true,
1181 serde_json::Value::Object(m) => m.is_empty(),
1182 _ => false,
1183 };
1184 if is_empty_input {
1185 return (Vec::new(), String::new());
1186 }
1187 return (Vec::new(), json_to_php(input));
1188 }
1189
1190 let mut setup_lines: Vec<String> = Vec::new();
1191 let mut parts: Vec<String> = Vec::new();
1192
1193 let arg_has_emission = |arg: &crate::config::ArgMapping| -> bool {
1198 let val = if arg.field == "input" {
1199 Some(input)
1200 } else {
1201 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1202 input.get(field)
1203 };
1204 match val {
1205 None | Some(serde_json::Value::Null) => !arg.optional,
1206 Some(_) => true,
1207 }
1208 };
1209 let any_later_has_emission = |from_idx: usize| -> bool { args[from_idx..].iter().any(arg_has_emission) };
1210
1211 for (idx, arg) in args.iter().enumerate() {
1212 if arg.arg_type == "mock_url" {
1213 if fixture.has_host_root_route() {
1214 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1215 setup_lines.push(format!(
1216 "${} = getenv('{env_key}') ?: getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}';",
1217 arg.name,
1218 ));
1219 } else {
1220 setup_lines.push(format!(
1221 "${} = getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}';",
1222 arg.name,
1223 ));
1224 }
1225 parts.push(format!("${}", arg.name));
1226 continue;
1227 }
1228
1229 if arg.arg_type == "handle" {
1230 let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
1232 let config_value = if arg.field == "input" {
1233 input
1234 } else {
1235 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1236 input.get(field).unwrap_or(&serde_json::Value::Null)
1237 };
1238 if config_value.is_null()
1239 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1240 {
1241 setup_lines.push(format!("${} = {class_name}::{constructor_name}(null);", arg.name,));
1242 } else {
1243 let name = &arg.name;
1244 let filtered_config = filter_empty_enum_strings(config_value);
1249 setup_lines.push(format!(
1250 "${name}_config = CrawlConfig::from_json(json_encode({}));",
1251 json_to_php_camel_keys(&filtered_config)
1252 ));
1253 setup_lines.push(format!(
1254 "${} = {class_name}::{constructor_name}(${name}_config);",
1255 arg.name,
1256 ));
1257 }
1258 parts.push(format!("${}", arg.name));
1259 continue;
1260 }
1261
1262 let val = if arg.field == "input" {
1263 Some(input)
1264 } else {
1265 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1266 input.get(field)
1267 };
1268
1269 if arg.arg_type == "bytes" {
1273 match val {
1274 None | Some(serde_json::Value::Null) => {
1275 if arg.optional {
1276 parts.push("null".to_string());
1277 } else {
1278 parts.push("\"\"".to_string());
1279 }
1280 }
1281 Some(serde_json::Value::String(s)) => {
1282 let var_name = format!("{}Bytes", arg.name);
1283 setup_lines.push(format!(
1284 "${var_name} = file_get_contents(\"{path}\");\n if (${var_name} === false) {{ $this->fail(\"failed to read fixture: {path}\"); }}",
1285 path = s.replace('"', "\\\"")
1286 ));
1287 parts.push(format!("${var_name}"));
1288 }
1289 Some(serde_json::Value::Array(arr)) => {
1290 let bytes: String = arr
1291 .iter()
1292 .filter_map(|v| v.as_u64())
1293 .map(|n| format!("\\x{:02x}", n))
1294 .collect();
1295 parts.push(format!("\"{bytes}\""));
1296 }
1297 Some(other) => {
1298 parts.push(json_to_php(other));
1299 }
1300 }
1301 continue;
1302 }
1303
1304 match val {
1305 None | Some(serde_json::Value::Null) if arg.arg_type == "json_object" && arg.name == "config" => {
1306 let type_name = if arg.name == "config" {
1312 "ExtractionConfig".to_string()
1313 } else {
1314 format!("{}Config", arg.name.to_upper_camel_case())
1315 };
1316 parts.push(format!("{type_name}::from_json('{{}}')"));
1317 continue;
1318 }
1319 None | Some(serde_json::Value::Null) if arg.optional => {
1320 if any_later_has_emission(idx + 1) {
1325 parts.push("null".to_string());
1326 }
1327 continue;
1328 }
1329 None | Some(serde_json::Value::Null) => {
1330 let default_val = match arg.arg_type.as_str() {
1332 "string" => "\"\"".to_string(),
1333 "int" | "integer" => "0".to_string(),
1334 "float" | "number" => "0.0".to_string(),
1335 "bool" | "boolean" => "false".to_string(),
1336 "json_object" if options_via == "json" => "null".to_string(),
1337 _ => "null".to_string(),
1338 };
1339 parts.push(default_val);
1340 }
1341 Some(v) => {
1342 if arg.arg_type == "json_object" && !v.is_null() {
1343 if let Some(elem_type) = &arg.element_type {
1345 if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && v.is_array() {
1346 parts.push(emit_php_batch_item_array(v, elem_type));
1347 continue;
1348 }
1349 if v.is_array() && is_php_reserved_type(elem_type) {
1353 parts.push(json_to_php(v));
1354 continue;
1355 }
1356 }
1357 match options_via {
1358 "json" => {
1359 let filtered_v = filter_empty_enum_strings(v);
1362
1363 if let serde_json::Value::Object(obj) = &filtered_v {
1365 if obj.is_empty() {
1366 parts.push("null".to_string());
1367 continue;
1368 }
1369 }
1370
1371 parts.push(format!("json_encode({})", json_to_php_camel_keys(&filtered_v)));
1372 continue;
1373 }
1374 _ => {
1375 if let Some(type_name) = options_type {
1376 let filtered_v = filter_empty_enum_strings(v);
1381
1382 if let serde_json::Value::Object(obj) = &filtered_v {
1385 if obj.is_empty() {
1386 let arg_var = format!("${}", arg.name);
1387 setup_lines.push(format!("{arg_var} = {type_name}::from_json('{{}}');"));
1388 parts.push(arg_var);
1389 continue;
1390 }
1391 }
1392
1393 let arg_var = format!("${}", arg.name);
1394 setup_lines.push(format!(
1398 "{arg_var} = {type_name}::from_json(json_encode({}));",
1399 json_to_php_camel_keys(&filtered_v)
1400 ));
1401 parts.push(arg_var);
1402 continue;
1403 }
1404 if let Some(obj) = v.as_object() {
1408 setup_lines.push("$builder = $this->createDefaultOptionsBuilder();".to_string());
1409 for (k, vv) in obj {
1410 let snake_key = k.to_snake_case();
1411 if snake_key == "preprocessing" {
1412 if let Some(prep_obj) = vv.as_object() {
1413 let enabled =
1414 prep_obj.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
1415 let preset =
1416 prep_obj.get("preset").and_then(|v| v.as_str()).unwrap_or("Minimal");
1417 let remove_navigation = prep_obj
1418 .get("remove_navigation")
1419 .and_then(|v| v.as_bool())
1420 .unwrap_or(true);
1421 let remove_forms =
1422 prep_obj.get("remove_forms").and_then(|v| v.as_bool()).unwrap_or(true);
1423 setup_lines.push(format!(
1424 "$preprocessing = $this->createPreprocessingOptions({}, {}, {}, {});",
1425 if enabled { "true" } else { "false" },
1426 json_to_php(&serde_json::Value::String(preset.to_string())),
1427 if remove_navigation { "true" } else { "false" },
1428 if remove_forms { "true" } else { "false" }
1429 ));
1430 setup_lines.push(
1431 "$builder = $builder->preprocessing($preprocessing);".to_string(),
1432 );
1433 }
1434 }
1435 }
1436 setup_lines.push("$options = $builder->build();".to_string());
1437 parts.push("$options".to_string());
1438 continue;
1439 }
1440 }
1441 }
1442 }
1443 parts.push(json_to_php(v));
1444 }
1445 }
1446 }
1447
1448 (setup_lines, parts.join(", "))
1449}
1450
1451fn render_assertion(
1452 out: &mut String,
1453 assertion: &Assertion,
1454 result_var: &str,
1455 field_resolver: &FieldResolver,
1456 result_is_simple: bool,
1457 result_is_array: bool,
1458) {
1459 if let Some(f) = &assertion.field {
1462 match f.as_str() {
1463 "chunks_have_content" => {
1464 let pred = format!(
1465 "array_reduce(${result_var}->chunks ?? [], fn($carry, $c) => $carry && !empty($c->content), true)"
1466 );
1467 out.push_str(&crate::template_env::render(
1468 "php/synthetic_assertion.jinja",
1469 minijinja::context! {
1470 assertion_kind => "chunks_content",
1471 assertion_type => assertion.assertion_type.as_str(),
1472 pred => pred,
1473 field_name => f,
1474 },
1475 ));
1476 return;
1477 }
1478 "chunks_have_embeddings" => {
1479 let pred = format!(
1480 "array_reduce(${result_var}->chunks ?? [], fn($carry, $c) => $carry && !empty($c->embedding), true)"
1481 );
1482 out.push_str(&crate::template_env::render(
1483 "php/synthetic_assertion.jinja",
1484 minijinja::context! {
1485 assertion_kind => "chunks_embeddings",
1486 assertion_type => assertion.assertion_type.as_str(),
1487 pred => pred,
1488 field_name => f,
1489 },
1490 ));
1491 return;
1492 }
1493 "embeddings" => {
1497 let php_val = assertion.value.as_ref().map(json_to_php).unwrap_or_default();
1498 out.push_str(&crate::template_env::render(
1499 "php/synthetic_assertion.jinja",
1500 minijinja::context! {
1501 assertion_kind => "embeddings",
1502 assertion_type => assertion.assertion_type.as_str(),
1503 php_val => php_val,
1504 result_var => result_var,
1505 },
1506 ));
1507 return;
1508 }
1509 "embedding_dimensions" => {
1510 let expr = format!("(empty(${result_var}) ? 0 : count(${result_var}[0]))");
1511 let php_val = assertion.value.as_ref().map(json_to_php).unwrap_or_default();
1512 out.push_str(&crate::template_env::render(
1513 "php/synthetic_assertion.jinja",
1514 minijinja::context! {
1515 assertion_kind => "embedding_dimensions",
1516 assertion_type => assertion.assertion_type.as_str(),
1517 expr => expr,
1518 php_val => php_val,
1519 },
1520 ));
1521 return;
1522 }
1523 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1524 let pred = match f.as_str() {
1525 "embeddings_valid" => {
1526 format!("array_reduce(${result_var}, fn($carry, $e) => $carry && count($e) > 0, true)")
1527 }
1528 "embeddings_finite" => {
1529 format!(
1530 "array_reduce(${result_var}, fn($carry, $e) => $carry && array_reduce($e, fn($c, $v) => $c && is_finite($v), true), true)"
1531 )
1532 }
1533 "embeddings_non_zero" => {
1534 format!(
1535 "array_reduce(${result_var}, fn($carry, $e) => $carry && count(array_filter($e, fn($v) => $v !== 0.0)) > 0, true)"
1536 )
1537 }
1538 "embeddings_normalized" => {
1539 format!(
1540 "array_reduce(${result_var}, fn($carry, $e) => $carry && abs(array_sum(array_map(fn($v) => $v * $v, $e)) - 1.0) < 1e-3, true)"
1541 )
1542 }
1543 _ => unreachable!(),
1544 };
1545 let assertion_kind = format!("embeddings_{}", f.strip_prefix("embeddings_").unwrap_or(f));
1546 out.push_str(&crate::template_env::render(
1547 "php/synthetic_assertion.jinja",
1548 minijinja::context! {
1549 assertion_kind => assertion_kind,
1550 assertion_type => assertion.assertion_type.as_str(),
1551 pred => pred,
1552 field_name => f,
1553 },
1554 ));
1555 return;
1556 }
1557 "keywords" | "keywords_count" => {
1560 out.push_str(&crate::template_env::render(
1561 "php/synthetic_assertion.jinja",
1562 minijinja::context! {
1563 assertion_kind => "keywords",
1564 field_name => f,
1565 },
1566 ));
1567 return;
1568 }
1569 _ => {}
1570 }
1571 }
1572
1573 if let Some(f) = &assertion.field {
1576 if !f.is_empty() && crate::codegen::streaming_assertions::is_streaming_virtual_field(f) {
1577 if let Some(expr) =
1578 crate::codegen::streaming_assertions::StreamingFieldResolver::accessor(f, "php", "chunks")
1579 {
1580 let line = match assertion.assertion_type.as_str() {
1581 "count_min" => {
1582 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1583 format!(
1584 " $this->assertGreaterThanOrEqual({n}, count({expr}), 'expected >= {n} chunks');\n"
1585 )
1586 } else {
1587 String::new()
1588 }
1589 }
1590 "count_equals" => {
1591 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1592 format!(" $this->assertCount({n}, {expr});\n")
1593 } else {
1594 String::new()
1595 }
1596 }
1597 "equals" => {
1598 if let Some(serde_json::Value::String(s)) = &assertion.value {
1599 let escaped = s.replace('\\', "\\\\").replace('\'', "\\'");
1600 format!(" $this->assertEquals('{escaped}', {expr});\n")
1601 } else if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1602 format!(" $this->assertEquals({n}, {expr});\n")
1603 } else {
1604 String::new()
1605 }
1606 }
1607 "not_empty" => format!(" $this->assertNotEmpty({expr});\n"),
1608 "is_empty" => format!(" $this->assertEmpty({expr});\n"),
1609 "is_true" => format!(" $this->assertTrue({expr});\n"),
1610 "is_false" => format!(" $this->assertFalse({expr});\n"),
1611 "greater_than" => {
1612 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1613 format!(" $this->assertGreaterThan({n}, {expr});\n")
1614 } else {
1615 String::new()
1616 }
1617 }
1618 "greater_than_or_equal" => {
1619 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1620 format!(" $this->assertGreaterThanOrEqual({n}, {expr});\n")
1621 } else {
1622 String::new()
1623 }
1624 }
1625 "contains" => {
1626 if let Some(serde_json::Value::String(s)) = &assertion.value {
1627 let escaped = s.replace('\\', "\\\\").replace('\'', "\\'");
1628 format!(" $this->assertStringContainsString('{escaped}', {expr});\n")
1629 } else {
1630 String::new()
1631 }
1632 }
1633 _ => format!(
1634 " // streaming field '{f}': assertion type '{}' not rendered\n",
1635 assertion.assertion_type
1636 ),
1637 };
1638 if !line.is_empty() {
1639 out.push_str(&line);
1640 }
1641 }
1642 return;
1643 }
1644 }
1645
1646 if let Some(f) = &assertion.field {
1648 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1649 out.push_str(&crate::template_env::render(
1650 "php/synthetic_assertion.jinja",
1651 minijinja::context! {
1652 assertion_kind => "skipped",
1653 field_name => f,
1654 },
1655 ));
1656 return;
1657 }
1658 }
1659
1660 if result_is_simple {
1663 if let Some(f) = &assertion.field {
1664 let f_lower = f.to_lowercase();
1665 if !f.is_empty()
1666 && f_lower != "content"
1667 && (f_lower.starts_with("metadata")
1668 || f_lower.starts_with("document")
1669 || f_lower.starts_with("structure"))
1670 {
1671 out.push_str(&crate::template_env::render(
1672 "php/synthetic_assertion.jinja",
1673 minijinja::context! {
1674 assertion_kind => "result_is_simple",
1675 field_name => f,
1676 },
1677 ));
1678 return;
1679 }
1680 }
1681 }
1682
1683 let field_expr = match &assertion.field {
1684 _ if result_is_simple => format!("${result_var}"),
1688 Some(f) if !f.is_empty() => field_resolver.accessor(f, "php", &format!("${result_var}")),
1689 _ => format!("${result_var}"),
1690 };
1691
1692 let field_is_array = assertion.field.as_ref().map_or(result_is_array, |f| {
1695 if f.is_empty() {
1696 result_is_array
1697 } else {
1698 field_resolver.is_array(f)
1699 }
1700 });
1701
1702 let trimmed_field_expr_for = |expected: &serde_json::Value| -> String {
1706 if expected.is_string() {
1707 format!("trim({})", field_expr)
1708 } else {
1709 field_expr.clone()
1710 }
1711 };
1712
1713 let assertion_type = assertion.assertion_type.as_str();
1715 let has_php_val = assertion.value.is_some();
1716 let php_val = match assertion.value.as_ref() {
1720 Some(v) => json_to_php(v),
1721 None if assertion_type == "equals" => "null".to_string(),
1722 None => String::new(),
1723 };
1724 let trimmed_field_expr = trimmed_field_expr_for(assertion.value.as_ref().unwrap_or(&serde_json::Value::Null));
1725 let is_string_val = assertion.value.as_ref().is_some_and(|v| v.is_string());
1726 let values_php: Vec<String> = assertion
1730 .values
1731 .as_ref()
1732 .map(|vals| vals.iter().map(json_to_php).collect::<Vec<_>>())
1733 .or_else(|| assertion.value.as_ref().map(|v| vec![json_to_php(v)]))
1734 .unwrap_or_default();
1735 let contains_any_checks: Vec<String> = assertion
1736 .values
1737 .as_ref()
1738 .map_or(Vec::new(), |vals| vals.iter().map(json_to_php).collect());
1739 let n = assertion.value.as_ref().and_then(|v| v.as_u64()).unwrap_or(0);
1740
1741 let call_expr = if let Some(method_name) = &assertion.method {
1743 build_php_method_call(result_var, method_name, assertion.args.as_ref())
1744 } else {
1745 String::new()
1746 };
1747 let check = assertion.check.as_deref().unwrap_or("is_true");
1748 let has_php_check_val = matches!(assertion.assertion_type.as_str(), "method_result") && assertion.value.is_some();
1749 let php_check_val = if matches!(assertion.assertion_type.as_str(), "method_result") {
1750 assertion.value.as_ref().map(json_to_php).unwrap_or_default()
1751 } else {
1752 String::new()
1753 };
1754 let check_n = assertion.value.as_ref().and_then(|v| v.as_u64()).unwrap_or(0);
1755 let is_bool_val = assertion.value.as_ref().is_some_and(|v| v.is_boolean());
1756 let bool_is_true = assertion.value.as_ref().and_then(|v| v.as_bool()).unwrap_or(false);
1757
1758 if matches!(assertion_type, "not_error" | "error") {
1760 if assertion_type == "not_error" {
1761 }
1763 return;
1765 }
1766
1767 let rendered = crate::template_env::render(
1768 "php/assertion.jinja",
1769 minijinja::context! {
1770 assertion_type => assertion_type,
1771 field_expr => field_expr,
1772 php_val => php_val,
1773 has_php_val => has_php_val,
1774 trimmed_field_expr => trimmed_field_expr,
1775 is_string_val => is_string_val,
1776 field_is_array => field_is_array,
1777 values_php => values_php,
1778 contains_any_checks => contains_any_checks,
1779 n => n,
1780 call_expr => call_expr,
1781 check => check,
1782 php_check_val => php_check_val,
1783 has_php_check_val => has_php_check_val,
1784 check_n => check_n,
1785 is_bool_val => is_bool_val,
1786 bool_is_true => bool_is_true,
1787 },
1788 );
1789 let _ = write!(out, " {}", rendered);
1790}
1791
1792fn build_php_method_call(result_var: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
1799 let extra_args = if let Some(args_val) = args {
1800 args_val
1801 .as_object()
1802 .map(|obj| {
1803 obj.values()
1804 .map(|v| match v {
1805 serde_json::Value::String(s) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")),
1806 serde_json::Value::Bool(true) => "true".to_string(),
1807 serde_json::Value::Bool(false) => "false".to_string(),
1808 serde_json::Value::Number(n) => n.to_string(),
1809 serde_json::Value::Null => "null".to_string(),
1810 other => format!("\"{}\"", other.to_string().replace('\\', "\\\\").replace('"', "\\\"")),
1811 })
1812 .collect::<Vec<_>>()
1813 .join(", ")
1814 })
1815 .unwrap_or_default()
1816 } else {
1817 String::new()
1818 };
1819
1820 if extra_args.is_empty() {
1821 format!("${result_var}->{method_name}()")
1822 } else {
1823 format!("${result_var}->{method_name}({extra_args})")
1824 }
1825}
1826
1827fn filter_empty_enum_strings(value: &serde_json::Value) -> serde_json::Value {
1831 match value {
1832 serde_json::Value::Object(map) => {
1833 let filtered: serde_json::Map<String, serde_json::Value> = map
1834 .iter()
1835 .filter_map(|(k, v)| {
1836 if let serde_json::Value::String(s) = v {
1838 if s.is_empty() {
1839 return None;
1840 }
1841 }
1842 Some((k.clone(), filter_empty_enum_strings(v)))
1844 })
1845 .collect();
1846 serde_json::Value::Object(filtered)
1847 }
1848 serde_json::Value::Array(arr) => {
1849 let filtered: Vec<serde_json::Value> = arr.iter().map(filter_empty_enum_strings).collect();
1850 serde_json::Value::Array(filtered)
1851 }
1852 other => other.clone(),
1853 }
1854}
1855
1856fn json_to_php(value: &serde_json::Value) -> String {
1858 match value {
1859 serde_json::Value::String(s) => format!("\"{}\"", escape_php(s)),
1860 serde_json::Value::Bool(true) => "true".to_string(),
1861 serde_json::Value::Bool(false) => "false".to_string(),
1862 serde_json::Value::Number(n) => n.to_string(),
1863 serde_json::Value::Null => "null".to_string(),
1864 serde_json::Value::Array(arr) => {
1865 let items: Vec<String> = arr.iter().map(json_to_php).collect();
1866 format!("[{}]", items.join(", "))
1867 }
1868 serde_json::Value::Object(map) => {
1869 let items: Vec<String> = map
1870 .iter()
1871 .map(|(k, v)| format!("\"{}\" => {}", escape_php(k), json_to_php(v)))
1872 .collect();
1873 format!("[{}]", items.join(", "))
1874 }
1875 }
1876}
1877
1878fn json_to_php_camel_keys(value: &serde_json::Value) -> String {
1883 match value {
1884 serde_json::Value::Object(map) => {
1885 let items: Vec<String> = map
1886 .iter()
1887 .map(|(k, v)| {
1888 let camel_key = k.to_lower_camel_case();
1889 format!("\"{}\" => {}", escape_php(&camel_key), json_to_php_camel_keys(v))
1890 })
1891 .collect();
1892 format!("[{}]", items.join(", "))
1893 }
1894 serde_json::Value::Array(arr) => {
1895 let items: Vec<String> = arr.iter().map(json_to_php_camel_keys).collect();
1896 format!("[{}]", items.join(", "))
1897 }
1898 _ => json_to_php(value),
1899 }
1900}
1901
1902fn build_php_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) {
1908 setup_lines.push("$visitor = new class {".to_string());
1909 for (method_name, action) in &visitor_spec.callbacks {
1910 emit_php_visitor_method(setup_lines, method_name, action);
1911 }
1912 setup_lines.push("};".to_string());
1913}
1914
1915fn emit_php_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
1917 let params = match method_name {
1918 "visit_link" => "$ctx, $href, $text, $title",
1919 "visit_image" => "$ctx, $src, $alt, $title",
1920 "visit_heading" => "$ctx, $level, $text, $id",
1921 "visit_code_block" => "$ctx, $lang, $code",
1922 "visit_code_inline"
1923 | "visit_strong"
1924 | "visit_emphasis"
1925 | "visit_strikethrough"
1926 | "visit_underline"
1927 | "visit_subscript"
1928 | "visit_superscript"
1929 | "visit_mark"
1930 | "visit_button"
1931 | "visit_summary"
1932 | "visit_figcaption"
1933 | "visit_definition_term"
1934 | "visit_definition_description" => "$ctx, $text",
1935 "visit_text" => "$ctx, $text",
1936 "visit_list_item" => "$ctx, $ordered, $marker, $text",
1937 "visit_blockquote" => "$ctx, $content, $depth",
1938 "visit_table_row" => "$ctx, $cells, $isHeader",
1939 "visit_custom_element" => "$ctx, $tagName, $html",
1940 "visit_form" => "$ctx, $actionUrl, $method",
1941 "visit_input" => "$ctx, $input_type, $name, $value",
1942 "visit_audio" | "visit_video" | "visit_iframe" => "$ctx, $src",
1943 "visit_details" => "$ctx, $isOpen",
1944 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "$ctx, $output",
1945 "visit_list_start" => "$ctx, $ordered",
1946 "visit_list_end" => "$ctx, $ordered, $output",
1947 _ => "$ctx",
1948 };
1949
1950 let (action_type, action_value, return_form) = match action {
1951 CallbackAction::Skip => ("skip", String::new(), "dict"),
1952 CallbackAction::Continue => ("continue", String::new(), "dict"),
1953 CallbackAction::PreserveHtml => ("preserve_html", String::new(), "dict"),
1954 CallbackAction::Custom { output } => ("custom", escape_php(output), "dict"),
1955 CallbackAction::CustomTemplate { template, return_form } => {
1956 let form = match return_form {
1957 TemplateReturnForm::Dict => "dict",
1958 TemplateReturnForm::BareString => "bare_string",
1959 };
1960 ("custom_template", escape_php(template), form)
1961 }
1962 };
1963
1964 let rendered = crate::template_env::render(
1965 "php/visitor_method.jinja",
1966 minijinja::context! {
1967 method_name => method_name,
1968 params => params,
1969 action_type => action_type,
1970 action_value => action_value,
1971 return_form => return_form,
1972 },
1973 );
1974 for line in rendered.lines() {
1975 setup_lines.push(line.to_string());
1976 }
1977}
1978
1979fn is_php_reserved_type(name: &str) -> bool {
1981 matches!(
1982 name.to_ascii_lowercase().as_str(),
1983 "string"
1984 | "int"
1985 | "integer"
1986 | "float"
1987 | "double"
1988 | "bool"
1989 | "boolean"
1990 | "array"
1991 | "object"
1992 | "null"
1993 | "void"
1994 | "callable"
1995 | "iterable"
1996 | "never"
1997 | "self"
1998 | "parent"
1999 | "static"
2000 | "true"
2001 | "false"
2002 | "mixed"
2003 )
2004}