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 no system ini (-n) to avoid conflicts with any system-installed
482// version of the extension, then load the local build explicitly.
483if (file_exists($extPath) && !getenv('ALEF_PHP_LOCAL_EXT_LOADED')) {{
484 putenv('ALEF_PHP_LOCAL_EXT_LOADED=1');
485 $php = PHP_BINARY;
486 $phpunitPath = __DIR__ . '/vendor/bin/phpunit';
487
488 $cmd = array_merge(
489 [$php, '-n', '-d', 'extension=' . $extPath],
490 [$phpunitPath],
491 array_slice($GLOBALS['argv'], 1)
492 );
493
494 passthru(implode(' ', array_map('escapeshellarg', $cmd)), $exitCode);
495 exit($exitCode);
496}}
497
498// Extension is now loaded (via the restart above with -n flag).
499// Invoke PHPUnit normally.
500$phpunitPath = __DIR__ . '/vendor/bin/phpunit';
501if (!file_exists($phpunitPath)) {{
502 echo "PHPUnit not found at $phpunitPath. Run 'composer install' first.\n";
503 exit(1);
504}}
505
506require $phpunitPath;
507"#
508 )
509}
510
511#[allow(clippy::too_many_arguments)]
512fn render_test_file(
513 category: &str,
514 fixtures: &[&Fixture],
515 e2e_config: &E2eConfig,
516 lang: &str,
517 namespace: &str,
518 class_name: &str,
519 test_class: &str,
520 type_defs: &[alef_core::ir::TypeDef],
521 php_enum_names: &HashSet<String>,
522 enum_fields: &HashMap<String, String>,
523 result_is_simple: bool,
524 php_client_factory: Option<&str>,
525 options_via: &str,
526 adapters: &[alef_core::config::extras::AdapterConfig],
527) -> String {
528 let header = hash::header(CommentStyle::DoubleSlash);
529
530 let needs_crawl_config_import = fixtures.iter().any(|f| {
532 let call =
533 e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.id, &f.resolved_category(), &f.tags, &f.input);
534 call.args.iter().filter(|a| a.arg_type == "handle").any(|a| {
535 let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
536 !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
537 })
538 });
539
540 let has_http_tests = fixtures.iter().any(|f| f.is_http_test());
542
543 let mut options_type_imports: Vec<String> = fixtures
545 .iter()
546 .flat_map(|f| {
547 let call = e2e_config.resolve_call_for_fixture(
548 f.call.as_deref(),
549 &f.id,
550 &f.resolved_category(),
551 &f.tags,
552 &f.input,
553 );
554 let php_override = call.overrides.get(lang);
555 let opt_type = php_override
556 .and_then(|o| o.options_type.as_deref())
557 .or_else(|| {
558 e2e_config
559 .call
560 .overrides
561 .get(lang)
562 .and_then(|o| o.options_type.as_deref())
563 })
564 .or(call.options_type.as_deref());
565 let element_types: Vec<String> = call
566 .args
567 .iter()
568 .filter_map(|a| a.element_type.as_ref().map(|t| t.to_string()))
569 .filter(|t| !is_php_reserved_type(t))
570 .collect();
571 opt_type.map(|t| t.to_string()).into_iter().chain(element_types)
572 })
573 .collect::<std::collections::HashSet<_>>()
574 .into_iter()
575 .collect();
576 options_type_imports.sort();
577
578 let mut imports_use: Vec<String> = Vec::new();
580 if needs_crawl_config_import {
581 imports_use.push(format!("use {namespace}\\CrawlConfig;"));
582 }
583 for type_name in &options_type_imports {
584 if type_name != class_name {
585 imports_use.push(format!("use {namespace}\\{type_name};"));
586 }
587 }
588
589 let mut fixtures_body = String::new();
591 for (i, fixture) in fixtures.iter().enumerate() {
592 if fixture.is_http_test() {
593 render_http_test_method(&mut fixtures_body, fixture, fixture.http.as_ref().unwrap());
594 } else {
595 render_test_method(
596 &mut fixtures_body,
597 fixture,
598 e2e_config,
599 lang,
600 namespace,
601 class_name,
602 type_defs,
603 php_enum_names,
604 enum_fields,
605 result_is_simple,
606 php_client_factory,
607 options_via,
608 adapters,
609 );
610 }
611 if i + 1 < fixtures.len() {
612 fixtures_body.push('\n');
613 }
614 }
615
616 crate::template_env::render(
617 "php/test_file.jinja",
618 minijinja::context! {
619 header => header,
620 namespace => namespace,
621 class_name => class_name,
622 test_class => test_class,
623 category => category,
624 imports_use => imports_use,
625 has_http_tests => has_http_tests,
626 fixtures_body => fixtures_body,
627 },
628 )
629}
630
631struct PhpTestClientRenderer;
639
640impl client::TestClientRenderer for PhpTestClientRenderer {
641 fn language_name(&self) -> &'static str {
642 "php"
643 }
644
645 fn sanitize_test_name(&self, id: &str) -> String {
647 sanitize_filename(id)
648 }
649
650 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
656 let escaped_reason = skip_reason.map(escape_php);
657 let rendered = crate::template_env::render(
658 "php/http_test_open.jinja",
659 minijinja::context! {
660 fn_name => fn_name,
661 description => description,
662 skip_reason => escaped_reason,
663 },
664 );
665 out.push_str(&rendered);
666 }
667
668 fn render_test_close(&self, out: &mut String) {
670 let rendered = crate::template_env::render("php/http_test_close.jinja", minijinja::context! {});
671 out.push_str(&rendered);
672 }
673
674 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
679 let method = ctx.method.to_uppercase();
680
681 let mut opts: Vec<String> = Vec::new();
683
684 if let Some(body) = ctx.body {
685 let php_body = json_to_php(body);
686 opts.push(format!("'json' => {php_body}"));
687 }
688
689 let mut header_pairs: Vec<String> = Vec::new();
691 if let Some(ct) = ctx.content_type {
692 if !ctx.headers.keys().any(|k| k.to_lowercase() == "content-type") {
694 header_pairs.push(format!("\"Content-Type\" => \"{}\"", escape_php(ct)));
695 }
696 }
697 for (k, v) in ctx.headers {
698 header_pairs.push(format!("\"{}\" => \"{}\"", escape_php(k), escape_php(v)));
699 }
700 if !header_pairs.is_empty() {
701 opts.push(format!("'headers' => [{}]", header_pairs.join(", ")));
702 }
703
704 if !ctx.cookies.is_empty() {
705 let cookie_str = ctx
706 .cookies
707 .iter()
708 .map(|(k, v)| format!("{}={}", k, v))
709 .collect::<Vec<_>>()
710 .join("; ");
711 opts.push(format!("'headers' => ['Cookie' => \"{}\"]", escape_php(&cookie_str)));
712 }
713
714 if !ctx.query_params.is_empty() {
715 let pairs: Vec<String> = ctx
716 .query_params
717 .iter()
718 .map(|(k, v)| {
719 let val_str = match v {
720 serde_json::Value::String(s) => s.clone(),
721 other => other.to_string(),
722 };
723 format!("\"{}\" => \"{}\"", escape_php(k), escape_php(&val_str))
724 })
725 .collect();
726 opts.push(format!("'query' => [{}]", pairs.join(", ")));
727 }
728
729 let path_lit = format!("\"{}\"", escape_php(ctx.path));
730
731 let rendered = crate::template_env::render(
732 "php/http_request.jinja",
733 minijinja::context! {
734 method => method,
735 path => path_lit,
736 opts => opts,
737 response_var => ctx.response_var,
738 },
739 );
740 out.push_str(&rendered);
741 }
742
743 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
745 let rendered = crate::template_env::render(
746 "php/http_assertions.jinja",
747 minijinja::context! {
748 response_var => "",
749 status_code => status,
750 headers => Vec::<std::collections::HashMap<&str, String>>::new(),
751 body_assertion => String::new(),
752 partial_body => Vec::<std::collections::HashMap<&str, String>>::new(),
753 validation_errors => Vec::<std::collections::HashMap<&str, String>>::new(),
754 },
755 );
756 out.push_str(&rendered);
757 }
758
759 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
764 let header_key = name.to_lowercase();
765 let header_key_lit = format!("\"{}\"", escape_php(&header_key));
766 let assertion_code = match expected {
767 "<<present>>" => {
768 format!("$this->assertTrue($response->hasHeader({header_key_lit}));")
769 }
770 "<<absent>>" => {
771 format!("$this->assertFalse($response->hasHeader({header_key_lit}));")
772 }
773 "<<uuid>>" => {
774 format!(
775 "$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}));"
776 )
777 }
778 literal => {
779 let val_lit = format!("\"{}\"", escape_php(literal));
780 format!("$this->assertEquals({val_lit}, $response->getHeaderLine({header_key_lit}));")
781 }
782 };
783
784 let mut headers = vec![std::collections::HashMap::new()];
785 headers[0].insert("assertion_code", assertion_code);
786
787 let rendered = crate::template_env::render(
788 "php/http_assertions.jinja",
789 minijinja::context! {
790 response_var => "",
791 status_code => 0u16,
792 headers => headers,
793 body_assertion => String::new(),
794 partial_body => Vec::<std::collections::HashMap<&str, String>>::new(),
795 validation_errors => Vec::<std::collections::HashMap<&str, String>>::new(),
796 },
797 );
798 out.push_str(&rendered);
799 }
800
801 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
807 let body_assertion = match expected {
808 serde_json::Value::String(s) if !s.is_empty() => {
809 let php_val = format!("\"{}\"", escape_php(s));
810 format!("$this->assertEquals({php_val}, (string) $response->getBody());")
811 }
812 _ => {
813 let php_val = json_to_php(expected);
814 format!(
815 "$body = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);\n $this->assertEquals({php_val}, $body);"
816 )
817 }
818 };
819
820 let rendered = crate::template_env::render(
821 "php/http_assertions.jinja",
822 minijinja::context! {
823 response_var => "",
824 status_code => 0u16,
825 headers => Vec::<std::collections::HashMap<&str, String>>::new(),
826 body_assertion => body_assertion,
827 partial_body => Vec::<std::collections::HashMap<&str, String>>::new(),
828 validation_errors => Vec::<std::collections::HashMap<&str, String>>::new(),
829 },
830 );
831 out.push_str(&rendered);
832 }
833
834 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
836 if let Some(obj) = expected.as_object() {
837 let mut partial_body: Vec<std::collections::HashMap<&str, String>> = Vec::new();
838 for (key, val) in obj {
839 let php_key = format!("\"{}\"", escape_php(key));
840 let php_val = json_to_php(val);
841 let assertion_code = format!("$this->assertEquals({php_val}, $body[{php_key}]);");
842 let mut entry = std::collections::HashMap::new();
843 entry.insert("assertion_code", assertion_code);
844 partial_body.push(entry);
845 }
846
847 let rendered = crate::template_env::render(
848 "php/http_assertions.jinja",
849 minijinja::context! {
850 response_var => "",
851 status_code => 0u16,
852 headers => Vec::<std::collections::HashMap<&str, String>>::new(),
853 body_assertion => String::new(),
854 partial_body => partial_body,
855 validation_errors => Vec::<std::collections::HashMap<&str, String>>::new(),
856 },
857 );
858 out.push_str(&rendered);
859 }
860 }
861
862 fn render_assert_validation_errors(
865 &self,
866 out: &mut String,
867 _response_var: &str,
868 errors: &[ValidationErrorExpectation],
869 ) {
870 let mut validation_errors: Vec<std::collections::HashMap<&str, String>> = Vec::new();
871 for err in errors {
872 let msg_lit = format!("\"{}\"", escape_php(&err.msg));
873 let assertion_code =
874 format!("$this->assertStringContainsString({msg_lit}, json_encode($body, JSON_UNESCAPED_SLASHES));");
875 let mut entry = std::collections::HashMap::new();
876 entry.insert("assertion_code", assertion_code);
877 validation_errors.push(entry);
878 }
879
880 let rendered = crate::template_env::render(
881 "php/http_assertions.jinja",
882 minijinja::context! {
883 response_var => "",
884 status_code => 0u16,
885 headers => Vec::<std::collections::HashMap<&str, String>>::new(),
886 body_assertion => String::new(),
887 partial_body => Vec::<std::collections::HashMap<&str, String>>::new(),
888 validation_errors => validation_errors,
889 },
890 );
891 out.push_str(&rendered);
892 }
893}
894
895fn render_http_test_method(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
900 if http.expected_response.status_code == 101 {
904 let method_name = sanitize_filename(&fixture.id);
905 let description = &fixture.description;
906 out.push_str(&crate::template_env::render(
907 "php/http_test_skip_101.jinja",
908 minijinja::context! {
909 method_name => method_name,
910 description => description,
911 },
912 ));
913 return;
914 }
915
916 client::http_call::render_http_test(out, &PhpTestClientRenderer, fixture);
917}
918
919#[allow(clippy::too_many_arguments)]
924fn render_test_method(
925 out: &mut String,
926 fixture: &Fixture,
927 e2e_config: &E2eConfig,
928 lang: &str,
929 namespace: &str,
930 class_name: &str,
931 type_defs: &[alef_core::ir::TypeDef],
932 php_enum_names: &HashSet<String>,
933 enum_fields: &HashMap<String, String>,
934 result_is_simple: bool,
935 php_client_factory: Option<&str>,
936 options_via: &str,
937 adapters: &[alef_core::config::extras::AdapterConfig],
938) {
939 let mut call_config = e2e_config.resolve_call_for_fixture(
941 fixture.call.as_deref(),
942 &fixture.id,
943 &fixture.resolved_category(),
944 &fixture.tags,
945 &fixture.input,
946 );
947 call_config = super::select_best_matching_call(call_config, e2e_config, fixture);
950 let per_call_getter_map = build_php_getter_map(
952 type_defs,
953 php_enum_names,
954 call_config,
955 e2e_config.effective_result_fields(call_config),
956 );
957 let call_field_resolver = FieldResolver::new_with_php_getters(
958 e2e_config.effective_fields(call_config),
959 e2e_config.effective_fields_optional(call_config),
960 e2e_config.effective_result_fields(call_config),
961 e2e_config.effective_fields_array(call_config),
962 &HashSet::new(),
963 &HashMap::new(),
964 per_call_getter_map,
965 );
966 let field_resolver = &call_field_resolver;
967 let call_overrides = call_config.overrides.get(lang);
968 let has_override = call_overrides.is_some_and(|o| o.function.is_some());
969 let result_is_simple = call_overrides.is_some_and(|o| o.result_is_simple) || result_is_simple;
973 let mut function_name = call_overrides
974 .and_then(|o| o.function.as_ref())
975 .cloned()
976 .unwrap_or_else(|| call_config.function.clone());
977 if !has_override {
982 function_name = function_name.to_lower_camel_case();
983 }
984 let result_var = &call_config.result_var;
985 let args = &call_config.args;
986
987 let method_name = sanitize_filename(&fixture.id);
988 let description = &fixture.description;
989 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
990
991 let call_options_type = call_overrides
995 .and_then(|o| o.options_type.as_deref())
996 .or_else(|| {
997 e2e_config
998 .call
999 .overrides
1000 .get(lang)
1001 .and_then(|o| o.options_type.as_deref())
1002 })
1003 .or(call_config.options_type.as_deref());
1004
1005 let adapter_request_type: Option<String> = adapters
1006 .iter()
1007 .find(|a| a.name == call_config.function.as_str())
1008 .and_then(|a| a.request_type.as_deref())
1009 .map(|rt| rt.rsplit("::").next().unwrap_or(rt).to_string());
1010 let (mut setup_lines, args_str) = build_args_and_setup(
1011 &fixture.input,
1012 args,
1013 class_name,
1014 enum_fields,
1015 fixture,
1016 options_via,
1017 call_options_type,
1018 adapter_request_type.as_deref(),
1019 );
1020
1021 let skip_test = call_config.skip_languages.iter().any(|l| l == "php");
1023 if skip_test {
1024 let rendered = crate::template_env::render(
1025 "php/test_method.jinja",
1026 minijinja::context! {
1027 method_name => method_name,
1028 description => description,
1029 client_factory => String::new(),
1030 setup_lines => Vec::<String>::new(),
1031 expects_error => false,
1032 skip_test => true,
1033 has_usable_assertions => false,
1034 call_expr => String::new(),
1035 result_var => result_var,
1036 assertions_body => String::new(),
1037 },
1038 );
1039 out.push_str(&rendered);
1040 return;
1041 }
1042
1043 let mut options_already_created = !args_str.is_empty() && args_str == "$options";
1045 if let Some(visitor_spec) = &fixture.visitor {
1046 build_php_visitor(&mut setup_lines, visitor_spec);
1047 if !options_already_created {
1048 let options_type = call_options_type.unwrap_or("ConversionOptions");
1049 if options_via == "from_json" {
1050 setup_lines.push(format!("$options = \\{namespace}\\{options_type}::from_json('{{}}');"));
1053 setup_lines.push(format!(
1054 "$visitorHandle = \\{namespace}\\VisitorHandle::from_php_object($visitor);"
1055 ));
1056 setup_lines.push("$options = $options->withVisitor($visitorHandle);".to_string());
1059 } else {
1060 setup_lines.push(format!("$builder = \\{namespace}\\{options_type}::builder();"));
1062 setup_lines.push("$options = $builder->visitor($visitor)->build();".to_string());
1063 }
1064 options_already_created = true;
1065 }
1066 }
1067
1068 let final_args = if options_already_created {
1069 if args_str.is_empty() || args_str == "$options" {
1070 "$options".to_string()
1071 } else {
1072 format!("{args_str}, $options")
1073 }
1074 } else {
1075 args_str
1076 };
1077
1078 let call_expr = if php_client_factory.is_some() {
1079 format!("$client->{function_name}({final_args})")
1080 } else {
1081 format!("{class_name}::{function_name}({final_args})")
1082 };
1083
1084 let has_mock = fixture.mock_response.is_some() || fixture.http.is_some();
1085 let api_key_var = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref());
1086 let client_factory = if let Some(factory) = php_client_factory {
1087 let fixture_id = &fixture.id;
1088 if let Some(var) = api_key_var.filter(|_| has_mock) {
1089 format!(
1090 "$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);"
1091 )
1092 } else if has_mock {
1093 let base_url_expr = if fixture.has_host_root_route() {
1094 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1095 format!("(getenv('{env_key}') ?: getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}')")
1096 } else {
1097 format!("getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}'")
1098 };
1099 format!("$client = \\{namespace}\\{class_name}::{factory}('test-key', {base_url_expr});")
1100 } else if let Some(var) = api_key_var {
1101 format!(
1102 "$apiKey = getenv('{var}');\n if (!$apiKey) {{ $this->markTestSkipped('{var} not set'); return; }}\n $client = \\{namespace}\\{class_name}::{factory}($apiKey);"
1103 )
1104 } else {
1105 format!("$client = \\{namespace}\\{class_name}::{factory}('test-key');")
1106 }
1107 } else {
1108 String::new()
1109 };
1110
1111 let is_streaming = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
1113
1114 let has_usable_assertions = fixture.assertions.iter().any(|a| {
1117 if a.assertion_type == "error" || a.assertion_type == "not_error" {
1118 return false;
1119 }
1120 match &a.field {
1121 Some(f) if !f.is_empty() => {
1122 if is_streaming && crate::codegen::streaming_assertions::is_streaming_virtual_field(f) {
1123 return true;
1124 }
1125 field_resolver.is_valid_for_result(f)
1126 }
1127 _ => true,
1128 }
1129 });
1130
1131 let collect_snippet = if is_streaming {
1133 crate::codegen::streaming_assertions::StreamingFieldResolver::collect_snippet("php", result_var, "chunks")
1134 .unwrap_or_default()
1135 } else {
1136 String::new()
1137 };
1138
1139 let mut fields_array_bindings: std::collections::BTreeMap<String, (String, String)> =
1149 std::collections::BTreeMap::new();
1150 for assertion in &fixture.assertions {
1151 if let Some(f) = &assertion.field {
1152 if !f.is_empty() && field_resolver.is_array(f) {
1153 if !fields_array_bindings.contains_key(f.as_str()) {
1155 let accessor = field_resolver.accessor(f, "php", &format!("${result_var}"));
1156 let var_name = f.to_lower_camel_case();
1157 fields_array_bindings.insert(f.clone(), (var_name, accessor));
1158 }
1159 }
1160 }
1161 }
1162
1163 let mut field_bindings = String::new();
1170 for (var_name, accessor) in fields_array_bindings.values() {
1171 field_bindings.push_str(&format!(" ${} = {};\n", var_name, accessor));
1172 }
1173
1174 let mut assertions_body = String::new();
1176 for assertion in &fixture.assertions {
1177 render_assertion(
1178 &mut assertions_body,
1179 assertion,
1180 result_var,
1181 field_resolver,
1182 result_is_simple,
1183 call_config.result_is_array,
1184 &fields_array_bindings,
1185 );
1186 }
1187
1188 if is_streaming && !expects_error && assertions_body.trim().is_empty() {
1194 assertions_body.push_str(" $this->assertTrue(is_array($chunks), 'expected drained chunks list');\n");
1195 }
1196
1197 let rendered = crate::template_env::render(
1198 "php/test_method.jinja",
1199 minijinja::context! {
1200 method_name => method_name,
1201 description => description,
1202 client_factory => client_factory,
1203 setup_lines => setup_lines,
1204 expects_error => expects_error,
1205 skip_test => fixture.assertions.is_empty(),
1206 has_usable_assertions => has_usable_assertions || is_streaming,
1207 call_expr => call_expr,
1208 result_var => result_var,
1209 collect_snippet => collect_snippet,
1210 field_bindings => field_bindings,
1211 assertions_body => assertions_body,
1212 },
1213 );
1214 out.push_str(&rendered);
1215}
1216
1217fn emit_php_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
1230 if let Some(items) = arr.as_array() {
1231 let item_strs: Vec<String> = items
1232 .iter()
1233 .filter_map(|item| {
1234 if let Some(obj) = item.as_object() {
1235 match elem_type {
1236 "BatchBytesItem" => {
1237 let content = obj.get("content").and_then(|v| v.as_array());
1238 let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1239 let content_code = if let Some(arr) = content {
1240 let bytes: Vec<String> = arr
1241 .iter()
1242 .filter_map(|v| v.as_u64())
1243 .map(|n| format!("\\x{:02x}", n))
1244 .collect();
1245 format!("\"{}\"", bytes.join(""))
1246 } else {
1247 "\"\"".to_string()
1248 };
1249 Some(format!(
1250 "new {}(content: {}, mimeType: \"{}\")",
1251 elem_type, content_code, mime_type
1252 ))
1253 }
1254 "BatchFileItem" => {
1255 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1256 Some(format!("new {}(path: \"{}\")", elem_type, path))
1257 }
1258 _ => {
1259 Some(json_to_php(&serde_json::Value::Object(obj.clone())))
1262 }
1263 }
1264 } else {
1265 None
1266 }
1267 })
1268 .collect();
1269 format!("[{}]", item_strs.join(", "))
1270 } else {
1271 "[]".to_string()
1272 }
1273}
1274
1275#[allow(clippy::too_many_arguments)]
1276fn build_args_and_setup(
1277 input: &serde_json::Value,
1278 args: &[crate::config::ArgMapping],
1279 class_name: &str,
1280 _enum_fields: &HashMap<String, String>,
1281 fixture: &crate::fixture::Fixture,
1282 options_via: &str,
1283 options_type: Option<&str>,
1284 adapter_request_type: Option<&str>,
1285) -> (Vec<String>, String) {
1286 let fixture_id = &fixture.id;
1287 if args.is_empty() {
1288 let is_empty_input = match input {
1291 serde_json::Value::Null => true,
1292 serde_json::Value::Object(m) => m.is_empty(),
1293 _ => false,
1294 };
1295 if is_empty_input {
1296 return (Vec::new(), String::new());
1297 }
1298 return (Vec::new(), json_to_php(input));
1299 }
1300
1301 let mut setup_lines: Vec<String> = Vec::new();
1302 let mut parts: Vec<String> = Vec::new();
1303
1304 let arg_has_emission = |arg: &crate::config::ArgMapping| -> bool {
1309 let val = if arg.field == "input" {
1310 Some(input)
1311 } else {
1312 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1313 input.get(field)
1314 };
1315 match val {
1316 None | Some(serde_json::Value::Null) => {
1317 if arg.arg_type == "json_object" && arg.name == "config" {
1323 return true;
1324 }
1325 !arg.optional
1326 }
1327 Some(_) => true,
1328 }
1329 };
1330 let any_later_has_emission = |from_idx: usize| -> bool { args[from_idx..].iter().any(arg_has_emission) };
1331
1332 for (idx, arg) in args.iter().enumerate() {
1333 if arg.arg_type == "mock_url" {
1334 if fixture.has_host_root_route() {
1335 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1336 setup_lines.push(format!(
1337 "${} = getenv('{env_key}') ?: getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}';",
1338 arg.name,
1339 ));
1340 } else {
1341 setup_lines.push(format!(
1342 "${} = getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}';",
1343 arg.name,
1344 ));
1345 }
1346 if let Some(req_type) = adapter_request_type {
1347 let req_var = format!("${}_req", arg.name);
1348 setup_lines.push(format!("{req_var} = new {req_type}(${});", arg.name));
1349 parts.push(req_var);
1350 } else {
1351 parts.push(format!("${}", arg.name));
1352 }
1353 continue;
1354 }
1355
1356 if arg.arg_type == "mock_url_list" {
1357 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1362 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1363 let val = if let Some(v) = input.get(field).filter(|v| !v.is_null()) {
1365 v.clone()
1366 } else {
1367 super::resolve_urls_field(input, &arg.field).clone()
1368 };
1369 let paths: Vec<String> = if let Some(arr) = val.as_array() {
1370 arr.iter()
1371 .filter_map(|v| v.as_str().map(|s| format!("\"{}\"", escape_php(s))))
1372 .collect()
1373 } else {
1374 Vec::new()
1375 };
1376 let paths_literal = paths.join(", ");
1377 let name = &arg.name;
1378 setup_lines.push(format!(
1379 "${name}_base = getenv('{env_key}') ?: getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}';"
1380 ));
1381 setup_lines.push(format!(
1382 "${name} = array_map(fn($p) => str_starts_with($p, 'http') ? $p : ${name}_base . $p, [{paths_literal}]);"
1383 ));
1384 parts.push(format!("${name}"));
1385 continue;
1386 }
1387
1388 if arg.arg_type == "handle" {
1389 let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
1391 let config_value = if arg.field == "input" {
1392 input
1393 } else {
1394 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1395 input.get(field).unwrap_or(&serde_json::Value::Null)
1396 };
1397 if config_value.is_null()
1398 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1399 {
1400 setup_lines.push(format!("${} = {class_name}::{constructor_name}(null);", arg.name,));
1401 } else {
1402 let name = &arg.name;
1403 let filtered_config = filter_empty_enum_strings(config_value);
1408 setup_lines.push(format!(
1409 "${name}_config = CrawlConfig::from_json(json_encode({}));",
1410 json_to_php_camel_keys(&filtered_config)
1411 ));
1412 setup_lines.push(format!(
1413 "${} = {class_name}::{constructor_name}(${name}_config);",
1414 arg.name,
1415 ));
1416 }
1417 parts.push(format!("${}", arg.name));
1418 continue;
1419 }
1420
1421 let val = if arg.field == "input" {
1422 Some(input)
1423 } else {
1424 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1425 input.get(field)
1426 };
1427
1428 if arg.arg_type == "bytes" {
1432 match val {
1433 None | Some(serde_json::Value::Null) => {
1434 if arg.optional {
1435 parts.push("null".to_string());
1436 } else {
1437 parts.push("\"\"".to_string());
1438 }
1439 }
1440 Some(serde_json::Value::String(s)) => {
1441 let var_name = format!("{}Bytes", arg.name);
1442 setup_lines.push(format!(
1443 "${var_name} = file_get_contents(\"{path}\");\n if (${var_name} === false) {{ $this->fail(\"failed to read fixture: {path}\"); }}",
1444 path = s.replace('"', "\\\"")
1445 ));
1446 parts.push(format!("${var_name}"));
1447 }
1448 Some(serde_json::Value::Array(arr)) => {
1449 let bytes: String = arr
1450 .iter()
1451 .filter_map(|v| v.as_u64())
1452 .map(|n| format!("\\x{:02x}", n))
1453 .collect();
1454 parts.push(format!("\"{bytes}\""));
1455 }
1456 Some(other) => {
1457 parts.push(json_to_php(other));
1458 }
1459 }
1460 continue;
1461 }
1462
1463 match val {
1464 None | Some(serde_json::Value::Null) if arg.arg_type == "json_object" && arg.name == "config" => {
1465 let type_name = if let Some(opt_type) = options_type {
1472 opt_type.to_string()
1473 } else if arg.name == "config" {
1474 "ExtractionConfig".to_string()
1475 } else {
1476 format!("{}Config", arg.name.to_upper_camel_case())
1477 };
1478 parts.push(format!("{type_name}::from_json('{{}}')"));
1479 continue;
1480 }
1481 None | Some(serde_json::Value::Null) if arg.optional => {
1482 if any_later_has_emission(idx + 1) {
1487 parts.push("null".to_string());
1488 }
1489 continue;
1490 }
1491 None | Some(serde_json::Value::Null) => {
1492 let default_val = match arg.arg_type.as_str() {
1494 "string" => "\"\"".to_string(),
1495 "int" | "integer" => "0".to_string(),
1496 "float" | "number" => "0.0".to_string(),
1497 "bool" | "boolean" => "false".to_string(),
1498 "json_object" if options_via == "json" => "null".to_string(),
1499 _ => "null".to_string(),
1500 };
1501 parts.push(default_val);
1502 }
1503 Some(v) => {
1504 if arg.arg_type == "json_object" && !v.is_null() {
1505 if let Some(elem_type) = &arg.element_type {
1507 if v.is_array() {
1508 if elem_type == "BatchBytesItem" || elem_type == "BatchFileItem" {
1509 parts.push(emit_php_batch_item_array(v, elem_type));
1510 continue;
1511 }
1512 if is_php_reserved_type(elem_type) {
1516 parts.push(json_to_php(v));
1517 continue;
1518 }
1519 if let Some(arr) = v.as_array() {
1521 let items: Vec<String> = arr
1522 .iter()
1523 .filter_map(|item| {
1524 item.as_object()
1525 .map(|obj| json_to_php(&serde_json::Value::Object(obj.clone())))
1526 })
1527 .collect();
1528 parts.push(format!("[{}]", items.join(", ")));
1529 continue;
1530 }
1531 }
1532 }
1533 match options_via {
1534 "json" => {
1535 let filtered_v = filter_empty_enum_strings(v);
1538
1539 if let serde_json::Value::Object(obj) = &filtered_v {
1541 if obj.is_empty() {
1542 parts.push("null".to_string());
1543 continue;
1544 }
1545 }
1546
1547 parts.push(format!("json_encode({})", json_to_php_camel_keys(&filtered_v)));
1548 continue;
1549 }
1550 _ => {
1551 if let Some(type_name) = options_type {
1552 let filtered_v = filter_empty_enum_strings(v);
1557
1558 if let serde_json::Value::Object(obj) = &filtered_v {
1561 if obj.is_empty() {
1562 let arg_var = format!("${}", arg.name);
1563 setup_lines.push(format!("{arg_var} = {type_name}::from_json('{{}}');"));
1564 parts.push(arg_var);
1565 continue;
1566 }
1567 }
1568
1569 let arg_var = format!("${}", arg.name);
1570 setup_lines.push(format!(
1574 "{arg_var} = {type_name}::from_json(json_encode({}));",
1575 json_to_php_camel_keys(&filtered_v)
1576 ));
1577 parts.push(arg_var);
1578 continue;
1579 }
1580 if let Some(obj) = v.as_object() {
1584 setup_lines.push("$builder = $this->createDefaultOptionsBuilder();".to_string());
1585 for (k, vv) in obj {
1586 let snake_key = k.to_snake_case();
1587 if snake_key == "preprocessing" {
1588 if let Some(prep_obj) = vv.as_object() {
1589 let enabled =
1590 prep_obj.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
1591 let preset =
1592 prep_obj.get("preset").and_then(|v| v.as_str()).unwrap_or("Minimal");
1593 let remove_navigation = prep_obj
1594 .get("remove_navigation")
1595 .and_then(|v| v.as_bool())
1596 .unwrap_or(true);
1597 let remove_forms =
1598 prep_obj.get("remove_forms").and_then(|v| v.as_bool()).unwrap_or(true);
1599 setup_lines.push(format!(
1600 "$preprocessing = $this->createPreprocessingOptions({}, {}, {}, {});",
1601 if enabled { "true" } else { "false" },
1602 json_to_php(&serde_json::Value::String(preset.to_string())),
1603 if remove_navigation { "true" } else { "false" },
1604 if remove_forms { "true" } else { "false" }
1605 ));
1606 setup_lines.push(
1607 "$builder = $builder->preprocessing($preprocessing);".to_string(),
1608 );
1609 }
1610 }
1611 }
1612 setup_lines.push("$options = $builder->build();".to_string());
1613 parts.push("$options".to_string());
1614 continue;
1615 }
1616 }
1617 }
1618 }
1619 parts.push(json_to_php(v));
1620 }
1621 }
1622 }
1623
1624 (setup_lines, parts.join(", "))
1625}
1626
1627fn render_assertion(
1628 out: &mut String,
1629 assertion: &Assertion,
1630 result_var: &str,
1631 field_resolver: &FieldResolver,
1632 result_is_simple: bool,
1633 result_is_array: bool,
1634 fields_array_bindings: &std::collections::BTreeMap<String, (String, String)>,
1635) {
1636 if let Some(f) = &assertion.field {
1639 match f.as_str() {
1640 "chunks_have_content" => {
1641 let pred = format!(
1642 "array_reduce(${result_var}->chunks ?? [], fn($carry, $c) => $carry && !empty($c->content), true)"
1643 );
1644 out.push_str(&crate::template_env::render(
1645 "php/synthetic_assertion.jinja",
1646 minijinja::context! {
1647 assertion_kind => "chunks_content",
1648 assertion_type => assertion.assertion_type.as_str(),
1649 pred => pred,
1650 field_name => f,
1651 },
1652 ));
1653 return;
1654 }
1655 "chunks_have_embeddings" => {
1656 let pred = format!(
1657 "array_reduce(${result_var}->chunks ?? [], fn($carry, $c) => $carry && !empty($c->embedding), true)"
1658 );
1659 out.push_str(&crate::template_env::render(
1660 "php/synthetic_assertion.jinja",
1661 minijinja::context! {
1662 assertion_kind => "chunks_embeddings",
1663 assertion_type => assertion.assertion_type.as_str(),
1664 pred => pred,
1665 field_name => f,
1666 },
1667 ));
1668 return;
1669 }
1670 "embeddings" => {
1674 let php_val = assertion.value.as_ref().map(json_to_php).unwrap_or_default();
1675 out.push_str(&crate::template_env::render(
1676 "php/synthetic_assertion.jinja",
1677 minijinja::context! {
1678 assertion_kind => "embeddings",
1679 assertion_type => assertion.assertion_type.as_str(),
1680 php_val => php_val,
1681 result_var => result_var,
1682 },
1683 ));
1684 return;
1685 }
1686 "embedding_dimensions" => {
1687 let expr = format!("(empty(${result_var}) ? 0 : count(${result_var}[0]))");
1688 let php_val = assertion.value.as_ref().map(json_to_php).unwrap_or_default();
1689 out.push_str(&crate::template_env::render(
1690 "php/synthetic_assertion.jinja",
1691 minijinja::context! {
1692 assertion_kind => "embedding_dimensions",
1693 assertion_type => assertion.assertion_type.as_str(),
1694 expr => expr,
1695 php_val => php_val,
1696 },
1697 ));
1698 return;
1699 }
1700 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1701 let pred = match f.as_str() {
1702 "embeddings_valid" => {
1703 format!("array_reduce(${result_var}, fn($carry, $e) => $carry && count($e) > 0, true)")
1704 }
1705 "embeddings_finite" => {
1706 format!(
1707 "array_reduce(${result_var}, fn($carry, $e) => $carry && array_reduce($e, fn($c, $v) => $c && is_finite($v), true), true)"
1708 )
1709 }
1710 "embeddings_non_zero" => {
1711 format!(
1712 "array_reduce(${result_var}, fn($carry, $e) => $carry && count(array_filter($e, fn($v) => $v !== 0.0)) > 0, true)"
1713 )
1714 }
1715 "embeddings_normalized" => {
1716 format!(
1717 "array_reduce(${result_var}, fn($carry, $e) => $carry && abs(array_sum(array_map(fn($v) => $v * $v, $e)) - 1.0) < 1e-3, true)"
1718 )
1719 }
1720 _ => unreachable!(),
1721 };
1722 let assertion_kind = format!("embeddings_{}", f.strip_prefix("embeddings_").unwrap_or(f));
1723 out.push_str(&crate::template_env::render(
1724 "php/synthetic_assertion.jinja",
1725 minijinja::context! {
1726 assertion_kind => assertion_kind,
1727 assertion_type => assertion.assertion_type.as_str(),
1728 pred => pred,
1729 field_name => f,
1730 },
1731 ));
1732 return;
1733 }
1734 "keywords" | "keywords_count" => {
1737 out.push_str(&crate::template_env::render(
1738 "php/synthetic_assertion.jinja",
1739 minijinja::context! {
1740 assertion_kind => "keywords",
1741 field_name => f,
1742 },
1743 ));
1744 return;
1745 }
1746 _ => {}
1747 }
1748 }
1749
1750 if let Some(f) = &assertion.field {
1753 if !f.is_empty() && crate::codegen::streaming_assertions::is_streaming_virtual_field(f) {
1754 if let Some(expr) =
1755 crate::codegen::streaming_assertions::StreamingFieldResolver::accessor(f, "php", "chunks")
1756 {
1757 let line = match assertion.assertion_type.as_str() {
1758 "count_min" => {
1759 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1760 format!(
1761 " $this->assertGreaterThanOrEqual({n}, count({expr}), 'expected >= {n} chunks');\n"
1762 )
1763 } else {
1764 String::new()
1765 }
1766 }
1767 "count_equals" => {
1768 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1769 format!(" $this->assertCount({n}, {expr});\n")
1770 } else {
1771 String::new()
1772 }
1773 }
1774 "equals" => {
1775 if let Some(serde_json::Value::String(s)) = &assertion.value {
1776 let escaped = s.replace('\\', "\\\\").replace('\'', "\\'");
1777 format!(" $this->assertEquals('{escaped}', {expr});\n")
1778 } else if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1779 format!(" $this->assertEquals({n}, {expr});\n")
1780 } else {
1781 String::new()
1782 }
1783 }
1784 "not_empty" => format!(" $this->assertNotEmpty({expr});\n"),
1785 "is_empty" => format!(" $this->assertEmpty({expr});\n"),
1786 "is_true" => format!(" $this->assertTrue({expr});\n"),
1787 "is_false" => format!(" $this->assertFalse({expr});\n"),
1788 "greater_than" => {
1789 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1790 format!(" $this->assertGreaterThan({n}, {expr});\n")
1791 } else {
1792 String::new()
1793 }
1794 }
1795 "greater_than_or_equal" => {
1796 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1797 format!(" $this->assertGreaterThanOrEqual({n}, {expr});\n")
1798 } else {
1799 String::new()
1800 }
1801 }
1802 "contains" => {
1803 if let Some(serde_json::Value::String(s)) = &assertion.value {
1804 let escaped = s.replace('\\', "\\\\").replace('\'', "\\'");
1805 format!(" $this->assertStringContainsString('{escaped}', {expr});\n")
1806 } else {
1807 String::new()
1808 }
1809 }
1810 _ => format!(
1811 " // streaming field '{f}': assertion type '{}' not rendered\n",
1812 assertion.assertion_type
1813 ),
1814 };
1815 if !line.is_empty() {
1816 out.push_str(&line);
1817 }
1818 }
1819 return;
1820 }
1821 }
1822
1823 if let Some(f) = &assertion.field {
1825 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1826 out.push_str(&crate::template_env::render(
1827 "php/synthetic_assertion.jinja",
1828 minijinja::context! {
1829 assertion_kind => "skipped",
1830 field_name => f,
1831 },
1832 ));
1833 return;
1834 }
1835 }
1836
1837 if result_is_simple {
1840 if let Some(f) = &assertion.field {
1841 let f_lower = f.to_lowercase();
1842 if !f.is_empty()
1843 && f_lower != "content"
1844 && (f_lower.starts_with("metadata")
1845 || f_lower.starts_with("document")
1846 || f_lower.starts_with("structure"))
1847 {
1848 out.push_str(&crate::template_env::render(
1849 "php/synthetic_assertion.jinja",
1850 minijinja::context! {
1851 assertion_kind => "result_is_simple",
1852 field_name => f,
1853 },
1854 ));
1855 return;
1856 }
1857 }
1858 }
1859
1860 let field_expr = match &assertion.field {
1861 _ if result_is_simple => format!("${result_var}"),
1865 Some(f) if !f.is_empty() => {
1866 if let Some((var_name, _)) = fields_array_bindings.get(f) {
1868 format!("${}", var_name)
1869 } else {
1870 field_resolver.accessor(f, "php", &format!("${result_var}"))
1871 }
1872 }
1873 _ => format!("${result_var}"),
1874 };
1875
1876 let field_is_array = assertion.field.as_ref().map_or(result_is_array, |f| {
1879 if f.is_empty() {
1880 result_is_array
1881 } else {
1882 field_resolver.is_array(f)
1883 }
1884 });
1885
1886 let trimmed_field_expr_for = |expected: &serde_json::Value| -> String {
1890 if expected.is_string() {
1891 format!("trim({})", field_expr)
1892 } else {
1893 field_expr.clone()
1894 }
1895 };
1896
1897 let assertion_type = assertion.assertion_type.as_str();
1899 let has_php_val = assertion.value.is_some();
1900 let php_val = match assertion.value.as_ref() {
1904 Some(v) => json_to_php(v),
1905 None if assertion_type == "equals" => "null".to_string(),
1906 None => String::new(),
1907 };
1908 let trimmed_field_expr = trimmed_field_expr_for(assertion.value.as_ref().unwrap_or(&serde_json::Value::Null));
1909 let is_string_val = assertion.value.as_ref().is_some_and(|v| v.is_string());
1910 let values_php: Vec<String> = assertion
1914 .values
1915 .as_ref()
1916 .map(|vals| vals.iter().map(json_to_php).collect::<Vec<_>>())
1917 .or_else(|| assertion.value.as_ref().map(|v| vec![json_to_php(v)]))
1918 .unwrap_or_default();
1919 let contains_any_checks: Vec<String> = assertion
1920 .values
1921 .as_ref()
1922 .map_or(Vec::new(), |vals| vals.iter().map(json_to_php).collect());
1923 let n = assertion.value.as_ref().and_then(|v| v.as_u64()).unwrap_or(0);
1924
1925 let call_expr = if let Some(method_name) = &assertion.method {
1927 build_php_method_call(result_var, method_name, assertion.args.as_ref())
1928 } else {
1929 String::new()
1930 };
1931 let check = assertion.check.as_deref().unwrap_or("is_true");
1932 let has_php_check_val = matches!(assertion.assertion_type.as_str(), "method_result") && assertion.value.is_some();
1933 let php_check_val = if matches!(assertion.assertion_type.as_str(), "method_result") {
1934 assertion.value.as_ref().map(json_to_php).unwrap_or_default()
1935 } else {
1936 String::new()
1937 };
1938 let check_n = assertion.value.as_ref().and_then(|v| v.as_u64()).unwrap_or(0);
1939 let is_bool_val = assertion.value.as_ref().is_some_and(|v| v.is_boolean());
1940 let bool_is_true = assertion.value.as_ref().and_then(|v| v.as_bool()).unwrap_or(false);
1941
1942 if matches!(assertion_type, "not_error" | "error") {
1944 if assertion_type == "not_error" {
1945 }
1947 return;
1949 }
1950
1951 let rendered = crate::template_env::render(
1952 "php/assertion.jinja",
1953 minijinja::context! {
1954 assertion_type => assertion_type,
1955 field_expr => field_expr,
1956 php_val => php_val,
1957 has_php_val => has_php_val,
1958 trimmed_field_expr => trimmed_field_expr,
1959 is_string_val => is_string_val,
1960 field_is_array => field_is_array,
1961 values_php => values_php,
1962 contains_any_checks => contains_any_checks,
1963 n => n,
1964 call_expr => call_expr,
1965 check => check,
1966 php_check_val => php_check_val,
1967 has_php_check_val => has_php_check_val,
1968 check_n => check_n,
1969 is_bool_val => is_bool_val,
1970 bool_is_true => bool_is_true,
1971 },
1972 );
1973 let _ = write!(out, " {}", rendered);
1974}
1975
1976fn build_php_method_call(result_var: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
1983 let extra_args = if let Some(args_val) = args {
1984 args_val
1985 .as_object()
1986 .map(|obj| {
1987 obj.values()
1988 .map(|v| match v {
1989 serde_json::Value::String(s) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")),
1990 serde_json::Value::Bool(true) => "true".to_string(),
1991 serde_json::Value::Bool(false) => "false".to_string(),
1992 serde_json::Value::Number(n) => n.to_string(),
1993 serde_json::Value::Null => "null".to_string(),
1994 other => format!("\"{}\"", other.to_string().replace('\\', "\\\\").replace('"', "\\\"")),
1995 })
1996 .collect::<Vec<_>>()
1997 .join(", ")
1998 })
1999 .unwrap_or_default()
2000 } else {
2001 String::new()
2002 };
2003
2004 if extra_args.is_empty() {
2005 format!("${result_var}->{method_name}()")
2006 } else {
2007 format!("${result_var}->{method_name}({extra_args})")
2008 }
2009}
2010
2011fn filter_empty_enum_strings(value: &serde_json::Value) -> serde_json::Value {
2015 match value {
2016 serde_json::Value::Object(map) => {
2017 let filtered: serde_json::Map<String, serde_json::Value> = map
2018 .iter()
2019 .filter_map(|(k, v)| {
2020 if let serde_json::Value::String(s) = v {
2022 if s.is_empty() {
2023 return None;
2024 }
2025 }
2026 Some((k.clone(), filter_empty_enum_strings(v)))
2028 })
2029 .collect();
2030 serde_json::Value::Object(filtered)
2031 }
2032 serde_json::Value::Array(arr) => {
2033 let filtered: Vec<serde_json::Value> = arr.iter().map(filter_empty_enum_strings).collect();
2034 serde_json::Value::Array(filtered)
2035 }
2036 other => other.clone(),
2037 }
2038}
2039
2040fn json_to_php(value: &serde_json::Value) -> String {
2042 match value {
2043 serde_json::Value::String(s) => format!("\"{}\"", escape_php(s)),
2044 serde_json::Value::Bool(true) => "true".to_string(),
2045 serde_json::Value::Bool(false) => "false".to_string(),
2046 serde_json::Value::Number(n) => n.to_string(),
2047 serde_json::Value::Null => "null".to_string(),
2048 serde_json::Value::Array(arr) => {
2049 let items: Vec<String> = arr.iter().map(json_to_php).collect();
2050 format!("[{}]", items.join(", "))
2051 }
2052 serde_json::Value::Object(map) => {
2053 let items: Vec<String> = map
2054 .iter()
2055 .map(|(k, v)| format!("\"{}\" => {}", escape_php(k), json_to_php(v)))
2056 .collect();
2057 format!("[{}]", items.join(", "))
2058 }
2059 }
2060}
2061
2062fn json_to_php_camel_keys(value: &serde_json::Value) -> String {
2067 match value {
2068 serde_json::Value::Object(map) => {
2069 let items: Vec<String> = map
2070 .iter()
2071 .map(|(k, v)| {
2072 let camel_key = k.to_lower_camel_case();
2073 format!("\"{}\" => {}", escape_php(&camel_key), json_to_php_camel_keys(v))
2074 })
2075 .collect();
2076 format!("[{}]", items.join(", "))
2077 }
2078 serde_json::Value::Array(arr) => {
2079 let items: Vec<String> = arr.iter().map(json_to_php_camel_keys).collect();
2080 format!("[{}]", items.join(", "))
2081 }
2082 _ => json_to_php(value),
2083 }
2084}
2085
2086fn build_php_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) {
2092 setup_lines.push("$visitor = new class {".to_string());
2093 for (method_name, action) in &visitor_spec.callbacks {
2094 emit_php_visitor_method(setup_lines, method_name, action);
2095 }
2096 setup_lines.push("};".to_string());
2097}
2098
2099fn emit_php_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
2101 let params = match method_name {
2102 "visit_link" => "$ctx, $href, $text, $title",
2103 "visit_image" => "$ctx, $src, $alt, $title",
2104 "visit_heading" => "$ctx, $level, $text, $id",
2105 "visit_code_block" => "$ctx, $lang, $code",
2106 "visit_code_inline"
2107 | "visit_strong"
2108 | "visit_emphasis"
2109 | "visit_strikethrough"
2110 | "visit_underline"
2111 | "visit_subscript"
2112 | "visit_superscript"
2113 | "visit_mark"
2114 | "visit_button"
2115 | "visit_summary"
2116 | "visit_figcaption"
2117 | "visit_definition_term"
2118 | "visit_definition_description" => "$ctx, $text",
2119 "visit_text" => "$ctx, $text",
2120 "visit_list_item" => "$ctx, $ordered, $marker, $text",
2121 "visit_blockquote" => "$ctx, $content, $depth",
2122 "visit_table_row" => "$ctx, $cells, $isHeader",
2123 "visit_custom_element" => "$ctx, $tagName, $html",
2124 "visit_form" => "$ctx, $actionUrl, $method",
2125 "visit_input" => "$ctx, $input_type, $name, $value",
2126 "visit_audio" | "visit_video" | "visit_iframe" => "$ctx, $src",
2127 "visit_details" => "$ctx, $isOpen",
2128 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "$ctx, $output",
2129 "visit_list_start" => "$ctx, $ordered",
2130 "visit_list_end" => "$ctx, $ordered, $output",
2131 _ => "$ctx",
2132 };
2133
2134 let (action_type, action_value, return_form) = match action {
2135 CallbackAction::Skip => ("skip", String::new(), "dict"),
2136 CallbackAction::Continue => ("continue", String::new(), "dict"),
2137 CallbackAction::PreserveHtml => ("preserve_html", String::new(), "dict"),
2138 CallbackAction::Custom { output } => ("custom", escape_php(output), "dict"),
2139 CallbackAction::CustomTemplate { template, return_form } => {
2140 let form = match return_form {
2141 TemplateReturnForm::Dict => "dict",
2142 TemplateReturnForm::BareString => "bare_string",
2143 };
2144 ("custom_template", escape_php(template), form)
2145 }
2146 };
2147
2148 let rendered = crate::template_env::render(
2149 "php/visitor_method.jinja",
2150 minijinja::context! {
2151 method_name => method_name,
2152 params => params,
2153 action_type => action_type,
2154 action_value => action_value,
2155 return_form => return_form,
2156 },
2157 );
2158 for line in rendered.lines() {
2159 setup_lines.push(line.to_string());
2160 }
2161}
2162
2163fn is_php_reserved_type(name: &str) -> bool {
2165 matches!(
2166 name.to_ascii_lowercase().as_str(),
2167 "string"
2168 | "int"
2169 | "integer"
2170 | "float"
2171 | "double"
2172 | "bool"
2173 | "boolean"
2174 | "array"
2175 | "object"
2176 | "null"
2177 | "void"
2178 | "callable"
2179 | "iterable"
2180 | "never"
2181 | "self"
2182 | "parent"
2183 | "static"
2184 | "true"
2185 | "false"
2186 | "mixed"
2187 )
2188}