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 );
1023
1024 let skip_test = call_config.skip_languages.iter().any(|l| l == "php");
1026 if skip_test {
1027 let rendered = crate::template_env::render(
1028 "php/test_method.jinja",
1029 minijinja::context! {
1030 method_name => method_name,
1031 description => description,
1032 client_factory => String::new(),
1033 setup_lines => Vec::<String>::new(),
1034 expects_error => false,
1035 skip_test => true,
1036 has_usable_assertions => false,
1037 call_expr => String::new(),
1038 result_var => result_var,
1039 assertions_body => String::new(),
1040 },
1041 );
1042 out.push_str(&rendered);
1043 return;
1044 }
1045
1046 let mut options_already_created = !args_str.is_empty() && args_str == "$options";
1048 if let Some(visitor_spec) = &fixture.visitor {
1049 build_php_visitor(&mut setup_lines, visitor_spec);
1050 if !options_already_created {
1051 let options_type = call_options_type.unwrap_or("ConversionOptions");
1052 if options_via == "from_json" {
1053 setup_lines.push(format!("$options = \\{namespace}\\{options_type}::from_json('{{}}');"));
1056 setup_lines.push(format!(
1057 "$visitorHandle = \\{namespace}\\VisitorHandle::from_php_object($visitor);"
1058 ));
1059 setup_lines.push("$options = $options->withVisitor($visitorHandle);".to_string());
1062 } else {
1063 setup_lines.push(format!("$builder = \\{namespace}\\{options_type}::builder();"));
1065 setup_lines.push("$options = $builder->visitor($visitor)->build();".to_string());
1066 }
1067 options_already_created = true;
1068 }
1069 }
1070
1071 let final_args = if options_already_created {
1072 if args_str.is_empty() || args_str == "$options" {
1073 "$options".to_string()
1074 } else {
1075 format!("{args_str}, $options")
1076 }
1077 } else {
1078 args_str
1079 };
1080
1081 let call_expr = if php_client_factory.is_some() {
1082 format!("$client->{function_name}({final_args})")
1083 } else {
1084 format!("{class_name}::{function_name}({final_args})")
1085 };
1086
1087 let has_mock = fixture.mock_response.is_some() || fixture.http.is_some();
1088 let api_key_var = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref());
1089 let client_factory = if let Some(factory) = php_client_factory {
1090 let fixture_id = &fixture.id;
1091 if let Some(var) = api_key_var.filter(|_| has_mock) {
1092 format!(
1093 "$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);"
1094 )
1095 } else if has_mock {
1096 let base_url_expr = if fixture.has_host_root_route() {
1097 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1098 format!("(getenv('{env_key}') ?: getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}')")
1099 } else {
1100 format!("getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}'")
1101 };
1102 format!("$client = \\{namespace}\\{class_name}::{factory}('test-key', {base_url_expr});")
1103 } else if let Some(var) = api_key_var {
1104 format!(
1105 "$apiKey = getenv('{var}');\n if (!$apiKey) {{ $this->markTestSkipped('{var} not set'); return; }}\n $client = \\{namespace}\\{class_name}::{factory}($apiKey);"
1106 )
1107 } else {
1108 format!("$client = \\{namespace}\\{class_name}::{factory}('test-key');")
1109 }
1110 } else {
1111 String::new()
1112 };
1113
1114 let is_streaming = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
1116
1117 let has_usable_assertions = fixture.assertions.iter().any(|a| {
1120 if a.assertion_type == "error" || a.assertion_type == "not_error" {
1121 return false;
1122 }
1123 match &a.field {
1124 Some(f) if !f.is_empty() => {
1125 if is_streaming && crate::codegen::streaming_assertions::is_streaming_virtual_field(f) {
1126 return true;
1127 }
1128 let is_synthetic_field = matches!(
1130 f.as_str(),
1131 "chunks_have_content"
1132 | "chunks_have_embeddings"
1133 | "chunks_have_heading_context"
1134 | "first_chunk_starts_with_heading"
1135 | "embeddings"
1136 | "embedding_dimensions"
1137 | "embeddings_valid"
1138 | "embeddings_finite"
1139 | "embeddings_non_zero"
1140 | "embeddings_normalized"
1141 );
1142 is_synthetic_field || field_resolver.is_valid_for_result(f)
1143 }
1144 _ => true,
1145 }
1146 });
1147
1148 let collect_snippet = if is_streaming {
1150 crate::codegen::streaming_assertions::StreamingFieldResolver::collect_snippet("php", result_var, "chunks")
1151 .unwrap_or_default()
1152 } else {
1153 String::new()
1154 };
1155
1156 let mut fields_array_bindings: std::collections::BTreeMap<String, (String, String)> =
1166 std::collections::BTreeMap::new();
1167 for assertion in &fixture.assertions {
1168 if let Some(f) = &assertion.field {
1169 if !f.is_empty() && field_resolver.is_array(f) {
1170 if !fields_array_bindings.contains_key(f.as_str()) {
1172 let accessor = field_resolver.accessor(f, "php", &format!("${result_var}"));
1173 let var_name = f.to_lower_camel_case();
1174 fields_array_bindings.insert(f.clone(), (var_name, accessor));
1175 }
1176 }
1177 }
1178 }
1179
1180 let mut field_bindings = String::new();
1187 for (var_name, accessor) in fields_array_bindings.values() {
1188 field_bindings.push_str(&format!(" ${} = {};\n", var_name, accessor));
1189 }
1190
1191 let mut assertions_body = String::new();
1193 for assertion in &fixture.assertions {
1194 render_assertion(
1195 &mut assertions_body,
1196 assertion,
1197 result_var,
1198 field_resolver,
1199 result_is_simple,
1200 call_config.result_is_array,
1201 &fields_array_bindings,
1202 );
1203 }
1204
1205 if is_streaming && !expects_error && assertions_body.trim().is_empty() {
1211 assertions_body.push_str(" $this->assertTrue(is_array($chunks), 'expected drained chunks list');\n");
1212 }
1213
1214 let rendered = crate::template_env::render(
1215 "php/test_method.jinja",
1216 minijinja::context! {
1217 method_name => method_name,
1218 description => description,
1219 client_factory => client_factory,
1220 setup_lines => setup_lines,
1221 expects_error => expects_error,
1222 skip_test => fixture.assertions.is_empty(),
1223 has_usable_assertions => has_usable_assertions || is_streaming,
1224 call_expr => call_expr,
1225 result_var => result_var,
1226 collect_snippet => collect_snippet,
1227 field_bindings => field_bindings,
1228 assertions_body => assertions_body,
1229 },
1230 );
1231 out.push_str(&rendered);
1232}
1233
1234fn emit_php_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
1247 if let Some(items) = arr.as_array() {
1248 let item_strs: Vec<String> = items
1249 .iter()
1250 .filter_map(|item| {
1251 if let Some(obj) = item.as_object() {
1252 match elem_type {
1253 "BatchBytesItem" => {
1254 let content = obj.get("content").and_then(|v| v.as_array());
1255 let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1256 let content_code = if let Some(arr) = content {
1257 let bytes: Vec<String> = arr
1258 .iter()
1259 .filter_map(|v| v.as_u64())
1260 .map(|n| format!("\\x{:02x}", n))
1261 .collect();
1262 format!("\"{}\"", bytes.join(""))
1263 } else {
1264 "\"\"".to_string()
1265 };
1266 Some(format!(
1267 "new {}(content: {}, mimeType: \"{}\")",
1268 elem_type, content_code, mime_type
1269 ))
1270 }
1271 "BatchFileItem" => {
1272 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1273 Some(format!("new {}(path: \"{}\")", elem_type, path))
1274 }
1275 _ => {
1276 Some(json_to_php(&serde_json::Value::Object(obj.clone())))
1279 }
1280 }
1281 } else {
1282 None
1283 }
1284 })
1285 .collect();
1286 format!("[{}]", item_strs.join(", "))
1287 } else {
1288 "[]".to_string()
1289 }
1290}
1291
1292#[allow(clippy::too_many_arguments)]
1293fn build_args_and_setup(
1294 input: &serde_json::Value,
1295 args: &[crate::config::ArgMapping],
1296 class_name: &str,
1297 _enum_fields: &HashMap<String, String>,
1298 fixture: &crate::fixture::Fixture,
1299 options_via: &str,
1300 options_type: Option<&str>,
1301 adapter_request_type: Option<&str>,
1302) -> (Vec<String>, String) {
1303 let fixture_id = &fixture.id;
1304 if args.is_empty() {
1305 let is_empty_input = match input {
1308 serde_json::Value::Null => true,
1309 serde_json::Value::Object(m) => m.is_empty(),
1310 _ => false,
1311 };
1312 if is_empty_input {
1313 return (Vec::new(), String::new());
1314 }
1315 return (Vec::new(), json_to_php(input));
1316 }
1317
1318 let mut setup_lines: Vec<String> = Vec::new();
1319 let mut parts: Vec<String> = Vec::new();
1320
1321 let arg_has_emission = |arg: &crate::config::ArgMapping| -> bool {
1326 let val = if arg.field == "input" {
1327 Some(input)
1328 } else {
1329 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1330 input.get(field)
1331 };
1332 match val {
1333 None | Some(serde_json::Value::Null) => {
1334 if arg.arg_type == "json_object" && arg.name == "config" {
1340 return true;
1341 }
1342 !arg.optional
1343 }
1344 Some(_) => true,
1345 }
1346 };
1347 let any_later_has_emission = |from_idx: usize| -> bool { args[from_idx..].iter().any(arg_has_emission) };
1348
1349 for (idx, arg) in args.iter().enumerate() {
1350 if arg.arg_type == "mock_url" {
1351 if fixture.has_host_root_route() {
1352 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1353 setup_lines.push(format!(
1354 "${} = getenv('{env_key}') ?: getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}';",
1355 arg.name,
1356 ));
1357 } else {
1358 setup_lines.push(format!(
1359 "${} = getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}';",
1360 arg.name,
1361 ));
1362 }
1363 if let Some(req_type) = adapter_request_type {
1364 let req_var = format!("${}_req", arg.name);
1365 setup_lines.push(format!("{req_var} = new {req_type}(${});", arg.name));
1366 parts.push(req_var);
1367 } else {
1368 parts.push(format!("${}", arg.name));
1369 }
1370 continue;
1371 }
1372
1373 if arg.arg_type == "mock_url_list" {
1374 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1379 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1380 let val = if let Some(v) = input.get(field).filter(|v| !v.is_null()) {
1382 v.clone()
1383 } else {
1384 super::resolve_urls_field(input, &arg.field).clone()
1385 };
1386 let paths: Vec<String> = if let Some(arr) = val.as_array() {
1387 arr.iter()
1388 .filter_map(|v| v.as_str().map(|s| format!("\"{}\"", escape_php(s))))
1389 .collect()
1390 } else {
1391 Vec::new()
1392 };
1393 let paths_literal = paths.join(", ");
1394 let name = &arg.name;
1395 setup_lines.push(format!(
1396 "${name}_base = getenv('{env_key}') ?: getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}';"
1397 ));
1398 setup_lines.push(format!(
1399 "${name} = array_map(fn($p) => str_starts_with($p, 'http') ? $p : ${name}_base . $p, [{paths_literal}]);"
1400 ));
1401 parts.push(format!("${name}"));
1402 continue;
1403 }
1404
1405 if arg.arg_type == "handle" {
1406 let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
1408 let config_value = if arg.field == "input" {
1409 input
1410 } else {
1411 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1412 input.get(field).unwrap_or(&serde_json::Value::Null)
1413 };
1414 if config_value.is_null()
1415 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1416 {
1417 setup_lines.push(format!("${} = {class_name}::{constructor_name}(null);", arg.name,));
1418 } else {
1419 let name = &arg.name;
1420 let filtered_config = filter_empty_enum_strings(config_value);
1425 setup_lines.push(format!(
1426 "${name}_config = CrawlConfig::from_json(json_encode({}));",
1427 json_to_php_camel_keys(&filtered_config)
1428 ));
1429 setup_lines.push(format!(
1430 "${} = {class_name}::{constructor_name}(${name}_config);",
1431 arg.name,
1432 ));
1433 }
1434 parts.push(format!("${}", arg.name));
1435 continue;
1436 }
1437
1438 let val = if arg.field == "input" {
1439 Some(input)
1440 } else {
1441 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1442 input.get(field)
1443 };
1444
1445 if arg.arg_type == "bytes" {
1449 match val {
1450 None | Some(serde_json::Value::Null) => {
1451 if arg.optional {
1452 parts.push("null".to_string());
1453 } else {
1454 parts.push("\"\"".to_string());
1455 }
1456 }
1457 Some(serde_json::Value::String(s)) => {
1458 let var_name = format!("{}Bytes", arg.name);
1459 setup_lines.push(format!(
1460 "${var_name} = file_get_contents(\"{path}\");\n if (${var_name} === false) {{ $this->fail(\"failed to read fixture: {path}\"); }}",
1461 path = s.replace('"', "\\\"")
1462 ));
1463 parts.push(format!("${var_name}"));
1464 }
1465 Some(serde_json::Value::Array(arr)) => {
1466 let bytes: String = arr
1467 .iter()
1468 .filter_map(|v| v.as_u64())
1469 .map(|n| format!("\\x{:02x}", n))
1470 .collect();
1471 parts.push(format!("\"{bytes}\""));
1472 }
1473 Some(other) => {
1474 parts.push(json_to_php(other));
1475 }
1476 }
1477 continue;
1478 }
1479
1480 match val {
1481 None | Some(serde_json::Value::Null) if arg.arg_type == "json_object" && arg.name == "config" => {
1482 let type_name = if let Some(opt_type) = options_type {
1489 opt_type.to_string()
1490 } else if arg.name == "config" {
1491 "ExtractionConfig".to_string()
1492 } else {
1493 format!("{}Config", arg.name.to_upper_camel_case())
1494 };
1495 parts.push(format!("{type_name}::from_json('{{}}')"));
1496 continue;
1497 }
1498 None | Some(serde_json::Value::Null) if arg.optional => {
1499 if any_later_has_emission(idx + 1) {
1504 parts.push("null".to_string());
1505 }
1506 continue;
1507 }
1508 None | Some(serde_json::Value::Null) => {
1509 let default_val = match arg.arg_type.as_str() {
1511 "string" => "\"\"".to_string(),
1512 "int" | "integer" => "0".to_string(),
1513 "float" | "number" => "0.0".to_string(),
1514 "bool" | "boolean" => "false".to_string(),
1515 "json_object" if options_via == "json" => "null".to_string(),
1516 _ => "null".to_string(),
1517 };
1518 parts.push(default_val);
1519 }
1520 Some(v) => {
1521 if arg.arg_type == "json_object" && !v.is_null() {
1522 if let Some(elem_type) = &arg.element_type {
1524 if v.is_array() {
1525 if elem_type == "BatchBytesItem" || elem_type == "BatchFileItem" {
1526 parts.push(emit_php_batch_item_array(v, elem_type));
1527 continue;
1528 }
1529 if is_php_reserved_type(elem_type) {
1533 parts.push(json_to_php(v));
1534 continue;
1535 }
1536 if let Some(arr) = v.as_array() {
1538 let items: Vec<String> = arr
1539 .iter()
1540 .filter_map(|item| {
1541 item.as_object()
1542 .map(|obj| json_to_php(&serde_json::Value::Object(obj.clone())))
1543 })
1544 .collect();
1545 parts.push(format!("[{}]", items.join(", ")));
1546 continue;
1547 }
1548 }
1549 }
1550 match options_via {
1551 "json" => {
1552 let filtered_v = filter_empty_enum_strings(v);
1555
1556 if let serde_json::Value::Object(obj) = &filtered_v {
1558 if obj.is_empty() {
1559 parts.push("null".to_string());
1560 continue;
1561 }
1562 }
1563
1564 parts.push(format!("json_encode({})", json_to_php_camel_keys(&filtered_v)));
1565 continue;
1566 }
1567 _ => {
1568 if let Some(type_name) = options_type {
1569 let filtered_v = filter_empty_enum_strings(v);
1574
1575 if let serde_json::Value::Object(obj) = &filtered_v {
1578 if obj.is_empty() {
1579 let arg_var = format!("${}", arg.name);
1580 setup_lines.push(format!("{arg_var} = {type_name}::from_json('{{}}');"));
1581 parts.push(arg_var);
1582 continue;
1583 }
1584 }
1585
1586 let arg_var = format!("${}", arg.name);
1587 setup_lines.push(format!(
1591 "{arg_var} = {type_name}::from_json(json_encode({}));",
1592 json_to_php_camel_keys(&filtered_v)
1593 ));
1594 parts.push(arg_var);
1595 continue;
1596 }
1597 if let Some(obj) = v.as_object() {
1601 setup_lines.push("$builder = $this->createDefaultOptionsBuilder();".to_string());
1602 for (k, vv) in obj {
1603 let snake_key = k.to_snake_case();
1604 if snake_key == "preprocessing" {
1605 if let Some(prep_obj) = vv.as_object() {
1606 let enabled =
1607 prep_obj.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
1608 let preset =
1609 prep_obj.get("preset").and_then(|v| v.as_str()).unwrap_or("Minimal");
1610 let remove_navigation = prep_obj
1611 .get("remove_navigation")
1612 .and_then(|v| v.as_bool())
1613 .unwrap_or(true);
1614 let remove_forms =
1615 prep_obj.get("remove_forms").and_then(|v| v.as_bool()).unwrap_or(true);
1616 setup_lines.push(format!(
1617 "$preprocessing = $this->createPreprocessingOptions({}, {}, {}, {});",
1618 if enabled { "true" } else { "false" },
1619 json_to_php(&serde_json::Value::String(preset.to_string())),
1620 if remove_navigation { "true" } else { "false" },
1621 if remove_forms { "true" } else { "false" }
1622 ));
1623 setup_lines.push(
1624 "$builder = $builder->preprocessing($preprocessing);".to_string(),
1625 );
1626 }
1627 }
1628 }
1629 setup_lines.push("$options = $builder->build();".to_string());
1630 parts.push("$options".to_string());
1631 continue;
1632 }
1633 }
1634 }
1635 }
1636 parts.push(json_to_php(v));
1637 }
1638 }
1639 }
1640
1641 (setup_lines, parts.join(", "))
1642}
1643
1644fn render_assertion(
1645 out: &mut String,
1646 assertion: &Assertion,
1647 result_var: &str,
1648 field_resolver: &FieldResolver,
1649 result_is_simple: bool,
1650 result_is_array: bool,
1651 fields_array_bindings: &std::collections::BTreeMap<String, (String, String)>,
1652) {
1653 if let Some(f) = &assertion.field {
1656 match f.as_str() {
1657 "chunks_have_content" => {
1658 let pred = format!(
1659 "array_reduce(${result_var}->chunks ?? [], fn($carry, $c) => $carry && !empty($c->content), true)"
1660 );
1661 out.push_str(&crate::template_env::render(
1662 "php/synthetic_assertion.jinja",
1663 minijinja::context! {
1664 assertion_kind => "chunks_content",
1665 assertion_type => assertion.assertion_type.as_str(),
1666 pred => pred,
1667 field_name => f,
1668 },
1669 ));
1670 return;
1671 }
1672 "chunks_have_embeddings" => {
1673 let pred = format!(
1674 "array_reduce(${result_var}->chunks ?? [], fn($carry, $c) => $carry && !empty($c->embedding), true)"
1675 );
1676 out.push_str(&crate::template_env::render(
1677 "php/synthetic_assertion.jinja",
1678 minijinja::context! {
1679 assertion_kind => "chunks_embeddings",
1680 assertion_type => assertion.assertion_type.as_str(),
1681 pred => pred,
1682 field_name => f,
1683 },
1684 ));
1685 return;
1686 }
1687 "embeddings" => {
1691 let php_val = assertion.value.as_ref().map(json_to_php).unwrap_or_default();
1692 out.push_str(&crate::template_env::render(
1693 "php/synthetic_assertion.jinja",
1694 minijinja::context! {
1695 assertion_kind => "embeddings",
1696 assertion_type => assertion.assertion_type.as_str(),
1697 php_val => php_val,
1698 result_var => result_var,
1699 },
1700 ));
1701 return;
1702 }
1703 "embedding_dimensions" => {
1704 let expr = format!("(empty(${result_var}) ? 0 : count(${result_var}[0]))");
1705 let php_val = assertion.value.as_ref().map(json_to_php).unwrap_or_default();
1706 out.push_str(&crate::template_env::render(
1707 "php/synthetic_assertion.jinja",
1708 minijinja::context! {
1709 assertion_kind => "embedding_dimensions",
1710 assertion_type => assertion.assertion_type.as_str(),
1711 expr => expr,
1712 php_val => php_val,
1713 },
1714 ));
1715 return;
1716 }
1717 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1718 let pred = match f.as_str() {
1719 "embeddings_valid" => {
1720 format!("array_reduce(${result_var}, fn($carry, $e) => $carry && count($e) > 0, true)")
1721 }
1722 "embeddings_finite" => {
1723 format!(
1724 "array_reduce(${result_var}, fn($carry, $e) => $carry && array_reduce($e, fn($c, $v) => $c && is_finite($v), true), true)"
1725 )
1726 }
1727 "embeddings_non_zero" => {
1728 format!(
1729 "array_reduce(${result_var}, fn($carry, $e) => $carry && count(array_filter($e, fn($v) => $v !== 0.0)) > 0, true)"
1730 )
1731 }
1732 "embeddings_normalized" => {
1733 format!(
1734 "array_reduce(${result_var}, fn($carry, $e) => $carry && abs(array_sum(array_map(fn($v) => $v * $v, $e)) - 1.0) < 1e-3, true)"
1735 )
1736 }
1737 _ => unreachable!(),
1738 };
1739 let assertion_kind = format!("embeddings_{}", f.strip_prefix("embeddings_").unwrap_or(f));
1740 out.push_str(&crate::template_env::render(
1741 "php/synthetic_assertion.jinja",
1742 minijinja::context! {
1743 assertion_kind => assertion_kind,
1744 assertion_type => assertion.assertion_type.as_str(),
1745 pred => pred,
1746 field_name => f,
1747 },
1748 ));
1749 return;
1750 }
1751 "keywords" | "keywords_count" => {
1754 out.push_str(&crate::template_env::render(
1755 "php/synthetic_assertion.jinja",
1756 minijinja::context! {
1757 assertion_kind => "keywords",
1758 field_name => f,
1759 },
1760 ));
1761 return;
1762 }
1763 _ => {}
1764 }
1765 }
1766
1767 if let Some(f) = &assertion.field {
1770 if !f.is_empty() && crate::codegen::streaming_assertions::is_streaming_virtual_field(f) {
1771 if let Some(expr) =
1772 crate::codegen::streaming_assertions::StreamingFieldResolver::accessor(f, "php", "chunks")
1773 {
1774 let line = match assertion.assertion_type.as_str() {
1775 "count_min" => {
1776 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1777 format!(
1778 " $this->assertGreaterThanOrEqual({n}, count({expr}), 'expected >= {n} chunks');\n"
1779 )
1780 } else {
1781 String::new()
1782 }
1783 }
1784 "count_equals" => {
1785 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1786 format!(" $this->assertCount({n}, {expr});\n")
1787 } else {
1788 String::new()
1789 }
1790 }
1791 "equals" => {
1792 if let Some(serde_json::Value::String(s)) = &assertion.value {
1793 let escaped = s.replace('\\', "\\\\").replace('\'', "\\'");
1794 format!(" $this->assertEquals('{escaped}', {expr});\n")
1795 } else if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1796 format!(" $this->assertEquals({n}, {expr});\n")
1797 } else {
1798 String::new()
1799 }
1800 }
1801 "not_empty" => format!(" $this->assertNotEmpty({expr});\n"),
1802 "is_empty" => format!(" $this->assertEmpty({expr});\n"),
1803 "is_true" => format!(" $this->assertTrue({expr});\n"),
1804 "is_false" => format!(" $this->assertFalse({expr});\n"),
1805 "greater_than" => {
1806 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1807 format!(" $this->assertGreaterThan({n}, {expr});\n")
1808 } else {
1809 String::new()
1810 }
1811 }
1812 "greater_than_or_equal" => {
1813 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1814 format!(" $this->assertGreaterThanOrEqual({n}, {expr});\n")
1815 } else {
1816 String::new()
1817 }
1818 }
1819 "contains" => {
1820 if let Some(serde_json::Value::String(s)) = &assertion.value {
1821 let escaped = s.replace('\\', "\\\\").replace('\'', "\\'");
1822 format!(" $this->assertStringContainsString('{escaped}', {expr});\n")
1823 } else {
1824 String::new()
1825 }
1826 }
1827 _ => format!(
1828 " // streaming field '{f}': assertion type '{}' not rendered\n",
1829 assertion.assertion_type
1830 ),
1831 };
1832 if !line.is_empty() {
1833 out.push_str(&line);
1834 }
1835 }
1836 return;
1837 }
1838 }
1839
1840 if let Some(f) = &assertion.field {
1842 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1843 out.push_str(&crate::template_env::render(
1844 "php/synthetic_assertion.jinja",
1845 minijinja::context! {
1846 assertion_kind => "skipped",
1847 field_name => f,
1848 },
1849 ));
1850 return;
1851 }
1852 }
1853
1854 if result_is_simple {
1857 if let Some(f) = &assertion.field {
1858 let f_lower = f.to_lowercase();
1859 if !f.is_empty()
1860 && f_lower != "content"
1861 && (f_lower.starts_with("metadata")
1862 || f_lower.starts_with("document")
1863 || f_lower.starts_with("structure"))
1864 {
1865 out.push_str(&crate::template_env::render(
1866 "php/synthetic_assertion.jinja",
1867 minijinja::context! {
1868 assertion_kind => "result_is_simple",
1869 field_name => f,
1870 },
1871 ));
1872 return;
1873 }
1874 }
1875 }
1876
1877 let field_expr = match &assertion.field {
1878 _ if result_is_simple => format!("${result_var}"),
1882 Some(f) if !f.is_empty() => {
1883 if let Some((var_name, _)) = fields_array_bindings.get(f) {
1885 format!("${}", var_name)
1886 } else {
1887 let accessor = field_resolver.accessor(f, "php", &format!("${result_var}"));
1888 if field_resolver.is_optional(f) {
1890 format!("({accessor} ?? null)")
1891 } else {
1892 accessor
1893 }
1894 }
1895 }
1896 _ => format!("${result_var}"),
1897 };
1898
1899 let field_is_array = assertion.field.as_ref().map_or(result_is_array, |f| {
1902 if f.is_empty() {
1903 result_is_array
1904 } else {
1905 field_resolver.is_array(f)
1906 }
1907 });
1908
1909 let trimmed_field_expr_for = |expected: &serde_json::Value| -> String {
1913 if expected.is_string() {
1914 format!("trim({})", field_expr)
1915 } else {
1916 field_expr.clone()
1917 }
1918 };
1919
1920 let assertion_type = assertion.assertion_type.as_str();
1922 let has_php_val = assertion.value.is_some();
1923 let php_val = match assertion.value.as_ref() {
1927 Some(v) => json_to_php(v),
1928 None if assertion_type == "equals" => "null".to_string(),
1929 None => String::new(),
1930 };
1931 let trimmed_field_expr = trimmed_field_expr_for(assertion.value.as_ref().unwrap_or(&serde_json::Value::Null));
1932 let is_string_val = assertion.value.as_ref().is_some_and(|v| v.is_string());
1933 let values_php: Vec<String> = assertion
1937 .values
1938 .as_ref()
1939 .map(|vals| vals.iter().map(json_to_php).collect::<Vec<_>>())
1940 .or_else(|| assertion.value.as_ref().map(|v| vec![json_to_php(v)]))
1941 .unwrap_or_default();
1942 let contains_any_checks: Vec<String> = assertion
1943 .values
1944 .as_ref()
1945 .map_or(Vec::new(), |vals| vals.iter().map(json_to_php).collect());
1946 let n = assertion.value.as_ref().and_then(|v| v.as_u64()).unwrap_or(0);
1947
1948 let call_expr = if let Some(method_name) = &assertion.method {
1950 build_php_method_call(result_var, method_name, assertion.args.as_ref())
1951 } else {
1952 String::new()
1953 };
1954 let check = assertion.check.as_deref().unwrap_or("is_true");
1955 let has_php_check_val = matches!(assertion.assertion_type.as_str(), "method_result") && assertion.value.is_some();
1956 let php_check_val = if matches!(assertion.assertion_type.as_str(), "method_result") {
1957 assertion.value.as_ref().map(json_to_php).unwrap_or_default()
1958 } else {
1959 String::new()
1960 };
1961 let check_n = assertion.value.as_ref().and_then(|v| v.as_u64()).unwrap_or(0);
1962 let is_bool_val = assertion.value.as_ref().is_some_and(|v| v.is_boolean());
1963 let bool_is_true = assertion.value.as_ref().and_then(|v| v.as_bool()).unwrap_or(false);
1964
1965 if matches!(assertion_type, "not_error" | "error") {
1967 if assertion_type == "not_error" {
1968 }
1970 return;
1972 }
1973
1974 let rendered = crate::template_env::render(
1975 "php/assertion.jinja",
1976 minijinja::context! {
1977 assertion_type => assertion_type,
1978 field_expr => field_expr,
1979 php_val => php_val,
1980 has_php_val => has_php_val,
1981 trimmed_field_expr => trimmed_field_expr,
1982 is_string_val => is_string_val,
1983 field_is_array => field_is_array,
1984 values_php => values_php,
1985 contains_any_checks => contains_any_checks,
1986 n => n,
1987 call_expr => call_expr,
1988 check => check,
1989 php_check_val => php_check_val,
1990 has_php_check_val => has_php_check_val,
1991 check_n => check_n,
1992 is_bool_val => is_bool_val,
1993 bool_is_true => bool_is_true,
1994 },
1995 );
1996 let _ = write!(out, " {}", rendered);
1997}
1998
1999fn build_php_method_call(result_var: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
2006 let extra_args = if let Some(args_val) = args {
2007 args_val
2008 .as_object()
2009 .map(|obj| {
2010 obj.values()
2011 .map(|v| match v {
2012 serde_json::Value::String(s) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")),
2013 serde_json::Value::Bool(true) => "true".to_string(),
2014 serde_json::Value::Bool(false) => "false".to_string(),
2015 serde_json::Value::Number(n) => n.to_string(),
2016 serde_json::Value::Null => "null".to_string(),
2017 other => format!("\"{}\"", other.to_string().replace('\\', "\\\\").replace('"', "\\\"")),
2018 })
2019 .collect::<Vec<_>>()
2020 .join(", ")
2021 })
2022 .unwrap_or_default()
2023 } else {
2024 String::new()
2025 };
2026
2027 if extra_args.is_empty() {
2028 format!("${result_var}->{method_name}()")
2029 } else {
2030 format!("${result_var}->{method_name}({extra_args})")
2031 }
2032}
2033
2034fn filter_empty_enum_strings(value: &serde_json::Value) -> serde_json::Value {
2038 match value {
2039 serde_json::Value::Object(map) => {
2040 let filtered: serde_json::Map<String, serde_json::Value> = map
2041 .iter()
2042 .filter_map(|(k, v)| {
2043 if let serde_json::Value::String(s) = v {
2045 if s.is_empty() {
2046 return None;
2047 }
2048 }
2049 Some((k.clone(), filter_empty_enum_strings(v)))
2051 })
2052 .collect();
2053 serde_json::Value::Object(filtered)
2054 }
2055 serde_json::Value::Array(arr) => {
2056 let filtered: Vec<serde_json::Value> = arr.iter().map(filter_empty_enum_strings).collect();
2057 serde_json::Value::Array(filtered)
2058 }
2059 other => other.clone(),
2060 }
2061}
2062
2063fn json_to_php(value: &serde_json::Value) -> String {
2065 match value {
2066 serde_json::Value::String(s) => format!("\"{}\"", escape_php(s)),
2067 serde_json::Value::Bool(true) => "true".to_string(),
2068 serde_json::Value::Bool(false) => "false".to_string(),
2069 serde_json::Value::Number(n) => n.to_string(),
2070 serde_json::Value::Null => "null".to_string(),
2071 serde_json::Value::Array(arr) => {
2072 let items: Vec<String> = arr.iter().map(json_to_php).collect();
2073 format!("[{}]", items.join(", "))
2074 }
2075 serde_json::Value::Object(map) => {
2076 let items: Vec<String> = map
2077 .iter()
2078 .map(|(k, v)| format!("\"{}\" => {}", escape_php(k), json_to_php(v)))
2079 .collect();
2080 format!("[{}]", items.join(", "))
2081 }
2082 }
2083}
2084
2085fn json_to_php_camel_keys(value: &serde_json::Value) -> String {
2090 match value {
2091 serde_json::Value::Object(map) => {
2092 let items: Vec<String> = map
2093 .iter()
2094 .map(|(k, v)| {
2095 let camel_key = k.to_lower_camel_case();
2096 format!("\"{}\" => {}", escape_php(&camel_key), json_to_php_camel_keys(v))
2097 })
2098 .collect();
2099 format!("[{}]", items.join(", "))
2100 }
2101 serde_json::Value::Array(arr) => {
2102 let items: Vec<String> = arr.iter().map(json_to_php_camel_keys).collect();
2103 format!("[{}]", items.join(", "))
2104 }
2105 _ => json_to_php(value),
2106 }
2107}
2108
2109fn build_php_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) {
2115 setup_lines.push("$visitor = new class {".to_string());
2116 for (method_name, action) in &visitor_spec.callbacks {
2117 emit_php_visitor_method(setup_lines, method_name, action);
2118 }
2119 setup_lines.push("};".to_string());
2120}
2121
2122fn emit_php_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
2124 let params = match method_name {
2125 "visit_link" => "$ctx, $href, $text, $title",
2126 "visit_image" => "$ctx, $src, $alt, $title",
2127 "visit_heading" => "$ctx, $level, $text, $id",
2128 "visit_code_block" => "$ctx, $lang, $code",
2129 "visit_code_inline"
2130 | "visit_strong"
2131 | "visit_emphasis"
2132 | "visit_strikethrough"
2133 | "visit_underline"
2134 | "visit_subscript"
2135 | "visit_superscript"
2136 | "visit_mark"
2137 | "visit_button"
2138 | "visit_summary"
2139 | "visit_figcaption"
2140 | "visit_definition_term"
2141 | "visit_definition_description" => "$ctx, $text",
2142 "visit_text" => "$ctx, $text",
2143 "visit_list_item" => "$ctx, $ordered, $marker, $text",
2144 "visit_blockquote" => "$ctx, $content, $depth",
2145 "visit_table_row" => "$ctx, $cells, $isHeader",
2146 "visit_custom_element" => "$ctx, $tagName, $html",
2147 "visit_form" => "$ctx, $actionUrl, $method",
2148 "visit_input" => "$ctx, $input_type, $name, $value",
2149 "visit_audio" | "visit_video" | "visit_iframe" => "$ctx, $src",
2150 "visit_details" => "$ctx, $isOpen",
2151 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "$ctx, $output",
2152 "visit_list_start" => "$ctx, $ordered",
2153 "visit_list_end" => "$ctx, $ordered, $output",
2154 _ => "$ctx",
2155 };
2156
2157 let (action_type, action_value, return_form) = match action {
2158 CallbackAction::Skip => ("skip", String::new(), "dict"),
2159 CallbackAction::Continue => ("continue", String::new(), "dict"),
2160 CallbackAction::PreserveHtml => ("preserve_html", String::new(), "dict"),
2161 CallbackAction::Custom { output } => ("custom", escape_php(output), "dict"),
2162 CallbackAction::CustomTemplate { template, return_form } => {
2163 let form = match return_form {
2164 TemplateReturnForm::Dict => "dict",
2165 TemplateReturnForm::BareString => "bare_string",
2166 };
2167 ("custom_template", escape_php(template), form)
2168 }
2169 };
2170
2171 let rendered = crate::template_env::render(
2172 "php/visitor_method.jinja",
2173 minijinja::context! {
2174 method_name => method_name,
2175 params => params,
2176 action_type => action_type,
2177 action_value => action_value,
2178 return_form => return_form,
2179 },
2180 );
2181 for line in rendered.lines() {
2182 setup_lines.push(line.to_string());
2183 }
2184}
2185
2186fn is_php_reserved_type(name: &str) -> bool {
2188 matches!(
2189 name.to_ascii_lowercase().as_str(),
2190 "string"
2191 | "int"
2192 | "integer"
2193 | "float"
2194 | "double"
2195 | "bool"
2196 | "boolean"
2197 | "array"
2198 | "object"
2199 | "null"
2200 | "void"
2201 | "callable"
2202 | "iterable"
2203 | "never"
2204 | "self"
2205 | "parent"
2206 | "static"
2207 | "true"
2208 | "false"
2209 | "mixed"
2210 )
2211}