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