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(
146 f.call.as_deref(),
147 &f.id,
148 &f.resolved_category(),
149 &f.tags,
150 &f.input,
151 );
152 cc.args
153 .iter()
154 .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
155 });
156
157 files.push(GeneratedFile {
159 path: output_base.join("bootstrap.php"),
160 content: render_bootstrap(
161 &pkg_path,
162 has_http_fixtures,
163 has_file_fixtures,
164 &e2e_config.test_documents_relative_from(0),
165 ),
166 generated_header: true,
167 });
168
169 files.push(GeneratedFile {
171 path: output_base.join("run_tests.php"),
172 content: render_run_tests_php(&extension_name, config.php_cargo_crate_name()),
173 generated_header: true,
174 });
175
176 let tests_base = output_base.join("tests");
178
179 let php_enum_names: HashSet<String> = enums.iter().map(|e| e.name.clone()).collect();
193
194 for group in groups {
195 let active: Vec<&Fixture> = group
196 .fixtures
197 .iter()
198 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
199 .collect();
200
201 if active.is_empty() {
202 continue;
203 }
204
205 let test_class = format!("{}Test", sanitize_filename(&group.category).to_upper_camel_case());
206 let filename = format!("{test_class}.php");
207 let content = render_test_file(
208 &group.category,
209 &active,
210 e2e_config,
211 lang,
212 &namespace,
213 &class_name,
214 &test_class,
215 type_defs,
216 &php_enum_names,
217 enum_fields,
218 result_is_simple,
219 php_client_factory,
220 options_via,
221 &config.adapters,
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 build_php_getter_map(
264 type_defs: &[alef_core::ir::TypeDef],
265 enum_names: &HashSet<String>,
266 call: &alef_core::config::e2e::CallConfig,
267 result_fields: &HashSet<String>,
268) -> PhpGetterMap {
269 let mut getters: HashMap<String, HashSet<String>> = HashMap::new();
270 let mut field_types: HashMap<String, HashMap<String, String>> = HashMap::new();
271 let mut all_fields: HashMap<String, HashSet<String>> = HashMap::new();
272 for td in type_defs {
273 let mut getter_fields: HashSet<String> = HashSet::new();
274 let mut field_type_map: HashMap<String, String> = HashMap::new();
275 let mut td_all_fields: HashSet<String> = HashSet::new();
276 for f in &td.fields {
277 td_all_fields.insert(f.name.clone());
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 all_fields.insert(td.name.clone(), td_all_fields);
287 if !field_type_map.is_empty() {
288 field_types.insert(td.name.clone(), field_type_map);
289 }
290 }
291 let root_type = derive_root_type(call, type_defs, result_fields);
292 PhpGetterMap {
293 getters,
294 field_types,
295 root_type,
296 all_fields,
297 }
298}
299
300fn inner_named(ty: &TypeRef) -> Option<String> {
303 match ty {
304 TypeRef::Named(n) => Some(n.clone()),
305 TypeRef::Optional(inner) | TypeRef::Vec(inner) => inner_named(inner),
306 _ => None,
307 }
308}
309
310fn derive_root_type(
321 call: &alef_core::config::e2e::CallConfig,
322 type_defs: &[alef_core::ir::TypeDef],
323 result_fields: &HashSet<String>,
324) -> Option<String> {
325 const LOOKUP_LANGS: &[&str] = &["php", "c", "csharp", "java", "kotlin", "go"];
326 for lang in LOOKUP_LANGS {
327 if let Some(o) = call.overrides.get(*lang)
328 && let Some(rt) = o.result_type.as_deref()
329 && !rt.is_empty()
330 && type_defs.iter().any(|td| td.name == rt)
331 {
332 return Some(rt.to_string());
333 }
334 }
335 if result_fields.is_empty() {
336 return None;
337 }
338 let matches: Vec<&alef_core::ir::TypeDef> = type_defs
339 .iter()
340 .filter(|td| {
341 let names: HashSet<&str> = td.fields.iter().map(|f| f.name.as_str()).collect();
342 result_fields.iter().all(|rf| names.contains(rf.as_str()))
343 })
344 .collect();
345 if matches.len() == 1 {
346 return Some(matches[0].name.clone());
347 }
348 None
349}
350
351fn is_php_scalar(ty: &TypeRef, enum_names: &HashSet<String>) -> bool {
352 match ty {
353 TypeRef::Primitive(_) | TypeRef::String | TypeRef::Char | TypeRef::Duration | TypeRef::Path => true,
354 TypeRef::Optional(inner) => is_php_scalar(inner, enum_names),
355 TypeRef::Vec(inner) => {
356 matches!(inner.as_ref(), TypeRef::Primitive(_) | TypeRef::String | TypeRef::Char)
357 || matches!(inner.as_ref(), TypeRef::Named(n) if enum_names.contains(n))
358 }
359 TypeRef::Named(n) if enum_names.contains(n) => true,
360 TypeRef::Named(_) | TypeRef::Map(_, _) | TypeRef::Json | TypeRef::Bytes | TypeRef::Unit => false,
361 }
362}
363
364fn render_composer_json(
369 e2e_pkg_name: &str,
370 e2e_autoload_ns: &str,
371 pkg_name: &str,
372 pkg_path: &str,
373 pkg_version: &str,
374 dep_mode: crate::config::DependencyMode,
375) -> String {
376 let (require_section, autoload_section) = match dep_mode {
377 crate::config::DependencyMode::Registry => {
378 let require = format!(
379 r#" "require": {{
380 "{pkg_name}": "{pkg_version}"
381 }},
382 "require-dev": {{
383 "phpunit/phpunit": "{phpunit}",
384 "guzzlehttp/guzzle": "{guzzle}"
385 }},"#,
386 phpunit = tv::packagist::PHPUNIT,
387 guzzle = tv::packagist::GUZZLE,
388 );
389 (require, String::new())
390 }
391 crate::config::DependencyMode::Local => {
392 let require = format!(
393 r#" "require-dev": {{
394 "phpunit/phpunit": "{phpunit}",
395 "guzzlehttp/guzzle": "{guzzle}"
396 }},"#,
397 phpunit = tv::packagist::PHPUNIT,
398 guzzle = tv::packagist::GUZZLE,
399 );
400 let pkg_namespace = pkg_name
403 .split('/')
404 .nth(1)
405 .unwrap_or(pkg_name)
406 .split('-')
407 .map(heck::ToUpperCamelCase::to_upper_camel_case)
408 .collect::<Vec<_>>()
409 .join("\\");
410 let autoload = format!(
411 r#"
412 "autoload": {{
413 "psr-4": {{
414 "{}\\": "{}/src/"
415 }}
416 }},"#,
417 pkg_namespace.replace('\\', "\\\\"),
418 pkg_path
419 );
420 (require, autoload)
421 }
422 };
423
424 crate::template_env::render(
425 "php/composer.json.jinja",
426 minijinja::context! {
427 e2e_pkg_name => e2e_pkg_name,
428 e2e_autoload_ns => e2e_autoload_ns,
429 require_section => require_section,
430 autoload_section => autoload_section,
431 },
432 )
433}
434
435fn render_phpunit_xml() -> String {
436 crate::template_env::render("php/phpunit.xml.jinja", minijinja::context! {})
437}
438
439fn render_bootstrap(
440 pkg_path: &str,
441 has_http_fixtures: bool,
442 has_file_fixtures: bool,
443 test_documents_path: &str,
444) -> String {
445 let header = hash::header(CommentStyle::DoubleSlash);
446 crate::template_env::render(
447 "php/bootstrap.php.jinja",
448 minijinja::context! {
449 header => header,
450 pkg_path => pkg_path,
451 has_http_fixtures => has_http_fixtures,
452 has_file_fixtures => has_file_fixtures,
453 test_documents_path => test_documents_path,
454 },
455 )
456}
457
458fn render_run_tests_php(extension_name: &str, cargo_crate_name: Option<&str>) -> String {
459 let header = hash::header(CommentStyle::DoubleSlash);
460 let ext_lib_name = if let Some(crate_name) = cargo_crate_name {
461 format!("lib{}", crate_name.replace('-', "_"))
464 } else {
465 format!("lib{extension_name}_php")
466 };
467 format!(
468 r#"#!/usr/bin/env php
469<?php
470{header}
471declare(strict_types=1);
472
473// Determine platform-specific extension suffix.
474$extSuffix = match (PHP_OS_FAMILY) {{
475 'Darwin' => '.dylib',
476 default => '.so',
477}};
478$extPath = __DIR__ . '/../../target/release/{ext_lib_name}' . $extSuffix;
479
480// If the locally-built extension exists and we have not already restarted with it,
481// re-exec PHP with the freshly-built extension loaded explicitly via `-d extension=`.
482// The system php.ini is kept (no `-n`) so PHPUnit's required extensions — dom, json,
483// libxml, mbstring, tokenizer, xml, xmlwriter — remain available. `-n` drops every
484// shared module, which breaks PHPUnit on distributions that ship those as shared
485// extensions (e.g. Debian/Ubuntu); they only survive `-n` where compiled statically.
486if (file_exists($extPath) && !getenv('ALEF_PHP_LOCAL_EXT_LOADED')) {{
487 putenv('ALEF_PHP_LOCAL_EXT_LOADED=1');
488 $php = PHP_BINARY;
489 $phpunitPath = __DIR__ . '/vendor/bin/phpunit';
490
491 $cmd = array_merge(
492 [$php, '-d', 'extension=' . $extPath],
493 [$phpunitPath],
494 array_slice($GLOBALS['argv'], 1)
495 );
496
497 passthru(implode(' ', array_map('escapeshellarg', $cmd)), $exitCode);
498 exit($exitCode);
499}}
500
501// Extension is now loaded (via the restart above).
502// Invoke PHPUnit normally.
503$phpunitPath = __DIR__ . '/vendor/bin/phpunit';
504if (!file_exists($phpunitPath)) {{
505 echo "PHPUnit not found at $phpunitPath. Run 'composer install' first.\n";
506 exit(1);
507}}
508
509require $phpunitPath;
510"#
511 )
512}
513
514#[allow(clippy::too_many_arguments)]
515fn render_test_file(
516 category: &str,
517 fixtures: &[&Fixture],
518 e2e_config: &E2eConfig,
519 lang: &str,
520 namespace: &str,
521 class_name: &str,
522 test_class: &str,
523 type_defs: &[alef_core::ir::TypeDef],
524 php_enum_names: &HashSet<String>,
525 enum_fields: &HashMap<String, String>,
526 result_is_simple: bool,
527 php_client_factory: Option<&str>,
528 options_via: &str,
529 adapters: &[alef_core::config::extras::AdapterConfig],
530) -> String {
531 let header = hash::header(CommentStyle::DoubleSlash);
532
533 let needs_crawl_config_import = fixtures.iter().any(|f| {
535 let call =
536 e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.id, &f.resolved_category(), &f.tags, &f.input);
537 call.args.iter().filter(|a| a.arg_type == "handle").any(|a| {
538 let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
539 !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
540 })
541 });
542
543 let has_http_tests = fixtures.iter().any(|f| f.is_http_test());
545
546 let mut options_type_imports: Vec<String> = fixtures
548 .iter()
549 .flat_map(|f| {
550 let call = e2e_config.resolve_call_for_fixture(
551 f.call.as_deref(),
552 &f.id,
553 &f.resolved_category(),
554 &f.tags,
555 &f.input,
556 );
557 let php_override = call.overrides.get(lang);
558 let opt_type = php_override
559 .and_then(|o| o.options_type.as_deref())
560 .or_else(|| {
561 e2e_config
562 .call
563 .overrides
564 .get(lang)
565 .and_then(|o| o.options_type.as_deref())
566 })
567 .or(call.options_type.as_deref());
568 let element_types: Vec<String> = call
569 .args
570 .iter()
571 .filter_map(|a| a.element_type.as_ref().map(|t| t.to_string()))
572 .filter(|t| !is_php_reserved_type(t))
573 .collect();
574 opt_type.map(|t| t.to_string()).into_iter().chain(element_types)
575 })
576 .collect::<std::collections::HashSet<_>>()
577 .into_iter()
578 .collect();
579 options_type_imports.sort();
580
581 let mut imports_use: Vec<String> = Vec::new();
583 if needs_crawl_config_import {
584 imports_use.push(format!("use {namespace}\\CrawlConfig;"));
585 }
586 for type_name in &options_type_imports {
587 if type_name != class_name {
588 imports_use.push(format!("use {namespace}\\{type_name};"));
589 }
590 }
591
592 let mut fixtures_body = String::new();
594 for (i, fixture) in fixtures.iter().enumerate() {
595 if fixture.is_http_test() {
596 render_http_test_method(&mut fixtures_body, fixture, fixture.http.as_ref().unwrap());
597 } else {
598 render_test_method(
599 &mut fixtures_body,
600 fixture,
601 e2e_config,
602 lang,
603 namespace,
604 class_name,
605 type_defs,
606 php_enum_names,
607 enum_fields,
608 result_is_simple,
609 php_client_factory,
610 options_via,
611 adapters,
612 );
613 }
614 if i + 1 < fixtures.len() {
615 fixtures_body.push('\n');
616 }
617 }
618
619 crate::template_env::render(
620 "php/test_file.jinja",
621 minijinja::context! {
622 header => header,
623 namespace => namespace,
624 class_name => class_name,
625 test_class => test_class,
626 category => category,
627 imports_use => imports_use,
628 has_http_tests => has_http_tests,
629 fixtures_body => fixtures_body,
630 },
631 )
632}
633
634struct PhpTestClientRenderer;
642
643impl client::TestClientRenderer for PhpTestClientRenderer {
644 fn language_name(&self) -> &'static str {
645 "php"
646 }
647
648 fn sanitize_test_name(&self, id: &str) -> String {
650 sanitize_filename(id)
651 }
652
653 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
659 let escaped_reason = skip_reason.map(escape_php);
660 let rendered = crate::template_env::render(
661 "php/http_test_open.jinja",
662 minijinja::context! {
663 fn_name => fn_name,
664 description => description,
665 skip_reason => escaped_reason,
666 },
667 );
668 out.push_str(&rendered);
669 }
670
671 fn render_test_close(&self, out: &mut String) {
673 let rendered = crate::template_env::render("php/http_test_close.jinja", minijinja::context! {});
674 out.push_str(&rendered);
675 }
676
677 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
682 let method = ctx.method.to_uppercase();
683
684 let mut opts: Vec<String> = Vec::new();
686
687 if let Some(body) = ctx.body {
688 let php_body = json_to_php(body);
689 opts.push(format!("'json' => {php_body}"));
690 }
691
692 let mut header_pairs: Vec<String> = Vec::new();
694 if let Some(ct) = ctx.content_type {
695 if !ctx.headers.keys().any(|k| k.to_lowercase() == "content-type") {
697 header_pairs.push(format!("\"Content-Type\" => \"{}\"", escape_php(ct)));
698 }
699 }
700 for (k, v) in ctx.headers {
701 header_pairs.push(format!("\"{}\" => \"{}\"", escape_php(k), escape_php(v)));
702 }
703 if !header_pairs.is_empty() {
704 opts.push(format!("'headers' => [{}]", header_pairs.join(", ")));
705 }
706
707 if !ctx.cookies.is_empty() {
708 let cookie_str = ctx
709 .cookies
710 .iter()
711 .map(|(k, v)| format!("{}={}", k, v))
712 .collect::<Vec<_>>()
713 .join("; ");
714 opts.push(format!("'headers' => ['Cookie' => \"{}\"]", escape_php(&cookie_str)));
715 }
716
717 if !ctx.query_params.is_empty() {
718 let pairs: Vec<String> = ctx
719 .query_params
720 .iter()
721 .map(|(k, v)| {
722 let val_str = match v {
723 serde_json::Value::String(s) => s.clone(),
724 other => other.to_string(),
725 };
726 format!("\"{}\" => \"{}\"", escape_php(k), escape_php(&val_str))
727 })
728 .collect();
729 opts.push(format!("'query' => [{}]", pairs.join(", ")));
730 }
731
732 let path_lit = format!("\"{}\"", escape_php(ctx.path));
733
734 let rendered = crate::template_env::render(
735 "php/http_request.jinja",
736 minijinja::context! {
737 method => method,
738 path => path_lit,
739 opts => opts,
740 response_var => ctx.response_var,
741 },
742 );
743 out.push_str(&rendered);
744 }
745
746 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
748 let rendered = crate::template_env::render(
749 "php/http_assertions.jinja",
750 minijinja::context! {
751 response_var => "",
752 status_code => status,
753 headers => Vec::<std::collections::HashMap<&str, String>>::new(),
754 body_assertion => String::new(),
755 partial_body => Vec::<std::collections::HashMap<&str, String>>::new(),
756 validation_errors => Vec::<std::collections::HashMap<&str, String>>::new(),
757 },
758 );
759 out.push_str(&rendered);
760 }
761
762 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
767 let header_key = name.to_lowercase();
768 let header_key_lit = format!("\"{}\"", escape_php(&header_key));
769 let assertion_code = match expected {
770 "<<present>>" => {
771 format!("$this->assertTrue($response->hasHeader({header_key_lit}));")
772 }
773 "<<absent>>" => {
774 format!("$this->assertFalse($response->hasHeader({header_key_lit}));")
775 }
776 "<<uuid>>" => {
777 format!(
778 "$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}));"
779 )
780 }
781 literal => {
782 let val_lit = format!("\"{}\"", escape_php(literal));
783 format!("$this->assertEquals({val_lit}, $response->getHeaderLine({header_key_lit}));")
784 }
785 };
786
787 let mut headers = vec![std::collections::HashMap::new()];
788 headers[0].insert("assertion_code", assertion_code);
789
790 let rendered = crate::template_env::render(
791 "php/http_assertions.jinja",
792 minijinja::context! {
793 response_var => "",
794 status_code => 0u16,
795 headers => headers,
796 body_assertion => String::new(),
797 partial_body => Vec::<std::collections::HashMap<&str, String>>::new(),
798 validation_errors => Vec::<std::collections::HashMap<&str, String>>::new(),
799 },
800 );
801 out.push_str(&rendered);
802 }
803
804 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
810 let body_assertion = match expected {
811 serde_json::Value::String(s) if !s.is_empty() => {
812 let php_val = format!("\"{}\"", escape_php(s));
813 format!("$this->assertEquals({php_val}, (string) $response->getBody());")
814 }
815 _ => {
816 let php_val = json_to_php(expected);
817 format!(
818 "$body = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);\n $this->assertEquals({php_val}, $body);"
819 )
820 }
821 };
822
823 let rendered = crate::template_env::render(
824 "php/http_assertions.jinja",
825 minijinja::context! {
826 response_var => "",
827 status_code => 0u16,
828 headers => Vec::<std::collections::HashMap<&str, String>>::new(),
829 body_assertion => body_assertion,
830 partial_body => Vec::<std::collections::HashMap<&str, String>>::new(),
831 validation_errors => Vec::<std::collections::HashMap<&str, String>>::new(),
832 },
833 );
834 out.push_str(&rendered);
835 }
836
837 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
839 if let Some(obj) = expected.as_object() {
840 let mut partial_body: Vec<std::collections::HashMap<&str, String>> = Vec::new();
841 for (key, val) in obj {
842 let php_key = format!("\"{}\"", escape_php(key));
843 let php_val = json_to_php(val);
844 let assertion_code = format!("$this->assertEquals({php_val}, $body[{php_key}]);");
845 let mut entry = std::collections::HashMap::new();
846 entry.insert("assertion_code", assertion_code);
847 partial_body.push(entry);
848 }
849
850 let rendered = crate::template_env::render(
851 "php/http_assertions.jinja",
852 minijinja::context! {
853 response_var => "",
854 status_code => 0u16,
855 headers => Vec::<std::collections::HashMap<&str, String>>::new(),
856 body_assertion => String::new(),
857 partial_body => partial_body,
858 validation_errors => Vec::<std::collections::HashMap<&str, String>>::new(),
859 },
860 );
861 out.push_str(&rendered);
862 }
863 }
864
865 fn render_assert_validation_errors(
868 &self,
869 out: &mut String,
870 _response_var: &str,
871 errors: &[ValidationErrorExpectation],
872 ) {
873 let mut validation_errors: Vec<std::collections::HashMap<&str, String>> = Vec::new();
874 for err in errors {
875 let msg_lit = format!("\"{}\"", escape_php(&err.msg));
876 let assertion_code =
877 format!("$this->assertStringContainsString({msg_lit}, json_encode($body, JSON_UNESCAPED_SLASHES));");
878 let mut entry = std::collections::HashMap::new();
879 entry.insert("assertion_code", assertion_code);
880 validation_errors.push(entry);
881 }
882
883 let rendered = crate::template_env::render(
884 "php/http_assertions.jinja",
885 minijinja::context! {
886 response_var => "",
887 status_code => 0u16,
888 headers => Vec::<std::collections::HashMap<&str, String>>::new(),
889 body_assertion => String::new(),
890 partial_body => Vec::<std::collections::HashMap<&str, String>>::new(),
891 validation_errors => validation_errors,
892 },
893 );
894 out.push_str(&rendered);
895 }
896}
897
898fn render_http_test_method(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
903 if http.expected_response.status_code == 101 {
907 let method_name = sanitize_filename(&fixture.id);
908 let description = &fixture.description;
909 out.push_str(&crate::template_env::render(
910 "php/http_test_skip_101.jinja",
911 minijinja::context! {
912 method_name => method_name,
913 description => description,
914 },
915 ));
916 return;
917 }
918
919 client::http_call::render_http_test(out, &PhpTestClientRenderer, fixture);
920}
921
922#[allow(clippy::too_many_arguments)]
927fn render_test_method(
928 out: &mut String,
929 fixture: &Fixture,
930 e2e_config: &E2eConfig,
931 lang: &str,
932 namespace: &str,
933 class_name: &str,
934 type_defs: &[alef_core::ir::TypeDef],
935 php_enum_names: &HashSet<String>,
936 enum_fields: &HashMap<String, String>,
937 result_is_simple: bool,
938 php_client_factory: Option<&str>,
939 options_via: &str,
940 adapters: &[alef_core::config::extras::AdapterConfig],
941) {
942 let mut call_config = e2e_config.resolve_call_for_fixture(
944 fixture.call.as_deref(),
945 &fixture.id,
946 &fixture.resolved_category(),
947 &fixture.tags,
948 &fixture.input,
949 );
950 call_config = super::select_best_matching_call(call_config, e2e_config, fixture);
953 let per_call_getter_map = build_php_getter_map(
955 type_defs,
956 php_enum_names,
957 call_config,
958 e2e_config.effective_result_fields(call_config),
959 );
960 let call_field_resolver = FieldResolver::new_with_php_getters(
961 e2e_config.effective_fields(call_config),
962 e2e_config.effective_fields_optional(call_config),
963 e2e_config.effective_result_fields(call_config),
964 e2e_config.effective_fields_array(call_config),
965 &HashSet::new(),
966 &HashMap::new(),
967 per_call_getter_map,
968 );
969 let field_resolver = &call_field_resolver;
970 let call_overrides = call_config.overrides.get(lang);
971 let has_override = call_overrides.is_some_and(|o| o.function.is_some());
972 let result_is_simple = call_overrides.is_some_and(|o| o.result_is_simple) || result_is_simple;
976 let mut function_name = call_overrides
977 .and_then(|o| o.function.as_ref())
978 .cloned()
979 .unwrap_or_else(|| call_config.function.clone());
980 if !has_override {
985 function_name = function_name.to_lower_camel_case();
986 }
987 let result_var = &call_config.result_var;
988 let args = &call_config.args;
989
990 let method_name = sanitize_filename(&fixture.id);
991 let description = &fixture.description;
992 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
993
994 let call_options_type = call_overrides
998 .and_then(|o| o.options_type.as_deref())
999 .or(call_config.options_type.as_deref())
1000 .or_else(|| {
1001 e2e_config
1002 .call
1003 .overrides
1004 .get(lang)
1005 .and_then(|o| o.options_type.as_deref())
1006 });
1007
1008 let adapter_request_type: Option<String> = adapters
1009 .iter()
1010 .find(|a| a.name == call_config.function.as_str())
1011 .and_then(|a| a.request_type.as_deref())
1012 .map(|rt| rt.rsplit("::").next().unwrap_or(rt).to_string());
1013 let (mut setup_lines, args_str) = build_args_and_setup(
1014 &fixture.input,
1015 args,
1016 class_name,
1017 enum_fields,
1018 fixture,
1019 options_via,
1020 call_options_type,
1021 adapter_request_type.as_deref(),
1022 namespace,
1023 );
1024
1025 let skip_test = call_config.skip_languages.iter().any(|l| l == "php");
1027 if skip_test {
1028 let rendered = crate::template_env::render(
1029 "php/test_method.jinja",
1030 minijinja::context! {
1031 method_name => method_name,
1032 description => description,
1033 client_factory => String::new(),
1034 setup_lines => Vec::<String>::new(),
1035 expects_error => false,
1036 skip_test => true,
1037 has_usable_assertions => false,
1038 call_expr => String::new(),
1039 result_var => result_var,
1040 assertions_body => String::new(),
1041 },
1042 );
1043 out.push_str(&rendered);
1044 return;
1045 }
1046
1047 let mut options_already_created = !args_str.is_empty() && args_str == "$options";
1049 if let Some(visitor_spec) = &fixture.visitor {
1050 build_php_visitor(&mut setup_lines, visitor_spec);
1051 if !options_already_created {
1052 let options_type = call_options_type.unwrap_or("ConversionOptions");
1053 if options_via == "from_json" {
1054 setup_lines.push(format!("$options = \\{namespace}\\{options_type}::from_json('{{}}');"));
1057 setup_lines.push(format!(
1058 "$visitorHandle = \\{namespace}\\VisitorHandle::from_php_object($visitor);"
1059 ));
1060 setup_lines.push("$options = $options->withVisitor($visitorHandle);".to_string());
1063 } else {
1064 setup_lines.push(format!("$builder = \\{namespace}\\{options_type}::builder();"));
1066 setup_lines.push("$options = $builder->visitor($visitor)->build();".to_string());
1067 }
1068 options_already_created = true;
1069 }
1070 }
1071
1072 let final_args = if options_already_created {
1073 if args_str.is_empty() || args_str == "$options" {
1074 "$options".to_string()
1075 } else {
1076 format!("{args_str}, $options")
1077 }
1078 } else {
1079 args_str
1080 };
1081
1082 let call_expr = if php_client_factory.is_some() {
1083 format!("$client->{function_name}({final_args})")
1084 } else {
1085 format!("{class_name}::{function_name}({final_args})")
1086 };
1087
1088 let has_mock = fixture.mock_response.is_some() || fixture.http.is_some();
1089 let api_key_var = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref());
1090 let client_factory = if let Some(factory) = php_client_factory {
1091 let fixture_id = &fixture.id;
1092 if let Some(var) = api_key_var.filter(|_| has_mock) {
1093 format!(
1094 "$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);"
1095 )
1096 } else if has_mock {
1097 let base_url_expr = if fixture.has_host_root_route() {
1098 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1099 format!("(getenv('{env_key}') ?: getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}')")
1100 } else {
1101 format!("getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}'")
1102 };
1103 format!("$client = \\{namespace}\\{class_name}::{factory}('test-key', {base_url_expr});")
1104 } else if let Some(var) = api_key_var {
1105 format!(
1106 "$apiKey = getenv('{var}');\n if (!$apiKey) {{ $this->markTestSkipped('{var} not set'); return; }}\n $client = \\{namespace}\\{class_name}::{factory}($apiKey);"
1107 )
1108 } else {
1109 format!("$client = \\{namespace}\\{class_name}::{factory}('test-key');")
1110 }
1111 } else {
1112 String::new()
1113 };
1114
1115 let is_streaming = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
1117
1118 let has_usable_assertions = fixture.assertions.iter().any(|a| {
1121 if a.assertion_type == "error" || a.assertion_type == "not_error" {
1122 return false;
1123 }
1124 match &a.field {
1125 Some(f) if !f.is_empty() => {
1126 if is_streaming && crate::codegen::streaming_assertions::is_streaming_virtual_field(f) {
1127 return true;
1128 }
1129 let is_synthetic_field = matches!(
1131 f.as_str(),
1132 "chunks_have_content"
1133 | "chunks_have_embeddings"
1134 | "chunks_have_heading_context"
1135 | "first_chunk_starts_with_heading"
1136 | "embeddings"
1137 | "embedding_dimensions"
1138 | "embeddings_valid"
1139 | "embeddings_finite"
1140 | "embeddings_non_zero"
1141 | "embeddings_normalized"
1142 );
1143 is_synthetic_field || field_resolver.is_valid_for_result(f)
1144 }
1145 _ => true,
1146 }
1147 });
1148
1149 let collect_snippet = if is_streaming {
1151 crate::codegen::streaming_assertions::StreamingFieldResolver::collect_snippet("php", result_var, "chunks")
1152 .unwrap_or_default()
1153 } else {
1154 String::new()
1155 };
1156
1157 let mut fields_array_bindings: std::collections::BTreeMap<String, (String, String)> =
1167 std::collections::BTreeMap::new();
1168 for assertion in &fixture.assertions {
1169 if let Some(f) = &assertion.field {
1170 if !f.is_empty() && field_resolver.is_array(f) {
1171 if !fields_array_bindings.contains_key(f.as_str()) {
1173 let accessor = field_resolver.accessor(f, "php", &format!("${result_var}"));
1174 let var_name = f.to_lower_camel_case();
1175 fields_array_bindings.insert(f.clone(), (var_name, accessor));
1176 }
1177 }
1178 }
1179 }
1180
1181 let mut field_bindings = String::new();
1188 for (var_name, accessor) in fields_array_bindings.values() {
1189 field_bindings.push_str(&format!(" ${} = {};\n", var_name, accessor));
1190 }
1191
1192 let mut assertions_body = String::new();
1194 for assertion in &fixture.assertions {
1195 render_assertion(
1196 &mut assertions_body,
1197 assertion,
1198 result_var,
1199 field_resolver,
1200 result_is_simple,
1201 call_config.result_is_array,
1202 &fields_array_bindings,
1203 );
1204 }
1205
1206 if is_streaming && !expects_error && assertions_body.trim().is_empty() {
1212 assertions_body.push_str(" $this->assertTrue(is_array($chunks), 'expected drained chunks list');\n");
1213 }
1214
1215 let rendered = crate::template_env::render(
1216 "php/test_method.jinja",
1217 minijinja::context! {
1218 method_name => method_name,
1219 description => description,
1220 client_factory => client_factory,
1221 setup_lines => setup_lines,
1222 expects_error => expects_error,
1223 skip_test => fixture.assertions.is_empty(),
1224 has_usable_assertions => has_usable_assertions || is_streaming,
1225 call_expr => call_expr,
1226 result_var => result_var,
1227 collect_snippet => collect_snippet,
1228 field_bindings => field_bindings,
1229 assertions_body => assertions_body,
1230 },
1231 );
1232 out.push_str(&rendered);
1233}
1234
1235fn emit_php_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
1248 if let Some(items) = arr.as_array() {
1249 let item_strs: Vec<String> = items
1250 .iter()
1251 .filter_map(|item| {
1252 if let Some(obj) = item.as_object() {
1253 match elem_type {
1254 "BatchBytesItem" => {
1255 let content = obj.get("content").and_then(|v| v.as_array());
1256 let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1257 let content_code = if let Some(arr) = content {
1258 let bytes: Vec<String> = arr
1259 .iter()
1260 .filter_map(|v| v.as_u64())
1261 .map(|n| format!("\\x{:02x}", n))
1262 .collect();
1263 format!("\"{}\"", bytes.join(""))
1264 } else {
1265 "\"\"".to_string()
1266 };
1267 Some(format!(
1268 "new {}(content: {}, mimeType: \"{}\")",
1269 elem_type, content_code, mime_type
1270 ))
1271 }
1272 "BatchFileItem" => {
1273 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1274 Some(format!("new {}(path: \"{}\")", elem_type, path))
1275 }
1276 _ => {
1277 Some(json_to_php(&serde_json::Value::Object(obj.clone())))
1280 }
1281 }
1282 } else {
1283 None
1284 }
1285 })
1286 .collect();
1287 format!("[{}]", item_strs.join(", "))
1288 } else {
1289 "[]".to_string()
1290 }
1291}
1292
1293#[allow(clippy::too_many_arguments)]
1294fn build_args_and_setup(
1295 input: &serde_json::Value,
1296 args: &[crate::config::ArgMapping],
1297 class_name: &str,
1298 _enum_fields: &HashMap<String, String>,
1299 fixture: &crate::fixture::Fixture,
1300 options_via: &str,
1301 options_type: Option<&str>,
1302 adapter_request_type: Option<&str>,
1303 namespace: &str,
1304) -> (Vec<String>, String) {
1305 let fixture_id = &fixture.id;
1306 if args.is_empty() {
1307 let is_empty_input = match input {
1310 serde_json::Value::Null => true,
1311 serde_json::Value::Object(m) => m.is_empty(),
1312 _ => false,
1313 };
1314 if is_empty_input {
1315 return (Vec::new(), String::new());
1316 }
1317 return (Vec::new(), json_to_php(input));
1318 }
1319
1320 let mut setup_lines: Vec<String> = Vec::new();
1321 let mut parts: Vec<String> = Vec::new();
1322
1323 let arg_has_emission = |arg: &crate::config::ArgMapping| -> bool {
1328 let val = if arg.field == "input" {
1329 Some(input)
1330 } else {
1331 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1332 input.get(field)
1333 };
1334 match val {
1335 None | Some(serde_json::Value::Null) => {
1336 if arg.arg_type == "json_object" && arg.name == "config" {
1342 return true;
1343 }
1344 !arg.optional
1345 }
1346 Some(_) => true,
1347 }
1348 };
1349 let any_later_has_emission = |from_idx: usize| -> bool { args[from_idx..].iter().any(arg_has_emission) };
1350
1351 for (idx, arg) in args.iter().enumerate() {
1352 if arg.arg_type == "mock_url" {
1353 if fixture.has_host_root_route() {
1354 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1355 setup_lines.push(format!(
1356 "${} = getenv('{env_key}') ?: getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}';",
1357 arg.name,
1358 ));
1359 } else {
1360 setup_lines.push(format!(
1361 "${} = getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}';",
1362 arg.name,
1363 ));
1364 }
1365 if let Some(req_type) = adapter_request_type {
1366 let req_var = format!("${}_req", arg.name);
1367 setup_lines.push(format!("{req_var} = new {req_type}(${});", arg.name));
1368 parts.push(req_var);
1369 } else {
1370 parts.push(format!("${}", arg.name));
1371 }
1372 continue;
1373 }
1374
1375 if arg.arg_type == "mock_url_list" {
1376 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1381 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1382 let val = if let Some(v) = input.get(field).filter(|v| !v.is_null()) {
1384 v.clone()
1385 } else {
1386 super::resolve_urls_field(input, &arg.field).clone()
1387 };
1388 let paths: Vec<String> = if let Some(arr) = val.as_array() {
1389 arr.iter()
1390 .filter_map(|v| v.as_str().map(|s| format!("\"{}\"", escape_php(s))))
1391 .collect()
1392 } else {
1393 Vec::new()
1394 };
1395 let paths_literal = paths.join(", ");
1396 let name = &arg.name;
1397 setup_lines.push(format!(
1398 "${name}_base = getenv('{env_key}') ?: getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}';"
1399 ));
1400 setup_lines.push(format!(
1401 "${name} = array_map(fn($p) => str_starts_with($p, 'http') ? $p : ${name}_base . $p, [{paths_literal}]);"
1402 ));
1403 parts.push(format!("${name}"));
1404 continue;
1405 }
1406
1407 if arg.arg_type == "handle" {
1408 let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
1410 let config_value = if arg.field == "input" {
1411 input
1412 } else {
1413 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1414 input.get(field).unwrap_or(&serde_json::Value::Null)
1415 };
1416 if config_value.is_null()
1417 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1418 {
1419 setup_lines.push(format!("${} = {class_name}::{constructor_name}(null);", arg.name,));
1420 } else {
1421 let name = &arg.name;
1422 let filtered_config = filter_empty_enum_strings(config_value);
1427 setup_lines.push(format!(
1428 "${name}_config = CrawlConfig::from_json(json_encode({}));",
1429 json_to_php(&filtered_config)
1430 ));
1431 setup_lines.push(format!(
1432 "${} = {class_name}::{constructor_name}(${name}_config);",
1433 arg.name,
1434 ));
1435 }
1436 parts.push(format!("${}", arg.name));
1437 continue;
1438 }
1439
1440 let val = if arg.field == "input" {
1441 Some(input)
1442 } else {
1443 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1444 input.get(field)
1445 };
1446
1447 if arg.arg_type == "bytes" {
1451 match val {
1452 None | Some(serde_json::Value::Null) => {
1453 if arg.optional {
1454 parts.push("null".to_string());
1455 } else {
1456 parts.push("\"\"".to_string());
1457 }
1458 }
1459 Some(serde_json::Value::String(s)) => {
1460 let var_name = format!("{}Bytes", arg.name);
1461 setup_lines.push(format!(
1462 "${var_name} = file_get_contents(\"{path}\");\n if (${var_name} === false) {{ $this->fail(\"failed to read fixture: {path}\"); }}",
1463 path = s.replace('"', "\\\"")
1464 ));
1465 parts.push(format!("${var_name}"));
1466 }
1467 Some(serde_json::Value::Array(arr)) => {
1468 let bytes: String = arr
1469 .iter()
1470 .filter_map(|v| v.as_u64())
1471 .map(|n| format!("\\x{:02x}", n))
1472 .collect();
1473 parts.push(format!("\"{bytes}\""));
1474 }
1475 Some(other) => {
1476 parts.push(json_to_php(other));
1477 }
1478 }
1479 continue;
1480 }
1481
1482 match val {
1483 None | Some(serde_json::Value::Null) if arg.arg_type == "json_object" && arg.name == "config" => {
1484 let type_name = if let Some(opt_type) = options_type {
1491 opt_type.to_string()
1492 } else if arg.name == "config" {
1493 "ExtractionConfig".to_string()
1494 } else {
1495 format!("{}Config", arg.name.to_upper_camel_case())
1496 };
1497 parts.push(format!("\\{namespace}\\{type_name}::from_json('{{}}')"));
1499 continue;
1500 }
1501 None | Some(serde_json::Value::Null) if arg.optional => {
1502 if any_later_has_emission(idx + 1) {
1507 parts.push("null".to_string());
1508 }
1509 continue;
1510 }
1511 None | Some(serde_json::Value::Null) => {
1512 let default_val = match arg.arg_type.as_str() {
1514 "string" => "\"\"".to_string(),
1515 "int" | "integer" => "0".to_string(),
1516 "float" | "number" => "0.0".to_string(),
1517 "bool" | "boolean" => "false".to_string(),
1518 "json_object" if options_via == "json" => "null".to_string(),
1519 _ => "null".to_string(),
1520 };
1521 parts.push(default_val);
1522 }
1523 Some(v) => {
1524 if arg.arg_type == "json_object" && !v.is_null() {
1525 if let Some(elem_type) = &arg.element_type {
1527 if v.is_array() {
1528 if elem_type == "BatchBytesItem" || elem_type == "BatchFileItem" {
1529 parts.push(emit_php_batch_item_array(v, elem_type));
1530 continue;
1531 }
1532 if is_php_reserved_type(elem_type) {
1536 parts.push(json_to_php(v));
1537 continue;
1538 }
1539 if let Some(arr) = v.as_array() {
1541 let items: Vec<String> = arr
1542 .iter()
1543 .filter_map(|item| {
1544 item.as_object()
1545 .map(|obj| json_to_php(&serde_json::Value::Object(obj.clone())))
1546 })
1547 .collect();
1548 parts.push(format!("[{}]", items.join(", ")));
1549 continue;
1550 }
1551 }
1552 }
1553 match options_via {
1554 "json" => {
1555 let filtered_v = filter_empty_enum_strings(v);
1558
1559 if let serde_json::Value::Object(obj) = &filtered_v {
1561 if obj.is_empty() {
1562 parts.push("null".to_string());
1563 continue;
1564 }
1565 }
1566
1567 parts.push(format!("json_encode({})", json_to_php(&filtered_v)));
1568 continue;
1569 }
1570 _ => {
1571 if let Some(type_name) = options_type {
1572 let filtered_v = filter_empty_enum_strings(v);
1577
1578 if let serde_json::Value::Object(obj) = &filtered_v {
1581 if obj.is_empty() {
1582 let arg_var = format!("${}", arg.name);
1583 setup_lines.push(format!(
1584 "{arg_var} = \\{namespace}\\{type_name}::from_json('{{}}');"
1585 ));
1586 parts.push(arg_var);
1587 continue;
1588 }
1589 }
1590
1591 let arg_var = format!("${}", arg.name);
1592 setup_lines.push(format!(
1596 "{arg_var} = \\{namespace}\\{type_name}::from_json(json_encode({}));",
1597 json_to_php(&filtered_v)
1598 ));
1599 parts.push(arg_var);
1600 continue;
1601 }
1602 if let Some(obj) = v.as_object() {
1606 setup_lines.push("$builder = $this->createDefaultOptionsBuilder();".to_string());
1607 for (k, vv) in obj {
1608 let snake_key = k.to_snake_case();
1609 if snake_key == "preprocessing" {
1610 if let Some(prep_obj) = vv.as_object() {
1611 let enabled =
1612 prep_obj.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
1613 let preset =
1614 prep_obj.get("preset").and_then(|v| v.as_str()).unwrap_or("Minimal");
1615 let remove_navigation = prep_obj
1616 .get("remove_navigation")
1617 .and_then(|v| v.as_bool())
1618 .unwrap_or(true);
1619 let remove_forms =
1620 prep_obj.get("remove_forms").and_then(|v| v.as_bool()).unwrap_or(true);
1621 setup_lines.push(format!(
1622 "$preprocessing = $this->createPreprocessingOptions({}, {}, {}, {});",
1623 if enabled { "true" } else { "false" },
1624 json_to_php(&serde_json::Value::String(preset.to_string())),
1625 if remove_navigation { "true" } else { "false" },
1626 if remove_forms { "true" } else { "false" }
1627 ));
1628 setup_lines.push(
1629 "$builder = $builder->preprocessing($preprocessing);".to_string(),
1630 );
1631 }
1632 }
1633 }
1634 setup_lines.push("$options = $builder->build();".to_string());
1635 parts.push("$options".to_string());
1636 continue;
1637 }
1638 }
1639 }
1640 }
1641 parts.push(json_to_php(v));
1642 }
1643 }
1644 }
1645
1646 (setup_lines, parts.join(", "))
1647}
1648
1649fn render_assertion(
1650 out: &mut String,
1651 assertion: &Assertion,
1652 result_var: &str,
1653 field_resolver: &FieldResolver,
1654 result_is_simple: bool,
1655 result_is_array: bool,
1656 fields_array_bindings: &std::collections::BTreeMap<String, (String, String)>,
1657) {
1658 if let Some(f) = &assertion.field {
1661 match f.as_str() {
1662 "chunks_have_content" => {
1663 let pred = format!(
1664 "array_reduce(${result_var}->chunks ?? [], fn($carry, $c) => $carry && !empty($c->content), true)"
1665 );
1666 out.push_str(&crate::template_env::render(
1667 "php/synthetic_assertion.jinja",
1668 minijinja::context! {
1669 assertion_kind => "chunks_content",
1670 assertion_type => assertion.assertion_type.as_str(),
1671 pred => pred,
1672 field_name => f,
1673 },
1674 ));
1675 return;
1676 }
1677 "chunks_have_embeddings" => {
1678 let pred = format!(
1679 "array_reduce(${result_var}->chunks ?? [], fn($carry, $c) => $carry && !empty($c->embedding), true)"
1680 );
1681 out.push_str(&crate::template_env::render(
1682 "php/synthetic_assertion.jinja",
1683 minijinja::context! {
1684 assertion_kind => "chunks_embeddings",
1685 assertion_type => assertion.assertion_type.as_str(),
1686 pred => pred,
1687 field_name => f,
1688 },
1689 ));
1690 return;
1691 }
1692 "embeddings" => {
1696 let php_val = assertion.value.as_ref().map(json_to_php).unwrap_or_default();
1697 out.push_str(&crate::template_env::render(
1698 "php/synthetic_assertion.jinja",
1699 minijinja::context! {
1700 assertion_kind => "embeddings",
1701 assertion_type => assertion.assertion_type.as_str(),
1702 php_val => php_val,
1703 result_var => result_var,
1704 },
1705 ));
1706 return;
1707 }
1708 "embedding_dimensions" => {
1709 let expr = format!("(empty(${result_var}) ? 0 : count(${result_var}[0]))");
1710 let php_val = assertion.value.as_ref().map(json_to_php).unwrap_or_default();
1711 out.push_str(&crate::template_env::render(
1712 "php/synthetic_assertion.jinja",
1713 minijinja::context! {
1714 assertion_kind => "embedding_dimensions",
1715 assertion_type => assertion.assertion_type.as_str(),
1716 expr => expr,
1717 php_val => php_val,
1718 },
1719 ));
1720 return;
1721 }
1722 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1723 let pred = match f.as_str() {
1724 "embeddings_valid" => {
1725 format!("array_reduce(${result_var}, fn($carry, $e) => $carry && count($e) > 0, true)")
1726 }
1727 "embeddings_finite" => {
1728 format!(
1729 "array_reduce(${result_var}, fn($carry, $e) => $carry && array_reduce($e, fn($c, $v) => $c && is_finite($v), true), true)"
1730 )
1731 }
1732 "embeddings_non_zero" => {
1733 format!(
1734 "array_reduce(${result_var}, fn($carry, $e) => $carry && count(array_filter($e, fn($v) => $v !== 0.0)) > 0, true)"
1735 )
1736 }
1737 "embeddings_normalized" => {
1738 format!(
1739 "array_reduce(${result_var}, fn($carry, $e) => $carry && abs(array_sum(array_map(fn($v) => $v * $v, $e)) - 1.0) < 1e-3, true)"
1740 )
1741 }
1742 _ => unreachable!(),
1743 };
1744 let assertion_kind = format!("embeddings_{}", f.strip_prefix("embeddings_").unwrap_or(f));
1745 out.push_str(&crate::template_env::render(
1746 "php/synthetic_assertion.jinja",
1747 minijinja::context! {
1748 assertion_kind => assertion_kind,
1749 assertion_type => assertion.assertion_type.as_str(),
1750 pred => pred,
1751 field_name => f,
1752 },
1753 ));
1754 return;
1755 }
1756 "keywords" | "keywords_count" => {
1759 out.push_str(&crate::template_env::render(
1760 "php/synthetic_assertion.jinja",
1761 minijinja::context! {
1762 assertion_kind => "keywords",
1763 field_name => f,
1764 },
1765 ));
1766 return;
1767 }
1768 _ => {}
1769 }
1770 }
1771
1772 if let Some(f) = &assertion.field {
1775 if !f.is_empty() && crate::codegen::streaming_assertions::is_streaming_virtual_field(f) {
1776 if let Some(expr) =
1777 crate::codegen::streaming_assertions::StreamingFieldResolver::accessor(f, "php", "chunks")
1778 {
1779 let line = match assertion.assertion_type.as_str() {
1780 "count_min" => {
1781 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1782 format!(
1783 " $this->assertGreaterThanOrEqual({n}, count({expr}), 'expected >= {n} chunks');\n"
1784 )
1785 } else {
1786 String::new()
1787 }
1788 }
1789 "count_equals" => {
1790 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1791 format!(" $this->assertCount({n}, {expr});\n")
1792 } else {
1793 String::new()
1794 }
1795 }
1796 "equals" => {
1797 if let Some(serde_json::Value::String(s)) = &assertion.value {
1798 let escaped = s.replace('\\', "\\\\").replace('\'', "\\'");
1799 format!(" $this->assertEquals('{escaped}', {expr});\n")
1800 } else if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1801 format!(" $this->assertEquals({n}, {expr});\n")
1802 } else {
1803 String::new()
1804 }
1805 }
1806 "not_empty" => format!(" $this->assertNotEmpty({expr});\n"),
1807 "is_empty" => format!(" $this->assertEmpty({expr});\n"),
1808 "is_true" => format!(" $this->assertTrue({expr});\n"),
1809 "is_false" => format!(" $this->assertFalse({expr});\n"),
1810 "greater_than" => {
1811 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1812 format!(" $this->assertGreaterThan({n}, {expr});\n")
1813 } else {
1814 String::new()
1815 }
1816 }
1817 "greater_than_or_equal" => {
1818 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1819 format!(" $this->assertGreaterThanOrEqual({n}, {expr});\n")
1820 } else {
1821 String::new()
1822 }
1823 }
1824 "contains" => {
1825 if let Some(serde_json::Value::String(s)) = &assertion.value {
1826 let escaped = s.replace('\\', "\\\\").replace('\'', "\\'");
1827 format!(" $this->assertStringContainsString('{escaped}', {expr});\n")
1828 } else {
1829 String::new()
1830 }
1831 }
1832 _ => format!(
1833 " // streaming field '{f}': assertion type '{}' not rendered\n",
1834 assertion.assertion_type
1835 ),
1836 };
1837 if !line.is_empty() {
1838 out.push_str(&line);
1839 }
1840 }
1841 return;
1842 }
1843 }
1844
1845 if let Some(f) = &assertion.field {
1847 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1848 out.push_str(&crate::template_env::render(
1849 "php/synthetic_assertion.jinja",
1850 minijinja::context! {
1851 assertion_kind => "skipped",
1852 field_name => f,
1853 },
1854 ));
1855 return;
1856 }
1857 }
1858
1859 if result_is_simple {
1862 if let Some(f) = &assertion.field {
1863 let f_lower = f.to_lowercase();
1864 if !f.is_empty()
1865 && f_lower != "content"
1866 && (f_lower.starts_with("metadata")
1867 || f_lower.starts_with("document")
1868 || f_lower.starts_with("structure"))
1869 {
1870 out.push_str(&crate::template_env::render(
1871 "php/synthetic_assertion.jinja",
1872 minijinja::context! {
1873 assertion_kind => "result_is_simple",
1874 field_name => f,
1875 },
1876 ));
1877 return;
1878 }
1879 }
1880 }
1881
1882 let field_expr = match &assertion.field {
1883 _ if result_is_simple => format!("${result_var}"),
1887 Some(f) if !f.is_empty() => {
1888 if let Some((var_name, _)) = fields_array_bindings.get(f) {
1890 format!("${}", var_name)
1891 } else {
1892 let accessor = field_resolver.accessor(f, "php", &format!("${result_var}"));
1893 if field_resolver.is_optional(f) {
1895 format!("({accessor} ?? null)")
1896 } else {
1897 accessor
1898 }
1899 }
1900 }
1901 _ => format!("${result_var}"),
1902 };
1903
1904 let field_is_array = assertion.field.as_ref().map_or(result_is_array, |f| {
1907 if f.is_empty() {
1908 result_is_array
1909 } else {
1910 field_resolver.is_array(f)
1911 }
1912 });
1913
1914 let trimmed_field_expr_for = |expected: &serde_json::Value| -> String {
1918 if expected.is_string() {
1919 format!("trim({})", field_expr)
1920 } else {
1921 field_expr.clone()
1922 }
1923 };
1924
1925 let assertion_type = assertion.assertion_type.as_str();
1927 let has_php_val = assertion.value.is_some();
1928 let php_val = match assertion.value.as_ref() {
1932 Some(v) => json_to_php(v),
1933 None if assertion_type == "equals" => "null".to_string(),
1934 None => String::new(),
1935 };
1936 let trimmed_field_expr = trimmed_field_expr_for(assertion.value.as_ref().unwrap_or(&serde_json::Value::Null));
1937 let is_string_val = assertion.value.as_ref().is_some_and(|v| v.is_string());
1938 let values_php: Vec<String> = assertion
1942 .values
1943 .as_ref()
1944 .map(|vals| vals.iter().map(json_to_php).collect::<Vec<_>>())
1945 .or_else(|| assertion.value.as_ref().map(|v| vec![json_to_php(v)]))
1946 .unwrap_or_default();
1947 let contains_any_checks: Vec<String> = assertion
1948 .values
1949 .as_ref()
1950 .map_or(Vec::new(), |vals| vals.iter().map(json_to_php).collect());
1951 let n = assertion.value.as_ref().and_then(|v| v.as_u64()).unwrap_or(0);
1952
1953 let call_expr = if let Some(method_name) = &assertion.method {
1955 build_php_method_call(result_var, method_name, assertion.args.as_ref())
1956 } else {
1957 String::new()
1958 };
1959 let check = assertion.check.as_deref().unwrap_or("is_true");
1960 let has_php_check_val = matches!(assertion.assertion_type.as_str(), "method_result") && assertion.value.is_some();
1961 let php_check_val = if matches!(assertion.assertion_type.as_str(), "method_result") {
1962 assertion.value.as_ref().map(json_to_php).unwrap_or_default()
1963 } else {
1964 String::new()
1965 };
1966 let check_n = assertion.value.as_ref().and_then(|v| v.as_u64()).unwrap_or(0);
1967 let is_bool_val = assertion.value.as_ref().is_some_and(|v| v.is_boolean());
1968 let bool_is_true = assertion.value.as_ref().and_then(|v| v.as_bool()).unwrap_or(false);
1969
1970 if matches!(assertion_type, "not_error" | "error") {
1972 if assertion_type == "not_error" {
1973 }
1975 return;
1977 }
1978
1979 let rendered = crate::template_env::render(
1980 "php/assertion.jinja",
1981 minijinja::context! {
1982 assertion_type => assertion_type,
1983 field_expr => field_expr,
1984 php_val => php_val,
1985 has_php_val => has_php_val,
1986 trimmed_field_expr => trimmed_field_expr,
1987 is_string_val => is_string_val,
1988 field_is_array => field_is_array,
1989 values_php => values_php,
1990 contains_any_checks => contains_any_checks,
1991 n => n,
1992 call_expr => call_expr,
1993 check => check,
1994 php_check_val => php_check_val,
1995 has_php_check_val => has_php_check_val,
1996 check_n => check_n,
1997 is_bool_val => is_bool_val,
1998 bool_is_true => bool_is_true,
1999 },
2000 );
2001 let _ = write!(out, " {}", rendered);
2002}
2003
2004fn build_php_method_call(result_var: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
2011 let extra_args = if let Some(args_val) = args {
2012 args_val
2013 .as_object()
2014 .map(|obj| {
2015 obj.values()
2016 .map(|v| match v {
2017 serde_json::Value::String(s) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")),
2018 serde_json::Value::Bool(true) => "true".to_string(),
2019 serde_json::Value::Bool(false) => "false".to_string(),
2020 serde_json::Value::Number(n) => n.to_string(),
2021 serde_json::Value::Null => "null".to_string(),
2022 other => format!("\"{}\"", other.to_string().replace('\\', "\\\\").replace('"', "\\\"")),
2023 })
2024 .collect::<Vec<_>>()
2025 .join(", ")
2026 })
2027 .unwrap_or_default()
2028 } else {
2029 String::new()
2030 };
2031
2032 if extra_args.is_empty() {
2033 format!("${result_var}->{method_name}()")
2034 } else {
2035 format!("${result_var}->{method_name}({extra_args})")
2036 }
2037}
2038
2039fn filter_empty_enum_strings(value: &serde_json::Value) -> serde_json::Value {
2043 match value {
2044 serde_json::Value::Object(map) => {
2045 let filtered: serde_json::Map<String, serde_json::Value> = map
2046 .iter()
2047 .filter_map(|(k, v)| {
2048 if let serde_json::Value::String(s) = v {
2050 if s.is_empty() {
2051 return None;
2052 }
2053 }
2054 Some((k.clone(), filter_empty_enum_strings(v)))
2056 })
2057 .collect();
2058 serde_json::Value::Object(filtered)
2059 }
2060 serde_json::Value::Array(arr) => {
2061 let filtered: Vec<serde_json::Value> = arr.iter().map(filter_empty_enum_strings).collect();
2062 serde_json::Value::Array(filtered)
2063 }
2064 other => other.clone(),
2065 }
2066}
2067
2068fn json_to_php(value: &serde_json::Value) -> String {
2070 match value {
2071 serde_json::Value::String(s) => format!("\"{}\"", escape_php(s)),
2072 serde_json::Value::Bool(true) => "true".to_string(),
2073 serde_json::Value::Bool(false) => "false".to_string(),
2074 serde_json::Value::Number(n) => n.to_string(),
2075 serde_json::Value::Null => "null".to_string(),
2076 serde_json::Value::Array(arr) => {
2077 let items: Vec<String> = arr.iter().map(json_to_php).collect();
2078 format!("[{}]", items.join(", "))
2079 }
2080 serde_json::Value::Object(map) => {
2081 let items: Vec<String> = map
2082 .iter()
2083 .map(|(k, v)| format!("\"{}\" => {}", escape_php(k), json_to_php(v)))
2084 .collect();
2085 format!("[{}]", items.join(", "))
2086 }
2087 }
2088}
2089
2090fn build_php_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) {
2096 setup_lines.push("$visitor = new class {".to_string());
2097 for (method_name, action) in &visitor_spec.callbacks {
2098 emit_php_visitor_method(setup_lines, method_name, action);
2099 }
2100 setup_lines.push("};".to_string());
2101}
2102
2103fn emit_php_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
2105 let params = match method_name {
2106 "visit_link" => "$ctx, $href, $text, $title",
2107 "visit_image" => "$ctx, $src, $alt, $title",
2108 "visit_heading" => "$ctx, $level, $text, $id",
2109 "visit_code_block" => "$ctx, $lang, $code",
2110 "visit_code_inline"
2111 | "visit_strong"
2112 | "visit_emphasis"
2113 | "visit_strikethrough"
2114 | "visit_underline"
2115 | "visit_subscript"
2116 | "visit_superscript"
2117 | "visit_mark"
2118 | "visit_button"
2119 | "visit_summary"
2120 | "visit_figcaption"
2121 | "visit_definition_term"
2122 | "visit_definition_description" => "$ctx, $text",
2123 "visit_text" => "$ctx, $text",
2124 "visit_list_item" => "$ctx, $ordered, $marker, $text",
2125 "visit_blockquote" => "$ctx, $content, $depth",
2126 "visit_table_row" => "$ctx, $cells, $isHeader",
2127 "visit_custom_element" => "$ctx, $tagName, $html",
2128 "visit_form" => "$ctx, $actionUrl, $method",
2129 "visit_input" => "$ctx, $input_type, $name, $value",
2130 "visit_audio" | "visit_video" | "visit_iframe" => "$ctx, $src",
2131 "visit_details" => "$ctx, $isOpen",
2132 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "$ctx, $output",
2133 "visit_list_start" => "$ctx, $ordered",
2134 "visit_list_end" => "$ctx, $ordered, $output",
2135 _ => "$ctx",
2136 };
2137
2138 let (action_type, action_value, return_form) = match action {
2139 CallbackAction::Skip => ("skip", String::new(), "dict"),
2140 CallbackAction::Continue => ("continue", String::new(), "dict"),
2141 CallbackAction::PreserveHtml => ("preserve_html", String::new(), "dict"),
2142 CallbackAction::Custom { output } => ("custom", escape_php(output), "dict"),
2143 CallbackAction::CustomTemplate { template, return_form } => {
2144 let form = match return_form {
2145 TemplateReturnForm::Dict => "dict",
2146 TemplateReturnForm::BareString => "bare_string",
2147 };
2148 ("custom_template", escape_php(template), form)
2149 }
2150 };
2151
2152 let rendered = crate::template_env::render(
2153 "php/visitor_method.jinja",
2154 minijinja::context! {
2155 method_name => method_name,
2156 params => params,
2157 action_type => action_type,
2158 action_value => action_value,
2159 return_form => return_form,
2160 },
2161 );
2162 for line in rendered.lines() {
2163 setup_lines.push(line.to_string());
2164 }
2165}
2166
2167fn is_php_reserved_type(name: &str) -> bool {
2169 matches!(
2170 name.to_ascii_lowercase().as_str(),
2171 "string"
2172 | "int"
2173 | "integer"
2174 | "float"
2175 | "double"
2176 | "bool"
2177 | "boolean"
2178 | "array"
2179 | "object"
2180 | "null"
2181 | "void"
2182 | "callable"
2183 | "iterable"
2184 | "never"
2185 | "self"
2186 | "parent"
2187 | "static"
2188 | "true"
2189 | "false"
2190 | "mixed"
2191 )
2192}